본글은 'Do it 자바 완전 정복' 책을 통한 공부내용을 정리한 글입니다.
제네릭 클래스와 제네릭 인터페이스
자바에서는 다양한 종류의 클래스와 인터페이스를 제공하며 이들을 내부 멤버에서 활용하는 클래스를 작성하고자 할 때에는 많은 가짓수의 클래스를 생성해야한다. 이러한 비효율성을 한 번에 해결하는 데 필요한 문법 요소가 바로 제네릭(generic)이다.
제네릭 없이 여러 객체를 저장하는 클래스 작성
제네릭 개념 이전의 자바 문법 지식만으로 다음의 예시 코드를 살펴보자.
class Apple {}
class Goods1 {
private Apple apple = new Apple();
public Apple getApple() {
return apple;
}
public Apple setApple(Apple apple) {
this.apple = apple;
}
}
class Pencil {}
class Goods2 {
private Pencil pencil = new Pencil();
public Pencil getPencil() {
return pencil;
}
public Pencil setPencil(Pencil pencil) {
this.pencil = pencil;
}
}
public class BeforeGeneric {
public static void main(String[] args) {
Goods1 goods1 = new Goods1();
goods1.setApple(new Apple()); // Apple 타입만 입력가능
Apple apple = goods1.getApple();
Goods2 goods2 = new Goods2();
goods2.getPencil(new Pencil()); // Pencil 타입만 입력가능
Pencil pencil = goods2.getPencil();
}
}
위의 예시 코드는 사과와 연필을 저장, 관리하기 위해 각각의 기능을 수행하는 클래스 2개가 존재한다. 위의 코드를 사용시에는 상품이 추가될 때 마다 새롭게 클래스를 추가해야 하는것이다.
이에 대한 해결책으로, 다음과 같이 필드를 모든 자바 클래스의 최상위 클래스인 Object 타입으로 선언하는 것이다.
예시 코드
class Apple {}
class Pencil {}
class Goods {
private Object object = new Object();
public Object getObject() {
return object;
}
public Object setObject() {
this.object = object;
}
}
public class Solution1_UsingObect {
public static void main(String[] args) {
Goods goods1 = new Goods();
goods1.setObject(new Apple());
Apple apple = (Apple)goods1.getObject();
Goods goods2 = new Goods();
goods2.setObject(new Pencil());
Pencil pencil = (Pencil)goods2.getObject();
}
}
이때 주의해야할 점은 데이터 저장시에는 상관 없지만, 저장된 데이터를 각각의 타입으로 꺼내오기 위해서는 저장된 형태로 캐스팅 해야한다. 만일 아래 코드와 같이 실제로는 Apple 객체를 저장, 가져온 Object를 Pencil 객체로 캐스팅 하게된다면 문법 오류는 발생하지 않으나, 캐스팅이 잘못 되었으므로 실행 중 예외를 발생시키고 프로그램이 강제 종료될 것이다. 이를 약한 타입 체크(weak type checking)라고 한다. 약한 타입 체크는 잘못된 타입 캐스팅에도 문법 오류를 발생시키지 않는것을 말한다.
Goods3 goods3 = new Goods();
goods3.set(new.Apple());
Pencil pen = (Pencil)goods3.get();
// 잘못된 캐스팅. (약한 타입 체크)
제네릭의 문법
제네릭을 사용하면 앞서 언급한 문제점을 모두 해결할 수 있다. (모든 타입의 상품 저장가능, 잘못된 캐스팅 시 문법 오류 발생) 이를 강한 타입 체크(strong type checking)라고 한다. 먼저 제네릭 클래스와 제네릭 인터페이스의 문법 구조에 대해 알아보자.
제네릭 클래스와 제네릭 인터페이스 정의
제네릭 클래스와 제네릭 인터페이스를 정의하는 방법은 클래스명 다음에 <제네릭 타입 변수명(들)>을 삽입하는 것이다.
// 제네릭 타입 변수명이 1개 일 때
접근지정자 class 클래스명<T> {
...
}
접근지정자 interface 클래스명<T> {
...
}
// 제네릭 타입 변수명이 2개일 때
접근지정자 class 클래스명<K, V> {
...
}
접근지정자 interface 클래스명<K, V> {
...
}
제네릭 타입 변수명은 사용자가 임의로 지정할 수 있지만 일반적으로 영문 대문자 한글자를 사용하며 관례적으로 사용하는 제네릭 타입 변수명과 의미는 다음과 같다.
T | 타입(Type) |
K | 키(Key) |
V | 값(Value) |
N | 숫자(Number) |
E | 원소(Element) |
제네릭 클래스의 객체 생성
객체 생성 과정은 일반 클래스의 객체 생성과 비슷하다. 다만 객체를 생성할 때 제네릭 타입 변수에 실제 타입을 대입하는 것에 차이가 있다.
클래스명<실제제네릭타입> 참조변수명 = new 클래스명<실제제네릭타입>();
// 또는
클래스명<실제제네릭타입> 참조변수명 = new 클래스명<>();
예시 코드
class MyClass<T> {
private T t;
public T get() {
return t;
}
public void set(T t) {
this.t = t;
}
}
public class SingleGenericArgument {
public static void main(String[] args) {
MyClass<String> mc1 = new MyClass<String>();
mc1.set("안녕");
System.out.println(mc1.get());
MyClass<Integer> mc2 = new MyClass<>();
mc2.set(100);
System.out.println(mc2.get());
}
}
첫 번째 객체 mc1은 객체를 생성할 때 실제 제네릭 타입으로 String을 대입했다. 즉, MyClass<T>에서의 T의 제네릭 타입 변수가 모두 String으로 치환된다고 생각하면 된다.
두 번째 객체 mc2는 객체를 생성할 때 Integer 타입을 대입했으므로 Integer 타입을 저장 및 관리할 수 있는 객체가 된다.
즉, 제네릭 클래스는 클래스를 정의하는 시접에 타입을 지정하는 것이 아니라 객체를 생성하는 시점에 타입을 지정하기 때문에 하나의 제네릭 클래스로 다양한 타입의 값을 저장 및 관리할 수 있는 객체를 생성할 수 있는것이다.
단순히 하나의 클래스로 다양한 타입의 객체를 저장하고 관리할 수 있게 하는 것은 Object 타입의 필드를 사용하는 것만으로도 가능하지만, 모든 객체는 Object 타입이므로 실수로 엉뚱한 객체를 저장하더라도 오류가 발생하지 않으며, getter 메서드를 꺼내온 값을 항상 다운캐스팅을 수행해야 한다.
제네릭 클래스를 활용한다면 입출력이 각각 객체를 생성할 때 정해진 타입으로 확정되므로 입력 타입을 정확히 확인(강한 타입 체크)할 수 있고, 출력 또한 해당 타입으로 리턴되므로 다운캐스팅이 필요없는 것이다.
이번에는 제네릭 타입 변수가 2개일 때의 예시 코드를 살펴보자
객체를 생성할 때 사용한 제네릭 타입은 순서대로 각각의 제네릭 타입 변수로 치환된다.
class KeyValue<K, V> {
private K key;
private V value;
public K getKey() {
return key;
}
public K setKet(K key) {
this.key = key;
}
public V getValue() {
return value;
}
public V SetValue(V value) {
this.value = value;
}
}
public class TwoGenericArgument {
public static void main(String[] args) {
KeyValue<String, Integer> kv1 = new KeyValue<>();
// 제네릭 타입 변수 K, V가 각 String, Integer 타입으로 결정
kv1.setKey("사과"); kv1.setValue(1000);
String key1 = kv1.getKey();
int value1 = kv1.getValue();
System.out.println("key : " + key1 + "value : " + value1);
KeyValue<Integer, String> kv2 = new KeyValue<>();
// 제네릭 타입 변수 K, V가 각 Integer, String 타입으로 결정
kv2.setKey(404); kv2.setValue("Not Found");
String key2 = kv2.getKey();
int value2 = kv2.getValue();
System.out.println("key : " + key2 + "value : " + value2);
KeyValue<String, Void> kv3 = new KeyValue<>();
// Void : 해당 제네릭 타입 변수의 필드를 사용하지 않음
kv3.setKey("키 값만 사용");
String key3 = kv3.getKey();
System.out.println("key : " + key3);
}
}
이제 사과와 연필을 저장했었던 코드를 제네릭 클래스를 사용해 바꿔보자
class Apple {}
class Pencil {}
class Goods<T> {
private T t;
public T get() {
return t;
}
public T set(T t) {
this.t = t;
}
}
public class Solution2_Generic {
public static void main(String[] args) {
Goods<Apple> goods1 = new Goods<>();
goods1.set(new Apple());
Apple apple = goods1.get(); // 다운 캐스팅 필요x
Goods<Pencil> goods2 = new Goods<>();
goods2.set(new Pencil());
Pencil pencil = goods2.get(); // 다운 캐스팅 필요x
Goods<Apple> goods3 = new Goods<>();
goods3.set(new Apple());
// Pencil pencil = goods3.get(); 문법오류 발생 (잘못된 타입 선언)
}
}
제네릭 클래스의 객체를 생성시 <실제 제네릭 타입>을 생략해도 문법 오류는 발생하지 않는다. 이때 제네릭 타입 변수에는 올 수 있는 타입 중 최상위 클래스인 Object가 대입된 것으로 간주해 객체가 생성된다.
즉, 제네릭 클래스 A<T>가 있는 경우에 A a = new A()와 같이 객체 생성시에는 A<Object> a = new A<Object>()와 동일한 의미를 가진다.
제네릭 메서드
제네릭 메서드의 정의와 호출
클래스 전체를 제네릭으로 선언하는 대신, 일반 클래스의 특정 메서드만 제네릭으로 선언할 수도 있다. 이를 제네릭 메서드라고 한다. 리턴 타입 또는 입력매개변수 타입을 제네릭 타입 변수로 선언하며 호출되는 시점에 실제 제네릭 타입을 지정한다.
먼저 제네릭 메서드의 문법 구조를 살펴보자
// 제네릭 타입 변수명이 1개 일때
접근지정자 <T> T 메서드명 (T t) {
..
}
// 제네릭 타입 변수명이 2개 일때
접근지정자 <T, V> T 메서드명 (T t, V v) {
..
}
// 매개변수에만 제네릭이 사용됐을 때
접근지정자 <T> void 메서드명 (T t) {
..
}
// 리턴타입에만 제네릭이 사용됐을 때
접근 지정자 <T> T 메서드명 (int a) {
..
}
제네릭 메서드 호출의 문법 구조도 살펴본다
참조객체.<실제 제네릭 타입>메서드명(입력매개변수);
제네릭 메서드는 메서드를 호출하 때 실제 제네릭 타입을 결정해주므로 호출할 메서드명 앞에 <실제 제네릭 타입>을 삽입해 표현해주어야 하지만 입력매개변수에 제네릭 타입 변수가 사용되어 입력매개변수의 타입만으로 실제 제네릭 타입을 예측할 수 있을 때에는 생략이 가능하다.
public <T> T method1(T t) {
return t;
}
위의 메서드가 있을때 원칙적으로 참조객체.<Integer>method1(100)과 같이 사용해야 하지만, 입력매개변수를 통해 제네릭 변수의 실제 타입을 예측할 수 있으므로 참조객체.method1(100)과 같이 표현해도 무방하다.
예시 코드
class GenericMethods {
public <T> T method1(T t) {
return t;
}
public <T> boolean method2(T t1, T t2) {
return t1.equals(t2);
}
public <K, V> void method3(K k, V v) {
System.out.println(k + ":" + v);
}
}
public class GenericMethod {
public static void main(String[] args) {
GenericMethods gm = new GenericMethods();
String str1 = gm.<String>method1("안녕");
String str2 = gm.method2("안녕");
// 입력 매개변수로 제네릭 타입을 유추할 수 있으므로 제네릭 타입지정 생략 가능
boolean bool1 = gm.<Double>method2(2.5, 2.5);
boolean bool2 = gm.method2(2.5, 2.5);
// 입력 매개변수로 제네릭 타입을 유추할 수 있으므로 제네릭 타입지정 생략 가능
gm.<String, Integer>method3("국어", 80);
gm.method3("국어", 80);
// 입력 매개변수로 제네릭 타입을 유추할 수 있으므로 제네릭 타입지정 생략 가능
}
}
제네릭 메서드 내에서 사용할 수 있는 메서드
제네릭 메서드를 정의하는 시점에는 아직 어떤 타입이 입력될지 모른다. 제네릭 메서드 정의하는 시점에서는 특정 타입에 포함되어 있는 메서드(대표적으로는 String 객체의 length() 등)는 메서드 정의 시점에서 사용할 수 없다. 제네릭 메서드가 호출되는 시점에서 실제 제네릭 타입이 어떤 것으로 결정되더라도 자바의 모든 클래스는 Object 클래스의 자식 클래스 이므로 Object에서 물려받은 메서드만 사용할 수 있다. 즉, 어떤 타입이 제네릭 타입 변수로 확정되더라도 항상 사용할 수 있는 메서드만 제네릭 메서드 내부에서 사용할 수 있는 것이다.
class A {
public <T> void method(T t) {
// System.out.println(t.length()); String 클래스의 메서드이므로 사용 불가능
System.out.println(t.equals("안녕"));
}
}
public class AvailableMethodInGenericMethod {
public static void main(String[] args) {
A a = new A();
a.<String>method("안녕");
}
}
사실 제네릭 메서드 내에서 Object의 메서드만 활용할 수 있다면 제네릭 메서드의 활용 범위는 매우 좁을 것이다. 이에대한 해결책이 바로 제네릭 타입의 범위 제한인데. 이는 아래에서 다룬다.
제네릭 타입 범위 제한
제네릭 타입 범위 제한의 필요성
제네릭 클래스 또는 제네릭 메서드를 사용할 때는 각각의 객체를 생성할 때와 메서드를 호출 할 때 제네릭 타입을 지정하므로 다양한 타입을 처리할 수 있다는 장점이 있다. 다음 상황을 고려해보자. 사과, 배, 딸기는 과일 클래스를 상속해 만든 클래스고, 연필, 지우개, 볼펜은 문구 클래스를 상속받은 자식 클래스이다. 이때 과일 또는 문구류의 종류만 저장 및 관리하는 클래스만 생성하고 싶다면? 일반적인 제네릭 클래스 또는 제네릭 메서드는 타입 변수에 모든 타입이 올 수 있기 때문에 구현할 수 없다.
이런 기능을 구현하려면 제네릭 타입으로 올 수 있는 실제 타입을 제한해야 한다. 이것이 바로 제네릭 타입의 범위 제한(bound)다. 제네릭 타입의 범위 제한으로 얻을 수 이는 장점은 입력매개변수로 제네릭 타입의 객체 t가 전달되었을 때 메서드 내부에서 사용할 수 있는 객체 t의 메서드 종류다. 해당 제네릭 메서드의 내부에서는 해당 객체의 메서드를 모두 사용할 수 있게 된다.
제네릭 타입 범위 제한의 종류와 타입 범위 제한 방법
제네릭 클래스와 제네릭 메서드, 제네릭 타입을 입력 매개변수로 갖는 일반메서드를 정의하는 과정에서 제네릭 타입의 범위를 제한한다.
제네릭 클래스의 타입 제한
<제네릭 타입 변수 extends 상위 클래스>와 같이 제네릭 타입으로 대입될 수 있는 최상위 클래스를 extends 키워드와 함께 정의한다.
접근지정자 class 클래스명<T extends 최상위클래스/인터페이스명> {
}
class Goods<T extends Fruit> { // 제네릭 타입으로 Fruit 또는 그 자식 클래스만 대입 가능
private T t;
public T get() { return t; }
public T set() { this.t = t; }
}
여기에서 사용한 extends 키워드는 상속에서 사용했을 때 처럼 상속하라는 의미가 아닌 최상위 클래스/인터페이스로 지정한다는 의미를 갖는다. 따라서, 뒤에 나오는 요소가 클래스인지 인터페이스인지에 상관없이 항상 extends 키워드를 사용한다.
예시 코드
class A {}
class B extends A {}
class C extends B {}
class D <T extends B> {
private T t;
public T get() { return t; }
public T set(T t) { this.t = t; }
}
public class BoundedTypeOfGenericClass {
public static void main(String[] args) {
// D<A> d1 = new D<>(); 최상위 클래스를 B로 지정했으므로 불가능
D<B> d2 = new D<>();
D<C> d3 = new D<>();
D d4 = new D(); // D<B> d2 = new D<>();와 동일함
d2.set(new B());
d2.set(new C());
// d3.set(new B()); d3객체는 생성시 제네릭 타입으로 C를 지정하므로 B 객체 입력불가
d3.set(new C());
d4.set(new B());
d4.set(new C());
}
}
제네릭 메서드의 타입 제한
제네릭 클래스와 마찬가지로 <제네릭 타입 변수 extends 상위 클래스>와 같이 올 수 있는 최상위 타입을 정의하며, 클래스와 인터페이스 모두 extends 키워드를 사용한다.
접근지정자 <T extends 최상위 클래스/인터페이스 명> T 메서드명(T t) {
// 최상위 클래스의 메서드 사용 가능
}
중요한 것은 메서드 내부에서 사용할 수 있는 메서드의 종류다. 타입을 제한하지 않을 때는 모든 타입의 최상위 클래스인 Object 메서드만 사용할 수 있었다. 같은 원리로 <T extends String>과 같이 표현하면 올 수 있는 모든 타입의 최상위 타입이 String이기 때문에 해당 제네릭 메서드는 String 객체의 멤버를 사용할 수 있다.
참고로 String은 자식 클래스를 생성할 수 없는 final 클래스로, 위와같이 표현하면 실제 제네릭 타입으로 올 수 있는 타입은 String밖에 없다.
예시 코드
class A {
public <T extends Number> void method1(T t) {
System.out.println(t.intValue());
}
}
interface MyInterface {
public abstract void print();
}
class B {
public <T extends MyInterface> void method1(T t) {
t.print();
}
}
public class BoundedTypeOfGenericMethod {
public static void main(String[] args) {
A a = new A();
a.method1(5.8);
B b = new B();
b.method1(new MyInterface() {
@Override
public void print() {
System.out.println("print() 구현");
}
});
}
}
메서드 매개변수 일 때 제네릭 클래스의 타입 제한
입력매개변수에 입력되는 제네릭 클래스 객체의 제네릭 타입은 크게 4가지 형태로 제한할 수 있다.
리턴타입 메서드명(제네릭 클래스명<제네릭 타입명> 참조 변수명) {}
리턴타입 메서드명(제네릭 클래스명<?> 참조변수명) {}
리턴타입 메서드명(제네릭 클래스명<? extends 상위클래스/인터페이스> 참조 변수명) {}
리턴타입 메서드명(제네릭 클래스명<? super 하위클래스/인터페이스> 참조 변수명) {}
// example
method(Goods<A> v) // 제네릭 타입 = A인 객체만 가능
method(Goods<?> v) // 제네릭 타입 = 모든 타입인 객체 가능
method(Goods<? extends B> v) // 제네릭 타입 = B 또는 B의 자식 클래스인 객체만 가능
method(Goods<? super B> v) // 제네릭 타입 = B 또는 B의 부모 클래스인 객체만 가능
예시 코드
class A {}
class B extends A {}
class C extends B {}
class D extends C {}
class Goods<T> {
private T t;
public T get() { return t; }
public void set(T t) { this.t = t; }
}
class Test {
void method1(Goods<A> g) {}
void method2(Goods<?> g) {}
void method3(Goods<? extends B> g) {}
void method4(Goods<? super B> g) {}
}
public class BoundedTypeOfInputArgruments {
public static void main(String[] args) {
Test t = new Test();
// case 1 : A 객체만 가능
t.method1(new Goods<A>());
// case 2 : 모든 객체 가능
t.method2(new Goods<A>());
t.method2(new Goods<B>());
t.method2(new Goods<C>());
t.method2(new Goods<D>());
// case 3 : B또는 B에게 상속받은 클래스만 가능
t.method3(new Goods<B>());
t.method3(new Goods<C>());
t.method3(new Goods<D>());
// case 4 : B 또는 B의 상위 클래스만 가능
t.method4(new Goods<A>());
t.method4(new Goods<B>());
}
}
제네릭의 상속
제네릭 클래스의 상속
부모 클래스가 제네릭 클래스일 때, 이를 상속한 자식 클래스도 제네릭 클래스가 된다. 자식 클래스는 제네릭 타입 변수를 추가해 정의할 수도 있다. 따라서 자식 클래스의 제네릭 타입 변수의 개수는 항상 부모보다 같거나 많을 것이다.
예시 코드
class Parent<T> {
T t;
public T getT() { return t; }
public void setT(T t) { this.t = t; }
}
class Child1<T> extends Parent<T> {}
class Child2<T, V> extends Parent<T> {
V v;
public V getV() { return v; }
public void setV(V v) { this.v = v; }
}
public class InheritanceGenericClass {
public static void main(String[] args) {
Parent<String> p = new Parent<>();
p.setT("부모 제네릭 클래스");
Child1<String> c1 = new Child1<>();
c1.setT("자식1 제네릭 클래스");
Child2<String, Integer> c2 = new Child2<>();
c2.setT("자식2 제네릭 클래스");
c2.setV(100);
}
}
제네릭 메서드의 상속
제네릭 메서드를 포함한 일반 클래스를 상속해 자식 클래스를 생성할 때에도 부모 클래스 내의 제네릭 메서드는 그대로 자식 클래스로 상속된다.
예시 코드
class Parent {
<T extends Number> void print(T t) {
System.out.println(t);
}
}
class Child extends Parent {}
public class InheritanceGenericMethod {
public static void main(String[] args) {
Parent p = new Parent();
p.<integer>print(10);
p.print(10);
// 자식 클래스에서 제네릭 메서드 이용
Child c = new Child();
c.<Double>print(5.8);
c.print(5.8);
}
}
'Backend > JAVA' 카테고리의 다른 글
[JAVA] #17.3 Set<E> 컬렉션 인터페이스 (0) | 2023.07.19 |
---|---|
[JAVA] #17 컬렉션 프레임워크, List<E> 컬렉션 인터페이스 (1) | 2023.07.18 |
[JAVA] #15.5 쓰레드의 상태 (0) | 2023.07.15 |
[Java] #15.4 쓰레드의 동기화 (0) | 2023.07.08 |
[Java] #15.3 쓰레드의 속성 (0) | 2023.07.05 |