[Kotlin in action] 5장: 람다로 프로그래밍

7 분 소요

🙂 이전부터 차근차근 읽고 있었던 ‘Kotlin in action’ 책을 본격적으로 정리하고자 한다.

단순히 읽는 것으로는 오래 기억되지 않을 것 같아 두고두고 볼 수 있도록 기록 해야겠다!!


👩 람다

람다? 다른 함수에 넘길 수 있는 작은 코드 조각

🙋‍♀️ 람다식

{x: Int, y: Int -> x+y}

  • 항상 중괄호 사이에 식이 위치한다.

run{ println(42) }

  • 람다를 직접 호출하는 것보다 run을 사용해 람다 본문을 실행하는 것이 더 좋다.
val sum = {x: Int, y: Int ->
  println("Computing the sum of $x and $y...")
  x+y //결과 값
  }
  • 여러 줄의 람다식에서는 마지막 식이 결과 값이 된다.


🙋‍♀️ 람다와 멤버 참조(::)

함수의 인자로 다음의 람다식을 넘기는 경우, {p -> p.age}, 함수가 중복된다는 것을 알 수 있다. 이럴 때 사용할 수 있는 것이 바로 멤버 참조이다.

Person::age

🙌 생성자 참조

  • 생성자 참조를 통해서 클래스의 생성을 연기/저장할 수 있다.
  data class Person(val name: String, val age: Int)
  ...
  val createPerson = ::Person // 생성자 참조
  val p = createPerson("Alice", 23) // Person(name=Alice, age=23)


👩 컬렉션 함수형 API

지금부터 컬렉션을 다루는 코틀린 표준 라이브러리 중 몇 가지를 살펴보고자 한다.

🙋‍♀️ filter & map

  • filter: 주어진 람다에 만족하는 원소만을 모아 반환
      val list = listOf(1, 2, 3, 4)
      println(list.filter{it % 2 == 0}) // 2, 4
    
    • 함수의 단 하나의 인자가 람다일 경우 밖으로 꺼내 작성할 수 있다.
  • map: 모든 원소에 적용한 결과를 모아 새 컬렉션으로 만들어 반환
      val list = listOf(1, 2, 3, 4)
      println(list.map{it*it}) // [1, 4, 9, 16]
    
  • 두 연산을 연쇄시킬 수도 있다.
      val list = listOf(1, 2, 3, 4)
      print(list.filter{it % 2 == 0}.map{it * it}) // [4, 16]
    
  • 컬렉션 map에서의 filter, map
    • filterKeys / filterValues: 각각 맵의 키와 값을 걸러내는 역할
    • mapKeys / mapValues: 각각 맵의 키와 값을 변환하는 역할
      val numbers = mapOf(0 to "zero", 1 to "one")
      println(numbers.mapValues{it.value.toUpperCase()}) // {0 = ZERO, 1 = ONE}
    


🙋‍♀️ all & any & count & find

  • all / any: 각각 컬렉션에서 조건을 모두/일부 만족하는 지 판단
  • count: 컬렉션에서 조건을 만족하는 원소의 개수를 반환
  • find: 컬렉션에서 조건을 만족하는 첫 번째 원소를 반환
    • 해당하는 원소가 없을 경우, null을 반환한다. (= findOrNull)
  val canBeInClub27 = {p: Person -> p.age <= 27} // 술어 함수
  val people = listOf(Person("Alice", 27), Person("Bob", 31))

  println(people.all(canBeInClub27)) // false
  println(people.any(canBeInClub27)) // true
  println(people.count(canBeInClub27)) // 1
  println(people.find(canBeInClub27)) // Person("Alice", 27)


🙋‍♀️ groupBy

  • 리스트를 하나의 특징을 키로 한 으로 변환해 반환한다.
  val list = listOf("a", "ab", "b")
  println(list.groupBy{String::first}) # {a= [a, ab], b= [b]}
  • 각 그룹의 값은 리스트이다.
  • 위 예시에서 String::first는 확장 함수를 멤버 참조를 통해 사용했다.


🙋‍♀️ flatten & flatMap

  • 중첩 컬렉션의 원소들을 하나의 리스트로 만든다.
  • flatten: 단순히 중첩 컬렉션을 하나의 리스트로 만든다.
  • flatMap: 람다를 적용해 얻어지는 여러 리스트를 하나로 모은다.
  val strings = listOf("abc", "def")
  println(strings.falttenMap{it.toList()}) // [a, b, c, d, e, f]


👩 시퀀스를 이용한 컬렉션의 지연 연산

다음과 같은 연쇄 연산을 살펴보자!

🙋‍♀️ 컬렉션에서의 연쇄 연산

  list.filter{it % 2 == 0}.map{it * it}
  • 여기서 주의해야 할 점은 filter, map 모두 결과로 리스트를 반환한다는 점이다.
  • 즉, 연쇄 매 단계마다 중간 결과가 새로운 컬렉션에 임시로 저장된다. (즉시 계산)

만약 리스트의 원소가 수백만 개가 된다면 매 단계마다 수백만 원소의 리스트가 생성되는 것이다. 이러한 문제점을 해결하는 것이 바로 시퀀스이다.


🙋‍♀️ 시퀀스에서의 연쇄 연산

  list.asSequence().filter{it % 2 == 0}.map{it * it}.toList()
  • 중간 결과를 저장하는 컬렉션이 생성되지 않는다. (지연 계산)
  • 결과를 이터레이션에만 사용한 다면 list로 변환하지 않아도 된다. 단, 인덱스를 사용해 접근하는 등의 다른 API 메서드를 사용할 경우 리스트로 변환해야 한다.


➕ 연산 순서에 따른 성능 차이

컬렉션에 대해 수행하는 연산 순서에 따라서 성능의 차이가 발생한다. 다음의 경우를 살펴보자.

  val people = listOf("Alice", 27), Person("Bob", 31), Person("Charles", 31), Person("Dan", 21))

  // map -> filter
  println(people.asSequence().map(Person::name).filter{it.length < 4}.toList()) # [Bob, Dan]

  // filter -> map
  println(people.asSequence().filter{it.name.length < 4}.map(Person::name)) # [Bob, Dan]

결과는 똑같지만 변환 횟수를 살펴보면 어느 연산을 먼저 하는 것이 효율적인 지를 파악할 수 있다.

  • map 연산을 먼저 할 경우 모든 원소를 변환하지만 filter를 먼저 적용하면 부적절한 원소는 제외하게 된다.


🙋‍♀️ 시퀀스 지연 연산

  • 시퀀스의 원소는 필요할 때 비로소 계산된다.
  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을 통한 새 시퀀스를 얻고, 그 시퀀스에 대해 filter를 수행하게 된다.
  println(listOf(1, 2, 3, 4).asSequence()
    .map{it * it}.find{it > 3}) // 4

위 식이 수행되는 과정을 통해 즉시 게산과 지연 계산의 차이를 명확히 알아보자!

  • 즉시 연산컬렉션에 연산을 적용한다.
  • 지연 연산은 원소 하나씩 연산을 진행한다.


🙋‍♀️ generateSequence로 시퀀스 생성

위의 경우 기존의 컬렉션에 asSequence()를 통해서 시퀀스를 생성했다. 하지만 직접 시퀀스를 생성하는 방법도 있다. 아래의 0-100까지의 합을 구하는 코드를 살펴보자!

  val naturalNumbers = generateSequnece(0) {it + 1}
  val numbersTo100 = naturalNumbers.takeWhile {it <= 100}
  println(numbersTo100.sum()) #5050
  • naturalNumbers, numbersTo100 시퀀스 모두 최종 연산인 sum이 수행될 때 계산이 된다.
  • generateSequence() 함수는 이전 원소를 인자로 받아 다음 원소를 계산한다.
  • 시퀀스는 일반적으로 객체의 조상으로 이뤄진 시퀀스를 만들어 조상의 특성을 알고 싶을 때 사용한다.
    • ex) 특정 파일의 상위 디렉터리를 검사하며 숨김 속성을 가진 디렉터리가 있는지 확인한다.


👩 함수형 인터페이스를 대신하는 람다

🙋‍♀️ 함수형/SAM 인터페이스

버튼에 리스너를 다는 작업을 살펴보자!

  button.setOnClickListener(new OnClickListener(){
    @Override
    public void onClick(View v){
      ...
    }
  })

자바에서는 setOnClickListener 메서드에 인자로 위와 같이 무명클래스의 인터페이스를 넘겼었다.

여기서 onClickListener는 추상 메서드가 onClick 단 하나만 존재한다. 이러한 인터페이스를 함수형 인터페이스라고 한다.

🙄 코틀린에서는 이렇게 함수형 인터페이스를 인자로 취하는 자바 메서드를 호출할 때 람다로 대체할 수 있다.

  button.setOnClickListener {view -> ...}
  • 이때 람다에 대해 무명 클래스를 만들고, 해당 클래스의 인스턴스를 만들어 메서드에 넘기는 방식을 취한다.


➕ 무명 객체 vs 람다의 인스턴스 생성

  • 무명 객체의 경우, 호출할 때마다 새로운 객체가 생성된다.
  • 람다의 경우, 하나의 인스턴스만 만들어져 사용된다.
    • 단, 람다에서 주변 영역의 변수를 포획한 경우, 인스턴스가 매번 새로 만들어진다.
      // fun postponeComputation(val delay: Int, val computation: Runnable)
    
      fun handleComputation(id: String){
          postponeComputation(1000) {println(id)}
      }
    
    • postponeComputation에 넘겨지는 람다에서 주변 인자 “id”를 포획했으므로 해당 Runnable 인스턴스는 매번 새로 만들어진다.


🙋‍♀️ SAM 생성자

  • 대부분의 경우 람다자바 함수형 인터페이스 사이 변환은 자동으로 이뤄진다.
  • 컴파일러가 자동으로 람다를 함수형 인터페이스 무명 클래스로 바꾸지 못하는 경우 SAM 생성자를 사용해 명시적으로 변경해주어야 한다.
  fun createAllDoneRunnable() : Runnable {
    return Runnable {println("All done!")}
  }

여기서 아래의 SAM 생성자를 통해 값을 반환하는 것을 알 수 있다.

  Runnable {
    ...
  }
  • SAM 생성자는 사용하려는 함수형 인터페이스와 이름이 같다.


➕ 람다에서의 this

  • 람다는 객체가 아니므로 인스턴스 자신을 가리키는 this가 없다.
  • 람다 안에서 사용되는 this는 람다를 둘러싼 클래스의 인스턴스를 가르킨다.
  • 이벤트 리스너를 람다를 사용해 생성할 경우 리스너 등록을 해제할 수 있다. 따라서 리스너 등록 해제를 위해서는 무명 객체를 사용해 리스너를 구현해야 한다.


👩 수신 객체 지정 람다

수신 객체를 명시하지 않고 람다의 본문 안에서 다른 객체의 메서드를 호출할 수 있게 하는 람다


🙋‍♀️ with 함수

  • 객체의 이름을 반복하지 않고도 그 객체에 대해 다양한 연산을 수행할 수 있게 한다.
  with(stringBuilder, {...})
  • 첫 번째 인자로 받은 객체를 두 번째 인자로 받은 람다의 수신 객체로 만든다.
  • 람다 안에서 this를 통해 수신 객체의 프로퍼티에 접근할 수 있다.
  fun alphabet() = with(StringBuilder()) {
      for (letter in 'A'..'Z'){
        this.append(letter)
      }
      append("\nNow i know the alphabet!")
      toString()
  }
  • 위 코드에서 알 수 있듯이 this 참조를 사용하지 않고 바로 함수를 호출해도 프로퍼티에 접근할 수 있다.


🙋‍♀️ apply

  • apply는 with와 유사하다.
  • 유일한 차이는 apply는 항상 자신에게 전달된 객체(수신 객체)를 반환한다.
  fun alphabet () = StringBuilder().apply {
    for (letter in 'A'..'Z'){
      append(letter)
    }
    append("\nNow i know the alphabet!")
  }.toString()

🙄 여기서 주목해야 할 부분은 마지막 toString()이다. 수신 객체StringBuilder가 반환되므로 toString을 호출해 String 객체를 얻을 수 있다.

  • apply는 객체의 인스턴스를 만들어 즉시 프로퍼티 중 일부를 초기화 하는 경우에 유용하게 사용된다.


🙋‍♀️ buildString

  • StringBuilder 객체를 만드는 일toString을 호출해주는 일을 알아서 해준다.
  • 이 또한 수신 객체 지정 람다를 사용하는 예이다.

위 에제를 buildString 함수를 사용해 리팩토링해보자!

  fun alphabet() = buildString {
    for (letter in 'A'..'Z'){
      append(letter)
    }
    append("\nNow i know the alphabet!")
  }


👩 요약

  • 람다를 사용하면 코드 조각을 다른 함수에게 인자로 넘길 수 있다.
  • 코틀린에서는 람다가 함수 인자인 경우 괄호 으로 람다를 빼낼 수 있고, 람다의 인자가 단 하나뿐인 경우 인자 이름을 지정하지 않고 it이라는 디폴트 이름으로 부를 수 있다.
  • 람다 안에 있는 코드는 그 람다가 들어있는 바깥 함수의 변수를 읽거나 쓸 수 있다.
  • 메서드, 생성자, 프로퍼티의 이름 앞에 ::을 붙이면 각각에 대한 참조를 만들 수 있다. 그런 참조를 람다 대신 다른 함수에게 넘길 수 있다.
  • filter, map, all, any 등의 함수를 활용하면 컬렉션에 대한 대부분의 연산을 직접 원소에 이터레이션하지 않고 수행할 수 있다.
  • 시퀀스를 사용하면 중간 결과를 담는 컬렉션을 생성하지 고도 컬렉션에 대한 여러 연산을 조합할 수 있다.
  • 함수형 인터페이스(추상 메서드가 단 하나뿐인 SAM 인터페이스)를 인자로 받는 자바 함수를 호출할 경우 람다를 함수형 인터페이스 인자 대신 넘길 수 있다.
  • 수신 객체 지정 람다를 사용하면 람다 안에서 미리 정해둔 수신 객체의 메서드를 직접 호출할 수 있다.
  • 표준 라이브러리의 with 함수를 사용하면 어떤 객체에 대한 참조를 반복해서 언급하지 않으면서 그 객체의 메서드를 호출할 수 있다. apply를 사용하면 어떤 객체라도 빌더 스타일의 API를 사용해 생성하고 초기화할 수 있다.


🙇‍♀️ 부족한 부분이 있다면 말씀해주세요! 감사합니다!

📃참고

  • ‘Kotlin in action’: 5장(람다로 프로그래밍)

댓글남기기