이번장에서 다루는 내용
collection은 쉽게 생각하면 List, Set, Map 등 코틀린에서 다루는 자료구조를 의미한다. 이때 이러한 collection은 2가지 형태로 구성된다.
mutable과 immutable은 왜 구분했는지 이유를 알아보고자 한다면, 객체를 단순히 read-only 하기 위한 것인지 아니면 read와 write 모두 할 것인지에 달렸다. 굳이 수정까지 할 이유가 없다면, 닫아 두는 것이 일반적인데, 이렇게 구분하는 이유는 시스템 오류를 방지하기 위함이 있다. 즉 개발자가 손쉽게 mutable한지 immutable 한지 구분하여 코드를 작성하므로서 디버깅 또는 구조 설계에 대해 더 효과적인 성능 줄 수 있기 때문에 mutable과 immutable을 구분하게 되었다. collection 자료 구조 뿐만 아니라, 단순히 변수를 할당할 때도 var, val, const 등을 구분하여 immutable과 mutable을 지정할 수 있기 때문에 개인적으로 코드를 작성하면서 구조를 좀 더 탄탄히 만들 수 있다고 생각한다.
이러한 특성을 이용하여 set, list, map을 만들어 보자.
fun main(args: Array<String>) {
val s = hashSetOf(1, 7, 53)
val l = arrayListOf(1, 7, 53)
val m = hashMapOf(1 to "one", 7 to "seven", 53 to "fifty-three")
println(s) // [1, 53, 7]
println(l) // [1, 7, 53]
println(m) // {1=one, 53=fifty-three, 7=seven}
}
여기서 hashMapOf의 to
는 특별한 키워가 아닌 함수다. 이때 각각의 collection의 클래스를 확인해보면 다음과 같다.
fun main(args: Array<String>) {
val s = hashSetOf(1, 7, 53)
val l = arrayListOf(1, 7, 53)
val m = hashMapOf(1 to "one", 7 to "seven", 53 to "fifty-three")
println(s.javaClass) // class java.util.HashSet
println(l.javaClass) // class java.util.ArrayList
println(m.javaClass) // class java.util.HashMap
}
??!! 왜 java가 뜨는 것인가? 그렇다. kotlin은 자체 collection을 제공하지 않는다. 즉 java 개발자는 기존의 java collection을 활용할 수 있다.
그렇다면 왜 kotlin은 자체 collection을 제공하지 않는가?
그 이유는 코틀린 언어 자체가 갖고 있는 상호운용성과 연관시킬 수 있다. 즉 만약에 kotlin에서 자체 collection을 제공하면, java collection으로 따로 변환하는 과정이 필요하다. 하지만, 이렇게 애초에 java class를 다루게 되면 따로 변환하게 되는 과정은 필요가 없게 된다. 더욱 손쉽게 다룰 수 있다는 것이다. 물론 그렇다고 java의 collection을 모두 따르지는 않는다. kotlin 자체에서 더 많은 작업을 할 수 있도록 돕고 있다. 아래의 코드를 보면서 이야기해보자.
fun main(args: Array<String>) {
val strings = listOf("first", "second", "fifth")
println(strings.first()) // "first"
println(strings.last()) // "fifth"
val numbers = setOf(1, 14, 2)
println(numbers.maxOrNull()) // 14
}
kotlin에서는 collection을 통해 첫 원소나 마지막 원소 또는 집합의 최대값 등을 찾을 수 있다.
위의 자료들을 간단하게 모든 원소를 출력해보자. 이러한 단순한 과정에서도 여러 중요한 개념들이 들어가 있다. java의 경우 collection에 default로 toString이 구현이 들어있다. 하지만, 이때! 이 default toString()의 출력 형식은 고정돼 있고 우리에게 필요한 형식이 아닐수도 있다.
fun main(args: Array<String>) {
val list = listOf("first", "second", "fifth")
println(list) // [first, second, fifth]
}
출력은 [first, second, fifth]
이 되었지만, 우리가 원하는 출력 구분자가 빈칸이 아니라면? 자바에서는 어떻게 해야 되는가? 당연히 따로 구현해야 한다. 보다 자세히는 구아바 또는 아파치 커먼즈 같은 서드파티 프로젝트를 추가하거나 직접 관련 로직을 구현해야 한다. 하지만! 코틀린은 이미 이러한 요구 사항을 처리할 수 있는 함수가 표준 라이브러리에 들어있다. 그래도 우리가 한번 직접 구현해보자.
처음에는 간단하게 구현해보자. 그리고 코틀린 답게 작성한 함수를 다시 구현해보자.
작성할 함수는 우리가 많이 사용하는 joinToString 함수이다. collection의 원소를 StringBuilder의 뒤에 덧붙인다. 이때 원소 사이에 구분자 separator를 추가하고, StringBuilder의 맨 앞과 맨 뒤에는 접두사와 접미사를 추가한다.
fun main(args: Array<String>) {
val list = listOf("first", "second", "fifth")
println(joinToString(list, ";", "(", ")")) // (first;second;fifth)
}
fun <T> joinToString(
collection: Collection<T>, // 입력될 collection
separator: String, // 구분자
prefix: String, // 접두사
postfix: String // 접미사
) : String {
val result = StringBuilder(prefix) // 접두사 붙인다
for ((idx, ele) in collection.withIndex()) {
if (idx > 0) result.append(separator)
result.append(ele)
}
result.append(postfix) // 접미사 붙인다.
return result.toString() // 출력한다. 물론 출력형식은 String이다.
}
지금 작성한 joinToString 함수는 Generic
하다. 이 뜻은 어떤 타입의 값을 원소로 하는 컬렉션이든 처리할 수 있다는 것이다. Generic 함수의 문법은 java와 비슷하다.
그런데, 지금 작성한 코드를 보면 좀 아니다 라고 생각하는게, 인자가 4개가 전달된다. 너무 많은 인자가 전달된다고 생각하지 않은가? 이때 발생하는 문제는 위 코드에서 입력한 인자값이 도대체 어디에 속하는지 함수를 직접 만들거나 직접 파헤치거나 하지 않은 개발자의 경우 의미를 알기 어렵다는 것이다. 이때 코틀린에서는 다음과 같이 작성할 수 있다.
joinToString(collection, separator=” ”, prefix=” “, postfix=” “)
이는 python과 동일한 메커니즘이다. 물론 이런 부분을 java에서는 지원하지 않는다고 한다. 뭔가 더 직관적으로 변했다.
이후 함수를 작성하면서 생각해 볼 것은 디폴트 파라미터 값인 오버로딩이다.
자바에서 일부 클래스에서는 오버로딩한 메소드가 너무 많아진다는 문제가 있다. java.lang.Thread에 있는 8가지 생성자를 살펴보면 이런 식의 오버로딩 메소드들은 하위 호환성을 유지하거나 API 사용자에게 편의를 더하는 등의 여러 가지 이유로 만들어진다. 하지만! 뭐든간에 중복이라는 결과는 같다. 파라미터 이름과 타입이 계속 반복되어도 약간의 기능들이 전부 달라지기 때문에 해당 함수의 이름이 같아도 관련 기능들에 대해 같은 이름의 함수라도 일일이 각가의 역할을 설명해주어야 하는 번거로움이 생기게 된다.
그리고 인자 중 일부가 생략된 오버로드 함수를 호출할 때 어떤 함수가 불릴지 모호한 경우가 생긴다.
코틀린에서는 함수 선언에서 파라미터의 default값을 설정할 수 있으므로 이런 오버로드 중 상당수를 피할 수 있다. 디폴트 값을 사용해 joinToString 함수를 개선해보자.
fun main(args: Array<String>) {
val list = listOf("first", "second", "fifth")
println(joinToString(list, ", ", "", "")) // first, second, fifth
println(joinToString(list)) // first, second, fifth
println(joinToString(list, "; ")) // first; second; fifth
}
fun <T> joinToString(
collection: Collection<T>,
separator: String =", ",
prefix: String = "",
postfix: String = ""
) : String {
val result = StringBuilder(prefix)
for ((idx, ele) in collection.withIndex()) {
if (idx > 0) result.append(separator)
result.append(ele)
}
result.append(postfix)
return result.toString()
}
일반 호출 문법(위 코드에서 joinToString 함수를 호출하는 부분)을 사용하려면함수를 선언할 때와 같은 순서로 인자를 지정해야 한다. 그런 경우 일부를 생략하면 뒷부분의 인자들이 생략된다. 이름 붙인 인자를 사용하는 경우에는 인자 목록의 중간에 있는 인자를 생략하고 지정하고 싶은 인자를 이름을 붙여서 순서와 관계없이 지정할 수 있다.
private val list = listOf("first", "second", "fifth")
fun main(args: Array<String>) = println(joinToString(list,
postfix=";",
prefix="# "))
// # first, second, fifth;
여기서 중요한 것은 default 파라미터 값은 함수를 호출하는 쪽이 아니라 함수 선언 쪽에서 지정된다는 사실을 기억해야 한다. 즉 어떤 클래스 안에 정의된 함수의 디폴트 값을 바꾸고 그 클래스가 포함된 파일을 recomplie 하면 그 함수를 호출하는 코드 중에 값을 지정하지 않은 모든 인자는 자동으로 바뀐 디폴트 값을 적용받는다.
참고로 자바에서는 default 파라미터 값이라는 개념이 없어서 코틀린 함수를 자바에서 호출하는 경우에는 그 코틀린 함수가 default 파라미터값을 제공하더라도 모든 인자를 명시해야 한다. 만약에 자바에서 코틀린 함수를 자주 호출해야 한다면, 자바 쪽에서 좀 더 편하게 코틀린 함수를 호출하고 싶을텐데, 이 때
@JvmOverloads
애노테이션을 함수로 추가할 수 있다.@JvmOverloads
을 추가하면 코틀린 컴파일러가 자동으로 맨 마지막 파라미터로부터 파라미터를 하나씩 생략한 overloading한 자바 메소드를 추가해준다. 즉 편리성이 극대화 된다. 이러한 기능이 필요한 이유는 kotlin과 java의 컴파일될 때의 차이점이 있다. 만약에 자바코드를 작성하면서 kotlin 코드를 끌어다 썼다고 해보자. 이때 java 코드가 바이트코드로 컴파일될 때 미리 만들어진 kotlin .class 파일과 함께 컴파일 된다.@JvmOverloads
을 사용했다면, JVM에서 오버로드 할 수 있는 메소드로 컴파일되었기 때문에 @@JvmOverloads
애노테이션을 사용하여 코드 작성에 따른 오류를 사전에 방지할 수 있다.