본문 바로가기
Java

[자바 라이브 스터디] 14. 제네릭

by 매트(Mat) 2021. 10. 23.

14주차 과제: 제네릭

목표

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

학습할 것

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




제네릭 사용법

개념 참고 : http://tcpschool.com/java/java_generic_concept

 

알게 모르게 제네릭을 그 동안 계속 사용해 왔습니다. 이 제네릭은 특히 자료구조와 같은 구조체를 직접 만들어 사용할 때 많이 쓰입니다.

 

제네릭(Generic), 해석하면 '일반적인' 이라는 뜻입니다.
자바에서 제네릭이란 데이터의 타입을 일반화한다는 의미입니다.
제네릭은 클래스나 메소드에서 사용할 내부 데이터 타입을 컴파일 시에 미리 지정하는 방법입니다. 이렇게 컴파일 시에 미리 타입을 체크하면 다음과 같은 장점이 있습니다.

  • 객체의 타입 안정성을 높일 수 있다.
  • 리턴값에 대한 타입 변환 및 타입 체크에 들어가는 노력을 줄일 수 있다.

 

즉, 제네릭을 사용하면 타입을 명시해서 Type Safety와 좀 더 명확해집니다.

List<> list = new ArrayList<>();

만약 위 코드와 같이 타입을 명시를 안해주면 저 List 안에는 String이 들어갈 수도 있고, Integer가 들어갈 수도 있고, Character가 들어갈 수도 있습니다. 그러면 명확하지도 않고 로직을 짤 때 실수할 수가 있습니다.

클래스 선언

예제 참고 : https://st-lab.tistory.com/154

public class Example<T> {
}

클래스의 경우 위와 같이 선언합니다.
여기서 T타입은 아직 타입이 정해져 있지 않으므로 우리가 직접 명시해주면 됩니다.

public class Main {
    public static void main(String[] args) {
        Example<String> example = new Example<>();
    }
}

 

또한 타입의 파라미터를 2개로 받을 수도 있습니다. 대표적으로 Map이 있겠죠.

public class Example<K, V> {
}
public class Main {
    public static void main(String[] args) {
        Example<String, Integer> example = new Example<>();
    }
}

이러면 타입 두 개를 명시할 수 있습니다.
K타입은 Key, V타입은 Value를 뜻합니다.

 

여기서 보시면 중요한 점이 있습니다.
제네릭 타입들은 모두 레퍼런스 타입이어야 합니다. 따라서 프리미티브 타입은 래퍼 클래스(Wrapper Class)로 사용하면 됩니다.

 

예제

package azurealstn;

public class Example<E> {

    private E element;

    void set(E element) {
        this.element = element;
    }

    E getElement() {
        return this.element;
    }
}

E타입은 Element를 뜻합니다.

public class Main {
    public static void main(String[] args) {
        Example<String> example1 = new Example<>(); //new 다음 제네릭은 생략 가능
        Example<Integer> example2 = new Example<>();

        example1.set("10");
        example2.set(10);

        System.out.println("example1 = " + example1.getElement());
        System.out.println("example1 Type = " + example1.getElement().getClass().getName());
        System.out.println();
        System.out.println("example2 = " + example2.getElement());
        System.out.println("example2 Type = " + example2.getElement().getClass().getName());
    }
}

new 다음에 나오는 제네릭은 생략이 가능합니다.
각각의 값과 타입을 출력해주는 코드였습니다.




제네릭 주요 개념 (바운디드 타입, 와일드 카드)

제네릭 타입 파라미터들은 바운드(Bound)될 수 있습니다. 바운드가 된다는 의미는 '제한된다' 라는 의미입니다. 즉, 메소드가 받을 수 있는 타입을 제한할 수도 있습니다.
이미 한 종류의 타입만 저장할 수 있도록 제한할 수 있지만 T타입에는 여전히 모든 종류의 타입을 지정할 수 있습니다.

 

그래서 모든 종류의 타입을 지정할 수 있는 것을 '제한' 하겠다는 의미가 바로 바운디드(제한된) 제네릭입니다.

public class Example<T extends SubExample> {

}
public class Main {
    public static void main(String[] args) {
        Example<SubExample> example1 = new Example<>();
        Example<SubSubExample> example2 = new Example<>();
    }
}

이러면 여전히 한 종류의 타입만 담을 수 있지만 그 타입에 SubExample 클래스와 같거나 SubExample 클래스의 서브 클래스인 SubSubExample 클래스 타입만 담을 수 있게 제한을 거는 것입니다.

 

와일드카드(Wild Card)란 간단하게 말해서 이름에 제한을 두지 않음을 표현하는 데 사용되는 '기호'를 말합니다.
자바에서는 물음표(?) 기호를 사용하여 와일드카드를 사용할 수 있습니다.

<?> //타입 변수에 모든 타입을 사용 가능
<? extends T> //T타입과 T타입을 상속받는 서브 클래스 타입만 사용 가능
<? super T> //T타입과 T타입이 상속받은 슈퍼 클래스 타입만 사용 가능
package azurealstn;

import java.util.ArrayList;

public class Example {
    public void myMethod(ArrayList list) {

    }
}

위 코드는 myMethod() 메소드의 파라미터로 ArrayList타입의 list를 선언하였습니다.
현재로서는 ArrayList타입에는 제네릭이 없기 때문에 모든 값을 받아들일 수 있습니다.
따라서 이런 경우에 와일드카드를 사용하여 타입을 지정할 수 있습니다.

import java.util.ArrayList;

public class Example {
    public void myMethod(ArrayList<? extends Number> list) {

    }
}

즉, Number 클래스와 Number 클래스를 상속받은 클래스들의 객체들만 받겠다고 명시한 것입니다.




제네릭 메소드 만들기

제네릭 메소드는 메소드의 선언를 선언할 때 제네릭으로 리턴 타입과 파라미터 타입을 정의하는 메소드입니다.

public class Example<T> {

    private int age;

    public static <T> T getAge(T age) {
        return age; 
    }
}

<T> : 제네릭 타입
T get Age() : 리턴타입
T age : 파라미터 타입

public static T getName(T name) {
    return name;
}

위 코드를 보면 컴파일 에러가 발생합니다.
그 이유는 모든 static 변수는 제네릭을 사용할 수 없기 때문입니다. static한 변수는 클래스가 로드되기 전에 메모리에 올라가기 때문에 아직 T 타입은 타입이 결정된 것이 아니기 때문에 컴파일 에러가 발생한 것입니다.

 

하지만 첫 번째 코드는 컴파일 에러가 발생하지 않습니다. 왜 그런걸까?
바로 제네릭 메소드이기 대문에 가능한 것입니다. 제네릭 메소드는 호출 시에 매개 타입을 지정하기 때문에 static이 가능합니다.

public static <T> T getAge(T age) {
    return age;
}

위 코드와 같이 리턴 타입 앞에 <T> 제네릭을 써주면 됩니다.
제네릭 메소드를 사용하면 <T>가 지역변수로 바뀝니다.

 

public class Example<T> {

    private int age;

    public static <T> T getAge(T age) {
        return age;
    }
}

위 코드를 보시면 Example<T>에 있는 T와 static <T>에 있는 T는 서로 다릅니다.

public class Example<T> {

    public static void printMethod1(ArrayList<? extends World> list1, ArrayList<? extends World> list2) {

    }

    public static <T extends World> void printMethod2(ArrayList<T> list1, ArrayList<T> list2) {

    }
}

만약 제네릭 메소드를 사용하지 않는다면 printMethod1() 메소드처럼 와일드카드를 써서 타입을 제한해야 합니다. 이러면 파라미터마다 와일드카드를 써야겠죠.
하지만 제네릭 메소드를 사용하면 printMethod2() 메소드처럼 파라미터마다 반복되는 코드를 줄일 수 있습니다.

 

여기서 중요한 포인트는 클래에 있는 제네릭 타입과 메소드에 있는 제네릭 타입은 서로 별개라는 것을 기억해두면 됩니다.




Erasure

제네릭은 JDK 1.5버전부터 도입되었습니다.
따라서 하위 버전과의 호환성 유지를 위한 작업이 필요했는데 이러한 코드의 호환성 떄문에 소거(Erasure) 방식을 사용하게 됩니다.

br>

소거(Erasure)란 타입을 컴파일타임에만 검사하고 런타임에는 해당 타입을 알 수 없는 것을 말합니다. 즉, 컴파일타임에만 타입 제약 조건을 걸고 런타임에는 타입을 제거한다는 의미입니다.

public class Example<T> {

    public void ExampleMethod(T exampleElement) {
        System.out.println(exampleElement.toString());
    }
}

위 코드는 컴파일타임의 타입 소거 전 상태입니다.
그리고 런타임 때인 타입 소거 후에는 아래 코드처럼 됩니다.

public class Example {

    public void ExampleMethod(Object exampleElement) {
        System.out.println(exampleElement.toString());
    }
}

unbounded type에 대해서는 위 코드처럼 Object로 바뀌게 됩니다.
그렇다면 bounded type에 대해서는 어떻게 바뀔까요?

public class Example<T extends Comparable<T>> {

    public void ExampleMethod(T exampleElement) {
        System.out.println(exampleElement.toString());
    }
}

<T extends Comparable<T>>와 같이 제한된 제네릭을 걸어두면 소거된 후에는 아래코드처럼 변경됩니다.

public class Example {

    public void ExampleMethod(Comparable exampleElement) {
        System.out.println(exampleElement.toString());
    }
}

이 때는 Object가 아닌 제한시킨 타입으로 변환되어서 Comparable 타입이 됩니다.




References

댓글