Search
Duplicate

Java - 제네릭(Generic)

글감
Java
작성자
작성 일자
2024/02/08 06:43
상태
완료
공개여부
공개
Date
생성자
작업자

제네릭이란?

자바에서 제너릭이란 데이터의 타입을 일반화(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
복사