.NET 作为面向对象老资历之一,其生态系统的架构设计也深度依赖于 OOP 范式。.NET 提供了蓝图来规定各个实体的能力、存储方式和行为。
回顾定义
让我们从最基础的概念 —— 类 (Class) 和 对象 (Object) 开始。
当我们在一个面向对象为范式的编程语言中对 抽象 (Abstraction) 的定义进行建模的时候,比如一个学生应当是什么样的,有哪些属性和行为,我们就会定义一个类。类是一个蓝图,描述了对象的结构和行为。
1 | class Student |
也就是说,类本身其实什么也没做,它只是一个模板。我们需要通过实例化 (Instantiation) 这个类来创建一个对象,这个对象才是真正的实体,拥有实际的数据和行为。
1 | var Misaka = new Student { Name = "Misaka", Age = 14 }; |

三大特性
在抽象之上,OOP 同样提供了三个重要的特性来帮助我们更好地组织和管理代码:封装 (Encapsulation)、继承 (Inheritance) 和 多态 (Polymorphism)。
C# 本身也随着版本更迭加入了诸如接口 (Interface)、仅初始化属性 (Init-only properties) 等特性来丰富 OOP 的表达能力。
封装
每个人心中都有秘密,一些信息可以让谁知道,从嘴里说出来的时候又会变成什么样子,这些都是封装的内容。
比如,我的年龄是19,但是我不希望别人能直接知道这个信息,我只能透露我是不是成年人。
1 | class Person(int age) |
Encapsulation Hiding the internal state and functionality of an object and only allowing access through a public set of functions.
封装 (Encapsulation) 是指将对象的状态(数据)和行为(方法)封装在一起,并通过访问修饰符 (Access Modifiers) 来控制对这些成员的访问。这样可以保护对象的内部状态不被外部直接修改,从而提高代码的安全性和可维护性。
- 状态(数据)通常通过字段 (Fields) 或 属性 (Properties) 来表示。
- 行为(方法)则通过方法 (Methods) 来定义。
- 访问修饰符如
private,public,protected等可以控制成员的可见性。
访问修饰符
为了达到封装的目的,C# 提供了多种访问修饰符来控制类成员的访问权限。以下是一些常见的:
| 修饰符 | 访问权限 | 应用场景 |
|---|---|---|
public | 任何地方都可以访问 | 定义 API 等需要被外部使用的成员 |
internal | 只能在同一程序集内访问 | 构建库内部核心逻辑、辅助方法等 |
protected | 只能在类及其派生类中访问 | 后面的继承部分会详细介绍 |
private | 只能在类内部访问 | 定义类的内部状态,保护数据不被外部直接修改 |
可以构想一个小说里容易见到的家族场景,每个家族是被视为internal的,家族成员之间是protected的,而家族外的人只能通过public的方法来了解这个家族的情况,当然,家族中每个人的秘密则是private的。
默认行为
在 C# 中,如果没有显式指定访问修饰符,类成员默认是 private 的,而类本身则默认是 internal 的。这种防御性的默认设置有助于鼓励开发者在设计类时明确地考虑访问权限,从而更好地实现封装。
当然,有些修饰符是可以组合使用的,也很容易混淆:
| 修饰符组合 | 访问权限 |
|---|---|
protected internal | 只能在同一程序集内或派生类中访问 |
private protected | 只能在同一程序集内的派生类中访问 |
protected internal 通常在大型项目中使用,在允许自身程序集内的调用的同时,开发者也可以通过继承来扩展类的功能。而 private protected 则更为严格,只有在同一程序集内的派生类才能访问,这对于一些需要高度封装的内部实现非常有用。
C# 还引入了 file 访问修饰符,表示成员只能在同一源文件内访问,在进行源生成器 (Source Generators) 开发时非常有用,可以将一些辅助方法或状态隐藏在同一文件中,而不暴露给其他文件。
属性和字段
在现代 C# 最佳实践中,暴露封装数据的标准方法是通过属性 (Properties),而不是直接暴露字段 (Fields)。
1 | class Example |
默认 get 和 set 的属性被称为自动属性 (Auto-implemented Properties)。
我们会发现相比于字段,属性多了一个访问器(get 和 set),这使得我们可以在访问属性时添加额外的逻辑,比如验证输入、触发事件等,从而更好地控制数据的访问和修改。
1 | class Person |
在最近 C# 14 中引入的field关键字,让字段的存在感进一步降低,开发者可以直接使用属性来定义数据成员,而不需要显式声明字段。
1 | class Person |
即使这样,字段依然是 C# 的基础数据成员类型,属性只是对字段的一层封装,编译器会将属性访问器转换为对字段的访问。

命名规范
在 C# 中,字段通常使用下划线前缀(例如 _age)来区分于属性,而属性则使用 PascalCase 命名法(例如 Age)。
对于bool类型的属性,通常会使用 Is 或 Has 前缀来表示一个状态,例如 IsAdult 或 HasLicense,以提高代码的可读性。
get 和 set 可以只保留其中一个,如果只保留 get,则该属性为只读的,只能获取值而不能修改;如果只保留 set,则该属性为只写的,只能设置值而不能获取。
需要注意的是,不要把属性写成方法,因为一个是数据的表达,另一个是行为的表达,混淆了两者会带来严重的设计问题。这在只写属性的情况下尤其明显,因为它会让使用者感到困惑,不知道这个方法是用来获取数据还是设置数据。
1 | // 错误的设计 |
和 get 和 set 一样,从不可变性的角度触发,C# 还引入了 init 访问器,表示属性只能在对象初始化时设置一次,之后就变成只读的了。
1 | class Person |
继承
一个学生先是一个人,然后才是学生。
学生具有人的所有特征和行为,同时还具有学生特有的特征和行为,这就是继承的概念。
1 | class Person |
Inheritance Ability to create new abstractions based on existing abstractions.
继承 (Inheritance) 是指一个类可以基于另一个类创建,新的类称为派生类 (Derived Class) 或 子类 (Subclass),原来的类称为基类 (Base Class) 或 父类 (Superclass)。派生类继承了基类的成员(字段、属性、方法等),并且可以添加新的成员或重写基类的成员来实现特定的行为。
它本质上建立了严格的is-a关系(比如苹果是水果,学生是人)。我们也可以称苹果是水果的特化,学生是人的特化。而在代码中,如果一个方法能够处理水果相关的逻辑,那么它同样能够处理苹果相关的逻辑。
和 C++ 不同,C# 不支持多重继承(一个类只能有一个直接的基类),这样有效避免了恶心的菱形继承问题(Diamond Problem):
1 | class Animal { |
继承是有传递性的,如果 Student 继承自 Person,而 GraduateStudent 继承自 Student,那么 GraduateStudent 也会继承自 Person。同样,在 .NET 中,所有的类最终都继承自 System.Object 类,这意味着所有的类都具有 System.Object 类定义的成员(如 ToString(), Equals(), GetHashCode() 等)。
不被继承的
在派生类继承基类时,类的 构造函数 (Constructor) 和 析构函数 (Destructor) 是不会被继承的。每个类都需要定义自己的构造函数来初始化对象的状态,而析构函数则用于在对象被垃圾回收时执行清理操作。
若基类存在有参构造函数,派生类必须显式调用基类的构造函数来确保基类的成员得到正确初始化。
1 | class Person |
抽象类
如果说类是对象的蓝图,那么抽象类就是一个不完整的蓝图。
C# 引入了 abstract 关键字来定义抽象类,表示这个类不能被实例化,只能被继承。抽象类可以包含抽象方法(没有实现的方法),派生类必须实现这些抽象方法才能成为具体的类。
1 | abstract class Animal |
接口与抽象类
最早的时候,开发者对于接口和抽象类的使用其实很明确,接口作为一种契约或是协议,定义了一个类必须实现的成员,而抽象类则提供了一种半成品的实现,允许派生类继承和扩展。
然而,随着 C# 8 引入了默认接口方法 (Default Interface Methods),接口也可以提供方法的默认实现,这使得接口和抽象类之间的界限变得模糊了起来。这一特性的初衷是为了在不破坏现有接口的情况下添加新功能:
1 | // 定义接口 |
这样,类库作者可以在不破坏兼容性的前提下向接口添加新功能,而实现类也可以选择是否覆盖默认实现来提供更具体的行为。但是我们依然应当明确接口和抽象类的设计意图:
- 接口 主要用于定义一个契约,强调的是能力 (can-do),它告诉我们一个类能做什么,但不关心它是如何实现的。
- 抽象类 则更强调身份 (is-a),它告诉我们一个类是什么,同时也提供了一些默认的实现,供派生类继承和扩展。
密封类
当然,不是所有类都适合被继承的,如果一个类不希望被继承,可以使用 sealed 关键字来修饰这个类,表示这个类不能被其他类继承。
1 | sealed class FinalClass |
组合优于继承
在过去的工程实践中,过度使用继承导致了代码的僵化和难以维护的问题。随着软件设计原则的发展,组合优于继承 (Composition over Inheritance) 的理念逐渐被广泛接受。
深层继承像多米诺骨牌,一个类的改变可能会引发整个继承链的连锁反应,导致大量的代码需要修改和测试。而现在的业务需求很少会完美契合严格的树状 is-a 关系。
依然适合继承
在设计 UI 组件库时,继承仍然是一个非常合适的选择。比如我们有一个 Button 类,继承自 Control 类,这样我们就可以在 Button 类中重用 Control 类的属性和方法,同时还可以添加一些特定于按钮的功能。同样,UI 组件库的扩展性也非常强,新的组件往往会在现有组件的基础上进行扩展,这时候继承就显得非常自然和高效了。
组合 (Composition) 则是将关系从 is-a 转变为 has-a,通过将一个类的实例作为另一个类的成员来实现功能的复用和扩展。这种方式更灵活,可以在运行时动态地改变对象的行为,而不需要修改类的定义。
1 | class Engine |
委托模式
跳出 C#,隔壁 Kotlin 语言借助委托模式 (Delegation Pattern) 来解决了继承带来的问题。它允许组合实现与继承相同的代码复用。
1 | class Rectangle(val width: Int, val height: Int) { |
Kotlin 的 by 关键字可以将操作委托给另一个对象的接口。
1 | interface ClosedShape { |
多态
不同于另外两个特性,多态 (Polymorphism) 是一个更为抽象的概念,它代表着以不同的形式表现出来的能力。

太抽象了,但是实际的意义其实非常非常简单:
1 | class Shape |
在这个例子中,DrawShape 方法接受一个 Shape 类型的参数,但它可以接受任何继承自 Shape 的对象(如 Circle 或 Square)。当我们调用 shape.Draw() 时,实际调用的是对象的运行时类型对应的 Draw 方法,无需编写if-else 或 switch 语句来判断对象的类型,这就是多态。
混淆
既然都是重写,那么它和abstract方法的区别是什么呢?抽象方法没有任何实现,派生类必须提供一个实现来覆盖它;而虚方法则有一个默认的实现,派生类可以选择是否覆盖它来提供更具体的行为。
1 | class Base |
在定义方法的时候,开发者可以和实例化一个对象一样,new一个方法来隐藏基类的方法,这被称为方法隐藏 (Method Hiding),它和重写是不同的概念。方法隐藏使用 new 关键字来声明,表示这个方法将隐藏基类中同名的方法,而不是重写它。
1 | class Base |
和override相对,通过new来隐藏方法会使其不再多态,所以会出现以下差异。
1 | Base baseObj = new Derived(); |
在实际使用中,隐藏方法其实并不常见,仅作为诸如防御性的版本控制等特殊场景下的一个工具而存在。大多数情况下,我们更倾向于使用重写来实现多态,因为它更符合面向对象设计的原则。
实现机制
多态的实现机制主要依赖于虚方法表 (Virtual Method Table, VTable),当一个方法被标记为 virtual 时,编译器会在类的内部创建一个虚方法表来存储该方法的地址。
当派生类重写这个方法时,虚方法表会更新为指向新的方法实现,这样在运行时就能够根据对象的实际类型来调用正确的方法了。
为什么在运行时进行而不在编译时进行呢?因为在编译时,编译器只能知道变量的静态类型(如 Shape),而无法确定它在运行时会引用哪个具体的对象(如 Circle 或 Square)。
因此,只有在运行时才能根据对象的实际类型来决定调用哪个方法实现,这个过程也被称为动态绑定 (Dynamic Binding) 或 后期绑定 (Late Binding)。
一个对象的虚方法表会包含其动态绑定方法的地址,同一类的所有对象共享同一个虚方法表,而类型兼容的类(如同一个基类的派生类们)则会共享相同的虚方法表结构。
让我们从 C# 层开始:
1 | public class Animal |
透过 IL 代码:
1 | IL_0001 newobj Dog..ctor |
这里的关键点在于callvirt指令,和call的静态绑定不同,它会对对象调用后期绑定方法,在这里根据 myAnimal 的实际类型来调用正确的 Speak 方法实现。
有关callvirt指令的更多细节,可以参考 Microsoft Learn - callvirt。
.NET 的 CLR 通过维护一套底层数据结构来支持这个操作,当一个对象被实例化并在堆上分配内存时,它的内存布局如下:
- 对象头(Object Header):包含一些运行时信息,如类型指针、同步块索引等。
- 方法表指针(Method Table Pointer):指向一个方法表,这个方法表包含了该对象所属类型的所有方法的地址。
- 实例字段(Instance Fields):存储对象的实际数据成员。
这里存储的不是方法表,而是一个指向方法表的指针,因为每个被 CLR 加载的类型都只有一个方法表,而所有该类型的对象都共享这个方法表。通过这个指针,CLR 就能够在运行时根据对象的实际类型来查找并调用正确的方法实现了。
而每个方法表在内存中都为虚方法留有槽位,每个槽位存储着该类某个虚方法在内存中实际的入口地址。
总之,这句 IL 在将这段 IL 转换为机器码时:
- 拿到
myAnimal指向堆内存的起始地址,获取对象引用 - 通过对象的起始地址偏移获取方法表指针
- 由于
Animal.Speak()在编译期确定了虚方法表中的某个固定槽位 - 通过方法表指针加上这个槽位的偏移来获取实际方法的地址(在这里是
Dog.Speak()的地址) - 调用这个地址来执行方法
由于虚方法在 C# 是需要被显式声明的,所以对于非虚方法,CPU 会 direct call 这个方法的地址,而不是通过虚方法表来调用。这就意味着工程师需要对虚方法的调用过程做优化,这被称为去虚化 (Devirtualization),它是编译器优化的一种技术,通过分析代码来确定某些虚方法调用实际上可以被静态绑定,从而直接调用方法的地址来提高性能。
案例:支付系统
在一个支付系统中,存在一个接口IPaymentProcessor,该接口规定了所有支付网关都必须实现的功能,如ProcessPayment方法。不同的支付网关(如 PayPal、Stripe、Square 等)都实现了这个接口,但它们的具体实现细节可能完全不同。这个设计过程借助了接口而非实现的思路,实现了依赖倒置,与第三方支付网关的耦合度大大降低了。
1 | public interface IPaymentProcessor |
为了处理这些支付业务中的共性逻辑,比如网络重试、全局日志等,我们可以引入一个抽象类BasePaymentGateway,它实现了IPaymentProcessor接口,利用protected访问修饰符来封装了各种 HTTP 请求的辅助方法。这种设计使得底层的基建代码仅对具体的网关子类可见,而不会暴露给外部使用者,从而实现了封装。
在基类中,ProcessPayment方法被定义为抽象方法,作为模板,处理完日志后,调用ExecuteTransactionCore方法来执行具体的支付逻辑,而这个方法则由派生类来实现。
1 | public abstract class BasePaymentGateway : IPaymentProcessor |
而派生类必须实现ExecuteTransactionCore方法来提供具体的支付逻辑,这样就实现了多态,调用者只需要通过接口来调用ProcessPayment方法,而不需要关心具体的支付网关实现细节。
1 | public class PayPalGateway : BasePaymentGateway |
在最后,我们可以利用依赖注入 (Dependency Injection) 容器,仅持有一个简单的IPaymentProcessor接口的引用来处理支付业务,而不需要关心具体的支付网关实现,这样就实现了面向接口编程,降低了系统的耦合度,提高了代码的可维护性和扩展性。
1 | public class PaymentService |
总结
.NET 在发展过程中不断引入新的特性来丰富 OOP 的表达能力,如记录、接口默认方法、仅初始化属性等,使得开发者能够更灵活地设计和实现面向对象的系统。开发者们借助封装建立起系统的边界,利用继承来构建概念分类,并通过多态来实现行为抽象,从而构建出高内聚、低耦合、易维护的代码结构。