본문 바로가기
Java

제네릭스 (Generics) 와 리스트 구현

by 밀러 (miller) 2022. 1. 12.

제네릭스 관련 내용 대부분은 자바의 정석 15장 입출력 내용을 정리하였습니다.

 

제네릭스 (Generics)

제네릭스 (Generics) 는 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입 체크 (compile-time type check) 를 해주는 기능이다.

 

제네릭스를 왜쓸까?

객체의 타입을 컴파일 시에 체크하기 때문에, 객체의 타입 안정성을 높이고 형변환의 번거로움이 줄어든다. 다시 말하면 객체를 저장할 때는 타입 안정성을 높여 의도하지 않은 타입의 객체가 저장되는 것을 막고, 객체를 불러올 때는 원래의 타입과 다른 타입으로 잘못 형변환되어 발생할 수 있는 오류를 줄여준다.

 

제네릭스의 장점

 

  1. 컴파일 시에 객체 타입 체크로 타입 안정성을 제공한다.
  2. 타입 체크와 형변환을 생략할 수 있으므로 코드가 간결해진다.

제네릭 타입은 클래스와 메서드에 선언할 수 있다. 어떻게 선언할까?

 

제네릭 클래스

제네릭 클래스의 선언

클래스 옆에 <T> 를 붙이면 제네릭 클래스로 선언할 수 있다. 여기서 Box원시 타입, T타입 변수 또는 타입매개변수, Box<T>제네릭 클래스이다.

 

class Box<T> {
    T item;

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

 

Box<T> 에서 T 를 타입 변수 (type variable) 라고 하며, 보통 타입 (Type), 요소 (Element), 키 (Key), 값 (Value) 와 같이 요소의 첫 글자를 따서 타입 변수로 사용한다. 타입 변수가 여러 개인 경우 Map<K, V> 와 같이 콤마를 구분자로 나열하면 된다. 타입 변수는 종류만 다를 뿐, 임의의 참조형 타입을 의미한다는 것은 모두 같다.

 

다양한 종류의 타입을 다루는 메서드의 매개변수나 리턴타입으로 Object 타입의 참조변수를 사용하고, 그로 인해 반환 후에 형변환이 불가피했지만, 제네릭 타입을 사용하게 되면 Object 타입 대신 원하는 타입을 지정하기만 하면 된다.

 

제네릭스의 불가

제네릭스는 다음 경우에 제한된다.

 

  • static 멤버에 타입 변수 T 사용 불가
  • new 연산자를 이용해 타입변수의 배열 생성 new T[] 불가

타입변수 T 는 인스턴스변수로 간주되므로, static 멤버에 타입변수 T 를 사용할 수 없다. static 멤버는 타입변수에 대입된 타입의 종류에 관계없이 동일한 것이어야 하기 때문이다.

 

그리고 제네릭 타입의 배열을 생성하는 것도 허용되지 않는다. 제네릭 배열 타입의 참조변수를 선언하는 것은 가능하지만, new T[10] 과 같이 배열을 생성하지는 못한다. 이는 new 연산자가 컴파일 시점에 타입 T 가 무엇인지 정확히 알 수 없기 때문이다.

 

제네릭 클래스의 객체 생성

제네릭 클래스의 객체를 생성할 때는 참조변수와 생성자에 타입 T 대신 사용될 실제 타입을 지정해 주면 된다. 여기서 String매개변수화된 타입 (Parameterized Type) 이고, 타입 매개변수에 타입을 지정하는 것을 제네릭 타입 호출이라고 한다.

 

Box<String> b = new Box<String>();

 

이제 제네릭 클래스 Box<String> 은 다음과 같이 정의된 것과 동일하다. 하지만 Box<T>String 이나 Integer 를 대입하여 제네릭 타입 호출을 한다고 해도, 생성된 인스턴스는 서로 다른 클래스의 인스턴스가 아니다. 이는 add(3, 5)add(2, 4) 에서 매개변수의 값이 다르다고 서로 다른 메서드를 호출하는 것이 아닌 것과 동일하다. 실제로, 컴파일 후에 Box<String>Box<Integer> 는 원시 타입인 Box 로 바뀐다.

 

class Box {
        String item;

        void setItem(String item) { this.item = item; }
        String getItem() { return item; }
}

 

선언한 제네릭 클래스의 객체는 다음과 같이 사용 가능하다.

 

b.setItem(new Object()); // 에러
b.setItem("ABC");
String item = b.getItem();

 

제네릭 클래스의 불가

Box<T> 의 객체를 생성할 때는 할당받을 참조변수와 클래스 생성자에 대입된 타입이 같아야한다. 일치하지 않으면 에러가 발생하며, 두 타입이 상속관계에 있어도 에러가 발생한다. 하지만 두 제네릭 클래스가 상속관계에 있고, 대입된 타입이 같은 것은 괜찮다.

 

Box<Apple> appleBox = new Box<Apple>(); // OK
Box<Apple> appleBox = new Box<Grape>(); // 에러

// 대입된 타입이 상속관계에 있는 경우 -> 에러
Box<Fruit> appleBox = new Box<Apple>(); // 에러

// 제네릭 클래스가 상속관계에 있는 경우 -> OK
Box<Apple> appleBox = new FuitBox<Apple>(); // OK

 

그리고 추정이 가능한 경우에는 타입을 생략할 수 있다. 아래와 같이 참조변수의 타입으로 BoxApple 타입의 객체만 저장한다는 것을 알 수 있으므로, 생성자에 반복해서 타입을 지정해주지 않아도 된다. (이제 드디어 ArrayList 를 생성할 때, 왜 ArrayList<String> arr = new ArrayList<>(); 로 클래스 생성자의 타입을 입력하지 않는지 알게되었다! )

 

Box<Apple> appleBox = new Box<Apple>(); // OK
Box<Apple> appleBox = new Box<>(); // OK

 

제네릭 클래스의 제약

타입 문자로 사용할 타입을 명시하면 한 종류의 타입만 저장할 수 있도록 제한할 수 있지만, 여전히 모든 종류의 타입을 지정할 수 있다는 것에는 변함이 없다. 하지만 제네릭 타입에 extends 를 사용하면, 특정 타입의 하위 클래스들만 대입할 수 있도록 제한할 수 있다.

 

class FruitBox<T extend Fruit> {
    ArrayList<T> list = new ArrayList<>();

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

FruitBox<Apple> appleBox = new FruitBox<Apple>(); // OK
FruitBox<Toy> toyBox = new FruitBox<Toy>(); // 에러

appleBox.add(new Apple()); // OK
appleBox.add(new Toy()); // 에러

 

만일 클래스가 아니라 인터페이스를 구현해야한다는 제약이 필요하면, implements 를 사용하지 않고 extends 를 사용한다. 그리고 여러 클래스/인터페이스를 구현해야할 경우 & 기호로 연결한다.

 

class FruitBox<T extends Fruit> {} // 클래스 제약
class FruitBox<T extends Eatable> {} // 인터페이스 제약
class FruitBox<T extends Fruit & Eatable> {} // 클래스 + 인터페이스 제약

 

와일드카드

static 메서드에서 매개변수에 제네릭 클래스를 사용해야하고 싶을 땐 어떻게 해야할까? static 에서는 제네릭 타입을 사용할 수 없다. 따라서 아래와 같이 매개변수의 타입에 제네릭 타입 대신에 특정 타입을 지정하고, 여러 타입의 인자를 받도록 아래와 같이 static 메서드를 여러 개 만들어줘야 한다.

 

class Juice {
    static Juice makeJuice(FruitBox<Fruit> box) {}
    static Juice makeJuice(FruitBox<Apple> box) {}
    ...
}

 

하지만 위와 같이 오버로딩하면, 제네릭 타입이 다른 것만으로 오버로딩이 성립하지 않아 컴파일 에러가 발생한다. 제네릭 타입은 컴파일러가 컴파일할 때만 사용하고 제거해 버리기 때문에, 위와 같은 경우는 오버로딩이 아니라 메시지 중복 정의다!

 

와일드카드는 이러한 경우에 사용할 수 있는데, ? 로 표현하며 어떠한 타입도 될 수 있다.

 

  • <? extends T> : T 와 그 자손들만 가능
  • <? super T> : T 와 그 조상들만 가능
  • <?> : <? extends Object> 와 동일하며, 모든 타입이 가능

따라서 위의 경우는 와일드 카드를 이용해 아래와 같이 바꿀 수 있다. 아래의 경우, makeJuice 구현 시 box.getList() 에서 Fruit 타입으로 구성된 시퀀스를 반환받아야 하므로, <? extends Fruit> 으로 제약이 필요하다.

 

static Juice makeJuice(FruitBox<? extends Fruit> box) {
    String tmp = "";
    for (Fruit f : box.getList()) tmp += f + " ";
    return new Juice(tmp);
}

FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox = new AppleBox<Apple>();

Juicer.makeJuice(fruitBox); // OK
Juicer.makeJuice(appleBox); // OK

 

Collection.sort()

static <T> void sort(List<T> list, Comparator<? super T> c)

 

Comparator<? super T> 에서 Comparator 의 타입변수로 T 자신과 상위 클래스를 대입 가능하다. 즉, Apple 클래스와 Grape 클래스의 상위 클래스인 Fruit 클래스에 대한 Comparator 객체를 만들면 두 클래스의 객체로 구성된 리스트에 대해 sort() 메서드를 적용 가능하다.

 

class FruitComp implements Comparator<Fruit> {
        public int compare(Fruit t1, Fruit t2) {
        return t1.weight - t2.weight;
    }
}

Collection.sort(appleBox.getList(), new FruitComp()); // OK
Collection.sort(grapeBox.getList(), new FruitComp()); // OK

 

제네릭 메서드

제네릭 메서드는 메서드의 선언부에서 제네릭 타입이 반환 타입 바로 앞에 선언된 메서드를 말한다. 앞서 살펴본 Collections.sort() 가 여기에 속한다.

 

static <T> void sort(List<T> list, Comparator<? super T> c)

 

static 메서드에는 타입 매개변수를 사용할 수 없지만, 제네릭 타입을 선언하고 사용하는 것은 가능하다. 메서드에 선언된 제네릭 타입은 지역 변수를 선언한 것과 같은데, 따라서 타입 매개변수는 메서드 내에서만 지역적으로 사용되므로 메서드가 static 이어도 상관없다. 위의 static 메서드 makeJuice 를 제네릭 메서드로 바꾸면 아래와 같다.

 

static Juice makeJuice(FruitBox<? extends Fruit> box) { // 와일드카드
    String tmp = "";
    for (Fruit f : box.getList()) tmp += f + " ";
    return new Juice(tmp);
}

static <T extends Fruit> Juice makeJuice(FruitBox<T> box) { // 제네릭 메서드
    String tmp = "";
    for (Fruit f : box.getList()) tmp += f + " ";
    return new Juice(tmp);
}

FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox = new AppleBox<Apple>();

Juicer.<Fruit>makeJuice(fruitBox); // OK
Juicer.<Apple>makeJuice(appleBox); // OK

Juicer.makeJuice(fruitBox); // 타입 추정가능 시 생략가능
Juicer.makeJuice(appleBox); // 타입 추정가능 시 생략가능

 

제네릭 타입은 타입 변수를 메서드 내에서 지역변수처럼 사용할 수 있으므로, static 메서드에서 제네릭 타입 매개변수가 사용가능하거나 미리 타입 변수에 대한 제약을 와일드카드 없이 줄 수 있다. 전자는 Collections.sort(), 후자는 위의 makeJuice 메서드에 해당한다.

 

제네릭 메서드의 주의

제네릭 클래스에 정의된 타입 매개변수와 제네릭 메서드에 정의된 타입 매개변수는 전혀 별개의 것이다. 아래에서 같은 타입 문자 T 를 사용해도 같은 것이 아니라는 것에 주의해야한다. 제네릭 클래스 선언부의 T 는 클래스 내의 타입변수이지만, 제네릭 메서드 선언부의 T 는 메서드 내에서 사용되는 지역변수일 뿐이다.

 

// 클래스 선언부와 메서드 선언부의 T는 별개의 타입변수
class FruitBox<T> {
    ...
    static <T> void sort(List<T> list, Compartor<? super T> c) {}
    ...
}

 

제네릭 타입의 형변환

제네릭 타입과 넌제네릭 (non-generic) 타입의 형변환은 경고가 발생하지만 항상 가능하다. 반대로 대입된 타입이 다른 제네릭 타입 간에는 형변환이 불가능하다. 이는 앞에서 다룬 ‘할당받을 참조변수와 클래스 생성자에 대입된 타입이 같아야한다’ 와 일맥상통하다. 하지만 Box<String>Box<? extends Object> 로 다형성을 이용해 형변환이 된다.

 

Box<Object> objBox = new Box<String>(); // 에러. 형변환 불가능

 

제네릭 타입의 제거

컴파일러는 제네릭 타입을 이용해서 소스파일을 체크하고, 필요한 곳에 형변환을 넣어주고, 제네릭 타입을 제거한다. 따라서 컴파일된 파일 (*.class) 에는 제네릭 타입에 대한 정보가 없다!

 

제네릭 타입의 제거 과정은 아래와 같다.

1. 제네릭 타입의 경계 (bound) 를 제거한다. 제네릭 타입이 <T extends Fruit> 라면 TFruit 으로 치환되고, <T> 라면 TObject 로 치환된다. 그리고 클래스 옆의 선언은 제거된다.

 

// 제거 이전
class Box<T extends Fruit> {
    void add(T t) {
        ...
    }
}

// 제거 후
class Box {
    void add(Fruit f) {
        ...
    }
}

 

2. 제네릭 타입을 제거한 후에 타입이 일치하지 않으면 형변환을 추가한다.

 

// 형변환 추가 전
T get (int i) {
    return list.get(i);
}

// 형변환 추가 후
Fruit get(int i) {
    return (Fruit) list.get(i);
}

 

제네릭을 이용한 리스트 구현

연결리스트 구현 시 필요한 노드와 데이터를 제너릭을 이용해 구현하자. 그리고 생성한 노드를 저장할 수 있는 자료구조로 addLast, insert, delete 와 같은 기본적인 연산을 수행할 수 있는 배열리스트와 연결리스트를 구현체를 만든다. 그리고 배열리스트와 연결리스트와 리스트 인터페이스를 구현해보자.

노드

먼저 데이터 객체로 사용할 Video 클래스는 다음과 같다. 영상에 관한 정보를 저장하는 객체로, 영상의 id 와 제목인 title, 재생시간인 duration 을 멤버 변수로 갖는다.

 

public class Video {
    private String id;
    private String title;
    private int duration;
}

 

해당 데이터 객체 뿐만 아니라 다양한 타입의 객체를 받으려면 노드 객체를 만들 때, 데이터에 해당하는 멤버 변수는 타입 변수로 선언하는 편이 좋을 것 같다. 따라서 data 멤버 변수는 타입 변수를 이용해 선언하고, 이중연결리스트 구현을 위해 nextprev 로 다른 노드 변수의 주소를 저장하는 멤버 변수를 갖는 Node 클래스를 만든다.

 

public class Node<T> {
    T data;
    Node<T> next;
    Node<T> prev;
}

 

리스트

이번에는 노드를 저장할 배열리스트연결리스트를 만들어보자. 먼저 배열리스트는 객체 내부에 타입 변수의 배열을 멤버 변수로 가져야 할텐데, 위에서 다뤘듯이 타입 변수로 배열을 다루기 어려우므로 Object 의 배열로 생성해보자. 그리고 배열의 크기를 다루는 size 멤버 변수를 추가한다.

 

public class ArrayList<T> {
    private Object[] arr;
    private int size;
}

 

다음으로 연결리스트를 만든다. 연결리스트는 헤드 포인터 역할을 하는 더미노드 headsize 멤버 변수를 가져야 할 것이다.

 

public class LinkedList<T> {
    private Node<T> head = new Node<T>(null);
    private int size;
}

 

이제 두 클래스가 공통으로 수행해야할 오퍼레이션인 addLast, insert, delete 를 추가해야한다. 해당 메서드들은 List 인터페이스에 추가 후, ArrayListLinkedList 에서 implements 키워드를 이용해 구현하도록 한다. 그리고 자료구조의 크기를 반환하는 size() 메서드 또한 하위 클래스에서 구현하도록 추가해보자.

 

public List List<T> {
    private int size();
    public void addLast(T element);
    public void insert(T element, int ind);
    public void delete(T element);
}

 

이제 다음과 같이 ArrayListLinkedList 에서 List 를 구현할 수 있다. 단, LinkedList 의 경우 Node<T> 를 내부 원소로 가지는 List 를 구현하는 편이 좋을 것 같다. 현재 연산에 사용되는 자료구조는 모두 노드이므로, 이해를 쉽게하기 위해 인자의 이름을 node 로 설정했다.

 

public class ArrayList<T> implements List<T> {
    private Object[] arr;
    private int size;

    public int size() { ... };
    public void addLast(T node) { ... };
    public void insert(T node, int ind) { ... };
    public void delete(T node) { ... };
}

 

public class LinkedList<T> implements List<Node<T>> {
    private Node<T> head = new Node<T>(null);
    private int size;

    public int size() { ... };
    public void addLast(T element) { ... };
    public void insert(T element, int ind) { ... };
    public void delete(T element) { ... };
}

 

연결 리스트

배열리스트의 경우, 자료구조에 대해 addLast, insert, delete 를 호출할 경우 내부 멤버변수인 배열 arr 에 대한 연산을 수행하므로 비교적 쉬운 편이다. 따라서 연결리스트를 집중적으로 구현해보자.

 

먼저 LinkedList 클래스는 단일 연결리스트와 이중 연결리스트로 구현할 수 있을텐데, 두 객체를 구현하기에 앞서 공통적으로 수행할 수 있는 오퍼레이션에 대해 LinkedList 에서 구현할 수 있을 것 같아 보인다. LinkedList 에서 size() 메서드는 구현하고, 나머지 메서드는 abstract 로 상속받는 클래스에서 구현하도록 하자.

 

public abstract class LinkedList<T> implements List<Node<T>> {
    protected Node<T> head = new Node<T>(null);
    protected int size;

    public int size() { return size };

    abstract public void addLast(T element);
    abstract public void insert(T element, int ind);
    abstract public void delete(T element);
}

 

이제 SingleLinkedListDoubleLinkedList 에서 addLast, insert, delete 를 구현만 하면 제네릭 클래스를 이용한 연결리스트 구현이 끝날 것 같다! 각각 LinkedList 를 상속받아 내부에 코드를 구현한다.

 

public class SingleLinkedList<T> extends LinkedList<T> {

    public SingleLinkedList(List<Node<T>> nodeList) {
        Node<T> p = head;

        for (Node<T> node : nodeList) {
            p.next = node;
            p = p.next;
        }
        size = nodeList.size();
    }

    public void addLast(Node<T> node) {
        Node<T> p = head;

        while (p.next != null) {
            p = p.next;
        }

        p.next = node;
        size++;
    }

    public void insert(Node<T> node, int ind) {
        if (ind >= size) {
            addLast(node);
            return;
        }

        Node<T> p = head;
        int count = 0;

        while (p.next != null && count++ < ind) {
            p = p.next;
        }

        node.next = p.next;
        p.next = node;
        size++;
    }

    public void delete(Node<T> node) {
        Node<T> p1 = head;
        Node<T> p2 = p1.next;

        while (p2 != null) {

            if (p2.equals(node)) {
                p1.next = p2.next;
                break;
            }

            p1 = p1.next;
            p2 = p2.next;
        }
        size--;
    }
}

 

DoubleLinkedList 의 경우 내부에 head 외에 tail 포인터가 필요하다.

 

public class DoubleLinkedList<T> extends LinkedList<T> {

    private Node<T> tail = new Node<T>(null);

    public DoubleLinkedList(List<Node<T>> nodeList) {
        Node<T> p = head, tmp = head;
        head.next = tail;

        for (Node<T> node : nodeList) {
            p.next = node;
            tmp = p;

            p = p.next;
            p.prev = tmp;

        }
        p.next = tail;
        tail.prev = p;

        size = nodeList.size();
    }

    @Override
    public void addLast(Node<T> node) {
        Node<T> p = tail.prev;

        p.next = node;
        node.prev = p;
        node.next = tail;

        tail.prev = node;
        size++;
    }

    @Override
    public void insert(Node<T> node, int ind) {
        if (ind >= size) {
            addLast(node);
            return;
        }

        Node<T> p1 = head, p2 = head;
        int count = 0;

        while (p1.next != null && count++ < ind) {
            p1 = p1.next;
        }
        p2 = p1.next;

        p1.next = node;
        node.prev = p1;

        p2.prev = node;
        node.next = p2;
        size++;
    }

    @Override
    public void delete(Node<T> node) {
        Node<T> p = head.next;

        while (p.getData() != null) {

            if (p.equals(node)) {
                p.prev.next = p.next;
                p.next.prev = p.prev;
                break;
            }
            p = p.next;
        }
        size--;
    }
}

 

SingleLinkedListDoubleLinkedList 는 내부적으로 구현이 다르고 중복되는 코드를 줄이기 어려우므로 추상클래스인 LinkedList 를 상속받아 각각 구현체를 작성하였다. 현재까지 코드를 작성한 객체들을 UML 로 그려보면 아래와 같다.

 

 

 

VideoLinkedListVideoArrayListVideo 객체를 사용할 때, 해당 객체에 대해 수행해야할 특정 오퍼레이션을 구현하기 위해 상속받아 구현한 클래스이다. 만약 VideoLinkedList 가 이중 연결 리스트가 아닌 단일 연결리스트로 구현하기로 변경한다면, DoubleLinkedList 가 아닌 SingleLinkedList 를 상속받는 것으로 바꾸기 위해 클래스 선언부만 변경하면 된다. (혹은 LinkedList 를 멤벼변수로 가지고, 생성자를 통해 주입받는 쪽이 더 유연한 객체 간의 협력을 만들 수 있어 보인다.)

댓글