Generics

김운채·2023년 5월 22일
0

TIL

목록 보기
14/22

JDK1.5 에서 처음 도입된 제네릭스는 JKD1.8부터 도입된 람다식만큼 큰 변화였다.
이번 포스트는 제네릭의 정의와 개념정도만 살펴보려고 한다.

제네릭이란?

다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입 체크를 해주는 기능이다.
객체의 타입을 컴파일 시에 체크하기 때문에 객체의 타입 안정성을 높이고 형변환의 번거로움이 줄어든다.

🤷‍♀️ 타입 안정성을 높인다..?
타입 안정성을 높인다는 것은 의도하지 않은 타입의 객체가 저장되는 것을 막고, 저장된 객체를 꺼내올 때 원래의 타입과 다른 타입으로 잘못 형변환되어 발생할 수 있는 오류를 줄여준다는 뜻이다.

예를 들어, ArrayList와 같은 컬렉션 클래스는 다양한 종류의 객체를 담을 수 있지만, 대체로는 한 종류의 객체를 담는 경우가 더 많다. 그런데도 꺼낼 때 마다 타입체크를 하고 형변환을 하는 것은 아무래도 불편할 수 밖에 없다. 게다가 원하지 않는 종류의 객체가 포함되는 것을 막을 방법이 없다는 것도 문제다. 이러한 문제들을 제네릭이 해결해 준다. 다룰 객체의 타입을 미리 명시해줌으로써 번거로운 형변환을 줄여준다는 것!

제네릭의 장점

  • 타입 안정성을 제공
  • 타입체크와 형변환을 생략할 수 있으므로 코드가 간결해 진다.
  • 런타임 환경에 영향을 주지 않는 전처리 기술이다.

제네릭의 선언

자바에서 제네릭은 클래스메소드에만 다음과 같은 방법으로 선언할 수 있다.

class MyArray<T> {

    T element;

    void setElement(T element) { this.element = element; }

    T getElement() { return element; }

}

위의 예제에서 사용된 T타입 변수(type variable)라고 하며, 임의의 참조형 타입을 의미한다. 자료형을 일반화(Generalize)시키기 위해 T라는 타입변수를 사용했고, 후에 인스턴스를 생성할때 외부에서 자료형을 지정하게 된다.

T뿐만 아니라 어떠한 문자를 사용해도 상관없다. 또한 여러 개의 타입 변수는 쉼표(,)로 구분하여 명시할 수 있다.

타입 변수는 클래스에서뿐만 아니라 메소드의 매개변수나 반환값으로도 사용할 수 있다.

📌 제네릭 용어

- T: 타입매개변수 혹은 타입 변수
- MyArray<T> : 제네릭 클래스
- MyArray : 원시타입(raw type)

위와 같이 선언된 제네릭 클래스(generic class)를 생성할 때에는 타입 변수 자리에 사용할 실제 타입을 명시해야 한다.

MyArray<Integer> myArr = new MyArray<Integer>();

위의 예제는 MyArray 클래스에 사용된 타입 변수로 Integer 타입을 사용하는 예제이다.

위처럼 제네릭 클래스를 생성할 때 사용할 실제 타입을 명시하면, 내부적으로는 정의된 타입 변수가 명시된 실제 타입으로 변환되어 처리된다.

Integer : 대입된 타입(매개변수화된 타입)

하지만 모든 객체에 대해 동일하게 동작해야하는 static 변수에 타입 변수 T를 사용할 수 없다. T는 인스턴스변수로 간주되기 때문이다. static 멤버는 인스턴스 변수를 참조할 수 없다.

class Box<T> {
	static T item; //에러
	static int compare(T t1, T t2){...} //에러
}

static 멤버는 대입된타입의 종류에 관계없이 동일한 것이어야 하기 때문이다.
Box<Apple>.item, Box<Grape>.item 이 둘이 다른 것이면 안된다는 말.

그리고 제네릭 타입의 배열을 생성하는 것도 불가능하다.
제네릭 배열타입의 참조변수를 선언하는 것은 가능하지만, new T[10] 이렇게는 안됨.

class Box<T>{
	T[] itemArr; //가능!
	T[] toArray(){
		T[] tmpArr = new T[itemArr.length]; //에러
		...
	}
}

이렇게 제네릭 배열을 생성할 수 없는 건, new 연산자 때문인데, 이 연산자는 컴파일 시점에 타입 T가 뭔지 정확히 알아야한다. 그런데 위의 코드에 Box를 컴파일 하는 시점에서는 T의 타입을 알수없다. instanceof 연산자도 같은 이유로 쓸 수 없다.

제네릭 클래스 객체 생성과 사용

제네릭 클래스 사용방법에 대해 알아보자
밀당이 거의 정재혁 급

제네릭에서는 참조변수와 생성자에 대입된 타입이 일치해야 한다.

Box<Apple> box = new Box<Apple>(); //가능
Box<Apple> box = new Box<Grape>(); //에러

두 타입이 상속관계에 있어도 마찬가지다. Apple이 Fruit의 자손이라고 해보자

Box<Fruit> box = new Box<Apple>(); //에러

하지만! 제네릭 클래스의 타입이 상속관계에 있고, 대입된 타입이 같은 것은 괜찮다. FruitBox 가 Box 의 자손이라고 해보자.

Box<Apple> box = new FruitBox<Apple>(); //가능! 다형성 

그러나! 생성된 Box의 객체에 void add(T item) 으로 객체를 추가할 때, 대입된 타입과 다른 타입의 객체는 추가할 수 없다.

Box<Apple> box = new Box<Apple>();
box.add(new Apple()); //가능
box.add(new Grape()); //에러

However! 타입T가 Fruit인 경우, void add(Fruit item) 가 되므로 Fruit 의 자손들은 이 메서드의 매개변수가 될 수 있다. Apple 이 Fruit의 자손이라 해보자.

Box<Fruit> Fruitbox = new Box<Fruit>();
Fruitbox.add(new Fruit());
Fruitbox.add(new Apple())

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

Box<Apple> box = new Box<>(); //가능

근데 만약 난 아무 타입이나 쓰게 하고싶지않아..🤔 매개변수 타입을 제한할 수 있는 방법이 없을까? 한다면 extends를 상용해서 특정 타입의 자손들만 대입할 수 있게 제한 할 수 있다.

class FruitBox<T extends Fruit>{
	ArrayList<T> list = new ArrayList<T>();
...
}

이렇게 되면 Fruit 의 자손들만 담을 수 있다.

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

만약 클래스 Fruit 의 자손이면서 Eatable 인터페이스도 구현해야 한다면 "&" 을 쓸 수 있다.

class FruitBox<T extends Fruit & Eatable>

와일드 카드

위에서 참조변수와 생성자에 대입된 타입이 상속관계에 있어도 사용할 수 없다고 했다. 이것을 불공변 이라고 한다.

  • 공변(covariant) : A가 B의 하위 타입일 때, T<A>T<B>의 하위 타입이면 T는 공변
  • 불공변(invariant) : A가 B의 하위 타입일 때, T<A>T<B>의 하위 타입이 아니면 T는 불공변

대표적으로 배열은 공변이며, 제네릭은 불공변이다. 예를 들어 배열의 요소들을 출력하는 메소드가 있다고 하자. 이때 우리가 변수의 선언 타입은 Integer로, 메소드의 선언 타입은 Object로 해두었다고 하자.

void genericTest() {
  Integer[] integers = new Integer[]{1, 2, 3};
  printArray(integers);
}

void printArray(Object[] arr) {
  for (Object e : arr) {
      System.out.println(e);
  }
}

위의 메소드는 정상적으로 실행이 된다. 왜냐하면 배열은 공변이기 때문에 Integer가 Object의 하위 타입이므로 Integer[] 역시 Object[]의 하위 타입이기 때문이다. 하지만 제네릭은 불공변이라서 제네릭을 사용하는 컬렉션을 보면 다음의 코드는 컴파일 에러가 발생한다.

void genericTest() {
  List<Integer> list = Arrays.asList(1, 2, 3);
  printCollection(list);   // 컴파일 에러 발생
}


void printCollection(Collection<Object> c) {
  for (Object e : c) {
      System.out.println(e);
  }
}
//에러!
java: incompatible types: java.util.List<java.lang.Integer> cannot be converted to java.util.Collection<java.lang.Object>

Integer는 Object의 하위 타입이다. 하지만 제네릭은 불공변이므로 List<Integer>List<Object>의 하위타입이 아니다. 둘은 아무런 관계가 없다. 그래서 위의 코드를 실행하면 컴파일 에러가 발생한다. 이러한 제네릭의 불공변 때문에 와일드카드(제네릭의 ?타입)가 등장해따.

제네릭이 등장하고 나서 타입의 안정성은 보장받았지만, 다른 문제가 발생한다.
제네릭이 불공변이기 때문에, 모든 타입에서 공통적으로 사용되는 메소드를 만들 방법이 없는 것이다.

위의 예시에 쓴 printCollection의 타입을 Integer에서 Object로 변경하여도 제네릭이 불공변이기 때문에 Collection<Object>Collection<Integer>의 하위타입이 아니므로 컴파일 에러가 발생하는 것이다.

이러한 상황은 오히려 제네릭이 등장하기 이전보다 실용성이 떨어졌기 때문에, 모든 타입을 대신할 수 있는 와일드카드 타입(<?>)을 추가하였다.

와일드카드는 정해지지 않은 unknown type이기 때문에 Collection<?>로 선언함으로써 모든 타입에 대해 호출이 가능해졌다. 그래서 제네릭의 활용성을 높일 수 있게 되었는데, 여기서 중요한 것은 와일드카드가 any type이 아닌 unknown type이라는 점이다.

와일드 카드의 종류

? 만으로는 Object와 다를게 없으므로, 다음과 같이 extendssuper상한/하한을 제한할 수 있다.

  • Upper Bounded Wildcard <? extends T> : 와일드 카드의 상한 제한, T와 그 자손들만 가능.
  • Lower Bounded Wildcard <? super T> : 와일드 카드의 하한 제한, T와 그 조상들만 가능.
  • Unbound WildCard <?> : 제한 없음. 모든 타입 가능, <? extends Object> 와 동일
void printCollection(Collection<?> c) {
    for (Object e : c) {
        System.out.println(e);
    }
}

와일드카드로 선언된 타입은 unknown type이기 때문에 다음과 같은 경우에 문제가 발생하였다.

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

컬렉션의 add로 값을 추가하려면 제네릭 타입인 E 또는 E의 자식을 넣어줘야 한다. 그런데 와일드카드는 unknown type이므로 Integer, String 또는 개발자가 추가한 클래스까지 될 수 있기 때문에 범위가 무제한이다. 와일드카드의 경우 add로 넘겨주는 파라미터가 unknown 타입의 자식이여야 하는데, 정해지지 않았으므로 어떠한 타입을 대표하는지 알 수 없어서 자식 여부를 검사할 수 없는 것이다.

반면에 get으로 값을 꺼내는 작업은 와일드카드로 선언되어 있어도 문제가 없다. 왜냐하면 값을 꺼낸 결과가 unknown 타입이여도 우리는 해당 타입이 어떤 타입의 자식인지 확인이 필요하지 않으며, 심지어 적어도 Object의 타입임을 보장할 수 있기 때문이다.

결국 이러한 상황이 생기는 것은 결국 와일드카드가 any 타입이 아닌 unknown 타입이기 때문이다.

Producer-Extends, Consumer-Super

🤷‍♀️ 그래서 언제 super를 사용해야 하고, 언제 extends를 사용해야 하는데?

그래서 이펙티브 자바에서는 PECS라는 공식을 만들었는데, 이는 Producer-Extends, Consumer-Super의 줄임말이다. 즉, 컬렉션으로부터 와일드카드 타입의 객체를 생성 및 만들면(produce) extends를, 갖고 있는 객체를 컬렉션에 사용 또는 소비(consumer)하면 super를 사용하라는 것이다.

void printCollection(Collection<? extends MyParent> c) {
    for (MyParent e : c) {
        System.out.println(e);
    }
}

void addElement(Collection<? super MyParent> c) {
    c.add(new MyParent());
}

produce : printCollection 같은 경우에는 컬렉션으로부터 원소들을 꺼내면서 와일드카드 타입 객체를 생성(produce)하고 있다.
consumer : addElement의 경우에는 컬렉션에 해당 타입의 원소를 추가함으로써 객체를 사용(consume)하고 있다.

그러므로 와일드카드 타입의 객체를 produce하는 printCollection은 extends가, 객체를 consume하는 addElement에는 super가 적합한 것이다.

제네릭 메서드

메서드의 선언부에 제네릭 타입이 선언된 메서드를 제네릭 메서드라고 한다.
제네릭 타입의 선언 위치는 반환타입 바로 앞이다.

static <T> void sort(List<T> list, Comparator<? super T> c)

여기서 같은 문자 T를 사용해도 같은 것이 아니라는 것에 주의하자.

또한, 위에서 설명한것 처럼 static멤버에는 타입매개변수를 사용할 수 없지만, 이처럼 메서드에 제네릭 타입을 선언하고 사용하는 것은 가능하다.

메서드에 선언된 제네릭 타입은 지역변수를 선언한 것과 같다고 생각하면 이해가 갈것이다. 이 타입 매개변수는 메서드 내에서만 지역적으로 사용될 것으므로 메서드가 static이 아니건 상관이 없다.

static Juice makeJuice(FruitBox<? extends Fruit> box){
	String tmp =""
	...
}

👇

static <T extends Fruit> Juice makeJuice(FruitBox<T> box){
	String tmp =""
	...
}

이렇게 바꿀수 있다.

이제 이 메서드를 호출해보자. 아래와 같이 변수에 타입을 대입해야 한다.

FruitBox<Fruit> FruitBox = new FruitBox<Fruit>();
...
System.out.println(Juicer.<Fruit>makeJuice(FruitBox));
System.out.println(Juicer.makeJuice(FruitBox)); // 생략가능

제네릭 타입의 제거

컴파일러는 제네릭 타입을 이용해서 소스파일을 체크하고 필요한곳에 형변환을 넣어준다. 그리고 제네릭 타입을 제거한다. 즉, 컴파일된 파일(*.class)에는 제네릭 타입에 대한 정보가 없는 것이다.

일단 제거 과정을 보자.

1. 제네릭 타입의 경계 제거

class Box<T extends Fruit>{
	void add(T t){
		...	
	}
}

이 코드가

class Box{
	void add(Fruit t){
		...
	}
}

이렇게 변한다. <T extends Fruit> 의 T 가 Fruit로 치환되면서 클래스 옆의 선언은 제거된다. 그냥 였다면, Object 로 치환된다.

2. 타입이 일치하지 않으면 형변환

T get(int i){
	return list.get(i);
}

List 의 get 은 Object를 반환하므로, 형변환이 필요하다.

Fruit get(int i){
	return (Fruit)list.get(i);
}

와일드 카드가 포함된 경우, 적절한 타입으로 형변환이 추가된다.

static Juice makeJuice(FruitBox<? extends Fruit> box){
	String tmp ="";
	for(Fruit f : box.getList()) tmp += f + "";
	return new Juice(tmp);
}

👇

static Juice makeJuice(FruitBox box){
	String tmp ="";
	iterator it = box.getList().iterator();
	while(it.hasNext()){
		tmp += (Fruit)it.next() + "";
	}
	return new Juice(tmp);
}

🤷‍♀️ 이짓을 왜할까?

앞서 말했듯이 제네릭은 JDK 1.5부터 도입되었다. 그 말인 즉, JDK 1.4까지 작성했던 코드는 제네릭이 존재하지 않는다는 얘기다.
그래서 제네릭이 도입되기 전의 소스코드와의 호환성을 위해서 타입소거를 하는것이다. 처음 언어를 설계할 때 제네릭이라는 개념을 집어넣지 않은 자바로서는 어쩔 수 없는 선택이다. 제네릭이 1.5에 도입되었다고 1.4까지의 코드를 모른체 할 순 없으니까.


참고자료:
남궁성, 자바의 정석
https://mangkyu.tistory.com/241
https://velog.io/@ohzzi/Generic-1-Generic-who-are-you#generic-who-are-you

0개의 댓글