本节摘要:本节过一遍全书的书写底座:val 与 var 的选择纪律(默认 val,变更必须显式签字)、类型推断的边界、函数作为表达式、if 与 when 的表达式化、字符串模板。重点不是语法罗列,而是每个语法点背后"消灭了哪类 Java 事故"——误改引用、
==与 equals 混淆、switch 穿透、字符串拼接噪音。
Java 与 Kotlin 各写同一个"计算折后价":
// Java public double finalPrice(double price, double discount, boolean vip) { double result = price; if (vip) { result = result * discount; } if (result < 20.0 && vip) { result = 20.0; } return result; }
// Kotlin fun finalPrice(price: Double, discount: Double, vip: Boolean): Double { var result = price if (vip) result *= discount if (result < 20.0 && vip) result = 20.0 return result }
行数差不多,但 Kotlin 版本里 var result 这一个词已经暴露了全部可变性。如果改写成完全表达式化的版本,var 也消失了:
fun finalPrice(price: Double, discount: Double, vip: Boolean): Double = when { vip && price * discount < 20.0 -> 20.0 vip -> price * discount else -> price }
三个分支覆盖全部情况,一眼看尽,没有中间状态。这就是本节的主线:Kotlin 用一小组语法决定,让"什么会变"在代码里显形。
Kotlin 声明变量只有两个关键字:
val limit: Int = 10 // 只读引用,类似 Java 的 final,但默认 var count: Int = 0 // 可变引用,写它需要理由
val 声明的是只读引用:引用一旦指向某个对象就不能再指别的。注意它不等于"对象不可变"——val list = mutableListOf(1, 2) 的引用不能重绑,但列表内容照样能改。这个区分是第 4 章的主题,这里先立住第一条纪律:
默认写 val,只有确实需要重新赋值才写 var,并让每一次 var 的出现都成为一次显式决策。
这条纪律消灭的事故相当具体。Java 里所有局部变量默认可变,于是任何一段代码都可能被后面的行悄悄改掉:
List<String> names = loadNames(); // 加载 // ……四十行之后 names = filterVip(names); // 引用被悄悄换掉了 // 再往后有人调 names.size() 做报表,数字对不上
全员 val 的 Kotlin 版本里,这种"引用被中途偷换"的事故需要显式写出 var 才可能发生——危险操作必须签字。到第 4 章你会看到这条纪律在并发场景的价值:只读引用可以被多个线程安全地共享,可变引用的排查范围则被 var 与 mutableListOf 这类显式标记限定。
类型什么时候需要显式写?有初始化表达式时可以省略:
val year = 2024 // 推断为 Int val ratio = 0.75 // 推断为 Double val tags = listOf("新用户", "首单") // 推断为 List<String>(只读接口) val map: Map<String, Int> = HashMap() // 接口类型需要显式标注
最后一种是常见坑:val map = HashMap<String, Int>() 推断出的是 HashMap 具体类型而不是 Map 接口。推断遵循右值的具体类型,想要宽接口就显式写左边。另外注意数值的字面量没有隐式拓宽,val x: Long = 2024 可以(常量折叠),但 Int 变量赋给 Long 变量必须 toLong()——类型转换在 Kotlin 里永远显式,这也是"危险必须显式"的一部分。
函数用 fun 声明,参数是 名字: 类型,返回类型跟在冒号后。单表达式函数可以去掉花括号:
fun square(x: Int): Int { return x * x } fun square(x: Int) = x * x // 表达式体,返回类型可推断
默认参数消灭了 Java 的重载爆炸:
// Java:一个日志接口配四个重载 void log(String msg); void log(String msg, String tag); void log(String msg, String tag, int level); void log(String msg, String tag, int level, boolean async);
// Kotlin:一个函数配默认值 fun log(msg: String, tag: String = "APP", level: Int = 2, async: Boolean = false) { /* ... */ } log("启动完成") log("支付失败", tag = "PAY", async = true) // 命名参数跳着传
调用点还能用命名参数显式标注含义——第 7 章会提到 @JvmOverloads 注解让 Java 调用方也能享受重载版本。
Kotlin 的 if 是表达式,有值:
val label = if (amount >= 1000) "大额" else "小额"
when 是 switch 的全面升级,同样是表达式,同样(在无 else 且类型穷举时)可以不带默认分支:
fun describe(os: String): String = when (os) { "android", "ios" -> "移动端" "windows" -> "桌面端" else -> "未知" } fun taxRate(income: Long): Double = when { income <= 0 -> 0.0 income <= 36000 -> 0.03 income <= 144000 -> 0.10 else -> 0.20 }
对比 Java 的 switch 有两类事故被消解:
break 导致顺序执行下一个 case,是 C 时代传下来的语法陷阱;when 分支天然互斥,不存在穿透;else 都不该写——写了反而放宽检查,这是第 3 章的核心戏码。when 还能做类型判断与解构条件,先给一个预告级的例子:
fun render(state: UiState) = when (state) { is UiState.Loading -> showSpinner() is UiState.Data -> showList(state.items) is UiState.Error -> showError(state.retry) } // is 分支内 state 已被智能转换为具体子类,无需强转
没有强制类型转换(Java 的 (UiState.Data) state 与可能的 ClassCastException),因为编译器替你做了"智能转换"。细节在第 3 章展开。
字符串模板把 Java 的拼接噪音压成一行:
val name = "阿禾" val order = "A1024" println("用户 $name 的订单 $order 已发货,共 ${items.size} 件")
$变量 与 ${表达式} 两种形态。多行文本用三个引号:
val sql = """ SELECT id, name FROM users WHERE vip = 1 AND created > '2024-01-01' ORDER BY created DESC """.trimIndent()
写 SQL、JSON、HTML 片段时不再需要转义与加号拼接。还有一个必须第一天就记住的差异——相等性:
val a = File("config") val b = File("config") a == b // true:结构相等,等价于 Java 的 a.equals(b) a === b // false:引用相等,等价于 Java 的 a == b
Java 的 == 比较引用、equals 比较内容,字符串比较忘写 equals 是几十年重灾区。Kotlin 把日常符号 == 定义为"调用 equals",引用比较反而要用生僻的 ===——用错的概率被符号的使用频率重新分配了。
下表汇总本节与后续各章反复出现的语法差异,可作为迁移期的桌面速查:

三条可立即执行的练习建议。其一,把一段手头的 Java 工具代码翻译成 Kotlin,规则只有一条:只允许 val,逼自己用表达式重写分支逻辑;写不下去的地方,就是你还没吃透的机制所在。其二,所有 if-else 赋值改写为 if 或 when 表达式,体会"语句变表达式"后中间状态的消失。其三,检查字符串比较:Java 代码里的 .equals 在 Kotlin 里改为 ==,同时全文搜索 === 确认没有误用。
⚠️ 一个新手高频坑:把
val理解成"常量"从而在需要修改时错误地复制出新变量。val 限制的是引用重绑,不是对象内容;列表要不要可变、数据要不要只读,是第 4 章要立的第一等概念,本节先把"引用默认只读"立住。
迁移期最容易低估的一组语法:Kotlin 没有专门的运算符语法,+、*、in 这些符号都是具名函数的约定调用——a + b 编译为 a.plus(b),a in b 编译为 b.contains(a),a[i] 编译为 a.get(i)。这带来一个直接后果:任何类都能让自己的实例支持运算符写法,标准库里大量用这一点造出了读起来像语法的 API:
val page = 3 val pageSize = 20 val from = (page - 1) * pageSize val inRange = page in 1..10 // 区间判断,编译为 IntRange.contains val chars = "Kotlin".slice(0..2) // "Kot" repeat(3) { print("哈") } // 哈哈哈哈,repeat 是普通函数带 lambda
区间是 for 循环的主力:
for (i in 1..4) print(i) // 1234,闭区间 for (i in 4 downTo 1 step 2) print(i) // 42,倒序步进 for (i in 0 until 4) print(i) // 0123,半开区间,索引遍历首选
downTo、step、until 是中缀函数——不加括号、直接夹在两个操作数中间。你会在标准库里频繁遇到这个写法(第 4 章集合的 zip、第 6 章的 to 也是中缀),自己也可以声明:infix fun Int.pow(n: Int): Int,然后写 2 pow 10。判断标准很简单:当两个操作数组合成一个布尔或区间语义时,中缀最顺眼;否则老实加括号。
再补一个 Java 老兵的肌肉记忆坑:位运算。Kotlin 用具名函数替代符号——and、or、xor、shl、shr:
val flags = FLAG_READABLE or FLAG_WRITABLE val canWrite = flags and FLAG_WRITABLE != 0 val shifted = 1 shl 4 // 16
安卓的 Intent 标志位、View 的可见性组合、权限掩码都靠它们。具名形式啰嗦一点,但换来了运算符可重载于任意类型的能力,而且 and/or 读出声就是语义本身,code review 时比一排符号少一半误读。
三引号字符串里有个进阶技巧顺带一提:${'$'} 可以转义美元符本身,写价格模板时会用到。而普通字符串里要表示字面 $,同样写 \$ 或 ${'$'}。
至此底座齐了:val 纪律、表达式化函数、when 分支、模板字符串、运算符约定。后面七章的所有代码,都是这组符号在五个机制方向上的展开。
问:既然 val 这么好,Kotlin 为什么不干脆取消 var?
因为可变性是真实需求:循环计数器、累加器、状态机的当前状态,硬用函数式递归或折叠去写反而降低可读性。Kotlin 的选择不是禁绝可变,而是给可变定价——var 必须显式写出、可变集合必须用 mutableListOf 这类显式构造、修改只读视图会编译失败。当可变性的成本从"零(Java 默认)"变成"每次都要签字",工程师自然会只在真正需要的地方使用它。这与第 5 章协程取消违约异常的设计哲学同源:语言不阻止你做危险的事,但要求你把危险写在明处。
问:表达式化的写法会不会太紧凑,团队里水平参差时反而难读?
紧凑与难读没有必然关系——val label = if (x) "A" else "B" 比四行 if-else 语句更好读,因为它把"给 label 赋值"这个意图压缩到一行。真正影响可读性的是嵌套深度:when 里再嵌 when、表达式里塞三目逻辑,任何语言都难读。团队约定两层护栏就够:表达式体不超过两行、单表达式函数的推断返回类型若为复杂泛型则显式标注。剩下的交给 IDE 的折叠与跳转,Kotlin 的类型系统保证了重构表达式时编译器全程盯梢。
$ 与 ${} 消灭拼接噪音,三引号承载多行文本;== 是 equals,=== 才是引用比较,高频符号承担高频语义;语法底座就位。下一章进入第一根支柱的正题:空安全——可空类型如何把 NPE 从线上崩溃榜拖进编译错误列表。