.NET 作为面向对象老资历之一,其生态系统的架构设计也深度依赖于 OOP 范式。.NET 提供了蓝图来规定各个实体的能力、存储方式和行为。

回顾定义

让我们从最基础的概念 —— (Class)对象 (Object) 开始。

当我们在一个面向对象为范式的编程语言中对 抽象 (Abstraction) 的定义进行建模的时候,比如一个学生应当是什么样的,有哪些属性和行为,我们就会定义一个类。类是一个蓝图,描述了对象的结构和行为。

1
2
3
4
5
6
7
8
9
10
class Student
{
public string Name { get; set; }
public int Age { get; set; }

public void Study()
{
Console.WriteLine($"{Name} is studying.");
}
}

也就是说,类本身其实什么也没做,它只是一个模板。我们需要通过实例化 (Instantiation) 这个类来创建一个对象,这个对象才是真正的实体,拥有实际的数据和行为。

1
2
var Misaka = new Student { Name = "Misaka", Age = 14 };
Misaka.Study(); // 输出: Misaka is studying.
「シスターズ」 妹達
「シスターズ」 妹達

三大特性

在抽象之上,OOP 同样提供了三个重要的特性来帮助我们更好地组织和管理代码:封装 (Encapsulation)继承 (Inheritance)多态 (Polymorphism)

C# 本身也随着版本更迭加入了诸如接口 (Interface)仅初始化属性 (Init-only properties) 等特性来丰富 OOP 的表达能力。

封装

每个人心中都有秘密,一些信息可以让谁知道,从嘴里说出来的时候又会变成什么样子,这些都是封装的内容。

比如,我的年龄是19,但是我不希望别人能直接知道这个信息,我只能透露我是不是成年人。

1
2
3
4
5
6
7
8
9
class Person(int age)
{
private int age = age; // 年龄是私有的,外部无法直接访问

public bool IsAdult() // 通过一个公共方法来判断是否是成年人
{
return age >= 18;
}
}

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
2
3
4
5
class Example
{
private int _field; // 字段
public int Property { get; set; } // 属性
}

默认 getset 的属性被称为自动属性 (Auto-implemented Properties)。

我们会发现相比于字段,属性多了一个访问器(getset),这使得我们可以在访问属性时添加额外的逻辑,比如验证输入、触发事件等,从而更好地控制数据的访问和修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Person
{
private int _age; // 私有字段

public int Age // 公共属性
{
get => _age; // 访问器:获取年龄
set
{
if (value < 0) // 验证输入
throw new ArgumentException("Age cannot be negative.");
_age = value; // 设置年龄
}
}
}

在最近 C# 14 中引入的field关键字,让字段的存在感进一步降低,开发者可以直接使用属性来定义数据成员,而不需要显式声明字段。

1
2
3
4
5
6
7
8
9
class Person
{
public int Age {
get;
set => field = (value >= 0)
? value
: throw new ArgumentOutOfRangeException(nameof(value), "Age cannot be negative.");
}
}

即使这样,字段依然是 C# 的基础数据成员类型,属性只是对字段的一层封装,编译器会将属性访问器转换为对字段的访问。

编译器对属性访问器的处理
编译器对属性访问器的处理

命名规范

在 C# 中,字段通常使用下划线前缀(例如 _age)来区分于属性,而属性则使用 PascalCase 命名法(例如 Age)。

对于bool类型的属性,通常会使用 IsHas 前缀来表示一个状态,例如 IsAdultHasLicense,以提高代码的可读性。


getset 可以只保留其中一个,如果只保留 get,则该属性为只读的,只能获取值而不能修改;如果只保留 set,则该属性为只写的,只能设置值而不能获取。

需要注意的是,不要把属性写成方法,因为一个是数据的表达,另一个是行为的表达,混淆了两者会带来严重的设计问题。这在只写属性的情况下尤其明显,因为它会让使用者感到困惑,不知道这个方法是用来获取数据还是设置数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 错误的设计
class PasswordManager
{
private string _password;

public string Password
{
set => _password = value; // 只写属性,设置密码
}

public bool VerifyPassword(string input)
{
return input == _password; // 验证密码的方法
}
}

getset 一样,从不可变性的角度触发,C# 还引入了 init 访问器,表示属性只能在对象初始化时设置一次,之后就变成只读的了。

1
2
3
4
5
6
7
class Person
{
public string Name { get; init; } // 只能在对象初始化时设置
}

var person = new Person { Name = "Alice" };
person.Name = "Bob"; // 无法修改 Name 属性

继承

一个学生先是一个人,然后才是学生。

学生具有人的所有特征和行为,同时还具有学生特有的特征和行为,这就是继承的概念。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Person
{
public string Name { get; set; }
public int Age { get; set; }

public void Eat()
{
Console.WriteLine($"{Name} is eating.");
}
}

class Student : Person // Student 继承自 Person
{
public string School { get; set; }

public void Study()
{
Console.WriteLine($"{Name} is studying at {School}.");
}
}

Inheritance Ability to create new abstractions based on existing abstractions.

继承 (Inheritance) 是指一个类可以基于另一个类创建,新的类称为派生类 (Derived Class)子类 (Subclass),原来的类称为基类 (Base Class)父类 (Superclass)。派生类继承了基类的成员(字段、属性、方法等),并且可以添加新的成员或重写基类的成员来实现特定的行为。

它本质上建立了严格的is-a关系(比如苹果是水果,学生是人)。我们也可以称苹果是水果的特化,学生是人的特化。而在代码中,如果一个方法能够处理水果相关的逻辑,那么它同样能够处理苹果相关的逻辑。


和 C++ 不同,C# 不支持多重继承(一个类只能有一个直接的基类),这样有效避免了恶心的菱形继承问题(Diamond Problem):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Animal {
public:
int weight = 10;
};

// 虎和狮都继承了 Animal
class Tiger : public Animal {};
class Lion : public Animal {};

// 狮虎兽 (Liger) 同时继承了两者
class Liger : public Tiger, public Lion {};

int main() {
Liger liger;
// error: 'weight' is ambiguous
// 你指的是 Tiger 里的 weight,还是 Lion 里的 weight?
std::cout << liger.weight << std::endl;
return 0;
}

继承是有传递性的,如果 Student 继承自 Person,而 GraduateStudent 继承自 Student,那么 GraduateStudent 也会继承自 Person。同样,在 .NET 中,所有的类最终都继承自 System.Object 类,这意味着所有的类都具有 System.Object 类定义的成员(如 ToString(), Equals(), GetHashCode() 等)。

不被继承的

在派生类继承基类时,类的 构造函数 (Constructor)析构函数 (Destructor) 是不会被继承的。每个类都需要定义自己的构造函数来初始化对象的状态,而析构函数则用于在对象被垃圾回收时执行清理操作。

若基类存在有参构造函数,派生类必须显式调用基类的构造函数来确保基类的成员得到正确初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Person
{
public string Name { get; }
public int Age { get; }

public Person(string name, int age)
{
Name = name;
Age = age;
}
}

// 传统写法
class Student : Person
{
public string School { get; }

// 借助 base 关键字调用基类的构造函数
public Student(string name, int age, string school) : base(name, age)
{
School = school;
}
}

// 现代写法
class Student(string name, int age, string school) : Person(name, age)
{
public string School { get; } = school;
}

抽象类

如果说类是对象的蓝图,那么抽象类就是一个不完整的蓝图。

C# 引入了 abstract 关键字来定义抽象类,表示这个类不能被实例化,只能被继承。抽象类可以包含抽象方法(没有实现的方法),派生类必须实现这些抽象方法才能成为具体的类。

1
2
3
4
5
6
7
8
9
10
11
12
abstract class Animal
{
public abstract void MakeSound(); // 抽象方法,没有实现
}

class Dog : Animal
{
public override void MakeSound() // 实现抽象方法
{
Console.WriteLine("Woof!");
}
}
接口与抽象类

最早的时候,开发者对于接口和抽象类的使用其实很明确,接口作为一种契约或是协议,定义了一个类必须实现的成员,而抽象类则提供了一种半成品的实现,允许派生类继承和扩展。

然而,随着 C# 8 引入了默认接口方法 (Default Interface Methods),接口也可以提供方法的默认实现,这使得接口和抽象类之间的界限变得模糊了起来。这一特性的初衷是为了在不破坏现有接口的情况下添加新功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 定义接口
public interface ILogger
{
// 类必须实现它
void Log(string message);

// 默认接口方法
// 现有的实现类(如 ConsoleLogger)不需要修改代码也能直接“继承”这个功能
void LogError(string message)
{
Console.WriteLine($"[ERROR]: {message}");
}
}

// 现有的实现类,只实现了 Log 方法
public class ConsoleLogger : ILogger
{
public void Log(string message)
{
Console.WriteLine($"[INFO]: {message}");
}
}

class Program
{
static void Main()
{
ILogger logger = new ConsoleLogger();

// 调用普通接口方法
logger.Log("这是一个日志消息");

// 调用默认接口方法
// 注意:默认方法只能通过接口变量调用,不能通过类变量调用
logger.LogError("这是一个错误消息");
}
}

这样,类库作者可以在不破坏兼容性的前提下向接口添加新功能,而实现类也可以选择是否覆盖默认实现来提供更具体的行为。但是我们依然应当明确接口和抽象类的设计意图:

  • 接口 主要用于定义一个契约,强调的是能力 (can-do),它告诉我们一个类能做什么,但不关心它是如何实现的。
  • 抽象类 则更强调身份 (is-a),它告诉我们一个类是什么,同时也提供了一些默认的实现,供派生类继承和扩展。

密封类

当然,不是所有类都适合被继承的,如果一个类不希望被继承,可以使用 sealed 关键字来修饰这个类,表示这个类不能被其他类继承。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sealed class FinalClass
{
public void DoSomething()
{
Console.WriteLine("This class cannot be inherited.");
}
}

// 报错 CS0509 'DerivedClass': cannot derive from sealed type 'FinalClass'
class DerivedClass : FinalClass
{
public void DoSomethingElse()
{
Console.WriteLine("This will never compile.");
}
}

组合优于继承

在过去的工程实践中,过度使用继承导致了代码的僵化和难以维护的问题。随着软件设计原则的发展,组合优于继承 (Composition over Inheritance) 的理念逐渐被广泛接受。

深层继承像多米诺骨牌,一个类的改变可能会引发整个继承链的连锁反应,导致大量的代码需要修改和测试。而现在的业务需求很少会完美契合严格的树状 is-a 关系。

依然适合继承

在设计 UI 组件库时,继承仍然是一个非常合适的选择。比如我们有一个 Button 类,继承自 Control 类,这样我们就可以在 Button 类中重用 Control 类的属性和方法,同时还可以添加一些特定于按钮的功能。同样,UI 组件库的扩展性也非常强,新的组件往往会在现有组件的基础上进行扩展,这时候继承就显得非常自然和高效了。

组合 (Composition) 则是将关系从 is-a 转变为 has-a,通过将一个类的实例作为另一个类的成员来实现功能的复用和扩展。这种方式更灵活,可以在运行时动态地改变对象的行为,而不需要修改类的定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Engine
{
public void Start() => Console.WriteLine("Engine started.");
}

class Car
{
private Engine _engine = new Engine(); // 组合关系

public void Start()
{
_engine.Start(); // 使用组合的对象来实现功能
Console.WriteLine("Car started.");
}
}
委托模式

跳出 C#,隔壁 Kotlin 语言借助委托模式 (Delegation Pattern) 来解决了继承带来的问题。它允许组合实现与继承相同的代码复用。

1
2
3
4
5
6
7
8
class Rectangle(val width: Int, val height: Int) {
fun area() = width * height
}

class Window(val bounds: Rectangle) {
// 通过委托来复用 Rectangle 的 area 方法
fun area() = bounds.area()
}

Kotlin 的 by 关键字可以将操作委托给另一个对象的接口。

1
2
3
4
5
6
7
8
9
10
interface ClosedShape {
fun area(): Int
}

class Rectangle(val width: Int, val height: Int) : ClosedShape {
override fun area() = width * height
}

// Window 类通过委托实现 ClosedShape 接口,直接使用 Rectangle 的 area 方法
class Window(private val bounds: Rectangle) : ClosedShape by bounds

多态

不同于另外两个特性,多态 (Polymorphism) 是一个更为抽象的概念,它代表着以不同的形式表现出来的能力。

Polymorph
Polymorph

太抽象了,但是实际的意义其实非常非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Shape
{
public virtual void Draw()
{
Console.WriteLine("Drawing a shape.");
}
}

class Circle : Shape
{
public override void Draw()
{
Console.WriteLine("Drawing a circle.");
}
}

class Square : Shape
{
public override void Draw()
{
Console.WriteLine("Drawing a square.");
}
}

void DrawShape(Shape shape)
{
shape.Draw(); // 多态调用,根据实际类型调用对应的 Draw 方法
}

在这个例子中,DrawShape 方法接受一个 Shape 类型的参数,但它可以接受任何继承自 Shape 的对象(如 CircleSquare)。当我们调用 shape.Draw() 时,实际调用的是对象的运行时类型对应的 Draw 方法,无需编写if-elseswitch 语句来判断对象的类型,这就是多态。

混淆

既然都是重写,那么它和abstract方法的区别是什么呢?抽象方法没有任何实现,派生类必须提供一个实现来覆盖它;而虚方法则有一个默认的实现,派生类可以选择是否覆盖它来提供更具体的行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Base
{
public virtual void Method()
{
Console.WriteLine("Base implementation.");
}
}

class Derived : Base
{
// 重写基类的虚方法,提供新的实现,但不是必须的
public override void Method()
{
Console.WriteLine("Derived implementation.");
}
}

在定义方法的时候,开发者可以和实例化一个对象一样,new一个方法来隐藏基类的方法,这被称为方法隐藏 (Method Hiding),它和重写是不同的概念。方法隐藏使用 new 关键字来声明,表示这个方法将隐藏基类中同名的方法,而不是重写它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Base
{
public void Method()
{
Console.WriteLine("Base implementation.");
}
}

class Derived : Base
{
// 使用 new 关键字隐藏基类的方法,而不是重写它
public new void Method()
{
Console.WriteLine("Derived implementation.");
}
}

override相对,通过new来隐藏方法会使其不再多态,所以会出现以下差异。

1
2
3
4
5
Base baseObj = new Derived();

// 如果是 override,那么调用的是 Derived 的 Method 方法,输出 "Derived implementation."
// 如果是 new,那么调用的是 Base 的 Method 方法,输出 "Base implementation."
baseObj.Method();

在实际使用中,隐藏方法其实并不常见,仅作为诸如防御性的版本控制等特殊场景下的一个工具而存在。大多数情况下,我们更倾向于使用重写来实现多态,因为它更符合面向对象设计的原则。

实现机制

多态的实现机制主要依赖于虚方法表 (Virtual Method Table, VTable),当一个方法被标记为 virtual 时,编译器会在类的内部创建一个虚方法表来存储该方法的地址。

当派生类重写这个方法时,虚方法表会更新为指向新的方法实现,这样在运行时就能够根据对象的实际类型来调用正确的方法了。

为什么在运行时进行而不在编译时进行呢?因为在编译时,编译器只能知道变量的静态类型(如 Shape),而无法确定它在运行时会引用哪个具体的对象(如 CircleSquare)。

因此,只有在运行时才能根据对象的实际类型来决定调用哪个方法实现,这个过程也被称为动态绑定 (Dynamic Binding)后期绑定 (Late Binding)

一个对象的虚方法表会包含其动态绑定方法的地址,同一类的所有对象共享同一个虚方法表,而类型兼容的类(如同一个基类的派生类们)则会共享相同的虚方法表结构

让我们从 C# 层开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Animal
{
public virtual void Speak()
{
Console.WriteLine("Animal speaks");
}
}

public class Dog : Animal
{
public override void Speak()
{
Console.WriteLine("Dog barks");
}
}

Animal myAnimal = new Dog();
myAnimal.Speak(); // 输出: "Dog barks"

透过 IL 代码:

1
2
3
4
IL_0001	newobj	Dog..ctor
IL_0006 stloc.0 // myAnimal
IL_0007 ldloc.0 // myAnimal
IL_0008 callvirt Animal.Speak ()

这里的关键点在于callvirt指令,和call的静态绑定不同,它会对对象调用后期绑定方法,在这里根据 myAnimal 的实际类型来调用正确的 Speak 方法实现。

有关callvirt指令的更多细节,可以参考 Microsoft Learn - callvirt

.NET 的 CLR 通过维护一套底层数据结构来支持这个操作,当一个对象被实例化并在堆上分配内存时,它的内存布局如下:

  • 对象头(Object Header):包含一些运行时信息,如类型指针、同步块索引等。
  • 方法表指针(Method Table Pointer):指向一个方法表,这个方法表包含了该对象所属类型的所有方法的地址。
  • 实例字段(Instance Fields):存储对象的实际数据成员。

这里存储的不是方法表,而是一个指向方法表的指针,因为每个被 CLR 加载的类型都只有一个方法表,而所有该类型的对象都共享这个方法表。通过这个指针,CLR 就能够在运行时根据对象的实际类型来查找并调用正确的方法实现了。

而每个方法表在内存中都为虚方法留有槽位,每个槽位存储着该类某个虚方法在内存中实际的入口地址。

总之,这句 IL 在将这段 IL 转换为机器码时:

  1. 拿到myAnimal指向堆内存的起始地址,获取对象引用
  2. 通过对象的起始地址偏移获取方法表指针
  3. 由于Animal.Speak()在编译期确定了虚方法表中的某个固定槽位
  4. 通过方法表指针加上这个槽位的偏移来获取实际方法的地址(在这里是 Dog.Speak() 的地址)
  5. 调用这个地址来执行方法

由于虚方法在 C# 是需要被显式声明的,所以对于非虚方法,CPU 会 direct call 这个方法的地址,而不是通过虚方法表来调用。这就意味着工程师需要对虚方法的调用过程做优化,这被称为去虚化 (Devirtualization),它是编译器优化的一种技术,通过分析代码来确定某些虚方法调用实际上可以被静态绑定,从而直接调用方法的地址来提高性能。

案例:支付系统

在一个支付系统中,存在一个接口IPaymentProcessor,该接口规定了所有支付网关都必须实现的功能,如ProcessPayment方法。不同的支付网关(如 PayPal、Stripe、Square 等)都实现了这个接口,但它们的具体实现细节可能完全不同。这个设计过程借助了接口而非实现的思路,实现了依赖倒置,与第三方支付网关的耦合度大大降低了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public interface IPaymentProcessor
{
bool ProcessPayment(decimal amount);
}

public class PayPalProcessor : IPaymentProcessor
{
public bool ProcessPayment(decimal amount)
{
// PayPal-specific implementation
return true;
}
}

public class StripeProcessor : IPaymentProcessor
{
public bool ProcessPayment(decimal amount)
{
// Stripe-specific implementation
return true;
}
}

为了处理这些支付业务中的共性逻辑,比如网络重试、全局日志等,我们可以引入一个抽象类BasePaymentGateway,它实现了IPaymentProcessor接口,利用protected访问修饰符来封装了各种 HTTP 请求的辅助方法。这种设计使得底层的基建代码仅对具体的网关子类可见,而不会暴露给外部使用者,从而实现了封装。

在基类中,ProcessPayment方法被定义为抽象方法,作为模板,处理完日志后,调用ExecuteTransactionCore方法来执行具体的支付逻辑,而这个方法则由派生类来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public abstract class BasePaymentGateway : IPaymentProcessor
{
public bool ProcessPayment(decimal amount)
{
// 处理共性逻辑,如日志、重试等
LogPaymentAttempt(amount);

// 调用抽象方法执行具体的支付逻辑
return ExecuteTransactionCore(amount);
}

protected abstract bool ExecuteTransactionCore(decimal amount);

private void LogPaymentAttempt(decimal amount)
{
// 记录支付尝试的日志
}
}

而派生类必须实现ExecuteTransactionCore方法来提供具体的支付逻辑,这样就实现了多态,调用者只需要通过接口来调用ProcessPayment方法,而不需要关心具体的支付网关实现细节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class PayPalGateway : BasePaymentGateway
{
protected override bool ExecuteTransactionCore(decimal amount)
{
// PayPal-specific transaction logic
return true;
}
}

public class StripeGateway : BasePaymentGateway
{
// 同时借助修饰符来封装了 Stripe 的 API 密钥等敏感信息,使得它们只能在 StripeGateway 类内部访问。
private readonly string _apiKey;

protected override bool ExecuteTransactionCore(decimal amount)
{
// Stripe-specific transaction logic
return true;
}
}

在最后,我们可以利用依赖注入 (Dependency Injection) 容器,仅持有一个简单的IPaymentProcessor接口的引用来处理支付业务,而不需要关心具体的支付网关实现,这样就实现了面向接口编程,降低了系统的耦合度,提高了代码的可维护性和扩展性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PaymentService
{
private readonly IPaymentProcessor _paymentProcessor;

public PaymentService(IPaymentProcessor paymentProcessor)
{
_paymentProcessor = paymentProcessor;
}

public void MakePayment(decimal amount)
{
if (_paymentProcessor.ProcessPayment(amount))
{
Console.WriteLine("Payment successful.");
}
else
{
Console.WriteLine("Payment failed.");
}
}
}

总结

.NET 在发展过程中不断引入新的特性来丰富 OOP 的表达能力,如记录、接口默认方法、仅初始化属性等,使得开发者能够更灵活地设计和实现面向对象的系统。开发者们借助封装建立起系统的边界,利用继承来构建概念分类,并通过多态来实现行为抽象,从而构建出高内聚、低耦合、易维护的代码结构。