10、继承与多态:open关键字、抽象类、接口、方法重写、属性重写、super关键字
继承和多态,说白了就是面向对象编程的「灵魂两件套」。我刚开始用Java写Android时,对继承的理解就是「子类拿父类的东西用」,后来踩了不少坑才明白——继承不是拿来主义,而是一种契约设计。
Kotlin在这块做了很多「防呆设计」。比如Java里类默认是开放的,你随便继承;Kotlin反其道而行,默认都是final的。我第一次写Kotlin时,写了个普通类想被继承,编译器直接报错——嗯,当时我还骂了一句「这什么鬼设计」。后来用久了才发现,这其实是在逼你思考:这个类真的该被继承吗?
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。你想想看,一个类被继承后,它的内部实现就暴露给了子类,后续改起来牵一发动全身。我在项目中遇到过好几次——基类改了个方法签名,十几个子类全部报错,那叫一个酸爽。
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出来干嘛?
我记得有一次,团队里新来的同事把抽象类当普通类用,在里面写了一大堆具体业务逻辑。结果后来需求变了,抽象类改一次,所有子类都得跟着改……嗯,抽象类不是用来写实现的,是用来定规矩的。
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机制就杜绝了这种问题,你写错了编译器直接告诉你。
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继承体系的基石。
10.8 避坑指南与最佳实践
最后,我把自己这些年踩过的坑总结一下,希望能帮你少走弯路:
- 继承层级不要太深——超过3层继承,代码基本就不可维护了。我见过一个项目,继承链有7层,改一个基类要重启整个服务……
- 优先用接口组合——接口比抽象类灵活,而且可以多实现。能用接口解决的问题,别用抽象类。
- open不是银弹——不要为了「以后可能用到」就随便加open。YAGNI原则(你不会需要它)在这里同样适用。
- super调用要谨慎——在重写方法里调用super时,注意调用顺序。先调super再干别的,还是先干别的再调super,结果可能完全不同。
- 属性重写注意setter——重写var属性时,setter的逻辑可能会被覆盖。如果你需要保留父类的校验逻辑,记得在重写时调用
super。
一句话总结: 继承是「是什么」的关系,接口是「能做什么」的关系。设计时多问自己一句——这个类真的需要被继承吗?还是用接口组合更合适?
公众号:蓝海资料掘金营,微信deep3321