본문 바로가기

Study/개발일지

[백엔드온라인TIL] java 학습 5일차

제네릭 (Generics) 이란

자바에서 제네릭(Generics)은 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 의미한다. 객체별로 다른 타입의 자료가 저장될 수 있도록 한다.

자바에서 배열과 함께 자주 쓰이는 자료형이 리스트(List)인데, 다음과 같이 클래스 선언 문법에 꺾쇠 괄호 <> 로 되어있는 코드 형태를 한번 쯤은 봤을 것이다.

JAVA
ArrayList<String> list = new ArrayList<>();Copy

 꺾쇠 괄호가 바로 제네릭이다. 괄호 안에는 타입명을 기재한다. 그러면 저 리스트 클래스 자료형의 타입은 String 타입으로 지정되어 문자열 데이터만 리스트에 적재할 수 있게 된다.

아래 그림과 같이 배열과 리스트의 선언문 형태를 비교해보면 이해하기 쉬울 것이다. 선언하는 키워드나 문법 순서가 다를뿐, 결국 자료형명을 선언하고 자료형의 타입을 지정한다는 점은 같다고 볼 수 있다.

이처럼 제네릭은 배열의 타입을 지정하듯이 리스트 자료형 같은 컬렉션 클래스나 메소드에서 사용할 내부 데이터 타입(type)을 파라미터(parameter) 주듯이 외부에서 지정하는 이른바 타입을 변수화 한 기능이라고 이해하면 된다.

Tip

우리가 변수를 선언할때 변수의 타입을 지정해주듯이, 제네릭은 객체(Object)에 타입을 지정해주는 것이라고 보면 된다.


제네릭 타입 매개변수

위에서 보다시피, 제네릭은 <> 꺾쇠 괄호 키워드를 사용하는데 이를 다이아몬드 연산자라고 한다. 그리고 이 꺾쇠 괄호 안에 식별자 기호를 지정함으로써 파라미터화 할 수 있다. 이것을 마치 메소드가 매개변수를 받아 사용하는 것과 비슷하여 제네릭의 타입 매개변수(parameter) / 타입 변수 라고 부른다.

 

타입 파라미터 정의

이 타입 매개변수는 제네릭을 이용한 클래스나 메소드를 설계할 때 사용된다.

예를들어 다음 코드는 제네릭을 감미한 클래스를 정의한 코드이다. 클래스명 옆에 <T> 기호로 제네릭을 붙여준 걸 볼 수 있다. 그리고 클래스 내부에서 식별자 기호 T 를 클래스 필드와, 메소드의 매개변수의 타입으로 지정되어 있다.

JAVA
class FruitBox<T> {
    List<T> fruits = new ArrayList<>();

    public void add(T fruit) {
        fruits.add(fruit);
    }
}Copy

제네릭 클래스를 만들었으면 이를 인스턴스화 해보자. 마치 파라미터를 지정해서 보내는 것 처럼 생성 코드에서 꺾쇠 괄호 안에 지정해주고 싶은 타입명을 할당해주면, 제네릭 클래스 선언문 부분으로 가서 타입 파라미터 T 가 지정된 타입으로 모두 변환되어 클래스의 타입이 지정되게 되는 것이다.

JAVA
// 제네릭 타입 매개변수에 정수 타입을 할당
FruitBox<Integer> intBox = new FruitBox<>(); 

// 제네릭 타입 매개변수에 실수 타입을 할당
FruitBox<Double> intBox = new FruitBox<>(); 

// 제네릭 타입 매개변수에 문자열 타입을 할당
FruitBox<String> intBox = new FruitBox<>(); 

// 클래스도 넣어줄 수 있다. (Apple 클래스가 있다고 가정)
FruitBox<Apple> intBox = new FruitBox<Apple>();Copy

이를 그림으로 표현해보면, 다음과 같이 제네릭 타입 전파가 행해진고 보면 된다. <T> 부분에서 실행부에서 타입을 받아와 내부에서 T 타입으로 지정한 멤버들에게 전파하여 타입이 구체적으로 설정 되는 것이다. 이를 전문 용어로 구체화(Specialization) 라고 한다.

 

타입 파라미터 생략

제네릭 객체를 사용하는 문법 형태를 보면 양쪽 두 군데에 꺾쇠 괄호 제네릭 타입을 지정함을 볼 수 있다. 하지만 맨 앞에서 클래스명과 함께 타입을 지정해 주었는데 굳이 생성자까지 제네릭을 지정해 줄 필요가 없다. (중복)

따라서 jdk 1.7 버전 이후부터,  new 생성자 부분의 제네릭 타입을 생략할 수 있게 되었다. 제네릭 나름대로 타입 추론을 해서 생략 된 곳을 넣어주기 때문에 문제가 없는 것이다.

JAVA
FruitBox<Apple> intBox = new FruitBox<Apple>();

// 다음과 같이 new 생성자 부분의 제네릭의 타입 매개변수는 생략할 수 있다.
FruitBox<Apple> intBox = new FruitBox<>();Copy

 

타입 파라미터 할당 가능 타입

제네릭에서 할당 받을 수 있는 타입은 Reference 타입 뿐이다. 즉, int형 이나 double형 같은 자바 원시 타입(Primitive Type)을 제네릭 타입 파라미터로 넘길 수 없다는 말이다.

우리가 Wrapper 클래스Visit Website에 대해 공부할때 int형, double형이 이미 존재하는데, 왜 굳이 똑같은 역할을 하는 Integer형, Double형 클래스를 만들어놨을까 고민을 해본적이 있었을 것이다. 바로 이때 사용하는 것이라고 이해하면 된다.

바로 적응이 되지는 않겠지만, 객체 지향 프로그래밍에서는 모든 것이 객체로 통신하기 때문에 번거롭더라도 익숙해 지어야 한다.

JAVA
// 기본 타입 int는 사용 불가 !!!
List<int> intList = new List<>(); 

// Wrapper 클래스로 넘겨주어야 한다. (내부에서 자동으로 언박싱되어 원시 타입으로 이용됨)
List<Integer> integerList = new List<>();Copy

 

또한 제네릭 타입 파라미터에 클래스가 타입으로 온다는 것은, 클래스끼리 상속을 통해 관계를 맺는 객체 지향 프로그래밍의 다형성 원리가 그대로 적용이 된다는 소리이다.

아래 예제 코드를 보면 타입 파라미터로 <Fruit> 로 지정했지만 업캐스팅Visit Website을 통해 그 자식 객체도 할당이 됨을 볼 수 있다.

JAVA
class Fruit { }
class Apple extends Fruit { }
class Banana extends Fruit { }

class FruitBox<T> {
    List<T> fruits = new ArrayList<>();

    public void add(T fruit) {
        fruits.add(fruit);
    }
}

public class Main {
    public static void main(String[] args) {
        FruitBox<Fruit> box = new FruitBox<>();
        
        // 제네릭 타입은 다형성 원리가 그대로 적용된다.
        box.add(new Fruit());
        box.add(new Apple());
        box.add(new Banana());
    }
}Copy

 

복수 타입 파라미터

제네릭은 반드시 한개만 사용하라는 법은 없다. 만일 타입 지정이 여러개가 필요할 경우 2개, 3개 얼마든지 만들 수 있다.

제네릭 타입의 구분은 꺽쇠 괄호 안에서 쉽표(,)로 하며 <T, U> 와 같은 형식을 통해 복수 타입 파라미터를 지정할 수 있다. 그리고 당연히 클래스 초기화할때 제네릭 타입을 두개를 넘겨주어야 한다.

JAVA
import java.util.ArrayList;
import java.util.List;

class Apple {}
class Banana {}

class FruitBox<T, U> {
    List<T> apples = new ArrayList<>();
    List<U> bananas = new ArrayList<>();

    public void add(T apple, U banana) {
        apples.add(apple);
        bananas.add(banana);
    }
}

public class Main {
    public static void main(String[] args) {
    	// 복수 제네릭 타입
        FruitBox<Apple, Banana> box = new FruitBox<>();
        box.add(new Apple(), new Banana());
        box.add(new Apple(), new Banana());
    }
}Copy

 

중첩 타입 파라미터

제네릭 객체를 제네릭 타입 파라미터로 받는 형식도 표현할 수 있다.

ArrayList 자체도 하나의 타입으로써 제네릭 타입 파라미터가 될수 있기 때문에 이렇게 중첩 형식으로 사용할 수 있는 것이다.

JAVA
public static void main(String[] args) {
    // LinkedList<String>을 원소로서 저장하는 ArrayList
    ArrayList<LinkedList<String>> list = new ArrayList<LinkedList<String>>();

    LinkedList<String> node1 = new LinkedList<>();
    node1.add("aa");
    node1.add("bb");

    LinkedList<String> node2 = new LinkedList<>();
    node2.add("11");
    node2.add("22");

    list.add(node1);
    list.add(node2);
    System.out.println(list);
}Copy

 

타입 파라미터 기호 네이밍

지금까지 제네릭 기호를 <T> 와 같이 써서 표현했지만 사실 식별자 기호는 문법적으로 정해진 것이 없다.

다만 우리가 for문을 이용할때 루프 변수를 i 로 지정해서 사용하듯이, 제네릭의 표현 변수를 T 로 표현한다고 보면 된다. 만일 두번째, 세번째 제네릭이 필요하다고 보면 for문의 j k 같이 S, U 로 이어나간다.

명명하고 싶은대로 아무 단어나 넣어도 문제는 없지만, 대중적으로 통하는 통상적인 네이밍이 있으면 개발이 용이해 지기 때문에 아래 표화 같은 암묵적인 규칙(convention)이 존재한다. 예를들어 예제에서 사용된 T 를 타입 변수(type variable)라고 하며, 임의의 참조형 타입을 의미한다.

타입 설명
<T> 타입(Type)
<E> 요소(Element), 예를 들어 List
<K> 키(Key), 예를 들어 Map<k, v>
<V> 리턴 값 또는 매핑된 값(Variable)
<N> 숫자(Number)
<S, U, V> 2번째, 3번째, 4번째에 선언된 타입

제네릭 사용 이유와 이점

 

1. 컴파일 타임에 타입 검사를 통해 예외 방지

자바에서 제네릭(Generic)은 자바 1.5에 추가된 스펙이다. 그래서 JDK 1.5 이전에서는 여러 타입을 다루기 위해 인수나 반환값으로 Object 타입을 사용했었었다. 하지만 Object로 타입을 선언할 경우 반환된 Object 객체를 다시 원하는 타입으로 일일히 타입 변환을 해야 하며, 런타임 에러가 발생할 가능성도 존재하게 된다.

아래 예제에선 Object 타입으로 선언한 배열에 Apple 과 Banana 객체 타입을 저장하고 이를 다시 가져오는 예제이다.

JAVA
class Apple {}
class Banana {}

class FruitBox {
    // 모든 클래스 타입을 받기 위해 최고 조상인 Object 타입으로 설정
    private Object[] fruit;

    public FruitBox(Object[] fruit) {
        this.fruit = fruit;
    }

    public Object getFruit(int index) {
        return fruit[index];
    }
}Copy
JAVA
public static void main(String[] args) {
    Apple[] arr = {
            new Apple(),
            new Apple()
    };
    FruitBox box = new FruitBox(arr);

    Apple apple = (Apple) box.getFruit(0);
    Banana banana = (Banana) box.getFruit(1);
}Copy

그런데 실행해보면 위와 같이 ClassCastException 런타임 에러가 발생하게 된다. 객체를 가져올때 형변환도 잘 해주어 문제가 없는 것 같은데 무엇이 문제일까?

원인은 간단하다. Apple 객체 타입의 배열을 FruitBox에 넣었는데, 개발자가 착각하고 Banana를 형변환하여 가져오려고 하였기 때문에 생긴 현상이다. 미리 코드에서 빨간줄로 알려줬으면 좋겠지만 보다시피 깨끗하다.

 

제네릭을 이용하면 이런 어처구니 없는 실수를 미연에 방지를 할수 있다. 왜냐하면 코드를 실행하기전 컴파일 타임에 미리 에러를 찾아 알려주기 때문이다.

JAVA
class FruitBox<T> {
    private T[] fruit;

    public FruitBox(T[] fruit) {
        this.fruit = fruit;
    }

    public T getFruit(int index) {
        return fruit[index];
    }
}Copy
JAVA
public static void main(String[] args) {
    Apple[] arr = {
            new Apple(),
            new Apple()
    };
    FruitBox<Apple> box = new FruitBox<>(arr);

    Apple apple = (Apple) box.getFruit(0);
    Banana banana = (Banana) box.getFruit(1);
}Copy

이 처럼 제네릭은 클래스나 메서드를 정의할때 타입 파라미터로 객체의 서브 타입을 지정해줌으로써, 잘못된 타입이 사용될 수 있는 문제를 컴파일 과정에서 제거하여 개발을 용이하게 해준다.

 

2. 불필요한 캐스팅을 없애 성능 향상

위의 예제 코드에서 Apple 배열을 FruitBox의 Object 배열 객체에 넣고, 배열 요소를 가져올때 반드시 다운 캐스팅(down casting)을 통해 가져와야 했다. 이는 곧 추가적인 오버헤드가 발생하는 것과 같다.

JAVA
Apple[] arr = { new Apple(), new Apple(), new Apple() };
FruitBox box = new FruitBox(arr);

// 가져온 타입이 Object 타입이기 때문에 일일히 다운캐스팅을 해야함 - 쓸데없는 성능 낭비
Apple apple1 = (Apple) box.getFruit(0);
Apple apple2 = (Apple) box.getFruit(1);
Apple apple3 = (Apple) box.getFruit(2);Copy

 

반면 제네릭은 미리 타입을 지정 & 제한해 놓기 때문에 형 변환(Type Casting)의 번거로움을 줄일 수 있으며, 타입 겁사에 들어가는 메모리를 줄일 수 있고 더불어 가독성도 좋아진다.

JAVA
// 미리 제네릭 타입 파라미터를 통해 형(type)을 지정해놓았기 때문에 별도의 형변환은 필요없다.
FruitBox<Apple> box = new FruitBox<>(arr);

Apple apple = box.getFruit(0);
Apple apple = box.getFruit(1);
Apple apple = box.getFruit(2);Copy

제네릭 사용 주의사항

 

1. 제네릭 타입의 객체는 생성이 불가

제네릭 타입 자체로 타입을 지정하여 객체를 생성하는 것은 불가능 한다. 즉, new 연산자 뒤에 제네릭 타입 파라미터가 올수는 없다.

JAVA
class Sample<T> {
    public void someMethod() {
        // Type parameter 'T' cannot be instantiated directly
        T t = new T();
    }
}Copy

 

2. static 멤버에 제네릭 타입이 올수 없음

아래 처럼 static 변수의 데이터 타입으로 제네릭 타입 파라미터가 올수는 없다. 왜냐하면 static 멤버는 클래스가 동일하게 공유하는 변수로서 제네릭 객체가 생성되기도 전에 이미 자료 타입이 정해져 있어야 하기 때문이다. 즉, 논리적인 오류인 것이다.

JAVA
class Student<T> {
    private String name;
    private int age = 0;

    // static 메서드의 반환 타입으로 사용 불가
    public static T addAge(int n) {

    }
}Copy

JAVA
class Student<T> {
    private String name;
    private int age = 0;

    // static 메서드의 매개변수 타입으로 사용 불가
    public static void addAge(T n) {

    }
}Copy

 

3. 제네릭으로 배열 선언 주의점

기본적으로 제네릭 클래스 자체를 배열로 만들 수는 없다.

JAVA
class Sample<T> { 
}

public class Main {
    public static void main(String[] args) {
        Sample<Integer>[] arr1 = new Sample<>[10];
    }
}Copy

하지만 제네릭 타입의 배열 선언은 허용된다.

위의 식과 차이점은 배열에 저장할 Sample 객체의 타입 파라미터를 Integer 로 지정한다는 뜻이다. 즉, new Sample<Integer>() 인스턴스는 저장이 가능하며, new Sample<String>() 인스턴스는 저장이 불가능하다는 소리이다.

JAVA
class Sample<T> { 
}

public class Main {
    public static void main(String[] args) {
    	// new Sample<Integer>() 인스턴스만 저장하는 배열을 나타냄
        Sample<Integer>[] arr2 = new Sample[10]; 
        
        // 제네릭 타입을 생략해도 위에서 이미 정의했기 때문에 Integer 가 자동으로 추론됨
        arr2[0] = new Sample<Integer>(); 
        arr2[1] = new Sample<>();
        
        // ! Integer가 아닌 타입은 저장 불가능
        arr2[2] = new Sample<String>();
    }
}Copy

제네릭 객체 만들어보기

제네릭을 이용해 직접 클래스와 인터페이스, 메서드를 만들어보고 사용해보는 시간을 가져보자.

 

제네릭 클래스

클래스 선언문 옆에 제네릭 타입 매개변수가 쓰이면, 이를 제네릭 클래스라고 한다.

JAVA
class Sample<T> {
    private T value; // 멤버 변수 val의 타입은 T 이다.

    // T 타입의 값 val을 반환한다.
    public T getValue() {
        return value;
    }

    // T 타입의 값을 멤버 변수 val에 대입한다.
    public void setValue(T value) {
        this.value = value;
    }
}Copy
JAVA
public static void main(String[] args) {
    // 정수형을 다루는 제네릭 클래스
    Sample<Integer> s1 = new Sample<>();
    s1.setValue(1);

    // 실수형을 다루는 제네릭 클래스
    Sample<Double> s2 = new Sample<>();
    s2.setValue(1.0);

    // 문자열을 다루는 제네릭 클래스
    Sample<String> s3 = new Sample<>();
    s3.setValue("1");
}Copy

제네릭 인터페이스

인터페이스에도 제네릭을 적용 할 수 있다. 단, 인터페이스를 implements 한 클래스에서도 오버라이딩한 메서드를 제네릭 타입에 맞춰서 똑같이 구현해 주어야 한다.

JAVA
interface ISample<T> {
    public void addElement(T t, int index);
    public T getElement(int index);
}

class Sample<T> implements ISample<T> {
    private T[] array;

    public Sample() {
        array = (T[]) new Object[10];
    }

    @Override
    public void addElement(T element, int index) {
        array[index] = element;
    }

    @Override
    public T getElement(int index) {
        return array[index];
    }
}Copy
JAVA
public static void main(String[] args) {
    Sample<String> sample = new Sample<>();
    sample.addElement("This is string", 5);
    sample.getElement(5);
}Copy

 

제네릭 함수형 인터페이스

특히 제네릭 인터페이스가 정말 많이 사용되는 곳이 바로 람다 표현식의 함수형 인터페이스이다. 아직 자바의 람다식에 대해 배우지 않은 독자분들도 계시겠지만, 앞으로 배울 예정일 것이니 람다 함수와 제네릭의 응용 형태를 눈에 익히고 가는것을 추천하는 바다.

JAVA
// 제네릭으로 타입을 받아, 해당 타입의 두 값을 더하는 인터페이스
interface IAdd<T> {
    public T add(T x, T y);
}

public class Main {
    public static void main(String[] args) {
        // 제네릭을 통해 람다 함수의 타입을 결정
        IAdd<Integer> o = (x, y) -> x + y; // 매개변수 x와 y 그리고 반환형 타입이 int형으로 설정된다.
        
        int result = o.add(10, 20);
        System.out.println(result); // 30
    }
}Copy


제네릭 메서드

제네릭 메서드 부분은 제네릭 클래스, 인터페이스와 달리 난이도가 조금 있다.

아래와 같이 제네릭 클래스에서 제네릭 타입 파라미터를 사용하는 메서드를 제네릭 메서드라고 착각하기 쉬운데, 이것은 그냥 타입 파라미터로 타입을 지정한 메서드 일 뿐이다.

JAVA
class FruitBox<T> {

    public T addBox(T x, T y) {
        // ...
    }
}Copy

제네릭 메서드란, 메서드의 선언부에 <T> 가 선언된 메서드를 말한다.

위에서는 클래스의 제네릭 <T> 에서 설정된 타입을 받아와 반환 타입으로 사용할 뿐인 일반 메서드라면, 제네릭 메서드는 직접 메서드에 <T> 제네릭을 설정함으로서 동적으로 타입을 받아와 사용할 수 있는 독립적으로 운용 가능한 제네릭 메서드라고 이해하면 된다.

JAVA
class FruitBox<T> {
	
    // 클래스의 타입 파라미터를 받아와 사용하는 일반 메서드
    public T addBox(T x, T y) {
        // ...
    }
    
    // 독립적으로 타입 할당 운영되는 제네릭 메서드
    public static <T> T addBoxStatic(T x, T y) {
        // ...
    }
}Copy

즉, 제네릭 클래스에 정의된 타입 매개변수와 제네릭 메서드에 정의된 타입 매개변수는 별개인게 되는 것이다. 제네릭 메서드의 제네릭 타입 선언 위치는 메서드 반환 타입 바로 앞이다.

 

제네릭 메서드 호출 원리

그럼 제네릭 메서드를 호출은 어떻게 할까?

제네릭 타입을 메서드명 옆에 지정해줬으니, 호출 역시 메서드 왼쪽에 제네릭 타입이 위치하게 된다.

JAVA
FruitBox.<Integer>addBoxStatic(1, 2);
FruitBox.<String>addBoxStatic("안녕", "잘가");Copy

이때 컴파일러가 제네릭 타입에 들어갈 데이터 타입을 메소드의 매개변수를 통해 추정할 수 있기 때문에, 대부분의 경우 제네릭 메서드의 타입 파라미터를 생략하고 호출할 수 있다. 

JAVA
// 메서드의 제네릭 타입 생략
FruitBox.addBoxStatic(1, 2); 
FruitBox.addBoxStatic("안녕", "잘가");Copy

 

그런데 한가지 궁금한 점이 있을 것이다. 클래스 옆에 붙어있는 제네릭과 제네릭 메소드는 똑같은 <T> 인데 어떻게 제네릭 메서드만이 독립적으로 운용되는지 말이다.

사실은 처음 제네릭 클래스를 인스턴스화하면, 클래스 타입 매개변수에 전달한 타입에 따라 제네릭 메소드도 타입이 정해지게 된다. 그런데 만일 제네릭 메서드를 호출할때 직접 타입 파라미터를 다르게 지정해주거나, 다른 타입의 데이터를 매개변수에 넘긴하면 독립적인 타입을 가진 제네릭 메서드로 운용되게 된다.

JAVA
class FruitBox<T, U> {
    // 독립적으로운영되는 제네릭 메서드
    public <T, U> void printBox(T x, U y) {
        // 해당 매개변수의 타입 출력
        System.out.println(x.getClass().getSimpleName());
        System.out.println(y.getClass().getSimpleName());
    }
}Copy
JAVA
public static void main(String[] args) {
    FruitBox<Integer, Long> box1 = new FruitBox<>();

    // 인스턴스화에 지정된 타입 파라미터 <Integer, Long>
    box1.printBox(1, 1);

    // 하지만 제네릭 메서드에 다른 타입 파라미터를 지정하면 독립적으로 운용 된다.
    box1.<String, Double>printBox("hello", 5.55);
    box1.printBox("hello", 5.55); // 생략 가능
}Copy


제네릭 타입 범위 한정하기

제네릭에 타입을 지정해줌으로서 클래스의 타입을 컴파일 타임에서 정하여 타입 예외에 대한 안정성을 확보하는 것은 좋지만 문제는 너무 자유롭다는 점이다.

예를들어 다음 계산기 클래스가 있다고 하자. 정수, 실수 구분없이 모두 받을 수 있게 하기위해 제네릭으로 클래스를 만들어주었다. 하지만 단순히 <T> 로 지정하게 되면 숫자에 관련된 래퍼 클래스 뿐만 아니라 String이나 다른 클래스들도 대입이 가능하다는 점이 문제이다.

JAVA
// 숫자만 받아 계산하는 계산기 클래스 모듈
class Calculator<T> {
    void add(T a, T b) {}
    void min(T a, T b) {}
    void mul(T a, T b) {}
    void div(T a, T b) {}
}

public class Main {
    public static void main(String[] args) {
        // 제네릭에 아무 타입이나 모두 할당이 가능
        Calculator<Number> cal1 = new Calculator<>();
        Calculator<Object> cal2 = new Calculator<>();
        Calculator<String> cal3 = new Calculator<>();
        Calculator<Main> cal4 = new Calculator<>();
    }
}Copy

개발자의 의도로는 계산기 클래스의 제네릭 타입 파라미터로 Number 자료형만 들어오도록 하고 문자열이나 또 다른 클래스 자료형이 들어오면 안되게 하고 싶다고 한다. 그래서 나온 것이 제한된 타입 매개변수 (Bounded Type Parameter) 이다.


타입 한정 키워드 extends

기본적인 용법은 <T extends [제한타입]> 이다. 제네릭 <T>  extends 키워드를 붙여줌으로써, <T extends Number> 제네릭을 Number 클래스와 그 하위 타입(Integer, Double)들만 받도록 타입 파라미터 범위를 제한 한 것이다. 

Tip

클래스의 상속 키워드와 제네릭의 타입 한정 키워드가 둘다 똑같이 extends 라 혼동할 소지가 다분이 있다. 꺾쇠 괄호 안에 extends가 있으면 이건 제한을 의미하며 괄호 바깥에 있으면 상속으로 보면 된다.

타입이 Number 이하로 제한되어 빨간줄이 뜨게 된다

 

인터페이스 타입 한정

extends 키워드 다음에 올 타입은 일반 클래스, 추상 클래스, 인터페이스 모두 올 수 있다. 인터페이스 부분은 약간 햇깔릴수 있는데, 클래스의 상속 관계와 다름이 없으니 그대로 빗대어 적용하면 된다.

JAVA
interface Readable {
}

// 인터페이스를 구현하는 클래스
public class Student implements Readable {
}
 
// 인터페이스를 Readable를 구현한 클래스만 제네릭 가능
public class School <T extends Readable> {
}Copy
JAVA
public static void main(String[] args) {
    // 타입 파라미터에 인터페이스를 구현한 클래스만이 올수 있게 됨	
    School<Student> a = new School<Student>();
}Copy

 

다중 타입 한정

만일 2개 이상의 타입을 동시에 상속(구현)한 경우로 타입 제한하고 싶다면,  & 연산자를 이용하면 된다. 해당 인터페이스들을 동시에 구현한 클래스가 제네릭 타입의 대상이 되게 된다.

단, 자바에서는 다중 상속을 지원하지 않기 때문에 클래스로는 다중 extends는 불가능하고 오로지 인터페이스로만이 가능하다.

JAVA
interface Readable {}
interface Closeable {}

class BoxType implements Readable, Closeable {}

class Box<T extends Readable & Closeable> {
    List<T> list = new ArrayList<>();

    public void add(T item) {
        list.add(item);
    }
}Copy
JAVA
public static void main(String[] args) {
    // Readable 와 Closeable 를 동시에 구현한 클래스만이 타입 할당이 가능하다
    Box<BoxType> box = new Box<>();

    // 심지어 최상위 Object 클래스여도 할당 불가능하다
    Box<Object> box2 = new Box<>(); // ! Error
}Copy

 

제네릭이 여러개인 다중 타입 파라미터를 사용할 경우에도 각각 다중 제한을 거는 것도 가능하다. (가독성이 으악이 되는건 함정)

JAVA
interface Readable {}
interface Closeable {}
interface Appendable {}
interface Flushable {}

class School<T extends Readable & Closeable, U extends Appendable & Closeable & Flushable> 
    void func(T reader, U writer){
    }
}Copy

 

재귀적 타입 한정

재귀적 타입 한정이란 자기 자신이 들어간 표현식을 사용하여 타입 매개변수의 허용 범위를 한정 시키는 것을 말한다. 실무에선 주로 Comparable 인터페이스와 함께 쓰인다.

예를들어 다음과 같이 <E extends Comparable<E>> 제네릭 E의 타입 범위를 Comparable<E> 로 한정한다는 E를 중첩시킨 표현식을 사용할수 있는데, 이 말은 '타입 E는 자기 자신을 서브 타입으로 구현한 Comparable 구현체로 한정' 한다는 뜻이다.

Tip

Comparable는 객체끼리 비교를 해야 할때 compareTo() 메서드를 오버라이딩할때 구현하는 인터페이스이다.
자바에서 Integer, Double, String 등이 값 비교가 되는 이유가 기본적으로 Comparable를 구현하고 있기 때문이다.

즉, Integer 객체를 제네릭 타입 E에 할당하게 된다면, Comparable을 구현한 객체면서 오로지 같은 E인 Integer 타입만 받는다는 의미가 된다. (자기 자신만 받는 다는 표현을 어렵게 빙돌려 표현한 것이다)

다음은 컬렉션을 인자로 받아 컬렉션의 요소들을 최대값(max)를 구해 반환하는 메서드 예제이다. 제네릭 메서드 타입으로 재귀적 타입 한정이 사용되었다.

JAVA
class Compare {
	// 외부로 들어온 타입 E는 Comparable<E>를 구현한 E 객체 이어야 한다.
    public static <E extends Comparable<E>> E max(Collection<E> collection) {
        if(collection.isEmpty()) throw new IllegalArgumentException("컬렉션이 비어 있습니다.");

        E result = null;
        for(E e: collection) {
            if(result == null) {
                result = e;
                continue;
            }

            if(e.compareTo(result) > 0) {
                result = e;
            }
        }

        return result;
    }
}Copy
JAVA
public static void main(String[] args) {
    Collection<Integer> list = Arrays.asList(56, 34, 12, 31, 65, 77, 91, 88);
    System.out.println(Compare.max(list)); // 91
    
    Collection<Number> list2 = Arrays.asList(56, 34, 12, 31, 65, 77, 91, 88);
    System.out.println(Compare.max(list2)); // ! Error - Number 추상 메서드는 Comparable를 구현하지않았기 때문에 불가능
}Copy

제네릭 형변환

728x90