DHistory

[Kotlin] Kotlin In Action - 03장 함수 정의와 호출 본문

Programming/Kotlin

[Kotlin] Kotlin In Action - 03장 함수 정의와 호출

ddu0422 2021. 9. 12. 18:01

코틀린에서 컬렉션 만들기

val set = hashSetOf(1, 7, 53)
val list = arrayListOf(1, 7, 53)
val map = hashMapOf(1 to "one", 7 to "seven", 53 to "fifty-three")

println(set.javaClass) // class java.util.HashSet
println(list.javaClass) // class java.util.ArrayList
println(map.javaClass) // class java.util.HashMap

코틀린은 자신만의 컬렉션 기능을 제공하지 않는다.

코틀린 컬렉션은 자바 컬렉션과 똑같은 클래스다. 하지만 코틀린에서는 자바보다 더 많은 기능을 쓸 수 있다.

val strings = listOf("first", "second", "fourteenth")
println(strings.last()) // fourteenth

val numbers = setOf(1, 14, 2)
println(numbers.max()) // 14

함수를 호출하기 쉽게 만들기

자바 컬렉션에는 디폴트 toString 구현이 들어있다. 하지만 출력 형식은 고정되어 우리에게 필요한 형식이 아닐 수도 있다.

fun <T> joinToString(
    collectoin: Collection<T>,
    separator: String,
    prefix: String,
    postfix: String
): String {

    val result = StringBuilder(prefix)
    for ((index, element) in collection.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(element)
    }
    result.append(postfix)
    return result.toString()
}

제네릭으로 작성되어 어떤 타입의 값을 원소로 하는 컬렉션이든 처리할 수 있다.

val list = listOf(1, 2, 3)
println(joinToString(list, "; ", "(", ")")) // (1; 2; 3)

이름 붙인 인자

함수 호출 부분의 가독성이 좋지 않다.

함수에 전달하는 인자 중 일부 혹은 전체의 이름을 명시할 수 있다.

호출 시 인자 중 어느 하나라도 이름을 명시하고 나면 혼동을 막기 위해 그 뒤에 오는 모든 인자는 이름을 꼭 명시해야 한다.

이름 붙인 인자는 특히 디폴트 파라미터 값과 함께 사용할 때 쓸모가 많다.

joinToString(collection, " ", " ", ".")
joinToString(collection, separator = " ", prefix = " ", postfix = ".")

디폴트 파라미터 값

코틀린에서는 함수 선언에서 파라미터의 디폴트 값을 지정할 수 있으므로 이런 오버로드 중 상당수를 피할 수 있다. 디폴트 값을 사용해 joinToString 함수를 개선해보자. 대부분의 경우 아무 접두사나 접미사 없이 콤마로 원소를 구분한다. 해당 값을 디폴트로 지정하자.

디폴트 파라미터 값은 함수를 호출하는 쪽이 아니라 함수 선언 쪽에 지정된다.

fun <T> joinToString(
    collectoin: Collection<T>,
    separator: String = ", ",
    prefix: String = "",
    postfix: String = ""
): String {

    val result = StringBuilder(prefix)
    for ((index, element) in collection.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(element)
    }
    result.append(postfix)
    return result.toString()
}

println(joinToString(list, ", ", "", "")) // 1, 2, 3
println(joinToString(list, ", ")) // 1, 2, 3
println(joinToString(list, "; ")) // 1; 2; 3

정적인 유틸리티 클래스 없애기: 최상위 함수와 프로퍼티

코틀린에서는 함수를 클래스 안에 선언할 필요가 전혀 없다.

함수를 직접 소스 파일의 최상위 수준, 모든 다른 클래스의 밖에 위치시키면 된다.

package strings

fun joinToString(...): String { ... }

위와 같이 선언하면, JVM이 클래스 안에 들어있는 코드만을 실행할 수 있기 때문에 컴파일러는 이 파일을 컴파일할 때 새로운 클래스를 정의해준다.

// Java로 변환 시
package strings;

public class JoinKt {
    public static string joinToString(...) { ... }
}

코틀린 컴파일러가 생성하는 클래스의 이름은 최상위 함수가 들어있던 코틀린 소스 파일의 이름과 대응한다. 파일 이름을 별도로 지정하려면 @file:JvmName을 이용한다.

코틀린 파일의 모든 최상위 함수는 이 클래스의 정적인 메서드가 된다.

@file:JvmName("StringFunctions")
package strings

fun joinToString(...): String { ... }
import strings.StringFunctions;

StringFunctions.joinToString(list, ", ", "", "");

최상위 프로퍼티

함수와 마찬가지로 프로퍼티도 파일의 최상위 수준에 놓을 수 있다.

var opCount = 0

fun performOperation() {
    opCount++
    // ...
}

fun reportOperationCount() {
    println("Operation performed $opCount times")
}

정적 필드에 프로퍼티 값을 저장한다. 코드에 상수를 추가할 때 최상위 프로퍼티를 사용한다.

const 변경자를 추가하면 프로퍼티를 public static final 필드로 컴파일하게 만들 수 있다.(단, 원시 타입과 String 타입의 프로퍼티만 const로 지정할 수 있다.)

const val UNIX_LINE_SEPARATOR = "\n"

메서드를 다른 클래스에 추가: 확장 함수와 확장 프로퍼티

확장 함수는 어떤 클래스의 멤버 메서드인 것처럼 호출할 수 있지만 그 클래스의 밖에 선언된 함수다.

확장할 클래스의 이름을 수신 객체 타입(receiver type)이라 부르며, 확장 함수가 호출되는 대상이 되는 값(객체)을 수신 객체(receiver object)라고 부른다.

package strings

// String: 수신 객체 타입
// this: 수신 객체
fun String.lastChar(): Char = this.get(this.length - 1)

println("Kotlin".lastChar()) // n

확장 함수 본문에서 this를 생략할 수 있다.

package strings

fun String.lastChar(): Char = get(length - 1)

println("Kotlin".lastChar()) // n

확장 함수 내부에서는 일반적인 인스턴스 메서드 내부에서와 마찬가지로 수신 객체의 메서드나 프로퍼티를 바로 사용할 수 있다.

확장 함수 내부에서는 클래스 내부에서만 사용할 수 있는 비공개(private 멤버나 보호된(protected) 멤버를 사용할 수 없어서 캡슐화를 깨지 않는다.

임포트와 확장 함수

확장 함수를 사용하기 위해서는 그 함수를 다른 클래스나 함수와 마찬가지로 임포트 해야 한다.

as 키워드를 사용하면 임포트 한 클래스나 함수를 다른 이름으로 부를 수 있다.

import strings.lastChar

println("Kotlin".lastChar()) // n
import strings.lastChar as last

println("Kotlin".last()) // n

일반적인 클래스나 함수라면 그 전체 이름을 써도 된다. 하지만 코틀린 문법상 확장 함수는 반드시 짧은 이름을 써야 한다.

확장 함수로 유틸리티 함수 정의

확장 함수는 단지 정적 메서드 호출에 대한 문법적인 편의일 뿐이다.

클래스가 아닌 더 구체적인 타입을 수신 객체 타입으로 지정할 수도 있다.

문자열의 컬렉션에 대해서만 호출할 수 있는 join 함수를 정의하고 싶다면 다음과 같이 하면 된다.

fun <T> Collection<T>.joinToString(
    separator: String = ", ",
    prefix: String = "",
    postfix: String = ""
): String {

    val result = StringBuilder(prefix)
    for ((index, element) in collection.withIndex()) {
        if (index > 0) result.append(separator)
        result.append(element)
    }
    result.append(postfix)
    return result.toString()
}

fun Collection<String>.join(
    separator: String = ", ",
    prefix: String = "",
    postfix: String = ""
) = joinToString(separator, prefix, postfix)

println(listOf("one", "two", "eight").join(" ")) // one two eight
println(listOf(1, 2, 8).join()) // Error: Type mismatch

확장 함수는 오버라이드 할 수 없다

코틀린의 메서드 오버라이드도 일반적인 객체 지향의 메서드 오버라이드와 마찬가지다.

하지만 확장 함수는 정적 메서드와 같은 특징을 가지므로, 하위 클래스에서 오버라이드 할 수 없다.

open class View {
    open fun click() = println("View clicked")
}

class Button: View() {
    override fun click() = println("Button clicked")
}

val view: View = Button()
view.click() // Button clicked

 

 

확장 함수는 클래스 밖에 선언된다.

확장 함수를 호출할 때 수신 객체로 지정한 변수의 정적 타입에 의해 어떤 확장 함수가 호출될지 결정되지 그 변수에 저장된 객체의 동적인 타입에 의해 확장 함수가 결정되지 않는다.

 

fun View.showOff() = println("I'm a view")
fun Button.showOff() = println("I'm a button")

val view: View = Button()
view.showOff() // I'm a view

어떤 클래스를 확장한 함수와 그 클래스의 멤버 함수의 이름과 시그니처가 같다면 확장 함수가 아니라 멤버 함수가 호출된다(멤버 함수의 우선순위가 더 높다). 클래스의 API를 변경할 경우 항상 이를 염두에 둬야 한다.

확장 프로퍼티

프로퍼티라는 이름으로 불리기는 하지만 상태를 저장할 적절한 방법이 없기 때문에 실제로 확장 프로퍼티는 아무 상태도 가질 수 없다.

하지만 프로퍼티 문법으로 더 짧게 코드를 작성할 수 있어서 편한 경우가 있다.

val String.lastChar: Char
    get() = get(length - 1)

뒷받침하는 필드가 없어서 기본 게터 구현을 제공할 수 없으므로 최소한 게터는 꼭 정의해야 한다.

초기화 코드도 쓸 수 없다.

프로퍼티를 var로 만들 수 있다.

var StringBuilder.lastChar: Char
    get() = get(length - 1)
    set(value: Char) {
        this.setCharAt(length - 1, value)
    }

println("Kotlin".lastChar) // n
val sb = StringBuilder("Kotlin?")
sb.lastChar = '!'
println(sb) // Kotlin!

컬렉션 처리: 가변 길이 인자, 중위 함수 호출, 라이브러리 지원

vararg 키워드를 사용하면 호출 시 인자 개수가 달라질 수 있는 함수를 정의할 수 있다.

중위 함수 호출 구문을 사용하면 인자가 하나뿐인 메서드를 간편하게 호출할 수 있다.

구조 분해 선언을 사용하면 복합적인 값을 분해해서 여러 변수에 나눠 담을 수 있다.

자바 컬렉션 API 확장

val strings: List<String> = listOf("first", "second", "fourteenth")
strings.last() // fourteenth

val numbers: Collection<Int> = setOf(1, 14, 2)
numbers.max() // 14

last와 max는 모두 확장 함수다.

가변 인자 함수: 인자의 개수가 달라질 수 있는 함수 정의

가변 길이 인자는 메서드를 호출할 때 원하는 개수만큼 값을 인자로 넘기면 자바 컴파일러가 배열에 그 값들을 넣어주는 기능이다.

파라미터 앞에 vararg 변경자를 붙인다.

배열에 들어있는 원소를 가변 길이 인자로 넘길 때 배열을 명시적으로 풀어서 배열의 각 원소가
인자로 전달되게 해야 한다. 스프레드 연산자(*)를 이용해야 한다.

fun main(args: Array<String>) {
    val list = listOf("args: ", *args)
    println(list)
}

값의 쌍 다루기: 중외 호출과 구조 분해 선언

맵을 만들려면 mapOf 함수를 사용한다.

val map = mapOf(1 to "one", 7 to "seven", 53 to "fifty-three")

to라는 단어는 키워드가 아닌 중위 호출이다. to라는 일반 메서드를 호출한 것이다.

1.to("one") == 1 to "one"

함수를 중위 호출에 사용하게 허용하고 싶으면 infix 변경자를 함수 선언 앞에 추가해야 한다.

to 함수는 Pair의 인스턴스를 반환한다.

Pair는 코틀린 표준 라이브러리 클래스로, 두 원소로 이뤄진 순서쌍을 표현한다.

val (number, name) = 1 to "one"

이런 기능을 구조 분해 선언이라고 부른다.

루프에서도 구조 분해 선언을 활용할 수 있다.

for ((index, element) in collection.withIndex()) {
    println("$index: $element")
}

문자열과 정규식 다루기

코틀린 문자열은 자바 문자열과 같다.

특별한 변환도 필요 없고 자바 문자열을 감싸는 별도의 래퍼도 생기지 않는다.

일부 메서드에 대해 더 명확한 코틀린 확장 함수를 제공한다.

문자열 나누기

자바의 split 구분 문자열은 실제로는 정규식이다.

"12.345-6.A".split(".") // [12, 345-6, A] (x)
                        // [12.345-6.A] (o)

코틀린에서는 여러 가지 다른 조합의 파라미터를 받는 split 확장 함수를 제공함으로써 혼동을 야기하는 메서드를 감춘다.

정규식을 파라미터로 받는 함수는 String이 아닌 Regex타입의 값을 받는다.

println("12.345-6.A".split("\\.|-".toRegex())) // [12, 345, 6, A]

split 확장 함수를 오버 로딩한 버전 중에는 구분 문자열을 하나 이상 인자로 받는 함수가 있다.

println("12.345-6.A".split(".", "-")) // [12, 345, 6, A]

정규식과 3중 따옴표로 묶은 문자열

전체 경로명을 디렉터리, 파일 이름, 확장자로 구분해보자.

fun parsePath(path: String) {
    val directory = path.substringBeforeLast("/")
    val fullName = path.substringAfterLast("/")
    val fileName = fullName.substringBeforeLast(".")
    val extension = fullName.substringAfterLast(".")

    println("Dir: $directory, name: $fileName, ext: $extension")
}

parsePath("/Users/duholee/kotlin/ch03.md")
// Dir: /Users/duholee/kotlin, name: ch03, ext: md

여러 줄 3중 따옴표 문자열

3중 따옴표 문자열에는 줄 바꿈을 표현하는 문자열은 이스케이프 없이 그대로 들어간다.

val kotlinLogo = """|  //
                   .| //
                   .|/  \"""

println(kotlinLogo.trimMargin("."))
|  //
| //
|/  \

이스케이프를 할 수 없기 때문에 문자열 템플릿의 시작을 표현하는 $를 3중 따옴표 문자열 안에 넣을 수 없는 문제가 생긴다.

코드 다듬기: 로컬 함수와 확장

좋은 코드의 중요한 특징 중 하나가 중복이 없는 것이라고 믿는다. (DRY, Don't Repeat Yourself)
코틀린에서는 함수에서 추출한 함수를 원 함수 내부에 중첩시킬 수 있다.

class User(val id: Int, val name: String, val address: String)

fun saveUser(user: User) {
    if (user.name.isEmpty()) {
        throw IllegalArgumentException("Can't save user ${user.id}: empty Name")
    }

    if (user.address.isEmpty()) {
        throw IllegalArgumentException("Can't save user ${user.id}: empty Address")
    }
}

saveUser(User(1, "", ""))
java.lang.IllegalArgumentException: Can't save user 1: empty Name
class User(val id: Int, val name: String, val address: String)

fun saveUser(user: User) {
    fun validate(user: User, value: String, fieldName: String) {
        if (value.isEmpty()) {
            throw IllegalArgumentException("Can't save user ${user.id}: empty $fieldName")
        }
    }

    validate(user, user.name, "Name")
    validate(user, user.address, "Address")
}

로컬 함수는 자신이 속한 바깥 함수의 모든 파라미터와 변수를 사용할 수 있다.

class User(val id: Int, val name: String, val address: String)

fun saveUser(user: User) {
    fun validate(value: String, fieldName: String) {
        if (value.isEmpty()) {
            throw IllegalArgumentException("Can't save user ${user.id}: empty $fieldName")
        }
    }

    validate(user.name, "Name")
    validate(user.address, "Address")
}

User 클래스를 확장한 함수로 만들 수도 있다.

class User(val id: Int, val name: String, val address: String)

fun User.vaildateBeforeSave() {
    fun validate(user: User, value: String, fieldName: String) {
        if (value.isEmpty()) {
            throw IllegalArgumentException("Can't save user $id: empty $fieldName")
        }
    }

    validate(name, "Name")
    validate(address, "Address")
}

하지만 중첩된 함수의 깊이가 깊어지면 코드 읽기가 상당히 어려워진다.
따라서 일반적으로 한 단계 함수만 중첩하는 걸 권장한다.

요약

  • 코틀린은 자체 컬렉션 클래스를 정의하지 않지만 자바 클래스를 확장해서 풍부한 API를 제공한다.
  • 함수 파라미터의 디폴트 값을 정의하면 오버 로딩한 함수를 정의할 필요성이 줄어든다. 이름 붙인 인자를 사용하면 함수의 인자가 많을 때 함수 호출의 가독성이 향상한다.
  • 코틀린 파일에서 클래스 멤버가 아닌 최상위 함수와 프로퍼티를 직접 선언할 수 있다. 최상위 함수와 프로퍼티를 사용하여 코드 구조를 유연하게 할 수 있다.
  • 확장 함수와 프로퍼티를 사용하면 외부 라이브러리에 정의된 클래스를 포함해 모든 클래스의 API를 바꾸지 않고 확장할 수 있다. 확장 함수를 사용해도 실행 시점에 부가 비용이 들지 않는다.
  • 중외 호출로 인자가 하나밖에 없는 메서드나 확장 함수를 깔끔한 구문으로 만들 수 있다.
  • 코틀린은 정규식과 일반 문자열을 처리할 때 다양한 문자열 처리 함수를 제공한다.
  • 자바 문자열로 표현하려면 많은 이스케이프가 필요한 문자열의 경우 3중 따옴표 문자열을 사용하면 깔끔하게 표현할 수 있다.
  • 로컬 함수를 사용하여 중복을 제거하여 깔끔하게 코드를 작성할 수 있다.