1事件是什么
用户按键、点击、移动鼠标,系统提示“下载完成”,都是事件,事件发生时程序要做出响应。在 C# 里,事件是类的一种成员,负责把“发生了什么”通知给订阅它的对象,这种做法叫观察者模式:发通知的一方不用知道对方的细节。事件建在上一节的委托上面。
用事件分四步:
- 声明委托,规定处理方法长什么样。
- 用
event关键字声明事件。 - 在合适的时候触发事件,通知所有订阅者。
- 别的类用
+=订阅,用-=取消订阅。
包含事件、负责触发的类叫发布器(publisher),提供处理方法的类叫订阅器(subscriber)。视频里把事件比成一条挂钩栏:订阅器把处理方法挂上去,发布器一拉拉杆,挂着的方法就被一个个调用。委托类型可以写在发布器里,也可以写在类外面。
2声明事件
| 22 | public delegate void BoilerLogHandler(string status); | // 委托 BoilerLogHandler:处理方法不返回值,接收 string |
| 23 | | |
| 24 | // 基于上面的委托定义事件 | |
| 25 | public event BoilerLogHandler BoilerEventLog; | // 用 event 声明这种委托类型的事件 BoilerEventLog |
触发事件的时候,挂在它上面的方法会被调用。
3第一个完整实例
发布器 ProcessBusinessLogic 有事件 ProcessCompleted,由 OnProcessCompleted 负责触发:
| 15 | protected virtual void OnProcessCompleted(EventArgs e) | // protected virtual:派生类能重写这个方法 |
| 16 | { | // OnProcessCompleted 开始 |
| 17 | ProcessCompleted?.Invoke(this, e); | // ?. :有人订阅才调用,没人订阅就什么也不做 |
订阅器的 Subscribe 方法用 += 挂上处理方法;Main 先创建发布器和订阅器,再订阅,最后调用 StartProcess:它先输出一行,再触发事件。
| 37 | process.ProcessCompleted += Process_ProcessCompleted; | // 用 += 把处理方法挂到事件上 |
查看完整代码(62 行)
| 1 | using System; | // 引入 System 命名空间 |
| 2 | | |
| 3 | namespace EventDemo | // 定义命名空间 EventDemo |
| 4 | { | // 命名空间开始 |
| 5 | // 定义一个委托类型,用于事件处理程序 | |
| 6 | public delegate void NotifyEventHandler(object sender, EventArgs e); | // 委托:参数 sender(谁发的)和 e(附加信息) |
| 7 | | |
| 8 | // 发布者类 | |
| 9 | public class ProcessBusinessLogic | // 发布器类 ProcessBusinessLogic |
| 10 | { | // 发布器类开始 |
| 11 | // 声明事件 | |
| 12 | public event NotifyEventHandler ProcessCompleted; | // 用 event 声明事件 ProcessCompleted |
| 13 | | |
| 14 | // 触发事件的方法 | |
| 15 | protected virtual void OnProcessCompleted(EventArgs e) | // 触发事件的方法,protected virtual:派生类能重写 |
| 16 | { | // OnProcessCompleted 开始 |
| 17 | ProcessCompleted?.Invoke(this, e); | // 有人订阅才调用:把 this 和 e 交给处理方法 |
| 18 | } | // OnProcessCompleted 结束 |
| 19 | | |
| 20 | // 模拟业务逻辑过程并触发事件 | |
| 21 | public void StartProcess() | // StartProcess:模拟一段业务 |
| 22 | { | // StartProcess 开始 |
| 23 | Console.WriteLine("Process Started!"); | // 先输出一行 |
| 24 | | |
| 25 | // 这里可以加入实际的业务逻辑 | |
| 26 | | |
| 27 | // 业务逻辑完成,触发事件 | |
| 28 | OnProcessCompleted(EventArgs.Empty); | // 业务完成,触发事件;EventArgs.Empty 表示没有附加信息 |
| 29 | } | // StartProcess 结束 |
| 30 | } | // 发布器类结束 |
| 31 | | |
| 32 | // 订阅者类 | |
| 33 | public class EventSubscriber | // 订阅器类 EventSubscriber |
| 34 | { | // 订阅器类开始 |
| 35 | public void Subscribe(ProcessBusinessLogic process) | // Subscribe:接收一个发布器 |
| 36 | { | // Subscribe 开始 |
| 37 | process.ProcessCompleted += Process_ProcessCompleted; | // 用 += 挂上处理方法 |
| 38 | } | // Subscribe 结束 |
| 39 | | |
| 40 | private void Process_ProcessCompleted(object sender, EventArgs e) | // 处理方法:签名和委托对得上 |
| 41 | { | // 处理方法开始 |
| 42 | Console.WriteLine("Process Completed!"); | // 收到通知,输出一行 |
| 43 | } | // 处理方法结束 |
| 44 | } | // 订阅器类结束 |
| 45 | | |
| 46 | class Program | // 定义 Program 类 |
| 47 | { | // Program 类开始 |
| 48 | static void Main(string[] args) | // 程序入口 Main 方法 |
| 49 | { | // Main 开始 |
| 50 | ProcessBusinessLogic process = new ProcessBusinessLogic(); | // 创建发布器 process |
| 51 | EventSubscriber subscriber = new EventSubscriber(); | // 创建订阅器 subscriber |
| 52 | | |
| 53 | // 订阅事件 | |
| 54 | subscriber.Subscribe(process); | // 订阅事件 |
| 55 | | |
| 56 | // 启动过程 | |
| 57 | process.StartProcess(); | // 启动过程,事件被触发 |
| 58 | | |
| 59 | Console.ReadLine(); | // 等待回车(现在的 VS 不写也会停住) |
| 60 | } | // Main 结束 |
| 61 | } | // Program 类结束 |
| 62 | } | // 命名空间结束 |
Process Started! Process Completed!
实际写代码通常不自己定义委托,直接用现成的 EventHandler(.NET 准备好的事件委托),或者带事件数据的版本 EventHandler<TEventArgs>。处理方法的两个参数里,sender 是发出通知的对象(这里是 this),e 是 EventArgs 类型、装着附加信息(EventArgs. 表示没有)。
VS 会在事件名下面画绿色波浪线(警告 CS8618):没人订阅时事件的值可以为空,要你写明白。在委托类型后面加个问号,警告就没了,输出不变:
| 12 | public event NotifyEventHandler? ProcessCompleted; | // NotifyEventHandler? :问号表示这个事件可以为空 |
4没人订阅时直接调用
做个实验:删掉订阅那一行,再把 ?. 也删掉。
| 17 | ProcessCompleted.Invoke(this, e); | // 没有 ?. ,直接调用事件 |
Process Started! Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object. at EventDemo.ProcessBusinessLogic.OnProcessCompleted(EventArgs e) in 事件/nocheck/Program.cs:line 17 at EventDemo.ProcessBusinessLogic.StartProcess() in 事件/nocheck/Program.cs:line 28 at EventDemo.Program.Main(String[] args) in 事件/nocheck/Program.cs:line 56
第一行照常输出,接着抛出空引用异常:没有任何方法挂在事件上,事件的值是空的,空值不能调用。所以触发之前要检查,?. 是最省事的写法。
5在类外面直接触发
再做一个实验:在 Main 里直接调用发布器的事件。
| 59 | process.ProcessCompleted(process, EventArgs.Empty); | // 在发布器外面直接调用事件 |
Program.cs(59,21): error CS0070: 事件“ProcessBusinessLogic.ProcessCompleted”只能出现在 += 或 -= 的左边(从类型“ProcessBusinessLogic”中使用时除外)
这就是 event 的作用:外面的类只能挂上、摘下处理方法,只有发布器自己能触发。
6实例 1:值变了才触发
发布器 EventTest 有私有字段 value,还有委托和事件 ChangeNum。
| 15 | if ( ChangeNum != null ) | // 先检查事件是不是空值 |
| 16 | { | // if 开始 |
| 17 | ChangeNum(); /* 事件被触发 */ | // 不是空值就触发:调用挂着的方法 |
| 18 | }else { | // if 结束,else 开始 |
| 19 | Console.WriteLine( "event not fire" ); | // 是空值就输出 event not fire |
SetValue 只在新值和旧值不同时,才更新并触发事件;构造函数里调用了 SetValue(。
| 32 | if ( value != n ) | // 新值和旧值不同才往下走 |
| 33 | { | // if 开始 |
| 34 | value = n; | // 更新 value |
| 35 | OnNumChanged(); | // 触发事件 |
| 56 | EventTest e = new EventTest(); /* 实例化对象,第一次没有触发事件 */ | // 创建发布器:输出 event not fire |
| 57 | subscribEvent v = new subscribEvent(); /* 实例化对象 */ | // 创建订阅器 |
| 58 | e.ChangeNum += new EventTest.NumManipulationHandler( v.printf ); /* 注册 */ | // 注册:把订阅器的 printf 挂到 ChangeNum 上 |
| 59 | e.SetValue( 7 ); | // 7 和旧值 5 不同:触发,输出 event fire |
| 60 | e.SetValue( 11 ); | // 11 和 7 不同:再触发一次 |
查看完整代码(63 行)
| 1 | using System; | // 引入 System 命名空间 |
| 2 | namespace SimpleEvent | // 定义命名空间 SimpleEvent |
| 3 | { | // 命名空间开始 |
| 4 | using System; | // 命名空间里面也可以写 using(这一句和第 1 行重复) |
| 5 | /***********发布器类***********/ | // /* */ 是块注释,这里标出发布器类 |
| 6 | public class EventTest | // 发布器类 EventTest |
| 7 | { | // EventTest 类开始 |
| 8 | private int value; | // 私有字段 value,默认是 0 |
| 9 | | |
| 10 | public delegate void NumManipulationHandler(); | // 委托 NumManipulationHandler:不接收参数,不返回值 |
| 11 | | |
| 12 | public event NumManipulationHandler ChangeNum; | // 事件 ChangeNum |
| 13 | protected virtual void OnNumChanged() | // 触发事件的方法 |
| 14 | { | // OnNumChanged 开始 |
| 15 | if ( ChangeNum != null ) | // 有人注册吗? |
| 16 | { | // if 开始 |
| 17 | ChangeNum(); /* 事件被触发 */ | // 触发事件 |
| 18 | }else { | // if 结束,else 开始 |
| 19 | Console.WriteLine( "event not fire" ); | // 没人注册:输出 event not fire |
| 20 | Console.ReadKey(); /* 回车继续 */ | // 每输出一行停下等按键 |
| 21 | } | // else 结束 |
| 22 | } | // OnNumChanged 结束 |
| 23 | | |
| 24 | public EventTest() | // 构造函数 |
| 25 | { | // 构造函数开始 |
| 26 | int n = 5; | // n = 5 |
| 27 | SetValue( n ); | // SetValue(5):0 变成 5,但还没人注册 |
| 28 | } | // 构造函数结束 |
| 29 | | |
| 30 | public void SetValue( int n ) | // SetValue |
| 31 | { | // SetValue 开始 |
| 32 | if ( value != n ) | // 新值不同才处理 |
| 33 | { | // if 开始 |
| 34 | value = n; | // 更新 value |
| 35 | OnNumChanged(); | // 触发事件 |
| 36 | } | // if 结束 |
| 37 | } | // SetValue 结束 |
| 38 | } | // EventTest 类结束 |
| 39 | | |
| 40 | /***********订阅器类***********/ | // 块注释:订阅器类 |
| 41 | | |
| 42 | public class subscribEvent | // 订阅器类 subscribEvent |
| 43 | { | // subscribEvent 类开始 |
| 44 | public void printf() | // 处理方法 printf |
| 45 | { | // printf 开始 |
| 46 | Console.WriteLine( "event fire" ); | // 输出 event fire |
| 47 | Console.ReadKey(); /* 回车继续 */ | // 每输出一行停下等按键 |
| 48 | } | // printf 结束 |
| 49 | } | // subscribEvent 类结束 |
| 50 | | |
| 51 | /***********触发***********/ | // 块注释:触发 |
| 52 | public class MainClass | // MainClass 类 |
| 53 | { | // MainClass 类开始 |
| 54 | public static void Main() | // 程序入口 Main 方法 |
| 55 | { | // Main 开始 |
| 56 | EventTest e = new EventTest(); /* 实例化对象,第一次没有触发事件 */ | // 创建发布器:构造函数里输出 event not fire |
| 57 | subscribEvent v = new subscribEvent(); /* 实例化对象 */ | // 创建订阅器 |
| 58 | e.ChangeNum += new EventTest.NumManipulationHandler( v.printf ); /* 注册 */ | // 注册(new 委托 的写法) |
| 59 | e.SetValue( 7 ); | // 触发:输出 event fire |
| 60 | e.SetValue( 11 ); | // 再触发:输出 event fire |
| 61 | } | // Main 结束 |
| 62 | } | // MainClass 类结束 |
| 63 | } | // 命名空间结束 |
event not fire event fire event fire
第一行是 event not fire:创建对象时还没人注册;后面两次都改了值,各触发一次。每输出一行,程序都停下来等你按一下键,输出里的空行是按下的回车回显出来的。注册用的是老写法,也可以直接写 += 加方法名,效果一样:
| 58 | e.ChangeNum += v.printf; /* 注册 */ | // 不写 new,直接 += 方法名 |
7实例 2:锅炉日志
Boiler 类记着温度 100 和压力 12。发布器的 LogProcess 检查这两个数:温度在 80 到 150 之间、压力在 12 到 15 之间就是正常。
| 34 | if (temp > 150 || temp < 80 || pressure < 12 || pressure > 15) | // 超出范围吗?|| 是“或者”,有一个成立就算 |
| 35 | { | // if 开始 |
| 36 | remarks = "Need Maintenance"; | // 超出就改成 Need Maintenance |
| 37 | } | // if 结束 |
检查完把结果拼成一段信息,交给触发方法去触发事件。这次有两个订阅者,都挂到同一个事件上:
| 82 | boilerEvent.BoilerEventLog += Logger; | // 订阅者一:输出到屏幕 |
| 83 | boilerEvent.BoilerEventLog += fileLogger.Logger; | // 订阅者二:写进文件 |
| 84 | boilerEvent.LogProcess(); | // 触发一次,两个订阅者按顺序运行 |
查看完整代码(90 行)
| 1 | using System; | // 引入 System 命名空间 |
| 2 | using System.IO; | // 引入 System.IO,文件读写用 |
| 3 | | |
| 4 | namespace BoilerEventAppl | // 定义命名空间 BoilerEventAppl |
| 5 | { | // 命名空间开始 |
| 6 | // Boiler 类 | |
| 7 | class Boiler | // Boiler 类 |
| 8 | { | // Boiler 类开始 |
| 9 | public int Temp { get; private set; } | // 温度:外面只能读,private set 只有类自己能写 |
| 10 | public int Pressure { get; private set; } | // 压力:同样外面只读 |
| 11 | | |
| 12 | public Boiler(int temp, int pressure) | // 构造函数 |
| 13 | { | // 构造函数开始 |
| 14 | Temp = temp; | // 存温度 |
| 15 | Pressure = pressure; | // 存压力 |
| 16 | } | // 构造函数结束 |
| 17 | } | // Boiler 类结束 |
| 18 | | |
| 19 | // 事件发布器 | |
| 20 | class DelegateBoilerEvent | // 发布器类 DelegateBoilerEvent |
| 21 | { | // 发布器类开始 |
| 22 | public delegate void BoilerLogHandler(string status); | // 委托:接收 string,不返回值 |
| 23 | | |
| 24 | // 基于上面的委托定义事件 | |
| 25 | public event BoilerLogHandler BoilerEventLog; | // 事件 BoilerEventLog |
| 26 | | |
| 27 | public void LogProcess() | // LogProcess:检查锅炉状态 |
| 28 | { | // LogProcess 开始 |
| 29 | string remarks = "O.K."; | // 备注默认 O.K. |
| 30 | Boiler boiler = new Boiler(100, 12); | // 温度 100、压力 12 |
| 31 | int temp = boiler.Temp; | // 读温度 |
| 32 | int pressure = boiler.Pressure; | // 读压力 |
| 33 | | |
| 34 | if (temp > 150 || temp < 80 || pressure < 12 || pressure > 15) | // 超出正常范围? |
| 35 | { | // if 开始 |
| 36 | remarks = "Need Maintenance"; | // 改成 Need Maintenance |
| 37 | } | // if 结束 |
| 38 | | |
| 39 | OnBoilerEventLog($"Logging Info:\nTemperature: {temp}\nPressure: {pressure}\nMessage: {remarks}"); | // 拼好信息去触发事件 |
| 40 | } | // LogProcess 结束 |
| 41 | | |
| 42 | protected void OnBoilerEventLog(string message) | // 触发事件的方法 |
| 43 | { | // OnBoilerEventLog 开始 |
| 44 | BoilerEventLog?.Invoke(message); | // 有人订阅才调用 |
| 45 | } | // OnBoilerEventLog 结束 |
| 46 | } | // 发布器类结束 |
| 47 | | |
| 48 | // 该类保留写入日志文件的条款 | |
| 49 | class BoilerInfoLogger : IDisposable | // 写文件的类,IDisposable 表示它能用 using 自动收尾 |
| 50 | { | // BoilerInfoLogger 类开始 |
| 51 | private readonly StreamWriter _streamWriter; | // 写入器,readonly 表示只在构造函数里赋值 |
| 52 | | |
| 53 | public BoilerInfoLogger(string filename) | // 构造函数,收文件名 |
| 54 | { | // 构造函数开始 |
| 55 | _streamWriter = new StreamWriter(new FileStream(filename, FileMode.Append, FileAccess.Write)); | // 以追加方式打开文件 |
| 56 | } | // 构造函数结束 |
| 57 | | |
| 58 | public void Logger(string info) | // 处理方法 Logger:写一行到文件 |
| 59 | { | // Logger 开始 |
| 60 | _streamWriter.WriteLine(info); | // 写入信息 |
| 61 | } | // Logger 结束 |
| 62 | | |
| 63 | public void Dispose() | // Dispose(释放资源的方法):关闭文件 |
| 64 | { | // Dispose 开始 |
| 65 | _streamWriter?.Close(); | // 关闭写入器 |
| 66 | } | // Dispose 结束 |
| 67 | } | // BoilerInfoLogger 类结束 |
| 68 | | |
| 69 | // 事件订阅器 | |
| 70 | public class RecordBoilerInfo | // 订阅器类 RecordBoilerInfo |
| 71 | { | // 订阅器类开始 |
| 72 | static void Logger(string info) | // 处理方法 Logger:输出到屏幕 |
| 73 | { | // Logger 开始 |
| 74 | Console.WriteLine(info); | // 输出信息 |
| 75 | } | // Logger 结束 |
| 76 | | |
| 77 | static void Main(string[] args) | // 程序入口 Main 方法 |
| 78 | { | // Main 开始 |
| 79 | using (BoilerInfoLogger fileLogger = new BoilerInfoLogger("e:\\boiler.txt")) | // 创建文件日志对象(e 盘路径) |
| 80 | { | // using 开始 |
| 81 | DelegateBoilerEvent boilerEvent = new DelegateBoilerEvent(); | // 创建发布器 |
| 82 | boilerEvent.BoilerEventLog += Logger; | // 挂上屏幕日志 |
| 83 | boilerEvent.BoilerEventLog += fileLogger.Logger; | // 挂上文件日志 |
| 84 | boilerEvent.LogProcess(); | // 触发事件 |
| 85 | } | // using 结束:自动关闭文件 |
| 86 | | |
| 87 | Console.ReadLine(); | // 等待回车(现在的 VS 不写也会停住) |
| 88 | } | // Main 结束 |
| 89 | } | // 订阅器类结束 |
| 90 | } | // 命名空间结束 |
Logging Info: Temperature: 100 Pressure: 12 Message: O.K.
同一次触发,文件里也写进了同样的四行。程序把文件写到 E 盘,电脑上没有 E 盘就会出错。
8用 -= 取消订阅
教程提到了 -= 但没给例子,我们自己写了一个闹钟:
| 30 | Alarm alarm = new Alarm(); | // 创建闹钟 |
| 31 | alarm.Rang += Light; | // 挂上 Light |
| 32 | alarm.Rang += Music; | // 挂上 Music |
| 33 | alarm.Ring(); | // 第一次响:按挂上的顺序运行两个方法 |
| 34 | | |
| 35 | alarm.Rang -= Light; | // 用 -= 摘掉 Light |
| 36 | alarm.Ring(); | // 第二次响:Alarm rings,只剩 Music plays |
查看完整代码(39 行)
| 1 | using System; | // 引入 System 命名空间 |
| 2 | | |
| 3 | namespace EventDemo | // 定义命名空间 EventDemo |
| 4 | { | // 命名空间开始 |
| 5 | public class Alarm | // Alarm 类(发布器) |
| 6 | { | // Alarm 类开始 |
| 7 | public event EventHandler? Rang; | // 事件 Rang,类型直接用现成的 EventHandler,? 表示可以为空 |
| 8 | | |
| 9 | public void Ring() | // Ring:响铃 |
| 10 | { | // Ring 开始 |
| 11 | Console.WriteLine("Alarm rings"); | // 先输出一行 |
| 12 | Rang?.Invoke(this, EventArgs.Empty); | // 再触发事件 |
| 13 | } | // Ring 结束 |
| 14 | } | // Alarm 类结束 |
| 15 | | |
| 16 | class Program | // Program 类 |
| 17 | { | // Program 类开始 |
| 18 | static void Light(object? sender, EventArgs e) | // 处理方法 Light;object? 的 ? 表示可以为空 |
| 19 | { | // Light 开始 |
| 20 | Console.WriteLine("Light on"); | // 输出 Light on |
| 21 | } | // Light 结束 |
| 22 | | |
| 23 | static void Music(object? sender, EventArgs e) | // 处理方法 Music |
| 24 | { | // Music 开始 |
| 25 | Console.WriteLine("Music plays"); | // 输出 Music plays |
| 26 | } | // Music 结束 |
| 27 | | |
| 28 | static void Main(string[] args) | // 程序入口 Main 方法 |
| 29 | { | // Main 开始 |
| 30 | Alarm alarm = new Alarm(); | // 创建闹钟 |
| 31 | alarm.Rang += Light; | // 挂上 Light |
| 32 | alarm.Rang += Music; | // 挂上 Music |
| 33 | alarm.Ring(); | // 响一次 |
| 34 | | |
| 35 | alarm.Rang -= Light; | // 摘掉 Light |
| 36 | alarm.Ring(); | // 再响一次 |
| 37 | } | // Main 结束 |
| 38 | } | // Program 类结束 |
| 39 | } | // 命名空间结束 |
Alarm rings Light on Music plays Alarm rings Music plays
一共五行:-= 只摘掉 Light 这一个,Music 照常运行。
9事件不是线程通信
事件不是用来做线程间通信的(教程的说法不对;线程是程序里的一条执行路径,最后一节讲)。我们打印一下线程编号:
| 14 | Done += OnDone; | // 挂上 OnDone |
| 15 | Console.WriteLine("raise thread " + Environment.CurrentManagedThreadId); | // 打印触发事件的线程编号 |
| 16 | Done?.Invoke(null, EventArgs.Empty); | // 触发事件:OnDone 打印它所在的线程编号 |
| 17 | Console.WriteLine("after raise"); | // 处理方法运行完,才走到这一句 |
查看完整代码(19 行)
| 1 | using System; | // 引入 System 命名空间 |
| 2 | | |
| 3 | class Program | // Program 类 |
| 4 | { | // Program 类开始 |
| 5 | static event EventHandler? Done; | // 静态事件 Done |
| 6 | | |
| 7 | static void OnDone(object? sender, EventArgs e) | // 处理方法 OnDone |
| 8 | { | // OnDone 开始 |
| 9 | Console.WriteLine("handler thread " + Environment.CurrentManagedThreadId); | // 打印处理方法所在的线程编号 |
| 10 | } | // OnDone 结束 |
| 11 | | |
| 12 | static void Main() | // 程序入口 Main 方法 |
| 13 | { | // Main 开始 |
| 14 | Done += OnDone; | // 挂上 OnDone |
| 15 | Console.WriteLine("raise thread " + Environment.CurrentManagedThreadId); | // 打印触发事件的线程编号 |
| 16 | Done?.Invoke(null, EventArgs.Empty); | // 触发事件 |
| 17 | Console.WriteLine("after raise"); | // 处理方法运行完,才走到这一句 |
| 18 | } | // Main 结束 |
| 19 | } | // Program 类结束 |
raise thread 1 handler thread 1 after raise
触发和处理都在线程 1 上,处理方法运行完才输出 after raise。所以事件不是线程通信,也不是中断,只是把方法一个个调用完。
10小结
- 先声明委托,规定处理方法的样子,再用
event声明事件。 - 订阅者用
+=挂上,用-=摘下。 - 发布器用
?.触发,挂着的方法按顺序运行。Invoke - 外面的类只能挂和摘,不能直接触发。
下一节:数组长度一开始就定死了,想随时往里加、往外删数据,用什么?
