프로그래밍 언어에는 Int, Long, Char 등 여러 타입이 존재합니다. 자신의 프로그램에 따라 타입을 적절하게 지정하는 것은 중요한데, 가끔은 모든 타입에 대해 동작을 하는 함수나 클래스를 구현하고자 할 때가 있습니다. 이 때 사용하게 되는 것이 제네릭입니다. 즉 제네릭(Generic)은 어떤 타입에 관계없이 일반화하는 것입니다. 일반화하다 (generalize) 에서 따와 Generic이 된 모양입니다. 사실 코틀린 말고도 다른 다양한 언어에서도 제네릭을 지원합니다. 그럼 코틀린에서는 어떻게 사용하는지 알아봅시다.
제네릭 함수
fun <T> add(a : T, b : T): T {
return (a.toString().toDouble() + b.toString().toDouble()) as T
}
fun main(){
print(add("1", 2.1)) // 3.1
print(add(1, 3)) // 4
print(add("10", "235")) // 245
print(add(1.5, 3.1)) // 4.6
}
일반적인 함수에서 위와 같이 <T>를 추가하여 모든 타입에 대해서 위의 함수가 동작할 수 있게 하였습니다. 들어오는 데이터인 a와 b를 강제로 String으로 치환하고, 덧셈 연산을 위해 다시 Double로 치환하고 더한 다음 return해 주었습니다.
이렇게 되면 a와 b에 각각 String 타입과 Double 타입을 넣어도 덧셈 연산을 할 수 있습니다. 타입에 관계 없이 String -> Double 순서로 강제 변환하니까요. 대신 toDouble()로 치환할 수 없는 문자열에서는 오류가 발생할 수 있습니다. ("abc"를 숫자로 치환할 수는 없으니까요.)
위의 함수는 말 그대로 모든 타입이 들어갈 수 있기 때문에, 특정 타입에서만 지원하는 메서드는 사용할 수 없습니다. 가령 a.toDouble()이라고만 쓴다면 오류가 발생할 것입니다. 모든 타입에서 toDouble()을 지원하지는 않을테니까요. 따라서 타입의 상한, 그러니까 제약을 걸 수 있는데, 이는 좀 있다가 확인해 보겠습니다.
fun <T> add(a:T, b:T, op:(T,T) -> T):T{
return op(a,b)
}
fun main(){
print(add("1", "2"){a, b -> a + b}) // 12
print(add(1, 2){a, b -> a + b}) // 3
}
이렇게 람다식을 이용해서도 더하는 연산을 구현할 수 있습니다...만 위의 결과는 어떻게 나올까요? 둘 다 예상한 결과인 3으로 나올까요? 위의 결과는 12가 나오고 아래의 경우에는 3이 출력됩니다. 위의 add("1", "2")의 경우는 String 타입에서의 + 연산을 시행하는데, String의 + 연산은 문자열을 수학적으로 더하는게 아니라 두 문자열을 이어붙이는 기능을 합니다. 따라서 "1" + "2" 와 같은 결과가 나오는 것이죠.
제네릭 클래스
class Calculator<T>(val a: T, val b: T){
fun add(): Double{
return (a.toString().toDouble() + b.toString().toDouble())
}
fun sub(): Double{
return (a.toString().toDouble() - b.toString().toDouble())
}
fun mul(): Double{
return (a.toString().toDouble() * b.toString().toDouble())
}
fun div(): Double{
return (a.toString().toDouble() / b.toString().toDouble())
}
}
fun main(){
print(Calculator<Int>(1, 2).add()) // 3.0
print(Calculator<Double>(2.0, 5.1).sub()) // -3.0999999999999996
print(Calculator("10", 2).mul()) // 20.0
print(Calculator<Any>("9", 2).div()) // 4.5
}
아무렇게나 막 짠 코드라 뭔가 좀 비효율적이고 별로 같아 보이지만.. 어쨌든 클래스에서도 제네릭을 사용할 수 있습니다. 함수처럼 <T>를 붙이면 알아서 타입을 추론해 줍니다. 물론 명시적으로 타입을 선언해줘도 되구요. main 함수의 3번째처럼 타입 선언을 안해도 알아서 잘 돌아갑니다.
그나저나 2번째 결과가 -3.09999... 로 나오는데, Double의 뺄셈 연산이 파이썬처럼 돌아가나 보네요. 근삿값으로 결과가 나오는데, 아래의 링크를 읽어보시면 좋을 것 같습니다.
https://whyprogrammer.tistory.com/561
제네릭 제한
fun <T> add(a : T, b : T): T {
return (a.toString().toDouble() + b.toString().toDouble()) as T
}
fun <T:Number> add(a : T, b : T): T {
return (a.toDouble() + b.toDouble()) as T
}
위에서 타입에 제한을 둘 수 있다고 얘기했는데요, 사실 별로 어려운 내용이 아닙니다. 말 그대로 타입을 어느 범위까지 허용할 것인지를 지정하는 것입니다. 첫번째 add는 모든 타입에 대해서 연산을 했다면, 두번째 add는 Number 범위까지만 허용했네요. 그렇기 때문에 Number에 존재하는 toDouble()을 바로 사용할 수 있습니다.
fun <T: Comparable<T>> compare(a: T, b: T) : Boolean{
if (a < b) return true
return false
}
fun main(){
print(compare(1, 2)) // true
print(compare(3, 2)) // false
print(compare("1", "2")) // true
print(compare("aaaa", "aaab")) // true
print(compare("cat", "dog")) // true
}
이번에는 Comparable<T>로 비교 연산이 가능하도록 만들어 봤습니다. 위와 같은 방식으로 Int, Double, String 등 여러 타입에 대해서 두 데이터의 크기 비교가 가능해집니다. 사실 Comparable은 두 값의 비교에 자주 사용하기도 하죠. 여튼 이렇게 타입의 범위에 제한을 둘 수 있습니다.
fun <T> compare(a: T, b: T) : Boolean where T:Comparable<T>, T : Number{
if (a < b) return true
return false
}
참고로 이렇게 where을 이용하면 여러 개에 대해서 제약을 둘 수도 있습니다. T가 Comparable<T>를 구현하고 Number를 상속 받도록 만들었습니다.
Invariance (불변성), Covariance (공변성), Contravariance (반공변성)
이건 또 뭐에 대한 개념이냐, 하나씩 살펴보겠습니다.
Invariance (불변성)
두 타입이 상속 관계여도 두 타입을 사용하는 클래스 간의 관계는 상속이 아닌 것을 Invariance라고 합니다. 예를 들어 Int -> Number 관계여도 NumberClass<Int> -> NumberClass<Number>의 관계는 될 수 없습니다. 기본적으로 제네릭의 타입은 Invariance를 가집니다.
class NumberClass<T: Number>(val a: T){
fun printNumber(){
print(a)
}
}
fun main(){
val n1 = NumberClass<Int>(1)
val n2 : NumberClass<Int> = n1
val n3 : NumberClass<Number> = n1
}
n1 = n2 는 문제가 없지만 n3 = n1은 type mismatch가 발생합니다. 두 타입에 대한 클래스가 서로 상속 관계는 아니니까요.
Covariance (공변성)
대신 out 키워드를 사용해서 Int -> Number일 때 NumberClass<Int> -> NumberClass<Number>의 관계로 만들어줄 수 있습니다. 아래와 같이 말이죠.
class NumberClass<out T: Number>(val a: T){
fun printNumber(){
print(a)
}
}
fun main(){
val n1 = NumberClass<Int>(1)
val n2 : NumberClass<Int> = n1 // success
val n3 : NumberClass<Number> = n1 // success
val n4 : NumberClass<Int> = n3 // fail
}
이렇게 하면 두 클래스가 서로 상속 관계를 가진다고 인식하기 때문에 컴파일이 됩니다. 마지막에 1줄이 더 추가되었는데, 이 때는 NumberClass<Int> <- NumberClass<Number> 관계를 가지기 때문에 오류가 발생합니다. 이 경우에는 다른 방법이 있죠.
Contravariance (반공변성)
요건 조금 다른데, in 키워드를 사용해서 Int -> Number일 때 NumberClass<Int> <- NumberClass<Number>의 관계를 갖게 합니다. 즉 out 키워드와 반대의 상황이죠.
class NumberClass<in T: Number>(val a: T){
fun printNumber(){
print(a)
}
}
fun main(){
val n1 = NumberClass<Int>(1)
val n2 : NumberClass<Int> = n1 // success
val n3 : NumberClass<Number> = n1 // fail
val n4 : NumberClass<Int> = n3 // success
}
이 경우에선 이번엔 n4인 4번째 줄은 컴파일이 되지만 3번째 줄은 컴파일이 되지 않을 것입니다.
마치며..
솔직히 제네릭 함수나 클래스만 간단하게 만들고 Invariance니 Covariance니 이런 개념은 제대로 공부한 적이 없었는데, 좋은 기회가 되었던 것 같습니다. 제네릭은 상당히 유용하게 쓰이는 곳이 많으니 잘 알아두셨으면 합니다. 그럼 오늘도 즐거운 코딩 하세요!
댓글
댓글 쓰기