코틀린, 어떻게 변했을까? #1– 1.3~1.4

MJ Studio
MJ Studio
Published in
12 min readMar 31, 2022

코태식이 돌아왔구나… 코틀린 1.3. 부터 변화를 빠르게 살펴보자.

코틀린은 놀랍게도 제가 가장 좋아하는 언어 중 하나입니다. 개발하는데 이만한 언어가 없습니다. Static typing에 함수형지원도 좋고 Java와도 JVM에서 호환이 되기 때문입니다. 문법도 굳입니다. 아님말고

Jetbrains의 관리하에 개발이 되는 언어다 보니 코드를 작성하는 방법도 일관적이고 IDE도 딱 정해져있으며 발전도 빠릅니다. JavaScript 같은 언어는 문법 하나 추가되려면 무슨 단계를 거쳐서 몇년동안 질질 끌리는 것과는 다른 양상입니다.

저도 코틀린을 안다뤄본지 1년이 넘어서 제가 개발을 하던 때 느낌상 1.3.x 였던것같은데 그동안 있었던 일들을 살펴봅니다.

중요한 문법적 변화/개선들만 다룰 것이고 IDE나 Multiplatform등 코틀린 전반적인 발전에 대해서는 웬만하면 따로 언급하지 않겠습니다.

물론 모든 내용은 코틀린 공식문서의 What’s new in Kotlin 섹션을 참고합니다.

Kotlin 1.3

Contracts

코틀린 컴파일러의 자동 형변환은 똑똑하지만 타입 검사가 다른 함수에서 이루어지면 제대로 동작을 안했습니다.

fun String?.isNotNull(): Boolean = this != null  fun foo(s: String?) {     
if (s.isNotNull()) s.length // No smartcast :(
}

그러나 1.3에서는 contracts 를 이용해 가능해집니다.

fun require(condition: Boolean) {
// This is a syntax form which tells the compiler:
// "if this function returns successfully, then the passed 'condition' is true"
contract { returns() implies condition }
if (!condition) throw IllegalArgumentException(...)
}

fun foo(s: String?) {
require(s is String)
// s is smartcast to 'String' here, because otherwise
// 'require' would have thrown an exception
}

require 함수의 contract 람다를 보면 implies 키워드를 이용해 이 함수에서 반환되는 값은 condition 조건을 의미하게됩니다.

따라서 foo 함수에서 s is String 이란 조건을 검사하면 foo 함수안에서도 sString 으로 판단됩니다. 그렇지 않다면 require 함수에서 Exception을 반환하기 때문입니다.

다른 예시를 보자면,

fun synchronize(lock: Any?, block: () -> Unit) {
// It tells the compiler:
// "This function will invoke 'block' here and now, and exactly one time"
contract { callsInPlace(block, EXACTLY_ONCE) }
}

fun foo() {
val x: Int
synchronize(lock) {
x = 42
}
println(x)
}

synchronize 함수에서는 block 람다가 단 한번 호출된다고 컴파일러에게 contract를 통해 알려줍니다.

foo 함수에서의 val x를 봅시다. val이기 때문에 무조건 한 번만 초기화가 되어야 합니다. synchronize 함수에 전달된 trailing lambda가 단 한번 호출되는 것이 보장되어있기 때문에 x는 초기화가 된다고 알 수 있고 값이 변하지 않는다는 것을 알 수 있기 때문에 컴파일러는 두 개의 경우에 대해서 에러를 내뱉지 않습니다.

사실상 contracts는 Kotlin stdlib에서 이미 활발하게 쓰이고 있습니다. 우리가 직접 함수를 만들어 contract 문법을 써 Custom contracts를 만들 수도 있지만, 아직 expermental 한 feature 라고 합니다.

예를 들어 stdlib의 isNullOrEmpty 함수는 조건이 false가 반환될 시 이녀석은 문자열이다! 라고 판단을 해주기 때문에 다음과 같은 사용이 가능합니다.

fun bar(x: String?) {
if (!x.isNullOrEmpty()) {
println("length of '$x' is ${x.length}")
// Yay, smartcast to not-null!
}
}

when 식에서 변수를 캡쳐하기

when 식의 subject에 바로 변수를 할당하고 사용할 수 있게 되었습니다. 여기에 선언된 변수는 scope가 when 식에 종속되기 때문에 namespace 충돌 문제를 예방합니다.

fun Request.getBody() =
when (val response = executeRequest()) {
is Success -> response.body
is HttpError -> throw HttpException(response.status)
}

인터페이스의 컴패니언의 @JvmStatic@JvmField

@JvmStatic@JvmField는 각각 Java와의 Interoperability를 구현하기 위해 존재하는 어노테이션들입니다. 코틀린에 정의된 애들을 자바에서 부를 수 있게 만들어 주는 어노테이션들입니다.

이 둘에 관해서 궁금하시면 다음과 같은 문서를 참고하시면 됩니다.

이것들을 이제 interfacecompanion object에서도 사용할 수 있게 되었다고 합니다.

// kotlin
interface
Foo {
companion object {
@JvmField
val answer: Int = 42

@JvmStatic
fun sayHello() {
println("Hello, world!")
}
}
}
//java
interface Foo {
public static int answer = 42;
public static void sayHello() {
// ...
}
}

자바로 위와 같은 표현입니다.

Kotlin 1.4

여기서부턴 더 재미있는 문법 변화/개선들이 많습니다.

인터페이스 SAM 변환

SAM이란 Single Abstract Method의 준말로 하나의 명령(보통 함수)를 수행하기 위해 쓰입니다. 다음과 같은 문법입니다. 아래 스니펫에선IntPredicate는 Java에서의 인터페이스라고 보시면 됩니다.

val isEven = IntPredicate { it % 2 == 0 }

원래 자바에서 정의된 메소드와 인터페이스를 코틀린에서 간편하게 사용할 수 있게 도와주는 문법이였는데, 이제 코틀린에서도 fun modifier를 붙여 인터페이스를 정의하면 쓸 수 있다고 합니다.

fun interface IntPredicate {
fun accept(i: Int): Boolean
}
val isEven = IntPredicate { it % 2 == 0 }

명명된 인자 위치의 자유로움

이전까진 명명된 인자들을 쓰려면 이름없이 호출되는 인자들을 호출의 앞에 먼저 두고 그 뒤에 사용할 수 밖에 없었습니다.

f(1, y = 2) 는 됐지만, f(x = 1, 2) 는 안되는 식이였습니다.

하지만 코틀린 1.4부터는 인자들이 올바른 위치에 있다면 명명된 인자들이 선언의 중간에 들어가도 문제가 없어졌습니다.

fun reformat(
str: String,
uppercaseFirstLetter: Boolean = true,
wordSeparator: Char = ' '
) {
// ...
}

//Function call with a named argument in the middle
reformat("This is a String!", uppercaseFirstLetter = false , '-')

Trailing 콤마

매개변수와 인자나 when식이나 자료형 destructuring 에서 제일 뒤에 trailing comma를 붙일 수 있습니다.

fun reformat(
str: String,
uppercaseFirstLetter: Boolean = true,
wordSeparator: Character = ' ', //trailing comma
) {
// ...
}
val colors = listOf(
"red",
"green",
"blue", //trailing comma
)

Callable reference 개선

흔히 함수형 변수라고도 불리는 callable 참조를 다루는 방법들이 개선되었습니다.

  • default 인자가 있는 함수의 참조
  • Unit을 반환하는 함수의 참조
  • vararg 를 사용하는 인자가 있는 함수의 참조
  • suspend 함수의 참조

when식에서 루프 탈출

이전에는 when식에서 루프를 unqualified 된 break와 continue로는 탈출할 수 없었습니다.

하지만 1.4부터는 가능해졌습니다.

fun test(xs: List<Int>) {
for (x in xs) {
when (x) {
2 -> continue
17 -> break
else -> println(x)
}
}
}

IDE — 코루틴 디버거

코틀린 1.4전에 코루틴을 디버깅하는 것은 고통이였습니다. 코루틴은 threads 간을 건너뛰며 동작하기 때문입니다.

kotlinx-coroutines-core 의 1.3.8 버전 이후로 디버깅이 작동합니다.

Debug Tool Window는 이제 Coroutines 탭을 가집니다. 이 탭에서 현재 동작되고 있거나 suspend 된 코루틴들을 살펴볼 수 있습니다.

코루틴들은 동작되고 있는 dispatcher 들로 묶입니다.

  • 각 코루틴들의 상태를 확인할 수 있습니다.
  • 코루틴 내의 지역 변수나 캡쳐된 변수들을 확인할 수 있습니다.
  • 전체적인 코루틴의 만들어지는 과정을 볼 수 있고 코루틴 내의 콜스택을 볼 수 있습니다.

컴파일러 — 개선된 타입 추론 알고리즘

코틀린 1.4는 더욱 강력해진 타입 추론 알고리즘을 사용합니다. 이는 1.3에서도 컴파일러 옵션을 이용해 사용될 수 있었습니다.

  • 타입이 자동으로 추론되는 더욱 많은 상황들

새로운 타입 추론 알고리즘은 기존의 알고리즘에서 명시적으로 타입을 선언해줘야 했던 부분들을 생략할 수 있게 해줍니다. 예를 들어 다음과 같은 예시에서 lambda parameter it는 저절로 String?로 추론됩니다.

val rulesMap: Map<String, (String?) -> Boolean> = mapOf(
"weak" to { it != null },
"medium" to { !it.isNullOrBlank() },
"strong" to {
it != null && "^[a-zA-Z0-9]+$".toRegex().matches(it) }
)
  • lambda의 마지막 반환식의 타입 추론 개선

코틀린 1.3에서는 명시적으로 타입을 명시하지 않으면 마지막 표현식의 타입이 제대로 추론되지 않았습니다.

그러나 이제 아래와 같은 코드에서 str 이 자동으로 String으로 추론됩니다.

val result = run {
var str = currentValue()
if (str == null) {
str = "test"
}
str // the Kotlin compiler knows that str is not null here
}
// The type of 'result' is String? in Kotlin 1.3 and String in Kotlin 1.4
  • callable reference의 스마트 캐스팅

코틀린 1.4부터 타입 검사 이후에 서브타입의 멤버들에 접근할 수 있습니다.

fun perform(animal: Animal) {
val kFunction: KFunction<*> = when (animal) {
is Cat -> animal::meow
is Dog -> animal::woof
}
kFunction.call()
}
  • 자바 interface의 인자가 다른 SAM 변환

이미 있는 Java의 라이브러리를 사용할 때, Java의 메서드가 두 개의 SAM interface를 인자로 받으면 인자들은 lambda나 regular object 둘 중에 하나만 통일되어야 합니다.

코틀린 1.4에선 이것이 가능해집니다.

// FILE: A.java
public class A {
public static void foo(Runnable r1, Runnable r2) {}
}
// FILE: test.kt
fun test(r1: Runnable) {
A.foo(r1) {} // Works in Kotlin 1.4
}

--

--