왜 제네릭을 사용하나요?

간단히 말해, 제네릭은 클래스, 인터페이스 및 메서드를 정의할 때 타입(클래스 및 인터페이스)을 매개변수로 사용할 수 있게 해줍니다. 메서드 선언에 사용되는 보다 친숙한 형식 매개변수와 마찬가지로 타입 매개변수는 동일한 코드를 다른 입력으로 재사용할 수 있는 방법을 제공합니다. 형식 매개변수에 대한 입력은 값이고 타입 매개변수에 대한 입력은 타입이라는 차이점이 있습니다.

제네릭을 사용하는 코드는 제네릭이 아닌 코드에 비해 많은 이점이 있습니다:

  • 컴파일 시 더 강력한 타입 검사. Java 컴파일러는 제네릭 코드에 강력한 타입 검사를 적용하고 코드가 타입 안전을 위반하는 경우 오류를 발생시킵니다. 컴파일 타임 오류를 수정하는 것은 찾기 어려운 런타임 오류를 수정하는 것보다 쉽습니다.

  • 형 변환 제거. 제네릭이 없는 다음 코드 스니펫은 형 변환이 필요합니다:

List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0);

제네릭을 사용하도록 코드를 다시 작성하면 형변환이 필요하지 않습니다:

List<String> list = new ArrayList<String>();
list.add("hello");
String s = list.get(0);   // no cast
  • 프로그래머가 일반 알고리즘을 구현할 수 있도록 지원합니다. 프로그래머는 제네릭을 사용하여 다양한 유형의 컬렉션에서 작동하고, 사용자 정의할 수 있으며, 타입이 안전하고 읽기 쉬운 제네릭 알고리즘을 구현할 수 있습니다.

 

제네릭 타입

간단한 Box 클래스

제네릭 타입은 타입에 대해 매개변수화된 제네릭 클래스 또는 인터페이스입니다. 다음 Box 클래스를 수정하여 개념을 설명하겠습니다.

public class Box {
    private Object object;
 
    public void set(Object object) { this.object = object; }
    public Object get() { return object; }
}

이 메서드는 Object를 받거나 반환하므로 기본 타입이 아니라면 원하는 것은 무엇이든 자유롭게 전달할 수 있습니다. 컴파일 시 클래스가 어떻게 사용되는지 확인할 수 있는 방법은 없습니다. 코드의 한 부분은 Box에 Integer를 넣고 Integer 타입의 객체를 가져올 것으로 기대하지만, 다른 부분은 실수로 String을 전달하여 런타임 오류가 발생할 수 있습니다.

Box 클래스의 Generic 버전

generic 클래스 는 다음 형식으로 정의됩니다:

class name<T1, T2, ..., Tn> { /* ... */ }

꺾쇠 괄호(<>)로 구분된 타입 매개변수 섹션은 클래스 이름 뒤에옵니다. 타입 매개변수(타입 변수라고도 함) T1, T2, … 및 Tn을 지정합니다.

Box 클래스에 제네릭을 사용하도록 업데이트하려면 “public class Box”를 “public class Box<T>”로 변경하여 제네릭 타입 선언을 생성합니다. 이렇게 하면 클래스 내부 어디에서나 사용할 수 있는 타입 변수 T가 도입됩니다.

이렇게 변경하면 Box 클래스가 됩니다:

/**
 * Generic version of the Box class.
 * @param <T> the type of the value being boxed
 */
public class Box<T> {
    // T stands for "Type"
    private T t;
 
    public void set(T t) { this.t = t; }
    public T get() { return t; }
}

보시다시피, Object의 모든 항목은 T로 대체됩니다. 타입 변수는 클래스 타입, 인터페이스 타입, 배열 타입 또는 다른 타입 변수 등 사용자가 지정한 모든 비 원시 타입이 될 수 있습니다.

일반 인터페이스를 만드는 데에도 동일한 기법을 적용할 수 있습니다.

타입 매개변수 명명 규칙

규칙에 따라 타입 매개변수 이름은 대문자로 된 단일 문자입니다. 이는 여러분이 이미 알고 있는 변수 명명 규칙과는 완전히 대조적인데, 이 규칙이 없으면 타입 변수와 일반 클래스 또는 인터페이스 이름을 구분하기 어렵기 때문입니다.

가장 일반적으로 사용되는 타입 매개변수 이름은 다음과 같습니다:

  • E - Element (자바 컬렉션 프레임워크에서 광범위하게 사용됩니다.)

  • K - Key

  • N - Number

  • T - Type

  • V - Value

  • S, U, V etc. - 2nd, 3rd, 4th types

  • 이 이름은 Java SE API와 이 섹션의 나머지 부분 전체에서 사용됩니다.

제네릭 타입 호출 및 인스턴스화하기

코드 내에서 제네릭 Box 클래스를 참조하려면 TInteger와 같은 구체적인 값으로 대체하는 제네릭 타입 호출을 수행해야 합니다:

Box<Integer> integerBox;

제네릭 타입 호출은 일반적인 메서드 호출과 비슷하다고 생각할 수 있지만, 메서드에 인수를 전달하는 대신 타입 인자(이 경우 Integer를) Box 클래스 자체에 전달합니다.

타입 매개변수와 타입 인자 용어: 많은 개발자가 “타입 매개변수”와 “타입 인자”라는 용어를 혼용하여 사용하지만 이 용어는 동일하지 않습니다. 코딩할 때 매개변수화된 타입을 생성하기 위해 타입 인수를 제공합니다. 따라서 Foo<T>T는 타입 매개변수이고 Foo<String> fString은 타입 인자입니다. 이 섹션에서는 이러한 용어를 사용할 때 이 정의를 준수합니다.

다른 변수 선언과 마찬가지로 이 코드는 실제로 새로운 Box 객체를 생성하지 않습니다. 단순히 integerBox가 “정수의 Box”에 대한 참조를 보유할 것이라고 선언하며, 이것이 Box<Integer>가 읽히는 방식입니다.

제네릭 타입의 호출을 일반적으로 매개변수화된 타입이라고 합니다.

이 클래스를 인스턴스화하려면 평소처럼 new 키워드를 사용하되, 클래스 이름과 괄호 사이에 <Integer>를 넣습니다:

Box<Integer> integerBox = new Box<Integer>();

다이아몬드

Java SE 7 이상에서는 컴파일러가 컨텍스트에서 타입 인수를 확인하거나 유추할 수 있는 한 제네릭 클래스의 생성자를 호출하는 데 필요한 타입 인수를 빈 타입 인수 집합(<>)으로 대체할 수 있습니다. 이 꺾쇠 괄호 쌍인 <>를 비공식적으로 다이아몬드라고 부릅니다. 예를 들어, 다음 문으로 Box<Integer>의 인스턴스를 만들 수 있습니다:

Box<Integer> integerBox = new Box<>();

다이아몬드 표기법 및 타입 추론에 대한 자세한 내용은 이 튜토리얼의 타입 추론 섹션을 참조하세요.

다중 타입 매개변수

앞서 언급했듯이 제네릭 클래스는 여러 타입 매개변수를 가질 수 있습니다. 예를 들어 제네릭 Pair 인터페이스를 구현하는 제네릭 OrderedPair 클래스를 들 수 있습니다:

public interface Pair<K, V> {
    public K getKey();
    public V getValue();
}
 
public class OrderedPair<K, V> implements Pair<K, V> {
 
    private K key;
    private V value;
 
    public OrderedPair(K key, V value) {
    this.key = key;
    this.value = value;
    }
 
    public K getKey()    { return key; }
    public V getValue() { return value; }
}

다음 문은 OrderedPair 클래스의 인스턴스 두 개를 생성합니다:

Pair<String, Integer> p1 = new OrderedPair<String, Integer>("Even", 8);
Pair<String, String>  p2 = new OrderedPair<String, String>("hello", "world");

new OrderedPair<String, Integer>() 코드는 KString으로, VInteger로 인스턴스화합니다. 따라서 OrderedPair의 생성자 매개변수 타입은 각각 StringInteger입니다. 오토박싱으로 인해 클래스에 Stringint를 전달하는 것이 유효합니다.

다이아몬드 섹션에서 언급했듯이 Java 컴파일러는 OrderedPair<String, Integer> 선언에서 KV 타입을 유추할 수 있으므로 이러한 문은 다이아몬드 표기법을 사용하여 단축할 수 있습니다:

OrderedPair<String, Integer> p1 = new OrderedPair<>("Even", 8);
OrderedPair<String, String>  p2 = new OrderedPair<>("hello", "world");

제네릭 인터페이스를 만들려면 제네릭 클래스를 만들 때와 동일한 규칙을 따르세요.

매개변수화된 타입

타입 매개변수(예: K 또는 V)를 매개변수화된 타입, 즉 List<String>으로 대체할 수도 있습니다. 예를 들어 OrderedPair<K, V> 예제를 사용합니다:

OrderedPair<String, Box<Integer>> p = new OrderedPair<>("primes", new Box<Integer>(...));

 

Raw Types

Raw Types 은 타입 인자가 없는 제네릭 클래스 또는 인터페이스의 이름입니다. 예를 들어 제네릭 Box 클래스가 있다고 가정해 보겠습니다:

public class Box<T> {
    public void set(T t) { /* ... */ }
    // ...
}

Box<T>의 매개변수화된 타입을 생성하려면 형식적 타입 매개변수 T에 실제 타입 인수를 지정합니다:

Box<Integer> intBox = new Box<>();

실제 타입 인자가 생략된 경우, Box<T>의 원시 타입을 생성합니다:

Box rawBox = new Box();

따라서 Box는 제네릭 타입 Box<T>의 원시 타입입니다. 그러나 제네릭이 아닌 클래스나 인터페이스 타입은 Raw types이 아닙니다.

Raw types이 레거시 코드에 나타나는 이유는 JDK 5.0 이전에는 많은 API 클래스(예: Collections 클래스)가 제네릭이 아니었기 때문입니다. Raw types을 사용하면 기본적으로 사전 제네릭 동작이 발생하며, Box는 객체를 제공합니다. 이전 버전과의 호환성을 위해 매개변수화된 타입을 Raw types에 할당하는 것은 허용됩니다:

Box<String> stringBox = new Box<>();
Box rawBox = stringBox;               // OK

그러나 매개변수화된 타입에 Raw type을 할당하면 경고가 표시됩니다:

Box rawBox = new Box();           // rawBox is a raw type of Box<T>
Box<Integer> intBox = rawBox;     // warning: unchecked conversion

Raw type을 사용하여 해당 제네릭 타입에 정의된 제네릭 메서드를 호출하는 경우에도 경고가 표시됩니다:

Box<String> stringBox = new Box<>();
Box rawBox = stringBox;
rawBox.set(8);  // warning: unchecked invocation to set(T)

이 경고는 Raw type이 제네릭 타입 검사를 우회하여 안전하지 않은 코드의 포착을 런타임으로 연기한다는 것을 보여줍니다. 따라서 Raw type을 사용하지 않아야 합니다.

Java 컴파일러가 Raw type을 사용하는 방법에 대한 자세한 내용은 타입 삭제 섹션을 참조하세요.

확인되지 않은 오류 메시지

앞서 언급했듯이 레거시 코드와 제네릭 코드를 혼합할 때 다음과 유사한 경고 메시지가 표시될 수 있습니다:

Note: Example.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.

다음 예시와 같이 Raw type에서 작동하는 이전 API를 사용할 때 이러한 문제가 발생할 수 있습니다:

public class WarningDemo {
    public static void main(String[] args){
        Box<Integer> bi;
        bi = createBox();
    }
 
    static Box createBox(){
        return new Box();
    }
}

“unchecked”라는 용어는 컴파일러가 타입 안전을 보장하는 데 필요한 모든 타입 검사를 수행하기에 충분한 타입 정보를 가지고 있지 않음을 의미합니다. 컴파일러에서 힌트를 제공하지만 기본적으로 “unchecked” 경고는 비활성화되어 있습니다. 모든 “unchecked” 경고를 보려면 -Xlint:unchecked로 다시 컴파일하세요.

이전 예제를 -Xlint:unchecked로 다시 컴파일하면 다음과 같은 추가 정보가 표시됩니다:

WarningDemo.java:4: warning: [unchecked] unchecked conversion
found   : Box
required: Box<java.lang.Integer>
        bi = createBox();
                      ^
1 warning

체크되지 않은 경고를 완전히 비활성화하려면 -Xlint:-unchecked 플래그를 사용합니다. @SuppressWarnings("unchecked") 어노테이션은 체크되지 않은 경고를 비활성화합니다. @SuppressWarnings 구문에 익숙하지 않은 경우 어노테이션 섹션을 참조하세요.

 

제네릭 메서드

제네릭 메서드 는 자체 타입 매개변수를 도입하는 메서드입니다. 제네릭 타입을 선언하는 것과 비슷하지만 타입 매개변수의 범위가 선언된 메서드로 제한됩니다. 정적 및 비정적 제네릭 메서드는 물론 제네릭 클래스 생성자도 허용됩니다.

제네릭 메서드의 구문에는 메서드의 반환 타입 앞에 나타나는 꺾쇠 괄호 안에 타입 매개변수 목록이 포함됩니다. 정적 제네릭 메서드의 경우, 타입 매개변수 섹션은 메서드의 반환 타입 앞에 나타나야 합니다.

Util 클래스에는 두 개의 Pair 객체를 비교하는 제네릭 메서드인 compare가 포함되어 있습니다:

public class Util {
    public static <K, V> boolean compare(Pair<K, V> p1, Pair<K, V> p2) {
        return p1.getKey().equals(p2.getKey()) &&
               p1.getValue().equals(p2.getValue());
    }
}
 
public class Pair<K, V> {
 
    private K key;
    private V value;
 
    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }
 
    public void setKey(K key) { this.key = key; }
    public void setValue(V value) { this.value = value; }
    public K getKey()   { return key; }
    public V getValue() { return value; }
}

이 메서드를 호출하는 전체 구문은 다음과 같습니다:

Pair<Integer, String> p1 = new Pair<>(1, "apple");
Pair<Integer, String> p2 = new Pair<>(2, "pear");
boolean same = Util.<Integer, String>compare(p1, p2);

타입은 굵게 표시된 것처럼 명시적으로 제공되었습니다. 일반적으로 이 부분은 생략할 수 있으며 컴파일러가 필요한 타입을 유추합니다:

Pair<Integer, String> p1 = new Pair<>(1, "apple");
Pair<Integer, String> p2 = new Pair<>(2, "pear");
boolean same = Util.compare(p1, p2);

타입 추론이라고 하는 이 기능을 사용하면 꺾쇠 괄호 사이에 타입을 지정하지 않고 제네릭 메서드를 일반 메서드처럼 호출할 수 있습니다. 이 주제는 다음 섹션인 타입 추론에서 자세히 설명합니다.

 

Bounded Type Parameters

매개변수화된 타입에서 타입 인자로 사용할 수 있는 타입을 제한하고 싶은 경우가 있을 수 있습니다. 예를 들어 숫자에 대해 연산하는 메서드는 Number 또는 그 서브클래스의 인스턴스만 허용하고 싶을 수 있습니다. 이것이 바로 바운드 타입 매개변수의 용도입니다.

바운드 타입 매개변수를 선언하려면 타입 매개변수의 이름 뒤에 extends 키워드와 그 상한(이 예에서는 Number입니다)을 나열합니다. 이 문맥에서 extends는 일반적인 의미로 “extends”(클래스에서처럼) 또는 “implements”(인터페이스에서처럼)이라는 의미로 사용된다는 점에 유의하세요.

public class Box<T> {
 
    private T t;          
 
    public void set(T t) {
        this.t = t;
    }
 
    public T get() {
        return t;
    }
 
    public <U extends Number> void inspect(U u){
        System.out.println("T: " + t.getClass().getName());
        System.out.println("U: " + u.getClass().getName());
    }
 
    public static void main(String[] args) {
        Box<Integer> integerBox = new Box<Integer>();
        integerBox.set(new Integer(10));
        integerBox.inspect("some text"); // error: this is still String!
    }
}

이 바운디드 타입 매개변수를 포함하도록 제네릭 메서드를 수정하면 이제 inspect 호출에 여전히 String이 포함되므로 컴파일이 실패합니다:

Box.java:21: <U>inspect(U) in Box<java.lang.Integer> cannot
  be applied to (java.lang.String)
                        integerBox.inspect("10");
                                  ^
1 error

제네릭 타입을 인스턴스화하는 데 사용할 수 있는 타입을 제한하는 것 외에도, 바운디드 타입 매개변수를 사용하면 바운드에 정의된 메서드를 호출할 수 있습니다:

public class NaturalNumber<T extends Integer> {
 
    private T n;
 
    public NaturalNumber(T n)  { this.n = n; }
 
    public boolean isEven() {
        return n.intValue() % 2 == 0;
    }
 
    // ...
}

isEven() 메서드는 n을 통해 Integer 클래스에 정의된 intValue() 메서드를 호출합니다.

다중 바운드

앞의 예시에서는 단일 바운드가 있는 타입 매개변수를 사용했지만, 타입 매개변수는 여러 바운드를 가질 수 있습니다:

<T extends B1 & B2 & B3>

바운드가 여러 개인 타입 변수는 바운드에 나열된 모든 타입의 하위 유형입니다. 바운드 중 하나가 클래스인 경우 클래스를 먼저 지정해야 합니다. 예를 들어

Class A { /* ... */ }
interface B { /* ... */ }
interface C { /* ... */ }
 
class D <T extends A & B & C> { /* ... */ }

바인딩된 A가 먼저 지정되지 않으면 컴파일 타임 오류가 발생합니다:

class D <T extends B & A & C> { /* ... */ }  // compile-time error

 

제네릭 메서드와 Bounded Type Parameters

Bounded Type Parameters는 제네릭 알고리즘 구현의 핵심입니다. 배열 T[]에서 지정된 요소 elem보다 큰 요소의 수를 세는 다음 메서드를 생각해 봅시다.

public static <T> int countGreaterThan(T[] anArray, T elem) {
    int count = 0;
    for (T e : anArray)
        if (e > elem)  // compiler error
            ++count;
    return count;
}

메서드의 구현은 간단하지만, short, int, double, long, float, bytechar와 같은 기본 타입에만 큰 값 연산자(>)가 적용되기 때문에 컴파일되지 않습니다. 객체를 비교하는 데 > 연산자를 사용할 수 없습니다. 이 문제를 해결하려면 Comparable<T> 인터페이스에 의해 제한된 타입 매개변수를 사용하세요:

public interface Comparable<T> {
    public int compareTo(T o);
}

결과 코드는 다음과 같습니다:

public static <T extends Comparable<T>> int countGreaterThan(T[] anArray, T elem) {
    int count = 0;
    for (T e : anArray)
        if (e.compareTo(elem) > 0)
            ++count;
    return count;
}

 

제네릭, 상속 및 하위 유형

이미 알고 있듯이, 타입이 호환되는 경우 한 타입의 객체를 다른 타입의 객체에 할당할 수 있습니다. 예를 들어, IntegerObject에 할당할 수 있는데, ObjectInteger의 슈퍼타입 중 하나이기 때문입니다:

Object someObject = new Object();
Integer someInteger = new Integer(10);
someObject = someInteger;   // OK

객체 지향 용어에서는 이를 “is” 관계라고 합니다. Integer는 객체의 일종이므로 대입이 허용됩니다. 그러나 IntegerNumber의 일종이므로 다음 코드도 유효합니다:

public void someMethod(Number n) { /* ... */ }
 
someMethod(new Integer(10));   // OK
someMethod(new Double(10.1));   // OK

제네릭도 마찬가지입니다. 제네릭 타입 호출을 수행하여 타입 인자로 Number를 전달할 수 있으며, 인자가 Number와 호환되는 경우 이후 추가 호출이 허용됩니다:

Box<Number> box = new Box<Number>();
box.add(new Integer(10));   // OK
box.add(new Double(10.1));  // OK

이제 다음 방법을 고려해 보세요:

public void boxTest(Box<Number> n) { /* ... */ }

어떤 타입의 인수를 받아들일까요? 시그니처를 보면 타입이 Box<Number>인 단일 인자를 받는다는 것을 알 수 있습니다. 하지만 이것이 무엇을 의미할까요? 예상할 수 있는 것처럼 Box<Integer> 또는 Box<Double>을 전달할 수 있을까요? 대답은 “아니오”입니다. Box<Integer>Box<Double>Box<Number>의 하위 유형이 아니기 때문입니다.

이는 제네릭을 사용한 프로그래밍에서 흔히 오해하는 부분이지만, 반드시 알아두어야 할 중요한 개념입니다. IntegerNumber의 하위 유형이긴 하지만 Box<Integer>Box<Number>의 하위 유형이 아닙니다.

Subtyping parameterized types

매개변수화된 타입을 하위 유형화합니다.

Note: 예를 들어 숫자정수라는 두 가지 구체적인 타입 AB가 주어졌을 때, AB의 관계 여부에 관계없이 MyClass<A>MyClass<B>와 아무런 관계가 없습니다. MyClass<A>MyClass<B>의 공통 부모는 Object입니다.

타입 매개변수가 관련되어 있을 때 두 제네릭 클래스 간에 서브타입과 같은 관계를 만드는 방법에 대한 자세한 내용은 와일드카드 및 서브타입 섹션을 참조하세요.

제네릭 클래스 및 하위 유형

제네릭 클래스나 인터페이스를 확장하거나 구현하여 서브타입화할 수 있습니다. 한 클래스 또는 인터페이스의 타입 매개변수와 다른 클래스 또는 인터페이스의 타입 매개변수 간의 관계는 extends 및 implements 절에 의해 결정됩니다.

컬렉션 클래스를 예로 들면, ArrayList<E>List<E>를 구현하고, List<E>Collection<E>을 확장합니다. 따라서 ArrayList<String>List<String>의 하위 유형이며, 이는 Collection<String>의 하위 유형입니다. 타입 인자를 변경하지 않는 한, 타입 간의 서브타입 관계는 유지됩니다.

A sample Collection hierarchy

샘플 컬렉션 계층구조입니다.

이제 각 요소에 제네릭 타입 P의 선택적 값을 연결하는 자체 목록 인터페이스인 PayloadList를 정의하고 싶다고 가정해 보겠습니다. 그 선언은 다음과 같을 수 있습니다:

interface PayloadList<E,P> extends List<E> {
  void setPayload(int index, P val);
  ...
}

PayloadList의 다음 매개변수화는 List<String>의 하위 유형입니다:

  • PayloadList<String,String>
  • PayloadList<String,Integer>
  • PayloadList<String,Exception>

A sample Payload hierarchy

페이로드 계층 구조 샘플.