제네릭(Generic)이란 단어가 가진 뜻은 "일반적인"이다. 그리고 JDK 1.5 에 도입된 제네릭도 이 "일반적인"에 해당하는 역할을 하고 있다. 아래의 예시를 보자
ArrayList<Integer> intList = new ArrayList<Integer>();
위는 자바에서 정수 타입의 동적 배열을 초기화하는 방법이다. 그리고 아래의 예시를 한번 보자.
ArrayList<Double> doubleList = new ArrayList<Double>();
우리는 처음의 예시를 통해 실수 타입의 동적 배열이라는 것을 유추해낼 수 있다. 그 이유는 바로 < > 안에 선언된 데이터 타입을 보고서일 것이다. 이처럼 데이터 타입을 특정한 하나에 종속되지 않게 하여 타입 제약 없이 일반적으로 사용할 수 있게 하는 것을 제네릭이라 한다. 쉽게 말하자면, 데이터 타입의 일반화이다. 마지막으로 위의 ArrayList가 어떻게 선언되어 있는 클래스인지 한번 보기만 하고 넘어가자.
- 데이터 타입의 일반화 -> 타입의 파라미터화 -> 다형성 증가
- 컴파일 타임에 구체적인 타입 결정 및 강한 타입 체크
- 런타임에 타입 에러 발생 방지 가능
- 캐스팅 명시 수고 X -> 개발 생산성 확대
제네릭이 데이터 타입의 일반화라는 것은 앞서 다뤘는데 코드 상에서 타입을 일반화하는 방식은 원하는 타입을 지정해야 하기 때문에 타입이 파라미터처럼 사용된다. 그리고 제네릭은 데이터 타입을 컴파일 타임에 결정한다.
위의 예시는 컴파일 타임에는 에러가 발생하지 않았다. 하지만, 런타임에 캐스팅을 하다가 ClassCastException 예외가 발생하였다. 물론, 주의를 잘 하면 이런 실수가 발생하지 않을 수 있지만, 로직이 복잡해지고 조직이 커지면 에러가 발생할 가능성이 높다. 하지만 제네릭을 잘 활요하면 런타임에서 발생할 수 있는 타입 에러를 사전에 방지할 수 있다.
또한, 위의 예시처럼 제네릭에 타입을 지정하지 않은 상태에서는 자바의 최상위 클래스인 Object 타입을 가지게 된다. 그리고 이를 실제로 활용하려면 항상 캐스팅을 해주어야 한다.예시는 간단해 보이지만 코드의 양이 많아지고 복잡해지면 실수가 나올 수 있는 부분이다.
반면에 아래는 제네릭에 타입을 명시한 경우이다. 제네릭에 타입을 명시해주면 이후에 캐스팅을 따로 해줄 수고가 없어지는 것을 알 수 있다. 그리고 클래스 파일을 보면 아래와 같이 컴파일러가 제네릭의 타입을 체크하여 캐스팅을 자동으로 명시해준다.
- <>, 타입 변수(Type Variables), 임의의 참조형 타입 의미
- 어떤 문자와도 OK
- 복수의 타입 변수 OK, 쉼표로 구분
- 변수, 파라미터, 반환값에 사용 가능
제네릭의 사용법은 꽤 간단하다. 아래의 예시를 보자
class MyArray<T> {
// T 에 명시된 데이터 타입으로 대체
T element;
void setElement(T element) { this.element = element; }
T getElement() { return element; }
}
위의 예제에서 사용된 'T'를 타입 변수(Type Variable)라고 하며, 임의의 참조형 타입을 의미한다. 그리고 타입 변수는 'T' 뿐만 아니라 어떠한 문자가 와도 상관없으며, 쉼표로 복수의 타입 변수를 구분하여 가질 수 있다. 또한, 변수의 타입, 파라미터의 타입 그리고 반환값의 타입까지 데이터 타입을 명시해야 하는 모든 곳에 활용 가능하다. 그리고 클래스 선언에 제네릭이 사용되면 제네릭 클래스, 메소드에 사용되면 제네릭 메소드라고 한다.
타입 변수에 아무런 문자가 와도 상관은 없지만, 아래의 네이밍 규칙은 지켜주는 것이 좋다.
제네릭을 사용하는 방법은 위의 예제가 일반적인 방법이지만 제네릭에 제약을 주고 사용하는 방법들도 있다.
- 특정 타입의 서브 타입으로 타입 변수 제한
- 인터페이스라도 extends 로 제한
- 멀티 바운드 가능, 선언 순서 주의
class Sample <T extends Number>
위의 예제에서 T 타입 변수에 들어갈 수 있는 타입은 Number 의 서브 클래스만 가능하다는 것을 알 수 있다. 이런식으로, 타입 변수의 범위를 줄이는 방법이 Bounded-Type이다.
확실히, 위의 예제에서도 Number 의 서브 타입이 아닌 String 타입에는 컴파일 에러가 발생하는 것을 알 수 있다.
참고로 아래의 예시 처럼 여러개의 제한을 줄 수도 있으며 and 관계이기에 각각의 범위 제약을 받는다.(B1, B2, B3)
<T extends B1 & B2 & B3>
실제로 사용은 아래와 같이 하며 예제처럼 A,B,C 순서로 제약을 선언하였으면 코드상에 A, B, C 가 순서대로 D 보다 우선 정의되어 있어야 한다.
class A { /* ... */ }
interface B { /* ... */ }
interface C { /* ... */ }
class D <T extends A & B & C> { /* ... */ }
이 내용은 생각보다 실수하기 쉽게 때문에 주의해야 되는 부분이다.
이 예제이서 Number 을 상속받는 Integer 는 성공적으로 컴파일된다. 즉, 제약 조건에 위배되지 않는다는 얘기이다.
그렇다면, Main 클래스 안에 작성할 메소드로서 아래의 예제를 보자
// 내부 요소 출력
public static void printValue(Sample<Number> parameter){
System.out.printValue(parameter.value);
}
...
Sample<Integer> sample = new Sample<>(1);
printValue(sample);
당연히 Integer 요소를 가지고 있는 sample 객체를 printValue() 의 파라미터로 전달하면, Number로 프로모션되어 정상 작동할 것이라 생각할 수 있다. 하지만, 컴파일 타임에 에러가 발생한다.
물론, 제네릭 타입에 사용될 수 있는 클래스 사이의 상속 관계는 유효하다. 하지만 착각하기 쉬운 것은 위의 예제처럼 제네릭은 타입에 대한 명시일 뿐 제네릭을 사용하는 클래스(List)의 상속 관계를 의미하는 것이 아니다.
즉, 제네릭 사이에서는 상속 관계가 있어도 제네릭 클래스는 상속 관계가 없는 데이터 타입으로 동작해서 컴파일 타임에 에러가 발생하는 것이다.
그러나 제네릭 클래스 자체를 상속받거나 인터페이스를 구현하는 것으로 서브 타입을 만들 수 있다. 대표적인 것이 자바 컬렉션이다.
순서대로 ArrayList -> List -> Collection 순으로 제네릭 클래스 간 상속 관계가 설정되는 것을 확인 할 수 있다.
와일드 카드도 위의 바운디드 타입과 마찬가지로 타입 범위를 제한하는 기능을 가지고 있다. 하지만 와일드 카드는 메소드의 매개변수나 리턴타입에 사용된다. 그리고 이 와일드 카드는 '?' 문자로 타입 변수를 표기한다.
public static void printValue ( Sample<? extends Number> parameter) {...}
위의 예제는, printValue() 메소드의 매개변수인 parameter 에 Number 의 서브타입만 받을 수 있게 제약을 주는 방식이다
위의 예제처럼 사용하면 되고 컴파일 에러없이 잘 작동한다.
그리고 눈여겨 볼 점은 앞선 서브 타입 관련 설명에서 Number 라고 명시한 것과 달리 ? extends Number 라는 와일드카드를 통해 타입 제약 조건에 알맞은 어떠한 타입이 들어간 제네릭 클래스라도 파라미터로 받을 수 있다.
public static void printValue ( Sample<?> parameter) {...}
모든 타입을 받을 수 있도록 내부적으로는 Object로 취급된다. 이는 일반적인 타입 변수 선언과 크게 다르지 않다. 아래와 같이 사용한다.
public static void printValue ( Sample<? super Integer> parameter) {...}
LowerBounded 는 말그대로 최소 범위에 대한 제약이고 extends 가 아닌 super 를 사용한다. 그리고 예제는 아래와 같다.
여기서 눈여겨 볼점은 main( ) 메소드 안의 sample 객체의 데이터 타입은 Number 타입을 제네릭으로 가지는 Sample 타입이다. 그리고 printValue()의 파라미터에는 Integer 타입보다 상위이거나 같은 데이터 타입을 제네릭으로 가지는 Sample 객체가 들어갈 수 있게 설정되어 있다.
- 메소드의 파라미터나 리턴타입에 대한 제네릭
- 메소드에 지역성을 가진다.(a.k.a 지역 변수)
제네릭 메소드란 메소드의 선언부의 파라미터나 리턴타입에 활용될 데이터 타입을 제네릭으로 명시한 것을 의미한다. 그리고 제네릭 클래스가 전역 변수처럼 클래스 내부에서 사용가능하다면, 제네릭 메소드의 제네릭은 해당 메소드에서만 유효하다. 사용법은 아래와 같다.
Number를 포함한 서브 타입만 가능한 제약이 있는 제네릭 T 가 리턴타입과 Sample 클래스의 제네릭으로 활용되고 정상 작동한다.
- 타입 소거는 컴파일 타임 타입 체크 이후, 런타임에는 타입 정보를 없애는 것을 의미
- unbounded Type(<?>, )는 Object로 변환
- bound type()은 명시된 타입으로 변환, Comprarable로 변환
- 일반 클래스, 인터페이스, 메소드에만 소거 규칙을 적용
- 타입 안정성 위해 필요하다면 type casting을 추가
- 소거 후 메소드 시그니처의 다형성을 보존하기 위해 bridge method를 생성
- erase 후 같은 메소드 시그니처를 갖게 되는 경우 컴파일 타임 에러 발생
아래는 타입 소거에 대한 간단한 예시이며 Unbounded-type 이다.
// 타입 소거 전
public class Sample<T> {
public void print(T sample) {
System.out.println(sample.toString());
}
}
위의 코드는 런타임에 아래와 같이 동작한다.
// 컴파일 후 타입 소거
public class Sample {
public void print(Object sample) {
System.out.println(sample.toString());
}
}
그리고 아래는 브릿지 메소드(Bridge Method)에 대한 예제이다.
// 타입 소거 전
public class CustomComparator implements Comparator<Integer> {
public int compare(Integer num1, Integer num2) {...}
}
이때 Comparator 의 제네릭 Integer 는 Object가 되기 때문에 compare 메소드에 파라미터로 사용되려면 캐스팅이 되어야 한다. 그래서 이를 자동으로 해결해주기 위한 메소드가 컴파일러에 의해 생기며, 이것이 Bridge Method이다.
// 컴파일 후 타입 소거
public class CustomComparator implements Comparator {
public int compare(Integer num1, Integer num2) {...}
//bridge method 생성
public int compare(Object num1, Object num2) {
return compare((Integer)num1, (Integer)num2);
}
}