[자바의 정석] 12. 지네릭스, 열거형, 애너테이션 (1) 지네릭스 (Generics)

jyleever·2024년 2월 4일
0

자바의 정석

목록 보기
12/12
post-thumbnail

1. Generics

1.1 지네릭스란?

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

  1. 타입 안정성을 제공한다.
  2. 타입 체크와 형변환을 생략할 수 있으므로 코드가 간결해진다.

1.2 지네릭 클래스의 선언

class Box<T>{
	T item;
    void setItem(T item) {
    	this.item = item;
    }
    T getItem() {
    	return item;
    }
}

Box<T>에서 T를 타입 변수라고 하며 타입 변수는 T가 아닌 다른 것을 사용해도 된다.
ex) ArrayList<E> - Element
Map<K, V> - Key, Value

  • 상황에 맞게 의미있는 문자를 선택해서 사용하는 것이 좋다.
    이들은 기호의 종류만 다를 뿐 임의의 참조형 타입을 의미한다는 것은 모두 같다.
  • 지네릭 클래스가 된 Box클래스의 객체를 생성할 때는 다음과 같이 참조변수와 생성자에 타입 T대신에 사용될 실제 타입을 지정해야 한다.
Box<String> b = new Box<String>(); // 타입 T대신 실제 타입 지정
b.setItem(new Object()); // 에러!
b.setItem("ABC"); // 오케
String item = b.getItem(); // 형변환 필요 없음

지네릭스의 용어

class Box<T> {}

Box<T> : 지네릭 클래스. T의 Box 또는 T Box 라고 읽는다.
T : 타입 변수 또는 타입 매개변수 (T는 타입 문자)
Box : 원시 타입(raw type)

Box<String> b = new Box<String<();

Box<String> : 지네릭 타입 호출
String : 대입된 타입 (매개변수화된 타입)

컴파일 후에 Box<String> 과 Box<Integer>는 이들의 원시 타입인 Box로 바뀐다. 즉, 지네릭 타입이 제거된다. -> 1.8 지네릭 타입의 제거

지네릭스의 제한

지네릭 클래스의 객체를 생성할 때 객체 별로 다른 타입을 지정하는 것은 적절하다. 지네릭스는 이처럼 인스턴스별로 다르게 동작하도록 하려고 만든 기능이기 때문이다.
하지만 몇 가지 제한이 있다.

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

  2. 지네릭 타입의 배열을 생성하는 것도 허용되지 않는다.
    지네릭 배열 타입의 참조변수를 선언하는 것은 가능하지만, new T[10] 과 같이 배열을 생성하는 것은 안 된다는 뜻이다.
    new 연산자 때문인데, new 연산자는 컴파일 시점에 타입 T가 뭔지 정확히 알아야 한다. 그런데 위 코드에 정의된 Box<T> 클래스를 컴파일하는 시점에서는 T가 어떤 타입이 될지 전혀 알 수 없다. instanceof 연산자도 new 연산자와 같은 이유로 T가 피연산자로 사용할 수 없다.

꼭 지네릭 배열을 생성해야할 필요가 있을 때는
(1) new 연산자 대신 Reflection APInewInstance()와 같이 동적으로 객체를 생성하는 메서드로 배열 생성
(2) Object 배열을 생성해 복사한 다음에 T[]로 형변환

1.3 지네릭 클래스의 객체 생성과 사용

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(){
    	retrun list;
    }
    int size(){
    	return list.size();
    }
    public String toString(){
    	return list.toString();
    }
}
  • Box<T> 객체 생성
    참조변수와 생성자에 대입된 타입(매개변수화된 타입)이 일치해야 한다.
Box<Apple> appleBox = new Box<Apple>();
  • 두 타입이 상속 관계에 있어도 대입된 타입이 일치해야 한다.
  • 두 지네릭 클래스의 타입이 상속관계에 있고, 대입된 타입이 같은 것은 괜찮다.
    FruitBox는 Box의 자손이라고 가정해보자.
Box<Apple> appleBox = new FruitBox<Apple>(); // 다형성
  • JDK1.7부터는 추정이 가능한 경우 타입을 생략할 수 있게 해준다.
    참조변수의 타입으로부터 Box가 Apple 타입의 객체만 저장한다는 것을 알 수 있기 때문에 생성자에 반복해서 타입을 지정해주지 않아도 된다.
Box<Apple> appleBox = new Box<Apple>(); // ok
Box<Apple> appleBox = new Box<>(); // ok
  • 생성된 Box<T>의 객체에 'void add(T item)'으로 객체를 추가할 때, 대입된 타입과 다른 타입의 객체는 추가할 수 없다.
    그러나 타입 T가 'Fruit'인 경우, 'void add(Fruit item)'가 되므로, Fruit의 자손들은 이 메서드의 매개변수가 될 수 있다.
Box<Fruit> fruitBox = new Box<Fruit>();
fruitBox.add(new Fruit()); // ok
fruitBox.add(new Apple()); // ok

1.4 제한된 지네릭 클래스

타입 매개변수 T에 지정할 수 있는 타입의 종류를 제한할 수 있는 방법?
지네릭 타입에 extends 를 사용하면 특정 타입의 자손들만 대입할 수 있게 제한할 수 있다.

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

add()의 매개변수 타입 T도 Fruit와 그 자손 타입이 될 수 있으므로 아래와 같이 여러 과일을 담을 수 있는 상자가 가능하게 된다.

FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
fruitBox.add(new Apple());
fruitBox.add(new Grape()); // Grape가 Fruit의 자손

다형성에서 조상타입의 참조변수로 자손 타입의 객체를 가리킬 수 있는 것처럼, 매개변수화된 타입의 자손 타입도 가능하다.
따라서 타입 매개변수 T에 Object를 대입하면, 모든 종류의 객체를 저장할 수 있게 된다.
인터페이스를 구현해야 한다면 이때에도 extends를 사용한다. implements를 사용하지 않는다는 점에 주의하자.

interface Eatable();
class FruitBox<T extends Eatable> { ... }

클래스 Fruit의 자손이면서 Eatable 인터페이스도 구현해야 한다면 아래와 같이 & 기호로 연결한다. FruitBox 클래스에는 Fruit의 자손이면서 Eatable을 구현한 클래스만 타입 매개변수 T에 대입될 수 있다.

class FruitBox<T extneds Fruit & Eatable> { ... }

1.5 와일드 카드

다음 클래스가 있다.

class Juicer {
	static Juice makeJuice(FruitBox<Fruit> box) { // <Fruit> 로 지정
    String tmp = "";
    for(Fruit f : box.getList()) tmp += f + " ":
    return new Juice(tmp);
}
  • 지네릭 클래스가 아니다.
  • static 메서드에는 타입 매개변수 T를 매개변수로 사용할 수 없다.

따라서 이런 경우에는 다음처럼 여러 가지 타입의 매개변수를 갖는 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);
}

위와 같이 오버로딩하면 컴파일 에러가 발생한다.
지네릭 타입이 다른 것만으로는 오버로딩이 성립하지 않기 때문이다.
지네릭 타입은 컴파일러가 컴파일 할 때만 사용하고 제거해버린다.
그래서 위 두 메서드는 오버로딩이 아니라 메서드 중복 정의가 된다.

이럴 때 사용하기 위해 고안된 것이 바로 와일드 카드
? 기호로 표현하는데, 와일드 카드는 어떤 타입도 될 수 있다.

extendssuper로 상한, 하한을 제한할 수 있다.

<? extends T> : 와일드카드의 상한 제한. T와 그 자손들만 사용 가능
<? super T> : 와일드 카드의 하한 제한. T와 그 조상들만 사용 가능
<?> : 제한 없음. 모든 타입이 가능. <? extends Object>와 동일.

참고로 와일드카드에는 &을 사용할 수 없다.

와일드카드를 이용해 makeJuice() 코드를 고쳐보자.

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

이제 이 메서드의 매개변수로 FruitBox 뿐만 아니라, FruitBox, FruitBox도 가능하게 된다.

<? extends Object>
매개변수의 타입을 FruitBox<? extends Object>로 하면, 모든 종류의 FruitBox가 이 메서드의 매개변수로 가능해진다. 대신, 전과 달리 box의 요소가 Fruit의 자손이라는 보장이 없으므로 아래 for문에서처럼 box에 저장된 요소를 특정 타입의 참조변수로 받지 못 한다.

static Juice makeJuice(FruitBox<? extends Object> box) {
	String tmp = "";
    for(Fruit f : box.getList()) tmp += f + " ": // 에러 발생. Fruit가 아닐 수 있음.
    return new Juice(tmp);
}

다만 위 경우에는 실제로 테스트했을 때 문제없이 컴파일되는데, 그 이유는 바로 지네릭 클래스 FruitBox를 제한했기 때문이다.

class FruitBox<T extends Fruit> extends Box<T> {}

컴파일러는 위 문장으로부터 모든 FruitBox의 요소들이 Fruit의 자손이라는 것을 알고 있으므로 문제 삼지 않는 것이다.

Comparator

p.682 예제의 코드를 참고해보자.

...
class Apple extends Fruit{
	...
}
class Grape extends Fruit{
	...
}
class AppleComp implements Comparator<Apple>{
	public int compare(Apple t1, Apple t2){
		return t2.weight = t1.weight;
	}
}
class GrapeComp implements Comparator<Grape>{
	public int compare(Grape t1 Grape t2){
		return t2.weight - t1.weight;
	}
}
class FruitComp implements Comparator<Fruit>{
	public int compare(Fruit t1, Fruit t2){
		return t1.weight - t2.weight;
	}
}
...

이 예제는 Collections.sort()를 이용해 appleBox와 grapeBox에 담김 과일을 무게 별로 정렬한다.
이 메서드의 선언부는 다음과 같다.

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

static <T><T>는 메서드에 선언된 지네릭 타입이다.
이런 메서드를 지네릭 메서드라고 하는데, 다음 단원에서 배운다.
첫 번째 매개변수는 정렬할 대상
두 번째 매개변수는 정렬할 방법이 정의된 Comparator

Comparator의 지네릭 타입에 하한 제한이 걸려있는 와일드 카드가 사용되었다.
만약 와일드카드를 사용하지 않았다고 가정해보자.

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

만일 타입 매개변수 T에 Apple이 대입되면 위 정의는 아래와 같이 바뀐다

static void sort(List<Apple> list, Comparator<Apple> c)

List<Apple>을 정렬하려면 Comparator<Apple>이 필요하다는 것을 의미하며, Comparator<Apple>을 구현한 클래스를 정의해야 한다.
하지만 Apple 대신 Grape가 대입된다면, List<Grape>가 필요하며, 이는 Comparator<Grape>을 구현한 클래스를 정의해야 한다.
두 클래스는 타입만 다를 뿐 완전히 같은 코드이다. 따라서 코드의 중복도 문제고, 새로운 Fruit의 자손이 생길 때마다 코드를 반복해서 만들어야 한다는 것이 문제다.

이런 문제를 해결하기 위해서는 타입 매개변수에 하한 제한의 와일드 카드를 적용해야 한다. 따라서 sort()는 원래 그렇게 정의되어 있다.

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

위의 문장에서 타입 매개변수 T에 Apple이 대입되면 다음과 같이 된다.

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

즉 Comparator의 타입 매개변수로 Apple과 그 조상이 가능하다는 뜻이다.
Grape에 대한 Comparator을 구현하지 않고도 사용할 수 있다.

Comparator에는 항상 <? super T>가 습관적으로 따라 붙는다. 와일드카드 때문에 어렵게 느껴진다면 그냥 쉽게 와일드 카드를 무시하고 Comparator<T>라고 생각해도 된다.

1.6 지네릭 메서드

메서드 선언부에 지네릭 타입이 선언된 메서드

  • 지네릭 타입의 선언 위치는 반환 타입 바로 앞이다.

예를 들어 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<? super T> c){
	...
	}
}

FruitBox에 선언된 타입 매개변수 T와 제네릭 메서드 sort()에 선언된 타입 매개변수 T는 타입 문자만 같을 뿐 서로 다른 것이다.
static 멤버에는 타입 매개변수를 사용할 수 없지만, 이처럼 메서드에 지네릭 타입을 선언하고 사용하는 것은 가능하다.
메서드에 선언된 지네릭 타입은 지역 변수를 선언한 것과 같다고 생각하면 이해가 쉬운데, 이 타입 매개변수는 메서드 내에서만 지역적으로 사용될 것이므로 메서드가 static이건 static이 아니건 상관 없다.

앞서 나왔던 makeJuice() 메서드를 지네릭 메서드로 바꾸면 다음과 같다.
이전

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

지네릭 메서드로 변환
...makeJuice(FruitBox<? extends Fruit> box) -> static <T extends Fruit> Juice 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>();
...
Juicer.<Fruit>makeJuice(fruitBox);
Juicer.<Apple>makeJuice(appleBox);

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

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

하지만 지네릭 메서드를 호출할 때 대입된 타입을 생략할 수 없는 경우에는 참조 변수나 클래스 이름을 생략할 수 없다.

<Fruit>makeJuice(fruitBox)); // 에러. 클래스 이름 생략 불가
this.<Fruit>makeJuice(fruitBox);
Juicer.<Fruit>makeJuice(fruitBox);;
  • 같은 클래스 내에 있는 멤버들끼리는 참조변수나 클래스 이름, 즉 this나 클래스 이름을 생략하고 메서드 이름만으로 호출이 가능하지만 대입된 타입이 있을 때는 반드시 써줘야 한다. 기술적인 규칙이므로 그냥 지키면 된다.

변환 예시1

지네릭 메서드는 매개변수의 타입이 복잡할 때도 유용하다.

public static void printAll(ArrayList<? extends Product> list, ArrayList<? extends Product> list2){
	for(Unit u L 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);
	}
}

복잡한 변환 예시2

이번엔 복잡하게 선언된 지네릭 메서드 하나를 예로 들어보자.
아래 메서드는 Collections 클래스의 sort()인데, 좀 전에 소개한 sort()와 달리 매개변수가 하나짜리다.

public static <T extneds Comparable<? super T>> void sort(List<T> list)

매개변수로 지정한 List<T>를 정렬한다는 것은 아겠는데, 메거드에 선언된 지네릭 타입이 좀 복잡하다.

  1. 이럴 때에는 와일드카드를 걷어내자.
public static <T extends Comparable<T>> void sort(List<T> list)
  1. List<?>의 요소가 Comparable인터페이스를 구현한 것이어야 한다는 뜻이다.
    앞서 살펴본 것처럼 인터페이스라고 해서 'implements'라고 쓰지 않는다.

  2. 이제 와일드카드를 다시 넣고 이해해보자.

    public static (2)<T extends Comparable<? super T>> void sort((1)List<T> list)

  • 1) 타입 T를 요소로 하는 List를 매개변수로 허용한다.
  • 2)
    <T extends Comparable> : 'T'는 Comparable을 구현한 클래스여야 한다.
    Comparable<? super T> : 그리고 그 Comparable은
    'T' 또는 그 조상의 타입을 비교하는 Comparable이어야 한다.
    만일 T가 Stuedent고, Person의 자손이라면, <? super T>는 Student, Person, Object가 모두 가능하다.

1.7 지네릭 타입의 형변환

지네릭 타입과 원시 타입 간의 형변환이 가능할까?

Box box = null;
Box<Object> objBox = null;

box = (Box)objBox; // 지네릭 타입 -> 원시 타입. 경고 발생
objBox = (Box<Object>)box; // 원시 타입 -> 지네릭 타입. 경고 발생

지네릭 타입과 원시 타입 간의 형변환은 항상 가능하지만 경고는 발생한다.

그렇다면 대입된 타입이 다른 지네릭 타입 간에는 형변환이 가능할까?

Box<Object> objBox = null;
Box<String> strBox = null;

objBox = (Box<Object>)strBox; // 에러. Box<String> -> Box<Object>
strBox = (Box<String>)objBox; // 에러. Box<Object> -> Box<String>

대입된 타입이 다른 지네릭 타입 간에는 형 변환이 불가능하다. 대입된 타입이 Object여도 불가능하다.

그렇다면 다음 문장은 가능할까?

Box<? extends Object> wBox = new Box<String>();

형변환이 가능하다. 그래서 전에 배운 makeJuice 메서드의 매개변수에 다형성이 적용될 수 있었던 것이다.

static Juice makeJuice(FruitBox<? extends Fruit> box){...}
FruitBox<? extends Fruit> box = new FruitBox<Fruit>(); // ok
FruitBox<? extends Fruit> box = new FruitBox<Apple>(); // ok

반대로의 형변환도 성립하긴 하나, 확인되지 않은 형변환이라는 경고가 뜬다.

FruitBox<? extends Fruit> box = null;
// ok. 미타입확인으로 형변환 경고
FruitBox<Apple> appleBox = (FruitBox<Apple>)box;

예시 - java.util.Optional 클래스의 실제 소스 일부

public final class Optional<T> {
	private static final Optional<?> EMPTY = new Optional<>();
    private final T value;
    ...
    public static<T> Optional<T> empty() {
  		Optional<T> t = (Optional<T>) EMPTY;
  		return t;
  	}
  ...
}
  1. static 상수 EMPTY에 비어있는 Optional 객체를 생성해서 저장
  2. empty()를 호출하면 EMPTY를 형변환해서 반환
Optional<?> EMPTY = new Optional<>();
-> Optional<? extends Object> EMPTY = new Optional<>();
-> Optional<? extends Object> EMPTY = new Optional<Object>();

<?>는 <? extends Object>를 줄여 쓴 것이며, <> 안에 생략된 타입은 ? 가 아니라 Object이다.

Optional<?> EMPTY = new Optional<?>(); // 에러. 미확인 타입의 객체는 생성 불가.
-> Optional<? extends Object> EMPTY = new Optional<Object>(); // OK
-> Optional<? extends Object> EMPTY = new Optional<>(); // OK. 위 문장과 동일

위의 문장에서 EMPTY의 타입을 Optional<Object>가 아닌 Optional<?>로 한 이유는 Optional로 형변환이 가능하기 때문이다.

Optional<? wopt = new Opional<Object>();
Optional<String> sopt = (Optional<String>) wopt; // OK.형변환 가능

Optional<Object> oopt = new Optional<Object>();
Optional<String> sopt = (Optional<String>) oopt; // 에러. 형변환 불가

empty()의 반환 타입이 Optional<T>이므로 EMPTY를 Optional<T>로 형변환해야 하는데, 위의 코드에서 알 수 있는 것처럼 Optional<Object\>는 Optional<T\>는 형변환이 불가능하다.

Optional<Object>를 Optional<String>으로 직접 형변환하는 것은 불가능하지만 와일드 카드가 포함된 지네릭 타입으로 형변환하는 것은 가능하다.
대신 확인되지 않은 타입으로의 형변환이라는 경고가 발생한다.

Optional<Object> -> Optional<T> // 형변환 불가
Optional<Object> -> Optional<?> -> Optional<T> // 형변환 가능

하나 더 덧붙이자면, 다음과 같이 와일드 카드가 사용된 지네릭 타입끼리도 다음과 같은 경우에는 형변환이 가능하다.

Fruit<? extends Object> objBox = null;
Fruit<? extends String> strBox = null;

strBox = (FruitBox<? extends String>) objBox;
objBox = (FruitBox<? extends Object>) strBox;
// 미확정 타입으로 형변환 경고, OK

형변환이 가능하긴 하지만, 와일드카드는 타입이 확정된 타입이 아니므로 컴파일러는 미타입 확정 타입으로 형변환하는 것이라고 경고한다.

1.8 지네릭 타입의 제거

컴파일러는 지네릭 타입을 이용해서 소스 파일을 체크하고, 필요한 곳에 형변환을 넣어준다.
그리고 지네릭 타입을 제거한다.
즉 컴파일된 파일(*.class) 에는 지네릭 타입에 대한 정보가 없는 것이다.
-> 지네릭이 도입되기 전 소스코드와의 호환성을 유지하기 위함
그러나 앞으로는 가능하면 원시 타입을 사용하지 않도록 하자.

지네릭 타입의 제거 과정은 꽤 복잡하기 때문에 기본적인 제거 과정만 알아보자.

1. 지네릭 타입의 경계(bound)를 제거한다.
<T extends Fruit> 라면 T는 Fruit로 지환된다.
<T>인 경우에 T는 Object로 지환된다. 그리고 클래스 옆의 선언은 제거된다.

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

변환 후

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

2. 지네릭 타입을 제거한 후에 타입이 일치하지 않으면, 형변환을 추가한다.
List의 get()은 Object 타입을 반환하므로 형변환이 필요하다.

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

변환 후

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

와일드 카드가 포함되어 있는 경우에는 다음과 같이 적절한 타입으로의 형변환이 추가된다.

static juice makeJuice(FruitBox<? extends Fruit> box){
	String tmp = "";
	for(Fruit t : 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);
}

0개의 댓글