아토믹 코틀린 3부 사용성

smpl published on
7 min, 1218 words

Tags: kotlin

아토믹 코틀린 책의 3부 사용성 챕터를 읽고, 읽은 내용을 정리해봅니다. 공부하면서 개인적으로 다른 언어와 달랐던 점, 인상깊었던 점 혹은 기억해야할 점들만 적었습니다.

확장 함수

확장함수(Extension Function)은 수신 객체 타입(Receiver Type)에 멤버함수를 추가하는 효과를 준다. 확장 함수는 수신 객체의 public 원소에만 접근할 수 있다.

// 형식
fun 수신타입.확장함수() { ... }

// ex
fun String.singleQuote() = "'$this'"

// 다른 패키지에서 사용하려면 임포트해야한다.
import mystringext.singleQuote

"Single".singleQuote() // 'Single'

이름 붙은 인자와 디폴트 인자

함수를 호출하면서 인자의 이름을 지정할 수 있는데, 가독성에 도움이 된다. 이름붙은 인자와 일반인자(위치기반)를 섞어 쓸 수 있는데, 만일 이름붙은 인자를 이용해 순서를 바꿨다면, 인자 목록의 나머지 부분에서도 인자에 이름을 붙여야 한다.

디폴트 인자와도 결합하면 더 유용하다.

class Color(
    val red: Int = 0,
    val green: Int = 0,
    val blue: Int = 0,
) {
    override fun toString() = "($red, $green, $blue)"
}

주의 필요한 것이, 객체 인스턴스를 디폴트 인자로 전달하는 경우 함수를 호출할 때마다 같은 인스턴스가 반복해서 전달된다. 디폴트 인자로 함수 호출이나 생성자 호출 등을 사용하는 경우는 함수를 호출할 때마다 디폴트 인자로 전달된 함수나 생성자가 호출되어 새 인스턴스가 전달된다.

class DefaultArg
val default = DefaultArg()

fun f1(d: DefaultArg = default) = println(d) // 매번 같은 인스턴스
fun f2(d: DefaultArg = default) = println(d) // 매번 같은 인스턴스, f1과도 같은 인스턴스

fun f3(d: DefaultArg = DefaultArg()) = println(d) // 매번 새로운 인스턴스 생성

오버로딩

함수의 시그니처는 함수 이름, 파라미터 목록, 반환 타입으로 이루어지는데, 다만 반환 타입은 오버로딩의 대상이 아니다. 함수 시그니처는 함수를 둘러싸고 있는 클래스(확장함수의 경우 수신 객체 타입)도 포함한다.

동일한 시그니처를 갖는 멤버 함수와 확장 함수가 있을 경우, 멤버 함수는 확장 함수를 가린다. (확장 함수는 불릴 수 없다.) 대신 동일한 이름으로 오버로딩을 해서 확장함수를 만들면 가능하다.

함수 오버로딩과 디폴트 인자를 함께 사용하는 경우, 오버로딩한 함수를 호출하면 시그니처와 함수 호출이 '가장 가깝게' 일치하는 함수를 호출한다.

when 식

when 식을 계산할 때는 비교 대상 값과 각 매치의 화살표 왼쪽 값을 순서대로 비교한다. when은 가능한 모든 경우를 처리해야 하므로, 가능한 모든 경우를 처리하기 위해 else 가지를 추가해야 할 수도 있다.

val ten = 10

fun ordinal(i: Int): String = 
    when (i)  {
        1 -> "one"
        2 -> "two"
        3 -> "three"
        4, 5 -> "four or five" // 여러 값 나열 가능
        6 -> {
            println("6")
            6
        }
        //7 -> {} // 아무 일도 하지 않음
        ten -> "ten" // 꼭 상수가 아니어도 된다.
        -1 -> return "minus one" // 바로 함수를 리턴하는 것도 가능
        else -> "else"
    }

// Set과 Set을 매치시킬 수 있음
fun mixColor(first: String, second: String) =
    when (setOf(first, second)) {
        setOf("red", "blue") -> "purple"
        setOf("red", "yellow") -> "orange"
        setOf("blue", "yellow") -> "green"
        else -> "unknown"
    }

// 인자가 없는 when은 화살표 왼쪽의 Boolean 식을 순서대로 검사한다.
when {
    a < 100 -> "<100"
    a < 200 -> "<200"
    else -> ">=200"
}

이넘

enum class Severity { // enum은 자동으로 toString()이 생성된다.
    Debug, Info, Warn, Error
}

// 같은 패키지
Severity.Debug // 한정 필요

import myenums.Severity.* // 이넘 값을 모두 현재 네임스페이스로 임포트해옴. 같은 패키지 내에서도 가능하며, 이넘이 선언되기 앞쪽에서도 가능하다.
Debug // 이제 한정 불필요

Severity.values() // 이넘의 값을 이터레이션 가능. Array를 반환.
Debug.ordinal // 0. 이넘의 첫 값부터 0에서 1씩 순차증가하는 ordinal 값이 매겨진다.

이넘은 인스턴스 개수가 정해져 있고 클래스 본문 안에 모든 인스턴스가 나열되어 있는 특별한 종류의 클래스이다. 이 점을 제외하면 멤버함수나 멤버프로퍼티를 정의할 수 있는 등, 일반 클래스와 똑같이 동작한다. 다만, 마지막 이넘 값 다음에 세미콜론을 추가한 후 정의를 포함해야 한다.

enum class Color(val red: Int, val green: Int, val blue: Int) {
    Red(255, 0, 0), Green(0, 255, 0), Blue(0, 0, 255), 
    Cyan(0, 255, 255), Magenta(255, 0, 255), Yellow(255, 255, 0),
    White(255, 255, 255), Black(0, 0, 0);

    val complementary: Color
        get() = when(this) {
            Red -> Cyan
            Green -> Magenta
            Blue -> Yellow
            White -> Black
            Cyan -> Red
            Magenta -> Green
            Yellow -> Blue
            Black -> White
        }
}

구조 분해 선언

구조분해(destructuring) 선언을 하면 여러 식별자를 동시에 선언하면서 초기화할 수 있다.

fun abs(input: Int): Pair<Int, String> = 
    if (input >= 0) {
        Pair(input, "positive")
    } else {
        Pair(-input, "negative")
    }

val (a, b) = abs(100)
val res = abs(-100)
val (c, d) = (res.first, res.second)

// 데이터 클래스는 자동으로 구조 분해 선언을 지원한다.
data class Color(
    val red: Int, 
    val green: Int,
    val blue: Int,
    val name: String,
)

val black = Color(0, 0, 0, "black")
val (r, g, b, name) = black
val (_, _, _, colorName) = black // _를 이용해 name만 얻을 수도 있고
val (cr, cg, cb) = black // 뒷쪽의 변수를 생략해서 name을 이렇게 생략할 수도 있다.

// map이나 list of pair 혹은 list를 인덱스와 함께 이터레이션하면서 구조분해를 할 수도 있다.
for ((key, value) in someMap) { ... }
for ((first, second) in listOfPairs) { ... }
for ((index, value) in list.withIndex()) { ... }

널이 될 수 있는 타입

코틀린에서 모든 타입은 기본적으로 널이 될 수 없는 타입이지만, ?를 붙여서 널이 될 수도 있음을 표시할 수 있다.

?를 붙인 타입은 원본 타입과 다른 타입이다. 예를 들어 String과 String?는 서로 다른 타입이다.

nullable 타입은 단순히 dereference할 수 없다. 코틀린은 명시적으로 null인지 검사를 하고 나야 객체를 참조할 수 있도록 허락해준다.

val s: String? = "abc"
if (s != null)
    println(s.length)

안전한 호출과 엘비스 연산자

안전한 호출(safe call)은 nullable type의 멤버에 접근하는 안전한 방법을 제공하며, 객체가 null이 아닐 때만 멤버에 접근하도록 한다.

엘비스(elvis) 연산자는 객체가 null이면 오른쪽 식의 값을 준다. null coalescing operator라고도 한다.

val s1: String? = "1234"
println(s1?.length) // safe call. 길이
val s2: String? = null
println(s2?.length) // null

println(s1 ?: "(null)") // elvis operator. 1234
println(s2 ?: "(null)") // (null)

class Node {
    val name: String,
    var next: Node? = null
}
val head = Node("head")
head.next?.next?.name // 연쇄적으로도 호출할 수 있다.
head.next?.next?.name ?: "next??" // 엘비스 연산자까지 결합 가능.

널 아님 단언

널 아님 단언(non-null assertion)은 객체가 null인 경우 NullPointerException을 던지게 한다.

val s: String = "abc"
s!!.length

널 아님 단언 대신 안전한 호출이나 명시적인 null 검사를 활용하는 것이 좀더 권장된다. 널 아님 단언은 자바와의 상호 작용을 위해 도입된 것이다.

확장 함수와 널이 될 수 있는 타입

String의 확장함수들

  • isNullOrEmpty
  • isNullOrBlank
// nullable 타입에 확장 함수 구현
fun String?.isNullOrEmpty(): Boolean =
    this == null || isEmpty()

널이 될 수 있는 타입에 확장함수를 구현하는 것보다는, 널이 될 수 없는 타입에 구현하는 것이 낫다. 안전한 호출과 명시적인 검사는 수신 객체의 널 가능성을 더 명백히 드러내는 반면, 널이 될 수 있는 타입의 확장함수는 널 간으성을 감추고 코드를 읽는 독자를 혼란스럽게 할 수 있다.

제네릭스 소개

class GenericHolder<T>( // T는 generic place holder라 부름.
    private val value: T
) {
    fun getValue(): T = value
}

val genericHolder = GenericHolder(Dog())
val dog = genericHolder.getValue()

// 각 타입 간에 공통점이 없으면 Any를 고려해봐도...
class AnyHolder(private val value: Any) {
    fun getValue(): Any = value
}

// 제네릭 함수
fun <T> identity(arg: T): T = arg
identity("yellow")
identity(100)

// 제네릭 확장 함수
fun <T> List<T>.first(): T {
    if (isEmpty())
        throw NoSuchElementException("Empty List")
    return this[0]
}

확장 프로퍼티

// 확장 프로퍼티 형식
fun ReceiverType.extensionFunction() { ... }
val ReceiverType.extensionProperty(): PropType
    get() { ... }

// 예시
val String.indices: IntRange
    get() = 0 until length

// 제네릭 확장 프로퍼티
val <T> List<T>.firstOrNull: T?
    get() = if (isEmpty()) null else this[0]

// star projection
val List<*>.indices: IntRange
    get() = 0 until size

파라미터가 없는 확장 함수는 항상 확장 프로퍼티로 변환 가능성이 있지만, 먼저 그래도 생각해보는 것이 좋다. 기능이 단순하고 가독성을 향상시키는 경우에만 프로퍼티를 권한다.

코틀린 스타일 가이드는 함수가 예외를 던질 경우 프로퍼티보다는 함수를 사용하는 것을 권장한다.

제네릭 인자 타입을 사용하지 않으면 로 대체할 수 있다. 이를 스타 프로젝션(star projection)이라고 한다. List<>를 사용하면 List에 담긴 원소의 타입정보는 모두 잃어버리고, 원소를 얻었을 때는 Any?에만 대입할 수 있다.

break와 continue

// 레이블 용법
val strings = mutableListOf<String>()
outer@ for (c in 'a'..'f') {
    for (i in 1..9) {
        if (i == 5) continue@outer
        if ("$c$i == "c3") break@outer
        strings.add("$c$i)
    }
}

break나 continue 대신 이터레이션 조건을 명시적으로 작성하는 것이 더 좋은 해법이다.