제네릭은 기본적으로 Generalize, '일반화'한다에서 나온 개념이다. 즉, 데이터의 타입을 일반화 한다는 것이다.
이것을 통해서 클래스나 메소드에서 사용할 내부 데이터 타입을 컴파일 타임에 미리 지정하는 방법이다.
이전의 JDK 버전에서는 여러가지 타입을 사용하는 클래스나 메소드에서 인수나 반환값으로 최상위 객체인 Object 타입을 이용해 왔다. 이 때는 Object 객체를 다시 원하는 타입으로 변환해야 하기에 오류가 발생할 가능성이 높았다.
하지만 이후 버전에서부터는 제네릭의 도입으로 컴파일 시에 미리 타입이 정해져 안정성이 증가하고 타입 변환과 검사를 할 필요가 없어졌다. 그리고 제네릭(타입 T)에 대한 일반 메소드를 구현해 놓으면 모든 타입의 객체에 사용할 수 있어서 확장성이 증가되었다.
정리해보자면 제레릭을 이용하면 다음과 같은 장점이 있다.
- 객체의 타입 안정성이 증가한다.
- 반환값에 대한 타입 변환 및 타입 검사를 할 필요가 없다.
- 모든 객체에 대한 확장성을 가진다.
그렇다면 제네릭은 어떻게 정의할 수 있을까?
제네릭 함수를 정의할 때, 타입이 정해지지 않은 변수는 함수 이름 앞에 <T>처럼 정의되어야 한다.
fun <T> addNumbers(num1:T, num2:T): T{
return (num1.toString().toDouble() + num2.toString().toDouble()) as T
}
위 함수는 타입 T 변수 num1과 num2를 더하고 타입 T변수를 리턴하는 함수이다.
위 함수는 다음과 같이 호출할 수 있다.
fun main(args: Array<String>) {
println(addNumbers(10, 20))
println(addNumbers(10.1,20.1))
}
결과
30
30.200000000000003
fun <T> add(a : T, b : T): T {
return (a.toString().toDouble() + b.toString().toDouble()) as T
}
fun main(){
println(add("1", 2.1))
println(add(1, 3))
println(add("10", "235"))
println(add(1.5, 3.1))
}
결과
3.1
4
245.0
4.6
T 타입이 정해지지 않았기 때문에 어떠한 타입이든 올 수 있다.
다음으로 제네릭 클래스를 정의할 때 타입이 정해지지 않은 변수는 클래스 이름 다음에 같이 정의한다. 제네릭 함수와 다른 점은 이름 다음에 <T>가 온다는 점이다.
class Rectangle<T>(val width: T, val height: T){
}
위 코드는 임의의 타입 T를 받는 Rectangle 클래스이다. T의 타입은 설정되지 않았기 때문에 어떤 타입이든 올 수 있다.
fun main(args: Array<String>) {
val rec1 = Rectangle<Double>(10.0, 20.0)
val rec3 = Rectangle<String>("aa", "bb")
}
위 코드는 앞서 정의한 Rectangle 클래스를 생성하는 코드이다. 객체를 생성할 때 Rectangle<Double> 처럼 T의 타입이 무엇인지 써주어야 한다.
하지만 코틀린은 전달된 인자로부터 T의 타입을 추론하기 때문에 다음과 같이 Rectangle만 써줘도 된다.
fun main(args: Array<String>) {
val rec2 = Rectangle(10.0, 20.0)
val rec4 = Rectangle("aa", "bb")
}
만약, 두 개 이상의 서로 다른 타입을 가진 변수들을 제네릭으로 정의하려면 <T, K>처럼 두개의 변수를 써주면 된다.
class Rectangle<T, K>(val width: T, val height: T, val name: K){
}
Constraints(제한, 제약)
위에서 정의한 클래스의 문제점은 Rectangle("aa", "bb")와 같이 숫자가 아닌 인자도 허용이 된다는 것이다. 따라서 width와 height 변수가 숫자만 허용되도록 만들기 위해서는 다음과 같이 <T:Number>을 이용하여 super type이 Number인 객체만 T로 받도록 해주면 된다.
class Rectangle<T:Number>(val width: T, val height: T){
fun getArea(): T{
return (width.toDouble() * height.toDouble()) as T
}
}
fun main() {
val rec1 = Rectangle(10, 20)
val rec2 = Rectangle(10.5, 20.5)
val rec3 = Rectangle("aa","bb") // 컴파일 에러
}
<T:Number>을 이용했는데도 불구하고 인자에 String이 들어가게 되면 컴파일 에러가 발생하게 되고 인자가 Int 와 Double 일 때 객체가 생성된 것을 볼 수 있다. (Int와 Double은 Number 클래스를 상속받았다.)
2개 이상의 Constraints
바로 위에서 T에 Number만 가능하도록 제약을 거는 것을 해보았다. 하지만 여기서 추가로 더 많은 제약을 줄 수 있다.
class Rectangle<T>(val width: T, val height: T) where T: Number, T: Comparable<T>{
fun getArea(): T{
return (width.toDouble() * height.toDouble()) as T
}
}
위 코드에서 T는 Number를 상속받고, Comparable을 구현한 객체로 제한하였다. 두가지 이상의 제약을 걸기 위해서는 where을 사용해야 한다. 클래스는 1개의 클래스만 상속 받을 수 있기 때문에, 2개 이상의 제약은 1개의 클래스와 1개 이상의 인터페이스가 된다.
불변성(Invariance)
class Double: Number, Comparable<Double>
Double은 Number를 상속하고, Double의 Super Class는 Number이다.
하지만 Rectangle<Double>의 Super Class는 Rectangle<Number>가 아니다. 이처럼 두개의 타입이 서로 상속 관계이지만, 제네릭 클래스의 상속관계는 아닌 것을 불변성(Invariance)이라고 한다.
코틀린에서는 제네릭의 모든 타입은 Invariance이다. Invariance의 반댓말은 Covariance인데, in/out 키워드로 제네릭을 Covariance로 변경할 수 있다.
공변성(Covariance, 함께 변하는 속성)
앞서 설명한 것처럼 공변성은 불변성의 반댓말이다. Number가 Double의 Super Class일 때, Rectangle<Number>가 Rectangle<Double>의 Super Class이면 이것을 공변성이라고 한다.
out 키워드
out 키워드는 두개의 타입이 Invariance일 때, Covariance로 만들어 준다. 즉, out 키워드는 Rectangle<Number>가 Rectangle<Double>의 Super Class가 되도록 한다.
class Rectangle<T: Number>(val width: T, val height: T){
}
fun main() {
val derivedClass = Rectangle<Double>(10.5, 20.5)
val baseClass: Rectangle<Number> = derivedClass
}
위 코드는 Rectangle<Double> 객체를 Rectangle<Number> 에 대입하는 코드이다. 이 코드는 다음과 같은 에러가 발생한다.
하지만 T앞에 out을 붙여주면 컴파일이 정상적으로 된다.
class Rectangle<out T: Number>(val width: T, val height: T){
}
fun main() {
val derivedClass = Rectangle<Double>(10.5, 20.5)
val baseClass: Rectangle<Number> = derivedClass
}
out이 타입의 상속구조가 제네릭의 상속구조와 같다는 것을 정의했기 때문이다. 컴파일러는 Rectangle<Double>이 Rectangle<Number>의 하위 클래스라고 인식하고 있다.
반공변성(Contravariance)
반공변성은 공변성의 반대방향으로 공변성 조건을 만족하는 것을 말한다. 위 코드에서 Number가 Double의 Super Class일 때 Rectangle<Double>이 Rectangle<Number>의 Super Class라면 반공변성이라고 한다.
in 키워드
in 키워드는 out 키워드와 반대되는 역할을 한다. in은 타입의 상/하위 클래스 구조가 제네릭에서는 반대방향의 상/하위 클래스 구조를 갖는다는 것을 의미한다. 즉, 두 타입의 관계가 반공변성임을 나타낼 때 사용한다.
class Rectangle<in T: Number>(val width: @UnsafeVariance T, val height: @UnsafeVariance T){
}
fun main() {
val baseClass = Rectangle<Number>(10.5, 20.5)
val derivedClass: Rectangle<Double> = baseClass
}
위 코드를 보면 T 앞에 in을 넣었다. 그래서 Rectangle<Double>이 Rectangle<Number>의 상위 클래스가 되었다.
결론적으로 Invariant를 제외한 나머지는 상속관계라는 것을 알 수 있다.