❒ Description
JDK5 부터 Generic이 도입되었다. Generic의 도입으로 우리는 매번 명시적으로 작성해줘야 했던 Type-Check와
Casting을 생략할 수 있을 뿐만 아니라, type-safe 한 좋은 코드를 작성할 수 있게 되었다. 이번 글에서는 Generic의
기초 뿐만 아니라, 보다 더 깊게 이해하기 위해 Variance(변성), Synthetic, Reification(실체화) 관련 내용들도 함께
정리하면서 공부해 볼 것이다.
❒ Variance (변성)
1. 변성이란?
Variance refers to how sub-typing between more complex types relates to sub-typing between their components.
(번역)
Variance은 더 복잡한 유형 간의 하위 유형이 해당 구성 요소 간의 하위 유형과 어떻게 관련되는지를 나타냅니다.
위 문구는 긱포긱에서 가져왔다. 사실 문장이 어려워서 이해는 못했고, ChatGPT가 요약해준 건 아래와 같다.
복잡한 유형 간의 관계와 그들을 구성하는 요소들 간의 관계에 대해 설명하고 있는 것이다. 암튼 이 Variance라는
것 덕분에 하위 유형 다형성을 안전하게 사용할 수 있다라고 설명되어있다.
2. 변성의 종류
변성에는 4가지 종류가 있다고 하는데, 여기서는 이변을 제외하고 공부할 것이다.
1. 공변성(Covariance)
공변성이란 원래 지정된 것보다 더 구체적인 형식을 사용할 수 있는 것을 의미한다. Up-Casting이라고 이해하고 있다.
public class Variance {
public static void main(String[] args) {
Object[] covariance = new Integer[10];
covariance[0] = "blah~blah~";
covariance[1] = 1;
}
}
위 코드의 경우 정상적으로 컴파일 된다. 하지만 Runtime에서 java.lang.ArrayStoreException를 던질 것이다.
2. 반공변성(Contravariance)
반공변성이란 공변성의 반대의 의미로 원래 지정된 것보다 덜 구체적인 형식을 사용할 수 있는 것을 의미한다.
하위 타입과의 관계가 역전됐다고 말할 수 있고, Down-Casting으로 이해할 수 있다.
Object[] covariance = new Integer[10];
Integer[] contraVariance = (Integer[]) covariance;
3. 무공변 / 불공변 (Invariant)
불공변성이란, 원래 지정된 형식만 사용할 수 있음을 의미한다. Casting을 할 수 없다.
public class InvarianceEx1 {
public static void main(String[] args) {
List<Object> integerList = new ArrayList<Integer>();
}
}
❒ Generic In Java
Generic Progrmming이란, 데이터의 형식에 의존하지 않고, 하나의 값이 여러 다른 데이터 타입들을
가질 수 있는 기술에 중점을 두어 재사용성을 높여주는 프로그래밍 방식이다.
왜 Java에서 Generic을 사용할까?
1. 컴파일 타임에 Type-Check를 통해 예외를 방지해준다. |
2. 불필요한 casting 작업을 없애, 성능 향상에 이점이 있다. |
Generic 사용시 몇 가지 주의할 점도 있다.
1. Generic 타입의 객체는 생성할 수 없다. |
2. static 멤버에 Generic이 올 수 없다. |
3. TBD |
주의사항 2번의 이유는 static 맴버는 클래스가 동일하게 공유하는 변수로서 컴파일 시점에
type이 정해져 있어야 하기 때문이다.
public class SampleGenericEx2<T> {
private String name;
private int age;
// !Compile Error!
public static T methodEx1() {
//...
}
}
위 코드는 클래스에 정의된 매개변수화 타입 T를 methodEx1 메소드의 반환 타입으로 사용하고 있다.
IDE에서도 'Generic.SampleGenericEx2.this' cannot be referenced from a static context 경고 문구가
뜨는 걸 확인할 수 있다.
위의 코드를 정상 동작하도록 하기 위해서는, static 메서드 레벨에서 별도의 매개변수화 타입 T를
선언해주면 된다.
public class SampleGenericUtilEx<T> {
//...
public static <T> void methodEx1(T value) {
//...
}
}
❒ <?> : Wild Card
1. Wild card란?
wild card는 하나 이상의 문자들을 상징하는 특수 문자 ?를 뜻한다. 이는 여러 타입이 들어올 수 있음을
의미한다. 다음 표는 와일드 카드의 종류와 특징을 나타낸 표다.
<?> | Unbounded(비한정) wild-card | 제한 없음 (모든 타입 가능) |
<? extends U> | Upper Bounded(상한 경계) wild-card | 상위 클래스 제한(U와 그 자손) |
<? super U> | Lower Bounded(하한 경계) wild-card | 하위 클래스 제한 (U와 그 조상) |
2. PECS : extends와 super를 구분하는 기준
이 내용은 Effective Java Item31(p184)에 나온 내용이다.
PECS란 `producer-extends, consumer-super`의 약자이며, parameterized(매개변수화) 타입 T가
생산자의 역할이라면 extends를 사용하고, 소비자의 역할이라면 super를 사용하라는 규칙이다.
3. Wild card로 Generic의 성격 바꾸기
원래 Generic은 불공변한 성격을 가지고 있는데 wild card를 잘 활용하면 상황에 따라 그 변경할 수 있다.
이렇게 특정 지점에서 변성을 정하는 것을 사용지점 변성(use-site variance)이라 한다.
#️⃣ 상한 경계 (불공변 → 공변)
public void pushAll(List<E> list) {
for (E e : list) {
push(e);
}
}
처음에는 pushAll 메소드의 매개변수화 타입 E에 wild card를 사용하지 않았다.
따라서 Generic의 불공변한 성격 때문에 컴파일이 수행되지 않았다
public class RunStackEx1 {
public static void main(String[] args) {
StackEx1<Number> numberStack = new StackEx1<>();
List<Integer> integers = new ArrayList<>(List.of(1,2,3,4));
numberStack.pushAll(integers);
}
}
java: incompatible types: java.util.List<java.lang.Integer> cannot be converted to java.util.List<java.lang.Number>
pushAll 메소드는 매개변수에 들어온 list 내 모든 요소들을 새로운 배열에 넣어주는 역할을 한다.
즉, 생성자의 역할을 하기 때문에 PECS 공식에 의거하여 extends 키워드를 사용하여 코드를 수정했다.
public void pushAll(List<? extends E> list) {
for (E e : list) {
push(e);
}
}
public class RunStackEx1 {
public static void main(String[] args) {
StackEx1<Number> numberStack = new StackEx1<>();
List<Integer> integers = new ArrayList<>(List.of(1,2,3,4));
numberStack.pushAll(integers);
}
}
이렇게 pushAll 메소드를 호출할 때 변성 성질을 변경해봤고, PECS 규칙까지 적용해서 정상 컴파일되는
코드를 완성하였다.
#️⃣ 하한 경계 (불공변 → 반공변)
상한 경계 예제와 유사한 흐름이다. 그럼 여기서도 매개변수화 타입에 와일드 카드를 적용해보자.
popAll 메소드는 현재 Stack에 있는 모든 값을 제거하는 기능이다. 즉, 소비자의 역할을 하기 때문에
PECS 공식에 의거하여 super 키워드를 사용할 것이다.
public void popAll(Collection<? super E> list) {
while (!isEmpty()) {
list.add(pop());
}
}
❒ put / get 제약
1. List<?>
• 타입 안전성을 위해 조회는 Object 타입으로만! |
• null을 제외한 어떠한 타입의 자료도 넣을 수 없다. |
2.상한 경계 : List<? extends U>
• 안전하게 조회하기 위해서 U타입으로 조회 |
• null을 제외한 어떠한 타입의 자료도 넣을 수 없다. |
3. 하한 경계 : List<? super U>
• 타입 안전성을 위해 조회는 Object 타입으로만! |
• U와 U의 자손 타입만 넣을 수 있다. |
그리고 여기서 신기한 점은 line 8에 있는데, 리스트를 초기화 할 때 Food로 초기화는 할 수 있다.
다시 말해, Fruit의 조상인 Food는 수용할 수 있다는 것이다. 하지만 add 메소드로 추가는 할 수 없다.
추가적으로 아래와 같이 코드를 작성하면 문제 없이 리스트의 요소들을 출력할 수 있다.
String name = null;
if (!fruitList.isEmpty()) {
for (Object fruit : fruitList) {
switch (fruit.getClass().getName()) {
case "Generic.put_get.Apple" -> {
name = "apple";
}
case "Generic.put_get.Banana" -> {
name = "banana";
}
case "Generic.put_get.Kiwi" -> {
name = "kiwi";
}
}
System.out.println(name);
}
}
❒ Type Erasure
1. 타입 소거란?
Generic은 JDK 1.5 부터 도입된 개념이다. 해당 개념을 도입하기 위해선 하위 버전과의 호환성 유지를 위한 작업이
필요했다. 따라서 코드의 호환성 때매 소거(erasure)방식을 사용하게 된 것이다.
아래 코드에 타입 소거가 이루어진다면?
public static <E> boolean containsElement(E [] elements, E element){
for (E e : elements){
if(e.equals(element)){
return true;
}
}
return false;
}
다음과 같이 변경된다. E는 unbound type이기 때문에 Object로 치환된다.
public static boolean containsElement(Object[] elements, Object element) {
for (Object e: elements) {
if (e.equals(element) {
return true;
}
return false;
}
}
2. 타입 소거의 종류
1️⃣ Class Type
클래스 레벨에서 해당 클래스의 type parameter를 소거한다. 그리고 <T extends Member>와 같은 형태인
경우 first bound로 대체한다. (여기서 first bound는 Member)
만약 type parameter가 제한이 없는 경우라면(unbounded, <?>) Object로 대체한다.
public class BoundStack<E extends Comparable<E>> {
private E[] stackContent;
public BoundStack(int capacity) {
this.stackContent = (E[]) new Object[capacity];
}
public void push(E data) {
// ..
}
public E pop() {
// ..
}
}
public class BoundStack {
private Comparable[] stackContent;
public BoundStack(int capacity) {
this.stackContent = (Comparable[]) new Object[capacity];
}
public void push(Comparable data) {
// ..
}
public Comparable pop() {
// ..
}
}
2️⃣ Method Type
Method 레벨에서는 method’s type parameter는 저장되지 않는다. 하지만 부모 타입 객체로 전환(convert) 된다.
public static <E extends Comparable<E>> void printArray(E[] array) {
for (E element : array) {
System.out.printf("%s ", element);
}
}
public static void printArray(Comparable[] array) {
for (Comparable element : array) {
System.out.printf("%s ", element);
}
}
3. Edge case
가끔 Type Erasure 과정에서 컴파일러가 Synthetic method를 사용하여 비슷한 메서드를 차별화 한다.
예제로 더 이해해보자.
public class Stack<E> {
private E[] stackContent;
public Stack(int capacity) {
this.stackContent = (E[]) new Object[capacity];
}
public void push(E data) {
// ..
}
public E pop() {
// ..
}
}
public class IntegerStack extends Stack<Integer> {
public IntegerStack(int capacity) {
super(capacity);
}
public void push(Integer value) {
super.push(value);
}
}
위 코드는 어떻게 동작할까?
1. IntegerStack integerStack = new IntegerStack(5);
2. Stack stack = integerStack;
3. stack.push("Hello");
4. Integer data = integerStack.pop();
4번에서 ClassCastExcpetion이 터질 것 이다. 이유는 2번에서 up-casting을 해서 모든 타입을
허용하게 되서 그렇다. 그렇다면 이를 해결할 수 있는 방법이 뭐가 있을까?
4. How to avoid edge case? Use Bridge method
Bridge Method란, Synthetic method의 특별한 case이다. 이는 제네릭의 type-erasure 처리 방식을
handling 해주는 method인데, 컴파일러가 자동으로 생성해준다. 왜 생성하는지는 아래 예시를 보면서 알아보자.
Message.java
public class Message<T> {
public T payload;
public void payload(T payload) {
this.payload = payload;
}
}
StringMessage.java
public class StringMessage extends Message<String> {
@Override
public void payload(String payload) {
super.payload(payload);
}
}
Compiled from "StringMessage.java"
public class Chap5_Generic.bridge_method.StringMessage extends Chap5_Generic.bridge_method.Message<java.lang.String> {
public Chap5_Generic.bridge_method.StringMessage();
Code:
0: aload_0
1: invokespecial #1 // Method Chap5_Generic/bridge_method/Message."<init>":()V
4: return
⑴ public void payload(java.lang.String);
Code:
0: aload_0
1: aload_1
2: invokespecial #7 // Method Chap5_Generic/bridge_method/Message.payload:(Ljava/lang/Object;)V
5: return
⑵ public void payload(java.lang.Object);
Code:
0: aload_0
1: aload_1
2: checkcast #11 // class java/lang/String
5: invokevirtual #13 // Method payload:(Ljava/lang/String;)V
8: return
}
위 소스는 `javap -c 클래스명` 명령어를 통해 disassemble한 것이다.
원본 소스(StringMessage.java)에는 String을 매개변수로 받는 payload 메서드만 존재한다.
하지만 disassemble한 코드를 살펴보면 Object를 매개변수로 받는 payload 메서드가 하나 더 존재한다.
그리고 Code:2:checkcast 쪽을 보면 주석으로 class java/lang/String 라고 되어있다. 그리고
Code:5:invokevirtual 쪽을 보면 주석으로 Method payload:(Ljava/lang/String;)V 라고 되어 있다.
이 부분을 이해하기 쉽게 java 코드로 변경해보면 아래와 같다.
public class StringMessage extends Message<String> {
public StringMessage() {
super();
}
@Override
public void payload(String payload) {
super.payload(payload);
}
// Bridge method to accept an Object payload
@Override
public void payload(Object payload) {
this.payload((String) payload);
}
}
즉, type-erasure 과정에서 컴파일러에 의해 생성된 Bridge(Synthethic) Method는 Object를
매개변수로 들어온 값을 String으로 casting 해주는 메서드를 자동으로 생성해주고 있던 것이다.
이렇게 소스 상에서는 override를 했지만, type-erasure 과정에서 overloading 처리가 되어버린다.
따라서 runtime 시점에는 항상 매개변수에 String 값만 받을 수 있게 되는 것이다.
그럼 이제 코드의 동작 순서를 알아보자.
만약 paylod(”gilbert”) 실행하면 호출한다면 ⑴ 메서드를 호출하는 것이다. 반면에 paylod(14)를 실행하면
⑵ 메서드를 호출할 것이며, String으로 casting을 시도할 것이다. 이때 ClassCastException이 발생하게 된다.
❒ 비슷한거 같으면서도 다른 Object와 Generic
그럼 Object가 있는데 왜 Generic을 쓸까?
Object 클래스는 Java의 모든 객체에 대한 기본적인 동작을 정의하며, 모든 타입을 수용할 수 있는
가장 일반적인 형태로 다형성을 제공한다.
반면, Generic은 타입 파라미터를 통해 컴파일 시점에 타입 안전성을 강화하고 코드의 재사용성을
높이는 '타입 시스템'의 확장이다.
'Langauge > Java' 카테고리의 다른 글
Comparable, Stream을 사용하여 뱃지 부여하기 (0) | 2024.10.10 |
---|