Search

제네릭(Generic)

생성일
2023/07/21 10:24
태그

제네릭이란?

자바에서 제너릭이란 데이터의 타입을 일반화(generalize)한다는 의미로, 클래스나 메소드에서 사용할 내부 데이터 타입을 외부에서 지정하여 컴파일 시에 미리 확인하는 방법이다.
제너릭을 사용하면 클래스나 메소드의 내부에서 사용되는 객체의 타입 안정성을 높일 수 있다.
컴파일 시 타입 검사를 수행하기 때문에 반환값에 대한 타입 변환 및 타입 검사에 들어가는 노력을 줄일 수 있다.
비슷한 기능을 지원하는 경우 코드 재사용성을 높일 수 있다.

제네릭의 선언 및 생성

class MyArray<T> { T element; void setElement(T element) { this.element = element; } T getElement() { return element; } } interface MyMap<K, V> { ... }
Java
복사
제네릭은 위와 같이 선언할 수 있는데, T를 타입 변수라고 하며 임의의 참조형 타입을 의미한다.
꼭 T를 사용할 필요는 없지만, T(ype), E(lement), K(ey), V(alue), N(umber)와 같은 표현들이 많이 사용된다.
인스턴스화 될 때 실행부에서 <T> 부분을 받아와 내부에 T 타입으로 지정한 멤버들에게 전파하여 타입이 설정되는데, 이를 구체화(Specialization)이라 한다.
이와같이 선언된 제네릭 클래스를 생성 시에는 타입 변수 자리에 실제 사용할 타입을 명시해야 하고, 자바 7부터는 다이아몬드 연산자를 통해 생략할 수 있다.
MyArray<Integer> myArr = new MyArray<Integer>(); // Java SE 7 이후 MyArray<Integer> myArr = new MyArray<>();
Java
복사
제네릭에 할당 할 수 있는 타입은 Reference 타입만 가능하며, int형이나 double형 같은 자바 원시(Primitive Type)은 제네릭에 타입 파라미터로 넘길 수 없다. 또한 List<Integer>와 같은 컬렉션도 매개변수화 타입으로 넣을 수 있는데, 이를 중첩 타입 파라미터라 한다.

제네릭 사용 이유

컴파일 타임에 타입 검사를 통해 오류를 발견하여 ClassCastException과 같은 에러가 런타임 도중에 발생하지 않도록 할 수 있다.
제네릭은 미리 타입을 지정하고 제한하기 때문에 형 변환의 번거로움을 줄이고, 불필요한 캐스팅이나 타입 검사에 들어가는 메모리를 줄인다.
가독성이 좋아진다.

제네릭 사용 시 주의사항

제네릭 타입은 실체화(생성)가 불가능하다. 다시말해 new 연산자 뒤에 제네릭 타입 파라미터가 올 수 없다.
class Sample<T> { public void someMethod() { // Type parameter 'T' cannot be instantiated directly T t = new T(); } }
Java
복사
정적(static) 멤버에 제네릭 타입이 올 수 없다. static 멤버는 클래스가 동일하게 공유하는 변수로써 제네릭 객체가 생성되기 전에 자료형이 정해져 있어야 하기 때문에 사용할 수 없다.
class Student<T> { private String name; private int age = 0; // static 메서드의 반환 타입으로 사용 불가 public static T addAge(int n) { } }
Java
복사
제네릭으로 배열을 선언 시 사용에 주의해야한다. 기본적으로 제네릭 클래스 자체는 배열로 만들 수 없지만, 제네릭 타입의 배열은 선언이 허용된다.
class Sample<T> { } /* public class Main { public static void main(String[] args) { // 선언 불가 - 에러 발생 Sample<Integer>[] arr1 = new Sample<>[10]; } } */ public class Main { public static void main(String[] args) { // new Sample<Integer>() 인스턴스만 저장하는 배열을 나타냄 Sample<Integer>[] arr2 = new Sample[10]; // 제네릭 타입을 생략해도 위에서 이미 정의했기 때문에 Integer 가 자동으로 추론됨 arr2[0] = new Sample<Integer>(); arr2[1] = new Sample<>(); // ! Integer가 아닌 타입은 저장 불가능 arr2[2] = new Sample<String>(); } }
Java
복사
배열과는 달리 제네릭은 서브 타입 간에 형변환이 불가능하다. 제네릭을 전달받은 그 타입으로만 서로 캐스팅이 가능하다. 제네릭은 타입 파라미터가 오직 똑같은 타입만 받기 때문에 다형성을 이용할 수 없어 이런 제네릭 캐스팅 문제가 있다.
// 배열은 OK Object[] arr = new Integer[1]; // 제네릭은 ERROR List<Object> list = new ArrayList<Integer>();
Java
복사
객체의 상속과 다형성 + 제네릭

제네릭 클래스와 제네릭 인터페이스

클래스 선언문 옆에 제네릭 타입 매개변수가 사용되면 제네릭 클래스라 부른다.
class Sample<T> { private T value; // 멤버 변수 val의 타입은 T 이다. // T 타입의 값 val을 반환한다. public T getValue() { return value; } // T 타입의 값을 멤버 변수 val에 대입한다. public void setValue(T value) { this.value = value; } }
Java
복사
인터페이스에 제네릭을 적용하면 제네릭 인터페이스라 부르며, 구현하는 클래스에서도 오버라이딩한 메서드를 제네릭 타입에 맞춰 똑같이 구현해야한다.
interface ISample<T> { public void addElement(T t, int index); public T getElement(int index); } class Sample<T> implements ISample<T> { private T[] array; public Sample() { array = (T[]) new Object[10]; } @Override public void addElement(T element, int index) { array[index] = element; } @Override public T getElement(int index) { return array[index]; } }
Java
복사
제네릭 함수형 인터페이스

제네릭 메서드

제네릭 메서드란 메서드의 선언부에 타입 변수 <T>를 사용한 메서드를 의미하고, 이 때의 타입 변수 선언은 메소드 선언부에서 반환 타입 바로 앞에 위치한다.
public static <T> void sort( ... ) { ... }
Java
복사
제네릭 클래스 내부의 제네릭 메서드는 별개의 의미를 가진다. 단순히 제네릭 타입 파라미터를 사용하는 메서드는 제네릭 메서드가 아니다.
// 제네릭 클래스 class ClassName<E> { private E element; // 제네릭 타입 변수 void set(E element) { // 제네릭 파라미터 메서드 this.element = element; } E get() { // 제네릭 타입 반환 메서드 return element; } static <T> T genericMethod(T o) { // 제네릭 메서드 return o; } }
Java
복사
위의 예시에서는 파라미터 타입에 따라 T 타입이 결정된다.
이런 방식은 클래스 객체로 인스턴스를 생성할 때 <> 사이에 파라미터로 넘겨준 타입으로 지정되는 제너릭에서, static으로 선언하여 프로그램 실행 시 메모리에 올려 클래스 이름으로 사용하기 위해 정적 메소드로 선언하여 사용된다.
정적 메소드로 선언된 제네릭 메소드는 제네릭 클래스와 별도로 독립적인 제네릭이 사용되어야 한다.
// 제네릭 클래스 class ClassName<E> { private E element; // 제네릭 타입 변수 void set(E element) { // 제네릭 파라미터 메서드 this.element = element; } E get() { // 제네릭 타입 반환 메서드 return element; } /* error! static E genericMethod(E o) { return o; } */ // 아래 메소드의 E타입은 제네릭 클래스의 E타입과 다른 독립적인 타입이다. static <E> E genericMethod1(E o) { // 제네릭 메서드 return o; } static <T> T genericMethod2(T o) { // 제네릭 메서드 return o; } }
Java
복사
제네릭 메서드를 호출할 때는 아래와 같이 메서드 왼쪽에 제네릭 타입을 지정하여 호출해야 한다.
FruitBox.<Integer>addBoxStatic(1, 2); FruitBox.<String>addBoxStatic("안녕", "잘가");
Java
복사

타입 변수의 제한(범위 한정하기)

제네릭에 타입을 지정해줌으로써 클래스의 타입을 컴파일 타임에서 정하여 타입 예외에 대한 안전성을 확보하는 것은 좋지만 너무 자유롭다는 문제가 있다.
제네릭을 만든 의도에 맞춰 들어올 수 있는 타입 파라미터를 제한하는 방법을 제한된 타입 매개변수(Bounded Type Parameter)라고 부른다.
제네릭은 extends 키워드를 사용하여 타입 변수에 특정 타입만 사용하도록 제한할 수 있다.
class AnimalList<T extends LandAnimal> { ... }
Java
복사
클래스가 아니라 인터페이스를 구현할 때에도 implements 키워드가 아닌 extends 키워드를 사용해야한다. 인터페이스 타입 한정을 두게 되면, 해당 인터페이스를 구현한 클래스만 제네릭 타입으로 받을 수 있다.
interface Readable { ... } // 인터페이스를 구현하는 클래스 public class Student implements Readable { ... } // 인터페이스를 Readable를 구현한 클래스만 제네릭 가능 public class School <T extends Readable> { ... }
Java
복사
클래스와 인터페이스를 동시에 상속받고 구현하고 싶거나 여러 인터페이스를 구현하고 싶다면 엠퍼센트(&) 기호를 사용하면 된다. 이를 다중 타입 한정이라 부르고, 클래스의 경우에는 다중 extends가 불가능하지만 인터페이스의 경우에는 가능하다.
class LandAnimal { ... } interface WarmBlood { ... } class AnimalList<T extends LandAnimal & WarmBlood> { ... }
Java
복사
아래와 같이 제네릭 타입이 여러 개인 경우에 각각 다중 제한을 거는 것도 가능하다.
interface Readable {} interface Closeable {} interface Appendable {} interface Flushable {} class School<T extends Readable & Closeable, U extends Appendable & Closeable & Flushable> void func(T reader, U writer){ } }
Java
복사

재귀적 타입 한정

자기 자신이 들어간 표현식을 사용하여 타입 매개변수의 허용 범위를 한정 시키는 것을 재귀적 타입 한정이라 부른다.
List<E extends Comparable<E>>와 같이 제네릭 E의 타입 범위를 Comparable<E>로 한정하는 중첩 표현식을 사용한다. 여기서는 타입 E가 자기 자신을 서브 타입으로 구현한 Comparable 구현체로 한정한다는 의미가 된다.
class Compare { // 외부로 들어온 타입 E는 Comparable<E>를 구현한 E 객체 이어야 한다. public static <E extends Comparable<E>> E max(Collection<E> collection) { if(collection.isEmpty()) throw new IllegalArgumentException("컬렉션이 비어 있습니다."); E result = null; for(E e: collection) { if(result == null) { result = e; continue; } if(e.compareTo(result) > 0) { result = e; } } return result; } }
Java
복사
public static void main(String[] args) { Collection<Integer> list = Arrays.asList(56, 34, 12, 31, 65, 77, 91, 88); System.out.println(Compare.max(list)); // 91 Collection<Number> list2 = Arrays.asList(56, 34, 12, 31, 65, 77, 91, 88); System.out.println(Compare.max(list2)); // ! Error - Number 추상 메서드는 Comparable를 구현하지않았기 때문에 불가능 }
Java
복사

공변성과 반공변성

변성(covariant)의 의미는 함께 변한다는 의미로, 타입의 상속 계층 관계에서 서로 다른 타입 간에 어떤 관계를 가지는지 나타내는 지표이다.
공변
S가 T의 하위 타입이면
S[]는 T[]의 하위 타입이다.
List<S>는 List<T>의 하위 타입이다.
반공변
S가 T의 하위 타입이면,
T[]는 S[]의 하위 타입이다.
List<T>는 List<S>의 하위 타입이다.
무공변/불공변
S와 T는 서로 관계가 없다.
List<S>와 List<T>는 서로 다른 타입이다.
공변성과 반공변성은 업캐스팅, 다운캐스팅을 말하는 것과 비슷하다.
// 공변성 Object[] Covariance = new Integer[10]; // 반공변성 Integer[] Contravariance = (Integer[]) Covariance;
Java
복사
위의 배열 코드는 잘 돌아가지만, 아래의 제네릭 코드는 자바에서는 제네릭 타입에 대해 공변성과 반공변성을 지원하지 않기 때문에 돌아가지 않는다.
// 공변성 ArrayList<Object> Convariance2 = new ArrayList<Integer>(); // 반공변성 ArrayList<Integer> Contravariance2 = new ArrayList<Object>();
Java
복사
그런 이유로 자바에서의 제네릭은 무공변성을 가진다고 할 수 있다.
제네릭이 무공변성을 가지기 때문에 와일드 카드 나오게 되었다.

와일드카드

와일드카드(wildcard)란 이름에 제한을 두지 않음을 표현하는데 사용되는 기호를 의미하고, 제네릭에서는 물음표(?) 기호를 사용하여 표현한다. 어떤 타입이든 될 수 있다는 의미로 사용된다.
단순히 <?>만 사용하면 Object 타입과 다름이 없기 때문에, 제네릭 타입 한정 연산자와 함께 사용된다.
<?> // Unbounded Wildcards : 타입 변수에 모든 타입을 사용 가능 <? extends T> // Upper Bounded Wildcards : T 타입과 T 타입을 상속받는 자식 클래스 타입만 사용 가능 <? super T> // Lower Bounded Wildcards : T 타입과 T 타입이 상속받은 조상(부모) 클래스 타입만 사용 가능
Java
복사
위에서 설명했듯 자바의 제네릭은 기본적으로 공변, 반공변을 지원하지 않는다(정확히 타입 매개변수로 전달받은 타입만 받을 수 있다). 하지만 상한, 하한 경계 와일드 카드를 사용하면 제네릭의 공변과 반공변이 적용되도록 설정할 수 있다.
상한 경계 와일드카드를 통한 공변
ArrayList<? extends Object> parent = new ArrayList<>(); ArrayList<? extends Integer> child = new ArrayList<>(); parent = child; // 공변성 (제네릭 타입 업캐스팅)
Java
복사
하한 경계 와일드카드를 통한 반공변
ArrayList<? super Object> parent = new ArrayList<>(); ArrayList<? super Integer> child = new ArrayList<>(); child = parent; // 반공변성 (제네릭 다운캐스팅)
Java
복사
비한정적 와일드카드를 사용하면 어떠한 타입도 받을 수 있지만, 매개변수를 꺼내거나 저장할 때 논리적 에러가 발생하게 된다. 하지만 extends, super를 통해 와일드카드의 경계를 정해주면 경고는 발생하더라도 오류는 발생하지 않는다.
public MyArrayList(Collection<?> in) { for (T elem : in) { // in이 ? 타입이므로 원소를 꺼낼 때 어떤 타입인지 알 수 없어 논리 에러 발생 element[index++] = elem; } } public void clone(Collection<?> out) { for (Object elem : element) { // out이 ? 타입이므로 어떤 타입을 저장하는 지 알 수 없어 논리 에러 발생 out.add((T) elem); } }
Java
복사
List<? extends U>
데이터를 U 타입으로 꺼낼 수 있지만, null을 제외한 어떠한 타입도 넣을 수 없다.
꺼내는 경우에는 U의 하위 타입 A와 B로 각각 꺼낼 경우 형제 캐스팅이 불가능하기 때문에 U 타입으로만 안전하게 꺼낼 수 있다.
저장하는 경우에는 A타입으로 받을 경우 B 타입을 저장할 수 없기 때문에 A와 B 모두 받을 수 없고, U 타입으로 받을 수 있지만 위의 논리 오류 때문에 그냥 컴파일 에러로 처리된다.
List<? super U>
Object 타입으로 꺼낼 수 있고, U와 U의 자손 타입만 넣을 수 있다.
형제 캐스팅이 불가능하다는 위의 논리 오류로 인해 와일드카드의 최상위인 Object 타입으로만 안전하게 꺼낼 수 있다.
List 내부에 U 타입의 어떤 조상 타입이 들어와있을지 모르기 때문에, 업캐스팅 가능한 상한인 U 타입과 그 자손 타입들만 받을 수 있다.
List<?>
super의 특징에 따라 안전하게 꺼내기 위해서는 Object 타입으로만 꺼낼 수 있다.
extends 특징에 따라 null을 제외한 어떠한 타입의 자료도 넣을 수 없다.
이러한 개념에서 유도된 공식이 PECS(Producer-Extends, Consumer-Super)이다.
데이터를 생산(Producer) 한다면 <? extends T>로 하위 타입으로 제한한다. <? extends T> 컬렉션에서 데이터를 꺼내어 내부 변수에 복사한다(in).
class MyArrayList<T> { Object[] element = new Object[5]; int index = 0; // 외부로부터 리스트를 받아와 매개변수의 모든 요소를 내부 배열에 추가하여 인스턴스화 하는 생성자 public MyArrayList(Collection<? extends T> in) { for (T elem : in) { element[index++] = elem; } } ... }
Java
복사
데이터를 소비(Consumer) 한다면 <? super T>로 상위 타입으로 제한한다. 다른 곳에서 사용하기 위해 내부 변수로부터 <? suepr T> 컬렉션에 데이터를 넣는다(out).
class MyArrayList<T> { Object[] element = new Object[5]; int index = 0; ... // 외부로부터 리스트를 받아와 내부 배열의 요소를 모두 매개변수에 추가해주는 메서드 public void clone(Collection<? super T> out) { for (Object elem : element) { out.add((T) elem); } } }
Java
복사
와일드카드는 설계가 아닌 사용이 목적인 기능이다. 아래와 같이 클래스나 인터페이스를 설계할 때 사용하게 되면 에러가 발생을 한다. 이미 만들어진 제네릭 클래스나 메서드를 사용할 대 와일드카드를 범위 한정하여 사용한다.
class Sample<? extends T> { // ! Error }
Java
복사
그렇기 때문에 <T extends 타입> 같은 경우에는 제네릭 클래스를 설계할 때 사용하고, <? extends U> 같은 경우에는 이미 만들어져있는 제네릭 클래스를 인스턴스화하여 사용할 때 적어주는 것이다.
<T super 타입> 같은 표현은 없는데, 그 이유는 무수히 많은 자바의 클래스들과 인터페이스를 받을 수 있는 경우로 쓸모 없는 코드가 되기 때문이다.
<?>는 <Object>와 다르다. List<Object>에는 Object 하위 타입 모두 넣을 수 있지만, List<?>에는 null만 넣을 수 있다. 이는 타입 안전성을 지키기 위한 제네릭 특성이다.

실체화 타입과 비실체화 타입

실체화 타입(Reifiable Type)은 컴파일 단계에서 타입소거에 의해 지워지지 않는 타입 정보이다.
int, double, float 등 원시 타입
Number, Integer 등 일반 클래스와 인터페이스 타입
List, ArrayList, Map 등 Raw 타입
List<?>, ArrayList<?> 등 비한정 와일드카드가 포함된 매개변수화 타입
→ 이 경우에는 와일드카드를 소거해도 Raw 타입처럼 동작하기 때문에 실체화 타입으로 본다.
비실체화(Non-Reifiable Type)은 컴파일 단계에서 타입소거에 의해 타입 정보가 지워지는 타입을 말한다. 제네릭 타입 파라미터는 모두 제거된다고 보면 된다.
List<T>, List<E>
List<Number>, List<String>
List<? extends Number>, List<? suepr String>

제네릭 타입 소거 과정

class Box<T extends Number> { List<T> list = new ArrayList<>(); void add(T item) { list.add(item); } T getValue(int i) { return list.get(i); } }
Java
복사
1.
제네릭 타입의 경계(bound)를 제거한다.
<T extends Number>이면 하위의 T는 Number로 치환된다.
<T>는 Objcet로 치환된다.
class Box { List list = new ArrayList(); // Object void add(Number item) { list.add(item); } Number getValue(int i) { return list.get(i); } }
Java
복사
2.
제네릭 타입을 제거한 후 타입이 일치하지 않는 곳은 형변환을 추가한다.
class Box { List list = new ArrayList(); // Object void add(Number item) { list.add(item); } Number getValue(int i) { return (Number) list.get(i); // 캐스팅 연산자 추가 } }
Java
복사