封装、继承、多态:C#面向对象编程实战精髓解析
有一次我帮朋友做技术面试复盘候选人简历里写着熟练掌握C#面向对象编程但当被问到你项目里哪些地方用了多态如果现在要加一个别人写的设备协议你希望你的代码怎样组织的时候他沉默了。这不是个例。很多人能把封装、继承、多态的定义背得滚瓜烂熟但落到真实的C#项目里就不知道这三个词到底在解决什么问题。这篇内容就基于我这些年的C#开发经验把封装、继承、多态拆开揉碎讲清楚它们各自解决什么问题、代码怎么写、什么时候该用、什么时候不该用以及那些踩过才知道的边界和坑。适合正在学C#基础的朋友也适合写过一些代码但总觉得面向对象差点意思的开发者。1. 为什么说封装、继承、多态是C#项目里活下去的根基1.1 面向对象不是语法糖是控制复杂度的策略刚开始学C#的时候很多人以为面向对象就是把代码放进类里。后来写多了才会明白类的存在不是为了把代码装起来而是为了把不变的东西和会变的东西分开。封装、继承、多态这三板斧本质上都是在干这件事。写过一两个小Demo的人可能感受不深因为几百行代码随便怎么写都能跑。但一旦到了真正的项目——比如一个上位机要同时对接Modbus设备、PLC、扫码枪或者一个后台服务要处理多种支付渠道——如果所有逻辑都平铺在一个类里改一个需求就要动一大片代码每动一次就引入新的bug。这时候就能理解面向对象的三大特性其实是给你提供了一套组织变化的规则。C#作为一门现代语言把这三件事的语法支持做得相当细致。比如封装有字段、属性、索引器、访问修饰符继承有virtual、override、new、sealed多态有抽象类、接口、泛型约束。每一样背后都有它要解决的问题。1.2 从顺序执行到责任划分C#设计者的选择C#从Java和C里吸收了很多设计取舍。跟C语言那种函数就是一切的思路不同C#从设计之初就鼓励你用类来组织状态和行为。状态就是字段、属性行为就是方法类就是这两者的容器。而访问修饰符则是给容器开了一个权限门。另外要注意一点热搜词里有很多0805封装尺寸PCB封装芯片封装设计之类的词那是硬件领域的封装跟C#里的封装是两码事。软件里的封装说的是把数据和行为绑在一起并对外隐藏内部细节。这个区别很多跨领域初学者会混淆先把它理清了。1.3 学习三大特性的正确顺序我的建议是先学封装再学继承最后学多态。封装是基础中的基础你连一个类的边界都划不好后面谈继承和多态就是空中楼阁。继承是代码复用的一种手段但也是最容易被滥用的东西。多态则是面向对象真正的灵魂它决定了你的代码是写死的还是可扩展的。这三者不是孤立的。封装让每个类有自己的边界继承让类与类之间形成层次关系多态让调用方可以忽略具体类型只依赖抽象。三者配合起来才能构建出真正能应对需求变化的系统。2. 封装代码边界的守门员——从属性设计到防御式编程2.1 字段与属性C#给了你两条路你选哪条C#里一个类要暴露数据有两种方式字段和属性。很多初学者觉得两者差不多不就是多写一对get/set吗其实差别非常大。字段是类的内部存储位置它就是一块内存。属性则是带逻辑的字段——表面上你写的是sensor.Temperature 25实际上编译器会把它编译成一个set_Temperature方法的调用。也就是说属性本质上是方法方法就可以加校验、加日志、加通知。public class TemperatureSensor { private double _temperature; // 属性对温度的读写进行控制 public double Temperature { get _temperature; set _temperature value; } }上面这种写法叫自动属性编译后会有编译器自动生成的一个私有字段。如果只是存储自动属性够用。但如果你要控制温度的范围就必须改用显式的属性写法把逻辑写进set访问器。2.2 访问修饰符的权限地图public/protected/internal到底怎么配封装的第一步是决定哪些成员对外开放哪些成员对外隐藏。C#的访问修饰符比很多语言都细修饰符可访问范围典型用途public任何地方都能访问对外API入口private仅当前类内部内部辅助方法、核心数据存储protected当前类及派生类给子类扩展的钩子internal当前程序集内同项目模块之间的协作protected internal当前程序集或派生类跨程序集的子类扩展点private protected当前程序集内的派生类极少数需要严格控制的情况实操中一个很常见的误区所有字段都写成public图省事。等数据被别的地方改坏了再回头排查成本高得多。我的习惯是字段默认全private数据访问全部走属性对外能不给public就不给public先给internal或protected需求明确后再放宽。2.3 属性校验把状态合法的控制权收回到类内部封装最直接的价值就是让别人不能随便把对象搞进非法状态。比如一个上位机项目里的温度传感器物理量程是-40到125摄氏度如果外部代码能直接给温度字段赋1000度整个监控界面都会收到假数据。把set访问器里的校验写进去之后非法赋值在发生时就被拦截了public class TemperatureSensor { private double _temperature; private const double MinValue -40.0; private const double MaxValue 125.0; public double Temperature { get _temperature; set { if (value MinValue || value MaxValue) { throw new ArgumentOutOfRangeException(nameof(value), $温度值必须在{MinValue}~{MaxValue}℃范围内); } _temperature value; } } }这就是封装最典型的实战价值不信任外部代码所有状态变更都要经过类内部的规则检验。你可以在构造函数的入参里校验、在属性set里校验、在方法入参里校验原则只有一个——类的状态永远保持合法。2.4 更进一步的封装索引器、只读成员与不可变对象属性封装单个值索引起封装一组值。C#里的索引器允许你用类似数组下标的方式访问类内部的数据集合public class DataBuffer { private byte[] _buffer new byte[1024]; // 索引器用下标方式读写内部缓冲区 public byte this[int index] { get _buffer[index]; set _buffer[index] value; } public int Length _buffer.Length; }索引器在一些协议解析场景里很常用。比如Modbus报文的寄存器区你有一个DeviceRegister类去封装整段寄存器数据用索引器访问就比暴露一个裸数组要安全得多——你可以顺便做边界检查、字节序转换。再说不可变对象。C#里用readonly修饰字段表示字段只能在构造函数里赋值C# 9之后又加了init访问器属性只能在对象初始化时赋值。这两者都是封装的强化让对象一旦创建就不可变天然线程安全不会有状态被外部篡改的问题。2.5 封装粒度过度封装反而让代码变得难维护封装不是越严越好。我见过有同事把每一个字段都私有然后对所有字段都加上public的get/set属性——这个意义不大因为属性里没有任何逻辑等于透明地暴露了字段。还见过一个类里五个字段属性全是private set结果外部想配置一下必须通过五个构造函数参数传入调用起来又长又难读。封装的本质是把实现细节藏起来把稳定的契约露出来。如果内部没有需要隐藏的秘密强行绕一层就是浪费。判断标准很简单如果某个字段将来可能变化、可能被不合法赋值、可能需要触发通知就用属性封装如果它就是个纯数据容器自动属性甚至public字段也并非不可接受。当然在类库这类要给别人用的公开接口里我还是建议所有对外数据都用属性因为它允许你将来在不破坏调用方的前提下增加逻辑。3. 继承复用还是耦合基类和派生类的关系要这么理3.1 is-a关系什么时候才应该真正使用继承继承是面向对象里复用代码最直观的手段。子类获得基类的字段、属性、方法然后在此基础上扩展。但继承也意味着强耦合——子类一旦继承基类就会绑死到基类的实现细节上。用继承有个经典判断标准只有is-a关系才适合继承。比如Car是VehicleDog是AnimalModbusTcpDevice是DeviceBase。如果不是is-a关系比如我想复用某个类里的方法那应该选择组合而不是继承。public abstract class Animal { public string Name { get; set; } public void Eat() { Console.WriteLine(${Name}正在吃东西); } // 抽象方法子类必须实现 public abstract void Speak(); } public class Dog : Animal { public override void Speak() { Console.WriteLine(汪汪); } } public class Cat : Animal { public override void Speak() { Console.WriteLine(喵喵); } }这个例子里Dog和Cat都是Animal的一种这就是is-a关系。3.2 构造函数顺序基类先跑派生类后跑继承体系里有一个写出很多诡异bug的地方——构造函数的执行顺序。C#规则很明确实例化派生类时先执行基类构造函数再执行派生类构造函数。public class BaseClass { public BaseClass() { Console.WriteLine(基类构造函数); } } public class DerivedClass : BaseClass { public DerivedClass() { Console.WriteLine(派生类构造函数); } }输出顺序是基类构造函数 派生类构造函数这就带来一个后果如果你在基类构造函数里调用了一个虚方法而这个虚方法在派生类里被重写了那么重写的方法会在派生类构造函数还没执行的时候被调用。此时派生类里很多字段都还没初始化最轻的是拿到null或0严重的直接抛异常。这是C#里非常经典的坑后面避坑清单里我会细说。3.3 base的两种用法调用基类构造函数和调用基类成员base关键字在继承里有两种主要用法。第一种是在派生类构造函数里显式调用基类的带参构造函数public class DeviceBase { public string DeviceName { get; } public DeviceBase(string deviceName) { DeviceName deviceName; } } public class ModbusTcpDevice : DeviceBase { public string IpAddress { get; } public ModbusTcpDevice(string deviceName, string ipAddress) : base(deviceName) // 显式调用基类构造函数 { IpAddress ipAddress; } }这种写法很关键。如果基类没有无参构造函数派生类构造函数必须通过: base(...)把参数传上去否则编译器直接报错。第二种用法是在派生类里调用基类已被重写的方法。比如基类有一个模板方法派生类要在基类逻辑前后插入自己的逻辑public class DeviceBase { protected virtual void OnBeforeStart() { } public void Start() { OnBeforeStart(); Console.WriteLine(设备启动中...); } } public class CanDevice : DeviceBase { protected override void OnBeforeStart() { base.OnBeforeStart(); // 先执行基类逻辑 Console.WriteLine(初始化CAN通道...); } }3.4 new隐藏与override重写一字之差行为却完全不同这是C#初学者最容易卡住的地方。假设基类和派生类有签名一样的方法派生类有两种处理方式new隐藏和override重写。它们的运行行为完全不同。new隐藏派生类定义了一个同名的方法但这个方法和基类的方法没有多态关系。通过基类引用调用时执行基类版本通过派生类引用调用时执行派生类版本。override重写派生类真正重写了基类的虚方法通过基类引用调用时执行的是派生类版本。public class BaseClass { public void NormalMethod() { Console.WriteLine(基类普通方法); } public virtual void VirtualMethod() { Console.WriteLine(基类虚方法); } } public class DerivedClass : BaseClass { public new void NormalMethod() { Console.WriteLine(派生类隐藏方法); } public override void VirtualMethod() { Console.WriteLine(派生类重写方法); } }调用测试BaseClass obj new DerivedClass(); obj.NormalMethod(); // 输出基类普通方法new隐藏不产生多态 obj.VirtualMethod(); // 输出派生类重写方法override产生多态new隐藏唯一合理的场景是你确信不需要多态、只是碰巧同名比如GetEnumerator()这种。绝大多数情况下你想让派生类替换基类行为应该用virtualoverride而不是new。3.5 sealed、abstract与继承的可控性继承不是想怎么用就怎么用的C#给了三把控制锁abstract类不能实例化可以含抽象方法没有方法体的方法和普通方法。抽象方法强制子类实现。sealed类不能被继承。典型的如string。sealed方法允许继承但不允许再被进一步重写。public abstract class CommandBase { public abstract void Execute(); // 子类必须实现 public void Log(string message) // 子类直接复用 { Console.WriteLine($[{DateTime.Now:HH:mm:ss}] {message}); } } public sealed class MoveCommand : CommandBase { public override void Execute() { Log(执行移动指令); } }sealed在安全性和可维护性上很有价值。一个类不想被外部滥继承就sealed掉一个方法不想被子类改来改去就sealed掉。很多人不敢用sealed觉得限制自由其实它是对改动范围的精确控制。4. 多态把变化交给运行时的艺术4.1 虚方法的多态方法分派如何从编译期走向运行期多态的意思是同一个类型的引用指向不同实际对象调用同一个方法时表现出不同的行为。底层原理其实不复杂C#的虚方法调用时会查虚方法表vtable根据对象的实际类型找到应该执行的入口。看刚才的例子Animal myAnimal new Dog(); myAnimal.Speak(); // 输出汪汪编译期myAnimal的类型是Animal但运行期它指向的是Dog对象Speak()是被重写过的所以输出汪汪。如果改成Cat输出喵喵。代码完全不用改行为却不同——这就是多态的意义所在调用方依赖抽象具体行为由运行时决定。4.2 抽象类与接口多态契约的两种实现方式C#里建立多态契约有两个工具抽象类和接口。很多人纠结什么时候用抽象类、什么时候用接口我个人的判断标准是这样的抽象类强调的是是什么可以包含具体实现。适合处理一组有公共逻辑、但部分行为需要子类定制的场景。接口强调的是能做什么本质上是一份能力契约。适合定义行为规范让完全不相关的类型也可以有统一的行为入口。// 接口契约 public interface IReportService { string GenerateReport(); } // 抽象类模板 扩展点 public abstract class ReportBase { public void Print() { string content GenerateReport(); Console.WriteLine(content); } protected abstract string GenerateReport(); }在C# 8之前接口里不能有实现C# 8之后接口可以有默认实现让接口和抽象类的边界模糊了一些但设计思想上依然有区别。多数情况下依赖接口是更宽松的耦合因为接口不绑定继承关系任何类都能实现它。4.3 面向抽象编程一个支付场景的三层演进多态的价值用一个支付场景能看得很清楚。第一版没有多态直接写if/elsepublic class PaymentService { public void Pay(string channel, decimal amount) { if (channel alipay) { Console.WriteLine($支付宝支付{amount}元); } else if (channel wechat) { Console.WriteLine($微信支付{amount}元); } else if (channel card) { Console.WriteLine($银行卡支付{amount}元); } } }这个写法的问题很明显每增加一个支付渠道就要修改PaymentService加一个else if。随着渠道增多这个方法越来越臃肿测试也越难写。第二版用接口定义多态public interface IPayment { Task PayAsync(decimal amount); }每个渠道一个实现类public class AlipayPayment : IPayment { public Task PayAsync(decimal amount) { // 调用支付宝SDK return Task.CompletedTask; } }然后调用方只依赖接口public class OrderService { private readonly IPayment _payment; public OrderService(IPayment payment) { _payment payment; } public void Checkout(decimal amount) { _payment.PayAsync(amount); } }第三版再加上一个简单工厂或选择器负责根据配置创建具体实现public class PaymentFactory { public static IPayment Create(string channel) channel switch { alipay new AlipayPayment(), wechat new WechatPayment(), card new CardPayment(), _ throw new NotSupportedException($不支持的支付渠道: {channel}) }; }到这里新增一个支付渠道只需要加一个实现IPayment的类然后改工厂的映射即可OrderService一行都不用动。这就是多态带来的可扩展性。4.4 重载、重写、隐藏三种同名方法的对决很多新手把重载、重写、隐藏混为一谈整理一下它们的关键区别概念关键字是否存在继承关系编译期/运行期典型场景重载Overload无不需要继承编译期绑定同一类中多个方法同名、参数不同重写Overridevirtual override必须有继承运行期绑定派生类替换基类虚方法实现隐藏Hidenew必须有继承看引用类型派生类碰巧与基类方法同名重载很好理解public class Logger { public void Log(string message) { } public void Log(string message, Exception ex) { } }重写和隐藏的区别前面已经讲过了。只要记住一点想实现多态就用override如果用了new就不在多态链路上通过基类引用调不到派生类的方法。5. 事件、泛型、扩展方法C#如何让三大特性继续进化5.1 事件用封装思想实现的发布订阅机制委托和事件是C#里封装思想的延伸。事件本质上是一个受保护的委托字段——外部只能注册处理器或-注销不能直接赋值也不能像方法一样被外部随意触发。这个设计把谁可以在什么时候触发通知的控制权牢牢捏在类内部。public class DeviceMonitor { // 事件对外只暴露订阅接口 public event EventHandlerDeviceStatusChangedEventArgs? StatusChanged; private DeviceStatus _status; public DeviceStatus Status { get _status; private set { if (_status value) return; _status value; // 在类内部触发事件 StatusChanged?.Invoke(this, new DeviceStatusChangedEventArgs(_status)); } } }外部代码这样做订阅var monitor new DeviceMonitor(); monitor.StatusChanged (sender, e) Console.WriteLine($设备状态变为: {e.Status});事件你看似是在用方法注册这个功能实际上它更像封装的变种数据委托链对外不可见只有一组操作/-对外开放。这就是把发布订阅这件事封装起来了。5.2 泛型与继承约束带来的静态多态泛型常被称为静态多态因为它让代码在编译期就确定了要操作的类型同时保持了类型安全。泛型约束where T : ...可以直接限制类型参数必须实现某个接口、继承某个基类或者是无参构造函数。public interface IEntity { int Id { get; } } public class RepositoryT where T : class, IEntity, new() { public void Save(T entity) { // 泛型约束保证T可以New出来 var copy new T(); Console.WriteLine($保存实体{entity.Id}); } }泛型和继承经常配合出现。比如我要写一个缓存服务里面用到对象拷贝就可以用泛型约束where T : class, ICloneable来保证类型安全。这比用object然后运行时强转要稳得多。5.3 扩展方法不继承也能给类塞新方法扩展方法是C#特有的一种语法糖它可以让你在不修改类、不继承类的情况下给一个类增加方法。这在跟第三方库、密封类打交道时特别有用。public static class StringExtensions { public static bool IsNullOrEmpty(this string? value) { return string.IsNullOrEmpty(value); } }调用时就像类自带的方法一样string? input null; if (input.IsNullOrEmpty()) { Console.WriteLine(字符串为空); }想一下热搜词里的uniapp封装h5如何指向2个域名这类问题——映射到C#里如果你的环境配置类被多个模块食用你可以用扩展方法在不同条件下返回不同的配置而不用去改原类。当然这只是其中一种解法扩展方法更常见的场景是工具链式的功能补充。5.4 记录类型为封装与值相等性而生C# 9引入的record类型用极简语法同时实现了封装和值相等性。它让类像值类型一样比较内容而不是比较引用。public record DeviceInfo(string DeviceName, string IpAddress, int Port);这行代码等价于写了一大堆成员构造函数、只读属性、Equals/GetHashCode重写、ToString()等等。开发中用到数据对象时我都优先考虑record而不是手写一堆样板代码。它的with表达式也很方便可以创建基于现有对象的副本并修改某些属性var dev1 new DeviceInfo(PLC-01, 192.168.1.10, 502); var dev2 dev1 with { Port 503 };对于上位机项目里大量使用DTO、配置对象、协议报文头的场景record大幅减少了手写样板代码的量而且天然线程安全——不可变性让并发场景里的麻烦事少了一大半。6. 实战拆解一个多设备上位机通讯框架中的三大特性6.1 需求拆解为什么需要抽象一层设备我做过一个上位机项目现场有Modbus TCP设备、西门子S7 PLC、还有通过CAN总线连接的设备。最开始代码里到处是if (deviceType modbus)这种判断后面越写越乱改一个设备的采集逻辑要翻好几个窗体。后来重构的核心思路就是用面向对象三大特性把每个设备都变成DeviceBase的派生类把调度逻辑抽象成依赖基类。这样UI层只和DeviceBase打交道新增设备不影响已有代码。6.2 设备基类与派生类的完整设计基类设计成这样public abstract class DeviceBase { public string DeviceName { get; } public bool IsConnected { get; protected set; } protected DeviceBase(string deviceName) { DeviceName deviceName; IsConnected false; } // 模板方法启动流程固定细节由子类实现 public void Start() { if (IsConnected) { Console.WriteLine(${DeviceName}已在运行); return; } Connect(); IsConnected true; OnStarted(); Console.WriteLine(${DeviceName}启动完成); } public void Stop() { if (!IsConnected) return; Disconnect(); IsConnected false; Console.WriteLine(${DeviceName}已停止); } public abstract DataPacket ReadData(); protected abstract void Connect(); protected abstract void Disconnect(); protected virtual void OnStarted() { } }一个Modbus TCP设备的实现public class ModbusTcpDevice : DeviceBase { private readonly string _ipAddress; private readonly int _port; private TcpClient? _client; public ModbusTcpDevice(string name, string ipAddress, int port) : base(name) { _ipAddress ipAddress; _port port; } protected override void Connect() { // 实际项目里这里要处理超时、重试等 _client new TcpClient(); _client.Connect(_ipAddress, _port); Console.WriteLine($已连接Modbus设备 {_ipAddress}:{_port}); } protected override void Disconnect() { _client?.Close(); _client null; } public override DataPacket ReadData() { // 读取Modbus寄存器并解析成DataPacket return new DataPacket(DeviceName, 25.5); } }一个CAN设备和一个PLC设备可以去实现各自的Connect/Disconnect/ReadData而基类的Start/Stop模板逻辑被所有设备复用。6.3 通讯调度器的多态调用调度器只依赖基类public class DeviceScheduler { private readonly ListDeviceBase _devices; public DeviceScheduler(IEnumerableDeviceBase devices) { _devices devices.ToList(); } public void StartAll() { foreach (var device in _devices) { device.Start(); } } public void ReportAll() { foreach (var device in _devices) { var packet device.ReadData(); Console.WriteLine($设备: {packet.DeviceName}, 数据: {packet.Data}); } } }这里就看到了多态的威力ListDeviceBase里放的是什么设备调用ReadData()就会执行哪个设备的版本。调用方完全不需要知道设备的具体类型将来新增一个AirconditionDevice调度器一行不改照样工作。从封装的角度看每个设备的连接细节、协议细节都锁在自己的类内部从继承的角度看公共的启动、停止、状态管理逻辑提炼到了基类子类只需要实现协议特有的部分从多态的角度看调度器依赖抽象而不是依赖具体。6.4 压测遇到的问题封装不到位导致的坑这个框架跑了一段时间后现场暴露了一个问题某个设备的ReadData()里抛了异常调度器整个采集循环就崩了。原因是在调度器里没有对异常做隔离因为派生类的异常没有被基类契约明确约束。后来我在基类里给ReadData()加了一个默认的异常捕获逻辑public virtual DataPacket SafeReadData() { try { return ReadData(); } catch (Exception ex) { // 记录日志、标记设备异常等 Console.WriteLine($读取{DeviceName}数据失败: {ex.Message}); return new DataPacket(DeviceName, double.NaN); } }这个例子说明基类作为一个契约提供者不仅要定义方法签名还要明确异常处理策略。封装不能只封装成功的情况还要把失败的处理也划进边界内否则外部每次都要记住哪个设备会抛什么异常——这违背了封装的本意。7. 继承与多态的五个现场翻车点与避坑经验7.1 构造函数中调用虚方法C#允许但危险前面提过基类构造函数执行时派生类构造函数还没执行。如果在基类构造函数里调用了虚方法而虚方法被派生类重写这个重写版本会在派生类字段尚未初始化的时候被调用。public class BaseClass { public BaseClass() { Print(); // 危险调用虚方法 } protected virtual void Print() { Console.WriteLine(基类Print); } } public class DerivedClass : BaseClass { private readonly string _message 初始化好的消息; public DerivedClass() : base() { } protected override void Print() { Console.WriteLine(_message); // 此刻_message可能还是null } }这里很容易踩坑_message的赋值在C#里其实是在构造函数体内执行的。当基类构造函数调用Print()时派生类的字段初始化还没完成拿到的是默认值null。规则很简单不要在构造函数里调用任何可能被重写的虚方法。如果确有需要可以把这段逻辑拆成protected virtual void OnCreated()形式然后明确要求子类不要在该方法里访问未初始化的成员——但最保险的还是不在构造函数里调用虚方法。7.2 覆写Equals不重写GetHashCode字典和HashSet会悄悄出问题C#里如果重写了Equals却没有重写GetHashCode两个内容相等的对象会算出来不同的哈希码。放进Dictionary或HashSet后就出现了明明Equal等于true却查不到的诡异现象。public class Point { public int X { get; } public int Y { get; } public Point(int x, int y) { X x; Y y; } public override bool Equals(object? obj) { return obj is Point other X other.X Y other.Y; } public override int GetHashCode() { return HashCode.Combine(X, Y); } }GetHashCode和Equals的规则是Equals返回true的对象哈希码必须相同。如果重写了Equals就必须同步重写GetHashCode。在C#里更省事的做法是用record类型它自动帮你把这两个方法都搞定。7.3 滥用继承导致脆弱的基类继承用得太多太深会出现一个典型问题基类一改所有派生类跟着遭殃。比如基类加了一个字段所有派生类的构造函数都要跟着传参基类改了某个方法的逻辑某个派生类的重写版本可能就悄无声息地用上了新逻辑行为完全变了。所以我现在的基本态度是优先组合而不是继承。继承层级尽量保持两层最多三层。基类保持精简和稳定尽量多抽象方法、少具体实现。如果只为了复用某个方法考虑扩展方法或组合。组合的方式很简单就是在类里持有另一个类的实例public class DeviceController { private readonly ModbusClient _client new ModbusClient(); public void Start() { _client.Connect(); } }这样耦合关系是显式的不像继承那样隐性地继承一大堆成员。7.4 接口默认实现带来的双刃剑C# 8.0允许接口里写默认实现这让接口越来越像抽象类。好处是给接口增加新方法时不会破坏已有实现坏处是它模糊了契约和实现的边界调用方可能会稀里糊涂地调用到接口默认实现而不是自己期望的具体类版本。从多态的角度看接口默认实现不会自动改变派生类的行为——如果一个类实现了接口但没重写某个默认方法通过接口调用该方法时会执行默认实现通过类引用调用时如果类本身没有定义该方法编译期直接报错。这种同一方法两种入口两种结果的局面正是坑所在。所以我的建议是接口默认实现谨慎使用最好只用来解决版本演进时的兼容性不要用它来写业务逻辑。7.5 值类型在集合里的多态陷阱装箱与拆箱多态通常应用于引用类型但值类型struct也可以实现接口。问题是一旦把值类型赋值给接口类型的变量就会发生装箱产生一个副本。后续对接口变量的任何修改都不会反映到原结构体上。public interface IChangeable { void Change(); } public struct Counter : IChangeable { public int Value; public void Change() { Value; } }这段代码能编译但下面的调用有个隐藏问题var list new ListIChangeable { new Counter() }; list[0].Change(); list[0].Change(); // 每次拿出来的都是新副本值已经变了但GetHashCode引用一样的对象实际上每次都是改了副本再放回去如果对数组成员做接口调用每调一次都会装箱、拆箱。在性能敏感的循环里这个开销不可忽视。所以需要多态行为的类型尽量用class涉及大量调用的简单数据结构用struct时别让它实现接口做多态。最后再分享一个我自己的习惯。现在拿到需求第一件事不是打开IDE写方法体而是先画类图——标出哪些类是稳定的哪些行为是可能变化的哪些地方应该依赖抽象。想清楚之后封装、继承、多态的使用边界也就自然清楚了。写完代码后再回头看看如果哪一天要新增一个需求需要改动的地方多不多。多说明设计有问题少说明三大特性用到位了。这套思路同样适用于你手头的C#项目不管它是上位机、Web服务还是工具类库。