Kotlin In Action 3장

함수 정의와 호출

코틀린에서 컬렉션 만들기

코틀린에서 컬렉션을 만드는 방법은 다음과 같다.

val hashSet = hashSetOf(1,7,53) // HashSet
val list = arrayListOf(1,7,53) // ArrayList
val map = hashMapOf(1 to "one", 7 to "seven" , 53 to "fifty-three") // HashMap

map에서 to는 키워드가 아닌 일반 함수이다.

이에 관해서는 나중에 다룬다고 한다.

코틀린은 자신만의 컬렉션 기능을 제공하지 않고 기존 자바 컬렉션을 활용할 수 있다는 뜻이기도 하다.

그러면 코틀린은 왜 자체 컬렉션을 제공하지 않을까?

이는 표준 자바 컬렉션을 그대로 활용하면서 자바 코드와의 상호운용성을 높이기 위함이다.

그런데 코틀린의 컬렉션과 자바의 컬렉션은 같은 클래스이지만 코틀린에서는 자바보다 더욱 다양한 기능을 사용할 수 있다.

예를 들어 리스트의 마지막 원소를 가져오거나 수로 이루어진 컬렉션에서 최댓값을 찾을 수 있다.

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

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

3장에서는 이런 기능이 어떻게 동작하는지 알려주고 자바 클래스에 없는 메소드를 코틀린이 어디에 정의하는지 살펴본다.

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

val list = listOf(1,2,3)
println(list)
// 출력 : [1,2,3]

자바 컬렉션에서는 디폴트 toString이 구현되어있다.

만약 위의 출력을 [1;2;3;]과 같이 바꾸려면 어떻게 해야할까?

이러한 기능을 하는 함수를 직접 구현해보자.

fun<T> joinToString(
    collection: 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;]

이 함수는 제네릭하며 이대로 사용해도 괜찮다.

하지만 위의 함수를 조금 더 간략하게 만들 수는 없을까?

이제 그 방법을 알아보자.

이름 붙인 인자

val list = listOf(1,2,3)
println(joinToString(list,";", "(" ,")"))
/// 출력 : [1;2;3;]

이 호출 방식은 다소 모호하다.

IDE의 도움을 받아 인자값에 어떤 값을 넣어줄 수 있는지 알 수 있지만, 함수 호출 자체가 모호하다.

코틀린에서는 다음과 같이 인자의 이름을 명시함으로써 가독성을 높일 수 있다.

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

단 주의해야할 점은 하나라도 이름을 명시한 경우 모든 인자는 반드시 이름을 명시해야한다.

디폴트 파라미터

자바에서는 과도한 오버로딩으로 인해 메소드가 너무 많아진다는 문제가 있었다.

그러나 오버로딩은 파라미터 이름과 타입이 계속 반복되며 모든 오버로딩 함수에 설명을 달아야한다.

이를 해결하기 위해 코틀린에서는 디폴트 파라미터라는 값을 제공하는데, 입력 값이 없는 경우 디폴트 값을 사용하는 기능이다.

위의 joinToString 함수를 디폴트 파라미터를 사용해서 개선해보자.

fun<T> joinToString(
    collection: 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

println(joinToString(list)
/// 출력 : 1,2,3

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

디폴트 파라미터를 통해 하나의 함수를 사용하면서 오버로딩과 동일한 기능을 사용할 수 있게 되었다.

이것이 코틀린의 강력한 점 중 하나이며 코드 가독성을 높일 수 있는 아주 좋은 방법이다.

참고로 자바에서 코틀린 코드를 호출하는 경우에는 모든 인자를 명시해야한다.

왜냐하면 자바에는 디폴트 파라미터 값이라는 개념이 없기 때문이다.

만약 자바에서 코틀린 함수를 자주 호출하는 경우라면 @JvmOverloads 애노테이션을 함수에 추가하면 코틀린 컴파일러가 모든 경우에 대한 오버로딩 자바 메소드를 추가해준다.

예를 들어 다음과 같이 말이다.

String joinToString(Collection<T> collection, String seperator, String prefix, String postfix);
String joinToString(Collection<T> collection, String seperator, String prefix);
String joinToString(Collection<T> collection, String seperator);
String joinToString(Collection<T> collection);

그리고 하나 더 중요한 사실은, 코틀린에서는 함수를 클래스 안에 선언할 필요는 없다는 것이다.

함수 외부에 선언하더라도 사용할 수 있다.

fun outFun(){
    println("Hello")
}

class Main{
    fun temp(){
        outFun()
        println("World")
    }
}

fun main() {
    val mainObj = Main()
    mainObj.temp()
}

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

자바에서는 모든 메소드는 클래스에서 작성해야만한다.

그러나 실제로는 특정 객체에게 책임을 위임하기 애매한 경우가 발생하며

이 경우 정적 메소드를 모아두는 클래스를 따로 만든다.

JDK의 Collections가 그 대표적인 예이다.

코틀린에서는 해당 함수를 소스 파일의 최상위에 위치시키면 모든 하위 클래스에서 사용이 가능하다.

만약 joinToString 함수를 strings 패키지에 넣어보자.

다음과 같다.

// 파일 이름 join.kt
package strings
fun joinToString(...): String { ... }

이 함수는 컴파일러가 이 .kt 파일을 컴파일 할 때 파일 이름과 동일한 임의의 클래스를 생성해준다.

join.kt를 컴파일한 결과와 같은 클래스를 자바 코드로 작성해보면 다음과 같다.

package strings;

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

참고로 @JvmName 애노테이션을 사용하여 클래스 이름을 지정하는 것도 가능하다.

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

기존 자바 API를 재작성하지 않으면서 코틀린에서 제공하는 편리한 기능을 사용할 수 있는 방법은 없을까?

이러한 고민을 해결하기 위해 코틀린에서는 확장 함수라는 개념을 도입했다.

개념적으로 확장함수는 단순하다.

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

간단하게 확장함수를 알아보기 위해 문자열의 마지막 문자를 반환하는 메소드를 만들어보자.

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

println("Kotlin.lastChar()")
// 출력 : n

이 예제에서 String은 수신 객체 타입이며 Kotlin이 수신 객체다.

어떤 면에서는 String 클래스에 새로운 메소드를 추가하는 것과 동일하다.

String 클래스는 우리가 작성한 코드도 아니며 심지어 String 클래스의 소스코드를 소유하지 않았지만

원하는 메소드를 String 클래스에 추가하는 것이 가능하다.

심지어 String 클래스가 자바든, 코틀린이든, 그루비와 같은 JVM 언어로 작성된 클래스라면 어떤 클래스도 확장할 수 있다.

확장 함수 내부에서 인스턴스 메소드 내부에서와 마찬가지로 수신 객체의 메소드나 프로퍼티를 접근하고 사용하는 것이 가능하다.

하지만 그렇다고 해서 확장 함수가 캡슐화를 깨지는 않는다는 것을 기억해야한다.

클래스 안에 정의한 메소드와 달리 확장 함수 안에서는 클래스 내부에서만 사용 가능한 private나 protected 멤버를 사용할 수는 없다.

임포트와 확장 함수, 자바에서 확장 함수 호출

확장 함수를 정의했다고 해서 프로젝트 안의 모든 소스코드에서 그 함수를 사용할 수 있는 것은 아니다.

확장 함수를 사용하기 위해서는 임포트해야만 한다.

내부적으로 확장 함수는 수신 객체를 첫 번째 인자로 받는 정적 메소드다.

그래서 확장 함수를 호출해도 다른 어댑터 객체나 실행 시점 부가 비용이 들지 않는다.

자바에서는 확장 함수를 호출하기 위해서는 해당 함수를 정의한 파일 이름으로 접근이 가능하다.

만약 확장 함수를 StringUtil.kt에 정의했다면 자바에서는 다음과 같이 호출할 수 있다.

char c = StringUtilKt.lastChar("Java");

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

확장 함수를 이용해 joinToString 함수의 최종 버전을 만들어보자.

fun<T> Collection<T>.joinToString(
    separator: String = "," ,
    prefix: String = "",
    postfix: String = ""
): String{
    val result = StringBuilder(prefix)
    for( (index, element) in this.withIndex()){
        if(index>0) result.append(separator)
        result.append(element)
    }
    result.append(postfix)
    return result.toString()
}

이 함수는 코틀린 라이브러리가 제공하는 함수와 거의 같아졌다.

val list = listOf(1,2,3)

println(list.joinToString(separator=";" , prefix="(" , postfix=")")
/// 출력 : (1;2;3)

이제 joinToString은 마치 Collection List 클래스의 메소드인 것 처럼 호출할 수 있다.

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

코틀린의 메소드 오버라이딩도 일반적인 객체지향의 메소드 오버라이딩과 같다.

하지만 확장 함수는 오버라이딩할 수 없다.

그 이유는 조금만 생각해보면 간단한데, 확장 함수는 정적 메소드이기 때문에 메소드 오버라이딩 처럼 런타임에 호출될 메소드가 결정되지 않는다.

너무나 당연한 사실이다.

참고로 어떤 클래스의 확장 함수와 그 클래스의 기존 멤버 함수 이름과 시그니처가 같다면 확장 함수가 아닌 멤버 함수가 호출된다. (멤버 함수의 우선순위가 더 높음)

확장 프로퍼티

확장 프로퍼티를 사용하면 기존 클래스 객체에 대한 프로퍼티 형식의 구문으로 사용할 수 있는 API를 추가할 수 있다.

기존 클래스 인스턴스 객체에 필드를 추가할 방법은 없으므로 실제 확장 프로퍼티는 아무 상태도 가질 수 없다.

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

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

컬렉션 처리

이번에는 코틀린에서 컬렉션 처리할 때 쓸 수 있는 코틀린 표준 라이브러리 함수를 몇가지 공부해보자.

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

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

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

가변 인자 함수

리스트를 생성하는 함수를 호출할 때 원하는 만큼 원소를 많이 전달할 수 있다.

val list = listOf(1,2,3,4,5,6)

코틀린 라이브러리에서 이 함수의 정의는 다음과 같다.

fun listOf<T>(vararg values:T): List<T> { ... }

자바에서는 를 사용해 표현했다면 코틀린에서는 vararg 변경자를 붙인다.

또한 배열에 이미 들어있는 원소를 가변 길이 인자로 넘길 때도 코틀린과 자바는 다르다.

코틀린에서는 배열을 명시적으로 풀어서 배열의 각 원소가 인자로 전달하게 해야 한다.

이러한 기술을 Spred 연산자라고 부르며 사용법은 간단히 배열 앞에 *를 붙이기만 하면 된다.

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

스프레드 연산자를 통해 배열의 값과 다른 여러 값을 함께 인자로 넘겨 함수를 호출할 수 있다.

이런 기능은 자바에서는 불가능하다.

중이 호출과 구조 분해 선언

map은 다음과 같이 선언과 초기화를 할 수 있다.

val map = mapOf(1 to "one", 2 to "two", 53 to "fifty-three")

앞서 to라는 단어는 코틀린 키워드가 아니라고 했다.

이 코드는 중위 호출이라는 특별한 방식으로 to라는 일반 메소드를 호출한 것이다.

중위 호출 시에는 수신 객체와 유일한 메소드 인자 사이에 메소드 이름을 넣는다.

따라서 두 호출은 동일하다.

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

인자가 하나뿐인 일반 메소드나 인자가 하나뿐인 확장 함수에 중위 호출을 사용할 수 있다.

to 함수는 Pair의 인스턴스를 반환하는데 Pair는 코틀린 표준 라이브러리 클래스이다. (C++의 그것을 생각하면 될 듯)

Pair의 사용법은 다음과 같다.

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

위에서는 Pair의 내용인 두 변수를 즉시 초기화하였다.

이러한 기능을 Destructuring Declaration (구조 분해 선언)이라고 부른다.

Pair 외에도 다른 객체에도 이를 적용할 수 있다.

가령 key,value로 이루어진 맵이 그러하다.

withIndex를 Destructuring Declaration과 조합하면 Collection의 인덱스와 값을 따로 변수에 담을 수 있다.


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

to 함수는 확장 함수다. to를 사용하면 타입과 관계없이 임의의 순서쌍을 만들 수 있다.

문자열과 정규식 다루기

자바에서 정규식은 점을 사용해 문자열을 분리할 수 없다.

많은 개발자들이 “12.345-6.A”.split(“.”)의 결과가 파이썬과 마찬가지로

[12,345-6,A]이길 기대하지만 현실은 빈 배열을 반환한다.

왜냐하면 split의 구분 문자열은 실제로는 정규식이기 때문이다.

.은 모든 문자를 나타내는 정규식으로 해석된다.

코틀린에서는 자바의 split 대신 여러 가지 다른 조합의 파라미터를 받는 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]

이 경우 ‘.’, ‘-‘ 처럼 문자열 대신 문자를 인자로 넘겨도 같은 결과를 볼 수 있다.

이렇게 여러 문자를 받을 수 있는 코틀린 확장 함수는 자바에 있는 단 하나의 문자만 받을 수 있는 메소드를 대신한다.

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

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"
        )
    }

    // user를 DB에 저장
}

위의 코드에서 검증을 하는 로직이 반복되고 있다.

이를 로컬 함수를 이용해 개선할 수 있는데 다음과 같다.

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")

    // user를 DB에 저장
}

로컬 함수에서는 자신이 속한 바깥 함수의 모든 파라미터와 변수를 사용할 수 있기 때문에 불필요한 User 파라미터를 없애보자.

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,user.name,"Name")
    validate(user,user.address,"Address")

    // user를 DB에 저장
}

더욱 더 개선하고 싶다면 검증 로직을 User 클래스를 확장한 함수로 만들 수 있다.

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

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

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


}

fun saveUser(user:User){
    user.validateBeforeSave()
    // user를 DB에 저장
}

훨씬 더 개선되고 발전된 코드임을 알 수 있다.

요약

  • 코틀린은 자체 컬렉션 클래스를 정의하는 대신 자바 클래스를 확장해서 더욱 풍부한 API를 제공한다.

  • 함수 파라미터의 디폴트 값을 정의하면 오버로딩한 함수를 정의할 필요성이 줄어들고 이는 코드의 간결성과 가독성을 높일 수 있다.

  • 코틀린 파일에서 클래스 멤버가 아닌 최상위 함수와 프로퍼티를 직접 선언할 수 있다.

  • 확장 함수와 프로퍼티를 사용하면 외부 라이브러리에 정의된 클래스를 포함해 모든 클래스의 API를 소스코드 변경 없이 확장 가능하다.

  • 중위 호출을 사용하면 더욱 더 깔끔한 구문으로 함수를 호출할 수 있다. 단 함수의 인자가 하나 밖에 없어야 한다.

  • 코틀린은 정규식과 일반 문자열을 처리할 때 유용한 기능을 추가로 제공한다.

  • 문자열 삼중 따옴표를 사용해서 더욱 편리한 표현이 가능하다.

  • 로컬 함수를 사용해서 코드를 더 깔끔하게 유지하면서 중복을 제거할 수 있다.

Reference

Kotlin In Action (드미트리 제메로프, 스베트라나 이사코바)



© 2022. by minkuk

Powered by minkuk