자바 컴파일러의 특징
- 자바는 기본적으로 상위 타입에서 하위 타입으로의 대입을 허용하지 않는 컴파일 규칙이 있다.
- 이는 런타임에 에러가 발생할 여지가 있는 문제 코드를 조기에 발견하여 프로세스가 실행 중에 중단되는 위험을 사전에 차단하기 위한 규칙의 일환이다.
public class Generic1 {
public static void main(String[] args) {
Object a = Integer.valueOf(1);
Integer b = a;
// error: incompatible types: Object cannot be converted to Integer
}
}
- 상위 타입에서 하위 타입으로의 대입을 하기 위해선 명시적으로 형변환 연산자를 사용해 다운캐스팅해야만 컴파일이 가능하다.
public class Generic1 {
public static void main(String[] args) {
Object a = Integer.valueOf(1);
Integer b = (Integer)a; // 컴파일 OK
}
}
- 그런데 만약 자바 컴파일러가 상위 타입에서 하위 타입으로의 대입을 규칙 상 허용해준다면?
- 즉, 자바 컴파일러가 명시적 형변환 연산자를 사용하지 않은 경우(다운캐스팅하지 않은 경우)에도 상위 타입에서 하위 타입으로의 대입을 허용해준다면?
public class Generic1 {
public static void main(String[] args) {
Object a = Integer.valueOf(1);
Integer b = a; // 컴파일 OK
}
}
- 위의 코드에서는
a가 참조하는 인스턴스가 실제로 Integer 타입이므로 Integer b = a;는 논리적으로 문제가 없다.
- 따라서 자바 컴파일러가 규칙 상 상위 타입에서 하위 타입으로의 대입을 허용해도 괜찮다고 판단할 수 있지만
- 규칙 상 컴파일은 통과해도 논리적으로는 오류가 발생하는 문제 코드가 작성될 가능성이 있다.
public class Generic1 {
public static void main(String[] args) {
Object a = Integer.valueOf(1);
a = "bug";
Integer b = a; // 컴파일은 OK
}
}
- 위의 코드에서
Integer b = a; 시점에 a 자체의 타입은 Integer 타입의 상위 타입인 Object이지만 a가 참조하는 실제 인스턴스는 String 타입이므로 논리적인 오류가 발생한다.
- 또한 추후
b가 참조하는 실제 인스턴스가 Integer 타입이라 생각하고 b로부터 Integer 타입의 객체가 수행할 수 있는 동작을 구현한 메서드를 호출하고자 했으나
b가 참조하는 실제 인스턴스는 String 타입이므로 해당 메서드를 찾지 못해 런타임에 에러가 발생할 수 있다.(메서드 검색은 실제 인스턴스의 타입을 기준으로 한다.)
- 런타임에 위와 같은 에러는 시점 상 버그 코드가 작성된 시점과는 꽤나 먼 곳에서 발생할 수 있기 때문에 디버깅도 복잡해지고 프로세스도 실행 중에 중단되기 때문에 여러모로 골치가 아파진다.
- 자바 컴파일러의 특징:
- 자바 컴파일러는 지금 당장 적힌 코드의 논리성은 판단하지 않고 오로지 정해진 규칙과 문법에만 의거해서 컴파일 가능 여부를 판단한다.
- 작성된 코드가 지금 당장 논리적으로는 문제가 없어 보여도 만약 그 코드가 규칙이나 문법을 어겼다면 자바 컴파일러는 무조건 컴파일을 허용하지 않는다.
- 문법과 규칙 검사를 할 때는 작성된 코드의 논리적인 흐름까지는 고려하지 않는다.
- 버그 코드가 작성될 가능성을 언어의 문법이나 규칙을 통해 통제할 수 있다.
- 컴파일 타임 때 문법과 규칙 검사를 함으로써 문제 코드를 컴파일타임에 사전 발견할 수 있다.
제네릭스의 필요성
- 자바 제네릭스도 런타임에 발생할 수 있는 문제를 컴파일타임에 사전 발견하고 런타임 에러 발생 가능성을 제거하기 위한 취지에서 생겨난 문법이자 기능이다.
- 제네릭이 도입되기 전(JDK 1.5 이전 버전)의 자바
List 요소들의 타입: 따로 제한을 두지 않았다.
public class Generic1 {
public static void main(String[] args) {
List ints = new ArrayList();
ints.add(new Integer(1));
ints.add(new Integer(2));
ints.add("bug");
System.out.println(ints);
}
}
- 위의 코드는 지금 당장은 논리적으로 문제가 없어보이지만 런타임 에러가 발생 가능한 코드를 추가할 수 있다.
public class Generic1 {
public static void main(String[] args) {
List ints = new ArrayList();
ints.add(new Integer(1));
ints.add(new Integer(2));
ints.add("bug");
System.out.println(ints);
intSum(ints);
}
static int intSum(List nums){
int sum = 0;
for(int i = 0; i < nums.size(); i++) {
Integer val = (Integer) nums.get(i); // 컴파일 OK, 런타임에 에러
sum += val.intValue();
}
return sum
}
}
- 런타임에
intSum 메서드가 실행되고 for 문의 Integer val = (Integer) nums.get(i);시점에 nums.get(i)의 결과가 문자열 상수 "bug"의 참조값인 경우 Integer 타입으로의 형변환 연산 (Integer)가 불가하므로
- 그 때서야 런타임 에러의 일종인
ClassCastException: String cannot be cast to Integer가 발생하게 된다. => 버그를 작성하는 시점과 버그가 발견되는 시점 간의 간극이 발생한다.
- 제네릭은 위와 같은 문제를 방지하기 위해 컴파일 타임에
List 요소들의 타입을 제한하도록 도입된 문법이다.(자바 5, JDK 1.5 버전부터 도입)
public class Generic1 {
public static void main(String[] args) {
List<Integer> ints = new ArrayList<Integer>();
ints.add(new Integer(1));
ints.add(new Integer(2));
ints.add("bug"); // 컴파일 에러
System.out.println(ints);
intSum(ints);
}
static int intSum(List<Integer> nums){
int sum = 0;
for(int i = 0; i < nums.size(); i++) {
Integer val = (Integer) nums.get(i);
sum += val.intValue();
}
return sum;
}
}
- 객체의 타입 안정성을 보장한다.
- 타입 체크와 형변환 연산을 생략 가능하기 때문에 코드가 간결해진다.
제네릭스 구현 방법
Toy 타입 객체의 참조값을 자신의 멤버로 갖는 ToyBox 클래스
public class ToyBox {
private Toy toy;
public Toy getToy(){
return toy;
}
public void setToy(Toy t) {
this.toy = t;
}
public void pack() {
...
}
public void unpack() {
...
}
}
Book 타입 객체의 참조값을 자신의 멤버로 갖는 BookBox 클래스
public class BookBox {
private Book book;
public Book getBook(){
return book;
}
public void setBook(Book b) {
this.book = b;
}
public void pack() {
...
}
public void unpack() {
...
}
}
Shoes 타입 객체의 참조값을 자신의 멤버로 갖는 ShoesBox 클래스
public class ShoesBox {
private Shoes shoes;
public Shoes getShoes(){
return shoes;
}
public void setShoes(Shoes s) {
this.shoes = s;
}
public void pack() {
...
}
public void unpack() {
...
}
}
- 정확히 동일한 기능을 하지만 멤버의 타입만 다른
Box 클래스들 => 중복 코드 발생, 기능 수정하기가 어렵다.
- 제네릭은 이런 클래스들을 하나로 통합할 수 있도록 타입 자체를 파라미터화할 수 있는 기능을 제공한다.
public class Box<T> {
private T item;
public T getItem(){
return item;
}
public void setItem(T i) {
this.item = i;
}
public void pack() {
...
}
public void unpack() {
...
}
}
- 제네릭을 사용하지 않고 단순 일반 클래스
ObjectBox로 통합하는 경우: 컴파일 타임에 버그를 발견하지 못하고 런타임에 캐스팅 도중 에러 발생할 가능성이 있다.
- 또한 멤버를
Object 타입으로 선언했기 때문에 매번 원하는 타입으로 캐스팅해줘야 하는 불편함이 발생한다.
public class ObjectBox {
private Object item;
public Object getItem(){
return item;
}
public void setItem(Object i) {
this.item = i;
}
public void pack() {
...
}
public void unpack() {
...
}
}
ObjectBox toyBox = new ObjectBox();
toyBox.setItem(new Book()); // 버그
Toy toy = (Toy) toyBox.getItem(); // 컴파일 OK, 런타임에 에러 발생
- 반면 제네릭을 사용해 멤버의 타입 자체를 파라미터화한
Box<T> 클래스를 이용하는 경우: 컴파일 타임에 버그를 발견한다.
Box<Toy> toyBox = new Box<Toy>();
toyBox.setItem(new Book()); // 버그 발견, 컴파일 에러
toyBox.setItem(new Toy());
Toy toy = toyBox.getItem(); // 형변환 연산 필요없다.
제네릭스 용어
Box<T> 클래스 선언에서의 용어
Box<T>: 제네릭 클래스, generic class(인터페이스라면 제네릭 인터페이스)
T: 타입 매개변수, 형식 타입 매개변수, 정규 타입 매개변수, type parameter, formal type parameter
<T>: type parameter section => 타입 매개변수를 선언하고 정의하는 용도
Box<T> 클래스 사용에서의 용어
Box<Toy>: 매겨변수화된 타입, parameterized type
Toy: 타입 인자, 타입 인수, 실제 타입 매개변수, type argument, actual type parameter => 타입 인자 전달 용도
<Toy>: type argument list
- generic type = generic class와 generic interface 한 번에 지칭
타입 파라미터 규칙
- 기본형(primitive type)은 타입 인자로 전달될 수 없다.
Box<int> (X)
Box<Integer> (O)
- 자바 7부터 제네릭 클래스 타입 객체 생성 시 타입 추론: 다이아몬드 연산자를 이용
List<String> strs = new ArrayList<String>();
List<String> strs = new ArrayList<>(); // 다이아몬드 연산자로 타입 추론
- type argument list 안의 type parameter는 2개 이상일 수 있다.
public interface Map<K, V> { ... }
public class HashMap<K,V> { ... }
Map<Long, String> nameById = new HashMap<Long, String>();
Map<Long, String> nameById = new HashMap<>(); // 다이아몬드 연산자로 타입 추론
- parameterized type도 type argument가 될 수 있다.
Map<Long, Box<Toy>> toyBoxById = new HashMap<>();
- 타입 인자 전달은 인스턴스 단위로 가능 => 제네릭 클래스의 static 멤버에는 타입 파라미터 사용 불가
class Box<T> {
static T item; // error
static int compare(T t1, T t2) {...} // error
}
- 제네릭 클래스 안에서 배열 생성 시 배열 생성에 타입 파라미터 사용 불가
class Box<T>{
T[] arr; // OK
T[] toArray(){ new T[arr.length]} // error
}
타입 파라미터의 타입 계층 제한, 상한 경계 지정
extends 키워드를 사용하면 제네릭 클래스의 타입 매개변수에 대한 타입 인자로 전달될 수 있는 타입의 종류를 제한시킬 수 있다. => 제네릭 클래스 타입 매개변수의 타입 계층 제한, 상한 경계 지정
interface Boxable {
default boolean isBreakable() {
return false;
}
}
public class Toy implements Boxable { ... }
public class Book implements Boxable { ... }
public class Box<T extends Boxable> {
// extends로 타입 파라미터 T의 상한 경계를 Boxable로 지정
// Boxable의 서브타입들만 Box의 타입 인자로 전달될 수 있다.
private T item;
...
public void pack() {
if(item.isBreakable()){
putMaxAirCap();
} else {
putAvrAirCap();
}
}
}
T extends Boxable: bounded type parameter
Boxable: upper bound
- upperbound가 인터페이스인 경우에도
implements 키워드가 아닌 extends 키워드를 사용하여 타입 파라미터의 상한 경계를 지정해줘야 한다.
- upperbound는 2개 이상일 수 있다.
- 단, 자바는 클래스 다중 상속은 불가하고 인터페이스 다중 구현은 가능하기 때문에 여러 개의upperbound 중 클래스는 하나여야만 하고 인터페이스는 하나 이상일 수 있다.
- upperbound가 하나의 클래스와 한 개 이상의 인터페이스로 이루어진다면
&로 구분지어야 하고 클래스가 제일 앞에 주어져야 한다.
- upperbound가 여러 개인 경우 이를 multiple bound라고 한다.
class A { ... }
interface B { ... }
interface C { ... }
class D<T extends A & B & C> { ... }
raw type
List strs = new ArrayList();
- 제네릭 타입을 사용하긴 했지만 type argument도 없이 이름만 가지고 사용했을 때
- 존재 이유: 제네릭이 없던 시절에 만들어진 클래스들과의 호환을 위해서
제네릭 메서드
- 다른 박스와 배송지 주소가 같은지를 검사하는 메서드를 구현한다고 해보자
- 서로 다른 타입의 아이템을 갖는 박스끼리도 배송지 주소를 비교할 수 있도록 한다.
public class Box<T extends Boxable> {
private T item;
private String destAddress; // 배송지 주소
public <U extends Boxable> boolean hasSameDestinationAs(Box<U> b) {
return destAddress != null
&& b != null
&& destAddress.equals(b.getDestAddress());
}
}
<U extends Boxable>: 제네릭 메서드hasSameDestinationAs의 타입 매개변수 U 선언, 상한 경계를 Boxable로 지정, U는 제네릭 클래스 Box<T extends Boxable>의 타입 매개변수 T와는 별개의 타입이다.
- 제네릭 메서드
hasSameDestinationAs 의 첫 번째 매개변수 타입으로 선언된 실체화된 매개변수 타입 Box<U>:
U는 제네릭 메서드의 상한 경계가 Boxable로 지정된 타입 매개변수 U가 제네릭 클래스 Box<T extends Boxable>의 타입 매개변수 T에 대해 타입 인자로 전달된 것이다.
- 제네릭 클래스
Box<T extends Boxable>의 타입 매개변수 T와 동일하게 제네릭 메서드의 타입 매개변수 U는 반드시 상한 경계가 Boxable이 되도록 <U extends Boxable>로 선언해야 한다. 그렇지 않으면 컴파일 에러가 발생한다.
- 클래스 타입 매개변수
T와는 달리 U는 반드시 자신이 선언된 제네릭 메서드 hasSameDestinationAs에만 한정되어 사용 가능하다.
- type parameter section(
<U extends Boxable>)의 위치는 제네릭 메서드의 리턴 타입 바로 앞이다.
- 단, 제네릭 메서드가 생성자인 경우엔 리턴 타입이 없므므로 이 경우엔 type parameter section의 위치가 생성자 이름 바로 앞이다.
제네릭 메서드 호출
- 타입 인자를
. 연산자와 제네릭 메서드 이름 사이에 작성하여 제네릭 메서드의 타입 매개변수에 대해 타입 인자를 전달해준다.
Box<Toy> toyBox = new Box<>("addr1");
Box<Book> bookBox = new Box<>("addr2");
Box<Shoes> shoesBox = new Box<>("addr3");
boolean result1 = toyBox.<Book>hasSameDestinationAs(bookBox);
boolean result2 = toyBox.<Shoes>hasSameDestinationAs(shoesBox);
- 제네릭 클래스의 타입 매개변수에 대해 전달되는 타입 인자와 마찬가지로 제네릭 메서드의 타입 매개변수에 대해 전달되는 타입 인자도 타입 추론이 가능하다 => 제네릭 메서드 타입 매개변수에 대한 타입 인자 전달 생략 가능
boolean result1 = toyBox.hasSameDestinationAs(bookBox);
boolean result2 = toyBox.hasSameDestinationAs(shoesBox);
제네릭 생성자
public class Box<T extends Boxable> {
private T item;
private String destAddress; // 배송지 주소
private int boxPrice;
public <U extends Boxable> boolean hasSameDestinationAs(Box<U> b) {
return destAddress != null
&& b != null
&& destAddress.equals(b.getDestAddress());
}
}
- 박스에 담을 아이템과 박스의 가격을 한 번에 파라미터로 받을 수 있는 생성자
- 박스의 가격에 해당하는 값의 타입이 반드시 int가 아니어도 되도록 한다.
public class Box<T extends Boxable> {
private T item;
private String destAddress; // 배송지 주소
private int boxPrice;
public <U extends Boxable> boolean hasSameDestinationAs(Box<U> b) {
return destAddress != null
&& b != null
&& destAddress.equals(b.getDestAddress());
}
public <U extends Number> Box(T i, U boxPrice){
this.item = i;
this.boxPrice = boxPrice.intValue();
}
}
Box<Toy> toyBox = new <Integer>Box<Toy>(new Toy(), 1230);
Box<Book> bookBox = new <Double>Box<Book>(new Book(), 1300.0)
- 제네릭 생성자의 타입 매개변수에 대한 타입 인자도 타입 추론이 가능하다.
Box<Toy> toyBox = new Box<Toy>(new Toy(), 1230);
Box<Book> bookBox = new Box<Book>(new Book(), 1300.0)
Box<Toy> toyBox = new Box<>(new Toy(), 1230);
Box<Book> bookBox = new Box<>(new Book(), 1300.0)
static 제네릭 메서드
- 박스를 요소로 하는 리스트를 전달받아 특정 주소를 배송지로 하는 박스를 제거한 리스트를 반환하는
static 메서드 removeByDestination
public class Box<T extends Boxable> {
private T item;
...
public static <U extends Boxable> List<U> removeByDestination(
List<Box<U>> boxes,
String destAddress) {
// 로직
return removedItems;
}
}
List<Box<Toy>> toyBoxes = new ArrayList<>();
List<Box<Book>> bookBoxes = new ArrayList<>();
List<Toy> removedToys = Box.<Toy>removeByDestination(toyBoxes, "addr1");
List<Book> bookBoxes = Box.<Book>removeByDestination(boookBoxes, "addr2");
List<Toy> removedToys = Box.removeByDesintation(toyBoxes, "addr1");
List<Book> bookBoxes = Box.removeByDestination(boookBoxes, "addr2");
제네릭스 변성
- 공변: 타입 A가 타입 B의 상위 타입일 때, 임의의 타입 생성자
F<>에 대해서 F<A>가 F<B>의 상위 타입이 되는 경우 공변이라고 한다.(타입의 상하위 관계를 유지시키는 경우라 보면 된다.)
- 대표적인 공변 예시: 자바 배열(
Object가 String의 상위 타입 => Object[]가 String[]의 상위 타입)
Integer[] intArray = {1, 2, 3};
Number[] numArray = intArray; // 컴파일 OK
intArray = (Integer[])numArray; // 컴파일 OK
- 배열이 공변이라서 생기는 문제: 버그를 컴파일 타임에 발견하지 못하고 런타임에 에러가 발생하여 발견 가능
Integer[] intArray = {1, 2, 3};
Number[] numArray = intArray;
numArray[0] = 15.9 // 컴파일 OK but 논리적으로 오류가 있는 버그
// 배열의 요소가 참조하는 실제 인스턴스의 타입은 Integer여야 하는데
// Double 타입 인스턴스의 참조값이 배열의 요소로 대입된다.
// 런타임에 ArrayStoreException이 발생
- 그럼에도 자바가 배열을 공변으로 만든 이유: 자바는 객체지향 패러다임을 따르기 때문에 배열도 다형성이 가능하게끔 만들기 위해
static void swap(Object[] arr, int i, int j) {
// 로직
}
Integer[] intArray = {1, 2, 3};
String[] strArray = {"자바", "배열", "공변"};
Toy[] toyArray = { ... };
swap(intArray, 1, 2);
swap(strArray, 1, 2);
swap(toyArray, 1, 2);
- 반공변: 타입 A가 타입 B의 상위 타입일 때, 임의의 타입 생성자
F<>에 대해서 F<A>가 F<B>의 하위 타입이 되는 경우 반공변이라고 한다.(타입의 상하위 관계가 뒤집히는 경우라 보면 된다.)
- 불공변: 공변적이지도 않고 반공변적이지도 않은 경우 불공변이라고 한다.
- 자바 제네릭 클래스는 기본적으로 불공변(invariant)이다. 즉, 공변적이지도 않고 반공변적이지도 않다. (
Object가 String의 상위 타입이지만 List<Object>는 List<String>의 상위 타입이 아니다. List<Object>와 List<String> 간에는 아무런 상하위 관계가 없다.)
List<Integer> intList = new ArrayList<>(List.of(1,2,3));
List<Number> numList = intList; // 컴파일 에러
- 제네릭 클래스는 기본적으로 불공변이므로 업캐스팅과 다운캐스팅, 심지어는 명시적인 캐스팅까지 불가한 것이 default다.
- 자바 배열과 달리 자바 제네릭은 불공변으로 만든 이유: 위와 같은 자바 배열의 공변으로 인한 런타임 에러 발생 가능 문제를 방지하기 위해서
변성과 타입 파라미터의 상한 경계 지정은 관련이 없다.
Box<T extends Number>로 선언해도 Box<Number>와 Box<Integer>는 여전히 아무런 상하위 관계가 없다. 즉, 여전히 불공변인 것이다.
제네릭 클래스 타입 계층
- 제네릭 클래스가 불공변적이라고 해서 제네릭 클래스 자체끼리의 상하위 관계를 정의하지 못하는 것은 아니다.
- 제네릭 클래스의 변성과 관계없이 동일한 타입 파라미터를 갖는 제네릭 클래스 간의 타입 계층 구현이 가능하다.
interface A<T> { ... }
interface B<U> extends A<U> { ... }
class C<V> extends B<V> { ... }
class D<W> extends C<W> { ... }
A<Integer> a = new D<Integer>();
B<Integer> b = new D<Integer>();
C<Integer> c = new D<Integer>();
A<Integer> a = new D<>();
B<Integer> b = new D<>();
C<Integer> c = new D<>();
A<Number> y = new D<Integer>(); // 컴파일 에러: 불공변의 영향
- 타입 파라미터의 개수가 다른 제네릭 클래스 간에도 타입 계층을 구현할 수 있다.
interface A<T> { ... }
class H<J,K> B implements A<J> { ... }
A<Long> a1 = new H<Long, Double>();
A<Long> a2 = new H<Long, String>();
A<Long> a3 = new H<Long, Toy>();
A<Long> a4 = new H<String, Double>();
// 컴파일 에러: 변성과 관계없이 Long과 String은 아무런 상하위 관계가 없다.
와일드카드
- 제네릭 클래스가 기본적으로 불공변이면 제네릭 클래스로는 다형성을 구현할 수 없는 걸까?
- 제네릭 클래스를 이용해서도 다형성을 구현할 수 있게 하기 위해 추가된 문법이자 기능이 와일드카드이다.
- 제네릭 클래스는 기본적으로 불공변이지만 와일드카드를 사용하면 공변 혹은 반공변으로 변경이 가능하다. => 다형성을 구현할 수 있다.
- 자바의 사용 지점 변성: 와일드카드(?)
- 자바는 코틀린이나 스칼라와 달리 제네릭 클래스의 선언부에서
out/in과 같은 변성 키워드를 붙이지 않는다.
- 대신 제네릭 클래스를 사용하는 지점에서 와일드카드(?)로 변성을 표기한다.
| 형태 |
의미(하위/상위) |
대입(서브타이핑) |
안전한 연산 |
List<? extends T> |
T의 하위 타입들 |
List<A>는 A <: T일 때 대입 가능 |
읽기 O(최소 T로 읽힘), 쓰기 X(null만) |
List<? super T> |
T의 상위 타입들 |
List<B>는 T <: B일 때 대입 가능 |
쓰기 O(최소 T를 넣기), 읽기 제한적(Object로만 안전) |
List<?> |
아무 타입 |
모든 List<X> 대입 가능 |
읽기 O, 쓰기 X(null만) |
- 왜
List<Object>가 아니라 List<?>를 쓸까?
List<Object>는 “정말로 요소가 Object인 리스트”만 허용 → List<String> 대입 불가.
List<?>는 “어떤 리스트든” 받되, 타입 안전상 요소 추가는 금지(읽기 전용) → API 인자로 “읽기만 할” 리스트를 받을 때 적합.
void printAll(List<?> xs) { // 어떤 리스트든 가능
for (Object x : xs) System.out.println(x);
// xs.add("x"); // 컴파일 에러: 안전하지 않음
}
- 와일드카드로 제네릭 클래스를 공변으로 사용(
? extends) vs 와일드카드로 제네릭 클래스를 반공변으로 사용(? super)
// 읽어오기에 적합(Producer 역할): extends로 상한 지정, 공변 사용
double sum(List<? extends Number> nums) {
double s = 0;
for (Number n : nums) s += n.doubleValue(); // 안전: 최소 Number
// nums.add(1); // 컴파일 에러: 구체 타입 미정
return s;
}
// 집어넣기에 적합(Consumer 역할): super로 하한 지정, 반공변 사용
void addInts(List<? super Integer> dst, int n) {
for (int i = 0; i < n; i++) dst.add(i);
// 안전: Integer 또는 그 상위(Number X, Object O)
// Integer x = dst.get(0); // 컴파일 에러: 읽으면 타입이 Object
}
- 와일드카드(
<?>) vs 타입 파라미터(<T>)
- 타입 변수
<T>: 여러 파라미터 간 관계(같은 타입이어야 함 등)를 선언 지점에서 모델링.
- 와일드카드(?): 단일 파라미터의 유연한 수용 범위를 주는 문법(사용 지점 변성).
// 두 리스트의 요소 타입이 ‘같다’를 보장해야 하면 <T>가 필요
public static <T> void move(List<T> src, List<T> dst) { ... }
// ‘읽기 전용 아무 리스트’면 와일드카드가 간결
void printAll(List<?> xs) { ... }
타입 파라미터 네이밍 컨벤션
| 이름 |
의미 |
예시 |
| T |
type |
|
| E |
element |
List<E>, Set<E> |
| N |
number |
|
| K V |
key value |
Map<K,V>, HashMap<K,V> |
| R |
return type |
Function<T, R> |
| U,S,... |
additional types |
BiFuntion<T, U, R> |
제네릭 타입 소거
- 컴파일 시 제네릭 타입 정보를 최대한 활용해 타입 안전성을 점검한 뒤,
- 바이트코드에서는 대부분의 제네릭 매개변수/인자 정보를 지워(erase)
Object나 경계(bound) 로 바꿔 넣는 방식.
- 결과적으로 런타임에는
new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass() 가 true.
- 소거 규칙: 타입 변수의 소거는 “왼쪽 첫 경계”. 경계가 인터페이스뿐이어도 그 첫 인터페이스가 소거 대상입니다. 경계가 없다면
Object.
| 소스(컴파일 전) |
소거 결과(컴파일 후) |
설명 |
List<String> |
List(raw와 유사) |
타입 인자 제거 |
T |
T의 좌측 첫 경계의 소거 (없으면 Object) |
<T extends Number & Comparable<T>> → Number |
List<? extends Number> |
List |
상한 와일드카드는 읽기 전용 보장만 컴파일 때 사용 |
List<? super Integer> |
List |
하한 와일드카드도 런타임엔 사라짐 |
| 제네릭 메서드 본문 |
필요한 곳에 캐스트 삽입 |
컴파일러가 안전성 증명 후 강제 캐스트 생성 |
| * 타입 소거의 이유 |
|
|
- 이전(JDK 1.5 이전) 바이트코드와의 완전한 호환성을 위해
- 런타임에 타입 인자를 보관/검사하는 reified generics를 도입하지 않음
- 런타임 타입 정보 부재 : 재귀화 가능한(reifiable) 타입만
instanceof 가능: 원시/비제네릭 타입, 원시(raw) 타입, List<?> 등.
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true
// instanceof List<String> // ❌ 컴파일 에러(비재귀화 타입: non-reifiable)
if (obj instanceof List<?>) { /* OK */ } // ✅
- bridge 메서드 생성
- 타입 소거 때문에 상위의 소거된 시그니처와 하위의 구체 시그니처가 달라지면, JVM이 “오버라이드”로 인식하지 못하는데
- 오버라이딩 시, 반환 타입을 상위 메서드의 반환 타입의 “하위 타입”으로 좁혀서 리턴할 수 있는 규칙인 공변 변환을 보존하기 위해 컴파일러는 bridge 메서드를 생성한다.
class Box<T> { T get() { return null; } } // 소거 후: Object get()
class StringBox extends Box<String> {
@Override String get() { return ""; } // 실제 구현: String get()
// 컴파일러 생성:
// public Object get() { return get(); }
// 브리지: 상위 시그니처(Object) 맞춤
}
- 오버로드 충돌
void f(List<String> xs) {}
void f(List<Integer> xs) {} // ❌ 동일 소거: 둘 다 f(List) 로 충돌