做Kotlin做了三四年,如果要我挑一个最容易让Java老手翻车的知识点,类和继承一定排得上号。表面上看,语法差异不算大——class还是class,extends变成了冒号,override依旧是override——但骨子里的设计理念已经完全不同了。前阵子帮一位朋友review他用Kotlin重写的后端服务,光是数据类和初始化顺序两个点,就暴露出一堆潜在问题。这篇是Kotlin专题的第七篇,专门聊类和继承,适合正在从Java迁到Kotlin的开发者,也适合已经写了段时间Kotlin、但对某些底层规则还停留在“背结论”阶段的同学。
我会从类的基本构造讲起,再拆继承、接口、数据类和密封类,最后把实际操作中踩过的坑整理成一份避坑清单。你跟着读完,至少能搞明白三件事:Kotlin为什么把类默认设计成final,初始化顺序到底坑在哪,以及数据类、密封类这些特殊类型在什么场景下才真正值得用。
1. 类的基本构造:主构造函数、init块与属性的正确打开方式
1.1 主构造函数的设计哲学
Kotlin类最常见的写法是这样:
class User(val id: Long, val name: String, var age: Int)就一行,类、构造参数、属性全搞定了。很多从Java转过来的人第一眼会愣住:构造函数在哪儿?属性定义在哪儿?其实都在类头里。主构造函数直接写在类名后面,括号里的参数如果加上了val或var,Kotlin会顺手把它变成这个类的属性,并自动生成对应的getter(val)和getter/setter(var)。
这个设计的本质是为了消灭模板代码。Java时代写一个POJO类,字段、构造器、getter、setter、toString、equals、hashCode,一套组合拳下来动辄上百行。Kotlin用一行类声明就能搞定其中一大半。我见过很多项目从Java迁到Kotlin后,实体类代码量直接减少60%以上,这个体验是实打实的。
主构造函数的参数如果不加val/var,它就只是一个构造参数,不会被保存为属性。这种参数适合在init块或属性初始化时用来做计算,用完即弃:
class Rectangle(width: Double, height: Double) { val area = width * height }这里的width和height只是传进来算面积的临时值,不需要在对象上长期保留,就不必做成属性。
1.2 init初始化块与次构造函数
有了主构造函数,init块就成了类初始化逻辑的核心位置。主构造函数本身不能直接写代码,所有需要执行的初始化逻辑都放在init块里:
class Person(val name: String, val age: Int) { init { require(age >= 0) { "年龄不能为负" } } }注意,init块可以写多个,按类体中的声明顺序从上往下执行。如果你既初始化属性又在init里做校验,要格外留意这个顺序问题,后面避坑部分我会展开讲。
次构造函数是另一回事。Kotlin要求次构造函数必须通过this(...)直接或间接委托给主构造函数:
class User(val id: Long, val name: String) { constructor(id: Long, name: String, email: String) : this(id, name) { // 这里才能写额外的初始化逻辑 } }实际开发里,次构造函数的使用频率远低于Java。我看到很多团队干脆约定“只用主构造函数,需要不同参数组合时用工厂函数或伴生对象的工厂方法替代”。这个约定我是支持的。Kotlin的函数支持默认参数,大多数场景下根本不需要多个构造函数。与其堆一堆重载,不如设计好默认值:
class User(val id: Long, val name: String, val email: String = "")调用时User(1L, "张三")和User(1L, "张三", "zhangsan@example.com")都支持,没必要再写次构造函数。
1.3 属性的真实身份:字段的替代者
有些Java开发者习惯把Kotlin的属性理解成“public字段”,这个认知会害死人。Kotlin的属性本质是“字段+getter/setter”的封装。你写val user = User(...),然后直接user.name,看似在访问字段,实际是调用生成好的getter。
你还可以给属性自定义getter或setter:
class Student(val firstName: String, val lastName: String) { val fullName: String get() = "$firstName $lastName" } class Order(val count: Int) { var quantity: Int = count set(value) { field = if (value > 0) value else 0 } }自定义setter里的field叫幕后字段(backing field),它只在属性的getter/setter里可访问,为的就是防止递归调用——如果你在setter里直接写quantity = value,会走setter再走setter,无限循环。Kotlin用field这种显式方式解决这个问题,比Java容易理解得多。
有个细节需要区分:fullName这种完全由其他属性计算出来的属性,不会生成幕后字段,每次访问都重新计算。所以它不能有setter,因为没有任何存储位置。这种设计是合理的,想要可变就直接把属性声明为var,想表达“只读派生值”就用计算属性,Kotlin把它们分得很清楚。
2. 继承机制:open、override与初始化顺序的底层逻辑
2.1 为什么Kotlin把类默认设为final
这是Kotlin继承机制里最反直觉、同时也是最有魄力的一个设计:类默认final,不允许被继承。想被继承,必须显式加上open:
open class Animal(val name: String) class Dog(name: String) : Animal(name)Java里,普通类默认所有子类都能继承(除非你写final)。这带来了什么问题?随便一个类被继承,父类换个方法签名、改个逻辑,可能就破坏了子类的行为。这个问题的根源就在于Java把“可继承”当成了默认项。
Kotlin参考了Effective Java里的那条著名建议——“设计继承,要么禁止继承”,把默认值改成不可继承。你每写一个open,都是经过思考的决定:这个类确实需要被扩展。这实际上是编码意图的表达,读代码的人看一眼就知道哪些类是扩展点。
我实际用下来,这种做法收益很大。项目里有明确设计意图的扩展点都用open标注,其余类全是final,回头重构时风险小很多,不必担心某个内部方法被子类悄悄override掉。
2.2 方法重写的规则与细节
Kotlin方法重写规则可以概括为三点:
- 父类的方法想要被重写,必须先标
open - 子类重写时必须显式写
override - 已经标记
override的方法,子类可以继续open(或者把open省掉让它变成final)
open class Animal(val name: String) { open fun sound(): String = "..." } class Dog(name: String) : Animal(name) { override fun sound(): String = "汪汪" }对比Java,Java的@Override只是个可选注解,你不写也能重写方法,万一参数列表打错还会变成重载,编译期根本发现不了。Kotlin把override变成强制关键字,没写就编译报错。这个强制性看着琐碎,实际能拦住很多低级错误。
有一个场景需要注意:如果父类和接口里出现了同名方法,实现类必须显式override,哪怕两个定义完全一样:
open class Base { open fun log() = println("Base") } interface Loggable { fun log() = println("Loggable") } class Child : Base(), Loggable { override fun log() { super<Base>.log() super<Loggable>.log() } }两个父级的同名方法,Kotlin不允许你默默“二选一”,逼着你写清楚调用谁。你用super<父类型>.方法()的语法就可以精确指定调用哪一个。这种显式性在Java的接口默认方法出现之后也非常重要,Java是规定“类的方法优先”,而Kotlin直接要求你明确表态,反而更好理解。
2.3 属性重写:从Java字段遮蔽到Kotlin的override
Java里有个很烦人的坑:子类可以声明一个和父类同名的字段,让人误以为它们是同一个字段,其实各是各的,存储空间都分开,这叫“字段遮蔽”。代码一复杂,很容易让人懵圈。
Kotlin干脆不支持字段遮蔽,属性要么是全新的,要么就必须override:
open class Shape { open val area: Double get() = 0.0 } class Circle(val radius: Double) : Shape() { override val area: Double get() = Math.PI * radius * radius }注意重写属性时有几条规则:
val属性可以被重写为var,反过来不行。因为val只保证getter,var表示有getter和setter,把只读的变成可变的是扩展,把可变的变成只读属于收缩能力,Kotlin并不允许- 重写属性时,类型必须兼容,比如父类
val x: Int,子类override var x: Int可以,而override val x: Double不行 - 重写依赖自定义getter的属性(没有幕后字段),新实现可以也只用getter逻辑,不需要关心底层字段
Java转Kotlin的开发者经常把字段遮蔽的习惯带过来,在子类里声明一个同名val,结果编译器直接报错:这个属性需要加override。这个报错是好事情,它强迫你重新审视自己的设计。
2.4 初始化顺序:一个让人防不胜防的陷阱
初始化顺序是Kotlin继承里最坑的一块,我几乎每次给团队讲类和继承都要重点强调。
先看规则:
- 父类的属性初始化和
init块先执行 - 之后是子类的属性初始化和
init块 - 构造函数的执行顺序是层层递推的:父类构造完成,才轮到子类
这本身不复杂,麻烦在于**“在父类构造阶段调用被子类override的方法”**。举个例子:
open class Base { open val value: Int = 10 val doubled: Int = value * 2 } class Derived : Base() { override val value: Int = 20 }如果你以为Derived().doubled是40,那就踩坑了。实际情况是:创建Derived对象时,先执行Base的构造,此时value这个被override的属性读到的结果是20——因为运行时多态已经生效,调用的是Derived的getter——但Derived自己的value字段还没初始化,所以这个getter在底层访问的字段是默认值0。也就是说,doubled可能是0 * 2 = 0,也可能更微妙。
这种问题在Java里同样存在,Effective Java专门有一条“禁止在构造器中调用可被重写的方法”。Kotlin虽然在设计上尽力压制这种写法,但因为open类必然要参与继承,这类风险无法物理屏蔽,只能靠开发者自觉。
我的建议是两条铁律:第一,构造函数和init块里不要调用任何open方法或open属性;第二,如果确实需要类似初始化钩子的功能,用明确的回调函数代替。
3. 抽象类与接口:Kotlin中多态的两种实现应该怎么选
3.1 抽象类:带上状态的抽象设计
抽象类在Kotlin里和Java差异不大,这类的主要意义是“定义一个不完整的模板,并允许子类补齐细节”。抽象类本身默认就是open的,抽象方法不需要也不能写open,子类必须实现这些抽象方法或用abstract override继续抽象:
abstract class Repository { abstract fun load(): String fun log() = println("loading...") } class UserRepository : Repository() { override fun load(): String = "user data" }抽象类和接口最大的区别在于:抽象类可以有构造函数、可以有实际的属性字段、可以持有状态。比如一个缓存基类,可以用抽象类定义好缓存容器,子类只需要实现数据加载逻辑:
abstract class CacheRepository { private val cache = mutableMapOf<String, String>() abstract fun fetch(key: String): String fun get(key: String): String { return cache.getOrPut(key) { fetch(key) } } }这里cache字段属于抽象类自己,接口做不到这一点。接口不能持有状态,不能有幕后字段,顶多定义只读属性然后提供getter实现。
3.2 接口的默认方法:比Java 8更彻底的默认实现
Kotlin接口允许方法带默认实现,这点从语法层面和Java 8很像,但有个重要区别:Java 8接口里接口只能有常量,而Kotlin接口还可以定义属性(尽管没有幕后字段):
interface Logger { val tag: String get() = "App" fun log(message: String) fun logError(message: String) = log("ERROR: $message") }tag这个接口属性没有幕后字段,每次访问都会执行getter。你当然可以在这里写死一个默认值,但只要接口被实现,实现类就可以override它。
接口默认方法最大的价值,在于解决“类职责扩展”的问题。Java老项目里经常看到为了复用代码,强行把不相关的类拉进同一个继承体系。Kotlin里你可以在接口里提供默认实现,再让不相关的类各自实现这个接口,代码复用和类型解耦同时拿到。
还有一点,Kotlin接口可以通过interface A : B来继承接口,多个接口之间可以组合。这比Java只能继承单一接口灵活得多。我经常用接口组合设计“能力模型”,比如一个类既需要Logger能力又需要Serializer能力,直接class Service : Logger, Serializer即可。
3.3 接口与抽象类的选择判断
很多人纠结到底用接口还是抽象类,我给一个简单实用的判断标准:
- 需要共享状态、公用字段或构造函数逻辑,选抽象类
- 只需要定义行为契约、提供默认方法,不需要状态,选接口
- 一个类型需要被多个不相关类型实现,选接口
- 想要在继承基础上扩展行为,优先考虑接口+组合
在Kotlin里,接口默认方法已经取代了相当一部分抽象类的历史功能。如果你发现自己写的抽象类只有一个抽象方法,没有字段没有状态,那不如改成接口。有一个例外场景:你需要给使用者提供“可选实现的钩子”,这种通常用抽象类,因为你可以写一个空实现的钩子方法,子类爱重写就重写。
另外,Kotlin还有类委托这个神奇的功能。by关键字可以让你直接把接口的实现委托给另一个对象:
interface Greeter { fun greet(): String } class EnglishGreeter : Greeter { override fun greet(): String = "Hello" } class PoliteGreeter(wrapped: Greeter) : Greeter by wrappedPoliteGreeter直接把Greeter的所有实现委托给wrapped对象,不需要手写一行转发代码。需要微调时,再override需要改的方法即可。这是“组合优于继承”的优雅解法,我在实际项目中用它替代了一大批XxxBaseAdapter、XxxBaseManager这种抽象基类。
4. 特殊类型全家桶:数据类、密封类与伴生对象的正确使用姿势
4.1 数据类:告别手写equals和hashCode
Kotlin的data class是写业务代码的一大福音,尤其适合当DTO(数据传输对象)或领域模型:
data class User(val id: Long, val name: String, val email: String)声明为data class后,编译器自动生成:
equals()/hashCode():基于所有主构造函数属性toString():格式清晰,方便日志输出copy():做浅拷贝componentN():支持解构声明
比如复制一个用户只改邮箱:
val old = User(1L, "张三", "old@example.com") val updated = old.copy(email = "new@example.com")再比如解构:
val (id, name) = updatedJava里这些方法全得手写,IDE生成的代码又臭又长,两个实体类一比较,差别立刻出来。
关于数据类的一些细节:
- 数据类默认是final的,不能继承,也不能被继承
- 属性类型最好都用
val,保持不可变性 - 如果你定义了
componentN、copy、copy同名方法,编译器会直接报错或覆盖自己的生成逻辑 equals和hashCode只考虑构造函数里的属性,类体内的属性不参与
一个我经常提的建议:领域模型里的数据类尽量设计成不可变的,用val声明全部属性。需要变更时通过copy()创建新实例。这在并发场景下能省掉很多烦恼,配合不可变集合食用效果更佳。
4.2 密封类:受限继承,让when穷举更安全
密封类是我个人非常喜欢的一个Kotlin特性,它在定义受限的继承层级时极其有用:
sealed class Result { data class Success(val data: String) : Result() data class Error(val code: Int, val message: String) : Result() object Loading : Result() }密封类的关键在于“限制”:所有直接子类必须和密封类在同一个包和模块内定义。也就是说,使用方除了自己写死的那几个子类,不可能再冒出新类型。这个特性配合when表达式,编译器能穷举所有可能分支,不再需要else兜底:
fun handle(result: Result) = when (result) { is Result.Success -> println("成功: ${result.data}") is Result.Error -> println("失败: ${result.code}") Result.Loading -> println("加载中") }如果你增加了一个Result的子类,编译器会立刻在when表达式处报错,提醒你漏了分支。这在Java里是完全做不到的,Java的枚举虽然也能做到类似的穷举,但枚举承载能力有限,没法携带复杂状态,密封类则能。
实际开发中,密封类的常见场景有:
- 网络请求的状态:
Loading、Success、Error - UI状态管理:比如列表页的
Loading、Content、Empty、Error - 事件驱动:聊天消息的类型、用户操作的类型
我个人的经验是,凡是可能用一堆布尔变量去表达“当前处于什么状态”的地方,都值得停下来想想能不能用密封类来重构。状态一多,布尔组合就失控了,密封类把状态显式化、穷举化,代码可读性和安全性会明显提升。
4.3 伴生对象:Kotlin的“静态成员”
Kotlin没有Java那种static关键字,静态成员的需求交给companion object来实现:
class Config { companion object { const val VERSION = "1.0.0" fun createDefault(): Config = Config() } } Config.VERSION Config.createDefault()companion object本质上是这个类的一个单例对象,你可以给它命名(不命名默认叫Companion),也可以让它实现接口,甚至可以继承别的类:
interface Factory<T> { fun create(): T } class User private constructor(val name: String) { companion object : Factory<User> { override fun create(): User = User("默认用户") } }这种设计比Java的静态工厂更自然,因为它让“创建逻辑”本身也具有类型和可扩展性。
有几个常用的细节提醒一下:
const val只能用于基本类型和String,编译期常量,可以直接内联进字节码- 如果要在Java代码里以
Config.Companion.x()的方式调用,可以在方法上加@JvmStatic注解 - 伴生对象不是类的一部分,从反射角度看它是独立的类
5. 避坑实战:从真实现场总结的5条经验
5.1 继承链过深,重构成本高到你怀疑人生
碰到过一个项目,一个BaseEntity到OrderEntity,中间隔了四层继承,每层加了不同的字段。出问题的时候,改任何一个父类构造函数,下面所有子类的构造全部连锁反应。排查问题的难度呈指数级上升。
这事的教训是:Kotlin把final作为默认值,其实已经是语言层面在劝你少用继承。当你想让类B继承类A的时候,先问自己三个问题:B和A真的有“is-a”关系吗?A的改动B能完全兼容吗?有没有组合可能更合适?
扩展函数和类委托是Kotlin提供的两个替代方案,能用就用。我个人现在写新代码时,深度超过两层的继承基本不会出现。
5.2 数据类不能继承,但可以用组合和接口
很多人写代码时会遇到这样的情况:两个数据类有很多公共字段,于是想做一个BaseUserData,然后让两个数据类去继承它。结果编译器告诉你“data class继承是不允许的”,因为数据类默认final。
正确的做法通常是两个选择:
- 公共字段抽成一个普通类,数据类持有它(组合)
- 公共行为抽成接口,数据类实现它
interface Named { val name: String } data class User(val id: Long, override val name: String) : Named data class Group(val id: Long, override val name: String) : Named想要公共逻辑时,写扩展函数或接口默认方法都行。这样既保留了数据类的生成能力,又不至于侵入继承体系。
5.3 初始化顺序:千万别在构造阶段碰多态
前面已经详细讲过这个坑,这里再补充一个现场案例。有一个BaseHandler,构造时注册了自己,HandlerRegistry保存所有实例。子类UserHandler继承后,override了一个handlerName()方法。结果注册时调用handlerName(),读到了null,整个事件分发直接报销。
诊断这个问题的过程让我印象深刻:Bug的根因不是注册逻辑,而是“父类构造期间调用了可重写的属性”。修复方法很简单——把handlerName()改成抽象属性,让子类通过构造函数传参注入,不通过重写获取。
这类问题在设计阶段就要预防。如果你看到一个类有open fun或open val,同时又调用了它,先停下来想一想调用时机。
5.4 属性重写时注意可见性和类型
Kotlin允许把父类的protected属性重写为public(扩大可见性),但不允许反过来缩小可见性。这实际上和一些语言的规则一致。重写时如果想把父类属性私有化或降低暴露,Kotlin会直接拒绝。
类型方面也有陷阱。比如父类属性类型是MutableList<String>,子类重写成List<String>,编译会失败。哪怕你觉得“只读视图更安全”,编译器也不允许你变窄属性声明。这种时候只能重新设计接口或使用out协变泛型。
5.5 接口冲突:多继承的来源不止一个
一个类同时实现两个接口,正好两个接口都有同名方法或属性,这是Kotlin开发中不算罕见的编译错误。处理方案很简单:在类里显式override,然后用super<A>和super<B>分别调用。
有一个特别容易忽略的变体:两个接口里定义了同名属性,一个要求val name: String,另一个要求var name: String,这个类里必须提供一个可变的var属性和对应的setter实现,否则编译器直接报错。这说明接口的属性设计也带有行为契约,不只是“有什么字段”。
| 问题类型 | 典型现象 | 排查方向 |
|---|---|---|
| 初始化顺序异常 | 子类属性为null/默认值 | 父类构造或init是否调用了open成员 |
| 数据类无法继承 | 编译报错,提示final | 改用组合或接口 |
| 接口同名方法冲突 | 编译报错,要求override | 显式override,用super<X>调用 |
| 属性类型不兼容 | 重写时类型或可见性报错 | 保持或扩大可见性,类型保持一致或协变 |
| 数据类参与继承 | 无法定义子类 | 考虑普通类+手动equals或接口方案 |
最后聊一点个人体会
和Java打了这么多年交道,又用了几年Kotlin,我最大的感受是:Kotlin的类和继承设计并不是为了炫技,而是在把面向对象里那些经典的坑尽量堵上。final默认值、强制override、数据类的copy、密封类的穷举式when,每一个特性都在帮你写出更安全、更可维护的代码。
实战中,我养成的习惯是:先尝试用接口和组合解决问题,继承是最后的选项;数据类一律用val声明;构造函数和init块绝不碰open成员;项目里所有表示状态的类型优先考虑密封类。这套规则也许不能让代码马上变得完美,但至少能避开我在前些年踩掉的那些雷。
如果你正在学Kotlin或者正从Java转型,不必急着背语法规则,先把今天说的几个“为什么”想明白,“怎么写”自然就记住了。下一次遇到有人问“Kotlin为什么不让类随便继承”,你可以直接告诉他:不是Kotlin不让你继承,是它想拦住你,不让你在没想清楚的时候继承。