본문 바로가기
코틀린/개발자를 위한 코틀린 프로그래밍

[chapter12] 제네릭 알아보기

by 측면삼각근 2023. 10. 21.
728x90
반응형

들어가기 전에

본 포스팅은 개발자를 위한 코틀린 프로그래밍의 chapter단위로 공부하고, 정리, 부족한 내용의 추가 학습내용을 정리하는 블로깅입니다.

이전 포스팅 ⬇️

[chapter11] 위임(delegation) 확장알아보기

 

[chapter11] 위임(delegation) 확장알아보기

들어가기 전에 본 포스팅은 개발자를 위한 코틀린 프로그래밍의 chapter단위로 공부하고, 정리, 부족한 내용의 추가 학습내용을 정리하는 블로깅입니다. 이전 포스팅 ⬇️ [chapter10] 함수 추가사항

messycode.tistory.com

01 제네릭 알아보기

  • 가능: 함수, 클래스, 추상 클래스, 인터페이스, 확장함수, 확장속성
  • 불가능: object정의, 동반객체, object 표현식
    ➡ 하나의 객체만 만들므로, 특별히 일반화할 필요가 없다. 
  • 클래스와 object 내의 멤버는 별도의 제네릭으로 만들 수 없다.
fun <T> printArray2(array: Collection<T>, print: (T) -> Unit) {
    for (element in array) {
        print(element)
    }
}

// 반환 타입이 분리된 제네릭인 fun2 예시
fun <T, R> generateArray(array: Collection<T>, generate: (T) -> R): List<R> {
    val result = mutableListOf<R>()
    for (element in array) {
        result.add(generate(element))
    }
    return result
}

fun main() {
    val array = listOf<Int>(1, 2, 3, 4, 5)
    printArray2(array, ::println)

    val result = generateArray(array, Int::toString)
    printArray2(result, ::println)
}

 

타입 매개변수에 특정 자료형을 제한하기

// 타입 파라미터의 제약
fun <T : Number> List<T>.sum(): T {
    var sum = 0.0
    for (i in this) {
        sum += i.toDouble()
    }
    return sum as T
}

fun main() {
    val list = listOf(1, 2, 3)
    println(list.sum()) // 6

    val list2 = listOf(1.1, 2.2, 3.3)
    println(list2.sum()) // 6.6
    
//     val list3 = listOf("a", "b", "c")
//     println(list3.sum()) // 컴파일 에러
}

제네릭 확장함수와 제네릭 확장 속성

제네릭으로 확장함수 만들기

// 타입 매개변수를 사용해서 확장함수 만들기
fun <T> T.easyPrint(): T {
    println(this)
    return this
}

fun main(){
    val a = 10
    val b = 20
    val c = 30

    a.easyPrint()
    b.easyPrint()
    c.easyPrint()
}

함수 자료형에 제네릭 확장함수 만들기

// 함수 자료형에 제네릭 확장함수 만들기
infix fun <T, R, P> ((T) -> P).andThen(f: (R) -> T)
        : (R) -> P = { x: R -> this(f(x)) }

fun main() {
    val square = { x: Int -> x * x }
    val triple = { x: Int -> x * 3 }
    val squareOfTriple = square andThen triple
    println(squareOfTriple(2)) // 36
}

제네릭 클래스

  • 클래스 명 다음에 꺽쇠괄호로 타입 매개변수를 지정
  • 매개변수, 속성, 메서드에 지정 가능
  • 컴파일 타임에는 임의의 자료형을 반영한다.
    특정 클래스에 대하 정보가 없고 실제 실행될 때 이 자료형의 정보를 전달해야 한다.
  • 타입 인자는 타입 매개변수에 실제 자료형을 매칭시켜 런타임에는 명시적인 자료형으로 처리할 수 있다.
// 제네릭 클래스
class Box<T>(t: T) {
    var value = t
}

fun main(){
    val box1: Box<Int> = Box<Int>(1)
    val box2 = Box(1) // 타입 추론
    val box3 = Box("Hello")
    println(box1.value)
    println(box2.value)
    println(box3.value)
}

제네릭 인터페이스

  • 일반 인터페이스와 동일이 상속도 가능하다.
// 제네릭 인터페이스 예제
interface AnimalAble<T> {
    val obj: T
    fun get(): T
}

class Dog {
    fun bark() = println("bark")
}

class Animal<T>(override val obj: T) : AnimalAble<T> {
    override fun get(): T = obj
}

fun main() {
    val animal: Animal<String> = Animal("강아지")
    animal.get()
    // 강아지

    val dog: Animal<Dog> = Animal(Dog())
    dog.get().bark()
    // bark
}
728x90

02 변성 알아보기

아래 내용은 책보다는 [Kotlin] Generics - 공변성(covariant)과 반공변성.. 의 내용입니다.

 

[Kotlin] Generics - 공변성(covariant)과 반공변성(contravariant)

What is generic? Generic이란 Class 또는 method에서 매개변수에 사용되는 자료형의 정의를 개체 생성시 정하게 하여 타입에 대한 안정성을 높이는 도구를 말한다. 일반적으로 로 표기된다. Generic 사용의

deep-dive-dev.tistory.com

변성의 종류

  1. 불면성(무변성; invariance)
  2. 공변성(covariance)
  3. 반공변성(contravariant)

1. 불변성

상속 관계에 상관없이 자신의 타입만 허용한다. 코틀린에서는 따로 지정해주지 않으면 기본적으로 모든 Generic은 무공변성이다.

open class Alcohol
class Soju : Alcohol()

interface Drinker<T> {
    fun drink()
}

fun varianceTest(input: Drinker<Alcohol>){
    input.drink()
}

fun main() {

    val alchol: Drinker<Alcohol> = object:Drinker<Alcohol>{
        override fun drink(){
            println("Drink!")
        }
    }

    val soju: Drinker<Soju> = object:Drinker<Soju>{
        override fun drink(){
            println("Drink Soju!")
        }
    }

    // Success
    println(varianceTest(alchol)) // Drink!

    // Error
    // println(varianceTest(soju))
    // Type mismatch: inferred type is Drinker<Soju> but Drinker<Alcohol> was expected
}

예시에서 Soju는 alcohol을 상속받았지만, 별도로 공변성을 지정하지 않았으므로, 무 공변성인 상태이다.
따라서 입력으로 Drinker <alcohol> 타입을 받는 함수 varianceTest에서 Drinker <Soju>를 입력하면 Type missmatch에러가 난다.

2. 공변성(covariant)

자기 자신자식 객체를 허용한다. 자바에서 <? extends T>와 같다. kotlin에서는 out키워드를 사용해 이를 표기한다.

open class Alcohol
class Soju : Alcohol()

interface Drinker<T> {
    fun drink()
}

fun varianceTest(input: Drinker<out Alcohol>){
    input.drink()
}

fun main() {

    val alchol: Drinker<Alcohol> = object:Drinker<Alcohol>{
        override fun drink(){
            println("Drink!")
        }
    }

    val soju: Drinker<Soju> = object:Drinker<Soju>{
        override fun drink(){
            println("Drink Soju!")
        }
    }

    val any: Drinker<Any> = object:Drinker<Any>{
        override fun drink(){
            println("Drink Any!")
        }
    }

    // Success
    println(varianceTest(alchol)) // Drink!

    // Success
    println(varianceTest(soju)) // Drink Soju!

    // Error
    println(varianceTest(any)) //Type mismatch: inferred type is Drinker<Any> but Drinker<out Alcohol> was expected

}

varianceTest함수에서 입력을 Drinker<out Alcohol>로 지정하였으므로 하위 타입인 Soju를 모두 입력받을 수 있다.

  • Soju는 Alcohol의 하위타입일 때
    Drinker<Soju>는 Drinker <Alcohol>의 하위타입이므로
    Drinker는 타입 인자 T에 공변적이다.

3. 반공변성(contravariance)

공변성의 반대이다. 자기 자신과 부모 객체만 허용한다. Java에서 <? super T>와 같다. Kotlin에서는 in 키워드를 사용해서 표현한다.

open class Alcohol
class Soju : Alcohol()

interface Drinker<T> {
    fun drink()
}

fun varianceTest(input: Drinker<in Alcohol>){ // in keyword 추가
    input.drink()
}

fun main() {

    val alchol: Drinker<Alcohol> = object:Drinker<Alcohol>{
        override fun drink(){
            println("Drink!")
        }
    }

    val soju: Drinker<Soju> = object:Drinker<Soju>{
        override fun drink(){
            println("Drink Soju!")
        }
    }

    val any: Drinker<Any> = object:Drinker<Any>{
        override fun drink(){
            println("Drink Any!")
        }
    }

    // Success
    println(varianceTest(alchol)) // Drink!

    // Error
    println(varianceTest(soju)) // Type mismatch: inferred type is Drinker<Soju> but Drinker<in Alcohol> was expected

    // Success
    println(varianceTest(any)) // Drink Any!

}

varianceTest함수에서 함수 입력을 Drinker<in Alcohol>로 지정하였으므로, AlcoholAlcohol의 상위타입(예시 코드에서는 별도로 지정된 상위타입이 없고, Any는 모든 객체의 상위타입) 입력을 사용할 수 있다.

out? in?

왜 out과 in이라는 키워드를 사용하는가?

이는 Producer(생산자, read-only)와 Consumer(소비자, write-only)에 빗댄것이다.
(C#에서도 코틀린과 같이 out, in 키워드를 사용한다)
  • class C가 type Parameter T에 대해 공변성(out)을 가지면 c는 T의 생산자이다. (Producer)
  • T가 반공변성(in)을 가지면 소비만 할 수 있고 생산될 수는 없다. (Consumer)
- class C가 type T를 생산 => (out)
- class C가 type T를 소비 => (in)

Any vs *(Star-projections)

[Kotlin] *(Star-projections)과 Any의 차이점

 

[Kotlin] *(Star-projections)과 Any의 차이점

특정 코드를 여러 타입에 대해 재사용 하고 싶을 때, 정확히 어떤 타입이 들어오게 될 지 미리 알 수 없을 때 제네릭 타입을 사용한다. 코틀린에서 모든 타입을 받기 위해 사용하는 Any, *가 어떻

zion830.tistory.com

Any

코틀린에서 모든 타입이 상속받는 최상위 타입. java로 디컴파일 시 Object 변환, euals(), hashCode(), toString()이 존재한다.

제네릭으로 Any를 사용하면, 어떤 객체를 집어넣든 업캐스팅이 적용되어 모든 타입이 들어갈 수 있게 된다.

*(Star-projections)

*는 어떤 타입이 들어올지 미리 알 수 없어도, 그 타입을 안전하게 사용하고 싶을 때 사용한다.

언제든지 모든 타입을 받을 수 있는 Any와 달리, 한 번 구체적인 타입이 정해지고 나면 해당 타입만 받을 수 있다.

val arrayList = arrayListOf<*>() // error!
// Projections are not allowed on type arguments of functions and properties
// 이런식으로 작성하면 arrayList의 타입이 영원히 결정되지 못하므로 syntax error가 발생한다.

ArrayList가 어떤 타입으로 초기화 될지 알 수 없으므로, syntax error가 발생한다.

fun acceptStarList(list: ArrayList<*>) {
    list.add("문자열") // error!
    list.add(1)      // error!
}

*는 구체적인 타입이 정해지기 전까지는 Any?로 취급된다.

하지만 *와 Any?는 다르다.

왜냐하면 <Any?> 는 모든 타입을 담을 수 있음을 의마하는 반면, <*>는 어떤 타입이라도 들어올 수 있으나, 구체적인 타입이 결정되는 과정이 진행되고, 일단 타입이 결정되면 그 타입과 하위타입의 원소만 담을 수 있다.
즉, 구체적인 타입이 결정된다.
fun acceptStarList(list: List<*>) {
    if (list.isNotEmpty()) {
        val item = list[0] // val item: Any?
    }
}

이런 식으로 SuperClass 또는 SuperClass의 자식 클래스만 들어올 수 있게 범위가 지정된 제네릭 클래스가 있다고 가정해보자.

open class SuperClass
class Child : SuperClass()

open class TestClass

class GenericClass<out T : SuperClass>() { }

여전히 T 자리에 어떤 타입이 들어올지 모르는 시점에서 GenericClass를 인자로 받는 메서드를 선언하기 위해 * 키워드를 사용했다. 그러면 acceptStart()를 호출할 때 지정해둔 범위에 맞지 않는 타입이 들어갔을 경우 syntax error가 발생한다.

fun acceptStar(value: GenericClass<*>) {}

fun main() {
    acceptStar(GenericClass<Child>())
    acceptStar(GenericClass<SuperClass>())
    acceptStar(GenericClass<TestClass>()) // error!
}

03 리플렉션 알아보기

[Kotlin] Reflection이란 무엇일까?

 

[Kotlin] Reflection이란 무엇일까?

그놈의 Reflection! Reflection 완전 정복하러 가기

medium.com

[Java] Reflection은 무엇이고 언제/어떻게 사용하는 것이 좋을까?Reflection

 

[Java] Reflection은 무엇이고 언제/어떻게 사용하는 것이 좋을까?

구체적인 Class Type을 알지 못하더라도 해당 Class의 method, type, variable들에 접근할 수 있도록 해주는 자바 API이며,Complie time이 아닌 Runtime에 동적으로 특정 Class의 정보를 추출할 수 있는 프로그래밍

velog.io

실행시점(동적으로) 객체의 Property와 Method에 접근할 수 있는 방법을 제공한다.

보통 객체의 Property와 Method에 접근할 때는 컴파일 과정에서 찾아내 해당 객체가 존재함을 보장한다.
하지만, 런타임 과정에서만 객체 Property와 Method를 알 수 있거나, 타입과 관계없이 객체를 다뤄야 하는 경우가 있다.

활용

  • 동적으로 Class를 사용 해야할 경우
    코드 작성 시점에서는 어떠한 Class를 사용해야할지 모르지만 Runtime에 Class를 가져와서 실행해야하는 경우
    (ex: Spring Annotation)
  • Test Code 작성
    private 변수를 변경하고 싶거나 private method를 테스트할 경우
  • 자동 Mapping 기능 구현
    IDE 사용 시 Da 입력만해도 이와 관련된 Class 혹은 Method 목록들을 IDE가 먼저 확인하고 사용자에게 제공한다.
  • Jackson, GSON 등의 JSON Serialization Library
  • 정적 분석 tool

Reflection Library

Reflection을 할 수 있는 라이브러리는 크게 두가지가 존재한다.

  • java.lang.reflect package
  • kotlin.reflect package
    -> java에 존재하지 않는 Property나, Nullable한 타입에 대한 Reflection을 제공한다

Kotlin Class의 경우에는 컴파일 과정에서 자바 코드로 변환이 되기 때문에 Kotlin에서 java package를 사용해도 완전히 호환이 된다.

Kotlin Reflection API

KClass

Class를 표현하는 것으로 이를 통해 Class안에 모든 선언을 열거하고 접근할 수 있다.
MyClass::class를 통해서 KClass의 객체를 얻을 수 있다.

val intent = Intent(this, MainActivity::class.java)
// MainActivity::class를 통해 KClass의 객체를 획득하고,
// .java를 통해 java의 Class로 변환한다.
startActivity(intent)

KClass의 내부는 어떻게 구성이 되어있을까?

KClass는 인터페이스이며 내부에 다양한 Property가 존재한다.

interface KClass<T: Any> {
  val simpleName: String?
  val qualifiedName: String?
  val members: Collection<KCallable<*>>
  // KClass<*>: 어떤 Class 레퍼런스도 대입할 수 있다. 
  val constructors: Collection<KFunction<T>>
  val nestedClasses: Collection<KClass<*>>
  ...

}

KCallable: members는 각각 Property와 Function에 대한 Collection이며, Property와 Function의 공통 상위 Interface이기 때문에 members의 타입이 위와같이 형성 될 수 있다.

interface KCallable<out R> {
  fun call(vararg args: Any?): R
  // varag리스트로 함수 인자를 전달하고,
  // call을 통해 특정 인자에 해당하는 함수 내 행위를 호출 할 수 있다.
}
fun foo(x: Int) = println(x)
val kFunction = ::foo
println(kFunction.call(42)) // 42 출력

KFunctionN

kCallable.call method같은 경우는 인자를 넘겨야 하며, 인자의 개수와 파라미터의 개수가 일치해야 한다.

만약 위 코드에서 인자가 x만 있는데, 2개의 파라미터를 제공한다면 Runtime Exception이 발생할 것이다.
Runtime Exception을 예방하고, 파라미터와 인자의 개수를 일치시키기 위해사 kFunctionN을 타입으로 지정해줄 수 있다.

fun sum (x: Int, y: Int) = x + y
val kFunction : KFunction2<Int, Int, Int> = ::sum
println(kFunction.invoke(1, 2) + kFunction(3, 4) // 10
// invoke를 사용하면 인자의 개수나 타입이 맞지 않는 경우 컴파일이 불가능하다.
kFunction(1) // Error

위와같이 (KFunctionN의)N이 2이기때문에, 인자가 2개라는 의미를 가지며, 2개일 때는 function의 return값이 결정되지만, 인자가 1개인 경우 Error가 발생한다.

KPropertyN

런타임 과정에서 reflection을 통해 property를 가져올 수 있다.

Kcallable이 상위 interface이기 때문에 이 역시 call function을 사용할주 있지만, KPropertyN은 더 좋은 방법을 제공한다.
바로 get method이다.

최상위 Property(Class 내부가 아닌 외부에 정의하는 Property)는 Kproperty0 interfacec의 인스턴스로 표현되며, 인자가 없는 get method가 있다.
맴버 Property는 어떤 객체에 속해있는 Property이기 때문에, 이를 가져오려면 맴버 Property가 속한 객체를 알아햐 한다. 따라서 인자로 맴버 Property가 속한 객체를 넘기게 되고 reflection을 하는 과정에서 동적으로 원하는 값을 가져오게 된다.

class Person(val name: String, val age: Int)
val person = Person("SEHUN", 25)
val memberProperty = Person::age
println(memberProperty.get(person)) // 25
KProperty를 사용할 때 주의해야할 사항은 최상위 Property와 맴버 Property만 Reflection을 통해 접근이 가능하며 function의 local 변수는 접근할 수 없다.

04 어노테이션(Annotation) 알아보기

어노테이션은 메타데이터(부가기능)을 코드에 추가할 수 있는 수단이다.
어노테이션은 Reflection이나, AOP를 이용할 수 있다.

추가 가능한 위치

  • 클래스, 어노테이션 클래스
  • 프로퍼티, (맴버 변수)
  • 필드(프로퍼티의 백킹 필드 포함)
  • 지역변수
  • 값 파라미터
  • 생성자
  • 함수
  • 개터, 세터
  • 표현식

어노테이션의 추가 속성

어노테이션으로 추가 속성을 달아 줄 수 있다. 어노테이션 선언 위에 어노테이션의 형식으로 단다.

대표적으로 다음 2가지의 예시가 있다.

  1. @Target
    • 어노테이션을 달 수 있는 구성 요소 선정
    • Annotation Target Enum클래스 활용
  2. @Retention
    • 어노테이션이 남아있는 단계 선정
    • Source(소스), Binary(컴파일 타임), Runtime(런타임) 중 선택이 가능하다.
    • 런타임으로 선언하면, 런타임 중 어노테이션 정보가 남아있고, 그렇지 않다면 어노테이션 정보는 사라진다.
    • AnnotationRetention Enum클래스 사용
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class Karol

기본 사용 방법

@Karol
class Sabarada(
    @Karol val first: Int
) {
    @Karol
    fun baz(@Karol second: Int): Int {
        return first + second
    }
}

커스텀 어노테이션 예시

클래스 레벨 제약조건을 걸기 위해 작성한 코드이다.

@Target(
    AnnotationTarget.ANNOTATION_CLASS,
    AnnotationTarget.CLASS
)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [KarolValidator::class])
annotation class ValidKarol(
    val message: String = "Your custom message",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = []
)

annotation의 primary constructor 사용

코틀린 어노테이션의 내부 변수 선언은 주 생성자를 이용한다.

선언할 수 있는 변수의 타입은 다음과 같다.

  • Java 기준 primitive type (Int, Long, Double, Char..)
  • String
  • Enum
  • KClass 타입
  • 위의 언급한 선언 가능한 타입의 Array

 

반응형