안녕하세요.
이번 시간엔 그 동안 잘 모르고 사용해왔던 자바의 제네릭에 대해 정리해보려고 합니다.
※ 제네릭(Generic) 이란?
흔히 List나 Map 객체를 사용할 때 다음과 같이 코드를 작성합니다.
List<String> myList = new ArrayList<>();
Map<String, Object> myMap = new HashMap<>();
ArrayList와 HashMap 클래스를 살펴보면 클래스의 정의가 다음과 같은 형태로 되어 있습니다.
// ArrayList.java
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {...}
// HashMap.java
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {...}
위 코드들에서 보이는 <> 괄호를 사용한 표현식이 바로 자바의 제네릭(Generic)입니다. 어떤 클래스에서 사용될 특정 필드나 메서드에 대한 타입을 고정값으로 미리 지정해놓는 것이 아니라 호출하는 사용자가 결정할 수 있도록 가능성을 열어두는 것입니다. 만일 ArrayList 객체를 생성할 때 타입을 String으로 지정한다면 해당 객체에 add할 수 있는 타입은 String 타입으로 한정될 것입니다.
제네릭에서 사용되는 전달 파라미터는 메소드에 매개변수를 전달하는 것과 유사하다 하여 흔히 타입 매개변수로 불립니다. 타입 매개변수 사용에는 일반적으로 사용되는 일정한 규칙이 있습니다. 메서드나 변수 네이밍 규칙처럼 일반적으로 사용하는 규칙이기 때문에 강제성을 띄진 않으나, 협업을 위해서는 어느 정도 규칙을 지켜가며 작성하는 것이 필요해 보입니다.
- T : Type
- K : Key
- V : Value
- E : Element
- N : Number
ArrayList의 경우 List를 구성하는 요소를 의미하는 것이므로 <E>를, HashMap의 경우 Key-Value 형태의 데이터를 저장하므로 <K, V>를 사용합니다.
이제 제네릭을 코드에서 어떻게 사용하는지 직접 작성해보도록 하겠습니다.
※ 실습
1. Generic Class
먼저 간단한 제네릭 클래스를 하나 만들었습니다.
public class GenericClass<T> {
private T t;
public T getField() {
return t;
}
public void setField(T t) {
this.t = t;
}
}
그리고 별도의 테스트 클래스를 만들어 main 메서드에서 GenericClass 객체를 만들어 메서드에 접근하도록 하겠습니다.
public class ExecTestClass {
public static void main(String[] args) {
GenericClass<String> c1 = new GenericClass<>();
c1.setField("TEST");
System.out.println("class1 value > : " + c1.getField());
GenericClass<Double> c2 = new GenericClass<>();
c2.setField(3.141592);
System.out.println("class2 value > : " + c2.getField());
}
}
- 실행 결과
class1 value > : TEST
class2 value > : 3.141592
앞서 살펴본 것처럼 객체를 생성할 때 지정해주었던 제네릭 타입에 따라 다양한 형태로 클래스를 활용할 수 있는 것을 볼 수 있습니다. 한 가지 주의할 점은, 제네릭 타입 매개변수는 참조 타입으로 강제되기 때문에 반드시 Wrapper Class를 사용해야 한다는 점입니다. 위의 코드에서도 원시타입 double이 아닌 Wrapper Class Double을 사용하였습니다.
2. Generic Method
제네릭 메서드는 메서드 선언 방식이 조금 다릅니다. 반환 타입을 지정하기 전에 제네릭 타입을 먼저 선언한다는 특징이 있습니다.
public <E> Class<? extends Object> getClassType(E e) {
return e.getClass();
}
위 코드를 추가하여 GenericClass 클래스를 살펴보겠습니다.
public class GenericClass<T> {
private T t;
public T getField() {
return t;
}
public void setField(T t) {
this.t = t;
}
public <E> Class<? extends Object> getClassType(E e) {
return e.getClass();
}
}
제네릭 클래스를 살펴볼 때 제네릭 타입 <T>는 필드와 get/set 메서드에 적용이 됐습니다. String 타입으로 객체를 생성하면 getField()는 String을 반환할 것이고, setField 매개변수로 String 타입의 객체를 받을 것입니다. 하지만 제네릭 메서드는 클래스의 타입에 영향을 받지 않기 때문에, 메서드를 선언할 때 <E> 와 같이 메서드의 타입 매개변수를 선언한 것입니다. 제네릭 클래스의 타입 매개변수가 이미 <T> 이므로 메서드의 타입 매개변수를 <T>로 동일하게 할 경우 헷갈릴 우려가 있어 위 코드에서는 메서드의 타입을 <E>로 다르게 지정하였습니다.
제네릭 메서드를 실행해보면..
GenericClass<String> c3 = new GenericClass<>();
Object result = c3.getClassType("TEST GENERIC METHOD...");
System.out.println("class3 value > : " + result);
class3 value > : class java.lang.String
GenericClass<String> c3 = new GenericClass<>();
Object result = c3.getClassType(3);
System.out.println("class3 value > : " + result);
class3 value > : class java.lang.Integer
다음과 같이 결과가 나오는 것을 확인할 수 있습니다. 파라미터의 타입에 맞게 컴파일러가 자동으로 Wrapper Class를 대입해주는 것 같습니다.
정리해보자면 제네릭 메서드는 제네릭 클래스와는 다르게 객체의 타입 매개변수에 영향을 받지 않고, 호출 시점에 파라미터 타입에 의해 제네릭 타입이 결정된다고 볼 수 있습니다. 여기서 한 가지 의문이 생기는데요. 제네릭 클래스의 타입과 무관한 메서드를 도대체 왜 쓰는걸까요? 코드를 보면서 살펴보겠습니다.
public class GenericClass<T> {
private T t;
public T getField() {
return t;
}
public void setField(T t) {
this.t = t;
}
public <E> Class<? extends Object> getClassType(E e) {
return e.getClass();
}
public static T makeNewInstance() {
return new T();
}
}
제네릭 클래스에 static 메서드 makeNewInstance() 를 추가하였는데, 컴파일 에러가 발생하였습니다. GenericClass의 <T> 타입변수는 객체가 생성되어 힙 영역에 적재될 때 그 타입이 정해지는데, makeNewInstance 메서드를 static으로 작성해놓게 되면 객체가 생성되기도 전에 이미 static 영역에 메서드가 올라가는 문제가 발생하여 메서드의 <T> 타입을 지정할 수 없게 되기 때문입니다. 그럼, 앞서 살펴본 제네릭 메서드를 static으로 선언한다면 어떨까요?
public static <T> T returnParamObj(T t) {
return t;
}
다음과 같이 static 타입으로 제네릭 메서드를 선언하게 되면 컴파일 에러가 발생하지 않습니다. 왜냐하면, 위 메서드에서 제네릭 타입 매개변수 <T>는 제네릭 클래스의 타입변수에 영향을 받지 않고, 메서드가 호출되는 시점의 파라미터 타입에 의해 결정되기 때문입니다. 런타임 시점에 JVM의 static 영역에 메서드가 올라가더라도 메서드의 호출 시점에 파라미터에 의해 <T>의 타입이 결정되므로 static 메서드를 사용한다면 반드시 제네릭 메서드 사용을 고려해야 하는 것입니다.
3. Generic 제한과 Wild-Card
앞서 살펴본 제네릭 클래스와 메서드 예시에서는 호출하는 쪽에서 입력하는 타입 매개변수에 제한이 없어 어떤 타입이든 객체 생성이 가능하였으나, 제네릭은 메서드를 제공하는 쪽에서 타입 매개변수의 범위를 제한할 수 있도록 기능을 제공하고 있습니다.
제네릭 제한과 와일드 카드는 다음 세 가지로 요약 정리할 수 있을 것 같습니다.
- extends - 상한 경계
- super - 하한 경계
- ? - 와일드 카드
개인적으로 위 내용을 이해하는데 시간이 조금 걸렸습니다. 그 만큼 어렵고 또 중요한 내용이기 때문에 하나하나 상세히 정리해보려고 합니다.
● extends - 상한 경계
와일드 카드에 대한 개념을 포함해서 함께 정리하도록 하겠습니다. extends(상한 경계)는 두 가지 구조로 나뉩니다.
상한 경계란 아래 예시에서 T타입과 T타입을 상속받은 자식 멤버만 가능하도록 상한선을 두는 것을 의미합니다.
- <E extends T> : E는 T 타입 자신과, T 타입을 상속받은 자식 멤버가 될 수 있다.
- <? extends T> : ? 타입은 T 타입 자신과 T 타입을 상속받은 자식 멤버가 될 수 있으나, 와일드 카드의 특성상 <?> 가 특정 타입으로 변환되지는 않는다. 특정 타입으로의 변환이 필요하다면 <E extends T> 와 같이 타입을 명시해주어야 한다. (타입 참조가 불가능함)
타입 참조를 통해 해당 타입을 클래스나 메서드 내에서 사용해야 하는 경우에는 <E extends T> 와 같이 타입을 지정해줄 필요가 있지만, 타입 참조 없이 단순히 기능만을 이용하는 경우라면 와일드 카드를 사용하여 <? extends T> 와 같이 정의하는 것 같습니다.
public class GenericClass<T extends Number> {
private T t;
public T getField() {
return t;
}
public void setField(T t) {
this.t = t;
}
public <E> Class<? extends Object> getClassType(E e) {
return e.getClass();
}
public static <T> T returnParamObj(T t) {
return t;
}
}
public class ExecTestClass {
public static void main(String[] args) {
//compile error
GenericClass<String> c1 = new GenericClass<>();
c1.setField("TEST");
System.out.println("class1 value > : " + c1.getField());
GenericClass<Double> c2 = new GenericClass<>();
c2.setField(3.141592);
System.out.println("class2 value > : " + c2.getField());
//compile error
GenericClass<String> c3 = new GenericClass<>();
Object result = c3.getClassType(3);
System.out.println("class3 value > : " + result);
}
}
● super - 하한 경계
하한 경계 역시 두 가지 구조로 나누어 살펴보겠습니다. 큰 틀은 상한 경계와 같으나 제네릭 제한의 기준이 다르다고 보면 되겠습니다.
하한 경계란 아래 예시에서 T타입과 T타입이 상속받은 부모 멤버만 가능하도록 하한선을 두는 것을 의미합니다.
<E super T> : E는 T 타입 자신과, T 타입이 상속받은 부모 멤버가 될 수 있다.찾아보니 super의 경우 참조 타입을 지정하여 사용할 수 없고, 와일드 카드와 함께 사용해야 합니다.- <? super T> : ? 타입은 T 타입 자신과 T 타입이 상속받은 부모 멤버가 될 수 있으나, 와일드 카드의 특성상 <?> 가 특정 타입으로 변환되지는 않는다.
● ? - 와일드 카드
와일드 카드 <?>는 타입의 제한을 두지 않는 것을 의미합니다. 자바의 최상위 클래스인 Object의 상한경계를 의미하는 <? extends Object> 와 동일합니다. 즉 타입이 어떤 것이든 올 수 있다는 개념으로 보시면 될 것 같습니다. (any type의 의미보다는 unknown - 알수없음 에 가까운 것 같습니다.)
※ 정리
지금까지 제네릭의 기초적인 내용을 살펴보았습니다. 아직 와일드카드와 공변, 불공변에 대한 학습이 부족하여 정리 후 별도로 포스팅을 해야겠습니다.
※ References
https://st-lab.tistory.com/153
https://blog.naver.com/zzang9ha/222059024135
https://yarisong.tistory.com/48
'development > java' 카테고리의 다른 글
[Java] Stream - GroupingBy, FlatMap (0) | 2024.09.15 |
---|