1. 제네릭
가. 학습 목표
•
제네릭 및 변성이 적용된 코드를 정확하게 이해할 수 있습니다. 코틀린 표준 라이브러리를 포함하여 다수의 라이브러리에서 제네릭과 변성의 개념을 적용하여, 클래스와 메서드의 시그니처를 설명하고 있습니다.
public interface List<out E> : Collection<E> {
override fun contains(element: @UnsafeVariance E): Boolean
override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
public operator fun get(index: Int): E
}
Kotlin
복사
fun <C, R> C.ifEmpty(
defaultValue: () -> R
): R where C : Array<*>, C : R
inline fun <T, C : MutableCollection<in T>> Iterable<T>.filterTo(
destination: C,
predicate: (T) -> Boolean
): C
Kotlin
복사
// ifEmpty 예시 코드
val emptyOrDefault: Array<Any> = emptyArray.ifEmpty { arrayOf("default") }
println(emptyOrDefault.contentToString()) // [default]
Kotlin
복사
// filterTo 예시 코드
val numbers: List<Int> = listOf(1, 2, 3, 4, 5, 6, 7)
val evenNumbers = mutableListOf<Int>()
numbers.filterTo(evenNumbers) { it % 2 == 0 }
Kotlin
복사
•
제네릭을 활용하여 OOP(LSP, OCP)에 따라 추상객체 기반으로 구현객체를 유연하게 결합할 수 있습니다. 객체 간 유연한 결합을 위해서는 부모 타입으로 관리되는 요소를 특정 자식 타입으로 반환 받을 수 있어야 하기 때문에 제네릭 개념이 등장했습니다. 제네릭과 변성에 대한 개념이 명확하지 않으면, 무분별한 type casting에 따라 컴파일타임 또는 런타임의 타입 불일치 이슈를 경험할 수 있습니다.
나. 기초 개념
1) 예시 코드 설명
•
주요 동작 코드
fun main() {
val cage = Cage()
cage.put(Carp(" "))
val carp: Carp = cage.getFirst() // Error: Type Mismatch
}
fun main() {
val cage = Cage()
cage.put(Carp(" "))
val carp: Carp = cage.getFirst() as Carp
}
fun main() {
val cage = Cage()
cage.put(Carp(" "))
val carp: Carp = cage.getFirst() as? Carp
?: throw IllegalArgumentException()
}
fun main() {
val cage = Cage2<Carp>()
cage.put(Carp("잉어"))
val carp: Carp = cage.getFirst()
}
fun main() {
val goldFishCage = Cage2<GoldFish>() goldFishCage.put(GoldFish(" "))
val cage = Cage2<Fish>()
cage.moveFrom(goldFishCage) // Error: Type Mismatch
}
Kotlin
복사
•
기본 객체
class Cage {
private val animals: MutableList<Animal> = mutableListOf()
fun getFirst(): Animal {
return animals.first()
}
fun put(animal: Animal) {
this.animals.add(animal)
}
fun moveFrom(cage: Cage) {
this.animals.addAll(cage.animals)
} }
class Cage2<T> {
private val animals: MutableList<T> = mutableListOf()
fun getFirst(): T {
return animals.first()
}
fun put(animal: T) {
this.animals.add(animal)
}
fun moveFrom(cage: Cage2<T>) {
this.animals.addAll(cage.animals)
} }
abstract class Animal(val name: String)
abstract class Fish(name: String) : Animal(name)
class GoldFish(name: String) : Fish(name)
class Carp(name: String) : Fish(name)
Kotlin
복사
2) 제네릭
•
제네릭 클래스: 타입 파라미터(T)로 정의된 클래스입니다.
class Cage2<T> {
// 클래스 본문 생략
}
Kotlin
복사
•
Raw Type: 제네릭 클래스를 인스턴스화할 때, 타입 매개변수를 전달하지 않는 것입니다. 자바에서는 지원하지만 코틀린에서는 지원하지 않습니다. 내부적으로 타입추론에 따라 타입을 지정할 수 있습니다.
List list = new ArrayList():
Java
복사
val list = mutableListOf<Int>()
val list = listOf('a', 'b', 'c')
Kotlin
복사
•
제네릭 제약: 제네릭 타입의 상속 관계를 제한하는 방법입니다. 이하 예시와 같이 제약을 걸게 되면 Animal과 이를 상속한 타입만 사용하도록 제한됩니다.
class Cage5<T : Animal> {
private val animals: MutableList<T> = mutableListOf()
}
Kotlin
복사
•
타입 소거: 런타임 환경에서 타입 파라미터가 제거되는 현상입니다. refined inline 키워드를 활용하면 타입 부분이 정적으로 재정의되므로 런타임 환경에서도 타입 정보를 확인할 수 있습니다.
•
Star Projection: 타입 파라미터로 어떤 것이 들어올지 알 수 없다는 표현입니다.
3) 변성
•
변성(variance): 타입 파라미터에 상속관계가 존재할 때, 양 타입으로 정의된 제네릭 클래스 사이에 대한 상속관계여부입니다.
•
무공변(불공변, in-variant): 타입 파라미터 간 상속관계가 존재하지만 제네릭 클래스 간에는 상속관계가 없는 상태입니다.
•
공변(co-variant): 타입 파라미터 간 상속관계가 동일하게 제네릭 클래스에도 적용되는 상태입니다.
코틀린에서는 변성 어노테이션 out을 사용하여 정의합니다.
•
반공변(contra-variant): 타입 파라미터의 상속관계에 반대로 제네릭 클래스에 적용되는 상태입니다.
코틀린에서는 변성 어노테이션 in을 사용하여 정의합니다.
•
변성 어노테이션(variance annotation): 변성을 정의하기 위한 방법입니다.
•
선언 지점 변성: 클래스 자체에 변성을 부여하는 방법입니다. 선언 지점 변성 시 해당 클래스는 데이터를 생산 또는 소비 중 하나만 할 수 있도록 제한됩니다.
class Cage3<out T> {
private val animals: MutableList<T> = mutableListOf()
fun getFirst(): T {
return animals.first()
}
fun getAll(): List<T> {
return this.animals
}
}
Kotlin
복사
•
사용 지점 변성: 클래스 메소드에 변성을 부여하는 방법입니다. 사용 지점 변성 시 해당 메소드 내부에서는 데이터를 생산 또는 소비 중 하나만 할 수 있습니다.
class Cage2<T> {
private val animals: MutableList<T> = mutableListOf()
fun getFirst(): T {
return animals.first()
}
fun put(animal: T) {
this.animals.add(animal)
}
fun moveFrom(cage: Cage2<out T>) {
this.animals.addAll(cage.animals)
}
}
Kotlin
복사
다. 변성 정의
1) 공변성과 불공변성
•
공변한 타입이 필요할 경우, 개발자가 직접 정의해야 합니다. Java 타입은 공변성에 따른 부작용을 방지하기 위해 기본적으로 불공변하도록 설계되어 있습니다.
•
공변성에 따른 부작용이란 컴파일 타임에서 타입 불일치에 따른 예외를 잡아낼 수 없다는 것입니다.
•
컬럭션 타입의 내부 요소에 대해 제네릭 타입을 지정할 경우, 컬렉션 타입에 대한 공변성이 필요하다면 추가적인 작업이 필요합니다.
Object[] objs = new String[]{"A", "B", "C"};
objs[0] = 1; // java.lang.ArrayStoreException: java.lang.Integer
Kotlin
복사
2) 사용지점 변성 정의
•
타입을 공변하게 정의하기 위해 타입 파라미터의 상속관계를 고려하여 제네릭 클래스의 상속관계를 지정할 필요가 있습니다.
•
variance annotation 사용하여 제네릭 클래스 간의 상속 관계를 지정할 수 있습니다. 단, 타입 안정성을 고려하여 사용처에 따라 별개의 variance annotation을 사용해야 합니다.
•
out(variance annotation): 타입 파라미터에 따라 제네릭 클래스 사이의 상속관계를 정의합니다. 타입 파라미터로 전달된 객체를 읽기 전용으로 사용합니다.
•
in(variance annotation): 타입 파라미터와 반대로 제네릭 클래스 사이의 상속관계를 정의합니다. 타입 파라미터로 전달된 객체를 쓰기 전용으로 사용합니다.
fun main() {
val goldFishCage = Cage2<GoldFish>() goldFishCage.put(GoldFish(" "))
val cage = Cage2<Fish>()
cage.moveFrom(goldFishCage) // Error: Type Mismatch
}
class Cage2<T> {
private val animals: MutableList<T> = mutableListOf()
fun getFirst(): T {
return animals.first()
}
fun put(animal: T) {
this.animals.add(animal)
}
fun moveFrom(cage: Cage2<out T>) {
this.animals.addAll(cage.animals)
}
fun moveTo(otherCage: Cage2<in T>) {
otherCage.animals.addAll(this.animals)
}
}
Kotlin
복사
3) 선언지점 변성 정의
•
메소드가 아닌 객체에 변성을 정의하는 방법입니다. 선언지점 변성은 Java에서 지원하지 않고, Kotlin에서만 지원합니다.
•
이하의 예시 코드에서 @UnsafeVariance 어노테이션이 적용되면 변성의 정의에 에외를 둔다는 것을 의미합니다.
public interface List<out E> : Collection<E> {
override fun contains(element: @UnsafeVariance E): Boolean
override fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
public operator fun get(index: Int): E
}
Kotlin
복사
라. 제네릭 활용
1) 제네릭 쉐도윙 이슈
•
객체의 멤버 변수와 메소드 내 지역변수의 관계와 마찬가지로 타입 파라미터에도 정의된 위치에 따라 주입되는 곳이 달라집니다.
•
이하의 예시코드에서 Carp를 추가했지만 타입 불일치 이슈가 발생하지 않습니다. 왜냐하면 Cage 정의 시 전달한 타입 파라미터와 Cage::addAnimal에 전달한 타입 파라미터가 다르기 때문입니다.
class Cage<T : Animal> {
fun <T : Animal> addAnimal(animal: T) {
} }
val cage = Cage<GoldFish>()
cage.addAnimal(GoldFish(" "))
cage.addAnimal(Carp(" "))
Kotlin
복사
2) 제네릭 클래스 상속
•
제네릭 클래스를 상속하는 방법에는 두 가지가 있습니다. 하나는 자식 클래스에서 부모 클래스의 타입 파라미터를 함께 명시하는 것입니다. 다른 방법은 자식 클래스에서는 타입을 명시하지 않고, 부모 클래스에서만 타입을 특정하여 정의하는 것입니다.
class CageV2<T: Animal> : CageV1<T>() {}
Kotlin
복사
class GoldFishCageV2 : CageV1<GoldFish>() {}
Kotlin
복사
3) Type Alias 활용
•
타입 파라미터 자체가 너무 길면, typealias를 활용하여 타입을 변수화할 수 있습니다.
typealias PersonDtoStore = Map<PersonDtoKey, MutableList<PersonDto>>
fun handleCacheStore(store: PersonDtoStore) {}
Kotlin
복사
2. 지연과 위임
가. 학습 목표
•
지연 및 위임 키워드의 내부 동작 원리를 이해하고, 적재적소에 활용할 수 있습니다.
•
지연 컬렉션의 동작을 이해하고, 적재적소에 활용할 수 있습니다.
나. 기초 개념
•
property: 클래스의 field와 accessor method를 엮은 개념입니다.
•
backing property: 외부에 드러나지 않는 property로, 외부에 드러나는 property의 보조격으로 사용됩니다. 언더바(_)를 변수명에 붙이는 것이 코틀린 표준입니다.
class Person {
private var _name: String? = null // backing property
val name: String
get() {
if (_name == null) {
Thread.sleep(2_000)
this._name = " "
}
return _name!!
}
}
Kotlin
복사
•
lateinit: 객체의 인스턴스화 시점과 분리하여 변수를 초기화하는 키워드입니다.
class Person {
lateinit var name: String
}
Kotlin
복사
•
by lazy: 초기화에 따른 비용이 발생할 경우, 속성이 사용되는 시점(get)에 초기화 로직을 수행합니다.
class Person {
val name: String by lazy {
Thread.sleep(2_000)
"테스트"
}
}
Kotlin
복사
다. 지연과 위임 키워드
1) lateinit vs by lazy
•
공통점: 키워드를 붙여서 변수를 정의하면 nullable type으로 정의되지만 null을 값으로 주입받는 것을 방지할 수 있습니다.
•
차이점: lateinit 키워드는 var로 정의된 property에 대해 초기화를 지연하기 때문에 여러번에 걸쳐서 다른 초기화 값을 할당할 수 있습니다. by lazy의 경우 val로 정의된 property에 대해 단 1회만 초기화 로직을 수행할 수 있습니다.
2) lateinit vs not null
•
lateinit은 원시타입(Int, Long)에 대해 사용할 수 없지만, not null 키워드는 원시타입에 대해 사용할 수 있습니다.
class Person {
var age: Int by notNull()
}
Kotlin
복사
라. 지연 컬렉션
1) Sequence vs Iterable
•
Iterable은 중간 연산에 따라 중간 결과를 담은 Collection이 생성됩니다. 다시 말해서 모든 요소에 대해 모든 연산이 적용됩니다.
•
Sequence는 요소별로 연산을 적용하고 최종 연산에 따른 결과를 새로운 Collection에 반영합니다.
•
대용량의 데이터를 대상으로 연산을 한다면 Sequence 사용이 적절합니다. 하지만 저용량의 데이터라면 Sequence 사용에 따른 오버헤드가 발생하여 성능이 상대적으로 떨어질 수 있습니다.
2) Sequence vs Stream
•
두 가지 모두 지연 컬렉션이고, JDK 1.8 이상의 환경에서 코틀린을 사용한다면 Stream을 사용할 수 있습니다.
•
코틀린 환경에서는 Sequence 보다 Stream 사용을 권장합니다. Sequence에는 Inline 등의 코틀린 중심의 최적화가 되어 있기 때문입니다.
3. 함수형 프로그래밍
가. 학습 목표
•
코틀린 함수 리터럴의 내부 동작 원리를 이해할 수 있습니다.
•
람다식과 익명함수를 상황에 따라 최적화하여 활용할 수 있습니다.
나. 기초 개념
•
1급 시민(first-class citizen): 일반적으로 다음의 조건을 만족하면 1급 시민으로 분류합니다. 함수의 매개변수 및 반환타입으로 지정 가능합니다. 또한 변수에 할당 가능합니다. 코틀린의 함수는 앞의 조건을 만족하지만 자바의 함수는 그렇지 못합니다.
•
함수 리터럴(또는 함수값): 1급 시민으로 간주할 수 있는 함수입니다. 함수의 마지막 매개변수로 함수 타입을 정의할 경우, 해당 함수 호출 시 함수타입을 중괄호로 빼서 전달할 수 있습니다.
compute(5, 3) { a, b -> a + b }
Kotlin
복사
•
람다: 일반적으로 이름 없는 함수를 가리킵니다.
•
return: 키워드가 정의된 함수를 종료하고 값을 반환하는 키워드입니다.
•
고차함수(higher order function): 함수를 매개변수 또는 반환타입으로 정의할 수 있는 함수입니다.
•
람다식/익명함수: 둘 다 함수 리터럴의 일종입니다. 두 개념의 차이에 대해서는 이하의 본문을 참고하세요.
•
inline 함수: 컴파일 시 함수의 정의부가 함수의 호출부에 재정의되어 실행되는 함수입니다. 결과적으로 함수 호출부에서도 다른 함수를 호출하는 과정이 생략되므로 성능 상 이점이 있습니다.
•
SAM(Single Abstract Method) Interface: 추상 메소드가 하나만 존재하는 인터페이스입니다.
다. 함수 타입 활용
1) 매개변수로 함수 리터럴 전달
•
함수 타입을 매개변수로 받는 함수에 대해 아래와 같이 람다식을 매개변수로 전달할 수 있습니다.
compute(5, 3) { a, b -> a + b }
fun compute(num1: Int, num2: Int, op: (Int, Int) -> Int): Int {
return op(num1 ,num2)
}
Kotlin
복사
•
동일한 함수타입 활용 함수에 대해 아래와 같이 익명함수를 매개변수로 전달할 수 있습니다.
compute(5, 3, fun(a, b) = a + b) // 매개변수 타입 X, 반환 타입 X
compute(5, 3, fun(a: Int, b: Int) = a + b) // 매개변수 타입 O, 반환 타입 O
compute(5, 3, fun(a: Int, b: Int): Int { // 매개변수 타입 O, 반환 타입 O, return O
return a + b
})
Kotlin
복사
2) return 키워드 활용
•
람다식 활용 시 내부에 return 키워드를 사용할 수 없으므로 early return 방식으로 구현할 수 없습니다.
iterate(listOf(1, 2, 3, 4, 5)) { num ->
if (num == 3) {
return // 'return' is not allowed here
}
println(num)
}
fun iterate(numbers: List<Int>, exec: (Int) -> Unit) {
for (number in numbers) {
exec(number)
}
}
Kotlin
복사
•
익명함수 활용 시 return 키워드를 사용할 수 있습니다. early return 구현이 가능합니다.
iterate(listOf(1, 2, 3, 4, 5), fun(num) {
if (num == 3) {
return // 'return' is allowed here
}
println(num)
})
Kotlin
복사
•
단, inline 키워드로 정의된 함수에 람다식을 매개변수로 전달한 경우, 이를 호출하는 함수에서는 람다식 내부에서 return을 사용할 수 있습니다. 이 때, 람다식 내부의 return은 외부 함수에 대한 return을 의미합니다. 이하 코드에서 return은 main 함수의 종료를 의미합니다.
fun main() {
iterate(listOf(1, 2, 3, 4, 5)) { num ->
if (num == 3) {
return
}
println(num)
}
}
inline fun iterate(numbers: List<Int>, crossinline exec: (Int) -> Unit) {
for (num in numbers) {
exec(num)
}
}
Kotlin
복사
3) 확장 함수의 함수 타입
•
이하 예시에서 첫번째 Int가 수신객체 타입입니다. this가 수신객체를 가리킵니다. 확장함수에 전달된 매개변수는 수신객체와 구분하기 위해 소괄호로 감쌉니다.
•
이하의 예시의 타입은 Int.(Long) → Int가 됩니다.
fun Int.add(other: Long): Int = this + other.toInt()
Kotlin
복사
라. 함수 리터럴 원리와 최적화
1) 함수 리터럴 동작 원리
•
함수 리터럴 사용 시 Compile 과정에서 새로운 객체를 생성하게 되므로 오버헤드가 발생합니다.
•
함수 리터럴로 정의된 것은 컴파일 과정에서 FunctionN 객체로 생성됩니다. N은 함수리터럴에 전달되는 매개변수의 개수를 의미합니다.
•
함수 리터럴 내부에서 참조하는 외부 변수는 Ref 객체로 감싸서 참조합니다.
// origin
compute(2, 3) { num1, num2 -> num1 * num2 }
// decompiled
compute(2, 3, (Function2)null.INSTANCE);
// origin
fun compute(num1: Int, num2: Int, op: (Int, Int) -> Int): Int {
return op(num1, num2)
}
// decompiled
public static final int compute(int num1, int num2, @NotNull Function2 op) {
return ((Number)op.invoke(num1, num2)).intValue();
}
Kotlin
복사
2) 함수 리터럴 최적화
•
함수 리터럴 사용 시 함수 객체가 인스턴스화되기 때문에 만약 함수 리터럴을 반복 사용하는 케이스가 있다면 inline 함수를 활용하여 최적화 처리가 필요합니다.
•
함수 리터럴을 매개변수로 받는 함수에 대해 인라인을 적용하면, 해당 함수 뿐만 아니라 매개변수로 전달된 함수 리터럴까지도 인라인됩니다.
fun main() {
repeat(2) { println("Hello World") }
}
inline fun repeat(times: Int, exec: () -> Unit) {
for (i in 1..times) {
exec()
}
}
public static final void main() {
int i$iv = 1;
while(true) {
System.out.println("Hello World");
if (i$iv == 2) {
return;
}
++i$iv;
}
}
Kotlin
복사
마. SAM & Reference 활용
1) 코틀린과 자바의 SAM 정의
•
아래와 같이 코틀린과 자바에서 SAM을 정의할 수 있습니다.
•
코틀린에서 SAM을 정의할 때 주의할 점은 interface 앞에 fun 키워드를 반드시 붙여야 합니다.
fun interface KStringFilter {
fun predicate(str: String): Boolean
}
Kotlin
복사
@FunctionalInterface
public interface StringFilter {
abstract public boolean predicate(String str);
}
Java
복사
2) 코틀린의 SAM 활용
•
코틀린은 자바와 달리 람다식 만으로 SAM을 인스턴스화할 수 없습니다.
•
아래와 같이 SAM 생성자를 활용해야 합니다. 다음을 SAM 생성자라 합니다. StringFilter { }
val filter: StringFilter = { s -> s.startsWith("A") } // Error: Type mismatch
val filter = StringFilter { s -> s.startsWith("A") } // SAM 생성자 활용
Kotlin
복사
3) callable reference
•
:: 활용하여 이미 정의된 함수를 변수에 할당하는 것을 호출가능 참조(callable reference)라고 합니다.
fun add(a: Int, b: Int): Int {
return a + b
}
fun main() {
val add3 = ::add
}
Kotlin
복사
•
:: 뒤에 클래스 이름을 붙이면 클래스 생성자에 대한 호출가능 참조를 적용할 수 있습니다.
fun main() {
val personConstructor = ::Person
}
class Person(
val name: String,
val age: Int
)
Kotlin
복사
4) callable reference 내부 원리
•
코틀린에서 호출 가능 참조의 결과값은 리플렉션 객체이므로 일급 시민으로 활용할 수 있습니다. 하지만 자바의 경우 함수형 인터페이스(Consumer, Supplier)이기 때문에 일급 시민으로 활용할 수 없습니다.
•
자바의 경우, 참조만으로 객체를 바로 얻을 수 없고, 메소드 이름에 대한 문자열을 전달하여 리플렉션을 완성하는 과정이 필요합니다.
•
코틀린의 경우, 참조만으로 리플렉션으로 객체를 바로 얻을 수 있습니다. 코틀린의 리플렉션 API (kotlin.reflect)를 사용하여 호출 가능 참조를 변수에 할당하고 직접 호출 가능합니다.
import java.lang.reflect.Method;
import java.util.function.Function;
public class Main {
public static void main(String[] args) throws Exception {
Function<String, Integer> func = Integer::parseInt;
int result = func.apply("123");
System.out.println(result); // 출력: 123
Method method = Main.class.getMethod("exampleMethod");
method.invoke(null)
}
public static void exampleMethod() {
System.out.println("Hello, Reflection!");
}
}
Java
복사
import kotlin.reflect.KFunction
fun exampleMethod() {
println("Hello, Reflection!")
}
fun main() {
val parseInt: (String) -> Int = String::toInt
val result = parseInt("123")
println(result) // 출력: 123
val method: KFunction<Unit> = ::exampleMethod
method.call()
}
Kotlin
복사
5) bound callable reference
•
인스턴스에 대한 호출 가능 참조를 얻는 것을 바인딩된 호출 가능 참조(bound callable reference)라고 합니다.
•
바인딩된 호출 가능 참조는 클래스의 멤버 외에도 확장함수에도 적용할 수 있습니다.
val p1 = Person("A", 100)
val boundingGetter = p1::name.getter
fun Int.addOne(): Int {
return this + 1
}
fun main() {
val plus = Int::addOne
}
Kotlin
복사
4. Kotlin DSL
가. 학습목표
•
Kotlin Gradle에 적용된 Kotlin DSL의 내부 원리를 이해할 수 있습니다.
나. 기초개념
•
연산자 오버로딩: ‘+’, ‘-’와 같이 연산자에 해당하는 함수를 오버로딩하여 맥락에 따라 간편하게 함수를 호출하는 기능입니다.
•
DSL(Domain Specific Language): 일부 domain에 한해서 선언적인 형태로 간략하게 사용되는 언어입니다.
•
내부 DSL: DSL의 단점인 추가적인 학습 비용을 상쇄하기 위해 범용 프로그래밍 언어 기반의 DSL입니다.
•
Kotlin DSL: Kotlin 기반의 내부 DSL입니다.
다. Kotlin Gradle & DSL
1) invoke 관례와 Kotlin Gradle
•
Kotlin Gradle에서 특정 설정 항목이 많을 경우 중첩된 블록 구조를 사용할 수 있습니다.
•
invoke 관례를 활용하여 중첩된 블록 구조를 간단하게 만들 수 있습니다.
val dependencies = DependencyHandler()
dependencies.compile("io.kotest:kotest-runner-junit5")
dependencies {
compile("io.kotest:kotest-runner-junit5")
compile("io.kotest:kotest-assertions-core")
compile("io.kotest:kotest-property")
}
// 위의 invoke 관례의 변환 결과
// dependencies.invoke({
// this.compile("io.kotest:kotest-runner-junit5")
// this.compile("io.kotest:kotest-assertions-core")
// this.compile("io.kotest:kotest-property")
// })
class DependencyHandler {
fun compile(coordinate: String) {
// 생략
}
operator fun invoke(body: DependencyHandler.() -> Unit) {
this.body()
}
}
Kotlin
복사
2) invoke 관례
•
관례란 특수 문법에 따라 일부 함수를 더 간단한 방법으로 호출하는 기능입니다. ex) boo.get(bar) → boo[bar]
•
invoke 관례란 invoke 함수가 정의된 객체를 간단하게 호출하는 것입니다. 오직 소괄호와 매개변수만을 사용하여, 마치 함수를 호출하는 것처럼 사용할 수 있습니다. ex) boo.invoke(bar) → boo(bar)
•
invoke 관례를 활용하면 람다 본문을 유연하게 사용할 수 있습니다. 람다 본문이 복잡할 경우, 인터페이스를 구현하는 방향으로 람다를 분리할 수 있습니다.
라. Kotest & DSL
1) Kotest 내부 DSL(중위함수 호출 연쇄 활용)
•
Kotest 내 중위 함수(should, with)를 연쇄적으로 호출했습니다.
•
중위함수 호출 연쇄가 가능한 이유는 should 함수가 with이라는 함수를 호출 할 수 있는 래퍼 객체를 반환하기 때문입니다.
// 중위 함수 호출 연쇄
"kotlin" should start with "kot"
// 일반 함수 호출 연쇄
"kotlin".should(start).with("kot")
object start
infix fun String.should(x: start): StratWrapper = StartWrapper(this)
class StartWrapper(val value: String) {
infix fun with(prefix: String) = // ...
}
Kotlin
복사
2) 중위함수 호출 연쇄
•
중위함수란 수신객체와 하나의 매개변수가 존재할 경우, 간편하게 호출하도록 정의한 함수입니다다.
•
중위함수 호출 연쇄를 활용하여 마치 영어 문장을 읽는 것과 유사한 수준의 가독성을 가질 수 있습니다.
// 중위 함수 정의
infix fun Int.add(other: Int): Int {
return this + other
}
// 중위 함수 호출
val result = 1 add 2
println("Result is $result") // 출력 결과: Result is 3
Kotlin
복사
5. 어노테이션 & 리플렉션
가. 학습 목표
•
어노테이션 정의 시 적용할 수 있는 여러 특성을 이해하고 활용할 수 있습니다.
•
코틀린 리플렉션과 자바 리플렉션의 차이를 이해할 수 있습니다.
나. 기초 개념
•
Retention: 어노테이션이 유지되는 순간을 제한합니다. 기본값으로 런타임에 어노테이션이 유지되고 리플렉션을 사용할 수 있습니다.
import kotlin.annotation.AnnotationRetention
import kotlin.annotation.Retention
@Retention(AnnotationRetention.RUNTIME)
annotation class RuntimeAnnotation(val message: String)
@RuntimeAnnotation("This annotation is retained at runtime")
class MyClass
fun main() {
val annotations = MyClass::class.annotations
annotations.forEach {
println("Annotation: ${it.annotationClass.simpleName}, Message: ${(it as RuntimeAnnotation).message}")
}
}
Kotlin
복사
•
Target: 어노테이션의 적용 대상을 제한합니다. 기본값으로 모든 대상에 적용 가능합니다.
import kotlin.annotation.AnnotationTarget
import kotlin.annotation.Target
@Target(AnnotationTarget.FUNCTION)
annotation class FunctionAnnotation(val description: String)
class AnotherClass {
@FunctionAnnotation("This annotation is only for functions")
fun annotatedFunction() {
println("This function is annotated")
}
}
fun main() {
val function = AnotherClass::class.members.find { it.name == "annotatedFunction" }
val annotations = function?.annotations
annotations?.forEach {
println("Annotation: ${it.annotationClass.simpleName}, Description: ${(it as FunctionAnnotation).description}")
}
}
Kotlin
복사
•
Repeatable: 어노테이션 적용 대상에 반복 적용이 가능합니다.
import kotlin.annotation.Repeatable
@Repeatable
annotation class RepeatableAnnotation(val info: String)
class ExampleClass {
@RepeatableAnnotation("First annotation")
@RepeatableAnnotation("Second annotation")
fun repeatedAnnotations() {
println("This function has repeatable annotations")
}
}
fun main() {
val function = ExampleClass::class.members.find { it.name == "repeatedAnnotations" }
val annotations = function?.annotations
annotations?.forEach {
println("Annotation: ${it.annotationClass.simpleName}, Info: ${(it as RepeatableAnnotation).info}")
}
}
Kotlin
복사
다. 코틀린 리플렉션
1) KClass<T>와 Class<T>
•
코틀린에서 KClass<T>와 Class<T>는 객체의 클래스 유형을 나타냅니다.
•
Class<T>가 자바 리플렉션 클래스, KClass<T>는 그 코틀린 리플렉션 클래스입니다.,
•
리플렉션 클래스에서 클래스의 메타데이터를 추출할 수 있는 메소드가 정의되어 있습니다.
•
::class 구문을 사용하면 리플렉션 클래스의 인스턴스를 얻을 수 있습니다.
import kotlin.reflect.KClass
class MyClass
fun main() {
val kClass: KClass<MyClass> = MyClass::class
val javaClass: Class<MyClass> = MyClass::class.java
println("KClass: $kClass")
println("Java Class: $javaClass")
}
Kotlin
복사
2) KClass.cast()
•
KClass.cast() 함수는 객체를 지정된 클래스 타입으로 캐스팅할 때 사용합니다.
•
이 방식으로 런타임에 객체가 예상된 타입인지 확인할 수 있습니다.
import kotlin.reflect.full.cast
fun main() {
val kClass: KClass<MyClass> = MyClass::class
val obj: Any = MyClass()
val castedObj: MyClass = kClass.cast(obj)
println("Casted Object: $castedObj")
}
Kotlin
복사
3) 리플렉션과 DI 컨테이너
•
리플렉션은 의존성 주입(DI) 컨테이너에서 클래스의 인스턴스를 동적으로 생성하고 런타임에 의존성을 주입하는 데 사용됩니다.
import kotlin.reflect.full.createInstance
import kotlin.reflect.full.primaryConstructor
class Service
class Controller(val service: Service)
fun main() {
val serviceClass: KClass<Service> = Service::class
val controllerClass: KClass<Controller> = Controller::class
val service: Service = serviceClass.createInstance()
val controller: Controller = controllerClass.primaryConstructor!!.call(service)
println("Controller: $controller with Service: $service")
}
Kotlin
복사
4) 리플렉션과 타입 안전성
•
코틀린에서 리플렉션은 타입 안전성을 염두에 두고 설계되어 있기 때문에 런타임 예외가 발생하지 않습니다.
•
코틀린의 리플렉션 API는 타입 정보를 보존하고 검사하여 컴파일 타임 보장을 제공하며, ClassCastException의 발생 가능성을 줄입니다.
fun <T : Any> printClassName(kClass: KClass<T>) {
println("Class name: ${kClass.simpleName}")
}
fun main() {
printClassName(Service::class)
printClassName(Controller::class)
}
Kotlin
복사
참고
•
최태현, 코틀린 고급편, https://www.inflearn.com/course/코틀린-고급편
•
드미트리 제메로프 & 스베트라나 이사코바, Kotlin In Action
•
제네릭 변성의 실무 활용 예시, https://www.inflearn.com/questions/1115827/comment/305993
•
lateinit의 실무 활용 예시, https://www.inflearn.com/questions/1098327