ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

继承与多态实战:C#、Python、TypeScript实现与避坑指南

继承与多态实战:C#、Python、TypeScript实现与避坑指南 前阵子有个朋友问我一个面向对象课程里的“8继承多态”到底在考什么。我让他把他写的代码发过来看好家伙一个订单状态判断写了六个 if每个 if 里又套了三个 if人还没看三行就晕了。我告诉他当你的代码里出现大量“根据类型选分支”的逻辑时往往就是继承和多态该出场的时候。继承负责把共同逻辑收拢到一处多态负责把“该执行哪个实现”这件事从你的业务代码里赶出去让运行时替你决定。这篇文章我想把这两个概念掰开揉碎讲清楚顺带把不少同学纠结的 C# 特性继承、Python 抽象基类多重继承、JavaScript 原型链继承、TypeScript 接口继承和静态方法重写这些细节点都过一遍。适合正在学面向对象、写代码时总爱堆 if-else或者准备面试想把底层原理讲明白的朋友。1. 继承与多态到底在解决什么问题1.1 先从一段难维护的业务代码说起想象一个支付模块。今天要接微信支付明天要接支付宝后天要接银行卡。很多同学的第一反应是写一个大类里面放上payByWechat()、payByAlipay()、payByBankCard()三个方法。来一个新渠道就加一个新方法。看起来挺方便可问题很快暴露每种支付方式都有“配置读取、签名、下单、回调验签、退款”这几件事这些方法之间大量代码是重复的更可怕的是底层逻辑里有一个公共步骤要调整比如统一加日志、统一做幂等判断你得改三个方法漏掉一个就得线上见。如果换成继承体系事情会清爽很多。先定义一个Payment抽象基类把公共流程写死在基类里把各渠道不同的部分抽象成虚方法微信、支付宝、银行卡各写一个子类各自实现自己的差异逻辑。调用方不管细节拿到一个Payment类型的对象直接调用pay()至于它是哪个子类实例、内部怎么签名怎么请求一律不用操心。这就是继承和多态的组合价值继承解决“公共逻辑只写一份”多态解决“外部不用关心内部实现扩展时不用改调用方代码”。两者单独拿出来都不够用配合起来才是真正的解耦。1.2 用生活里的类比读懂这两个概念继承最简单直白的理解是“is-a”的关系猫是动物出租车是交通工具抖音视频是一种新媒体内容。在代码里Cat继承Animal意思是Cat具备Animal的所有能力并且可以扩展出自己的新能力。这个关系里最关键的一点是子类永远可以当成父类来用因为子类“是”一个父类。代码里常见的写法Animal animal new Cat()靠的就是这个语义。生活里随处可见多态的例子。比如 3.5mm 耳机孔插着耳机能听歌插音箱能放巡演插麦克风能录播客但对手机来说它只认识那个孔不关心插的是什么设备只要符合协议就能工作。这就是运行时的多态外部接口相同具体表现由实际类型决定。继承和多态不能混为一谈。继承是代码的组织形式是在编译期就确定的结构多态则是运行时的行为分发机制。继承了不重写那只是代码复用继承后重写了父类方法并且通过父类引用去调用时才产生了多态效果。2. 不同语言的继承实现方式对比与选型搜索引擎里高频出现“C#继承Attribute”“Python abc多继承”“JS继承”“TypeScript interface怎么继承”“TypeScript static继承重写”这些词可见大家不是不懂继承的大概念而是被各种语言的不同实现方式卡住了。这块值得单独展开对比。2.1 C#单根继承 接口 Attribute 继承C# 的继承根基非常严格一个类只能继承一个基类想实现多能力只能靠实现多个接口。这种设计让继承链变得简单不会出现 C 那样复杂的菱形问题。基类里的方法要允许子类重写必须标记为virtual子类重写时用override。有个平时容易被忽略的点是 Attribute 也可以继承。C# 里自定义特性类时默认AttributeUsage的Inherited参数是true意味着基类或基类成员上的特性子类和子类成员也能“继承”到。这种技术常用于自定义校验逻辑先写一个抽象校验特性基类再派生手机号校验、邮箱校验等子类用特性标注字段时不同的校验器自然形成多态关系。[AttributeUsage(AttributeTargets.Property, Inherited true)] public abstract class ValidationAttribute : Attribute { public abstract bool IsValid(string value); } public class PhoneValidationAttribute : ValidationAttribute { public override bool IsValid(string value) Regex.IsMatch(value, ^1\d{10}$); } public class EmailValidationAttribute : ValidationAttribute { public override bool IsValid(string value) Regex.IsMatch(value, ^\S\S\.\S$); }注意如果某个校验器只希望在自己这个类上生效不希望被子类继续背着就把Inherited显式设为false。默认行为是允许继承这个细节在反射遍历特性时表现非常明显容易踩坑。C# 里还有一种常见的错误处理方式子类用new关键字隐藏父类方法而不是用override。这样写时如果用父类类型引用子类实例调用到的仍然是父类的方法多态完全失效。所以判断一个方法该不该重写先看基类方法有没有virtual或者abstract修饰。2.2 PythonABC 抽象基类与多重继承Python 继承比 C# 灵活得多类可以多重继承。但灵活往往意味着更复杂的规则。定义抽象基类有两种方式继承ABC类或者指定metaclassABCMeta。推荐直接用from abc import ABC, abstractmethod然后继承ABC可读性和可维护性都更好。抽象方法用abstractmethod装饰。一个类只要还有抽象方法没实现就不能实例化。它传递一个明确的信号继承这个基类的人必须实现这些方法否则程序直接报错给你看。这样就把“约定”从注释里搬到了代码里。from abc import ABC, abstractmethod class Payment(ABC): def __init__(self, config: dict): self.config config abstractmethod def pay(self, amount: float) - str: ...多重继承最常见的问题是菱形继承。假设A是所有类的父类B继承AC也继承AD同时继承B和C。当 D 的实例调用一个在 A 中定义的方法时到底走 B 的路径还是 C 的路径Python 用 C3 线性化算法解决了这个顺序问题你不需要把算法背下来但一定要学会用ClassName.__mro__查看方法解析顺序然后理解super()在多重继承里不是“调父类”而是“调 MRO 里的下一个类”。print(D.__mro__) # (class D, class B, class C, class A, class object)写多重继承时如果基类们的__init__参数不一致子类的super().__init__()很容易报错常规做法是让基类的初始化方法都保持一致的签名或者干脆在子类里显式分别调用各个父类的初始化。这与 C# 的单根继承形成了鲜明对比也让新手更容易踩到super传参的坑。2.3 JavaScript/TypeScript原型继承、接口继承与 static 重写JavaScript 没有类所谓 class 只是基于原型链的语法糖。你写class Child extends Parent引擎做的事情就是设置Child.prototype.[[Prototype]]指向Parent.prototype。原型链是找方法的方式实例上找不到属性就往其原型找原型找不到就往原型的原型找直到Object.prototype和null。ES6 的 class 继承里有条硬性规定子类的constructor里必须先调用super()才能访问this因为子类实例的构建依赖父类实例的初始化。这条规则让不少新手困惑本质原因是 JS 的继承机制需要通过父类来创建 this 对象。TypeScript 在这个基础上增加了类型层面的继承。接口可以继承多个接口一个类可以实现多个接口这些都是纯粹的类型描述在编译后不会留下任何代码。interface Entity { id: number; createdAt: Date; } interface UserEntity extends Entity { name: string; email: string; }接口还能继承类这会把这个类的所有成员包括私有成员都纳入接口定义因此只有该类的子类才能正确实现这个接口。这个行为比较反直觉实际项目里更常用的是接口继承接口。TypeScript 里 static 继承也常常被搜到。静态成员属于类本身与实例无关但它同样可以被继承和重写。子类定义一个与父类同名的静态方法就完成了重写。有一个细节非常好用静态方法里用this访问静态属性会在继承时自动指向当前类配合多态甚至能写出模板化仓储逻辑。class Repository { static tableName: string base; static create(data: unknown): void { console.log(insert into ${this.tableName}, data); } } class UserRepo extends Repository { static tableName user; } Repository.create({ id: 1 }); // insert into base UserRepo.create({ id: 2 }); // insert into user注意TypeScript 目前不支持抽象的静态方法abstract static如果你试图用一个抽象静态方法约束子类必须实现某个静态方法语言层面并不提供这种能力只能靠约定或运行时检查。设计时别在这上面硬撞。3. 完整实操一个跨语言示例的继承多态实现篇幅有限我不可能把每种语言的所有细节都铺开。这一节我用同一个业务场景——通知推送系统分别在 C#、Python、TypeScript 里完整实现一遍。三种实现放一起看继承和多态在每个语言里的落地形态一目了然。3.1 C# 示例通知处理器多态分发这个场景里有一个通知调度器它接收一个Notification对象调用指定通道发送。不同通道的发送逻辑完全不同但对调度器来说它只认识“发送通知”这一个动作。public abstract class NotificationHandler { public abstract string Channel { get; } public void Send(Notification notification) { BeforeSend(notification); SendCore(notification); AfterSend(notification); } protected virtual void BeforeSend(Notification n) Console.WriteLine($[{Channel}] preparing...); protected virtual void AfterSend(Notification n) Console.WriteLine($[{Channel}] finished.); protected abstract void SendCore(Notification notification); } public class EmailNotificationHandler : NotificationHandler { public override string Channel email; protected override void BeforeSend(Notification n) { base.BeforeSend(n); Console.WriteLine(render email template); } protected override void SendCore(Notification n) Console.WriteLine($sending email to {n.Target}); } public class SmsNotificationHandler : NotificationHandler { public override string Channel sms; protected override void SendCore(Notification n) Console.WriteLine($sending sms to {n.Target}); }这段代码里模板方法模式其实已经悄悄出现了Send方法在基类里定义好步骤骨架BeforeSend和AfterSend用virtual提供默认实现SendCore用abstract强制子类实现。外部调用时只需要持有NotificationHandler类型的引用NotificationHandler handler new EmailNotificationHandler(); handler.Send(notification);执行到handler.Send时实际打印的内容会包含[email] preparing...、[email] finished.以及 email 模板渲染的输出换成 Sms 实例则不会渲染模板。这就是多态的直观效果同一个调用点注入不同子类行为随之变化调度器完全不知道具体通道的存在。这种设计的优势在于以后新增一个“站内信推送”只需要新增一个NotificationHandler的子类并注册进去Send方法的骨架一次都不用改。这也是为什么说多态是最符合“开闭原则”的实践对扩展开放对修改封闭。3.2 Python 示例抽象基类约束多态接口Python 里的通知系统我用了abc模块和多重继承演示两层抽象。第一层定义一个通知内容接口第二层定义不同的通知渠道实现。from abc import ABC, abstractmethod from dataclasses import dataclass dataclass class Notification: title: str content: str target: str class Notifier(ABC): property abstractmethod def channel(self) - str: ... abstractmethod def notify(self, notification: Notification) - bool: ...实际的EmailNotifier和SmsNotifier实现Notifier接口。为了演示多重继承我加了一个Loggable混入类上面的 notifier 也继承它这样所有通道都自动具备日志能力。class Loggable: def log(self, message: str): print(f[log] {message}) class EmailNotifier(Notifier, Loggable): property def channel(self) - str: return email def notify(self, notification: Notification) - bool: self.log(fstart sending email to {notification.target}) # 真正的发邮件逻辑 return True class SmsNotifier(Notifier, Loggable): property def channel(self) - str: return sms def notify(self, notification: Notification) - bool: self.log(fstart sending sms to {notification.target}) # 真正的发短信逻辑 return True调用逻辑同样只依赖 Notifierdef dispatch(notifier: Notifier, notification: Notification): ok notifier.notify(notification) print(fchannel{notifier.channel}, result{ok})如果有个新人忘了实现channel属性或notify方法类实例化时 Python 会直接抛出TypeError不会等到运行到一半才发现问题。抽象基类在这里起到的是“编译期强制约束”的替代作用——虽然 Python 是动态语言但用 ABC 约束契约后很多低级错误在启动阶段就能暴露。多重继承里的Loggable和Notifier没有共同祖先MRO 相对简单[EmailNotifier, Notifier, Loggable, ABC, object]。一旦混入类之间也有继承关系就需要借助__mro__来验证比如EmailNotifier.__mro__的输出顺序决定了super()调用链调试时先看它至少能省半小时。3.3 TypeScript/JavaScript 示例接口继承与静态方法重写TS 的解释能力体现在类型层面。我先定义一个基础实体接口再定义带名称字段的用户接口用户接口继承基础接口。然后定义两个处理器类通过静态属性重写实现不同表的仓储逻辑。interface Entity { id: number; createdAt: Date; } interface UserEntity extends Entity { name: string; email: string; } interface Notifiable { notify(target: string, message: string): boolean; } class BaseNotifier implements Notifiable { protected static providerName base; notify(target: string, message: string): boolean { const provider (this.constructor as typeof BaseNotifier).providerName; console.log([${provider}] send to ${target}: ${message}); return true; } } class EmailNotifier extends BaseNotifier { protected static providerName email; } class SmsNotifier extends BaseNotifier { protected static providerName sms; }看仔细providerName是静态属性在子类被重写之后notify方法里通过this.constructor.providerName拿到的就是子类的值。这就是前面提到的静态继承技巧相当于把静态信息和实例共用的一套多态逻辑串起来了。再演示接口多继承的场景interface Timestamped { createdAt: Date; } interface Traceable { traceId: string; } interface AuditableEntity extends Timestamped, Traceable { id: number; } class User implements AuditableEntity { id: number; createdAt: Date; traceId: string; constructor(id: number) { this.id id; this.createdAt new Date(); this.traceId trace-${id}; } }接口多继承的价值在于它把描述“多个维度能力”的接口任意组合类只需要实现最终那个组合接口不会受到单继承限制。编译后这些接口全部消失不产生任何运行时开销。JS 本身的继承如果不用 TS直接写类继承也可以但原型链的细节还是值得懂。Object.create负责建立原型关系super关键字在合成类里负责调用父类构造和方法。写原生 JS 时千万别在子类构造函数里忘记调用super()否则this未初始化代码会直接抛 ReferenceError。3.4 三种语言实现放一起怎么选型把 C#、Python、TS 三版代码摆在一起选型思路就清晰了。用一个表格整理核心差异维度C#PythonTypeScript继承种类单根继承多接口多重继承类单继承、接口多继承抽象成员abstract方法/属性abstractmethod装饰器接口描述无运行时抽象多态实现virtualoverride鸭子类型天然支持接口实现类重写静态成员重写支持但无覆写语义支持可重新赋值支持同名即覆盖菱形继承无此问题C3 线性化解决接口层面无实现冲突实际项目中我一般这样选如果团队强类型风格重、需要严守接入契约C# 或 TypeScript 都很好编译期能抓住大量问题如果项目脚本化程度高、业务逻辑迭代飞快Python 的鸭子类型配合 ABC 约束抽象边界也足够稳不要为了形式主义把简单逻辑硬拆成一堆抽象类。4. 常见问题与排查技巧实录4.1 高频报错和异常现象速查表这些年在代码评审和带新人时继承多态相关的报错翻来覆去就是那几个。我把它们整理成速查表排查时可以按图索骥。现象根本原因解决思路C# 里父类引用调子类方法走的却是父类逻辑子类方法用了new隐藏而非override基类方法加virtual子类方法加overridePython 子类实例化报 TypeError: Cant instantiate abstract class基类的抽象方法没实现完检查子类是否漏实现某个abstractmethodPython 多重继承下super()调用链错乱MRO 顺序理解偏差调用顺序与预期不符打印ClassName.__mro__确认顺序TS 接口 extends 类时其他类无法实现接口继承了类的私有成员改用接口 extends 接口或让实现类成为该类的子类JS 子类构造函数里访问this报 ReferenceError在super()之前使用了 this调整顺序先调用父类构造TypeScript 子类静态方法重写不生效静态方法与实例无关需要同名重新定义子类显式定义同名的静态方法C# 自定义特性在子类反射时读不到AttributeUsage的Inherited设为了 false按业务需要设置Inherited true继承层次过深改一个基类方法导致所有子类行为变化设计上把可变行为和固定行为混在了一起收缩基类职责把变化点抽象到更细的接口或基类4.2 值得留意的实操细节讲几个代码之外的实操体会。能用继承解决“公共流程复用”的话尽量让基类是非抽象的逻辑骨架而不是把所有方法都做成virtual让人随意改。原因是虚方法越多基类行为越不可控某个子类一改整体行为就不可预期了。对需要扩展的点用抽象方法约束对允许微调的点用virtual提供默认实现其余一律写成普通方法这是一个值得坚持的纪律。调用super()/base的顺序也很有讲究。我在 C# 里习惯把所有通用的后置逻辑放在基类模板方法里子类的override方法先调用base.XXX()再追加细节。这样能保证日志、链路追踪这类横切能力始终被执行不会因为子类忘写而漏掉。Python 多重继承里尤甚一定要时刻记住super()指向 MRO 中的下一个类而不是机械地理解为“父类”。静态方法重写是个容易被忽视但又特别好用的点。TypeScript 里用this.constructor.静态属性动态取当前类的静态成员可以写出非常灵活的工厂方法C# 里没有直接的虚静态方法替代方案要么用泛型约束要么改用实例方法。设计 API 时先想清楚调用方拿的是类还是实例别把静态成员当成多态的替代品硬用。关于继承层次经验值是别超过三层。超过三层以后类之间的依赖关系已经很难在头脑里构建完整地图。遇到那种想继承四五层的模型先停下来想想能否用组合替代继承或者把公共部分抽到更底层的接口里。组合优于继承这条原则虽然老但在 90% 的业务代码里都成立至少能让变动点局部化。最后再分享一个排查技巧遇到“改了子类没反应”这类诡异问题第一步不是翻代码而是把对象运行时的真实类型打印出来C# 里用GetType()Python 里用type(instance)TypeScript 编译后可以运行时输出instance.constructor.name。很多所谓的多态失效其实都是调用方持有的引用类型比自己以为的更“父级”加上某个方法没有被正确重写造成的。输出运行时类型的那一刻问题往往就已经解决了一半。我个人在实际操作中最深的体会是继承和多态不是为了把代码写得看起来抽象而是为了把变化从主流程中剥离。如果你发现自己为了展示技术写了一大堆接口和子类但业务本身根本没有多个变化维度那大概率是在过度设计。真正舒服的状态是新增一种支付、新增一个通知渠道、新增一种报表格式时改动点只有“新增一个子类并注册”调用方一行不动——这套东西才算真正落地了。
返回列表