[Chapter05] 람다로 프로그래밍
Kotlin In Action을 공부하며 작성한 글입니다.
혼자 공부하고 정리한 내용이며, 틀린 부분은 지적해주시면 감사드리겠습니다 😀
람다 식과 멤버 참조
람다 식(lambda expression) 또는 람다는 기본적으로 다른 함수에 넘길 수 있는 작은 코드 조각을 의미한다.
1. 람다 소개 : 코드 블록을 함수 인자로 넘기기
람다 식을 사용하면 함수를 선언할 필요가 없고, 코드 블록을 직접 함수의 인자로 전달할 수 있다. 람다의 등장 전에는 아래 코드와 같이 무명 내부 클래스를 이용했다.
button.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View view) {
doSomething();
}
})
위 코드를 람다를 사용한 식으로 변경하면 다음과 같이 바꿀 수 있다.
button.setOnClickListener { doSomething() }
자바 무명 클래스에 비해 훨씬 간결하고, 읽기 쉽다.
2. 람다와 컬렉션
코드에서 중복을 제거하는 것은 DRY(Do not Repeat Yourself) 원칙이 있을 정도로 프로그래밍 스타일을 개선하는 방법 중 하나이다. 컬렉션을 처리할 일이 있을 경우 대부분 비슷한 로직을 사용해 처리한다.
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)
}
위 코드와 같이 최대값 혹은, 특정한 값을 얻기 위해서는 컬렉션을 순회하며 값을 도출하는 코드를 작성해야 한다. 이런 이유로 컬렉션에는 사용자가 쓰기 편하도록 다양한 기능을 제공한다. 위 코드를 람다를 사용한 코드로 수정해보자.
fun main() {
val people = listOf(
Person("Alice", 29), Person("Sponge", 52), Person("Bob", 97)
)
println(people.maxBy { it.age })
}
이전 코드에 비해 훨씬 간결하며, 사용하기도 편하다. maxBy
함수는 가장 큰 원소를 찾기 위해 비교에 사용할 값을 돌려주는 함수를 인자로 받으며, it
은 그 인자를 가리킨다. 혹은 멤버 참조를 사용해 조금 더 직관적으로 작성할 수 있다.
println(people.maxBy(Person::age))
이렇게 람다 혹은 멤버 참조를 이용하면 코드 다이어트가 가능하며, 이해하기 쉬워진다.
3. 람다 식의 문법
람다는 값처럼 여기저기 전달할 수 있는 동작의 모음이다. 람다를 따로 선언해서 변수에 저장할 수도 있다. 우선 람다 식을 선언하기 위한 문법을 살펴보자.
{x: int, y: Int -> x + y}
위 코드에서 x, y를 선언한 부분은 파라미터라 부르며, x + y는 본문을 나타낸다. 즉, 화살표인 ->
을 기준으로 인자 목록과 람다 본문을 구분해준다. 그러면 앞서 말한 람다 식을 변수에 저장해보자.
val sum = {x: Int, y: Int -> x + y}
println(sum(1, 2))
이렇게 람다가 저장된 변수를 다른 일반 함수와 마찬가지로 괄호를 통해 인자를 넣어 호출할 수 있다. 실행 시점에 코틀린 람다 호출에는 아무 부가 비용이 들지 않으며, 프로그램의 기본 구성 요소와 비슷한 성능을 낸다. 코틀린에서 람다를 작성하는 방법은 다양하다.
// 정식 람다 방식
people.maxBy ({ p: Person -> p.age })
// 람다 블록 분리
people.maxBy() { p: Person -> p.age }
// 빈 소괄호 제거
people.maxBy { p: Person -> p.age }
// 타입 추론
people.maxBy { it.age }
이전에 작성했던, joinToString
을 개선해보자.
fun main() {
val names = people.joinToString(" ") { it.name }
println(names)
}
private fun <T> Collection<T>.joinToString(
separator: String = ", ",
prefix: String = "",
postfix: String = "",
block: ((T) -> String)? = null
): String {
val result = StringBuilder(prefix)
for ((idx, element) in this.withIndex()) {
if (idx > 0) result.append(separator)
if(block != null) {
result.append(block(element))
} else {
result.append(element)
}
}
result.append(postfix)
return result.toString()
}
위와 같이 람다를 넘겨 원하는 원소만 toString
으로 뽑아낼 수 있다. 간혹 컴파일러가 람다 파라미터의 타입을 추론하지 못하는 경우도 있지만, 처음에는 쓰지 않고, 람다를 작성하고, 컴파일러가 타입을 추론하지 못할 때만 명시하도록 하자.
joinToString
의 호출부를 살펴보면,it.name
을 넘겨준 것을 볼 수 있다. 람다의 depth가 깊어질 경우에는 it을 남용하다가 원하는 결과를 못받을 수 있다. 때문에 it이 아닌 원소에 걸맞는p -> p.name
과 같이 파라미터 이름을 지정하고 사용하는 것이 좋다.
람다의 본문이 여러 줄로 이뤄진 경우 본문의 맨 마지막에 있는 식이 람다의 결과 값이 된다.
val sum= {x: Int, y: Int ->
println("Computing the sum of $x and $y")
x + y
}
4. 현재 영역에 있는 변수에 접근
람다를 함수 안에서 정의하면 함수의 파라미터뿐 아니라 람다 정의의 앞에 선언된 로컬 변수까지 람다에서 모두 사용할 수 있다.
fun printMessageWithPrefix(messages: Collection<String>, prefix: String) {
messages.forEach { msg ->
println("$prefix $msg")
}
}
fun main() {
val errors = listOf("403 Forbidden", "404 Not Found")
printMessageWithPrefix(errors, "Error :")
}
위 코드처럼 forEach
에서 함수의 파라미터 사용이 가능하다. 자바와 다른 점 중 중요한 한 가지는 코틀린 람다 안에서는 final
변수가 아닌 변수에 접근할 수 있고, 람다 안에서 바깥 변수를 변경할 수 있다.
public class CollectionTestJava {
public static void main(String[] args) {
List<String> lists = List.of("403 Forbidden", "404 Not Found");
String prefix = "Error :";
lists.forEach(msg -> {
System.out.printf("%s %s", prefix, msg);
});
}
}
자바 8에서만 사용 불가능하며, 이후 자바 버전에서는 final
이 아닌 변수에 접근할 수 있다.
다시 본론으로 돌아와 아래 코드는 람다 안에서 바깥 함수의 로컬 변수를 변경하는 코드이다.
fun printProblemCounts(response: Collection<String>) {
var clientErrors = 0
var serverErrors = 0
response.forEach { res ->
if(res.startsWith("4")) clientErrors++
else if(res.startsWith("5")) serverErrors++
}
println("Client Errors : $clientErrors, Server Errors : $serverErrors")
}
이렇게 람다 안에서 사용하는 외부 변수를 '람다가 포획(capture)한 변수'라고 부른다. 함수를 쓸모 있는 일급 시민으로 만드려면 포획한 변수를 제대로 처리해야 하고, 포획한 변수를 제대로 처리하려면 클로저(closure)가 꼭 필요하다. 그래서 람다를 클로저라고도 부른다.
클로저란, 람다에서 시작하는 모든 참조가 포함된 닫힌(closed) 객체 그래프를 코드와 함께 저장하는 데이터 구조를 의미한다.
일급 시민이란, 특정 권한이나 제약 없이 취급되는 개체를 의미한다. 변수에 값을 할당 가능하며, 인자로 전달 가능하고, 데이터 구조 안에 저장할 수 있어야 한다.
5. 멤버 참조
앞에서 잠깐 언급이 되었는데, 말 그대로 클래스의 멤버(프로퍼티)를 참조하는 것을 의미한다.
val ageProperty = Person::age
위와 같이 멤버를 참조할 경우 ageProperty
의 타입은 KProperty1<Person, Int>
가 된다. 이는 리플렉션을 사용해 클래스의 특정 필드를 지정할 수 있으며, val
로 선언할 경우 KProperty1
, var
로 선언할 경우 KMutableProperty1
로 타입이 지정된다.
이렇게 이중 콜론(::)을 사용하는 식을 멤버 참조(member reference)라고 부른다. 여기서 Person은 클래스를 나타내며, age는 해당 클래스 내부에 있는 멤버 필드를 나타낸다. 앞서 봤던 maxBy
함수에서 람다로 넘겨주는 값은 특정 프로퍼티였다. 때문에 아래 코드로도 람다를 사용할 수 있는 것이다.
people.maxBy(Person::age)
이렇게 클래스 멤버를 지칭할 수도 있고, 다른 클래스의 멤버가 아닌 최상위에 선언된 함수나 프로퍼티를 참조할 수도 있다. 최상위 프로퍼티를 멤버 참조로 사용할 경우 클래스 이름을 생략하고 바로 이중 콜론(::)을 사용한다.
fun salute() = println("Salute!")
fun main() {
run(::salute)
}
이러한 참조는 멤버 프로퍼티 뿐만 아니라 생성자 참조(constructor reference)도 가능하다. 이를 사용하면, 클래스 생성 작업을 연기하거나, 저장해둘 수 있다.
val action = { p: Person, msg: String -> sendEmail(p, msg) }
fun sendEmail(p: Person, msg: String) {
println("""
-----------
To. ${p.name}
$msg
-----------
""".trimIndent())
}
fun main() {
val personConstructor = ::Person
action(personConstructor("전청조", 28), "나 전청조인데 개추 눌렀다.")
}
바운드 멤버 참조
코틀린 1.0에서는 멤버 참조를 하기 위해 인스턴스 객체를 항상 제공해야했지만, 코틀린 1.1 부터는 바운드 멤버 참조를 지원한다.
val p = Person("Dmitry", 34)
// 1.0 기존 멤버 참조 방식
val personAgeFunction = Person::age
println(personAgeFunction(p))
// 1.1 바운드 멤버 참조 방식
val personAgeFunction = p::age
println(personAgeFunction())