변성(variance) 개념은 List<String>, List<Any>와 같이 기저 타입이 같고 타입 인자가 다른 여러 타입이 서로 어떤 관계가 있는지 설명하는 개념이다.
List<Any> 타입의 파라미터를 받는 함수에 List<String>을 넘기면 안전할까? String 클래스는 Any를 확장하므로 Any 타입 값을 파라미터로 받는 함수에 String 값을 넘겨도 절대로 안전하다. 하지만 Any와 String이 List 인터페이스의 타입 인자로 들어가는 경우 그렇게 자신 있게 안전성을 말할 수 없다.
fun printContents(list: List<Any>) {
println(list.joinToString())
}
fun main() {
printContents(listOf("abc", "bac"))
}
// abc, bac
이 경우에는 문자열 리스트도 잘 동작한다. 이 함수는 각 원소를 Any로 취급하며 모든 문자열은 Any 타입이기도 하므로 완전히 안전하다.
이제 리스트를 변경하는 다른 함수를 살펴보자
fun addAnswer(list: MutableList<Any>) {
list.add(42)
}
fun main() {
val strings = mutableListOf("abc", "bac")
addAnswer(strings) // Type mismatch: inferred type is MutableList<String> but MutableList<Any> was expected
println(strings.maxBy { it.length })
}
컴파일러가 addAnswer(Strings)를 받아들인다면 정수가 문자열 뒤에 추가된다. 따라서 이 함수 호출은 컴파일될 수 없다. 이 에제는 MutableList<Any>가 필요한 곳에 MutableList<String>을 넘기면 안 된다는 사실을 보여준다.
다시 돌아와서, List<Any> 타입의 파라미터를 받는 함수에 List<String>을 넘기면 안전할까? 원소 추가나 변경이 없기 때문에 안전한다.
코틀린에서는 리스트의 변경 가능성에 따라 적절한 인터페이스를 선택하면 안전하지 못한 함수 호출을 막을 수 있다. 함수가 읽기 전용 리스트를 받는다면 더 구체적인 타입의 원소를 갖는 리스트를 그 함수에 넘길 수 있다. 하지만 리스트가 변경 가능하다면 그럴 수 없다.
변수의 타입은 그 변수에 담을 수 있는 값의 집합을 지정한다. 타입과 클래스는 다르다. 그 둘의 차이에 대해 알아보자.
제네릭 클래스가 아닌 클래스에서는 클래스 이름을 바로 타입으로 쓸 수 있다. 에를 들어 var x : String이라고 쓰면 String 클래스의 인스턴스를 저장하는 변수를 정의할 수 있다. 하지만 var x : String?처럼 같은 클래스 이름을 널이 될 수 있는 타입에도 쓸 수 있다. 이는 모든 코틀린 클래스가 적어도 둘 이상의 타입을 구성할 수 있다는 뜻이다.
제네릭 클래스에서는 상황이 더 복잡하다. 올바른 타입을 얻으려면 제네릭 타입의 타입 파라미터를 구체적인 타입 인자로 바꿔줘야 한다. 예를 들어 List는 타입이 아니다(하지만 클래스다). 하지만 타입 인자를 치환한 List<Int>, List<String?>, List<List<String?>> 등은 모두 제대로 된 타입이다. 각각의 제네릭 클래스는 무수히 많은 타입을 만들어낼 수 있다.
타입 사이의 관계를 논하기 위해 하위 타입(subtype)이라는 개념을 잘 알아야 한다. 어떤 타입 A의 값이 필요한 모든 장소에 어떤 타입 B의 값을 넣어도 아무 문제가 없다면 타입 B는 타입 A의 하위 타입이다. 예를 들어 Int는 number의 하위 타입이지만 String의 하위 타입은 아니다. 이 정의는 모든 타입이 자신의 하위 타입이라는 뜻이기도 하다.
상위 타입(super type)은 하위 타입의 반대다. A 타입이 B 타입의 하위 타입이라면 B는 A의 상위 타입이다.
한 타입이 다른 타입의 하위 타입인지 왜 중요할까 컴파일러는 변수 대입이나 함수 인자 전달 시 하위 타입 검사를 매번 수행한다.
fun test(i: Int) {
val n: Number = i
fun f(s: String) { TODO() }
f(i) // Type mismatch: inferred type is Int but String was expected
}
fun main() {
test(3)
}
어떤 값의 타입이 변수 타입의 하위 타입인 경우에만 값을 변수에 대입하게 허용한다. 이 예제에서 변수를 초기화한 i의 Int로 변수의 타입인 Number의 하위 타입이다. 따라서 이 대입은 올바른다. i 인자의 타입인 Int는 파라미터 타입인 String의 하위 타입이 아니다. 따라서 f 함수 호출은 컴파일되지 않는다.
하위 타입은 하위 클래스(subclass)와 근본적으로 같다. 예를 들어 Int 클래스는 Number의 하위 클래스이므로 Int는 Number의 하위 타입이다. String은 CharSequence의 하위 타입인 것처럼 어떤 인터페이스를 구현하는 클래스의 타입은 그 인터페이스 타입의 하위 타입이다.