Study/kotlin

[코틀린 프로그래밍] Chapter.12 코틀린에서 구현하는 유창성

에디개발자 2021. 6. 1. 07:00
반응형

나를 닮았다고 한다...

먼저 정리하기에 앞서 이 책에서 가장 기억에 남는 문장

"죄송합니다. 편지를 짧게 쓸 여유가 없어서 길게 씁니다" 
    - 블레즈 파스칼 -

개발을 하면 위 문장이 어떤 의미인지 이해할 수 있을 것입니다. 중복 코드 제거, 하나의 클래스 혹은 메서드에 모든 로직을 다 작성, 이중 삼중 if문(for문) 등등 다양한 이유로 코드는 길어집니다. 하지만 고민과 시간을 들이면 코드는 점점 줄어들 것 입니다. 이번 장에서는 어떻게 코틀린 코드를 잘 쓰고, 표현력 있게 쓰고 간결하게 쓰는 지를 작성할 것입니다. 

 

연산자 오버로딩

코틀린은 연산자를 오버로딩하여 사용할 수 있습니다. 즉 기능을 확장시킬 수 있습니다. 예제를 살펴보겠습니다. 

val one = BigInteger("1")
val two = BigInteger("2")

val result1 = one.plus(two)
val result2 = one + two    // Java 에서는 Error 발생

 

위 두개의 코드를 비교해보자. BigInteger 타입의 변수를 2개 선언하고 더하는 예제코드 입니다. 첫 번째 코드는 BigInteger에서 + 를 하기 위해 제공하는 plus 메서드를 사용하였습니다. 두 번쨰 코드는 + 만 사용하였습니다. 코틀린이 아닌 자바코드에서는 + 만으론 에러가 발생할 것 입니다. 코틀린은 연산자 오버로딩을 사용하여 구현이 가능합니다. + 구현체는 아래와 같습니다. 

@kotlin.internal.InlineOnly
public inline operator fun BigInteger.plus(other: BigInteger): BigInteger = this.add(other)

 

위 처럼 코드를 작성한다면 코드의 가독성은 높아질 것 입니다. 하지만 단점으로는 너무 무차별하게 사용한다면 개발자가 의미를 혼동하여 가독성이 떨어지는 역효과가 나타날 것 입니다. 

days + 2

Quiz) 위 코드는 2일 뒤일까요? 2시간 뒤일까요? 2달 뒤일까요?
개발자에게 퀴즈를 내지말자. 무분별한 사용은 지양하자

 

 

연산자를 오버로딩하기 위해서는 operator 키워드로 정의되어 있어야합니다. 아래의 예제는 Pair를 연산자 오버로딩을 적용하여 쉽게 풀어나가는 내용입니다. 

operator fun Pair<Int, Int>.plus(other: Pair<Int, Int>) =
    Pair(first + other.first, second + other.second)

val firstPair = Pair(1, 2)
val secondPair = Pair(3, 4)

val response = firstPair + secondPair
println("first: ${response.first} second: ${response.second}")
// first: 4 second: 6

 

위와 비슷한 맥락의 예제 코드 하나 더 작성해보겠습니다. 

import kotlin.math.abs

data class Complex(val real: Int, val imaginary: Int) {
    // operator fun times은 *와 동일
    operator fun times(other: Complex) =
        Complex(
            real * other.real - imaginary * other.imaginary,
            real * other.imaginary + imaginary * other.real
        )

    private fun sign() = if (imaginary < 0) "-" else "+"
    override fun toString() = "$real ${sign()} ${abs(imaginary)}i"
}

println(Complex(4, 2) * Complex(-3, 4))  // -20 + 101
println(Complex(1, 2) * Complex(-3, 4))  // -11 - 21

 

operator 키워드를 사용하여 연산자를 오버로딩할 수 있지만 정해진 메서드명을 사용해야 원하는 연산자와 매핑될 수 있습니다. 정해지지 않은 메서드 명을 사용한다면 에러가 발생합니다. 

data class Complex(val real: Int, val imaginary: Int) {
    // operator fun times은 *와 동일
    operator fun times(other: Complex) =
        Complex(
            real * other.real - imaginary * other.imaginary,
            real * other.imaginary + imaginary * other.real
        )

    operator fun aaa(other: Complex) {  // error 발생. 'operator' modifier is inapplicable on this function: illegal function name

    }

    private fun sign() = if (imaginary < 0) "-" else "+"
    override fun toString() = "$real ${sign()} ${abs(imaginary)}i"
}

 

연산자와 대응하는 메소드 이름은 아래와 같습니다.

연산자 대응하는 메소드 주의사항
+x x.unaryPlus()  
-x x.unaryMinus()  
!x x.not()  
x + y x.plus(y)  
x - y x.minus(y)  
x * y x.times(y)  
x / y x.div(y)  
x % y x.rem(y)  
++x x.inc() x는 할당 가능해야 함
x++  x.inc() x는 할당 가능해야 함
--x x.dec() x는 할당 가능해야 함
x-- x.dec() x는 할당 가능해야 함
x == y x.equals(y)  
x != y  !(x.equals(y))  
x < y x.compareTo(y) <=, >, >= 도 사용 가능
x[i] x.get(i)  
x[i] = y x.set(i, y)  
y in x x.contains(y) !in 으로도 사용 가능
x..y x.rangeTo(y)  
x() x.invoke()  
x(y)
x.invoke(y)  
+=, -=, *=, /=, %= {method}Assign()  

 

연산자 오러로딩할 때는 지켜야 할 규칙이 있습니다. 연산자를 오버로딩할 때 객체를 변경해서는 안됩니다. 아래의 코드로 알아보겠습니다.

class Counter(val value: Int) {
    operator fun inc() = Counter(value + 1)
    operator fun dec() = Counter(value - 1)
    override fun toString() = "$value"
}


 위 코드로 연산자를 오버로드 하였지만 객체를 변경하지 않고 새로운 객체를 리턴하는 것을 확인할 수 있습니다. 사용할 때 몇 가지 추천사항을 따르도록 합시다!

  - 절제하자
  - 코드를 읽는 사람 입장에서 당연하게 받아들여질 경우만 사용하자
  - 오버로딩 연산자는 일반적인 연산자의 동작이어야한다
  - 변수이름을 의미있게 만들어라. 그래야 오버로딩의 문맥을 파악하기 좋다.
 

 

확장 함수와 속성을 이용한 인젝팅

코틀린은 개발자에게 다른 JVM언어에서 작성된 클래스를 포함한 모든 클래스에 메소드와 속성을 인젝팅할 수 있는 권한을 주고 있습니다. 코틀린은 인젝션을 런타임 패치나 클래스 로딩없이 수행합니다. 또한 클래스는 확장에 열려있습니다. 상속이 불가능한 클래스 또한 확장에는 여려있습니다. 확장함수와 확장 속성은 확장하려는 클래스의 바이트코드 변경 없이 메소드와 속성을 추가하는 테크닉입니다.

 

확장 함수를 이용한 메소드 인젝팅

예제를 통해서 이해해봅시다! 먼저 2개의 데이터 클래스를 생성합니다.

data class Point(val x: Int, val y: Int)
data class Circle(val cx: Int, val cy: Int, val radius: Int)

 

생성한 클래스에는 아무런 메서드가 존재하지 않습니다. 그리고 Circle라는 클래스에 확장 함수를 적용해보겠습니다.

fun Circle.contains(point: Point) =
    (point.x - cx) * (point.y - cx) + (point.y - cy) * (point.y - cy) < radius * radius

 

위 코드는 클래스 바깥에 존재합니다. 하지만 확장함수를 통해 클래스에 기능을 구현할 수 있습니다. 사용법은 일반적으로 사용하는 방법과 동일합니다.

// 선언
val circle = Circle(100, 100, 25)
val point1 = Point(110, 110)
val point2 = Point(10, 100)

// 사용
println(circle.contains(point1))  // true
println(circle.contains(point1))  // false

사용 범위로는 같은 파일이거나 메소드가 있는 패키지를 임포트한 경우는 사용할 수 있습니다. 확장 함수는 패키지의 static 메소드로 생성됩니다. 메소드 호출로 보이는 과정은 사실은 static 메소드를 호출하는 과정과 동일합니다. 

 

확장함수의 한계
확장함수와 인스턴스 메소드가 같은 이름을 가지고 있어서 충돌을 일으키면 항상 인스턴스 메소드가 실행됩니다.
인스턴스의 캡슐화된 부분에 접근할 수 있는 인스턴스 메소드와는 다르게 확장 함수는 정의된 패키지 안에서 객체에 보이는 부분에만 접근가능합니다.

 

확장 함수를 이용한 연산자 인젝팅

확장 함수는 연산자 역시 될 수 있습니다. 단순히 확장 함수 앞에 operator 키워드를 더해 사용할 수 있습니다. 예제로 살펴보겠습니다.

// 선언
operator fun Circle.contains(point: Point) =
    (point.x - cx) * (point.y - cx) + (point.y - cy) * (point.y - cy) < radius * radius
    
// 사용    
println(circle.contains(point1))  // true
println(point1 in circle)  // true
println(point2 in circle)  // false    

 

확장 속성을 이용한 속성 인젝팅

확장 함수와 같은 맥락으로 확장 속성 역시 추가가 가능합니다. 확장 속성은 클래스 내부에 존재하지 않기 때문에 백킹 필드를 가질 수 없습니다. 즉, 확장 속성은 field에 접근할 수 없습니다. 예제로 살펴보겠습니다.

// 선언
val Circle.area: Double
    get() = kotlin.math.PI * radius * radius

// 사용
val circle2 = Circle(100, 100, 25)
println("Area is ${circle2.area}")

var로 정의된 확장 속성에는 setter도 사용할 수 있습니다. setter또한 백킹 필드를 가질 수 없습니다.

 

서드파티 클래스 인젝팅

확장함수는 서드파티 클래스에 추가할 수 있고 이미 존재하는 메소드로 확장 함수를 라우팅할 수도 있습니다. 

// 선언
fun String.isPalindrome(): Boolean {
    return reversed() == this
}

// 선언
fun String.shout() = toUpperCase()

// 사용
val str = "dad"
println(str.isPalindrome())  // true
println(str.shout())  // DAD

 

확장 함수로 이미 존재하는 메소드의 동작을 바꿔 개발자에게 혼란을 주지 맙시다!

아래의 예제는 소문자로 변경하는 기능을 강제로 대문자로 변경하도록 하는 소스입니다.

fun String.toLowerCase() = toUpperCase()  // Bad!!

 

확장 함수를 통해 아래와 같은 String iterator도 구현이 가능합니다. 

// 선언
operator fun ClosedRange<String>.iterator() =
    object : Iterator<String> {
        private val next = StringBuilder(start)
        private val last = endInclusive
        override fun hasNext() = last >= next.toString() && last.length >= next.length
        override fun next(): String {
            val result = next.toString()
            val lastCharacter = next.last()
            if (lastCharacter < Char.MAX_VALUE) {
                next.setCharAt(next.length - 1, lastCharacter + 1)
            } else {
                next.append(Char.MIN_VALUE)
            }
            return result
        }
    }
    
// 사용
for (word in "hell".."help") {
    println("$word")
}

// 결과
hell, helm, heln, helo, help,

 

Static 메소드 인젝팅

클래스의 컴패니언 객체를 확장하여 static 메소드를 인젝팅할 수 있습니다.

// 선언
fun String.Companion.toURL(link: String) = java.net.URL(link)

// 사용
val url: java.net.URL = String.toURL("https://naver.com")

 

클래스 내부에서 인젝팅

위에서 알아본 확장 함수는 모두 클래스 외부에서 작성하고 인젝팅하였습니다. 하지만 확장 함수는 클래스 내부에서도 사용할 수 있습니다.

class Point(x: Int, y: Int) {
    private val pair = Pair(x, y)
    private val firstSign = if (pair.first < 0) "" else "+"
    private val secondSign = if (pair.second < 0) "" else "+"
    override fun toString() = pair.point2String()  // 클래스 내부에서 선언
    fun Pair<Int, Int>.point2String() =
        "(${firstSign}${first}, ${this@Point.secondSign}${this.second})"
}

// 사용 ( 객체를 프린트하면 toString() 메소드를 실행합니다. )
println(Point(1, -3))
println(Point(-3, 4))

// 결과
(+1, -3)
(-3, +4)

 

확장 함수가 클래스 내부에 생성되었기 때문에 확장 함수에는 this와 this@Point 두개의 리시버를 가지고 있습니다. 이 두 리시버는 코틀린에서는 별개의 이름을 가지고 있습니다. 하나는 Extension Receiver, 다른 하나는 dispatch receiver 입니다. 

  • Extension Receiver
    • 확장 함수가 실행되는 객체입니다. 즉 확장 함수를 리시브하는 객체입니다.
    • ${firstSign}${first}, ${this@Point.secondSign}${this.second}
  • Dispatch Receiver
    • 확장 함수를 만들어 추가한 클래스의 인스턴스입니다. 즉, 메소드 인젝션이 된 클래스입니다.
    • ${firstSign}${first}, ${this@Point.secondSign}${this.second}

 

함수 확장

코틀린에서 함수는 객체입니다. 그리고클래스에 메소드를 인젝트한 것처럼 함수에도 메소드를 인젝트할 수 있습니다.

// andThen 메소드를 인젝트 해보자!
fun <T, R, U> ((T) -> R).andThen(next: (R) -> U): (T) -> U =
    { input: T -> next(this(input)) }

 

위에서 인젝트한 메소드를 사용하기 위해 두 개의 단독함수를 생성하여 사용해보겠습니다.

// 단독함수 선언
fun increment(number: Int): Double = number + 1.toDouble()
fun double(number: Double) = number * 2

// 사용
val incrementAndDouble = ::increment.andThen(::double)
println(incrementAndDouble(5)) // 12.0

 

코드에서 결과값으로 확인할 수 있듯이 변수 incrementAndDouble은 increment 함수를 먼저 실행한 후 double 함수를 실행합니다. 

 

infix를 이용한 중위표기법

점과 괄호는 우리가 작성하는 코드에서 흔하게 볼 수 있습니다. 하지만 많은 경우 점과 괄호를 제거하면 코드의 노이즈를 적게 만들고 이해하기 쉬운 코드를 만들 수 있습니다. 간단한 Java 코드를 살펴보겠습니다

//Java
if (obj instanceof String) {

}

 

위 코드처럼 작성하면 가독성은 높을 것입니다. 하지만 위 코드가 아래와 같다면 가독성은 많이 낮아질 것입니다. 

if (obj.instanceOf(String) {

} 

 

앞에서 작성한 Circle과 Point 코드를 살펴보겠습니다

// data 클래스 선언
data class Point(val x: Int, val y: Int)
data class Circle(val cx: Int, val cy: Int, val radius: Int)

// 사용
println(circle.contains(point1))
println(point1 in circle)

contains() 메서드를 호출하기 위해서 in을 사용하지 않고 점과 괄호를 사용했습니다. contains()는 메소드고 in은 + 와 마찬가지로 연산자이기 때문입니다. 코틀린에서 연산자는 항상 자동으로 중위표기법을 사용합니다. 하지만 코틀린에서는 약간의 변경만 해주면 contains를 in처럼 사용 가능해집니다.

 

메소드에 infix 어노테이션을 사용하면 코틀린은 점과 괄호를 제거하는 것을 허용해줍니다. infix는 operator와 함께 사용할 수 있습니다. 하지만 infix와 operator는 항상 같이 사용해야할 필요가 없는 독립적입니다. 위 예제코드를 중위표기법으로 변경해보겠습니다.

// 선언
operator infix fun Circle.contains(point: Point) =
    (point.x - cx) * (point.y - cx) + (point.y - cy) * (point.y - cy) < radius * radius
    
// 사용
val circle = Circle(100, 100, 25)
val point1 = Point(110, 110)

println(circle.contains(point1)) // true
println(circle contains point1) // true  ( 중위표기법 )

 

코틀린은 infix를 이용해서 함수의 유연성을 제공하지만 한계점이 존재합니다. infix 메소드는 정확히 하나의 파라미터만 받아야합니다. 즉, vararg도 사용이 불가능하고 기본 파라미터도 사용 불가능합니다.

 

Any 객체를 이용한 자연스러운 코드

코틀린은 몇 가지 편리한 함수를 추가하여 표련력 있고 코드가 덜 장황해지는 기능을 가능하게 만들었습니다. 코틀린은 also(), apply(), let(), run() 인 4가지의 특별한 메소드를 가지고 있습니다. 이 메소드는 람다 표현식을 파라미터로 받고 전달받은 람다를 실행하고 객체를 리턴해줍니다. 메소들ㄹ 실행하는 방법은 아래와 같습니다. 

result = context.oneOfTheseFourMethods { optionalParamter ->
  ...body...
  ...what's this (receiver) here?...
  optionalResult
}  

 

4가지 메소드의 동작

바로 예제로 살펴보겠습니다. 

val format = "%-10s%-10s%-10s%-10s"
val str = "context"
val result = "RESULT"
fun toString() = "lexical"
println(String.format("%-10s%-10s%-10s%-10s", "Method", "Argument", "Receiver", "Return", "result"))
println("==========================================================")

// let
val result1 = str.let { arg ->
    print(String.format(format, "let", arg, this, result))
    result
}
println(String.format("%-10s", result1))

// also
val result2 = str.also { arg ->
    print(String.format(format, "also", arg, this, result))
    result
}
println(String.format("%-10s", result2))

// run
val result3 = str.run {
    print(String.format(format, "run", "N/A", this, result))
    result
}
println(String.format("%-10s", result3))

// apply
val result4 = str.apply {
    print(String.format(format, "apply", "N/A", this, result))
    result
}
println(String.format("%-10s", result4))

 

위 코드의 결과값은 아래와 같습니다. 

Method    Argument  Receiver  Return
==========================================================
let       context   lexical   RESULT    RESULT
also      context   lexical   RESULT    context
run       N/A       context   RESULT    RESULT
apply     N/A       context   RESULT    context

 

4개의 메소드를 간단하게 정리해보겠습니다. 

  - 4개의 메소드 모두 전달받은 람다를 실행
  - let()과 run()은 람다를 실행시키고 람다의 결과를 호출한 곳으로 리턴
  - also()와 apply()는 람다의 결과를 무시하고 컨텍스트 객체를 호출한 곳으로 리턴
  - run()과 apply()는 run()과 apply()를 호출한 컨텍스트 객체의 실행 컨텍스트를 this로 사용하여 실행

 

장황하고 지저분한 코드로부터

위에서 학습한 4가지 메소드를 연습해보겠습니다. 먼저 클래스 Mailer를 선언합니다.

class Mailer {
    val details = StringBuilder()
    fun from(addr: String) = details.append("from $addr...\n")
    fun to(addr: String) = details.append("to $addr...\n")
    fun subject(line: String) = details.append("subject $line...\n")
    fun body(message: String) = details.append("body $message...\n")
    fun send() = "...sending...\n$details"
}

 

그리고 위 클래스를 사용하는 다소 장황한 예제입니다. 

val mailer = Mailer()
mailer.from("builder@naver.com")
mailer.to("kong@naver.com")
mailer.subject("Your code sucks")
mailer.body("...details...")
val mailResult = mailer.send()
println(mailResult)

// 결과
...sending...
from builder@naver.com...
to kong@naver.com...
subject Your code sucks...
body ...details......

 

apply를 이용한 반복 참조 제거

위에서 작성한 장황한 코드를 apply를 적용하여 변경해보겠습니다. 

val applyMailer =
    Mailer()
        .apply { from("builder@naver.com") }
        .apply { to("kong@naver.com") }
        .apply { subject("Your code sucks") }
        .apply { body("...details...") }

var applyMailerResult = applyMailer.send()
println(applyMailerResult)

 

결과값은 동일하게 나옵니다. 하지만 위 코드도 반복된 apply로 지저분해졌습니다. 아래와 같이 수정해보자!

val apply2Mailer =
    Mailer().apply {
        from("builder@naver.com")
        to("kong@naver.com")
        subject("Your code sucks")
        body("...details...")
    }
var apply2MailerResult = apply2Mailer.send()
println(apply2MailerResult)

 

builder-pattern 같은 종류의 표현식은 어떤 클래스에서도 사용할 수 있습니다. setter를 체이닝으로 사용되도록 디자인되지 않은 클래스에서도 사용가능합니다.

 

run을 이용한 결과 얻기

apply는 Mailer의 메소드를 여러 번 콜하도록 만들었습니다. 하지만 우리가 최종적으로 원했던 것은 send() 메소드를 호출하는 것이었습니다. 메소드의 연속적인 호출 이후에 인스턴스를 더 이상 쓸일이 없다면 run() 사용할 수 있습니다. apply()와는 다르게 run() 메소드는 람다의 결과를 리턴합니다. run을 적용해보겠습니다.

val runMailerResult =
    Mailer().run {
        from("builder@naver.com")
        to("kong@naver.com")
        subject("Your code sucks")
        body("...details...")
        send()
    }
println(runMailerResult)

 

run()에서 사용된 Malier 인스턴스는 더 이상 사용이 불가능해졌습니다. 

연속적으로 메소드 호출을 하고 마지막에 타깃 객체를 유지하고 싶다면 apply()를 사용하자.

마지막 타깃 객체가 아닌 람다 표현식의 결과를 유지하고 싶다면 run을 사용하자.

 

let을 이용해 객체를 아규먼트로 넘기기

함수에서 인스턴스를 받았지만 해당 인스턴스를 다른 메소드의 아규먼트로 전달하고 싶다고 가정해봅니다. 이런 연산의 순서는 일반적으로 코드의 자연스러운 흐름을 깨버립니다. 이럴 때 let을 사용합니다.

이런 관점을 설명하기 위해 다음 예제를 살펴보겠습니다. 

fun createMailer() = Mailer()
fun prepareAndSend(mailer: Mailer) = mailer.run {
    from("builder@naver.com")
    to("kong@naver.com")
    subject("Your code sucks")
    body("...details...")
    send()
}

 

createMailer()와 prepareAndSend()의 내부는 여기서 주요하지 않습니다. 두 함수를 사용하는 코드에 집중해보자!!

val newMailer = createMailer()
val newResult = prepareAndSend(newMailer)
println(newResult)

 

위 코드는 아래와 같이 변경이 가능합니다. 

println(prepareAndSend(createMailer()))

 

여기까지 살펴봤을 때 코드에 자연스러움이 없습니다. 여러개의 괄호 때문에 매우 무겁게 느껴지고 있습니다. 여기서 원하는 것은 연산 하나의 결과를 가지고 온 후 다음 스텝을 수행하게 하는 것입니다. 메소드 호출을 한 후 다음 메소드로 연결이 편해야 한다는 의미입니다. let() 메소드를 적용해보겠습니다.

val letResult = createMailer().let { mailer ->
    prepareAndSend(mailer)
}

 

let 메서드 안에서 자기 자신은 it으로 사용할 수 있습니다. 

createMailer().let { prepareAndSend(it) }

 

위 코드는 함수 참조 사용을 통해 아래와 같이 더욱 간단하게 작성할 수 있습니다. 

createMailer().let(::prepareAndSend)

 

let()에 아규먼트로 전달한 람다의 결과를 사용하기 원한다면 let()이 좋은 선택입니다. 하지만 let()을 호출한 타깃 객체에서 뭔가 작업을 계속 하길 원한다면 also()를 사용해아합니다. 

 

also를 사용한 void 함수 체이닝

also() 메소드는 체이닝을 사용할 수 없는 void 함수를 체이닝 하려고 할 때 유용합니다. 

fun prepareMailer(mailer: Mailer): Unit {
    mailer.run {
        from("builder@naver.com")
        to("kong@naver.com")
        subject("Your code sucks")
        body("...details...")
    }
}

fun sendMail(mailer: Mailer): Unit {
    mailer.send()
    println("Mail sent.....")
}

 

위 코드를 사용해보겠습니다. 

// 본인이 이런코드 정말 많이 짬..
val new2Mailer = createMailer()
prepareMailer(new2Mailer)
sendMail(new2Mailer)

 

also()를 이용하면 함수 호출을 할 때 체이닝을 사용할 수 있습니다. 왜냐면 also()가 타깃 객체를 람다에 파라미터로 전달하고, 람다의 리턴을 무시한 후 타깃을 다시 호출한 곳으로 리턴하기 때문입니다. 

// also 개선
createMailer()
    .also(::prepareMailer)
    .also(::sendMail)

 

4개의 메소드 정리

Method 사용 케이스
apply 연속적으로 메소드 호출을 하고 마지막에 타깃 객체를 유지하고 싶을 경우
run 마지막 타깃 객체가 아닌 람다 표현식의 결과를 유지하고 싶을 경우
let 함수에서 인스턴스를 받았지만 해당 인스턴스를 다른 메소드의 아규먼트로 전달하고 싶을 경우
also 체이닝을 사용할 수 없는 void 함수를 체이닝 하고 싶을 경우

 

암시적 리시버

let()이나 also() 메소드와는 다르게 run()이나 apply() 메소드는 타깃 컨텍스트에서 람다를 실행시킵니다. 코틀린의 언어나 라이브러리를 구현체들이 각 구현체들 안에서만 실행되는 것이 아니고 언어를 사용하는 모두에게 접근 권한을 주었다는 점입니다.

 

리시버 전달

JavaScript에서 함수는 0개 이상의 파라미터를 받습니다. 하지만 call() 메소드나 apply() 함수를 이용하면 컨텍스트 객체를 전달할 수 있습니다. 리시버를 다루기 전에 일반적인 람다 표현식을 살펴보겠습니다. 

var length = 100
val printIt: (Int) -> Unit = { n: Int ->
    println("n is $n, length is $length")
}
printIt(6)  // n is 6, length is 100

 

위 코드에서 length는 어디서 나왔을까? 람다 내부의 스코프에 length는 없습니다. 그래서 컴파일러는 렉시컬 스코프의 변수에서 length를 찾습니다. 즉, 람다 외부에서 왔습니다. 코틀린은 람다에 리시버를 세팅하는 좋은 방법을 제공합니다. 이렇게 하려면 람다의 시그니처를 약간만 변경하면 됩니다. 

val printIts: String.(Int) -> Unit = { n: Int ->
    println("n is $n, length is $length")
}

"Hello".printIts(6)  // n is 6, length is 5

 

위 코드는 String의 인스턴스의 컨텍스트에서 실행된다는 의미를 가지고 있습니다. 만일 (Int, Double)처럼 하나 이상의 파라미터를 받는 람다를 사용한다면 Int, Double 람다는 Type.(Int, Double) 이런 식으로 호출하면서 리시버를 가져올 수 있고 이 경우 TYpe이 리시버가 됩니다. 변수의 스코프를 해결할 때 컴파일러는 리시버의 스코프를 먼저 확인합니다. 하지만 리시버가 없거나 변수를 리시버에서 찾을 수 없으면 컴파일러는 렉시컬스코프를 찾게 됩니다. 

위 코드처럼 "Hello".printIts(6) 작성하게 된다면 코틀린튼 람다의 this는 렉시컬 this가 아닌 전달된 리시버를 참조하게 됩니다. 즉 "Hello"의 length 속성이 됩니다. 

 

리시버를 이용한 멀티플 스코프

람다 표현식은 다른 람다 표현식에 중첩될 수 있습니다. 이때 내부의 람다 표현식은 멀티플 리시버를 가진 것 처럼 보일 수 있습니다. ( 다이렉트 리시버와 부모의 리시버 ) 부모 람다가 다른 람다에 중첩된 람다라면 가장 안쪽의 람다 표현식은 3개 이상의 리시버를 가지는 것처럼 보일 수 있습니다. 하지만 람다 하나는 하나의 리시버만 가집니다. 하지만 중첩 레벨에 따라서 변수에 바인딩하기 위한 멀티플 스코프를 가질 수도 있습니다. 이제부터 예제를 살펴보겠습니다. 

fun top(func: String.() -> Unit) = "hello".func()
fun nested(func: Int.() -> Unit) = (-2).func()
top {
    println("In outer lambda $this and $length")
    nested {
        println("in inner lambda $this and ${toDouble()}")
        println("from inner through receiver of outer: ${length}")
        println("from inner to outer receiver ${this@top}")
    }
}

 

top()과 nested() 두개의 함수는 리시버를 포함한 람다 표현식을 파라미터로 받습니다. 각각의 함수는 전달받은 타깃 컨텍스트 또는 리시버 객체에서 람다 표현식을 실행합니다. 

top() 메소드는 String인 "hello" 리시버로 사용하는 람다 표현식을 실행합니다. nested() 함수는 같은 동작을 합니다. 위 코드의 결과는 아래와 같습니다. 

In outer lambda hello and 5
in inner lambda -2 and -2.0
from inner through receiver of outer: 5
from inner to outer receiver hello

첫 번째 println 에서는 top의 hello 인 리시버를 사용합니다. 

두 번째 println 에서는 nested의 -2 리시버를 사용합니다. 

세 번째 println 에서는 length를 nested에서 불러오려했으나 int에는 length가 없어 top으로 올라가(렉시컬스코프) 찾게됩니다.

네 번째 println 에서는 @를 사용하여 top에 있는 hello 리시버를 사용합니다.

 

정리

  - 언어의 유창성, 표현력은 언어 자체를 깨뜨릴 수도 있고, 만들 수도 있다.
  - 연산자 오버로딩을 조심스럽게 사용하면 직접 만든 클래스에 적절한 부분에서 편리하게 연산자를 사용할 수 있다.
  - 함수 확장, 프로퍼티 확장, 연산자 확장을 사용하면 우리는 서드파티 클래스에 우리만의 편리한 멤버를 추가해서 코드에 직관성을 추가 가능!
  - 중위표기법은 괄호와 점으로 인한 노이즈를 제거
  - 람다의 암시적 리시버를 비롯한 이런 기능들은 코틀린으로 DSL을 만드는데 큰 영향을 준다!

반응형