이 글은 생활코딩 제네릭 강의를 기반으로 작성하였습니다.
제네릭(Generic)은 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 방법을 말한다.
이 코드는 p1.info
의 데이터 타입을 String
으로, p2.info
의 데이터 타입은 StringBuilder
로 지정한다.
좀 더 이해해보기 위해 코드를 살펴보겠다.
class Person <T>{
public T info;
}
클래스를 선언하는 부분을 보면 info
의 데이터 타입이 T
이다. 하지만 T
라는 데이터 타입은 존재하지 않는다. 이는 아래 코드에서 <>사이에 지정된 데이터 타입에 의해 결정된다.
Person<String> p1 = new Person<String>();
Person<String> p1
은 변수 p1
의 데이터 타입을 지정하고, new Person<String>();
은 인스턴스를 생성한다.
이처럼 제네릭은 데이터 타입을 확정하지 않고 인스턴스를 생성할 때 데이터 타입을 지정하는 기능을 한다.
class StudentInfo{
public int grade;
StudentInfo(int grade){ this.grade = grade; }
}
class StudentPerson{
public StudentInfo info;
StudentPerson(StudentInfo info){ this.info = info; }
}
class EmployeeInfo{
public int rank;
EmployeeInfo(int rank){ this.rank = rank; }
}
class EmployeePerson{
public EmployeeInfo info;
EmployeePerson(EmployeeInfo info){ this.info = info; }
}
public class GenericDemo {
public static void main(String[] args) {
StudentInfo si = new StudentInfo(2);
StudentPerson sp = new StudentPerson(si);
System.out.println(sp.info.grade); // 2
EmployeeInfo ei = new EmployeeInfo(1);
EmployeePerson ep = new EmployeePerson(ei);
System.out.println(ep.info.rank); // 1
}
}
위 코드를 보면 StudentInfo
와 EmployeePerson
은 사실 같은 구조를 가진다. 즉 중복이 발생하고 있는 것이다.
class StudentInfo{
public int grade;
StudentInfo(int grade){ this.grade = grade; }
}
class EmployeeInfo{
public int rank;
EmployeeInfo(int rank){ this.rank = rank; }
}
class Person{
public Object info;
Person(Object info){ this.info = info; }
}
public class GenericDemo {
public static void main(String[] args) {
Person p1 = new Person("부장");
EmployeeInfo ei = (EmployeeInfo)p1.info;
System.out.println(ei.rank);
}
}
StudentInfo
와 EmployeeInfo
를 하나의 Person
클래스로 합친다.
이제 info
변수의 타입을 지정한다. EmployeeInfo
와 StudentInfo
클래스를 대체할 수 있는 것을 찾아야하는데, 모든 클래스의 상위인 Object
를 사용한다. 이렇게 되면 info
에 어떤 클래스든 지정할 수 있다.
그런데 사실 이 코드에는 문제가 있다. IDE에서 오류가 보이지 않아 성공적으로 컴파일된 것처럼 보이지만, 실행하면 오류가 발생한다.
아래의 코드를 보자.
Person p1 = new Person("부장");
info
의 데이터 타입이 Object
이므로 모든 객체가 들어갈 수 있다. 따라서 info
가 EmployeeInfo
객체가 아닌 String
이 와도 컴파일 에러가 발생하지 않는 것이다. 문법적으로 오류가 없어보이지만 코드의 취지, 설계에는 부합하지 않다.
Java는 컴파일 언어와 인터프리터 언어를 혼합한 형태인 하이브리드 언어이다. 컴파일 언어라는 장점을 살려 모든 에러가 컴파일 단계에서 발생해야하지만 그렇지 않다. 에러가 던져지는 런타임 단계는 실제로 애플리케이션이 동작하고 있으므로 심각한 문제가 발생할 수 있는 것이다.
이를 모든 타입이 올 수 있기 때문에, 타입에 대해 안전하지 않다고 한다.
class StudentInfo{
public int grade;
StudentInfo(int grade){ this.grade = grade; }
}
class EmployeeInfo{
public int rank;
EmployeeInfo(int rank){ this.rank = rank; }
}
class Person<T>{
public T info;
Person(T info){ this.info = info; }
}
public class GenericDemo {
public static void main(String[] args) {
Person<EmployeeInfo> p1 = new Person<EmployeeInfo>(new EmployeeInfo(1));
EmployeeInfo ei1 = p1.info;
System.out.println(ei1.rank); // 성공
Person<String> p2 = new Person<String>("부장");
String ei2 = p2.info;
System.out.println(ei2.rank); // 컴파일 실패
}
}
p1
은 제네릭을 이용해서 외부에서 p1
의 타입을 정확하게 지정했기 때문에 잘 동작한다.
반면 p2
는 컴파일 오류가 발생한다. p2.info
가 String
인데 String
은 rank
필드가 없기 때문이다.
이처럼 Generic을 사용하면 1) 컴파일 단계에서 오류를 검출할 수 있고, 2) 중복의 제거와 타입 안전성을 동시에 추구할 수 있다.
클래스 내에서 여러개의 제네릭 필요로 할 수 있다. 이때는 서로 다른 이름으로 ,
를 사용해서 나타낼 수 있다.
+) 제네릭을 나타낼 때 T, S 문자들을 자주 볼 수 있는데, 제네릭도 컨벤션이 있다고 한다.
class EmployeeInfo{
public int rank;
EmployeeInfo(int rank){ this.rank = rank; }
}
class Person<T,S>{
public T info;
public S id;
Person(T info, S id){
this.info = info;
this.id = id;
}
}
제네릭은 참조 데이터 타입만 사용할 수 있고, 기본 데이터 타입(int, char…)는 사용할 수 없다. 기본 데이터 타입을 사용하려면 Wrapper를 사용해야 한다.
class EmployeeInfo{
public int rank;
EmployeeInfo(int rank){ this.rank = rank; }
}
class Person<T,S>{
public T info;
public S id;
Person(T info, S id){
this.info = info;
this.id = id;
}
}
public class GenericDemo {
public static void main(String[] args){
EmployeeInfo e = new EmployeeInfo(1);
Integer id = new Integer(10);
Person<EmployeeInfo, Integer> p1 = new Person<EmployeeInfo, Integer>(e, i);
System.out.println(p1.id.intValue());
}
}
Integer id = new Integer(10);
는 기본 데이터 타입인 int를 참조 데이터 타입으로 변환한다. 이러한 클래스를 wrapper 클래스라고 한다.
만약 id
를 참조 데이터 타입인 String
으로 변환했다면 String
형태의 id
를 사용할 수 있다.
p1
과 p2
는 동일하게 동작하는데, 이미 e
와 i
의 데이터 타입을 알기 때문이다.
public class GenericDemo {
public static void main(String[] args){
EmployeeInfo e = new EmployeeInfo(1);
Integer i = new Integer(10);
Person<EmployeeInfo, Integer> p1 = new Person<EmployeeInfo, Integer>(e, i);
Person p2 = new Person(e, i);
}
}
제네릭은 메서드 단계에서도 사용할 수 있다. 다음은 제네릭을 설명한 예시인데 코드로만 봐서 잘 이해가 가지 않기 때문에 그림으로 나타내겠다.
class EmployeeInfo{
public int rank;
EmployeeInfo(int rank){ this.rank = rank; }
}
class Person<T,S>{
public T info;
public S id;
Person(T info, S id){
this.info = info;
this.id = id;
}
public <U> void printInfo (U info){
System.out.println(info);
}
}
public class GenericDemo {
public static void main(String[] args){
EmployeeInfo e = new EmployeeInfo(1);
Integer id = new Integer(10);
Person p1 = new Person(e, i);
p1.<EmployeeInfo>printInfo(e);
}
}
제네릭은 데이터 타입을 인스턴스화 할 때 지정하기 때문에 제네릭으로 들어갈 수 있다. 이를 제한할 수 있는 방법은 무엇일까?
extends
는 제네릭으로 올 수 있는 데이터 타입을 특정 클래스의 자식으로 제한한다.
abstract class Info{
public abstract int getLevel();
}
class EmployeeInfo extends Info{
public int rank;
EmployeeInfo(int rank){this.rank = rank;}
public int getLevel(){
return this.rank;
}
}
class Person<T extends Info>{
public T info;
Person(T info){this.info = info;}
}
public class GenericDemo{
public static void main(String[] args) {
Person p1 = new Person(new EmployeeInfo(1));
Person<String> p2 = new Person<String>("부장");
}
}
class Person<T extends Info>{
는 Person
의 T
에 Info
클래스나 그 자식 외에는 올 수 없다. 따라서 p2
에서 String
은 Info
의 자식이 아니기 때문에 에러가 발생한다. 즉 컴파일 에러가 발생해서 런타임 때 발생하는 오류를 미연에 감지할 수 있다.
제너릭 제한은 interface 관계에서도 사용할 수 있다.
interface Info{
int getLevel();
}
class EmployeeInfo implements Info{
public int rank;
EmployeeInfo(int rank){ this.rank = rank; }
public int getLevel(){
return this.rank;
}
}
class Person<T extends Info>{
public T info;
Person(T info){ this.info = info; }
}
public class GenericDemo {
public static void main(String[] args) {
Person p1 = new Person(new EmployeeInfo(1));
Person<String> p2 = new Person<String>("부장");
}
}