1.0.0

Generic

시작하기에 앞서 관련 용어를 정리해 둘것이다.

한글영어

매개변수화 타입

parameterized type

List<String>

실제 타입 매개변수

actual type parameter

String

제네릭 타입

generic type

List<E>

정규 타입 매개변수

formal type parameter

E

비한정적 와일드카드 타입

unbounded wildcard type

List<?>

로 타입

raw type

List

한정적 타입 매개변수

bounded type parameter

<E extends Number>

재귀 타입 한정

recursive type bound

<T extends Comparable<T>>

한정적 와일드카드 타입

bounded wildcard type

List<? extends Number>

제네릭 메서드

generic method

static <E> List<E> asList(E[] a)

타입 토큰

type token

String.class


Generic이란 무엇일까?

JDK 1.5에 처음 도입되었으며, 제네릭은 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 의미한다.(생활코딩 )

public class Fruit<T> {
  	public T fruit;
}
Fruit<Apple> apple = new Fruit<>();
Fruit<Banana> apple = new Fruit<>();

위의 예제를 보면 Fruit 인스턴스를 생성할 때, Apple, Banana 를 넣어 타입을 지정하고 있다. 즉, 클래스를 정의 할 때는 어떤 타입이 들어올지 확정하지 않고, 인스턴스를 생성할 떄 데이터 타입을 지정하는 기능이다.

왜 제네릭을 사용할까?

Generic은 클래스와 인터페이스, 메서드를 정의할 때 타입을 파라미터로 사용할 수 있게 한다. Generic 타입을 이용함으로써 잘못된 타입이 사용될 수 있는 문제를 컴파일 과정에서 제거할 수 있게되었다. 타입 파라미터는 코드 작성 시 구체적인 타입으로 대체되어 다양한 코드를 생성하도록 해준다.

컴파일 시 강한 타입 체크를 할 수 있다.

컴파일 언어의 기본은 모든 에러는 컴파일이 발생할 수 있도록 하는 것이 좋다.(오류는 빨리 발견할 수록 좋다.) 런타임은 실제로 애플리케이션이 동작하고 있는 상황이기 때문에 런타임에 발생하는 에러는 항상 심각한 문제를 초래할 수 있기 때문이다.

public class GenericTest {

    @Test
    void runtimeExceptionTest() {
        Person person = new Person("파트너");
        Assertions.assertThrows(ClassCastException.class, () -> {
            Employee employee = (Employee) person.info;
        });
    }

    static class Employee {
        public int rank;
        Employee(int rank){ this.rank = rank; }
    }

    static class Person {
        public Object info;
        Person(Object info){ this.info = info; }
    }
}
java.lang.ClassCastException: class java.lang.String cannot be cast to class ...

위는 성공적으로 컴파일되지만, 런타임시 ClassCastException 이 발생하는 것을 보여주는 예제이다. 이때 Generic Type을 사용해 컴파일 타임에 오류를 발견할 수 있도록 할 수 있다.

public class GenericTest {

    @Test
    void compileTimeExceptionTest() {
        Person<Employee> person = new Person<>(new Employee(10));
        Employee employee = person.info;
        Assertions.assertEquals(10, employee.rank);

        // 컴파일 오류 발생
        // java: incompatible types: java.lang.String cannot be converted to int
        Person<Employee> person2 = new Person<>(new Employee("파트너"));
    }

    static class Employee {
        public int rank;
        Employee(int rank){ this.rank = rank; }
    }

    static class Person<T> {
        public T info;
        Person(T info){ this.info = info; }
    }
}

Generic type으로 변경 후에는 java: incompatible types: java.lang.String cannot be converted to int 컴파일 오류가 발생하는 것을 확인할 수 있다.

즉, 컴파일 단계에서 오류 검출이 가능하며, 타입 안정성을 추구할 수 있게된다.

타입 변환(casting)을 제거한다.

// generic이 아닌경우
List list = new ArrayList();
list.add("hello");
String str = (String)list.get(0); // 타입 변환이 필요

// generic
List<String> list = new ArrayList<String>();
list.add("hello");
String str = list.get(0); // 타입 변환을 하지 않음

Generic Type

Generic Type은 **타입을 파라미터로 가지는 클래스(class<T>)와 인터페이스(interface<T>)**를 말한다.

public class 클래스명<T> {...}
public interface 인터페이스명<T> {...}

타입 파라미터는 변수명과 동일한 규칙으로 작성될 수 있지만, 일반적으로 대문자 한글자로 표현한다. Generic Type을 실제 코드에서 사용하려면 타입 파라미터에 구체적인 타입을 지정해야한다.

public class Box {
    private Object obj;

    public void set(Object object){ this.obj = object; }
    public Object get(){ return obj; }
}

위의 코드에서 클래스 필드 타입을 Object로 선언한 이유는 필드에 모든 종류의 객체를 저장하고 싶어서이다. Object는 모든 자바 클래스의 최상위 조상(부모) 클래스이다. 자식 객체는 부모타입에 대입할 수 있기 때문에 모든 자바 객체는 Object타입으로 자동 타입변환되어 저장된다.

Box box = new Box();
box.set("안녕");                      // String 타입을 Object타입으로 자동타입 변환
String str = (String) box.get();    // Object 타입을 String타입으로 강제 타입 변환

다음과 같이 get으로 가져오기위해서는 강제 타입 변환이 필요하다.

Object 타입을 사용하면 모든 종류의 자바 객체를 저장할 수 있다는 장점은 있지만, 저장할 때와 읽어올 때 타입 변환이 발생하며, 잦은 타입 변환은 전체 프로그램 성능에 좋지 못한 결과를 가져올 수 있다.

Generic을 통해서 타입변환이 발생하지 않도록 할 수 있다.

public class Box<T>{
    private T t;
    public void set(T t){this.t = t;}
    public T get(){ return t; }
}
Box<String> box = new Box<String>();

여기서 T는 클래스로 객체를 생성할 때 구체적인 타입으로 변경된다. 그렇기 때문에 저장할 때와 읽어올 때 타입 변환이 발생하지 않는다. 이와 같이 generic은 클래스 설계시 구체적은 타입을 명시하지 않고, 타입 파라미터로 대체했다가 실제 클래스가 사용될 때 구체적인 타입을 지정함으로써 타입 변환을 최소화시킨다.

타입 파라미터

타입 파라미터의 이름은 짓기 나름이지만, 일반적으로 대문자 한글자로 표현 보편적으로 자주 사용하는 타입 매개변수는 다음과 같다.

타입 매개변수의미

E

Element

K

Key

N

Number

T

Type

V

Value

S,U,V

2nd, 3rd, 4th types

다이아몬드 <>

제네릭 타입 변수 선언과 객체 생성을 동시에 할 때 타입 파라미터에 구체적인 타입을 지정하는 코드가 중복될 수 있다. 그렇기 때문에 자바7에서 부터는 <> (다이아몬드연산자)를 제공한다. 자바 컴파일러는 타입 파라미터 부분에 <>연산자를 사용하면 타입 파라미터를 유추해서 자동으로 설정해준다.

// java7이후
Box<String> box = new Box<>();

다중 타입 파라미터(Multiple Type Parameters)

Generic Type은 두 개 이상의 멀티 타입 파라미터를 사용할 수 있다. 이 경우 각 타입 파라미터를 콤마로 구분한다.

public class Product<T, M>{
    private T kind;
    private M model;
    
    public T getKind(){ return this.kind; }
    public M getModel(){ return this.model; }
    
    public void setKind(T kind){ this.kind = kind; }
    public void setMode(M model){ this.model = model; }
}
public class ProductEx{
    public static void main(String[] args){
        Product<TV, String> prd1 = new Product<TV,String>();
        prd1.setKind(new TV());
        prd1.setModel("삼성TV");
        TV tv = prd1.getKind();
        String tvModel = prd1.getModel();

				Product<Car, String> carPrd = new Product<Car,String>();
        carPrd.setKind(new Car());
        carPrd.setModel("디젤");
        Car car = carPrd.getKind();
        String carModel = car.getModel();
    }
}

Raw Types

Raw type은 타입 파라미터가 없는 제네릭 타입을 의미한다.

public class Box<T> {
    public void set(T t) { /* ... */ }
    // ...
}
// Box는 Generic type이지만 타입 파라미터 없이 생성
Box rawBox = new Box();

Raw type은 Java가 제네릭을 도입하기전(JDK 5.0) 이전 기존 코드와의 호환성을 보장하기 위해서 제공하고 있는 것이다.

Box rawBox = new Box();
Box<String> stringBox = new Box<>();
Box rawBox = stringBox; // OK

하지만, raw type을 paratmeterized type으로 설정하면 경고문구를 볼 수 있다.

Box rawBox = new Box();           // rawBox is a raw type of Box<T>
Box<Integer> intBox = rawBox;     // warning: unchecked conversion

또한, raw type을 사용해, 제네릭 타입의 제네릭 메서드를 호출하는 경우에도 경고가 표시된다.

Box<String> stringBox = new Box<>();
Box rawBox = stringBox;
rawBox.set(8);  // warning: unchecked invocation to set(T)

위 경고는 raw type이 generic type 검사를 생략해 안전하지 않은 코드가 런타임시에 발견될 수도 있다는 경고 문구이다. 그러므로, raw type은 최대한 사용하지 않는 것이 좋으며, 자세한 내용은 **[effective java - ITEM 26]**에서 확인할 수 있다.

Generic Method

  • 일반 클래스의 메서드에서 타입 매개변수를 사용해 정의 가능

  • 제네릭 메서드에서 타입 매개변수 범위는 메서드 내부로 제한된다.

  • 제네릭 클래스의 생성자에서도 사용 가능

Generic Method는 매개타입과 리턴 타입으로 타입 파라미터를 갖는 메소드이다.

public <타입파라미터, ...> 리턴타입 메소드명(매개변수, ...){...}
public <T> Box<T> boxing(T t){...}

타입 매개변수 <T> 는 반드시 메서드의 수식자(public, static)와 반환형 사이에 위치되어야한다.

Generic 메소드는 다음과 같이 호출될 수 있다.

//명시적으로 구체적 타입을 지정
리턴타입 변수 = <구체적타입> 메소드명(매개값);
Box<Integer> box = <Integer>boxing(100);

//매개값을 보고 구체적 타입을 추정
리턴타입 변수 = 메소드명(매개값);
Box<Integer> box = boxing(100);
public class Util {
    public static <K, V> boolean compare(Pair<K, V> p1, Pair<K, V> p2) {
        return p1.getKey().equals(p2.getKey()) &&
               p1.getValue().equals(p2.getValue());
    }
}

public class Pair<K, V> {

    private K key;
    private V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public void setKey(K key) { this.key = key; }
    public void setValue(V value) { this.value = value; }
    public K getKey()   { return key; }
    public V getValue() { return value; }
}
Pair<Integer, String> p1 = new Pair<>(1, "apple");
Pair<Integer, String> p2 = new Pair<>(2, "pear");
boolean same = Util.<Integer, String>compare(p1, p2);

Util.<Integer, String>compare(p1, p2) 과 같이 타입을 명시적으로 썼지만, 다음과 같이 생략해도 컴파일러가 유추할 수 있다.

boolean same = Util.compare(p1, p2);

제한된 타입 파라미터(<T extends 최상위타입>)

타입 파라미터에 지정되는 구체적인 타입을 제한할 필요가 종종 있다. 숫자를 연산하는 제네릭 메소드의 매개값으로는 Number타입 또는 그 하위 클래스 타입(Integer, Double, Long, Short, ...) 의 인스턴스만 가져야한다. 이러한 이유로 제한된 타입 파라미터가 필요한 경우가 있다.

public <T extends 상위타입> 리턴타입 메소드(매개변수, ...){...}

상위 타입은 클래스뿐만 인터페이스도 가능하다. 하지만 인터페이스라고 해서 implements를 사용하지 않는다.( extends 사용)

  • 타입 파라미터의 구체적 타입 : 상위타입, 상위타입의 하위 또는 구현클래스

  • {} 안에서의 타입 파라미터 변수 : 상위 타입의 멤버(필드, 메서드로 제한)

public <T extends Number> int compare(T t1, T t2){
    double v1 = t1.doubleValue();
    double v2 = t2.doubleValue();
    return Double.compare(v1,v2);
}
public class Util{
    public static <T extends Number> int compare(T t1, T t2){
        double v1 = t1.doubleValue(); 
        double v2 = t2.doubleValue();
        return Double.compare(v1,v2);
    }
}
public class Example{
    public static void main(String[] args){
        //String str = Util.compare("a","b"); Number타입이 아니므로 오류
        int result = Util.compare(10,20);
        int result2 = Util.compare(10.5,20);
    }
}

여기서 주의할 점은 함수 내에서 타입 파라미터 변수로 사용한 것은 상위 타입의 멤버(필드, 메서드)로 제한된다는 점이다. doubleValue()Number 클래스의 메서드이기때문에 사용할 수 있는 것이다.

Muliple Bounds

제한된 타입 파라미터를 여러개로 설정할 수 있다.

<T extends B1 & B2 & B3>
Class A { /* ... */ }
interface B { /* ... */ }
interface C { /* ... */ }
class D <T extends A & B & C> { /* ... */ }

여기서 D 클래스의 타입파라미터는 A, B, C 모든 유형의 하위 클래스여야하며, 이중 한개가 클래스인 경우 반드시 먼저 선언되어야한다.

class D <T extends B & A & C> { /* ... */ }

위와 같이 A 가 클래스이지만, 인터페이스인 B 보다 늦게 선언된다면 컴파일 오류가 발생한다.

Generic Method와 Bounded Type Parameter

제한된 타입 파라미터(Bounded Type Parameter)는 제네릭 알고리즘을 구현할때 핵심이된다.

// 두번째 인자(elem)보다 큰 값이 anArray에 몇개가 있는지 세는 메서드
public static <T> int countGreaterThan(T[] anArray, T elem) {
    int count = 0;
    for (T e : anArray)
        if (e > elem)  // compiler error
            ++count;
    return count;
}

위 메서드는 컴파일 오류가 발생한다.

java: bad operand types for binary operator '>'
  first type:  T
  second type: T

> 연산자는 기본형(int, short, double, long ...)에만 동작이 허용되기 때문이다. 즉, > 연산자는 객체간 비교에 사용할 수 없으며, Comparable 인터페이스를 사용해 해당 오류를 해결할 수 있다.

public interface Comparable<T> {
    public int compareTo(T o);
}
public static <T extends Comparable<T>> int countGreaterThan(T[] anArray, T elem) {
    int count = 0;
    for (T e : anArray)
        if (e.compareTo(elem) > 0)
            ++count;
    return count;
}

Comparable 인터페이스로 제한된 타입을 사용해 객체를 비교할 수 있다.

Generics, Inheritance, Subtypes

Object someObject = new Object();
Integer someInteger = new Integer(10);
someObject = someInteger;   // OK

타입간 호환이된다면, 특정 타입의 객체를 다른 타입에 할당이 가능하다. ObjectInteger의 상위 클래스이기 때문에 할당이 가능하다. 객체지향 이론에서는 이러한 경우를 "is a" 관계라고 부른다. "Integer is a Object" 이므로 Object에 Integer가 할당이 가능한 것이다.

public void someMethod(Number n) { /* ... */ }

someMethod(new Integer(10));   // OK
someMethod(new Double(10.1));   // OK

또한, "Integer is a Number" 이므로 위 예제 코드 또한 잘 작동하는 것을 볼 수 있다.

이러한 규칙은 제네릭에서도 똑같이 적용된다. 제네릭 타입 호출시, 타입 인자가 "is a" 관계라면 타입 인자로 전달할 수 있는 것이다.

Box<Number> box = new Box<Number>();
box.add(new Integer(10));   // OK
box.add(new Double(10.1));  // OK

여기서 주의해야할 부분이 있다.

public void boxTest(Box<Number> n) { /* ... */ }

boxTest() 메서드의 인자로 어떤 타입을 받을 수 있을까? 대부분 Box<Integer>Box<Double> 이 전달 가능할거라고 생각할 것이다. 하지만, Box<Integer>Box<Double>Box<Number> 의 서브타입이 아니기 때문에 인자값으로 전달 할 수 없다.

Generic Classes and Subtyping

제네릭 클래스 상속 또는 제네릭 인터페이스 구현시, 두 클래스간 "is a" 관계를 만들 수 있다.

예를 들어, Collection 클래스를 사용할 때, ArrayList<E>List<E> 를 구현하고, List<E>Collection<E> 를 상속 받고 있는 것을 볼 수 있다.

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
public interface List<E> extends Collection<E> {

그러므로, ArrayList<String>List<String>Collection<String> 의 하위 타입으로 "is a"관계가 성립하게 되는 것이다.

interface PayloadList<E,P> extends List<E> {
  void setPayload(int index, P val);
  ...
}

List<E> 인터페이스를 상속받는 PayloadList가 다음과 같이 정의되었을 때, PayloadList 는 다음과 같은 서브타입 관계가 형성될 수 있는걸 볼 수 있다.

Type Inference(타입 추론)

타입추론은 컴파일러가 각 메서드 호출과 해당 선언을 검토하고 호출을 적용할 수 있게 하는 인수를 결정하는 능력이다. 추론 알고리즘은 인수의 유형과 결과가 할당되거나 반환되는 유형을 결정하고, 가장 구체적인 타입을 찾기 위해 노력한다.

Type Inference and Generic Methods

public class BoxDemo {

  public static <U> void addBox(U u, List<Box<U>> boxes) {
    Box<U> box = new Box<>();
    box.set(u);
    boxes.add(box);
  }

  public static <U> void outputBoxes(List<Box<U>> boxes) {
    int counter = 0;
    for (Box<U> box: boxes) {
      U boxContents = box.get();
      System.out.println("Box #" + counter + " contains [" +
             boxContents.toString() + "]");
      counter++;
    }
  }

  public static void main(String[] args) {
    ArrayList<Box<Integer>> listOfIntegerBoxes = new ArrayList<>();
    BoxDemo.<Integer>addBox(Integer.valueOf(10), listOfIntegerBoxes);
    BoxDemo.addBox(Integer.valueOf(20), listOfIntegerBoxes);
    BoxDemo.addBox(Integer.valueOf(30), listOfIntegerBoxes);
    BoxDemo.outputBoxes(listOfIntegerBoxes);
  }
}
Box #0 contains [10]
Box #1 contains [20]
Box #2 contains [30]

위 예제에서 제네릭 메서드 addBox() 는 타입 매개변수(U)가 선언되어있다. 일반적으로 컴파일러는 해당 제네릭 메서드를 호출하는 곳을 보고 타입 파라미터를 추론할 수 있다. 제네릭 메서드 addBox 를 호출할 때, 구체적인 타입 매개변수를 주지 않아도 컴파일러가 자동으로 값을 추론할 수 있다.

BoxDemo.addBox(Integer.valueOf(20), listOfIntegerBoxes);

그러므로, 대부분 타입을 구체적으로 선언하지 않고도 사용할 수 있는 것이다.

Type Inference and Instantiation of Generic Classes

제네릭 클래스를 생성자를 통해 객체를 생성할 때 타입 인자 대신 <> 다이아몬드 연산자를 사용하면, 컴파일러는 타입인자를 유추할 수 있다.

Map<String, List<String>> myMap = new HashMap<String, List<String>>();

제네릭 클래스의 생성자에 타입 매개변수화한 생성자 대신 아래처럼 <> 만 사용하여 선언할 수 있다.

Map<String, List<String>> myMap = new HashMap<>();

하지만, <> 연산자를 선언하지 않으면, raw 타입이므로 타입추론을 하지 않기때문에 주의해야한다

Map<String, List<String>> myMap = new HashMap(); // raw type

Type Inference and Generic Constructors of Generic and Non-Generic Classes

제네릭 클래스와 비제네릭 클래스(non-generic) 모두 제네릭 생성자를 선언할 수 있다.

class MyClass<X> {
  <T> MyClass(T t) {
    // ...
  }
}
new MyClass<Integer>("");

위 코드는 매개변수화된 타입 MyClass의 인스턴스를 생성한다. 제네릭 클래스인 MyClass의의 형식 매개변수 X에 대해 Integer 타입을 명시적으로 지정하고 있다. 이 제네릭 클래스의 생성자는 형식 매개 변수 T를 포함하고 있으며, 컴파일러는 이 제네릭 클래스(MyClass)의 생성자의 파라미터 타입이 String인 것을 추론할 수 있다. 자바 SE 7 이전의 컴파일러는 제네릭 메서드와 유사한 제네릭 생성자의 실제 타입 파라미터를 추론할 수 있으며, 자바 SE7 이상 컴파일러는 <> 다이아몬드 연산자를 사용하면, 제네릭 클래스의 실제 타입 파라미터를 추론할 수 있다.

MyClass<Integer> myObject = new MyClass<>(""); // JavaSE7 이후

위 예시에서 컴파일러는 제네릭 클래스 MyClass<X>의 타입 파라미터가 Integer 임을 추론할 수 있고, 이 제네릭 클래스의 생성자의 타입 파라미터 TString 임을 추론할 수 있다.

Target Types

타겟 타입 표현식은 자바 컴파일러가 예상하는 데이터 타입이다.

@SuppressWarnings("unchecked")
public static final <T> List<T> emptyList() {
		return (List<T>) EMPTY_LIST;
}
List<String> list = Collections.emptyList();

위 선언문은 리턴타입을 List<String> 타입으로 받고 있기때문에 List<String> 를 예상할 수 있다. 이 데이터 타입을 바로 target type이라 한다. emptyList() 메서드가 List<T> 를 반환하기때문에 컴파일러는 TString 이라고 추론할 수 있다.

List<String> list = Collections.<String>emptyList();

물론 위와 같이 T 타입을 명시 할 수 있지만, 문맥상 String 인것이 명백하기 때문에 적어주지 않아도 된다. 명시적으로 타입을 선언해줘야하는 경우를 살펴보자.(Type witness)

void processStringList(List<String> stringList) {
    // process stringList
}
processStringList(Collections.emptyList());

위 예제는 Java SE7에서 컴파일 되지 않으며, List<Object> cannot be converted to List<String> 오류가 발생한다. 컴파일러는 T의 타입 인자가 필요하지만, 아무것도 주어지지 않았기 때문에 Object를 타입 인자로 갖게된다. Collections.emptyList()List<Object> 객체를 반환하기때문에 컴파일 오류를 발생시키는 것이다. Java SE7에서는 반드시 타입값을 명시해야한다.

processStringList(Collections.<String>emptyList());

하지만, Java SE8 이상부터는 타겟 타입을 결정할 떄 메서드의 인자 값도 살피도록 확장되었으므로, 명시해줄 필요가 없어졌다. 즉, Collections.emptyList() 의 반환 값인 List<T>List<String> 인게 추론이 가능해졌기 때문에 Java SE8부터는 아래 예시도 컴파일 된다.

List<String> list = Collections.emptyList();

Wildcards (와일드카드)

제네릭에서 unkwon타입을 표현하는 ?를 일반적으로 와일드카드라고 부른다. 와일드카드는 파라미터, 필드, 직역변수 타입, 리턴타입 등 다양한 상황에서 쓰이며, 제네릭 메서드 호출, 제네릭 클래스 인스턴스 생성, 상위 타입(super type)의 타입 인자로는 사용되지 않는다.

코드종류설명

<?>

Unbounded wildcards 비한정적 와일드 카드

제한 없음 (타입 파라미터를 대치하는 구체적 타입으로 모든 클래스나 인터페이스 타입이 올 수 있다.)

<? extends 상위타입>

Upper Bounded Wildcards 상한 경계 와일드카드

상위 클래스 제한 (타입 파라미터를 대치하는 구체적 타입으로 상위 타입이나 하위 타입만 올 수 있다.)

<? super 하위타입>

Lower Bounded Wildcards 하한 경계 와일드카드

하위 클래스 제한 (타입 파라미터를 대치하는 구체적 타입으로 하위 타입이나 상위타입이 올 수 있다.)

public class Couse<T>{
    private String name;
    private T[] students;
    
    public Course(String name, int capacity){
        this.name = name;
        // 타입 파라미터로 배열을 생성하려면 new T[n]형태가 아닌 (T[])(new T[n])의 형태로 생성해야한다.
        students = (T[])(new Object[capacity]);
    }
    
    public String getName(){ return name; }
    public T[] getStudents(){ return students; }
    public void add(T t){
        for(int i=0;i<students.length;i++){
            if(students[i] == null){
                students[i]=t;
                break;
            }
        }
    }
}

수강생이 될 수 있는 타입이 아래와 같다.

  • Person

    • Worker

    • Student

      • HighStudent

  • Course<?> : 수강생은 모든 타입(Person, Worker, Student, HightStudent)

  • Course<? extends Students> : 수강생는 Student와 HighStudent만 가능

  • Course<? super Worker> : Worker, Person만 가능

Unbounded Wildcards

List<?> 와 같이 ? 의 형태로 정의되며, 비한정적 와일드카드 타입이 사용될 수 있는 시나리오는 다음과 같다.

  1. Object 클래스에서 제공되는 기능을 사용하여 구현할 수 있는 메서드를 작성하는 경우

  2. 타입 파라미터에 의존적이지 않은 일반 클래스의 메소드를 사용하는 경우( ex) List.clear, List.size, Class<?>)

public static void printList(List<Object> list) {
    for (Object elem : list)
        System.out.println(elem + " ");
    System.out.println();
}

위 메서드의 목표는 어떠한 타입의 리스트가 오더라도 그 요소를 출력하는 것이다. 하지만, 위 예제는 한가지 문제점이 있다.

List<Integer> li = Arrays.asList(1, 2, 3);
List<String>  ls = Arrays.asList("one", "two", "three");
printList(li);
printList(ls);
java: incompatible types: java.util.List<java.lang.Integer> cannot be converted to java.util.List<java.lang.Object>

List<Object> 외의 List<Integer>, List<String>, List<Double>의 출력은 java: incompatible types: 컴파일 오류가 발생하며 실패한다. 왜나하면 List<Object>의 하위타입이 아니기 때문이다.

public static void printList(List<?> list) {
    for (Object elem: list)
        System.out.print(elem + " ");
    System.out.println();
}

비한정적 와일드카드 타입을 사용한다면, 성공적으로 출력되는 것을 알 수 있다. 왜나하면, 어떠한 타입 A가 와도 List<A>List<?>의 하위 타입이기 때문이다.

List<Integer> li = Arrays.asList(1, 2, 3);
List<String>  ls = Arrays.asList("one", "two", "three");
printList(li);
printList(ls);

비한정적 와일드카드 List<?> 에서 가져온 원소는 Object 타입이다.

public static void get(List<?> list) {
  Object obj = list.get(0);
  Integer integer = list.get(0); // compile error
}

비한정적 와일드 카드는 어떠한 타입이 와도 읽을 수 있도록, 모든 타입의 공통 조상인 Object 로 받는다.

여기서 주의할 점은 List<Object>List<?>이 같지 않으며, List<Object>에는 Object 의 하위 타입은 모두 넣을 수 있지만, List<?> 에는 오직 null만 넣을 수 있다. 왜나하면 비경계 와일드 카드의 원소가 어떠한 타입이 들어오는 지 알 수 없으므로, 타입 안정성을 지키기 위해 null만 추가할 수 있다.

List<?> list = new ArrayList<>();
list.add(null); // 가능
list.add("test"); // 컴파일 오류 

만약 다음과 같이 모든 타입을 넣을 수 있게 한다면, List<Integer>Double을 추가하는 모순 발생하게 된다. 이는 제네릭의 타입 안정성을 위반하게 되며, null 만 추가할 수 있도록 했다.

public static void main(String[] args) {
  List<Integer> ints = new ArrayList<>();
  addDouble(ints);
}

private static void addDouble(List<?> ints){
  ints.add(3.14); // List<Integer>에 Double을 추가하는 모순 발생
}

Upper Bounded Wildcards

상한 경계 와일드 카드를 사용해 변수에 대한 제한을 완화할 수 있다. 예를 들어, List<Integer>, List<Double>, List<Number> 에서만 동작하는 메서드를 원할 때 상한 경계 와일드 카드를 사용하면된다.

List<? extends Number>

List<Number> 는 오직 List<Number> 만 올 수 있으므로, NumberNumber의 하위클래스가 모두 올 수 있는 List<? extends Number> 보다 더 제한적이다.

즉, <? extends T>는 T의 하위 타입만 올 수 있다.

<? extends T>에서 Get한 원소는 T 이다.

상한 경계 와일드 카드의 원소는 T 혹은 T의 하위 클래스이며, 원소들의 최고 공통 조상인 T로 읽으면 어떤 타입이 오든 T로 읽을 수 있다.

public static void printList(List<? extends Number> list) {
    for (Number elem : list) {
        System.out.println(elem);
    }
}

여기서 만약 하위 타입인 Integer로 하면 컴파일 오류가 발생한다. 왜냐하면, DoubleNumber로 값이 들어오게 되면, Integer로 타입을 변환할 수 없기 때문이다.

public static void printList(List<? extends Number> list) {
    for (Integer elem : list) { //compile error
        System.out.println(elem); 
    }
}

List<? extends T> 에는 null만 삽입할 수 있다.

상한경계 와일드카드의 원소가 어떤 타입인지 알 수 없기 때문에 null 만 삽입할 수 있다.

List<Integer> ints = new ArrayList<>();

List<? extends Number> numbers = ints;

numbers.add(Double.valueOf(3.14)); // compile error

Lower Bounded Wildcards

<? super T> 의 형태로, List<? super T> 와 같이 사용한다. T 혹은 T의 상위 클래스만 인자로 올 수 있다.

<? super T>에서 Get한 원소는 Object 이다.

T 하한 경계 와일드카드의 원소는 T의 상위 클래스 중 어떠한 타입도 올 수 있다. 어떠한 타입이 와도 읽을 수 있도록, T들의 공통 조상인 Object로 받는다. List<Integer>, List<Double>, List<Number>가 와도 모두 읽을 수 있다.

public static void printList(List<? super Integer> list) {
    for (Object elem : list) { 
        System.out.println(elem); 
    }
}

List<? super T> 에는 T의 하위 클래스만 삽입할 수 있다.

List<? super Integer> ints = new ArrayList<>();
ints.add(new Integer());
ints.add(new Number()); // compile error

만약 ints가 List<Integer> 일 경우 NumberInteger의 상위 클래스 이므로 원소를 추가할 수 없다. List<Integer>, List<Number>, List<Object> 중 어떠한 리스트가 올지 ints는 알지 못한다. 하지만 그 중 어떠한 리스트가 오더라도, Integer의 하위 클래스는 원소로 추가할 수 있다.

Wildcards and Subtyping

비제네릭 클래스에서든 상위 클래스에 하위 클래스를 대입할 수 있다.

class A { /* ... */ }
class B extends A { /* ... */ }
B b = new B();
A a = b; // OK

하지만, 제네릭 클래스에서는 컴파일 오류가 발생한다.

List<B> lb = new ArrayList<>();
List<A> la = lb; //compile error

왜냐하면 List<B>List<A>의 하위 타입이 아니며, 아무런 관계가 없다. List<B>List<A>의 공통 조상은 List<?>이다. 그렇다면, BA의 요소를 받는 제네릭 타입을 만들고 싶을때는 상위 경계 와일드카드를 사용하면된다.

List<? extends Integer> ints = new ArrayList<>();
List<? extends Number> numbers = ints; // OK

List<? extends Integer>List<? extends Number>의 하위 타입이기 때문에 가능하다. 왜냐하면 Integer의 하위 타입은 Number의 하위타입이기 때문에, 아래와 같은 관계가 성립된다.

Wildcard Capture

컴파일러는 어떠한 경우에, 와일드카드의 타입을 추론한다. 예를들어, List<?> 리스트가 정의되어있을때, 컴파일러는 코드에서 특정 타입을 추론한다. 이러한 시나리오를 와일드카드 캡처라고 한다.

"capture of" 오류 문구를 제외하고는 와일드카드 캡처를 신경쓰지 않아도 된다.

public class WildcardError {
    void foo(List<?> i) {
        i.set(0, i.get(0)); // capture of compile error
    }
}

위 예제에서는 foo() 메서드에서 List.set(int, E)를 호출할때 컴파일러는 List에 삽입되는 객체의 유형을 확인할 수 없기 때문에 오류를 발생시킨다. 이러한 유형의 오류가 발생하면 일반적으로 컴파일러는 변수에 잘못된 타입의 값을 할당하고 있다고 믿는다는 것을 의미한다. 이렇게 자바 컴파일타임에 타입안전을 추가하기위해 제네릭이 추가된 것이다.

public class WildcardFixed {

    void foo(List<?> i) {
        fooHelper(i);
    }


    // Helper method created so that the wildcard can be captured
    // through type inference.
    private <T> void fooHelper(List<T> l) {
        l.set(0, l.get(0));
    }

}

이러한 경우 fooHelper() 처럼 private helper method를 만들어서 문제를 해결할 수 있다. helper 메서드 덕분에 컴파일러는 T가 캡쳐 변수인 CAP#1 임을 추론할 수 있다. 일반적으로 helper 메서드는 originalMethodNameHelper로 지정된다.

Type Erasure

제네릭은 타입의 안정성을 보장하며, 실행시간에 오버헤드가 발생하지 않도록 하기위해 추가됐다. 컴파일러는 컴파일 시점에 제네릭에 대해 원소 타입 소거(type erasure)를 한다. 즉, 컴파일 타임에만 타입 제약 조건을 정의하고, 런타임에는 타입을 제거한다는 뜻이다.

  • unbounded Type(<?>, <T>)은 Object로 변환

  • bound type(<E extends Comparable>)의 경우는 Object가 아닌 Comprarable로 변환

  • 제네릭 타입을 사용할 수 있는 일반 클래스, 인터페이스, 메소드에만 소거 규칙을 적용

  • 타입 안정성 보존을 위해 필요시 type casting

  • 확장된 제네릭 타입에서 다형성을 보존하기위해 bridge method 생성

하나씩 예제를 보면서 알아볼 것이다.

unbounded Type(<?>, <T>)은 Object로 변환

// 타입 소거 이전
public class Node<T> {

    private T data;
    private Node<T> next;

    public Node(T data, Node<T> next) {
        this.data = data;
        this.next = next;
    }

    public T getData() { return data; }
    // ...
}
// 런타임(타입 소거 후)
public class Node {

    private Object data;
    private Node next;

    public Node(Object data, Node next) {
        this.data = data;
        this.next = next;
    }

    public Object getData() { return data; }
    // ...
}

type erasure가 적용되면서 특정 타입으로 제한되지 않은 <T>는 다음과 같이 Object로 대체된다.

제네릭 메서드에서도 동일하다.

public static <T> int count(T[] anArray, T elem) {
    int cnt = 0;
    for (T e : anArray)
        if (e.equals(elem))
            ++cnt;
        return cnt;
}
public static int count(Object[] anArray, Object elem) {
    int cnt = 0;
    for (Object e : anArray)
        if (e.equals(elem))
            ++cnt;
        return cnt;
}

T 는 비한정적 타입이므로, 컴파일러가 Object 로 변환한다.

bound type(<E extends T>)의 경우는 Object가 아닌 T로 변환

// 컴파일 할 때 (타입 변환 전) 
public class Node<T extends Comparable<T>> {

    private T data;
    private Node<T> next;

    public Node(T data, Node<T> next) {
        this.data = data;
        this.next = next;
    }

    public T getData() { return data; }
    // ...
}
// 런타임 시
public class Node {

    private Comparable data;
    private Node next;

    public Node(Comparable data, Node next) {
        this.data = data;
        this.next = next;
    }

    public Comparable getData() { return data; }
    // ...
}

한정된 타입(bound type)에서는 컴파일 시점에 제한된 타입으로 변환된다. Comparable 로 변환된 것을 확인할 수 있다.

public static <T extends Shape> void draw(T shape) { /* ... */ }
public static void draw(Shape shape) { /* ... */ }

여기서는 Shape 로 변환된 것을 확인할 수 있다.

확장된 제네릭 타입에서 다형성을 보존하기위해 bridge method 생성

컴파일러가 컴파일 시점에 제네릭 타입 안정성을 위해 bridge method를 생성할 수 있다. 다음 예제를 살펴보자.

public class Node<T> {

    public T data;

    public Node(T data) { this.data = data; }

    public void setData(T data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node<Integer> {
    public MyNode(Integer data) { super(data); }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}

위 두개의 클래스가 있다. 이때 다음과 코드를 실행해야한다고 예를 들어보자.

MyNode mn = new MyNode(5);
Node n = mn;            // A raw type - compiler throws an unchecked warning
n.setData("Hello");     // Causes a ClassCastException to be thrown.
Integer x = mn.data;    

타입이 소거된 후에는 다음과 같이 적용되며,런타임시 ClassCastException 를 발생시키게 된다.

MyNode mn = new MyNode(5);
Node n = (MyNode)mn;         // A raw type - compiler throws an unchecked warning
n.setData("Hello");          // Causes a ClassCastException to be thrown.
Integer x = (String)mn.data; 

타입 소거 후에 NodeMyNode는 다음과 같이 변환되는 것을 볼 수 있으며, 소거 후에는 Node 시그니처 메서드가 setData(T data) 에서 setData(Object data)로 바꾸기 때문에 MyNodesetData(Integer data)를 overriding 할 수 없게 된다.

public class Node {

    public Object data;

    public Node(Object data) { this.data = data; }

    public void setData(Object data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node {

    public MyNode(Integer data) { super(data); }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}

런타임 시에는 다음과 같이 타입이 소거된 상태로 변할 것이다. (Object로 변환) 그렇게 되면, Object 로 변하게 되는 경우에 대한 불일치를 없애기 위해 컴파일러는 런타임에 해당 제네릭 타입의 타임 소거를 위한 bridge method를 생성해준다.

class MyNode extends Node {

    // Bridge method generated by the compiler
    //
    public void setData(Object data) {
        setData((Integer) data);
    }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }

    // ...
}

그렇기때문에 ClassCastException 예외를 던지는 것을 알 수 있다.

Reifiable vs Non-Reifiable

Reifiable Type

런타임시에 완전하게 오브젝트 정보를 표현할 수 있는 타입을 실체화 타입(Reifiable Type)이라고 한다. 즉, 컴파일 단계에서 타입소거에 의해 지워지지 않는 타입 정보이다.

  1. 원시 타입 (ex) int, double, float, byte 등등

  2. Number, Integer와 같은 일반 클래스와 인터페이스 타입

  3. 비한정 와일드카드(?)가 포함된 매개변수화 타입 (ex) List<?>, ArrayList<?>

  4. Raw type (ex) List, ArrayList, Map

비한정적 와일드카드는 애초에 타입 정보를 전혀 명시하지 않았으므로, 컴파일시에 타입 소거를 한다고 해도 잃을 정보가 없기 때문에 실체화 타입이라 볼 수 있으며, 타입 소거에 의해 컴파일 시점에 Object로 변환된다.

Non-Reifiable Type

컴파일 단계에서 타입소거에 의해서 타입 정보가 제거된 타입이다. 이 경우 런타임시에 런타임시에 알 수 있는 정보가 없다.

  1. Generic Type (ex) List<E>

  2. Parameterized Type (ex) List<Number>, ArrayList<String>

  3. Bounded wildcard Type (ex) List<? extends Number>, List<? super String>

Heap Pollution

힙 오염은 파라미터화 된 타입의 변수가 해당 파라미터화된 타입이 아닌 객체를 참조할 때 발생한다.

  • raw type과 파라미터화된 타입이 섞여 사용되는 경우

  • unchecked cast가 사용된 경우

Potential Vulnerabilities of Varargs Methods with Non-Reifiable Formal Parameters

가변인수(varargs) 매개변수를 포함한 제네릭 메서드는 힙 오염을 유발할 수 있다.

public class ArrayBuilder {

  public static <T> void addToList (List<T> listArg, T... elements) {
    for (T x : elements) {
      listArg.add(x);
    }
  }

  public static void faultyMethod(List<String>... l) {
    Object[] objectArray = l;     // Valid
    objectArray[0] = Arrays.asList(42);
    String s = l[0].get(0);       // ClassCastException thrown here
  }

}
public class HeapPollutionExample {

  public static void main(String[] args) {

    List<String> stringListA = new ArrayList<String>();
    List<String> stringListB = new ArrayList<String>();

    ArrayBuilder.addToList(stringListA, "Seven", "Eight", "Nine");
    ArrayBuilder.addToList(stringListB, "Ten", "Eleven", "Twelve");
    List<List<String>> listOfStringLists = new ArrayList<List<String>>();
    ArrayBuilder.addToList(listOfStringLists, stringListA, stringListB);
    ArrayBuilder.faultyMethod(Arrays.asList("Hello!"), Arrays.asList("World!"));
  }
}

컴파일시에 addToList() 메서드 정의 부분에 다음과 같은 경고 문구가 뜬다.

Possible heap pollution from parameterized vararg type 

컴파일이 가변인수(varargs) 메서드를 만나게되면 가변인수(varargs) 파라미터를 배열로 변환시킨다. 하지만, 자바는 파라미터화된 타입의 배열의 생성을 허용하지 않는다. 위 예제에서 ArrayBuilder.addToList 가변인수 매개변수 T...T[] 배열로 변환하는데, 타입 소거에 의해서 컴파일러는 가변인수 파라미터를 Object[]로 변환시키게되고 결과적으로 힙오염의 가능성이 생기는 것이다.

Object[] objectArray = l; 

구문은 힙오염을 잠재적으로 내재하고 있는데, 컴파일러는 경고문구를 생성하지 않고 있다. 왜냐하면, 컴파일는 List<String>... l 을 형식 파라미터 List[] 로 변환할 때 이미 경고를 만들었기 때문이다. 즉, 변수 lObject[]의 하위 타입인 List[] 타입을 갖게되고 이 구문은 유효하게 되는 것이다.

objectArray[0] = Arrays.asList(42);

여기서 objectArray의 첫번째 원소에 Integer타입의 객체 리스트를 할당하고,

 ArrayBuilder.faultyMethod(Arrays.asList("Hello!"), Arrays.asList("World!"));

ArrayBuilder.faultyMethod 를 호출한다면, 런타임시에 ClassCastException 오류를 발생시키는 것이다.

Prevent Warnings from Varargs Methods with Non-Reifiable Formal Parameters

파라미터화 타입의 가변인수 메서드가 ClassCastException 을 발생시키지 않고, 비슷한 오류가 발생하지 않는 다는 것이 보장되면(타입 안전성이 보장) static과 생성자가 아닌 메서드 선언부에@SafeVarargs 어노테이션을 추가해 경고 문구를 지울 수 있다. 이 어노테이션은 메서드 구현에서 varargs 형식 파라미터에 대해 부적절한 처리를하지 않았음을 나타낸다. 좋은 방법은 아니지만 메서드 선언에 다음과 같은 방법으로도 경고 문구를 억제할 수 있다.

@SuppressWarnings({"unchecked", "varargs"})

단, 이경우에는 메서드 호출 부분에서 생성되는 경고를 막아주지는 않는다.

Generic Type의 상속과 구현

제네릭 타입도 부모 클래스가 될 수 있다.

public class ChildProduct<T,M> extends Product<T,M>{...}

자식 제네릭 타입은 추가적으로 타입 파라미터를 가질 수 있다.

public class ChildProduct<T,M,C> extends Product<T,M>{...}

제네릭 인터페이스를 구현한 클래스도 제네릭 타입이된다.

public interface Storage<T>{
    public void add(T item, int index);
    public T get(int index);
}
public class StorageImpl<T> implements Storage<T>{
    private T[] array;
    
    public StorageImpl(int capacity){
        this.array = (T[])(new Object[capacity]);
    }

    @Override
    public void add(T item, int index){
        array[index] = item;
    }
    
    @Override
    public T get(int index){
        return array[index];
    }
}

참고

Last updated