본문 바로가기
Java&Kotlin

[Java] Generic Type & Wildcard Type

by 모모_모 2024. 6. 27.
JDK 1.5에 등장한 제네릭은, 여러 타입의 파라미터를 삽입해 객체를 생성할 수 있어 코드를 간결하게, 재사용성을 높여주었다. 또한 컴파일 단계에서 검출되지 않은 타입 문제가 런타임 단계에서 발생할 가능성을 방지해준다.

 

 

1️⃣ 제네릭 타입

Generic Types
A generic type is a generic class or interface that is parameterized over types.

 

  • 유형에 대해 매개변수화되는 제네릭 클래스 또는 인터페이스.
  • 타입을 구체적으로 지정하는 것이 아닌, 추후에 지정할 수 있도록 일반화해두는 것.
    • 내부에서 타입을 지정하는 것이 아닌, 외부에서 지정하게끔 일반화
  • 작성한 클래스 또는 메서드의 코드가 특정 데이터 타입에 얽매이지 않게 한다.

 

생김새

 

ArrayList<String> list = new ArrayList<>();

//제네릭 타입 매개변수에 정수 타입을 할당
FruitBox<Integer> intBox = new FruitBox<>();

 

 

  • <> 해당 꺽쇠 괄호가 "제네릭" 이다.
  • <T> 부분의 실행부에서 타입을 받아와, 내부에서 T 타입으로 지정한 멤버들에게 전파하여 타입이 구체적으로 설정된다.

 

타입 파라미터 기호 네이밍

제네릭 기호는 문법적으로 정해진 것이 없고, 통상적인 네이밍 규칙(convention)이 있을 뿐이다.

  • <T> : 타입(Type)
  • <E> : 요소(Element) - List
  • <K> : 키(Key) - Map<k,v>

 

 

제네릭 클래스 고려사항

 

  • 제네릭 타입 간에는 상하위의 관계가 없다.
  • 제네릭 타입의 배열 생성 불가.
    • 컴파일 시점에서 메모리를 확보해야 하는데, 타입 변수가 무엇인지 알수 없는 제네릭 특성상 불가능하다.
    • Object 배열 이후, T[]로 형변환은 가능하다.
class Box<T>{
    T[] tArr1; //선언 가능
    T[] tArr2 = new T[3]; //생성은 불가
}

 

  • static 변수/메소드 선언 불가
    • 제네릭 타입은 인스턴스가 생성되어야 정해지지만, static 멤버는 인스턴스 생성 전에 메모리에 올라가 있다.

 

제네릭 메서드 고려사항

  • 제네릭 타입 간 상하위 관계가 없다.
  • 제네릭 클래스 내부에서도 정의 가능
    • 제네릭 클래스의 타입이름 <T>와 제네릭 메서드 타입이름<T>는 이름만 같을 뿐, 별개이다.
    • 만약 제너릭("<>") 없이 T를 사용했다면, 구체화된 T 타입을 가진다.
  • static 메서드가 가능하다
    • 제네릭 메서드의 제네릭 타입은 지역변수처럼 사용되고, 프로그램 실행시 static 메서드가 메모리에 올라갈 때 타입 지정 없이 메서드의 틀만 공유될 수 있다.
    • 메서드 호출 시 타입을 지정하면 된다.

 

 

2️⃣  공변(covariant) 과 불공변(invariant)

 

공변이란, A가 B의 하위 타입일 때 T<A> 가 T<B>의 하위 타입이면 T는 공변이다.

불공변이란, A가 B의 하위 타입일 때 T<A>가 T<B>의 하위 타입이 아니면 T는 불공변이다.

 

  • 대표적으로 배열은 공변, 제네릭은 불공변
    • Integer이 Object의 하위 타입이고, Integer[]은 Object[]의 하위 타입이다.
    • Integer은 Object의 하위 타입이고, List<Integer>은 List<Object>의 하위 타입이 아니다.
  • 다음은 공변, 불공변의 예시이다.
void foo(){
    List<Integer> list1 = Arrays.asList(1,2,3);
    Integer[] list2 = new Integer[]{1,2,3};
    
    //컴파일 에러 발생
    printCollection(list1);
    //실행가능
    printArray(list2);
}

void printCollection(Collection<Object> c){
	for(Object e : c){
    	sout(e);
    }
}


void printArray(Object[] arr){
	for(Object e : arr) {
    	sout(e);
    }
}

 

 

여기서 제네릭의 ? 타입인, 와일드카드 타입이 등장한다. 

 

3️⃣ 와일드카드

  • 제네릭이 등장했지만 오히려 실용성이 떨어지는 상황들이 생긴다.
  • 모든 타입을 대신할 수 있는 타입 - 와일드카드 타입 
  • 임의의 타입을 나타내며, 정해지지 않은 unknown type으로 타입에 제한이 없어 비제한 와일드카드 타입이라고 한다.
  • 위에서 제네릭의 불공변 특성으로 인해, 컬렉션을 제대로 활용하지 못하는 상황을 해결해준다.
    • 자바 모든 객체의 최상위 부모는 Object
    • 그러나 요소를 추가하는 것은 타입 안전성을 보장할 수 없어, 허용하지 않는다.
    • 와일드카드의 타입이 any가 아닌, unknown이다.
void printCollection(Collection<?> c){
	for (Object e : c){
    	sout(e);
}

void addCollection(){
	Collection<?> c = new ArrayList<String>();
    c.add(new Object()); //컴파일 에러 
}

 

 

4️⃣ 한정적 와일드 카드

위와 같은 문제를 해결하기 위해, 한정적 와일드카드(Bounded Wildcard)를 제공한다.

특정 타입을 기준으로, 상한 범위와 하한 범위를 지정함으로써 호출 범위를 확장 또는 제한할 수 있다.

 

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

 

  • 상한 경계 와일드카드는 와일드카드 타입에 extends를 사용해 와일드카드 타입의 최상위 상위 타입을 정의함으로써 상한 경계를 설정한다.
  • 결론부터 말하자면, 상한 경계 와일드카드는 컬렉션에서 원소를 꺼내 활용하는 것이 가능하다.

 

다음과 같은 3가지 클래스가 존재한다고 가정하자.

class MyGrandParent {}

class MyParent extends MyGrandParent {}

class MyChild extends MyParent {}

 

 

이후 실행되는 코드를 보자.

void printCollection(Collection<? extends MyParent> c) {
    // 컴파일 에러
    for (MyChild e : c) {
        System.out.println(e);
    }

    for (MyParent e : c) {
        System.out.println(e);
    }

    for (MyGrandParent e : c) {
        System.out.println(e);
    }

    for (Object e : c) {
        System.out.println(e);
    }
}

 

  • MyParent를 extend했기 때문에, MyParent와 MyGrandParent, Object 타입으로 get하는 연산에는 문제가 없다.
  • 그러나 범위 바깥의 MyChild는 접근할 수 없어 컴파일 에러가 발생한다.

이후, Collection<? extend MyParent> c 의 c의 원소를 사용 또는 소모하여 컬렉션에 추가하는 경우는 상황이 달라진다.

 

아래 코드를 보자

void addElement(Collection<? extends MyParent> c) {
    c.add(new MyChild());        // 불가능(컴파일 에러)
    c.add(new MyParent());       // 불가능(컴파일 에러)
    c.add(new MyGrandParent());  // 불가능(컴파일 에러)
    c.add(new Object());         // 불가능(컴파일 에러)
}

 

  • 모든 타입에 대해 컴파일 에러가 발생한다.
  • 컬렉션 타입인 <? extend MyParent> 로 가능한 타입은 MyParent와 미지의 모든 MyParent 자식 클래스들이므로, c가 MyParent 하위 타입중 어떤 타입인지 모르기 때문이다.
  • 상한 경계가 지정된 경우에는 하위 타입을 특정할 수 없어 새로운 원소를 추가하는 것이 불가능하다.
  • 상한 경계 와일드카드는 컬렉션에서 원소를 꺼내 활용할 수 있다.

 

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

 

  • super를 사용해, 와일드카드의 최하위 타입을 정의해 하한 경계를 설정할 수 있다.
  • 결론부터 말하자면, 컬렉션에서 값을 꺼내 원소를 만드는 경우가 일부 가능하다.
void addElement(Collection<? super MyParent> c) {
    c.add(new MyChild());
    c.add(new MyParent());
    c.add(new MyGrandParent());  // 불가능(컴파일 에러)
    c.add(new Object());         // 불가능(컴파일 에러)
}

 

  • 컬렉션 c가 갖는 타입은 적어도 MyParent의 부모 타입들이다.
  • 그러므로 MyParent의 자식 타입이라면 안전하게 컬렉션에 추가할 수 있고, 부모 타입인 경우에만 컴파일 에러가 ㅏㄹ생한다.

컬렉션에서 원소를 꺼내 활용하는 경우를 보자

void printCollection(Collection<? super MyParent> c) {
    // 불가능(컴파일 에러)
    for (MyChild e : c) {
        System.out.println(e);
    }

    // 불가능(컴파일 에러)
    for (MyParent e : c) {
        System.out.println(e);
    }

    // 불가능(컴파일 에러)
    for (MyGrandParent e : c) {
        System.out.println(e);
    }

    for (Object e : c) {
        System.out.println(e);
    }
}

 

 

  • 부모타입을 특정할 수 없어, 모든 부모 타입들에 컴파일 에러가 발생한다.
  • Object의 경우에는 모든 객체의 부모임이 명확하기에 컴파일 에러가 발생하지 않는다.
  • 하위 타입인 경우에도, MyParent와 미지의 MyParent 부모 타입이기에 MyChild와 같이 경계 아래 하위타입은 추가될 수 없다.

 

5️⃣ 맺으며

PECS(Producer-Extends, Consumer-Super) 공식

 

제네릭 타입과 와일드카드는 상당히 많이 헷갈리는 주제이고, 언제 사용해야 할지 모호해 보인다. 이펙티브 자바에서 등장한 PECS 공식은 이러하다.

  • 컬렉션으로부터 와일드카드 타입의 객체를 꺼내 생성(product)하면 extend를
  • 갖고있는 객체를 컬렉션에 사용(consume)해 추가하면 super를

이러한 공식을 염두에 두고, 추후에 사용하게 된다면 사용해보자.