资讯动态

C# partial 关键字全解析:分部类、分部方法与 C# 13 新特性

发布时间:2026/10/21 9:40:13 来源:尧图企业网站定制
你第一次注意到partial这个关键字多半是从 IDE 自动生成的那堆文件开始的。打开一个窗体项目你能看到两个名字几乎一样的文件一个写着你的业务代码另一个是设计器生成的初始化代码后缀还不一样。两个文件里都声明了同一个类编译器却不报重复定义靠的就是partial。这篇文章把这套机制从头到尾捋一遍partial class、partial struct、partial interface、分部方法再聊到 C# 9.0 对分部方法的增强以及 C# 13 新增的 partial 属性和索引器。适合正在写代码生成器、需要配合设计器工作或者单纯好奇“一个类为什么能劈成两半”的人。1. 分部类到底在解决什么问题1.1 先看懂 IDE 里那对“双胞胎”文件拿一个常见的桌面端窗体项目举例创建完一个窗体后项目里会出现两个文件。一个文件名后缀带.Designer里面是控件布局、初始化组件这种“机器生成”的代码另一个是我们平时写点击事件、业务逻辑的地方。两个文件里都有partial class xxx看着像同一个类重复定义实际上编译器在编译时会把这多个部分合并成一个完整的类。这就是partial最朴素也最核心的用途把同一个类型的定义分散到多个源文件中。设计器要重写生成代码又不想覆盖用户手写的逻辑用 partial class 就能让两边各写各的互不干扰。如果不用这个机制唯一的选择只能是设计器先备份用户的代码、再重新拼回文件这种方案历史上有过不少体验都很糟糕。你甚至可以自己验证合并效果在一个项目中写两个文件各自声明partial class Demo一个放字段一个放方法编译运行后字段和方法都在同一个对象上没有任何区别。编译产物里只存在一个Demo类型源文件层面的拆分只是给人看的运行时感知不到。1.2 合并规则编译器怎么把这些“碎片”拼回去合并不是简单地把文本拼起来而是要遵守一套明确的语言规则。第一条每个部分都必须带partial关键字漏了任何一个都会报错。第二条所有部分必须在同一个程序集、同一个模块中声明跨程序集拆分类是不被允许的这一点后面会重点展开。访问修饰符必须一致比如一个部分是public另一个部分也是public不允许一个是public、一个是internal。基类约束也一样如果某个部分指定了基类其他部分要么不写要么写同一个基类写两个不同的基类会直接编译失败。接口相对宽松不同部分可以重复声明同一个接口也可以分散声明不同的接口最终效果等同于这一个类实现了所有这些接口。成员的合并遵循正常类的规则重载方法可以分布在两个部分里但完全相同签名的成员不能重复出现否则会出现类型成员冲突的编译错误。泛型类型也可以用 partial类型参数、约束条件需要在所有部分保持一致。partial不止适用于类struct和interface同样支持只有枚举和委托不行这两个类型没有“分部声明”的语义。1.3 只能拆文件不能拆程序集许多新手尝试用 partial class 给自己的类库增加成员时会遇到一个非常尴尬的情况报错提示找不到另一部分声明。原因是 partial 的各个部分必须编译进同一个程序集也就是说你无法在另一个项目里通过“再写一个 partial 文件”来扩展一个别人已经编译好的类。这一点在设计上是有意为之。如果允许跨程序集扩展同一个类型的成员就分布在不同的编译单元里加载顺序、版本兼容、类型身份都会变得不可控。partial解决的是“源文件组织”问题不是“运行时扩展”问题。想在程序集外部给一个类型增加行为应该走继承、组合或者扩展方法而不是 partial。很多刚接触的人会把 partial 和“给第三方类加方法”混为一谈其实这两个诉求相差很远。我见过有人在项目里试图用一个partial class给某个框架内置的实体类补充字段折腾半天报错后回来求助排除了半天才发现是对 partial 的边界理解错了。搞清楚这条限制能省下不少排查时间。2. 分部方法一份声明两份契约2.1 C# 3.0 时代的严格限制partial关键字可以修饰类也可以修饰方法。分部方法是 C# 3.0 引入的特性目的是配合代码生成器使用。早期版本对分部方法的要求极其严格返回类型必须为void参数不能使用out不能带任何访问修饰符也不能用virtual、abstract、override这类修饰符方法本身隐式是private。写法上有两个部分。一个部分只写方法签名以分号结尾没有方法体另一个部分提供完整实现也必须带partial关键字。比如在一个生成的文件里有public partial class Order { partial void OnBeforeSave(); }这个声明本身不报错表示“可能存在这样一个扩展点”。然后我们在自己的文件里写public partial class Order { partial void OnBeforeSave() { // 保存前的自定义逻辑 } }这里有整个特性最特别的地方如果一直没有找到实现部分编译器会把所有调用这个方法的语句直接删除。不是报“找不到方法”不是生成一个空方法而是连调用代码一起抹掉。反过来一旦某个地方提供了实现所有调用点就正常保留调用真正的实现方法。这种“删掉”的机制有一个容易被忽略的后果如果调用语句里的参数表达式带有副作用这些副作用也会一起被删。比如OnBeforeSave(PrepareData())当没有实现时PrepareData()根本不会执行。这在调试时会让人非常困惑我后面会单独讲这个坑。2.2 C# 9.0 之后partial 方法终于放开手脚到了 C# 9.0这部分限制大幅放宽。分部方法现在可以返回非void类型可以使用out参数也可以加访问修饰符。只要方法带上了访问修饰符比如public就必须同时提供实现部分否则编译器会直接报错。这意味着原先只能当“无返回值通知钩子”用的分部方法现在能正儿八经参与计算了。举个例子一个订单类里可以这样设计// 文件一生成代码 public partial class Order { public partial decimal CalculateDiscount(decimal amount); } // 文件二手写代码 public partial class Order { public partial decimal CalculateDiscount(decimal amount) { return amount * (IsVip ? 0.2m : 0.05m); } }调用方正常调用CalculateDiscount签名、返回值、参数全都和普通方法一致。分部方法的行为范围一下子扩大了很多从“占位回调”变成了“可按需实现的计算扩展点”。不过有一点没变如果方法没有访问修饰符编译器在找不到实现时仍然会静默移除调用点。只有带访问修饰符的分部方法才强制要求实现。因此在 C# 9.0 之后写代码生成器设计扩展点时要想清楚到底用“有就实现、没有就移除”的轻量钩子还是用“必须实现”的强契约。两种语义差别很大不能混用。2.3 分部方法对比虚方法的取舍很多人会问代码生成器为什么不用virtual方法让用户重写非要弄一个 partial method关键在于性能成本和语义。virtual方法即使派生类不重写调用点也仍然存在运行时还要走一次虚方法分派。对于生成器里每处都可能调用的钩子这会有额外的开销。分部方法在没有实现时调用点彻底消失生成代码可以保持“零成本扩展”这对高频路径很友好。语义上分部方法更像是一种“可选通知”而不是“契约重写”。派生类重写虚方法必须要有一个完整的类继承关系而 partial method 在同一个类内部就能完成“声明”和“实现”的分离。代码生成器想要在生成代码里埋点不希望强迫用户必须写一段空实现分部方法就成了很自然的选择。实际项目中常拿它做生命周期回调比如保存前、加载后、字段变更时。我第一次接触这个模式是在一个 ORM 框架的上下文类里生成器中有一堆partial void OnXxx()的占位符自己业务里需要哪个就实现哪个不需要的完全不产生任何代码干净利落。3. 实战场景分部类用得最舒服的几个地方3.1 设计器代码与手写代码的分界线partial class 最广为人知的场景是设计器。窗体设计器会生成控件的声明、布局、资源初始化代码如果这些代码和用户的点击事件逻辑混在同一个文件里设计器每次调整布局都要小心翼翼地处理用户代码很容易发生覆盖冲突。用两个 partial 文件把“机器生成的部分”和“手写的部分”彻底隔开之后设计器只管重写生成文件用户文件不动彼此不受影响。这也是文件命名规范里常看到.Designer.cs、.g.cs这类后缀的原因看到这些后缀就知道该文件不能手改改了也会被覆盖。这种模式不仅限于桌面窗体像一些前端模板绑定、数据模型脚手架、远程调用客户端这类代码生成工具也普遍采用“生成代码 partial 扩展文件”的结构。要遵守一个不成文的规矩生成文件里的partial类原则上保持“只生成、不手改”用户的定制代码一律放到另一个 partial 文件中并且在文件顶部加注释提醒这是团队协作里相当重要的一条纪律。3.2 实体模型里的扩展钩子很多人没注意自己每天使用的实体类就有 partial 的身影。某个流行的对象关系映射框架生成的数据库实体类通常会设计成 partial class并留下一堆partial void方法。相比手动去改生成的实体文件正确做法是在扩展文件里实现这些钩子。比如一个产品实体生成的代码里有OnNameChanged这样的声明我们就可以在自己的部分里写校验逻辑public partial class Product { partial void OnNameChanged() { if (string.IsNullOrWhiteSpace(Name)) { throw new InvalidOperationException(产品名称不能为空); } } }这样既没有改动生成代码又让每次属性变更时都能执行自定义逻辑。生成器升级时重新生成文件我们的扩展文件不受影响。这个“生成器管生成用户管扩展”的分工模式才是 partial class 和分部方法组合起来最大的价值。3.3 大文件拆分的正确姿势和错误姿势还有一种常见用法一个类文件写了几千行开发者为图方便把它拆成几个 partial 文件。这种做法本身可行但不是最优解。拆文件只是把代码从物理上分开并没有降低类本身的复杂度。如果一个类既处理数据存储又处理权限校验还处理界面展示更合理的做法是拆成多个职责单一的类型而不是改造成“多个文件拼成一个大杂烩”。我认为真正适合拆成 partial 文件的大型类是那些“必须是一个类但各种方法可以从不同关注点管理”的类型。比如一个内部使用的工具类基础方法放一个文件调试辅助方法放一个文件序列化相关的放一个文件每个文件主题清晰查找和维护都能省不少力。还有一种错误姿势是多人协作时每个人各自建一个 partial 文件往同一个类里加功能。表面上看避免了文件合并冲突实际上是在纵容类无限膨胀。版本控制里的行级冲突是真实存在的但用 partial 回避这个问题等于把设计债往后拖。我见过最离谱的情况是一个类被拆成七八个文件里面成员互相引用代码逻辑混乱想找一个字段的定义要翻遍所有文件这比大文件更难受。4. C# 13 新特性partial 属性和 partial 索引器4.1 为什么分部方法出了那么久属性才跟上分部方法从 C# 3.0 就有了但 partial 属性和 partial 索引器直到 C# 13 才正式落地。确实让人等了很多年。原因是语言团队对“属性拆分”的信息完整度要求更高。分部方法在没有实现时可以整段移除调用这一招对属性不太行得通因为属性往往要有明确的值语义编译器不能随意把一个this.Xxx的读取从调用处抹掉。C# 9.0 对分部方法的能力放宽之后设计 partial 属性的基础才逐渐成熟。语言团队最终给出的方案是属性的声明部分和实现部分都需要写partial关键字并且两个部分必须同时存在。这一点和方法有根本区别分部方法可以只有声明没有实现分部属性不行。4.2 partial 属性的语法与配对规则语法上声明部分使用类似自动属性的写法访问器以分号结尾没有方法体。实现部分则写出真正的访问器逻辑。一个简单的例子// 生成代码里只声明 public partial class Invoice { public partial decimal TaxAmount { get; } public partial string DisplayCode { get; set; } } // 手写代码里提供实现 public partial class Invoice { public partial decimal TaxAmount Amount * TaxRate; public partial string DisplayCode { get { return _displayCode; } set { _displayCode value; } } }声明部分里的访问器列表代表了该属性的形状实现部分则提供真正的读取和写入逻辑。两部分必须保持一模一样的属性签名和可访问性不能声明部分是只读、实现部分却写入了 setter更不能一边是public一边是internal。编译器会对这些信息做核对。这个特性解决了一个老问题以前生成器生成属性时想加入验证或者通知逻辑只能选择重新生成整个属性并对属性名做约定或者让用户彻底放弃自动属性去手写全套。现在生成器可以生成属性的“骨架声明”用户在扩展文件里注入真正的读取和写入逻辑。数据绑定场景下生成器负责声明属性开发者实现在属性变更时抛出通知事件代码分离得很干净。4.3 partial 索引器一个实用小例子partial 索引器的规则和 partial 属性一脉相承声明部分与实现部分签名一致且必须分别位于同一个 partial 类型的不同部分中。以二维表格数据的包装类为例// 声明部分 public partial class TableData { public partial int this[int row, int col] { get; set; } } // 实现部分 public partial class TableData { public partial int this[int row, int col] { get { return _cells[row, col]; } set { _cells[row, col] value; OnCellChanged(row, col); } } }索引器被拆成声明和实现之后代码生成器可以专注于生成类的骨架和数据结构用户可以在实现部分自由地做越界检查、数据转换、变更通知不需要去改生成文件。对于网格类控件、报表模型这类需要大量索引访问的场景这个能力相当实用。要提醒一句partial 属性刚推出时很多人会把实现部分也写成自动属性带分号的样子结果编译不过。实际上实现部分必须写出具体的访问器体或者使用表达式体不能再用自动属性的“幽灵访问器”。这是从旧的分部方法习惯带过来的思维惯性我一开始也踩过。5. 避坑指南这些坑我基本都踩过5.1 高频编译错误速查表把这些年见过、踩过的 partial 相关报错整理成一张表基本覆盖了大部分场景错误信息特征根本原因解决办法“missing partial modifier”其中一个部分漏写了partial检查所有声明的文件补上partial“conflicting base classes”两个部分指定了不同的基类统一基类或只在其中一个部分写基类“already contains a definition”两个部分声明了相同签名的成员删除重复定义或改成重载“must have an implementation part because it has accessibility modifiers”带访问修饰符的分部方法缺少实现为该方法补上实现部分“partial property must have an implementation part”partial 属性只有声明没有实现在另一部分补上访问器实现调用一个 declared 方法没有反应方法是整体删除型分部方法未找到实现实现该方法或改用带访问修饰符的方法表格里最后一行尤其值得注意它不会报错只是代码静默失效排查起来最痛苦。5.2 被悄悄删掉的调用一个真实教训某同学在一个业务实体里看到生成的代码写了partial void OnCreated()就在构造函数里调用了它又在自己的扩展文件里实现了OnCreated结果发现调试时日志断点没进来。查了很久才发现扩展文件里写的实现部分拼错了签名参数列表和声明不一致编译器按“未找到实现”处理把这个调用点直接删掉了。因为没有编译错误代码就是看不到任何执行痕迹。从那以后我养成了一个习惯调试 partial method 时先确认实现部分确实存在且签名完全一致再用反编译工具查看程序集里的真实代码。如果调用点消失说明实现没有对上如果调用点还在但没执行到问题在方法体内部。用这个思路排查基本能一步定位问题。这类“静默删除”的行为还容易影响读取状态的操作比如if (TryGetSomething(out var result))这种用法。一旦方法未实现整个if会被吞掉逻辑直接跳过而代码不会报任何错。设计生成器的钩子时如果希望用户必须实现某逻辑请不要用不带访问修饰符的分部方法。5.3 代码组织与团队协作的注意事项partial 类在团队协作中有一组默认约定最好写进项目文档。生成文件使用统一的后缀比如.g.cs或.Designer.cs并在文件头注明“该文件由工具自动生成请勿手动修改”。手写扩展文件采用语义化命名比如订单.校验.cs、订单.事件.cs这样按文件名就能快速定位职责。一个类型的 partial 部分数量不要太多。我的经验是控制在四五个以内超过这个数量编译器虽然不在意但开发者会疯。另外不要在多个 partial 部分中定义次序相关的初始化逻辑C# 不保证不同源文件之间的成员声明顺序依赖“文件按某种排序生成”的程序往往脆弱得不堪一击。团队规范里最好明确一条partial 优先用于和代码生成器配合的场景而不是用于让“大文件看起来更整洁”。这个原则能让代码库的 partial 使用保持在健康范围。如果某个类型在多个场景中反复需要被扩展先考虑是不是应该拆分成多个类型而不要急着给类增加新的部分。最后再分享一个实践经验。你要是自己写代码生成工具生成器里的 partial 类型部分最好只包含生成逻辑不要夹带任何手写片段。生成器每次重新生成时直接覆盖整个文件手写代码留在另一个 part 里这样可以放心地做 N 次重新生成业务逻辑永远安然无恙。这套“生成文件只存机器代码扩展文件只存人逻辑”的思路我用在各种代码生成工具上已经有几年了每次复盘都觉得当初设计 partial 机制的人想得很远。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑