017. Lambda

Lambda

Java는 버전 8부터 람다를 도입하였다.

이번 포스팅에선 람다의 유용함에 대해 학습하고, 코틀린에서의 사용법에 대해 알아보자.

코드 블록을 함수의 파라미터로 사용하기

프로그래밍을 하면서 코드에 특정 동작을 전달하거나 저장하는 작업은 꽤 자주하는 작업이다.

Java 8 이전에는 익명 내부클래스를 활용해 블록을 전달하는 방식의 구현이 가능했지만, 길고 복잡한 문법을 필요로 한다.

이를 간결하게 사용하기 위해 함수형 프로그래밍에선 함수 자체를 파라미터로 넘기는 방식을 지원하고 있다.

특정 함수의 동작을 위해 클래스를 선언하고, 해당 클래스에 함수를 정의하는 것이 아닌, 함수를 직접 전달하여 간결하게 작성하는 방식이다.

람다 표현식을 활용하면 좀 더 편하고 직관적으로 함수를 전달할 수 있다.

먼저 Java로 작성한 예제를 보자.

아래 코드는 특정 버튼을 눌렀을 때, 해당 이벤트를 처리하기 위해 OnClickListener를 추가하고, 해당 OnClickListeneronClick() 메소드를 호출하는 코드이다.

1
2
3
4
5
6
button.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View view) {
/* actions on click */
}
});

이때 익명의 OnClickLister 클래스를 onClick() 메소드를 실행하는 것이 자명하므로 객체의 생성을 생략할 수 있다.

1
button.setOnClickListener { /* actions on click */ }

람다와 콜렉션

람다는 익명 내부 클래스 객체의 대체 뿐만 아니라 콜렉션에서도 유용하게 쓰인다.

콜렉션을 이용해 수행하는 대부분의 코드는 일정한 패턴을 따르기 때문에 라이브러리에 구현 코드가 존재한다.

이 라이브러리가 람다를 이용해 어떻게 콜렉션에 대해 처리하고 있는 지 알아보자.

먼저 예제를 보자.

Person이라는 클래스가 있고, 해당 클래스는 nameage라는 프로퍼티를 가지고 있다.

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

만약 사용자들의 목록을 저장한 콜렉션인 List<Person>이 있다고 할때 이중 가장 나이가 많은 Person 객체를 찾아야 한다고 가정해보자.

만약 람다 표현식을 쓰지 않고 구현한다면 아래와 같이 반복문과 최대 나이값을 저장한 변수를 이용해 구현할 수 있을 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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)
Person(name=Bob, age=31)

이제 람다 표현식으로 제공되는 라이브러리 함수를 이용해 좀 더 간결하게 구해보자.

1
2
3
>>> val people = listOf(Person("Alice", 29), Person("Bob", 31))
>>> println(people.maxBy { it.age })
Person(name=Bob, age=31)

maxBy 함수는 최대값을 찾기 위해 모든 콜렉션에서 호출할 수 있는 함수이다.

it.age 코드는 최대값을 찾기 위해 구현한 람다 표현식이며, it을 통해 파라미터로 콜레션의 요소를 넘긴 뒤 비교를 진행한다.

만약 람다 표현식이 함수나 프로퍼티를 위임받아 처리하는 경우 아래와 같은 멤버 참조로 대체할 수 있다.

1
people.maxBy(Person::age)

람다 표현식의 문법

상술했듯, 람다를 이용하면 작은 행동 조각(=코드 블록)을 값으로 넘겨줄 수 있다.

이 람다는 독립적으로 선언된 후 변수를 통해 입력될 수도 있지만, 특정 기능으로 전달되면서 직접적으로 선언되는 경우가 더 잦다.

이 람다를 선언하기 위한 문법을 알아보자.

람다는 항상 코드 블록을 뜻하는 {}로 둘러싸여 있으며, 파라미터를 감싸는 ()는 존재하지 않는다.

그리고 ->를 이용해 실제 프로그램이 동작하는 메인 로직과 람다식을 구분한다.

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

위의 코드처럼 sum 상수에 특정 값을 저장할 수 있다.

1
2
>>> { println(42) }()
42

만약 직접적인 호출을 하려면 위와 같이 작성한다.

하지만 위의 코드는 읽을 수도 없고, 딱히 의미가 존재하지도 않는다.

위처럼 코드 블록 내에 코드를 작성하는 경우, 작성된 코드를 넘겨받는 라이브러리 함수 run을 사용할 수 있다.

1
2
>>> run { println(42) }
42

자 다시, List<Person> 중 가장 나이가 높은 Person을 찾는 코드로 돌아가보자.

1
2
3
>>> val people = listOf(Person("Alice", 29), Person("Bob", 31))
>>> println(people.maxBy { it.age })
Person(name=Bob, age=31)

it 구문을 사용하지 않고 직접적으로 접근하도록 다시 작성하면 아래와 같이 변경할 수 있다.

1
2
3
>>> val people = listOf(Person("Alice", 29), Person("Bob", 31))
>>> println(people.maxBy({ p: Person -> p.age })) // Case 1
Person(name=Bob, age=31)

위의 코드를 다시 살펴보면 {}로 작성된 부분은 람다 표현식으로 이 부분을 maxBy()에 파라미터로 전달한 것이다.

하지만 코드의 부호가 너무 많아서 가독성이 떨어지고, 결과값의 타입 또한 추론이 가능한 상태이며, 이 경우엔 람다 표현식의 파라미터명을 지정할 필요가 없다.

위 내용을 바탕으로 좀 더 개선해보자.

이 구문의 람다 표현식의 경우, 람다 표현식 자체가 유일한 파라미터 이므로 () 밖으로 빼낼 수 있다.

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

마찬가지로 유일한 파라미터이기 때문에 ()도 제거할 수 있다.

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

아래 세 가지 케이스는 모두 같은 것을 의미한다.

1
2
3
people.maxBy({ p: Person -> p.age }) // Case 1
people.maxBy() { p: Person -> p.age } // Case 2
people.maxBy { p: Person -> p.age } // Case 3

하지만 가독성을 따져 보았을 때, 세번째 케이스가 가장 읽기 쉽다.

위의 예처럼, 람다의 위치에 대한 특별한 제한이나 차이가 없으므로 람다가 유일한 파라미터인 경우엔 ()를 생략하고 쓰는 것이 가장 이상적이다.

반대로 유일한 파라미터가 아닌 경우엔 ()안에 람다를 배치하거나 바깥쪽에 위치시켜서 파라미터가 여러 개임을 강조하는 것도 좋은 방법이다.

이번엔 람다표현식을 이용해 joinToString() 메소드를 다시 살펴보자.

joinToString() 메소드는 코틀린 표준 라이브러리에 정의되어있는 메소드로, 컬렉션의 특정 값들을 하나의 문자열로 변환하는 데 사용할 수 있다.

1
2
3
4
5
>>> val people = listOf(Person("Alice", 29), Person("Bob", 31))
>>> val names = people.joinToString(separator = " ",
... transform = { p: Person -> p.name })
>>> println(names)
Alice Bob

똑같이 () 바깥으로 빼보면 아래와 같다.

1
people.joinToString(" ") { p: Person -> p.name }

위의 코드는 파라미터명을 이용해 람다의 용도를 좀 더 명확히 해준다.

반대로 아래 코드의 경우 람다의 용도를 명시하지 않기 때문에 호출되는 함수 자체에 익숙하지 않으면 조금 이해하기 어려울 것이다.

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

위의 코드처럼 구문을 단순화하는 것을 넘어 파라미터명 자체를 생략할 수도 있다.

지역변수와 마찬가지로 람다의 파라미터또한 타입을 추론할 수 있다면 별도로 명시할 필요는 없다.

위의 maxBy() 함수의 경우 파라미터의 유형은 항상 콜렉션의 요소 타입과 동일하다.

1
people.maxBy { it.age }

기본 파라미터명인 it 은 파라미터명을 명시하지 않은 경우에만 자동으로 생성된다.

람다를 특정 변수에 저장하는 경우, 해당 타입을 추론하는 문맥을 가져올 수 없으므로, 명시적으로 지정해주어야 한다.

아래 코드로 확인해보자.

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

지금까지 하나의 람다표현식이나 의미로만 구성된 람다 예시를 살펴보았다.

하지만 아래 코드처럼 람다는 작은 크기에 국한되지 않고 여러 개의 의미를 지니도록 표현될 수 있다.

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

현재 스코프내 변수에 대한 접근

함수 내 익명 내부 클래스를 선언할 때, 해당 클래스의 내부에서 호출되는 함수에 대한 파라미터 및 지역 변수에 대한 참조가 가능하다.

람다에서도 똑같은 동작을 수행할 수 있는데, 표준 라이브러리 함수를 이용해 확인해보도록 하자.

이번에 확인해볼 표준 라이브러리 함수는 forEach() 함수이다.

아래 코드는 forEach() 함수를 이용해 메시지 목록을 가져온 뒤, 동일한 접두어를 사용해 출력하는 예제이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
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

아래 코드는 위와 동일하게 forEach() 함수를 이용해 클라이언트 및 서버의 오류 발생 수를 세는 예제이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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")
}

/* 출력 결과 */
>>> val responses = listOf("200 OK", "418 I'm a teapot",
... "500 Internal Server Error")
>>> printProblemCounts(responses)
1 client errors, 1 server errors

코틀린은 Java와 달리 람다에서 final이 아닌 변수에 접근하고 수정할 수도 있다.

중요한 점은 람다가 비동기적으로 사용되지 않는 경우 로컬 변수에 대한 수정이 람다의 실행 시점에만 발생한다는 것이다.

그래서 아래 코드는 잘못된 코드이다.

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

이 함수는 항상 0을 반환한다.

onClick()이 동작하더라도 이미 값을 반환한 후에 호출되며, 이를 올바르게 사용하려면 clicks 변수는 외부에 선언하여 참조하도록 작성해야 한다.

Member references

만약 파라미터로 넘겨야하는 코드가 이미 함수로 정의된 경우엔 어떻게 활용할 수 있을까?

코틀린에서는 :: 연산자를 활용해 함수를 값으로 변경하는 기능을 제공한다.

1
val getAge = Person::age

이러한 표현식을 멤버 참조(Member references)라고 하며, 단 하나의 메소드를 호출하거나 프로퍼티에 접근하는 함수를 생성할 때 필요한 짧은 문법을 제공해준다.

람다로 동일한 표현식을 작성하면 아래와 같다.

1
val getAge = { person: Person -> person.age }

단 멤버 참조의 경우 () 괄호를 이름 뒤에 붙여선 안된다.

추가적으로 람다와 동일한 구성을 할 수 있으므로 서로 대치할 수 있다.

1
people.maxBy(Person::age)

도한 최상위에 선언된 함수를 참조할 수도 있다.

아래 코드를 보자.

1
2
3
4
5
fun salute() = println("Salute!")

/* 출력 결과 */
>>> run(::salute)
Salute!

위의 경우 최상위이기 때문에 클래스명을 생략하고 ::를 통해 접근할 수 있다.

이때 멤버 참조 ::Salute는 라이브러리 함수 run()의 파라미터로 전달된다.

1
2
3
4
val action = { person: Person, message: String ->
sendEmail(person, message)
}
val nextAction = ::sendEmail

이번엔 생성자 자체를 참조하여 객체의 생성 작업을 저장하거나 연기시켜 작업할 수도 있다.

아래 코드를 통해 확인해보자.

1
2
3
4
5
6
7
data class Person(val name: String, val age: Int)

/* 출력 결과 */
>>> val createPerson = ::Person
>>> val p = createPerson("Alice", 29)
>>> println(p)
Person(name=Alice, age=29)

클래스에 대한 참조가 가능하니, 확장 함수에 대한 참조도 물론 가능하다.

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