안녕하세요 커널360 1기 크루 우무룡입니다.
기술세미나 발표주제로 익숙하면서도 헷갈릴 수 있는 제네릭을 선정했습니다. 기본적인 개념에 대해 간단히 알아보고 개인적으로 헷갈렸던 제네릭 메서드와 타입 범위 한정에 대해 주로 알아보겠습니다!
제네릭이란
자바에서 제네릭은 타입 안정성을 위해 jdk 1.5부터 도입된 문법이다. 클래스 내부에서 사용할 데이터 타입을 클래스 내부에서 선언하는 것이 아닌 외부에서 사용자에 의해 지정하는 기법이다.
ArrayList<String> list = new ArrayList<>();
저 꺾쇠 괄호가 바로 제네릭이다. 괄호 안에는 타입명을 기재한다. 그러면 저 리스트 클래스 자료형의 타입은 String 타입으로 지정되어 문자열 데이터만 리스트에 적재할 수 있게 된다.
이처럼 제네릭은 클래스나 메소드에서 사용할 내부 데이터 타입(type)을 파라미터(parameter) 주듯이 외부에서 지정하는 이른바 타입을 변수화 한 기능이라고 이해하면 된다.
제네릭 클래스
클래스 선언문 옆에 제네릭 타입 파라미터가 쓰이면, 이를 제네릭 클래스라고 한다.
class Sample<T> {
private T value; // 멤버 변수 val의 타입은 T 이다.
// T 타입의 값 val을 반환한다.
public T getValue() {
return value;
}
// T 타입의 값을 멤버 변수 val에 대입한다.
public void setValue(T value) {
this.value = value;
}
}
public class Main {
public static void main(String[] args) {
Sample<String> sample = new Sample<String>();
//Sample<String> sample = new Sample<>(); 생성자 부분 타입 생략가능
}
제네릭 클래스의 타입 파라미터는 해당 클래스를 인스턴스화 하는 시점에 입력한 값을 받아와서 해당 파라미터가 지정된 곳으로 전파된다. jdk 1.7 이후부터는 생성자 부분의 제네릭 타입은 생략 가능하다.
제네릭의 장점
재사용성
ArrayList<Integer> list1 = new ArrayList<Integer>();
ArrayList<String> list2 = new ArrayList<String>();
LinkedList<Double> list3 = new LinkedList<Double>():
LinkedList<Character> list4 = new LinkedList<Character>();
흔히쓰는 ArrayList와 LinkedList를 예로 들었다. 위와 같이 ArrayList와 LinkedList에 다양한 타입을 사용할 때에도 IntegerArrayList나 StringArrayList 같은 클래스를 사용할 필요없이 ArrayList에 제네릭에 <원하는 타입="">을 지정해 줌으로써 같은 클래스를 재사용할 수 있었다.원하는>
제네릭을 사용하면 우리가 나중에 어떤 클래스를 작성할 때에도 지원하고자 하는 타입에 따라 모든 별도의 클래스를 만들필요 없이 사용할때 지정해주도록 해서 재사용성을 높일 수 있다.
컴파일 타임 타입검사
제네릭이 없던 jdk 1.5 이전에는 하나의 클래스가 여러 타입을 다루기 위해서 모든 클래스의 조상인 Object 클래스를 인자와 반환값으로 사용했다. 반환된 Object를 원하는 타입으로 사용하기 위해서는 일일이 타입 변환을 해야했고, 그로 인해 런타임 에러가 발생할 가능성이 존재했다.
List list = new ArrayList();
list.add(new Apple());
list.add(new Banana());
Apple apple = (Apple)list.get(0);
Apple apple1 = (Apple)list.get(1); // 런타임 에러! (ClassCastException)
위 예시에는 ArrayList를 제네릭 없이 선언했고 그러면 인자와 반환값을 모두 Object로 사용한다. list에는 Apple만 들어있어야 했으나 개발자가 착각해서 Banana를 추가했다. 다시 객체를 가져올 때 Apple로 형변환 해 가져오려고 했기 때문에 런타임 에러인 ClassCastException이 발생한다.
List<Apple> list = new ArrayList<Apple>();
list.add(new Apple());
list.add(new Banana()); // 컴파일 에러
Apple apple = list.get(0); // 불필요한 캐스팅 제거
제네릭을 사용하면 이런 실수를 사전에 방지할 수 있다. 코드를 실행하기 전 컴파일 타임에 에러를 찾아 알려주기 때문이다. 게다가 타입이 이미 정해져 있기 때문에 불필요하게 형변환을 해줄 필요가 없다.
제네릭 메소드
제네릭 메서드란 클래스에 선언한 제네릭과 관계없이(혹은 클래스에서 선언하지 않더라도) 독립적인 제네릭을 메서드의 타입파라미터로 지정하는 메서드를 의미한다. 보통 아래 예시의 get, set 메서드처럼 클래스의 제네릭을 매개변수로 받거나 반환값으로 사용하는 메서드를 제네릭 메서드로 오해하기 쉽다.
genericMethod처럼 메서드의 선언부에 별도의 타입파라미터를 선언한 메서드가 제네릭 메서드임을 주의하자.
class ClassName<E> {
private E element;
void set(E element) {
this.element = element;
}
E get() {
return element;
}
// 제네릭 메소드
<T> T genericMethod(T o) {
return o;
}
}
public class Main {
public static void main(String[] args) {
ClassName<String> a = new ClassName<>();
a.set("10");
System.out.println("a data : " + a.get());
System.out.println("a E Type : " + a.get().getClass().getName());
// 제네릭 메소드 Integer
System.out.println("<T> returnType : " + a.genericMethod(3).getClass().getName());
// 제네릭 메소드 String
System.out.println("<T> returnType : " + a.genericMethod("ABCD").getClass().getName());
}
}
위 예시의 메인메서드와 아래의 실행결과를 보면 클래스의 제네릭과 독립적이라는 것이 어떤 의미인지 알 수 있다. 위 코드를 실행시키면 아래와 같이 출력된다.
‘a’ 인스턴스를 생성하면서 String으로 타입파라미터를 전달했다. E로 선언된 제네릭은 인스턴스 생성시점에 String으로 결정되어 E타입을 String으로 반환한다.
그러나 genericMethod에 인자를 3이라는 정수로 전달하였더니 ‘a’ 클래스를 생성할때 전달했던 타입과 관계없이 Integer를 반환한다. 제네릭 메서드의 타입은 제네릭 클래스와 달리 메서드를 호출하는 시점에 결정된다. 따라서, 클래스에 선언된 제네릭과 독립적으로 타입을 지정할 수 있다.
그럼 제네릭 클래스의 타입을 가져다 사용하면 되는데 제네릭 클래스와 독립적인 타입을 선언해야 하는 이유는 뭘까? 바로 정적 메서드를 선언할 때는 꼭 필요하기 때문이다.
이전에 제네릭 클래스의 타입은 인스턴스화 할때 결정된다고 했다. 하지만 static 메서드는 인스턴스를 생성하기도 전에 프로그램 실행시 모두 스태틱 메모리 영역에 올라가게 된다. 그래서 인스턴스를 생성하지 않고 바로 사용할 수 있는데, 인스턴스가 생성되기 이전 시점에 제네릭 클래스로 부터 타입을 받아올 수 없다. 따라서, 제네릭이 사용되는 메소드를 정적메소드로 두고 싶은 경우 제네릭 클래스와 별도로 독립적인 제네릭이 사용되어야 한다.
하지만 제네릭 메서드가 꼭 정적 메서드로만 선언되어야 하는 건 아니다. 인스턴스 메서드로 선언한다면 메서드 호출을 위해 클래스의 인스턴스를 생성해서 접근해야겠지만, 여전히 클래스에 선언된 제네릭(또는 선언되지 않았더라도)과 관계없이 독립적인 타입파라미터를 선언해 활용할 수 있다.
제네릭 타입 범위 한정
제네릭을 사용하면 타입을 원하는 대로 자유롭게 지정할 수 있다는 것이 장점이지만 또, 너무 자유롭다는 것이 단점이 될 수도 있다.
예를 들어, 개발자가 아래와 같은 계산기 클래스를 만들었다고 하자. 다양한 숫자타입의 계산을 지원하기 위해 제네릭 클래스로 선언했다. 하지만, 단순히 <T> 로 지정하게 되면 숫자에 관련된 래퍼 클래스 뿐만 아니라 String이나 다른 클래스들도 대입이 가능하다는 점이 문제다.
// 숫자만 받아 계산하는 계산기 클래스 모듈
class Calculator<T> {
void add(T a, T b) {}
void min(T a, T b) {}
void mul(T a, T b) {}
void div(T a, T b) {}
}
public class Main {
public static void main(String[] args) {
// 제네릭에 아무 타입이나 모두 할당이 가능
Calculator<Number> cal1 = new Calculator<>();
Calculator<Object> cal2 = new Calculator<>();
Calculator<String> cal3 = new Calculator<>();
Calculator<Main> cal4 = new Calculator<>();
}
}
개발자는 숫자타입만 지원하는 것이 의도였으므로 원하지 않는 다른 자료형은 할당하지 못하도록 제한하는 기능이 필요하다. 그래서 필요한 것이 제한된 타입 매개변수 (Bounded Type Parameter) 이다. 위와 같이 서로 다른 클래스들이 상속관계를 갖고 있다고 가정해보자.
타입을 한정하는데 사용하는 키워드들은 다음과 같다 extends
, super
, ?
보통 이해하기 쉽게 다음과 같이 부른다. extends : 상한 경계 super : 하한 경계 ? : 와일드 카드(Wild card)
<T extends B> // B와 C타입만 올 수 있음
<T extends E> // E타입만 올 수 있음
<T extends A> // A, B, C, D, E 타입이 올 수 있음
<? extends B> // B와 C타입만 올 수 있음
<? extends E> // E타입만 올 수 있음
<? extends A> // A, B, C, D, E 타입이 올 수 있음
<K super B> // B와 A타입만 올 수 있음
<K super E> // E, D, A타입만 올 수 있음
<K super A> // A타입만 올 수 있음
<? super B> // B와 A타입만 올 수 있음
<? super E> // E, D, A타입만 올 수 있음
<? super A> // A타입만 올 수 있음
주석에 적혀있듯이 extends 키워드는 extends 뒤에 오는 타입을 최상위 타입으로 한정짓는 것이다. 계산기 예시와 같이 숫자와 관련된 클래스만 타입으로 받고싶은 경우 Number class를 상속받는 숫자형 래퍼 클래스들로만 지정하고 싶은 경우 아래와 같이 쓸 수 있다.
public class ClassName <K extends Number> { ... }
super 키워드는 super 뒤에 오는 타입이 최하위 타입으로 한정된다. 대표적으로는 해당 객체가 업캐스팅(Up Casting)이 될 필요가 있을 때 사용한다.
예로들어 ‘과일’이라는 클래스가 있고 이 클래스를 각각 상속받는 ‘사과’클래스와 ‘딸기’클래스가 있다고 가정해보자.
이 때 각각의 사과와 딸기는 종류가 다르지만, 둘 다 ‘과일’로 보고 자료를 조작해야 할 수도 있다. (예로 들면 과일 목록을 뽑는다거나 등등..) 그럴 때 ‘사과’를 ‘과일’로 캐스팅 해야 하는데, 과일이 상위 타입이므로 업캐스팅을 해야한다. 이럴 때 쓸 수 있는 것이 바로 super이다.
<? extends E>와 <K extends E> 차이
위 주석에서 보다시피 <? extends(또는 super) E>
와 <K extends(또는 super) E>
가 타입을 한정하는 범위는 똑같은데 어떤 차이가 있는지 궁금할 수 있다. 차이점은 K는 특정 타입으로 지정이 되지만, ?는 타입이 지정되지 않는다는 의미다. 즉, K extends E는 타입 지정이 필요한 제네릭 클래스를 선언할때 사용하고 ? extends E는 이미 만들어진 제네릭 클래스를 매개변수로 받거나 반환타입을 받을때 사용한다고 보면 된다.
재귀적 타입 한정
재귀적 타입 한정이란 자기 자신이 들어간 표현식을 사용하여 타입 매개변수의 허용 범위를 한정 시키는 것을 말한다. 실무에선 주로 Comparable 인터페이스와 함께 쓰인다.
예를 들어, 다음과 같이 <E extends Comparable<E>>
제네릭 E의 타입 범위를 Comparable
public class ClassName <E extends Comparable<E>> { ... }
public class Student implements Comparable<Student> {
@Override
public int compareTo(Student s) { ... };
}
public class Main {
public static void main(String[] args) {
ClassName<Student> a = new ClassName<Student>();
}
}
위 예시로 보면 a 인스턴스의 E는 Student로 설정되었다. E가 Comparable을 extends 하므로 E는 Comparable를 구현한 타입이 와야 한다. 그래서 Student는 Comparable을 implements하면서 compareTo 메서드를 @Override하고 있다. 동시에 Comparable의 타입이 Student로 정해지므로 메서드 내에서 Student 타입을 활용하고 있다.
개발자가 Student 객체끼리만 비교하고자 하는 의도를 갖고 있을때 위 예시처럼 선언할 수 있다.
Comparable는 객체끼리 비교를 해야 할때 compareTo() 메서드를 오버라이딩할때 구현하는 인터페이스이다.자바에서 Integer, Double, String 등이 값 비교가 되는 이유가 기본적으로 Comparable를 구현하고 있기 때문이다.
public class ClassName <E extends Comparable<? super E>> { ... }
그런데 위와 같은 형태도 자주 보게 될 것이다. 대표적인 예시로 Collections의 sort 메서드가 위와 같은 타입 한정을 사용한다. 왜 Comparable<E>
가 아닌 Comparable<? super E>
일까? 아래의 예시를 보자.
//public class ClassName <E extends Comparable<E>> { ... } // Error가능성 있음
public class ClassName <E extends Comparable<? super E>> { ... } // 안전성이 높음
public class Person {...}
public class Student extends Person implements Comparable<Person> {
@Override
public int compareTo(Person o) { ... };
}
public class Main {
public static void main(String[] args) {
SaltClass<Student> a = new SaltClass<Student>();
}
}
이제는 개발자가 Student 객체끼리 뿐만 아니라 상위객체와도 비교하고자 하는 의도가 생겼다.
예시를 보면, Student 클래스는 Person을 상속하고 Comparable<Person>
을 구현하고 있다. 만약 ClassName을 <E extends Comparable<E>>
로 선언한다면 ClassName<Student>
는 Comparable<Student>
만을 구현해야 하는데 Comparable<Person>
을 구현하고 있으므로 컴파일 에러를 일으킨다.
<E extends Comparable<? super E>>
로 선언해야 더 유연한 제약을 제공한다. 이 부분은 E 또는 E의 슈퍼클래스가 Comparable을 구현하도록 요구하므로, ClassName<Student>
가 Comparable<Person>
을 구현하는 것이 가능하다. 또는, Person이 Comparable<Person>
을 구현하더라도 Student는 Person을 상속받기 때문에 Student 객체끼리 뿐만 아니라 상위객체와도 비교가 가능하다.
즉, <E extends Comparable<? super E>>
는 쉽게 말하자면 E 타입 또는 E 타입의 슈퍼클래스가 Comparable을 의무적으로 구현해야한다는 뜻으로 슈퍼 클래스 타입으로 UpCasting하고자 할 때 타입 안정성을 보장받을 수 있다.
참고 출처 :
https://st-lab.tistory.com/153 [st-lab:티스토리]
https://inpa.tistory.com/entry/JAVA-☕-제네릭Generics-개념-문법-정복하기 [Inpa Dev 👨💻:티스토리]