제네릭이란 데이터의 타입을 일반화(Generalization) 하는 것을 의미한다.
다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일시 타입체크(compile-time type check)를 해주는 기능이다. 객체의 타입을 컴파일 시에 체크하기 떄문에 객체의 타입 안정성을 높이고 형변환의 번거로움이 줄어든다.
제네릭은 자바 5 부터 사용할 수 있는 기능으로 제네릭을 지원하기 전에는 여러 타입을 사용하는 대부분 메서드나 컬렉션 클래스에서 인수나 반환 값으로 Object 타입을 사용했기 때문에 컬렉션에서 객체를 꺼낼 때 마다 형변환을 해야했다. 그런데 누군가 실수로 엉뚱한 타입의 객체를 넣어두면 런타임에 형변환 오류가 나곤 했다.
반면 제네릭을 사용하면 컬렉션이 담을 수 있는 타입을 컴파일러에 알려주고 컴파일러는 알아서 형변환 코드를 추가하여 엉뚱한 타입의 객체를 넣게되면 컴파일 과정에서 차단하여 더 안전하고 명확한 프로그램을 만들어 준다.
컴파일시 타입 검사 수행시 장점
- 클래스나 메서드 내부에서 사용되는 객체의 타입 안정성을 높일 수 있다.
- 반환 값에 대한 타입 변환 및 타입 검사에 들어가는 노력을 줄일 수 있다.
- 타입에 대해 유연성과 안전성을 확보한다.
- 런타임 환경에 영향을 주지 않는 전처리 기술이다.
*타입 안정성을 높인다는 것은 의도하지 않은 타입의 객체를 저장하는 것을 막고, 저장된 객체를 꺼내올 때 원래의 타입과 다른 타입으로 형변환되어 발생할 수 있는 오류를 줄여준다는 듯이다.
ArrayList 클래스의 선언에서 클래스 이름 옆에 <> 안에 있는 E를 타입 변수(type variable)라고 하며, 일반적으로 Type의 첫 글자 T를 사용하지만 반드시 T를 사용해야 하는것이 아닌 변수일 뿐이다. ArrayList는 Element(요소) 첫 글자 E를 사용한것이다.
*타입 변수에는 primitive type을 사용할 수 없고 반드시 refferences type을 사용해야 한다,
타입 변수가 여러 개일 경우 , 콤마 구분자로 나열하면 된다. Map 인터페이스를 보면 K는 Key를 의미하고 V는 Value를 의미한다.
제네릭이 도입 되기 전 다양한 종류의 타입을 다루는 메서드의 매개변수나 리턴타입으로 모든 참조형 객체를 받을 수 있는Object 타입의 참조변수를 많이 사용했고 그로인해 형변환이 불가피 했지만, 이젠 Object 타입 대신 원하는 타입을 지정하기만 하면 된다.
제네릭의 용어
- Box<T> : 제네릭 클래스. 'T의 Box' 또는 'T Box'라고 읽는다.
- T : 타입 변수 또는 타입 매겨변수. (T는 타입 문자)
- Box : 원시 타입 (raw type)
타입 문자 T는 지네릭 클래스 Box<T>의 타입 변수 또는 타입 매개변수라고 하는데, 매서드의 매개변수와 유사한 면이 있기 때문이다.
타입 매개변수에 타입을 지정하는 것을 '제네릭 타입 호출'이라고 하고, 지정된 타입 'String'을 '매개변수화된 타입(parameterized type)이라고 한다.
Box<String>과 Box<Integer>는 제네릭 클래스 Box<T>에 서로 다른 타입을 대입하여 호출한 것일 뿐, 별개의 클래스를 의미하는 것은 아니다. add(3,5)와 add(2,4)가 서로 다른 메서드를 호출하는 것이 아닌 매개변수 값을 다르게 준 것과 같다.
컴파일 후에 Box<String>과 Box<Integer>는 이들의 원시타입인 Box로 바뀐다.
즉 제네릭 타입이 제거된다.
제네릭의 타입과 다형성
제네릭 클래스의 객체를 생성할 때 참조변수에 지정해준 제네릭 타입과 생성자에 지정해준 제네릭 타입은 일치해야 한다.
클래스 Tv와 Product가 서로 상속 관계에 있어도 일치하지 않으면 에러가 난다.
제네릭 타입이 아닌 클래스 타입 간 다형성 적용은 가능하다. 이 경우에도 제네릭 타입은 일치해야 한다.
ArrayList에 Product 자손 객체만 저장해야 할 때는 제네릭 타입이 Product인 ArrayList를 생성하고 이 리스트에 Product의 자손인 Tv와 Audio의 객체를 저장하면 된다.
대신 ArrayList에 저장된 객체를 꺼낼떄 부모의 타입으로 꺼내는 것이 아닌 자식 본인의 타입으로 꺼낼 경우 형변환이 필요하다.
당연히 Product와 is-a 관계가 아닌 Person은 list에 추가할 수 없다.
제한된 제네릭 클래스
타입 문자로 사용할 타입을 명시하면 한 종류의 타입만 저장할 수 있도록 제한할 수 있지만 그래도 여전히 모든 종류의 타입을 지정할 수 있다는 것에는 변함이 없다.
이제는 타입 매개변수 T에 지정할 수 있는 타입의 종류를 제한할 수 있는 방법을 알아보자.
현재 코드는 과일 박스에 장난감도 넣을 수 있는 모든 종류의 타입을 지정하는 코드이다.
만약 과일박스 제네릭 타입에 extends를 사용하면 특정 타입의 자손만 대입할 수 있게 제한할 수 있다.
여전히 한 종류의 타입만 담을 수 있지만 Fruit 클래스의 자손만 담을 수 있는 제한이 추가되었다.
만약 클래스가 아니라 인터페이스를 구현해야 한다는 제약이 필요하다면 이때도 extends를 사용한다. 제네릭에는 implements 키워드를 사용하지 않는다.
클래스 Fruit의 자손이면서 Eatable 인터페이스도 구현해야 한다면 &기호로 연결한다.
제네릭의 제약
모든 객체에 대해 동일하게 동작해야하는 static 멤버에 타입 변수 T를 사용할 수 없다.
T는 인스턴스 변수로 간주되기 때문에 static 멤버는 인스턴스 변수를 참조할 수 없다.
static 멤버는 타입 변수에 지정된 타입. 즉 대입된 타입의 종류에 관계없이 동일한 것이어야 하는데 Box<Apple>.item과 Box<Grape>.item이 달라서는 안된다.
그리고 제네릭 타입의 배열을 생성하는 것도 허용되지 않는다.
제네릭 배열 타입의 참조변수를 선언하는 것은 가능하지만 new T[10]과 같이 배열을 생성하는것안 안된다.
제네릭 배열을 생성할 수 없는 것은 new 연산자 때문인데, 이 연산자는 컴파일 시점에 타입 T가 뭔지 정확히 알아야한다.
그런데 위에 코드에 정의된 Box<T> 클래스를 컴파일하는 시점에서 T가 어떤 타입이 될지 전혀 알 수 없다.
instanceof 연산자도 new 연산자와 같은 이유로 T를 피연산자로 사용할 수 없다.
와일드카드
제네릭 클래스를 생성할 때, 참조변수에 지정된 제네릭 타입과 생성자에 지정된 제네릭 타입은 일치해야 한다.
만일 일치하지 않으면 다음과 같이 컴파일 에러가 난다. Fruit와 Apple이 서로 상속관계라도 마찬가지이다.
제네릭 타입으로 다형성을 적용하는 방법은 제네릭 타입으로 와일드 카드를 사용하면 된다.
와일드 카드는 기호 ? 를 사용하는데 다음과 같이 extends와 super로 상한(upper bound)와 하한(lower bound)을 제한할 수 있다.
<? extends T> : 와일드 카드의 상한 제한. T와 그 자손들만 가능
<? super T> : 와일드 카드의 하한 제한. T와 그 조상들만 가능
<?> : 제한 없음. 모든 타입이 가능. <? extends Object>와 동일
와일드 카드를 이용하면 다음과 같이 하나의 참조변수로 다른 제네릭 타입이 지정된 객체를 다룰 수 있다.
제네릭 메서드
메서드 선언부에 제네릭 타입이 선언된 메서드를 제네릭 메서드라고 한다.
Collections.sort()가 제네릭 메서드이며, 제네릭 타입의 선언 위치는 반환 타입 바로 앞이다.
제네릭 클래스에 정의된 타입 매개변수가 T이고 제네릭 메서드에 정의된 타입 매개변수가 T 이어도 이 둘은 전혀 별개의 것이다. 같은 타입 문자 T를 사용해도 같은것이 아니라는 것에 주의해야 한다. 제네릭 메서드는 제네릭 클래스가 아닌 클래스에도 정의될 수 있다.
위의 코드에서 제네릭 클래스 FruitBox에 선언된 타입 매개변수 T와 제네릭 메서드 sort()에 선언된 타입 매개변수 T는 타입 문자만 같을 뿐 서로 다른 것이다.
그리고 sort() 가 static 키워드인데 앞에서는 static 멤버에는 타입 매개변수를 사용할 수 없다고 했지만 메서드에 제네릭 타입을 선언하고 사용하는 것은 가능하다.
메서드에 선언된 제네릭 타입은 지역변수를 선언한 것과 같다고 생각하면 이해가 쉬운데, 이 타입 매개변수는 메서드 내에서만 지역적으로 사용될 것이므로 메서드가 static이건 아니건 상관 없다.
제네릭 메서드로 변경하면
이제 이 메서드를 호출할 때는 아래와 같이 타입 변수에 타입을 대입해야하지만 대부분 컴파일러가 대입된 타입을 추정할 수 있기 때문에 생략해도 된다.
컴파일러가 타입을 추정할 수 있어서 타입 생략 가능
한가지 주의할 점은 제네릭 메서드 호출시 대입된 타입을 생략할 수 없는 경우에는 참조변수나 클래스 이름을 생략할 수 없다. 같은 클래스 내에 있는 멤버들끼리는 참조변수나 클래스이름, 즉 this나 클래스이름. 을 생략하고 메서드 이름만으로 호출이 가능하지만, 대입된 타입이 있을 대는 반드시 써줘야 한다.
제네릭 타입의 제거
컴파일러는 제네릭 타입을 이용해서 소스파일을 체크하고, 필요한 곳에 형변환을 넣어준다.
그리고 제네릭 타입을 제거한다. 즉 컴파일될 파일 .class 에는 제네릭 타입에 대한 정보가 없다. 이렇게 하는 이유는 제네릭이 도입되기 이전(자바 5 이전)의 소스 코드와 호환성을 유지하기 위해서이다.
제네릭 타입 제거 과정
- 제네릭 타입의 경계(bound)를 제거한다.
- 제네릭 타입이 <T extends Fruit>라면 T는 Fruit로 치환된다. <T>인 경우는 T는 Object로 치환된다. 그리고 클래스 옆의 지네릭 타입 선언은 제거된다.
- 제네릭 타입을 제거한 후에 타입이 일치하지 않으면 형변환을 추가한다.
- List의 get()은 Object 타입을 반환하므로 형변환이 필요하다.
와일드 카드가 포함되어 있는 경우에는 다음과 같이 적절한 타입으로의 형변환이 추가된다.
참고자료
'Effective Java > 키워드' 카테고리의 다른 글
Java의 참조 유형 (0) | 2023.01.30 |
---|---|
가비지 컬렉션 (GC, Garbage Collection) (0) | 2023.01.22 |
자바 메모리 구조 (0) | 2023.01.21 |
팩터리 메서드 패턴 (Factory Method Pattern) (0) | 2023.01.08 |
플라이 웨이트 패턴 (Flyweight Pattern) (0) | 2023.01.07 |