Java Generic을 이해해보자

허준현·2023년 9월 7일
0

JAVA

목록 보기
4/5
post-thumbnail

이번에 프로젝트에서 배치 프로그램을 작성하는 중에 SimpleStepBuilder 멤버 변수를 확인하는 도중에 제너릭이 있는 것을 확인하였으나 기존에 알고 있던 부분과 많이 달라 이번에 제대로 공부하고자 한다.
만일 아래의 예시 중 Generic 부분에 어떤 타입 경계가 들어가는지 바로 알 수 있다면 해당 Generic 시리즈를 이해하고 있다고 볼 수 있다.
해당 정답은 이후의 포스트 예시로 이해해보자에서 공개할 예정이다.

// 컬렉션 최대값 구하는 메소드 
public <T Generic Comparable<? Generic T>> T max(Collection<? Generic T> col)

// SimpleSteupBuilder 에서 사용하는 멤버 변수 
private ItemReader<? Generic I> reader;
private ItemWriter<? Generic O> writer;
private ItemProcessor<? Generic I, ? Generic O> processor;
private Function<? Generic I, ? Generic O> itemProcessorFunction;

// FunctionalInterface Function의 메소드 compose
public interface Function<T, R> {
	default <V> Function<V, R> compose(Function<? Generic V, ? Generic T> before) {
        Objects.requireNonNull(before);
        return (V v) -> apply(before.apply(v));
    }
}

제너릭의 정의

주로 접하는 제너릭의 정의는 무엇인가? 쉽게 말하면 클래스 내부에서 사용할 데이터의 타입을 외부에서 지정하는 기법을 말한다. 추가적으로 다양한 타입의 객체들을 다루는 메소드나 컬렉션 클래스에 컴파일 시에 캐스팅 코드를 넣어주어 타입체크를 하는 기능을 가지고 있다.

또 한 제너릭을 사용할 때는 primitive 타입에는 사용이 불가능하다. 따라서 int, double 과 같은 자료형은 Wrapper class 인 Integer, Double 를 사용해야 한다.

제너릭이 필요한 경우

List list = new ArrayList();
list.add(1);
list.add("1");
list.add(new double[]{1.2, 12.4});

int a  = (int)list.get(0);

String b  = (String)list.get(2);//런타임 에러

위와 같은 코드에서 알 수 있듯이 명시적 형변환은 확신이 있는 경우에만 사용하는 것이 좋다. 확실하지 않은 경우에는 런타임 에러가 나기 때문이다.

List와 Array의 차이점

먼저 자바에서 주로 담을 객체를 사용할 때 Array를 사용하거나 List를 사용하곤 한다. 그 중에서 주로 ArrayList를 사용하는데 타입 안정성을 제공하기 위해 제너릭을 사용한다. 도대체 타입 안정성이라는 것은 무엇일까?

Integer[] integerArray = new Integer[10];
Number[] numberArray = new Number[10];
numberArray[0]= 1.4;  //Runtime Error!

위의 코드에서 알 수 있듯이 배열의 경우에는 런탕미에 실제로 들어있는 데이터의 타입을 알게 되기 때문에 Runtime Error가 발생하기 쉽다. 따라서 이를 방지하기 위해서 Collection들은 컴파일 시간에 제너릭 타입을 통해서 타입을 체크하게 된다.

 List<Integer> integers = new ArrayList<Integer>();
 integers.add(1);
 integers.add(2);
 List<Number> myNums = integers; // Complie Error!

하지만 제너릭은 배열과 다르게 업케스팅, 다운케스팅을 지원하지 않는다. 이를 무공변이라 하며 무공변, 공변에 대해서 알아보도록 하자.

심화과정 비검사 경고(@SuppressWarnings("unchecked"))

// 비검사 경고 발생하는 코드 
List<food> foodList = new ArrayList();
// 개선 코드
List<food> foodList = new ArrayList<food>(); 
// -> 자바7 부터는 다이아몬드 연산자만표현하면 자동 추론가능하다.
List<food> foodList = new ArrayList<>();

public <T> T[] toArray(T[] a){
	@SuppressWarnings("unchecked")
	T[] result = (T[]) Arrays.copyOf(param,size,param.getClass());
}

위와 같은 코드는 warning이 떠서 개발자에게 경고를 알려준다.
이와 같은 warning 경고를 제거할 수 없는 상황이지만 타입이 안전하다고 확신이 드는 경우에는 변수 선언, 메소에 선언가능하며 짧은 범위에 선언하는 것이 좋다.
위와 같은 경우에는 result 변수 선언 부분에 해당 변수는 타입이 안잔하다는 것을 컴파일러에게 알리는 것이다.

Type Erasure

앞에서 살펴 보았듯이 자바에서는 제너릭 클래스를 인스턴스화 할때 해당 타입을 지워버린다. 따라서 그 타입은 컴파일시에만 존재하고 컴파일 이후에는 타입을 알 수 없게 된다.
그러면 한 가지 의문점이 들 수 있다.
물론 컴파일시에 타입을 확인 하였으므로 해당 메소드나 클래스가 타입에 대해서 안전하지만 굳이 런타임에는 타입이 없어야 할까?

다른 블로그에서 찾아보면 하위 버전과 호환성을 맞추기 위해서 라는 말을 자주 사용하는데 아래와 같은 case로 인해 상위 버전과 맞추기 위해서 부가적인 코드가 들어가는 것을 방지하기 위함이며 로타입에 대한 지원과 코드의 호환성을 제공한다.

//someTingFunction 의 반환값은 List<Bar>이다.
List<Foo> somethingFooList = readSometingList(p);

for(Foo foo : somethingFooList){
		// CastingError
}

이처럼 List <Bar>를 기대했지만 런타임시에 타입이 지워지지 않아 캐스팅 오류가 발생하게 되는 것이며 부모자식 관계이더라도 부가적인 캐스팅 코드가 필요하게 된다.

따라서 제너릭 파라미터를 받는 메소드는 오버로딩이 불가능하다. 파라미터에 따라서 메소드를 구분하게 되는데 런타임 시에 해당 파라미터가 지워서 구분할 수 없기 때문이다.

추가적으로 stackoverflow를 찾아보다가 좋은 글이 있어 공유하고자 한다.

해당 글에서는 이전 버전과의 호환성 및 런타임 성능에 더불어 논리적인 이점에 대해서 설명하고 있다.

공변성(covariant)

부모 클래스의 타입을 자식 클래스의 타입으로 대체 할 수 있는 성질을 말한다.
예시로는 배열과 , 메서드 반환 타입이 있다. 배열은 위에서 알아보았고 메서드 반환 타입에 대해서 알아보자.

class Super{}
class Sub extends Super{}
class Parent {
	public Super function(){
    return new Sueper();

}

class Child extends Parent {
	@Override
    public Sub function(){
    return new Sub();
    }
}

이 처럼 오버라이드의 경우에 반환값에 공변성이 적용된 것을 확인할 수 있다.
이런 공변성을 제너릭에서 정의하기 위해서는 extends 키워드를 사용해야 한다.

public Number sum(List<? extends Number> numbers) {
    long sum = 0;
    for (Number number : numbers) {
        sum += number.longValue();
    }
    return sum;
}


List<Integer> ints = List.of(1,2,3);
List<Long> longs = List.of(1L, 2L, 3L);
List<Double> Doubles = List.of(1.0, 2.0, 3.0);
System.out.println(sum(ints)); // 정상
System.out.println(sum(longs)); // 정상
System.out.println(sum(Doubles)); // 정상

위의 extends 상위 경계는 다음과 같이 해석 될 수 있다.

내가 지금 어떤 객체들을 담고 있는지는 모르겠어. 다만 내가 가지고 있는 객체는 Number이거나 하위 객체들이야.

그러면 해당 하위 객체들을 numbers 에 넣을수 있을까?
다른 예시로는 엄마의 상속 관계로 아들과 딸이 있다고 가정하고 List<Mother> 안에 son, daughter 를 넣을 순 없을 것이다. 이는 안의 객체들을 꺼내서 읽으려고 할 때 어떤 객체인지 모르기 때문이다.
따라서 extends 상위 경계 객체에는 읽기는 가능하지만 쓰기는 불가능하여 런타임 중에 에러가 발생하는 것을 막는다. 이후 예시에서 자세히 알아보도록 하자.

반공변성(Contravariance)

반공변성은 공변의 반대로 자식 클래스의 타입을 부모 클래스의 타입으로 대체할 수 있는 성질을 말하며 읽기가 아닌 쓰기 기능을 제공하고 있다. 어떻게 하면 쓰기 기능을 제공할 수 있는 걸까?

public void addNumber(List<? super Integer> numbers) {
    numbers.add(6);
    // numbers.get(0); 컴파일 에러
}

List<Number> myInts = new ArrayList<>();
addNumber(myInts);

필자는 처음에 아 하위경계이니까 상위 객체 값을 리스트 안에 넣을 수 있구나! 라고 생각하였다. 하지만 넣을 수 있는 건 예시의 Integer의 하위 관계인 객체만 넣을 수 있다. 쉽게 풀어서 설명하면

내가 어떤 객체를 가지고 있는지 정확히는 모르겠지만 가지고 있는 객체는 Integer 이거나 혹은 상위 객체를 가지고 있어.

따라서 해당 제너릭 파라미터로 받을 수 있는 건 List<Object> , List<Number> 등이 넘어 올 수 있는데 add(Object) 를 넣게 된다면 해당 제너릭 객체는 더이상 List<Number> 상위 객체를 표현 할 수 없게 되는 것이다. 따라서 Integer로 캐스팅 될 수 있는 하위 관계의 객체들만 쓰기 기능을 사용할 수 있다.
물론 add와 같은 메소드로 넣는 것이 아닌 = new ArrayList<Object>(); 처럼 다른 객체를 바라보게 하는 것은 가능하다.

무공변성(Invariance)

무공변성은 부모 타입과 자식 타입간의 변환을 제한하는 성질을 말한다. 이는 우리가 주로 컬렉션에서 사용하는 제너릭의 성질을 말하기도 한다.

PECS : producer-extends, consumer-super

Collections을 사용할 때 어떤 경계를 사용할지 난해 할때 생각할 때 일련의 법칙을 정해두었다. 즉 값을 생성하는 역할은 extends, 값을 소비하는 역할이면 super를 사용한다. 한동안 제너릭을 공부하면서 제일 이해하기 쉬웠던 stackoverflow 답변이 기억에 남는다.

tl;dr: "PECS" is from the collection's point of view. If you are only pulling items from a generic collection, it is a producer and you should use extends; if you are only stuffing items in, it is a consumer and you should use super. If you do both with the same collection, you shouldn't use either extends or super.

즉 PECS 는 컬렉션의 관점에서 어떻게 사용할지를 나타내는 것이며 다음과 같이 사용한다.

메서드가 파라메터로 제네릭을 적용한 컨테이너를 받고, 메서드 안에서 해당 컨테이너가 생산하는 작업을 하는 경우 ? extends T 타입 파라메터를 사용한다.
메서드가 파라메터로 제네릭을 적용한 컨테이너를 받고, 메서드안에서 해당 컨테이너가 소비하는 작업을 하는 경우 ? super T 타입 파라메터를 사용한다.

심화과정: 언제 제너릭을 사용할까

공변, 반공변을 처리하는 상황을 제외하고 타입 파라미터 T만 사용하는 경우와 와일드카드 ? 만을 사용하는 부분이 애매할 것이다.
이펙티브 자바에서는 헬퍼 메소드를 사용해서 메소드를 정의하는 것을 추천하고 있다.

public static void swap(List<?> list, int i, int j) {
		swapHelper(list, i, j);
}
//와일드카드 타입을 실제 타입으로 바꿔주는 private 도우미 클래스
private static <E> void swapHelper(List<E> list, int i, int j){
		list.set(i, list.set(j, list.get(i)));
}

해당 코드를 보면 바로 제너릭을 사용하지 왜 Helper 메소드를 사용해야 하는가 의문이 들 수 있다.
만일 제너릭을 사용하면 해당 정의된 제너릭을 사용한다는 의미를 담고 있으며 외부에서 사용자가 정의된 제너릭으로 선언되기 때문에 사용자는 해당 제너릭을 신경쓸 수 밖에 없다. 따라서 와일드 카드와 헬퍼 메소드를 사용해서 T에 대해 관심을 가지지 않아도 되며 사용자 입장에서도 의도를 파악하기가 쉬워진다.
참고 : 제너릭

제너릭을 사용할 수 없는 경우

List를 선언할 시에 제너릭 T를 사용했는데 배열을 생성하는 부분에서도 T를 사용할 수 있을까? ex) new T[5];
먼저 new 연산자는 heap 영역에 충분한 공간이 있는지 확인한 후에 메모리를 확보하는 역할을 한다. 충분한 공간을 판단하는 여부에 타입을 알아야 하는데 컴파일 시점에 배열의 경우 타입 T가 무엇인지 알 수 없기 떄문이다.
동일하게 static 변수는 인스턴스에 종속되지 않는 클래스 변수이며 공통된 저장공간을 공유하는 변수인데 생성되는 인스턴스의 제너릭에 따라서 타입이 변할 수 없기 때문이다.

static 메소드는 가능한 이유

먼저 static 메소드가 있는 클래스에 제너릭 T가 존재해도 메소드 제너릭 선언 부분에 <T> 를 선언하면 클래스의 제너릭 타입을 영향받지 않게 된다. 따라서 static 메소드의 경우에는 메소드의 틀만 공유하는 형태로 존재하기 때문에 가능하다.

Heap Pollution

힙 오염이라고 하며 주로 매개변수화 된 타입의 변수가 타입이 다른 객체를 참조할 때 발생한다.

public static void main(String[] args) {
    List<Integer> list = (ArrayList<Integer>)Arrays.asList(1,2,3);
    Object obj = list;
    List<String> strList = (List<String>) obj;
    System.out.println("strList.get(1) = " + strList.get(1));
}

해당 코드를 intelij에 사용하게 되면 unchecked cast waring이 발생한다. 즉
컴파일 당시에는 에러가 발생하지 않지만 런타임에 ClassException이 발생하게 된다. 타입 케스팅 연산자는 컴파일러가 검사하지 않기 때문에 발생한다. 이를 방지하기 위해서는 Collections의 유틸리티 클래스의 checkedList 메소드를 사용해야 한다.

List<Integer> integers = Collections.checkedList(new ArrayList<>(), Integer.class);

심화과정 Reifiable 타입

앞에서 <?>는 컴파일 시에 Object 타입으로 변환 되는 것을 확인하였고 <? extends Object> 또한 컴파일 시에 Object로 변환될 것이다. 그러면 두 제너릭의 차이는 무엇일까?
정답은 Reifiable Type 인지 아닌지이다.
Reifiable은 구체화 가능 타입으로 해석 되는데 런 타입시에 오브젝트 정보를 표현 할 수 있는지 없는지를 나타낸다.

따라서 런타임에 타입이 지워지지 않은 타입으로 예시로는
1. int, long 과 같은 원시 타입
2. String 와 같은 제너릭 타입이 아닌 경우
3. List, Map와 같은 순수 Raw type
4. List<?> 와 같은 Unbounded Wildcard가 포함된 Parameterized Type

반대로 아래와 같은 경우에는 런타임 시에 타입이 지워진다.
1. List<? extends Number>, List<? super String> 와 같이 경계가 포함된 Parameterized Type
2. Generic Type( )
3. List<Number, ArrayList 와 같은 Parameterized Type

이처럼 런타임에 타입이 지워짐에 따라서 어떤 불편함을 겪게 될 것인가?

public static <T> boolean isIntegerList(T b){
        return b instanceof List<Integer>; //Compile 에러 발생
    }
public static <T> boolean isIntegerType(T b){
        return b instanceof Integer; //Compile 에러 발생하지 않음
    }

Integer 는 위에서 말한 원시타입이며 Reifiable 타입이므로 런타임 시에 비교를 할 수 있으나 List와 같은 경우에는 Reifiable하지 않으므로 컴파일 오류가 발생하며 대체제인 List 혹은 List<?> 로 변경해야 한다.

제너릭 부분을 마치고서

어느정도 제너릭에 대해서 이해를 한 당신을 위해서 몇가지 문제에 대해서 다음 포스트에서 알아보도록 하자.

참조

배열과 컬렉션 제너릭의차이
제너릭 helper 메소드 사용이유
PECS
제너릭을 사용할 수 없는 경우
Reifiable
<?> vs <? extends Obejct>

profile
best of best

0개의 댓글