[Java] Generic
제네릭이란?
제네릭은 클래스나 메서드에서 사용할 내부 데이터 타입을 외부에서 지정하는 기법입니다.
제네릭을 사용하는 이유
1) 컴파일 시 강한 타입 체크를 할 수 있습니다.
public class CustomArrayList {
private int size;
private Object[] element = new Object[5];
public void add(Object value) {
element[size++] = value;
}
public Object get(int idx) {
return element[idx];
}
}
public static void main(String[] args) {
CustomArrayList list = new CustomArrayList();
list.add("100");
Integer value = (Integer) list.get(0); // 실행 시 에러가 나는 부분
}
2) 타입 변환(casting)을 제거합니다.
public static void main(String[] args) {
CustomArrayList list = new CustomArrayList();
list.add(50);
Integer value = (Integer) list.get(0);
}
제네릭을 활용하면 다음과 같이 변경할 수 있습니다.
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(10);
String value1 = list.get(0); //컴파일 에러 발생
Integer value2 = list.get(0); //형변환 X
}
제네릭 타입 (Class<T>, interface <T>)
제네릭 타입은 타입을 파라미터로 갖는 클래스와 인터페이스를 말합니다. 제네릭 타입은 클래스 또는 인터페이스 이름 뒤에 "<>" 부호가 붙고, 사이에 타입 파라미터가 위치합니다.
public class ClassName <T> { ... }
public Interface InterfaceName <T> { ... }
여기서 주의할 점은 타입 파라미터로 참조 타입(Reference Type)만 명시할 수 있습니다. 즉. int, double 등 기본 타입(Primitive Type)은 올 수 없습니다.
간단한 제네릭 클래스를 만들어 보겠습니다.
public class Box<T> {
private T t;
public T get() {
return t;
}
public void set(T t) {
this.t = t;
}
}
public static void main(String[] args) {
Box<String> box = new Box<>();
box.set("box");
String value = box.get();
System.out.println("Value : " + value);
System.out.println("Value Type : " + value.getClass().getName());
}
제네릭 클래스인 Box 인스턴스를 생성할 때 "<>" 부호 안에 타입 파라미터를 지정합니다. 그러면 생성한 인스턴스의 제네릭 타입은 지정한 파라미터로 변환됩니다.
위 코드의 출력 결과는 다음과 같습니다.
Value : box
Value Type : java.lang.String
만약, 두 개 이상의 제네릭 타입을 쓰고 싶다면 다음과 같이 각 타입 파라미터를 콤마로 구분하면 됩니다.
public class Product<T, M> {
private T kind;
private M model;
public Product() {
}
public T getKind() {
return this.kind;
}
public M getModel() {
return this.model;
}
}
제네릭 메서드(<T, R> R method(T t))
제네릭 메서드는 매개 타입과 리턴 타입으로 타입 파라미터를 갖는 메서드를 말합니다. 제네릭 메서드를 선언하는 방법은 리턴 타입 앞에 <> 기호를 추가하고 타입 파라미터를 기술한 다음, 리턴 타입과 매개 타입으로 타입 파라미터를 사용하면 됩니다.
클래스에서 지정한 제네릭 타입과 별도로 메서드에서 제네릭 타입을 선언할 수 있는 이유는 정적 메서드를 선언할 때 필요하기 때문입니다.
public <타입파라미터, ...> 리턴타입 메서드명(매개변수, ...) { ... }
public <T> T method(T t){
...
}
다음은 제네릭 메서드 예시를 위한 제네릭 클래스입니다.
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;
}
}
Util 클래스에 정적 제네릭 메서드로 compare()를 정의했습니다. compare() 메서드는 두 개의 Pair를 매개 값으로 받아 K와 V 값이 동일한지 검사하고 boolean 값을 리턴합니다.
public class Util {
public static <K, V> boolean compare(Pair<K, V> p1, Pair<K, V> p2) {
boolean keyCompare = p1.getKey().equals(p2.getKey());
boolean valueCompare = p1.getValue().equals(p2.getValue());
return keyCompare && valueCompare;
}
}
제네릭 메서드가 정상적으로 작동하는지 확인해보겠습니다.
public class Main {
public static void main(String[] args) {
Pair<Integer, String> p1 = new Pair<>(1, "사과");
Pair<Integer, String> p2 = new Pair<>(1, "사과");
boolean result = Util.compare(p1, p2);
System.out.println(result);
}
}
결과가 정상적으로 출력되는 것을 확인할 수 있습니다.
true
제한된 타입 파라미터
상한 경계 (<T extends 최상위 타입>)
타입 파라미터에 지정되는 구체적인 타입을 제한할 필요가 종종 있습니다. 예를 들어 숫자를 연산하는 제네릭 메서드는 매개 값으로 Number 타입 또는 하위 클래스 타입(Byte, Short, Integer, Long, Double)의 인스턴스만 가져야 합니다.
위와 같이, 상위 타입과 하위 클래스 타입만 타입 파라미터로 선언하려면 타입 파라미터 뒤에 extends 키워드를 붙이고
상위 타입을 명시하면 됩니다.
//클래스
class 클래스명<T extends 상위타입> { ... }
//메서드
public <T extends 상위타입> 리턴타입 메소드(메개변수, ...) { ... }
다음 코드는 상한 경계를 사용한 간단한 예시입니다.
public class Box<T extends Number> {
private T t;
public T get() {
return t;
}
public void set(T t) {
this.t = t;
}
}
public static void main(String[] args) {
Box<Integer> boxI = new Box<>();
Box<Float> boxF = new Box<>();
Box<Double> boxD = new Box<>();
Box<String> box = new Box<>(); //컴파일 에러 발생
}
와일드카드 타입
코드에서 ?를 일반적으로 와일드카드라고 부릅니다. 제네릭 타입을 매개 값이나 리턴 타입으로 사용할 때 구체적인 타입 대신에 와일드카드를 다음과 같이 세 가지 형태로 사용할 수 있습니다.
- 제네릭 타입 <?> : Unbounded Wildcards (제한 없음)
- 타입 파라미터를 대치하는 구체적인 타입으로 모든 클래스나 인터페이스 타입이 올 수 있습니다.
- 제네릭 타입 <? extends 상위타입> : Upper Bounded Wildcard (상위 클래스 제한)
- 타입 파라미터를 대치하는 구체적인 타입으로 상위 타입이나 하위 타입만 올 수 있습니다.
- 제네릭타입<? super 하위 타입> : Lower Bounded Wildcards (하위 클래스 제한)
- 타입 파라미터를 대치하는 구체적인 타입으로 하위 타입이나 상위 타입이 올 수 있습니다.
와일드카드를 사용하지 않고 제네릭 타입만 명시하면 어떤 에러가 나는지 살펴보겠습니다.
public class Box<T extends Number> {
private T t;
private Box<T> sub;
public T get() {
return t;
}
public void set(T t) {
this.t = t;
}
public void put(Box<T> box) {
sub = box;
}
}
public static void main(String[] args) {
Box<Integer> boxI = new Box<>();
Box<Number> boxN = new Box<>();
boxI.put(boxF); //컴파일 에러 발생
}
제네릭 타입으로 Integer 객체가 들어가는 boxI 인스턴스를 생성하고, Number 객체가 들어가는 boxN을 생성했습니다.
boxI에 서브 박스로 boxN을 넣고 싶지만, boxI를 생성하며 타입을 Integer로 정해 Number 타입인 boxN은 들어갈 수 없습니다.
위 예제를 컴파일하면 에러가 발생하는 것을 볼 수 있습니다. 제네릭은 타입 호환성이 없어 'Box <Integer>'과 'Box <Number>'은 다르기 때문입니다. 이러한 속성 때문에 제네릭이 유연하다고 느끼지 않을 수 도 있는데, 제대로 활용하려면 정해진 타입 매개변수 대신에 타입 매개변수의 상한 경계, 하한 경계를 명시할 수 있어야 합니다.
위 코드를 와일드카드를 사용해 수정해보겠습니다.
public class Box<T extends Number> {
private T t;
private Box<? extends Number> sub;
public T get() {
return t;
}
public void set(T t) {
this.t = t;
}
public void put(Box<? extends Number> box) {
sub = box;
}
}
public static void main(String[] args) {
Box<Integer> boxI = new Box<>();
Box<Number> boxF = new Box<>();
boxI.put(boxF);
}
put() 메서드의 매개변수 box의 타입 매개변수가 T 또는 그 서브타입을 모두 허용하므로, 원하는 대로 컴파일을 할 수 있게 되었습니다.
타입 소거(Type Erasure)
타입 소거는 원소 타입을 컴파일 타임에만 검사하고 런타임에는 해당 타입 정보를 알 수 없는 것을 말합니다. 다시 말해, 컴파일 타임에만 타입에 대한 제약 조건을 적용하고, 런타임에는 타입에 대한 정보를 소거하는 프로세스입니다.
아래의 제네릭 매서드가 컴파일 시점에 어떻게 바뀌는지 살펴보겠습니다.
public static <E> boolean containsElement(E [] elements, E element){
for (E e : elements){
if(e.equals(element)){
return true;
}
}
return false;
}
컴파일러는 unbound 타입 E를 Object로 변환합니다. 따라서 컴파일러는 타입 안정성을 보장하고 런타임 에러를 예방합니다.
public static boolean containsElement(Object[] elements, Object element) {
for (Object e : elements) {
if (e.equals(element)) {
return true;
}
}
return false;
}
만약 제네릭 타입 파라미터를 bound 하게 설정한다면
public static <E extends Comparable<E>> void containsElement(E[] elements) {
for (E e : elements) {
System.out.println("%s", e);
}
}
타입이 소거될 때 Object가 아닌 한정시킨 타입인 Comparable로 변환됩니다.
public static void containsElement(Comparable[] elements) {
for (Comparable e : elements) {
System.out.println("%s", e);
}
}
확장된 제네릭 타입에서 다형성 보존을 위해 어떠한 클래스나, 인스턴스를 상속 혹은 구현할 때 bridge method를 생성합니다.
타입 소거 전
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;
}
}
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);
}
}
타입 소거 후
public class MyNode extends Node {
// 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);
}
}
타입 소거 후 extends Node <Integer>이 extends Node로 변환된 것을 볼 수 있으며, setData()의 파라미터를 Object가 아닌 Integer로 맞추기 위한 bridge method가 생성된 것을 확인할 수 있습니다.
생성된 setData() 메서드는 MyNode 클래스의 원본 setData를 사용하게 해 줍니다.
참고
'프로그래밍 언어 > Java' 카테고리의 다른 글
[Java] Enum (0) | 2021.12.01 |
---|---|
[Java] Interface (0) | 2021.12.01 |
[Java] Reflection (0) | 2021.11.21 |
[Java] PermGen과 Metaspace (0) | 2021.11.03 |
[Java] ClassLoader (0) | 2021.11.02 |
댓글