자바의 Generic

박시시·2022년 11월 6일
0

JAVA

목록 보기
13/13

제네릭이란 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 말한다. geeksforgeeks에서는 parameterized types 라고도 표현하는데, 말 그대로 타입을 파라미터로 넘겨 클래스나 메서드 내에서 사용하는 것을 뜻한다.
예전에는 여러 타입을 사용하는 클래스나 메서드에서 인자값이나 반환값으로 주로 Object 타입을 사용했다. 다양한 타입을 받거나 반환해야 하는 클래스이므로 모든 타입을 받을 수 있는 Object 타입이 가장 적절했기 때문이다.
적절하다고 문제가 없는 것은 아니었다. 가장 일반적인 예제는 바로 List이다. 아래의 코드를 보자.

(밸덩 예제코드)

List list = new LinkedList();
list.add(new Integer(1)); 
Integer i = list.iterator().next();

위 코드는 당연히 컴파일 에러가 발생한다. list의 리턴타입이 Object 타입이기 때문이다. 컴파일 에러를 없애려면 명시적인 캐스팅을 해줘야 한다.

Integer i = (Integer) list.iterator.next();

이러한 방식(인자값으로 Object를 받거나 반환)은 명시적으로 캐스팅을 강제함으로써 오류가 발생될 여지가 크다.

또 하나 예를 들어보자.


class WithoutGenericsTest {
    public static void main(String[] args) {
        ArrayList list = new ArrayList();

        list.add("test")
        list.add(10) // 컴파일 에러 발생 안한다.

        String test1 = (String)list.get(0)
        String test2 = (String)list.get(1) // 런타임 에러가 발생한다.
    }
}

주석에 써놓았다시피 위의 코드는 컴파일 시에 에러가 발생하지 않고 런타임 때 발생한다. 타입 안정성이 낮은 것이다.

제네릭 사용 이유

List<Integer> list = new LinkedList<>();
list.add(new Integer(1)); 
Integer i = list.iterator().next();
class WithGenericsTest {
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<String>();

        list.add("test")
        list.add(10) // 컴파일 시 에러가 발생하게 된다.
    }
}

위와 같이 제네릭을 사용함으로써
1. 외부에서 타입을 지정하기 때문에 타입 변환을 해줄 필요가 없어졌다.
2. 잘못된 타입이 사용될 수 있는 문제를 컴파일 과정에서 타입을 강하게 체크하여 미리 잡아낼 수 있다(타입안정성 제고).
3. 코드 재사용성이 높아진다. 만약 Interger와 String으로 반환하고자 하는 클래스가 있을 때, Object 반환으로 인한 문제를 해결하기 위해 Interger와 String 용으로 각각 코드를 만들고자 하는 선택을 할 수도 있다. 이러한 경우 제네릭을 사용하여 타입을 파라미터화 한다면 중복을 제거할 수 있다.

제네릭 사용법

제네릭 클래스, 인터페이스

public class ClassName <T> {...}
public interface InterfaceName <T> {...}

HashMap 처럼 타입을 여러개 둘 수도 있다.

public class HashMap <K, V> { ... }

제네릭 메서드

메서드에도 제네릭을 사용할 수 있다.

public <T> void genericMethod(T o) {
    ...
}

접근제어자 <제네릭타입> 리턴타입 메서드이름(제네릭타입 인자값)

제네릭 메서드 정의시 리턴타입과 상관없이 제네릭 메서드라는 것을 컴파일러에게 알려야 하기 때문에 위와 같이 리턴타입 정의 전에 제네릭 타입을 명시해준다.

중요한 점은 클래스에 정의된 제네릭타입과 메서드에 정의된 제네릭타입은 상관이 없다는 것이다.

class ClassWithGeneric<E> {
    private E el;

    void set(E el) {
        this.el = el;
    }

    E getEl() {
        return el;
    }

    static <E> E genericMethod(E e) {
        return e;
    }
}

public class Main {
    public static void main(String[] args) {
        ClassWithGeneric<String> a = new ClassWithGeneric<String>();

        a.set("test");

        System.out.println("a E Type: " + a.get().getClass().getName());

        System.out.println("genericMethod E Type: " + ClassWithGeneric.<Integer>genericMethod(10).getClass().getName());
    }
}

[결과값]

a E Type: java.lang.String
genericMethod E Type: java.lang.Integer

위 ClassWithGeneric 클래스에서의 E와 genericMethod 메서드에서 사용하는 E는 별개라는 것을 알 수 있다.

제네릭 사용시 주의사항

  1. static 메서드에는 클래스의 제네릭 타입을 사용할 수 없다.
    위의 예제코드 중 static <E> E genericMethod(E e) { return e; } 부분에서 이미 사용한 것 아니냐고?
    아니다. 말했듯이 메서드 시그니쳐에 제네릭 타입이 들어가면 클래스와 별개로 제네릭 메서드 내에서 제네릭 타입을 사용할 수 있다. 즉 클래스 제네릭과는 별개라는 뜻이다.
    바꿔말하면 static 메서드에서 제네릭을 사용하려면 위와 같이 작성해서 사용해야 한다는 뜻이다.
    하지만 아래와 같이 쓰일 순 없다.
class ClassWithGeneric<T> {
    static void normalMethod(T param) { // 에러 발생
        ...
    }
}

위의 코드는 에러가 발생한다. 해당 메서드는 static 이기 때문에 객체 생성 전에 클래스 정보와 함께 메모리에 올라가게 된다. 그러므로 객체 생성 없이 사용이 가능한데,

public static void main(String[] args) {
    ClassWithGeneric.normalMethod(10)
}

위와 같이 사용하려 할 때 클래스에서 T 타입을 지정할 방법이 없으니 에러가 나는 것이다.
참고로 클래스 시그니쳐에서의 <T> 는 일종의 인스턴스 변수라 생각하면 된다. 객체 생성시에 지정되는 것이기 때문이다.
다시 돌아와 클래스의 제네릭 타입은 객체 생성시 지정할 수 있으므로 객체 생성전에 이미 메모리에 올라간 static 메서드에서 해당 제네릭 타입을 사용할 수 없는것이다.

  1. static 변수에 사용할 수 없다.
    static T testVar 이런 식으로 사용 불가능하다. 위와 마찬가지로 static는 클래스 로드시에 메모리에 올라가게 된다. 해당 정적 변수의 타입을 알 수가 없으므로 에러가 난다. 또한 제네릭 타입은 그때그때 인자값 넘겨주듯 바뀔 수 있는 건데, 예를 들자면 ClassWithGeneric<Integer> = new ClassWithGeneric<Integer>(); 혹은 ClassWithGeneric<String> = new ClassWithGeneric<String>(); 이런 식으로 쓰일 수 있는 건데 static은 각 객체가 공유하는 변수이므로 객체마다 타입을 다르게 쓸 수가 없는 것이다.

  2. 제네릭으로 배열을 생성할 수 없다.
    int[] num = new int[3] 배열의 선언 방식이다. 만약 new T[3] 이런 식의 제네릭 타입이 들어온다면 어떻게 될까? new 키워드를 사용하게 되면 heap 영역에 메모리 공간을 확보하게 되는데 이 때 타입을 확인하여 해당 타입의 사이즈를 기반으로 확보 공간을 계산하게 된다. 하지만 이와같이 제네릭으로 선언되어있다면 메모리 확보가 불가능하게 된다. 그러므로 배열에 사용할 수 없다.

  3. 제네릭은 불공변이다.
    불공변이란 서로 다른 타입 Type1과 Type2가 있을 때, List<Type1>List<Type2>는 서로 관련이 없음을 뜻한다.

공변이란 Sub가 Super의 하위 타입이라면 배열 Sub[] 역시 배열 Syper[]의 하위 타입이 됨을 뜻한다. 대표적으로 배열이 있다.

예를 들어보자.
(여기 참조)

List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // 컴파일 에러
animals.add(new Cat());
Dog dog = dogs.get(0);

위의 코드는 컴파일 에러가 발생한다. 제네릭은 불공변이기 때문이다. 즉, Dog가 Animal의 하위 타입이라 했을 때, List<Dog>List<Animal>의 하위 타입이 아니다.
만약 제네릭이 공변이라 한다면, List<Dog>List<Animal>의 하위 타입이라 여겨질 것이다. 그러므로 컴파일 에러가 발생하지 않을 것이다.
이때 3번째 줄의 코드 역시 문제없이 실행된다. 이러한 상황에서 마지막 줄에서처럼 dogs 리스트에서 값을 꺼내면 Dog 객체가 아닌 Cat 객체가 반환된다. 이로인해 ClassCastException이 발생되며 타입안정성이 깨지게 된다.
위와 같은 이유로 제네릭은 불공변이며 이로써 타입안정성이 지켜지게 된다.

Bounded 제네릭

들어오는 타입을 특정 범위로 제한(Bounded)할 수도 있다. extends, super, 와일드카드(?)를 사용해서이다.
extends를 사용하게 되면 upper bound 라 하며 super를 사용하면 lower bound라 한다.

예를 들어 T extends Number라 하면 Number를 상속받는 Integer, Double, Long 등의 타입만 T 자리에 올 수 있다.
반면 K super T 라고 하면, T가 하한 경계가 되어 T 타입과 T의 상위타입만 K 자리에 들어올 수 있게 된다.

와일드카드

와일드카드는 ?(물음표)로 나타낼 수 있다. 와일드카드로 나타낸 타입은 unknown 타입이다.

public static void paintTargets(List<String> targets) {
    ...
}

위에서 String List 외에도 Integer같은 다른 타입의 List를 사용하고 싶을 때는 어떻게 해야 할까. 이때 와일드카드를 사용하면 된다.

public static void paintTargets(List<?> targets) {
    ...
}

이로써 String 뿐만 아닌 다른 타입을 사용할 수 있게 되었다.

그런데, 와일드카드 대신 Object를 쓰면 안되는 것일까? 그러니까 paintTargets(List<Object> targets) 이런식으로 말이다.
String이든 Integer든 Object의 하위 타입이니 위와 같이 써도 되지 않을까 싶은 생각이 들 수도 있다.

하지만 위에서도 한 번 언급됐듯이 제네릭은 불공변이다. 즉 List<Object>List<String>, List<Integer> 사이에는 아무 관련이 없다는 것이다.

예제를 봐보자.

(밸덩 예제코드)

public static void printListObject(List<Object> list) {    
    for (Object element : list) {        
        System.out.print(element + " ");    
    }        
}    

public static void printListWildCard(List<?> list) {    
    for (Object element: list) {        
        System.out.print(element + " ");    
    }     
}

이제 위의 두 메서드에 Integer 타입의 리스트를 파라미터로 넘기고자 한다.

List<Integer> li = Arrays.asList(1, 2, 3);

printListObject(li); // 1
printListWildCard(li); // 2

다시 말하지만 얼핏 생각하면 Integer가 Object의 하위타입이므로 List<Object>가 들어갈 자리에 List<Integer>를 넣어도 되는거 아닌가 생각할 수 있다. 하지만 1번 코드는 컴파일 에러가 발생한다. 제네릭은 불공변하기 때문이다.
반면 2번 코드는 컴파일에러 없이 잘 작동한다.

또하나의 궁금증이 생긴다.

public static void printListGeneric(List<?> list) {...}

public static void printListGeneric(List<T> list) {...}

이 둘은 무슨 차이인가?

둘 다 List<Integer> li를 넘겨도, List<String> li를 넘겨도 잘 작동한다.
하지만 둘을 혼용해서 사용할 순 없다. 분명한 차이점이 있기 때문이다. 차이점은 아래와 같다.

제네릭: 현재 이 타입을 모르지만 해당 타입이 정해지면 그 타입의 특성에 맞게 사용하겠다.
와일드카드: 어떠한 타입이 와도 되나 해당 타입을 참조하지는 않을 것이다.

예를 들어보자면 아래와 같다.

interface Animal{}
class Dog implements Animal{}
class Cat implements Animal{}

public class GenericTest {
    static void printList1(List<?> list) {
        list.add(list.get(1)); // 컴파일 실패
    }

    static <T> void printList2(List<T> list) {
        list.add(list.get(1)); // 컴파일 성공
    }    
}

public void Main() {
    public static void main(String[] args) {
        List<Dog> li = Arrays.asList(1, 2, 3);

        GenericTest.printList1(li);
        GenericTest.printList2(li);    
    }
}

주석에 적어놨듯이 printList1() 메서드는 컴파일에 실패한다. 와일드카드는 list에 담긴 원소에는 전혀 관심이 없기 때문에 어떤 타입인지 확신할 수 없다. 그러므로 와일드카드로 넘어온 리스트에는 값을 추가하는 add나 addAll을 사용할 수 없다.
반면 printList2() 메서드는 잘 작동한다. T라는 제네릭 타입을 입력받는 순간 해당 타입으로 고정되기에 타입을 확신할 수 있어서이다.

T extends Comparable<? super T> 분석

마지막으로 sort 함수 같은 곳에서 볼 수 있는 T extends Comparable<? super T>와 같은 형식의 와일드카드 활용법을 분석해보고자 한다.

먼저 와일드카드를 제거한 형태의 코드를 살펴보자.

public static <T extends Comparable<T>> void sort(T t){
    ...
}

위에서 말했다시피 extends는 상한 한계이다. 이말인즉슨 T는 Comparable을 구현한 하위 클래스여야 한다는 뜻이다.
아래의 예제를 살펴보자.

class Building implements Comparable<Building> {
    private int price;

    public Building(int price) {
        this.price = price;
    }

    @Override
    public int compareTo(Building b) {
        return price - b.price;
    }
}

public class sortSample {
    public static void main(String[] args) {
        List<Building> li = new ArrayList<>();
        li.add(new Building(10000));
        li.add(new Building(20000));

        Collection.sort(li);
    }
}

sort 메서드는 문제없이 잘 작동한다. Building 클래스에서 Comparable<Building>을 구현하고 있기 때문이다.

하지만 만약 Buildng을 상속받은 House라는 클래스가 존재한다고 생각해보자.

class House extends Building {}


public class sortSample {
    public static void main(String[] args) {
        List<House> li = new ArrayList<>();
        li.add(new House(10000));
        li.add(new House(20000));

        Collection.sort(li);
    }
}

이러한 경우에도 작동을 할까?
그렇지 않다. Building 클래스에서는 Comparable<Building> 만 구현했지 Comparable<House>를 구현하지는 않았기 때문이다.
불공변에 의해 Building의 하위 클래스가 House라 할지라도 Comparable<Building>Comparable<House>은 전혀 상관이 없다.
그렇다고 모든 하위 클래스에 Comparable을 일일이 구현해 줄 수도 없는 노릇이다. 이때 <? super T>를 사용할 수 있다.
위에서도 설명했지만 super는 하한 한계이다. 즉 <? super T>은 T와 T의 상위 클래스가 올 수 있다는 뜻이다.
T에 House를 넣게 된다면
public static <T extends Comparable<? super T>> void sort(T t)
-> public static <House extends Comparable<? super House>> void sort(House t)
로 치환 가능하다. 그리고 <? super House> 이 부분은 <Building>이 될 수 있으므로 최종적으로는
-> public static <House extends Comparable<Building>> void sort(House t)
이렇게 표현할 수 있는 것이다. 우리는 Building 클래스에서 Comparable을 구현해 두었으므로 이제 sort 함수에서 House 타입의 리스트를 정렬할 수 있게 되었다.

마무리

이렇게 정리를 했는데도 아직 제네릭 소거 부분 정리를 못했다. 정리해보고자 했으나 이 부분 역시 너무 복잡하여 엄두가 안난다.
생각보다 제네릭은 어렵고 복잡한 것 같다. 나중에 시간이 날 때 소거 부분도 마저 정리해보겠다.

참조

https://www.baeldung.com/java-generics
https://st-lab.tistory.com/153
https://yaboong.github.io/java/2019/01/19/java-generics-1/
https://www.geeksforgeeks.org/generics-in-java/
https://mangkyu.tistory.com/241
https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=zxwnstn&logNo=221550689930
https://www.baeldung.com/java-generics-vs-extends-object
https://hwan33.tistory.com/24
https://nauni.tistory.com/143
https://vvshinevv.tistory.com/55?category=692309

0개의 댓글