[Java Study 14주차] 제네릭스(Generics)
본 포스팅은 백기선님이 진행하시는 자바 스터디 를 진행하며 혼자 공부하고 이해한 내용을 바탕으로 정리한 글입니다. 오류나 지적 사항이 있다면 댓글로 알려주시면 감사하겠습니다.
해당 메서드의 인자로 들어오는 리스트의 요소는 어떤 특징을 가진 타입이어야 할까요?
public static <T extends Comparable<? super T>> void sort(List<T> list) {...}
잘 모르겠다면 아래의 글을 읽어보세용!
제네릭스는 무엇이고 왜 쓰는가?
제네릭스는 JDK 1.5부터 도입된 기능으로 클래스나 메서드 선언 시에 타입을 매개변수(type parameter)로 사용할 수 있도록 해주는 기능이다. 제네릭스를 이용할 때 얻을 수 있는 장점은 다음과 같다.
- 컴파일 타임에 더욱 강력한 타입 체크가 가능하다.
- 캐스팅 과정이 사라진다.
- 일반적인 타입의 알고리즘 구현이 가능하다. (알고리즘을 구현할 때, 여러 타입에 대해서 동작하도록 구현할 수 있다)
간단히 예시를 통해서 살펴보자.
public static void main(String[] args) {
// 제네릭스를 사용하지 않는 경우
ArrayList numbers = new ArrayList();
numbers.add(10);
numbers.add(20);
numbers.add("30");
int numberOne = (int) numbers.get(0);
}
ArrayList는 내부적으로 Object 배열을 가지고 있어서, 모든 종류의 객체를 담을 수 있다. 나는 정수만 들어있는 ArrayList를 만들고 싶은데 위처럼 문자열을 넣어도 컴파일 타임에서는 아무런 문제가 발생하지 않는다. 내부적으로는 모든 것을 Object로 바꿔서 저장하기 때문이다. 같은 이유로 ArrayList에서 값을 꺼낼때는 정수로 다시 캐스팅을 해줘야한다. 제네릭스를 이용하면 이러한 문제를 해결할 수 있다.
public static void main(String[] args) {
// 제네릭스를 사용하는 경우
ArrayList<Integer> numbers = new ArrayList();
numbers.add(10);
numbers.add(20);
numbers.add("30"); // 컴파일 에러
int numberOne = numbers.get(0);
}
이렇게 꺽쇠"< >" 안에 내가 저장할 타입을 명시해주면, 그 이외의 타입을 넣으려고 할 때 컴파일 에러를 발생시킨다. 또 값을 꺼낼때 형변환을 해주지 않아도 된다.
이렇게 제네릭스를 이용하면 타입의 안정성을 높이고 타입 체크와 형변환을 생략할 수 있다.
또 타입을 파라미터로 받을 수 있으므로 내 코드가 특정 타입에 종속되지 않는다. 이건 자바 API를 확인해보면 정말 많은 예시를 찾아볼 수 있는데, 예를 들어 함수형 인터페이스 중 하나인 Cunsumer
의 문서를 보면
이렇게 타입 정보를 타입 파라미터로 받기 때문에 특정 타입에 종속되지 않으면서도 컴파일 타임에는 타입을 강제할 수 있는 멋짐을 발휘할 수 있는 것이다.
제네릭 타입(Generic Types)
제네릭 타입이란 타입 파라미터화된 클래스나 인터페이스를 말한다. 예시를 통해 살펴보자
임의의 객체를 저장하는 non-generic Box
클래스를 다음과 같이 정의할 수 있다.
public class Box {
private Object object;
public void set(Object object) {
this.object = object;
}
public Object get() {
return object;
}
}
박스 안에 원시타입을 제외한 어떤 타입이든 들어갈 수 있기 때문에 버그를 내기 십상이다. 어떤 타입이든 들어갈 수 있다는 말은, 꺼낼 때 어떤 타입이 나올지 알 수 없다는 말과 같기 때문이다.
선언 방법 및 사용 방법
제네릭 타입은 다음과 같은 형태로 만들 수 있다.
class name<T1, T2, T3, ..., Tn> {};
위처럼 클래스(혹은 인터페이스) 이름 옆에 꺽쇠<>를 열고 그 안에 타입 파라미터(타입 변수라고도 불린다)를 정의하면 된다.
앞서 만든 Box
클래스를 아래와 같이 제네릭 타입으로 만들 수 있다.
public class Box<T> {
// T stands for "Type"
private T t;
public void set(T t) {
this.t = t;
}
public T get() {
return t;
}
}
위 제네릭 타입을 코드에서 사용하고 싶으면 아래와 같이 선언해주면 된다.
public class GenericsDemo {
public static void main(String[] args) {
Box<String> box = new Box<String>();
box.set("Apple");
}
}
참조변수 box
를 선언할 때, 타입 인자(type argument)로 String
을 넘겨주면 타입 변수 T
가 사용된 곳에 다른 타입이 올 경우 컴파일 에러가 발생한다. 타입 인자로 원시타입 빼고는 뭐든 들어갈 수 있다. 심지어 아래처럼 또 다른 타입 변수도 들어갈 수 있다.
public class Office<V> {
private Box<V> box;
}
자바 7부터는 타입 인자를 컴파일러가 추론할 수 있는 경우 생성자 부분의 타입 인자를 굳이 쓰지 않아도 된다. (대부분의 경우 타입 추론이 가능하다 0_< )
Box<String> box = new Box<>();
아래처럼 타입 파라미터는 여러 개 사용 가능하다.
public interface Pair<K, V> {
public K getKey();
public V getValue();
}
class OrderedPair<K, V> implements Pair<K, V> {
private K key;
private V value;
public OrderedPair(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() {
return key;
}
public V getValue() {
return value;
}
}
타입 파라미터 네이밍 컨벤션
타입 파라미터(혹은 타입 변수)의 이름으로는 아무 문자나 사용해도 된다. 이름처럼 '변수'이기 때문에 대입된 타입으로 치환될 뿐이다. 하지만 컨벤션으로 대문자 알파벳 하나를 이름으로 사용하며 자주 사용되는 이름으로는 아래와 같은 것들이 있다. 아래의 컨벤션을 지키면서 코딩하도록 하자
- E - Element
- K - Key
- V - Value
- N - Number
- T - Type
- S, U, V - 2nd, 3rd, 4th types
제네릭 메서드
제네릭 메서드란 타입 파라미터를 사용하는 메서드를 말한다. 제네릭 타입을 선언하는 것과 비슷하지만 타입 변수의 범위가 메서드의 블록 스코프 내로 한정된다. 다음과 같은 형태로 선언한다.
class Util {
public static <K, V> boolean compare(Pair<K, V> p1, Pair<K, V> p2) {
return p1.getKey().equals(p2.getKey()) &&
p1.getValue().equals(p2.getValue());
}
}
이때 타입 변수를 담은 꺽쇠 <K, V>
는 반드시 메서드의 리턴 타입 앞에 나타나야한다.
메서드를 호출할 때는 다음과 같은 형태로 호출한다.
Pair<Integer, String> p1 = new Pair<>(1, "apple");
Pair<Integer, String> p2 = new Pair<>(2, "pear");
boolean same = Util.<Integer, String>compare(p1, p2);
앞서 살펴봤듯이 타입이 추론 가능할 때는 생략 가능하다.
boolean same = Util.compare(p1, p2);
타입 변수 K
, V
는 지역변수처럼 메서드 범위 내에서만 유효하다.
예를 들어, 아래의 예시에서 제네릭 타입에 선언된 <K, V>
는 foo() 메서드에 사용된 <K, V>
와 이름만 같지 서로 아무 관련이 없다. 따라서 예시처럼 pair의 타입 인자로는 String, Integer가 전달되고 foo 메서드의 인자로는 Boolean, Long이 전달되어도 아무런 문제가 없다.
public class Pair<K, V> {
public <K, V> K foo(K k, V v) {
return k;
}
public static void main(String[] args) {
Pair<String, Integer> pair = new Pair<>();
Boolean k = Boolean.TRUE;
Long v = 1L;
Boolean result = pair.foo(k, v);
}
}
Bounded Type Parameters
이따금 타입 파라미터로 전달되는 타입을 제한해야할 필요가 있다. 예를 들어, Number
타입만 담고 싶은 상자를 만들고 싶다면 아래처럼 extends
키워드를 사용해 제한을 걸어준다. (주의. 해당 타입이 인터페이스이더라도 extends 키워드 사용)
public class NumberBox<T extends Number> {
private T number;
public T getNumber() {
return number;
}
public void setNumber(T number) {
this.number = number;
}
}
위 클래스의 타입 인자로 들어올 수 있는 타입은 Number
혹은 그 서브 클래스로 제한된다.
public class GenericsDemo {
public static void main(String[] args) {
NumberBox<Integer> integerBox = new NumberBox<>();
integerBox.setNumber(10);
NumberBox<Long> longBox = new NumberBox<>();
longBox.setNumber(10L);
NumberBox<String> stringBox = new NumberBox<>(); // 컴파일 에러 발생
}
}
또 바운디드 타입으로 설정되면 해당 타입의 메서드를 호출할 수 있다.
public class NumberBox<T extends Number> {
private T number;
...
public Integer getIntegerValue() {
return number.intValue(); // Number 클래스의 메서드 호출 가능
}
}
Multiple Bounds
만약 여러 클래스, 혹은 특정 인터페이스를 구현한 클래스를 바운디드 타입으로 설정하고 싶다면 &
키워드로 연결해준다.
class A { /* ... */ }
interface B { /* ... */ }
interface C { /* ... */ }
class D <T extends A & B & C> { /* ... */ }
만약 연결되는 바운디드 타입 중 클래스가 하나라도 있으면 클래스가 반드시 앞에 와야한다.(위 예시의 A)
제네릭 사용시 주의사항
1.타입 변수는 static 멤버에게 사용될 수 없다.
public class NumberBox<T extends Number> {
public static T staticMember; // 컴파일 에러 발생
}
스태틱 멤버는 타입 파라미터로 넘어온 타입 변수가 뭐든간에 상관없이 항상 똑같이 동작해야하기 때문에 애초에 타입 변수를 사용하는 것이 불가능하다.
2.제네릭 타입의 배열은 생성할 수 없다.
배열의 메모리 생성을 위해서는 new
연산자가 사용되어야 하는데, new 연산자는 컴파일 시점에 생성할 객체의 타입을 정확하게 알아야한다. 그렇기 때문에 제네릭 타입의 배열은 생성할 수 없다. 다만 제네릭 타입 배열의 변수를 선언하는 것은 가능하다.
class Box<T> {
T[] tArr; //이건 가능
...
T[] toArray() {
T[] tmpArr = new T[tArr.length]; // 에러 발생
...
return tmpArr;
}
}
3.제네릭에는 다형성이 적용되지 않는다.
매개변수 T에 대입되는 타입은 항상 같아야한다. 아래와 같이 다형성을 적용할 수 없다.
public static void main(String[] args) {
//Apple은 Fruit를 상속한다고 가정
Box<Fruit> appleBox = new Box<Apple>(); // 에러 발생
}
왜냐하면 Apple
은 Fruit
의 서브타입이 맞지만 List<Apple>
은 List<Fruit>
의 서브타입이 아니기 때문이다. (기술적으로는 서로 전혀 관련이 없다)
Wildcards
제네릭 코드에서 ?
는 와일드카드라고 불리며 unkown type을 의미한다. 와일드카드는 제네릭 메서드를 작성할 때 다양하게 활용할 수 있는데, 하나씩 알아보도록 하자.
Upper Bounded Wildcards
Number
의 하위 타입, 예를들어 Integer
, Long
, Double
타입 등의 리스트를 받아서 합을 반환하는 메서드를 만들고 싶다고 가정해보자. 그리고 아래와 같이 코드를 작성했다.
public static double sumOfList(List<Number> list) {
double s = 0.0;
for (Number n : list)
s += n.doubleValue();
return s;
}
앞서 말했듯이 제네릭에는 다형성이 적용되지 않기 때문에 아래처럼 하위 타입을 넘겨줄 수 없다.
public static void main(String[] args) {
List<Integer> IntegerArr = Arrays.asList(1, 2, 3, 4);
double res = Util.sumOfList(IntegerArr); // 컴파일 에러 발생
}
이때 와일드카드를 사용하여 문제를 해결할 수 있다.
public static double sumOfList(List<? extends Number> list) {
double s = 0.0;
for (Number n : list)
s += n.doubleValue();
return s;
}
이 때 매개변수로 들어오는 리스트의 타입 ? extends Number
는 Number 클래스 혹은 그 서브타입을 의미한다.
이렇게 작성한 것과 똑같다.
public static <T extends Number> double sumOfList(List<T> list) {
double s = 0.0;
for (Number n : list)
s += n.doubleValue();
return s;
}
Unbounded Wildcards
만약 인자로 들어오는 리스트의 요소가 어떤 타입이든 상관없이 출력하는 메서드를 작성하고 싶다면 어떻게 해야할까?
앞서 계속 언급했듯이 아래의 코드는 List<Object>
만 인자로 넘어올 수 있다.
public static void printList(List<Object> list) {
for (Object elem : list)
System.out.println(elem + " ");
System.out.println();
}
List<Integer>
, List<String>
등 모든 참조 타입의 리스트를 인자로 넘기고 싶다면 다음과 같이 코드를 작성한다.
public static void printList(List<?> list) {
for (Object elem : list)
System.out.println(elem + " ");
System.out.println();
}
이처럼 타입 변수에 와일드카드 하나만 사용하는 경우를 언바운디드 와일드카드라고 부른다. 언바운디드 와일드카드는 다음 두 상황에서 유용하게 사용된다.
- Object 클래스에서 제공하는 기능을 사용하는 메서드를 작성할 때
- 타입 파라미터에 의존하지 않는 메서드를 작성할 때
실제로 java.lang.Class
에 정의되어 있는 메서드에는 언바운디드 와일드카드가 굉장히 많이 사용되고 있다. 왜냐하면 Class<T>
에 정의되어 있는 메서드 대부분은 T
에 의존하지 않기 때문이다.
Lower Bounded Wildcards
<? super A>
형태로 하위 경계를 제한할 수도 있다. 예를 들어 <? super Integer>
는 Integer
를 포함한 상위 타입만 허용한다는 뜻이다.
다시 도입부에서 보았던 메서드를 살펴보자
public static <T extends Comparable<? super T>> void sort(List<T> list) {...}
지금까지 살펴본 내용으로 위 메서드의 선언부를 풀이해보면, "타입 T를 요소로 하는 List를 매개변수로 허용하는데, 그 타입 T는 Comparable을 구현한 클래스이며, T 또는 그 조상의 타입을 비교하는 Comparable이어야 한다"는 것을 의미한다.
Erasure
컴파일러는 제네릭 타입을 이용해서 소스파일을 체크하고, 필요한 곳에 형변환을 넣어준다. 그리고 제네릭 타입을 제거한다. 그래서 바이트코드(*.class)에는 제네릭 타입에 대한 정보가 없다. 이를 타입 소거(Type Erasure)라고 한다.
이렇게 하는 이유는 하위 호환성 때문이다. 제네릭스는 JDK 1.5부터 도입되었지만 아직도 로 타입(raw type)으로 작성한 레거시 코드가 사용되고 있다. 그래서 소스코드에 제네릭을 사용하더라도 바이너리 파일에는 해당 정보를 지우는 것이다.
기본적인 제거 과정은 다음과 같다.
1.제네릭 타입의 경계(bound)를 제거한다.
제네릭 타입이 라면 T는 Fruit로 치환된다. 인 경우 T는 Object로 치환된다.
class Box<T extends Fruit> {
void add(T t) {
...
}
}
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
class Box {
void add(Fruit t) {
...
}
}
2.제네릭 타입을 제거한 후에 타입이 일치하지 않으면 형변환을 추가한다.
List의 get 메서드는 Object 타입을 반환하므로 Fruit로 캐스팅한다.
T get (int i) {
return list.get(i);
}
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
Fruit get(int i) {
return (Fruit)list.get(i);
}
와일드 카드가 포함되어 있는 경우에는 적절한 타입으로의 형변환이 추가된다.