-
[Java] 제네릭 개념 정리IT/개발(자바, 서블릿, 스프링 등) 2020. 1. 24. 20:52
ㅇ1. 제네릭이란?
자바는 90년대 1.0이 발표된 이후 꾸준하게 업데이트하여 현재 자바 11 버전까지 발표되었다. 새로운 버전이 발표될 때마다 기능이 추가되었는데 그중 자바 언어의 많은 변화를 가져온 것이 JDK 5에서 소개된 제네릭이다. 제네릭은 소스에서 데이터 타입을 프로그래밍할 때 결정하는 것이 아니고 실행할 때 결정하게 하는 기능이다. 매개변수로 받아서 데이터 타입을 결정한다고 해서 제네릭을 '매개변수 타입(parameter type)'이라고도 부른다. JDK에 제네릭이 포함됨으로써 기본자바 API들도 많이 변경되었다.
가방에 책, 연필통, 노트를 담는 작업을 자바 언어로 구현한다고 가정하자. 가방, 책, 연필통, 노트는 객체이므로 다음 코드처럼 각각 클래스로 만들어야 한다. 클래스 선언이 완료되면 Bag에 Book, PencilCase, Notebook을 담는 작업을 한다. Bag에 객체를 담는 작업은 자바에서 'has a' 관계로 표현한다. has a 관계는 필드 선언으로 나타낸다. 그런데 Bag이 가지는 객체를 필드로 선언할 때 데이터 타입을 무엇으로 선언할까?
public class Bag<T> { T thing; public Bag(T thing) { this.thing = thing; } }
제네릭이란 데이터 타입을 매개변수로 지정하는 것을 말한다. 타입 매개변수란 실행 시 인자로 전달하는 타입을 변수의 타입으로 지정하는 것이다. 타입 매개변수는 클래스, 인터페이스, 메서드에서 사용할 수 있으며, 이것을 각각 제네릭 클래스, 제네릭 인터페이스, 제네릭 메서드라고 한다.
제네릭 클래스 선언
class 클래스명<타입 매개변수>{
}
제네릭 클래스의 인스턴스를 생성할 때 타입 매개변수는 인자로 전달받은 타입으로 대체된다. 일반적인 타입 매개변수 이름은 T, V처럼 알파벳 대문자 한 글자로 표현한다. Bag 클래스에서 사용한 <T>는 인스턴스 생성 시 전달되는 타입으로 대체된다. 타입 매개변수로 전달되는 값을 '타입 인자'라고 한다.
제네릭 클래스 생성
제네릭 클래스의 타입 매개변수에 타입 인자를 전달하려면 다음처럼 new 명령문으로 클래스 생성시 클래스 이름 다음에 타입 인자를 <> 기호로 감싼다. 제네릭 클래스의 인스턴스를 생성하는 문법은 다음과 같다.
new 클래스명<타입 인자>();
new Bag<Book>(new Book()); new Bag<PencilCase>(new PencilCase()); new Bag<Notebook>(new Notebook());
위의 코드를 실행하면 Bag 클래스의 T는 순서대로 Book, PencilCase, Notebook으로 대체된다.
제네릭 클래스를 사용하면 인스턴스 생성 시 타입을 지정할 수 있으므로 동적으로 코드를 재사용할 수 있는 장점이 있다. 제네릭 클래스의 인스턴스를 생성할 때 JDK 7 부터는 타입 인자를 생략할 수 있다.
new Bag<>(new Book()); new Bag<>(new PencilCase()); new Bag<>(new Notebook());
타입 인자를 생략하면 컴파일러가 자동으로 생성자의 인자로 전달되는 객체의 타입을 지정한다.
제네릭 클래스 참조
제네릭 클래스의 인스턴스를 생성한 후 참조하는 변수가 있어야 계속 사용할 수 있다.
클래스명<타입 인자>
이렇게 제네릭 인스턴스의 참조변수 타입을 선언할 수 있다.
Bag<Book> bag1 = new Bag<Book>(new Book()); Bag<PencilCase> bag2 = new Bag<PencilCase>(new PencilCase()); Bag<Notebook> bag3 = new Bag<Notebook>(new Notebook());
지금까지 살펴본 내용을 완성된 코드로 작성하면 아래와 같다.
class Bag<T> { private T thing; public Bag(T thing){ this.thing = thing; } public T getThing(){ return thing; } public void setThing(T thing){ this.thing = thing; } void showType(){ System.out.println("T의 타입은 " + thing.getClass().getName()); } } class Book {} class PencilCase {} class Notebook {} public class BagTest { public static void main(String[] args){ Bag<Book> bag1 = new Bag<>(new Book()); Bag<PencilCase> bag2 = new Bag<>(new Pencilcase()); Bag<Notebook> bag3 = new Bag<>(new Notebook)); bag1.showType(); bag2.showType(); bag3.showType(); } } [실행결과] T의 타입은 com.test.java.Book T의 타입은 com.test.java.PencilCase T의 타입은 com.test.java.Notebook
제네릭의 장점
제네릭 등장 전에 기존 구현 방법은 변수의 타입을 Object로 선언하는 것이었다. 모든 자바 클래스의 최상위 객체이므로 어떤 타입의 값도 저장할 수 있다. 제네릭으로 구현해야 하는 이유가 뭘까?
제네릭은 불필요한 타입 변경을 없애준다.
메서드 getThing()의 리턴 타입은 Object다. 메서드 반환에서 오브젝트 타입을 반환하면 사용하기 전에 원래 타입으로 변경(type casting)해주어야 한다. 안그러면 Object에서 상속한 메서드만 사용할 수 있기 때문이다.
class Bag<Object> { private Object thing; public Bag(Object thing){ this.thing = thing; } public Object getThing(){ return thing; } public void setThing(Object thing){ this.thing = thing; } void showType(){ System.out.println("T의 타입은 " + thing.getClass().getName()); } } class Book {} class PencilCase {} class Notebook {} public class BagTest { public static void main(String[] args){ Bag bag1 = new Bag(new Book()); Bag bag2 = new Bag(new PencilCase()); Bag bag3 = new Bag(new Notebook()); Book book = (Book) bag1.getThing(); PencilCase pc = (PencilCase) bag2.getThing(); Notebook nb = (Notebook) bag3.getThing(); } }
제네릭은 엄격한 타입 검사를 통해 안정성을 높여준다.
Bag bag1 = new Bag(new Book()); Bag bag2 = new Bag(new PencilCase()); Bag bag3 = new Bag(new Notebook()); bag1 = bag2; // 오류가 발생하지 않음
오류가 발생하지 않은 이유는 bag1, bag2 모두 Bag 타입이기 때문이다. 그러나 의미상 bag1은 Book을 가진 Bag, bag2는 PencilCase를 가지는 Bag 객체를 참조하므로 bag1 = bag2는 잘못된 코드다.
의미상 잘못된 코드이지만 컴파일러가 오류라고 해석하지 않는 코드도 제네릭을 사용하면 오류로 해석해준다.
Bag<Book> bag1 = new Bag<>(new Book()); Bag<PencilCase> bag2 = new Bag<>(new PencilCase()); Bag<Notebook> bag3 = new Bag<>(new Notebook()); bag1 = bag2; //오류 발생
컴파일러가 타입 검사를 엄격하게 해서 타입의 안정성을 보장받을 수 있다.
2. 타입 매개변수
멀티 타입 매개변수
제네릭 클래스를 선언할 때 타입 매개변수는 여러 개를 선언할 수 있다. 2개 이상의 타입 매개변수를 선언할 땐 콤마를 구분자로 사용한다.
제네릭 클래스 생성 후 참조변수 선언
클래스명<타입 인자 목록> 변수명 = new 클래스명<타입 인자 목록>(생성자 인자 목록);
Bag<Book, String> bag = new Bag<Book, String>(new Book(), "과학");
Bag 클래스는 2개의 타입 매개변수를 선언하고 있으므로 참조변수 타입에도, 객체를 생성할 때도 2개 타입 인자를 지정해야 한다. Bag 객체 생성 시 지정한 타입 인자는 순서대로 지정된다. T는 모두 Book으로, N은 String으로 대체된다.
class Bag<T, N> { private T thing; private N name; public Bag(T thing, N name){ this.thing = thing; this.name = name; } public T getThing(){ return thing; } public void setThing(T thing){ this.thing = thing; } public N getName(){ return name; } public void setName(N name){ this.name = name; } void showType(){ System.out.println("T의 타입은 " + thing.getClass().getName(); System.out.println("N의 타입은 " + thing.getClass().getName(); } } class Book { public String toString(){ return "책"; } } class PencilCase{} class Notebook{} public class BagTest{ public static void main(String[] args){ Bag<Book, String> bag = new Bag<Book, String>(new Book(), "과학"); bag.showType(); Book book = bag.getThing(); String name = bag.getName(); System.out.println("Thing is "+book); System.out.println("Name is " +name); } } [실행결과] T의 타입은 com.test.java.Book N의 타입은 java.lang.String Thing is : 책 Name is : 과학
타입 제한
제네릭 클래스 Bag이 다음처럼 선언되어 있다면 Bag은 객체 생성 시 어떤 타입 인자도 받을 수 있다.
class Bag<T> { private T thing; public Bag(T thing){ this.thing = thing; } }
그런데 다음과 같이 Bag에 담을 수 있는 객체에 제한을 두려고 한다. 책, 필통, 노트처럼 고체로 되어 있는 물건은 담을 수 있지만, 물이나 커피처럼 액체로 된 물건은 담을 수 없도록 하려고 한다. 즉, 타입 매개변수에 제한을 두겠다는 의미다.
타입 매개변수 제한 설정
<T extends superclass>
위와 같이 선언하면 T를 대체할 수 있는 타입은 슈퍼클래스나 슈퍼클래스를 상속받는 하위 객체들만 가능하다. 다음처럼 선언된 제네릭 클래스의 T는 Solid 또는 Solid를 상속하는 객체로만 대체할 수 있다.
class Bag<T extends Solid> {
Solid는 고체, Liquid는 액체를 나타내는 클래스다. 고체 특성이 있는 객체들은 Solid를 상속받고, 액체 특성이 있는 객체들은 Liquid를 상속받는다. <T extends Solid>로 선언하면 타입 매개변수로 지정할 수 있는 것은 Solid 또는 Solid를 상속받는 Book, PencilCase, Notebook만 가능하다.
class Bag<T extends Solid> { private T thing; public Bag(T thing, N name){ this.thing = thing; this.name = name; } public T getThing(){ return thing; } public void setThing(T thing){ this.thing = thing; } void showType(){ System.out.println("T의 타입은 " + thing.getClass().getName(); System.out.println("N의 타입은 " + thing.getClass().getName(); } } class Solid{} class Liquid{} class Book extends Solid{} class PencilCase extends Solid{} class Notebook extends Solid{} class Water extends Liquid() class Coffee extends Liquid() public class BagTest{ public static void main(String[] args){ Bag<Book> bag1 = new Bag<Book>(new Book()); Bag<PencilCase> bag2 = new Bag<PencilCase>(new PencilCase)); Bag<Notebook> bag3 = new Bag<Notebook>(new Notebook()); Bag<Water> bag4 = new Bag<Water>(new Water()); // 오류 발생 Bag<Coffee> bag5 = new Bag<Coffee>(new Coffee()); // 오류 발생 } }
3. 와일드카드
class Bag<T extends Solid> { private T thing; private String owner; public Bag(T thing){ this.thing = thing; } public T getThing(){ return thing; } public void setThing(T thing){ this.thing = thing; } void showType(){ System.out.println("T의 타입은 " + thing.getClass().getName(); } public String getOwner(){ return owner; } public void setOwner(String owner){ this.owner = owner; } boolean isSameOwner(Bag<T> obj){ if(this.owner.equals(obj.getOwner())){ return true; } else { return false; } } } class Solid{} class Liquid{} class Book extends Solid{} class PencilCase extends Solid{} class Notebook extends Solid{} class Water extends Liquid{} class Coffee extends Liquid{} public class BagTest{ public static void main(String[] args){ Bag<Book> bag1 = new Bag<Book>(new Book()); Bag<PencilCase> bag2 = new Bag<PencilCase>(new PencilCase()); Bag<Notebook> bag3 = new Bag<Notebook>(new Notebook()); bag1.setOwner("aaa"); bag2.setOwner("aaa"); bag1.isSameOwner(bag2); // 오류 발생!! /* bag1의 타입 매개변수는 Book, bag2의 타입 매개변수는 PencilCase. 따라서 bag1의 isSameOwner() 메서드에서 사용하는 T와 인자로 전달된 obj의 T 값이 서로 달라서 오류가 발생한다. */ } }
위의 예제에서 bag1의 타입 매개변수는 Book, bag2의 타입 매개변수는 PencilCase다. 따라서 bag1의 isSameOwner() 메서드에서 사용하는 T와 인자로 전달된 obj의 T 값이 서로 달라서 오류가 발생한다.
isSameOwner() 메서드에서 구현하고 싶은 것은 Bag 안의 물품이 무엇인지와 상관없이 현재 Bag의 물품과 인자로 전달된 Bag 안의 물품 소유주가 같은지를 비교하여 같으면 true, 다르면 false를 반환하는 것이다. 따라서 인자로 지정된 Bag의 타입 매개변수와 현재 객체의 타입 매개변수가 같을 필요가 없다.
이럴 때 사용할 수 있는 것이 와일드카드다. 와일드카드는 ? 기호로 표현한다. 어떤 타입 매개변수도 지정할 수 있다는 의미다. 다음처럼 <?> 와일드카드를 사용하면 현재 객체의 타입 매개변수와 같지 않은 타입의 Bag을 인자로 받을 수 있다.
boolean isSameOwner(Bag<?> obj){
위와 같이 지정하면 isSameOwner() 메서드 호출 시 발생하던 오류가 사라진다.
와일드카드 제한
와일드카드에도 타입을 제한할 수 있다. 방법은 상위 제한, 하위 제한 두가지가 있다.
[상위 제한]
<? extends 슈퍼클래스>
슈퍼클래스 또는 슈퍼클래스를 상속받은 하위 객체만 타입으로 지정할 수 있다.
[하위 제한]
<? super 서브클래스>
서브클래스 또는 서브클래스가 상속하고 있는 상위 객체만 타입으로 지정할 수 있다.
private static double sum(List<?> list) { double total = 0; for (Number v : list) { total += v.doubleValue(); } return total; }
위의 소스코드는 for문에서 list의 요소들을 저장할 변수로 Number v를 선언했다. 그런데 for문이 동작하려면 list 요소들이 숫자여야 한다. sum() 메서드의 매개변수에 List의 요소 타입으로 와일드카드를 선언했기 때문에 숫자가 아닌 다른 타입 요소를 가진 List도 인자로 받을 수 있다. 이 때 for문에서 Number v가 아니라 Object v로 변경하면 오류를 해결할 수 있지만, 합계를 구할 수 없게 된다. 따라서 sum() 메서드는 List 요소가 숫자일 때만 받아서 처리해야 한다. 자바에서 숫자 타입은 Byte, Short, Integer, Long, Float, Double이며 모두 Number를 상속받는다. 따라서 Number를 상속받는 타입이 타입이 넘어올 때만 매개변수로 받아서 처리하면 된다.
private static double sum(List<? extends Number> list) {
아래는 예제 소스 전체이다.
import java.util.Arrays; import java.util.List; public class WildCardTest { public static void main(String[] args) { // int, double 타입의 배열을 선언하고 // Integer, Double 등 Wrapper 타입으로 참조한다. // int는 Integer로, double은 Double 타입으로 자동처리된다. Integer[] inum = {1,2,3,4,5}; Double[] dnum = {1.0, 2.0, 3.0, 4.0, 5.0}; String[] snum = {"1", "2", "3", "4", "5"}; // Arrays.asList() 메서드는 인자로 전달한 배열을 리스트 객체로 변환하여 반환. // java.util에 정의된 List는 제네릭 인터페이스므로 List<Integer> 타입 매개변수를 전달한다. List<Integer> iList = Arrays.asList(inum); List<Double> dList = Arrays.asList(dnum); List<String> sList = Arrays.asList(snum); // sum() 메서드 호출하면서 List<Integer>, List<Double>, List<String> 타입의 인자를 전달. double isum = sum(iList); double dsum = sum(dList); // sum(sList); // 오류 발생!! System.out.println("inum의 합계 : " + isum); System.out.println("dnum의 합계 : " + dsum); } // Number를 상속받은 타입만 매개변수로 전달받을 수 있도록 제한. // 따라서 sList는 List<String> 타입이므로 받을 수 없다. private static double sum(List<? extends Number> list) { double total = 0; for (Number v : list) { total += v.doubleValue(); } return total; } } [실행결과] inum의 합계 : 15.0 dnum의 합계 : 15.0
4. 제네릭 다양한 적용
제네릭 메서드
타입 매개변수를 사용하는 메서드를 '제네릭 메서드'라고 한다. 제네릭 클래스뿐만 아니라 일반 클래스에서도 선언해서 사용할 수 있다.
[제네릭 메서드에 타입 매개변수 선언]
<타입 매개변수 목록> 리턴타입 메서드명(매개변수목록){ ... }
static <T extends Number, V extends T> boolean isInclude(T num, V[] array) {
isInclude() 메서드에서 타입 매개변수 T, V를 선언했다. T와 V의 조건을 지정하고자 리턴 타입 앞에 T는 Number 또는 Number를 상속하는 하위 타입만 받겠다고 선언했고, V는 T 또는 T를 상속하는 타입만 받겠다고 선언했다.
import com.sun.tools.internal.ws.resources.GeneratorMessages; public class GenMethodTest { static <T extends Number, V extends T> boolean isInclude(T num, V[] array) { for(int i=0; i<array.length; i++){ if(array[i] == num){ return true; } } return false; } public static void main(String[] args) { Integer[] inum = {1,2,3,4,5}; Double[] dnum = {1.0, 2.0, 3.0, 4.0, 5.0}; String[] snum = {"1", "2", "3", "4", "5"}; boolean b1 = isInclude(3, inum); System.out.println("결과 : " + b1); boolean b2 = isInclude(5.0, dnum); System.out.println("결과 : " + b2); GenMethodTest.<Integer, Integer>isInclude(3, inum); GenMethodTest.<Double, Double>isInclude(5.0, dnum); } } [실행결과] 결과 : true 결과 : false
제네릭 생성자
import com.sun.deploy.util.StringUtils; class StringUtil { private String value; <T extends CharSequence> StringUtil(T value){ this.value = value.toString(); } void printVal() { System.out.println("value: "+value); } } public class GenConsTest { public static void main(String[] args) { String s = new String("서울"); StringBuffer stringBuffer = new StringBuffer("대전"); StringBuilder stringBuilder = new StringBuilder("대구"); StringUtil stringUtil = new StringUtil(s); StringUtil stringUtil1 = new StringUtil(stringBuffer); StringUtil stringUtil2 = new StringUtil(stringBuilder); stringUtil.printVal(); stringUtil1.printVal(); stringUtil2.printVal(); } } [실행결과] value: 서울 value: 대전 value: 대구
제네릭 인터페이스
// 제네릭 인터페이스 선언. // 타입 매개변수는 extends Comparable로 선언했으므로 Compareable을 상속받는 타입만 전달받는다. // Comparable<T>는 java.lang에 제네릭 인터페이스로서 compareTo(T o) 메서드가 선언되어있다. // compareTo() 메서드는 현재 객체와 매개변수로 전달받은 객체의 순서를 비교한다. // 작으면 음수, 같으면 0, 크면 양수를 반환한다. interface Maximum<T extends Comparable<T>>{ T max(); } class NumUtil<T extends Comparable<T>> implements Maximum<T> { T[] value; // 제네릭 생성자. 매개변수로 전달받은 배열을 value 필드에서 참조한다. NumUtil(T[] value) { this.value = value; } // max() 메서드의 리턴 타입은 타입 매개변수로 지정했다. public T max() { // 변수 v에 value 배열의 첫 번째 값을 저장한다. T v = value[0]; // 배열의 모든 요소에 대한 값을 비교한다. // value[i] 값이 더 크면 v 변숫값에 저장한다. // 결국 v에는 배열의 가장 큰 값이 저장된다. for(int i = 0; i <value.length; i++){ if(value[i].compareTo(v)>0) { v = value[i]; } } return v; } } public class GenInterfaceTest { public static void main(String[] args) { Integer[] inum = {1,2,3,4,5}; Double[] dnum = {1.0, 2.0, 3.0, 4.0, 5.0}; String[] snum = {"1", "2", "3", "4", "5"}; // 타입 매개변수를 Integer, Double로 지정하는 제네릭 클래스의 인스턴스를 생성한다. NumUtil<Integer> iutil = new NumUtil<Integer>(inum); NumUtil<Double> dutil = new NumUtil<Double>(dnum); NumUtil<String> sutil = new NumUtil<String>(snum); // iutil.max() 메서드는 iutil이 참조하는 인스턴스의 value 배열 중 가장 큰 값을 반환한다. System.out.println("inum 최댓값 : " + iutil.max()); System.out.println("dnum 최댓값 : " + dutil.max()); System.out.println("snum 최댓값 : " + sutil.max()); } }
'IT > 개발(자바, 서블릿, 스프링 등)' 카테고리의 다른 글
[Java] 컬렉션 개요 (0) 2020.01.27 [Java] 어노테이션 (0) 2020.01.25 [Java] 예외 처리하기 (0) 2020.01.24 [Java] 유틸리티 API (4) java.utilDate / java.util.Calendar 클래스 (0) 2020.01.19 [Java] 유틸리티 API (3) java.util.Arrays 클래스 (0) 2020.01.19