목차
<제네릭?>
프로그램에서 변수를 선언할 때, 메서드에서 매개 변수를 사용할 때도 모든 변수는 자료형이 있다.
대부분은 하나의 자료형으로 구현하지만, 변수나 메서드의 자료형을 필요에 따라 여러 자료형으로 바꿀 수 있다면 프로그램이 훨씬 더 유연해질 것이다.
이처럼 어떤 값이 하나의 참조 자료형이 아닌 여러 참조 자료형을 사용할 수 있도록 프로그래밍하는 것을
'제네릭(Generic)프로그래밍'이라고 한다.
'제네릭 프로그램'은 참조 자료형이 변환될 때 컴파일러가 검증을 해 꽤 안정적이다.
'컬렉션 프레임워크'의 많은 부분이 이 '제네릭'으로 구현되어있다.
<제네릭의 필요성>
예제로 제네릭에 대해 이해해보자.
3D프린터는 재료를 가지고 입체 모형을 만드는 일을 한다. 재료는 파우더나 플라스틱을 사용한다.
먼저 파우더를 재료로 쓰는 3D프린터 클래스를 보자.
public class ThreeDPrinter {
private Powder material; //재료가 파우더인 경우
public Powder getMaterial() {
return material;
}
public void setMaterial(Powder material) {
this.material = material;
}
}
다음 플라스틱을 재료로 쓰는 3D프린터 클래스를 보자
public class ThreeDPrinter {
private Plastic material; //재료가 플라스틱인 경우
public Plastic getMaterial() {
return material;
}
public void setMaterial(Plastic material) {
this.material = material;
}
}
그런데 재료만 바뀔 뿐 프린터 기능이 동일하다면 프린터 클래스를 두 개 만드는 것은 비효율적이다.
이런 경우 어떠한 재료를 쓸 수 있도록 material 변수의 자료형을 Object로 사용할 수 있다. Object는 모든 클래스의 최상위 클래스이므로 모든 클래스는 Object로 변환이 가능하기 때문.
Object를 활용한 코드를 보자.
public class ThreeDPrinter {
private Object material; //재료가 플라스틱인 경우
public Object getMaterial() {
return material;
}
public void setMaterial(Object material) {
this.material = material;
}
}
material 변수의 자료형을 Object로 선언한 ThreeDPrinter에 파우더를 재료로 사용하면 다음 코드를 구현할 수 있다.
ThreeDPrinter printer = new ThreeDPrinter();
Powder p1 = new Powder();
printer.setMaterial(p1); //Object로 자동 형 변환
powder p2 = (Powder)printer.getMaterial();
// 직접 Powder로 형 변환
setMaterial() 메서드를 활용해 Powder를 재료로 선택할 때는 매개변수 자료형이 Object이므로 자동으로 형 변환이 된다.
하지만 반환형이 Object 클래스인 getMaterial() 메서드로 Powder자료형 변수를 반환받을 때는 반드시 형 변환을 해줘야 한다.
즉 어떤 변수가 여러 참조 자료형을 사용할 수 있도록 Object클래스를 사용하면 다시 원래 자료형으로 반환해 주기 위해 매번 형 변환을 해야하는 번거로움이 있는 것!
이럴 때 필요한 프로그래밍 방식이 '제네릭(Generic)'이다.
여러 참조 자료형이 쓰일 수 있는 곳에 특정한 자료형을 지정하지 않고, 클래스나 메서드를 정의한 후 사용하는 시점에 어떤 자료형을 사용할 것인지 지정하는 방식.
<제네릭 클래스 정의하기>
제네릭에서는 여러 참조 자료형을 사용해야 하는 부분에 하나의 문자로 표현한다.
코드를 보면 다음과 같다.
public class GenericPrinter<T> {
//제네릭 클래스, T는 type의 약자(자료형 매개변수)
private T material;
public T getMaterial() {
return material;
}
public void setMaterial(T material) {
this.material = material;
}
}
- 여러 자료형으로 바꾸어 사용할 material 변수의 자료형을 T라고 썼다.
이때 T를 '자료형 매개변수(type parameter)'라고 부른다. - 클래스 이름을 GenericPrinter<T>라고 정의하고, 나중에 클래스를 사용할 때 T위치에 실제 사용할 자료형을 지정한다.
- 클래스의 각 메서드에서 해당 자료형이 필요한 부분에는 모두 T문자를 사용하여 구현.
다이아몬드 연산자 <>
자바 7부터 제네릭 자료형의 클래스를 생성할 때 생성자에 사용하는 자료형을 명시하지 않을 수 있다.
예로 ArrayList를 살펴보자.
ArrayList<String> list = new ArrayList<>(); //생략 가능.
여기서 <>를 다이아몬드 연산자라고 한다.
선언된 자료형을 보고 생략된 부분이 String이라고 컴파일러가 자동으로 유추하기 때문.
자료형 매개변수 T와 static
static변수나 메서드는 인스턴스를 생성하지 않아도 클래스 이름으로 호출할 수 있다.
- static 변수는 인스턴스 변수가 생성되기 이전에 생성되고, static 메서드는 인스턴스 변수를 사용할 수 없다.
- T의 자료형은 제네릭 클래스의 인스턴스가 생성될 때 정해진다.
따라서 static이 T의 자료형이 결정되는 시점보다 빠르기 때문에 static 변수의 자료형이나 static 메서드 내부 변수의 자료형으로 T를 사용할 수 없다.
제네릭에서 자료형 추론하기
자바 10부터 지역 변수에 한해서 자료형을 추론할 수 있다. 제네릭에서도 마찬가지.
String을 자료형 매개변수로 사용한 ArrayList선언코드를 다음처럼 바꿀 수 있다.
ArrayList<String> list = new ArrayList<String>();
위 코드를 아래처럼↓
var list = new ArrayList<String>();
생성되는 인스턴스를 바탕으로 list의 자료형이 ArrayList<String>임을 추론할 수 있기 때문.
물론 list가 지역 변수로 선언되는 경우만 가능하다.
<제네릭 클래스 사용하기>
파우더가 재료인 프린터는 다음과 같이 선언하여 생성한다.
GenericPrinter<Powder> powderPrinter = new GenericPrinter<Powder>();
powderPrinter.setMaterial(new Powder());
Powder powder = powderPrinter.getMaterial(); //명시적 형 변환을 하지 않음
T로 정의한 클래스 부분에 Powder형을 넣어주고, T형 매개변수가 필요한 매서드에 Powder클래스를 생성하여 대입해 준다.
GenericPrinter<Powder>에서 어떤 자료형을 사용할지 명시했으므로 getMaterial() 메서드에서 반환할 때 형 변환을 하지 않는다.
이렇게 실제 제네릭 클래스를 사용할 때 T 위치에 사용한 Powder형을 '대입된 자료형'이라 하고,
Powder를 대입해 만든 GenericPrinter<Powder>를 '제네릭 자료형'이라고 한다.
용어 | 설명 |
GenericPrinter<Powder> | 제네릭 자료형(Generic type), 매개변수화된 자료형(parameterized type) |
Powder | 대입된 자료형 |
제네릭으로 구현하면 왜 형 변환을 하지 않아도 될까?
제네릭 클래스를 사용하면 컴파일러는 일단 대입된 자료형이 잘 쓰였는지 확인한다. 그리고 class파일을 생성할 때 T를 사용한 곳에 지정된 자료형에 따라 컴파일하므로 형 변환을 하지 않아도 된다.
따라서 제네릭을 사용하면 컴파일러가 자료형을 확인해 주기 때문에 안정적이면서 형 변환 코드가 줄어든다.
※ 문제: 이번에는 플라스틱 액체가 재료인 프린터를 선언해보자. 빈칸[ ] 채우기.
GenericPrinter<[ ]> plasticPrinter = new GenericPrinter<Plastic>();
[ ].setMaterial(new Plastic());
Plastic plastic = plasticPrinter.[ ];
정답 : Plastic / plasticPrinter / getMaterial()
제네릭 클래스 사용 예제
지금까지 내용을 코드로 작성하면 다음과 같다.
재료로 쓸 파우더와 플라스틱 클래스를 먼저 정의한다.
package generics;
public class Powder {
@Override
public String toString() {
return "재료는 Powder입니다.";
}
}
package generics;
public class Plastic {
@Override
public String toString() {
return "재료는 Plastic입니다.";
}
}
파우더와 플라스틱을 재료로 하는 프린터를 제네릭 클래스로 정의하면
package generics;
public class GenericPrinter<T> {
private T material; //T 자료형으로 선언한 변수
public void setMaterial(T material) {
this.material = material;
}
public T getMaterial() {
return material;
} //T 자료형 변수 material을 반환하는 제네릭 메서드
@Override
public String toString() {
return material.toString();
}
}
GenericPrinter<T> 클래스의 인스턴스 변수 material은 자료형 매개변수 T로 선언했다.
그리고 getMaterial()메서드는 T 자료형 변수 material을 반환한다.
메서드 선언부나 메서드의 매개변수로 자료형 매개변수 T를 사용한 메서드를 '제네릭 메서드'라고 한다.
제네릭 메서드는 일반 메서드뿐 아니라 static에서도 활용할 수 있다.
이제 프로그램을 실행해보자.
package generics;
public class GenericPrinterTest {
public static void main(String[] args) {
GenericPrinter<Powder> powderPrinter = new GenericPrinter<>();
//Powder형으로 GenericPrinter클래스 생성
powderPrinter.setMaterial(new Powder());
Powder powder = powderPrinter.getMaterial();
System.out.println(powderPrinter);
GenericPrinter<Plastic> plasticPrinter = new GenericPrinter<>();
//Plastic형으로 GenericPrinter클래스 생성
plasticPrinter.setMaterial(new Plastic());
Plastic plastic = plasticPrinter.getMaterial();
System.out.println(plasticPrinter);
}
}
5행과 12행처럼 사용할 참조 자료형을 지정하여 GenericPrinter클래스를 생성한다.
만약 새로운 재료가 추가되면 추가된 재료 클래스를 만들고 T대신 해당 클래스를 대입하여 GenericPrinter를 생성하면 되는 것.
제네릭에서 대입한 자료형을 명시하지 않는 경우
제네릭 클래스를 사용할 때는 GenericPrinter<Powder>의 Powder와 같이 대입된 자료형을 명시해야 한다.
그런데 다음 처럼 명시하지 않고 사용할 수도 있다.
GenericPrinter powderPrinter2 = new GenericPrinter ();
//대입된 자료형 명시 X
powderPrinter2.setMaterial(new Powder());
Powder powder2 = (Powder)powderPrinter2.getMaterial(); //강제 형 변환
System.out.println(powderPrinter2);
이렇게 클래스에 대입된 자료형을 명시하지 않는 경우 컴파일 오류는 아니지만, 사용할 자룧여을 명시하라는 의미의 경고 줄이 나타난다. 또한 컴파이러가 어떤 자료형을 사용할 것인지 알 수 없으므로 getMaterial() 메서드에서 강제로 형 변환을 해야한다.
따라서 제네릭 클래스를 사용하는 경우에는 되도록이면 대입된 자료형으로 사용할 참조 자료형을 지정하는 것이 Best!만약 여러 자료형을 동시에 사용하려면 다음처럼 Object클래스를 사용할 수도 있다.
GenericPrinter<Object> genericPrinter = new GenericPrinter<Object>();
<T extends 클래스>
제네릭 클레스에서 T 자료형에 사용할 자료형에 제한을 둘 수 있다.
예를 들어 우리가 구현한 GenericPrinter<T> 클래스는 사용할 수 있는 재료가 한정되어있다. 만일 아무 제약이 없으면 다음처럼 물을 재료로 쓸 수도 있는 것이다.
GenericPrinter<water> printer = new GenericPrinter<Water>();
물은 인쇄할 수 없는 재료다. 이런 일을 방지하기 위해 사용할 클래스에 자료형을 제한하는 방법으로 extends예약어를 사용할 수 있다.
GenericPrinter<T> 클래스의 T에 대입된 자료형으로 사용할 재료 클래스를 추상클래스에서 상속받도록 하는 것.
Material 클래슨느 다음처럼 추상클래스로 정의했다.
상속받은 클래스는 doPrinting() 추상 메서드를 반드시 구현해야 한다.
package generics;
public abstract class Material {
public abstract void doPrinting();
}
Material을 상속받은 파우더와 플라스틱 클래스.
package generics;
public class Powder extends Material{
public void doPrinting() {
System.out.println("재료 Powder로 출력");
}
@Override
public String toString() {
return "재료는 Powder입니다.";
}
}
package generics;
public class Plastic extends Material{
public void doPrinting() {
System.out.println("재료 Plastic로 출력");
}
@Override
public String toString() {
return "재료는 Plastic입니다.";
}
}
<T extends Material>을 사용한 GenericPrinter 클래스
package generics;
public class GenericPrinter<T extends Material> {
// extends 예약어로 사용할 수 있는 자료형에 제한을 둠
private T material;
...
}
클래스 이름에 <T extends Material>이라고 명시해 사용할 수 있는 자료형에 제한을 둔다. Material 클래스를 상속받지 않은 재료 클래스를 사용하면 오류가 발생한다.
T 위치에 특정 인터페이스를 구현한 클래스만 사용하려는 경우에도 extends 예약어를 사용할 수 있다.
<T extends 클래스>로 상위 클래스 메서드 사용
<T extends Material>로 선언하면 제네릭 클래스를 사용할 때 상위 클래스 Material에서 선언한 메서드를 사용할 수도 있다.
우선 <T extends Material>을 사용하지 않을 경우.
T는 컴파일 할 때 Object 클래스로 변환된다. 따라서 이 경우에는 Object클래스가 기본으로 제공하는 메서드만 쓸 수 있다. (자료형을 알 수 없으니까!)
만약 <T extends Material>을 사용하면?
material이 사용할 수 있는 메서드에 doPrinting()이 나타난다.
즉 상위 클래스 Material에서 선언하거나 구현한 메서드를 모두 사용할 수 있다.
실제로 <T extends Material>을 사용하면 컴파일할 때 내부적으로 T자료형이 Object가 아닌 Material로 변환된다.
package generics;
public class GenericPrinter<T extends Material> {
private T material;
public void setMaterial(T material) {
this.material = material;
}
public T getMaterial() {
return material;
}
@Override
public String toString() {
return material.toString();
}
public void printing() {
material.doPrinting();
}
}
T형 material 변수에서 doPrinting() 메서드를 호출했다.
package generics;
public class GenericPrinterTest2 {
public static void main(String[] args) {
GenericPrinter<Powder> powderPrinter = new GenericPrinter<>();
powderPrinter.setMaterial(new Powder());
powderPrinter.printing();
GenericPrinter<Plastic> plasticPrinter = new GenericPrinter<>();
plasticPrinter.setMaterial(new Plastic());
plasticPrinter.printing();
}
}
<제네릭 메서드 활용>
메서드의 매개변수를 자료형 매개변수로 사용하는 경우에 대해 알아보자.
또 자료형 매개변수가 하나 이상인 경우도 알아보자.
'제네릭 메서드의 일반 형식'은 다음과 같다.
public <자료형매개변수> 반환형 메서드이름(자료형매개변수···){}
반환형 앞에 사용하는 <자료형매개변수>는 여러 개일 수 있으며, 이는 메서드 내에서만 유효하다.
자료형매개변수를 여러개 사용하는 제네릭 메서드 예제를 보자.
Point클래스가 있다. 한 점을 나타내기 위해 x, y 두 멤버 변수를 사용하는데 모두 자료형 매개변수<X, Y>로 선언.
package generics;
public class Point<T,V> {
T x;
V y;
Point(T x, V y){
this.x = x;
this.y = y;
}
public T getX() {
return x;
}// 제네릭 메서드
public V getY() {
return y;
}// 제네릭 메서드
}
한 점을 나타내는 Point 클래스의 두 좌표는 x, y는 정수인지 실수인지 알 수 없다. 그래서 T와 V라는 자료형 매개변수로 표현했으며, 이 변수들을 위한 메서드 getX(), getY()는 T와 V를 반환하고 있으므로 제네릭 메서드이다.
이제 이 Point 클래스를 활용해 다음 두 점을 생성한다.
Point<Integer, Double> p1 = new Point<>(0,0.0);
Point<Integer, Double> p2 = new Point<>(10,10.0);
// <> 다이아몬드 연산자만 사용하고 자료형 명시X
- 두 점의 위치를 표현할 때 x좌표는 Integer를 사용하고, y좌표는 Double을 사용했다.
- 컴파일러는 선언된 자료형을 보고 생성되는 인스턴스의 자료형을 유추할 수 있으므로, <>다이아몬드 연산자에는 자료형을 생략해도 된다.
그럼 두 점을 매개변수로 받아 만들어지는 사각형의 넓이를 계산하는 makeRectangle() 메서드를 만들어보자.
두 점이 Integer형으로 만들어질 수도 있고, Double형으로 만들어질 수도 있기 때문에 넓이를 계산하는 makeRectangle() 역시 제네릭 메서드로 만들어야 한다.
package generics;
public class GenericMethod {
public static <T,V> double makeRectangle(Point<T,V> p1, Point<T,V> p2) { //제네릭 메서드
double left = ((Number)p1.getX()).doubleValue();
double right = ((Number)p2.getX()).doubleValue();
double top = ((Number)p1.getY()).doubleValue();
double bottom = ((Number)p2.getY()).doubleValue();
double width = right-left; //두 점의 x값을 빼면 너비
double height = bottom-top; //두 점의 y값을 빼면 높이
return width * height;
}
public static void main(String[] args) {
Point<Integer, Double> p1 = new Point<Integer, Double>(0,0.0);
Point<Integer, Double> p2 = new Point<>(10,10.0);
double rect = GenericMethod.<Integer, Double>makeRectangle(p1, p2);
System.out.println("두 점으로 만들어진 사각형의 넓이는 " + rect + "입니다.");
}
}
*Number 클래스의 doubleValue() 메서드는 wrapper클래스 안에 있는 기본 자료형 값을 double형으로 변환하여 반환해주는 메서드.
- GenericMethod 클래스는 제네릭 클래스가 아니다. 제네릭 클래스가 아니더라도 내부에 제네릭 메서드를 구현할 수 있다.
- 제네릭 메서드인 makeRectangle()메서드는 static으로 구현.
- makeRectangle()메서드에서 사용하는 T와 V는 메서드 안에서만 유효하게 쓸 수 있다.
다음처럼 제네릭 클래스 안에 제네릭 메서드를 선언했다고 가정하자.
class Shape<T> {
public static <T, V> double makeRectangle(Point<T, V>p1, Point<T, V>p2) {
...
}
}
이때 Shape<T>에서 사용하는 T와 makeRectangle()에서 사용하는 T는 완전히 다른 T이다.
앞에서 설명했듯 makeRectangle()메서드에서 사용하는 T는 메서드 내에서만 유효하다.
이제 구현한 제네릭 메서드를 호출해보자.
20행에서 사용할 자료형으로 <Integer, Double>로 대입하여 메서드를 호출한다. 만약 자료형을 명시하지 않고 메서드를 호추하면 매개변수 클래스에서 자료형을 유추하게 된다.
만약 p1, p2가 Point<Integer, Double>형으로 선언된 경우 제네릭 메서드에서 대입할 자료형이 생략돼도 컴파일러가 자동으로 유추한다.
Point<Integer, Double> p1 = new Point<>(0,0.0);
Point<Integer, Double> p2 = new Point<>(10,10.0);
//<>로 생략
double rect = GenericMethod.makeRectangle(p1, p2);
//makeRectangle앞에 <Integer, Double>을 생략
<컬렉션 프레임워크에서 사용하는 제네릭>
컬렉션 프레임워크에서는 다양한 자료형을 관리하기 위해 제네릭을 자주 사용한다.
ArrayList를 예로 들면, ArrayList.java에서 ArrayList클래스의 정의는 다음과 같다.
public class ArrayList<E> extends AbstractList<E> implements List<E>,
RandomAccess, Cloneable, java.io.Serializable {
...
}
배열은 요소를 가지므로 T보다는 Element의 E를 더 많이 쓴다.
다음처럼 E위치에 원하는 자료형을 넣어 배열을 사용할 수 있다.
ArrayList<String> list = new ArrayList<String>();
ArrayList에서 미리 정의되어 있는 메서드 중 가장 많이 사용하는 get() 메서드를 살펴보면 아래 코드와 같다.
public E get(int index) {
rengeCheck(index);
return elementDate(index);
}
E라고 쓰인 반환형은 ArrayList를 생성할 때 사용한 자료형으로 반환합니다. 여기서는 String.
또한 컴파이러가 형 변환을 구현하므로 프로그래머가 직접 형 변환을 하지 않아도 된다.
ArrayList<String> list = new ArrayList<String>();
String str = new String("abc");
list.add(str);
String s = list.get(0); //형 변환을 사용하지 않음