[Kotlin in action] 5장: 람다로 프로그래밍
🙂 이전부터 차근차근 읽고 있었던 ‘Kotlin in action’ 책을 본격적으로 정리하고자 한다.
단순히 읽는 것으로는 오래 기억되지 않을 것 같아 두고두고 볼 수 있도록 기록 해야겠다!!
👩 람다
람다? 다른 함수에 넘길 수 있는 작은 코드 조각
🙋♀️ 람다식
{x: Int, y: Int -> x+y}
- 항상 중괄호 사이에 식이 위치한다.
run{ println(42) }
- 람다를 직접 호출하는 것보다 run을 사용해 람다 본문을 실행하는 것이 더 좋다.
val sum = {x: Int, y: Int ->
println("Computing the sum of $x and $y...")
x+y //결과 값
}
- 여러 줄의 람다식에서는 마지막 식이 결과 값이 된다.
🙋♀️ 람다와 멤버 참조(::)
함수의 인자로 다음의 람다식을 넘기는 경우, {p -> p.age}
, 함수가 중복된다는 것을 알 수 있다. 이럴 때 사용할 수 있는 것이 바로 멤버 참조이다.
Person::age
🙌 생성자 참조
- 생성자 참조를 통해서 클래스의 생성을 연기/저장할 수 있다.
data class Person(val name: String, val age: Int)
...
val createPerson = ::Person // 생성자 참조
val p = createPerson("Alice", 23) // Person(name=Alice, age=23)
👩 컬렉션 함수형 API
지금부터 컬렉션을 다루는 코틀린 표준 라이브러리 중 몇 가지를 살펴보고자 한다.
🙋♀️ filter & map
- filter: 주어진 람다에 만족하는 원소만을 모아 반환
val list = listOf(1, 2, 3, 4) println(list.filter{it % 2 == 0}) // 2, 4
- 함수의 단 하나의 인자가 람다일 경우 밖으로 꺼내 작성할 수 있다.
- map: 모든 원소에 적용한 결과를 모아 새 컬렉션으로 만들어 반환
val list = listOf(1, 2, 3, 4) println(list.map{it*it}) // [1, 4, 9, 16]
- 두 연산을 연쇄시킬 수도 있다.
val list = listOf(1, 2, 3, 4) print(list.filter{it % 2 == 0}.map{it * it}) // [4, 16]
- 컬렉션 map에서의 filter, map
- filterKeys / filterValues: 각각 맵의 키와 값을 걸러내는 역할
- mapKeys / mapValues: 각각 맵의 키와 값을 변환하는 역할
val numbers = mapOf(0 to "zero", 1 to "one") println(numbers.mapValues{it.value.toUpperCase()}) // {0 = ZERO, 1 = ONE}
🙋♀️ all & any & count & find
- all / any: 각각 컬렉션에서 조건을 모두/일부 만족하는 지 판단
- count: 컬렉션에서 조건을 만족하는 원소의 개수를 반환
- find: 컬렉션에서 조건을 만족하는 첫 번째 원소를 반환
- 해당하는 원소가 없을 경우, null을 반환한다. (= findOrNull)
val canBeInClub27 = {p: Person -> p.age <= 27} // 술어 함수
val people = listOf(Person("Alice", 27), Person("Bob", 31))
println(people.all(canBeInClub27)) // false
println(people.any(canBeInClub27)) // true
println(people.count(canBeInClub27)) // 1
println(people.find(canBeInClub27)) // Person("Alice", 27)
🙋♀️ groupBy
- 리스트를 하나의 특징을 키로 한 맵으로 변환해 반환한다.
val list = listOf("a", "ab", "b")
println(list.groupBy{String::first}) # {a= [a, ab], b= [b]}
- 각 그룹의 값은 리스트이다.
- 위 예시에서
String::firs
t는 확장 함수를 멤버 참조를 통해 사용했다.
🙋♀️ flatten & flatMap
- 중첩 컬렉션의 원소들을 하나의 리스트로 만든다.
- flatten: 단순히 중첩 컬렉션을 하나의 리스트로 만든다.
- flatMap: 람다를 적용해 얻어지는 여러 리스트를 하나로 모은다.
val strings = listOf("abc", "def")
println(strings.falttenMap{it.toList()}) // [a, b, c, d, e, f]
👩 시퀀스를 이용한 컬렉션의 지연 연산
다음과 같은 연쇄 연산을 살펴보자!
🙋♀️ 컬렉션에서의 연쇄 연산
list.filter{it % 2 == 0}.map{it * it}
- 여기서 주의해야 할 점은 filter, map 모두 결과로 리스트를 반환한다는 점이다.
- 즉, 연쇄 매 단계마다 중간 결과가 새로운 컬렉션에 임시로 저장된다. (즉시 계산)
만약 리스트의 원소가 수백만 개가 된다면 매 단계마다 수백만 원소의 리스트가 생성되는 것이다. 이러한 문제점을 해결하는 것이 바로 시퀀스이다.
🙋♀️ 시퀀스에서의 연쇄 연산
list.asSequence().filter{it % 2 == 0}.map{it * it}.toList()
- 중간 결과를 저장하는 컬렉션이 생성되지 않는다. (지연 계산)
- 결과를 이터레이션에만 사용한 다면 list로 변환하지 않아도 된다. 단, 인덱스를 사용해 접근하는 등의 다른 API 메서드를 사용할 경우 리스트로 변환해야 한다.
➕ 연산 순서에 따른 성능 차이
컬렉션에 대해 수행하는 연산 순서에 따라서 성능의 차이가 발생한다. 다음의 경우를 살펴보자.
val people = listOf("Alice", 27), Person("Bob", 31), Person("Charles", 31), Person("Dan", 21))
// map -> filter
println(people.asSequence().map(Person::name).filter{it.length < 4}.toList()) # [Bob, Dan]
// filter -> map
println(people.asSequence().filter{it.name.length < 4}.map(Person::name)) # [Bob, Dan]
결과는 똑같지만 변환 횟수를 살펴보면 어느 연산을 먼저 하는 것이 효율적인 지를 파악할 수 있다.
- map 연산을 먼저 할 경우 모든 원소를 변환하지만 filter를 먼저 적용하면 부적절한 원소는 제외하게 된다.
🙋♀️ 시퀀스 지연 연산
- 시퀀스의 원소는 필요할 때 비로소 계산된다.
listOf(1, 2, 3, 4).asSequence()
.map{print("map($it) "); it * it} // 중간 연산
.filter{print("filter($it) "); it % 2 == 0} // 중간 연산
.toList() //최종 연산
시퀀스 연산은 중간 연산과 최종 연산으로 나뉜다.
🙄 위의 식에서 최종연산이 없다면 아무 것도 출력되지 않는다. 최종 연산이 호출될 때 연기됐던 중간연산이 비로소 진행되어 출력 결과를 볼 수 있게 된다. 여기서 또 주목해야 할 점은 연산의 수행 순서이다. 출력 결과를 통해서 수행 순서를 확인해보자.
map(1) filter(1) map(2) filter(4) map(3) filter(9) map(4) filter(16)
- 연산이 각 원소에 대해 🔔순차적으로 적용된다.
- 컬렉션 연쇄 연산에서는 map을 통한 새 시퀀스를 얻고, 그 시퀀스에 대해 filter를 수행하게 된다.
println(listOf(1, 2, 3, 4).asSequence()
.map{it * it}.find{it > 3}) // 4
위 식이 수행되는 과정을 통해 즉시 게산과 지연 계산의 차이를 명확히 알아보자!
- 즉시 연산은 컬렉션에 연산을 적용한다.
- 지연 연산은 원소 하나씩 연산을 진행한다.
🙋♀️ generateSequence로 시퀀스 생성
위의 경우 기존의 컬렉션에 asSequence()
를 통해서 시퀀스를 생성했다. 하지만 직접 시퀀스를 생성하는 방법도 있다. 아래의 0-100까지의 합을 구하는 코드를 살펴보자!
val naturalNumbers = generateSequnece(0) {it + 1}
val numbersTo100 = naturalNumbers.takeWhile {it <= 100}
println(numbersTo100.sum()) #5050
- naturalNumbers, numbersTo100 시퀀스 모두 최종 연산인 sum이 수행될 때 계산이 된다.
generateSequence()
함수는 이전 원소를 인자로 받아 다음 원소를 계산한다.- 시퀀스는 일반적으로 객체의 조상으로 이뤄진 시퀀스를 만들어 조상의 특성을 알고 싶을 때 사용한다.
- ex) 특정 파일의 상위 디렉터리를 검사하며 숨김 속성을 가진 디렉터리가 있는지 확인한다.
👩 함수형 인터페이스를 대신하는 람다
🙋♀️ 함수형/SAM 인터페이스
버튼에 리스너를 다는 작업을 살펴보자!
button.setOnClickListener(new OnClickListener(){
@Override
public void onClick(View v){
...
}
})
자바에서는 setOnClickListener 메서드에 인자로 위와 같이 무명클래스의 인터페이스를 넘겼었다.
여기서 onClickListener는 추상 메서드가 onClick 단 하나만 존재한다. 이러한 인터페이스를
함수형 인터페이스
라고 한다.
🙄 코틀린에서는 이렇게 함수형 인터페이스를 인자로 취하는 자바 메서드를 호출할 때 람다로 대체할 수 있다.
button.setOnClickListener {view -> ...}
- 이때 람다에 대해 무명 클래스를 만들고, 해당 클래스의 인스턴스를 만들어 메서드에 넘기는 방식을 취한다.
➕ 무명 객체 vs 람다의 인스턴스 생성
- 무명 객체의 경우, 호출할 때마다 새로운 객체가 생성된다.
- 람다의 경우, 하나의 인스턴스만 만들어져 사용된다.
- 단, 람다에서 주변 영역의 변수를 포획한 경우, 인스턴스가 매번 새로 만들어진다.
// fun postponeComputation(val delay: Int, val computation: Runnable) fun handleComputation(id: String){ postponeComputation(1000) {println(id)} }
- postponeComputation에 넘겨지는 람다에서 주변 인자 “id”를 포획했으므로 해당 Runnable 인스턴스는 매번 새로 만들어진다.
🙋♀️ SAM 생성자
- 대부분의 경우 람다와 자바 함수형 인터페이스 사이 변환은 자동으로 이뤄진다.
- 컴파일러가 자동으로 람다를 함수형 인터페이스 무명 클래스로 바꾸지 못하는 경우 SAM 생성자를 사용해 명시적으로 변경해주어야 한다.
fun createAllDoneRunnable() : Runnable {
return Runnable {println("All done!")}
}
여기서 아래의 SAM 생성자를 통해 값을 반환하는 것을 알 수 있다.
Runnable {
...
}
- SAM 생성자는 사용하려는 함수형 인터페이스와 이름이 같다.
➕ 람다에서의 this
- 람다는 객체가 아니므로 인스턴스 자신을 가리키는 this가 없다.
- 람다 안에서 사용되는 this는 람다를 둘러싼 클래스의 인스턴스를 가르킨다.
- 이벤트 리스너를 람다를 사용해 생성할 경우 리스너 등록을 해제할 수 있다. 따라서 리스너 등록 해제를 위해서는 무명 객체를 사용해 리스너를 구현해야 한다.
👩 수신 객체 지정 람다
수신 객체를 명시하지 않고 람다의 본문 안에서 다른 객체의 메서드를 호출할 수 있게 하는 람다
🙋♀️ with 함수
- 객체의 이름을 반복하지 않고도 그 객체에 대해 다양한 연산을 수행할 수 있게 한다.
with(stringBuilder, {...})
- 첫 번째 인자로 받은 객체를 두 번째 인자로 받은 람다의 수신 객체로 만든다.
- 람다 안에서 this를 통해 수신 객체의 프로퍼티에 접근할 수 있다.
fun alphabet() = with(StringBuilder()) {
for (letter in 'A'..'Z'){
this.append(letter)
}
append("\nNow i know the alphabet!")
toString()
}
- 위 코드에서 알 수 있듯이 this 참조를 사용하지 않고 바로 함수를 호출해도 프로퍼티에 접근할 수 있다.
🙋♀️ apply
- apply는 with와 유사하다.
- 유일한 차이는 apply는 항상 자신에게 전달된 객체(수신 객체)를 반환한다.
fun alphabet () = StringBuilder().apply {
for (letter in 'A'..'Z'){
append(letter)
}
append("\nNow i know the alphabet!")
}.toString()
🙄 여기서 주목해야 할 부분은 마지막 toString()
이다. 수신 객체인 StringBuilder가 반환되므로 toString을 호출해 String 객체를 얻을 수 있다.
- apply는 객체의 인스턴스를 만들어 즉시 프로퍼티 중 일부를 초기화 하는 경우에 유용하게 사용된다.
🙋♀️ buildString
- StringBuilder 객체를 만드는 일과 toString을 호출해주는 일을 알아서 해준다.
- 이 또한 수신 객체 지정 람다를 사용하는 예이다.
위 에제를 buildString 함수를 사용해 리팩토링해보자!
fun alphabet() = buildString {
for (letter in 'A'..'Z'){
append(letter)
}
append("\nNow i know the alphabet!")
}
👩 요약
- 람다를 사용하면 코드 조각을 다른 함수에게 인자로 넘길 수 있다.
- 코틀린에서는 람다가 함수 인자인 경우 괄호 밖으로 람다를 빼낼 수 있고, 람다의 인자가 단 하나뿐인 경우 인자 이름을 지정하지 않고 it이라는 디폴트 이름으로 부를 수 있다.
- 람다 안에 있는 코드는 그 람다가 들어있는 바깥 함수의 변수를 읽거나 쓸 수 있다.
- 메서드, 생성자, 프로퍼티의 이름 앞에 ::을 붙이면 각각에 대한 참조를 만들 수 있다. 그런 참조를 람다 대신 다른 함수에게 넘길 수 있다.
- filter, map, all, any 등의 함수를 활용하면 컬렉션에 대한 대부분의 연산을 직접 원소에 이터레이션하지 않고 수행할 수 있다.
- 시퀀스를 사용하면 중간 결과를 담는 컬렉션을 생성하지 않고도 컬렉션에 대한 여러 연산을 조합할 수 있다.
- 함수형 인터페이스(추상 메서드가 단 하나뿐인 SAM 인터페이스)를 인자로 받는 자바 함수를 호출할 경우 람다를 함수형 인터페이스 인자 대신 넘길 수 있다.
- 수신 객체 지정 람다를 사용하면 람다 안에서 미리 정해둔 수신 객체의 메서드를 직접 호출할 수 있다.
- 표준 라이브러리의 with 함수를 사용하면 어떤 객체에 대한 참조를 반복해서 언급하지 않으면서 그 객체의 메서드를 호출할 수 있다. apply를 사용하면 어떤 객체라도 빌더 스타일의 API를 사용해 생성하고 초기화할 수 있다.
🙇♀️ 부족한 부분이 있다면 말씀해주세요! 감사합니다!
📃참고
- ‘Kotlin in action’: 5장(람다로 프로그래밍)
댓글남기기