제네릭(Generic)

반응형

제네릭?

쉽게 말해서 클래스(메서드) 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 말합니다.

즉, 클래스(메서드)에서 사용할 데이터 타입을 우선 보류해두었다가 나중에 사용할 때 상황에 맞춰서 확정하는 방법인거죠.

 

저는 처음에 상당히 어렵게 생각했었는데, 제네릭의 사용 방법은 그렇게 어렵게 생각할 필요가 없었습니다.

많이 사용해왔던 메서드의 매개변수와 유사하게 생각하면 되는데, 메서드의 매개변수가 "값"을 집어넣는 것이었다면, 제네릭은 "데이터의 타입"을 집어넣는 것이라고 생각하면 됩니다.


어떤 데이터 타입이 들어올지 모르니 여러 데이터 타입을 받아들일 수 있는 클래스를 만들겠다.

듣기만해도 정말 편해보입니다.

 

그러면 제네릭이 없을 때는 어떻게 했을까요

 

제네릭이 없이 다양한 타입을 다루기 위해서는 2가지의 방법이 있습니다.

  1. 동일한 메커니즘을 가진 클래스와 메서드를 여러개 정의한다
  2. Object 타입을 사용한다

첫번째 방법은 딱 봐도 너무 비효율적이고 개발자들이 싫어하는 방법이라는 걸 알겠죠

 

하지만 두번째 방법은 어떤 단점이 있길래 제네릭이 나오게 되었을까요?

Object 타입을 사용하면 Object가 모든 타입의 조상이기 때문에 코드 중복은 막을 수 있겠지만 들어오는 객체의 타입을 컴파일 타임에 체크할 수 없다는 큰 제약사항을 가지게 됩니다. 즉 개발자가 전혀 모르고 있다가 막상 런타임에 오류가 발생할 수 있다는 거죠 (너무 끔찍한 일)

 

하지만 제네릭을 사용하면 객체의 타입을 컴파일 타임에 체크할 수 있어서 타입 안정성을 높일 수 있게 됩니다.

제네릭의 장점
- 타입 안정성 확보
- 코드 간결
타입 안정성: 의도하지 않은 타입의 객체가 저장되는 것을 막고, 저장된 객체를 꺼내올 때 다른 타입으로 잘못 형변환되어 발생할 수 있는 오류를 줄인다.

제네릭은 <> 꺽쇠 괄호를 사용하는데 다이아몬드 연산자라고 부릅니다.

 

제네릭 클래스

먼저 제네릭 클래스에 대해서 알아보겠습니다.

코드로 먼저 예를 들면 아래와 같이 사용될 수 있습니다.

class Sample<T> {
    private T value;

    public T getValue() {
        return value;
    }

    public void setValue(T value) {
        this.value = value;
    }
}

public class Main {

    public static void main(String[] args) {

        Sample<Integer> s1 = new Sample<>();
        s1.setValue(1);

        Sample<Double> s2 = new Sample<>();
        s2.setValue(1.0);

        Sample<String> s3 = new Sample<>();
        s3.setValue("1");

        System.out.println("s1 : " + s1.getValue() + 1);
        System.out.println("s2 : " + s2.getValue() + 1);
        System.out.println("s3 : " + s3.getValue() + 1);
    }
}

결과값은 아래와 같이 나오게 됩니다.

위의 코드에서 보이는 것처럼 s1에는 Integer가, s2에는 Double, s3에는 String으로 각각 다른 타입이 들어갔고, 그에 맞춰서 결과들이 나왔습니다.

이렇게 <T>로 제네릭을 설정하고 실제로 사용할 때 이 안에 넣고자 하는 타입을 입력해줌으로서 원하는 타입으로 해당 클래스를 사용할 수 있게 됩니다.

 

쉽게 설명된 그림이 있어서 가져와봤습니다.

https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%A0%9C%EB%84%A4%EB%A6%ADGenerics-%EA%B0%9C%EB%85%90-%EB%AC%B8%EB%B2%95-%EC%A0%95%EB%B3%B5%ED%95%98%EA%B8%B0

1번처럼 제네릭 클래스를 선언하고 2번처럼 그 클래스에 원하는 타입을 넣어서 사용하게 되면, 실제 동작하는 제네릭 클래스는 3번처럼 바뀌어서 동작하게 되는 것입니다.

 

또한 여러 개의 타입을 넣어서 사용할 수도 있습니다.

//출처: https://inpa.tistory.com/

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

class Apple {}
class Banana {}

class FruitBox<T, U> {
    List<T> apples = new ArrayList<>();
    List<U> bananas = new ArrayList<>();

    public void add(T apple, U banana) {
        apples.add(apple);
        bananas.add(banana);
    }
}

public class Main {
    public static void main(String[] args) {
    	// 복수 제네릭 타입
        FruitBox<Apple, Banana> box = new FruitBox<>();
        box.add(new Apple(), new Banana());
        box.add(new Apple(), new Banana());
    }
}

제네릭 메서드

제네릭은 클래스뿐만 아니라 메서드에서도 사용할 수 있습니다.

 

클래스의 제네릭 타입은 전역 변수처럼 전체에 영향을 준다면 메서드의 제네릭 타입은 해당 메서드 안에서만 영향을 주게 됩니다.

 

제네릭 타입은 메서드의 선언부에 <T>를 함께 선언합니다.

*밑에서 보여질 예시는 모두 https://inpa.tistory.com/ 에서 가져왔습니다.

 

제네릭 클래스의 일반 메서드는 단순히 제네릭 클래스에서 타입을 받아와 그것을 사용할 뿐입니다.

하지만 제네릭 메서드는 직접 메서드 자체 <T> 제네릭을 선언함으로서 독립적으로 동적인 타입을 받아와 사용할 수 있습니다.

class FruitBox<T> {
	
    // 클래스의 타입 파라미터를 받아와 사용하는 일반 메서드
    public T addBox(T x, T y) {
        // ...
    }
    
    // 독립적으로 타입 할당 운영되는 제네릭 메서드
    public static <T> T addBoxStatic(T x, T y) {
        // ...
    }
}

위의 코드처럼 제네릭 클래스와 제네릭 메서드가 함께 사용되면 각각의 <T> 타입 매개변수는 별개의 것이 됩니다.

https://inpa.tistory.com

그러면 제네릭 메서드의 호출에 대해서 좀 더 자세히 알아보겠습니다.

 

제네릭 메서드는 호출에서 "<제네릭 타입>메서드명"으로 사용되게 됩니다.

FruitBox.<Integer>addBoxStatic(1, 2);
FruitBox.<String>addBoxStatic("안녕", "잘가");

https://inpa.tistory.com/

그렇지만 대다수의 상황에서 제네릭에 들어갈 데이터 타입을 메서드의 매개변수를 통해서 컴파일러가 추정할 수 있기 때문에 제네릭 메서드의 타입 파라미터는 생략하는 경우가 많습니다.

// 메서드의 제네릭 타입 생략
FruitBox.addBoxStatic(1, 2); 
FruitBox.addBoxStatic("안녕", "잘가");

https://inpa.tistory.com/

 

위에서 제네릭 클래스와 제네릭 메서드가 함께 선언되어도 제네릭 메서드는 독립적으로 사용되어질 수 있다고 얘기했습니다.

사실 처음 제네릭 클래스를 인스턴스화하면, 클래스 타입 매개변수에 전달한 타입에 따라 제네릭 메서드도 타입이 정해지게 됩니다.

그 이후에 만일 제네릭 메서드를 호출할 때 직접 타입 파라미터를 다르게 지정해주거나, 다른 타입의 데이터를 매개변수에 넣어주게 되면 독립적인 타입을 가진 제네릭 메서드가 되는 것입니다.

class FruitBox<T, U> {
    // 독립적으로운영되는 제네릭 메서드
    public <T, U> void printBox(T x, U y) {
        // 해당 매개변수의 타입 출력
        System.out.println(x.getClass().getSimpleName());
        System.out.println(y.getClass().getSimpleName());
    }
}
public static void main(String[] args) {
    FruitBox<Integer, Long> box1 = new FruitBox<>();

    // 인스턴스화에 지정된 타입 파라미터 <Integer, Long>
    box1.printBox(1, 1);

    // 하지만 제네릭 메서드에 다른 타입 파라미터를 지정하면 독립적으로 운용 된다.
    box1.<String, Double>printBox("hello", 5.55);
    box1.printBox("hello", 5.55); // 생략 가능
}

https://inpa.tistory.com/
https://inpa.tistory.com/


제네릭 제한

제네릭을 사용하여 다양한 타입을 받아들일 수 있고 타입 안정성을 가지게 되긴 하지만, 한편으론 또 너무나 자유롭기 때문에 그 부분에 있어서 어느정도의 제약조건을 줄 필요가 있습니다.

 

예를 들어 계산기를 만들었을 때 정수와 실수를 구분 없이 받을 수 있는 것은 좋지만, String을 받아들이게 되면 이상한 결과값이 나오게 될겁니다. 이럴 때 바로 제네릭 제한이 필요한거죠.

 

extends

extends를 사용하면 그 뒤에 오는 타입과 그 자식 클래스만 타입 매개변수로 들어올 수 있습니다.

 

위와 같이 계산기에 입력되는 타입을 Number로 제한해두었는데, 그 외의 타입이 들어오게 되면 빨간줄이 그어지며 에러가 발생하게 됩니다.

 

인터페이스도 거의 동일한데, 타입 매개변수에 인터페이스를 extends 받으면, 해당 인터페이스를 구현한 클래스만 타입 매개변수로 들어올 수 있습니다.

// 출처: https://inpa.tistory.com/

interface Readable {
}

// 인터페이스를 구현하는 클래스
public class Student implements Readable {
}
 
// 인터페이스를 Readable를 구현한 클래스만 제네릭 가능
public class School <T extends Readable> {
}


public static void main(String[] args) {
    // 타입 파라미터에 인터페이스를 구현한 클래스만이 올수 있게 됨	
    School<Student> a = new School<Student>();
}

다중 타입 한정 또한 가능한데 & 연산자를 사용하게 됩니다.

물론 자바에서는 다중 상속을 지원하지 않기 때문에 인터페이스를 통해서만 다중 타입 한정이 가능합니다.

여기서 주의할 점은 '해당 인터페이스들 중 하나가 아닌 2가지 모두를 구현한 클래스만' 타입 매개변수로 들어올 수 있다는 것입니다.

// 출처: https://inpa.tistory.com/

interface Readable {}
interface Closeable {}

class BoxType implements Readable, Closeable {}

class Box<T extends Readable & Closeable> {
    List<T> list = new ArrayList<>();

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



public static void main(String[] args) {
    // Readable 와 Closeable 를 동시에 구현한 클래스만이 타입 할당이 가능하다
    Box<BoxType> box = new Box<>();

    // 심지어 최상위 Object 클래스여도 할당 불가능하다
    Box<Object> box2 = new Box<>(); // ! Error
}

여러 개의 제네릭을 사용할 때, 각각의 제네릭에 다중 타입 한정을 걸 수 있습니다.

//출처: https://inpa.tistory.com/

interface Readable {}
interface Closeable {}
interface Appendable {}
interface Flushable {}

class School<T extends Readable & Closeable, U extends Appendable & Closeable & Flushable> 
    void func(T reader, U writer){
    }
}

 

super

위에서 설명한 extends는 해당 타입의 자손 타입만 올 수 있도록하는 상한(upper bound)였다면, super는 반대로 하한(lower bound)입니다.

예를 들어 <T super Readable>은 Readable의 조상 타입만 받을 수 있도록 제한하는 것 입니다.

 


와일드 카드

제네릭 타입 같은 경우 일반적인 변수 타입과 달리 제네릭 서브 타입간에 형변환이 불가능합니다.

무슨 말인지 잘 이해가 안될거 같으니 (저도 이해가 안됨)

아래 코드를 통해 설명하겠습니다.

출처: https://atoz-develop.tistory.com/

class Juice<T>{
    private T fruits;

    Juice(T item){
        this.fruits = item;
    }

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

class Fruit {

    String name;

    @Override
    public String toString() {
        return name;
    }
}

class Apple extends Fruit {

    public Apple() {
        this.name = "사과";
    }
}

class Banana extends Fruit {

    public Banana() {
        this.name = "바나나";
    }
}

class FruitCup<T extends Fruit> {

    List<T> fruits;

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

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

    public List<T> getFruits() {
        return fruits;
    }
}

public class Juicer {

    public static Juice makeJuice(FruitCup<Fruit> fruitCup) {
        return new Juice(fruitCup.getFruits());
    }

    public static void main(String[] args) {
        FruitCup<Fruit> fruitCup = new FruitCup<>();
        fruitCup.addFruit(new Apple());
        fruitCup.addFruit(new Banana());
        
        System.out.println("결과값: " + Juicer.makeJuice(fruitCup).toString());
    }
}

 

위와 같이 makeJuice 메서드에 선언된대로 Fruit타입으로 이뤄진 FruitCup<Fruit>을 매개변수로 넣었을 때는 정상 작동합니다.

하지만 이 Fruit를 상속받은 Apple이나 Banana로 이뤄진 FruitCup<Apple>이나 FruitCup<Banana>를 매개변수로 넣었을 때는 어떻게 될까요?

FruitCup<Apple> appleCup = new FruitCup<>();
appleCup.addFruit(new Apple());
System.out.println(Juicer.makeJuice(appleCup).toString());  // 불가

위와 같이 정상 동작하지 않습니다.

 

일반 변수형이었다면 아마 자동으로 캐스팅되어서 작동했을 겁니다. 원래 타입을 상속받은 매개변수를 넣었으니까요.

하지만 FruitCup과 같은 제네릭을 매개변수로 받는 메서드는 오직 타입 파라미터가 똑같은 타입 만을 받습니다.

 

그렇기 때문에 이러한 제네릭 타입을 매개변수로 받는 메서드에서 똑같은 타입 파라미터 뿐만 아니라 그 자식이 제네릭 타입을 매개변수로 받으려면 2가지 방법 중 하나를 선택해야합니다.

 

  1. '와일드 카드'를 사용한다
  2. 메서드 레벨에서 제네릭 타입을 선언한다

우선 와일드 카드를 살펴보면

? 기호를 사용하는 것인데, 이 기호를 사용하면 어떤 타입도 받아들일 수 있게 됩니다.

하지만 ? 기호만을 사용하면 너무나 넓은 범위를 모두 받아들일 수 있게 되므로 extends, super 키워드를 통해 제한을 두게 됩니다.

  • <?> : 제한 없이 모든 타입 가능. <? extends Object>와 동일한 표현
  • <? extends T> : T와 그 자손 타입만 가능
  • <? super T> : T와 그 조상 타입만 가능

우선 와일드 카드를 사용하여 위의 코드에서 Juicer 메서드가 appleCup을 받아들이도록 수정하면 아래와 같습니다.

출처: https://atoz-develop.tistory.com/

public class Juicer {

    public static Juice makeJuice(FruitCup<? extends Fruit> fruitCup) {	// 수정된 부분
        return new Juice(fruitCup.getFruits());
    }

    public static void main(String[] args) {
        FruitCup<Fruit> fruitCup = new FruitCup<>();
        fruitCup.addFruit(new Apple());
        fruitCup.addFruit(new Banana());
        System.out.println("fruitCup 결과값: " + Juicer.makeJuice(fruitCup).toString());

        FruitCup<Apple> appleCup = new FruitCup<>();
        appleCup.addFruit(new Apple());
        appleCup.addFruit(new Apple());
        appleCup.addFruit(new Apple());
        System.out.println("appleCup 결과값: " + Juicer.makeJuice(appleCup).toString());
    }
}

만약 와일드 카드를 사용하지 않고 메서드 레벨에서 제네릭 타입을 선언하면 아래와 같이 변경할 수 있습니다.

    public static <T extends Fruit> Juice makeJuice(FruitCup<T> fruitCup) {
        return new Juice(fruitCup.getFruits());
    }

출처1

출처2

반응형

'방구석 컴퓨터 > 방구석 자바' 카테고리의 다른 글

인덱스 범위 설정: 끝 인덱스의 직전까지로 하는 이유  (1) 2024.11.15
equals와 hashCode  (0) 2024.11.14
빌더 패턴(Builder Pattern)  (0) 2023.08.21
StringUtils  (0) 2023.08.17
메이븐(Maven)  (0) 2023.08.16
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유