[Kotlin] 람다로 프로그래밍(코틀린 인 액션 5장)
본 내용은 코틀린 인 액션을 읽고 저의 방식대로 정리한 글입니다.
그에따라 틀린 내용이 있을 수 있습니다. 틀린 내용이 있으면 댓글로 알려주시면 감사하겠습니다.
람다식의 문법
책에서 말하길 람다는 값처럼 여기저기 전달할 수 있는 동작의 모음이라고 설명한다.
{x: Int, y:Int-> x+y}
//파라미터 부분 //본문
값처럼 여기저기 전달할 수 있다는게 잘 이해가 안됐는데, 변수에도 저장할 수 있다는 예시를 보고 이해가 되었다.
val sum = {x:Int, y:Int -> x+y}
println(sum(1, 2))
//람다가 값이라는 증거
val getAge= {p:Person -> p.age}
people.maxByOrNull(getAge)
또한 코틀린에서는 함수 호출시 마지막 인자(파라미터)가 람다식이면 람다를 괄호 밖으로 뺄 수 있는 문법 관습이 있다.
두번째 예시를 보면 좀 더 확실한 예시를 확인할 수 있을것이다. seperator는 함수의 parameter로 넘기고 람다가 마지막에 있는경우 ()밖으로 뺄 수 있기에 다음과 같은 문법이 가능한것이다.
또한 ex2의 3번째 예시를 보면 Person이 생략가능한것을 확인할수있다. 이는 코틀린 컴파일러가 추론할 수 있기 때문이다.( 그 이유는 maxBy함수는 해당 함수의 경우 파라미터 타입은 항상 컬렉션 원소 타입-Person 과 같기 때문이다.)
** 모든 경우에 대해서 추론가능한것은 아니다. 하지만 해당책에서 아직 그런부분에 대해서는 다루지 않는다.
//문법 변화
people.maxByOrNull({p:Person -> p.age})
people.maxByOrNull(){p:Person -> p.age}
people.maxByOrNull{p:Person -> p.age}
people.maxByOrNull{p -> p.age}
people.maxByOrNull{it.age}
//ex2
people.joinToString(separator = ",", transform = {p:Person->p.name})
people.joinToString(","){p:Person->p.name})
people.joinToString(","){p->p.name}
또한
people.maxByOrNull{it.age}
해당 부분을 보면 마지막에는 제일 간결하게 it을 쓸수 있는데, it은 디폴트 파라미터로, 람다에서 자동으로 생성되는 파라미터이다. it은 파라미터가 하나뿐이고, 그 타입을 컨파일러가 추론할 수 있을때만 사용가능하다. ( 해당 책에서는 권장하진 않는다. -> 명시하는게 더 좋은코드라고 설명하고 있음)
현재 영역에 있는 변수에 접근
fun printMessage(messages: Collection<String>, prefix: String) {
messages.forEach {
println("$prefix $it")
}
}
val erros = listOf<String>("403 forbidden", "404 not found")
print(erros)
해당 코드를 보면 람다 안에서 함수의 파라미터를 사용하는것을 볼 수 있다($prefix)
또한 $it은 messages안의 String값을 나타내는것이다.
fun printProblemCounts(responses: Collection<String>) {
var clientErrors=0
var serverErrors=0
responses.forEach {
if (it.startsWith("4")) {
clientErrors++;
}else if (it.startsWith("5")) {
serverErrors++
}
}
}
또한 코틀린은 람다 안에서 파이널변수가 아닌 변수에 접근가능하다는 점이다. 심지어 람다 안에서 해당 값을 변경해도 괜찮다.
아래의 코드는 비교를 위해 자바코드도 작성하였다
public class ParameterJava {
public static void main(String[] args) {
int x = 10; // 외부 변수
final int finalX = 10;
Runnable runnable = () -> {
System.out.println(x); //컴파일 에러
System.out.println(finalX); //final 명시되어 있는것만 가능
finalX++; //컴파일에러 값 변경 x
};
x = 20; // x를 변경
runnable.run(); // 람다 실행
}
}
*** 람다를 사용할때 주의할점
람다를 이벤트 핸들러나 다른 비동기적으로 실행되는 코드로 활용되는 경우 함수 호출이 끝난 다음에 로컬변수가 변경될 수 있다는 점이다.
fun buttonHandlerByLamda(button: Button) {
val clicks=0
button.onClick(clicks++)
return clicks
}
buton.onClick은 이벤트 핸들러 이기에 함수상에서는 onclikcs 변수를 증가시키는 로직이지만, 실제로는 핸들러가 호출되는 시점은 이미 해당함수가 clicks가 return되고 난 이후다. 그렇기에 해당 함수는 항상 0을 반환할것이다.
해당 함수를 원하는 동작으로 변경시키려면 해당 변수를 클래스프로퍼티나 전역프로퍼티로 빼야한다.
멤버 참조
멤버참조는 흔히 "::"식을 생각하면 된다.
그래서 "Person::age" 는 클래스::멤버(함수, 프로퍼티)등으로 매핑될 수 있다.
또한 다음과 같은 코드에서는 앞에 클래스가 명시되어 있지 않고 ::이 있는데, 이는 최상위 함수도 참조 가능한것을 볼 수 있다. 이를 상위 함수에 위임하는 과정이라고 말한다.
open class TopClass {
fun topFunction() = print("top")
}
class MemberRefernce : TopClass() {
fun testFunction() {
run(::topFunction)
}
}
fun main() {
val memberRefernce = MemberRefernce()
memberRefernce.testFunction()
}
//resutl: top
컬렉션 함수형 API
Filter
fileter함수는 컬렉션을 iteration하면서 주어진 람다값에 원소를 넘겨, 람다가 true인것들만 원소로 모은다.
people.filter{it.age>30}
map
map 함수는 람다를 컬렉션의 모든 원소에 적용한 결과를 수집한다.
people.map{it.age*it.age}
All
컬렉션 내의 모든 원소가 해당 조건을 만족하는지 알려준다
val people= listOf(Person(10,"test1"),Person(21,"test2"))
val over20={p:Person -> p.age > 20}
people.all(over20) //false
Any
컬렉션 내의 원소중 하나라도 조건에 만족하면 true를 반환한다.
val people= listOf(Person(10,"test1"),Person(21,"test2"))
val over20={p:Person -> p.age > 20}
people.any(over20) //true
count
컬렉션 내의 원소중 조건에 만족하는 갯수를 알려준다.
val people= listOf(Person(10,"test1"),Person(21,"test2"))
val over20={p:Person -> p.age > 20}
people.count(over20) //1
**
상황에 맞게 쓰면 count가 size보다 유리할 수 있다. 다음과같이 filter를 쓰게되는경우 나는 20살넘는 사람의 원소 갯수만이 궁금하지만, filter를 필요없는 데이터를 컬렉션에 저장까지 하기에 효율적이지 못하다.
people.count(over20)
people.filter(over20).size
find
술어를 만족하는 원소 하나를 찾고 싶으면 find -> 조건을 가장 먼저 만족하는 원소를 리턴( 그렇지 않으면 null을 리턴한다.)
people.find(over20)
groupBy
주어진 파라미터를 키값으로 해당 원소들을 분류한다. 쉽게 아래의 예제에서는 age가 키값이 되고 그에 해당되는 Person객체를 values라고 보면 쉬울것이다. => type Map<Int,List<Person>>
val people = listOf(Person(10, "child1"), Person(10, "child2"), Person(30, "adult"))
print(people.groupBy { it.age })
//{10=[Person(age=10, name=child1), Person(age=10, name=child2)], 30=[Person(age=30, name=adult)]}
중첩된 컬렉션 안의 원소 처리 : flatMap
flatMap
해당 함수는 인자로 주어진 람다를 컬렉션의모든 객체에 적용하고 (또는 매핑) 람다를 적용한 결과 얻어진 리스트를 한 리스트로 한데 모은다.? 라고 적혀있다. 무슨말인지 이해가 안가니 직접 코드 예시를 보자
val books = listOf(Book("title1", listOf("writer1,writer2"))
,Book("title2", listOf("writer22,writer23"))
)
println(books.flatMap { it.authors }.toSet()) //[writer1,writer2, writer22,writer23]
즉 다음과 같이 flatmap은 모든 책의 작가를 flat한 리스트로 모은다 -> ToSet을 통해 중복을 제거한다.
지연 계산 컬렉션 연산
지연계산을 말그대로 lamda를 사용할때 지연계산이 되는것이다. 일반적으로 Sequnce를 사용하지 않으면 하나의 함수가 실행될때마다 새로운 컬렉션이 생겨 람다 조건을 거친후 새로운 컬렉션에 조건이 패스된 원소를 담는다. 이는 비효율적일 수 있다. 이를 Sequnce가 해결해준다.
val result = listOf(1, 2, 3, 4)
.asSequence()
.map { it * it }
.filter { it % 2 == 0 }
.toList()
println(result) // [4, 16]
다음과 같은 문법을 가진다.
실제 작동되는것을 보면 다음과 같다.
val result = listOf(1, 2, 3, 4)
.asSequence()
.map { print("map $it "); it * it }
.filter { print("filter $it "); it % 2 == 0 }
.toList()
println(result) // map 1 filter 1 map 2 filter 4 map 3 filter 9 map 4 filter 16 [4, 16]
위에서 말한것 처럼 map에서 1차적으로 listof(1,4,9,16)이 생기는것이 아니가 1 -> map -> filter, 2->map->filter...와 같이 지연계산된다. 그러면 정말로 sequence를 사용하면 하나의 컬렉션만을 사용한다는 의문을 가질 수 있는데, 실제로 다음의 코드에서 확인할 수 있다.
val result = listOf(1, 2, 3, 4)
.asSequence()
.map { print("map $it "); it * it }
.filter { print("filter $it "); it % 2 == 0 }
// .toList()
println(result) // kotlin.sequences.FilteringSequence@27fa135a
다음의 코드에서 toList()를 주석했을때 결과값은 어떠한 컬렉션이 나오는것이 아니고 Sequnce의 구현체가 리턴된다.
이러한 결과를 보고 map, filter와 같은 부분을 sequnce의 중간 연산, toList부분을 최종연산이라고 부른다.
** 그렇다고 sequnce가 항상 좋은건 아니다. 단지 위와같이 결과값만이 필요한경우에는 적합하지만 실제 컬렉션 원소에 접근을 해서 추가적인 작업이 필요한경우는 asSequence를 사용하지 못한다.
** 또한 코틀린의 sequnce와 java의 stream과 개념이 같다. 코틀린에서 따로 구현한 이유는 , 안드로이등에서 예전 자바버전(자바8)에는 스트림이 없기 때문이다.
자바 메서드에 람다를 인자로 연결
함수형 인터페이스를 인자로 원하는 자바메서드에 코틀린 람다를 전달할 수 있다.
아래와 같아 함수형 인터페이스(Runnable)을 인자로 원하는 자바로 메서드가 있다.
void postponeCompuatation(int delay, Runnable runnable) {
return;
}
그리고 아래와 같이 코틀린에서 다음과 같이 작성가능하다
fun main() {
val runnableJava = RunnableJava()
runnableJava.postponeCompuatation(10){ print(10)}
}
하지만 여기서 람다와 무명객체에서는 차이가 있다.
//무명객체
runnableJava.postponeCompuatation(10, object : Runnable {
override fun run() {
print("object 매번 생성")
}
})
//람다
runnableJava.postponeCompuatation(10, {
print("object 한번 생성")
})
첫번째는 무명객체를 이용한 구현인데, 이때는 해당 함수를 호출할때마다 새로운 객체가 생성된다.
반대로 두번째 람다를 사용한경우 Object는 한번만 생성하고 이후 호출될때마다 재사용 한다. -> 즉 전역변수로 컴파일 되고, 프로그램안에 단 하나의 인스턴스로만 생성된다.
하지만 주의해야할 점이 있다. 아래의 코드는 다른 함수의 파라미터값 사용(주변 영역의 변수 포획이라는 용어를 책에선 사용한다)시에는 같은 인스턴스를 사용할 수 없다. 이때는 매번 인스턴스를 생성하고 호출한다.
fun handleCompute(id: String) {
runnableJava.postponeCompuatation(10,{print(id)})
}
with
with는 객체에서 함수를 연속적으로 호출해야하는 상황에서 유용하게 사용될 수 있다. 자바에서는 제공하지 않으며, 코틀린에서만 제공된다.
아래의 예시를 보면 객체에서 함수를 연속적으로 호출해야하는 상황예 예시가 나와있다. 또한 해당 예시의 아래에는 with를 사용하여 리팩토링한 결과를 확인할 수 있다.
fun aplabet(): String {
val result = StringBuilder()
for (letter in 'A'..'Z') {
result.append(letter)
}
result.append("clear '\n")
return result.toString()
}
fun aplabetRefactor(): String {
val stringBuilder = StringBuilder()
return with(stringBuilder){ //메서드를 호출하는 객체를 파라미터로
for (letter in 'A'..'Z') {
this.append(letter) //이때 this는 파라미터?로 받는 stringBuilder
}
append("clear\n") //this 생략가능
this.toString()
}
}
with를 잘 살펴보면 파라미터가 실제로는 2개이다. 첫번째는 stringBuilder, 두번째는 람다를 호출하는것이다( 이전에 람다가 마지막에 들어올 수 있는경우 밖으로 뺄 수 있다고 배움) 즉 with(a,{lamda}) 이고 더 자세하게 말하면 with(a: Object, {a객체 사용})이다.
apply
apply는 확장함수로 정의되어 있다. apply의 특징은 람다에서 전달된 객체를 리턴한다는것이다.
apply를 호출한 객체는 람다에서 수신 객체가 된다. 즉, 아래의 코드에서 apply의 return 값은 StringBuilder이다.
이러한 기능은 객체의 인스턴스를 만들면서 즉시 프로퍼티 중 일부를 조작 및 초기화할때 유용할 수 있다.
fun alpaApply() =
StringBuilder().apply {
for (letter in 'A'..'Z') {
this.append(letter)
}
append("clear\n") //this 생략가능
}.toString()