본문 바로가기

경험과 지식

제네릭, 와일드카드 처음부터 끝까지 이해하기[with PECS, <E extends Comparable<? super E>> E max(Collection<? extends E> c)]

특정 타입 E의 컬렉션을 인자로 받아 최댓값을 반환하는 제네릭 한 max 메서드를 만든다면, 선언부는 아래와 같이 설계할 수 있습니다.
public static <E extends Comparable<? super E>> E max(Collection<? extends E> c) { ... }
자바를 처음 배울 때 난해하다고 생각했던 기억이 있어서 자바 기초지식이 없는 입장에서 정리해보려고합니다.

 

Collection

프로그래밍을 하다 보면 특정 타입의 객체들을 리스트나 배열에 담을 일이 많습니다. 웹사이트 유저목록, 메뉴판의 메뉴목록 등과 같습니다.

이를 위해 자바에서는 데이터 특성에 따라 사용가능한 컬렉션 인터페이스(List, Queue, Set, Map 등)들이 있습니다. 이들은 공통 적으로 Collection 인터페이스를 확장합니다. 다음은 일부입니다.

public interface Collection<E> extends Iterable<E> {
	int size();
	// 외 메서드 생략
}

 

이 인터페이스의 구현체들은(ArrayList, PriorityQueue...) 크기를 재는 기능이 필수입니다. 순회하거나, 비어있는지 체크하거나 등등 다양한 부분에서 필요할 것 같습니다. 그래서 Collection의 구현체는 항상 size() 메서드를 구현해야 합니다. 마찬가지 이유로 Collection의 구현체들은 size() 말고도 다양한 기능들을 구현해야 합니다.

 

Collection<E> - 제네릭

제네릭에 대한 개념은 이미 많은 문서들이 있으므로  간략히 정리하도록 하겠습니다.

Collection 인터페이스가 Collection<E> 와 같은 모양을 갖는 이유는 타입안정성 때문입니다. 

Collection 에는 여러 객체를 담고 싶지만 의도한 타입의 객체가 들어오지 않을 수도 있습니다. 아래 예시는 정수 배열의 총합을 출력하는 addAll() 메서드 예시입니다.

private static int addAll() {
    List list = new ArrayList();
    list.add(1);
    list.add(2);
    list.add("3");

    int sum = 0;
    for (int i = 0; i < list.size(); i++) {
        int o = (int) list.get(i);
        sum += o;
    }
    return sum;
}

어떤 실수로 인해 "3"이라는 문자열이 리스트에 추가되었습니다. 코드는 정상적으로 컴파일되겠지만, int 캐스팅과정에서 런타임 에러가 날 것입니다. 

컴파일 단계에서 이 오류를 잡기 위해 integer 타입만을 담는 IntegerList 같은 Collection을 새로 만들어 해결이 가능합니다. 아마 IntegerList의 add() 메서드는 파라미터로 Integer 타입을 받을 것입니다.

하지만 이렇게 하면 리스트에 담길 타입별로 리스트를 각각 따로 만들어주어야 합니다. 문자열을 담는 StringList, 정수만 담는 IntegerList 등등입니다.

그래서 타입을 강제하면서 여러 타입에 대해 적용가능한 클래스와 메서드를 만들기 위해 Generics가 등장하였습니다.

 

Collection 인터페이스의 구현체는 선언할 때 Collection에 담길 요소들의 타입을 명시해 줍니다.

아래 List<Integer> 와 같이 사용합니다. 참고로 제네릭 타입에는 원시타입이 사용될 수 없어서 int 대신 Integer 래퍼타입으로 명시하였습니다.

아래 코드는 Collection에 선언된 타입과 다른 요소가 추가되어 컴파일 에러가 발생합니다. 개발자는 자신의 실수를 최대한 빨리 알아챌 수 있습니다.

private static int addAll() {
    List<Integer> list = new ArrayList<>();
    list.add(1);
    list.add(2);
    list.add("3");//compile error 배포 전 수정 가능

    int sum = 0;
    for (int i = 0; i < list.size(); i++) {
        Integer i1 = list.get(i);
        sum += i1;
    }
    return sum;
}

 

Comparable

직역하면 '비교가능한'이란 인터페이스입니다. 이 인터페이스에는 compareTo라는 단 하나의 기능만이 정의되어 있습니다.

public interface Comparable<T> {
    int compareTo(T var1);
}

 

 

이 인터페이스를 구현하게 되면, 구현한 타입 인스턴스는 T타입의 인스턴스와 비교가능해야 합니다.

다음은 Integer 클래스에서 관련 부분입니다. 내부적으로 호출되는 compare(); 메서드에 대한 설명은 생략하겠습니다.

public final class Integer extends Number implements Comparable<Integer> {
    public int compareTo(Integer anotherInteger) {
        return compare(this.value, anotherInteger.value);
    }

    public static int compare(int x, int y) {
        return x < y ? -1 : (x == y ? 0 : 1);
    }
}

 

Integer 타입인스턴스 Integer 타입의 인스턴스와 비교가능합니다. 아래 예시와 같습니다. 

Integer integer = 1;
integer.compareTo(2);

 

 

와일드카드 '?'

Generic을 활용하여 다양한 타입을 지원하고 한 번 정해진 타입을 강제할 수 있게 되었지만 아직 고민할 부분이 남아있습니다. 아래와 같은 경우입니다.

import java.util.ArrayList;
import java.util.List;

interface Animal {
    void makeFriends(List<Animal> animalList);
}

class Dog implements Animal {
    @Override
    public void makeFriends(List<Animal> animalList) {
    }
}

public class Main {
    public static void main(String[] args) {
        Animal animal = new Dog();
        List<Dog> dogList = new ArrayList<>();
        dogList.add(new Dog());
        animal.makeFriends(dogList);
    }
}

Animal 끼리 친구를 맺을 수 있는 makeFriends() 메서드가 있습니다. Animal은 같은 Animal 타입의 리스트를 받아 친구를 사귈 수 있습니다. 19라인의 animal.makeFriends(dogList); 는 언뜻 보면 문제가 없어 보입니다. Animal의 하위타입 Dog 리스트를 인자로 하여 makeFriends() 메서드를 호출했습니다.

그러나 이 코드는 List<Animal> 이 들어갈 자리에 List<Dog>가 들어왔다는 IDE 경고와 함께 컴파일 에러가 납니다.

간단히 말하면 Dog는 Animal의 하위타입이지만 List<Dog> 는 List<Animal>의 하위타입이 아니기 때문입니다. 조금만 생각해 보면 당연함을 알 수 있습니다.

주어진 요구사항은 Animal과 그 하위타입까지 받을 수 있는 List를 인자로 받고 싶습니다. 이를 위해 와일드카드 문자 '?'가 있습니다.

'?'는 상/하위 타입을 유연하게 처리하기 위한 특수한 매개변수화 타입으로, makeFriends() 메서드의 매개변수 부분을 다음과 같이 바꾸어 의도한 대로 동작하게 변경하였습니다.

interface Animal {
    void makeFriends(List<? extends Animal> animalList);
}

이로써 Animal 하위타입의 List를 인자로 받는 메서드로 변경되었고 잘 작동하게 되었습니다.

'? extends Animal'은 Animal 및 하위타입이란 뜻이고 '? super Animal'은 Animal 및 상위타입이라는 뜻입니다.

 

PECS

위 같은 상황을 피하기 위해 파라미터를 유연하게 만들자는 취지로 PECS원칙이 있습니다.

PECS(Producer-Extends, Consumer-Super)는 매개변수화 타입 T가 생산자면 extends를 쓰고 소비자면 super를 사용하는 원칙입니다.

 

외부로부터 컬렉션 매개변수를 받아 로직을 수행하는 메서드가 있을 때 이 매개변수를 생산자(producer)라고 합니다. 

생산자의 매개변수화 타입은 그냥 <E>가 아닌 <? extends E>와 같이 사용합니다.

 

반대로 이 클래스의 멤버에서 또는 새로 인스턴스를 생성해서 매개변수 컬렉션으로 소비할 때, 이 매개변수를 소비자(consumer)라고 합니다.

소비자의 매개변수화 타입은 그냥 <E>가 아닌 <? super E>와 같이 사용합니다.

아래 print()와 addDefaultValuesToList()가 각각의 예시 메서드입니다.

import java.util.List;

public class ListProcessor<E> {

    void print(Iterable<? extends E> producer) {
        producer.forEach(el-> System.out.println(el));
    }

    void addDefaultValuesToList(List<? super String> consumer) {
        consumer.addAll(List.of("init1","init2"));
    }
}

 

메서드에 선언된 매개변수화 타입

간략하게 짚고 넘어가겠습니다. 매개변수화 타입은 메서드에도 선언될 수 있습니다. 문법은 다음과 같습니다. 

import java.util.List;

public class MyClass {
    <V> V method(V v) { //접근제어자(생략) <매개변수화타입> 리턴타입 메서드명 (매개변수) {
        return v;
    }
}

 

max() 메서드 이해하기

드디어 이해를 위한 기반지식을 모두 갖추었습니다. 전체 max() 메서드는 아래와 같습니다.

구현부는 간단합니다. 비어있지 않은 <? extends E> 타입 컬렉션의 각 요소들에서 최우선 순위의 요소 E를 리턴합니다.

public static <E extends Comparable<? super E>> E max(Collection<? extends E> c) {
    if (c.isEmpty()) throw new IllegalArgumentException("empty collection");
    E result = null;
    for (E e : c) {
        if (result == null || e.compareTo(result) > 0) {
            result = Objects.requireNonNull(e);
        }
    }
    return result;
}

 

 

메서드에 선언된 매개변수화 타입부터 보겠습니다. <E extends Comparable<? super E>> 입니다.

하나씩 해석하기 위해 우선 PECS 원칙에 의한 와일드카드를 제거해 보겠습니다. 'E extends Comparable <E>'이 형태는 Comparable 단락에서 Integer 클래스에서 본 형태와 비슷합니다.

이 매개변수화 타입 E는 자기 자신 E타입의 Comparable을 구현한 타입, 즉 자기와 같은 타입의 다른 인스턴스를 비교할 수 있는 타입입니다. max라는 의미를 구현하기 위해서는 컬렉션의 각 요소끼리 비교가 가능해야 하므로 자기 자신의 Comparable 구현이 필수적입니다.

E 들어갈 수 있는 클래스는 아래와 같은 모습일 것입니다. EChild 클래스와 EParents 클래스 모두 E가 될 수 있습니다.

(참고 1) Comparable의 매개변수화 타입은 Comparable<? super E>를 나타내고자 Object로 하였습니다.

(참고 2) compareTo()의 구현부는 임시로 작성되었습니다. 이 부분에 들어가는 로직은 '최댓값'의 정의에 따라 달라집니다.

public class EParents implements Comparable<Object>{
    @Override
    public int compareTo(Object o) {
        return 0;
    }
}

class EChild extends EParents {}

 

이제 max() 메서드의 매개변수 부분을 보겠습니다. 

Collection<? extends E>입니다. PECS 원칙을 지켜 작성되어 있습니다. 위 Collection<E> 및 와일드카드 단락에서 본 내용과 같습니다. E 타입의 하위타입 컬렉션을 모두 매개변수로 받을 수 있습니다.

 

리턴타입은 E입니다. 결국 E 컬렉션을 받아 E 타입 요소를 하나만 리턴하면 해당 메서드는 모두 구현된 것이라고 할 수 있습니다.

적용 예시는 다음과 같습니다. Collection으로 사용된 List에 EChild, EParents 둘 다 적용이 가능하며 두 타입이 섞여있어도 가능합니다.

개발자는 compareTo()에 적당한 비교 로직을 담아 'max'라는 의미에 맞는 하나의 E를 리턴하도록 하면 됩니다.

public class Main {

    public static void main(String[] args) throws Exception {
        List<EParents> eParents = List.of(new EParents(), new EParents());
        List<EChild> eChildren = List.of(new EChild(), new EChild());
        EParents max = max(eParents);
        EChild max1 = max(eChildren);
    }
    
    public static <E extends Comparable<? super E>> E max(Collection<? extends E> c) {
        if (c.isEmpty()) throw new IllegalArgumentException("empty collection");
        E result = null;
        for (E e : c) {
            if (result == null || e.compareTo(result) > 0) {
                result = Objects.requireNonNull(e);
            }
        }
        return result;
    }
}

 

 

회고

public static <E extends Comparable<? super E>> E max(Collection<? extends E> c) { ... }

를 풀어서 정리하면 아래와 같습니다.

max 메서드의 매개변수화 타입은 E타입이다. E는 E 또는 그 상위타입의 Comparable을 구현했다. 그러므로 CompareTo() 메서드를 통해 서로 비교가 가능한 타입이다. max 메서드는 매개변수로 이 E 또는 E의 하위타입의 컬렉션을 매개변수로 받고 리턴으로 E타입을 반환한다.

PECS 원칙을 비롯한 매개변수화 타입은 자바 API 등에서 매우 자주 보이는 형태로 이해하고 문서를 읽는 습관을 갖겠습니다!!