data class Point(val x: Int, val y: Int) {
operator fun plus(other: Point): Point {
return Point(x + other.x, y + other.y)
}
}
@Test
fun `test operator overloading`() {
val p1 = Point(10, 20)
val p2 = Point(30, 40)
val result = p1 + p2
assertEquals(Point(40, 60), result)
}
비교 연산자는 equals와 compareTo 메소드로 변환
class Person(val name: String, val age: Int) : Comparable<Person> {
override fun compareTo(other: Person): Int {
return age - other.age
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Person) return false
return name == other.name && age == other.age
}
override fun hashCode(): Int {
return name.hashCode() * 31 + age
}
}
@Test
fun `test comparison operators`() {
val p1 = Person("Alice", 30)
val p2 = Person("Bob", 25)
val p3 = Person("Alice", 30)
assertTrue(p1 > p2)
assertFalse(p1 == p2)
assertTrue(p1 == p3)
}
get, set, contains 함수를 정의하면 그 클래스의 인스턴스에 대해 []와 in 연산 사용 가능
해당 객체는 코틀린 컬렉션 객체와 유사
class CustomCollection {
private val items = mutableListOf<String>()
operator fun get(index: Int): String {
return items[index]
}
operator fun set(index: Int, value: String) {
items[index] = value
}
operator fun contains(value: String): Boolean {
return items.contains(value)
}
fun add(value: String) {
items.add(value)
}
}
@Test
fun `test get, set, contains`() {
val collection = CustomCollection()
collection.add("Hello")
collection.add("World")
assertEquals("Hello", collection[0]) // get()
collection[1] = "Kotlin" // set()
assertEquals("Kotlin", collection[1]) // get()
assertTrue("Kotlin" in collection) // contains()
}
rangeTo, iterator 함수를 정의하면 범위를 만들거나 컬렉션과 배열의 원소를 이터레이션 가능
class CustomRange(val start: Int, val end: Int) {
operator fun rangeTo(other: CustomRange): IntRange {
return start..other.end
}
operator fun iterator(): Iterator<Int> {
return (start..end).iterator()
}
}
@Test
fun `test rangeTo and iterator`() {
val range = CustomRange(1, 5)
val result = mutableListOf<Int>()
for (i in range) { // iterator()
result.add(i)
}
assertEquals(listOf(1, 2, 3, 4, 5), result)
val range2 = CustomRange(6, 10)
val combinedRange = range..range2 // rangeTo()
assertEquals((1..10), combinedRange)
}
구조 분해 선언을 통해 한 객체의 상태를 분해해서 여러 변수에 대입 가능
함수가 여러 값을 한꺼번에 반환해야 하는 경우 유용
data class Person(val name: String, val age: Int)
@Test
fun `test destructuring declaration`() {
val person = Person("Alice", 30)
val (name, age) = person
assertEquals("Alice", name)
assertEquals(30, age)
}
위임 프로퍼티를 통해 프로퍼티 값을 저장, 초기화, 읽거나 변경할 때 사용하는 로직을 재활용 가능
위임 프로퍼티는 프레임워크를 만들 때 아주 유용
Delegates.observable 함수를 사용하면 프로퍼티 변경 관찰자를 쉽게 추가 가능
class User {
var name: String by Delegates.observable("<no name>") { _, old, new ->
println("Name changed from $old to $new")
}
}
@Test
fun `test delegated property`() {
val user = User()
assertEquals("<no name>", user.name)
user.name = "Alice"
assertEquals("Alice", user.name)
user.name = "Bob"
assertEquals("Bob", user.name)
}
표준 라이브러리 함수인 lazy를 통해 지연 초기화 프로퍼티를 쉽게 구현
class LazyInitialization {
val lazyValue: String by lazy {
println("Computed!")
"Hello"
}
}
@Test
fun `test lazy property`() {
val instance = LazyInitialization()
assertEquals("Hello", instance.lazyValue) // 최초로 접근 시 초기화
}
📕 고차 함수 요약
함수 타입을 사용해 함수에 대한 참조를 담는 변수, 파라미터, 반환 값을 만들 수 있다.
// 함수 타입을 사용한 변수
val add: (Int, Int) -> Int = { a, b -> a + b }
// 함수 타입을 파라미터로 받는 함수
fun operate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
return operation(a, b)
}
// 함수 타입을 반환 값으로 사용하는 함수
fun getOperation(type: String): (Int, Int) -> Int {
return when (type) {
"add" -> { a, b -> a + b }
"multiply" -> { a, b -> a * b }
else -> { _, _ -> 0 }
}
}
@Test
fun `test function type as variable`() {
assertEquals(5, add(2, 3))
}
@Test
fun `test function type as parameter`() {
val result = operate(2, 3, add)
assertEquals(5, result)
}
@Test
fun `test function type as return value`() {
val addOperation = getOperation("add")
val multiplyOperation = getOperation("multiply")
assertEquals(5, addOperation(2, 3))
assertEquals(6, multiplyOperation(2, 3))
}
고차 함수는 다른 함수를 인자로 받거나 함수를 반환한다.
// 함수를 인자로 받는 고차 함수
fun applyOperation(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
return operation(a, b)
}
// 함수를 반환하는 고차 함수
fun getOperation(type: String): (Int, Int) -> Int {
return when (type) {
"add" -> { a, b -> a + b }
"subtract" -> { a, b -> a - b }
else -> { _, _ -> 0 }
}
}
@Test
fun `test applyOperation with add`() {
val result = applyOperation(3, 4) { a, b -> a + b }
assertEquals(7, result)
}
@Test
fun `test applyOperation with subtract`() {
val result = applyOperation(10, 4) { a, b -> a - b }
assertEquals(6, result)
}
@Test
fun `test getOperation with add`() {
val addOperation = getOperation("add")
val result = addOperation(3, 4)
assertEquals(7, result)
}
@Test
fun `test getOperation with subtract`() {
val subtractOperation = getOperation("subtract")
val result = subtractOperation(10, 4)
assertEquals(6, result)
}
인라인 함수를 컴파일할 때 컴파일러는 그 함수의 본문과 그 함수에게 전달된 람다의 본문을 컴파일한 바이트 코드를 모든 함수 호출 지점에 삽입해준다.
inline fun performOperation(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
return operation(a, b)
}
@Test
fun `test performOperation with add`() {
val result = performOperation(3, 4) { x, y -> x + y }
assertEquals(7, result)
}
@Test
fun `test performOperation with multiply`() {
val result = performOperation(3, 4) { x, y -> x * y }
assertEquals(12, result)
}
고차 함수를 사용하면 컴포넌트를 이루는 각 부분의 코드를 더 잘 재사용할 수 있다.
또 고차 함수를 활용해 강력한 제네릭 라이브러리를 만들 수 있다.
// 고차 함수: 두 리스트를 합치는 함수
fun <T> combineLists(list1: List<T>, list2: List<T>, combine: (T, T) -> T): List<T> {
return list1.zip(list2, combine)
}
// 고차 함수: 리스트의 모든 요소에 연산을 적용하는 함수
fun <T, R> mapList(list: List<T>, transform: (T) -> R): List<R> {
return list.map(transform)
}
@Test
fun `test combineLists with addition`() {
val list1 = listOf(1, 2, 3)
val list2 = listOf(4, 5, 6)
val result = combineLists(list1, list2) { a, b -> a + b }
assertEquals(listOf(5, 7, 9), result)
}
@Test
fun `test combineLists with string concatenation`() {
val list1 = listOf("a", "b", "c")
val list2 = listOf("d", "e", "f")
val result = combineLists(list1, list2) { a, b -> a + b }
assertEquals(listOf("ad", "be", "cf"), result)
}
@Test
fun `test mapList with square`() {
val list = listOf(1, 2, 3, 4)
val result = mapList(list) { it * it }
assertEquals(listOf(1, 4, 9, 16), result)
}
@Test
fun `test mapList with string length`() {
val list = listOf("apple", "banana", "cherry")
val result = mapList(list) { it.length }
assertEquals(listOf(5, 6, 6), result)
}
인라인 함수에서는 람다 안에 있는 return 문이 바깥쪽 함수를 반환시키는 non-local return을 사용할 수 있다.
inline fun performOperationWithNonLocalReturn(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
return operation(a, b)
}
@Test
fun `test non-local return in inline function`() {
val result1 = performOperationWithNonLocalReturn(3, 4) { x, y ->
if (x > y) return@performOperationWithNonLocalReturn x
x + y
}
assertEquals(7, result1)
val result2 = performOperationWithNonLocalReturn(3, 1) { x, y ->
if (x > y) return@performOperationWithNonLocalReturn x
x + y
}
assertEquals(3, result2)
}
무명 함수는 람다 식을 대신할 수 있으며 return 식을 처리하는 규칙이 일반 람다 식과는 다르다.
본문 여러 곳에서 return 해야 하는 코드 블록을 만들어야 한다면 람다 대신 무명 함수를 쓸 수 있다.
fun performOperationWithAnonymousFunction(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
return operation(a, b)
}
@Test
fun `test anonymous function with multiple returns`() {
val result = performOperationWithAnonymousFunction(3, 4, fun(x, y): Int {
if (x > y) return x
return x + y
})
assertEquals(7, result)
val result2 = performOperationWithAnonymousFunction(5, 2, fun(x, y): Int {
if (x > y) return x
return x + y
})
assertEquals(5, result2)
}
산술 연산자 오버로딩
코틀린에서 관례를 사용하는 가장 단순한 예는 산술 연산자
자바에서는 원시 타입에 대해서만 산술 연산자 사용 가능하고, String에 대해 + 연산자를 사용 가능
이항 산술 연산 오버로딩
operator 변경자를 추가해 plus 함수를 선언하고 나면 + 기호로 두 Point 객체를 더할 수 있음
// case 1. 연산자를 자체 함수로 정의하기
data class Point(val x: Int, val y: Int) {
operator fun plus(other: Point): Point {
return Point(x + other.x, y + other.y)
}
}
// case 2. 연산자를 확장 함수로 정의하기
data class Point2(val x: Int, val y: Int)
operator fun Point2.plus(other: Point2): Point2 {
return Point2(x + other.x, y + other.y)
}
@Test
fun `이항 산술 연산 오버로딩`() {
val p1 = Point(10, 20)
val p2 = Point(30, 40)
assertEquals(Point(40, 60), p1 + p2)
val p3 = Point2(10, 20)
val p4 = Point2(30, 40)
assertEquals(Point2(40, 60), p3 + p4)
}
ℹ️ 오버로딩 가능한 이항 산술 연산자
Expression
Function name
a * b
times
a / b
div
a % b
mod
a + b
plus
a - b
minus
연산자를 정의할 때 두 피연산자가(연산자 함수의 두 파라미터)가 같은 타입일 필요는 없다.
또는 연산자 함수의 반환 타입이 꼭 두 피연산자 중 하나와 일치해야만 하는 것도 아니다.
data class Point(val x: Int, val y: Int)
// case 1. 두 피연산자가 다른 연산자 정의
// Point, Double 타입의 피연산자를 받아 Point 반환
operator fun Point.times(scale: Double): Point {
return Point((x * scale).toInt(), (y * scale).toInt())
}
// case 2. 반환 타입이 피연산자와 다른 연산자 정의
// Char, Int 타입의 피연산자를 받아 String 반환
operator fun Char.times(count: Int): String {
return toString().repeat(count)
}
@Test
fun `연산자 정의`() {
val point = Point(2, 3)
val scaledPoint = point * 2.5
assertEquals(Point(5, 7), scaledPoint)
val result = 'a' * 3
assertEquals("aaa", result)
}
복합 대입 연산자 오버로딩
+=, -= 등의 연산자는 복합 대입(compound assignment) 연산자라 불림
operator fun Point.plus(other: Point): Point {
return Point(x + other.x, y + other.y)
}
@Test
fun `복합 대입 연산자`() {
var point = Point(1, 2)
point += Point(3, 4)
assertEquals(Point(4, 6), point)
}
data class Point(val x: Int, val y: Int)
operator fun Point.unaryMinus(): Point {
return Point(-x, -y)
}
@Test
fun `단항 연산자`() {
val p = Point(10, 20)
assertEquals(Point(-10, -20), -p)
}
ℹ️ 오버로딩할 수 있는 단항 산술 연산자
Expression
Function name
+a
unaryPlus
-a
unaryMinus
!a
not
++a, a++
inc
--a, a--
dec
비교 연산자 오버로딩
동등성 연산자: equals
코틀린이 == 연산자 호출을 equals 메소드 호출로 컴파일
≠ 연산자를 사용하는 식도 equals 호출로 컴파일
class Point(val x: Int, val y: Int) {
override fun equals(obj: Any?): Boolean {
if (obj === this) return true
if (obj !is Point) return false
return obj.x == x && obj.y == y
}
}
@Test
fun `동등성 연산자`() {
assertTrue(Point(10, 20) == Point(10, 20))
assertTrue(Point(10, 20) != Point(5, 5))
assertFalse(null == Point(1, 2))
}
순서 연산자: compareTo
자바에서 정렬, 최댓값, 최솟값 등 값을 비교해야 하는 알고리즘에 사용할 클래스는 Comparable
인터페이스 구현이 필요하듯, 코틀린도 같은 Comparable 인터페이스를 지원
@Test
fun `순서 연산자`() {
class Person(
val firstName: String, val lastName: String
) : Comparable<Person> {
override fun compareTo(other: Person): Int {
return compareValuesBy(
this, other,
Person::lastName, Person::firstName
)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Person) return false
return firstName == other.firstName && lastName == other.lastName
}
override fun hashCode(): Int {
return firstName.hashCode() * 31 + lastName.hashCode()
}
}
val person1 = Person("John", "Doe")
val person2 = Person("Jane", "Doe")
val person3 = Person("John", "Smith")
// 같은 성, 다른 이름
assertTrue(person1 > person2)
assertFalse(person1 < person2)
// 다른 성
assertTrue(person1 < person3)
assertFalse(person1 > person3)
// 같은 성, 같은 이름 (equals, hashCode 오버라이드 필요)
val person4 = Person("John", "Doe")
assertTrue(person1 == person4)
}
코틀린 표준 라이브러리의 compareValuesBy 함수를 사용해 compareTo를 쉽고 간결하게 정의할 수 있다.
@Test
fun `compareValuesBy compareTo`() {
assertTrue("abc" < "bac")
}
컬렉션과 범위에 대해 쓸 수 있는 관례
in 관례
In은 객체가 컬렉션에 들어있는지 검사.
in 연산자와 대응하는 함수는 contains
data class Point(val x: Int, val y: Int)
data class Rectangle(val upperLeft: Point, val lowerRight: Point)
operator fun Rectangle.contains(p: Point): Boolean {
// 20 in (10 <= true < 50) && 30 in (20 <= true < 50)
return p.x in upperLeft.x until lowerRight.x &&
p.y in upperLeft.y until lowerRight.y
}
@Test
fun `in 관례`() {
val rect = Rectangle(Point(10, 20), Point(50, 50))
assertTrue(Point(20, 30) in rect)
assertFalse(Point(5, 5) in rect)
}
@Test
fun `rangeTo 관례`() {
val n = 9
assertEquals(IntRange(0, 10), 0..(n + 1))
(0..n).forEach { print(it) } // 0123456789
}
for 루프를 위한 iterator 관례
코틀린에서는 iterator 메소드를 확장 함수로 정의 가능
이런 성질로 인해 일반 자바 문자열에 대한 for 루프가 가능
operator fun ClosedRange<LocalDate>.iterator(): Iterator<LocalDate> =
object : Iterator<LocalDate> {
var current = start
override fun hasNext() =
current <= endInclusive
override fun next() = current.apply {
current = plusDays(1)
}
}
@Test
fun `for 루프를 위한 iterator 관례`() {
val newYear = LocalDate.ofYearDay(2024, 1)
val daysOff = newYear.minusDays(1)..newYear
/**
* 2023-12-31
* 2024-01-01
*/
for (dayOff in daysOff) { println(dayOff) }
}
구조 분해 선언과 component 함수
구조 분해를 사용하면 복합적인 값을 분해해서 여러 다른 변수를 한꺼번에 초기화 가능
data class Point(val x: Int, val y: Int)
@Test
fun `구조 분해 선언`() {
val p = Point(10, 20)
val (x, y) = p
assertEquals(10, x)
assertEquals(20, y)
}
구조 분해 선언은 함수에서 여러 값을 반환할 때 유용
👎🏻 여러 값을 한꺼번에 반환해야 하는 함수가 있다면 반환해야 하는 모든 값이 들어갈 데이터 클래스를 정의하고 함수의 반환 타입을 그 데이터 클래스로 바꿔주어야 한다.
👍🏼 구조 분해 선언 구문을 사용하면 이런 함수가 반환하는 값을 쉽게 풀어서 여러 변수에 넣을 수 있다.
data class NameComponents(val name: String,
val extension: String)
fun splitFilename(fullName: String): NameComponents {
val result = fullName.split('.', limit = 2)
return NameComponents(result[0], result[1]) // 함수에서 데이터 클래스의 인스턴스를 반환
}
@Test
fun `component 함수`() {
val (name, ext) = splitFilename("example.kt") // 구조 분해 선언 구문을 사용해 데이터 클래스를 해체
assertEquals("example", name)
assertEquals("kt", ext)
}
구조 분해 선언과 루프
함수 본문 내의 선언문 뿐 아니라,
변수 선언이 들어갈 수 있는 장소라면 어디든 구조 분해 선언을 사용 가능
fun printEntries(map: Map<String, String>) {
// 구조 분해 선언
for ((key, value) in map) {
println("$key -> $value")
}
}
@Test
fun `구조 분해 선언과 루프`() {
/**
* Oracle -> Java
* JetBrains -> Kotlin
*/
val map = mapOf("Oracle" to "Java", "JetBrains" to "Kotlin")
printEntries(map)
}
두 가지 코틀린 관례를 활용한 예시
객체를 이터페이션하는 관례
구조 분해 선언
@Test
fun `코틀린 관례`() {
val map = mapOf(1 to "one", 2 to "two", 3 to "three")
val keys = mutableListOf<Int>()
val values = mutableListOf<String>()
for (entry in map.entries) {
// 코틀린 라이브러리는 Map.Entry에 대한 확장 함수로 component1, component2 제공
val key = entry.component1()
val value = entry.component2()
keys.add(key)
values.add(value)
}
assertEquals(listOf(1, 2, 3), keys)
assertEquals(listOf("one", "two", "three"), values)
}
프로퍼티 접근자 로직 재활용: 위임 프로퍼티
위임 프로퍼티(delegated property)를 사용하면 값을 뒷받침하는 필드에 단순히 저장하는 것보다
더 복잡한 방식으로 작동하는 프로퍼티를 쉽게 구현 가능하다.
또한, 그 과정에서 접근자 로직을 매번 재구현할 필요도 없다.
위임은 객체가 직접 작업을 수행하지 않고, 다른 도우미 객체가 그 작업을 처리하게 맡기는 디자인 패턴을 말한다.
이때 작업을 처리하는 도우미 객체를 위임 객체라고 부른다.
위임 프로퍼티 사용: by lazy()를 사용한 프로퍼티 초기화 지연
// AS-IS
@Test
fun `**AS-IS by lazy**`() {
data class Email(val address: String)
class Person(val name: String) {
// 데이터를 저장하고 emails의 위임 객체 역활을 하는 _emails 프로퍼티
private var _emails: List<Email>? = null
val emails: List<Email>
get() {
if (_emails == null) {
_emails = loadEmails(this) // 최초 접근 시 이메일 가져오기
}
return _emails!! // 저장해 둔 데이터가 있으면 그 데이터를 반환
}
private fun loadEmails(person: Person): List<Email> {
println("Loading emails for ${person.name}")
return listOf(
Email("alice@example.com"),
Email("alice.work@example.com")
)
}
}
val p = Person("Alice")
assertEquals(listOf(
Email("alice@example.com"),
Email("alice.work@example.com")
), p.emails) // 최초로 emails를 읽을 때 단 한번만 이메일을 가져온다.
assertEquals(listOf(
Email("alice@example.com"),
Email("alice.work@example.com")
), p.emails)
}
// TO-BE
/*
* 지연 초기화해야 하는 프로퍼티가 많아지면 코드가 복잡해지고,
* 스레드 안전하지 않아서 언제나 제대로 작동한다고 말할 수 없다.
* 이 경우 위임 프로퍼티를 사용하면 훨씬 더 간편해진다.
*/
@Test
fun `by lazy`() {
data class Email(val address: String)
class Person(val name: String) {
// lazy 함수는 코틀린 관례에 맞는 시그니처의 getValue 메소드가 들어있는 객체를 반환
// 따라서 lazy를 by 키워드와 함께 사용해 위임 프로퍼티를 만들 수 있다.
val emails by lazy { loadEmails(this) }
private fun loadEmails(person: Person): List<Email> {
println("Loading emails for ${person.name}")
return listOf(
Email("alice@example.com"),
Email("alice.work@example.com")
)
}
}
val p = Person("Alice")
val emails1 = p.emails // 최초로 emails를 읽을 때 단 한번만 이메일을 가져온다.
val emails2 = p.emails
assertEquals(emails1, emails2)
assertEquals(listOf(
Email("alice@example.com"),
Email("alice.work@example.com")
), emails1)
}
고차 함수 정의
고차 함수는 다른 함수를 인자로 받거나 함수를 반환하는 함수
코틀린에서는 람다나 함수 참조를 사용해 함수를 값으로 표현 가능
따라서 고차 함수는 람다나 함수 참조를 인자로 넘길 수 있거나, 람다나 함수 참조를 반환하는 함수
그냥 함수를 정의한다면 함수의 파라미터 목록 뒤에 오는 Unit 반환 타입 지정을 생략해도 되지만,
⚠️ 함수 타입을 선언할 때는 반환 타입을 반드시 명시해야 하므로 Unit을 필수로 명시
인자로 받은 함수 호출
인자로 받은 함수를 호출하는 구문은 일반 함수를 호출하는 구문과 동일
fun twoAndThree(operation: (Int, Int) -> Int): Int { // 함수 타입의 인자를 받는 함수
val result = operation(2, 3)
return result
}
@Test
fun `인자로 받은 함수 호출`() {
assertEquals(5, twoAndThree { a, b -> a + b })
assertEquals(6, twoAndThree { a, b -> a * b })
}
디폴트 값을 지정한 함수 타입 파라미터나 널이 될 수 있는 함수 타입 파라미터
파라미터를 함수 타입으로 선언할 때도 디폴트 값을 정할 수 있다.
fun <T> Collection<T>.joinToString(
separator: String = ", ",
prefix: String = "",
postfix: String = "",
transform: (T) -> String = { it.toString() } // 함수 타입 파라미터를 선언하면서 람다를 디폴트 값으로 지정
): String {
val result = StringBuilder(prefix)
for ((index, element) in this.withIndex()) {
if (index > 0) result.append(separator)
result.append(transform(element)) // transform 파라미터로 받은 함수를 호출
}
result.append(postfix)
return result.toString()
}
@Test
fun `파라미터를 함수 타입으로 선언할 때도 디폴트 값 설정 가능`() {
val letters = listOf("Alpha", "Beta")
assertEquals("Alpha, Beta", letters.joinToString()) // 디폴트 변환 함수 사용
assertEquals("alpha, beta", letters.joinToString { it.lowercase() }) // 람다를 인자로 전달
assertEquals("alpha! beta! ",
letters.joinToString(separator = "! ", postfix = "! ", transform = { it.lowercase() })
) // 이름 붙은 인자 구문을 사용해 람다를 포함하는 여러 인자를 전달
}
함수를 함수에서 반환
다른 함수를 반환하는 함수를 정의하려면 함수의 반환 타입으로 함수 타입을 지정
함수를 반환하려면 return 식에 람다나 멤버 참조나 함수 타입의 값을 계산하는 식 등을 넣으면 된다.
data class Person(
val firstName: String,
val lastName: String,
val phoneNumber: String?
)
class ContactListFilters {
var prefix: String = ""
var onlyWithPhoneNumber: Boolean = false
fun getPredicate(): (Person) -> Boolean { // 함수를 반환하는 함수를 정의한다.
val startsWithPrefix = { p: Person ->
p.firstName.startsWith(prefix) || p.lastName.startsWith(prefix)
}
if (!onlyWithPhoneNumber) {
return startsWithPrefix // 함수 타입의 변수 반환
}
return { // 람다 반환
startsWithPrefix(it)
&& it.phoneNumber != null
}
}
}
@Test
fun `함수를 함수에서 반환`() {
val contacts = listOf(
Person("Dmitry", "Jemerov", "123-4567"),
Person("Svetlana", "Isakova", null)
)
val contactListFilters = ContactListFilters()
with(contactListFilters) {
prefix = "Dm"
onlyWithPhoneNumber = true
}
assertEquals(
listOf(Person("Dmitry", "Jemerov", "123-4567")),
contacts.filter(contactListFilters.getPredicate()) // 반환된 람다 적용
)
with(contactListFilters) {
prefix = "Sv"
onlyWithPhoneNumber = false
}
assertEquals(
listOf(Person("Svetlana", "Isakova", null)),
contacts.filter(contactListFilters.getPredicate()) // 반환된 함수 타입 변수 적용
)
}
람다를 활용한 중복 제거
함수 타입과 람다 식은 재활용하기 좋은 코드를 만들 때 쓸 수 있는 훌륭한 도구
웹 사이트 방문 기록을 분석하는 예시
@Test
fun `람다를 활용한 중복 제거`() {
/**
* Case 01. 중복이 발생하는 코드
*/
val averageWindowsDuration = log
.filter { it.os == OS.WINDOWS }.map(SiteVisit::duration).average()
val averageMacDuration = log
.filter { it.os == OS.MAC }.map(SiteVisit::duration).average()
assertEquals(23.0, averageWindowsDuration)
assertEquals(22.0, averageMacDuration)
/**
* Case 02. 함수를 사용하여 일반 함수를 통해 중복을 줄이기
*/
fun List<SiteVisit>.averageDurationFor(os: OS) =
filter { it.os == os }.map(SiteVisit::duration).average()
assertEquals(23.0, log.averageDurationFor(OS.WINDOWS))
assertEquals(22.0, log.averageDurationFor(OS.MAC))
/**
* Case 02. 고차 함수를 이용하여 함수를 확장
* > 특정 OS의 평균 방문 시간을 구하고 싶다거나, 특정 페이지 평균 방문 시간을 구하고 싶을 경우
*/
fun List<SiteVisit>.averageDurationFor(predicate: (SiteVisit) -> Boolean) =
filter(predicate).map(SiteVisit::duration).average()
// 모바일 디바이스(IOS, 안드로이드)의 평균 방문 시간
assertEquals(12.15, log.averageDurationFor { it.os in setOf(OS.ANDROID, OS.IOS) })
// IOS 사용자의 /signup 페이지 평균 방문 시간
assertEquals(8.0, log.averageDurationFor { it.os == OS.IOS && it.path == "/signup" })
}
인라인 함수: 람다의 부가 비용 없애기
inline 변경자를 함수에 붙이면 컴파일러는 그 함수를 호출하는 모든 문장을
함수 본문에 해당하는 바이트 코드로 바꿔준다.
인라이닝이 작동하는 방식
특정 함수를 inline 선언하면 그 함수의 본문이 인라인
함수를 호출하는 코드를 함수를 호출하는 바이트코드 대신에 함수 본문을 번역한 바이트 코드로 컴파일
@Test
fun `test inline function`() {
/*
* inline 변경자를 사용한 함수 호출 시 컴파일러가 해당 함수 호출을 실제 함수 본문으로 대체
* 함수 호출의 오버헤드를 줄이고 성능을 향상
*/
val addition = inlineFunction { a, b -> a + b }
assertEquals(5, addition)
// 컴파일러는 val addition = { a: Int, b: Int -> a + b }(2, 3) 로 변환
assertEquals(5, { a: Int, b: Int -> a + b }(2, 3))
val multiplication = inlineFunction { a, b -> a * b }
assertEquals(6, multiplication)
// 컴파일러는 val multiplication = { a: Int, b: Int -> a * b }(2, 3) 로 변환
assertEquals(6, { a: Int, b: Int -> a * b }(2, 3))
}
컬렉션 연산 인라이닝
코틀린 표준 라이브러리의 컬렉션 함수는 대부분 람다를 인자로 받는다.
filter, map은 인라인 함수
두 함수의 본문은 인라이닝되며, 추가 객체나 클래스 생성은 없다.
하지만 이 코드는 리스트를 걸러낸 결과를 저장하는 중간 리스트를 만든다.
처리할 원소가 많아지면 중간 리스트를 사용하는 부가 비용도 걱정할 만큼 커진다.
asSequence를 통해 리스트 대신 시퀀스를 사용하면 중간 리스트로 인한 부가 비용은 줄어든다.
각 중간 시퀀스는 람다를 필드에 저장하는 객체로 표현되며,
최종 연산은 중간 시퀀스에 있는 여러 람다를 연쇄 호출
@Test
fun `test filter and map with list`() {
val list = listOf(1, 2, 3, 4, 5)
val result = list.filter { it % 2 == 0 }.map { it * 2 }
assertEquals(listOf(4, 8), result)
}
@Test
fun `test filter and map with sequence`() {
val list = listOf(1, 2, 3, 4, 5)
val result = list.asSequence().filter { it % 2 == 0 }.map { it * 2 }.toList()
assertEquals(listOf(4, 8), result)
}
함수를 인라인으로 선언해야 하는 경우
⚠️ inline 키워드의 이점을 배우고 나면 코드를 더 빠르게 만들기 위해 여기저기에서 inline을 사용하고 싶어질 것이다. 하지만❗️ 이는 좋은 생각이 아니다.
inline 키워드를 사용해도 람다를 인자로 받는 함수만 성능이 좋아질 가능성이 높다.
1️⃣ 일반 함수 호출의 경우 JVM은 이미 강력하게 인라이닝을 지원한다.
JVM은 코드 실행을 분석해서 가장 이익이 되는 방향으로 호출을 인라이닝한다.
이런 과정은 바이트 코드를 실제 기계어 코드로 번역하는 과정(JIT)에서 일어난다.
이런 JVM의 최적화를 활용한다면 바이트 코드에서는 각 함수 구현이 정확히 한 번만 있으면 되고, 그 함수를 호출하는 부분에서 따로 함수 코드를 중복할 필요가 없다.
2️⃣ 인라인 함수는 바이트 코드에서 각 함수 호출 지점을 함수 본문으로 대치하기 때문에 코드 중복이 생긴다.
게다가 함수를 직접 호출하면 스택 트레이스가 더 깔끔해진다.
3️⃣ inline 변경자를 함수에 붙일 때는 코드 크기에 주의를 기울여야 한다.
인라이닝하는 함수가 큰 경우 함수의 본문에 해당하는 바이트 코드를 모든 호출 지점에 복사해 넣고 나면 바이트코드가 전체적으로 아주 커질 수 있다.
🎬 그러면 함수를 인라인으로 언제 선언할까 ❓
람다를 인자로 받는 함수
람다를 인자로 받는 함수는 인라인으로 선언하면 성능이 향상될 수 있다.
함수 호출의 오버헤드를 줄이고, 람다를 인라인으로 대체하여 추가 객체 생성을 피할 수 있다.
고차 함수
고차 함수(다른 함수를 인자로 받거나 반환하는 함수)는 인라인으로 선언하면 호출 비용을 줄일 수 있다.
성능이 중요한 경우
함수 호출이 빈번하고, 성능이 중요한 경우 호출 오버헤드를 줄일 수 있다.
작은 함수
함수 본문이 작고, 여러 번 호출되는 경우 코드 중복이 크게 증가하지 않으면서 성능을 향상
자원 관리를 위해 인라인된 람다 사용
자바의 try-with-resource 문은 코틀린에서 제공하지 않는다.
대신 같은 기능을 제공하는 use 라는 함수가 코틀린 표준 라이브러리 안에 있다.
use 함수는 닫을 수 있는(closeable) 자원에 대한 확장 함수며, 람다를 인자로 받는다.
람다 안에서 return을 사용하면 람다로부터만 반환되는 게 아니라 그 람다를 호출하는 함수가 실행을 끝내고 반환된다.
자신을 둘러싸고 있는 블록보다 더 바깥에 있는 다른 블록을 반환하게 만드는 return 문을 non-local return이라 부른다.
이렇게 return이 바깥쪽 함수를 반환시킬 수 있는 때는 람다를 인자로 받는 함수가 인라인 함수인 경우뿐
forEach는 인라인 함수이므로 람다 본문과 함께 인라이닝
따라서 return 식이 바깥쪽 함수(lookForAlice)를 반환시키도록 쉽게 컴파일
@Test
fun `람다 안의 return문`() {
data class Person(val name: String, val age: Int)
fun lookForAlice(people: List<Person>): String {
for (person in people) {
if (person.name == "Alice") {
return "Found!"
}
}
return "Alice is not found"
}
fun lookForAliceForEach(people: List<Person>): String {
people.forEach {
if (it.name == "Alice") {
return "Found!"
}
}
return "Alice is not found"
}
val people = listOf(Person("Alice", 29), Person("Bob", 31))
assertEquals("Found!", lookForAlice(people))
assertEquals("Found!", lookForAliceForEach(people))
val people2 = listOf(Person("Bob", 31))
assertEquals("Alice is not found", lookForAlice(people2))
assertEquals("Alice is not found", lookForAliceForEach(people2))
}
람다로부터 반환: 레이블을 사용한 return
람다 식에서도 local return을 사용할 수 있다.
람다 안에서 local return은 for루프의 break와 비슷한 역할을 한다.
단, local return과 non-local return을 구분하기 위해 레이블(label)을 사용해야 한다.
@Test
fun `람다로부터 반환`() {
data class Person(val name: String, val age: Int)
fun lookForAlice(people: List<Person>): String {
people.forEach label@{
// 람다를 호출하는 함수가 끝나지 않고 람다로부터 반환
if (it.name == "Alice") return@label
}
return "Alice might be somewhere"
}
val people = listOf(Person("Alice", 29), Person("Bob", 31))
assertEquals("Alice might be somewhere", lookForAlice(people))
val people2 = listOf(Person("Bob", 31))
assertEquals("Alice might be somewhere", lookForAlice(people2))
}
✅ 람다에 레이블을 붙여서 사용하는 대신 람다를 인자로 받는 인라인 함수의 이름을 return 뒤에 레이블로 사용해도 된다.
fun lookForAlice(people: List<Person>) {
people.forEach {
if (it.name == "Alice") return@forEach
}
println("Alice might be somewhere")
}
무명 함수: 기본적으로 로컬 return
무명 함수는 코드 블록을 함수에 넘길 때 사용할 수 있는 다른 방법
@Test
fun `무명 함수`() {
data class Person(val name: String, val age: Int)
fun lookForAlice(people: List<Person>) {
people.forEach(fun (person) {
if (person.name == "Alice") return
println("${person.name} is not Alice")
})
}
val people = listOf(Person("Alice", 29), Person("Bob", 31))
lookForAlice(people) // Bob is not Alice
}
무명 함수는 일반 함수와 비슷해 보이지만, 차이는 함수 이름이나 파라미터 타입을 생략할 수 있다는 점 뿐
무명 함수 안에서 레이블이 붙지 않은 return 식은 무명 함수 자체를 반환시킬 뿐 무명 함수를 둘러싼 다른 함수를 반환시키지 않는다.
return에 적용되는 규칙은 단순히 fun 키워드를 사용해 정의된 가장 안쪽 함수를 반환시킨다는 점
람다 식의 구현 방법이나 람다 식을 인라인 함수에 넘길 때 어떻게 본문이 인라이닝 되는지 등의 규칙을 무명 함수에도 모두 적용 가능