제네릭
- 제네릭이란 클래스나 메소드에서 사용할 내부 데이터 타입을 외부에서 지정하는 기법
제네릭 정의를 이해하기 위해 먼저 제네릭클래스에 대해서 알아보자
제네릭 클래스
- 클래스 선언에 타입매개변수가 쓰인 클래스
class FruitBox<T> { /* */}
단순히 클래스 이름 옆에 꺽쇠괄호와 타임 매개변수를 적어 주면 된다. 여기서 T가 타입 매개변수이다.
제네릭클래스 예시
class FruitBox<T> {
List<T> fruits = new ArrayList<>();
public void add(T fruit) {
fruits.add(fruit);
}
}
FruitBox는 T 타입의 과일들을 가진 리스트를 가지고 있으며 add로 과일을 추가하는 메서드를 가진 클래스이다.
FruitBox 클래스의 인스턴스 생성 과정
FruitBox<Apple> appleBox = new FruitBox<>();
FruitBox 클래스를 외부에서 Apple 타입으로 생산했다.
class FruitBox<Apple> {
List<Apple> fruits = new ArrayList<>();
public void add(Apple fruit) {
fruits.add(fruit);
}
}
그러면 위와 같이 FruitBox에 내부의 타입매개변수가 애플로 변경된다. 제네릭에 정의와 같이 FruitBox에 타입매개변수를 외부에서 Apple로 지정했다. 클래스 내부에서 사용하는 타입매개변수는 지정한 타입이 된다고 생각하시면 편하다. 지만 실제로 지정한 타입으로 변경 되는 것은 아니니 주의가 필요하다. 이것에 대한 것은 제네릭에 타입 이레이저에 대해서 참고가 필요하다.
제네릭을 사용하는 이유
1) 타입 안정성
/* 클래스 */
class Apple {}
class Banana {}
class FruitBox {
private Object fruit;
public FruitBox(Object fruit) {
this.fruit = fruit;
}
public Object getFruit() {
return fruit; // 런타임
}
}
위와 같이 사과, 바나나 클래스와 과일 단 하나를 담을 수 있는 FruitBox 클래스가 있다.
/* 정상 코드 */
public static void main(String[] args) {
FruitBox appleBox = new FruitBox(new Apple());
FruitBox bananaBox = new FruitBox(new Banana());
Apple apple = (Apple) appleBox.getFruit();
Banana banana = (Banana) bananaBox.getFruit();
}
appleBox 와 bananaBox를 만들어서 각각의 과일을 담고 가져 올 수 있다. 이 코드는 정상 코드이므로 성공적으로 작동한다.
/* 에러 코드 */
public static void main(String[] args) {
FruitBox appleBox = new FruitBox(new Apple());
FruitBox bananaBox = new FruitBox(new Banana());
Apple apple = (Apple) appleBox.getFruit();
Banana banana = (Banana) appleBox.getFruit(); // 런타임 에러 발생
}
위 코드도 각각의 과일을 담고 가져오는 코드이나 이번에는 작성자가 실수했다. 애플 박스에서 가져온 과일을 바나나에 대입 해버렸다. 이런 우려는 실행해 보지 않는 이상 모를 것이다. 실행 하고 나서야 이런 버그가 있었다는 것을 알게 된다. 문법적으로는 문제가 없지만 자료형의 오류에 대한 검증이 컴파일 타임에 이루어지지 않으므로써 런타임 오류가 발생하거나 또는 오류 사실을 알지 못하는 경우가 발생한다.
/* 제네릭 클래스 */
class Apple {}
class Banana {}
class FruitBox<T> {
private T fruit;
public FruitBox(T fruit) {
this.fruit = fruit;
}
public T getFruit() {
return fruit; // 런타임
}
}
이번에는 FruitBox 클래스를 제네릭으로 작성 코드이다.
/* 에러 코드 */
public static void main(String[] args) {
FruitBox<Apple> appleBox = new FruitBox<>(new Apple());
FruitBox<Banana> bananaBox = new FruitBox<>(new Banana());
Apple apple = appleBox.getFruit();
Banana banana = appleBox.getFruit(); // 컴파일 에러 발생
}
제네릭클래스 인스턴스에서 아까와 같은 상황이 일어났다고 가정해보면 바로 IntelliJ가 컴파일 에러를 띄워준다. 자바 컴파일러는 제네릭 코드에 강한 타입 체크를 한다. 그리고 만약 타입 안정성에 위배된다면 에러를 발생시킨다. 컴파일타임 에러를 수정 하는 것은 발견하기 어려울 수 있는 런타임 에러 수정한 것보다 훨씬 쉽다.
2) 케스팅 삭제
/* 비제네릭 클래스 */
Apple apple = (Apple) appleBox.getFruit();
/* 제네릭 클래스 */
Apple apple = appleBox.getFruit();
제네릭에 또 하나의 장점은 캐스팅이 사라진다는 것이다. 오브젝트 클래스는 사용하는 타입에 맞게 캐스팅 해줘야만 한다. 하지만 제네릭은 컴파일 타임에 미리 타입이 정해지므로 이런 형변환을 하지 않아도 된다.
요약
제네릭을 사용하는 이유
- 컴파일 타임에 자료형의 오류에 대한 검증을 수행하여 런타임에 자료형에 안전한 코드를 실행한다.
- 반환값에 대한 타입 변환 및 타입 검사에 들어가는 노력을 줄일 수 있고 형변환이 없어짐으로 가독성이 좋아진다.
제네릭 메소드
- <> 안의 타입으로 매개변수의 데이터 타입을 지정
- 타입 파라미터의 범위는 메소드 블록 이내로 한정
public <T> void add(T t) {
// ...
}
제네릭 메소드는 일반 메소드처럼 작성한 후 타입매개변수를 반환 타입으로 지정해주면 된다. 클래스에서 비슷하게 꺽쇠 괄호 안에 타입이 메서드 내부의 타입매개변수로 전해진다. 이것으로 add 메서드는 내부에서 타입매개변수 T를 사용할 수 있다. 이 타입 파라미터의 범위는 메서드 블록 이내로 한정된다. 즉 여기선 add 메서드 내부에서만 사용할 수 있다.
- 제네릭 메서드는 제네릭 클래스 뿐만 아니라 제네릭 클래스가 아니어도 정의가 가능하다.
class Name {
public <T> void printClassName(T t) {
System.out.println(t.getClass().getName());
}
}
public static void main(String[] args) {
Name name = new Name();
name.printClassName(1);
name.printClassName(3.14);
}
실행 결과
제네릭 메서드는 제네릭 클래스 독립적으로 정의할 수 있다. Name 클래스는 printClassName 이라는 하나의 메서드만을 가지고 있다. 이 메서드는 전달받은 매개 변수의 클래스 이름을 출력한다. 예를 들어 printClassName 메서드에 1과 3.14를 전달하면 각각 Integer와 Double을 출력한다.
여기서 주의할 점이 있다. 그것은 바로 제네릭 메서드의 타입 매개변수와 제네릭 클래스의 타입 메게변수는 다를 수 있다는 것이다. 이번에는 아래와 같이 네임 클래스를 String 타입의 제네릭클래스로 인스턴스를 생성했다.
class Name<T> {
public <T> void printClassName(T t) {
System.out.println(t.getClass().getName());
}
}
public static void main(String[] args) {
Name<String> name = new Name<>();
name.printClassName(1);
name.printClassName(3.14);
}
실행 결과
메인 클래스에 printClassName 메서드에 Integer 클래스와 Double 클래스의 인스턴스를 각각 전달했다. 실행결과 Name의 실제 타입매개변수인 String이 아닌 메서드의 인수의 타입인 Integer와 Double이 나왔다. 즉, 메소드와 클래스의 타입매개변수가 갔다면 타입매개변수는 메서드 것을 따라간다.
타입매개변수의 제한
class FruitBox<T> {
private List<Fruit> fruits = new ArrayList<>();
public void add(T fruit) {
fruits.add(fruit); // Compile Error
}
}
다음과 같이 음식에 대한 클래스 계층구조가 있고 위에서 소개드렸던 FruitBox 클래스가 있다. Apple. Banana, Fruit은 모두 Fruit의 하위클래스이므로 T가 Fruit의 하위 클래스라면 문제없이 과일들을 추가할 수 있을 것으로 보인다. 하지만 정상적으로 보이는 위 클래스는 컴파일 에러가 발생한다. 왜냐하면 T가 Fruit의 하위클래스가 아닐 수도 있기 때문이다.
class FruitBox<Vegetable> {
private List<Fruit> fruits = new ArrayList<>();
public void add(Vegetable fruit) {
fruits.add(fruit); // Compile Error
}
}
만약 T에 Vegetable 클래스가 들어갔다고 해본다면 FruitBox는 과일 상자에 야채를 넣으려고 하는 것이다. 이렇듯 잘못된 타입이 들어올 수 있으니 컴파일러가 정보를 보여주는 것이다. 그러므로 Fruit의 하위클래스만 들어올 수 있도록 경계를 설정 해주어야 한다. 연결을 설정하는 방식은 두 가지이다.
상한 경계
- T extends Fruit의 형태로 작성한다.
T는 반드시 Fruit 클래스 혹은 Fruit을 상속 받은 하위 객체여야 한다. Fruit이 상속하는 클래스는 사용할 수 없다. 이렇듯 위를 제한 하였으므로 상한 제한 이다.
하한 경계
- T super Fruit 형태로 작성한다.
T는 반드시 Fruit 클래스 혹은 Fruit 의 상위 클래스여야 한다. Fruit 아래로는 사용할 수 없다. 이렇듯 아래를 제한 하였으므로 하한 경계이다.
class FruitBox<T extends Fruit> {
private List<Fruit> fruits = new ArrayList<>();
public void add(T fruit) {
fruits.add(fruit); // OK
}
}
위에서 컴파일 에러가 발생한 코드를 위와 같이 FruitBox 클래스에 Fruit으로 상한 경계를 설정해 주면 Fruit의 하위클래스만 들어온다는 것을 보장해준다.(<T extends Fruit>으로 Fruit 혹은 Fruit의 하위 클래스만이 올 것을 보장) 그 결과 컴파일 에러가 발생하지 않는 것을 확인할 수 있다.
와일드카드
비경게 와일드카드(Unbounded Wildcards)
- ? 의 형태로 사용. 예를 들어, List<?>
- 모든 타입이 인자가 될 수 있다.
와일드카드는 상한 경계와 하한 경계 때와 다르게 경계가 정해져 있지 않다. 그러므로 어떤 타입이든 인자로 올 수 있다.
비경계 와일드카드의 사용
public static void printList<List<Object> list){
for(Object elem : list){
System.out.println(elem+" ");
}
System.out.println();
}
printLIst 메서드는 리스트가 어떤 타입인지 출력한다. 오브젝트의 리스트이든 String의 리스트이든 말이다. 하지만 이 코드는 한 가지 문제점이 있다,
List<String> strings = new ArrayList<>();
printList(strings); // Complie Error
오브젝트 리스트의 인스턴스만을 인수로 받기 때문에 리스트 스트링이 인수로 온다면 실패 한다.
리스트 오브젝트와 리스트 스트링은 상속관계가 아니기 때문인데 임의의 타입 A의 List는 List의 서브 타입이 아니다.
public static void printList<List<?> list){
for(Object elem : list){
System.out.println(elem+" ");
}
System.out.println();
}
List<String> strings = new ArrayList<>();
printList(strings); // OK
어떤 타입이든 프린트 하기 위해선 오브젝트가 아닌 비경계 와일드카드를 사용해야 한다. 와일드카드를 하면 성공적으로 컴파일이 되는 것을 확인할 수 있다.
타입 A의 리스트는 경계 비경계 와일드카드 리스트의 서브 타입이다. 그러므로 어떤 타입의 리스트이든 프린트 할 수 있다.
비경계 와일드카드 특징
1) List<?>에서 Get한 원소는 Object타입이다.
- 비경계 와일드카드의 원소는 어떤 타입도 될 수 있다.
어떤 타입이 와도 읽을 수 있도록, 모든 타입의 공통 조상인 Object로 받는다.
public static void get<List<?> list){
Object object = list.get(0);
Integer integer = list.get(0); // Compile Error
}
다음과 같이 리스트에서 첫 번째 원소를 가져 오는 코드가 있다. get 메소드에 매개 변수 리스트 입장에서 보면 리스트에 타입매개변수가 모든 타입이 올 수 있는 와일드카드 이므로 인자의 타입이 Integer인지 String인지 어떤 타입인지 정확한 타입을 모른다. 그러므로 모든 클래스의 공통조상인 오브젝트로 받을 수 밖에 없다.
2) List<?>에는 null만 삽입할 수 있다.
- 비경계 와일드카드의 원소가 어떤 타입인지 알 수 없다. 그러므로 타입 안정성을 지키기 위해 null만 삽입할 수 있다.
public static void main(String[] args) {
List<Integer> ints = new ArrayList<>();
addDouble(ints);
}
private static void addDouble(List<?> ints) {
ints.add(3.14);
}
위와같이 int형 리스트가 있다. 그 뒤로 비경계 와일드카드 리스트를 받아서 그 리스트에 Double 원소를 추가하는 메서드가 있다. 만약 와일드카드 리스트에 모든 타입의 값을 넣는 것을 허용한다면 Integer 리스트에 Double 형이 들어가게 되는 문제가 발생한다. 이것은 제네릭에 타임 안정성에 위배되는 일이다. 그러므로 비경계 와일드카드 리스트에는 null만을 삽입할 수 있다.
상한 경제 와일드 카드(Upper Bounded Wildcards)
- ? extends T의 형태로 사용한다. 예를 들어, List<? extends T>
- T 혹은 T의 하위 클래스만 인자로 올 수 있다.
(앞으로 ? extends T를 'T 상한 와일드카드'라 하고 List<? extends T>를 'T 상한 와일드카드의 리스트'라고 읽겠다.)
상한 경계 와일드카드 특징
1) List<? extends T>에서 Get한 원소는 T이다.
- 상한 경계 와일드카드의 원소는 T 혹은 T의 하위 클래스이다.
- 원소들의 최고 공통 조상인 T로 읽으면, 어떤 타입이 오든 T로 읽을 수 있다.
public static void printList<List<? extends Fruit> fruits){
for(Fruit fruit : fruits){
System.out.println(fruits + " ");
}
System.out.println();
}
fruits를 반복문을 돌릴 때 타입을 Fruit로 설정해주어야 한다. 올 수 있는 타입들 중 가장 공통조상인 Fruit으로 하면 어떤 타입의 오든 fruit을 읽을 수 있을 것이다.
public static void printList<List<? extends Fruit> fruits){
for(Apple fruit : fruits){ // Compile Error
System.out.println(fruits + " ");
}
System.out.println();
}
예를 들어 위와 같이 Fruit의 하위 클래스인 Apple로 하면 컴파일 에러가 발생한다. 왜냐하면 Apple로 타입을 설정할 경우 Fruit 혹은 Banana가 들어오면 Apple로 타입을 변경할 수 없기 때문이다.
2) List<? extends T>에는 null만 삽입할 수 있다.
- 상한 경계 와일드카드의 원소가 어떤 타입인지 알 수 없다.
public static void main(String[] args) {
List<Apple> apples = new ArrayList<>();
List<? extends Fruit> fruits = apples;
fruits.add(new Banana()); // Compile Error
}
리스트에는 null만 삽입할 수 있다. 어째서 null만 삽입할 수 있을까? T 혹은 T의 하위 클래스라면 넣을 수 있지 않을까?
위 코드를 보면 Apple의 리스트를 선언하고 Fruit의 상한의 리스트 타입은 Fruit 혹은 Fruit의 하위 클래스만 올 수 있다는 뜻이므로 다음과 같이 fruits에 apples를 대입 가능 하다. 만약 여기서 Fruit의 하위 클래스를 삽입할 수 있다면 다음과 같이 Apple 리스트에 Banana가 들어갈 수 있다. 이렇게 타임 안정성이 깨지게 되므로 다른 클래스의 인스턴스를 삽입할 수 없고
null만 삽입할 수 있다.
하한 경계 와일드카드(Lower Bounded Wiledcards)
- ? super T의 형태로 사용한다. 예를 들어, List<? super T>
- T 혹은 T의 상위 클래스만 인자로 올 수 있다.
하한 경계 와일드카드 특징
1) List<? super T>에서 Get한 원소는 Object이다.
- T 하한 경계 와일드카드의 원소는 T의 상위 클래스 중 어떤 타입도 될 수 있다.
어떤 타입이 와도 읽을 수 있도록, T들의 공통 조상인 Object로 받는다.
public static void printList<List<? super Fruit> fruits){
for(Object fruit : fruits){
System.out.println(fruits + " ");
}
System.out.println();
}
printList의 fruist를 반복문을 돌릴 때 타입을 오브젝트를 설정 타입을 오브젝트로 설정 해주어야 한다. 오브젝트가 아닌 다른 클래스라면 컴파일에러가 발생한다. 어째서 꼭 Object로 받아야 할까? 그건 비경계 와일드카드와 비슷하다. 와일드카드 리스트의 원소들을 읽을 때는 올 수 있는 타입들에 공통조상으로 타입을 설정해야 한다. 어떤 타입이 와도 공통조상 으로 설정하면 해당 클래스로 변환하여 값을 읽어올 수 있기 때문이다. 하한 제한은 아래만을 제한하므로 위로는 어떤 타입이든 올 수 있다. 그러므로 가장 공통조상인 Obect로 읽는다.
(List<Fruit>, List<Food>, List<Object>가 매개변수로 오면 Object타입으로 모두 읽을 수 있다.)
2) List<? super T>에는 T 혹은 T의 하위 클래스만 삽입할 수 있다.
- 하한 경계 와일드카드의 원소는 T 혹은 T의 상위 클래스이다.
public static void main(String[] args) {
List<? super Fruit> fruits = new ArrayList<>();
fruits.add(new Apple());
fruits.add(new Fruit());
fruits.add(new Food()); // Complie Error
}
위 코드에서 Fruit 하한 와일드카드의 리스트 fruits을 생성한다. fruits에 Fruit의 하위 클래스인 Apple과 Fruit을 넣었을 때는 괜찮았지만 Fruit의 상위 클래스인 Food를 넣으니 컴파일 에러 발생했다. fruits는 Fruit 혹은 Fruit의 상위 클래스만 올 수 있는거 아닐까? 어째서 Fruit과 Fruit의 하위 클래스만 추가할 수 있을까?
컴파일 에러의 원인
컴파일러는 fruits가 Fruit의 리스트인지 Food의 리스트인지 Object의 리스트인지 모른다. 만약 fruits가 List<Fruit> 일 경우, Food는 Fruit의 상위 클래스이므로 원소를 추가할 수 없다. 컴파일러 이 상황을 대비하여 Food를 추가할 수 없도록 에러를 발생시킨다.
T 혹은 T의 하위 클래스만 삽입할 수 있는 이유
Fruit 혹은 Fruit의 하위 클래스만 삽입할 수 있는 이유는, fruits는 List<Fruit>, List<Food>, List<Object> 타입으로 올 수 있는데 이들 중 어떤 타입의 리스트가 올지 모른다. 하지만 그 중 어떤 리스트여도, Fruit 혹은 Fruit의 하위 클래스를 원소로 추가할 수 있다.
참고
https://www.youtube.com/watch?v=Vv0PGUxOzq0&ab_channel=%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC
'개발 관련 강의 정리 > 10분 테코톡' 카테고리의 다른 글
[10분 테코톡] 😼 피카의 TDD와 단위테스트 정리 (0) | 2023.06.26 |
---|---|
[10분 테코톡] 이오의 OSI 7계층 정리 (0) | 2023.06.25 |
[10분 테코톡] 폴로의 Forward proxy vs Reverse proxy vs Load Balancer 정리 (0) | 2023.06.23 |
[10분 테코톡] 레고의 Ajax 정리 (0) | 2023.06.22 |
[10분 테코톡] 달리의 WEB의 응답과 요청 과정 정리 (0) | 2023.06.21 |
댓글