[kotlin] 인터페이스 상의 var와 val의 차이

개요top

코틀린에서 var나 val을 사용하면 다른 언어처럼 속성이 되는 것이라기보다는 속성처럼 보이는 문법을 쓰게 해주는 getter, setter를 만들어내는 셈입니다.
따라서 추상클래스나 인터페이스에서 추상수준의 var나 val을 만들 수 있다는 건 어찌보면 당연하죠.
여기에는 미묘한 차이와 재밌는 것들이 있어 이를 간단히 소개해볼까 합니다.

val과 var의 기본top

예를 들어 다음과 같은 선언이 있다고 해보죠.

interface Test{
  val a:Int
  var b:String
}

이건 아마 자바로 번역하면 다음과 같이 될 것입니다.

interface Test{
  int getA();
  String getB();
  void setB(v:String);
}

val인 경우는 getter만 만들어내는 것이고, var인 경우는 setter까지 만들어내는 인터페이스인 셈입니다.

상속 시의 시그니처 및 반환형의 공변top

자바에서 메소드의 시그니쳐는 메소드이름과 인자들 및 인자들의 타입이 정확히 일치하는 것을 전재 하로 하고 있습니다.

  1. 즉 메소드명이 완전히 일치해야 하고
  2. 각 인자는 완전히 동일한 형을 갖고 있어야합니다(무공변)

override는 바로 시그니처가 일치할 때 사용할 수 있는 것입니다.
헌데 함수의 반환형은 어찌되는 것일까요?
자바는 함수의 반환형에 대해 무공변이 아니라 공변을 지원하는데 override한 메소드의 반환형이 원형 메소드의 반환형의 자식계열이면 허용하게 됩니다.

즉 아래와 같이 성립합니다.

interface Test{
  Object getB()
}

class Concreate implements Test{
  @override
  String getB(){return "abc";}
}

위 예에서 Concreate는 Test의 getB()를 구상할 때 반환형을 String으로 했는데 이는 추상형의 반환형인 Object를 상속한 형이므로 공변이 성립되어 바르게 override가 됩니다. 이는 당연한 객체지향의 원리로

  1. 외부에서는 Concreate의 getB()메소드에 접근할 때 Test의 메소드로서 접근하게 되는데
  2. 결국 getB()는 Obejct형을 반환하므로
  3. String을 반환해도 공변에 의해 Object형을 반환했다는 점은 문제없기 때문입니다.

여기까지 설명한 자바의 내용은 코틀린도 동일하게 따르고 있습니다(사실 많은 언어가 그렇게 되어있지만 반환형이 무공변이거나 인자형에 대한 공변을 인정하는 언어도 있습니다)

val을 구상해보기top

이제 본래 주제 중 하나인 val을 구상해보죠.

interface Num{
  val num:Number
}

이렇게 간단히 num라는 Number속성을 인터페이스로 선언하면

inline class NumInt(override val num:Int):Num
inline class NumFloat(override val num:Float):Num
inline class NumDouble(override val num:Double):Num

이렇게 손쉽게 구상형을 전개해갈 수 있죠. 이를 알기 쉽게 자바로 재전개해보면 다음과 같을 것입니다.

class NumInt implements Num{
  private int num;
  NumInt(v:int){
    num = v;
  }
  @override
  int num(){return num;}
}
..

이런 식이 될 것입니다(귀찮아서 더 못쓰겠…옛날에 자바 어찌 썼누..=.=)

그래서 요점은 val의 경우는 손쉽게 구상 클래스에서 상속관계에 있는 하위형으로 바꿔 쓸 수 있다는 점입니다.
하지만 이를 노출할 때는 가리키는 형에 따라 다른 형으로 인식될 것입니다.

val numInt = NumInt(3)
numInt.num //Int형임

val num:Num = numInt
num.num //Number형임

당연하다면 당연한건데 같은 인스턴스라할지라도 Num추상형으로 인식하면 Num수준에서 속성을 바라보기 때문에 Number형이 되는 것이죠.
따라서 추상클래스에서 이 속성을 이용하려고 할 때 구상형이 지정한 num의 형을 인식하는 것은 불가능합니다.
즉 다음과 같은 제네릭을 대체할 수는 없다는 것입니다.

interface Resource{
  fun init()
  fun terminate()
}
abstract class Model<T:Resource>{
  val res:T
  fun init(v:String):T = res.apply{init(v)}
  fun setRes(v:T){
    res.terminate()
    res = T
  }
  ..
}

이유는 init가 T형을 반환하기 때문이죠. 만약 val을 상속해서 구상형이 맘대로 res의 타입을 지정할 수 있다곤 해도 추상타입인 Model입장에서는 그 형을 알 수는 없으므로 반환형에 사용할 수는 없습니다.
뿐만 아니라 그 형을 인자로 받아들이는 것도 불가능합니다. 메소드의 인자는 무공변이기 때문이죠.
따라서 추상형에서 메소드의 반환형이나 인자형에 상속된 val의 구상형을 알 수는 없기 때문에 이 경우는 제네릭을 제거할 수는 없다는 것입니다.
하지만 이런 형식의 제네릭은 편리하긴 하지만 대부분 디자인상으로 LSP를 회피하기 위한 가장 간단한 조치일 뿐입니다.
애당초 좋은 디자인이라면 추상형이라면 추상형끼리 대화하도록 만들고 구상형에겐 행동을 위임하기 때문에 val의 상속은 응용범위가 넓습니다.

위 예제에서 디자인은 처음부터 순수한 객체지향으로서는 잘못되어있는데 우선 속성이 res가 노출된다는 점이겠죠. 이미 행위를 위임하려는 객체지향에 위배되고 있습니다. T형을 인자로 받는 경우도 마찬가지로 인터페이스 레벨의 Resource에 의존하지 않겠다는 의미이므로 이미 잘못된 디자인 결정입니다.

왜 이런 일이 생길까요. 보통은 잘못된 디자인 의사결정이기라기보다 Model클래스가 다른 기능을 갖고 있는 건 별개로 치고 바로 저 두개의 메소드가 Resource에 대한 범용 유틸리티처럼 사용되고 있기 때문입니다.
일종의 미디에이터 기능을 수행하는데 미디에이션을 위한 별도의 추상형 체계가 귀찮기 때문에 제네릭을 이용해서 일괄처리를 하는 스크립트성 유틸리티를 만들고 그걸 통해 후처리가 끝난 Resourece를 받으려고 했기 때문이죠. 이런 경우는 유틸리티처리가 끝난 후 추상형이 아니라 구상형을 받고 싶기 때문에 제네릭을 사용하는 것이라 할 수 있습니다.

안그러면 이걸 받는 클라이언트 코드에서 런타임 타운캐스팅을 해야하기 때문이죠.

따라서 이런 일은 자주 생기고 나름 의미도 있습니다만 쨌든 이 경우는 제네릭 대신 val의 추상화를 쓸 수는 없습니다. 하지만 반대로 ADT(algebraic data type)같은 걸 만들 때는 더 할 나위 없이 강력하게 작동합니다.

간단히 JSON데이터에 대해서 생각해볼까요.

JSON은 크게 number, string, boolean, null과 같은 값 또는 배열이나 오브젝트를 갖을 수 있는 타입입니다.
만약 오브젝트를 map으로 표현한다면 map<String, JSONValue>라고 표현할 수 있을 테고 이 JSONValue야 말로 ADT에 해당됩니다.
그대로 표현하면 다음과 같습니다.

sealed class JSONValue{
  class JSONNumber:JSONValue()
  class JSONBoolean:JSONValue()
  class JSONString:JSONValue()
  class JSONNull:JSONValue()
  class JSONObject:JSONValue(), MutableMap<String, JSONValue> by mutableMapOf()
  class JSONArray:JSONValue(), MutableList<JSONValue> by mutableListOf()
}

모든 추상타입은 모두 값을 가져야하기 때문에 상수값 속성을 JSONValue수준에서 val로 잡아주고 구상 클래스가 이를 구상형으로 만들어주면 됩니다.

sealed class JSONValue(){ 

  abstract val v:Any //Any형으로 추상 필드 선언

  inline class JSONNumber(override val v:Number):JSONValue()
  inline class JSONBoolean(override val v:Boolean):JSONValue()
  inline class JSONString(override val v:String):JSONValue()
  inline class JSONNull:JSONValue(){override val v:Any = true} //v를 null로 만들면 전체가 옵셔널이 되므로 귀찮음

  class JSONObject:JSONValue(), MutableMap<String, JSONValue> by mutableMapOf(){ //델리 때리고
    override val v:MutableMap<String, JSONValue> = this //자신으로 대체
  }

  class JSONArray:JSONValue(), MutableList<JSONValue> by mutableListOf(){
    override val v:MutableList<JSONValue> = this //상동
  }
}

위 예에서 추상필드인 v는 구상 필드로 내려오면서 각각에 맞는 값으로 상수항이 되었습니다. 이제 손쉽게 stringify같은 메소드를 만들 수 있죠.

sealed class JSONValue(){ 

  abstract val v:Any //Any형으로 추상 필드 선언
  
  open fun stringify() = "$v"  //기본적으로는 문자열화해서 반환함

  inline class JSONNumber(override val v:Number):JSONValue()
  inline class JSONBoolean(override val v:Boolean):JSONValue()

  inline class JSONString(override val v:String):JSONValue(){

    //문자열은 "로 감싸고 내부에 "가 있는 경우는 \"로 치환해야 함
    override fun stringify() = "\"${if(v.isBlank()) v else v.replace("\"", "\\\"")}\""
  }


  inline class JSONNull:JSONValue(){
    override val v:Any = true
    override fun stringify() = "null"
  }

  class JSONObject:JSONValue(), MutableMap<String, JSONValue> by mutableMapOf(){
    override val v:MutableMap<String, JSONValue> = this

    //map을 순회하면 내부의 값은 전부 JSONValue이므로 다시 stringify를 호출하여 모으기만 하면 됨
    override fun stringify() = "".let{
      forEach{(k, v)->it += ""","$k":${v.stringify()}"""}
      "{${if(it.isNotBlank()) it.substring(1) else ""}}"
    }
  }

  class JSONArray:JSONValue(), MutableList<JSONValue> by mutableListOf(){
    override val v:MutableList<JSONValue> = this
    //상동
    override fun stringify() = fold(""){r, v->r + ",${v.stringify()}"}.let{
      "[${if(it.isNotBlank()) it.substring(1) else ""}]"
    }
  }
}

이렇듯 추상화 레벨이 LSP를 잘 지키고 있다면 val에 의한 속성 상속은 굉장히 강력한 힘을 발휘합니다.

var는 다르다.top

val이 구상층에서 자유롭게 자식형으로 변환할 수 있는 이유는 getter메소드라서 반환형에 대한 공변지원 때문이었습니다.
하지만 var는 setter에서 인자로 해당형을 받기 때문에 구상층에서 무공변 때문에 그대로 추상형을 써야합니다.

interface Num{
  var num:Number
}

class NumInt(override var num:Int):Num //error

val은 문제없이 되던게 var되면 에러가 나죠. 이는 getter, setter로 나눠서 생각해보면 자명합니다.

interface Num{
  fun getNum():Number
  fun setNum(v:Number) ← 바로 여기!!
}

class NumInt:Num{
  override fun getNum():Int{}
  override fun setNum(v:Int){}
}

결국 var는 setter에 인자로 Number를 확정하고 있으므로 상속한 NumInt의 setNum에서 v에 Int형을 지정하면 시그니쳐 위반이 되어버린다는 거죠.

의외로 이 문제는 해결하기 어렵습니다. 유일한 대안은 추상층은 val로 지정하고 구상형은 var로 override하는 것입니다.

interface Num{
  val num:Number //추상층은 val로 지정하여 getter만 내린다
}

class NumInt(override var num:Int):Num //ok 구상층에서는 var로 선언해도 무방!

이것도 역시 getter, setter로 보자면 다음과 같습니다.

interface Num{
  fun getNum():Number
}

class NumInt:Num{
  override fun getNum():Int{}
  fun setNum(v:Int){}
}

결국 추상층에서는 getter만 정의한 거고 구상층의 setter는 override를 하지 않은 것으로 처리되는 것이죠.

결론top

코틀린은 기본적으로 모든 필드가 getter, setter로 지정되기 때문에 인터페이스 수준에서 val이나 var를 쓸 수 있게 됩니다.
하지만 미묘한 차이와 사용법을 주의깊게 이해할 필요가 있죠.
또한 더 나아가 코틀린에서 val, var의 의미가 단순히 불변값을 의미하는 것이 아니라 얼마든지 val이었던 것을 구상레이어에서 var로 바꿀 수 있다는 자각해둘 필요가 있습니다.