...
제네릭 (Generics) 이란
자바에서 제네릭(Generics)은 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 의미한다. 객체별로 다른 타입의 자료가 저장될 수 있도록 한다.
자바에서 배열과 함께 자주 쓰이는 자료형이 리스트(List)인데, 다음과 같이 클래스 선언 문법에 꺾쇠 괄호 <> 로 되어있는 코드 형태를 한번 쯤은 봤을 것이다.
ArrayList<String> list = new ArrayList<>();
저 꺾쇠 괄호가 바로 제네릭이다. 괄호 안에는 타입명을 기재한다. 그러면 저 리스트 클래스 자료형의 타입은 String 타입으로 지정되어 문자열 데이터만 리스트에 적재할 수 있게 된다.
아래 그림과 같이 배열과 리스트의 선언문 형태를 비교해보면 이해하기 쉬울 것이다. 선언하는 키워드나 문법 순서가 다를뿐, 결국 자료형명을 선언하고 자료형의 타입을 지정한다는 점은 같다고 볼 수 있다.
이처럼 제네릭은 배열의 타입을 지정하듯이 리스트 자료형 같은 컬렉션 클래스나 메소드에서 사용할 내부 데이터 타입(type)을 파라미터(parameter) 주듯이 외부에서 지정하는 이른바 타입을 변수화 한 기능이라고 이해하면 된다.
우리가 변수를 선언할때 변수의 타입을 지정해주듯이, 제네릭은 객체(Object)에 타입을 지정해주는 것이라고 보면 된다.
제네릭 타입 매개변수
위에서 보다시피, 제네릭은 <> 꺾쇠 괄호 키워드를 사용하는데 이를 다이아몬드 연산자라고 한다. 그리고 이 꺾쇠 괄호 안에 식별자 기호를 지정함으로써 파라미터화 할 수 있다. 이것을 마치 메소드가 매개변수를 받아 사용하는 것과 비슷하여 제네릭의 타입 매개변수(parameter) / 타입 변수 라고 부른다.
타입 파라미터 정의
이 타입 매개변수는 제네릭을 이용한 클래스나 메소드를 설계할 때 사용된다.
예를들어 다음 코드는 제네릭을 감미한 클래스를 정의한 코드이다. 클래스명 옆에 <T> 기호로 제네릭을 붙여준 걸 볼 수 있다. 그리고 클래스 내부에서 식별자 기호 T 를 클래스 필드와, 메소드의 매개변수의 타입으로 지정되어 있다.
class FruitBox<T> {
List<T> fruits = new ArrayList<>();
public void add(T fruit) {
fruits.add(fruit);
}
}
제네릭 클래스를 만들었으면 이를 인스턴스화 해보자. 마치 파라미터를 지정해서 보내는 것 처럼 생성 코드에서 꺾쇠 괄호 안에 지정해주고 싶은 타입명을 할당해주면, 제네릭 클래스 선언문 부분으로 가서 타입 파라미터 T 가 지정된 타입으로 모두 변환되어 클래스의 타입이 지정되게 되는 것이다.
// 제네릭 타입 매개변수에 정수 타입을 할당
FruitBox<Integer> intBox = new FruitBox<>();
// 제네릭 타입 매개변수에 실수 타입을 할당
FruitBox<Double> intBox = new FruitBox<>();
// 제네릭 타입 매개변수에 문자열 타입을 할당
FruitBox<String> intBox = new FruitBox<>();
// 클래스도 넣어줄 수 있다. (Apple 클래스가 있다고 가정)
FruitBox<Apple> intBox = new FruitBox<Apple>();
이를 그림으로 표현해보면, 다음과 같이 제네릭 타입 전파가 행해진고 보면 된다. <T> 부분에서 실행부에서 타입을 받아와 내부에서 T 타입으로 지정한 멤버들에게 전파하여 타입이 구체적으로 설정 되는 것이다. 이를 전문 용어로 구체화(Specialization) 라고 한다.
타입 파라미터 생략
제네릭 객체를 사용하는 문법 형태를 보면 양쪽 두 군데에 꺾쇠 괄호 제네릭 타입을 지정함을 볼 수 있다. 하지만 맨 앞에서 클래스명과 함께 타입을 지정해 주었는데 굳이 생성자까지 제네릭을 지정해 줄 필요가 없다. (중복)
따라서 jdk 1.7 버전 이후부터, new 생성자 부분의 제네릭 타입을 생략할 수 있게 되었다. 제네릭 나름대로 타입 추론을 해서 생략 된 곳을 넣어주기 때문에 문제가 없는 것이다.
FruitBox<Apple> intBox = new FruitBox<Apple>();
// 다음과 같이 new 생성자 부분의 제네릭의 타입 매개변수는 생략할 수 있다.
FruitBox<Apple> intBox = new FruitBox<>();
타입 파라미터 할당 가능 타입
제네릭에서 할당 받을 수 있는 타입은 Reference 타입 뿐이다. 즉, int형 이나 double형 같은 자바 원시 타입(Primitive Type)을 제네릭 타입 파라미터로 넘길 수 없다는 말이다.
우리가 Wrapper 클래스에 대해 공부할때 int형, double형이 이미 존재하는데, 왜 굳이 똑같은 역할을 하는 Integer형, Double형 클래스를 만들어놨을까 고민을 해본적이 있었을 것이다. 바로 이때 사용하는 것이라고 이해하면 된다.
바로 적응이 되지는 않겠지만, 객체 지향 프로그래밍에서는 모든 것이 객체로 통신하기 때문에 번거롭더라도 익숙해 지어야 한다.
// 기본 타입 int는 사용 불가 !!!
List<int> intList = new List<>();
// Wrapper 클래스로 넘겨주어야 한다. (내부에서 자동으로 언박싱되어 원시 타입으로 이용됨)
List<Integer> integerList = new List<>();
또한 제네릭 타입 파라미터에 클래스가 타입으로 온다는 것은, 클래스끼리 상속을 통해 관계를 맺는 객체 지향 프로그래밍의 다형성 원리가 그대로 적용이 된다는 소리이다.
아래 예제 코드를 보면 타입 파라미터로 <Fruit> 로 지정했지만 업캐스팅을 통해 그 자식 객체도 할당이 됨을 볼 수 있다.
class Fruit { }
class Apple extends Fruit { }
class Banana extends Fruit { }
class FruitBox<T> {
List<T> fruits = new ArrayList<>();
public void add(T fruit) {
fruits.add(fruit);
}
}
public class Main {
public static void main(String[] args) {
FruitBox<Fruit> box = new FruitBox<>();
// 제네릭 타입은 다형성 원리가 그대로 적용된다.
box.add(new Fruit());
box.add(new Apple());
box.add(new Banana());
}
}
복수 타입 파라미터
제네릭은 반드시 한개만 사용하라는 법은 없다. 만일 타입 지정이 여러개가 필요할 경우 2개, 3개 얼마든지 만들 수 있다.
제네릭 타입의 구분은 꺽쇠 괄호 안에서 쉽표(,)로 하며 <T, U> 와 같은 형식을 통해 복수 타입 파라미터를 지정할 수 있다. 그리고 당연히 클래스 초기화할때 제네릭 타입을 두개를 넘겨주어야 한다.
import java.util.ArrayList;
import java.util.List;
class Apple {}
class Banana {}
class FruitBox<T, U> {
List<T> apples = new ArrayList<>();
List<U> bananas = new ArrayList<>();
public void add(T apple, U banana) {
apples.add(apple);
bananas.add(banana);
}
}
public class Main {
public static void main(String[] args) {
// 복수 제네릭 타입
FruitBox<Apple, Banana> box = new FruitBox<>();
box.add(new Apple(), new Banana());
box.add(new Apple(), new Banana());
}
}
중첩 타입 파라미터
제네릭 객체를 제네릭 타입 파라미터로 받는 형식도 표현할 수 있다.
ArrayList 자체도 하나의 타입으로써 제네릭 타입 파라미터가 될수 있기 때문에 이렇게 중첩 형식으로 사용할 수 있는 것이다.
public static void main(String[] args) {
// LinkedList<String>을 원소로서 저장하는 ArrayList
ArrayList<LinkedList<String>> list = new ArrayList<LinkedList<String>>();
LinkedList<String> node1 = new LinkedList<>();
node1.add("aa");
node1.add("bb");
LinkedList<String> node2 = new LinkedList<>();
node2.add("11");
node2.add("22");
list.add(node1);
list.add(node2);
System.out.println(list);
}
타입 파라미터 기호 네이밍
지금까지 제네릭 기호를 <T> 와 같이 써서 표현했지만 사실 식별자 기호는 문법적으로 정해진 것이 없다.
다만 우리가 for문을 이용할때 루프 변수를 i 로 지정해서 사용하듯이, 제네릭의 표현 변수를 T 로 표현한다고 보면 된다. 만일 두번째, 세번째 제네릭이 필요하다고 보면 for문의 j나 k 같이 S, U 로 이어나간다.
명명하고 싶은대로 아무 단어나 넣어도 문제는 없지만, 대중적으로 통하는 통상적인 네이밍이 있으면 개발이 용이해 지기 때문에 아래 표화 같은 암묵적인 규칙(convention)이 존재한다. 예를들어 예제에서 사용된 T 를 타입 변수(type variable)라고 하며, 임의의 참조형 타입을 의미한다.
타입 | 설명 |
<T> | 타입(Type) |
<E> | 요소(Element), 예를 들어 List |
<K> | 키(Key), 예를 들어 Map<k, v> |
<V> | 리턴 값 또는 매핑된 값(Variable) |
<N> | 숫자(Number) |
<S, U, V> | 2번째, 3번째, 4번째에 선언된 타입 |
제네릭 사용 이유와 이점
1. 컴파일 타임에 타입 검사를 통해 예외 방지
자바에서 제네릭(Generic)은 자바 1.5에 추가된 스펙이다. 그래서 JDK 1.5 이전에서는 여러 타입을 다루기 위해 인수나 반환값으로 Object 타입을 사용했었었다. 하지만 Object로 타입을 선언할 경우 반환된 Object 객체를 다시 원하는 타입으로 일일히 타입 변환을 해야 하며, 런타임 에러가 발생할 가능성도 존재하게 된다.
아래 예제에선 Object 타입으로 선언한 배열에 Apple 과 Banana 객체 타입을 저장하고 이를 다시 가져오는 예제이다.
class Apple {}
class Banana {}
class FruitBox {
// 모든 클래스 타입을 받기 위해 최고 조상인 Object 타입으로 설정
private Object[] fruit;
public FruitBox(Object[] fruit) {
this.fruit = fruit;
}
public Object getFruit(int index) {
return fruit[index];
}
}
public static void main(String[] args) {
Apple[] arr = {
new Apple(),
new Apple()
};
FruitBox box = new FruitBox(arr);
Apple apple = (Apple) box.getFruit(0);
Banana banana = (Banana) box.getFruit(1);
}
그런데 실행해보면 위와 같이 ClassCastException 런타임 에러가 발생하게 된다. 객체를 가져올때 형변환도 잘 해주어 문제가 없는 것 같은데 무엇이 문제일까?
원인은 간단하다. Apple 객체 타입의 배열을 FruitBox에 넣었는데, 개발자가 착각하고 Banana를 형변환하여 가져오려고 하였기 때문에 생긴 현상이다. 미리 코드에서 빨간줄로 알려줬으면 좋겠지만 보다시피 깨끗하다.
제네릭을 이용하면 이런 어처구니 없는 실수를 미연에 방지를 할수 있다. 왜냐하면 코드를 실행하기전 컴파일 타임에 미리 에러를 찾아 알려주기 때문이다.
class FruitBox<T> {
private T[] fruit;
public FruitBox(T[] fruit) {
this.fruit = fruit;
}
public T getFruit(int index) {
return fruit[index];
}
}
public static void main(String[] args) {
Apple[] arr = {
new Apple(),
new Apple()
};
FruitBox<Apple> box = new FruitBox<>(arr);
Apple apple = (Apple) box.getFruit(0);
Banana banana = (Banana) box.getFruit(1);
}
이 처럼 제네릭은 클래스나 메서드를 정의할때 타입 파라미터로 객체의 서브 타입을 지정해줌으로써, 잘못된 타입이 사용될 수 있는 문제를 컴파일 과정에서 제거하여 개발을 용이하게 해준다.
2. 불필요한 캐스팅을 없애 성능 향상
위의 예제 코드에서 Apple 배열을 FruitBox의 Object 배열 객체에 넣고, 배열 요소를 가져올때 반드시 다운 캐스팅(down casting)을 통해 가져와야 했다. 이는 곧 추가적인 오버헤드가 발생하는 것과 같다.
Apple[] arr = { new Apple(), new Apple(), new Apple() };
FruitBox box = new FruitBox(arr);
// 가져온 타입이 Object 타입이기 때문에 일일히 다운캐스팅을 해야함 - 쓸데없는 성능 낭비
Apple apple1 = (Apple) box.getFruit(0);
Apple apple2 = (Apple) box.getFruit(1);
Apple apple3 = (Apple) box.getFruit(2);
반면 제네릭은 미리 타입을 지정 & 제한해 놓기 때문에 형 변환(Type Casting)의 번거로움을 줄일 수 있으며, 타입 겁사에 들어가는 메모리를 줄일 수 있고 더불어 가독성도 좋아진다.
// 미리 제네릭 타입 파라미터를 통해 형(type)을 지정해놓았기 때문에 별도의 형변환은 필요없다.
FruitBox<Apple> box = new FruitBox<>(arr);
Apple apple = box.getFruit(0);
Apple apple = box.getFruit(1);
Apple apple = box.getFruit(2);
제네릭 사용 주의사항
1. 제네릭 타입의 객체는 생성이 불가
제네릭 타입 자체로 타입을 지정하여 객체를 생성하는 것은 불가능 한다. 즉, new 연산자 뒤에 제네릭 타입 파라미터가 올수는 없다.
class Sample<T> {
public void someMethod() {
// Type parameter 'T' cannot be instantiated directly
T t = new T();
}
}
2. static 멤버에 제네릭 타입이 올수 없음
아래 처럼 static 변수의 데이터 타입으로 제네릭 타입 파라미터가 올수는 없다. 왜냐하면 static 멤버는 클래스가 동일하게 공유하는 변수로서 제네릭 객체가 생성되기도 전에 이미 자료 타입이 정해져 있어야 하기 때문이다. 즉, 논리적인 오류인 것이다.
class Student<T> {
private String name;
private int age = 0;
// static 메서드의 반환 타입으로 사용 불가
public static T addAge(int n) {
}
}
class Student<T> {
private String name;
private int age = 0;
// static 메서드의 매개변수 타입으로 사용 불가
public static void addAge(T n) {
}
}
3. 제네릭으로 배열 선언 주의점
기본적으로 제네릭 클래스 자체를 배열로 만들 수는 없다.
class Sample<T> {
}
public class Main {
public static void main(String[] args) {
Sample<Integer>[] arr1 = new Sample<>[10];
}
}
하지만 제네릭 타입의 배열 선언은 허용된다.
위의 식과 차이점은 배열에 저장할 Sample 객체의 타입 파라미터를 Integer 로 지정한다는 뜻이다. 즉, new Sample<Integer>() 인스턴스는 저장이 가능하며, new Sample<String>() 인스턴스는 저장이 불가능하다는 소리이다.
class Sample<T> {
}
public class Main {
public static void main(String[] args) {
// new Sample<Integer>() 인스턴스만 저장하는 배열을 나타냄
Sample<Integer>[] arr2 = new Sample[10];
// 제네릭 타입을 생략해도 위에서 이미 정의했기 때문에 Integer 가 자동으로 추론됨
arr2[0] = new Sample<Integer>();
arr2[1] = new Sample<>();
// ! Integer가 아닌 타입은 저장 불가능
arr2[2] = new Sample<String>();
}
}
제네릭 객체 만들어보기
제네릭을 이용해 직접 클래스와 인터페이스, 메서드를 만들어보고 사용해보는 시간을 가져보자.
제네릭 클래스
클래스 선언문 옆에 제네릭 타입 매개변수가 쓰이면, 이를 제네릭 클래스라고 한다.
class Sample<T> {
private T value; // 멤버 변수 val의 타입은 T 이다.
// T 타입의 값 val을 반환한다.
public T getValue() {
return value;
}
// T 타입의 값을 멤버 변수 val에 대입한다.
public void setValue(T value) {
this.value = value;
}
}
public static void main(String[] args) {
// 정수형을 다루는 제네릭 클래스
Sample<Integer> s1 = new Sample<>();
s1.setValue(1);
// 실수형을 다루는 제네릭 클래스
Sample<Double> s2 = new Sample<>();
s2.setValue(1.0);
// 문자열을 다루는 제네릭 클래스
Sample<String> s3 = new Sample<>();
s3.setValue("1");
}
제네릭 인터페이스
인터페이스에도 제네릭을 적용 할 수 있다. 단, 인터페이스를 implements 한 클래스에서도 오버라이딩한 메서드를 제네릭 타입에 맞춰서 똑같이 구현해 주어야 한다.
interface ISample<T> {
public void addElement(T t, int index);
public T getElement(int index);
}
class Sample<T> implements ISample<T> {
private T[] array;
public Sample() {
array = (T[]) new Object[10];
}
@Override
public void addElement(T element, int index) {
array[index] = element;
}
@Override
public T getElement(int index) {
return array[index];
}
}
public static void main(String[] args) {
Sample<String> sample = new Sample<>();
sample.addElement("This is string", 5);
sample.getElement(5);
}
제네릭 함수형 인터페이스
특히 제네릭 인터페이스가 정말 많이 사용되는 곳이 바로 람다 표현식의 함수형 인터페이스이다. 아직 자바의 람다식에 대해 배우지 않은 독자분들도 계시겠지만, 앞으로 배울 예정일 것이니 람다 함수와 제네릭의 응용 형태를 눈에 익히고 가는것을 추천하는 바다.
// 제네릭으로 타입을 받아, 해당 타입의 두 값을 더하는 인터페이스
interface IAdd<T> {
public T add(T x, T y);
}
public class Main {
public static void main(String[] args) {
// 제네릭을 통해 람다 함수의 타입을 결정
IAdd<Integer> o = (x, y) -> x + y; // 매개변수 x와 y 그리고 반환형 타입이 int형으로 설정된다.
int result = o.add(10, 20);
System.out.println(result); // 30
}
}
제네릭 메서드
제네릭 메서드 부분은 제네릭 클래스, 인터페이스와 달리 난이도가 조금 있다.
아래와 같이 제네릭 클래스에서 제네릭 타입 파라미터를 사용하는 메서드를 제네릭 메서드라고 착각하기 쉬운데, 이것은 그냥 타입 파라미터로 타입을 지정한 메서드 일 뿐이다.
class FruitBox<T> {
public T addBox(T x, T y) {
// ...
}
}
제네릭 메서드란, 메서드의 선언부에 <T> 가 선언된 메서드를 말한다.
위에서는 클래스의 제네릭 <T> 에서 설정된 타입을 받아와 반환 타입으로 사용할 뿐인 일반 메서드라면, 제네릭 메서드는 직접 메서드에 <T> 제네릭을 설정함으로서 동적으로 타입을 받아와 사용할 수 있는 독립적으로 운용 가능한 제네릭 메서드라고 이해하면 된다.
class FruitBox<T> {
// 클래스의 타입 파라미터를 받아와 사용하는 일반 메서드
public T addBox(T x, T y) {
// ...
}
// 독립적으로 타입 할당 운영되는 제네릭 메서드
public static <T> T addBoxStatic(T x, T y) {
// ...
}
}
즉, 제네릭 클래스에 정의된 타입 매개변수와 제네릭 메서드에 정의된 타입 매개변수는 별개인게 되는 것이다. 제네릭 메서드의 제네릭 타입 선언 위치는 메서드 반환 타입 바로 앞이다.
제네릭 메서드 호출 원리
그럼 제네릭 메서드를 호출은 어떻게 할까?
제네릭 타입을 메서드명 옆에 지정해줬으니, 호출 역시 메서드 왼쪽에 제네릭 타입이 위치하게 된다.
FruitBox.<Integer>addBoxStatic(1, 2);
FruitBox.<String>addBoxStatic("안녕", "잘가");
이때 컴파일러가 제네릭 타입에 들어갈 데이터 타입을 메소드의 매개변수를 통해 추정할 수 있기 때문에, 대부분의 경우 제네릭 메서드의 타입 파라미터를 생략하고 호출할 수 있다.
// 메서드의 제네릭 타입 생략
FruitBox.addBoxStatic(1, 2);
FruitBox.addBoxStatic("안녕", "잘가");
그런데 한가지 궁금한 점이 있을 것이다. 클래스 옆에 붙어있는 제네릭과 제네릭 메소드는 똑같은 <T> 인데 어떻게 제네릭 메서드만이 독립적으로 운용되는지 말이다.
사실은 처음 제네릭 클래스를 인스턴스화하면, 클래스 타입 매개변수에 전달한 타입에 따라 제네릭 메소드도 타입이 정해지게 된다. 그런데 만일 제네릭 메서드를 호출할때 직접 타입 파라미터를 다르게 지정해주거나, 다른 타입의 데이터를 매개변수에 넘긴하면 독립적인 타입을 가진 제네릭 메서드로 운용되게 된다.
class FruitBox<T, U> {
// 독립적으로운영되는 제네릭 메서드
public <T, U> void printBox(T x, U y) {
// 해당 매개변수의 타입 출력
System.out.println(x.getClass().getSimpleName());
System.out.println(y.getClass().getSimpleName());
}
}
public static void main(String[] args) {
FruitBox<Integer, Long> box1 = new FruitBox<>();
// 인스턴스화에 지정된 타입 파라미터 <Integer, Long>
box1.printBox(1, 1);
// 하지만 제네릭 메서드에 다른 타입 파라미터를 지정하면 독립적으로 운용 된다.
box1.<String, Double>printBox("hello", 5.55);
box1.printBox("hello", 5.55); // 생략 가능
}
제네릭 타입 범위 한정하기
제네릭에 타입을 지정해줌으로서 클래스의 타입을 컴파일 타임에서 정하여 타입 예외에 대한 안정성을 확보하는 것은 좋지만 문제는 너무 자유롭다는 점이다.
예를들어 다음 계산기 클래스가 있다고 하자. 정수, 실수 구분없이 모두 받을 수 있게 하기위해 제네릭으로 클래스를 만들어주었다. 하지만 단순히 <T> 로 지정하게 되면 숫자에 관련된 래퍼 클래스 뿐만 아니라 String이나 다른 클래스들도 대입이 가능하다는 점이 문제이다.
// 숫자만 받아 계산하는 계산기 클래스 모듈
class Calculator<T> {
void add(T a, T b) {}
void min(T a, T b) {}
void mul(T a, T b) {}
void div(T a, T b) {}
}
public class Main {
public static void main(String[] args) {
// 제네릭에 아무 타입이나 모두 할당이 가능
Calculator<Number> cal1 = new Calculator<>();
Calculator<Object> cal2 = new Calculator<>();
Calculator<String> cal3 = new Calculator<>();
Calculator<Main> cal4 = new Calculator<>();
}
}
개발자의 의도로는 계산기 클래스의 제네릭 타입 파라미터로 Number 자료형만 들어오도록 하고 문자열이나 또 다른 클래스 자료형이 들어오면 안되게 하고 싶다고 한다. 그래서 나온 것이 제한된 타입 매개변수 (Bounded Type Parameter) 이다.
타입 한정 키워드 extends
기본적인 용법은 <T extends [제한타입]> 이다. 제네릭 <T> 에 extends 키워드를 붙여줌으로써, <T extends Number> 제네릭을 Number 클래스와 그 하위 타입(Integer, Double)들만 받도록 타입 파라미터 범위를 제한 한 것이다.
클래스의 상속 키워드와 제네릭의 타입 한정 키워드가 둘다 똑같이 extends 라 혼동할 소지가 다분이 있다. 꺾쇠 괄호 안에 extends가 있으면 이건 제한을 의미하며 괄호 바깥에 있으면 상속으로 보면 된다.
인터페이스 타입 한정
extends 키워드 다음에 올 타입은 일반 클래스, 추상 클래스, 인터페이스 모두 올 수 있다. 인터페이스 부분은 약간 햇깔릴수 있는데, 클래스의 상속 관계와 다름이 없으니 그대로 빗대어 적용하면 된다.
interface Readable {
}
// 인터페이스를 구현하는 클래스
public class Student implements Readable {
}
// 인터페이스를 Readable를 구현한 클래스만 제네릭 가능
public class School <T extends Readable> {
}
public static void main(String[] args) {
// 타입 파라미터에 인터페이스를 구현한 클래스만이 올수 있게 됨
School<Student> a = new School<Student>();
}
다중 타입 한정
만일 2개 이상의 타입을 동시에 상속(구현)한 경우로 타입 제한하고 싶다면, & 연산자를 이용하면 된다. 해당 인터페이스들을 동시에 구현한 클래스가 제네릭 타입의 대상이 되게 된다.
단, 자바에서는 다중 상속을 지원하지 않기 때문에 클래스로는 다중 extends는 불가능하고 오로지 인터페이스로만이 가능하다.
interface Readable {}
interface Closeable {}
class BoxType implements Readable, Closeable {}
class Box<T extends Readable & Closeable> {
List<T> list = new ArrayList<>();
public void add(T item) {
list.add(item);
}
}
public static void main(String[] args) {
// Readable 와 Closeable 를 동시에 구현한 클래스만이 타입 할당이 가능하다
Box<BoxType> box = new Box<>();
// 심지어 최상위 Object 클래스여도 할당 불가능하다
Box<Object> box2 = new Box<>(); // ! Error
}
제네릭이 여러개인 다중 타입 파라미터를 사용할 경우에도 각각 다중 제한을 거는 것도 가능하다. (가독성이 으악이 되는건 함정)
interface Readable {}
interface Closeable {}
interface Appendable {}
interface Flushable {}
class School<T extends Readable & Closeable, U extends Appendable & Closeable & Flushable>
void func(T reader, U writer){
}
}
재귀적 타입 한정
재귀적 타입 한정이란 자기 자신이 들어간 표현식을 사용하여 타입 매개변수의 허용 범위를 한정 시키는 것을 말한다. 실무에선 주로 Comparable 인터페이스와 함께 쓰인다.
예를들어 다음과 같이 <E extends Comparable<E>> 제네릭 E의 타입 범위를 Comparable<E> 로 한정한다는 E를 중첩시킨 표현식을 사용할수 있는데, 이 말은 '타입 E는 자기 자신을 서브 타입으로 구현한 Comparable 구현체로 한정' 한다는 뜻이다.
Comparable는 객체끼리 비교를 해야 할때 compareTo() 메서드를 오버라이딩할때 구현하는 인터페이스이다.
자바에서 Integer, Double, String 등이 값 비교가 되는 이유가 기본적으로 Comparable를 구현하고 있기 때문이다.
즉, Integer 객체를 제네릭 타입 E에 할당하게 된다면, Comparable을 구현한 객체면서 오로지 같은 E인 Integer 타입만 받는다는 의미가 된다. (자기 자신만 받는 다는 표현을 어렵게 빙돌려 표현한 것이다)
다음은 컬렉션을 인자로 받아 컬렉션의 요소들을 최대값(max)를 구해 반환하는 메서드 예제이다. 제네릭 메서드 타입으로 재귀적 타입 한정이 사용되었다.
class Compare {
// 외부로 들어온 타입 E는 Comparable<E>를 구현한 E 객체 이어야 한다.
public static <E extends Comparable<E>> E max(Collection<E> collection) {
if(collection.isEmpty()) throw new IllegalArgumentException("컬렉션이 비어 있습니다.");
E result = null;
for(E e: collection) {
if(result == null) {
result = e;
continue;
}
if(e.compareTo(result) > 0) {
result = e;
}
}
return result;
}
}
public static void main(String[] args) {
Collection<Integer> list = Arrays.asList(56, 34, 12, 31, 65, 77, 91, 88);
System.out.println(Compare.max(list)); // 91
Collection<Number> list2 = Arrays.asList(56, 34, 12, 31, 65, 77, 91, 88);
System.out.println(Compare.max(list2)); // ! Error - Number 추상 메서드는 Comparable를 구현하지않았기 때문에 불가능
}
제네릭 형변환
제네릭 캐스팅 문제
배열과 같은 일반적인 변수 타입과 달리 지네릭 서브 타입간에는 형변환이 불가능하다. 심지어 대입된 타입이 Object일지라도 말이다. 자연스럽게 다형성이 적용될 것이라 생각하였지만, 실상 제네릭은 전달받은 딱 그 타입으로만 서로 캐스팅이 가능한 것이다.
처음에 소개한 타입 파라미터의 다형성은 포함 원소로서 가능하다는거지, 형변환은 객체 대 객체를 말하는 거니 다른 개념이다.
// 배열은 OK
Object[] arr = new Integer[1];
// 제네릭은 ERROR
List<Object> list = new ArrayList<Integer>();
List<Object> listObj = null;
List<String> listStr = null;
// 에러. List<String> -> List<Object>
listObj = (List<String>) listStr;
// 에러. List<Object> -> List<String>
listStr = (List<Object>) listObj;
이 특징이 왜 문제시가 되냐면은 제네릭 객체에 요소를 넣거나 가져올때, 캐스팅 문제로 인해 애로사항이 발생하기 때문이다.
예를 들어 배열 같은 경우 반복문의 변수로 Object 타입으로 받아 사용해도 문제가 없다.
public static void main(String[] args) {
Apple[] integers = new Apple[]{
new Apple(),
new Apple(),
new Apple(),
};
print(integers);
}
public static void print(Fruit[] arr) {
for (Object e : arr) {
System.out.println(e);
}
}
하지만 위의 코드에서 배열을 리스트의 제네릭으로 바꾸면 컴파일 에러가 발생한다.
public static void main(String[] args) {
List<Integer> lists = new ArrayList<>(Arrays.asList(1, 2, 3));
print(lists); // ! 컴파일 에러 발생
}
public static void print(List<Object> list) {
for (Object e : list) {
System.out.println(e);
}
}
배열 같은 경우 print 메서드의 매개변수로 아규먼트가 넘어갈때 Integer[] 배열 타입이 Object[] 배열 타입으로 업캐스팅이 적용되어 문제가 없지만, 제네릭 같은 경우 타입 파라미터가 오로지 똑같은 타입만 받기 때문에 다형성을 이용할수 없어서 그런 것이다.
제네릭 와일드 카드
따라서 제네릭 간의 형변환을 성립되게 하기 위해서는 제네릭에서 제공하는 와일드 카드 ? 문법을 이용하여야 한다.
<?>: Unbounded Wildcards (제한 없음)- 타입 파라미터를 대치하는 구체적인 타입으로 모든 클래스나 인터페이스 타입이 올 수 있다
<? extends 상위타입>: Upper Bounded Wildcards (상위 클래스 제한)- 타입 파라미터를 대치하는 구체적인 타입으로 상위 타입이나 상위 타입의 하위 타입만 올 수 있다
<? super 하위타입>: Lower Bounded Wildcards (하위 클래스 제한)- 타입 파라미터를 대치하는 구체적인 타입으로 하위 타입이나 하위 타입의 상위 타입만 올 수 있다
List<? extends Object> list = new ArrayList<String>();
List<? super String> list2 = new ArrayList<Object>();
public static void main(String[] args) {
List<Integer> lists = new ArrayList<>(Arrays.asList(1, 2, 3));
print(lists); // OK
}
// Number와 그 하위 타입(Integer, Double 등) 만을 받는다
public static void print(List<? extends Number> list) {
for (Object e : list) {
System.out.println(e);
}
}
제네릭 와일드 카드 문법은 자바의 제네릭에서 가장 난이도가 높은 부분이다. 언제 <? extends 상위타입> 를 써야되고 언제 <? super 하위타입> 써야하는지는 현업 개발자도 때때로 어려워 하는 부분이다. 따라서 별도의 아래 포스팅을 통해 제네릭 와일드카드 사용법을 완벽히 이해해보도록 하자.
제네릭 타입 소거
제네릭은 타입 안정성을 위해 JDK 1.5부터 도입된 문법으로 이전 자바에서는 제네릭 타입 파라미터 없이 자바 언어를 코딩해왔다. 그래서 이전의 자바 버전의 코드와 호환성을 위해 제네릭 코드는 컴파일되면 제네릭 타입은 사라지게 된다. 즉, 클래스 파일(.class)에는 제네릭 타입에 대한 정보는 존재하지 않는 것이다.
어찌보면 제네릭은 실제 바이트 코드에는 없는 반쪽짜리 언어 문법이라고 할수 있다. 그래서 제네릭을 개발자가 잘못된 방향으로 설계를 하면 잠재적인 힙 오염(heap pollution) 문제에 빠지게 될 수 있다. 따라서 우리가 바이트 코드를 보고 코딩할 것은 아니지만, 올바르게 제네릭을 설계하기 위해선 제네릭의 컴파일 과정을 한번 쯤 알아둘 필요성이 있다.
# 참고 자료
https://www.youtube.com/watch?v=Vv0PGUxOzq0
https://www.youtube.com/watch?v=w5AKXDBW1gQ
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.