从Java转过来学Kotlin的人,多半会经历一个"别扭"的阶段:明明语法看着和Java差不多,写起来却处处不一样。DAY 3我集中啃了面向对象三大特性——封装、继承、多态,本以为只是换皮,结果发现Kotlin在这三件事上的设计思路几乎是对Java的一次彻底重构。这篇笔记不按官方文档顺序讲,我按自己实际踩坑和理解的过程来梳理,重点放在"为什么Kotlin要这么设计"以及"写代码时该怎么做"上。
1. 写封装前,先搞清楚Kotlin的可见性设计和Java差在哪
很多初学者看到"封装"第一反应就是private、getter、setter,然后背一套"把字段私有化,通过公共方法访问"的公式。这套说法没有错,但在Kotlin里如果你还按Java的老思路去写,代码会变得非常啰嗦。Kotlin对封装的理解不是"字段私有化",而是"暴露稳定的接口,隐藏不稳定的实现",属性本身就是一种封装。
1.1 默认final和默认public:Kotlin的反直觉设计
Kotlin的类和方法默认是final的,这一点和Java完全相反。Java里你能继承任何普通类,除非你主动加final;Kotlin里你只能继承主动标记了open的类。我第一次看到这个设计觉得不习惯,后来才发现这是深思熟虑的:默认final意味着作者必须明确思考"我这个类就是设计给子类扩展的",而不是随手new一个类然后继承它拆东墙补西墙。封装的前置条件其实是"边界清晰",默认final天然逼迫你规划好继承边界。
更反直觉的是可见性默认值。Java的默认包可见性,也就是package-private,在实际业务代码里几乎没人用,大家写Java时的习惯是"不写修饰符就等于public"或者"能public就public"。Kotlin干脆把默认值直接设为public,省得你每次都要敲一遍。真正承载"模块内部可见"语义的是internal,这比Java的package-private更符合工程实际:Kotlin里internal表示整个模块内可见,而Java的包可见性只对同一个包生效,跨包同事类反而不可见,很多时候要打擦边球。
1.2 private和protected的层级差异
Java里private只作用于类内部,Kotlin里private可以作用于类、接口、顶层函数甚至扩展函数。比如你可以在一个Kotlin文件里写一个private的顶层函数,它只对当前文件可见。这在工具类拆分时非常有用。
protected在Kotlin里有一个特殊点:它只能在类内部使用,而且子类可以访问父类的protected成员,这一点和Java一致。但Kotlin的protected不允许在顶层声明。另外Java里protected还带包访问权,Kotlin直接去掉了这个特性,代码反而更清晰。
| 可见性修饰符 | Kotlin中的效果 | Java对比说明 |
|---|---|---|
| private | 类内可见;顶层声明时对当前文件可见 | Java仅类内可见,范围更小 |
| protected | 类内及子类可见;不能用于顶层 | Java额外带包可见性,Kotlin已去掉 |
| internal | 整个模块内可见 | Java无对应概念,最接近的是package-private但作用范围不同 |
| public | 默认;任何位置可见 | Java也有public,但Kotlin是默认值 |
我在做Android项目时习惯把一些Util函数写成internal,方便同模块的多个包共用,又不至于暴露给外部SDK消费者。这个设计在Java世界里做不到,除非你把类放在同一个包里,或者干脆public。
1.3 封装不是"私有化",是"接口稳定"
Kotlin里经常看到"属性私有化、对外暴露只读接口"的写法。与其写一堆getXxx(),不如直接在类里定义一个val,然后在内部用private set。比如:
kotlin复制class ApiClient {
var token: String? = null
private set
fun updateToken(value: String?) {
token = value
// 内部可以加校验、通知、持久化逻辑
}
}
调用方可以读token,但不能随便改。改动只能走updateToken(),这就是Kotlin式的封装。它的好处是不用把getter/setter全写出来,代码量下降,可读性上升。同样的需求在Java里你得写一个私有字段、一个public getter、一个public的设置方法,或者用Lombok去应付。
我实际写下来发现,Kotlin更推荐"先设计好公开API,再决定内部状态"。你把面向调用者的接口想清楚了,实现细节自然知道要不要私有化。反过来先写一堆字段再一个个加getter,很容易把不该暴露的内容露出去。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 属性封装不是写getter/setter:Kotlin的幕后字段与属性委托
Java的字段在Kotlin里几乎全部被属性(property)取代。封装的关键不是"私有化字段",而是"控制属性的读写行为"。Kotlin编译器会为val生成getter,为var生成getter和setter,但你完全可以在声明里自定义这些访问器的逻辑,而不用手写一个方法。
2.1 field关键字和访问器的自定义逻辑
先看一个最简单的自定义setter的例子:
kotlin复制class User {
var name: String = ""
set(value) {
field = value.trim()
}
}
这里的关键是field关键字,它代表幕后字段(backing field)。当你重写getter或setter时,不能再直接访问属性名本身,因为那样会形成无限递归。比如:
kotlin复制class User {
var name: String = ""
set(value) {
name = value.trim() // 这里会无限递归,最终栈溢出
}
}
多数人第一次写自定义setter都会踩到这个坑。Kotlin的设计是:只要你重写了访问器,就需要用field来操作真实存储值;如果你既没重写getter也没重写setter,编译器默认就用field存数据。这个设计让"属性的读写逻辑"和"属性的实际存储"完全解耦。
2.2 val和var的选择本身就是一种封装
val表示只读引用,var表示可变引用。这不只是语法糖,而是对"这个属性是否对外可变更"的一种明确声明。在设计类时,优先用val,只有确实需要修改时才用var。这个习惯能有效减少状态突变带来的bug。
我遇到过一种情况:类内部需要可变状态,但对外暴露只读。做法是内部用var,对外用val或者只读接口:
kotlin复制class TemperatureMonitor {
private var _currentValue
