Kotlin 基础

从只读引用、静态类型与空安全出发,掌握 Kotlin 的表达式、控制流和函数调用边界。

难度 入门 时长 标准深度约 13分钟
版本 Kotlin 2.4.10
what

Kotlin 是静态类型语言。它用 valvar 区分引用能否重新赋值,并把可空性写进类型。

trap

val 不等于对象不可变,类型推断也不会替你做隐式数字转换;!! 只会把空值问题推迟到运行时。

fix

默认使用 val,在输入边界解析并验证数据,通过空值检查、?.?: 和穷尽的 when 表达真实分支。

是什么,为什么存在

Kotlin 的基础语法围绕一个简单目标展开:让程序的约束直接出现在代码里。名称能否重新赋值由 valvar 表达,值能否为 null 由类型末尾的 ? 表达,分支可以直接产生结果。编译器因此能在程序运行前拒绝一批不成立的状态。

Kotlin 是静态类型语言。每个表达式都有编译期类型,但局部变量通常不必重复写出类型,因为编译器会做 类型推断(type inference) 。推断不会把语言变成动态类型:val count = 3 一旦推断为 Int,之后就不能把字符串赋给 count

这些规则会出现在任何 Kotlin 程序中,无论目标是 JVM、JavaScript、Native 还是 Wasm。本页使用普通 Kotlin/JVM 命令行程序说明语言核心,不涉及 Android 生命周期、协程、集合操作链或类设计;这些内容各有独立主题。

这套语法让所有权和失败路径更容易读,少写几个字符只是附带收益。看到 String?,调用方就知道缺失值是契约的一部分;看到没有 else 的枚举 when,维护者就知道新增枚举成员会触发编译检查。

工作原理

声明绑定名称

val 声明只读引用:初始化完成后,这个名称不能指向另一个值。var 声明可重新赋值的引用。局部 val 可以先声明、后赋值一次,但这种写法必须给出类型,而且编译器要能证明每条使用路径都已完成初始化。

只读引用与不可变对象不是一回事。val quantities = intArrayOf(1, 2) 禁止执行 quantities = intArrayOf(3),却允许执行 quantities[0] = 3,因为数组对象本身仍可修改。选择 val 约束的是名称,而对象是否可变由其类型和 API 决定。

var 适合循环计数、状态机当前位置等确实会变化的绑定。把所有局部量都写成 var 会扩大可变范围,使读者必须追踪更多赋值点。一个实用的判断是:如果删除重新赋值后代码仍能成立,这个绑定就应是 val

推断仍然产生确定类型

有初始化值时,编译器通常能推断局部变量类型。整数文字通常推断为 Int,超出 Int 范围时推断为 Long;带小数点的文字默认是 DoublefF 后缀表示 Float。类型需要承载接口契约、允许 null 或避免读者猜测时,应显式写出。

Kotlin 的数字类型不会在赋值和函数传参时自动相互转换。需要把 Int 交给接收 Long 的 API 时,写 count.toLong()。显式转换让截断、溢出和精度变化留在代码审查的视线内。

混合数字表达式可以按运算符重载得到较宽的结果,但这不等于所有上下文都有隐式转换。尤其要留意整数除法:5 / 2 的结果是 2,要得到 2.5,至少一个操作数必须是浮点类型。

可空性属于类型

String 不接受 null,而 可空类型(nullable type) String? 接受。可空接收者不能直接访问普通成员;代码必须先证明值非空,使用安全调用 ?.,或者用 Elvis 操作符 ?: 提供另一条路径。

安全调用在接收者为 null 时返回 null,因此调用链的结果往往仍是可空类型。profile?.address?.city 中任何一级缺失,整个表达式都得到 null?: 只在左侧为 null 时计算右侧,所以右侧可以是默认值、returnthrow

编译器能证明一个值在某条控制流路径上具有更具体类型时,会执行 智能转换(smart cast) 。例如,value == null 后立即 return,后续路径中的 value 就可按非空类型使用。这个证明依赖稳定性;可能被其他代码改变的开放属性或可变属性通常不能直接智能转换。

分支也能产生值

ifwhen 既能控制流程,也能作为表达式返回结果。作为值使用的 if 必须覆盖两侧分支,各代码块的最后一个表达式就是该分支结果。这样可以直接初始化 val,避免先声明一个可变临时量再在每个分支赋值。

when 可以匹配值、范围、类型,也可以省略主题后按顺序检查布尔条件。编译器要求作为表达式使用的 when 穷尽 when(exhaustive when) 。对枚举或密封层级列出所有已知分支而不写宽泛的 else,新增成员时就会得到编译错误,而不是悄悄落入默认行为。

分支按源码顺序检查,第一个匹配项胜出。范围可能重叠时,顺序就是业务规则的一部分。把更窄、更特殊的条件放在宽泛条件之前,通常比依赖读者自行推导覆盖关系更清楚。

循环处理序列工作

for 循环遍历能提供迭代器的值,常见目标包括范围、数组和集合。1..5 包含两端,1 until 5 不包含末端;降序使用 downTo,间隔使用 step。边界差异会直接影响次数,生成代码尤其容易把 ..until 混用。

while 在每轮之前判断条件,do-while 至少执行一次。break 结束最近一层循环,continue 跳到下一轮;带标签的跳转能跨越嵌套循环,但也会增加控制流成本。能通过提取函数或改写条件消除标签时,代码通常更容易测试。

函数公开契约

函数参数必须声明类型。块函数通常显式声明返回类型,尤其是公共 API;单表达式函数可以从表达式推断返回类型。没有有意义返回值的函数返回 Unit,返回类型通常可以省略。

默认参数在声明处定义缺省行为,命名参数让调用方写出选择。命名参数还允许调整实参顺序,但名称属于 Kotlin API 的源码契约;调用 Java 方法时不能依赖 Java 参数名使用 Kotlin 命名实参。

前置条件可以用 require 表达。条件失败时会抛出 IllegalArgumentException,并把错误定位在调用方给出的无效数据上。对于可以预期的用户输入错误,返回可空值或领域结果类型往往比抛异常更合适;边界应由调用契约决定。

示例

val 开始,只缩小必要的可变范围

第一个程序保留一个可变计数器,但客户名、数组引用、单价和结果都是只读绑定。数组内容仍可修改,这正好展示了 val 约束引用而不冻结对象。

references_and_values.kt
fun main() {
    val customer = "Ari"
    val quantities = intArrayOf(2, 1, 3)

    // val 阻止引用重新赋值,并不阻止修改数组元素。
    quantities[1] = 2

    // 只有累计量需要重新赋值。
    var itemCount = 0
    for (quantity in quantities) {
        itemCount += quantity
    }

    val unitPriceCents = 1_250
    val subtotalCents = unitPriceCents * itemCount

    println("Customer: $customer")
    println("Quantities: ${quantities.joinToString()}")
    println("Items: $itemCount")
    println("Subtotal cents: $subtotalCents")
}
Customer: Ari
Quantities: 2, 2, 3
Items: 7
Subtotal cents: 8750

金额用整数分表示,避免在这个基础示例中引入二进制浮点舍入。这里的 1_250 仍是普通 Int,下划线只提高数字文字的可读性。若金额与数量的乘积可能超过 Int 范围,应在运算前选择 Long 或专门的金额类型。

在输入边界处理 null 与格式错误

第二个程序把原始字符串解析为 Int?。空白、缺失值和非数字文本都得到 null;调用方再把“无法取得重量”与合法重量的运费档位分开。

nulls_and_branches.kt
fun parseWeight(raw: String?): Int? {
    val normalized = raw?.trim()

    // 提前返回后,normalized 在剩余路径中被智能转换为 String。
    if (normalized.isNullOrEmpty()) return null
    return normalized.toIntOrNull()
}

fun shippingBand(weight: Int): String = when {
    weight <= 0 -> "invalid"
    weight <= 10 -> "standard"
    weight <= 30 -> "heavy"
    else -> "freight"
}

fun main() {
    val inputs = listOf(" 8 ", null, "heavy", "42")

    for (raw in inputs) {
        val weight = parseWeight(raw)
        val band = if (weight == null) "missing" else shippingBand(weight)
        println("${raw ?: "<null>"} -> $band")
    }
}
 8  -> standard
<null> -> missing
heavy -> missing
42 -> freight

toIntOrNull() 把解析失败留在类型中,而不是依赖捕获异常。if 的两侧都产生 String,所以 band 的类型可推断为 String。业务若需要区分“缺失”和“格式错误”,就不应继续把两者压成同一个 null,而应返回能表达不同失败原因的类型。

用默认参数表达常用调用

第三个程序把单位固定在“分”,并用 Long 承载金额。默认参数保留最常见的一件商品、无折扣调用;批量订单通过命名实参写出所有变化点。

functions_and_arguments.kt
fun lineTotal(
    priceCents: Long,
    quantity: Int = 1,
    discountPercent: Int = 0,
): Long {
    require(priceCents >= 0)
    require(quantity >= 0)
    require(discountPercent in 0..100)

    val gross = priceCents * quantity
    return gross * (100 - discountPercent) / 100
}

fun main() {
    val regular = lineTotal(priceCents = 2_500)
    val bulk = lineTotal(
        priceCents = 2_500,
        quantity = 4,
        discountPercent = 10,
    )

    println("Regular cents: $regular")
    println("Bulk cents: $bulk")
}
Regular cents: 2500
Bulk cents: 9000

这里的除法是整数除法,折扣后不足一分的部分会被截去。这不是普遍正确的财务舍入规则,只是函数契约中的具体选择。生产代码应给舍入策略命名并测试边界,而不是让运算符顺序暗中决定结果。

让枚举分支保持穷尽

最后一个程序先把不可信字符串转换为 Access?,再对有限状态做穷尽处理。解析函数需要 else 接住任意文本;路由函数则列出 null 和每个枚举成员,不使用 else

exhaustive_when.kt
enum class Access {
    GUEST,
    MEMBER,
    ADMIN,
}

fun parseAccess(raw: String): Access? = when (raw.lowercase()) {
    "guest" -> Access.GUEST
    "member" -> Access.MEMBER
    "admin" -> Access.ADMIN
    else -> null
}

fun homeRoute(access: Access?): String = when (access) {
    null -> "/sign-in"
    Access.GUEST -> "/welcome"
    Access.MEMBER -> "/account"
    Access.ADMIN -> "/admin"
}

fun main() {
    for (raw in listOf("member", "ADMIN", "unknown")) {
        val access = parseAccess(raw)
        println("$raw -> ${homeRoute(access)}")
    }
}
member -> /account
ADMIN -> /admin
unknown -> /sign-in

如果后来加入 SUPPORThomeRoute 会停止编译,迫使维护者决定新角色的页面。parseAccess 的输入集合是开放的,因此它仍需要默认分支。两个 when 形式相似,但边界契约不同。

陷阱

val 当成深度不可变

修复: 同时检查绑定和对象 API。需要快照时创建副本,需要公开只读视图时返回只读接口;如果调用方必须完全相信数据不会变化,应让类型设计本身表达不可变,而不是只把变量改成 val

依赖隐式数字扩宽

修复: 在语义边界显式调用 toLong()toDouble() 等转换函数,并在运算开始前选好结果类型。转换到更窄的整数类型可能改变数值,浮点转整数会截去小数部分,因此转换本身也需要测试。

!! 消除编译错误

修复: 在接收输入的边界选择策略:用 ?: return 放弃当前操作,用 ?: throw IllegalArgumentException(...) 报告违反契约,或让返回类型继续保留 null。只有外部不变量已经建立、编译器却无法表达时,才考虑局部而有说明的断言。

=== 比较内容

修复: 值比较使用 ==,只有算法确实关心对象身份时才使用 ===。数组是一个额外边界:数组的 == 不做逐元素比较,需要按目的选择 contentEquals()contentDeepEquals()

给有限状态加宽泛 else

修复: 对封闭状态逐项列出分支,让编译器检查穷尽性。只有输入集合本来就是开放的,例如任意字符串、整数或外部协议值,才保留 else,并明确未知输入是拒绝、忽略还是降级。

在复杂表达式中隐藏副作用

修复: 表达式只承担一个容易命名的结果。先完成输入验证,再计算值,最后执行副作用;当分支需要多步工作时,用块和局部名称展开,而不是追求最少行数。

深入 推断的边界由上下文决定

推断的边界由上下文决定

类型推断使用初始化表达式和周围的预期类型。val count = 1 得到 Int,而 val count: Long = 1 中同一个文字受声明类型约束。整数文字本身在解析阶段有特殊的文字类型,编译器会在允许的范围内根据上下文选择具体数字类型。

推断关注当前表达式能够证明的公共类型,而不是未来可能需要的类型。两个分支返回不同具体类时,结果可能被推断为共同父类型;把空值放进结果时,类型通常会变成可空。公共函数若依赖这种细节,显式返回类型能防止实现重构无意间改变调用方看到的契约。

泛型调用也从实参和预期返回类型收集约束。看似无关的显式类型可能改变类型参数推断,因此修复类型错误时不应随意加入强制转换。先确认希望公开的类型,再让声明或函数签名提供清晰的预期类型。

智能转换同样是一种基于控制流的推断。编译器会记录类型检查、空值检查、提前返回以及布尔条件之间的关系。只要值可能在检查后被别处修改,证明就不再成立;把属性读入局部 val,常能同时固定观察到的值并恢复智能转换。

UnitNothing 描述控制流

Unit 表示函数正常完成,但没有要交给调用方的有意义结果。它是一个真实类型,只有一个值 Unit;这与某些语言中完全没有值的 void 不同。块函数不写返回类型时,如果没有返回其他值,通常就推断为 Unit

Nothing 表示表达式永远不会正常产生结果。throw 的类型是 Nothing,永不返回的函数也可以声明为 Nothing。因为 Nothing 可以适配其他结果类型,val name = input ?: throw IllegalArgumentException(...) 的整体类型仍能成为非空字符串类型。

return 也是表达式,所以能放在 Elvis 右侧。val id = parseId(raw) ?: return 把失败路径从当前函数移走,剩余代码中的 id 就是非空值。这种卫语句适合单一失败动作;如果多个失败原因需要不同响应,显式的结果类型会比一串提前返回保留更多信息。

理解这两个类型有助于阅读分支的共同类型。一个分支产生业务值,另一个分支抛异常时,异常分支不会迫使整个表达式退化为 Any。编译器知道正常继续的路径只可能携带业务值。

JVM 表示不是源码类型系统

在 JVM 目标上,非空数字通常可以使用 JVM 原始表示;可空数字和泛型位置通常需要装箱为对象。这个实现细节不改变 Kotlin 源码中的 Int 类型规则,却会影响与 Java API 交互时看到的签名与可空性。

不要用引用相等推导数字是否相等。JVM 可能缓存部分装箱数字对象,使两个相同小数字的引用碰巧相同,而更大的相同数值使用不同对象。数值语义始终使用 ==;对象身份只有在身份本身就是领域概念时才值得检查。

Java 声明经常缺少 Kotlin 所需的可空性信息。编译器把这类边界呈现为平台类型,允许调用方按可空或非空方式接收,但错误选择仍可能在运行时产生空指针异常。平台类型不是可以在 Kotlin 源码里手写的普通类型标注,而是互操作边界上的不完整信息。

处理 Java 返回值时,应尽早把平台类型收窄为明确的 Kotlin 类型。声明 val name: String? = javaApi.name() 会保留缺失可能;如果契约保证非空,可以在边界验证并给出有意义的错误。让平台类型深入业务代码会使每次成员访问都隐含同一个未经说明的假设。

相等、数组与可变内容

Kotlin 的 == 会转换为安全的 equals 调用,并处理左侧为 null 的情况。=== 不调用 equals,只判断引用身份。类是否提供有意义的结构相等,取决于它对 equals 的实现;普通类的默认实现仍可能等同于身份比较。

数组没有把 == 定义为逐元素比较。同一批元素放在两个不同数组对象中,通常不能靠 == 判断内容相等。一维数组使用 contentEquals(),嵌套数组按需要使用 contentDeepEquals();测试代码若选错比较方式,会把正确结果报错,或根本没有验证元素内容。

集合的只读接口与不可变集合也不是同义词。只读引用可能指向仍由别处持有可变别名的对象,随后观察到内容变化。API 需要稳定快照时应复制数据,并明确谁拥有底层可变状态。

审查时直接问:代码约束的是名称、接口、值,还是对象身份?把四者混在一起,短示例往往仍能运行;数据开始共享、跨越 Java 边界或经历状态变化后,错误才会出现。

作用域、包与入口

Kotlin 允许在文件顶层声明函数、属性和类型,不要求把所有代码包在类里。顶层声明仍属于包;在 JVM 上,编译器负责生成承载它们的类表示,Kotlin 调用方通常不需要关心这个生成名称。

package 声明位于文件开头,import 只影响当前源文件的名称解析。源码目录不必与包名机械对应,但项目构建通常仍按包组织目录,方便查找与权限审查。导入冲突时可以用 as 提供局部别名,而不必把完整限定名散落在每次调用中。

花括号会建立局部作用域。内层声明可以遮蔽外层同名声明,但这会让日志和调试器中的名称难以区分。生成代码喜欢反复使用 resultvaluedata;在嵌套块中应改用能说明单位和状态的名称。

局部变量必须在读取前初始化。把 val 的赋值放进条件分支时,编译器会检查所有可继续执行的路径;漏掉一条路径就会拒绝之后的读取。直接用 ifwhen 表达式初始化,往往能把“所有分支都提供值”的要求写得更明显。

普通命令行程序以顶层 main 函数作为入口。它可以没有参数,也可以接收 Array<String>。业务逻辑不必全部放进 main;入口负责把外部字符串转换为明确类型,再调用可以单独测试的函数。

文件边界不会自动形成访问控制。没有修饰符的顶层声明默认是 public;仅供当前文件使用的辅助函数可以标成 private,模块内共享而不公开给模块外时可以使用 internal。可见性是 API 选择,不应等到发布库时才处理。

审查一个新的基础 Kotlin 文件时,可以按以下顺序确认边界:

  1. 包名和导入是否让依赖来源清楚。
  2. 顶层声明是否使用了最窄的合理可见性。
  3. 入口是否在边界完成解析与验证。
  4. 业务函数是否只接收已经具有明确类型和单位的值。

这个顺序能把语法问题和架构问题分开。编译器负责名称、类型和初始化证明;作者仍要决定哪些值可信、哪些声明属于公开契约,以及一次失败应如何反馈给调用方。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?