第 8 章 · 类、接口与组合:继承、鸭子类型与无类
解决什么问题:几种东西要以同一种方式被使用、各自有不同的实现,又要共用一段行为时,怎么把它们组织起来,调用时又怎么找到真正该执行的那段代码。
四种语言的态度:Python 有类和多重继承,JS 用原型链、
class只是写法,C# 是单继承加多接口的名义类型(nominal typing),Go 没有类和继承,只有结构体、方法、隐式接口和嵌入。
通知系统有两种发送渠道:邮件和短信。两者都要能“发送”,调用方拿到的是一组渠道,不关心每个具体是哪种;两者的发送方式不同,邮件打印 [email],短信打印 [sms];两者又要共用同一段“发送完记一笔日志”的逻辑,这段逻辑只写一次。程序向邮箱和手机号各发一条“订单已发货”,最后由日志报告一共发了几条。
调用方想对每个渠道都说一句“发送”,而不去判断“如果是邮件就……如果是短信就……”。这需要三样东西。一是接口(interface):约定“能发送”意味着有一个什么样的方法;二是多态(polymorphism):同一句 sender.send(...),对邮件对象执行邮件的实现,对短信对象执行短信的实现;三是共享行为:记日志的代码只写一份,两个渠道都能用上。
面向对象语言给出的经典答案是类(class)和继承(inheritance):把公共部分写在父类里,子类继承它,再重写(override)自己不同的那个方法。另一种答案是组合(composition):渠道对象不继承日志,而是持有一个日志对象,需要时调它。继承表达“是一个”,组合表达“有一个”。“组合优于继承”这条经验的来龙去脉和配套的模式,见《设计模式》一书,本章只看四种语言在语言层面给了哪些工具。
同一句 sender.send(...) 要到运行时才知道执行哪段代码,这叫动态分派(dynamic dispatch)。实现分两大类。一类按名字查找:对象带着自己的类或原型,调用时拿方法名去一层层的字典里找,Smalltalk、Python、JavaScript 走这条路,灵活,代价是每次调用都要查,于是运行时再加缓存。另一类按位置索引:编译器给每个虚方法在虚方法表(virtual method table,vtable)里分配一个固定槽位,调用时取对象的表、取那个槽、间接跳转,C++、C#、Java 走这条路。Go 的接口值属于后一类,只是表不挂在对象上,而是挂在接口值上。
上图是按名字查找:Python 和 JS 从调用出发,一层层查字典,步数随继承层数变化。
下图是按位置取函数:C# 和 Go 经方法表或 itab 取函数,步数是固定的几步。
接口也分流派(第 3 章讲过):C# 是名义类型,必须声明实现;TS 和 Go 是结构类型(structural typing),方法对上就算。Python 运行时根本不查,对象有 send 方法就能当发送者用,这叫鸭子类型(duck typing)。
Python 怎么做
Section titled “Python 怎么做”Python 原生支持类、多重继承和动态分派;接口没有专门的语法,用标准库的抽象基类(abstract base class,ABC)或类型检查器认得的 Protocol 来表达。下面的写法把记日志放进一个混入类(mixin),再用多重继承把它和渠道类拼起来。
from abc import ABC, abstractmethod
class Sender(ABC): def __init__(self, log: list[str]) -> None: self.log = log
@abstractmethod def send(self, to: str, text: str) -> None: ...
class LoggingMixin: log: list[str]
def send(self, to: str, text: str) -> None: super().send(to, text) # 调的是 MRO 里的下一个类,不是 object self.log.append(to) # 记一笔日志
class EmailSender(Sender): def send(self, to: str, text: str) -> None: print(f"[email] 发送给 {to}:{text}")
class SmsSender(Sender): def send(self, to: str, text: str) -> None: print(f"[sms] 发送给 {to}:{text}")
class LoggedEmail(LoggingMixin, EmailSender): ...class LoggedSms(LoggingMixin, SmsSender): ...
log: list[str] = []senders: list[Sender] = [LoggedEmail(log), LoggedSms(log)]for sender, to in zip(senders, ["a@example.com", "13800000000"]): sender.send(to, "订单已发货")print(f"[log] 已发送 {len(log)} 条")
# 输出:# [email] 发送给 a@example.com:订单已发货# [sms] 发送给 13800000000:订单已发货# [log] 已发送 2 条运行:python notify.py。Sender 继承 ABC,send 标了 @abstractmethod,直接写 Sender(log) 会在实例化时抛 TypeError,提示还有抽象方法没实现。LoggingMixin 自己只继承 object,它的 super().send() 却调到了 EmailSender.send,因为 super() 找的不是“我的父类”,而是“当前对象所属类的方法解析顺序(method resolution order,MRO)里排在我后面的那个类”。LoggedEmail.__mro__ 是 LoggedEmail → LoggingMixin → EmailSender → Sender → ABC → object。
只想表达“有 send 方法就行”时,用 typing.Protocol:mypy 这类检查器按方法签名判断兼容,运行时什么也不查。
底层怎么实现。 MRO 由 C3 线性化(C3 linearization)在定义类时算好,存进 __mro__:它保证子类排在父类前面,多个父类之间保持书写的先后,菱形继承里公共祖先只出现一次、排在所有子类之后;算不出满足这些条件的顺序时,class 语句直接抛 TypeError。super() 不带参数时,编译器给方法悄悄补上一个 __class__ 引用,运行时拿 type(self).__mro__,从 __class__ 之后的位置接着找。
sender.send 这样的属性读取由 object.__getattribute__ 完成,顺序是:先沿类的 MRO 找这个名字,找到的若是数据描述符(data descriptor,例如 property),直接用它;否则看实例的 __dict__;再否则用类里找到的东西,函数是非数据描述符,取出时和实例绑定成方法对象。上图画的是对普通方法效果上的顺序。每次都走一遍会很慢,所以 CPython 有按类型版本号失效的属性缓存,3.11 起的特化自适应解释器(specializing adaptive interpreter)还在字节码旁留了内联缓存,类没改过就直接用上次的结果。
- 多重继承下
super()调的不一定是直接父类:LoggingMixin单独看只继承object,换一个子类把它和别的类组合,同一行super().send()就会调到别的类。所以混入链上的方法都要调super()、签名都要一致,中间有一个不调,后面的就全被跳过。 - 类属性被所有实例共享:在类体里写
tags = [],再在方法里self.tags.append(...),所有实例改的是同一个列表;而self.count += 1碰上类属性count = 0时,会先读类属性、再在实例上新建一个同名属性,计数各算各的。
JS/TS 怎么做
Section titled “JS/TS 怎么做”JavaScript 原生有对象和原型(prototype),ES2015 加入的 class 是在原型之上的写法,不是另一套机制。TypeScript 加了 interface、implements、abstract、private 等,全部只在编译期检查。下面的写法用组合:一个包装类持有真正的渠道和日志对象。
interface Sender { send(to: string, text: string): void;}
class Log { #count = 0; // 真私有:运行时在类外也读不到 record(): void { this.#count++; } get count(): number { return this.#count; }}
class EmailSender implements Sender { // implements 只是让编译器核对一遍 send(to: string, text: string): void { console.log(`[email] 发送给 ${to}:${text}`); }}
const smsSender = { // 没写 implements,形状对得上就是 Sender send(to: string, text: string): void { console.log(`[sms] 发送给 ${to}:${text}`); },};
class LoggedSender implements Sender { // 组合:包住一个 Sender,再加上记日志 constructor(private inner: Sender, private log: Log) {} send(to: string, text: string): void { this.inner.send(to, text); this.log.record(); }}
const log = new Log();const senders: Sender[] = [new LoggedSender(new EmailSender(), log), new LoggedSender(smsSender, log)];const targets = ["a@example.com", "13800000000"];senders.forEach((sender, i) => sender.send(targets[i], "订单已发货"));console.log(`[log] 已发送 ${log.count} 条`);
// 输出:// [email] 发送给 a@example.com:订单已发货// [sms] 发送给 13800000000:订单已发货// [log] 已发送 2 条运行:tsx notify.ts。smsSender 只是一个对象字面量,没有类、没写 implements,照样能放进 Sender[],因为 TS 的接口是结构类型。implements Sender 不改变运行时的任何东西,作用只是让编译器在类定义处核对一遍,写错了在这里报错,而不是等到被当成 Sender 使用的地方。用基类共享行为同样常见:abstract class 加 extends,super.send() 调父类的方法。
TS 有两种私有。private log 只在编译期检查,编译后就是一个普通属性,JSON.stringify 会把它序列化出来,TS 自己也允许 obj["log"] 这样的方括号写法绕过检查。#count 是 JavaScript 自己的私有字段(ES2022),由引擎强制,类外的代码无论怎么写都读不到,连访问它的语法都会被拒绝。
底层怎么实现。 class LoggedSender 编译后的本质是一个构造函数,方法都挂在 LoggedSender.prototype 上,每个实例有一个内部链接指向它。读 sender.send 时,引擎先看对象自身的属性,没有就沿原型链往上找,到 Object.prototype 还没有就得到 undefined。extends 不过是把子类原型的链接指向父类原型,所以继承层数越多,链越长。
V8 给每个对象挂一个隐藏类(hidden class,内部叫 Map),记录属性的名字和偏移;按同样顺序添加同样属性的对象共用一个隐藏类。每个读属性的位置带一个内联缓存(inline cache):第一次查完,记下“隐藏类是这个,属性在偏移几”或“方法在原型的哪里”;下次遇到同一个隐藏类,直接按偏移取。只见过一种隐藏类时最快(单态,monomorphic),见过几种时逐个比对(多态,polymorphic),种类再多就退回通用查找(超态,megamorphic)。
- 把方法当回调传出去会丢掉
this:const f = log.record; f();里this是undefined(模块和class体都是严格模式),读#count时抛TypeError。要传就写箭头函数() => log.record()或log.record.bind(log)。 - 构造函数里按不同顺序添加属性,或者事后
delete属性,会让同一个类的对象分裂成多个隐藏类,读属性的位置从单态变成多态甚至超态,热点代码明显变慢,而源码上看不出任何差别。
C# 怎么做
Section titled “C# 怎么做”C# 原生支持类、接口和动态分派:一个类只能继承一个父类,但能实现任意多个接口;方法默认不是虚的,要写 virtual(或 abstract)子类才能用 override 重写;sealed 禁止再被继承或重写。下面的写法用抽象基类共享“先发送、再记日志”。
using System;
var log = new Log();ISender[] senders = [new EmailSender(log), new SmsSender(log)];string[] targets = ["a@example.com", "13800000000"];for (var i = 0; i < senders.Length; i++) senders[i].Send(targets[i], "订单已发货");Console.WriteLine($"[log] 已发送 {log.Count} 条");
interface ISender { void Send(string to, string text); }
class Log{ public int Count { get; private set; } public void Record() => Count++;}
abstract class LoggedSender(Log log) : ISender{ public void Send(string to, string text) // 非虚方法:子类改不了"先发送、再记日志"的顺序 { Deliver(to, text); log.Record(); }
protected abstract void Deliver(string to, string text); // abstract 隐含 virtual}
sealed class EmailSender(Log log) : LoggedSender(log){ protected override void Deliver(string to, string text) => Console.WriteLine($"[email] 发送给 {to}:{text}");}
sealed class SmsSender(Log log) : LoggedSender(log){ protected override void Deliver(string to, string text) => Console.WriteLine($"[sms] 发送给 {to}:{text}");}
// 输出:// [email] 发送给 a@example.com:订单已发货// [sms] 发送给 13800000000:订单已发货// [log] 已发送 2 条运行:dotnet run notify.cs。ISender 是名义类型:一个类只有在声明里写了 : ISender 才算实现它,方法签名碰巧一样也不行。LoggedSender.Send 故意不是虚方法,子类只能填 Deliver 这个空,这种“父类定流程、子类填步骤”的写法就是模板方法。private set 让 Count 只能在 Log 内部修改。
C# 8 起接口可以带默认实现(default interface method),用来给已发布的接口加方法而不破坏现有实现;但接口不能有实例字段,共享状态还得靠类。record(C# 9)是自动生成按值相等和 with 复制的类,适合数据。
底层怎么实现。 每个对象带一个对象头和一个指向方法表(method table)的指针;方法表里有该类型的 vtable,每个虚方法占一个槽,子类的方法表先复制父类的槽,override 的方法覆盖对应的槽,新的虚方法追加在后面。所以调用 Deliver 时,JIT 生成的代码是:取对象的方法表指针,取固定偏移的槽,间接调用,不管继承了几层都是这几步。
接口调用不能这么做:一个类可以实现任意多个接口,同一个接口方法在不同类的 vtable 里不在同一个位置。CoreCLR 在 Windows、Linux 上用的是虚存根分派(virtual stub dispatch):senders[i].Send 这个调用点先跳到一个存根,存根记着上次见到的类型和它的实现,类型对上就直接跳过去;对不上再查一张按(类型,接口方法)索引的缓存表,最后才解析方法表。此外 .NET 8 起默认开启的动态 PGO 会统计调用点实际出现的类型,生成“如果是 EmailSender 就直接调用”的守卫代码;sealed 类上的虚调用 JIT 能直接去虚化(devirtualization)。
- 忘了写
virtual:父类public void Send()、子类也写一个public void Send(),编译器只给出警告 CS0108,子类的方法是隐藏(hiding)而不是重写;通过父类类型的变量调用,执行的仍是父类版本。父类写了virtual、子类却漏了override,同样是隐藏,警告是 CS0114。显式写new只是表明“我知道这是隐藏”,行为不变。 - 默认接口方法不进实现类:类没有自己实现时,用类类型的变量调用它编译不过,要先转成接口类型;把它当成多重继承来用,很快会碰到这个限制。
Go 怎么做
Section titled “Go 怎么做”Go 没有类,也没有继承。数据放在结构体里,方法写在类型外面、通过接收者(receiver)挂到类型上;接口是隐式实现的,一个类型有接口要求的全部方法,就自动满足这个接口;共享行为用嵌入(embedding),把一个类型作为匿名字段放进另一个结构体,它的方法会被提升(promote)到外层。这些都是语言本身的机制。
package main
import "fmt"
// Sender 是接口:哪个类型有 Send 方法,哪个类型就自动满足它。type Sender interface { Send(to, text string)}
// Logger 是被共享的那段行为。type Logger struct{ count int }
func (l *Logger) Record() { l.count++ }
// EmailSender 嵌入 *Logger:Logger 的方法被提升,e.Record() 可以直接调用。type EmailSender struct{ *Logger }
func (e EmailSender) Send(to, text string) { fmt.Printf("[email] 发送给 %s:%s\n", to, text) e.Record() // 等价于 e.Logger.Record()}
type SmsSender struct{ *Logger }
func (s SmsSender) Send(to, text string) { fmt.Printf("[sms] 发送给 %s:%s\n", to, text) s.Record()}
func main() { log := &Logger{} senders := []Sender{EmailSender{log}, SmsSender{log}} targets := []string{"a@example.com", "13800000000"} for i, s := range senders { s.Send(targets[i], "订单已发货") } fmt.Printf("[log] 已发送 %d 条\n", log.count)}
// 输出:// [email] 发送给 a@example.com:订单已发货// [sms] 发送给 13800000000:订单已发货// [log] 已发送 2 条运行:go run notify.go。EmailSender 从头到尾没有提到 Sender,只因为有 Send(to, text string) 方法,就能放进 []Sender。所以接口可以由使用方事后定义,标准库的 io.Writer 就只有一个 Write 方法,谁有它谁就能交给 fmt.Fprintf。访问控制只有一条规则:名字首字母大写的导出到包外,小写的只在包内可见,count 在包外就读不到。
嵌入不是继承。EmailSender 有一个字段,字段名就是类型名 Logger,e.Record() 只是 e.Logger.Record() 的简写。被提升的 Record 执行时,接收者是那个 Logger,它不知道自己被嵌在谁里面,也调不回外层的方法,所以没有“父类调用子类重写的方法”这回事。
对比上下两栏的箭头:上栏是“有一个”,EmailSender 指向它持有的 Logger,想把它当 *Logger 传出去会被编译器拒绝;下栏是“是一个”,EmailSender 可以出现在任何要 LoggedSender 或 ISender 的地方。
底层怎么实现。 非空接口的值占两个字:第一个字指向 itab,第二个字指向数据(这里 EmailSender 只有一个指针字段,直接放得下;其他值一般要复制到堆上再存指针)。itab 记着接口类型、具体类型、类型的哈希,以及一个按接口方法排好序的函数指针数组 Fun。s.Send(...) 编译成:从 itab 取 Fun[0],把数据字作为接收者,间接调用。itab 对每一对(接口,具体类型)只算一次:编译期就知道的转换由编译器生成,运行时才发生的转换(比如类型断言)在第一次时计算,存进全局哈希表。
提升的方法是编译器生成的包装函数:EmailSender.Record 取出 Logger 字段再调 (*Logger).Record。方法集(method set)的规则决定了谁满足接口:值类型 T 的方法集只含值接收者的方法,*T 的方法集两种都含;嵌入 *Logger 时外层的值和指针都拿到 Record,嵌入 Logger 值时只有外层的指针拿到。
- 指针接收者和值接收者决定谁实现了接口:把
Send改成func (e *EmailSender) Send(...),EmailSender{log}就放不进[]Sender,报错 “method Send has pointer receiver”,要写&EmailSender{log}。 - 嵌入值而不是指针时,计数可能悄悄丢失:写成
struct{ Logger },Send又是值接收者,那么Send拿到的是整个结构体的副本,e.Record()加的是副本里的count。这样调用两次后计数仍是 0,编译器和go vet都不报。 - 嵌入不是子类型:
EmailSender不能传给要*Logger的函数,要写e.Logger;外层定义了同名方法时,外层的遮住被提升的那个,这是按嵌套深度选名字,不是重写。
| 维度 | Python | JS/TS | C# | Go |
|---|---|---|---|---|
| 支持程度 | 原生 | 原生 | 原生 | 原生(无类) |
| 实现机制(方法分派) | 沿 MRO 查字典 + 属性缓存 | 原型链 + 隐藏类和内联缓存 | vtable 槽;接口走虚存根分派 | 接口值里的 itab 函数表 |
| 继承 | 类继承 | 原型链,extends |
类单继承 | 没有,用嵌入 |
| 接口实现方式 | 鸭子类型;ABC 显式,Protocol 结构 |
结构类型,implements 只做检查 |
名义类型,必须声明 : ISender |
隐式,方法对上即实现 |
| 多重继承 | 支持,C3 线性化 | 不支持,可用混入函数 | 只能多接口 | 可嵌入多个类型 |
| 访问控制 | 约定:_name,__name 改名 |
TS private 编译期;#name 运行时 |
private、protected、internal 等 |
首字母大小写,按包 |
| 共享行为的惯用写法 | 基类或混入类 | 基类或包装对象 | 抽象基类、默认接口方法 | 嵌入 |
差别最大的是“实现机制”一行:前两种语言在运行时按名字找方法,靠缓存提速;后两种语言按位置从 vtable 或 itab 取函数,C# 的接口调用另经存根缓存。
面向对象有两条源头。一条是 1967 年的 Simula:Dahl 和 Nygaard 为了写仿真程序引入了类、子类和虚过程,C++ 继承了这套设计,又传给 Java 和 C#,特点是类型在编译期确定、方法表在编译期排好。另一条是 Smalltalk:Alan Kay 把对象看成互相发送消息的小计算机,收到消息时才去查自己会不会处理,Python 的动态查找和鸭子类型接近这一支;JavaScript 的原型则来自 Smalltalk 的后继 Self,那里根本没有类,对象直接从别的对象继承。
动态查找换来的是灵活:Python 和 JS 能在运行时给类加方法、替换方法,测试时把任何方法换成假实现只要一行。代价是每次访问都要查,只能靠缓存补回来,而缓存依赖对象“长得一样”,形状一乱性能就悄悄下降。C# 的名义类型和 vtable 方向相反:分派只是固定的几次取内存,JIT 还能结合 sealed 和运行时统计去虚化;代价是关系必须事先声明,第三方的类没实现你的接口,你就只能再包一层。写测试替身时也要事先留口子:常用的模拟框架只能替换接口方法或虚方法,非虚方法替换不了。
Go 的官方 FAQ 说,主流面向对象语言在类型之间的关系上花了太多功夫,而这些关系常常可以自动推出来;Rob Pike 在 2012 年的文章《Go at Google》里说得更直接:类型层次必须在设计程序之初就定下来,早期的决定在程序写成后很难更改。于是他们只留下接口和嵌入:接口隐式实现,使用方可以在需要时才定义一个小接口,实现方不用知道它存在;嵌入复用代码但不建立“是一个”的关系。接口值自带 itab,调用时同样是固定的几步。后果是重构便宜:拆分一个接口、换一个实现,不用回头改一串类声明;测试替身也好写,在测试里定义一个有同样方法的结构体就行。代价是没有“父类定流程、子类填步骤”的写法,Python、TS、C# 里用模板方法表达的结构,在 Go 里要改成把步骤作为接口或函数传进来。
TS 的类型系统是结构的,因为它必须描述已有的 JS 代码,而 JS 程序早就在到处传“有某几个方法的对象”。它改变不了运行时,所以 implements 只是一次检查,private 只是编译期约定,运行时私有要等 JS 自己加上 #name。
旁注:别的语言
Section titled “旁注:别的语言”Rust 也没有类继承,共享行为靠 trait。和 Go 不同,Rust 要求显式写 impl Sender for EmailSender,是名义的;分派方式由调用方选择:泛型参数 fn notify(s: &impl Sender) 在编译期为每个具体类型生成一份代码,直接调用;写成 &dyn Sender 才是动态分派,此时的引用是一个胖指针(fat pointer),数据指针加 vtable 指针,和 Go 的接口值几乎是同一个结构。
trait Sender { fn send(&self, to: &str, text: &str); }struct EmailSender;impl Sender for EmailSender { fn send(&self, to: &str, text: &str) { println!("[email] 发送给 {to}:{text}"); }}- 先写接口,再决定共享行为怎么挂上去:Python 用
Protocol描述调用方需要什么,TS 用interface,C# 用小接口,Go 在使用方定义只有一两个方法的接口。 - 继承层次保持浅:Python 的混入链每个方法都调
super();C# 只在需要扩展点的方法上写virtual,其余类默认写sealed;TS 能用包装对象就不用三层extends。 - 在 JS/TS 里把方法交给回调、事件、定时器时,一律写箭头函数或
bind;在 Go 里一个类型的方法要么全用指针接收者,要么全用值接收者,别混用。
定义一个“形状”接口,有名称和面积两个方法;写矩形(3×4)和正方形(边长 3)两个实现;再加一段共享的“描述”行为,生成 名称:面积 N 这样的一行。打印两行:矩形:面积 12 和 正方形:面积 9。用 TS 和 Go 各实现一遍:TS 用基类或组合放描述行为,Go 用嵌入。
提示:TS 的基类方法里写 this.area(),调到的是子类的实现;Go 里被嵌入的类型调不回外层的方法,“描述”要用到面积时,你打算让它接收什么?
- 接口约定“能做什么”,多态让同一次调用执行不同实现,共享行为可以靠继承(“是一个”),也可以靠组合(“有一个”)。
- 动态分派有两种实现:Python 和 JS 在运行时按名字沿 MRO 或原型链查找,靠属性缓存和内联缓存提速;C# 和 Go 按位置从 vtable 或 itab 取函数指针,C# 的接口调用另走虚存根分派。
- Python 的
super()找的是 MRO 里的下一个类,不一定是直接父类;C# 的方法默认不是虚的,漏了virtual或override就变成隐藏。 - Go 没有类和继承,嵌入只是带简写的字段:方法会被提升,但外层不是内层的子类型,指针接收者和值接收者决定谁实现了接口。