Kotlin In Action을 공부하며 작성한 글입니다.
혼자 공부하고 정리한 내용이며, 틀린 부분은 지적해주시면 감사드리겠습니다 😀
1. 지연 계산(lazy) 컬렉션 연산
컬렉션의 함수들은 결과를 즉시(eagerly) 생성한다. 즉, 컬렉션 함수를 연쇄할 경우 매 단계마다 계산 중간 결과를 새로운 컬렉션에 임시로 담는다는 말이다.
people.map(Person::name).filter { it.startsWith("A") }
map과 filter의 반환 타입을 보면 알 수 있듯, 위 코드는 2개의 리스트를 생성하고 있다. 만약 원소가 수백만 개가 되면 효율성이 많이 떨어질 것이다. 하지만 시퀀스(sequence)를 사용하면 중간 임시 컬렉션을 사용하지 않고도 컬렉션 연산을 연쇄할 수 있다.
val list = people.asSequence()
.map(Person::name)
.filter { it.startsWith("A") }
.toList()
위 코드는 원본 컬렉션을 시퀀스로 변환해 해당 시퀀스를 계속해서 사용한다. 즉, 임시 리스트를 만드는 것이 아닌, 시퀀스로 변환한 원본 컬렉션을 계속해서 사용해 원소가 많은 경우 성능이 눈에 띄게 좋아진다.
시퀀스의 강점은 원소를 필요할 때 비로소 계산되어 중간 처리 결과를 저장하지 않고도 연산을 연쇄적으로 적용해서 효율적으로 계산을 수행할 수 있다. 즉, 위 코드는 연산 과정만 등록을 해놓은 상태이며, 실질적인 연산은 list를 사용하는 시점에 적용된다.
1-1. 시퀀스 연산 실행 : 중간 연산과 최종 연산
시퀀스에 대한 연산은 중간(intermediate) 연산과 최종(terminal) 연산으로 나뉜다. 중간 연산은 다른 시퀀스를 반환하며, 해당 시퀀스는 최초 시퀀스의 원소를 변환하는 방법을 안다. 최종 연산은 결과를 반환한다.
// 중간 연산
// 출력 X
listOf(1, 2, 3, 4).asSequence()
.map { print("i : map($it) "); it * it }
.filter { print("i : filter($it) "); it % 2 == 0 }
// 최종 연산
// 출력 : t : map(1) t : filter(1) .. map(4) t : filter(16)
listOf(1, 2, 3, 4).asSequence()
.map { print("t : map($it) "); it * it }
.filter { print("t : filter($it) "); it % 2 == 0 }
.toList()
중간 연산은 항상 지연 계산 되므로, map과 filter 변환이 늦쳐져서 결과를 얻을 필요가 있을 때(최종 연산이 호출될 때) 적용이 된다. 때문에 아무런 결과도 출력되지 않는다. 반면에 최종 연산(toList)을 호출할 경우 연기됐던 모든 계산이 수행된다.
최종 연산은 함수의 반환 타입이
Sequence
가 아닌 다른 타입으로 변환할 때를 의미한다.
시퀀스가 아닐 경우 연산 수행 순서는 컬렉션의 모든 원소에 대해 map 함수를 진행해 새로운 컬렉션을 얻고, 해당 컬렉션을 통해 filter 함수를 수행한다. 하지만, 시퀀스에 대한 연산 수행 순서는 시퀀스의 모든 연산은 각 원소에 대해 순차적으로 적용된다.
즉, 첫 번째 원소가 변환된 다음 걸러지면서 처리되고, 다시 두 번째 원소가 처리되는 형태이다. 따라서 원소에 연산을 차례대로 적용하다, 결과가 얻어지면 그 이후의 원소에 대해서는 변환이 이루어지지 않을수도 있다. 다음 코드를 통해 확인해보자.
// 출력 : 4
println(listOf(1, 2, 3, 4).asSequence()
.map { it * it }.find { it > 3 }
)
만약 위 코드가 시퀀스가 아니라 일반 컬렉션이라면, map의 결과가 먼저 평가돼 최초 컬렉션의 모든 원소가 변환된다. 그 다음 find의 조건을 통해 그에 적합한 원소를 반환한다. 하지만 시퀀스의 경우에는 앞서 말한 것과 같이 1에 대한 map을 먼저 적용하고, find에 대한 조건을 검사한다. 때문에 2에서 find에 대한 조건을 만족해 반환하므로, 3, 4에 대한 연산을 수행하지 않는다.
1-2. 시퀀스 만들기
시퀀스를 만드는 다른 방법으로 generateSequence
함수를 사용할 수 있다.
val naturalNumbers = generateSequence(0) { it + 1 }
val numbersTo100 = naturalNumbers.takeWhile { it <= 100 }
// 출력 : 5050
println(numbersTo100.sum())
위 코드에서 naturalNumbers
, numbersTo100
는 모두 시퀀스이므로, 연산을 지연 계산한다. 여기서는 sum
이 최종 연산을 나타내며, 최종 연산을 수행하기 전까지는 시퀀스의 각 숫자는 계산되지 않는다. 시퀀스를 사용하는 일반적인 용례 중 하나는 객체의 조상으로 이뤄진 시퀀스를 만들어내는 것이다.
fun File.isInsideHiddenDirectory() =
generateSequence(this) { it.parentFile }.any { it.isHidden }
fun main() {
val file = File("/.idea/.gitignore")
println(file.isInsideHiddenDirectory())
}
위 코드처럼 file의 조상인 it.parentFile
을 통해 모든 조상의 시퀀스에서 숨김 속성을 가진 디렉터리가 하나라도 있는지 확인할 수 있다.
2. 수신 객체 지정 람다 : with와 apply
이 기능들은 수신 객체를 명시하지 않고, 람다의 본문 안에서 다른 객체의 메소드를 호출하게 할 수 있다. 이런 람다를 수신 객체 지정 람다(lambda with receiver)라고 부른다.
2-1. with
with는 특정 객체의 이름을 반복하지 않고도, 그 객체에 대해 다양한 연산을 수행할 수 있게 해준다.
fun alphabet(): String {
val result = StringBuilder()
for (letter in 'A'..'Z') {
result.append(letter)
}
result.append("\nNow I know the alphabet!")
return result.toString()
}
위 코드를 보면 result를 계속해서 반복해 작성하고 있다. 코드가 더 길어질 경우 굉장히 귀찮아질 것이다. 이 예제에 with를 적용해보자.
// 방식1
fun alphabet(): String {
val sb = StringBuilder()
// 수신 객체 지정
return with(sb) {
for (letter in 'A'..'Z') {
// 수신 객체의 메소드 호출
this.append(letter)
}
// 수신 객체의 메소드 호출(this 생략)
append("\nNow I know the alphabet!")
// 람다에서 값 반환
toString()
}
}
// 방식2
fun alphabet() = with(StringBuilder()) {
for (letter in 'A'..'Z') {
append(letter)
}
append("\nNow I know the alphabet!")
toString()
}
위 코드처럼 with를 사용하는 방법은 두 가지가 있다. 만약 방식2와 같이 작성했을 때, 메소드 이름이 같아 충돌이 날 경우 this를 붙여 사용하면 된다. with가 반환하는 값은 람다 코드를 실행한 결과이며, 그 결과는 람다 식의 본문에 있는 마지막에 있는 값이다.
2-2. apply
때로는 람다의 결과 대신 수신 객체가 필요한 경우가 있다. with의 경우 수신 객체를 사용해 특정한 값을 반환하지만, apply의 경우 항상 자신에게 전달된 객체인 수신 객체를 반환한다.
fun alphabet() = StringBuilder().apply {
for (letter in 'A'..'Z') {
append(letter)
}
append("\nNow I know the alphabet!")
}.toString()
위 코드를 보면 apply 자체가 반환하는 값은 StringBuilder
에 대한 객체이다. 이후 toString()을 호출해 문자열로 반환한다. with는 일반 함수이지만, apply는 확장 함수로 정의되어 있고, 자신과 같은 타입을 반환하도록 구현되어 있다.
StringBuilder를 apply와 함께 사용할 경우 buildString
을 사용하는 것도 좋다. 이는 이미 StringBuilder를 반환하며, apply가 적용되어 있는 라이브러리이다.
fun alphabet() = buildString {
for (letter in 'A'..'Z') {
append(letter)
}
append("\nNow I know the alphabet!")
}
'Book Study > [Kotlin] 코틀린 인 액션' 카테고리의 다른 글
[Chapter06] 원시 타입, 컬렉션 및 배열 (0) | 2024.03.01 |
---|---|
[Chapter06] 널 가능성, 플랫폼 타입 (0) | 2024.03.01 |
[Chapter05] 람다로 프로그래밍 (0) | 2024.02.07 |