[JAVA] 제네릭(Generic)이란 ?

김정민·2023년 6월 5일
1
post-thumbnail

제네릭(Generic)이란

클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 의미한다.

class Person<T> {
	public T info;
}

public class GenericDemo {
	public static void main(String[] args) {
    	Person<String> p = new Person<String>();
    }
}

제네릭을 사용하는 이유 ?

  1. 컴파일 시 강한 타입 체크를 할 수 있다.
    - 컴파일 시 에러를 다 도출해내기 때문에 실행 중에 일어나는 에러(RuntimeException)를 방지할 수 있습니다.
// List Integer 타입으로 제네릭 타입 적용
List<Integer> list = new ArrayList();
list.add(1);
list.add("5"); // ERROR java: incompatible types: java.lang.String cannot be converted to java.lang.Integer
  1. 타입 변환을 제거한다.(casting)
    - 다음 예시와 같이 제네릭을 사용하지 않는 경우 불필요한 타입 변환이 필요하게 됩니다.
    - 따라서 프로그램 성능에 좋지 않은 영향을 미칩니다.
List list = new ArrayList();
list.add(1);

// 강제 형 변환 필요 
Integer number = (Integer) list.get(0);

물론 제네릭을 사용하지 않으면 다음과 같이 모든 종류의 객체를 저장할 수 있습니다.

  1. Object 형태로 변환되어 List에 쌓입니다.
  2. 읽어올 때에도 타입 변환이 발생하게 됩니다.
List list = new ArrayList();
list.add(1);
list.add("5");
list.add(0.1);

Object o1 = list.get(0);
Object o2 = list.get(1);
Object o3 = list.get(2);

Integer num = (Integer) o1;
String str = (String) o2;
Long L = (Long) o3;

제네릭을 사용할 경우 List에 저장되는 요소를 Integer로 제한하기 때문에 get(index)할 경우 타입 변환이 필요가 없게 되어 프로그램 성능이 향상됩니다.

List<Integer> list = new ArrayList();
list.add(1);

// 형 변환 필요 없음
Integer number = list.get(0);

위와 같이 같이 처리하면 걸리는 시간을 단축할 수 있다.

제네릭 사용 전 후 비교

< 제네릭 사용 전 >

class Box {
    Object item;

    void setItem(Object item) {this.item = item;}
    Object getItem() {return item;}
}

public class JavaChapter {

    public static void main(String[] args) {
        Box box = new Box();

        box.setItem("AAA");

        String a = (String) box.getItem();
        System.out.println(a);
        
    }
}

< 제네릭 사용 후 >

class Box<T> {
    T item;

    void setItem(T item) {this.item = item;}
    T getItem() {return item;}
}

public class JavaChapter {

    public static void main(String[] args) {
        Box<String> sbox = new Box<>();
        Box<Integer> ibox = new Box<>();

        sbox.setItem("AAA");
        ibox.setItem(1234);

        System.out.println(sbox.getItem());
        System.out.println(ibox.getItem());
        
    }
}

한정적 와일드 카드

Java에서는 위와 같은 문제를 해결하고자 한정적 와일드카드(Bounded Wildcard)를 제공하고 있다. 한정적 와일드카드를 사용하면 특정 타입을 기준으로 상한 범위와 하한 범위를 지정함으로써 호출 범위를 확장 또는 제한할 수 있다. 한정적 와일드카드에는 상한 경계 와일드카드(Upper Bounded Wildcard)와 하한 경계 와일드카드(Lower Bounded Wildcard)가 있는데, 각각에 대해 자세히 알아보도록 하자. 상황을 이해하기 위해 다음과 같이 3가지 클래스가 존재한다고 가정하고 살펴보도록 하자.

class MyGrandParent {

}

class MyParent extends MyGrandParent {

}

class MyChild extends MyParent {

}

상한 경계 와일드카드

상한 경계 와일드카드는 와일드카드 타입에 extends를 사용해서 와일드카드 타입의 최상위 타입을 정의함으로써 상한 경계를 설정한다. 예를 들어 다음과 같이 매개변수를 출력하는 메소드에 MyParent로 상한 경계를 주었다고 하자. 아래의 코드에서 MyChild 타입으로 꺼내는 경우에는 컴파일 에러가 발생하고, 나머지 타입으로 꺼내는 것은 가능하다.

void printCollection(Collection<? extends MyParent> c) {
    // 컴파일 에러
    for (MyChild e : c) {
        System.out.println(e);
    }

    for (MyParent e : c) {
        System.out.println(e);
    }

    for (MyGrandParent e : c) {
        System.out.println(e);
    }

    for (Object e : c) {
        System.out.println(e);
    }
}

extends는 자식 클래스를 만들 때 사용되므로, 위의 <? extends MyParent>으로 가능한 타입은 MyParent와 미지(unknown)의 모든 MyParent 자식 클래스들이다. 미지의 MyParent 자식 클래스라는 것은 자식이 어떤 타입인지 알 수 없다는 것으로, 그 타입이 MyChild 일 수도 있지만, 아닐 수도 있다. 예를 들어 또 다른 MyParent의 자식인 AnotherChild 라는 클래스가 있다고 하자.

class AnotherChild extends MyParent {

}

<? extends MyParent> 타입으로는 MyChild와 AnotherChild (또는 그 외의 타입)이 될 수도 있다. 컬렉션 c에서 꺼내서 만들어지는 객체(produce)가 반드시 MyChild 타입이 아닌 AnotherChild가 될 수도 있다. 그렇기 때문에 MyChild 타입으로 꺼내려고 시도하면 컴파일 에러가 발생한다. 하지만 적어도 MyParent 임은 확실하므로 MyParent와 그 부모 타입으로 꺼내는 것은 문제가 없다.

갖고 있는 원소를 사용 또는 소모(consume)하여 컬렉션에 추가하는 경우에는 상황이 달라진다. 다음과 같이 원소를 추가하는 코드는 모든 타입에 대해 컴파일 에러가 발생한다.

void addElement(Collection<? extends MyParent> c) {
    c.add(new MyChild());        // 불가능(컴파일 에러)
    c.add(new MyParent());       // 불가능(컴파일 에러)
    c.add(new MyGrandParent());  // 불가능(컴파일 에러)
    c.add(new Object());         // 불가능(컴파일 에러)
}

왜냐하면 컬렉션의 타입인 <? extends MyParent> 으로 가능한 타입은 MyParent와 미지(unknown)의 모든 MyParent 자식 클래스들이므로, 우리는 c가 MyParent의 하위 타입 중에서 어떤 타입인지 모르기 때문이다. 먼저 하위 타입으로는 MyChild가 될 수도 있지만, AnotherChild와 같은 또 다른 하위 타입이 될 수도 있으므로 하위 타입을 결정할 수 없다. 또한 MyGrandParent와 같이 상위 타입은 적어도 MyParent 타입은 절대 아니므로, 상위 타입 역시 원소를 사용 또는 소모(consume)하는 경우는 불가능하다.

원소를 소모하는 경우에는 상한 경계가 아닌 하한 경계를 지정하여 최소한 MyParent 타입 임을 보장하면 문제를 해결할 수 있다.

하한 경계 와일드카드

상한 경계와 반대로 super를 사용해 와일드카드의 최하위 타입을 정의하여 하한 경계를 설정할 수도 있는데, 이를 하한 경계 와일드카드(Lower Bounded Wildcard)라고 한다. 예를 들어 <? super MyParent>으로 가능한 타입은 MyParent와 미지의 MyParent 부모 타입들이다. 갖고 있는 원소를 사용(consume)하여 컬렉션에 추가하는 경우를 살펴보도록 하자.

void addElement(Collection<? super MyParent> c) {
    c.add(new MyChild());
    c.add(new MyParent());
    c.add(new MyGrandParent());  // 불가능(컴파일 에러)
    c.add(new Object());         // 불가능(컴파일 에러)
}

컬렉션 C가 갖는 타입은 적어도 MyParent의 부모 타입들이다. 그러므로 해당 컬렉션에는 MyParent의 자식 타입이라면 안전하게 컬렉션에 추가할 수 있고, 부모 타입인 경우에만 컴파일 에러가 발생할 것이다.

하지만 상한 경계와 반대로 컬렉션에서 값을 꺼내서 원소를 만드는(produce) 경우에는 상황이 다르다.

void printCollection(Collection<? super MyParent> c) {
    // 불가능(컴파일 에러)
    for (MyChild e : c) {
        System.out.println(e);
    }

    // 불가능(컴파일 에러)
    for (MyParent e : c) {
        System.out.println(e);
    }

    // 불가능(컴파일 에러)
    for (MyGrandParent e : c) {
        System.out.println(e);
    }

    for (Object e : c) {
        System.out.println(e);
    }
}

우선 상한 타입부터 살펴보도록 하자. <? super MyParent>으로 가능한 타입은 MyParent와 미지의 MyParent 부모 타입들이므로, 부모 타입을 특정할 수 없어 모든 부모 타입들에 제약(컴파일 에러)이 발생한다. Object 같은 경우에는 Java에서 지원하는 모든 객체의 부모임이 명확하므로, 특별히 Object 타입의 객체로 원소를 만드는(produce) 경우에는 컴파일 에러가 발생하지 않는다.

하위 타입인 경우에도 문제가 되는데, <? super MyParent>으로 가능한 타입은 MyParent와 미지의 MyParent 부모 타입들이므로 MyChild와 같이 경계 아래의 하위 타입들은 당연히 추가될 수 없기 때문이다.

PECS(Producer-Extends, Consumer-Super) 공식

그렇다면 도대체 언제 super를 사용해야 하고, 언제 extends를 사용해야 하는지 헷갈릴 수 있다. 그래서 이펙티브 자바에서는 PECS라는 공식을 만들었는데, 이는 Producer-Extends, Consumer-Super의 줄임말이다. 즉, 컬렉션으로부터 와일드카드 타입의 객체를 생성 및 만들면(produce) extends를, 갖고 있는 객체를 컬렉션에 사용 또는 소비(consumer)하면 super를 사용하라는 것이다.

void printCollection(Collection<? extends MyParent> c) {
    for (MyParent e : c) {
        System.out.println(e);
    }
}

void addElement(Collection<? super MyParent> c) {
    c.add(new MyParent());
}

printCollection 같은 경우에는 컬렉션으로부터 원소들을 꺼내면서 와일드카드 타입 객체를 생성(produce)하고 있다. 반대로 addElement의 경우에는 컬렉션에 해당 타입의 원소를 추가함으로써 객체를 사용(consume)하고 있다. 그러므로 와일드카드 타입의 객체를 생성(produce)하는 printCollection은 extends가, 객체를 사용(consume)하는 addElement에는 super가 적합한 것이다.

출처 : [Java] 제네릭 사용 이유 (Why do use Generic in Java?) - https://veneas.tistory.com/entry/Java-%EC%A0%9C%EB%84%A4%EB%A6%AD-%EC%82%AC%EC%9A%A9-%EC%9D%B4%EC%9C%A0-Why-do-use-Generic-in-Java
출처 : 생활코딩
출처 : [Java] 제네릭을 사용하는 이유 - https://velog.io/@yeony402/Java-%EC%A0%9C%EB%84%A4%EB%A6%AD%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0
출처 : [Java] 제네릭과 와일드카드 타입에 대해 쉽고 완벽하게 이해하기(공변과 불공변, 상한 타입과 하한 타입) - https://mangkyu.tistory.com/241

0개의 댓글