Note: see the link below for the English version of this article.
https://duongnt.com/kotlin-null-safety
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 String
và String?
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?
và 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;()
và @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
và @NotNull
. Điều này là đúng vì 2 tham số của ta lần lượt là String?
và 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.