이펙티브 자바 완벽 공략 2부
아이템 30. 이왕이면 제네릭 메서드로 만들라
아이템 30. 이왕이면 제네릭 메서드로 만들라
- 아이템 30. 이왕이면 제네릭 메서드로 만들라
- 아이템 30. 핵심 정리
- 아이템 30. 이왕이면 제네릭 메서드로 만들라
- 제네릭 메서드가 필요한 대표적인 상황
- Raw Type으로 union 메서드를 만들어보자
- 서로 다른 타입도 합칠 수 있다
- 문제는 클라이언트의 형변환에서 발생한다
- 타입 오류를 런타임까지 가져갈 필요가 없다
- union을 제네릭 메서드로 만들자
- 제네릭 메서드의 타입 매개변수는 어디에 선언할까?
- 제네릭 메서드의 기본 문법
- union의 E는 하나의 동일한 타입을 나타낸다
- String Set 두 개를 합치면 String Set이 된다
- 호출하는 쪽에서 직접 타입을 명시할 필요가 없는 경우가 많다
- 서로 호환되지 않는 타입 사용을 컴파일 단계에서 찾을 수 있다
- 반환값을 사용할 때도 형변환이 필요 없다
- 제네릭 메서드의 핵심 장점
- 타입 매개변수는 메서드 하나에만 존재할 수 있다
- Java의 Collections가 대표적인 사례다
- 두 번째 활용: 제네릭 싱글턴 팩터리
- UnaryOperator로 항등 함수를 표현해보자
- 동작이 완전히 같다면 객체 하나를 재사용할 수 있다
- 제네릭 싱글턴 팩터리 메서드를 만든다
- 왜 하나의 객체를 여러 타입으로 사용할 수 있을까?
- 타입 소거와도 연결된다
- 싱글턴 팩터리라는 이름의 의미
- 사용하는 쪽은 형변환하지 않는다
- 제네릭 싱글턴 팩터리의 중요한 전제
- 세 번째 개념: 재귀적 타입 한정
- 먼저 E extends Comparable부터 생각해보자
- 문장으로 풀어보자
- String을 보면 이해하기 쉽다
- 왜 재귀적이라고 부를까?
- max 메서드를 만들어보자
- E에는 compareTo가 있다는 보장이 없다
- Comparable로 제한한다
- max 메서드의 기본 구조
- 문자열에서도 사용할 수 있다
- 숫자 타입에서도 Comparable 조건을 만족한다면 사용할 수 있다
- 재귀적 타입 한정의 목적
- 아이템 29의 한정적 타입 매개변수와 연결된다
- 제네릭 메서드와 제네릭 클래스의 차이
- 어떤 것을 사용해야 할까?
- 제네릭 클래스가 아닌 클래스에도 제네릭 메서드를 넣을 수 있다
- 제네릭 메서드는 타입 관계를 문서화하기도 한다
- Object 기반 메서드와 비교해보자
- 제네릭 메서드를 고려할 신호
- 하지만 모든 메서드를 억지로 제네릭화할 필요는 없다
- 현재 union 메서드에는 추가 개선 여지가 있다
- 아이템 29와 아이템 30을 연결하면
- 전체 흐름 정리
- 핵심 정리
- 한 줄 요약
- 아이템 30. 핵심 정리
아이템 30. 핵심 정리
아이템 30. 이왕이면 제네릭 메서드로 만들라
아이템 29에서는 클래스를 직접 설계할 때 가능하다면 제네릭 타입으로 만들라고 했다.
예를 들어 기존에 Object를 사용하던 Stack을
public class Stack<E> {
}
처럼 바꾸면 클라이언트가 직접 형변환할 필요가 없어지고 컴파일 타임의 타입 안전성도 얻을 수 있었다.
이번 아이템 30은 그 원칙을 메서드 수준으로 확장한다.
여러 타입에서 공통으로 사용할 수 있는 메서드라면 이왕이면 제네릭 메서드로 만들라.
특히 정적 유틸리티 메서드를 만들 때 제네릭 메서드는 매우 유용하다.
Java 표준 라이브러리의 Collections 같은 유틸리티 클래스를 살펴보면 다양한 메서드가 제네릭을 적극적으로 사용하는 이유도 여기에 있다.
제네릭 메서드가 필요한 대표적인 상황
다음과 같은 메서드를 생각해보자.
두 Set을 전달받는다.
↓
두 Set의 원소를 합친다.
↓
새로운 Set을 반환한다.
이 메서드는 특정 타입에 종속될 이유가 없다.
Set<String>
Set<Integer>
Set<Member>
Set<Order>
어떤 타입의 Set에도 동일한 알고리즘을 적용할 수 있다.
이럴 때 제네릭 메서드를 사용하면
코드 재사용
+
컴파일 타임 타입 안전성
을 동시에 얻을 수 있다.
Raw Type으로 union 메서드를 만들어보자
먼저 제네릭을 사용하지 않고 두 Set을 합치는 메서드를 작성해보자.
public static Set union(
Set s1,
Set s2
) {
Set result =
new HashSet(s1);
result.addAll(s2);
return result;
}
기능 자체는 단순하다.
첫 번째 Set을 복사한다.
new HashSet(s1)
두 번째 Set의 내용을 추가한다.
result.addAll(s2);
그리고 결과를 반환한다.
서로 다른 타입도 합칠 수 있다
Raw Type을 사용하고 있으므로 다음과 같은 코드도 가능하다.
Set<String> strings =
Set.of(
"A",
"B",
"C"
);
Set<Integer> numbers =
Set.of(
1,
2,
3
);
Set result =
union(
strings,
numbers
);
두 Set의 원소 타입이 완전히 다르지만 메서드는 이를 막지 못한다.
결과는 개념적으로 다음과 같다.
[
"A",
"B",
"C",
1,
2,
3
]
합치는 것 자체는 가능하다.
문제는 값을 꺼내 사용하는 순간이다.
문제는 클라이언트의 형변환에서 발생한다
개발자가 결과에는 문자열만 들어 있다고 생각했다고 해보자.
for (Object value : result) {
String text =
(String) value;
System.out.println(text);
}
문자열 원소에서는 문제가 없다.
하지만 Integer가 나오면
Integer
↓
String으로 형변환
↓
ClassCastException
이 발생할 수 있다.
결국 Raw Type을 사용하면서 타입 안전성을 잃은 것이다.
타입 오류를 런타임까지 가져갈 필요가 없다
현재 흐름은 다음과 같다.
서로 다른 타입의 Set 전달
↓
union()에서 허용
↓
컴파일 성공
↓
프로그램 실행
↓
결과 조회
↓
ClassCastException
잘못된 타입 조합을 이미 메서드 호출 시점에 알 수 있는데도 문제 발견이 런타임까지 미뤄졌다.
제네릭의 목적은 이런 문제를 컴파일 시점으로 끌어올리는 것이다.
union을 제네릭 메서드로 만들자
다음과 같이 변경할 수 있다.
public static <E> Set<E> union(
Set<E> s1,
Set<E> s2
) {
Set<E> result =
new HashSet<>(s1);
result.addAll(s2);
return result;
}
핵심은 다음 부분이다.
<E>
이것이 메서드의 타입 매개변수 목록이다.
제네릭 메서드의 타입 매개변수는 어디에 선언할까?
클래스의 제네릭 타입 매개변수는 클래스 이름 뒤에 작성한다.
public class Stack<E> {
}
하지만 제네릭 메서드는 다르다.
다음과 같이 접근 제어자와 반환 타입 사이에 선언한다.
public static <E> Set<E> union(...)
구조를 분리하면 다음과 같다.
public static
<E>
Set<E>
union(...)
각각 의미는 다음과 같다.
public static
→ 접근 제어자와 static
<E>
→ 이 메서드에서 사용할 타입 매개변수
Set<E>
→ 반환 타입
union
→ 메서드 이름
제네릭 메서드의 기본 문법
일반적인 형태는 다음과 같다.
public static <T> T method(T value) {
return value;
}
타입 매개변수가 두 개라면
public static <K, V> V method(
K key,
V value
) {
return value;
}
처럼 사용할 수도 있다.
핵심은 타입 매개변수 선언이 반환 타입보다 앞에 위치한다는 것이다.
union의 E는 하나의 동일한 타입을 나타낸다
다음 메서드를 다시 보자.
public static <E> Set<E> union(
Set<E> s1,
Set<E> s2
)
여기 등장하는 E들은 서로 독립적인 타입이 아니다.
하나의 메서드 호출 안에서는 모두 같은 타입을 의미한다.
Set<E> s1
Set<E> s2
Set<E> return
즉 개념적으로
첫 번째 Set의 원소 타입
=
두 번째 Set의 원소 타입
=
반환되는 Set의 원소 타입
이라는 관계를 표현하고 있다.
String Set 두 개를 합치면 String Set이 된다
다음 코드를 보자.
Set<String> first =
Set.of(
"A",
"B"
);
Set<String> second =
Set.of(
"C",
"D"
);
호출한다.
Set<String> result =
union(
first,
second
);
컴파일러는 호출 문맥을 통해 E를 String으로 추론할 수 있다.
개념적으로 다음과 같다.
E = String
↓
Set<String> union(
Set<String>,
Set<String>
)
↓
Set<String> 반환
호출하는 쪽에서 직접 타입을 명시할 필요가 없는 경우가 많다
제네릭 메서드를 호출할 때 매번 다음처럼 타입을 적을 필요는 없다.
Utility.<String>union(
first,
second
);
대부분의 경우 컴파일러가 메서드 인자와 호출 문맥을 보고 타입을 추론한다.
그래서 보통 다음처럼 사용한다.
Set<String> result =
union(
first,
second
);
Java의 타입 추론 덕분이다.
서로 호환되지 않는 타입 사용을 컴파일 단계에서 찾을 수 있다
제네릭 메서드를 사용하면서 서로 맞지 않는 타입 조합이 생기면 컴파일러가 타입 관계를 검사한다.
Raw Type 방식에서는
일단 받아들임
↓
나중에 문제 발견
이었다.
제네릭 방식에서는
호출 시 타입 관계 확인
↓
타입이 맞지 않으면
컴파일 단계에서 확인
할 수 있다.
이것이 제네릭 메서드의 핵심 장점이다.
반환값을 사용할 때도 형변환이 필요 없다
Raw Type 방식에서는 반환 타입이 Set이었다.
Set result =
union(
first,
second
);
안에 어떤 타입이 들어 있는지 타입 시스템이 제대로 표현하지 못한다.
반면 제네릭 메서드는
Set<String> result =
union(
first,
second
);
처럼 반환 타입도 유지된다.
따라서 원소를 사용할 때 직접 캐스팅할 필요가 없다.
for (String value : result) {
System.out.println(value);
}
제네릭 메서드의 핵심 장점
구조를 정리하면 다음과 같다.
Raw Type Utility Method
Set
↓
Set
↓
Set
타입 관계 표현 불가능
반면
Generic Utility Method
Set<E>
↓
Set<E>
↓
Set<E>
입력과 출력의 타입 관계를
컴파일러가 이해
할 수 있다.
즉 제네릭 메서드는 단순히 여러 타입에서 메서드를 재사용하는 기능이 아니라 입력 타입과 출력 타입 사이의 관계를 타입 시스템으로 표현하는 기능이기도 하다.
타입 매개변수는 메서드 하나에만 존재할 수 있다
제네릭 클래스를 만들지 않아도 메서드 하나만 제네릭하게 만들 수 있다.
예를 들어 다음 클래스 자체는 제네릭 클래스가 아니다.
public class Sets {
}
그 안의 메서드만 제네릭일 수 있다.
public class Sets {
public static <E> Set<E> union(
Set<E> s1,
Set<E> s2
) {
Set<E> result =
new HashSet<>(s1);
result.addAll(s2);
return result;
}
}
Sets 클래스에는 <E>가 없다.
public class Sets
대신 union() 메서드에만 존재한다.
public static <E> ...
이런 형태는 정적 유틸리티 메서드에서 특히 많이 사용된다.
Java의 Collections가 대표적인 사례다
컬렉션 관련 유틸리티 기능들은 특정 하나의 타입에 종속되면 재사용성이 크게 떨어진다.
예를 들어
String 전용 알고리즘
Integer 전용 알고리즘
Order 전용 알고리즘
을 각각 만들 필요는 없다.
알고리즘이 동일하다면 타입을 매개변수화해서 재사용하면 된다.
개념적으로
public static <T> ...
형태의 제네릭 메서드가 자연스럽다.
따라서 컬렉션을 받아 처리하는 정적 유틸리티 메서드는 제네릭 메서드가 특히 잘 어울린다.
두 번째 활용: 제네릭 싱글턴 팩터리
제네릭 메서드의 흥미로운 활용 방법 중 하나가 제네릭 싱글턴 팩터리(Generic Singleton Factory) 다.
먼저 항등 함수(Identity Function)를 생각해보자.
항등 함수는 입력받은 값을 그대로 반환한다.
수학적으로 표현하면 다음과 같다.
f(x) = x
Java에서는 다음과 같이 만들 수 있다.
public static <T> T identity(
T value
) {
return value;
}
입력한 객체를 그대로 반환한다.
"A"
→ "A"
100
→ 100
Order
→ 같은 Order
UnaryOperator로 항등 함수를 표현해보자
Java의 UnaryOperator<T>는 하나의 T를 받아 같은 타입 T를 반환하는 함수형 인터페이스다.
개념적으로
T
↓
Function
↓
T
구조다.
항등 함수는 매우 단순하다.
UnaryOperator<String> identity =
value -> value;
숫자도 마찬가지다.
UnaryOperator<Number> identity =
value -> value;
하지만 여기서 생각해볼 수 있다.
String용 항등 함수
Number용 항등 함수
Order용 항등 함수
Member용 항등 함수
이런 객체를 타입마다 따로 만들 필요가 있을까?
실제로 수행하는 동작은 전부 같다.
입력값
↓
그대로 반환
동작이 완전히 같다면 객체 하나를 재사용할 수 있다
하나의 항등 함수 객체를 만든다.
private static final
UnaryOperator<Object>
IDENTITY_FN =
value -> value;
이 함수는 Object를 받는다.
그리고 같은 객체를 그대로 반환한다.
Object
↓
IDENTITY_FN
↓
same Object
이 동작 자체는 입력 객체의 구체적인 타입과 무관하다.
제네릭 싱글턴 팩터리 메서드를 만든다
이제 제네릭 메서드를 하나 만든다.
@SuppressWarnings("unchecked")
public static <T>
UnaryOperator<T> identityFunction() {
return (UnaryOperator<T>)
IDENTITY_FN;
}
메서드를 호출하는 쪽에서 원하는 타입이 결정된다.
예를 들어 문자열용 함수가 필요하다.
UnaryOperator<String> sameString =
identityFunction();
숫자용 함수도 같은 방식이다.
UnaryOperator<Number> sameNumber =
identityFunction();
겉으로 보면 서로 다른 타입의 함수다.
하지만 내부적으로는 하나의 싱글턴 객체를 재사용할 수 있다.
왜 하나의 객체를 여러 타입으로 사용할 수 있을까?
항등 함수의 구현을 다시 보자.
value -> value
입력값을 변경하지 않는다.
새로운 타입의 객체를 만들어내지도 않는다.
그저 받은 객체를 그대로 반환한다.
T 입력
↓
그 T 자체 반환
따라서 호출 시점에서
UnaryOperator<String>
으로 사용하면 String이 들어와서 같은 String이 나온다.
UnaryOperator<Number>
로 사용하면 Number가 들어와서 같은 Number가 나온다.
동작 자체에 타입별 차이가 없다.
타입 소거와도 연결된다
제네릭은 타입 소거 방식으로 구현된다.
소스 코드에서는
UnaryOperator<String>
과
UnaryOperator<Number>
처럼 서로 다른 타입 정보를 표현하고 있지만 타입 인수는 런타임의 별도 구현 객체를 반드시 요구하지 않는다.
항등 함수처럼 타입별 상태나 동작 차이가 없는 객체라면 하나의 객체를 재사용하면서 제네릭 메서드가 클라이언트에게 타입 안전한 형태로 제공할 수 있다.
이런 패턴을 제네릭 싱글턴 팩터리 패턴이라고 부른다.
싱글턴 팩터리라는 이름의 의미
구조를 나눠보면 이해하기 쉽다.
Singleton
→ 하나의 객체가 존재
그리고
Factory Method
→ 객체를 반환하는 메서드
여기에 제네릭을 적용한다.
Generic Singleton Factory
↓
하나의 객체를
↓
제네릭 팩터리 메서드를 통해
↓
여러 타입의 타입 안전한 객체처럼 제공
한다.
사용하는 쪽은 형변환하지 않는다
클라이언트는 다음처럼 사용한다.
UnaryOperator<String> sameString =
identityFunction();
String text =
sameString.apply(
"hello"
);
결과는
hello
다.
숫자도 마찬가지다.
UnaryOperator<Number> sameNumber =
identityFunction();
Number number =
sameNumber.apply(
100
);
클라이언트는 직접
(String)
이나
(Number)
같은 형변환을 작성하지 않는다.
제네릭 메서드가 타입 관계를 제공하기 때문이다.
제네릭 싱글턴 팩터리의 중요한 전제
모든 제네릭 객체를 이런 식으로 하나의 인스턴스로 공유할 수 있다는 뜻은 아니다.
항등 함수가 가능한 이유는 매우 특수하다.
타입별 상태가 없다.
입력 값을 그대로 반환한다.
구체 타입에 의존하는 동작이 없다.
즉 String일 때와 Number일 때의 실제 알고리즘이 완전히 동일하다.
이런 경우 하나의 객체를 공유할 수 있다.
세 번째 개념: 재귀적 타입 한정
이번 아이템에서는 또 하나 중요한 제네릭 문법이 등장한다.
바로 재귀적 타입 한정(Recursive Type Bound) 이다.
대표적으로 다음 형태다.
<E extends Comparable<E>>
처음 보면 상당히 복잡해 보인다.
E가 두 번 등장하기 때문이다.
하지만 하나씩 분리해서 보면 어렵지 않다.
먼저 E extends Comparable부터 생각해보자
다음 형태부터 보자.
<E extends Comparable>
의미는
E는 Comparable을 구현한 타입이어야 한다.
정도로 이해할 수 있다.
즉 서로 비교할 수 있는 타입으로 제한하고 싶은 것이다.
하지만 Comparable도 제네릭 인터페이스다.
실제로는 무엇과 비교 가능한지도 지정해야 한다.
그래서
Comparable<E>
가 된다.
결국 다음 형태가 나온다.
<E extends Comparable<E>>
문장으로 풀어보자
다음을
<E extends Comparable<E>>
문장으로 바꾸면
E는 자기 자신인 E와 비교할 수 있는 타입이어야 한다.
정도로 이해할 수 있다.
즉
E
implements
Comparable<E>
관계를 요구한다.
String을 보면 이해하기 쉽다
String은 서로 비교할 수 있다.
"A".compareTo("B");
문자열과 문자열을 비교한다.
개념적으로
String
↓
Comparable<String>
관계가 존재한다.
따라서 String을 E라고 치환해보면
<E extends Comparable<E>>
구조와 잘 맞는다.
E = String
↓
String implements Comparable<String>
즉 String은 자기 자신의 타입과 비교 가능한 타입이다.
왜 재귀적이라고 부를까?
다음 선언을 보자.
<E extends Comparable<E>>
E를 정의하고 있다.
그런데 E의 조건을 설명하면서 다시 E를 사용하고 있다.
E의 조건
↓
Comparable<E>
↑
다시 E 등장
자기 자신을 다시 참조하는 형태이므로 재귀적 타입 한정이라고 부른다.
max 메서드를 만들어보자
재귀적 타입 한정이 대표적으로 사용될 수 있는 곳이 최댓값을 찾는 메서드다.
개념적으로 다음과 같은 메서드를 만들고 싶다.
public static <E> E max(
Collection<E> collection
)
컬렉션 안의 원소를 서로 비교해서 가장 큰 값을 반환한다.
문제는 일반적인 E만으로는 비교할 방법이 없다는 것이다.
E에는 compareTo가 있다는 보장이 없다
다음과 같이 선언하면
<E>
E는 거의 모든 참조 타입이 될 수 있다.
String
Integer
Order
Member
Object
컴파일러는 E가 Comparable인지 알 수 없다.
따라서 다음 코드를 사용할 수 없다.
value.compareTo(other)
E에 compareTo()가 있다는 보장이 없기 때문이다.
Comparable로 제한한다
그래서 타입을 제한한다.
<E extends Comparable<E>>
이제 컴파일러가 알게 된다.
E가 정확히 무엇인지는 모른다.
하지만
E는 E와 비교할 수 있다.
따라서
value.compareTo(other)
를 사용할 수 있다.
max 메서드의 기본 구조
다음과 같은 형태로 작성할 수 있다.
public static
<E extends Comparable<E>>
E max(
Collection<E> collection
) {
Iterator<E> iterator =
collection.iterator();
E result =
iterator.next();
while (iterator.hasNext()) {
E next =
iterator.next();
if (next.compareTo(result) > 0) {
result = next;
}
}
return result;
}
핵심은 비교 부분이다.
next.compareTo(result)
이 코드가 가능한 이유는
<E extends Comparable<E>>
라고 제한했기 때문이다.
문자열에서도 사용할 수 있다
다음과 같은 컬렉션이 있다고 하자.
List<String> values =
List.of(
"Black Sheep",
"White Sheep",
"Yellow Sheep"
);
그리고
String max =
max(values);
를 호출한다.
String은 Comparable<String>을 지원하므로 타입 조건을 만족한다.
따라서 문자열의 비교 규칙에 따라 가장 큰 값을 구할 수 있다.
숫자 타입에서도 Comparable 조건을 만족한다면 사용할 수 있다
예를 들어 Integer 역시 서로 비교할 수 있다.
Integer.valueOf(1)
.compareTo(2);
따라서
List<Integer> values =
List.of(
10,
20,
30
);
를 대상으로
Integer max =
max(values);
와 같은 형태로 사용할 수 있다.
재귀적 타입 한정의 목적
복잡해 보이지만 목적은 단순하다.
이 메서드는 아무 타입이나 받는 것이 아니다.
↓
서로 비교 가능한 타입만 받는다.
↓
그리고 비교 대상의 타입은
자기 자신과 호환되어야 한다.
↓
그래서 compareTo()를 안전하게 사용할 수 있다.
즉 타입 제한을 통해 메서드 내부에서 사용할 수 있는 연산을 컴파일러에게 알려주는 것이다.
아이템 29의 한정적 타입 매개변수와 연결된다
앞에서는 다음을 살펴봤다.
<E extends Number>
의 의미는
E는 Number 계층이어야 한다.
였다.
그래서 구현 안에서
value.doubleValue();
같은 Number 기능을 사용할 수 있었다.
이번에는
<E extends Comparable<E>>
다.
따라서 구현 안에서
value.compareTo(other);
를 사용할 수 있다.
둘의 원리는 같다.
타입을 특정 계약으로 제한
↓
컴파일러가 그 계약을 이해
↓
그 타입의 기능을
제네릭 구현 안에서 안전하게 사용
하는 것이다.
제네릭 메서드와 제네릭 클래스의 차이
두 개념을 구분해보자.
제네릭 클래스
public class Stack<E> {
public void push(E element) {
}
public E pop() {
...
}
}
E가 클래스 전체에서 사용된다.
객체를 만들 때 타입이 결정된다.
Stack<String> stack =
new Stack<>();
제네릭 메서드
public static <E> E identity(
E value
) {
return value;
}
타입 매개변수가 메서드 하나에만 존재한다.
메서드를 호출할 때 타입이 결정된다.
String value =
identity("hello");
어떤 것을 사용해야 할까?
타입 관계가 클래스 전체에 걸쳐 유지되어야 한다면 제네릭 클래스가 자연스럽다.
예를 들어
Stack<String>
생성 후 계속 String만 다룸
이다.
반면 호출마다 다른 타입으로 재사용할 수 있는 독립적인 알고리즘이라면 제네릭 메서드가 잘 맞는다.
union()
max()
identity()
copy()
sort()
같은 유틸리티성 연산이 대표적이다.
제네릭 클래스가 아닌 클래스에도 제네릭 메서드를 넣을 수 있다
다음과 같이 일반 유틸리티 클래스가 있다.
public final class CollectionUtils {
private CollectionUtils() {
}
}
클래스 자체에는 타입 매개변수가 없다.
하지만 각 메서드는 필요에 따라 제네릭일 수 있다.
public static <E> Set<E> union(...) {
...
}
public static
<E extends Comparable<E>>
E max(...) {
...
}
즉 클래스 전체를 제네릭하게 만들 필요가 없는 경우에는 필요한 메서드만 제네릭하게 만드는 것이 가능하다.
제네릭 메서드는 타입 관계를 문서화하기도 한다
다음 메서드를 보자.
public static <T> T identity(
T value
)
이 선언만 봐도 알 수 있다.
입력 타입
=
출력 타입
다음 메서드는
public static <T> Set<T> union(
Set<T> a,
Set<T> b
)
이므로
첫 번째 Set 타입
=
두 번째 Set 타입
=
반환 Set 타입
이라는 의도를 표현한다.
즉 제네릭 타입 매개변수는 단순히 형변환을 없애는 기능이 아니라 API 안의 타입 관계를 표현하는 언어다.
Object 기반 메서드와 비교해보자
다음 메서드를 생각해보자.
public static Object identity(
Object value
) {
return value;
}
호출한다.
String text =
(String) identity(
"hello"
);
클라이언트가 형변환해야 한다.
반면 제네릭으로 만들면
public static <T> T identity(
T value
) {
return value;
}
다음처럼 사용할 수 있다.
String text =
identity(
"hello"
);
컴파일러가 타입 관계를 알고 있으므로 명시적인 형변환이 필요하지 않다.
제네릭 메서드를 고려할 신호
실무에서 다음과 같은 코드가 반복된다면 제네릭 메서드를 검토해볼 수 있다.
Object를 받고 Object를 반환한다
Object process(Object value)
실제로는 입력과 반환 타입이 동일한 관계일 수도 있다.
그렇다면
<T> T process(T value)
가 더 적절할 수 있다.
Raw Collection을 받는다
Set merge(
Set a,
Set b
)
대신
<T> Set<T> merge(
Set<T> a,
Set<T> b
)
처럼 표현할 수 있는지 확인한다.
같은 알고리즘을 타입별로 반복한다
mergeString()
mergeInteger()
mergeOrder()
실제 로직이 같다면
<T> ...
로 일반화할 수 있는지 검토할 수 있다.
하지만 모든 메서드를 억지로 제네릭화할 필요는 없다
제네릭을 사용한다고 무조건 좋은 API가 되는 것은 아니다.
메서드가 실제로 하나의 타입만 처리해야 한다면
public void processOrder(
Order order
)
처럼 구체적인 타입을 사용하는 편이 더 명확하다.
제네릭이 적절한 상황은
알고리즘은 동일하고
타입만 달라질 수 있으며
입력과 출력 사이의 타입 관계를
표현할 필요가 있을 때
다.
현재 union 메서드에는 추가 개선 여지가 있다
현재 예제는 다음처럼 되어 있다.
public static <E> Set<E> union(
Set<E> s1,
Set<E> s2
)
같은 E 타입의 Set을 받도록 설계했다.
이것만으로도 Raw Type보다 훨씬 안전하다.
하지만 제네릭 API를 더 유연하게 만들기 위해서는 한정적 와일드카드 타입을 활용할 수도 있다.
? extends ...
? super ...
같은 문법이다.
이 부분은 제네릭 메서드가 값을 어디에서 읽고 어디에 쓰는지에 따라 API의 허용 범위를 더 넓혀주는 중요한 개념이다.
이는 다음 아이템의 핵심 주제로 이어진다.
아이템 29와 아이템 30을 연결하면
아이템 29에서는
Object를 저장하는 범용 클래스
↓
제네릭 클래스
로 만드는 방법을 살펴봤다.
예를 들어
Stack<E>
다.
아이템 30에서는 범용 알고리즘을
Object / Raw Type 기반 메서드
↓
제네릭 메서드
로 바꾼다.
예를 들어
<E> Set<E> union(...)
이다.
둘의 공통 목표는 같다.
클라이언트의 형변환 제거
+
컴파일 타임 타입 검사
+
코드 재사용성
+
API의 타입 관계 명확화
전체 흐름 정리
제네릭 메서드를 만들기 전에는 다음과 같았다.
Raw Type / Object
↓
여러 타입을 받을 수 있음
↓
범용성은 있음
하지만
↓
타입 관계를 컴파일러가 알 수 없음
↓
클라이언트 형변환
↓
Runtime ClassCastException 가능
제네릭 메서드를 사용하면
<T>
↓
여러 타입에서 재사용
+
입력과 출력의 타입 관계 표현
↓
컴파일러가 타입 검사
↓
명시적 형변환 감소
↓
더 안전한 API
가 된다.
핵심 정리
- 아이템 30의 핵심은 여러 타입에서 동일하게 사용할 수 있는 메서드를 가능하면 제네릭 메서드로 만드는 것이다.
- 컬렉션을 받아 처리하는 정적 유틸리티 메서드는 제네릭 메서드를 적용하기 좋은 대표적인 사례다.
- Raw Type 기반
union(Set, Set)은 서로 다른 타입의 Set도 받아들일 수 있어 타입 안전성을 잃을 수 있다. - 서로 다른 타입이 섞인 결과를 특정 타입이라고 가정하고 사용하면 런타임에
ClassCastException이 발생할 수 있다. public static <E> Set<E> union(Set<E>, Set<E>)처럼 제네릭 메서드로 만들면 입력과 출력 사이의 타입 관계를 컴파일러에게 알려줄 수 있다.- 제네릭 메서드의 타입 매개변수
<E>는 접근 제어자 및static뒤, 반환 타입 앞에 선언한다. - 하나의 메서드 선언 안에서 같은 이름의
E는 동일한 타입 관계를 표현한다. - 제네릭 메서드를 호출할 때는 컴파일러가 인수와 호출 문맥을 이용해 타입을 추론하므로 대부분 타입 인수를 직접 작성하지 않아도 된다.
- 제네릭 메서드를 사용하면 반환값을 사용할 때 클라이언트의 명시적 형변환을 줄일 수 있다.
- 클래스 자체가 제네릭 타입이 아니더라도 메서드 하나만 독립적으로 제네릭 메서드로 만들 수 있다.
- 제네릭 메서드는 범용성뿐 아니라 입력 타입과 출력 타입의 관계를 API에 명시적으로 표현하는 역할을 한다.
- 제네릭 메서드의 또 다른 활용법으로 제네릭 싱글턴 팩터리가 있다.
- 타입별 동작이나 상태가 없는 항등 함수처럼 모든 타입에서 동일하게 동작하는 객체는 하나의 싱글턴 객체를 재사용할 수 있다.
- 제네릭 싱글턴 팩터리는 하나의 객체를 제네릭 팩터리 메서드를 통해 클라이언트가 원하는 타입으로 제공하는 패턴이다.
- 항등 함수는
f(x) = x처럼 입력한 값을 그대로 반환하는 함수다. UnaryOperator<T>를 이용하면 하나의 타입T를 입력받아 같은 타입T를 반환하는 함수를 표현할 수 있다.- 제네릭 싱글턴 팩터리가 모든 제네릭 객체에 적용되는 것은 아니며 구체 타입에 독립적인 동작이어야 한다.
- 제네릭 메서드에서도
<E extends Number>같은 한정적 타입 매개변수를 사용할 수 있다. <E extends Comparable<E>>처럼 타입을 자기 자신과 비교 가능한 타입으로 제한하는 문법을 재귀적 타입 한정이라고 한다.E extends Comparable<E>는 E가 자기 자신의 타입과 비교할 수 있어야 한다는 의미로 이해할 수 있다.String처럼Comparable<String>을 구현한 타입은 이런 재귀적 타입 한정의 조건을 만족한다.max()와 같이 컬렉션 원소끼리 비교해야 하는 제네릭 메서드는Comparable계약이 필요하므로 재귀적 타입 한정을 활용할 수 있다.- 한정적 타입 매개변수를 사용하면 단순히 허용할 타입을 제한하는 것뿐 아니라 메서드 구현 안에서
compareTo()같은 상위 계약의 기능을 안전하게 사용할 수 있다. - 제네릭 클래스는 객체의 생명주기 동안 타입 관계를 유지하는 경우에 적합하고, 제네릭 메서드는 호출마다 독립적으로 타입을 결정할 수 있는 범용 알고리즘에 적합하다.
Object나 Raw Collection을 받고 클라이언트에게 형변환을 요구하는 유틸리티 메서드가 있다면 제네릭 메서드로 개선할 수 있는지 검토할 가치가 있다.- 제네릭 메서드의 핵심은 같은 알고리즘을 여러 타입에서 재사용하면서 입력과 출력의 타입 관계를 컴파일러에게 알려 타입 오류를 컴파일 시점에 차단하는 것이다.
- 더 유연한 제네릭 API를 만들기 위해서는 이후
? extends T,? super T같은 한정적 와일드카드 타입을 함께 활용할 수 있다.
한 줄 요약
같은 알고리즘을 여러 타입에 적용하는 유틸리티 메서드라면
<T>같은 타입 매개변수를 선언해 제네릭 메서드로 만들고, 필요하다면<T extends Comparable<T>>같은 한정적·재귀적 타입 경계까지 활용해 입력과 출력의 타입 관계를 컴파일러가 검증하도록 만드는 것이 형변환과 런타임 타입 오류를 줄이는 핵심이다.