[Kotlin] 제네릭

이상목·2024년 4월 30일
0

Kotlin

목록 보기
5/20
post-thumbnail

제네릭이란 ?

  • 코틀린은 코드에 타입 안정성을 주기 위해 많은 노력을 한다.
    제네릭 타입 역시 안정성을 높여 코드를 작성할 수 있게 해주는데,
    제네릭 타입은 자바에서도 제공되었기 때문에 아주 새로운 문법은 아니지만, 약간의 특징과 사용법이 달라 정리해보겠다.

제네릭을 왜 사용할까 ?

  • 코드를 작성하다보면 다양한 타입에 동일한 로직을 적용하기 위해 코드의 재사용을 과도하게 하려는 경우가 있다. 예를들어, 파라미터를 전부 Any 타입으로 받는 등 타입의 안정성을 저하시킬 수 있다.

  • 제네릭은 이러한 이슈에 적절한 균형을 맞춰준다.
    즉, 제네릭을 사용하면 다양한 타입에서 사용 가능한 코드를 작성할 수 있다 !

  • 또한, 코틀린 컴파일러는 제네릭 클래스 또는 함수가 의도하지 않은 타입에서 사용되는지를 검증할 수 있다.

자바에서의 제네릭은 기본적으로 타입 불변성을 강요했다.
정리하자면 제네릭 함수가 파라미터 타입 T를 받는다면, T의 부모 클래스나 자식 클래스를 사용하는 것이 불가능했다.
즉, 타입이 정확히 일치해야 했는데, 이러한 제약이 크게 걸릴 수록 안정성이 높아진다고 생각한다.

any로 공통모듈 사용하는 예시..

getCounselSpaceList(data: any): Promise<any[]> {
  const param: object = {
    mapCode: MAPPER.GET_COUNSEL_SPACE,
    ...data,
  };

  return new Promise((resolve, reject) => {
    this._postApiService.select(param).then((res: any) => {
      resolve(res);
    }, reject);
  });
}


더 쉽게 이해하기 !

클래스 선언

open class Fruit         // 부모 클래스
class Apple : Fruit()    // 자식 클래스
class Banana : Fruit()   // 자식 클래스 

-- Apple과 Banana 클래스는 Fruit를 상속받는다.

함수 선언

fun receiveFruits(fruits: Array<Fruit> {
	println("Number of fruits: ${fruits.size}")
}

제네릭 타입으로 Fruits 클래스를 지정한 배열(Array)을 
파라미터(fruits)로 받는 receiveFruits() 함수를 생성하였다.

문제 발생

Array<T> 를 사용한 예시

fun main() {
    val fruits: Array<Apple> = arrayOf(Apple())
    receiveFruits(fruits)
}
 
fun receiveFruits(fruits: Array<Fruit>) {
	fruits[0] = Banana() // 문제가 될 수 있음!
}

타입 체크를 통해 fruits의 요소 타입이 Banana 일 경우에만 변경 가능하도록 구현을 할 수 있겠지만, 
이러한 방식도 SOLID의 리스코프 치환 원칙에 위배된다.
  • 위와 같이 receiveFruits 함수의 파라미터로는 Fruit 클래스 타입의 배열인 파라미터밖에 들어올 수 없기 때문에, Apple이나, Banana로 파라미터를 던져줄 시 문제가 발생한다.
  • 즉, Apple이 Fruit를 상속 받았더라도, Array<Apple> 를 Array<Fruit>으로 취급해서 전달하는 것을 막아 제네릭의 타입을 안정적으로 만들었다.
  • 이러한 문제가 발생하는 이유는 코틀린이 가진 제네릭에 대한 타입 불변성 때문에 발생한다.

List<T>로 사용한 예시

fun receiveFruits(fruits: List<Fruit>) {
    println("Number of fruits: ${fruits.size}")
}
 
fun main() {
    val fruits: List<Apple> = listOf(Apple(), Apple())
    receiveFruits(fruits)   // Number of fruits: 2
}

출력 : Number of fruits:2

왜 List는 정상적으로 출력될까?

  • Array<T>는 가변(mutable)이지만, List<T>는 불변(immutable)이다.
  • 개발자는 Array의 아이템은 변경할 수 있지만, List의 아이템은 변경할 수 없다.

그렇다면 컴파일러는 어떻게 저 차이점을 알고 알려주는걸까 ?

  • 두 타입이 정의되는 방식에 달려있다.
    Array<T>는 class Array<T>로 정의되어 있고, List 는 interface List로 정의되어 있다.
    가장 큰 차이로 List 제네릭 타입에 사용된 out가 중요한 키워드가 되겠다.



Array와 List의 차이

  • Array<T> 클래스는 T 타입의 객체를 읽고, 쓰는 메소드 모두를 가지고 있다.
    하지만 out 키워드를 통해 공성을 사용하기 위해서는 우리가 코틀린 컴파일러에게 주어진 Array<T> 파라미터에서 어떤 값도 추가하거나 변경하지 않겠다고 약속을 해야한다.
  • 이런 제네릭 클래스를 사용하는 관점에서 공변성을 이용하는 것을 user-site variance 또는 type projection이라고 부른다. 이와 달리 제네릭 타입을 사용할 때 가 아닌 선언할 때 공변성을 사용한다고 지정하는 것을 declation-site variance 라고 부른다.
    이에 대한 예제는 위에서 사용한 List 인터페이스에서 찾아볼 수 있다.
  • List 로 declation-site variance 가 이미 정의 되어 있기 때문에 receiveFruits() 함수를 정의할 때 파라미터에 List 형태로 선언하지 않고도 List 을 receiveFruits() 함수에 전달할 수 있었던 것이다.



변성(공변성, 반공변성, 무공변성)

공변성이란 ?

  • 변성은 타입생성자에게 리스코프 치환 법칙을 허용하여 유연한 설계를 가능하게 해줍니다.
interface Cage<T> {
    fun get(): T
}

open class Animal

open class Hamster(var name: String) : Animal()

class GoldenHamster(name: String) : Hamster(name)

fun tamingHamster(cage: Cage<out Hamster>) {
    println("길들이기 : ${cage.get().name}")
}

fun main() {

    val animal = object : Cage<Animal> {
        override fun get(): Animal {
            return Animal()
        }
    }
    val hamster = object : Cage<Hamster> {
        override fun get(): Hamster {
            return Hamster("Hamster")
        }
    }
    val goldenHamster = object : Cage<GoldenHamster> {
        override fun get(): GoldenHamster {
            return GoldenHamster("Leo")
        }
    }

    tamingHamster(animal) // compile Error
    tamingHamster(hamster)
    tamingHamster(goldenHamster)
}
  
// tamingHamster 함수는 Hamster의 서브타입만을 받기때문에 animal 변수는 들어갈 수 없다.

반공변성이란 ?

  • 공변성의 반대 개념으로 자기 자신과 부모 객체만을 허용한다.
interface Cage<T> {
    fun get(): T
}

open class Animal

open class Hamster(var name: String) : Animal()

class GoldenHamster(name: String) : Hamster(name)

fun ancestorOfHamster(cage: Cage<in Hamster>) {
    println("ancestor = ${cage.get()::javaClass.name}")
}

fun main() {

    val animal = object : Cage<Animal> {
        override fun get(): Animal {
            return Animal()
        }
    }
    val hamster = object : Cage<Hamster> {
        override fun get(): Hamster {
            return Hamster("Hamster")
        }
    }
    val goldenHamster = object : Cage<GoldenHamster> {
        override fun get(): GoldenHamster {
            return GoldenHamster("Leo")
        }
    }

    ancestorOfHamster(animal) 
    ancestorOfHamster(hamster)
    ancestorOfHamster(goldenHamster) // compile Error
}  
  
// ancestorOfHamster에서 햄스터의 조상을 찾는 함수를 구현하여 햄스터를 포함한 그 조상들만 허용하도록 제한하였다.
// 하위타입인 Cage<GoldenHamster>는 제한에 걸려있어 compile error가 나는것을 확인 해볼 수 있겠다. 

무공변성이란 ?

  • Java, Kotlin의 Generic은 기본적으로 무공변성으로 아무런 설정이 없는 기본 Generic을 말한다.
interface Cage<T> {
    fun get(): T
}

open class Animal

open class Hamster(var name: String) : Animal()

class GoldenHamster(name: String) : Hamster(name)

fun matingGoldenHamster(cage: Cage<GoldenHamster>) {
    val hamster = GoldenHamster("stew")
    println("교배 : ${hamster.name} & ${cage.get().name}")
}

fun main() {

    val animal = object : Cage<Animal> {
        override fun get(): Animal {
            return Animal()
        }
    }
    val hamster = object : Cage<Hamster> {
        override fun get(): Hamster {
            return Hamster("Hamster")
        }
    }
    val goldenHamster = object : Cage<GoldenHamster> {
        override fun get(): GoldenHamster {
            return GoldenHamster("Leo")
        }
    }

    matingGoldenHamster(animal) // compile Error
    matingGoldenHamster(hamster) // compile Error
    matingGoldenHamster(goldenHamster)
}
  
// 위 코드의 Cage<Animal>, Cage<Hamster>, Cage<GoldenHamster>는 서로 각각 연관이 없는 객체로서 무공변성의 적절한 예시이다.  



<out T>으로 공변성(convariance) 사용하기

  • 가끔은 코틀린에게 타입 안정성을 희생하지 않고 약간의 제약을 풀어달라고 요청해야 할 때가 있을 것이다.

  • 예를들어 코틀린 컴파일러가 공변성을 허용해서 제네릭 베이스 타입이 요구되는 곳에 제네릭 파생 타입이 허용되도록 하길 원한다는 것이다.

  • 이럴 때는 타입 프로젝션(type projections)이 필요하다.

  • 우선 예시를 통해 한번 더 정리해보자.

정상 예시

fun copyFromTo(from: Array<Fruit>, to: Array<Fruit>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}
// copyFromTo() 함수는 from 배열의 객체를 순회하면서 to 배열로 값을 넣어주는 함수이다.
 
fun main() {
    val fruitsBasket1 = Array<Fruit>(3) { _ -> Fruit() }
    val fruitsBasket2 = Array<Fruit>(3) { _ -> Fruit() }
    copyFromTo(fruitsBasket1, fruitsBasket2)
}
  
// 이 때 전달받은 두 배열의 크기가 동일하다고 가정하고 구동 시 별 문제 없이 작동한다.
// copyFromTo() 함수의 파라미터로 Fruit 타입의 배열로 정의했고, 그에 맞춰 넘겨줬기 때문이다. 

문제 예시

fun copyFromTo(from: Array<Fruit>, to: Array<Fruit>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}
 
// copyFromTo() 함수는 from 배열의 객체를 순회하면서 to 배열로 값을 넣어주는 함수이다.
  
fun main() {
    val fruitsBasket1 = Array<Apple>(3) { _ -> Apple() }
    val fruitsBasket2 = Array<Fruit>(3) { _ -> Fruit() }
    copyFromTo(fruitsBasket1, fruitsBasket2) // type mismatch
}
  
// copyFromTo의 1번 파라미터로 Fruit 타입이 아닌 Apple 타입으로 fruitsBasket1을 넘겨줄 시 컴파일 에러가 발생한다.

out을 이용한 공변성 예시

fun copyFromTo(from: Array<out Fruit>, to: Array<Fruit>) {
    for (i in from.indices) {
        to[i] = from[i]
    }
}
 
fun main() {
    val fruitsBasket1 = Array<Apple>(3) { _ -> Apple() }
    val fruitsBasket2 = Array<Fruit>(3) { _ -> Fruit() }
    copyFromTo(fruitsBasket1, fruitsBasket2) // type missmatch
}  

// 코틀린은 from 레퍼런스에 data 가 새로 들어가게 하는 메소드 호출이 없다는 사실을 확인하고 메소드 시그니처가 호출되는 것을 확인하여 이를 검증한다.
// 위 케이스에서 from 파라미터는 파라미터의 값을 읽기만 하기 때문에 Array<T>의 T에 Fruit 클래스나 Fruit 하위 클래스가 전달되더라도 아무런 위험이 없다. 
// 이런 것을 타입이나 파생 타입에 접근하기 위한 파라미터 타입의 공변성이라고 한다.
// 하지만 이때 Array<<out Fruit>out Fruit> 으로 선언된 from 파라미터는 읽기만 가능할 뿐 내용을 변경하거나 추가하려 할 경우에는 컴파일 에러가 발생하게 된다.

<in T>를 이용한 반공변성 (contravariance)

  • 자료형의 상하 관계가 반대이다.
  • 상위 클래스의 자료형을 하위 클래스의 자료형으로 캐스팅 하는 경우 (다운 캐스팅)
  • in 키워드를 사용해 정의

예시

package chap04.section1

class Person<in T>(val age: Int)

fun main() {
    //val anys: Person<Any> = Person<Int>(10)
    val nothings: Person<Nothing> = Person<Int>(20) // 다운 캐스팅 
}  



공변성/반공변성/무변성 정리

공변성

  • out 키워드를 이용하여 업 캐스팅 해 사용할 수 있다.
open class Animal(val size: Int){
    fun feed() = println("Feeding...")
}

class Cat(val jump: Int): Animal(50)

class Spider(val poison: Boolean): Animal(1)

class Box<in T>

fun main() {
    val c1 = Cat(10)
    val s1 = Spider(true)

    var a1: Animal = c1
    a1 = s1
    println("s1 ${a1.size} ${a1.poison}")

    val b1: Box<Cat> = Box<Animal>() // 다운 캐스팅 
    //val b2: Box<Animal> = Box<Cat>() // 업 캐스팅
    val b3 = Box<Spider>()
}  



반공변성

  • in 키워드를 통해 다운 캐스팅을 할 수 있다.
open class Animal(val size: Int){
    fun feed() = println("Feeding...")
}

class Cat(val jump: Int): Animal(50)

class Spider(val poison: Boolean): Animal(1)

class Box<out T: Animal>

fun main() {
    val c1 = Cat(10)
    val s1 = Spider(true)

    var a1: Animal = c1
    a1 = s1
    println("s1 ${a1.size} ${a1.poison}")

    //val b1: Box<Cat> = Box<Animal>() // 다운 캐스팅
    val b2: Box<Animal> = Box<Cat>() // 업 캐스팅
    val b3 = Box<Spider>()
    //val b4: Box<Number> = Box<Int>() // 업 캐스팅
}



무변성

  • 상하 관계가 없다.
open class Animal(val size: Int){
    fun feed() = println("Feeding...")
}

class Cat(val jump: Int): Animal(50)

class Spider(val poison: Boolean): Animal(1)

class Box<out T>

fun main() {
    val c1 = Cat(10)
    val s1 = Spider(true)

    var a1: Animal = c1
    a1 = s1
    println("s1 ${a1.size} ${a1.poison}")

    //val b1: Box<Cat> = Box<Animal>()
    val b2: Box<Animal> = Box<Cat>() // 업 캐스팅 
    val b3 = Box<Spider>()
}

-- 쉽게 정리하자면
읽기 전용은 안에 들어있는 값을 빼서 읽어야 하니까 out, 쓰기 전용은 새로운 값을 집어 넣어야 하니까 in 으로 외우자

profile
기록은 기억을 지배한다.

0개의 댓글