출시·고도화 중
Kotlin 안내서 · 6/6
마지막 장에서는 Kotlin을 Kotlin답게 만드는 네 가지 개념을 다룹니다. 타입 시스템이 null을 다루는 방식, 기존 클래스에 함수를 덧붙이는 확장 함수, 람다와 고차 함수, 그리고 비동기 코드를 순차 코드처럼 쓰게 해 주는 코루틴입니다.
Kotlin은 null이 될 수 있는 타입과 그렇지 않은 타입을 구분합니다. String에는 null을 넣을 수 없고, null을 허용하려면 String?처럼 물음표를 붙입니다. null일 수 있는 값은 그대로 멤버를 호출할 수 없어서, 많은 NullPointerException이 컴파일 단계에서 막힙니다.
fun main() {
var nickname: String? = null // ?가 붙어야 null을 담을 수 있습니다
// println(nickname.length) // 컴파일 오류
println(nickname?.length) // null: 안전 호출
println(nickname?.length ?: 0) // 0: 엘비스 연산자로 기본값
nickname = "코틀린"
println(nickname!!.length) // 3: null이면 NullPointerException
nickname?.let { println("별명은 $it") } // null이 아닐 때만 실행
}| 연산자 | 뜻 |
|---|---|
?. | null이 아니면 호출, null이면 결과도 null |
?: | 왼쪽이 null이면 오른쪽 값을 사용 |
!! | null이 아니라고 단언, null이면 예외 |
as? | 안전한 형 변환, 실패하면 null |
!!는 "여기서는 절대 null이 아니다"라는 약속이므로 꼭 필요한 곳에만 씁니다. 컴파일러는 null 검사나 is 검사 뒤에 그 변수를 자동으로 좁은 타입으로 다루는데, 이것을 스마트 캐스트라고 합니다.
fun lengthOf(value: Any?): Int {
if (value == null) return 0
if (value is String) return value.length // String으로 스마트 캐스트
if (value is List<*>) return value.size
return value.toString().length
}
fun findUser(id: Int): String? = if (id == 1) "admin" else null
fun requireUser(id: Int): String =
findUser(id) ?: throw IllegalArgumentException("없는 사용자: $id")다른 코드가 중간에 바꿀 수 있는 var 속성처럼, 검사 뒤에 값이 바뀌지 않는다고 보장할 수 없으면 스마트 캐스트가 되지 않습니다. 이럴 때는 값을 지역 val에 담은 뒤 검사합니다.
확장 함수는 클래스를 고치거나 상속하지 않고도 그 클래스에 새 함수를 덧붙인 것처럼 쓰게 해 줍니다. 함수 이름 앞에 받는 타입을 적고, 본문에서는 this로 그 객체를 가리킵니다.
fun String.initials(): String =
split(" ").filter { it.isNotBlank() }.joinToString("") { it.first().uppercase() }
val <T> List<T>.secondOrNull: T?
get() = if (size >= 2) this[1] else null
fun main() {
println("kotlin is fun".initials()) // KIF
println(listOf(1, 2, 3).secondOrNull) // 2
}확장 함수는 실제로 클래스를 바꾸지 않습니다. 컴파일하면 객체를 첫 번째 인자로 받는 정적 함수가 되고, 어떤 확장이 불릴지는 실행 시점이 아니라 컴파일 시점의 타입으로 정해집니다. 그래서 private 멤버에는 접근할 수 없습니다. 표준 라이브러리의 filter, map, let 같은 함수도 대부분 확장 함수입니다.
함수를 인자로 받거나 함수를 돌려주는 함수를 고차 함수라고 합니다. 함수 타입은 (Int) -> Int처럼 적고, 람다는 중괄호로 씁니다. 매개변수가 하나뿐인 람다에서는 그 매개변수를 it으로 부를 수 있습니다.
fun applyTwice(x: Int, f: (Int) -> Int): Int = f(f(x))
fun isEven(n: Int) = n % 2 == 0
fun main() {
val double: (Int) -> Int = { it * 2 }
val add = { a: Int, b: Int -> a + b }
println(applyTwice(3, double)) // 12
println(applyTwice(3) { it + 10 }) // 23: 마지막 람다는 괄호 밖으로
println(add(2, 5)) // 7
println((1..6).filter(::isEven)) // [2, 4, 6]: 함수 참조
val words = listOf("apple", "kiwi", "banana", "fig")
val result = words
.filter { it.length > 3 }
.sortedBy { it.length }
.map { it.uppercase() }
println(result) // [KIWI, APPLE, BANANA]
}마지막 매개변수가 함수이면 람다를 괄호 밖으로 빼서 쓸 수 있습니다. 이 규칙 덕분에 repeat(3) { ... }나 Gradle의 dependencies { ... }처럼 언어 문법처럼 보이는 API를 만들 수 있습니다. 람다를 받는 함수에 inline을 붙이면 호출 지점에 코드가 펼쳐져 람다 객체를 만드는 비용이 사라집니다.
코루틴은 스레드를 막지 않고 잠시 멈췄다가 이어서 실행할 수 있는 가벼운 실행 단위입니다. 언어는 suspend 키워드만 제공하고, launch, async 같은 도구는 kotlinx-coroutines-core 라이브러리에 있으므로 이 의존성을 추가해야 합니다.
import kotlinx.coroutines.*
suspend fun fetchPrice(item: String): Int {
delay(300) // 스레드를 막지 않고 기다립니다
return item.length * 1000
}
fun main() = runBlocking {
val job = launch {
delay(100)
println("launch: 결과 없이 일을 합니다")
}
val apple = async { fetchPrice("apple") }
val melon = async { fetchPrice("melon") }
println("합계: ${apple.await() + melon.await()}") // 두 작업이 동시에 진행됩니다
job.join()
}suspend 함수는 코루틴이나 다른 suspend 함수 안에서만 부를 수 있습니다. launch는 결과 없이 일을 시작하고 Job을 돌려주며, async는 결과를 내는 일을 시작하고 Deferred를 돌려주는데 await()로 값을 받습니다. runBlocking은 일반 코드와 코루틴을 잇는 다리로, main이나 테스트에서만 씁니다.
코루틴은 구조적 동시성을 따릅니다. 자식 코루틴은 부모 범위 안에서 실행되고, 부모는 자식이 모두 끝날 때까지 기다리며, 하나가 실패하면 나머지도 취소됩니다. 파일이나 네트워크처럼 막히는 작업은 withContext(Dispatchers.IO)로 알맞은 스레드 풀에 넘깁니다.
suspend fun readConfig(path: String): String = withContext(Dispatchers.IO) {
java.io.File(path).readText()
}
suspend fun loadAll(paths: List<String>): List<String> = coroutineScope {
paths.map { async { readConfig(it) } }.awaitAll()
}T와 T?를 구분하고, ?., ?:, 스마트 캐스트로 null을 안전하게 다룹니다. !!는 아껴 씁니다.suspend 함수와 launch, async로 비동기 코드를 순차 코드처럼 쓰게 해 줍니다.더 깊이 공부하려면 공식 문서를 참고하세요.
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.
첫 댓글을 남겨 보세요.