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: ListInt list // 换成只读视图add 就调不到了 println(readOnly) // [1, 2, 3, 4]这段代码给出两个层次的可变性mutableListOf返回的是可写实现赋给val只是锁住变量名显式标注成ListInt之后编译器只暴露读方法。参数说明上list的静态类型是MutableListIntreadOnly的静态类型是ListInt两者指向同一个对象区别只在编译期可见的方法集。复习重点是养成“默认 val需要改再换 var”的习惯。这不只是风格问题Kotlin 的智能转换smart cast在处理可空类型时只有编译器能确认变量不会被重新赋值才敢放宽类型val 的局部变量因此比 var 更容易通过非空检查。后面的可空类型一章里很多“为什么这里编译不过”的疑问根源都在这一节。2.2 类型系统与数值转换没有隐式拓宽的语言Kotlin 的数值类型是 Byte、Short、Int、Long、Float、Double 六种外加 Boolean、Char、String。和很多语言不一样的是它不做隐式数值拓宽Int 不能直接赋给 Long必须显式调用toLong()。这个设计初看别扭实际是为了避免小类型变大类型时的静默精度损失也让重载解析更可预测。类型位宽复习时该记的点Byte8参与运算会自动提升为 IntShort16同上算术结果不是 ShortInt32整数字面量默认类型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) // sum84toLong()、toDouble()是成员函数而不是强制转换语法每种数值类型都带一整套 toXxx 方法。字符串模板里的$变量名只识别简单标识符遇到运算、属性访问或函数调用一律换成${...}这是初学者最常写错的地方之一。另一个容易忽略的点是类型的推断方向val x 1推成 Intval 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 ListT.secondOrNull(): T? if (size 2) this[1] else null val T ListT.lastIndex: Int get() size - 1 // 扩展属性只能靠 getter 计算不能初始化 fun main() { val xs listOf(a, b, c) println(xs.secondOrNull()) // b println(xs.lastIndex) // 2 }secondOrNull的类型参数 T 由接收者ListT决定返回值是可空的 T。lastIndex是扩展属性只有 getter因为它没有地方存字段所有值都得现算。参数说明上扩展属性如果是var必须同时提供 getter 和 settersetter 也只能操作接收者已有的成员。这一节的分量不大但泛型是 Section VII 里 Creating Generics 的前置。复习时至少要知道类型参数可以加约束T : ComparableT星投影和型变先放一放等看到委托和泛型函数再回头补。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))) // ok1 println(Wrapper(Child(y)).hello()) // child(y)委托转发 }密封类的子类必须定义在同一个文件或同一模块内编译器因此知道全部可能类型when 不需要 else。类委托把接口方法的实现自动转发给 inner省掉一堆样板代码。参数说明上by inner只转发接口方法Wrapper 自己的成员不会被转发。伴生对象要单独提一句它看起来像静态成员实际是类内部的一个单例对象访问时通过类名调用能实现接口、能继承。理解这一点后面看工厂方法和常量定义就不会再混淆。4.5 防错与 Power Tools异常、check、作用域函数、委托属性Section VI 和 Section VII 把“少出错”和“写得省”两件事放在一起。异常处理用 try/catch/finallyrequire和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失败抛 IllegalArgumentExceptioncheck失败抛 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、非基本类型、且不能用于可空类型访问未初始化值抛 UninitializedPropertyAccessExceptionby 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只有断言失败才执行不会拖慢正常路径。血泪经验是前两轮千万别跳着看目录里那些看起来“太基础”的标题往往就是第三轮卡住你的地方。从那以后我每次复习这本书都强制先盲测目录再翻正文宁可多花二十分钟自问也不愿在写代码时被一个!!或一个静态解析坑掉半天。希望帮到你。本文还有配套的精品资源点击获取