1. 先搞清楚:封装到底在干什么
C#的上位机、C#连接西门子OPC、C#Con rs用串口和UVC摄像头回调区分,这些我都做过,一路走到现在回头看,最绕不开也最容易理解偏的,就是封装这两个字。
很多人学C#,变量、if、for、类、属性都会写了,一讲到封装,脑子里浮现的往往是"把字段设成private,再配上get/set"。对,那确实是封装,但只是最皮毛的一层。我见过太多代码:类倒是有了,字段也私有化了,可整个程序依然是几千行堆在按钮Click事件里,读数据、解析、刷新UI、写日志全挤在一起。那叫"加了属性的静态类集合",不叫封装。
所以我这篇文章不谈语法书上的定义,直接聊我在项目里怎么理解、怎么用封装,尤其是结合上位机开发、串口通信、PLC交互这些真实场景。适合刚看完C#基础语法、想进一步理解面向对象思想的人,也适合那些代码能跑、但一加需求就崩、改一行动全身的开发者。看完你能明白一件事:封装不是把代码塞进类里,而是给代码划边界、定契约、藏变化。
我自己印象最深的一次重构,是做一个多品牌PLC的数据采集网关。一开始图省事,所有品牌、所有协议的逻辑全写在一个类里。新需求来了,要加一个设备型号,结果牵扯出十几个if分支,每次改完都要全量回归。后来拆出接口、把每种协议各自封装、对外只暴露统一的读写方法,改动从一个"爆炸"变成一个类的内部迭代。那次之后我才真正明白:封装不是在装饰代码,而是在保护代码的可维护性。
1.1 封装的本质是"隔离变化"
封装最核心的价值,不是"数据私有",而是"变化隔离"。你要把不稳定的东西关在笼子里,让外面的世界只看到一个稳定的面孔。
怎么理解?打个比方:你去餐厅吃饭,看的是菜单,点的是菜品名,端上来的是做好的菜。你不会关心后厨是明火还是电磁炉、师傅用的是哪把刀。而后厨怎么改造、设备怎么更新,只要菜品口味和上菜速度不受影响,你根本感知不到。
封装同理。对于一段"外部代码"来说,它调用的方法签名、返回值、可能抛出的异常,就是"菜单"。方法内部是查数据库还是读内存,是同步请求还是异步回调,都是"后厨自由度"。只要契约不变,内部怎么折腾都不会影响调用方。
这就是我反复在项目里推的一个原则:职责边界比代码复用更重要。封装的第一任务不是"让多个地方都能用",而是"让每个地方只负责自己该管的"。设备驱动只管收发数据,业务逻辑只管加工数据,UI只管展示数据,日志只管记录数据。边界清楚了,变化才可控。
1.2 封装不是孤军奋战:与继承、多态的分工
封装、继承、多态,C#面向对象三大支柱,经常被放在一起讲,但它们的分工其实很清楚:
- 封装:解决"内部实现"与"外部契约"的关系,让别人用起来安全、省心。
- 继承:解决"共性与个性"的关系,把公共代码放父类,子类扩展差异。
- 多态:解决"同一个调用、不同表现"的关系,让调用方写同一句代码,不同对象给出不同行为。
举个例子:我要封装一个"上位机数据采集器"的公共框架。基础类DeviceBase里放连接、断开、读取抽象方法,这是继承,是把共性捞上来。各个品牌设备各自实现读取细节,这叫多态,是让同一句device.Read()在不同对象上走不同协议。而整个设备对象对外只暴露Connect()、Disconnect()、Read()这几个方法,内部不管是走Modbus TCP还是走S7协议,外部一概不管,这是封装。
三者的关系不是并列选择题,而是层层嵌套。继承和多态建立在封装之上——父类之所以敢规定抽象方法,恰恰是因为每个子类都能把最复杂的协议细节封装在自己内部。
2. 封装的三个落地层级:字段、方法、对象
封装不是一个开关,打开就结束。它是一个从代码到设计逐层递进的过程。我习惯把它拆成三个实操层级,每个层级都能立刻在代码里落地。
2.1 层级一:类的内部封装——从字段+属性开始,但不限于此
第一层是语法层面的封装,核心动作是"私有字段、公开属性(或方法)"。这一步是为了保护数据的完整性和合法性。
很多人会问:属性不就等于get/set吗,跟直接提字段有啥区别?区别在于你在那个set里能加规则:
public class TemperatureSensor { private double _temperature; // 私有字段:外界直接碰不到 public double Temperature { get => _temperature; set { if (value is < -50 or > 150) throw new ArgumentOutOfRangeException(nameof(value), "温度超出传感器有效范围"); _temperature = value; } } public bool IsHighAlarm => _temperature >= 100; // 只读计算属性:外部无权限写 }这里有几个细节值得说:
- 用
get => _temperature; set { ... }表达式字典,简洁,但不该在里面写复杂逻辑时别硬塞。 - 检验合法性放在
set里做,数据一进来就把关,比调用方自己校验强得多。 - 对外只暴露
IsHighAlarm这种只读计算属性,外部能查报警状态,但不能伪造,这个边界就是封装给的安全感。
再往深一层,字段和属性其实还涉及一个"C#里byte、char、byte[]"这些基础类型怎么封装的问题。比如在串口通信里,你读到的很可能是一个byte[]缓冲,如果直接把数组丢出去,调用方就能随意改缓冲里的内容,等于拿走了后厨的菜刀。正确的做法是把byte[]封装在类内部,对外暴露解析好的业务对象,比如double Temp、bool Status。对外的人看到的不是字节,是"温度""状态"。这是封装里很常被忽略的一环:连数据形态都是可封装的。
2.2 层级二:方法级封装——把"一组动作"捆成一个"有名字的意图"
方法级封装比字段多一层思考:不是"把字段设为私有"就完了,而是"把一组会重复出现、容易出错的操作,抽象成一个有业务含义的方法"。
举个例子,做Modbus串口通信。原始操作是什么?打开串口 -> 构造报文 -> 算CRC -> 发指令 -> 接收响应 -> 验证CRC -> 提取数据 -> 关闭或保持。这些步骤如果散落在每个按钮事件里,你会疯掉。封装之后应该是这样:
public class ModbusRtuMaster { private readonly SerialPort _port; public ModbusRtuMaster(string portName, int baudRate, Parity parity, int dataBits, StopBits stopBits) { _port = new SerialPort(portName, baudRate, parity, dataBits, stopBits); } // 对外只暴露一个"读寄存器"的动作 public ushort[] ReadHoldingRegisters(byte slaveId, ushort startAddr, ushort count) { byte[] request = BuildReadRequest(slaveId, startAddr, count); // 私有方法 _port.Write(request, 0, request.Length); // 私有细节 byte[] response = ReadFullResponse(); // 私有方法 ValidateCrc(response); // 私有校验 return ParseRegisters(response); // 私有解析 } private byte[] BuildReadRequest(...) { ... } private byte[] ReadFullResponse() { ... } private void ValidateCrc(byte[] frame) { ... } private ushort[] ParseRegisters(byte[] response) { ... } }看到区别了吗?调用方不需要知道你发了什么报文、CRC怎么算、响应该怎么读。他只要说一句"帮我读这个从站的3号寄存器起始的10个值",剩下的全是类内部的事。这就是方法级封装的核心:把操作序列变成业务意图。
这里有一个我必须提醒的点:别把所有方法都塞到一个类里。我看到有人写"多功能工具类",把所有能想到的方法全塞进去,类越来越大,依赖越来越乱。方法级封装要有判断力,一个类只放一组内聚的操作。ModbusRtuMaster只管Modbus协议和串口读写,不该在里面塞"数据记录到Excel"这种功能。否则下次改记录格式,你又要进协议类里翻代码。
2.3 层级三:对象/组件级封装——接口就是合同
最高一层的封装,是把你整个类当作一个组件,通过接口(interface)定义"合同",调用方只跟接口打交道,不知道你背后是哪个具体实现。这一层解决的是可替换性和松耦合。
接前面西门子OPC那个热词,做上位机常要对接不同设备。西门子走S7协议,三菱走MC协议,Modbus走RTU/TCP。如果让业务代码直接new一个"西门子客户端",将来换品牌,业务代码全要改。正确姿势是定义接口:
public interface IPlcDriver : IDisposable { bool IsConnected { get; } Task ConnectAsync(string ip, int port); Task DisconnectAsync(); Task<object> ReadAsync(string address); // 统一契约:地址是字符串,返回object Task WriteAsync(string address, object value); }然后西门子类、三菱类、Modbus类都去实现这个接口。业务代码里只注入IPlcDriver,拿到一个设备就调用同一套方法。换品牌?把具体实现类换掉就行了,上层代码一行都不用改。这就是"面向接口编程"。封装在这里的意义,是把每个品牌的"差异化"全部锁进各自的实现类里,让变化的洪水不会冲刷到业务层。
我在项目中经常再做一步:用一个DeviceManager或DriverFactory去管理这些具体实例的创建(简单工厂),对外只暴露注册和获取接口的方法。这让"创建谁"的逻辑也封装起来,上层只知道"我要一个能读写的PLC",而不用管"到底读哪个牌子的PLC"。
3. 实战拆解:给西门子PLC做一个可复用的操作封装
这一节我拿一个实际需求完整演示怎么做一个封装。需求:做一个上位机通信层,连接西门子PLC(通过S7协议),支持读写若干个数据块地址,并能在断线后重连。
这类需求在搜"c#连接西门子opc"时经常被问到。注意,这是OPC和S7是两条路——OPC UA/DA是走服务器中转,而S7协议是西门子自己那套点对点通信。我这里演示的是更常见的S7直连方式,用开源的S7.Net Plus库来做,因为工业现场用它有大量生产验证。
3.1 先画边界:什么算对外能力,什么算内部细节
动手写代码前,先在纸上列边界。
对外暴露的能力(稳定契约):
- 连接与断开(带超时和重试)
- 读取任意地址数据(按数据类型)
- 写入任意地址数据
- 连接状态事件(连上了、断了)
- 释放资源(IDisposable)
内部封闭的细节(变化点):
- 具体使用哪种协议库
- 连接超时怎么算
- 重试策略怎么定
- 异常如何归类(超时/协议错/参数错)
这么列完,接口的设计自然就出来了,不会漏,也不会过度设计。
3.2 接口定制:把"变化"关在笼子里
按照上面的边界,我先定制接口,再写实现类:
public interface IS7PlcService : IDisposable { bool IsConnected { get; } event EventHandler<bool> ConnectionChanged; // true=已连接,false=已断开 event EventHandler<string> ErrorOccurred; // 把异常用统一事件抛出去 Task ConnectAsync(string ip, int rack, int slot); Task DisconnectAsync(); Task<short> ReadInt16Async(string dbNumber, int startByteAdr); Task<float> ReadRealAsync(string dbNumber, int startByteAdr); Task WriteInt16Async(string dbNumber, int startByteAdr, short value); Task WriteRealAsync(string dbNumber, int startByteAdr, float value); }我这里特意没有用"读任意类型返回object"的泛化接口,而是按数据类型写不同方法。为什么?因为ReadAsync返回object会让调用方做强制类型转换,这等于把"类型是否匹配"的责任交给外层,一旦读错类型,运行期才暴露,而且报错难排查。按类型分开的方法虽然看着啰嗦,但类型安全是编译期的,调用方想读short就得调ReadInt16Async,想读float就得调ReadRealAsync。
3.3 用事件解耦:封装内部怎么"通知"外部
上位机界面通常要在连接断开时弹提示、或者在后台重连。如果封装内部直接调用UI窗体方法,就等于让底层驱动依赖上层界面,这是封装的大忌。正确做法是用事件,让外部自己订阅:
public class S7PlcService : IS7PlcService { private readonly object _lock = new object(); private Plc _plc; // S7.Net Plus 的核心对象,完全封装在内部 private CancellationTokenSource _cts; public bool IsConnected => _plc?.IsConnected == true; public event EventHandler<bool> ConnectionChanged; public event EventHandler<string> ErrorOccurred; public async Task ConnectAsync(string ip, int rack, int slot) { await DisconnectAsync(); // 先清理旧连接,防止重复连接 var plc = new Plc(CpuType.S1500, ip, rack, slot); plc.Open(); // S7协议建连(内部是TCP 102端口) if (plc.IsConnected) { _plc = plc; ConnectionChanged?.Invoke(this, true); // 事件通知外部 _ = Task.Run(MonitorLoop); // 后台监控连接状态 } else { plc.Close(); throw new TimeoutException($"连接PLC失败,IP={ip}"); } } private async Task MonitorLoop() { _cts = new CancellationTokenSource(); try { while (!_cts.Token.IsCancellationRequested) { await Task.Delay(2000, _cts.Token); if (_plc != null && !_plc.IsConnected) { ConnectionChanged?.Invoke(this, false); // 内部尝试重连,重连成功再发true事件 TryReconnectWithRetry(3); } } } catch (TaskCanceledException) { } } }这段代码有几个容易被初学者忽略的封装细节:
第一,_plc对象彻底私有,外部不可能拿到S7库的原生对象去乱调用。第二,连接变化通过事件抛出,UI层订阅后自己决定是弹窗、变色还是重试。第三,重连策略封装在TryReconnectWithRetry方法里,外部感知不到重试细节,只会看到"断开"和"连上"两个事件。这种"外部只收到结果、不看过程"的设计,才是封装该有的样子。
3.4 给调用方留好"契约说明":XML注释与异常设计
封装不只是写代码,还包含"别人怎么理解你"。我强烈建议给接口方法写完整XML注释,写明参数含义、返回值、可能抛出的异常。这不是废话,是给别人看的第一份简明文档。
/// <summary> /// 读取PLC数据块中的16位有符号整数。 /// </summary> /// <param name="dbNumber">数据块号,如 DB1 就传 1</param> /// <param name="startByteAdr">起始字节偏移,如 0 表示DB块第1个字节</param> /// <returns>读取到的 short 值</returns> /// <exception cref="TimeoutException">连接超时或读取超时</exception> /// <exception cref="InvalidOperationException">未连接时调用读取</exception> public Task<short> ReadInt16Async(string dbNumber, int startByteAdr) { if (_plc == null || !_plc.IsConnected) throw new InvalidOperationException("PLC未连接,请先调用ConnectAsync"); var result = _plc.Read(dbNumber, startByteAdr, VarType.Int, 1); return Task.FromResult((short)result); }这个设计有层用心:把"未连接就操作"这个错误在进入协议栈之前就拦截成InvalidOperationException,调用方立刻明白是自己使用顺序错了。而真正的通信超时则抛TimeoutException。异常语义区分清楚,排查问题快得多。很多人封装失败,就是异常乱抛,给调用方留下一堆"不知道这是什么错"的难题。
4. 封装的进阶节点:委托、事件、异步
基础封装思路理清之后,有几个C#高频点跟封装的化学反应特别大,掌握它们能让封装的"手感"上一个台阶。热词里那串"c#委托、c#泛型委托、c# task的用法"都指向这里。
4.1 委托就是"把方法当作参数传出去"
封装的过程中,你经常需要让外部传入一段行为,而不是让类把所有行为写死。C#里干这事的就是委托(Delegate)。
一个直白场景:上位机要记录每条通信日志,但日志写到文件还是写到数据库?封装层不确定,也不该确定。可以通过委托把"写日志"这个行为交给外部:
public class PlcCommunicationService { private readonly Action<string> _logWriter; // 委托字段 public PlcCommunicationService(Action<string> logWriter) { _logWriter = logWriter ?? Console.WriteLine; // 默认写控制台 } private void Log(string message) { _logWriter($"{DateTime.Now:HH:mm:ss} -> {message}"); } }调用方就能在构造时决定日志去哪:
var service = new PlcCommunicationService(msg => File.AppendAllText("comm.log", msg + Environment.NewLine));这样封装类不依赖具体的文件类库、不依赖UI控件,只依赖一个"能写一句话的方法"。
再往上就是泛型委托Func<T>/Action<T>。区别很简单:Action没返回值,Func有返回值。相比之下,自定义委托更适合可读性强的场景,比如定义public delegate void DataReceivedHandler(byte[] data);,一看就知道是"收到数据了"。我在通信组件里会优先用自定义委托或事件,因为接口语义更清晰。
4.2 事件是把委托"留给外部订阅"
事件和委托的关系是:事件就是"受约束的委托"。封装内部触发,外部多个地方可以同时订阅。事件的关键词是event:
public class UvcCameraService { public event EventHandler<CameraFrameEventArgs> FrameReceived; private void OnFrameReceived(CameraFrame frame, int cameraIndex) { FrameReceived?.Invoke(this, new CameraFrameEventArgs(frame, cameraIndex)); } }热词里有条特别有意思:"c# directshow uvc 回调里区分多个摄像头"。这个场景我处理过。DirectShow的UVC回调基于原生回调机制,在释放进托管C#时,回调里往往只拿到一帧数据,不告诉你它是哪个摄像头来的。如果你用同一套封装处理多个USB摄像头,就会乱了套。
我的解法是:在封装层把"摄像头实例"和"回调管道"一一绑定,每个摄像头对象内部有独立的回调处理器。这样回调事件里所携带的参数,天然就绑定这个摄像头实例。对外再暴露CameraIndex属性或FrameReceived事件,调用方订阅每个摄像头的事件,根本不用在回调里做区分。这就是封装的价值:把"多摄像头区分"这个棘手问题,在内部消化掉,外部看到的就是"这个摄像头来了新帧"。
4.3 异步封装:Task 与取消令牌
上位机UI禁止在界面线程上做耗时通信,所以异步是标配。封装层要支持async/await,同时还要支持取消操作。上面那个MonitorLoop里用了CancellationTokenSource,就是为了一键停止后台循环。热词里的"通过sse流式输出实现大模型回答实时渲染,配合abort"也是同一个思路——大模型回答通过SSE流式输出,前端界面的请求如果被用户取消,配合AbortController或CancellationTokenSource就能中断数据流,不必等它跑到结束。
在封装里做异步要特别注意两个坑:
第一,别在异步方法里封同步调用。有的类库(比如老版S7.Net)只有同步方法,你在async Task方法里直接Task.Run(() => plc.Read(...)),这本身没错,但注意这里并没有真正把"线程阻塞"变成"异步等待",只是换了个线程。如果有真正异步API,优先用异步API。
第二,取消令牌必须传给底层库,而不是只在循环里检查。如果底层库支持CancellationToken,一定要传进去,不然哪怕外层已经取消,底层的TCP读还会继续堵着。
public async Task<byte[]> ReadWithCancelAsync(byte[] request, CancellationToken ct) { await _port.BaseStream.WriteAsync(request, 0, request.Length, ct); ct.ThrowIfCancellationRequested(); // 继续读 ... }这里把CancellationToken一路传到底层流的读写中,整个链路一起停止,不会卡死。
5. 常见问题与排查手册:封装的"售后"经验
封装是写出来的,也是改出来的。这一节我挑几个我在项目中真实踩过、也常被问到的坑,整理成速查式经验。
5.1 "封装之后性能变差了?"
有个很经典的质疑:属性比字段慢吗?方法调用比直接写代码慢吗?泛型和反射是不是会拖垮性能?
我的经验判断按优先级排:
- 属性、方法调用本身是JIT内联的,性能开销可以忽略不计,优先用,不用为这点开销犹豫。
- 泛型是编译期展开的,没有运行期反射开销,比如
List<T>比ArrayList快得多还安全,多用泛型。 - 反射(
PropertyInfo.SetValue那类)确实慢,但这是用来做框架的,不是在每条业务路径上用的。我的原则:反射的锅不要让封装的锅背。如果你发现封装后性能有问题,先去Profile,90%的情况是IO、锁、或网络等待,而不是多了一层方法调用。
5.2 C#调用C++出现 access violation c0000005 怎么办?
热词里那条c#调用c++出现 access violation c0000005很有群众基础。Access Violation在C#里一般在P/Invoke(调用非托管DLL)时候碰到。它的字面意思是"访问了无权访问的内存"。
排查思路按顺序来:
- 先查DllImport的签名是否跟原生导出函数一致。参数类型不匹配是最高频原因,特别是
int和uint、char*和string、结构体的大小差异。 - 查结构体布局。C#结构体默认有对齐和填充,C++那边可能是
#pragma pack(1)紧凑布局,两边要对齐。在结构体上标[StructLayout(LayoutKind.Sequential)]并指定Pack值。 - 查回调。如果你的C++ DLL会异步回调C#委托,一定确认委托对象被引用保存住,否则GC回收后回调时直接崩溃。
- 查内存属性。C#托管数组传给C++后,C++如果写越界,破坏的是托管堆,后果是Access Violation或干脆静默数据损坏。
有个偷懒的调试土办法:在调用DLL的前后打印日志,把入参值、返回码全部记录下来,通常就能定位到哪个参数对不上。C++那边如果有日志开关,打开再跑一遍,比对两边日志能快速缩小范围。
5.3 封装会让代码变"洋葱"?——不要过度封装
封装不足是问题,过度封装同样恶心。过度封装表现为:类套类、接口套接口、工厂套工厂,为了"扩展性"给每个方法都设计一整套抽象。结果阅读代码要跳五层才知道最终在干嘛,改一个逻辑牵扯六个文件。
怎么判断封装是不是过度了?我有个简单标准:如果这个"抽象"目前只有一种实现,那它不配叫接口。一只鸟的时候别急着搞"鸟类接口",等第二只不同的鸟出现再抽象不迟。比如你只连西门子PLC,就别先忙活着IPlcDriver接口和工厂,一个具体类就够。等真要接三菱了,再抽接口是低成本的事——IDE自动重构几分钟就完成。过早抽象才是成本黑洞。
封装的目标是降低"演进成本",不是降低"打字成本"。为看不懂的扩展性提前写抽象,是最容易犯的错。我见过最惨的案例是:一个下载器,为"支持不同存储后端"整出五层接口和六个工厂类,实际只有本地磁盘一个后端,最后没人能维护下去。
5.4 事件和委托的清理:内存泄漏在封装里的高发区
封装里用事件是常态,但如果忘记解除订阅,就会埋下内存泄漏。这是C#里最隐蔽也最常见的泄漏方式。
场景:UI窗体订阅了PlcCommunicationService.ConnectionChanged事件,服务是静态或长期存活的。窗体关了以后,窗体对象还被服务的委托引用着,GC永远不会回收它,每次打开新窗体就多泄漏一份。
解法很简单:谁订阅、谁来退订。在窗体的Dispose或FormClosed事件里写service.ConnectionChanged -= OnConnectionChanged;。另一个更稳的做法是让窗体不要直接订阅长期对象的事件,改成用一个弱引用事件管理器(比如WeakEvent模式),但那通常太重,日常开发先做到"对称订阅"即可。同样道理,Task里如果捕获了UI控件引用,而且这个Task又被长期缓存,也要警惕。
5.5 速查对比:封装设计决策参考
| 你想解决的问题 | 推荐工具 | 不推荐做法 |
|---|---|---|
| 对外隐藏字段并做合法性校验 | 属性 + 私有字段 | 公开字段、四处直接操作 |
| 想让多个调用方共享同一组逻辑 | 静态方法或实例方法 | 把逻辑复制到每个按钮事件 |
| 想隐藏具体品牌/协议差异 | 接口 + 实现类 | 在业务代码里if/switch各种协议 |
| 想让类在特定时刻对外通知 | event 事件 | 方法返回bool然后外层轮询 |
| 想传一段行为给封装内部执行 | Action/Func/自定义委托 | 加bool参数/加字符串操作类型参数 |
| 想让异步操作可取消 | CancellationToken 一路贯传 | 独立线程 + 关不掉的死循环 |
| 想让调用方在构造时配置组件依赖 | 构造函数注入 | 静态管理器 + 全局可变状态 |
这表里的每一行,都是我实际在项目里碰到过的选择时刻。它不是标准答案,但照着走,能让你避开至少80%的封装设计坑。
5.6 关于AI交互逻辑封装的补充参考
热词里还有一条"基于什么技术栈封装ai交互逻辑,通过sse流式输出实现大模型回答实时渲染,配合abort"。这块我简单说一句,因为本质也是一次封装。
技术栈通常是:后端用ASP.NET Core的流式响应(SSE),前端用浏览器或桌面客户端接收。C#里可以用HttpClient配合StreamReader按行读取SSE推送,把读取、解析、渲染彻底分离。封装的核心是:对外暴露一个Task<ChatResult> ChatAsync(string message, CancellationToken ct),内部藏住HttpClient、SSE协议解析、重试逻辑。界面上用的AIBox控件的实时渲染,则是订阅了消息块事件。取消就是CancellationToken传给HttpClient.GetStreamAsync并配合ct.Register(() => httpClient.CancelPendingRequests())。整体架构和二、三章讲的上位机通信封装没有本质区别——划定边界、定义契约、隐藏实现、事件通知、可取消。会了封装的思路,换什么技术栈都一样。
6. 最后再分享两个我一直放在心里的封装准则
写完这整篇,我发现最值得反复琢磨的不是语法,而是两句话:
第一句:封装是给"变化"上保险,不是给"不变"上枷锁。如果你不确定这里会不会变,就先别封;等它变了两次再封,你会封得更准。
第二句:封装判断好不好,不看类内部多整齐,而看调用方有多省心。我重构完成一个组件后,总会站到"调用方"视角重新看一遍接口:需要传的参数会不会太多?异常语义清楚吗?事件命名能不能看懂?如果调用方还需要翻源码才知道怎么用,那封装就没到位。
做上位机、做通信、做AI交互、做业务系统,翻来覆去都在跟封装打交道。这不算什么高深技巧,但它决定了你的代码是"能跑"还是"能长期维护"。我后来带人写代码,第一件事就是让他把封装思路写在设计文档里:对外暴露哪些方法、哪个变化点封在哪个类。等他能把这件事表达清楚了,写出来的代码一般不会太差。希望你读完这一篇,也能在下一个项目里,动手把最让你头疼的那段代码好好封装一次。