본문 바로가기
스터디/[white-ship] 자바 스터디(study hale)

14주차 과제: 제네릭

by doyoungKim 2021. 2. 27.

목표

자바의 제네릭에 대해 학습하세요.

학습할 것 (필수)

  • 제네릭 사용법
  • 제네릭 주요 개념 (바운디드 타입, 와일드 카드)
  • 제네릭 메소드 만들기
  • Erasure

제네릭 등장 이전의 자바 코드가 갖는 불편함과 문제점

제네릭이 갖는 의미는 '일반화'이다. 그리고 자바에서 그 일반화의 대상은 자료형이다. 그럼 제네릭이 존재하지 않던 시절의 코드와 제네릭이 존재하는 시절의 코드 비교에서부터 이야기를 시작하겠다.

제네릭 이전의 코드

사과와 오렌지를 담는 상자를 담는 상자를 각각 생성하여 그상자에 사과와 오렌지를 담았다가 꺼내는 과정을 보이는 다음 예제를 관찰하자.

public class FruitAndBox {
    public static void main(String[] args) {
        AppleBox aBox = new AppleBox(); // 시과 상자 생성
        OrangeBox oBox = new OrangeBox(); // 오렌지 박스 생성

        aBox.setAp(new Apple()); // 사과를 사과 상자에 담는다.
        oBox.setOr(new Orange()); // 오렌지를 오렌지 상자에 담는다.

        Apple ap = aBox.getAp(); // 상자에서 사과를 꺼낸다.
        Orange or = oBox.getOr(); // 상자에서 오렌지를 꺼낸다.

        System.out.println(ap); // I am a apple.
        System.out.println(or); // I am a orange.
        
    }
}

class Apple { // 사과를 단순히 표현한 클래스
    @Override
    public String toString() {
        return "I am a apple.";
    }
}


class Orange { // 오렌지를 단순히 표현한 클래스
    @Override
    public String toString() {
        return "I am a orange.";
    }
}

class AppleBox { // 사과 담는 상자를 표현한 클래스
    private Apple ap;

    public Apple getAp() { // 사과를 꺼낸다.
        return ap;
    }

    public void setAp(Apple ap) { // 사과를 담는다.
        this.ap = ap;
    }
}


class OrangeBox { // 오렌지 담는 상자를 표현한 클래스
    private Orange or;

    public Orange getOr() { // 오렌지를 꺼낸다.
        return or;
    }

    public void setOr(Orange or) { // 오렌지를 담는다.
        this.or = or;
    }
}

 

 

위 예제에서 AppleBox 와 OrangeBox 가 하는 일은 성격도 같고 내용도 같다. 따라서 이 둘은 다음 클래스 하나로 대체할 수 있다.

public class Box {
    private Object ob; // Object 를 상속하는 인스턴스면 무엇이든 담는다.

    public void setOb(Object ob) {
        this.ob = ob;
    }

    public Object getOb() {
        return ob;
    }
}

 

이제 Box는 사과와 오렌지뿐 아니라 무엇이든 담을 수 있는 상자가 되었다. 그럼 이 클래스를 예제에서 적용해 보겠다.

public class FruitAndBox2 {

    public static void main(String[] args) {
        Box aBox = new Box(); // 시과 상자 생성
        Box oBox = new Box(); // 오렌지 박스 생성

        aBox.setOb(new Apple()); // 사과를 사과 상자에 담는다.
        oBox.setOb(new Orange()); // 오렌지를 오렌지 상자에 담는다.

        Apple ap = (Apple) aBox.getOb(); // 상자에서 사과를 꺼낸다.
        Orange or = (Orange) oBox.getOb(); // 상자에서 오렌지를 꺼낸다.

        System.out.println(ap); // I am a apple.
        System.out.println(or); // I am a orange.
    }
}

 

위 예제에서 주목할 사실은 다음과 같다.

"Box 인스턴스에서 내용물을 꺼낼 때 형 변환을 해야 한다."

Box 내에서 인스턴스를 저장하는 참조변수가 Object 형이기 때문에, 저장된 인스턴스를 꺼낼 때에는 인스턴스에 맞는 형 변환을 해야만 한다. 그리고 이러한 번거로운 과정으로 인해 다음과 같은 실수가 발생할 수도 있다.

public class FruitAndBox3 {

    public static void main(String[] args) {
        Box aBox = new Box(); // 시과 상자 생성
        Box oBox = new Box(); // 오렌지 박스 생성

        // 사과와 오렌지가 아닌 문자열을 담았다.
        aBox.setOb("apple");
        oBox.setOb("orange");

        // 상자에 과일이 담기지 않았는데, 과일을 꺼내려 한다.
        Apple ap = (Apple) aBox.getOb();
        Orange or = (Orange) oBox.getOb();

        System.out.println(ap);
        System.out.println(or);
    }
}

 

Apple 인스턴스와 Orange 인스턴스를 담으려 한 프로그래머의 의도와 달리 실수로 만들어진 코드이다.

이는 다음과 같은 형 변환 컴파일 오류를 발생시킨다.

Exception in thread "main" java.lang.ClassCastException: 
class java.lang.String cannot be cast to class com.company.generics.before.Apple
(java.lang.String is in module java.base of loader 'bootstrap';
 com.company.generics.before.Apple is in unnamed module of loader 'app')
at com.company.generics.before.FruitAndBox3.main(FruitAndBox3.java:14)

 

모든 실수는 컴파일 단계에서 드러나는 것이 좋다. 컴파일 오류는 원인을 바로 찾을 수 있기 때문이다. 그러나 실행 중에 발생하는 예외는 다르다. 예외의 원인은 쉽게 발견되지 않는 경우도 많다. 뿐만 아니라 위와 같은 실수는 드러나지 않을 수 도 있다. 

public class FruitAndBox4 {

    public static void main(String[] args) {
        Box aBox = new Box(); // 시과 상자 생성
        Box oBox = new Box(); // 오렌지 박스 생성

        // 사과와 오렌지가 아닌 문자열을 담았다.
        // 프로그래머의 실수
        aBox.setOb("apple");
        oBox.setOb("orange");
        
        System.out.println(aBox.getOb()); // apple 출력 
        System.out.println(oBox.getOb()); // orange 출력
    }
}

 

위 예제는 흔히 하는 말로 '대형 사고'로 이어질 수 있다. 사고가 발생했는지 조차 모르고 넘어갈 수 있기 때문이다.

지금까지 제네릭 등장 이전의 자바 코드가 갖는 불편함과 문제점을 설명하였다. 불편함이라 하면 상자에서 물건을 꺼낼 때 형 변환을 해야 한다는 것이고, 문제점이라 하면 프로그래머가 실수를 해도 그 실수가 드러나지 않을 수 있다는 것이다.

 

제네릭 기반의 클래스 정의하기

제네릭이 등장하면서 자료형에 의존적이지 않은 클래스를 정의할 수 있게 되었다. 그리고 위에서 언급한 불편함과 문제점이 해결되었다.
그럼 이에 대한 이해를 위해 먼저 다음 클래스를 제네릭 기반으로 정의하고, 앞서 소개한 예제들을 제네릭 이후의 코드로 수정해보겠다.

public class Box {
    private Object ob; // Object 를 상속하는 인스턴스면 무엇이든 담는다.

    public void setOb(Object ob) {
        this.ob = ob;
    }

    public Object getOb() {
        return ob;
    }
}

 

위의 클래스는 Object형 인스턴스를 저장하고 반환한다. 따라서 자료형에 의존적이지 않은 형태로 위의 클래스를 정의하기 위해 Object 를 T로 다음과 같이 대체하자.

public class Box {
    private T ob;

    public void setOb(T ob) {
        this.ob = ob;
    }

    public T getOb() {
        return ob;
    }
}

 

이제 T는 인스턴스를 생성할 때 결정하면 된다. 사과를 저장할 목적이면 T를 Apple로 결정하면 되고, 오렌지를 저장할 목적이면 T를 Orange로 결정하면 된다. 이렇듯 인스턴스 생성 시 T의 자료형을 결정하는 것이 '제네릭'이다.

그런데 위의 클래스를 컴파일 하면 오류가 발생한다. 컴파일러가 T를 클래스의 이름으로 판단하고 T라는 이름의 클래스가 없다는 오류 메시지를 전달한다. 따라서 "T는 인스턴스 생성 시 자료형을 결정하기 위한 표식"임을 알려야 한다. 방법은 다음과 같이 클래스 이름 뒤에 <T>를 붙이는 것이다.

public class Box<T> {
    private T ob;

    public void setOb(T ob) {
        this.ob = ob;
    }

    public T getOb() {
        return ob;
    }
}

 

이로써 제네릭 기반의 클래스 정의가 완성되었다. 따라서 T는 인스턴스 생성 순간에 결정할 수 있게 되었다. 그럼 위의 클래스를 대상으로 인스턴스를 생성하는 다음 문장들을 보자.

Box<Apple> aBox = new Box<>();

 

  • T를 Apple로 결정하여 인스턴스 생성
  • 따라서 Apple 또는 Apple을 상속하는 하위 클래스의 인스턴스 저장 가능

잠시 용어 정리를 하면,

public class Box<T> {
 ...
}

 

Box<T> 클래스에서 사용자 T를 가리켜 '타입 매개변수 (Type Parameter)' 라 한다. 메소드의 매개변수와 유사하게 자료형 정보를 인자로 전달받는 형태이기 때문이다. 또한 다음 문장에서 사용된  Apple을 가리쳐 '타입 인자(Type Argument)'라 한다. Apple을 타입 매개변수 T에 전달되는 인자로 바라보고 그렇게 이름을 지어준 것이다.

Box<Apple> aBox = new Box<>();

 

마지막으로 Box<Apple>을 가리쳐 '매개변수화 타입(Parameterized Type)'이라 한다. 자료형 Apple이 타입 매개변수 T에 전달되어 Box<Apple> 이라는 새로운 자료형이 완성된 것이기 때문에 '매개변수화 타입' 이라 부른다.

  • 타입 매개변수(Type Parameter): Box<T> 에서 T
  • 타입 인자 (Type Argument): Box<Apple> 에서 Apple
  • 매개변수화 타입 (Parameterized Type), 제네릭 타입(Generic Type): Box<Apple>

 

자주 사용 되는 타입 매개변수

  • E: Element
  • K: Key
  • N: Number
  • T: Type
  • V: Value
  • R: Return Type
  • S: String

 

제네릭 이후의 코드

제네릭 기반으로 클래스를 정의하였고 또 인스턴스의 생성 방법도 소개하였다. 따라서 이름 기반으로 예제를 작성했을 때 앞서 언급한 다음 불편함과 문제점이 사라짐을 확인할 차례이다.

  • 필요시 형 변환을 해야 한다.
  • 자료형과 관련된 프로그래머의 실수가 컴파일 과정에서 드러나지 않는다.

 

먼저 다음 예제를 통해서 형 변환이 불필요해진 부분에 대해 확인하겠다.

public class FruitAndBox2 {
    public static void main(String[] args) {
        Box<Apple> aBox = new Box<>(); // T를 Apple 로 결정
        Box<Orange> oBox = new Box<>(); // T를 Orange 로 결정

        aBox.set(new Apple()); // 사과를 상자에 담는다.
        oBox.set(new Orange()); // 오렌지를 상자에 담는다.

        Apple ap = aBox.get(); // 사과를 꺼내는데 형 변환 하지 않는다.
        Orange or = oBox.get(); // 오렌지를 꺼내는데 형 변환 하지 않는다.

        System.out.println(ap); // I am a apple.
        System.out.println(or); // I am a orange.
    }
}

 

 

기본 자료형에 대한 제한, 타입소거 그리고 래퍼 클래스

제네릭 클래스에 대하여 Box<Apple>과 같이 '매개변수화 타입'을 구성할 때 기본 자료형의 이름은 '타입 인자'로 쓸 수 없다. 즉 다음과 같은 문장 구성은 불가능하다.

Box<int> box = new Box<>(); // 컴파일 오류 발생 Type argument cannot be of primitive type

 

기본자료형인 primitive 타입도 타입인데 제네릭에서 타입 인자로 사용하지 못한다는게 이상하다.
결론부터 애기하면 타입 소거(type Erasure) 때문이다.

자바 컴파일러는 컴파일 과정에서 제네릭에 대해 타입 소거(Type Erasure)를 진행한다. 타입 소거란 타입 정보를 컴파일 타임에만 유지하고, 런타임에는 삭제시켜 버리는 것인데 제네릭이 없던 버전과의 하위 호환성을 위해서이다.

// 타입소거 전
public class Box<T> {
    private T ob;

    public void set(T ob) {
        this.ob = ob;
    }

    public T get() {
        return ob;
    }
}

// 타입소거 후
public class Box {
    private Object ob;

    public void set(Object ob) {
        this.ob = ob;
    }

    public Object get() {
        return ob;
    }
}

 

타입 인자를 사용할 경우 Object 타입으로 취급하여 처리한다.
Primitive 타입을 사용하지 못하는 것도 바로 이 기본 타입은 Object 클래스를 상속받고 있지 않기 때문이다.
그래서 기본 타입 자료형을 사용하기 위해서는 Wrapper 클래스를 사용해야 한다.
필요한 상황에서 박싱과 언박싱이 자동으로 이뤄지기 때문에 다음과 같은 수준의 코드를 작성할 수 있다.

public class PrimitivesAndGeneric {
    public static void main(String[] args) {
        Box<Integer> iBox = new Box<>();
        iBox.set(123); // 오토 박싱 진행
        int num = iBox.get(); // 오토 언박싱 진행
        System.out.println(num);
    }
}

 

 

제네릭 클래스의 타입 인자 제한하기

앞서 정의한 Box<T>에는 무엇이든 담을 수 있다. String 인스턴스를 담고 싶으면 다음과 같이 상자를 생성하면 되고,

Box<String> sBox = new Box<>();

 

Apple 인스턴스를 담고 싶으면 다음과 같이 상자를 생성하면 된다.

Box<Apple> aBox = new Box<>();

 

그러나 상자에도 특성과 용도가 있다. 따라서 담고 싶은 것을 제한할 수 있어야 한다. (얇고 작은 상자에 수박을 넣을 수 없듯이) 그리고 이때 사용하는 것이 extends 이다. 예를 들어서 Number 클래스를 상속하는 클래스의 인스턴스만 담고 싶다면 다음과 같이 클래스를 정의하면 된다.

public class Box<T extends Number> {
	...
}

 

제네릭 클래스의 타입인자를 Number 또는 이를 상속하는 하위 클래스로 제한을 하였다. 그리고 이렇게 제한을 하면 또 다른 특성이 생긴다. Box<T> 클래스에는 다음과 같은 코드를 넣을 수 없다. 

public class Box<T> {
    private T ob;
    
    public int toIntValue() {
        return ob.intValue(); // ERROR !
    }
}

 

참조변수 ob가 참조하게 될 것은 인스턴스이다. 하지만 어떠한 클래스의 인스턴스를 참조하게 될지 알 수 없기 때문에 ob를 통해서 호출할 수 있는 메소드는 Object 클래스의 메소드로 제한이 된다. 반면 다음과 같이 타입 인자를 제한하면 Number 클래스의 intValue 메소드를 호출 할 수 있다. ob가 참조하는 인스턴스는 intValue 메소드를 가지고 있음을 100퍼센트 보장할 수 있기 때문이다.

public class Box<T extends Number> {
    private T ob;

	...
    
    public int toIntValue() {
        return ob.intValue(); // OK !
    }
}

 

제네릭 메소드의 정의

클래스 전부가 아닌 일부 메소드에 대해서만 제네릭으로 정의하는 것도 가능하며, 이렇게 정의된 메소드를 가리켜 '제네릭 메소드'라 한다. 제네릭 메소드는 인스턴스 메소드 뿐만 아니라 다음과 같이 클래스 메소드에 대해서도 정의가 가능하다.

public static Box<T> makeBox(T o) {...}

 

위의 메소드 정의에 대해서 다음 내용을 파악 할 수 있어야 한다.

"메소드의 이름은 makeBox이고 반환형은 Box<T>이다."

그러나 위의 메소드 정의는 완전하지 않다. 이 상태에서 컴파일러는 T가 무엇이냐고 물어보며 컴파일 오류가 일으킨다. 따라서 T가 타입 매개변수의 선언임을 다음과 같이 표시해야 한다.

public static <T> Box<T> makeBox(T o){...}

*static 과 Box <T> 사이에 위치한 <T> 는 T가 타입 매개변수임을 알리는 표시

이후로도 위와 같은 메소드 정의를 보면 Box<T>가 반환형임을, 그리고 그 앞에 위치한 <T>는 T가 타입 매개변수임을 알리는 표식임을 알 수 있어야 한다.

public class BoxFactory<T> {
    public static <T>  Box<T> makeBox(T o){
        Box<T> box = new Box<>(); // 상자를 생성하고,
        box.set(o); // 전달된 인스턴스를 상자에 담아서,
        return box; // 상자를 반환한다.
    }
}

 

제네릭 클래스는 인스턴스 생성 시 자료형이 결정된다. 반면 제네릭 메소드는 '메소드 호출시에 자료형이 결정' 된다. 따라서 위 클래스에 정의되어 있는 makeBox 제네릭 메소드는 다음과 같이 호출해야 한다.

public class GenericMethodBoxMaker2 {
    public static void main(String[] args) {
        Box<String> sBox = BoxFactory.<String>makeBox("Sweet");
        Box<Double> dBox = BoxFactory.<Double>makeBox(0.123); // 오토 박싱 진행됨
    }
}

 

위의 두 문장에서 메소드의 이름 앞에 표시한 <String> 과 <Double> 이 T에 대한 타입 인자이다. 즉 첫 번째 문장에서는 T를 String 으로 결정하여 호출하였고, 두 번째 문장에서는 Double로 결정하여 호출하였다. 

 

제네릭 메소드의 제한된 타입 매개변수 선언

앞서 제네릭 클래스를 정의할 때 다음과 같이 타입 인자를 제한할 수 있었다.

public class Box<T extends Number> {
	...
}

 

마찬가지로 제네릭 메소드도 호출 시 전달되는 타입 인자를 제한할 수 있다. 그리고 제네릭 클래스의 타입 인자를 제한할 때 생기는 특성이 제네릭 메소드의 타입 인자를 제한할 때에도 생긴다. 

public class BoxFactory<T> {
    public static <T extends Number>  Box<T> makeBox(T o){
        Box<T> box = new Box<>(); 
        box.set(o);
        System.out.println("Boxed data: " + o.intValue());
        return box; 
    }
}

 

와일드카드 (Wildcard)

상자의 내용물을 반환하지 않고 그저 '무엇이 들었나' 정도만 확인하는 기능의 제네릭 메소드를 하나 추가하여 다음 예제를 작성하였다.

public class WildcardUnboxer {
    public static void main(String[] args) {
        Box<String> box = new Box<>();
        box.set("So Simple String");
        Unboxer.peekBox(box); // So Simple String 출력
    }
}


class Box<T> {
    private T ob;

    public void set(T ob) {
        this.ob = ob;
    }

    public T get() {
        return ob;
    }

    @Override
    public String toString() {
        return ob.toString();
    }
}

class Unboxer {
    public static <T> T openBox(Box<T> box) {
        return box.get();
    }

    // 상자 안의 내용물을 확인하는 (출력하는) 기능의 제네릭 메소드
    public static <T> void peekBox(Box<T> box) {
        System.out.println(box);
    }
}

 

public static <T> void peekBox(Box<T> box) {
	System.out.println(box);
}

이 메소드를 제네릭으로 정의한 이유가 Box<Integer>, Box<String> 의 인스턴스를 인자로 전달받도록 하기 위함이니 다음과 같이 정의해도 되지 않겠는가?

public static void peekBox(Box<Object> box) {
	System.out.println(box);
}

 

안된다! 

"Box<Object>와 Box<String>은 상속 관계를 형성하지 않는다."
"Box<Object>와 Box<Integer>은 상속 관계를 형성하지 않는다."

즉, Object 와 String 이 상속 관계에 있더라도 Box<Object> 와 Box<String>은 상속 관계를 형성하지 않은 별개의 자료형이다. 대신 '와일드카드' 라는 것을 사용한다면 원하는 바를 이룰 수 있다.


public class WildcardUnboxer2 {
    public static void main(String[] args) {
        Box<String> box = new Box<>();
        box.set("So Simple String");
        Unboxer.peekBox(box); // So Simple String 출력
    }
}

...

class Unboxer {
    public static <T> T openBox(Box<T> box) {
        return box.get();
    }

    public static void peekBox(Box<?> box) { // 와일드 카드 사용
        System.out.println(box);
    }
}

 

사실 기능적인 측면에서 보면 와일드 카드 전과 후의 peekBox 메소드는 동일하다. 즉 제네릭 메소드와 와일드카드 기반 메소드는 상호 대체 가능한 측면이 있다. 그러나 코드가 조금 더 간결하다는 이유로 와일드카드 기반 메소드의 정의를 선호한다.

 

 

출처

728x90

'스터디 > [white-ship] 자바 스터디(study hale)' 카테고리의 다른 글

[white-ship] 자바 스터디 후기  (0) 2021.04.13
15주차 과제: 람다식  (1) 2021.03.14
13주차 과제: I/O  (0) 2021.02.20
12주차 과제: 애노테이션  (0) 2021.02.06
11주차 과제: Enum  (0) 2021.01.30

댓글