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에는 이런 요구 사항을 처리할 수 있는 함수가 표준 라이브러리에 이미 들어있다. (!)
이번 절에서는 그런 함수들에 대해 알아보도록 하겠다.
joinToString(collection, separator=" ", prefix=" ", postfic=".")
Kotlin으로 작성한 함수를 호출할 때는 위와 같이 함수에 전달하는 인자 중 일부 또는 전부의 이름을 명시할 수 있다.
호출 시 인자 중 어느 하나라도 이름을 명시하고 나면 혼동을 막기 위해 그 뒤에 오는 모든 인자는 이름을꼭 명시해야 한다.
Java의 경우 일부 클래스에서 오버로딩한 메서드가 너무 많아진다는 문제가 있다. 그러나 Kotlin에서는 함수 선언에서 파라미터의 디폴트 값을 지정할 수 있으므로 오버로드 중 상당수를 피할 수 있다.
fun <T> joinToString(
collection: Collection<T>,
separoator: String = ",",
prefix: String = "",
postfix: String = ""
): String
이제 함수를 호출할 때 모든 인자를 쓸 수도 있고, 일부를 생략할 수도 있게 되었다.
객체지향 언어인 Java에서는 모든 코드를 클래스의 메서드로 작성해야 한다.
그러나 실전에서는
그 결과 다양한 정적 메서드를 모아두는 역할만 담당하며, 특별한 상태나 인스턴스 메서드는 없는 클래스가 생겨난다. 그러나 Kotlin에서는 이런 무의미한 클래스가 필요 없다.
대신 함수를 직접 소스 파일의 최상위 수준, 모든 다른 클래스의 밖에 위치시키면 된다.
또한 함수와 마찬가지로 프로퍼티도 파일의 최상위 수준에 놓을 수 있다. 이런 프로퍼티의 값은 정적 필드에 저장된다. 최상위 프로퍼티를 활용해 코드에 상수를 추가할 수 있다.
기본적으로 최상위 프로퍼티도 다른 모든 프로퍼티처럼 접근자 메서드를 통해 코드에 노출된다. 겉으로는 상수처럼 보이는데, 실제로는 게터를 사용해야 한다면 자연스럽지 못하다.
자연스럽게 사용하려면 상수를 public static final
필드로 컴파일해야 한다.
const
변경자를 추가하면 프로퍼티를public static final
필드로 컴파일하게 만들 수 있다.
(단, 원시 타입과String
타입의 프로퍼티만const
로 지정할 수 있다.)
기존 Java API를 재작성하지 않고도 Kotlin이 제공하는 여러 편리한 기능을 사용할 수 있다면 편리할 것이다.
그리고 Kotlin의 확장 함수가 바로 그런 역할을 해준다.
확장 함수는 어떤 클래스의 멤버 메서드인 것처럼 호출할 수 있지만 그 클래스의 밖에 선언된 함수다.
확장 함수를 만들려면 추가하려는 함수 이름 앞에 그 함수가 확장할 클래스의 이름을 덧붙이기만 하면 된다.
package strings
fun String.lastChar(): Char = this.get(this.length-1)
println("Kotlin".lastChar())
위 코드에서는 String
이 수신 객체 타입이고 kotlin
이 수신 객체다.
어떤 면에서 이는 String
클래스에 새로운 메서드를 추가하는 것과 같다. 비록 이 클래스가 직접 작성한 코드도 아니고 그 클래스의 소스코드를 소유한 것도 아니지만, 원하는 메서드를 이 클래스에 추가할 수 있는 것이다.
확장 함수 내부에서는 일반적인 인스턴스 메서드의 내부에서와 마찬가지로 수신 객체의 메서드나 프로퍼티를 바로 사용할 수 있다. 하지만 확장 함수가 캡슐화를 깨지는 않는다.
확장 함수를 사용하기 위해서는 그 함수를 다른 클래스나 함수와 마찬가지로 임포트해야만 한다. 확장 함수를 정의하자마자 어디서든 그 함수를 쓸 수 있다면 한 클래스에 같은 이름의 확장 함수가 둘 이상 있어서 이름이 충돌하는 경우가 자주 생길 수 있다.
Kotlin에서는 클래스를 임포트할 때와 동일한 구문을 사용해 개별 함수를 임포트할 수 있다.
import strings.lastChar
import strings.*
import strings.lastChar as last // as 키워드를 사용해 다른 이름으로 부른다.
한 파일 안에서 다른 여러 패키지에 속해있는 이름이 같은 함수를 가져와 사용해야 하는 경우 이름을 바꿔서 임포트하면 이름 충돌을 막을 수 있다. 물론 일반적인 클래스나 함수라면 그 전체 이름을 써도 된다.
하지만 Kotlin 문법상 확장 함수는 반드시 짧은 이름을 써야 한다. 따라서 이름을 바꾸는 것이 확장 함수 이름 충돌을 해결할 수 있는 유일한 방법이다.
내부적으로 확장 함수는 수신 객체를 첫 번째 인자로 받는 정적 메서드다. 그래서 확장 함수를 호출해도 다른 어댑터 객체나 실행 시점 부가 비용이 듣지 않는다.
이런 설계로 인해 자바에서 확장 함수를 사용하기도 편하다. 단지 정적 메서드를 호출하면서 첫 번째 인자로 수신 객체를 넘기기만 하면 된다.
char c = StringUtilKt.lastChar("Java");
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
을 마치 클래스의 멤버인 것처럼 호출할 수 있다.
확장 함수는 단지 정적 메서드 호출에 대한 문법적인 편의일 뿐이다. 그래서 클래스가 아닌 더 구체적인 타입을 수신 객체 타입으로 지정할 수도 있다.
kotlin의 메서드 오버라이드도 일반적인 객체지향의 메서드 오버라이드와 마찬가지다. 확장 함수가 정적 메서드와 같은 특징을 가지므로, 확장 함수를 하위 클래스에서 오버라이드할 수는 없다.
확장 함수는 클래스의 일부가 아니다.
확장 함수는 클래스 밖에 선언된다.
이름과 파라미터가 완전히 같은 확장 함수를 기반 클래스와 하위 클래스에 대해 정의해도 실제로는 확장 함수를 호출할 때 수신 객체로 지정한 변수의 정적 타입에 의해 어떤 확장 함수가 호출될지 결정되지, 그 변수에 저장된 객체의 동적인 타입에 의해 확장 함수가 결정되지 않는다.
확장 프로퍼티를 사용하면 기존 클래스 객체에 대한 프로퍼티 형식의 구문으로 사용할 수 있는 API를 추가할 수 있다.
프로퍼티라는 이름으로 불리기는 하지만 상태를 저장할 적절한 방법이 없기 때문에 실제로 확장 프로퍼티는 아무 상태도 가질 수 없다. 하지만 프로퍼티 문법으로 더 짧게 코드를 작성할 수 있어서 편한 경우가 있다.
앞에서 쓴 lastChar
라는 함수를 프로퍼티로 바꿔보자.
val String.lastChar: Char
get() = get(length -1)
확장 프로퍼티도 일반적인 프로퍼티와 같다. 단지 수신 객체 클래스가 추가됐을 뿐이다.
또한 아래 두 가지 사항을 고려해야 한다.
이 절에서 다룰 3가지 내용은 아래와 같다.
vararg
키워드를 사용하면 호출 시 인자 개수가 달라질 수 있는 함수를 정의할 수 있다맨 앞 부분에서 Kotlin 컬렉션은 Java와 같은 클래스를 사용하지만 더 확장된 API를 제공한다고 했다.
Kotlin 표준 라이브러리는 수많은 확장 함수를 포함한다. 그리고 이 모든 라이브러리의 기능은 다 외우고 있을 필요가 없다.
컬랙션이나 다른 객체에 대해 사용할 수 있는 메서드나 함수가 무엇인지 궁금할 때마다 IDE의 코드 완성 기능을 통해 그런 메서드나 함수를 살펴볼 수 있다.
(실제로 프로그래밍하며 가장 많이 쓰고 가장 유용하다고 느끼는 기능이다.)
앞서 컬렉션을 만들어내는 함수를 몇 가지 살펴봤다. 그런 함수가 모두 가진 특징은 바로 인자의 개수가 그때그때 달라질 수 있다는 점이다. 이렇게 파라미터 개수가 달라질 수 있는 함수를 정의하는 방법에 대해 살펴보자.
가변 길이 인자는 메서드를 호출할 때 원하는 개수만큼 값을 인자로 넘기면 자바 컴파일러가 배열에 그 값들을 넣어주는 기능이다.
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)
}
기술적으로는 스프레드 연산자가 그런 작업을 해준다. 하지만 실제로는 전달하려는 배열 앞에 *
를 붙이기만 하면 된다.
맵을 만들려면 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의 경우 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에 있는 단 하나의 문자만 받을 수 있는 메서드를 대신한다.
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중 따옴표를 쓰면 줄 바꿈이 들어있는 프로그램 텍스트를 쉽게 문자열로 만들 수 있다.
여러 줄 문자열을 코드에서 더 보기 좋게 표현하고 싶다면 들여쓰기를 하되 들여쓰기의 끝부분을 특별한 문자열로 표시하고,
trimMargin
을 사용해 그 문자열과 그 직전의 공백을 제거한다.
\n
과 같은 특수 문자를 사용해 넣을 수는 없다.프로그래밍 시 여러 줄 문자열이 요긴한 분야로는 테스트를 꼽을 수 있다.
테스트에서는 여러 줄의 텍스트 출력을 만들어내는 연산을 실행하고 그 결과를 예상 결과와 비교해야 하는 경우가 자주 있다. 여러 줄 문자열은 테스트의 예상 출력을 작성할 때 가장 완벽한 해법이다.
복잡하게 이스케이프를 쓰거나 외부 파일에서 텍스트를 불러올 필요가 없다. 단지 3중 따옴표 사이에 HTML이나 텍스트를 넣으면 된다. 그리고 소스코드에서 더 보기 좋게 하려면 앞에서 본 trimMargin
확장 함수를 사용하면 된다.
Kotlin에서는 리팩토링을 위한 깔끔한 해법이 있다.
Kotlin에서는 함수에서 추출한 함수를 원 함수 내부에 중첩시킬 수 있다. 그렇게 하면 문법적인 부가 비용을 들이지 않고도 깔끔하게 코드를 조직할 수 있다.