"클래스 내부에서 사용할 데이터 타입을 외부에서 파라미터 형태로 지정하면서 데이터 타입을 일반화한다." 즉, 클래스나 메서드에서 사용할 내부 데이터 타입을 컴파일 시에 미리 지정하는 방법이다
제네릭 없이 객체를 여러 자료형으로 받을 수 있도록 설계한다면 int, float, String 등의 여러 자료형에 대한 각각의 클래스를 하나씩 만들어야 하는 불편함이 있기 때문에 제네릭을 사용하면 클래스 하나만 생성하여 원하는 타입으로 받아 사용할 수 있다.
실제 사용되는 자료형의 반환은 컴파일러에 의해 검증된다. 그리고 컬렉션 프레임워크에서 많이 사용된다.
public class 클래스명<타입 매개변수> {...}
public interface 인터페이스명<타입 매개변수> {...}
[제네릭을 사용한 클래스 & 인터페이스]
타입 인자 | 설명 |
---|---|
T | Type |
E | Element |
K | Key |
N | Number |
V | Value |
R | Result |
java의 모든 클래스는 Object(최상위 클래스)를 상속받아 확장한다.
public class CoffeeMachine {
// 브라질산 커피
private SantosBeans beans;
public void setBeans(SantosBeans beans) {
this.beans = beans;
}
public SantosBeans getBeans() {
return beans;
}
}
public class CoffeeMachine2 {
// 콜롬비아산 커피
private ColumbiaBeams beans;
public void setBeans(ColumbiaBeams beans) {
this.beans = beans;
}
public ColumbiaBeams getBeans() {
return beans;
}
}
여러 종류의 커피 원두를 이용해서 커피를 만드려고 할때 계속해서 각각의 원두를 커피 머신 클래스에 멤버로 할당해줘야 한다. 이 경우 Object 타입을 이용하여 하나의 커피 머신 클래스에 여러 원두를 멤버로 할당해줄 수 있다.
public class CoffeeMachine {
private Object beans;
public void setBeans(Object beans) {
this.beans = beans;
}
public Object getBeans() {
return beans;
}
}
public class Main {
public static void main(String[] args) {
CoffeeMachine coffeeMachine = new CoffeeMachine();
SantosBeans santosBeans = new SantosBeans();
coffeeMachine.setBeans(santosBeans);
// getBeans()의 return type이 Object이기 때문에 형변환 필요!
SantosBeans sb = (SantosBeans)coffeeMachine.getBeans();
}
}
Object를 이용하여 여러 종류의 커피 원두를 커피 머신의 원두로 설정할 수 있지만 커피 머신이 사용하고 있는 원두를 가져오려고 할때는 Object 타입을 return 하고 있어 형변환이 반드시 필요하게 된다. 이에 따라 코드가 복잡해지고 잘못된 수동 타입 변환으로 인해 에러가 발생하기도 한다.
public class CoffeeMachine<T> {
private T beans;
public void setBeans(T beans) {
this.beans = beans;
}
public T getBeans() {
return beans;
}
}
public class Main {
public static void main(String[] args) {
CoffeeMachine<SantosBeans> coffeeMachine = new CoffeeMachine<>();
coffeeMachine.setBeans(new SantosBeans());
// getBeans()의 return type이 타입 매개변수이기 때문에 형변환 불필요!
SantosBeans sb = coffeeMachine.getBeans();
}
}
위와 같이 제네릭을 이용하면 코드가 간결해지고 수동으로 형변환을 하지 않아도 된다. 그런데 T(타입 매개변수)는 언제 해당 타입으로 바뀌는지 의문이 들 수 있다. 이는 setBeans() 함수를 이용해 커피 원두를 설정할때 T가 SantosBeans 참조 타입으로 변환이 된다.
제네릭의 장점을 정리하자면 다음과 같다.
여러 참조 자료형을 사용해야 하는 경우에 Object가 아닌 하나의 문자로 표현해서 클래스에서 사용할 수 있다. 또한 하나의 타입 매개변수만 사용할 수 있는게 아니라 여러 개의 타입 매개변수를 사용할 수 있다. 그리고 class에서만 사용이 국한되지 않고 interface나 abstract, method에도 사용 가능하다.
접근 제어자 class 클래스명<T> {...} // 제네릭 타입 매개변수가 1개일 때
접근 제어자 class 클래스명<T, E> {...} // 제네릭 타입 매개변수가 2개일 때
눈에 잘 익지 않는 형태라 조금 이해하는데 어려울 수 있어 나름대로 뜻을 정리하자면 "해당 클래스에 어떠한 타입을 쓰겠다, 그 타입은 정해지지 않았고 컴파일 할때(추후에) 정하겠다."라고 받아들이면 좀 더 수월하게 이해되지 않을까 싶다.
extends 키워드를 이용하여 타입 매개변수의 범위를 제한할 수 있다. 또한 상위 클래스에서 선언하거나 정의하는 메서드를 활용(상속과 다형성)할 수 있으며, 다이아몬드 연산자 안에 타입 매개변수만 있다면 Object의 확장이 생략된 것이라고 볼 수 있다.
<T> == <T extends Object>
<T extends 클래스> : 클래스를 상속받는 하위 클래스만 사용 가능
<T super 클래스> : 사용 불가
커피 머신을 이용해서 커피를 만드려면 원두 뿐만 아니라 물도 필요하다. 하지만 사용하는 원두에 따라 커피 머신을 관리하고자 한다고 가정한다면 물은 굳이 필요가 없게 된다.
// 추상 클래스 Beans
public abstract class Beans {
public abstract void doChange();
}
// 브라질산 원두 - Beans 확장
public class SantosBeans extends Beans {
@Override
public void doChange() {
System.out.println("브라질산 원두 교체");
}
public String toString() {
return "브라질산 원두 입니다.";
}
public void santosana() {
System.out.println("santosana");
}
}
// 콜롬비아산 원두 - Beans 확장
public class ColumbiaBeans extends Beans {
@Override
public void doChange() {
System.out.println("콜롬비아산 원두 교체");
}
public String toString() {
return "콜롬비아산 원두 입니다.";
}
public void columbiana() {
System.out.println("columbiana");
}
}
public class Water {
public String toString() {
return "물 입니다.";
}
public void doChange() {
System.out.println("물 교체");
}
}
public class CoffeeMachine<T extends Beans> {
private T beans;
public void setBeans(T beans) {
this.beans = beans;
}
public T getBeans() {
return beans;
}
public String toString() {
// toString()은 Object 클래스에서 정의되어 있으나 overriding 하였음!
return beans.toString();
}
public void change() {
// 추상 클래스(Beans)에서 정의한 메서드 사용 가능!!
beans.doChange();
}
public void info() {
if(beans instanceof ColumbiaBeans) // 타입이 확실해지면 해당 클래스에 정의한 메서드 호출 가능!
((ColumbiaBeans) beans).columbiana();
if(beans instanceof SantosBeans)
((SantosBeans) beans).santosana();
}
}
public class Main {
public static void main(String[] args) {
CoffeeMachine<SantosBeans> santosCoffeeMachine = new CoffeeMachine<>();
santosCoffeeMachine.setBeans(new SantosBeans());
System.out.println(santosCoffeeMachine); // 브라질산 원두입니다.
santosCoffeeMachine.change(); // 브라질산 원두 교체
santosCoffeeMachine.info(); // santosana
CoffeeMachine<ColumbiaBeans> columbiaCoffeeMachine = new CoffeeMachine<>();
columbiaCoffeeMachine.setBeans(new ColumbiaBeans());
ColumbiaBeans columbiaBeans = columbiaCoffeeMachine.getBeans(); // 형변환 필요 x
System.out.println(columbiaBeans); // 콜롬비아산 원두 입니다.
columbiaBeans.doChange(); // 콜롬비아산 원두 교체
columbiaCoffeeMachine.info(); // columbiana
// Type parameter 'generic.Water' is not within its bound; should extend 'generic.Beans'
// CoffeeMachine<Water> waterCoffeeMachine = new CoffeeMachine<>(); // 에러 발생!
// 타입 매개변수를 지정하지 않으면 Object를 반환
CoffeeMachine coffeeMachine = new CoffeeMachine();
// coffeeMachine.change(); // NullPointerException -> coffeeMachine 객체에 타입을 지정하지 않았기 때문
coffeeMachine.setBeans(new SantosBeans());
coffeeMachine.change(); // 브라질산 원두 교체
coffeeMachine.info(); // santosana
}
}
추상 클래스 Beans를 상속받은 SantosBeans와 ColumbiaBeans, 그리고 Water class를 CoffeeMachine 객체에 할당한 예제를 살펴보자.
<T extends Beans>
키워드가 붙어 있다. 이를 통해 Water를 제외한 두개의 class는 Beans를 상속(확장)받고 있어 Main class에서 CoffeeMachine 참조타입으로 사용이 가능하지만 Water는 사용하지 못한다. <T implements Beans>
로 설정할 수 있을까?불가능하다! 이유는 implement(구현하다), extend(확장하다)의 뜻에서 알 수 있듯이 확장을하는 개념이지 구현을 하는 개념이 아니기 때문이다.
? 키워드를 이용하여 와일드카드를 사용할 수 있다. ?의 의미는 알 수 없는 타입을 의미한다.
<?> // Unbounded Wildcards : 타입 매개변수에 모든 타입 사용
<? extends T> // Lower Bounded Wildcards : T타입과 T타입을 상속받는 모든 하위 클래스 타입만 사용
<? super T> // Upper Bounded Wildcards : T타입과 T타입을 상속받은 상위 클래스 타입만 사용
그렇다면 제네릭 타입 매개변수랑 차이가 뭘까? "타입"보다 타입 매개변수를 사용하는 "방법"이 더 중요할때 사용한다.
public <T extends Number> void printList(List<? extends T> list) {
for(Number num : list)
System.out.println(num);
}
"타입 매개변수를 메서드의 매개변수나 반환 값으로 가지는 메서드"로 제네릭 클래스와 같이 타입 매개변수가 하나 이상인 경우도 있다.
public class TestClass1<T> {
private T test;
// 제네릭 메서드
public void setTest(T test) {
this.test = test;
}
// 제네릭 메서드
public T getTest() {
return test;
}
}
public class TestClass2 { // 일반 클래스 내부에 제네릭 메서드 선언
// 제네릭 메서드
public <T> T accept(T t){ // 리턴 타입 앞에 사용할 타입 매개변수 선언
return t;
}
// 제네릭 메서드
public <K, V> void getPrint(K k, V v) { // 리턴 타입 앞에 사용할 타입 매개변수 선언
System.out.println(k + " : " + v);
}
}
public class Main {
public static void main(String[] args) {
TestClass2 testClass2 = new TestClass2();
String str1 = testClass2.<String>accept("test");
// 입력 매개변수 값으로 제네릭 타입이 추론 가능하면 생략 가능
String str2 = testClass2.accept("test");
System.out.println(str1); // test
System.out.println(str2); // test
testClass2.<String, Integer>getPrint("test", 1); // test : 1
// 입력 매개변수 값으로 제네릭 타입이 추론 가능하면 생략 가능
testClass2.getPrint("test", 1); // test : 1
}
}
여기서 주의해야할 점은 컴파일 시점에서 타입 매개변수의 타입이 정해지기때문에 어떤 타입이 입력되는지 알 수 없다. 그래서 만약 String 타입이 제네릭 메서드의 매개변수로 들어온다면 .length()와 같은 String 클래스의 메서드를 사용할 수 없고 최상위 클래스인 Object의 메서드만 사용이 가능하다.