* 람다 식과 멤버 참조
* 함수형 스타일로 컬렉션 다루기
* 시퀀스: 지연 컬렉션 연산
* 자바 함수형 인터페이스를 코틀린에서 사용
* 수신 객체 지정 람다 사용
앞 내용에서 map
이나 filter
같은 몇 가지 컬렉션 함수를 알아봤다. 해당 함수는 결과 컬렉션을 즉시(eager) 생성한다. 이는 컬렉션 함수를 연쇄하면 매 단계마다 계산 중간 결과를 새로운 컬렉션에 임시로 담는다는 뜻이다.
시퀀스를 사용하면 중간 임시 컬렉션을 사용하지 않고도 컬렉션 연산을 연쇄할 수 있다. 각 연산이 컬렉션을 직접 사용하는 대신에 시퀀스를 사용하게 만들어야 한다.
원소의 개수가 적다면 크게 문제가 되지 않지만 수백만 개가 되면 효율성에서 큰 차이가 발생한다.
people
.asSequence()
.filter { it.age % 2 == 1 }
.map { it.name }
.count()
people
의 원소 개수가 1000만개인 상황에서 테스트를 진행했을 때 asSequence
를 사용하지 않은 경우에는 평균적으로 125ms
가 소요됐다. asSequence
를 사용했을 때에는 12ms
가 소요된다.
asSequence
확장 함수르 호출하면 어떤 컬렉션이든 시퀀스로 바꿀 수 있다. 시퀀스를 리스트로 만들 때는 toList를 사용한다. 그런데 굳이 시퀀스를 리스트로 만들 필요가 있을까? 그리고 만들 필요가 없다면 어떠한 상황이던 시퀀스를 사용하면 되는 것 아닌가? 라고 물을 수 있다.
결론은 리스트로 만들어야할 경우가 있다. 그리고 만들어야하는 경우가 있기 때문에 항상 시퀀스 쓰는 것이 정답은 아니다. 그 이유는 원소를 차례로 이터레이션하는 경우라면 시퀀스를 사용해도 된다. 하지만 특정 인덱스를 사용하여 접근할 필요가 있는 경우에는 리스트로 변환해야한다.
큰 컬렉션에 대해서 연산을 연쇄시킬 때는 시퀀스를 사용하는 것을 규칙으로 삼는다.
시퀀스에 대한 연산은 중간연산과 최종연산으로 나뉜다. 중간연산은 다른 시퀀스를 반환한다. 최종연산은 결과를 반환한다.
listOf(1, 2, 3, 4)
.asSequence()
.map { println("map($it) "); it * it }
.filter { println("filter($it)"); it % 2 == 0 }
여기서 map
과 filter
는 중간연산에 속한다. 그리고 시퀀스에서 중간연산은 항상 지연 계산된다. 따라서 무언가 출력이 되길 기대했지만 아무것도 출력되지 않는다. 이는 중간연산인 map
과 filter
변환이 늦춰져서 결과를 얻을 필요가 있을 때(즉, 최종연산이 호출될 때) 적용된다는 뜻이다.
listOf(1, 2, 3, 4)
.asSequence()
.map { println("map($it) "); it * it }
.filter { println("filter($it)"); it % 2 == 0 }
.toList()
// result
map(1)
filter(1)
map(2)
filter(4)
map(3)
filter(9)
map(4)
filter(16)
toList
라는 최종연산을 통해서 결과를 얻을 수 있었다. 그런데 여기서 결과에 찍힌 순서를 보고 수행순서에 잘 알아둬야할 것이 있다. 시퀀스에 대한 map
과 filter
연산은 각 원소에 대해서 순차적으로 적용하기 때문에 위와 같은 출력결과를 얻게 된다.
하지만, 시퀀스 없이 직접연산을 구현한다면 map
함수를 각 원소에 대해 먼저 수행해서 새 시퀀스를 얻고, 그 시퀀스에 대해 다시 filter
를 수행할 것이다. 아마도 결과가 아래와 같이 나올 것이다.
map(1)
map(2)
map(3)
map(4)
filter(1)
filter(4)
filter(9)
filter(16)
그리고 또 한 가지 차이점은 결과값이 일찍 얻어지면 그 이후의 원소에 대해서는 변환이 이뤄지지 않을 수도 있다. 이해가 쉽지 않을 테니 아래의 예시를 보자
println(listOf(1, 2, 3, 4)
.map { println("map($it) "); it * it }
.filter { println("filter($it)"); it % 2 == 0 }
.find { it > 3 })
// result
map(1)
map(2)
map(3)
map(4)
filter(1)
filter(4)
filter(9)
filter(16)
4
예상하던 대로 결과가 출력됐다. 하지만 위 코드에서 asSequence
를 추가하게 되면 어떻게 출력이 될지 생각해보라.
// result
map(1)
filter(1)
map(2)
filter(4)
4
위와 같이 2번째 원소에서 결과를 찾았기 때문에 그 이후의 원소에 대해서는 map
과 filter
연산을 적용하지 않았다. 위와 같은 예시를 보며 컬렉션에 대해 수행하는 연산의 순서도 성능에 영향을 끼친다.
Q. User를 담고 있는 컬렉션(리스트)에서 이름의 길이가 4자리보다 작은 사람의 명단을 얻고 싶다고 하자. 어떤 순서로 구성하는 것이 좋을지 생각해보라.
정답은
filter
를 먼저하고map
을 하는 것이다. map을 먼저하고 filter를 해도 결과는 같을 수 있지만 모든 원소에 대해서map
연산을 해줘야한다. 하지만filter
를 먼저할 경우 조건에 만족하지 않는 원소를 제외시키기 때문에 제오된 원소들은map
연산을 수행하지 않는다.
코틀린 표준 라이브러리의 with
와 apply
를 보여준다. 자바의 람다에는 없는 코틀린 람다의 독특한 기능이다. 이 기능은 수신 객체를 명시하지 않고 람다의 본문 안에서 다른 객체의 메소드를 호출할 수 있게 하는 것이다. 이를 수신 객체 지정 람다라고 부른다.
어떤 객체의 이름을 반복하지 않고도 그 객체에 대해 다양한 연산을 수행할 수 있다면 좋을 것이다. 코틀린은 with
라는 라이브러리 함수를 통해 제공한다. 아래의 코드를 리팩토링해보자.
val result = StringBuilder()
for (letter in 'A' .. 'Z') {
result.append(letter)
}
result.append("\nNow I know the alphabet!")
return result.toString()
}
여기서 result
변수에 대해 여러 메소드를 호출하면서 반복 사용했다. 이 정도 반복은 크게 무리가 없지만 코드가 훨씬 길거나, result
변수를 더 자주 반복하는 경우 with
로 대체할 수 있다.
fun alphabet() : String {
val stringBuilder = StringBuilder()
return with(stringBuilder) {
for (letter in 'A' .. 'Z') {
append(letter)
}
append("\nNow I know the alphabet!")
toString()
}
}
위와 같은 문법은 with(StringBuilder, { ... })
라고 생각해도 무방하다. 그리고 this.를 통해서 접근할 수 있지만 생략 가능하므로 생략했다. 위의 코드를 다시 한 번 리팩토링하면 아래 코드와 같다
fun alphabet() = with(StringBuilder()) {
for (letter in 'A' .. 'Z') {
append(letter)
}
append("\nNow I know the alphabet!")
toString()
}
불필요한 stringBuilder
변수를 없애면 식을 본문으로하는 함수로 표현할 수 있다.
with
에게 인자로 넘긴 객체의 클래스와with
블록 내에서 사용하는 메소드명이 같은 상황에서 해당 메소드를 호출하면 무슨 일이 생길까?
그런 경우this
참조 앞에 레이블을 붙이면 호출하고 싶은 메소드를 명확하게 정할 수 있다.
alphabet
함수가OuterClass
의 메소드인 경우StringBuilder
가 아닌 바깥쪽 클래스에 정의된toString
을 호출하고 싶다면 다음과 같은 구문을 사용해야 한다.
this@OuterClass.toString()
/* 메소드 이름 충돌 예시 */
class Coffee {
override fun toString() = "americano"
fun alphabet(): String = with(StringBuilder()) {
for (letter in 'A' .. 'Z') {
append(letter)
}
append("\nNow I know the alphabet!")
this@Coffee.toString()
}
}
// result
americano
with
가 반환하는 값은 람다 코드를 실행한 결과며, 그 결고는 람다 식의 본문에 있는 마지막 식의 값이다. 하지만 때로는 람다의 결과 대신 수신 객체가 필요한 경우도 있다. 이 때 apply
함수를 사용한다.
apply
는 확장 함수로 정의돼 있다. apply
를 실행한 결과는 StringBuilder
객체다.
fun alphabet() = StringBuilder().apply {
for (letter in 'A' .. 'Z') {
append(letter)
}
append("\nNow I know the alphabet!")
}.toString()