본문 바로가기
  • GDG on campus Ewha Tech Blog
3-1기 스터디/Kotlin

[10주차] 람다로 프로그래밍(4)

by Joong 2022. 1. 16.

 


Ch05 람다로 프로그래밍

 

📌 학습목표

• 람다 식과 멤버 참조
• 함수형 스타일로 컬렉션 다루기
• 시퀀스: 지연 컬렉션 연산
• 자바 함수형 인터페이스를 코틀린에서 사용
• 수신 객체 지정 람다 사용

람다 식(lambda expression) 또는 람다는 다른 함수에 넘길 수 있는 작은 코드 조각을 뜻한다. 람다를 사용하면 공통 코드 구조를 라이브러리 함수로 뽑아낼 수 있다.



5.1 람다 식과 멤버 참조

5.1.1 람다 소개: 코드 블록을 함수 인자로 넘기기

  • 함수형 프로그래밍에서는 함수를 값처럼 다룬다. 또한, 클래스를 선언하고 그 클래스의 인스턴스를 함수에 넘기는 것이 아니라 함수 자체를 다른 함수에 전달한다.
    → 이때, 람다 식을 사용하면 함수를 선언할 필요가 없고 코드 블록을 직접 함수의 인자로 전달할 수 있기 때문에 코드가 더욱 간결해진다.

 

Ex) 버튼 클릭 시 특정 이벤트 발생

button.setOnClickListener(new OnClickListener() {
  @Override
  public void onClick(View view) {
    /* 클릭 시 수행할 동작 */
  }
});

→ 무명 내부 클래스 선언하느라 코드가 번잡하다.

button.setOnClickListener { /* 클릭 시 수행할 동작 */ }

 람다를 메소드가 하나뿐인 무명 객체 대신 사용할 수 있다.


5.1.2 람다와 컬렉션

Ex) 사람의 이름과 나이를 저장하는 Person 클래스를 사용하고, 사람들로 이루어진 리스트에서 연장자를 찾는 예제

fun findTheOldest(people: List<Person>) {
 var maxAge = 0
 var theOldest: Person ?= null
 for (person in people) {
   if (person.age > maxAge) {
     masAge = person.age
     theOldest = person
   }
 }
 println(theOldest)
}
>>> val people = listOf(Person("Alice", 29), Person("Bob", 31))
>>> findTheOldest(people)
Person(name=Bob, age=31)

→ 람다를 사용하지 않는다면, 위와 같이 루프를 사용해 직접 검색을 구현할 것이다.

  • 코틀린에서는 더 좋은 방법이 있다. 라이브러리 함수를 사용하자!
>>> val people = listOf(Person("Alice", 29), Person("Bob", 31))
>>> println(people.maxBy { it.age }) //age 프로퍼티를 비교해 값이 가장 큰 원소 찾기
Person(name=Bob, age=31)

→ 모든 컬렉션에 대해 maxBy 함수를 호출할 수 있는데, maxBy는 가장 큰 원소를 찾기 위해 비교에 사용할 값을 돌려주는 함수를 인자로 받는다.
{ it.age }는 바로 비교에 사용할 값을 돌려주는 함수이며, 컬렉션의 원소를 인자로 받아 (it이 그 인자를 지칭) 비교에 사용할 값을 리턴한다. 위의 예제에서 { it.age }가 반환하는 값은 Person 객체의 age 필드에 저장된 나이 정보다.

  • 단지 함수나 프로퍼티를 반환하는 역할을 하는 람다는 멤버 참조로 대치할 수 있다.
people.maxBy(Person::age)

→ 이 코드는 바로 위의 코드와 같은 일을 한다.


5.1.3 람다 식의 문법

{ x: Int, y: Int -> x + y }
  • 람다는 값처럼 전달할 수 있으며, 람다를 따로 선언해서 변수에 저장할 수도 있다. 하지만 함수에 인자로 넘기면서 바로 정의하는 경우가 대부분이다.
  • 람다식은 항상 중괄호로 둘러싸여 있으며, 인자 목록 주변에는 괄호가 없다. 화살표가 인자 목록과 람다 본문을
    구분해준다.

 

  • 람다 식을 변수에 저장할 수도 있다. 람다가 저장된 변수를 다른 일반 함수와 마찬가지로 다룰 수 있다.
    예를 들어, 변수 이름 뒤에 괄호를 쓰고 그 안에 인자를 넣어 람다를 호출할 수 있다.
>>> val sum = { x: Int, y: Int -> x + y }
>>> println(sum(1, 2)) //변수에 저장된 람다를 호출
3



>>> run { println(42) }
42

→ 코드의 일부분을 블록으로 둘러싸 실행할 필요가 있다면 run을 사용한다. run은 인자로 받은 람다를 실행해주는 라이브러리 함수이다.

  • 위의 예제 중 people.maxBy { it.age }에서 코드를 줄여 쓴 부분을 제거하고 정식으로 람다를 작성하면
    다음과 같다.

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

→ 여기서 문맥 상 컴파일러가 유추할 수 있는 인자 타입은 굳이 적어줄 필요 없고, 인자가 단 하나뿐인 경우 굳이 인자에 이름을 붙이지 않아도 된다. 또한, 함수 호출 시 맨 뒤에 있는 인자가 람다식이면 그 람다를 괄호 밖으로 뺴낼 수 있다.

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

→ 람다가 어떤 함수의 유일한 인자이고 괄호 뒤에 람다를 썼다면 호출 시 빈 괄호를 없애도 된다.

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




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

  • 람다를 함수 안에서 정의하면, 함수의 파라미터뿐 아니라 람다 정의의 앞에 선언된 로컬 변수까지 람다에서 모두 사용할 수 있다.
    Ex) 함수 파라미터를 람다 안에서 사용하는 예제
fun printMessagesWithPrefix(messages: Collection<String>, prefix: String) {
  messages.forEach { //각 원소에 대해 수행할 작업을 람다로 처리
    println("$prefix $it") //람다 안에서 함수의 'prefix' 파라미터 사용
  }
}
>>> val errors = listOf("403 Forbidden", "404 Not Found")
>>> printMessagesWithPrefix(errors, "Error:")
Error: 403 Forbidden
Error: 404 Not Found

 

  • 코틀린에서는 자바와 달리 람다에서 람다 밖 함수에 있는 파이널이 아닌 변수에 접근할 수 있고,
    그 변수를 변경할 수도 있다.
    Ex) 람다 안에서 바깥 함수의 로컬 변수 변경하는 예제
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

 

  • 주의) 람다를 이벤트 핸들러나 다른 비동기적으로 실행되는 코드로 활용하는 경우, 함수 호출이 끝난 후
    로컬 변수가 변경될 수 있다.
    Ex)
fun tryToCountButtonClicks(button: Button): Int {
  var clicks = 0
  button.onClick { clicks++ }
  return clicks
}

→ 항상 0을 return한다. onClick 핸들러는 tryToCountButtonClicks가 clicks를 반환한 다음에 호출되기 때문에, clicks 값의 변화를 알 수 없다. 제대로 구현하려면 클릭 횟수를 세는 카운터 변수를 클래스의 프로퍼티나 전역 프로퍼티 위치로 빼야 한다.


5.1.5 멤버 참조

람다를 사용해 코드 블록을 다른 함수에게 인자로 넘기는 방법 말고, 넘기려는 코드가 이미 함수로 선언된 경우 그 함수를 직접 넘길 수는 없을까?

  • 코틀린에서는 이중 콜론(::)을 사용해 함수를 값으로 바꿀 수 있다.
val getAge = Person::age

 

  • :: 를 사용하는 식을 멤버 참조(member reference)라고 하며, 멤버 참조는 프로퍼티나 메소드를 하나만
    호출하는 함수 값을 만들어 준다.
  • 멤버 참조는 그 멤버를 호출하는 람다와 같은 타입이므로, 다음의 예처럼 둘을 자유롭게 바꿔 쓸 수 있다.
people.maxBy(Person::age)
people.maxBy { p -> p.age }
people.maxBy { it.age }

 

  • 최상위에 선언된 함수나 프로퍼티를 참조할 수도 있다.
fun salute() = println("Salute!")
>>> run(::salute) //최상위 함수 참조
Salute!

→ 클래스 이름을 생략하고 ::로 바로 참조를 시작한다.

  • 생성자 참조(constructor reference)를 사용하면 클래스 생성 작업을 연기하거나 저장해둘 수 있다. 
    :: 뒤에 클래스 이름을 넣으면 생성자 참조를 만들 수 있다.
data class Person(val name: String, val age: Int)
>>> val createPerson = ::Person //Person의 인스턴스를 만드는 동작을 값으로 저장
>>> val p = createPerson("Alice", 29)
>>> println(p)
Person(name=Alice, age=29)




5.2 컬렉션 함수형 API

컬렉션을 다루는 코틀린 표준 라이브러리 몇가지를 배워보자! filter와 map, 그리고 이 둘을 뒷받침하는 개념에 대해서

 

5.2.1 필수적인 함수: filter와 map

  • filter 함수는 컬렉션을 iteration하면서 주어진 람다에 각 원소를 넘겨 true를 반환하는 원소만 모은다.
data class Person(val name: String, val age: Int)
>>> val list = listOf(1, 2, 3, 4)
>>> println(list.filter { it % 2 == 0 }) //짝수만 filter
[2, 4]

→ 주어진 조건(술어)를 만족하는 원소만 걸러 새로운 컬렉션을 만든다.

  • filter 함수는 컬렉션에서 원치 않는 원소를 제거할 때 쓰지만 원소를 변환할 수는 없어서 변환하려면 map 함수를 사용해야 한다.
    → map 함수는 주어진 람다를 컬렉션의 각 원소에 적용한 결과를 모아 새 컬렉션을 만든다.
>>> val list = listOf(1, 2, 3, 4)
>>> println(list.map { it * it }) //제곱값으로 변경
[1, 4, 9, 16]

 

>>> val people = listOf(Person("Alice", 29), Person("Bob", 31))
>>> println(people.map { it.name }) //people이 아닌 people의 name list 출력
[Alice, Bob]

 

→ 멤버 참조를 사용하면 더욱 간결하게 작성이 가능하다.

people.map(Person::name)

 

  • 연쇄적으로 호출해 30살 이상인 사람의 이름을 출력해보자!
>>> people.filter { it.age > 30 }.map(Person::name)

 

  • 가장 나이 많은 사람의 이름을 출력해보자!
    → list에 있는 사람들의 나이의 최댓값을 구하고 나이가 구한 최댓값과 같은 모든 사람의 이름을 return
people.filter { it.age == people.maxBy(Person::age)!!.age }

→ list에서 최댓값을 구하는 연산을 사람 수 만큼 반복한다는 단점이 존재한다.

val maxAge = people.maxBy(Person::age)!!.age
people.filter { it.age == maxAge }

→ 위의 코드를 개선해 최댓값을 한 번만 계산하도록 수정

  • filter와 변환 함수를 map에 적용할 수도 있다.
>>> val numbers = mapOf(0 to "zero", 1 to "one")
>>> println(numbers.mapValues { it.value.toUpperCase() })
{0=ZERO, 1=ONE}

 map은 key와 value를 처리하는 함수가 따로 존재한다.
filterKeys, mapKeys: key를 걸러내거나 변환
filterValues, mapValues: value를 걸러내거나 변환



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

all, any: 컬렉션의 모든 원소가 어떤 조건을 만족하는지 판단하는 연산
count: 조건을 만족하는 원소의 개수를 반환하는 연산
find: 조건을 만족하는 첫 번쨰 원소를 반환하는 연산



Ex) 어떤 사람의 나이가 27살 이하인지 판단하는 함수인 canBeInClub27

val canBeInClub27 = { p: Person -> p.age <= 27 }

 

  1. all
>>> val people = listOf(Person("Alice", 27), Person("Bob", 31))
>>> println(people.all(canBeInClub27))
false

→ people의 모든 원소가 canBeInClub27 술어를 만족하는지 판단하기 위해 all 사용

  1. any
>>> println(people.any(canBeInClub27)
true

→ people의 원소 중 canBeInClub27을 만족하는 원소가 하나라도 있는지 판단하기 위해 any 사용


드 모르간의 법칙에 의해, 어떤 조건에 대해 !all을 수행한 결과와 그 조건의 부정에 대해 any를 수행한 결과는 같다. 마찬가지로, 어떤 조건에 대해 any를 수행한 결과와 그 조건의 부정에 대해 !all을 수행한 결과 또한 같다.

>>> val list = listOf(1, 2, 3)
>>> println(!list.all { it == 3 }) //list의 모든 원소가 3인 것은 아니다. (= 적어도 하나의 원소는 3이 아니다.)
true
>>> println(list.any{ it != 3 }) // any를 사용하면 술어를 부정해야 한다 (!=)
true



  1. count
>>> val people = listOf(Person("Alice", 27), Person("Bob", 31))
>> println(people.count(canBeInClub27))
1

→ people의 원소 중 canBeInClub27을 만족하는 원소의 개수를 구하기 위해 count 사용

  1. find
>>> val people = listOf(Person("Alice", 27), Person("Bob", 31))
>>> println(people.find(canBeInClub27))
Person(name=Alice, age=27)

→ canBeInClub27을 만족하는 원소를 하나 찾기 위해 find 사용
 find는 조건을 만족하는 원소가 하나라도 있으면 그 원소를 반환하고, 만족하는 원소가 없을 경우 null을 반환한다.
따라서 find는 firstOrNull과 같다.


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

컬렉션의 모든 원소를 어떤 특성에 따라 여러 그룹으로 분류하고 싶을 때 사용하는 groupBy

 

  • groupBy: 특성을 파라미터로 전달하면 자동으로 컬렉션을 구분해주는 함수
>>> 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)]}

→ groupBy 함수 연산의 결과는 컬렉션의 원소를 구분하는 특성 (= age)이 key이고, key 값에 따른 각 그룹(= Person 객체의 모임)이 value인 map이다.
→ 출력 결과에서 각 그룹은 list이고, groupBy의 결과 타입은 Map<Int, List>이다.


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

Ex)

class Book(val title: String, val authors: List<String>)
books.flatMap { it.authors }.toSet() //books 컬렉션에 있는 책의 모든 저자들의 집합



  • flatMap: 인자로 주어진 람다를 컬렉션의 모든 객체에 적용(맵핑)하고, 람다를 적용해 얻은 여러 list를 한 list로 모은다(flatten).
>>> val strings = listOf("abc", "def")
>>> println(strings.flatMap { it.toList() })
[a, b, c, d, e, f]

→ abc, def에 toList를 적용하면 문자열을 구성하는 모든 문자로 이루어진 list가 만들어지므로, a, b, c로 이루어진 list와 d, e, f로 이루어진 list가 만들어진다.
→ 다음으로, flatMap 함수는 list에 들어있던 모든 원소로 구성된 단일 list를 반환한다.


>>> val books = listOf(Book("Thursday Next", listOf("Jasper Fforde")),
...                    Book("Mort", listOf("Terry Pratchett")),
...                    Book("Good Omens", listOf("Terry Pratchett",
...                                              "Neil Gaiman")))
>>> println(books.flatMap { it.authors }.toSet())
[Jasper Fforde, Terry Pratchett, Neil Gaiman]

 books.authors 프로퍼티는 작가를 모아둔 컬렉션이고, flatMap 함수는 모든 책의 작가를 flat(문자열만으로 이루어진)한 하나의 list로 모은다. toSet은 flatMap의 결과로 만들어진 list에서 중복을 없애고 집합으로 만들기 때문에, 최종 출력 부분에서는 Terry Pratchett 작가가 한 번만 포함된다.

  • 모든 중첩된 list의 원소를 한 list로 모을 때는 flatMap을 사용하고, 반환할 내용이 없다면 list의 list(중첩된 list)를 펼치기만 하면 되므로, 이런 경우 flatten 함수를 사용한다.




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

  • map이나 filter 같은 함수는 컬렉션을 즉시 생성하기 때문에, 여러 컬렉션 함수를 연쇄로 사용할 경우 매 단계의 중간 결과를 새로운 컬렉션에 담는다. 이때, sequence를 사용하면 중간 임시 컬렉션을 사용하지 않고 연쇄적으로 컬렉션 연산을 수행할 수 있다.

Ex)

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

→ 위 예제는 두 개의 list가 생성되는데, 한 list는 filter의 결과를, 다른 list는 map의 결과를 담는다.

people.asSequence() //원본 컬렉션을 sequence로 변환
    .map(Person::name)
    .filter { it.startsWith("A") }
    .toList() //결과 sequence를 다시 list로 변환

→ 앞의 예제를 더욱 효율적으로 만들기 위해, 컬렉션을 직접 사용하는 대신 sequence를 사용하도록 수정하자.
→ 이 예제에서처럼 sequence를 사용하면 중간 컬렉션이 생기지 않기 때문에 원소가 많은 경우 성능이 훨씬 좋아진다.

  • sequence의 원소는 필요할 때 계산이 되기 때문에 중간 처리 결과를 저장하지 않아도 연산을 연쇄적으로 적용해 계산을 수행할 수 있다.
  • asSequence 함수를 호출하면 컬렉션을 sequence로 변환할 수 있고, sequence를 list로 변환할 때는 toList를 사용한다.




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

  • sequence 연산은 중간(intermediate) 연산과 최종(terminal) 연산으로 나뉜다.
  • 중간 연산은 다른 시퀀스를 반환하고, 최종 연산은 결과를 반환한다. (여기서 결과는 중간 연산을 통해 얻은
    컬렉션이나 원소, 숫자 또는 객체다)

Ex)

>>> listOf(1, 2, 3, 4).asSequence()
...           .map { print("map($it) "); it * it }
...           .filter { print("filter($it) "); it % 2 == 0 }
...           .toList()
map(1) filter(1) map(2) filter(4) map(3) filter(9) map(4) filter(16)
  • 컬렉션에 대해서는 map 함수를 각 원소에 대해 먼저 수행해 새 sequence를 얻은 후 그 sequence에 대해 filter를 수행한다. 하지만 sequence의 경우에는 모든 연산이 각 원소에 대해 순차적으로 적용된다. 다시 말해, 첫 번째 원소가 처리된 후 그 다음 원소로 넘어간다. 그러므로 연산을 차례대로 적용하다 앞의 원소에서 결과가 얻어지면 그 이후의 원소에 대해서는 연산이 이루어지지 않을 수 있다.




5.3.2 시퀀스 만들기

  • sequnce를 생성할 때 asSequence()를 호출하는 방법말고 다른 방법으로 generateSequence 함수를 사용할 수 있다. generateSequence 함수는 이전 원소를 인자로 받아 다음 원소를 계산한다.

 

>>> val naturalNumbers = generateSequence(0) { it + 1 }
>>> val numbersTo100 = naturalNumbers.takeWhile { it <= 100 }
>>> println(numbersTo100.sum())
5050

→ naturalNumbers와 numbersTo100은 모두 sequence이며, 지연 계산으로 인해 최종 연산(여기서는 sum)을 수행하기 전까지 sequence의 각 숫자는 계산되지 않는다.



5.4 자바 함수형 인터페이스 활용

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

  • 인자로 함수형 인터페이스를 원하는 자바 메소드에 코틀린 람다를 전달할 수 있다.

Ex)

/* 자바 */
void postponeComputation(int delay, Runnable computation);
postponeComputation(1000) { println(42) }

→ 코틀린에서 postponeComputation 함수에 람다를 인자로 넘길 수 있고, 컴파일러는 람다를 Runnable 인스턴스로 변환한다. 이때 Runnable 인스턴스 Runnable을 구현한 무명 클래스의 인스턴스이다.

  • Runnable을 구현한 무명 객체를 명시적으로 만들어 사용할 수도 있다.
postponeComputation(1000, object : Runnable { //객체 식을 함수형 인터페이스 구현으로 넘긴다
    override fun run() {
        println(42)
    }
})

 

  • 람다와 무명 객체는 다르다. 객체를 명시적으로 선언하면 메소드를 호출할 때마다 새로운 객체가 생성되는 반면, 람다의 경우 람다에 대응하는 무명 객체를 메소드를 호출할 때마다 반복 사용한다. (위 예시의 경우, 람다를 사용하면 Runnable의 인스턴스는 단 하나만 생성된다.)

  • 객체를 명시적으로 선언하면서 람다와 동일하게 작성할 수도 있다. 이 경우 Runnable 인스턴스를 변수에
    저장하고 메소드를 호출할 때마다 그 인스턴스를 사용한다.
val runnable = Runnable { println(42) }
fun handleComputation() {
    postponeComputation(1000, runnable) //handlecomputation이 호출될 때마다 같은 객체 사용
}

 

  • 그러나 매 호출마다 같은 인스턴스를 사용할 수 없는 경우에는, 컴파일러가 매번 주변 영역의 변수를 포함한
    새로운 인스턴스를 생성한다.
    Ex)
fun handleComputation(id: String) { //람다 안에서 변수 id를 포획
    postponeComputation(1000) { println(id) } //handleComputation을 호출할 때마다 Runnable 인스턴스를 새로 생성
}

→ handleComputation이 호출될 때마다 id를 필드로 저장하는 새로운 Runnable 인스턴스를 새로 생성해 사용한다.


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

  • SAM 생성자: 람다를 함수형 인터페이스의 인스턴스로 변환할 수 있게 컴파일러가 자동으로 생성한 함수
    컴파일러가 람다를 함수형 인터페이스 무명 클레스로 자동으로 바꾸지 못하는 경우에 사용한다.

Ex) 함수형 인터페이스의 인스턴스를 반환하는 메소드가 있으면 람다를 직접 반환할 수 없고 반환하려는 람다를 SAM 생성자로 감싸야 한다.

fun createAllDoneRunnable(): Runnable {
    return Runnable { println("All done!") }
}
>>> createAllDoneRunnable().run()
All done!

→ SAM 생성자의 이름은 사용하려는 함수형 인터페이스의 이름과 동일하다.

  • 람다로 생성한 함수형 인터페이스 인스턴스를 변수에 저장해야 하는 경우에도 사용할 수 있다.
    Ex) SAM 생성자를 활용해 람다를 함수형 인터페이스 인스턴스로 만들어 변수에 저장해 여러 버튼에 같은
    리스너 적용
val listener = onClickListener { view ->
    val text = when (view.id) {
      R.id.button1 -> "First button"
      R.id.button2 -> "Second button"
      else -> "unknown button"
    }
  toast(text)
}
button1.setOnClickListener(listener)
button2.setOnClickListener(listener)




댓글