写在前面,本文档是SAST C#组 24级组长撰稿,文档描述非常形象,好评🥰🥰。

什么是面向对象

面向对象是用对象之间的协作来组织程序的思想。

如果把过程式思维比作“写一系列指令操作数据”;那么面向对象则是“先定义会做事的角色,再让角色彼此配合”。

举个例子,如果我们需要在程序中实现“驾驶员开车”的操作,面向过程式的实现方式为:上车、点火、挂挡、踩油门

  • 思路:用数据结构表示车与人,写一组独立函数操作这些数据;在 Main函数中串起步骤。
  • 特点:简单直白,易于一次性任务;一旦出现多车型/新规则,流程将相当复杂且难以维护。

面向对象式的实现方式为:定义汽车、定义驾驶员类、编写驾驶员调用汽车的驾驶方法

  • 思路:把“车”的状态与操作封装在类里,“驾驶员”只需要调用“车”提供的方法。
  • 特点:每个类只需维护自身的逻辑,步骤着眼于“对象之间的交互”。

为什么需要面向对象

在规模小(几十行到一两百行)时没问题。但大型项目里往往不好组织程序的流程,代码的可维护性差。

而面向对象是把软件想成“协作的角色”而不是“流水账的步骤”。

在现实生活中,做一件事的思路不会是“把 A 的名字放进列表,再调用一个函数”,而是:

  • “司机启动汽车”
  • “顾客下单,支付系统处理支付”
  • “播放器读取文件并解码音频”

这些“名词 + 动作”就是面向对象的基本视角:先识别“角色”(对象)再组织“角色协作”(类的方法),而不是流程的堆砌。

如何实现面向对象

类(Class)

类(class)是面向对象的核心,是现实事物的模型。我们使用类来实现对事物的抽象。在这之前,我们需要简单了解“类”这一语法概念。

定义一个 class

简单来讲,我们将一个事物抽象为两部分:状态 + 行为。

状态的语法载体:字段/属性;行为的语法载体:方法/函数。还是让我们以一辆车为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Car
{
// 字段:存储一系列相关信息
public string Brand;
public int Speed;

// 方法:表示能做的事
public void Accelerate(int delta)
{
Speed += delta;
}

public void Brake(int delta)
{
Speed = Math.Max(0, Speed - delta);
}

public string GetInfo()
{
return $"品牌={Brand}, 速度={Speed}";
}
}

现实中,一辆车可能有无数个状态信息,有成百上千种行为。但在上述例子中,我们只考虑它的两个信息:品牌(Brand)与速度(Speed);以及两个行为:加速(Accelerate)与刹车(Brake)。从繁杂的事物中去伪存真,提取出我们所关心的部分并抽象成一个简单清晰的类定义,这一过程也称之为建模——建立现实事物的代码模型。

new 一个对象

我们已经定义了一个 Car类,但要如何创建一辆“真正的”车并对其进行操作呢?也许你已经见过了:使用 new 操作符。在操作之前,让我们谈谈类与对象的关系。

对象,又称实例。实例就是:某个类型(类、结构体、数组、匿名类型等)在程序运行时被创建出来、占用具体内存并携带自己数据的那个“具体对象”。

类型是蓝图(描述结构和行为),实例是根据蓝图制造出来的“实物”。在现实中,我们说“一辆车在行驶”,行驶的是某辆“具体的车”,而非“车”这一概念。在我们的代码中,class Car是一个蓝图,它定义了 Car类型的概念,而我们想拥有一个能够操作的 Car对象,就必须根据蓝图创建一个具体的对象。这就是 new操作符的功能:创建类型对应的实例,也叫做实例化

1
2
3
Car car0 = new Car();
var car1 = new Car();
Car car2 = new(); // 不同的创建实例的方法

构造函数(Constructor)

既然每个 Car实例都有自己的字段,与其在实例化之后通过成员访问操作符逐个修改,为什么不能在创建实例时就指定呢?于是,我们找到了构造函数(Constructor)。构造函数提供了一种便捷的初始化方式。除此之外,我们还可以为字段提供默认值。

对象初始化器(Initializer)

除了使用构造函数,我们还可以通过初始化器来为对象的成员赋值。两者的职责看上去是重复的,那我们为什么还需要初始化器呢?

职责不同(先构造,再配置)。

  • 构造函数:保证对象诞生时就处于“最小有效状态”,建立不变量与必需依赖。
  • 对象初始化器:在构造完成后,用简洁、具名的方式为可写成员赋初值。 执行顺序:先调用构造函数,再按初始化器中的顺序给属性/字段赋值。

避免“构造函数爆炸” :当有大量可选设置时。

  • 用构造函数:要么参数过长/易错,要么堆一堆重载/可选参数,难以维护。
  • 用初始化器:只写需要的那几项,调用点更清晰、稳定;新增可选属性通常无需增加新构造重载。

可读性与可维护性。

  • 具名赋值一眼看出“哪项被设置成了什么”,比长参数列表更不易出错。
  • 演进友好:新增可选属性不会破坏既有调用;而改变构造参数顺序/形态更易引入破坏性变更。

属性 (Property)

字段(Filed)作为存储数据的变量,其本身无法实现对值的校验,这会导致一些意料之外的情况发生。

1
2
var car0 = new Car();
car0.Speed = -1; //速率不可以为负数!

将速率设置为负数并没有违反任何语法规则,但这违反了语义——显然一辆车的速率不可能为负!如果不加以限制,这些可能传入的非法值就可能造成灾难性的后果。我们需要编写一些额外的逻辑来检验值的合法性,一个很自然的想法是通过访问控制符和方法来包装字段。

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
class Car
{
private int Speed = 0; //将字段改为私有,防止外部直接调用

public void SetSpeed(int speed) //通过方法内的逻辑来安全地更改字段
{
if(speed < 0)
{
Console.WriteLine("Too Slow!");
Speed = 0;
}
else if(speed > 100)
{
Console.WriteLine("Too Fast!");
Speed = 100;
}
else
{
Speed = speed;
}
}

public int GetSpeed()
{
return Speed;
}
}

这体现了面向对象中的封装**(encapsulation)**思想,即对类内部的实现方式进行隐藏,对外暴露有限的接口,大大提高了安全性,简化了调用(调用时无需关注类的内部实现)。作为一种编程范式,C#为其实现了专用的类成员——属性(Property)。

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
class Car
{
private int _speed = 0; //根据命名规范,私有字段应该以下划线+小写字母开头

public int Speed
{
get { return _speed; }
set
{
if(value < 0)
{
Console.WriteLine("Too Slow!");
_speed = 0;
}
else if(value > 100)
{
Console.WriteLine("Too Fast!");
_speed = 100;
}
else
{
_speed = value;
}
}
}
}
  • 属性拥有 get访问器与 set/init访问器。
  • 读取属性时,执行 get 访问器的代码块;对属性赋值时,执行 setinit 访问器的代码块。
  • set 访问器类似于返回类型为 void 的方法。 它使用名为 value 的隐式参数,其类型与属性相同。
  • init 访问器只能在构造函数中或通过对象初始化器使用,初始化完毕后,该属性成为只读属性。实际上属性的访问器在编译时等被视作方法,属性本身也属于一种语法糖。

当属性访问器中不需要其他逻辑时,可以使用自动实现的属性来使属性声明更加简洁:

1
public int Speed{ get; set;}    //语法糖上的语法糖

在此情况下,编译器将创建仅可以通过该属性的 getset 访问器访问的专用匿名字段。

当你想要对外暴露某个状态,并希望保留封装、校验、只读/只写控制等,应当使用属性而非公开字段,因为属性实现了对字段的封装,提供了额外的可控性。操作对象时,属性看上去和字段几乎完全一致,使用同样的语法(点操作符)。

方法(Method)

如果说属性定义了对象“是什么”,那么方法就定义对象“做什么”。

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

public void Accelerate(int delta)
{
Speed += delta;
}

public void Brake(int delta)
{
Speed = Math.Max(0, Speed - delta);
}

public string GetInfo()
{
return $"品牌={Brand}, 速度={Speed}";
}
}

这里的 AccelerateBrakeGetInfo就是 Car可以执行的“动作”,也是真正构成操作的部分。让我们定义一个 Driver类来与 Car配合实现“开车”。

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
class Driver
{
public string Name { get; set; }
public Car? CurrentCar { get; private set; }

public void GetIn(Car car)
{
CurrentCar = car;
}

public void Drive()
{
if (CurrentCar is null)
{
Console.WriteLine($"{Name} 还没有车。");
return;
}

CurrentCar.Accelerate(15);
Console.WriteLine($"{Name} 正在开车:{CurrentCar.GetInfo()}");
}

public void Stop()
{
if (CurrentCar is null)
{
Console.WriteLine($"{Name} 还没有车。");
return;
}

CurrentCar.Brake(15);
Console.WriteLine($"{Name} 停下来了:{CurrentCar.GetInfo()}");
}
}

我们为 Driver定义了一个 Car类型的 CurrentCar属性,这意味着 Driver类依赖于 Car类,只有拥有了车,驾驶员才可以驾驶,这在逻辑上完全成立。准备就绪后,我们只需调用 DriveStop方法,就可以完成汽车的启动与停止,而无需关心其内部实现,这再一次体现了封装的重要性。方法就是对复杂逻辑的封装。由此,我们通过对象之间的简单配合,完成了较为复杂的操作。

静态类与静态成员(static)

static关键字修饰时,被修饰的内容不管有没有使用到都会加载进内存,

  • 静态成员(static member):属于类本身而不是某个实例的字段或方法。所有该类的实例共享同一份静态字段;静态方法可以在不创建实例的情况下调用。
  • 静态类(static class):C#支持将整个类声明为静态类,表示类不能被实例化和继承,且只能包含静态成员。