Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.
코틀린 컬렉션, 문자열, 정규식에서의 함수 정의와 호출 기능을 알아봅니다.
코틀린 컬렉션 만들기
아래와 같이 set 과 리스트, 맵을 만들 수 있다.
private val set = hashSetOf(1, 7, 53)
private val list = arrayListOf(1, 7, 53)
private val map = hashMapOf(1 to "one", 7 to "seven", 53 to "fifty-three")
여기서 `to` 는 코틀린의 키워드가 아닌 일반 함수이다. (나중에 조금 더 자세히 다룬다.)
@Test
fun printObjectType() {
println(set.javaClass)
println(list.javaClass)
println(map.javaClass)
}
코틀린에서 `javaClass` 는 자바의 `getClass` 에 해당하는 코틀린 코드이다.
출력 결과
class java.util.HashSet
class java.util.ArrayList
class java.util.HashMap
위에서 만든 `set`, `list`, `map` 객체가 모두 `java.util` 패키지에 속한 클래스의 인스턴스임을 알 수 있다.
즉, 코틀린이 코틀린만의 컬렉션 기능을 제공하지 않는다는 뜻이다.
자바 개발자가 코틀린을 새로 배우거나 언어를 이전할 시에 기존 자바 컬렉션을 활용할 수 있으며
코틀린이 자바와의 상호운용성이 굉장히 좋다는 근거 중 하나이다.
자바에서 코틀린 함수를 호출하거나 그 반대 경우일 때 자바와 코틀린 컬렉션을 서로 변환할 필요가 없다.
코틀린 컬렉션은 자바 컬렉션과 똑같은 클래스이지만, 코틀린에서는 아래 코드에서처럼 자바보다 더 많은 기능을 쓸 수 있다.
@Test
fun moreFeaturesOfKotlinCollection() {
val string = listOf("first", "second", "third")
println(string.last()) // 리스트의 마지막 원소를 가져온다
val numbers = setOf(1, 14, 2)
println(numbers.max()) // 컬렉션에서 최대 값을 가져온다
}
출력
third
14
이것은 코틀린에서의 확장 함수 개념 덕분에 가능한 것이다. 확장 함수는 다음 글에서 알아본다.
함수를 호출하기 쉽게
자바 컬렉션에서는 디폴트 `toString` 구현이 들어있다. 이 출력형식은 당연히 고정되어 있다.
@Test
fun defaultToStringOfCollection() {
val list = listOf(1, 2, 3)
println(list)
}
출력
[1, 2, 3]
그런데 만약 출력을 `[1;2;3]` 의 형태로 하고 싶다면 어떻게 해야할까?
자바 프로젝트에서는 다른 서드파티 프로젝트를 추가하거나 직접 관련 로직을 구현해야 하지만,
코틀린에서는 이것을 간단하게 할 수 있는 기능이 있다.
먼저 직접 함수를 구현해 보자.
Generic 한 함수를 하나 만들어서 모든 타입의 원소를 가진 컬렉션을 처리할 수 있도록 한다.
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()
}
그리고 아래처럼 출력해보자.
@Test
fun changeToStringFormatOfCollection() {
val list = listOf(1, 2, 3)
println(joinToString(list, ";", "(", ")"))
}
출력
(1;2;3)
잘 작동하지만 함수 호출부가 조금 번잡하다.
이름 붙인 argument
함수를 호출할 때마다 매번 네 인자를 모두 전달하지 않는 방법이 있다.
먼저 함수 호출 부분의 가독성이 나쁘다.
만약 `joinToString(list, " ", " ", " ")` 라고 함수를 사용하면, 각 argument 들이 어떤 역할을 하는지 알기 쉽지 않다.
(물론 아래처럼 IDE 를 사용하면 각 argument 가 어떤 패러미터의 값인지 알 수 있지만 깃허브의 PR 코드를 볼 때처럼 항상 IDE 를 사용하여 코드를 읽는 것은 아니다.)
코틀린에서는 아래처럼 함수에 전달하는 argument 의 이름을 명시할 수 있다.
호출 시 어떤 argument 중 하나라도 이름을 명시하면, 보통 그 뒤에 오는 모든 인자는 혼동을 피하기 위해 이름을 명시한다.
joinToString(list, separator = " ", prefix = " ", postfix = " ")
Effective kotlin 에서 말하는 이름 붙인 argument
이 부분은 심화 내용이므로 간단히 이해하고 넘어가거나, 이해가 안되면 넘어가도 된다.
사용 시 장점
- 이름을 기반으로 값이 무엇을 나타내는지 알 수 있다.
- 파라미터 입력 순서와 상관 없으므로 안전하다.
사용을 추천하는 경우
- 디폴트 argument 의 경우(디폴트 파라미터 값을 가진 파라미터의 argument)
- 일반적으로 함수 이름은 필수 파라미터들과 관련되어 있다.
그래서 함수 이름이 디폴트 값을 갖는 optional parameter(옵션 파라미터)의 설명은 충분히 해주지 못한다.
그러므로 이 argument 는 이름을 붙이는 게 좋다.
- 일반적으로 함수 이름은 필수 파라미터들과 관련되어 있다.
- 같은 타입의 파라미터가 많은 경우
- 파라미터가 모두 다른 타입이라면, 위치를 잘못 입력하면 오류가 발생할 것이므로 쉽게 문제를 발견할 수 있다.
하지만 파라미터에 같은 타입이 있다면 잘못 입력했을 때 문제 찾기가 어렵다.
- 파라미터가 모두 다른 타입이라면, 위치를 잘못 입력하면 오류가 발생할 것이므로 쉽게 문제를 발견할 수 있다.
- 함수 타입의 파라미터가 있는 경우(마지막 파라미터인 경우는 제외):코틀린에서는 함수 타입을 함수의 argument 로 넘겨줄 수 있음
- 함수 타입 파라미터는 보통 마지막 위치에 배치하는 것이 좋다.
- 함수 이름은 함수 타입 argument 를 설명해줄 때가 많다.
- 예를 들어 repeat 뒤에 오는 람다는 반복될 블록을 나타내고,
thread 뒤에 오는 람다는 블록이 스레드의 바디라는 것을 쉽게 알 수 있다.
- 예를 들어 repeat 뒤에 오는 람다는 반복될 블록을 나타내고,
- 그 밖에 모든 함수 타입 argument 는 이름 붙인 argument 를 사용하는 것이 이해하기 쉽다.
- 아래 예시들
// view DSL 이 있다고 하면
val view = linearLayout {
text("Click below")
button({/* 1 */ }, {/* 2 */ })
}
// 아래처럼 button 함수의 이름 붙인 argument 를 사용하여 빌더 부분과 클릭 리스너 부분을 명확히 한다
val view = linearLayout {
text("Click below")
button(onClick = {/* 1 */}) {
/* 2 */
}
}
fun call(before: ()-> Unit = {}, after: ()-> Unit ={}){
before()
print("Middle")
after()
}
// 이름 붙이지 않은 argument 로 함수 호출
call ({ print(" CALL ") }) // CALL Middle 출력됨. after 가 디폴트 파라미터 값 {} 로 동작
call { print(" CALL ") } // Middle CALL 출력됨. before 가 디폴트 파라미터 값 {} 로 동작
// 이름 붙인 argument 로 함수 호출
call(before = { print(" CALL ") }) // CALL Middle 출력됨
call(after = { print(" CALL ") }) // Middle CALL 출력됨
위 예시에 대해 자세히 이해하려면 코틀린에서의 람다를 잘 알아야 한다. 이는 나중에 다루겠다.
/* RxJava 에서 Observable 을 구독할 때 함수를 설정한다.
* 각각의 아이템을 받을 때 onNext
* 오류가 발생했을 때 onError
* 전체가 완료되었을 때 onComplete
*/
Obervable.getUsers()
.subsribe((List<User> users) -> {
// ... onNext 부분
}, (Throwable throwable) -> {
// ... onError 부분
}, () -> {
// ... onCompleted 부분
});
// 위 코드를 코틀린의 이름 붙인 argument 를 사용해서 의미를 더 명확히 한다.
Obervable.getUsers()
.subscribeBy(
onNext = { users: List<User> ->
// ...
},
onError = {throwable: Throwable -> {
// ...
},
onCompleted = {
// ...
})
디폴트 파라미터 값
아래는 자바의 Thread 클래스에 내용 중 일부이다.
public Thread() { this(null, null, 0, null, 0, null); }
public Thread(Runnable task) { this(null, null, 0, task, 0, null); }
public Thread(ThreadGroup group, Runnable task) { this(group, null, 0, task, 0, null); }
public Thread(String name) { this(null, checkName(name), 0, null, 0, null);}
public Thread(ThreadGroup group, String name) { this(group, checkName(name), 0, null, 0, null); }
public Thread(Runnable task, String name) { this(null, checkName(name), 0, task, 0, null);}
public Thread(ThreadGroup group, Runnable task, String name) { this(group, checkName(name), 0, task, 0, null); }
public Thread(ThreadGroup group, Runnable task, String name, long stackSize) { this(group, checkName(name), 0, task, stackSize, null); }
...
자바의 일부 클래스에서는 overloading 한 메서드가 너무 많아진다는 문제가 있다.
오버로딩 메서드들은 하위 호환성을 유지하거나 API 사용자에게 편의를 더하는 등의 이유로 만들어진다.
하지만 파라미터 이름과 타입이 계속 반복되며, 설명 주석도 반복될 것이다.
또 여러 오버로드 함수 중에서 일부 파라미터가 생략된 오버로드 함수를 호출하면 어떤 함수가 호출될지 모호한 경우도 생길 수 있다.
코틀린에서는 함수 선언에서 파라미터의 디폴트 값을 지정할 수 있으므로 이런 오버로드에서의 반복 중 상당수를 피할 수 있다.
위에서 선언한 `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()
}
이제 함수 호출 시에 모든 인자를 쓸 수도 있고, 일부를 생략할 수도 있다.
@Test
fun changeToStringFormatOfCollection() {
val list = listOf(1, 2, 3)
println(joinToString(list, ",", "", ""))
println(joinToString(list)) // separator, prefix, postfix 생략 -> 기본값
println(joinToString(list, "; ")) // prefix, postfix 생략 -> 기본값
}
결과 출력
1,2,3
1,2,3
1; 2; 3
당연히 함수 호출시에 argument 는 파라미터의 순서대로 지정해야 한다.
그래서 어떤 argument 를 생략하면, 뒤의 argument 도 생략해야 한다.
하지만 만약 이름 붙인 argument 를 사용한다면 순서와 관계없이 사용할 수 있다.
println(joinToString(list, postfix = ";", prefix = "# "))
출력 결과
# 1, 2, 3;
자바에는 디폴트 파라미터 값이라는 개념이 없어서
코틀린 함수를 자바에서 호출하는 경우에는 그 코틀린 함수가 디폴트 파라미터 값을 제공하더라도 모든 인자를 명시해야 한다.
만약 자바에서 코틀린 함수를 자주 호출해야 한다면, `@JvmOverloads` 애노테이션을 함수에 추가하는 것이 도움이 될 수 있다.
이것을 함수에 추가하면, 코틀린 컴파일러가 자동으로 맨 마지막 파라미터부터 파라미터를 하나씩 생략한 오버로딩한 자바 메서드를 추가해준다.
만약 디폴트 파라미터 값을 사용한 `joinToString` 함수에 `@JvmOverloads` 애노테이션을 붙이면 아래처럼 자바 함수가 만들어진다.
String joinToString(Collection<T> collection, String separator, String prefix, String postfix);
String joinToString(Collection<T> collection, String separator, String prefix);
String joinToString(Collection<T> collection, String separator);
String joinToString(Collection<T> collection);
그리고 각각의 오버로딩한 함수들은 시그니처에서 생략된 파라미터들에 대해서 코틀린 함수의 디폴트 파라미터 값을 사용한다. 즉, 자바 함수 구현부에 해당 디폴트 파라미터 값이 들어가서 만들어진다.
최상위(top-level) 함수 & 최상위 프로퍼티로 정적 유틸리티 클래스 없애기
최상위 함수
자바에서는 모든 동작을 클래스의 메서드로 작성해야 한다.
하지만 실전에서는 어느 한 클래스에 넣기 어려운 동작이 많이 생긴다.
- 일부 연산에서는 비슷하게 중요한 역할을 하는 클래스가 둘 이상 있을 수 있음.
- 중요한 객체는 하나지만 그 연산을 객체의 인스턴스 API 에 추가하면 API 가 너무 커지고 이렇게 구현하고 싶지 않을 수 있음.
결국 다양한 정적 메서드를 모아두는 역할만 담당하며 특별한 상태, 인스턴스, 정적이 아닌 메서드는 없는 클래스가 생긴다. (ex: JDK 의 `Collections` 클래스)
그런데 코틀린에서는 이런 무의미한 클래스를 만들 필요가 없다.
대신 함수를 소스 파일의 최상위 수준에, 즉, 모든 클래스의 밖에 위치시키면 된다.
그런 함수들은 여전히 그 파일이 속한 패키지의 멤버이므로 다른 패키지에서 그 함수를 사용하고 싶다면 그 함수가 정의된 패키지를 `import` 하면 된다.
이 때 유틸리티 클래스 이름 (`~~Util`)이 `import` 문에 들어갈 필요가 없다.
`Join.kt` 라는 파일을 아래처럼 작성해보자.
package inaction.chap3
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()
}
이렇게 작성하면 같은 패키지의 모든 곳에서 이 함수를 사용할 수 있으며 다른 패키지에서는 `Join.kt` 가 속한 패키지를 `import` 하여 사용할 수 있다.
이 파일을 컴파일할 때 컴파일러가 새로운 클래스를 정의해준다.
코틀린 언어로 개발하는 개발자들은 이렇게 자유롭게 사용할 수 있다.
만약 자바 언어로 위 `Join.kt` 의 함수를 호출하려면 어떻게 해야할까?
코틀린이 `Join.kt` 를 컴파일했을 때 생기는 클래스를 자바 언어로는 아래와 같다.
package inaction.chap3
public class JoinKt {
public static String joinToString(...) { ... }
}
코틀린 컴파일러는 최상위 함수가 들어있는 코틀린 소스 파일과 비슷한 이름의 클래스를 생성해준다.
코틀린 파일의 모든 최상위 함수는 위처럼 정적 메서드가 된다.
자바에서는 아래처럼 호출하면 된다.
import inaction.chap3.JoinKt;
/* ... */
JoinKt.joinToString(list, ", ", "", "");
물론 컴파일러가 생성하는 클래스의 이름을 소스 파일의 이름과 다르게 직접 지정해줄 수도 있다.
파일에 `@JvmName` 이라는 애노테이션을 추가하면 된다. 이 애노테이션은 파일의 맨 앞, 패키지 선언보다 위에 위치해야 한다.
@file:JvmName("StringFunctions")
package inaction.chap3
fun <T> joinToString(...): String { ... }
최상위 프로퍼티
최상위 함수처럼 프로퍼티도 파일의 최상위 수준에 놓을 수 있다.
사용 예시를 들어보면, 어떤 연산을 수행한 횟수를 저장하는 `var` 프로퍼티를 만들 수 있다.
var opCount = 0
fun performOperation() {
opCount++
// ....
}
fun reportOperationCount() = println("Operation performed $opCount times")
이런 프로퍼티의 값은 정적 필드에 저장된다.
또 최상위 프로퍼티를 활용해서 코드에 상수를 추가할 수도 있다.
val UNIX_LINE_SEPARATOR = "\n"
기본적으로 최상위 프로퍼티도 다른 모든 프로퍼티처럼 접근자 메서드를 통해서 자바 코드에 노출된다.(`val` 의 경우 `getter`, `var` 의 경우 `getter`, `setter` 가 생김.)
상수인데 실제로는 getter 를 사용해야 한다면 부자연스럽다.
상수라면 `public static final` 필드로 컴파일하는 것이 더 자연스럽다.
코틀린에서는 `const` 변경자를 추가하면 프로퍼티를 `public static final` 필드로 컴파일하게 만들 수 있다. (원시 타입과 `String` 타입만 `const` 로 지정 가능)
const val UNIX_LINE_SEPARATOR = "\n"
이 코드는 아래 자바 코드와 같다.
public static final String UNIX_LINE_SEPARATOR = "\n";
다음 글에서는 확장 함수와 확장 프로퍼티를 알아보자.
'Kotlin' 카테고리의 다른 글
[Kotlin] 컬렉션 처리 API 확장 & 가변 인자(vararg) 함수 (1) | 2024.01.03 |
---|---|
[Kotlin] 확장 함수 & 확장 프로퍼티 (0) | 2024.01.01 |
[Kotlin] 예외 처리 (1) | 2023.12.28 |
[kotlin] 이터레이션 기초: while, for 루프 (1) | 2023.12.27 |
[Kotlin] enum & when & smart cast(스마트 캐스트) (1) | 2023.12.27 |