☰
C# partial 关键字全解析:分部类、分部方法与 C# 13 新特性
2026/10/21 9:40:08 网站建设 项目流程

你第一次注意到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 机制的人想得很远。

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

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

立即咨询