uncategorized

코틀린 함수 정의와 호출

이번 포스트에서는 코틀린의 컬렉션, 문자열, 정규식 영역에서 함수 정의와 호출을 알아보자.

요약
함수를 사용하기 쉽게 만들기
확장 함수
확장 프로퍼티
가변인자, 중위 호출
로컬 함수

코틀린에서 컬렉션 만들기

코틀린에서는 {컬렉션 종류}Of(~) 이런 형태로 쉽게 컬렉션을 만들 수 있다.

1
2
3
val set = hashSetOf(1, 2, 3)
val list = arrayListOf(1, 2, 3)
val map = hashMapOf(1 to "hi", 2 to "ho")

코틀린은 자기만의 컬렉션 객체를 가지지 않고 자바의 컬렉션 프레임워크를 사용한다. 자바에서 사용하던 컬렉션을 코틀린에서도 그대로 사용하므로 특별한 변환 작업이 필요없다.

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

자바에서 toString 메서드를 커스텀하게 구현해본 경험이 있을 것이다. 코틀린에서는 이를 어떻게 쉽게 해결하는 지 체험해보자!

우리는 리스트의 원소들을 다양한 구분자와 접두사, 접미사를 추가해서 문자열을 만드는 함수를 만들어 볼 것이다!

무식하게 구현하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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()
}

제네릭을 활용해서 확장성이 좋은 함수를 만들었다. 만든 함수를 사용하는 코드도 살펴보자.

1
2
val list = listOf(1, 2, 3)
println(joinToString(list, ":", "{", "}"))

흠.. 호출하는 쪽은 조금 난잡하다. 함수를 호출할 때 인자 4개를 모두 전달하지 않는 방법은 없을까??

이름 붙인 인자

코틀린에서는 함수를 호출할 때 매개변수의 이름을 통해 인자를 전달해줄 수 있다.

1
println(joinToString(prefix = "{", collection = list, separator = ":", postfix = "}"))

이름을 붙인 인자를 활용할 때는 이름이 붙은 첫 인자 뒤로 모든 인자들에게 이름을 붙여줘야 한다. 왜냐면 이름을 명시하면 순서가 의미가 없어지기 때문에 이름을 붙여주지 않으면 혼동이 올 수 있다.
함수 호출하는 코드에서 인자들의 역할이 명확해졌다. 하지만 여전히 난잡한 느낌을 지울 수 없다.

디폴트 파라미터 값

1
2
3
4
5
6
7
8
9
10
fun <T> joinToString(
collection: Collection<T>,
separator: String = ":",
prefix: String = "",
postfix: String = ""
) : String {
// 생략...
}

println(joinToString(prefix = "{", collection = list, postfix= "}"));

이렇게 매개변수 선언부에 디폴트값을 할당해서 적어줄 수 있다. 이렇게 디폴트값을 활용하면 비효율적인 생상자 오버로딩을 대폭 줄일 수 있다.

자바 메서드를 코틀린에서 디폴트 값 적용해서 사용하기
매개변수의 디폴트값을 지원하지 않는 자바 메서드를 코틀린에서 디폴트 값으로 활용하기 까다롭다는게 문제다. 이를 위해서 자바 메서드에 @JvmOverloads 어노테이션을 붙이면 맨 뒤 매개변수부터 하나씩 빼서 오버로딩된 메서드가 만들어진다.

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

최상위 함수

자바의 경우 모든 메서드는 클래스 안에 있어야 했다. 그래서 우리가 원하는 건 작동하는 함수 하나여도 클래스를 선언해서 해당 메서드를 포함해야 했다. 하지만 코틀린은 그럴 필요가 없다.
코틀린은 함수를 최상위 수준으로 위치할 수 있다. 다음과 같이 특정 패키지 바로 다음에 함수가 올 수 있다.

1
2
3
4
5
6
7
8
9
10
package string

fun <T> joinToString(
collection: Collection<T>,
separator: String = ":",
prefix: String = "",
postfix: String = ""
) : String {
// 생략...
}

이런 최상위 함수는 컴파일 될 때 새로운 클래스를 정의해서 컴파일한다. 코틀린만 사용하면 그냥 특정 클래스가 생기나보다 하고 넘기면 되지만, 자바에서 코틀린의 최상위 함수를 호출해야 할 경우에는 코틀린의 최상위 함수를 위한 클래스가 어떤 모양인지 알아야 한다.

최상위 함수를 위한 클래스는 최상위 함수를 포함하는 파일 이름에 따라 달라진다. 예를 들어 join.kt파일에 joinToString메서드를 구현한 경우에는 다음과 같이 자바 클래스가 만들어진다.

1
2
3
4
5
6
7
8
public class JoinKt {
public static void <T> joinToString(...) {
... 생략
}
}

// 사용
JoinKt.joinToString(...);

최상위 함수를 위한 클래스 명 바꾸기
@file:JvmName("ClassName")을 패키지 선언문 위에 적으면 클래스 이름도 바꿀 수 있다.

최상위 프로퍼티

프로퍼티도 최상위에 둘 수 있다. 연산 수행 횟수를 재는 프로퍼티 같은 경우 사용할 수 있다.

1
2
val count = 0
fun blahBlah() {...}

이런 경우 프로퍼티는 정적 필드에 저장된다. 이때 특이한 점은 최상위 프로퍼티도 다른 프로퍼티처럼 접근자 메서드를 제공하는데 만약 최상위 프로퍼티를 상수처럼 보이는 데 실제로는 접근자 메서드를 통해 접근하는 것이 어색할 수 있다. 그렇다면 const 키워드를 사용해서 public static final 필드로 컴파일 되게 할 수 있다.

1
2
3
4
5
// kotlin
const val count = 0

// java
public static final count = 0;

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

확장 함수

확장 함수는 어떤 클래스의 멤버 메서드인 것처럼 호출할 수 있지만 그 클래스의 밖에 선언된 함수다. 확장 함수는 기존 자바 API에 코틀린의 추가 기능을 쉽게 제공할 수 있도록 돕기 위해 등장했다.

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

1
2
3
4
fun String.lastChar(): Char = this[this.length - 1]

// 사용하는 코드
println("hello".lastChar()) // o

위 예시는 String 클래스에 마지막 문자를 찾아 반환하는 확장 함수를 추가했다.
확장 함수에서 붙여진 클래스 이름을 수신 객체 타입(receiver type) 이라고 부르며 확장 함수가 호출되는 대상이 되는 값을 수신 객체(receiver object)라고 부른다.
fun String.lastChar(): Char = this.get(this.length - 1)
위 확장 함수에서는 String이 수신 객체 타입이고, this가 수신 객체가 된다.
확장 함수는 자바 클래스로 컴파일한 클래스 파일이 있는 한 원하는대로 확장할 수 있다. 그리고 멤버 메서드와 확장 함수를 통틀어 메서드라고 부르겠다.

하지만 멤버 메서드와 가장 큰 차이점은 private나 protected 멤버는 확장 함수가 접근할 수 없다.
그리고 확장 함수를 사용할 때는 임포트를 해야 한다. (다른 함수와 동일한 이름을 가져서 충돌할 수 있으므로.)

1
2
import {패키지}.{확장함수 명}
import {패키지}.{확장함수 명} as {다른 이름}

자바에서 확장 함수 활용하기
확장 함수는 내부적으로 수신 객체를 첫 매개변수로 가지는 정적 메서드다. 위 예시에서 자바는 다음과 같이 작성하면 된다.

1
char c = StringUtilKt.lastChar("hello");

이제 joinToString 메서드를 확장 함수로 리팩토링 해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fun <T> Collection<T>.joinToString(
separator: String = ":",
prefix: String = "",
postfix: String = ""
) : String {
val result = StringBuilder(prefix)
for ((index, element) in withIndex()) {
if (index > 0) result.append(separator)
result.append(element);
}
result.append(postfix)
return result.toString()
}

// 호출 코드
println(list.joinToString2(prefix = "{", postfix = "}"))

확장 함수를 더 엄격하게 사용
확장 함수 수신 타입 객체를 더 엄격한 타입을 사용하면 확장 함수를 사용하는 수신 객체를 제한할 수 있다.
fun <T> Collection<String>.joinToString(...) {...}
이런 식으로 구현하면 문자열 리스트만 확장 함수를 사용할 수 있게 된다.

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

오버라이드는 변수에 저장된 객체의 동적인 타입에 따라 호출할 메서드가 달라지는 기능이다.
왜 확장 함수는 오버라이드 할 수 없을까? 확장 함수는 정적으로 선언된 메서드이기 때문이다. 확장 함수는 인스턴스 메서드가 아닌 인스턴스를 매개변수로 받는 정적 메서드이므로 오버라이딩이 불가능하다.

멤버 함수와 확장 함수의 시그니처가 같으면 멤버 함수가 우선한다.

확장 프로퍼티

확장 프로퍼티를 사용하면 기존 클래스 객체에 프로퍼티 형식의 구문을 사용할 수 있는 API를 추가할 수 있다. 이때 중요한 점은 확장 프로퍼티는 아무 상태도 가질 수 없다. 기존 클래스의 인스턴스 객체에 필드를 추가할 방법이 없기 때문이다. 하지만 프로퍼티 문법으로 더 짧게 코드를 작성할 수 있다.
확장 프로퍼티는 마치 프로퍼티 접근 방식과 유사하게 API를 제공하는 것이지 실제로 필드를 가져서 접근자 메서드를 제공하는 것이 아님을 명심하자.

1
2
3
4
5
6
7
8
9
var StringBuilder.lastChar: Char
get() = get(length - 1)
set(value) {
this.setCharAt(length - 1, value)
}

val sb = StringBuilder("Kotlin?")
sb.lastChar2 = '!'
println(sb)

만약 자바에서 사용하려면 다음과 같을 것이다.

1
Char lastChar = StringUtilKt.getLastChar();

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

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

자바에서는 ...으로 가변 인자를 표현할 수 있었다. 코틀린에서는 vararg 변경자를 앞에 붙여주면 된다.
대표적인 가변 인자 함수인 listOf 함수의 경우를 살펴보자.

1
2
3
var list = listOf(1, 2, 3)

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

코틀린은 자바처럼 배열을 바로 가변 인자로 넘겨줄 수 없다. 배열의 원소를 풀어서 각 원소가 인자로 전달되게 해야한다.
이 역할을 스프레드 연산자 *가 한다. 스프레드 연산자를 활용하면 배열과 함께 다른 인자들도 같이 전달해줄 수 있다.

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

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

대표적인 중위 호출인 map에서 to 메서드 호출을 살펴보자.

1
val map = mapOf(1 to "one", 2 to "two")

중위 호출은 수신 객체와 유일한 메서드 인자 사이에 메서드 이름을 넣는다. 다음 두 호출은 동일하다!

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

중위 호출은 수신 객체의 메서드 중 매개변수가 하나뿐인 경우에 사용할 수 있다.

실제 to 메서드의 구현을 살펴보자.

1
public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)

일단 infix 키워드를 통해 중위 호출을 사용할 수 있도록 했다. 제네릭을 활용해서 A 수신 객체 타입에 확장 함수로 구현했다.

구조 분해 선언

to 메서드는 Pair 인스턴스를 반환하는데 Pair 인스턴스로 두 변수를 즉시 초기화 할 수 있다.

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

이렇게 numbername 두 변수를 Pair를 구조 분해해서 할당했다.

이런 구조 분해 선언은 루프에서도 사용할 수 있다.

1
for ((index, element) in collection.withIndex()) { ... }

문자열과 정규식 다루기

코틀린 문자열은 자바 문자열과 같다. 다만 코틀린은 확장 함수로 더 다양한 기능을 제공한다.

문자열 나누기

코틀린의 split은 정규식으로 나눠야 하는 경우 Regex 객체를 요구한다. 이로써 개발자가 혼동하는 일이 없도록 한다.
또한 여러 구분 문자열을 지정할 수 있도록 지원한다.

1
2
3
val line = "12.345-6.A"
println(line.split("\\.|-".toRegex()))
println(line.split(".", "-"))

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

코드 중복을 줄이기 위해서 메서드 추출을 통해 작은 메서드로 분리하기 시작하면 메서드가 너무 많아져서 코드가 파악하기 어려워진다.
코틀린은 함수에서 추출한 함수를 원 함수 내부에 중첩시킬 수 있다.

먼저 중복되는 예시를 보자.

1
2
3
4
5
6
7
8
9
10
11
class User(val id: Int, val name: String, val address: String)

fun saveUser(user: User) {
if (user.name.isEmpty()) {
throw IllegalArgumentException("${user.id} empty name" )
}

if (user.address.isEmpty()) {
throw IllegalArgumentException("${user.id} empty email" )
}
}

보면 비슷한 검증로직이 존재한다.

이를 로컬 함수로 개선해보자.

1
2
3
4
5
6
7
8
9
10
fun saveUser(user: User) {
fun validate(user: User, value: String, fieldName: String) {
if (value.isEmpty()) {
throw IllegalArgumentException("${user.id} empty $fieldName")
}
}
validate(user, user.name, "Name")
validate(user, user.address, "Address")
// save logic...
}

함수 안에 함수를 만들어서 중복 코드를 줄였다. 하지만 User 객체를 로컬 함수에게 일일히 전달하는게 아쉽다.
사실 그러지 않아도 된다. 로컬 함수는 자신이 속한 바깥 함수의 모든 파라미터와 변수를 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
fun saveUser(user: User) {
fun validate(value: String, fieldName: String) {
if (value.isEmpty()) {
throw IllegalArgumentException("${user.id} empty $fieldName")
}
}
validate(user.name, "Name")
validate(user.address, "Address")
// save logic...
}

아재 곰중 로직을 확장 함수로 추출해보자. 이렇게 하면 외부에서 사용되지 않는 로직은 도메인 클래스에서 제거할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fun User.validateBeforeSave() {
fun validate(value: String, fieldName: String) {
if (value.isEmpty()) {
throw IllegalArgumentException("$id empty $fieldName")
}
}
validate(name, "Name")
validate(address, "Address")
}

fun saveUser4(user: User) {
user.validateBeforeSave()
// save logic...
}
Share