제네릭

클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법

이렇게 다룰 객체의 타입을 미리 명시해줌으로써 형변환을 하지 않아도 되게 하는 것이다. 제네릭에서는 참조형 타입(reference type) 을 의미하는 기호로 T 를 사용하는데 ‘type’의 첫 글자로 어떠한 참조형 타입도 가능하다는 것을 의미한다.

  • E : element, 요소 (주로 자바 컬렉션에 사용)
  • K : key, 키
  • V : value, 값
  • N : 숫자
  • S, U ,V : 두번째, 세번째, 네번째에 선언된 타입

기호의 종류만 다를 뿐 모두 임의의 참조형 타입을 의미한다.

class GenericDemo<T> {
    T info;
    void setInfo(T info) {
        this.info = info;
    }
    T getInfo() {
            return info;
    }
}

등장 배경

💡 1. 타입 안정성 (type-safety) 제공
💡2. 타입체크와 형변환을 생략 할 수 있게 해 줌 → 코드가 간결해짐

타입 체크 (compile-time type check)

type-safety를 높인다는 말은

  • 의도하지 않은 타입의 객체를 저장하는 것을 막고,
  • 저장된 객체를 꺼내올 때 원래의 타입과 다른 타입으로 형변환되어 발생할 수 있는 오류를 줄여준다는 말이다.

예를들어,

public class ElementarySchool {

	public int subjectCount;

	public ElementarySchool(int subjectCount) {
		this.subjectCount = subjectCount;
	}
}

public class ElementaryStudent {

	public ElementarySchool es;

	public ElementaryStudent(ElementarySchool es) {
		this.es = es;
	}
}

public class HighSchool {

	public int subjectCount;

	public HighSchool(int subjectCount) {
		this.subjectCount = subjectCount;
	}
}

public class HighSchoolStudent {

	public HighSchool hs;

	public HighSchoolStudent(HighSchool hs) {
		this.hs = hs;
	}
}

위 코드에서 ElementaryStudent 클래스와 HighSchoolStudent 는 같은 구조를 갖고 있는 코드로 중복이 발생하고 있다. 중복을 제거해 보면,

public class HighSchool {

	public int subjectCount;

	public HighSchool(int subjectCount) {
		this.subjectCount = subjectCount;
	}
}

public class ElementarySchool {

	public int subjectCount;

	public ElementarySchool(int subjectCount) {
		this.subjectCount = subjectCount;
	}
}

public class Student {

	public Object school;

	public Student(Object school) {
		this.school = school;
	}
}

이렇게 중복을 제거했을 때의 문제점은 타입 안정성이 보장되지 않는다는 것이다. 시간이 흘러흘러 어떤 목적으로 개발했는지 기억에서 희미해질 무렵에 저 클래스를 사용한다고 가정하면,

public class App {
	public static void main(String[] args) {
		Student student = new Student("아름초등학교");
		ElementarySchool es = (ElementarySchool) student.school;
		System.out.println(es.subjectCount);
	}
}

Student 클래스의 생성자는 Object 타입을 받아 모든 객체를 받을 수 있어, ElementarySchool 객체가 아닌 String 이 와도 컴파일 에러가 발생하지 않는다.

그러나 실행을 하면 RuntimeException이 발생한다.

이와 같은 에러를 type-safety 하지 않아 발생한 에러라고 한다.

자바는 컴파일 언어이다. 컴파일 언어의 기본은 모든 에러는 컴파일이 발생할 수 있도록 유도해야 한다는 것이다. 위와 같은 상황을 막기 위해 즉, 컴파일 상황에 타입체크를 할 수 있기 위해 제네릭이 등장하였다.

제네릭화

public class ElementarySchool {
    public int subjectCount;

    public ElementarySchool(int subjectCount) {
        this.subjectCount = subjectCount;
    }
}
public class HighSchool {
    public int subjectCount;

    public HighSchool(int subjectCount) {
        this.subjectCount = subjectCount;
    }
}

public class Student<T>{
    public T school;

    public Student(T school) {
        this.school = school;
    }
}

public class App {
    public static void main(String[] args) {

        Student<HighSchool> s1 = new Student<>(new HighSchool(3));
        HighSchool highSchool = s1.school;
        System.out.println(highSchool.subjectCount);

        Student<String> s2 = new Student<>("아름초등학교");
        String elementarySchool = s2.school;
        System.out.println(elementarySchool.subjectCount);  // 컴파일 에러

    }
}

제네릭화로 코드를 변경하면 컴파일단계에서 오류를 검출할 수 있다. (StringsubjectCount 필드가 없어서 오류 나옴)

제네릭을 사용하면

  • 컴파일 단계에서 오류가 검출된다.
  • 중복의 제거와 타입 안정성을 동시에 추구 할 수 있다.

컬렉션 클래스인 ArrayList 를 통해 예를 또한번 들어보겠다.

ArrayList<School> schoolList = new ArrayList<School>(); 
schoolList.add(new School());
schoolList.add(new Hakwon());   // 컴파일 에러

컬렉션 클래스 바로 뒤에 저장할 객체의 타입을 저장해주면 컬렉션에 저장할 수 있는 객체는 지정한 타입의 객체 (School) 뿐이라 해당 타입이 아닌 다른 객체를 저장하게 되면 컴파일 시 에러가 발생한다

또, 저장된 객체를 꺼낼 때 형변환할 필요가 없다. 이미 어떤 타입의 객체들이 저장되었는지 알기 때문이다.

ArrayList schoolList = new ArrayList();
schoolList.add(new School());
School s = (School)schoolList.get(0);
ArrayList<School> schoolList = new ArrayList<School>(); 
schoolList.add(new School());
School s = schoolList.get(0);

기본 데이터 타입

제네릭은 참조 데이터 타입에 대해서만 사용이 가능하다. 따라서 기본 데이터 타입을 데이터 객체로 포장해 주는 클래스인 래퍼 클래스(Wrapper class) 를 사용해야 한다.

ex.

Student<HighSchool, int> s1 = new Student<>(); → x

Student<HighSchool, Integer> s2 = new Student<>(); → o

primitive 타입은 왜 제네릭을 사용 못할까?

type Erasure 때문이다.

Erasure

타입 파라미터를 컴파일 타임에만 검사하고, 런타임시에는 해당 타입 정보를 알 수 없게 소거하는 것

public class ErasureTest {
   List<Integer> list = new ArrayList<>();
}

이 코드의 바이트 코드를 보면 다음과 같다.

// class version 58.0 (58)
// access flags 0x21
// signature <T:Ljava/lang/Object;>Ljava/lang/Object;
// declaration: ErasureTest<T>
public class ErasureTest {

  // compiled from: ErasureTest.java

  // access flags 0x1
  public <init>()V
   L0
    LINENUMBER 1 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    RETURN
   L1
    LOCALVARIABLE this LErasureTest; L0 L1 0
    // signature LErasureTest<TT;>;
    // declaration: this extends ErasureTest<T>
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x1
  // signature (TT;)V
  // declaration: void test(T)
  public test(Ljava/lang/Object;)V
   L0
    LINENUMBER 3 L0
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    ALOAD 1
    INVOKEVIRTUAL java/lang/Object.toString ()Ljava/lang/String;
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
   L1
    LINENUMBER 4 L1
    RETURN
   L2
    LOCALVARIABLE this LErasureTest; L0 L2 0
    // signature LErasureTest<TT;>;
    // declaration: this extends ErasureTest<T>
    LOCALVARIABLE test Ljava/lang/Object; L0 L2 1
    // signature TT;
    // declaration: test extends T
    MAXSTACK = 2
    MAXLOCALS = 2
}

INVOKESPECIAL java/lang/Object.<init> ()V

ArrayList 가 생성될 때 타입 정보가 없다. (제네릭이 사용하지 않고 raw type으로 생성해도 똑같음) ⇒ 타입 소거 (type Erasure)

자바는 왜 타입소거를 했을까?

java5에서 컴파일 된 코드를 java4에서도 실행시킬 수 있고 제네릭을 사용하지 않던 레거시 코드들도 무사히 잘 실행될 수 있게 하도록 즉, 하위 호환성 때문에 그러지 않았나 싶다.

타입 파라미터가 있는 클래스에서는 Erasure가 어떻게 일어날까?

public class ErasureTest<T> {
    private T data;

    public T getData() {
        return data;
    }

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

이 코드를 자바 컴파일러는 아래와 같이 변경한다.

public class ErasureTest {
    private Object data;

    public Object getData() {
        return data;
    }

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

타입 파라미터에 범위를 제한한다면?

public class ErasureTest<T extends Comparable<T>> {
    private T data;

    public T getData() {
        return data;
    }

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

아래와 같이 변경된다.

public class ErasureTest {
    private Comparable data;

    public Comparable getData() {
        return data;
    }

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

결론적으로 제네릭 타입이 특정 타입으로 제한되어 있을 경우 (T extends Comparable ) 해당 타입에 맞춰 컴파일 시 타입 변경이 발생하고 제한이 없을 경우 Object 타입으로 변경된다. 고로, primitive 타입을 사용 못하는 것도 기본 타입은 Object 클래스를 상속받고 있지 않기 때문이다. 그래서 Wrapper 클래스를 사용하여 Object 클래스를 상속받는 참조형으로 변경한 뒤 사용해야 하는 것이다.

제네릭 배열

제네릭 타입을 사용해서 배열을 생성하려면 다음과 같이 해야 한다.

  • T[] array = new T[size]; (x)
    • 컴파일 에러 발생 : Type parameter ‘T’ cannot be instantiated directly
  • T[] array = new Object[size]; (o)

new 연산자를 사용하면 동적 메모리 할당 영역인 heap 영역에 생성한 객체를 할당하는데, 제네릭은 컴파일 타임에 동작하는 문법으로 컴파일 타임에 T의 타입이 어떤 타입인지 알 수 없기 때문에 Object 타입으로 생성한 다음 타입 캐스팅을 해주어야 사용이 가능하다.

제네릭 static

  • static T value; (x)
    • static 변수에서는 제네릭 타입을 사용할 수 없다.
    • static 키워드를 사용하면 Test.value 처럼 객체에 종속되지 않고 클래스의 이름으로 접근하여 사용할 수 있다. 근데 제네릭 타입을 사용하게 되면 Test 클래스의 타입도 컴파일 타임에서는 정해져있지 않고 특정 객체에 종속되기 때문에(Test<Integer>, Test<String>, …) 사용 할 수 없다.
  • static T void test(); (o)
    • static 메소드에서는 제네릭을 사용할 수 있다.
      public class App {
          public static void main(String[] args) {
              ArrayList<String> list = new ArrayList<>();
              ArrayList<Boolean> list1 = new ArrayList<>();
              list.add("static 변수는 ");
              list1.add(false);
              list.add("static 메소드는 ");
              list1.add(true);
        
              printAll(list);
              printAll(list1);
          }
        
          public static <T> void printAll(ArrayList<T> list) {
              for (T t : list) {
                  System.out.println(t);
              }
          }
      }
    

    메소드의 경우 해당 값은 메소드 안에서 지역변수로 사용되기 때문에 사용할 수 있다.

생략

제네릭은 생략이 가능한데,

HighSchool h = new HighSchool(1);
Integer i = new Integer(5);
// 1
Student<HighSchool, Integer> s1 = new Student<>(h,i);
// 2
Student s2 = new Studnet(h, i);

hi 의 데이터 타입을 알고 있기 때문에 1번 코드를 2번 코드처럼 생략이 가능하다. (생성자의 매개변수를 통해 알 수 있기 때문)

메소드에서의 제네릭

public class HighSchool {
    public int subjectCount;

    public HighSchool(int subjectCount) {
        this.subjectCount = subjectCount;
    }
}

public class Student<T>{
    public T school;

    public Student(T school) {
        this.school = school;
    }
    
    // 메소드에 제네릭 사용
    public <U> void printSchool(U school) {
            System.out.println(info);
    }
}
public class App {
    public static void main(String[] args) {

        HighSchool h = new HighSchool(1);
        Integer i = new Integer(5);
        
        Student<HighSchool, Integer> s1 = new Student<>(h,i);
        s1.<HighSchool>printSchool(h);
        s1.printInfo(h);        // 생략 가능
    }
}

Bounded Type Parameter, 한정적 타입 매개변수

타입을 미정으로 해두고 인스턴스화가 진행될 때 타입을 지정하는 것이 제네릭이라고 했다. → 그렇다보니까 오만가지의 타입들이 들어올 수 있다는 단점이 있다.

자바에서는 extends 를 사용하여 제네릭으로 올 수 있는 데이터 타입을 특정 클래스의 자식으로 제한할 수 있다.

public class Student<T extends School>{
    public T school;

    public Student(T school) {
        this.school = school;
    }
}
public class HighSchool extends School{
    public int subjectCount;

    public HighSchool(int subjectCount) {
        this.subjectCount = subjectCount;
    }

    @Override
    public String getArea() {
        return "서울";
    }
}

public class App {
    public static void main(String[] args) {

        Student<HighSchool> s1 = new Student<>(new HighSchool(3));
        Student<String> s2 = new Student<String>();  // 컴파일 에러
    }
}

extends 키워드를 사용한 제네릭을 사용하면 School 클래스나 그 자식 외에 다른 클래스가 올 경우 컴파일 오류가 발생한다.

또, extends 는 상속(extends) 뿐만 아니라 구현(implements)관계에서도 사용이 가능하다.

interface School {
    String getArea();
}
public class HighSchool extends School{
    public int subjectCount;

    public HighSchool(int subjectCount) {
        this.subjectCount = subjectCount;
    }

    @Override
    public String getArea() {
        return "서울";
    }
}

public class App {
    public static void main(String[] args) {

        Student<HighSchool> s1 = new Student<>(new HighSchool(3));
        Student<String> s2 = new Student<String>();  // 컴파일 에러
    }
}

컬렉션 클래스인 ArrayList 를 통해 다시 한번 정리해보자

class Product{}
class Tablet extends Product{}
class Laptop extends Product{}

// Product 클래스의 자손 클래스를 저장할 수 있는 ArrayList 생성
ArrayList<Product> list = new ArrayList<Product>();
/* 컴파일 에러 발생하지 않음 */
list.add(new Product());
list.add(new Tablet());
list.add(new Laptop());

Product p = list.get(0)        // 형변환 필요 x
Tablet t = (Tablet)list.get(1) // 형변환 필요 o

/* 다형성 적용 */
List<Tablet> tabletList = new ArrayList<Tablet>(); // 허용

ArrayList<Product> list = new ArrayList<Tablet>(); // 허용 x
  • ArrayListProduct 타입의 객체를 저장하도록 지정하면 자손인 TabletLaptop 타입의 객체도 저장할 수 있다.
    • 꺼내올 때는 형변환이 필요하다.
  • 다형성을 적용해서 List<Tablet> tabletList = new ArrayList<Tablet>(); 같은 코드는 가능하지만, ArrayList<Product> list = new ArrayList<Tablet>(); 와 같은 코드는 허용하지 않는다.

      public static void printAll(ArrayList<Product> list) {
          for (Product p : list) {
              System.out.println(p);
          }
      }
        
      public static void main(String[] args) {
          ArrayList<Product> list = new ArrayList<Product>();
          ArrayList<Tablet> list2 = new ArrayList<Tablet>();
    
          printAll(list);
          printAll(list2);       // 컴파일 에러 발생
      }
    

    메서드의 매개변수 타입이 ArrayList<Product> 로 선언된 경우 ArrayList<Product> 타입의 객체만 매개변수로 사용할 수 있다. 그렇지 않으면 컴파일 에러가 발생한다.

와일드 카드

보통 제네릭에서는 단 하나의 타입을 지정하지만, 와일드 카드는 하나 이상의 타입을 지정하는 것을 가능하게 해 준다.

Ex.1

방금 위에서 봤던 printAll(ArrayList<Product>list) 메소드는 ArrayList<Product> 타입의 객체만 매개변수로 사용할 수 있었다. 그런데, 다음과 같이 바꾸면 Tablet , Laptop 클래스의 객체도 사용이 가능하다.

public static void printAll(ArryaList<? extends Product> list) {
    for (Product p : list) {
            System.out.println(p);
    }
}

어떤 타입 (‘?‘)이 있고 그 타입이 Product 의 자손이라고 선언하면 ArrayList<Tablet>, ArrayList<Laptop>를 매개변수로 넘겨 줄 수있다. TabletLaptop 모두 Product의 자손이기 때문이다.

Ex.2

  • ① : List 인터페이스를 구현한 컬렉션을 매개변수 타입으로 정의하고, 그 컬렉션에는 T 라는 타입의 객체를 저장하도록 선언되어 있음
  • ② : T는 Comparable 인터페이스를 구현한 클래스의 타입이어야 하며,
  • ③ : 또는, 그 조상의 타입을 비교하는 Comparable이어야 한다.

예를들어, A클래스가 B클래스의 자손이라면 <? super T>는 A클래스 타입이나 B 클래스 타입이 가능하다.

💡 <?> : 모든 종류의 클래스나 인터페이스 타입
💡 <? extends T> : T 또는 T의 자손 타입
💡 <? super T> : T 또는 T의 조상 타입