일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- 불친절한SQL
- Spring
- 패캠
- devcamp
- 인프런
- 남궁성
- 스프링
- 쿼리
- 클린빌드
- 국비지원
- 기초쿼리
- SpringFramework
- Oracle
- 오라클
- SQL
- 소셜로그인
- cleanbuild
- 패스트캠퍼스
- 스프링의정석
- mariadb
- 자바
- 자바연습문제
- MySQL
- oauth
- 자바문제
- RDBMS
- 자바기초
- 자바의정석
- ApplicationContext
- java
- Today
- Total
Darren's Devlog
제네릭스: 타입시스템 본문
이번 게시글에서는 제네릭스부터 시작해, 클래스와 타입, 일반화, 변성에 대한 개념에 대해 설명한다.
모두 타입과 관련된 일반적인 개념이다.
프로그래밍 언어는 타입으로 부터 시작한다고 해도 과언이 아닐 것이다.
그렇기 때문에 개발자는 타입 시스템을 잘 이해할 필요가 있다고 생각하며 정리를 시작했다.
말은 거창하게 했지만, 본 게시글에서 다루는 내용은 사실 타입 시스템에서도 아주 기초적인 부분이다.
본 게시글은 코틀린 인 액션의 제네릭스 챕터를 많이 인용했다.
문법적 표현의 차이는 존재할 수 있지만 전반적인 개념을 이해하는데는 문제가 없을거라(?) 생각한다.
제네릭스
제네릭은 타입 안전성을 유지하면서 유연성을 제공하는 중요한 기능이다.
내부에서 사용할 데이터 타입을 외부에서 지정할 수 있다.
타입 파라미터를 사용하여 다양한 타입의 인스턴스를 생성할 수 있다.
제네릭 클래스 선언
class Box<T> { ... }
클래스 이름 뒤, 다이아몬드 연산자에 타입 파라미터를 지정하여 클래스를 제네릭하게 만들 수 있다.
이제 클래스 내부에서 타입 파라미터를 일반 타입처럼 사용할 수 있다.
일반 클래스
class Toy { ... }
일반 클래스에는 타입 파라미터가 선언되어있지 않다.
제네릭 타입
일반 클래스를 타입으로 지정할 수 있듯이, 제네릭 클래스를 타입으로 지정할 수 있다.
타입 파라미터에 실제 타입을 타입 인자로 넘기면 제네릭 타입이 된다.
타입인자로 실제 타입 Toy를 넘겨 Box<Toy>타입이 되었다.
일반 타입
val toy: Toy
일반 타입은 타입인자 없이, 일반 클래스명으로 타입을 지정한 형태를 보이고 있다.
제네릭 함수/메서드
fun <T> List<T>.elementAt(index: Int): T { ... }
제네릭 클래스 선언과 동일하게, 제네릭 함수/메서드도 T를 타입 파라미터로 받는다.
타입 파라미터를 매개변수의 타입, 반환 타입, 그리고 수신 객체에 사용할 수 있다.
일반 함수/메서드
fun compareTo(other: String): Int
일반 함수에는 타입파라미터가 선언되어있지 않다.
타입 파라미터 제약(Generic Constraints)
타입 파라미터 제약은 클래스나 함수에 사용할 수 있는 타입 인자를 제한하는 기능이다.
특정 타입을 타입 파라미터에 대한 상한(upper bound)으로 지정하여 타입 인자를 제한할 수 있다.
어느 날, 프로그램에 합계를 구하는 기능이 필요해졌다고 가정해 보자.
fun sum(list: List<Int>): Int {
var sum: Int = 0;
for (i in list) {
sum += i
}
return sum
}
fun main(args: Array<String>) {
val numbers: List<Int> = (1..9).toList()
print(sum(numbers))
}
Int로 구성된 리스트를 매개변수로 받아 합계를 구하는 로직을 만들었다.
이후에 Long타입도 고려가 되어야 한다고 하여 코드를 추가했다.
fun sum(list: List<Long>): Long {
var sum: Long = 0;
for (i in list) {
sum += i
}
return sum
}
여기까지는 특별한 문제가 없을 수 있다.
하지만 또 다른 타입들도 고려되어야 한다면, 계속해서 비슷한 기능의 다른 코드가 늘어날 것이다.
fun sum(list: List<Float>): Float { ... }
fun sum(list: List<Double>): Double { ... }
다양한 타입 때문에 코드가 늘어나는 게 문제라면, 모든 타입을 받도록 수정하면 되지 않을까?
fun sum(list: List<Any>): Double { ... }
위 코드는 리스트의 요소가 불분명하기 때문에 적절한 sum함수라고 할 수 없다.
이런 요구사항을 타입 파라미터 제약을 활용하여 개선할 수 있다.
제약을 위한 상한 타입 잘 지정하기 위해서는 일반화하는 과정이 필요하다.
일반화(Generalization)
일반화란 여러 실체유형 간의 공통적인 특성을 파악하는 과정을 말한다.
이전 예제에 일반화를 적용하며 개선해 보도록 하겠다.
우리가 사용했던 8개의 자료형들을 정수와 부동소수점수(floating-point number) 그룹으로 분류했다.
정수와 부동소수점수 두 그룹 모두 컴퓨터에서 수(numeric value)를 표현한다는 공통적인 특성을 가지고 있다.
그렇다면 수(numeric value)를 대표하는 자료형을 만들어 관계를 맺을 수 있을 것이다.
Number 클래스를 만들어 정수와 부동소수점수 타입들이 Number클래스를 확장하도록 수정한다.
이제 Number는 일반화된 타입이고 정수 자료형들과 부동소수점수 자료형들은 구체화된 타입이다.
이제 상한 타입을 지정하여 코드를 개선해 보도록 하겠다.
fun <T: Number> sum(list: List<T>): Double {
var sum: Double = 0.0;
for (i in list) {
sum += i.toDouble()
}
return sum
}
일반화된 타입 Number를 상한 타입으로 지정한 제네릭 함수를 만들었다.
sum함수는 Number와 그 하위 타입만 타입 인자로 허용한다.
이제 sum함수는 우리가 기대하는 합산 기능을 안전하게 수행할 수 있다는 것이 보장되었다.
타입을 정하는 매 순간 일반화는 고려된다.
개발자는 필요에 따라 타입을 일반화하는 기술이 필요하다.
클래스와 타입
타입 간의 상위타입(supertype), 하위타입(subtype) 개념이 존재한다.
앞선 일반화 과정을 통해, 공통된 특성을 파악하고,
일반화된 새로운 클래스를 생성하여 타입 간의 관계를 맺어주는 과정을 보았다.
클래스와 타입은 서로 밀접하게 연관되어 있기 때문에 이 둘의 개념이 혼용되는 경우가 있다.
타입 간의 관계를 더 얘기하기 전에 먼저 클래스와 타입의 차이에 대해 알아보자.
일반 클래스
일반 클래스에서는 클래스 이름 그대로 타입을 표현할 수 있다.
val firstName: String
val lastName: String
Nullable 타입
일반 클래스가 두 개 이상의 타입을 구성할 수도 있다.
val fullname: String
val nickname: String?
String 클래스는 Nullable(널 허용), Non-nullable(널이 될 수 없는) 타입이 될 수 있다.
제네릭 클래스
제네릭 클래스의 타입은 조금 더 특별하다.
val texts: ArrayList<String>
val numbers: ArrayList<Number>
같은 ArrayList 클래스이지만, ArrayList<String>는 타입이다.
ArrayList<String>, ArrayList<Number>, ArrayList<Any> 모두 엄연히 서로 다른 타입이다.
제네릭 클래스는 무수히 많은 타입을 만들어낼 수 있다.
클래스와 타입은 서로 다르다는 것을 알았으니 타입 관계에 알아보도록 하겠다.
타입 관계 - 하위 타입(Subtype)
타입 사이의 하위 타입 개념은 다음과 같은 이유로 아주 중요하다.
- 컴파일러는 변수에 값을 할당하거나 함수/메서드 인자 전달 시, 하위 타입 검사를 매번 수행한다.
- 어떤 값의 타입이, 변수 타입의 하위 타입인 경우에만 변수에 값을 할당한다.
"어떤 타입 A의 값이 필요한 곳에 어떤 타입 B의 값을 넣어도 아무 문제가 없다면,
타입 B는 타입 A의 하위 타입이다."
코틀린 인 액션 p.407
아주 당연한 말 일수도 있겠지만,
Number 타입의 값이 필요한 곳에 Int타입의 값을 넣어도 아무 문제가 없으니, Int는 Number의 하위 타입이다.
String 타입이 필요한 곳에 Int타입의 값을 넣을 수 없으니, Int는 String의 하위 타입이 아니다.
앞서 언급한 Nullable과 Non-nullable 타입에도 상위/하위타입 관계를 적용할 수 있다.
Nullable타입의 경우, null과 아닌 값을 모두 수용하지만, Non-null 타입은 null 값은 허용하지 않는다.
즉 Nullable 타입이 상위 타입이고, Non-nullable타입은 하위타입이다.
하위 타입 관계 더 간단하게 표현하자면, 얼마나 더 확장하는지 또는 멤버가 더 많은지 비교하는 것이라 할 수도 있다.
각 언어는 고유한 타입 시스템을 갖고 있어, 실제 관계를 평가하는 방식은 이론적으로 다를 수 있지만,
이런 정의들은 모든 언어에 공통적으로 적용할 수 있는 개념이라 생각한다.
하위타입 평가에 대해 더 궁금하다면 Nominal과 Structural Typing에 대해 알아보자.
변성(Variance)
변성은 성질의 변화를 의미하는데, 프로그래밍에서는 변성 개념이 앞서 알아본 타입 관계에 적용될 수 있다.
이전에 하위타입 개념은 아주 중요하다고 언급하였다.
타입 간의 관계가 변할 수 있는데, 타입 시스템에서 하위 타입 간의 관계가 어떻게 변하는지를 나타내는 개념이 변성이다.
변성은 타입 시스템에서 아주 중요한 개념 중 하나로, 잘 이해할 필요가 있다.
변성의 대표적인 세 가지 유형에서 어떻게 하위타입 관계가 형성되는지 알아보겠다.
공변성
일반적인 하위타입 관계가 동일하게 유지된다.
B가 A의 하위타입이다.
반공변성
공변성의 반대되는 개념으로, 일반적인 하위 타입 관계가 역전된다.
B가 A의 하위타입일 때, A가 B의 하위타입이 된다.
무공변성
무공변성에서는 하위타입 관계가 성립되지 않는다.
A와 B는 아무 관계가 없다.
컴파일러는 값이 할당될 때 항상 하위 타입 관계가 성립되는지 체크한다고 말하였다.
변성은 하위 타입 관계의 변화에 대한 규칙으로, 컴파일러는 변성이 적용된 타입의 사용위치를 제한한다.
리스코프 치환 원칙에서는 시그니처에 변성을 적용하여 강제하는 요구사항이 있는데 간단히 정리하자면 다음과 같다.
공변성 | 반공변성 | 무공변성 | |
관계 | 하위 타입 관계 유지 | 하위 타입 관계 역전 | 하위타입 관계 X |
제한 | 반환 위치에만 사용 가능 | 함수/메서드 파라미터에만 사용 가능 | 제한 X |
이런 제한사항은 타입 안정성을 위한 것이며, 사실 조금만 생각해 보면 아주 당연한 것이다.
제네릭 클래스의 변성
타입의 변성은 제네릭 클래스에 적용할 수 있다.
제네릭 클래스의 변성은 Base타입은 같지만, 다른 타입 인자를 갖는 제네릭 타입 간의 상하위 관계를 결정하는 것이다.
공변성
B가 A의 하위 타입일 때, Producer<B>가 Producer<A>의 하위 타입이면 Producer는 공변적이다.
이를 하위 타입 관계가 유지된다고 한다.
코틀린의 List는 대표적인 공변 인터페이스이다.
var numbers: List<Number>
numbers = listOf<Int>(1,2,3,4,5)
numbers = listOf<Long>(1L, 2L, 3L, 4L, 5L)
numbers = listOf<Double>(1.0, 2.0, 3.0, 4.0, 5.0)
Int, Long, Double은 Number의 하위 타입이기 때문에, 공변적인 List<T>의 하위타입 관계가 유지된다.
public interface List<out E> : Collection<E> {
/* ... */
public operator fun get(index: Int): E
public fun subList(fromIndex: Int, toIndex: Int): List<E>
}
공변적인 타입 파라미터는 오직 반환 타입에만 사용할 수 있다.
코틀린에서는 out 키워드를 사용하여 타입 파라미터에 대해 공변적임을 표시하며, 타입 파라미터의 사용을 반환 타입으로 제한한다.
반환 = out, 아주 명시적이지 않은가?
타입 파라미터가 반환 타입에만 사용되는 경우,
공변적으로 선언해도 안전하며 제네릭 타입 간 하위타입 관계를 유지함으로써 더 유연하게 사용할 수 있다.
반공변성
B가 A의 하위 타입일 때, Consumer<A>가 Consumer<B>의 하위 타입이면 Consumer은 반공변적이다.
이를 하위 타입 관계가 역전된다고 한다.
Comparator은 대표적인 반공변 클래스이다.
interface Comparator<in T> {
fun compare(el: T, e2: T): Int { /* ... */ }
}
반공변적인 타입 파라미터는 오직 함수/메서드의 파라미터 타입에만 사용할 수 있다.
Comparator 클래스는 파라미터 타입 T를 반공변적으로 선언하였다.
코틀린에서는 in 키워드를 사용하여 타입 파라미터에 대해 반공변적임을 표시하며,
타입 파라미터의 사용을 함수/메서드의 파라미터 타입으로 제한한다.
함수 내부 = in, 아주 인상적이다.
val anyComparator = Comparator<Any> {
o1, o2 -> o1.hashCode() - o2.hashCode()
}
val strings: List<String> = listOf("A", "B", "C")
strings.sortedWith(anyComparator)
strings의 sortedWith() 함수는 Comparator<in String>의 구현체를 받아 정렬하는 함수이다.
Comparator가 타입 파라미터 String에 대해 반공변적이기 때문에, 역전된 하위 타입 관계가 성립된다.
즉 Comparator<Any>는 Comparator<String>의 반공변적 하위타입이다.
이 말은 sortedWith() 함수에 Comparator<Any> 타입을 인자로 넘겨도 안전하다는 의미이다.
타입 파라미터가 함수 매개변수에만 사용되는 경우 반공변적으로 선언할 수 있으며,
하위 타입 관계가 역전된(일반화된) 타입만 허용한다.
무공변성
B가 A의 하위 타입이어도, 아무런 하위 타입 관계가 형성되지 않는다.
MutableList는 대표적인 무공변 클래스이다.
var numbers: MutableList<Number>
numbers = mutableListOf<Number>(1,2,3,4,5)
//numbers = mutableListOf<Int>(1,2,3,4,5)
//Type mismatch: inferred type is MutableList<Int> but MutableList<Number> was expected
//numbers = mutableListOf<Double>(1.0, 2.0, 3.0, 4.0, 5.0)
//Type mismatch: inferred type is MutableList<Double> but MutableList<Number> was expected
Int, Long, Double은 Number의 하위 타입이지만, MutableList<T>에는 아무런 하위 타입 관계가 형성되지 않는다.
public interface MutableList<E> : List<E>, MutableCollection<E> {
/* ... */
override fun add(element: E): Boolean
public operator fun get(index: Int): E
}
MutableList의 타입파라미터에는 변성에 대한 표시가 없다. 그렇기 때문에 무공변적이다.
무공변적인 타입 파라미터는 파라미터 타입(in)과 반환 타입(out)에 모두 사용될 수 있다.
타입 파라미터를 함수/메서드 파라미터로 받고, 반환 타입으로도 사용한다면,
무공변적으로 선언하여 타입 안전성을 제공해주어야 한다.
클래스 | 공변성 | 반공변성 | 무공변성 |
선언 | Producer<out T> | Consumer<in T> | MutableList<T> |
관계 | 타입 인자의 하위 타입 관계가 유지 | 타입 인자의 하위타입 관계가 역전 | 하위타입 관계 X |
제한 | T를 아웃 위치(반환 타입)에서만 사용 가능 | T를 인 위치(매개변수)에서만 사용 가능 | T를 아무 위치에서 사용 가능 |
변성 규칙의 핵심은 클래스 외부의 사용자가 클래스를 잘못 사용하는 일을 방지하기 위함이다.
함수/메서드 변성
함수는 일급 객체이다. 이 말은 함수를 변수, 매개변수, 반환타입으로 전달할 수 있다는 것이다.
이 말은 함수 또한 함수 자료형이 존재하다는 의미이기도 하다.
함수 자료형에 대한 포스팅은 추후에 추가하도록 하겠다.