[Kotlin] 5장: 람다로 프로그래밍

sonnie
lucky-sonnie
Published in
21 min readJan 9, 2021

5.1 람다 식과 멤버 참조

5.1.1 람다 소개: 코드 블록을 함수 인자로 넘기기

함수형 프로그래밍 : 함수를 값처럼 다루는 접근 방법을 택함으로써 일련의 동작을 변수에 저장하거나 다른 함수에 넘긴다. 클래스를 선언하고 그 클래스의 인스턴스를 함수에 넘기는 대신 함수형 언어에서는 함수를 직접 다른 함수에 전달할 수 있다.

5.1.2 람다와 컬렉션

람다를 사용해 라이브러리에서 제공하는 컬렉션 처리 함수를 사용하자.

data class Person(val name: String, val age: Int)

val people= listOf(Person("Alice", 29), Person("Bob", 31))
println(people.maxByOrNull { it.age })
>>> 결과값: Person(name=Bob, age=31)

(책에서는 maxBy로 설명하지만 deprecated 되었고, 레퍼런스에서는 그 대신 maxByOrNull 사용을 권장하고 있다.)

위의 코드처럼 단지 함수나 프로퍼티를 반환하는 역할을 수행하는 람다는 멤버 참조로 대치할 수 있다.

people.maxByOrNull(Person::age)

자바 컬렉션에 대해(자바 8 이전) 수행하던 대부분의 작업은 람다나 멤버 참조를 인자로 취하는 라이브러리 함수를 통해 개선할 수 있다. 그렇게 람다나 멤버참조를 인자로 받는 함수를 통해 개선한 코드는 더 짧고 더 이해하기 쉽다.

5.1.3 람다 식의 문법

{ x: Int, y: Int -> x + y } 화살표(->)전은 파라미터(인자 목록), 그 후는 본문이라고 한다. 코틀린 람다 식은 항상 중괄호로 둘러쌓여 있다. 인자 목록 주변에 괄호가 없다는 사실을 꼭 기억해야 한다.

val sum = {x: Int, y: Int -> x + y}
println(sum(1,2)) // 변수에 저장된 람다를 호출한다.
>>> 결과값: 3
{println(42)}()
>>> 결과값: 42

두번째 예제 코드의 경우는 읽기 어렵고 쓸모도 없다. 코드의 일부분을 블록으로 둘러싸 실행할 필요가 있다면 run을 사용한다. run은 인자로 받은 람다를 실행해주는 라이브러리 함수다.

run { println(42) }
>>> 42

실행 시점에 코틀린 람다 호출에는 아무 부가 비용이 들지 않으며, 프로그램의 기본 구성요소와 비슷한 성능을 낸다.

5.1.2 에서 작성한 예제 코드를 람다를 사용해 정식으로 사용하면 아래와 같다

people.maxByOrNull({ p: Person -> p.age})

중 괄호 안에 있는 코드는 람다 식이고 그 람다 식을 maxByOrNull 함수에 넘긴다. 람다 식은 Person 타입의 값을 인자로 받아서 인자의 age를 반환한다.

하지만 이 코드는 번잡하다. 구분자가 너무 많이 쓰여 가독성이 떨어지고, 컴파일러가 유추할 수 있는 인자 타입을 굳이 적을 필요는 없다. 마지막으로 인자가 단 하나뿐인 경우 굳이 인자에 이름을 붙이지 않아도 된다.

people.maxByOrNull(p -> p.age)

처음에는 타입을 쓰지 않고 람다를 작성하고 컴파일러가 타입을 모르겠다고 불평하는 경우에만 타입을 명시한다. 파라미터 중 일부의 타입은 지정하고 나머지 파라미터는 타입을 지정하지 않고 이름만 남겨둬도 된다. 타입 정보가 코드를 읽을 때 도움이 된다면 그렇게 일부 타입만 표시하면 편하다.

마지막으로 람다의 파라미터 이름을 디폴트 이름인 it으로 바꾸면 람다 식을 더 간단하게 만들 수 있다. 람다의 파라미터가 하나뿐이고 그 타입을 컴파일러가 추론할 수 있는 경우 it을 바로 쓸 수 있다.

people.maxByOrNull(it)

람다 안에 람다가 중첩되는 경우 각 람다의 파라미터를 명시하는 편이 낫다. (가독성)

람다를 변수에 저장할 때는 파라미터 타입을 추론할 문맥이 존재하지 않는다. 따라서 파라미터 타입을 명시해야 한다.

val getAge={ p: Person -> p.age}
people.maxByOrNull(getAge)

본문이 여러 줄로 이뤄진 경우 본문의 맨 마지막에 있는 식이 람다의 결과 값이 된다.

5.1.4 현재 영역에 있는 변수에 접근

fun printProblemCounts(responses: Collection<String>){
var clientError = 0
var serverError = 0
responses.forEach{
if(it.startsWith("4")){
clientError++
}else if(it.startsWith("5")){
serverError++
}
}
println("$clientError client errors, $serverError server errors")
}

val responses = listOf("200 OK", "418 I'm a teapot", "500 Internal server Error")
>>> 결과값: 1 client errors, 1 server errors

자바와 다른 점 중 중요한 한가지는 코틀린은 람다 안에서 파이널 변수가 아닌 변수에 접근할 수 있다는 점이다. 또한 람다 안에서 바깥의 변수를 변경해도 된다. 람다 안에서 사용하는 외부 변수를 ‘람다가 포획(capture)한 변수’라고 부른다.

기본적으로 함수 안에 정의된 로컬 변수의 생명주기는 함수가 반환되면 끝난다. 하지만 어떤 함수가 자신의 로컬 변수를 포획한 람다를 반환하거나 다른 변수에 저장한다면 로컬 변수의 생명주기와 함수의 생명주기가 달라질 수 있다. 이런 동작이 가능할까? 파이널 변수를 포획한 경우에는 람다 코드를 변수 값과 함께 저장한다. 파이널이 아닌 변수를 포획한 경우에는 변수를 특별한 래퍼로 감싸서 나중에 변경하거나 읽을 수 있게 한 다음, 래퍼에 대한 참조를 람다 코드와 함께 저장한다.

변경 가능한 변수를 저장하는 원소가 단 하나뿐인 배열을 선언하거나, 변경 가능한 변수를 필드로 하는 클래스를 선언하는 것이다.

class Ref<T> (var value: T)
>>> val counter = Ref(0)
>>> val inc = {counter.value++} // 공식적으로 변경 불가능한 변수를 포획했지만 그 변수가 가리키는 객체의 필드 값을 바꿀 수 있다.

람다가 var를 포획하면 변수를 Ref 클래스 인스턴스에 넣는다. 그 Ref 인스턴스에 대한 참조를 파이널로 만들면 쉽게 람다로 포획할 수 있고, 람다 안에서는 Ref 인스턴스의 필드를 변경할 수 있다.

람다를 이벤트 핸들러나 다른 비동기적으로 실행되는 코드로 활용하는 경우 함수 호출이 끝난 다음에 로컬 변수가 변경될 수도 있다. 예를 들어 다음 코드는 버튼 클릭 횟수를 제대로 셀 수 없다.

fun tryToCountButtonClicks(button: Button): Int{
var clicks = 0
button.onClick{clicks++}
return clicks
}

이 함수는 항상 0을 반환한다. onClick 핸들러는 호출될 때마다 clicks 값을 증가시키지만 그 값의 변경을 관찰할 수는 없다. 핸들러는 tryToCountButtonClicks가 clicks를 반환한 다음에 호출되기 때문이다. 이 함수를 제대로 구현하려면 클릭 횟수를 세는 카운터 변수를 함수의 내부가 아니라 클래스의 프로퍼티나 전역 프로퍼티 등의 위치로 빼냇서 나중에 변수 변화를 살펴볼 수 있게 해야한다.

5.1.5 멤버 참조

넘기려는 코드가 이미 함수르 선언된 경우 함수를 값으로 바꿔 넘길 수 있다. 멤버 참조(::)를 사용하면 된다.

val getAge = Person::age

멤버 참조는 프로퍼티나 메소드를 단 하나만 호출하는 함수 값을 만들어준다. 참조 대상이 함수인지 프로퍼티인지와 관계없이 멤버 참조 뒤에는 괄호를 넣으면 안된다.

people.maxByOrNull(Person::age)
people.maxByOrNull(p -> p.age)
people.maxByOrNull(it.age)

최상위에 선언된(그리고 다른 클래스의 멤버가 아닌) 함수나 프로퍼티를 참조할 수도 있다.

fun salute() = println("Salute!")
>>> run(::salute)
>>> 결과값: Salute!

생성자 참조를 사용하면 클래스 생성 작업을 연기하거나 저장해둘 수 있다. :: 뒤에 클래스 이름을 넣으면 생성자 참조를 만들 수 있다.

val createPerson = ::Person // "Person"의 인스턴스를 만드는 동작을 값으로 저장
val p = createPerson("Alice". 29)
println(p)
>>> 결과값: Person(name=Alice, age=29)

확장 함수도 멤버 함수와 똑같은 방식으로 참조할 수 있다는 점을 기억하라.

fun Person.isAdult() = age >= 21
val predicate = Person::isAdult

📚 추가)바운드 멤버 참조

val p = Person(“sonnie”, 34)
val sonnieAgeFunction = p::age
printlnt(sonnieAgeFunction())
>>> 결과값: 34

5.2 컬렉션 함수형 API

5.2.1 필수적인 함수 : filter와 map

val list = listOf(1,2,3,4)
println(list.filter { it % 2 == 0 }) // 짝수만 남는다.
>>> 결과값: [2, 4]

filter함수는 컬렉션에서 원치 않는 원소를 제거한다. 하지만 filter는 원소를 변환할 수는 없다. 원소를 변환하려면 map 함수를 사용해야 한다.

val list = listOf(1,2,3,4)
println(list.map { it * it })
>>> 결과값: [1, 4, 9, 16]

사람의 리스트가 아니라 이름의 리스트를 출력하고 싶다면 map으로 사람의 리스트를 이름의 리스트로 변환하면 된다.

val people= listOf(Person("Alice", 29), Person("Bob", 31))
println(people.map{ it.name })
println(people.map(Person::name))
>>> 결과값: [Alice, Bob]println(people.filter { it.age > 30 }.map (Person::name))
>>> 결과값: [Bob]

목록에서 가장 나이 많은 사람의이름을 알고 싶다고 할 때, 먼저 목록에 있는 사람들의 나이의 최댓값을 구하고 나이가 그 최댓값과 같은 모든 사람을 반환하면 된다.

people.filter{ it.age == people.maxByOrNull(Person::age)!!.age}

위의 코드는 목록에서 최댓값을 구하는 작업을 계속 반복한다는 단점이 있다. 이를 개선해 최댓값을 한 번만 계산하게 만든 코드는 아래와 같다.

val maxAge = people.maxByOrNull(Person::age)!!.age
people.filter{ it.age == maxAge}

필터와 변환 함수를 맵에 적용할 수도 있다.

val numbers = mapOf(0 to "zero", 1 to "one")
println(numbers.mapValues{ it.value.toUpperCase() })
{0=ZERO, 1=ONE}

맵의 경우 키와 값을 처리하는 함수가 따로 존재한다. filterKey와 mapKeys는 키를 걸러내거나 변환하고 fileterValues와 mapValues는 값을 걸러 내거나 변환한다.

5.2.2. all, any, count, find : 컬렉션에 술어 적용

컬렉션에 대해 자주 수행하는 연산으로 컬렉션의 모든 원소가 어떤 조건을 만족하는지 판단하는 연산이 있다. 코틀린에서는 all과 any가 이런 연산이다. count 함수는 조건을 만족하는 원소의 개수를 반환하며, find 함수는 조건을 만족하는 첫 번째 원소를 반환한다.

>>> val canBeInClub27 = {p: Person -> p.age <= 27}
>>> val people= listOf(Person("Alice", 27), Person("Bob", 31))
>>> println(people.all(canBeInClub27))
false

모든 원소가 canBeInClub27를 만족하는지 궁금하다면 all 함수를 써서 판단할 수 있다. canBeInClub27를 만족하는 원소가 하나라도 있는지 궁금하면 any를 쓴다.

>>> println(people.all(canBeInClub27))
true

함수를 적재적소에 사용하라: count와 size

count가 있다는 사실을 잊어버리고, 컬렉션을 필터링한 결과의 크기를 가져오는 경우가 있다.

>>> println(people.filter(canBeInClub27).size)
1

이렇게 처리하면 조건을 만족하는 모든 원소가 들어가는 중간 컬렉션이 생긴다. 반면 count는 조건을 만족하는 원소의 개수만을 추적하지 조건을 만족하는 원소를 따로 저장하지 않기 때문에 더 효율적이다.

>>> val canBeInClub27 = {p: Person -> p.age <= 27}
>>> val people= listOf(Person("Alice", 27), Person("Bob", 31))
>>> println(people.find(canBeInClub27))
Person(name=Alice, age=27)

find는 firstOrNull과 같다. 조건을 만족하는 원소가 없으면 null이 나온다는 사실을 더 명확히 하고 싶다면 firstOrNull을 쓸 수 있다.

5.2.3 groupBy: 리스트를 여러 그룹으로 이뤄진 맵으로 변경

컬렉션의 모든 원소를 어떤 특성에 따라 여러 그룹으로 나누고 싶다고 하자. 예를 들어 사람을 나이에 따라 분류해보자.

>>> val people= listOf(Person("Alice", 29), 
Person("Carol", 29), Person("Bob", 31))
>>> println(people.groupBy{ it.age })
{29=[Person(name=Alice, age=29), Person(name=Carol, age=29)],
31=[Person(name=Bob, age=31)]}

5.2.4: flatMap과 flatten: 중첩된 컬렉션 안의 원소 처리

>>> data class Book(val title: String, val authors: List<String>)
>>> val books = listOf(Book("코틀린", listOf("안소현", "안주현")),
Book("자바", listOf("안소현", "안정현")))

책마다 저자가 한 명 또는 여러 명 있다. 도서관에 있는 책의 저자를 모두 모은 집합을 다음과 같이 가져올 수 있다.

>>> books.flatMap { it.authors }.toSet()
[안소현, 안주현, 안정현]

flatMap 함수는 먼저 인자로 주어진 람다를 컬렉션의 모든 객체에 매핑하고 람다를 적용한 결과 얻어지는 여러 리스트를 한 리스트로 모든다. 문자열에 대해 이 개념을 적용한 예를 살펴보자.

>>> val strings = listOf("abc", "def")
>>> strings.flatMap { it.toList() }
[a, b, c, d, e, f]

5.3. 지연 계산(Lazy) 컬렉션 연산

map이나 filter는 결과 컬렉션을 즉시 생성한다. 이는 컬렉션 함수를 연쇄하면 매 단계마다 계산 중간 결과를 새로운 컬렉션에 임시로 담는다는 말이다. 시퀀스(Sequence)를 사용하면 중간 임시 컬렉션을 사용하지 않고도 컬렉션 연산을 연쇄할 수 있다.

people.map(Person::name).filter { it.startsWith("A") }

코틀린 표준 라이브러리 참조 문서에 filter와 map이 리스트를 반환한다고 써 있다. 이는 이 연쇄 호출이 리스트를 2개 만든다는 뜻이다. (1 for map, 1 for filter) 원본 리스트에 원소가 훨씬 많아지면 성능이 떨어질 수 있기 때문에 시퀀스를 사용하게 만들어야 한다.

people.asSequence()   // 원본 컬렉션을 시퀀스로 변환한다.
.map(Person::name)
.filter { it.startsWith("A") }
.toList() // 결과 시퀀스를 다시 리스트로 변환한다.

시퀀스는 중간결과를 저장하는 컬렉션이 생기지 않기 때문에 원소가 많은 경우 성능이 눈에 띄게 좋아진다. 코틀린 지연 계산 시퀀스는 Sequence 인터페이스에서 시작한다. 이 인터페이스는 단지 한 번에 하나씩 열거될 수 있는 원소의 시퀀스를 표현할 뿐이다. Sequence 안에는 iterator라는 단 하나의 메소드가 있다. 그 메소드를 통해 시퀀스로부터 원소 값을 얻을 수 있다.

시퀀스 원소는 필요할 때 비로소 계산된다. 따라서 중간 처리 결과를 저장하지 않고도 연산을 연쇄적으로 적용해서 효율적으로 게산을 수행할 수 있다. asSequence 확장 함수를 호출하면 어떤 컬렉션이든 시퀀스로 바꿀 수 있다.

왜 시퀀스를 다시 컬렉션으로 되돌려야 할까? 시퀀스의 원소를 차례로 이터레이션해야 한다면 시퀀스를 직접 써도 된다. 하지만 시퀀스 원소를 인텍스를 사용해 접근하는 등의 다른 API 메소드가 필요하다면 시퀀스를 리스트로 변환해야 한다.

큰 컬렉션에 대해서 연산을 연쇄시킬 때는 시퀀스를 사용하는 것을 규칙으로 삼아라.

5.3.1 시퀀스 연산 실행: 중간 연산과 최종 연산

시퀀스에 대한 연산은 중간 연산최종 연산으로 나뉜다. 중간 연산은 다른 시퀀스를 반환한다. 그 시퀀스는 최초 시퀀스의 원소를 변환하는 방법을 안다. 최종 연산은 결과를 반환한다.

listOf(1,2,3,4).asSequence()
.map { print("map($it)"); it * it }
.filter { print("filter($it)"); it%2 == 0 }

이 코드를 실행하면 아무 내용도 출력되지 않는다. 이는 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)

원소에 연산을 차례대로 적용하다가 결과가 얻어지면 그 이후의 원소에 대해서는 변환이 이뤄지지 않을 수도 있다.

자바 8을 채택하면 스트림 연산(map과 filter) 을 여러 CPU에서 병렬적으로 실행하는 기능을 할 수 있다.

5.3.2 시퀀스 만들기

시퀀스를 만드는 다른 방법으로 generateSequence 함수를 사용할 수 있다.

5.4 자바 함수형 인터페이스 활용

코틀린은 함수형 인터페이스를 인자로 취하는 자바 메소드를 호출할 때 람다를 넘길 수 있게 해준다.

5.4.1 자바 메소드에 람다를 인자로 전달

함수형 인터페이스를 인자로 원하는 자바 메소드에 코틀린 람다를 전달할 수 있다. 예를 들어 다음 메소드는 Runnable 타입의 파라미터를 받는다.

/* 자바 */
void postponeComputation(int delay, Runnable computation);
/* 코틀린 */
postponeComputation(1000) {println(42)}

컴파일러는 자동으로 람다를 Runnable인스턴스로 변환해준다. 여기서 ‘Runnable 인스턴스’라는 말은 실제로 ‘Runnable을 구현한 익명 클래스의 인스턴스’라는 뜻이다. 컴파일러는 자동으로 그런 익명 클래스와 인스턴스를 만들어준다.

Runnable을 구현하는 무명 객체를 명시적으로 만들어서 사용할 수도 있다.

postponeComputation(1000, object: Runnable{
override fun run(){
println(42)
}
})

객체를 명시적으로 선언하는 경우 메소드를 호출할 때마다 새로운 객체가 생성된다. 하지만 람다는 해당 인스턴스를 반복 사용한다.

람다가 주변 영역의 변수를 포획한다면 매 호출마다 같은 인스턴스를 사용할 수 없다. 그런 경우 컴파일러는 매번 주변 영역의 변수를 포획한 새로운 인스턴스를 생성해준다.

5.4.2 SAM 생성자: 람다를 함수형 인터페이스로 명시적으로 변경

SAM(Single Abstract Method)생성자는 람다를 함수형 인터페이스의 인스턴스로 변환할 수 있게 컴파일러가 자동으로 생성한 함수다. 컴파일러가 자동으로 람다를 함수형 인터페이스 무명 클래스로 바꾸지 못하는 경우 SAM 생성자를 사용할 수 있다. 예를 들어 함수형 인터페이스의 인스턴스를 반환하는 메소드가 있다면 람다를 직접 반환할 수 없고, 반환하고픈 람다를 SAM 생성자로 감싸야 한다.

fun createAllDoneRunnable(): Runnable{
return Runnable{ println("All done!")}
}
>>> createAllDoneRunnable().run()All done!

SAM 생성자는 그 함수형 인터페이스의 유일한 추상 메소드의 본문에 사용할 람다만을 인자로 받아서 함수형 인터페이스를 구현하는 클래스의 인스턴스를 반환한다.

람다 안에서 this는 그 람다를 둘러싼 클래스의 인스턴스를 가리킨다.

가끔 오버로드한 메소드 중에서 어떤 타입의 메소드를 선택해 람다를 변환해 넘겨줘야 할지 모호한 때가 있다. 그런 경우 명시적으로 SAM을 생성자를 적용하면 컴파일 오류를 피할 수 있다.

5.5. 수신 객체 지정 람다: with과 apply

코틀린 람다에는 수신 객체를 명시하지 않고 람다의 본문 안에서 다른 객체의 메소드를 호출할 수 있게 한다. 이를 수신 객체 람다(Lamda with Receiver) 라고 부른다. 수신 객체 람다에는 withapply가 있다.

5.5.1 with 함수

with 이라는 라이브러리 함수를 통해 어떤 객체의 이름을 반복하지 않고도 그 객체에 대해 다양한 연산을 수행할 수 있다.

fun alphabet(): String{
val stringBuilder= StringBuilder()
return with(stringBuilder){
for(letter in 'A'..'Z'){
this.append(letter)
}
append("\n Now I know the alphabet!")
this.toString()
}
}
>>> 결과값: ABCDEFGHIJKLMNOPQRSTUVWXYZ
Now I know the alphabet!

with은 사실 파라미터가 2개 있는 함수다. 여기서 첫 번째 파라미터는 stringBuilder이고 두 번째 파라미터는 람다다. 람다를 괄호 밖으로 빼내는 관례를 사용함에 따라 전체 함수 호출이 언어가 제공하는 특별 구문처럼 보인다. 이 방식 대신 with(stringBuilder, {...}) 라고 할 수도 있지만 읽기가 더 나빠진다.

with 함수는 첫 번째 인자로 받은 객체를 두 번째 인자로 받은 람다의 수신 객체로 만든다. 인자로 받은 람다 본문에서는 this를 사용해 그 수신 객체에 접근할 수 있다. 일반적인 this와 마찬가지로 프로퍼티나 메소드 이름만 사용해도 수신 객체의 멤버에 접근할 수 있다.

alphabet() 함수를 더 리팩토링해서 불필요한 StringBuilder 변수를 없앨 수 있다.

fun alphabet() = with(StringBuilder()){
for(letter in 'A'..'Z'){
append(letter)
}
append("\n Now I know the alphabet!")
toString()
}

메소드 이름 충돌

with에게 인자로 넘긴 객체의 클래스와 with을 사용하는 코드가 들어있는 클래스 안에 이름이 같은 메소드가 있으면 무슨 일이 생길까? 그런 경우 this 참조 앞에 레이블을 붙이면 호출하고 싶은 메소드를 명확하게 정할 수 있다.

alphabet 함수가 OuterClass 의 메소드라고 하자. StringBuilder 가 아닌 OuterClass 에 정의된 toString 을 호출하고 싶다면 this@OuterClass.toString() 을 사용하면 된다.

with가 반환하는 값은 람다 코드를 실행한 결과며, 그 결과는 람다 식의 본문에 있는 마지막 식의 값이다. 하지만 람다의 결과 대신 수신 객체가 필요한 경우도 있다. 그럴 때는 apply 라이브러리 함수를 사용할 수 있다.

5.5.2 apply 함수

apply 함수는 거의 with 과 같다. 유일한 차이란 apply는 항상 자신에게 전달된 객체를 반환한다는 점 뿐이다.

fun alphabet() = StringBuilder().apply{
for(letter in 'A'..'Z'){
append(letter)
}
append("\n Now I know the alphabet!")
}.toString()

이런 apply 함수는 객체의 인스턴스를 만들면서 즉시 프로퍼티 중 일부를 초기화해야 하는 경우 유용하다. 코틀린에서는 어떤 클래스가 정의돼 있는 라이브러리의 특별한 지원 없이도 그 클래스 인스턴스에 대해 apply를 활용할 수 있다.

buildString을 사용해 더 간단히 표현하기

buildString은 StringBuilder 객체를 만드는 일과 toString을 호출해주는 일을 알아서 해준다.

fun alphabet() = buildString{
for(letter in 'A'..'Z'){
append(letter)
}
append("\n Now I know the alphabet!")
}

--

--