C#中分部类和分部方法(partial)
作者:武藤一雄
分部类(Partial Class).cs
这个特性不是用来解决业务逻辑混乱的,而是为了解决机器生成代码与人工编写代码之间的冲突。
核心应用场景
- 自动生成的代码(Source Generators / Designer) 这是最主要的使用场景。当你使用 Entity Framework (DB First)、或者早期的 WinForms/WPF 时,IDE 会根据数据库或 UI 设计图自动生成大量的 C# 代码。
- 痛点:如果你直接在自动生成的类里写逻辑,下次重新生成时,你的代码会被覆盖。
- 方案:机器写一个
partial class放在 A 文件,你写一个partial class放在 B 文件。互不干扰,平安无事。
- 多人协作开发 当一个类(比如一个极其复杂的业务控制器)由于历史原因变得非常庞大时,虽然最好的做法是重构,但在短期内为了避免 Git 合并冲突(Merge Conflict),可以使用分部类让不同的人在不同的文件里工作。
- 代码组织与关注点分离 有时候一个类需要实现多个复杂的接口,或者内部包含大量的常量定义。你可以通过分部类将“接口实现”、“私有成员”、“公共 API”分别放在不同的文件中,提高可读性。

代码示例
假设你有一个用户类,一分部由数据库工具生成,一分部是你自己写的验证逻辑。
文件 1: User.Generated.cs (机器生成,不要动)
public partial class User
{
public int Id { get; set; }
public string Name { get; set; }
}
文件 2: User.Logic.cs (你写的逻辑)
public partial class User
{
public bool Validate()
{
return !string.IsNullOrEmpty(Name);
}
}
在 C# 中,您可以使用 partial 关键字将类、结构、方法或接口的实现拆分到多个 .cs 文件中。编译器在编译程序时会将来自多个 .cs 文件的所有实现组合起来。
考虑以下包含 Employee 类的 EmployeeProps.cs 和 EmployeeMethods.cs 文件。
// EmployeeProps
public partial class Employee
{
public int EmpId { get; set; }
public string Name { get; set; }
}
// EmployeeMethods
public partial class Employee
{
//constructor
public Employee(int id, string name){
this.EmpId = id;
this.Name = name;
}
public void DisplayEmpInfo() {
Console.WriteLine(this.EmpId + " " this.Name);
}
}上面,EmployeeProps.cs 包含 Employee 类的属性,而 EmployeeMethods.cs 包含 Employee 类的所有方法。这些文件将被编译成一个 Employee 类。
示例:组合类
public class Employee
{
public int EmpId { get; set; }
public string Name { get; set; }
public Employee(int id, string name){
this.EmpId = id;
this.Name = name;
}
public void DisplayEmpInfo(){
Console.WriteLine(this.EmpId + " " this.Name );
}
}分部类的规则
- 所有分部类定义必须在相同的程序集和命名空间中。
- 所有分部必须具有相同的可访问性,例如 public 或 private 等。
- 如果任何分部被声明为 abstract、sealed 或基类型,则整个类都被声明为相同的类型。
- 不同的分部可以有不同的基类型,因此最终类将继承所有基类型。
partial修饰符只能紧接在class、struct或interface关键字之前。- 允许嵌套分部类型。
分部方法
分部类还支持分部方法。这就像是一个“钩子(Hook)”。
// 在 A 文件定义钩子
partial void OnDataChanged();
// 在 B 文件实现钩子(如果不实现,编译器会直接删掉这个调用,零性能损耗)
partial void OnDataChanged()
{
Console.WriteLine("数据变了!");
}分部类或结构可以包含一个方法,该方法被拆分到分部类或结构的两个单独的 .cs 文件中。其中一个 .cs 文件必须包含该方法的签名,而另一个文件可以包含分部方法的可选实现。方法的声明和实现都必须具有 partial 关键字。
public partial class Employee
{
public Employee() {
GenerateEmpId();
}
public int EmpId { get; set; }
public string Name { get; set; }
partial void GenerateEmployeeId();
}
public partial class Employee
{
partial void GenerateEmployeeId()
{
this.EmpId = random();
}
}上面,EmployeeProps.cs 包含分部方法 GenerateEmployeeId() 的声明,该方法在构造函数中使用。EmployeeMethods.cs 包含 GenerateEmployeeId() 方法的实现。以下代码演示了如何创建一个使用分部方法的 Employee 类对象。
class Program
{
static void Main(string[] args)
{
var emp = new Employee();
Console.WriteLine(emp.EmpId); // prints genereted id
Console.ReadLine();
}
}分部方法的规则
- 分部方法必须使用
partial关键字,并且必须返回void。 - 分部方法可以有
in或ref参数,但不能有out参数。 - 分部方法是隐式私有方法,因此不能是虚方法。
- 分部方法可以是静态方法。
- 分部方法可以是泛型方法。
简单粗暴地说,分部方法(Partial Method)的意义在于:给机器生成的代码“预留后悔药”,同时不给运行环境增加一丁点负担。
这种设计解决了软件工程中一个经典的矛盾:“自动化生成的代码”与“个性化业务需求”的冲突。
分部方法的意义
核心意义:安全的“挂钩(Hook)”
在大型项目中(尤其是使用 Entity Framework 或 WPF 时),工具会自动生成成千上万行代码。
- 如果不提供分部方法:你想在类初始化时加一行日志,你只能改动生成的文件。一旦数据库表结构变了,你重新生成代码,你写的日志代码瞬间被冲掉。你会陷入“改了丢,丢了再改”的死循环。
- 有了分部方法:机器在生成的代码里到处埋下
partial void OnSomething()。它告诉你:“我在这里留了个口子,你想写逻辑就去另一个文件写,不写也没关系。”
性能极致
这是它和 Virtual 方法或 Event(事件)最大的区别。
- Virtual/Event:即便没人重写方法,即便没有订阅者,程序运行时依然要分配内存、进行跳转判断。
- Partial Method:如果你不写实现,编译器在生成
.dll时会直接抹除这个调用。- 意义:对于需要极高性能的基础底层库,既给了开发者扩展的能力,又保证了不扩展时是“零开销”。
场景对比:为什么不直接定义一个普通方法?
| 方案 | 机器生成代码中的动作 | 开发者动作 | 后果 |
|---|---|---|---|
| 普通方法 | 直接写死逻辑 | 无法干预 | 必须修改生成的文件,重构即地狱。 |
| 虚方法 (Virtual) | 定义 virtual 方法 | override 重写 | 存在虚函数表查询开销,必须实例化对象。 |
| 分部方法 | 声明 partial 方法并调用 | 实现 partial 方法 | 不实现则代码消失,实现则无缝嵌入,两全其美。 |
只要是虚方法(Virtual Method),哪怕你大括号里一个字都不写,它在运行时依然有“身份支出”。
作为老司机,我给你拆解一下这背后的“隐形账单”。
虚方法的“买路钱”:vtable 查找
虚方法的本质是运行时多态。编译器无法在编译时确定你到底要执行哪个方法,所以它得留个心眼。
- vtable(虚函数表):每个包含虚方法的类,在内存里都会多出一张表,记录着方法的地址。
- vptr(虚表指针):每一个该类的实例(对象),头部都会偷偷多占 4 到 8 个字节,专门用来指向这张表。
- 查找开销:每次调用虚方法,CPU 都要玩一次“三级跳”:
- 找到对象的指针。
- 通过指针找到
vtable。 - 在
vtable里查到方法真正的内存地址,最后才跳过去执行。
在 C# 中,虚表(VTable) 是一种底层机制,用于支持面向对象编程中的多态性和虚方法调用。它是一个数据结构,存储了类的虚方法的地址,以便在运行时动态调用正确的方法实现。
哪怕方法体是空的,这套“三级跳”的动作一次都少不了。
无法“内联”的损失
这是性能差距最大的地方。
- 内联(Inlining):JIT 编译器发现一个方法很简单,会直接把代码“平铺”到调用点,省掉函数调用的压栈和出栈。
- 虚方法的阻碍:因为虚方法要到运行时才知道跑哪段逻辑,JIT 编译器通常不敢对其进行内联优化。
性能对比表(Partial & Virtual)
| 特性 | 分部方法 (Partial Method) | 虚方法 (Virtual Method) |
|---|---|---|
| 未实现/空实现时 | 彻底消失。编译器直接把调用代码删了。 | 依然存在。CPU 照样执行跳转指令。 |
| 内存占用 | 0 额外占用。 | 每个对象多出指针空间(4/8 bytes)。 |
| 调用速度 | 极快(直接调用或内联)。 | 较慢(需要查表跳转)。 |
| 灵活性 | 编译时决定,不能跨程序集。 | 运行时决定,支持动态加载插件。 |
什么时候用哪种?
你可以根据这个逻辑来选:
- 用
partial的情况:你写的是底层框架或代码生成工具,你想给开发者留个“可选的开关”,而且对性能有极致追求。如果不写逻辑,你希望这功能像从未存在过一样。 - 用
virtual的情况:你需要真正的多态。比如你写了一个Animal类,你想让Dog和Cat在运行时表现出不同的Eat()行为。
该执行哪一个方法?
答案很简单:你不需要知道,也不需要选。
因为在 C# 中,分部方法(Partial Method)遵循的是**“有且仅有一个实现”**的原则。
编译器眼里的“合并”
在你提供的代码中,编译器会报错,原因是你写了两个实现(方法体 {})。你可以把分部方法的机制理解为:
- 定义端(声明):像是一个“槽位”,告诉编译器:“我这里可能会有一段逻辑”。
- 实现端(逻辑):像是往“槽位”里填东西。
如果你在两个地方都写了 { ... }(哪怕一个是空的 {}),编译器就会像见到两个同名同姓的人一样陷入混乱,直接报错。
正确的执行逻辑
如果你希望在某些情况下输出 “BBB”,在另一些情况下“什么都不做”,你不能靠定义两个方法实现,而是要靠逻辑判断。
正确的写法应该是这样的:
// 【生成代码部分】
public partial class MyClass
{
// 只有声明,没有大括号!这代表“此处预留钩子”
partial void OnOpen();
public void DoWork()
{
OnOpen(); // 编译器在这里调用钩子
}
}
// 【开发者代码部分】
public partial class MyClass
{
// 如果你想执行逻辑,就实现它
partial void OnOpen()
{
if (DateTime.Now.Hour > 12)
{
Console.WriteLine("BBB");
}
// 否则什么都不做(这就是你想要的“空方法”效果)
}
}为什么分部方法不能像普通方法那样“重载”?
分部方法的设计初衷不是为了让你做选择题,而是为了**“无缝注入”**。
- 如果你没写实现:编译器在生成的
.dll里会把OnOpen()彻底抹除。调用它的地方(如DoWork内部)也会被删掉。就像这段代码从未存在过。 - 如果你写了实现:编译器会把实现里的代码“缝”到原本声明调用的地方。
它不存在“调用哪一个”的问题,因为最终编译出来的类里,只有一个 OnOpen。
易混淆
那这样执行的时候,不会冲突么,或者有两个分部类,他会执行哪一个
不会冲突,因为在编译器眼里,它们压根就不是两个类,而是一个类。
你可以把“分部类”想象成一块拼图:源代码阶段你手里拿着几块碎片,但编译完之后,这些碎片已经严丝合缝地粘在一起,变成了一块完整的板子。
为什么不会冲突?
- 物理上的“合体”:当你点击“生成”或“运行”时,C# 编译器(Roslyn)会先扫描所有带
partial关键字的类。如果发现命名空间相同、类名相同的partial类,它会把它们所有的成员(字段、属性、方法、构造函数)全部收集起来,塞进同一个 IL(中间语言)定义里。 - 符号的唯一性:由于它们最终是一个类,所以普通的 C# 规则依然适用。比如,你在 A 文件里定义了
public int Id,就不能在 B 文件里再定义一个public int Id,否则编译器会报错:“类已经包含该成员的定义”。
执行顺序是怎么算的?
既然物理上合二为一了,就不存在“执行哪一个”的问题。程序的入口(比如 Main 方法)调用该类的方法时,该在哪就在哪。

唯一需要注意的顺序:构造函数
如果两个分部类里都写了构造函数,情况如下:
- 普通构造函数:如果你在两个文件里都定义了
public User()(无参构造),编译会报错。因为同一个类里不能有两个签名完全一样的构造函数。 - 执行逻辑:如果你想在不同文件中初始化数据,通常会在 A 文件写构造函数,在构造函数里调用一个在 B 文件实现的分部方法(
partial method)。
避坑指南
虽然编译器帮你合体了,但在开发中要遵循以下守则:
- 同一程序集:所有的分部类必须在同一个程序集(Project)内,不能跨 DLL。
- 修饰符一致:如果 A 文件写了
public partial,B 文件写了internal partial,编译器会直接罢工。访问级别必须完全统一。 - 基类一致:如果 A 继承自
BaseClassA,B 继承自BaseClassB,编译报错。一个类只能有一个亲爹。
同一个成员(方法)在同一个类中不能有多个实现
// 生成代码分部
public partial class MyClass
{
public string Name{get;set;}
partial void OnOpen()
{
Console.WriteLine("AAA");
};
}
// 开发者代码分部
public partial class MyClass
{
public string Age{get;set;}
partial void OnOpen()
{
Console.WriteLine("BBB");
};
}虽然 partial 类可以将代码拆分,但它依然遵循 C# 最基本的语法规则:同一个成员(方法)在同一个类中不能有多个实现。
为什么会报错?
分部方法(Partial Method)的设计初衷是:一个地方定义声明(类似占位符),另一个地方编写实现(可选)。
在你的例子中,两个分部类都写了具体的 { ... } 逻辑块,编译器会认为你对同一个方法定义了两次,直接抛出编译错误:
CS0757: 分部方法声明具有多个实现方法。
正确的用法应该是这样
// ------------------------------------
// 文件 A:生成代码分部(通常由工具生成)
// ------------------------------------
public partial class MyClass
{
public string Name { get; set; }
// 这里只定义“钩子”的签名,不写大括号逻辑
partial void OnOpen();
public void Trigger()
{
OnOpen(); // 编译器在这里埋下调用点
}
}
// ------------------------------------
// 文件 B:开发者代码分部(由你编写)
// ------------------------------------
public partial class MyClass
{
public string Age { get; set; }
// 这里编写具体的业务逻辑
partial void OnOpen()
{
Console.WriteLine("BBB");
}
}执行结果: 调用 Trigger() 方法时,屏幕会输出:BBB。
如果开发者没写代码会怎样?
这是分部方法最聪明的地方。如果你在文件 B 中没有编写 partial void OnOpen() 的实现:
- 编译器会发现这个方法没被实现。
- 为了绝对的性能,编译器会直接删掉文件 A 中的方法声明,并删掉所有对
OnOpen()的调用。 - 程序运行时,就像这个方法从未存在过一样,没有任何性能开销。
专业词汇详解
- 编译时合并 (Compile-time Merging): 意味着
partial只是编译器层面的语法糖。运行时的 CLR(公共语言运行时)根本不知道分部类的存在,它看到的永远是一个完整的类。 - Source Generators (源代码生成器): .NET 5+ 引入的新技术,在代码编译期间实时生成额外的 C# 代码。它极度依赖分部类,因为它不能修改你现有的代码,只能通过
partial注入新功能。 - 零性能损耗 (Zero Performance Overhead): 如果你定义了一个分部方法但没有去实现它,编译器在生成 IL 代码时会彻底移除该方法的声明和所有调用点。
到此这篇关于C#中分部类和分部方法(partial)的文章就介绍到这了,更多相关C#分部类和分部方法内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
