Live Study 14주차 - 제네릭


제네릭

사용법을 알기 전, 제네릭(Generics)이 뭔지부터 알아보자.

제네릭은 클래스 내부에서 사용할 데이터 타입을 클래스 외부에서 지정하는 기법이다. 오라클 문서에 의하면, Nutshell 에서는 제네릭에 대해 아래처럼 설명했다고 한다.

In a nutshell, generics enable types (classes and interfaces) to be parameters when defining classes, interfaces and methods.

source : Oracle Java Document (Generics)

왜 데이터 타입을 클래스 내부가 아니라 클래스 외부에서 지정하도록 허용한걸까?

  • 컴파일 타임에서 강력한 타입 체크를 할 수 있다.
  • 형변환(타입 캐스팅)을 제거해준다.

출처 : Oracle Java Documen - Generics

제네릭 용어

제네릭에서 사용되는 용어를 정리하고 넘어가겠다.

Box<T> 제네릭 클래스, 'T의 Box' 또는 'T Box'라고 읽는다.
T 타입 변수 또는 타입 매개변수. (T는 타입 문자)
Box 원시 타입(Raw Type)

출처 : 남궁성 - Java의 정석


제네릭 사용이유 2가지(컴파일 타임에서 타입 체크, 형변환 코드 제거)를 간단한 예제코드를 통해 검증해보자.

List 타입의 데이터를 파라미터로 받아서 각각의 요소(element)에 X2를 하여 반환하는 메서드를 만들고, 이 메서드를 출력해보는 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class StudyHalle {
public static void main(String[] args) {
List items = new ArrayList();
items.add(1);
items.add(2);
items.add("3");

List converted = convertList(items);
for (int i = 0; i < converted.size(); i++) {
System.out.println(converted.get(i).getClass().getSimpleName());
}
}

public static List convertList(List list){
List converted = new ArrayList();
for (int i=0; i<list.size();i++){
int temp = (int) list.get(i);
converted.add(temp);
}
return converted;
}
}

이미 코드를 보면 알겠지만 이 코드는 예외(ClassCastException)가 발생한다. convertList()에서 형변환이 발생하는데, 파라미터로 들어온 List의 요소중 하나가 int형으로 강제형변환되지 않는 타입이었다. String은 참조형 타입이므로 기본형 타입으로 형변환되지 않는다.

convertList()의 코드를 보면 파라미터로 가져온 List를 형변환후 가공하여 새로운 List에 담아서 반환을 한다. 그럼 파라미터로 들어오는 List는 형변환이 가능한 요소들로만 채워져야 한다.

그런데 main()에서 List 데이터를 선언하면서 요소들에 데이터를 채울때 convertList()에서 형변환이 불가능한 데이터가 추가되버리면서 결국 런타임에서 예외가 발생한 것이다.

이 때 List에 들어오는 값의 데이터 타입을 제네릭으로 강제할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class StudyHalle {
public static void main(String[] args) {
List<Integer> items = new ArrayList<>(); // 제네릭으로 타입 체크
items.add(1);
items.add(2);
items.add(3);

StudyHalle study = new StudyHalle();
List converted = study.convertList(items);
for (int i = 0; i < converted.size(); i++) {
System.out.println(converted.get(i).getClass().getSimpleName());
}
}

// 제네릭 메서드 사용
// 메서드 입장에선 어떤 타입이 들어올지 알 수 없으므로 제네릭 타입 매개변수를 사용한 제네릭 메서드 사용
public <T> List<T> convertList(List<T> list){
List<T> converted = new ArrayList<>();
for (int i=0; i<list.size();i++){
converted.add(list.get(i));
// 메서드 시그니처를 보면 알수있듯이 converted와 list는 같은 제네릭 타입 매개변수(<T>)를 공유하는 List이므로 형변환이 필요하지 않음
}
return converted;
}
}

제네릭으로 타입을 강제한 덕분에 제네릭으로 지정한 타입 외 다른 타입의 데이터가 리스트의 요소로 추가될 경우, 컴파일 에러가 발생하였다. 이를 제네릭을 사용했을때 얻을 수 있는 가장 큰 이점인 타입 안정성이라 한다.

컴파일 에러가 발생하는 코드를 수정한후 다시 컴파일하여 프로그램을 실행하면 아래와 같은 결과를 콘솔에 출력된다.

1
2
3
Integer
Integer
Integer

List는 요소의 데이터 타입이 기본적으로 Object이므로 어떤 데이터도 추가할 수 있는데, 제네릭으로 데이터 타입을 고정하면서 모든 요소의 데이터 타입이 Integer로 저장된걸 알 수 있다.

convertList()를 보면 제네릭 메서드로 수정하여 파라미터로 들어오는 List와 반환될 List의 제네릭 타입 매개변수(<T>)가 같아서 형변환을 하지않아도 됐다.


제네릭 사용법

그럼 제네릭은 어떻게 사용하는걸까?

앞서 제네릭은 클래스, 메서드, 인터페이스의 타입을 파라미터로 사용하는것이라는 설명을 인용했다.

제네릭 타입 매개변수

외부에서 데이터 타입을 지정할때까지는 컴파일 단계에서 데이터 타입을 지정하지 않기 위해서 사용하는 매개변수(Parameter)이다. 타입 매개변수는 제네릭 클래스내에서 사용이 가능하다.

1
2
3
4
public class Movie<T, S> {
private T title;
private S releasedDate;
}

아래 예제코드의 일부다. 제네릭 클래스 Movie의 제네릭 타입 매개변수는 T, S이다. 이렇게 제네릭 클래스에서 사용된 타입 매개변수는 클래스 내부에서 사용할 수 있다.

위의 코드에서 title, releasedDate 필드는 아직 데이터 타입이 명시되지 않고, 대신 제네릭 타입 매개변수로 선언되어 있다. 향후 외부에서 Movie 클래스의 인스턴스를 사용할때 컴파일러에서 제네릭 타입 매개변수에 어떤 타입을 사용할지 강제하는데, 이 때 이들의 데이터 타입이 결정된다.

타입 매개변수를 통해서 제네릭 클래스에서 데이터 타입이 사용되는 원리는 다음과 같다.

출처 : 생활코딩

외부에서 제네릭 타입 매개변수로 데이터 타입을 지정하면, 인스턴스에서 사용된 모든 타입 매개변수가 이 데이터 타입으로 지정된다.


일반적으로 제네릭 타입 매개변수로 T, S 를 쓰곤 하는데, 아래와 같은 규칙으로 사용되고있다. 이는 컴파일러에서 강제하는게 아니라 규칙이다.

Type Variable Definition
T 첫번째 타입 매개변수
S, U, V 2번째, 3번째, 4번째 타입 매개변수
E Element
K Key, Map 인터페이스에서 사용된다.
V Value, Map 인터페이스에서 사용된다.
N Number

출처 : [DevAndy] Java - Generics


제네릭 클래스, 컴파일러의 타입 추론

제네릭 클래스는 클래스 이름 오른쪽에 제네릭 타입 매개변수를 작성하면 컴파일러가 해당 클래스를 제네릭 클래스로 인식한다.

1
class Movie <T> { }

컴파일러가 제네릭 클래스의 타입을 어떻게 추론하는지 알아보자.

1
2
3
4
5
6
7
8
9
class Fruit {
public String toString() { return "Fruit"; }
}
class Apple extends Fruit {
public String toString() { return "Apple"; }
}
class Grape extends Fruit {
public String toString() { return "Grape"; }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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();
}
}
1
2
3
4
5
6
7
8
9
10
class FruitBox {
public static void main(String[] args){
Box<Fruit> fruitBox = new Box<Apple>();
Box<Apple> appleBox = new Box<Apple>();
// Box<Grape> grapeBox = new Box<Apple>(); 컴파일 에러 타입 불일치

fruitBox.add(new Apple());
// appleBox.add(new Fruit()); 컴파일 에러
}
}

Fruit 클래스와 이를 상속받는 Apple, Grape 클래스를 선언했다.
Box 클래스는 제네릭 클래스로써 외부에서 지정한 데이터 타입으로 ArrayList의 list를 갖는다.

FruitBox 클래스의 main()을 보자. 이 코드는 2가지 컴파일 에러를 유발하는데, 이 컴파일 에러가 왜 발생했는지 알아보자.

이 코드는 타입 불일치로 발생한 컴파일 에러이다. 인스턴스의 타입에 선언된 타입 매개변수(Grape)와 초기화하는 생성자에서 사용된 타입 매개변수(Apple)이 달랐기 때문이다.

인스턴스를 선언하면서 컴파일러에게 타입 매개변수가 Grape 클래스라고 선언해놓고, 인스턴스의 초기화는 Apple 클래스로 하겠다고 하니 컴파일러에서 에러를 발생하는 것이다.

제네릭 클래스의 인스턴스를 선언할때는 반드시 같게 해주어야한다.

두번째 컴파일 에러는 제네릭 타입 매개변수로 사용된 클래스의 상속관계 때문에 발생한 컴파일 에러였다.

appleBoxBox<Apple>으로 선언된 인스턴스이다. add() 메서드의 코드는 아래와 같다.

1
2
3
void add(T item){
list.add(item);
}

메서드의 매개변수로 제네릭 타입 매개변수를 사용했다. 여기서 사용된 제네릭 타입 매개변수는 제네릭 클래스 Box<T>에서 사용된 제네릭 타입 매개변수이다.

appleBox를 선언하면서 사용한 제네릭 타입 매개변수는 Apple이므로 컴파일러는 add()의 매개변수로 들어오는 데이터 타입도 Apple을 기대했을 것이다. 그런데 Apple의 부모 객체인 Fruit가 들어오면서 컴파일러 에러가 발생했다.

컴파일러는 주어진 클래스를 통해서 부모 클래스를 추론할수 있지만, 자식 클래스는 추론할 수 없다. 자식 클래스가 하나만 있지 않을수 있기 때문이다.

1
fruitBox.add(new Apple());

위 코드에서 new Fruit() 뿐 아니라 Apple을 허용한것과 같다.

일단 인스턴스가 정상적으로 선언되고 나면, 컴파일러가 타입 매개변수로 들어온 데이터 타입을 추론할 수 있기 때문에 제네릭 클래스의 메서드에서 사용되는 타입 매개변수의 자식 클래스도 컴파일러가 추론할 수 있기 때문이다.

마지막으로 제네릭 클래스는 static 키워드와 함께 사용할 수 없다. static 키워드로 선언하면 heap 영역에 객체를 할당시켜야 하는데, 제네릭 클래스는 컴파일 시점에서 컴파일러가 아직 데이터 타입을 추론할 수 없기 때문에 heap 영역에 객체를 할당 할 수 없다. 따라서 제네릭 클래스는 반드시 인스턴스를 통해서만 사용된다.


제네릭 주요 개념(바운디드 타입, 와일드 카드)

와일드 카드가 무엇이고, 왜 필요한지 알기위해 예제코드를 먼저 살펴보자. 코드를 천천히 따라 읽어보고, main()에서 왜 컴파일 에러가 발생하는지 미리 예측해보자.

1
2
3
4
5
6
7
8
9
class Juicer {
static Juice makeApple(FruitBox<Fruit> box){
String tmp = "";
for (Fruit f : box.getList()) {
tmp += f+" ";
}
return new Juice(tmp);
}
}
1
2
3
4
5
6
7
8
9
10
11
class Juice {
String name;
Juice(String name){
this.name = name + "Juice";
}

@Override
public String toString() {
return name;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Box<T> {
ArrayList<T> list = new ArrayList<>();
void add(T item){
list.add(item);
}
ArrayList<T> getList() {
return list;
}
@Override
public String toString() {
return list.toString();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
public class FruitBox2 {
public static void main(String[] args) {
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox = new FruitBox<Apple>();

fruitBox.add(new Apple());
fruitBox.add(new Grape());
appleBox.add(new Apple());

System.out.println(Juicer.makeApple(fruitBox));
// System.out.println(Juicer.makeApple(appleBox)); 타입 불일치로 인한 컴파일 에러
}
}

출처 : 남궁성 - Java의 정석

makeApple()의 매개변수는 제네릭 클래스를 받고 있다.

1
2
class Juicer {
static Juice makeApple(FruitBox<Fruit> box){ ... }

여기서 제네릭 타입 매개변수로 Fruit을 강제하기 때문에 Apple 클래스를 타입 매개변수로 사용된 인스턴스 appleBoxmakeApple()의 매개변수가 될 수 없는 것이다.

그렇다고 makeApple()을 제네릭 메서드로 만들수도 없다. static 키워드로 선언되었기 때문이다.

그럼 결국 서로 다른 제네릭 타입 매개변수를 갖는 makeApple()을 여러개 선언(오버로딩)해야한다.

이 때 필요한게 와일드카드(Wildcards)이다.

와일드카드는 ? 를 사용한다. 와일드카드는 Bounded Wildcards와 Unbounded Wildcards로 구분된다.

  • Bounded Wildcards : 제한된 와일드카드
    • <? extends T>, <? super T>
  • Unbounded Wildcards : 제한없는 와일드카드
    • ?, <? extends Object>

여기서 Bound란, 경계를 의미한다고 보면 된다. Bounded는 경계가 존재하는 것이고, Unbounded는 경계가 존재하지 않는것이다.

wildcards description
<? extends T> 와일드 카드의 상한 제한(Upper Bounded Wildcards). T와 그 자손들만 가능
<? super T> 와일드 카드의 하한 제한(Lower Bounded Wildcards). T와 그 조상들만 가능
<?> 제한 없음. 모든 타입이 가능. <? extends Object>와 동일

출처 : 남궁성 - Java의 정석


제네릭 메서드 만들기

제네릭 메서드의 선언부는 다음과 같다.

1
public <T> void foo(List<T> list) { }

반환타입 앞에 타입 매개변수를 지정해주면, 제네릭 메서드가 된다.

메서드 파라미터에서 사용하는 제네릭 타입 매개변수는 선언부에서 사용된 타입 매개변수를 통해 추론된다. 따라서 같은 타입 매개변수여야 한다.

위의 코드는 제네릭 메서드 선언부에서 타입 매개변수로 <T>가 선언되었는데, 파라미터에서 다른 타입 매개변수를 사용하려고 해서 컴파일러에서 타입을 추론하지 못하여 발생하는 컴파일 에러이다.


Erasure

오라클 문서를 보면, Type Erasure(타입 소거)를 다음과 같이 설명한다.

  • 컴파일러는 제네릭 타입에서는 해당하는 타입 매개변수(T)로 변경해주며, 매개변수가 unbounded라면, Object로 변경해준다.
  • 타입 안정성 보존을 위해 필요하다면 형변환을 넣어준다.
  • 확장된 제네릭 타입에서 다형성을 보존하기 위해 브릿지 메서드(bridge method)를 생성해준다.

오라클 문서의 예제 코드를 통해 확인해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Node<T> {
private T data;
private Node<T> next;

public Node(T data, Node<T> next){
this.data = data;
this.next = next;
}

public T getData() {
return data;
}
}

위의 코드는 자바 컴파일러에 의해 아래와 같이 소거된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Node {
private Object data;
private Node next;

public Node(Object data, Node next){
this.data = data;
this.next = next;
}

public Object getData(){
return data;
}
}

타입 매개변수가 unbounded였으므로, 자바 컴파일러는 이 매개변수를 모두 Object로 변경한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Node<T extends Comparable<T>> {
private T data;
private Node<T> next;

public Node(T data, Node<T> next){
this.data = data;
this.next = next;
}

public T getData() {
return data;
}
}

타입 매개변수가 bounded 일때는 어떻게 소거될까?

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Node {
private Comparable data;
private Node next;

public Node(Comparable data, Node next){
this.data = data;
this.next = next;
}

public Comparable getData(){
return data;
}
}

타입 매개변수가 적용된 타입을 Comparable로 변경하였다.

출처 : Oracle Java Document - Erasure of Generic Types

제네릭 메서드는 어떻게 소거될까?

1
public static <T extends Shape> void draw(T shape) { }

위의 제네릭 메서드는 자바 컴파일러에 의해 타입 매개변수를 Shape로 변경된다.

1
public static void draw(Shape shpape) { }

출처 : Oracle Java Document - Erasure of Generic Methods

브릿지 메서드 예제코드이다.

1
2
3
4
5
6
7
8
9
10
public class Node<T> {
public T data;
public Node(T data) {
this.data = data;
}
public void setData(T data){
System.out.println("Node.setData");
this.data = data;
}
}
1
2
3
4
5
6
7
8
9
public class MyNode extends Node<Integer> {
public MyNode(Integer data) {
super(data);
}
public void setData(Integer data){
System.out.println("MyNode.setData");
super.setData(data);
}
}

자바 컴파일러에 의해 소거된 코드는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
public class Node {
public Object data;
public Node(Object data) {
this.data = data;
}
public void setData(Object data){
System.out.println("Node.setData");
this.data = data;
}
}
1
2
3
4
5
6
7
8
9
public class MyNode extends Node {
public MyNode(Integer data) {
super(data);
}
public void setData(Integer data){
System.out.println("MyNode.setData");
super.setData(data);
}
}

여기서 타입 불일치가 발생한다. MyNode 클래스의 seData() 파라미터의 타입이 Integer인데, Node 클래스의 seData()파라미터 타입은 Object이기 때문이다.

자바 컴파일러는 이 때 형변환하는 브릿지 메서드를 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MyNode extends Node {
public MyNode(Integer data) {
super(data);
}

// bridge method generated by the compiler
public void setData(Object data){
setData((Integer) data);
}

public void setData(Integer data){
System.out.println("MyNode.setData");
super.setData(data);
}
}

다형성을 이용하여 같은 이름의 메서드를 오버로딩하여 형변환을 했다. 이게 브릿지 메서드의 역할이다.

출처 : Oracle Java Document - Effects of Type Erasure and Bridge Methods