제네릭(generics)과 와일드카드(wildcards)에 대해 알아보자

Learn about generics and wildcards

Level2 방학 동안 그동안 했던 것들을 돌아보기 위해 Good Code, Bad Code를 사서 맛보고 있는데 매우 맛있다. 해당 책은 높은 품질의 코드를 짜기 위해서 여러 가지 전략들을 제시 해 준다. 그 중 코드를 재사용 가능하고 일반화할 수 있게 작성하라라는 부분을 읽던 중 “제네릭의 사용을 고려하라”라는 방법을 보게 되었다.

특정 타입에 의존하면 일반화가 제한된다고 한다. 예를 들어, 단어 맞히기 게임을 개발한다고 생각해 보자. 게임 참여자들이 각각 단어를 제출한 다음 돌아가며 한 단어씩 동작으로 설명하면 다른 참여자들이 어떤 단어인지 맞혀야 한다. 이를 무작위 큐를 이용해 다음과 같이 구현해 볼 수 있을 것이다.

class RandomizedQueue {
  private final List<String> values = [];

  //새로운 단어(문자열) 추가
  void add(String value) {
    values.add(value);
  }

  //큐로부터 무작위로 한 항목을 삭제하고 그 항목을 반환
  String getNext() {
    if (values.isEmpty()) {
      return null;
    }
    int randomIndex = Math.randomInt(0, values.size());
    values.swap(randomIndex, values.size() -1);
    return values.removeLast();
  }
}

위처럼 코드를 짜게 되면 문자열(String)로 표현될 수 있는 단어를 저장하는 특정한 타입의 문제는 해결할 수 있지만 다른 타입의 동일한 하위 문제를 해결할 수 있을 만큼 일반화되어 있지 않다. 즉, 단어가 아닌 사진을 보고 설명하고 그 외 나머지는 다 동일한 게임을 다른 팀에서 개발한다고 생각해 보면 위의 코드는 문자열을 사용하도록 하드 코딩되어 있기 때문에 재사용할 수 없다.

위의 코드를 일반화하여 사용하려면 다음과 같이 제네릭을 이용해서 개선해볼 수 있다.

class RandomizedQueue<T> {
  private final List<T> values = [];

  void add(T value) {
    values.add(value);
  }

  T getNext() {
    if (values.isEmpty()) {
      return null;
    }
    int randomIndex = Math.randomInt(0, values.size());
    values.swap(randomIndex, values.size() -1);
    return values.removeLast();
  }
}

이렇게 하면 이제 RandomizedQueue 클래스는 어떤 것이라도 저장할 수 있기 때문에 단어를 사용하는 게임 버전에서는

RandomizedQueue<String> words = new RandomizedQueue<String>();

사진을 사용하는 게임 버전에서는 다음과 같이 쉽게 사용할 수 있게 된다.

RandomizedQueue<Picture> words = new RandomizedQueue<Picture>();

이처럼 제네릭을 사용하면 코드를 재사용하여 좋은 코드를 작성할 수 있어 매력적이게 보였다. 하지만, 지금까지 제네릭을 프로젝트에서 제대로 적용해 본 적은 없었기 때문에 제네릭에 대해 자세히 알아볼 필요성을 느꼈다. 이 포스팅을 기반으로 후에 잘 적용할 수 있기를!

제네릭(Generics)이란?

제네릭이란 JDK 5.0부터 도입된 기능으로, 컴파일 타임 타입 안전성을 제공하면서 다양한 타입의 객체에서 작동할 수 있게 해준다. 제네릭을 사용함으로 써 컬렉션 프레임워크에 컴파일 타임 타입 안정성을 추가하고 캐스팅의 번거로움을 없앨 수 있다.

제네릭이 등장하기 전

제네릭이 왜 등장했을까? 제네릭이 등장하기 전에는 다음과 같은 문제점들이 있었다.

  1. 필수적인 캐스팅(형변환)
  2. 런타임 시점 에러 발생
List myIntList = new LinkedList();
myIntList.add(new Integer(0));
Integer value = (Integer) myIntList.iterator().next();

컴파일러는 이터레이터가 객체를 반환한다는 것만 보장할 수 있기 때문에 정수형 변수에 대한 할당이 필요하다면 (Integer)와 같은 형 변환이 필요했다. 이 형 변환은 복잡성을 유발할 뿐 아니라, 프로그래머가 실수할 수 있으므로 런타임 오류가 발생할 가능성도 있다.

이를 제네릭을 사용하여 특정 데이터 타입을 포함하도록 목록을 제한하는 것으로 나타낼 수 있다면 어떨까?

List<Integer> myIntList = new LinkedList<Integer>();
myIntList.add(new Integer(0));
Integer value = myIntList.iterator().next();

세 번째 줄에 형 변환이 사라지고, List 옆에 타입 매개변수가 추가되었다. 이렇게 함으로 써 형 변환을 제거할 수 있게 되었고 이제 컴파일러는 컴파일 시점에 프로그램의 타입 정확성을 확인할 수도 있게 되었다.

제네릭(Generics)

제네릭을 적용하여 클래스를 생성해보자. 다음과 같이 클래스명 옆에 화살 괄호가 추가되고 그 안에 타입 매개 변수가 위치하게 된다.

class GenericList<T> {
  private List<T> values = new ArrawyList<>();

  public void add(T value) {
    values.add(value);
  }
}

GenericList<String> stringList = new GenericList<>();
GenericList<Integer> IntegerList = new GenericList<>();

또한, 여러개의 매개 변수도 받을 수 있다.

class Generics<T, E> {
  private T generic1;
  private E gerneric2;
}

Generics<String, Integer> generic = new Generics<String, Integer>();

제네릭 메서드(Generic Methods)

클래스의 메소드 안에서만 제네릭을 사용할 수도 있는데 그렇게 되면 타입 매개 변수의 범위가 메서드 내로 제한되게 된다.

public class GenericMethod {

  public static <T> void print(T info) {
    System.out.println(info);
  }
}

GenericMethod.printInfo("String");
GenericMethod.printInfo(100);

공변과 불공변

와일드카드를 알아보기 전에 공변불공변이 뭔지 알아보자

공변(Covariant): 타입 B가 타입 A의 하위 타입일 때, T<B> 가 T<A>의 하위 타입인 경우

  • ex) Object[] objects = new Integer[10];

불공변(Invariant): 타입 B가 타입 A의 하위 타입일 때, T<B> 가 T<A>의 하위 타입이 아닌 경우

  • ex) List<Object> list = new ArrayList<Integer>(); //컴파일 에러 발생

와일드카드(Wildcards)

컬렉션의 모든 요소를 출력한다고 생각해보자. 제네릭이 나오기 이전에는 다음과 같다.

void printCollection(Collection c) {
    Iterator i = c.iterator();
    for (k = 0; k < c.size(); k++) {
        System.out.println(i.next());
    }
}

그리고 제네릭을 사용하게 되면 코드는 다음과 같을 것이다.

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


List<String> list = Arrays.asList("a", "b");
printCollection(list); //컴파일 에러 발생

첫 번째 코드는 모든 종류의 컬렉션을 매개변수로 사용하여 호출할 수 있는 반면, 제네릭은 불공변이기 때문에 두 번째 코드로 작성해놓더라도 실제로 모든 타입에서 공통적으로 사용할 수 없는 문제점이 있었다. 즉, 제네릭을 사용함으로 써 더 유용하지 못하게 되었다는 것인데 이를 극복하기 위해 나온 것이 바로 와일드카드이다.

모든 타입의 상위 타입인 와일드카드(<?>)를 사용해서 우리는 다음과 같이 사용해 모든 타입의 컬렉션을 호출할 수 있다.

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

하지만 또 와일드카드로 선언함으로써 생기는 문제점이 있었는데

Collection<?> list = new ArrayList<String>();
list.add(new Object()); // 컴파일 타임 에러 발생

해당 list의 요소 타입이 무엇인지 모르기 때문에 우리는 객체를 추가할 수 없다. add() 메서드로 추가하기 위해서는 컬렉션의 요소 타입인 E 타입 혹은 E 타입의 하위 유형을 전달해서 추가할 수 있다. 하지만 타입 매개변수가 ?인 경우 알 수 없는 타입(unknown type)을 나타내기 때문에 우리는 아무것도 전달할 수 없다.(null은 예외적으로 가능)

하지만, List<?>가 주어졌을 때 get()을 호출하고 결과는 사용할 수 있다. 왜냐하면 결과 유형은 알 수 없는 유형이지만 항상 객체라는 것을 알고 있기 때문이다.

한정적 와일드카드(Bounded Wildcards)

그렇기 때문에 한정적 와일드카드(Bounded Wildcards)라는 기능이 존재하는데 이 한정적 와일드카드를 이용하여 타입의 범위를 제한해 위의 문제점을 해결할 수 있다. 우선 다음과 같이 클래스가 정의되어 있다고 생각해 보자

public class Shape {
  ...
}

public class Circle extends Shape {
  ...
}

public class Rectangle extends Shape {
  ...
}

상한 경계 와일드 카드(Upper Bounded Wildcards)

상한 경계 와일드카드는 extends를 이용하여 상위 타입을 정의해 주므로 써 상한 경계를 설정해줄 수 있다. 그렇게 하면 제한적으로 다음과 같이 꺼낼 수 있게 된다.

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

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

    //컴파일 에러 발생
    for (Circle e : c) {
        System.out.println(e);
    }

    //컴파일 에러 발생
    for (Rectangle e : c) {
        System.out.println(e);
    }
}

위와 같이 Shape로 상한 경계를 준 경우 Shape 이상(부모)인 클래스로 꺼내는 경우 모두 가능하지만, 그 아래(자식) 클래스로 꺼내는 것은 불가능하다. <? extedns Shape>으로 가능한 타입은 Shape와 그 자식 클래스이므로 Shape 이상 클래스로 꺼내는 것은 문제가 없다. 하지만, 그 자식 클래스로 꺼내는 경우는 Circle 인지 Rectangle 인지 알 수 없으므로 컴파일 에러가 발생한다.

상한 경계 와일드카드를 사용했을 때 추가하는 경우를 한번 보자.

void addShapes(Collection<? extends Shape> c) {
  c.add(new Shape());       //컴파일 에러 발생
  c.add(new Object());      //컴파일 에러 발생
  c.add(new Rectangle());   //컴파일 에러 발생
  c.add(new Circle());      //컴파일 에러 발생
}

컬렉션에 추가하는 경우 모든 타입에 대해 컴파일 에러가 발생한다. 왜냐하면 <? extends Shape>으로 가능한 타입은 Shape와 모든 그 자식 클래스인데 c가 정확히 어떤 타입인지 모르기 때문이다. 만약 Rectangle로 타입 매개변수가 된 컬렉션인 경우 Shape가 들어올 위험이 있을 수 있다. 그래서 추가하는 경우에는 상한 경계가 아닌 하한 경계 와일드카드를 사용해 볼 수 있다.

하한 경계 와일드 카드(Lower Bounded Wildcards)

하한 경계 와일드카드는 super를 이용하여 하위 타입을 정의해 주므로 써 하한 경계를 설정해줄 수 있다. 그렇게 하면 제한적으로 다음과 같이 추가할 수 있게 된다.

void addShapes(Collection<? super Shape> c) {
  c.add(new Object());      //컴파일 에러 발생
  c.add(new Shape());       
  c.add(new Rectangle());   
  c.add(new Circle());      
}

위와 같이 Shape로 하한 경계를 준 경우 Shape 이하(자식)인 클래스들을 추가하는 것은 가능하지만 Shape 그 위(부모)의 클래스들을 추가하는 것은 불가능하다. <? super Shape>으로 가능한 타입은 Shape와 그 부모 클래스이므로 Shape 이하 클래스로 추가하는 것은 문제가 없다. 하지만, 그 부모 클래스로 추가하는 경우는 상한 경계처럼 상위 타입이 추가될 위험이 있으므로 컴파일 에러가 발생한다.

하한 경계 와일드카드를 사용했을 때 꺼내는 경우를 한번 보자.

void printCollection(Collection<? super Shape> c) {
    //컴파일 에러 발생
    for (Shape e : c) {
        System.out.println(e);
    }

    //컴파일 에러 발생
    for (Circle e : c) {
        System.out.println(e);
    }

    //컴파일 에러 발생
    for (Rectangle e : c) {
        System.out.println(e);
    }

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

<? super Shape>으로 가능한 타입은 Shape와 Shape의 부모 클래스이므로 정확한 부모 타입를 알 수 없어 컴파일 에러가 발생한다. 예를 들어 Shape로 타입 매개변수가 된 컬렉션인 경우 그 부모 클래스로 꺼낼 수 없을 것이다. 하지만, Object 같은 경우 모든 객체의 부모임이 확실하므로 컴파일 에러가 발생하지 않는다.

+그렇다면 언제 extends, super…?

이펙티브 자바에서는 펙스(PECS)라는 공식이 나온다. producer-extends, consumer-super 즉, 매개변수화 타입 T가 생성자라면 <? extends T>를 사용하고, 소비자라면 <? super T>를 사용하라는 것이다.

//컬렉션의 원소를 꺼내 와일드카드 타입 객체 생성(produce)
void printCollection(Collection<? extends Shape> c) {
    for (Shape e : c) {
        System.out.println(e);
    }
}

//컬렉션에 와일드카드 타입 원소 추가함으로 객체 소비(consume)
void addShape(Collection<? super Shape> c) {
  c.add(new Shape());       
}

참고:

*틀린 부분이 있으면 언제든지 말씀해 주시면 공부해서 수정하겠습니다.;


© 2022. All rights reserved.

Powered by 애송이