[Kotlin in Action] 3. 함수 정의와 호출

akim·2022년 11월 4일
0

Kotlin in Action

목록 보기
4/12
post-thumbnail

코틀린에서 컬렉션 만들기

val set = hashSetOf(1,2,3) // 집합
val list = arrayListOf(1,7,53) //리스트
val map = hashMapOf(1 to "one", 7 to "seven", 53 to "fifty-three") // 맵
//여기서 to는 키워드가 아닌 함수

위 코드를 보면 Java와 상당히 유사해보이는데, 그럴만한 이유가 있다.

Kotlin은 자체 컬렉션 기능을 제공하지 않는다.

표준 Java 컬렉션을 활용하면 Java 코드와 상호작용하기가 훨씬 더 쉽기 때문이다.
Java에서 Kotlin 함수를 호출하거나 Kotlin에서 Java 함수를 호출할 때 서로 컬렉션을 변환할 필요가 없는 것이다.

Kotlin 컬렉션은 Java 컬렉션과 똑같은 클래스다. 하지만 Kotlin에서는 더 많은 기능을 쓸 수 있다. 지금부터 어떠한 기능이 어떻게 동작하는지, Java 클래스에 없는 메서드는 어디에 정의하는지 등에 대해 알아보겠다.


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

Java 컬렉션에는 디폴트 toString 구현이 들어있다. 그러나 이 디폴트 toString의 출력 형식은 고정되어 있고, 우리에게 필요한 형식이 아닐 수도 있다.

그렇다면 디폴트 구현과 달리 우리가 원하는 형식으로 처리하려면 어떻게 해야할까? Kotlin에는 이런 요구 사항을 처리할 수 있는 함수가 표준 라이브러리에 이미 들어있다. (!)
이번 절에서는 그런 함수들에 대해 알아보도록 하겠다.

1. 이름 붙인 인자

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

Kotlin으로 작성한 함수를 호출할 때는 위와 같이 함수에 전달하는 인자 중 일부 또는 전부의 이름을 명시할 수 있다.
호출 시 인자 중 어느 하나라도 이름을 명시하고 나면 혼동을 막기 위해 그 뒤에 오는 모든 인자는 이름을꼭 명시해야 한다.

2. 디폴트 파라미터 값

Java의 경우 일부 클래스에서 오버로딩한 메서드가 너무 많아진다는 문제가 있다. 그러나 Kotlin에서는 함수 선언에서 파라미터의 디폴트 값을 지정할 수 있으므로 오버로드 중 상당수를 피할 수 있다.

fun <T> joinToString(
	collection: Collection<T>,
    separoator: String = ",",
    prefix: String = "",
    postfix: String = ""
): String

이제 함수를 호출할 때 모든 인자를 쓸 수도 있고, 일부를 생략할 수도 있게 되었다.

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

객체지향 언어인 Java에서는 모든 코드를 클래스의 메서드로 작성해야 한다.
그러나 실전에서는

  • 어느 한 클래스에 포함시키기 어려운 코드가 많이 생긴다.
  • 일부 연산에는 비슷하게 중요한 역할을 하는 클래스가 둘 이상 있을 수도 있다.
  • 중요한 객체는 하나뿐이지만 그 연산을 객체의 인스턴스 API에 추가해서 API를 너무 크게 만들고 싶지는 않은 경우도 있다.

그 결과 다양한 정적 메서드를 모아두는 역할만 담당하며, 특별한 상태나 인스턴스 메서드는 없는 클래스가 생겨난다. 그러나 Kotlin에서는 이런 무의미한 클래스가 필요 없다.

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

또한 함수와 마찬가지로 프로퍼티도 파일의 최상위 수준에 놓을 수 있다. 이런 프로퍼티의 값은 정적 필드에 저장된다. 최상위 프로퍼티를 활용해 코드에 상수를 추가할 수 있다.

기본적으로 최상위 프로퍼티도 다른 모든 프로퍼티처럼 접근자 메서드를 통해 코드에 노출된다. 겉으로는 상수처럼 보이는데, 실제로는 게터를 사용해야 한다면 자연스럽지 못하다.
자연스럽게 사용하려면 상수를 public static final 필드로 컴파일해야 한다.

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


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

기존 Java API를 재작성하지 않고도 Kotlin이 제공하는 여러 편리한 기능을 사용할 수 있다면 편리할 것이다.
그리고 Kotlin의 확장 함수가 바로 그런 역할을 해준다.

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

확장 함수를 만들려면 추가하려는 함수 이름 앞에 그 함수가 확장할 클래스의 이름을 덧붙이기만 하면 된다.

  • 수신 객체 타입(receiver type): 함수가 확장할 클래스의 이름
  • 수신 객체(receiver object): 확장 함수가 호출이 되는 값(객체)
package strings
fun String.lastChar(): Char = this.get(this.length-1)

println("Kotlin".lastChar())

위 코드에서는 String이 수신 객체 타입이고 kotlin이 수신 객체다.

어떤 면에서 이는 String 클래스에 새로운 메서드를 추가하는 것과 같다. 비록 이 클래스가 직접 작성한 코드도 아니고 그 클래스의 소스코드를 소유한 것도 아니지만, 원하는 메서드를 이 클래스에 추가할 수 있는 것이다.

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

1. 임포트와 확장 함수

확장 함수를 사용하기 위해서는 그 함수를 다른 클래스나 함수와 마찬가지로 임포트해야만 한다. 확장 함수를 정의하자마자 어디서든 그 함수를 쓸 수 있다면 한 클래스에 같은 이름의 확장 함수가 둘 이상 있어서 이름이 충돌하는 경우가 자주 생길 수 있다.

Kotlin에서는 클래스를 임포트할 때와 동일한 구문을 사용해 개별 함수를 임포트할 수 있다.

import strings.lastChar
import strings.*
import strings.lastChar as last // as 키워드를 사용해 다른 이름으로 부른다.

한 파일 안에서 다른 여러 패키지에 속해있는 이름이 같은 함수를 가져와 사용해야 하는 경우 이름을 바꿔서 임포트하면 이름 충돌을 막을 수 있다. 물론 일반적인 클래스나 함수라면 그 전체 이름을 써도 된다.

하지만 Kotlin 문법상 확장 함수는 반드시 짧은 이름을 써야 한다. 따라서 이름을 바꾸는 것이 확장 함수 이름 충돌을 해결할 수 있는 유일한 방법이다.

2. 자바에서 확장 함수 호출

내부적으로 확장 함수는 수신 객체를 첫 번째 인자로 받는 정적 메서드다. 그래서 확장 함수를 호출해도 다른 어댑터 객체나 실행 시점 부가 비용이 듣지 않는다.

이런 설계로 인해 자바에서 확장 함수를 사용하기도 편하다. 단지 정적 메서드를 호출하면서 첫 번째 인자로 수신 객체를 넘기기만 하면 된다.

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

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

fun <T> Collection<T>.joinToString( //Collection<T>에 대한 확장 함수 선언
	// 파라미터의 디폴트 값을 지정
	separator: String = ", ",
	prefix: String = "",
	postfix: String = ""
): String {
	val result = StringBuilder(prefix)

    for ((index, element) in this.withIndex()) { //this는 수신 객체. 여기서는 T 타입의 원소로 이루어진 컬렉션
        if (index > 0) result.append(separator)
        result.append(element)
    }

    result.append(postfix)
    return result.toString()
}

fun main(args: Array<String>) {
    val list = arrayListOf(1, 2, 3)
    println(list.joinToString(" "))
}

원소로 이뤄진 컬렉션에 대한 확장을 만들고, 모든 인자에 대한 디폴트 값을 지정하였다.
이제 joinToString을 마치 클래스의 멤버인 것처럼 호출할 수 있다.

확장 함수는 단지 정적 메서드 호출에 대한 문법적인 편의일 뿐이다. 그래서 클래스가 아닌 더 구체적인 타입을 수신 객체 타입으로 지정할 수도 있다.

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

kotlin의 메서드 오버라이드도 일반적인 객체지향의 메서드 오버라이드와 마찬가지다. 확장 함수가 정적 메서드와 같은 특징을 가지므로, 확장 함수를 하위 클래스에서 오버라이드할 수는 없다.

확장 함수는 클래스의 일부가 아니다.

확장 함수는 클래스 밖에 선언된다.
이름과 파라미터가 완전히 같은 확장 함수를 기반 클래스와 하위 클래스에 대해 정의해도 실제로는 확장 함수를 호출할 때 수신 객체로 지정한 변수의 정적 타입에 의해 어떤 확장 함수가 호출될지 결정되지, 그 변수에 저장된 객체의 동적인 타입에 의해 확장 함수가 결정되지 않는다.

5. 확장 프로퍼티

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

프로퍼티라는 이름으로 불리기는 하지만 상태를 저장할 적절한 방법이 없기 때문에 실제로 확장 프로퍼티는 아무 상태도 가질 수 없다. 하지만 프로퍼티 문법으로 더 짧게 코드를 작성할 수 있어서 편한 경우가 있다.

앞에서 쓴 lastChar라는 함수를 프로퍼티로 바꿔보자.

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

확장 프로퍼티도 일반적인 프로퍼티와 같다. 단지 수신 객체 클래스가 추가됐을 뿐이다.
또한 아래 두 가지 사항을 고려해야 한다.

  • 뒷받침하는 필드가 없어서 기본 게터 구현을 제공할 수 없으므로 최소한 게터는 꼭 정의를 해야 한다.
  • 마찬가지로 초기화 코드에서 계산한 값을 담을 장소가 전혀 없으므로 초기화 코드도 쓸 수 없다.

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

이 절에서 다룰 3가지 내용은 아래와 같다.

  • vararg 키워드를 사용하면 호출 시 인자 개수가 달라질 수 있는 함수를 정의할 수 있다
  • 중위 함수 호출 구문을 사용하면 인자가 하나뿐인 메서드를 간편하게 호출할 수 있다.
  • 구조 분해 선언을 사용하면 복합적인 값을 분해하여 여러 변수에 나눠 담을 수 있다.

1. Java 컬렉션 API 확장

맨 앞 부분에서 Kotlin 컬렉션은 Java와 같은 클래스를 사용하지만 더 확장된 API를 제공한다고 했다.
Kotlin 표준 라이브러리는 수많은 확장 함수를 포함한다. 그리고 이 모든 라이브러리의 기능은 다 외우고 있을 필요가 없다.

컬랙션이나 다른 객체에 대해 사용할 수 있는 메서드나 함수가 무엇인지 궁금할 때마다 IDE의 코드 완성 기능을 통해 그런 메서드나 함수를 살펴볼 수 있다.
(실제로 프로그래밍하며 가장 많이 쓰고 가장 유용하다고 느끼는 기능이다.)

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

앞서 컬렉션을 만들어내는 함수를 몇 가지 살펴봤다. 그런 함수가 모두 가진 특징은 바로 인자의 개수가 그때그때 달라질 수 있다는 점이다. 이렇게 파라미터 개수가 달라질 수 있는 함수를 정의하는 방법에 대해 살펴보자.
가변 길이 인자는 메서드를 호출할 때 원하는 개수만큼 값을 인자로 넘기면 자바 컴파일러가 배열에 그 값들을 넣어주는 기능이다.

Kotlin의 가변 길이 인자는 Java와 비슷하다. 다만 문법이 조금 다르다.

public fun <T> listOf(vararg elements: T): List<T> = if (elements.size > 0) elements.asList() else emptyList()

fun main(args: Array<String>) {
    val list = listOf("one", "two", "eight")
}

타입 뒤에 ... 를 붙이는 대신 Kotlin에서는 파라미터 앞에 varage 변경자를 붙인다.

이미 배열에 들어있는 원소를 가변 길이 인자로 넘길 때도 Kotlin과 Java 구문이 다르다. Java에서는 배열을 그냥 넘기면 되지만 Kotlin에서는 배열을 명시적으로 풀어서 배열의 각 원소가 인자로 전달되게 해야 한다.

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

기술적으로는 스프레드 연산자가 그런 작업을 해준다. 하지만 실제로는 전달하려는 배열 앞에 *를 붙이기만 하면 된다.

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

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

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

앞에서도 잠깐 언급한 내용이지만, 여기서 to는 Kotlin 키워드가 아니다. 이 코드는 중위 호출이라는 특별한 방식으로 to라는 일반 메서드를 호출한 것이다.

중위 호출시에는 수신 객체와 유일한 메서드 인자 사이에 메서드 이름을 넣는다.
이때 객체, 메서드 이름, 유일한 인자 사이에는 공백이 들어가야 한다.

1.to("one") // "to" 메소드를 일반적인 방식으로 호출
1 to "one" // "to" 메소드를 중위 호출 방식으로 호출

to 함수는 Pair의 인스턴스를 반환한다.
Pair는 Kotlin 표준 라이브러리 클래스로, 이름 그대로 두 원소로 이뤄진 순서쌍을 표현한다.

Pair의 내용으로 두 변수를 즉시 초기화할 수 있다.

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

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

to 함수는 확장 함수다. to를 사용하면 타입과 관계없이 임의의 순서쌍을 만들 수 있다. 이는 to의 수신 객체가 제네릭하다는 뜻이다.


문자열과 정규식 다루기

Kotlin 문자열은 Java 문자열과 같은데, 다양한 확장 함수를 제공함으로써

  • 표준 Java 문자열을 더 쉽게 다룰 수 있게 하고
  • 혼동이 야기될 수 있는 일부 메서드에 대해 더 명확한 Kotlin 확장 함수를 제공함으로써 프로그래머의 실수를 줄여준다.

1. 문자열 나누기

Java의 경우 split 메서드로는 .을 사용해 문자열을 분리할 수 없다.

"12.345-6.A".split(".")

위 호출에 대한 결과는 [12, 345-6, A]이 아니다.
Java의 split 메서드는 빈 배열을 반환하게 된다. split의 구분 문자열은 사실 정규식이기 때문이다. 따라서 .는 모든 문자를 나타내는 정규식으로 해석된다.

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

정규식을 파라미터로 받는 함수는 String이 아닌 Regex 타입의 값을 받는다. 따라서 Kotlin에서는 split 함수에 전달하는 값의 타입에 따라 정규식이나 일반 텍스트 중 어느 것으로 문자열을 분리하는지 쉽게 알 수 있다.

그중에서도 간단한 경우에는 꼭 정규식을 쓸 필요가 없다. split 확장 함수를 오버로딩한 버전 중에는 구분 문자열을 하나 이상 인자로 받는 함수가 있다.

println("12.345-6.A".split(".","-")) // 여러 구분 문자열을 지정한다. 
[12, 345, 6, A]

이렇게 여러 문자를 받을 수 있는 Kotlin 확장 함수는 Java에 있는 단 하나의 문자만 받을 수 있는 메서드를 대신한다.

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

Kotlin에서는 정규식을 사용하지 않고도 문자열을 쉽게 파싱할 수 있다.
정규식은 강력하기는 하지만 나중에 알아보기 힘든 경우가 많다. 정규식이 필요할 때는 Kotlin 라이브러리를 사용하면 더 편하다.

fun parsePath(path: String) {
    val regex = """(.+)/(.+)\\.(.+)""".toRegex()
    val matchResult = regex.matchEntire(path)
    if (matchResult != null) {
        val (directory, filename, extension) = matchResult.destructured
        println("Dir: $directory, name: $filename, ext: $extension")
    }
}

위 예시에서는 3중 따옴표 문자열을 사용해 정규식을 썼다.

3중 따옴표 문자열에서는 \를 포함한 어떤 문자도 이스케이프할 필요가 없다.

위 예시의 parsePath 함수 구현은 아래와 같은 방법으로 구현되었다.
1. 우선 정규식을 만든다.
2. 그 정규식을 인자로 받은 path에 매치시킨다.
3. 매치에 성공하면(결과가 null이 아니면) 그룹별로 분해한 매치 결과를 의미하는 destructured 프로퍼티를 각 변수에 대입한다.

3. 여러 줄 3중 따옴표 문자열

3중 따옴표 문자열을 문자열 이스케이프를 피하기 위해서만 사용하지는 않는다. 3중 따옴표 문자열에는 줄 바꿈을 표현하는 아무 문자열이나 그대로 들어간다. 따라서 3중 따옴표를 쓰면 줄 바꿈이 들어있는 프로그램 텍스트를 쉽게 문자열로 만들 수 있다.

여러 줄 문자열을 코드에서 더 보기 좋게 표현하고 싶다면 들여쓰기를 하되 들여쓰기의 끝부분을 특별한 문자열로 표시하고, trimMargin을 사용해 그 문자열과 그 직전의 공백을 제거한다.

  • 여러 줄 문자열에는 들여쓰기나 줄 바꿈을 포함한 모든 문자가 들어간다.
  • 여러 줄 문자열에는 줄 바꿈이 들어가지만 줄 바꿈을 \n과 같은 특수 문자를 사용해 넣을 수는 없다.
  • 3중 따옴표 문자열 안에 문자열 템플릿을 사용할 수도 있다.

프로그래밍 시 여러 줄 문자열이 요긴한 분야로는 테스트를 꼽을 수 있다.
테스트에서는 여러 줄의 텍스트 출력을 만들어내는 연산을 실행하고 그 결과를 예상 결과와 비교해야 하는 경우가 자주 있다. 여러 줄 문자열은 테스트의 예상 출력을 작성할 때 가장 완벽한 해법이다.

복잡하게 이스케이프를 쓰거나 외부 파일에서 텍스트를 불러올 필요가 없다. 단지 3중 따옴표 사이에 HTML이나 텍스트를 넣으면 된다. 그리고 소스코드에서 더 보기 좋게 하려면 앞에서 본 trimMargin 확장 함수를 사용하면 된다.


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

Kotlin에서는 리팩토링을 위한 깔끔한 해법이 있다.
Kotlin에서는 함수에서 추출한 함수를 원 함수 내부에 중첩시킬 수 있다. 그렇게 하면 문법적인 부가 비용을 들이지 않고도 깔끔하게 코드를 조직할 수 있다.

profile
학교 다니는 개발자

0개의 댓글