前言
C#基础指路:www.bilibili.com/video/BV1wx...
铁锰老师yyds!
本篇为个人学习笔记,仅供参考。
一、SOLID原则
Single Responsibility Principle(SRP):单一职责原则
一个类或者一个模块只做一件事。让一个类或者一个模块专注于单一的功能,减少功能之间的耦合程度。这样做在需要修改某个功能时,就不会影响到其他的功能。
Open Closed Principle(OCP):开闭原则
对扩展开放,对修改关闭。一个类独立之后就不应该去修改它,而是以扩展的方式适应新需求。
Liskov Substitution Principle(LSP):里氏替换原则
所有基类出现的地方都可以用派生类替换而不会让程序产生错误,派生类可以扩展基类的功能,但不能改变基类原有的功能。
Interface Segregation Principle(ISP):接口隔离原则
一个接口应该拥有尽可能少的行为,使其精简单一。对于不同的功能的模块分别使用不同接口,而不是使用同一个通用的接口。
Dependence Inversion Principle(DIP):依赖倒置原则
高级模块不应该依赖低级模块,而是依赖抽象接口,通过抽象接口使用对应的低级模块。
二、关于委托(delegate)
参考基础视频:委托详解_哔哩哔哩_bilibili
1 什么是委托
委托,可以理解为是C中函数指针的升级版
直接调用 和间接调用的本质:
- 直接调用:通过函数名调用函数。CPU通过函数名直接获得函数所在地址,执行------》返回
- 间接调用:通过函数指针调用函数。CPU通过读取函数指针存储的值,获得函数所在地址,执行------》返回
无论是直接or间接,CPU最终执行的机器语言指令是一样的,最终效果也是一样的。
程序中一个重要的概念:一切皆地址。程序的本质=数据+算法。
变量是用来寻找数据的地址,函数是用来寻找算法的地址。
2 委托的简单使用
不同于C中函数指针要先声明再使用,C#类库中已定义了许多常用的委托类型可以直接使用。常用的委托有:Action委托、Func委托
- Action委托:无参、返回值Void
- Func委托:泛型委托
3 自定义委托
委托是一种类,而类是一种(引用)数据类型,所以委托也是一种数据类型。
它的声明方式与一般的类不同(不用Class XXX),而仿照函数指针的声明格式------一是为了照顾可读性,二是为了同C/C++的传统保持一致。
自定义委托的声明和使用:
需要注意的点
- 注意声明委托的位置(要在命名空间中声明,和其他类同级。要避免错写在类里,导致变成嵌套类)
- 委托与所封装的方法必须 "类型兼容" (返回值类型、参数列表个数&类型)
4 委托的一般使用
把委托当作参数传进方法。
即:通过委托,把方法A当作参数传给B方法。
好处: 假设方法B包含一个委托参数,委托封装了另一方法A。在B的方法体内,可以通过使用委托,动态调用方法A,从而形成一种动态调用方法的代码结构。
这种方法常用的实践有以下两种:
- 实践1:模板方法。借用指定的外部方法来产生结果。
相当于方法中有一个填空题,需要传进来的委托来进行补充。一般在代码中部使用委托间接调用方法,获取返回值,之后根据返回值来进行后续的运算。
- 实践2:回调(callback)方法。调用指定的外部方法。
把委托类型的参数传入主调方法,委托类型参数则封装了被回调的方法。由主调函数根据自己的逻辑来决定是否出发回调方法。
这类委托通常在函数末尾被使用,需要等主方法逻辑基本执行完后做出判断,委托封装的方法一般无返回值,常用来执行一些后续的工作,构成类似流水线的结构。
模板方法的使用参考:
使用模板方法的好处:
后续Product类、Box类、WrapFactory类都不用再改动,只需要不断去扩展ProductFactory产品工厂类即可。
最大限度的实现了代码复用(Reuse),可以提高工作效率,同时降低Bug的引入。
回调方法的使用参考:
回调方法,也称做好莱坞方法(类似演员面试后给导演留下名片。演员无法联络导演,而导演如果选中某位演员就通过名片联络演员------这就是回调函数的一个经典场景)
5、委托的缺点
使用委托要注意不能滥用。现实使用中一定要慎之又慎。
ps1.方法级别的紧耦合,往往是违反设计模式的
ps2.滥用委托为什么会造成内存泄露?
委托引用的方法,如果是实例方法,必定会隶属于一个对象。一旦委托引用了这个方法,方法对应的对象就必须存在于内存之中,即便没有其他引用变量引用该对象,该对象也不能释放------因为一旦释放,委托就不能再去间接调用对象的方法。所以有可能造成内存泄漏。随着泄露的内存越来越多。程序的性能会不断下降,直到程序崩溃。
6 委托的高级使用
几种常用的使用方式------
A. 多播(multicast)委托
一个委托内部封装多个方法的方式,叫多播委托。
一个委托内部封装一个方法的形式,叫单播委托。
B.隐式异步调用
何为同步 / 异步?
同步:B等待A做完,再A的基础上接着做
异步:A和B各做各的,同步进行
同步调用和异步调用的对比:
每一个运行的程序是一个进程process。每个进程拥有一个多个线程thread。最先运行的是主线程,其他的是分支线程。
同步调用是指再同一线程内依次调用;
异步调用是指在不同线程调用方法,彼此在逻辑上互不影响(物理上有可能互相抢夺资源)。异步的底层原理是多线程。
串行==同步==单线程,并行==异步==多线程
使用委托进行隐式异步调用:
多播委托中使用invoke()方法,是同步调用
想要异步调用,需要使用委托提供的另一个方法:beginInvoke()
beginInvoke方法会自动生成一个分支线程,之后在分支线程中调用封装的方法
两个参数:1、异步调用的回调;2、第二个参数一般也是null
使用线程进行显式异步调用:
在实际应用中,还有一点需要注意:应该适时地使用接口Interface取代一些对委托的使用。(比如java不具有委托的功能,完全用接口取代了委托)
重构上面<模板方法>的代码,用接口取代委托:
三、关于C#事件
1 事件的基本概念
(1-1)首先了解与事件相关的几个概念
A. 事件 Event:
事件,在日常语言中,可以理解为"能够发生的什么事情"。
而在 C# 中的定义是:一个隶属于类/对象、能够使类/对象具备通知能力 的成员。
------ 作为类/对象的成员,事件、属性、方法等各自具备自己的功能,而<事件>的功能不在于事件本身,而是在于在事件发生时通知订阅者的能力。
------简而言之,事件的功能就是通知。
B. 通知:
如何理解通知?
比如,我们说 手机具有响铃事件,就意味着------手机可以通过响铃这个事件来通知关注手机的人,也可以说,响铃这个事件让手机具备了通知关注者的能力。
当响铃事件发生时,从手机角度看------手机通知关注他的人,要求关注他的人采取行动;
从人的角度看------人得到手机的通知,可以采取行动了;
C. 事件参数:
在通知的基础上更进一步,有时伴随着通知,可能会产生一些与事件相关的数据。
比如手机来电响铃or接收微信响铃,这时手机在提醒关注者的同时也会将消息(电话、微信)发送给关注者 ------ 这种经由事件发送出来的与事件本身相关的数据 ,就是消息,被称为事件参数 EventArgs。
D.事件处理器:
手机的关注者被通知之后,会去检查事件参数,之后根据参数内容采取相应的行动,比如接电话、回微信、或者可能直接忽略消息给手机关机 ------ 这种根据通知和事件参数采取行动的行为 称为响应事件 ,关注者在响应事件时具体所作的事情叫做 事件处理器 EventHandler。
E. 缺少事件参数的事件:
还有一种比较特殊的,只有通知、没有参数的事件。比如开车遇到红灯自动停车、楼里火警铃响了大家自觉逃生------这类事件的特点时:无需额外的消息,事件发生的本身就足矣说明一切。
(1-2)事件的功能
由以上分析可以得出,事件的功能 = 通知(其他的对象/类) + 可选的事件参数(即详细信息)
因此,在程序中,事件用于对象与类间的动作协调与信息传递(消息推送)
------ 事件一旦发生,订阅该对象的对象们会依次接到通知,随后各自做出响应------这时各个对象协调统一的开始工作,程序开始运转。
(1-3)事件的原理
事件模型(event model),又叫发生--->响应模型。
5个部分:闹钟响了我起床 ------ 这里 闹钟、响铃、我、做饭共计4个部分,加上隐含的"订阅"关系,总计5个部分。
5个动作:第4个动作里,依次通知------通知的顺序就是订阅的顺序,先订阅先通知
(1-4)一些说明
一些含义相同的说法:
在日常的开发中需要注意:
2 事件模型的5个组成部分
- 事件的拥有者(event source,对象)
- 事件成员(event,成员)
- 事件的响应者(event subscriber,对象)
- 事件处理器(event handler,成员)------ 本质上是一个回调方法
- 事件订阅 ------ 把事件处理器与事件关联在一起,本质上是一种以委托类型为基础的"约定"
事件拥有者 :事件的源头,一定是对象/类。(又称为事件的源头、事件的主体、事件消息的发送者)
事件 :本身是用来通知的工具,是被动发生的,需要事件拥有者在内部逻辑中触发。
事件响应者:订阅了事件的对象or类,它们在接收到通知后,会使用自身拥有的事件处理器来根据业务逻辑处理事件。
事件处理器:事件响应者的一个方法成员。本质上是一个回调方法。
事件订阅:解决了三个问题 ------
-
事件发生时,拥有者通知哪些对象
-
拿什么样的事件处理器才能处理事件
- 不同的事件(比如电话、微信)所要做出的响应是不同的。
- 当订阅者进行事件订阅时,C#编译器会进行严格的类型检查,用于订阅事件的事件处理器必须和事件遵守同一个约定
- "约定"既约束事件发送的消息类型,又约束事件处理器处理消息的类型。只有二者的消息类型匹配时才能够订阅。
以上所说的"约定",本质上就是委托。因此常说事件是基于委托的。
- 事件的响应者具体用什么方法来处理事件
- 订阅时会进行具体的指定
事件模型几种常见的组合方式:
1星:标准的事件机制模型,MVC/MVP等设计模式的雏形
2星:事件拥有者同时也是事件响应者,用自己的方法订阅自己的事件。常用的模型
3星:应用最广泛的。拥有者是响应者的一个字段成员(包含关系)
比如窗口对象包含按钮。按钮是窗口的成员。一旦点击按钮,窗口的某个订阅方法会得到通知,给窗口做出相应变化(比如页面跳转之类)
之所以应用广泛,是因为它是windows平台默认的事件订阅和处理结构------现在仍在使用。
使用中需要注意的点:
ps.对象打点弹出的下拉框中三种图标------扳手_属性(存储数据),方块_方法(做事情),闪电_事件(通知别人)
3 简单实践
实例1:
实例2:
反映1星的组合方式------拥有者和响应者是不同对象
实例3:
反映2星的组合方式------拥有者和响应者是同一个对象
实例4:
反映2星的组合方式------拥有者是响应者的一个字段成员,响应者用方法订阅着自己字段成员的事件
实现案例:窗口里一个文本框一个按钮,点击按钮,文本框显示HelloWorld。
实例5
事件处理器是可以重用的(比如两个Button绑定同一个事件)
前提是:这个事件处理器必须和被处理的事件保持约束上的一致。
ps.由此可以得出:
- 一个事件可以挂接多个事件处理器(如实例1,Boy和Girl的Action都挂接在Elapsed事件上) ;
- 一个事件处理器也可以被多个事件所挂接;
实例6
额外几种挂接处理器的方式:
四、为做基类而生的"抽象类"与"开闭原则"
1 什么是抽象
抽象关键字 :abstract
抽象方法:没有方法体(即没有逻辑实现)。也称为纯虚方法。
- 因为虚方法virtual是有方法体的,等待子类去override
- 而抽象方法方法体都没有,因此称为纯虚方法。
- 而抽象方法所在的类就是抽象类(一旦包含抽象方法,类就必须也用 abstract 来修饰)
抽象类:函数成员没有被完全实现的(abstract)类。
------反义词是具体类(concrete),就是常说的 "类"。
抽象类中的抽象成员不能是private(因为需要子类去实现它,必须要允许子类访问)
编译器不允许实例化抽象类。(因为含有未实现的方法,实例化后编译器不知道该如何调用)
------ 由此得出抽象类的用处:
- 作为基类;
- 可以声明变量,用基类类型变量引用子类类型的实例
2 开闭原则在抽象类中的体现
开闭原则要求不要修改已经完成的类,而是在类的基础上进行扩展。
结合抽象类,就有了这样的体现 ------
我们应该封装 那些不变的、稳定的、固定的、确定的成员 ,把不确定的 、有可能改变的成员声明为抽象成员 ,留给子类去实现。
3 结合代码重构理解抽象类的作用
假设现在我有一辆小汽车。我为它创建一个小汽车类,具有汽车Run、Stop这两个功能;半年后我又购入一辆卡车,于是新增一个卡车类,具有卡车Run、Stop功能;
这时我发现二者的Stop功能是一样的,为了去除重复代码,我提取一个车辆基类Vehicle,拥有有Stop功能,让两个类分别继承。
代码变得整洁了,但是出现了一个新的问题------当在主函数中用 Vehicle 来声明一个Car的对象,这个对象只能调用Stop,不能调用Run(因为Run方法是在子类中各自新增的)
这时如果想要让这个对象可以调用Run方法,比较传统的方式是在 Vehicle 类中也加一个 Run方法,通过逻辑判断来决定是Car还是Truck
------ 但是这种方式具有缺点,比如我又新购入一辆赛车,就需要在这里又加一条if判断
**------ 这样就是典型的违反了开闭原则(不应该在修bug&新增功能两个目的以外,改动现有类的代码)
这种情况下,解决方案有:
(1)用虚方法解决
Vehicle中定义一个虚方法Run,两个子类重写
但是!接下来我们发现一个问题:就是Vehicle的Run方法永远不会被调用。
这样看来,方法体的存在就毫无意义 ------ 因此更进一步,我们直接去掉Vehicle.Run的方法体------这时Run就变成了纯虚方法 ------ 也就是抽象方法。
(2)用抽象方法解决
------存在抽象方法的类必须也加上abstract关键字,这时Vehicle类也就变成了抽象类。
这时我们再添加一个赛车类,就会发现,完全不需要改动Vehicle类,只需要在现有的基础上扩展一个新类即可------正好符合开闭原则。
以下是最终版本完整代码:
由此可以得出结论:
抽象类------专为做基类而生的类。
它的功能在于:以基类类型来声明子类类型变量,从而引用一些已经完全实现了它抽象成员的子类成员,实现多态。
ps.需要注意的一个点是:子类必须实现抽象类中未完成的方法,否则它就只能也是一个抽象类 ------ 实现抽象方法时和实现虚方法一样,都要加上override关键字。
4 抽象类的进一步进化------>纯抽象类------>接口
按之前的步骤,抽象类完成了。
这时可能会出现一个疑问:有没有一种可能,抽象类里所有的成员都是抽象的?
有可能。
按照以下步骤,我们建了一个 纯抽象类(其余部分代码不变)。
从代码可以看出:从VehicleBase,到Vehicle,再到Car系列的子类,就是一个 特别抽象------抽象------具体 的过程。
到这里,我们搬出一个热知识:在java、C#等语言里,纯抽象类,实际上就是接口。
在C#里,只需要把abstract替换为Interfase即可。
接口要求其所有成员都必须是public的,因此public默认省略。同时接口默认所有成员都是抽象的,因此abstract需要省略掉,避免重复。
------因为接口abstract已省略,因此实现它的子类也要省略override关键字。
以下为进一步进化(纯虚类替换为interface)的代码。可以看出从 接口------抽象类------具体类 的过程。
一个总结:
抽象类是未完全实现,接口是完全未实现。
五、接口 & 单元测试
1 什么是接口
关键字 :Interface
接口成员必须是 public
接口的本质 :服务调用者(消费者)与服务提供者之间的契约。
作为契约,必须对供需双方透明可见(因此其成员必须是public)
接口作为契约,约束是对供需双方的。
比如以下代码,供需双方都遵守IEnumerable接口 ------
接口的产生:自顶向上(重构),自顶向下(设计)
C#中接口的实现:隐式、显式、多接口
2 依赖与耦合
在面向对象的设计中,程序与程序之间的合作,就叫做依赖。依赖的同时就出现了耦合------依赖越直接,耦合就越紧。
引入接口之后,类扩展时只需要在主函数中声明即可,不用动其它部分的代码 ------ 成功解耦,变成松耦合。
接口的作用:接口为松耦合而生。
它可以让功能的提供方变得可替换,减少不可替换带来的高风险和高成本。
- ------ 高风险:提供方出错导致依赖它的其他功能不能正常工作
- ------ 高成本:开发中,提供方部分代码未完成,导致团队其他部分进入等待,不能正常工作
ps.有没有办法连主函数中的代码都不用修改(即new NokiaPhone替换为new EricssonPhone 的部分)------ 有,可以使用反射!直接在配置文件中定义即可。
3、接口和依赖倒置原则
依赖倒置原则:高级模块不应该依赖低级模块,而是依赖抽象接口,通过抽象接口使用对应的低级模块。
体现如下图 ------ 原本向下依赖的箭头 变为 向上依赖的箭头,这就是依赖倒置的名字由来。
4 接口 /解耦 /依赖倒置原则在单元测试中的应用
假如我是生产电扇的厂商,电扇转速的快慢由电源大小决定------
原始的紧耦合写法:
加入接口,完成解耦:
正常开发中,比如这类测试数据一般不允许写在正常代码里,这时就要用到单元测试(把测试数据放在单元测试中)
新建单元测试方法:
右键解决方案------添加------新建项目------选择测试------推荐使用xUnit(.Net平台自己使用,兼容性比较好)
命名可以copy正式项目名,后加".Tests"后缀
在测试项目中写入测试case:
之后就可以运行测试了!
运行之前有两点需要注意:
- 需要给单元测试项目右键添加引用(引用被测试的项目)
- 同时保证被测试的类和方法是public
运行单元测试的方法:
选中项目文件------右键运行测试
在弹出的测试资源管理器中查看测试信息:
如果测试case报错,可以加断点debug寻找问题点,方法和正常debug代码相同。
5 单元测试的进一步优化
按照如上方法,会发现单元测试中存在一个很大的问题:为了测试不同的情况,要不停创建接口的实现类,造成类越来越多
如何解决这个问题------ Mock !
在Nuget管理中安装Moq。
使用Moq可以直接创建实现接口方法的实例,越过创建类的步骤------简化单元测试。
简化后的测试案例:
一个总结:
接口方法(纯虚)------ abstract方法 ------ virtual方法 都属于虚方法的范畴,抽象程度依次递减
virtual、override、实现方法都是有了方法体的方法
6 接口隔离原则
Interface Segregation Principle:一个接口应该拥有尽可能少的行为 ,使其精简单一。对于不同的功能的模块分别使用不同接口,而不是使用同一个通用的接口。
体现在接口中,结合之前的"接口契约论"可知:契约的作用在于,同时约束供需双方,保证供方不会少给,需方不会多要。
- 供方------不少给:是硬性规定,因为实现接口时,必须实现接口中所有方法。
- 需方------不多要:是软性规定,编译器无法识别,需要设计模式来进行规范。
那么,如何诊断需方是否"多要":
传给调用者的接口类型里是否存在一直没被调用的函数成员,如果有,就说明接口不够精简------即,违背了接口隔离原则。
在这种情况下,实现了这个接口的类往往违背了单一职责原则(要求一个类只做一件事/一组相关的事)
------ 很多情况下,接口隔离原则和单一职责原则说的是一回事。一个在服务调用者角度,一个在服务提供者角度。
所以,该如何实现接口隔离原则?
把本质不同的功能隔离开,用不同接口封装起来。
相关的三个案例见:
(00:12:00 ------ 00:39:00)