Java - Generics

  • ToC


    제네릭(Generic)

    제네릭은 클래스 내부에서 사용하는 데이터 타입을 외부에서 지정하는 기법이다.

    오픈튜토리얼스에서 이고잉님이 설명하신 제네릭 이미지를 인용한다.
    제네릭 클래스 Person을 사용하여 인스턴스를 만들때 타입 매개변수로 String을 주입했다. 이렇게 하면, 인스턴스 p1String형 Person 객체에 대한 인스턴스로 생성된다. 마찬가지로 인스턴스 p2StringBuilder형 Person 객체에 대한 인스턴스로 생성된다.

    하나의 객체의 인스턴스를 생성하며 다양한 타입의 인스턴스로 생성할 수 있는 것이다.

    다른 예제 코드도 함께 첨부한다.

    Person.class

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Person <T> {
    public T id;

    public T getId() {
    return id;
    }

    public T setId(T id) {
    this.id = id;
    }
    }

    PersonExam.class

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class PersonExam {
    public static void main(String[] args){
    Person<String> t1 = new Person<String>();
    t1.setId("DevAndy");
    System.out.println( t1.getId() );

    Person<Integer> t2 = new Person<Integer>();
    t2.setId(123456789);
    System.out.println( t2.getId() );
    }
    }
    1
    2
    3
    // 출력결과
    DevAndy
    123456789

    Person 클래스의 멤버필드 id는 따로 데이터타입을 지정하지 않았다. 그냥 T 라는 기호를 붙여뒀을뿐인데, 이 T는 **타입 매개변수(Type variable)**이라 하며, 클래스에서 파라미터를 통해 가져오는 데이터 타입이다.

    외부에서 Person 클래스를 사용할때 데이터 타입을 지정하면 이 때 지정한 데이터 타입이 파라미터를 통해 멤버필드의 타입으로 주입되는 것이다.


    타입 매개변수

    위에서 타입 매개변수라고 했는데, 일반적으로 타입 매개변수로 T를 많이 사용하긴 하나 왜 T를 사용하는지 몰랐다. 그러나 이 타입 매개변수를 작성하는데에도 관례가 존재했다.

    원칙이나 표준은 아니기에 타입 매개변수는 조직 내에서 약속된 범위내에서 자유롭게 쓸수 있을거라고 생각한다.

    Type Variable Definition
    T Type
    S, U, V 2nd, 3rd, 4th..
    E Element
    K Key
    V Value
    N Number

    제네릭의 효용 - 타입 안정성

    자, 근데 그래서 이걸 왜 쓰는걸까..? 아니 그냥 선언할때 데이터 타입 지정하면 외부에서 가져다 쓸때도 편하지 않을까? 굳이 데이터 타입 명시하는 일을 외부에 넘기는 이유가 뭘까?

    Java에서 List를 사용하면 IDE에서 자동으로 제네릭을 사용하는걸 경험해봤을 것이다. 자료구조 특징상 같은 데이터 타입의 데이터를 저장할 것이기에 제네릭으로 데이터 타입을 고정하여 타입 안정성을 높힌 것이다.

    1
    2
    3
    4
    List<String> stringList = new ArrayList<String>();

    stringList.add(1) // compile error
    stringList.add("2") // ok

아래의 예제 코드를 통해 타입 안정성이 무슨 말인지 알아보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.ArrayList;
import java.util.List;

public class itemBox {
public static void main(String[] args){
List items = new ArrayList();

items.add("ABC"); // unchecked
items.add(123); // unchecked

for(Object obj : items){
System.out.println(obj);
}
}
}

List를 선언하면서 제네릭을 사용하지 않았다. 이때에 List는 기본적으로 Object 타입이므로 모든 객체 타입을 받아들일 수 있다. 즉 String 형 데이터도 add 할 수 있고, int 형 데이터도 add 할 수 있는 것이다.

이런 경우가 타입 안정성이 검증되지 않았다고 이야기할 수 있다. 어떤 타입의 데이터가 List에 add될지 알 수 없으므로 개발자가 의도하지 않은 에러가 런타임시에 발생할 수도 있는 불확실성이 존재한다.

List는 자바에서 자료구조 인터페이스에 해당한다. 따라서 다른 타입의 데이터가 같은 리스트에 묶이기 보다는 서로 같은 타입의 데이터가 묶여야 한다.

그러나 위 코드에서 제네릭을 적용한다면,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.util.ArrayList;
import java.util.List;

public class ItemBox {
public static void main(String[] args) {
List<String> items = new ArrayList<String>();

items.add("hello");
items.add("generic!");
// items.add(123); // compile error

for(String str : items){
System.out.println(str);
}
}
}
1
2
3
// 출력 결과
hello
generic!

String 형만 add 할 수 있으므로 다른 데이터 타입을 List에 add 하려고 하면, 컴파일 에러를 유발하게 된다. 이렇게 제네릭을 사용하면 개발자가 의도하지 않은 데이터 타입이 List에 추가될시 컴파일 단계에서 이 문제를 인지할 수 있게된다.

제네릭의 이런 특징을 타입 안정성을 보장 한다고 표현한다. 런타임시에 발생할 수 있는 에러를 컴파일 시에 발생하도록 유도하여 안정적인 개발을 돕는 것이다.


제네릭의 특징

기본형 타입은 타입 매개변수에서 불가능

제네릭에서 타입 매개변수에 들어올 수 있는 데이터 타입으로 기본형 타입은 쓸 수 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Person<T, S> {
public T num;
public S name;

public Person(T num, S name){
this.num = num;
this.name = name;
}
}

class PersonExam {
public static void main(String[] args){
// Person<int, String> p1 = new Person<int, String>(10, "devandy");
// compile error

System.out.println(p1.name);
}
}

위의 코드처럼 제네릭 클래스를 사용하는 외부 클래스에서 타입으로 기본형 타입을 사용할 수 없다는 이야기이다. 기본형 타입을 사용하고자 한다면, 직접 사용할 순 없고 기본형 데이터 타입을 객체화하는 Wrapper 클래스를 사용해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Person<T, S> {
public T num;
public S name;

public Person(T num, S name){
this.num = num;
this.name = name;
}
}

class PersonExam {
public static void main(String[] args){
Person<Integer, String> p1 = new Person<Integer, String>(10, "devandy");
System.out.println(p1.name);
}
}
1
2
// 출력 결과
devandy

제네릭 메서드

제네릭은 클래스뿐만 아니라 메서드에서도 사용가능하다.

1
public <T> void foo(List<T> list){ }
  • <T> : 타입 매개변수

  • void : 리턴 타입

  • foo : 메서드 명

  • List<T> list : 매개변수

    메서드를 제네릭 메서드로 선언하기 위해서는 접근제한자와 리턴타입 사이에 타입 매개변수를 작성해야 컴파일러에서 이 메서드를 제네릭 메서드로 인식하여 컴파일시 파라미터로 들어오는 타입 매개변수를통해 타입 추론을 할 수 있다.
    이 내용은 아래에서 더 정리를 했다!


    왜 제네릭 메서드가 필요한가?

    1
    2
    3
    4
    public List<String> add(List<String> list, String element){
    list.add(element);
    return list;
    }

    add()StringList에만 대응할 수 있는 메서드이다. 그런데 만약 String형뿐만 아니라 Integer 등 다른 타입의 List도 대응하고 싶다면, 위의 메서드에서 List의 타입을 제네릭으로 바꿔야한다.

    1
    2
    3
    4
    public <T> List<T> add(List<T> list, T element){
    list.add(element);
    return list;
    }

    이렇게 하면, 다양한 데이터 타입의 List에 대응할 수 있는 add()로 변경된다.

    예제 코드

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    public class GenericMethodEx {
    // 일반 메서드
    public List<String> add(List<String> list, String element){
    list.add(element);
    return list;
    }

    // 제네릭 메서드
    public <T> List<T> add(List<T> list, T element){
    list.add(element);
    return list;
    }

    public static void main(String[] args){
    GenericMethodEx ex = new GenericMethodEx();

    List<String> strList = new ArrayList<String>();
    strList.add("hello");
    strList.add("world");

    List<Integer> intList = new ArrayList<Integer>();
    intList.add(123);
    intList.add(456);

    System.out.println(ex.add(strList, "!"));
    System.out.println(ex.add(intList, 1000));
    }
    }
    1
    2
    3
    // 출력 결과
    [hello, world, !]
    [123, 456, 1000]

    제네릭 메서드에서 타입 매개변수를 제한하고 싶을때

    메서드로 전달되는 타입 매개변수의 범위를 제한하고자 한다면 아래처럼 입력할 수 있다.

    1
    2
    3
    public <T extends Fruit> void foo(List<T> list){
    // success
    }

    그러나 아래처럼 메서드의 파라미터 안에서 extends 키워드를 사용한다면 이는 컴파일 에러가 발생한다. 파라미터가 아니라 메서드 리턴 타입에서 제한을 시켜야만 제네릭을 인자로 불러올 수 있다.

    1
    2
    3
    public <T> void foo(List<T extends Fruit> list){
    // compile error
    }

    컴파일러의 제네릭 타입 추론

    Java를 처음 공부할때엔 접근제한자, 리턴 타입, 메서드명, 파라미터 순으로 메서드에 대한 정의가 제대로 이루어져야만 컴파일이 된다고 배웠다. 당연히 그도 그럴 것이, 컴파일러가 해당 메서드가 호출되는 시점에 메모리에 올리려면 정확한 객체 정보가 필요하기 때문이다.

    그런데 제네릭을 사용하면, 대체 컴파일러는 어떻게 타입을 추론하는걸까?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class GenericEx01<T> {
    public void genericSample(T param){
    System.out.println(param);
    }

    public static void main(String[] args){
    genericEx01<String> ex = new genericEx01<String>();

    ex.genericSample("hello");
    }
    }
    1
    2
    // 출력결과
    hello

    위의 코드는 제네릭 클래스 GenericEx의 인스턴스를 생성해서 genericSample()을 호출하는 코드이다.

    genericSample()은 제네릭 타입의 파라미터를 받고 있는데, main()에서 인스턴스를 생성하면서 데이터 타입을 String으로 전달했기 때문에 컴파일러가 데이터 타입을 추론할 수 있었다.

    이제 여기서 변화를 줘보겠다. GenericEx를 제네릭 클래스에서 일반 클래스로 바꿔보겠다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class GenericEx02 {
    public void genericSample(T param){
    System.out.println(param);
    }

    public static void main(String[] args){
    genericEx02 ex = new genericEx02();

    ex.genericSample("hello"); // compile error
    }
    }
    1
    2
    3
    // 출력결과
    Error: Unable to initialize main class genericExDevAndy.genericEx02
    Caused by: java.lang.NoClassDefFoundError: T

    T라는 클래스를 찾을수 없다는 메세지와 함께 컴파일 에러가 발생했다.

    왜 컴파일러는 타입 추론을 하지 못한걸까?

    위의 코드를 다시 훑어보면, genericSample()의 파라미터로 들어오는 T라는 타입을 컴파일러 입장에서 추론할 수 있는 방법이 없다. 인스턴스를 통해 전달된 데이터 타입이 없기 때문에 컴파일러가 타입 매개변수 T가 어떤 타입인지 추론하지 못하는 것이다.

    그래서 컴파일러에게 이 T 가 제네릭 타입 매개변수임을 알려주기 위해 genericSample()이 있는 클래스를 제네릭 클래스로 바꾸던, 메서드 자체를 제네릭 메서드로 바꾸던 해주어야 한다. 그럼 컴파일러는 T를 제네릭 타입 매개변수로 인지하고 이 객체를 사용하는쪽에서 주입하는 데이터 타입으로 맞춰서 호출해준다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // ok
    public class GenericEx03 {
    public <T> void genericSample(T param) {
    System.out.println(param);
    }

    public static void main(String[] args) {
    GenericEx03 ex = new GenericEx03();
    ex.genericSample("hello");
    }
    }
    1
    2
    // 출력결과
    hello
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // ok
    public class GenericEx04<제네릭> {
    public void genericSample(제네릭 param) {
    System.out.println(param);
    }

    public static void main(String[] args) {
    GenericEx04<String> ex = new GenericEx04<String>();
    ex.genericSample("hello");
    }
    }
    1
    2
    // 출력결과
    hello

    지금까지는 일반 메서드 예제코드를 다뤄봤다. 그렇다면 static 메서드에서는 어떨까.

    1
    2
    3
    4
    5
    class GenericEx05{
    public static void genericSample(T param){
    // compile error. 타입 추론 불가.
    }
    }

    static 키워드를 사용함으로써 런타임시 heap 메모리에 바로 객체를 올려두어야 하는데, 런타임시에는 이 파라미터의 데이터 타입을 알 수 없으므로 heap 메모리에 객체를 올릴수가 없게된다.

    따라서 이 경우엔 static 메서드를 제네릭 메서드로 바꿔주어야만 컴파일이 가능해진다. 그냥 리턴 타입 앞에 제네릭 타입 변수를 명시해주면 된다.

    1
    2
    3
    4
    5
    class GenericEx06 {
    public static <T> void genericSample(T param){
    // ok. 타입 추론 가능.
    }
    }

    이렇게 제네릭 메서드로 바꾸면, 컴파일러에서 타입 추론이 가능해진다.

    컴파일러는 genericSample() 가 타입 매개변수 T를 사용하는 제네릭 메서드라는걸 알고 있고, 제네릭 메서드 타입 매개변수와 같은 타입 매개변수가 파라미터로 들어왔기 때문에 컴파일러가 이 타입을 추론할 수 있는 것이다.

    만약 아래의 코드처럼 파라미터의 타입 매개변수와 제네릭 메서드의 타입 매개변수가 다르면, 컴파일러에서 타입 추론이불가능해지기 때문에 이 역시 컴파일 에러를 유발한다.

    1
    2
    3
    4
    5
    6
    class GenericEx07 {
    public static <T> void genericSample(S param){
    // compile error.
    // T cannot be resolved to a type
    }
    }

    타입 매개변수 T를 기대했지만, 파라미터로 들어온 타입 매개변수는 S이기 때문이다.


    제한된 제네릭

    제네릭 클래스는 이를 인스턴스로 생성해서 사용하는 쪽에서 타입 변수로 아무런 데이터 타입을 넣어서 사용할 수 있다. 그러나 이 타입변수에 extends 키워드를 사용하면, 특정 클래스를 상속받는 클래스로만 타입 변수를 제한할 수 있다. 이를 제한된 제네릭이라고 한다.

    예제 코드를 살펴보자.

    1
    2
    3
    4
    5
    6
    7
    public class Box<T extends Fruit> {
    List<T> items = new ArrayList<>();

    void add(T item){
    items.add(item);
    }
    }

    위의 코드처럼 타입 매개변수 뒤에 extends 키워드를 사용하면, 이 제네릭 클래스는 특정 클래스를 상속받는 타입 변수만 허용된다는 제한 조건이 성립된다. 따라서 타입변수 T 에 아무 데이터 타입이 들어올 수 없게 된 것이다.


    제한된 제네릭의 특징

    제한된 제네릭에선 타입 매개변수의 제한조건으로 extends 키워드만 사용할 수 있다. 설사 인터페이스를 구현하는 제약을 추가하려고 해도 implement대신 반드시 extends 만 사용해야 한다.

    1
    2
    interface Eatable { }
    class sample<T extends Eatable> { }

    추가로 클래스 Fruit의 자식이면서 인터페이스 Eatable도 구현해야한다면 & 기호를 사용한다.

    1
    class FruitBox<T extends Fruit & Eatable>{ }

    제한된 제네릭 사용예제 코드

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    interface Eatable { }

    class Fruit implements Eatable {
    public String toString() { return "Fruit"; }
    }


    class Apple extends Fruit {
    public String toString() { return "Apple"; }
    }


    class Grape extends Fruit {
    public String toString() { return "Grape"; }
    }


    class Toy {
    public String toString() { return "Toy"; }
    }


    class Box<T> {
    List<T> list = new ArrayList<T>();

    void add(T item) { list.add(item); }

    T get(int i) { return list.get(i); }

    int size() { return list.size(); }

    public String toString() { return list.toString(); }
    }


    class FruitBox<T extends Fruit & Eatable> extends Box<T> { }

    class FruitBoxEx {
    public static void main(String[] args) {
    FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
    FruitBox<Apple> appleBox = new FruitBox<Apple>();
    FruitBox<Grape> grapeBox = new FruitBox<Grape>();

    // FruitBox<Grape> grapeBox2 = new FruitBox<Apple>(); // compile error. 타입 불일치.
    // FruitBox<Grape> grapeBox2 = new FruitBox<Fruit>(); // compile error. 타입 불일치.
    // FruitBox<Toy> toyBox = new FruitBox<Toy>(); // compile error. FruitBox의 제한조건에 부합하지 않으므로.

    fruitBox.add(new Fruit());
    fruitBox.add(new Apple()); // appleBox의 타입 Apple이 FruitBox의 타입인 Fruit의 자식 객체이므로 Ok.
    fruitBox.add(new Grape()); // Grape가 FruitBox의 타입인 Fruit의 자식 객체이므로 Ok.
    appleBox.add(new Apple());
    // applpeBox.add(new Grape()); // appleBox의 타입 Apple과 Grape는 서로 상속관계가 아님. 타입 불일치.
    grapeBox.add(new Grape());

    System.out.println("fruitbox : "+fruitBox);
    System.out.println("appleBox : "+appleBox);
    System.out.println("grapeBox : "+grapeBox);
    }
    }
    1
    2
    3
    4
    // 출력 결과
    fruitbox : [Fruit, Apple, Grape]
    appleBox : [Apple]
    grapeBox : [Grape]

    Repl에서 실행해보기


    와일드 카드

    와일드 카드란 제네릭 타입을 파라미터나 리턴 타입으로 사용할 때 구체적인 타입 대신 사용하는 문법이다.

    예제 코드를 따라가보자.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    public class GenericsWildCards {
    public int intSum(List<? extends Number> list) {
    int sum = 0;

    for(Numer num : list){
    sum += num.doubleValue();
    }

    return sum;
    }

    public static void main(String[] args){
    GenericsWildCards generic = new GenericsWildCards();

    List<Integer> intNums = new ArrayList<Integer>();
    List<String> stringWords = new ArrayList<String>();

    intNums.add(3);
    intNums.add(5);
    intNums.add(10);

    int sum1 = generic.intSum(intNums);
    int sum2 = generic.intSum(stringWords); // compile error. 와일드카드 조건에 불일치.

    System.out.println("sum of integers : "+sum1);
    System.out.println("Sum of doubles : "+sum2);
    }
    }

    위의 코드를 보면, sum2를 선언하는 코드에서 컴파일 에러가 발생한다.

    원인은 sum2를 선언하는 과정에서사용한 intSum() 의 파라미터로 들어간 타입이 generic 인스턴스 객체의 와일드카드 조건에 부합하지 않은 인자(argument)로 들어왔다는 경고 문구이다.

    intSum()을 보면 다음과 같이 와일드 카드를 사용하고 있다.

    1
    public int intSum(List<? extends Number> list) { }

    intSum() 파라미터로 List형을 받을 수 있는데, List의 타입은 Number를 상속하는 자식객체 타입으로 사용을 제한하는 와일드카드가 선언되어 있다.

    1
    2
    3
    GenericsWildCards generic = new GenericsWildCards();
    List<String> stringWords = new ArrayList<String>();
    int sum2 = generic.intSum(stringWords);

    그런데 intSum()의 파라미터로 String형 List를 인자로 사용하니 컴파일 에러가 발생한 거였다.


    와일드카드 종류

    제네릭에서 와일드 카드는 크게 3가지로 분류할 수 있다.

    와일드 카드 (Unbounded Wildcards)

  • <?>

  • 제한이 없기때문에 모든 클래스나 인터페이스 타입이 올 수 있다.

  • 사실상 <? extends Object>와 같다.

    1
    public int intSum(List<?> list) { }

    와일드 카드의 하위 클래스 제한(Upper Bounded Wildcards)

  • <? extends T>

  • 타입 매개변수 T와 그 자식 객체들을 구현한 매개변수만 가능

    1
    public int intSum(List<? extends T> list) { }

    와일드 카드의 상위 클래스 제한 (Lower Bounded Wildcards)

  • <? super T>

  • 타입 매개변수 T와 그 부모 객체들을 구현한 매개변수만 가능

    1
    public int intSum(List<? super T> list) { }    

    와일드카드 예제 코드

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    public class GenericWildcards {

    // 와일드카드 unbounded wildcard
    public static void printData(List<?> list){
    for(Object obj : list){
    System.out.println(obj+" ::");
    }
    }

    // 와일드카드 상위클래스 제한 lower bounded wildcard
    public static void addInteger(List<? super Integer> list){
    list.add(120);
    System.out.println(list);
    }

    // 와일드카드 하위클래스 제한 upper bounded wildcard
    public static double sum(List<? extends Number> list){
    double sum = 0;
    for(Number n : list){
    sum += n.doubleValue();
    }
    return sum;
    }

    public static void main(String[] args){
    List<Integer> intNums = new ArrayList<Integer>();
    List<String> stringWords = new ArrayList<String>();

    intNums.add(3);
    intNums.add(10);
    intNums.add(200);

    stringWords.add("hello");
    stringWords.add("generic");
    stringWords.add("widlcard");

    printData(stringWords);
    addInteger(intNums);
    System.out.println(sum(intNums));
    }
    }
    1
    2
    3
    4
    5
    6
    //   출력 결과
    hello ::
    generic ::
    wildcard ::
    [3, 10, 200, 120]
    333.0

    Repl에서 실행하기


    제한된 제네릭과 와일드 카드의 차이?

    제네릭을 공부하다보니 제한된 제네릭과 와일드카드가 헷갈렸다. 아래의 글을 읽고 관점을 구분해서 바라보니 그 차이를 조금씩 이해할 수 있었다.

    제한된 제네릭 와일드카드
    관점 제네릭 클래스를 선언하는 개발자 메서드를 만드는 개발자
    목적 객체 생성시 사용될 객체를 제한 메서드에 사용될 매개변수가 제네릭 클래스를 구현한 객체일때 그 제네릭 클래스의 타입 매개변수를 제한

이제 코드 작성시 제한된 제네릭과 와일드카드의 차이를 살펴보겠다.

제네릭 메서드는 파라미터에서 제한된 제네릭을 사용할 수 없었다.

1
2
3
4
5
6
7
public <T> void genericMethodEx01(List<T extends Fruit> list){
// compile error.
}

public <T extends Fruit> void genericMethodEx02(List<T> list){
// ok.
}

반면, 와일드카드는 파라미터에서 제한된 제네릭을 사용하는게 가능하다.

1
2
3
public void genericMethodEx03(List<? extends Fruit> list){
// ok.
}

References