- 람다식 또는 람다는 기본적으로 다른 함수에 널길 수 있는 작은 코드 조각을 의미한다.
- 람다를 사용하면 쉽게 공통 코드 구조를 라이브러리 함수로 뽑아낼 수 있다.
- 람다가 무엇인지 알아보자
- 람다 함수의 전형적인 사용 패턴을 보고 코틀린에서 그런 사용 패턴의 모습을 논의해보자
- 멤버 참조와 람다의 관계를 살펴보자
- 람다를 자바 API나 라이브러리와 함께 사용하는 방법을 살펴보자
- 함수형 인터페이스를 사용하는 방법을 살펴보자
- 수신 객체 지정 람다를 알아보자
5.1 람다식과 멤버 참조
5.1.1 람다 소개: 코드 블록을 값으로 다루기
- 코드에서 일련의 동작을 변수에 저장하거나 다른 함수에 넘겨야 하는 경우가 자주 있다.
- 익명 내부 클래스를 사용하면 코드를 함수에 넘기거나 변수에 저장할 수 있기는 하지만 상당히 번거롭다.
- 이 문제를 해결하는 다른 접근 방법: 함수를 값처럼 다루기
- 클래스를 선언하고 그 클래스의 인스턴스를 함수에 넘기는 대신, 함수를 직접 다른 함수에 전달
- 람다식을 사용하면 함수를 선언할 필요가 없고 실질적으로 코드 블록을 직접 함수의 인자로 전달할 수 있다.
- 함수를 값으로 다루고 행동을 표현하기 위해 함수를 조합하는 접근 방법은 함수형 프로그래밍의 기둥이된다.
- 함수형 프로그래밍 특성
- 일급 시민인 함수: 함수를 값으로 다룰 수 있다.
- 불변성: 객체를 만들 때 일단 만들어진 다음에는 내부 상태가 변하지 않음을 보장하는 방법으로 설계할 수 있다.
- 부수 효과(side effect) 없음: 함수가 똑같은 입력에 대해 같은 출력을 내놓고(함수가 입력 인자에 의존) 다른 객체나 외부 세계의 상태를 변경하지 않게 구성한다.
- 람다식이 도움되는 예제: 이벤트 발생 시의 이벤트 핸들러
- object 선언으로 리스너 구현
button.setOnClickListener(object: OnClickListener{ override fun onClick(v: View){ println("I was clicked!") } });
- 코틀린 람다로 고쳐쓴 코드5.1.2 람다와 컬렉션
button.setOnClickListener { println("I was clicked!") }
- 사람의 이름과 나이를 저장하는 Person 클래스
data class Person(val name: String, val age: Int)
- 사람들로 이뤄진 리스트에서 그 중 가장 나이가 많은 사람 찾기
- 컬렉션을 for 루프로 직접 검색
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)
}
fun main() {
val people = listOf(Person("Alice", 29), Person("Bob", 31))
findTheOldest(people)
//Person(name=Bob, age=31)
}
- 표준 라이브러리 함수를 사용
- maxByOrNull 함수를 사용
fun main() {
val people = listOf(Person("Alice", 29), Person("Bob", 31))
println(people.maxByOrNull{ it.age })
//Person(name=Bobm, age=31)
}
- 모든 컬렉션에 대해 maxByOrNull 함수를 호출할 수 있다.
- maxByOrNull 함수는 인자를 하나만 받고 가장 큰 원소를 찾기 위해 비교에 사용할 값을 돌려주는 함수다.
- { it.age }는 이 선택자 로직을 구현한다. 선택자는 어떤 원소를 인자로 받아 비교에 사용할 값을 반환
- 람다가 인자를 하나만 받고 그 인자에 구체적 이름을 붙이고 싶지 않기 때문에 it이라는 암시적 이름을 사용한다.
- 람다가 단순히 함수나 프로퍼티에 위임할 경우에는 멤버 참조를 사용
people.maxByOrNull(Person::age)
5.1.3 람다식의 문법
{ x: Int, y: Int -> x + y }
// x: Int, y: Int => 파라미터 리스트
// 화살표(->) => 파라미터 리스트와 람다 본문 구분
// x + y => 람다 본문
- 람다식을 변수에 저장할 수 있고, 람다가 지정된 변수를 다른 일반 함수와 마찬가지로 다룰 수 있다.
fun main(){
val sum = { x: Int, y: Int -> x + y }
println(sum(1, 2))
}
fun main() {
{ println(42) }()
// 42
}
- 람다식을 직접 호출하는 구문은 읽기 어렵고 쓸모도 없다.
- 람다를 만들자마자 바로 호출하느니 람다 본문의 코드를 직접 실행하는 편이 낫다.
- run은 인자로 받은 람다를 실행해주는 라이브러리 함수다.
fun main() {
run { println(42) }
//42
}
- 식이 필요한 부분에서 코드 블록을 직접 실행하고 싶을 때 run이 아주 유용하다.
val myFavoriteNumber = run {
println("I'm thinking!")
println("I'm doing some more work...")
}
- 코틀린이 코드를 줄여쓸 수 있게 했던 기능을 제거한 람다의 정식 작성 형식
people.maxByOrNull({ p:Person -> p.age })
- 람다의 정식 작성 형식의 코드는 번잡하다. 구분자가 많아 가독성이 떨어진다.
- 컴파일러가 문맥으로부터 유추할 수 있는 인자 타입을 굳이 적을 필요는 없다.
- 마지막으로 인자가 단 하나뿐인 경우 굳이 인자에 이름을 붙이지 않아도 된다.
- 코틀린에는 함수 호출 시 맨 뒤에 있는 인자가 람다식이라면 그 람다를 괄호 밖으로 빼낼 수 있다
people.maxByOrNull() { p: Person -> p.age }
- 람다가 어떤 함수의 유일한 인자이고 괄호 뒤에 람다를 썼다면 호출 시 빈 괄호를 없애도 된다.
people.maxByOrNull { p: Person -> p.age }
- 함수에 인자로 주어지는 람다가 둘 이상인 경우 둘 이상의 람다를 괄호 밖으로 빼낼 수는 없다
- 그런 경우에는 괄호 안에 모든 람다를 넣는 편이 더 낫다.
- 로컬 변수처럼 컴파일러는 람다 파라미터의 타입도 추론할 수 있다. 람다 파라미터 타입을 명시할 필요가 없다.
- maxByOrNull 함수의 경우 파라미터의 타입은 항상 컬렉션 원소 타입과 같다.
people.maxByOrNull { p -> p.age }
- 파라미터 중 일부 타입은 지정하고 나머지 파라미터는 타입을 지정하지 않고 이름만 남겨둘 수도 있다.
- 람다의 파라미터가 하나 뿐이고 타입을 컴파일러가 추론할 수 있는 경우 디폴트 이름인 it를 쓸 수 있다.
people.maxByOrNull{ it.age }
- 람다를 변수에 저장할 때는 파라미터의 타입을 추론할 문맥이 존재하지 않기 때문에 파라미터 타입을 명시해야 한다.
val getAge = { p: Person -> p.age }
- 꼭 람다 본문이 한 문장의 식으로 이뤄져있는 것은 아니다.
- 람다 본문이 여려 문장의 식이나 명령으로 이뤄질 수 있다.
fun main() {
val sum = { x: Int, y: Int ->
println("Computing the sum of $x and $y...")
x + y // 명시적인 return 생략
}
println(sum(1, 2))
// Computing the sum of 1 and 2...
// 3
}
5.1.4 현재 영역에 있는 변수 접근: 람다의 변수 캡처
- 함수 안에 익명 내부 클래스(object 키워드를 이용한 객체 식)를 선언하면 그 클래스 안에서 함수의 파라미터와 로컬 변수를 참조할 수 있다.
- 마찬가지로 람다를 함수 안에서 정의하면 함수의 파라미터뿐 아니라 람다 정의보다 앞에 선언된 로컬 변수까지 람다에서 모두 사용할 수 있다.
fun printMessagesWithPrefix(messges: Collection<String>, prefix: String){
messages.forEach {
println("$prefix $it") // 람다 안에서 함수의 prefix 파라미터를 사용한다.
}
}
fun main(){
val errors = listOf("403 Forbidden", "404 Not Found")
printMessagesWithPrefix(errors, "Error: ")
}
- 코틀린과 자바 람다의 다른 점 중 중요한 한 가지는 코틀린 람다 안에서는 파이널 변수가 아닌 변수에 접근이 가능하다는 것이다.
- 자바 람다의 경우엔 람다 외부의 파이널 변수가 아닌 변수에는 접근이 불가능하다.
- 코틀린에서는 람다 안에서 람다 바깥의 변수에 접근하여 값을 변경해도 된다.
fun printProblemCounts(responses: Collection<String>){
var clientErrors = 0
var serverErrors = 0
responses.forEach{
if(it.startsWith("4")){
clientErrors++
}else if(it.startsWith("5")){
serverErrors++
}
}
println("$clientErrors client errors, $serverErrors server errors")
}
fun main(){
val responses = listOf("200 OK", "418 I'm a teapot",
"500 Internal Server Error")
printProblemCounts(responses)
}
- 람다 안에서 접근할 수 있는 외부 변수를 '람다가 캡처한 변수'라고 부른다.
- 원래 기본적으로 함수 안에서 정의된 로컬 변수의 생명주기는 함수가 반환되면 끝난다.
- 하지만 어떤 함수가 자신의 로컬 변수를 캡처한 람다를 반환하거나 다른 변수에 저장한다면 로컬 변수의 생명주기와 함수의 생명주기가 달라질 수 있다.
- 캡처한 변수가 있는 람다를 저장해서 함수가 끝난 뒤에 실행해도 람다의 본문 코드는 여전히 캡처한 변수를 읽거나 쓸 수 있다.
- 어떻게 이것이 가능한 것일까?
- 람다가 만약 파이널 변수를 캡처한 경우에는 파이널 변수의 값을 복사하여 람다 코드와 함께 저장하고
- 파이널이 아닌 변수를 캡처한 경우에는 해당 변수를 특별한 래퍼로 감싸서 나중에 변경하거나 읽을 수 있게 한 다음, 해당 래퍼에 대한 참조를 람다 코드와 함께 저장하기 때문에 이것이 가능하다
- 코틀린에서는 파이널이 아닌 변수를 캡처할 시 해당 변수를 특별한 래퍼로 감싸기 때문에 자바와 달리 파이널이 아닌 변수를 캡처하여 람다 코드에서 접근하고 변경할 수 있었던 것이다.
- 자바는 특별한 래퍼로 파이널이 아닌 변수를 감싸지 않기 때문에 파이널이 아닌 변수는 캡처가 불가능한 것이다.
5.1.5 멤버 참조
- 넘기려는 코드가 이미 함수로 선언된 경우엔 멤버 참조를 사용할 수 있다.
val getAge = Person::age
- ::을 사용하는 식을 멤버 참조라고 부른다.
- 멤버 참조는 정확히 한 메서드를 호출하거나 한 프로퍼티에 접근하는 함수 값을 만들어준다.
- 최상위 선언된 함수나 프로퍼티를 참조할 수도 있다.
fun salute() = println("Salute!")
fun main {
run(::salute)
// Salute!
}
- 람다 인자가 여럿인 다른 함수에게 작업을 위임하는 경우 멤버 참조를 제공하면 아주 편리
val action
= { person: Person, message: String: String -> sendEmail(person, message)}
// 이 람다는 sendEmail 함수에 작업을 위임
val nextAction = ::sendEmail
- 생성자 참조를 사용하면 클래스 생성 작업을 연기하거나 저장해둘 수 있다.
data class Person(val name: String, val age: Int)
fun main() {
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
5.1.6 값과 엮인 호출 가능 참조
- 값과 엮인 호출 가능 참조를 사용하면 같은 멤버 참조 구문을 사용해 특정 객체 인스턴스에 대한 메서드 호출 참조를 만들 수 있다.
fun main(){
val seb = Person("Sebastian", 26)
val personsAgeFunction = Person::age
println(personAgeFunction(seb))
// 26
val sebsAgeFunciton = seb::age // { seb.age }라는 람다와 같다
println(sebsAgeFunciton())
}
5.2 자바의 함수형 인터페이스 사용: 단일 추상 메서드
- 코틀린 람다는 자바 API와도 완전히 호환된다.
/* 자바 */
public interface OnClickListener {
void onClick(View view);
}
/* 자바 */
public class Button {
public void setOnClickListener(OnClickListener l){...}
}
fun main(){
val button = Button()
button.setOnClickListener { view -> ... }
// { view -> ...} 람다는 onClick처럼 유일한 파라미터로 View 타입 값을 받는다.
}
- OnClickListener 인터페이스 안에 추상메서드가 단 하나뿐이어서 이런 코드가 가능하다.
- 이런 인터페이스를 함수형 인터페이스나, 단일 추상 메서드(SAM, Single Abstract Method) 인터페이스라고 부른다.
- 자바 API에는 Runnable, Callable 등의 함수형 인터페이스로 가득 차 있고, 이들을 활용하는 메서드도 많다.
- 코틀린은 함수형 인터페이스를 파라미터로 받는 자바 메서드를 호출할 때 람다를 사용할 수 있게 해준다.
5.2.1 람다를 자바 메서드의 파라미터로 전달
- 함수형 인터페이스를 파라미터로 받는 모든 자바 메서드에 코틀린 람다를 전달할 수 있다.
/*자바*/
void postponeComptation(int delay, Runnable computation);
- 컴파일러는 자동으로 람다를 Runnable의 인스턴스로 변환해준다.
postponeComputation(100){println(42)}
- 'Runnable의 인스턴스'는 함수형 인터페이스 Runnable을 구현하는 익명 클래스의 인스턴스를 뜻한다.
- 컴파일러는 익명 클래스의 인스턴스를 만들고 람다를 그 인스턴스의 유일한 추상 메서드의 본문으로 만들어준다.
- Runnable을 명시적으로 구현하는 익명 객체를 만들어 똑같은 효과를 낼 수 있다.
postponeComputaion(100, object: Runnable{
override fun run(){
println(42)
}
})
- 명시적으로 객체를 정의하는 경우엔 정의할 때마다 매번 그에 따른 새 인스턴스가 생기지만
- 람다를 사용하는 경우엔 상황에 따라 달라진다.
- 람다가 자신이 정의된 함수의 변수에 접근하지 않는다면 람다가 정의된 함수를 호출될 때마다 람다에 해당하는 익명 객체가 재사용된다.
postponeComputation(100) { println(42) } // 전체 프로그램에 Runnable 인스턴스가 하나만 만들어진다.
- 람다가 자신을 둘러싼 환경의 변수를 캡처하면 더 이상 각각의 함수 호출에 같은 인스턴스를 재사용할 수 없다.
- 이런 경우 컴파일러는 호출마다 새로운 인스턴스를 만들고 그 객체 안에 캡처한 변수를 저장한다.5.2.2 SAM 변환: 람다를 함수형 인터페이스로 명시적 변환
fun handleComputation(id: String){ postponeComputation(1000){ // 호출마다 새 Runnable 인스턴스가 만들어진다. println(id) // id를 캡처한다. } }
- 컴파일러가 람다를 단일 추상 메서드 인터페이스(함수형 인터페이스, SAM)의 인스턴스로 자동 변환 해주지 못하는 경우가 있다.
- 예를 들면 함수에서 함수형 인터페이스의 인스턴스를 반환하려는 경우 람다를 직접 반환할 수 없다.
- 이런 상황에서 사용할 수 있는 것이 SAM 생성자, 즉 단일 추상 메서드 인터페이스 인스턴스 생성자이며 람다를 SAM 생성자로 감싸야 한다.
- SAM 생성자는 컴파일러가 생성한 함수로 람다를 단일 추상 메서드 인터페이스의 인스턴스로 명시적으로 변환해준다.
fun createAllDoneRunnable() : Runnable {
return Runnable{ println("All done!") } // Runnable SAM 생성자
}
fun main(){
createAllDoneRunnable().run()
// All done!
}
- SAM 생성자의 이름은 사용하려는 함수형 인터페이스의 이름과 같다.
- SAM 생성자는 하나의 인자(함수형 인터페이스의 유일한 추상 메서드의 본문에 사용될 람다)만을 받아 함수형 인터페이스를 구현하는 클래스의 인스턴스를 반환한다.
- 람다로 생성한 함수형 인터페이스의 인스턴스를 변수에 저장해야 하는 경우에도 SAM 생성자를 사용할 수 있다.
val listener = OnClickListenr { view -> // OnClickListenr SAM 생성자
val text = when (view.id){
button1.id -> "First button"
button2.id -> "Second button"
else -> "Unknown button"
}
toast(text);
} // OnClickListenr 구현 클래스의 인스턴스 반환
button1.setOnClickListener(listener)
button2.setOnClickListener(listener)
- object 키워드를 이용해 OnClickListener를 구현하는 객체 선언을 통해 리스너를 만들 수도 있지만 SAM 생성자를 쓰는 쪽이 더 간결하다.
- 가끔 오버로드한 메서드 중에서 어떤 타입의 메서드를 선택해 람다를 변환해 넘겨줘야 할지 모호한 때가 있는데, 그런 경우 명시적으로 SAM 생성자를 적용하면 컴파일 오류를 피할 수 있다.
5.3 코틀린에서 SAM 인터페이스 정의: fun interface
- 코틀린에서 fun interface를 선언하면 커스텀 함수형 인터페이스를 선언할 수 있다.
- 코틀린의 함수형 인터페이스는 정확히 하나의 추상 메서드만 포함하지만 다른 비추상 메서드를 여럿 가질 수 있다.
fun interface IntCondition {
fun check(i: Int) : Boolean // 추상 메서드
fun checkString(s: String) = check(s.toInt()) // 비추상 메서드
fun checkChar(c: Char) = check(c.digitToInt()) // 비추상 메서드
}
fun main(){
val isOdd = IntCondition { it % 2 != 0 } // IntCondition SAM 생성자
println(isOdd.check(1))
// true
println(isOdd.checkString("2"))
// false
println(isOdd.check('3'))
// true
}
- fun interface라고 정의된 타입의 파라미터를 받는 함수가 있을 때 람다 구현이나 람다에 대한 참조를 직접 넘길 수 있다.
- 두 경우 모두 동적으로 인터페이스 구현을 인스턴스화해준다.
fun checkCondition(i: Int, condition: IntCondition): Boolean {
return condition.check(i)
}
fun main(){
checkCondition(1){ it % 2 != 0 } // 람다 구현을 직접 넘김
// true
val isOdd: (Int) -> Boolean = { it % 2 != 0 }
checkCondition(1, isOdd)
// IntCondition 단일 추상 메서드와 시그니처가 일치하는 람다에 대한 참조 넘김
// true
}
5.4 수신 객체 지정 람다: with, apply, also
- 코틀린 표준 라이브러리의 with, apply, also 함수
- 수신 객체를 명시하지 않고 람다의 본문 안에서 다른 객체(수신 객체)의 메서드를 호출할 수 있게 하는 기능
- 수신 객체 지정 람다
5.4.1 with 함수
fun alphabet(): String {
val stringBuilder = StringBuilder()
return with(stringBuilder){ // 여기부터 with 함수의 두 번째 인자에 해당하는 람다
for(letter in 'A' .. 'Z'){ // 수신 객체를 명시하지 않은 람다 본문
this.append(letter)
}
this.append("\nNow I know the alphabet!")
this.toString() // with 함수의 결과 반환
}
}
- with 함수는 파라미터가 2개인 함수이다.
- with 함수는 첫 번째 인자로 받은 객체를 두 번째 인자로 받은 람다의 수신 객체로 만든다.
- 람다 안에서 명시적인 this 참조를 사용해 그 수신 객체에 접근할 수도 있고 this를 생략해 메서드나 프로퍼티 이름만 사용해 접근할 수도 있다.
fun alphabet(): String {
val stringBuilder = StringBuilder()
return with(stringBuilder){ // 여기부터 with 함수의 두 번째 인자에 해당하는 람다
for(letter in 'A' .. 'Z'){ // 수신 객체를 명시하지 않은 람다 본문
append(letter) // 생략된 this는 stringBuilder
}
append("\nNow I know the alphabet!")
toString() // with 함수의 결과 반환
}
}
fun alphabet() = with(StringBuilder()){
for(letter in 'A' .. 'Z'){ // 수신 객체를 명시하지 않은 람다 본문
append(letter) // this는 stringBuilder
}
append("\nNow I know the alphabet!")
toString() // with 함수의 결과 반환
}
5.4.2 apply 함수
apply 함수는 with와 동일하게 작동하는데, 대신 항상 자신에게 전달된 객체, 즉 수신 객체를 반환한다는 것이 중요하다.
apply를 임의의 타입의 확장 함수로 호출할 수 있고 apply를 호출한 객체는 apply에 전달된 람다의 수신 객체가 된다.
fun alphabet() = StringBuilder().apply{
for(letter in 'A' .. 'Z'){
append(letter)
}
append("\nNow I know the alphabet!")
}.toString()
- 인스턴스를 만들면서 즉시 프로퍼티 중 일부를 초기화해야하는 경우
apply가 유용한데 자바에서는 보통 별도의 Builder 객체가 이런 역할을 담당한다.
apply를 객체 초기화에 활용하는 예: 안드로이드의 TextView 컴포넌트를 만들면서 속성 설정
fun createViewWithCustomAttributes(context: Context)=
TextView(context).apply{
text = "Sample Text"
textSize = 20.0
setPadding(10, 0, 0, 0)
}
- buildString 함수를 이용하면 alphabet 함수를 더 단순화할 수 있다.
- buildString 함수의 인자는 수신 객체 지정 람다이며, 수신 객체는 항상 StringBuilder가 되고 buildString 함수의 반환 타입은 String이다.
fun alphabet() = buildString {
for(letter in 'A' .. 'Z'){
append(letter)
}
append("\nNow I know the alphabet!")
}
- 코틀린 라이브러에는 읽기 전용 타입인 List, Set, Map을 생성 과정에서는 가변 컬렉션인 것처럼 다루고 싶을 때 도움이 되는 컬렉션 빌더 함수가 들어있다.
- 컬렉션 빌더 함수들은 수신 객체 지정 람다를 인자로 받는다.
val fibonacci = buildList {
addAll(listOf(1, 1,2))
add(3)
add(index = 0, element = 3)
}
val sholdAdd = true
val fruits = buildSet {
add("Apple")
if(shouldAdd){
addAll(listOf("Apple", "Banana", "Cherry"))
}
}
val meals = buildMap<String, Int>{
put("Gold", 1)
putAll(listOf("Silver" to 2, "Bronze" to 3))
}
5.4.3 객체에 추가 작업 수행: also
- also 함수도 apply 함수와 마찬가지로 수신 객체를 받으며 그 수신 객체에 대해 어떤 동작을 수행한 후 수신 객체를 돌려준다.
- 단 also 함수의 람다 안에서는 수신 객체를 인자로 참조한다는 점이다.
- 따라서 파라미터 이름을 부여하거나 디폴트 이름인 it 를 사용해야 한다
fun main(){
val fruits = listOf("Apple", "Banana", "Cherry")
val uppercaseFruits = mutableListOf<String>()
val reversedLongFruits = fruits
.map { it.uppercase() }
.also { uppercaseFruits.addAll(it) }
.filter { it.length > 5 }
.also { println(it) }
.reversed()
}