▩목 차▩
1. 람다 식과 멤버 참조
1-1. 람다 소개: 코드 블록을 함수 인자로 넘기기
1-2. 람다와 컬렉션
1-3. 람다 식의 문법
1-4. 현재 영역에 있는 변수에 접근
1-5. 멤버 참조
2. 컬렉션 함수형 API
2-1. 필수적인 함수: filter와 map
2-2. all. any, count, find: 컬렉션에 술어 적용
2-3. groupBy: 리스트를 여러 그룹으로 이뤄진 맵으로 변경
2-4. flatMap과 flatten: 중첩된 컬렉션 안의 원소 처리
3. 지연 계산(lazy) 컬렉션 연산
3-1. 시퀸스 연산 실행: 중간 연산과 최종 연산
3-2. 시퀸스 만들기
4. 자바 함수형 인터페이스 활용
4-1. 자바 메소드에 람다를 인자로 전달
4-2. SAM 생성자: 람다를 함수형 인터페이스로 명시적으로 변경
5. 수신 객체 지정 람다: with와 apply
5-1. with 함수
5-2. apply 함수
6. 정리
람다식 또는 람다는 기본적으로 다른 함수에 넘길 수 있는 작은 코드 조각을 뜻한다. 람다를 사용하면 쉽게 공통 코드 구조를 라이브러리 함수로 뽑아낼 수 있다. 코틀린 표준 라이브러리는 람다를 아주 많이 사용한다.
람다를 자주 사용하는 경우로 컬렉션 처리를 들 수 있고, 이번 5장에서는 컬렉션을 처리하는 패턴을 표준 라이브러리 함수에 람다를 넘기는 방식의 예제를 많이 살펴보겠다. [ 자바 라이브러리와 람다를 함께 사용하는 방법도 살펴본다. ]
또한 수신 객체 지정 람다[ 람다 선언을 둘러싸고 있는 환경과는 다른 상황에서 람다 본문을 실행할 수 있음]에 대해 살펴본다.
[ 헷갈리지 않아야 할 것 ]
멤버 참조 :: 를 사용할때는 중괄호{}가 아닌 그냥 ()괄호를 쓴다. [EX] people.map(Person::name).filter { it.startsWith("A") }
람다 식을 사용할때는 괄호가()가 아닌 중괄호{}를 쓴다. [EX] people.map(Person::name).filter { it.startsWith("A") }
■ 1. 람다 식과 멤버 참조 ■
■ 1-1. 람다 소개: 코드 블록을 함수 인자로 넘기기
이벤트가 발생하면 특정 핸들러를 실행하거나, 데이터 구조의 모든 원소에 특정 연산을 적용하기 위해서 일련의 동작을 변수에 저장하거나 다른 함수에 넘겨야 하는 경우가 자주 있다. [ 자바의 경우 , 무명 내부 클래스(명시적인 선언없이 객체를 바로 생성하는 것)를 사용하면 코드를 함수에 넘기거나 변수에 저장할 수 있기는 하지만 상당히 번거롭다. ]
==> 함수형 프로그래밍에서는 함수를 값처럼 다루는 접근 방법을 택함으로써 이 문제를 해결한다. 클래스를 선언하고 그 클래스의 인스턴스를 함수에 넘기는 대신 함수형 언어에서는 함수를 직접 다른 함수에 전달 할 수 있다. 람다 식을 사용하면 코드가 더욱 더 간결해진다.
람다 식을 사용하면 함수를 선언할 필요가 없고 코드 블록을 직접 함수의 인자로 전달 할 수 있다.
예제로 버튼 클릭에 따른 동작을 정의하고 싶을때라고 가정을 하면, 그런 경우에 클릭 이벤트를 처리하는 리스너를 추가한다. 버튼 클릭 리스너는 onClick이라는 메소드가 들어있는 OnClickListener를 구현해야 한다.
[EX] - 자바의 무명 내부 클래스로 리스너 구현하기
/★ 자바 ★/
button.setOnClickListener(new OnClickListener( 》 {
©Override
public void onClick (View view》{
/* 클릭 시 수행할 동작 *i
});
위의 코드르 보면 무명 내부 클래스를 선언하느라 코드가 번잡하다
==> 클릭 시 벌어질 동작을 간단히 기술할 수 있는 표기법이 있다면 이런 불필요한 코드를 제거할 수 있을 것이다.
[EX] - 코틀린의 람다로 리스너 구현하기
button.setOnClickListener { /★ 클릭 시 수행할 동작 ★/ }
위에 있는 코틀린 코드는 자바의 무명 내부 클래스보다 훨씬 더 간결하고 읽기 쉽다.
이제부터는 함수형 언어에서 람다를 많이 활용해 온 컬렉션에 대해 살펴보자.
■ 1-2. 람다와 컬렉션
자바에서는 쓰기 편한 컬렉션 라이브러리가 적었으며, 그에 따라 자바 개발자들은 필요한 컬렉션 기능을 직접 작성하곤 했다.
==> 코틀린에서는 이런 습관을 버려야 한다!!
예제로, 사람의 이름과 나이를 저장하는 Person 클래스를 사용하자.
data class Person (val name: String, val age: Int)
사람들도 이뤄진 리스트가 있고 그 중에 가장 연장자를 찾고 싶을때, 람다를 사용하지 않는다면 루프를 써서 직접 검색을 구현할 것이다.
아마 나이의 최댓값과 그 최댓값에 해당하는 나이를 먹은 첫 번째 인물을 저장하기 위해 변수를 두 개 만들고 리스트에 이터레이션하면서 그 두 변수를 갱신 할 것이다.
[EX] - 컬렉션을 직접 검색하기
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(args: Array<String>){
val people = listOf(Person("Alice",29), Person("Bob",31))
findTheOldest(people)
}
Person (name=Bob, age=31)
위의 루프는 상당히 많은 코드가 들어있기 때문에 작성하다 실수를 저지르기도 쉽다.
코틀린에서는 더 좋은 방법이 있다. 라이브러리 함수를 쓰면 된다. 다음 예를 보자.
[EX] - 람다를 사용해 컬렉션 검색하기
fun main(args: Array<String>){
val people2 = listOf(Person("Alice",29), Person("Bob",31))
println(people2.maxBy { it.age })
}
Person (name=Bob, age=31)
모든 컬렉션에 대해 maxBy 함수를 호출할 수 있다. maxBy는 가장 큰 원소를 찾기 위해 비교에 사용할 값을 돌려주는 함수를 인자로 받는다. 중괄호로 둘러싸인 코드 { it.age}는 바로 비교에 사용할 값을 돌려주는 함수다. 이 코드는 컬렉션의 원소를 인자로 받아서 (it이 그 인자를 가르킨다.) 비교에 사용할 값을 반환다. 이 예제에서는 컬렉션의 원소가 Person 객체였으므로 이 함수가 반환하는 값은 Person 객체의 age 필드에 저장된 나이 정보다.
==> 이런 식으로 단지 함수나 프로퍼티를 반환하는 역할을 수행하는 람다는 멤버 참조로 대치할 수 있다.
[EX] - 멤버 참조를 사용해 컬렉션 검색하기
println( people2.maxBy ( Person::age ))
위의 코드는 people.maxBy { it.age} 와 같은 역할을 한다.
자바 컬렉션에 대해 수행하던 대부분의 작업은 람다나 멤버 참조를 인자로 취하는 라이브러리 함수를 통해 개선할 수 있다.
==> 람다나 멤버참조를 인자로 받는 함수를 통해 개선한 코드는 더 짧고 이해하기 쉽다.
■ 1-3. 람다 식의 문법
람다는 값처럼 여기저기 전달할 수 있는 동작의 모음이다. 람다를 따로 선언해서 변수에 저장할 수도 있다. 하지만 함수에 인자로 넘기면서 바로 람다를 정의하는 경우가 대부분이다.
람다식을 선언하기 위한 문법을 보자.
코틀린의 람다 식은 항상 중괄호로 둘러싸여 있다. 인자 목록 주변에 괄호가 없다는 사실을 꼭 기억하라. 또한 화살표(->)가 인자 목록과 람다 본문을 구분해준다.
람다 식을 변수에 저장할 수 있다. 람다가 저장된 변수를 다른 일반 함수와 마찬가지로 다룰 수 있다.
val sum = { x: Int, y: Int -> x + y}
println(sum(5,7))
12
람다를 만들자마자 호출할 수도 있지만 람다 본문을 직접 실행하는 편이 낫다.
run { println(42) }
또한 코드의 일부분을 블록으로 둘러싸 실행할 필요가 있다면 run을 사용한다. 실행 시점에 코틀린 람다 호출에는 아무 부가 비용이 들지 않는다.
fun main(args: Array<String>){
val people2 = listOf(Person("Alice",29), Person("Bob",31))
println(people2.maxBy { it.age })
}
Person (name=Bob, age=31)
위의 코드에서 코틀린이 코드를 줄여 슬 수 있게 제공했던 기능을 제거하고 정식으로 람다를 작성하면 아래와 같다.
people.maxBy({ p: Person -> p.age })
위의 코드를 설명하자면, 중괄호 안에 있는 코드는 람다식이고 그 람다 식을 maxBy함수에 넘긴다. 람다식은 Person 타입의 값을 인자로 받아서 인자의 age를 반환한다.
==> 하지만 이 코드는 번잡하고 구분자가 많이 스여서 가독성이 떨어진다. 또한 컴파일러가 문맥으로부터 유추할 수 있는 인자 타입을 굳이 적을 필요는 없다. 또한 인자가 단 하나뿐인 경우에 인자에 이름을 붙이지 않아도 된다. 지금 언급한 문제점들을 개선해보자.
중괄호부터 시작해본다면, 코틀린에는 함수 호출 시 맨 뒤에 있는 인자가 람다 식이면 그 람다를 괄호 밖으로 빼낼 수 있다는 문법 관습이 있다. 이 예제에서는 람다가 유일한 인자이므로 마지막 인자이기도 하다. 따라서 괄호 뒤에 람다를 둘 수 있다.
people.maxBy() { p: Person -> p.age }
또한 람다가 어떤 함수의 유일한 인자이고 괄호 뒤에 람다를 썼다면 호출 시 빈 괄호를 없애도 된다.
people.maxBy { p: Person -> p.age }
위의 세가지 형태는 모두 같은 뜻이지만 위에 있는 마지막 문장이 가장 읽기 쉽다. 람다가 함수의 유일한 인자라면 괄호 없이 람다를 바로 쓰는 것을 강추한다.
인자가 여럿 있는 경우에는 람다를 밖으로 빼낼 수도 있고 람다를 괄호 안에 유지해서 함수의 인자임을 분명히 할 수도 있다. 두 방식 모두 정당하다. 둘 이상의 람다를 인자로 받는 함수라고 해도 인자 목록의 맨 마지막 람다만 밖으로 뺄 수 있다.
==> 그런 경우에는 괄호를 사용하는 일반적인 함수 호출 구문을 사용하는 편이 낫다.
jointToString은 맨 마지막 인자로 함수를 더 받는다. 또한 리스트의 원소를 toString이 아닌 다른 방식을 통해 문자열로 변환하고 싶은 경우 이 인자를 활용한다.
다음 예제는 각 사람의 이름만 출력하기 위해 joinToString에 람다를 사용한 예제이다.
[EX] - 이름 붙인 인자를 사용해 람다 넘기기
fun main(args: Array<String>){
val people2 = listOf(Person("Alice",29), Person("Bob",31))
val names = people2.joinToString(separator = " ",
transform = { p: Person -> p.name})
println(names)
}
Alice Bob
[EX] - 람다를 괄호 밖에 전달하기
people2.joinToString(" ") { p: Person -> p.name }
이해하기에는 이름 붙인 인자를 사용해 람다 넘기기 예자가 쉽고, 람다를 괄호 밖에 전달하기 예제는 더 어렵지만 간결하다.
[EX] - 람다를 괄호 밖에 전달하기2
people2.joinToString(" "){it.name }
var aaa = people2.joinToString(" " )(Person::name)
var aaaa = people2.joinToString(" " ,(Person::name))
위에 있는 예제를 더 간단하게 다듬고 파라미터 타입을 없애보자.
[EX] - 람다 파라미터 타입 제거하기
people2.maxBy { p:Person -> p.age } //파라미터 타입을 명시
people2.maxBy { p-> p.age } //파라미터 타입을 생략(컴파일러가 추론)
로컬 변수처럼 컴파일러는 람다 파라미터의 타입도 추론할 수 있다.
==> 따라서 타입을 명시할 필요가 없다. maxBy 함수의 경우 파라미터의 타입은 항상 컬렉션 원소 타입과 같다. 컴파일러는 Person 타입의 객체가 들어있는 컬렉션에 대해 maxby를 호출한다는 사실을 알고 있으므로 람다의 파라미터도 Person이라는 사실을 이해 할 수 있다.
[ 처음에는 타입을 쓰지 않고 람다를 작성하고 컴파일러가 타입을 모르겠다고 오류를 내면 그때 타입을 명시하자. ]
람다의 파라미터 이름을 디폴트 이름인 it으로 바꾸면 람다 식을 더 간단하게 만들 수 있다. 람다의 파라미터가 하나뿐이고 그 타입을 컴파일러가 추론할 수 있는 경우 it을 바로 쓸 수 있다.
people.maxBy { it.age } // it은 자동 생성된 파라미터 이름이다.
람다 파라미터 이름을 따로 지정하지 않은 경우에만 it이라는 이름이 자동으로 만들어진다.
it을 사용하면 코드를 아주 간단하게 만들어준다. 하지만 이를 남용하면 안된다. 특히, 람다 안에 람다가 중첩되는 경우 각 람다의 파라미터를 명시하는 편이 낫다. 왜냐하면 각각의 it이 가리키는 파라미터가 어떤 람다에 속했는지 파악하기 어렵기 때문이다.
람다를 변수에 저장할 떄는 파라미터의 타입을 추론할 문맥이 존재하지 않는다.
==> 파라미터 타입을 명시해야 한다.
val getAge = { p:Person -> p.age}
people2.maxBy(getAge)
지금까지는 한 문장(식)으로 이뤄진 작은 람다만을 예제로 살펴봤는데, 꼭 한 줄로 이뤄진 작은 람다만 있지 않다.
본문이 여러 줄로 이뤄진 경우 본문의 맨 마지막에 있는 식이 람다의 결과가 된다.
■ 1-4. 현재 영역에 있는 변수에 접근
자바 메소드 내에서 익명 내부 클래스 정의시 메소드 로컬 변수를 익명 내부 클래스에서 사용할 수 있다.
람다에서도 동일하게 함수 내에서 정의한다면 함수 파라미터 뿐이 아닌 람다 앞에 존재하는 로컬 변수까지 모두 사용이 가능하다.
람다 내에서 사용하는 외부 변수를 람다가 포획한 변수 라고 한다.
[ 위의 기능을 보여주기 위해 forEach 표준 함수를 사용해볼 건데, forEach는 가장 기본적인 컬렉션 조작 함수 중 하나이며 컬렉션의 모든 원소에 대해 람다를 호출해준다. forEach는 일반적인 for 루프보다 훨씬 간결하지만 그렇다고 다른 장점이 많지는 않다. ]
[EX] - 함수 파라미터를 람다 안에서 사용하기
package list5
fun printMessageWithPrefix(messages: Collection<String>, prefix:String){
messages.forEach{// 각 원소에 대해 수행할 작업을 람다로 받는다.
println("$prefix $it") //람다 안에서 함수의 "prefix" 파라미터를 사용한다.
}
}
fun main(args: Array<String>){
val errors = listOf("403 Forbidden", "404 Not Found")
printMessageWithPrefix(errors,"Error:")
}
Error: 403 Forbidden
Error: 404 Not Found
자바와 다른 점 중 중요한 한 가지는 코틀린 람다 안에서는 파이널 변수가 아닌 변수에 접근할 수 있다는 점이다. 또한 람다 안에서 바깥의 변수를 변경해도 된다.
다음 예제는 전달받은 상태 코드 목록에 있는 클라이언와 서버 오류의 횟수를 세는 리스트다.
[EX] - 람다 안에서 바깥 함수의 로컬 변수 변경하기
package list5
fun printProblemCounts(resposes: Collection<String>) {
var clientErrors = 0 //람다에서 사용할 변수를 정의한다.
var serverErrors = 0 //람다에서 사용할 변수를 정의한다.
resposes.forEach{
if(it.startsWith("4")){
clientErrors++ //람다 안에서 람다 밖의 변수를 변경한다.
}
else if (it.startsWith("5")){
serverErrors++ //람다 안에서 람다 밖의 변수를 변경한다.
}
}
println("$clientErrors client errors, $serverErrors server errors")
}
fun main(args: Array<String>){
val resposes = listOf("200 OK", "418 I'm a teapot", "500 Internal Server Error")
println(printProblemCounts(resposes))
}
1 client errors, 1 server errors
위의 코드를 보게 되면, 코틀린에서는 자바와 달리 람다에서 람다 밖 함수에 있는 파이널이 아닌 변수에 접근할 수 있고, 그 변수를 변경할 수도 있다. 위의 예제들에선 prefix, clientErrors, serverErrors와 같이 람다 안에서 사용하는 외부 변수를 '람다를 포힉한 변수'라고 부른다.
기본적으로 함수 안에 정의된 로컬 변수의 생명주기는 함수가 반환되면 끝난다. 하지만 어떤 함수가 자신의 로컬 변수를 포획한 람다를 반환하거나 다른 변수에 저장한다면 로컬 변수의 생명주기와 함수의 생주기가 달라질 수 있다. 포획한 변수가 있는 람다를 저장해서 함수가 끝난 뒤에 실행해도 람다의 본문 코드는 여전히 포획한 변수를 읽거나 쓸 수 있다. 어떻게 가능한 것인가..?
==> 파이널 변수를 포획한 경우에는 람다 코드를 변수 값과 함께 저장한다. 파이널이 아닌 변수를 포획한 경우에는 변수를 특별한 래퍼로 감싸서 나중에 변경하거나 읽을 수 있게 한 다음 래퍼에 대한 참조를 람다 코드와 함께 저장한다.
한 가지 꼭 알아둬야 할 함정이 있다. 람다를 이벤트 핸들러나 다른 비동기적으로 실행되는 코드로 활용하는 경우 함수 호출이 끝난 다음에 로컬 변수가 변경될 수도 있다.
예를 들어 다음 코드는 버튼 클릭 횟수를 제대로 셀 수 없다. [ 위에 있는 예제와 같이 람다 안에서 바깥 함수의 로컬 변수 변경하기가 같아 보이는데 왜 결과같이 항상 0이 나올까? ]
fun tryToCountButtonClicks(button: Button): Int {
var clicks = 0
button.onClick { clicks++ }
return clicks
}
이 함수는 항상 0을 반환한다. onClick 핸들러는 호출될 때마다 clicks의 값을 증가시키지만 그 값의 변경을 관찰 할 수는 없다.
핸들러는 tryToCountButtonClicks가 clicks를 반환한 다음에 호출되기 때문이다.
==> 이 함수를 제대로 구현하려면 클릭 횟수를 세는 카운터 변수를 함수의 내부가 아니라 클래스의 프로퍼티나 전역 프로퍼티 등의 위치로 빼내서 나중에 변수 변 변화를 살펴볼 수 있게 해야 한다.
■ 1-5. 멤버 참조
람다를 사용해 코드 블록을 다른 함수에게 인자로 넘기는 방법을 살펴봤다. 하지만 넘기려는 코드가 이미 함수로 선언된 경우는 어떻게 해야 할까?
==> 물론 그 함수를 호출하는 람다를 만들면 되지만 중복이다. 함수를 직접 넘길 수 없을까?
코틀린에서는 함수를 값으로 바꿀 수 있다. 이 때 이중 콜론(::)을 사용한다.
val getAge = Person::age
::를 사용하는 식을 멤버 참조 라고 한다. 멤버 참조는 프로퍼티나 메소드를 단 하나만 호출하는 함수 값을 만들어준다.
::는 클래스 이름과 참조하려는 멤버(프로퍼티나 메소드) 이름 사이에 위치한다.
위의 멤버 참조 문법은 아래의 람다 식을 더 간략하게 표현한 것이다.
val getAge = { person: Person -> person.age }
멤버 참조는 그 멤버를 호출하는 람다와 같은 타입이다. 따라서 다음 예처럼 그 둘을 자유롭게 바꿔 쓸 수 있다.
people.maxBy (Person::age)
people.maxBy { p -> p.age }
people.maxBy { it.age }
최상위 선언된(그리고 다른 클래스의 멤버가 아닌) 함수나 프로퍼티를 참조할 수도 있다.
fun salute() = println("Salute!")
fun main(args: Array<String>){
run(::salute) // 최상위 함수를 참조한다.
}
Salute!
클래스 이름을 생략하고 ::로 참조를 바로 시작한다. ::salute라는 멤버 참조를 run 라이브러리 함수에 넘긴다. 그 후 run은 인자로 받은 람다를 호출한다.
람다가 인자가 여럿인 다른 함수한테 작업을 위임하는 경우 람다를 정의하지 않고 직접 위임 함수에 대한 참조를 제공하면 편하다.
var action = { person: Person, message:String -> //이 람다는 sendEmail 함수에게 작업을 위임한다.
sendEmail(person,message)
}
val nextAction = ::sendEmail //람다 대신 멤버 참조를 쓸 수 있다.
생성자 참조를 사용하면 클래스 생성 작업을 연기하거나 저장해둘 수 있다.
:: 뒤에 클래스 이름을 넣으면 생성자 참조를 만들 수 있다.
fun main(args: Array<String>){
val createPerson = ::Person //Person의 인스턴스를 만드는 동작을 값으로 저장한다.
val p = createPerson("Alcie",29)
println(p)
}
확장 함수도 멤버 함수와 똑같은 방식으로 참조할 수 있다는 점[ 확장 함수도 클래스 인스턴스 변수(프로퍼티)를 이용할 수 있다. 이 예제 에서는 Person클래스의 확장 함수 isAdult()는 Person의 프로퍼티를 이용했다 ].을 기억하라.
fun main(args: Array<String>){
val createPerson = ::Person //Person의 인스턴스를 만드는 동작을 값으로 저장한다.
val p = createPerson("Alcie",29)
val predicate = p::isAdult
println(p.isAdult())
}
fun Person.isAdult() = age >= 21
isAdult는 Person 클래스의 멤버가 아니고 확장 함수다. 그렇지만 isAdult를 호출할 때 person.isAdult()로 인스턴스 멤버 호출 구문을 쓸 수 있는 것처럼 Persson::isAdult로 멤버 참조 구문을 사용해 이 확장 함수에 대한 참조를 얻을 수 있다.
■ 2. 컬렉션 함수형 API ■
함수형 프로그래밍 스타일을 사용하면 컬렉션을 다룰 때 편리하다.
==> 대부분의 작업에 라이브러리 함수를 활용할 수 있고 그로 인해 코드를 아주 간결하게 만들 수 있다.
이번 장은 filter와 map 함수와 그 두 함수를 뒷받침하는 개념으로부터 시작한다.
그 후 다른 유용한 함수를 살펴보면서 함수형 API를 활용하되 남용하지는 않으면서 코드를 더 이해하기 쉽고 깔끔하게 작성하는 방법에 대해 조언한다.
[ 함수형 프로그래밍에서는 람다나 다른 함수를 인자로 받거나 함수를 반환하는 함수를 고차함수라고 부른다. 고차 함수는 기본 함수를 조합해서 새로운 연산을 정의하거나, 다른 고차 함수를 통해 조합된 함수를 또 조합해서 더 복잡한 연산을 쉽게 정의할 수 있다는 장점이 있다.
이런 식으로 고차 함수와 단순한 함수를 이리저리 조합해서 코드를 작성하는 기법을 컴비네이터 패턴이라 부르고, 컴비네이터 패턴에서 복잡한 연산을 만들기 위해 값이나 함수를 조합할 때 사용하는 고차 함수를 컴비네이터라고 부른다. ]
■ 2-1. 필수적인 함수: filter와 map
filter와 map은 컬렉션을 활용할 때 기반이 되는 함수다. 대부분의 컬렉션 연산을 이 두 함수를 통해 표현할 수 있다.
숫자를 사용한 예제와 Person을 사용한 예제를 통해 이 두 함수를 자세히 살펴보자.
data class Person(val name: String, val age: Int)
filter 함수(필터 함수 또는 걸러내는 함수라고 부름)는 컬렉션을 이터레이션하면서 주어진 람다에 각 원소를 넘겨서 람다가 true를 반환하는 원소만 모은다.
fun main(args: Array<String>){
val list = listOf(1,2,3,4)
println(list.filter { it % 2 ==0 })
}
[2, 4]
결과는 입력 컬렉션의 원소 중에서 주어진 술어(참/거짓을 반환하는 함수를 술어라고 함)를 만족하는 원소만으로 이뤄진 새로운 컬렉션이다.
만약 30살 이상인 사람만 필요하다면 filter를 사용한다.
fun main(args: Array<String>){
val people = listOf(Person("Alice",29), Person("Bob",31))
println(people.filter { it.age>30 })
}
[Person(name=Bob, age=31)]
filter 함수는 컬렉션에서 원치 않는 원소를 제거한다. 하지만 filter는 원소를 변환 할 수는 없다.
==> 원소를 변환하려면 map 함수를 사용해야 한다.
mpa함수는 주어진 람다를 컬렉션의 각 원소에 적용한 결과를 모아서 새 컬렉션을 만든다. 다음과 같이 하면 숫자로 이뤄진 리스트를 각 숫자의 제곱이 모인 리스트로 바꿀 수 있다.
package list5
fun main(args: Array<String>){
val list = listOf(1,2,3,4)
println(list.map { it*it })
}
[1, 4, 9, 16]
사람의 리스트가 아니라 이름의 리스트를 출력하고 싶다면 map으로 사람의 리스트를 이름의 리스트로 변환하면 된다.
package list5
fun main(args: Array<String>){
val people = listOf(Person("Alice",29), Person("Bob",31))
println(people.map { it.name })
}
[Alice, Bob]
이 예제를 멤버 참조를 사용해 간결하게 작성할 수도 있다.
fun main(args: Array<String>){
val people = listOf(Person("Alice",29), Person("Bob",31))
var aa = people.map(Person::name)
println(aa)
}
[Alice, Bob]
filter와 map을 함께 사용할 수 있다. 예를 들어 30살 이상인 사람의 이름을 출력해보자.
fun main(args: Array<String>){
val people = listOf(Person("Alice",29), Person("Bob",31))
var bb = people.filter { it.age>30 }.map ( Person::name )
println(bb)
}
[Bob]
이 예제에서 people.filter { it.age > 30 }은 people.filter {(it.age > 30)}과 같으므로
전체 식은 people.filter({ it.age >30 }).map(Person::name)으로 해석된다.
이제 이 목록에서 가장 나이 많은 사람의 이름을 알고 싶다고 하면, 먼저 목록에 있는 사람들의 나이의 최댓값을 구하고 나이가 그 최댓값과 같은 모든 사람을 반환하면 된다. 람다를 사용하면 쉽게 그런 코드를 작성 가능하다.
people.filter { it.age == people.maxBy(Person::age)!!.age }
위의 코드는 만약 100명의 사람이 있다면 100번의 최댓값 연산을 수행하는 단점이 있다..
아래의 코드는 위의 코드를 개선해 최댓값을 한 번만 계산하게 만든 코드다.
val maxAge = people.maxBy(Person::age)!!.age
people.filter { it.age==maxAge }
람다를 인자로 받는 함수에 람다를 넘기면 겉으로 볼 때는 단순해 보이는 식이 내부 로직의 복잡도로 인해 실제로는 엄청나게 불합리한 계산식이 될 때가 있다.
필터와 변환 함수를 맵에 적용할 수도 있다.
val numbers = mapOf(0 to "zero", 1 to "one")
println(numbers.mapValues { it.value.toUpperCase() })
맵의 경우 키와 값을 처리하는 함수가 따로 존재한다. filterKeys와 mapKeys는 키를 걸러 내거나 변환하고, flterValues와 mapValues는 값을 걸러 내거나 변환한다.
■ 2-2. all. any, count, find: 컬렉션에 술어 적용
컬렉션에 대해 자주 수행하는 연산으로 컬렉션의 모든 원소가 어떤 조건을 만족하는지 판단하는 연산이 있다.
==> 코틀린에서는 all과 any가 이런 연산이다. count함수는 조건을 만족하는 원소의 개수를 반환하며, find 함수는 조건을 만족하는 첫 번째 원소를 반환한다.
예제로, 어떤 사람의 나이가 27살 이하인지 판단하는 술어 함수 canBeInClub27를 만들어보자.
val canBeInClub27 = { p:Person -> p.age<=27}
val canBeInClub27 = (Person::age)
[ 위에 있는 멤버 변수를 이용하여 조건을 주기 위해선 어떻게 고쳐야 할까..? ]
모든 원소가 이 술어를 만족하는지 궁금하다면 all 함수를 쓴다.
package list5
fun main(args: Array<String>){
val canBeInClub27 = { p:Person -> p.age<=27}
val people = listOf(Person("Alice",27), Person("Bob",31))
println(people.all(canBeInClub27))
}
false
술어를 만족하는 원소가 하나라도 있는지 궁금하면 any 함수를 쓴다.
package list5
fun main(args: Array<String>){
val canBeInClub27 = { p:Person -> p.age<=27}
val people = listOf(Person("Alice",27), Person("Bob",31))
println(people.any(canBeInClub27))
}
println(people.all{it.age<=27 })
true
!all을 수행한 결과는 조건의 부정에 대해 any를 수행한 결과는 같다. [ 드모간의 법칙 ]
가독성을 위해서라면 any와 all앞에 !를 붙이지 않는 편이 낫다.
package list5
fun main(args: Array<String>){
val list = listOf(1,2,3)
println(!list.all { it==3 }) // 아래의 조건의 부정에 대해 any와 같다.
println(list.any { it !=3 }) //위의 all 부정과 같다.
}
true
true
첫 번째 식은 list의 모든 원소가 3인 것은 아니라는 뜻이다. 즉, 적어도 하나는 3이 아니라는 말
두 번째 식은 list의 원소 중 3이 아닌 것은 있다라는 뜻이다. 즉, 적어도 하나는 3이 아니라는 말
즉, 첫번째와 두번째 식은 같다.
술어를 만족하는 원소의 개수를 구하려면 count 함수를 사용한다.
package list5
fun main(args: Array<String>){
val canBeInClub27 = { p:Person -> p.age<=27}
val people = listOf(Person("Alice",27), Person("Bob",31))
println(people.count(canBeInClub27))
}
1
count 함수를 사용하지 않고 size 함수를 이용하는 경우가 있다.
이렇게 size를 이용하면 조건을 만족하는 모든 원소가 들어가는 중간 콜렉션이 생긴다.
반면 count는 조건을 만족하는 원소의 개수만을 추적하지 조건을 만족하는 원소를 따로 저장하지 않아서 count가 더 효율적이다.
술어를 만족하는 원소를 하나 찾고 싶으면 find 함수를 사용한다.
package list5
fun main(args: Array<String>){
val canBeInClub27 = { p:Person -> p.age<=27}
val people = listOf(Person("Alice",27), Person("Bob",31))
println(people.find(canBeInClub27))
}
Person(name=Alice, age=27)
조건을 만족하는 원소가 하나라도 있는 경우 가장 먼저 조건을 만족한다고 확인된 원소를 반환하며, 만족하는 원소가 전혀 없는 경우 null을 반환한다. find는 firstOrNull과 같다.
■ 2-3. groupBy: 리스트를 여러 그룹으로 이뤄진 맵으로 변경
컬렉션의 모든 원소를 어떤 특성에 따라 여러 그룹으로 나누고 싶다면, groupBy 함수를 이용하자.
예를 들어 나이에 따라 분류를 해본다면, 특성을 파라미터로 전달하면 컬렉션을 자동으로 구분해주는 함수가 있으면 편리할 것이다.
==> groupBy 함수가 해결해준다.
package list5
fun main(args: Array<String>){
val people = listOf(Person("Alice",27), Person("Bob",31), Person("Carol",31))
println(people.groupBy{it.age})
}
{27=[Person(name=Alice, age=27)], 31=[Person(name=Bob, age=31), Person(name=Carol, age=31)]}
위의 코드에서 결과를 보게 되면 컬렉션의 원소를 구분하는 특성(age)이 키이고, 키 값에 따른 각 그룹(Person 객체의 모임)이 값인 맵이다.
groupBy의 결과 타입은 Map<Int, List<Person>>이다. 필요하면 이 맵을 mapKey나 mapValues 등을 사용해 변경 할 수 있다.
다른 예로 멤버 참조를 활용해 문자열을 첫 글자에 따라 분류하는 코드를 보자.
fun main(args: Array<String>){
var list = listOf("a","ab","b")
println(list.groupBy(String::first))
}
{a=[a, ab], b=[b]}
first는 String의 멤버가 아니라 확장함수지만 여전히 멤버 참조를 사용해 first에 접근할 수 있다.
■ 2-4. flatMap과 flatten: 중첩된 컬렉션 안의 원소 처리
Book으로 표현한 책에 대한 정보를 저장하는 도서관이 있다고 가정하자.
class Book(val title: String, val authors: List<String>)
책마다 저자가 한 명 혹은 여러 명 있다. 도서관에 있는 책의 저자를 모두 모은 집합을 다음과 같이 가져올 수 있다.
fun main(args: Array<String>){
var book = listOf( Book("title", listOf("author1","author2","author3","author2")))
println( book.flatMap { it.authors}.toSet()) //toSet을 이용하여 중복 불가를 만들었다.
}
[author1, author2, author3]
flatMap 함수는 먼저 인자로 주어진 람다를 컬렉션의 모든 객체에 적용하고 람다를 적용한 결과 얻어지는 여러 리스트를 한 리스트로 한 곳에 모은다.
문자열에 대한 예를 보며 이해해보자.
fun main(args: Array<String>){
var Strings = listOf("abc","def")
println(Strings.flatMap { it.toList() })
}
[a, b, c, d, e, f]
toList 함수를 문자열에 적용하면 그 문자열에 속한 모든 문자로 이뤄진 리스트가 만들어진다.
map과 toList를 함께 사용하면 위의 그림 5.6의 가운데 줄에 표현한 것처럼 문자로 이뤄진 리스트가 생긴다.
flatMap 함수는 다음 단계로 리스트의 라스트에 들어있던 모든 원소로 이뤄진 단일 리스트를 반환한다.
다시 책으로 돌아와서 도서관을 리스트로 만들어보자.
fun main(args: Array<String>){
var book = listOf( Book("Thursday Next", listOf("Jasper Eforde")),
Book("Mort", listOf("Terry Pratchett")),
Book("Good Omens", listOf("Terry Pratchett","Neil Gaiman")))
println( book.flatMap { it.authors}.toSet())
}
[Jasper Eforde, Terry Pratchett, Neil Gaiman]
책을 여러 작가가 함께 쓸 수도 있다. book.authors 프로퍼티는 작가를 모아둔 컬렉션이다. flatMap 함수는 모든 책의 작가를 평평한(문자열만으로 이뤄진) 리스트 하나로 모은다. toSet은 flatMap의 결과 리스트에서 중복을 없애고 집합으로 만든다. 그렇기데 최종 출력에서는 Terry Pratchett를 한번만 볼 수 있다.
리스트의 리스트가 있는데 모든 중첩된 리스트의 원소를 한 리스트로 모아야 한다면 flatMap을 떠올 릴 수 있을 것이다.!!!!
하지만 특별히 변환해야 할 내용이 없다면 리스트의 리스트를 평평하게 펼지기만 하면 된다. 그런 경우 listOfLists.flatten()처럼 flatten 함수를 사용할 수 있다.
지금까지 코틀린 표준 라이브러리가 제공하는 몇 가지 켈렉션 연산 함수를 살펴봤는데, 아직 배우지 않은 것도 많다.
컬렉션을 다루는 코드를 작성할 경우에는 원하는 바를 어떻게 일반적인 변환을 사용해 표현할 수 있는지 생각해보고 그런 변환을 제공하는 라이브러리 함수가 있는지 살펴보고 공부해보아라.
대부분 경우 원하는 함수를 찾을 수 있을 것이고, 찾은 함수를 활용하면 직접 코드로 구현하는 것보다 문제를 해결하기 빠르고 쉬울 것이다.
■ 3. 지연 계산(lazy) 컬렉션 연산 ■
앞에서 map이나 filter 같은 몇 가지 컬렉션 함수를 살펴보았는데 이러한 함수는 결과 컬렉션을 즉시 생성한다.
==> 이 말은 컬렉션 함수를 연쇄하면 매 단계마다 계산 중간 결과를 새로운 컬렉션에 임시로 담는다는 말이다. 즉, 매번 메모리를 소비한다는 말이다.
시퀸스를 사용하면 중간 임시 컬렉션을 사용하지 않고도 컬렉션 연산을 연쇄할 수 있다.
예를 보자.
people.map(Person::name).filer{ it.startWith("A") }
코틀린 표준 라이브러리 참조 문서에 filter와 map이 리스트를 반환한다고 써 있다. 즉, 연쇄 호출이 리스트를 2개 만든다는 뜻이다.
한 리스트는 filter의 결과를 담고, 다른 하나는 map의 결과를 담는다.
원본 리스트에 원소가 몇 개 없다면 문제가 되지 않겠지만, 원소가 수백만 개가 되면 효율이 훨씬 떨어질 것이다.
==> 이를 더 효율적으로 만들기 위해서는 각 연산이 컬렉션을 직접 사용하는 대신 시퀀스를 사용하게 만들어야 한다.
fun main(args: Array<String>){
val people = listOf(Person("Alice",27), Person("Bob",31)) //원본 컬렉션을 시퀀스로 변환한다.
println( people.asSequence().map(Person::name) //시퀸스도 컬렉션과 똑같은 API를 제공한다.
.filter { it.startsWith("A") } //시퀸스도 컬렉션과 똑같은 API를 제공한다.
.toList()) // 결과 시퀸스를 다시 리스트로 변환한다.
}
[Alice]
전체 연산을 수행한 결과는 이름이 "A"로 시작하는 사람의 목록으로 이전의 예제와 같다.
하지만 방금 살펴본 예제에서는 중간 결과를 저장하는 컬렉션이 생기지 않기 때문에 원소가 많은 경우 성능이 눈의 띄게 좋아진다.
코틀린 지연 계산 시퀸스는 Sequence 인터페이스에서 시작한다. 이 인터페이스는 단지 한 번에 하나씩 열거될 수 있는 원소의 시퀸스를 표현할 뿐이다. Sequence 안에는 iterator라는 단 하나의 메소드가 있다. 이 iterator라는 단 하나의 메소드를 통해 시퀸스로부터 원소 값을 얻을 수 있다.
Sequence 인터페이스의 강점은 그 인터페이스 위에 구현된 연산이 계산을 수행하는 방법 때문에 생긴다. 시퀸스의 원소는 필요할 때 비로소 계산된다.
==> 따라서 중간 처리 결과를 저장하지 않고도 연산을 연쇄적으로 적용해서 효율적으로 계산을 수행할 수 있다.
asSuquence 확장 함수를 호출하면 어떤 컬렉션이든 시퀸스로 바꿀 수 있다. 시퀸스를 리스트로 만들 때는 toList를 사용한다.
왜 시퀸스를 다시 컬렉션으로 되돌려야 할까? 컬렉셔보다 시퀸스가 훨씬 더 낫다면 그냥 시퀸스를 쓰면 되지 않을까?
==> 때에 따라 다르다이다. 시퀸스의 원소를 차례로 이터레이션해야 한다면 시퀸스를 직접 써도 된다. 하지만 시퀸스 원소를 인덱스를 사용해 접근하는 등의 다른 API 메소드가 필요하다면 시퀸스를 리스트로 변환해야 한다.
큰 컬렉션에 대해서 연산을 연쇄시킬 때는 시퀸스를 사용하는 것을 규칙으로 삼아야한다.
컬렉션에 들어있는 원소가 많으면 중간 원소를 재배열하는 비용이 커지기 때문에 지연 계산이 더 낫다.
시퀸스에 대한 연산을 지연 계산하기 때문에 정말 계산을 실행하게 만들려면 최종 시퀸스의 원소를 하나씩 이터레이션하거나 최종 시퀸스를 리스트로 변환해야 햔다.
■ 3-1. 시퀸스 연산 실행: 중간 연산과 최종 연산
시퀸스에 대한 연산은 중간연산과 최종연산으로 나뉜다.
중간 연산은 다른 시퀸스를 반환한다. 그 시퀸스는 최초 시퀸스의 원소를 변환하는 방법을 안다.
최종 연산은 결과를 반환한다. 결과는 최초 컬렉션에 대해 변환을 적용한 시퀸스로부터 일련의 계산을 수행해 얻을 수 있는 컬렉션이나 원소, 숫자 또는 객체다.
중간 연산은 항상 지연 계산된다. 최종 연산이 없는 예제를 한번 보자.
val list = listOf(1, 2, 3, 4).asSequence().map { print("map($it)"); it*it }
.filter { print("filter($it)"); it % 2==0 }
위의 코드를 실행하면 아무 내용도 출력되지 않는다.
==> map과 filter 변환이 늦춰져서 결과를 얻을 필요가 있을 때(최종 연산이 호출될때) 적용된다는 뜻이다.
fun main(args: Array<String>){
val list2 = listOf(1, 2, 3, 4).asSequence().map { print("map($it)"); it*it }
.filter { print("filter($it)"); it % 2==0 }.toList()
println(list2)
}
map(1)filter(1)map(2)filter(4)map(3)filter(9)map(4)filter(16)[4, 16]
위의 코드같이 최종 연산을 호출하면 연기됐던 모든 계산이 수행된다.
연산 수행 순서는 중요하다. 직접 연산을 구현한다면 map함수를 각 원소에 대해 먼저 수행해서 새 시퀸스를 얻고, 수행해서 새 시퀸스를 얻고, 그 시퀸스에 대해 다시 filter를 수행할 것이다. 컬렉션에 대한 map과 filter는 그런 방식이다.
하지만 시퀸스에 대한 map과 filter는 그렇지 않다. 시퀸스의 경우 모든 연산은 각 원소에 대해 순차적으로 적용된다.
즉, 첫번째 원소가 (변환된 다음에 걸러짐) 처리되고, 다시 두번 째 원소가 처리되며, 이런 처리가 모든 원소에 적용된다.
==> 따라서 원소에 연산을 차례대로 적용하다가 결과가 얻어지면 그 이후의 원소에 대해서는 변환이 이루어지지 않을 수도 있다.
위의 예를 map과 find 연산으로 들어보자. [ map으로 리스트의 각 숫자를 제곱하고 제곱한 숫자 중에서 find로 3보다 큰 첫 번째 원소를 찾아보자. ]
fun main(args: Array<String>){
println(listOf(1, 2, 3, 4).asSequence().map { it*it }.find { it>3 })
}
4
위는 시퀸스 사용을 통한 지연계산의 예이다.
- 지연 계산(시퀸스 사용)은 컬렉션 안의 원소를 한번에 하나씩만 처리하고 그 후 그 다음 원소를 처리하고 먼저 조건이 맞는 원소를 찾게 된다.
- 즉시 계산(컬렉션 사용)은 컬렉션의 모든 원소를 연산을 하는 형식으로 처리하고 거기에 맞는 조건을 찾게 된다.
==> 그림으로 보면 이해하기 쉬울 것이다.
위의 그림을 살펴보자.
- 컬렉션을 사용하면 리스트가 다른 리스트로 변환된다. 그래서 map 연산은(1,2,3,4) 모든 원소를 변환한다. 그 후 find가 술어를 만족하는 첫 번째 원소인 4(2의 제곱)를 찾는다.
- 시퀸스를 사용하면 find 호출이 원소를 하나씩 처리하기 시작한다. 최초 시퀸스로부터 수를 하나 가져와서 map에 지정된 변환을 수행한 다음에 find에 지정된 술어를 만족하는지 검사한다. 최초 시퀀스에서 2를 가져오면 제곱 값(4)이 3보다 커지기 때문에 그 제곱 값을 결과로 반환한다. 여기서 이미 답을 찾았으므로 3과 4를 처리할 필요가 없다.
컬렉션에 대해 수행하는 연산의 순서도 성능에 영향을 끼친다. 사람의 컬렉션이 있는데 이름이 어떤 길이보다 짧은 사람의 명단을 얻고 싶다고 할 때, 이를 처리하기 위해선 각 사람을 이름으로 map한 다음에 이름 중에서 길이가 긴 사람을 제외시켜야한다.
==> 이 경우 map과 filter를 어떤 순서로 수행해도 된다. 그러나 map 다음에 filter를 하는 경우와 filter 다음에 map을 하는 경우 결과는 같아도 수행해야 하는 변환의 전체 횟수는 다르다.
[EX] - map 다음에 filter 수행
fun main(args: Array<String>){
val people = listOf(Person("Alice",27), Person("Bob",31),Person("Dan",21))
println(people.asSequence().map(Person::name).filter { it.length<4 }.toList()) //map 다음에 filter 수행
}
[Bob, Dan]
[EX] - filter 다음에 map 수행
fun main(args: Array<String>){
val people = listOf(Person("Alice",27), Person("Bob",31),Person("Dan",21))
println(people.asSequence().filter { it.name.length<4 }.map { Person::name }.toList()) //filter 다음에 map 수행
}
[Bob, Dan]
위의 그림은 두 예제(map->filter, filter->map)에 대한 것이고 한번 살펴보자.
map(변환)을 먼저하게 되면 모든 원소를 변환한다. 하지만 filter를 먼저하면 부적절한 원소를 먼저 제외하기 때문에 그런 원소는 변환되지 않는다.
■ 3-2. 시퀸스 만들기
지금까지는 컬렉션에 대해 asSequence()를 호출해 시퀀스를 만들었다.
시퀀스를 만드는 다른 방법으로는 generateSequence 함수를 사용할 수 있다. 이 함수는 이전의 원소를 인자로 받아 다음 원소를 계산한다.
예제로, geberateSequence로부터 0부터 100까지 자연수의 합을 구해보자.
[EX] 자연수의 시퀀스를 생성하고 사용하기
fun main(args: Array<String>){
val naturalNumbers = generateSequence(0) {it + 1}
val nubersTo100 = naturalNumbers.takeWhile { it <=100 }
println(nubersTo100.sum()) //모든 지연 연산은 'sum'의 결과를 계산할 때 수행된다.
}
5050
이 예제에서 naturalNumbers와 numberTo100은 모두 시퀀스며, 연산을 지연 계산한다.
최종 연산을 수행하기 전까지는 시퀀스의 각 숫자는 계산되지 않는다.(여기서는 sum이 최종 연산이다.)
다음 예제는 어떤 파일의 상위 디렉터리를 뒤지면서 숨긴 속성을 가진 디렉터리가 있는지 검사함으로써 파일이 감춰진 디렉터리 안에 들어있는지 알아본다.
[EX] - 상위 디렉터리의 시퀸스를 생성하고 사용하기
fun main(args: Array<String>){
var file = File("/Users/svtk/.HiddenDir/a.txt")
println(file.isInsideHiddenDirectory())
}
fun File.isInsideHiddenDirectory() = generateSequence (this) {it. parentFile}.any{it.isHidden}
true
위의 코드를 보자.
generateSequence를 이용을 하는데 첫번째 원소를 지정하고, 시퀀스의 한 원소로부터 다음 원소를 계산하는 방법을 제공함으로써 시퀀스를 만든다. [ any를 find로 바꾸면 원하는 디렉터리를 찾을 수도 있다. ]
이렇게 시퀀스를 사용하면 조건을 만족하는 디렉터리를 찾은 뒤에는 더 이상 상위 디렉터리를 찾지 않는다.
■ 4. 자바 함수형 인터페이스 활용 ■
이번에는 코틀림 람다를 어떻게 자바 API에 활용할 수 있는지 살표보겠다.
5장을 시작하면서 자바 메소드에 람다를 넘기는 예제가 있었다.
button.setOnClickListener { /* 클릭 시 수행할 동작 */ } //람다를 인자로 넘김
Button 클래스는 setOnClickListener메소드를 사용해 버튼의 리스너를 설정한다. 이때 인자의 타입은 onClickListener다.
/* 자바 */
public class Button {
public void setOnClickListener(OnClickListener 1) {...}
}
여기서 OnClickListener 인터페이스는 onClick이라는 메소드만 선언된 인터페이스다.
/* 자바 */
public interface OnClickListener {
void onClick(View v);
}
자바 8 이전 자바에서는 setOnCLickListenr 메소드에게 인자로 넘기기 위해 무명 클래스의 인스턴스(아래의 예제에선 new OnClickListener())를 만들어야만 했다.
button.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
...
}
}
코틀린에서는 무명 클래스 인스턴스 대신 람다를 대신 넘길 수 있다.
button.setOnClickListener { view -> ... }
onClickListener를 구현하기 위해 사용한 람다에는 view라는 파라미터가 있다. view의 타입은 View이다. 이는 onClick 메소드의 인자 타입과 같다.
이런 코드가 작동하는 이유는 OnClickListener에 추상 메소드가 단 하나만 있기 때문이다.
==> 이러한 인터페이스를 함수형 인터페이스 또는 SAM 인터페이스라고 한다. SAM은 단일 추상 메소드라는 뜻이다.
자바 API에는 Runnalbe이나 Callable과 같은 함수형 인터페이스와 그런 함수형 인터페이스를 활용하는 메소드가 많다.
코틀린은 함수형 인터페이스(추상 메소드가 단 하나만 있어야함)를 취하는 자바 메소드를 호출할 때 람다를 넘길 수 있게 해준다.
==> 따라서 코틀린 코드는 무명 클래스 인스턴스를 정의하고 활용할 필요가 없어서 깔끔하며 코틀린다운 코드로 쓸 수 있다.
■ 4-1. 자바 메소드에 람다를 인자로 전달
함수형 인터페이스를 인자로 원하는 자바 메소드에 코틀린 람다를 전달 할 수 있다.
예를 들어 다음 메소드는 Runnable 타입의 파라미터를 받는다.
/* 자바 */
void postponeComputation(int delay, Runnable computation);
코틀린에서 람다를 이 함수에 넘길 수 있다. 컴파일러는 자동으로 람다를 Runnable 인스턴스로 변환해준다.
postponeComputation(1000) { println(42) }
여기서 'Runnable 인스턴스'라는 말은 실제로는 'Runnable을 구현한 무명 클래스의 인스턴스'라는 뜻이다.
컴파일러는 자동으로 그런 무명 클래스와 인스턴스를 만들어진다. 이 때 그 무명 클래스에 있는 유일한 추상 메소드를 구현 할 때 람다 본문을 메소드 본문으로 사용한다. 여기서는 Runnable의 run이 단 하나만 있는 그런 추상 메소드다.
postponeComputation(1000, object : Runnable { //객체 식을 함수형 인터페이스 구현으로 넘긴다.
override fun run() {
println(42)
}
})
위의 예제는 object를 이용한 무명 객체를 이용하여 파라미터로 쓴다.
람다와 무명 객체(object를 이용) 사이에는 차이가 있다. 무명 객체를 사용할 시 객체를 명시적으로 선언하는 경우 메소드를 호출할 때마다 새로운 객체가 생성된다.
람다는 정의가 들어있는 함수의 변수에 접근하지 않는 람다에 대응하는 무명 객체를 메소드를 호출할 때마다 반복 사용한다.
postponeComputation(1000) { println(42) } //프로그램 전체에서 Runnalbe의 인스턴스는 단 하나만 만들어진다.
위의 코드는 람다를 사용했고, 프로그램 전체에서 Runnable의 인스턴스는 단 하나만 만들어진다.
val runnable = Runnalbe { println(42) } //Runnalbe은 SAM 생성자, 전역 변수로 컴파일되므로 프로그램 안에 단 하나의 인스턴스만 존재한다.
fun handleComputation() {
postponeComputation(1000, runnable) //모든 handleComputation 호출에 같은 객체를 사용한다.
위의 코드는 람다를 이용한건데, 명시적인 object 선언을 사용하는 이다. 이 경우 Runnable 인스턴스를 변수에 저장[ 전역 변수로 컴파일 되므로 프로그램 안에 단 하나의 인스턴스만 존재한다. ] 하고 호출할 때 [ 함수 호출에 같은 객체만을 사용 ]마다 그 인스턴스를 사용한다.
다음예는 함수에서 id를 필드로 저장하는 새로운 Runnable 인스턴스를 매번 새로 만들어 사용하는 예이다.
fun handleComputation(id: String) { //람다 안에서 "id" 변수를 포획한다.
postponeComputation(1000) { println(id) } // handleComputation을 호출할 때마다 새로 Runnable 인스턴스를 만든다.
}
위의 코드를 보면 람다 안에서 바깥 함수의 매개변수인 id를 포획한다. 그리고 handleComputation을 호출할 때마다 새로 Runnable 인스턴스를 만든다.
람다에 대해 무명 클래스를 만들고 그 클래스의 인스턴스를 만들어서 메소드에 넘긴다는 설명은 함수형 인터페이스를 받는 자바 메서드를 코틀린에서 호출할 때 쓰는 방식을 설명해주지만, 컬렉션을 확장한 메소드에 람다를 넘기는 경우 코틀린은 그런 방식을 사용하지 않는다.
■ 4-2. SAM 생성자: 람다를 함수형 인터페이스로 명시적으로 변경
대부분의 경우 람다와 자바 함수형 인터페이스 사이의 변환은 자동으로 이루어 지지만 수동으로 변환해야 하는 경우가 있는데 람다가 어떻게 처리하는지 살펴보자.
SAM 생성자는 람다를 함수형 인터페이스의 인스턴스로 변환할 수 있게 컴파일러가 자동으로 생성한 함수다.
컴파일러가 자동으로 람다를 함수형 인터페이스 무명 클래스로 바꾸지 못하는 경우 SAM 생성자를 사용할 수 있다.
예를 들어 함수형 인터페이스의 인스턴스를 반환하는 메소드가 있다면 직접 반환할 수 없고, 반환하고픈 람다를 SAM 생성자로 감싸야 한다. 다음 예제를 보자.
[EX] - SAM 생성자를 사용해 값 반환하기
package list5
fun createAllDoneRunnable(): Runnable {
return Runnable { println("All done!") }
}
fun main(args: Array<String>){
createAllDoneRunnable().run()
}
SAM 생성자 이름은 사용하려는 함수형 인터페이스의 이름과 같다. SAM 생성자는 그 함수형 인터페이스의 유일한 추상 메소드의 본문에 사용할 람다만을 인자로 받아서 함수형 인터페이스를 구현하는 클래스의 인스턴스를 반환한다.
람다로 생성한 함수형 인터페이스 인스턴스를 변수에 저장해야 하는 경우에도 SAM 생성자를 사용할 수 있다.
예를 들어, 여러 버튼에 같은 리스너를 적용하고 싶다면 다음 리스트처럼 SAM 생성자를 통해 람다를 함수형 인터페이스 인스턴스로 만들어서 변수에 저장해 활용할 수 있다. [ 안드로이드 경우, Activity.onCreate 메소드 안에 이런 코드가 들어갈 수 있다. ]
[EX] - SAM 생성자를 사용해 listener 인스턴스 재사용하기
var listener = OnClickListener { view ->
val text = when (view.id) { //view.id를 어떤 버튼이 클릭됐는지 판단한다.
R.id.button1 -> "First button"
R.id.button2 -> "Second button"
else -> "Unknown button"
}
toast(text) // "text"의 값을 사용자에게 보여준다.
}
button1.setOnClickListener(listener)
button2.setOnClickListener(listener)
First button
Unknown button
위의 코드는 안드로이드에서 많이 사용하는 코드다 살펴보자.
listener는 어떤 버튼이 클릭됐는지에 따라 내가 설정한 동작을 수행한다.
onClickListener를 구현하는 무명 객체 혹은 객체 선언을 통해 리스너를 만들 수도 있지만 SAM 생성자를 쓰는 쪽이 더 간결하다.
람다에는 무명 객체와 달리 인스턴스 자신을 가리키는 this가 없다는 사실에 유의해라.
==> 따라서 람다를 변환한 무명 클래스의 인스턴스를 참조할 방법이 없다. 컴파일러 입장에서 보면 람다는 코드 블록일 뿐이고, 객체가아니므로 객체처럼 람다를 참조할 수는 없다. 람다 안에서 this는 그 람다를 룰러싼 클래스의 인스턴스를 가리킨다.
이벤트 리스너가 이벤트를 처리하다가 자기 자신의 리스너 등록을 해제해야 한다면 람다를 사용할 수 없다. 그런 경우 람다 대신 무명 객체를 사용해 리스너를 구현해라
==> 무명 객체 안에서는 this가 그 무명 객체 인스턴스 자신을 가리키기 때문에, 리스너를 해제하는 API 함수에게 this를 넘길 수 있다.
SAM 생성자에 대해 정리해보자면, 함수형 인터페이스(추상형 메서드 한개인 인터페이스)를 요구하는 메소드를 호출 할 때 SAM 변환을 컴파일러가 자동으로 수행할 수 있지만, 어떤 타입의 메소드를 선택해 람다를 변환해 넘겨줘야 할 지 모호할 때가 있는데, 이 때 명시적으로 SAM 생성자를 적용하여 컴파일 오류를 피할 수 있다.
■ 5. 수신 객체 지정 람다: with와 apply ■
이번에는 코틀린 표준 라이브러리의 with와 apply를 알아보자.
또한 자바의 람다에는 없는 코틀린 람다의 독특한 기능인 수신 객체를 명시하지 않고 람다의 본문 안에서 다른 객체의 메소드를 호출할 수 있게 하는 것을 알아보자.
■ 5-1. with 함수
어떤 객체의 이름을 반복하지 않고도 그 객체에 대해 다양한 연산을 수행할 수 있다면 좋을 것이다.
==> 코틀린에서는 with라는 라이브러리 함수를 통해 제공한다.
다음 예제를 보면 with에 대해 이해해보자.
[EX] - 알파벳 만들기
package list5
fun alphaber() : String {
val result = StringBuilder()
for (letter in 'A'..'Z'){
result.append(letter)
}
result.append("\nNow I know the alphaber!")
return result.toString()
}
fun main(args: Array<String>){
println(alphaber())
}
위의 코드는 result에 대해 다른 여러 메소드를 호출하면서 매번 result를 반복 사용했다. 이 정도 반복은 나쁘지 않지만, 코드가 훨씬 더 길거나 result를 더 반복했다면 어땠을까?
[EX] - with를 사용해 알파벳 만들기
package list5
fun alphabet2() : String {
val stringBuilder = StringBuilder()
return with(stringBuilder) {//메소드를 호출하려는 수신 객체를 지정한다.
for(letter in 'A'..'Z') {
this.append(letter)//this를 명시해서 앞에서 지정한 수신 객체의 메소드를 호출한다.
}
append("\nNow I know the alphabet!")//this를 생략하고 메소드를 호출한다.
this.toString() //람다에서 값을 반환한다.
}
}
with문은 언어가 제공하는 특별한 구문처럼 보인다. 하지만 실제로는 파라미터가 2개 있는 함수다.
여기서 첫 번째 파라미터는 StringBuilder이고, 두 번째 파라미터는 람다다. 람다를 괄호 밖으로 빼내는 것을 사용함에 따라 전체 함수 호출이 언어가 제공하는 특별 구문처럼 보인다. 이 방식 대신 with(StringBulder, {..})라고 쓸 수도 있찌만 더 읽기 나빠진다.
with 함수는 첫번째 인자로 받은 객체를 두번째 인자로 받은 람다의 수신 객체로 만든다. 인자로 받은 람다 본문에서는 this를 사용해 그 수신 객체에 접근할 수 있다. 일반적인 this와 마찬가지로 this와 점(.)을 사용하지 않고 프로퍼티나 메소드 이름만 사용해도 수신 객체의 멤버에 접근할 수 있다.
위의 예제에서 this는 with의 첫 번째 인자로 전달된 stringBuilder다. stringBuilder의 메소드를 this.append(letter)처럼 this 참조를 통해 접근하거나 append("\nNow I know the alphaber!")처럼 바로 호출 할 수있다.
앞의 alphabet 함수를 더 리팩토링해서 불필요한 stringBuilder 변수를 없앨 수도 있다.
[EX] - with와 식을 본문으로 하는 함수를 활용해 알파벳 만들기
package list5
import java.lang.StringBuilder
fun alphbabet3() = with(StringBuilder()) {
for (letter in 'A'..'Z') {
append(letter)
}
append("\nNow I know the alphabet!")
toString()
}
불필요한 stringBuilder 변수를 없애면 alphabet 함수가 식의 결과를 바로 반환하게 된다.
==> 따라서 식을 본문으로 하는 함수로 표현할 수 있다. StringBuilder의 인스턴스를 만들고 즉시 with에게 인자로 넘기고, 람다 안에서 this를 사용해 그 인스턴스를 참조한다. [ this를 생략해도 프로퍼티나 메소드의 이름만 사용해도 수신 객체의 멤버에 접근할 수 있다. ]
with가 반환하는 값은 람다 코드를 실행한 결과이며, 그 결과는 람다 식의 본문에 있는 마지막의 식의 값이다.
하지만 때로는 람다의 결과 대신 수신 객체가 필요한 경우도 있다.
■ 5-2. apply 함수
apply 함수는 거의 with와 같다. 유일한 차이란 apply는 항상 자신에게 전달된 객체(수신 객체)를 반환한다는 점 뿐이다.
apply를 써서 alphabet 함수를 다시 리팩터링해보자.
[EX] - apply를 사용해 알파벳
package list5
fun alphabet4() = StringBuilder().apply {
for(letter in 'A'..'Z') {
append(letter)
}
append("\nNow I know the alphabet!")
}.toString()
apply는 확장 함수로 정의돼 있다. apply의 수신 객체가 전달받은 람다의 수신 객체가 된다.
이 함수에서 apply를 실행한 결과는 StringBuilder 객체다
==> 따라서 그 객체의 toString을 호출해서 객체를 얻을 수 있다.
이런 apply 함수는 객체의 인스턴스를 만들면서 즉시 프로퍼티 중 일부를 초기화해야 하는 경우 유용하다.
==> [ 자바에서는 Builder 객체가 이런 역할을 담당] , 코틀린에서는 어떤 클래스가 정의돼 있는 라이브러리 없이도 그 클래스 인스턴스에 대해 apply를 활용할 수 있다.
apply를 객체 초기화에 활용하는 예로 안드로이드의 TextView 컴포넌트를 만들어 보자.
[EX] - apply를 TextView 초기화에 사용하기
fun createViewWithCustomAttributes(context: Context) =
TextView(context).apply {
text = "Sample Text"
textSize = 20.0
setPaddomg(10,0,0,0)
}
apply 함수를 사용하면 함수의 본문에 간결한 식을 사용할 수 있다. 새로운 TextView 인스턴스를 만들고 즉시 그 인스턴스를 apply에 넘긴다. apply에 전달된 람다 안에서는 TextView가 수신 객체가 된다.
==> 따라서 원하는 대로 TextView의 메소드를 호출하거나 프로퍼티를 설정 할 수 있다. 람다를 실행하고 나면 apply는 람다에 의해 초기화된 TextView 인스턴스를 반환한다. 그 인스턴스는 createViewWithCustomAttrubutes함수의 결과가 된다.
with와 apply는 수신 객체 지정 람다를 사용하는 일반적인 예제 중 하나다.
예를 들어, 표준 라이브러리의 buildString 함수를 사용하면 alphabet 함수를 더 단순화 할 수 있다. buildString은 앞에서 살펴본 alphabet 코드에서 StringBuilder 객체를 만드는 일과 toString을 호출해주는일을 해준다. buildString의 인자는 수신 객체 지정 람다며, 수신 객체는 항상 StringBuilder가 된다.
[EX] - buildString으로 알파벳 만들기
fun alphabet5() = buildString {
for(letter in 'A'..'Z'){
append(letter)
}
append("\nNow I know the alphabet!")
}
buildString 함수는 StringBuilder를 활용해 String을 만드는 경우 사용할 수 있는 멋진 해법이다.
■ 6. 정리 ■
- 람다를 사용하면 코드 조각을 다른 함수에게 인자로 넘길 수 있다.
- 코틀린에서는 람다가 함수 인자인 경우 괄호 밖으로 람다를 빼낼 수 있고, 람다의 인자가 단 하나뿐인 경우 인자 이름을 지정하지 않고 it이라는 디폴트 이름으로 부를 수 있다.
- 람다 안에 있는 코드는 그 람다가 들어있는 바깥 함수의 변수를 읽거나 쓸 수 있다.
- 메소드, 생성자, 프로퍼티의 이름 앞에 ::을 붙이면 각각에 대한 참조를 만들 수 있다. 그런 참조를 람다 대신 다른 함수에게 넘길 수 있다.
- filter, map, all, any 등의 함수를 활용하면 컬렉션에 대한 대부분의 연산을 직접 원소를 이터레이션하지 않고 수행할 수 있다.
- 시퀀스를 사용하면 중간 결과를 담는 컬렉션을 생성하지 않고도 컬렉션에 대한 여러 연산을 조합할 수 있다.
- 함수형 인터페이스(추상 메소드가 단 하나뿐인 SAM 인터페이스)를 인자로 받는 자바 함수를 호출할 경우 람다를 함수형 인터페이스 인자 대신 넘길 수 있다.
- 수신 객체 지정 람다를 사용하면 람다 안에서 미리 정해둔 수신 객체의 메소드를 직접 호출할 수 있다.
- 표준 라이브러리의 with 함수를 사용하면 어떤 객체에 대한 참조를 반복해서 언급하지 않으면서 그 객체의 메소드를 호출할 수 있다.
apply를 사용하면 어떤 객체라도 빌더 스타일의 API를 사용해 생성하고 초기화할 수 있다.
'Kotlin > Kotlin in action' 카테고리의 다른 글
6장 코틀린 타입 시스템 (0) | 2022.10.04 |
---|---|
4장 클래스, 객체, 인터페이스 (1) | 2022.09.24 |
3장 함수 정의와 호출 (1) | 2022.09.23 |
2장 코틀린 기초 (1) | 2022.09.22 |
1장 코틀린이란 무엇이며, 왜 필요한가? (1) | 2022.09.21 |