본문 바로가기
CS/java

자바에서 PECS란?

by LDY3838 2024. 3. 20.
반응형

PECS란 Producer Extends Consumer Super의 줄임말입니다.

이를 직역하면 Producer 역할을 맡을 때는 extends를 사용하고 Consumer의 역할을 맡을 때는 super를 쓴다는 말입니다.

위에서 언급한 Producer 역할과 Consumer의 역할을 맡는 개체는 Collection에 해당합니다.

extends와 super는 자바의 키워드로 상속 관계에서 자신을 상속한 타입인지, 자신의 부모 타입인지를 나타냅니다.

지금부터 PECS가 무엇인지 자세히 알아보도록 하겠습니다.


우선 PECS를 이해하기 위해서는 자바 제네릭스와 wildcard에 대한 개념을 이해해야 합니다.

우선 자바 제네릭스란 하나의 메서드나 클래스에서 여러 종류의 타입을 받아서 공통으로 쓸 수 있게 하는 기능을 의미합니다.

제네릭스를 이해하기 위해서 아래의 코드를 살펴보도록 하겠습니다.

public class Main {

    public static void main(String[] args) {
        String stringVal = "asd";
        Integer intVal = 3;
        Double doubleVal = 3.2;

        genericPrint(stringVal);
        genericPrint(intVal);
        genericPrint(doubleVal);
    }

    static <T> void genericPrint(T t) {
        System.out.println(t);
    }
}

 

asd
3
3.2

위의 코드를 실행키면 이와 같은 결과가 나오게 됩니다.

<T>와 같은 키워드를 통해서 해당 메서드에서 제네릭스를 사용하겠다는 것을 나타냅니다.

이와 같이 타입과 상관없이 값을 받아서 쓸 수 있는 편리한 기능이 제네릭스입니다. 만약 제네릭스가 없었다면 우리는 아래와 같은 코드를 써야 했을 것입니다.

public class Main {

    public static void main(String[] args) {

        String stringVal = "asd";
        Integer intVal = 3;
        Double doubleVal = 3.2;

        genericPrintString(stringVal);
        genericPrintInt(intVal);
        genericPrintDouble(doubleVal);
    }
    
    static void genericPrintInt(Integer t) {
        System.out.println(t);
    }
    static void genericPrintDouble(Double t) {
        System.out.println(t);
    }
    static void genericPrintString(String t) {
        System.out.println(t);
    }
}

위의 코드도 같은 결과가 나오지만 중복되는 기능을 가진 메서드를 여러 개 만들어야 합니다.

현재는 간단한 출력 기능만 있기 때문에 괜찮아 보이지만 중복되는 로직이 매우 많은 메서드를 여러 개 쓰면 나중에 수정할 때도 실수할 가능성이 높아지게 됩니다.


이와 같은 상황만 보면 제네릭스를 쓰는 것이 무조건 좋아 보입니다.

하지만 제네릭스를 쓰는 것에는 단점이 있습니다.

우선 해당 class가 가진 기능을 사용하지 못합니다. T라는 형식은 런타임에 어떤 형식이 들어올지 결정되기 때문에 컴파일 타임에 해당 class가 가진 메서드 등을 사용하지 못하게 됩니다. 

또한 위와 같은 이유로 T 타입의 배열의 선언이 불가합니다. 이 이유는 컴파일 시점에는 T의 크기가 얼마인지 알 수 없기 때문입니다. 따라서 아래와 같은 코드는 컴파일 에러가 발생합니다.

     <T> void genericPrint(T t) {
        T[] tArray = new T[5];
        System.out.println(t);
    }

 

Type parameter 'T' cannot be instantiated directly

위와 같이 T, R 등을 이용하여 여러 타입을 받을 수 있게 만든 기능이 제네릭스 였습니다.


이제 wildcard가 무엇인지 알아보도록 하겠습니다.

wildcard란 java가 collection을 다룰 때 다양한 타입의 collection을 다룰 수 있게 만든 기능입니다.

우선 아래의 코드를 보겠습니다.

public class Main {

    public static void main(String[] args) {
        
        Integer intV = 3;
        Number numberV = 4;
        
        numberV = intV;
    }
}

간단한 down casting 예제입니다. Number class는 Integer class의 부모 클래스이기 때문에 down casting의 적용이 가능합니다.

하지만 아래의 코드를 보면 컴파일 에러가 발생합니다.

public class Main {

    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>();
        for (int i = 1; i <= 5; i++) {
            list.add(i);
        }
        
        wildcardPrint(list);
    }


    static void wildcardPrint(List<Number> list) {
        for (Number i : list) {
            System.out.println(i);
        }
    }
}

 

분명 Number class는 Integer class의 부모 클래스여서 위와 같은 기능도 가능할 것이라고 예측하였지만 java 언어에서는 이를 허용하지 않습니다.

그럼 Integer 타입을 가지고 있는 List와 Double 타입을 가지고 있는 List에서 값을 출력하기 위해서는 아래와 같이 코드를 따로 만들어 주어야 할까요?

public class Main {

    public static void main(String[] args) {
        List<Integer> intList = new ArrayList<>();
        for (int i = 1; i <= 5; i++) {
            intList.add(i);
        }
        List<Double> doubleList = new ArrayList<>();
        for (int i = 1; i <= 5; i++) {
            doubleList.add((double) i);
        }
        
        wildcardPrintInteger(intList);
        wildcardPrintDouble(doubleList);
    }


    static void wildcardPrint(List<Number> list) {
        for (Number i : list) {
            System.out.println(i);
        }
    }
    static void wildcardPrintInteger(List<Integer> list) {
        for (Number i : list) {
            System.out.println(i);
        }
    }
    static void wildcardPrintDouble(List<Double> list) {
        for (Number i : list) {
            System.out.println(i);
        }
    }
}

 

1
2
3
4
5
1.0
2.0
3.0
4.0
5.0

위와 같은 코드를 사용하면 wildcardPrintInteger() 메서드와 wildcardPrintDouble() 메서드는 완전히 같은 기능을 하고 있지만 서로 다른 메서드가 필요합니다.

심지어 Object에 있는 기능인 toString()만을 사용하고 있는데도 Collection이라는 이유 만으로 서로 다른 메서드를 만들어야 하는 것은 너무 비효율적인 일입니다.

위와 같은 문제를 해결하기 위해서 우리는 wildcard라는 기능을 사용할 수 있습니다.

public class Main {

    public static void main(String[] args) {
        List<Integer> intList = new ArrayList<>();
        for (int i = 1; i <= 5; i++) {
            intList.add(i);
        }
        List<Double> doubleList = new ArrayList<>();
        for (int i = 1; i <= 5; i++) {
            doubleList.add((double) i);
        }

        wildcardPrint(intList);         
        wildcardPrint(doubleList);
    }


    static void wildcardPrint(List<?> list) {
        for (Object i : list) {
            System.out.println(i);
        }
    }
}

위의 코드에서는 기존의 Collection의 안에 어떤 타입이 들어가 있는지에 따라서 메소드를 다르게 만들어주어야 하는 문제를 해결하였습니다.

위의 코드는 이전의 코드와 완전히 같은 결과가 나옵니다.

List<?>를 이용하여 Object를 상속한 객체 즉 모든 객체들을 사용할 수 있게 해줍니다.

이렇게 Collection 안에 있는 타입이 무엇이냐와 관계없이 사용할 수 있게 해주는 기능이 wildcard입니다.

하지만 이렇게 함으로써 모든 문제가 해결된 것일까요?


intList와 doubleList라는 2개의 List는 각각 Integer, Double 타입을 가지고 있습니다.

위의 두 타입은 + 연산이 가능하고 각각의 수에 +를 한 값을 결과로 도출하게 됩니다.

하지만 아래의 코드를 보시면 컴파일 에러가 발생합니다.

public class Main {

    public static void main(String[] args) {
        List<Integer> intList = new ArrayList<>();
        for (int i = 1; i <= 5; i++) {
            intList.add(i);
        }
        List<Double> doubleList = new ArrayList<>();
        for (int i = 1; i <= 5; i++) {
            doubleList.add((double) i);
        }

        wildcardPrint(intList);
        wildcardPrint(doubleList);
    }


    static void wildcardPrint(List<?> list) {
        for (Object i : list) {
            System.out.println(i + 2);
        }
    }
}

이러한 문제가 발생하는 이유는 List<?>와 같은 파라미터로 List를 받으면 내부에 있는 값을 Object로 다루기 때문입니다.

그럼 우리는 +, - 와 같은 Number class가 가진 기능들을 모두 포기해야 하는 것일까요?

그렇지 않습니다. 아래와 같은 코드를 통해서 우리는 Number class가 가진 기능을 사용할 수 있습니다.

public class Main {

    public static void main(String[] args) {
        List<Integer> intList = new ArrayList<>();
        for (int i = 1; i <= 5; i++) {
            intList.add(i);
        }
        List<Double> doubleList = new ArrayList<>();
        for (int i = 1; i <= 5; i++) {
            doubleList.add((double) i);
        }

        wildcardPrint(intList);
        wildcardPrint(doubleList);
    }


    static void wildcardPrint(List<? extends Number> list) {
        for (Number i : list) {
            System.out.println(i.intValue() + 2);
        }
    }
}

위의 코드는 List<? extends Number> 타입의 list를 사용하여 + 연산을 진행하였습니다. intValue()라는 메서드를 사용한 이유는 doubleList와 같은 내부에 Double 값을 가지는 List가 파라미터로 주어지는 경우 int 값을 더하기 쉽지 않아 그냥 모두 int 값으로 변경하여 덧셈을 진행하였습니다.

위의 실행 결과는 아래와 같습니다.

3
4
5
6
7
3
4
5
6
7

각각 1~5의 숫자에 2가 더해진 값이 출력되는 모습입니다.

위와 같이 wildcard를 쓸 때 extends를 사용하여 상한선을 정해주는 방식을 upper bound wildcard라고 합니다.

위의 예제에서는 Number라는 class로 상한선을 정해주었습니다.

이와 같은 코드를 작성하면 Number class를 상속한 타입을 요소로 갖는 List들은 모두 파라미터로 들어올 수 있고, 파라미터로 들어온 List의 요소들은 모두 Number class를 상속하고 있기 때문에 Number class가 가지는 메서드들을 사용할 수 있습니다.


이와 반대로 super 키워드를 사용하여 하한선을 정해주는 방식도 있는데 이는 lower bound wildcard라고 부릅니다.

이와 같이 lower bound wildcard도 아래와 같이 사용할 수는 있습니다.

public class Main {

    public static void main(String[] args) {
        List<Object> objectList = new ArrayList<>();
        for (int i = 1; i <= 5; i++) {
            objectList.add(i);
        }
    }


    static void wildcardPrint(List<? super Number> list) {
        for (Object i : list) {
            System.out.println(i);
        }
    }
}

하지만 해당 코드는 Object class에 있는 기능만을 사용할 수 있기 때문에 굳이 super를 사용해야 하나? 라는 생각이 듭니다.

lower bound wild card는 해당 Collection에 값을 넣을 때 그 진가를 발휘합니다.

public class Main {

    public static void main(String[] args) {
        List<Number> numberList = new ArrayList<>();
        wildcardAdd(numberList);

        for (Number number : numberList) {
            System.out.println(number);
        }
    }


    static void wildcardAdd(List<? super Integer> list) {
        
        for (int i = 1; i <= 5; i++) {
            list.add(i);
        }
    }
}

 

1
2
3
4
5

위의 코드를 보시면 기존에 extends 키워드를 사용하였을 때는 해당 Collection에서 값을 꺼내서 사용했었는데 super 키워드를 사용하면 해당 Collection에 값을 넣는 용도로 사용하는 것을 보실 수 있습니다.

위와 같은 코드가 가능한 이유는 lower bound 즉 하한선을 Integer로 정해서 파라미터로 넘어온 list 안에는 Integer class이거나 Integer class의 부모 클래스의 값들이 있기 때문입니다.

위와 같이 부모 클래스 = 자식 클래스와 같이 할당이 가능한 것을 upcasting이라고 합니다.

아래와 같이 List<? super Integer>를 했어도 List<Integer>를 넣을 수 있는 것을 보실 수 있습니다.

public class Main {

    public static void main(String[] args) {
        List<Integer> numberList = new ArrayList<>();
        wildcardAdd(numberList);

        for (Number number : numberList) {
            System.out.println(number);
        }
    }


    static void wildcardAdd(List<? super Integer> list) {

        for (int i = 1; i <= 5; i++) {
            list.add(i);
        }
    }
}

위의 개념들을 이해하셨다면 PECS를 쉽게 이해하실 수 있습니다.

위에서 알아보았던 extends 키워드를 사용하는 경우 Collection을 producer로 사용하고 super 키워드를 사용하면 Collection을 consumer 즉 값을 먹는 용도로 사용하겠다는 것입니다.

public class Main {

    public static void main(String[] args) {
        
        List<Integer> l = new ArrayList<>();
        for (int i = 1; i < 5; i++) {
            l.add(i);
        }

        produce(l);
        consume(l);

        for (Integer i : l) {
            System.out.println(i);
        }
    }

    static void produce(List<? extends Integer> list) {
        for (Integer number : list) {
            System.out.println(number.floatValue());
        }
    }

    static List<? super Integer> consume(List<? super Integer> list) {
        list.add(5);
        return list;
    }
}

 

1.0
2.0
3.0
4.0
1
2
3
4
5

위의 코드에서 produce 메서드에서 list는 producer로, comsume 메소드에서 list는 consumer로 사용되었습니다.

위의 코드에서 아래와 같이 produce 메서드를 바꾸면 컴파일 에러가 발생합니다.

static void produce(List<? extends Integer> list) {
    for (Integer number : list) {
        System.out.println(number.floatValue());
    }
    list.add(5);
}

list 안에 있는 요소들을 Integer를 상속한 타입이기 때문에 Integer의 기능을 사용할 수 있지만 Integer를 상속한 class 중에서 정확히 어떤 타입인지 알 수 없기 때문에 upcasting이 어디서부터 가능한지 알 수 없기 때문입니다.

반대로 consume 메소드를 아래와 같이 수정해도 컴파일 에러가 발생합니다.

static List<? super Integer> consume(List<? super Integer> list) {
    list.add(5);

    for (Integer number : list) {
        System.out.println(number.floatValue());
    }
    return list;
}

consume가 파라미터로 받는 list는 Integer나 Integer의 부모 클래스를 요소로 갖는 List들이 될 수 있습니다.

따라서 Integer나 Integer를 상속받는 클래스들이 이 요소로 upcasting을 할 수는 있지만 list가 정확히 어떤 타입을 가지고 있는지 알 수 없습니다. 최악의 경우 Object 타입의 요소를 가지고 있을 수 있기 때문에 Object가 기본으로 가지는 메소드 외에는 다른 클래스의 메소드를 사용할 수 없습니다.


위와 같이 메서드에서 입력을 받는 List의 범위를 넓히면서 역할을 설정할 수 있다는 것이 PECS의 장점이라고 생각합니다.

반응형

'CS > java' 카테고리의 다른 글

java에서의 static의 의미와 사용법  (0) 2024.05.18

댓글