프로그래밍 언어/Java

[Java] 자바 제네릭스는 무엇이고 어떻게 동작하는가?

happy_life 2022. 7. 1. 22:56

자바 제네릭스는 무엇이고 어떻게 동작하는가?

 

 

네릭스란?

다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에서 사용되는 것으로, 컴파일 시 타입을 체크해주는 것이다. 객체의 타입을 컴파일 시 체크하기 때문에 객체 타입의 안정성을 높이고 형변환의 번거로움을 줄일 수 있다.

타입 안정성을 높인다는 것은 의도하지 않은 타입의 객체를 저장하는 것을 막고, 저장된 객체를 꺼낼 때에도 원래의 타입과 다른 타입으로 형변환 되는 오류를 줄인다는 것이다. 예를 들어 String형만 저장하고 싶을 때, 제네릭스를 사용하면 다른 형을 입력받지 못하게 하여 타입 안정성을 제공하는 것이다. 참고로 타입 안정성과 하위 호환을 위해 컴파일 타임에만 이용되고 런타임 때는 사라진다.

ArrayList<String> strings = new ArrayList<>();
strings.add("A");
strings.add("B");
//strings.add(3); // String 형이 아니므로 컴파일 에러

 

네릭스의 장점

1. 타입안정성을 제공한다.
2. 타입 체크와 형변환을 생략할 수 있으므로 코드가 간결해진다.

 

 

네릭스 용어

Person 같이 기존의 클래스 이름을 원시 타입이라고 한다. T는 타입 매개변수라고 부르고 이 둘 모두인 Person<T>를 지네릭 클래스라고 부른다.

 

    PersonGeneric<String> stringPersonGeneric = new PersonGeneric<String>();

위의 코드와 같이 T를 String 등의 타입으로 지정하는 것을 "지네릭 타입 호출" 이라고 하고, 지정된 타입 String을 "매개변수화된 타입"이라고 한다. 참고로 PersonGeneric<String>은 컴파일 후 원시타입으로 바뀐다.

 

 

제네릭스 이전의 코드

코드 예제

public class GenericsEx1 {
    public static void main(String[] args) {
        Person person = new Person();
        person.setField("Name");

        String field = (String)person.getField(); // 형변환이 필요함.
    }
}

class Person {
    Object field;

    public void setField(Object field) {
        this.field = field;
    }

    public Object getField() {
        return field;
    }
}

코드를 입력하는 사람 입장에서 String을 넣어야하는지, int를 넣어야하는지 어떤 종류의 타입을 넣을지 모른다. 또한 get을 통해 값을 꺼낼 때에도 형변환이 반드시 필요해 불편하다.

 

 

네릭스 적용 코드

위 클래스를 지네릭 클래스로 변경하려면 클래스 옆에 <T>를 붙이면 되고, 기존의 Object도 T로 바꾸면 된다.

 

코드 예제

public class GenericsEx2 {
    public static void main(String[] args) {
        PersonGeneric<String> stringPersonGeneric = new PersonGeneric<String>();
        
        stringPersonGeneric.setField("Name");
        String name = stringPersonGeneric.getField(); // 형변환 필요X 이미 String임을 아니까
        
    }
}

class PersonGeneric<T> {
    T field;

    public T getField() {
        return field;
    }

    public void setField(T field) {
        this.field = field;
    }
}

 

String 대신 int 등 각각의 형에 맞춰 사용할 수 있다. T는 마치 대명사 같은 것이다.  K나 E도 그냥 T와 같은 대명사("임의의 참조형 타입")이다.

다만 기본형(Primitive)는 사용할 수 없다.

Integer 등의 제네릭 타입

 

* JDK1.7 이상 부터는 추정이 가능한 경우 타입을 생략할 수 있다. 오른쪽의 new PersonGeneric<>();으로 생략할 수 있다. 왼쪽에 Integer, String 등으로부터 추정이 가능하기 때문이다.

 

네릭스의 제한 사항

1. static 멤버에 사용할 수 없다. 지네릭스는 객체별로 다른 타입을 지정해 인스턴스 별로 다르게 동작하려고 생긴 것이다. T는 인스턴스 변수로 사용된다.

 

2. 배열이나 객체를 생성할 때 직접 타입 변수를 사용 불가능하다. new 연산자로 객체를 생성하려면 컴파일 시점에 타입을 알아야 하는데, 아래의 코드같은 경우 컴파일 시점에 어떤 타입인지 전혀 알 수 없다. 

 

 

제한된 네릭 클래스

타입 문자로 명시해 사용할 경우 한 종류의 타입으로만 제한할 수 있다. 하지만 String이든 어떤 타입이든 들어올 수 있는데 지정할 수 있는 T 타입의 종류는 제한할 방법은 없을까? extends를 사용하면 특정 타입의 자손들만 대입할 수 있게 제한할 수 있다. 아래의 코드를 보며 이해해보자.

 

코드 예제

public class GenericsEx3 {
    public static void main(String[] args) {
        Generics<String> stringGenerics = new Generics<>();
//        Generics<Integer> integerGenerics = new Generics<Integer>(); -> String이 아니므로 컴파일 에러
    }
}

class Generics<T extends String> {
    T member1;

    public T getMember1() {
        return member1;
    }

    public void setMember1(T member1) {
        this.member1 = member1;
    }
}

Integer 형을 넣으면 컴파일 에러가 난다. 이런 식으로 특정 타입만을 지정할 수 있게 할 수 있다.

 

* 클래스가 아닌 인터페이스를 구현해야 한다는 제한사항이 생기면 이 때도 "extend"를 사용한다.  (implements 아님)

 

클래스 String만 사용해야 하고, 특정 인터페이스도 구현해야 한다면 & 기호를 사용한다.

class Generics<T extends String & Comparable> {}

 

 

와일드 카드

static 클래스엔 제네릭을 사용할 수 없다. 따라서 여러 타입으로 매개변수를 받으려면 오버라이딩을 각각 해줘야 한다. 하지만 지네릭 타입이 다른 것만으로는 메서드의 오버로딩이 안된다. 지네릭 타입은 컴파일 때만 사용하고 제거해버리기 때문이다.

static Juice makeJuice(FruitBox<Fruit> box) {}
static Juice makeJuice(FruitBox<Apple> box) {}

예를 들어 Fruit 와 Apple을 인자로 받기위해 위와 같이 오버라이딩하면, 컴파일러는 같은 것으로 보기때문에 오버라이딩을 할 수 없다. 형이 일치하는 것만 인자로 올 수 있으므로 이런 경우 하나를 포기해야하는 불상사가 생긴다.

 

이럴 때 등에 사용하기 위해 등장한 것이 바로 와일드 카드이다. 와일드 카드는 여러 개의 객체 타입으로 확장하기 위해 사용된다. 기본은  "?"로 어떤 타입이든 될 수 있다.

하지만 "?"그 자체 만으로는 Object 타입과 다를 게 없으므로, extends와 super로 상한 하한을 정한다.

 

<? extends T> 와일드 카드 상한 제한하기 T와 그 자식들만 가능
<? super T> 와일드 카드 하한 제한하기 T와 그 부모들만 가능
<?>  모든 타입 가능 = Object

 

코드 예제

public class GenericsEx4 {
    public static void main(String[] args) {
        FruitBox<Fruit> fruitFruitBox = new FruitBox<>(); //Fruit의 자식까지 가능
        FruitBox<Apple> appleFruitBox = new FruitBox<>(); //Apple의 자식까지 가능

        fruitFruitBox.add(new Apple()); //Fruit 의 자식이므로 가능
        fruitFruitBox.add(new Grape()); //Fruit 의 자식이므로 가능
        appleFruitBox.add(new Apple()); //Apple만 가능
        appleFruitBox.add(new Apple()); //Apple만 가능

        // 둘다 가능한 이유 <? extends Fruit>로 Fruit 이하까지 가능하도록 상한을 두었기 때문
        System.out.println(Juicer.makeJuice(fruitFruitBox)); //Apple Grape Juice
        System.out.println(Juicer.makeJuice(appleFruitBox)); //Apple Apple Juice
    }
}

class Fruit {
    @Override
    public String toString() {
        return "Fruit";
    }
}

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

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

class Juice {
    String name;

    Juice(String name) {
        this.name = name + "Juice";
    }

    @Override
    public String toString() {
        return name;
    }
}

class FruitBox<T extends Fruit> extends Box<T>{}  //FruitBox에 Fruit 포함 그 자식까지 올 수 있음.

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

    void add(T item) {
        list.add(item);
    }
   
    ArrayList<T> getList() {
        return list;
    }

}
class Juicer {
    static Juice makeJuice(FruitBox<? extends Fruit> box) {//<? extends Fruit>로 Fruit 이하까지 가능하도록 상한
        String tmp = "";
        for (Fruit f : box.getList()) {
            tmp += f + " ";
        }
        return new Juice(tmp);
    }

}

 

 

 

제네릭 메서드

지네릭 메서드는 메서드를 호출할 때마다 다른 제네릭 타입을 대입할 수 있게 한 것이다. 지네릭 메서드는 타입 변수가 메서드 내에서만 유요하다. 따라서, 지네릭 메서드에 정의된 타입 매개변수와 지네릭 메서드에 정의된 타입 매개변수는 서로 다른 것이니 꼭 주의해야한다. 한편 이러한 특징으로 인해 static 메서드에서도 사용이 가능하다.

 

코드 예제

class Member<T> {
    T id;

    public T getId() {
        return id;
    }

    public void setId(T id) {
        this.id = id;
    }
    //지네릭 메서드의 T와 멤버와 클래스 선언부의 T는 다른 것이다.
    static <T> void getParamList(List<T> list) {

    }
}

 

 

 

제네릭 메서드 사용법

코드를 보고 이해하는 게 낫다.

 

기존 코드 예제

class Juicer {
    static Juice makeJuice(FruitBox<? extends Fruit> box) {//<? extends Fruit>로 Fruit 이하까지 가능하도록 상한
        String tmp = "";
        for (Fruit f : box.getList()) {
            tmp += f + " ";
        }
        return new Juice(tmp);
    }
}

변경 코드 예제

class Juicer {
    static <T extends Fruit> Juice makeJuice(FruitBox<T> box) {//<? extends Fruit>로 Fruit 이하까지 가능하도록 상한
        String tmp = "";
        for (Fruit f : box.getList()) {
            tmp += f + " ";
        }
        return new Juice(tmp);
    }
}

 

이제 이 메서드를 호출할 때는 타입 변수를 지정해줘야 한다. 

System.out.println(Juicer.<Fruit>makeJuice(fruitFruitBox)); //Apple Grape Juice
System.out.println(Juicer.<Apple>makeJuice(appleFruitBox)); //Apple Apple Juice

그러나 대부분의 경우 컴파일러가 추정할 수 있어 생략 가능하다.

 

지네릭 메서드는 파라미터로 들어오는 T 타입에 대한 부연 설명이라고 간단히 이해하면 좋을 것같다. 위의 경우에도 FruitBox<T>로 들어오는 타입이 <T extends Fruit> 즉 Fruit을 포함하고 Fruit의 자식 클래스를 매개변수로 받을 수 있다는 부연 설명을 하고 있는 것이다.

 

 

제네릭 타입의 형변환

제네릭 타입과 제네릭이 아닌 형의 변환은 항상 가능하다. (바람직 하진 않음)

그렇다면 제네릭 타입간은 어떨까? 아래 코드를 보자

 

예제 코드

 

불가능

 

위의 코드를 보듯 불가능하다. 하지만 와일드 카드를 사용하면 가능하다.

 

예제 코드

가능

 

가능