第 20 节 · 第三部分 面向对象

类(Class)

类是蓝图,new 照着它造对象;每个对象有自己的一份字段,static 成员整个类只有一份。

时长 7:40授课计划第 7 周

学完这一节,你能

讲义

跟着视频的顺序写。每行代码都有注释,代码和运行结果都是实际运行得到的。

1类是什么

数据和操作数据的方法,怎么装进一个能造出很多对象的模板?用 class 关键字写一个类。

定义类就像画一张蓝图:写清楚照它造出来的东西有哪些数据、能做哪些操作,但蓝图本身不装数据。照蓝图造出来的才是对象,也叫这个类的实例;一张蓝图能造很多对象,每个对象有自己的一份数据。写在类里面的变量和方法,统称类的成员。

2类的定义

一般形式:开头写 class 和类名,主体放在一对大括号里。里面先写成员变量(访问修饰符、数据类型、变量名),再写成员方法(访问修饰符、返回类型、方法名和参数列表,后面是方法体)。返回类型就是方法算完交回的值是什么类型。

访问修饰符(教程叫访问标识符)决定谁能访问这个成员。不写就用默认值:类本身默认 internal(同一个项目里都能用),成员默认 private(只有类自己能用)。访问对象的成员用点号,把对象名和成员名连起来。

3第一个例子:Box

C# · 第 4-9 行
4class Box// 定义 Box 类
5{// Box 类开始
6 public double length; // 长度// public:外面能用点号访问
7 public double breadth; // 宽度// 成员变量,类型 double
8 public double height; // 高度// 成员变量,类型 double
9}// Box 类结束

new 造出两个对象,变量里放的是对象的引用,就像一张取货牌:

C# · 第 14-16 行
14Box Box1 = new Box(); // 声明 Box1,类型为 Box// new 造出第一个对象,Box1 拿到它的引用
15Box Box2 = new Box(); // 声明 Box2,类型为 Box// new 造出第二个对象
16double volume = 0.0; // 体积// 存放体积的变量

再用点号给三条边赋值:Box1 的高、长、宽是 5、6、7,Box2 是 10、12、13,两个对象各有一份,互不影响。最后乘出体积:

C# · 第 28-29 行
28// Box1 的体积
29volume = Box1.height * Box1.length * Box1.breadth;// 5 × 6 × 7 = 210
查看完整代码(38 行)
C#
1using System;// 引入 System 命名空间
2namespace BoxApplication// 定义命名空间 BoxApplication
3{// 命名空间开始
4 class Box// 定义 Box 类
5 {// Box 类开始
6 public double length; // 长度// 成员变量:长
7 public double breadth; // 宽度// 成员变量:宽
8 public double height; // 高度// 成员变量:高
9 }// Box 类结束
10 class Boxtester// 定义 Boxtester 类,放 Main
11 {// Boxtester 类开始
12 static void Main(string[] args)// 程序入口 Main 方法
13 {// Main 开始
14 Box Box1 = new Box(); // 声明 Box1,类型为 Box// 造出第一个对象
15 Box Box2 = new Box(); // 声明 Box2,类型为 Box// 造出第二个对象
16 double volume = 0.0; // 体积// 存放体积
17
18 // Box1 详述
19 Box1.height = 5.0;// Box1 的高
20 Box1.length = 6.0;// Box1 的长
21 Box1.breadth = 7.0;// Box1 的宽
22
23 // Box2 详述
24 Box2.height = 10.0;// Box2 的高
25 Box2.length = 12.0;// Box2 的长
26 Box2.breadth = 13.0;// Box2 的宽
27
28 // Box1 的体积
29 volume = Box1.height * Box1.length * Box1.breadth;// 5 × 6 × 7 = 210
30 Console.WriteLine("Box1 的体积: {0}", volume);// 输出 210
31
32 // Box2 的体积
33 volume = Box2.height * Box2.length * Box2.breadth;// 10 × 12 × 13 = 1560
34 Console.WriteLine("Box2 的体积: {0}", volume);// 输出 1560
35 Console.ReadKey();// 等待按键(现在的 VS 不写也会停住)
36 }// Main 结束
37 }// Boxtester 类结束
38}// 命名空间结束
运行结果
Box1 的体积: 210
Box2 的体积: 1560

把 length 前面的 public 去掉,成员默认是 private,外面一用点号访问就编译报错:

C# · 第 6 行
6double length; // 没写访问修饰符// 默认是 private
编译报错
Program.cs(13,18): error CS0122: “Box.length”不可访问,因为它具有一定的保护级别

4成员函数和封装

成员函数就是写在类里的方法,能访问这个对象的所有成员。C# 里方法写在一处就完整了,不用像 C++ 那样另写“原型”。

第二个例子把三条边改成 private,外面不能直接碰,只能调用 public 的方法,这就是封装:

C# · 第 6-12 行
6private double length; // 长度// private:只有 Box 自己的方法能用
7private double breadth; // 宽度// 外面摸不到
8private double height; // 高度// 外面摸不到
9public void setLength( double len )// public 方法:设置长度
10{// setLength 开始
11 length = len;// 在类内部可以直接改 length
12}// setLength 结束
C# · 第 23-26 行
23public double getVolume()// 返回类型 double:算完交回体积
24{// getVolume 开始
25 return length * breadth * height;// 在类内部把三条边乘起来
26}// getVolume 结束
C# · 第 37-39 行
37Box1.setLength(6.0);// 调用方法设长 = 6
38Box1.setBreadth(7.0);// 设宽 = 7
39Box1.setHeight(5.0);// 设高 = 5,getVolume() 得 210
查看完整代码(57 行)
C#
1using System;// 引入 System 命名空间
2namespace BoxApplication// 定义命名空间 BoxApplication
3{// 命名空间开始
4 class Box// 定义 Box 类
5 {// Box 类开始
6 private double length; // 长度// 私有成员变量:长
7 private double breadth; // 宽度// 私有成员变量:宽
8 private double height; // 高度// 私有成员变量:高
9 public void setLength( double len )// 设置长度
10 {// setLength 开始
11 length = len;// 存进 length
12 }// setLength 结束
13
14 public void setBreadth( double bre )// 设置宽度
15 {// setBreadth 开始
16 breadth = bre;// 存进 breadth
17 }// setBreadth 结束
18
19 public void setHeight( double hei )// 设置高度
20 {// setHeight 开始
21 height = hei;// 存进 height
22 }// setHeight 结束
23 public double getVolume()// 计算体积
24 {// getVolume 开始
25 return length * breadth * height;// 返回长 × 宽 × 高
26 }// getVolume 结束
27 }// Box 类结束
28 class Boxtester// 定义 Boxtester 类
29 {// Boxtester 类开始
30 static void Main(string[] args)// 程序入口 Main 方法
31 {// Main 开始
32 Box Box1 = new Box(); // 声明 Box1,类型为 Box// 造出第一个对象
33 Box Box2 = new Box(); // 声明 Box2,类型为 Box// 造出第二个对象
34 double volume; // 体积// 存放体积
35
36 // Box1 详述
37 Box1.setLength(6.0);// Box1 长 = 6
38 Box1.setBreadth(7.0);// Box1 宽 = 7
39 Box1.setHeight(5.0);// Box1 高 = 5
40
41 // Box2 详述
42 Box2.setLength(12.0);// Box2 长 = 12
43 Box2.setBreadth(13.0);// Box2 宽 = 13
44 Box2.setHeight(10.0);// Box2 高 = 10
45
46 // Box1 的体积
47 volume = Box1.getVolume();// 6 × 7 × 5 = 210
48 Console.WriteLine("Box1 的体积: {0}" ,volume);// 输出 210
49
50 // Box2 的体积
51 volume = Box2.getVolume();// 12 × 13 × 10 = 1560
52 Console.WriteLine("Box2 的体积: {0}", volume);// 输出 1560
53
54 Console.ReadKey();// 等待按键(现在的 VS 不写也会停住)
55 }// Main 结束
56 }// Boxtester 类结束
57}// 命名空间结束

输出和第一个例子一样,还是 210 和 1560。成员变量可以看成对象的特征;C# 另有专门的“属性”语法(后面会讲)。

5构造函数

构造函数是类里一个特殊的方法,每次用 new 创建对象都会自动执行。它的名字和类名完全一样,不写返回类型,连 void 也不写。

C# · 第 7-10 行
7public Line()// 构造函数:名字就是类名 Line,没有返回类型
8{// Line 构造函数开始
9 Console.WriteLine("对象已创建");// 只打印一句
10}// Line 构造函数结束
C# · 第 23-26 行
23Line line = new Line();// new 的那一刻构造函数就跑了:先打印“对象已创建”
24// 设置线条长度
25line.setLength(6.0);// 长度设成 6
26Console.WriteLine("线条的长度: {0}", line.getLength());// 再打印“线条的长度: 6”

上面没有参数,叫默认的构造函数。构造函数也可以带参数,叫参数化构造函数,创建对象的同时就给初始值:

C# · 第 7-11 行
7public Line(double len) // 参数化构造函数// 带一个参数 len
8{// Line 构造函数开始
9 Console.WriteLine("对象已创建,length = {0}", len);// 先打印
10 length = len;// 再把 len 存进 length
11}// Line 构造函数结束
C# · 第 24-28 行
24Line line = new Line(10.0);// 传进 10:打印“对象已创建,length = 10”
25Console.WriteLine("线条的长度: {0}", line.getLength());// 长度 10
26// 设置线条长度
27line.setLength(6.0);// 改成 6
28Console.WriteLine("线条的长度: {0}", line.getLength());// 长度 6
运行结果
对象已创建,length = 10
线条的长度: 10
线条的长度: 6

注意:自己写了带参数的构造函数,编译器就不再补那个无参的,这时写 new Line() 会报错:

C# · 第 14 行
14Line line = new Line();// 只有 Line(double len),却不传参数
编译报错
Program.cs(14,26): error CS7036: 未提供与“Line.Line(double)”的所需参数“len”对应的参数

6析构函数

析构函数的名字是类名前加波浪号 ~,没有返回值,也不带参数。在 Line 里加上它,看“对象已删除”会不会打印:

C# · 第 11-14 行
11~Line() //析构函数// ~ 加类名:析构函数
12{// ~Line 开始
13 Console.WriteLine("对象已删除");// 析构时打印
14}// ~Line 结束
运行结果
对象已创建
线条的长度: 6

在现在的 .NET 上真跑只有两行(教程的说法已过时)。析构函数什么时候跑由垃圾回收器(.NET 里自动清理没人用的对象的部分)决定,不是变量一离开范围就跑;而且 .NET 5 以后程序退出时也不调用它。先让对象没人再用,再请垃圾回收器来一趟,才打印出来:

C# · 第 16-20 行
16[MethodImpl(MethodImplOptions.NoInlining)]// 让 Make 保持为独立的方法,不被合并进 Main
17static void Make()// Make:只在里面建一个对象
18{// Make 开始
19 Line line = new Line();// 打印“对象已创建”
20}// Make 结束
C# · 第 24-27 行
24Make(); // 离开 Make,line 不再被用到// 对象没人用了
25GC.Collect(); // 请垃圾回收器来一趟// 发现对象没人用,把它的析构函数排进待执行队列
26GC.WaitForPendingFinalizers(); // 等析构函数跑完// 等队列里的析构函数执行完:打印“对象已删除”
27Console.WriteLine("程序结束");// 最后打印“程序结束”
运行结果
对象已创建
对象已删除
程序结束

官方文档现在把它叫作终结器。一个类只能有一个,不能继承,也不能重载。内存由垃圾回收器管;直接管理文件这类外部资源时,主要用 Dispose(一个专门用来释放资源的方法)主动释放(用完马上关掉);析构函数只是忘了调用 Dispose 时的兜底。

7静态成员

用 static 声明的成员,不管类造了多少个对象,都只有一份。

C# · 第 6-10 行
6public static int num;// 静态变量 num:整个类共用一份,默认 0
7 public void count()// 实例方法 count:每调用一次给共用的 num 加 1
8 {// count 开始
9 num++;// num 加 1
10 }// count 结束
C# · 第 20-29 行
20StaticVar s1 = new StaticVar();// 对象 s1
21StaticVar s2 = new StaticVar();// 对象 s2
22s1.count();// num = 1
23s1.count();// num = 2
24s1.count();// num = 3
25s2.count();// 加在同一个 num 上:num = 4
26s2.count();// num = 5
27s2.count();// num = 6
28Console.WriteLine("s1 的变量 num: {0}", s1.getNum());// 输出 6
29Console.WriteLine("s2 的变量 num: {0}", s2.getNum());// 也是 6

静态变量的值随时能改,就像这里的 num,它不是常量,真要常量用 const。C# 一般在声明静态变量的地方直接写初始值,不像 C++ 那样在类外部初始化。

方法也可以声明成 static,叫静态函数,它在对象创建之前就存在,用类名直接调用,不需要对象:

C# · 第 11-14 行
11public static int getNum()// 静态函数 getNum
12{// getNum 开始
13 return num;// 返回静态变量 num
14}// getNum 结束

对象 s 调用三次 count 以后,用类名调用:

C# · 第 24 行
24Console.WriteLine("变量 num: {0}", StaticVar.getNum());// 类名.方法名 调用,不需要对象:输出 3

静态函数没有自己的对象,不能直接用实例成员(每个对象各有一份的成员):

C# · 第 7-10 行
7public int id;// 实例字段 id:每个对象各有一份
8 public static int getNum()// 静态函数
9 {// getNum 开始
10 return id;// 没有对象,不知道是哪个对象的 id
编译报错
Program.cs(10,20): error CS0120: 对象引用对于非静态的字段、方法或属性“StaticVar.id”是必需的

8小结

  • 类是一张蓝图,用 new 造出对象,用点号访问成员;成员默认 private。
  • 构造函数在创建时执行;写了带参构造函数,编译器就不再自动补无参的。
  • 析构函数(终结器)什么时候跑由垃圾回收器决定。
  • 静态成员整个类只有一份,静态函数用类名调用。

下一节:已经有了一个类型,想在它的基础上加点功能,不用从头再写,该怎么办?

检查题

5 道,选完马上告诉你对不对。

  1. 1class Box { double length; },Main 里写 Box1.length = 6.0; 会怎样?

  2. 2Line 的构造函数里打印“对象已创建”,Main 先 new Line() 再打印长度,先输出哪一行?

  3. 3Line 只写了 public Line(double len),再写 new Line() 会?

  4. 4在现在的 .NET 上运行教程的析构函数示例,“对象已删除”会?

  5. 5s1、s2 各调用三次 count()(num 是 static),s1.getNum() 是?