본문 바로가기
Language/자바&코틀린

[진행중][자바, 코틀린] 제너릭이란? 그리고 공변성과 원시(primitive) 타입 비허용

by 코딩공장공장장 2023. 12. 20.

제너릭이란?

클래스, 인터페이스, 함수에서 사용되는 필드, 매개변수, 반환타입을 미리 정의하지 않고 사용하는 시점에 특정 타입을 지정할 수 있도록 해주는 기법을 제너릭(Generic)이라고 한다. 제너릭을 통해 클래스나 메서드를 각각의 타입마다 재정의할 필요 없이 코드 중복 없이 여러 타입에 대하여 재사용할 수 있다. 자바나 코틀린에서는 컴파일 단계에서 안정적인 타입체크를 함으로써 여러타입에 대해 안정적으로 사용할 수 있게 지원하고 있다.

*용어 정의 

  • 제너릭 타입 : 여러 타입을 받을 수 있는 요소, 즉 제너릭 개념이 적용된 타입을 제너릭 타입이라고 한다.
  • 원시(raw) 타입 : 제너릭이 도입되기 이전 다양한 데이터 타입을 다루기 위해 Object타입으로 데이터를 형변환하여 사용하는 타입
  • 제너릭 메서드(함수) : 매개변수나 반환타입으로 제너릭 타입을 사용하는 메서드를 제너릭 메서드라 한다. 
  • 제너릭 클래스 : 제너릭 메서드나 제너릭 타입을 포함하는 클래스를 제너릭 클래스라고 한다.

in java

class Box<T> { // 제너릭 클래스
    private T item; // 제너릭 타입
    
    public T getItem(){ // 제너릭 메서드
        return item;
    }
    
    public void setItem(T item){ // 제너릭 메서드
        this.item = item;
    }
}

 

in kotlin

// 제너릭 클래스
class Box<T>(
    var item: T, // 제너릭 타입
) {
    fun myItem(): T = item // 제너릭 메서드
    fun changeItem(item: T) { // 제너릭 메서드
        this.item = item
    }
}

 

raw type

// 제너릭 타입
List<String> genericList  = new ArrayList<>();
genericList.add("str");
String genericItem = genericList.get(0);
		
// 원시(raw) 타입
List rawList  = new ArrayList();
rawList.add("str");
rawList.add(1);
String rawStrItem = (String)rawList.get(0);
int rawIntItem = (int)rawList.get(1);

 

자바에서는 위와 같은 소스가 모두 동작한다.

raw  type을 보면 제너릭 타입이 선언되어있지 않고 값을 빼낼때 형변환하여 사용되고 있다. 

소스코드에 명시적으로 선언하지 않았지만 값을 넣을때는 Object타입으로 형변환 되어 값이 들어간다.위와 같이 제너릭 타입 없이 Object타입으로 데이터를 담는 구조를 raw type이라고 하고 제너릭 이전 버전에 사용하던 방식이다. 

 

타입소거

위의 raw type은 단순히 제너릭 출현 이전 버전만은 아니다. raw type은 실제로 제너릭 타입의 런타임 환경에서 실행되는 방식과 같다. 즉, 제너릭은 컴파일 단계에서 안정적으로 타입 체크를 한 이후에 제너릭 클래스가 가지고 있는 타입정보를 제거한다. 그리고 이후에 런타임 단계에서는 위와 같이 raw type으로 실행이 된다. 

 

그만큼 컴파일러가 안정적으로 타입체크를 해주었기 때문에 런타임에서는 타입정보 없이 빠른 실행환경을 갖출 수 있다.더불어, 제너릭 이전 버전 raw type만 존재하던 방식과도 호환되는 하위 호환을 이룰 수 있다.

 

raw type 예제는 코틀린 예제는 없이 자바만 사용하였다. 그렇다. 코틀린은 raw type을 사용할 수 없다. 

비교적 최근에 나온 코틀린은 raw type을 개발자가 명시적으로 사용할 수 없다. but, 동작방식은 자바와 똑같다. 

코틀린의 제너릭도 컴파일 단계에서 안정적인 타입체크 후 타입정보를 제거하고 런타임에서 동작한다.

 

 

제너릭의 장점

제너릭을 사용하는 장점이 바로 위에서 설명한 모든 내용에 있다. summary를 하자면

1. 컴파일 단계의 안정적인 타입 체크와 타입소거를 통한 빠른 런타임 실행환경

2. 타입캐스팅 없이 사용

3. 쉬운 사용법

라는 장점이 있는것 같다.

 

제너릭에 원시타입(primitive type)을 사용할 수 있나?

용어에 주의하자. raw type, primitive type 둘다 한국말로 원시타입이다. raw type은 이전에 설명했고,

primitive type은 알다시피 int, long, float, boolean 등 기본타입이다.

 

코틀린은 개발자 입장에서 primitive type 그 wrapper type을 명시적으로 구분하여 사용하지 않는다.

즉, 자바처럼, int와 Integer로 나뉘어져 있지 않고 Int로 통일해서 사용하고 컴파일러가 알아서 상황에 맞게 primitive와 wrapper 타입을 적용시킨다. 당연히 제너릭에서는 primitive type의 wrapper type으로 컴파일러가 알아서 적용한다.

따라서 코틀린에서 제너릭을 사용할 때 코드구현에 신경 쓸 부분은 없지만 자바와 마찬가지로 JVM 환경에서 돌아가니 개념은 알고있자.

 

제네릭에 primitive type 지원 안하는 이유

  1. 하위호환을 위해 타입소거 방식으로  런타임에서 Object 타입으로 저장되도록 설계
    => 밑에서 자세히 설명
  2. null 처리, primitive type은 null을 가질 수 없다.  하지만 제너릭은 null을 허용하도록 설계됬다. 
    => null에 관한 내용도 나오니 제너릭은 내생각엔 참조객체로 일관성있게 처리하고 싶었던거 같다. 
[잠깐] 왜 제너릭은 내부적으로 Object 타입으로 저장되도록 설계했을까?
Object 타입은 모든 자바 클래스 인스턴스의 최상위 객체로 모든 자바 객체를 저장할 수 있다.
primitive type은 자신의 타입 값 범위에 있는 값만 저장할 수 있다. 당연히 primitive type은 Object 타입과 상속관계가 Object에 값을 할당 할 수 없다.
따라서 둘 중에 하나를 선택한다면 당연히 범용성이 큰 Object를 선택하는 것이고
둘다 저장할 수 있다면 타입정보를 가지고 다녀야 한다.
하지만 타입정보를 가지고 다닌다면 하위호환성이 깨진다. 제너릭 이전의 raw type은 Object 타입으로 객체를 저장했고 제너릭은 하위 호환을 위해 컴파일 시점에 안정적으로 타입체크하고 런타임에서 raw type처럼 Object로 객체를 저장하도록 설계됬다. 이로인해 하위호환을 이루고 런타임 시점에 좀 더 빠른 실행환경을 얻을 수 있는 있게되었다.

=> 여기서 잠깐. 그러면 raw type은 왜 Object만 허용하도록 설계됬나?
... 너무나 당연하다. 제너릭도 없었던 시절에 타입안정성이나 타입정보를 들고다닌다는 개념은 없었을테니 다양한 데이터 타입을 달수 있는 Object를 선택할수 있는 방법 밖에 없었다. (제너릭은 raw type의 타입안정성 문제로 출현한 개념이다.)

 

제너릭 클래스의 인스턴스 타입은 어떻게 결정이 되는가?

제너릭 클래스의 인스턴스는 raw type과 제너릭 타입이 결합하여 결정된다.

List<String>은 (List, String) 하나의 조합이 이 제너릭 클래스의 타입이다.

List<Int>는 (List, Integer) 조합이 제너릭 클래스의 타입이 되는 것이다. 

List<String>과 List<Integer>는 전혀 다른 타입이다.

그렇다면 String과 Object(코틀린의 Any)는 상속관계를 이루는데 List<String>과 List<Any>는 상속관계를 이루는가? 아니면 관련없는가? 그 답은 변성이라는 개념에 있다.

 

 

서브타이핑과 변성

서브타이핑(subtyping)이란 상속관계에 있는 타입에서 하위 타입이 상위 타입의 인스턴스를 대체하는 것이다.

oop의 리스코프 치환 원칙에서 중점적으로 다루는 행동적 하위형화(behavioral subtyping)에서 나오는 subtyping이 이 개념의 일부이다. (행동적 하위형화는 객체의 행동인 메서드의 시그니치와 동작이 하위타입에서 올바르게 정의되어야 한다는 내용이다.)

 

변성(variance)이란 복합타입(complex type)간의 관계가 서브 타입 관계에 따라 정의되는가를 나타내는 개념이다. 위에서 말한 String과 Object가 상속일 때 List<String>과 List<Object>는 어떤 관계를 정의할 것인가를 나타내는 것이다. 이는 서브타이핑과 함께 정의된다.

 

제너릭에서 변성

  • 공변성(covirance) : 제너릭 타입간의 관계가 제너릭 클래스 타입에 대해서도 그대로 유지된다.
    이 관계의 의미는 '제너리 타입 S가 T의 하위타입이면, S를 T로 대체할 수 있는가, 서브타이핑 되는가'를 의마한다. oop의 리스코프 치환원칙에 해당하는 내용이다.
  • 반공변성(contravariance) : 제너릭 타입간의 관계가 제너릭 클래스 타입에서 역전된다.
  • 불공변성(invariance) : 제너릭 타입간 관계가 제너릭 클래스 타입간 관계와 아무 상관 없다.
    String은 Object의 하위타입이지만 List<String>은 List<Object>의 하위타입이 아니고 대체할 수도 없다.
    => 자바와 코틀린 모두 불공변성 특징을 갖는다.
    => 제너릭 클래스는 원시타입과 제너릭 타입이 합쳐져 자신의 타입을 결정한다. 그러므로 구성요소인 제너릭의 타입간의 관계는 컴파일러에게 무시된다.

 

불공변성 예시

 

in java

class Animal {}
class Cat extends Animal {}


public class Main
{
public static void main(String[] args) {
    //object 배열
    Object[] animalArr = new Object[5];
    animalArr[0] = new Animal();
    Cat[] catArr = new Cat[5];
    catArr[0] =  new Cat();

           //공변성, 하위타입이 상위타입 대체(서브타이핑)
    animalArr = catArr;

    // List
    List<Animal> animalBox = new ArrayList<>();
    List<Cat> catBox = new ArrayList<>();

           // (컴파일 오류)불공변성으로 인하여 서브타이핑 불가
    animalBox = catBox;
}
}

자바의 경우 배열은 공변성이지만, 복합타입은 불공변성이다

 

in kotlin

open class Animal
class Cat : Animal()

fun main() {
  // 배열
  var animalArr: Array<Animal?> = arrayOfNulls(5)
  animalArr[0] = Animal()
  var catArr: Array<Cat?> = arrayOfNulls(5)
  catArr[0] = Cat()
  // (컴파일 에러)불공변성, 하위타입이 상위타입 대체 (서브타이핑)
  animalArr = catArr // 컴파일 에러

  // 리스트
  var animalBox: MutableList<Animal> = mutableListOf()
  var catBox: MutableList<Cat> = mutableListOf()
  // (컴파일 에러) 불공변성, 서브타이핑 불가
    animalBox = catBox
}

 

코틀린은 배열과 복합타입 모두 불공변성

 

공변성 예시

 

in kotiln - out 키워드와 val 변수

 

open class Animal
class Cat : Animal()

class CatBox<out T : Animal>(val item: T) // generic class

fun main() {
  val animalLootBox: CatBox<Cat> = CatBox(Cat())
  val lootBox: CatBox<Animal> = animalLootBox // 공변성 가능
}

 

out 키워드를 사용함으로써  Cat과 Animal의 상속관계가 CatBox<Cat>과 CatBox<Animal>에도 그대로 유지됬다.

out 키워드를 사용함으로써 컴파일러가 제너릭 타입 관계를 복합타입에도 고려 하게된다.

또한, 중요한 것은 제너릭 속성을 val로 선언해야한다. 제너릭 타입의 타입 안정성을 위하여 읽기전용으로 사용하여 컴파일 단계에서만 타입 체크를 하는 방식의 안정성을 그대로 이어나간다.

 

반공변성 예시

 

in kotlin - in 키워드

open class Animal
class Cat : Animal()

class CatBox<in T : Animal>(item: T)

fun main() {
  val animalLootBox: CatBox<Animal> = CatBox(Animal())
  val lootBox: CatBox<Cat> = animalLootBox // 반공변성
}

Cat은 Animal의 하위 타입이지만 CatBox<Cat>이 CatBox<Animal> 클래스의 인스턴스를 참조하고있다.

관계가 역전되었다. Animal<-Cat이지만 CatBox<Cat> <- CatBox<Animal>로 제너릭 클래스의 관계가 역전된다.

in 키워드를 사용하면 위와같이 반공변성을 적용할 수 있다.

 

반응형