Kotlin, 어렵지 않게 사용하기 (8) - lambda 3

2022. 10. 18. 23:58Spring/Kotlin

kotlin의 람다 사용 방식과 용례를 Java 코드와 비교하며 학습하는 것이 해당 포스팅의 목표입니다.

🔗 Kotlin 시리즈 모아보기

 

안녕하세요.

이번 포스팅에서는 Kotlin의 람다와 관련된 활용 문법을 학습합니다.

 


 

Sequence

: 한 번에 하나씩 열거될 수 있는 원소의 시퀀스를 표현

 

코틀린 지연 계산 시퀀스는 Sequence 인터페이스에서 시작한다.

 

public interface Sequence<out T> {
    public operator fun iterator(): Iterator<T>
}


Sequence 안에는 iterator라는 단 하나의 메소드가 있는데, 이를 통해 원소 값을 얻을 수 있다.

 

 

 

Collection API vs Sequence

이전 포스팅에서 map, filter 등 주요 컬렉션 함수를 살펴보았는데,

이런 함수는 각각의 함수 연산마다 새로운 컬렉션을 생성 후 다음 연산을 이어 결과 컬렉션을 생성한다.

시퀀스sequence를 사용하면 중간 임시 컬렉션을 사용하지 않고도 컬렉션 연산을 연쇄할 수 있다.

 

위의 말이 직관적으로 이해하기 어려울 수 있는데, 아래 예시를 확인해보자.

 

/* Basic Collection API */
listOf(1, 2, 3, 4)
	.map { it * it }
	.filter { it % 2 == 0 }
// map(1) map(2) map(3) map(4) filter(1) filter(4) filter(9) filter(16)


/* Sequnce */
listOf(1, 2, 3, 4)
	.map { it * it }
	.filter { it % 2 == 0 }
    .toList()
// map(1) filter(1) map(2) filter(4) map(3) filter(9) map(4) filter(16)

 

출력된 순서를 확인해보면 금방 이해할 수 있을 것이다.

기본 Collection API는 map에 대한 연산을 모두 수행한 리스트를 생성해서 filter로 넘기고,

Sequence는 하나의 원소를 map 연산 후 filter 연산을 거쳐 리스트에 담는다.

 

시퀀스를 사용한 예제는 중간 결과를 저장하는 컬렉션이 생기지 않고, 

특히 filter를 사용해 필요없는 연산을 미리 없앨 수 있기 때문에 원소가 많은 경우 성능이 눈에 띄게 좋아진다.

이 내용은 아래에서 더 살펴보도록 하자.

 

이처럼 코틀린 시퀀스는 Sequence 인터페이스는 지연 계산 실행하는데, 그림으로 보면 아래와 같다.

 

 

 

Intermediate and Terminal Operations

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

 

sequence.map { ... }.filter { ... }	// 중간 연산
    	.toList()			// 최종 연산

 

 

✔️ 중간 연산

: 원본 시퀀스 요소를 변경한 다른 시퀀스를 반환한다. 항상 지연 계산된다. 

(직역: 원본 시퀀스 요소를 변환하는 방법을 아는 다른 시퀀스를 반환한다.)

 


✔️ 최종 연산

: 초기 컬렉션의 변환 시퀀스로 얻은 컬렉션, 요소, 숫자 또는 기타 개체일 수 있는 결과를 반환한다.

 

 

 

중간 연산은 항상 지연 연산된다고 했는데,

그 의미를 알아보기 위해 최종 연산이 없는 예제를 살펴보자.

 

listOf(1, 2, 3, 4).asSequence()
	.map { it * it }
	.filter { it % 2 == 0 }  // 출력 값 없음

 

위 코드를 실행하면 아무 내용도 출력되지 않는다.

이는 map과 filter 변환이 지연되는데

이는 결과를 얻을 필요가 있을 때, 즉 최종 연산이 호출될 때 실행된다.

 

listOf(1, 2, 3, 4).asSequence()
	.map { it * it }
	.filter { it % 2 == 0 }
	.toList()
// map(1) filter(1) map(2) filter(4) map(3) filter(9) map(4) filter(16)

 

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

 

 

toList()

위의 예시에서 시퀀스를 다시 리스트로 변경 시켜주었다.

시퀀스의 원소를 순서에 따라 차례대로 순회iteration 한다면 시퀀스를 사용해도 된다.

하지만 인덱스 접근이나 다른 API 메소드가 필요하다면 시퀀스를 리스트로 변환해야 한다.

 

 

 

Order of operations

컬렉션 연산에서는 수행 순서를 잘 따져봐야 한다.

 

 

 

예시를 map과 find 연산으로 살펴보자.
map으로 리스트의 각 숫자를 제곱하고 제곱한 숫자중에서 find로 3보다 큰 첫번째 원소를 찾아보자.

 

listOf(1, 2, 3, 4).asSequence ()
    .map { it * it }
    .find { it > 3 }
// 4


시퀀스 연산에서는 연산을 차례대로 적용하다가

첫 원소가 map과 filter에 대한 모든 연산을 수행한 후, 두 번째 원소가 처리되는 식으로 모든 원소가 처리된다.

결과가 얻어지면 그 이후의 원소에 대해서는 변환이 이뤄지지 않을 수도 있다.

 

같은 연산을 시퀀스가 아니라 컬렉션에 수행하면 map의 결과가 먼저 평가되어 최초 컬렉션의 모든 원소가 변환된다.

두 번째 단계에서는 map을 적용해서 얻은 중간 컬렉션으로 부터 술어를 만족하는 원소를 찾는다.

 

시퀀스를 사용하면 지연 계산으로 인해 원소 중 일부의 계산은 이뤄지지 않는다.

이 코드를 즉시 계산(Eager operation, 컬렉션 사용)과 지연 계산(Lazy operation, 시퀀스 사용)으로 평가하는 경우의 차이를 보여준다.

 

 

 

Eager vs Lazy

: 즉시 계산 지연 계산

 

즉시 계산은 전체 컬렉션에 연산을 적용하지만 지연 계산은 원소를 한번에 하나씩 처리한다.

 

 

 


Eager Operation

: 컬렉션을 사용한 연산으로 리스트가 다른 리스트로 변환된다. 

 

위의 그림의 map 연산은 모든 원소를 변환한다. 

그 후 find가 조건을 만족하는 첫 번째 원소인 4($2^2$)를 찾는다.

 

 

Lazy Operation

: 시퀀스를 사용하면 find 호출이 원소를 하나씩 처리하기 시작한다. 

 

최초 시퀀스로 부터 원소를 하나 가져와서 map에 지정된 변환을 수행한다.

그 후 find에 지정된 조건을 만족하는지 검사한다. 

 

최초 시퀀스에서 2를 가져오면 제곱 값(4)이 3보다 커지기 때문에 그 제곱 값을 결과로 반환한다. 

이때 이미 답을 찾았으므로 3과 4를 처리할 필요가 없다.

 

 

 

위와 같은 연산 방식으로, 컬렉션 연산에서는 연산 순서가 성능에 큰 역할을 할 수 있다.

 

사람의 컬렉션에서 이름이 특정 길이보다 짧은 사람의 명단을 얻고 싶다고 하자.

 

 이 경우 map과 filter를 어떤 순서로 수행해도 된다.

그러나 map → filter의 경우와 filter → map의 경우, 결과는 같아도 수행해야 하는 변환의 전체 횟수는 다르다.

 

val people = listOf(Person("Alice", 29), Person ("Bob", 31), Person("Charles", 31), Person("Dan", 21))

// map → filter
people.asSequence()
    .map(Person::name)		// [Alice, Charles, Bob, Dan]
    .filter { it.length < 4 }	// [Bob, Dan]
    .toList() 

// filter → map
people.asSequence()
    .filter { it.name.length < 4 }	// [Person ("Bob", 31), Person("Dan", 21)]
    .map(Person::name)			// [Bob, Dan]
    .toList()

 

위 처럼 filter를 먼저 적용하면 전체 변환 횟수가 줄어든다.

 

 

시퀀스라는 개념은 자바 8 스트림과 비슷하다.

자바 스트림과 코틀린 시퀀스 비교하는 글은 이 곳을 참고할 수 있다.

 

 

decompile to Java

sequence를 java 파일로 decompile하면 어떻게 될까?

 

val people = listOf(Person("Alice", 29), Person("Bob", 31))

// kotlin
val sequenceName = people.asSequence()
	.find { it.name.length > 5 }
	?.name

 

위의 코틀린 코드를 java코드로 변환시키면 아래와 같다. 

 

// to Java
Sequence sequence = CollectionsKt.asSequence((Iterable)people);
Iterator iter = sequence.iterator();

Object result;
while(true) {
  if (iter.hasNext()) {
    Object nextPerson = iter.next();
    Person it = (Person)nextPerson;
    if (it.getName().length() <= 5) {
      continue;
    }

    result = nextPerson;
    break;
  }

  result = null;
  break;
}

 

보다시피 Iterator를 생성해서 찾는 것을 확인할 수 있다.

만약 여러 메소드 체인을 사용한다면,

Sequences.kt의 TransformingSequence를 통해 원소를 하나씩 조회한다.

 

 

 

 

Create Sequence

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

시퀀스를 만드는 다른 방법으로 generateSequence 함수를 사용할 수 있다. 

 

이 함수는 이전의 원소를 인자로 받아 다음 원소를 계산한다. 

다음은 generateSequence 로 0부터 100까지 자연수의 합을 구하는 프로그램이다.

 

val naturalNumbers = generateSequence(0) { it + 1 }
val numbersTo100 = naturalNumbers.takeWhile { it <= 100 }
println(numbersTo100.sum()) // 모드 지연 연산은 "sum"의 결과를 계산할 때 수행된다.
// 5050

 

이 예제에서 naturalNumbers와 numbersTo100은 모두 시퀀스며, 연산을 지연 계산한다. 

최종 연산인 sum() 메소드를 수행하기 전까지는 시퀀스의 각 숫자는 계산되지 않는다.

 

 

 

Characteristic

시퀀스의 특징을 살펴보면 아래와 같다.

 

✔️ 연산 수행: 시퀀스의 원소는 정의를 할 때 실행되는게 아니라 필요한 때에 계산

✔️ 중간 처리 결과를 따로 저장하지 않고 연산을 연쇄적으로 적용해서 효율적으로 계산

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

✔️ 시퀀스를 리스트로 만들 때는 toList를 사용한다.

 

하나의 규칙으로써, 연쇄적인 큰 컬렉션 연산이 필요할 때는 시퀀스를 사용하자.
중간 컬렉션을 생성함에도 불구하고 코틀린에서 즉시 계산 컬렉션에 대한 연산 이 더 효율적인 이유를 설명한다.
하지만 컬렉션에 들어있는 원소가 많으면 중간 원소를 재배열하는 비용이 커지기 때문에 지연 계산이 더 낫다.
시퀀스에 대한 연산을 지연 계산하기 때문에 정말 계산을 실행하게 만들려면 최종 시퀀스의 원소를 하나씩 이터레이션하거나 최종 시퀀스를 리스트로 변환해야 한다. 

 

 

 

Java functional interface

실제 개발을 하다보면 사용할 API 중 상당수는 자바로 작성되었을 것이다.

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


하나의 예시로 setOnClickListener 메소드만 존재하는 Button 클래스를 정의했다고 해보자.

이때 파라미터의 타입은 OnClickListener다.

 

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

 

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

/* 자바 */
public interface OnClickListener {
  void onClick(View v);
}

 

자바 8 이전의 자바에서는 setOnClickListener 메소드의 인자로 무명 클래스 인스턴스를 만들어야만 했다.

 

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

 

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

 

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


OnClickListener를 구현하기 위해 사용한 람다의 유일한 파라미터인

View 타입인 view 인자를 받는 onClick 람다를 바로 구현한 모습이다.

 

public interface OnClickListener {
	void onClick(View v);  // -> { view - > ... }
}

 

OnClickListener에 추상 메소드가 단 하나만 있는 함수형 인터페이스이기 때문에 가능한 코드이다.

함수형 인터페이스functional interface 또는 SAM 인터페이스, 

SAM은 단일 추상 메소드single abstract method라는 뜻이다.

 

자바 API에는 Runnable이나 Callable과 같은 함수형 인터페이스와 그런 함수형 인터페이스를 활용하는 메소드가 많다.

코틀린은 함수형 인터페이스를 인자로 취하는 자바 메소드를 호출할 때 람다를 넘길 수 있게 해준다.

따라서 코틀린 코드는 무명 클래스 인스턴스를 정의하고 활용할 필요가 없어서 여전히 깔끔하며 코틀린다운 코드로 남아있을 수 있다.

 

 

 

Lambda as Parameter

함수형 인터페이스를 함수의 인자로 코틀린 람다를 전달할 수 있다.

예를 들어 다음 메소드는 Runnable 타입의 파라미터를 받는 Java 메소드이다.

 

/* Java */
static public void postponeComputation(int delay, Runnable computation) {
  try {
    Thread.sleep(delay);
  } catch (Exception e) {
    System.out.println("Error occurs during thread sleep");
  }
        
  computation.run();
}

 

코틀린에서 람다를 이 함수에 넘길 수 있다.

컴파일러는 자동으로 람다를 Runnable 인스턴스로 변환해준다.

여기서 'Runnable 인스턴스'라는 말은 실제로는 'Runnable을 구현한 무명 클래스의 인스턴스'라는 뜻이다.

 

postponeComputation(1000) { println(42) }

 

컴파일러는 자동으로 그런 무명 클래스와 인스턴스를 만들어준다.

람다의 내부 로직이 바로 무명 클래스에 있는 유일한 추상 메소드 내부 로직이 된다.

위의 예제에서는 Runnable의 run이 추상 메소드다.
Runnable을 구현하는 무명 객체를 명시적으로 만들어서 사용할 수도 있다.

 

 

 

SAM Constructure

: 람다를 함수형 인터페이스로 명시적으로 변경

 

위에서 언급했다시피, SAM은 단일 추상 메소드single abstract method라는 뜻이다.

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

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

 

예를 들어 함수형 인터페이스의 인스턴스를 반환하는 메소드가 있다면 람다를 직접 반환할 수 없고, 

반환하고픈 람다를 SAM 생성자로 감싸야 한다. 

 

fun createAllDoneRunnable(): Runnable {
  return Runnable { println("All done! ")
}

 

SAM 생성자의 이름은 사용하려는 함수형 인터페이스의 이름과 같다. 

SAM 생성자는 그 함수형 인터페이스의 유일한 추상 메소드의 본문에 사용할 람다만을 인자로 받아서 

함수형 인터페이스를 구현하는 클래스의 인스턴스를 반환한다.

 

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

또한 함수형 인터페이스를 요구하는 메소드를 호출할 때

대부분의 SAM 변환을 컴파일러가 자동으로 수행할 수 있지만,

가끔 오버로드한 메소드 중에서 어떤 타입의 메소드를 선택해 람다를 변환해 넘겨줘야 할지 모호한 때가 있다.

그런 경우 명시적으로 SAM 생성자를 적용하면 컴파일 오류를 피할 수 있다.

 

 

 

Lambdas with Receivers

: with, apply

 

with와 apply, 두 함수는 매우 편리하며 많은 사람들이 사용한다. 

하지만 두 함수가 어떻게 정의됐는지 모르는 사용자도 많다.

 

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

그런 람다를 수신 객체 지정 람다lambda with receiver라고 부른다.

 

 

with()

: with는 수신 객체 지정 람다를 활용

 

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

코틀린은 with라는 라이브러리 함수를 통해 해당 기능을 제공한다.

 

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

// After Refactoring using With Method
fun alphabet(): String {
  val stringBuilder = StringBuilder()
    return with(stringBuilder) {
      for (letter in 'A'..'Z') {
        append(letter)
      }
      append("\nNow I know the alphabet!")
      this.toString()	// this는 with의 첫 번째 인자로 전달된 stringBuilder
    }
}

 

this는 with의 첫 번째 인자로 전달된 stringBuilder이다.

with를 통해 stringBuilder의 메소드를 this.append(...) 처럼 this 참조를 통해 접근하거나,

append(...) 처럼 바로 호출할 수 있다.

 

with문은 언어가 제공하는 특별한 구문처럼 보인다. 

하지만 with는 단순히 파라미터가 2개 있는 함수다. 

 

@kotlin.internal.InlineOnly
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    return receiver.block()
}

 

여기서 첫 번째 파라미터는 stringBuilder이고, 두 번째 파라미터는 람다다. 

람다를 괄호 밖으로 빼내는 관례를 사용함에 따라 전체 함수 호출이 언어가 제공 하는 특별 구문처럼 보인다. 

물론 이 방식 대신 with (stringBuilder, { . . . }) 라고 쓸 수도 있지만 더 읽기 나빠진다.

 

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

인자로 받은 람다 본문에서는 this를 사용해 그 수신 객체에 접근할 수 있다.

일반적인 this와 마찬가지로 this와 점(.)을 사용하지 않고 프로퍼티나 메소드 이름만 사용해도 수신 객체의 멤버에 접근할 수 있다.

 

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

 

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

 

StringBuilder의 인스턴스 를 만들고 즉시 with에게 인자로 넘기고, 람다 안에서 this를 사용해 그 인스턴스를 참조한다.

with가 반환하는 값은 람다 코드를 실행한 결과며, 그 결과는 람다 식의 본문에 있는 마지막 식의 값이다.

 

 



✔️ 메소드 이름 충돌

with에게 인자로 넘긴 객체의 클래스와 with를 사용하는 코드가 들어있는 클래스 안에 이름이 같은 메소드가 있으면 무슨 일이 생길까?

그런 경우 this 참조 앞에 레이블을 붙이면 호출하고 싶은 메소드를 명확하게 정할 수 있다.

alphabet 함수가 OuterClass의 메소드라고 하자.

StringBuilder가 아닌 바깥쪽 클래스 (OuterClass)에 정의된 toString을 호출하고

싶다면 다음과 같은 구문을 사용해야 한다.

this@OuterClass.toString()

 

 

 

 

 

 

apply()

apply 함수는 거의 with와 같다. 

 

때로 with를 사용하다가 람다의 결과 대신 수신 객체가 필요한 경우도 있다.

그럴 때는 apply 라이브러리 함수를 사용할 수 있다.

 

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

apply를 써서 alphabet 함수를 다시 리팩터링해 보자.

 

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

 

이 함수에서 apply를 실행한 결과는 StringBuilder 객체다.

따라서 그 객체의 toString을 호출해서 String 객체를 얻을 수 있다.

 

apply는 확장 함수로 정의돼 있다. 

apply의 수신 객체가 전달받은 람다의 수신 객체가 된다. 

 

@kotlin.internal.InlineOnly
public inline fun <T> T.apply(block: T.() -> Unit): T {
    contract {
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
    }
    block()
    return this
}

 

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

자바에서는 보통 별도의 Builder 객체가 이런 역할을 담당한다. 

 

코틀린에서는 어떤 클래스가 정의돼 있는 라이브러리의 특별한 지원 없이도 그 클래스 인스턴스에 대해 apply를 활용할 수 있다.

buildString은 앞에서 살펴 본 alphabet 코드에서 StringBuilder 객체를 만드는 일과 toString을 호출해주는 일을 알아서 해준다.

buildString의 인자는 수신 객체 지정 람다며, 수신 객체는 항상 StringBuilder 된다.

 

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

 

참고로, buildString는 아래와 같이 정의되어 있다.

 

 

@kotlin.internal.InlineOnly
public inline fun buildString(builderAction: StringBuilder.() -> Unit): String {
    contract { callsInPlace(builderAction, InvocationKind.EXACTLY_ONCE) }
    return StringBuilder().apply(builderAction).toString()
}

 

 

 

 

이번 포스팅으로 람다 시리즈를 마칩니다.

다음 포스팅은 Kotlin의 타입 시스템에 관한 내용을 가져오겠습니다.

오타나 잘못된 내용을 댓글로 남겨주세요!

감사합니다 ☺️