[Java] 자바 제네릭(Generic)이란? 개념 정리 및 활용
Intro
class Parents {
private String info;
Parents(String info) {
this.info = info;
}
public String getInfo() {
return info;
}
public void setInfo(String info) {
this.info = info;
}
}
다음과 같은 코드가 있을 때, 문자열 변수 info의 데이터 타입은 String이다. 따라서 Parents 클래스를 통해 만들어진 인스턴스는 String 타입의 데이터를 저장해야 한다.
만약, Parents 클래스와 같은 기능을 하면서 String이 아닌 다양한 데이터 타입도 저장할 수 있도록 하려면 타입 별로 같은 내용의 클래스를 반복적으로 작성해야 할 것이다.
class ParentsStr {
private String info;
...
}
class ParentsInt {
private int info;
...
}
class ParentsChar {
private char info;
...
}
...
제네릭(Generic)
자바에서 제네릭이란 데이터 타입을 일반화하는 것으로, 클래스나 메서드에서 사용할 데이터의 타입을 컴파일할 때 지정하는 방식이다. 이는 클래스 내부에서 지정하는 것이 아닌 외부 사용자에 의해 지정되는 것을 뜻한다.
즉, 제네릭을 사용하면 컴파일 시에 미리 타입이 지정된다. 이는 작성한 클래스 또는 메서드의 코드가 특정 데이터 타입에 얽매이지 않게 하는 것으로, 반복적인 작업이나 타입 변환을 하지 않고도 실행할 수 있다.
제네릭(Generic)을 사용하면 다음과 같이 작성하여 반복적인 작업을 줄일 수 있다.
class Parents<T> {
private T info;
public Parents(T info) {
this.info = info;
}
public getInfo() {
return info;
}
public void setInfo(T info) {
this.info = info;
}
}
만약 Parents 클래스의 변수 info를 String 타입으로 데이터 타입을 지정하고 싶다면 다음과 같이 작성하여 객체를 생성할 수 있다.
Parents<String> parents = new Parents<String>("부모님");
위 코드로 객체를 생성하면 parents 인스턴스에 대해서 Parents 클래스는 다음과 같이 변환되어 작동하게 된다.
class Parents<String> {
private String info;
public Parents(String info) {
this.info = info;
}
public getInfo() {
return info;
}
public void setInfo(String info) {
this.info = info;
}
}
제네릭 클래스 정의
앞서 작성한 Parents 클래스와 같이 제네릭이 사용된 클래스를 제네릭 클래스라 한다.
<T>는 타입 매개변수라 하며, 클래스 내부에서 사용할 타입 매개변수를 선언할 수 있다.
// 제네릭 클래스 정의
class Parents<T> {
// T를 임의의 데이터 타입으로 사용할 수 있음
private T info;
}
만약, 타입 매개변수를 여러 개 사용해야 한다면, 다음과 같이 선언할 수 있다.
class Parents<T, K> {
private T info;
private K name;
}
제네릭 타입 종류
제네릭의 타입 매개변수는 다양한 종류가 있다. 각각 사용 용도에 따라 선언하여 사용할 수 있다. <Hello>, <Type> 등 다양하게 사용할 수도 있다.
아래의 타입 종류는 일반적으로 사용되는 선언 방식으로 관례적으로 사용되는 타입으로, 무조건적으로 지켜야 하는 규칙은 아니다.
<T> : Type
<K> : Key
<V> : Value
<E> : Element
<N> : Number
<R> : Result
<S>, <U>, <V> … 등 여러 개의 매개변수를 사용할 때 이용할 수 있다.
제네릭 사용 시 주의할 점
클래스 변수(static 변수)에는 제네릭 타입 매개변수를 사용할 수 없다.
class Parents<T> {
static T info; // 사용 불가능
}
제네릭 클래스는 기본 타입(int, double, char, … 등)을 지정할 수 없다. 따라서 Integer, Double과 같은 래퍼 클래스를 활용하여 지정해야 한다.
// 사용 불가능
Parents<int> info1 = new Parents<int>(10);
Parents<double> info2 = new Parents<double>(12.34);
// 사용 가능
Parents<Integer> info1 = new Parents<Integer>(10);
Parents<Double> info2 = new Parents<Double>(12.34);
제네릭 클래스 사용
객체 생성 시 다음과 같이 참조 변수의 타입으로부터 유추가 가능하기 때문에 구체적인 타입을 생략하고 작성할 수 있다.
Parents<Integer> info1 = new Parents<>(10);
Parents<Double> info2 = new Parents<>(12.34);
Parents<String> info3 = new Parents<>("Parents");
또한, 제네릭 클래스를 사용할 때에도 다형성을 적용할 수 있다.
class Cellphone {
}
class Galaxy extends Cellphone {
Galaxy() {
System.out.println("Galaxy");
}
}
class Test<T> {
private T model;
public T getModel() {
return model;
}
public void setModel(T model) {
this.model = model;
}
public static void main(String[] args) {
Test<Cellphone> cellphone = new Test<>();
cellphone.setModel(new Galaxy());
}
}
다음과 같이 제네릭 타입 매개변수를 생성한 Cellphone 타입으로 지정할 수 있으며, Cellphone 클래스의 상속을 받는 Galaxy 클래스를 타입으로 지정하여 객체를 생성할 수 있다.
제한된 제네릭 클래스
제네릭 클래스를 사용하여 객체를 생성할 때, 어떠한 제한 없이 다양한 타입을 지정할 수 있다.
class Cellphone {
}
class Galaxy extends Cellphone {
}
class Car {
}
class Test<T> {
private T model;
public T getModel() {
return model;
}
public void setModel(T model) {
this.model = model;
}
public static void main(String[] args) {
Test<Cellphone> cellphone1 = new Test<>();
Test<Galaxy> cellphone2 = new Test<>();
Test<Car> car = new Test<Car>;
}
}
그러나, 제네릭 클래스가 특정 클래스를 상속받을 경우, 상속받은 클래스만을 타입으로 지정할 수 있도록 제한할 수 있다.
class Cellphone {
}
class Galaxy extends Cellphone {
}
class Car {
}
// Cellphone 클래스의 하위 클래스만 지정할 수 있음
class Test<T extends Cellphone> {
private T model;
public T getModel() {
return model;
}
public void setModel(T model) {
this.model = model;
}
public static void main(String[] args) {
Test<Cellphone> cellphone1 = new Test<>();
Test<Galaxy> cellphone2 = new Test<>();
// Car 클래스 객체 생성 불가능
Test<Car> car = new Test<Car>;
}
}
이는 클래스뿐만 아니라 특정 인터페이스를 구현한 클래스만을 타입으로 지정할 수도 있다.
interface Cellphone {
}
class Galaxy implements Cellphone {
Galaxy() {
System.out.println("갤럭시");
}
}
class IPhone implements Cellphone {
IPhone() {
System.out.println("아이폰");
}
}
class Test<T extends Cellphone> {
private T model;
public T getModel() {
return model;
}
public void setModel(T model) {
this.model = model;
}
public static void main(String[] args) {
Test<Galaxy> galaxy = new Test<>();
Test<IPhone> iphone = new Test<>();
galaxy.setModel(new Galaxy());
iphone.setModel(new IPhone());
}
}
제네릭 메서드(Generic Method)
클래스 전체를 제네릭으로 선언할 수도 있지만, 클래스 내부의 특정 메서드에도 제네릭으로 선언할 수 있다.
제네릭 메서드의 타입 매개변수 선언은 반환 타입 앞에서 이루어지며, 해당 메서드 내에서만 사용할 수 있다.
class Parents {
public <T> void genericMethod(T element) {
}
}
제네릭 메서드 사용 시 주의할 점
제네릭 메서드의 타입 매개변수는 제네릭 클래스의 타입 매개변수와는 별개이다.
이는 서로 지정되는 시점이 다르기 때문이다.
제네릭 클래스의 타입 매개변수는 클래스가 인스턴스화 될 때 타입이 지정된다.
제네릭 메서드의 타입 매개변수는 메서드가 호출될 때 타입이 지정된다.
// 제네릭 클래스에 선언한 매개변수 T와
class GenericClass<T> {
// 제네릭 메서드에서 선언한 매개변수 T는 서로 다르다.
public <T> void genericMethod(T element) {
}
}
// 제네릭 클래스의 타입 지정(String)
GenericClass<String> generic = new GenericClass<>();
// 제네릭 메서드의 타입 지정(Integer)
generic.<Integer>genericMethod(10);
// 제네릭 메서드의 경우 타입 생략 가능
generic.genericMethod(10);
또한, 제네릭 메서드의 타입 매개변수는 static 메서드에도 선언하여 사용이 가능하다.
class GenericClass<T> {
// 클래스 변수에는 사용 불가능
// static T element;
// static 메서드에는 제네릭 사용 가능
public static <T> void genericMethod(T element) {
}
}
제네릭 메서드는 메서드가 호출되는 시점에서 타입이 지정되므로, 메서드를 정의하는 시점에는 실제로 어떤 타입이 입력되는지는 알 수 없다. 따라서 length()와 같은 String 클래스의 메서드는 정의하는 시점에서 사용이 불가능하다.
class GenericClass<T> {
public static <T> void genericMethod(T element) {
// 타입이 결정되지 않은 상태이므로 length() 사용 불가능
System.out.println(element.length());
}
}
하지만 Object 클래스의 메서드는 사용이 가능하다. 이는 모든 클래스는 Object 클래스의 상속을 받기 때문이다. 즉, equals(), toString() 등과 같은 Object 클래스의 메서드는 사용이 가능하다.
class GenericClass<T> {
public static <T> void genericMethod(T element) {
// Object 클래스의 메서드는 사용 가능
System.out.println(element.equals("Generic"));
}
}
와일드카드
자바에서 와일드카드는 제네릭에서 사용 가능한 타입 파라미터로, 와일드카드는 어떤 타입으로든 대체될 수 있는 타입 파라미터이다. 물음표(?) 기호를 사용하여 와일드카드를 사용할 수 있다.
일반적으로 와일드카드는 extends와 super 키워드를 조합하여 사용한다.
<? extends T>
<? super T>
<? extends T>
<? extends T>는 와일드카드에 상한 제한을 두는 것이다. 즉, T와 T를 상속받는 하위 클래스 타입만을 타입 파라미터로 받을 수 있도록 지정하는 것이다.
<? super T>
<? super T>는 와일드카드에 하한 제한을 두는 것이다. 즉, T와 T의 상위 클래스만 타입 파라미터로 받도록 한다.
<?>
extends와 super를 사용하지 않은 와일드카드로 <? extends Object>와 같은 의미이다. 즉, 모든 클래스 타입을 타입 파라미터로 받을 수 있다.
와일드카드 활용 예제
다음과 같이 클래스들의 상속 계층을 나타낸 후 각 기능을 사용한다.
Galaxy와 IPhone은 Phone 클래스로부터 상속받고, 갤럭시 시리즈와 아이폰 시리즈는 각각 Galaxy와 IPhone으로부터 상속을 받는다고 가정한다.
class Phone {
}
class Galaxy extends Phone {
}
class IPhone extends Phone {
}
// 갤럭시 시리즈
class GalaxyS extends Galaxy {
}
class GalaxyNote extends Galaxy {
}
// 아이폰 시리즈
class IPhonePro extends IPhone {
}
class IPhoneMini extends IPhone {
}
class User<T> {
public T phone;
public User(T phone) {
this.phone = phone;
}
}
이후, 각 휴대폰 별 기능을 분류하여 작성한다.
call : 휴대폰의 기본적인 통화 기능, 모든 휴대폰에서 사용
? extends Phone으로 타입 제한
samsungPay : 갤럭시에서만 사용 가능한 결제 기능
? extends Galaxy으로 타입 제한
faceId : 아이폰에서만 사용 가능한 안면 인식 기능
? extends IPhone으로 타입 제한
recordVoice : 통화 녹음 기능으로 아이폰을 제외한 휴대폰에서만 사용
? super Galaxy으로 타입 제한
class PhoneFun {
// call : 모든 휴대폰에서 사용
public static void call(User<? extends Phone> user) {
System.out.println(user.phone.getClass().getSimpleName());
}
// faceId : 아이폰에서만 사용
public static void faceId(User<? extends IPhone> user) {
System.out.println(user.phone.getClass().getSimpleName());
}
// samsungPay : 갤럭시에서만 사용
public static void samsungPay(User<? extends Galaxy> user) {
System.out.println(user.phone.getClass().getSimpleName());
}
// recordVoice : 아이폰을 제외한 휴대폰에서만 사용
public static void recordVoice(User<? super Galaxy> user) {
System.out.println(user.phone.getClass().getSimpleName());
}
}
getClass() 메서드와 getSimpleName() 메서드는 Object 클래스의 메서드로 제네릭 메서드에서 사용이 가능하다.
getClass() : 현재 참조하고 있는 클래스를 확인할 수 있는 메서드
getSimpleName() : 패키지 경로가 포함되지 않은 클래스 이름을 출력해주는 메서드
public class PhoneEx {
public static void main(String[] args) {
// call 기능 확인 (모두 사용 가능)
PhoneFun.call(new User<Phone>(new Phone()));
PhoneFun.call(new User<Galaxy>(new Galaxy()));
PhoneFun.call(new User<IPhone>(new IPhone()));
PhoneFun.call(new User<GalaxyS>(new GalaxyS()));
PhoneFun.call(new User<GalaxyNote>(new GalaxyNote()));
PhoneFun.call(new User<IPhonePro>(new IPhonePro()));
PhoneFun.call(new User<IPhoneMini>(new IPhoneMini()));
System.out.println();
// samsungPay 기능 확인 (갤럭시만 사용 가능)
PhoneFun.samsungPay(new User<GalaxyS>(new GalaxyS()));
PhoneFun.samsungPay(new User<GalaxyNote>(new GalaxyNote()));
PhoneFun.samsungPay(new User<Galaxy>(new Galaxy()));
// PhoneFun.samsungPay(new User<Phone>(new Phone()));
// PhoneFun.samsungPay(new User<IPhone>(new IPhone()));
// honeFun.samsungPay(new User<IPhonePro>(new IPhonePro()));
// PhoneFun.samsungPay(new User<IPhoneMini>(new IPhoneMini()));
System.out.println();
// faceId 기능 확인 (아이폰만 사용 가능)
PhoneFun.faceId(new User<IPhone>(new IPhone()));
PhoneFun.faceId(new User<IPhonePro>(new IPhonePro()));
PhoneFun.faceId(new User<IPhoneMini>(new IPhoneMini()));
// PhoneFun.faceId(new User<GalaxyS>(new GalaxyS()));
// PhoneFun.faceId(new User<GalaxyNote>(new GalaxyNote()));
// PhoneFun.faceId(new User<Galaxy>(new Galaxy()));
// PhoneFun.faceId(new User<Phone>(new Phone()));
System.out.println();
// recordVoice 기능 확인 (아이폰을 제외한 휴대폰 사용 가능)
PhoneFun.recordVoice(new User<Galaxy>(new Galaxy()));
PhoneFun.recordVoice(new User<Phone>(new Phone()));
// PhoneFun.recordVoice(new User<IPhone>(new IPhone()));
// PhoneFun.recordVoice(new User<IPhonePro>(new IPhonePro()));
// PhoneFun.recordVoice(new User<IPhoneMini>(new IPhoneMini()));
// PhoneFun.recordVoice(new User<GalaxyS>(new GalaxyS()));
// PhoneFun.recordVoice(new User<GalaxyNote>(new GalaxyNote()));
}
}
주석으로 처리된 부분은 모두 에러가 발생한다.
call과 samsungPay, faceId는 모두 의도한 대로 타입 매개변수를 제한하여 사용할 수 있다.
하지만, recordVoice는 의도와는 다르게 GalaxyS와 GalaxyNote 클래스에서도 사용이 불가능하다.
이러한 이유는 super의 기능 때문이다.
<? super Galaxy>는 Galaxy와 Galaxy의 상위 클래스인 Phone 클래스만을 타입 파라미터로 받도록 제한되어 있기 때문이다.
즉, Galaxy의 하위 클래스인 GalaxyS와 GalaxyNote 클래스는 recordVoice를 호출할 수 없다.