- 제네릭 사용법
- 제네릭 주요 개념 (바운디드 타입, 와일드 카드)
- 제네릭 메소드 만들기
- Raw Type
- Erasure
제네릭이란
제네릭은 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입체크(compile-time type check)를 해주는 기능이다.
객체의 타입을 컴파일 시에 체크하기 때문에 객체의 타입 안정성을 높이고 형변환의 번거로움이 줄어든다.
타입 안정성을 높인다는 것은 의도하지 않은 타입의 객체가 저장되는 것을 막고, 저장된 객체를 꺼내올 때 원래의 타입과 다른 타입으로 잘못 형변환되어 발생할 수 있는 오류를 줄여준 다는 뜻이다.
제네릭의 장점
- 타입 안정성을 제공한다.
- 타입체크와 형변환을 생략할 수 있으므로 코드가 간결해 진다.
제네릭을 사용하는 이유
- 잘못된 타입이 사용될 수 있는 문제는 컴파일과정에서 사전에 방지할 수 있다.
- 실행 시 타입 에러가 나는 것보다 컴파일 과정에서 미리 타입을 강하게 체크해서 사전에 에러를 방지할 수 있다.
- 제네릭을 사용하여 타입을 국한하기 때문에 타입 변환을 할 필요가 없어 프로그램 성능이 향상된다.
1. 제네릭 사용법
제네릭 타입은 클래스와 메서드에 선언할 수 있는데, 먼저 클래스에 선언하는 제네릭 타입이다.
예를들어 클래스 Box가 다음과 같이 정의되어 있다고 가정하자.
class Box{
Object item;
void setItem(Object item) {this.item = item;}
Object getItem() {return item;}
}
이 클래스를 지네릭 클래스로 변경하면 다음과 같이 클래스 옆에 '' 를 붙이면 된다. 그리고 'Object' 를 모두 'T' 로 바꾼다.
class Box<T>{ //지네릭 타입 T를 선언
T item;
void setItem(T item) { this.item = item; }
}
Box 에서 T를 '타입 변수(type variable)' 라고 하며, 'Type'의 첫 글자에서 따온 것이다. 타입 변수는 T가 아닌 다른 것을 사용해도 된다. ArrayList의 경우, 타입 변수 E는 'Element(요소)' 의 첫 글자를 따서 사용했다.
타입 변수가 여러 개인 경우에는 Map<K,V>와 같이 콤마 ','를 구분자로 나열하면 된다. K는 Key(키)를 의미하고, V는 Value(값)을 의미한다.
무조건 'T'를 사용하기보다 가능하면, 이 처럼 상황에 맞게 의미있는 문자를 서택해서 사용하는 것이 좋다.
이들은 기호의 종류만 다를 뿐 '임의의 참조형 타입'을 의미한다는 것은 모두 같다.
기존에는 다향한 종류의 타입을 다루는 메서드의 매개변수나 리턴타입으로 Object타입의 참조변수를 많이 사용했고, 그로 인해 형변환이 불가피했지만, 이젠 Object타입 대신 원하는 타입을 지정하기만 하면 되는 것이다.
이제 제네릭 클래스가 된 Box클래스의 객체를 생성할 때는 다음과 같이 참조변수와 생성자에 타입 T대신에 사용될 실제 타입을 지정해주어야 한다.
Box<String> b = new Box<String>(); //타입 T대신, 실제 타입을 지정
b.setItem(new Object()); //에러. String이외의 타입은 지정불가
b.setItem("ABC"); //Ok. String 타입이므로 가능
String item = b.getItem(); //형변환이 필요없음
위의 코드에서 타입 T대신에 String타입을 지정해줬으므로, 제네릭 클래스 Box는 다음과 같이 정의된 것과 같다.
class Box { //제네릭 타입을 String으로 지정
String item;
void setItem(String item) { this.item = item; }
String getItem() { return item; }
}
제네릭이 도입되기 이전의 코드와 호환을 위해, 제네릭 클래스인데도 예전의 방식으로 객체를 생성하는 것이 허용된다. 다만 제네릭 타입을 지정하지 않아서 안전하지 않다는 경고가 발생한다.
Box b = new Box(); //Ok. T는 Object로 간주된다.
b.setItem("ABC"); //경고. unchecked or unsafe operation
b.setItem(new Object()); //경고. unchecked or unsafe operation
아래와 같이 타입 변수 T에 Object타입을 지정하면, 타입을 지정하지 않는 것이 아니라 알고 적은 것이므로 경고는 발생하지 않는다.
Box<Object> b = new Box<Object>();
b.setItem("ABC"); //경고발생 안함
b.setItem(new Object()); //경고발생 안함
제네릭이 도임되기 이전의 코드와 호환성을 유지하기 위해서 제네릭을 사용하지 않는 코드를 허용하는 것일 뿐, 앞으로 제네릭 클래스를 사용할 때는 반드시 타입을 지정해서 제네릭과 관련된 경고가 나오지 않도록 하자.
제네릭의 용어
다음과 같이 제네릭 클래스 Box가 선언되어 있을 때
class Box<T> {}
Box<T> //제네릭 클래스. 'T의 Box'또는 'T Box'라고 읽는다.
T //타입 변수 또는 타입 매개변수 (T는 타입문자)
Box //원시타입 (raw type)
타입 문자 T는 제네릭 클래스 Box의 타입 변수 또는 타입 매개변수라고 하는데, 메서드의 매개변수와 유사한 면이 있기 때문이다. 그래서 아래와 같이 타입 매개변수에 타입을 지정하는 것을 '제네릭 타입 호출' 이라고 하고, 지정된 타입 'String'을 '매개변수화된 타입(parameterized type)'이라고 한다.
매개변수화된 타입이라는 용어가 좀 길어서, 앞으로 이 용어 대신 '태입된 타입'이라는 용어를 사용할 것이다.
Box<String> b = new Box<String>();
예를 들어, Box과 Box 는 제네릭 클래스 Box에 서로 다른 타입을 대입하여 호출한 것일 뿐, 이 둘이 별개의 클래스를 의미하는 것은 아니다.
이는 마치 매개변수의 값이 다른 메서드 호출, 즉 add(3,5)와 add(2,4)가 서로 다른 메서드를 호출하는 것이 아닌 것과 같다.
컴파일 후에 Box과 Box는 인들의 '원시타입'인 Box로 바뀐다.
즉, 제네릭 타입이 제거된다. 이에 대해서는 '1.8 제네릭 타입의 제거'에서 자세히 설명한다.
제네릭의 제한
제네릭 클래스 Box의 객체를 생성할 때, 객체별로 다른 타입을 지정하는 것은 적절하다. 제네릭은 이처럼 인스턴스별로 다르게 동작하도록 하려고 만든 기능이니까.
Box<Apple> appleBox = new Box<Apple>(); //Ok. Apple객체만 저장가능
Box<Grape> grapeBox = new Box<Grape>(); //ok. Grape객체만 저장가능
그러나 모든 객체에 대해 동일하게 동작해야하는 static멤버에 타입 변수 T를 사용할 수 없다.
T는 인스턴스변수로 간주되기 때문이다. 이미 알고 있는 것처럼 static 멤버는 인스턴스변수를 참조할 수 없다.
class Box<T> {
static T itme; //에러
static int compare(T t1, T t2) {...} //에러
...
}
2. 제네릭 주요 개념
바운디드 제네릭 (Bounded Gnerics)
타입 파라미터들은 바운드(bound) 될 수 있다. 바운드 된다는 의미는 제한된다는 의미인데 메소드가 받을 수 있는 타입을 제한 할 수 있다는 것이다.
예를들면, 어떤 타입과 그 타입의 모든 서브 클래스들을 허용하거나 어떤 타입과 그 타입의 모든 부모클래스들을 허용하도록 메소드를 작성할 수 있다.
public <T extends Number> List<T> fromArrayToList(T[] a){
...
}
위의 코드에서 extends 키워드는 클래스의 경우 타입 T 가 상위클래스를 상속받은 타입만 허용 한다는 의미이며, 인터페이스의 경우에는 상위 인터페이스를 구현 하는 타입을 허용한다는 의미이다.
와일드 카드
매개변수에 과일박스를 대입하면 주스를 만들어서 반환하는 Juicer라는 클래스가 있고, 이 클래스에는 과일을 주스로 만들어서 반환하는 makeJuice()라는 static메서드가 다음과 같이 정의되어 있다고 가정하자.
class Juicer{
static Juice makeJuice(FruitBox<Fruit> box){ //<Fruit>으로 지정
String tmp = "";
for(Fruit f : box.getList()) tmp += f + "";
return new Juice(tmp);
}
}
Juicer클래스는 제네릭 클래스가 아닌데다, 제네릭 클래스라고 해도 static메서드에는 타입 매개변수 T를 매개변수에 사용할 수 없으므로 아예 제네릭을 적용하지 않던가, 위와 같이 타입 매개 변수 대신 , 특정 타입을 지정해줘야 한다.
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox = new FruitBox<Apple>();
...
System.out.println(Juicer.makeJuice(fruitBox)); //OK. FruitBox<Fruit>
System.out.println(Juicer.makeJuice(appleBox)); //에러. FruitBox<Apple>
이렇게 제네릭 타입을 'FruitBox' 로 고정해 놓으면, 위의 코드에서 알 수 있듯이 'FruitBox' 타입의 객체는 makeJuice()의 매개변수가 될 수 없으므로, 다음과 같이 여러 가지 타입의 매개변수를 갖는 makeJuice()를 만들 수 밖에 없다.
static Juice makeJuice(FruitBox<Fruit> box) {
String tmp = "";
for(Fruit f : box.getList()) tmp += f + "";
return new Juice(tmp);
}
static Juice makeJuice(FruitBox<Apple> box){
String tmp = "";
for(Fruit f : box.getList()) tmp += f + "";
return new Juice(tmp);
}
그러나 위와 같이 오버로딩하면, 컴파일 에러가 발생한다. 제네릭 타입이 다른 것만으로는 오버로딩이 성립하지 않기 때문이다.
제네릭 타입은 컴파일러가 컴파일할 때만 사용하고 제거해버린다.
그래서 위의 두 메서드는 오버로딩이 아니라 '메서드 중복 정의' 이다.
이럴 때 사용하기 위해 고안된 것이 '와일드 카드'이다.
와일드 카드는 기호 '?' 로 표현하는데, 와일드 카드는 어떠한 타입도 될 수 있다.
'?' 만으로는 Object 타입과 다를 게 없으므로, 다음과 같이 'extends'와 'super'로 상한과 하한을 제한할 수 있다.
<? extends T> //와일드 카드의 상한 제한. T와 그 자손들만 가능
<? super T> //와일드 카드의 하한 제한. T와 그 조상들만 가능
<?> //제한 없음. 모든 타입이 가능 <? extends Object>와 동일
와일드 카드를 사용해서 makeJuice() 의 매개변수 타입을 FruitBox에서 FruitBox< ? extends Fruit>으로 바꾸면 다음과 같이 된다.
static Juice makeJuice(FruitBox<? extends Fruit> box){
String tmp = "";
for(Fruit f : box.getList()) tmp += f + "";
return new Juice(tmp);
}
이제 이 메서드의 매개변수로 FruitBox 뿐만 아니라, FruitBox와 FruitBox 도 가능하게 된다.
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox = new FruitBox<Apple>();
...
System.out.println(Juicer.makeJuice(fruitBox)); //OK. FruitBox<Fruit>
System.out.println(Juicer.makeJuice(appleBox)); //OK. FruitBox<Apple>
매개변수의 타입을 FruitBox<? extends Object>로 하면, 모든 종류의 FruitBox가 이 메서드의 매개변수로 가능해 진다. 대신 , 전과 달리 box의 요소가 Fruit의 자손이라는 보장이 없으므로 아래의 for문에서 box에 저장된 요소를 Fruit타입의 참조변수로 못 받는다.
static Juice makeJuice(FruitBox<? extends Object> box){
String tmp = "";
for(Fruit f : box.getList()) tmp += f + ""; //에러 Fruit이 아닐 수 있음
return new Juice(tmp);
}
그러나 실제로 테스트 해보면 문제없이 컴파일되는데 그 이유는 바로 제네릭 클래스 FruiteBox를 제한 했기 때문이다.
class FruitBox<T extends Fruit> extends Box<T> {}
컴파일러는 위 문장으로부터 모든 FruitBox의 요소들이 Fruit의 자손이라는 것을 알고 있으므로 문제 삼지 않는 것이다.
3. 제네릭 메소드 만들기
메서드 선언부에 제네릭 타입이 선언된 메서드를 제네릭 메서드라 한다. Collections.sort()가 바로 제네릭 메서드이며, 제네릭 타입의 선언 위치는 반환 타입 바로 앞이다.
static <T> void sort(List<T> list, Comparator<? super T> c)
제네릭 클래스에 정의된 타입 매개변수와 제네릭 메서드에 정의된 타입 매개변수는 전혀 별개의 것이다.
같은 타입 문자 T를 사용해도 같은 것이 아니라는 것에 주의해야 한다.
//제네릭 메서드는 제네릭 클래스가 아닌 클래스에도 정의될 수 있다.
class FruitBox<T> {
...
static <T> void sort(List<T> list, Comparator<? suprt T> c){
...
}
}
위의 코드에서 제네릭 클래스 FruitBox에 선언된 타입 매개변수 T와 제네릭 메서드 sort()에 선언된 타입 매개변수 T는 타입 문자만 같을 뿐 서로 다른 것이다.
그리고 sort()가 static 메서드라는 것에 주목하자. 앞서 설명한 것처럼, static 멤버에는 타입 매개변수를 사용할 수 없지만, 이처럼 메서드에 제네릭 타입을 선언하고 사용하는 것은 가능하다.
메서드에 선언된 제네릭 타입은 지역 변수를 선언한 것과 같다고 생각하면 이해하기 쉬운데, 이 타입 매개변수는 메서드 내에서만 지역적으로 사용될 것이므로 메서드가 static이건 아니건 상관이 없다.
앞서 나왔던 makeJuice()를 제네릭 메서드로 바꾸면 다음과 같다.
static <T extends Fruit> Juice makeJuice(FruitBox<T> box){
String tmp = "";
for(Fruit f : box.getList()) tmp += f +"";
return new Juice(tmp);
}
이제 이 메서드를 호출할 때는 아래와 같이 타입 변수에 타입을 대입해야 한다.
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox = new FruitBox<Apple>();
...
System.out.println(Juice.<Fruit>makeJuice(fruitBox));
System.out.println(Juice.<Apple>makeJuice(appleBox));
그러나 대부분의 경우 컴파일러가 타입을 추정할 수 있기 때문에 생략해도 된다. 위의 코드에서도 fruitBox와 appleBox의 선언부를 통해 대입된 타입을 컴파일러가 추정할 수 있다.
System.out.println(Juice.makeJuice(fruitBox)); //대입된 타입을 생략할 수 있다.
System.out.println(Juice.makeJuice(appleBox));
한가지 주의할 점은 제네릭 메서드를 호출할때, 대입된 타입을 생략할 수 없는 경우에는 참조변수나 클래스 이름을 생략할 수 없다는 것이다.
System.out.println(<Fruit>makeJuice(fruitBox)); //에러. 클래스 이름 생략불가
System.out.println(this.<Fruit>makeJuice(fruitBox));
System.out.println(Juice.<Fruit>makeJuice(fruitBox));
같은 클래스 내에 있는 멤버들끼리는 참조변수나 클래스이름, 즉 'this'이나 '클래스이름'을 생략하고 메서드 이름만으로 호출이 가능하지만,
대입된 타입이 있을때는 반드시 써줘야 한다.
제네릭 메서드는 매개변수의 타입이 복잡할 때도 유용하다. 만일 아래와 같은 코드가 있다면 타입별로 선언함으로써 코드를 간략히 할 수 있다.
public static void printAll(ArrayList<? extends Product> list,
ArrayList<? extends Product> list2 ){
for(Unit u : list){
System.out.println(u);
}
}
에서
public static <T extends Product> void printAll(ArrayList<T> list, ArrayList<T> list2){
for(Unit u : list){
System.out.println(u);
}
}
로 간략히 할 수 있다.
4. 자바의 제네릭과 로타입( Raw Type )
public class<T> Example{
private T member;
}
위와 같이 클래스 및 인터페이스 선언에 타입 매개변수 T 가 사용되면 제네릭 클래스, 제네릭 인터페이스라고 말하는데, 이때 사용된 이 클래스 Example<T> 를 제네릭 타입이라고 이야기 한다.
제네릭을 사용하면 Raw Type이라는 개념이 나오는데, 로타입은 제네릭 타입에서 타입 매개변수를 전혀 사용하지 않았을 때를 의미한다. 즉, 위 제네릭 타입Example<T>를 Example로만 선언하여 사용했을 경우를 말한다.
public class Example<T> {
private T member;
public Example(T member) {
this.member = member;
}
public static void main(String[] args){
Example<Integer> parameterType = new Example<>(1);
Integer parameterTypeMember = parameterType.member;
System.out.println(parameterTypeMember);
Example rawType = new Example(1);
Object rawTypeMember = rawType.member;
System.out.println(rawTypeMember);
}
}
위 코드는 제네릭 파라미터 타입과 로타입을 사용한 경우이다. 하지만 로타입은 사용하지말자.
제네릭의 장점은 컴파일 타임에 타입에 대한 안정성을 보장받을 수 있다는 점이다. 제네릭 타입으로 선언한 변수는 컴파일 타임에 타입체크를 하기 때문에 런타임에서 ClassCastException 과 같은 UncheckedException을 보장 받을 수 있다.
반면 아래와 같이 로타입으로 사용될 경우에는 제네릭을 사용했을 때의 안정성과 표현력이라는 장점을 발휘할 수 없기 때문에, IDE 에서도 "Raw use of parameterized class 'Example' " 라는 경고를 주는 것을 볼 수 있다.
로타입이 나오게 된 이유는 제네릭의 특징인 소거(Erasure)와 관련이 있다.
5. Erasure
제네릭 타입 이레이져란 ?
소거란 원소 타입을 컴파일 타입에만 검사하고 런타임에는 해당 타입 정보를 알 수 없는 것입니다.
한마디로, 컴파일 타임에만 타입 제약 조건을 정의하고, 런타임에는 타입을 제거한다는 뜻입니다.
Java 컴파일러의 타입 소거
- unbounded Type(<?>, ) 는 Object로 변환합니다.
- bound type()의 경우에는 Object가 아닌 Comparable로 변환합니다.
- 제네릭 타입을 사용할 수 있는 일반 클래스, 인터페이스, 메소드에만 소거 규칙을 적용합니다.
- 타입 안정성 보존을 위해 필요하다면 type casting을 넣습니다.
- 확장된 제네릭 타입에서 다형성을 보존하기 위해 bridge method를 생성합니다.
예제를 보겠습니다.
unbouned type
// 컴파일 할 때 (타입 소거 전)
public class Test<T> {
public void test(T test) {
System.out.println(test.toString());
}
}
// 런타임 때 (타입 소거 후)
public class Test {
public void test(Object test) {
System.out.println(test.toString());
}
}unbouned type
unbouned type에 대해서는 위와 같이 Object로 바꾸게 됩니다.
bound type
public class Test<T extends Comparable<T>> {
private T data;
public T getData() {
return data;
}
public void setData(T data){
this.data = data;
}
}
public class Test{
private Comparable data;
public Comparable getData(){
return data;
}
public void setData(Comparable data){
this.data = data;
}
}
bound type에 대해서는 Object가 아닌 한정시킨 타입으로 변환이 됩니다. 그리고 세 번째 규칙이 있습니다.
세 번째 규칙은 자바 컴파일러가 제네릭의 타입 안정성을 위해 bridge method도 만들어 낼 수 있다는 것입니다.
public class MyComparator implements Comparator<Integer> {
public int compare(Integer a, Integer b){
//
}
}
런타임에는 코드가 다음과 같이 변합니다.
public class MyComparator implements Comparator{
public int compare(Integer a, Integer b){
//
}
}
소거된 상태로 변할것이고, Comparator의 compare 메소드의 매개변수 타입은 Object로 바뀔 것입니다.
java 컴파일러는 제네릭의 타입 안정성을 위해 Bridge Method도 만들어낼 수 있다. Bridge Mothod는 java 컴파일러가 컴파일 할 때 메서드 시그니처가 조금 다르거나 애매할 경우에 대비하여 작성된 메서드 이다.
이 경우는 파라미터화된 클래스나 인터페이스를 확장한 클래스를 컴파일 할 때 생길 수 있다.
public class MyComparator implements Comparator<Integer> {
public int compare(Integer a, Integer b) {
//
}
//THIS is a "bridge method"
public int compare(Object a, Object b) {
return compare((Integer)a, (Integer)b);
}
}
그러면 매개변수가 Integer 타입의 compare 메소드를 사용할 수 있게 됩니다.
참고 문헌
JAVA의 정석
참조: https://sthwin.tistory.com/22
'JAVA' 카테고리의 다른 글
JAVA 버전별 특징(8, 10, 11) (2) | 2021.05.25 |
---|---|
15주차 : 람다식(Lambda) (0) | 2021.03.14 |
13주차 : I/O (0) | 2021.03.01 |
12주차 : Annotation (애노테이션) (0) | 2021.02.07 |
11주차 : enum (0) | 2021.02.07 |