Kotlin空安全

前言

访问空引用的成员变量就会导致空指针异常,在Java中被称作NullPointerException,简称NPE,Kotlin中NPE产生的原因只可能是以下几种:

  • 显式调用 throw NullPointerException()

  • 使用了!!操作符

  • 数据在初始化时不一致,例如:

  • Java互操作

    • 企图访问平台类型null 引用的成员;

    • 用于 Java 互操作的泛型类型的可空性问题,例如一段 Java 代码可能会向 Kotlin 的 MutableList<String> 中加入 null,就需要一个 MutableList<String?> 才能处理。

    • 由外部 Java 代码引发的其他问题。

  1. 泄漏this

当你在构造函数中传递 this 引用到其他对象时,如果这些对象试图使用 this,而此时对象尚未完全初始化,就可能导致空指针异常。

class MyClass {
    private val value: String

    init {
        // 假设在这里传递了 this 并且立即使用了某个未初始化的属性
        SomeOtherClass.register(this)
        value = "Initialized"
    }

    fun printValueLength() {
        // 如果在 value 初始化之前被调用,会导致问题
        println(value.length)
    }
}

object SomeOtherClass {
    fun register(myClass: MyClass) {
        // 模拟在注册时立即调用
        myClass.printValueLength()
    }
}

fun main() {
    val myObject = MyClass()
}

在这个例子中,SomeOtherClass.registervalue 初始化之前调用了 printValueLength,这导致使用未初始化的属性,从而引发空指针异常

  1. 超类构造函数调用一个开放成员

open class Base {
    init {
        println("Base init block")
        // 调用开放成员
        println("Value from openMethod: ${openMethod()}")
    }

    open fun openMethod(): String {
        return "Base"
    }
}

class Derived : Base() {
    private val value: String = "Derived"

    override fun openMethod(): String {
        // 使用未初始化的状态
        return value
    }
}

fun main() {
    val derived = Derived()
}

在构造派生类的新实例的过程中,第一步完成其基类的初始化 (在之前只有对基类构造函数参数的求值),这意味着它发生在派生类的初始化逻辑运行之前。然后这里Base 的构造函数调用了 openMethod,而在 Derived 中,value 尚未初始化,从而导致潜在的空指针异常。

声明可空/非空变量

可空变量

只需要在声明变量的时候在变量的类型后面加个?操作符,即可将变量声明为可空,例如:

var canNullVar: String? = null // 合法

非空变量

在声明变量时不在变量的类型加?操作符,或者直接给该变量赋值,声明的变量就是非空的,例如:

var notNullVar: String = "12345" // 合法
var notNullVar: String = null // 非法
// --------------------
var notNullVar = "12345" // 合法
notNullVar = null // 非法

如何访问可空变量的属性

在条件中检测null

与Java一样,可以在用一个可空的变量前,先对该变量进行判空,再继续使用,例如:

fun main() {
    val b: String? = "Kotlin"
    if (b != null && b.length > 0) {
        print("String of length ${b.length}")
    } else {
        print("Empty string")
    }
}

这里b是一个可空的String类型,在我们尝试调用blength属性前,我们对b做了判空,再去使用。但这种方式要求b是不可变的情况(即在检测与使用之间没有修改过的局部变量 ,或是有幕后字段且不可覆盖的 val 成员),因为否则可能会发生在检测之后 b 又变为 null 的情况。一个典型的例子就是,我们在判断类成员变量不为空后,尝试获取类成员变量的属性依旧会提示空指针异常

安全调用

  1. 使用安全操作符?.

在一个可空的变量后面接?.操作符,即可安全地访问一个可空变量的属性,这在链式调用中很有用,比如下面我只是想判断一个集货任务的设备状态是否是处于Pending状态,如果按照上面的方式,将会写比较长的判断逻辑,但通过?.操作符,我们就可以这样实现:

DeviceStatus.PENDING_MERGE != mergeTaskInfo?.mergeTaskDeviceInfo?.currentDevice?.deviceMergeStatus

当在这一环中任意一个为null时,就会停止继续往下调用,直接返回null,显然不会与一个Int类型的值相等

  1. 使用let配合安全操作符
fun main() {
    val listWithNulls: List<String?> = listOf("Kotlin", null)
    for (item in listWithNulls) {
        item?.let { println(it) } // 输出 Kotlin 并忽略 null
    }
}

使用let只会对非空值做某个操作

!!操作符

非空断言运算符!!将任何值转换为非空类型,若该值为 null 则抛出异常。例如

var canNullVar: String? = null
str!!.length

此时会报Exception in thread "main" java.lang.NullPointerException错误

补充

赋值

  1. 安全调用也可以出现在赋值的左侧。这样,如果调用链中的任何一个接收者为 null 都会跳过赋值,而右侧的表达式根本不会求值,例如:
// 如果 `person` 或者 `person.department` 其中之一为空,都不会调用该函数:
person?.department?.head = managersPool.getManager()

Elvis操作符

当有一个可空的引用 b 时,可以说“如果 b 不是 null,就使用它;否则使用某个非空的值”:

val l: Int = if (b != null) b.length else -1

除了写完整的 if 表达式,还可以使用 Elvis 操作符 ?: 来表达:

val l = b?.length ?: -1

如果 ?: 左侧表达式不是 null,Elvis 操作符就返回其左侧表达式,否则返回右侧表达式。 请注意,当且仅当左侧为 null 时,才会对右侧表达式求值。

因为 throwreturn 在 Kotlin 中都是表达式,所以它们也可以用在 elvis 操作符右侧。这可能会很方便,例如,检测函数参数:

fun foo(node: Node): String? {
    val parent = node.getParent() ?: return null
    val name = node.getName() ?: throw IllegalArgumentException("name expected")
    // ……
}

类型转换

如果对象不是目标类型,那么常规类型转换可能会导致 ClassCastException。 另一个选择是使用安全的类型转换,如果尝试转换不成功则返回 null

val aInt: Int? = a as? Int

可空类型的集合

如果你有一个可空类型元素的集合,并且想要过滤非空元素,你可以使用 filterNotNull 来实现:

val nullableList: List<Int?> = listOf(1, 2, null, 4)
val intList: List<Int= nullableList.filterNotNull()

Kotlin是如何实现空安全的

可空成员变量

class SequenceTest {
    var sequence: String? = null
}

将其转为字节码之后再生成Java代码,如下:

点击Tools -> Kotlin -> show Kotlin bytecode -> Decomplie

import kotlin.Metadata;
import org.jetbrains.annotations.Nullable;

@Metadata(
   mv = {1, 9, 0},
   k = 1,
   d1 = {"\u0000\u0014\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\b\u0002\n\u0002\u0010\u000e\n\u0002\b\u0005\u0018\u00002\u00020\u0001B\u0005¢\u0006\u0002\u0010\u0002R\u001c\u0010\u0003\u001a\u0004\u0018\u00010\u0004X\u0086\u000e¢\u0006\u000e\n\u0000\u001a\u0004\b\u0005\u0010\u0006\"\u0004\b\u0007\u0010\b¨\u0006\t"},
   d2 = {"LSequenceTest;", "", "()V", "sequence", "", "getSequence", "()Ljava/lang/String;", "setSequence", "(Ljava/lang/String;)V", "Study"}
)
public final class SequenceTest {
   @Nullable
   private String sequence;

   @Nullable
   public final String getSequence() {
      return this.sequence;
   }

   public final void setSequence(@Nullable String var1) {
      this.sequence = var1;
   }
}

可以看到,只是在成员变量上面加了个Nullable注解

非空可变成员变量

class SequenceTest {
    var sequence: String = "Hello World"
}

转成Java实现后:

public final class SequenceTest {
   @NotNull
   private String sequence = "Hello World";

   @NotNull
   public final String getSequence() {
      return this.sequence;
   }

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

public static void checkNotNullParameter(Object value, String paramName) {
    if (value == null) {
        throwParameterIsNullNPE(paramName);
    }
}

添加了NotNull注解,并在赋值时做了判空抛出空指针异常的处理

非空不可变成员变量

class SequenceTest {
    val sequence: String = "Hello World"
}
public final class SequenceTest {
   @NotNull
   private final String sequence = "Hello World";

   @NotNull
   public final String getSequence() {
      return this.sequence;
   }
}

直接加了个final关键字在成员变量中,且没有实现set方法

延迟加载成员变量

class SequenceTest {
    lateinit var sequence: String
}
public final class SequenceTest {
   public String sequence;

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

      return var10000;
   }

   public final void setSequence(@NotNull String var1) {
      Intrinsics.checkNotNullParameter(var1, "<set-?>");
      this.sequence = var1;
   }
}
public static void throwUninitializedPropertyAccessException(String propertyName) {
    throwUninitializedProperty("lateinit property " + propertyName + " has not been initialized");
}

可以看到相比于非空可变成员变量,它在get方法的实现内还加了判空处理,也就是我们会看到的延迟初始化变量未被初始化的异常

参数可空方法

fun test(str: String?) {}
public final class SequenceTestKt {
   public static final void test(@Nullable String str) {
   }
}

同样只是在方法名内加了个Nullable注解

参数非空方法

fun test(str: String) {}
public final class SequenceTestKt {
   public static final void test(@NotNull String str) {
      Intrinsics.checkNotNullParameter(str, "str");
   }
}

同样,除了在方法名加了个NotNull注解外,还做了判空的处理

?.的实现

fun test(str: String?) {
    println(str?.length)
}
public final class SequenceTestKt {
   public static final void test(@Nullable String str) {
      Integer var1 = str != null ? str.length() : null;
      System.out.println(var1);
   }
}

实际上就是帮我们做了判空,如果非空才做后续处理,让我们再加一层看看:

fun test(str: CharSequence?) {
    println(str?.toString()?.length)
}
public final class SequenceTestKt {
   public static final void test(@Nullable CharSequence str) {
      Integer var2;
      label12: {
         if (str != null) {
            String var10000 = str.toString();
            if (var10000 != null) {
               var2 = var10000.length();
               break label12;
            }
         }

         var2 = null;
      }

      Integer var1 = var2;
      System.out.println(var1);
   }
}

使用了局部变量用于赋值最后将这个局部变量赋值给结果

!!的实现

fun test(str: String) {
    str!!.length
}
public final class SequenceTestKt {
   public static final void test(@NotNull String str) {
      Intrinsics.checkNotNullParameter(str, "str");
      str.length();
   }
}

也就是在实际调用前一下断言处理,如果真的非空就继续执行,如果为空直接抛出异常

let的实现

Kotlin的源码在上面有,这里就不贴了,直接看是如何实现的,也就是先判空,非空才做后续操作

public final class SequenceTestKt {
   public static final void main() {
      List listWithNulls = CollectionsKt.listOf(new String[]{"Kotlin", null});
      Iterator var2 = listWithNulls.iterator();

      while(var2.hasNext()) {
         String item = (String)var2.next();
         if (item != null) {
            int var5 = false;
            System.out.println(item);
         }
      }

   }

   // $FF: synthetic method
   public static void main(String[] var0) {
      main();
   }
}

Elvis的实现

fun test(str: String?) {
    str?: return
    println(str.length)
}
public final class SequenceTestKt {
   public static final void test(@Nullable String str) {
      if (str != null) {
         int var1 = str.length();
         System.out.println(var1);
      }
   }
}

as的实现

fun test(str: Any?) {
    val result = str as String
}
public final class SequenceTestKt {
   public static final void test(@Nullable Object str) {
      if (str == null) {
         throw new NullPointerException("null cannot be cast to non-null type kotlin.String");
      } else {
         String result = (String)str;
      }
   }
}

直接判空,如果为空就抛出异常

as?的实现

fun test(str: Any?) {
    val result = str as? String
}
public final class SequenceTestKt {
   public static final void test(@Nullable Object str) {
      Object var10000 = str;
      if (!(str instanceof String)) {
         var10000 = null;
      }

      String result = (String)var10000;
   }
}

先新建一个临时变量,将原值赋值给它,随后判断是否为特定类型,如果不是的话赋值为null,再做强转。因此经过as?处理后的参数可能为null,后续使用需要使用?.

总结

一些实践经验

  1. 减少成员变量

尽可能少地减少在ActivityFragment中存储成员变量,如果实在需要一个成员变量用于记录上一次的值等信息,可以尝试将该成员变量移动到ViewModel中,然后由ViewModel提供一个get方法。其实这样修改表面上没有减少成员变量,但是如果将成员变量放到ViewModel中,结合LiveData,会显著减少成员变量的数量

  1. 尽量使用非空类型

尽量使用非空类型,只有在明确需要时才用可空类型。

在网络请求中,我们大多数情况下,需要后端返回的值非空才会做后续处理,因此我们可以在定义MultableLiveData时将类型设置为非空,确保视图监听获得的值一定非空,再去做后续处理,这样就可以减少判空处理

  1. 非必要不使用!!操作符

什么时候是必要呢?这里有一个典型的例子,在Fragment中,实际上有两套生命周期,我们一般会使用ViewBinding来快速获取视图的控件,ViewBinding的生命周期应该是跟Fragment的视图生命周期保持一致,那么它应该在Fragment中是可空的,但我们平时使用的时候,肯定不希望每次都加个?.,Android官方的推荐写法是:

private var _binding: ResultProfileBinding? = null
// This property is only valid between onCreateView and
// onDestroyView.
private val binding get() = _binding!!

override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
): View? {
    _binding = ResultProfileBinding.inflate(inflater, container, false)
    val view = binding.root
    return view
}

override fun onDestroyView() {
    super.onDestroyView()
    _binding = null
}

视图绑定  |  Android Developers

因此,对于这种理论上是可空的,而且应当被回收,但实际使用时我们可以确定它一定不为空,可以参考上面的方式来实现

  1. 使用Gson序列化与反序列化,尽量使用可空

后端返回的字段,一般把它标记为可空会比较好。这样做虽然对我们来说变得更麻烦了,但是如果遇到后端异常返回的时候,至少不会引发crash,因为空安全不是完全的安全,下面举一个例子:

data class User(
    val id: Int,
    val name: String,
    val age: Int
)

fun main() {
    val json = """
        {
            "id": "1234254665",
            "name": null,
            "age": 20
        }
    """.trimIndent()
    val user = Gson().fromJson(json, User::class.java)
    println(user.name.length)
}

编译器是允许我们直接获取user.name.length的,但是返回的json里面,name是null,这就引发了空指针问题。

即使我们这样写,同样会抛出空指针异常

data class User(
    val id: Int,
    val name: String = "User",
    val age: Int
)

这是因为创建对象的途径是通过sun.misc.Unsafe进行创建的对象默认赋值的是一个空值null,不会执行我们的默认赋值操作。

4.1 手动检查与设置默认值

data class User(
    val id: Int,
    var name: String?,
    val age: Int
)
fun main() {
    val json = """
        {
            "id": "1234254665",
            "name": null,
            "age": 20
        }
    """.trimIndent()

    val user = Gson().fromJson(json, User::class.java)
    if (user.name.isNullOrEmpty()) {
        user.name = "User Name"
    }
    println(user.name)
}

4.2 自定义反序列化器

fun main() {
    val json = """
        {
            "id": 1234254665,
            "name": null,
            "age": 20
        }
    """.trimIndent()

    val gsonBuilder = GsonBuilder()
    gsonBuilder.registerTypeAdapter(User::class.java, UserDeserializer())
    val gson = gsonBuilder.create()
    val user = gson.fromJson(json, User::class.java)
    println(user.name)
}

class UserDeserializer : JsonDeserializer<User> {
    override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): User {
        val jsonObject = json.asJsonObject

        val id = jsonObject.get("id").asInt
        val name = if (jsonObject.has("user_name") && !jsonObject.get("user_name").isJsonNull) {
            jsonObject.get("user_name").asString
        } else {
            "User"
        }
        val age = jsonObject.get("age").asInt

        return User(id, name, age)
    }
}

先检查字段是否存在,并提供默认值

  1. 接口的入参根据是否必传选择可空或非空

对于必传的参数,将方法名中参数类型设置为非空,可以减少错误地请求发生。对于部分可以不传的参数,将其设置为可空,并为它默认赋值一个null,可以有效减少赋值,下面一个提交质检的Body,只有当质检结果为damage时需要给可空对象进行赋值,后端又不想我们把所有的参数都传给他,所以先定义一个data class如下

data class RoughQcBody(
    @SerializedName("task_id")
    val taskId: String,
    @SerializedName("sku_id")
    val skuId: String,
    @SerializedName("qc_qty")
    val qcQty: Int,
    @SerializedName("damage_qty")
    var damageQty: Int? = null,
    @SerializedName("sku_quality")
    val skuQuality: Int,
    @SerializedName("damage_type")
    var damageType: Int? = null,
    @SerializedName("new_tracking_id")
    var newTrackingId: String? = null,
    @SerializedName("damage_rate")
    var damageRate: Int? = null,
    @SerializedName("damage_limit")
    val damageLimit: Int,
    @SerializedName("qc_decision")
    var qcDecision: Int? = null,
    @SerializedName("reject_all_qty")
    var rejectAllQty: Int? = null,
)

那么在qc结果为good的时候,就可以这样写:

if (SystemEnum.SkuQualityType.GOOD == skuQuality) {
    val roughQcBody = RoughQcBody(
        taskId = taskId,
        skuId = skuId,
        qcQty = checkSkuInfo.remainingQcQty,
        skuQuality = skuQuality,
        damageLimit = checkSkuInfo.damageLimit,
    )

参考文档

  1. 空安全 · Kotlin 官方文档 中文版

  2. Kotlin刨根问底(一):你真的了解Kotlin中的空安全吗?空安全不是Kotlin特有的,其他很多编程语言也有,下面 - 掘金

  3. Kotlin的空安全真的安全吗?是一个常见的空安全异常,向空安全的变量赋了Null 值。但值变量是也是来自空安全变量,为 - 掘金

  4. 继承 · Kotlin 官方文档 中文版

posted @ 2025-02-06 20:06  ZJHqs  阅读(98)  评论(0)    收藏  举报