Generic
JDK1.5 부터 도입된 제너릭스는 다양한 타입의 객체를 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입체크를 해주어 타입 안정성을 높이고 형변환의 번거로움을 줄여줄 수 있는 최근에는 꼭 사용해야 할 기능이다. 이제는 공식문서를 볼 때 제너릭스를 알지 못하면 공식문서 분석도 버거울 정도이다. 실제로도 이러한 문제를 몸소 겪어서, 이번에 제너릭스에 대해 한 번 자세히 들여다보았습니다.
제너릭스는 다음과 같이 선언이 가능하다.
class Box<T> {
T item;
void setItem(T item) {
this.item = item;
}
}
기존의 보던 클래스와 다른 점을 살펴보면 제너릭 타입변수 T가 생긴 것을 볼 수 있다. 여기서 한 가지 의문을 가질 수 있는 경우는 T의 의미인데 f(x, y) = x + y 와 f(k, v) = k + v 가 같은 의미를 가진 것 처럼 단지 기호만 다를 뿐이다.
하지만 개발자에게 이러한 통일성은 중요하다. 따라서 Oracle은 몇 가지 컨벤션을 제시해주고 있었다.
Generics Convention
- E : Element
- K : Key
- V : Vaue
- T : Type
- N : Number
- (S, U, V) : 2nd, 3rd, 4th Types
- R : Return
위와 같이 기본적인 틀은 지키고 이외에도 필요하다면, 각 회사에서 컨벤션에 맞게 사용하면 될 것 같다.
Generic Types (The Java™ Tutorials > Learning the Java Language > Generics (Updated))
The Java Tutorials have been written for JDK 8. Examples and practices described in this page don't take advantage of improvements introduced in later releases and might use technology no longer available. See Java Language Changes for a summary of updated
docs.oracle.com
Generics 용어
Generics에 대해 자세히 알기전에 Generics의 용어를 먼저 아는 것이 좋을 것 같다. 다음과 같은 제너릭 클래스가 있다고 하자.
class Box<T>{}
- Box<T> : 제너릭 클래스, 'T의 Box' , 'T Box' 라고 부른다.
- T : 타입변수 or 타입 매개변수
- Box : 원시타입
이 제너릭 클래스를 호출했을 때의 기준으로도 살펴보자.
Box<String> b = new Box<String>();
- Box<String> : 제너릭 타입 호출
- <String> : 매개변수화된 타입, 대입된 타입
매개변수화된 타입이라는 용어가 길어서 실제로는 대입된 타입이라는 말을 많이 사용한다고 한다.
Generics의 제한
제너릭스는 타입체크와 형변환을 간단하게 해주는 대신에 몇 가지 제한이 있다. 첫번째로 중요한 것은 다음과 같다.
모든 객체에 동일하게 동작하는 static 멤버의 경우에는 타입변수를 사용할 수 없다.
끝까지 보면 알겠지만 꼭 그렇다고는 할 수 없는 것은 static 메서드의 경우 제너릭 메서드를 사용하게 되면 타입 변수를 지역 변수처럼 쓸 수 있어서 가능하긴 해서, 무조건 맞는 말은 아니다. 단, 여기서 말하고자 하는 타입변수는 클래스에 선언된 타입 변수를 사용할 때를 지칭하는 것 같다.
class Box<T> {
static T print() {
return T;
}
}
스태틱 메서드를 사용할 때에는 클래스명과 스태틱 메서드명을 바로 붙여서 사용할 수 있다. 스태틱 메서드는 JVM에 올라갈 때 컴파일러가 스태틱 메서드의 경우 데이터 영역에 저장해주기 때문인데, 스태틱 메서드는 해당 클래스의 모든 인스턴스에서 동일하게 동작해야 한다는 단점이 있다. 따라서 호출을 Box.print(); 로 호출을 할텐데, Box의 타입마다 다른 값이 나온다면 이는 스태틱 메서드의 본질에서 벗어난다.
두번째로, 제너릭 타입의 배열을 생성하는 것도 허용하지 않는다.
이 문제도, 메모리랑 관련이 있는 것인데, 배열을 생성하게 되면 해당 타입에 맞게 컴파일 당시 힙 영역에 그만한 메모리를 할당을 해주어야 한다. 그런데 타입변수의 배열은 어떤 타입이 들어오는지 컴파일러 입장에서는 체크를 할 수 없기 때문에, 사용할 수 없는 것이다.
하지만 꼭, 제너릭 타입의 배열을 생성해야 한다면, Reflection API 와 Object 배열을 만들어두고 T[]로 형변환 하는 방식이 있다.
private static final Optional<?> EMPTY = new Optional<>();
public static<T> Optional<T> empty() {
@SuppressWarnings("unchecked")
Optional<T> t = (Optional<T>) EMPTY;
return t;
}
다음과 같은 예시처럼 가능한 것이다. <?> 의 '?'는 와일드 카드인데 아래에서 무엇인지 자세하게 볼 수 있다.
제너릭 클래스의 객체 생성과 사용
다음과 같은 상속관계의 클래스들이 있다고 하자.
제너릭 객체 생성시에는 참조 변수의 대입된 타입과 생성자에 대입된 타입이 일치해야 한다.
Box<Apple> box = new Box<Apple>(); // o
Box<Apple> box = new Box<Grape>(); // x
Box<Fruit> box = new Box<Apple>(); // x
처음 봤을 때, 가장 아마 이해가 안될 부분은 3번째 예시일 것이다. 위의 상속관계를 보고
Apple 타입은 Fruit 타입의 자식 관계니까. 다형성으로 인해 가능 한 것 아닌가?
라는 의문이 들 수 있는데, 이후에 나올 바운디드 타입(제한된 타입) 을 사용해서 이를 가능하게 할 수 있다.
지금은 위와 같이 어떤 하나의 클래스 타입을 정확하게 명시했을 때에는 해당 클래스만 들어올 수 있다는 것이 중요한 포인트이다.
하지만 제너릭스에 그렇다고 자식 타입을 사용하지 못한다는 것은 아니다. 다음과 같은 예시는 가능하다.
class Box<T> {
ArrayList<T> list = new ArrayList<>();
void add(T item){list.add(item);}
T get(int i){return list.get(i);}
int size(){return list.size();}
@Override
public String toString() {return list.toString();}
}
class FruitBox <T> extends Box<T> {
}
public class Main {
FruitBox<Fruit> fruitBox = new FruitBox<>();
fruitBox.add(new Fruit());
fruitBox.add(new Apple());
fruitBox.add(new Grape());
}
위와 같은 상속 구조를 가지고 있는 클래스들이 있고, 이를 그대로 활용할 때 Fruit라는 대입된 타입이 있다면, 이 타입에 해당하는 매개변수에는 다형성 적용이 가능하기 때문에, 우리가 기존의 사용하던 방식이 그대로 가능하다.
추가적으로 new 연산자뒤에 제너릭스의 대입된 타입이 비어있는 것을 볼 수 있는데, 이는 JDK1.7 부터 타입 추론이 가능하다면, 생략이 가능해졌기 때문이다.
따라서, 다형성을 그대로 활용하여 다음과 같은 생성 또한 가능하다.
Box<Apple> box = new FruitBox<Apple>();
제너릭의 형변환이 아닌, 클래스의 상속관계를 이용한 참조변수의 형변환이다.
제너릭스 바운디드 타입(제한된 타입)
위에서 잠깐 언급했던 제너릭의 타입을 범위로 제한할 수 있다. 먼저 바운디드 타입을 사용한 모습을 살펴보자.
class FruitBox<T extends Fruit>{}
기존의 예시에서는 타입변수를 <T> 로 설정해두었기 때문에 타입변수에 Object도 들어올 수 있다는 문제점이 있었다.
그러나, 우리는 과일 상자에는 과일만이 담기기를 바랄것이다. 이럴 경우에 위처럼 바운디드 타입을 사용하여 범위를 제한 할 수 있다.
이제 우리는 바운디드 타입을 사용함으로서 FruitBox를 생성할 때, Fruit와 그 자손들만 생성할 수 있게 된 것이다.
여기서 주의할점은 바운디드 타입을 사용할 때, 어떠한 인터페이스를 구현한 클래스만 대입되게 하고싶을 때도 사용할 수 있는데,
class FruitBox<T extends Eatable> {}
위와 같이 인터페이스를 구현한 대입된 타입을 얻고자 할 때에도 implements 가 아닌 extends를 사용해야 한다.
추가로 Fruit를 상속하면서 Eatable이 구현된 클래스만 가져오고자 할 때에는 '&' 를 사용해 다중 선택이 가능하다.
class FruitBox<T extends Fruit & Eatable>{}
제너릭 와일드 카드
제너릭 타입은 기본적으로 오버로딩이 성립하지 않는다. 컴파일러 입장에서는 똑같은 매개변수 타입과 똑같은 매개변수 개수를 사용한 것이기 때문에, 제너릭 타입이 다르다고 해도 동일한 것으로 간주하기 때문이다. 하지만 이를 와일드 카드를 사용한다면 해결이 가능하다. 다음 예시를 살펴보자.
class Juicer {
static Juice makeJuice(FruitBox<Fruit> box) {
String tmp = "";
for(Fruit f : box.list) {
tmp += f;
}
return new Juice(tmp);
}
static Juice makeJuice(FruitBox<Apple> box) { // 컴파일 에러
String tmp = "";
for(Fruit f : box.list) {
tmp += f;
}
return new Juice(tmp);
}
}
class Juice {
String ingredient;
Juice() {
}
Juice(String ingredient) {
this.ingredient = ingredient;
}
}
위의 예시를 보면 알겠지만, 요구사항은 각 과일 상자별로 쥬스를 만들고 싶었다. 그러나, 위에서 얘기했듯이 똑같은 매개변수 타입과 개수를 사용한 것이기 때문에 오버로딩 되지 않고, 중복된 메서드로 컴파일 에러가 발생한다. 이어서 와일드 카드를 사용하여 해결한 예시를 살펴보자.
class Juicer {
static Juice makeJuice(FruitBox<? extends Fruit> box) {
String tmp = "";
for(Fruit f : box.list) {
tmp += f;
}
return new Juice(tmp);
}
}
class Juice {
String ingredient;
Juice() {
}
Juice(String ingredient) {
this.ingredient = ingredient;
}
}
와일드 카드를 사용하여 한 개의 메서드로 오버로딩 한 것과 같은 효과를 볼 수 있는데, 와일드카드는 다음과 같이 사용할 수 있기 때문이다.
- <? extends T> : 와일드카드의 상한을 제한한다. T와 그 자손들만 가능
- <? super T> : 와일드카드의 하한을 제한한다. T와 그 조상들만 가능
- <?> : 제한없음. 모든 타입이 가능 <? extends Object>와 동일
한 가지 특수한 상황에 대한 케이스를 한 번 살펴보자.
class Juicer {
static Juice makeJuice(FruitBox<? extends Object> box) {
String tmp = "";
for(Fruit f : box.list) {
tmp += f;
}
return new Juice(tmp);
}
}
class Juice {
String ingredient;
Juice() {
}
Juice(String ingredient) {
this.ingredient = ingredient;
}
}
class FruitBox<T extends Fruit> {}
다음과 같은 상황이 있을 때, makeJuice 메서드의 매개변수 타입에는 Fruit 타입이 아닌 다른 자식 타입들도 들어올 수 있어 오류가 발생할 것으로 예상이 되는데, 정상적으로 동작하는 것을 볼 수 있다. 이유는 FruitBox의 타입을 진작에 Fruit의 하위 타입들만 가능하도록 선언해두었기 때문인데, 컴파일러가 FruitBox의 타입 변수 명시를 보고 Fruit의 하위 타입만이 들어온다는 것을 알고 있기 때문에, 오류없이 수행이 가능해진 것이다.
제너릭 메서드
앞에서 언급했던 제너릭 메서드이다. 우리는 기존에 static 멤버에는 타입 변수를 사용할 수 없다고 했지만, 제너릭 메서드에 타입 변수를 명시하면 해당 타입 변수는 제너릭 메서드 내부에서 지역 변수 처럼 동작한다. 따라서 이전에 와일드카드를 사용한 메서드 작성을 다음과 같이 리팩토링 할 수 있다.
static Juice makeJuice(FruitBox<? extends Fruit> box) // 이전의 와일드카드 사용 메서드
static <T extends Fruit> Juice makeJuice(FruitBox<T> box) // 제너릭 메서드
한 눈에 봐도 굉장히 보기 깔끔해진 것을 볼 수 있다. 단, 주의해야할 점이 한 가지가 있다. 해당 부분은 제너릭 메서드를 사용할 때 나타난다.
제너릭 메서드를 사용할 때는 다음과 같이 사용한다.
FruitBox<Fruit> fruitBox = new FruitBox<>();
FruitBox<Apple> appleBox = new FruitBox<>();
Juicer.<Fruit>makeJuice(fruitBox);
Juicer.<Apple>makeJuice(appleBox);
다음과 같이 제너릭 메서드에 사용할 타입을 명시를 해주어야 하는데, 제너릭 메서드의 타입을 컴파일러가 추정할 수 있을 때에는 생략이 가능하다. 그러나, 생략이 불가능한 상황에서는 꼭 명시를 해주어야 하고, 그런 상황에서는 참조변수나 클래스 이름을 생략할 수 없다고 한다.
이 부분이 너무나도 궁금해서 찾아봤는데, 제너릭 메서드의 생략 불가능한 상황은 다음과 같다고 한다.
제네릭 메서드가 여러 개의 타입 매개변수를 사용하고, 각 매개변수의 타입이 서로 다른 경우, 타입 인수를 생략할 수 없습니다.
내가 잘못 이해한 것 같지만 직접 다음과 같은 메서드를 한 번 짜봤다.
Test.addSum(3, "3");
static <K, V> void addSum(K a, V b) {
System.out.println(a + " " + b);
}
말 그대로 여러 개의 타입 매개 변수를 사용했고, 각 매개변수의 타입이 서로 다른 경우... 그대로 짜봤는데 생략을 해도 정상적으로 동작했다. ㅠㅠ 혹시 이 글을 보고 해당 예외 사례에 대해서 설명해주시거나 포스팅 링크를 걸어주시면 감사할 것 같다..!
이어서, 제너릭 메서드를 공부하다가 나는 다음과 같은 생각이 들었다.
뭐야, 그럼 와일드카드를 안써도 되겠네. 그냥 제너릭 메서드 선에서 전부 해결되는 것 아닌가?
하지만 와일드카드는 제너릭 타입의 형변환에서 아주 큰 힘을 발휘하는 것을 볼 수 있었는데, 다음 파트에서 설명한다.
제너릭 타입의 형변환
제너릭 타입과 None-제너릭타입의 형변환은 항상 가능하다고 한다. 하지만 경고가 발생한다! 이는 JDK 1.5 이전 코드의 호환성때문에 남겨둔 것이라고 하는데, 웬만하면 제너릭을 사용하는 것을 습관화 하는 것이 좋겠다.
또, 이어서 다음과 같은 상황에도 형변환이 가능하다.
Box<? extends Object> wBox = new Box<String>();
Box에 들어올 수 있는 타입 변수는 Object를 포함한 Object의 자식들이다. 뭔가 느낌이 오지 않는가? 와일드카드를 사용하면 마치 다형성을 사용했던 것처럼 형변환이 가능해졌다!
그리고 반대의 경우에도 형변환이 가능한데, 대신에 와일드카드에 어떤 타입이 들어올지 컴파일러에 입장에서는 모른다. 따라서, 형변환 경고가 발생을 한다. 이러한 와일드 카드의 장점을 잘 살린 라이브러리를 Optional 클래스에서 찾아볼 수 있었다.
private static final Optional<?> EMPTY = new Optional<>();
public static<T> Optional<T> empty() {
@SuppressWarnings("unchecked")
Optional<T> t = (Optional<T>) EMPTY;
return t;
}
Optional 클래스의 일부이다. 어떤 타입이 들어오든지, EMPTY 상수는 와일드카드로 선언되어 있기 때문에, 어떤 타입이든지 형변환이 가능한 것을 볼 수 있다
결론적으로 제너릭 타입에서의 필수적인 형변환이 없거나 하는 경우에는 제너릭 메서드를 사용해서 메서드를 깔끔하게 볼 수 있게 하는 것이 더 좋을 것 같다. 다만 형변환이 필요하다면 와일드카드를 사용해서 가능하게 끔 하는 것이 옳은 방법이라고 생각한다.
번외로 실제로 컴파일 된 클래스 파일에 보면 우리가 사용했었던 제너릭 타입이 없는 것을 살펴볼 수 있는데, 이는 컴파일러가 제너릭 타입을 제거하기 때문이다. 그 과정을 마지막으로 살펴보자.
제너릭 타입의 제거
컴파일러는 제너릭 타입을 이용해서 소스 파일을 체크하고 필요한 곳에 타입을 대입하거나 형변환을 넣어준다. 이렇게 하는 이유는 위에서 잠깐 언급했지만 제너릭 타입이 없던 JDK1.5 버전 이전의 코드와의 호환성 때문이다.
제거 순서는 다음과 같다.
- 제너릭 타입의 경계를 제거한다. <T extends Fruit> 라면 T 는 Fruit로 치환하고, 만약 <T> 라면 Object로 타입 변수를 사용한 곳을 적절하게 치환하고 제거한다.
- 타입을 제거한 이후에 반환 타입이 일치하지 않는 경우에는 형변환을 추가해준다. 예시는 다음과 같다.
// 제너릭 타입 제거 전
T get(int i) {
return list.get(i);
}
// 제너릭 타입 제거 후
Fruit get(int i) {
return (Fruit)list.get(i);
}
여기까지 제너릭에 대해서 살펴봤다. 프로젝트를 하면서 제너릭에 대해서 언젠가 제대로 살펴봐야지. 라고 미루다가 정말 베이스가 중요하다는 생각이 들어서 이번 기회에 보게 되었다. 아직 해결 못한 부분이 남아있지만, 작업을 하다보면 제너릭 메서드에서 타입을 생략하지 않는 순간을 어느 날 발견할 수 있을 것이라고 본다. 그 때, 추가로 포스팅하고 피드백이 있다면 바로 반영하도록 하겠다!
Java의 정석 | 남궁성 - 교보문고
Java의 정석 | 자바의 기초부터 실전활용까지 모두 담다!자바의 기초부터 객제지향개념을 넘어 실전활용까지 수록한『Java의 정석』. 저자의 오랜 실무경험과 강의한 내용으로 구성되어 자바를
product.kyobobook.co.kr
'Language > Java' 카테고리의 다른 글
enum의 Enum 상수 객체의 변수 사용과 생성 (0) | 2023.04.12 |
---|---|
7-7 내부 클래스 (재업로드), 익명 클래스 (0) | 2023.03.29 |
4-2 자바 반복문 (0) | 2023.03.23 |
4-1 자바 조건문 (0) | 2023.03.23 |
7-6 다형성 (0) | 2023.03.23 |