본문 바로가기
프로그래밍/Java

[java] 배열(Array)과 컬렉션 제네릭의 차이

by 사바라다 2020. 11. 8.

자바의 객체 타입은 고정이 아닙니다. 객체 타입은 런타임에 자신이 상속받고 있는 타입으로 변하는 업 캐스팅(upcasting)과 자신 자신의 아래 클래스의 형태로 변할 수 있는 다운 캐스팅(downcasting)이 있습니다. 이를 자바에서는 객체가 다형성(Polymorphism)을 가지고 있다고 합니다. 상속 매커니즘과 더불어 이런 다형성은 객체의 재사용성을 높여주고 객체지향(OOP)를 잘 달성할 수 있게 도와줍니다.

그리고 ArrayList, HashMap과 같은 Collections을 자바 개발자는 자주 사용하게됩니다. 이때 타입의 안정성을 위해 제네릭(generic)을 사용합니다. 이 제네릭은 다양한 Type을 지원할 수 있도록 타입 파라미터(type paramter) T를 지원합니다.

오늘 여러분과 함께 알아볼 내용은 제네릭 타입의 다형성입니다.

배열

제네릭 타입에 대해서 이야기할때 항상 배열의 타입에 대한 문제점 이야기를 합니다.

배열은 제네릭에 비해 타입형변환이 자유로운 편입니다. 아래의 예제를 보시겠습니다. Number는 Integer 객체의 부모 객체입니다. 따라서 Integer 객체는 Number 객체로 업 캐스팅이 가능합니다. 배열또한 마찬가지로 가능합니다.

Integer integer = 5;
Number number = integer; // OK !!

Integer[] integerArray = new Integer[10];
Number[] numberArray = new Number[10];
objectArray = stringArray; // OK !!

여기까지는 이슈가 없습니다. 하지만 Number는 Integer 뿐만 아니라 Double 객체의 상위 객체이기도 합니다. 그렇다면 아래와 같이 변경하면 어떻게 될까요 ? 컴파일 타임에는 문제가 없습니다. 하지만 런타임에서 ArrayStoreException가 출력되게 됩니다. 왜냐하면 numberArray의 실질적인 데이터 타입은 integerArray이기 때문입니다. 배열은 이렇게 런타임에 실제로 들어있는 데이터의 타입을 알게됩니다.

numberArray[0] = 1.4; // Error

제네릭

위에서의 배열의 문제점을 해결하기 위해 Java의 Collection들은 컴파일 시간에 제네릭 타입을 통해 타입을 체크합니다. 이렇게 타입체크를 컴파일시 정확한 타입을 체크함으로써 런타임에 발생할 수 있는 오류를 미연에 방지합니다.

1. List<Integer> myInts = newArrayList<Integer>();
2. myInts.add(1);
3. myInts.add(2);
4. List<Number> myNums = myInts; // 컴파일 에러

제네릭은 Java 1.5 버전부터 등장하였습니다. 따라서 해당 이전 버전에서는 제네릭이 없기때문에 호환성의 문제가 생길 수 있습니다. 이 문제 때문에 제네릭은 런타임시에는 버려지게 됩니다. 이를 type erasure라고 부릅니다. 제네릭 타입이 버려지게 되기때문에 런타임에는 어떠한 타입도 들어올 수 있게됩니다. 하지만 컴파일 타임에서 이를 걸러내기 때문에 안전합니다. 업 캐스팅, 다운 캐스팅에 의해서 배열처럼 문제가 일어날 수 있지 않을까요? 그렇지 않습니다. 제네릭은 배열과 다르게 업캐스팅과 다운캐스팅이 불가능합니다. 이를 무공변(invariant)라고 합니다. 따라서 4번 라인처럼 상위 타입이라고 할지라도 캐스팅이 되지 않고 컴파일에러가 출력되는 것입니다.

좀 더 쉽게 이해하기 위해서 코드로 배열과 제네릭을 한번 보도록하겠습니다. 아래 코드는 Number 타입의 배열을 받아들여 합계를 반환하는 메서드입니다. 그리고 아래처럼 사용할 수 있습니다. 모두 타입이 업 캐스팅이 잘 일어나며 원하는 결과를 얻을 수 있습니다.

public Number sum(Number[] numbers) {
    long sum = 0;
    for (Number number : numbers) {
        sum += number.longValue();
    }
    return sum;
}

Integer[] myInts = {1,2,3,4,5};
Long[] myLongs = {1L, 2L, 3L, 4L, 5L};
Double[] myDoubles = {1.0, 2.0, 3.0, 4.0, 5.0};
System.out.println(sum(myInts)); // 정상 응답.
System.out.println(sum(myLongs)); // 정상 응답.
System.out.println(sum(myDoubles)); // 정상 응답.

이를 Collection과 Generic으로 동일하게 만든다면 아래와 같습니다. 바뀐 부분은 메서드의 파라미터가 List<Number>가 되었다는 것 뿐입니다. 하지만 결과는 아래의 결과 처럼 모두 컴파일 에러가 발생하게돕니다.

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

List<Integer> myInts = List.of(1,2,3,4,5);
List<Long> myLongs = List.of(1L, 2L, 3L, 4L, 5L);
List<Double> myDoubles = List.of(1.0, 2.0, 3.0, 4.0, 5.0);
System.out.println(sum(myInts)); // 컴파일 에러
System.out.println(sum(myLongs)); // 컴파일 에러
System.out.println(sum(myDoubles)); // 컴파일 에러

마무리

오늘은 이렇게 자바의 배열과 제네릭의 기본적인 차이점인 공변과 무공변에 대해서 알아보는 시간을 가져보았습니다.

다음 시간부터는 본격적으로 제네릭에 대해서 좀 더 자세히 알아보도록하며 제네릭에 공변성과 반공변성을 주는 방법을 알아보도록 하겠습니다.

감사합니다.

참조

happinessoncode_java-generic-and-variance-1

stackoverflow_covariance-invariance-and-contravariance-explained-in-plain-english

댓글