코틀린 1.1 ~ 1.7 버전별 언어/표준라이브러리 차이점 간략정리

kyung.hoon.min
NAVER Pay Dev Blog
Published in
46 min readSep 5, 2022

안녕하세요, 네이버 파이낸셜에서 후불결제 BE 개발 업무를 담당하고 있는 민경훈 입니다.

저희팀에서는 자체 과제로 올해 2월 코틀린의 버전을 1.4.2 에서 1.6.1로 버전업을 했었는데요, 그때 정리했었던 코틀린 각 버전별 차이점을 올려봅니다.

덧붙여서 후불결제에서는 이번 정기배포에서 1.6.1 → 1.7.1 으로 버전업을 계획하고 있기에, 코틀린 1.7.0 버전에 대한 내용도 한눈에 볼 수 있도록 추가적으로 뒷부분에 같이 정리 합니다.

Kotlin 의 오늘자 최신 버전은 1.7.20 RC 버전입니다 (출처 : 코틀린 공식 홈페이지)

정리하면서 내용이 길어져 코틀린 Native/JS 및 코루틴 부분은 의도적으로 제외하였으니 보실 때 참고 부탁드립니다. 요약 정리한 내용이라 경어체를 사용하지 않았으니 양해 부탁 드립니다.

코틀린 1.1

타입 별명 (typealias)

기존 타입에 별명을 만드는 기능으로 typealias 라는 키워드가 추가되었다.

// 콜백 함수 타입에 대한 타입 별명
typealias MyHandler = (Int, String, Any) -> Unit
// MyHandler 를 받는 고차 함수
fun addHandler(h: MyHander) { .. }
// 컬렉션의 인스턴스에 대한 타입 별명
typealias Args = Array<String>
fun main(args: Args) { ... }// 제네릭 타입 별명
typealias StringKeyMap<V> = Map<String, V>
val myMap: StringKeyMap<Int> = mapOf("One" to 1, "Two" to 2)
// 중첩 클래스
class Foo {
class Bar {
inner class Baz
}
}
typealias FooBarBaz = Foo.Bar.Baz

주의할점은

  • 아직까진 최상위 수준에서만 타입 별명을 정의할 수 있다.
  • 제네틱 타입 별명에 변성을 지정할 수 없다.

봉인 클래스와 데이터 클래스 (sealed, data)

봉인 클래스의 하위 클래스를 봉인 클래스 내부에 정의할 필요가 없고, 같은 소스 파일 안에만 정의하면 된다.

또한 데이터 클래스로 봉인 클래스를 확장하는 것도 가능해졌다.

sealed class Expr
data class Num(val value: Int) : Expr()
data class Sum(val left: Expr, val right: Expr) : Expr()
fun eval(e: Expr) : Int =
when (e) {
is Num -> e.value
is Sum -> eval(e.right) + eval(e.left)
}
fun main(args: Array<String>) {
val v = Sum(Num(1), Sum(Num(10, Num(20))
println(v)
println(eval(v))
}
// 결과
Sum(left=Num(value=1), right=Sum(left=Num(value=10), right=Num(value=20)))
31

데이터 클래스로 봉인 클래스를 상속하는 경우 toString() 메서드가 자연스럽게 계층 구조의 값을 출력해 준다는 장점이 있다.

바운드 멤버 참조 (bound member reference)

멤버 참조를 생성할 때 클래스 인스턴스를 함께 저장한 다음 나중에 그 인스턴스에 대해 멤버를 호출해준다. 따라서 호출 시 수신 대상 객체를 별도로 지정해 줄 필요가 없다.

val p = Person("Dmity", 34)
val personsAgeFunction = Person::age
println(personsAgeFunction(p))
// 34
val dmitrysAgeFunction = p::age // 바운드 멤버 참조
println(dmitrysAgeFunction()) // 별도의 수신 대상 객체를 지정해줄 필요가 없음
// 34

람다 파라미터에서 구조 분해 사용

구조 분해 선언을 람다의 파라미터 목록에서도 사용할 수 있다.

val nums = listOf(1,2,3)
val names = listOf("One", "Two","Three")
(nums zip names).forEach { (num, name) -> println("${num} = ${name}") }
// result
// 1 = One
// 2 = Two
// 3 = Three

밑줄(_)로 파라미터 무시

람다를 정의하면서 사용하지 않는 파라미터가 있다면 _을 그 위치에 넣으면 따로 파라미터 이름을 붙이지 않고 람다를 정의할 수 있다. 구조 분해시에도 마찬가지로 사용가능하다.

data class YMD(val year: Int, val month: Int, val day: Int)
typealias YMDFUN = (YMD) -> Unit
fun applyYMD(v: YMD, f: YMDFUN) = f(v)
val now = YMD(2017, 10, 9)
val 삼일운동 = YMD(1919, 3, 1)
val (삼일운동이_일어난_해, _, _) = 삼일운동 // 1919
applyYMD(now) { (year, month, _) -> println("year = ${year}, month = ${month}") }
// year = 2017, month = 10

식이 본문인 게터만 있는 읽기 전용 프로퍼티의 타입 생략

읽기 전용 프로퍼티를 정의할 때 게터의 본문이 식이라면 타입을 생략해도 컴파일러가 추론해준다.

data class Foo(val value: Int) {
val double
get() = value * 2
}
val foo = Foo(10)
println(foo.double.javaClass.name) // int

프로퍼티 접근자 인라이닝

프로퍼티 접근자도 함수이므로 inline을 사용할 수 있다. 게터, 세터, 일반 멤버 프로퍼티, 확장 멤버 프로퍼티, 최상위 프로퍼티도 인라인 가능하다.

그러나 한가지 제약은 프로퍼티에 뒷받침하는 필드(backing field)가 있다면 인라이닝 할 수 없다.

val toplevel: Double
inline get() = Math.PI // getter 인라이닝
class InlinePropExample(var value: Int) {
var setOnly: Int
get() = value
inline set(v) { value = v } // setter 인라이닝
// 컴파일 오류: Inline property cannot have backing field
val backing: Int = 10
inline get() = field * 1000
}
// 확장 멤버 프로퍼티 getter, setter 인라이닝
inline var InlinePropExample.square: Int
get() = value * value
set(v) { value = Math.sqrt(v.toDouble()).toInt() }

제네릭 타입으로 enum 값 접근 (generic enum)

제네릭 파라미터로 이넘의 모든 값을 이터레이션하는 경우 reified 타입을 활용해 제네릭하게 접근할 수 있다.

enum의 값을 가져온다면:

inline fun <reified T: Enum<T>> enumValues(): Array<T>

역으로 이름에서 값을 가져온다면:

inline fun <reified T: Enum<T>> enumValueOf(name: String): T

를 사용한다.

enum class DAYSOFWEEK { MON, TUE, WED, THR, FRI, SAT, SUN }
inline fun <reified T: Enum<T>> mkString(): String =
buildString {
for (v in enumValues<T>()) {
append(v)
append(",")
}
}
fun main(args: Array<String>) {
println("${mkString<DAYSOFWEEK>()}")
}
// MON,TUE,WED,THR,FRI,SAT,SUN,

DSL의 수신 객체 제한

수신 객체 지정 람다를 사용한 빌더 DSL에서 내부 람다는 외부 람다의 묵시적 수신 객체에 정의된 메서드를 자유롭게 사용할 수 있다. 어휘 규칙에 따라 내부 람다의 this에 정의되지 않은 메서드를 외부 람다의 this에서 찾기 때문이다. 이것이 유용할때도 있지만, 사용해서는 안되는 메서드까지 사용할 수 있는 경우가 생긴다.

만약 다음과 같은 HTML 빌더 DSL가 있다고 해보자

table {
tr {
tr { ... }
}
}

tr안에 tr을 사용할 수 있게된다. 1.1 버전 부터는 @DslMarker 를 사용해 이를 해결한다. 어노테이션이 적용되면 묵시적으로 사용가능한 타입 객체가 여럿 있는 경우 안쪽에서만 사용가능하고, 나머지는 this@레이블 형태로 명시적으로 사용해야 한다.

아래 예시는 @DslMarker를 적용한 Tag 클래스를 사용한 코드를 보여준다.

@DslMarker
annotation class HtmlTagMarker
@HtmlTagMarker
abstract class Tag(val name: String) { ... }
class Table(): Tag("table") { ... }
class Tr() : Tag("tr") { ... }

@DslMarker가 적용됐기 때문에 수신객체 지정 람다 안에서는 맨 안쪽의 묵시적 this만 사용할 수 있다.

table {
tr {
tr { ... } // 컴파일 안됨!
this@table.tr { ... } // 명시적으로는 가능
}
}

로컬 변수 등을 위임 (Delegated properties)

클래스나 객체의 프로퍼티 외에 로컬 변수나 최상위 수준의 변수에도 위임을 사용할 수 있다.

import kotlin.reflect.KPropertyclass BarDelegate {
operator fun getValue(
thisRef: Any?, property: KProperty<*>
): Int {
println("thisRef = ${thisRef}")
println("property.name = ${property.name}")
return 100
}
}
val y: Int by BarDelegate() // 최상위 수준 변수에 위임 사용fun main(args: Array<String>) {
val x: Int by BarDelegate() // 로컬 변수에 위임 사용
println("x = ${x} y = ${y}")
}
/*
thisRef = null
property.name = x
thisRef = null
property.name = y
x = 100 y = 100
*/

위임 객체 프로바이더

provideDelegate() 연산자를 제공하는 객체를 by 다음에 쓰면 매번 새로운 위임 객체를 만들어내서 사용할 수 있다.

provideDelegate() 안에는 적절한 검증을 하거나 상황에 맞는 적절한 프로퍼티 위임 객체를 제공하는 작업을 수행할 수 있다.

아래 코드는 디버그 모드일때는 mock 서버에 연결하고, 프로덕션 모드일 때는 실제 서버를 연결해서 JSON문자열을 가져오는 예제이다.

interface ConfigServer {
fun getConfig(section: String): String
}
class RealConfigService(val addr: String): ConfigServer {
override fun getConfig(section: String): String
= "${addr}에서 읽은 JSON"
}
class MockConfigService(val addr: String): ConfigServer {
override fun getConfig(section: String): String
= "테스트 JSON"
}
class ConfigServiceDelegateProvider(
val server: String,
val port: Int = 8880,
debug: Boolean = false) {
val service: ConfigService;
init {
service = if (debug)
MockConfigService(server + ":" + port)
else
RealConfigService(server + ":" + port)
}
operator fun provideDelegate(
thisRef: RemoteConfig,
prop: KProperty<*>): ReadOnlyProperty<RemoteConfig, String> {
if (prop.name == "tomcat") {
return object: ReadOnlyProperty<RemoteConfig, String> {
override fun getValue(
thisRef: RemoteConfig,
property: KProperty<*>): String {
return service.getConfig(prop.name)
}
}
} else if (prop.name == "httpd" || prop.name == "apache") {
... // 비슷한 코드
} else throw IllegalArgumentException("...")
}
}
class RemoteConfig(val debug: Boolean) {
val tomcat by ConfigServiceDelegateProvider(
"111.111.11.1",
debug=this.debug
)
val httpd by ConfigServiceDelegateProvider(
"111.111.11.1",
debug=this.debug
)
}

mod와 rem

mod대신 rem이 % 연산자로 해석된다.

data class V(val value: Int) {
infix operator fun rem(other: V) = V(10)
infix operator fun mod(other: V) = V(-10)
}
val x = V(5)
val y = V(7)
val r1 = x % y // V(10)
val r2 = x mod y // V(-10)
val r3 = x rem y // V(10)

코틀린 1.1 표준 라이브러리 변화

문자열-숫자 변환

문자열을 숫자로 변환할때 예외를 던지는 대신 Null을 돌려주는 메서드가 추가됐다.

val port = System.getenv("PORT")?.toIntOrNull() ?: 80

위처럼 예외 처리 없이 널 처리로 디폴트 값을 제공할 수 있다.

onEach()

컬렉션과 시퀀스에 onEach 확장 함수가 생겼다. forEach와 비슷하지만 다시 컬렉션이나 시퀀스를 반환하기 때문에 체이닝이 가능하다.

listOf(1,2,3,4,5)
.onEach { println("${it}") }
.map { it*it }
.joinToString(",")

also(), takeIf(), takeUnless()

also는 apply와 비슷하지만 람다 안에서 this가 바뀌지 않기 때문에 수신 객체를 it으로 활용해야 한다는 점이 다르다.

class MyClass {
fun foo(v: OtherClass) {
...
val result = v.also {
// MyClass 의 멤버는 this를 통해 사용가능, v의 멤버는 it을 통해 사용
}.transform()
}
}
inline fun <T> T.also(block: (T) -> Unit): T {
block(this)
return this
}
inline fun <T> T.apply(block: T.() -> Unit): T {
block()
return this
}

takeIf 는 수신 객체가 술어를 만족하는지 검사해서 만족할 때 수신 객체를 반환하고, 불만족할 때 null을 반환한다. takeUnless는 takeIf의 반대이다.

val srcOrKotlin: Any = File("src").takeIf { it.exists() } ?: File("kotlin")

groupingBy()

컬렉션을 키에 따라 분류한다. 이때 Grouping이라는 타입의 값을 반환한다.

(1..100).groupingBy { it % 3 }.eachCount()
// {1=34, 2=33, 0=33}

Map.toMap()과 Map.toMutableMap()

맵을 복사할 때 사용함

val m1 = mapOf(1 to 2)
val m2 = m1.toMutableMap()
m2[10] = 100
println(m2) // {1=2, 10=100}

minOf(), maxOf()

둘 또는 세 값 중 최소, 최대 값을 구할때 사용한다. Comparator를 지정해서 비교 방법을 지정할 수도 있다.

val longest = maxOf(listOf(), listOf(10), compareBy { it.size })
// 10

람다를 사용한 리스트 초기화

Array 생성자처럼 리스트 생성자 중에서도 람다를 파라미터로 받는 생성자가 생겼다.

fun initListWithConst(v: Int, size: Int) = MutableList(size) { v }
val evens = List(10) { 2 * it } // 0 2 4 6 8 10 12 14 16 18
val thirtyZeros = initListWithConst(0, 5) // 0 0 0 0 0

코틀린 1.2

어노테이션의 배열 리터럴

이제 어노테이션에 여러 값을 배열로 넘길 때 []를 사용할 수 있다.

@RequestMapping(value = ["v1", "v2"], path = ["path", "to", "resource"])

지연 초기화(lateinit) 개선

초기화 됐는지 검사할 수 있게 됐다. 최상위 프로퍼티 및 지역 변수에도 사용가능 하다.

lateinit var url: String
... // url을 외부에서 받아 초기화
if (::url.isInitialized) {
...
}

인라인 함수의 디폴트 함수 타입 파라미터 지원

인라인 함수가 람다를 인자로 받는 경우에도 디폴트 파라미터를 사용할 수 있다.

inline fun <E> Iterable<E>.strings(
transform: (E) -> String = { it.toString() }
) = map { transform(it) }
val defaultStrings = listOf(1,2,3).strings()
val customStrings = listOf(1,2,3).strings { "($it)" }

함수/메서드 참조 개선

this::foo 에서 this를 빼고 ::foo 라고 써도 현재 맥락의 this에 맞춰 해석해준다.

이넘 원소 안의 클래스는 내부 클래스로

이넘 원소안에 클래스, 인터페이스 등 중첩 타입을 정의할 수 있었고, inner 클래스는 정의할 수 없었지만 1.2 부터는 inner로 표시된 내부 클래스만 정의가 가능하다.

enum class Foo {
BAR {
inner class Baz // 1.1에서는 불가능, 1.2에서는 가능
class Baz2 // 1.1에서는 가능, 1.2에서는 경고
}
}

1.3 부터는 아예 컴파일도 안될 예정이므로 이넘 원소안에 클래스를 정의해야 한다면 내부 클래스를 이용해야한다.

표준 라이브러리

컬렉션

컬렉션 원소를 n개씩 짝을 지어 처리할 수 있는 슬라이딩 윈도우 처리 메서드들이 추가됐다.

  • chunked/chunkedSequence: 컬렉션을 정해진 크기의 덩어리로 나눠준다.
  • windowed/windowedSequence: 정해진 크기의 슬라이딩 윈도우를 만들어서 컬렉션을 처리한다.
  • zipWithNext: 컬렉션에서 연속한두 원소에 대해 람다를 적용한 결과를 얻는다.

아래는 10개짜리 리스트에 대해 각 메서들 적용한 예제이다.

fun main(args: Array<String>) {
val l = listOf(1,2,3,4,5,6,7)
println("l = ${l}")
// 그룹
println("l.chunked(size=3)")
l.chunked(size=3).forEach { println(it) }
/*
[1, 2, 3]
[4, 5, 6]
[7]
*/
// 슬라이딩 윈도우
println("l.windowed(size=3, step=1)")
l.windowed(size=3, step=1).forEach { println(it) }
/*
[1, 2, 3]
[2, 3, 4]
[3, 4, 5]
[4, 5, 6]
[5, 6, 7]
*/
// 연속된 2 원소씩 쌍
println("l.zipWithNext")
l.zipWithNext { x,y -> println("($x,$y)") }
/*
(1,2)
(2,3)
(3,4)
(4,5)
(5,6)
(6,7)
*/
}

리스트의 원소를 처리하는 메서드도 추가돼었다.

  • fill: 모든 원소를 지정한 값으로 채운다.
  • replaceAll: 모든 원소를 람다를 적용한 결과 값으로 갱신한다. in-place map과 동일함
  • shuffle: 원소를 임의로 뒤섞는다.
  • shuffled: 뒤섞은 새 리스트를 반환한다.
val items = (1..5).toMutableList()
items.shuffle()
println(items) // [3,2,4,5,1] 매번달라짐
items.replaceAll { it * 2 }
println(items) // [6,4,8,10,2]
items.fill(5)
println(items) // [5,5,5,5,5]

kotlin.math

수학 패키지에 많은 메서드가 추가되었다.

  • 상수: PI, E
  • 삼각 함수: cos, sin, tan, acos, asin, atan, atan2
  • 하이퍼볼릭 함수: cosh, sinh, tanh
  • 지수함수: pow, sqrt, hypot, exp, expm1
  • 로그 함수: log, log2, log10, ln, ln1p
  • 올림/내림 함수: ceil, floor, truncate, round, roundToInt, roundToLong
  • 부호와 절댓값 함수: abs, sign, absoluteValue, sign, withSign
  • 값의 최댓값/최솟값: max, min
  • 부동소수점의 2진 표현: ulp, nextUp, nextDown, nextTowards, toBits, toRawBits, Double.fromBits

코틀린 1.3

코루틴과 코루틴을 활용한 비동기 프로그래밍 (async, await)이 정식 포함되었다.

컨트랙트 (Contract, 계약) (실험적 기능)

스마트캐스트 기능은 타입 검사를 통해 널 가능한 별수에 저장된 값이 널일 가능성이 없으면 자동으로 널이 될 수 없는 타입으로 캐스팅해준다.

fun foo(s: String?) {
if (s != null) s.length // 컴파일러가 s를 String으로 자동 캐스팅
}

하지만 이 검사를 함수로 분리하면 더이상 지원되지 않았다.

fun String?.isNotNull(): Boolean = this != null
fun foo(s: String?) {
if (s.isNotNull()) s.length // 자동변환 안됨..
}

코틀린 1.3부터는 컨트랙트를 이용해 이런 상황을 개선할 수 있다.

컨트랙트는 함수의 동작을 컴파일러가 이해할 수 있게 기술하기 위한 기능이다. 현재 2가지 종류의 컨트랙트가 있다.

  • 함수의 반환값과 인자 사이의 관계를 명시해서 스마트캐스트 분석을 쉽게 만들어주는 컨트랙트
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
@OptIn(ExperimentalContracts::class)
fun require(condition: Boolean) {
// 이 함수가 정상적으로 변환되면 condition이 참이다 라는 조건을 표현하는 컨트랙트
contract { returns() implies condition }
if (!condition)
throw IllegalArgumentException("illegal argument")
}
fun foo(s: String?) : Int {
require(s is String)
// s is String이라는 조건이 참이면 예외가 발생하지 않으므로
// 이하 코드에서는 자동을 s를 String으로 스마트캐스트할 수 있다.
return s.length
}
fun main(args: Array<String>) {
val count = foo("hello world")
println(count)
}
  • 고차 함수가 있을 때 컴파일러가 변수 초기화 여부 분석을 더 잘 할 수 있게 돕는 컨트랙트
@OptIn(ExperimentalContracts::class)
fun synchronize(lock: Any?, block: () -> Unit) {
// 이 함수는 block을 여기서 바로 실행하며 오직 한번만 실행한다 라는 뜻의 컨트랙트
contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
}
fun foo() {
val x: Int
// 한번만 실행되기 때문에 val 값에 값을 재대입 한다는 오류를 표시하지 않음
synchronize(lock) {
x = 42
}
println(x) // 초기화 되지 않을 수 있다는 오류를 발생시키지 않음
}

직접 커스텀 컨트랙트를 만들 수도 있지만, 아직 실험적인 기능이므로 문법은 얼마든지 바뀔 수 있다.

When의 대상을 변수에 포획

1.3 부터 when절 안에서 대상을 변수에 대입 할 수 있다.

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

이런식으로 사용하면 when 문 밖의 네임스페이스가 더럽혀지는 일을 줄일 수 있다.

함수 파라미터 수 제한 완화

1.2 까지는 함수가 파라미터를 22개까지 받을 수 있었다. 1.3부터는 255개 까지 늘어났다.

인라인 클래스 (실험적 기능) → 1.5버전 부터는 value class로 변경됨

프로퍼티가 하나뿐인 클래스를 inline 키워드를 사용해 인라인 클래스로 정의할 수 있다.

inline class Name(val s: String)

코틀린 컴파일러는 인라인 클래스에 대해 공격적으로 최적화 할 수 있다. 별도의 생성자를 만들지 않고 인스턴스 객체 대신 내부 프러퍼티 객체를 사용하게 코드를 생성하는 등 최적화가 가능하다.

fun main() {
// Name 인스턴스를 만들지 않고 "Kotlin" 이라는 문자열만 만든다.
val name = Name("Kotlin")
println(name.s) // 문자열을 바로 접근하는 코드를 만듦
}

부호 없는 정수 (실험적 기능) → 1.5버전 부터는 정식 지원 시작함

부호 없는 정수 타입이 추가되었다. 다만 사용하기 위해서는 @ExperimentalUnsignedTypes 을 사용하거나 Xopt-in=kotlin.ExperimentalUnsignedTypes 옵션을 활성화 해주어야 한다.

  • kotlin.UByte: 0 ~ 255 부호 없는 8비트 정수
  • kotlin.UShort: 0 ~ 65535 부호 없는 16비트 정수
  • kotlin.Uint: 0 ~ ²²³ -1 부호 없는 32비트 정수
  • kotlin.ULong: 0 ~ ²⁶⁴ -1 부호 없는 32비트 정수

표준 라이브러리 변화

배열 원소 복사 확장 함수 copyInto() 추가

어떤 배열의 내용을 다른 배열에 복사할 수 있다. 자바의 java.lang.System.arraycopy() 와 비슷한 동작을 한다.

fun <T> Array<out T>.copyInto(
destination: Array<T>,
destinationOffset: Int = 0,
startIndex: Int = 0,
endIndex: Int = size
): Array<T>
val sourceArr = arrayOf("k", "o", "t", "l", "i", "n")
val targetArr = sourceArr.copyInto(arrayOfNulls<String>(6), 3, startIndex = 3, endIndex = 6)
println(targetArr.contentToString()) // [null, null, null,l,i,n]
sourceArr.copyInto(targetArr, startIndex = 0, endIndex = 2)
println(targetArr.contentToString()) // [k,o,null,l,i,n]

맵 연관 쌍 추가 함수 associateWith() 추가

키:값 컬렉션을 1:1로 연관시킬 때 associate { it to getValue(it) } 를 사용할 수 있었다. associateWith 를 사용하면 더 쉽게 연관시킬 수 있다.

val keys = 'a'..'f'
val map = keys.associateWith {
it.toString().repeat(5).capitalize()
}
map.forEach { println(it) }
// a=Aaaa
// b=Bbbb
// c=Cccc
// d=Dddd
// e=Eeee
// f=Ffff

ifEmpy와 ifBlank 함수

컬렉션이 비어있는지 확인 후 특정 람다를 실행시킬 수 있다.

fun printAllUppercase(data: List<String>) {
val result = data
.filter { it.all { c -> c.isUpperCase() } }
.ifEmpty { listOf("<대문자로만 이뤄진 원소 없음>") }
result.forEach { println(it) }
}

코틀린 1.4

Kotlin 인터페이스 용 SAM 변환

Kotlin 1.4.0 이전에는 자바 메소드 및 자바 인터페이스로 작업 할 때만 SAM (Single Abstract Method) 변환을 적용 할 수 있었다. 이제부터는 Kotlin 인터페이스에도 SAM 변환을 사용할 수 있다. 이렇게하려면 fun 수정자를 사용하여 Kotlin 인터페이스를 명시적으로 표시하면 된다.

fun interface IntPredicate {
fun accept(i: Int): Boolean
}

SAM 변환을 사용하지 않는 경우 다음과 같은 코드를 작성해야 했었다.

// 클래스의 인스턴스 생성
val isEven = object : IntPredicate {
override fun accept(i: Int): Boolean {
return i % 2 == 0
}
}

Kotlin의 SAM 변환을 활용하면 대신 다음과 같이 코드를 작성할 수 있다.

fun interface IntPredicate {
fun accept(i: Int): Boolean
}
// 람다를 사용하여 인스턴스 만들기
val isEven = IntPredicate { it % 2 == 0 }
fun main() {
println("Is 7 even? - ${isEven.accept(7)}")
}

이름이 지정된 인수와 위치 인수 혼합

1.3에서는 이름이 지정된 arguments로 함수를 호출 할 때 이름이없는 모든 인수를 이름이 지정된 인수 앞에 배치해야했다. 예를 들어

f(1, y = 2) 호출 할 수 있지만 f(x = 1, 2) 호출 할 수 없었다.

1.4에는 이러한 제한이 없어짐. 이제 파라미터 중간에 파라미터 이름을 지정할 수 있게 되었다.

fun reformat(
str: String,
uppercaseFirstLetter: Boolean = true,
wordSeparator: Char = ' '
) {
// ...
}
// 중간에 이름있는 인수가있는 함수 호출
reformat("This is a String!", uppercaseFirstLetter = false , '-')

후행 쉼표

쉼표를 추가하거나 제거하지 않고도 새 항목을 추가하고 순서를 변경할 수 있다. 매개 변수 또는 값에 여러 줄 구문을 사용하는 경우 특히 유용함

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

호출 가능한 참조 개선

callable 참조에 대한 더 많은 사례를 지원함

  • 기본 인수 값이있는 함수에 대한 참조
fun foo(i: Int = 0): String = "$i!"fun apply(func: () -> String): String = func()fun main() {
// 함수 foo 에 대한 호출 가능 참조가 인수를 사용하지 않는 경우 기본값 0 이 사용
println(apply(::foo))
}
  • Unit 반환 함수의 함수 참조

1.4에서는 Unit 반환 함수에 모든 유형을 반환하는 함수 참조를 사용할 수 있게 되었다.

fun foo(f: () -> Unit) { }
fun returnsInt(): Int = 42
fun main() {
foo { returnsInt() } // 1.4 이전에는 이것이 유일한 방법
foo(::returnsInt) // 1.4부터이 기능도 작동
}
  • 가변 인수를 받는 함수 참조

이제 가변 개수의 인수 (vararg) 함수에 대한 참조를 적용 가능. 전달 된 인수 목록 끝에 동일한 유형의 매개 변수를 얼마든지 전달할 수 있다.

fun foo(x: Int, vararg y: String) {}fun use0(f: (Int) -> Unit) {}
fun use1(f: (Int, String) -> Unit) {}
fun use2(f: (Int, String, String) -> Unit) {}
fun test() {
use0(::foo)
use1(::foo)
use2(::foo)
}

루프에 표현식이 포함 된 when 내부에서 break 및 continue 사용

코틀린 1.3 까지는 break, continue가 for문안에 when에서 사용될 경우, 레이블을 반드시 사용했어야 했었다.

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

1.4 부터는 레이블을 사용하지 않고도 사용하는게 가능해졌다. 가장 가까운 둘러싸는 루프를 종료하거나 다음 단계로 진행하여 예상대로 작동한다.

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

ArrayDeque 클래스 추가

Double-ended 큐의 구현 인 ArrayDeque 클래스를 추가함.

fun main() {
val deque = ArrayDeque(listOf(1, 2, 3))
deque.addFirst(0)
deque.addLast(4)
println(deque) // [0, 1, 2, 3, 4]
println(deque.first()) // 0
println(deque.last()) // 4
deque.removeFirst()
deque.removeLast()
println(deque) // [1, 2, 3]
}

코틀린 1.5

JVM records 지원

Java 14 레코드 클래스는 Kotlin data 클래스와 유사하며 주로 단순한 데이터 홀더로 사용된다. Java 레코드는 익숙한 ‘getX()’ 및 ‘getY()’ 대신 ‘x()’ 및 ‘y()’ 메서드를 사용하는 차이점이 있다.

자바와의 상호운용성을 위해 data class에 @JvmRecord를 사용할 수 있다.

@JvmRecord
data class Point(val x: Int, val y: Int)

sealed interface

기존의 sealed class는 다중 상속이 불가능 했다. 하지만 sealed interface를 사용하면 여러개의 sealed 계층구조를 가지는 서브클래스를 만들 수 있다. 또한 sealed interface를 이용하면 sealed class 는 안되는 enum class 를 서브클래스로 만들 수 도 있다.

예를들어 다음과 같이 sealed class를 이용해 도메인 에러를 모델링한 코드가 있다고 해보자.

sealed class CommonErrors
object ServerError : CommonErrors()
object Forbidden : CommonErrors()
object Unauthorized : CommonErrors()
sealed class LoginErrors {
data class InvalidUsername(val username: String) : LoginErrors()
object InvalidPasswordFormat : LoginErrors()
data class CommonError(val error: CommonErrors) : LoginErrors()
}
sealed class GetUserErrors {
data class UserNotFound(val userId: String) : GetUserErrors()
data class InvalidUserId(val userId: String) : GetUserErrors()
data class CommonError(val error: CommonErrors) : GetUserErrors()
}

로그인과 유저 정보 가져오기와 같은 여러개의 네트워크 요청이 있다고 가정해보면, 각 요청은 각 도메인에 특화된 에러를 만들어낼 것이다. 그러나 동시에 CommonErrors와 같이 공통의 속성을 가진 에러가 될 수도있다.

만약 sealed class를 사용하면서, 이 계층구조를 이용하려면 다소 복잡해진다. 왜냐하면 재사용하고 싶은 각 계층구조에 대한 추가적인 래퍼 케이스를 만들어줘야 하기 때문이다. 그래서 아래와 같이 중첩된 when 문을 써야할 필요성이 생긴다.

fun handleError(loginError: LoginErrors): String = when (loginError) {
is LoginErrors.InvalidUsername -> TODO()
LoginErrors.InvalidPasswordFormat -> TODO()
is LoginErrors.CommonError -> when (loginError.error) {
Forbidden -> TODO()
ServerError -> TODO()
Unauthorized -> TODO()
}
}

이 방법은 이상적인 방법과는 다소 거리가 멀다. 바깥쪽, 안쪽 sealed class를 각자 따로 체크해주어야 하기 때문이다.

한가지 해볼만한것은, sealed class를 다른 sealed class로 확장하는것은 가능하다. 그러면 아래와 같이 재구성 할 수 있다.

sealed class CommonErrors : LoginErrors() // CommonErrors를 LoginErrors의 서브클래스로 두었다.
object ServerError : CommonErrors()
object Forbidden : CommonErrors()
object Unauthorized : CommonErrors()
sealed class LoginErrors {
data class InvalidUsername(val username: String) : LoginErrors()
object InvalidPasswordFormat : LoginErrors()
}

그러면 when문에서 중첩을 사용하지 않고도 에러 케이스를 다룰 수 있게된다.

fun handleLoginError(error: LoginErrors): String = when (error) {
ServerError -> TODO()
Forbidden -> TODO()
Unauthorized -> TODO()
is LoginErrors.InvalidUsername -> TODO()
LoginErrors.InvalidPasswordFormat -> TODO()
}

하지만 문제는 다중 상속이 안되기 때문에 GetUserErrors 에 대한 계층 구조를 만들지 못한다.

이제 sealed interface를 이용하면 이 문제가 해결된다.

sealed class CommonErrors : LoginErrors, GetUserErrors
object ServerError : CommonErrors()
object Forbidden : CommonErrors()
object Unauthorized : CommonErrors()
sealed interface LoginErrors {
data class InvalidUsername(val username: String) : LoginErrors
object InvalidPasswordFormat : LoginErrors
}
sealed interface GetUserErrors {
data class UserNotFound(val userId: String) : GetUserErrors
data class InvalidUserId(val userId: String) : GetUserErrors
}
fun handleLoginError(error: LoginErrors): String = when (error) {
Forbidden -> TODO()
ServerError -> TODO()
Unauthorized -> TODO()
LoginErrors.InvalidPasswordFormat -> TODO()
is LoginErrors.InvalidUsername -> TODO()
}
fun handleGetUserError(error: GetUserErrors): String = when (error) {
Forbidden -> TODO()
ServerError -> TODO()
Unauthorized -> TODO()
is GetUserErrors.InvalidUserId -> TODO()
is GetUserErrors.UserNotFound -> TODO()
}

이제 여러 케이스를 계층구조를 사용하면서도 다룰 수 있는 코드로 작성할 수 있다.

value class 지원

value class는 하나의 필드를 가지는 클래스이다. 이것은 하나의 값을 가지는 래퍼 클래스와 다를게 없지만, 코틀린 컴파일러는 오버헤드가 없도록 공격적으로 최적화 작업을 수행해 준다. 다만 모든 값은 읽기 전용 val이어야 한다.

@JvmInline
value class Duration private constructor(
val millis: Long,
) {
companion object {
fun millis(millis: Long) = Duration(millis)
fun seconds(seconds: Long) = Duration(seconds * 1000)
}
}

value 키워드를 통해 value class를 정의하면 컴파일러의 도움을 받아 최적화의 대상이 된다. @JvmInline 어노테이션은 코틀린의 다른 버전과의 호환을 위해 존재하는 어노테이션이다.

value class는 JVM이 바이트코드로 컴파일하는 과정에서 객체를 제거하고 value class의 프로퍼티로 대체하는 최적화를 수행한다.

부호 없는 정수형 지원

Uint, ULong, UByte, UShort 와 같은 부호 없는 정수형 지원이 안정화 되었다.

inline class UInt : Comparable<UInt>
inline class ULong : Comparable<ULong>
inline class UByte : Comparable<UByte>
inline class UShort : Comparable<UShort>

부호없는 클래스의 경우 필드가 1개이기 때문에 inline class로 선언되어 코틀린 컴파일러에 의해 최적화를 추가적으로 수행할 수 있다.

추가 Char 클래스 전용 메서드

Char 카테고리 타입을 멀티플랫폼 프로젝트에 맞게 유니코드로 변환하는 새로운 API가 추가되었다.

fun Char.isDigit(): Boolean
fun Char.isLetter(): Boolean
fun Char.isLetterOrDigit(): Boolean
val chars = listOf('a', '1', '+')
val (letterOrDigitList, notLetterOrDigitList) = chars.partition {
it.isLetterOrDigit()
}
println(letterOrDigitList) // [a, 1]
println(notLetterOrDigitList) // [+]

Char.category 프로퍼티가 추가됨. enum class CharCategory를 반환하는데, Char의 유니코드에 정의된 일반적인 카테고리를 의미한다.

enum class CharCategory(val value: Int, val code: String) {
UNASSIGNED(0, "Cn"),
UPPERCASE_LETTER(1, "Lu"),
LOWERCASE_LETTER(2, "Ll"),
TITLECASE_LETTER(3, "Lt"),
MODIFIER_LETTER(4, "Lm"),
OTHER_LETTER(5, "Lo"),
...
}

Stable Path API

java.nio.file.Path 에 대한 실험적 기능이였던 Path API가 안정화됨

// construct path with the div (/) operator
val baseDir = Path("/base")
val subDir = baseDir / "subdirectory"
// list files in a directory
val kotlinFiles: List<Path> = Path("/home/user").listDirectoryEntries("*.kt")

새로운 컬렉션 함수 firstNotNullOf()

새로운 함수 firstNotNullOf, firstNotNullOfOrNull 가 추가되었다.

val data = listOf("Kotlin", "1.5")
// toDoubleOrNull 로 변환된 첫번째 non-null 값을 가져온다.
// 만약 non-null 값이 없으면 NoSuchElementException 예외를 던진다.
println(data.firstNotNullOf(String::toDoubleOrNull))
// toIntOrNull 로 변환된 첫번째 non-null 값을 가져온다.
// 만약 non-null 값이 없으면 null을 리턴한다.
println(data.firstNotNullOfOrNull(String::toIntOrNull))

코틀린 1.6

완전한 sealed when 문

when 문이 완전하지 않는 경우 kotlin 컴파일러에서 경고를 하는 기능이다. 덕분에 코드를 더 안전하게 작성 할 수 있다. 예를들어,

sealed class Contact {
data class PhoneCall(val number: String) : Contact()
data class TextMessage(val number: String) : Contact()
data class InstantMessage(val type: IMType, val user: String) : Contact()
}

이때 깜빡잊고 모든 타입을 처리하지 않는 경우, 이제 컴파일러에서 오류를 내보내게 된다.

fun Rates.computeMessageCost(contact: Contact): Cost =
when (contact) { // ERROR: 'when' expression must be exhaustive
is Contact.PhoneCall -> phoneCallCost
is Contact.TextMessage -> textMessageCost
}

이것은 코드 유지 관리에 더 큰 도움이 된다. 만약 sealed class에 새로운 class가 추가되거나 하였을 때, 잊어먹은 when 문 케이스에 대해 컴파일러가 알려줄것이므로 안심하고 코드를 수정할 수 있다.

Non-exhaustive 'when' statements on sealed class/interface will be prohibited in 1.7.
Add an 'is InstantMessage' branch or 'else' branch instead.

표준 입력을 위한 새로운 함수

콘솔에서 읽기용 함수가 새롭게 제공되어 다음과 같은 환경이 마련됨.

  • readln() 은 EOF에 도달하면 예외를 던진다. !!로 null 에 대한 readLine() 대신 이 함수를 사용하기를 권장.
  • readlnOrNull()은 null을 반환하는 대체 함수. readLine과 동일하지만 이름이 더 이해하기 쉬워짐.
fun main() {
println("Input two integer numbers each on a separate line")
val num1 = readln().toInt()
val num2 = readln().toInt()
println("The sum of $num1 and $num2 is ${num1 + num2}")
}

typeOf() 안정화

실험적인 API로 제공되었던 typeOf()를 이제 모든 Kotlin 플랫폼에서 사용할 수 있게 되었으며 컴파일러가 추론할 수 있는 모든 Kotlin 타입의 KType 표현을 얻을 수 있다.

inline fun <reified T> renderType(): String {
val type = typeOf<T>()
return type.toString()
}
fun main() {
val fromExplicitType = typeOf<Int>() // Int
val fromReifiedType = renderType<List<Int>>() // List<Int>
}

중위 표기법의 compareTo

두 객체를 비교하기 위해 중위 표기법으로 Comparable.compareTo 함수를 호출하는 기능을 추가됨.

class WrappedText(val text: String) : Comparable<WrappedText> {
override fun compareTo(other: WrappedText): Int =
this.text compareTo other.text
}

코틀린 1.7

타입 인자에 언더스코어 연산자 사용

타입 인자를 위한 언더스코어 연산자 _ 를 사용할 수 있다. 이제 다른 타입들이 명시되었을 때, 언더스코어를 사용하여 자동적으로 타입이 추론되도록 할 수 있다.

abstract class SomeClass<T> {
abstract fun execute(): T
}
class SomeImplementation : SomeClass<String>() {
override fun execute(): String = "Test"
}
class OtherImplementation : SomeClass<Int>() {
override fun execute(): Int = 42
}
object Runner {
inline fun <reified S: SomeClass<T>, T> run(): T {
return S::class.java.getDeclaredConstructor()
.newInstance().execute()
}
}
fun main() {
// T는 String으로 추론된다. 왜냐하면 SomeImplementation이
// SomeClass<String>으로 부터 파생되었기 때문이다.
val s = Runner.run<SomeImplementation, _>()
assert(s == "Test")
// T는 Int로 추론된다. 왜냐하면 OtherImplementation이
// SomeClass<Int>으로 부터 파생되었기 때문이다.
val n = Runner.run<OtherImplementation, _>()
assert(n == 42)
}

min(), max() 컬렉션 함수가 이제 non-nullable을 리턴

코틀린 1.4.0에서, min(), max() 컬렉션 함수를 minOrNull() 과 maxOrNull()로 재명명했었다. 이런 네이밍은 함수의 리시버가 비어있다면 null을 반환하는 함수의 작동방식을 잘 설명해준다. 코틀린 컬렉션 API는 전반에 걸쳐 이러한 함수 작명 방식을 채용했었다. minBy, maxBy, minWith, maxWith 함수 또한 orNull() 버전이 따로 구비되어 있다.

코틀린 1.7.0 부터 non-nullable을 리턴하던 기존의 함수들은 더 엄격하게 바뀌었다. 새로운 min(), max(), minBy(), maxBy(), minWith(), maxWith() 함수는 이제 컬렉션의 원소를 반환하거나 예외를 던지도록 바뀌었다.

fun main() {
val numbers = listOf<Int>()
println(numbers.maxOrNull()) // "null"
println(numbers.max()) // "Exception in... Collection is empty."
}

자바 옵셔널(Optional)을 위한 새로운 실험적인 확장 함수

코틀린 1.7.0 부터 자바에서의 Optional 클래스를 다루기위한 새로운 편의함수를 제공한다. 이 새로운 함수는 옵셔널 객체를 벗겨내거나 변환하는데 사용될 뿐아니라, 자바 API와 더 명확하게 동작하도록 해준다.

getOrNull(), getOrDefault(), getOrElse() 확장 함수는 Optional 객체의 값이 있다면 가져오거나, 디폴트 값을 반환하거나, null 또는 함수의 반환값을 각각 가져올 수 있다.

val presentOptional = Optional.of("I'm here!")println(presentOptional.getOrNull())
// "I'm here!"
val absentOptional = Optional.empty<String>()println(absentOptional.getOrNull())
// null
println(absentOptional.getOrDefault("Nobody here!"))
// "Nobody here!"
println(absentOptional.getOrElse {
println("Optional was absent!")
"Default value!"
})
// "Optional was absent!"
// "Default value!"

toList, toset, asSequence() 확장 함수는 값이 있는 Optional을 각각 리스트, 셋, 시퀀스, 빈 컬렉션으로 변환해준다. toCollection() 확장 함수는 이미 존재하는 컬렉션에 옵셔널 값이 존재할때만 추가한다.

--

--