kotlin in action
을 보고 정리한 글입니다.
lambda expression은 lambda(람다)라고 부르기도 하며, 함수에 넘길 수 있는 어떤 코드 조각을 말한다. 람다를 사용하면 라이브러리 함수를 이용해 공통 부분을 쉽게 추출할 수 있고 각 라이브러리 함수에서 람다를 많이 활용하기도 한다.
자바 8에서 람다의 등장은 언어적 진화라고 불릴만큼 큰 영향을 가져왔다. 람다는 문법적으로 어떤 부분에서 장점을 가지고 있을까.
데이터 구조의 모든 요소에 이 함수를 적용해라
,
어떤 상황이 발생했을때 이 핸들러를 실행해라
와 같은 실무에서 많이 볼 수 있는 요구 사항들에서 보듯이,
어떤 행동을 하도록 코드를 작성하거나 다른 행동 주체에게 코드 자체를 넘기는 것은 굉장히 자주 일어나는 일이다.
자바에서 이러한 요구사항을 anonymous inner classes를 이용해 구현할 수 있었지만, 많은 코드의 양을 작성해야 한다.
함수형 프로그래밍은 함수를 값으로 다루는 아이디어를 통해 이런 문제를 해결했다. 클래스로 인스턴스를 만들어 함수에 넘기는게 아니라 함수 자체를 다른 함수에 넘긴다. 이를 통해서 코드가 정확해지고 가다듬어진다.
버튼 클릭에 이벤트 리스너를 할당하는 예시를 보자.
button.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View view) {
/* actions on click */
}
}
이제 anonymous inner class를 익명 클래스
라고 하겠다(영어로 쓰기에 너무 길다..).
익명 함수를 계속해서 사용하는 것은 문법적으로 지루하고 번거로운 일이다. 버튼을 클릭했을때 어떤 동작을 해야 하는지 좀 더 명료하게 나타낼 수는 없을까?
답은 람다 표현식이다.
button.setOnClickListener { /* actions on click */ }
코틀린에서는 자바에서 익명 클래스를 사용하는 것과 같은 결과를 주지만 좀 더 명료하고 읽기도 쉽다.
이제 collections를 이용해 람다를 사용하는 고전적인 방법들을 보자.
중복을 피하는 것은 좋은 프로그래밍 스타일의 주요 신조이다. 그렇기에 collections를 이용해 사용하는 많은 로직들은 일정 패턴들이 존재할 확률이 높고 이러한 작업들은 라이브러리를 사용해야 한다.
람다가 없이는 collections를 사용하는 간편한 라이브러리들을 만들기가 어렵다.
data class Person(val name: String, val age: Int)
가장 나이가 많은 사람을 가져올 때 람다를 사용하지 않고 해보자.
fun findTheOldest(people: List<Person>) {
var maxAge = 0
var theOldest: Person? = null
for (person in people) {
if (person.age > maxAge) {
maxAge = person.age
theOldest = person
}
}
println(theOldest)
}
val people = listOf(Person("Alice", 29), Person("Bob", 31))
findTheOldest(people)
많은 코드를 작성했다. 이제 람다 표현식을 사용해 작성해보자.
val people = listOf(Person("Alice", 29), Person("Bob", 31))
println(people.maxBy { it.age })
maxBy
함수는 collection을 대상으로 하나의 argument사용한다. 이 함수는 어떤 값을 비교해서 가장 큰 값을 가진 element를 뽑아내야 하는지에 대해 명세한다.
{ it.age }는 람다식을 구현한 것이고 lambda implementing이라고 한다. 주로 it
라는 값을 사용하며 비교해야 할 값을 반환한다.
그저 람다표현식이 함수나 property에게 위임한다면 member reference로 위 람다식을 대체할 수 있다.
다음 코드를 보자
people.maxBy(Person::age)
람다는 어떤 행동을 내부에 정의하고 있고 어떤 값을 받아들인다. 이때 값을 받아들일 수 있는 행동
은 변수에 할당할 수 있고 독립적으로 선언될 수도 있지만 보통 람다표현식을 사용할 때 바로 정의해서 넘기는 편이다.
코틀린에서 람다표현식의 문법은 항상 {}로 감싸져야 한다. arguments는 ()로 감싸지 않는다는 점에 주목하자.
val sum = { x: Int, y: Int -> x + y }
println(sum(1,2))
// 3
자바스크립트의 IFFY (즉시실행함수) 처럼 다음 처럼 즉시 실행 람다 표현식을 작성할 수도 있다.
{ println(42) }()
하지만 좀 더 나은 문법이 있다.
run { prointln(42) }
참고로 위와 같은 방법은 런타임 오버헤드가 없다.
앞서서 maxBy 예시 코드의 람다표현식을 축약문을 제외하고 작성한다면 다음과 같다.
people.maxBy({ p: Person -> p.age })
하지만 너무 많은 괄호가 있어 가독성이 낮고, 타입은 추론될 수 있으므로 굳이 명시적으로 작성하지 않아도 된다.
다양한 람다 표현식에 대해 알아보자.
단일 람다표현식을 함수 호출에 넘겨야 할 때 람다 표현식의 {}를 () 밖으로 뺄 수 있다.
people.maxBy() { p: Person -> p.age }
람다표현식이 단일 인자를 받는다면 다음과 같이 () 자체를 없앨 수 있다.
people.maxBy { p: Person -> p.age }
람다를 named argument에 넘기는 문법을 살펴보자.
val people = listOf(Person("Alice", 29), Person("Bob", 31))
val names = people.joinToString(separator= " ", transform = { p: Person -> p.name })
println(names)
// Alice Bob
() 밖으로 람다 표현식을 빼보자
people.joinToString(" ") { p: Person -> p.name }
신기하다!
마지막으로 가장 축약된 람다표현식은 parameter까지도 없애는 것이다. 이 때 사용되는 it는 default parameter라고 불리며 오직 람다 표현식의 한 개의 인자만 받을 때 사용한다. 이 때 it의 타입은 자동 추론된다.
people.maxBy { it.age }
한 가지 유의할 점은 it를 사용하는 것이 문법적으로 간편하나 남용하면 안된다는 것이다. 중첩 람다 표현식에서는 it를 가르키는 context가 불분명하게 비춰질 수 있기에 각 람다 표현식을 명시적으로 작성하는게 좋다.
람다표현식을 변수에 할당한다면, 타입 추론을 할 수 있는context가 존재하지 않기 때문에 명시적으로 타입을 작성해야 한다.
val getAge = { p: Person -> p.age }
PTSD가 오는 것 같은데, 자바스크립트의 클로저 처럼 람다표현식 안에서 표현식 밖에 있는 지역 변수를 참조할 수 있다.
다음은 forEach 내부에 있는 람다표현식에서 파라메터로 넘어온 prefix를 참조하고 있는 모습이다.
fun printMessagesWithPrefix(messages: Collection<String>, prefix: string) {
messages.forEach {
println("$prefix $it")
}
}
val errors = listOf("403 Forbidden", "404 Not Found")
printMessagesWithPrefix(errors, "Error:")
Error: 403 Forbidden
Error: 404 Not Found
코틀린에서는 final 변수에도 접근할 수 있으며 final이 아닌 변수는 변경이 가능하다.
이때 람다 표현식 외부에서 람다 표현식에게 사용되는 것을 captured by lambda라고 한다.
기본적으로 지역 변수의 생명주기는 변수가 선언된 함수에 의해 결정되지만, 람다에 의해 capture된 변수는 그렇지 않다. 람다표현식에서 final 변수를 capture한다고 가정할 때 이 때 변수는 람다 코드와 함께 저장된다.
final 변수가 아니라면, 변수는 특별한 wrapper 안에 가둬진다. 그리고 그 wrapper가 람다와 함께 저장된다.
코틀린에서 mutable variable을 캡쳐하고 싶으면 다음의 방법을 사용한다.
class Ref<T>(var value: T)
val counter = Ref(0)
val inc = { counter.value ++ }
위에서 class Ref는 mutable variable을 캡처링하는 wrapper를 만든것이고 이에 대한 instance를 만들어 람다표현식에서 사용했다.
실제 코드에서는 이렇게 매번 wrapper를 만들 필요 없이 로컬 변수로 만들면 된다.
var counter = 0
var inc = { counter++ }
예외적인 케이스로 람다 표현식이 event handler이거나 비동기적으로 동작한다면, 로컬 변수의 변경은 람다가 실행될때만 이뤄진다.
다음은 잘못 작성된 예시다.
fun tryToCountButtonClicks(button: Button): Int {
var clicks = 0
button.onClick { clicks ++ }
return clicks
}
이 함수는 항상 0을 리턴한다. onClick handler가 clicks
를 변경한다고 하더라도, 변수 변경이 이뤄지지 않는다. 왜냐하면 onClick 핸들러는 함수가 0을 반환한 이후 실행되기 때문이다.
이를 구현하는 올바른 방법은 함수 내부의 변수를 외부로 옮기는 것이다.
이제 함수로 reference를 전달하는 방법에 대해 알아보자.
이미 정의된 함수를 다른 함수의 인자로 넘기려면 어떻게 해야 하는가?
member reference에 대해 알아보자.
val getAge = Person::age
Person에 이미 정의된 메소드나 property를 사용하는 것이다. ::
는 double colon이라고 부르는데 메소드나 property 접근에 사용한다.
member reference는 람다표현식과 동일한 타입이므로 다음과 같이 사용할 수 있다.
people.maxBy(Person::age)
다음과 같은 top-level function의 경우 다음과 같이 축약할 수 있다.
fun salute() = println("Salute!")
run(::salute)
// Salute!
member reference는 여러 개의 인자를 받는 함수로 위임하는 람다 표현식 대신에 사용하면 편하다.
val action = { person: Person, message: String -> sendEmail(person, message) }
val nextAction = ::sendEmail
constructor referenc
를 사용해서instance 생성을 lazy하게 할 수 있다.
data class Person(val name: String, val age: Int)
val createPerson = ::Person
val p = createPerson("Alice", 29)
println(p)
// Person(name= Alice, age = 29)
extension function도 가능하다.
fun Person.isAdult() = age >= 21
val predicate = Person::isAdult
앞서 알아본 람다표현식을 실제로 어떻게 쓰는지 눈여겨 보면서 퀵하게 짚고 넘어가자.
filter, map은 새로운 collection을 반환한다.
data class Person(val name: String, val age: Int)
val list = listOf(1, 2, 3, 4)
println(list.filter { it % 2 == 0 })
// [3, 4]
val people = listOf(Person("Alice", 29), Person("Bob", 31))
println(people.map { it.name })
// [Alice, Bob]
다음 처럼 체이닝을 할 수도 있다.
people.filter { it.age > 30 }.map(Person::name}
다음은 가장 연장자인 사람을 구하는 로직이다. 직관적이다.
people.filter { it.age == people.maxBy(Person::age).age }
하지만 다음의 방법은 100명의 사람이 있을 때 10000번의 연산을 수행하기 때문에 적절하지 않다.
람다 표현식은 간단하지만 위와 같은 성능 저하 포인트를 놓칠 수 있다는 위험성이 있다. 위의 코드는 아래와 같이 수정할 수 있다.
val maxAge = people.maxBy(Person::age).age
people.filter { it.age == maxAge }
!all은 any로 대신할 수 있다.
count와 size가 다른점이 무엇일까?
println(people.filter(canBeInClub27).size)
// 1
이 경우 predicate를 충족시키는 요소에 대해 새로운 collection이 생성된다. count는 이와 다르게 element의 갯수만 추적하기 때문에 더 효율적이다.
특정 조건에 따라 collection 내부의 요소를 그룹으로 나누고 싶을 때 사용한다.
val people = listOf(Person("Alice", 31), Person("Bob", 29), Person("Carol", 31))
println(people.groupBy { it.age })
/**
{29=[Person(name=Bob, age=29)],
31=[Person(name=Alice, age=31), Person(name=Carol, age=31)]}
*/
리턴 타입은 Map<Int, List<Person>>
이며, 다음과 같이 첫 글자에 따라 그룹으로 나눌 수도 있다.
val list = listOf("a", "ab", "b")
println(list.groupBy(String::first))
// {a=[a, ab], b=[b]}
class Book(val title: String, val authors: List<String>)
저자들만 모아서 보고 싶으면 다음과 같이 한다.
books.flatMap { it.authors }.toSet()
flatMap 함수는 주어지는 람다표현식에 따라 map 연산을 하며 여러 리스트를 하나로 합친다.
println(strings.flatMap { it.toList() })
// [a, b, c, d, e, f]
IIFY?
IIFE 아닌가용?