空安全、扩展函数、Lambda、集合操作、作用域函数 —— 这些让 Kotlin 从众多语言中脱颖而出
排除不可能,剩下的就是真相
欢迎来到本案的核心现场。前两章我们收集了 Kotlin 语言的基础证据,而本章,我们将深入调查那些真正令人着迷的"杀手级特性"。每一个特性都是一件精密的侦探工具 —— 空安全系统如同排除嫌疑人的严密逻辑,扩展函数是为既有证物附加新的分析能力,Lambda 和集合操作则是我们处理案件档案的高效分析术。让我们运用演绎法,逐一揭开这些特性背后的真相。
各位助手,如果说前两章是案件的背景调查,那么本章就是我们正式破案的关键环节。这些特性不仅让代码更简洁,更重要的是它们从根本上排除了一整类程序缺陷的可能性,让代码更安全、更具表达力。经过本章的演绎推理,你会发现 Kotlin 的设计之精妙,犹如一桩完美的推理案件。
2009 年,Tony Hoare(空引用的发明者)公开承认了一桩惊天"罪行":
"我把它称为我的十亿美元错误。那就是在 1965 年发明了空引用……它导致了无数的错误、漏洞和系统崩溃。"
这是编程史上最大的悬案之一。在 Java、JavaScript、Python 等语言中,NullPointerException(NPE)就像一个潜伏在暗处的连环杀手,随时可能让你的程序崩溃。经过缜密的分析,我可以推断出 Kotlin 的破案策略:在编译期就将所有可疑的 null 值逐一排除,把空指针这个罪犯从运行时的暗处拉到编译时的聚光灯下。
Kotlin 的类型系统就像一套严密的身份验证机制。默认情况下,所有类型都被推断为"清白"的非空类型。要允许一个变量携带 null 这个"嫌疑",必须在类型后加上 ? 标记:
// 非空类型 —— 已被排除嫌疑,永远不会为 null
var name: String = "Kotlin"
name = null // 编译错误!嫌疑人无法混入清白名单
// 可空类型 —— 标记为嫌疑人,可能携带 null
var nickname: String? = "K"
nickname = null // OK,嫌疑人可以是空的
// 清白者可以进入嫌疑人名单,反过来则不行
val a: String = "hello"
val b: String? = a // OK
val c: String = b // 编译错误!嫌疑人必须经过审讯才能洗脱嫌疑
C++ 对比:裸指针 vs 编译期空安全
在 C++ 中,裸指针 int* ptr = nullptr; 完全合法,解引用空指针是未定义行为(比 Java 的 NPE 更危险 —— 连崩溃都不保证)。C++17 引入了 std::optional<T>,但它只是一个库类型,编译器不会强制你检查。Kotlin 的 ? 类型标记直接内建于类型系统,编译器会在每一处使用可空值的地方强制你处理 null —— 这是从语言层面彻底排除空指针嫌犯的做法。
?.:谨慎搜查面对一个嫌疑对象,我们不能贸然行动。?. 操作符就是我们的安全搜查令:如果对象为 null,整个表达式安全地返回 null,而不是引发崩溃。
val name: String? = "Kotlin"
// 安全搜查:name 非空则调用 length,否则返回 null
val len: Int? = name?.length // 6
val nullName: String? = null
val len2: Int? = nullName?.length // null(安全撤退,不会崩溃)
链式安全调用堪称侦探的连环推理 —— 任何一环线索断裂就立即短路返回 null,而不是继续冒险:
// 假设 company、address、city 都是待审的嫌疑对象
val city = user?.company?.address?.city
// 等价于 Java 中令人头疼的多层排查:
// String city = null;
// if (user != null) {
// Company company = user.getCompany();
// if (company != null) {
// Address address = company.getAddress();
// if (address != null) {
// city = address.getCity();
// }
// }
// }
C++ 对比:?. vs std::optional::value_or
Kotlin 的 ?. 安全调用在概念上类似 C++ 的 std::optional 配合 and_then(C++23 的 monadic 操作),但语法远比 C++ 优雅。C++ 中要实现类似的链式安全访问需要大量模板元编程或嵌套 if (opt.has_value()) 检查。Kotlin 的 ?. 一个符号搞定,这就是语言级支持与库级方案的差距。
?::备用方案一个优秀的侦探总会准备备用方案。当线索中断(值为 null)时,Elvis 操作符让我们立刻启用后备证据:
val name: String? = null
// 如果主要线索有效就采用,否则启用备用方案
val displayName = name ?: "匿名用户" // "匿名用户"
// 经典组合:安全搜查 + 备用方案
val len = name?.length ?: 0 // name 为 null 时返回 0
// Elvis 右侧也可以是 return 或 throw —— 终止调查或报告异常
fun process(input: String?) {
val value = input ?: return // 线索断裂,撤退
val data = input ?: throw IllegalArgumentException("证据不能为空")
println(value)
}
!!:孤注一掷的断案使用 !! 就像侦探对着陪审团拍桌子说"我以性命担保这不是 null"。如果你判断失误,后果就是 NullPointerException —— 案件当场崩盘:
val name: String? = "Kotlin"
val len = name!!.length // OK,因为 name 确实不是 null
val bad: String? = null
val crash = bad!!.length // 运行时崩溃!侦探的判断失误了
慎用 !!
每一个 !! 都是你对编译器开出的"空头支票",本质上放弃了 Kotlin 空安全的保护。在代码审查中,!! 应当像犯罪现场的可疑物品一样被特别关注。绝大多数情况下,可以用 ?.、?: 或 let 这些更安全的侦查手段替代。
C++ 对比:!! vs *ptr
Kotlin 的 !! 在行为上类似 C++ 对裸指针的直接解引用 *ptr —— 都不做空值检查。区别在于:Kotlin 的 !! 至少会抛出一个明确的 NullPointerException,而 C++ 解引用空指针是未定义行为,可能崩溃、可能返回垃圾数据、甚至可能"正常运行"但在三天后以完全无关的方式爆炸。C++ 中稍好一些的做法是用 std::optional::value(),它会抛 std::bad_optional_access。
编译器本身就是一位出色的助手侦探:一旦你通过 if 检查排除了某个变量为 null 的可能性,在对应的分支里它会自动将类型从 String? 推断为 String —— 无需你再手动声明:
fun printLength(text: String?) {
if (text != null) {
// 嫌疑排除!text 自动演绎为 String(非空)
println("长度: ${text.length}")
}
// 也适用于 is 类型检查 —— 身份确认后自动转型
val obj: Any = "Hello"
if (obj is String) {
// obj 自动推断为 String 类型,无需强制转换
println(obj.uppercase())
}
}
?.let { } 是处理嫌疑对象最优雅的方式:仅当嫌疑人"现身"(非空)时,才对其执行 Lambda 中的审讯代码。
val email: String? = getUserEmail()
// 仅当证据存在时才展开分析
email?.let { e ->
println("发送邮件到: $e")
sendEmail(e)
}
// 单参数可以用 it 替代命名参数
email?.let {
println("邮箱长度: ${it.length}")
}
在 Android 开发这座"案发大楼"中,很多 API 返回可空值。Kotlin 的空安全体系让我们能够系统性地排除每一个潜在的空值嫌疑:
// 从 Intent 获取数据(嫌疑人可能不在场)
class DetailActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 安全获取 Intent 中的 String 数据
val userId = intent.getStringExtra("USER_ID") ?: run {
finish() // 关键证据缺失,终止调查
return
}
// 安全恢复保存的状态
savedInstanceState?.let { bundle ->
val scrollPosition = bundle.getInt("SCROLL_POS", 0)
restoreScrollPosition(scrollPosition)
}
// 链式安全调用:层层深入排查 Fragment 中的 View
val text = supportFragmentManager
.findFragmentByTag("detail")
?.view
?.findViewById<TextView>(R.id.title)
?.text
?: "无标题"
}
}
在侦探工作中,我们经常需要对已有的证物进行新的分析,但我们不能篡改原始证物。扩展函数正是如此 —— 它让你在不修改原始类的情况下,为已有类"附加"新的分析方法:
// 为 String 附加一个新的分析方法
fun String.addExclamation() = this + "!"
// 使用时就像是 String 与生俱来的能力
println("Hello".addExclamation()) // "Hello!"
// 带参数的扩展函数
fun String.repeat(times: Int, separator: String = ""): String {
return (1....times).joinToString(separator) { this }
}
println("Ha".repeat(3, " ")) // "Ha Ha Ha"
编译原理:真相揭秘
让我揭示这个把戏背后的真相:扩展函数并不会真正修改原始类。编译器会把它转换为一个静态方法,接收对象作为第一个参数。例如 fun String.addExclamation() 编译后等价于 static String addExclamation(String $this)。正因如此,扩展函数无法访问私有成员 —— 就像我们可以分析证物的外表,但不能打开它的密封包装。
C++ 对比:扩展函数 vs 自由函数
C++ 没有扩展函数的直接等价物。最接近的方式是定义一个以对象引用作为第一参数的自由函数:std::string addExclamation(const std::string& s)。但调用形式完全不同 —— C++ 中你必须写 addExclamation(str),而 Kotlin 可以写 str.addExclamation(),后者的链式调用体验更流畅。有趣的是,Kotlin 扩展函数编译后的字节码本质上就是 C++ 自由函数的这种形式 —— 只不过 Kotlin 在语法层面做了一层优雅的伪装。
除了扩展函数,我们也可以为证物附加扩展属性(但不能有 backing field,只能用 getter/setter —— 毕竟我们不能在原始证物上刻字):
// 为 String 附加扩展属性
val String.firstChar: Char
get() = this[0]
val String.lastChar: Char
get() = this[length - 1]
println("Kotlin".firstChar) // 'K'
println("Kotlin".lastChar) // 'n'
// 1. List 扩展:安全获取第二个元素(第二条线索)
fun <T> List<T>.secondOrNull(): T? = if (size >= 2) this[1] else null
// 2. String 扩展:判断是否为有效邮箱(简化版身份验证)
fun String.isValidEmail(): Boolean = contains("@") && contains(".")
// 3. Int 扩展:格式化为带千分位的字符串
fun Int.withCommas(): String = "%,d".format(this)
println(1234567.withCommas()) // "1,234,567"
// dp 转 px —— 度量转换工具
val Int.dp: Int
get() = (this * Resources.getSystem().displayMetrics.density).toInt()
// 使用:设置 16dp 的边距
view.setPadding(16.dp, 8.dp, 16.dp, 8.dp)
// Toast 扩展 —— 快速发布调查通告
fun Context.toast(message: String, duration: Int = Toast.LENGTH_SHORT) {
Toast.makeText(this, message, duration).show()
}
// 使用:在 Activity 中一行搞定
toast("保存成功")
// View 可见性扩展 —— 证物的展示控制
fun View.show() { visibility = View.VISIBLE }
fun View.hide() { visibility = View.GONE }
fun View.invisible() { visibility = View.INVISIBLE }
// 使用
progressBar.show()
errorText.hide()
Lambda 就像侦探派出的匿名情报员 —— 不需要正式身份(函数名),直接执行任务。用花括号 { } 包裹即可部署:
// 完整写法 —— 明确身份的情报员
val sum: (Int, Int) -> Int = { x: Int, y: Int -> x + y }
// 类型可推断时简写 —— 编译器自行演绎身份
val sum2 = { x: Int, y: Int -> x + y }
// 调用 Lambda
println(sum(3, 5)) // 8
// 无参数的 Lambda
val greet = { println("你好!") }
greet() // "你好!"
C++ 对比:Lambda 捕获机制
Kotlin 的 Lambda 与 C++ Lambda 有一个关键的区别 —— 捕获方式。C++ 要求你显式声明捕获列表:[&] 按引用捕获、[=] 按值捕获、[this] 捕获 this 指针,甚至可以混合使用如 [&x, =y, this]。而 Kotlin Lambda 默认捕获外部作用域中的所有变量,且能直接修改它们(类似 C++ 的 [&]),无需声明任何捕获列表。这让 Kotlin 的 Lambda 写起来更简洁,但也意味着你需要注意闭包对外部可变状态的副作用。
高阶函数是接受函数作为参数或返回函数的函数 —— 就像侦探总部可以接收不同的调查策略,也可以派发定制的调查方案:
// 接受不同的调查策略(函数作为参数)
fun calculate(x: Int, y: Int, operation: (Int, Int) -> Int): Int {
return operation(x, y)
}
// 传入不同的 Lambda 实现不同的分析
println(calculate(10, 3) { a, b -> a + b }) // 13
println(calculate(10, 3) { a, b -> a * b }) // 30
// 派发定制调查方案(返回函数)
fun getMultiplier(factor: Int): (Int) -> Int {
return { number -> number * factor }
}
val triple = getMultiplier(3)
println(triple(5)) // 15
println(triple(10)) // 30
当函数的最后一个参数是 Lambda 时,可以把它放到括号外面。这是 Kotlin 最常用的语法糖之一 —— 让部署指令更加简洁明了:
// 标准写法
listOf(1, 2, 3).filter({ it > 1 })
// 尾随 Lambda 写法(推荐)
listOf(1, 2, 3).filter { it > 1 }
// 如果 Lambda 是唯一的参数,括号可以省略
run { println("Hello") }
it:隐式单参数名当 Lambda 只有一个参数时,可以用 it 代替 —— 就像我们说"那个嫌疑人"而不用每次都报出全名:
// 显式参数名
listOf(1, 2, 3).map { number -> number * 2 }
// 使用 it(推荐用于简短的 Lambda)
listOf(1, 2, 3).map { it * 2 } // [2, 4, 6]
// 嵌套 Lambda 时,建议使用显式参数名避免歧义
listOf("hello", "world").forEach { word ->
word.toList().forEach { char ->
print(char) // 嵌套时用 it 会导致混淆:是 word 还是 char?
}
}
// 点击事件(尾随 Lambda)
button.setOnClickListener {
toast("按钮被点击了")
}
// RecyclerView 设置(对比 Java 的匿名内部类)
recyclerView.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = MyAdapter(items) { item ->
navigateToDetail(item)
}
}
任何称职的侦探都知道,原始证据必须妥善保管不得篡改。Kotlin 严格区分只读集合和可变集合,正如案件中区分"封存证据"和"工作副本":
// 只读集合 —— 封存的证据,不可篡改
val fruits = listOf("苹果", "香蕉", "橙子")
val numbers = setOf(1, 2, 3)
val config = mapOf("host" to "localhost", "port" to "8080")
// 可变集合 —— 工作副本,允许修改
val mutableFruits = mutableListOf("苹果", "香蕉")
mutableFruits.add("橙子") // OK
mutableFruits.removeAt(0) // OK
// fruits.add("梨") // 编译错误!封存证据不可篡改
// 封存与工作副本之间的转换
val readOnly = mutableFruits.toList() // 工作副本 -> 封存
val editable = fruits.toMutableList() // 封存 -> 新的工作副本
处理案件档案时,我们需要对数据进行各种变换和分析。Kotlin 的集合操作就是我们的分析工具集:
val numbers = listOf(1, 2, 3, 4, 5, 6)
// map: 对每条证据进行转换分析
numbers.map { it * 2 } // [2, 4, 6, 8, 10, 12]
// filter: 筛选符合条件的嫌疑人
numbers.filter { it % 2 == 0 } // [2, 4, 6]
// flatMap: 展开嵌套线索后展平
val words = listOf("hello", "world")
words.flatMap { it.toList() } // [h, e, l, l, o, w, o, r, l, d]
// groupBy: 按类别归档
numbers.groupBy { if (it % 2 == 0) "偶数" else "奇数" }
// {奇数=[1, 3, 5], 偶数=[2, 4, 6]}
// associate: 建立索引档案
val names = listOf("Alice", "Bob")
names.associateWith { it.length } // {Alice=5, Bob=3}
C++ 对比:集合操作 vs C++20 Ranges
Kotlin 的 map / filter 操作在概念上等价于 C++20 引入的 std::ranges::views::transform / std::ranges::views::filter。但 Kotlin 从语言诞生之初就内置了这些操作,而 C++ 直到 C++20 标准才引入 Ranges 库,且语法相对冗长。例如,Kotlin 的 numbers.filter { it % 2 == 0 }.map { it * 2 } 在 C++20 中需要写成 numbers | views::filter([](int n){ return n%2==0; }) | views::transform([](int n){ return n*2; })。功能相似,但 Kotlin 的表达显然更为凝练。
val numbers = listOf(1, 2, 3, 4, 5)
// find: 找第一个满足条件的嫌疑人(找不到返回 null)
numbers.find { it > 3 } // 4
// first / last: 第一个/最后一个符合条件的,找不到抛异常
numbers.first { it > 3 } // 4
numbers.last { it < 3 } // 2
// firstOrNull / lastOrNull: 更安全的搜查版本
numbers.firstOrNull { it > 10 } // null
// any: 是否存在符合特征的嫌疑人
numbers.any { it > 3 } // true
// all: 是否所有证据都符合预期
numbers.all { it > 0 } // true
// none: 是否没有任何反例
numbers.none { it < 0 } // true
让我们用链式操作来分析一组"案件档案" —— 学生成绩数据。请注意观察我是如何一步步排除不相关的数据,最终锁定目标的:
data class Student(
val name: String,
val grade: Int, // 年级
val score: Double, // 成绩
val subject: String // 科目
)
val students = listOf(
Student("张三", 3, 92.5, "数学"),
Student("李四", 3, 85.0, "数学"),
Student("王五", 2, 78.0, "数学"),
Student("赵六", 3, 95.0, "英语"),
Student("钱七", 2, 88.0, "英语"),
)
// 调查任务:锁定三年级数学成绩 >= 90 分的学生,按成绩降序排列
val topStudents = students
.filter { it.grade == 3 } // 排除非三年级
.filter { it.subject == "数学" } // 排除非数学科目
.filter { it.score >= 90 } // 排除 90 分以下
.sortedByDescending { it.score } // 按证据强度排序
.map { it.name } // 提取目标姓名
// 结果: ["张三"]
// 调查任务:按年级统计平均成绩
val avgByGrade = students
.groupBy { it.grade }
.mapValues { (_, students) ->
students.map { it.score }.average()
}
// 结果: {3=90.83, 2=83.0}
普通集合操作是立即求值的 —— 每一步推理都会生成完整的中间结论。但当案件档案浩如烟海时,我们应当使用 Sequence 进行惰性推理:只在真正需要答案时才动手分析。
// 普通集合:每步推理都生成完整中间结论(大案件时浪费资源)
val result1 = (1..1_000_000)
.map { it * 2 } // 生成 100 万条中间记录
.filter { it % 3 == 0 } // 再筛选一遍
.first() // 只取第一条... 前面白忙了
// Sequence 版本:惰性推理,找到第一条就收工
val result2 = (1..1_000_000).asSequence()
.map { it * 2 } // 记录策略,暂不执行
.filter { it % 3 == 0 } // 记录策略,暂不执行
.first() // 终端指令,触发推理,处理极少元素就结案
何时使用 Sequence?
经验法则:如果案件档案庞大(上万条记录)且需要多步链式分析,使用 asSequence()。对于小型案件,普通 List 操作更直观且性能差异可忽略。
Kotlin 标准库提供了 5 个作用域函数:let、run、with、apply、also。它们就像侦探的五种审讯技巧,核心区别只有两点:引用对象的方式(this 还是 it)和返回值(Lambda 结果还是对象本身)。让我逐一演绎它们的用法。
引用对象为 it,返回 Lambda 结果。最常用于非空排查和证据转换。
// 非空排查
val name: String? = "Kotlin"
name?.let {
println("名字是 $it, 长度为 ${it.length}")
}
// 证据转换:将可空值推断为另一种形式
val length: Int? = name?.let { it.length } // 6
引用对象为 this,返回 Lambda 结果。适合配置对象并分析出结论。
// 组装证据并得出结论
val result = StringBuilder().run {
append("Hello")
append(" ")
append("World")
toString() // 返回最终结论
}
println(result) // "Hello World"
// 也可以用于非空对象的连续操作
val service: NetworkService? = getService()
val data = service?.run {
connect()
fetchData() // 返回调查数据
}
引用对象为 this,返回 Lambda 结果。与 run 类似,但对象作为参数传入。适合对一个已确认的证物进行多角度分析。
val person = Person("张三", 25)
val info = with(person) {
// 这里的 this 就是 person,可以直接访问其属性
println("姓名: $name")
println("年龄: $age")
"$name 今年 $age 岁" // 分析结论
}
引用对象为 this,返回对象本身。最适合对象初始化/配置 —— 布置调查现场。
// 最经典的用法:构建和配置调查工具
val textView = TextView(context).apply {
text = "Hello"
textSize = 16f
setTextColor(Color.BLACK)
setPadding(16.dp, 8.dp, 16.dp, 8.dp)
}
// textView 已经配置好了,随时可以投入使用
// 配置 Intent —— 准备调查指令
val intent = Intent(this, DetailActivity::class.java).apply {
putExtra("USER_ID", userId)
putExtra("FROM_PAGE", "home")
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
引用对象为 it,返回对象本身。适合附加操作(如记录日志、调试验证),不改变证物本身。
// 在链式推理中插入调查日志
val numbers = mutableListOf(1, 2, 3)
.also { println("初始证据: $it") }
.also { it.add(4) }
.also { println("补充后: $it") }
// 调试时临时插入,不影响原有推理链
fun getUser(): User {
return repository.findUser(id)
.also { println("DEBUG: 查到嫌疑人 ${it.name}") }
}
| 函数 | 对象引用 | 返回值 | 典型场景 |
|---|---|---|---|
let |
it |
Lambda 结果 | 非空排查、证据转换 |
run |
this |
Lambda 结果 | 配置对象 + 得出结论 |
with |
this |
Lambda 结果 | 对已确认证物多角度分析 |
apply |
this |
对象本身 | 布置现场 / 对象初始化 |
also |
it |
对象本身 | 附加操作(日志、调试) |
选择指南
需要配置对象?用 apply。
处理可空值?用 let。
想在链式推理中加日志?用 also。
需要对对象做操作并返回分析结论?用 run 或 with。
不确定?先用 let,它最通用。
data class 自动支持解构 —— 一次性将证物的各项属性拆分到独立的变量中进行分析,如同将一个密封信封拆开,逐一检视内部文件:
data class User(val name: String, val age: Int, val email: String)
val user = User("张三", 25, "zhangsan@mail.com")
// 解构声明 —— 一次拆解
val (name, age, email) = user
println(name) // "张三"
println(age) // 25
println(email) // "zhangsan@mail.com"
// 在函数参数中解构
fun greet(user: User) {
val (name, age) = user
println("你好, $name!你 $age 岁了")
}
val scores = mapOf(
"张三" to 92,
"李四" to 85,
"王五" to 78
)
// 使用解构遍历案件索引
for ((name, score) in scores) {
println("$name 的成绩: $score")
}
// 在 Lambda 中使用解构
scores.forEach { (name, score) ->
println("$name: $score")
}
_ 跳过不相关的线索val user = User("张三", 25, "zhangsan@mail.com")
// 只需要姓名和邮箱,年龄不是本案的关键线索
val (name, _, email) = user
println("$name - $email")
// Map 遍历时只关心 value
for ((_, score) in scores) {
println(score)
}
复杂的类型名称就像冗长的法律术语。类型别名让我们为复杂类型起一个简短的代号,提高推理时的可读性:
// 为复杂的函数类型起代号
typealias UserClickHandler = (User) -> Unit
typealias Predicate<T> = (T) -> Boolean
// 使用代号让函数签名更清晰
fun setOnUserClick(handler: UserClickHandler) {
// ...
}
// 为嵌套的泛型类型起代号
typealias UserScores = Map<String, List<Int>>
fun processScores(scores: UserScores) {
scores.forEach { (name, scoreList) ->
println("$name: ${scoreList.average()}")
}
}
高阶函数每次调用都会创建 Lambda 对象,就像每次分析都要复印一份档案。inline 关键字让编译器把函数体直接"嵌入"到调用处 —— 消除了额外的开销:
// 内联函数:Lambda 体直接插入调用处,无额外对象创建
inline fun measureTime(block: () -> Unit): Long {
val start = System.currentTimeMillis()
block()
return System.currentTimeMillis() - start
}
// 使用
val elapsed = measureTime {
// 执行一些耗时的调查工作
Thread.sleep(100)
}
println("耗时: ${elapsed}ms")
何时使用 inline?
Kotlin 标准库中的 let、run、apply、also、filter、map 等都是 inline 函数。自己写高阶函数时,如果 Lambda 参数很小且调用频繁,加上 inline 可以提升性能。但对于大型函数体,内联反而会增大生成代码的体积 —— 正如侦探不需要把整个案件档案馆搬到每个犯罪现场。
中缀函数可以省略点号和括号,让代码读起来如同自然语言中的逻辑推理:
// 定义中缀函数
infix fun Int.times(str: String): String = str.repeat(this)
// 使用中缀表示法(省略 . 和括号)
val result = 3 times "Ha " // "Ha Ha Ha "
// Kotlin 内置的中缀函数
val pair = "name" to "Kotlin" // to 就是中缀函数,创建 Pair
val range = 1 until 10 // until 也是中缀函数
// 自定义 DSL 风格的断言 —— 验证推理结论
infix fun <T> T.shouldBe(expected: T) {
if (this != expected) throw AssertionError("期望 $expected,实际 $this")
}
// 验证推理
5 shouldBe 5 // OK
"hello".length shouldBe 5 // OK
中缀函数的三条规则
中缀函数必须满足三个条件:(1) 是成员函数或扩展函数;(2) 只有一个参数;(3) 参数不能有默认值,也不能是可变参数。违反任何一条,编译器都会立即排除其资格。
经过一整章的缜密调查,我们已经掌握了 Kotlin 最核心的语言特性。这些特性正是让 Kotlin 区别于 Java 和其他语言的关键证据:
?.、?:、let 是你的三大侦查利器map、filter、groupBy 等链式操作是处理案件档案的高效手段let、apply、also、run、with 五种审讯技巧各有所长我的演绎到此告一段落。下一章我们将进入 Kotlin 另一个杀手级特性 —— 协程,它将彻底改变你处理异步编程的方式。正如破案不能只靠单线推理,协程让你同时追踪多条线索,互不阻塞。
给定以下数据结构,请编写一个函数 getStreetName,安全地获取用户的街道名称。如果任何一层为 null,则返回 "地址不详"。
data class Address(val street: String?, val city: String?)
data class Company(val name: String, val address: Address?)
data class User(val name: String, val company: Company?)
fun getStreetName(user: User?): String {
return user?.company?.address?.street ?: "地址不详"
}
// 测试
val user1 = User("张三", Company("侦探所", Address("贝克街221B", "伦敦")))
val user2 = User("李四", null)
val user3: User? = null
println(getStreetName(user1)) // "贝克街221B"
println(getStreetName(user2)) // "地址不详"
println(getStreetName(user3)) // "地址不详"
为 String 编写以下两个扩展函数:
wordCount():返回字符串中的单词数量(以空格分隔)maskEmail():将邮箱地址中 @ 之前的部分用 *** 替代(例如 "sherlock@baker.st" 变为 "***@baker.st")。如果不包含 @,返回原始字符串。fun String.wordCount(): Int = trim().split("\\s+".toRegex()).size
fun String.maskEmail(): String {
val atIndex = indexOf('@')
return if (atIndex >= 0) "***" + substring(atIndex) else this
}
// 测试
println("The game is afoot".wordCount()) // 4
println("sherlock@baker.st".maskEmail()) // "***@baker.st"
println("no-email-here".maskEmail()) // "no-email-here"
编写一个高阶函数 retry,它接受最大重试次数和一个操作(Lambda),如果操作抛出异常就重试,直到成功或达到最大次数。如果全部失败,抛出最后一次的异常。
// 函数签名
fun <T> retry(maxAttempts: Int, action: () -> T): T
fun <T> retry(maxAttempts: Int, action: () -> T): T {
var lastException: Exception? = null
repeat(maxAttempts) { attempt ->
try {
return action()
} catch (e: Exception) {
lastException = e
println("第 ${attempt + 1} 次尝试失败: ${e.message}")
}
}
throw lastException!!
}
// 测试:模拟不稳定的网络请求
var callCount = 0
val result = retry(3) {
callCount++
if (callCount < 3) throw RuntimeException("网络超时")
"调查成功"
}
println(result) // "调查成功"(第三次才成功)
给定以下嫌疑人数据,完成两个分析任务:
data class Suspect(
val name: String,
val age: Int,
val city: String,
val hasAlibi: Boolean
)
val suspects = listOf(
Suspect("张三", 35, "北京", false),
Suspect("李四", 28, "上海", true),
Suspect("王五", 42, "北京", false),
Suspect("赵六", 31, "广州", false),
Suspect("钱七", 38, "北京", true),
)
// 任务1:排除有不在场证明的人,按年龄降序
val primarySuspects = suspects
.filter { !it.hasAlibi } // 排除有不在场证明的
.sortedByDescending { it.age } // 按年龄降序
.map { it.name } // 提取姓名
println(primarySuspects) // [王五, 张三, 赵六]
// 任务2:按城市统计无不在场证明的嫌疑人数
val suspectsByCity = suspects
.filter { !it.hasAlibi }
.groupBy { it.city }
.mapValues { (_, list) -> list.size }
println(suspectsByCity) // {北京=2, 广州=1}
编写一个函数 processUserData,接收一个 List<Map<String, String?>>(模拟从 JSON 解析的原始数据),要求:
"name" 字段为 null 或空字符串的记录"age" 字段无法转为整数的记录data class UserInfo(val name: String, val age: Int)要求全程使用空安全操作符,不允许使用 !!。
data class UserInfo(val name: String, val age: Int)
fun processUserData(rawData: List<Map<String, String?>>): List<UserInfo> {
return rawData.mapNotNull { record ->
val name = record["name"]?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
val age = record["age"]?.toIntOrNull() ?: return@mapNotNull null
UserInfo(name, age)
}.sortedBy { it.age }
}
// 测试
val rawData = listOf(
mapOf("name" to "福尔摩斯", "age" to "34"),
mapOf("name" to null, "age" to "28"), // name 为 null,排除
mapOf("name" to "华生", "age" to "abc"), // age 无效,排除
mapOf("name" to "", "age" to "45"), // name 为空,排除
mapOf("name" to "莫里亚蒂", "age" to "41"),
)
val result = processUserData(rawData)
println(result)
// [UserInfo(name=福尔摩斯, age=34), UserInfo(name=莫里亚蒂, age=41)]