从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: Double = 0.0
val currentValue: Double
get() = _currentValue
}
如果你看到类里出现下划线开头的属性,一般来说就是在做这种"内部可变、外部只读"的封装。这是Kotlin社区约定俗成的写法,Android里也有类似命名惯例。
2.3 lateinit和by lazy:两种延迟初始化的封装思路
Kotlin里声明非空类型属性时如果不初始化会直接编译报错。但有些场景属性确实要在后面才赋值,比如Android里的View绑定、依赖注入、单元测试里的mock对象。这时候可以用lateinit:
kotlin复制class LoginActivity : AppCompatActivity() {
private lateinit var viewModel: LoginViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProvider(this)[LoginViewModel::class.java]
}
}
lateinit的本质是"我保证在使用前会赋值",但如果你提前访问会抛UninitializedPropertyAccessException。另一种延迟初始化是by lazy,它把初始化逻辑变成lambda,在第一次访问时才执行,之后缓存结果:
kotlin复制class OrderService(private val repository: OrderRepository) {
private val validator: OrderValidator by lazy {
OrderValidator(repository)
}
}
两者的应用场景完全不同:lateinit适合依赖注入或框架回调赋值的场景,用var;by lazy适合计算开销大或需要统一初始化入口的场景,用val。我建议优先考虑by lazy,因为它默认是val,更能保证线程安全和不可变性,但要小心它默认是线程安全的,同步开销略高,如果确认同步不需要,可以显式指定LazyThreadSafetyMode.NONE。
2.4 属性委托把"可复用逻辑"从类里抽出去
除了by lazy,Kotlin还支持自定义属性委托。你自己写一个类,实现getValue和setValue操作符,然后就能在其他类里用by复用这一套逻辑。比如观察者模式可以很优雅地封装成委托:
kotlin复制class ObservableProperty<T>(initialValue: T) {
var value: T = initialValue
set(newValue) {
field = newValue
listeners.forEach { it(newValue) }
}
private val listeners = mutableListOf<(T) -> Unit>()
fun addListener(listener: (T) -> Unit) {
listeners.add(listener)
}
operator fun getValue(thisRef: Any?, property: Any?): T = value
operator fun setValue(thisRef: Any?, property: Any?, newValue: T) {
value = newValue
}
}
然后在类里这样用:
kotlin复制class ProfileViewModel {
var name: ObservableProperty<String> by ObservableProperty("")
}
实际项目中属性委托经常用于数据存储(SharedPreferences、DataStore)、状态管理和日志埋点。如果你发现多个类都在写同样的"读字段+写字段+副作用"逻辑,属性委托就是一个很好的抽取点。
3. 继承的边界设计:open、abstract、sealed、interface怎么选
Kotlin里的继承和Java最大的区别就一个字:约束。类的继承关系是强耦合关系,父类一改,子类全受影响。Kotlin用默认final的机制强制你"不要随便继承",同时给了abstract、sealed、interface这一组边界清晰的扩展手段,让继承真正用在刀口上。
3.1 open关键字:显式表达"这个类是给扩展用的"
在Kotlin中,如果要让类被继承,就必须用open标记;要让方法被子类重写,方法本身也要open。这是对Java世界"所有方法默认虚函数"的一种纠偏。
为什么我会觉得这个设计特别重要?看看Java的ArrayList或者HashMap,几乎每个方法都可以被重写,结果就是有无数人继承了这些类然后改坏逻辑。Kotlin在语言层面直接否定了这种"啥都能重写"的自由,减少了大量因为父类行为漂移导致的故障。
kotlin复制open class BaseCalculator {
open fun calculate(input: Int): Int = input * 2
}
class TaxCalculator : BaseCalculator() {
override fun calculate(input: Int): Int = super.calculate(input) + 100
}
如果你写代码的时候发现一个类没有open,但又非要继承它,这通常是一个设计信号:你要么是误用了继承,要么是需要给这个类补充open标记。Kotlin用编译错误逼你停下来思考。
3.2 抽象类与接口的选择:什么时候用哪个
Kotlin的抽象类和接口都支持抽象方法,但抽象类保存状态,接口在新版本里也支持属性,两者边界似乎有点模糊。我按自己的经验划一条线:
- 如果多个子类共享同一个"状态"(字段),并且父类需要保存这个状态或提供一个构造器入口,用抽象类。
- 如果只是定义行为契约,不关心状态,用接口。
- 如果一个类可以承担多种角色,用接口更灵活。Kotlin的类只能单继承,却可以实现多个接口。
接口里的属性本质上是一个"必须被实现或覆写的抽象属性",不能像抽象类那样直接初始化:
kotlin复制interface Shape {
val area: Double // 抽象属性,实现类必须override
}
class Circle(val radius: Double) : Shape {
override val area: Double
get() = Math.PI * radius * radius
}
3.3 sealed class:让继承范围变得可控
sealed class是Kotlin一个非常亮眼的设计,官方叫密封类,它限制了子类只能在同一个包/模块中定义。这意味着你枚举了一个封闭的继承层级,when表达式可以穷尽所有分支。
kotlin复制sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val code: Int, val message: String) : Result<Nothing>()
object Loading : Result<Nothing>()
}
在业务里用的时候,可以配合when做穷尽判断:
kotlin复制fun handleResult(result: Result<String>) {
when (result) {
is Result.Success -> println(result.data)
is Result.Error -> println(result.code)
Result.Loading -> println("loading")
}
}
因为sealed class的子类集合是固定的,编译器能判断所有分支齐全,不用写else。这比Java的枚举强大得多——枚举只能枚举值,sealed class可以枚举一组具有不同状态和行为的子类型。我经常用它表达网络请求的三种状态、UI的加载状态、流程状态机。
3.4 菱形继承问题和Kotlin的解法
搜索"菱形继承"的人多半是从C++或Java那边过来的。C++的多继承会导致子类保存多个基类副本,引发二义性。Java没有类的多继承,但在接口那里出现了类似问题:一个接口继承多个接口时,若它们有相同默认方法,实现类必须手动解决冲突。
Kotlin的接口继承规则是这样:
- 如果一个类实现的两个接口有同名默认方法,必须重写这个方法来消除岐义。
- 重写时可以指定调用某个父接口的实现,用super<父接口名>.method()的语法。
kotlin复制interface A {
fun foo() { println("A") }
}
interface B {
fun foo() { println("B") }
}
class C : A, B {
override fun foo() {
super<A>.foo()
super<B>.foo()
}
}
Kotlin不允许类多继承,从根源上避免了真正的菱形继承问题。接口层面的冲突是可预测、可解决的,编译期就能发现。
4. 多态落地:重写、重载和基于接口的派发
面向对象的多态本质是"同一个消息,不同对象给出不同反应"。在Kotlin里,多态主要体现在三种形式:重写(override)、重载(overload)和通过接口/抽象类引用调用不同实现。有人说Kotlin比Java更像"很多语法糖包装的C#",我觉得从多态的实现方式来看,Kotlin还是很Java的。
4.1 重写:override必须显式写出
Kotlin重写父类方法时,override关键字是强制的。如果不写override却恰好签名一致,编译器会直接报错。这个设计和Java正好相反——Java允许你在子类里"恰好"写一个签名一样的方法来覆盖父类方法(如果父类方法非final),而Kotlin必须显式声明。
这其实是个巨大的工程价值点:你读代码时一眼就能看出哪些方法是覆盖父类的,哪些是子类自己的,不会出现"这个类是重写还是新增?"的疑问。
重写还有一个细节:Kotlin要求重写后的方法不能再写open,除非你自己要允许孙类继续重写。
kotlin复制open class Parent {
open fun printName() { println("Parent") }
}
class Child : Parent() {
final override fun printName() { println("Child") }
}
在不知道是否要继续被继承时,我倾向于给override默认加final,因为一旦你允许子类继续重写,这个类的扩展面就会指数增长,违反Kotlin的设计哲学。
4.2 重载:参数列表不同,返回类型无关
重载是编译期就确定的,不像重写是运行期动态派发。Kotlin允许在同一个类里定义多个同名但参数列表不同的方法,但有一点要注意:默认参数不能和重载混用得过于放飞。
kotlin复制class Printer {
fun print(text: String) {
println(text)
}
fun print(text: String, times: Int) {
repeat(times) { println(text) }
}
}
如果你给第一个方法加默认参数,比如fun print(text: String, times: Int = 1),那调用时print("hi")和print("hi", 3)都能工作,就不需要再写两个重载了。Kotlin在一定程度上用默认参数减少了重载的用量,这在设计API时更简洁。
4.3 基于父类引用调用子类实现:运行期多态
多态最经典的场景是:
kotlin复制open class Animal {
open fun speak() { println("animal") }
}
class Dog : Animal() {
override fun speak() { println("woof") }
}
class Cat : Animal() {
override fun speak() { println("meow") }
}
fun makeItSpeak(animal: Animal) {
animal.speak()
}
fun main() {
makeItSpeak(Dog())
makeItSpeak(Cat())
}
这个例子虽然老套,但确实点出了多态的核心:编译时animal的类型看似是Animal,运行时却会调用Dog或Cat的speak()。这种"编译看左边,运行看右边"的机制,是面向对象设计模式的基础。
我在项目中经常用策略模式来体现这点:接口定义算法,不同的实现类代表不同的业务策略,调用方只依赖接口,不依赖具体实现。想在运行时切换策略,只要换一个实现类对象就可以了。
4.4 sealed class与when表达式:多态的一种"穷尽式"用法
sealed class配合when是Kotlin里非常爽的多态场景。传统Java写法是if-else判断类型,或者用枚举做switch,很容易漏分支。sealed class让每个子类都有自己的行为,when表达式能直接处理:
kotlin复制sealed class AppState {
data class LoggedIn(val userName: String) : AppState()
data class LoggedOut(val reason: String) : AppState()
object Loading : AppState()
}
fun render(state: AppState) {
when (state) {
is AppState.LoggedIn -> showUser(state.userName)
is AppState.LoggedOut -> showLoginPage(state.reason)
AppState.Loading -> showLoading()
}
}
这种写法比多态方法的优势在于:处理逻辑不一定属于子类本身,可以收敛在函数内部;如果以后新增一个AppState子类,编译器会提醒所有when需要补齐分支,比运行期才发现强很多。
4.5 泛型实现参数多态:Kotlin里同样重要
传统面向对象多态只讲子类型多态,但Kotlin泛型其实也是一种多态,叫参数多态。一个函数可以接受任意类型T,表现出统一的结构行为。比如:
kotlin复制fun <T> identity(value: T): T = value
在Android开发里,泛型和接口多态经常一起用,比如Repository模式中BaseRepository
5. 组合优于继承?Kotlin中扩展函数与类委托的取舍
"组合优于继承"是面向对象设计里被说烂的一句话。多数人的理解停留在一句口号上,实际写代码时依然习惯继承。Kotlin给了两个非常实用的工具来落地"组合优于继承":扩展函数和类委托。
5.1 扩展函数:在不改变类的情况下给类增加能力
扩展函数并不是Kotlin类的成员方法,它只是语法层面的"伪方法",本质上是一个静态函数,把接收者对象作为参数传进去。比如:
kotlin复制fun String.isPhoneNumber(): Boolean {
return this.matches(Regex("^1[3-9]\\d{9}$"))
}
用的时候就像在调用String的方法一样:
kotlin复制val m = "13800138000"
println(m.isPhoneNumber())
看起来是String多了一个方法,但String类的代码一个字节都没有被修改。这就是封装思想的一个延伸:一个类不需要为了给自身增加能力,就强制把所有逻辑都写进类里;扩展函数可以在类外部组织逻辑,保持类自身的简洁。
我以前在Java里想给Utils工具类加一个方法,要么改Utils类本身,要么新建一个工具类,要么继承。Kotlin的扩展函数直接绕开了这个困境,而且它对所有引用这个函数的地方生效。但它有一个明显的局限:扩展函数只能通过静态类型决定调用哪个版本,它不具备运行时多态性。看看这个例子:
kotlin复制open class Base
class Child : Base()
fun Base.say() = "base"
fun Child.say() = "child"
fun main() {
val b: Base = Child()
println(b.say()) // 输出 "base",而不是 "child"
}
这说明扩展函数的分派完全看编译时类型,不是运行期动态绑定。如果你需要真正的多态,还是得靠类成员函数的重写。扩展函数适合解决"给很多没有层级关系的类补充公共能力"的场景,不适合重写具体逻辑。
5.2 类委托by关键字:把实现细节包出去
Kotlin的类委托通过by关键字实现,一个类可以选择把接口的实现委托给另一个对象:
kotlin复制interface MusicPlayer {
fun play()
fun pause()
}
class BaseMusicPlayer : MusicPlayer {
override fun play() = println("playing")
override fun pause() = println("paused")
}
class LoggedMusicPlayer(
private val wrapped: MusicPlayer = BaseMusicPlayer()
) : MusicPlayer by wrapped
这里LoggedMusicPlayer通过by语法自动获得了MusicPlayer接口的所有实现,但又不继承BaseMusicPlayer的类结构,而是用组合关系实现对象行为复用。如果你想在委托基础上加一点自己的逻辑,可以手动重写个别方法:
kotlin复制class LoggedMusicPlayer(
private val wrapped: MusicPlayer = BaseMusicPlayer()
) : MusicPlayer by wrapped {
override fun play() {
println("log before play")
wrapped.play()
}
}
这种模式比继承BaseMusicPlayer再重写方法更加可控,也可以让一个类组合多个对象的实现。
5.3 什么时候你确实应该用继承
组合也不应该是万能的。有些场景继承是合理的:
- 子类确实是父类的一种特殊化,比如三角形是图形的具体实现。
- 你需要利用父类的构造器参数和初始化逻辑,统一准备状态。
- 你要复用父类的字段,而不仅仅是行为约定。
- 你需要在框架的模板方法里让子类填充关键步骤,比如Android的Activity生命周期方法。
Kotlin的sealed class本质上也依赖继承,但它把继承范围限制在一个模块,反而是一种受控的组合。我不建议把"组合优于继承"变成教条,而是应该把它当作"继承前先问一句:另一个对象能不能帮我干这件事"的提醒。
5.4 实际项目里的分层设计:封装、继承、多态如何协同
一个典型的网络请求模块可以说明三者的协同:
- 用接口定义Repository的契约(多态)。
- 用抽象基类封装通用逻辑,比如统一错误处理、日志打印(继承)。
- 用data class封装响应体,字段私有化或只读化(封装)。
- 用sealed class定义请求结果的多种状态(继承+穷尽多态)。
- 用extension函数提供JSON解析、鉴权头补充等公共能力(组合)。
你很难说哪个特性是独立的,它们组合起来才能让代码结构稳定。学习Kotlin三个特性时,不要把它们割裂着背概念,要尝试放在一个真实场景里看它们如何互相支撑。
6. 学习Kotlin OOP过程中的几个典型坑与心得
6.1 属性重写时val和var的兼容性
父类如果是val,子类可以用var重写;父类如果是var,子类不能用val重写。道理很简单:val表示只读,子类扩展成可变是可以的;父类可变,子类如果变成只读,就会破坏父类已经声明的setter能力。
kotlin复制open class Parent {
open val value: Int = 10
}
class Child : Parent() {
override var value: Int = 20 // 可以
}
这个限制如果你不知道,编译期会报错,但新手经常不知道是为什么。理解"访问器兼容性"是关键:override val要求子类至少实现getter;override var要求子类实现getter和setter。所以父类是var,子类必须保留setter能力,不能用val缩小接口。
6.2 data class的继承陷阱
data class默认生成equals、hashCode、toString、copy、componentN,但如果你让data class继承一个父类,这些自动生成的方法就变得脆弱了。来看一个典型问题:
kotlin复制open class Base(val id: Int)
data class User(val name: String, val age: Int) : Base(0)
两个User对象即使name、age相同,如果Base的id不同,equals也会因为父类字段参与equals判断而返回false。data class生成equals时并不包含父类属性,但你调用父类equals时才可能产生不一致。
我的经验是:如果需要继承的类又需要数据语义,建议优先用组合而不是继承。data class里放入另一个data class作为字段,而不是让它继承一个基类。这能避免大量equals/hashCode的隐性问题。
6.3 Kotlin和Java互操作时的可见性补偿
internal属性在Kotlin编译成Java后变成public,并添加一个名字带修饰的奇怪前缀,这是JVM层面的兼容手段。如果你在Android项目里做Kotlin和Java混合开发,Java端可以直接访问internal成员,这实际上破坏了internal的封装语义。
要解决这个问题,可以从代码规范上约定:Java代码不要调用Kotlin的internal成员;如果要防止被Java访问,使用private而不是internal。另外,如果Kotlin代码用internal暴露给同模块的Java使用,要认识到这等同于public,需要额外谨慎。
6.4 重载与默认参数结合时的编译歧义
前面提到默认参数能减少重载,但有些情况会跟重载产生编译歧义。比如:
kotlin复制fun doSomething(x: Int, y: Int = 10) = x + y
fun doSomething(x: Int, y: Int) = x * y // 编译错误:与第一个方法的默认参数冲突
更隐蔽的是调用时的歧义:如果你有两个重载方法,一个参数是Int,另一个参数是Int?,传入null时能区分,但传入普通Int时两者都能匹配,编译器可能会报错。解决方法是避免在重载函数上混用默认参数,或者给方法起不同的名字,不要强行用重载来表达一切。
6.5 伴生对象不是静态成员,别指望它实现完全的Java静态语义
Kotlin的companion object看起来像Java的static,但它是真正意义上的对象,可以有父类、可以实现接口,也可以被赋值。很多人在类里写companion object来做静态工厂,但它依然属于类实例。如果想用静态方法,建议直接用顶层函数,这样更纯粹。
从封装的角度来看,伴生对象能访问类的私有构造函数,适合做工厂方法。但它不继承类的OOP语义,只有一层"和类绑定"的关系。要清晰区分"伴生对象里的函数"和"类的成员函数",否则在写多态时容易搞错所属上下文。
6.6 lateinit与委托混用时的常见误解
有人以为lateinit可以在Component的onCreate后用,实际上它只能修饰var,不能修饰val。如果你发现一个属性想用val却又是后期注入,可以考虑用by viewModels这类特性的委托或者手动设计一个懒加载机制。另外,lateinit不支持基本类型,比如Int、Double,只能用Integer这类包装类型或者直接初始化。
我在项目里观察到,很多人把lateinit用在Activity的某些属性上,但如果生命周期回调里还没有赋值就访问,就会崩溃。所以尽量在最短的时间窗口内完成赋值,并使用if (::property.isInitialized)做检查,这段逻辑能避免不少运行时崩溃。
6.7 嵌套类、内部类和继承之间的关系
Kotlin的嵌套类默认是静态的,它不持有外部类引用。内部类用inner修饰,持有外部类引用。如果内部类继承了一个泛型或抽象类,很容易因为构造函数链问题出现编译困难。
在继承体系里,内部类通常更适合做"默认适配器"或"回调持有者",而不是主体。当你发现一个类拥有大量内部子类并频繁继承外部类时,往往意味着外层类职责过重,应该考虑拆分。
写到最后的一点建议
DAY 3这篇内容量不小,但如果用一个词来概括Kotlin对OOP的处理,我觉得是"克制":默认final、显式override、受限的sealed class、委托语法,所有机制都在减少滥用继承和过度暴露状态的可能。
我个人的学习路径是:先把Java里熟悉的OOP概念用Kotlin写一遍,看哪里和Java不一样;然后刻意练习"先设计接口和封装边界,再写实现"的思维顺序;最后再回头看设计模式,很多模式在Kotlin里的表达比Java简洁好几倍,就是因为语言本身提供了更安全的抽象力量。
如果现在让我给初学者一个可复制的操作清单,我会总结成:
- 类默认尽量final,只有明确要扩展时才加open。
- 属性能用val就不用var,能少暴露就不暴露。
- 重写方法必须带override,不需要子类再重写时别加open。
- 一组固定子类型用sealed class表达,比多层继承清晰得多。
- 扩展函数用于给已有类追加能力,但不能用它们实现运行时多态。
- 类委托by可以帮助你组合多个接口实现,减少继承的使用。
- data class要不要继承,先想清楚equals的语义一致性再决定。
- 混合开发时,注意internal和private在Java眼里的真实样子。
把这三样东西吃透后,你再看Kotlin协程、Compose状态管理这类进阶话题,会发现底子非常扎实。封装解决"谁有权改"的问题,继承解决"要不要重新造轮子"的问题,多态解决"同一行为如何不同实现"的问题——这三件事想通,Kotlin才算是真正入门了。
