본문 바로가기

JAVA

제네릭(Generic)

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

 

  • 제네릭이란?

제네릭은 '클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법' 입니다.

 

그러면 왜 클래스 내부에서 사용할 데이터 타입을 밖에서 지정해주자는 것인가?

아래 상황을 살펴봅시다.

 

자바를 좋아하는 촉이 좋은 '감자바'씬느 친구 '좀해조'씨로부터 두개의 숫자를 출력하는 프로그램을 만들어 달라는 요청을 받았습니다. 그래서 감자바씨는 흔쾌히 만들어주겠다고 하며 두팔을 걷어부치고 코딩을 했습니다.

public class GenericExample {
    public static void main(String[] args) {
        cal cp = new calc(13, 17);
        cp.print();
    }
}

class calc {
    private int num1;
    private int num2;
    calc() {  }
    calc(int num1, int num2) {
        this.num1 = num1;
        this.num2 = num2;
    }
    public void print() {
        System.out.println("num1 : " + num1);
        System.out.println("num2 : " + num2);
    }
}

 

개발을 완료 후 김자바씨는 친구에게 소스를 보내줬습니다.

얼마 후 친구 좀해조씨가 에러가 난다고 연락이 왔습니다.

그럴리 없다고 난색을 표하는 감자바씨

알고보니 친구가 소스를 아래와 같이 고쳐서 돌리고 있었습니다.

 

public class GenericExample {
    public static void main(String[] args) {
        cal cp = new calc(13.2, 17.3);
        cp.print();
    }
}

class calc {
    private int num1;
    private int num2;
    calc() {  }
    calc(int num1, int num2) {
        this.num1 = num1;
        this.num2 = num2;
    }
    public void print() {
        System.out.println("num1 : " + num1);
        System.out.println("num2 : " + num2);
    }
}

 

좀해조씨 얘기를 들어보니

자기는 소수점도 출력해야 하는데 감자바 네 녀식이 정수만 출력할 수 있게 해놨다 이거였습니다.

 

부탁하는 입장에 짜증을 내자 화가난 감자바씨

다시 마음을 추스리고 어덯게 해야할지 고민에 빠집니다.

 

그러다 수업시간에 제네릭에 대해 얼핏 들은걸 기억해낸 김자바씨는 급히 소스 수정에 들어갑니다.

 

최종 수정본은 아래와 같습니다.

public class GenericExample {
    public static void main(String[] args) {
        cal<Integer> cp1 = new calc<Integer>(13, 17);
        cal<Double> cp2  = new calc<Double>(13.2, 17.3)
        cp1.print();
        cp2.print();
    }
}

class calc<T> {
    private T num1;
    private T num2;
    calc() {  }
    calc(T num1, T num2) {
        this.num1 = num1;
        this.num2 = num2;
    }
    public void print() {
        System.out.println("num1 : " + num1);
        System.out.println("num2 : " + num2);
    }
}

 

이제 출력하고 싶은게 있으면 3, 4번 라인을 수정해서 원하는 타입을 넣으면 뭐든 출력할 수 있을거라고 전해주었습니다.

 

우선 그 얘기부터 해봅시다.
변수라는 개념이 생겨난 배경은 상수를 표현하기 위해서는 상수가 달라질 때마다 계속 상수를 추가해서 서야만 했을 것입니다. 그래서 나온 것이 변수입니다.

변수, 수가 변한다는 것입니다. 공간은 하나인데 그 공간은 다양한 수가 공유를 하며 이 값도 넣고 저 값도 넣고 그렇다 보니 너무 중구난방이라 일정한 타입의 개념으로 묶어 변수를 만들었습니다.

그것이 정수형은 int, 실수형은 float, 문자열은 String .. 등의 타입으로 구분한 것입니다.

그런데 이렇게 만들어 놓고 보니, 가단한 예시로, 성정적리를 할 때
과목수가 많아지면 int kor, eng, math, history .... 너무 많아지더라는 것이다.

행여나 나중에 과목이라도 하나 추가가 된다면 총점을 구하고 평균을 구하는 곳에 생각할 부분이 너무 많아지고 관리가 힘들고 소스는 길어지는 문제가 있는 것입니다.
그래서 나온 개념이 바로 배열입니다.

동일한 자료형에 대해서 하나의 변수로 묵어 관리하는 것.
그것이 바로 배열입니다.
int[] subjectScore = new int[10];
이렇게 10과목을 선언하고, 총점을 구할 때도
kor + eng + math.. 이것이 아닌 for (int i = 0; i < subjectScore.length; i++) {sum += subjectScore[i]; }
이런 식으로 과목이 몇개가 추가되어도 간단하게 수정할 수 있게 된 것입니다.


하지만 또 고민이 생겼습니다.
이렇게 과목별 점수를 관리하는 건 배열이 생겨서 쉬워졌는데..
특정 학생 단위로 묶어서 과목별 점수와 총점 평균을 관리할려다 보니
평균에서 실수형 자료형이 나오면서 하나의 배열로 묶을 수 없게 된것입니다.
심지어는 학생 단위로 묶으면 그 학생에 대한 기본 정보도 있어야 누구의 점수인지 구별을 할텐데...
그래서 나온 개념이 자바의 클래스입니다.

클래스는 서로 다른 자료형에 대해서 (+ 그 자료를 조작하는 메소드도) 하나의 묶음으로 관리할 수 있는 개념인 것입니다. 그래서 이제 클래스라는 강력한 개념을 갖고 일상생활에서의 문제를 해결할 수 있게 되었는데,
자꾸 쓰다보니 또 뭔가 불편해 진 것입니다.

뭔가 불편한가... 고민을 해봤더니 이게 윈결 소스가 계속 중복되고 있던 것입니다.
그것도 자료형이 다르다는 이유 하나만으로.

다시 위에 작성한 감자바씨의 최초 소스를 봅시다.
int라는 자료형에서 double이라는 자료형으로 다루는 데이터 타입이 바뀌면
만약 제네릭이라는 개념이 없었다면 실수형에 대한 처리를 하는 부분을 어떻게든 따로 하나 더 만들었어야 했을 것입입니다. 
캐스팅을 한다면? 이라고 생각할지 모르겠지만, 그건 데이터 손실을 가져오기 대문에 추천할만한 방버은 아닙니다.

즉, 제네릭도 "중복되는 소스를 하나로 묶어 소스코드의 재사용성을 더욱 극대화하기 위해" 생겨난 것입니다.

 

제네릭을 사용하는 사용 이유에는 흔히 알고있는 컴파일 타임에 타입체크를 하기 위함이나 타임 캐스팅을 제거하여 프로그램 성능 향상을 위해서 입니다.

하지만 보다 궁긍적인 목적은 중복코드의 제거에 있다고 생각합니다.

 

예를 들어 다음과 같이 List에 담긴 내용을 모두 출력하는 메소드를 구현한다고 생각해봅시다.

list에 담긴 요소의 타입이 정수인 경우 다음과 같이 만들어 볼 수 있습니다.

public static void printAllIntegers(List list) {
    for (int i = 0; i < list.size(); i++) {
        System.out.println((int)list.get(i));
    }
}

 

list에 담긴 요소의 타입이 문자열인 경우 다음과 같이 만들어 볼 수 있습니다.

public static void printAllIntegers(List list) {
    for (int i = 0; i < list.size(); i++) {
        System.out.println((String)list.get(i));
    }
}

 

각 메소드의 구현부를 비교해보면 타입 캐스팅 부분을 제외하고 모두 동일합니다.

만약 매개변수로 전달 받은 list에 담긴 요소가 기대하지 않은 타입의 요소가 담겨 있다면 런타임에 예외가 발생할 것입니다.

 

첨언이지만, List클래스의 경우 제네릭을 지원합니다. 하지만 위에 작성한 예제는 제네릭을 사용하지 않고
구현했고, 실제로도 동작하는 것을 확인할 수 있습니다.

이렇게 제네릭을 지원하지만 제네릭을 사용하지 않고 클래스 자체를 사용하는 경우
raw type(로 타입)을 사용했다고 합니다.

앞으로 정리하겠지만 raw type은 되도록 사용하지 말아야 합니다.
사용할 경우 앞서 제네릭의 강력한 장점인 컴파일 타임에 타입 체클르 하는 것과 같이 타입 캐스팅을 
하지 않음으로 얻는 이득을 얻지 못하기 때문입니다.

그럼 왜 자바를 만든 사람은 제네릭을 만들었으면서 사용을 강제하지 않고 raw type도 지원하도록 했을까요?
그건 아마다 자바 언어 개바자들이 하위 호환성을 유지하기 위해서 입니다.

제네릭은 JDK5에 추가된 개념인데 사용을 강제하도록 하면 이전 버전에서는 사용할 수 없기 떄문입니다.
마지막으로 살펴볼 Erasure도 하위 호환성을 유지하기 위한 내용입니다.

 

  • 제네릭 사용법

제네릭은 클래스, 인터페이스 그리고 메소드에 사용할 수 있습니다. 이 때 중요한 것은 매개변수로 타입을 전달할 수 있다는 것입니다.

이것을 흔히 타입 파라미터라고 부르고 타입 파라미터의 타입을 작성을 할 떄는 각 괄호 <>를 이용합니다.

문자열을 요소로 가지는 리스트를 정의하고 값을 출력하는 예를 살펴보자.

 

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

public class Exam_002 {
    public static void main(Stringp[] args) {
        List without_generic = new ArrayLIst();
        without_generic.add("제네릭을 사용하지 않은 로 타입 리스트");
        
        List<String> with_generic = new ArrayList<>();
        with_generic.add("제네릭을 사용한 리스트");
        
        /*
        객체를 생성할 떄 아래와 같이 구체적인 타입을 작성해야 하지만 생략 가능합니다.
        ListL<String> with_generic = new ArrayList<String>();
        
        이렇게 제네릭을 사용할 때 구체적인 타입 생략이 가능한 것을 다이아몬드 연산자라고도 합니다.
        */
        
        // 출력
        String without_generic_String = (String)without_generic.get(0);// 타입 캐스팅이 필요합니다.
        String with_generic_String    = with_generic.get(0);  // 타입캐스팅이 필요하지 않습니다.
    }
}

제네릭을 사용한다고 하면 위와 같이 각괄호 안에 사용할 타입을 작성해서 사용하면 됩니다.

 

이번엔 컴파일 타입을 체크 한다는 것이 무엇인지 알아봅시다.

만약 타입 파라미터와 맞지 않는 타입의 값을 사용하려 한다면 다음과 같은 메시지를 보여줍니다.

기억해야하는 것은 '컴파일 타임'에 타입 체크를 한다는 것입니다.

 

제네릭을 사용하는 것은 알겠는데, 어떻게 만들어져 있길래 사용할 수 있는건지에 대해서 이야기해 보겠습니다.

직접 제네릭을 사용한 클래스를 만들어 봅시다.

다음은 타입 매개변수 타입의 값을 하나 저장할 수 있는 클래스를 선언하고 사용하는 예제입니다.

public class Exam_004 {

    // T 타입 value를 저장할 수 있는 클래스
    static class MyGenericClass<T> {
        T value;
        
        MyGenericClass(T value) {
            T value;
            
            MyGenericClass(T value) {
                this.value = value;
            }
            
            public T getValue() {
                return value;
            }
            
            public void setValue(T value) {
                this.value = value;
            }
        }
        
        public static void main(String[] args) {
            MygenericClass<String> mgc_String = new MyGenericClass<>("사과");
            System.out.println(mgc_String.getValue());
            
            mgc_String.setValue("자몽");
            System.out.println(mgc_String.getValue());
        }
    }
}

[출력 결과]
사과
자몽

처음 보면 다소 생소할 수 있지만, 차분히 보면 그리 어렵지 않습니다.

6라인에서 MyGeneric 클래스를 보면 지금까지 정의했던 클래스와 다른점은 <T>라는 부분이 추가된 것입니다.

이 내용을 작성 함으로 인해 MyGeneric이라는 클래스 내부에서 T라는 것을 사용할 수 있게 됩니다.

그리고 이 T는 이 클래스 내부에서 '타입 매개변수'를 대표한느 값으로 사용됩니다.

 

그럼 왜 하필 T 일까?

사실 어던 문자를 사용해도 상관은 없습니다.

심지어 <MyTypeParameter>라고 해도 잘 동작합니다.

물론 이렇게 정의했을 경우, 너무나 당연하게도, 위에서 T라고 쓴 부분을 모두 myTypeParameter 라고 고쳐줘야 합니다.

물론 이렇게 정의 했을 경우, 이유는 흔히 컨벤션이라고 하는 우리들의 사이의 약속이기 때문입니다.

되도록 컨벤션을 지켜 코드를 작성하는게 서로 이해하기 쉽기 때문에 특별한 경우가 아니라면 지켜주는게 좋습니다.

 

만약 타입 파라미터가 두 개이상일 경우 T이외에 R, S, E, K, V, N, U 등의 문자를 많이 사용하고 콤마(,)로 구분합니다.

보통 T(type), R(return type), S(String), E(Element), K(Key), V(Value), N(Number)의 의미로 사용하고 있습니다.

 

public class Exam_005 {
    static class MyGenericClass<T, M> {
        T value_1;
        M value_2;
        
        public T getValue_1() {
            return value_1;
        }
        
        public void setValue_1(T value_1) {
            this.value_1 = value_1;
        }
        
        public M getValue_2() {
            return value_2;
        }
        
        public void setValue_2(M value_2) {
            this.value_2 = value_2;
        }
    }
    
    public static void main(String[] args) {
        MyGenericClass<String, Integer> mgc = new MygenericClass<>();
        mgc.setValue_1("사과");
        mgc.setValue_2(1000);
        System.out.println(mgc.getValue_1() + " 한 개 " + mgc.getValue_2() + "원");
    }
}

[출력]
사과 한개 1000원

 

  • 제네릭 메소드 만들기

앞서 제네릭은 클래스와 인터페이스 그리고 메소드에서 사용할 수 있다고 했는데, 이번엔 메소드에서 사용하는 방법을 

알아 볼려고 합니다.

 

제네릭 메소드에 대해 정의하자면, 파라미터와 그 반환 타입으로 제네릭 타입을 사용하는 것을 말합니다.

다음은 타입 파라미터를 하나 전달 받아 그 값을 그대로 반환하는 코드입니다.

 

public class Exam_006 {
    
    public static <T> T myGenericTest(T t) {
        return t;
    }
    
    public static void main(String[] args) {
        System.out.println(myGenericTest("자몽"));
        System.out.println(myGenericTest(1500));
    }
}

[출력]
자몽
1500

메소드에서 사용할 경우 클래스에서 사용할 때와 다른 부분이 있습니다.

앞서 클래스에서 사용할 예시에서 getter, setter에서는 볼수 없었던 3라인에 작성된 <T>가 그렇습니다.

 

제네릭 클래스에서는 해당 클래스 내부에서 사용할 타입 파라미터가 무엇인지 알려주기 위해 class를 선언할 때 알려주었다면 제네릭 메소드에서는 메소드를 정의할 때, 해당 메소드 내부에서 사용할 타입 파라미터가 무엇인지 알려주기 위해 메소드를 정의할 때 3라인 처럼 먼저 나열해주고 사용해야 합니다. 

즉, 리턴 타입을 명시하기 전에 작성되어야 합니다.

 

이번에 처음에 작성했떤 List 내용을 모두 출력하던 메소드를 개선해봅시다.

개선하기 전의 모습은 다음과 같습니다.

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

public class Exam_001 {

    public static void printAllIntegers(List list) {
        for (int i = 0; i < list.size(); i++) {
            System.out.println((int)list.get(i));
        }
    }
    
    public static void printAllIntegers(List list) {
        for (int i = 0; i < list.size(); i++) {
            System.out.println((String)list.get(i));
        }
    }

    public static void main(String[] args) {
        List myList_Integers = new ArrayList();
        myList_Integers.add(1);
        myList_Integers.add(2);
        myList_Integers.add(3);
        
        List myList_Strings = new ArrayList();
        myList_Strings.add("가");
        myList_Strings.add("나");
        myList_Strings.add("다");
        
        printAllIntegers(myList_Integers);
        printAllStrings(myList_Strings);
    }
}

[출력]
1
2
3
가
나
다

 

이것을 제네릭 메소드로 구현하면 다음과 같이 작성해 볼 수 있습니다.

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

public class Exam_007 {

    public static <T> printAll(List<T> list) {
        for (T t : list) {
            System.out.println(t);
        }
    }

    public static void main(String[] args) {
        List<Integer> myList_Integers = new ArrayList<>();
        myList_Integers.add(1);
        myList_Integers.add(2);
        myList_Integers.add(3);
        
        List<String> myList_Strings = new ArrayList<>();
        myList_Strings.add("가");
        myList_Strings.add("나");
        myList_Strings.add("다");
        
        printAll(myList_Integers);
        printAll(myList_Strings);
    }
}

실행 해보면 앞서 작성한 것과 동일한 결과가 나오는 것을 확인할 수 있습니다.

6라인에 작성한 printAll 제네릭 메소드를 보면,

앞서 List에 담긴 요소의 타입에 따라 서로 다른 메소드를 작성하고 요소의 값을 사용할 경우 각 타입에 맞게 타입 캐스팅을 해주어야 했습니다.

 

제네렉을 사용하면 컴파일 타임에 요소의 타입 검사를 통해 런타임 오류를 방지하면서 

타임 캐스팅도 하지 않고 보다 범용적인 메소드를 작성할 수 있는 것을 볼 수 있습니다.

 

이쯤이면 제네릭이 굉장히 매력적이라고 생각할 수 있습니다.

 

이정도만 해도 충분히 훌륭해 볼일수 있지만 사실 문제가 남아있습니다.

이 문제는 수많은 개발자분들이 이미 알고 있고 해결해두었으니 걱정할 필요 없습니다.

 

 

  • 제네릭 주요 개념(바운디드 타입, 와일드 카드)

바로 위에서 언급한 남아있는 문제를 해결하기 위한 방법이 바운트 타입과 와일드 카드였습니다.

리스트의 모든 요소를 출력하는 예제를 다음과 같이 수정해 보았습니다.

 

다음 리스트에 담긴 요소를 문자열이라 가정하고, 공백을 기준으로 나누어 그 개수가 몇개인지 출력하는 메소드입니다.

 

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

public class Exam_008 {
    
    public static <T> List<Integer> printTokenSizeList<List<T> list> {
        List<Integer> result = new ArrayList<>();
        for (T t : list) {
            result.add((String)t).split(" ").length);
        }
        return result;
    }
    
    public static void main(String[] args) {
        List<String> myList = new ArrayList<>();
        myList.add("가을 하늘 공활한데 높고 구름 없이");
        myList.add("밝은 달은 우리 가슴 일편단심일세");
        myList.add("무궁화 삼천리 화려 경산");
        myList.add("대한사람 대한으로 길이 보전하세");
        
        List<Integer> result = printTokenSizeList(myList);
        
        for (int elem : result) {
            System.out.println(elem);
        }
    }
}

[출력]
6
5
4
4

 

별다른 문제 없이 의도한 대로 동작하는 것 같지만 사실 불편한 부분이 몇가지 있습니다.

우선 11라인에서 String 타입으로 캐스팅하는 코드가 있다는 것입니다.

 

만약 이 메소드의 매개변수로 다음과 같은 List가 전달되면 어떻게 될까요?

List<Integer> myNewList = new ArrayList<>();
myNewList.add(1);
myNewList.add(2);
myNewList.add(3);

커파일 오류는 발생하지 않지만 런타임 에러가 발생합니다.

Exception in thread "main" java.lang.ClassCastException: class.java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')

 

제네릭을 사용해서 컴파일 타임에 타입 체크도 해주고, 코드 재사용성도 좋아졌다고 했는데 신통치 않습니다.

 

이 문제를 해결하기 위한 개념이 바로 바운디드 타입입니다. 이 개념을 사용한다면 타입 파라미터의 타입을 제한할 수 있습니다. 쉽게 얘기하면 타입 파라미터로 T의 값을 String 클래스로 제한하도록 수정한 코드입니다.

 

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

import class Exam_008 {
    
    public static <T extends String> List<Integer> printTokenSizeList(List<T> list) {
        List<Integer> result = new ArrayList<>();
        for (T t : list) {
            result.add(t.split(" ").length);
        }
        return result;
    }
    
    public static void main(String[] args) {
        List<String> myList = new ArrayList<>();
        myList.add("가을 하늘 공활한데 높고 구름 없이");
        myList.add("밝은 달은 우리 가슴 일편단심일세");
        myList.add("무궁화 삼천리 화려 경산");
        myList.add("대한사람 대한으로 길이 보전하세");
        
        List<Integer> result = printTokenSizeList(myList);
        
        for (int elem : result) {
            System.out.println(elem);
        }
    }
}

수정한 부분은 6라인에서 타입 파라미터의 타입을 String으로 제한 부분과

9라인에서 T 타입의 변수 t에 대해 String 타입이라는 것을 알고 있기 때문에

더 이상 타입 캐스팅이 의미가 없어 삭제한 부분입니다.

 

extends를 사용해서 타입 제한을 하는 경우 제한한 타입을 포함한 해당 타입의 하위 타입들을 사용할 수 있습니다.

다음 예시는 List에 담긴 모든 요소 값의 합을 반환하는 예제입니다.

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

public class Exam_010 {

    public static <T extends Number> double getSum(List<T> list) {
        double sum = 0.0;
        for (T t : list) {
            sum += t.doubleValue();
        }
    }
    
    public static vod main(String[] args) {
        List<Number> myNumber = new ArrayList<>();
        myNumber.add(10);
        myNumber.add(2.5);
        
        System.out.println(getSum(myNumber));
    }
}
[출력]
12.5

 

이렇게 타입을 제한 하는 방법 중에 와일드 카드를 사용하는 방법이 있습니다.

와일드 카드는 보통 '모든 것'을 뜻하는데 *(별표, asteris) 또는 ? (물음표)를 사용하는데, 자바 제네릭에서는 ?를 사용합니다.

 

크게 세가지 형태가 존재합니다.

 

1. <?>

-> 모든 종류의 클래스나 인터페이스 타입 사용 가능

 

2. <? extends 상위타입>

-> 상위타입의 타입 또는 이 타입의 하위타임만 사용 가능

 

3. <? super 하위타입>

-> 하위타입의 타입 또는 이 타입의 상위타입만 사용 가능

 

이해를 돕기 위해 다음과 같은 상속 구조를 갖는 클래스들으 정의했다고 가정합시다.

import java.util.Arrays;
import java.util.List;

public class Exam_011 {
    public static void main(String[] args) {
        // List의 요소 타입으로 제한을 두지 않습니다.
        List<?> wildcard_tet = Arrays.asList(
            new Root()
          , new Sub_01()
          , new Sub_02()
          , new Sub_02_Sub()
          , new Exam_011()
        );
        
        // List의 요소 타입으로 Sub_02 또는 Sub_02 하위 타입으로 제한
        List<? extends Sub_02> wildcard_extends_test = Arrays.asList(
            new Sub_02()
          , new Sub_02_Sub()
        );
    
        // List의 요소 타입으로 Sub_01 또는 Sub_01 상위 타입으로 제한
        List<? super Sub_01> wildcard_super_test = Arrays.asList(
            new Root()
          , new Sub_01()
        )
    
        wildcard_test.forEach(System.out::println);
        System.out.println();
        wildcard_extends_test.forEach(System.out::println);
        System.out.println();
        wildcard_super_test.forEach(System.out::println);
    }
}

class Root {}
class Sub_01 extends Root {}
class Sub_02 extends Root {}
class Sub_02_Sub extends Sub_02 {}

[출력]
me.xxelppa.study.week14.Root@2f7a2357
me.xxelppa.study.week14.Sub_01@566776ad
me.xxelppa.study.week14.Sub_02@6108b2d7
me.xxelppa.study.week14.Sub_02_Sub@1554909b
me.xxelppa.study.week14.Exam_011@6bf256fa

me.xxelppa.study.week14.Sub_02@22f71333
me.xxelppa.study.week14.Sub_02_Sub@13969fbe

me.xxelppa.study.week14.Root@3498ed
me.xxelppa.study.week14.Sub_01@1a407d53

 

만약 타입 네한에 위배되는 경우 다음과 같은 오류 메시지를 보여줍니다.

 

 

  • Erasure

제네릭에 대해 알아보면서 다양한 코드를 접하고 작성해봤겠지만 특이한 점이 있습니다.

바로 타입 파라미터에 primitive 타입을 사용하지 않았다는 것입니다.

 

primitive 타입도 타입인데 타입으로 사용하지 못한다는게 이상하다고 생각해야 합니다.

결론부터 얘기하면 타입 소거(type Erasure) 때문입니다.

 

이해를 돕기 위해 List<Integer>를 정의해봅시다.

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

public clss Exam_012 {
    List<Integer> list = new ArrayList<>();
}

 

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

// class version 55.0 (55)
// access flags 0x21
public class me/xxxelppa/study/week14/Exam_012 {

    // compiled from: Exam_012.java
    
    // access flags 0x0
    // signature Ljava/util/List<Ljava/lang/Integer;>;
    // declaration: list extends java.util.List<java.llang.Integer>
    Ljava/util/List; list

    // access flags 0x1
    public <int>()V
      L0
        LINENUMBER 6 L0
        ALOAD 0
      L1
        LINENUMBER 7 L1
        ALOAD 0
        NEW java/util/ArrayList
        DUP
        INVOKESPECIAL java/lang/Object. <int> ()V
        PUTFIELD me/xxxelppa/study/week14/Exam_012.list : Ljava/util/List:
        RETURN
       L2
         LOCALVARIABLE this Lme/xxxelppa/study/week14/Exam_012; L0 L2 0
         MAXSTACK = 3
         MAXLOCALS = 1
}

여기서 주목해야 할 부분은 ArrayList가 생성될 때 타입 정보가 없다는 것입니다.

재미있는 것은 제네릭을 사용하지 않고 raw type으로 ArrayList를 생성 해도 똑같은 바이트 코드를 볼 수 있다는 것입니다. 그리고 내부에서 타입 파라미터를 사용할 경우 Object 타입으로 취급하여 처리 됩니다.

 

이것을 타입 소거 (type Erasure)라고 합니다.

타입 소거는 제네릭 타입이 특정 타입으로 제한되어 있을 경우 해당 타입에 맞춰 컴파일시 타입 변경이 발생하고 타입 제한이 없을 경우 Object 타입으로 변경됩니다.

 

 

그럼 왜 이렇게 만들었을까요? 그 이유는 하위 호환성을 지키기 위해서 입니다.

제네릭을 사용하더라도 하위 버전에서도 동일하게 동작해야하기 때문입니다.

 

primitive 타입을 사용하지 못하는 것도 바로 이 기본 타입은 Object 클래스를 상속받고 있지 않기 때문입니다.

그래서 기본 타입 자료형을 사용하기 위해서는 Wrapper 클래스를 사용해야 합니다.

Wrapper 클래스를 사용할 경우 Boxing과 unBoxing을 명시적으로 사용할 수도 있지만 암묵적으로 사용할 수 있어 구현 자체에는 크게 신경쓸 부분은 없는거 같습니다.

 

 

마지막으로 제네릭과 관련하여 한가지 더 생각해볼 문제가 있습니다.

 

다음 코드는 제네릭 타입 파라미터를 사용해서 배열을 생성하는 예제입니다.

import java.util.Arrays;

public class Exam_013<T> {
    private T[] myArray;
    
    Exam_013(int size) {
        // myArray = new T[size]; // Type paramter 'T' cannot be instantiated directly
        myArray = (T[]) new Object[size];
    }
    
    public void addElem(int index, T t) {
        myArray[index] = t;
    }
    
    public void printElem() {
        System.out.println(Arrays.toString());
    }
    
    public static void main(String[] args) {
        Exam_013<String> e2 = new Exam_013<>(3);
        e2.addElem(0, "java");
        e2.addElem(0, "generic");
        
        e2.printElem();
    }
}

[출력]
java.generic.null

제네릭 타입을 사용해서 배열을 생성하려면 5라인 처럼 쓰는게 편할텐데, 왜 사용하지 못하고 6라인처럼 생성해야 하는 것일까요?

 

그 이유는 new 연산자를 사용하기 때문입니다.

new 연산자는 동적 메모리 할당 영역인 heap 영역에 생성한 객체를 할당합니다.

하지만 제네릭 컴파일 타임에 동작하는 문법입니다.

 

연장선에서 static 변수에도 제네릭 타입을 사용할 수 없습니다.

public class Exam_014<T> {
    private T myvalue_1;
    // 'me.xxxelppa.study.week14.Exam_014.this' cannot be referenced from a static context
    // private static T myValue_2;
}

 

조금만 생각해보면 그 이유를 금방 알 수 있습니다.

 

static 키워드를 사용해서 멤버 필드를 선언하게 되면, 특정 객체에 종속되지 않고 클래스 이름으로 접근해서 사용할 수 있습니다. 제네릭 타입을 사용하면 위 예제의 경우 Exam_014<String> 과 Exam_014<Integer> 등으로 객체를 생성해서 인스턴스마다 사용하는 타입을 다르게 사용할 수 있어야 하는데, static으로 선언한 변수가 가능할 수가 없습니다.

 

그렇기 때문에 static 변수에는 제네릭 타입을 사용할 수 없습니다.

 

하지만 재미있게도 static 메소드에는 제네릭을 사용할 수 있습니다.

심지어 위에 작성한 예제에서도 static 메소드를 자유롭게 사용했습니다.

 

왜 static 변수에는 사용할 수 없었는데 메소드에는 가능했을까요?

이것도 조금만 생각해보면 그 이유를 알 수 있습니다.

 

static 키워드를 사용하면 클래스 이름으로 접급하여 객체를 생성하지 않고 여러 인스턴스에서 공유해서 사용할 수 있습니다.

변수 같은 경우 해당 값을 사용하려면 값의 타입을 알아야 하지만 메소드의 경우 해당 기능을 공유해서 사용하는 것이기 때문에 제네릭 타입 변수 T를 매개변수로 사용한다고 하면 해당 값은 메소드 안에서 지역 변수로 사용되기 때문에 변수와 달리 메소드는 static으로 선언 되어 있어도 제네릭을 사용할 수 있습니다.

 

 

[참조]

blog.naver.com/hsm622/222251602836

'JAVA' 카테고리의 다른 글

JVM은 무엇이며 자바 코드는 어떻게 실행하는 것인가?  (0) 2021.04.18
람다식  (0) 2021.04.15
I/O (Input/Output)  (0) 2021.02.20
어노테이션  (0) 2021.02.03
Enum  (0) 2021.01.25