Note: see the link below for the English version of this article.

https://duongnt.com/kotlin-null-safety

Kotlin null safety deep dive

Null safety là một trong những cải thiện của Kotlin so với Java. Trong Kotlin, ta có sự phân biệt rõ giữa những reference có thể chứa null (nullable type) và reference không thể chứa null (non-nullable type). Nhưng suy cho cùng, code Kotlin vẫn sẽ được dịch thành bytecode và chạy trên JVM.

Hôm nay, chúng ta sẽ tìm hiểu cách Kotlin phân biệt giữa StringString? trong khi JVM lại chỉ cần biết một kiểu Ljava/lang/String (cách xử lý những kiểu dữ liệu khác cũng hoàn toàn tương tự).

Null safety với biến local

Ta sẽ bắt đầu từ trường hợp đơn giản nhất, null safety cho biến local. Dưới đây là đoạn code khai báo một biến String và một biến String?.

val canBeNull: String? = null
val nonNull: String = null // compiler error

Và đây là bytecode của chúng.

LOCALVARIABLE nonNull Ljava/lang/String; L2 L3 1
LOCALVARIABLE canBeNull Ljava/lang/String; L1 L3 0

Ta có thể thấy là không có sự khác biệt giữa String?String type ở mức bytecode. Cả 2 đều được compile thành Ljava/lang/String. Tuy nhiên compiler của Kotlin có thể phát hiện ra khi ta gán giá trị null cho nonNull và sẽ báo lỗi.

Nhưng nếu ta gọi hàm được định nghĩa từ trước thì sao? Compiler có phải dò ngược call chain để kiểm tra mã nguồn từng hàm không? Và nếu ta chỉ có bytecode mà không có mã nguồn thì sao?

Kiểm tra kiểu dữ liệu trả về của hàm

Hãy xem 2 hàm dưới đây.

fun returnNullable(): String? = "dummy data"

fun returnNonNullable(): String = "dummy data"

Đây là bytecode của chúng.

public final static returnNullable()Ljava/lang/String;
@Lorg/jetbrains/annotations/Nullable;() // invisible
 L0
  LINENUMBER 127 L0
  LDC "dummy data"
  ARETURN
 L1
  MAXSTACK = 1
  MAXLOCALS = 0

public final static returnNonNullable()Ljava/lang/String;
@Lorg/jetbrains/annotations/NotNull;() // invisible
 L0
  LINENUMBER 129 L0
  LDC "dummy data"
  ARETURN
 L1
  MAXSTACK = 1
  MAXLOCALS = 0

Ở đây, điểm đáng chú ý nhất là 2 annotation @Lorg/jetbrains/annotations/Nullable;()@Lorg/jetbrains/annotations/NotNull;(). Mặc dù kiểu trả về của cả 2 hàm đều là Ljava/lang/String, hàm returnNullable được đánh dấu là @Nullable, còn hàm returnNonNullable được đánh dấu là @NotNull.

Bản thân những annotation này không làm thay đổi cách JVM chạy bytecode. Tuy nhiên compiler có thể dùng chúng để kiểm tra xem một kiểu dữ liệu có là nullable hay không. Nhờ đó, compiler sẽ báo lỗi nếu như ta viết sai code, như trong ví dụ dưới đây.

val nonNull: String = returnNullable() // type mismatch

Kiểm tra kiểu dữ liệu của tham số trong hàm

Trong ví dụ tiếp theo, ta sẽ thêm 2 tham số vào hàm returnNullable.

fun returnNullable(canBeNull: String?, notNull: String): String? = "dummy data"

Bytecode lúc này sẽ có sự thay đổi.

public final static returnNullable(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
@Lorg/jetbrains/annotations/Nullable;() // invisible
  // annotable parameter count: 2 (visible)
  // annotable parameter count: 2 (invisible)
  @Lorg/jetbrains/annotations/Nullable;() // invisible, parameter 0
  @Lorg/jetbrains/annotations/NotNull;() // invisible, parameter 1
 L0
  ALOAD 1
  LDC "notNull"
  INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkNotNullParameter (Ljava/lang/Object;Ljava/lang/String;)V
 L1
  LINENUMBER 127 L1
  LDC "dummy data"
  ARETURN
 L2
  LOCALVARIABLE canBeNull Ljava/lang/String; L0 L2 0
  LOCALVARIABLE notNull Ljava/lang/String; L0 L2 1
  MAXSTACK = 2
  MAXLOCALS = 2

Một lần nữa ta lại thấy là trong bytecode cả 2 tham số đều là Ljava/lang/String. Nhưng bây giờ ta có thêm 2 annotation mới, tương ứng với 2 tham số đó. Chúng lần lượt được đánh dấu là @Nullable@NotNull. Điều này là đúng vì 2 tham số của ta lần lượt là String?String.

Kiểm tra null cho tham số generic

Kiểu generic không có annotation

Trường hợp phức tạp nhất là kiểm tra null trong generic typing. Ta xét thử class đơn giản dưới đây.

class DummyClass {
    fun genericType(): Pair<String, String?> {
        throw NotImplementedError()
    }
}

Hàm genericType của nó tương ứng với bytecode như sau.

public final genericType()Lkotlin/Pair;
@Lorg/jetbrains/annotations/NotNull;() // invisible
 L0
  LINENUMBER 3 L0
  NEW kotlin/NotImplementedError
  DUP
  ACONST_NULL
  ICONST_1
  ACONST_NULL
  INVOKESPECIAL kotlin/NotImplementedError.<init> (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
  CHECKCAST java/lang/Throwable
  ATHROW
 L1
  LOCALVARIABLE this LDummyClass; L0 L1 0
  MAXSTACK = 5
  MAXLOCALS = 1

Ta thấy có một annotation @NotNull ở đây. Nhưng đó là annotation của cả kiểu Pair<out A, outB>. Nó không đánh dấu tính nullable cho từng biến generic của Pair.

Vậy mà compiler vẫn có thể phát hiện khi ta viết sai code, như trong trường hợp sau: val (a: String, b: String) = getGenericType(). Có nghĩa là phải có một cách khác để đánh dấu tính nullable cho tham số generic.

Metadata sẽ có ích trong trường hợp này

Metadata trong bytecode của Kotlin có chứa thông tin về tham số của kiểu generic. Đây là metadata của DummyClass.

  @Lkotlin/Metadata;(mv={1, 7, 0}, k=1, d1={"\u0000\u0016\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\u0008\u0002\n\u0002\u0018\u0002\n\u0002\u0010\u000e\n\u0000\u0018\u00002\u00020\u0001B\u0005\u00a2\u0006\u0002\u0010\u0002J\u0014\u0010\u0003\u001a\u0010\u0012\u0004\u0012\u00020\u0005\u0012\u0006\u0012\u0004\u0018\u00010\u00050\u0004\u00a8\u0006\u0006"}, d2={"LDummyClass;", "", "()V", "genericType", "Lkotlin/Pair;", "", "Demo"})

Trông không được dễ hiểu cho lắm đúng không? Chúng ta sẽ giải mã metadata này bằng package kotlinx-metadata-jvm.

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-metadata-jvm:0.6.0")
}

Đọc flag nullable từ metadata

Dưới đây là code để kiểm tra tính nullable của tham số kiểu generic trong kiểu trả về của hàm genericType.

val metadataAnnotation = Metadata(
    kind = 1, // giá trị trường k trong metadata
    metadataVersion = intArrayOf(1, 7, 0), // giá trị trường mv
    // map với d1
    data1 = arrayOf(<value of the d1 field>),
    // map với d2
    data2 = arrayOf(<value of the d2 field>),
)

val metadata = KotlinClassMetadata.read(metadataAnnotation)
// Ta dùng KotlinClassMetadata.Class ở đây vì DummyClass là một class
val classMetadata = metadata as KotlinClassMetadata.Class
val kclass = classMetadata.toKmClass()

// DummyClass chỉ có một hàm. Vì thế ta đọc phần tử đầu tiên của kclass.functions,
// sau đó ta đọc biến số trong kiểu trả về của nó
val returnTypeArguments = kclass.functions.first().returnType.arguments
// Vì kiểu trả về có 2 tham số generic, ta đọc phần tử thứ 1 và 2 trong mảng returnTypeArguments,
// rồi ta đọc ra kiểu của chúng
val firstArgType = returnTypeArguments[0].type
val secondArgType = returnTypeArguments[1].type

// Với mỗi kiểu, ta kiểm tra flag của nó xem có phải là nullable không
val isFirstArgNullable = Flag.Type.IS_NULLABLE(firstArgType?.flags!!)
val isSecondArgNullable = Flag.Type.IS_NULLABLE(secondArgType?.flags!!)

// Và ta xuất kết quả ra console
println("isFirstArgNullable: $isFirstArgNullable, isSeconArgNullable: $isSecondArgNullable")

Kết quả của đoạn code ở trên là như sau.

isFirstArgNullable: false, isSeconArgNullable: true

Có thể thấy là thông tin trong metadata là khớp với kiểu Pair<String, String?> mà ta sử dụng. Compiler có thể dùng thông tin này để báo lỗi tại thời điểm compile.

Trường hợp gọi code Java bằng Kotlin

Một trường hợp đáng chú ý nữa là khi ta gọi code Java bằng Kotlin. Nếu hàm Java sử dụng String thì trong Kotlin ta nên dùng String hay String?. Trong trường hợp này bytecode không có annotation cũng như metadata.

Câu trả lời là Kotlin cho phép ta coi kiểu dữ liệu ở đây là platform type. Người dùng có thể sử dụng String hay String? tuỳ thích. Compiler sẽ tin vào lựa chọn này. Ta sẽ thử qua ví dụ dưới đây.

// Code Java
public class Container {
    public static String FromJava() {
        return "dummy";
    }
}

// Code Kotlin
val canBeNull: String? = Container.FromJava()
val nonNull: String = Container.FromJava()

Đoạn code Kotlin ở trên tương ứng với bytecode dưới đây

// val nullable: String? = Container.FromJava()
LINENUMBER 88 L2
INVOKESTATIC Container.FromJava ()Ljava/lang/String;
ASTORE 1

// val nonNull: String = Container.FromJava()
LINENUMBER 89 L3
INVOKESTATIC Container.FromJava ()Ljava/lang/String;
DUP
LDC "Container.FromJava()"
INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkNotNullExpressionValue (Ljava/lang/Object;Ljava/lang/String;)V
ASTORE 2

Như đã thấy, khi ta gán giá trị trả về của hàm FromJava cho một biến String thì compiler sẽ tự gọi hàm checkNotNullExpressionValue. Hàm này sẽ kiểm tra xem giá trị trả về đó có thật sự không null hay không. Tức là mặc dù dòng thứ 2 có thể compile được nhưng nó vẫn có thể sẽ gây ra lỗi tại runtime.

Kết thúc

Kotlin sử dụng kết hợp cả annotation lẫn metadata để implement tính năng null safety. Cho dù ta có dùng String hay String? thì đối với JVM kiểu dữ liệu vẫn là Ljava/lang/String. Nhờ đó, tính năng null safety không ảnh hưởng đến sự tương thích giữa Kotlin và Java.

A software developer from Vietnam and is currently living in Japan.

Leave a Reply