Java Generic을 이해해보자!

L-cloud·2023년 8월 15일
0

스터디

목록 보기
3/5
post-thumbnail

저는 Generic이 처음에 그냥 <T>와 같은 형태인줄만 알았습니다. List<String> 이런 것을 보며 이제 왜 제네릭의 예시인지 이해하지 못 했습니다. 그래서 공부를 하며 Generic을 이해하기 위한 여정을 기록해보았습니다. 아래 순서를 따라 가며 Generic을 이해해 봅시다!

본 글에서 알아볼 내용

  • 제네릭 정의와 장점 및 기능
  • 제네릭과 공변성, 반공변성, 무공변성
  • 힙 오염과 제네릭과의 관계

제네릭 이란?

간결하게 말하면 타입을 파라미터화 한 것입니다.

List<String> 을 예시로 들어볼까요? 아래 그림은 Java8 docs에 있는 List 인터페이스에 대한 설명입니다. 즉 타입을 외부에서 지정해서 넘겨받아서 사용한다는 의미입니다.

출처

만약 데이터 타입을 외부에서 지정할 수 없다면 어떻게 될까요? String , Integer, Charactor 등.. 사용할 타입을 모두 선언하고 작성해야겠죠? 만약 내가 만든 클래스를 List에 담고 싶다면? 작성해야 할 코드가 한두줄이 아니겠죠? 그런데 이는 Object를 사용하면 똑같은 효과를 내는 것 아닌가요? Object 는 모든 클래스의 부모니 원시 타입을 제외한 모든 타입을 받을 수 있잖아요! (Generic도 원시 타입을 허용하지 않습니다) 왜 굳이 Genric을 사용할까요? 코드로 바로 살펴봅시다.

장점 1. 컴파일 타입에서 오류 체크가 가능하다.

Object 타입으로 선언하면 실제로 animalsMonkeyDog 클래스 배열을 모두 넣을 수 있습니다.

하지만 아래의 경우처럼 animals 배열에 Monkey 클래스를 넣었는데 개발자가 착각하고 Dog 타입으로 형 변환을 시도할 경우 런타임 에러가 발생합니다.

class Monkey {}
class Dog {}

class AnimalFamily{
    // 모든 클래스 타입을 받기 위해 최고 조상인 Object 타입으로 설정
    private Object[] animals;

    public AnimalFamily(Object[] animals) {
        this.animals= animals;
    }

    public Object getAnimal(int index) {
        return animals[index];
    }
}

public static void main(String[] args) {
    Monkey [] arr = {new Monkey (),new Monkey ()};
    AnimalFamily animalfamily = new AnimalFamily(arr);

    Monkey monkey= (Monkey) animalfamily.getAnimal(0);
    Dog dog= (Dog) animalfamily.getAnimal(1);  // 런타임 에러됨... 컴파일 에러로 못 잡음
}

하지만 Generic의 경우 이를 컴파일 에러로 잡아줍니다.

class AnimalFamily<T> {
    private T[] animals;

    public AnimalFamily(T[] animals) {
        this.animals= animals;
    }

    public T getAnimal(int index) {
        return animals[index];
    }
}
public static void main(String[] args) {
    Monkey [] arr = {new Monkey (),new Monkey ()};
    AnimalFamily animals = new AnimalFamily(arr);

    Monkey monkey= (Monkey) animals.getAnimal(0);
    Dog dog= (Dog) animals.getFruit(1); // 컴파일 에러 
}

장점 2. 불필요한 캐스팅 없앰

이는 아래 코드를 비교하면 이해가 쉽게 됩니다.

// Object인 경우
Monkey [] arr = { new Monkey (), new Monkey (), new Monkey () };
AnimalFamilybox animals = new AnimalFamily<>(arr);

// 가져온 타입이 Object 타입이기 때문에 일일히 다운캐스팅을 해야함 - 쓸데없는 성능 낭비
Monkey mokey1 = (Monkey) animals.getFruit(0);
Monkey mokey2 = (Monkey) animals.getFruit(1);
Monkey mokey3 = (Monkey) animals.getFruit(2);

// Generic인 경우
Monkey [] arr = { new Monkey (), new Monkey (), new Monkey () };
AnimalFamilybox animals = new AnimalFamily<>(arr);

// 미리 제네릭 타입 파라미터를 통해 형(type)을 지정해놓았기 때문에 별도의 형변환은 필요없다.
Monkey mokey1 =  animals.getFruit(0);
Monkey mokey2 =  animals.getFruit(1);
Monkey mokey3 =  animals.getFruit(2);

기능

물론 복수개나 중첩도 가능하고 제네릭 타입을 받는 클래스를 타입으로 받을 수도 있습니다.

// 복수개
class AnimalFamily<T, E> {
 ...
} 
// 제네릭 타입을 받는 클래스를 타입으로 받음
ArrayList<LinkedList<String>> l = new ArrayList<LinkedList<String>>();

제네릭 인터페이스도 가능합니다. 물론 implements 한 클래스에서도 오버라이딩한 메서드를 제네릭 타입에 맞춰서 똑같이 구현해 주어야 합니다. 좋은 예시는 아니지만 아래와 같은 코드를 생각할 수 있습니다.

import java.util.*;
public class Main {
    public static void main(String[] args) {
        List<a> l = new LinkedList<>();
        l.add(new C());
        l.add(new B());
        l.get(0).fun("a"); //class java.lang.String
        l.get(1).fun(1); // class java.lang.Integer
    }

}

interface a <T>{
    void fun(T var);
}
class C<T> implements a<T>{
    @Override
    public void fun(T var) {
        System.out.println(var.getClass());

    };
}
class B<T> implements a<T>{
    @Override
    public void fun(T var) {
        System.out.println(var.getClass());
    };
}

제네릭 메서드

메서드 선언부에 가 선언되어 독립적으로 타입을 할당받는 메소드를 의미합니다. 아래는 예시 코드입니다.

class AnimalFamily<T> {
		...
		// AnimalFamily<T>의 T 타입을 반환!
    public T getAnimal(int index) { 
        return animals[index];
    }
		// 독립적인 타입 할당!
	 public static <T> T staticTest(T x){
				System.out.println(x);
				return x;
	 }		
}

AnimalFamily.staticTest("TT"); // class java.lang.Integer
AnimalFamily.staticTest(11); // class java.lang.String

Generic과 extends

제네릭의 범위를 한정시킬 수는 없을까요? AnimalFamily 클래스에는 모든 타입이 다 들어올 수 있습니다. Animal혹은 그 하위 클래스들만 인자로 받을 수는 없을까요? extends 키워드를 사용하면 됩니다!

public class Main {
    public static void main(String[] args) {
    	Dog[] d = {new Dog(),new Dog()};
    	Monkey[] m = {new Monkey(), new Monkey()};
    	Object[] o = {new Object()};
    	AnimalFamily<Dog> af1 = new AnimalFamily<>(d);
    	AnimalFamily<Monkey> af2 = new AnimalFamily<>(m);
    	AnimalFamily<Object> af3 = new AnimalFamily<>(o); ///에러!!!!
    }
}

class AnimalFamily<T extends Animal> {
    private T[] animals;
    

    public AnimalFamily(T[] animals) {
        this.animals= animals;
    }

    public T getAnimal(int index) {
        return animals[index];
    }
}
class Animal {}
class Monkey extends Animal {}
class Dog extends Animal {}

이러면 재귀적 타입 한정도 가능합니다. 만약 Myclass도 제네릭 타입을 받는 클래스라면 어떻게 될까요? <E extends Myclass> 가 아니라 <E extends Myclass<E>> 이런 식의 표현이 가능합니다. 주로 Comparable과 함께 사용하는데 이 글을 읽어보시면 금방 이해하실 수 있습니다!

하지만 이는 한계가 있습니다. 예를 들어서 아래와 같은 코드를 봅시다. 해당 코드는 잘 동작할까요?

public static void printFirstAnimal(AnimalFamily<Animal> af){
        System.out.println(af.getAnimal(0));
    }
  ...
  printFirstAnimal(af1); // 잘 동작 할까?
  
  

정답은 컴파일 에러가 발생합니다! 왜 그런지 공변성이라는 키워드를 통해 알아봅시다!

제네릭과 공변성

공변(영어: Covariance)할 때, S가 T의 서브타입이라면 (S <: T), I<S>I<T>의 서브타입이다. (I<S> <: I<T>)
반공변(영어: Contravariance)할 때, S가 T의 서브타입이라면 (S <: T), I<T>I<S>의 서브타입이다. (I<T> <: I<S>)
이변(영어: Bivariance)할 때, 공변하면서도 반공변한다.
무공변(영어: Invariant)하다면 공변하지도 반공변하지도 않는다.
출처

조금 어렵죠? 코드로 살펴보면 이해가 쉽게 가리라 생각합니다.

// 공변성
Object[] Covariance = new Integer[10];

// 반공변성
Integer[] Contravariance = (Integer[]) Covariance;

제네릭은 무공변이기에 위에서 본 printFirstAnimal(AnimalFamily<Animal> af) 함수에 AnimalFamily<Dog>를 인자로 넣어주면 오류가 발생합니다! 그렇다면 어떻게 해야할까요? printFirstAnimal 함수에서 인자를 Object로 받거나 AnimalFamily<Dog>, AnimalFamily<Monkey>를 받는 함수를 따로 생성해야 할까요? 아닙니다! 제네릭은 와일드 카드를 통해서 공변과 반공변성을 주입할 수 있습니다.

와일드 카드

  • 상한 경계 와일드카드 <? extends T> : 공변성 적용 (상위 클래스를 못 들어오게 제한)
  • 하한 경계 와일드카드 <? super T> : 반공변성 적용 (하위 클래스를 못 들어오게 제한)
  • <?>는 제한없이 모든 타입이 들어오게 해줍니다.

그럼 아래처럼 코드를 수정한다면?! Animal의 하위 타입을 인자로 받는 AnimalFamily 객체가 들어올 수 있도록 해주겠죠!

public static void printFirstAnimal(AnimalFamily<? extends Animal> af){
        System.out.println(af.getAnimal(0));
    }
  ...

사실 와일드 카드를 사용해서 데이터를 꺼내거나 넣는 행위는 어려운 부분이 많습니다. "PESC 공식" 이라는 키워드로 학습을 해보시는 것을 추천 드립니다. (물론 해당 공식에도 여러 의견이 있습니다.)

추가적으로 와일드 카드는 제네릭 클래스를 만들 때 사용하는 것이 아니라, 이미 만들어진 제네릭 클래스에 타입을 지정할 때 사용하는 것입니다. 예를 들어 class AnimalFamily<? extends Animal>{}이런 코드는 에러를 발생시킵니다.

주의점

제네릭 타입의 객체는 생성이 불가능 하며 static 멤버나 메소드에 제네릭 타입이 올 수 없습니다. 제네릭 메서드는 독립적인 타입을 받아오지만 단순 static 메서드는 그렇지 않는 다는 점과 static의 특성을 생각하면 납득이 가죠?

class AnimalFamily<T> {
    private T[] animals;
    
    public static T getName(T n) { ... } //Error!! 
	//제네릭 메서드가 아님을 생각해보세요! 제네릭 메서드는 static과 함께 사용이 가능합니다.

또한 기본적으로 제네릭 클래스 자체를 배열로 만들 수는 없습니다.

ArrayList<Integer>[] arr = new ArrayList<>[10]; // Error
//Incorrect number of arguments for type ArrayList<E>; it cannot be parameterized with arguments <>

ArrayList<Integer>[] arr = new ArrayList[10]; // 이거는 가능! 
// ArrayList<Integer>로만 채워진 배열을 만들겠다!

마지막으로 제네릭이 등장하기 전 버전와 호환성을 유지하기 위해 제네릭은 컴파일 시 타입이 사라지게 됩니다. 이를 타입 소거라고 합니다. 코드로 확인해 봅시다.

package com.test;

public class Main {
    public static void printFirstAnimal(AnimalFamily<? extends Animal> af){
        System.out.println(af.getAnimal(0));
    }
    public static void main(String[] args) {
        Dog[] d = {new Dog(),new Dog()};
        AnimalFamily<Dog> af1 = new AnimalFamily<>(d);
        TestInteger a = new TestInteger(1);
        TestString b = new TestString("a");
    }
}

class TestInteger {
    TestInteger(Integer a){}
}
class TestString{
    TestString(String a){}
}

class AnimalFamily<T> {
    private T[] animals;


    public AnimalFamily(T[] animals) {
        this.animals= animals;
    }

    public T getAnimal(int index) {
        return animals[index];
    }
}
class Animal {}
class Dog extends Animal {}

해당 코드를 컴파일 한 뒤 바이트 코드를 보게되면 정말 Object타입으로 변경된 것을 볼 수 있습니다.

javap -c Main
Warning: File ./Main.class does not contain class Main
Compiled from "Main.java"
public class com.test.Main {
  public com.test.Main();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void printFirstAnimal(com.test.AnimalFamily<? extends com.test.Animal>);
    Code:
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: aload_0
       4: iconst_0
       5: invokevirtual #3                  // Method com/test/AnimalFamily.getAnimal:(I)Ljava/lang/Object;
       8: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
      11: return

  public static void main(java.lang.String[]);
    Code:
       0: iconst_2
       1: anewarray     #5                  // class com/test/Dog
       4: dup
       5: iconst_0
       6: new           #5                  // class com/test/Dog
       9: dup
      10: invokespecial #6                  // Method com/test/Dog."<init>":()V
      13: aastore
      14: dup
      15: iconst_1
      16: new           #5                  // class com/test/Dog
      19: dup
      20: invokespecial #6                  // Method com/test/Dog."<init>":()V
      23: aastore
      24: astore_1
      25: new           #7                  // class com/test/AnimalFamily
      28: dup
      29: aload_1
      30: invokespecial #8                  // Method com/test/AnimalFamily."<init>":([Ljava/lang/Object;)V
      33: astore_2
      34: new           #9                  // class com/test/TestInteger
      37: dup
      38: iconst_1
      39: invokestatic  #10                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      42: invokespecial #11                 // Method com/test/TestInteger."<init>":(Ljava/lang/Integer;)V
      45: astore_3
      46: new           #12                 // class com/test/TestString
      49: dup
      50: ldc           #13                 // String a
      52: invokespecial #14                 // Method com/test/TestString."<init>":(Ljava/lang/String;)V
      55: astore        4
      57: return
}

기존 코드를 살짝 변경해서 다시 바이트 코드를 확인해 봅시다. class AnimalFamily<T extends Animal>로 변경 하였습니다. 변경된 바이트 코드만 살짝 확인해봅시다.

...
   5: invokevirtual #3                  // Method com/test/AnimalFamily.getAnimal:(I)Lcom/test/Animal;
...
      25: new           #7                  // class com/test/AnimalFamily
      28: dup
      29: aload_1
      30: invokespecial #8                  // Method com/test/AnimalFamily."<init>":([Lcom/test/Animal;)V

extends를 사용하면 해당 클래스로 변경됨을 확인할 수 있습니다. 그런데 이러한 타입 소거가 무슨 영향을 준다는 것일까요? 이는 바로 힙 오염과 관련이 있습니다.

번외로 컴파일시 Bridge methods를 생성하기도 합니다. 링크1 링크2

힙 오염

예제 코드에 아래 내용을 추가해보았습니다. Object로 형 변환을 한 뒤 어떤 로직을 수행하고 실수로 AnimalFamily<Dog>가 아닌 AnimalFamily<Monkey>로 형 변환을 해주었습니다. 하지만 이는 컴파일러가 잡아주지 못 합니다!

...
        Dog[] d = {new Dog(),new Dog()};
        AnimalFamily<Dog> af1 = new AnimalFamily<>(d);
		... 
        Object obj = af1;
		...
        AnimalFamily<Monkey>af2 = (AnimalFamily<Monkey>) obj;
        Monkey m = af2.getAnimal(0); // RuntimeError!!!
...

컴파일러는 캐스팅하였을 때 변수에 저장 가능성 유무를 검사할 뿐이기 때문입니다. 제네릭은 컴파일 타임에 Object로 변환이 되기에 컴파일러가 이를 잡아주지 못하는 것이죠!

이렇게 Heap에 있는 데이터가 적절하지 않은 데이터를 참조하여 문제가 생기는 경우를 힙 오염이라고 합니다. Collections.checkList를 사용하면 컴파일러의 도움을 받을 수 있습니다!

참고링크 : https://inpa.tistory.com/

profile
내가 배운 것 정리

1개의 댓글

comment-user-thumbnail
2023년 8월 15일

감사합니다. 이런 정보를 나눠주셔서 좋아요.

답글 달기