- 다형성 중 하나 (Parametric)
- 상속 받은 클래스가 상위 클래스와 호환이 가능하도록 작동
상속간의 규칙
1. 형제간에 casting 불가
위와같이 자바에서 new 키워드를 통한 메모리 할당시 클래스별로 메모리 할당 및 자료형이 다르기 때문에 부모 자식간이 아닌 형제간의 형변환은 문제를 일으킬 수 있다.
위 예제처럼 string 과 Number가 서로 호환이 되지 않음을 알 수 있다.
2. 부모가 자식으로 캐스팅시 에러 유발
Cat cat = new Cat("haha");
Animal animal1 = new Animal("neew");
Cat newCat = (Cat) animal1;
부모 클래스는 자식 클래스로 캐스팅이 가능하지만, 자식클래스에 정의된 메서드등을 사용할경우 에러가 나게 된다. 메모리에 실제적으로 저장된건 animal 클래스이기 때문이다.
제네릭의 종류
제네릭은 3가지 종류로 나눌 수 있다.
- 무공변
- 공변
- 반공변
세가지 다 어려운 말인데 무엇을 뜻할까?
간단하게 말하자면 무공변은 서로 관련이 없다는 뜻, 공변은 하위 자식들을 인정해주겠다, 반공변은 부모님만 인정해주겠다는 뜻이다.
제네릭의 선언 방식
선언 방식에는 2가지가 있는데, 코틀린 및 자바에서는 함수명 앞에 선언을 해주는 사용 지점 변성, 클래스 처음에 선언하는 선언 지점 변성 두가지가 있다.
// 선언 지점 변성
open class Tryout<out T>(startInt: Int) {
// 사용자 지점 변성
fun <T : R, R> copyData(source: MutableList<T>, destination: MutableList<R>) {
자바 와일드카드란?
와일드 카드 또한 제네릭의 일종으로 상위 타입 제한, 하위 타입 제한을 걸 수 있는 장치이다.
<? extends ~ >, <? super ~ > 2가지를 사용할 수 있으며 각각 상위 타입 제한, 하위 타입 제한이다.
상위 타입 제한
상위 타입 제한은 어떨 때 쓰일까?
위와 같은 관계가 있다고 하자.
static Juice makeJuice(FruitBox<Grape> box);
static Juice makeJuice(FruitBox<Apple> box);
이런식으로 포도와 사과를 갈아서 만드는 주스 함수를 생성했지만 컴파일 오류가 생성된다. 단순히 지네릭 타입이 다른것으로만 오버로딩이 되지 않기 때문이다. (오버로딩은 함수의 시그니처가 달라야 한다)
그렇다면 어떻게 해결할 수 있을까?
static Juice makeJuice(FruitBox<? extends Fruit> box);
이런식으로 상위클래스를 제한하여 하위클래스들 어떤것이든 들어올 수 있도록 만들면 한번에 모든 문제를 해결할 수 있다.
상위 타입 제한은 read-only?
상위 타입 제한은 왜 read only일까?
public void outBox(Box <? extends Fruit> box) {
Fruit fruit = box.get();
box.add(new Fruit()); // 컴파일 오류
}
위의 예시를 보면 알 수 있는데 Fruit 과 Fruit의 자식들은 Fruit으로 캐스팅해도 상관없으므로 Fruit으로 읽어올 수 있다. 하지만 box에 추가할때는 Fruit 인지 Fruit의 자식 (Grape or Apple) 인지 어떤것을 넣을지 알 수 없으므로 컴파일 오류를 출력한다 (위에서 보았듯이 형제관계 등 서로 아예 다른 실질적인 구조를 가졌을 수 있기 때문에)
하위 타입 제한
하위 타입제한은 주로 Comparator로 사용한다
class AppleComp implements Comparator<Apple>
class GrapeComp implements Comparator<Grape>
class GrapeComp implements Comparator<Fruit>
사과끼리는 사과끼리 비교해야 하고 포도끼리는 포도끼리 비교해야 하기 때문이다. 하지만 이런식으로 작성하면 모든 클래스마다 comparator를 구현해서 비교해야 한다.
그렇기에 똑똑한 sort함수는
static <T> void sort(List<T> list, Comparator<? super T> c)
이런식으로 작성되어있고 c에 super타입 하위타입을 못들어오게 작성되어있다. 이렇게 하면 모든 클래스마다 comparator를 작성하지 않아도 상위 클래스에 있는 comparator를 통해서 정렬이 가능해진다.
하위타입제한은 왜 write-only?
public void inBox(Box <? super Apple> box) {
Apple apple = (Apple) box.get(); //컴파일 오류
//class Fruit cannot be cast to class Apple
box.add(new Apple());
}
하위타입제한은 말그대로 상위타입만을 들고있는 것을 정의하는것이다. 그러므로 Apple 은 맨 하위타입이기 때문에 자료를 추가할 수 있지만, 데이터 읽기는 box안의 원소가 Apple 위의 상위타입중 (Object, Fruit) 어떤것이 Apple 으로 캐스팅될지 모르고 어떤 메서드를 사용할지 모르기 때문에 컴파일 오류를 보여준다. (부모가 자식으로 캐스팅 할시 캐스팅 오류를 보여주는 예시)
코틀린에서의 제네릭
코틀린에서는 in과 out 키워드로 나누어진다.
in 은 <? super ~ >, out 은 <? extends ~ > 이다.
개념과 같지만 조금 다른 부분이 있는데 클래스에서 선언하는 선언 지점 변성에서 제네릭을 정의할시에 아래처럼 in과 out의 위치를 꼭 지켜주어야 한다. 지켜주지 않으면 컴파일에러를 볼 수 있다.
fun transformation (t: T) // in 위치
fun transformation (): T // out 위치
이렇게 꼭 위치를 잘 적어주어야 컴파일 오류가 나지 않는다. 위치가 틀릴경우 컴파일오류가 나는 부분은 자바와는 다르게 코틀린에서 지켜야할 부분이다.
코틀린 제네릭 사용
코틀린 인 액션에서 보면 좋은 예제들이 나와있는데 그중 하나이다.
fun <T: R, R> transformation (source: List<T>, target: List<R>)
fun transformation (source: MutableList<out T>, target: MutableList<T>)
fun transformation (source: MutableList<T>, target: MutableList<in T>)
위 세가지 모두 잘 작동하는 방식이다. source가 target보다 하위관계여야 하는 상태인데 <T: R, R> 처럼 표현할 수 있는 부분 또한 눈여겨봐야 할 부분이다(T는 R을 상속받으니 R의 하위타입 R은 그대로 R타입으로 알려주는것). 게다가 out키워드와 in 키워드는 위의 자바 와일드카드와 같이 작동하기 때문에 자세한설명은 위의 예제를 다시 읽어보면 된다.
제네릭을 사용하면 좋은점
- 컴파일 타임에 타입을 체크
컴파일 타임에 타입을 체크하기 때문에 객체 자체의 타입 안정성이 올라가고, 의도하지 않은 타입의 객체가 저장되는것을 방지할 수 있어 유용하다.
- 형변환의 번거로움을 줄인다
Object 객체 대신 타입을 지정해줌으로써 형변환의 번거로움을 줄일 수 있다. (타입을 정확히 적시하여 컴파일러가 어떤 클래스를 사용하는지 분명히 알려주는 방법)
제네릭 주의점
- Primitive Type은 사용할 수 없다.
public void someMethod() {
List<int> intList = new List<>(); // 기본 타입 int는 사용 불가
List<Integer> integerList = new List<>(); // Okay!
}
2. 제네릭을 통해 객체를 생성하는것은 불가능
public void someMethod() {
// Type parameter 'T' cannot be instantiated directly
T t = new T();
return t;
}
부록 + 컴파일시 제네릭 소거
컴파일시 사용하고 난 제네릭은 런타임시에 타입이 제거되는데 이를 제네릭 소거라고 부른다.
// 컴파일 할 때 (타입 소거 전)
public class Test<T extends Comparable<T>> {
private T data;
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
// 런타임 때 (타입 소거 후)
public class Test {
private Comparable data;
public Comparable getData() {
return data;
}
public void setData(Comparable data) {
this.data = data;
}
}
하지만 위의 소거 방식이 진행되고 나서는 보완적인 부분이 필요하기에 타입 소거시 진행되는 부분은 다음과 같다.
- unbounded Type(<?>, <T>)는 Object로 변환합니다.
- bound type(<E extends Comparable>)의 경우는 Object가 아닌 Comprarable로 변환합니다.
- 제네릭 타입을 사용할 수 있는 일반 클래스, 인터페이스, 메소드에만 소거 규칙을 적용합니다.
- 타입 안정성 보존을 위해 필요하다면 type casting을 넣습니다.
- 확장된 제네릭 타입에서 다형성을 보존하기 위해 bridge method를 생성합니다.
즉 타입 안정성을 더 올리기 위해서 Unbounded Type (<T>) 와 같은 부분들은 Object로 변경되고, Bounded Type 은 명시된 타입으로 바뀌며 Type casting이 추가 된다. 마지막으로 Bridge method 또한 안정적인 런타임 지원을 위해서 작성이 된다.
public class MyComparator implements Comparator<Integer> {
public int compare(Integer a, Integer b) {
//
}
//THIS is a "bridge method"
public int compare(Object a, Object b) {
return compare((Integer)a, (Integer)b);
}
}
최근댓글