자바의 제네릭(Generic) 쉽게 알아보기

rvlwldev·2023년 2월 9일
0

제네릭은 자바나 코틀린, C계열언어를 다뤄봤다면 어떠한 형태인지, HashMap등의 자료형을 사용했다면 어떻게 사용하는지는 자연스럽게 알 수 있지만 왜 이런 식으로 사용하는지, 장점 등을 깊게 생각해본 사람이 적을 것이다.

제네릭의 사용 이유

Generic은 일반적인, 총칭적인 이란 뜻을 가지고 있다.
제네릭이 선언된 클래스나 인터페이스, 메소드를 생성, 구현, 활용하려면 Type을 지정함으로써
많은 이점을 가질 수 있다.

1. 잘못된 타입을 사용하는 것을 방지한다.

ex)

ArrayList<Integer> list = new ArrayList<>();
list.add("Test"); /* 에러 */

위와 같은 경우 Integer타입으로 선언한 ArrayList에 Test라는 문자열 요소를 추가하려는 코드이며 에러가 발생한다.

이 처럼 미리 타입을 검사할 수 있으며 반환값에 대한 타입 변환 및 타입 검사에 들어가는 노력을 줄일 수 있다.

2. 외부에서 타입이 지정되기 때문에 관리가 편하다.

ex) 

public interface List<E> extends Collection<E> { ... }

위 예시는 제네릭이 시용되어 선언된 자바의 List인터페이스이다.
List인터페이스를 구현하는 ArrayList 등의 클래스를 생성할 때 타입을 지정해주므로
List, ArrayList 등을 구현, 생성할 때 타입을 체크하거나 변환해줄 필요가 없어지게 된다.

T와 E의 차이는? 결론부터 말하자면 없다.

Collection을 상속하는 배열형태의 클래스에서 제네릭 사용 시 E(Element)를 자주 사용하는데
배열의 요소(Element)가 Type보다 통상적으로 더 어울리기 때문이다.

사실 상 암묵적인 규칙이며 다른 클래스에서 제네릭 사용 시
임의의 문자를 임의의 길이로 넣는 것도 가능하다.

통상적으로 자주 사용하는 제네릭 선언의 종류는
T : Type
E : Element
K : Key
V : Value
N : Number
등이 있다.

3. 클래스의 재사용성이 높아진다.

ex)

HashMap<String, List<Integer>> map1 = new HashMap<>();
HashMap<Integer, String> map2 = new HashMap<>();
HashMap<String, String> map3 = new HashMap<>();

ArrayList<Integer> list1 = new ArrayList<>();
ArrayList<String> list2 = new ArrayList<>();
...

위 예시처럼 제네릭으로 선언된 하나의 클래스를 구현기능의 알맞게 타입을 생성할 때 정해주므로 코드의 재사용성이 높아진다.


제네릭의 특징

1. Reference Type(참조타입)만 사용가능하다.

int, char 들과 같은 Primitive type(원시 타입)은 사용할 수 없다.
참조타입만 가능하며 사용자가 작성한 클래스도 사용이 가능하다.

원시타입을 지원하지 않는 이유는 이전 버전의 JVM과의 하위 호환성을 위함인데, 제네릭의 개념이 2004년부터 지원되었기 때문이다.
이전에는 여러타입을 지원하기 위해 Object로 선언했기 때문에 하위 호환성 측면에서 원시타입은 적용하기 어려웠을 것이다.

권장되는 방법은 아니지만 객체 생성 시 제네릭을 지정해 주지 않음으로써 Raw Type도 사용이 가능하다.
Raw Type은 제네릭이 도입되기 전 코드의 호환성을 위해 제공되는 타입이다.
Raw Type을 사용하게 되면 제네릭의 장점 대부분을 상실하게 된다.


2. 제네릭의 소멸 시기

코드에서 사용되는 제네릭은 컴파일 단계에서 사라진다. .class 파일에서는 제네릭이 없다.
제네릭을 사용하지 않은 코드와의 호환성을 위해 컴파일러는 제네릭 타입을 검사하고 타입을 변환해준다.

ex)

// 컴파일 할 때 (타입 소거 전) 
public class Test<T> {
    public void test(T test) {
        System.out.println(test.toString());
    }
}

// 런타임 때 (타입 소거 후)
public class Test {
    public void test(Object test) {
        System.out.println(test.toString());
    }
}

3. 제네릭은 기본적으로 불공변성(Invariant)이다.

제네릭은 상속 관계에 상관없이, 자기 타입만 허용한다.
쉽게 말하면 List<String>List<Object>로 타입변환이 허용되지 않는다는 것이다.
(String이 Object의 자식클래스임에도 불구하고...)

이런 특징으로 컴파일 시 타입체크를 엄격하게 할 수 있으며, 런타임 환경에서 발생하는 에러를 사전에 방지할 수 있다.

하지만 반대로 이런 특징때문에 실용성이 떨어지는 경우 제네릭으로 와일드카드 타입를 사용할 수 있다.

와일드카드 사용 예시)

void printList(List<?> list) {
    for (Object e : list) System.out.println(e);
}

그렇다면 제네릭 선언 시, <?><Object> 는 서로 같은 것인가. (x)

제네릭 사용 시 ?와 Object는 범위에서 차이가 있다.
예를들어 위 와일드카드 사용 예시에서는 List<Integer> , List<Double> 타입도 모두 파라미터로 사용 가능한 메소드이지만 위 예시에서 ?대신 Object로 선언 시 동작하지 않는다...
?는 생긴 모양 그대로 Unknown Type이다.

제네릭의 와일드카드

와일드카드는 Unknown Type이므로 위 예시 처럼 모든 타입에 대해서 호출이 가능해 졌으며 제네릭의 활용성 또한 높아지지만 엄격하게 타입체크를 할 수 있다는 장점을 어느정도 잃게 된다.

Java는 이 문제를 한정적 와일드카드(Bounded Wildcard)를 지원함으로써 해결한다.

1. 상한 경계 와일드카드(Upper Bounded Wildcard)

  • 제네릭 선언 시 <? extends E> 형식으로 타입의 상한선을 제한할 수 있다.
  • 이 경우 E와 E를 상속받는 자식클래스만 사용이 가능하며 공변성(Covariance)을 가진다.

2. 하한 경계 와일드카드(Lower Bounded Wildcard)

  • 제네릭 선언 시 <? super E> 형식으로 타입의 하한선을 제한할 수 있다.
  • 이 경우 E와 E의 조상클래스만 사용이 가능하며 반공변성(Contravariance)을 가진다.

프로그래밍에서 변성(Variance)의 종류 (공변성, 불공변성, 반공변성) (참고)

3. 와일드카드의 예시

기본적으로 불공변성을 가지는 제네릭을 와일드카드로 공변성/반공변성을 적절히 구현한다면 유연한 코드를 작성할 수 있다.
너무 많이 남용한다면 가독성을 해치는 원인이 될지도 모른다..

사실 <? extends E>, <? super E> (상한, 하한)별로 제네릭타입의 객체를 다루는데 있어서
약간의 제약이 걸리게 된다.

이 경우 상속관계에서 타입을 캐스팅하는 부분에 있어서 문제가 발생할 수 있기 때문이다.

3-1. 상한 경계 와일드카드 (extends)

ex)

class Earth {...}
class Country extends Earth {...}
class Korea extends Country {...}
class Japan extends Country {...}

위 클래스들이 존재한다고 가정할 때

class extendsTest {
	public void worldOut(World<? extends Country> country) {
        Country nation = country.getField(); 
        country.set(new Country()); // 에러!
	}
}

class World<T> {
    private T field;

    public void set(T country) {
        this.field = country;
    }

    public T getField() {
        return field;
    }
}

extendsTest클래스에서 worldOut메소드는 World클래스에서 country필드를 가져오는것은 가능하다.
하지만 World클래스의 country필드에 새로운 Country객체를 설정해주지 못하고 에러가 난다.

이유는 상한 경계 와일드카드( World<? extends Country> ) 로 선언되었을 경우 Country와 Country를 상속받는 Korea, Japan 세가지의 타입을 모두 사용할 수 있기 때문이다.

위에서 말한대로 제네릭의 특징 중 2.제네릭의 소멸 시기의 예시처럼 worldOut메소드에서 World<Korea> 타입으로 파라미터가 들어온다면 컴파일 단계에서 World클래스는 아래와 같이 변환될 것이다.

class World {
    private Korea field;

    public void set(Korea country) {
        field = country;
    }

    public Korea getField() {
        return field;
    }
}

위 코드를 보고 아래 코드를 보면

country.set(new Country());

왜 컴파일에러가 나는지 쉽게 이해할 수 있다.

이유는 파라미터의 타입으로 World<Korea>가 들어왔다고 가정했을때 상속관계에 의해서
Korea타입은 Japan타입으로 캐스팅 될 수 없지만 Korea, Japan 모두 부모타입이자, 상한 경계 와일드카드의 최상위타입인 Country로는 캐스팅 될 수 있기 때문이다.

즉, 상한 경계 와일드카드 문법을 사용 시 어떠한 타입의 데이터도 삽입할 수 없다. (null값은 가능)
마찬가지로 상속관계에 의해서 데이터를 가져올때도 최상위의 타입으로만 가져올 수 있다.

그렇다면 제네릭 선언 시, <?><? extends Object> 는 서로 같은 것인가. (x)

?는 Object보다 범위가 더 넓다. ?는 '모든 것'이라고 할 수도 있다.
위에서 객체 생성 시 제네릭으로 타입을 지정해주지 않으면 Raw Type을 사용한다고 언급했는데
이 Raw Type은 Object의 자식이 아니다.

즉, 내가 이해한 예시로는 ..

List list = new ArrayList<>(); /* Raw Type으로 선언 */

boolean test1 = list instanceof List<?>; /* true */
boolean test2 = list instanceof List<? extends Object>; /* 에러! */

두 번째 test2의 경우 illegal generic type for instanceof 라는 에러를 내밷는다.

3-2. 하한 경계 와일드카드 (super)

위 예시 코드 그래도 extendssuper로 바꿔주게 되면 에러가 나는 부분은
바로 윗줄 Country nation = country.getField();으로 바뀌게 된다.

3-1을 이해했다면 쉽게 이해할 수 있는데
예를들어 Country클래스의 부모클래스인 Earth가 들어왔다면 World클래스는 아래와 같이 변환될 것이다.

class World {
    private Earth field;

    public void set(Earth country) {
        field = country;
    }

    public Earth getField() {
        return field;
    }
}

역시 상속관계에 의해 Country는 Earth타입으로 초기화 될 수 없다.
하지만 모든 클래스의들의 최상위 클래스인 Object는 가능하다.

즉, 하한 경계 와일드카드 문법을 사용 시 데이터를 가져올 때는 Object타입으로 밖에 받을 수 없다.
(제네릭은 기본적으로 참조타입만 사용가능하기 때문에 모든 하한 경계 와일드카드에 대해서 최상위 클래스가 Object라는 것이 보장된다.
이런 이유 등으로 제네릭 도입 이전 코드의 호환성을 위해 제공되는 Raw Type을 권장하지 않는 것이다.)

3-1에서 설명한 것처럼 역시 상속관계에 의해 <? super E> 형태에서
데이터를 삽입하기 위해서는 E와 E의 자식타입만 가능하다.

profile
ㅇ0ㅇ

0개의 댓글