Java

토비의 봄을 담은 제네릭 1편

Jordy-torvalds 2020. 3. 26. 01:46
반응형

작성 계기

자바 공부 5년차 자알못이 분수를 깨닫고 부족한 개념을 쌓기 위한 첫 걸음으로, Java 5에 처음 등장해 활약 중인 제네릭에 대하여 토비 님의 강의를 듣고 정리해보았다. 이펙티브 자바나 구글링을 통해서도 이해하기 힘들었던 제네릭이 완전히 이해되었다. 이 글을 쓰며 내 머릿 속을 다시 한 번 정리하고, 기록하고자 한다.


제네릭

제네릭이란?

클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법이다. 아래 예제를 보자.

public class GenericEx1 {
    public static void main(String[] args) {
        Jordy <String> stringJordy = new Jordy <String>();
        stringJordy.setT("죠르디");
        System.out.println(stringJordy.getT() + " " + stringJordy.getT().getClass());

                Jordy <Integer> integerJordy = new Jordy <Integer>();
        integerJordy.setT(1234);
        System.out.println(integerJordy.getT() + " " + integerJordy.getT().getClass());

    }
}

class Jordy <T> {
    T t;

    public void setT(T t){
        this.t = t;
    }

    public T getT() {
        return t;
    }
}

-

// console
죠르디 class java.lang.String
1234 class java.lang.Integer

위 예제를 보면 같은 T 변수에 문자열과 정수를 할당하고, getT() 로 이를 다시 반환했다. 어떻게 한 변수가 문자열과 숫자라는 다른 변수 형태를 가질 수 있는 걸까?

.

앞서 말했듯이 Jordy 클래스 기준에서 외부에 해당하는 메인 메서드에서 Jordy 클래스 내부에서 쓸 타입을 지정해줬기 때문이다. 메인 메서드 상에서 객체 선언 부를 보면 다이아몬드 기호<> 사이에 String 과 Integer를 넣어줌으로써 Jordy 클래스 내부에서 쓸 타입을 지정해줬다.

Jordy <String> stringJordy = new Jordy <String>();

Jordy <Integer> integerJordy = new Jordy <Integer>();

.

그래서 첫번째 set, get에서는 문자열을 넣어주고 뺄 수 있었고, 두번째 set, get에서는 정수를 넣고 뺄 수 있었던 것이다.

.

객체 선언시 다이아몬드 기호 사이에 클래스 내부에서 쓸 타입을 넣어주는 것을 타입 아규먼트(Type Argument)라고 하고, class jordy 옆에 다이아몬드 기호 사이에 알파벳을 넣어주는 것을 타입 파라미터(type parameter)라고 부른다.


한정적 타입 파라미터 (Bounded Type Parameter)

가장 기본적인 제네릭을 알게되어서 막상 써먹으려 하니 타입 파라미터로 Object부터 리스트, 셋, 맵, 스택 등 수 많은 객체가 들어올 수 있을 것 같다. 그래서 개발자의 목적에 따라 타입을 한정하고 싶으면 어떻게 하면 될까? 아래 class jordy 옆 다이아몬드 기호를 중점으로 해서 보자.

public class GenericEx1 {
    public static void main(String[] args) {
        Jordy <String> stringJordy = new Jordy <String>();
        stringJordy.setT("죠르디");
        System.out.println(stringJordy.getT() + " " + stringJordy.getT().getClass());

        Jordy <Integer> integerJordy = new Jordy <Integer>();
        integerJordy.setT(1234);
        System.out.println(integerJordy.getT() + " " + integerJordy.getT().getClass());

    }
}

class Jordy <T extends Number> {
    T t;

    public void setT(T t){
        this.t = t;
    }

    public T getT() {
        return t;
    }
}

.

// console
Error:(5, 16) java: type argument java.lang.String is not within bounds of type-variable T
Error:(5, 49) java: type argument java.lang.String is not within bounds of type-variable T

콘솔을 보면 타입 아규먼트 String은 타압 변수 T의 범위 내에 있지 않다는 에러를 2줄 출력하고 있다.

.

이러한 에러가 출력되는 이유는 class Jordy 가 Number 클래스를 확장한 객체로 T를 제한하고 있기 때문이다.

.

그래서 String을 타입 아규먼트로 준 부분에 에러가 나오고, Number 클래스를 상속한 Integer에는 에러가 발생되지 않는다.


복수 타입 파라미터 (Type Parameters)

타입 파라미터를 여러 개 부여할 수 있을까? 물논.

public class GenericEx1 {
    public static void main(String[] args) {
        Jordy <Integer, String> stringJordy = new Jordy <Integer, String>();
        stringJordy.setT(1234);
        stringJordy.setS("죠르디");
        System.out.println(stringJordy.getT() + " " + stringJordy.getT().getClass());
        System.out.println(stringJordy.getS() + " " + stringJordy.getS().getClass());
    }
}

class Jordy <T extends Number, S extends String> {
    T t;
    S s;

    public void setT(T t){
        this.t = t;
    }

    public T getT() {
        return t;
    }
    public void setS(S s){
        this.s = s;
    }

    public S getS() {
        return s;
    }
}

추가적으로 String을 확장한 클래스로 한정한 타입 파라미터를 선언해줌으로써 죠르디와 1234를 모두 받을 수 있게 되었다.


타입 추론 (Type Inference) & 메소드 타입 파라미터 (Method Type Parameter)

인공지능이랑 바둑을 두는 시대에 일일히 타입 아규먼트를 줘야할까? 알아서 JVM이 타입을 판단할 수는 없는걸까?

.

물논 가능하다.

public class GenericEx1 {

    static <T> void method(T t, List<T> list) {
    }

    public static void main(String[] args) {
        method(1, Arrays.asList(1,2,3,4,5));
    }
}

method 의 메소드 시그니처를 보면 리턴 타입 바로 앞에 타입 파라미터를 T라고 씀으로써 해당 메소드 내에서 T를 파라미터로 쓸 수 있게 되고, 그래서 파라미터로 T와 List를 받을 수 있게 된다.

.

특이한 게 메인 메소드 내에 method 를 보면 타입 아규먼트를 주고 있지 않고 파라미터로 1과 1에서 5까지의 숫자를 리스트로 바꿔주는 정적 팩토리만 넣어줬다. 이전 예제와 다르게 타입 아규먼트를 넣어주지 않아 문제가 있을것 같아보이는데 컴파일을 해주면 정상적으로 컴파일이 된다.

.

파라미터로 넣어주는 값들을 바탕으로 타입을 추론해주기 때문이다.

.

또 다른 예제를 보자.

public class GenericEx1 {
    public static void main(String[] args) {
        List<String> c = Collections.emptyList();
    }
}

//

public class Collections {
...
public static final <T> List<T> emptyList() {
...
}

위 예제를 보면 Collections의 정적 메소드인 emptyList에 별도의 타입 아규먼트를 주지 않고 있다. 이 또한 객체 선언에 String이란 타입 아규먼트를 바탕으로 emptyList로 반환될 객체의 타입 아규먼트를 추론하고 있다.

.

타입 추론과 관련해 마지막 예제를 보자.

public class GenericEx1 {
    public static void main(String[] args) {
        List<String> c = new ArrayList<>();
    }
}

아니.. ArrayList에 타입 아규먼트를 주지 않다니. 틀림없이 틀린 문법이다 라고 생각이 들수도 있겠다.

하지만 그렇지 않다. 먼저 ArrayList에 다이아몬드 기호에 별도의 타입 아규먼트를 주지 않는 경우를 다이아몬드 연산자라고 하며, 다이아몬드 연산자를 쓰게 되면 객체 선언에 부여된 타입 아규먼트인 String를 대신해서 쓰게 된다.

모르면 당황 스럽지만, 알면 한결 코드가 간결해지는 것이다. 다이아몬드 연산자 또한 타입 추론의 일종이다.


타입 힌트 (Type Witness)

앞서 나온 타입 추론과 다르게 좀 더 명시적이고, 분명히 표현하고 싶으면 어떻게 하면 될까?

public class GenericEx1 {

    static <T> void method(T t, List<T> list) {
    }

    public static void main(String[] args) {
        GenericEx1.<Integer> method (1, Arrays.asList(1,2,3,4,5));
    }
}

위 예제와 같이 다이아몬드 기호에 사용할 타입 아규먼트를 넣어주면 된다. 이를 타입 힌트 혹은 타입 위트니스 라고 부른다.

명시적이여서 가독성 또한 좋다.


와일드카드 타입(Wildcard Type)

만약에 구현될 코드의 타입이 어떤 것이 오든지 정말 전혀 상관이 없을 때는 어떻게 하면될까? 그때는 와일드카드 타입을 부여하면 된다. 와일드카드 타입은 타입 파라미터 자리에 ? 를 주는 것을 말한다.

public class GenericEx1 {
    static <T> void objectTypeArg (List <Object> list) {
        list.forEach(o -> System.out.println(o));
    }

    static <T> void wildcardTypeArg (List <?> list) {
        list.forEach(o -> System.out.println(o));
    }

    public static void main(String[] args) {
        List<Integer> argList = Arrays.asList(1,2,3,4,5);
        objectTypeArg(argList);
        wildcardTypeArg(argList);
    }
}
// console
Error:(19, 9) java: method objectTypeArg in class base.GenericEx1 cannot be applied to given types;
  required: java.util.List<java.lang.Object>
  found: java.util.List<java.lang.Integer>
  reason: cannot infer type-variable(s) T
    (argument mismatch; java.util.List<java.lang.Integer> cannot be converted to java.util.List<java.lang.Object>)

콘솔 창의 에러 내용을 보면 objectTypeArg()는 Object 형을 원한 반면에 우리는 Interger를 넣어주고 있다. 모든 클래스의 부모인 Object에 자식인 Integer가 안되다니, 왜 그런걸까?

.

그 이유는, Object와 Integer가 상속 관계인 반면 List

와 List는 상속 관계가 아니기 때문이다. 이러한 규칙은 리스트 외에도 셋, 맵, 큐, 스택 등 모든 클래스가 적용된다.

.

반면에 와일드카드 타입인 wildcardTypeArg() 는 정상적으로 작동한다. 말 그대로 어떤 타입이 오더라도 상관이 없기 때문이다.

.

여기서 의문이 생길 것이다. 그러면 타입 파라미터와 와일드카드 타입은 어떤 차이가 있는 것일까?

둘의 차이점은 아래와 같다.

.

  • 타입 파라미터는 해당 타입 파리미터와 관련이 있는 처리를 할 때 사용한다.
    • 리스트 기준 새로운 값 추가, 저장된 값 읽기 등
  • 와일드카드 타입은 메소드 내부에서 타입의 종류와 전혀 관련성이 없는 처리를 할 때 사용한다.
    • 리스트 기준 null 값 추가, size 조회, 리스트 클리어, Object 메서드(equals, hashcode 등)

위 내용은 자바 랭기지 스펙에 나오는 내용이다. 타입 파라미터가 적합한 로직에 와일드카드 타입을 넣어서 구현했을 때 기능이 동일하게 작동할 수 있다. 하지만 그렇게 해서는 안되는 이유는, 랭기지 스펙을 기반으로 코드를 해석했을 때 오해가 생길 수 있기 때문이다.

.

아래 예제는 타입과 무관한 메소드에 와일드카드 타입의 리스트를 파라미터로 하여 로직을 구현한 예제이다.

public class GenericEx1 {
    static boolean isEmpty (List<?> list) {
        return list.size() == 0 ;
    }

    static long frequency (List<?> list, Object item) {
        long result = list.stream().filter(s -> s.equals(item)).count();

        return result;
    }

    public static void main(String[] args) {
        List<Number> argList = Arrays.asList(1, 2.5, 3.2, 4.1, 5, 3, 3, 4.2, 7, 2.5);
        System.out.println(frequency(argList, 2.5));
    }
}

위 코드는 리스트의 타입 파라미터의 종류와 상관 없이, item의 값과 같은 변수의 갯수를 세어주는 frequency()와 리스트가 비었는지 여부를 확인해주는 isEmpty()를 구현해서 모두 와일드카드 타입을 부여했다.


타입 아규먼트를 가진 인터페이스를 구현한 클래스

다른 제목과 다르게 유난히 긴 제목이 등장했다. 타입 아규먼트를 가진 인터페이스를 구현하는 경우는 어떤게 있을까?

대표적인 것이 바로 값 정렬을 해주는 함수형 인터페이스인 Comparable이 있다.

public class GenericEx1 {

    public static <T extends Comparable<? super T>> T max (List<? extends T> list) {
        return list.stream().reduce((a,b) -> a.compareTo(b) > 0 ? a : b).get();
    }

    public static void main(String[] args) {
        List<Integer> argList = Arrays.asList(2, 2, 2 ,2, 3);
        System.out.println(max(argList));
    }
}

위 코드는 Integer 란 특정 타입의 객체 내에 값중 가장 큰 값을 뽑아내는 코드다.

참고로 reduce 연산은 말 그대로 감소 연산으로 a와 b를 비교해서 0 을 초과하면 a가 더 큰 값이므로 a를 다음 연산에서 활용하고, 0 이하면 b를 다음 연산에서 활용한다.

.

위 코드를 보면 파라미터에는 ? extends T 를 리턴에는 ? super T 가 사용된 것을 확인할 수 있다. 이는 PECS(Produce-Extends-Consumer-Super)를 준수한 것이다.

펙스란 단어로는 이해가 어려으므로 아래 내용을 참고하자.

  • 내부에서 처리 되기 위해 쓰이면 extends
  • 외부에서 처리 되기 위해 쓰이면 super

헬퍼 메서드

앞서 말한 것처럼 와일드카드 타입은 타입의 종류와 무관한 로직을 구현할 때 사용된다. 그런데, 특정 타입을 사용하지는 않지만, 타입 확인이 필요할 때는 어떻게 하면 좋을까? 우선 문제의 코드를 보자.

public class GenericEx1 {

    public static void main(String[] args) {
        List<Integer> argList = Arrays.asList(2, 2, 2 ,2, 3);
        reverse(argList);
    }

    private static void reverse(List<?> argList) {
        List <?> temp = new ArrayList<>(argList);
        for (int i = 0 ; i < argList.size() ; i ++)
            argList.set(i, temp.get(temp.size()-i-1));
    }
}

.

// console
Error:(18, 36) java: incompatible types: java.lang.Object cannot be converted to capture#1 of ?

위 코드가 오류가 발생하는 이유는 분명 와일드카드 타입을 통해 타입의 종류는 상관없다고 밝혔던 것과 달리 타입과 관련성 있는 작업인 set을 해주기 때문에 오류가 발생한 것이다.

그래서 와일드카드를 캡처할 수 있도록 만든 헬퍼 메소드를 구현해야한다.

public class GenericEx1 {

    public static void main(String[] args) {
        List<Integer> argList = Arrays.asList(21, 12, 62 ,22, 113);
        reverse(argList);
    }

    private static void reverse(List<?> argList) {
        System.out.println(reverseHelper(argList));
    }

    private static <T> List<T> reverseHelper(List<T> argList) {
        List <T> temp = new ArrayList<>(argList);
        for (int i = 0 ; i < argList.size() ; i ++)
            argList.set(i, temp.get(temp.size()-i-1));

        return argList;
    }
}

헬퍼 메소드 구현은 간단한데, 문제가 있던 로직을 별도의 메소드로 분리한 후에 와일드카드 타입을 타입 파라미터로 변경해주면 된다.

캡처

타입을 추론하는 것을 말한다.

와일드카드 타입의 경우 타입의 종류가 상관없지만, argList.set을 할 때는 그 리스트의 타입에 맞게 넣어줘야해서 타입의 추론이 필요하기 때문에 캡처 메세지가 출력된 것이다.

해결 방법은 앞서 설명한 헬퍼 메서드를 사용하는 방법과 타입을 주지 않는 RAW 타입으로 변경하는 방법이 있다. RAW 타입 변경의 경우 타입 안전성 경고가 출력되게 되는데, 그 안전성을 확신할 수 있는 상황 있을 때는 SuppressWarning 어노테이션로 타입 안전성 보장 표시를 해주면 그 경고가 사라진다.


지금까지 제네릭에 대하여 알아보았다.

제네릭이 헷갈렸던 분들은 이 글을 통해 정리가 되는 계기가 되었으면 한다.

반응형