(先决条件:知道 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 的实例化,储存为变量名 student1student2student3

你可能对 { 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);

privatepublic 关键字有什么用?

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 时,student1student2 都指向同一个对象。而如果你把 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(); // 返回标识符字面量字符串

classstructenum 对比

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

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

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

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

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

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

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

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, ...>:无返回值,参数类型为 T1T2...
  • Func<T1, T2, ..., TResult>:有返回值,参数类型为 T1T2...,返回类型为 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 或直接调用。