[JAVA] 제네릭

Mando·2023년 3월 26일
0

JAVA

목록 보기
2/10

목표

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

학습할 것

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

제네릭이란?


프로그램에서 변수를 선언할 떄, 메서드에서 매개변수를 사용할 때도 모든 변수에는 자료형이 있습니다.
대부분은 하나의 자료형을 사용하지만, 변수나 메서드의 매개변수 자료형을 여러 자료형으로 바꿀 수 있다면 프로그램이 훨씬 유연해질 것입니다.

이처럼 어떤 값이 하나의 참조 자료형이 아닌 여러 참조 자료형을 사용할 수 있도록 프로그래밍하는 것을 제네릭 프로그래밍이라고 합니다.(주의할 점은 wrapper type만 가능하다! 그 이유는 아래에서)

제네릭 프로그래밍은 참조 자료형이 변환될 때 컴파일러가 검증을 하기 떄문에 안정적입니다.

제네릭은 언제 사용하는가?


제네릭을 사용하기 전

3D 프린터가 있습니다. 해당 프린터의 재료는 파우더,플라스틱입니다.

파우더를 재료로 하는 3D 프린터입니다.

public class ThreeDPrinter {
	private Powder material; //재료가 파우더인 경우

	public Powder getMaterial() {
		return material;
	}

	public void setMaterial(Powder material) {
		this.material = material;
	}
}

플라스틱을 재료로 하는 3D 프린터입니다.

public class ThreeDPrinter {
	private Plastic material; //재료가 플라스틱인 경우

	public Plastic getMaterial() {
		return material;
	}

	public void setMaterial(Plastic material) {
		this.material = material;
	}
}

중복된 코드가 굉장히 많이 보입니다.
재료만 바뀔 뿐 두 프린터가 가지고 있는 책임은 동일하기 떄문입니다.
이런 경우 어떠한 재료도 쓸 수 있게끔 material의 타입을 Object로 사용할 수 있습니다.
왜냐하면 Object는 모든 클래스의 최상위 클래스이므로 모든 클래스는 Object로 변환이 가능하기 때문입니다.

public class ThreeDPrinter {
	private Object material; //재료가 플라스틱인 경우

	public Object getMaterial() {
		return material;
	}

	public void setMaterial(Object material) {
		this.material = material;
	}
}
ThreeDPrinter printer = new ThreeDPrinter();

Powder p1 = new Powder(); 
printer.setMaterial(p1); //Object로 자동 형 변환

powder p2 = (Powder)printer.getMaterial();
// 직접 Powder로 형 변환

하지만 material을 Object타입으로 사용했을 때 문제점은
다시 원래 자료형으로 반환해주기 위해 매번 형 변환을 해주어야 한다는 점입니다.

따라서 이럴 때 필요한 프로그래밍 방식이 제네릭(Generic)입니다.
제네릭은 여러 참조 자료형이 쓰일 수 있는 곳에 특정 자료형을 지정하지 않고, 클래스나 메서드를 정의한 후 사용하는 시점에 어떤 자료형을 사용할 것인지 지정하는 방식입니다.

제네릭 클래스 사용하기

여러 자료형을 사용할 수 있도록 material 변수의 타입을 T(타입 변수)로 지정했습니다.

  • 클래스의 이름을 GenericPrinter로 지정하고, 나중에 클래스를 사용할 때 T 위치에 실제 사용할 자료형을 지정합니다.
  • 이후 클래스의 각 메서드에 해당 자료형이 필요한 부분에는 모두 T(자료형 매개변수)를 이용해 구현합니다.
public class GenericPrinter<T> { 
	private T material;

	public T getMaterial() {
		return material;
	}

	public void setMaterial(T material) {
		this.material = material;
	}
}
public class Main {
  	public static void main(String[]args){
		GenericPrinter<Plastic> printer = new GenericPrinter<Plastic>();
  		printer.getMaterial(); //Plastic type이 return 된다.
  	}
}

지네릭스의 용어


class Box<T>
  • Box : 지네릭 클래스 T의 Box 또는 T Box 라고 읽는다.
  • T : 타입 변수 또는 타입 매개변수
  • Box : 원시 타입
Box<String> b = new Box<String>();
  • String : 대입된 타입(매개변수화된 타입)
  • Box : 타입 매개변수에 타입을 지정하는 것을 지네릭 타입 호출이라고 한다.

제니릭 클래스 객체 생성과 사용

다음과 같은 제네릭 클래스 Box가 있다고 하자.

public class Box<T> {
    List<T> list = new ArrayList<T>();

    void add (T item){
        list.add(item);
    }
}
public class Main {
    public static void main(String[] args) {
        Box<Apple> appleBox = new Box<Apple>();
  		//1. 참조변수 타입으로부터 Box가 Apple 타입만 저장할 수 있다는 것을 알기에 생성자에서 반복적으로 타입을 지정하지 않아도 된다.
        Box<Apple> appleBox2 = new Box<>();
        
  		//2. 참조변수와 생성자에 대입된 타입(매개변수화된 타입)이 일치해야 한다.
        Box<Apple> grapeBox = new Box<Grape>(); //컴파일 에러
  
		//3. 참조변수와 매개변수화된 타입이 상속 관계에 있을 때도 두 타입이 일치하지 않으면 에러가 난다.
        Box<Fruit> appleBox1 = new Box<Apple>(); //컴파일 에러
  		
  		//4. 두 지네릭 클래스 타입이 상속관계에 있고, 대입된 타입이 동일한 것은 괜찮다.(다형성)
 		Box<Apple>box = new FruitBox<Apple>();
  		
    }
}

4번 예시 ) 두 제네릭 클래스의 타입이 상속관계에 있고, 대입된 타입이 같은 것은 가능하다.

public class FruitBox<T> extends Box {

}
public class Main {
    public static void main(String[] args) {
        Box<Fruit> fruitBox = new FruitBox<Fruit>();

        fruitBox.add(new Fruit());

    }
}

타입 T가 Fruit인 경우 Fruit의 자식들은 add 메서드의 매개변수가 될 수 있을까❓︎

-> 타입 T가 Fruit이면 void add(Fruit item)이 된다.
따라서 Fruit의 자식들은 add메서드의 매개변수가 될 수 있다.

public class Main {
    public static void main(String[] args) {
        Box<Fruit> fruitBox = new Box<>();

        fruitBox.add(new Fruit());
        fruitBox.add(new Apple()); //OK
        fruitBox.add(new Grape()); //OK 
    }
}

제네릭 제약 조건

  1. static 변수 사용 불가 ✅
  2. primitive type은 사용 불가(Wrapper type만 사용) ❌
  3. 배열 사용 불가능 ❌

❌는 현재 포스팅에서는 다루지 않습니다!! 별도의 포스팅을 통해 알아보도록 하겠습니다!

static과 제네릭


static 변수와 제네릭

아래는 컴파일 에러가 발생한다.
왜냐하면 static 변수는 제네릭을 사용할 수 없다.
Fruit 클래스가 인스턴스화 되기 전에 static은 메모리에 올라가기 때문에 이때 name의 타입인 T가 결정되지 않기 때문이다.
따라서 static 변수의 경우 제네릭을 사용하면 여러 인스턴스에서 어떤 타입으로 공유되어야 할지 지정할 수 없어서 사용할 수가 없다.

public class Fruit<T> {
    static T name;
}

static 메서드와 제네릭

static 메서드 또한 컴파일 에러가 발생한다.
이유는 static 변수와 동일하다.
Fruit 클래스가 인스턴스화 되기 전에 메모리에 올라가기 때문에 T의 타입이 정해지지 않았기 때문이다.

제네릭 메서드

리턴 타입 앞에 타입 변수를 선언한 메서드

  • 제네릭 클래스에 정의된 타입 매개변수와 제네릭 메서드에 정의된 타입 매개변수는 전혀 별개의 것이다. 같은 문자 T를 사용한다 해도 같은 것이 아니다.
  • 제네릭 클래스가 아닌 일반 클래스 내부에서도 제네릭 메서드를 사용할 수 있다.
  • 실제 사용시 타입을 지정해도 되지만, 주로 타입 추론을 통해서 타입을 자동으로 알아낼 수 있다.
public class Fruit<T> {
    static <T>T getName(){
        return name;
    }
}

클래스에 표시하는 는 인스턴스 변수라고 생각하면 된다.(인스턴스를 생성할 떄마다 지정하기 떄문)
제네릭 메서드에서 타입 매개변수는 지역 변수 라고 생각하면 된다.
즉, 제네릭 static 메서드의 경우 메서드의 틀만 공유하는 것이다. 그리고 그 틀안에서 지역변수처럼 타입 파라미터가 바뀌는 것이다.

따라서 제네릭 메서드는 호출 시에 매개 타입을 지정하므로 static이 될 수 있다.

public class Fruit {
  public static void main(String[] args) {
     System.out.println(Fruit.<String>getName("abc")); //"abc"
  	 System.out.println(Fruit.getName("abc")); //"abc"(타입 추론)
  }
  static <T>T getName(T id){
      return id;
  }
}
public static void printFirstChar(T param) {
    System.out.println(param.charAt(0));
}

위 코드가 허용되지 않는 이유는 다음과 같다.
클래스에 표시하는 는 인스턴스 변수이다. 그러므로 static 메서드에서 인스턴스 변수로 여겨지는 타입 파라미터를 사용하고 있으므로 컴파일 에러가 발생한다.

바운디드 타입


타입 문자로 타입을 명시하면 한 종류의 타입만 저장할 수 있도록 제한할 수 있지만,
여전히 모든 종류의 타입을 지정할 수 있다는 것에는 변함이 없다.

바운디드 타입을 사용하면 매개변수 T에 들어갈 수 있는 타입의 종류를 제한한다.

바운디드 타입 적용 전과 후 알아보기

바운디드 타입을 적용하지 않아도 아래처럼 FruitCup의 타입변수가 Fruit라면 addFruit메서드가 Fruit의 자식 클래스를 모두 매개변수로 받을 수 있으므로 이는 <T extends Fruit>와 다른 게 없다고 생각했다.

```java
public class Test {
    public static void main(String[] args) {
        FruitCup<Fruit> fruitFruitCup = new FruitCup<>();

        fruitFruitCup.addFruit(new Fruit());
        fruitFruitCup.addFruit(new Apple());
    }
}

타입의 종류를 제한한다 가 바운디드 타입 개념이 나오게 된 이유같다.
따라서 타입의 종류를 제한한다. 라는 말이 무슨 말인지 코드를 통해 알아보자

바운디드 타입 적용 전

아래 예시에서 볼 수 있는 것처럼 T에는 한 종류의 타입만 들어갈 수 있다.
하지만, Fruit타입, Toy타입 모두가 들어갈 수 있다.

public class FruitCup<T> {
    List<T> fruits;

    public FruitCup() {
        this.fruits = new ArrayList<>();
    }

    public void addFruit(T fruit){
        fruits.add(fruit);
    }

    public List<T>getFruits(){
        return fruits;
    }
}
public class Test {
    public static void main(String[] args) {
        FruitCup<Fruit> fruitFruitCup = new FruitCup<>();

        fruitFruitCup.addFruit(new Fruit());
        fruitFruitCup.addFruit(new Apple());

        FruitCup<Toy> toyFruitCup = new FruitCup<>();
        toyFruitCup.addFruit(new Toy());
    }
}

바운디드 타입 적용 후

아래 예시에서 볼 수 있는 것처럼 T에는 Fruit의 자식 클래스만 들어갈 수 있다.
이처럼 바운디드 타입을 적용하면 타입의 종류 자체를 제한할 수 있다!!!!!!!!!

public class FruitCup<T extends Fruit> {
    List<T> fruits;

    public FruitCup() {
        this.fruits = new ArrayList<>();
    }

    public void addFruit(T fruit){
        fruits.add(fruit);
    }

    public List<T>getFruits(){
        return fruits;
    }
}
public class Test {
    public static void main(String[] args) {
        FruitCup<Fruit> fruitFruitCup = new FruitCup<>();

        fruitFruitCup.addFruit(new Fruit());
        fruitFruitCup.addFruit(new Apple());

        // 컴파일 에러 발생
        // FruitCup<Toy> toyFruitCup = new FruitCup<>();
        // toyFruitCup.addFruit(new Toy());
    }
}

다음과 같이 제네릭 타입에 extends 를 사용하면, 특정 타입의 자손들만 대입할 수 있게 제한할 수 있다.
이 경우에는 Fruit 클래스와 Fruit 클래스의 자손들만 담을 수 있다는 제한이 생긴다.

FruitBox<Apple> appleBox = new FruitBox<Apple>(); //OK
FruitBox<Toy> toyBOx = new FruitBox<Toy>(); //Toy는 Fruit의 자식 클래스가 아니므로 들어갈 수 없다.

add() 매개변수 타입 T도 Fruit와 그 자손 타입이 될 수 있으므로, FruitBox는 Fruit를 상속받은 여러 클래스를 담을 수 있다.

public class FruitBox<T extends Fruit> {
    List<T> list = new LinkedList<T>();

    public void add(T item){
        list.add(item);
    }

    @Override
    public String toString() {
        return "FruitBox{" +
                "list=" + list +
                '}';
    }
}
public class Main {
    public static void main(String[] args) {
        FruitBox<Apple> appleFruitBox = new FruitBox<Apple>();
        FruitBox<Grape> grapeFruitBox = new FruitBox<>();

        FruitBox<Fruit> fruitFruitBox = new FruitBox<>();

        fruitFruitBox.add(new Apple()); //가능
        fruitFruitBox.add(new Grape()); //가능

        System.out.println(fruitFruitBox);

    }
}

만일 클래스가 아니라 인터페이스를 구현해야한다는 제약이 필요하다면, 이때도 extends를 사용한다.

interface Eatable(){}
class FruitBox<T extends Eatable>{...}

클래스 Fruit의 자손이면서 Eatable 인터페이스도 구현해야한다면 아래와 같이 &기호로 연결한다.

class FruitBox<T extends Fruit & Eatable>{...|

와일드 카드


  • 타입 변수에 사용된 ?를 와일드 카드라고 합니다.
  • 와일드 카드에는 &를 사용할 수 없습니다.
<?> //타입 변수에 모든 타입을 사용 가능
<? extends T> // T와 자손들 사용 가능
<? super T> // T와 조상들 사용가능

Erasure(소거)


Erasure는 무엇인가?

컴파일 타임에만 타입을 확인하고 런타임에서는 타입을 무시하는 것
따라서 컴파일된 파일(*.class)에는 지네릭 타입에 대한 정보가 없다.

public static <E> boolean containsElement(E [] elements, E element){
    for (E e : elements){
        if(e.equals(element)){
            return true;
        }
    }
    return false;
}

위의 제네릭 메서드가 런타임시에는 아래처럼 되는 것이다.

public static  boolean containsElement(Object [] elements, Object element){
    for (Object e : elements){
        if(e.equals(element)){
            return true;
        }
    }
    return false;
}

그렇기 떄문에 제네릭 타입이 다른 것은 오버로딩의 조건을 충족시키지 못한다.

Erasure(소거)개념이 생긴 이유

자바 1.5 버전 이하에서는 제네릭이라는 개념이 없었다. 그래서 자바 컴파일러는 1.5 이전과 이후 버전의 상호 호환성을 위해서 제네릭을 지워버린다.

참고자료

해당 블로그에서 프린터의 예시가 제네릭이 필요한 상황을 이해하는 데 도움이 되었습니다.
https://montoo.tistory.com/entry/JAVA-Basic-%EC%A0%9C%EB%84%A4%EB%A6%AD

0개의 댓글