[Kotlin] 5-2. 람다 프로그래밍

hansol_kim·2021년 9월 26일
0

kotlin 적응기

목록 보기
5/5
post-thumbnail
* 람다 식과 멤버 참조
* 함수형 스타일로 컬렉션 다루기
* 시퀀스: 지연 컬렉션 연산
* 자바 함수형 인터페이스를 코틀린에서 사용
* 수신 객체 지정 람다 사용

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

앞 내용에서 map이나 filter같은 몇 가지 컬렉션 함수를 알아봤다. 해당 함수는 결과 컬렉션을 즉시(eager) 생성한다. 이는 컬렉션 함수를 연쇄하면 매 단계마다 계산 중간 결과를 새로운 컬렉션에 임시로 담는다는 뜻이다.

시퀀스를 사용하면 중간 임시 컬렉션을 사용하지 않고도 컬렉션 연산을 연쇄할 수 있다. 각 연산이 컬렉션을 직접 사용하는 대신에 시퀀스를 사용하게 만들어야 한다.

원소의 개수가 적다면 크게 문제가 되지 않지만 수백만 개가 되면 효율성에서 큰 차이가 발생한다.

people
	.asSequence()
  .filter { it.age % 2 == 1 }
  .map { it.name }
  .count()

people의 원소 개수가 1000만개인 상황에서 테스트를 진행했을 때 asSequence를 사용하지 않은 경우에는 평균적으로 125ms가 소요됐다. asSequence를 사용했을 때에는 12ms가 소요된다.

asSequence 확장 함수르 호출하면 어떤 컬렉션이든 시퀀스로 바꿀 수 있다. 시퀀스를 리스트로 만들 때는 toList를 사용한다. 그런데 굳이 시퀀스를 리스트로 만들 필요가 있을까? 그리고 만들 필요가 없다면 어떠한 상황이던 시퀀스를 사용하면 되는 것 아닌가? 라고 물을 수 있다.

결론은 리스트로 만들어야할 경우가 있다. 그리고 만들어야하는 경우가 있기 때문에 항상 시퀀스 쓰는 것이 정답은 아니다. 그 이유는 원소를 차례로 이터레이션하는 경우라면 시퀀스를 사용해도 된다. 하지만 특정 인덱스를 사용하여 접근할 필요가 있는 경우에는 리스트로 변환해야한다.

큰 컬렉션에 대해서 연산을 연쇄시킬 때는 시퀀스를 사용하는 것을 규칙으로 삼는다.

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

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

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

여기서 mapfilter중간연산에 속한다. 그리고 시퀀스에서 중간연산은 항상 지연 계산된다. 따라서 무언가 출력이 되길 기대했지만 아무것도 출력되지 않는다. 이는 중간연산인 mapfilter 변환이 늦춰져서 결과를 얻을 필요가 있을 때(즉, 최종연산이 호출될 때) 적용된다는 뜻이다.

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

// result
map(1)
filter(1)
map(2)
filter(4)
map(3)
filter(9)
map(4)
filter(16)

toList라는 최종연산을 통해서 결과를 얻을 수 있었다. 그런데 여기서 결과에 찍힌 순서를 보고 수행순서에 잘 알아둬야할 것이 있다. 시퀀스에 대한 mapfilter연산은 각 원소에 대해서 순차적으로 적용하기 때문에 위와 같은 출력결과를 얻게 된다.

하지만, 시퀀스 없이 직접연산을 구현한다면 map함수를 각 원소에 대해 먼저 수행해서 새 시퀀스를 얻고, 그 시퀀스에 대해 다시 filter를 수행할 것이다. 아마도 결과가 아래와 같이 나올 것이다.

map(1) 
map(2) 
map(3) 
map(4) 
filter(1)
filter(4)
filter(9)
filter(16)

그리고 또 한 가지 차이점은 결과값이 일찍 얻어지면 그 이후의 원소에 대해서는 변환이 이뤄지지 않을 수도 있다. 이해가 쉽지 않을 테니 아래의 예시를 보자

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

// result
map(1) 
map(2) 
map(3) 
map(4) 
filter(1)
filter(4)
filter(9)
filter(16)
4

예상하던 대로 결과가 출력됐다. 하지만 위 코드에서 asSequence를 추가하게 되면 어떻게 출력이 될지 생각해보라.

// result
map(1) 
filter(1)
map(2) 
filter(4)
4

위와 같이 2번째 원소에서 결과를 찾았기 때문에 그 이후의 원소에 대해서는 mapfilter연산을 적용하지 않았다. 위와 같은 예시를 보며 컬렉션에 대해 수행하는 연산의 순서도 성능에 영향을 끼친다.

Q. User를 담고 있는 컬렉션(리스트)에서 이름의 길이가 4자리보다 작은 사람의 명단을 얻고 싶다고 하자. 어떤 순서로 구성하는 것이 좋을지 생각해보라.

정답은 filter를 먼저하고 map을 하는 것이다. map을 먼저하고 filter를 해도 결과는 같을 수 있지만 모든 원소에 대해서 map연산을 해줘야한다. 하지만 filter를 먼저할 경우 조건에 만족하지 않는 원소를 제외시키기 때문에 제오된 원소들은 map연산을 수행하지 않는다.

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

코틀린 표준 라이브러리의 withapply를 보여준다. 자바의 람다에는 없는 코틀린 람다의 독특한 기능이다. 이 기능은 수신 객체를 명시하지 않고 람다의 본문 안에서 다른 객체의 메소드를 호출할 수 있게 하는 것이다. 이를 수신 객체 지정 람다라고 부른다.

with 함수

어떤 객체의 이름을 반복하지 않고도 그 객체에 대해 다양한 연산을 수행할 수 있다면 좋을 것이다. 코틀린은 with라는 라이브러리 함수를 통해 제공한다. 아래의 코드를 리팩토링해보자.

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

여기서 result 변수에 대해 여러 메소드를 호출하면서 반복 사용했다. 이 정도 반복은 크게 무리가 없지만 코드가 훨씬 길거나, result 변수를 더 자주 반복하는 경우 with로 대체할 수 있다.

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

위와 같은 문법은 with(StringBuilder, { ... })라고 생각해도 무방하다. 그리고 this.를 통해서 접근할 수 있지만 생략 가능하므로 생략했다. 위의 코드를 다시 한 번 리팩토링하면 아래 코드와 같다

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

불필요한 stringBuilder 변수를 없애면 식을 본문으로하는 함수로 표현할 수 있다.

  • 메소드 이름 충돌

    with에게 인자로 넘긴 객체의 클래스와 with 블록 내에서 사용하는 메소드명이 같은 상황에서 해당 메소드를 호출하면 무슨 일이 생길까?
    그런 경우 this 참조 앞에 레이블을 붙이면 호출하고 싶은 메소드를 명확하게 정할 수 있다.
    alphabet 함수가 OuterClass의 메소드인 경우 StringBuilder가 아닌 바깥쪽 클래스에 정의된 toString을 호출하고 싶다면 다음과 같은 구문을 사용해야 한다.
    this@OuterClass.toString()

/* 메소드 이름 충돌 예시 */
class Coffee {
    override fun toString() = "americano"

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

// result
americano

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

apply는 확장 함수로 정의돼 있다. apply를 실행한 결과는 StringBuilder객체다.

fun alphabet() = StringBuilder().apply {
    for (letter in 'A' .. 'Z') {
        append(letter)
    }
    append("\nNow I know the alphabet!")
}.toString()
profile
1주 1글 실천하자

0개의 댓글