선장님과 함께하는 자바 스터디입니다.
자바 스터디 Github
github.com/whiteship/live-study
나의 Github
github.com/cmg1411/whiteShip_live_study
- 제네릭 사용법
- 제네릭 주요 개념 (바운디드 타입, 와일드 카드)
- 제네릭 메소드 만들기
- Erasure
제네릭
제네릭은 Java 5 부터 추가된 기능이다.
제네릭 타입을 사용하면, 잘못된 타입을 사용되었을때 컴파일타임에 잡을 수 있다.
에러는 언제나 런타임보다 컴파일타임에 잡는 것이 좋다.
제네릭 용어
제네릭은 제네릭만의 용어가 많다.
제네릭이 왜 나왔는가
- 제네릭은 타입 변환을 하지 않아도 된다.
위의 말이 무엇일까.
제네릭을 사용하지 않을 때의 코드를 보자.
public static void main(String[] args) {
List list = new ArrayList();
list.add("white");
list.add("shio");
list.add("study");
for (Object o : list) {
String str = (String) o;
System.out.println(str);
}
}
- 제네릭이 생긴 이후 List 같은 것을 로(raw) 타입이라 한다.
- 로 타입은 타입을 지정하지 않고 (Object 하위타입) 을 읽어들이고, Object 형으로 데이터를 가진다.
- 따라서 String 으로 쓰기 위해서는 명시적인 타입 캐스팅이 필요하다.
명시적인 타입 캐스팅은 귀찮으며, 실수를 유발한다.
또한 로 타입 컬렉션은 Object 타입을 넣을 수 있기 때문에, String 만 들어가리라는 보장이 없다.
예를들어 위의 코드에서 누군가 실수로 아래의 코드처럼 만들 수 있다.
public static void main(String[] args) {
List list = new ArrayList();
list.add("white");
list.add("shio");
list.add("study");
list.add(1);
for (Object o : list) {
String str = (String) o;
System.out.println(str);
}
}
list 에 정수 1을 넣는 구문은 컴파일상 아무 문제가 없다.
하지만 아래 for 문에서 값을 빼서 처리하는 곳에서 ClassCastException 이 발생한다.
ClassCastException 는 RuntimeException 의 하위 예외로, UncheckedException 이다.
컴파일타임에 알 수 없고, 런타임에야, 프로그램이 돌아가고 있을때야 예외를 발생하여 문제를 일으킨다.
int 를 String 으로 캐스팅하려 했기 때문에 이런 일이 발생한다.
제네릭을 쓰면 ?
- 제네릭을 쓰면 위의 문제들이 모두 해결된다.
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
list.add("white");
list.add("shio");
list.add("study");
// list.add(1);
for (String s : list) {
String str = s;
System.out.println(str);
}
}
명시적인 형변환 없이, 바로 String 으로 사용할 수 있다.
또한, 정수 1을 넣으려는시도는 컴파일타임에 오류가 나기 때문에 ClassCastException 의 위험도 없다.
제네릭의 특징
1. 불공변
- 제네릭과 비교하여 배열은 공변이다.
- 공변이라 함은, Sub 가 Super 의 하위타입일 때, Sub[] 는 Super[] 의 하위타입인 것이다.
- 예를 들어, String 은 Object 의 하위타입이기 때문에, String[] 은 Object[] 의 하위타입이다.
- 따라서 아래와 같은 코드는 컴파일오류를 뱉지 않으며, 런타임에서야 예외를 던지는 문제가 발생한다.
Obect[] ob = new String[5]; // 가능
ob[0] = 1; // ArrayStoreException 을 런타임에 발생
- 그에 반해, 제네릭은 불공변이다.
- List<String> 은 List<Object> 의 하위타입이 되지 않는다.
- 따라서 아래와 같은 코드는 컴파일타임에 에러를 내기 때문에 사전에 차단할 수 있다.
List<Object> obList = new ArrayList<String>(); // 컴파일에러 !
obList.add(1);
2. 타입추론
- 타입추론은 메서드를 호출하는 코드에서 타입인자가 정의한대로 제대로 쓰였는지 살펴보는 컴파일러의 기능이다.
2-1. 제네릭 메서드의 타입 추론
- 다음과 같은 메서드가 있다. 박스객체의 리스트와 T 타입의 객체 t 를 받아서, T 타입의 박스를 만들어 박스객체 리스트에 추가시킨다.
public Class BoxClass {
public static <T> void addBox(T t, List<Box<T>> boxes) {
Box<T> box = new Box<>();
box.set(t);
boxes.add(box);
}
}
원래 이 메서드를 사용하려면 아래와 같이 써야 한다.
BoxClass.<Integer>addBox(Integer.valueOf(10), listOfIntegerBoxes);
이를 명시적 타입인수라 한다.
하지만 Java8 부터는 타입추론으로 의해 아래와 같이 쓸 수 있게 되었다.
BoxClass.addBox(Integer.valueOf(10), listOfIntegerBoxes);
컴파일러는 메서드를 호출하는 함수를 보고 제네릭 메서드의 타입 매개변수 T 를 추론할 수 있는 것이다.
2-2. 객체의 타입추론
- 우리는 컬렉션을 생성할 때 아래와같이 빈 다이아몬드 연산자를 쓴다.
List<String> strList = new ArrayList<>();
원래는 아래와 같이 써야 하는데 말이다.
List<String> strList = new ArrayList<String>();
컴파일러는 코드 문맥으로 생성할 타입 인자를 추론할 수 있다.
3. 소거 (Erasure)
- 제네릭은 타입의 정보가 런타임에는 소거 된다.
- 원소의 타입을 컴파일타임에만 검사하고 보증함으로써, 런타임에는 타입 정보를 알 수 조차 없게 한다.
- 이를 실체화가 되지 않는다 라고 한다.
- 이는 제네릭이 생기기 이전의 레거시코드가 호환될 수 있도록 한 조치이다.
- 반면에 배열은 타입 정보를 런타임에도 가지고 있으며, 이를 실체화 된다고 한다.
제네릭이 런타임에 타입 정보가 소거되는지 알아보자.
제네릭을 마구 쓴 클래스를 작성해 보았다.
public class Erasure<T> {
private List<T> list;
public void setList() {
list = new ArrayList<>();
}
public <R> List<R> get(List<R> item) {
if (item.equals(list)) return item;
else return null;
}
public static void main(String[] args) {
Erasure<String> str = new Erasure<>();
str.setList();
str.get(new ArrayList<Integer>());
}
}
아래는 위 코드의 바이트코드이다.
바이트코드는 런타임에 읽는 코드이므로 런타임에 타입 정보가 소거된다면 여기에 타입정보가 없어야 할 것이다.
살펴보자.
public class com/whiteship/white_ship_study/week14/Erasure {
private Ljava/util/List; list
public <init>()V
L0
LINENUMBER 7 L0
ALOAD 0
INVOKESPECIAL java/lang/Object.<init> ()V
RETURN
L1
LOCALVARIABLE this Lcom/whiteship/white_ship_study/week14/Erasure; L0 L1 0
MAXSTACK = 1
MAXLOCALS = 1
public setList()V
L0
LINENUMBER 12 L0
ALOAD 0
NEW java/util/ArrayList
DUP
INVOKESPECIAL java/util/ArrayList.<init> ()V
PUTFIELD com/whiteship/white_ship_study/week14/Erasure.list : Ljava/util/List;
L1
LINENUMBER 13 L1
RETURN
L2
LOCALVARIABLE this Lcom/whiteship/white_ship_study/week14/Erasure; L0 L2 0
MAXSTACK = 3
MAXLOCALS = 1
public get(Ljava/util/List;)Ljava/util/List;
// parameter item
L0
LINENUMBER 16 L0
ALOAD 1
ALOAD 0
GETFIELD com/whiteship/white_ship_study/week14/Erasure.list : Ljava/util/List;
INVOKEINTERFACE java/util/List.equals (Ljava/lang/Object;)Z (itf)
IFEQ L1
ALOAD 1
ARETURN
L1
LINENUMBER 17 L1
FRAME SAME
ACONST_NULL
ARETURN
L2
LOCALVARIABLE this Lcom/whiteship/white_ship_study/week14/Erasure; L0 L2 0
LOCALVARIABLE item Ljava/util/List; L0 L2 1
MAXSTACK = 2
MAXLOCALS = 2
public static main([Ljava/lang/String;)V
L0
LINENUMBER 21 L0
NEW com/whiteship/white_ship_study/week14/Erasure
DUP
INVOKESPECIAL com/whiteship/white_ship_study/week14/Erasure.<init> ()V
ASTORE 1
L1
LINENUMBER 22 L1
ALOAD 1
INVOKEVIRTUAL com/whiteship/white_ship_study/week14/Erasure.setList ()V
L2
LINENUMBER 23 L2
ALOAD 1
NEW java/util/ArrayList
DUP
INVOKESPECIAL java/util/ArrayList.<init> ()V
INVOKEVIRTUAL com/whiteship/white_ship_study/week14/Erasure.get (Ljava/util/List;)Ljava/util/List;
POP
L3
LINENUMBER 24 L3
RETURN
L4
LOCALVARIABLE args [Ljava/lang/String; L0 L4 0
LOCALVARIABLE str Lcom/whiteship/white_ship_study/week14/Erasure; L1 L4 1
MAXSTACK = 3
MAXLOCALS = 2
}
마지막에 main 메서드의 인자로 들어가는 String 타입 말고 찾은 타입이 있는가?
Ljava/util/List
모두 이런 식으로 타입 정보는 소거된 것을 볼 수 있다.
equals 에서는
Ljava/lang/Object
처럼 모든 타입을 포괄하는 Object 로 바꾼 것을 볼 수 있다.
이처럼 런타임에는 제네릭 타입정보는 모두 소거 된다.
타입 소거에 대한 예시는 아래와 같다.
github.com/cmg1411/effectiveJava/blob/master/src/main/java/Chapter5/Day29/item29.md
(제가 쓴 글입니다)
제네릭의 사용
제네릭은 크게 클래스와 메서드를 만들 때 사용할 수 있다.
차례대로 알아보도록 하자.
1. 제네릭 타입
- 제네릭 타입은 클래스와 인터페이스에서 타입을 매개변수(파라미터)로 가지는 것을 말한다.
public class 클래스명<T> {...}
public interface 인터페이스명<T> {...}
클래스명 다음에 <T> 를 (정규) 타입 매개변수라 한다.
의미적으로는 해당 클래스나 인터페이스 내에서는 T 타입을 사용하겠다는 것이다.
아래 코드를 보자.
public class Box<T> {
private T in;
public void push(T element) {
in = element;
}
public T pop() {
return in;
}
}
- Box 클래스의 타입 매개변수로 <E> 를 사용한 것을 볼 수 있다.
- push() 에서는 E 타입을 매개변수로 받고 있다.
- pop() 에서는 E 타입을 리턴타입으로 한다.
E 는 어떤 타입도 올 수 있으며, Stack 객체를 만들 떄 결정된다.
만약 Box<String> 으로 객체를 생성한다면, 위 클래스에서 E 는 모두 String 이 되어 동작하게 된다.
public static void main(String[] args) {
Box<String> s = new Box<>();
s.push("hi");
System.out.println(s.pop());
}
- s 객체는 Box<String> 으로 지정했기 떄문에, 모든 E 가 String 인 것 처럼 동작한다. (아래 코드처럼)
public class Box<String> {
private String in;
public void push(String element) {
in = element;
}
public String pop() {
return in;
}
}
- 따라서, push() 함수에서 정수 1 을 넣으려는 시도는 컴파일 오류를 뱉는다.
정규 타입 매개변수 알파벳은 아무 알파벳을 사용해도 오류가 나지 않는다.
하지만 관용적으로 사용하는 알파벳들이 있다.
T : 아래의 경우가 아닌 대부분 T를 사용한다.
E : Element 의 E 이다. 리스트 같은 컬렉션의 요소에 사용한다.
K : key 의 K 이다. Map 의 key 에 사용한다.
V : value 의 V 이다. Map 의 value 에 사용한다.
멀티 타입 파라미터
- 타입 파라미터가 두개 이상 필요한 경우가 있다.
- 그럴 땐, 타입 파라미터를 <> 안에 쉼표로 필요한 만큼 선언하면 된다.
public class Student<K, V> {
private K studentNumber;
private V name;
public MultiTypeParameter(K studentNumber, V name) {
this.studentNumber = studentNumber;
this.name = name;
}
private K getStudentNumber() {
return studentNumber;
}
public V getName() {
return name;
}
}
학번을 K 로 받고, 이름을 V 로 받았다.
이 또한 객체를 생성할때 결정된다.
public static void main(String[] args) {
Student<Integer, String> student = new Student<>(12, "mingeor");
System.out.println(student.getName());
}
2. 제네릭 메서드
- 제네릭 메서드는 매개 타입과 리턴 타입으로 타입 파라미터를 갖는 메서드이다.
- 리턴 타입 앞에 <> 를 추가하여 타입 파라미터를 적는다.
- 매개변수의 타입과 리턴타입, 메서드 안에서 타입 파라미터를 사용한다.
<> 는 비공식적으로 다이아몬드 연산자라고 하기도 한다.
다이아몬드 연산자 안에는 primitive 타입을 쓸 수 없다. int 는 Integer 로 처럼 Wrapper 클래스를 사용해야 한다.
제네릭 메서드의 형식은 아래와 같다.
public <타입파라미터, ...> 리턴타입 메서드명(매개변수 ...) {}
제네릭 메서드를 어떻게 사용하는지 알아보자.
아래의 compare() 메서드는 Key 값과 Value 값이 모두 같은지를 검사하는 메서드이다.
public class GenericMethod {
//제네릭 클래스 (inner)
static class Pair<K, V> {
private final K key;
private final V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() {
return key;
}
public V getValue() {
return value;
}
}
// 제네릭 메서드
public static <K, V> boolean compare(Pair<K, V> m1, Pair<K, V> m2) {
boolean keyCompare = m1.getKey().equals(m2.getKey());
boolean valueCompare = m1.getValue().equals(m2.getValue());
return keyCompare && valueCompare;
}
public static void main(String[] args) {
Pair<String, Integer> p1 = new Pair<>("한국", 1);
Pair<String, Integer> p2 = new Pair<>("한국", 2);
System.out.println(compare(p1, p2));
}
}
- compare() 는 K, V 라는 두 타입 매개변수를 이용하므로 반환타입 앞에 선언되었다.
- 따라서 매개변수에서 K, V 를 사용할 수 있다.
- main 메서드에서 p1, p2 를 사용하고 compare(p1, p2) 를 호출하면 compare() 는 다음과 같은 코드처럼 동작하게 된다.
public static <String, Integer> boolean compare(Pair<String, Integer> m1, Pair<String, Integer> m2) {
boolean keyCompare = m1.getKey().equals(m2.getKey());
boolean valueCompare = m1.getValue().equals(m2.getValue());
return keyCompare && valueCompare;
}
제네릭 주요 개념
제네릭에는 위의 타입 파라미터를 응용하여 여러가지 개념이 생긴다.
또한 와일드카드라는 특이한 놈도 있는데, 알아보도록 하자.
1. 제한된 타입 파라미터 (바운디드 제네릭)
- 타입 파라미터는 아무거나 들어올 수 있다.
- 하지만 '~중에 아무거나' 를 구현하려면 어떻게 해야할까.
- 이때 사용할 수 있는 것이 제한된 타입 파라미터이다.
- 제한된 타입 파라미터에는 extends 키워드를 사용할 수 있다.
extends
// 제네릭 메서드
public static <T extends Serializable> write(T object) {
File file = new File("test.txt");
try (ObjectOutputStream outputStream = new ObjectOutputStream(new FileOutputStream(file))) {
outputStream.writeObject(object);
} catch (IOException e) {
e.printStackTrace();
}
}
- ObjectOutputStream 의 메서드 writeObject() 는 직렬화할 수 있는 객체만 매개변수로 넣을 수 있다.
- 객체가 Serializable 을 구현하고 있지 않으면, NotSerializableException 을 뱉는다.
- 물론, try-catch 예외처리를 통해 해결할 수 있지만, 위와같이 쓰면 애초에 write 함수에서 Serializable 의 하위 타입만 받아서 메서드를 실행시키도록 할 수 있는 것이다.
그러면 위처럼, Serializable 을 구현하지 않은 클래스로만든 객체는 컴파일조차 되지않는 오류를 뱉는다.
결국
<T extends Cla> 는 Cla 타입을 포함안 하위타입 아무거나 라는 뜻을 가진다.
2. 와일드카드
와일드카드는 제네릭이 불공변이기 때문에 사용한다는 관점도 있다.
매개변수로 객체를 받는데 아무 타입의 객체를 받고 싶다면 모든 객체의 부모인 Object 를 사용하면 된다.
하지만 매개변수로 리스트를 받는데 아무 타입의 리스트를 받고 싶을 때에는 List<Object> 는 모든 List<> 의 부모타입이 아니다.
제네릭은 불공변이기 때문에.
따라서 List<?> 라는 와일드카드가 나왔다.
- 와일드카드는 다이아몬드 연산자 <> 안에 ? 를 쓴 <?> 로 표현된다.
- 와일드카드는 의미적으로, 어떤 용도로도 쓰일 수 있는 비장의 카드라는 뜻이다.
- 여기서부터 제네릭이 헷갈리기 시작한다. 어떤 용도로 쓸 수 있는 것이 아무것이나 쓸 수 있는 것 아닌가 ? 그럼 타입 매개변수랑 다른게 뭔가 ?
- 맞다. 비슷하다. 하지만 면밀히 보면 다르다. 제일 확연한 차이는 사용되는 위치이다.
- 타입 매개변수는 제네릭 타입에서는 클래스 이름 옆에 <T>, 메서드의 반환타입 앞의 <T> 를 붙였었다. 이는 해당 클래스에서, 해당 메서드에서는 T 타입을 사용할 것이라는 뜻이었다. 해당 클래스와 메서드 전체에 적용되는 타입 매개변수를 선언해준 격이다.
- 하지만 와일드카드는 다르다. 아래 메서드를 보자.
public static void printList(List<?> list) {
list.stream().forEach(System.out::println);
}
매개변수의 List<?> list 는 아무 타입 매개변수 리스트를 인자로 받을 수 있음을 뜻한다.
위처럼 와일드카드는 매개변수의 타입을 지정할 때 주로 쓰인다.
List<?> list 의 타입은 인자를 넘기는 타이밍에 정해지지만, 그 타입이 list 라는 리스트의 타입 외에 메서드에서 영향을 미치는 곳은 존재하지 않는다.
다시 타입 매개변수로 넘어가서, 위의 메서드를 타입 매개변수로 적어보자.
public static <T> void printList(List<T> list) {
list.stream().forEach(System.out::println);
}
이렇게 쓰면 와일드카드를 사용했을 때와 기능은 같다.
하지만 이 타입 매개변수는 아래와 같은 기능도 할 수 있다.
public static <T> T printList(List<T> list) {
int length = list.size();
int count = 0;
for (T o : list) {
count++;
System.out.println(o);
if (count == length) {
return o;
}
}
return null;
}
<T> 타입은 반환값에도, 매개변수 타입에도, 메서드 코드 중에서도 사용할 수 있다.
이는 분명히 와일드카드는 못하는 것이고 다른점이라 할 수 있다.
또 중요한 차이로는, 와일드카드는 <> 안에 있어야 한다.
타입 매개변수는
public <T> void go(T param) {...}
처럼 단독으로 쓰일 수 있지만,
public void go(? param) {...}
이런건 없다.
2-0. 와일드카드 타입 : ?
- 위에서 설명한 <?> 이다.
- 와일드카드 타입은 <?>, 한정적 와일드카드 타입인 <? extends E>, <? super E> 의 세가지 이용법이 있다.
2-1. 한정적 와일드카드 타입 : extends
- 한정적 타입 매개변수와 비슷하다.
- ~중 아무거나 를 표현하고 싶을 때 쓸 수 있다.
public class Chooser<T> {
private final List<T> choiceList;
public Chooser(Collection<T> choices) {
this.choiceList = new ArrayList<>(choices);
}
public T choose() {
Random rnd = ThreadLocalRandom.current();
return choiceList.get(rnd.nextInt(choiceList.size()));
}
}
이 클래스의 필드 choiceList 에는 Chooser 클래스를 만든 타입의 컬렉션을 넣을 수 있다.
List<Number> numberList = new ArrayList<>();
List<Integer> integerList = new ArrayList<>();
Chooser<Number> chooser1 = new Chooser<>(numberList); // 가능
Chooser<Number> chooser2 = new Chooser<>(integerList); // 불가능
이번에도 제네릭의 불공변의 특성 때문에, Integer 는 Number 의 하위 타입이지만
Collection<Integer> 은 Collection<Number> 의 하위 타입이 아니기 때문에 Integer 타입 리스트는 생성자 인수로 넣을 수 없다.
이때 한정적 와일드카드 타입을 사용할 수 있다.
public Chooser(Collection<? extends T> choices) {
this.choiceList = new ArrayList<>(choices);
}
<? extends T> 는 T 타입을 포함한 T 의 하위타입 아무거나 와 같은 뜻이다.
이렇게 바꾸면 불가능하던 Integer 타입 리스트도 생성자의 인수로 넣을 수 있다.
2-2. 한정적 와일드카드 타입 : super
public class Box<E> {
private final List<E> sockets;
public Box() {
this.sockets = new ArrayList<>();
}
public void putItems(E object) {
sockets.add(object);
}
public List<? super E> getNewItemList(List<? super E> list) {
list.addAll(sockets);
return list;
}
public static void main(String[] args) {
Box<String> strBox = new Box<>();
strBox.putItems("hi");
strBox.putItems("bye");
List<Object> objectList = new ArrayList<>();
System.out.println(strBox.getNewItemList(objectList));
}
}
이번 클래스를 보자.
Box 는 제네릭 타입이다. E 라는 타입 매개변수를 가지는 리스트 socket 를 필드로 가지고 있다.
socket 에 요소를 집어넣는 putItem 이라는 메서드를 가지고 있다.
그리고 getNewItemList() 는 새로운 리스트를 줘서 socket 의 내용을 거기다가 복사해 담아서 반환하는 메서드이다.
여기서 생각해보자.
옮겨담을 리스트는 E의 상위타입이면 옮겨 담을 수 있을 것이다.
코드의 예와 같이, List<String> 의 내용물인 String 타입은, List<Object> 의 내용물인 Object 타입에 옮겨담을 수 있다.
따라서 옮겨담을 리스트의 타입 매개변수는 sockect 의 타입 매개변수의 상위타입이면 옮겨담을 수 있는 것이다.
<? super E> 는 이 의미를 완전히 내포한다. E 의 상위타입 아무거나 라는 뜻이다.
PECS 공식이라는 것이 있다.
Producer-extends, Consumer-super 의 약자로, 생산자는 extends, 소비자는 super 을 쓰라는 것이다.
위의 예에서 Chooser 클래스의 생성자에서 Collection<? extends E> 매개변수는 Chooser 클래스가 무언가를 생산하게 만드는 생산자이다. 따라서 extends 를 썼다.
List<? super E> list 는 Box 클래스가 Box 클래스의 자원을 소비하게 만드는 소비자이다. 따라서 super 를 썼다.
이펙티브 자바 3E
이것이 자바다 - 신용권
'WhiteShip Java Study : 자바 처음부터 멀리까지' 카테고리의 다른 글
람다식(feat. 익명 구현 클래스 vs 람다식) (0) | 2021.03.07 |
---|---|
NIO (0) | 2021.02.19 |
I/O (0) | 2021.02.17 |
ServiceLoader (0) | 2021.02.14 |
Annotation (0) | 2021.02.13 |