Kotlin lateinit var에 관하여

반달·2023년 4월 2일
1

Kotlin 탐구

목록 보기
1/2
post-custom-banner

한창 지연 초기화 방법에 대해 공부하고 있었다.
그 중에서 lateinit var를 공부하던 중 생겨난 의문점들을 탐구한 과정을 적어보려한다.

1. 왜 primitive 타입만 안될까?

검색해 볼 수도 있겠지만 디컴파일을 통해 Java에서 어떻게 표현될지를 보는게 좋을듯하다.

일단 lateinit var로 프로퍼티를 만들어보자.

class Test {
    lateinit var age: Int
    // 에러: 'lateinit' modifier is not allowed on properties of primitive types

}

lateinit은 원시타입의 프로퍼티가 허락되지 않는다는 에러가 발생하게 된다.
이걸 다시 디컴파일 해보면 내부에서 어느 부분이 에러인지 나온다.

public final class Test {
   public int age = 20;

   public final int getAge() {
      int var10000 = this.age;
      if (var10000 == null) { // Operator '==' cannot be applied to 'int', 'null'
         Intrinsics.throwUninitializedPropertyAccessException("age");
      }

      return var10000;
   }

   public final void setAge(int var1) {
      this.age = var1;
   }
}

int와 null 사이에서 == 연산자를 쓸 수 없다고 나온다.
이유는 String과 같이 참조 타입이라면 null이 가능해서 비교식이 성립하지만, int는 primitive 타입이다 보니 그럴 수 없는 것이다.

이처럼 lateinit은 내부적으로 getter에서 임의의 변수인 var10000에 lateinit을 할 필드를 넣어두고 이것이 null값인지 확인해보고 null이라면 exception을 날린다.

그럼 어떻게 하면 이게 성립되게끔 할 수 있을까?

class Test {
    lateinit var age: Integer
    // This class shouldn't be used in Kotlin. Use kotlin.Int instead.
}

조금 웃긴 방법으로 자바의 Integer 클래스를 사용하는 방법이 있다.
Integer 클래스는 참조형 타입이기 때문에 null을 가질 수 있어서이다.
물론 IDE에서 코틀린의 Int를 사용하라고 권장한다.😅

이어서 String 타입을 보겠다.

class Test {
    lateinit var age: String
}

디컴파일 하면?

public final class Test {
   public String age;

   @NotNull
   public final String getAge() {
      String var10000 = this.age;
      if (var10000 == null) {
         Intrinsics.throwUninitializedPropertyAccessException("age");
      }

      return var10000;
   }

   public final void setAge(@NotNull String var1) {
      Intrinsics.checkNotNullParameter(var1, "<set-?>");
      this.age = var1;
   }
}

코틀린에서 Int가 자바에선 int로 디컴파일 되던 것과 달리 String 타입은 똑같이 String 타입으로 디컴파일된다.
이유는 자바에서 String은 int, double 등의 값 타입과는 달리 참조 타입이기 때문이다.
따라서 클래스이자 참조타입인 String은 null을 가질 수 있기에 getter의 해당 부분에서 에러가 나질 않는다.

if (var10000 == null) { // String인 var10000은 null이 들어갈 수 있기 때문에 에러가 나질 않는다.
    Intrinsics.throwUninitializedPropertyAccessException("age");
}

2. lateinit은 왜 val를 쓸 수 없을까?

기계적으로 var변수를 나중에 초기화고 싶으면 lateinit을 사용한다라는 고정관념으로 써왔지만, 왜 그래야하는지는 생각하지 않고 써왔기에 이번 기회에 정리해보려고 한다.

일단 먼저 var 타입의 프로퍼티를 선언해보자.

class Test {
    var age: Int = 20
}

이걸 Java로 디컴파일 해보면?

public final class Test {
   private int age = 20;

   public final int getAge() {
      return this.age;
   }

   public final void setAge(int var1) {
      this.age = var1;
   }
}

var 타입이다 보니 읽기, 쓰기 기능을 할 getter와 setter가 구현되어있다.

이어서 val 타입으로 프로퍼티를 선언하면 어떻게 될까?

class Test {
    val name: String = "bandal"
}

디컴파일하면?

public final class Test {
   @NotNull
   private final String name = "bandal";

   @NotNull
   public final String getName() {
      return this.name;
   }
}

재할당이 불가하여 setter가 따로 없는 모습을 보인다.

억지로 lateinit val로 프로퍼티를 선언하면 어떻게 될까?

class Test {
    lateinit val age: String
    // 에러: 'lateinit' modifier is allowed only on mutable properties
}

lateinit은 오직 가변 프로퍼티만 허용된다는 에러를 발생시킨다.
이유는 무엇일까? 눈치가 빠른 사람이라면 알아차릴 수도 있다.
한번 디컴파일 해보자.

public final class Test {
   public String name;

   @NotNull
   public final String getName() {
      String var10000 = this.name;
      if (var10000 == null) {
         Intrinsics.throwUninitializedPropertyAccessException("name");
      }

      return var10000;
   }
}

위의 var 타입을 선언한 것과 비교했을 때 무엇이 다른가?
바로 setter가 없다는 것이다.
val로 프로퍼티를 선언시엔 동시에 초기화를 해야 사용 가능하다.(by lazy로 지연초기화를 하는 것은 잠시 논외로하자)

위의 디컴파일 된 코드로는 name이 초기화가 되어있지 않다는 에러를 낸다.
var의 경우 초기화를 안하고 setter를 통해 나중에 값을 다시 넣어주게끔 구현이 가능하지만, val의 경우 setter가 구현되어 있지 않아 초기화 말곤 name에 값을 넣을 수 없다.

그렇게되면 초기화를 해줘야하는데 그러면 lateinit을 쓰는 이유가 없다.
그래서 lateinit은 후에 계속 값을 할당할 수 있는 var만 가능하다.

3. nullable한 타입은 왜 가질 수 없을까?

이거는 혹시나 궁금해하는 사람이 있을까봐 같이 정리해둔다.
일단 한번 써보자.

class Test() {
    lateinit var name: String?
    // 에러: 'lateinit' modifier is not allowed on properties of nullable types
}

디컴파일을 해보면

public final class Test {
   public String name;

   @Nullable
   public final String getName() {
      String var10000 = this.name;
      if (var10000 == null) {
         Intrinsics.throwUninitializedPropertyAccessException("name");
      }

      return var10000;
   }

   public final void setName(@Nullable String var1) {
      this.name = var1;
   }
}

사실 내부적으로 오류가 나진 않는다.
맞다 사실 코드상의 오류가 있는것이 아니라 논리적인 오류 때문에 허용하지 않는 것이다.
lateinit var는 지연 초기화를 사용하기 위함이었다.
근데 lateinit var name: String?은 애초에 var name: String? = null로 치환이 가능하다.
null로 초기화를 해두었다가 나중에 다시 값을 넣어주면 되는 것이다.


정리

  1. 왜 primitive 타입만 안될까?

lateinit의 내부구현은 null과 비교하는 구문이 있다.
값 타입인 primitive 타입은 null을 가질 수 없으며, null과 비교자체가 불가능 하기 때문에 primitive 사용할 수 없다.

  1. 왜 var만 가능하고 val은 lateinit을 사용하지 못할까?

var은 내부적으로 getter와 setter가 구현되어 있지만, val은 getter만 구현이 되어있다.
lateinit의 경우 초기화를 지연하기 때문에 선언과 동시에 초기화를 하는 구문이 없다.
즉 setter를 사용하여 값을 다시 넣어줘야하는 이 경우에 setter를 가지고 있지 않은 val은 부적합하다.
따라서 lateinit은 오직 var만 사용이 가능한 것이다.

  1. 왜 nullable한 타입은 가질 수 없을까?

lateinit var는 지연 초기화를 사용하기 위함이었다.
근데 lateinit var name: String?은 애초에 var name: String? = null로 치환이 가능하다.
null로 초기화 해두고 나중에 원하는 값을 다시 넣어주면 그만이다.


새로운 의견이나 이의가 있다면 언제든 댓글로 알려주시기 바랍니다!😆

profile
깊이 있는 안드로이드 개발자가 되기 위해
post-custom-banner

0개의 댓글