Kotlin 入门与面试:从空安全、扩展函数到协程

Kotlin 的语法比 Java 紧凑,但"代码更短"并不等于"规则更少"。入门时最容易混淆的,是 val 与对象不可变、? 与运行时空指针、扩展函数与真正的重写,以及 async 与"异常只在 await() 时发生"。

本文按"先会写,再解释为什么"的顺序整理 Kotlin 常用知识。示例基于现代 Kotlin/JVM 与 kotlinx.coroutines;为突出语言规则,部分片段省略常规 import 或业务类型定义。协程在 Android 的生命周期用法可继续阅读文末的专篇。

一、变量、类型与函数:先把代码读顺

kotlin 复制代码
val name: String = "Kotlin" // 引用不能重新赋值
var count = 0               // 类型推断为 Int,可以重新赋值
count += 1

fun greet(user: String, prefix: String = "Hi"): String = "$prefix, $user"

val message = greet("Ada")

val 类似只读引用,不代表指向的对象绝对不可变。例如 val list = mutableListOf(1) 仍可调用 list.add(2)。在能表达需求时优先用 val,能减少状态变化;不是为了"符合 Kotlin 风格"把所有对象都误认为不可变。

if、when 可以返回值:

kotlin 复制代码
fun level(score: Int): String = when {
    score >= 90 -> "A"
    score >= 60 -> "B"
    else -> "C"
}

函数可以写在文件顶层,不必为了放一个工具函数专门建 Utils 类。参数支持默认值与命名实参,调用时例如 greet(user = "Ada", prefix = "Hello")。表达式函数适合短逻辑;复杂业务仍应重视可读性。

二、空安全:String 和 String? 不是一回事

Kotlin 在类型系统里区分非空和可空引用:

kotlin 复制代码
val title: String = "Home"
val subtitle: String? = null

val length = subtitle?.length ?: 0

?. 表示接收者为 null 时停止这一步并返回 null;?: 提供备用值。!! 则是开发者断言"这里一定非空",一旦判断错仍会抛异常,不应拿它当解决编译错误的万能按钮。

kotlin 复制代码
fun printLength(value: String?) {
    if (value != null) {
        println(value.length) // 此处可被智能转换为 String
    }
}

智能类型转换依赖编译器确认值在检查后不会被不安全地改掉;可变属性或并发可变状态未必能直接智能转换。此时先保存到局部 val 往往更清楚。

lateinit var 适合稍后初始化的非空可变属性,例如某些框架注入对象;读取前没初始化会抛异常。val x by lazy { ... } 是首次访问时计算并缓存,两者不是空安全的替代品。Android 中若 lazy 闭包捕获 Activity,也要考虑持有时间。

三、类、继承与数据建模

Kotlin 的类默认不能被继承,成员默认也不能被重写。需要多态时显式写 open 和 override:

kotlin 复制代码
open class Animal {
    open fun sound(): String = "..."
}

class Dog : Animal() {
    override fun sound(): String = "woof"
}

val animal: Animal = Dog()
println(animal.sound()) // woof:真正的成员函数动态分派

data class 适合承载数据,会生成 equals、hashCode、toString、componentN 和 copy 等。注意 copy() 是浅拷贝:

kotlin 复制代码
data class User(val name: String, val tags: MutableList<String>)

val original = User("Ada", mutableListOf("Android"))
val copied = original.copy()
copied.tags.add("Kotlin")
println(original.tags) // [Android, Kotlin]:共享同一个列表

状态集合通常适合 sealed class / sealed interface,编译器能在同一封闭层级的 when 中检查分支是否完整:

kotlin 复制代码
sealed interface UiState {
    data object Loading : UiState
    data class Content(val text: String) : UiState
    data class Error(val message: String) : UiState
}

fun render(state: UiState): String = when (state) {
    UiState.Loading -> "加载中"
    is UiState.Content -> state.text
    is UiState.Error -> state.message
}

object 声明单例;companion object 提供与类关联的对象成员,但它不是 Java static 的逐字复制。sealed 更适合每个分支可能携带不同数据的有限状态;enum class 更适合一组固定的单例常量。

主构造函数的参数只有写上 val 或 var 才会同时成为属性;init 可做初始化校验。接口用于约定能力,类可以实现多个接口:

kotlin 复制代码
interface Named {
    fun displayName(): String
}

class Person(val name: String, private val age: Int) : Named {
    init { require(age >= 0) }
    override fun displayName(): String = name
}

这里的 name、age 是属性;若只写 class Person(name: String),name 只是构造参数,不能在普通成员函数里当属性长期使用。private、internal、protected、public 还决定了成员能从哪里访问,Kotlin 默认可见性是 public。

四、集合、Lambda 与作用域函数

Kotlin 区分只读接口 List<T> 与可变接口 MutableList<T>,但只读接口不保证底层对象永远不变:

kotlin 复制代码
val source = mutableListOf("A")
val view: List<String> = source
source.add("B")
println(view) // [A, B]

常用的 filter、map、associateBy、groupBy 用于描述集合变换。普通集合链式操作通常会产生中间结果;长链且数据量较大时可评估 asSequence() 的惰性计算,但小集合不必为了"看起来高级"全部改成 Sequence。

作用域函数的关键不是背名字,而是分清接收对象写法 与返回值:

函数 在代码块中引用对象 返回值 常见用途
let it 代码块结果 可空值处理、映射
run this 代码块结果 在对象上下文内计算
with(obj) this 代码块结果 对已有对象连续操作
apply this 原对象 初始化、配置
also it 原对象 日志、附带动作

例如 val view = TextView(context).apply { text = "Hello" } 返回配置后的 TextView。连续嵌套几个 let/also 时,it 指向谁很容易读错;把中间值命名通常更好。

五、扩展函数:语法像成员,分派不是成员

扩展函数让调用形式更自然:

kotlin 复制代码
fun String.lastOrDash(): Char = lastOrNull() ?: '-'

println("Kotlin".lastOrDash()) // n

它不会修改原类 ,也不是给类动态装上一个虚方法。编译器在调用点根据可见的扩展函数和接收者的静态类型选择目标。

面试题:父类和子类有同名扩展,会编译报错吗?

**通常不会。**接收者类型不同,它们是两个重载:

kotlin 复制代码
open class Parent
class Child : Parent()

fun Parent.label(): String = "Parent extension"
fun Child.label(): String = "Child extension"

val child = Child()
val parent: Parent = child

println(child.label())  // Child extension
println(parent.label()) // Parent extension

第二个变量里实际装的是 Child 对象,但表达式 parent 的声明类型是 Parent,所以调用 Parent.label()。**扩展接收者静态解析,不发生类似 override 的运行时多态。**如果在 if (parent is Child) 分支里编译器完成智能类型转换,那一处调用可选中 Child.label(),仍是编译期根据更具体的类型解析。

真正可能编译失败的是同一作用域里声明完全重复的签名,或引入候选后调用点无法消除歧义;不能把"父子类同名"本身当作错误。若类已经有同名且适用的成员函数,成员函数优先于扩展函数,扩展不会覆盖它。

扩展属性也只能提供 getter/setter 逻辑,没有可附着在原对象上的新 backing field。适合提供便利视图,不适合用来偷偷保存对象状态。关于成员扩展的"双接收者"分派和 JVM 字节码,可看父子类同名扩展函数专题。

六、泛型:out、in 与 reified 先抓住用途

泛型帮助在复用代码时保留类型约束。Kotlin 默认泛型通常是不变的;声明处的 out T 表示主要"生产 T",in T 表示主要"消费 T"。例如只读 List<out E> 可以安全读取更具体类型的元素;MutableList<T> 既读又写,不能随意把 MutableList<Dog> 当成 MutableList<Animal>,否则就可能写入一只猫。

JVM 泛型通常会类型擦除,因此普通 fun <T> ... 不能直接用 value is T 判断运行时泛型参数。inline 函数的 reified 参数可在调用点保留这类类型信息:

kotlin 复制代码
inline fun <reified T> isInstance(value: Any): Boolean = value is T

println(isInstance<String>("Kotlin")) // true

inline 不只为 reified 服务,也可减少某些高阶函数调用的开销;它会复制函数体到调用点附近,不能把所有函数都无脑内联。

七、协程入门:suspend、launch、async 各负责什么?

协程是可以挂起和恢复的任务,最终仍在线程上执行。suspend 只表示函数可能挂起 ,不自动创建协程、切换线程,也不会把 Thread.sleep() 变成非阻塞。

API 返回 主要用途 等待方式
launch Job 启动不直接产出结果的任务 join() 等完成
async Deferred<T>(也是 Job) 并发计算一个值 await() 取结果,失败会重新抛出异常
withContext 代码块结果 T 在指定上下文执行并等待 调用本身挂起直到完成
kotlin 复制代码
suspend fun loadPage(): Page = coroutineScope {
    val user = async { api.loadUser() }
    val notices = async { api.loadNotices() }
    Page(user.await(), notices.await())
}

两个 async 先启动,才有并发重叠的机会;连续写两次 async { ... }.await() 通常只会顺序执行。若任务之间有依赖,直接顺序调用更易懂。

面试题:launch 和 async 只是返回类型不同吗?

不止。launch 面向不直接返回值的工作,未处理异常会沿协程层级传播;async 把结果或异常保存在 Deferred,调用 await() 时取得结果或抛出异常。但有一个关键边界:普通父作用域中的 async 子任务失败,也可能立即使父任务失败并取消兄弟任务,不必等到代码执行 await()。

async 默认会立即调度启动,不是"等 await() 才开始";除非显式使用 CoroutineStart.LAZY。launch 也不是"发出去就不用管":结构化并发要求明确作用域和生命周期。在 Android 中,页面相关任务通常放进 viewModelScope 或合适的生命周期作用域,避免 GlobalScope 让任务失去所有者。supervisorScope 可以隔离直接子任务之间的失败,但不会替你处理 await() 抛出的异常。

取消同样是面试高频点:delay() 可取消且不持续阻塞线程,Thread.sleep() 会占住线程;捕获宽泛 Exception 时不要吞掉 CancellationException。更完整的取消、异常、Flow 与状态机解释见协程从入门到面试专题。

八、常见面试题速查

问题 先说结论 还要补充的边界
val 与 var 区别? 引用能否重新赋值 val 指向的可变对象仍可变
== 与 === 区别? 结构相等(调用 equals)与引用相同 比较业务值通常用 ==,不要依赖装箱对象身份
String 与 String? 区别? 类型系统区分非空/可空 !! 仍可能抛异常;Java 互操作有平台类型风险
lateinit 与 lazy 区别? 稍后赋值的 var 与首次访问计算的 val 前者未初始化读取报错,后者关注初始化线程安全与持有对象
data class.copy() 是深拷贝吗? 不是,默认浅拷贝 嵌套可变对象可能共享
类和成员能直接被继承/重写吗? 默认不能,需要 open/override 扩展函数不参加这种运行时重写
sealed 与 enum 怎么选? 有限状态且分支可携带不同数据用 sealed 简单固定常量更适合 enum
List 一定不可变吗? List 接口只读 底层对象可能仍被其他引用修改
同名扩展定义在父子类上会冲突吗? 接收者类型不同,通常可以重载 选择由调用点静态类型决定,不是动态分派
成员函数与扩展函数同名谁优先? 适用成员优先 扩展不能给原类真正添加或覆盖成员
inline reified 用来做什么? 在内联调用点使用泛型类型信息 不能把所有泛型擦除问题都交给它解决
suspend 会自动开线程吗? 不会 阻塞 API 仍会阻塞当前线程
launch 与 async 怎么选? 无直接结果用 launch,需要值用 async/await 二者的子任务失败都要结合父作用域理解
async 的异常只在 await() 时影响父协程吗? 不是 普通父子关系下失败可立即向父传播
delay 与 sleep 区别? 挂起协程与阻塞线程 delay 响应取消;恢复线程由调度器决定

如果只能记住一条学习路线:**先用类型系统避免错误,再用类与扩展组织代码,最后用有所有者的协程处理异步。**面试也按同样顺序回答:先给规则,再用一个反例说明规则的边界,通常比背一串 API 名更有说服力。

参考资料与延伸阅读

相关推荐
沫璃染墨1 小时前
《从零入门Linux系统篇(五十八):线程篇·十一——线程安全与死锁详解:从可重入到多锁管理》
linux·服务器·开发语言·c++·驱动开发·安全·架构
传奇开心果编程2 小时前
【Compose Multiplatform 跨端开发学与练】第8课 资源管理与主题
android·windows·学习·ios·kotlin·web·composer
Flynt2 小时前
800KB 的 JSON 吃掉 8.5 秒 CPU:jackson 这波修复里,最容易被漏掉的洞藏在字符串里
java·安全
赛博守夜人3 小时前
跨境业务与出海合规之六:跨境数据出境与《个保法》/GDPR 冲突下的数据安全架构,技术隔离与加密信道实践
安全·安全架构
EatFan3 小时前
「失控AI智能体」首遭FTC立案:英伟达Agent安全体系落地,AI智能体合规设计如何前置
大数据·人工智能·安全·ai智能体·mcp·agent安全·ftc
白帽攻防录3 小时前
SRC 挖洞:V8 Turbofan 沙箱逃逸深度复盘,CVE-2026-6307 一根长矛怎么刺穿 Chrome 两道边界
网络·chrome·安全·网络安全·浏览器安全
传奇开心果编程4 小时前
【Compose Multiplatform 跨端开发学与练】第9课 测试与调试
android·学习·macos·ios·kotlin·web·composer
欣欣之王来了4 小时前
5G网络基础:5G的核心特性,eMBB、uRLLC、mMTC的应用场景
网络·学习·计算机网络·安全·教程
天衍四九-4 小时前
Docker 进阶实战系列(一):容器镜像安全,最小镜像与非 Root 运行
安全·docker·eureka