코딩관계론

제네릭과 와일드카드에 대해서 알아보자 본문

개발/Java

제네릭과 와일드카드에 대해서 알아보자

개발자_티모 2024. 6. 19. 01:55
반응형

제네릭 타입을 이해하기 위해선 먼저 공변과 불공변의 개념을 알아야 한다.

공변

공변이란 자기의 타입과 자신의 하위 타입까지 같다고 인식합니다. 아래의 코드를 보면 Anmain배열에 cats를 할당하고 있는데 Animal배열에서 Cat타입이 같다고 인식해 컴파일 오류가 발생하지 않는 경우입니다.

class Animal {}
class Cat extends Animal {}

public class Main {
    public static void main(String[] args) {
        Animal[] animals = new Cat[10];  // 공변성 허용
        animals[0] = new Cat();          // 유효
        animals[1] = new Animal();       // 런타임 오류 (ArrayStoreException)
    }
}

 

불공변

불공변이란 자기와 타입이 같은 것만 같다고 인식합니다. 따라서 아래의 주석을 해제하면 컴파일 오류가 발생하게 됩니다.

import java.util.ArrayList;
import java.util.List;

class Animal {}
class Cat extends Animal {}
class Dog extends Animal {}

public class Main {
    public static void main(String[] args) {
        List<Cat> cats = new ArrayList<>();
        cats.add(new Cat());
        // List<Animal> animals = cats; // 컴파일 오류
        List<Animal> animals = new ArrayList<>();
        animals.add(new Dog());
        animals.add(new Cat());
    }
}

 

 

제네릭이란

제네릭이란 컴파일 시점에 형타입을 검사함으로써 런타임에 오류를 방지하기 위해서 사용된다.

 

제네릭 선언 방법 

이제는 제네릭의 사용법은 아래의 그림과 같다. 제네릭은 타입 매개변수라는 것이 존제하는데 아래의 코드에서는 T가 타입 매개변수가 된다. 타입 매개변수는 어떤 이름을 입력해도 상관없지만 자바에서의 규약은 지켜주는 것이 좋다.

  • E - 요소(Element, 예: List<E>)
  • K - 키(Key, 예: Map<K, V>)
  • V - 값(Value, 예: Map<K, V>)
  • T - 타입(Type, 예: Comparable<T>)
  • N - 숫자(Number)

 

만약 아래의 코드처럼사용자가  형 변환을 위해서 instanceof를 사용하거나, 체크 전 강제로 형 변환을 하게 된다면 런타임 오류가 발생할 확률이 높아지게 된다. 

package com.example.javatest;
public class Box {
    private Integer t;

    public void Box(){
        this.t = 1
    }

    public void set(Integer t) {
        this.t = t;
    }

    public Object get() {
        return t;
    }
    
    public static void main(String[] args) {
        Box box = new Box();
        Object r = box.get();

        if(r instanceof Integer){
            r = (Integer)r;
        }

        if(r instanceof String){
            r = (Integer)r;
        }
    }
}

 

그럼 위의 문제를 해결하기 위해 제네릭 파라미터를 사용하게 된다면 아래의 코드에서 처럼 사용자가 잘못된 형타입을 받게 된다면 컴파일이 실패하게 된다.

package com.example.javatest;
public class Box<T> {
    private T t;

    public void Box(T t){
        this.t = t;
    }

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

    public T get() {
        return t;
    }


    public static void main(String[] args) {
        Box box = new Box();
        box.set(1);
        // String r = box.get();
        Integer r = box.get();
    }
}

 

제네릭은 비구체화 타입

제네릭은 자바 5에서 새롭게 도입된 기능이라고 말씀드렸다. 그럼 기능이 이전 버전의 자바와 호환되기 위해 컴파일 타임에서 제네릭 타입을 제거한다.  이렇기 때문에 제네릭 타입은 비구체화 타입이라고 말한다 

더보기

구체화 타입과 비구체화 타입의 차이점은 구체화 타입은 런타임 시점에서도 타입이 유지되고, 비구체화 타입은 런타임 시점에서 타입이 유지가 되지 않는다.

Type erasure 작동 방식

컴파일러는 제네릭 타임을 감지하면 unbounded Type(<?>, <T>)는 Object로 변환하고, Bound type의 경우에는 Comparable로 변환합니다.  아래의 코드로 unbounded type이 컴파일 후에는 어떻게 변하는지 보여드리겠습니다. 아래의 코드를 보면 T라는 제네릭 타입이 모두 Object로 변환 것을 확인할 수 있습니다.

public class Box<T> {
    private T t;

    public void Box(T t) {
        this.t = t;
    }

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

    public T get() {
        return t;
    }
}

//컴파일 후

public class Box {
    private Object t;
    
    public void set(Object t) {
        this.t = t;
    }

    public Object get() {
        return t;
    }
}

 

여기서 궁금한 점은 클래스를 변환하는 것은 이해가 가지만 그렇다면 get이라는 함수를 사용하는 쪽은 컴파일러가 어떻게 처리하는지 궁금하실 것입니다.

 

바로 아래의 코드처럼 컴파일러가 타입의 안전성이 필요한 경우에는 직접 타입 변환을 추가하게 됩니다.

public static void main(String[] args) {
    Box<Integer> box = new Box();
    box.set(1);
    Integer a = box.get();
}

//컴파일 후
    public static void main(String[] args) {
        Box<Integer> box = new Box();
        box.set(1);
        Integer a = (Integer)box.get();
    }

 

제네릭의 단점

1. 제네릭은 자바의 기본타입은 사용할 수 없다. 제네릭의 경우 Object를 타겟으로 하기 때문이고, 자바의 프리미티브 타입은 Object가 아니다.

2.static 메소드의 경우 새로운 제네릭 파라미터를 할당해야 한다.왜냐하면 제네릭은 인스턴스 단위로 존제하기 떄문이다

3. 인스턴스 생성 시에 타입 매개변수로 활용될 수 없는데, 그 이유는 컴파일 타임에 타입 소거가 진행되기 때문에 런타임에서는 어떤 타입으로 인스턴스 해야 하는지 모르기 때문이다.

 

 

와일드카드 

앞서 제네릭은 불공변이라고 했는데, 이에 대한 제약을 줄이기 위해 반공변 제네릭으로 만드는 기술이 존제한다. 이 기술은 상한와일드 카드, 하한와일드카드, ?라고 불린다.

 

 

상한 와일드카드 

상한와일드 카드는 다음과 같은 "? extends "의 형식으로 선언된다. 

ArrayList<? extends Animal> parent = new ArrayList<>();

 

 

상한 와일드카드는 제네릭 타입에 대한 유연성을 제공하지만, 특정 제한 사항이 있습니다. 상한 와일드카드를 사용하면 해당 타입의 하위 클래스 타입을 허용할 수 있지만, 해당 컬렉션에 값을 추가할 수 없습니다. 이는 타입 안정성을 유지하기 위함입니다. 예제를 통해 이를 더 명확히 설명하겠습니다.

    class Animal {
        public void sound() {
            System.out.println("Some generic animal sound");
        }
    }

    class Cat extends Animal {
        @Override
        public void sound() {
            System.out.println("Some mammal sound");
        }
    }

    class Dog extends Animal {
        @Override
        public void sound() {
            System.out.println("Bark");
        }
    }

    public void testUpperWildCard() {
        ArrayList<? extends Animal> parent = new ArrayList<>();

        Animal animal = parent.get(0);
        Dog dog = (Dog) parent.get(0); //논리적 Error 발생 가능성 있음 
        
        parent.add(new Animal())  //컴파일 에러 발생
        parent.add(new Dog())    // 컴파일 에러 발생
    }

 

하한 와일드카드

상한와일드 카드는 다음과 같은 "? super "의 형식으로 선언된다. 필자는 이 하한이라는 개념이 이해가 안됐는데 다음곽 같은 방식으로 이해했다. 하한 와일드카드는 하한에 제한을 둔다는 의미임으로 최소한 arraryList에 추가되기 위해선 최소한 Animal 클래스는 상속해 구현해야 한다는 의미가 된다.

ArrayList<? super Animal> parent = new ArrayList<>();

 

하한 와일드카드는 제네릭 타입에 하한을 설정하여 특정 타입 이상의 상위 타입만을 허용하는 방식입니다. 이를 통해 컬렉션에 안전하게 값을 추가할 수 있으며, 상위 타입으로부터 객체를 읽을 때는 타입 안정성을 보장합니다.

    public void testUpperWildCard() {
        ArrayList<? super Animal> parent = new ArrayList<>();

        Object animal = parent.get(0);
        parent.add(new Dog());
        parent.add(new Cat());
    }

 

? 와일드카드

?와일드카드는 사실 <? extends Object>와 같은 의미가 된다. 따라서 값을 읽어오는 것은 가능하지만 추가하는 것은 불가능하다.

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        testWildcard();
    }

    public static void testWildcard() {
        // 와일드카드를 사용하는 ArrayList 선언
        List<?> list1 = new ArrayList<>(); // <? extends Object>와 같음
        List<? extends Object> list2 = new ArrayList<>(); // <? extends Object>와 같음

        // 모든 타입을 포함하기 때문에 다양한 타입의 객체를 추가할 수 없음
        // list1.add(new Object()); // 컴파일 오류
        // list1.add(new String("Hello")); // 컴파일 오류

        // 값을 읽어오는 것은 가능
        Object obj1 = list1.get(0); // Object로 받음
        Object obj2 = list2.get(0); // Object로 받음

        // 값을 적절한 타입으로 캐스팅하여 사용 가능
        if (obj1 instanceof String) {
            String str = (String) obj1;
            System.out.println("String value: " + str);
        }
        if (obj2 instanceof Integer) {
            Integer integer = (Integer) obj2;
            System.out.println("Integer value: " + integer);
        }
    }
}

 

 

깊게 생각해보기

1. 왜 자바 API를 보면 제네릭을 많이 사용할까?

그 이유는 사용자가 어떤 object 생성해서 넘길지 모르기 때문이다.

반응형