Generic 과 코틀린 확장 함수

2020-10-09

Java를 접하게 되면서 가장 익숙치 않고 어려웠던 제네릭에 대해서

미루고 미루도 드디어 공부를 하게 되었다.

이번 한번 본다고 모든 코드를 이해할 수 없겠지만, 이 기회에 감은 잡고 가야겠다.

Generic

제네릭(Generic)은 객체 내부에서 사용할 데이터 타입을 외부에서 정하는 기법이다.

class Box(t: Int) {
    var value = t
}

Box 클래스는 타입이 Int인 value를 가지고 있는데, 값은 Int 타입으로 고정이 되어있다. 따라서 생성자는 Int 값만 받을 수 있다.

제네릭은 객체의 타입을 고정하지 않고, 다양한 타입의 객체를 가지게 해준다.

class Box<T>(t: T) {
    var value = t
}

이제 Box클래스는 타입이 T 인 value를 갖게 된다. 클래스가 선언된 시점에서 t의 구체적인 타입(concrete type)은 결정 되지 않았다. t의 타입은 생성자 호출을 통해서 값이 입력되면 그때 입력된 값의 타입으로 확정된다.

val box = Box("kotlin") 
val box2 = Box<String>("kotlin") // 위와 같음

제네릭 함수 선언

list의 모든 요소에 대한 합을 구하는 함수를 만들면 fun sum(list: List<Int>): Int 과 같이 선언할 수 있다. 하지만 이런 경우에는 제네릭이 적합하지 않다. 모든 타입에 더하기가 적용 될 수 없기 때문이다.

따라서 list의 첫번째 요소를 꺼내오는 함수와 같은 타입에 관계 없는 함수에 제네릭을 적용하는것이 더 적합하다.

코틀린 표준 라이브러리

코틀린의 표준 라이브러리는 제네릭으로 구현 되어있다.

let, with, run, apply, also, use 와 같은 함수들을 말하는건데 다 비슷한 기능을 해보이는것 같으니 코드를 까보며 확인해 보자

let

// let
fun <T, R> T.let(block: (T) -> R):R 
// let

let 함수는 매개변수화된 타입(parameterized type) T의 확장 함수다. 객체가 자기 자신인 T를 받아서 R을 반환하는 block을 입력으로 받고, block 함수의 반환값 R을 반환한다. 뭔 말이야

주로 객체의 값을 변경할때 사용하는것 같다.

// AS-IS
val person = Person("dd", 30)
person.name = "aa"
person.age = 20

// TO-BE
val person = Person("dd", 30)
val result = person.let {
    it.name = "aa"
    it.age = 10
    it // block 함수의 반환 값
}

입력받은 block 함수에서는 it을 사용해서 person 객체에 접근한다. 그리고 block 함수의 반환값 it가 result에 할당된다.

let 함수는 null 처리에도 유용하게 사용 된다.

data class User(val firstName: String, val lastName: String)

// AS-IS
fun printUserName(user: User?) {
    if (user != null) {
        println(user.firstName)
    }
}
// TO-BE
fun printUserName(user: User?) {
    user?.let { println(it.firstName) } // user가 null 이라면 let 함수는 실행되지 않는다.
}
// TO-BE 2
val user = User("mh", "han")
val name = user?.let { it.lastName + it.firstName} ?: "unknown" // elvis 연산자를 통해 기본값 설정

with

fun <T, R> with(receiver: T, block: T.() -> R): R

with 은 let과 다르게 일반적인 함수로 선언되어 있다. 따라서 객체(receiver)를 직접 입력 받고, 객체를 사용하기 위한 block 함수를 두번째 매게변수로 받는다.

여기서 T.()를 람다 리시버라고 하는데, 람다 리시버는 첫 번째 매개변수로 받은 receiver의 타입 T를 block 함수의 입력인 T.()로 전달한다.

이렇게 전달받으면 block 함수에서 receiver로 받은 객체에 this를 사용하지 않고 접근할 수 있다. 그리고 with 함수는 block 함수의 반환값을 그대로 반한한다.

// 이해를 돕기 위한 람다 리시버 예
val stringToInt: String.() -> Int = { toInt() }
// 람다 리시버는 String.() 이고, toInt()는 String 객체에서 제공하는 함수다.
// stringToInt 함수의 구현부에서 this를 사용하지 않고 toInt를 사용했다.
// 원래라면
val stringToInt2: Int = (?) -> this.toInt() // 뭐 이런식이겠지?

with 함수도 객체 상태 변경하는데 사용된다.

val person = Person("dd", 30)
val result = with(person) {
    name = "aa"
    age = 10
    this
}

with 함수는 person 객체를 함수의 입력으로 받고, 블록 내에서 객체 프로퍼티에 직접 접근했다. (it, this 사용하지 않고) 가능한 이유는 block의 첫 번째 매개변수가 람다 리시버이기 때문이다. 특이점은 람다 리시버에서도 객체 자체에 접근할 때는 this를 사용한다.


run

fun <T, R> T.run(block: T.() -> R): R
fun <R> run(block: () -> R): R

run 함수는 두 가지 형태로 선언되어 있다.

첫번째 run 함수는 let과 같은 매개변수화된 타입 T의 확장 함수고, block 함수에 this가 람다 리시버로 전달된다. 그리고 block 함수의 반환 값을 그대로 반환한다.

val person = Person("dd", 30)
val result = person.run {
    name = "aa"
    age = 10
    this
}

run 함수는 let 함수가 확장함수라는 점, with 함수가 람다 리시버를 사용했다는점을 섞은 형태이다.

두번째 run 함수는 확장 함수가 아니고, block 함수에 입력값이 없다. 따라서 let, with 함수나 첫번째 run 함수와 같이

어떤 객체로부터 값을 연결되는 블록을 수행하기 위한 함수가 아니다.

두 번째 run 함수는 객체를 생성하기 위한 명령문을 하나의 블록으로 묶는 용도로 사용한다.

val person = run {
    val name = "dd"
    val age = 20
    Person(name, age)
}

두 번째 run 함수는 입력값이 없기 때문에 Person 객체를 반환하는 기능만 한다. 일반적으로 코드의 가독성을 높이기 위해 사용한다.


apply

fun <T> T.apply(block: T.() -> Unit): T

T의 확장 함수 이므로 apply 함수는 객체를 통해서 호출된다. 또한 block 함수의 입력을 람다 리시버로 받았으므로 block 내에서 this없이 객체에 접근할 수 있다.

run 함수와 비슷하지만, block 함수의 반환값이 없고 객체 자신 T 를 반환하는 점이 다르다.

val person = Person("dd", 30)
val result = person.apply {
    name = "aa"
    age = 10
}

person 객체로부터 apply 함수가 호출되었고, block 함수에서 this 없이 객체의 프로퍼티에 접근했다. apply 함수와 차이는 this를 반환하지 않고도 result에 수정된 객체가 할당되었다는 점이다.

apply 함수는 block 함수 내에서 객체 자체를 수정하고, 반환값이 없기 때문에 객체의 타입 자체를 다른 타입으로 바꾸어 반환할 수 없다. 하지만, 객체만 변경할 때는 반환 값을 명시하지 않아도 되기 때문에 let, with, run 함수보다 사용하기 편리하다


also

fun <T> T.also(block: (T) -> Unit): T

확장함수지만, block 함수의 반환값이 없고 객체 자신인 T를 반환하는 점에서 let 함수와 차이가 있다.

block 함수의 입력을 람다 리시버로 받지않고 this로 받았다는 점에서는 apply 함수 와 다르다. block 함수의 반환값이 없고 객체 자신 T를 반환 하므로 apply 함수와 동일하게 객체 자체를 변경할때만 사용된다.

val person = Person("dd", 30)
val result = person.also {
    it.name = "aa"
    it.age = 10
}

apply, also 함수는 빌더 패턴과 동일한 용도로 사용한다.


use

클로저블 객체(closeable object)는 자원을 사용한 후 닫아줘야 한다.

use는 이를 자동으로 해 주는 함수이며 자바의 try-with-resource와 기능이 동일하다

// java
Properties property = new Properties();
try (FileInputStream stream = new FileInputStrea("config.properties")) {
    property.load(it)
} // FileInputStream이 자동으로 닫힘
val property = Properties()
FileInputStream("config.properties).use {
    property.load(it)
} // FileInputStream이 자동으로 닫힘

깰 - 끔