제네릭은 왜 필요할까?
자바에서는 여러 클래스와 인터페이스를 제공하고 있다. 다양한 클래스와 인터페이스를 활용하고자하면 많은 가짓수의 클래스를 생성하거나 오버로딩을 수행해야 할 것이다. 이러한 비효율성을 해결해주는 문법 요소가 제네릭이다.
예를 들어 사과와 연필을 각각 저장 및 관리하려고 할 때를 생각해보자.
class Apple{}
class Pencil{}
class Goods1 {
private Apple apple = new Apple();
public Apple get() {
return apple;
}
public void set(Apple apple) {
this.apple = apple;
}
}
class Goods2 {
private Pencil pencil = new Pencil();
public Pencil get() {
return pencil;
}
public void set(Pencil pencil) {
this.pencil = pencil;
}
}
Goods1 클래스는 사과만 관리하는 클래스이고, Goods2는 연필만 관리하는 클래스이다. 만약 이후에 새로운 상품이 추가된다면 Goods 클래스가 더 늘어나거나 한 클래스 내에 멤버 변수, getter, setter 메서드가 더욱 많아질 것이다.
그렇다면 이에 대한 해결책은 무엇이 있을까?
먼저 생각할 수 있는 것은 모든 자바 클래스의 최상위 클래스인 Object 타입으로 모든 필드를 선언해서 모든 클래스를 관리하는 것이다. 하지만 이 경우에는 setter를 통해 데이터를 가져올 때 문제가 생길 것이다.
Goods goods = new Goods();
goods.set(new Apple());
Pencil pen = (Pencil)goods.get();
저장된 데이터를 꺼내올 때는 저장된 형태로 캐스팅해야 하는데, 이 때 저장은 사과를 했지만 가져오는 Object 객체를 연필로 캐스팅하려고 하면 ClassCastException이라는 실행 예외가 발생할 것이다.
실행 예외는 약한 타입 체크(weak type checking)이기 때문에 잘못된 타입 캐스팅이 일어나도 문법 오류가 생기지 않지만 실행 중에 예외를 발생시키므로 좋지 않다.
이러한 약한 타입 체크를 발생시키지 않고 여러 클래스나 인터페이스를 다룰 수 있는 해결법이 '제네릭'이다.
제네릭을 사용하면 기본적으로 모든 타입의 클래스, 인터페이스를 저장할 수 있으면서도 잘못된 캐스팅을 할 때 문법 오류를 발생시켜서 실행 이전에 컴파일 단계에서 오류를 방지할 수 있다.
제네릭의 문법
제네릭은 관례적으로 다음과 같은 표기를 사용한다.
제네릭 타입 변수 | 의미 |
T | 타입(Type) |
K | 키(Key) |
V | 값(Value) |
N | 숫자(Number) |
E | 원소(Element) |
1. 제네릭 클래스, 인터페이스
public class MyClass <T> {
private T t;
public T get() {
return t;
}
public void set(T t) {
this.t = t;
}
}
public class MyClass <K, V> { ... }
[접근 지정자] class [클래스명] <제네릭 타입> { ... }
public interface MyInterface <T> { ... }
public interface MyInterface <K, V> { ... }
기본적으로 제네릭 클래스는 위와 같이 정의할 수 있다.
제네릭 타입 변수를 1개 또는 여러개 정의해서 사용할 수 있으며, 중괄호 {} 내에서 정의한 제네릭 타입 변수를 사용할 수 있다.
MyClass<String> mc1 = new MyClass<String>();
MyClass<Integer> mc2 = new MyClass<>();
MyClass<String, Integer> mc3 = new MyClass<>();
기본적으로 제네릭 클래스는 위와 같이 생성할 수 있다.
제네릭 클래스는 객체를 정의할 때가 아닌 생성할 때 제네릭 타입 변수에 실제 타입을 대입한다. 그리고 객체의 생성 과정에서 생성자명에 포함된 오른쪽 항의 실제 제네릭 타입은 항상 왼쪽항과 동일하기 때문에 생략할 수 있다.
MyClass<Integer> mc2 = new MyClass<>();
mc2.set("테스트"); // 오류 발생
제네릭 타입을 쓴 클래스를 사용하게되면 위와 같이 실제 타입을 대입했을 때 클래스 내에서 해당 제네릭 타입 변수을 사용한 모든 곳에서 실제 타입으로 적용이 된다. 따라서 강한 타입 체크를 통해서 실행 이전인 컴파일 시에 오류를 확인 할 수 있으므로 안전하게 사용할 수 있다.
2. 제네릭 메서드
public <T> T myMethod(T t) { ... }
public <T, V> T myMethod(T t,V v) { ... }
public <T> void myMethod(T t) { ... }
public <T> T myMethod(int a) { ... }
제네릭 메서드는 리턴 타입 또는 입력매개변수의 타입을 제네릭 타입 변수로 선언한다.
제네릭 클래스가 객체를 생성하는 시점에 실제 타입을 지정하는 것과 다르게 제네릭 메서드는 호출되는 시점에 실제 제네릭 타입을 지정한다.
class MyClass {
public <T> T method(T t) {
return T;
}
}
public class GenericMethod {
public static void main(String[] args) {
MyClass mc = new MyClass();
String str1 = mc.<String>method("테스트");
String str2 = mc.method("테스트");
}
}
제네릭 메서드를 사용할 때는 기본적으로 메서드명 앞에 <실제 제네릭 타입> 을 붙여서 사용한다.
그리고 메서드의 입력매개변수에 제네릭 타입 변수가 사용되서 입력매개변수의 타입만으로 실제 제네릭 타입을 예측할 수 있을 때는 생략이 가능하다.
지금까지 제네릭 클래스와 제네릭 메서드에 대해 알아봤는데, 만약 String을 실제 제네릭 타입으로 지정한다면 해당 제네릭 클래스나 메서드에서 동일한 제네릭 타입으로 선언된 변수에서 String 객체의 length() 메서드를 사용할 수 있을까?
위의 경우에는 사용할 수 없다. 왜냐하면 제네릭 클래스나 메서드에서는 모든 클래스가 들어올 것을 가정하고 있기때문에 Object에서 물려받은 메서드가 아니면 사용할 수 없다.
그렇다면 제네릭의 활용 범위는 매우 제한적일 것이라 생각할 수 있는데, 이에 대한 해결책이 있다. 제네릭 타입의 범위 제한을 하는 방법이다.
제네릭의 타입의 범위 제한
제네릭 타입의 범위를 제한하는 방법은 제네릭 클래스에서 제네릭 타입을 제한할 때, 제네릭 메서드에서 제네릭 타입을 제한할 때, 일반 메서드의 매개변수로서 제네릭 클래스의 타입을 제한할 때로 나눠 고려할 수 있다. 세가지 모두 각각을 정의하는 과정에서 제네릭 타입의 범위를 제한한다.
1. 제네릭 클래스의 타입 제한
public class MyClass<T extends String> { ... }
public class MyClass<T extends MyInterface> { ... }
제네릭 클래스는 기본적으로 위와 같이 정의할 수 있다.
<제네릭 타입 변수 extends 최상위 클래스>와 같이 제네릭 타입으로 대입될 수 있는 최상위 클래스를 extends 키워드와 함께 정의한다. 그리고 클래스와 인터페이스에 상관없이 extends 키워드를 사용한다.
class A {}
class B extends A {}
class C extends B {}
class D<T extends B> {}
D<A> d1 = new D<>(); // 불가능
D<B> d2 = new D<>();
D<C> d3 = new D<>():
D d4 = new D();
위의 예시를 보면 클래스 A - B - C 순으로 상속 구조를 가지고 있고 제네릭 클래스 D는 제네릭 타입으로 클래스 B 또는 클래스 B의 자식 클래스만 오도록 제한했다.
따라서 객체를 생성할 때 클래스 A를 실제 타입으로 지정할 경우 제네릭 타입 제한에 걸려서 객체 생성 자체가 불가능하다.
마지막 줄처럼 제네릭 타입을 생략한 경우는 대입될 수 있는 모든 타입의 최상위 클래스가 입력된 것으로 간주한다. 따라서 위의 경우에는 D<B> d4 = new D<>()가 될 것이다.
2. 제네릭 메서드의 타입 제한
public <T extends MyClass> T myMethod(T t) { ... }
제네릭 메서드는 기본적으로 위와 같이 정의할 수 있다.
제네릭 클래스와 마찬가지로 <T extends 최상위 클래스>와 같이 제네릭 타입으로 대입될 수 있는 최상위 클래스를 extends 키워드와 함께 정의한다. 그리고 클래스와 인터페이스에 상관없이 extends 키워드를 사용한다.
public <T extends String> void method(T t) {
char c = t.charAt(0);
}
제네릭 메서드에서 중요한 것은 메서드 내부에서 사용할 수 있는 메서드의 종류다.
타입을 제한하지 않을 때는 모든 타입의 최상위 클래스인 Object 메서드만 사용할 수 있어서 활용 범위에 제약이 있었다. 하지만 같은 원리로 <T extends String>과 같이 표현하면 메서드 내부의 제네릭 타입에 올 수 있는 모든 타입의 최상위 타입이 String이기 때문에 String 객체의 멤버를 사용할 수 있게 된다.
3. 메서드 매개변수일 때 제네릭 클래스의 타입 제한
void method(MyClass<A> v) // 제네릭 타입 = A인 객체만 가능
void method(MyClass<?> v) // 제네릭 타입 = 모든 타입 객체 가능
void method(MyClass<? extends B> v) // 제네릭 타입 = B 또는 B의 자식 클래스인 객체만 가능
void method(MyClass<? super B> v) // 제네릭 타입 = B 또는 B의 부모 클래스인 객체만 가능
메서드 매개변수일 때 제네릭 클래스의 타입 제한은 기본적으로 위와 같이 할 수 있다.
첫번째는 객체의 제네릭 타입을 특정 타입으로 확정하는 방법이다. 이때 해당 타입을 제네릭 타입으로 갖는 제네릭 객체만 입력매개변수로 전달할 수 있다.
두번째는 제네릭 타입 변수에 <?>를 사용할 때이다. 이때는 해당 제네릭 객체이기만 하면 매개변수로 사용할 수 있다.
세번째는 <? extends 상위 클래스/인터페이스>와 같이 표기하는 방법이다. 이때는 상위 클래스 또는 상위 클래스의 자식 클래스 타입이 제네릭 타입으로 대입된 객체가 매개변수로 올 수 있다.
네번째는 <? super gkdnl 클래스/인터페이스>와 같이 표기하는 방법이다. 이때는 하위 클래스 또는 하위 클래스의 부모 클래스 타입이 제네릭 타입으로 대입된 객체가 매개변수로 올 수 있다.
Reference
- 자바 완전 정복 | 김동형 지음
댓글