SKILL/JAVA

제네릭이란?

Jedy_Kim 2021. 9. 1. 20:21
728x90

자바를 공부하면서 제네릭을 마주하였고, 뭐 <> 이런 식으로 쓰면 되는 거구나라고 여기고 넘어갔지만 책장을 넘기며 진도를 나아가다 이해가 안 되는 모양의 녀석을 만나

public <S, T extends Car> void multiGeneric(WildCardGeneric<T> c, T addValue, S another)

다시 제대로 깊게 짚고 넘어가야 겠다는 생각을 하고, 결심 끝에 이렇게 정리를 해본다.

 

우선 들어가기에 앞서 DTO 객체를 한번 살펴보도록 하자.

모양은 Obejct 타입의 변수 하나와 get/set 메서드를 가진 이상할게 없는 전형적인 DTO의 모습을 하고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.io.Serializable;
 
public class CastingDTO implements Serializable {
    private Object obj;
 
    public Object getObj() {
        return obj;
    }
 
    public void setObj(Object obj) {
        this.obj = obj;
    }
}
cs

아래는 위의 DTO를 사용하는 코드이다.

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
public class GenericSample {
 
    public static void main(String[] args) {
        GenericSample sample = new GenericSample();
        sample.checkCastingDTO(sample);
    } 
    
 
    public void checkDTO(CastingDTO dto) {
        Object tempObject = dto.getObj();
        if (tempObject instanceof StringBuilder) {
            System.out.println("StringBuilder");
        } else if (tempObject instanceof StringBuffer) {
            System.out.println("StringBuffer");
        }
    }
 
    public void checkCastingDTO(GenericSample sample) {
        CastingDTO dto1 = new CastingDTO();
        dto1.setObj(new String());
 
        CastingDTO dto2 = new CastingDTO();
        dto2.setObj(new StringBuffer());
        sample.checkDTO(dto2);
 
        CastingDTO dto3 = new CastingDTO();
        dto3.setObj(new StringBuilder());
        sample.checkDTO(dto3);
    } 
 
}
cs

위의 코드 또한 문제가 없어 보인다. 하지만  checkDTO메서드를 보면 저장되어 있는 객체를 불러올 때에는 getObj() 메서드를 통해 가져오는데 이를 사용하기 위해서는 위와 같이 instanceof 를 통해 분기 처리를 해서 별도의 방식으로 형 변환 처리를 한 뒤 사용해야만 한다.

코드로 표현해보자면

StringBuffer test = (StringBuffer)dto2.getObj() 

이런 식으로 말이다.

이런 부분에 있어서 문제가 아니라고 여길 수 있지만, 잠재적 형변환 오류의 가능성과 또, 유지 보수 등 문제가 될 수 있는 부분이다. 

특히 형 변환 문제는 컴파일 시점에서는 어떤 오류도 발생하지 않는다는 것이 문제다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.io.Serializable;
 
 
public class CastingDTO implements Serializable {
    private StringBuffer obj;
 
    public StringBuffer getObj() {
        return obj;
    }
 
    public void setObj(StringBuffer obj) {
        this.obj = obj;
    }
}
cs

위와 같은 상황을 없애고자 한다면 분명 같은 성격의 DTO임에도 타입별로 만들어줘야 하는 중복의 문제점이 생긴다. 이는 유지 보수 시 지양해야 하는 부분이다.

 

이러한 형 변환에서 발생할 수 있는 문제점을 "사전"에 없애기 위해서 제네릭은 만들어졌다.

 

위의 코드를 개선하여 제네릭을 적용한 코드를 보면서 이해를 해보자. 우선 <T> 가 추가되었고,

곳곳에 'T' 가 눈에 보인다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.io.Serializable;
 
public class CastingGenericDTO<T> implements Serializable {
 
    private T obj;
 
    public T getObj() {
        return obj;
    }
 
    public void setObj(T obj) {        
        this.obj = obj;
    }
}
cs

위의 DTO를 사용한 아래의 코드를 보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class GenericSample {
 
    public static void main(String[] args) {
        GenericSample sample = new GenericSample(); 
        sample.checkGenericDTO();
    } 
 
    public void checkGenericDTO() {
        CastingGenericDTO<String> dto1 = new CastingGenericDTO<String>();
        dto1.setObj(new String());
 
        CastingGenericDTO<StringBuffer> dto2 = new CastingGenericDTO<StringBuffer>();
        dto2.setObj(new StringBuffer());
 
        CastingGenericDTO<StringBuilder> dto3 = new CastingGenericDTO<StringBuilder>();
        dto3.setObj(new StringBuilder());
 
        String temp1        = dto1.getObj();
        StringBuffer temp2  = dto2.getObj();
        StringBuilder temp3 = dto3.getObj();
    }
}
 
cs

사용할 때를 보면 '<>' 사이에 타입이 명시된 것을 볼 수 있고, 꺾쇠 안의 타입에 맞춰 set 메서드에서 객체를 생성해 준다.

그 아래 get 메서드 부분을 보면, 위의 제네릭 적용 전 코드와 달리 형 변환 없이 바로 받아쓰고 있다. 또 잘 못된 타입으로 치환하면 컴파일 자체가 안된다. 즉, "실행 시"에 타입으로 잘못 형 변환하여 예외가 발생하는 일은 없다. 이와 같이 명시적으로 타입을 지정할 때 사용하는 것이 제네릭이고, 위의 예제는 제네릭의 가장 기본적인 사항이다.

 

Generic(제네릭)의 장점을 정리해보자면 아래와 같다.

1. 제네릭을 사용하면 잘못된 타입이 들어올 수 있는 것을 컴파일 단계에서 방지할 수 있다.

2. 클래스 외부에서 타입을 지정해 주기 때문에 따로 타입을 체크하고 변환해 줄 필요가 없다.

3. 비슷한 기능을 지원하는 경우 코드의 재사용성이 높아진다.

 

제네릭 타입

타입 설명
E 요소 (Element, 자바 컬렉션에서 주로 사용됨)
K
N 숫자
T 타입
V
S, U, V 두 번째, 세 번째, 네 번째에 선언된 타입

꼭 위와 같은 규칙을 지켜야 컴파일이 되는 것은 아니지만, 가독성을 위해서는 위와 같은 규칙을 따르는 게 좋다.

 

1. 클래스 및 인터페이스 선언

1
2
3
public class CastingGenericDTO<T> implements Serializable {...}
 
public interface MyInterface <T> {...}
cs

기본적으로 제네릭 타입의 클래스나 인터페이스의 경우 위와 같이 선언한다.

T 타입은 해당 블록 { ... } 안에서까지 유효하다.

 

네릭 타입을 두 개로도 사용할 수 있다. 

1
2
3
public class CastingGenericDTO<T, K> implements Serializable {...}
 
public interface MyInterface <T, K> {...}
cs

 

2. 제네릭 클래스

위의 부분으로만 봐서는 구체적으로 어떻게 사용되는지 쉽게 감이 잡히지 않는다. 예제를 살펴보자 예제는 제네릭 타입 두 개를 사용한 예제이지만 자연스럽게 한 개를 쓴 것도 이해가 될 것이라 생각한다.

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package com.study;
 
import java.util.HashMap;
import java.util.Map;
 
class GenericTypeTest<K, V> {
    private Map<K, V> map;
 
    public Map<K, V> getMap() {
        return map;
    }
 
    private K key;
    private V value;
 
    public GenericTypeTest() {}
 
    public GenericTypeTest(K key, V value) {
        map = new HashMap<>();
        map.put(key, value);
    }
 
    public K getKey() {
        return key;
    }
 
    public void setKey(K key) {
        this.key = key;
    }
 
    public V getValue() {
        return value;
    }
 
    public void setValue(V value) {
        this.value = value;
    }
}
 
public class GenericTest {
    public static void main(String[] args) {
        // sample 1
        GenericTypeTest<String, Integer> sample = new GenericTypeTest<String, Integer>();
        sample.setKey("banana");
        sample.setValue(3000);
        System.out.println(sample.getKey() + " : " + sample.getValue());
 
        // sample 2
        GenericTypeTest<String, Integer> sample2 = new GenericTypeTest<String, Integer>("apple"1000);
        Map<String, Integer> getMap = sample2.getMap();
        for(Map.Entry<String, Integer> ele : getMap.entrySet()) {
            System.out.println(ele.getKey() + " : " + ele.getValue());
        }
 
        // sample 3
        GenericTypeTest<StringString> sample3 = new GenericTypeTest<>("Fruit""Company");
        Map<StringString> getMap2 = sample3.getMap();
        for(Map.Entry<StringString> ele : getMap2.entrySet()) {
            System.out.println(ele.getKey() + " : " + ele.getValue());
        }
    }
}
 
cs

sample1, sample2, sample3를 보면 <String, Integer>, <String, String> ... 각각 다양하게 데이터 타입을 외부로부터 지정할 수 있도록 할 수 있다. 이렇게 타입을 구체적으로 지정해 주면서 사용하고 있음을 볼 수 있다. 

class GenericTypeTest<K, V>  해당 클래스를 보면 꺽쇠안의 K, V를 클래스 안에서 하나의 타입으로 사용하고 있으며,

sample 들의 출력 부분들을 보면 형 변환의 신경 쓰지 않고 바로바로 쓰이는 것을 볼 수 있다. 

 

이때 주의해야 할 점은 타입 파라미터로 명시할 수 있는 것은 참조 타입(Reference Type)밖에 올 수 없다. 즉, int, double, char 같은 primitive type은 올 수 없다는 것이다. 그래서 int형 double형 등 primitive Type의 경우 Integer, Double 같은 Wrapper Type으로 쓰는 이유가 바로 위와 같은 이유다.

 

또한 바꿔 말하면 참조 타입이 올 수 있다는 것은 사용자가 정의한 클래스도 타입으로 올 수 있다는 것이다.

 

3. 제네릭 메소드

1
2
3
4
5
6
7
public <T> T genericMethod(T o) { // 제네릭 메소드
        ...
}
 
[접근 제어자] <제네릭타입> [반환타입] [메소드명]([제네릭타입] [파라미터]) {
    // 텍스트
}
cs

예제를 통해 살펴보도록하자.

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
32
33
34
35
36
37
38
39
40
41
42
package com.study;
 
class GenericTypeTest<E> {
    // 제네릭 타입 변수
    private E element;
 
    public E getElement() {
        return element;
    }
    public void setElement(E element) {
        this.element = element;
    }
    // 제네릭 메서드
    <T> T genericMethod(T o) {
        return o;
    }
}
 
public class GenericTest {
    public static void main(String[] args) {
        GenericTypeTest<String> sample1 = new GenericTypeTest<>();
        GenericTypeTest<Integer> sample2 = new GenericTypeTest<>();
 
        sample1.setElement("10");
        sample2.setElement(10);
 
        System.out.println("sample1 data : " + sample1.getElement());
        System.out.println("sample1 E type : " + sample1.getElement().getClass().getName());
 
        System.out.println("");
 
        System.out.println("sample2 data : " + sample2.getElement());
        System.out.println("sample2 E type : " + sample2.getElement().getClass().getName());
 
        System.out.println("");
 
        System.out.println("<T> return type : " + sample1.genericMethod(3).getClass().getName());
        System.out.println("<T> return type : " + sample1.genericMethod("abcd").getClass().getName());
        System.out.println("<T> return type : " + sample1.genericMethod(sample2).getClass().getName());
    }
}
 
cs

결과

결과를 보면 선언했던 타입들이 나온다. 메서드를 좀 더 살펴보면 <T> 는 매개변수 타입과 일치하기 때문에 매개변수의 타입이 반환되어 찍힌다. 마지막 <T> return type 을 보면 매개변수로 "sample2"를 던졌기 때문에  "GenericTypeTest"가 찍히는 것을 볼 수 있다. 또 클래스에서 지정한 제네릭 유형과 별도로 메서드에서 독립적으로 제네릭 유형을 선언하여 쓸 수 있다.

 

왜? 독립적으로 제네릭 유형을 선언하는 방식이 필요할까? 

바로 '정적 메서드로 선언할 때 필요'하기 때문이다.

우리가 예제를 통해 살펴본 제네릭은 유형을 외부에서 지정해 줬다. 즉, 해당 클래스 객체가 new 생성자로 클래스 객체를 생성하고, <>괄호 사이에 파라미터로 넘겨준 타입으로 지정되는 방식이었다.

 

하지만 static은 기본적으로 프로그램 실행 시 메모리에 이미 올라가 있다. 즉 객체 생성을 통해 접근할 필요 없이 이미 메모리에 올라가 있기 때문에 클래스 이름을 통해 바로 쓸 수 있다는 것이다.

 

여기서 다시 생각해 보면 static 메서드는 객체가 생성되기 전에 이미 메모리에 올라가는데 타입을 어디서 얻을 수 있을까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.study;
 
class GenericTypeTest<E> {
    // 제네릭 메서드 : 에러!
    static E genericMethod(E o) {
        return o;
    }
}
 
public class GenericTest {
    public static void main(String[] args) {
        GenericTypeTest.genericMethod(3);
    }
}
 
cs

직접 코드를 넣어보면 에러가 남을 알 수 있다. 즉 E 유형을 클래스로부터 얻어올 방법이 없다.

 

즉 이런 문제를 해결하기 위해서는 제네릭이 사용되는 메서드를 정적 메서드로 두고 싶은 경우 제네릭 클래스와 별도로 독립적인 제네릭이 사용되어야만 한다.

다음 예제를 통해 다시 살펴보자. GenericTypeTest 클래스에 주의 깊게 보자.

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.study;
 
class GenericTypeTest<E> {
    // 제네릭 타입 변수
    private E element;
 
    public E getElement() {
        return element;
    }
    public void setElement(E element) {
        this.element = element;
    }
    // 제네릭 메서드
    static <E> E genericMethod1(E o) {
        return o;
    }
    static <T> T genericMethod2(T o) {
        return o;
    } 
}
 
public class GenericTest {
    public static void main(String[] args) {
        GenericTypeTest<String> sample1 = new GenericTypeTest<>();
        GenericTypeTest<Integer> sample2 = new GenericTypeTest<>();
 
        sample1.setElement("10");
        sample2.setElement(10);
 
        System.out.println("sample1 data : " + sample1.getElement());
        System.out.println("sample1 E type : " + sample1.getElement().getClass().getName());
 
        System.out.println("");
 
        System.out.println("sample2 data : " + sample2.getElement());
        System.out.println("sample2 E type : " + sample2.getElement().getClass().getName());
 
        System.out.println("");
 
        System.out.println("<T> return type : " + GenericTypeTest.genericMethod1(3).getClass().getName());
        System.out.println("<T> return type : " + GenericTypeTest.genericMethod1("abcd").getClass().getName());
        System.out.println("<T> return type : " + GenericTypeTest.genericMethod1(sample2).getClass().getName());
        System.out.println("<T> return type : " + GenericTypeTest.genericMethod1(3.0).getClass().getName());
    }
}
 
cs

 

결과

보다시피 제네릭 메서드는 제네릭 클래스 타입과 별도로 지정된다는 것을 볼 수 있다.

<> 괄호 안에 타입을 파라미터로 보내 제네릭 타입을 지정해 주는 것. 이것이 바로 제네릭 프로그래밍이다.


4. 와일드 카드

우리는 앞선 예제를 통해 제네릭 즉 꺾쇠(<>)를 사용할 때, 안에 들어가는 타입은 어떤 타입이라도 상관없다는 것을 예제를 통해 살펴보았다. 

제네릭 메서드를 통해서 보면 타입을 외부에서 지정해 주었다. 즉 사용할 때 코드에 미리 <String>, <Integer> 등의 타입이 지정되어야만 한다.

이 부분에서 의문 하나를 던질 수 있다. 바로 Overloading이다. 메서드 모양은 같지만 파라미터 인자 하나는 <String> 또 하나는 <Integer> 가 필요한 경우가 있을 것이다.

1
2
3
4
5
6
7
8
9
    public void wildcardStringMethod(WildGenericTest<String> c) {
        String value = c.getWildcard();
        System.out.println(value);
    }
 
    public void wildcardStringMethod(WildGenericTest<Integer> c) {
       int value = c.getWildcard();
        System.out.println(value);
    }
cs

이렇게 쓰고 싶지만, 코드를 넣어보면 에러가 난다. 즉 제네릭 한 클래스의 타입만 바꾼다고 Overloading이 불가능하다.

이러한 부분에 해결책을 제시하는 것이 바로 "wildcard 타입" "?"를 사용하여 나타낸다.

먼저 예제를 통해 살펴보자.

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
32
33
34
35
36
37
38
39
40
41
package com.study;
 
class WildGenericTest<W> {
     W wildcard;
 
    public W getWildcard() {
        return wildcard;
    }
 
    public void setWildcard(W wildcard) {
        this.wildcard = wildcard;
    }
}
 
public class GenericTest {
    public static void main(String[] args) {
        GenericTest sample = new GenericTest();
        sample.callWildCardMethod();
    }
 
    public void callWildCardMethod() {
 
        WildGenericTest<String> wildcard = new WildGenericTest<>();
        wildcard.setWildcard("A");
        wildcardStringMethod(wildcard);
 
        WildGenericTest<Integer> wildcard2 = new WildGenericTest<>();
        wildcard2.setWildcard(1);
        wildcardStringMethod(wildcard2);
 
    }
 
    public void wildcardStringMethod(WildGenericTest<?> c) {
        Object value = c.getWildcard();
        if(value instanceof String)
            System.out.println("String value : " + value);
        else if(value instanceof Integer)
            System.out.println("Integer value : " + value);
    }
 
}
cs

 

wildcardStringMethod(WildGenericTest<?> c) 코드에서처럼 <?>를 사용하면 어떤 타입이 제네릭 타입이 되더라도 상관없다. 당연히 Object로 받아서 처리를 해줘야 한다.

 

wildcard는 메서드의 매개 변수로만 사용하는 것이 좋다. 어떤 객체를 wildcard로 선언하고, 그 객체의 값은 가져올 수는 있지만, 와일드카드로 객체를 선언했을 때에는 특정 타입으로 값을 지정하는 것은 "불가능" 하다.

 

 

5. 제한된 제네릭

다시 한번 제네릭을 상기시켜보면 제네릭은 참조 타입 모두 될 수 있다. 이 부분에 있어서는 예제를 통해 의심의 여지가 없다.

하지만 이런 특성을 제한하고 싶을 수도 있을 것이다. 즉 범위를 제한하고 싶은 것이다.

 

이때 필요한 것이 extends 와 super, 그리고 ?(와일드카드 -  알 수 없는 타입)이다.

1
2
3
4
5
6
<extends T>    // T와 T의 자손 타입만 가능 (K는 들어오는 타입으로 지정 됨)
<super T>        // T와 T의 부모(조상) 타입만 가능 (K는 들어오는 타입으로 지정 됨)
 
<extends T>    // T와 T의 자손 타입만 가능
<super T>        // T와 T의 부모(조상) 타입만 가능
<?>                // 모든 타입 가능. <? extends Object>랑 같은 의미
cs

보통 이해하기 쉽게 다음과 같이 부른다.

 extends T : 상한 경계

? super T : 하한 경계

 

이때 주의해야 할 게 있다. K extends T와? extends T는 비슷한 구조지만 차이점이 있다.

'유형 경계를 지정'하는 것은 같으나 경계가 지정되고 K는 특정 타입으로 지정이 되지만, ?는 타입이 지정되지 않는다는 의미다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
 * Number와 이를 상속하는 Integer, Short, Double, Long 등의
 * 타입이 지정될 수 있으며, 객체 혹은 메소드를 호출 할 경우 K는
 * 지정된 타입으로 변환이 된다.
 */
<extends Number>
 
 
/*
 * Number와 이를 상속하는 Integer, Short, Double, Long 등의
 * 타입이 지정될 수 있으며, 객체 혹은 메소드를 호출 할 경우 지정 되는 타입이 없어
 * 타입 참조를 할 수는 없다.
 */
<extends T>    // T와 T의 자손 타입만 가능
cs

 

다음과 같이 서로 다른 클래스들이 상속관계를 갖고 있다고 가정해보자.

<K extends T>, <? extends T>

이것은 T 타입을 포함한 자식(자손) 타입만 가능하다는 의미다. 즉, 다음과 같은 경우들이 있다.

1
2
3
4
5
6
7
<extends B>    // B와 C타입만 올 수 있음
<extends E>    // E타입만 올 수 있음
<extends A>    // A, B, C, D, E 타입이 올 수 있음
 
<extends B>    // B와 C타입만 올 수 있음
<extends E>    // E타입만 올 수 있음
<extends A>    // A, B, C, D, E 타입이 올 수 있음
cs

상한 한계. 즉 extends 뒤에 오는 타입이 최상위 타입으로 한계가 정해지는 것이다.

 

대표적인 예로는 제네릭 클래스에서 수를 표현하는 클래스만 받고 싶은 경우가 있다. 대표적인 Integer, Long, Byte, Double, Float, Short 같은 래퍼 클래스들은 Number 클래스를 상속받는다.

 

즉,  Integer, Long, Byte, Double, Float, Short 같은 수를 표현하는 래퍼 클래스만으로 제한하고 싶은 경우 다음과 같이 쓸 수 있다.

 

예제를 통해 살펴보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.study;
 
class WildGenericTest<extends Number> {
    K wildcard;
    public K getWildcard() {
        return wildcard;
    }
    public void setWildcard(K wildcard) {
        this.wildcard = wildcard;
    }
}
 
public class GenericTest {
    public static void main(String[] args) {
        WildGenericTest<Double> test1 = new WildGenericTest<>(); // 정상
        WildGenericTest<String> test2 = new WildGenericTest<>(); // 에러        
    }
}
 
cs

 

여기서 코드를 넣어보면 <Double>은 Number를 상속했으므로 정상적으로 처리가 되지만 <String>은 제한 범위에 걸리기 때문에 에러를 발생시킨다.

 

<K super T>, <? super T>

1
2
3
4
5
6
7
<super B>    // B와 A타입만 올 수 있음
<super E>    // E, D, A타입만 올 수 있음
<super A>    // A타입만 올 수 있음
 
<super B>    // B와 A타입만 올 수 있음
<super E>    // E, D, A타입만 올 수 있음
<super A>    // A타입만 올 수 있음
cs

하한 한계. 즉 super 뒤에 오는 타입이 최하위 타입으로 한계가 정해지는 것이다.

 

대표적으로는 해당 객체가 업캐스팅(Up Casting)이 될 필요가 있을 때 사용한다.

예로 들어 '과일'이라는 클래스가 있고 이 클래스를 각각 상속받는 '사과'클래스와 '딸기'클래스가 있다고 가정해보자.

 

이때 각각의 사과와 딸기는 종류가 다르지만, 둘 다 '과일'로 보고 자료를 조작해야 할 수도 있다. (예로 들면 과일 목록을 뽑는다거나 등등..) 그럴 때 '사과'를 '과일'로 캐스팅해야 하는데, 과일이 상위 타입이므로 업캐스팅을 해야 한다. 이럴 때 쓸 수 있는 것이 바로 super라는 것이다.

 

조금 더 현실성 있는 예제라면 제네릭 타입에 대한 객체 비교가 있다.

1
public class ClassName <extends Comparable<super E> { ... }
cs

이런 문구를 한 번쯤 보셨을 수 있다. 특히 PriorityQueue(우선순위 큐), TreeSet, TreeMap 같이 값을 정렬하는 클래스 만약 여러분이 특정 제네릭에 대한 자기 참조 비교를 하고 싶을 경우 대부분 공통적으로 위와 같은 형식을 취한다.

 

E extends Comparable부터 한 번 분석해보자.

extends는 앞서 말했듯 extends 뒤에 오는 타입이 최상위 타입이 되고, 해당 타입과 그에 대한 하위 타입이라고 했다. 그럼 역으로 생각해 보면 이렇다. E 객체는 반드시 Comparable을 구현해야 한다는 의미 아니겠는가?

 

예제로 보면 이렇다는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
public class SoltClass <extends Comparable<E>> { ... }
 
public class Student implements Comparable<Student> {
    @Override
    public int compareTo(Person o) { ... };
}
 
public class Main {
    public static void main(String[] args) {
        SoltClass<Student> a = new SoltClass<Student>();
    }
}
cs

이렇게만 쓴다면 E extends Comparable<E>까지만 써도 무방하다.

즉, SoltClass의 E는 Student 이 되어야 하는데, Comparable<Person>의 하위 타입이어야 하므로 거꾸로 말해 Comparable을 구현해야 한다는 의미인 것이다.

 

그러면 왜 Comparable<E> 가 아닌 <? super E> 일까?

잠깐 설명했지만, super E는 E를 포함한 상위 타입 객체들이 올 수 있다고 했다.

 

만약에 위의 예제에서 학생보다 더 큰 범주의 클래스인 사람(Person) 클래스를 둔다면 어떻게 될까? 한마디로 아래와 같다면?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class SoltClass <extends Comparable<E>> { ... }    // Error가능성 있음
public class SoltClass <extends Comparable<super E> { ... }    // 안전성이 높음
 
public class Person {...}
 
public class Student extends Person implements Comparable<Person> {
    @Override
    public int compareTo(Person o) { ... };
}
 
public class Main {
    public static void main(String[] args) {
        SoltClass<Student> a = new SoltClass<Student>();
    }
}
cs

쉽게 말하면 Person을 상속받고 Comparable 구현부인 comparTo에서 Person 타입으로 업캐스팅(Up-Casting) 한다면 어떻게 될까?

 

만약 <E extends Comparable<E>>라면 SoltClass<Student> a 객체가 타입 파라미터로 Student를 주지만, Comparable에서는 그보다 상위 타입인 Person으로 비교하기 때문에 Comparable<E>의 E인 Student보다 상위 타입 객체이기 때문에 제대로 정렬이 안되거나 에러가 날 수 있다.

 

그렇기 때문에 E 객체의 상위 타입, 즉 <? super E> 을 해줌으로써 위와 같은 불상사를 방지할 수가 있는 것이다.

 

<E extends Comparable<? super E>>에 대해 설명이 조금 길었다. 이 긴 내용을 한 마디로 정의하자면 이렇다.

"E 자기 자신 및 조상 타입과 비교할 수 있는 E"

 

 

마무리 하며..

우선 이렇게 제네릭을 한번 공부를 하고 나니 막연했던 두려움?이라 해야 할지 불안함?이라 해야 할지...아무튼 어느 정도 잠재적 폭탄이 해소된 느낌이다. 특히 5. 제한된 제네릭 부분에서는 "Stranger's lab"님의 https://st-lab.tistory.com/153 정리 글을 복붙하기에 급급했다.. 읽는 것만으로도 이해하기 어려웠다. 사실 이해하기가 어려웠는지 집중력이 바닥났는지는 정확히 몰겠지만, 이런 좋은 글을 남겨주신 분께 깊이 감사드립니다.

좀 더 나의 생각을 정리하고 쓸 수 있도록 노력해나가겠다.

 

참고자료

-> 자바의신 - 이상민 저

-> https://st-lab.tistory.com/153

-> https://coding-factory.tistory.com/573

 

반응형