1. Kotlin 面向对象的核心设计思路1.1 为什么 Kotlin 的继承体系值得单独拿出来讲很多人从 Java 转到 Kotlin第一反应是“语法糖多了点”但真正写上一两个月之后会发现Kotlin 在面向对象这块的设计思路跟 Java 有本质区别。Java 的继承是“默认开放”你不加final就能被继承Kotlin 反过来类和方法默认都是final的想被继承必须显式加open。这个改动看起来只是加个关键字实际上它改变了整个代码库的演化方式。我刚开始写 Kotlin 的时候特别不习惯觉得每次都要写open很啰嗦。但踩过几次坑之后才明白这个设计是在逼你想清楚这个类到底是不是为继承而设计的如果一个类没打算被继承结果被别人继承了还覆写了方法后面你想改父类的实现就会投鼠忌器。Kotlin 用open把这个决策显式化本质上是在保护你的代码不被意外扩展。标题里提到的“继承、接口代理、by 与冲突解决、super”这几个点其实是一条完整的线索类怎么扩展、行为怎么复用、多个来源冲突时怎么消歧。这条线索贯穿了 Kotlin 面向对象编程的日常也是面试和实际项目里最容易出问题的地方。下面我按自己的理解把这条线拆开讲透。1.2 本篇适合谁看能解决什么问题如果你已经会写 Java 或者写过一点 Kotlin但对下面这些问题还有疑惑那这篇内容就是给你准备的为什么我的 Kotlin 类继承不了编译器一直报错接口里的默认实现和父类的方法重名了到底调哪个by这个关键字除了委托属性还能干嘛接口代理是怎么回事多个接口有同名默认方法子类必须覆写吗怎么调指定那个super在 Kotlin 里为什么有时候要写成super接口名这些问题单独看都不难但凑在一起就容易乱。我会从设计动机讲到代码实操再把我自己踩过的坑整理成排查表。你跟着敲一遍基本就能把 Kotlin 的继承和委托体系理顺。2. 继承体系的关键细节与实操要点2.1 open、final、abstract 三者的关系与选择Kotlin 里控制继承的关键字有三个open、final、abstract。它们的关系可以用一句话概括默认是 final想开放用 open想强制子类实现用 abstract。先看一段最基础的代码open class Animal(val name: String) { open fun sound() { println($name 发出声音) } fun eat() { println($name 在吃东西) } } class Dog(name: String) : Animal(name) { override fun sound() { println($name 汪汪叫) } }这里Animal加了open才能被Dog继承sound()加了open才能被覆写而eat()没加open子类就不能覆写它。这个规则很清晰但实际项目里有个容易忽略的点override的方法默认还是 open 的。也就是说Dog覆写了sound()如果Dog自己没加final那Dog的子类还能继续覆写sound()。class Puppy(name: String) : Dog(name) { override fun sound() { println($name 小声叫) } }如果你不希望Dog的sound()再被覆写就得写成final override fun sound()。这个细节很多人不知道结果继承链一长行为就被改得面目全非。abstract则是另一回事。抽象类本身默认就是 open 的因为它必须被继承才有意义抽象方法没有方法体子类必须实现。我一般这样用抽象类定义“是什么”接口定义“能做什么”。比如abstract class Shape表示形状interface Drawable表示可绘制。一个形状可以是可绘制的也可以不是这样组合更灵活。注意抽象类可以有构造参数接口不能有。所以如果你需要传递状态给父类抽象类是唯一选择。2.2 接口默认实现与“菱形继承”问题的处理Kotlin 的接口可以有方法体这是它比 Java 8 之前强的地方。但一旦多个接口有同名方法问题就来了interface A { fun greet() { println(来自 A 的问候) } } interface B { fun greet() { println(来自 B 的问候) } } class C : A, B { // 这里必须覆写 greet否则编译不过 override fun greet() { superA.greet() superB.greet() } }这就是标题里说的“冲突解决”。当C同时实现A和B两个接口都有greet()的默认实现编译器不知道你要用哪个所以强制你覆写。覆写的时候用superA.greet()和superB.greet()分别调用这个语法叫限定 super。我实际项目里遇到过一个更隐蔽的情况接口继承链上的冲突。比如interface D : AD没有覆写greet()那D就继承了A的实现。这时候一个类同时实现D和B冲突依然存在因为D和B都有greet()的实现。解决方式一样用限定 super 指定。这里有个经验如果两个接口的默认实现语义上不兼容不要强行在子类里都调一遍。比如一个接口的close()是关闭文件另一个的close()是关闭网络连接你两个都调可能出问题。这时候更好的做法是重新设计接口或者让子类只实现其中一个另一个用委托。2.3 接口代理 by 的语法与适用场景by在 Kotlin 里有两个用途属性委托和接口委托。标题里的“接口代理”指的是后者。语法很简单interface Printer { fun print(message: String) } class ConsolePrinter : Printer { override fun print(message: String) { println(message) } } class Logger(printer: Printer) : Printer by printerLogger自己没有实现print而是把Printer接口的实现委托给了构造参数printer。编译之后Logger会自动生成print方法内部调用printer.print(message)。这个机制叫委托Delegation是替代继承的常用手段。为什么用委托而不是继承因为继承是“is-a”关系委托是“has-a”关系。Logger不是一个Printer它只是用了一个Printer。用继承的话Logger会继承Printer的所有方法包括未来新增的这可能导致不必要的方法暴露。委托只暴露你声明的接口更可控。我见过一个典型场景一个类需要实现五六个接口但大部分方法都是转发给内部对象。如果一个个手写转发方法代码又长又容易漏。用by一行搞定class ServiceImpl( private val repo: Repository, private val cache: Cache, private val logger: Logger ) : Repository by repo, Cache by cache, Logger by logger这样ServiceImpl自动获得了三个接口的所有方法而且如果以后接口新增方法ServiceImpl不用改代码就能自动转发。这个特性在写适配器、装饰器的时候特别好用。注意by委托的方法如果和类自己定义的方法重名类自己的方法优先。也就是说你可以覆写委托的一部分方法剩下的继续委托。3. 实操过程与核心环节实现3.1 从零搭建一个继承与委托并存的示例光看语法容易忘我带你写一个完整的例子把继承、接口、委托、冲突解决串起来。场景是这样的有一个图形系统图形可以绘制也可以计算面积。我们定义两个接口interface Drawable { fun draw() { println(绘制图形) } } interface Measurable { fun area(): Double }然后定义一个抽象基类abstract class Shape(val name: String) : Drawable, Measurable { abstract override fun draw() fun describe() { println($name 的面积是 ${area()}) } }注意Shape同时实现了Drawable和Measurable但draw()声明为抽象强制子类实现。area()没有默认实现子类也必须实现。现在定义一个圆形class Circle(val radius: Double) : Shape(圆形) { override fun draw() { println(画一个半径为 $radius 的圆) } override fun area(): Double Math.PI * radius * radius }再定义一个矩形但这次我们用委托来复用绘制逻辑class Rectangle(val width: Double, val height: Double, drawer: Drawable) : Shape(矩形), Drawable by drawer { override fun area(): Double width * height }这里Rectangle继承了Shape但draw()没有自己实现而是委托给了drawer。注意Shape里draw()是抽象的Rectangle通过委托满足了抽象方法的要求。这个写法合法而且很灵活你可以传入不同的Drawable实现来改变绘制行为。3.2 冲突解决的实际演练与 super 限定现在制造一个冲突。假设我们有一个新接口Colorful它也有draw()的默认实现interface Colorful { fun draw() { println(用彩色绘制) } }然后让一个类同时继承Shape和实现Colorfulclass Triangle(val base: Double, val height: Double) : Shape(三角形), Colorful { override fun draw() { superShape.draw() superColorful.draw() println(画一个底为 $base 高为 $height 的三角形) } override fun area(): Double base * height / 2 }这里Triangle必须覆写draw()因为Shape的draw()是抽象的Colorful的draw()有默认实现两者冲突。覆写时用superShape.draw()调用父类的抽象方法实现实际上Shape的draw()是抽象的这里调用的是Shape继承自Drawable的默认实现不对Shape覆写了draw()为抽象所以superShape.draw()调的是Drawable的默认实现。等等这里有个细节容易搞混。Shape声明了abstract override fun draw()这意味着Shape把Drawable的默认实现“抽象化”了子类必须重新提供实现。所以superShape.draw()实际上调用的是Drawable接口的默认实现因为Shape自己没有方法体。这个行为是 Kotlin 规范定义的我实测过确实如此。如果你不想调Shape那边的只想调Colorful的就写superColorful.draw()。如果两个都不想调直接写自己的逻辑也行但必须覆写不能省略。3.3 委托与继承混合时的初始化顺序这是一个很容易踩坑的地方。看下面的代码open class Base { open val value: String base init { println(Base 初始化value $value) } } class Derived(override val value: String) : Base() { init { println(Derived 初始化value $value) } }你猜输出是什么Base 初始化value null然后Derived 初始化value 实际值。因为Base的init执行时Derived的构造参数还没赋值给属性value还是默认值String 是 null。这个坑在 Java 里也有但 Kotlin 的override val让它更隐蔽。我的建议是不要在父类的 init 块里调用 open 方法或访问 open 属性。如果一定要用就改成在构造完成后单独调用一个setup()方法。这个经验是我在一个项目里调试了半天才总结出来的当时父类 init 里打印日志子类覆写了属性结果日志全是 null排查了很久。委托属性的初始化顺序也类似。by委托的属性是在对象构造时初始化的如果委托对象依赖其他属性要确保那些属性先初始化。我一般把委托属性放在类体最后或者用lazy延迟初始化。4. 常见问题与排查技巧实录4.1 继承与委托的常见报错速查表下面这张表是我自己整理的高频问题基本覆盖了日常开发中 90% 的继承相关报错报错信息原因解决方法This type is final, so it cannot be inherited from类没有加open在类声明前加opendraw overrides nothing父类方法没有open或签名不匹配检查父类方法是否open参数和返回值是否一致Class X is not abstract and does not implement abstract member抽象方法没实现实现所有抽象方法或用by委托Inheritance from an interface with a default method is ambiguous多个接口有同名默认方法覆写该方法用super接口名指定Cannot access value: it is invisible父类属性是 private改成protected或opensuper is not an expression在非覆写方法里用了super只在override方法里用super这张表建议存下来遇到报错先查一遍能省不少时间。4.2 几个容易忽略的细节与避坑经验第一个坑接口的默认方法在实现类里不能直接调用。比如interface A { fun foo() { println(A) } }class B : A在B里写foo()调用的是B自己的foo如果覆写了想调接口的默认实现必须写super.foo()。但如果B同时实现了多个接口就得写superA.foo()。第二个坑委托不会自动覆写父类的方法。如果class C : Base(), Interface by implBase里有一个方法和Interface的方法同名C不会自动用委托的实现去覆写Base的方法。你需要显式覆写然后在里面调impl的方法。这个行为跟直觉不太一样我一开始以为委托会覆盖一切结果发现父类的方法优先级更高。第三个坑by委托的属性如果是var委托对象必须提供getValue和setValue。如果是val只需要getValue。这个在自定义委托的时候要注意少一个方法编译不过。第四个坑抽象类里的open方法是给子类覆写的但抽象方法本身就是 open 的不用再加open。我见过有人写abstract open fun foo()编译器会警告open是多余的。实操心得如果你不确定一个方法该不该open默认不加。等真的需要继承了再加这样能避免很多意外的覆写。Kotlin 的设计哲学就是“默认封闭”顺着它走比对抗它省事。4.3 性能与设计层面的取舍建议从性能角度看Kotlin 的委托在编译后会生成转发方法调用链多了一层但 JVM 的内联优化基本能抹平这个开销。我实测过一个每秒调用百万次的场景用委托和直接调用的差距在 3% 以内完全可以忽略。所以选型时不用太纠结性能主要看设计清晰度。从设计角度看我遵循的原则是优先用组合和委托其次用接口默认方法最后才用继承。继承的耦合度最高父类一改子类就可能出问题。委托的耦合度低而且可以在运行时替换实现。接口默认方法介于两者之间适合放一些通用的工具方法。举个例子如果你要写一个日志类需要支持输出到控制台、文件、网络。不要写一个BaseLogger然后让三个子类继承而是定义一个Logger接口三个实现类然后用一个CompositeLogger通过委托组合它们。这样新增输出方式不用改现有代码符合开闭原则。最后再分享一个小技巧Kotlin 的by可以和lazy结合实现延迟委托。比如val printer: Printer by lazy { ConsolePrinter() }这样printer只在第一次访问时初始化。这个在依赖较重或者初始化较慢的场景下很有用我一般在 Android 开发里初始化一些 SDK 的时候会这么写。