1. 引言

Kotlin 允许我们在类构造函数中为参数设置默认值,这一特性在很多场景下非常实用。但有一种情况它无能为力:当传入 null 值时,如何让一个非可选(non-optional)参数自动使用默认值?

比如,我们接收外部数据(如 JSON 反序列化、RPC 调用等),字段可能是 null,但我们希望最终构建的对象字段是非空的,并且有合理的默认值。

本文将探讨几种简洁高效的解决方案,避免在每次构造对象时都手动写 ?: 判断,✅ 提升代码整洁度,❌ 减少重复“踩坑”。

2. 非可选参数与 null 的矛盾

先看问题本质。假设有如下数据类:

data class Person(val name: String, val age: Int)

Kotlin 支持参数默认值:

data class PersonWithDefaults(val name: String = "John", val age: Int = 30)

但这只在调用方不传参时生效。如果传的是 null,默认值不会触发:

val nullableName: String? = "John"
val nullableAge: Int? = null

// ❌ 无法使用默认值,必须手动处理 null
Person(
    name = nullableName ?: "John",
    age = nullableAge ?: 0
)

这就引出核心问题:

能否在传入 null 时,自动使用默认值填充非可选参数?

下面介绍几种优雅解法。

3. 处理 null 值的多种方式

Kotlin 的灵活性提供了多种实现路径,各具优势,按需选择即可。

3.1. 利用类属性处理 null

最直接的方式:构造函数接收可空类型,内部属性转为非空并设置默认值。

class PersonClassSolution(nullableName: String?, nullableAge: Int?) {
    val name: String = nullableName ?: "John"
    val age: Int = nullableAge ?: 30
}

同样适用于 data class,只需将构造参数设为 private,防止外部直接访问:

data class PersonDataClassSolution(private val nullableName: String?, private val nullableAge: Int?) {
    val name: String = nullableName ?: "John"
    val age: Int = nullableAge ?: 30
}

✅ 优点:

  • 逻辑清晰,易于理解
  • 完全隐藏可空字段,暴露安全 API

⚠️ 注意:data classcopy()componentN() 只基于主构造函数参数,这里会包含 nullableNamenullableAge —— 如果你关心这点,慎用此方案。

3.2. 使用次构造函数(Secondary Constructor)

data class 要求主构造函数定义所有状态字段。若想主构造函数参数是非空的,可通过次构造函数桥接可空输入:

data class PersonWithAdditionalConstructor(val name: String, val age: Int) {
    constructor(name: String?, age: Int?) : this(name ?: "John", age ?: 0)
}

调用方式:

val person = PersonWithAdditionalConstructor(null, null) // name="John", age=0

✅ 优点:

  • 主构造函数保持干净非空
  • 支持标准 data class 特性(copy, equals 等)

❌ 缺点:

  • 暴露了两个构造函数,调用者仍可能误用主构造函数传 null
  • 若想完全屏蔽主构造函数入口,需配合其他手段

3.3. 使用自定义 Getter

通过私有可空字段 + 公共非空属性的 getter 实现转换:

data class PersonWithGetter(private val nullableName: String?, private val nullableAge: Int?) {
    val name: String
        get() = nullableName ?: "John"

    val age: Int
        get() = nullableAge ?: 0
}

效果与 3.1 类似,但更强调“读取时计算”,而非“构造时赋值”。

✅ 优点:

  • 封装良好,外部只能访问非空属性
  • 适合需要延迟计算或动态默认值的场景

⚠️ 注意:字段仍存在于 data class 的结构中,toString()copy() 会包含原始可空字段。

3.4. 使用 invoke 操作符(推荐)

终极方案:**私有主构造函数 + companion object 中的 operator fun invoke**。

data class PersonWithInvoke private constructor(val name: String, val age: Int) {
    companion object {
        operator fun invoke(name: String? = null, age: Int? = null) = 
            PersonWithInvoke(name ?: "John", age ?: 0)
    }
}

调用方式极其自然,看起来就像普通构造函数:

val p1 = PersonWithInvoke("Alice", 25)
val p2 = PersonWithInvoke(null, null) // 自动使用默认值

✅ 优点:

  • 调用语法简洁,无感知地处理 null
  • 主构造函数完全私有,杜绝非法构造
  • 不影响 data class 的任何行为(copy, componentN, toString 等)
  • 支持默认参数(如 invoke 中的 = null

⚠️ 注意:需要了解 Kotlin 操作符重载机制,特别是 invoke 的作用 —— 它允许对象像函数一样被调用。

4. 总结

面对“传入 null 时自动使用默认值”的需求,Kotlin 提供了多种实现方式:

方案 是否推荐 适用场景
类属性赋值 ⚠️ 中 快速原型,不介意 copy() 包含中间字段
次构造函数 ✅ 推荐 需要保留主构造函数但提供兼容入口
自定义 Getter ⚠️ 中 字段需动态计算,或作为过渡方案
invoke 操作符 ✅✅ 强烈推荐 生产环境首选,API 最干净

📌 最佳实践建议
优先使用 invoke 模式,它既保证了类型的严谨性,又提供了最友好的调用体验,是处理此类“智能构造”场景的优雅解法。

遇到类似问题不必再到处写 ?:,一个 invoke 搞定,代码瞬间清爽。


原始标题:Defaulting Null Values on Non-Optional Parameters in Kotlin