타입안정성을 높여줌. 사용될 타입을 미리 지정하지 않고 클래스나 메서드를 정의할 수 있다.
형변환의 번거로움을 줄여줌. 클래스나 메서드에서 사용될 수 있는 데이터 타입을 파라미터화할 수 있다.
타입에 의존하는 클래스나 메서드는 문제가 발생할 수 있음.
//제네릭을 사용하지 않는 경우
//컴파일러는 Box에 어떤 타입의 객체가 저장되는지 알지 못한다.
public class Box {
private Object item;
public void setItem(Object item) {
this.item = item;
}
public Object getItem() {
return item;
}
}
//제네릭을 사용하는 경우
//컴파일러는 Box에 T라는 타입의 객체가 저장됨을 알 수 있다.
public class Box<T> {
private T item;
public void setItem(T item) {
this.item = item;
}
public T getItem() {
return item;
}
}
제네릭을 사용하는 이유
컴파일러가 컴파일 시점에 형변환 오류와 같은 변수 타입과 관련된 오류를 잡아낼 수 있다.
프로젝트 규모가 커질수록, 타입과 관련하여 발생하는 런타임 시점의 오류를 잡아내기가 어려워진다.
변수 타입을 명시함으로써 코드가독성과 유지보수가 용이하다.
컴파일러가 변수 타입을 알기 때문에 코드가 더 효율적으로 최적화될 수 있고, 실행시간 단축 및 성능이 향상된다.
import java.util.ArrayList;
public class Main {
public static void main(String[] args) {
// 제네릭을 명시하지 않은 경우
ArrayList my_list = new ArrayList();
my_list.add("Hello");
my_list.add(123);
// 아래 코드는 문법상 허용된다.
// 그러나 런타임 시점에 오류가 발생한다.
for (Object item : my_list) {
String str = (String) item; // 형변환 시도
System.out.println(str.toUpperCase()); // 런타임에 ClassCastException 발생
}
}
}
🍑 본론
1. 배열은 공변이고, 리스트는 불공변이다.
불공변성 : 자료형 간의 대체가 없음. 즉, 하위 자료형이나 상위 자료형으로의 대체가 허용되지 않는다.
공변성 : 자료형 간의 대체가 가능. 즉, 하위 자료형을 상위 자료형으로 대체할 수 있다.
간단하게 얘기해서
T가 T'의 부모이면 C\도 C<T'>의 부모
import java.util.ArrayList;
import java.util.List;
class Animal {}
class Cat extends Animal {}
//공변성과 불공변성의 차이
Cat cat = new Cat();
Animal animal = cat;
Cat[] cat_array = new Cat[5];
Animal[] animal_array = cat_array //할당 가능
List<Cat> cat_list = new List<Cat>();
List<Animal> animal_list = cat_list //할당 불가능
공변성으로 발생하는 문제의 예시
public class test04 {
public static void main(String[] args) {
Cat[] cats = new Cat[5];
cats[0] = new Cat();
// 배열을 Animal[]로 캐스팅
Animal[] animals = cats;
// 다른 종류의 동물 객체를 추가
animals[1] = new Animal();
// cats 배열을 순회하면서 각 요소를 출력
for (Cat cat : cats) {
System.out.println(cat); // 런타임 시점에서 Unchecked Error인 ArrayStoreException 발생한다.
}
}
}
제네릭을 사용할 경우 다음과 같이 타입 안정성을 보장할 수 있다.
public class test03 {
public static void main(String[] args) {
List<Cat> cats = new ArrayList<>();
cats.add(new Cat());
// 불공변성으로 인해 List<Animal>으로 캐스팅 불가능
List<Animal> animals = cats; // 컴파일 시점에서 Check Error인 Type Mismatch 에러가 발생한다.
// 동일한 리스트를 사용하여 다른 동물을 추가
animals.add(new Animal());
for (Cat cat : cats) {
System.out.println(cat);
}
}
}
배열과 리스트를 섞어서 사용할 경우, 공변성으로 인해 다음과 같은 문제가 발생할 수 있다.
List<String>[] stringLists = new List<String>[1];
List<Integer> intList = List.of(42);
Object[] objects = stringLists; // Object는 String의 부모클래스 이므로 할당가능(공변성)
objects[0] = intList; //object에 원소를 할당하면, stringLists 참조했으므로 stringLists[0] 도 바뀜
String s = stringLists[0].get(0); //
2. 배열은 실체화(reify)된다. 리스트는...
실체화 : 자신의 타입 정보를 런타임에도 알고 있는 것(정확히는 런타임시에도 타입을 지속적으로 체크)
비실체화 : 자신의 타입정보를 런타임 시점에 소거(erasure)하여 컴파일 타임보다 정보를 적게 가지는 것
제네릭 Type Erasure : 컴파일 타입에만 타입 제약 조건을 정의하고, 런타임에는 타입을 제거
타입소거란?
ArrayList<Integer> integerList;
ArrayList<String> stringList;
//이 코드는 컴파일하면,
ArrayList integerList;
ArrayList stringList;
//이렇게 같은 타입으로 변한다. 따라서, 다음과 같은 오버로딩은 불가능하다.
public void overload(List<Integer> integerList) {}
public void overload(List<String> stringList) {}
타입 소거(erasure)로 인한 문제점
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> stringList = new ArrayList<>();
stringList.add("Hello");
stringList.add("World");
// Object 배열 생성
Object[] objArray = new Object[2];
// 제네릭 리스트의 내용을 배열로 복사
copyListToArray(stringList, objArray);
// 배열 요소 출력
for (Object obj : objArray) {
// ClassCastException 발생 가능
String str = (String) obj;
System.out.println(str);
}
}
// 제네릭 리스트를 배열로 복사하는 메서드
public static <T> void copyListToArray(List<T> list, T[] array) {
for (int i = 0; i < list.size(); i++) {
array[i] = list.get(i);
}
}
}
//위의 예제에서는 List<String>을 Object[]로 복사하려고 시도했습니다.
//그러나 자바에서는 제네릭의 타입 정보는 런타임에 소거되기 때문에,
//copyListToArray 메서드는 컴파일 시에는 제대로 동작하지만 런타임에는 제네릭 타입 정보가 소거되어
// Object 배열에 실제로는 String이 아닌 Object로 채워지게 됩니다.
//이렇게 되면 배열에서 값을 가져올 때 ClassCastException이 발생할 수 있습니다.
//따라서, reify한 제네릭과 reify하지 않은 배열을 혼합해서 사용할 때는 주의해야 합니다.
//가능하다면 제네릭을 사용한 컬렉션을 배열로 변환하는 것은 피하는 것이 좋습니다.
배열로 형변환 시, 제네릭 배열 생성 오류나 비검사 형변환 경고가 뜨는 경우 대부분 E[]대신 List를 사용하면 해결된다.
public class Chooser {
private final Object[] choiceArray;
public Chooser(Collection choices) {
choiceArray = choices.toArray();
}
//실제로 이 메서드를 써먹으려면
//반환된 Object를 다시 형변환해주는 과정이 필요하다.
public Object choose() {
Random rnd = ThreadLocalRandom.current();
return choiceArray[rnd.nextInt(choiceArray.length)];
}
}
public class Chooser<T> {
private final T[] choiceArray;
public Chooser(Collection<T> choices){
choiceArray = choices.toArray();
}
//choose 메서드는 그대로
}
//근데 이건 왼쪽이 T[]인데 오른쪽은 object[]이므로 타입이 서로 호환되는지 알 수 없으므로 컴파일에러 발생
public class Chooser<T> {
private final T[] choiceArray;
public Chooser(Collection<T> choices){
//형변환하면 컴파일에러가 생기지 않는다.
choiceArray = (T[]) choices.toArray();
}
//choose 메서드는 그대로
}
//컴파일 에러가 이제 발생하지 않지만, 그대신 경고가 뜸
//why? 명시적 캐스팅은 오류의 원인을 프로그래머가 책임지는것이므로
//리스트를 써서 제네릭을 쓰면 깔끔하다
public class Chooser<T> {
private final List<T> choiceList;
public Chooser(Collection<T> choices){
choiceList = new ArrayList<>(choices);
}
public T choose(){
Random rnd = ThreadLocalRandom.current();
return choiceList.get(rnd.nextInt(choiceList.size()));
}
}
🍑 결론
배열보다 리스트를 사용해라. 둘의 가장 큰 차이는 제네릭을 사용할 수 있냐 없냐. 제네릭의 사용하면 타입안정성, 즉 컴파일 시 타입으로 인한 오류를 잡아낼 수 있다. 런타임 에러는 항상 치명적일 수 있음을 인지하자!
번외
Java 컴파일러의 타입 소거 규칙
unbounded Type(<?>, )는 Object로 변환
bound type()의 경우는 Object가 아닌 Comprarable로 변환
제네릭 타입을 사용할 수 있는 일반 클래스, 인터페이스, 메소드에만 소거 규칙을 적용
타입 안정성 보존을 위해 필요하다면 type casting
확장된 제네릭 타입에서 다형성을 보존하기 위해 bridge method를 생성
bound type의 타입 소거
// 컴파일 할 때 (타입 소거 전)
public class Test<T> {
public void test(T test) {
System.out.println(test.toString());
}
}
// 런타임 때 (타입 소거 후)
public class Test {
public void test(Object test) {
System.out.println(test.toString());
}
}
타입이 컴파일 타임에만 유효하고, 런타임엔 전부 Object로 치환되는 희한한 특성 때문에, 자바 제네릭에는 몇 가지 제약이 존재한다.
원시 타입의 제네릭 타입 생성 불가
제네릭 타입 인스턴스화 불가
제네릭 타입의 static 필드 선언 불가
제네릭 타입으로의 형변환이나 instanceof 사용 불가
제네릭 타입의 배열 생성 불가
제네릭 타입이 포함된 클래스 생성, catch/throw 불가
타입 소거가 됐을 때 동일한 메서드 오버로딩 불가
자바의 제네릭은 각종 제약을 가질 뿐더러 타입 안정성을 완벽하게 보장하지 않으며 잘못된 사용 시 "힙 오염 (Heap pollution)"을 유발하는 주범이 된다.
그럼에도 왜 이런 방식을 사용하나?
C#에서는 제네릭을 조금 다른 방식으로 구현한다.
다음과 같은 C# 코드가 있다고 가정해보자.
List<int> integerList;
List<string> stringList;
//컴파일러는 코드 내에서 List가 사용하는 제네릭 타입 (int, string)을 전부 확인한다. 그리고 List의 코드에서 제네릭 타입 파라미터를 int, string으로 치환한 특수 버전 List를 2개 생성하여 각각 List<int>, List<string>에 대입한다. 이러한 방식은 자바와 달리 완벽한 타입 검사를 지원하고, 타입이 실체화 되어있기 때문에 타입의 동적인 사용이 가능하다.
// 자바와 달리 제네릭 타입의 동적인 생성 가능
new T();
//초창기 자바에는 제네릭이 존재하지 않았다.
//따라서 현재 제네릭을 지원하는 컬렉션 프레임워크 등도 제네릭 없이, Object 타입으로 구현이 되어 있었다.
// 제네릭이 지원되기 전 ArrayList
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0);
//그러다 2004년 JDK 1.5 출시와 함께 제네릭이 도입되었지만 이미 수많은 코드들이 Object를 구현하는 방식으로 짜여져 있었고,
//이들과 호환성을 유지하기 위해 자바는 제네릭 타입을 런타임에 소거하는 방식을 택했다.
//제네릭을 사용하더라도 런타임엔 Object로 치환되기 때문에 기존 코드들과 호환성을 유지할 수 있는 것이다.
Chapter : 5. 제네릭
Item : 28. 배열보다는 리스트를 사용하라
Assignee : jseok0917
🍑 서론
제네릭
제네릭을 사용하는 이유
🍑 본론
1. 배열은 공변이고, 리스트는 불공변이다.
불공변성 : 자료형 간의 대체가 없음. 즉, 하위 자료형이나 상위 자료형으로의 대체가 허용되지 않는다.
공변성 : 자료형 간의 대체가 가능. 즉, 하위 자료형을 상위 자료형으로 대체할 수 있다.
간단하게 얘기해서
2. 배열은 실체화(reify)된다. 리스트는...
배열로 형변환 시, 제네릭 배열 생성 오류나 비검사 형변환 경고가 뜨는 경우 대부분 E[]대신 List를 사용하면 해결된다.
🍑 결론
번외
Java 컴파일러의 타입 소거 규칙
bound type의 타입 소거
타입이 컴파일 타임에만 유효하고, 런타임엔 전부 Object로 치환되는 희한한 특성 때문에, 자바 제네릭에는 몇 가지 제약이 존재한다.
그럼에도 왜 이런 방식을 사용하나?
C#에서는 제네릭을 조금 다른 방식으로 구현한다. 다음과 같은 C# 코드가 있다고 가정해보자.
Referenced by https://velog.io/@bedshanty/%EC%9E%90%EB%B0%94%EC%9D%98-%ED%83%80%EC%9E%85-%EC%86%8C%EA%B1%B0-Type-erasure