ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Kotlin 学习路线:七层知识地图与三轮复习法

Kotlin 学习路线:七层知识地图与三轮复习法

简介:《Atomic Kotlin》英文版PDF面向希望系统入门并夯实Kotlin基础的开发者,尤其适合准备Android开发、服务端或Web开发,以及需要从语法细节过渡到面向对象与函数式实践的学习者。全书由Bruce Eckel与Svetlana Isakova编写,以短小章节组织,从Hello, World!、var与val、数据类型、函数、if表达式、字符串模板、数值与布尔类型,延伸到while与for循环、in关键字、表达式与语句、类与对象、属性、构造器、可见性、包、测试、异常和列表等内容,目录完整,便于按主题检索与循序阅读。包内为1个PDF文件,大小约2.79MB,轻量且适合离线查阅。目前已有351人学习,读者可据此建立清晰的Kotlin知识框架,理解值语义、类型系统与函数调用方式,并借助示例反复练习,逐步掌握从基础语法到对象建模、异常处理与集合使用的关键思路。

1. 一张 Kotlin 知识地图:七个 Section 该按顺序逐段学习

把 Atomic Kotlin 英文版 pdf 的目录展开,会看到七个 Section 从 Programming Basics 一路排到 Power Tools,中间夹着两份 Summary 和两个附录。多数人下载完就从第一页开始读,读到对象那一节开始跟不上,因为这本书的组织方式并不是简单地由浅入深,它按照语言特性来分层:先给语法地基,再补对象模型,然后用可用性、函数式、面向对象三条线把同一批知识点重新组织一遍。真正适合复习的读法,是先把这张地图记住——基础、对象、可用性、函数式、OOP、防错、工具,每一层都在补上一层的表达力缺口。这份笔记按这七层梳理,适合已经写过一点 kotlin、但知识点还是零散状态的人。

2. 语言地基:var/val、类型推断与控制流的复习要点

Section I 覆盖的是 26 页到 73 页这一段,从 Hello, World! 开始,到 Expressions & Statements 和 Summary 1 结束。这一段的篇幅不大,但埋了后面所有章节的伏笔:可空类型的智能转换依赖 val 的不可变性,集合操作依赖函数作为值,区间和 in 关键字又贯穿了循环与成员判断。复习时不要把它当成语法速查,而要按“哪些设计决策会在后面反复出现”来记。

2.1 var 与 val 的背后:值语义和可变性边界

书上把 var 和 val 放在最前面讲,顺序是刻意的。val 声明的是只读引用,不是深度不可变对象:引用不能重新指向别的对象,但对象内部状态照样可以改。这一点在集合上最容易误判,很多人看到val list就默认它不能被修改,结果add调用一路畅通。真正的规则是——想禁止内容变化,得用只读接口类型(List、Set、Map)而不是val关键字。

val list = mutableListOf(1, 2, 3) list.add(4) // 合法:val 锁的是引用,不是元素 // list = mutableListOf(5) // 编译错误:val 不能重新赋值 val readOnly: List<Int> = list // 换成只读视图,add 就调不到了 println(readOnly) // [1, 2, 3, 4]

这段代码给出两个层次的可变性:mutableListOf返回的是可写实现,赋给val只是锁住变量名;显式标注成List<Int>之后,编译器只暴露读方法。参数说明上,list的静态类型是MutableList<Int>,readOnly的静态类型是List<Int>,两者指向同一个对象,区别只在编译期可见的方法集。

复习重点是养成“默认 val,需要改再换 var”的习惯。这不只是风格问题:Kotlin 的智能转换(smart cast)在处理可空类型时,只有编译器能确认变量不会被重新赋值才敢放宽类型,val 的局部变量因此比 var 更容易通过非空检查。后面的可空类型一章里,很多“为什么这里编译不过”的疑问,根源都在这一节。

2.2 类型系统与数值转换:没有隐式拓宽的语言

Kotlin 的数值类型是 Byte、Short、Int、Long、Float、Double 六种,外加 Boolean、Char、String。和很多语言不一样的是,它不做隐式数值拓宽:Int 不能直接赋给 Long,必须显式调用toLong()。这个设计初看别扭,实际是为了避免小类型变大类型时的静默精度损失,也让重载解析更可预测。

类型位宽复习时该记的点
Byte8参与运算会自动提升为 Int
Short16同上,算术结果不是 Short
Int32整数字面量默认类型
Long64字面量要加 L 后缀
Float32字面量要加 f 后缀
Double64小数字面量默认类型
val i: Int = 42 val l: Long = i.toLong() // 必须显式转换,不能直接赋值 val d: Double = l.toDouble() val text = "i=$i, l=$l" // 字符串模板:$ 后接变量名 val expr = "sum=${i + l}" // 表达式要用大括号包起来 println(expr) // sum=84

toLong()、toDouble()是成员函数而不是强制转换语法,每种数值类型都带一整套 toXxx 方法。字符串模板里的$变量名只识别简单标识符,遇到运算、属性访问或函数调用,一律换成${...},这是初学者最常写错的地方之一。

另一个容易忽略的点是类型的推断方向:val x = 1推成 Int,val y = 1L推成 Long,字面量后缀直接决定类型。复习时把“字面量默认类型 + 显式转换 + 模板插值”这三件事绑在一起记,比单独背类型表有效得多。

2.3 函数、if 表达式与字符串模板

函数声明用fun,参数写法是“名字: 类型”,返回值写在参数表后面。单表达式函数可以省略大括号和 return,直接=接表达式。返回 Unit 的函数可以不写返回类型。这些规则本身简单,重点是理解 Kotlin 把“表达式”抬到了语言的核心位置:if、when、try 都是表达式,都有值。

fun maxOf(a: Int, b: Int): Int = if (a > b) a else b fun describe(n: Int): String { return when { n < 0 -> "负数" n == 0 -> "零" else -> "正数" } } fun main() { println(maxOf(3, 7)) // 7 println(describe(-1)) // 负数 }

maxOf是表达式体函数,if直接作为返回值,不需要先声明变量再赋值。describe用了块体和无参数的when,每个分支是布尔条件,最后的 else 兜底。参数说明上,when无参数时按顺序匹配第一个为真的分支,所以条件的排列顺序会影响结果,负数判断必须放在最前面。

表达式与语句的区别在 Summary 1 之前单独占了一节,这是有原因的:理解了“一切皆表达式”,后面看try返回最后一个表达式的值、看 lambda 的返回值、看when做穷尽检查,都不会再卡住。if 作为表达式时,两个分支的类型要能统一成一个公共父类型,否则推出来的类型会退化成Any。

2.4 循环、区间与 in 关键字

循环有三种:while、do-while、for。for 不写 C 风格的三段式,只用来迭代区间、集合或任何带迭代器的东西。区间用..构造,闭区间;until是左闭右开;downTo倒序;step控制步长。这一组操作符加上 in 关键字,构成了 Kotlin 里最常用的一类表达。

fun main() { for (i in 1..5) { } // 1,2,3,4,5 for (i in 1 until 5) { } // 1,2,3,4 for (i in 10 downTo 1 step 2) { } // 10,8,6,4,2 val inRange = 3 in 1..5 // true:区间成员判断 val inList = "kotlin" in listOf("java", "kotlin") // true:集合成员判断 println("$inRange $inList") }

in在三种位置出现:for 的迭代、区间或集合的成员判断、以及配合!in做排除。它背后调用的是contains和iterator约定,所以自定义类型只要实现了对应成员,也能用 in。

复习时把区间操作符和循环放在一起记,但要注意一个边界:1..5是闭区间,until是半开区间,两者在倒序和步长组合时行为不同。downTo必须配闭区间语义,step只能接在区间后面,写错顺序编译器直接报错——这反而是好事,比运行期才发现少循环一次强。

3. 可用性与类型安全:扩展函数、可空类型与泛型入门

Section III 从 173 页到 232 页,主题叫 Usability,但内容远不止“好用”:扩展函数改变了你组织工具代码的方式,命名参数和默认参数压缩了重载的数量,when 和密封类配合能做穷尽检查,可空类型三件套则决定了整个项目里 NPE 出现的频率。这一层是把语法变成工程习惯的转折点。

3.1 扩展函数与命名/默认参数

扩展函数看起来像给已有类加方法,本质是静态解析的顶层函数:接收者对象作为第一个参数传进去,编译后就是普通静态调用。这个机制的好处是不改动原类、不继承也能加能力,坏处是它不参与多态——调用哪个扩展函数,取决于变量的静态类型,而不是运行期实际类型。

fun String.lastChar(): Char = this[length - 1] fun greet(name: String, greeting: String = "Hello"): String = "$greeting, $name" fun main() { println("kotlin".lastChar()) // n println(greet("A")) // Hello, A println(greet(name = "A", greeting = "Hi")) // Hi, A:命名参数可以乱序 }

lastChar里的this指向接收者字符串,length直接可用,不需要写this.length。greet的第二个参数带默认值,调用时省略即取默认;命名参数让调用点自解释,尤其是多个同类型参数相邻时,能避免传反顺序。参数说明上,默认值在函数声明处求值,每次调用省略该参数都会重新计算,如果默认值是个耗时表达式要留意。

复习重点是记住扩展函数的解析规则。它不能访问私有成员,不能真的重写成员函数——如果类里已经有一个同名同签名的成员函数,成员函数永远优先。这条规则在后面的易错点里还会出现。

3.2 when、枚举与数据类:把分支和数据建模做干净

when 是 Kotlin 里最强的分支结构,可以带主语,也可以不带;分支条件可以是常量、类型判断、区间、甚至任意布尔表达式。配合枚举和密封类,编译器能做穷尽性检查:漏掉一个分支就编译失败,而不是运行期掉进 else。

enum class Level(val weight: Int) { LOW(1), MID(2), HIGH(3) } data class User(val name: String, val level: Level) fun check(u: User): String = when (u.level) { Level.LOW -> "观察" Level.MID -> "跟进" Level.HIGH -> "优先" } // 枚举分支穷尽,不需要 else fun main() { val u = User("A", Level.MID) val (n, lv) = u // 解构:依赖自动生成的 component1/component2 println("$n/${lv.weight} -> ${check(u)}") // A/2 -> 跟进 }

枚举的每个常量都是单例,可以带构造参数和成员函数,weight就是挂在常量上的属性。数据类自动生成equals、hashCode、toString、copy和一组componentN函数,解构声明正是靠这些 component 函数工作。参数说明上,copy是浅拷贝,只替换你指定的字段,其余字段共享引用。

这一节的重点在于按数据特征选择合适的建模工具:固定集合用枚举,带结构的只读数据用数据类,需要受限继承层级用密封类。三者都不是语法糖,选错了会在后面的分支维护上付出代价。

3.3 可空类型三件套:安全调用、Elvis 与非空断言

String?和String在类型系统里是两个不同的类型,前者不能直接调用成员函数。要把可空类型变成非空,有三条路:安全调用?.、Elvis 运算符?:、非空断言!!。前两个是日常工具,第三个是后备手段,能在代码评审里被挑出来的那种。

fun lengthOf(s: String?): Int = s?.length ?: 0 fun main() { val a: String? = null val b: String? = "kotlin" println(lengthOf(a)) // 0 println(b?.uppercase() ?: "EMPTY") // KOTLIN println(a?.length) // null:安全调用直接短路 // println(a!!.length) // 运行期 NPE }

s?.length在 s 为 null 时整个表达式返回 null,不会继续求值,这叫短路。?:接在可空表达式后面,左侧为 null 时取右侧值,右侧可以是表达式或return/throw,所以常用来做提前退出。!!是断言非空,失败就抛 NPE,它不会让代码更安全,只是把检查从编译期挪到了运行期。

复习时把三者的适用场景分清:?.用于链路中每一步都可能为空,?:用于给空值一个兜底,!!只在“逻辑上绝不可能为空、且为空说明程序已经错了”的位置出现。扩展函数也能定义在可空类型上,比如fun String?.orEmpty(),这类扩展在清理链路时很好用。

3.4 泛型入门与扩展属性

泛型入门只讲最基础的部分:类型参数写在函数名或类名后面,用尖括号包起来;调用时通常能推断出来,不需要显式写。扩展属性是扩展函数的变体,语法上像属性,实际是 getter/setter 函数,因此不能有幕后字段。

fun <T> List<T>.secondOrNull(): T? = if (size >= 2) this[1] else null val <T> List<T>.lastIndex: Int get() = size - 1 // 扩展属性只能靠 getter 计算,不能初始化 fun main() { val xs = listOf("a", "b", "c") println(xs.secondOrNull()) // b println(xs.lastIndex) // 2 }

secondOrNull的类型参数 T 由接收者List<T>决定,返回值是可空的 T。lastIndex是扩展属性,只有 getter,因为它没有地方存字段,所有值都得现算。参数说明上,扩展属性如果是var,必须同时提供 getter 和 setter,setter 也只能操作接收者已有的成员。

这一节的分量不大,但泛型是 Section VII 里 Creating Generics 的前置。复习时至少要知道:类型参数可以加约束(<T : Comparable<T>>),星投影和型变先放一放,等看到委托和泛型函数再回头补。

4. 函数式与 OOP 双主线:lambda、继承、委托与防错工具

Section IV 到 Section VII 占了全书一半以上的篇幅,主题从函数式编程一路铺到 Power Tools。这一段最容易复习得零散,因为它同时在讲三件事:怎么用函数作为值来写集合处理、怎么用类和接口组织大型结构、怎么用异常和委托把出错概率压下去。把它们串起来的主线只有一个——表达力和安全性的取舍。

4.1 从 lambda 到高阶函数:集合操作的表达力

lambda 是花括号包起来的匿名函数,单参数时可以用 it 指代,多参数要显式写名字。高阶函数指参数或返回值是函数的函数。成员引用::是 lambda 的简写形式,当 lambda 只是转调一个已有函数时用它,可读性更好。

data class Order(val id: Int, val amount: Double) fun main() { val orders = listOf(Order(1, 30.0), Order(2, 120.0), Order(3, 80.0)) val big = orders.filter { it.amount > 50 } // it 指代当前元素 val ids = big.map(Order::id) // 成员引用替代 { it.id } val total = orders.sumOf { it.amount } // 聚合为一个值 val byTier = orders.groupBy { if (it.amount >= 100) "high" else "low" } println("$ids $total ${byTier.keys}") // [2, 3] 230.0 [low, high] }

filter保留满足条件的元素,map做一对一变换,sumOf把元素折算成数值,groupBy按 key 分组返回 Map。参数说明上,这些函数的 lambda 参数都是最后一位,所以可以用尾随 lambda 语法写在括号外面;sumOf有多个重载,传 Double 时返回 Double,别和sum混用。

复习重点是记住“集合操作链”的默认行为:每一步都产生一个新的中间集合。数据量小的时候无所谓,链一长、集合一大,内存和 GC 压力就上来了,这正是下一节 Sequences 要解决的问题。

4.2 Sequences 与折叠:什么时候惰性才划算

Sequence 把操作分成中间操作和终止操作,中间操作只记录不执行,直到终止操作触发才逐个元素跑完整条链。它的收益在“大数据量 + 提前截断”两个条件同时成立时才明显;小集合上它反而比 List 慢,因为多了一层迭代器开销。

fun main() { val result = (1..1_000_000).asSequence() .filter { it % 3 == 0 } // 惰性:不会先造出 33 万个元素 .map { it * 2 } .take(5) // 取满 5 个就停,后面的元素不再处理 .toList() println(result) // [6, 12, 18, 24, 30] val folded = listOf(1, 2, 3).fold(100) { acc, x -> acc + x } println(folded) // 106:带初始值 }

asSequence把区间转成惰性序列,take是短路操作,元素处理到第 5 个就终止。fold需要显式初始值,reduce不需要但空集合会抛异常,这是两者最实际的区别。参数说明上,fold的 lambda 接收累计值和当前元素两个参数,返回值成为下一轮的累计值。

复习时把这条判断标准记牢:链式操作超过两三步、或者数据量上万、或者有 take/first 这类提前结束的需求,才考虑 Sequence;单纯的“我想写得函数式一点”不构成理由。折叠和递归放在同一节讲,也是因为它们都能把循环压成表达式,但递归在 Kotlin 里没有尾调用优化的普遍保证,深递归该用循环还得用循环。

4.3 接口、继承与多态:向上转型和基类初始化

接口定义能力契约,可以带默认实现;类默认是 final 的,要继承必须标 open;重写成员必须写 override。向上转型是把子类实例赋给父类型变量,之后调用的是运行期实际类型的方法,这就是多态。基类初始化顺序是这一节最容易出问题的地方。

interface Greeter { fun hello(): String = "hi" } open class Base(val tag: String) { init { println("Base init: $tag") } // 先于子类 init 执行 } class Child(tag: String) : Base(tag), Greeter { override fun hello() = "child($tag)" } fun main() { val g: Greeter = Child("x") // 向上转型 println(g.hello()) // child(x):按实际类型分派 val c: Child = g as Child // 向下转型,失败会抛异常 println(c.tag) }

Child的主构造器把 tag 传给基类,基类的 init 块在子类 init 之前执行,所以基类构造期间子类字段还没初始化。g as Child是向下转型,类型不匹配抛 ClassCastException,安全版本是as?,失败返回 null。参数说明上,open只对当前类生效,子类若还想被继承,得自己也标 open。

复习时把“默认 final”当成一条硬规则来记,它和 Java 的默认开放正好相反。多态的实现细节不用深究,但初始化顺序必须背下来:基类属性初始化 → 基类 init 块 → 子类属性初始化 → 子类 init 块。在基类构造器里调用开放成员,是典型的翻车现场。

4.4 委托、密封类与伴生对象

组合优于继承这句话在这里落地成两个工具:类委托用by把接口实现转给另一个对象,密封类用受限层级让 when 能做穷尽检查。再加上嵌套类、内部类、object 和伴生对象,这一节基本覆盖了 Kotlin 组织代码结构的全部手段。

sealed class Result { data class Ok(val value: Int) : Result() data class Err(val msg: String) : Result() } fun render(r: Result): String = when (r) { is Result.Ok -> "ok=${r.value}" is Result.Err -> "err=${r.msg}" } // 密封类:不写 else,新增子类时编译报错提醒你补分支 class Wrapper(private val inner: Greeter) : Greeter by inner fun main() { println(render(Result.Ok(1))) // ok=1 println(Wrapper(Child("y")).hello()) // child(y):委托转发 }

密封类的子类必须定义在同一个文件或同一模块内,编译器因此知道全部可能类型,when 不需要 else。类委托把接口方法的实现自动转发给 inner,省掉一堆样板代码。参数说明上,by inner只转发接口方法,Wrapper 自己的成员不会被转发。

伴生对象要单独提一句:它看起来像静态成员,实际是类内部的一个单例对象,访问时通过类名调用,能实现接口、能继承。理解这一点,后面看工厂方法和常量定义就不会再混淆。

4.5 防错与 Power Tools:异常、check、作用域函数、委托属性

Section VI 和 Section VII 把“少出错”和“写得省”两件事放在一起。异常处理用 try/catch/finally,require和check做前置条件断言,Nothing类型标记永不返回的表达式,use负责资源关闭。Power Tools 里最重要的是作用域函数和委托属性。

函数接收者返回值典型场景
letitlambda 结果可空值链式处理
runthislambda 结果配置后计算一个结果
withthislambda 结果对同一对象连续操作
applythis接收者本身对象初始化
alsoit接收者本身插入日志等副作用
class Config { var host: String = ""; var port: Int = 0 } fun main() { val cfg = Config().apply { // apply 返回对象本身,适合初始化 host = "localhost" port = 8080 } require(cfg.port in 1..65535) { "port 越界" } // 不满足直接抛 IllegalArgumentException val conn: String by lazy { // 首次访问才求值,之后复用 println("只算一次") "connection" } println(conn) println(conn) // 第二次不再打印“只算一次” }

apply内部用 this 指代接收者,返回值还是接收者,所以能链式接其他调用。require失败抛 IllegalArgumentException,check失败抛 IllegalStateException,区别在语义:前者检查参数,后者检查状态。by lazy默认线程安全,第二次访问直接取缓存值。

属性委托还有observable和vetoable两个工具,前者在值变化时回调,后者能拦截不合法的赋值。延迟初始化lateinit解决的是另一类问题——依赖注入场景下没法在构造时赋值,只能先声明后填。这两者的适用边界,下一章会专门拆开讲。

5. 易错点清单:Kotlin 里最容易翻车的五处细节

把前面几层过完,会发现真正让人栽跟头的,往往是一些看起来都懂、写起来就错的细节。这一类错误有个共同点:编译器不报错,运行时也不一定立刻报错,等到某次重构或某个边界值才暴露出来。梳理下来,最容易忽略的是这五处。

5.1 类型与空安全上的三处坑

扩展函数是静态解析,不参与多态。容易忽略的原因在于语法太像成员函数,看到obj.ext()就默认它会按运行期类型分派。补法很直接:记住扩展函数编译成静态方法,调用点按变量的静态类型决定调用哪个版本;如果父类型和子类型各有一个同名扩展,把子类实例赋给父类型变量后,调到的永远是父类型那个。写扩展时尽量避开已有成员函数的签名,能减少一半困惑。

open class A class B : A() fun A.name() = "A" fun B.name() = "B" fun main() { val b: A = B() println(b.name()) // A:按静态类型解析,不是 B }

非空断言在 Java 互操作边界上最危险。容易忽略的原因在于 Kotlin 自己的代码里,!!用起来似乎总能蒙对,因为大部分可空值确实非空。补法是:所有来自 JVM、反射、序列化框架的值,在 Kotlin 眼里都是平台类型,编译器不做可空检查,这时!!等于把 NPE 直接放行。遇到这类来源,先判空或给默认值,实在要在边界上断言,就紧贴着边界写,别让它扩散到业务层。

val 只锁引用,不锁集合内容。容易忽略的原因在于关键字语义太强,看到 val 就默认整个对象冻结。补法是区分两个层次:变量不可重新赋值靠 val,内容不可修改靠只读接口类型。团队里约定集合参数一律用List/Set/Map而不是 Mutable 版本,传进来之后想改再显式复制一份,这条习惯能省掉很多“谁在什么时候改了这个集合”的排查。

5.2 结构与初始化上的两处坑

lateinit 和 by lazy 用错场景。容易忽略的原因在于两者都能“延迟给值”,但约束完全不同。lateinit 只能用在 var、非基本类型、且不能用于可空类型,访问未初始化值抛 UninitializedPropertyAccessException;by lazy 只能用在 val,首次访问求值一次,默认线程安全。补法是按“值会不会变”来选:只读依赖用 lazy,可变的注入字段用 lateinit,并且在使用前用::field.isInitialized做一次检查。

基类构造器里调用开放成员。容易忽略的原因在于这种写法在语法上完全合法,编译不报任何警告。补法是记住初始化顺序:基类构造期间,子类的属性和 init 块都还没执行,此时调用被子类重写的开放成员,读到的是未初始化的默认值(0、null、false)。规则很简单——构造器里只做本类字段的赋值,任何需要子类参与的逻辑都挪到构造完成之后的方法里。

6. 复习清单:三轮过书法与自检表

这份材料的目录结构本身就是一份复习提纲,七个 Section 加两个附录,每一节标题就是一个可自测的知识点。我的做法是分三轮,每轮的产出物不同,避免“读了很多遍但还是不会写”的假复习。

第一轮只做一件事:合上书,按目录标题逐个自问“这一节讲了什么、关键 API 是什么、最容易错在哪”。答不上来的标题画勾,第二轮只看画勾的。这一轮不写代码,目标是暴露知识盲区,通常第一遍会勾掉三成左右的标题。

轮次输入产出判定标准
第一轮只看目录标题盲区清单能说出每节的核心 API
第二轮盲区章节 + 官方示例最小复现文件每个盲区有一段可运行代码
第三轮两份 Summary + 附录闭卷自测不给提示能写出典型用法

第二轮针对盲区写最小复现,每个知识点控制在二十行以内,必须能跑。这一轮的副产品是那些“以为懂了、一写就错”的细节,比如扩展属性的 setter 到底能不能写、as?失败之后怎么兜底、fold的初始值类型会不会影响推断结果。写完跑一遍,报错信息本身就是最好的复习材料。

第三轮用 Summary 1 和 Summary 2 做闭卷自测,这两份小结是作者自己压缩过的知识密度最高的部分。附录里的测试工具和互操作章节不要跳过,前者决定你能不能给自己写的练习加断言,后者决定你在混合代码库里会不会被平台类型影响。

// 自测骨架:每个知识点写成一个小函数,配一条断言 fun main() { check("kotlin".lastChar() == 'n') { "扩展函数" } check((null as String?)?.length == null) { "安全调用" } check(listOf(1, 2, 3).fold(0) { a, b -> a + b } == 6) { "折叠" } println("all passed") }

这段骨架的用法是:每复习一个知识点就加一行check,跑通再进下一个。check失败抛 IllegalStateException,消息参数写知识点名字,报错时一眼能看出是哪条没过。参数说明上,第二个参数是惰性求值的消息 lambda,只有断言失败才执行,不会拖慢正常路径。

血泪经验是:前两轮千万别跳着看,目录里那些看起来“太基础”的标题,往往就是第三轮卡住你的地方。从那以后我每次复习这本书,都强制先盲测目录再翻正文,宁可多花二十分钟自问,也不愿在写代码时被一个!!或一个静态解析坑掉半天。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进