JAVA

[자바] 제네릭

감자b 2024. 12. 24. 19:13

제네릭이란?

특정 타입에 속하지 않고 일반적으로 사용된다는 의미로 클래스에서 사용될 타입을 외부에서 결정하도록 하여 타입에 대한 결정을 나중으로 미루는 것을 의미한다.

제네릭을 사용하면 코드 재사용성을 올리며 타입 안전성까지 보장된다는 장점이 있다.

 

용어

  • 제네릭 클래스 : 다이아몬드 기호(<>)를 사용한 클래스
  • 제네릭 타입 : 클래스, 인터페이스 정의 시에 타입 매개변수를 사용하는 것으로, 제네릭 클래스, 인터페이스를 뜻함.
  • 타입 매개변수 : 제네릭 타입, 메서드에서 사용되는 변수
  • 타입 인자 : 제네릭 타입에 실제로 제공되는 타입으로 기본형은 인자가 될 수 없음
public class Generic<T> { 
	// T - 타입 매개변수
	T data;
}

// Generic<String> generic = new Generic<>() - 인자로 넘어가는 String은 타입 인자.

 

 

타입 매개변수 관례

<T> Type
<E> Element
<N> Number
<K> Key
<V> Value
<S, U, V> 각각 2, 3, 4번째에 선언된 타입

로 타입(원시 타입)

위 코드에서 제네릭 타입은 사용할 때 지정하는 것이라고 했는데 이를 생략할 수 있다.

Generic generic = new Generic();

위처럼 제네릭 타입에서 타입을 지정하지 않는 것(타입 매개변수 사용X)을 로 타입(원시 타입) 이라고 한다.

이렇게 작성하게 되면 내부의 타입 매개변수는 Object로 작성된다.

따라서 컴파일 시점에는 데이터를 모두 받아들여 이상이 없을 수 있지만 이후에 데이터를 사용할 때 캐스팅이 되지 않아 예외가 터지는 문제가 발생할 수 있다.

 

로 타입 선언 vs 타입 매개변수 Object 지정 시 차이

  • 로 타입 사용

해당 코드는 컴파일이 되지만 ClassCastException 발생.

public class Test {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        unsafe(list, Integer.valueOf(1));
        String s = list.get(0);
        System.out.println(s);
    }

    private static void unsafe(List list, Object o) {
        list.add(o);
    }
}

Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap') at generic.mytest.test.Test.main(Test.java:9)

 

  • 타입 매개변수 Object 명시

컴파일 조차 되지 않음.

public class Test {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        unsafe(list, Integer.valueOf(1));
        String s = list.get(0);
        System.out.println(s);
    }

    private static void unsafe(List<Object> list, Object o) {
        list.add(o);
    }
}

java: incompatible types: java.util.List<java.lang.String> cannot be converted to java.util.List<java.lang.Object>

 

위에서 제네릭의 하위 타입 규칙에 의하여 List<String>은 로 타입 list의 하위 타입이지만, List<Object>의 하위 타입은 아니다. → 타입 안전성을 유지할 수 있음.

로 타입은 제네릭이 생기기 전 과거의 코드와 호환을 위해 사용되는데 이는 타입 안전성을 떨어뜨리므로 사용하지 않는 것을 권장한다. (이펙티브 자바 - 아이템 26)

 

Raw use of parameterized class 'TestGeneric' 로 타입 사용 시 해당 경고 발생

참고 :

  • class 리터럴에는 로 타입을 사용해야함. → List<String>.class 허용X
  • 런타임에는 타입 이레이저에 의해 제네릭 타입의 정보가 지워지므로 instanceof 사용 시에는 로 타입을 사용하는 것이 깔끔하다.
if (o instanceof Set) { // 로 타입
    Set<?> set = (Set<?>) o; // 와일드 카드 타입
}

타입 매개변수 제한

타입 매개변수는 특정 타입으로 제한하는 것이 가능.

public class NumberTest<T extends Number> {
    private T number;

    public NumberTest(T number) {
        this.number = number;
    }

    public void printMethod() {
        System.out.println(number.intValue());
        System.out.println(number.longValue());
        System.out.println(number.floatValue());
    }
}
public class Test {
    public static void main(String[] args) {
        NumberTest<Double> intNum = new NumberTest<>(1.2);
        intNum.printMethod();
//        NumberTest<String> str = new NumberTest<String>(); 컴파일 에러
    }
}

타입 매개변수 T는 <T extends Number> 를 통해 Number와 그 자식의 타입만 들어올 수 있도록 제한하였다.

따라서 Number의 자식이 아닌 String 타입이 인자로 넘어올 시 컴파일 에러가 발생한다. 또한 제네릭 클래스 내부에서 Number 클래스의 메서드 역시 사용이 가능해진다.


제네릭 메서드

제네릭은 클래스가 아닌 메서드에도 적용할 수 있다.

참고로 아래 코드에서 제네릭 클래스의 타입 매개변수 T와 제네릭 메서드의 타입 매개변수 T는 무관.

타입 매개변수의 이름이 같다면 모호해지므로 실제로는 다르게 할 것!

public class TestGeneric<T> { // 제네릭 클래스
    public static <T extends Number> T genericMethod(T t) { // 제네릭 메서드
        System.out.println(t);
        return t;
    }
}

public class Test {
    public static void main(String[] args) {
        Integer integer = TestGeneric.<Integer>genericMethod(1);
//        String str = TestGeneric.<String>genericMethod("hello"); 컴파일 에러

        System.out.println("integer : " + integer);
    }
}

제네릭 메서드는 반환 타입 앞에 다이아몬드(<>)를 사용하여 제네릭 메서드임을 명시하여 특정 메서드 단위로 제네릭을 도입할 수 있으며 역시 타입 매개변수의 상한을 지정할 수 있다.

 

제네릭 타입 vs 제네릭 메서드

  제네릭 타입 제네릭 메서드
타입 인자 전달 객체 생성 시점에 전달 메서드 호출 시점에 전달
static 메서드 static 메서드에 타입 매개변수 사용 불가 인스턴스, static 메서드 모두 적용 가능
public class TestGeneric<T> {
    public T instanceMethod(T t) {
        System.out.println(t);
        return t;
    }
    
// 제네릭 타입을 반환 타입, 매개변수 타입으로 사용하는 것이 모두 불가능
//    public static T staticMethod(T t) {
//        System.out.println(t);
//        return t;
//    }

    public static <S> S genericStaticMethod(S s) {
        System.out.println(s);
        return s;
    }

    public <S> S genericInstanceMethod(S s) {
        System.out.println(s);
        return s;
    }
}

제네릭 타입은 객체를 생성하는 시점에 타입이 정해짐

→ static 메서드는 컴파일 시점에 메서드 영역에 정보가 올라가는데 타입을 이 때 무슨 타입인지 알 수 없으므로 타입 매개변수 사용 불가.


타입 추론

자바 컴파일러는 타입 인자를 추론하여 이를 생략하는 것이 가능하다.

  • 제네릭 타입
    • 자바 컴파일러는 변수 선언 시에 사용되는 인자를 보고 우측에 필요한 타입을 추론할 수 있음. 따라서 우측에는 생략이 가능
List<String> list1 = new ArrayList<String>();
List<String> list2 = new ArrayList<>(); // 타입 추론
  • 제네릭 메서드
    • 매개 변수로 전달되는 인자의 타입이 Integer 라는 것을 알 수 있음. 따라서 타입 인자를 직접 전달하지 않아도 생략이 가능
Integer integer1 = TestGeneric.<Integer>genericMethod(1);
Integer integer2 = TestGeneric.genericMethod(1); // 타입 추론

와일드 카드

자바는 제네릭을 더 편리하게 사용할 수 있도록 와일드 카드라는 *, ? 같은 특수 문자를 지원한다.

와일드 카드 → 제네릭 타입, 제네릭 메서드를 선언하는 것이 아닌 이미 만들어진 제네릭 타입을 활용할 때 사용.

public class Box<T> {
    T data;

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }
}
public class WildcardTest {
    public static void main(String[] args) {
        Box<Integer> integerBox = new Box<>();
        integerBox.setData(10);
        WildcardTest.genericV1(integerBox);
        WildcardTest.wildcardV1(integerBox);
        Integer getInteger = WildcardTest.genericV2(integerBox);
        Number getNumber = WildcardTest.wildcardV2(integerBox);
    }
    static <T> void genericV1(Box<T> box) {
        T data = box.getData();
    }
    static void wildcardV1(Box<?> box) {
        Object data = box.getData();
    }
    static <T extends Number> T genericV2(Box<T> box) {
        T t = box.getData();
        return t;
    }
    static Number wildcardV2(Box<? extends Number> box) {
        Number animal = box.getData();
        return animal;
    }
}
  • V1 메서드 → 와일드 카드에 상한이 없다. 이를 비제한 와일드카드라고 하며 모든 타입을 다 받을 수 있음.
    • 제네릭 메서드
      1. 먼저 integerBox를 전달한다.
      2. 제네릭 메서드에서 T는 Integer로 타입이 정해진다.
      3. 실행
    • 와일드 카드
      1. 먼저 integerBox를 전달한다.
      2. 실행
    타입 인자를 보고 타입 매개변수의 타입이 결정되는 과정을 와일드카드는 하지 않음. 제네릭 타입, 메서드 정의가 꼭 필요하지 않다면 와일드카드 권장
  • V2 메서드
    • 제네릭 메서드
      • 전달한 타입을 명확하게 반환받을 수 있음.
      • Integer getInteger = WildcardTest.genericV2(integerBox);
    • 와일드카드
      • 상한으로 제한한 타입이 반환된다.
      • Number getNumber = WildcardTest.wildcardV2(integerBox);

즉 메서드의 반환 타입을 특정 시점에 따라서 변경하고 싶다면 제네릭 타입이나 메서드 사용

그렇지 않고 일반적인 메서드에 이미 만든 제네릭 타입을 전달받아서 활용한다면 와일드카드 사용

 

와일드카드의 경우 하한도 지정할 수 있음 → 하한으로 지정된 타입 포함 상위 타입까지 가능

Box<? super Number> box

 

extends, super 동작 제약

  • extends
public class Test {
    public static void main(String[] args) {
        ArrayList<Child> parents = new ArrayList<>();
        test(parents);
    }

    public static void test(List<? extends Parent> parents) {
        Parent parent = new Parent();
        Child child = new Child();
 //       parents.add(parent); 불가능
 //       parents.add(child); 불가능
    }
}

extends로 상한을 지정할 시 Collection에 add가 불가능 → 메서드 호출 시점에 어떤 타입으로 들어올 지 모르기 때문에 사전에 차단함. null만 추가할 수 있음

값을 꺼낼 때는 상한으로 지정된 타입으로 꺼내야 함.

 

  • super
public class Test {
    public static void main(String[] args) {
        ArrayList<Parent> parents = new ArrayList<>();
        test(parents);
    }

    public static void test(List<? super Parent> parents) {
        Parent parent = new Parent();
        Child child = new Child();
        parents.add(parent);
        parents.add(child);
    }
}

하한으로 제한한 타입을 포함하고 그 하위 타입까지 추가하는 것이 가능.

값을 꺼낼 때는 Object 타입으로 꺼내야 함.


타입 이레이져

제네릭은 자바 컴파일 단계에서만 사용되고 컴파일이 끝나면 제네릭 정보가 모두 없어진다.

즉 .java 파일에는 제네릭의 타입 매개변수들이 있지만 컴파일이 끝난 .class 파일에는 제네릭 타입 매개변수들이 모두 사라진다는 뜻이다.

 

예를 들어 아래와 같은 코드가 있다고 하자.

public class TestGeneric<T extends Number> {
    private T data;

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }
}

이 때 메인 메서드에서 타입 인자로 Integer 가 넘어오는 코드가 있다고 하면 컴파일 시점에 컴파일러는 다음과 같이 이해한다.

public class Test {
    public static void main(String[] args) {
        TestGeneric<Integer> generic = new TestGeneric<>();
        Integer data = generic.getData();
    }
}
public class TestGeneric<Integer> {
    private Integer data;

    public Integer getData() {
        return data;
    }

    public void setData(Integer data) {
        this.data = data;
    }
}

그리고 컴파일이 끝나면 아래와 같이 변경된다. (상한이 Number. 만약 상한 제한이 없다면 Object로 바뀜)

public class TestGeneric {
    private Number data;

    public Number getData() {
        return data;
    }

    public void setData(Number data) {
        this.data = data;
    }
}
public class Test {
    public static void main(String[] args) {
        TestGeneric generic = new TestGeneric();
        Integer data = (Integer)generic.getData();
    }
}

메인 메서드를 보면 컴파일러가 다운 캐스팅하는 코드를 추가하는데 이는 위에서 Integer 값이 들어온 것을 확인하였기 때문에 문제가 발생하지 않는다.

즉 자바 컴파일러는 타입 인자로 지정한 타입으로 다운 캐스팅을 함!

 

위처럼 제네릭 타입은 컴파일 시점에만 존재하며 런타임 시점에는 제네릭 정보가 지워지는 것을 타입 이레이저라고 한다.

따라서 타입 이레이저에 의해 아래와 같은 코드는 불가능하다.

public class TestGeneric<T> {
    public void of() {
        return new T(); 
    }
    public void instanceofMethod(Object o) {
        if (o instanceof T) {
            ...
        }
    }
}

new T() → 타입 이레이저에 의해 런타임 시에 무조건 new Object()로 동작

o instanceof T → 무조건 Object와 비교하게 됨.


공변, 반공변, 불공변

  • 공변 : 함께 변한다는 의미로 Sub가 Super의 하위 타입이라면 배열 Sub[] 역시 Super[]의 하위 타입
public static void main(String[] args) {
	Parent[] parents = new Child[10];
	parents[0] = new Parent();
}

컴파일에는 문제가 없지만 Exception in thread "main" java.lang.ArrayStoreException 이라는 예외 발생

  • 반공변 : 공변의 반대로 Sub가 Super의 하위 타입이라면 배열 Super[]는 Sub[]의 하위 타입
Child[] child = (Child[]) parents;
  • 불공변 : Sub와 Super는 상속에 상관없이 관계가 전혀 없음.
public class Test {
    public static void main(String[] args) {
//        ArrayList<Parent> parents = new ArrayList<Child>(); 불가능
//        ArrayList<Child> children = new ArrayList<Parent>(); 불가능
    }
}

 

자바의 제네릭 타입은 불공변성을 지닌다.

 

주의점

제네릭 클래스 간의 다형성은 똑같이 적용.

Collection<Integer> ← List<Integer> ← ArrayList<Integer> 가능!

다이아몬드(<>) 내부의 타입 인자들 간의 다형성이 적용이 안된다는 것!

ArrayList<Object> ← ArrayList<Number> ← ArrayList<Double> 불가능!

 

즉 제네릭에서 공변 / 반공변을 적용하고 싶다면 위에서 말한 와일드카드를 적용해야함.

<? extends T> : 공변성 적용

public class Test {
    public static void main(String[] args) {
        ArrayList<Child> child = new ArrayList<>();
        test(child);
    }
    public static void test(List<? extends Parent> parents) {
        ...
    }
}

 

<? super T> : 반공변성 적용

public class Test {
    public static void main(String[] args) {
        ArrayList<Parent> parent = new ArrayList<>();
        test(parent);
    }
    public static void test(List<? super Child> child) {
        ...
    }
}