资讯动态

Kotlin类与继承全解析:从主构造函数到多态陷阱

发布时间:2026/10/15 1:40:08 来源:尧图企业网站定制
开门见山地说写到Kotlin专题第七篇我终于可以把类和继承单独拎出来聊透了。很多从Java转过来的开发者第一次看到Kotlin的类声明都会有点发愣构造方法跑到类头去了属性还可以直接写在参数列表里继承一个类为什么非要写openoverride为什么是必须的这些语法差别背后其实是一整套和Java大不相同的设计取舍。这篇专题不打算做语法手册式的罗列我尽量从“为什么这么设计”和“实际写代码时会在哪里翻车”两个角度来讲。适用人群是正在学Kotlin、准备用Kotlin写服务端或客户端模块、或者已经写了几个月Kotlin但总觉得类和继承的坑踩得莫名其妙的开发者。看完之后你会更理解类声明的执行顺序、继承的开放策略、抽象类/接口/密封类的选择以及几个非常隐蔽的多态细节。1. 类声明与对象初始化主构造函数不是你想的那个“构造方法”1.1 类头参数被拆成了两种角色构造入参与属性声明先看一段最普通的类声明class Person( val name: String, val birthYear: Int ) { val age: Int get() 2025 - birthYear fun greeting() 你好我是$name }这里真正让Java开发者不适应的是Person后面的括号。括号里的参数就是主构造函数参数但一旦加上val或var它们就不只是构造入参了而是会同时被声明成类的属性并且自动生成对应的gettervar还会额外生成setter。我把这两者拆开对比一下class Student(name: String, val id: String)这个类里id被保存为属性外部可以通过student.id访问而name纯粹只是构造入参类体里没有声明对应的属性。如果构造函数后面没有类体Kotlin甚至可以把花括号都省略掉。如果类体里还想用name就只能把它赋值给某个自定义属性class Student(name: String, val id: String) { val displayName name }那么问题来了为什么Kotlin要把主构造函数放到类头而不是像Java那样在类体里写一个同名方法我的理解是Kotlin想用最少的样板代码表达“创建一个有若干不可变属性的对象”这个最常见需求。过去在Java里要写字段声明、构造方法、getter三套东西现在类头一行搞定。对于依赖注入、DTO、配置对象这类场景这种写法几乎就是为它们量身定做的。1.2 init块和属性初始化顺序一个容易被忽略的执行细节主构造函数不能直接写逻辑所以需要初始化逻辑时要用init块。但是很多人第一次写时会以为init块和属性初始化是独立的其实它们的执行顺序明确得吓人主构造函数参数形成之后类体中的属性初始化器和init块会按照它们在类体中书写的顺序从上到下依次执行。这句话听起来简单实际写错的人不少。我之前调试过一个连接初始化问题简化之后长这样class ApiClient(val endpoint: String) { init { http!!.connect() // 这里会抛空指针因为 http 还没被赋值 } var http: HttpClient? null }这段代码的问题在于http的声明写在init块的下面按顺序执行时init先跑http此时还是默认值null。如果你用!!强转直接空指针如果你以为它会“自动往后找”那就彻底搞反了。这一点在继承场景里还会被放大。父类的属性初始化器和init块一定先于子类执行也就是说在父类构造函数阶段子类的所有属性都还没有初始化。这个规则我会在后面的实战翻车现场专门展开。执行阶段说明1. 主构造函数参数求值只负责把入参准备好此时类体尚未初始化2. 父类构造函数如果存在父类父类的属性初始化和init块全部先执行3. 类体属性初始化器和init块按书写顺序从上到下执行4. 次构造函数体主构造函数流程走完后才进入次构造函数体2. 继承的设计底层逻辑open、override 与默认 final2.1 默认final的哲学让继承成为一个需要斟酌的决定Java里类和方法默认都是可以被继承、被重写的只有加了final才禁止。Kotlin把这件事反了过来默认就是final想被继承要显式写open。open class Animal { open fun sound() ... } class Dog : Animal() { override fun sound() 汪汪 }写惯了Java的人第一反应是“麻烦”其实这个设计反而是在保护你。Java里任何非final方法都是潜在的多态分派点框架和库很难判断哪些方法是设计给子类重写的哪些只是内部实现细节。Kotlin用默认final逼着你显式表达意图这个类就是当基类的这个方法就是允许子类替换的。在实际项目里我见过很多Java基类写着写着就膨胀成“上帝类”一个重要原因就是所有方法默认开放后人随手继承、随手重写最后行为完全不可控。Kotlin的open相当于一道闸门你每次敲下open都会先想一想我真的允许别人改这个方法吗这个思考成本很低但能挡住很多烂设计。2.2 方法重写与属性重写的边界override关键字背后的纪律性Kotlin规定子类重写父类方法必须显式写override。这不仅仅是语法要求它让“重写关系”变成可搜索、可检查的显式信息。如果你只写了override但父类对应方法并没有open编译直接报错而不是像Java那样给你一个Override可写可不写的宽松环境。属性也可以重写。父类声明open val后子类可以override val也可以用主构造函数参数直接覆盖open class Base { open val version: Int 1 } class Derived(override val version: Int) : Base()这里有个很容易忽略的规则var可以覆盖val反过来不行。原因是父类的val只保证只读子类把它放开成var属于扩展能力没问题但父类如果是var子类想缩成val等于把已经开放的setter藏起来会破坏通过父类引用修改属性的合法路径所以编译器不允许。这背后其实是“可见性只能扩大不能缩小”的一致性思想。方法重写也有类似的可见性约束父类是public的open方法子类不能把它重写成protected或private反过来父类如果protected子类可以提升到public。原因一样父类引用必须能继续安全地调用这个方法。2.3 子类构造与父类构造的协作主构造函数如何向上委托继承语法里的: Animal()不是装饰它表示子类主构造函数必须调用父类的某个构造函数。open class Animal(val name: String) class Dog(name: String, val breed: String) : Animal(name)这里Dog的name主构造参数没有加val因为它不是子类自己的属性只是用来转发给父类构造函数的。一个常见错误是子类想额外做点处理比如拼接字符串再传给父类却忘了可以在参数进入父类之前处理class Dog(name: String, val breed: String) : Animal(宠物狗-$name)如果父类没有主构造函数但有多个次构造函数子类就要显式选择其中一个。还有一种情况是父类的主构造函数所有参数都有默认值这时候编译器会允许子类直接写: Base()不传参数也能过。这个特性有时候会掩盖设计问题父类参数一旦全有默认值子类调用关系会变得很隐晦排查构造链时要比平时多留个心眼。3. 抽象类、接口和密封类三种继承蓝图的取舍3.1 抽象类半成品蓝图把公共实现沉淀在基类抽象类在Kotlin里和Java类似但它天生就是open的抽象方法也不需要再写open子类直接override就行。abstract class Shape { abstract fun area(): Double fun describe() 这是一块面积为${area()}的图形 } class Circle(val radius: Double) : Shape() { override fun area(): Double Math.PI * radius * radius }abstract class适合什么场景我个人的判断标准是这个继承层级里有没有“公共状态”或“公共实现”需要下沉。比如多个子类共享同一个数据源、同一个缓存策略、同一套校验逻辑这些放在抽象类的普通方法或属性里比放在接口的默认方法里更自然因为抽象类可以有构造器可以为属性做真实的初始化。抽象类也允许定义抽象属性子类必须实现abstract class Report { abstract val title: String abstract val content: String fun print() { println(title) println(content) } } class DailyReport : Report() { override val title 日报 override val content 今天写了很多代码 }这种写法的好处是公共逻辑print()被固定住而差异点title和content交给子类去填。继承在这里扮演的是“模板方法”的角色而不是单纯的类型归类。3.2 接口默认方法、属性声明以及多接口冲突的仲裁方式Kotlin接口比Java 8的接口更强的地方在于它不但可以有默认方法还可以声明属性只是属性不能直接初始化存储字段但可以提供自定义getter。interface Named { val name: String fun displayName(): String 名字$name } class User(override val name: String) : Named这里User只用主构造函数参数就实现了接口要求非常轻量。多接口方法冲突也是必踩的点。假设两个接口都有同名默认方法interface A { fun log(msg: String) println(A.log: $msg) } interface B { fun log(msg: String) println(B.log: $msg) } class C : A, B { override fun log(msg: String) { superA.log(msg) superB.log(msg) println(C.log: $msg) } }Kotlin要求你明确仲裁不能装看不见。superA.log(msg)这个尖括号语法就是指定调用哪条继承路径上的默认实现。实际编码里如果你发现自己的类需要动不动就仲裁多个接口方法大概率是接口职责划分出了问题建议把接口拆细而不是每次上线都打补丁。对比维度抽象类接口构造函数可以有不能有属性存储可以声明并初始化字段只能声明属性或提供getter不能直接存字段多继承单继承可实现多个默认实现普通方法即可默认方法典型用途公共状态/模板方法下沉契约、能力描述、多角色组合3.3 密封类把继承范围装进“管子里”再谈穷尽sealed class是Kotlin对继承体系纪律性最强的一种约束。它允许子类继承但子类的范围被限制在同一个模块和同一个包旧版本要求同包Kotlin 1.5以后放宽为同模块这样一来当你用when表达式处理所有子类时编译器可以判断分支是否穷尽。sealed class Result { data class Success(val data: String) : Result() data class Error(val code: Int, val message: String) : Result() object Loading : Result() } fun handle(r: Result) when (r) { is Result.Success - 成功${r.data} is Result.Error - 失败${r.code} Result.Loading - 加载中 }如果你新增了一个Result的子类编译器会强制提醒你去补对应的when分支这让状态机类代码的维护压力小很多。我用下来的体会是凡是“整个状态空间封闭可控”的场景比如网络请求结果、用户操作意图、表单校验状态都优先考虑sealed class而不是到处散落接口实现。也要注意sealed class和枚举不是一回事。枚举是同一个类型的固定实例集合而密封类的每个子类可以携带完全不同的属性结构表达能力完全不同。4. data class一行声明背后的样板代码与继承边界4.1 equals、hashCode、toString、copy与解构编译器替我们写了什么data class大概是Kotlin里性价比最高的类形态了。data class User( val name: String, val age: Int )编译器会自动生成equals、hashCode、toString、copy以及component1()到componentN()。最后这项能力直接撑起了解构声明val user User(张三, 28) val (name, age) user println($name 今年 $age 岁)copy也不是单纯复制它支持指定某些属性生成新对象val younger user.copy(age 18)这是写不可变数据模型时的神器。但data class有一堆硬性约束比如主构造函数必须至少有一个参数参数必须标记为val或var而且类本身不能是open、abstract、sealed或inner。4.2 data class与继承能继承别人却别指望被别人继承很多人以为data class既然这么方便就让它到处当基类这其实踩了一个坑data class默认是final的它不能当父类。它可以做子类去继承一个open父类但这种继承会让编译器自动生成的那些方法变得非常微妙。看这个例子open class BaseEntity { var id: Long 0 } data class User( val name: String ) : BaseEntity() { // 主构造参数是 name父类的 id 被忽略了 } fun main() { val a User(张三).apply { id 1 } val b User(张三).apply { id 2 } println(a b) // true }id明明不同但结果相等。因为data class自动生成的equals主要基于主构造函数里的属性父类里单独定义的字段默认不在比较范围内。如果你在业务里依赖id区分对象这个结果会直接把缓存、去重、日志全带偏。4.3 浅拷贝与集合属性data class最容易误导的两个细节copy是浅拷贝只复制引用不深挖对象内部。如果属性里挂着可变集合或自定义对象拷贝出来的新对象会共享同一份引用data class Address(val city: String) data class Employee( val name: String, val address: Address ) val e1 Employee(张三, Address(北京)) val e2 e1.copy(name 李四) e2.address.city 上海 println(e1.address.city) // 上海另一个更隐蔽的坑是copy不处理父类状态。结合上面的BaseEntity例子val old User(张三).apply { id 100 } val copied old.copy(name 李四) println(copied.id) // 0父类字段没有被复制如果你在用data class建模最好遵守一条朴素原则所有需要参与相等性比较和副本复制的状态都放进主构造参数里。父类带着一套状态data class自己又是一套状态两边各管一半迟早要出事。5. 多态细节里的“隐形陷阱”智能转换与扩展函数分派5.1 智能转换的生效条件以及var属性为什么会被“卡住”Kotlin的智能转换用过都说爽但它不是万能的。最经典的场景是is检查之后直接当成目标类型用fun describe(x: Any) { if (x is String) { println(x.length) // 编译器已经知道 x 是 String } }这很正常。但如果你把x换成一个可变的类属性事情就不一样了class Box(var content: Any) { fun wrongPrint() { if (content is String) { println(content.length) // 编译报错智能转换不可能因为 content 是可变属性 } } }原因是编译器无法保证在is检查和length访问之间没有其他线程或代码把content改掉。既然var本身开放了修改能力智能转换就自动失效。解决方式很朴素先取出局部变量再做判断。fun rightPrint() { val current content as? String ?: return println(current.length) }这个坑在Java转Kotlin的团队里出现频率非常高因为Java没有“智能转换”这个概念大家不会意识到“编译器已经自动帮你转型”了也就更不会意识到失效的条件。5.2 扩展函数不参与多态一个经典的“看错结果”问题扩展函数写起来很像成员方法但它本质上是一个静态工具函数分派依据是变量的声明类型而不是运行时的实际类型。open class Animal class Dog : Animal() fun Animal.speak() 动物在说话 fun Dog.speak() 狗在汪汪 fun main() { val animal: Animal Dog() println(animal.speak()) // 动物在说话 }这里animal的实际类型是Dog但调用扩展函数speak()时编译器看的只是它被声明为Animal所以走的是Animal.speak()。很多人第一次看到输出结果会以为扩展函数也支持动态多态其实完全相反。我把这条单独拿出来讲是因为很多继承体系里会同时存在成员方法和扩展函数名字还经常撞车。当这两者同时出现时成员方法永远优先不会再分派给扩展函数。如果你发现某个方法的调用结果和你预想的不一致先看看它到底是普通成员函数还是扩展函数思维切换一下问题基本就定位了。5.3 可见性修饰符在继承树内的微妙边界Kotlin有四种可见性private、protected、internal、public。其中和继承关系最紧密的是protected和internal它们在Kotlin里的语义和Java并不完全一样。Java的protected包含“同包可见”Kotlin把它去掉了只保留“当前类及其子类可见”。也就是说和你在同一个包但没有任何继承关系的类是访问不到那个protected成员的。这个变化经常被Java老手忽略。我常用的判断表是这样修饰符当前类内子类内同模块其他文件其他模块private可见不可见不可见不可见protected可见可见不可见仅在子类内可见internal可见可见可见不可见public可见可见可见可见另外重写方法时不能降低可见性父类public的open方法不能被子类重写成protected或private。如果你想通过降低可见性来“藏”掉父类的public方法这条路在Kotlin是堵死的正确的做法是重新审视继承设计而不是跟编译器较劲。6. 继承实战翻车现场三个高频问题与完整排查链路6.1 场景一基类构造期间调用open方法值还没初始化完就先被用掉了这是我见过最经典的Kotlin继承错误没有之一。open class Base { init { configure() } open fun configure() println(Base.configure) } class Derived : Base() { var num: Int 42 override fun configure() { println(Derived.configure, num$num) } } fun main() { Derived() }你可能会以为输出Derived.configure, num42实际输出是Derived.configure, num0原因链条是构造Derived必须先构造BaseBase的init块执行时会动态分派到Derived.configure()但此刻Derived自己的属性初始化器还没运行num还停留在JVM整型属性的默认值0。排查这个问题我有一套固定套路。先看构造链里有没有open方法调用有就直接锁死嫌疑再看被调用的open方法会不会访问子类专属属性会就基本确认最后用日志把初始化顺序打出来等看到num0的那一行整个链路就清楚了。解决方式也很直接不要在构造函数或init块里调用可重写方法。如果确实需要子类参与初始化建议用抽象方法配合模板方法模式但一定要保证父类构造函数阶段不会去读取子类尚未初始化的状态或者把初始化动作放到子类属性就绪之后再显式触发。6.2 场景二data class的equals没比较父类状态排查了半天才发现还是那个BaseEntity的问题比较常见的是它在缓存里的表现。我当时在一个服务里排查一个“对象明明不同却始终被判定为相同”的问题。接口返回的数据变成User对象后业务方用contains去重结果怎么都去不干净。加日志发现equals返回了true但两个对象的id字段明明一个1一个2。顺着equals一层层看发现User是data class主构造参数只有nameid来自BaseEntity父类。data class生成的equals完全不看父类的id所以在比较层面它们就是“相等”的。这种问题最怕乱猜。稳定复现之后我的排查顺序是全球眼检查类定义看看继承关系确认属性分布再点进自动生成的equals实现看比较维度到底包含哪些字段最后确认是不是用了copy导致父类状态丢失。最终修复不是去改data class而是调整设计一个需要参与比较和复制的对象要么完整使用data class做主构造函数属性要么就不让它继承带有额外状态的父类。如果你发现自己在写“data class 继承 open 父类”请先停一下问自己父类里的那些字段是否也应该参与equals、hashCode、toString和copy如果不参与它们存在的意义是什么如果参与那么data class的自动生成能力根本覆盖不了你只能手写一大堆覆盖方法那还不如不继承。6.3 场景三类和接口都有同名方法时谁覆盖了谁Kotlin接接口默认方法有一套“类优先”规则。当基类和接口有同名的普通成员方法时基类的具体方法会直接覆盖接口默认实现不需要子类做额外选择。interface CanLog { fun log(msg: String) println(接口日志$msg) } open class BaseLog { fun log(msg: String) println(基类日志$msg) } class AppLog : BaseLog(), CanLog fun main() { AppLog().log(hello) // 输出基类日志hello }这个输出意味着如果你以为子类同时从类和接口继承同名方法会报冲突那就错了。类是“元老”接口是“后来者”同名情况下类的方法优先。反过来如果基类没有定义这个方法只是实现了接口而子类又没重写那走的就是接口默认实现。这个场景的排查难点在于它不报错所以很多人根本注意不到。我的建议是写代码时明确一点子类只要不显式override有多少条隐式路径就是多少条行为来源。真需要控制行为就直接在子类里写override fun log把分叉收敛到一个实现别让调用方去猜到底走的是接口的还是基类的。再说一个排查习惯遇到继承相关的诡异输出永远先画一遍构造链和分派链。构造链看“谁先初始化”分派链看“override到底覆盖到哪一层”。Kotlin的编译期检查已经帮你挡住了大部分低级错误剩下的运行期坑基本都藏在这两条链的交汇处。把这套思路练熟了类和继承这块至少能把排查时间砍掉一半。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑