[Kotlin in Action] 5. 람다로 프로그래밍

akim·2022년 11월 18일
0

Kotlin in Action

목록 보기
6/12
post-thumbnail

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

  • 람다를 사용하면 쉽게 공통 코드 구조를 라이브러리 함수로 뽑아낼 수 있다.

  • 코틀린에서는 컬렉션 처리에 람다를 자주 사용한다.

5장에서는 컬렉션을 처리하는 패턴을 표준 라이브러리 함수에 람다를 넘기는 방식으로 대치하는 예제를 다수 살펴본다. 또한 자바 라이브러리와 람다를 함께 사용하는 방법도 살펴본다. 마지막으로 수신 객체 지정 람다에 대해 살펴본다. 수신 객체 지정 람다는 특별한 람다로, 람다 선언을 둘러싸고 있는 환경과는 다른 상황에서 람다 본문을 실행할 수 있다.

람다 식과 멤버 참조

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

  • 이벤트가 발생하면 이 핸들러를 실행하자
  • 데이터 구조의 모든 원소에 이 연산을 적용하자

위와 같은 생각들을 코드로 표현하기 위해 일련의 동작을 변수에 저장하거나 다른 함수에 넘겨야 하는 경우가 자주 있다.

자바에서는 무명 내부 클래스를 통해 이런 목적을 달성했다. 무명 내부 클래스를 이용하면 코드를 함수에 넘기거나 변수에 저장할 수 있기는 하지만 상당히 번거롭다.

/*무명 내부 클래스로 리스너 구현하기 (Java)*/
button.setOnClickListener(new OnclickListener() {
	@override
    public void onClick(View view) {
    	/*클릭 시 수행할 동작*/
    }
});

위 코드에서 보이는 것과 같이 무명 내부 클래스를 선언하느라 코드가 번잡스러워졌다. 이와 비슷한 작업을 많이 수행해야 하는 경우 그런 번잡함은 난잡함으로 변해 개발자를 괴롭힌다. 클릭 시 벌어질 동작을 간단히 기술할 수 있는 표기법이 있다면 이런 불필요한 코드를 제거할 수 있을 것이다.

이와 달리 함수형 프로그래밍에서는 함수를 값처럼 다루는 접근 방식을 택함으로써 이 문제를 해결한다. 클래스를 선언하고 그 클래스의 인스턴스를 함수에 넘기는 대신 함수형 언어에서는 함수를 직접 다른 함수에 전달할 수 있다.

람다 식을 사용하면 코드가 더욱 더 간결해진다. 람다 식을 사용하면 함수를 선언할 필요가 없고 코드 블록을 직접 함수의 인자로 전달할 수 있다.

/*람다로 리스너 구현하기 (Kotlin)*/
button.setOnClickListener { /*클릭 시 수행할 동작*/ }

위 코드는 앞서 본 자바 무명 내부 클래스와 같은 역할을 하는 코드다.
이런 코드를 실제 안드로이드 개발을 하며 수없이 써왔는데, 자바에서는 저렇게 복잡하게 써야 했다는 사실이 다소 충격적이다. 코틀린 구문이 훨씬 더 간결하고 읽기 쉽다는 사실을 다시 한 번 깨닫는다.

2. 람다와 컬렉션

사람들로 이뤄진 리스트가 있고 그중에 가장 연장자를 찾고 싶다고 해보자.
람다가 없다면 나이의 최댓값과 그 최댓값에 해당하는 나이를 먹은 첫 번째 인물을 저장하기 위해 변수를 두 개 만들고 리스트에 대해 이터레이션하면서 그 두 변수를 갱신할 것이다.

루프 쯤이야 껌이지 라고 생각할 수 있지만 귀찮다 !

나같이 귀찮은 사람들을 위해 코틀린에는 루프보다 더 좋은 방법이 있다. 라이브러리 함수를 쓰면 된다.

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

그리고 위와 같이 단순히 함수나 프로퍼티를 반환하는 역할을 수행하는 람다는 멤버 참조로 대치하여 더 간단하게 쓸 수도 있다.

people.maxBy(Person::age)

처음에는 루프까지 쓸 뻔했는데 람다를 통해 이렇게 한 줄로 확 줄어들었다. 혁신적이다 !

3. 람다 식의 문법

그럼 이렇게 혁신적인 람다 식은 도대체 어떻게 쓰면 되는 것일까.

앞서 살펴본 것 처럼 람다는 값처럼 여기저기 전달할 수 있는 동작의 모음이다. 람다를 따로 선언해서 변수에 저장할 수도 있지만 대부분은 함수에 인자로 넘기면서 바로 람다를 정의한다.

위는 람다식의 문법을 보여주는 이미지이다. 보이는 것과 같이 람다 식은 항상 중괄호로 둘러싸여 있으며 인자 목록 주변에 괄호가 따로 없다! -> 만이 인자 목록과 람다 본문을 구분해주는 역할을 한다.

위처럼 만든 람다 식은 변수에 저장할 수 있다. 이렇게 람다가 저장된 변수를 다른 일반 함수와 마찬가지로 다룰 수 있다.

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

앞서 보았던 이 코드를 정식으로 람다를 통해 작성하면 아래와 같이 쓸 수 있다.

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

중괄호 안에 있는 코드는 람다 식이고 그 람다 식을 maxBy 함수에 넘긴다. 람다 식은 Person 타입의 값을 인자로 받아서 인자의 age를 반환한다.

하지만 이 코드는 번잡하다. 아니 이렇게까지 줄였는데 아직도 번잡하단 말인가?!

  1. 구분자가 너무 많이 쓰여서 가독성이 떨어진다.
  2. 컴파일러가 문맥으로부터 유추할 수 있는 인자 타입을 굳이 적을 필요는 없다.
  3. 인자가 단 하나뿐인 경우 굳이 인자에 이름을 붙이지 않아도 된다.

위 사항들을 개선해보자.

구분자 줄이기

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

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

여기서 만약 람다가 어떤 함수의 유일한 인자이고 괄호 뒤에 람다를 썼다면 호출 시 아예 빈 괄호를 없애도 된다.

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

파라미터 타입 생략

다음으로 파라미터 타입을 없애보자. 로컬 변수처럼 컴파일러는 람다의 파라미터 타입도 추론할 수 있다.

maxBy 함수의 경우 파라미터의 타입은 항상 컬렉션 원소 타입과 같다. 컴파일러는 우리가 Person 타입의 객체가 들어있는 컬렉션에 대해 maxBy 를 호출한다는 사실을 알고 있으므로 람다의 파라미터도 Person 이라는 사실을 이해할 수 있다.

컴파일러가 람다 파라미터의 타입을 추론하지 못하는 경우도 있지만 그런걸 굳이 외우고 살지는 말자 !
그냥 뭐 무조건 타입은 안 쓰고 코드 작성하다가 컴파일러가 이거 타입 뭐냐! 난 이런거 모르겠다!" 할 때만 어어 그래 알겠어 써줄게;; 하고 타입을 명시해주면 되니까 ^___^

인자 이름 생략

마지막으로 람다의 파라미터 이름을 디폴트 이름인 it 으로 바꾸면 람다 식을 더 간단하게 만들 수 있다. 람다의 파라미터가 하나뿐이고 그 타입을 컴파일러가 추론할 수 있는 경우 it 을 바로 쓸 수 있다.

people.maxBy{ it. age }

이렇게 해서 정말 간단하게 안드 개발하면서 진짜 많이 썼던 형식 한 줄의 코드를 만들 수 있다.

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

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

자바와 다른 점 중 중요한 한 가지는 코틀린 람다 안에서는 파이널 변수가 아닌 변수에 접근할 수 있다는 점이다. 또한 람다 안에서 바깥의 변수를 변경해도 된다.

또한 코틀린에서는 자바와 달리 람다 밖 함수에 있는 파이널이 아닌 변수에 접근할 수 있고, 그 변수를 변경할 수도 있다. 이렇게 람다 안에서 사용하는 외부 변수를 람다가 포획한 변수라고 부른다.

기본적으로 함수 안에 정의된 로컬 변수의 생명주기는 함수가 반환되면 끝난다. 하지만 어떤 함수가 자신의 로컬 변수를 포획한 람다를 반환하거나 다른 변수에 저장한다면 로컬 변수의 생명주기와 함수의 생명주기가 달라질 수 있다. 포획한 변수가 있는 람다를 저장해서 함수가 끝난 뒤에 실행해도 람다의 본문 코드는 여전히 포획한 변수를 읽거나 쓸 수 있다. 어떻게 그런 동작이 가능할까?

파이널 변수를 포획한 경우

람다 코드를 변수 값과 함께 저장한다.
이 경우 자바와 마찬가지로 그 변수의 값이 복사된다.

파이널이 아닌 변수를 포획한 경우

변수를 특별한 래퍼로 감싸서 나중에 변경하거나 읽을 수 있게 한 다음, 래퍼에 대한 참조를 람다 코드와 함께 저장한다.
변수를 Ref 클래스 인스턴스에 넣는다. 그 Ref 인스턴스에 대한 참조를 파이널로 만들면 쉽게 람다로 포획할 수 있고, 람다 안에서는 Ref 인스턴스의 필드를 변경할 수 있다.

5. 멤버 참조

람다를 사용해 코드 블록을 다른 함수에게 인자로 넘기는 방법은 이제 알았다. 근데 이렇게 넘기려는 코드가 이미 함수로 선언된 경우에는 어떻게 해야 할까? 물론 그 함수를 호출하는 람다를 만들면 된다..... 하지만...? 이는 중복이다. 함수를 직접 넘길 수는 없을까?

코틀린에서는 함수를 값으로 바꿀 수 있다. 이때 ::(이중 콜론) 을 사용한다. 그리고 :: 를 사용하는 식을 멤버 참조 라고 부른다.

멤버 참조는 프로퍼티나 메서드를 단 하나만 호출하는 함수 값을 만들어준다. :: 는 클래스 이름과 참조하려는 멤버 이름 사이에 위치한다.

위 멤버 참조 식은 다음 람다 식을 더 간략하게 표현한 것이다.

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

참조 대상이 함수인지 프로퍼티인지와는 관계없이 멤버 참조 뒤에는 괄호를 넣으면 안된다.

  • 클래스 이름을 생략하고 :: 로 바로 참조를 시작할 수도 있다.

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

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

컬렉션 함수형 API

1. 필수적인 함수: filter와 map

filtermap 은 컬렉션을 활용할 때 기반이 되는 함수다. 대부분의 컬렉션 연산을 이 두 함수를 통해 표현할 수 있다.

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

결과는 입력 컬렉션의 원소 중에서 주어진 술어를 만족하는 원소만으로 이뤄진 새로운 컬렉션이다.

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

위 예시처럼 filter 함수는 컬렉션에서 원치 않는 원소를 제거한다. 하지만 원소를 변환할 수는 없다. 원소를 변환하려면 map 함수를 사용해야 한다.

map 함수는 주어진 람다를 컬렉션의 각 원소에 적용한 결과를 모아서 새 컬렉션을 만든다.

결과는 원본 리스트와 원소의 개수는 같지만, 각 원소는 주어진 함수에 따라 변환된 새로운 컬렉션이다.

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

이런 함수를 멤버 참조를 사용해 더 멋지게 작성할 수도 있다.

people.map(Person::name)

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

모든 원소가 이 술어를 만족하는지 궁금하다면 all 함수를 쓴다.

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

술어를 만족하는 원소가 하나라도 있는지 궁금하면 any 함수를 쓴다.

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

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

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

술어를 만족하는 원소를 하나 찾고 싶으면 find 함수를 사용한다.
find 함수는 조건을 만족하는 첫 번째 원소를 반환한다.

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

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

컬렉션의 모든 원소를 어떤 특성에 따라 여러 그룹으로 나누고 싶다고 하자. 예를 들어 사람을 나이에 따라 분류해보자. 특성을 파라미터로 전달하면 컬렉션을 자동으로 구분해주는 함수가 있으면 편리할 것이다. groupBy 함수가 그런 역할을 한다.

>>> val people = listOf(Person("Alice", 31),
... Person("Bob", 29), Person("Carol", 31))
>>> println(people.groupBy { it.age })

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

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

flatMap 함수는 먼저 인자로 주어진 람다를 컬렉션의 모든 객체에 적용하고(매핑하고: map) 람다를 적용한 결과 얻어지는 여러 리스트를 한 리스트로 한데 모은다(펼친다: flatten).

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

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

앞서 본 컬렉션 함수들은 결과 컬렉션을 즉시 생성한다. 이는 컬렉션 함수를 연쇄하면 매 단계마다 계산 중간 결과를 새로운 컬렉션에 임시로 담는다는 말이다.

시퀀스 를 사용하면 중간 임시 컬렉션을 사용하지 않고도 컬렉션 연산을 연쇄할 수 있다. 이렇게 하여 중간 결과를 저장하는 컬렉션이 생기지 않으면 원소가 많은 경우 성능이 눈에 띄게 좋아진다.

코틀린 지연 계산 시퀀스는 Sequence 인터페이스에서 시작한다. 이 인터페이스는 단지 한 번에 하나씩 열거될 수 있는 원소의 시퀀스를 표현할 뿐이다.

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

asSequence 확장 함수를 호출하면 어떤 컬렉션이든 시퀀스로 바꿀 수 있다.

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

시퀀스에 대한 연산은 중간 연산최종 연산 으로 나뉜다.

중간 연산은 다른 시퀀스를 반환한다. 그 시퀀스는 최초 시퀀스의 원소를 변환하는 방법을 안다.

중간 연산은 항상 지연 계산된다.

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

위 코드를 실행하면 아무 내용도 출력되지 않는다. 이는 mapfilter 변환이 늦춰져서 결과를 얻을 필요가 있을 때 즉, 최종 연산이 호출될 때 적용된다는 뜻이다.

최종 연산은 결과를 반환한다. 결과는 최초 컬렉션에 대해 변환을 적용한 시퀀스로부터 일련의 계산을 수행해 얻을 수 있는 컬렉션이나 원소, 숫자 또는 객체다.

>>> 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)

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

2. 시퀀스 만들기

지금까지 살펴본 예제는 모두 컬렉션에 대해 asSequence()를 호출해 시퀀스를 만들었다.

시퀀스를 만드는 다른 방법으로 generateSequence 함수를 사용할 수 있다. 이 함수는 이전의 원소를 인자로 받아 다음 원소를 계산한다.

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

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

코틀린 라이브러리와 람다를 사용하는 것은 멋지지만, 우리가 다룰 API 중 상당수는 코틀린이 아니라 자바로 작성된 API일 가능성이 높다.

그리고 아주 다행인 점은 코틀린 람다를 자바 API에 사용해도 아무 문제가 없다는 것이다. 그렇다면 코틀린 람다는 자바 API에 어떻게 활용할 수 있을까?

1. 자바 메서드에 람다를 인자로 전달

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

객체를 명시적으로 선언하는 경우 메서드를 호출할 때 마다 새로운 객체가 생성된다.

postponComputation(1000, object : Runnable { // 객체 식을 함수형 인터페이스 구현으로 넘긴다. 
		override fun run() {
				println(42)
		}
})

그러나 람다는 다르다.

정의가 들어있는 함수의 변수에 접근하지 않는 람다에 대응하는 무명 객체를 메서드를 호출할 때 마다 반복 사용한다.

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

람다가 주변 영역의 변수를 포획한다면 매 호출마다 같은 인스턴스를 사용할 수 없다. 그런 경우 컴파일러는 매번 주변 영역의 변수를 포획한 새로운 인스턴스를 생성해준다.

fun handlerComputation(id: String) {
		postponeComputation(1000) { println(id) } // handlerComputation을 호출할 때마다 새로 Runnable 인스턴스를 만든다. 
}

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

지금까지 살펴본대로 대부분의 경우 람다와 자바 함수형 인터페이스 사이의 변환은 자동으로 이뤄진다. 컴파일러가 그 둘을 자동으로 변환할 수 있는 경우 우리가 할 일은 전혀없다 !

그러나 어쩔 수 없이 수동으로 변환해야 하는 경우도 있다. 이런 경우 어떻게 람다를 처리하는지 살펴보자.

SAM(Single Abstract Method) 생성자는 람다를 함수형 인터페이스의 인스턴스로 변환할 수 있게 컴파일러가 자동으로 생성한 함수다.

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

  • 람다로 생성한 함수형 인터페이스 인스턴스를 변수에 저장해야 하는 경우에도 SAM 생성자를 사용할 수 있다.

  • 함수형 인터페이스를 요구하는 메서드를 호출할 때 가끔 오버로드한 메서드 중에서 어떤 타입의 메서드를 선택해 람다를 변환해 넘겨줘야 할지 모호할 때가 있다. 이런 경우 명시적을 SAM 생성자를 적용하면 컴파일 오류를 피할 수 있다.

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

자바의 람다에는 없는 코틀린 람다의 독특한 기능이 있다.

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

이런 람다를 수신 객체 지정 람다 라고 부른다.

1. with 함수

어떤 객체의 이름을 반복하지 않고도 그 객체에 대해 다양한 연산을 수행할 수 있다면 좋을 것이다. 코틀린에서는 이 기능을 언어 구성 요소로 제공하지 않고 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()
}

// with를 사용해 알파벳 만들기
fun alphabet(): String {
    val stringBuilder = StringBuilder()
    return with(stringBuilder) {
        for (letter in 'A'..'Z') {
            this.append(letter)
        }
        append("\\nNow I know the alphabet!")
        this.toString()
    }
}

with문은 언어가 제공하는 특별한 구문처럼 보인다. 하지만 실제로는 파라미터가 2개 있는 함수다.
여기서 첫 번째 파라미터는 stringBuilder이고, 두 번째 파라미터는 람다다.

with 함수는 첫 번째 인자로 받은 객체를 두 번째 인자로 받은 람다의 수신 객체로 만든다.

인자로 받은 람다 본문에서는 this를 사용해 그 수신 객체에 접근할 수 있다. 일반적인 this와 마찬가지로 this.을 사용하지 않고 프로퍼티나 메서드 이름만 사용해도 수신 객체의 멤버에 접근할 수 있다.

with가 반환하는 값은 람다 코드를 실행한 결과며, 그 결과는 람다 식의 본문에 있는 마지막 식의 값이다. 하지만 때로는 람다의 결과 대신 수신 객체가 필요한 경우도 있다. 그런 경우 apply 라이브러리 함수를 사용할 수 있다.

2. apply 함수

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

profile
학교 다니는 개발자

0개의 댓글