(先决条件:知道 C# 怎么声明数字类型和字符串变量,会调用如 Console.WriteLine() 等方法)

前言:对象的描述

在程序设计中,我们需要给对象建立模型。描述一个对象(也可以理解为实体),我们通常用属性去表示它,比如,我要描述某个叫张三的学生,可以将他表示成:

1
2
3
4
5
6
名字:张三
年龄:20
性别:男
身高:170cm
体重:60kg
体育爱好:打篮球

从语义上来看,我们已经通过上述描述,使用 6 个属性,完成了对“张三”这个对象的建模。

在 C# 中,这个张三可以通过创建一个匿名对象来创建:

1
var student1 = new { Name = "张三", Age = 20, Gender = "男", Height = 170, Weight = 60, Hobby = "打篮球" };

这是一个包含初始化部的声明,等号右边的匿名对象通过关键字 new 和对象初始化器 { 属性名1 = 值1, 属性名2 = 值2, ... } 来创建,然后赋值给 student1 变量,而关键字 var 用作类型推断。匿名对象意味着它并不是某个类型系统的实例。

class :类

如果我们对很多个学生建模,比如约翰、菊次郎等,都用这同样的六个属性,此时,对“学生”这个概念进行建模(不是具体的学生)就有了它的意义。建模思路如下:

“‘学生’这个模型,就是一个具有名字、年龄、性别、身高、体重、体育爱好等属性的类型。而张三、约翰、菊次郎等具体的学生,就是这个模型的一个实例。”

C# 正好提供了“类”这个概念,实现了这样的建模。使用关键字 class 可以声明一个类,并在类体中声明属性,比如:

1
2
3
4
5
6
7
8
9
class Student
{
public string Name { get; set; } // 姓名
public int Age { get; set; } // 年龄
public string Gender { get; set; } // 性别
public double Height { get; set; } // 身高
public double Weight { get; set; } // 体重
public string Hobby { get; set; } // 体育爱好
}

此时,我们成功定义了一个含有 6 个属性的类 Student,它可以用来描述学生。然后只要进行实例化,就可以创建具体的学生对象:

1
2
3
var student1 = new Student { Name = "张三", Age = 20, Gender = "男", Height = 170, Weight = 60, Hobby = "打篮球" };
var student2 = new Student { Name = "约翰", Age = 21, Gender = "男", Height = 165, Weight = 55, Hobby = "乒乓球" };
var student3 = new Student { Name = "菊次郎", Age = 19, Gender = "女", Height = 160, Weight = 50, Hobby = "游泳" };

上述代码就分别把张三、约翰、菊次郎三个学生对象通过对 Student 的实例化,储存为变量名 student1、student2、student3。

你可能对 { get; set; } 表示疑惑。因为类的数据成员可以分为字段(无访问器)和属性(有访问器),而 { get; set; } 就是一种访问器,表示“自动属性”,也就是我们只要通过声明这个属性,就会隐式生成一个字段。

那字段和属性本质上有什么区别?

字段可以说是类“最本质的数据成员”,它是类这个模型本质的数据。姓名、年龄、性别、身高、体重、体育爱好这些数据,都是属于学生对象的本质信息,所以,学生对象一定包含这些字段。

而属性并不是最本质的数据成员,它是根据字段实时计算而获取的。这里所谓“姓名”属性,实际上是对象里面储存了“姓名”字段,然后当我们访问“姓名”属性时,它返回“姓名”字段的值。自动属性的作用就是这样隐式声明了字段。如果不适用自动属性,那么声明“姓名”属性的语法就相当于:

1
2
3
4
5
6
private string _name;
public string Name
{
get { return _name; } // get 访问器
set { _name = value; } // set 访问器
}

不难看出,get 访问器表示访问 Name 这个标识符时,返回代码块里 return 后面的值;而 set 访问器是设置 Name 这个标识符时,执行代码块里的操作,比如给字段赋值。属性可以只有 get 访问器,做只读属性。比如,在有学生有字段“身高”和“体重”的情况下,我们可以声明只读属性“体质指数”:

1
2
3
4
5
6
7
public double BMI
{
get { return Weight / (Height / 100) / (Height / 100); }
}

// 也可以写成
// public double BMI => Weight / (Height / 100) / (Height / 100);

那 private 和 public 关键字有什么用?

private 关键字表示这个成员只能在类内部访问,而 public 关键字表示这个成员可以在类外部(比如控制台应用里面 Program 类的 Main 方法)访问。

方法

除了字段和属性,方法也是类的成员。方法接受输入一些参数(不输入也可以),然后返回一些输出(也可以不返回)。比如,学生可以定义一个“吃饭”方法,传入饭量,不输出内容,但是体重会增加:

1
2
3
4
public void Eat(double amount)
{
Weight += amount * 0.1;
}

静态成员

静态成员是指类级别的成员,它不依赖于实例对象,而是类本身。通俗来说它并不关于对象,反而像把类当成命名空间储存全局变量或方法。比如,在学生类可以定义一个“学生数量”作为静态属性:

1
public static int StudentCount { get; set; } = 0;

这样,我们就可以通过 Student.StudentCount 来获取学生数量,而不需要实例化学生对象。

顺带提一句,常用的 Console.WriteLine() 方法就是 System.Console 类的静态方法。

构造函数

构造函数在类体中用 类名(参数列表) 声明,在实例化时执行。比如给 Student 类可以添加一个这样的构造函数:

1
2
3
4
5
6
7
8
9
10
public Student(string name, int age, string gender, double height, double weight, string hobby)
{
Name = name;
Age = age;
Gender = gender;
Height = height;
Weight = weight;
Hobby = hobby;
StudentCount++;
}

在主程序中,只要调用:

1
2
3
4
Student student1 = new Student("张三", 20, "男", 170, 60, "打篮球");  // 免去对象初始化器

// C# 12 开始可以这样写
// Student student1 = new("张三", 20, "男", 170, 60, "打篮球");

类还支持方法重载、继承、多态这些功能,这里就不详细讲了。

struct :结构体

结构体类似于类,可以通过声明字段、属性和方法进行建模。但是,结构体不支持继承和多态。此外,类的实例属于引用类型,而结构体的实例属于值类型(后面会解释)。

“体质”这种抽象指标就很适合用结构体建模:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct Physique
{
public double Height { get; set; }
public double Weight { get; set; }
public double BMI { get { return Weight / (Height / 100) / (Height / 100); } }
public Physique(double height, double weight)
{
Height = height;
Weight = weight;
}
}

// 它的实例可以用作 Student 的属性
class Student
{
public string Name { get; set; }
public int Age { get; set; }
public string Gender { get; set; }
public Physique Physique { get; set; }
public string Hobby { get; set; }
}

如何理解 “类的实例属于引用类型,而结构体的实例属于值类型” ?

在 C# 中,所有 值类型 都隐式继承自 System.ValueType(其本身继承自 System.Object),而所有 引用类型 都直接或间接继承自 System.Object。

值类型和引用类型的核心区别,并不在于“栈”与“堆”的简单对立,而在于变量存储的是否是数据本身,还是对数据的引用。

因此,引用类型变量是对象的引用,而值类型变量是值本身。当你把 student1 赋值给 student2 时,student1 和 student2 都指向同一个对象。而如果你把 physique1 赋值给 physique2,它们就是两个不同的对象。

enum :枚举

枚举像类、结构体一样都封装了数据成员,但它的所有数据成员都是静态的、是常量,且有序,并被称为“枚举成员”。

“体育爱好”就很适合用枚举建模:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum Hobby
{
Basketball,
Volleyball,
Tennis,
Swimming
}
// 它的实例可以用作 Student 的属性
class Student
{
public string Name { get; set; }
public int Age { get; set; }
public string Gender { get; set; }
public Physique Physique { get; set; }
public Hobby Hobby { get; set; }
}

枚举不通过 new 关键字实例化,毕竟它所有成员都是静态的。访问枚举成员就直接返回枚举的实例,比如:

1
Hobby yourHobby = Hobby.Basketball;

枚举成员还可以转为整型或字符串,比如:

1
2
int hobbyInt = (int)yourHobby;  // 返回对应的枚举顺序
string hobbyString = yourHobby.ToString(); // 返回标识符字面量字符串

class 、struct 、enum 对比

这三者被我称为 C# 三大最常用的类型系统。

99% 情况下的实体建模都是用 class ,首先因为 class 本身实例是引用类型,变量传递计算量小,此外还允许继承、多态、方法重载等特性。每一个 class 实例都 具有身份 ,批量小,信息大。

struct 如其名“结构体”,适合对结构化轻量数据结构建模。比如一个 Point 结构体,包含两个 double 类型的 X 和 Y 字段,可以用来表示二维坐标。像前面的 Physique 结构体,以及时间日期、颜色甚至是土建材料质地等,都可以用 struct 建模。struct 的实例 没有身份 ,程序中会创建很多结构体的 大批量小对象 ,对于这种场景,struct 就比 class 更适合。

enum 相对于前两者比较特殊,它可以比喻为直接面向已知有限的(可支持数字代号的)实例进行建模。比如性别只有男女两种(只能被枚举为男或女),就可以建立只有男女两种枚举成员的 Gender 枚举。体育爱好、学历,甚至是星座,都可以用 enum 建模。

在实际项目中,struct 和 enum 通常是在 class 成员有需要进行建模时才用的。比如,在学生类中,可以用 struct 建模学生的体质,而用 enum 建模学生的体育爱好。

record、record struct :记录类、记录结构体

这两者分别相当于把 class 和 struct 只允许通过构造函数赋值字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
record Student
{
public string Name { get; init; }
public int Age { get; init; }
public string Gender { get; init; }
public Physique Physique { get; init; }
public Hobby Hobby { get; init; }
}
// 或者
// record Student(string Name, int Age, string Gender, Physique Physique, Hobby Hobby)

// 创建实例
var student1 = new Student("张三", 20, "男", new Physique(170, 60), Hobby.Basketball);

记录类实例一旦创建,就不能修改字段的值了。但如果它有成员是引用类型,还是可以改变这个成员的内部数据的。

interface :接口

接口是一种抽象建模方式,它定义了一些方法签名,但不提供实现。接口可以被类、结构体、枚举实现,也可以被其他接口继承。

比如,我们可以定义一个 IAnimal 接口,它定义了一些动物的基本行为:

1
2
3
4
5
6
interface IAnimal
{
void Eat();
void Sleep();
void Run();
}

然后,我们可以定义一些具体的动物类,实现 IAnimal 接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Dog : IAnimal
{
public void Eat() { Console.WriteLine("狗吃骨头"); }
public void Sleep() { Console.WriteLine("狗睡觉"); }
public void Run() { Console.WriteLine("狗跑"); }
}

class Cat : IAnimal
{
public void Eat() { Console.WriteLine("猫吃鱼"); }
public void Sleep() { Console.WriteLine("猫睡觉"); }
public void Run() { Console.WriteLine("猫跑"); }
}

delegate :委托

委托像是把方法做成了类型系统。它建模的内容包括:

  • 方法输入的参数类型
  • 返回类型(如果有)

委托分为两种:

  • Action<T1, T2, ...>:无返回值,参数类型为 T1、T2、...
  • Func<T1, T2, ..., TResult>:有返回值,参数类型为 T1、T2、...,返回类型为 TResult

委托实例可以储存方法引用,理论上只要是和委托类型兼容的方法都可以给委托实例进行引用,可以用 += 运算符进行多播委托。

定义委托和声明方法、调用委托和调用方法在语法上很相似:

1
2
3
4
5
6
7
8
9
10
11
12
13
delegate void MyDelegate(string message);  // 声明了一个委托 Action<string>

void MyMethod(string message)
{
Console.WriteLine(message);
}

MyDelegate myMethodDelegate = MyMethod; // 实例化,把 MyMethod 放进函数指针(委托实例)的引用中
// 如果没有定义 MyDelegate,你也可以写 Action<string> myMethodDelegate = MyMethod;
// 使用 lambda 表达式也可以创建委托实例
// MyDelegate myLambdaDelegate = (string message) => Console.WriteLine(message);
myMethodDelegate += MyMethod; // 多播委托
myMethodDelegate("Hello World!"); // 调用委托实例,输出两次 "Hello World!"

委托的应用:事件(event)

事件是一种特殊的委托实例,声明的时候要加上修饰符 event。

通常使用 事件名.Invoke(参数) 触发事件。事件本身可以直接用 事件名(参数) 被调用,但如果事件无订阅者(没有引用方法),容易报错。

事件名.Invoke(参数) 通常作为表达式语句放在事件所在类的方法中,表示这个方法会在触发事件时调用。

在主程序中,事件要 先绑定,再触发 :

  • 绑定事件:事件名 += 方法 把方法放进事件里面进行引用(多放几个可以多播委托)。
  • 触发事件:调用触发事件的方法。

下面是一个使用事件的例子:

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
public class Alarm
{
public event EventHandler Ring; // 声明事件,使用 EventHandler

public void TimeReached()
{
Console.WriteLine("时间到了!");
Ring?.Invoke(this, EventArgs.Empty); // 触发事件
}
}

class Program
{
static void Main()
{
Alarm alarm = new Alarm();
alarm.Ring += OnAlarmRing; // 绑定事件

alarm.TimeReached(); // 触发事件
}

static void OnAlarmRing(object sender, EventArgs e)
{
Console.WriteLine("闹钟响了!");
}
}

你可能会发现这里的代码里面,事件的声明是否使用 event 修饰符都可以。实际上它设置了委托实例的访问权限:外部只能够通过 += -= 进行订阅和取消订阅( public ),其它访问方式都是 private 的,比如赋值为 null 或直接调用。