본문 바로가기

스터디/다재다능 코틀린 프로그래밍

12. 코틀린에서 구현하는 유창성

다재다능 코틀린 프로그래밍 책으로 코틀린 스터디를 진행하면서 발표를 위해 준비했던 글입니다.


코틀린은 Java 바이트코드로 컴파일되는데 JVM은 연산자 오버로딩을 지원하지 않는다.

코틀린은 연산자 오버로딩을 지원하기 위해 연산자 별로 특정 메소드에 맵핑한다.

함수를 operator 키워드로 정의하고 연산자와 맵핑된 함수를 확장하면 연산자를 오버로딩할 수 있다.

연산자 오버로딩은 코드가 읽기에 명확하지 않다면 사용하지 않는게 좋다.

+연산자를 오버로딩해서 Pair를 더하는 예제

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

직접 만든 클래스에서 연산자를 오버로딩하려면 멤버함수로 작성해야 한다.

+=, -=, *= 과 같은 혼합된 연산자는 첫 번째 연산자가 매핑된 메소드명 뒤에 Assign을 붙여서 오버로딩하면 되는데 첫 번째 연산자를 오버로딩했다면 굳이 둘 다 구현할 필요는 없다.

예를 들어 +연산자의 plus() 함수를 구현했다면 plusAssign()은 구현하지 않아도 컴파일러는 구현된 plus()함수를 적절히 사용한다.

+=-=연산은 피연산자의 상태를 변경시키기 때문에 뮤터블이어야한다.

하지만 연산자를 오버로딩할 때는 객체를 변경하면 안된다.

무슨말인지 예제로 알아보자.

class Count(private val value: Int) {
    operator fun inc() = Count(value + 1)
    override fun toString() = "$value"
}
var count = Count(1)
println(count.hashCode())  // 124313277
println(count++)           // 1
println(count.hashCode())  // 88579647
println(count++)           // 2
println(count.hashCode())  // 654845766

+연산자를 객체를 변경하지 않도록 구현했지만 코틀린 컴파일러가 연산 결과로 나온 새로운 객체를 저장해두었다가 기존 객체에 대체해주는 식으로 연산을 처리한다. ++ , --, += 등등도 이렇게 처리되기 때문에 연산자 오버로딩은 객체를 변경하지 않도록 구현하고 피연산자는 뮤터블로 선언하면 된다.

결론적으로 연산자 오버로딩은 의미있는 변수이름으로 읽는 사람 입장에서 당연하게 받아들여질 경우에만 사용하자.

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

점 좌표가 원의 범위안에 포함되어있는지 확인하는 예제

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

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

val circle = Circle(100, 100, 25)
val point = Point(110, 110)
println(circle.contains(point)) // true

Point가 Circle안에 위치에 있는지 찾아야 될 때 Circle 클래스에 직접 메소드를 만들 필요가 없이 확장함수를 Circle 클래스에 인젝트할 수 있다.

패키지 top level에 확장 함수를 작성해주면 Circle 클래스의 인스턴스 멤버에 접근할 수 있다. 그리고 패키지의 static 메소드로 만들어진다.

확장 함수는 인스턴스 메소드와 같은 이름을 가져서 충돌을 일으키면 항상 인스턴스 메소드가 실행된다.

그리고 public한 속성과 메소드에 밖에 접근할 수 없다.

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

in 연산자는 contains 메소드와 맵핑된다. 따라서 operator 키워드로 확장 함수로 연산자 인젝팅을 해줄 수 있다.

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

val circle = Circle(100, 100, 25)
val point = Point(110, 110)
println(point in circle) // true

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

확장 속성을 추가하면 실제 속성처럼 사용할 수 있다. 단 내부에 존재하는게 아니기에 백킹 필드는 가질 수 없다.

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

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

val circle = Circle(100, 100, 25)
println("Area is ${circle.area}")

var로 정의된 확장 속성에는 setter도 사용할 수 있다.

서드파티 클래스 인젝팅

확장 함수를 서드파티 클래스에 추가할 수 있다.

// String 클래스에 확장 함수 추가
fun String.isPalindrome(): Boolean {
    return reversed() == this
}

이미 존재하는 메소드를 호출하는 단일표현식을 사용할 수 있다.

fun String.shout() = toUpperCase()

4-1 정방향 반복에서 실패했던 String 범위 반복도 서드파티 클래스 인젝팅으로 해결할 수 있다.

for (alphabet in "a".."z") {
    print("%word, ")
}
// for-loop range must have an iterater() method

CloseRange 클래스에서 iterator 메소드가 없어서 실패했던 코드이다.

아래와 같이 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 {
            ...
        }
    }

static 메소드 인젝팅

클래스의 컴페니언 객체를 확장해서 static 메소드를 인젝팅할 수 있다. 즉 컴페니언 객체를 가지고 있다면 static 메소드를 인젝팅할 수 있다.

String은 컴패니언 객체가 있기에 아래와 같이 static 메소드를 추가할 수 있다.

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

모든 서드파티 클래스에 static 메소드를 추가할 수는 없다.

java.net.URL 클래스는 컴패니언 클래스가 없기때문에 static 메소드를 추가할 수 없다.

클래스 내부에서 인젝팅

지금까지 확장 함수는 탑레벨에서 인젝팅 했고 클래스 내부에서도 인젝트할 수 있다.

앞서 봤던 Point 클래스에 x, y를 Pair 객체로 private하게 갖고있다면 클래스 내부에서 Pair 확장 함수를 인젝트해줘야 한다.

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.pointToString()
    fun Pair<Int, Int>.pointToString() =
        "(${firstSign}${first}, ${this@Point.secondSign}${this.second}"
}

확장 함수가 클래스 내부에서 생성되었을 때는 this와 this@Point 라는 두개의 리시버를 가진다.

이 두 리시버는 각각 익스텐션 리시버와 디스패치 리시버라고 불린다.

  • 익스텐션 리시버: 확장 함수가 실행되는 객체, this에 해당, Pair의 확장함수에서 this기 때문에 Pair 객체에 해당한다. first, second에 바인딩
  • 디스패치 리시버: 확장 함수를 추가한 클래스의 인스턴스, this@Point에 해당 네이밍그대로 Point 객체에 해당한다. firstSign, secondSign에 바인딩
  • 먼저 익스텐션 리시버에 바인딩 시도하고 없으면 디스패치 리시버에 바인딩한다.
  • 익스텐션 리시버를 바이패스한 후 디스패치 리시버에 참조를 걸고싶다면 this@Outer 문법을 사용하면 된다.

함수 확장

자바8 함수인터페이스 Function<T, R>은 두개의 함수를 조합하기 위한 andThen() 메소드를 가지고 있지만 코틀린은 가지고 있지 않다.

코틀린 함수에 andThen() 메소드를 인젝트해보자.

fun <T, R, U> ((T) -> R).andThen(next: (R) -> U): (T) -> U =
    { input: T -> next(this(input))}

infix를 이용한 중위표기법

infix를 이용한 중위표기법으로 점과 괄호를 제거하고 이해하기 쉬운 코드를 만들 수 있다.

앞의 예제에서 contains를 in으로 사용했던 것처럼 코틀린에서 약간의 변경만 해주면 중위표기법을 사용할 수 있다.

infix operator fun Circle.contains(point: Point) =
    (point.x - cx) * (point.y - cy) * (point.y - cy) < radius * radius
println(circle contains point)

in이 아니라 contains로도 중위표기법을 사용할 수 있게 되었다.

infix와 operator는 서로 독립적이라는 것을 기억하자.

infix 메소드도 한계는 존재하는데, 하나의 파라미터만 받을 수 있고 기본 파라미터와 vararg도 사용할 수 없다.

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

4가지 메소드 동작 (let, also, run, apply)

al format = "%-10s%-10s%-10s%-10s"
val str = "context"
val result = "RESULT"
fun toString() = "lexical"

val result1 = str.let { arg -> 
    print(String.format(format, "let", arg, this, result))
    result
}
println(String.format("%-10s", result1))
val result2 = str.also { arg ->
    print(String.format(format, "also", arg, this, result))
    result
}
println(String.format("%-10s", result2))
val result3 = str.run { 
    print(String.format(format, "run", "N/A", this, result))
    result
}
println(String.format("%-10s", result3))
val result4 = str.apply {
    print(String.format(format, "apply", "N/A", this, result))
    result
}
println(String.format("%-10s", result4))
// RESULT    
// context   
// RESULT    
// context

위 코드 실행 결과로 알 수 있는 사실을 요약하면..

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

솔직히 무슨말인지 잘 모르겠다. 아래의 예제로 더 자세히 살펴보자..

Mailer 클래스를 사용해 Mail을 보내는 예제를 4가지 메소드로 바꿔보자.

import java.lang.StringBuilder

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

val mailer = Mailer()
mailer.from("dundung@woowahan.com")
mailer.to("junseong@woowahan.com")
val result = mailer.send();
println(result)

// sending..!
// from dundung@woowahan.com... 
// to junseong@woowahan.com...

apply를 이용한 반복 참조 제거

apply() 메소드는 호출한 객체의 컨텍스트에서 실행하고 컨텍스트 오브젝트를 호출한 객체로 다시 리턴해준다.

val mailer = Mailer()
    .apply { from("dundung@woowahan.com")}
    .apply { to("junseong@woowahan.com")}
val result = mailer.send();
println(result)

각각의 apply 호출에서 같은 인스턴스를 리턴받는 것이다.

아래와 같이 더 간단하게도 사용가능하다.

val mailer = Mailer().apply {
      from("dundung@woowahan.com")
        to("junseong@woowahan.com")
}
val result = mailer.send();
println(result)

run을 이용한 결과 얻기

val result = Mailer().run {
    from("dundung@woowahan.com")
    to("junseong@woowahan.com")
    send()
}
print(result)

run() 메소드는 apply()와 타겟 객체의 컨텍스트에서 람다를 실행시킨다는 점은 같지만 apply()와 다르게 람다의 결과를 리턴해준다.

타깃 객체를 유지하고 싶다면 apply()를 사용하고 마지막에 람다의 결과를 갖고싶다면 run()을 사용하자.

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

위와 같은 예제에서 Mailer를 만드는 createMailer 메소드와

Mailer객체를 파라미터로 받아서 from, to를 호출한 후 send까지해주는 prepareAndSend 메소드를 추가했다.

fun createMailer() = Mailer()
fun prepareAndSend(mailer: Mailer) = mailer.run {
    from("dundung@woowahan.com")
    to("junseong@woowahan.com")
    send()
}

val mailer = createMailer();
val result = prepareAndSend(mailer)
print(result)

추가한 두 개의 메소드를 평소 자바 처럼 사용했다.

let을 이용해서 객체를 아규먼트로 넘기면 아래와 같이 더 간단하게 사용할 수 있다.

val result = createMailer().let(::prepareAndSend)
print(result)

also를 사용한 void 함수 체이닝

also() 메소드는 체이닝 할 수 없는 void 함수를 체이닝할 때 유용하다.

void 함수를 추가한 예제를 보자.

fun createMailer() = Mailer()
fun prepareMailer(mailer: Mailer): Unit {
    mailer.run {
        from("dundung@woowahan.com")
        to("junseong@woowahan.com")
    }
}
fun sendMail(mailer: Mailer): Unit {
    mailer.send()
    println("Mail sent")
}
val mailer = createMailer()
prepareMailer(mailer)
sendMail(mailer)

void 메소드를 사용하는 전형적인 예시이다.

also()는 타깃 객체를 람다에 파라미터로 전달하고, 람다의 리턴을 무시한 후 타깃을 다시 호출한 곳으로 리턴하기 때문에 void 함수 호출도 체이닝할 수 있다.

createMailer()
.also(::prepareMailer)
.also(::sendMail)

암시적 리시버

리시버 전달

일반적인 람다 표현식

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

10장에서 봤던 것 처럼 클로저에서 렉시컬스코핑으로 length 변수를 참조하고 있다.

코틀린은 람다에 리시버를 세팅하는 좋은 방법을 제공한다. 람다의 시그니처만 약간 변경하면 된다.

val printIt: String.(Int) -> Unit = {
    n: Int -> println("n: $n, length: $length")
}
printIt("Hello", 5)
// n: 5, length: 5

String.(Int)로 리시버를 설정해주니 외부에 length 변수가 없어도 타깃 리시버인 "Hello"의 length 속성이 된다.

타깃 리시버에 해당 속성이 없을 경우에는 컴파일러는 렉시컬 스코프를 찾게 된다.

하나 이상의 파라미터를 받는 경우에는 Type.(Int, Double) 처럼 리시버를 가져올 수 있다.

아래와 같이 람다를 리시버의 멤버 함수처럼 사용할 수도 있다.

"Hello".printIt(5)

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

가장 가까운 리시버(이너 리시버)와 부모 리시버(아우터 리시버)에 모두 접근할 수 있다.

fun top(func: String.() -> Unit) = "hello".func()
fun nested(func: Int.() -> Unit) = (-2).func()
top {
    println("hello 리시버 사용 $this and $length")
    nested { 
        println("Int -2를 리시버로 사용 $this and ${toDouble()}")
        println("부모 리시버로 라우팅 $length")
        println("부모 리시버 접근 ${this@top}")
    }
} 

// hello 리시버 사용 hello and 5
// Int -2를 리시버로 사용 -2 and -2.0
// 부모 리시버로 라우팅 5
// 부모 리시버 접근 hello
  • top 함수는 "hello"를 리시버로 사용했기 때문에 $this는 hello가 찍히고 $length는 5가 찍힌다.
  • nested는 -2를 리시버로 사용했기 때문에 $this는 -2가 찍히고 ${toDouble()}은 2.0이 찍힌다.
  • -2, Int에는 length 속성이 없기 때문에 부모 리시버로 라우팅된다, $length는 5가 찍힌다.
  • this@OuterFunctionName 문법을 이용해서 외보 스코프를 참조할 수 있다. this@top으로 부모 리시버를 참조했으므로 ${this@top}은 hello가 찍힌다.