들어가며
자바에서 동적으로 배열의 크기를 변경하기 위해 배열 대신 List를 사용하곤 한다. 그런데 클래스 선언 문법에 <>로 되어있는 코드를 보았을 것이다. 이걸 제네릭(Generic) 이라고 부르며, 제네릭 파라미터는 꺽쇠안에 포함하여 전달한다. 제네릭이 하는게 무엇이고, 왜 사용할까? 한번 알아보자.
제네릭 (Generics) 이란?
ArrayList<String> list = new ArrayList<>();
제네릭(Generics)는 다양한 타입의 객체를 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입 체크(compile-time type check)를 해주는 기능이다. 객체의 타입을 컴파일 시에 체크하면, 객체의 타입 안정성을 높이고, 형변환의 번거로움을 줄여준다.
쉽게 말하면, 제네릭스를 사용하면 다루고자 하는 객체의 타입을 미리 지정해 줄 수 있고 이를 통해, 코드에서 복잡한 형변환을 줄일 수 있다는 말이다.
예를 들어, ArrayList<String>처럼 컬렉션 클래스에 String 타입을 명시하게 되면, 해당 컬렉션에는 String 타입의 객체만 저장될 수 있다. 이런 방식으로 타입 안정성이 보장되며, 컬렉션에서 객체를 꺼낼 때 이미 String 타입으로 인식되기 때문에, 개발자가 매번 꺼낸 객체가 String 타입인지 확인할 필요가 없어져 형변환의 번거로움도 줄여준다.
제네릭 클래스의 선언
제네릭 타입은 클래스와 메서드에 선언할 수 있다. 먼저 클래스에 선언하는 제네릭 타입에 대해 살펴보자.
class Box {
Object item;
void setItem(Object item) {
this.item = item;
}
Object getItem() {
return item;
}
}
위 코드는 제네릭을 사용하지 않은 Box 클래스이다. 제네릭 클래스로 변경하려면 클래스 옆에 '<T>'를 붙이면 된다.
이제 제네릭 클래스로 변환해보자.
class Box<T>{
T item;
void setItem(T item) {
this.item = item;
}
T getItem() {
return item;
}
}
여기서 T를 타입 변수라고 한다.
타입 변수는 Type의 첫 글자에서 따온 것으로 T가 아닌 다른 것을 사용해도 된다. 보통 아래표와 같은 타입변수가 주로 사용된다.
기호의 종류만 다를 뿐 "임의의 참조형 타입"을 의미한다는 것은 모두 같다. 따라서 무조건 "T"만 사용하기 보다 상황에 맞게 의미있는 문자를 사용해서 사용하는 것이 좋다.
Box<String> b = new Box<String>(); //타입 T 대신, 실제 타입 String을 지정
b.setItem(new Object()); // 에러발생 제네릭이 String이므로 String타입만 가능
b.setItem("ABC"); // String 타입이므로 가능
String item = b.getIgem(); // 제네릭의 다른 장점으로 형변환 생략가능
이렇게 제네릭은 컴파일 시 타입을 체크해준다.
제네릭스의 용어
class Box<T>
Box<String> box = new Box<String>();
제네릭스의 용어들은 헷갈리기 쉬운 용어들이 많다. 확실하게 정리하자.
- Box<T> : 제네릭 클래스, T의 Box or T Box 라고 읽는다.
- T : 타입 변수 or 타입 매개변수
- Box : 원시 타입(raw type)
- Box<String> : 제네릭 타입 호출
- <String> : 매개변수화된 타입(대입된 타입)
- box : Box<String> 타입의 인스턴스를 참조하는 변수
- new Box<String>() : String 타입의 객체만을 담을 수 있는 Box 클래스의 새 인스턴스를 생성
제네릭스의 제한
Box<Apple> appleBox = new Box<Apple>(); Apple객체만 저장이 가능
Box<Grape> appleBox = new Box<Grape>(); Grape객체만 저장이 가능
제네릭 클래스 Box의 객체를 생성할 때, 객체별로 다른 타입을 지정하는 것은 적절하다. 제네릭스는 인스턴스별로 다르게 동작하도록 만든 기능이기 때문이다.
class Box<T> {
static T item; //에러발생
static int compare(T t1,T t2){...} //에러발생
}
제네릭스가 제한되는 예시들이다.
모든 객체에 대해 동일하게 동작하는 static멤버에는 타입 변수 T를 사용할 수 없다. T는 인스턴스 변수로 간주되기 때문이다. (static 멤버는 인스턴스 변수를 참조불가)
class Box<T> {
T[] itemArr;
T[] toArray() {
T[] tmpArr = new T[imemArr.length]; //에러. 제네릭 배열 생성불가
}
}
또한 제네릭 배열 타입의 참조변수를 선언하는 것은 가능하지만, 제네릭 타입의 배열을 생성할 수 없다.
그 이유는 new 연산자 때문이다. new 연산자는 컴파일 시점에 타입 T가 무엇인 지 알아야 하나, Box<T> 클래스의 컴파일 시점에서는 T가 어떤 타입이 될 지 전혀 알 수가 없다. (instance of도 같은 이유로 타입 T를 피연산자로 사용할 수 없음.)
제네릭 클래스의 객체 생성과 사용
// 참조변수와 생성자에 대입된 타입
Box<Apple> appleBox = new Box<Apple>();
Box<Apple> appleBox = new Box<Grape>(); // 에러
// 참조변수와 생성자에 대입된 타입 - 상속 관계
Box<Fruit> appleBox = new Box<Apple>(); // 에러
Box<Apple> appleBox = new FruitBox<Apple>();
// 객체 추가
Box<Apple> appleBox = new Box<Apple>();
appleBox.add(new Apple());
appleBox.add(new Grape()); // 에러
// 객체 추가 - 상속 관계
Box<Fruit> appleBox = new Box<Fruit>();
fruitBox.add(new Fruit());
fruitBox.add(new Apple());
제네릭 클래스를 생성할때 Box<Apple> applebox = new Box<Grape>() 같이 타입이 일치하지 않으면 에러가 발생한다.
두 타입(Apple,Grape)이 상속관계에 있어도 마찬가지이다.
단, Box<Apple> appleBox = new FruitBox<Apple>()와 같이 두 제네릭 클래스이 타입이 상속관계이고 타입이 같은 것은 허용한다. (Box class가 FruitBox class의 부모 클래스)
void add(T item)으로 객체를 추가할 때, appleBox.add(new Grape())와 같이 대입된 타입과 다른 타입의 객체는 추가할 수 없다.
단, 타입 T가 'Fruit'이면 void add(Fruit item)이 되므로 fruitBox.add(new Apple())과 같이 Fruit의 자손들은 매개변수가 될 수 있다.
제네릭의 범위 설정
제네릭을 단순히 이런 식으로 설정할 수도 있지만 범위를 주고 싶을 수도 있다. 예를 들어 Interger, Byte, Double 등과 같은 숫자를 다루는
래퍼 클래스들만 범위를 주고 싶을 때 extends라는 키워드를 사용한다. 설정 시에는 숫자 관련된 래퍼 클래스와 그 자손들만 담을 수 있다.
와일드 카드
제네릭 타입만으로는 오버로딩을 성립시킬 수 없기 때문에 와일드카드가 도입되었다. 와일드카드는 ?로 표현되며, 어떤 타입이든 허용할 수 있다. 그러나 <?>만으로는 모든 타입을 포괄하는 점에서 Object와 차이가 없으므로, extends와 super를 사용해 상한과 하한을 제한할 수 있다.
<? extends T> 와일드 카드의 상한 제한. T와 그 자손들만 가능
< ? super T> 와일드 카드의 하한 제한. T와 그 조상들만 가능
< ? > 제한 없음. 모든 타입이 가능 < ? extends Object >와 동일
< K extends T>, <? extends T>는 차이가 있다 K는 특정 타입으로 지정이 되지만, ?는 타입이 지정되지 않는다는 의미가 있다.
제네릭 메소드(Generic method)
메서드에도 제네릭을 적용할 수 있다. 제네릭 메소드의 제네릭 타입변수의 위치는 반환형 앞에 위치하며 제네릭 클래스에
정의된 타입 매개변수와는 별개의 것이다. 같은 타입의 문자를 사용했더라도 같은 것이 아니니 주의해야한다.
static Juice makeJuice(FruitBox<? extends Fruit> box) {
Fruit fruit = box.getFruit(); // Fruit 타입으로 읽기 가능
box.setFruit(new Apple()); // ERROR: 와일드카드는 쓰기 불가
return new Juice();
}
이 코드에서는 FruitBox<? extends Fruit>라는 와일드카드를 사용하고 있다.
이는 FruitBox라는 상자에 Fruit 또는 그 하위 타입(예: Apple, Banana)을 담을 수 있음을 의미한다.
하지만 와일드카드(? extends Fruit)를 사용하면, 메서드 내부에서 상자 안에 들어 있는 구체적인 타입(Apple, Banana 등)을 알 수 없다. 따라서 데이터를 꺼내는 것은 가능하지만, 상자에 새로운 데이터를 추가하는 것은 불가능하다.
static <T extends Fruit> Juice makeJuice(FruitBox<T> box) {
T fruit = box.getFruit(); // T 타입으로 읽기 가능
box.setFruit(fruit); // T 타입으로 쓰기 가능
return new Juice();
}
반면, 제네릭 메서드를 사용하면 타입 파라미터를 직접 지정할 수 있다.
즉, FruitBox<T>와 같은 형태로 특정 타입을 지정하면, 메서드 내부에서 상자에 담긴 구체적인 타입(Apple, Banana 등)을 알 수 있게 된다. 이를 통해 데이터를 꺼내는 것뿐만 아니라, 상자에 새로운 데이터를 추가하는 것도 가능하다.
.
참고
Java의 정석 | 남궁성 - 교보문고
Java의 정석 | 자바의 기초부터 실전활용까지 모두 담다!자바의 기초부터 객제지향개념을 넘어 실전활용까지 수록한『Java의 정석』. 저자의 오랜 실무경험과 강의한 내용으로 구성되어 자바를
product.kyobobook.co.kr
'JAVA' 카테고리의 다른 글
[JAVA] this와 this()의 차이점 (0) | 2024.04.09 |
---|---|
[JAVA] 문자열 비교 방법의 차이 (==, equals, matches) (1) | 2024.02.25 |