제네릭이란? (타입 파라미터)
제네릭(Generic
)은 결정되지 않은 타입을 파라미터로 처리하고,
실제 사용할 때 파라미터를 구체적인 타입으로 바꾸는 기능입니다. (Java 5에서 지원 시작)
(여기서 제네릭은 기능
을 나타내는 단어라는 것에 유의해야 합니다.)
컬렉션(리스트, 맵 등)
, 스트림
, Optional
등의 내부 구현에서도 쓰이고,
활용도가 높은 만큼 꼭 이해해야 하는 자바의 기능 중 하나입니다.
다음과 같은 이점이 있습니다.
- 다양한 타입을 사용하는 클래스(인터페이스)를 만들 수 있다. (타입 변수의 다형성 지원)
- 코드가 유연해집니다.
- 불필요한 타입 변환을 제거할 수 있습니다. → 성능 개선
- 컴파일 시 강한 타입 체크를 할 수 있습니다. → 런타임 에러 방지
제네릭 이전에는 다양한 타입이 필요할 경우 Object
로 선언하고, 강제 타입 변환(Casting)을 했었습니다.
이를 생각하면 제네릭의 이점이 더 확연하게 보일 것입니다.
// 제네릭을 사용하지 않은 예
class Box {
private final String content;
public String getContent;
}
위의 코드에서 Box
의 내용물(content
)에는 String
만 들어갈 수 있습니다.
하지만 Box
에 Integer
나 Date
같은 것도 담고 싶다면, 하나의 타입마다 Box
를 새로 만들어야 합니다.
class IntegerBox {
private final Integer content;
...
}
class DateBox {
...
}
제네릭을 사용하면, 타입을 인스턴스 생성 시에 결정할 수 있습니다.
class Box<T> {
private final T content;
public T getContent {
return this.content;
}
}
Box<String> boxOfString = new Box<String>(); // 좌변과 우변이 모두 String이면 -> 우변의 String 생략 가능
Box<Integer> boxOfInteger = new Box<Integer>();
위에서는 class
단위에서 사용하는 타입에 제네릭을 사용했습니다.
더 작은 단위로 메서드 내부에서 사용하는 타입에도 제네릭을 사용할 수 있습니다.
우선 간단한 용례만 보고, 밑에서 자세히 설명하겠습니다.
public <T> T returnAsItIs(T value) {
T newValue = value; // T라는 타입을 사용할 수 있다.
return value;
}
여기서 세 가지 용어를 설명하겠습니다.
제네릭 타입
우선 위의 코드 중, Box
라는 class
내부에서 사용할 타입에 제네릭(기능)을 사용했습니다.
이렇게 제네릭(기능)을 사용하여 결정되지 않은 타입을 파라미터로 가지는 클래스
와 인터페이스
를 제네릭 타입
이라고 합니다.
위의 예시에서는 Box
가 제네릭 타입입니다.
제네릭 메서드
위의 returnAsItIs()
메서드는 결정되지 않은 타입을 파라미터로 가지고, 메서드 내부에서 사용합니다.
이런 메서드를 제네릭 메서드
라고 합니다.
타입 파라미터
제네릭 타입, 제네릭 메서드에서 가지고 있는 타입(위에서는 T)을 타입 파라미터
라고 합니다.
제네릭 타입을 생성할 때(Box
를 생성할 때 new Box<String>()
), 제네릭 메서드를 호출할 때
타입 파라미터에 정해진 타입을 지정해서 전달해야 합니다.
제네틱 타입과 제네릭 메서드
이번에는 위에서 설명한 제네릭 타입와 제네릭 메서드를 좀 더 상세하게 알아보겠습니다.
제네릭 타입의 사용
public class Box<A, B, ...> { ... }
public interface Box<A, B, ...> { ... }
위처럼 결정되지 않은 타입을 파라미터(A, B, …)로 가지는 클래스와 인터페이스를 제네릭 타입이라고 합니다.
제네릭 타입을 사용하려면, 각각의 타입 파라미터에 구체적인 타입을 지정해야 합니다. (그렇지 않으면 암묵적으로 Object
가 사용됩니다.)
public class Box<K, V> {
K key;
V Content;
public Box(K key, V content) {
this.key = key;
this.content = content;
}
public V getContentByKey(K key) {
if (key.equals(this.key)) {
return content;
}
return null;
}
Box<String, Integer> box1 = new Box<String, Integer>("key1", 1);
Box<String, Integer> box2 = new Box<>("key2", 2); // 좌변과 우변에 지정하는 타입 파라미터가 같으면 생략 가능
box1.getContentByKey("key1") // 1 반환
box2.getContentByKey("key3") // null 반환
위와 같이 인스턴스 생성시에 지정한 K(String
), V(Integer)
타입을, 제네릭 타입 내부에서 자유롭게 사용할 수 있습니다.
제네릭 메서드의 사용
public <T> Box<T> boxing(T content) {
return new Box<T>(content);
}
public <A, B> void printTwoArgument(A a, B b) {
System.out.println(a);
System.out.println(b);
}
위처럼 결정되지 않은 타입을 파라미터(A, B, …)로 가지는 메서드를 제네릭 메서드라고 합니다.
boxing()
메서드를 호출해보겠습니다.
Box<String> boxOfString = this.<String>boxing("hi"); // 타입 파라미터 전달
위의 코드처럼, 제네릭 메서드를 호출 당하는 인스턴스와에 세부 타입을 지정해서 넘겨주어야 합니다.
그런데 boxing()
은 인자로 타입 파라미터와 같은 타입을 사용합니다.
이 경우, 인자를 통해 타입 파라미터를 유추할 수 있기 때문에 생략이 가능합니다.
Box<String> boxOfString = boxing("hi") // "hi"가 String 타입이므로, T에 String 전달
제네릭 타입과 제네릭 메서드의 타입 파라미터가 중복될 때
public class Box<T> {
T content;
public Box(T content) {
this.content = content;
}
public <T> T getInputAsItIs(T value) {
return value;
}
}
위의 코드에서, boxOfString
은 제네릭 타입 Box
에 String
을 타입 파라미터 T
로 전달했습니다.
그런데 Box
내부의 메서드 getInputAsItIs()
에서 타입 파라미터 T
를 재정의합니다.
Box<String> boxOfString = new Box<>("hi");
System.out.println(boxOfString.content.getClass().getSimpleName()); // 출력 : String
System.out.println(boxOfString.getInputAsItIs(1).getClass().getsimpleName()); // 출력 : Integer
이런 경우, 위의 코드 출력에서 알 수 있듯이
메서드 내부 블록에서는 새롭게 정의한 T를 사용하고,
그 밖에서는 Box
인스턴스 생성 시 전달 받은 타입을 사용합니다.
제한된 타입 파라미터
위에서 사용한 제네릭은 타입 파라미터에 모든 타입이 들어올 수 있습니다.
하지만 내부에서 덧셈 후 결과를 반환해야 한다면, 타입을 Number
클래스의 하위 클래스들로 제한할 필요가 있습니다.
그럴 경우, 제한된 타입 파라미터를 사용할 수 있습니다.
public class NumberBox<T extends Number> {
T number;
public NumberBox(T number) {
this.number = number;
}
}
위의 NumberBox
클래스는 타입 파라미터로 Number
를 상속받은 클래스만 전달할 수 있습니다.
new NumberBox<Integer>(1); // -> 생성 성공
new NumberBox(1); // -> T에 Integer 전달
new NumberBox<String>("hi"); // 컴파일 에러 발생(Type parameter 'java.lang.String' is not within its bound; should extend 'java.lang.Number')
이처럼 NumberBox
의 타입 파라미터에는 Number
를 상속받은 클래스만 들어갈 수 있습니다.
public class IntegerBox<T super Integer> {
T integer;
...
}
위와 같이 super
키워드를 사용하면, Integer
클래스의 상위 클래스들만 들어갈 수 있습니다.
new IntegerBox<Integer>(1); // 생성 성공
new IntegerBox<Number>(1); // 생성 성공
new IntegerBox<String>("hi"); // 컴파일 에러 발생
제한된 타입 파라미터는, 타입 파라미터입니다.
따라서 타입 파라미터에만 사용될 수 있습니다.
class Box<T extends Number> { ... } // 사용 가능
public <T extends String> void printValue(T str) { ... } // 사용 가능
타입 파라미터가 아닌 곳에는 사용할 수 없습니다.
public <T> T extends String void printValue(T str) { ... } // 사용 불가능
public <T> void printValue(T extends String str) { ... } // 사용 불가능
와일드카드 타입 파라미터
제네릭 타입을 인자, 반환 타입, 변수의 타입 선언 용도로 사용할 때,
타입 파라미터로 와일드카드(?)를 사용할 수 있습니다.
의미는 다음과 같습니다.
?
: 모든 클래스 사용 가능(? extends Object
와 동일)? extends A
:A
를 상속 받은 모든 타입 사용 가능? super B
:B
의 모든 상위 타입 사용 가능
위와 같은 상속 구조를 가정하고, 와일드카드에 대해서 상세하게 알아보겠습니다.
public void register(Box<? extends Student> box) { // Student, HighStudent, MiddleStudent 만 가능
System.out.println(box.getContent());
}
Box<Student> boxOfStudent = new Box<>(new Student());
Box<? extends Student> boxOfHighStudent = new Box<>(new HighStudent());
Box<Worker> boxOfWorker = new Box<>(new Worker());
register(boxOfStudent); // 성공
register(boxOfHighStudent); // 성공
register(boxOfWorker); // 컴파일 에러
위의 코드를 통해, 제네릭 타입의 타입 파라미터 범위를 제한하기 위해 와일드카드를 사용할 수 있음을 알 수 있습니다.
하지만 제네릭 타입이 아닌 일반 타입에는 와일드카드를 쓸 수 없습니다.
public void register(? extends Student student) { ... } // 불가능
? extends Number number = 1; // 불가능
제네릭의 변성(공변, 반공변)
변성이란,
타입
을 타입 매개변수
로서 타입 생성자
에 넣었을 때 계층 관계가 유지되는지를 결정하는 성질입니다. (
?? 뭔 소리야
)
말이 좀 어려운데요,
예를 들면, Cat extends Animal
인 경우에서
List<T>(타입 생성자)
의 T(타입 매개변수)
에 Cat, Animal(타입)
을 넣으면 List<Cat>
, List<Animal>
이라는 새로운 타입이 생성됩니다.
이때 List<Cat>
이 LIst<Animal>
의 하위 타입인지, 혹은 그 반대인지, 아니면 아무런 연관이 없는지를 결정하는 것을 말합니다.
(TMI) 변성의 정의 추가 설명
제네릭 타입은 일종의 타입 생성자입니다.
class Box<T> { ... }
Box<T>
의 T
에 String
을 넣으면 Box<String>
이라는 새로운 타입이 생성됩니다.
따라서 제네릭 타입에 대해 변성을 고려해야 합니다.
타입 생성자에는 제네릭 타입만 있는 것은 아닙니다. 배열도 일종의 타입 생성자이고(int
→ int[]
), Javascript
의 Promise
나, Rust
의 Result
등도 타입 생성자입니다.
본 아티클에서는, 자바의 제네릭(Generic
)에 관한 변성에 대해서 정리해보겠습니다.
변성의 종류
변성에는 4가지 종류가 있습니다. (해당 정의가 어렵게 느껴진다면, 아래의 List 예시로 넘어가도 좋습니다.)
I
를 타입 매개변수 하나를 받는 타입 생성자라고 할 때,
- 공변(Covariance) :
S <: T
이면,I<S> <: I<T>
이다. (변환된 타입의 서브타입 관계가 기존 관계와 동일) - 반공변(Contravariance) :
S <: T
이면,I<T> <: I<S>
이다. (변환된 타입의 서브타입 관계가 기존 관계에 배반) - 이변(Bivariance) : 공변하면서 반공변한다.
- 무공변(Invariant) : 공변하지도 반공변하지도 않는다.
(S <: T
→ S
가 T
의 서브타입이다)
변성의 종류를 List로 표현
변성의 4가지 종류를 List<T>
를 예로 들어 표현해보겠습니다.
- 공변(Covariance) :
Cat
이Animal
의 하위 타입이면,List<Cat>
은List<Animal>
의 하위 타입이다.List<Animal> animals = new List<Cat>(cat1, cat2);
왼쪽의 의사코드 실행이 가능해야 함.
- 반공변(Contravariance) :
Cat
이Animal
의 하위 타입이면,List<Animal>
은List<Cat>
의 하위 타입이다.List<Cat> cats = new List<Animal>(cat1, cat2);
왼쪽의 의사코드 실행이 가능해야 함.
- 이변(Bivariance) : 공변하면서 반공변한다.
List<Animal> animals = new List<Cat>(cat1, cat2);
List<Cat> cats = new List<Animal>(cat1, cat2);
- 위의 의사코드가 모두 실행 가능해야 함.
- 무공변(Invariant) : 공변하지도 반공변하지도 않는다.
제네릭은 무공변이다
예상하셨겠지만, 자바의 제네릭은 무공변입니다.
List<Animal>
타입에 List<Cat>
을 저장할 수도,
List<Cat>
타입에 List<Animal>
을 저장할 수도 없습니다.
(반면 배열은 공변입니다.)
제네릭이 무공변이라는 것은, 타입의 다형성을 이용할 수 없다는 뜻입니다.
public List<Object> produce(List<Object> values) {
return new List.of(values);
}
List<String> strings = List.of("hi", "bye");
produce(strings);
위와 같이 코드를 짜는 것이, 다형성을 구현하는 객체지향 프로그래밍의 핵심 중 하나입니다만..
제네릭 타입은 위의 코드 실행이 불가능합니다.
하지만, 와일드카드 타입 파라미터를 사용한다면 가능하게 만들 수는 있습니다.
제네릭 공변으로 만들기
ArrayList<? extends Animal> animals = new ArrayList<Cat>();
위와 같이 extends
를 사용하면 Cat-Animal
의 관계를 ArrayList
타입 적용 후에도 공변으로 만들 수 있습니다.
public List<Object> produce(List<? extends Object> values) { ... } // 사실 ? extends Object는 그냥 ?와 동일합니다.
List<String> strings = List.of("hi", "bye");
produce(strings);
위에서 불가능했던 타입 다형성의 구현이 가능해졌습니다.
제네릭 반공변으로 만들기
ArrayList<? super Cat> cats = new ArrayList<Animal>();
위와 같이 super
키워드를 사용하면 Cat-Animal
의 관계를 ArrayList
타입 적용 후에 반공변으로 만들 수 있습니다.
class IntegerFactory {
private final List<Integer> numbers;
public IntegerFactory() {
this.numbers = new ArrayList<>();
this.numbers.add(1);
}
public void consume(List<? super Integer> values) {
values.add(this.numbers.get(0));
}
}
IntegerFactory integerFactory = new IntegerFactory();
List<Number> values = new ArrayList<>();
integerFactory.consume(values); // values를 consume 함수에 보내 1을 받아온다.
System.out.println(values); // 출력 : [1]
consume()
메서드의 인자로 List<Number>
를 보낼 수 있게 되었습니다.
제네릭 PECS
PECS
는 Producer-Extends / Consumer-Super
의 약자입니다.
눈치채셨을 수도 있는데, 위의 공변, 반공변 예시 코드에서 produce
, consume
이라는 메서드명을 사용했습니다.
외부에서 온 데이터(매개변수)
를 가지고 생산(Produce)
에 사용하면 <? extends T>
를 사용하고,
외부에서 온 데이터(매개변수)
를 가지고 소비(Consumer)
에 사용하면 <? super T>
를 사용하라는 원칙입니다.
이는 조슈아 블로흐의 이펙티브 자바에서 소개된 공식입니다.
잘만 이해하면 제네릭의 공변, 반공변성 설정을 쉽게 하도록 도와줍니다. 그런데,
이해하기가 쉽지 않네요.. 개인적으로 직관적인 공식은 아닌 것 같습니다.
생산, 소비의 기준이 ‘외부에서 온 데이터를 가지고’인데,
이는 Producer-Extends / Consumer-Super
라는 표현에 들어 있지 않습니다.
독립적인 정보로 외워야 합니다.
각설하고, 위에서 예시로 들었던 코드보다
조금 더 상세한 예시로 PECS
를 설명해보겠습니다.
class CustomList<T> {
Object[] elements = new Object[10]
int pointer = 0;
// Prodece - Extends : 공변성 부여
public void produce(List<? extends T> values) {
for (T element : values) {
elements[index++] = element;
}
}
// Consume - Super : 반공변성 부여
public void consume(List<? super T> values) {
for (Object element : this.elements) {
values.add((T)element);
}
}
}
이처럼 생산, 소비의 관점에서 extends
, super
를 구분해서 사용할 수 있습니다.
그리고 각각은 공변성, 반공변성을 구현합니다.
제네릭의 뜻과 사용법,
제한된 타입 파라미터와 와일드카드,
변성과 그것을 구현할 수 있는 PECS 공식까지 알아보았습니다.
궁금하신 점이 있다면 질문을, 잘못된 점이 있다면 지적 부탁드립니다.
감사합니다!
'Java' 카테고리의 다른 글
자동 언박싱 프로세스 (Auto-unboxing process) (0) | 2023.04.13 |
---|---|
얕은 복사, 방어적 복사, 깊은 복사, 불변 컬렉션 반환 총 정리! #JAVA (0) | 2023.04.06 |
다중 상속한 Interface의 default 메서드가 겹치면 어떻게 될까? (0) | 2023.03.15 |
Null을 안전하게 사용하기, Optional 올바르게 사용하기 (0) | 2023.03.02 |
[Java] 특정 문자로 문자열 연결하기 #String.join (0) | 2022.12.11 |