본문 바로가기
안드로이드/코틀린

Generic 이란

by 나이아카 2023. 12. 8.

 처음 학교에서 자바를 배울 때 제네릭을 들었었는데, 이때는 뭔가 제네릭이 크게 와닿지도 않고 학교 과제 정도를 할 때에는 제네릭을 크게 만들어볼 일도 없어서 무심코 넘어갔던 기억이 있네요. 안드로이드 관련 포스트를 보다가 제네릭에 대한 이야기를 발견해서 정리할 겸 한 번 작성해봅니다.

참고: https://kotlinlang.org/docs/generics.html#unchecked-casts

 

Generics: in, out, where | Kotlin

 

kotlinlang.org


ArrayList<String> texts = new ArrayList<>();

 자바를 이용해 코드를 작성하다보면 위와 같은 코드를 자주 발견하곤 했습니다. 이는 ArrayList라는 Class를 선언할 때 그 클래스 내부에서 사용하는 변수의 타입을 지정해주는 것인데요. 이는 자바와 같이 정적 언어에서 변수를 사용할 때 그 변수는 항상 타입이 고정되어 있는 특성 때문에 발생하는 개념이라고 볼 수 있습니다. 만약 제네릭이라는 개념이 없다면, 저 ArrayList는 아래와 같이 선언되지 않고 특정 primitive 타입에 대해서 각각 Class가 선언되어 있을 것입니다. 더욱이 커스텀 Class를 제네릭으로 받아 사용하기 위해서는 또 다른 Custom Class의 ArrayList를 만들어줘야 합니다. 이는 생각만해도 기분이 썩 좋지 않네요.

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable

 

 제네릭의 정의는 한 줄로 '타입을 일반화한다'로 표기되고 있습니다. 이는 위 코드처럼 클래스 코드를 직접 타이핑할 때 그 클래스의 변수 타입을 지정하는 것이 아니라, 제네릭을 가진 특정 클래스를 사용할 때 <>를 통해 타입을 지정받아 클래스 내부에서 사용하겠다는 것을 의미합니다. 이는 클래스를 선언할 때 타입이 고정되는 특징이 있고, 이 특징에 따라 제네릭을 사용하는 ArrayList 클래스에서 <E>로 받아온 타입을 클래스 내부에서 사용할 수 없음을 의미합니다. 예를 들어 ArrayList의 add 메소드는 아래와 같이 구현되어 있는데, 이때 변수 e의 데이터가 어떤 타입인 지 클래스 내부에서는 정의되어 있지 않으므로 만약 ArrayList<String>으로 선언했다 하더라도 이 e가 String 타입인지는 ArrayList 내부에서는 확인할 수 없습니다. 그래서 e.toInt()와 같은 메소드를 이용할 수 없게 되는 것입니다. 그래서 기본적으로 제네릭을 이용할 때에는 클래스 내에서 제네릭에 들어올 특정 클래스가 사용하는 메소드들을 이용할 일이 없는 경우에 사용된다고 볼 수 있겠습니다.

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}

 

 이러한 제네릭을 사용할 때 암묵적으로 사용하는 알파벳들이 있는데, T는 Type을, E는 Element를, K는 Key, V는 Value, N은 Number에 해당하는 알파벳을 사용한다고 합니다. 이는 꼭 지켜야 하는 규칙은 아니지만, 암묵적으로 가독성을 높이기 위해 사용할 수 있다는 것 정도로 알아두면 좋을 것 같습니다.

 

open class BaseFragment<T : ViewDataBinding>(@LayoutRes private val layoutId: Int) :Fragment()

 위 코드는 DataBinding을 위한 BaseFragment를 작성한 것으로, 보면 T라는 제네릭을 통해 ViewDataBinding이라는 클래스를 받아오는 것을 확인할 수 있습니다. 이 코드는 ArrayList와 다르게 T의 값이 ViewDataBinding으로 고정되어 있는데, (ViewDataBinding은 xml의 DataBinding class입니다)이는 Class내에서 아래와 같이 제네릭의 메소드를 사용하기 위함입니다.

private var _binding: T? = null
protected val binding: T get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        _binding = DataBindingUtil.inflate(
            layoutInflater,
            layoutId,
            null,
            false
        )
        binding.lifecycleOwner = this
        return binding.root
    }

 ArrayList처럼 제네릭의 내부 코드를 알 필요가 없는 경우 단순하게 Type만을 받아오는 형태 <T>로 작업할 수 있고, 내부의 메소드와 같은 부분들이 필요한 경우 binding처럼 <T: ViewDataBinding> 형태로 제네릭을 사용할 수 있습니다. 

 

 좀 더 다양한 예제를 통해 어떤 방식으로 사용할 수 있는지 다뤄보겠습니다.

open class F { var size = 3 }
class A : F() { var aSize = 2 }
class B : F() { var bSize = 1 }

 먼저 아무 의미는 없지만, F를 상속하는 A와 B 클래스가 있습니다. 이 클래스들을 통해 어떻게 제네릭을 작성할 수 있는지 살펴보겠습니다.

fun <T: F> receiveF(f: T): T {
    println(f.size)
//    val a :A = f type mismatch
//    println(f.aSize) error
//    println(f.bSize) error
    return f
}

fun <T> receiveT(f: T) :T {
    println(f)
    //f가 어떤 클래스인지 여기서는 알 수 없기 때문에 그 어떤 메소드도 참조 불가
    return f
}

fun receiveArray(fArray: Array<F>) : Array<F> {
    println(fArray)
    return fArray
}

fun receiveList(fList: List<F>): List<F> {
    println(fList)
    return fList
}

 먼저 파라미터로 받은 데이터를 출력한 후, 다시 반환하는 메소드들을 선언했습니다. 이는 list와 array의 차이, 그리고 단순 클래스를 제네릭으로 받는 경우를 확인하기 위한 메소드입니다. 그럼 이 네 메소드를 왜 만들었는지 간략하게 소개하겠습니다.

 일단 첫 번째로 receiveF는 특정 클래스(F)를 제네릭으로 받을 때 어떤식으로 동작하는지에 대해서 알아보기 위함입니다. 일단 메소드 내에서 F에 선언된 필드 및 메소드를 사용할 수 있는지, 그리고 F를 상속받는 자식 클래스들이 들어왔을 때 자식 클래스의 메소드를 사용할 수 있는지에 대한 여부입니다.

 두 번째는 receiveT인데요. 이는 특정 클래스가 아닌 불특정 클래스 T로 받았을 때 메소드 내에서 어떠한 메소드도 사용이 불가능하다는 것을 보여주기 위함입니다.

 세 번째는 receiveArray로 제네릭을 가진 Array에 F 클래스를 주고, F의 자식 클래스들을 가진 Array를 파라미터로 주었을 때 정상 동작이 이루어지는 지 확인하기 위한 메소드입니다.

 마지막은 receiveList인데, 이는 위의 receiveArray와 다르게 List로 받고 있습니다. Array와 List로 받았을 때 차이점이 존재하는지 확인하기 위함입니다.

fun test() {
    val aArray: Array<A> = arrayOf(A(), A())
    val bArray: Array<F> = arrayOf(B(), B())
    val aList: List<A> = listOf(A(), A())
    val bList: List<B> = listOf(B(), B())
    val a = receiveF(A()) //반환 클래스는 A
    val b = receiveF(B()) //반환 클래스는 B
    val aT = receiveT(A()) //반환 클래스는 A
    val bT = receiveT(B()) //반환 클래스는 B
    receiveArray(aArray) // Type mismatch.
    receiveArray(bArray) // 정상 수행
    receiveList(aList) // 정상 수행
    receiveList(bList) // 정상 수행
    println(a.aSize)
    println(b.bSize)
    println(aT.aSize)
    println(bT.bSize)
}

 변수를 선언하는 부분은 제외하고 결과를 확인해보겠습니다. 먼저 aArray를 파라미터로 받는 receiveArray는 type mismatch syntax 에러가 발생합니다. 문구를 잘 보면 Required Array<F>, Found Array<A>라는 것도 확인할 수 있습니다. 이는 F는 A의 부모 클래스이지만, 이 클래스를 제네릭으로 이용하는 Array<F>는 Array<A>와 상속관계가 아니라는 것을 의미합니다. 이는 Array가 가진 set처럼 Array 내부의 값이 수정 가능하다는 점이 가장 큰 원인이 되는데, 예를 들어 Array<F>를 파라미터로 받는 receiveArray에서 fArray.set을 통해 데이터를 변경하고자 한다면, fArray를 Array<A>로 받은 경우 fArray.set(0, B())를 할 때 문제가 발생할 수 있습니다.

 실제로 Array<F>인 경우 set을 통해 B를 넣든, A를 넣든 둘 다 자식 클래스라 문법적으로 문제가 없지만, Array<A>인 경우 set을 통해 B를 넣을 수 없기 때문에(업캐스팅을 통해 array를 생성하는 것은 괜찮으나 다운 캐스팅의 경우 언제든 에러가 발생할 수 있는 문제에서 기인) 이를 아예 문법적으로 막아둔 것입니다. 이러한 문제를 예방하고자 문법적으로 막아둔 것이기에 ArrayList, Array등 내부 element를 수정할 수 있는 Collection은 전부 이러한 문제를 지니고 있습니다.

 그러나 List의 경우에는 문제가 발생하지 않습니다. List는 불변의 속성을 띄고 있기 때문입니다. 내부 속성에 변화를 줄 수 없으니 Array<A>는 Array<F>를 대신해도 됩니다.(물론 업캐스팅된 상태로 메소드 파라미터로 들어간 것이니 A의 기능을 receiveList 메소드 내에서 사용할 수 있는 것은 아닙니다.) 물론 이를 코틀린이 알아서 적용해주는 것은 아니고, 이러한 생각에 맞게끔 키워드를 사용해주어야 합니다. 이때 List의 코드를 보면 <out T>로 이루어져 있는 것을 확인할 수 있습니다.

 

 제네릭의 out 키워드는 자바의 F extends T와 같은 형태의 와일드카드와 동일한 기능을 합니다. 이는 상한 경계 와일드카드로 T와 그 자식 클래스들을 제네릭으로 받겠다고 선언하는 것을 의미합니다. 이 덕분에 List는 자식을 제네릭으로 받을 수 있게 되는 것입니다. 물론 위의 receiveArray에도 out을 선언할 수 있습니다. 아래 코드와 같이 out을 붙여주게 되면, 파라미터로 aArray와 bArray 모두 받을 수 있게 되는 것인데요. 대신 코틀린에서 fArray.set을 할 수 없도록 막습니다. 이는 아까 말했던 불변성이 있는 콜렉션만 out을 달고 있는 것과 동일한 이유라고 볼 수 있습니다. 아예 문법적으로 막아버려서 문제가 없도록 하는 것입니다. 이를 요약하면 out 키워드는 read 전용(그렇다고 clear와 같이 내부 element를 알 필요가 없는 메소드까지 막히는 것은 아닙니다)이다 라고 생각하면 되겠습니다. 이 out 키워드가 제공하는 것을 공변성(Covariance)이라고 합니다.

fun receiveArray(fArray: Array<out F>) : Array<out F> {
//    fArray.set(0, B()) error
    println(fArray)
    return fArray
}

 

 그렇다면 위에 소개했던 코드들로 설명되지는 않았지만 연관관계에 있는 제네릭의 in 키워드도 한 번 알아보겠습니다. in 키워드는 자바의 제네릭을 아시는 분들이라면 예상하시겠지만 F super T와 같은 형태의 와일드카드와 동일한 기능을 합니다. 이는 하한 경계 와일드카드로 T와 T 클래스의 부모 클래스들만 받겠다는 것을 의미합니다. 이렇게 되면 out 키워드와는 반대로 Write만 가능한 형태가 됩니다. 이 역시 내부 element를 참조하지 않는 경우 문제가 발생하지 않지만, write는 필수적으로 내부를 수정한다는 의미이기 때문에 특별한 예외는 없다고 생각하시면 됩니다. 이렇게 in 키워드가 제공하는 것을 반공변성(Contravariance)이라고 부릅니다.

정리하자면, out 키워드는 공변성을 주며 read가 가능한 제네릭이고 in 키워드는 반공변성이며 write만 가능한 제네릭이라고 볼 수 있습니다.(세부내용을 모두 숙지한 상태에서 이렇게 정리하셔야 합니다...)

 


 원래 글을 쓸 때 예제를 잘 쓰지 않는 편인데, 제네릭은 예제 없이는 이해가 잘 안되는 경향이 있네요. 제네릭을 자주 사용하지만 아직 익숙하지 않아서 그런 문제겠죠. 특히 코틀린의 제네릭의 기본을 위해 자바(이 글에는 없지만)까지 다녀왔습니다. 간만에 대학생처럼 공부한 느낌이라 기분이 상쾌해서 좋았네요. 여러모로 이런 글들을 자주 쓸 수 있으면 좋겠습니다.

'안드로이드 > 코틀린' 카테고리의 다른 글

Android - 시스템 앱 알림 상태 확인  (0) 2023.10.25
(Kotlin) Flow - 1  (0) 2023.08.10
AAC Navigation의 특징  (0) 2023.06.02
JvmStatic 어노테이션  (0) 2023.03.16
Kotlin - Object 키워드(with SingleTon)  (1) 2023.02.17

댓글