제네릭
제네릭은 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입 체크를 해주는 기능입니다.
왜 제네릭을 사용해야 하는가?
제네릭 타입을 이용함으로써 잘못된 타입이 사용될 수 있는 문제를 컴파일 과정에서 제거할 수 있습니다.
제네릭은 클래스와 인터페이스, 메서드를 정의할 때 타입(type)을 파라미터(parameter)로 사용할 수 있도록 해줍니다.
ArrayList를 생성 시 제네릭을 사용하지 않고 생성을 했을 때 문제점을 찾아보겠습니다.
List list = new ArrayList();
list.add(1);
list.add(2);
list.add(3);
int index1 = (int)list.get(0);
int index2 = (int)list.get(1);
int index3 = (int)list.get(2);
System.out.println("index 1 : " + index1);
System.out.println("index 2 : " + index2);
System.out.println("index 3 : " + index3);
위와 같은 코드는 int타입을 list에 담고 다시 int타입을 꺼내와 출력하는 코드입니다. 문제없이 잘 돌아갑니다.
List list = new ArrayList();
list.add(1);
list.add(2);
list.add(3);
String index1 = (String)list.get(0);
String index2 = (String)list.get(1);
String index3 = (String)list.get(2);
System.out.println("index 1 : " + index1);
System.out.println("index 2 : " + index2);
System.out.println("index 3 : " + index3);
하지만 위의 코드를 실행하면 어떻게 될까요? 컴파일 시에는 문제가 없지만 런타임 시점에 예외가 발생합니다.
지금은 코드가 단순해서 문제가 없어 보이지만 복잡한 상황에 다양한 타입들이 한 컬렉션에 들어가 있다면 대혼란을 일으킬 것입니다.
제네릭 사용법
public class Car<T> {...}
public interface Lotto<T> {...}
클래스나 인터페이스 이름 바로 뒤에 <>를 사용합니다.
class Box<HEELO, HI, A, B, C, D, E, F, G> {...}
이런 식으로도 가능은 합니다. 타입 파라미터의 정해진 규칙은 없지만 관례적으로 대문자 알파벳 한 글자로 표현합니다.
자주 사용하는 타입 인자
타입인자 | 설명 |
<T> | Type |
<E> | Element |
<K> | Key |
<N> | Number |
<V> | Value |
<R> | Result |
제네릭 타입을 통해 다음과 같이 인스턴스 변수나 메서드에 사용할 수 있습니다. 앞으로 나올 예제 코드를 보면서 제네릭을 활용하는 방법도 보시면 되겠습니다.
class Box<T> {
private T t;
public void add(T t) {
this.t = t;
}
public T get() {
return this.t;
}
}
static에도 적용이 되나요?
클래스에 선언한 Box<T>는 인스턴스 변수와 인스턴스 변수에 적용이 가능합니다. static 변수와 메서드는 인스턴스가 생성되기 전 런타임 시점에 static 영역에 저장되기 때문에 공유가 불가능합니다. static메서드는 제네릭으로 선언이 가능하지만 static 변수는 선언이 불가능합니다. 애초에 런타임 시점에 어떤 타입을 받을지 알 수 없기 때문에 컴파일 에러가 발생합니다.
class Box<T> {
private static T T; // 컴파일 에러
public static <T> T method(T t) { // Box의 T와는 다른 타입
return t;
}
}
제네릭 메소드 선언 방법
당연하게도 메서드의 선언부에 제네릭 타입이 선언된 메서드가 제네릭 메서드입니다. 제네릭 메서드를 정의할 때는 리턴 타입이 무엇인지와는 상관없이 내가 제네릭 메서드라는 것을 컴파일러에게 알려줘야 합니다. 그러기 위해서 리턴 타입을 정의하기 전에 제네릭 타입에 대한 정의를 반드시 적어야 합니다.
그리고 중요한 점이 제네릭 클래스가 아닌 일반 클래스 내부에도 제네릭 메서드를 정의할 수 있습니다. 그 말은, 클래스에 지정된 타입 파라미터와 제네릭 메서드에 정의된 타입 파라미터는 상관이 없다는 것입니다. 즉, 제네릭 클래스에 <T>를 사용하고, 같은 클래스의 제네릭 메서드에도 <T>로 같은 이름을 가진 타입 파라미터를 사용하더라도 둘은 전혀 상관이 없다는 것을 의미합니다. 하지만 동료 개발자에게 혼동을 줄 수 있으니 다른 타입의 이름으로 선언하는 것을 추천합니다. (인텔리제이에서도 권장합니다.)
class Box<T> {
private T t;
// 반환값이 T타입이거나 void라면 파리마터로 클래스에 선언한
// T타입 외의 다른 제네릭 타입을 받을 수 없습니다.
public void set(T t) {
this.t = t;
}
public void set(A a) { // 컴파일 에러
this.t = a;
}
// 반환값은 클래스에 선언한 T타입과 같습니다.
public List<T> getT() {
return new ArrayList<>();
}
// <T> 타입으로 새로 선언한 타입입니다.
// 클래스와 선언한 T타입과는 다른 타입입니다.
public <T> List<T> getWho(T t) {
return new ArrayList<>();
}
// static 메소드는 반드시 타입을 명시해야 합니다.
public static <T> T get(T t) {
return t;
}
}
제네릭 주요 개념 (바운디드 타입, 와일드카드)
바운드 타입은 뜻 그대로 타입을 제한할 수 있습니다. 특정 타입의 서브 타입으로 제한하거나 상위 타입을 제한할 수 있습니다.
상한 (Upper-Bounded)
상위에 있는 클래스들을 제한할 수 있습니다. 인터페이스는 implements 키워드를 사용하지 않고 똑같이 extends 키워드를 사용하면 됩니다. 예제 코드로 노트북을 상속하는 클래스만 담는 박스를 만들었습니다. 자동차는 노트북을 상속하지 않기 때문에 박스에 담을 수 없습니다.
class Notebook {}
class Mac extends Notebook {}
class Window extends Notebook {}
class Car {}
class Box<T extends Notebook> {
private List<T> t = new ArrayList<>();
public void add(T t) {
this.t.add(t);
}
}
자동차를 박스에 담으려고 하면 컴파일 에러를 발생합니다.
public static void main(String[] args) {
Box<Notebook> lapTopBox = new Box<>();
lapTopBox.add(new Notebook());
lapTopBox.add(new Mac());
lapTopBox.add(new Window());
lapTopBox.add(new Car()); // 컴파일 에러
}
하한 (Lower-Bounded)
상한과 반대로 하위의 타입들을 제한할 수 있습니다. 코드에 나온?(물음표)는 와일드카드라고 불리며 어떤 타입이든 들어갈 수 있습니다.
class Box<T> {
public void add(Box<? super Mac> t) {...}
}
와일드카드에 들어오는 타입은 Mac이 상속하고 있는 클래스들만 담을 수 있습니다.
Box<Mac> box = new Box();
Box<Mac> macBox = new Box<>();
Box<MacAir> macAirBox = new Box<>();
Box<Notebook> notebookBox = new Box<>();
Box<Window> windowBox = new Box<>();
box.add(macBox);
box.add(macAirBox); // 컴파일 에러
box.add(notebookBox);
box.add(windowBox); // 컴파일 에러
다중 바운드
다중 바운드 타입은 여러 개의 클래스를 제한할 수 있습니다. 자바가 다중 상속이 되지 않는 것처럼 하나의 클래스와 여러 개의 인터페이스로 제한이 가능합니다. 인터페이스보다 클래스 타입을 먼저 선언해야 컴파일 에러가 발생하지 않습니다.
// T 타입은 LapTop을 상속하고 Comparable과 Runnable을 구현한 클래스여야 한다.
class Box<T extends LapTop & Comparable & Runnable> { }
와일드카드
제네릭 타입을 매개 값이나 리턴 타입으로 사용할 때 구체적인 타입 대신 와일드카드를 다음과 같이 세 가지 형태로 사용할 수 있습니다.
- 제네릭 타입 <?>: Unbounded Wildcards(제한 없음)
Box<?> play = new Box<>();
- 제네릭 타입 <? extends 상위 타입>: Upper Bounded Wildcards(상위 클래스 제한)
public void method(Box<? extends Notebook> box) {}
- 제네릭 타입 <? Super 하위 타입>: Lower Bounded Wildcards(하위 클래스 제한)
public void method(Box<? super Notebook> box) {}
Producer-Extends-Consumer-Super
상한과 하한의 개념을 이해하더라도 코드를 작성할 때 어떻게 사용해야 할지 감이 안 오거나 헷갈리는 경우가 많습니다. 이펙티브 자바 아이템 31번에서는 PECS라는 개념으로 어떤 상황에 적절하게 사용할 수 있는지 가이드를 제시해줍니다. 메서드의 매개변수 타입이 생산자의 역할을 한다면 extends를 사용하고 소비자의 역할을 한다면 super를 사용하면 됩니다. 이펙티브 자바에서 나오는 제네릭 관련 내용은 따로 포스팅하겠습니다.
타입 소거 Type Erasure
제네릭의 타입 소거란 원소 타입을 컴파일 타임에만 검사하고 런타임에는 해당 타입 정보를 알 수 없는 것입니다.
List<Long> list = new ArrayList<>();
list를 생성할 때 List <Long>으로 생성하지 않아도 컴파일 오류가 나지 않습니다. 컴파일 시 제네릭 타입이 특정 타입으로 제한되어 있으면 제한된 타입으로 생성되고 그렇지 않으면 Object로 생성됩니다. (그래서 원시 타입으로 제네릭을 생성할 수 없다.) 제네릭은 1.5 버전부터 나왔고 하위 버전에서도 동일하게 작동해야 되기 때문에 하위 호환성을 고려하여 설계되었습니다.
- 제네릭 형식의 모든 형식 매개 변수를 해당 범위로 바꾸거나 형식 매개 변수가 제한되지 않은 경우 Object로 바꿉니다. 따라서 생성된 바이트 코드에는 일반 클래스, 인터페이스 및 메서드만 포함됩니다.
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; }
// ...
}
매개 변수에 제한이 없기 때문에 컴파일러는 Obejct로 변환시킵니다.
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; }
// ...
}
만약 <T extends Comparable> 같이 타입을 제한시킨다면 Obejct가 아닌 Comparable로 변환시킵니다.
오라클 공식 문서 : https://docs.oracle.com/javase/tutorial/java/generics/erasure.html
제네릭을 활용한 예제 코드
예전 토비의 봄 유튜브에서 보았지만 해당 영상을 찾지 못했습니다. 해당 코드는 토비의 봄 유튜브 채널에서 토비님이 설명해주시는 코드를 토대로 작성된 예시 코드입니다.
사과와 바나나를 저장, 조회하는 기능 구현
사과와 바나나 클래스가 id를 가지고 각각 Repository에 저장하고 조회하는 코드를 작성해보겠습니다.
public class Apple {
private Long id;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public static Apple of(Long id) {
Apple apple = new Apple();
apple.setId(id);
return apple;
}
}
public class AppleRepository {
private final Map<Long, Apple> data = new HashMap<>();
public void add(Apple apple) {
data.put(apple.getId(), apple);
}
public Apple findById(Long id) {
return data.get(id);
}
}
public class Banana {
private Long id;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public static Banana of(Long id) {
Banana banana = new Banana();
banana.setId(id);
return banana;
}
}
public class BananaRepository {
private final Map<Long, Banana> data = new HashMap<>();
public void add(Banana banana) {
data.put(banana.getId(), banana);
}
public Banana findById(Long id) {
return data.get(id);
}
}
요구사항 추가 (딸기도 저장하고 조회하도록)
딸기가 추가된다면 딸기에 관한 로직도 만들어야 한다. repository에서 코드 중복이 일어난다. 위 코드의 중복을 제거하려면 제네릭을 사용하여 문제를 해결할 수 있다.
public class GenericRepository<E, K> {
private final Map<E, K> data = new HashMap<>();
public void add(E entity) {
data.put(entity.getId(), entity); // getId 사용 불가
}
public K findById(K k) {
return data.get(k);
}
}
이제 E타입에 과일을 담고, K타입에 키를 담아줄 수 있는 Repository를 만들게 되었습니다.
하지만 현재 E 타입은 Object이기 때문에 getId 메서드를 사용할 수 없습니다. 앞서 배운 바운디드 타입을 사용하여 클래스를 제한하게 된다면 getId메서드를 사용할 수 있습니다. 그전에 Entity클래스를 생성하여 사과와 바나나 클래스에게 상속시켜주겠습니다.
public class Entity<K> {
private K k;
public K getK() {
return k;
}
public void setK(K k) {
this.k = k;
}
}
Entity는 K타입으로 생성될 수 있습니다.. 사과와 바나나 클래스에서 Long으로 생성한 Entity를 상속받게 되면 get과 set메서드는 Long타입으로 제한됩니다.
public class Apple extends Entity<Long> {
public Apple of(Long id) {
Apple apple = new Apple();
apple.setK(id);
return apple;
}
}
public class Banana extends Entity<Long> {
public static Banana of(Long id) {
Banana banana = new Banana();
banana.setK(id);
return banana;
}
}
public class GenericRepository<E extends Entity<K>, K> {
private Map<K, E> data = new HashMap<>();
public void add(E entity) {
data.put(entity.getK(), entity);
}
public E findById(Long id) {
return data.get(id);
}
}
Entity의 하위 타입으로만 E타입이 생성되게 제한하였습니다. 스프링을 data-jpa의 JpaRepository와 유사한 모양이 되었습니다.
public class Main {
public static void main(String[] args) {
Apple apple = Apple.of(1L);
Apple apple1 = Apple.of(2L);
GenericRepository<Apple, Long> apples = new GenericRepository<>();
apples.add(apple);
apples.add(apple1);
Apple byId = apples.findById(1L);
System.out.println(byId);
}
}
이제 과일이 늘어나도 Entity <Long>을 상속받게 하여 과일을 만들고 과일에 맞는 레퍼지토리를 만들어주어 중복되는 코드를 줄일 수 있게 되었습니다.
'개발 > Java' 카테고리의 다른 글
[Java] 데이터 타입, 변수 (0) | 2022.04.16 |
---|---|
[JUnit] @ParameterizedTest - @MethodSource 사용하기 (0) | 2022.02.27 |
[Java] package, import (2) | 2022.02.15 |
[JUnit] Parameterized Test - 테스트를 효율적으로 (1) | 2022.02.11 |
[Java] 리플렉션 (reflection) 개념 이해하기 (0) | 2022.01.29 |