참고자료
자바에서 제네릭이란 데이터의 타입을 일반화한다는 것을 의미한다. 제네릭은 클래스나 메소드에서 사용할 내부 데이터 타입을 컴파일 시에 미리 지정하는 방법이다. 컴파일에 미리 타입 검사를 수행하면 다음과 같은 장점을 얻을 수 있다.
JDK 1.5 이전에는 여러 타입을 사용하는 클래스나 메소드는 Object 타입을 사용하였다. Object를 사용하게 되면 Object 객체를 다시 원하는 타입으로 형 변환해줘야 한다. 그런데, 실수로 형 변환을 하지 않거나 다른 타입으로 변환할 수도 있다. 문제는 이런 오류는 런타임에 알 수 있다는 것이다.
제네릭을 사용하므로서, 컴파일 타임에 미리 체크할 수 있다.
public class BoxMain {
public static void main(String[] args) {
int value = 10;
BoxObject boxObject = new BoxObject();
boxObject.setElement(value);
String object = (String) boxObject.getElement();
BoxGeneric<Integer> boxGeneric = new BoxGeneric<>();
boxGeneric.setElement(value);
String object2 = boxGeneric.getElement();
}
}
public class BoxObject {
private Object element;
public Object getElement() {
return element;
}
public void setElement(Object element) {
this.element = element;
}
}
public class BoxGeneric<T> {
private T element;
public T getElement() {
return element;
}
public void setElement(T element) {
this.element = element;
}
}
// 컴파일 에러
// java: incompatible types: java.lang.Integer cannot be converted to java.lang.String
BoxObject
에서는 Object
객체를 반환하기 때문에 컴파일 타임에 에러를 확인할 수 없다. 반면, BoxGeneric
은 type argument
로 받았던 Integer
를 반환하기 때문에 String
타입과 일치하지 않아, 컴파일 에러를 발생시킨다.
public class BiteCode <T>{
private T t;
public void setT(T t){
this.t = t;
}
public T getT(){
return t;
}
public static void main(String[] args) {
BiteCode<Integer> biteCode = new BiteCode<>();
biteCode.setT(10);
Integer i = biteCode.getT();
}
}
제네릭 타입이 컴파일되었을 때 type parameter
는 어떻게 컴파일 될 지 궁금하였다. 그래서 바이트 코드를 확인하였다.
public class com/jm/test/BiteCode {
private Ljava/lang/Object; t
public <init>()V
L0
LINENUMBER 3 L0
ALOAD 0
INVOKESPECIAL java/lang/Object.<init> ()V
RETURN
L1
LOCALVARIABLE this Lcom/jm/test/BiteCode; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1
public setT(Ljava/lang/Object;)V
L0
LINENUMBER 6 L0
ALOAD 0
ALOAD 1
PUTFIELD com/jm/test/BiteCode.t : Ljava/lang/Object;
L1
LINENUMBER 7 L1
RETURN
L2
LOCALVARIABLE this Lcom/jm/test/BiteCode; L0 L2 0
LOCALVARIABLE t Ljava/lang/Object; L0 L2 1
MAXSTACK = 2
MAXLOCALS = 2
public getT()Ljava/lang/Object;
L0
LINENUMBER 9 L0
ALOAD 0
GETFIELD com/jm/test/BiteCode.t : Ljava/lang/Object;
ARETURN
L1
LOCALVARIABLE this Lcom/jm/test/BiteCode; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1
public static main([Ljava/lang/String;)V
L0
LINENUMBER 12 L0
NEW com/jm/test/BiteCode
DUP
INVOKESPECIAL com/jm/test/BiteCode.<init> ()V
ASTORE 1
L1
LINENUMBER 13 L1
ALOAD 1
BIPUSH 10
INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
INVOKEVIRTUAL com/jm/test/BiteCode.setT (Ljava/lang/Object;)V
L2
LINENUMBER 14 L2
ALOAD 1
INVOKEVIRTUAL com/jm/test/BiteCode.getT ()Ljava/lang/Object;
CHECKCAST java/lang/Integer
ASTORE 2
L3
LINENUMBER 15 L3
RETURN
L4
LOCALVARIABLE args [Ljava/lang/String; L0 L4 0
LOCALVARIABLE biteCode Lcom/jm/test/BiteCode; L1 L4 1
LOCALVARIABLE i Ljava/lang/Integer; L3 L4 2
MAXSTACK = 2
MAXLOCALS = 3
}
T
는 Object
로 컴파일되었다. 이렇게 컴파일되면 제네릭을 사용하지 않고, Object
를 사용하는 것과 어떤 차이가 있는 것일까? 핵심은 CHECKCAST
에 있다.
Integer i = biteCode.getT();
CHECKCAST java/lang/Integer
BiteCode
의 인스턴스에서 t
를 반환한 뒤, 해당 값이 Integer i
에 할당될 때, 컴파일러는 자동으로 getT
가 반환한 값이 Integer
인지 타입 확인을 수행한다. 즉, Heap영역에 존재할 때는 Object 타입으로 존재하지만, 실제로 해당 값이 사용될 때 타입 확인을 수행하는 것이다. 자바 컴파일러가 수행하는 타입 확인을 명시적으로 나타낸다면 다음과 같을 것이다.
BiteCode<Object> biteCode = new BiteCode<>();
biteCode.setT(10);
Integer i = (Integer)biteCode.getT();
제네릭은 Object를 형변환하고 타입을 확인하는 일련의 과정을 컴파일러에서 자동으로 추가해주는 기법이라고 볼 수 있다.
제네릭에서 Type parameter
는 다음과 같은 convention을 따른다.
대표적인 type parameter name은 다음과 같다.
일반 변수의 name convention과 다르기 때문에 일반 변수와 type parameter를 쉽게 구분할 수 있게 함이다.
BoxGeneric<Integer> boxGeneric = new BoxGeneric<>();
위 코드는 BoxGeneric 클래스의 인스턴스를 생성하기 위한 코드이다. T
가 위치하던 자리에 Integer
가 들어가있다. 이때 Integer
를 type argument
라고 하며, T
는 type parameter
라고 말한다.
자바 7부터 제네릭 클래스의 생성자를 호출하기 위해 사용했던 type argument
를 <>
로 대체할 수 있다. 컴파일러가 context를 확인하여 Type을 결정하게 된다.
제네릭 클래스는 여러 개의 type parameter
를 받을 수 있다.
public class Pair<K, V>{
private K key;
private V value;
public Pair(K key, V value){
this.key = key;
this.value = value;
}
public K getKey(){return key;}
public V getValue(){return value;}
}
Pair<String, Integer> p1 = new Pair<String,Integer>("Even",8);
Pair<String, String> p2 = new Pair<String,String>("hello","world");
K, V는 서로 다른 타입일 수 있고, 같은 타입일 수 있다. type argument
는 primitive type
이여도, 자바의 autoboxing
덕분에 Wrapper class
로 변환되어 정상적으로 컴파일 된다.
primitive type
의 값을 해당 타입과 대응되는 wrapper class
로 변환해주는 것을 autoboxing
이라고 말한다. autoboxing은 다음 조건에서 수행된다.wrapper class
를 parameter
로 받는 메소드에서 해당 wrapper class와대응되는 primitive type의 값을 넘겨줄 때wrapper class
로 변환할 수 있을 때autoboxing
과는 반대로, wrapper class
의 인스턴스를 대응되는 primitive type
의 변수로 변환하는 것을 unboxing
이라고 말한다. unboxing은 다음 조건에서 수행된다.primitive type
을 parameter
로 받는 메소드에서 해당 parameter type과 대응되는 wrapper class
의 인스턴스를 넘겨줄 때primitive type
변 로 변환할 수 있을 때type parameter
에 들어갈 type argument
를 특정 타입으로 제한하고 싶을 수 있다. 예를 들어 Number
클래스의 인스턴스이거나, 서브클래스이도록 제한을 두고 싶을 수 있다. 바운디드 타입이 이러한 제한을 가능케 한다.
위와 같이 바운디드 타입 파라미터를 정의한다. U
는 upper bound인 Number
의 인스턴스 or 서브클래스 이어야 한다.
public class BoundedBox<T> {
private T t;
public void set(T t){
this.t = t;
}
public <U extends Number> void inspect(U u){
System.out.println("T: " + t.getClass().getName());
System.out.println("U: " + u.getClass().getName());
}
public static void main(String[] args) {
BoundedBox<Integer> boundedBox = new BoundedBox<>();
boundedBox.set(10);
boundedBox.inspect("hello!"); // 여기서 컴파일 에러 발생
}
}
//출력문(에러)
//java: method inspect in class com.jm.test.BoundedBox<T> cannot be applied to given types;
// required: U
// found: java.lang.String
// reason: inferred type does not conform to upper bound(s)
// inferred: java.lang.String
// upper bound(s): java.lang.Number
inspect
제네릭 메소드의 type parameter
는 Number
클래스를 upper bound로 삼고 있다. 그런데 inspect
를 호출하는 곳에서 type argument
로 String
타입의 값을 넘겨주었다. String
은 Number
클래스의 인스턴스 or 서브클래스가 아니기 때문에 type 할당이 불가능하다.
type parameter
는 다중 바운드를 가지고 있을 수 있다.<T extends B1 & B2 & B3>
그런데 만약 멀티 바운드 중에 클래스가 존재한다면, 해당 클래스는 반드시 가장 맨 앞에 명시 되어야 한다.Class A { /* ... */ }
interface B { /* ... */ }
interface C { /* ... */ }
class D <T extends A & B & C>{}
class D <T extends B & A & C>{} // compile-time error
public static <T> int countGreaterThan(T[] anArray, T elem){
int count = 0;
for(T e : anArray)
if(e > elem) // compiler error
++count;
return count;
}
위 함수는 정상적으로 동작할 것 같지만, 비교(>
)부분에서 컴파일 에러가 발생한다. 비교 연산자는 오직 primitive type
에 대해서만 수행 가능하다. T
는 primitive type
이 아닐 수 있기 때문에 에러가 발생한다. 이 문제를 해결하기 위해 type parameter
가 Compare<T>
인터페이스를 upper bound로 삼도록 해야 한다.public interface Comparable<T>{
public int compareTo(T o);
}
public static <T extends Comparable<T>> int countGreaterThan(T[] anArray, T elem){
int count = 0;
for(T e : anArray)
if(e > elem) // compiler error
++count;
return count;
}
Integer
는 Number
의 서브 클래스이다. 즉, Integer
는 Number
를 상속한다. 따라서, Number type
의 변수에 Integer type
변수가 대입될 수 있다.
그렇다면, Box<Number>
의 변수에 Box<Integer>
변수가 대입될 수 있을까?
public class GenericMethodBounded {
public static void boxTest(Box<Number> n){}
public static void main(String[] args) {
Box<Integer> integerBox = new Box<>();
boxTest(integerBox); // compile-time error
}
}
class Box<T>{}
결론은 “대입될 수 없다”이다. Integer
가 Number
의 서브타입이라도, Box<Integer>
는 Box<Number>
의 서브타입이 아니다.
class Box<T>{}
class ChildBox<T> extends Box{} // 1
class ChildBox<T> extends Box<T>{} // 2
class ChildBox<E> extends Box<T>{} // 3 error
class ChildBox<T> extends Box<E>{} // 4 error
class ChildBox<E> extends Box<E>{}
class ChildBox<T,E> extends Box<T>{} // 5
ChildBox는 Box를 상속 받지만, Box의 type parameter
인 T
를 상속 받지 않는다. ChildBox<T>
에서 T
는 ChildBox의 독립적인 type parameter
이다. 단, ChildBox의 생성자에서 Box의 생성자를 반드시 호출해야 한다. 그래야만, Box<T>
의 type argument
를 전달할 수 있기 때문이다.
class ChildBox<T> extends Box{
public ChildBox(T t2) {
super(123); // 부모클래스의 생성자 호출
this.t2 = t2;
}
}
ChildBox는 Box를 상속 받으며, Box의 type parameter
인 T
도 상속 받는다. 따라서, ChildBox<T>
의 T
는 Box<T>
의 T
와 일치한다. 1과 마찬가지로 부모 클래스의 생성자를 호출하는 것은 동일하다.
Box<T>
에 T
가 어떤 타입인지 알 수 없다. 자식 클래스인 ChildBox
에서 부모 클래스의 type parameter
를 정의해줘야 한다.
3과 동일한 이유로 에러가 발생한다.
정상적으로 동작한다. 자식 클래스에서 부모 클래스의 type parameter
의 name
을 따라갈 필요는 없다. 부모 클래스에게 type argument
만 제시해주면 된다.
부모 클래스의 type parameter
와 자식 클래스의 고유한 type parameter
를 함께 사용하려면, ChildBox<T, E>
와 같이 전부 명시해줘야 한다.
타입 추론은 자바 컴파일러가 type argument
를 context
를 보고 직접 결정하는 것을 말한다.
public class BoxDemo {
public static <U> void addBox(U u, java.util.List<Box<U>> boxes){
Box<U> box = new Box<>();
box.set(u);
boxes.add(box);
}
public static <U> void outputBoxes(java.util.List<Box<U>> boxes){
int counter = 0;
for(Box<U> box : boxes){
U boxContents = box.get();
System.out.println("Box #" + counter + " contains [" +
boxContents.toString() + "]");
counter++;
}
}
public static void main(String[] args) {
java.util.ArrayList<Box<Integer>> listOfIntegerBoxes =
new java.util.ArrayList<>();
BoxDemo.<Integer>addBox(Integer.valueOf(10), listOfIntegerBoxes);
BoxDemo.addBox(Integer.valueOf(20), listOfIntegerBoxes);
BoxDemo.addBox(Integer.valueOf(30), listOfIntegerBoxes);
BoxDemo.outputBoxes(listOfIntegerBoxes);
}
static class Box<T>{
private T t;
public void set(T t){
this.t = t;
}
public T get(){
return t;
}
}
}
// 출력
// Box #0 contains [10]
// Box #1 contains [20]
// Box #2 contains [30]
위 코드에서 제네릭 메소드인 addBox
와 outputBoxes
를 집중적으로 보겠다.
BoxDemo.<Integer>addBox(Integer.valueOf(10), listOfIntegerBoxes);
BoxDemo.addBox(Integer.valueOf(20), listOfIntegerBoxes);
BoxDemo.addBox(Integer.valueOf(30), listOfIntegerBoxes);
BoxDemo.outputBoxes(listOfIntegerBoxes);
두 메소드의 호출문이다. 이상한 점은 첫번째 줄에서는 <Integer>
를 명시해주었는데, 두번째 줄부터는 type argument
를 지정하지 않았는데도 정상적으로 동작한다는 점이다. 이게 가능한 이유는 자바 컴파일러가 type inference
를 수행하였기 때문이다. 입력받은 값을 보고 type argument
를 유추하는 것이다.
제네릭 코드에서 와일드 카드라고 불리는 ?
는 알 수 없는 타입을 명시한다. 와일드 카드는 type argument
로는 사용될 수 없다.
public <? extends Number> void method(){} // compile-error
위와 같은 형태로 와일드 카드를 사용할 수 없다. 와일드 카드는 제네릭 타입 혹은 제네릭 메소드에서 type argument
를 대신할 수 없다.
그렇다면 와일드 카드는 언제 사용되며 왜 사용하는 것일까? 사실 Lower Bounded를 제외하고, Object를 사용하여 와일드 카드로 가능한 모든 것들을 구현할 수 있다. 하지만, 이는 제네릭을 사용하는 이유와 어긋나는 행동이다. 또한, 와일드 카드를 사용하면 다음과 같은 코드를 간단하게 만들 수 있다.
어떤 타입이든 들어갈 수 있는 List를 List에 넣는다고 가정하자. 와일드 카드로 나타내면 다음과 같다.
public static void main(String[] args) {
List<List<?>> listOfAnyList = new ArrayList<>();
listOfAnyList.add(new ArrayList<String>());
listOfAnyList.add(new ArrayList<Double>());
}
와일드 카드를 사용하지 않는 코드는 다음과 같다.
class ListOfAnyClassWithGeneric{
List<Object> list = new ArrayList<>();
public <T extends List> void addList(T t){
list.add(t);
}
public static void main(String[] args) {
ListOfAnyClassWithGeneric listOfAnyList2 = new ListOfAnyClassWithGeneric();
listOfAnyList2.addList(new ArrayList<String>());
listOfAnyList2.addList(new ArrayList<Double>());
}
}
제네릭 함수가 아닌 곳에서 제네릭 키워드를 사용하려면, 해당 클래스에서 제네릭 메소드를 정의해야 한다. 또한, Object
(raw type)을 사용하는 것은 지양해야 할 방법이다. 이와 같이, 제네릭 키워드로 구현할 수 있지만, 와일드 카드로 손쉽게 구현할 수 있는 경우가 있기 때문에 와일드 카드를 사용한다고 볼 수 있다.
List<? extends Number>
라고 와일드 카드를 사용할 수 있다. List
의 type argument
로 들어올 수 있는 것은 Number
의 인스턴스 이거나, 하위 클래스만 가능하다는 것을 의미한다. 따라서, List<Integer>
or List<Number>
가 가능하다.List<Number>
를 사용한다면, List<Integer>
는 타입 캐스팅이 불가능할 것이다. 왜냐하면, Integer
가 Number
의 하위 클래스라는 것이, List<Integer>
가 List<Number>
의 하위 클래스라는 것을 보장하지 않기 때문이다.List<? super Number>
라고 명시할 경우, ?
에 들어갈 수 있는 것은 Number
의 인스턴스이거나, 부모 클래스(ex. Object
)일 경우만 가능하다. Lower Bounded는 와일드 카드에서만 가능한 특징이다.List<?>
와 같이 사용한다. Unbounded는 Object
클래스의 메소드 혹은 제네릭 클래스에서 type parameter
와 상관없는 메소드를 정의할 때 사용한다.제네릭 메소드는 type parameter
를 가지고 있는 메소드를 말한다. generic type
과 유사해 보이지만, type parameter
의 스코프가 메소드에 한정된다는 점에서 차이가 있다. 즉, 제네릭 메소드의 type parameter
는 독립적인 파라미터이다.
generic type
은generic class
와generic interface
를 의미한다.
제네릭 메소드에 대해 알기 위해 제네릭 타입과 비교하겠다.
class Study<T>{
static T t;
}
static 변수는 type parameter일 수 없다. 왜냐하면 static 변수는 클래스의 인스턴스가 생성되기 이전에 먼저 메모리에 적재되는데, T
가 어떤 type인지 알 수 없기 때문이다.class Study<T>{
static T method(T t){
return t;
}
}
static 메소드의 경우도 static 변수와 동일한 이유로 사용이 불가능 하다.class Study<T>{
static <E> E method(E e){
return e;
}
}
이때 주의할 점은 제네릭 메소드의 type parameter는 제네릭 타입의 type parameter와 별개라는 것이다. 즉, 위 예제에서 T
와 E
는 서로 다른 type parameter이다. 제네릭 메소드는 자신만의 고유한 type parameter를 갖는다. 제네릭 타입에 종속적이지 않다는 특징 덕분에, static 하게 동작할 수 있다.class Study{
static <E> E method(E e){
return e;
}
}
제네릭 메소드에서는 고유한 type parameter를 갖기 때문에, 제네릭 메소드가 정의된 클래스의 Type과는 관계없이 어느 곳에서나 정의될 수 있다.```java
public class Study {
public <T> void method(T t){
System.out.println(t);
}
public static void main(String[] args) {
Study study = new Study();
study.method("Generic method call!");
}
}
```
제네릭은 컴파일 타임에 타이트한 타입 체킹을 수행하기 위해 등장하였다. 제네릭을 구현하기 위해 자바 컴파일러는 type erasure
를 수행한다. type erasure
는 다음과 같다.
type parameter
를 Object
또는 upper bounded class
로 대체한다. 따라서 바이트코드에는 제네릭이 아닌 일반적인 클래스 코드들이 저장된다.type cast
를 추가한다.bridge method
를 추가한다.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;}
}
위와 같은 제네릭 타입에서 type parameter
들은 Object
또는 upper bounded class
로 대체된다. 이렇게 대체함으로서, 실제 객체가 생성될 때 type argument
로 들어올 수 있는 모든 타입들을 수용할 수 있게 한다.
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;}
}
public class Node<T>{
public T data;
public Node(T data){this.data = data;}
public void setData(T data){this.data = data;}
public static void main(String[] args) {
MyNode mn = new MyNode(5);
Node n = mn;
n.setData("Hello");
Integer x = mn.data;
}
}
class MyNode extends Node<Integer>{
public MyNode(Integer data) {
super(data);
}
public void setData(Integer data){super.setData(data);}
}
//출력문
//Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer
// at com.jm.test.MyNode.setData(Node.java:16)
// at com.jm.test.Node.main(Node.java:11)
위 코드는 런타임 에러(ClassCastException
)를 발생시킨다. 제네릭은 컴파일 타임에 타입 체킹을 수행하는데 어째서 런타임 에러가 발생하는 것일까?
public class Node{
public Object data;
public Node(Object data){this.data = data;}
public void setData(Object data){this.data = data;}
public static void main(String[] args) {
MyNode mn = new MyNode(5);
Node n = mn;
n.setData("Hello");
Integer x = mn.data;
}
}
class MyNode extends Node<Integer>{
public MyNode(Integer data) {
super(data);
}
public void setData(Integer data){super.setData(data);}
}
type erasure
가 수행된 이후의 코드이다. Node
의 setData(Object data)
와 MyNode
의 setData(Integer data)
는 서로 파라미터의 타입이 다르기 때문에 오버라이딩이 이뤄지지 않는다. 이 문제를 해결하기 위해 자바 컴파일러는 Bridge method
를 추가한다. Bridge method
는 다음과 같다.
public void setData(Object data){
setData((Integer) data);
}
이를 통해, 오버라이딩이 진행된다. main()
에서 n.setData("Hello")
는 MyNode.setData(Object data)
를 호출하고, 해당 메소드에서는 setData((Integer) data)
를 호출한다. 그런데, 이때 넘겨받은 값은 String
이기 때문에 Integer
로 타입 캐스팅 되지 않는다. 따라서, ClassCastException
이 발생하게 된다.
주의할점! 자바는 Dynamic method dispatch를 지원한다. 실행될 메소드를 결정지을 때 컴파일 타임이 아닌, 런타임에 수행된다. 즉, 부모 클래스 변수에서 자식 클래스 객체를 가리키고 있을 때, 오버라이딩된 메소드를 호출하면 부모 클래스의 메소드가 아닌 자식 클래스의 메소드가 호출된다.