글을 쓰게 된 이유
Java 기초를 더 단단히 다지기 위해서 Java 관련 스터디를 진행하고 있습니다.
그래서 이번 주 주제인 Generic을 공부해 보도록 하겠습니다.
Generic은 왜 사용하는 것일까?
타입 안정성
타입 안정성을 보장한다는 것은 무슨 뜻일까요? Java는 많은 타입을 가지게 되는데, 이러한 타입들이 제대로 매칭되지 않으면 ClassCastException이 런타임 시점에 발생하게 됩니다.
아시다시피 제일 힘든 에러는 런타임 에러, 제일 착한 에러는 컴파일 에러라고 하는데 제네릭을 사용하면 ClassCastException을 컴파일 에러 때 잡을 수 있어서 "타입 안정성"을 보장한다고 할 수 있습니다.
먼저 제네릭 사용여부에 따른 예시 코드를 보고 왜 타입 안정성을 보장할 수 있는지 확인해보겠습니다.
no Generic
class Box {
private Object fruit;
public Object getFruit() {
return fruit;
}
public void setFruit(Object fruit) {
this.fruit = fruit;
}
@Override
public String toString() {
return "Box{" +
"fruit=" + fruit +
'}';
}
}
class Apple {
@Override
public String toString() {
return "apple";
}
}
class Banana {
@Override
public String toString() {
return "banana";
}
}
@Test
void noGeneric() throws Exception {
//given
Box appleBox = new Box();
Box bananaBox = new Box();
appleBox.setFruit(new Apple());
bananaBox.setFruit(new Banana());
//when
Apple apple = (Apple) appleBox.getFruit();
Banana banana = (Banana) bananaBox.getFruit();
//then
assertThat(apple).isInstanceOf(Apple.class);
assertThat(banana).isInstanceOf(Banana.class);
}
이번 코드는 제네릭을 사용하지 않을 때입니다. Box에서 Apple이나 Banana를 꺼낼 때, 행 변환이 필요하게 됩니다. 만약에 변수명을 appleBox나 bananaBox와 같이 의미를 가지지 않는 다른 변수명이었다면 appleBox와 bananaBox를 구별할 수 있을까요?
if (appleBox.getFruit() instanceof Apple) {
Apple apple = (Apple) appleBox.getFruit();
}
아마도 instanceof를 사용하여 일일이 확인해야 할 것입니다.
또한, 프로그래머가 실수로 객체를 넣지 않고 String을 넣어주면 런타임 시점에서 ClassCastException이 발생하게 됩니다,, 그러면 컴파일 시점에 발견하지 못하게 추적하기 어려운 런타임 에러가 발생하게 됩니다.
appleBox.setFruit("apple");
이러한 점들을 해결하고자 Java 5에서 제네릭이 등장하게 됐습니다.
Generic
Box를 이제 제네릭 클래스로 바꿔보겠습니다.
class Box<T> {
private T fruit;
public T getFruit() {
return fruit;
}
public void setFruit(T fruit) {
this.fruit = fruit;
}
@Override
public String toString() {
return "Box{" +
"fruit=" + fruit +
'}';
}
}
@Test
void noGeneric() throws Exception {
//given
Box<Apple> appleBox = new Box();
Box<Banana> bananaBox = new Box();
appleBox.setFruit(new Apple());
appleBox.setFruit("apple"); //compile error
bananaBox.setFruit(new Banana());
//when
Apple apple = appleBox.getFruit();
Banana banana = bananaBox.getFruit();
//then
assertThat(apple).isInstanceOf(Apple.class);
assertThat(banana).isInstanceOf(Banana.class);
}
제네릭을 사용하지 않을 때와 비교해 보면 getFruit를 하면 굳이 형 변환을 하지 않아도 되고,
Apple 객체가 아닌 apple 문자열을 넣게 되면 빨간 밑줄로 인해 컴파일 에러가 발생하게 됩니다.
왜냐하면 이미 Box 객체를 생성할 때 Apple 객체만 들어올 수 있게 명시를 해놓았기 때문입니다.
이처럼 제네릭을 사용하게 되면 명시하지 않은 다른 값이 들어올 때 컴파일 시점에 알 수 있기 때문에 "타입 안정성"이라는 특징을 가질 수 있습니다.
용어 정리
구글링을 통해 제네릭을 검색하게 되면 매우 비슷한 의미를 가지고 있는 듯 하지만, 가리키는 게 서로 다른 단어들이 있습니다.
예를 들어서 타입 매개변수, 타입 인자, 매개변수화 타입이라는 용어들이 있습니다.
(참고 : Effective Java 3/E Item26)
타입 매개변수(Type Parameter)
Box<T> 에서 T를 타입 매개변수라고 합니다.
타입 인자(Type Argument)
Box<Apple>에서 Apple를 타입 인자라고 합니다.
매개변수화 타입(Parameterized Type)
매개변수화 타입을 제네릭 타입이라고도 합니다. Box<Apple> 이 자체를 매개변수화 타입이라고 합니다.
static generic method
static 메서드에서는 generic 메서드가 어떻게 동작하는지 알아보겠습니다.
public static void callStaticGenericT(T t) {
System.out.println("t = " + t);
}
class Box<T>{}
public static <S> void callStaticGenericS(S s) {
System.out.println("s = " + s);
}
기존 Box 클래스에 위 두 static 메서드를 추가해 보겠습니다.
@Test
void staticGenericTest() throws Exception {
Box.callStaticGenericT(new Apple());
}
위 테스트를 진행하게 되면 아래와 같은 오류가 발생하게 됩니다. 왜 그럴까요?
callStaticGenericT에서 사용된 T는 클래스의 인스턴스가 생성될 때 T가 무엇인지 결정됩니다.
즉, 런타임에 결정 나게 되는 거죠.
그런데 static 메서드는 이미 컴파일 시점에 메모리에 올라가야 하는데 T를 모르기 때문에 에러가 발생하게 됩니다.
@Test
void staticGenericTest() throws Exception {
Box.callStaticGenericS(new Apple());
}
위 테스트를 진행하게 되면 정상적으로 Apple이 출력되는 것을 알 수 있습니다.
callStaticGenerics에서 S는 T와는 별개의 값을 의미합니다. 그래서 static 메서드를 호출할 때, S의 값이 결정되기 때문에 정상적으로 수행됩니다.
제네릭 실제 적용
static long countGreaterThan(Integer[] arr, Integer standard) {
return Arrays.stream(arr)
.filter(val -> val > standard)
.count();
}
@Test
void noGenericCompare() throws Exception {
long count = countGreaterThan(new Integer[]{1, 2, 3, 4, 5, 6, 7}, 5);
assertEquals(count, 2);
}
위와 같이 Integer의 대소 비교를 하는 메서드가 있습니다. 만약에 Integer가 아닌 String 데이터를 넣어서 count 하고 싶다면 String 용 메서드를 하나 더 만들어야 할까요?
굳이 String 용 메서드를 만들지 않고, 제네릭을 사용해서 메서드를 재활용할 수 있습니다.
static <T> long countGreaterThan(T[] arr, T standard) {
return Arrays.stream(arr)
.filter(val -> val > standard)
.count();
}
코드를 이렇게 바꿔봤지만 문제가 생겼습니다. 만약에 T가 숫자일 경우에만 부등호 연산이 가능한데, String 같은 경우에는 어떡할까요?
Java에서는 객체끼리의 비교 연산을 할 수 있게 compareTo 메서드를 가진 Comparable을 통해 비교할 수 있습니다.
그래서 Comparable을 상속받는 객체만 오게 되면 compareTo를 사용해서 대소 비교를 할 수 있을 것 같습니다.
Comparable을 상속받는 객체만을 해당 메서드에서 사용할 수 있도록 하는 것을 Recursive Type Bound라고 합니다.
static <T extends Comparable<T>> long countGreaterThan(T[] arr, T standard) {
return Arrays.stream(arr)
.filter(val -> val.compareTo(standard) > 0)
.count();
}
@Test
void noGenericCompare() throws Exception {
long count = countGreaterThan(new Integer[]{1, 2, 3, 4, 5, 6, 7}, 5);
long stringCount = countGreaterThan(new String[]{"a", "b", "c", "d", "e"}, "b");
assertEquals(count, 2);
assertEquals(stringCount, 3);
}
Bounded Type Parameter
이펙티브 자바에서는 위와 같은 <T extends Comparable<T>>를 Recursive Type Bound라고 합니다.
Bounded Type Parameter의 개념을 이해하면 Recursive Type Bound도 이해될 것이라 생각이 들어 Bounded Type Parameter를 설명드리겠습니다.
직역하면 제한된 타입 파라미터입니다. 즉, 앞서 타입 파라미터란 Box<T>에서 T를 의미합니다.
T는 어떤 타입도 올 수 있습니다. 그러나 우리는 T에 대해서 제한을 둘 수 있는데 이러한 제한을 Bounded Type Parameter라고 합니다.
위 예제에서 보다시피 <T extends Comparable<T>>가 의미하는 바는 T는 Comaprable 또는 Comparable를 상속받고 있는 즉, Comparable의 sub type만 가능합니다.
Comparable이 제일 위에 있고, 그 아래 type들만 올 수 있도록 제한을 했기 때문에 upper bound라고 합니다.
또한, bound를 하나만 주는 것이 아닌 & 를 통해서 여러 개를 줄 수 있습니다.
<T extends Comparable & Serializable & List>
당연히 Java에서는 단일 상속만을 지원하기 때문에 제한할 수 있는 클래스는 하나만 올 수 있고, 여러 개의 인터페이스를 제한할 수 있습니다.
그러면 타입 안정성을 어떻게 보장할 수 있을까요?
앞에서 제네릭을 사용하는 이유가 타입 안정성을 보장한다고 했습니다.
그러면 자바는 제네릭을 사용해서 타입 안정성을 보장할 수 있을까요?
이 부분은 바이트 코드를 통해서 확인해 보겠습니다.
public class Box<T> {
private T value;
public Box(final T value) {
this.value = value;
}
}
바이트 코드를 보시면 T가 전부 Object로 type erasure 된 것을 알 수 있습니다.
T -> Object 로 변환되면서 모든 객체를 받을 수 있기 때문에 타입 안정성을 보장한다고 할 수 있습니다.
그렇다면 앞에서 배운 Bounded Type Parameter는 어떻게 타입 안정성을 보장할까요?
public class Box<T extends Number> {
private T value;
public Box(T value) {
this.value = value;
}
}
앞에서와 달리 Object가 아닌 T가 Number로 형변환 된 것을 알 수 있습니다.
<T extends Number>는 Number의 sub type 만 올 수 있고, Number의 서브타입은 상위타입인 Number로 upcasting이 가능하기 때문에 T를 Number로 형변환 시켰습니다.
공변, 불공변
제네릭을 공부하면 공변, 불공변이라는 단어를 보신 적이 있으실 거고, 대표적으로 배열은 공변, 제네릭은 불공변이다 라는 말을 많이 들어보셨을 겁니다.
간단하게 설명하자면 배열은 런타임에도 자신이 담기로 한 원소의 타입을 인지하고 확인하는 "실체화"라는 특징을 가지고 있고, 제네릭은 타입 정보가 런타임에 소거됩니다. 즉, 원소 타입을 컴파일 타임에만 검사하고 런타임에서 알 수 조차 없습니다.
예상 질문 예시 코드에서 배열, 제네릭의 바이트 코드를 보시면 이해하실 수 있습니다.
공변(covariant)
A가 B의 sub type이라면 A[]는 B[]의 하위타입이 됩니다. 이를 공변이라 하며, 함께 변한다라는 뜻을 가지고 있습니다.
대표적인 예시는 배열입니다.
불공변(incovariant)
불공변이란 A가 B의 sub type이라면 List<A>, List<B>는 아무 관계가 없습니다. 대표적인 예시는 제네릭입니다.
예상 질문
제네릭은 왜 런타임 시점에 타입 소거를 하나요?
type erasure를 하는 이유는 제네릭이 지원되기 전의 레거시 코드와 제네릭 타입을 함께 사용할 수 있도록 호환성을 위해 사용됩니다.
@Test
void type_erasure() throws Exception {
List<Fruit> fruits = new ArrayList<>();
List<Apple> apples = new ArrayList<>();
Fruit[] fruits1 = new Fruit[2];
Apple[] apples1 = new Apple[2];
Fruit fruit = new Fruit();
Apple apple = new Apple();
Box<Fruit> fruitBox = new Box<>(fruit);
Box<Apple> appleBox = new Box<>(apple);
}
class Fruit {}
class Apple extends Fruit {}
class Box<T> {
private T element;
public Box(final T element) {
this.element = element;
}
}
제네릭을 제외하고 배열과 클래스 자체로 객체를 생성할 때는 Fruit, Apple 이 명시되어 있습니다.
그러나 제네릭이 사용된 객체들을 보면 List, Box로 타입이 명시되어있지 않습니다.
그래서 이를 타입 소거라 하며, 호환성을 위해 사용됩니다.
제네릭에선 Auto Boxing을 왜 지원하지 않을까요?
제네릭은 컴파일 타임에 동작하게 됩니다. 하지만 Auto Boxing은 런타임에 동작되기 때문에 지원하지 않습니다.
또한, 제네릭은 불공변이므로 List<Integer> 와 List<Number>는 아무 관계가 없습니다.
왜 Generic에서 type parameter로 primitive type이 올 수 없을까요?
제네릭은 컴파일 시점에 Object로 type erasure가 됩니다. 하지만 primitive은 Object를 상속받지 않기 때문에 type parameter로 primitive type이 올 수 없게 됩니다.
REFERENCES
primitive type은 Object를 상속하지 않을까?
제네릭에선 auto boxing을 왜 지원하지 않을까?
Effective Java 3/e
'Java' 카테고리의 다른 글
unmodifiableList & copyOf & 방어적 복사 (0) | 2023.02.21 |
---|---|
Generic & Wildcard (0) | 2022.12.29 |
JDK ? JRE ? (0) | 2022.09.15 |
정적 메서드는 왜 오버라이딩 되지 않을까? (0) | 2022.08.29 |
throw vs throws (0) | 2022.05.17 |