10、继承与多态:open关键字、抽象类、接口、方法重写、属性重写、super关键字

继承和多态,说白了就是面向对象编程的「灵魂两件套」。我刚开始用Java写Android时,对继承的理解就是「子类拿父类的东西用」,后来踩了不少坑才明白——继承不是拿来主义,而是一种契约设计

Kotlin在这块做了很多「防呆设计」。比如Java里类默认是开放的,你随便继承;Kotlin反其道而行,默认都是final的。我第一次写Kotlin时,写了个普通类想被继承,编译器直接报错——嗯,当时我还骂了一句「这什么鬼设计」。后来用久了才发现,这其实是在逼你思考:这个类真的该被继承吗?

核心思想: Kotlin的继承体系强调「显式设计」——你想让类被继承,就写open;你想让方法被重写,也写open。一切都在明面上。

10.1 open关键字——继承的「通行证」

在Kotlin里,类默认是final的。你想让别人继承你的类?必须加上open关键字。方法也一样,想被重写就得标记open。

// 这个类不能被继承
class Animal {
    fun eat() { println("吃东西") }
}

// 这个类可以被继承
open class Pet {
    open fun play() { println("玩耍") }
    fun sleep() { println("睡觉") }  // 这个方法不能被重写
}

class Dog : Pet() {
    override fun play() { 
        println("狗狗在玩飞盘") 
    }
}

我个人习惯是:除非明确设计为基类,否则一律不加open。你想想看,一个类被继承后,它的内部实现就暴露给了子类,后续改起来牵一发动全身。我在项目中遇到过好几次——基类改了个方法签名,十几个子类全部报错,那叫一个酸爽。

我的经验: 如果你不确定要不要开放继承,就先别加open。等真的需要了再加,IDE重构工具几秒钟就搞定了。

10.2 抽象类——半成品的设计哲学

抽象类是什么?说白了就是「只搭架子,不填肉」。它定义了一组规范,但具体实现交给子类去完成。

abstract class Shape {
    abstract fun area(): Double  // 抽象方法,没有实现体
    
    fun description(): String {  // 普通方法,有默认实现
        return "这是一个形状,面积是${area()}"
    }
}

class Circle(val radius: Double) : Shape() {
    override fun area(): Double {
        return Math.PI * radius * radius
    }
}

class Rectangle(val width: Double, val height: Double) : Shape() {
    override fun area(): Double {
        return width * height
    }
}

抽象类有个特点:不能直接实例化。你写Shape()编译器会报错——这很合理,一个没填肉的半成品,你new出来干嘛?

我记得有一次,团队里新来的同事把抽象类当普通类用,在里面写了一大堆具体业务逻辑。结果后来需求变了,抽象类改一次,所有子类都得跟着改……嗯,抽象类不是用来写实现的,是用来定规矩的。

避坑指南: 抽象类里的抽象方法不能有方法体,但可以带参数和返回值。另外,抽象类可以有构造方法,子类必须通过super调用。

10.3 接口——契约的极致形态

接口比抽象类更「抽象」。它只定义「能做什么」,不关心「怎么做」。Kotlin的接口比Java灵活很多——可以有默认实现,甚至可以定义属性。

interface Flyable {
    val maxAltitude: Int  // 抽象属性
    
    fun fly()  // 抽象方法
    
    fun land() {  // 有默认实现的方法
        println("正在降落...")
    }
}

interface Swimmable {
    fun swim()
}

class Duck : Flyable, Swimmable {
    override val maxAltitude: Int = 100
    
    override fun fly() {
        println("鸭子飞了${maxAltitude}米高")
    }
    
    override fun swim() {
        println("鸭子在游泳")
    }
}

这里有个细节:Kotlin的接口可以多实现,但类只能单继承。你想想看,一个鸭子既能飞又能游,用接口组合就特别自然。如果用抽象类,你得搞个「会飞的动物」再搞个「会游的动物」,层级关系就乱套了。

我个人建议:能用接口就别用抽象类。接口更灵活,耦合度更低。我在项目中重构过一个支付模块,原来用抽象类写了三层继承,后来改成接口组合,代码量少了30%,测试也好写了。

10.4 方法重写——override的正确姿势

方法重写是多态的基础。Kotlin里重写方法必须加override关键字,这是强制性的——少写了编译器直接报错。

open class Parent {
    open fun sayHello() {
        println("你好,我是父类")
    }
    
    fun secret() {  // 没有open,不能重写
        println("这是秘密")
    }
}

class Child : Parent() {
    override fun sayHello() {
        println("你好,我是子类")
    }
    
    // 下面这行会编译报错
    // override fun secret() { }
}

为什么要强制加override?我遇到过这么个事:Java里有个方法叫show(),子类不小心写了个shwo()——拼写错误,结果变成了重载而不是重写,bug找了半天。Kotlin的override机制就杜绝了这种问题,你写错了编译器直接告诉你。

小技巧: 在IDEA里,你可以用快捷键 Ctrl+O 快速选择要重写的方法,不会漏也不会错。

10.5 属性重写——不只是方法的专利

Kotlin里属性也可以重写。这一点和Java很不一样——Java的属性是字段,没有多态行为。Kotlin的属性本质上是getter/setter的语法糖,所以可以重写。

open class Base {
    open val name: String = "基类"
    open var age: Int = 0
}

class Derived(override val name: String) : Base() {
    override var age: Int = 10
        set(value) {
            if (value > 0) field = value
        }
}

fun main() {
    val obj = Derived("子类")
    println(obj.name)  // 输出:子类
    obj.age = -5
    println(obj.age)   // 输出:10(因为setter做了校验)
}

属性重写有个限制:val可以重写为var,但反过来不行。为什么?因为val只有getter,var有getter和setter——你想想看,父类只承诺了「可读」,子类却要改成「可读写」,这没问题。但父类承诺了「可读写」,子类改成「只读」?那就违背了里氏替换原则。

10.6 super关键字——调用父类的「后门」

子类重写了父类的方法后,如果想调用父类的原始版本,就用super。这在很多场景下很有用——比如你只想在父类行为基础上加点东西,而不是完全替换。

open class BaseActivity {
    open fun onCreate() {
        println("BaseActivity: 初始化布局")
    }
}

class MainActivity : BaseActivity() {
    override fun onCreate() {
        super.onCreate()  // 先调用父类的初始化
        println("MainActivity: 加载数据")
        println("MainActivity: 设置监听器")
    }
}

接口的多实现场景下,super的用法稍微复杂一点——你需要指定调用哪个父接口的方法。

interface A {
    fun foo() { println("A的foo") }
}

interface B {
    fun foo() { println("B的foo") }
}

class C : A, B {
    override fun foo() {
        super<A>.foo()  // 调用A的foo
        super<B>.foo()  // 调用B的foo
        println("C的foo")
    }
}

这种语法我第一次看到时觉得有点怪,但用多了就发现——它解决了多继承里的「钻石问题」。Java 8之前接口不能有默认实现,所以没这个问题;Kotlin从一开始就设计好了,每个父接口的方法都能精确调用。

10.7 知识体系总览

下面这张图把本章的核心知识点串起来了。我建议你多看几遍,理解每个概念之间的关系——尤其是「open」和「override」这对组合,它们是Kotlin继承体系的基石。

Kotlin 继承与多态知识体系 继承与多态 open 关键字 抽象类 接口 super 关键字 类默认 final 方法默认 final 显式开放继承 不能实例化 抽象方法无实现 可含构造方法 多实现 可含默认实现 可定义属性 调用父类方法 接口冲突解决 super<T>.foo() 核心原则:显式设计 · 契约优先 · 组合优于继承

10.8 避坑指南与最佳实践

最后,我把自己这些年踩过的坑总结一下,希望能帮你少走弯路:

  • 继承层级不要太深——超过3层继承,代码基本就不可维护了。我见过一个项目,继承链有7层,改一个基类要重启整个服务……
  • 优先用接口组合——接口比抽象类灵活,而且可以多实现。能用接口解决的问题,别用抽象类。
  • open不是银弹——不要为了「以后可能用到」就随便加open。YAGNI原则(你不会需要它)在这里同样适用。
  • super调用要谨慎——在重写方法里调用super时,注意调用顺序。先调super再干别的,还是先干别的再调super,结果可能完全不同。
  • 属性重写注意setter——重写var属性时,setter的逻辑可能会被覆盖。如果你需要保留父类的校验逻辑,记得在重写时调用super

一句话总结: 继承是「是什么」的关系,接口是「能做什么」的关系。设计时多问自己一句——这个类真的需要被继承吗?还是用接口组合更合适?


公众号:蓝海资料掘金营,微信deep3321