[Java] 자바의 제네릭이란, Generic, 타입 매개변수
해당 글에서는 Generic의 깊은 이해보다는 어떠한 이유로 제네릭이 생겨났구나 정도 파악하고 예제 코드로 학습하기 위한 목적입니다.
자바에서 제네릭이 필요한 이유
만약에 하나의 기능을 제공하는데, 타입별로 다를 경우를 생각해보면된다.
예를들어 String 타입의 값을 담아서 출력하는 객체, Integer타입의 값을 담아서 출력하는 객체, Double, Boolean 등등 있다고 가정해 보겠다.
그럼 각각의 타입이 서로다르다는 이유로 값을 담아서 출력해야하는 로직을 각 타입에 맞게 코드를 작성해주어야 한다.
OutputString outputStr = new OutputString();
String hello = "hello";
outputStr.print(hello);
OutputInteger OutputInt OutputInteger();
int num = 1;
OutputInt.print(num);
// Double...
// Boolean...
해당 문제를 해결할 수 있는게 바로 제네릭이다. 제네릭이란 인자로 담는 타입을 동적으로 변경하여 객체를 담을 수 있다.
제네릭 클래스 만들기
제네릭을 사용하면 런타임 시점에 타입이 결정되므로 타입 안정성을 보장 받을 수 있다. 그리고 로직이 같다면 MyGeneric에 정의해 놓은 메서드를 사용함으로써 코드를 재사용할 수 있다.
public class MyGeneric<T> {
private T value;
public void setValue(T value) {
this.value = value;
}
public T getValue() {
return value;
}
}
public class BoxMain3 {
public static void main(String[] args) {
MyGeneric<Integer> integerMyGeneric = new MyGeneric<>();
integerMyGeneric.setValue(3);
Integer value = integerMyGeneric.getValue();
System.out.println(value);
MyGeneric<String> stringMyGeneric = new MyGeneric<>();
stringMyGeneric.setValue("hello");
String strValue = stringMyGeneric.getValue();
System.out.println(strValue);
}
}
이제 하나의 클래스(MyGeneric
)클래스 하나로 String 타입도 받고, Integer타입도 받을 수 있게 되었다.
제네릭 핵심
사용할 타입을 미리 결정하지 않고 런타임 시점에 결정한다는 것이다.
- 타입 매개변수 : Generic<
T
>에서 T가 타입 매개변수이다. - 타입 인자 : Generic<
Integer
>에서Integer
를 타입 인자라고한다.
제네릭 명명관례
일반적으로 대문자를 사용하고 용도에 맞는 단어의 첫글자를 사용하는 관례를 따른다.
- E - Element
- K - Key
- N - Number
- T - Type
- V - Value
- 타입 인자로 기본형은 사용할 수 없다* 대신에 래퍼클래스(
Integer, Double
)를 사용하면 된다.
해당 예제 코드의 문제점
여기서 해결된 문제는 각각의 클래스를 따로 만들어주지 않고 하나의 클래스 MyGeneric
클래스 하나로 여러가지 타입을 받을 수 있게 되었다는 것.
MyGeneric<Integer> integerMyGeneric = new MyGeneric<>();
integerMyGeneric.setValue("asd");
그리고 제네릭의 타입으로 Integer를 명시적으로 해줬기 때문에 Integer외의 다른 타입이 올 경우 컴파일 에러를 보여준다.
이로 인해 타입 안정성을 보장 받을 수 있다.
허나 여기서 제네릭을 도입함으로써 생기는 문제가 있다.
우리가 제네릭을 사용하는 이유는 타입 안정성, 그리고 여러가지 타입을 받을 수 있다는 점으로 인해 제네릭을 사용하는데, 후자가 바로 문제점이다.
여러가지 타입을 받기 위해서 제네릭을 사용했는데 이것이 문제라니?? 코드로 보자.
public class Animal {
private String name;
private int size;
public Animal(String name, int size) {
this.name = name;
this.size = size;
}
public String getName() {
return name;
}
public int getSize() {
return size;
}
public void sound() {
System.out.println("동물 울음 소리");
}
@Override
public String toString() {
return "Animal{" +
"name='" + name + '\'' +
", size=" + size +
'}';
}
}
package generic.animal;
public class Cat extends Animal {
public Cat(String name, int size) {
super(name, size);
}
@Override
public void sound() {
System.out.println("냐옹");
}
}
package generic.animal;
public class Dog extends Animal {
public Dog(String name, int size) {
super(name, size);
}
@Override
public void sound() {
System.out.println("멍멍");
}
}
해당 코드가 있다고 가정해보자. Animal 타입의 클래스가 있고 이를 상속받음 Cat,Dog 클래스가 있다.
그리고 제네릭으로 받아서 Cat과 Dog의 기능을 사용할 수 있는 제네릭 클래스를 만들어주자.
public class Box<T> {
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
public class AnimalMain1 {
public static void main(String[] args) {
Dog dog = new Dog("멍멍이", 100);
Cat cat = new Cat("냐옹이", 50);
Box<Dog> dogBox = new Box<>();
dogBox.set(dog);
Dog dog1 = dogBox.get();
System.out.println(dog1);
Box<Cat> catBox = new Box<>();
catBox.set(cat);
Cat cat1 = catBox.get();
System.out.println(cat1);
//문제점
Box<String> stringBox = new Box<>();
stringBox.set("Bug");
String s = stringBox.get();
}
}
Box의 제네릭으로 Cat과 Dog의 타입을 받아서 오버라이딩한 각각의 메서드를 호출할 수 있었다.
문제점의 코드를 보면, 분명 해당 코드를 개발한 개발자의 의도는 Animal을 상속받은 Cat과 Dog만 타입으로 받는것을 의도했을 것이다.
허나 의도와는 다르게 모든 타입을 받을 수 있다보니 모든 타입이 제네릭으로 들어올 수 있다는 점이다.
이를 해결하기 위해서는 아래 코드와 같이 타입 매개변수 상한을 설정한다.
public class Box<T extends Animal> {
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
타입 매개변수를 상한을 설정하면 extends Animal
로 설정을 하면 해당 타입 + 자식 타입을만 제네릭의 타입으로 들어올 수 있게된다.
Box<String> strBox = new Box<>();
그러므로 해당 코드는 컴파일 에러가 날 것이다.
제네릭을 사용하는 이유
- 여러가지 타입을 받을 수 있게 해놨지만 매개변수의 상한을 지정함으로써 지정된 타입의 자식 타입까지 받을 수 있다.
- 타입 안정성을 제공한다.
타입 이레이저
제네릭 타입이 컴파일 시점에서 어떻게 작동되는지 살펴보겠다.
제네릭은 자바 컴파일 단계에서만 사용된다. 컴파일 이후에는 제네릭 정보가 삭제되며, 제네릭에 사용한 타입 매개변수가 모두 사라지는 것이다.
컴파일 전인 .java
자바 파일에는 제네릭 타입 매개변수가 존재하지만, 컴파일 이후인 자바 바이트코드 .class
클래스 파일에는 타입 매개변수가 존재하지 않는 것이다.
public class GenericBox<T> {
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
예를 들어 해당 코드와 같이 제네릭 타입을 선언했다고 가정해보자.
여기서 제네릭에 Integer
타입을 전달했다.
void main() {
GenericBox<Integer> box = new GenericBox<Integer>();
box.set(10);
Integer result = box.get();
}
이렇게 코드를 실행시킨다면 자바 컴파일러는 컴파일 시점에 타입 매개변수와 타입 인자를 포함한 제네릭 정보를 활용해서 new GenericBox<Integer>
로 이해하고 실행한다.
그러면 GenericBox
클래스의 상태는
public class GenericBox<Integer> {
private Integer value;
public void set(Integer value) {
this.value = value;
}
public Integer get() {
return value;
}
}
이러한 상태가 되는데, 컴파일 시점에는 이러한 상태를 유지하다가 컴파일이 모두 끝난 후 .class
클래스 파일로 변환될 때 생성된 정보는 Integer -> Object로 변환된다.
타입 매개변수 제한의 경우
제네릭에는 상한 타입을 지정해줄 수 있었다.
만약 다음과 같이 타입 매개변수를 제한하면 제한한 타입으로 코드를 변경한다.
제한하지 않았을 경우에는 Object
타입으로 컴파일이 완료 된 후 클래스파일에 생성이 되었었지만, 제한을 둘 경우, 제한한 타입으로 클래스 파일에 생성된다.
컴파일 단계
public class AnimalHospitalV3<T extends Animal> {
private T animal;
public void set(T animal) {
this.animal = animal;
}
public void checkup() {
System.out.println("동물 이름: " + animal.getName()); System.out.println("동물 크기: " + animal.getSize()); animal.sound();
}
public T getBigger(T target) {
return animal.getSize() > target.getSize() ? animal : target;
}
}
컴파일 완료 후
public class AnimalHospitalV3 {
private Animal animal;
public void set(Animal animal) {
this.animal = animal;
}
public void checkup() {
System.out.println("동물 이름: " + animal.getName()); System.out.println("동물 크기: " + animal.getSize()); animal.sound();
}
public Animal getBigger(Animal target) {
return animal.getSize() > target.getSize() ? animal : target;
}
}
자바의 제네릭은 단순히 생각해보면, 직접 캐스팅하는 코드들을 컴파일러가 대신 처리해주는 것이다. 자바의 제네릭 타입은 컴파일 시점에만 존재하고, 런타임 시에는 제네릭 정보가 다 지워진다고 보면된다.
정리
제네릭을 사용하는 이유로는 재사용성이다. 클래스나 메서드를 다양한 타입으로 재사용할 수 있으며, 동일한 코드를 여러 타입에 대해 중복작성하지 않고, 하나의 제네릭 타입으로 처리할 수 있다, 그리고 컴파일 시 타입을 체크할 수 있어 타입 안정성을 제공한다.(런타임에 발생할 수 있는 타입 관련 오류를 줄일 수 있다.)
제네릭 타입을 사용하는 이유와, 제네릭 타입을 선언하고 실행할 컴파일 타임과, 런타임시점에 어떠한 형태로 존재하는지, 그리고 타입 이레이저와 .class
파일이 어떻게 생성되는지 알아보았다.