클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법이다.
다양한 타입을 유연하게 받아야 할 필요가 있을 때 주로 사용하며
대표적인 예시가 자바의 List 의 경우이다.
List<String> stringList = new ArrayList<>();
List<Integer> integerList = new ArrayList<>();
위와 같이 사용하기 위해선 클래스나 메서드에서 제네릭을 이용해 설계를 해야하는데
간단한 예시를 통해 사용법을 알아보자
public class ItemBox<T> {
private List<T> items = new ArrayList<>();
public void add(T item) {
items.add(item);
}
}
// 다음과 같이 사용이 가능
// 원래는 Items<Integer> intItem = new Items<Integer>(); 인데 생략 가능해도 추론해서 주입
Items<Integer> intItem = new Items<>();
Items<String> intItem = new Items<>();
Items<Long> intItem = new Items<>();
Items<객체> intItem = new Items<>();
-> 타입을 선언한 해당 시점에서 클래스의 제네릭에 할당 , 속성과 메서드에 있는 제네릭 타입으로 전파
인스턴스를 생성할 때 타입을 지정하는 부분에서 클래스의 으로 전달되고 ,
나머지 속성과 메서드의 타입으로 전파가 되어 해당 타입이 사용된다.
물론 복수의 타입도 지정이 가능하다.
public class ItemBox<T, S> {
private List<T> items = new ArrayList<>();
private S num;
public void add(T item, S num) {
items.add(item);
this.num = num;
}
제네릭 타입은 사실 아무렇게나 지어도 되지만
보통 암묵적으로 아래와 같은 규칙을 따른다
| 타입 | 설명 |
|---|---|
| <"T"> | Type |
| <"E"> | Element |
| <"K"> | Key |
| <"N"> | Number |
| <"V"> | Value |
| <"R"> | Result |
제네릭 메소드는 매개타입과 리턴타입으로 타입 파리미터를 갖는 메소드를 말한다.
리턴 타입 앞에 <> 기호를 추가하고 타입 파라미터를 넣은 다음,
리턴타입과 매개타입으로 타입 파라미터를 사용.
지금까지 위에서 설명한 건 클래스에 제네릭을 정의를 한 제네릭 클래스에 해당한다.
제네릭 클래스는 클래스 전체에 걸쳐 타입 파라미터를 정의하고 사용하며,
제네릭 메서드는 클래스가 제네릭이 아니더라도 메서드 수준에서
제네릭 타입을 정의하여 사용한다.
쉽게 말해서 클래스와 독립적으로 타입을 할당하여 운영하는 것이라 생각하자
// 클래스 단위로 정의한 제네릭 클래스 , of 통해 접근하면 여긴 아무런 작동안함
public class PageResult<T> {
private List<T> items;
// 메서드에 <> 를 통해 독립적으로 정의, of 메서드 사용하면 파라미터가 이곳으로 주입
public static <U> PageResult<U> of(List<U> items) {
return new PageResult<>(items);
}
}
// 호출 부분
List<String> stringItems = Arrays.asList("Item1", "Item2", "Item3");
PageResult<String> pageReuslt = PageResult.of(stringItems)
아래와 같이 정적 팩토리 메서드 패턴과 함께 활용할 수 도 있다.
@getter
public class PageResult<T> {
private List<T> items;
private int totalPage;
private String currenPage;
private PageResult(List<T> items, int totalPage, String currenPage) {
this.items = items;
this.totalPage = totalPage;
this.currenPage = currenPage;
}
public static <T> PageResult<T> of(List<T> items, int totalPage, String currenPage) {
return new PageResult<>(items, totalPage, currenPage);
}
public List<T> getItems() {
return items;
}
public int getTotalPage() {
return totalPage;
}
public String getCurrenPage() {
return currenPage;
}
}
앞서 설명한 것처럼 동일한 클래스나 메서드를 다양한 타입에 대해 재사용이 가능해져
유연성과 코드 재사용성에 매우 강력한 강점을 지닌다. (따라서 유지보수하기 매우 편리해진다!)
제네릭이 등장하기 전까진 Object 타입을 대신 사용했었는데,
개발자의 착각으로 맞지않는 타입으로 변환하여 가져오면 런타임 에러가 발생한다.
헌데 제네릭은 컴파일 시점에 미리 잡아주므로
사전에 에러를 방지할 수 있다는 큰 이점이 존재한다.
캐스팅을 하는 과정도 작업이고 요청이 수백,수천만건이 와
캐스팅 작업을 그만큼 수행하게 되면 당연히 성능에 영향을 미치는데
제네릭은 이런 과정을 생략하여 성능 상 이점을 누릴 수 있게 한다.
변성은 타입의 상속 계층 관게에서 서로 다른 타입 간 어떤 관계가 있는지를 나태내는 지표이다.
공변성은 서로 다른 타입간 함께 변할 수 있다는 특징을 말한다.
예를 들어 배열과 리스트가 있다고 하면
공변: S 가 T 의 하위 타입이면
반공변: S 가 T 의 하위 타입이면
무공변: S 와 T 는 서로 다른 타입이다.
자바 코드를 예시로 들면
//== 배열 ==//
// 공변성
Object[] Covariance = new Integer[10];
// 반공변성
Integer[] Contravariance = (Integer[])Covariance;
//== 리스트 ==//
// 공변성
ArrayList<Object> objects = new ArrayList<Integer>();
// 반공변성
ArrayList<Integer> integers = new ArrayList<Object>();
무공변의 특징을 가진다고 할 수 있다.객체 클래스 타입은 상하 관계가 존재하며 Object 타입으로 선언한 부모 객체와 Integer 타입으로 선언한 자식 객체는 서로 캐스팅이 가능하다
Object parent = new Obect();
Integer child = new Chind();
parent = child; // 다형성 (업캐스팅)
Object parent = new Integer(1);
Integer child;
child = (Integer)parent; // 다형성 (다운캐스팅)
또한 제네릭 클래스에도 마찬가지로 이러한 특성이 적용된다.
Collection<Integer> parent = new ArrayList<>();
ArrayList<Integer> child = new ArrayList<>();
parent = child; // 다형성 (업캐스팅)
반면 제네릭의 타입 파라미터 (꺽쇠 괄호) 끼리는 캐스팅이 불가능하다
왜냐하면 앞서 설명했듯이 제네릭 타입은 무공변이기 때문이다.
ArrayList<Object> parent = new ArrayList<>();
ArrayList<Integer> child = new ArrayList<>();
parent = child; // X
child = parent;
즉, 꺽쇠 괄호 외부의 원시 타입 Raw type 에 대해서는 공변성이 적용되지만,
꺽쇠 괄호 내부의 타입 파라미터에는 공변성이 적용이 되지 않는다.
매개변수로 제네릭을 사용할 때, 외부에서 제네릭 타입 파라미터를 다르게 선언하면 컴파일 에러가 발생한다.
public static void print(List<Object> arr) {
for (Object e : arr) {
System.out.println(e);
}
}
public static void main(String[] args) {
List<Integer> integers = Arrays.asList(1, 2, 3);
print(integers); // 에러 발생
}
공변성이 적용되지 않아 캐스팅이 작동하지 않으며 발생하는 문제인 것이다.
그렇다면 반드시 외부로부터 값을 받는 매개변수 파라미터를 Integer 로 선언해주어야 할텐데,
외부에서 Integer 타입만 들어오면 기능이 매우 한정적으로 축소되고,
여러 타입을 받고 싶은 경우 수많은 오버로딩 메서드를 작성해야 하므로
여러모로 매우 매우 귀찮고 불편한 상황이 발생하게 된다.
이를 해결하기 위한 방안이 없을까?.
<?> : 와일드카드는 이와 같은 형태로 표시하며 어떤 타입이든 될 수 있다는 뜻을 가지고 있다.
하지만 단순히 <?> 로 사용하면 Object 타입이나 다름 없어지므로
다음과 같은 제네릭 타입 한정 연산자와 함께 쓰인다. extends , super
<?> : 제한 없음 모든 타입이 가능
<? extends U> : 상위 클래스 제한 (U와 그 자손들만 가능)
<? super U> : 하위 클래스 제한 (U와 그 부모들만 가능)
<? extends U>ArrayList<? extends Object> parent = new ArrayList<>();
ArrayList<? extends Integer> child = new ArrayList<>();
parent = child; // 공변성 (제네릭 타입 업캐스팅)
위와 같이 Integer 는 Object 의 하위 타입이다
ArrayList<? extneds Object> parent 는 Object 의 하위의 타입들을 받을 수 있다는 뜻이니
ArrayList<? extneds Integer> child 는 parent 에 들어갈 수 있다.
즉 공변성을 지니며 업캐스팅이 가능
<? super U>ArrayList<? super Object> parent = new ArrayList<>();
ArrayList<? super Integer> child = new ArrayList<>();
child = parent; // 반공변성 (제네릭 다운캐스팅)
반대로 Object 가 Integer 의 상위 타입일 경우, C 는 C<? super Integer> 의 하위 타입이 된다.
즉 반공변의 성질을 가지며 보통 이런 경우는 인스턴스화 된 클래스의 제네릭 보다 상위 타입의
데이터를 적재하는데 주로 이용된다.
그러면 언제 extends 를 사용하고 언제 super 를 사용해야 하는지 감이 안잡힐 수 있다.
그래서 이펙티브 자바는 PECS 라는 공식을 만들어 냈는데,
와일드카드 타입의 객체를 생성할 땐 (Produce) extends 를 사용하고,
와일드 카드 타입의 객체를 소비할 땐 (Consume) super 를 사용하라는 것이다.
아래의 예시를 보면 이해하는데 훨씬 더 도움이 될 것이다.
void printCollection(Collection<? extends MyParent> c) {
for (MyParent e : c) {
System.out.println(e);
}
}
void addElement(Collection<? super MyParent> c) {
c.add(new MyParent());
}
위와 같이 printCollection 은 파라미터 c 에 담긴 원소들을 꺼내면서 와일드 타입 객체를 생성하고 있고,
addElement 는 c 에 해당 타입 원소를 추가하면서 객체를 사용하고 있다.
와일드 카드를 사용할 때는 이러한 공식에 따라
객체를 생성할 때는 extends, 객체를 소비할 때는 super 를 사용하는 방식을 추천한다.