본문 바로가기
Programming/Java

[Generic] 1. 개념과 사용이유

by s.rookie 2022. 2. 21.


서론


개인적으로 자바에서 가장 어려운 부분이 제네릭이라고 생각한다. 무의식중에 제네릭을 사용하면서도 잘 알지 못하는 이런 웃지못할 상황을 한번 해결해보고자 제네릭을 정리하는 시간을 가져본다.

 

먼저 `List`만을 사용하여 코드를 작성해보자.

 

List list = new ArrayList<>();
list.add(1);

// 컴파일 에러
int a = list.iterator().next();

 

다음 코드는 컴파일 에러가 발생할 것이다. 그 이유는 형변환을 해주지 않았기 때문이다.

 

따라서 우리는 다음과 같이 코드를 변경해야 한다.

 

int a = (int) list.iterator().next();

 

여기서 잠깐! 한번 생각을 해보도록 하자.

list가 반환하는 타입이 int인 것을 보장하는 조건이 있는가? 현재 코드상에서는 존재하지 않는다.

 

즉, 현재 list에는 어떠한 객체도 담을 수 있다는 말이 된다.

 

List list = new ArrayList();
list.add(1);
list.add("1");
list.add(new double[]{1.0, 10.0});

 

위의 list 처럼 int, String, double 등 Object의 하위 타입들은 전부 담을 수 있게 된다. 해당 타입을 사용하고 싶으면 명시적 캐스팅이 필수인데 실수를 할 가능성이 너무나도 높은 부분이다.

 

또한, 컴파일시 체크가 되지 않아 런타임 시에 에러가 발생할 수 있는 위험한 상황이 생기기도 한다.

가령 밑의 예시와 같이 말이다.

 

List list = new ArrayList();
list.add(1);
list.add("1");
list.add(new double[]{1.0, 10.0});

int a  = (int)list.get(0);
// 런타임 에러
String b  = (String)list.get(2);

 

이러한 문제를 자바에서는 어떻게 해결했을까?? 지금 설명하고자 한 제네릭이 이러한 문제를 해결해준다.

 

 

 

제네릭 기본 개념 (제네릭 클래스, 제네릭 메서드)


다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입 체크를 해주는 기능`제네릭`이라고 한다.

또한, 클래스나 메서드에서 사용할 내부 데이터 타입을 외부에서 지정하는 기법이라고 불리기도 한다.

 

public class PattiBox<T> { ... }

코드를 통해서 제네릭에 대해서 한번 살펴보도록 하자.

 

 

1. 제네릭 클래스

밑의 코드처럼 클래스 선언에 타입 매개변수가(<T>) 사용되면, 이를 제네릭 클래스라고 명칭한다.

 

class PattiBox<T> {
    List<T> meets = new ArrayList<>();
    
    public void add(T meet) {
        meets.add(meet);
    }
}

 

위의 코드는 `PattiBox`라는 제네릭 클래스이다.

이를 통해, 다음과 같은 인스턴스를 생성하는 코드 작성이 가능해지게 된다.

 

PattiBox<Pork> pattiBox = new PattiBox<>();

 

해당 설명까지를 토대로 무슨 정보를 얻을 수 있는가?

클래스는 타입 매개변수를 `T`로만 설정하였다. 하지만 외부에서 `Pork`라는 타입을 지정해서 넣어주었다는 것을 우리는 알 수 있게 된다.

 

public class PattiBox<Pork> {
    List<Pork> patti = new ArrayList<>();
    
    public void add(Pork pork) {
        patti.add(pork);
    }
}

 

그렇게 되면 `PattiBox` 제네릭 클래스는 아까까지 타입 매개변수가 `T` 였지만, 외부에서 지정한 `Pork`로 타입이 변경되게 된다.

 

하지만 정밀하게 말하자면 실제로는 변경되는 것이 아니다.

사실, 런타임시에 소거가 된다고 말할 수 있다. 이를 `타입 이레이저`라고 부른다.

 


Tip. 타입 이레이저

 

제네릭을 사용하면, 컴파일러는 컴파일 시점에 제네릭에 대해서 `타입 이레이저`라는 프로세스를 적용한다. 이는 타입을 소거하는 프로세스이며, 컴파일이 끝난 이후에는 제네릭에 대한 정보가 없어지게 된다.

 

즉, 컴파일 시점에만 타입에 대한 제약 조건을 설정하고, 런타임 시점에서는 해당 정보를 소거하는 프로세스이다.

 

다음 코드를 통해 타입 이레이저의 과정을 살펴보자.

 

0 단계. 컴파일 시점

public class Box<T extends Fruit> {
    List<T> list = new ArrayList<>();
    
    void add(T t) {
       list.add(t);
    }
    
    T get(int i) {
        return list.get(i);
    }
}

 

 

1 단계. 제네릭 타입의 경게를 제거

 

컴파일 시점에 Box 옆에 있는 `<>`는 지워지고 List의 타입인 `T`는 Fruit로 치환된다. 만약 extends가 없다면 T는 Object로 변환된다.

 

public class Box {
    List<Fruit> list = new ArrayList<>();
    
    void add(Fruit t) {
        list.add(t);
    }
    
    Fruit get(int i) {
        return list.get(i);
    }
}

 

그리고 List<Fruit>List<E>이기 때문에, 역시 타입 이레이저에 의해서 다음과 같이 변환된다.

 

public class Box {
    List list = new ArrayList();
    
    void add(Fruit t) {
        list.add(t);
    }
    
    Fruit get(int i) {
        return list.get(i);
    }
}

 

 

2 단계. 제네릭 타입을 제거한 후, 타입이 일치하지 않으면 형변환을 추가

 

밑의 Box의 get 메서드를 보면, list의 get을 반환하는데, 이는 Object를 반환하므로 Fruit 타입으로 형변환을 시켜주게 되어 타입 이레이저가 동작하게 된다.

public class Box {
    List list = new ArrayList();
    
    void add(Fruit t) {
        list.add(t);
    }
    
    Fruit get(int i) {
        return (Fruit)list.get(i);
    }
}

 

2. 제네릭 메서드

`제네릭 메서드`자체 타입 매개 변수를 사용하는 메서드이다. 제네릭 타입을 선언하는 것과 비슷하지만 타입 매개변수의 범위는 선언된 메서드로 제한된다.

 

public class Student<T> {
    // 타입 파라미터, 리턴타입 파라미터 or void, 매개변수 타입
    static <T> T getOneStudent(T id) {
        return id;
    }
}

 

사용 시 주의해야 할 점은 클래스에서 지정한 제네릭 타입제네릭 메서드에 붙은 <T>같은 T여도 전혀 다른 별개라는 점이다.

 

클래스에 표시하는 <T>는 인스턴스 변수이다. 인스턴스가 생성될 때 마다 지정되기 때문이다. 그리고 제네릭 메서드에 붙은 T는 지역변수를 선언한 것과 같다고 생각하면 된다. 

 

역시 간단한 예시를 통해서 한번 살펴보도록 하자.

 

public class Box<T> {
    private T t;
    
    public T getBox() {
        return t;
    }
    
    public T setBox(T t) {
        this.t = t;
    }
}

public class Util {
    public static <T> Box<T> boxing(T t) {
        Box<T> box = new Box<T>();
        box.setBox(t);
        
        return box;
    }
}

public class Main {
    public static void main(String[] args) {
        Box<Integer> intgerBox = Util.<Integer>boxing(100);
        int intValue = integerBox.getBox();
        
        Box<String> stringBox = Util.boxing("암묵적 호출");
        String stringValue = stringBox.getBox();
        
        System.out.println("intValue : " + intValue + "\n" + "stringValue : " + stringValue);
    }
}

 

Box<T>라는 제네릭 클래스를 만든 후, 제네릭 메서드 <T> Box<T> boxing(T t) 를 포함하는 Util 클래스를 만든다. 

 

진행과정을 보면 제네릭 메서드는 호출 시 사용자가 정의한 타입 파라미터를 이용해서 메서드 내부적으로 모든 타입을 매핑시키고 메서드 리턴타입인 Box<T> 를 리턴하게 된다.

 

위의 실행결과는 어떻게 나오게 될까?? 다음과 같이 나오게 된다.

 

intValue : 100

stringValue : "암묵적 호출"

 

 

이를 통해서 클래스에서 지정한 제네릭 타입 제네릭 메서드에 붙은 <T> 같은 T여도 전혀 다른 별개라는 것을 알 수 있게 된다.

 

 

 

제네릭 사용 이유


1. 타입 안정성을 가질 수 있다

앞에서 설명했듯이 제네릭을 사용함으로써 `타입의 안정성`을 가져올 수 있다. 

 

제네릭을 사용하지 않을 경우 자료형에 대한 검증이 컴파일 단계에서 이루어지지 않아 런타임 에러가 일어날 수 있는 상황을 컴파일 단계에서 확인할 수 있도록 해준다.

public class Apple { ... }
public class Banana { ... }

public class FruitBox<T> {
    private T fruit;
    
    public FruitBox(T fruit) {
        this.fruit = fruit;
    }
    
    public T getFruit() {
        return fruit;
    }
}
public static void main(String... args) {
    FruitBox<Apple> appleBox = new FruitBox<>(new Apple()); 

    Banana banana = appleBox.getFruit();    // 컴파일 에러 발생
}

 

 

2. 형 변환의 번거로움을 줄일 수 있다

또한 제네릭을 사용함으로써 '캐스팅을 삭제`할 수 있다.

 

제네릭을 사용하지 않을 경우 사용하는 타입에 맞게 캐스팅을 해주어야 한다. 

이런 문제를 제네릭을 사용함으로써 타입이 정해지므로 캐스팅을 할 필요가 없어 가독성이 좋아지게 된다.

 

public static void main(String[] args) {
    // 제네릭을 사용하기 전
    FruitBox appleBox = new FruitBox(new Apple());
    Apple apple = (Apple) appleBox.getFruit();
    
    // 제네릭을 사용한 후
    FruitBox<Apple> appleBox = new FruitBox<>(new Apple());
    Apple apple = appleBox.getFruit();
}

 

 

 

제한된 제네릭과 와일드 카드


1. 타입 매개변수의 제한 (상한 경계, 하한 경계)

public class FruitBox<T> {
    private List<Fruit> fruits = new ArrayList<>();
    
    public void add(T fruit) {
        fruits.add(fruit);
    }
}

 

위의 사진과 같은 클래스 계층구조가 있다고 가정을 한 후 `과일들을 추가하는 기능을 하는 객체`를 만들기 위해 위의 코드와 같이 작성을 하였다.

 

과연 위의 코드는 올바르게 동작할까?

안타깝게도 컴파일 에러가 발생한다. 그 이유는 add 메서드의 T가 Fruit의 하위 클래스가 들어온다는 보장을 할 수 없기 때문이다.

 

이러한 문제를 해결하기 위해 상한 경게, 하한 경계라는 방법이 있다.

 

 

상한 경계타입 매개변수의 클래스거나 타입 매개변수의 하위 클래스로 제약을 두는 것을 말한다. 

 

public class FruitBox<T extends Fruit> {
    private List<Fruit> fruits = new ArrayList<>();
    
    public void add(T fruit) {
        fruits.add(fruit);
    }
}

 

하한 경계타입 매개변수의 클래스거나 타입 매개변수의 상위 클래스로 제약을 두는 것을 말한다.

 

public class FruitBox<T super Fruit> {
    List<Fruit> fruits = new ArrayList<>();
    
    public void add(T fruit) {
        fruits.add(fruit);
    }
}

 

 

2. 와일드카드

 

와일드 카드는 비경계 와일드카드, 상한 경계 와일드카드, 하위 경계 와일드카드 이렇게 3개로 구분을 할 수 있다.

 

 

1. 비경계 와일드카드

 

?의 형태로 사용이 된다. 예를 들면, List<?> 처럼 사용을 한다.

 

비경계 와일드카드는 모든 타입이 인자가 될 수 있다.

public static void printList(List<?> list) {
    for (Object object : list) {
        System.out.println(object + " ");
    }
}
public class Main() {
    public static void main(String... args) {
        List<String> strings = new ArrayList<>();
        printList(strings);
    }
}

 

위의 사진을 보고 알 수 있듯이 List가 어떤 타입이든 출력이 가능한 코드이다. 

 

만약 printList 메서드가 List<Object> list 일 경우 String은 Object의 인스턴스가 아니기에 컴파일에러가 발생할 것이다. 

 

 

이제 비경계 와일드카드의 특징에 대해서 간단히 알아보자.

 

첫번째, List<?>에서 가져온 원소는 Object 타입이다.

 

비경계 와일드카드의 원소는 어떤 타입이 오더라도 될 수 있으며, 가능하게 하기 위해 모든 타입의 공통 조상인 Object로 받게 된다.

public static void get(List<?> list) {
    Object object = list.get(0);
    
    // Compile Error
    Integer object = list.get(0);
}

 

두번째, List<?>에는 null만 삽입을 할 수 있다.

 

비경계 와일드카드의 원소가 어떤 타입인지는 알 수 없다. 즉, 타입안정성을 유지하기 위해 null만 삽입이 가능하다.

만약, 값을 추가할 수 있게 된다면 Integer 타입이 추가된 상태에, Double 타입이 추가되는 모순이 발생할 수 있기 때문이다.

 

 

2. 상한 경계 와일드카드

 

? extends T 의 형태로 사용이 된다. 예를 들면, List<? extends Fruit> 처럼 사용을 한다. 

 

상한 경계 와일드카드는 T혹은 T의 하위 클래스만 인자로 오는 것이 가능하다.

 

public static void printList(List<?> extends Fruit> fruits) {
    for (Fruit fruit : fruits) {
        System.out.println(fruit);
    }
}

 

 

간단하게 비경계 와일드카드의 특징에 대해서 간단히 알아보자.

 

첫번째, List<? extends T>에는 null 만 삽입할 수 있다.

 

위에서 설명한 비경계 와일드카드와 동일한 이유이다. 예시를 통해서 한번 살펴보자.

List<Apple> apples = new ArrayList<>();
List<? extends Fruit> fruits = apples;

// Compile Error
fruits.add(new Banana());

 

역시 상한 경계 와일드카드의 원소가 어떤 타입인지 알 수가 없기 때문에 (List<Apple>로 생성된 fruits 리스트에 `Banana`가 들어가는 모순) null만 삽입이 가능하게 된다.

 

 

 

3. 하한 경계 와일드 카드

 

? super T 의 형태로 사용이 된다. 예를 들면, List<? super Fruit> 처럼 사용을 한다.

 

하한 경계 와일드카드는 T혹은 T의 상위 클래스만 인자로 오는 것이 가능하다.

 

 

동일하게 하한 경계 와일드카드의 특징에 대해서도 간단히 알아보자.

 

첫번째, List<? super T>에서 읽은 타입은 Object이다.

 

T 하한 경계 와일드카드의 원소는 T의 상위 클래스 중 어떠한 타입도 될 수가 있다. 이를 위해, T 들의 공통 조상인 Object로 받게 된다.

 

 

두번째, List<? super T>에는 T혹은 T의 하위 클래스만 삽입할 수 있다.

 

그 이유는 List<Fruit> 일 경우 Food는 Fruit의 상위 클래스에 있기 때문에 원소를 추가할 수 없기 때문이다. 

List<? super Fruit> fruits = new ArrayList<>();

fruist.add(new Apple());
fruits.add(new Fruit());

//compile error
fruits.add(new Food(());

 

 

 

 

.Reference

https://www.youtube.com/watch?v=Vv0PGUxOzq0&t=263s 

https://tecoble.techcourse.co.kr/post/2020-11-09-generics-basic/

 

 

 

댓글