자바를 배우면서 어려웠던 개념 중 하나라서 정리하고자 작성한다.
먼저 오라클 재단의 설명부터 보자.
간단히 말해, 제네릭은 클래스, 인터페이스 및 메서드를 정의할 때 (클래스 및 인터페이스의) Type이 매개 변수가 되도록 합니다. 메소드 선언에서 사용되는 친숙한 형식 매개변수처럼, Type 매개변수는 다른 입력으로 동일한 코드를 재사용할 수 있는 방법을 제공합니다. 둘의 차이점은 형식 매개변수에 대한 입력은 values고, Type매개 변수는 Type이라는 것입니다. - Oracle, Why Use Generics?
즉 클래스, 인터페이스, 메서드를 정의할때 클래스 / 인터페이스의 Type을 매개변수로 하는 것을 제네릭이라고 하는 것을 알 수 있는데,
그런데 이걸 어떻게 사용하고, 왜 쓸모가 있는지는 이해하기가 힘들다.
특히 자바는 정적언어이기 때문에, 더더욱 그러한 면이 많다고 생각한다. 따라서 나는 이걸 JavaScript
를 정적타입으로 명시하게 만든 TypeScript
로 이해하기로 했다.
일단 먼저 TypeScript
에서 배열의 가장 첫번째에 값을 넣어주는 함수를 만들었다고 생각해보자.
function insertAtBetinning(array: any[], value: any) {
const newArray = [value, ...array];
return newArray;
}
const demoArray = [1, 2, 3];
const updatedArray = insertAtBeginning(demoArray, -1); // [-1, 1, 2, 3]
여기서 가장 문제는 updatedArray
의 배열이 any로 설정이 되는 것이다. 왜냐하면 TS는 demoArray
에 숫자만 들어간 것을 모른다. 왜냐면 함수에서 any를 사용하기로 했기 때문이다.
이렇게 되면 updatedArray
는 any객체가 들어가 있는 배열로 TypeScript
가 정해 버렸기 때문에 TypeScript
의 도움을 받을 수 없다. 사실상 JavaScript
의 일반적인 배열과 똑같은 것이다.
그러면 어떤 문제가 발생하는가?
const updatedArray = insertAtBeginning(demoArray, -1); // [-1, 1, 2, 3]
updatedArray[0].split(''); // 컴파일 단계에서 오류를 체크해주지 않음!!!
String.prototype.split()
함수는 String
타입에서만 이용되는 함수지만 컴파일 단계에서 이러한 오류를 잡아주지 않는다!
왜냐하면 앞서 말햇듯이 updatedArray
는 any객체가 들어가 있는 배열로, JS의 일반적인 배열과 똑같은 것이기 때문이다.
TypeScript
를 사용하는 이유 중 하나가 타입 에러들을 컴파일 단계에서 잡기 위함인데, 이를 체크하지 못한다는 것은 매우 큰 문제다.
그렇다면 이를 어떻게 해결해야할까?
function insertAtBeginning(array: any[], value: any): number[] {
const newArray = [value, ...array];
return newArray;
}
함수에서 return으로 나올 값을 타입추론을 통해 정해줄 수 도 있다.
하지만 이렇게 되면 number
배열만 반환하므로 재사용성이 떨어지게 된다.
이러한 문제점을 해결하기 위해 나온것이 바로 제네릭이라고 할 수 있다.
function insertAtBeginning<T>(array: T[], value: T): T[] {
const newArray = [value, ...array];
return newArray;
}
이전의 함수를 다시 이렇게 짜보자.
여기서 T는 타입을 의미하지만, 무조건 T로만 해야하는 것은 아니고, 다른 값을 넣어도 문제없이 작동한다. 암묵적인 규칙일 뿐이다.
이렇게 짜게 된다면, insertAtBeginning
의 반환 array는 항상 T 타입 값으로만 채워질 테고, value또한 T만 가능할 것이다.
그래서 앞의 예시를 똑같이 들고오면, String.prototype.split()
를 적용하려 할 때, 컴파일 단계에서 오류를 짚어낼 수 있게 된다.
왜냐하면 updatedArray
가 number로만 채워진 array라는 것을 인식했기 때문이다.
뿐만 아니라 이 제네릭 함수로 string 배열을 짜게 되더라도 똑같이 array를 string array로 인식하게 된다.
즉 재사용성도 충분히 손상되지 않는다는 것이다.
정리하자면, 이처럼 제네릭을 사용할 경우, 코드에 타입 안정성과 유연성/재사용성을 가져다주기 때문에, 사용하는 것이다.
이러한 제네릭은 함수에서만 사용되는 것이 아니다.
클래스, 인터페이스에도 똑같이 적용이 될 수 있다.
interface GenericI<T> {
one: T;
other: T;
plus: () => T[];
}
class GenericC<T> implements GenericI<T> {
public one: T
public other: T
constructor(one:T, other:T) {
this.one = one
this.other = other
}
plus = ():T[] => {
return [this.one, this.other]
}
}
const exam1 = new GenericC(1, 2);
const exam2 = new GenericC("A", "B");
const exam1NumberArr = exam1.plus();
const exam2StringArr = exam2.plus();
console.log(exam1NumberArr); // [ 1, 2 ]
console.log(exam2StringArr); // [ 'A', 'B' ]
다음과 같은 예시가 마찬가지다.
참고로 위와 같이, 같은 타입을 넣지 않는 경우 이렇게 오류가 나오게 된다.
앞서 타입스크립트에서 실행했던 제네릭도 자바에서 동일하게 적용이 된다.
interface ExInterfaceGenerics<T> {
public void Say(T t);
}
class ExClassGenerics<T> implements ExInterfaceGenerics<T> {
private T t;
void set(T t) {
this.t = t;
}
@Override
public void Say(T type) {
System.out.println("method type is " + type.getClass().getName());
System.out.println("class type is " + t.getClass().getName());
}
}
class Main {
public static void main(String[] args) {
ExClassGenerics<String> a = new ExClassGenerics<String>();
ExClassGenerics<Integer> b = new ExClassGenerics<Integer>();
a.set("100");
b.set(100);
a.Say("100");
b.Say(100);
}
}
이 경우를 살펴보면 인터페이스와 클래스가 타입스크립트의 경우와 비슷하게 제네릭이 적용되어 있음을 알 수 있다.
자바에서 약간 독특한건 제네릭 메소드의 경우라고 할 수 있다.
바로 클래스 내부의 함수라고 할 수 있는 메소드에서 또 다시 제네릭을 걸 수 있다는 소리다.
위의 예시에 한번 추가해서 적용해보자.
interface ExInterfaceGenerics<T> {
public void Say(T t);
}
class ExClassGenerics<T> implements ExInterfaceGenerics<T> {
private T t;
void set(T t) {
this.t = t;
}
<E> E genericMethod(E e) {
return e;
}
@Override
public void Say(T type) {
System.out.println("method type is " + type.getClass().getName());
System.out.println("class type is " + t.getClass().getName());
}
}
class Main {
public static void main(String[] args) {
ExClassGenerics<String> a = new ExClassGenerics<String>();
ExClassGenerics<Integer> b = new ExClassGenerics<Integer>();
a.set("100");
b.set(100);
System.out.println("a return type is " + a.getClass().getName());
System.out.println("return type is " + a.genericMethod(1).getClass().getName());
}
}
결과
즉, ExClassGenerics
클래스에는 a
가 제네릭을 String
으로 선언했음에도 불구하고, 내부의 메소드를 통해, Integer
를 넣고 그 타입을 반환할 수 있다는 소리다.
이러한 제네릭 메소드는 static
에 유용하게 쓰인다. 제네릭 메소드는 a
의 경우 처럼 객체를 인스턴스화 할 때 매개변수로 넘긴 타입이 지정이 된다.
하지만 static
은 객체 생성 이전에, 자바를 실행할 경우 이미 메모리에 올라가 있으며, 클래스 이름을 통해 바로 사용한다. 그렇게 된다면, 제네릭 메소드가 없을 경우를 생각하면 static
메소드는 타입을 얻을 수가 없다.
따라서 static
메소드에 제네릭을 적용하고 싶으면, 제네릭 클래스와 별도로 독립적인 제네릭 메소드를 사용하는 것이다.
interface ExInterfaceGenerics<T> {
public void Say(T t);
}
class ExClassGenerics<T> implements ExInterfaceGenerics<T> {
private T t;
void set(T t) {
this.t = t;
}
static <E> E staticGenericMethod(E e) {
return e;
}
@Override
public void Say(T type) {
System.out.println("method type is " + type.getClass().getName());
System.out.println("class type is " + t.getClass().getName());
}
}
class Main {
public static void main(String[] args) {
ExClassGenerics<String> a = new ExClassGenerics<String>();
ExClassGenerics<Integer> b = new ExClassGenerics<Integer>();
a.set("100");
b.set(100);
System.out.println("a return type is " + a.getClass().getName());
System.out.println("return type is " + ExClassGenerics.staticGenericMethod(1).getClass().getName());
}
}
결과
즉 이렇게 사용할 수 있다는 것이다.
제네릭의 이름에 임의의 이름을 붙여도 성립 하지만, 일단 자바에서 정하고 있는 규칙은 다음과 같다.
타입인자 | 설명 |
---|---|
T | Type, 타입 |
E | Element, 요소 |
K | Key, 키 |
V | Value, 값 |
N | Number, 숫자 |
여기서 T와 E는 거의 구별하기 힘들지만, 대체적으로 E가 Collection
과 같은 곳에서 자주 쓰인다고 한다. -출처
K와 V는 HashMap과 같이 키-값이 쌍으로 있는 곳에서 자주 쓰이며 N은 나중에 서술할 숫자만 사용하는 제네릭에서 쓰인다.
그럼 이제 제네릭 제약에 대해서 보자.
제네릭 제약은 말 그대로, 제네릭에 대한 범위를 설정하는 것으로, 자바에서 수많은 클래스들이 상속을 하고/받고 있는데 이 범위를 지정할 수 있다는 것이다.
상속의 예시를 들어서 보자.
다음과 같은 상속관계를 맺고있는 클래스들이 있다고 가정해보자.
아래의 경우 extends
를 통한 4가지 제약의 경우를 보자.
<T extends Grandchild>
: Grandchild 타입만 사용 가능.<T extends Son>
: Son, Grandchild 타입 사용 가능<T extends Daughter>
: Daughter 타입만 사용가능<T extends Parents>
: Parents, Son, Grandchild, Daughter 타입 사용 가능extends
라는 말이 상속을 의미하듯이, 상속된 타입까지 가능하다는 소리다.
이걸 응용해서 사용한 것이, 앞서 말한 N
을 쓰는 경우, 즉 수를 표현하는 클래스만 받는 경우다.
Integer
, Long
, Byte
, Double
, Float
, Short
와 같은 클래스들은 모두 Number
클래스를 상속받기 때문에 이렇게 사용 가능하다.
class NumberGenericsClass <N extends Number> {
private N n;
void set(N n) {
this.n = n;
}
}
public class NumberGenerics {
public static void main(String[] args) {
NumberGenericsClass<Integer> n1 = new NumberGenericsClass<Integer>();
NumberGenericsClass<Double> n2 = new NumberGenericsClass<Double>();
n1.set(1);
n2.set(4.1);
NumberGenericsClass<String> n3 = new NumberGenericsClass<String>(); // String은 Number를 extends하고 있지 않으므로 에러발생
}
}
이런 경우가 대표적인 예시라고 할 수 있을 것이다.
반대로 super는 조상의 타입으로 거슬러 올라가서 제약을 두는 경우다.
위의 예시로 설명하면 다음과 같다.
<? super Grandchild>
: Grandchild, Son, Parents 타입 사용 가능.<? super Son>
: Son, Parents 타입 사용 가능<? super Daughter>
: Daughter, Parents 타입 사용가능<? super Parents>
: Parents타입만 사용 가능그런데 여기서는 T가 아니라 ?, 물음표를 쓴다. 그렇다면 이 물음표는 무엇인가?
물음표는 와일드 카드를 뜻하며, 와일드카드의 의미는 정해지지 않은 unknown type
이다.
즉 어떤 타입이든 받아서 출력가능하다는 것이다.
또한 앞서 설명했듯이 일반적인 제네릭과 다르게, 와일드카드는 super
라는 제약도 사용 가능하다는 점이 차이점이다.