封装、继承、多态:C#面向对象编程实战精髓解析
2026/9/24 21:11:15 网站建设 项目流程

有一次我帮朋友做技术面试复盘,候选人简历里写着"熟练掌握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"关系才适合继承。比如,CarVehicleDogAnimalModbusTcpDeviceDeviceBase。如果不是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("喵喵"); } }

这个例子里,DogCat都是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()这种。绝大多数情况下,你想让派生类替换基类行为,应该用virtual+override,而不是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/else:

public 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)不需要继承编译期绑定同一类中多个方法同名、参数不同
重写(Override)virtual + override必须有继承运行期绑定派生类替换基类虚方法实现
隐藏(Hide)new必须有继承看引用类型派生类"碰巧"与基类方法同名

重载很好理解:

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 EventHandler<DeviceStatusChangedEventArgs>? 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 Repository<T> 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 List<DeviceBase> _devices; public DeviceScheduler(IEnumerable<DeviceBase> 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}"); } } }

这里就看到了多态的威力:List<DeviceBase>里放的是什么设备,调用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,两个"内容相等"的对象会算出来不同的哈希码。放进DictionaryHashSet后,就出现了"明明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); } }

GetHashCodeEquals的规则是: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 List<IChangeable> { new Counter() }; list[0].Change(); list[0].Change(); // 每次拿出来的都是新副本,值已经变了,但GetHashCode引用一样的对象,实际上每次都是改了副本再放回去

如果对数组成员做接口调用,每调一次都会装箱、拆箱。在性能敏感的循环里,这个开销不可忽视。所以,需要多态行为的类型尽量用class,涉及大量调用的简单数据结构用struct时,别让它实现接口做多态。

最后再分享一个我自己的习惯。现在拿到需求,第一件事不是打开IDE写方法体,而是先画类图——标出哪些类是稳定的,哪些行为是可能变化的,哪些地方应该依赖抽象。想清楚之后,封装、继承、多态的使用边界也就自然清楚了。写完代码后再回头看看,如果哪一天要新增一个需求,需要改动的地方多不多。多,说明设计有问题;少,说明三大特性用到位了。这套思路同样适用于你手头的C#项目,不管它是上位机、Web服务还是工具类库。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询