uncategorized

람다로 프로그래밍

람다 식과 멤버 참조

람다 식의 문법

코틀린의 람다 식은 항상 중괄호로 둘러 쌓여 있다. 그리고 인자 목록을 괄호로 싸지 않는다. 람다를 변수에 저장하고 함수처럼 사용할 수 있다.

1
2
3
val sum = {x: Int, y: Int -> x + y}

println(sum(1, 2))

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

사람 목록에서 가장 연장자를 찾는 예제를 작성해본다.

1
2
val people = listOf(Person("Bob", 10), Person("Rob", 11))
println(people.maxBy { it.age })

maxBy 함수에 나이를 반환하는 람다를 전달했다. 이때 maxBy 부분을 최대한 풀어서 쓰면 다음과 같다.

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

하지말 이렇게 쓰면 너무 번잡하다. 어떻게 간편하게 개선되는지 알아보자.

먼저 함수의 맨 마지막 인자가 람다면 괄호 밖으로 뺄 수 있다.

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

그리고 람다만 가지고 있는 함수에서 람다를 밖으로 빼면 빈 괄호를 생략할 수 있다.

1
people.maxBy { p:Person -> p.age}

마지막으로 람다 파라미터를 컴파일러가 예상할 수 있어서 생략할 수 있다.

1
people.maxBy { p.age }

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

1
2
val getAge = { p: Person -> p.age }
people.maxBy(getAge)

코틀린에서 람다의 파라미터가 하나뿐이고 그 타입을 컴파일러가 추론할 수 있는 경우 it 키워드를 통해 파라미터를 표현할 수 있다.

1
people.maxBy { it.age }

람다의 본문에 여러 줄이 될 수 있다. 그럴 때는 마지막 줄이 해당 람다의 반환값이 된다.

1
2
3
4
val sum = { x: Int, y: Int ->
println("$x and $y")
x + y
}

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

람다는 함수 파라미터를 람다 안에서 사용하 ㄹ수 있다.

1
2
3
4
5
fun printMessageWithPrefix(messages: Collection<String>, prefix: String) {
messages.forEach {
println("$prefix")
}
}

자바와 다르게 코틀린의 람다는 파이널 변수가 아닌 변수에 접근할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
fun printProblemCounts(responses: Collection<String>) {
var clientErrors = 0
var serverError = 0
responses.forEach {
if (it.startsWith("4")) {
clientErrors++
} else if (it.startsWith("5")) {
serverError++
}
}
println("$clientErrors and $serverError")
}

람다 안에서 사용하는 외부 변수를 람다가 포획한 변수라고 부른다. 만약 함수가 변수를 포획한 람다를 반환한다면 변수의 생명 주기는 함수와 달라지게 된다. 함수가 종료되어도 변수를 포획한 람다를 실행하면 해당 변수를 일고 쓸 수 있다. 파이널 변수를 포획한 경우 람다 코드를 변수 값과 함께 저장한다. 파이널이 아닌 변수는 특별한 래퍼로 감싸서 나중에 변경하거나 읽을 수 있게 한 다음, 래퍼에 대한 참조를 람다 코드와 함께 저장한다.

다만, 람다를 비동기적으로 실행되는 코드로 활용하는 경우 함수 호출이 끝난 다음에 로컬 변수가 변경될 수 있다.

1
2
3
4
5
fun tryToCountButtonClick(button: Button) : Int {
var clicks = 0
button.onClick { clicks++}
return clicks
}

이미 함수에서 로컬변수를 반환해서 변수의 변화를 볼 수 없다.

멤버 참조

자바 8의 메서드 참조와 동일한 기능을 코틀린의 멤버 참조로 할 수 있다. 코틀린은 메서드뿐만 아니라 프로퍼티도 가능하다.

1
people.maxBy(Person::age)

그리고 최상위로 선언된 함수나 프로퍼티를 참조할 수 있다. 또한 생성자를 참조할 수 있다.

1
2
3
4
5
fun salute() = pritnln("Salute!")
run(::salute)

val createPerson = ::Person
val person = createPerson("Bob", 11)

바운드 멤버 참조

1
2
3
val p = Person("Bob", 11)
val personAgeFunc = Person::age
println(personAgeFunc(p))

코틀린 1.0에서는 클래스의 메서드나 프로퍼티에 참조를 얻고 클래스 인스턴스를 전달해줘야 가능했다. 하지만 코틀린 1.1부터는 바운드 멤버 참조를 지원한다. 바운드 멤버 참조는 클래스 인스턴스를 함께 저장한 다음 그 인스턴스에 대해 멤버를 호출한다.

1
2
3
val p = Person("Bob", 11)
val personAgeFunc = p::age
println(personAgeFunc())

컬렉션 함수형 API

필수적인 함수: filter, map

filter는 특정 조건에 해당하는 원소만 남길 수 있다.
map은 원소를 바꾸어서 새 컬렉션을 만든다.

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

count는 조건을 만족하는 원소의 갯수를 반환한다. 참고로 filter를 한 컬렉션의 size를 측정하는 것보다 count를 사용하는 것이 결과 컬렉션을 만들지 않아서 더 효과적이다.
find는 조건을 만족하는 원소의 첫번째 원소를 반환한다.
all은 모든 원소가 조건에 만족하는 지 반환한다.
any는 조건에 만족하는 원소가 하나라도 존재하는 지 확인한다.

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

1
2
3
4
val list = listOf("a", "ab", "b")
fun main() {
println(list.groupBy(String::first)
}

Map<Int, List<String>>을 만들어서 반환한다. 위 예시는 {a=[a, ab], b=[b]}

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

flatMap 함수는 먼저 인자로 주어진 람다를 컬렉션의 모든 객체에 적용하고(map) 결과로 얻어지는 여러 리스트를 한 리스트로 모은다(flat).

1
2
val strings = listOf("abc", "def")
println(strings.flatMap { it.toList() })

문자열을 list로 매핑하고, 각 매핑된 컬렉션들을 하나의 컬렉션으로 모은다.
중첩된 리스트이 원소를 한 리스트로 단순히 모아야 하는 경우 flatten 함수를 사용할 수 있다.

지연 계산(lazy) 컬렉션 연산

map, filter 같은 컬렉션 함수는 컬렉션을 즉시 생성해서 반환한다. 즉 컬렉션 함수를 연쇄적으로 사용하면 매 단계마다 계산 중간 결과를 새로운 컬렉션에 임시로 담는다.

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

이 경우에 map과 filter의 결과를 리스트로 만들어 저장한다. 즉 중간 계산 결과가 두번 저장된다. 중간 게산 결과값이 크면 중간 계산 결과 저장이 비효율적일 것이다.

코틀린에서는 시퀀스를 통해 중간 임시 컬렉션을 사용하지 않도록 할 수 있다.

1
2
3
people.asSequence()
.map(Person::name)
.filter { it.startsWith("B") }

코틀린 지연 연산 시퀀스는 Sequence 인터페이스에서 시작한다. 이 인터페이스는 iterator라는 메서드를 통해 원소값을 얻어 낸다. 하지만 하나씩 접근하는 것 외에 인덱스로 접근이나 다른 API를 사용하려면 시퀀스를 다른 컬렉션으로 다시 바꿔주자.

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

시퀀스에 적용되는 함수는 일반적인 컬렉션 함수와 다르게 작동한다. 컬렉션 함수는 적용 즉시 컬렉션을 반환하지만, 시퀀스 함수에는 중간 연산과 최종 연산을 나눠서 생각해야 한다. 중간 연산은 최초 시퀀스에서 중간 연산을 적용할 줄 아는 시퀀스이다. 최종 연산은 연산들을 모두 적용한 결과를 반환한다.

1
2
3
people.asSequence()
.map(Person::name)
.filter { it.startsWith("B") }

이 예시는 중간 연산만 적용되어 있어서 아직 연산이 적용된 결과 값이 만들어지지 않았다. 하지만 다음 예시처럼 최종 연산이 들어가면 비로소 모든 연산이 적용되고 결과값이 반환된다.

1
2
3
4
people.asSequence()
.map(Person::name)
.filter { it.startsWith("B") }
.toList()

여기서 컬렉션과 시퀀스의 연산 적용 방식에 차이를 알아보자.
컬렉션은 하나의 연산마다 모든 컬렉션에 적용을 한다. 반면 시퀀스는 하나의 원소에 모든 연산을 모아서 한번에 적용한다.

1
2
3
listOf(1,2,3,4).asSequence()
.map { it*it }
.find { it > 3 }

위와 같은 예시에서 컬렉션으로 연산했다면, 1 2 3 4 모두 map해야 한다. 그 다음 find를 진행한다.
하지만 시퀀스의 경우 1 2만 모든 연산이 적용된다. 왜? 마지막 find는 최초 원소를 찾는데 2에 모든 연산을 적용하고 나서 조건을 만족함을 확인했기 때문에 3 4에 연산을 적용하지 않는 것이다.

자바 스트림과 코틀린 시퀀스의 차이
사실 스트림과 매우 비슷하나 스트림의 병렬 처리를 시퀀스에서 제공하지 않는다. 다만 시퀀스는 자바 8보다 낮은 버전에서도 활용할 수 있다.

시퀀스 만들기

asSequence함수와 generateSequence 함수를 통해 시퀀스를 만들 수 있다.
generateSequence 함수는 첫 원소를 인자로 받고 다음 원소들을 계산하는 법을 람다로 받아서 만든다.

1
2
3
val printZeroTo100 = generateSequence(0) { it + 1}
.takeWhile { it <= 100 }
.forEach { println(it) }

자바 함수형 인터페이스 적용

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

함수형 인터페이스를 인자로 요구하는 자바 메서드에 코틀린 람다를 전달해줄 수 있다. 이 경우 컴파일러가 자동으로 람다를 해당 인터페이스의 인스턴스로 변환해준다.

1
2
// void compute(Runnable computation); 이란 자바코드가 있다고 가정
compute { println("hi") }

물론 무명 객체를 전달해줄 수 있다.

1
2
3
4
5
compute(object : Runnable {
override fun run() {
println("hi")
}
})

하지만 람다를 전달하는 것과 무명 객체를 전달하는 것은 차이가 있다. 객체를 명시적으로 선언하면 메서드를 호출할 때마다 새로운 객체가 생성된다. 반면 람다는 람다에 대응하는 무명 객체를 메서드가 호출 할때마다 반복 사용한다. (하지만 함수의 변수에 접근하는 람다는 제외)

람다와 무명 클래스 객체의 this 차이
무명 클래스 객체의 this는 자기 자신을 가리킨다. 반면 람다는 컴파일 타임에 아직 인스턴스가 없으므로 자기 자신을 가르킬 방법이 없다. 람다에서 this를 가르킬 경우 람다를 감싼 클래스 객체를 가리키게 된다. 만약 자기 자신을 가르켜야 되는 경우는 람다 대신 무명 객체를 활용하자.

람다를 무명 클래스의 인스턴스로 변환하는 것은 함수형 인터페이스를 받는 자바 메서드의 경우에만 그렇다.
컬렉션 확장 함수 (inline 표시된 코틀린 함수)에게 람다를 전달해도 무명 클래스가 만들어지지 않는다.

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

SAM 생성자는 람다를 함수형 인터페이스의 인스턴스로 변환할 수 있게 컴파일러가 자동 생성한 함수다. 컴파일러가 자동으로 람다를 함수형 인터페이스의 무명 클래스 인스턴스로 변환하지 못할 때 사용한다. 예를 들어 함수형 인터페이스의 인스턴스를 반환하는 경우 람다로 반환하지 못한다.
SAM 생성자는 함수형 인터페이스의 이름과 람다식을 전달해주면 된다.

1
2
3
4
fun createAllRunnable() : Runnable {
//람다는 안됨 return { println("GOOD!")}
return Runnable { println("GOOD!") }
}

수신 객체 지정 람다 : with & apply

수신 객체를 명시하지 않고 람다에서 다른 객체의 메서드를 호출하는 기능을 수신 객체 지정 람다라고 한다.

with

with의 힘을 느껴보기 위해 하나의 예시를 들어보고 with로 리팩토링 해보자

1
2
3
4
5
6
7
8
fun alphabet(): String {
val result = StringBuilder()
for (letter in 'a'..'z') {
result.append(letter)
}
result.append("\n alphabet end~\n")
return result.toString()
}

여기서 StringBuilder를 with로 처리하면 다음과 같다. with에 수신 객체를 적어두면 람다 내부에서 수신 객체를 적는 대신 this로 접근할 수 있다. 이렇게 하면 다양한 StringBuilder에서 해당 로직을 재활용 할 수 있다.

1
2
3
4
5
6
7
8
9
fun alphabet(): String {
return with(StringBuilder()) {
for (letter in 'a'..'z') {
this.append(letter)
}
append("\n alphabet end~ \n")
this.toString()
}
}

여기서 this를 생략할 수도 있다.

1
2
3
4
5
6
7
8
9
fun alphabet(): String {
return with(StringBuilder()) {
for (letter in 'a'..'z') {
append(letter)
}
append("\n alphabet end~ \n")
toString()
}
}

메서드 이름 충돌
만약 위 예시의 toString이 StringBuilder가 아닌 해당 함수를 감싼 클래스의 toString을 호출하고 싶다면 어떻게 해야되나? this@OuterClass.toString()와 같은 방식으로 적어주면 된다.

apply

apply는 with와 같다. 다만 항상 수신 객체를 반환한다. 그리고 확장함수로 정의되어 있다.

1
2
3
4
5
6
7
8
fun alphabet3(): String {
return StringBuilder().apply {
for (letter in 'a'..'z') {
append(letter)
}
append("\n alphabet end~ \n")
}.toString()
}

마지막으로 수신 객체 지정 람다를 사용하는 더 구체적인 예시로 buildString 같은 함수가 있다. 인자로 수신 객체 지정 람다를 받으며(매번 수신 객체는 StringBuilder로 고정), StringBuilder 객체 생성과 toString 호출을 알아서 해주는 녀석이다.

Share