제네릭스 Generics

다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스를 컴파일 시에 타입 체크가 가능하도록 해주는 기능이다.

객체의 타입 안정성을 높이고 형변환의 번거로움을 줄인다.

 

예를 들어 저번 장에서 설명한 ArrayList는 내부에 Object 배열이 선언되어 있어 모든 종류의 객체를 담을 수 있지만,

실제로 사용할 때에는 한 종류의 객체만 담는 경우가 많다.

 

그런데도 꺼낼때마다 타입체크와 형변환을 하는 것은 불편할 수 밖에 없으며,

실수로 다른 타입의 객체가 포함되는 것을 막을 방법도 없다.

 

이를 제네릭스가 해결해준다.

 

제네릭의 선언과 활용

class MyArrayList{
    private Object[] array = new Object[10];
    private int length = 0;
    
    public void add(Object o){
        this.array[length++]= o;
    }
    
    public Object get(int index){
        return this.array[index];
    }
}
MyArrayList lst = new MyArrayList();
MyClass mc = (MyClass) lst.get(0);

다음과 같이 직접 간단하게 구현한 ArrayList 클래스가 있다.

이 ArrayList에 존재하는 객체를 꺼내서 사용하려면 get함수가 Object 타입을 반환되므로 항상 형변환을 해줘야 한다.

class MyArrayList<T>{
    private T [] array;
    private int length = 0;
    
    public MyArrayList(){
        this.array = (T []) new Object[10];
    }
    
    public void add(T o){
        this.array[length++]= o;
    }
    
    public T get(int index){
        return this.array[index];
    }
}

MyArrayList<MyClass> lst = new MyArrayList<> ();
MyClass mc = lst.get(0);

클래스를 제네릭 클래스로 변경하려면 다음과 같이 클래스 옆에 <T>를 붙이면 된다.

 

이 T를 타입 변수라고 하며 굳이 T가 아니더라도 다른 것을 사용해도 된다.

(보통 ArrayList의 경우 element의 E를 따서 사용한다.)

 

주의할 점은 타입 변수는 기본 타입이 아닌 참조 타입만 적을 수 있다.

제네릭이 만들어진 이유를 생각하면 당연한 이야기이다.

 

타입변수가 여러개일 경우 Map<K,V> 처럼 쉼표를 이용해 나열하면 된다.

상황에 맞춰 의미 있는 문자를 사용하는 것이 좋으며,

이들은 기호의 종류만 다를 뿐 모두 임의의 참조형 타입을 의미한다.

MyArrayList<MyClass> lst = new MyArrayList<MyClass> ();
MyArrayList<MyClass> lst = new MyArrayList<> (); //JDK 1.7부터 가능

또한 JDK1.7 부터 추정이 가능한 경우 타입을 생략할 수 있다.

참조변수의 타입으로부터 MyClass만 받는다는 것을 알 수 있기 때문에

생성자에 반복해서 타입을 지정해주지 않아도 된다.

 

이렇게 제네릭을 활용해 클래스를 선언하면 Object 타입을 반환할 필요 없이 임의의 T타입을 반환하며

귀찮은 형변환을 하지 않아도 되며, 실수로 다른 타입의 객체가 들어올 일도 없어진다.

 

그런데 코드를 보면 이상한 부분이 있다.

class MyArrayList{
    private Object[] array = new Object[10];
    ...
}

class MyArrayList<T>{
    private T [] array;
    ...
    public MyArrayList(){
        this.array = (T []) new Object[10];
    }
    ...
}

왜 T[] array = new T[10]; 으로 선언하지 않고 Object 배열을 선언한 후 형변환할까?

이는 new 연산자가 컴파일 시점에 정확하게 타입을 알아야만 하기 때문이다.

(마찬가지의 이유로 instanceOf역시 T를 피연산자로 사용할 수 없으며 static 메서드에서도 T 타입을 사용할 수 없다.)

 

그래서 제네릭 배열을 만들고 사용하기 위해선 다음처럼 Object 배열을 만든 후 형변환 시키거나

Reflection API의 newInstance()와 같이 동적으로 객체를 생성해야 한다.

 

제한된 제네릭 클래스

class FruitBox<T extends Fruit> {
    ArrayList<T> list = new ArrayList<T>();
		
	public void add(T element){
	    list.add(element);
	}
	public T get(int index){
	    return list.get(index);
	}	
}

FruitBox<Fruit> box = new FruitBox<> ();
box.add(new Grape());
box.add(new Apple());
box.add("hi"); //에러
		
System.out.println(box.get(1));

다음과 같이 제네릭 타입에 extends를 사용하면 특정 타입의 자손들만 대입할 수 있도록 제한할 수 있다.

만약 클래스가 아니라 인터페이스를 구현해야 한다는 제약이 필요해도 ‘extends’를 사용한다.

(’implements’가 아니다!)

 

와일드카드

만약 메서드에 제네릭 클래스를 매개변수로 받고 싶다면 어떻게 해야할까?

FruitBox<Fruit> fruitBox = new FruitBox<> ();
FruitBox<Apple> appleBox = new FruitBox<> ();

Juicer.makeJuice(fruitBox);
Juicer.makeJuice(appleBox);

만약 FruitBox를 받아 주스로 만드는 Juicer 클래스와 makeJuice 스태틱 메서드를 구현해야한다면?

 

class Jucier <T extends Fruit> {
    static String makeJuice(FruitBox<T> box){ //컴파일 오류 발생
        String tmp = "";
        for (Fruit f : box.list){
            tmp += f.toString();
        }
        return tmp;
    }
}

앞서 말했듯이 static 메서드 안에는 제네릭 클래스를 활용할 수 없다. (클래스 파일 로드 시에 타입이 정해져야 하므로)

class Jucier{
    static String makeJuice(FruitBox<Fruit> box){
        String tmp = "";
        for (Fruit f : box.list){
            tmp += f.toString();
        }
        return tmp;
    }
}

Juicer.makeJuice(fruitBox); // 가능
Juicer.makeJuice(appleBox); // 컴파일 에러

다음과 같이 <Fruit>를 받도록 선언해버리면 FruitBox<Apple> 타입은 매개변수로 넣을 수 없다. (이유는 후술할 예정)

class Jucier{
    static String makeJuice(FruitBox<Fruit> box){
        String tmp = "";
        for (Fruit f : box.list){
            tmp += f.toString();
        }
        return tmp;
    }
    static String makeJuice(FruitBox<Apple> box){
        String tmp = "";
        for (Fruit f : box.list){
            tmp += f.toString();
        }
        return tmp;
    }
}

이렇게 오버로딩을 한다면?

놀랍게도 컴파일 에러가 발생한다. 제네릭 타입이 다른 것만으로는 오버로딩이 성립하지 않는다.

즉 메서드가 중복 정의된 것이 되버린다.

 

이러한 상황에서 사용하도록 와일드카드가 추가되었다.

static String makeJuice(FruitBox<? extends Fruit> box){}

기호 ?로 표현하는 와일드 카드는 어떠한 타입도 될 수 있다.

 

? 안에는 어떤 객체든 들어갈 수 있으며 extends와 super를 이용해 상한과 하한을 제한할 수 있다.

  • <?> 제한 없음, 모든 타입이 가능
  • <? extends T> 상한 제한, T와 그 자손들만 가능
  • <? super T> 하한 제한, T와 그 조상들만 가능

그래서 와일드카드를 대체 어디다 써먹나요?

제네릭에 대해 정확하게 이해하기 위해선 먼저 알아야하는 개념이 있다.

 

공변(convariant)과 불공변(invariant)

공변 : 함께 변한다.

Object[] objects = new String[10]; // 컴파일 성공
objects[1] = 1; // 런타임에러 ArrayStoreException!

Sub 타입이 Super 타입의 하위 타입일 경우 Sub[]는 Super[]의 하위 타입이 된다.

이런 경우를 공변한다고 이야기한다.

 

자바에서 배열은 공변한다.

따라서 다음과 같이 배열을 선언할 경우 컴파일시에 objects는 Object[]이므로 Integer를 할당해도 문제가 없다.

그러나 런타임 시에 String[] 이 되기 때문에 예외가 발생한다.

 

그러나 제네릭은 불공변하다.

즉 List<String>은 List<Object>의 하위 타입이 아니다.

ArrayList<Object> lst = new ArrayList<String> (); //컴파일에러!!
list.add(1);

배열처럼 사용할 경우 컴파일 에러가 발생한다!

제네릭의 경우 컴파일 시에 저 둘을 완전히 다른 타입으로 인식한다.

 

앞서서 메서드에 <Fruit> 타입으로 매개변수를 선언할시 <Apple> 타입의 인자를 받다고 한 이유이기도 하다.

 

왜 배열은 공변하고 제네릭은 불공변할까?

배열의 경우 다형성을 위해 타입 안정성을 포기했다고 볼 수 있다.

그러나 제네릭이 생긴 이유가 타입 안정성을 위해서 였으므로,

제네릭은 불공변하게 만들어 타입 안정성을 지켰다고 생각한다.

 

물론 이러한 불공변하다는 특징 때문에 앞서 설명한 메서드의 매개변수로 받는 과정에서 문제가 생기고

와일드카드가 구현되었다. 불공변한 제네릭에 다형성을 구현하게 된 것이다.

 

??? : 그럼 Object 배열처럼 <?>는 무적이네요

그럼 extends, super 같은 키워드 없이 <?> 만쓰면 되는거 아닌가요?

 

어림도 없다. 가장 흔하게 쓰는 List를 예시로 들자면

List<?> 타입에는 null을 제외한 어떤 값도 넣을 수 없다.

 

이게 갑자기 뭔 소리야? 싶겠지만 사실이다.

 

와일드카드는 설계가 아닌 사용을 위한 것.

class Box<? extends T>{} // 에러 발생!

다음과 같이 클래스나 인터페이스에 제네릭을 설계할 때에는 와일드카드를 사용할 수 없다.

와일드카드는 이미 만들어진 제네릭 클래스나 메서드를 사용할때 이용하는 것이다.

 

static String makeJuice(List<? extends Fruit> box){}

즉 변수나 메서드의 파라미터로 객체 타입을 받을 때 범위를 지정해주기 위해서 사용하는 것이다.

이 상황에서 매개변수의 인자로 List<Apple>, List<Grape> 모두 사용하기 위해 사용한다.

 

그리고 앞서서 설명했듯이 와일드 카드는 extends와 super를 이용해 상한과 하한을 제한할 수 있다.

  • <?> 제한 없음, 모든 타입이 가능
  • <? extends T> 상한 제한, T와 그 자손들만 가능
  • <? super T> 하한 제한, T와 그 조상들만 가능

 

따라서 <? extends Fruit>는 다음과 같은 특징을 가진다고도 할 수 있다.

 

1. 안전하게 객체를 사용하려면 Fruit 타입으로 받아야 한다.

static String makeJuice(List<? extends Fruit> box){
  Fruit f1 = box.get(0); //가능
  
  Apple a = box.get(0); // 잠재적인 에러!
  Grape a = box.get(0); // 잠재적인 에러!
}

만약 리스트에서 객체를 꺼내 사용할때 Fruit 타입이 아닌 다른 Apple 타입으로 변환하려 하면,

인자로 List<Grape>가 넘어왔을 때 오류가 발생한다! (반대도 가능)

 

2. null을 제외한 어떤 자료형도 넣을 수 없다.

static String addFruit(List<? extends Fruit> box){
  box.add(new Fruit()); // 인자에List<Apple>이 들어올 시 Apple 배열에 Fruit 저장 불가능
  box.add(new Apple()); // 인자에 List<Grape>이 들어올 시 Grape 배열에 Apple 저장 불가능
  box.add(new Grape()); // 마찬가지로 저장 불가능
  box.add(new Object());// 마찬가지로 저장 불가능
}

매개변수에 어떤 타입의 List가 넘어올지 모르기 때문에 null을 제외한 모든 자료형을 넣을 수 없다.

인자로 List<Grape>가 넘어온다면 Apple, Fruit, Object를 넣는 것이 불가능하다.

 

어떤 객체를 담은 리스트가 넘어올지 모르므로 어떤 객체를 집어넣었을때 오류가 날지 안날지 예측할 수 없다!

따라서 null을 제외한 어떤 객체도 리스트에 집어넣을 수 없다.

 

반대로 <? super Fruit>는 다음과 같은 특징을 가진다.

  • Object 타입으로만 객체를 꺼낼 수 있다.
  • Fruit와 자식 타입으로만 객체를 저장할 수 있다.
static String method(List<? super Fruit> box){
  Fruit f1 = box.get(0); //List<Object> 들어올 시 에러!
  Apple a = box.get(0); 
	Grape a = box.get(0); 

  box.add(new Fruit()); // 모두 가능
	box.add(new Apple());
	box.add(new Grape());
	box.add(new Object()); //List<Fruit> 들어올 시 에러!
}

당연하다. 위 메서드에 들어올 수 있는 인자는 Fruit와 그 부모 클래스인 Object가 들어간 List<Fruit>, List<Object>이다.

 

어떤 타입의 리스트가 인자로 넘어올지 모르므로 Object 타입으로만 객체를 꺼낼 수 있으며

리스트에 값을 넣을 때에는 Fruit와 자식 타입의 객체만 넣을 수 있다.

 

이제 List<?> 타입에는 null을 제외한 어떤 값도 넣을 수 없다는 말이 무슨 뜻인지 이해가 갈 것이다.

static String method(List<?> box){
  box.add(new Fruit()); // List<String> 들어올 시 에러
	box.add(new Apple()); 
	box.add(new Grape());
	box.add(new Object()); //List<Fruit> 들어올 시 에러!
}

<?> 시에는 모든 타입의 객체가 인자로 들어올 수 있으므로 null을 제외한 어떤 값도 넣을 수 없다.

 

그런데 이것만 보면 어디서 extends, super를 어떻게 사용해야하는지 조금 복잡해진다.

이펙티브 자바를 아직 읽진 않았는데 이펙티브 자바에서 PECS라는 공식을 소개해준다고 한다.

 

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

  • 외부에서 온 데이터를 이용해 생산(Producer) 한다면 extends
  • 외부에서 온 데이터를 소비(Consumer) 한다면 super

즉 메서드에서 받아온(외부) 제네릭 객체를 이용해 데이터를 꺼내 데이터를 사용해야한다면 extends

메서드에서 받아온 제네릭 객체에 데이터를 추가(소비) 해야한다면 super를 사용하라는 것이다.

 

오라클 공식 문서는 in/out으로 설명한다고 한다.

  • 매개변수명이 in (코드에 데이터를 복사할 목적)이면 extends
  • 매개변수명이 out(다른 곳에서 사용할 데이터를 적재) 이면 super

그냥 메서드로 받아온 데이터를 읽을 예정이면 extends, 추가할 예정이면 super를 사용하라는 뜻인 것 같다.

 

제네릭 메서드

짜잔~ 아깐 안된다고 했었지만 사실 스태틱 메서드에도 제네릭을 넣을 수 있다

바로 메서드 선언부에 제네릭 타입을 선언해 놓으면 된다.

// Collections.sort()
static <T> void sort (List<T> , Comparator <? super T> c)

메서드의 반환 값 앞에 <T> 가 선언되어 있다.

이 경우 해당 메서드에서만 지역적으로 제네릭을 사용하겠다는 의미이다.

 

클래스 앞에 <T> 가 붙어도 앞서 선언한 타입과 전혀 상관 없는 새로운 제네릭 타입이다.

지역 변수처럼 사용할 T를 선언했다고 생각하면 된다.

 

따라서 위의 makeJuice 코드를 제네릭 메서드를 이용해 고치면 다음과 같다.

//와일드 카드 사용
static String makeJuice(FruitBox<? extends Fruit> box)

// 제네릭 메서드 사용
static <T extends Fruit> String makeJuice(FruitBox<T> box)

그리고 이 메서드를 사용할 때에는 메서드 앞에 타입을 대입해야 한다.

그러나 대부분의 경우 컴파일러가 타입을 추측할 수 있기 때문에 생략할 수 있다.

Juicer.<Fruit>makeJuice(fruitBox);
Juicer.makeJuice(fruitBox); // fruitBox가 <Fruit> 타입인걸 알기 때문에 생략 가능

 

제네릭 타입 소거(Type erasure)와 로우 타입(raw type)

사실 우리가 선언해 놓은 제네릭 타입은 런타임시 소거된다.

class MyArrayList<T extends Fruit>{
    public T get(int index){
        return this.array[index];
    }
}

class MyArrayList{
    public Fruit get(int index){
        return (Fruit) this.array[index];
    }
}

컴파일러는 제네릭 타입을 이용해서 소스파일을 체크하고, 필요한 곳에 형변환 코드를 넣어준다.

그 이후 제네릭 타입을 제거한다! 즉 컴파일된 파일에는 제네릭에 대한 정보가 존재하지 않는다.

 

  • <T> <?> 타입은 Object로, <T extends U> 타입은 U로 변환한다.
  • 변환된 곳에서도 원래 코드대로 작동할 수 있도록 필요한 경우 형변환 코드를 추가한다.
ArrayList<String> lst = new ArrayList<> (); //변경 전
ArrryList lst = new ArrayList(); //변경 후

이렇게 제네릭 타입을 소거하고 나면 실제로 클래스 파일에는 다음과 같이 코드가 변경된다고 볼 수 있다.

사실 지난 시간에 배운 Collection 클래스의 형태와 똑같아졌다.

 

이렇게 제네릭 타입이 들어갈 수 있는 곳에 아무것도 없는 기본 형태의 타입을 Raw Type이라고 한다.

(타입 파라미터 <> 안에 아무 타입도 넣지 않고 생성 시에도 raw type으로 생성한다.)

 

이렇게 바꾸는 이유는 제네릭이 도입되기 전 코드들과의 호환을 위해서이다.

 

제네릭을 불공변하게 만든 이유도, 다음과 같이 런타임시에는 타입이 소거되므로

타입 안정성을 위해 컴파일 시에 최대한 오류를 잡기 위해서 라고 생각된다.

 

Raw type은 전 코드들과의 호환성을 위해서 존재할 뿐

제네릭이 주는 장점을 모두 잃게되므로 그냥 사용하지 말자.

 

 

<참고>

☕ 자바 제네릭의 공변성 & 와일드카드 완벽 이해

 

☕ 자바 제네릭의 공변성 & 와일드카드 완벽 이해

자바의 공변성 / 반공변성 제네릭의 와일드카드를 배우기 앞서 선수 지식으로 알고 넘어가야할 개념이 있다. 조금 난이도 있는 프로그래밍 부분을 학습 하다보면 한번쯤은 들어볼수 있는 공변

inpa.tistory.com

 

+ Recent posts