본문 바로가기
kotlin/Kotlin In Action

4. 람다로 프로그래밍

by 유저혀 2021. 6. 17.
반응형

Kotlin IN ACTION 책을 공부하면서 정리한 내용입니다.

 

http://www.acornpub.co.kr/book/kotlin-in-action

 

Kotlin in Action

이 책은 코틀린 언어를 개발한 젯브레인의 코틀린 컴파일러 개발자들이 직접 쓴 일종의 공식 서적이라 할 수 있다.

www.acornpub.co.kr

 

 

람다는 기본적으로 다른 함수에 넘길 수 있는 작은 코드 조각을 뜻한다. 

 

1.  람다 식과 멤버 참조

 

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

함수형 프로그래밍에서는 함수를 값처럼 다루는 접근 방법을 택한다. 함수형 언어에서는 함수를 직접 다른 함수에 전달할 수 있다. 람다식을 사용하면 코드가 더 간결해진다. 람다식을 사용하면 함수를 선언할 필요가 없고 코드 블록을 직접 함수의 인자로 전달할 수 있다.

 

// 자바 : 무명 내부 클래스로 리스너 구현하기
button.setOnClickListner(new OnClickListner(){
  @override
  public void onClick(View view){
    
  }
});
// 람다로 리스너 구현하기
button.setOnclickListner{ /* 클릭 시 수행할 동작 */ }

 

1.2. 람다와 컬렉션

사람들로 이루어진 리스트가 있고 가장 연장자를 찾고 싶다. 람다를 사용하지 않는다면 루프를 써서 직접 검색을 구현해야 한다. 

fun findTheOldest(people: List<Person>){
  var maxAge = 0
  var theOldest = Person? = null
  
  for(person: people) {
    if(person.age > maxAge) {
      maxAge = person.age
      theOldest = person
    }
  }
  
  println(theOldest)
}

 

코틀린에서는 람다를 사용하여 컬렉션을 검색할 수 있다.

val peopel = listOf(Person("Alice", 29), Person("Bob", 31))
println(people.maxBy{ it.age })  // Person(name=Bob, age=31)

모든 컬렉션에 대해 maxBy 함수를 호출할 수 있다.

 

이런식으로 단지 함수나 프로퍼티를 반환하는 역할을 수행하는 람다는 멤버 참조로 대치할 수 있다.

people.maxBy(Person::age)

 

 

1.3. 람다 식의 문법

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

람다 식도 변수에 저장할 수 있다. 

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

 

1.2의 예제 코드 people.maxBy { it.age} 에서 코틀린이 코드를 줄여쓸 수 있게 제공했던 기능을 제거하고 정식으로 람다를 작성하면 다음과 같다.

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

 

 

코틀린에서는 함수 호출 시 맨 뒤에 있는 인자가 람다 식이라면 그 람다를 괄호 밖으로 빼낼 수 있다. 

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

이 코드처럼 람다가 어떤 함수의 유일한 인자이고 괄호 뒤에 람다를 썼다면 호출 시 빈 괄호는 없앨 수 있다.

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

파라미터 타입 또한 없앨 수 있다.

people.maxBy{p -> p.age}

컴파일러는 로컬 변수처럼 람다 파라미터의 타입도 추론할 수 있다. 따라서 파라미터 타입을 명시할 필요는 없다.

 

마지막으로 람다의 파라미터 이름을 디폴트 이름인 it으로 바꾸면 람다 식을 더 간단하게 만들 수 있다. 

people.maxBy{it.age}

 

람다를 변수에 저장할 때는 파라미터 타입을 추론할 문맥이 없으므로 파라미터 타입을 명시해야 한다.

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

 

 

1.4. 현재 영역에 있는 변수에 접근

람다를 함수 안에서 정의하면 함수의 파라미터 뿐만 아니라 람다 정의의 앞에 선언된 로컬 변수까지 람다에서 모두 사용할 수 있다.

fun printMessagesWithPrefix(messages: Collection<String>, prefix: String){
  message.forEach{
    println("$prefix $it")
  }
}

 

자바와 다른 점은 코틀린 람다 안에서는 파이널 변수가 아닌 변수에 접근할 수 있다는 점이다. 

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 response = listOf("200 OK", "418 I'm a teapot", "500 Internal Server Error")

printProblemCounts(response) // 1 client errors, 1 server errors

 

fun tryToCountButtonClicks(button: Button): Int {
  var clicks = 0
  button.onClick { clicks++ }
  return clicks
}

 

 

1.5. 멤버 참조

::를 사용하는 식을 멤버 참조라고 부른다. 

val getAge = { person: Person -> person.age }
people.maxBy{ p -> p.age }
people.maxBy{ Person::age }
people.maxBy{ it.age }

 

 

최상위에 선언된(그리고 다른 클래스의 멤버가 아닌) 함수나 프로퍼티를 참조할 수도 있다. 클래스 이름을 생략하고 ::로 참조를 바로 시작한다. ::salute라는 멤버 참조를 run 라이브러리 함수에 넘긴다(run은 인자로 받은 람다를 호출한다)

fun salute() = println("salute!")

fun main() {
    run(::salute)  // salute
}

 

 

생성자 참조(constuctor reference)를 사용하면 클래스 생성 작업을 연기하거나 저장해둘 수 있다. :: 뒤에 클래스 이름을 넣으면 생성자 참조를 만들 수 있다.

data class Person(val name: String, val age: Int)
val createPerson = ::Person
val p = createPerson("Alice",29)
println(p)

 

확장 함수도 멤버 함수와 똑같은 방식으로 참조할 수 있다.

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

 


2. 컬렉션 함수형 API

 

2.1. filter와 map

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

 

filter는 컬렉션을 이터레이션 하면서 주어진 람다에 각 원소를 넘겨서 람다가 true를 반환하는 원소만 모은다

val list = listOf(1,2,3,4)
println(list.filter{ it % 2 == 0 })  // [2,4]

val person = listOf(Person("Alice",29), Person("Bob", 31))
println(person.filter{ it.age > 30 })  // [Person(name=Bob, age=31)]

 

 

 

map 함수는 주어진 람다를 컬렉션의 각 원소에 적용한 결과를 모아서 새 컬렉션을 만든다. 각 원소는 주어진 함수에 따라 변환된 새로운 컬렉션이다.

val list = listOf(1,2,3,4)
println(list.map{ it * it }) // [1,4,9,16]

 

 

 

예를 들어 30살 이상인 사람의 이름을 출력해보자

people.filter{ it.age > 30}.map(Person::name)

 

가장 나이 많은 사람의 이름을 모두 알고 싶으면

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

하지만 위 코드는 최대값을 구하는 작업을 계속 반복한다는 단점이 있다. 

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

 

 

 

 

2.2. all, any, count, find 

컬렉션에 대해 자주 수행하는 연산으로 컬렉션의 모든 원소가 어떤 조건을 만족하는지 판단하는 연산이 있다. 코틀린에서는 all, any가 이런 연산이다.  count는 조건을 만족하는 원소의 개수를 반환하며, find 함수는 조건을 만족하는 첫 번째 원소를 반환한다.

val canBeInClub27 = {p:Persion -> p.age <= 27 }
val people = listOf(Person("Alice", 27), Person("Bob", 31))

println(people.all(canBeInClub27)) // false
println(people.any(canBeInClub27)) // true

 

어떤 조건에 대해 !all과 any를 수행한 경우는 같다. !any를 수행한 결과도 all을 수행한 결과와 같다.

val list = listOf(1,2,3)
println(!list.all{ it == 3 }) // true
println(list.any{ it != 3 })  // true

 

 

만족하는 원소의 개수를 구하려면 count를 사용한다.

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

 

만족하는 원소를 하나 찾고 싶으면 find 함수를 사용한다. 만족하는 원소가 전혀 없을 경우 null을 반환한다. find는 firstOrNull과 같다.

println(people.find(canBeInClub27))  // Person(name=Alice, age=27)

 

 

2.3. groupBy: 리스트를 여러 그룹으로 이루어진 맵으로 변경

컬렉션의 모든 원소를 어떤 특성에 따라 여러 그룹으로 나누고 싶다고 하자. groupBy 함수가 그런 역할을 한다.

이 연산의 결과는 컬렉션의 원소를 구분하는 특성이 키이고, 키 값에 따른 각 그룹(이 예제에서는 Person 객체)이 값인 맵이다.

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의 결과 타입은 Map<Int, List<Person>>이다. 필요하면 mapKeys나 mapValues 등을 사용해 변경할 수 있다.

val list = listOf("a","ab","b")
println(list.groupBy(String::first)) // {a: [a,ab], b: [b]}

 

 

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

class Book(val title: String, val author: List<String>)

책마다 저자가 한 명 이상일 수 있다. 도서관에 있는 책의 저자를 모두 모은 집합은 다음과 같이 가져올 수 있다.

books.flatMap{ it.authors }.toSet() 

flatMap 함수는 먼저 인자로 주어진 람다를 실행하고 모든 객체에 적용한 뒤 람다를 적용한 결과 얻어지는 여러 리스트를 한 리스트로 한데 모은다.(펼치기 flatten) 

 

val strings = listOf("abc", "def")
println(strings.flatMap{ it.toList() }) // [a,b,c,d,e,f]

 

val books = listOf(Book("Thursday Next", listOf("Jasper Eforde")),
  Book("Mort", listOf("Terry Pratchett")),
  Book("Good Omens", listOf("Terry Pratchett", "Nell Gaiman")))

println(books.flatMap(Book::authors).toSet())

// [Jasper Eforde, Terry Pratchett, Nell Gaiman]

 

특별히 변환해야 할 내용이 없다면 리스트를 펼치기만 하면 된다. 그런 경우 flatten() 함수를 사용할 수 있다.

 

 


3.  지연 계산(lazy) 컬렉션 연산

앞에서 나왔던 map이나 filter 함수는 컬렉션을 즉시 생성한다. 이는 컬렉션 함수를 연쇄하면 매 단계마다 계산 중간 결과를 새로운 컬렉션에 임시로 담는다는 말이다. 시퀀스(sequence)를 사용하면 중간 임시 컬렉션을 사용하지 않고도 컬렉션 연산을 연쇄할 수 있다.

 

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

코틀린 표준 라이브러리 문서에는 filter와 map이 리스트를 반환한다고 써 있다. 이는 이 연쇄 호출이 리스트를 2개 만든다는 뜻이다. 

이를 더 효율적으로 만들기 위해서는 각 연산이 컬렉션을 직접 사용하는 대신 시퀀스를 사용하게 만들어야 한다.

 

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

중간 결과를 저장하는 컬렉션이 생기지 않기 때문에 원소가 많은 경우 성능이 눈에 띄게 좋아진다.

 

Sequence 안에는 iterator라는 단 하나의 메소드가 있다. 그 메소드를 통해 시퀀스로부터 원소 값을 얻을 수 있다.

asSequence 확장 함수를 호출하면 어떤 컬렉션이든 시퀀스로 바꿀 수 있다. 반대로 시퀀스를 리스트로 만들 때는 toList를 사용한다.

 

 

 

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

시퀀스에 대한 연산은 중간 연산과 최종 연산으로 나뉜다. 중간 연산은 다른 시퀀스를 반환한다. 최종 시퀀스는 결과를 반환한다.

중간연산은 항상 지연 계산된다. 최종 연산이 없는 예제를 살펴보자

listOf(1, 2, 3, 4).asSequence()
  .map{ print("map($it)"); it * it}
  .filter{ print("filter($it)"); it % 2 ==0 }

이 코드를 실행하면 아무 내용도 출력되지 않는다. 이는 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) 

최종 연산을 호출하면 연기됐던 모든 계산이 수행된다.

 

 

3.2. 시퀀스 만들기

지금까지 살펴본 예제는 컬렉션에 대해 asSequence()를 호출해 시퀀스를 만들었다. 시퀀스를 만드는 다른 방법으로 generateSequence 함수를 사용할 수 있다. 이 함수는 이전의 원소를 인자로 받아 다음 원소를 계산한다.

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

 

 

 

다음 예제는 어떤 파일의 상위 디렉토리를 뒤지면서 숨김 디렉토리가 있는지 검사하는 예제이다.

fun File.isInsideHiddenDirectory() = 
  generateSequence(this){ it.parentFile }.any{ it.isHidden }

val file = File("/Users/svtk/.HiddenDir/a.txt")
println(file.isInsideHiddenDirectory())

이렇게 시퀀스를 사용하면 조건을 만족하는 디렉토리를 찾은 뒤에는 더 이상 상위 디렉토리를 뒤지지 않는다.

 

 

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

코틀린 람다를 자바 API에 사용해도 아무 문제가 없다.

button.setOnClickListener{ ... } // 람다를 인자로 넘김

 

Button 클래스는 setOnClickListener 메소드를 사용해 버튼의 리스너를 설정한다. 이때 인자의 타입은 OnClickListener이다.

OnClickListener 인터페이스는 onClick이라는 메소드만 선언된 인터페이스이다.

/* 자바 */
public class Button {
  public void setOnClickListener(OnClickListener l) {
    ...
  }
}

public interface OnClickListener{
  void onClick(View v);
}

 

자바8 이전의 자바에서는 setOnClickListener 메소드에게 인자를 넘기기 위해서는 무명클래스(익명클래스)의 인스턴스를 만들어야 했다.

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

 

코틀린에서는 무명 클래스 인스턴스 대신 람다를 넘길 수 있다

button.setOnClickListener{ view -> ... }

 

이런 코드가 작동하는 이유는 onClickListener에 추상 메소드가 단 하나만 있기 때문이다. 그런 인터페이스를 함수형 인터페이스(functional interface) 또는 SAM 인터페이스라고 한다. SAM은 단일 추상 메소드(single abstract method)라는 뜻이다. 자바 API에는 Runnable이나 Callable과 같은 함수형 인터페이스와 그런 함수형 인터페이스를 활용하는 메소드들이 많다. 코틀린은 함수형 인터페이스를 인자로 취하는 자바 메소드를 호출할 때 람다를 넘길 수 있게 해준다.

 

 

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

함수형 인터페이스를 인자로 원하는 자바 메소드에 코틀린 람다를 전달할 수 있다. 자바의 Runnable 인터페이스는 다음과 같이 사용한다

/* Java Runnable */
class foo implements Runnable{
	@Override
	public void run() {
		// do something...
	}
}

public interface Runnable {
  void run();
}

 

아래 메소드는 자바 Runnable 타입의 파라미터를 받는다. 코틀린에서는 람다를 이 함수에 넘길 수 있다. 

void postponeComputation(int delay, Runnable runnable);

postponeComputation(1000){ println(42) }

 

Runnable을 구현하는 무명 객체를 명시적으로 만들어 사용할 수도 있다.

postponeComputation(1000, object: Runnable {
  override fun run(){
    println(42)
  }
})

하지만 람다와 무명 객체 사이에는 차이가 있다. 객체를 명시적으로 선언하는 경우 메소드를 호출할 때마다 새로운 객체가 생성된다. 람다는 다르다. 람다는 대응하는 무명 객체를 메소드가 호출될 때마다 반복 사용한다.

 

postponeComputation(1000){ println(42) } // 프로그램 전체에서 Runnable 인스턴스는 단 하나만 만들어진다

따라서 명시적인 object 선언을 사용하면서 람다와 동일한 코드는 아래와 같다. 이 경우 Runnable 인스턴스를 변수에 저장하고 메소드를 호출할 때마다 그 인스턴스를 사용한다.

// 전역 변수로 컴파일되므로 프로그램 안에 단 하나의 인스턴스만 존재한다
val runnable = Runnable{ println(42) }
fun handleComputation() {
  postponeComputation(1000, runnable)
}

 

 

 


5. 수신 객체 지정 람다: with와 apply

코틀린의 람다는 수신 객체를 명시하지 않고 람다의 본문 안에서 다른 객체의 메소드를 호출할 수 있다. 그런 람다를 수신 지정 람다(lambda with receiver)라고 부른다.

 

5.1. with 함수

어떤 객체의 이름을 반복하지 않고도 그 객체에 대해 다양한 연산을 수행할 수 있다면 좋을 것이다.

fun alphabet(): String {
  val result = StringBuilder()
  for(letter in 'A'..'Z'){
    result.append(letter)
  }
  result.append("\nNow I know the alphabet!")
  return result.toString()
}

println(alphabet()) 

이 예제에서는 result에 대해 다른 여러 메소드를 호출하면서 매번 result를 반복 사용했다. 앞의 예제를 with로 다시 작성해보자.

fun alphabet(): String {
  val stringBuilder = StringBuilder()
  return with(stringBuilder){  // 메소드를 호출하려는 수신 객체를 지정한다.
    for(letter in 'A'..'Z') {
      this.append(letter)  // this를 명시해서 앞에서 지정한 수신 객체의 메소드를 호출한다
    }
    append("\nNow I know the alphabet!")
    this.toString()
  }
}

with 함수는 첫 번째 인자로 받은 객체를 두 번째 인자로 받은 람다의 수신 객체로 만든다. 여기서 첫번째 파라미터는 stringBuilder이고, 두번째 파라미터는 람다다. 람다를 괄호 밖으로 빼내는 관례를 사용함에 따른다. 인자로 받은 람다 본문에서는 this를 사용해 그 수신 객체에 접근할 수 있다. 

앞의 alphabet 함수를 더 리펙토링해서 불필요한 stringBuilder 변수를 없앨 수도 있다.

fun alphabet() = with(StringBuilder()){
  for(letter in 'A'..'Z'){
    append(letter)
  }
  append("\nNow I know the alphabet!")
  toString()
}

 

 

 

5.2. apply 함수

apply 함수는 거의 with와 같다. 유일한 차이는 apply 함수는 항상 자신에게 전달된 객체(즉 수신 객체)를 반환한다는 점 뿐이다. 

fun alphabet() = StringBuilder().apply{
  for(letter in 'A'..'Z'){
    append(letter)
  }
  append("\nNow I know the alphabet!")
}.toString()

apply는 확장 함수로 정의돼있다. apply의 수신 객체가 전달받은 람다의 수신 객체가 된다. 이 함수에서 apply를 실행한 결과는 StringBuilder 객체이다. 

 

이런 apply 함수는 객체의 인스턴스를 만들면서 즉시 프로퍼티 중 일부를 초기화해야 하는 경우 유용하다.

fun crateViewWithCustomAttributes(context: Context) = 
  TextView(context).apply {
    text = "Sample Text"
    textSize = 20.0
    setPadding(10,0,0,0)
  }

새로운 TextView 인스턴스를 만들고 즉시 그 인스턴스를 apply에 넘긴다.

728x90

'kotlin > Kotlin In Action' 카테고리의 다른 글

3. 클래스, 객체, 인터페이스  (0) 2021.06.14
2. 함수 정의와 호출  (0) 2021.06.11
1. 코틀린 기초  (0) 2021.06.10

댓글