제네릭스(Generics)
제네릭스란 JDK 1.5부터 도입한 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법이다. 다양한 타입의 객체들을 다루는 메서드나
컬렉션 클래스
에 컴파일 시의 타입 체크
를 해주는 기능이다.
제네릭스가 필요한 이유는 객체의 타입을 컴파일 시에 체크하기 때문에 객체의 타입 안정성을 높이고 형변환의 번거로움이 줄어든다. 타입 안정성을 높인다는 것은 의도하지 않은 타입의 객체가 저장되는 것을 막고, 저장된 객체를 꺼내올 때 원래의 타입과 다른 타입으로 잘못 형변환되어 발생할 수 있는 오류를 줄여준다는 뜻이다.
장점
- 타입 안정성을 제공한다.
- 타입체크와 형변환을 생략할 수 있으므로 코드가 간결해 진다.
즉 객체의 타입을 미리 명시해줌으로써 번거로운 형변환을 줄여준다는 이야기이다.
지네릭 클래스의 선언
지네릭 타입은 클래스와 메서드에 선언할 수 있다.
class Box<T> {
T item;
void setItem(T item) { this.item = item; }
T getItem( return item; )
}
지네릭 클래스가 된 클래스의 객체를 생성할 때는 다음과 같이 참조변수와 생성자에 타입 T대신에 사용될 실제 타입을 지정해주어야 한다.
Box<String> b = new Box<String>(); // 타입 T 대신, 실제 타입을 지정
b.setItem(new Object()) // 에러, String이외에 타입은 지정불가
b.setItem("ABC") // ok
String item = b.getItem(); // (String)b.getItem(); 처럼 형변환이 필요없음
컴파일 후에 Box
즉 지네릭 타입이 제거된다. 지네릭 타입의 제거에서 자세히 다뤄보자
지네릭이 도입되기 이전의 코드와 호환을 위해, 지네릭 클래스인데도 예전의 방식으로 객체를 생성하는 것이 허용된다. 다만 지네릭 타입을 지정하지 않아서 경고가 발생한다.
Box b = new Box(); // ok, T는 Object로 간주
b.setItem("ABC"); // 경고, uncheck or unsafe operation
b.setItem(new Object()); // 경고, uncheck or unsafe operation
타입 파라미터 컨벤션
제네릭에서 사용하는 타입 파라미터에 자주 봤던 T 같은 문자가 아니고 아무런 문자나 넣어도 코드가 동작하는 데는 문제가 없다.
하지만 타입 파라미터에도 컨벤션이 존재한다. 컨벤션을 왜 지켜야 하는지는 다들 잘 아실 것이다. 기억이 안 난다면 Code Conventions for the Java Programming Language 글의 Why Have Code Conventions 부분을 보자.
그래서 타입 파라미터 컨벤션은 아래와 같다.
제네릭 클래스나 메서드를 구현할 일이 있다면 컨벤션에 맞춰서 구현하자!
지네릭스의 제한
제네릭스 클래스의 객체를 생성할 때, 객체별로 다른 타입을 지정하는 것은 적절하다. 하지만 모든 객체에 대해 동일하게 동작해야하는 static 멤버에 타입 변수 T를 사용할 수 없다. T는 인스턴스 변수로 간주되고, static 멤버는 타입 변수에 지정된 타입, 즉 대입된 타입의 종류에 관계없이 동일한 것이어야 하기 때문이다.
class Box<T> {
T[] itemArr; // ok, T타입의 배열을 위한 참조변수
T[] toArray() {
T[] tmpArr = new T[itemArr.length]; // error, 지네릭 배열 생성불가
...
}
}
또, 지네릭 배열 타입의 배열을 생성하는 것도 허용되지 않는다.
지네릭 배열 타입의 참조변수를 선언하는 것은 가능하지만 new T[10]과 같이 배열을 생성하는 것은 안된다. 생성할 수 없는 이유는 new 연산자 때문, 이 연산자는 컴파일 시점에 타입 T가 뭔지 정확히 알아야 한다.
그런데 위의 코드는 정의된 Box
꼭 지네릭 배열을 생성해야할 필요가 있을 때는 new 연산자 대신 Reflection API의 newInstance()와 같이 동적으로 객체 생성하거나 Object 배열을 생성해서 복사한 다음에 T[]로 형변환 하는 방법이 존재한다.
지네릭 클래스의 객체 생성과 사용
class Box<T> {
ArrayList<T> list = new ArrayList<T>();
void add(T item) { list.add(item); }
T get(int i) { return list.get(i); }
ArrayList<T> getList() { return list; }
int size() { return list.size(); }
public String toString() { return list.toString(); }
}
Box
Box<Apple> appleBox = new Box<Apple>(); // ok
Box<Apple> appleBox = new Box<Grape>(); // error
Apple이 Fruit의 자손이라고 가정해보자 하지만 그럼에도 error이다.
Box<Fruit> appleBox = new Box<Apple>(); // error, 대입된 타입이 다르다.
단, 두 지네릭 클래스의 타입이 상속관계에 있고, 대입된 타입이 같은 것은 괜찮다. FruitBox는 Box의 자손이라고 가정
Box<Apple> appleBox = new FruitBox<Apple>(); // ok, 다형성
와일드 카드
제네릭 클래스가 아닌 클래스에 static 메서드의 매개변수로 특정 타입을 지정해줬을 때, 제네릭 타입을 특정 타입으로 고정해 놓으면 다른 타입의 객체가 메서드의 매개변수가 될 수 없으므로 여러 가지 타입의 매개변수를 갖는 메서드를 만들어야 한다.
그러나 이와 같이 오버로딩하면, 컴파일 에러가 발생한다. 제네릭 타입이 다른 것만으로는 오버로딩이 성립하지 않기 때문이다. 제네릭 타입은 컴파일러가 컴파일할 때만 사용하고 제거해버린다. 따라서 위 설명과 같은 경우에 메서드들은 오버로딩이 아니라 ‘메서드 중복 정의’가 된다.
이럴 때 사용하기 위해 고안된 것이 ‘와일드 카드’이다. 와일드 카드는 기호 ?
로 표현하며, 어떠한 타입도 될 수 있다.
?
만으로는 Object타입과 다를 게 없으므로, 다음과 같이 상한(upper bound)과 하한(lower bound)을 제한할 수 있다.
< ? extends T > 와일드 카드의 상한 제한. T와 그 자손들만 가능
< ? super T > 와일드 카드의 하한 제한. T와 그 조상들만 가능
< ? > 제한 없음. 모든 타입이 가능. < ? extends Object > 와 동일(raw type)
제네릭 메서드
제네릭 메소드는 메서드의 선언부에 제네릭 타입이 선언된 메서드를 제네릭 메서드라 하며 선언된 제네릭으로 리턴 타입, 파라미터의 타입이 정해지는 메소드이다.
그리고 중요한 점이 제네릭 클래스가 아닌 일반 클래스 내부에도 제네릭 메서드를 정의할 수 있다. 그 말은, 클래스에 지정된 타입 파라미터와 제네릭 메서드에 정의된 타입 파라미터는 상관이 없다는 것이다.
즉, 제네릭 클래스에 전혀 상관이 없다
는 것을 의미한다.
class GenericClass<T> {
...
static <T> void sort(List<T> list, Comparator<? super T> c) {
...
}
}
위 코드에서 제네릭 클래스에 선언된 타입 매개변수 T와 제네릭 메서드 sort()에 선언된 타입 매개변수 T는 타입 문자만 같고 서로 다른 것이다. sort()가 static메서드이므로 타입 매개변수를 사용할 수 없지만, 메서드에 제네릭 타입을 선언하고 사용하는 것은 가능하다.
메서드에 선언된 제네릭 타입은 지역 변수를 선언한 것과 같다고 생각하면 된다. 이 타입 매개변수는 메서드 내에서만 지역적으로 사용될 것이므로 메서드가 static이건 아니건 상관이 없다.
| 참고 | 같은 이유로 내부 클래스에 선언된 타입 문자가 외부 클래스의 타입 문자와 같아도 구별될 수 있다.
제네릭 메서드를 호출할 때는 타입 변수에 타입을 대입해야 한다. 대부분의 경우 컴파일러가 타입을 추정할 수 있기 때문에 생략할 수 있다.
Erasure
제네릭은 타입의 안정성을 보장하며 실행시간에 오버헤드가 발생하지 않도록 하기 위해 추가 되었다. 컴파일러는 컴파일 시점에 제네릭에 대하여 type erasure(타입 이레이저)
라고 부르는 프로세스를 적용한다.
이렇게 하는 주된 이유는 지네릭이 도입되기 이전의 소스 코드와의 호환성을
유지하기 위해서이다.
타입 이레이저는 모든 타입의 파라미터들을 제거하고 나서 그 자리를 제한하고 있는 타입으로 변경하거나 타입 파라미터의 제한 타입이 지정되지 않았을 경우에는 Object로 대체한다. 따라서 컴파일 후에 바이트 코드는 새로운 타입이 생기지 않도록 보장하는 일반 클래스들과 인터페이스, 메소드들만 포함한다. Object 타입도 컴파일 시점에 적절한 캐스팅이 적용된다.
public <T> List<T> genericMethod(List<T> list) {
return list.stream().collect(Collectors.toList());
}
타입 이레이저가 적용되면서 특정 타입으로 제한되지 않은 T는 Object로 대체된다.
public List<Object> withErasure(List<Object> list) {
return list.stream().collect(Collectors.toList());
}
public List withErasure(List list) {
return list.stream().collect(Collectors.toList());
}
타입이 제한되어 있을 경우 그 타입은 컴파일 시점에 제한된 타입으로 교체된다.
public <T extends Building> void genericMethod(T t) {
...
}
위 코드는 컴파일 후 다음과 같이 변경된다.
public void genericMethod(Building t) {
...
}
브릿지 메서드
java compiler는 제네릭의 타입안정성을 위해 Bridge Method도 만들어낼 수있다.
Bridge Method는 java 컴파일러가 컴파일 할 때 메서드 시그니처가 조금 다르거나 애매할 경우에대비하여 작성된 메서드이다. 이 경우는 파리미터화된 클래스나 인터페이스를 확장한 클래스를 컴파일 할 때 생길 수 있다.타입안정성을 위해 Bridge Method를 만들수도 있다.
public class IntegerStack extends Stack<Integer> {
public Integer push(Integer value) {
super.push(value);
return value;
}
}
Java 컴파일러는 다형성을 제네릭 타입 소거에서도 지키기 위해, IntegerStack
의 push(Integer)
메서드와 Stack의 push(Object)
메서드 시그니처 사이에 불일치가 없어야 했다. 따라서 컴파일러는 런타임에 해당 제네릭 타입의 타입소거를 위한 Bridge 메서드를 만드는데 아래와같은 방식으로 만든다.
public class IntegerStack extends Stack {
// Bridge method generated by the compiler
public Integer push(Object value) {
return push((Integer) value);
}
public Integer push(Integer value) {
return super.push(value);
}
}
즉 extends Stack<Integer>
-> Stack
으로 변경한 것을 볼 수 있으며, push
에 parameter를 Object
가 아닌 Integer
로 맞추기 위한 도우미 메서드가 늘어났다는 것을 알 수 있다. 결과적으로 Stack
클래스의 push method는 타입소거를 진행한 후에, IntegerStack
클래스의 원본 push 방법을 사용하게 한다.
참고 출처
'java' 카테고리의 다른 글
[모던 자바 인 액션] 자바 8, 9, 10, 11에서 일어난 일 (0) | 2024.07.07 |
---|---|
static inner vs non-static inner class (0) | 2024.07.07 |
java 버전별 차이 & 특징 (0) | 2024.07.07 |
Object 클래스 (0) | 2024.07.07 |
String vs StringBuilder vs StringBuffer (0) | 2024.07.07 |