제네릭스<Generics>

제네릭스(Generics)

제네릭스(Generics)는 자바에서 제공하는 기능 중 하나로, 클래스나 인터페이스에서 사용될 데이터 타입을 미리 지정하는 것입니다. 즉, 클래스나 인터페이스에서 사용하는 데이터 타입을 일반화하여 여러 종류의 타입에 대해 동일한 코드를 작성할 수 있도록 합니다.

 

제네릭스를 사용하지 않는 경우, 클래스나 메서드에서 사용하는 데이터 타입이 명확하지 않아서 다음과 같은 문제가 발생할 수 있습니다. 예를 들어, 여러 종류의 데이터를 담을 수 있는 리스트를 만들고자 한다고 가정해봅시다. 그렇다면 다음과 같이 리스트를 만들 수 있습니다.

ArrayList list = new ArrayList();
list.add("hello");
list.add(1234);

 

위 코드에서 ArrayList 클래스는 데이터 타입이 지정되어 있지 않습니다. 따라서 list 변수에는 문자열과 정수를 모두 추가할 수 있습니다. 하지만 이 경우, 컴파일러가 정적 타입 체크를 수행할 때 오류가 발생하지 않기 때문에, 런타임 시에 오류가 발생할 가능성이 높아집니다.

 

반면에, 제네릭스를 사용하면 이러한 문제를 해결할 수 있습니다. 아래 예시 코드에서는 ArrayList 클래스를 제네릭스로 선언하여 데이터 타입을 "String"으로 지정하였습니다.

ArrayList<String> list = new ArrayList<String>();
list.add("hello");
list.add(1234); // 컴파일 에러 발생

위 코드에서는 list 변수에 문자열만 추가할 수 있도록 제한하였기 때문에, 컴파일러가 타입 체크를 수행하면서 정적 오류를 발견할 수 있습니다. 이렇게 제네릭스를 사용하면 코드의 안정성을 높일 수 있습니다.


용어

 

타입 매개변수

아래 사진을 보게 되면 자료구조타입 오른쪽에 <> 로 지네릭스 타입을 지정하게 되는데 이를 다이아몬드 연산자라고 합니다. 그리고 식별자 기호 T는 타입 매개변수 or 매개변수화된 타입이라고 부릅니다. 그리고 List는 원시타입이라고 부릅니다.

https://inpa.tistory.com

 

대체로 지네릭스를 사용하게 되면 T 라는 용어를 많이 사용하게 되는데, 여기서 T 는 Type의 줄임말로 하나의 약어로 사용이 되고 있습니다. 그리고 다른 용어도 사용이 되는데 그러한 설명은 아래와 같습니다. 

  • <T> : type
  • <E> : Element
  • <K> : Key
  • <V> : Value
  • <N> : Number
  • <S, U, V> : 2번째, 3번째, 4번째에 선언된 타입

여기서 K와 V를 활용하여 설정 할 수 있는 자료구조는 Map 으로 예시로는 Map<K, V> 라고 할 수 있겠습니다. 

 

이러한 매개변수화된 타입을 사용하면 코드의 재사용성을 높이고, 컴파일러가 타입 체크를 수행할 때 오류를 더 쉽게 발견할 수 있도록 해줍니다. 또한, 제네릭스를 사용하면 형변환 코드를 줄일 수 있어 코드의 가독성도 향상됩니다.

 

예를 들어, 아래의 코드는 Integer 값을 담는 제네릭 클래스 Box를 선언한 예시입니다.

public class Box<T> {
    private T data;

    public Box(T data) {
        this.data = data;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }
}

Box 클래스에서는 T라는 타입 매개변수를 사용하여 데이터 타입을 정의하고 있습니다. 이렇게 선언된 Box 클래스는 아래와 같이 사용할 수 있습니다.

Box<Integer> box1 = new Box<>(10);
Box<String> box2 = new Box<>("hello");

위 코드에서 Box 클래스를 사용할 때 Integer와 String 타입 매개변수를 지정하여 Box 객체를 생성하고 있습니다. 이렇게 생성된 box1 변수는 Integer 타입의 데이터를 담을 수 있는 Box 객체가 되고, box2 변수는 String 타입의 데이터를 담을 수 있는 Box 객체가 됩니다.

 

이와 같이 매개변수화된 타입을 사용하면, 타입 안정성을 보장하면서 보다 유연하고 재사용성이 높은 코드를 작성할 수 있습니다.


타입 파라미터 생략

타입 파라미터 생략은 컴파일러가 코드를 컴파일할 때 타입 파라미터가 명확하게 추론될 수 있는 상황에서만 가능합니다.

 

대표적인 경우로는 변수 선언 시 변수 타입이 추론 가능한 경우가 있습니다. 예를 들어, 다음과 같은 코드에서는 List<String> 타입 파라미터를 생략할 수 있습니다.

List<String> myList = new ArrayList<>();

컴파일러가  List<String>  를 보고 new ArrayList<>()타입 파라미터를 추론할 수 있기 때문입니다.

 

또한, 메소드 호출 시 메소드의 반환 타입이나 매개변수 타입이 추론 가능한 경우에도 타입 파라미터를 생략할 수 있습니다. 예를 들어, 다음과 같은 코드에서는 Collections.emptyList() 메소드의 반환 타입인 List<String>의 타입 파라미터를 생략할 수 있습니다.

List<String> emptyList = Collections.emptyList();

이러한 코드가 가능한 것이 위에서 설명했듯이, 컴파일러가 Collections.emptyList() 메소드를 호출할 때 반환 타입이 List<String>임을 추론할 수 있기 때문입니다.

 

하지만, 타입 파라미터가 명확하게 추론되지 않는 상황에서는 타입 파라미터 생략이 불가능합니다. 이런 경우에는 명시적으로 타입 파라미터를 지정해주어야 합니다.


타입 파라미터를 할당 가능한 타입

타입 파라미터는 클래스, 인터페이스, 메소드 등에서 사용할 수 있으며, 할당 가능한 타입으로는 모든 참조 타입이 가능합니다. 다음은 타입 파라미터가 할당 가능한 타입의 예시입니다.

 

클래스

public class MyClass<T> {
    private T data;
    // ...
}

인터페이스

public interface MyInterface<T> {
    T getData();
    // ...
}

메소드

public <T> void myMethod(T data) {
    // ...
}

타입 파라미터는 클래스, 인터페이스, 메소드 등에서 사용할 수 있으며, 할당 가능한 타입으로는 모든 참조 타입이 가능합니다. 즉, String, Integer, Wrapper Class등과 같은 모든 참조 타입이 타입 파라미터로 할당될 수 있습니다. 


제네릭스 사용시 주의사항

 

제네릭스 타입의 객체는 생성이 불가

제네릭스 타입 자체로는 타입을 지정하여 객체를 생성하는 것이 불가능 합니다. 

class Sample<T> {
    public void someMethod() {
        // Type parameter 'T' cannot be instantiated directly
        T t = new T();
    }
}

 

static 멤버에 제네릭스 타입이 올 수 없다

기본적으로 제네릭스는 컴파일시에 타입이 지정이 되어지는데, 여기서 static은 인스턴스보다 우선적으로 메모리에 올라가기 때문에 다른 인스턴스를 참조를 할 수 없게 됩니다. 그러므로 static에는 제네릭스를 사용할 수 없습니다.   

class Student<T> {
    private String name;
    private int age = 0;

    // static 메서드의 반환 타입으로 사용 불가
    public static T addAge(int n) {

    }
}

 

제네릭스로 배열을 선언 가능

단순 제네릭스로는 배열을 선언하는게 불가능합니다. 하지만 다른 원시타입의 매개변수 타입으로 지정하면서 배열로 지정하는 것은 가능하므로 다음과 같은 형태를 가질 수 있게 됩니다.

class Sample<T> { 
}

public class Main {
    public static void main(String[] args) {
    	// new Sample<Integer>() 인스턴스만 저장하는 배열을 나타냄
        Sample<Integer>[] arr2 = new Sample[10]; 
        
        // 제네릭 타입을 생략해도 위에서 이미 정의했기 때문에 Integer 가 자동으로 추론됨
        arr2[0] = new Sample<Integer>(); 
        arr2[1] = new Sample<>();
        
        // ! Integer가 아닌 타입은 저장 불가능
        arr2[2] = new Sample<String>();
    }
}

제네릭스 메소드

제네릭 메소드(Generic Method)란, 매개변수 타입(parameterized type)을 일반화(제네릭)하는 메소드를 말합니다. 메소드 내부에서 사용되는 모든 매개변수 타입을 제네릭 타입으로 선언하여 재사용성과 타입 안정성(type safety)을 높일 수 있습니다.

 

제네릭 메소드를 선언할 때는 메소드 이름 앞에 <T>와 같은 형태의 타입 매개변수를 선언하며, 이는 타입 매개변수를 나타내는 것입니다. 그리고 메소드 내에서는 타입 매개변수 T를 일반적인 타입처럼 사용할 수 있습니다.

 

아래는 간단한 예시 코드입니다.

public class GenericMethodExample {
    
    public static <T> void printArray(T[] array) {
        for (T element : array) {
            System.out.println(element);
        }
    }

    public static void main(String[] args) {
        Integer[] intArray = {1, 2, 3};
        Double[] doubleArray = {1.1, 2.2, 3.3};
        String[] stringArray = {"hello", "world"};

        printArray(intArray);
        printArray(doubleArray);
        printArray(stringArray);
    }
}

위 예시에서 제네릭 메소드 printArray는 타입 매개변수 T를 가지며, 매개변수로 배열을 받아 해당 배열의 원소들을 출력하는 역할을 합니다. 이렇게 제네릭 메소드를 사용하면, 다양한 타입의 배열을 매개변수로 전달할 수 있으며, 컴파일러는 매개변수의 타입에 따라 타입 매개변수 T를 자동으로 추론합니다.

 

제네릭 메소드의 장점은 메소드의 재사용성과 타입 안정성을 높일 수 있다는 것입니다. 또한, 매개변수나 반환값 등에 대한 형변환 코드를 줄일 수 있어 코드의 가독성을 높일 수 있습니다. 하지만 제네릭 메소드를 사용할 때에도 타입 파라미터에 대한 제약이 존재하며, 상황에 따라 타입 추론이 원하는 대로 이루어지지 않을 수도 있습니다.


지네릭스 타입 제한

Java에서 제네릭 타입 파라미터는 특정 클래스 또는 인터페이스를 상속 또는 구현하는 타입으로 제한할 수 있습니다. 이를 제한하는 방법은 크게 두 가지가 있습니다.

 

extends 키워드를 사용한 제한

extends 키워드를 사용하여 특정 클래스나 인터페이스를 상속 또는 구현하는 타입으로 제한할 수 있습니다. 아래는 예시 코드입니다.

public class Example<T extends Number> {
    private T value;

    public Example(T value) {
        this.value = value;
    }

    public void print() {
        System.out.println("value = " + value);
    }
}

위 코드에서 T extends Number는 T가 Number 클래스 또는 그 하위 클래스만 가능하다는 것을 의미합니다. 따라서 Example<String>과 같이 Number 클래스를 상속하지 않는 타입을 사용하면 컴파일 에러가 발생합니다.

 

& 연산자를 사용한 제한

& 연산자를 사용하여 둘 이상의 인터페이스를 구현하는 타입으로 제한할 수 있습니다. 아래는 예시 코드입니다.

public class Example<T extends Serializable & Cloneable> {
    private T value;

    public Example(T value) {
        this.value = value;
    }

    public void print() {
        System.out.println("value = " + value);
    }
}

위 코드에서 T extends Serializable & Cloneable는 T가 Serializable 인터페이스와 Cloneable 인터페이스를 모두 구현하는 타입만 가능하다는 것을 의미합니다.


제네릭스 형변환

제네릭수 서브 타입간에는 형변환이 불가능합니다. 심지어 부모와 자식간에도 형변환이 불가능 합니다. 제네릭스 타입에서 서브 타입 간 형변환이 불가능한 이유는 제네릭스 타입의 타입 파라미터가 서로 다른 타입인 경우에 대한 호환성 문제 때문입니다.

 

다음은 제네릭스 타입 Box<T>와 그 서브타입인 BoxSub를 정의한 코드입니다.

class Box<T> {
    private T item;

    public void setItem(T item) {
        this.item = item;
    }

    public T getItem() {
        return item;
    }
}

class BoxSub extends Box<String> { }

BoxSub는 Box<String>의 서브타입으로, 타입 파라미터 T가 String으로 고정된 상태입니다.

 

이제 Box<String> 객체를 Box<Object> 변수에 대입하려고 시도해보겠습니다.

Box<String> boxString = new Box<String>();
boxString.setItem("hello");

Box<Object> boxObject = boxString; // 컴파일 에러 발생

위 코드는 컴파일 오류가 발생합니다. 이유는 Box<String>와 Box<Object>는 서로 별개의 타입이므로 서로 형변환이 불가능합니다.

 

만약 위와 같은 작업을 수행하고 싶다면, 와일드카드(?)를 이용해야 합니다. 다음과 같이 Box<?>로 선언된 변수에 Box<String> 객체를 대입하는 것은 가능합니다.

Box<String> boxString = new Box<String>();
boxString.setItem("hello");

Box<?> boxWildcard = boxString; // 가능

와일드 카드

제네릭스 와일드 카드는 제네릭 타입에서 사용되는 특별한 기호로, 어떤 타입이든 대응할 수 있도록 유연성을 제공합니다. 일반적으로 와일드 카드는 매개변수화된 타입의 하위 타입이나 상위 타입을 표현할 때 사용됩니다.

 

와일드 카드의 종류로는 ?, ? extends, ? super 세 가지가 있습니다. ?는 와일드 카드 자리에 어떤 타입이든 대응될 수 있다는 의미입니다. ? extends는 해당 타입의 하위 타입을 대응할 수 있도록 제한합니다. 반면 ? super는 해당 타입의 상위 타입을 대응할 수 있도록 제한합니다.

 

와일드 카드는 주로 메소드 매개변수나 반환 타입에서 사용됩니다. 와일드 카드를 사용하면 코드의 유연성이 증가하며, 재사용성이 높아집니다.

 

아래는 와일드 카드를 사용한 예시 코드입니다.

// 모든 타입의 List를 처리하는 메소드
public void processList(List<?> list) {
    for (Object obj : list) {
        // do something
    }
}

// Number 타입의 List를 처리하는 메소드
public void processNumberList(List<? extends Number> list) {
    for (Number num : list) {
        // do something
    }
}

// Integer 타입의 List를 처리하는 메소드
public void processIntegerList(List<? super Integer> list) {
    for (Object obj : list) {
        Integer num = (Integer) obj;
        // do something
    }
}

위 코드에서 processList 메소드는 List 타입을 매개변수로 받으며, 와일드 카드를 사용하여 모든 타입의 List를 처리할 수 있습니다. processNumberList 메소드는 Number 타입의 List를 매개변수로 받으며, 와일드 카드를 사용하여 Number 타입의 하위 타입인 Integer, Double 등을 처리할 수 있습니다. 마지막으로 processIntegerList 메소드는 Integer 타입의 List를 매개변수로 받으며, 와일드 카드를 사용하여 Integer 타입의 상위 타입인 Object 등을 처리할 수 있습니다.


지네릭스 타입 소거

제네릭스를 사용하면서 컴파일러는 타입 파라미터를 실제 타입으로 대체하여 코드를 생성합니다. 이러한 과정을 타입 소거(Type Erasure)라고 합니다.

 

타입 소거를 통해 컴파일러는 제네릭 타입을 사용하는 코드를 일반 코드로 변환합니다. 즉, 제네릭 타입에 대한 정보는 런타임에는 지워지게 됩니다.

 

타입 소거로 인해 일부 정보가 손실되지만, 컴파일러는 필요한 정보를 유지하기 위해 몇 가지 규칙을 따릅니다. 예를 들어, List<String> 타입의 인스턴스를 생성할 때, 생성자에 List() 대신 List<String>()를 사용하면, 컴파일러는 String을 타입 파라미터로 추론합니다.

 

하지만, 제네릭 타입의 경계(bound)에 대한 정보는 소거되지 않습니다. 예를 들어, class Foo<T extends Number>와 같이 정의된 제네릭 클래스에서 T가 Number의 하위 타입이라는 경계(bound)가 설정되어 있으면, 컴파일러가 T를 Number로 대체할 때 이 정보를 유지합니다. 따라서, T 대신 Number로 소거됩니다.

 

이러한 타입 소거는 제네릭스가 등장하기 전의 코드와 호환성을 유지하기 위해 필요합니다. 하지만, 타입 소거는 제네릭 타입의 사용에 제약을 줄 수 있습니다. 예를 들어, 타입 소거로 인해 런타임에 제네릭 타입 정보를 알 수 없으므로, 제네릭 타입의 인스턴스를 생성할 수 없습니다.

'Language > Java' 카테고리의 다른 글

스트림(Stream)  (0) 2023.05.18
리플렉션(Reflection)  (0) 2023.05.11
Collections Framework  (0) 2023.04.26
객체 지향 프로그래밍(OOP)  (0) 2023.04.25
JVM GC(Garbage Collection)  (0) 2023.04.13