kotlin in action
을 보고 정리한 내용입니다.
아, 공부하기 싫다.
공부하기 싫으니까 섬네일은 2장 주세요.
collection은 코틀린뿐만 아니라 여러 프로그래밍 언어에서 사용하는 개념이다. 우리는 여러 개의 객체들의 집합을 collection이라고 부르며 각 언어의 standard library를 통해 각 객체들을 가져오거나 변형하거나 하는 작업을 수월하게 수행할 수 있다.
코틀린에는 세가지 타입의 collection이 있다.
List
는 순서가 정해져 있는 ordered collection으로 자바스크립트의 배열과 같이 index로 list의 value에 접근할 수 있다.
Set
은 유일한 값이 보장되는 collection으로 수학에서 사용되는 집합과 의미가 비슷하다. set에서 순서는 중요하지 않으며, 마치 당첨된 로또 번호는 유일한 값이어야 하지만 순서는 상관없는 것과 마찬가지이다.
Map
은 key-value pairs로 이뤄져 있는 collection으로 key-value는 일대일 대응이어야 하므로 키 값은 한 map collection 내부에서 유일해야 한다.
kotlin.collections 패키지에는 여러 모듈들이 정의 되어 있는데 여기에 정의되어 있는 클래스와 함수를 사용할 것이다.
val set = hashSetOf(1,2,3)
val list = arrayListOf(1,2,3)
val map = hashMapOf(1 to "one", 2 to "two", 3 to "three")
println(set.javaClass) // class java.util.HashSet
println(list.javaClass) // class java.util.ArrayList
println(map.javaClass) // class java.util.HashMap
코틀린의 collection은 자바의 standard collection class를 사용하는 것을 확인할 수 있는데, 자바의 collection class를 알고 있다면 kotlin의 collection을 다시 공부해야 할 수고를 덜 수 있다.
자바의 collection 에는 toString 함수가 기본적으로 구현되어 있다.
val list = listOf(1, 2, 3)
println(list) // [1, 2, 3]
list를 [1,2,3] 이 아닌 다른 포메팅으로 출력할 수 있는 함수를 만들어보자.
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()
}
타입스크립트를 아는 사람이면 TS문법과 비슷하다는 것을 느낄 수 있는데
인자 collection의 타입에는 제너릭 <T>
를 사용해 함수 호출부에서 collection 타입을 임의로 지정할 수 있게 했다.
val list = listOf(1,2,4)
println(joinToString(list, "; ", "(", ")")) // (1; 2; 3)
joinToString을 호출할 때 4개의 인자를 알맞은 인자에 넣어야 하기 때문에 제 3자가 코드를 볼 때 함수 시그니처가 어떻게 이뤄져 있는지 해석하기 어려울 수 있다.
코틀린에서는 함수 인자에 이름을 명시하여 가독성을 올릴 수 있다.
joinToString(collection, separator = " ", prefix = " ", postfix = ".")
javascript와 동일하게 default parameter를 지정할 수 있다.
java에서는 standard library에서는 지원하지 않았던 기능이다.
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()
}
함수 호출부에서는 default parameter가 지정되어 있는 인자를 생략할 수 있다. 다만 이 때 n 번쨰 인자를 생략하지 않고 명시적으로 넣기 위해서는 n-1번째 까지의 인자 또한 명시적으로 넣어줘야 한다.
>>> joinToString(list, ", ", "", "")
1, 2, 3
>>> joinToString(list)
1, 2, 3
>>> joinToString(list, "; ")
1; 2; 3
default value는 함수 호출부가 아니라 함수 선언부에서 지정되기 때문에 함수 선언부의 default parameter를 수정한다면 인자를 명시하지 않은 함수의 호출 부분의 값은 당연하게도 변경되게 된다.
java에서는 모든 함수들은 class 내부에 선언되어야 했기 때문에 javascript와는 다르게 global 하게 사용되는 어떤 유틸성 함수라도 class의 method로 생성되어야 했다.
코틀린에서는 소스 파일 내부에 함수를 선언하여 다른 파일에서 해당 파일의 패키지를 import해서 사용할 수 있다.
// join.kt
package strings
fun joinToString(...): String { ... }
이렇게 만들어진 함수를 다른 파일에서는 import strings.joinToString
를 통해 사용할 수 있다.
JVM은 class만 실행할 수 있기 때문에 코틀린 파일이 컴파일 될 때 위와 같은 top-level functions는 class로 변환되는데 그 때의 모습은 아래와 같다.
package strings;
public class JoinKt {
public static String joinToString(...) { ... }
}
이 때 만들어진 class의 이름은 파일 이름 + 확장자로 명명됨을 알 수 있는데 만들어지는 class의 이름을 @JvmName
annotation을 이용해 변경할 수 있다.
// join.kt
@file:JvmName("StringFunctions")
package strings
fun joinToString(...): { ... }
java 파일에서 해당 패키지를 임포트해서 사용할 때는 다음과 같이 JvmName annotation으로 명명된 class의 이름을 사용해야 한다.
import strings.StringFunctions
StringFunctions.joinToString(list, ", ", "", "");
근데 내가 자바를 사용할 일이 있을까?
소스 파일 내부에 함수와 동일하게 변수를 선언할 수 있다.
예를 들어 특정 파일에 유일한 값들을 모어둔다고 하자
val UNIX_LINE_SEPERATOR = "\n";
이러한 top level properties 들은 java code에 getter/setter accessor들로 접근이 가능하며 (val의 경우 getter만), java의 public static final
field로 사용하고 싶다면
const
키워드를 사용하면 된다.
const val UNIX_LINE_SEPERATOR = "\n";
public static final String UNIX_LINE_SEPARATOR = "\n";
extension function은 클래스의 멤버 메소드를 클래스 외부에서 정의하는 것을 말한다.
string의 last character을 가져오는 extension function을 정의해보자.
package strings
fun String.lastChar(): Char = this.get(this.length - 1)
본 글에서는 A
class나 interface에 extension function을 만드는 것을 A에 함수를 확장
한다고 하겠다.
함수를 확장하기 위해서는 확장함수 앞에 확장 대상인 class, interface를 붙여주면 된다. 이때 확장 대상인 class를 receiver type
이라고 하며 this는 receiver type의 instance를 가르키는데 receiver object
라고 한다.
println("Kotlin".lastChar())
위의 코드에서 receiver type은 String이며 receiver object는 "Kotlin"이다.
extension function을 통해 String class를 직접 정의하지 않는 사용자도 String class의 메소드를 정의해 사용할 수 있게 된다.
코틀린으로 작성된 언어에만 국한된 피쳐가 아니라 JVM language로 작성된 class라면 extension function을 만들 수 있다.
앞서 봤던 코드의 receiver object를 생략할 수 있는데 다음과 같다.
package strings
fun String.lastChar(): Char = get(length - 1)
extension function은 receiver type의 method와 properties를 직접 접근할 수 있는데 그렇다고 캡슐화를 파괴하는 것은 아니다.
class 내부에서 선언된 메소드와는 다르게 extension function은 private, protected 필드에 접근할 수 없다.
extension function을 사용하기 위해서는 다른 top level function을 임포트 하는것과 동일하게 임포트해야 한다.
임포트 하는 방법은 다음과 같이 다양하다.
import strings.lastChar
val c = "Kotlin".lastChar()
import strings.*
val c = "Kotlin".lastChar()
import strings.lastChar as last
val c = "Kotlin".last()
extension function은 top level function과 동일하게 static method로 컴파일된다.
다음은 extension function을 stringUtil.kt 파일에 정의했다고 가정했을때 java에서는 다음과 같이 사용할 수 있다.
//java
char c = StringUtilKt.lastChar("Java");
앞서 만든 joinToString을 수정했다. 일반적인 Kotlin standard library의 함수는 다음과 같은 모양으로 작성되어 있다고 볼 수 있다.
fun <T> Collection<T>.joinToString(
separator: String = ", ",
prefix: String = "",
postfix: STring = ""
): String {
val result = StringBuilder(prefix)
for((index, element) in this.withIndex()) {
if (index > 0) result.append(separator)
result.append(element)
}
result.append(postfix)
return result.toString()
}
val list = listOf(1,2,3)
println(list.joinToString(seperator = "; ", prefix = "(", postfix = ")")
/// (1; 2; 3)
collection의 extension function으로 joinToString 함수를 만들었다. default parameter에 대한 설명은 생략하겠다.
위에서 this가 사용되는 부분이 있는데 this는 앞서 말했듯이 receiver object를 참조하며 여기서는 T 타입의 instance를 의미한다.
extension function은 static method의 syntactic sugar인데, 다음과 같이 T 타입을 String 타입으로 제약할 수 있다.
fun Collection<String>.join(
separator: String = ", ",
prefix: String = "",
postfix: String = "",
) = joinToString(separator, prefix, postfix)
>>> println(listOf("one", "two", "eight").join(" "))
// one two eight
>>> listOf(1, 2, 8).join()
Error: Type mismatch: inferred type is List<Int> but Collection<String> was expected.
extension function을 대상으로는 overriding을 할 수 없다.
View class와 서브 클래스인 Button class가 있다고 하자.
open class View {
open fun click() = println("View clicked")
}
class Button: View() {
override fun click() = print("Button clicked")
}
특정 변수를 View 타입으로 정의한다면, Button class는 View의 서브 클래스이기 때문에 해당 변수에 선언 가능하다.
이 때 click은 regular method이므로 Button에 override된 onclick이 호출된다.
val view: View = Button()
view.click()
그러나 다음과 같이 class 외부에서 정의된 extension function의 경우, 변수 view.showOff는 Button이 아닌 View의 extension function을 호출한다.
fun View.showOff() = println("I'm a view!")
fun Button.showOff() = println("I'm a Button!");
val view: View = Button()
view.showOff()
// I'm a view!
만약 member function과 extension function이 동일한 signature를 가지고 있다면 member function의 호출이 우선된다.
extension properties는 properties라는 이름으로 불리지만 어떤 상태를 담지는 못한다. 클래스 외부에서 상태를 가지는 것은 side effect를 불러일으키기 때문에 좋은 방식이 아니기 때문이다.
따라서 java objects에 extra fields를 만드는 것은 불가능하다.
여기서 말하는 extension properties는 function 문법 보다는 간결하게 getter 및 setter에 접근하는 것이라고 이해하는 것이 옳다.
extension properties를 정의할 때는 setter는 선택적이나 getter는 꼭 존재해야 한다.
val String.lastChar: Char
get() = get(length-1)
다음의 StringBuilder.lastChar는 val이 아닌 var로 선언했기 때문에 setter 선언이 가능하다.
var StringBuilder.lastChar: Char
get() = get(length-1)
set(value: Char) {
tihs.setCharAt(length - 1, value)
}
println("Kotlin".lastChar)
// n
val sb = StringBuilder("Kotlin?")
sb.lastChar = "!"
println(sb)
// Kotlin!
코틀린 collection에는 다양한 standard library가 있다.
val strings: List<String> = listOf("first", "second", "fourteenth")
strings.last()
// fourteenth
max, last는 extension function으로 declare된 것이다.
다양한 standard library는 extension function으로 declare되어있다.
var list = listOf(2, 3, 5, 7, 11)
listOf 함수는 어떻게 정의되어 있길래 여러개의 인자를 받을 수 있는 것일까?
fun listOf<T>(vararg values: T): List<T> { ... }
자바에서는 ...
spread operator를 통해 함수 시그니처에 여러 개의 인자를 선언하지만 코틀린에서는 vararg modifier를 통해 여러 개의 인자를 선언한다.
함수 호출부에서는 어떨까
fun main(args: Array<String>) {
val list = listOf("args: ", *args)
println(list)
}
...
대신spread operator *
키워드를 spread operator로 사용한다.
val map = mapOf(1 to "one", 7 to "seven", 53 to "fifty-three")
to
는 infix call이라고 불린다. method가 target object와 parameter 사이에 자동으로 들어간다. 1.to("one")
과 1 to "one"
는 동일한 의미이다.
호출하는데 parameter 가 필요한 regular method나 extension function의 경우 infix notation을 지정해서 사용할 수 있다.
어떻게 선언하는지 살펴보자
infix fun Any.to(other: Any) = Pair(this, other)
infix fun Any.why(other: Any) = Pair(this, other)
val (number, name) = 1 to "one"
위와 같이 number, name에 각각 1과 "one"으로 initialization을 했는데 이러한 문법을 destructuring declaration이라고 한다. Pair에만 한정되어서 사용할 수 있는 것이 아니고 다음과 같이 key-value를 가져올 수 있는 상황에서도 사용된다
for ((index, element) in collection.withIndex()) {
println("$index: $element")
}