快速认识 C# 类型系统
(先决条件:知道 C# 怎么声明数字类型和字符串变量,会调用如 Console.WriteLine()
等方法)
前言:对象的描述
在程序设计中,我们需要给对象建立模型。描述一个对象(也可以理解为实体),我们通常用属性去表示它,比如,我要描述某个叫张三的学生,可以将他表示成:
1 | 名字:张三 |
从语义上来看,我们已经通过上述描述,使用 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 | class Student |
此时,我们成功定义了一个含有 6 个属性的类 Student
,它可以用来描述学生。然后只要进行实例化,就可以创建具体的学生对象:
1 | var student1 = new Student { Name = "张三", Age = 20, Gender = "男", Height = 170, Weight = 60, Hobby = "打篮球" }; |
上述代码就分别把张三、约翰、菊次郎三个学生对象通过对 Student
的实例化,储存为变量名 student1
、student2
、student3
。
你可能对 { get; set; }
表示疑惑。因为类的数据成员可以分为字段(无访问器)和属性(有访问器),而 { get; set; }
就是一种访问器,表示“自动属性”,也就是我们只要通过声明这个属性,就会隐式生成一个字段。
那字段和属性本质上有什么区别?
字段可以说是类“最本质的数据成员”,它是类这个模型本质的数据。姓名、年龄、性别、身高、体重、体育爱好这些数据,都是属于学生对象的本质信息,所以,学生对象一定包含这些字段。
而属性并不是最本质的数据成员,它是根据字段实时计算而获取的。这里所谓“姓名”属性,实际上是对象里面储存了“姓名”字段,然后当我们访问“姓名”属性时,它返回“姓名”字段的值。自动属性的作用就是这样隐式声明了字段。如果不适用自动属性,那么声明“姓名”属性的语法就相当于:
1 | private string _name; |
不难看出,get
访问器表示访问 Name
这个标识符时,返回代码块里 return
后面的值;而 set
访问器是设置 Name
这个标识符时,执行代码块里的操作,比如给字段赋值。属性可以只有 get
访问器,做只读属性。比如,在有学生有字段“身高”和“体重”的情况下,我们可以声明只读属性“体质指数”:
1 | public double BMI |
那 private
和 public
关键字有什么用?
private
关键字表示这个成员只能在类内部访问,而 public
关键字表示这个成员可以在类外部(比如控制台应用里面 Program
类的 Main
方法)访问。
方法
除了字段和属性,方法也是类的成员。方法接受输入一些参数(不输入也可以),然后返回一些输出(也可以不返回)。比如,学生可以定义一个“吃饭”方法,传入饭量,不输出内容,但是体重会增加:
1 | public void Eat(double amount) |
静态成员
静态成员是指类级别的成员,它不依赖于实例对象,而是类本身。通俗来说它并不关于对象,反而像把类当成命名空间储存全局变量或方法。比如,在学生类可以定义一个“学生数量”作为静态属性:
1 | public static int StudentCount { get; set; } = 0; |
这样,我们就可以通过 Student.StudentCount
来获取学生数量,而不需要实例化学生对象。
顺带提一句,常用的 Console.WriteLine()
方法就是 System.Console
类的静态方法。
构造函数
构造函数在类体中用 类名(参数列表)
声明,在实例化时执行。比如给 Student
类可以添加一个这样的构造函数:
1 | public Student(string name, int age, string gender, double height, double weight, string hobby) |
在主程序中,只要调用:
1 | Student student1 = new Student("张三", 20, "男", 170, 60, "打篮球"); // 免去对象初始化器 |
类还支持方法重载、继承、多态这些功能,这里就不详细讲了。
struct
:结构体
结构体类似于类,可以通过声明字段、属性和方法进行建模。但是,结构体不支持继承和多态。此外,类的实例属于引用类型,而结构体的实例属于值类型(后面会解释)。
“体质”这种抽象指标就很适合用结构体建模:
1 | struct Physique |
如何理解 “类的实例属于引用类型,而结构体的实例属于值类型” ?
在 C# 中,所有 值类型 都隐式继承自 System.ValueType
(其本身继承自 System.Object
),而所有 引用类型 都直接或间接继承自 System.Object。
值类型和引用类型的核心区别,并不在于“栈”与“堆”的简单对立,而在于变量存储的是否是数据本身,还是对数据的引用。
因此,引用类型变量是对象的引用,而值类型变量是值本身。当你把 student1
赋值给 student2
时,student1
和 student2
都指向同一个对象。而如果你把 physique1
赋值给 physique2
,它们就是两个不同的对象。
enum
:枚举
枚举像类、结构体一样都封装了数据成员,但它的所有数据成员都是静态的、是常量,且有序,并被称为“枚举成员”。
“体育爱好”就很适合用枚举建模:
1 | enum Hobby |
枚举不通过 new
关键字实例化,毕竟它所有成员都是静态的。访问枚举成员就直接返回枚举的实例,比如:
1 | Hobby yourHobby = Hobby.Basketball; |
枚举成员还可以转为整型或字符串,比如:
1 | int hobbyInt = (int)yourHobby; // 返回对应的枚举顺序 |
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 | record Student |
记录类实例一旦创建,就不能修改字段的值了。但如果它有成员是引用类型,还是可以改变这个成员的内部数据的。
interface
:接口
接口是一种抽象建模方式,它定义了一些方法签名,但不提供实现。接口可以被类、结构体、枚举实现,也可以被其他接口继承。
比如,我们可以定义一个 IAnimal
接口,它定义了一些动物的基本行为:
1 | interface IAnimal |
然后,我们可以定义一些具体的动物类,实现 IAnimal
接口:
1 | class Dog : IAnimal |
delegate
:委托
委托像是把方法做成了类型系统。它建模的内容包括:
- 方法输入的参数类型
- 返回类型(如果有)
委托分为两种:
Action<T1, T2, ...>
:无返回值,参数类型为T1
、T2
、...
Func<T1, T2, ..., TResult>
:有返回值,参数类型为T1
、T2
、...
,返回类型为TResult
委托实例可以储存方法引用,理论上只要是和委托类型兼容的方法都可以给委托实例进行引用,可以用 +=
运算符进行多播委托。
定义委托和声明方法、调用委托和调用方法在语法上很相似:
1 | delegate void MyDelegate(string message); // 声明了一个委托 Action<string> |
委托的应用:事件(event)
事件是一种特殊的委托实例,声明的时候要加上修饰符 event
。
通常使用 事件名.Invoke(参数)
触发事件。事件本身可以直接用 事件名(参数)
被调用,但如果事件无订阅者(没有引用方法),容易报错。
事件名.Invoke(参数)
通常作为表达式语句放在事件所在类的方法中,表示这个方法会在触发事件时调用。
在主程序中,事件要 先绑定,再触发 :
- 绑定事件:
事件名 += 方法
把方法放进事件里面进行引用(多放几个可以多播委托)。 - 触发事件:调用触发事件的方法。
下面是一个使用事件的例子:
1 | public class Alarm |
你可能会发现这里的代码里面,事件的声明是否使用 event
修饰符都可以。实际上它设置了委托实例的访问权限:外部只能够通过 +=
-=
进行订阅和取消订阅( public
),其它访问方式都是 private
的,比如赋值为 null
或直接调用。