ARTICLE DETAIL

资讯详情

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

C#结构体与类、接口与抽象类:值类型与引用类型的本质辨析

C#结构体与类、接口与抽象类:值类型与引用类型的本质辨析 干了这行十多年C# 结构体和类、接口和抽象类这两组概念几乎隔三差五就有人问我一遍。每次面试候选人一到这个问题十有七八能背出“结构体是值类型类是引用类型”但再往深问一句“值类型到底值在哪、引用类型到底引在什么”就卡壳了。真正在项目里写代码时因为没分清这两组东西而埋雷的例子我见过太多。今天不整教科书式的定义就用实际代码和踩坑经验把这两个话题彻底聊透。不管你是刚入门 C#还是写了几年想搞清楚细节这篇文章都值得花十分钟看完。1. 结构体和类之争值类型与引用类型带来的连锁反应要理解结构体和类最根本的区别别急着背“一个在栈上一个在堆上”这种结论。这句话误导了很多人——因为结构体内部如果包含引用类型字段它的数据照样会引用到堆上的对象。真正要搞清楚的是变量里装的到底是什么。1.1 一切从变量里存的是“东西”还是“地址”开始先看两段代码struct Point { public int X; public int Y; } class PointClass { public int X; public int Y; }然后分别这样用Point p1 new Point { X 1, Y 2 }; Point p2 p1; p2.X 100; // p1.X 依然是 1 PointClass pc1 new PointClass { X 1, Y 2 }; PointClass pc2 pc1; pc2.X 100; // pc1.X 也变成了 100这就是最核心的区别p2 p1的时候结构体是把字段值逐项复制了一份两个变量各存各的数据而pc2 pc1复制的是引用地址两个变量指向堆里同一个对象改谁都行对方也能看到。书面化一点说值类型变量本身直接包含数据引用类型变量包含指向数据所在内存的地址。你可以把结构体当成一个“打包好的盒子”赋值就是把整个盒子原封不动地复制一份类则更像“一把钥匙”复制钥匙不代表复制了保险库你和邻居拿着同一把钥匙自然开的是同一个门。这带来的连锁反应非常多。比如函数参数传递给方法传一个结构体方法内部怎么改都不影响原变量传一个类对象方法内部改的是堆里的同一个物体原变量看到的变化是“透明”的。有些人利用这一点搞出一个“污染” bug排查半天最后发现是类对象被不经意间改了数据而结构体变量因为是“按值传递”意外成了保护伞。1.2 结构体数组和类数组性能差距不在“省一个指针”这么简单如果只是内存模型不同问题还不大。真正影响实际性能的是它们在数组/集合中的存储方式。假设有一个Vector3结构体和一个Vector3Class类各有三个 float 字段。定义一个长度为 10000 的数组Vector3[] structArray new Vector3[10000]; Vector3Class[] classArray new Vector3Class[10000];structArray的内存是一整块连续的、按 12 字节3×4字节挨个排布的存储而classArray先分配一个引用类型的数组里面每个元素存的是一个引用地址8 字节这些地址指向堆中分散的对象每个对象还要额外背负对象头等元数据。这意味着几件事内存占用量类数组会有大量额外开销包括数组本身的引用存储、每个对象的对象头、内存对齐的填充。一个纯数据类实际占用的内存经常是结构体的两三倍。遍历速度结构体数组在遍历时CPU 能很好地利用缓存连续地址预取效率极高类数组每个对象分散在堆里缓存命中率低特别是数据量大时差距会被放大。GC 压力类对象创建得多了垃圾回收就要频繁标记、清理结构体数组则基本不给 GC 添麻烦。我之前在做一个点云处理的小工具需要存储几十万个点坐标。一开始图省事全用 class结果一加载就明显感觉卡顿内存占用飙到几百 MB。后来把点数据从改结构体性能立刻好转内存降了一个量级遍历速度快了不止一倍。这不是玄学而是值类型连续存储带来的现实红利。1.3 装箱与拆箱编译器替你偷偷造的垃圾如果你在代码里写过这样的旧式集合ArrayList list new ArrayList(); Point p new Point(1, 2); list.Add(p);注意ArrayList的元素类型是object向里面塞一个结构体会发生装箱——也就是在堆上分配一个新的对象把结构体的数据拷贝进去。取出来的时候又得拆箱再拷贝一次。这中间不仅产生了额外的堆分配还会给 GC 增加压力。这也是泛型集合ListT存在的意义之一ListPoint可以直接存储结构体不产生装箱。在性能敏感的项目里能用ListT就别用ArrayList能用泛型集合就别用非泛型集合这几乎是一条铁律。有些初学者觉着“网上说结构体可以装箱挺方便的”但方便背后是有代价的而且这个代价在循环里会成倍放大。2. 结构体的“可变性陷阱”看起来能改其实改的是副本结构体有一个非常容易让人翻车的问题它的值语义导致某些你想当然的修改操作其实根本改不到原数据甚至直接编译不过。2.1 索引器为什么不能给结构体字段赋值有这样一个常见的写法ListPoint points new ListPoint(); points.Add(new Point { X 1, Y 2 }); points[0].X 10; // 编译报错无法修改“points[0]”的返回值因为它不是变量很多人第一次遇到这个报错时一脸懵“数组下标为什么不能赋值我记得数组可以啊”确实数组可以因为在 C# 里数组元素是体现在运行时内存位置上的可以通过下标直接定位并修改但ListT的索引器是一个属性返回的是T的副本。对于结构体这个副本是一个全新的局部值你修改它没有任何意义因此编译器直接禁止你写。如果想修改List里的结构体怎么办几种常见做法用数组代替ListT只要你确定长度不变。先取出来改再赋回去var p points[0]; p.X 10; points[0] p;把结构体改成类但如果你是为了性能才用的结构体就得不偿失了。在 .NET 6 / C# 10 里可以使用CollectionsMarshal.AsSpan(points)拿到内部数组的首个元素引用然后直接操作但要注意别让列表扩容否则引用就失效了。类对象没有这个问题因为索引器返回的是“可以直接访问对象的地址”你拿到的不是副本而是那个对象本身的触手改X就是在改堆里那个对象。2.2 readonly 修饰与 in 参数C# 7.2 之后如何避免结构体被意外复制如果结构体很大字段特别多又经常作为参数传递那么“按值传递”会造成频繁的内存拷贝性能受损。C# 7.2 起引入了更多的优化手段readonly struct把结构体标记为只读告诉编译器整个结构体不可变所有字段都是只读的。这样在方法调用时可以安全地按只读引用传递不用复制。in参数声明void Foo(in BigStruct data)表示只读引用传递方法内不能修改其字段。但这里有个坑如果结构体是可变mutable的编译器在方法内访问其字段时为了安全起见可能会建立一个“防御性副本”结果反而更慢。所以in搭配readonly struct使用才是正确姿势。ref readonly返回可以从方法返回一个只读引用避免复制大型结构体。我自己在写一个数学计算库时就踩过in参数的坑结构体很大想着用in省拷贝结果因为结构体里字段有非只读的每次访问成员都悄悄多了复制操作性能反而下降。改成readonly struct以后才真正达到预期效果。这个细节告诉我们现代 C# 不是简单的“结构体比类快”而是要配合正确的不可变设计、只读语义才能真正发挥威力。2.3 自定义结构体的正确姿势小而稳定别有“二心”那么什么时候该定义结构体我自己的经验是三个标准它代表一个单一、不可分的值比如坐标、颜色、等钱数实例大小比较小通常建议不超过 16~24 字节它是不可变或近乎不可变的修改时应该生成新值而不是原地改。如果你定义一个结构体里面有一大堆引用类型字段甚至还有继承体系虽然结构体不能继承类但可以实现接口那基本是给自己找麻烦。结构体的复制语义会把引用字段的地址拷贝一遍看起来像“深拷贝”实际上两个结构体实例里的引用字段还是指向同一个对象很容易出现“一个变了另一个也变了”的诡异现象。比较优雅的现代做法是使用readonly record structpublic readonly record struct Money(decimal Amount, string Currency);这个写法同时具备了记录类型的相等性比较、只读特性和结构体的值语义很适合做领域里的值对象。3. 接口与抽象类能力契约与血缘骨架是两种设计语言聊完结构体和类再来看接口和抽象类。很多教程喜欢列一堆表格比如“接口不能有字段抽象类可以有接口不能有构造函数抽象类可以有……”这些表格当然没错但如果只背表格就会变成“知其然而不知其所以然”。关键要理解它们的设计意图完全不同。3.1 什么是“契约”什么是“骨架”从两个需求场景说起抽象类和接口最本质的区别是血缘与能力的分野。抽象类是“它本质上是一种……”。比如你有一个Animal抽象类里面有Eat()、Sleep()等具体实现还有MakeSound()抽象方法。Dog继承Animal是因为狗就是一种动物它天然拥有动物所有的公共特征可以复用那些已经写好的行为逻辑。抽象类表达的是is-a关系是沿着类继承树一路下来的。接口是“它能……”。比如你定义IFlyable接口里面有个Fly()方法。Bird可以实现它Airplane也可以实现它Superman还能实现它。它们彼此毫无血缘关系唯一的共同点是“会飞”。接口表达的是can-do或has-capability它不需要知道实现者是谁。这个差异在业务建模时非常重要。我见过有人为了“让一些不相关的类能统一调用某个方法”硬造出一个抽象基类把所有方法塞进去最后搞得继承树异常臃肿。比如做一个消息系统有 Email、SMS、WeChat 三种消息本来定义IMessageSender接口特别合适结果非要让这三个类都继承一个BaseMessage抽象类把公共属性放进去。表面看起来挺合理但后来加入一个新的“扫码枪触发事件”消息类型时发现它并不想继承BaseMessage里那套业务状态就很难受了。用接口就不会有这个困扰只要实现Send()方法就行。3.2 接口和抽象类的语法差异放到一张表里看虽然说不主张背表格但把关键差异列出来还是方便查阅。以下是一张基于 C# / .NET 8 的对比表对比项抽象类接口关键关系is-acan-do继承数量一个类只能继承一个抽象类一个类可实现多个接口字段可以有实例字段/静态字段C# 8 之前不能有实例字段C# 13 仍不允许实例字段但可以有静态抽象成员构造函数可以有通常 protected不能有构造函数访问修饰符成员可定义不同访问级别成员默认 publicC# 8 后可为成员指定修饰符但仍不允许 private 实现以外的东西方法实现可以提供具体方法的完整实现传统上只能声明C# 8 起可以有默认实现状态可保存状态不携带状态无实例字段扩展时的破环性向抽象类中加一个非虚方法子类基本无感加抽象方法则必须重写给接口加一个新方法不提供默认实现会破坏所有实现类典型用途模板方法、复用基类代码、表达继承链上的共同骨架解除耦合、依赖倒置、定义可组合的行为能力有一处常被误解C# 8 开始接口可以有默认实现方法这让“接口和抽象类的区别”变得没那么泾渭分明。但你要明白接口默认实现的初衷是为了发布 API 时向后兼容避免新增一个抽象方法导致所有实现类编译失败。它并不会让接口变成抽象类的平替——因为接口依然不能有实例字段不能有实例构造函数也不能保存对象状态。如果你发现自己在接口里写了一堆默认实现并且这些实现内部又依赖某些公共逻辑那这几乎是在提醒你这里该用抽象类了。4. 接口和抽象类的选型一个支付流程里我为什么两者都用纸上谈兵没意思我拿一个实际中的支付模块来拆解。假设要做一个电商系统的支付部分里面有多个渠道微信、支付宝、银行卡。渠道差异非常大但它们整体流程都是“创建订单 → 调用支付 → 处理回调 → 更新订单状态”。这个时候接口和抽象类的最好用法是配合使用而不是只选一个。4.1 模板方法用抽象类把业务流程骨架固定住我会先定义一个抽象类作为业务流程的“骨架”public abstract class PaymentFlowBase { // 模板方法固定整体业务流程子类不能重写 public async TaskPaymentResult ExecuteAsync(PaymentRequest request) { ValidateRequest(request); var paymentOrder await CreateOrderAsync(request); var payResult await PayAsync(paymentOrder); await HandleCallbackAsync(payResult); await UpdateOrderAsync(paymentOrder, payResult); return payResult; } protected virtual void ValidateRequest(PaymentRequest request) { // 公共校验逻辑子类可按需重写 } protected abstract TaskPaymentOrder CreateOrderAsync(PaymentRequest request); protected abstract TaskPaymentResult PayAsync(PaymentOrder order); protected abstract Task HandleCallbackAsync(PaymentResult result); protected abstract Task UpdateOrderAsync(PaymentOrder order, PaymentResult result); }这个抽象类承担了三个任务复用公共实现比如校验里判断金额合法、检查订单是否重复。约束流程顺序子类不能破坏“创建订单 → 支付 → 回调 → 更新”的先后顺序。预留扩展点Virtual方法让子类可以微调abstract方法则强制子类必须给出自己的实现。这就是抽象类最典型的用法——模板方法模式。如果你用接口来做这件事每个实现类都得自己再写一遍ValidateRequest里那段公共代码而且没办法强制“流程顺序”接口只约束你“有这四个方法”但不约束你按什么顺序调用这是非常危险的。4.2 扩展点用接口让系统不被具体实现绑死接着每个支付渠道的底层调用是完全不同的。我不会让各渠道实现类再继承一个超级抽象类而是定义一个功能型的接口public interface IPaymentGateway { string ChannelCode { get; } TaskPaymentResult PrepayAsync(PaymentOrder order); TaskPaymentResult QueryAsync(string tradeNo); TaskPaymentResult RefundAsync(RefundRequest request); }然后让微信支付类、支付宝支付类分别实现这个接口。为什么这儿用接口而不是继续继承抽象类渠道之间没有“血缘关系”它们都是“支付网关”这个能力的提供者。一个渠道类很可能还需要实现其他接口比如审计接口IAuditable、缓存接口ICacheable。C# 类只支持单继承如果全用抽象类功能一多就塞不下了。依赖注入的时候我只需要面向IPaymentGateway编程程序集之间可以完全解耦。抽象类虽然也能多态但继承链会把实现类牢牢绑在基类程序集上。// 启动配置里注册 services.AddScopedIPaymentGateway, WeChatPayGateway(); services.AddScopedIPaymentGateway, AlipayGateway();业务层调用private readonly IPaymentGateway _gateway; public async TaskPaymentResult PayAsync(PaymentOrder order) { var gateway _gatewayResolver.Resolve(order.ChannelCode); return await gateway.PrepayAsync(order); }这样整个系统新增一个支付渠道只需要加一个实现IPaymentGateway的类注册一下完事完全不用动现有代码。这就是“对修改关闭、对扩展开放”的开闭原则在实战中的体现。4.3 什么时候该拒绝抽象类我见过最糟糕的抽象树抽象类不是不能用但滥用起来真的能让项目变成灾难。我接手过一个老系统里面有个继承链EntityBase → AggregateRoot → OrderEntity → BaseOrder → RealOrder → OrderServiceImpl看得人头皮发麻。每往上扒一层就能看到一些字段、虚方法被塞进去原因只是为了“让几个类共享这段代码”。结果就是消息传递被破坏、重写逻辑层层叠加、几乎没人能说清某个字段到底在哪个层级被赋值。这种“为了复用而继承”的坏味道根子就在于把抽象类当成了“代码复用工具”。正确的复用方式是组合而不是继承。比如你要一个“可记录创建时间和操作人”的功能应该做一个IAuditable接口加上一个帮助类或扩展方法而不是搞一个AuditEntityBase让所有实体都继承。抽象类只应该用在“这些类确实存在共同骨架并且这个骨架是它们身份的一部分”时比如前面的支付流程。在我参与评审的代码里我通常给团队定两条粗红线如果两个类之间没有“is-a”关系就绝对不要用抽象类。如果只是为了抽取公共方法优先考虑组合、接口加默认工具类而不是搞基类。5. 面试和代码评审里那些容易翻车的辨析点最后聊一些具体的、有版本坑的辨析点这也是面试官最爱的提问角度。别小看这些细节它们往往能反映你是“背过答案”还是“踩过坑”。5.1 结构体能不能写无参构造函数C# 10 之后能但别高兴太早“结构体不能定义无参构造函数”这是老版本的规则。C# 10 开始允许显式声明结构体的无参构造函数了struct Temperature { public int Value; public Temperatre() // 注意这里的方法名要正确故意写Typo再改掉 { Value 25; } }看起来很方便但有个隐藏的坑如果你显式定义了无参构造函数结构体的所有字段必须在这个构造函数里完成赋值而如果没有显式定义默认的default(T)会把所有字段清零0、null、false。这导致很多代码不能简单依赖“new 一定调用构造函数”因为default(Tempature)依然绕过了构造函数直接得到零值结构体。更隐蔽的是性能问题带无参构造的结构体在某些集合初始化场景下可能产生额外开销。我在做代码评审时看到有人因为结构体能写无参构造函数了就在里面做一堆复杂初始化结果在频繁分配结构体的循环里性能急剧下降。结构体的本意是轻量、非多态、纯值语义一旦你依赖于构造函数里的业务逻辑就很容易破坏“default 即有效”的默认约定。这里建议尽量保持结构体简单默认值应该是合法且自洽的。如果你的结构体需要复杂的初始化逻辑那它大概率应该是一个类。5.2 接口默认实现是“银弹”吗我踩过的版本坑C# 8 引入了“默认接口方法”这在库作者发布新版本时确实是很大的进步——不需要破坏所有现有实现类就可以给接口加一个带默认实现的新方法。但我见过有人因此就提倡“接口可以当抽象类用”这是把特性用歪了。默认接口方法有一个现实问题它不像类和抽象类那样有真实的虚拟分派表某些情况下会触发接口调用开销最坑的是如果实现类中没有重写这个方法而调用方持有的是接口引用走的才是默认实现如果持有的是具体类引用它压根看不到默认实现方法——因为这个方法不是类的成员。这导致明明同样的对象用不同引用类型调用同一个方法结果可能完全不同。有次我在排查一个诡异 bug正是某人在接口里提供了一个GetStatus()的默认实现结果他通过具体类调用到的是自己写的方法通过接口调用到的却是默认实现两个结果不一致愣是查了半天。这个教训是默认接口方法可以用于解决 API 兼容性但不要让接口承担业务实现职责。需要共享实现时还是老老实实放抽象类或者静态帮助类里。5.3 抽象类构造顺序和显式接口实现评审高频问题抽象类的构造顺序是个常问问题。比如abstract class Base { protected Base() { Console.WriteLine(Base 构造); VirtualMethod(); } public abstract void VirtualMethod(); } class Derived : Base { private string name hello; public Derived() { Console.WriteLine(Derived 构造); } public override void VirtualMethod() { Console.WriteLine(name); } } new Derived();运行结果是Base 构造、name的值然后Derived 构造。在Base构造函数里调用一个虚方法此时派生类字段name还没被初始化——hello还没赋上去输出可能是null或空字符串。这是很多人容易踩的重型坑不要在构造函数里调用虚成员。如果要调用一定要理解初始化的先后顺序或者设法延迟到派生类完全构造后再触发。显式接口实现也是面试高频点。什么时候会用当两个接口里有同名同签名的成员或者你想隐藏某个实现对调用方可见性时interface IReadable { string Read(); } interface IWritable { void Write(string content); } class FileHandler : IReadable, IWritable { public string Read() read; void IWritable.Write(string content) { } }如果通过FileHandler直接调用Write是编译不过的必须先把对象转为IWritable接口才能调用。这种显式实现常用于“类的主接口更干净辅助接口需要强制转换”的场景。但注意显式接口实现的方法不是类的公开成员用反射或弱接口引用时可能会让人摸不着头脑滥用也会增加调用方的理解成本。我一般只在两个接口确实有命名冲突时才使用。做了这么多年 C#我的体会是面试题也好、日常开发也好结构体与类、接口与抽象类的选择本质上就是在回答两个问题这份数据应该是“值”还是“物体”这个类型应该定义“身份骨架”还是“行为契约”把这两个问题想清楚大部分设计都不会跑偏。团队里再有人拿不准时我总喜欢丢一句话结构体小而不可变类大而可变接口描述能力抽象类描述血缘能力可以有很多血缘只有一个。
返回列表