一.开发环境简介
Visual Studio 2022
两个帮助学习的文档
1.help viewer的MSDN文档
2.微软官网的C#语言定义文档
二.初识各类应用程序
解决方案(Solution)是针对客户需求的总的解决方案。举例:汽车经销商需要一套销售软件
项目(Project)解决某个具体的问题
一个解决方案(Solution)可以包含一个或多个项目(Project),类似于将一个大问题拆分成多个小问题进行解决,一般来说简单的解决方案只需要一个项目就足够了
一个项目可以通过不同的模板来实现,从而满足不同的需求
C#的文件扩展名是.cs
常见的C#项目模板(注:一般C#项目要选框架项目)
1.Console Application( framework)
即控制台应用( framework)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ConsoleHelloWorld
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello world");//在控制台输出一行文本
Console.ReadKey();//按下任意键后继续
}
}
}
单击F5进行调试运行,Ctrl+F5进行不调试运行,调试运行可能会运行完直接自动关闭控制台,而不调试运行不会自动关闭,或者调试运行时在最后加上Console.ReadKey()也不会自动关闭
注:不勾选调试停止时自动关闭控制台也不会使程序运行结束时自动关闭控制台,该选项位于工具-选项-调试-常规中
2.Windows Forms Application( framework)
即Windows窗体应用( framework)
可在视图(view)中找到工具箱(toolbox)进行添加各种组件(如button,text box等)到窗体并编辑位置大小等信息,右击某一组件可以查看并更改其属性,如名称name(取名时尽量取可读性好的有意义的名称)、文本内容text等,在属性框附近的闪电⚡标志表示事件(events),事件表示在对应组件处发生某样操作时程序如何响应
以点击按钮输出Hello world至文本框为例,点击事件标志,双击操作click跳转至代码编辑页面
输入如下代码即可使按钮在按下后文本框输出Hello world
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace WindowsFormsHelloWorld
{
public partial class Form1: Form
{
public Form1()
{
InitializeComponent();
}
private void button1_Click(object sender, EventArgs e)
{
textBoxShowHello.Text = "Hello World!";
}
}
}
3.WPF Application( framework)
即WPF应用( framework),可以看作是Windows窗体应用( framework)的升级版
操作上大体与Windows窗体应用相同,只是多了一些细节
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace WPFHelloWorld
{
/// <summary>
/// MainWindow.xaml 的交互逻辑
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void ButtonSayHello_Click(object sender, RoutedEventArgs e)
{
TextBoxShowHello.Text = "Hello World!";
}
}
}
4.ASP.NET Web Application( framework)
即ASP.NET Web应用程序( framework)
1.Web Forms
创建时选择Web Forms
右击项目选择添加Web窗体并命名项名称
注:Web Forms的扩展名是.aspx
输入以下代码后运行即可在网页上输出Hello world
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Default.aspx.cs" Inherits="WebFormHelloWorld.Default" %>
<!DOCTYPE html>
<html xmlns="http://www.w3/1999/xhtml">
<head runat="server">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title></title>
</head>
<body>
<h1>Hello World!</h1>
</body>
</html>
另:右击项目名,点击发布(publish)可以将其发布至各种网站
2.MVC(Model-View-Controller)
比Web Form先进,能确保代码的结构划分清晰,便于维护
创建时选择MVC
右击Controller添加控制器,选择MVC5控制器并命名
右击view()函数内部添加新视图
然后在添加的视图代码框内输入以下代码并运行即可
@{
ViewBag.Title = "Index";
}
<h2>Hello World!</h2>
注:MVC同样也可以像Web Forms发布网站
还有诸如WCF,WF,Cloud,Windows Store,Windows Phone等其他应用程序模板,但最常用的是本文介绍的前三种Console,WinForms,WPF
三.初识类与名称空间
1.类(class)与命名空间(namespace)
- 类(class)构成程序主体
- 命名空间(namespace)以树形结构组织类(和其它类型),可以有效地避免同名类的冲突
注:C#是完全面向对象的语言,因此程序本身也是一个类(class)
关于面向过程和面向对象:
面向过程:注重事情的每一个步骤以及顺序,比较高效
面向对象:注重事情有哪些参与者,参与者各自需要做什么事情,更易于维护拓展
using namespace即引用命名空间,类似于C中的引用头文件#include headfiles
以命名空间System内的Console类为例,若在开头using System,之后可以直接使用Console类,程序会自动在命名空间System中找到Console类,减少代码量,提高开发效率,否则在后续使用时只能写全限定名System.Console,不然程序无法识别
如果不知道某个系统类来自于哪个命名空间,可以打开help viewer在index中进行搜索查询(若有多个查询结果,一般写桌面程序的时候选择 framework)
使用关键字typeof(),再用数据类型Type声明一个变量并引用,可以获取到一个数据的数据类型,并可以使用Console.WriteLine()和FullName打印出其全限定名
例:Type myType = typeof(Form);
Console.WriteLine(myType.FullName);即可获取Form的全限定名
或者如果实在分不清help viewer中查询到的多个结果也可以使用VS的补全操作,将鼠标移至所要补全的对象,稍等一会儿或使用alt+enter快捷键即可弹出选项,选择using System即在程序头自动加上using语句,或选择System.Console自动补全全限定名
注:1、因为不同的命名空间可能包含同名的类,因此一次性引用太多命名空间可能会造成类冲突,此时只能使用全限定名,导致命名空间失去作用(命名空间的作用就是分隔同名类,没有命名空间的话为了使类名不冲突只能使得类名加上各种前缀,越来越复杂,因此在自己设计时应精确命名类并将其放入所属的命名空间)
2、在C#中父命名空间一般不自动导入子命名空间,这样是为了避免类型命名污染和冲突,同时减少编译器不必要的类型搜索,提高效率,这样显式引用还能明确依赖关系,提高可读性。例如System.Threading在逻辑上是System的子命名空间,但是在使用时,即使引用了System仍然需要显式引用System.Threading。
2.类库(Class library)
类库:用于存放命名空间和类
可以在help viewer中对某一对象的所属类库进行查询,在其所属程序集位置
类库的引用
类库的引用是使用命名空间的物理基础,可以在解决方案资源管理器中的引用查看到当前项目所引用的类库(注:引用只是拥有了能够using的条件,在程序中using namespace才算使用,才能够在程序中使用其空间内的类)
双击其中的类库可以打开对象浏览器,可以在此查看当前类库中的命名空间和类
一般创建项目的时候使用不同的模板系统会自动引用一些所需的不同的类库
若要手动引用类库,有以下两种方式
1.DLL引用(黑盒引用,无源代码)
dll:dynamic link library 动态链接库
配合对应的文档阅读使用,否则没有意义,若是系统类库则可查询MSDN文档
右击资源管理器的引用-添加引用-浏览-添加对应类库.dll文件即可(程序集选项是用于添加系统类库,浏览选项用于添加个人类库)
存在问题:没有源代码,如果类库中存在bug,则使用方无法修改只能由类库编写人员在源代码中修改并重新编译新的.dll文件发送给使用方进行引用解决bug
NuGet技术
NuGet技术用于解决复杂的依赖关系,即引用一个类库时自动引用更底层的前置类库,类似于MOD整合包,同样在引用中右击使用
2.项目引用(白盒引用,有源代码)
如果要建立自己的类库项目,右击解决方案-添加-新建项目-类库,进行类库编写,然后右击引用-添加引用-项目-解决方案即可引用
3.依赖关系(耦合关系)
优秀的程序追求“高内聚,低耦合”,高内聚:类要精确地放在自己所属的类库中,低耦合:类,类库之间的依赖关系要尽可能的弱。
可以画出UML图查看各个对象之间的依赖关系
3.类、命名空间、类库之间的关系
类似于书、书架、图书馆之间的关系
四.类、对象、类成员简介
1.类的概念
类(class)是现实世界事物的模型,是对现实世界事物进行抽象所得到的结果
·事物包括“物质”(实体)与“运动”(逻辑)
·建模是一个去伪存真,由表及里的过程
2.对象的概念
对象也叫实例,是类经过实例化之后得到的内存中的实体。通常情况下,对象与实例是一回事,常常可以混用,只是有时候在语境上有细微差别:对象侧重现实世界,实例侧重程序世界
所谓实例化,即依照类创建对象
注:有些类无法实例化比如数学
2.1.使用new操作符创建类的实例
以创建一个form类实例为例,输入以下代码即可在内存中创建一个form实例并显示在控制台
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace Testing
{
class Program
{
static void Main(string[] args)
{
new Form().ShowDialog();//创建一个form实例并显示在控制台
}
}
}
2.2.引用变量和实例之间的关系
创建一个实例之后需要将其内存地址传递给引用变量,否则无法对创建的实例进行操作
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Form myForm;//声明引用变量myForm,类型为Form类
myForm = new Form();//此处的myForm是引用参数变量,类似于C中指针
myForm.Text = "Hello";//对新建实例myForm进行操作
myForm.ShowDialog();
}
}
}
引用变量与实例之间的关系类似于孩子和气球的关系:
一个引用变量可以没有对应的实例----一个孩子没有牵着气球;
一个实例可以没有对应的引用变量----一个气球没有孩子牵着(此时该实例在被创建后一段时间会被内存垃圾收集器回收,类似于气球飘走了)
一个实例可以有多个对应的引用变量-----一个气球可以由多个孩子使用各自的绳子牵着(也有使用同一根绳子的情况,使用ref参数,后续会讲)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Form myForm1,myForm2;//声明引用变量myForm1,myForm2,类型为Form类
myForm1 = new Form();//此处的myForm是引用参数变量,类似于C中指针
myForm2 = myForm1;//将myForm2也引用myForm1所引用的实例
myForm1.Text = "Hello";
myForm2.Text = "I changed it";
myForm1.ShowDialog();//myForm1和myForm2引用的是同一份实例,指向同一地址空间,因此两者显示的都是I changed it
}
}
}
引用变量存的是内存地址,类似C中指针
3.类的三大成员
3.1.属性(property)
存储数据,组合起来表示类或对象当前的状态
3.2.方法(method)
由C语言中的函数(function)进化而来,表示类或对象能做什么
3.3.事件(event)
类或对象通知其他类或对象的机制,为C#独有(java通过其他方式实现这个机制)
善用事件机制非常重要,切勿滥用
一个系统类的功能简介、属性、方法和事件可以在对应MSDN文档中查询
一个类不一定每种成员都有,某些特殊的类或对象在成员侧重方面有所不同:
模型类或对象侧重属性,如Entity Framework
工具类或对象侧重方法,如Math, Console
通知类或对象侧重事件,如各种Timer
4.静态成员和实例成员
静态(static)成员在语义上表示他是“类的成员"
实例(非静态)成员在语义上表示他是"对象的成员"
可理解为静态成员不需要类实例化即可使用,而实例成员需要类实例化之后才能使用
例:Console.WriteLine()中WriteLine是静态成员,而Form myForm = new Form(); myForm.Text = ="Hello"; Form.ShowDialog();中Text和ShowDialog都是实例成员
注:在MSDN文档中带大S图标的就是静态成员,如静态属性
绑定(binding) 指的是编译器如何将一个成员与类或对象关联起来
访问类或对象的成员时用.操作符即成员访问操作符
五.C#语言基本元素概览,初识类型、变量与方法,算法简介
1.构成C#语言的基本元素
注:关键字,操作符,标识符,标点符号和文本统称为标记(Token),即对编译器有意义的记号
1.1.关键字(Keyword)
关键字就是构成一门编程语言的基本词汇
MSDN文档中可以查询到C#所有的关键字,查询方法:MSDN-目录-Visual Basic和Visual C#-Visual C#-C#参考-C#关键字
下面两张表包含C#所有的关键字
上表的关键字在任何时候都是关键字,而下表的关键字称为上下文关键字,只有在特定的语境下才算关键字,其他情况下不是关键字
关于C#中五个权限修饰符以及其权限
修饰符 | 级别 | 适用成员 | 解释 |
public | 公开 | 类及类成员 | 对访问成员没有级别限制 |
private | 私有 | 类成员 | 只能在类的内部访问 |
protect | 受保护的 | 类成员 | 在类的内部或者在派生类中访问,不管该类和派生类是否在同一程序集中 |
internal | 内部的 | 类及类成员 | 只能在同一程序集中访问 |
protected internal | 受保护的内部 | 类及类成员 | 如果是继承关系,不管是不是在同一程序集中都能访问,如果不是继承关系只能在同一程序集中访问 |
如果在声明时未指定访问修饰符,则使用默认的访问修饰符,类的默认访问权限是internal,类成员的默认访问权限是private
注:关于程序集,一个程序集就是一个项目(project)编译后的结果,例如:在一个解决方案在有两个项目A,B,其中A中有个class为internal级别的,那么B引用了A的程序集也不能调用这个类。常见的程序集就两种,分别是.exe和.dll
static是状态修饰符,表明当前属性是所属类的静态成员
1.2.操作符(Operator)
操作符也叫做运算符,是用来表达运算思想的符号
MSDN文档中可以查询到C#所有的操作符,查询方法:MSDN-目录-Visual Basic和Visual C#-Visual C#-C#参考-C#操作符
以下为C#包含的所有操作符(注:有些操作符和关键字是重名的)
1.3.标识符(Identifier)
标识符就是命名时给变量,类或类的成员等取的名字
1.3.1.标识符的合法性
能够通过编译器编译的就是合法的标识符,反之不合法
要成为合法的标识符需要满足以下条件:
1.不能是关键字,如果非要拿关键字做标识符,需要在关键字前面加上@符号
2.必须以字符或者下划线开头(字符包括大小写字母和汉字)
3.开始字符后面可以跟数字,字符,下划线等等
1.3.2.标识符的命名规范
命名一定要有意义并且可读性高,如类的名字一定要是名词,属性一定要是名词,方法一定要是动词或动词短语等等
1.3.3.标识符的大小写规范
一般有驼峰命名法和Pascal命名法
1.驼峰命名法(一般用于变量名):总名称第一个字母小写,后面每个单词的首字母大写
2.Pascal命名法(一般用于方法、类、命名空间等):每个单词的首字母都大写
4.标点符号
;和{}等不参与运算的符号
5.文本(以下只列举了一小部分)
1.5.1.整数
整型int(32位)、长整型long(64位)
赋值例:int x = 4; long y = 100L;(长整型一般需要在数值后面加上大写L)
1.5.2.实数
单精度浮点型float(32位)、双精度浮点型double(64位)
赋值例:float x = 3.0F; double y = 4.0L;(单精度浮点型和双精度浮点型一般需要在数值后面分别加上F和D)
1.5.3.字符
字符型char,赋值时要在字符上加上单引号''
赋值例:char x = 'a';
1.5.4.字符串
字符串型string,赋值时要在字符串上加上双引号""
赋值例:string x ="hello";
1.5.5.布尔值
布尔型bool,赋值数值只有true和false两种
1.5.6.空值(null)
无任何值,为空
6.注释与空白
单行注释://,块注释:/*代码段*/
C#中当有多个空白时,只会自动保留一个空白如 int a = 1;和int a = 1;是一样的(若程序代码格式有很多乱的地方,可以点击编辑-高级-设置文档格式进行快速格式化)
2.初识类型、变量和方法
2.1.数据类型
有整型char,长整型long,浮点型float,双精度浮点型double,字符型char等等各种数据类型,一般在声明变量的时候需要主动表明其数据类型,称为显式类型变量,或者使用关键字var来让系统在编译时自动推断并分配当前数值的数据类型(一旦分配不可更改),称为隐式类型变量(注:dynamic在运行时确定数据类型)
例:int a = 3;或 var a = 3(此时系统会自动将a分配为int类型)
2.2.变量
变量是存放数据的地方,简称数据
变量的声明和使用
声明时,用变量的数据类型加上变量名即可,后续使用直接使用变量名即可
2.3.方法
由C语言中的函数进化而来,是处理数据的逻辑,又称算法,是数据的”加工厂“
using System;
namespace Testing
{
class Program
{
static void Main(string[] args)
{
int a = 1;
int b = 2;
Console.WriteLine(Calculator.AddTwo(1, 2));//调用方法AddTwo完成两数相加并打印结果
}
class Calculator//定义Calculator类
{
public static int AddTwo(int a,int b)//public修饰符使得方法AddTwo在Calculator类外也可以被调用,static修饰符表示方法AddTwo是静态成员
{
return a + b;//返回两数相加结果
}
}
}
}
3.算法简介
C#中的循环、递归和分支结构基本上与C中一样,但要注意的是C#中的for循环的初始化条件要求很严格,初始化条件部分只能为声明变量并复制语句,或者是在循环外声明变量在初始化条件部分重新赋值或者为空,否则会报错。而C中可以只为一个变量名不会报错,但是也不推荐这样做。
六.详解类型、变量与对象
1.类型(Type)
1.1.概念
又称数据类型(Data Type),是性质相同的一些值的集合,并进行有效的表达,同时配备了一系列专门针对这种类型的值的操作,即数据类型=值+操作。
数据类型也表示了数据在内存中存储时的型号,小内存容纳大尺寸数据会丢失精度,发生错误等,而大内存容纳小尺寸数据会导致浪费。
强类型语言与弱类型语言的比较
即数据受数据类型约束的强度,如C#是强类型语言,禁止不同类型的数据给变量赋值(但可以进行隐式类型转换),bool 变量只能为true或false两个值,而C相比之下就弱一些,比如可以bool a = 1,此时C将a看为true,而JavaScript就是彻底的弱类型语言,完全不受数据类型约束,如var a = 100; a = ”hello“;不会报错,(注:这里的var与C#的var不是同一种意思,C#中的var会给变量名分配一个所属数据类型),在C#中使用dynamic模仿JavaScript的弱类型。
1.2.作用
一个C#类型中所包含的信息有:
- 存储此类型变量所需的内存空间大小
- 此类型的值可表示的最大最小值
- 此类型所包含的成员(如方法,属性,事件等)
- 此类型由何基类派生而来
- 此类型所允许的操作(运算)
- 程序运行的时候,此类型的变量被分配在内存的什么位置
栈(Stack)和堆(Heap):都是内存中的一片区域,不同类型的变量会分别被选择存放在栈还是堆中,调用的方法存放在栈中,空间较小,会栈溢出(Stack Overflow),对象存放在堆中,空间很大, 会内存泄露,即分配的空间没有回收(C中需要手动释放使用的内存,内存泄露就真的泄露了,但C#中有垃圾收集器,会在合适的时机收集没有使用的内存,无需手动释放,基本上不会出现内存泄露,这是托管语言的特性)
tip:C#中不推荐使用指针,如果非要使用需要加上unsafe关键字
2.C#语言的类型系统
2.1.C#的五大数据类型
查询一个目标的数据类型的方法:
1.去MSDN文档搜索可以查询到当前目标属于哪个数据类型,例如查询到Console是属于类的:
2.使用Type类,再使用关键字typeof()就可以使用Console.WriteLine()和FullName打印出其全限定名
例:Type myType = typeof(Form);
Console.WriteLine(myType.FullName);即可获取Form的全限定名
再利用全限定名去MSDN文档中查询其数据类型,或者直接使用语句 Console.WriteLine(myType.IsClass);判断其是否是类
关于Type类
3.直接转到要查询目标的定义代码处查看
类(Classes):如Window, Form,Console,String
结构体(Structures):如Int32(等效Int),Int64(等效Long),Single,Double
枚举(Enumerations):如HorizontalAlignment,Visibility
接口(Interfaces)
委托(Delegates)
2.2.C#类型的派生谱系
水蓝色表示这些关键字代码定义就是一个数据类型,由于太常用,C#已经将他们吸收为自己的关键字,并且他们是最基础的数据类型,其他的数据类型都由他们组成。
3.变量、对象与内存
3.1.变量
从表面上看,变量的用途是存储数据,实际上,变量表明了存储位置,并且决定了什么样的值能够存入该变量(以变量名所对应的内存地址为起点,以其数据类型所要求的存储空间为长度的一块内存区域)。
变量一共有七种:
静态变量(静态字段)使用static修饰,
实例变量(成员变量,字段)不使用static修饰,
数组元素,数组使用数据类型+[]+名称声明,如int[] array = new int[100],
值参数变量,
引用参数变量在参数类型前面用ref修饰,
输出参数变量在参数类型前面用out修饰,
局部变量(本地变量)
狭义的变量指局部变量,因为其他种类的变量都有自己约定俗称的名称,一般不喊变量
简单地说,局部变量就是方法体(函数体)中声明的变量
注:引用类型不加修饰声明的变量是引用参数变量,因为引用类型本身代表的就是地址,值类型不加修饰声明的变量是值参数变量
变量的声明:(有效的修饰符组合+)类型+变量名(+初始化器),括号表示可有可无
3.2.引用类型与值类型的区别
引用类型变量内存的是引用类型实例在堆上的内存地址,值类型没有实例,所谓的“实例”与变量合二为一,因此,在初始化引用类型变量的时候需要new而值类型不需要。
3.3.内存分配与其他一些概念
局部变量会被分配在栈上,实例变量会随着实例分配到堆上
变量的默认值:成员变量在实例创建后若不显式地赋值,系统会给每个bit置0
注:C#中局部变量系统不会给予默认值,因此声明时必须显式给出值(C中会给局部变量默认值)
常量:即值不可更改的变量,使用关键字const修饰,在声明时必须显式给出值,不能省略初始化器,否则报错,赋初值后便无法更改了。
装箱与拆箱(Boxing & Unboxing):装箱就是将栈上的值类型的值封装成object类型的实例复制到堆上,拆箱就是将堆上object类型的实例里的值按照要求拆成目标数据类型复制回栈上,这两种操作会损失性能。(下面代码就是装箱与拆箱)
七.方法的定义,调用与调试
1.方法的由来
方法 (method)的前身是C/C++语言的函数(function),又称成员函数
方法是面向对象范畴的概念,在非面向对象语言中仍称为函数,函数在成为类或结构体的成员之后就叫做方法,而方法永远都是类或者结构体的成员,不可能独立于类或结构体之外存在(在C++中可以,称为全局函数)
方法是类或结构体最基本的成员之一,最基本的成员只有两个:字段和方法(成员变量与成员函数),本质还是数据+算法,其中方法表示类或结构体能做什么事情。C#中方法的声明顺序绝大部分情况下不会影响可调用行,即绝大部分情况下可以前向调用
同一个类中成员互相调用不需要指明来自哪个类,不同类的成员互相调用需要指明所属类
使用方法和函数的目的:1,隐藏复杂逻辑,2,将大算法分解为小算法,3,复用
2.方法的声明与调用
C#中方法的声明与定义不分家,声明时有三个必要条件:返回值类型,函数名,圆括号
定义时圆括号内的参数称为形式参数 (parameter),形式参数需要写上数据类型和名称
调用方法时直接写函数名,圆括号加上参数即可,此时的参数是实际参数(Argument),实际参数只需要写上数据名称或者值即可
C#是强类型语言,因此实际参数的类型一定要与形式参数匹配,不然会报错
静态方法使用static修饰,属于类的成员,实例方法属于实例的成员,两者使用时的差别在于是否需要实例化,静态方法不需要实例化即可使用,而实例方法需要实例化才可使用
方法体内定义的变量和常量称为局部变量和局部常量,作用域限制在方法体内
自己定义方法时尽量符合单一职责原则:即一个方法尽量只做一件事情,从而方便程序的修改和维护
3.构造器(Constructor)
即构造函数,是类型的成员之一,属于特殊的函数(没有返回值)
狭义的构造器指的是实例构造器,用于构造实例的内部结构(静态构造器只需将实例构造器的public改为static即可,用于初始化静态成员,静态构造器在类首次被使用时自动调用且只会被调用一次)
默认构造器
当声明一个类的时候,若没有显式为其准备一个构造器,编译器会给其准备一个默认构造器,
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Student stu = new Student();//此处student()就是调用了默认构造器
Console.WriteLine(stu.ID);
Console.WriteLine(stu.Name);
}
class Student
{//不自己写构造器,程序会自动默认生成一个构造器,其中值类型默认值为0(全部bit默认值为0),引用类型默认值为NULL
public int ID;
public string Name;
}
}
}
显式准备构造器有两种:无参数和有参数的,且一般构造实例实在别的类内部构造,因此要加上public修饰符,且构造器是特殊的函数,没有返回值,连void都不用写,命名时需要与构造器所属的类名完全一致,构造器的声明与调用和函数一样。
显式准备不带参数的构造器
关键字this表示当前实例本身
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Student stu = new Student();//此处student()即调用了准备好的无参数的构造器
Console.WriteLine(stu.ID);
Console.WriteLine(stu.Name);
}
class Student
{ public Student()
{
this.ID = 1;
this.Name = "No name";//手动赋初值,this表示当前实例
}
public int ID;
public string Name;
}
}
}
显式准备带参数的构造器
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Student stu = new Student(2,"hello");//此处的Student(2,"hello")即调用了准备好的带参数构造器
Console.WriteLine(stu.ID);
Console.WriteLine(stu.Name);
}
class Student
{ public Student(int initID, string initName)
{
this.ID = initID;
this.Name = initName;//this表示当前实例,将值赋为给定参数值
}
public int ID;
public string Name;
}
}
}
一个类内部可以自行准备多个构造器,并且可以同时启用,但当有自行准备的构造器时,默认构造器失效
tip:在类内部输入ctor按下回车可以快速建立一个构造器框架
4.方法的重载(Overload)
当为一个类创建多个方法的时候,方法的名称可以完全一样但是方法的签名不能一样,这就称为方法的重载
方法签名:由方法的名称,类型形参的个数和他的每一个形参(按从左至右的顺序)的类型(如int, double等)以及种类(值不加、引用加ref或输出加out)组成。方法签名不包括返回类型
实例构造器也可以重载,实例构造函数的签名由他的每一个形参(按从左至右的顺序)的类型(如int, double等)以及种类(值不加、引用加ref或输出加out)组成。
重载决策:即到底调用哪一个重载,用于在给定了参数列表和一组候选函数成员的情况下,选择一个最佳函数成员来实施调用
5.如何对方法进行debug
- 设置断点
- 观察方法调用时的call stack(调用堆栈)
- step-in步入, step-over跨步, step-out步出
- 观察局部变量的值与变化
注:方法调用层级过深会导致栈溢出
八.操作符详解
1.操作符概览
操作符(operator)也叫运算符,是用来操作数据的,被操作符操作的数据称为操作数(operand)
这张表格为C#的所有操作符,运算优先级从上往下依次降低,同一行的优先级相同,注意赋值运算符的运算顺序是从右往左
1.1.基本操作符
- 成员访问操作符.:访问外层名称空间的子集名称空间;访问名称空间中的类型 ;访问类的静态成员;访问对象的成员
- 方法调用操作符f(x):用于调用方法,调用方法时()不能省略
- 元素访问操作符[]:用于访问数组和字典等内的元素,创建数组例:int[] myArray = new int[5]{1,2,3,4,5};int[] myArray = {1,2,3,4,5};数组属于引用类型,后面的花括号为初始化器,且不像C,C#数组中的个数必须与初始化赋的个数完全一样,访问时直接int[0]即可,下标从0开始。[]不一定是整数,如在访问字典成员的时候放入的是集合索引
- 后置自增和自减x++,x--:相当于x=x+1,x=x-1,并且是先赋值后运算,表达式的返回值为运算前的x值
- new操作符:在内存中创建一个类型的实例并立即调用实例构造器,而且能得到该实例的内存地址并通过赋值符号将地址交给负责访问该实例的引用变量;还能调用该实例的初始化器设置该实例的属性值,使用方法是在调用实例构造器后用一对花括号即可调用初始化器(可同时初始化多个属性),除此之外new也是一个修饰符,可用于修饰子类,对父类的方法进行隐藏,但不常用
- 注:new操作符不可滥用,会造成紧密耦合,为解决此问题可以在设计模式中使用依赖注入将紧密耦合变为比较松的耦合(了解即可)
为匿名类型创建对象:
非匿名类型就是显式声明类型名的类型如Form类等,匿名类型就是没有明确告诉类型名的类型,为其创建对象的语法是:var person = {Name = "Mr.Okay", Age = 20};即可创建对象并用隐式类型变量来引用实例,其类型名字为 < >_ AnonymousType0'2,< >_ AnonymousType是约定的匿名类型前缀,0表示在程序中创建的第一个匿名类型,2表示泛型类,构成该类型的时候需要两个类型,
C#的语法糖衣:对于最基础的数据类型,本来需要使用new的可以省略掉new,像值类型一样直接创建实例,如string = "hello";int[] myArray = {1,2,3,4,5};等,达到统一操作、简化代码的目的,称为语法糖衣
- typeof操作符:用于查看一个类型的内部结构,与Type类搭配使用
- default操作符:帮助获取一个类型的默认值,以结构体类型和引用类型为例,会返回每位全0的值,注意枚举类型的默认值返回的是第一个数值(均未赋值)或者显式赋值为0的对象 ,若没有显式赋0的值,,返回的值为0,不属于枚举的任意值。
- checked和unchecked操作符:checked用于检查一个值在内存中是否有溢出,若有则抛出一个异常,unchecked用于告诉程序不需要去检查是否溢出,不显式写出时默认为unchecked,此时如果发生溢出,不会抛出异常而是会自动对结果进行截断,checked和unchecked也可用于上下文语句块,被包含的语句块都会被检查或不检查。检查时一般使用catch语句抓住异常
- delegate操作符(已过时):一般更广泛的用途不是用作操作符而是作为关键字来声明委托。而用于操作符的作用:声明匿名方法已经被Lambda表达式取代
- sizeof操作符:用于获取一个对象在内存中所占字节数。默认情况下只能用于获取基本结构体数据类型的实例在内存中所占字节数,非默认情况下,可以使用sizeof操作符获取自定义的结构体类型的实例在内存中所占字节数,但是需要将其放在不安全unsafe的上下文中并启用项目的不安全代码选项。
- ->操作符:用结构体指针访问结构体的成员,且该操作符只能用于访问结构体类型,还要放在不安全的上下文中才能使用(用.操作符属于直接访问,用->操作符属于间接访问)
1.2.一元/单目操作符
- &和*操作符:&为取地址符,*为解引用符,C#中与指针相关的操作符都只能在unsafe上下文中使用并启用项目的不安全代码选项,使用条件很有限。
引用类型变量与指针区别:
引用变量是安全的,并且由垃圾回收器自动管理内存,不能操作变量存储的地址
指针是不安全的,需要手动释放内存,能直接操作地址
一般在C#中优先使用引用变量
- +,-操作符:正负操作符,用于的指定的值取正或反,但是连续使用时需要用()分隔开,不然会变成前置自增或自减操作符。使用正负操作符可能会导致溢出,因为一个数据类型能存下的最大正数与负数的绝对值一般不相等。
- ~操作符:取反操作符,对一个值在二进制级别上进行按位取反
- !操作符:非操作符,只能用于操作bool类型值
- 前置自增和自减++x,--x:相当于x=x+1,x=x-1,并且是先运算后赋值,表达式的返回值为运算后的x值
注:自增自减运算符尽量单独使用,不然代码可读性很差
- (T)x操作符:强制类型转换操作符
关于类型转换
隐式类型转换(implicit):不需要明确表明要将一个值的数据类型转换为另一种数据类型
- 不丢失精度的转换
下表显示C#内置数值类型之间的预定义隐式类型转换:
- 子类向父类的转换
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Teacher t = new Teacher();
Human h = t;
Animal a = h;//此时,发生了t到h,h到a的隐式类型转换
}
class Animal
{
public void Eat()
{
Console.WriteLine("Eating...");
}
}
class Human:Animal//表示Human派生自Animal,此时Human具有Animal所有的成员
{
public void Think()
{
Console.WriteLine("I am thinking");
}
}
class Teacher:Human//表示Teacher派生自Human,此时Teacher具有Human所有的成员(包括Animal的成员)
{
public void Teach()
{
Console.WriteLine("I am teaching programming");
}
}
}
}
注:当使用一个引用变量去访问它所引用的实例所具有的成员的时候,只能访问到这个变量的类型所具有的成员,如上述代码所示,在引用变量h中只能访问到Human类型所具有的成员(当然包括Animal类型的成员),而无法访问到子类型Teacher类型特有的成员如方法Teach()
- 装箱
显式类型转换(explicit):明确表明要将一个值的数据类型转换为另一种数据类型
- 有可能丢失精度(甚至发生错误的转换),即cast,当两个数据类型差距不大时,使用强制类型转换操作符(T)x将大的数据塞到小空间内
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine(ushort.MaxValue);
uint x = 65536;
ushort y = (ushort)x;//将x强制转换为ushort类型导致位数丢失
Console.WriteLine(y);
}
}
}
下表显示C#不存在隐式转换的内置数值类型之间的预定义显式转换:
- 拆箱
- 使用Convert类的各种静态方法
使用Convert类的各种静态方法可以进行大部分数据类型的转换(有的会精度丢失),当两个数据类型差距过大时也可以如String(此处必须是纯整数字符串不能是纯文本字符串)转为Int,若转为double或float类型时,字符串可以是科学计数法字符串,因为科学计数法通常用于浮点数
namespace Testing { class Program { static void Main(string[] args) { int a = Convert.ToInt32("123");//将整数字符串123转为整数123 Console.WriteLine(a); double b = Convert.ToDouble("1e3");//将科学计数法字符串1e3转为浮点数1000 Console.WriteLine(b); } } }
- ToString方法与各数据类型的Parse/TryParse方法
将数值类型转为字符串类型可以调用Conver类的静态方法ToString或者调用数值类型数据的实例方法ToString
namespace Testing { class Program { static void Main(string[] args) { int a = 1234; int b = 12345; string astr = Convert.ToString(a);//Convert类的静态方法 string bstr = b.ToString();//数值类型的实例方法 Console.WriteLine(a); Console.WriteLine(b); } } }
大部分目标数据类型的Parse方法对字符串进行解析(有一些数据类型没有此方法如string类型)也可以实现字符串类型到数值类型的转换,但字符串类型只能为整数字符串,若目标数据类型为浮点型则可以为科学计数法字符串,TryParse对程序更友好
namespace Testing { class Program { static void Main(string[] args) { string a = "1234"; int aint = int.Parse(a);//利用目标数据类型int的Parse方法实现字符串到数值的转换 Console.WriteLine(a); string b = "1e2"; double bdou = double.Parse(b);//利用目标数据类型double的Parse方法实现科学计数法字符串到数值的转换 Console.WriteLine(b); } } }
自定义类型转换操作符
格式一般为public static implicit/explicit operator 目标类型名(源类型名 源类型对象名) ,且它的返回类型就是目标类型,而且必须有返回值,返回值为目标类型的对象
格式中的目标类型名表示该转换操作符的名字,括号内的内容表示他操作的对象
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Stone HolyStone = new Stone();
HolyStone.Age = 5000;
Monkey Wukong = (Monkey)HolyStone;
Console.WriteLine(Wukong.Age);
}
class Stone
{
public static explicit operator Monkey(Stone stone)//自定义显式类型转换操作符,由Stone类转换到Monkey类
{
Monkey moneky = new Monkey();
moneky.Age = stone.Age / 100;
return moneky;//返回目标类型的对象
}
public int Age;
}
class Monkey
{
public int Age;
}
}
}
上述代码创建的是显示类型转换操作符,若要创建隐式类型转换操作符,将explicit修饰符改为implicit即可
1.3.算术操作符
+,-,*,/,%操作符:加,减,乘,除,取余操作符
在使用时要注意:每一个算术操作符都是与他的数据类型相关的(运算符两边若都是整数则进行整数运算,若都是浮点数则进行浮点运算,若都是金融小数decimal类型则进行金融小数运算),以及数值提升即数据类型提升(不同类型数值运算的时候会将运算结果自动提升为精度更高的那一个)
- 加法操作符也可以用于连接两个字符串或者操作委托
- 浮点数类型除法可以被除数为0,其余类型则不可以,会报错
- 浮点类型double和float有两种特殊数值:NaN(即不是数)和无穷大(+∞和-∞),若要拿到这无穷大可以使用double a = double.PositiveInfinity(正无穷大)或者double.NegativeIndinity(负无穷大)
因此对于浮点类型的数值运算操作有些特殊情况,以下列举出了浮点数的各种运算的结果列表:
乘法操作:
除法操作:
取余数操作:
加法操作:减法操作:
1.4.位移操作符
<<, >>操作符:左移,右移操作符,用于操作数值二进制代码的左移右移
左移相当于乘以2,并且补位无论正负均补0,右移相当于除以2,正数补0,负数补1
移位时要注意溢出和精度丢失问题
左移位(Left Shift):如果是 8 位有符号数,其表示范围是 - 128 到 127。当向左移位时,如果原始数值的符号位(最高位)在移位后变为0,并且这个0不是由原始数值的符号位移动过来的,那么就会溢出。例如,8位整数的最大正值是01111111(十进制127),如果左移一位变成11111110,这实际上是一个负数,因为最高位是1,这在二进制补码表示中是负数的符号位。
右移位(Right Shift):在算术右移中,通常不会发生溢出,因为右移位会保留符号位。但是,如果右移导致数值超出了数据类型的表示范围,那么也会发生溢出。例如,8位整数的最小负值是10000000(十进制-128),如果右移一位变成11000000,这超出了8位整数的表示范围
1.5.关系和类型操作符
<, >, <=, >=, ==, !=操作符:关系操作符,可以用来判断数值类型之间的关系、字符类型之间的关系或者字符串类型之间的关系,他们的运算结果都是布尔类型
数值类型和字符类型都可以比较大小或是否相等,比较字符类型的关系时,是自动转为比较他们对应的Unicode码值,而字符串类型只能使用==或!=比较 (或使用String.Compare()方法进行比较)
不管是C还是C#,比较两个不同类型的数值大小时,编译器会自动进行隐式转换,因此直接进行比较即可,不需要强制类型转换
using System;
namespace Testapplication
{
class Test
{
static void Main(string[] args)
{
int x = 5;
double y = 2.1;
var result = x > y;//比较时虽然数值类型不同,但编译器会自动进行隐式转换
Console.WriteLine(result.GetType().FullName);//关系比较的结果类型是布尔类型
Console.WriteLine(result);
char char1 = 'a';
char char2 = 'A';
result = char1 > char2; //字符类型也能比较大小和是否相等,编译器会自动将字符转换为对应Unicode编码进行比较
Console.WriteLine(result);
ushort u1 = (ushort)char1;
ushort u2 = (ushort)char2;
Console.WriteLine(u1);
Console.WriteLine(u2);//将a与A的Unicode编码打印出来可以发现a为97,A为65,因此比较结果为True
string str1 = "abc";
string str2 = "Abc";
result = str1 != str2;//关系比较操作符只有==和!=能用于字符串类型比较,编译器会将字符串对齐挨个字符比较Unicode码值
Console.WriteLine(result);
int NewResult = String.Compare(str1,str2);//也可以使用String.Compare()方法比较字符串的大小关系,此时能比较大小关系,返回值为整数
Console.WriteLine(NewResult);//若返回值为0,则相等,为正值,前者大于后者,为负值,前者小于后者
}
}
}
is, as操作符:类型检验操作符
is操作符用于检验某一变量是否属于该类型,运算结果类型也是布尔类型(注:引用变量检验的是其所指向的实例类型而非变量本身的类型),若该变量的类型派生自某一类型,则用is判断其是否是该父类型检验的结果也为True,反过来则不行。
所有的类型都默认由object类派生而来
as操作符的运算结果为null或者当前同一对象的引用,可以用于判断是否能进行强制类型转换或者检查当前对象的类型是否与目标类型兼容
using System;
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Teacher t = new Teacher();
var result = t is Teacher;
Console.WriteLine(result.GetType().FullName);//is操作符的结果为布尔类型
Console.WriteLine(result);//因为引用变量t指向的实例是teacher类,因此结果为True
object o = new Teacher();//创建object类型引用变量o,创建teacher类的实例并用o引用,此时该实例发生隐式类型转换退化,只能调用object类的成员(object是所有类型默认的父类型)
/*if(o is Teacher){
Teacher tea = (Teacher)o;//强制将o转换为teacher类型引用变量
tea.Teach();//从而可以调用teacher类成员
}*/
Teacher tea = o as Teacher;
if(tea != null)
{
tea.Teach();
}//除去上述注释掉的代码段外,也可以使用as操作符对变量进行类型转换,若对象与给定的类型兼容则返回同一个对象的非null引用,否则返回null
}
class Animal
{
public void Eat()
{
Console.WriteLine("Eating...");
}
}
class Human:Animal
{
public void Think()
{
Console.WriteLine("I am thinking");
}
}
class Teacher:Human
{
public void Teach()
{
Console.WriteLine("I am teaching programming");
}
}
}
}
1.6.逻辑操作符
&,^,|操作符:位与,位异或,位或操作符,用于对二进制数据进行每一位的与,或,异或操作
&&,||:条件与,条件或操作符,用于操作布尔类型的数据,并且返回值也是布尔类型(使用时要注意避免短路效应)
短路效应:
当逻辑或(&&)的第一个条件为false时,就不会再去判断第二个条件;
当逻辑与(||)的第一个条件为true时,就不会再去判断第二个条件。
1.7.NULL值合并操作符
可空整数类型如nullable<int> x,C#中可简写为int? x =100;其他类型的可空类型同理
int y = x ?? 1;表示x是否为null,若是,则将其修改为1;
namespace Testing
{
class Program
{
static void Main(string[] args)
{
//Nullable<int> x = null;//定义可空类型整型变量x
int? x = null;//上述定义的C#简写语法
x = 100;
Console.WriteLine(x);
Console.WriteLine(x.HasValue);//显示True表示x当前有值
x = null;
Console.WriteLine(x.HasValue);//显示False表示x当前无值
int y = x ?? 1;//表示x是否为null,若是,则将其修改为1;
Console.WriteLine(y);
}
}
}
1.8.条件操作符
?:操作符:条件操作符,相当于if else分支的简写 ,:两边的数据类型必须要能够进行隐式数据类型转化
namespace Testing
{
class Program
{
static void Main(string[] args)
{
int x = 80;
/*string str = string.Empty;
if (x >= 60)
{
str = "Pass";
}
else
{
str = "False";
}*/
string str = x >= 60 ? "Pass" : "False";//上述注释代码段的简写,意为x是否大于等于60,若是则str赋为;左边值,反之为右边值
Console.WriteLine(str);
}
}
}
1.9.赋值和Lambda操作符
=,*=,/=,%=,+=,-=,<<=,>>=,&=,^=,|=操作符:赋值操作符(注意赋值操作符的运算顺序是从右向左)
以+=为例,int x+=1;相当于int x = x + 1;,其余操作符类似
=>操作符:Lambda操作符
2.操作符的本质
操作符的本质是函数(即算法)的简记法
操作符不能脱离与他关联的数据类型,可以说操作符就是与固定数据类型相关联(比如+号如果左右两边都是String类型,则进行的是对字符串的拼接操作而不是int类型的数值相加,/号如果左右两边都是float类型,则进行的是对float类型的除法操作而非int类型的除法)的一套基本算法的简记法(可以在创建方法的时候用operator关键字给方法定义一个符号来指代方法)
自定义操作符的格式:public static 返回类型 operator 自定义符号(参数,参数....)
{
方法体,表示该自定义操作符可以对所给参数对象做什么
}
注:创建强制类型转换操作符的格式稍有不同,详见上自定义类型转换操作符部分
3.优先级与运算顺序
- 可以使用圆括号提高被括起来表达式的优先级,圆括号可以嵌套
- 除了带有赋值功能的操作符,同优先级的操作符都是从左向右进行运算,带有赋值功能的操作符的运算顺序是从右向左
九.表达式和语句
1.表达式
任何一门语言的基本组件都包括表达式,命令和声明,其中表达式是核心组件,是一种专门用来求值的语法实体。各种语言对表达式的实现不尽相同,但大体上都符合这个定义
1.1.C#对表达式的定义
- 表达式是一个由一个或多个操作对象(包括字面值,方法调用或简单名字(包括变量名,类型成员名,方法参数名,命名空间名或类型名)等等)和零个或多个操作符组成的用于进行求值的序列,C#的表达式的返回值类型可能是single value,object,method或者namespace
1.由各种操作对象组成表达式的例子(以下例子表达式指赋值操作符右边整体即表达式):
字面值:
int x; string y; x = 100; y = "hello";//等号右面的100,hello均为字面值
方法调用:
double x = Math.Pow(2,3);//Math.Pow()即方法调用
简单名字:
int x =100; int y; y = x;//此处即变量名x构成一个表达式 Type myType = typeof(Int32);//此处的typeof(Int32)内Int32即类型名
2.以下列出各种操作符操作对象组成表达式的返回值类型:
- .成员访问操作符:不确定,由访问的成员类型决定
- f(x)方法调用操作符:不确定,由方法的返回值类型决定
- a[x]元素访问操作符:不确定,由集合的元素类型决定
- 前置,后置++和--操作符:与操作数数据类型相同
- new操作符:不确定,由创建的实例类型决定
- typeof操作符:Type类型
- default默认值操作符:操作对象的数据类型
- checked和unchecked操作符:与操作数类型相同
- delegate,sizeof和->操作符的返回值类型意义不大,不需要了解
- +,-正负操作符:与操作数类型相同
- !取非操作符:布尔类型
- ~按位取反操作符:与操作数类型相同
- T(x)强制类型转换操作符:与目标数据类型相同
- await操作符暂时跳过
- +,-,*,/,%算术运算符:不发生数据提升的情况下,返回值由操作数类型相同,发生数据提升时,与精度最高的操作数类型相同
- <<,>>移位操作符:与操作符左边的操作数类型相同
- <, >, <=, >=, ==, !=,is操作符:布尔类型
- as操作符:若as成功,则与as右边数据类型相同,若失败则为null
- &,^,|按位与,或,异或操作符:与操作数类型相同
- &&,||条件与或操作符:布尔类型
- null合并操作符:由null合并操作符??左边的操作数的数据类型的类型参数决定,若右边数值的精度更高,发生了数据提升,则与右边数值的类型相同(即根据??左右两边精度更高的数据类型决定)
以Nullable<int> x = null; var y = x ?? 100;为例,??左边的操作数的数据类型的类型参数即Nullable<int>x中的int
- ?:条件操作符:由:两边数据类型精度更高者决定
- =,*=,/=,%=,+=,-=,<<=,>>=,&=,^=,|=赋值操作符:与赋值操作符左边的数据类型相同
如:int x = 100; int y; y = x;表示了将x的值赋给y,且整体表达式y = x;也有返回值就是赋值操作符左边变量最终的值
- Lambda操作符:较复杂,暂不研究
注:表达式返回值(即表达式的值)的类型代表这个表达式的类型,要与操作数的值区分开,如var x = 3+5;返回值为int 8,var x = 3<5;返回值为bool true,int x = 100; x++的返回值为100,++x的返回值为101,二者区别在于先用x返回表达式值再对x进行运算还是先对x进行运算后用x返回表达式值
- 算法逻辑的最基本(最小)单元,表达一定的算法意图
- 因为操作符有优先级,所以表达式也有优先级
1.2.C#中表达式的分类
- 任何能够得到值的运算
- 一个变量
- 一个命名空间
- 一个类型
- 方法组,如Console.WriteLine不加方法调用(),返回的是一组方法,重载决策决定具体调用哪一个
- null值
- 匿名方法
- 属性访问
- 事件访问
- 索引访问
- Nothing,即对返回值为void的方法的调用
2.语句
2.1.语句定义
广义定义:
- 语句是命令式编程语言(一般是高级语言)中最小的独立元素,也是一种用于表达即将执行的一些动作的语法实体,编程即用一系列语句来编写程序,语句拥有自己的内部组件:表达式
- 语句是高级语言的语法,低级语言如汇编语言和机器语言只有指令(高级语言中的表达式对应低级语言的指令),不严格的讲,高级语言的程序由语句组成,低级语言的程序由指令组成。语句等价于一个或一组有明显逻辑相关的指令
C#定义:
- 一个程序所要执行的动作就是以语句的形式展现的,语句通常有以下功能:声明变量,对变量赋值,调用函数,在集合在进行循环(迭代语句),根据给定的条件在分支直接跳转(判断语句),程序中语句的执行顺序称为控制流或者执行流,语句的顺序在程序编写好的时候就固定了,但程序的控制流在运行时是可以变化的
- C#语言的语句除了能够让程序员顺序地表达算法思想,还能通过条件判断、跳转和循环等方法控制程序逻辑的走向
- 简言之语句的功能就是陈述算法思想,控制逻辑走向,完成有意义的动作
- C#的语句大部分由分号;结尾,但由分号结尾的不一定是语句,如using namespace是using指令而非语句,在类内部声明Name字段public string Name;是字段声明,也不是语句
- 语句只能出现在方法体里面(但出现在方法体内部的不一定是语句)
2.2.语句详解
C#语句分为以下三大类:
2.2.1.标签语句(labeled-statement)
标签语句在编程时少见,不详写了
2.2.2.声明语句(declaration-statement)
用于声明一个或多个变量与常量
声明局部变量(声明与赋值可以分开):
例如int x = 100;或int x; x = 100;
注意区分赋值和追加初始化器的区别:
int x = 100;为追加初始化器,int x; x = 100;为赋值操作
数组初始化器为{},如int[] myArrary={1,2,3};
声明局部常量(声明时必须同步初始化):
例如const int x = 100;
声明语句支持这样声明:int x, y, z;或int x=1, y=2, z=3;但不推荐这样操作,因为会造成可读性下降
2.2.3.嵌入式语句(embedded-statement)
1.表达式语句(expression-statement)
用于计算所给定的表达式,由此表达式计算出来的值(如果有但是未被显式接受)被丢弃(如int x; x = 100;此赋值表达式的值为100,但是被舍弃,该表达式目的只是为了将100赋给变量x,若再加上x++;则该自增表达式的值为100,但是被舍弃,该表达式目的只是为了将变量x自增1)
不是所有的表达式都可以作为语句来使用。具体而言,不允许像x + y和x == 1这样只计算一个值(此值将被放弃)的表达式作为语句使用(在C中可以这样使用)
以下表达式都可以加;作为语句使用:
- 方法调用表达式,例如Console.WriteLine("hello");
- 对象创建表达式,例如new Form();
- 赋值语句,如 int x; x =100;
- 前置后置的自增,自减表达式,
- await表达式
2.块语句(block)
用于在只允许使用单个语句的上下文中编写多条语句,单独写块语句的情况不常见,一般与if,while等语句合起来使用
形式上如下
{statement-list}//块语句最后不用加;分号
若statement-list为空,则称该块为空
块内可以为任意类型任意数量的语句,编译器无论何时都会将整个块当作一条完整语句看待,因此最后}外不需要加;分号
要注意区分命名空间的命名空间体{},类的类体{},方法的方法体{}和块语句的块体{},他们不是一个东西,块语句属于语句,只能存在于方法体内
变量的作用域:在语句块之前之外声明的变量在块内也可以使用,但是块内声明的变量不可以在块外使用
namespace Testing
{
class Program
{
static void Main(string[] args)
{
{
int x = 100;//声明语句
if (x > 60)
{
Console.WriteLine("hello");
}//嵌入式语句
hello: Console.WriteLine("hello world");
goto hello;//标签语句
}//块内可以使用任意类型语句
}
}
}
3.选择语句(selection-statement)
选择语句会根据表达式的值从若干个给定的语句中选择一个来执行
3.1 if语句
if语句根据布尔表达式的值来选择要执行的语句
一条if语句形式如下:if(boolean-expression)
embedded-statement
else
embedded-statement
......
括号内只能是布尔类型表达式,if()后面的语句只能是一条嵌入式语句
若要同时使用多条语句,需要在嵌入式语句外面加上{}成为一条块语句,单独使用一条语句的话,可加可不加,但根据编程规范最好加上{}
if与else的就近匹配原则:else会与距离他最近的当前未匹配的if匹配
由于if本身就是嵌入式语句,因此在if()后面的语句也可以是if语句,从而达成多重嵌套,实现树状分支结构
else if相当于多重if else的优化简写结构,能够提高可读性,如以下代码的优化
int score = 88; if (score >= 0 && score <= 100) { if (score >= 80) { Console.WriteLine("A"); } else { if (score >= 60) { Console.WriteLine("B"); } else { if (score >= 40) { Console.WriteLine("C"); } else { Console.WriteLine("Failed"); } } } }
可简化为:
int score = 88; if(score >= 0 && score <= 100) { if (score >= 80 && score <= 100) { Console.WriteLine("A"); } else if (score >= 60) { Console.WriteLine("B"); } else if (score >= 40) { Console.WriteLine("C"); } else { Console.WriteLine("Failed"); } }
3.2 switch语句
switch语句选择一个要执行的语句列表,此列表具有一个相关联的switch标签,它对应于switch表达式的值
一条switch语句形式如下:
switch (expression) //类似筛选的条件
{
case constant-expression://一条case标签,case后面必须是常量表达式,且必须与switch表达式的expression类型一致
statement-list//一个语句列表
break;
default://一条default标签,类似于if语句最后的else
break;//一个break代表着该标签所属switch section的结束
}//注:也可以多条标签对应一个语句列表
其中,switch表达式expression的类型必须为以下类型之一:sbyte、byte、short、ushort、int、uint、long、ulong、bool、char、string、或枚举类型enum-type,或者是对应于以上某种类型的可空类型
一旦一个标签后面跟了语句列表,必须显式加上一个break;(若要进行goto则不需要加break;),此时就变成了一个switch section,因此如果两个标签做的事情一样,只需要将两个标签连起来即可
default标签不是必须的,但是根据编程规范,无论何时最好都要写上default标签的switch section,防止出现意外逻辑
int score = 88;
switch(score/10)
{
case 10:
if(score == 100)
{
goto case 9;//为保证代码逻辑一致,使用goto语句
}
else
{
goto default;
}//100时特殊对待
case 9:
case 8://多条标签对应一个语句列表
Console.WriteLine("A");
break;
case 7:
case 6:
Console.WriteLine("B");
break;
case 5:
case 4:
Console.WriteLine("C");
break;
default:
Console.WriteLine("Failed");
break;
}
Code Snippet
VS自动补全框架功能
以自动补全枚举类型变量的switch语句结构为例
输入sw双击tab,再输入switch表达式,点击任意地方即可自动补全模板
4.try语句
try语句提供一种机制,用于捕捉在块的执行期间发生的各种异常。此外,try语句还能指定一个代码块,并保证当控制离开try语句时,总是先执行该代码
三种可能的try语句格式
1:一个try块后接一个或多个catch块
2:一个try块后接一个finally块,此时不会捕捉异常
3:一个try块后接一个或多个catch块,后面再跟一个finally块
try相当于尝试执行一段语句块,当出现异常时,跳过该try语句块内当前异常的语句以及后续语句,并使用catch对异常进行捕捉,从而对异常进行分门别类处理,finally子句不论try语句块是否发生异常都会执行,一段try语句只可以有一个finally子句,但是可以有多个catch子句,并且只会执行其中一个catch子句
catch子句分类:通用catch子句:能捕捉任意类型异常
专用catch子句:只能捕捉某一特定类型的异常
可以使用MSDN文档查询某些方法对应可能出现的异常,如Int32.Parse 对应以下三种异常:
static void Main(string[] args)
{
Console.WriteLine(Calculator.Add("a", "234"));
}
class Calculator
{
public static int Add(string str1,string str2)
{
int a = 0;
int b = 0;
try
{
a = int.Parse(str1);
b = int.Parse(str2);
}
/*catch
{
Console.WriteLine("You argument(s) is not number.");
}//通用catch子句,捕捉任意类型异常*/
catch(ArgumentNullException)//空值异常
{
Console.WriteLine("Your argument(s) is null");
}
catch(FormatException)//格式异常
{
Console.WriteLine("Your argument(s) is not number");
}
catch (OverflowException)//溢出异常
{
Console.WriteLine("Out of range");
}//三种int.Parse方法对应的异常类型
int result = a + b;
return result;
}
}
以上三种专用类型异常也可以像下面这样,再catch后面的异常类型加上一个异常标识符,由于异常在被catch后会自动创建一个异常实例,因此可以在后面直接打印出异常实例的message成员:
catch(ArgumentNullException ane)//空值异常
{
Console.WriteLine(ane.Message);
}
catch(FormatException fe)//格式异常
{
Console.WriteLine(fe.Message);
}
catch (OverflowException oe)//溢出异常
{
Console.WriteLine(oe.Message);
}//三种int.Parse方法对应的异常类型
finally块内一般用于写释放系统资源的语句和程序的log即执行记录
对于写不写finally块,try或catch完毕之后都会执行后面语句,那么写finally块的意义是什么的解答:
finally块是防止try或catch语句里面有return导致无法及时关闭某些东西,加上finally块后,即使前面块内有return语句,也会先执行finally块再return,如果没有finally块则不会执行
下面以finally块内写程序log为例:
static void Main(string[] args)
{
Console.WriteLine(Calculator.Add("123", "a34"));
}
class Calculator
{
public static int Add(string str1, string str2)
{
int a = 0;
int b = 0;
bool hasError = false;//hasError用于记录是否发生异常
try
{
a = int.Parse(str1);
b = int.Parse(str2);
}
catch (ArgumentNullException ane)//空值异常
{
Console.WriteLine(ane.Message);
hasError = True;
}
catch (FormatException fe)//格式异常
{
Console.WriteLine(fe.Message);
hasError = True;
}
catch (OverflowException oe)//溢出异常
{
Console.WriteLine(oe.Message);
hasError = True;
}//三种int.Parse方法对应的异常类型
finally
{
if (hasError)
{
Console.WriteLine("Execution has error");
}
else
{
Console.WriteLine("Done");
}
}//添加log表明程序执行状态,能表示最后的result是否是正确执行所得到的
int result = a + b;
return result;
}
}
throw关键字:可以用于抛出一个新建异常如throw new Exception("Number is wrong");或者用于捕捉住已有异常后不处理,抛出去,谁调用谁处理。throw的语法比较灵活,以下几种都是合法使用例子
catch (OverflowException oe)//溢出异常
{
throw oe;
}//抛出异常oe
catch (OverflowException oe)//溢出异常
{
throw;
}//抛出异常
catch (OverflowException)//溢出异常
{
throw;
}//抛出异常
注:尽量在程序可能出现异常的地方都要try catch,防止漏掉异常导致程序出错
5.迭代语句(iteration-statement)
即循环语句,重复地执行嵌入语句
5.1 while语句
while 语句按不同条件执行一个嵌入语句零次或多次
一条while语句的形式如下:
while(boolean-expression)
embedded-statement//while后面循环体只能是一条嵌入式语句,若要同时执行多条语句,需要使用块语句
5.2 do语句
do 语句按不同条件执行一个嵌入语句一次或多次
一条do语句的形式如下:
do embedded-statement while(boolean-expression)//do后面循环体只能是一条嵌入式语句,若要同时执行多条语句,需要使用块语句
5.3 for语句
for 语句计算一个初始化表达式序列,然后,当某个条件为真时,重复执行相关的嵌入语句并计算一个迭代表达式序列
计数循环更适合for循环而不是while和do循环,代码可读性更高
一条for语句的形式如下:
for(for-initializeropt;for-conditionopt;for-iteratoropt) embedded-statement//for后面循环体只能是一条嵌入式语句,若要同时执行多条语句,需要使用块语句
for()内三者分别为初始化器,循环条件,迭代器,执行顺序如下:for循环第一次开始时会执行初始化器,并且在整个循环只执行这一次,紧接着执行循环条件的判断,若判断通过,则进入循环体,然后执行迭代器
for()内三者都是可选的(;分号不能省略),但若三者都不写,那就相当于一个死循环,实际使用时最好都写上,提高可读性,并且初始化器的变量最好不要在循环外声明。
使用for循环打印九九乘法表:
namespace Testing
{
class Program
{
static void Main(string[] args)
{
for(int a = 1;a <= 9; a++)
{
for(int b = 1; b <=a; b++)
{
Console.Write("{0}*{1}={2}\t", a, b, a * b);//\t为制表符
}
Console.WriteLine();//打印一个回车
}
}
}
}
5.4 foreach语句
foreach 语句用于枚举一个集合的元素,并对该集合中的每个元素执行一次相关的嵌入语句,即集合遍历循环
- 什么样的集合可以被遍历:
以数组和泛型为例,数据类型后面加上了[]就表明它是一个数组类型,C#中所有数组类型的基类都是Array类,转到Arrary类的定义可以发现其实现了IEnumerable接口,泛型类也具有这个接口,而所有实现IEnumerable接口的类就是可以被遍历循环的集合。IEnumerable接口只有一个方法成员GetEnumerator(),用于获得一个集合的迭代器,因此C#所有可以被遍历的集合都能够获得自己的迭代器
- 迭代器IEnumerator:
相当于对一个集合元素的指示器(类似现实中的点名)
Current表示当前指示的元素,MoveNext()方法表示如果迭代器还能向后移动,就返回True,若不能继续移动,则表示走到了集合的最后一个元素,返回False(无法继续移动的情况是迭代器走到了集合的最后元素的后面位置), Resrt()方法可以将迭代器拨回集合的最开始,此时指向第一个元素之前(即不指向任何元素)
以下代码为对数组和泛型类的集合遍历
static void Main(string[] args) { var intArray = new int[] { 1, 3, 5 ,7}; IEnumerator enumerator = intArray.GetEnumerator();//获得intArray集合的迭代器 while (enumerator.MoveNext()) { Console.WriteLine(enumerator.Current); }//若迭代器还能向后移动,则打印当前元素 var intList = new List<int>() { 2, 4, 6, 8 }; IEnumerator enumerator2 = intList.GetEnumerator(); while (enumerator2.MoveNext()) { Console.WriteLine(enumerator2.Current); } }
foreach语句就是对集合遍历的一种简记法
一条foreach语句的形式如下:
foreach(local-variable-type identifier in expression) embedded-statement//foreach后面循环体只能是一条嵌入式语句,若要同时执行多条语句,需要使用块语句
foreach()内为迭代器指向变量的类型 迭代变量 in 集合
以下代码为上述集合遍历代码的foreach简记法:
static void Main(string[] args) { var intArray = new int[] { 1, 3, 5, 7 }; foreach(var current in intArray)//鼓励使用var,编译器会自动判断指向的元素类型, //也即是迭代遍历的类型 //此处的迭代变量current相当于上述代码的enumerator.Current { Console.WriteLine(current); } var intList = new List<int>() { 2, 4, 6, 8 }; foreach (var current in intList) { Console.WriteLine(current); } }
6.跳转语句(jump-statement)
用于无条件地转移控制
6.1 break语句
break语句将退出直接封闭它的switch、while、do、for或foreach语句,即退出整个循环体
6.2 continue语句
continue语句将开始直接封闭它的while、do、for或foreach语句的一次新迭代,即放弃当前循环,开启新一轮循环
注:在多重循环的时候,break与continue只能影响到直接包含它们的循环体,影响不到外层循环
6.3 goto语句
现在已经不是主流,不怎么使用了
6.4 throw语句
在try语句部分已经讲过
6.5 return语句
使用return语句的几个原则:
尽早return:通过提前 return 可以让代码阅读者立刻就鉴别出来程序将在什么情况下 return,同时减少 if else 嵌套,写出更优雅的代码
class Program
{
static void Main(string[] args)
{
Greeting("Mr.Duan");
}
static void Greeting(string name)
{
if (string.IsNullOrEmpty(name))
{
// 通过尽早 return 可以让代码阅读者立刻就鉴别出来
// name 参数在什么情况下是有问题的
return;
}
Console.WriteLine("Hello, {0}", name);//如果后面语句很多,也可以避免语句头重脚轻的问题,减少if语句的长度
}
}
关于using语句、yield语句、checked/unchecked语句、lock语句(用于多线程)、标签语句和空语句,要么太难,要么不常用或者过时,所以不赘述
十.字段、属性、索引器、常量
本文提到C#类型时一般指类或结构体,类或结构体有以下成员:
成员 | 说明 |
常量 | 与类关联的常量值 |
字段 | 类的变量 |
方法 | 类可执行的计算和操作 |
属性 | 与读写类的命名属性相关联的操作 |
索引器 | 与以数组方式索引类的实例相关联的操作 |
事件 | 可由类生成的通知 |
运算符 | 类所支持的转换和表达式运算符 |
构造函数 | 初始化类的实例或类本身所需的操作 |
析构函数 | 在永久丢弃类的实例之前执行的操作 |
类型 | 类所声明的嵌套类型 |
字段、属性、索引器、常量都是用于表示数据的,因此综合起来讲
1.字段(field)
1.1.字段的定义
- 字段是一种表示对象或类型(类与结构体)关联的变量
- 字段是类型的成员,旧称成员变量
- 与对象关联的字段也称作实例字段
- 与类型关联的字段称为静态字段,由static修饰
1.2.字段的声明与初始值
字段的命名一定要是名词,声明时一定要在类或结构体内部
关于字段的声明格式:特性s(可选) 修饰符s(可选) 字段的数据类型 变量声明器;
- 允许的修饰符有:new, public, protected, internal, private, static, readonly, volatile,若修饰符有多个,则必须为有意义的修饰符组合,像public private就是非法的
- 变量声明器有两种,一种是单独的变量名,此时编译器会自动赋给他们默认值,即字段的数据类型的默认值,另一种是变量名加上变量初始化器(鼓励在声明是就显式初始化的操作),这种操作的原理与在构造器内部初始化字段是一样的,但是更便于维护,这样若构造器发生变化,字段初始值不会改变。对于实例字段,它初始化的时机是在实例被创建的时候,对于静态字段,它初始化的时机是在运行环境第一次加载这个数据类型的时候(从此也可以发现静态构造器永远只执行一次)
- 尽管字段声明最后加上了;分号,但它不属于语句,因为它出现在类或结构体内部而不是方法体内部
1.3.只读字段readonly
关于只读字段readonly:为实例或类型保存一旦初始化后就不希望再改变的值,只读字段只能在初始化时进行一次赋值,之后任何的更改都是不被允许的。
2.属性(property)
属性是C#独有的概念
2.1.属性的定义
- 属性是一种用于访问对象或类型的特征的成员,特征反映了状态
- 属性是字段的自然扩展
- 从命名上看,字段更偏向于实例对象在内存中的布局,属性更偏向于反映现实世界对象的特征
- 对外:暴露数据,数据是可以存储在字段里的,也可以是动态计算出来的
- 对内:保护字段不被非法值污染
- 属性由Get/Set方法对进化而来
为了防止字段被异常数据污染,一般会将字段权限更改为private(此时编程规范建议将变量名改为驼峰命名法 ),并使用Get/Set方法对来对private字段进行访问和修改
使用Get/Set方法对之前,字段值有可能会被污染:
class Program
{
static void Main(string[] args)
{
var stu1 = new Student()
{
Age = 20
};
var stu2 = new Student()
{
Age = 20
};
var stu3 = new Student()
{
// 异常值,污染字段
Age = 200
};
var avgAge = (stu1.Age + stu2.Age + stu3.Age) / 3;
Console.WriteLine(avgAge);
}
}
class Student
{
public int Age;
}
将字段权限更改为private,使用Get/Set方法对之后:
class Program
{
static void Main(string[] args)
{
try
{
var stu1 = new Student();
stu1.SetAge(20);
var stu2 = new Student();
stu2.SetAge(20);
var stu3 = new Student();
stu3.SetAge(200);
var avgAge = (stu1.GetAge() + stu2.GetAge() + stu3.GetAge()) / 3;
Console.WriteLine(avgAge);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
}
class Student
{
private int age;//age字段的权限改为private之后无法在类体外部直接对其进行访问修改
//但可以使用内部的Get/Set方法对对其进行访问修改
public int GetAge()
{
return age;
}//Get方法用于访问字段
public void SetAge(int value)
{
if (value >= 0 && value <= 120)
{
age = value;
}
else
{
throw new Exception("Age value has error.");//发现值非法,抛出异常
}
}//Set方法用于修改字段
}
C++、JAVA 里面是没有属性的概念,因此使用 Get/Set 来保护字段的方法至今仍在 C++、JAVA 里面流行
因为 Get/Set 方法对写起来冗长,微软应广大程序员请求,给 C# 引入了属性
引入属性之后:
class Program
{
static void Main(string[] args)
{
try
{
var stu1 = new Student();
stu1.Age = 20;
var stu2 = new Student();
stu2.Age = 20;
var stu3 = new Student();
stu3.Age = 200;
var avgAge = (stu1.Age + stu2.Age + stu3.Age) / 3;
Console.WriteLine(avgAge);
}//使用属性Age对私有字段age进行访问修改
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
}
class Student
{
private int age;//私有字段age
public int Age//公有属性Age
{
get
{
return age;
}//get访问器getter,用于访问字段值
set
{
if (value >= 0 && value <= 120)
{
age = value;
}
else
{
throw new Exception("Age value has error.");
}
}//set访问器setter,用于设置字段值
}
}
注:写set访问器时,编译器会自动准备好一个叫做value的上下文关键字,代表用户传递进来的值,不需要像写set方法时主动声明一个参数value
- 又一个语法糖——属性背后的秘密
语法糖:为了方便程序员进行程序编写,在编程语言中一段比较简单的逻辑背后是为了隐藏比较复杂的逻辑,这样的简单逻辑称为语法糖,像foreach循环和属性都属于语法糖
2.2.属性的声明
- 完整声明
完整声明的形式如下:
特性(s)(可选)+ 修饰符(s)(可选)+ 属性数据类型 + 属性的名称{getter setter}
完整声明属性时一般需要显式声明一个对应字段用于存储数据,属性名与对应字段名一般需要相同,但是命名方式有区别,属性名用 Pascal命名,对应的私有字段用驼峰命名。属性最常见的修饰符组合为public或者pubic static,属性若是静态的,则对于的字段必须也是静态的。有些属性getter与setter访问器都有,此时能同时对字段进行访问和修改,有些属性只有getter,只能从字段中读取值不能赋值,称之为只读属性(如果一个属性的setter权限为private,也不能在类体外部对字段进行更改,但这种属性不是只读属性,因为它可以在属性内部被访问),反之称为只写属性,但是一般编程时只写属性非常少见,因为属性的作用就是向外暴露数据,只写属性就失去了这一功能。
private int age;
public int Age
{
get
{
return age;
}
set
{
if (value >= 0 && value <= 120)
{
age = value;
}
else
{
throw new Exception("Age value has error.");
}
}
}//一个完整声明的属性
Code Snippet:propfull+2*TAB
- 简略声明
通过简略声明出来的属性在功能上与一个公有字段完全一样,其值不受保护。但是其声明起来非常简单,一般用于传递数据。简略声明属性不需要显式定义对应字段,编译器会为其自动生成一个对应的后台私有字段,但由于get与set访问器为空,因此对字段起不到任何的保护作用。(简略声明的属性和公有字段虽然在大部分情况下可以互换,但推荐使用属性,因为属性的封装性和灵活性更好,便于未来添加验证或逻辑)
public int Age{get; set;}//一个简略声明的属性
这段代码相当于以下代码:
private int <Age>k__BackingField;//编译器自动生成的后台私有字段
public int Age
{
get
{
return <Age>k__BackingField;
}
set
{
<Age>k__BackingField = value;
}//默认生成的get与set访问器
}
注:只有简略声明属性时才会自动生成后台私有字段,其他情况下不会自动生成
Code Snippet:prop+2*TAB
VS也提供一键重构封装字段生成属性的操作
- 动态计算值的属性
主动计算,每次获取 CanWork 时都计算,适用于 CanWork 属性使用频率低的情况:
此时不需要对应的字段来存储值,因为值是在每次计算的过程中实时获取的
class Program
{
static void Main(string[] args)
{
try
{
var stu1 = new Student();
stu1.Age = 12;
Console.WriteLine(stu1.CanWork);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
}
class Student
{
private int age;
public int Age
{
get
{
return age;
}
set
{
age = value;
}
}
public bool CanWork//只读属性CanWork,没有对应字段来存储值
{
get
{
return age > 16;
}
}
}
被动计算,只在 Age 赋值时计算一次,计算之后将其存入canWork字段,之后使用时直接读取即可,不需要计算,适用于 Age 属性使用频率低,CanWork 使用频率高的情况:
class Program
{
static void Main(string[] args)
{
try
{
var stu1 = new Student();
stu1.Age = 12;
Console.WriteLine(stu1.CanWork);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
}
class Student
{
private int age;
public int Age
{
get { return age; }
set
{
age = value;
CalculateCanWork();
}
}
private bool canWork;//创建了一个私有canWork字段用于存储每次计算的值
public bool CanWork
{
get { return canWork; }
}
private void CalculateCanWork()
{
canWork = age > 16;
}
}
- 注意实例属性和静态属性
- 属性的名字一定是名词
2.3.属性与字段的关系
- 一般情况下,他们都用于表示实体(对象或类型)的状态
- 属性大多数情况下是字段的包装器
- 建议:永远使用属性(而不是字段)来暴露数据,即字段永远都是private或protected的
3.索引器(indexer)(概述)
一般用于检索集合,索引器使对象能够用与数组相同的方式(即使用下标)进行索引 ,拥有索引器这种成员的数据类型绝大部分都是集合类型,但也有例外,如下(为了演示索引器的作用):
class Program
{
static void Main(string[] args)
{
var stu = new Student();
stu["Math"] = 90;
stu["Math"] = 100;
var mathScore = stu["Math"];
Console.WriteLine(mathScore);
}
}
class Student
{
private Dictionary<string, int> scoreDictionary = new Dictionary<string, int>();
//创建一个私有的成绩字典,包含两个成员,科目名和成绩
public int? this[string subject]//索引器的返回值类型为可空int类型,用string类型进行索引
{
get
{
if (this.scoreDictionary.ContainsKey(subject))
{
return scoreDictionary[subject];
}
else
{
return null;
}
}//在给定的字典内查询对应科目的成绩并返回,若没查到,返回null
set
{
if (value.HasValue == false)//判断value是否为空
{
throw new Exception("Score cannot be null");
}
if (this.scoreDictionary.ContainsKey(subject))
{
// 可空类型的 Value 属性才是其真实值。
this.scoreDictionary[subject] = value.Value;
}
else
{
this.scoreDictionary.Add(subject, value.Value);
}
}
}//成绩索引器的创建
}
Code Snippet:index + 2*TAB
4.常量(constant)
4.1.常量的定义
- 常量是表示常量值(即可以在编译时进行运算的值)的类成员,可类比C中的宏定义
- 常量隶属于类型而不是对象,即没有实例常量,“实例常量”的角色由实例只读字段担当,常量在编译时会直接进行替换,不需要访问内存,而实例只读字段需要访问内存
- 注意区分成员常量与局部常量 成员常量一般在类体内,方法体外,局部常量一般在方法体内 ,都使用const修饰
4.2.常量的声明
成员常量:修饰符s(可选) const 常量数据类型 变量名 初始化器;
局部常量:const 常量数据类型 变量名 初始化器
4.3.各种“只读”的应用场景
- 常量:隶属于类型,没有所谓的实例常量
- 只读字段:只有一次初始化机会,就是在声明它时初始化(等价于在构造函数中初始化)
- 只读属性:对于类使用静态只读属性,对于实例使用实例只读属性 要分清没有 Setter与 private Setter的区别 常量比静态只读属性性能高,因为编译时,编译器将用常量的值代替常量标识符
- 静态只读字段:字段没有类型局限,但常量只能是如int,double等简单类型,不能是类/自定义结构体类型,此时只能使用静态只读字段 (C#9.0以上版本支持方法体内使用static readonly)
十一.传值、输出、引用、数组、具名、可选参数和扩展方法
1.传值参数
声明时不带修饰符的形参是值形参。一个值形参对应于一个局部变量,只是他的初始值来自该方法调用所提供的相应实参。
当形参是值参数时。方法调用中的对应实参必须是表达式,并且它的类型可以隐式转换为形参的类型。
允许方法将新值赋给值参数。这样的赋值只影响由该值形参表示的局部存储位置,而不会影响在方法调用时由调用方给出的实参。
1.1值类型的传值参数
上图虚线上代表方法体外,虚线下代表方法体内
class Program
{
static void Main(string[] args)
{
int y = 100;
Calculator.AddOne(y);
Console.WriteLine(y);
}
}
class Calculator
{
public static void AddOne(int x)
{
x = x + 1;
}
}
上述代码打印的y值仍然为100,因为方法AddOne的参数int x是值类型的传值参数,方法AddOne内的x相当于y的副本,因此在AddOne方法内对x值进行加1不会影响到y的值
注:C#中控制台应用程序的默认入口类Program类应当尽量保持精简,主要作用为应用程序的启动入口和顶层协调器,具体的逻辑应该委托给其他类,实际开发时应优先在Program外部定义类
1.2引用类型的传值参数(创建新对象)
tip: 多字段类的初始化器用{},可以只初始化部分字段
class Program
{
static void Main(string[] args)
{
Student stu1 = new Student() { Name = "Mike" };
Student.SomeMethod(stu1);
Console.WriteLine(stu1.Name);
}
}
class Student
{
public string Name { get; set; }
public static void SomeMethod(Student stu)
{
stu = new Student() { Name = "Nike" };
Console.WriteLine(stu.Name);
}
}
上述代码会依次打印Nike和Mike,这是因为方法SomeMethod的参数Student stu是引用类型的传值参数,方法SomeMethod内的stu相当于stu1的副本,stu1保存Mike实例的地址,开始调用方法后,方法内的stu被重新赋值,此时保存的是新创建的Nike实例地址,所以会先在方法内打印Nike,步出方法后,stu1仍旧保存的是Mike实例的地址,所以会打印Mike
这种状况很少见,一般情况都是读取传进来的值,而不会将其连接到新对象
GetHashCode()
若SomeMethod方法内部是stu = new Student() { Name = "Mike" };则会打印两次Mike,但实际上stu和stu1引用的是两个不同的实例,这样会无法区分两者是否引用的是同一份实例,此时需要使用方法GetHashCode()来区分
Object.GetHashCode() 方法,用于获取代表当前对象的唯一哈希代码值,每个对象的 Hash Code 都不一样。
通过 Hash Code 来区分两个 Name 相同的 stu 对象。
class Program
{
static void Main(string[] args)
{
var stu = new Student() { Name="Tim"};
SomeMethod(stu);
Console.WriteLine("{0},{1}",stu.Name,stu.GetHashCode());
}//{0},{1}表示占位符,用后面的变量值替换,然后变成字符串一起输出
static void SomeMethod(Student stu)
{
stu = new Student { Name = "Tim" };
Console.WriteLine("{0},{1}",stu.Name,stu.GetHashCode());
}
}
class Student
{
public string Name { get; set; }
public static void SomeMethod(Student stu)
{
stu = new Student() { Name = "Nike" };
Console.WriteLine(stu.Name);
}
}
1.3引用类型的传值参数(只操作对象,不创建新对象)
class Program
{
static void Main(string[] args)
{
Student Stu = new Student() { Name = "Mike" };
Student.UpdateObject(Stu);
Console.WriteLine("HashCode={0}, Name = {1}", Stu.GetHashCode(), Stu.Name);
}
}
class Student
{
public string Name { get; set; }
public static void UpdateObject(Student stu)
{
stu.Name = "Nike" ;
Console.WriteLine("HashCode={0}, Name = {1}", stu.GetHashCode(), stu.Name);
}
}
上述代码两次都会打印同一个哈希值,并且Name都是Nike,这是因为方法UpdateObject的参数Student stu是引用类型的传值参数,方法UpdateObject内的stu保存的就是外部stu的副本,他们都引用的是实例Mike,因此在方法内调用stu.Name访问到的就是实例Mike的Name,对其进行修改自然会将对象Mike的字段值修改掉。
这种通过传递进来的参数修改其引用对象的值的情况,在工作中也比较少见。因为作为方法,其主要作用是返回一个值,主要输出还是靠返回值。因此把这种修改实际参数或其所引用对象的值等在主要作用之外的结果操作叫做方法的副作用(side-effect),这种副作用平时编程时要尽量避免。
2.引用参数
引用形参是用ref修饰符声明的形参。与值形参不同,引用形参并不创建新的存储位置。相反,引用形参表示的存储位置恰是在方法调用中作为实参给出的那个变量所表示的存储位置。
当形参为引用形参时,方法调用中的对应实参必须由关键字ref并后接一个与形参类型相同的variable-reference组成。变量在可以作为引用形参传递之前,必须先明确赋值。
在方法内部,引用形参始终被认为时明确赋值的。
声明为迭代器的方法不能有引用形参。
2.1值类型的引用参数
static void Main(string[] args)
{
int y = 1;
IWantSideEffect(ref y);
Console.WriteLine(y);
}
static void IWantSideEffect(ref int x)
{
x += 100;
}
上述代码会打印出101,因为方法IWantSideEffect的x是值类型的引用参数,在方法内部对x进行操作就相当于对y进行操作,操作的是同一份数据,而非副本。
2.2引用类型的引用参数(创建新对象)
class Program
{
static void Main(string[] args)
{
var outterStu = new Student() { Name = "Tim" };
Console.WriteLine("HashCode={0}, Name={1}", outterStu.GetHashCode(), outterStu.Name);
Console.WriteLine("-----------------");
Student.IWantSideEffect(ref outterStu);
Console.WriteLine("HashCode={0}, Name={1}", outterStu.GetHashCode(), outterStu.Name);
}
}
class Student
{
public string Name { get; set; }
public static void IWantSideEffect(ref Student stu)
{
stu = new Student() { Name = "Tom" };
Console.WriteLine("HashCode={0}, Name={1}", stu.GetHashCode(), stu.Name);
}
}
上述代码会先打印Tim和一个哈希值,然后会打印两份Tom和与Tim不同的哈希值,并且两份Tom的哈希值相同。这是因为方法方法IWantSideEffect的stu是引用类型的引用参数,在方法体内部对stu进行操作就是对outterStu进行操作,两者是同一份数据,而非副本,引用类型变量outterStu一开始引用的是实例Tim,方法调用完成之后就相当于让其引用新创建的实例Tom。
2.3引用类型的引用参数(只操作对象,不创建新对象)
class Program
{
static void Main(string[] args)
{
var Stu = new Student() { Name = "Tim" };
Console.WriteLine("HashCode={0}, Name={1}", Stu.GetHashCode(), Stu.Name);
Console.WriteLine("-----------------");
Student.SomeSideEffect(ref Stu);
Console.WriteLine("HashCode={0}, Name={1}", Stu.GetHashCode(), Stu.Name);
}
}
class Student
{
public string Name { get; set; }
public static void SomeSideEffect(ref Student stu)
{
stu.Name = "Tom";
Console.WriteLine("HashCode={0}, Name={1}", stu.GetHashCode(), stu.Name);
}
}
上述代码三次都会打印同一个哈希值,第一次Name是Tim,后两次Name都是Tom,这是因为方法SomeSideEffect的参数Student stu是引用类型的引用参数,方法体内外操作的都是同一份数据,即方法内部的stu和外部的outterStu是同一份数据而非副本,并且他们都引用同一个实例,因此会出现上述结果。这与传值参数的第三种在效果上并未差别,但是方法内外的参数stu和Stu在内存机理上有差别,一个是副本,一个是同一份数据,但最终都指向同一份实例。
3.输出参数
用out修饰符声明的形参是输出形参。类似于引用形参,输出形参不创建新的存储位置。相反,输出形参表示的存储位置恰是在该方法调用中作为实参给出的那个变量所表示的存储位置。
当形参为输出形参时,方法调用中的相应实参必须由关键字out并后接一个与形参类型相同的variable-reference组成。变量在可以作为输出形参传递之前不一定需要明确赋值,但是在将变量作为输出形参传递的调用之后,该变量被认为是明确赋值的。
在方法内,与局部变量相同,输出形参最初被认为是未赋值的,因而必须在使用他的值之前明确赋值。
在方法返回之前,该方法的每个输出形参都必须明确赋值。
声明为分部方法或迭代器的方法不能有输出形参。
输出形参通常用在需要产生多个返回值的方法中。
通俗来讲,方法一次只能返回一个值,如果需要在一个方法内返回多个值,需要用到输出参数,并且输出参数和引用参数一样,不会给传进来的参数创建副本,和传进来的实参是同一份数据。因为输出参数的作用是输出数据,因此不要求输出参数在方法体外被赋值,但在方法体内一定要赋值,因为需要输出它。
3.1值类型的输出参数
和引用参数一样,也是有意利用副作用。
下面以double类型的TryParse方法为例:
static void Main(string[] args)
{
Console.WriteLine("Please input first number:");
var arg1 = Console.ReadLine();
double x = 0;//输出参数x
if (double.TryParse(arg1, out x) == false)
{//TryParse将字符串arg1解析为double类型,如果解析成功,返回True,否则False
//解析成功会将数据输出为x
Console.WriteLine("Input error!");
return;
}
Console.WriteLine("Please input second number:");
var arg2 = Console.ReadLine();
double y = 0;//输出参数y
if (double.TryParse(arg2, out y) == false)
{
Console.WriteLine("Input error!");
return;
}
double z = x + y;
Console.WriteLine(z);
}
下面代码是自己实现的TryParse方法:
class Program
{
static void Main(string[] args)
{
double x = 0;
if (DoubleParser.TryParse("aa", out x))
{
Console.WriteLine(x);
Console.WriteLine("All rigth");
}
else
{
Console.WriteLine("Wrong");
}
if (DoubleParser.TryParse("12.23", out x))
{
Console.WriteLine(x);
Console.WriteLine("All rigth");
}
}
}
class DoubleParser
{
public static bool TryParse(string input, out double result)
{
try
{
result = double.Parse(input);
return true;
}
catch
{
result = 0;
return false;
}
}
}
3.2引用类型的输出参数
class Program
{
static void Main(string[] args)
{
Student stu = null;
if(StudentFactory.Create("Tim", 34, out stu))
{
Console.WriteLine("Student {0}, age is {1}",stu.Name,stu.Age);
}
}
}
class Student
{
public int Age { get; set; }
public string Name { get; set; }
}
class StudentFactory
{
public static bool Create(string stuName,int stuAge,out Student result)
{
result = null;
if (string.IsNullOrEmpty(stuName))
{
return false;
}
if (stuAge < 20 || stuAge > 80)
{
return false;
}
result = new Student() { Name = stuName, Age = stuAge };
return true;
}
}
4.数组参数
- 一个方法的参数列表只能有一个数组参数,且必须是形参列表中的最后一个,由 params 修饰
使用params关键字之前,需要声明一个数组才能调用CalculateSum方法:
class Program
{
static void Main(string[] args)
{
var myIntArray = new int[] { 1, 2, 3 };
int result = CalculateSum(myIntArray);
Console.WriteLine(result);
}
static int CalculateSum(int[] intArray)
{
int sum = 0;
foreach (var item in intArray)
{
sum += item;
}
return sum;
}
}
使用params关键字之前,系统会在调用方法时传入参数时自动为其创建一个数组:
class Program
{
static void Main(string[] args)
{
int result = CalculateSum(1, 2, 3);
Console.WriteLine(result);
}
static int CalculateSum(params int[] intArray)
{
int sum = 0;
foreach (var item in intArray)
{
sum += item;
}
return sum;
}
}
其实早在WriteLine中就用到过params关键字:
String.Split 方法
根据提供一个或多个字符数组参数对字符串进行分割,并将分割结果返回一个数组
class Program
{
static void Main(string[] args)
{
string str = "Tim;Lisa,Cola.Mike";
string[] result = str.Split(';', ',', '.');
foreach (var name in result)
{
Console.WriteLine(name);
}
}
}
5.具名参数
参数的位置不再受约束
具名参数的优点:
- 提高代码可读性
- 参数的位置不在受参数列表约束
class Program
{
static void Main(string[] args)
{
PrintInfo("Tim", 34);
//不具名参数写法,编写时必须按照方法定义的参数列表顺序输入数据
PrintInfo(age: 24, name:"Wonder");
//具名参数写法,编写时参数顺序不被限制
}
static void PrintInfo(string name, int age)
{
Console.WriteLine("Helllo {0}, you are {1}.",name,age);
}
}
注:严格来说具名参数并不是参数的某个种类,而是参数的使用方法
6.可选参数
- 参数因为具有默认值而变得“可选”(即调用方法时,该参数可写可不写,不写就使用默认值)
- 不推荐使用可选参数
class Program
{
static void Main(string[] args)
{
PrintInfo();
}
static void PrintInfo(string name = "Tim", int age = 34)
{
Console.WriteLine("Helllo {0}, you are {1}.",name,age);
}
}
7.扩展方法(this参数)
- 方法必须是公有的、静态的、即被public static修饰的
- 必须是形参列表中的第一个,由this修饰
- 必须由一个静态类(一般类名为SomeTypeEtension)来统一收纳对SomeType类型的扩展方法
例如想给double类型添加一个Round方法,在无扩展方法的时候,无法给double类型添加Round方法(没有源代码,即使有修改后也无法添加到系统类库中):
class Program
{
static void Main(string[] args)
{
double x = 3.14159;
// double 类型本身没有 Round 方法,只能使用 Math.Round。
double y = Math.Round(x, 4);
Console.WriteLine(y);
}
}
使用扩展方法后:
class Program
{
static void Main(string[] args)
{
double x = 3.14159;
double y = x.Round(4);//这里可以理解为x本身就是Round方法第一个参数
Console.WriteLine(y);
}
}
static class DoubleExtension
{
public static double Round(this double input,int digits)
{
return Math.Round(input, digits);
}
}
注:扩展方法被定义为静态方法,但它们是通过实例方法语法进行调用的,它们的第一个参数指定该方法作用于哪个类型,并且该参数以 this 修饰符为前缀。
举例:LINQ方法
class Program
{
static void Main(string[] args)
{
var myList = new List<int>(){ 11, 12, 9, 14, 15 };
//bool result = AllGreaterThanTen(myList);
// 这里的 All 就是一个扩展方法
bool result = myList.All(i => i > 10);
Console.WriteLine(result);
}
static bool AllGreaterThanTen(List<int> intList)
{
foreach (var item in intList)
{
if (item <= 10)
{
return false;
}
}
return true;
}
}
All 第一个参数带 this,确实是扩展方法。
8.总结
各种参数的使用场景总结:
- 传值参数:参数的默认传递方法
- 输出参数:用于除返回值外还需要输出的场景
- 引用参数:用于需要修改实际参数值的场景
- 数组参数:用于简化方法的调用
- 具名参数:提高可读性
- 可选参数:参数拥有默认值
- 扩展方法(this 参数):为目标数据类型“追加”方法
十二.委托(delegate)
1.委托的定义
委托是函数指针的升级版,下述代码就是C语言中的函数指针实例:
#include <stdio.h>
int (*Calc)(int a, int b);
//声明有两个int形参,返回类型为int的函数指针类型
int Add(int a, int b)
{
int result = a + b;
return result;
}
int Sub(int a, int b)
{
int result = a - b;
return result;
}
int main()
{
int x = 100;
int y = 200;
int z = 0;
Calc funcPoint1 = &Add;
Calc funcPoint2 = ⋐//取函数的地址赋给函数指针变量
z = funcPoint1(x,y);
printf("%d+%d=%d\n",x,y,z);
z = funcPoint2(x,y);//利用函数指针调用函数
printf("%d-%d=%d\n",x,y,z);
system("pause");
return 0;
}
一切皆地址
- 变量(数据)是以某个地址为起点的一段内存中所存储的值
- 函数(算法)是以某个地址为起点的一段内存中所存储的一组机器语言指令
直接调用与间接调用
- 直接调用:通过函数名来调用函数,CPU通过函数名直接获得函数所在地址并开始执行,然后返回
- 间接调用:通过函数指针来调用函数,CPU通过读取函数指针存储的值获得函数所在地址并开始执行,然后返回
Java中没有与委托相对应的功能实体
Java 语言由 C++ 发展而来,为了提高应用安全性,Java 语言禁止程序员直接访问内存地址。即 Java 语言把 C++ 中所有与指针相关的内容都舍弃掉了。
委托的简单使用
- Action委托
- Func委托
Action 和 Func 是 C# 内置的委托,它们都有很多重载以方便使用
class Program
{
static void Main(string[] args)
{
var calculator = new Calculator();
// Action 用于无形参无返回值的方法。
Action action = new Action(calculator.Report);
//将action委托实例指向calculator.Report方法
calculator.Report();//直接调用calculator.Report方法
action.Invoke();//间接调用calculator.Report方法
action();//模仿函数指针的简略写法。
Func<int, int, int> func1 = new Func<int, int, int>(calculator.Add);
Func<int, int, int> func2 = new Func<int, int, int>(calculator.Sub);
//Func用于参数列表为多个,返回值类型为指定类型的方法
int x = 100;
int y = 200;
int z = 0;
z = func1.Invoke(x, y);
Console.WriteLine(z);
z = func2.Invoke(x, y);
Console.WriteLine(z);
// Func 也有简略写法。
z = func1(x, y);
Console.WriteLine(z);
z = func2(x, y);
Console.WriteLine(z);
}
}
class Calculator
{
public void Report()
{
Console.WriteLine("I have 3 methods.");
}
public int Add(int a, int b)
{
return a + b;
}
public int Sub(int a, int b)
{
return a - b;
}
}
Action委托适用于指向参数列表为0个或最多16个且没有返回值的方法
表示Func委托适用于指向参数列表为0个或至多16个,返回值类型为指定类型的方法(返回值类型在委托参数列表最后一个)
注:使用委托时,委托括号内只需要写方法名即可,不要在方法名后加括号,因为此时不需要调用该方法
2.委托的声明(自定义委托)
- 委托是一种类,类是数据类型所以委托也是一种数据类型
static void Main(string[] args)
{
Type t = typeof(Action);
Console.WriteLine(t.IsClass);
}
上述代码会打印True,证实委托是一种类
- 委托的声明方式和一般的类不同,主要是为了照顾可读性和C/C++传统
声明委托的示例如下:
class Program
{
static void Main(string[] args)
{
Calculator calculator = new Calculator();
Calc calc1 = new Calc(calculator.Add);
Calc calc2 = new Calc(calculator.Sub);
Calc calc3 = new Calc(calculator.Mul);
Calc calc4 = new Calc(calculator.Div);
double a = 100;
double b = 200;
double c = 0;
c = calc1.Invoke(a, b);
Console.WriteLine(c);
c = calc2.Invoke(a, b);
Console.WriteLine(c);
c = calc3.Invoke(a, b);
Console.WriteLine(c);
c = calc4.Invoke(a, b);
Console.WriteLine(c);
}
}
public delegate double Calc(double x, double y);
//声明委托,delegate表示委托,double表示目标方法的返回值类型,括号内参数表示目标方法的参数列表
class Calculator
{
public double Add(double x, double y)
{
return x + y;
}
public double Sub(double x, double y)
{
return x - y;
}
public double Mul(double x, double y)
{
return x * y;
}
public double Div(double x, double y)
{
return x / y;
}
}
- 注意声明委托的位置
声明委托时应该将其放在名称空间体内,这样它就与其他类同级了(委托就是一种类数据类型)。但 C# 允许嵌套声明类(一个类里面可以声明另一个类),所以有时也会有 delegate 在 class 内部声明的情况。
注:嵌套类在使用时,若是在被嵌入的类外部调用,需要标明来自于哪个类,若在被嵌入的类内部使用则不需要
- 委托所封装的方法必须类型兼容
注:参数类型的顺序要一一对应
3.委托的一般使用
实例:把方法当作参数传给另一个方法
正确使用1:模板方法,借用指定的外部方法来产生结果
- 相当于填空题
- 常位于代码中部
- 委托有返回值
利用模板方法,提高代码复用性。
下例中 WrapFactory方法的参数就来自于委托指向的方法的返回值。Product、Box、WrapFactory 都不用修改,只需要在 ProductFactory 里面新增不同的 MakeXXX 然后作为委托传入 WrapProduct 就可以对其进行包装。class Program { static void Main(string[] args) { var productFactory = new ProductFactory(); //新建一个产品工厂实例productFactory Func<Product> func1 = new Func<Product>(productFactory.MakePizza); Func<Product> func2 = new Func<Product>(productFactory.MakeToyCar); //创建委托func1和func2,分别指向productFactory实例的MakePizza和MakeToyCar方法 var wrapFactory = new WrapFactory(); //创建包装工厂实例wrapFactory Box box1 = wrapFactory.WrapProduct(func1); Box box2 = wrapFactory.WrapProduct(func2); //调用实例wrapFactory的WrapProduct方法,传入的参数分别是委托func1和func2,在WrapProduct方法内部完成委托的执行,并将Product类型返回值赋给创建的box1和box2实例,完成打包 Console.WriteLine(box1.Product.Name); Console.WriteLine(box2.Product.Name); } } class Product { public string Name { get; set; } }//产品类,属性为产品名 class Box { public Product Product { get; set; } }//包装盒类,属性为产品类的实例 class WrapFactory { // 模板方法,提高复用性 public Box WrapProduct(Func<Product> getProduct) { var box = new Box(); Product product = getProduct.Invoke(); box.Product = product; return box; }//方法WrapProduct,返回类型为包装盒类,参数为返回类型是Product且无参数的委托getProduct //方法内实现了创建新包装盒实例box,并执行传入的委托,将返回值赋给新创建的Product类实例product,再将实例product赋给box实例的Product属性,完成打包并返回box }//包装工厂类 class ProductFactory { public Product MakePizza() { var product = new Product(); product.Name = "Pizza"; return product; } public Product MakeToyCar() { var product = new Product(); product.Name = "Toy Car"; return product; } }//产品工厂类,用于声明生产各种产品的方法
注:将WrapFactory方法的参数改为Product类型,在调用时直接将产品工厂的方法作为整体(即方法的返回值)传入也可以实现一样的复用效果
两者差别:
1.将方法返回值作为参数
特点:
- 立即执行:方法在参数位置被立即调用;
- 传递的是值:传递的是方法执行后的返回值;
- 静态绑定:编译时确定具体调用哪个方法;
2.将方法本身作为参数
特点:
- 延迟执行:方法在接收方决定何时调用;
- 传递的是行为:传递的是方法本身的引用;
- 动态绑定:可以在运行时决定调用哪个方法;
tip:Reuse,重复使用,也叫“复用”。代码的复用不但可以提高工作效率,还可以减少 bug 的引入。良好的复用结构是所有优秀软件所追求的共同目标之一。
正确使用2:回调(callback)方法,调用指定的外部方法
- 相当于流水线
- 常位于代码末尾
- 委托无返回值
回调方法是通过委托类型参数传入主调方法的被调用方法,主调方法根据自己的逻辑决定是否调用这个方法。
class Program { static void Main(string[] args) { var productFactory = new ProductFactory(); var wrapFactory = new WrapFactory(); var logger = new Logger(); //创建产品工厂实例productFactory,包装工厂实例wrapFactory,Logger实例logger Func<Product> func1 = new Func<Product>(productFactory.MakePizza); Func<Product> func2 = new Func<Product>(productFactory.MakeToyCar); // Func 前面是传入参数,最后一个是返回值,所以此处以 Product 为返回值 //创建委托实例func1和func2分别指向产品工厂实例的制造方法MakePizza和MakeToyCar Action<Product> log = new Action<Product>(logger.Log); // Action 只有传入参数,所以此处以 Product 为参数 //创建委托实例log和func2分别指向实例logger的方法log Box box1 = wrapFactory.WrapProduct(func1, log); Box box2 = wrapFactory.WrapProduct(func2, log); //执行包装工厂实例的方法WrapProduct,参数分别为委托func1,log和委托func2,log, //两个产品都会装盒,但不一定执行log Console.WriteLine(box1.Product.Name); Console.WriteLine(box2.Product.Name); } } class Logger { public void Log(Product product) { // Now 是带时区的时间,存储到数据库应该用不带时区的时间 UtcNow。 Console.WriteLine("Product '{0}' created at {1}.Price is {2}", product.Name, DateTime.UtcNow, product.Price); } }//Logger类用于记录程序运行状态,打印产品的信息和制造时间 class Product { public string Name { get; set; } public double Price { get; set; } }//产品类,有Name和Price两个属性 class Box { public Product Product { get; set; } }//包装类,有Product属性 class WrapFactory { public Box WrapProduct(Func<Product> getProduct, Action<Product> logCallBack) { var box = new Box(); Product product = getProduct.Invoke();//制造产品 // 只 log 价格高于 50 的 if (product.Price >= 50) { logCallBack(product); } box.Product = product;//产品装盒 return box; } }//包装工厂类 class ProductFactory { public Product MakePizza() { var product = new Product { Name = "Pizza", Price = 12 }; return product; } public Product MakeToyCar() { var product = new Product { Name = "Toy Car", Price = 100 }; return product; } }//产品工厂类,用于存储产品制造方法
注意:委托难精通+易使用+功能强大,一旦被滥用则后果严重
- 缺点1:这是一种方法级别的紧耦合,现实工作中要慎之又慎
- 缺点2:使可读性下降,debug的难度增加
- 缺点3:把委托回调、异步调用和多线程纠缠在一起,会让代码变得难以阅读和维护
- 缺点4:委托使用不当有可能造成内存泄露和程序性能下降
委托滥用实例(等水平上去了可以回来看看这段代码):
4.委托的高级使用
4.1.多播委托
多播委托即一个委托内部封装不止一个方法。
using System;
using System.Threading;//该名称空间与多线程相关
namespace DelegateExample
{
class Program
{
static void Main(string[] args)
{
var stu1 = new Student { ID = 1, PenColor = ConsoleColor.Yellow };
var stu2 = new Student { ID = 2, PenColor = ConsoleColor.Green };
var stu3 = new Student { ID = 3, PenColor = ConsoleColor.Red };
var action1 = new Action(stu1.DoHomework);
var action2 = new Action(stu2.DoHomework);
var action3 = new Action(stu3.DoHomework);
// 单播委托
//action1.Invoke();
//action2.Invoke();
//action3.Invoke();
// 多播委托
action1 += action2;
action1 += action3;//相当于将action2和action3合并到了action1中
action1.Invoke();//此时委托action1包含三个DoHomework实例方法
}
}
class Student
{
public int ID { get; set; }
public ConsoleColor PenColor { get; set; }
//ConsoleColor类型表示控制台打印输出的颜色
public void DoHomework()
{
for (int i = 0; i < 5; i++)
{
Console.ForegroundColor = PenColor;
//ForegroundColor方法用于调整控制台打印输出的颜色
Console.WriteLine("Student {0} doing homework {1} hour(s)", ID, i);
Thread.Sleep(1000);//Sleep方法表示将当前线程挂起1000ms即1s
}
}
}
}
注:多播委托类似于链表,+=在链尾添加方法,-=查找并断开一个方法连接,其余的方法顺序和完整性不变,并且多播委托执行方法的顺序是按照封装方法时的顺序执行
上述代码输出结果如下:
4.2.隐式异步调用
4.2.1.关于计算机领域的同步与异步含义
- 同步表示操作按顺序执行,必须等待当前任务完成才能继续。
- 异步表示操作非顺序执行,可并发或延迟处理。
注:异步互不相干:这里说的“互不相干”指的是逻辑上各做各的,而现实工作当中经常会遇到多个线程共享(即同时访问)同一个资源(比如某个变量)的情况,这时候如果处理不当就会产生线程间争夺资源的冲突。
4.2.2.同步调用与异步调用的对比
同步异步表示是否使用委托,显式隐式表示是否创建线程的方式
每一个运行的程序称为一个进程(process),而每一个进程可以拥有一个或者多个线程(thread),进程运行时第一个运行的线程称为该进程的主线程,其余的称为分支线程 。
同步调用指的在同一个线程内进行串行方法调用;异步调用指的是在不同的线程内进行并行方法调用,其底层机理是多线程:
- 直接同步调用:使用方法名
- 间接同步调用:使用单播/多播委托的Invoke方法
- 隐式异步调用:使用委托的BeginInvoke
- 显式异步调用:使用Thread或Task
以下为几种同步调用的代码(只有一个进程)示例:
using System;
using System.Threading;
namespace DelegateExample
{
class Program
{
static void Main(string[] args)
{
var stu1 = new Student { ID = 1, PenColor = ConsoleColor.Yellow };
var stu2 = new Student { ID = 2, PenColor = ConsoleColor.Green };
var stu3 = new Student { ID = 3, PenColor = ConsoleColor.Red };
// 直接同步调用
//stu1.DoHomework();
//stu2.DoHomework();
//stu3.DoHomework();
var action1 = new Action(stu1.DoHomework);
var action2 = new Action(stu2.DoHomework);
var action3 = new Action(stu3.DoHomework);
// 间接同步调用
//单播委托同步调用
//action1.Invoke();
//action2.Invoke();
//action3.Invoke();
// 多播委托同步调用
action1 += action2;
action1 += action3;
action1.Invoke();
// 主线程模拟在做某些事情。
for (var i = 0; i < 10; i++)
{
Console.ForegroundColor=ConsoleColor.Cyan;
Console.WriteLine("Main thread {0}",i);
Thread.Sleep(1000);
}
}
}
class Student
{
public int ID { get; set; }
public ConsoleColor PenColor { get; set; }
public void DoHomework()
{
for (int i = 0; i < 5; i++)
{
Console.ForegroundColor = PenColor;
Console.WriteLine("Student {0} doing homework {1} hour(s)", ID, i);
Thread.Sleep(1000);
}
}
}
}
几种同步调用的执行结果都如下:
以下为隐式异步调用的代码示例:
using System;
using System.Threading;
namespace DelegateExample
{
class Program
{
static void Main(string[] args)
{
var stu1 = new Student { ID = 1, PenColor = ConsoleColor.Yellow };
var stu2 = new Student { ID = 2, PenColor = ConsoleColor.Green };
var stu3 = new Student { ID = 3, PenColor = ConsoleColor.Red };
var action1 = new Action(stu1.DoHomework);
var action2 = new Action(stu2.DoHomework);
var action3 = new Action(stu3.DoHomework);
// 使用委托进行隐式异步调用。
// BeginInvoke 隐式自动生成分支线程,并在分支线程内调用委托封装的方法。
action1.BeginInvoke(null, null);
action2.BeginInvoke(null, null);
action3.BeginInvoke(null, null);
// 主线程模拟在做某些事情。
for (var i = 0; i < 10; i++)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("Main thread {0}",i);
Thread.Sleep(1000);
}
}
}
class Student
{
public int ID { get; set; }
public ConsoleColor PenColor { get; set; }
public void DoHomework()
{
for (int i = 0; i < 5; i++)
{
Console.ForegroundColor = PenColor;
Console.WriteLine("Student {0} doing homework {1} hour(s)", ID, i);
Thread.Sleep(1000);
}
}
}
}
会打印如下结果,可以看到几个进程明显是并行执行的,但因为Console.ForegroundColor是全局共享资源,导致发生了资源争抢,多个线程同时访问造成冲突,使得结果偏离预期(也会导致每次执行的结果可能不同):
要解决资源冲突需要学习锁相关知识
以下为显式异步调用代码示例:
using System;
using System.Threading;
using System.Threading.Tasks;
namespace DelegateExample
{
class Program
{
static void Main(string[] args)
{
var stu1 = new Student { ID = 1, PenColor = ConsoleColor.Yellow };
var stu2 = new Student { ID = 2, PenColor = ConsoleColor.Green };
var stu3 = new Student { ID = 3, PenColor = ConsoleColor.Red };
// 老的显式异步调用方式 Thread,显式创建分支线程
//var thread1 = new Thread(new ThreadStart(stu1.DoHomework));
//var thread2 = new Thread(new ThreadStart(stu2.DoHomework));
//var thread3 = new Thread(new ThreadStart(stu3.DoHomework));
//thread1.Start();
//thread2.Start();
//thread3.Start();
//启动分支线程
// 使用 Task
var task1 = new Task(new Action(stu1.DoHomework));
var task2 = new Task(new Action(stu2.DoHomework));
var task3 = new Task(new Action(stu3.DoHomework));
task1.Start();
task2.Start();
task3.Start();
// 主线程模拟在做某些事情。
for (var i = 0; i < 10; i++)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("Main thread {0}", i);
Thread.Sleep(1000);
}
}
}
class Student
{
public int ID { get; set; }
public ConsoleColor PenColor { get; set; }
public void DoHomework()
{
for (int i = 0; i < 5; i++)
{
Console.ForegroundColor = PenColor;
Console.WriteLine("Student {0} doing homework {1} hour(s)", ID, i);
Thread.Sleep(1000);
}
}
}
}
打印结果如下,由于也发生了资源争抢,因此结果与BeginInvoke类似:
4.3.适时地使用接口(interface)取代委托
Java 完全使用接口取代了委托功能。C#也能使用接口取代委托,从而消除一些方法级别的耦合。 以前面的模板方法举列,通过接口也能实现方法的可替换:
using System;
namespace DelegateExample
{
class Program
{
static void Main(string[] args)
{
IProductFactory pizzaFactory = new PizzaFactory();
IProductFactory toyCarFactory = new ToyCarFactory();
var wrapFactory = new WrapFactory();
Box box1 = wrapFactory.WrapProduct(pizzaFactory);
Box box2 = wrapFactory.WrapProduct(toyCarFactory);
Console.WriteLine(box1.Product.Name);
Console.WriteLine(box2.Product.Name);
}
}
interface IProductFactory
{
Product Make();
}//声明一个接口IProductFactory,该接口内部只有一个返回值为Product类型的方法Make
class PizzaFactory : IProductFactory
{
public Product Make()
{
var product = new Product();
product.Name = "Pizza";
return product;
}
}
class ToyCarFactory : IProductFactory
{
public Product Make()
{
var product = new Product();
product.Name = "Toy Car";
return product;
}
}
class Product
{
public string Name { get; set; }
}
class Box
{
public Product Product { get; set; }
}
class WrapFactory
{
// 模板方法,提高复用性
public Box WrapProduct(IProductFactory productFactory)
{
var box = new Box();
Product product = productFactory.Make();
box.Product = product;
return box;
}
}
}
十三.事件详解
1.事件的定义
事件(Event),通俗说就是能够发生的某些事情。事件是类型的成员之一,是一种使对象或类能够提供通知的成员(即可以使对象或类具备通知能力)。事件发生不属于其功能,而事件发生之后的效果才是事件的功能(即通知)。自然世界中的事件一般都隶属于一个主体,如公司上市这个事件主体就是公司,而编程世界的事件的主体就是类型。
事件主体经由事件所发送的与事件本身相关的消息称为事件参数 EventArgs(又称事件信息,事件数据,事件消息),而根据通知和事件参数来采取行动的行为称作响应事件或处理事件,处理事件时所做的事情称为事件处理器 Event Handler,有些事件只有通知,没有事件参数,这样的事件发生本身就足以说明一切,不需要额外的消息。
因此,可以说事件的功能=通知+可选的事件参数(即详细信息),事件的使用方法就是用于对象或类之间的动作协调与信息传递(消息推送)
以手机的响铃事件举列:
- 手机可以通过响铃事件来通知关注手机的人
- 响铃事件让手机具备了通知关注者的能力
- 从手机角度看:响铃要求关注者采取行动;通知关注者的同时,把相关消息也发送给关注者
- 从人的角度看:人得到手机的通知,可以采取行动了;除了得到通知,还收到了事件主体者(手机)经由事件发送过来的消息 事件参数 EventArgs
- 响应事件:关注者得到通知后,检查事件参数,依据其内容采取响应的行动 处理事件具体所做的事情:事件处理器 Event Handler;如果是会议提醒:就去准备会议;如果是电话接入:选择是否接听;如果关注者在开会,直接抛弃掉事件参数,不做处理
事件模型event model(发生-响应模型)
组成部分: 发生-响应模型有五个部分
- 事件的拥有者
- 事件
- 事件的订阅者:又称事件消息的接收者、事件的响应者、事件的处理者或被事件所通知的对象
- 事件的处理器
- 事件的订阅(隐含)
如——闹钟响了你起床,五个部分分别为闹钟(事件的拥有者),响(事件),你(事件的响应者),起床(事件的处理器) ,闹钟是被你关注的(隐含的事件订阅);
孩子饿了你做饭,五个部分分别为孩子(事件的拥有者),饿(事件),你(事件的响应者),做饭(事件的处理器) ,孩子是被你关注的(隐含的事件订阅)
发生-响应模型有五个构建/运行动作:(1)我有一个事件->(2)一个人或一群人关心我的这个事件(即订阅)->(3)我的这个事件发生了->(4)关心这个事件的人会依次被通知到(通知顺序就是订阅顺序)->(5)被通知的人根据拿到的事件信息(又称事件数据,事件参数,通知)对事件进行响应(又称处理事件)
一些提示
- 事件多用于桌面、手机等开发的客户端编程,因为这些程序经常是用户通过事件来驱动的(在用户角度来看就是操作一次,程序逻辑就动一次,因此称为事件驱动)。从用户操作开始到用户看到新结果称为一次事件循环。
- 事件模型属于从现实世界抽象出来的一种客观存在,与具体的编程语言无关,任何一种语言都可以实现这种模型,而各种编程语言对这个机制的实现方法不尽相同。
- Java里没有事件这种成员,也没有委托这种数据类型,Java的事件是使用接口来实现的。
- 事件模式本身也是一种设计模式,而事件模式有一些缺陷,例如牵扯到的元素比较多(5个),不加约束的话,程序逻辑很容易变得一团乱麻。为了约束团队成员写代码时保持一致,把具有相同功能的代码写到固定的地方去,人们总结出一些最佳解决方案,逐渐形成了 MVC、MVP、MVVM 等程序架构模式。这些模式要求程序员在处理事件时有所为有所不为,代码该放到哪就放到哪,让程序更有条理。 MVC,MVP,MVVM等模式,是事件模式更高级更有效的玩法。
- 日常开发的时候,使用已有事件的机会比较多,自己声明事件的机会比较少,所以先学使用。
2.事件的应用
几个注意点:
- 事件处理器是方法成员
- 挂接事件处理器的时候,可以使用委托实例,也可以直接使用方法名,这是一个语法糖
- 事件处理器对事件的订阅不是随意的,匹配与否由声明事件时所使用的委托类型来检测
- 事件可以同步也可以异步调用
2.1.事件模型的五个组成部分
- 事件的拥有者(event source,对象)——站在事件拥有者的角度来看,事件就是一个用来通知别人的工具,事件自己是不会主动发生的,而是当事件的拥有者在完成某个内部逻辑之后,事件才会被触发发生
- 事件(event,成员)
- 事件的订阅者(event subscriber,对象)——是订阅了事件的对象或类,当一个事件发生时,被通知到的类或对象就是事件的订阅者
- 事件的处理器(event handler,成员)——本质上是一个回调方法
- 事件订阅(event source,对象)——把事件处理器与事件关联在一起,本质上是一种以委托类型为基础的约定
- 事件是一种特殊的委托,因此事件处理器订阅事件的时候必须符合该事件的委托类型,即参数类型和返回类型完全匹配才能订阅,否则无法订阅。
- +=和-=实际上是调用了委托的Combine和Remove方法,是一种语法糖。
- 挂接的逻辑是将事件处理器挂接到事件拥有者的内部的一个私有委托字段上,该字段的类型与事件处理器类型一样,外部代码通过+=和-=操作这个字段但不能直接访问它,私有委托天然支持多播,因此可以存储多个事件处理器
几者之间的关系以及常见误区:
一般来说属于事件订阅者,但也不一定:
这五个组成部分的组合方式千变万化,后续会介绍三种组合方式形成的事件订阅方式
2.2.事件订阅解决了三个问题
(1)当一个事件发生时,事件的拥有者都会通知谁?
会通知订阅该事件的类或对象(2)拿什么样的事件处理器(方法)才能够处理该事件?
当拿着一个事件处理器去订阅一个事件时,C#编译器会做非常严格的类型检查,C#规定:用于订阅事件的事件处理器必须与事件遵守同一个约定;这个约定,既约束了事件能够把什么样的消息发送给事件处理器,也约束了事件处理器能够处理什么样的消息如果事件是使用某个约定定义的,而且事件处理器也遵循同样的约定,那么【事件处理器与事件就是匹配的】,说明该事件处理器可以订阅该事件;如果不匹配,编译器就会报错
这个约定,就是委托——因此说事件是基于委托的
(3)事件的响应者具体拿哪个方法来处理该事件?
如果类或对象的多个方法都与事件匹配,那么在订阅事件时,就会告诉事件:未来会用哪个具体方法来处理事件
2.3.关于事件拥有者通过内部逻辑触发事件的实例
用户按下按钮执行操作,看似是用户的外部操作引起按钮的 Click 事件触发,实际不然,详细情况大致如下:
- 当用户点击图形界面的按钮时,实际是用户的鼠标向计算机硬件发送了一个电信号。Windows 检测到该电信号后,就查看一下鼠标当前在屏幕上的位置。当 Windows 发现鼠标位置处有个按钮,且包含该按钮的窗口处于激活状态,它就通知该按钮,用户按下了,然后按钮的内部逻辑开始执行
- 典型的逻辑是按钮快速地把自己绘制一遍,绘制成自己被按下的样子,然后记录当前的状态为被按下了。紧接着如果用户松开了鼠标,Windows 就把消息传递给按钮,按钮内部逻辑又开始执行,把自己绘制成弹起的状态,记录当前的状态为未被按下
- 按钮内部逻辑检测到,按钮被执行了连续的按下、松开动作,即按钮被点击了。按钮马上使用自己的 Click 事件通知外界,自己被点击了。如果有别的对象订阅了该按钮的 Click 事件,这些事件的订阅者就开始工作
简言之:用户操作通过 Windows 调用了按钮的内部逻辑,最终还是按钮的内部逻辑触发了 Click 事件。
2.4.事件示例
Timer 的一些成员,其中闪电符号标识的两个就是事件:
通过查看 Timer 的成员,我们不难发现一个对象最重要的三类成员:
- 属性:对象或类当前处于什么状态
- 方法:它能做什么
- 事件:它能在什么情况下通知谁
Timer Elapsed 事件示例:
using System;
using System.Timers;
namespace EventExample
{
class Program
{
static void Main(string[] args)
{
// 1.事件拥有者 timer
Timer timer = new Timer();
timer.Interval = 1000;//Interval是timer的属性,表示触发Elapsed事件的时间间隔为1000ms
// 3.事件的响应者 boy
Boy boy = new Boy();
Girl girl = new Girl();
// 2.事件成员 Elapsed,当timer度过一段时间就会触发,时间长短由编写者设定
// 5.事件订阅 +=
timer.Elapsed += boy.Action;
timer.Elapsed += girl.Action;//为Elapsed事件挂接事件处理器Action,Action2
timer.Start();//打开timer
Console.ReadLine();//timer是后台线程,依赖主线程存活,若没有Console.ReadLine()阻止主线程退出,则timer的事件来不及触发,整个进程就结束了
}
}
class Boy
{
// 这是通过 VS 自动生成的事件处理器,适合新手上手。
// 4.事件处理器 Action
internal void Action(object sender, ElapsedEventArgs e)
{
Console.WriteLine("Jump!");
}
}
class Girl
{
internal void Action(object sender, ElapsedEventArgs e)
{
Console.WriteLine("Sing!");
}
}
}
可以使用Visual Studio修补程序自动生成对应的事件处理器:
注:1.为事件挂接事件处理器(即订阅事件)的操作符是+=,+=操作符的右边要写挂接的实例方法,但要注意不能带启动器()。2.采取自动生成事件处理器的原因是:订阅事件的事件处理器必须与事件遵守同一个约定,该约定是一个委托类型;而作为初学者很难搞清楚这到底是什么委托类型;Visual Studio会自动按照这个委托类型去生产事件处理器,直接拿来填补就好。
3.几种事件订阅方式
3.1⭐事件拥有者和事件响应者是完全不同的两个对象
这种组合方式结构非常清晰,是标准的事件机制模型,也是MVC,MVP等设计模式的雏形
特点是:事件拥有者和事件响应者是完全不同的两个对象;事件响应者用自己的事件处理器订阅着这个事件,当事件发生时,事件处理器开始执行
下列代码实现了当用户点击窗体时,窗体标题栏会显示当前时间的功能
参数类型是约定的一部分,Click 事件与上例的 Elapsed 事件的第二个参数的数据类型不同,即这两个事件的约定是不同的。
也就是说,不能拿影响 Elapsed 事件的事件处理器去响应 Click 事件 —— 因为遵循的约束不同,所以他们是不通用的。
using System;
using System.Windows.Forms;
namespace EventExample
{
class Program
{
static void Main(string[] args)
{
// 1.创建窗体,事件拥有者
var form = new Form();
// 3.创建控制器,事件响应者
var controller = new Controller(form);
//显示窗体,进入消息循环
form.ShowDialog();
}
}
class Controller
{
private Form form;
public Controller(Form form)
{
if (form != null)
{
this.form = form;
// 2.事件成员 Click 5.事件订阅 +=
this.form.Click += this.FormClicked;
}
}
// 4.事件处理器
private void FormClicked(object sender, EventArgs e)
{
this.form.Text = DateTime.Now.ToString();
//让form的标题栏显示当前时间
}
}
}
-
事件模型的5个组成部分
(1)事件的拥有者:form
(2)事件:form的Click事件
(3)事件的响应者:controller
(4)事件处理器:类Controller的实例方法FormClicked()
(5)订阅事件 -
为什么FormClicked()与Action()的第二个参数不同?
因为Click事件与它的事件处理器FormClicked()共同遵守着约定A,而Elapsed事件与它的事件处理器Action()共同遵守着约定B,约定A和约定B不同,也就是遵循的约束不同;所以,不能拿响应Elapsed事件的事件处理器去响应Click事件,它们之间是不通用的 -
为什么要给类Controller声明一个Form类的实例字段?
为了架起一个桥梁
首先明确:事件是不会主动发生的,它一定是被事件拥有者的某些内部逻辑所触发,而在这个例子当中,事件拥有者和事件响应者是完全不同的两个对象,那么事件响应者如何知道事件已被触发?换句话说,事件的响应者如何被通知,从而响应事件?
所以,事件的响应者 controller 需要将事件的拥有者,也就是Form类的实例form,吸收为自己的实例字段,因为实例字段可表示该实例当前的状态,那么当form的Click事件触发后,controller就会被通知到,这就相当于架起了事件拥有者与事件响应者之间的一个桥梁 -
事件的响应者 controller 如何将事件的拥有者 form 吸收为自己的实例字段?
通过自定义类Controller的构造器
定义该构造器时,规定未来创建类Controller的实例时,必须传进一个数据类型为Form的实参,显然该实参就是事件的拥有者 form,如果 form 不为空,就把这个form赋值给类Controller的实例字段【form】(注意区分两个form:this.form = form;赋值符号左边是实例字段,右边是实参),并为实例字段form的Click事件挂接事件处理器
3.2.⭐⭐事件的拥有者和响应者是同一个对象
一个对象拿着自己的方法去订阅和处理自己的事件
该示例中事件的拥有者和响应者都是 from。示例中顺便演示了继承:
using System;
using System.Windows.Forms;
namespace EventExample
{
class Program
{
static void Main(string[] args)
{
// 1.创建窗体,事件拥有者
// 2.事件响应者
// 都是form
var form = new MyForm();
// 3.订阅事件
// 4.事件
form.Click += form.FormClicked;
//显示窗体,进入消息循环
form.ShowDialog();
}
}
// 因为无法直接修改 Form 类,所以创建了继承与 Form 类的 MyForm 类
class MyForm : Form
{
//5.事件处理器
internal void FormClicked(object sender, EventArgs e)
{
this.Text = DateTime.Now.ToString();
}
}
}
-
事件模型的5个组成部分
(1)事件的拥有者:form
(2)事件:Click事件
(3)事件的响应着:form
(4)事件处理器:FormClick()
(5)订阅事件 -
为什么要声明一个派生于Form类的子类MyForm?
因为如果直接去创建一个Form类的实例,是无法为该实例的Click事件去挂接事件处理器的,因为Form类早就已经写好了,不能修改;但如果要为了能够写事件处理器而去创建一个全新的类,则有点小题大做了,是不太现实的;所以声明一个类MyForm,它既继承了Form类的所有成员,也可以自定义事件处理器
3.3.⭐⭐⭐事件的拥有者是事件响应者的一个字段成员
事件的响应者用自己的方法订阅着自己的字段成员的某个事件,这种情况,意义重大,应用非常广泛;因为它是Windows平台上默认的事件订阅和处理结构
举例:
【按钮是窗口的字段成员】
按钮是Click事件的拥有者,而窗口则是Click事件的响应者;
当为这个窗口编程时,会为其准备一个方法(事件处理器),该方法订阅着按钮的Click事件,一旦用户点击按钮,按钮就会通过Click事件通知窗口自己被点击了,窗口就会应用自己的事件处理器去响应该事件
代码示例:
实现:在窗口中有一个文本框,一个按钮;当点击按钮时,文本框中就会显示 “hello,world!!!!” 字符串
using System;
using System.Windows.Forms;
using System.Drawing;
namespace EventExample
{
class Program
{
static void Main(string[] args)
{
//创建窗体,是事件的响应者
var form = new MyForm();
//显示窗体,进入消息循环
form.ShowDialog();
}
}
// 因为无法直接修改 Form 类,所以创建了继承与 Form 类的 MyForm 类
class MyForm : Form // 2.事件响应者
{
private Button button;
private TextBox textBox;//添加按钮和文本框,按钮是事件拥有者
public MyForm()
{
this.button = new Button();
this.textBox = new TextBox();
this.button.Text = "Click me!";
// 设置控件的位置和大小
this.textBox.Location = new System.Drawing.Point(10, 10);
this.textBox.Size = new System.Drawing.Size(200, 20);
this.button.Location = new System.Drawing.Point(10, 40);
this.button.Size = new System.Drawing.Size(100, 30);
this.Controls.Add(this.textBox);
this.Controls.Add(this.button);//将控件显示到窗体上
this.button.Click += this.ButtonClicked;//事件本身button.Click,订阅事件
}
//5.事件处理器
private void ButtonClicked(object sender, EventArgs e)
{
this.textBox.Text = "hello,world!!!!";
}
}
}
-
事件模型的5个组成部分
(1)事件的拥有者:button
(2)事件:Click事件
(3)事件的响应者:form
(4)事件处理器:ButtonClick()方法
(5)订阅事件 -
事件的响应者到底是谁?
不要以为在textbox中显示了字符串事件的响应者就是textbox,因为TextBox是微软早就准备好的类,它是不会拥有自定义的事件处理器的;唯一能修改的只有MyForm这个类,事件的响应者应是它的实例form
从上例衍生出的非可视化编程到可视化编程问题
上述代码中,由于我们在编写代码时是非可视化的,对于每个控件的位置和大小我们是无法知道运行后所展示的具体样子,所以也没办法,只能乱猜,这样就会把大量时间浪费在设计窗体上,所以我们需要可视化编程,也就是:所见即所得
打开WinForms,添加好控件和文本框(可以给他们自定义名称),代码会自动变化
(1)添加文本框,按钮控件后,在设计区右键鼠标,点击 “view code(查看代码)”,进入代码填写完整逻辑
(2)也可以自动生成事件处理器,方法是添加控件后,选中事件的拥有者button,在右侧属性面板中找到它的Click事件,填写事件处理器名称 “ButtonClicked” 后敲击回车(可以随时改名,不过改完后需要手动删掉旧处理器的代码),就会看到已经生成的好的事件处理器框架,往里添加所需逻辑即可
问题:这种情况的事件订阅在哪呢?
右击事件处理器ButtonClicked,选择 “Find All Reference(查找所有引用)”,高亮的部分就是订阅事件的表达式(运行后窗体设计器自动完成)
注:上图的订阅写法是比较老的写法,现在直接在+=后面接事件处理器名即可 ,效果一样
几点补充
1.一个事件处理器是可以重用的 (即可以同时被多个事件挂接)
但要注意:重用的前提是这个事件处理器必须与所要处理的事件保持约束上的一致
举例:
如果为窗口再添加一个button2控件,那么由于button1的事件处理器ButtonClicked与button2的Click事件遵循的是同一个约定,所以button2的Click事件也可以挂接上该事件处理器(可以在button2的属性面板中找到它的Click事件,在下拉菜单中直接选择ButtonClicked事件处理器)
注意ButtonClicked事件处理器的第一个参数:【Sender】——event source(也就是事件的拥有者,事件的source,事件消息的发送者;这也是为什么这个参数叫作 “sender” 的原因:sender的意思是:事件消息的发送者)
由于ButtonClicked与所要处理的Click事件保持约束上的一致,可以处理两个click事件,那么就可以根据事件sender(事件拥有者)的不同来决定逻辑的不同
2.一个事件可以挂接多个事件处理器
见上Timer Elapsed 事件示例:
3.挂接事件处理器的几种方法
- 最常用的挂接方式,直接写方法名:
this.button.Click += this.ButtonClicked
- 界面编辑器会采用更传统的挂接方式;visual studio会自动判断出EventHandler是事件处理器和事件所共同遵循的约定:
this.button1.Click += new System.EventHandler(this.ButtonClicked);
- 用匿名方法进行挂接(已经废弃了)
this.button.Click += delegate(object sender, EventArgs e)
{
this.textBox.Text = "Hello World";
}
- 用lambda表达式进行挂接,当使用这种写法时,编译器可以通过委托约束来推断出参数的数据类型,不写参数类型都可,如下代码可以不写object和EventArgs:
this.button.Click += (object sender, EventArgs e) =>
{
this.textBox.Text = "Hello World";
}
4.如何用WPF应用程序使用事件?
- WPF与WinForms中绝大部分是相同的,两者使用事件的方法几乎是一致的,只是WPF有种新的使用方法
- WPF发明的XAML与html是同一个家族的语言,以便设计师轻松参与到程序开发中的窗体设计部分
- 可以在XAML中直接用 : click = “…” 的格式挂接处理器,双引号中是事件处理器的名字,注意该语句要写对位置,是谁的click事件就写在谁中,不要写错,然后会在后台自动生成对应代码
- 如果用传统的委托的方式挂接事件处理器,会发现WPF中button的Click事件与WinForms中button的Click事件的所用的约束(委托类型)是不一样的,这是因为WPF是一种新技术,它的事件成为路由事件
public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); //this.Button1.Click += this.ButtonClicked; this.Button1.Click += new RoutedEventHandler(this.ButtonClicked); } private void ButtonClicked(object sender, RoutedEventArgs e) { this.TextBox1.Text = "Hello, WASPEC!"; } }
注:代码操作控件时需要对应控件有名字,不然无法操作该控件
4.自定义事件
4.1.事件的声明
事件基于委托的两重含义:1、事件需要使用委托类型来做一个约束,这个约束既规定了时间能够发送什么样的消息给响应者,也规定了事件的响应者能够收到什么样的事件消息,这就决定了事件响应者的事件处理器必须能和这个约束匹配上才能订阅该事件;2、当事件的响应者向事件的拥有者提供了能够匹配这个事件的事件处理器之后,需要一个记录该事件处理器的地方,而能够引用,即记录方法的任务只有委托类型的实例能够做到。因此说事件无论是从表层约束还是从底层实现都是依赖于委托的。
事件声明有完整声明和简略声明两种,简略声明是完整声明的语法糖。
4.1.1.完整声明
注:声明委托类型(与类同级)≠ 声明委托类型字段(在类内部)。
using System;
using System.Threading;
namespace EventExample
{
class Program
{
static void Main(string[] args)
{
// 事件拥有者
var customer = new Customer();
// 事件响应者
var waiter = new Waiter();
// 事件成员、事件订阅
customer.Order += waiter.Action;
customer.Action();
customer.PayTheBill();
}
}
// 该类用于传递点的是什么菜,作为事件参数
public class OrderEventArgs:EventArgs
{
public string DishName { get; set; }
public string Size { get; set; }
}
// 声明一个委托类型,该委托用于事件处理,同时约束事件,即前面说的事件处理器和事件共同遵守的约定
public delegate void OrderEventHandler(Customer customer, OrderEventArgs e);
//括号内为事件拥有者和消息参数,一般事件消息参数就直接写e即可
public class Customer
{
// 委托类型字段,用于存储事件处理器(委托)
private OrderEventHandler orderEventHandler;
// 事件声明,event表明这是一个事件,OrderEventHandler表明约束该事件的委托类型,Order表示事件名称
public event OrderEventHandler Order
{
add { this.orderEventHandler += value; }
remove { this.orderEventHandler -= value; }//事件处理器的挂接器和移除器
}
public double Bill { get; set; }
public void PayTheBill()
{
Console.WriteLine("I will pay ${0}.",this.Bill);
}
public void WalkIn()
{
Console.WriteLine("Walk into the restaurant");
}
public void SitDown()
{
Console.WriteLine("Sit down.");
}
public void Think()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine("Let me think ...");
Thread.Sleep(1000);
}
if (this.orderEventHandler != null)
{
var e = new OrderEventArgs();
e.DishName = "Kongpao Chicken";
e.Size = "large";
this.orderEventHandler.Invoke(this,e);
}//这个if段中this.orderEventHandler不可以改为this.Order,因为语法规定事件名只能在+=或-=的左边
}
public void Action()
{
Console.ReadLine();
this.WalkIn();
this.SitDown();
this.Think();
}
}
public class Waiter
{
// 事件处理器
public void Action(Customer customer, OrderEventArgs e)
{
Console.WriteLine("I will serve you the dish - {0}.",e.DishName);
double price = 10;
switch (e.Size)
{
case "small":
price *= 0.5;
break;
case "large":
price *= 1.5;
break;
default:
break;
}
customer.Bill += price;
}
}
}
几点注意:1、声明委托类型时应与类平级,由于C#支持嵌套类型,因此将委托类型的声明放在类体里面也不会编译报错,但还是必须将委托放到与类平级的正确位置(这是要求);2、若委托是为了声明某个事件而准备的,该委托名应为事件名+EventHandler后缀(可以提高可读性:表明该委托是用于声明事件的的,也表明该委托是用于约束事件处理器的,同时表明该委托未来创建的实例是专门用于存储事件处理器的),委托的EventArgs参数名一般为e即可;3、若一个类是用于传递事件信息的,则该类的名应为事件名+EventArgs(也是为了提高可读性),且必须继承自EventArgs类;4,因为EventArgs,EventHandler类和事件拥有者类会配合使用,所以需要保证他们的访问级别相同
4.1.2.简略声明(字段式声明,field-like)
简略格式与上例的完整格式只有事件声明和事件触发两处不同。
using System;
using System.Threading;
namespace EventExample
{
class Program
{
static void Main(string[] args)
{
// 1.事件拥有者
var customer = new Customer();
// 2.事件响应者
var waiter = new Waiter();
// 3.Order 事件成员 5. +=事件订阅
customer.Order += waiter.Action;
customer.Action();
customer.PayTheBill();
}
}
public class OrderEventArgs:EventArgs
{
public string DishName { get; set; }
public string Size { get; set; }
}
public delegate void OrderEventHandler(Customer customer, OrderEventArgs e);
public class Customer
{
// 简略事件声明,看上去像一个委托(delegate)类型字段
// 并且省略了完整声明中的主动委托字段声明
public event OrderEventHandler Order;
public double Bill { get; set; }
public void PayTheBill()
{
Console.WriteLine("I will pay ${0}.",this.Bill);
}
public void WalkIn()
{
Console.WriteLine("Walk into the restaurant");
}
public void SitDown()
{
Console.WriteLine("Sit down.");
}
public void Think()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine("Let me think ...");
Thread.Sleep(1000);
}
if (this.Order != null)
{
var e = new OrderEventArgs();
e.DishName = "Kongpao Chicken";
e.Size = "large";
// 事件触发
this.Order.Invoke(this,e);
}//这里可以写成this.Order是因为这是一种语法糖,但微软在设计时造成了语法上的前后矛盾
}
public void Action()
{
Console.ReadLine();
this.WalkIn();
this.SitDown();
this.Think();
}
}
public class Waiter
{
// 4.事件处理器
public void Action(Customer customer, OrderEventArgs e)
{
Console.WriteLine("I will serve you the dish - {0}.",e.DishName);
double price = 10;
switch (e.Size)
{
case "small":
price *= 0.5;
break;
case "large":
price *= 1.5;
break;
default:
break;
}
customer.Bill += price;
}
}
}
使用 ildasm 反编译,查看隐藏在事件简化声明背后的秘密。可以看到Customer内自动生成的Bill字段和委托类型orderEventHandler字段(访问不到,因此只能使用事件名进行比较和执行等操作,这是一种语法糖,但是设计的不太好,导致语法和语言规定有些矛盾)
4.1.3.委托类型字段能否代替事件
既然已有了委托类型字段/属性,为什么还要事件?
——因为事件成员能让程序逻辑更加“有道理”、更加安全,谨防“借刀杀人”。
真正项目中,往往很多人在同一段代码上工作,如果在语言层面未对某些功能进行限制,这种自由度很可能被程序员滥用或误用。
像下面这种使用字段的方式,和 C、C++ 里面使用函数指针是一样的,经常出现函数指针指到了一个程序员不想调用的函数上去,进而造成逻辑错误。这也是为什么 Java 彻底放弃了与函数指针相关的功能 —— Java 没有委托类型。
using System;
using System.Threading;
namespace EventExample
{
class Program
{
static void Main(string[] args)
{
Console.ReadLine();
var customer = new Customer();
var waiter = new Waiter();
customer.Order += waiter.Action;
//customer.Action();
// badGuy 借刀杀人,给 customer 强制点菜
OrderEventArgs e = new OrderEventArgs();
e.DishName = "Manhanquanxi";
e.Size = "large";
OrderEventArgs e2 = new OrderEventArgs();
e2.DishName = "Beer";
e2.Size = "large";
var badGuy = new Customer();
badGuy.Order += waiter.Action;
badGuy.Order.Invoke(customer, e);
badGuy.Order.Invoke(customer, e2);
customer.PayTheBill();
}
}
public class OrderEventArgs : EventArgs
{
public string DishName { get; set; }
public string Size { get; set; }
}
public delegate void OrderEventHandler(Customer customer, OrderEventArgs e);
public class Customer
{
// 去掉 Event,把事件声明改成委托字段声明
public OrderEventHandler Order;
public double Bill { get; set; }
public void PayTheBill()
{
Console.WriteLine("I will pay ${0}.", this.Bill);
}
public void WalkIn()
{
Console.WriteLine("Walk into the restaurant");
}
public void SitDown()
{
Console.WriteLine("Sit down.");
}
public void Think()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine("Let me think ...");
Thread.Sleep(1000);
}
if (this.Order != null)
{
var e = new OrderEventArgs();
e.DishName = "Kongpao Chicken";
e.Size = "large";
this.Order.Invoke(this, e);
}
}
public void Action()
{
Console.ReadLine();
this.WalkIn();
this.SitDown();
this.Think();
}
}
public class Waiter
{
public void Action(Customer customer, OrderEventArgs e)
{
Console.WriteLine("I will serve you the dish - {0}.", e.DishName);
double price = 10;
switch (e.Size)
{
case "small":
price *= 0.5;
break;
case "large":
price *= 1.5;
break;
default:
break;
}
customer.Bill += price;
}
}
}
上述代码将Order声明成一个委托类型的字段(该委托指向处理器),访问权限为public,因此不同的顾客Customer都能利用参数在事件拥有者Customer类外部对其Order委托字段进行操作,造成逻辑错误。若是使用event关键字显式表明Order是一个事件(此时的Order对委托进行封装),委托类型字段orderEventHandler的访问级别是private,隐藏委托字段orderEventHandler(此时该委托指向处理器),对外只暴漏对委托的挂接和删除操作,确保委托orderEventHandler的具体执行只在事件拥有者Customer类内部进行而在外部不被允许,就能保证数据的安全性。
正是为了解决 public 委托字段在类的外部被滥用或误用的问题,微软才推出了事件这个成员。
一旦将 Order 声明为事件(添加 event 关键字),就能避免上面的问题。
4.1.4.事件的本质
事件的本质是委托字段的一个包装器,这个包装器对委托字段的访问起限制作用,相当于一个蒙板,封装的一个重要功能就是隐藏,事件对外界隐藏了委托实例的大部分功能,仅暴露添加/移除事件处理器的功能
蒙板 Mask: 事件这个包装器对委托字段的访问起限制作用,让你只能给事件添加或移除事件处理器。让程序更加安全更好维护。
封装 Encapsulation: 上面的限制作用,就是面向对象的封装这个概念。把一些东西封装隐藏起来,在外部只暴露我想让你看到的东西。
4.2.命名约定
4.2.1.用于声明事件的委托类型的命名约定
用于声明Foo事件的委托,一般命名为FooEventHandler(除非是一个非常通用的事件约束,如EventHandler)
FooEventHandler委托的参数一般有俩个(由Win32 API演化而来,历史悠久)
- 第一个是object类型,名字为sender,实际上就是事件的拥有者、事件的source
- 第二个是EventArgs的派生类,类名一般为FooEventArgs,参数名为e。也就是前面讲过的事件参数
- 虽然没有官方说法,但可以把委托的参数列表看作是事件发生后发送给事件响应者的事件消息
触发Foo事件的方法一般命名为OnFoo,即因何引发,事出有因
- 访问级别为protected(自己的类成员及派生类能访问),不能为public,不然又可以借刀杀人了(即事件的触发必须由事件拥有者自己完成)
EventHandler的使用
EventHandler是一个非常通用的系统委托类型,其中C#任何类型都继承自object,所有的事件参数都继承自EventArgs,其定义代码为:
public delegate void EventHandler(object sender, EventArgs e)
using System;
using System.Threading;
namespace EventExample
{
class Program
{
static void Main(string[] args)
{
var customer = new Customer();
var waiter = new Waiter();
customer.Order += waiter.Action;
customer.Action();
customer.PayTheBill();
}
}
public class OrderEventArgs : EventArgs
{
public string DishName { get; set; }
public string Size { get; set; }
}
//public delegate void OrderEventHandler(Customer customer, OrderEventArgs e);
public class Customer
{
// 使用默认的 EventHandler,而不是声明自己的
public event EventHandler Order;
public double Bill { get; set; }
public void PayTheBill()
{
Console.WriteLine("I will pay ${0}.", this.Bill);
}
public void WalkIn()
{
Console.WriteLine("Walk into the restaurant");
}
public void SitDown()
{
Console.WriteLine("Sit down.");
}
public void Think()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine("Let me think ...");
Thread.Sleep(1000);
}
if (this.Order != null)
{
var e = new OrderEventArgs();
e.DishName = "Kongpao Chicken";
e.Size = "large";
this.Order.Invoke(this, e);
}
}
public void Action()
{
Console.ReadLine();
this.WalkIn();
this.SitDown();
this.Think();
}
}
public class Waiter
{
public void Action(object sender, EventArgs e)
{
// 类型转换
var customer = sender as Customer;
var orderInfo = e as OrderEventArgs;
Console.WriteLine("I will serve you the dish - {0}.", orderInfo.DishName);
double price = 10;
switch (orderInfo.Size)
{
case "small":
price *= 0.5;
break;
case "large":
price *= 1.5;
break;
default:
break;
}
customer.Bill += price;
}
}
}
专用于触发事件的方法
依据单一职责原则,把原来的 Think 中触发事件的部分单独提取为 OnOrder 方法。
public void Think()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine("Let me think ...");
Thread.Sleep(1000);
}
this.OnOrder("Kongpao Chicken","large");
}
protected void OnOrder(string dishName,string size)
{
if (this.orderEventHandler != null)
{
var e = new OrderEventArgs();
e.DishName = dishName;
e.Size = size;
this.orderEventHandler.Invoke(this, e);
}
}
4.2.2.事件的命名约定
- 带有时态的动词或者动词短语
- 事件拥有者正在做什么事情,用进行时,事件拥有者做完了什么事情,用完成时
5.事件与委托的关系
- 事件真的是“以特殊方式声明的委托字段/实例”吗?
- 不是!只是声明的时候“看起来像”(对比委托字段与事件的简化声明,field-like)
- 事件声明的时候使用了委托类型,简化声明造成事件看上去像一个委托的字段(实例),而 event 关键字则更像是一个修饰符 —— 这就是错觉的来源之一
- 订阅事件的时候 += 操作符后面可以是一个委托实例,这与委托实例的赋值方法语句相同,这也让事件看起来像是一个委托字段 —— 这是错觉的又一来源
- 重申:事件的本质是加装在委托字段上的一个“蒙板”(mask),是个起掩蔽作用的包装器。这个用于阻挡非法操作的“蒙板”绝不是委托字段本身
- 为什么要使用委托类型来声明事件?
- 站在 source 的角度来看,是为了表明 source 能对外传递哪些消息
- 站在 subscriber 的角度来看,它是一种约定,是为了约束能够使用什么样签名的方法来处理(响应)事件
- 委托类型的实例将用于存储(引用)事件处理器
- 对比事件与属性
- 属性不是字段 —— 很多时候属性是字段的包装器,这个包装器用来保护字段不被滥用
- 事件不是委托字段 —— 它是委托字段的包装器,这个包装器用来保护委托字段不被滥用
- 包装器永远都不可能是被包装的东西
在上图中是被迫使用事件去做 !=
和 .Invoke()
,学过事件完整声明格式,就知道事件做不了这些。在这里能这样是因为简略格式下事件背后的委托字段是编译器自动生成的,这里访问不了。
总结:事件不是委托类型字段(无论看起来多像),它是委托类型字段的包装器,限制器,从外界只能访问 += -= 操作。
十四.类
前面的总结
- 讲解了 C# 基本元素、基本语法
- 把类的成员过了一遍:字段、属性、方法、事件
- 面向对象编程最主要的三个特征是封装,继承和多态,在前面其实已经讲过了封装、后面讲继承和多态
1.什么是类
类是一种数据结构,它可以包含数据成员(常量和字段)、函数成员(方法、属性、事件、索引器、运算符、实例构造函数、静态构造函数和析构函数)以及嵌套类型。类类型支持继承,继承是一种机制,它使派生类可以对基类进行扩展和专用化。 —— 《C# 语言规范》
注:这是在描述类是什么,讲的是类的外延而不是类的内涵。
类是面向对象编程的核心。
计算机领域的类有下面三个方面
- 是一种数据结构(data structure)
- 是一种数据类型
- 代表现实世界中的“种类”
1.1.是一种数据结构
类是一种“抽象”的数据结构。
- 类本身是“抽象”的结果,例如把学生抽象为 Student 类
- 类也是“抽象”结果的载体,Student 类承载着学生的抽象(学生的 ID,学生的行为等)
这里提到的 data structure 和算法里面的 data structure 略有不同。算法里面的数据结构更多是指集合(List、Dictionary 等)数据类型。
1.2.是一种数据类型
类是一种引用类型,具体到每一个类都是一个自定义的引用类型
- 可以用类去声明变量
- 可以用类去创建实例(把类作为实例的模板)
class Program
{
static void Main(string[] args)
{
// 2.可以用类声明变量、创建实例
Student stu = new Student
{
ID=1,
Name = "Timothy"
};
// 2.类是实例的模板
Console.WriteLine(stu.ID);
Console.WriteLine(stu.Name);
stu.Report();
}
}
// 1. 类是一种数据结构
// 2. 类是一种自定义的引用类型
class Student
{
// 1.从现实世界学生抽象出来的属性
public int ID { get; set; }
public string Name { get; set; }
// 1.从现实世界学生抽象出来的行为
public void Report()
{
//$用于简化字符串拼接
Console.WriteLine($"I'm #{ID} student, my name is {Name}.");
}
}
反射与 dynamic 示例 (了解一下)
这两个示例也展现了类作为“数据类型”的一面。
反射的基础(后面会详细讲):
Type t = typeof(Student);
object o = Activator.CreateInstance(t, 1, "Timothy");
Student stu = o as Student;
Console.WriteLine(stu.Name);
dynamic编程(了解一下):
Type t = typeof(Student);
dynamic stu = Activator.CreateInstance(t, 1, "Timothy");
Console.WriteLine(stu.Name);
1.3. 代表现实世界中的“种类”
程序中的类与哲学、数学中的类有相通的地方。
class Program
{
static void Main(string[] args)
{
Student s1 = new Student(1, "Timothy");
Student s2 = new Student(2, "Jacky");
Console.WriteLine(Student.Amount);
}
}
class Student
{
// 3. Amount 代表现实世界中学生种类的个数
public static int Amount { get; set; }
static Student()
{
Amount = 100;
}
public Student(int id, string name)
{
ID = id;
Name = name;
Amount++;
}
~Student()
{
Amount--;
}
...
}
1.4.构造器与析构器
构造器(Constructor)和析构器(Destructor)是面向对象编程中的两个重要概念,它们分别用于在对象创建和销毁的时候执行特定的操作。
- 构造器
类的构造器是类的一个特殊的成员函数,没有返回类型,包括void。当创建类的新对象时就会执行构造函器,用于初始化对象的状态。默认的构造器是没有任何参数的,可以重新设置无参数的构造器,也可以为构造器设置参数,构造器的名称必须跟类名一样。
- 析构器
作用是释放资源,析构器用于在对象销毁时执行清理操作,例如释放资源、关闭文件、断开连接等。需要注意的是,C#中的垃圾回收机制会自动管理对象的内存,而不是依赖于析构器来释放内存。因此,析构器一般用于释放非托管资源(如文件句柄、数据库连接等),而不是用于释放内存。
与构造器不同,析构器在对象销毁时自动被调用,而不是在对象创建时。但是在处理过程中GC机制会进行回收,因此析构器最大的作用是提前释放资源。析构器不能有参数,不能有任何修饰符而且不能被调用。析构器与构造器的标识符不同,特点是在析构器前面需要加上前缀“~”以示区别。如果系统中没有指定析构器,那么编译器由GC(Garbage Collection,垃圾回收机制)来决定什么时候进行释放资源。
注:
- 析构函数不能被显式调用,它由垃圾回收器自动调用。
- 一个类只能有一个析构函数,不能重载。
- 析构函数与类同名,但在方法名前加上~符号。
class Program
{
static void Main(string[] args)
{
// 使用默认构造器
//Student stu = new Student();
// 一旦有了非默认构造器,系统就不在为我们生成默认构造器
Student stu = new Student(1, "Timothy");
stu.Report();
}
}
class Student
{
public Student(int id, string name)
{
ID = id;
Name = name;
}
~Student()//析构器声明
{
Console.WriteLine("Bye bye! Release the system resources ...");
}
public int ID { get; set; }
public string Name { get; set; }
public void Report()
{
Console.WriteLine($"I'm #{ID} student, my name is {Name}.");
}
}
2.类的声明与访问级别
2.1.声明类的位置
- 在名称空间内(最常见的情况),下述代码的Main方法成为类成员:
namespace HelloClass
{
class Program
{
static void Main(string[] args)
{
...
}
}
...
- 放在显式的名称空间之外实际上是声明在了全局名称空间Global里面,实际上是把类声明在名称空间的一种特殊情况。
namespace HelloClass
{
...
}
class Computer
{
...
}
- 声明在类体里面,称为成员类,成员类在学习时不常见,但实际项目中常用。
namespace HelloClass
{
class Program
{
...
class Student
{
...
}
}
}
2.2.声明(declare)与定义(define)
在C或者C++中,类的声明和定义默认是分开的(推荐),也可以手动写到一起。但是在C#或JAVA中,声明即定义 。
2.3.类声明的语法
声明语法:
语法定义很夸张,但即使是 ASP.NET Core 这么大的项目里面也没有特别复杂的类声明。 一般声明一个类都很简短,其中class关键字, identifier(类名)和class-body(类体)不可以省略。
2.4.类的访问级别
类修饰符class-modifiers包括:new, public, protected, internal, private, abstract, sealed, static,根据使用方式可以分别归类到不同的组中。
其中public和internal两者被归为访问级别组:
class 前面没有任何修饰符等于默认加了 internal。
- internal:仅在自身程序集(Assembly)里面可以访问
- public:从 Assembly 暴露出去,可以在程序集外部访问
注:VisualStudio项目之间禁止互相引用;一个项目可能会包含多个命名空间;若一个命名空间没有任何一个类暴露给外部的时候,那么这么命名空间也就不会暴露在外部了(能Using但是没啥用,而且Using的时候也没自动补全)。
右键解决方案,在主项目TestingFile外创建一个新类库项目MyLib,再在MyLib内部创建一个MyNameSpace文件夹(会自动生成一个MyNameSpace命名空间),在文件夹内创建一个Calcultor类项,然后在TestingFile内引用MyLib,研究internal和public的访问级别:
可以发现当访问级别为public时,在TestingFile项目中是能够正常using命名空间MyLib.MyNameSpace(或使用全限定名),并使用Calcultor类的
而将访问级别改为internal或者不加访问修饰符的时候,在TestingFile项目中仍然可以using命名空间MyLib.MyNameSpace或写出全限定名MyLib.MyNameSpace,但此时不会出现代码补全提示了,并且也无法使用Calcultor类
此时若在MyLib项目下再新建一个MyNameSpace1文件夹并新建一个student类项,那么可以在这个student类项中对calculator类进行访问
注:rebuild就是重新编译,若要修改编译后的程序集名称,可以进入对应项目的属性中更改
private class仅当这个class是另一个class的成员时可以这样使用
在一个类名上按下F12可以跳至其定义处
3.类的派生与继承
3.1.继承类的声明
继承类的声明只需要在identifier和class-body之间加上class-base即冒号:(表继承)+类基础(基类名或基接口名)即可。(基类派生类和父类子类是一个意思)
namespace HelloOOP
{
class Program
{
static void Main(string[] args)
{
var t = typeof(Car);
var tb = t.BaseType;
var top = tb.BaseType;
Console.WriteLine(tb.FullName);
Console.WriteLine(top.FullName);
Console.WriteLine(top.BaseType == null);
}
}
class Vehicle {}
class Car : Vehicle {}
}
.BaseType的显示逻辑:如果类型显式继承自某个类,如Class A : B,则返回B;如果类型没有显式继承,则返回object,object类没有基类,会返回null。定义一个类时,类体内部可以为空,因为他会隐式继承Object类,并自动拥有Object类的几乎所有成员(构造器和析构器除外)
注:.NET是单根的,即所有类型的继承链的顶端都是object类,当声明一个类的时候没有显式指明他的基类是谁,实际相当于在后面加上了:Object
3.2.is a 概念
表示一个派生类(子类)的实例,从语义上来说也是一个基类(父类)的实例,反过来不成立。
var car = new Car();
Console.WriteLine(car is Vehicle);
Console.WriteLine(car is Object);
var vehicle = new Vehicle();
Console.WriteLine(vehicle is Car);
可以用基类类型的变量来引用派生类实例 :
// 可以用基类类型的变量来引用派生类实例
Vehicle vehicle = new Car();
Object o1 = new Vehicle();
Object o2 = new Car();
注:此时通过基类类型变量只能访问基类定义的成员,而不能访问派生类添加的新成员,并且若方法被标记为virtual并在派生类中override,调用时执行派生类的实现,若没有重写而是隐藏,则仍然调用基类版本
3.3.一些小知识点
- sealed封闭类是不能当作基类使用的
- C#只支持继承一个基类但基接口可以有多个,因此要说一个类继承/派生自某个基类,实现了某个基接口。注:C++ 支持多继承,但它也受菱形继承的困扰
- 子类的访问权限不能超越父类
3.4.继承的本质
继承的本质是派生类在基类已有的成员基础上,对基类进行的横向和纵向的扩展。
- 横向扩展:对类成员个数的扩充
- 纵向扩展(重写):对类成员版本的更新,属于比较高级的内容
只能扩展不能缩减,无法在子类中删除继承的父类成员,这是静态类型语言(C#、C++、Java 等)的特征,继承时类成员只能越来越多。
动态类型语言(Python、JavaScript)可以在子类中移除继承的父类成员。
注:
- 子类会继承父类的几乎全部成员,构造器和析构器除外,并且这种继承会在继承链上一直传到底
- 在继承体系中,在父类添加类成员容易,但是移除难,因为修改父类会影响所有子类,并且需要重新编译依赖代码。因此在进行类或类库设计时一定要非常小心,不要贸然引进新的类成员,可能会导致后期无法移除。
- 子类类体内部为空时,其实也是隐含了继承自父类的成员,本质上还是有内容的
关于基类对象
前提:继承关系中,构造器是无法被继承的
当继承链中的类创建一个对象时,是从最顶层基类(一般是Object)的构造器开始执行,先构造一个称之为基类对象的对象,再向下一层一层用子类构造器对这个基类对象继续进行构造,最终从基类对象构造出需要的子类对象
using System;
using System.Threading;
namespace HelloOOP
{
class Program
{
static void Main(string[] args)
{
var car = new Car();
car.ShowOwner();
}
}
class Vehicle
{
public Vehicle()
{
this.Owner = "N/A";
}
public string Owner { get; set; }
}
class Car : Vehicle
{
public Car()
{
this.Owner = "Car Owner";
}
public void ShowOwner()
{
Console.WriteLine(this.Owner);
Console.WriteLine(base.Owner);//base引用的对象就是基类构造器构造出的对象,
//并且base关键字只能向上访问一层,并不能多级访问,也不能base.base这样使用
}
}
}
注:并不是同时创建多个独立的基类对象,而是会在内存中创建一个包含完整继承链的单一对象实例,也就是虽然从基类到子类依次调用了构造器,但所有的构造器都在操作同一个对象,如上述代码两次打印的都是Car Owner,因为Car类中的构造器修改了Owner的值,覆盖了之前的基类对象Owner值,因此在继承中,子类对象和基类对象最终是没有区别的,都是同一个实例
如果父类构造器是含参数的,那么在调用子类构造器时,隐含的自动调用基类构造器这一步会出错,因为需要显式传入参数
此时解决方法为:
1.在子类构造器体前加上:base(值)显式给基类构造器传入参数
class Vehicle
{
public Vehicle(string owner)
{
this.Owner = owner;
}
public string Owner { get; set; }
}
class Car : Vehicle
{
public Car():base("N/A")
{
this.Owner = "Car Owner";
}
public void ShowOwner()
{
Console.WriteLine(this.Owner);
Console.WriteLine(base.Owner);
}
}
2.直接给子类构造器也加上参数并传给基类构造器:
class Vehicle
{
public Vehicle(string owner)
{
this.Owner = owner;
}
public string Owner { get; set; }
}
class Car : Vehicle
{
public Car(string owner):base(owner){}
//在基类构造器里已经把Owner的值设置为owner参数的值了,所以不需要在Car
//的构造器内再设置一遍了,让Car的构造器为空就可以了
public void ShowOwner()
{
Console.WriteLine(this.Owner);
Console.WriteLine(base.Owner);
}
}
4.类成员的访问级别
类成员的访问级别以类的访问级别为上限(后续的接口也遵循这个规则)。即假如一个类的访问级别是internal,即使这个类内部有public的成员,那么它在别的程序集内也是看不见的
注:在团队合作中,自己写的类或方法不想被他人调用时,推荐的做法就是严格限制访问级别。如果应该封装的成员没有封装,对方只要发现能够调用,又能解决问题,他就一定会去用,进而导致一些不确定的问题。并且:推荐写项目的时候一个类单独写在一个项中,会使得项目结构更清晰
关键字 | 访问级别 |
public | |
protected internal /internal protected | |
internal | |
protected(更多应用在方法上,因为对子类进行纵向扩展,即重写和protected关系紧密) | |
private(默认是private,但还是建议加上) |
C# 7.2 推出了最新的 Private Protected: The member declared with this accessibility can be visible within the types derived from this containing type within the containing assembly. It is not visible to any types not derived from the containing type, or outside of the containing assembly. i.e., the access is limited to derived types within the containing assembly.( Private Protected 仅对程序集内的派生类可见)
注:一个类可以通过继承获得别的类的private级的类成员,但它是无法直接访问该成员的,不过某些是可以间接访问的,如下述代码虽然无法直接在Car类内部访问继承下来的_rpm字段,但是可以通过public的Speed属性来使用_rmp,证实了private int _rmp是被继承下来了:
using System;
using System.Threading;
namespace HelloOOP
{
class Program
{
static void Main(string[] args)
{
var car = new Car();
car.Accelarate();
car.Accelarate();
Console.WriteLine(car.Speed);
}
}
public class Vehicle
{
private int _rpm;
public void Accelarate()
{
_rpm += 1000;
}
public int Speed { get { return _rpm / 1000; } }
}
public class Car : Vehicle
{
}
}
命名偏好
随着越来越多 C++、Java 程序员加入 .NET 社区,private 字段的命名普遍遵循下划线 + 小写。
例:private int _rmp
面向对象的实现风格
开放心态,不要有语言之争。
我们现在学到的封装、继承、多态的风格是基于类的(Class-based)。 还有另外一个非常重要的风格就是基于原型的(Prototype-based),JavaScript 就是基于原型的面向对象。
Java 也是基于类的,让我们一撇 Java:
package comc;
public class Main {
public static void main(String[] args) {
Car car = new Car();
car.owner = "Timothy";
System.out.println(car.owner);
}
}
class Vehicle{
public String owner;
}
class Car extends Vehicle{
}
几点对C#格式的补充:
1.C#中}后面一般不加分号;
2.C#在语法上允许类成员声明在构造函数之后,但建议按照一定的顺序编写代码,如:字段-属性-构造器-方法,方法的内部的局部变量仍须遵循先声明后使用的规则
5.重写和多态
多态是基于重写的,重写:纵向扩展,成员没有增加,但成员的版本增加了
本节内容
- 类的继承
类成员的“横向扩展”(成员越来越多)——上一节主要内容
类成员的“纵向扩展”(行为改变,版本增高)——即重写,本节主要内容
类成员的隐藏(不常用)
重写与隐藏的发生条件:函数成员(指方法,属性,事件,索引器,自定义的操作符,构造器,析构器这些具有行为的代码块,一般重写比较多的是方法和属性),可见(父类成员必须对子类是可见的),签名一致(比如方法需要保证方法名,返回类型和参数一致)
- 多态(polymorphism)
基于重写机制(virtual -> override)
调用到的函数成员的具体行为(版本)由对象决定
回顾:C# 语言的变量和对象都是有类型的,所以会有“代差”
5.1.重写 (Override)
子类对父类成员的重写。
因为类成员个数还是那么多,只是更新版本,所以又称为纵向扩展。
重写需要父类成员标记为 virtual,子类成员标记 override。
注:被标记为 override 的成员,隐含也是 virtual 的,可以继续被重写。
class Program
{
static void Main(string[] args)
{
var car = new Car();
car.Run();
// Car is running!
var v = new Vehicle();
v.Run();
// I'm running!
}
}
class Vehicle
{
public virtual void Run()
{
Console.WriteLine("I'm running!");
}
}
class Car : Vehicle
{
public override void Run()
{
Console.WriteLine("Car is running!");
}
}
5.2.隐藏(Hide)
如果子类和父类中函数成员签名相同,但又没标记 virtual 和 override,则会在子类中隐藏来自父类的函数成员,这就称为隐藏。
这会导致 Car 类里面有两个 Run 方法,一个是从 Vehicle 继承的 base.Run(),一个是自己声明的 this.Run()。
用基类类型的变量来引用派生类实例:此时通过基类类型变量只能访问基类定义的成员,而不能访问派生类添加的新成员,并且若方法被标记为virtual并在派生类中override,调用时执行派生类的实现,若没有重写而是隐藏,则仍然调用基类版本
class Program
{
static void Main(string[] args)
{
Vehicle v = new Car();
v.Run();
// I'm running!
}
}
class Vehicle
{
public void Run()
{
Console.WriteLine("I'm running!");
}
}
class Car : Vehicle
{
public void Run()
{
Console.WriteLine("Car is running!");
}
}
总结一下:当使用派生类类型的变量引用派生类实例时,对于重写和隐藏,访问到的方法都是自己类体内写明的方法版本,当使用基类类型的变量来引用派生类实例时,若是重写,则访问派生类重写的版本,若是隐藏,则访问基类的版本或者说是子类中隐藏的基类版本。(可以理解为 v 作为 Vehicle 类型,它本来应该顺着继承链往下(一直到 Car)找 Run 的具体实现,但由于 Car 没有 Override,现在Car里面写明的Run是继承链之外的单独的版本,所以它找不下去,只能调用与Car实例相关的继承链上的最新Run版本,也就是Vehicle 里面的 Run或者说是Car中隐藏的Vehicle的Run版本。)当然对于基类的实例,只能使用基类类型变量引用,并且不管怎样访问的都是自己类体内的版本。
注:
- 新手在C#不必过于纠结 Override 和 Hide 的区分、关联。因为原则上是不推荐用 Hide 的。很多时候甚至会视 Hide 为一种错误
- Java 里面是天然重写,不必加 virtual 和 override,也没有 Hide 这种情况
- Java 里面的 @Override(annotation)只起到辅助检查重写是否有效的功能
5.3.多态(Polymorphism)
C# 支持用父类类型的变量引用子类类型的实例。当用这个变量调用一个被重写的成员的时,调用到的函数成员的具体行为(版本)由对象决定:总是能够调用到与这个实例相关的,并且是继承链上最新的成员版本,这就叫做多态。其设计目标是让代码能够通过基类统一处理所有子类对象。
回顾:因为 C# 语言的变量和对象都是有类型的,就导致存在变量类型与对象类型不一致的情况,所以会有“代差”。
下列代码展示了多态,同时也展示了属性被重写:
namespace HelloOOP
{
class Program
{
static void Main(string[] args)
{
Vehicle v = new Vehicle();
v.Run();
Console.WriteLine(v.Speed);
Vehicle v = new Car();
v.Run();
Console.WriteLine( v.Speed);
Console.ReadKey();
}
}
class Vehicle
{
private int _speed;
public virtual int Speed
{
get { return _speed; }
set { _speed = value; }
}
public virtual void Run()
{
Console.WriteLine("I'm running!");
_speed = 100;
}
}
class Car : Vehicle
{
private int _rpm;
public override int Speed
{
get { return _rpm / 100; }
set { _rpm = value * 100; }
}
public override void Run()
{
Console.WriteLine("Car is running!");
_rpm = 5000;
}
}
class RaceCar : Car
{
public override void Run()
{
Console.WriteLine("Race car is running!");
}
}
}
C# vs Python
Python 是对象有类型,变量没有类型的语言,Python 变量的类型永远跟着对象走。 所以在 Python 中即使重写了,也没有多态的效果。
PS:
- JS 和 Python 类似,也是对象有类型,变量没类型
- TypeScript 是基于 JS 的强类型语言,所以 TS 变量是有类型的,存在多态
十五.接口、抽象类、SOLID、单元测试、反射
接口和抽象类既是理论难点,又是代码难点。接口和抽象类用得好,写出来的代码才好测试。
注:本节 PPT 的内容不是引导大纲,是总结 PPT。
引言
软件也是工业的分支,设计严谨的软件必须经得起测试。软件能不能测试、测试出问题后好不好修复、软件整体运行状态好不好监控,都依赖于对接口和抽象类的使用。
接口和抽象类是现代面向对象的基石,也是高阶面向对象程序设计的起点。
学习设计模式的前提:
- 透彻理解并熟练使用接口和抽象类
- 深入理解 SOLID 设计原则,并在日常工作中自觉得使用它们
算法、设计原则、设计模式必须要用到工作中去,才能真正掌握。还是那句话“学习编程的重点不是学是用”。
SOLID
- SRP:Single Responsibility Principle (单一功能原则)
- OCP:Open Closed Principle (开闭原则)
- LSP:Liskov Substitution Principle (里氏替换原则)
- ISP:InterfaceSegregation Principle (口隔离原则)
- DIP:Dependency Inversion Principle (依赖反转原则)
SOLID是由罗伯特·C·马丁在21世纪早期引入的记忆术首字母缩略字,指代了面向对象编程和面向对象设计的五个基本原则,由这五个基本设计原则孕育了几十种设计模式(设计模式是设计原则的高阶固定用法),因此也可以说SOLID是设计模式之母。
首字母 | 指代 | 概念 |
S | 单一功能原则 | 对象应该仅具有一种单一功能。 |
O | 开闭原则 | 软件体应该是对于扩展开放的,但是对于修改封闭的。 |
L | 里氏替换原则 | 程序中的对象应该是可以在不改变程序正确性的前提下被它的子类所替换。 参考契约式设计。 |
I | 接口隔离原则 | 多个特定客户端接口要好于一个宽泛用途的接口。 |
D | 依赖反转原则 | 一个方法应该遵从“依赖于抽象而不是一个实例”。依赖注入是该原则的一种实现方式。 |
关于 C# 设计原则的更多知识,推荐《Agile Principles, Patterns, and Practices in C#》。
1.为做基类而生的“抽象类”与“开放/关闭原则”
抽象类和开闭原则有密切的联系。
设计原则的重要性和它在敏捷开发中扮演的重要角色:
- 之前学了类的封装与继承,理论上爱怎么用怎么用,但要写出高质量、工程化的代码,就必须遵循一些规则
- 写代码就必须和人合作,即使是你一个人写的独立软件,未来的你也会和现在的你合作
- 这些规则就如同交通规则,是为了高效协作而诞生的
硬性规定:例如变量名合法,语法合法
软性规则
1.1.抽象类
一个类里面一旦有了 abstract 成员,类就变成了抽象类,就必须在声明类时标 abstract。
抽象类内部至少有一个函数成员(指方法,属性,事件,索引器,自定义的操作符,构造器,析构器这些具有行为的代码块)未完全实现。
abstract class Student
{
//抽象方法,只有返回值,甚至连方法体的花括号都没有
abstract public void Study();
}
abstract 成员即暂未实现的成员,因为它必须在子类中被实现,所以不能是 private 。
一个类不允许实例化,它就只剩两个用处了:
- 作为基类,生成派生类,在派生类里面实现基类中的 abstract 成员
- 声明基类(抽象类)类型变量去引用子类(已实现基类中的 abstract 成员)类型的实例,这又称为多态
抽象方法的实现,看起来和 override 重写 virtual 方法有些类似,所以抽象方法在某些编程语言(如 C++)中又被称为“纯虚方法”。virtual(虚方法)还是有方法体的,只不过是等着被子类重写,abstract(纯虚方法)却连方法体都没有。
PS:我们之前学的非抽象类又称为 Concrete Class。
1.2.开闭原则
如果不是为了修复 bug 和添加新功能,别总去修改类的代码,特别是类当中函数成员的代码。
我们应该封装那些不变的、稳定的、固定的和确定的成员,而把那些不确定的,有可能改变的成员声明为抽象成员,并且留给子类去实现。
开放修复 bug 和添加新功能,关闭对类的更改。
示例
示例演示如何添加交通工具类,通过版本的迭代来讲解开闭原则、抽象类和接口。
初始版本:从 Car 直接 copy 代码到 Truck:
class Car
{
public void Run()
{
Console.WriteLine("Car is running");
}
public void Stop()
{
Console.WriteLine("Stopped");
}
}
class Truck
{
public void Run()
{
Console.WriteLine("Truck is running");
}
public void Stop()
{
Console.WriteLine("Stopped");
}
}
这就已经违反了设计原则:不能 copy paste。
提取父类版:将相同的方法提取出来放在父类里面:
class Vehicle
{
public void Stop()
{
Console.WriteLine("Stopped");
}
}
class Car : Vehicle
{
public void Run()
{
Console.WriteLine("Car is running");
}
}
class Truck : Vehicle
{
public void Run()
{
Console.WriteLine("Truck is running");
}
}
但这样会有一个问题就是 Vehicle 类型变量无法调用 Run 方法,有两种解决方法:
- Vehicle 里面添加一个带参数的 Run 方法
- 虚方法
添加带参数的 Run:
class Vehicle
{
public void Stop()
{
Console.WriteLine("Stopped!");
}
public void Run(string type)
{
if (type == "car")
{
Console.WriteLine("Car is running...");
}
else if (type == "truck")
{
Console.WriteLine("Truck is running...");
}
}
}
这就违反了开闭原则,既没有修 bug 又没有添新功能就多了个 Run 方法。而且一旦以后再添加别的交通工具类,你就又得打开(Open) Vehicle 类,修改 Run 方法。
虚方法:
class Program
{
static void Main(string[] args)
{
Vehicle v = new Car();
v.Run();
// Car is running...
}
}
class Vehicle
{
public void Stop()
{
Console.WriteLine("Stopped!");
}
public virtual void Run()
{
Console.WriteLine("Vehicle is running...");
}
}
class Car : Vehicle
{
public override void Run()
{
Console.WriteLine("Car is running...");
}
}
class Truck : Vehicle
{
public override void Run()
{
Console.WriteLine("Truck is running...");
}
}
虚方法解决了 Vehicle 类型变量调用子类 Run 方法的问题,也遗留下来一个问题:Vehicle 的 Run 方法的行为本身就很模糊,且在实际应用中也根本不会被调到。而且从测试的角度来看,测试一段你永远用不到的代码,也是不合理的。
抽象类版
要不就干脆 Run 方法里面什么都不写,进而直接把 Run 的方法体干掉,Run 就变成了一个抽象方法。于是 Vehicle 也变成了抽象类。当 Vehicle 变成抽象类后,再添加新的继承于 Vehicle 的类就很简单了,也无需修改 Vehicle 的代码。
abstract class Vehicle
{
public void Stop()
{
Console.WriteLine("Stopped!");
}
public abstract void Run();
}
class Car : Vehicle
{
public override void Run()
{
Console.WriteLine("Car is running...");
}
}
...
class RaceCar : Vehicle
{
public override void Run()
{
Console.WriteLine("Race car is running...");
}
}
不光要掌握最后虚方法的用法,还有理解之前过程中的问题,进而识别并改善工作中的代码。
纯抽象类版(接口)
有没有一种可能,一个抽象类里面的所有方法都是抽象方法?
VehicleBase 是纯虚类,它将成员的实现向下推,推到 Vehicle。Vehicle 实现了 Stop 和 Fill 后将 Run 的实现继续向下推。
// 特别抽象
abstract class VehicleBase
{
public abstract void Stop();
public abstract void Fill();
public abstract void Run();
}
// 抽象
abstract class Vehicle:VehicleBase
{
public override void Stop()
{
Console.WriteLine("Stopped!");
}
public override void Fill()
{
Console.WriteLine("Pay and fill...");
}
}
// 具体
class Car : Vehicle
{
public override void Run()
{
Console.WriteLine("Car is running...");
}
}
在 C++ 中能看到这种纯虚类的写法,但在 C# 和 Java 中,纯虚类其实就是接口。
- 因为 interface 已经表明其内部所有成员都一定默认是 public 的,所以就把 public 去掉了
- 接口本身就包含了“是纯抽象类”的含义(所有成员一定是抽象的),所以 abstract 也去掉了
- 因为 abstract 关键字去掉了,所以实现过程中的 override 关键字也去掉了
- 命名空间下接口的默认访问级别是intenal,嵌套在类中接口的默认访问级别是private;C# 8.0 之前所有接口成员隐式public,且不能显式指定修饰符,C#8.0之后允许为接口成员指定private,protected,internal等;
-
接口成员的访问级别不允许超过接口本身的访问级别。
这是为了保证:如果接口对外不可见,其成员也不应被外部直接访问(否则逻辑矛盾)。
//接口
interface VehicleBase
{
void Stop();
void Fill();
void Run();
}
//由借口下推的抽象类
abstract class Vehicle : VehicleBase
{
public void Stop()
{
Console.WriteLine("Stopped!");
}
public void Fill()
{
Console.WriteLine("Pay and fill...");
}
// Run 暂未实现,所以依然是 abstract 的
public abstract void Run();
}
//由抽象类下推的具体类
class Car : Vehicle
{
public override void Run()
{
Console.WriteLine("Car is running...");
}
}
纯虚类演变成了接口,现在的代码架构就有点像平时工作中用的了。
又因为接口在 C# 中的命名约定以 I 开头:
interface IVehicle
{
void Stop();
void Fill();
void Run();
}
1.3.总结
什么是接口和抽象类:
- 接口和抽象类都是“软件工程产物”
- 具体类 -> 抽象类 -> 接口:越来越抽象,内部实现的东西越来越少
对于一个方法来说,方法体就是它的实现;对于数据成员,如字段,它就是对类存储数据的实现。
- 抽象类是未完全实现逻辑的类(可以有字段和非 public 成员,它们代表了“具体逻辑”)
- 抽象类为复用而生:专门作为基类来使用。也具有解耦功能
解耦的具体内容留待下一节讲接口时讲
- 封装确定的,开放不确定的(开闭原则),推迟到合适的子类中去实观
- 接口是完全未实现逻辑的“类”(“纯虚类”;只有函数成员;成员全部隐式public)
抽象类中的方法只要求不是private就行,可以是protected和internal;但接口中的方法必须是public的,而且是强制隐式public
- 接口为解耦而生:“高内聚,低耦合”,方便单元测试
- 接口是一个“协约”。早已为工业生产所熟知(有分工必有协作,有协作必有协约)
- 它们都不能实例化。只能用来声明变量、引用具体类(concrete class)的实例
2.接口、依赖反转、单元测试
abstract 中的抽象方法只规定了不能是 private 的,而接口中的“抽象方法”只能是 public 的。
这样的成员访问级别就决定了接口的本质:接口是服务消费者和服务提供者之间的契约。既然是契约,那就必须是透明的,对双方都是可见的。
除了 public,abstract 的抽象方法还可以是 protected 和 internal,它们都不是给功能调用者准备的,各自有特定的可见目标。
接口即契约(contract)
契约使自由合作成为可能,所谓自由合作就是一份合同摆在这里,它即约束服务的使用者也约束服务的提供者。如果该契约的使用者和提供者有多个,它们之间还能自由组合。
2.1.接口契约实例
未使用接口时:
class Program
{
static void Main(string[] args)
{
int[] nums1 = new int[] { 1, 2, 3, 4, 5 };
ArrayList nums2 = new ArrayList { 1, 2, 3, 4, 5 };
Console.WriteLine(Sum(nums1));
Console.WriteLine(Average(nums1));
Console.WriteLine(Sum(nums2));
Console.WriteLine(Average(nums2));
}
//针对int[](强类型数组(仅存储 int))和ArrayList(非泛型集合(存储 object))的重载Sum和Average方法
static int Sum(int[] nums)
{
int sum = 0;
foreach (int num in nums)
{
sum += num;
}
return sum;
}
static double Average(int[] nums)
{
if (nums.Length == 0)
{
return 0;
}
return (double)Sum(nums) / nums.Length;
}
static int Sum(ArrayList nums)
{
int sum = 0;
foreach (var num in nums)
{
sum += (int)num;
}
return sum;
}
static double Average(ArrayList nums)
{
if (nums.Count == 0)
{
return 0;
}
return (double)Sum(nums) / nums.Count;
}
}
使用接口时:
服务提供方是 nums1 和 nums2,服务使用方是 Sum 和 Avg 这两函数。使用方需要传进来的参数可以迭代就行,别的不关心也用不到。整型数组的基类是 Array,Array 和 ArrayList 都实现了 IEnumerable接口。
static int Sum(IEnumerable nums)
{
int sum = 0;
foreach (var n in nums)
{
sum += (int)n;
}
return sum;
}
static double Avg(IEnumerable nums)
{
int sum = 0;
double count = 0;
foreach (var n in nums)
{
sum += (int)n;
count++;
}
return sum / count;
}
2.2.依赖与耦合
现实世界中有分工、合作,面向对象是对现实世界的抽象,它也有分工、合作。类与类、对象与对象间的分工、合作。在面向对象中,合作有个专业术语叫“依赖”,依赖的同时就出现了耦合。依赖越直接,耦合就越紧。
Car 与 Engine 紧耦合的示例:
class Program
{
static void Main(string[] args)
{
var engine = new Engine();
var car = new Car(engine);
car.Run(3);
Console.WriteLine(car.Speed);
}
}
class Engine
{
public int RPM { get; private set; }
public void Work(int gas)
{
this.RPM = 1000 * gas;
}
}
class Car
{
// Car 里面有个 Engine 类型的字段,它两就是紧耦合了
// Car 依赖于 Engine
private Engine _engine;
public int Speed { get; private set; }
public Car(Engine engine)
{
_engine = engine;
}
public void Run(int gas)
{
_engine.Work(gas);
this.Speed = _engine.RPM / 100;
}
}
紧耦合的问题:
- 基础类一旦出问题,上层类写得再好也没辙
- 程序调试时很难定位问题源头
- 基础类修改时,会影响写上层类的其他程序员的工作
所以程序开发中要尽量避免紧耦合,解决方法就是接口。
接口:
- 约束调用者只能调用接口中包含的方法
- 让调用者放心去调,不必关心方法怎么实现的、谁提供的
2.3.接口解耦示例
以老式手机举例,对用户来说他只关心手机可以接(打)电话和收(发)短信。对于手机厂商,接口约束了他只要造的是手机,就必须可靠实现上面的四个功能。用户如果丢了个手机,他只要再买个手机,不必关心是那个牌子的,肯定也包含这四个功能,上手就可以用。用术语来说就是“人和手机是解耦的”。
class Program
{
static void Main(string[] args)
{
//PhoneUser User = new PhoneUser(new NokiaPhone());
PhoneUser User = new PhoneUser(new EricssonPhone());
User.UsePhone();
}
}
//定义一个PhoneUser类,包含一个IPhone类型的成员变量
class PhoneUser
{
private IPhone _phone;
public PhoneUser(IPhone phone)
{
_phone = phone;
}
public void UsePhone()
{
_phone.Dial();
_phone.Pickup();
_phone.SendMessage();
_phone.ReceiveMessage();
}
}
//声明一个IPhone接口,包含拨打电话、接听电话、发送短信和接收短信的方法
interface IPhone
{
void Dial();
void Pickup();
void SendMessage();
void ReceiveMessage();
}
//在NokiaPhone类中实现IPhone接口
class NokiaPhone : IPhone
{
public void Dial()
{
Console.WriteLine("Nokia phone dialing...");
}
public void Pickup()
{
Console.WriteLine("Nokia phone picking up...");
}
public void SendMessage()
{
Console.WriteLine("Nokia phone sending message...");
}
public void ReceiveMessage()
{
Console.WriteLine("Nokia phone receiving message...");
}
}
//在EricssonPhone类中实现IPhone接口
class EricssonPhone : IPhone
{
public void Dial()
{
Console.WriteLine("Ericsson phone dialing...");
}
public void Pickup()
{
Console.WriteLine("Ericsson phone picking up...");
}
public void SendMessage()
{
Console.WriteLine("Ericsson phone sending message...");
}
public void ReceiveMessage()
{
Console.WriteLine("Ericsson phone receiving message...");
}
}
没有用接口时,如果一个类坏了,你需要 Open 它再去修改,修改时可能产生难以预料的副作用。引入接口后,耦合度大幅降低,换手机只需要换个类名,就可以了。等学了反射后,连这里的一行代码都不需要改,只要在配置文件中修改一个名字即可。
在代码中只要有可以替换的地方,就一定有接口的存在;接口就是为了解耦(松耦合)而生。
松耦合最大的好处就是让功能的提供方变得可替换,从而降低紧耦合时“功能的提供方不可替换”带来的高风险和高成本。
- 高风险:功能提供方一旦出问题,依赖于它的功能都挂
- 高成本:如果功能提供方的程序员崩了,会导致功能使用方的整个团队工作受阻
2.4.依赖反转原则
解耦在代码中的表现就是依赖反转。单元测试就是依赖反转在开发中的直接应用和直接受益者。
人类解决问题的典型思维:自顶向下,逐步求精。在面向对象里像这样来解决问题时,这些问题就变成了不同的类,且类和类之间紧耦合,它们也形成了这样的金字塔。依赖反转给了我们一种新思路,用来平衡自顶向下的单一思维方式。
平衡:不要一味推崇依赖反转,很多时候自顶向下就很好用,就该用。
2.5.单元测试
用例子来展示接口、解耦和依赖反转原则是怎么被单元测试应用的。
紧耦合:
class Program
{
static void Main(string[] args)
{
var fan = new DeskFan(new PowerSupply());
Console.WriteLine(fan.CheckWorkingStatus());
}
}
// 背景:电扇有个电源,电源输出电流越大电扇转得越快
// 电源输出有报警上限
class PowerSupply
{
public int GetPower()
{
//return 100;
return 210;
}
}
class DeskFan
{
private PowerSupply _powerSupply;
public DeskFan(PowerSupply powerSupply)
{
_powerSupply = powerSupply;
}
public string CheckWorkingStatus()
{
int power = _powerSupply.GetPower();
if (power <= 0)
{
return "Won't work.";
}
else if (power < 100)
{
return "Slow";
}
else if (power < 200)
{
return "Work fine";
}
else
{
return "Warning";
}
}
}
现在的问题是:我要测试电扇是否能按预期工作,我必须去修改 PowerSupply 里面的代码(即OPEN了PowerSupply类),这违反了开闭原则。而且可能有除了电扇外的别的电器也连到了这个电源上面(在其他位置也引用了 PowerSupply),为了测试电扇工作就去改电源,很可能会造成别的问题。
接口的产生:自底向上(重构)和自顶向下(设计)。只有对业务足够熟悉才能做到自顶向下,即一开始就知道如何设计接口,更多时候是一边写一边重构,现在我们就用接口去对电源和风扇进行解耦。
class Program
{
static void Main(string[] args)
{
var fan = new DeskFan(new PowerSupply());
Console.WriteLine(fan.Work());
}
}
public interface IPowerSupply
{
int GetPower();
}
public class PowerSupply : IPowerSupply
{
public int GetPower()
{
return 110;
}
}
public class DeskFan
{
private IPowerSupply _powerSupply;
public DeskFan(IPowerSupply powerSupply)
{
_powerSupply = powerSupply;
}
public string Work()
{
int power = _powerSupply.GetPower();
if (power <= 0)
{
return "Won't work.";
}
else if (power < 100)
{
return "Slow";
}
else if (power < 200)
{
return "Work fine";
}
else
{
return "Warning";
}
}
}
有接口后,我们就可以专门创建一个用于测试的电源类。
- 为了单元测试,将相关的类和接口都显式声明为 public
- 示例本身是个 .NET Core Console App,其相应的测试项目最好用 xUnit(官方之选)
- 测试项目命名:被测试项目名.Tests,例如 InterfaceExample.Tests
- 测试项目要引用被测试项目
- 测试项目里面的类和被测试项目的类一一对应,例如 DeskFanTests.cs
using Xunit;
namespace InterfaceExample.Tests
{
public class DeskFanTest
{
[Fact]
public void PowerLowerThanZero_OK()
{
var fan = new DeskFan(new PowerSupplyLowerThanZero());
var expected = "Won't work.";
var actual = fan.Work();
Assert.Equal(expected, actual);
}
[Fact]
public void PowerHigherThan200_Warning()
{
var fan = new DeskFan(new PowerSupplyHigherThan200());
// 注:此处为了演示,实际程序那边先故意改成了 Exploded!
var expected = "Warning";
var actual = fan.Work();
Assert.Equal(expected, actual);
}
}
class PowerSupplyLowerThanZero : IPowerSupply
{
public int GetPower()
{
return 0;
}
}
class PowerSupplyHigherThan200 : IPowerSupply
{
public int GetPower()
{
return 220;
}
}
}
每当有新的代码提交后,就将 TestCase 全部跑一遍,如果原来通过了的,这次却没有通过(称为回退),就开始 Debug。平时工作中写测试 case 和写代码的重要性是一样的,没有测试 case 监控的代码的正确性、可靠度都不能保证。
程序想要能被测试,就需要引入接口、松耦合、依赖反转。
十六.泛型,partial类,枚举,结构体
1.泛型
- 为什么需要泛型:避免成员膨胀或类型膨胀
- 正交性:泛型类型(类、接口、委托……) 泛型成员(属性、方法、字段……)
- 类型方法的参数推断
- 泛型与委托,Lambda 表达式
泛型在面向对象中的地位与接口相当。其内容很多,这里只介绍最常用最重要的部分。
1.1基本介绍
正交性:将其它的编程实体看作横轴,将泛型看作纵轴,则泛型和其它的编程实体(类,接口,委托,方法等)都有正交点,即有诸如泛型类,泛型接口等产物,导致泛型对编程的影响广泛而深刻。
如果给泛型一个全称,应该叫做泛化类型或泛化数据类型
泛化与特化或具体化是对立的概念,泛型的对象在编程的时候是不可以直接使用的,必须要经过特化才能用来编程。
1.1.1泛型类示例
示例背景:开了个小商店,一开始只卖苹果,卖的苹果用小盒子装上给顾客。顾客买到后可以打开盒子看苹果颜色。
class Program
{
static void Main(string[] args)
{
var apple = new Apple { Color = "Red" };
var box = new Box { Cargo = apple };
Console.WriteLine(box.Cargo.Color);
}
}
class Apple
{
public string Color { get; set; }
}
class Box
{
public Apple Cargo { get; set; }
}
后来小商店要增加商品(卖书),有下面几种处理方法。
一:我们专门为 Book 类添加一个 BookBox 类的盒子。
class Program
{
static void Main(string[] args)
{
var apple = new Apple { Color = "Red" };
var box = new AppleBox { Cargo = apple };
Console.WriteLine(box.Cargo.Color);
var book = new Book { Name = "New Book" };
var bookBox = new BookBox { Cargo = book };
Console.WriteLine(bookBox.Cargo.Name);
}
}
class Apple
{
public string Color { get; set; }
}
class AppleBox
{
public Apple Cargo { get; set; }
}
class Book
{
public string Name { get; set; }
}
class BookBox
{
public Book Cargo { get; set; }
}
现在代码就出现了“类型膨胀”的问题。未来随着商品种类的增多,盒子种类也须越来越多,类型膨胀,不好维护。
二:用同一个 Box 类,每增加一个商品时就给 Box 类添加一个属性。
class Program
{
static void Main(string[] args)
{
var apple = new Apple { Color = "Red" };
var book = new Book { Name = "New Book" };
var box1 = new Box { Apple = apple };
var box2 = new Box { Book = book };
}
}
class Apple
{
public string Color { get; set; }
}
class Book
{
public string Name { get; set; }
}
class Box
{
public Apple Apple { get; set; }
public Book Book { get; set; }
}
但这会导致每个 box 变量只有一个属性被使用,也就是“成员膨胀”(类中的很多成员都是用不到的)。
三:Box 类里面的 Cargo 改为 Object 类型。
class Program
{
static void Main(string[] args)
{
var apple = new Apple { Color = "Red" };
var book = new Book { Name = "New Book" };
var box1 = new Box { Cargo = apple };
var box2 = new Box { Cargo = book };
Console.WriteLine((box1.Cargo as Apple)?.Color);
//表示当box1里面装的确实是Apple时才打印Color
//如果不是,则会打印NULL值,即什么都没有
}
}
class Apple
{
public string Color { get; set; }
}
class Book
{
public string Name { get; set; }
}
class Box
{
public Object Cargo{ get; set; }
}
使用时必须进行强制类型转换或 as操作符,即这种解决办法向盒子里面装东西省事了,但取东西时很麻烦。
一.开发环境简介
Visual Studio 2022
两个帮助学习的文档
1.help viewer的MSDN文档
2.微软官网的C#语言定义文档
二.初识各类应用程序
解决方案(Solution)是针对客户需求的总的解决方案。举例:汽车经销商需要一套销售软件
项目(Project)解决某个具体的问题
一个解决方案(Solution)可以包含一个或多个项目(Project),类似于将一个大问题拆分成多个小问题进行解决,一般来说简单的解决方案只需要一个项目就足够了
一个项目可以通过不同的模板来实现,从而满足不同的需求
C#的文件扩展名是.cs
常见的C#项目模板(注:一般C#项目要选框架项目)
1.Console Application( framework)
即控制台应用( framework)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ConsoleHelloWorld
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello world");//在控制台输出一行文本
Console.ReadKey();//按下任意键后继续
}
}
}
单击F5进行调试运行,Ctrl+F5进行不调试运行,调试运行可能会运行完直接自动关闭控制台,而不调试运行不会自动关闭,或者调试运行时在最后加上Console.ReadKey()也不会自动关闭
注:不勾选调试停止时自动关闭控制台也不会使程序运行结束时自动关闭控制台,该选项位于工具-选项-调试-常规中
2.Windows Forms Application( framework)
即Windows窗体应用( framework)
可在视图(view)中找到工具箱(toolbox)进行添加各种组件(如button,text box等)到窗体并编辑位置大小等信息,右击某一组件可以查看并更改其属性,如名称name(取名时尽量取可读性好的有意义的名称)、文本内容text等,在属性框附近的闪电⚡标志表示事件(events),事件表示在对应组件处发生某样操作时程序如何响应
以点击按钮输出Hello world至文本框为例,点击事件标志,双击操作click跳转至代码编辑页面
输入如下代码即可使按钮在按下后文本框输出Hello world
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace WindowsFormsHelloWorld
{
public partial class Form1: Form
{
public Form1()
{
InitializeComponent();
}
private void button1_Click(object sender, EventArgs e)
{
textBoxShowHello.Text = "Hello World!";
}
}
}
3.WPF Application( framework)
即WPF应用( framework),可以看作是Windows窗体应用( framework)的升级版
操作上大体与Windows窗体应用相同,只是多了一些细节
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace WPFHelloWorld
{
/// <summary>
/// MainWindow.xaml 的交互逻辑
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void ButtonSayHello_Click(object sender, RoutedEventArgs e)
{
TextBoxShowHello.Text = "Hello World!";
}
}
}
4.ASP.NET Web Application( framework)
即ASP.NET Web应用程序( framework)
1.Web Forms
创建时选择Web Forms
右击项目选择添加Web窗体并命名项名称
注:Web Forms的扩展名是.aspx
输入以下代码后运行即可在网页上输出Hello world
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Default.aspx.cs" Inherits="WebFormHelloWorld.Default" %>
<!DOCTYPE html>
<html xmlns="http://www.w3/1999/xhtml">
<head runat="server">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title></title>
</head>
<body>
<h1>Hello World!</h1>
</body>
</html>
另:右击项目名,点击发布(publish)可以将其发布至各种网站
2.MVC(Model-View-Controller)
比Web Form先进,能确保代码的结构划分清晰,便于维护
创建时选择MVC
右击Controller添加控制器,选择MVC5控制器并命名
右击view()函数内部添加新视图
然后在添加的视图代码框内输入以下代码并运行即可
@{
ViewBag.Title = "Index";
}
<h2>Hello World!</h2>
注:MVC同样也可以像Web Forms发布网站
还有诸如WCF,WF,Cloud,Windows Store,Windows Phone等其他应用程序模板,但最常用的是本文介绍的前三种Console,WinForms,WPF
三.初识类与名称空间
1.类(class)与命名空间(namespace)
- 类(class)构成程序主体
- 命名空间(namespace)以树形结构组织类(和其它类型),可以有效地避免同名类的冲突
注:C#是完全面向对象的语言,因此程序本身也是一个类(class)
关于面向过程和面向对象:
面向过程:注重事情的每一个步骤以及顺序,比较高效
面向对象:注重事情有哪些参与者,参与者各自需要做什么事情,更易于维护拓展
using namespace即引用命名空间,类似于C中的引用头文件#include headfiles
以命名空间System内的Console类为例,若在开头using System,之后可以直接使用Console类,程序会自动在命名空间System中找到Console类,减少代码量,提高开发效率,否则在后续使用时只能写全限定名System.Console,不然程序无法识别
如果不知道某个系统类来自于哪个命名空间,可以打开help viewer在index中进行搜索查询(若有多个查询结果,一般写桌面程序的时候选择 framework)
使用关键字typeof(),再用数据类型Type声明一个变量并引用,可以获取到一个数据的数据类型,并可以使用Console.WriteLine()和FullName打印出其全限定名
例:Type myType = typeof(Form);
Console.WriteLine(myType.FullName);即可获取Form的全限定名
或者如果实在分不清help viewer中查询到的多个结果也可以使用VS的补全操作,将鼠标移至所要补全的对象,稍等一会儿或使用alt+enter快捷键即可弹出选项,选择using System即在程序头自动加上using语句,或选择System.Console自动补全全限定名
注:1、因为不同的命名空间可能包含同名的类,因此一次性引用太多命名空间可能会造成类冲突,此时只能使用全限定名,导致命名空间失去作用(命名空间的作用就是分隔同名类,没有命名空间的话为了使类名不冲突只能使得类名加上各种前缀,越来越复杂,因此在自己设计时应精确命名类并将其放入所属的命名空间)
2、在C#中父命名空间一般不自动导入子命名空间,这样是为了避免类型命名污染和冲突,同时减少编译器不必要的类型搜索,提高效率,这样显式引用还能明确依赖关系,提高可读性。例如System.Threading在逻辑上是System的子命名空间,但是在使用时,即使引用了System仍然需要显式引用System.Threading。
2.类库(Class library)
类库:用于存放命名空间和类
可以在help viewer中对某一对象的所属类库进行查询,在其所属程序集位置
类库的引用
类库的引用是使用命名空间的物理基础,可以在解决方案资源管理器中的引用查看到当前项目所引用的类库(注:引用只是拥有了能够using的条件,在程序中using namespace才算使用,才能够在程序中使用其空间内的类)
双击其中的类库可以打开对象浏览器,可以在此查看当前类库中的命名空间和类
一般创建项目的时候使用不同的模板系统会自动引用一些所需的不同的类库
若要手动引用类库,有以下两种方式
1.DLL引用(黑盒引用,无源代码)
dll:dynamic link library 动态链接库
配合对应的文档阅读使用,否则没有意义,若是系统类库则可查询MSDN文档
右击资源管理器的引用-添加引用-浏览-添加对应类库.dll文件即可(程序集选项是用于添加系统类库,浏览选项用于添加个人类库)
存在问题:没有源代码,如果类库中存在bug,则使用方无法修改只能由类库编写人员在源代码中修改并重新编译新的.dll文件发送给使用方进行引用解决bug
NuGet技术
NuGet技术用于解决复杂的依赖关系,即引用一个类库时自动引用更底层的前置类库,类似于MOD整合包,同样在引用中右击使用
2.项目引用(白盒引用,有源代码)
如果要建立自己的类库项目,右击解决方案-添加-新建项目-类库,进行类库编写,然后右击引用-添加引用-项目-解决方案即可引用
3.依赖关系(耦合关系)
优秀的程序追求“高内聚,低耦合”,高内聚:类要精确地放在自己所属的类库中,低耦合:类,类库之间的依赖关系要尽可能的弱。
可以画出UML图查看各个对象之间的依赖关系
3.类、命名空间、类库之间的关系
类似于书、书架、图书馆之间的关系
四.类、对象、类成员简介
1.类的概念
类(class)是现实世界事物的模型,是对现实世界事物进行抽象所得到的结果
·事物包括“物质”(实体)与“运动”(逻辑)
·建模是一个去伪存真,由表及里的过程
2.对象的概念
对象也叫实例,是类经过实例化之后得到的内存中的实体。通常情况下,对象与实例是一回事,常常可以混用,只是有时候在语境上有细微差别:对象侧重现实世界,实例侧重程序世界
所谓实例化,即依照类创建对象
注:有些类无法实例化比如数学
2.1.使用new操作符创建类的实例
以创建一个form类实例为例,输入以下代码即可在内存中创建一个form实例并显示在控制台
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace Testing
{
class Program
{
static void Main(string[] args)
{
new Form().ShowDialog();//创建一个form实例并显示在控制台
}
}
}
2.2.引用变量和实例之间的关系
创建一个实例之后需要将其内存地址传递给引用变量,否则无法对创建的实例进行操作
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Form myForm;//声明引用变量myForm,类型为Form类
myForm = new Form();//此处的myForm是引用参数变量,类似于C中指针
myForm.Text = "Hello";//对新建实例myForm进行操作
myForm.ShowDialog();
}
}
}
引用变量与实例之间的关系类似于孩子和气球的关系:
一个引用变量可以没有对应的实例----一个孩子没有牵着气球;
一个实例可以没有对应的引用变量----一个气球没有孩子牵着(此时该实例在被创建后一段时间会被内存垃圾收集器回收,类似于气球飘走了)
一个实例可以有多个对应的引用变量-----一个气球可以由多个孩子使用各自的绳子牵着(也有使用同一根绳子的情况,使用ref参数,后续会讲)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Form myForm1,myForm2;//声明引用变量myForm1,myForm2,类型为Form类
myForm1 = new Form();//此处的myForm是引用参数变量,类似于C中指针
myForm2 = myForm1;//将myForm2也引用myForm1所引用的实例
myForm1.Text = "Hello";
myForm2.Text = "I changed it";
myForm1.ShowDialog();//myForm1和myForm2引用的是同一份实例,指向同一地址空间,因此两者显示的都是I changed it
}
}
}
引用变量存的是内存地址,类似C中指针
3.类的三大成员
3.1.属性(property)
存储数据,组合起来表示类或对象当前的状态
3.2.方法(method)
由C语言中的函数(function)进化而来,表示类或对象能做什么
3.3.事件(event)
类或对象通知其他类或对象的机制,为C#独有(java通过其他方式实现这个机制)
善用事件机制非常重要,切勿滥用
一个系统类的功能简介、属性、方法和事件可以在对应MSDN文档中查询
一个类不一定每种成员都有,某些特殊的类或对象在成员侧重方面有所不同:
模型类或对象侧重属性,如Entity Framework
工具类或对象侧重方法,如Math, Console
通知类或对象侧重事件,如各种Timer
4.静态成员和实例成员
静态(static)成员在语义上表示他是“类的成员"
实例(非静态)成员在语义上表示他是"对象的成员"
可理解为静态成员不需要类实例化即可使用,而实例成员需要类实例化之后才能使用
例:Console.WriteLine()中WriteLine是静态成员,而Form myForm = new Form(); myForm.Text = ="Hello"; Form.ShowDialog();中Text和ShowDialog都是实例成员
注:在MSDN文档中带大S图标的就是静态成员,如静态属性
绑定(binding) 指的是编译器如何将一个成员与类或对象关联起来
访问类或对象的成员时用.操作符即成员访问操作符
五.C#语言基本元素概览,初识类型、变量与方法,算法简介
1.构成C#语言的基本元素
注:关键字,操作符,标识符,标点符号和文本统称为标记(Token),即对编译器有意义的记号
1.1.关键字(Keyword)
关键字就是构成一门编程语言的基本词汇
MSDN文档中可以查询到C#所有的关键字,查询方法:MSDN-目录-Visual Basic和Visual C#-Visual C#-C#参考-C#关键字
下面两张表包含C#所有的关键字
上表的关键字在任何时候都是关键字,而下表的关键字称为上下文关键字,只有在特定的语境下才算关键字,其他情况下不是关键字
关于C#中五个权限修饰符以及其权限
修饰符 | 级别 | 适用成员 | 解释 |
public | 公开 | 类及类成员 | 对访问成员没有级别限制 |
private | 私有 | 类成员 | 只能在类的内部访问 |
protect | 受保护的 | 类成员 | 在类的内部或者在派生类中访问,不管该类和派生类是否在同一程序集中 |
internal | 内部的 | 类及类成员 | 只能在同一程序集中访问 |
protected internal | 受保护的内部 | 类及类成员 | 如果是继承关系,不管是不是在同一程序集中都能访问,如果不是继承关系只能在同一程序集中访问 |
如果在声明时未指定访问修饰符,则使用默认的访问修饰符,类的默认访问权限是internal,类成员的默认访问权限是private
注:关于程序集,一个程序集就是一个项目(project)编译后的结果,例如:在一个解决方案在有两个项目A,B,其中A中有个class为internal级别的,那么B引用了A的程序集也不能调用这个类。常见的程序集就两种,分别是.exe和.dll
static是状态修饰符,表明当前属性是所属类的静态成员
1.2.操作符(Operator)
操作符也叫做运算符,是用来表达运算思想的符号
MSDN文档中可以查询到C#所有的操作符,查询方法:MSDN-目录-Visual Basic和Visual C#-Visual C#-C#参考-C#操作符
以下为C#包含的所有操作符(注:有些操作符和关键字是重名的)
1.3.标识符(Identifier)
标识符就是命名时给变量,类或类的成员等取的名字
1.3.1.标识符的合法性
能够通过编译器编译的就是合法的标识符,反之不合法
要成为合法的标识符需要满足以下条件:
1.不能是关键字,如果非要拿关键字做标识符,需要在关键字前面加上@符号
2.必须以字符或者下划线开头(字符包括大小写字母和汉字)
3.开始字符后面可以跟数字,字符,下划线等等
1.3.2.标识符的命名规范
命名一定要有意义并且可读性高,如类的名字一定要是名词,属性一定要是名词,方法一定要是动词或动词短语等等
1.3.3.标识符的大小写规范
一般有驼峰命名法和Pascal命名法
1.驼峰命名法(一般用于变量名):总名称第一个字母小写,后面每个单词的首字母大写
2.Pascal命名法(一般用于方法、类、命名空间等):每个单词的首字母都大写
4.标点符号
;和{}等不参与运算的符号
5.文本(以下只列举了一小部分)
1.5.1.整数
整型int(32位)、长整型long(64位)
赋值例:int x = 4; long y = 100L;(长整型一般需要在数值后面加上大写L)
1.5.2.实数
单精度浮点型float(32位)、双精度浮点型double(64位)
赋值例:float x = 3.0F; double y = 4.0L;(单精度浮点型和双精度浮点型一般需要在数值后面分别加上F和D)
1.5.3.字符
字符型char,赋值时要在字符上加上单引号''
赋值例:char x = 'a';
1.5.4.字符串
字符串型string,赋值时要在字符串上加上双引号""
赋值例:string x ="hello";
1.5.5.布尔值
布尔型bool,赋值数值只有true和false两种
1.5.6.空值(null)
无任何值,为空
6.注释与空白
单行注释://,块注释:/*代码段*/
C#中当有多个空白时,只会自动保留一个空白如 int a = 1;和int a = 1;是一样的(若程序代码格式有很多乱的地方,可以点击编辑-高级-设置文档格式进行快速格式化)
2.初识类型、变量和方法
2.1.数据类型
有整型char,长整型long,浮点型float,双精度浮点型double,字符型char等等各种数据类型,一般在声明变量的时候需要主动表明其数据类型,称为显式类型变量,或者使用关键字var来让系统在编译时自动推断并分配当前数值的数据类型(一旦分配不可更改),称为隐式类型变量(注:dynamic在运行时确定数据类型)
例:int a = 3;或 var a = 3(此时系统会自动将a分配为int类型)
2.2.变量
变量是存放数据的地方,简称数据
变量的声明和使用
声明时,用变量的数据类型加上变量名即可,后续使用直接使用变量名即可
2.3.方法
由C语言中的函数进化而来,是处理数据的逻辑,又称算法,是数据的”加工厂“
using System;
namespace Testing
{
class Program
{
static void Main(string[] args)
{
int a = 1;
int b = 2;
Console.WriteLine(Calculator.AddTwo(1, 2));//调用方法AddTwo完成两数相加并打印结果
}
class Calculator//定义Calculator类
{
public static int AddTwo(int a,int b)//public修饰符使得方法AddTwo在Calculator类外也可以被调用,static修饰符表示方法AddTwo是静态成员
{
return a + b;//返回两数相加结果
}
}
}
}
3.算法简介
C#中的循环、递归和分支结构基本上与C中一样,但要注意的是C#中的for循环的初始化条件要求很严格,初始化条件部分只能为声明变量并复制语句,或者是在循环外声明变量在初始化条件部分重新赋值或者为空,否则会报错。而C中可以只为一个变量名不会报错,但是也不推荐这样做。
六.详解类型、变量与对象
1.类型(Type)
1.1.概念
又称数据类型(Data Type),是性质相同的一些值的集合,并进行有效的表达,同时配备了一系列专门针对这种类型的值的操作,即数据类型=值+操作。
数据类型也表示了数据在内存中存储时的型号,小内存容纳大尺寸数据会丢失精度,发生错误等,而大内存容纳小尺寸数据会导致浪费。
强类型语言与弱类型语言的比较
即数据受数据类型约束的强度,如C#是强类型语言,禁止不同类型的数据给变量赋值(但可以进行隐式类型转换),bool 变量只能为true或false两个值,而C相比之下就弱一些,比如可以bool a = 1,此时C将a看为true,而JavaScript就是彻底的弱类型语言,完全不受数据类型约束,如var a = 100; a = ”hello“;不会报错,(注:这里的var与C#的var不是同一种意思,C#中的var会给变量名分配一个所属数据类型),在C#中使用dynamic模仿JavaScript的弱类型。
1.2.作用
一个C#类型中所包含的信息有:
- 存储此类型变量所需的内存空间大小
- 此类型的值可表示的最大最小值
- 此类型所包含的成员(如方法,属性,事件等)
- 此类型由何基类派生而来
- 此类型所允许的操作(运算)
- 程序运行的时候,此类型的变量被分配在内存的什么位置
栈(Stack)和堆(Heap):都是内存中的一片区域,不同类型的变量会分别被选择存放在栈还是堆中,调用的方法存放在栈中,空间较小,会栈溢出(Stack Overflow),对象存放在堆中,空间很大, 会内存泄露,即分配的空间没有回收(C中需要手动释放使用的内存,内存泄露就真的泄露了,但C#中有垃圾收集器,会在合适的时机收集没有使用的内存,无需手动释放,基本上不会出现内存泄露,这是托管语言的特性)
tip:C#中不推荐使用指针,如果非要使用需要加上unsafe关键字
2.C#语言的类型系统
2.1.C#的五大数据类型
查询一个目标的数据类型的方法:
1.去MSDN文档搜索可以查询到当前目标属于哪个数据类型,例如查询到Console是属于类的:
2.使用Type类,再使用关键字typeof()就可以使用Console.WriteLine()和FullName打印出其全限定名
例:Type myType = typeof(Form);
Console.WriteLine(myType.FullName);即可获取Form的全限定名
再利用全限定名去MSDN文档中查询其数据类型,或者直接使用语句 Console.WriteLine(myType.IsClass);判断其是否是类
关于Type类
3.直接转到要查询目标的定义代码处查看
类(Classes):如Window, Form,Console,String
结构体(Structures):如Int32(等效Int),Int64(等效Long),Single,Double
枚举(Enumerations):如HorizontalAlignment,Visibility
接口(Interfaces)
委托(Delegates)
2.2.C#类型的派生谱系
水蓝色表示这些关键字代码定义就是一个数据类型,由于太常用,C#已经将他们吸收为自己的关键字,并且他们是最基础的数据类型,其他的数据类型都由他们组成。
3.变量、对象与内存
3.1.变量
从表面上看,变量的用途是存储数据,实际上,变量表明了存储位置,并且决定了什么样的值能够存入该变量(以变量名所对应的内存地址为起点,以其数据类型所要求的存储空间为长度的一块内存区域)。
变量一共有七种:
静态变量(静态字段)使用static修饰,
实例变量(成员变量,字段)不使用static修饰,
数组元素,数组使用数据类型+[]+名称声明,如int[] array = new int[100],
值参数变量,
引用参数变量在参数类型前面用ref修饰,
输出参数变量在参数类型前面用out修饰,
局部变量(本地变量)
狭义的变量指局部变量,因为其他种类的变量都有自己约定俗称的名称,一般不喊变量
简单地说,局部变量就是方法体(函数体)中声明的变量
注:引用类型不加修饰声明的变量是引用参数变量,因为引用类型本身代表的就是地址,值类型不加修饰声明的变量是值参数变量
变量的声明:(有效的修饰符组合+)类型+变量名(+初始化器),括号表示可有可无
3.2.引用类型与值类型的区别
引用类型变量内存的是引用类型实例在堆上的内存地址,值类型没有实例,所谓的“实例”与变量合二为一,因此,在初始化引用类型变量的时候需要new而值类型不需要。
3.3.内存分配与其他一些概念
局部变量会被分配在栈上,实例变量会随着实例分配到堆上
变量的默认值:成员变量在实例创建后若不显式地赋值,系统会给每个bit置0
注:C#中局部变量系统不会给予默认值,因此声明时必须显式给出值(C中会给局部变量默认值)
常量:即值不可更改的变量,使用关键字const修饰,在声明时必须显式给出值,不能省略初始化器,否则报错,赋初值后便无法更改了。
装箱与拆箱(Boxing & Unboxing):装箱就是将栈上的值类型的值封装成object类型的实例复制到堆上,拆箱就是将堆上object类型的实例里的值按照要求拆成目标数据类型复制回栈上,这两种操作会损失性能。(下面代码就是装箱与拆箱)
七.方法的定义,调用与调试
1.方法的由来
方法 (method)的前身是C/C++语言的函数(function),又称成员函数
方法是面向对象范畴的概念,在非面向对象语言中仍称为函数,函数在成为类或结构体的成员之后就叫做方法,而方法永远都是类或者结构体的成员,不可能独立于类或结构体之外存在(在C++中可以,称为全局函数)
方法是类或结构体最基本的成员之一,最基本的成员只有两个:字段和方法(成员变量与成员函数),本质还是数据+算法,其中方法表示类或结构体能做什么事情。C#中方法的声明顺序绝大部分情况下不会影响可调用行,即绝大部分情况下可以前向调用
同一个类中成员互相调用不需要指明来自哪个类,不同类的成员互相调用需要指明所属类
使用方法和函数的目的:1,隐藏复杂逻辑,2,将大算法分解为小算法,3,复用
2.方法的声明与调用
C#中方法的声明与定义不分家,声明时有三个必要条件:返回值类型,函数名,圆括号
定义时圆括号内的参数称为形式参数 (parameter),形式参数需要写上数据类型和名称
调用方法时直接写函数名,圆括号加上参数即可,此时的参数是实际参数(Argument),实际参数只需要写上数据名称或者值即可
C#是强类型语言,因此实际参数的类型一定要与形式参数匹配,不然会报错
静态方法使用static修饰,属于类的成员,实例方法属于实例的成员,两者使用时的差别在于是否需要实例化,静态方法不需要实例化即可使用,而实例方法需要实例化才可使用
方法体内定义的变量和常量称为局部变量和局部常量,作用域限制在方法体内
自己定义方法时尽量符合单一职责原则:即一个方法尽量只做一件事情,从而方便程序的修改和维护
3.构造器(Constructor)
即构造函数,是类型的成员之一,属于特殊的函数(没有返回值)
狭义的构造器指的是实例构造器,用于构造实例的内部结构(静态构造器只需将实例构造器的public改为static即可,用于初始化静态成员,静态构造器在类首次被使用时自动调用且只会被调用一次)
默认构造器
当声明一个类的时候,若没有显式为其准备一个构造器,编译器会给其准备一个默认构造器,
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Student stu = new Student();//此处student()就是调用了默认构造器
Console.WriteLine(stu.ID);
Console.WriteLine(stu.Name);
}
class Student
{//不自己写构造器,程序会自动默认生成一个构造器,其中值类型默认值为0(全部bit默认值为0),引用类型默认值为NULL
public int ID;
public string Name;
}
}
}
显式准备构造器有两种:无参数和有参数的,且一般构造实例实在别的类内部构造,因此要加上public修饰符,且构造器是特殊的函数,没有返回值,连void都不用写,命名时需要与构造器所属的类名完全一致,构造器的声明与调用和函数一样。
显式准备不带参数的构造器
关键字this表示当前实例本身
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Student stu = new Student();//此处student()即调用了准备好的无参数的构造器
Console.WriteLine(stu.ID);
Console.WriteLine(stu.Name);
}
class Student
{ public Student()
{
this.ID = 1;
this.Name = "No name";//手动赋初值,this表示当前实例
}
public int ID;
public string Name;
}
}
}
显式准备带参数的构造器
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Student stu = new Student(2,"hello");//此处的Student(2,"hello")即调用了准备好的带参数构造器
Console.WriteLine(stu.ID);
Console.WriteLine(stu.Name);
}
class Student
{ public Student(int initID, string initName)
{
this.ID = initID;
this.Name = initName;//this表示当前实例,将值赋为给定参数值
}
public int ID;
public string Name;
}
}
}
一个类内部可以自行准备多个构造器,并且可以同时启用,但当有自行准备的构造器时,默认构造器失效
tip:在类内部输入ctor按下回车可以快速建立一个构造器框架
4.方法的重载(Overload)
当为一个类创建多个方法的时候,方法的名称可以完全一样但是方法的签名不能一样,这就称为方法的重载
方法签名:由方法的名称,类型形参的个数和他的每一个形参(按从左至右的顺序)的类型(如int, double等)以及种类(值不加、引用加ref或输出加out)组成。方法签名不包括返回类型
实例构造器也可以重载,实例构造函数的签名由他的每一个形参(按从左至右的顺序)的类型(如int, double等)以及种类(值不加、引用加ref或输出加out)组成。
重载决策:即到底调用哪一个重载,用于在给定了参数列表和一组候选函数成员的情况下,选择一个最佳函数成员来实施调用
5.如何对方法进行debug
- 设置断点
- 观察方法调用时的call stack(调用堆栈)
- step-in步入, step-over跨步, step-out步出
- 观察局部变量的值与变化
注:方法调用层级过深会导致栈溢出
八.操作符详解
1.操作符概览
操作符(operator)也叫运算符,是用来操作数据的,被操作符操作的数据称为操作数(operand)
这张表格为C#的所有操作符,运算优先级从上往下依次降低,同一行的优先级相同,注意赋值运算符的运算顺序是从右往左
1.1.基本操作符
- 成员访问操作符.:访问外层名称空间的子集名称空间;访问名称空间中的类型 ;访问类的静态成员;访问对象的成员
- 方法调用操作符f(x):用于调用方法,调用方法时()不能省略
- 元素访问操作符[]:用于访问数组和字典等内的元素,创建数组例:int[] myArray = new int[5]{1,2,3,4,5};int[] myArray = {1,2,3,4,5};数组属于引用类型,后面的花括号为初始化器,且不像C,C#数组中的个数必须与初始化赋的个数完全一样,访问时直接int[0]即可,下标从0开始。[]不一定是整数,如在访问字典成员的时候放入的是集合索引
- 后置自增和自减x++,x--:相当于x=x+1,x=x-1,并且是先赋值后运算,表达式的返回值为运算前的x值
- new操作符:在内存中创建一个类型的实例并立即调用实例构造器,而且能得到该实例的内存地址并通过赋值符号将地址交给负责访问该实例的引用变量;还能调用该实例的初始化器设置该实例的属性值,使用方法是在调用实例构造器后用一对花括号即可调用初始化器(可同时初始化多个属性),除此之外new也是一个修饰符,可用于修饰子类,对父类的方法进行隐藏,但不常用
- 注:new操作符不可滥用,会造成紧密耦合,为解决此问题可以在设计模式中使用依赖注入将紧密耦合变为比较松的耦合(了解即可)
为匿名类型创建对象:
非匿名类型就是显式声明类型名的类型如Form类等,匿名类型就是没有明确告诉类型名的类型,为其创建对象的语法是:var person = {Name = "Mr.Okay", Age = 20};即可创建对象并用隐式类型变量来引用实例,其类型名字为 < >_ AnonymousType0'2,< >_ AnonymousType是约定的匿名类型前缀,0表示在程序中创建的第一个匿名类型,2表示泛型类,构成该类型的时候需要两个类型,
C#的语法糖衣:对于最基础的数据类型,本来需要使用new的可以省略掉new,像值类型一样直接创建实例,如string = "hello";int[] myArray = {1,2,3,4,5};等,达到统一操作、简化代码的目的,称为语法糖衣
- typeof操作符:用于查看一个类型的内部结构,与Type类搭配使用
- default操作符:帮助获取一个类型的默认值,以结构体类型和引用类型为例,会返回每位全0的值,注意枚举类型的默认值返回的是第一个数值(均未赋值)或者显式赋值为0的对象 ,若没有显式赋0的值,,返回的值为0,不属于枚举的任意值。
- checked和unchecked操作符:checked用于检查一个值在内存中是否有溢出,若有则抛出一个异常,unchecked用于告诉程序不需要去检查是否溢出,不显式写出时默认为unchecked,此时如果发生溢出,不会抛出异常而是会自动对结果进行截断,checked和unchecked也可用于上下文语句块,被包含的语句块都会被检查或不检查。检查时一般使用catch语句抓住异常
- delegate操作符(已过时):一般更广泛的用途不是用作操作符而是作为关键字来声明委托。而用于操作符的作用:声明匿名方法已经被Lambda表达式取代
- sizeof操作符:用于获取一个对象在内存中所占字节数。默认情况下只能用于获取基本结构体数据类型的实例在内存中所占字节数,非默认情况下,可以使用sizeof操作符获取自定义的结构体类型的实例在内存中所占字节数,但是需要将其放在不安全unsafe的上下文中并启用项目的不安全代码选项。
- ->操作符:用结构体指针访问结构体的成员,且该操作符只能用于访问结构体类型,还要放在不安全的上下文中才能使用(用.操作符属于直接访问,用->操作符属于间接访问)
1.2.一元/单目操作符
- &和*操作符:&为取地址符,*为解引用符,C#中与指针相关的操作符都只能在unsafe上下文中使用并启用项目的不安全代码选项,使用条件很有限。
引用类型变量与指针区别:
引用变量是安全的,并且由垃圾回收器自动管理内存,不能操作变量存储的地址
指针是不安全的,需要手动释放内存,能直接操作地址
一般在C#中优先使用引用变量
- +,-操作符:正负操作符,用于的指定的值取正或反,但是连续使用时需要用()分隔开,不然会变成前置自增或自减操作符。使用正负操作符可能会导致溢出,因为一个数据类型能存下的最大正数与负数的绝对值一般不相等。
- ~操作符:取反操作符,对一个值在二进制级别上进行按位取反
- !操作符:非操作符,只能用于操作bool类型值
- 前置自增和自减++x,--x:相当于x=x+1,x=x-1,并且是先运算后赋值,表达式的返回值为运算后的x值
注:自增自减运算符尽量单独使用,不然代码可读性很差
- (T)x操作符:强制类型转换操作符
关于类型转换
隐式类型转换(implicit):不需要明确表明要将一个值的数据类型转换为另一种数据类型
- 不丢失精度的转换
下表显示C#内置数值类型之间的预定义隐式类型转换:
- 子类向父类的转换
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Teacher t = new Teacher();
Human h = t;
Animal a = h;//此时,发生了t到h,h到a的隐式类型转换
}
class Animal
{
public void Eat()
{
Console.WriteLine("Eating...");
}
}
class Human:Animal//表示Human派生自Animal,此时Human具有Animal所有的成员
{
public void Think()
{
Console.WriteLine("I am thinking");
}
}
class Teacher:Human//表示Teacher派生自Human,此时Teacher具有Human所有的成员(包括Animal的成员)
{
public void Teach()
{
Console.WriteLine("I am teaching programming");
}
}
}
}
注:当使用一个引用变量去访问它所引用的实例所具有的成员的时候,只能访问到这个变量的类型所具有的成员,如上述代码所示,在引用变量h中只能访问到Human类型所具有的成员(当然包括Animal类型的成员),而无法访问到子类型Teacher类型特有的成员如方法Teach()
- 装箱
显式类型转换(explicit):明确表明要将一个值的数据类型转换为另一种数据类型
- 有可能丢失精度(甚至发生错误的转换),即cast,当两个数据类型差距不大时,使用强制类型转换操作符(T)x将大的数据塞到小空间内
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine(ushort.MaxValue);
uint x = 65536;
ushort y = (ushort)x;//将x强制转换为ushort类型导致位数丢失
Console.WriteLine(y);
}
}
}
下表显示C#不存在隐式转换的内置数值类型之间的预定义显式转换:
- 拆箱
- 使用Convert类的各种静态方法
使用Convert类的各种静态方法可以进行大部分数据类型的转换(有的会精度丢失),当两个数据类型差距过大时也可以如String(此处必须是纯整数字符串不能是纯文本字符串)转为Int,若转为double或float类型时,字符串可以是科学计数法字符串,因为科学计数法通常用于浮点数
namespace Testing { class Program { static void Main(string[] args) { int a = Convert.ToInt32("123");//将整数字符串123转为整数123 Console.WriteLine(a); double b = Convert.ToDouble("1e3");//将科学计数法字符串1e3转为浮点数1000 Console.WriteLine(b); } } }
- ToString方法与各数据类型的Parse/TryParse方法
将数值类型转为字符串类型可以调用Conver类的静态方法ToString或者调用数值类型数据的实例方法ToString
namespace Testing { class Program { static void Main(string[] args) { int a = 1234; int b = 12345; string astr = Convert.ToString(a);//Convert类的静态方法 string bstr = b.ToString();//数值类型的实例方法 Console.WriteLine(a); Console.WriteLine(b); } } }
大部分目标数据类型的Parse方法对字符串进行解析(有一些数据类型没有此方法如string类型)也可以实现字符串类型到数值类型的转换,但字符串类型只能为整数字符串,若目标数据类型为浮点型则可以为科学计数法字符串,TryParse对程序更友好
namespace Testing { class Program { static void Main(string[] args) { string a = "1234"; int aint = int.Parse(a);//利用目标数据类型int的Parse方法实现字符串到数值的转换 Console.WriteLine(a); string b = "1e2"; double bdou = double.Parse(b);//利用目标数据类型double的Parse方法实现科学计数法字符串到数值的转换 Console.WriteLine(b); } } }
自定义类型转换操作符
格式一般为public static implicit/explicit operator 目标类型名(源类型名 源类型对象名) ,且它的返回类型就是目标类型,而且必须有返回值,返回值为目标类型的对象
格式中的目标类型名表示该转换操作符的名字,括号内的内容表示他操作的对象
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Stone HolyStone = new Stone();
HolyStone.Age = 5000;
Monkey Wukong = (Monkey)HolyStone;
Console.WriteLine(Wukong.Age);
}
class Stone
{
public static explicit operator Monkey(Stone stone)//自定义显式类型转换操作符,由Stone类转换到Monkey类
{
Monkey moneky = new Monkey();
moneky.Age = stone.Age / 100;
return moneky;//返回目标类型的对象
}
public int Age;
}
class Monkey
{
public int Age;
}
}
}
上述代码创建的是显示类型转换操作符,若要创建隐式类型转换操作符,将explicit修饰符改为implicit即可
1.3.算术操作符
+,-,*,/,%操作符:加,减,乘,除,取余操作符
在使用时要注意:每一个算术操作符都是与他的数据类型相关的(运算符两边若都是整数则进行整数运算,若都是浮点数则进行浮点运算,若都是金融小数decimal类型则进行金融小数运算),以及数值提升即数据类型提升(不同类型数值运算的时候会将运算结果自动提升为精度更高的那一个)
- 加法操作符也可以用于连接两个字符串或者操作委托
- 浮点数类型除法可以被除数为0,其余类型则不可以,会报错
- 浮点类型double和float有两种特殊数值:NaN(即不是数)和无穷大(+∞和-∞),若要拿到这无穷大可以使用double a = double.PositiveInfinity(正无穷大)或者double.NegativeIndinity(负无穷大)
因此对于浮点类型的数值运算操作有些特殊情况,以下列举出了浮点数的各种运算的结果列表:
乘法操作:
除法操作:
取余数操作:
加法操作:减法操作:
1.4.位移操作符
<<, >>操作符:左移,右移操作符,用于操作数值二进制代码的左移右移
左移相当于乘以2,并且补位无论正负均补0,右移相当于除以2,正数补0,负数补1
移位时要注意溢出和精度丢失问题
左移位(Left Shift):如果是 8 位有符号数,其表示范围是 - 128 到 127。当向左移位时,如果原始数值的符号位(最高位)在移位后变为0,并且这个0不是由原始数值的符号位移动过来的,那么就会溢出。例如,8位整数的最大正值是01111111(十进制127),如果左移一位变成11111110,这实际上是一个负数,因为最高位是1,这在二进制补码表示中是负数的符号位。
右移位(Right Shift):在算术右移中,通常不会发生溢出,因为右移位会保留符号位。但是,如果右移导致数值超出了数据类型的表示范围,那么也会发生溢出。例如,8位整数的最小负值是10000000(十进制-128),如果右移一位变成11000000,这超出了8位整数的表示范围
1.5.关系和类型操作符
<, >, <=, >=, ==, !=操作符:关系操作符,可以用来判断数值类型之间的关系、字符类型之间的关系或者字符串类型之间的关系,他们的运算结果都是布尔类型
数值类型和字符类型都可以比较大小或是否相等,比较字符类型的关系时,是自动转为比较他们对应的Unicode码值,而字符串类型只能使用==或!=比较 (或使用String.Compare()方法进行比较)
不管是C还是C#,比较两个不同类型的数值大小时,编译器会自动进行隐式转换,因此直接进行比较即可,不需要强制类型转换
using System;
namespace Testapplication
{
class Test
{
static void Main(string[] args)
{
int x = 5;
double y = 2.1;
var result = x > y;//比较时虽然数值类型不同,但编译器会自动进行隐式转换
Console.WriteLine(result.GetType().FullName);//关系比较的结果类型是布尔类型
Console.WriteLine(result);
char char1 = 'a';
char char2 = 'A';
result = char1 > char2; //字符类型也能比较大小和是否相等,编译器会自动将字符转换为对应Unicode编码进行比较
Console.WriteLine(result);
ushort u1 = (ushort)char1;
ushort u2 = (ushort)char2;
Console.WriteLine(u1);
Console.WriteLine(u2);//将a与A的Unicode编码打印出来可以发现a为97,A为65,因此比较结果为True
string str1 = "abc";
string str2 = "Abc";
result = str1 != str2;//关系比较操作符只有==和!=能用于字符串类型比较,编译器会将字符串对齐挨个字符比较Unicode码值
Console.WriteLine(result);
int NewResult = String.Compare(str1,str2);//也可以使用String.Compare()方法比较字符串的大小关系,此时能比较大小关系,返回值为整数
Console.WriteLine(NewResult);//若返回值为0,则相等,为正值,前者大于后者,为负值,前者小于后者
}
}
}
is, as操作符:类型检验操作符
is操作符用于检验某一变量是否属于该类型,运算结果类型也是布尔类型(注:引用变量检验的是其所指向的实例类型而非变量本身的类型),若该变量的类型派生自某一类型,则用is判断其是否是该父类型检验的结果也为True,反过来则不行。
所有的类型都默认由object类派生而来
as操作符的运算结果为null或者当前同一对象的引用,可以用于判断是否能进行强制类型转换或者检查当前对象的类型是否与目标类型兼容
using System;
namespace Testing
{
class Program
{
static void Main(string[] args)
{
Teacher t = new Teacher();
var result = t is Teacher;
Console.WriteLine(result.GetType().FullName);//is操作符的结果为布尔类型
Console.WriteLine(result);//因为引用变量t指向的实例是teacher类,因此结果为True
object o = new Teacher();//创建object类型引用变量o,创建teacher类的实例并用o引用,此时该实例发生隐式类型转换退化,只能调用object类的成员(object是所有类型默认的父类型)
/*if(o is Teacher){
Teacher tea = (Teacher)o;//强制将o转换为teacher类型引用变量
tea.Teach();//从而可以调用teacher类成员
}*/
Teacher tea = o as Teacher;
if(tea != null)
{
tea.Teach();
}//除去上述注释掉的代码段外,也可以使用as操作符对变量进行类型转换,若对象与给定的类型兼容则返回同一个对象的非null引用,否则返回null
}
class Animal
{
public void Eat()
{
Console.WriteLine("Eating...");
}
}
class Human:Animal
{
public void Think()
{
Console.WriteLine("I am thinking");
}
}
class Teacher:Human
{
public void Teach()
{
Console.WriteLine("I am teaching programming");
}
}
}
}
1.6.逻辑操作符
&,^,|操作符:位与,位异或,位或操作符,用于对二进制数据进行每一位的与,或,异或操作
&&,||:条件与,条件或操作符,用于操作布尔类型的数据,并且返回值也是布尔类型(使用时要注意避免短路效应)
短路效应:
当逻辑或(&&)的第一个条件为false时,就不会再去判断第二个条件;
当逻辑与(||)的第一个条件为true时,就不会再去判断第二个条件。
1.7.NULL值合并操作符
可空整数类型如nullable<int> x,C#中可简写为int? x =100;其他类型的可空类型同理
int y = x ?? 1;表示x是否为null,若是,则将其修改为1;
namespace Testing
{
class Program
{
static void Main(string[] args)
{
//Nullable<int> x = null;//定义可空类型整型变量x
int? x = null;//上述定义的C#简写语法
x = 100;
Console.WriteLine(x);
Console.WriteLine(x.HasValue);//显示True表示x当前有值
x = null;
Console.WriteLine(x.HasValue);//显示False表示x当前无值
int y = x ?? 1;//表示x是否为null,若是,则将其修改为1;
Console.WriteLine(y);
}
}
}
1.8.条件操作符
?:操作符:条件操作符,相当于if else分支的简写 ,:两边的数据类型必须要能够进行隐式数据类型转化
namespace Testing
{
class Program
{
static void Main(string[] args)
{
int x = 80;
/*string str = string.Empty;
if (x >= 60)
{
str = "Pass";
}
else
{
str = "False";
}*/
string str = x >= 60 ? "Pass" : "False";//上述注释代码段的简写,意为x是否大于等于60,若是则str赋为;左边值,反之为右边值
Console.WriteLine(str);
}
}
}
1.9.赋值和Lambda操作符
=,*=,/=,%=,+=,-=,<<=,>>=,&=,^=,|=操作符:赋值操作符(注意赋值操作符的运算顺序是从右向左)
以+=为例,int x+=1;相当于int x = x + 1;,其余操作符类似
=>操作符:Lambda操作符
2.操作符的本质
操作符的本质是函数(即算法)的简记法
操作符不能脱离与他关联的数据类型,可以说操作符就是与固定数据类型相关联(比如+号如果左右两边都是String类型,则进行的是对字符串的拼接操作而不是int类型的数值相加,/号如果左右两边都是float类型,则进行的是对float类型的除法操作而非int类型的除法)的一套基本算法的简记法(可以在创建方法的时候用operator关键字给方法定义一个符号来指代方法)
自定义操作符的格式:public static 返回类型 operator 自定义符号(参数,参数....)
{
方法体,表示该自定义操作符可以对所给参数对象做什么
}
注:创建强制类型转换操作符的格式稍有不同,详见上自定义类型转换操作符部分
3.优先级与运算顺序
- 可以使用圆括号提高被括起来表达式的优先级,圆括号可以嵌套
- 除了带有赋值功能的操作符,同优先级的操作符都是从左向右进行运算,带有赋值功能的操作符的运算顺序是从右向左
九.表达式和语句
1.表达式
任何一门语言的基本组件都包括表达式,命令和声明,其中表达式是核心组件,是一种专门用来求值的语法实体。各种语言对表达式的实现不尽相同,但大体上都符合这个定义
1.1.C#对表达式的定义
- 表达式是一个由一个或多个操作对象(包括字面值,方法调用或简单名字(包括变量名,类型成员名,方法参数名,命名空间名或类型名)等等)和零个或多个操作符组成的用于进行求值的序列,C#的表达式的返回值类型可能是single value,object,method或者namespace
1.由各种操作对象组成表达式的例子(以下例子表达式指赋值操作符右边整体即表达式):
字面值:
int x; string y; x = 100; y = "hello";//等号右面的100,hello均为字面值
方法调用:
double x = Math.Pow(2,3);//Math.Pow()即方法调用
简单名字:
int x =100; int y; y = x;//此处即变量名x构成一个表达式 Type myType = typeof(Int32);//此处的typeof(Int32)内Int32即类型名
2.以下列出各种操作符操作对象组成表达式的返回值类型:
- .成员访问操作符:不确定,由访问的成员类型决定
- f(x)方法调用操作符:不确定,由方法的返回值类型决定
- a[x]元素访问操作符:不确定,由集合的元素类型决定
- 前置,后置++和--操作符:与操作数数据类型相同
- new操作符:不确定,由创建的实例类型决定
- typeof操作符:Type类型
- default默认值操作符:操作对象的数据类型
- checked和unchecked操作符:与操作数类型相同
- delegate,sizeof和->操作符的返回值类型意义不大,不需要了解
- +,-正负操作符:与操作数类型相同
- !取非操作符:布尔类型
- ~按位取反操作符:与操作数类型相同
- T(x)强制类型转换操作符:与目标数据类型相同
- await操作符暂时跳过
- +,-,*,/,%算术运算符:不发生数据提升的情况下,返回值由操作数类型相同,发生数据提升时,与精度最高的操作数类型相同
- <<,>>移位操作符:与操作符左边的操作数类型相同
- <, >, <=, >=, ==, !=,is操作符:布尔类型
- as操作符:若as成功,则与as右边数据类型相同,若失败则为null
- &,^,|按位与,或,异或操作符:与操作数类型相同
- &&,||条件与或操作符:布尔类型
- null合并操作符:由null合并操作符??左边的操作数的数据类型的类型参数决定,若右边数值的精度更高,发生了数据提升,则与右边数值的类型相同(即根据??左右两边精度更高的数据类型决定)
以Nullable<int> x = null; var y = x ?? 100;为例,??左边的操作数的数据类型的类型参数即Nullable<int>x中的int
- ?:条件操作符:由:两边数据类型精度更高者决定
- =,*=,/=,%=,+=,-=,<<=,>>=,&=,^=,|=赋值操作符:与赋值操作符左边的数据类型相同
如:int x = 100; int y; y = x;表示了将x的值赋给y,且整体表达式y = x;也有返回值就是赋值操作符左边变量最终的值
- Lambda操作符:较复杂,暂不研究
注:表达式返回值(即表达式的值)的类型代表这个表达式的类型,要与操作数的值区分开,如var x = 3+5;返回值为int 8,var x = 3<5;返回值为bool true,int x = 100; x++的返回值为100,++x的返回值为101,二者区别在于先用x返回表达式值再对x进行运算还是先对x进行运算后用x返回表达式值
- 算法逻辑的最基本(最小)单元,表达一定的算法意图
- 因为操作符有优先级,所以表达式也有优先级
1.2.C#中表达式的分类
- 任何能够得到值的运算
- 一个变量
- 一个命名空间
- 一个类型
- 方法组,如Console.WriteLine不加方法调用(),返回的是一组方法,重载决策决定具体调用哪一个
- null值
- 匿名方法
- 属性访问
- 事件访问
- 索引访问
- Nothing,即对返回值为void的方法的调用
2.语句
2.1.语句定义
广义定义:
- 语句是命令式编程语言(一般是高级语言)中最小的独立元素,也是一种用于表达即将执行的一些动作的语法实体,编程即用一系列语句来编写程序,语句拥有自己的内部组件:表达式
- 语句是高级语言的语法,低级语言如汇编语言和机器语言只有指令(高级语言中的表达式对应低级语言的指令),不严格的讲,高级语言的程序由语句组成,低级语言的程序由指令组成。语句等价于一个或一组有明显逻辑相关的指令
C#定义:
- 一个程序所要执行的动作就是以语句的形式展现的,语句通常有以下功能:声明变量,对变量赋值,调用函数,在集合在进行循环(迭代语句),根据给定的条件在分支直接跳转(判断语句),程序中语句的执行顺序称为控制流或者执行流,语句的顺序在程序编写好的时候就固定了,但程序的控制流在运行时是可以变化的
- C#语言的语句除了能够让程序员顺序地表达算法思想,还能通过条件判断、跳转和循环等方法控制程序逻辑的走向
- 简言之语句的功能就是陈述算法思想,控制逻辑走向,完成有意义的动作
- C#的语句大部分由分号;结尾,但由分号结尾的不一定是语句,如using namespace是using指令而非语句,在类内部声明Name字段public string Name;是字段声明,也不是语句
- 语句只能出现在方法体里面(但出现在方法体内部的不一定是语句)
2.2.语句详解
C#语句分为以下三大类:
2.2.1.标签语句(labeled-statement)
标签语句在编程时少见,不详写了
2.2.2.声明语句(declaration-statement)
用于声明一个或多个变量与常量
声明局部变量(声明与赋值可以分开):
例如int x = 100;或int x; x = 100;
注意区分赋值和追加初始化器的区别:
int x = 100;为追加初始化器,int x; x = 100;为赋值操作
数组初始化器为{},如int[] myArrary={1,2,3};
声明局部常量(声明时必须同步初始化):
例如const int x = 100;
声明语句支持这样声明:int x, y, z;或int x=1, y=2, z=3;但不推荐这样操作,因为会造成可读性下降
2.2.3.嵌入式语句(embedded-statement)
1.表达式语句(expression-statement)
用于计算所给定的表达式,由此表达式计算出来的值(如果有但是未被显式接受)被丢弃(如int x; x = 100;此赋值表达式的值为100,但是被舍弃,该表达式目的只是为了将100赋给变量x,若再加上x++;则该自增表达式的值为100,但是被舍弃,该表达式目的只是为了将变量x自增1)
不是所有的表达式都可以作为语句来使用。具体而言,不允许像x + y和x == 1这样只计算一个值(此值将被放弃)的表达式作为语句使用(在C中可以这样使用)
以下表达式都可以加;作为语句使用:
- 方法调用表达式,例如Console.WriteLine("hello");
- 对象创建表达式,例如new Form();
- 赋值语句,如 int x; x =100;
- 前置后置的自增,自减表达式,
- await表达式
2.块语句(block)
用于在只允许使用单个语句的上下文中编写多条语句,单独写块语句的情况不常见,一般与if,while等语句合起来使用
形式上如下
{statement-list}//块语句最后不用加;分号
若statement-list为空,则称该块为空
块内可以为任意类型任意数量的语句,编译器无论何时都会将整个块当作一条完整语句看待,因此最后}外不需要加;分号
要注意区分命名空间的命名空间体{},类的类体{},方法的方法体{}和块语句的块体{},他们不是一个东西,块语句属于语句,只能存在于方法体内
变量的作用域:在语句块之前之外声明的变量在块内也可以使用,但是块内声明的变量不可以在块外使用
namespace Testing
{
class Program
{
static void Main(string[] args)
{
{
int x = 100;//声明语句
if (x > 60)
{
Console.WriteLine("hello");
}//嵌入式语句
hello: Console.WriteLine("hello world");
goto hello;//标签语句
}//块内可以使用任意类型语句
}
}
}
3.选择语句(selection-statement)
选择语句会根据表达式的值从若干个给定的语句中选择一个来执行
3.1 if语句
if语句根据布尔表达式的值来选择要执行的语句
一条if语句形式如下:if(boolean-expression)
embedded-statement
else
embedded-statement
......
括号内只能是布尔类型表达式,if()后面的语句只能是一条嵌入式语句
若要同时使用多条语句,需要在嵌入式语句外面加上{}成为一条块语句,单独使用一条语句的话,可加可不加,但根据编程规范最好加上{}
if与else的就近匹配原则:else会与距离他最近的当前未匹配的if匹配
由于if本身就是嵌入式语句,因此在if()后面的语句也可以是if语句,从而达成多重嵌套,实现树状分支结构
else if相当于多重if else的优化简写结构,能够提高可读性,如以下代码的优化
int score = 88; if (score >= 0 && score <= 100) { if (score >= 80) { Console.WriteLine("A"); } else { if (score >= 60) { Console.WriteLine("B"); } else { if (score >= 40) { Console.WriteLine("C"); } else { Console.WriteLine("Failed"); } } } }
可简化为:
int score = 88; if(score >= 0 && score <= 100) { if (score >= 80 && score <= 100) { Console.WriteLine("A"); } else if (score >= 60) { Console.WriteLine("B"); } else if (score >= 40) { Console.WriteLine("C"); } else { Console.WriteLine("Failed"); } }
3.2 switch语句
switch语句选择一个要执行的语句列表,此列表具有一个相关联的switch标签,它对应于switch表达式的值
一条switch语句形式如下:
switch (expression) //类似筛选的条件
{
case constant-expression://一条case标签,case后面必须是常量表达式,且必须与switch表达式的expression类型一致
statement-list//一个语句列表
break;
default://一条default标签,类似于if语句最后的else
break;//一个break代表着该标签所属switch section的结束
}//注:也可以多条标签对应一个语句列表
其中,switch表达式expression的类型必须为以下类型之一:sbyte、byte、short、ushort、int、uint、long、ulong、bool、char、string、或枚举类型enum-type,或者是对应于以上某种类型的可空类型
一旦一个标签后面跟了语句列表,必须显式加上一个break;(若要进行goto则不需要加break;),此时就变成了一个switch section,因此如果两个标签做的事情一样,只需要将两个标签连起来即可
default标签不是必须的,但是根据编程规范,无论何时最好都要写上default标签的switch section,防止出现意外逻辑
int score = 88;
switch(score/10)
{
case 10:
if(score == 100)
{
goto case 9;//为保证代码逻辑一致,使用goto语句
}
else
{
goto default;
}//100时特殊对待
case 9:
case 8://多条标签对应一个语句列表
Console.WriteLine("A");
break;
case 7:
case 6:
Console.WriteLine("B");
break;
case 5:
case 4:
Console.WriteLine("C");
break;
default:
Console.WriteLine("Failed");
break;
}
Code Snippet
VS自动补全框架功能
以自动补全枚举类型变量的switch语句结构为例
输入sw双击tab,再输入switch表达式,点击任意地方即可自动补全模板
4.try语句
try语句提供一种机制,用于捕捉在块的执行期间发生的各种异常。此外,try语句还能指定一个代码块,并保证当控制离开try语句时,总是先执行该代码
三种可能的try语句格式
1:一个try块后接一个或多个catch块
2:一个try块后接一个finally块,此时不会捕捉异常
3:一个try块后接一个或多个catch块,后面再跟一个finally块
try相当于尝试执行一段语句块,当出现异常时,跳过该try语句块内当前异常的语句以及后续语句,并使用catch对异常进行捕捉,从而对异常进行分门别类处理,finally子句不论try语句块是否发生异常都会执行,一段try语句只可以有一个finally子句,但是可以有多个catch子句,并且只会执行其中一个catch子句
catch子句分类:通用catch子句:能捕捉任意类型异常
专用catch子句:只能捕捉某一特定类型的异常
可以使用MSDN文档查询某些方法对应可能出现的异常,如Int32.Parse 对应以下三种异常:
static void Main(string[] args)
{
Console.WriteLine(Calculator.Add("a", "234"));
}
class Calculator
{
public static int Add(string str1,string str2)
{
int a = 0;
int b = 0;
try
{
a = int.Parse(str1);
b = int.Parse(str2);
}
/*catch
{
Console.WriteLine("You argument(s) is not number.");
}//通用catch子句,捕捉任意类型异常*/
catch(ArgumentNullException)//空值异常
{
Console.WriteLine("Your argument(s) is null");
}
catch(FormatException)//格式异常
{
Console.WriteLine("Your argument(s) is not number");
}
catch (OverflowException)//溢出异常
{
Console.WriteLine("Out of range");
}//三种int.Parse方法对应的异常类型
int result = a + b;
return result;
}
}
以上三种专用类型异常也可以像下面这样,再catch后面的异常类型加上一个异常标识符,由于异常在被catch后会自动创建一个异常实例,因此可以在后面直接打印出异常实例的message成员:
catch(ArgumentNullException ane)//空值异常
{
Console.WriteLine(ane.Message);
}
catch(FormatException fe)//格式异常
{
Console.WriteLine(fe.Message);
}
catch (OverflowException oe)//溢出异常
{
Console.WriteLine(oe.Message);
}//三种int.Parse方法对应的异常类型
finally块内一般用于写释放系统资源的语句和程序的log即执行记录
对于写不写finally块,try或catch完毕之后都会执行后面语句,那么写finally块的意义是什么的解答:
finally块是防止try或catch语句里面有return导致无法及时关闭某些东西,加上finally块后,即使前面块内有return语句,也会先执行finally块再return,如果没有finally块则不会执行
下面以finally块内写程序log为例:
static void Main(string[] args)
{
Console.WriteLine(Calculator.Add("123", "a34"));
}
class Calculator
{
public static int Add(string str1, string str2)
{
int a = 0;
int b = 0;
bool hasError = false;//hasError用于记录是否发生异常
try
{
a = int.Parse(str1);
b = int.Parse(str2);
}
catch (ArgumentNullException ane)//空值异常
{
Console.WriteLine(ane.Message);
hasError = True;
}
catch (FormatException fe)//格式异常
{
Console.WriteLine(fe.Message);
hasError = True;
}
catch (OverflowException oe)//溢出异常
{
Console.WriteLine(oe.Message);
hasError = True;
}//三种int.Parse方法对应的异常类型
finally
{
if (hasError)
{
Console.WriteLine("Execution has error");
}
else
{
Console.WriteLine("Done");
}
}//添加log表明程序执行状态,能表示最后的result是否是正确执行所得到的
int result = a + b;
return result;
}
}
throw关键字:可以用于抛出一个新建异常如throw new Exception("Number is wrong");或者用于捕捉住已有异常后不处理,抛出去,谁调用谁处理。throw的语法比较灵活,以下几种都是合法使用例子
catch (OverflowException oe)//溢出异常
{
throw oe;
}//抛出异常oe
catch (OverflowException oe)//溢出异常
{
throw;
}//抛出异常
catch (OverflowException)//溢出异常
{
throw;
}//抛出异常
注:尽量在程序可能出现异常的地方都要try catch,防止漏掉异常导致程序出错
5.迭代语句(iteration-statement)
即循环语句,重复地执行嵌入语句
5.1 while语句
while 语句按不同条件执行一个嵌入语句零次或多次
一条while语句的形式如下:
while(boolean-expression)
embedded-statement//while后面循环体只能是一条嵌入式语句,若要同时执行多条语句,需要使用块语句
5.2 do语句
do 语句按不同条件执行一个嵌入语句一次或多次
一条do语句的形式如下:
do embedded-statement while(boolean-expression)//do后面循环体只能是一条嵌入式语句,若要同时执行多条语句,需要使用块语句
5.3 for语句
for 语句计算一个初始化表达式序列,然后,当某个条件为真时,重复执行相关的嵌入语句并计算一个迭代表达式序列
计数循环更适合for循环而不是while和do循环,代码可读性更高
一条for语句的形式如下:
for(for-initializeropt;for-conditionopt;for-iteratoropt) embedded-statement//for后面循环体只能是一条嵌入式语句,若要同时执行多条语句,需要使用块语句
for()内三者分别为初始化器,循环条件,迭代器,执行顺序如下:for循环第一次开始时会执行初始化器,并且在整个循环只执行这一次,紧接着执行循环条件的判断,若判断通过,则进入循环体,然后执行迭代器
for()内三者都是可选的(;分号不能省略),但若三者都不写,那就相当于一个死循环,实际使用时最好都写上,提高可读性,并且初始化器的变量最好不要在循环外声明。
使用for循环打印九九乘法表:
namespace Testing
{
class Program
{
static void Main(string[] args)
{
for(int a = 1;a <= 9; a++)
{
for(int b = 1; b <=a; b++)
{
Console.Write("{0}*{1}={2}\t", a, b, a * b);//\t为制表符
}
Console.WriteLine();//打印一个回车
}
}
}
}
5.4 foreach语句
foreach 语句用于枚举一个集合的元素,并对该集合中的每个元素执行一次相关的嵌入语句,即集合遍历循环
- 什么样的集合可以被遍历:
以数组和泛型为例,数据类型后面加上了[]就表明它是一个数组类型,C#中所有数组类型的基类都是Array类,转到Arrary类的定义可以发现其实现了IEnumerable接口,泛型类也具有这个接口,而所有实现IEnumerable接口的类就是可以被遍历循环的集合。IEnumerable接口只有一个方法成员GetEnumerator(),用于获得一个集合的迭代器,因此C#所有可以被遍历的集合都能够获得自己的迭代器
- 迭代器IEnumerator:
相当于对一个集合元素的指示器(类似现实中的点名)
Current表示当前指示的元素,MoveNext()方法表示如果迭代器还能向后移动,就返回True,若不能继续移动,则表示走到了集合的最后一个元素,返回False(无法继续移动的情况是迭代器走到了集合的最后元素的后面位置), Resrt()方法可以将迭代器拨回集合的最开始,此时指向第一个元素之前(即不指向任何元素)
以下代码为对数组和泛型类的集合遍历
static void Main(string[] args) { var intArray = new int[] { 1, 3, 5 ,7}; IEnumerator enumerator = intArray.GetEnumerator();//获得intArray集合的迭代器 while (enumerator.MoveNext()) { Console.WriteLine(enumerator.Current); }//若迭代器还能向后移动,则打印当前元素 var intList = new List<int>() { 2, 4, 6, 8 }; IEnumerator enumerator2 = intList.GetEnumerator(); while (enumerator2.MoveNext()) { Console.WriteLine(enumerator2.Current); } }
foreach语句就是对集合遍历的一种简记法
一条foreach语句的形式如下:
foreach(local-variable-type identifier in expression) embedded-statement//foreach后面循环体只能是一条嵌入式语句,若要同时执行多条语句,需要使用块语句
foreach()内为迭代器指向变量的类型 迭代变量 in 集合
以下代码为上述集合遍历代码的foreach简记法:
static void Main(string[] args) { var intArray = new int[] { 1, 3, 5, 7 }; foreach(var current in intArray)//鼓励使用var,编译器会自动判断指向的元素类型, //也即是迭代遍历的类型 //此处的迭代变量current相当于上述代码的enumerator.Current { Console.WriteLine(current); } var intList = new List<int>() { 2, 4, 6, 8 }; foreach (var current in intList) { Console.WriteLine(current); } }
6.跳转语句(jump-statement)
用于无条件地转移控制
6.1 break语句
break语句将退出直接封闭它的switch、while、do、for或foreach语句,即退出整个循环体
6.2 continue语句
continue语句将开始直接封闭它的while、do、for或foreach语句的一次新迭代,即放弃当前循环,开启新一轮循环
注:在多重循环的时候,break与continue只能影响到直接包含它们的循环体,影响不到外层循环
6.3 goto语句
现在已经不是主流,不怎么使用了
6.4 throw语句
在try语句部分已经讲过
6.5 return语句
使用return语句的几个原则:
尽早return:通过提前 return 可以让代码阅读者立刻就鉴别出来程序将在什么情况下 return,同时减少 if else 嵌套,写出更优雅的代码
class Program
{
static void Main(string[] args)
{
Greeting("Mr.Duan");
}
static void Greeting(string name)
{
if (string.IsNullOrEmpty(name))
{
// 通过尽早 return 可以让代码阅读者立刻就鉴别出来
// name 参数在什么情况下是有问题的
return;
}
Console.WriteLine("Hello, {0}", name);//如果后面语句很多,也可以避免语句头重脚轻的问题,减少if语句的长度
}
}
关于using语句、yield语句、checked/unchecked语句、lock语句(用于多线程)、标签语句和空语句,要么太难,要么不常用或者过时,所以不赘述
十.字段、属性、索引器、常量
本文提到C#类型时一般指类或结构体,类或结构体有以下成员:
成员 | 说明 |
常量 | 与类关联的常量值 |
字段 | 类的变量 |
方法 | 类可执行的计算和操作 |
属性 | 与读写类的命名属性相关联的操作 |
索引器 | 与以数组方式索引类的实例相关联的操作 |
事件 | 可由类生成的通知 |
运算符 | 类所支持的转换和表达式运算符 |
构造函数 | 初始化类的实例或类本身所需的操作 |
析构函数 | 在永久丢弃类的实例之前执行的操作 |
类型 | 类所声明的嵌套类型 |
字段、属性、索引器、常量都是用于表示数据的,因此综合起来讲
1.字段(field)
1.1.字段的定义
- 字段是一种表示对象或类型(类与结构体)关联的变量
- 字段是类型的成员,旧称成员变量
- 与对象关联的字段也称作实例字段
- 与类型关联的字段称为静态字段,由static修饰
1.2.字段的声明与初始值
字段的命名一定要是名词,声明时一定要在类或结构体内部
关于字段的声明格式:特性s(可选) 修饰符s(可选) 字段的数据类型 变量声明器;
- 允许的修饰符有:new, public, protected, internal, private, static, readonly, volatile,若修饰符有多个,则必须为有意义的修饰符组合,像public private就是非法的
- 变量声明器有两种,一种是单独的变量名,此时编译器会自动赋给他们默认值,即字段的数据类型的默认值,另一种是变量名加上变量初始化器(鼓励在声明是就显式初始化的操作),这种操作的原理与在构造器内部初始化字段是一样的,但是更便于维护,这样若构造器发生变化,字段初始值不会改变。对于实例字段,它初始化的时机是在实例被创建的时候,对于静态字段,它初始化的时机是在运行环境第一次加载这个数据类型的时候(从此也可以发现静态构造器永远只执行一次)
- 尽管字段声明最后加上了;分号,但它不属于语句,因为它出现在类或结构体内部而不是方法体内部
1.3.只读字段readonly
关于只读字段readonly:为实例或类型保存一旦初始化后就不希望再改变的值,只读字段只能在初始化时进行一次赋值,之后任何的更改都是不被允许的。
2.属性(property)
属性是C#独有的概念
2.1.属性的定义
- 属性是一种用于访问对象或类型的特征的成员,特征反映了状态
- 属性是字段的自然扩展
- 从命名上看,字段更偏向于实例对象在内存中的布局,属性更偏向于反映现实世界对象的特征
- 对外:暴露数据,数据是可以存储在字段里的,也可以是动态计算出来的
- 对内:保护字段不被非法值污染
- 属性由Get/Set方法对进化而来
为了防止字段被异常数据污染,一般会将字段权限更改为private(此时编程规范建议将变量名改为驼峰命名法 ),并使用Get/Set方法对来对private字段进行访问和修改
使用Get/Set方法对之前,字段值有可能会被污染:
class Program
{
static void Main(string[] args)
{
var stu1 = new Student()
{
Age = 20
};
var stu2 = new Student()
{
Age = 20
};
var stu3 = new Student()
{
// 异常值,污染字段
Age = 200
};
var avgAge = (stu1.Age + stu2.Age + stu3.Age) / 3;
Console.WriteLine(avgAge);
}
}
class Student
{
public int Age;
}
将字段权限更改为private,使用Get/Set方法对之后:
class Program
{
static void Main(string[] args)
{
try
{
var stu1 = new Student();
stu1.SetAge(20);
var stu2 = new Student();
stu2.SetAge(20);
var stu3 = new Student();
stu3.SetAge(200);
var avgAge = (stu1.GetAge() + stu2.GetAge() + stu3.GetAge()) / 3;
Console.WriteLine(avgAge);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
}
class Student
{
private int age;//age字段的权限改为private之后无法在类体外部直接对其进行访问修改
//但可以使用内部的Get/Set方法对对其进行访问修改
public int GetAge()
{
return age;
}//Get方法用于访问字段
public void SetAge(int value)
{
if (value >= 0 && value <= 120)
{
age = value;
}
else
{
throw new Exception("Age value has error.");//发现值非法,抛出异常
}
}//Set方法用于修改字段
}
C++、JAVA 里面是没有属性的概念,因此使用 Get/Set 来保护字段的方法至今仍在 C++、JAVA 里面流行
因为 Get/Set 方法对写起来冗长,微软应广大程序员请求,给 C# 引入了属性
引入属性之后:
class Program
{
static void Main(string[] args)
{
try
{
var stu1 = new Student();
stu1.Age = 20;
var stu2 = new Student();
stu2.Age = 20;
var stu3 = new Student();
stu3.Age = 200;
var avgAge = (stu1.Age + stu2.Age + stu3.Age) / 3;
Console.WriteLine(avgAge);
}//使用属性Age对私有字段age进行访问修改
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
}
class Student
{
private int age;//私有字段age
public int Age//公有属性Age
{
get
{
return age;
}//get访问器getter,用于访问字段值
set
{
if (value >= 0 && value <= 120)
{
age = value;
}
else
{
throw new Exception("Age value has error.");
}
}//set访问器setter,用于设置字段值
}
}
注:写set访问器时,编译器会自动准备好一个叫做value的上下文关键字,代表用户传递进来的值,不需要像写set方法时主动声明一个参数value
- 又一个语法糖——属性背后的秘密
语法糖:为了方便程序员进行程序编写,在编程语言中一段比较简单的逻辑背后是为了隐藏比较复杂的逻辑,这样的简单逻辑称为语法糖,像foreach循环和属性都属于语法糖
2.2.属性的声明
- 完整声明
完整声明的形式如下:
特性(s)(可选)+ 修饰符(s)(可选)+ 属性数据类型 + 属性的名称{getter setter}
完整声明属性时一般需要显式声明一个对应字段用于存储数据,属性名与对应字段名一般需要相同,但是命名方式有区别,属性名用 Pascal命名,对应的私有字段用驼峰命名。属性最常见的修饰符组合为public或者pubic static,属性若是静态的,则对于的字段必须也是静态的。有些属性getter与setter访问器都有,此时能同时对字段进行访问和修改,有些属性只有getter,只能从字段中读取值不能赋值,称之为只读属性(如果一个属性的setter权限为private,也不能在类体外部对字段进行更改,但这种属性不是只读属性,因为它可以在属性内部被访问),反之称为只写属性,但是一般编程时只写属性非常少见,因为属性的作用就是向外暴露数据,只写属性就失去了这一功能。
private int age;
public int Age
{
get
{
return age;
}
set
{
if (value >= 0 && value <= 120)
{
age = value;
}
else
{
throw new Exception("Age value has error.");
}
}
}//一个完整声明的属性
Code Snippet:propfull+2*TAB
- 简略声明
通过简略声明出来的属性在功能上与一个公有字段完全一样,其值不受保护。但是其声明起来非常简单,一般用于传递数据。简略声明属性不需要显式定义对应字段,编译器会为其自动生成一个对应的后台私有字段,但由于get与set访问器为空,因此对字段起不到任何的保护作用。(简略声明的属性和公有字段虽然在大部分情况下可以互换,但推荐使用属性,因为属性的封装性和灵活性更好,便于未来添加验证或逻辑)
public int Age{get; set;}//一个简略声明的属性
这段代码相当于以下代码:
private int <Age>k__BackingField;//编译器自动生成的后台私有字段
public int Age
{
get
{
return <Age>k__BackingField;
}
set
{
<Age>k__BackingField = value;
}//默认生成的get与set访问器
}
注:只有简略声明属性时才会自动生成后台私有字段,其他情况下不会自动生成
Code Snippet:prop+2*TAB
VS也提供一键重构封装字段生成属性的操作
- 动态计算值的属性
主动计算,每次获取 CanWork 时都计算,适用于 CanWork 属性使用频率低的情况:
此时不需要对应的字段来存储值,因为值是在每次计算的过程中实时获取的
class Program
{
static void Main(string[] args)
{
try
{
var stu1 = new Student();
stu1.Age = 12;
Console.WriteLine(stu1.CanWork);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
}
class Student
{
private int age;
public int Age
{
get
{
return age;
}
set
{
age = value;
}
}
public bool CanWork//只读属性CanWork,没有对应字段来存储值
{
get
{
return age > 16;
}
}
}
被动计算,只在 Age 赋值时计算一次,计算之后将其存入canWork字段,之后使用时直接读取即可,不需要计算,适用于 Age 属性使用频率低,CanWork 使用频率高的情况:
class Program
{
static void Main(string[] args)
{
try
{
var stu1 = new Student();
stu1.Age = 12;
Console.WriteLine(stu1.CanWork);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
}
class Student
{
private int age;
public int Age
{
get { return age; }
set
{
age = value;
CalculateCanWork();
}
}
private bool canWork;//创建了一个私有canWork字段用于存储每次计算的值
public bool CanWork
{
get { return canWork; }
}
private void CalculateCanWork()
{
canWork = age > 16;
}
}
- 注意实例属性和静态属性
- 属性的名字一定是名词
2.3.属性与字段的关系
- 一般情况下,他们都用于表示实体(对象或类型)的状态
- 属性大多数情况下是字段的包装器
- 建议:永远使用属性(而不是字段)来暴露数据,即字段永远都是private或protected的
3.索引器(indexer)(概述)
一般用于检索集合,索引器使对象能够用与数组相同的方式(即使用下标)进行索引 ,拥有索引器这种成员的数据类型绝大部分都是集合类型,但也有例外,如下(为了演示索引器的作用):
class Program
{
static void Main(string[] args)
{
var stu = new Student();
stu["Math"] = 90;
stu["Math"] = 100;
var mathScore = stu["Math"];
Console.WriteLine(mathScore);
}
}
class Student
{
private Dictionary<string, int> scoreDictionary = new Dictionary<string, int>();
//创建一个私有的成绩字典,包含两个成员,科目名和成绩
public int? this[string subject]//索引器的返回值类型为可空int类型,用string类型进行索引
{
get
{
if (this.scoreDictionary.ContainsKey(subject))
{
return scoreDictionary[subject];
}
else
{
return null;
}
}//在给定的字典内查询对应科目的成绩并返回,若没查到,返回null
set
{
if (value.HasValue == false)//判断value是否为空
{
throw new Exception("Score cannot be null");
}
if (this.scoreDictionary.ContainsKey(subject))
{
// 可空类型的 Value 属性才是其真实值。
this.scoreDictionary[subject] = value.Value;
}
else
{
this.scoreDictionary.Add(subject, value.Value);
}
}
}//成绩索引器的创建
}
Code Snippet:index + 2*TAB
4.常量(constant)
4.1.常量的定义
- 常量是表示常量值(即可以在编译时进行运算的值)的类成员,可类比C中的宏定义
- 常量隶属于类型而不是对象,即没有实例常量,“实例常量”的角色由实例只读字段担当,常量在编译时会直接进行替换,不需要访问内存,而实例只读字段需要访问内存
- 注意区分成员常量与局部常量 成员常量一般在类体内,方法体外,局部常量一般在方法体内 ,都使用const修饰
4.2.常量的声明
成员常量:修饰符s(可选) const 常量数据类型 变量名 初始化器;
局部常量:const 常量数据类型 变量名 初始化器
4.3.各种“只读”的应用场景
- 常量:隶属于类型,没有所谓的实例常量
- 只读字段:只有一次初始化机会,就是在声明它时初始化(等价于在构造函数中初始化)
- 只读属性:对于类使用静态只读属性,对于实例使用实例只读属性 要分清没有 Setter与 private Setter的区别 常量比静态只读属性性能高,因为编译时,编译器将用常量的值代替常量标识符
- 静态只读字段:字段没有类型局限,但常量只能是如int,double等简单类型,不能是类/自定义结构体类型,此时只能使用静态只读字段 (C#9.0以上版本支持方法体内使用static readonly)
十一.传值、输出、引用、数组、具名、可选参数和扩展方法
1.传值参数
声明时不带修饰符的形参是值形参。一个值形参对应于一个局部变量,只是他的初始值来自该方法调用所提供的相应实参。
当形参是值参数时。方法调用中的对应实参必须是表达式,并且它的类型可以隐式转换为形参的类型。
允许方法将新值赋给值参数。这样的赋值只影响由该值形参表示的局部存储位置,而不会影响在方法调用时由调用方给出的实参。
1.1值类型的传值参数
上图虚线上代表方法体外,虚线下代表方法体内
class Program
{
static void Main(string[] args)
{
int y = 100;
Calculator.AddOne(y);
Console.WriteLine(y);
}
}
class Calculator
{
public static void AddOne(int x)
{
x = x + 1;
}
}
上述代码打印的y值仍然为100,因为方法AddOne的参数int x是值类型的传值参数,方法AddOne内的x相当于y的副本,因此在AddOne方法内对x值进行加1不会影响到y的值
注:C#中控制台应用程序的默认入口类Program类应当尽量保持精简,主要作用为应用程序的启动入口和顶层协调器,具体的逻辑应该委托给其他类,实际开发时应优先在Program外部定义类
1.2引用类型的传值参数(创建新对象)
tip: 多字段类的初始化器用{},可以只初始化部分字段
class Program
{
static void Main(string[] args)
{
Student stu1 = new Student() { Name = "Mike" };
Student.SomeMethod(stu1);
Console.WriteLine(stu1.Name);
}
}
class Student
{
public string Name { get; set; }
public static void SomeMethod(Student stu)
{
stu = new Student() { Name = "Nike" };
Console.WriteLine(stu.Name);
}
}
上述代码会依次打印Nike和Mike,这是因为方法SomeMethod的参数Student stu是引用类型的传值参数,方法SomeMethod内的stu相当于stu1的副本,stu1保存Mike实例的地址,开始调用方法后,方法内的stu被重新赋值,此时保存的是新创建的Nike实例地址,所以会先在方法内打印Nike,步出方法后,stu1仍旧保存的是Mike实例的地址,所以会打印Mike
这种状况很少见,一般情况都是读取传进来的值,而不会将其连接到新对象
GetHashCode()
若SomeMethod方法内部是stu = new Student() { Name = "Mike" };则会打印两次Mike,但实际上stu和stu1引用的是两个不同的实例,这样会无法区分两者是否引用的是同一份实例,此时需要使用方法GetHashCode()来区分
Object.GetHashCode() 方法,用于获取代表当前对象的唯一哈希代码值,每个对象的 Hash Code 都不一样。
通过 Hash Code 来区分两个 Name 相同的 stu 对象。
class Program
{
static void Main(string[] args)
{
var stu = new Student() { Name="Tim"};
SomeMethod(stu);
Console.WriteLine("{0},{1}",stu.Name,stu.GetHashCode());
}//{0},{1}表示占位符,用后面的变量值替换,然后变成字符串一起输出
static void SomeMethod(Student stu)
{
stu = new Student { Name = "Tim" };
Console.WriteLine("{0},{1}",stu.Name,stu.GetHashCode());
}
}
class Student
{
public string Name { get; set; }
public static void SomeMethod(Student stu)
{
stu = new Student() { Name = "Nike" };
Console.WriteLine(stu.Name);
}
}
1.3引用类型的传值参数(只操作对象,不创建新对象)
class Program
{
static void Main(string[] args)
{
Student Stu = new Student() { Name = "Mike" };
Student.UpdateObject(Stu);
Console.WriteLine("HashCode={0}, Name = {1}", Stu.GetHashCode(), Stu.Name);
}
}
class Student
{
public string Name { get; set; }
public static void UpdateObject(Student stu)
{
stu.Name = "Nike" ;
Console.WriteLine("HashCode={0}, Name = {1}", stu.GetHashCode(), stu.Name);
}
}
上述代码两次都会打印同一个哈希值,并且Name都是Nike,这是因为方法UpdateObject的参数Student stu是引用类型的传值参数,方法UpdateObject内的stu保存的就是外部stu的副本,他们都引用的是实例Mike,因此在方法内调用stu.Name访问到的就是实例Mike的Name,对其进行修改自然会将对象Mike的字段值修改掉。
这种通过传递进来的参数修改其引用对象的值的情况,在工作中也比较少见。因为作为方法,其主要作用是返回一个值,主要输出还是靠返回值。因此把这种修改实际参数或其所引用对象的值等在主要作用之外的结果操作叫做方法的副作用(side-effect),这种副作用平时编程时要尽量避免。
2.引用参数
引用形参是用ref修饰符声明的形参。与值形参不同,引用形参并不创建新的存储位置。相反,引用形参表示的存储位置恰是在方法调用中作为实参给出的那个变量所表示的存储位置。
当形参为引用形参时,方法调用中的对应实参必须由关键字ref并后接一个与形参类型相同的variable-reference组成。变量在可以作为引用形参传递之前,必须先明确赋值。
在方法内部,引用形参始终被认为时明确赋值的。
声明为迭代器的方法不能有引用形参。
2.1值类型的引用参数
static void Main(string[] args)
{
int y = 1;
IWantSideEffect(ref y);
Console.WriteLine(y);
}
static void IWantSideEffect(ref int x)
{
x += 100;
}
上述代码会打印出101,因为方法IWantSideEffect的x是值类型的引用参数,在方法内部对x进行操作就相当于对y进行操作,操作的是同一份数据,而非副本。
2.2引用类型的引用参数(创建新对象)
class Program
{
static void Main(string[] args)
{
var outterStu = new Student() { Name = "Tim" };
Console.WriteLine("HashCode={0}, Name={1}", outterStu.GetHashCode(), outterStu.Name);
Console.WriteLine("-----------------");
Student.IWantSideEffect(ref outterStu);
Console.WriteLine("HashCode={0}, Name={1}", outterStu.GetHashCode(), outterStu.Name);
}
}
class Student
{
public string Name { get; set; }
public static void IWantSideEffect(ref Student stu)
{
stu = new Student() { Name = "Tom" };
Console.WriteLine("HashCode={0}, Name={1}", stu.GetHashCode(), stu.Name);
}
}
上述代码会先打印Tim和一个哈希值,然后会打印两份Tom和与Tim不同的哈希值,并且两份Tom的哈希值相同。这是因为方法方法IWantSideEffect的stu是引用类型的引用参数,在方法体内部对stu进行操作就是对outterStu进行操作,两者是同一份数据,而非副本,引用类型变量outterStu一开始引用的是实例Tim,方法调用完成之后就相当于让其引用新创建的实例Tom。
2.3引用类型的引用参数(只操作对象,不创建新对象)
class Program
{
static void Main(string[] args)
{
var Stu = new Student() { Name = "Tim" };
Console.WriteLine("HashCode={0}, Name={1}", Stu.GetHashCode(), Stu.Name);
Console.WriteLine("-----------------");
Student.SomeSideEffect(ref Stu);
Console.WriteLine("HashCode={0}, Name={1}", Stu.GetHashCode(), Stu.Name);
}
}
class Student
{
public string Name { get; set; }
public static void SomeSideEffect(ref Student stu)
{
stu.Name = "Tom";
Console.WriteLine("HashCode={0}, Name={1}", stu.GetHashCode(), stu.Name);
}
}
上述代码三次都会打印同一个哈希值,第一次Name是Tim,后两次Name都是Tom,这是因为方法SomeSideEffect的参数Student stu是引用类型的引用参数,方法体内外操作的都是同一份数据,即方法内部的stu和外部的outterStu是同一份数据而非副本,并且他们都引用同一个实例,因此会出现上述结果。这与传值参数的第三种在效果上并未差别,但是方法内外的参数stu和Stu在内存机理上有差别,一个是副本,一个是同一份数据,但最终都指向同一份实例。
3.输出参数
用out修饰符声明的形参是输出形参。类似于引用形参,输出形参不创建新的存储位置。相反,输出形参表示的存储位置恰是在该方法调用中作为实参给出的那个变量所表示的存储位置。
当形参为输出形参时,方法调用中的相应实参必须由关键字out并后接一个与形参类型相同的variable-reference组成。变量在可以作为输出形参传递之前不一定需要明确赋值,但是在将变量作为输出形参传递的调用之后,该变量被认为是明确赋值的。
在方法内,与局部变量相同,输出形参最初被认为是未赋值的,因而必须在使用他的值之前明确赋值。
在方法返回之前,该方法的每个输出形参都必须明确赋值。
声明为分部方法或迭代器的方法不能有输出形参。
输出形参通常用在需要产生多个返回值的方法中。
通俗来讲,方法一次只能返回一个值,如果需要在一个方法内返回多个值,需要用到输出参数,并且输出参数和引用参数一样,不会给传进来的参数创建副本,和传进来的实参是同一份数据。因为输出参数的作用是输出数据,因此不要求输出参数在方法体外被赋值,但在方法体内一定要赋值,因为需要输出它。
3.1值类型的输出参数
和引用参数一样,也是有意利用副作用。
下面以double类型的TryParse方法为例:
static void Main(string[] args)
{
Console.WriteLine("Please input first number:");
var arg1 = Console.ReadLine();
double x = 0;//输出参数x
if (double.TryParse(arg1, out x) == false)
{//TryParse将字符串arg1解析为double类型,如果解析成功,返回True,否则False
//解析成功会将数据输出为x
Console.WriteLine("Input error!");
return;
}
Console.WriteLine("Please input second number:");
var arg2 = Console.ReadLine();
double y = 0;//输出参数y
if (double.TryParse(arg2, out y) == false)
{
Console.WriteLine("Input error!");
return;
}
double z = x + y;
Console.WriteLine(z);
}
下面代码是自己实现的TryParse方法:
class Program
{
static void Main(string[] args)
{
double x = 0;
if (DoubleParser.TryParse("aa", out x))
{
Console.WriteLine(x);
Console.WriteLine("All rigth");
}
else
{
Console.WriteLine("Wrong");
}
if (DoubleParser.TryParse("12.23", out x))
{
Console.WriteLine(x);
Console.WriteLine("All rigth");
}
}
}
class DoubleParser
{
public static bool TryParse(string input, out double result)
{
try
{
result = double.Parse(input);
return true;
}
catch
{
result = 0;
return false;
}
}
}
3.2引用类型的输出参数
class Program
{
static void Main(string[] args)
{
Student stu = null;
if(StudentFactory.Create("Tim", 34, out stu))
{
Console.WriteLine("Student {0}, age is {1}",stu.Name,stu.Age);
}
}
}
class Student
{
public int Age { get; set; }
public string Name { get; set; }
}
class StudentFactory
{
public static bool Create(string stuName,int stuAge,out Student result)
{
result = null;
if (string.IsNullOrEmpty(stuName))
{
return false;
}
if (stuAge < 20 || stuAge > 80)
{
return false;
}
result = new Student() { Name = stuName, Age = stuAge };
return true;
}
}
4.数组参数
- 一个方法的参数列表只能有一个数组参数,且必须是形参列表中的最后一个,由 params 修饰
使用params关键字之前,需要声明一个数组才能调用CalculateSum方法:
class Program
{
static void Main(string[] args)
{
var myIntArray = new int[] { 1, 2, 3 };
int result = CalculateSum(myIntArray);
Console.WriteLine(result);
}
static int CalculateSum(int[] intArray)
{
int sum = 0;
foreach (var item in intArray)
{
sum += item;
}
return sum;
}
}
使用params关键字之前,系统会在调用方法时传入参数时自动为其创建一个数组:
class Program
{
static void Main(string[] args)
{
int result = CalculateSum(1, 2, 3);
Console.WriteLine(result);
}
static int CalculateSum(params int[] intArray)
{
int sum = 0;
foreach (var item in intArray)
{
sum += item;
}
return sum;
}
}
其实早在WriteLine中就用到过params关键字:
String.Split 方法
根据提供一个或多个字符数组参数对字符串进行分割,并将分割结果返回一个数组
class Program
{
static void Main(string[] args)
{
string str = "Tim;Lisa,Cola.Mike";
string[] result = str.Split(';', ',', '.');
foreach (var name in result)
{
Console.WriteLine(name);
}
}
}
5.具名参数
参数的位置不再受约束
具名参数的优点:
- 提高代码可读性
- 参数的位置不在受参数列表约束
class Program
{
static void Main(string[] args)
{
PrintInfo("Tim", 34);
//不具名参数写法,编写时必须按照方法定义的参数列表顺序输入数据
PrintInfo(age: 24, name:"Wonder");
//具名参数写法,编写时参数顺序不被限制
}
static void PrintInfo(string name, int age)
{
Console.WriteLine("Helllo {0}, you are {1}.",name,age);
}
}
注:严格来说具名参数并不是参数的某个种类,而是参数的使用方法
6.可选参数
- 参数因为具有默认值而变得“可选”(即调用方法时,该参数可写可不写,不写就使用默认值)
- 不推荐使用可选参数
class Program
{
static void Main(string[] args)
{
PrintInfo();
}
static void PrintInfo(string name = "Tim", int age = 34)
{
Console.WriteLine("Helllo {0}, you are {1}.",name,age);
}
}
7.扩展方法(this参数)
- 方法必须是公有的、静态的、即被public static修饰的
- 必须是形参列表中的第一个,由this修饰
- 必须由一个静态类(一般类名为SomeTypeEtension)来统一收纳对SomeType类型的扩展方法
例如想给double类型添加一个Round方法,在无扩展方法的时候,无法给double类型添加Round方法(没有源代码,即使有修改后也无法添加到系统类库中):
class Program
{
static void Main(string[] args)
{
double x = 3.14159;
// double 类型本身没有 Round 方法,只能使用 Math.Round。
double y = Math.Round(x, 4);
Console.WriteLine(y);
}
}
使用扩展方法后:
class Program
{
static void Main(string[] args)
{
double x = 3.14159;
double y = x.Round(4);//这里可以理解为x本身就是Round方法第一个参数
Console.WriteLine(y);
}
}
static class DoubleExtension
{
public static double Round(this double input,int digits)
{
return Math.Round(input, digits);
}
}
注:扩展方法被定义为静态方法,但它们是通过实例方法语法进行调用的,它们的第一个参数指定该方法作用于哪个类型,并且该参数以 this 修饰符为前缀。
举例:LINQ方法
class Program
{
static void Main(string[] args)
{
var myList = new List<int>(){ 11, 12, 9, 14, 15 };
//bool result = AllGreaterThanTen(myList);
// 这里的 All 就是一个扩展方法
bool result = myList.All(i => i > 10);
Console.WriteLine(result);
}
static bool AllGreaterThanTen(List<int> intList)
{
foreach (var item in intList)
{
if (item <= 10)
{
return false;
}
}
return true;
}
}
All 第一个参数带 this,确实是扩展方法。
8.总结
各种参数的使用场景总结:
- 传值参数:参数的默认传递方法
- 输出参数:用于除返回值外还需要输出的场景
- 引用参数:用于需要修改实际参数值的场景
- 数组参数:用于简化方法的调用
- 具名参数:提高可读性
- 可选参数:参数拥有默认值
- 扩展方法(this 参数):为目标数据类型“追加”方法
十二.委托(delegate)
1.委托的定义
委托是函数指针的升级版,下述代码就是C语言中的函数指针实例:
#include <stdio.h>
int (*Calc)(int a, int b);
//声明有两个int形参,返回类型为int的函数指针类型
int Add(int a, int b)
{
int result = a + b;
return result;
}
int Sub(int a, int b)
{
int result = a - b;
return result;
}
int main()
{
int x = 100;
int y = 200;
int z = 0;
Calc funcPoint1 = &Add;
Calc funcPoint2 = ⋐//取函数的地址赋给函数指针变量
z = funcPoint1(x,y);
printf("%d+%d=%d\n",x,y,z);
z = funcPoint2(x,y);//利用函数指针调用函数
printf("%d-%d=%d\n",x,y,z);
system("pause");
return 0;
}
一切皆地址
- 变量(数据)是以某个地址为起点的一段内存中所存储的值
- 函数(算法)是以某个地址为起点的一段内存中所存储的一组机器语言指令
直接调用与间接调用
- 直接调用:通过函数名来调用函数,CPU通过函数名直接获得函数所在地址并开始执行,然后返回
- 间接调用:通过函数指针来调用函数,CPU通过读取函数指针存储的值获得函数所在地址并开始执行,然后返回
Java中没有与委托相对应的功能实体
Java 语言由 C++ 发展而来,为了提高应用安全性,Java 语言禁止程序员直接访问内存地址。即 Java 语言把 C++ 中所有与指针相关的内容都舍弃掉了。
委托的简单使用
- Action委托
- Func委托
Action 和 Func 是 C# 内置的委托,它们都有很多重载以方便使用
class Program
{
static void Main(string[] args)
{
var calculator = new Calculator();
// Action 用于无形参无返回值的方法。
Action action = new Action(calculator.Report);
//将action委托实例指向calculator.Report方法
calculator.Report();//直接调用calculator.Report方法
action.Invoke();//间接调用calculator.Report方法
action();//模仿函数指针的简略写法。
Func<int, int, int> func1 = new Func<int, int, int>(calculator.Add);
Func<int, int, int> func2 = new Func<int, int, int>(calculator.Sub);
//Func用于参数列表为多个,返回值类型为指定类型的方法
int x = 100;
int y = 200;
int z = 0;
z = func1.Invoke(x, y);
Console.WriteLine(z);
z = func2.Invoke(x, y);
Console.WriteLine(z);
// Func 也有简略写法。
z = func1(x, y);
Console.WriteLine(z);
z = func2(x, y);
Console.WriteLine(z);
}
}
class Calculator
{
public void Report()
{
Console.WriteLine("I have 3 methods.");
}
public int Add(int a, int b)
{
return a + b;
}
public int Sub(int a, int b)
{
return a - b;
}
}
Action委托适用于指向参数列表为0个或最多16个且没有返回值的方法
表示Func委托适用于指向参数列表为0个或至多16个,返回值类型为指定类型的方法(返回值类型在委托参数列表最后一个)
注:使用委托时,委托括号内只需要写方法名即可,不要在方法名后加括号,因为此时不需要调用该方法
2.委托的声明(自定义委托)
- 委托是一种类,类是数据类型所以委托也是一种数据类型
static void Main(string[] args)
{
Type t = typeof(Action);
Console.WriteLine(t.IsClass);
}
上述代码会打印True,证实委托是一种类
- 委托的声明方式和一般的类不同,主要是为了照顾可读性和C/C++传统
声明委托的示例如下:
class Program
{
static void Main(string[] args)
{
Calculator calculator = new Calculator();
Calc calc1 = new Calc(calculator.Add);
Calc calc2 = new Calc(calculator.Sub);
Calc calc3 = new Calc(calculator.Mul);
Calc calc4 = new Calc(calculator.Div);
double a = 100;
double b = 200;
double c = 0;
c = calc1.Invoke(a, b);
Console.WriteLine(c);
c = calc2.Invoke(a, b);
Console.WriteLine(c);
c = calc3.Invoke(a, b);
Console.WriteLine(c);
c = calc4.Invoke(a, b);
Console.WriteLine(c);
}
}
public delegate double Calc(double x, double y);
//声明委托,delegate表示委托,double表示目标方法的返回值类型,括号内参数表示目标方法的参数列表
class Calculator
{
public double Add(double x, double y)
{
return x + y;
}
public double Sub(double x, double y)
{
return x - y;
}
public double Mul(double x, double y)
{
return x * y;
}
public double Div(double x, double y)
{
return x / y;
}
}
- 注意声明委托的位置
声明委托时应该将其放在名称空间体内,这样它就与其他类同级了(委托就是一种类数据类型)。但 C# 允许嵌套声明类(一个类里面可以声明另一个类),所以有时也会有 delegate 在 class 内部声明的情况。
注:嵌套类在使用时,若是在被嵌入的类外部调用,需要标明来自于哪个类,若在被嵌入的类内部使用则不需要
- 委托所封装的方法必须类型兼容
注:参数类型的顺序要一一对应
3.委托的一般使用
实例:把方法当作参数传给另一个方法
正确使用1:模板方法,借用指定的外部方法来产生结果
- 相当于填空题
- 常位于代码中部
- 委托有返回值
利用模板方法,提高代码复用性。
下例中 WrapFactory方法的参数就来自于委托指向的方法的返回值。Product、Box、WrapFactory 都不用修改,只需要在 ProductFactory 里面新增不同的 MakeXXX 然后作为委托传入 WrapProduct 就可以对其进行包装。class Program { static void Main(string[] args) { var productFactory = new ProductFactory(); //新建一个产品工厂实例productFactory Func<Product> func1 = new Func<Product>(productFactory.MakePizza); Func<Product> func2 = new Func<Product>(productFactory.MakeToyCar); //创建委托func1和func2,分别指向productFactory实例的MakePizza和MakeToyCar方法 var wrapFactory = new WrapFactory(); //创建包装工厂实例wrapFactory Box box1 = wrapFactory.WrapProduct(func1); Box box2 = wrapFactory.WrapProduct(func2); //调用实例wrapFactory的WrapProduct方法,传入的参数分别是委托func1和func2,在WrapProduct方法内部完成委托的执行,并将Product类型返回值赋给创建的box1和box2实例,完成打包 Console.WriteLine(box1.Product.Name); Console.WriteLine(box2.Product.Name); } } class Product { public string Name { get; set; } }//产品类,属性为产品名 class Box { public Product Product { get; set; } }//包装盒类,属性为产品类的实例 class WrapFactory { // 模板方法,提高复用性 public Box WrapProduct(Func<Product> getProduct) { var box = new Box(); Product product = getProduct.Invoke(); box.Product = product; return box; }//方法WrapProduct,返回类型为包装盒类,参数为返回类型是Product且无参数的委托getProduct //方法内实现了创建新包装盒实例box,并执行传入的委托,将返回值赋给新创建的Product类实例product,再将实例product赋给box实例的Product属性,完成打包并返回box }//包装工厂类 class ProductFactory { public Product MakePizza() { var product = new Product(); product.Name = "Pizza"; return product; } public Product MakeToyCar() { var product = new Product(); product.Name = "Toy Car"; return product; } }//产品工厂类,用于声明生产各种产品的方法
注:将WrapFactory方法的参数改为Product类型,在调用时直接将产品工厂的方法作为整体(即方法的返回值)传入也可以实现一样的复用效果
两者差别:
1.将方法返回值作为参数
特点:
- 立即执行:方法在参数位置被立即调用;
- 传递的是值:传递的是方法执行后的返回值;
- 静态绑定:编译时确定具体调用哪个方法;
2.将方法本身作为参数
特点:
- 延迟执行:方法在接收方决定何时调用;
- 传递的是行为:传递的是方法本身的引用;
- 动态绑定:可以在运行时决定调用哪个方法;
tip:Reuse,重复使用,也叫“复用”。代码的复用不但可以提高工作效率,还可以减少 bug 的引入。良好的复用结构是所有优秀软件所追求的共同目标之一。
正确使用2:回调(callback)方法,调用指定的外部方法
- 相当于流水线
- 常位于代码末尾
- 委托无返回值
回调方法是通过委托类型参数传入主调方法的被调用方法,主调方法根据自己的逻辑决定是否调用这个方法。
class Program { static void Main(string[] args) { var productFactory = new ProductFactory(); var wrapFactory = new WrapFactory(); var logger = new Logger(); //创建产品工厂实例productFactory,包装工厂实例wrapFactory,Logger实例logger Func<Product> func1 = new Func<Product>(productFactory.MakePizza); Func<Product> func2 = new Func<Product>(productFactory.MakeToyCar); // Func 前面是传入参数,最后一个是返回值,所以此处以 Product 为返回值 //创建委托实例func1和func2分别指向产品工厂实例的制造方法MakePizza和MakeToyCar Action<Product> log = new Action<Product>(logger.Log); // Action 只有传入参数,所以此处以 Product 为参数 //创建委托实例log和func2分别指向实例logger的方法log Box box1 = wrapFactory.WrapProduct(func1, log); Box box2 = wrapFactory.WrapProduct(func2, log); //执行包装工厂实例的方法WrapProduct,参数分别为委托func1,log和委托func2,log, //两个产品都会装盒,但不一定执行log Console.WriteLine(box1.Product.Name); Console.WriteLine(box2.Product.Name); } } class Logger { public void Log(Product product) { // Now 是带时区的时间,存储到数据库应该用不带时区的时间 UtcNow。 Console.WriteLine("Product '{0}' created at {1}.Price is {2}", product.Name, DateTime.UtcNow, product.Price); } }//Logger类用于记录程序运行状态,打印产品的信息和制造时间 class Product { public string Name { get; set; } public double Price { get; set; } }//产品类,有Name和Price两个属性 class Box { public Product Product { get; set; } }//包装类,有Product属性 class WrapFactory { public Box WrapProduct(Func<Product> getProduct, Action<Product> logCallBack) { var box = new Box(); Product product = getProduct.Invoke();//制造产品 // 只 log 价格高于 50 的 if (product.Price >= 50) { logCallBack(product); } box.Product = product;//产品装盒 return box; } }//包装工厂类 class ProductFactory { public Product MakePizza() { var product = new Product { Name = "Pizza", Price = 12 }; return product; } public Product MakeToyCar() { var product = new Product { Name = "Toy Car", Price = 100 }; return product; } }//产品工厂类,用于存储产品制造方法
注意:委托难精通+易使用+功能强大,一旦被滥用则后果严重
- 缺点1:这是一种方法级别的紧耦合,现实工作中要慎之又慎
- 缺点2:使可读性下降,debug的难度增加
- 缺点3:把委托回调、异步调用和多线程纠缠在一起,会让代码变得难以阅读和维护
- 缺点4:委托使用不当有可能造成内存泄露和程序性能下降
委托滥用实例(等水平上去了可以回来看看这段代码):
4.委托的高级使用
4.1.多播委托
多播委托即一个委托内部封装不止一个方法。
using System;
using System.Threading;//该名称空间与多线程相关
namespace DelegateExample
{
class Program
{
static void Main(string[] args)
{
var stu1 = new Student { ID = 1, PenColor = ConsoleColor.Yellow };
var stu2 = new Student { ID = 2, PenColor = ConsoleColor.Green };
var stu3 = new Student { ID = 3, PenColor = ConsoleColor.Red };
var action1 = new Action(stu1.DoHomework);
var action2 = new Action(stu2.DoHomework);
var action3 = new Action(stu3.DoHomework);
// 单播委托
//action1.Invoke();
//action2.Invoke();
//action3.Invoke();
// 多播委托
action1 += action2;
action1 += action3;//相当于将action2和action3合并到了action1中
action1.Invoke();//此时委托action1包含三个DoHomework实例方法
}
}
class Student
{
public int ID { get; set; }
public ConsoleColor PenColor { get; set; }
//ConsoleColor类型表示控制台打印输出的颜色
public void DoHomework()
{
for (int i = 0; i < 5; i++)
{
Console.ForegroundColor = PenColor;
//ForegroundColor方法用于调整控制台打印输出的颜色
Console.WriteLine("Student {0} doing homework {1} hour(s)", ID, i);
Thread.Sleep(1000);//Sleep方法表示将当前线程挂起1000ms即1s
}
}
}
}
注:多播委托类似于链表,+=在链尾添加方法,-=查找并断开一个方法连接,其余的方法顺序和完整性不变,并且多播委托执行方法的顺序是按照封装方法时的顺序执行
上述代码输出结果如下:
4.2.隐式异步调用
4.2.1.关于计算机领域的同步与异步含义
- 同步表示操作按顺序执行,必须等待当前任务完成才能继续。
- 异步表示操作非顺序执行,可并发或延迟处理。
注:异步互不相干:这里说的“互不相干”指的是逻辑上各做各的,而现实工作当中经常会遇到多个线程共享(即同时访问)同一个资源(比如某个变量)的情况,这时候如果处理不当就会产生线程间争夺资源的冲突。
4.2.2.同步调用与异步调用的对比
同步异步表示是否使用委托,显式隐式表示是否创建线程的方式
每一个运行的程序称为一个进程(process),而每一个进程可以拥有一个或者多个线程(thread),进程运行时第一个运行的线程称为该进程的主线程,其余的称为分支线程 。
同步调用指的在同一个线程内进行串行方法调用;异步调用指的是在不同的线程内进行并行方法调用,其底层机理是多线程:
- 直接同步调用:使用方法名
- 间接同步调用:使用单播/多播委托的Invoke方法
- 隐式异步调用:使用委托的BeginInvoke
- 显式异步调用:使用Thread或Task
以下为几种同步调用的代码(只有一个进程)示例:
using System;
using System.Threading;
namespace DelegateExample
{
class Program
{
static void Main(string[] args)
{
var stu1 = new Student { ID = 1, PenColor = ConsoleColor.Yellow };
var stu2 = new Student { ID = 2, PenColor = ConsoleColor.Green };
var stu3 = new Student { ID = 3, PenColor = ConsoleColor.Red };
// 直接同步调用
//stu1.DoHomework();
//stu2.DoHomework();
//stu3.DoHomework();
var action1 = new Action(stu1.DoHomework);
var action2 = new Action(stu2.DoHomework);
var action3 = new Action(stu3.DoHomework);
// 间接同步调用
//单播委托同步调用
//action1.Invoke();
//action2.Invoke();
//action3.Invoke();
// 多播委托同步调用
action1 += action2;
action1 += action3;
action1.Invoke();
// 主线程模拟在做某些事情。
for (var i = 0; i < 10; i++)
{
Console.ForegroundColor=ConsoleColor.Cyan;
Console.WriteLine("Main thread {0}",i);
Thread.Sleep(1000);
}
}
}
class Student
{
public int ID { get; set; }
public ConsoleColor PenColor { get; set; }
public void DoHomework()
{
for (int i = 0; i < 5; i++)
{
Console.ForegroundColor = PenColor;
Console.WriteLine("Student {0} doing homework {1} hour(s)", ID, i);
Thread.Sleep(1000);
}
}
}
}
几种同步调用的执行结果都如下:
以下为隐式异步调用的代码示例:
using System;
using System.Threading;
namespace DelegateExample
{
class Program
{
static void Main(string[] args)
{
var stu1 = new Student { ID = 1, PenColor = ConsoleColor.Yellow };
var stu2 = new Student { ID = 2, PenColor = ConsoleColor.Green };
var stu3 = new Student { ID = 3, PenColor = ConsoleColor.Red };
var action1 = new Action(stu1.DoHomework);
var action2 = new Action(stu2.DoHomework);
var action3 = new Action(stu3.DoHomework);
// 使用委托进行隐式异步调用。
// BeginInvoke 隐式自动生成分支线程,并在分支线程内调用委托封装的方法。
action1.BeginInvoke(null, null);
action2.BeginInvoke(null, null);
action3.BeginInvoke(null, null);
// 主线程模拟在做某些事情。
for (var i = 0; i < 10; i++)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("Main thread {0}",i);
Thread.Sleep(1000);
}
}
}
class Student
{
public int ID { get; set; }
public ConsoleColor PenColor { get; set; }
public void DoHomework()
{
for (int i = 0; i < 5; i++)
{
Console.ForegroundColor = PenColor;
Console.WriteLine("Student {0} doing homework {1} hour(s)", ID, i);
Thread.Sleep(1000);
}
}
}
}
会打印如下结果,可以看到几个进程明显是并行执行的,但因为Console.ForegroundColor是全局共享资源,导致发生了资源争抢,多个线程同时访问造成冲突,使得结果偏离预期(也会导致每次执行的结果可能不同):
要解决资源冲突需要学习锁相关知识
以下为显式异步调用代码示例:
using System;
using System.Threading;
using System.Threading.Tasks;
namespace DelegateExample
{
class Program
{
static void Main(string[] args)
{
var stu1 = new Student { ID = 1, PenColor = ConsoleColor.Yellow };
var stu2 = new Student { ID = 2, PenColor = ConsoleColor.Green };
var stu3 = new Student { ID = 3, PenColor = ConsoleColor.Red };
// 老的显式异步调用方式 Thread,显式创建分支线程
//var thread1 = new Thread(new ThreadStart(stu1.DoHomework));
//var thread2 = new Thread(new ThreadStart(stu2.DoHomework));
//var thread3 = new Thread(new ThreadStart(stu3.DoHomework));
//thread1.Start();
//thread2.Start();
//thread3.Start();
//启动分支线程
// 使用 Task
var task1 = new Task(new Action(stu1.DoHomework));
var task2 = new Task(new Action(stu2.DoHomework));
var task3 = new Task(new Action(stu3.DoHomework));
task1.Start();
task2.Start();
task3.Start();
// 主线程模拟在做某些事情。
for (var i = 0; i < 10; i++)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("Main thread {0}", i);
Thread.Sleep(1000);
}
}
}
class Student
{
public int ID { get; set; }
public ConsoleColor PenColor { get; set; }
public void DoHomework()
{
for (int i = 0; i < 5; i++)
{
Console.ForegroundColor = PenColor;
Console.WriteLine("Student {0} doing homework {1} hour(s)", ID, i);
Thread.Sleep(1000);
}
}
}
}
打印结果如下,由于也发生了资源争抢,因此结果与BeginInvoke类似:
4.3.适时地使用接口(interface)取代委托
Java 完全使用接口取代了委托功能。C#也能使用接口取代委托,从而消除一些方法级别的耦合。 以前面的模板方法举列,通过接口也能实现方法的可替换:
using System;
namespace DelegateExample
{
class Program
{
static void Main(string[] args)
{
IProductFactory pizzaFactory = new PizzaFactory();
IProductFactory toyCarFactory = new ToyCarFactory();
var wrapFactory = new WrapFactory();
Box box1 = wrapFactory.WrapProduct(pizzaFactory);
Box box2 = wrapFactory.WrapProduct(toyCarFactory);
Console.WriteLine(box1.Product.Name);
Console.WriteLine(box2.Product.Name);
}
}
interface IProductFactory
{
Product Make();
}//声明一个接口IProductFactory,该接口内部只有一个返回值为Product类型的方法Make
class PizzaFactory : IProductFactory
{
public Product Make()
{
var product = new Product();
product.Name = "Pizza";
return product;
}
}
class ToyCarFactory : IProductFactory
{
public Product Make()
{
var product = new Product();
product.Name = "Toy Car";
return product;
}
}
class Product
{
public string Name { get; set; }
}
class Box
{
public Product Product { get; set; }
}
class WrapFactory
{
// 模板方法,提高复用性
public Box WrapProduct(IProductFactory productFactory)
{
var box = new Box();
Product product = productFactory.Make();
box.Product = product;
return box;
}
}
}
十三.事件详解
1.事件的定义
事件(Event),通俗说就是能够发生的某些事情。事件是类型的成员之一,是一种使对象或类能够提供通知的成员(即可以使对象或类具备通知能力)。事件发生不属于其功能,而事件发生之后的效果才是事件的功能(即通知)。自然世界中的事件一般都隶属于一个主体,如公司上市这个事件主体就是公司,而编程世界的事件的主体就是类型。
事件主体经由事件所发送的与事件本身相关的消息称为事件参数 EventArgs(又称事件信息,事件数据,事件消息),而根据通知和事件参数来采取行动的行为称作响应事件或处理事件,处理事件时所做的事情称为事件处理器 Event Handler,有些事件只有通知,没有事件参数,这样的事件发生本身就足以说明一切,不需要额外的消息。
因此,可以说事件的功能=通知+可选的事件参数(即详细信息),事件的使用方法就是用于对象或类之间的动作协调与信息传递(消息推送)
以手机的响铃事件举列:
- 手机可以通过响铃事件来通知关注手机的人
- 响铃事件让手机具备了通知关注者的能力
- 从手机角度看:响铃要求关注者采取行动;通知关注者的同时,把相关消息也发送给关注者
- 从人的角度看:人得到手机的通知,可以采取行动了;除了得到通知,还收到了事件主体者(手机)经由事件发送过来的消息 事件参数 EventArgs
- 响应事件:关注者得到通知后,检查事件参数,依据其内容采取响应的行动 处理事件具体所做的事情:事件处理器 Event Handler;如果是会议提醒:就去准备会议;如果是电话接入:选择是否接听;如果关注者在开会,直接抛弃掉事件参数,不做处理
事件模型event model(发生-响应模型)
组成部分: 发生-响应模型有五个部分
- 事件的拥有者
- 事件
- 事件的订阅者:又称事件消息的接收者、事件的响应者、事件的处理者或被事件所通知的对象
- 事件的处理器
- 事件的订阅(隐含)
如——闹钟响了你起床,五个部分分别为闹钟(事件的拥有者),响(事件),你(事件的响应者),起床(事件的处理器) ,闹钟是被你关注的(隐含的事件订阅);
孩子饿了你做饭,五个部分分别为孩子(事件的拥有者),饿(事件),你(事件的响应者),做饭(事件的处理器) ,孩子是被你关注的(隐含的事件订阅)
发生-响应模型有五个构建/运行动作:(1)我有一个事件->(2)一个人或一群人关心我的这个事件(即订阅)->(3)我的这个事件发生了->(4)关心这个事件的人会依次被通知到(通知顺序就是订阅顺序)->(5)被通知的人根据拿到的事件信息(又称事件数据,事件参数,通知)对事件进行响应(又称处理事件)
一些提示
- 事件多用于桌面、手机等开发的客户端编程,因为这些程序经常是用户通过事件来驱动的(在用户角度来看就是操作一次,程序逻辑就动一次,因此称为事件驱动)。从用户操作开始到用户看到新结果称为一次事件循环。
- 事件模型属于从现实世界抽象出来的一种客观存在,与具体的编程语言无关,任何一种语言都可以实现这种模型,而各种编程语言对这个机制的实现方法不尽相同。
- Java里没有事件这种成员,也没有委托这种数据类型,Java的事件是使用接口来实现的。
- 事件模式本身也是一种设计模式,而事件模式有一些缺陷,例如牵扯到的元素比较多(5个),不加约束的话,程序逻辑很容易变得一团乱麻。为了约束团队成员写代码时保持一致,把具有相同功能的代码写到固定的地方去,人们总结出一些最佳解决方案,逐渐形成了 MVC、MVP、MVVM 等程序架构模式。这些模式要求程序员在处理事件时有所为有所不为,代码该放到哪就放到哪,让程序更有条理。 MVC,MVP,MVVM等模式,是事件模式更高级更有效的玩法。
- 日常开发的时候,使用已有事件的机会比较多,自己声明事件的机会比较少,所以先学使用。
2.事件的应用
几个注意点:
- 事件处理器是方法成员
- 挂接事件处理器的时候,可以使用委托实例,也可以直接使用方法名,这是一个语法糖
- 事件处理器对事件的订阅不是随意的,匹配与否由声明事件时所使用的委托类型来检测
- 事件可以同步也可以异步调用
2.1.事件模型的五个组成部分
- 事件的拥有者(event source,对象)——站在事件拥有者的角度来看,事件就是一个用来通知别人的工具,事件自己是不会主动发生的,而是当事件的拥有者在完成某个内部逻辑之后,事件才会被触发发生
- 事件(event,成员)
- 事件的订阅者(event subscriber,对象)——是订阅了事件的对象或类,当一个事件发生时,被通知到的类或对象就是事件的订阅者
- 事件的处理器(event handler,成员)——本质上是一个回调方法
- 事件订阅(event source,对象)——把事件处理器与事件关联在一起,本质上是一种以委托类型为基础的约定
- 事件是一种特殊的委托,因此事件处理器订阅事件的时候必须符合该事件的委托类型,即参数类型和返回类型完全匹配才能订阅,否则无法订阅。
- +=和-=实际上是调用了委托的Combine和Remove方法,是一种语法糖。
- 挂接的逻辑是将事件处理器挂接到事件拥有者的内部的一个私有委托字段上,该字段的类型与事件处理器类型一样,外部代码通过+=和-=操作这个字段但不能直接访问它,私有委托天然支持多播,因此可以存储多个事件处理器
几者之间的关系以及常见误区:
一般来说属于事件订阅者,但也不一定:
这五个组成部分的组合方式千变万化,后续会介绍三种组合方式形成的事件订阅方式
2.2.事件订阅解决了三个问题
(1)当一个事件发生时,事件的拥有者都会通知谁?
会通知订阅该事件的类或对象(2)拿什么样的事件处理器(方法)才能够处理该事件?
当拿着一个事件处理器去订阅一个事件时,C#编译器会做非常严格的类型检查,C#规定:用于订阅事件的事件处理器必须与事件遵守同一个约定;这个约定,既约束了事件能够把什么样的消息发送给事件处理器,也约束了事件处理器能够处理什么样的消息如果事件是使用某个约定定义的,而且事件处理器也遵循同样的约定,那么【事件处理器与事件就是匹配的】,说明该事件处理器可以订阅该事件;如果不匹配,编译器就会报错
这个约定,就是委托——因此说事件是基于委托的
(3)事件的响应者具体拿哪个方法来处理该事件?
如果类或对象的多个方法都与事件匹配,那么在订阅事件时,就会告诉事件:未来会用哪个具体方法来处理事件
2.3.关于事件拥有者通过内部逻辑触发事件的实例
用户按下按钮执行操作,看似是用户的外部操作引起按钮的 Click 事件触发,实际不然,详细情况大致如下:
- 当用户点击图形界面的按钮时,实际是用户的鼠标向计算机硬件发送了一个电信号。Windows 检测到该电信号后,就查看一下鼠标当前在屏幕上的位置。当 Windows 发现鼠标位置处有个按钮,且包含该按钮的窗口处于激活状态,它就通知该按钮,用户按下了,然后按钮的内部逻辑开始执行
- 典型的逻辑是按钮快速地把自己绘制一遍,绘制成自己被按下的样子,然后记录当前的状态为被按下了。紧接着如果用户松开了鼠标,Windows 就把消息传递给按钮,按钮内部逻辑又开始执行,把自己绘制成弹起的状态,记录当前的状态为未被按下
- 按钮内部逻辑检测到,按钮被执行了连续的按下、松开动作,即按钮被点击了。按钮马上使用自己的 Click 事件通知外界,自己被点击了。如果有别的对象订阅了该按钮的 Click 事件,这些事件的订阅者就开始工作
简言之:用户操作通过 Windows 调用了按钮的内部逻辑,最终还是按钮的内部逻辑触发了 Click 事件。
2.4.事件示例
Timer 的一些成员,其中闪电符号标识的两个就是事件:
通过查看 Timer 的成员,我们不难发现一个对象最重要的三类成员:
- 属性:对象或类当前处于什么状态
- 方法:它能做什么
- 事件:它能在什么情况下通知谁
Timer Elapsed 事件示例:
using System;
using System.Timers;
namespace EventExample
{
class Program
{
static void Main(string[] args)
{
// 1.事件拥有者 timer
Timer timer = new Timer();
timer.Interval = 1000;//Interval是timer的属性,表示触发Elapsed事件的时间间隔为1000ms
// 3.事件的响应者 boy
Boy boy = new Boy();
Girl girl = new Girl();
// 2.事件成员 Elapsed,当timer度过一段时间就会触发,时间长短由编写者设定
// 5.事件订阅 +=
timer.Elapsed += boy.Action;
timer.Elapsed += girl.Action;//为Elapsed事件挂接事件处理器Action,Action2
timer.Start();//打开timer
Console.ReadLine();//timer是后台线程,依赖主线程存活,若没有Console.ReadLine()阻止主线程退出,则timer的事件来不及触发,整个进程就结束了
}
}
class Boy
{
// 这是通过 VS 自动生成的事件处理器,适合新手上手。
// 4.事件处理器 Action
internal void Action(object sender, ElapsedEventArgs e)
{
Console.WriteLine("Jump!");
}
}
class Girl
{
internal void Action(object sender, ElapsedEventArgs e)
{
Console.WriteLine("Sing!");
}
}
}
可以使用Visual Studio修补程序自动生成对应的事件处理器:
注:1.为事件挂接事件处理器(即订阅事件)的操作符是+=,+=操作符的右边要写挂接的实例方法,但要注意不能带启动器()。2.采取自动生成事件处理器的原因是:订阅事件的事件处理器必须与事件遵守同一个约定,该约定是一个委托类型;而作为初学者很难搞清楚这到底是什么委托类型;Visual Studio会自动按照这个委托类型去生产事件处理器,直接拿来填补就好。
3.几种事件订阅方式
3.1⭐事件拥有者和事件响应者是完全不同的两个对象
这种组合方式结构非常清晰,是标准的事件机制模型,也是MVC,MVP等设计模式的雏形
特点是:事件拥有者和事件响应者是完全不同的两个对象;事件响应者用自己的事件处理器订阅着这个事件,当事件发生时,事件处理器开始执行
下列代码实现了当用户点击窗体时,窗体标题栏会显示当前时间的功能
参数类型是约定的一部分,Click 事件与上例的 Elapsed 事件的第二个参数的数据类型不同,即这两个事件的约定是不同的。
也就是说,不能拿影响 Elapsed 事件的事件处理器去响应 Click 事件 —— 因为遵循的约束不同,所以他们是不通用的。
using System;
using System.Windows.Forms;
namespace EventExample
{
class Program
{
static void Main(string[] args)
{
// 1.创建窗体,事件拥有者
var form = new Form();
// 3.创建控制器,事件响应者
var controller = new Controller(form);
//显示窗体,进入消息循环
form.ShowDialog();
}
}
class Controller
{
private Form form;
public Controller(Form form)
{
if (form != null)
{
this.form = form;
// 2.事件成员 Click 5.事件订阅 +=
this.form.Click += this.FormClicked;
}
}
// 4.事件处理器
private void FormClicked(object sender, EventArgs e)
{
this.form.Text = DateTime.Now.ToString();
//让form的标题栏显示当前时间
}
}
}
-
事件模型的5个组成部分
(1)事件的拥有者:form
(2)事件:form的Click事件
(3)事件的响应者:controller
(4)事件处理器:类Controller的实例方法FormClicked()
(5)订阅事件 -
为什么FormClicked()与Action()的第二个参数不同?
因为Click事件与它的事件处理器FormClicked()共同遵守着约定A,而Elapsed事件与它的事件处理器Action()共同遵守着约定B,约定A和约定B不同,也就是遵循的约束不同;所以,不能拿响应Elapsed事件的事件处理器去响应Click事件,它们之间是不通用的 -
为什么要给类Controller声明一个Form类的实例字段?
为了架起一个桥梁
首先明确:事件是不会主动发生的,它一定是被事件拥有者的某些内部逻辑所触发,而在这个例子当中,事件拥有者和事件响应者是完全不同的两个对象,那么事件响应者如何知道事件已被触发?换句话说,事件的响应者如何被通知,从而响应事件?
所以,事件的响应者 controller 需要将事件的拥有者,也就是Form类的实例form,吸收为自己的实例字段,因为实例字段可表示该实例当前的状态,那么当form的Click事件触发后,controller就会被通知到,这就相当于架起了事件拥有者与事件响应者之间的一个桥梁 -
事件的响应者 controller 如何将事件的拥有者 form 吸收为自己的实例字段?
通过自定义类Controller的构造器
定义该构造器时,规定未来创建类Controller的实例时,必须传进一个数据类型为Form的实参,显然该实参就是事件的拥有者 form,如果 form 不为空,就把这个form赋值给类Controller的实例字段【form】(注意区分两个form:this.form = form;赋值符号左边是实例字段,右边是实参),并为实例字段form的Click事件挂接事件处理器
3.2.⭐⭐事件的拥有者和响应者是同一个对象
一个对象拿着自己的方法去订阅和处理自己的事件
该示例中事件的拥有者和响应者都是 from。示例中顺便演示了继承:
using System;
using System.Windows.Forms;
namespace EventExample
{
class Program
{
static void Main(string[] args)
{
// 1.创建窗体,事件拥有者
// 2.事件响应者
// 都是form
var form = new MyForm();
// 3.订阅事件
// 4.事件
form.Click += form.FormClicked;
//显示窗体,进入消息循环
form.ShowDialog();
}
}
// 因为无法直接修改 Form 类,所以创建了继承与 Form 类的 MyForm 类
class MyForm : Form
{
//5.事件处理器
internal void FormClicked(object sender, EventArgs e)
{
this.Text = DateTime.Now.ToString();
}
}
}
-
事件模型的5个组成部分
(1)事件的拥有者:form
(2)事件:Click事件
(3)事件的响应着:form
(4)事件处理器:FormClick()
(5)订阅事件 -
为什么要声明一个派生于Form类的子类MyForm?
因为如果直接去创建一个Form类的实例,是无法为该实例的Click事件去挂接事件处理器的,因为Form类早就已经写好了,不能修改;但如果要为了能够写事件处理器而去创建一个全新的类,则有点小题大做了,是不太现实的;所以声明一个类MyForm,它既继承了Form类的所有成员,也可以自定义事件处理器
3.3.⭐⭐⭐事件的拥有者是事件响应者的一个字段成员
事件的响应者用自己的方法订阅着自己的字段成员的某个事件,这种情况,意义重大,应用非常广泛;因为它是Windows平台上默认的事件订阅和处理结构
举例:
【按钮是窗口的字段成员】
按钮是Click事件的拥有者,而窗口则是Click事件的响应者;
当为这个窗口编程时,会为其准备一个方法(事件处理器),该方法订阅着按钮的Click事件,一旦用户点击按钮,按钮就会通过Click事件通知窗口自己被点击了,窗口就会应用自己的事件处理器去响应该事件
代码示例:
实现:在窗口中有一个文本框,一个按钮;当点击按钮时,文本框中就会显示 “hello,world!!!!” 字符串
using System;
using System.Windows.Forms;
using System.Drawing;
namespace EventExample
{
class Program
{
static void Main(string[] args)
{
//创建窗体,是事件的响应者
var form = new MyForm();
//显示窗体,进入消息循环
form.ShowDialog();
}
}
// 因为无法直接修改 Form 类,所以创建了继承与 Form 类的 MyForm 类
class MyForm : Form // 2.事件响应者
{
private Button button;
private TextBox textBox;//添加按钮和文本框,按钮是事件拥有者
public MyForm()
{
this.button = new Button();
this.textBox = new TextBox();
this.button.Text = "Click me!";
// 设置控件的位置和大小
this.textBox.Location = new System.Drawing.Point(10, 10);
this.textBox.Size = new System.Drawing.Size(200, 20);
this.button.Location = new System.Drawing.Point(10, 40);
this.button.Size = new System.Drawing.Size(100, 30);
this.Controls.Add(this.textBox);
this.Controls.Add(this.button);//将控件显示到窗体上
this.button.Click += this.ButtonClicked;//事件本身button.Click,订阅事件
}
//5.事件处理器
private void ButtonClicked(object sender, EventArgs e)
{
this.textBox.Text = "hello,world!!!!";
}
}
}
-
事件模型的5个组成部分
(1)事件的拥有者:button
(2)事件:Click事件
(3)事件的响应者:form
(4)事件处理器:ButtonClick()方法
(5)订阅事件 -
事件的响应者到底是谁?
不要以为在textbox中显示了字符串事件的响应者就是textbox,因为TextBox是微软早就准备好的类,它是不会拥有自定义的事件处理器的;唯一能修改的只有MyForm这个类,事件的响应者应是它的实例form
从上例衍生出的非可视化编程到可视化编程问题
上述代码中,由于我们在编写代码时是非可视化的,对于每个控件的位置和大小我们是无法知道运行后所展示的具体样子,所以也没办法,只能乱猜,这样就会把大量时间浪费在设计窗体上,所以我们需要可视化编程,也就是:所见即所得
打开WinForms,添加好控件和文本框(可以给他们自定义名称),代码会自动变化
(1)添加文本框,按钮控件后,在设计区右键鼠标,点击 “view code(查看代码)”,进入代码填写完整逻辑
(2)也可以自动生成事件处理器,方法是添加控件后,选中事件的拥有者button,在右侧属性面板中找到它的Click事件,填写事件处理器名称 “ButtonClicked” 后敲击回车(可以随时改名,不过改完后需要手动删掉旧处理器的代码),就会看到已经生成的好的事件处理器框架,往里添加所需逻辑即可
问题:这种情况的事件订阅在哪呢?
右击事件处理器ButtonClicked,选择 “Find All Reference(查找所有引用)”,高亮的部分就是订阅事件的表达式(运行后窗体设计器自动完成)
注:上图的订阅写法是比较老的写法,现在直接在+=后面接事件处理器名即可 ,效果一样
几点补充
1.一个事件处理器是可以重用的 (即可以同时被多个事件挂接)
但要注意:重用的前提是这个事件处理器必须与所要处理的事件保持约束上的一致
举例:
如果为窗口再添加一个button2控件,那么由于button1的事件处理器ButtonClicked与button2的Click事件遵循的是同一个约定,所以button2的Click事件也可以挂接上该事件处理器(可以在button2的属性面板中找到它的Click事件,在下拉菜单中直接选择ButtonClicked事件处理器)
注意ButtonClicked事件处理器的第一个参数:【Sender】——event source(也就是事件的拥有者,事件的source,事件消息的发送者;这也是为什么这个参数叫作 “sender” 的原因:sender的意思是:事件消息的发送者)
由于ButtonClicked与所要处理的Click事件保持约束上的一致,可以处理两个click事件,那么就可以根据事件sender(事件拥有者)的不同来决定逻辑的不同
2.一个事件可以挂接多个事件处理器
见上Timer Elapsed 事件示例:
3.挂接事件处理器的几种方法
- 最常用的挂接方式,直接写方法名:
this.button.Click += this.ButtonClicked
- 界面编辑器会采用更传统的挂接方式;visual studio会自动判断出EventHandler是事件处理器和事件所共同遵循的约定:
this.button1.Click += new System.EventHandler(this.ButtonClicked);
- 用匿名方法进行挂接(已经废弃了)
this.button.Click += delegate(object sender, EventArgs e)
{
this.textBox.Text = "Hello World";
}
- 用lambda表达式进行挂接,当使用这种写法时,编译器可以通过委托约束来推断出参数的数据类型,不写参数类型都可,如下代码可以不写object和EventArgs:
this.button.Click += (object sender, EventArgs e) =>
{
this.textBox.Text = "Hello World";
}
4.如何用WPF应用程序使用事件?
- WPF与WinForms中绝大部分是相同的,两者使用事件的方法几乎是一致的,只是WPF有种新的使用方法
- WPF发明的XAML与html是同一个家族的语言,以便设计师轻松参与到程序开发中的窗体设计部分
- 可以在XAML中直接用 : click = “…” 的格式挂接处理器,双引号中是事件处理器的名字,注意该语句要写对位置,是谁的click事件就写在谁中,不要写错,然后会在后台自动生成对应代码
- 如果用传统的委托的方式挂接事件处理器,会发现WPF中button的Click事件与WinForms中button的Click事件的所用的约束(委托类型)是不一样的,这是因为WPF是一种新技术,它的事件成为路由事件
public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); //this.Button1.Click += this.ButtonClicked; this.Button1.Click += new RoutedEventHandler(this.ButtonClicked); } private void ButtonClicked(object sender, RoutedEventArgs e) { this.TextBox1.Text = "Hello, WASPEC!"; } }
注:代码操作控件时需要对应控件有名字,不然无法操作该控件
4.自定义事件
4.1.事件的声明
事件基于委托的两重含义:1、事件需要使用委托类型来做一个约束,这个约束既规定了时间能够发送什么样的消息给响应者,也规定了事件的响应者能够收到什么样的事件消息,这就决定了事件响应者的事件处理器必须能和这个约束匹配上才能订阅该事件;2、当事件的响应者向事件的拥有者提供了能够匹配这个事件的事件处理器之后,需要一个记录该事件处理器的地方,而能够引用,即记录方法的任务只有委托类型的实例能够做到。因此说事件无论是从表层约束还是从底层实现都是依赖于委托的。
事件声明有完整声明和简略声明两种,简略声明是完整声明的语法糖。
4.1.1.完整声明
注:声明委托类型(与类同级)≠ 声明委托类型字段(在类内部)。
using System;
using System.Threading;
namespace EventExample
{
class Program
{
static void Main(string[] args)
{
// 事件拥有者
var customer = new Customer();
// 事件响应者
var waiter = new Waiter();
// 事件成员、事件订阅
customer.Order += waiter.Action;
customer.Action();
customer.PayTheBill();
}
}
// 该类用于传递点的是什么菜,作为事件参数
public class OrderEventArgs:EventArgs
{
public string DishName { get; set; }
public string Size { get; set; }
}
// 声明一个委托类型,该委托用于事件处理,同时约束事件,即前面说的事件处理器和事件共同遵守的约定
public delegate void OrderEventHandler(Customer customer, OrderEventArgs e);
//括号内为事件拥有者和消息参数,一般事件消息参数就直接写e即可
public class Customer
{
// 委托类型字段,用于存储事件处理器(委托)
private OrderEventHandler orderEventHandler;
// 事件声明,event表明这是一个事件,OrderEventHandler表明约束该事件的委托类型,Order表示事件名称
public event OrderEventHandler Order
{
add { this.orderEventHandler += value; }
remove { this.orderEventHandler -= value; }//事件处理器的挂接器和移除器
}
public double Bill { get; set; }
public void PayTheBill()
{
Console.WriteLine("I will pay ${0}.",this.Bill);
}
public void WalkIn()
{
Console.WriteLine("Walk into the restaurant");
}
public void SitDown()
{
Console.WriteLine("Sit down.");
}
public void Think()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine("Let me think ...");
Thread.Sleep(1000);
}
if (this.orderEventHandler != null)
{
var e = new OrderEventArgs();
e.DishName = "Kongpao Chicken";
e.Size = "large";
this.orderEventHandler.Invoke(this,e);
}//这个if段中this.orderEventHandler不可以改为this.Order,因为语法规定事件名只能在+=或-=的左边
}
public void Action()
{
Console.ReadLine();
this.WalkIn();
this.SitDown();
this.Think();
}
}
public class Waiter
{
// 事件处理器
public void Action(Customer customer, OrderEventArgs e)
{
Console.WriteLine("I will serve you the dish - {0}.",e.DishName);
double price = 10;
switch (e.Size)
{
case "small":
price *= 0.5;
break;
case "large":
price *= 1.5;
break;
default:
break;
}
customer.Bill += price;
}
}
}
几点注意:1、声明委托类型时应与类平级,由于C#支持嵌套类型,因此将委托类型的声明放在类体里面也不会编译报错,但还是必须将委托放到与类平级的正确位置(这是要求);2、若委托是为了声明某个事件而准备的,该委托名应为事件名+EventHandler后缀(可以提高可读性:表明该委托是用于声明事件的的,也表明该委托是用于约束事件处理器的,同时表明该委托未来创建的实例是专门用于存储事件处理器的),委托的EventArgs参数名一般为e即可;3、若一个类是用于传递事件信息的,则该类的名应为事件名+EventArgs(也是为了提高可读性),且必须继承自EventArgs类;4,因为EventArgs,EventHandler类和事件拥有者类会配合使用,所以需要保证他们的访问级别相同
4.1.2.简略声明(字段式声明,field-like)
简略格式与上例的完整格式只有事件声明和事件触发两处不同。
using System;
using System.Threading;
namespace EventExample
{
class Program
{
static void Main(string[] args)
{
// 1.事件拥有者
var customer = new Customer();
// 2.事件响应者
var waiter = new Waiter();
// 3.Order 事件成员 5. +=事件订阅
customer.Order += waiter.Action;
customer.Action();
customer.PayTheBill();
}
}
public class OrderEventArgs:EventArgs
{
public string DishName { get; set; }
public string Size { get; set; }
}
public delegate void OrderEventHandler(Customer customer, OrderEventArgs e);
public class Customer
{
// 简略事件声明,看上去像一个委托(delegate)类型字段
// 并且省略了完整声明中的主动委托字段声明
public event OrderEventHandler Order;
public double Bill { get; set; }
public void PayTheBill()
{
Console.WriteLine("I will pay ${0}.",this.Bill);
}
public void WalkIn()
{
Console.WriteLine("Walk into the restaurant");
}
public void SitDown()
{
Console.WriteLine("Sit down.");
}
public void Think()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine("Let me think ...");
Thread.Sleep(1000);
}
if (this.Order != null)
{
var e = new OrderEventArgs();
e.DishName = "Kongpao Chicken";
e.Size = "large";
// 事件触发
this.Order.Invoke(this,e);
}//这里可以写成this.Order是因为这是一种语法糖,但微软在设计时造成了语法上的前后矛盾
}
public void Action()
{
Console.ReadLine();
this.WalkIn();
this.SitDown();
this.Think();
}
}
public class Waiter
{
// 4.事件处理器
public void Action(Customer customer, OrderEventArgs e)
{
Console.WriteLine("I will serve you the dish - {0}.",e.DishName);
double price = 10;
switch (e.Size)
{
case "small":
price *= 0.5;
break;
case "large":
price *= 1.5;
break;
default:
break;
}
customer.Bill += price;
}
}
}
使用 ildasm 反编译,查看隐藏在事件简化声明背后的秘密。可以看到Customer内自动生成的Bill字段和委托类型orderEventHandler字段(访问不到,因此只能使用事件名进行比较和执行等操作,这是一种语法糖,但是设计的不太好,导致语法和语言规定有些矛盾)
4.1.3.委托类型字段能否代替事件
既然已有了委托类型字段/属性,为什么还要事件?
——因为事件成员能让程序逻辑更加“有道理”、更加安全,谨防“借刀杀人”。
真正项目中,往往很多人在同一段代码上工作,如果在语言层面未对某些功能进行限制,这种自由度很可能被程序员滥用或误用。
像下面这种使用字段的方式,和 C、C++ 里面使用函数指针是一样的,经常出现函数指针指到了一个程序员不想调用的函数上去,进而造成逻辑错误。这也是为什么 Java 彻底放弃了与函数指针相关的功能 —— Java 没有委托类型。
using System;
using System.Threading;
namespace EventExample
{
class Program
{
static void Main(string[] args)
{
Console.ReadLine();
var customer = new Customer();
var waiter = new Waiter();
customer.Order += waiter.Action;
//customer.Action();
// badGuy 借刀杀人,给 customer 强制点菜
OrderEventArgs e = new OrderEventArgs();
e.DishName = "Manhanquanxi";
e.Size = "large";
OrderEventArgs e2 = new OrderEventArgs();
e2.DishName = "Beer";
e2.Size = "large";
var badGuy = new Customer();
badGuy.Order += waiter.Action;
badGuy.Order.Invoke(customer, e);
badGuy.Order.Invoke(customer, e2);
customer.PayTheBill();
}
}
public class OrderEventArgs : EventArgs
{
public string DishName { get; set; }
public string Size { get; set; }
}
public delegate void OrderEventHandler(Customer customer, OrderEventArgs e);
public class Customer
{
// 去掉 Event,把事件声明改成委托字段声明
public OrderEventHandler Order;
public double Bill { get; set; }
public void PayTheBill()
{
Console.WriteLine("I will pay ${0}.", this.Bill);
}
public void WalkIn()
{
Console.WriteLine("Walk into the restaurant");
}
public void SitDown()
{
Console.WriteLine("Sit down.");
}
public void Think()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine("Let me think ...");
Thread.Sleep(1000);
}
if (this.Order != null)
{
var e = new OrderEventArgs();
e.DishName = "Kongpao Chicken";
e.Size = "large";
this.Order.Invoke(this, e);
}
}
public void Action()
{
Console.ReadLine();
this.WalkIn();
this.SitDown();
this.Think();
}
}
public class Waiter
{
public void Action(Customer customer, OrderEventArgs e)
{
Console.WriteLine("I will serve you the dish - {0}.", e.DishName);
double price = 10;
switch (e.Size)
{
case "small":
price *= 0.5;
break;
case "large":
price *= 1.5;
break;
default:
break;
}
customer.Bill += price;
}
}
}
上述代码将Order声明成一个委托类型的字段(该委托指向处理器),访问权限为public,因此不同的顾客Customer都能利用参数在事件拥有者Customer类外部对其Order委托字段进行操作,造成逻辑错误。若是使用event关键字显式表明Order是一个事件(此时的Order对委托进行封装),委托类型字段orderEventHandler的访问级别是private,隐藏委托字段orderEventHandler(此时该委托指向处理器),对外只暴漏对委托的挂接和删除操作,确保委托orderEventHandler的具体执行只在事件拥有者Customer类内部进行而在外部不被允许,就能保证数据的安全性。
正是为了解决 public 委托字段在类的外部被滥用或误用的问题,微软才推出了事件这个成员。
一旦将 Order 声明为事件(添加 event 关键字),就能避免上面的问题。
4.1.4.事件的本质
事件的本质是委托字段的一个包装器,这个包装器对委托字段的访问起限制作用,相当于一个蒙板,封装的一个重要功能就是隐藏,事件对外界隐藏了委托实例的大部分功能,仅暴露添加/移除事件处理器的功能
蒙板 Mask: 事件这个包装器对委托字段的访问起限制作用,让你只能给事件添加或移除事件处理器。让程序更加安全更好维护。
封装 Encapsulation: 上面的限制作用,就是面向对象的封装这个概念。把一些东西封装隐藏起来,在外部只暴露我想让你看到的东西。
4.2.命名约定
4.2.1.用于声明事件的委托类型的命名约定
用于声明Foo事件的委托,一般命名为FooEventHandler(除非是一个非常通用的事件约束,如EventHandler)
FooEventHandler委托的参数一般有俩个(由Win32 API演化而来,历史悠久)
- 第一个是object类型,名字为sender,实际上就是事件的拥有者、事件的source
- 第二个是EventArgs的派生类,类名一般为FooEventArgs,参数名为e。也就是前面讲过的事件参数
- 虽然没有官方说法,但可以把委托的参数列表看作是事件发生后发送给事件响应者的事件消息
触发Foo事件的方法一般命名为OnFoo,即因何引发,事出有因
- 访问级别为protected(自己的类成员及派生类能访问),不能为public,不然又可以借刀杀人了(即事件的触发必须由事件拥有者自己完成)
EventHandler的使用
EventHandler是一个非常通用的系统委托类型,其中C#任何类型都继承自object,所有的事件参数都继承自EventArgs,其定义代码为:
public delegate void EventHandler(object sender, EventArgs e)
using System;
using System.Threading;
namespace EventExample
{
class Program
{
static void Main(string[] args)
{
var customer = new Customer();
var waiter = new Waiter();
customer.Order += waiter.Action;
customer.Action();
customer.PayTheBill();
}
}
public class OrderEventArgs : EventArgs
{
public string DishName { get; set; }
public string Size { get; set; }
}
//public delegate void OrderEventHandler(Customer customer, OrderEventArgs e);
public class Customer
{
// 使用默认的 EventHandler,而不是声明自己的
public event EventHandler Order;
public double Bill { get; set; }
public void PayTheBill()
{
Console.WriteLine("I will pay ${0}.", this.Bill);
}
public void WalkIn()
{
Console.WriteLine("Walk into the restaurant");
}
public void SitDown()
{
Console.WriteLine("Sit down.");
}
public void Think()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine("Let me think ...");
Thread.Sleep(1000);
}
if (this.Order != null)
{
var e = new OrderEventArgs();
e.DishName = "Kongpao Chicken";
e.Size = "large";
this.Order.Invoke(this, e);
}
}
public void Action()
{
Console.ReadLine();
this.WalkIn();
this.SitDown();
this.Think();
}
}
public class Waiter
{
public void Action(object sender, EventArgs e)
{
// 类型转换
var customer = sender as Customer;
var orderInfo = e as OrderEventArgs;
Console.WriteLine("I will serve you the dish - {0}.", orderInfo.DishName);
double price = 10;
switch (orderInfo.Size)
{
case "small":
price *= 0.5;
break;
case "large":
price *= 1.5;
break;
default:
break;
}
customer.Bill += price;
}
}
}
专用于触发事件的方法
依据单一职责原则,把原来的 Think 中触发事件的部分单独提取为 OnOrder 方法。
public void Think()
{
for (int i = 0; i < 5; i++)
{
Console.WriteLine("Let me think ...");
Thread.Sleep(1000);
}
this.OnOrder("Kongpao Chicken","large");
}
protected void OnOrder(string dishName,string size)
{
if (this.orderEventHandler != null)
{
var e = new OrderEventArgs();
e.DishName = dishName;
e.Size = size;
this.orderEventHandler.Invoke(this, e);
}
}
4.2.2.事件的命名约定
- 带有时态的动词或者动词短语
- 事件拥有者正在做什么事情,用进行时,事件拥有者做完了什么事情,用完成时
5.事件与委托的关系
- 事件真的是“以特殊方式声明的委托字段/实例”吗?
- 不是!只是声明的时候“看起来像”(对比委托字段与事件的简化声明,field-like)
- 事件声明的时候使用了委托类型,简化声明造成事件看上去像一个委托的字段(实例),而 event 关键字则更像是一个修饰符 —— 这就是错觉的来源之一
- 订阅事件的时候 += 操作符后面可以是一个委托实例,这与委托实例的赋值方法语句相同,这也让事件看起来像是一个委托字段 —— 这是错觉的又一来源
- 重申:事件的本质是加装在委托字段上的一个“蒙板”(mask),是个起掩蔽作用的包装器。这个用于阻挡非法操作的“蒙板”绝不是委托字段本身
- 为什么要使用委托类型来声明事件?
- 站在 source 的角度来看,是为了表明 source 能对外传递哪些消息
- 站在 subscriber 的角度来看,它是一种约定,是为了约束能够使用什么样签名的方法来处理(响应)事件
- 委托类型的实例将用于存储(引用)事件处理器
- 对比事件与属性
- 属性不是字段 —— 很多时候属性是字段的包装器,这个包装器用来保护字段不被滥用
- 事件不是委托字段 —— 它是委托字段的包装器,这个包装器用来保护委托字段不被滥用
- 包装器永远都不可能是被包装的东西
在上图中是被迫使用事件去做 !=
和 .Invoke()
,学过事件完整声明格式,就知道事件做不了这些。在这里能这样是因为简略格式下事件背后的委托字段是编译器自动生成的,这里访问不了。
总结:事件不是委托类型字段(无论看起来多像),它是委托类型字段的包装器,限制器,从外界只能访问 += -= 操作。
十四.类
前面的总结
- 讲解了 C# 基本元素、基本语法
- 把类的成员过了一遍:字段、属性、方法、事件
- 面向对象编程最主要的三个特征是封装,继承和多态,在前面其实已经讲过了封装、后面讲继承和多态
1.什么是类
类是一种数据结构,它可以包含数据成员(常量和字段)、函数成员(方法、属性、事件、索引器、运算符、实例构造函数、静态构造函数和析构函数)以及嵌套类型。类类型支持继承,继承是一种机制,它使派生类可以对基类进行扩展和专用化。 —— 《C# 语言规范》
注:这是在描述类是什么,讲的是类的外延而不是类的内涵。
类是面向对象编程的核心。
计算机领域的类有下面三个方面
- 是一种数据结构(data structure)
- 是一种数据类型
- 代表现实世界中的“种类”
1.1.是一种数据结构
类是一种“抽象”的数据结构。
- 类本身是“抽象”的结果,例如把学生抽象为 Student 类
- 类也是“抽象”结果的载体,Student 类承载着学生的抽象(学生的 ID,学生的行为等)
这里提到的 data structure 和算法里面的 data structure 略有不同。算法里面的数据结构更多是指集合(List、Dictionary 等)数据类型。
1.2.是一种数据类型
类是一种引用类型,具体到每一个类都是一个自定义的引用类型
- 可以用类去声明变量
- 可以用类去创建实例(把类作为实例的模板)
class Program
{
static void Main(string[] args)
{
// 2.可以用类声明变量、创建实例
Student stu = new Student
{
ID=1,
Name = "Timothy"
};
// 2.类是实例的模板
Console.WriteLine(stu.ID);
Console.WriteLine(stu.Name);
stu.Report();
}
}
// 1. 类是一种数据结构
// 2. 类是一种自定义的引用类型
class Student
{
// 1.从现实世界学生抽象出来的属性
public int ID { get; set; }
public string Name { get; set; }
// 1.从现实世界学生抽象出来的行为
public void Report()
{
//$用于简化字符串拼接
Console.WriteLine($"I'm #{ID} student, my name is {Name}.");
}
}
反射与 dynamic 示例 (了解一下)
这两个示例也展现了类作为“数据类型”的一面。
反射的基础(后面会详细讲):
Type t = typeof(Student);
object o = Activator.CreateInstance(t, 1, "Timothy");
Student stu = o as Student;
Console.WriteLine(stu.Name);
dynamic编程(了解一下):
Type t = typeof(Student);
dynamic stu = Activator.CreateInstance(t, 1, "Timothy");
Console.WriteLine(stu.Name);
1.3. 代表现实世界中的“种类”
程序中的类与哲学、数学中的类有相通的地方。
class Program
{
static void Main(string[] args)
{
Student s1 = new Student(1, "Timothy");
Student s2 = new Student(2, "Jacky");
Console.WriteLine(Student.Amount);
}
}
class Student
{
// 3. Amount 代表现实世界中学生种类的个数
public static int Amount { get; set; }
static Student()
{
Amount = 100;
}
public Student(int id, string name)
{
ID = id;
Name = name;
Amount++;
}
~Student()
{
Amount--;
}
...
}
1.4.构造器与析构器
构造器(Constructor)和析构器(Destructor)是面向对象编程中的两个重要概念,它们分别用于在对象创建和销毁的时候执行特定的操作。
- 构造器
类的构造器是类的一个特殊的成员函数,没有返回类型,包括void。当创建类的新对象时就会执行构造函器,用于初始化对象的状态。默认的构造器是没有任何参数的,可以重新设置无参数的构造器,也可以为构造器设置参数,构造器的名称必须跟类名一样。
- 析构器
作用是释放资源,析构器用于在对象销毁时执行清理操作,例如释放资源、关闭文件、断开连接等。需要注意的是,C#中的垃圾回收机制会自动管理对象的内存,而不是依赖于析构器来释放内存。因此,析构器一般用于释放非托管资源(如文件句柄、数据库连接等),而不是用于释放内存。
与构造器不同,析构器在对象销毁时自动被调用,而不是在对象创建时。但是在处理过程中GC机制会进行回收,因此析构器最大的作用是提前释放资源。析构器不能有参数,不能有任何修饰符而且不能被调用。析构器与构造器的标识符不同,特点是在析构器前面需要加上前缀“~”以示区别。如果系统中没有指定析构器,那么编译器由GC(Garbage Collection,垃圾回收机制)来决定什么时候进行释放资源。
注:
- 析构函数不能被显式调用,它由垃圾回收器自动调用。
- 一个类只能有一个析构函数,不能重载。
- 析构函数与类同名,但在方法名前加上~符号。
class Program
{
static void Main(string[] args)
{
// 使用默认构造器
//Student stu = new Student();
// 一旦有了非默认构造器,系统就不在为我们生成默认构造器
Student stu = new Student(1, "Timothy");
stu.Report();
}
}
class Student
{
public Student(int id, string name)
{
ID = id;
Name = name;
}
~Student()//析构器声明
{
Console.WriteLine("Bye bye! Release the system resources ...");
}
public int ID { get; set; }
public string Name { get; set; }
public void Report()
{
Console.WriteLine($"I'm #{ID} student, my name is {Name}.");
}
}
2.类的声明与访问级别
2.1.声明类的位置
- 在名称空间内(最常见的情况),下述代码的Main方法成为类成员:
namespace HelloClass
{
class Program
{
static void Main(string[] args)
{
...
}
}
...
- 放在显式的名称空间之外实际上是声明在了全局名称空间Global里面,实际上是把类声明在名称空间的一种特殊情况。
namespace HelloClass
{
...
}
class Computer
{
...
}
- 声明在类体里面,称为成员类,成员类在学习时不常见,但实际项目中常用。
namespace HelloClass
{
class Program
{
...
class Student
{
...
}
}
}
2.2.声明(declare)与定义(define)
在C或者C++中,类的声明和定义默认是分开的(推荐),也可以手动写到一起。但是在C#或JAVA中,声明即定义 。
2.3.类声明的语法
声明语法:
语法定义很夸张,但即使是 ASP.NET Core 这么大的项目里面也没有特别复杂的类声明。 一般声明一个类都很简短,其中class关键字, identifier(类名)和class-body(类体)不可以省略。
2.4.类的访问级别
类修饰符class-modifiers包括:new, public, protected, internal, private, abstract, sealed, static,根据使用方式可以分别归类到不同的组中。
其中public和internal两者被归为访问级别组:
class 前面没有任何修饰符等于默认加了 internal。
- internal:仅在自身程序集(Assembly)里面可以访问
- public:从 Assembly 暴露出去,可以在程序集外部访问
注:VisualStudio项目之间禁止互相引用;一个项目可能会包含多个命名空间;若一个命名空间没有任何一个类暴露给外部的时候,那么这么命名空间也就不会暴露在外部了(能Using但是没啥用,而且Using的时候也没自动补全)。
右键解决方案,在主项目TestingFile外创建一个新类库项目MyLib,再在MyLib内部创建一个MyNameSpace文件夹(会自动生成一个MyNameSpace命名空间),在文件夹内创建一个Calcultor类项,然后在TestingFile内引用MyLib,研究internal和public的访问级别:
可以发现当访问级别为public时,在TestingFile项目中是能够正常using命名空间MyLib.MyNameSpace(或使用全限定名),并使用Calcultor类的
而将访问级别改为internal或者不加访问修饰符的时候,在TestingFile项目中仍然可以using命名空间MyLib.MyNameSpace或写出全限定名MyLib.MyNameSpace,但此时不会出现代码补全提示了,并且也无法使用Calcultor类
此时若在MyLib项目下再新建一个MyNameSpace1文件夹并新建一个student类项,那么可以在这个student类项中对calculator类进行访问
注:rebuild就是重新编译,若要修改编译后的程序集名称,可以进入对应项目的属性中更改
private class仅当这个class是另一个class的成员时可以这样使用
在一个类名上按下F12可以跳至其定义处
3.类的派生与继承
3.1.继承类的声明
继承类的声明只需要在identifier和class-body之间加上class-base即冒号:(表继承)+类基础(基类名或基接口名)即可。(基类派生类和父类子类是一个意思)
namespace HelloOOP
{
class Program
{
static void Main(string[] args)
{
var t = typeof(Car);
var tb = t.BaseType;
var top = tb.BaseType;
Console.WriteLine(tb.FullName);
Console.WriteLine(top.FullName);
Console.WriteLine(top.BaseType == null);
}
}
class Vehicle {}
class Car : Vehicle {}
}
.BaseType的显示逻辑:如果类型显式继承自某个类,如Class A : B,则返回B;如果类型没有显式继承,则返回object,object类没有基类,会返回null。定义一个类时,类体内部可以为空,因为他会隐式继承Object类,并自动拥有Object类的几乎所有成员(构造器和析构器除外)
注:.NET是单根的,即所有类型的继承链的顶端都是object类,当声明一个类的时候没有显式指明他的基类是谁,实际相当于在后面加上了:Object
3.2.is a 概念
表示一个派生类(子类)的实例,从语义上来说也是一个基类(父类)的实例,反过来不成立。
var car = new Car();
Console.WriteLine(car is Vehicle);
Console.WriteLine(car is Object);
var vehicle = new Vehicle();
Console.WriteLine(vehicle is Car);
可以用基类类型的变量来引用派生类实例 :
// 可以用基类类型的变量来引用派生类实例
Vehicle vehicle = new Car();
Object o1 = new Vehicle();
Object o2 = new Car();
注:此时通过基类类型变量只能访问基类定义的成员,而不能访问派生类添加的新成员,并且若方法被标记为virtual并在派生类中override,调用时执行派生类的实现,若没有重写而是隐藏,则仍然调用基类版本
3.3.一些小知识点
- sealed封闭类是不能当作基类使用的
- C#只支持继承一个基类但基接口可以有多个,因此要说一个类继承/派生自某个基类,实现了某个基接口。注:C++ 支持多继承,但它也受菱形继承的困扰
- 子类的访问权限不能超越父类
3.4.继承的本质
继承的本质是派生类在基类已有的成员基础上,对基类进行的横向和纵向的扩展。
- 横向扩展:对类成员个数的扩充
- 纵向扩展(重写):对类成员版本的更新,属于比较高级的内容
只能扩展不能缩减,无法在子类中删除继承的父类成员,这是静态类型语言(C#、C++、Java 等)的特征,继承时类成员只能越来越多。
动态类型语言(Python、JavaScript)可以在子类中移除继承的父类成员。
注:
- 子类会继承父类的几乎全部成员,构造器和析构器除外,并且这种继承会在继承链上一直传到底
- 在继承体系中,在父类添加类成员容易,但是移除难,因为修改父类会影响所有子类,并且需要重新编译依赖代码。因此在进行类或类库设计时一定要非常小心,不要贸然引进新的类成员,可能会导致后期无法移除。
- 子类类体内部为空时,其实也是隐含了继承自父类的成员,本质上还是有内容的
关于基类对象
前提:继承关系中,构造器是无法被继承的
当继承链中的类创建一个对象时,是从最顶层基类(一般是Object)的构造器开始执行,先构造一个称之为基类对象的对象,再向下一层一层用子类构造器对这个基类对象继续进行构造,最终从基类对象构造出需要的子类对象
using System;
using System.Threading;
namespace HelloOOP
{
class Program
{
static void Main(string[] args)
{
var car = new Car();
car.ShowOwner();
}
}
class Vehicle
{
public Vehicle()
{
this.Owner = "N/A";
}
public string Owner { get; set; }
}
class Car : Vehicle
{
public Car()
{
this.Owner = "Car Owner";
}
public void ShowOwner()
{
Console.WriteLine(this.Owner);
Console.WriteLine(base.Owner);//base引用的对象就是基类构造器构造出的对象,
//并且base关键字只能向上访问一层,并不能多级访问,也不能base.base这样使用
}
}
}
注:并不是同时创建多个独立的基类对象,而是会在内存中创建一个包含完整继承链的单一对象实例,也就是虽然从基类到子类依次调用了构造器,但所有的构造器都在操作同一个对象,如上述代码两次打印的都是Car Owner,因为Car类中的构造器修改了Owner的值,覆盖了之前的基类对象Owner值,因此在继承中,子类对象和基类对象最终是没有区别的,都是同一个实例
如果父类构造器是含参数的,那么在调用子类构造器时,隐含的自动调用基类构造器这一步会出错,因为需要显式传入参数
此时解决方法为:
1.在子类构造器体前加上:base(值)显式给基类构造器传入参数
class Vehicle
{
public Vehicle(string owner)
{
this.Owner = owner;
}
public string Owner { get; set; }
}
class Car : Vehicle
{
public Car():base("N/A")
{
this.Owner = "Car Owner";
}
public void ShowOwner()
{
Console.WriteLine(this.Owner);
Console.WriteLine(base.Owner);
}
}
2.直接给子类构造器也加上参数并传给基类构造器:
class Vehicle
{
public Vehicle(string owner)
{
this.Owner = owner;
}
public string Owner { get; set; }
}
class Car : Vehicle
{
public Car(string owner):base(owner){}
//在基类构造器里已经把Owner的值设置为owner参数的值了,所以不需要在Car
//的构造器内再设置一遍了,让Car的构造器为空就可以了
public void ShowOwner()
{
Console.WriteLine(this.Owner);
Console.WriteLine(base.Owner);
}
}
4.类成员的访问级别
类成员的访问级别以类的访问级别为上限(后续的接口也遵循这个规则)。即假如一个类的访问级别是internal,即使这个类内部有public的成员,那么它在别的程序集内也是看不见的
注:在团队合作中,自己写的类或方法不想被他人调用时,推荐的做法就是严格限制访问级别。如果应该封装的成员没有封装,对方只要发现能够调用,又能解决问题,他就一定会去用,进而导致一些不确定的问题。并且:推荐写项目的时候一个类单独写在一个项中,会使得项目结构更清晰
关键字 | 访问级别 |
public | |
protected internal /internal protected | |
internal | |
protected(更多应用在方法上,因为对子类进行纵向扩展,即重写和protected关系紧密) | |
private(默认是private,但还是建议加上) |
C# 7.2 推出了最新的 Private Protected: The member declared with this accessibility can be visible within the types derived from this containing type within the containing assembly. It is not visible to any types not derived from the containing type, or outside of the containing assembly. i.e., the access is limited to derived types within the containing assembly.( Private Protected 仅对程序集内的派生类可见)
注:一个类可以通过继承获得别的类的private级的类成员,但它是无法直接访问该成员的,不过某些是可以间接访问的,如下述代码虽然无法直接在Car类内部访问继承下来的_rpm字段,但是可以通过public的Speed属性来使用_rmp,证实了private int _rmp是被继承下来了:
using System;
using System.Threading;
namespace HelloOOP
{
class Program
{
static void Main(string[] args)
{
var car = new Car();
car.Accelarate();
car.Accelarate();
Console.WriteLine(car.Speed);
}
}
public class Vehicle
{
private int _rpm;
public void Accelarate()
{
_rpm += 1000;
}
public int Speed { get { return _rpm / 1000; } }
}
public class Car : Vehicle
{
}
}
命名偏好
随着越来越多 C++、Java 程序员加入 .NET 社区,private 字段的命名普遍遵循下划线 + 小写。
例:private int _rmp
面向对象的实现风格
开放心态,不要有语言之争。
我们现在学到的封装、继承、多态的风格是基于类的(Class-based)。 还有另外一个非常重要的风格就是基于原型的(Prototype-based),JavaScript 就是基于原型的面向对象。
Java 也是基于类的,让我们一撇 Java:
package comc;
public class Main {
public static void main(String[] args) {
Car car = new Car();
car.owner = "Timothy";
System.out.println(car.owner);
}
}
class Vehicle{
public String owner;
}
class Car extends Vehicle{
}
几点对C#格式的补充:
1.C#中}后面一般不加分号;
2.C#在语法上允许类成员声明在构造函数之后,但建议按照一定的顺序编写代码,如:字段-属性-构造器-方法,方法的内部的局部变量仍须遵循先声明后使用的规则
5.重写和多态
多态是基于重写的,重写:纵向扩展,成员没有增加,但成员的版本增加了
本节内容
- 类的继承
类成员的“横向扩展”(成员越来越多)——上一节主要内容
类成员的“纵向扩展”(行为改变,版本增高)——即重写,本节主要内容
类成员的隐藏(不常用)
重写与隐藏的发生条件:函数成员(指方法,属性,事件,索引器,自定义的操作符,构造器,析构器这些具有行为的代码块,一般重写比较多的是方法和属性),可见(父类成员必须对子类是可见的),签名一致(比如方法需要保证方法名,返回类型和参数一致)
- 多态(polymorphism)
基于重写机制(virtual -> override)
调用到的函数成员的具体行为(版本)由对象决定
回顾:C# 语言的变量和对象都是有类型的,所以会有“代差”
5.1.重写 (Override)
子类对父类成员的重写。
因为类成员个数还是那么多,只是更新版本,所以又称为纵向扩展。
重写需要父类成员标记为 virtual,子类成员标记 override。
注:被标记为 override 的成员,隐含也是 virtual 的,可以继续被重写。
class Program
{
static void Main(string[] args)
{
var car = new Car();
car.Run();
// Car is running!
var v = new Vehicle();
v.Run();
// I'm running!
}
}
class Vehicle
{
public virtual void Run()
{
Console.WriteLine("I'm running!");
}
}
class Car : Vehicle
{
public override void Run()
{
Console.WriteLine("Car is running!");
}
}
5.2.隐藏(Hide)
如果子类和父类中函数成员签名相同,但又没标记 virtual 和 override,则会在子类中隐藏来自父类的函数成员,这就称为隐藏。
这会导致 Car 类里面有两个 Run 方法,一个是从 Vehicle 继承的 base.Run(),一个是自己声明的 this.Run()。
用基类类型的变量来引用派生类实例:此时通过基类类型变量只能访问基类定义的成员,而不能访问派生类添加的新成员,并且若方法被标记为virtual并在派生类中override,调用时执行派生类的实现,若没有重写而是隐藏,则仍然调用基类版本
class Program
{
static void Main(string[] args)
{
Vehicle v = new Car();
v.Run();
// I'm running!
}
}
class Vehicle
{
public void Run()
{
Console.WriteLine("I'm running!");
}
}
class Car : Vehicle
{
public void Run()
{
Console.WriteLine("Car is running!");
}
}
总结一下:当使用派生类类型的变量引用派生类实例时,对于重写和隐藏,访问到的方法都是自己类体内写明的方法版本,当使用基类类型的变量来引用派生类实例时,若是重写,则访问派生类重写的版本,若是隐藏,则访问基类的版本或者说是子类中隐藏的基类版本。(可以理解为 v 作为 Vehicle 类型,它本来应该顺着继承链往下(一直到 Car)找 Run 的具体实现,但由于 Car 没有 Override,现在Car里面写明的Run是继承链之外的单独的版本,所以它找不下去,只能调用与Car实例相关的继承链上的最新Run版本,也就是Vehicle 里面的 Run或者说是Car中隐藏的Vehicle的Run版本。)当然对于基类的实例,只能使用基类类型变量引用,并且不管怎样访问的都是自己类体内的版本。
注:
- 新手在C#不必过于纠结 Override 和 Hide 的区分、关联。因为原则上是不推荐用 Hide 的。很多时候甚至会视 Hide 为一种错误
- Java 里面是天然重写,不必加 virtual 和 override,也没有 Hide 这种情况
- Java 里面的 @Override(annotation)只起到辅助检查重写是否有效的功能
5.3.多态(Polymorphism)
C# 支持用父类类型的变量引用子类类型的实例。当用这个变量调用一个被重写的成员的时,调用到的函数成员的具体行为(版本)由对象决定:总是能够调用到与这个实例相关的,并且是继承链上最新的成员版本,这就叫做多态。其设计目标是让代码能够通过基类统一处理所有子类对象。
回顾:因为 C# 语言的变量和对象都是有类型的,就导致存在变量类型与对象类型不一致的情况,所以会有“代差”。
下列代码展示了多态,同时也展示了属性被重写:
namespace HelloOOP
{
class Program
{
static void Main(string[] args)
{
Vehicle v = new Vehicle();
v.Run();
Console.WriteLine(v.Speed);
Vehicle v = new Car();
v.Run();
Console.WriteLine( v.Speed);
Console.ReadKey();
}
}
class Vehicle
{
private int _speed;
public virtual int Speed
{
get { return _speed; }
set { _speed = value; }
}
public virtual void Run()
{
Console.WriteLine("I'm running!");
_speed = 100;
}
}
class Car : Vehicle
{
private int _rpm;
public override int Speed
{
get { return _rpm / 100; }
set { _rpm = value * 100; }
}
public override void Run()
{
Console.WriteLine("Car is running!");
_rpm = 5000;
}
}
class RaceCar : Car
{
public override void Run()
{
Console.WriteLine("Race car is running!");
}
}
}
C# vs Python
Python 是对象有类型,变量没有类型的语言,Python 变量的类型永远跟着对象走。 所以在 Python 中即使重写了,也没有多态的效果。
PS:
- JS 和 Python 类似,也是对象有类型,变量没类型
- TypeScript 是基于 JS 的强类型语言,所以 TS 变量是有类型的,存在多态
十五.接口、抽象类、SOLID、单元测试、反射
接口和抽象类既是理论难点,又是代码难点。接口和抽象类用得好,写出来的代码才好测试。
注:本节 PPT 的内容不是引导大纲,是总结 PPT。
引言
软件也是工业的分支,设计严谨的软件必须经得起测试。软件能不能测试、测试出问题后好不好修复、软件整体运行状态好不好监控,都依赖于对接口和抽象类的使用。
接口和抽象类是现代面向对象的基石,也是高阶面向对象程序设计的起点。
学习设计模式的前提:
- 透彻理解并熟练使用接口和抽象类
- 深入理解 SOLID 设计原则,并在日常工作中自觉得使用它们
算法、设计原则、设计模式必须要用到工作中去,才能真正掌握。还是那句话“学习编程的重点不是学是用”。
SOLID
- SRP:Single Responsibility Principle (单一功能原则)
- OCP:Open Closed Principle (开闭原则)
- LSP:Liskov Substitution Principle (里氏替换原则)
- ISP:InterfaceSegregation Principle (口隔离原则)
- DIP:Dependency Inversion Principle (依赖反转原则)
SOLID是由罗伯特·C·马丁在21世纪早期引入的记忆术首字母缩略字,指代了面向对象编程和面向对象设计的五个基本原则,由这五个基本设计原则孕育了几十种设计模式(设计模式是设计原则的高阶固定用法),因此也可以说SOLID是设计模式之母。
首字母 | 指代 | 概念 |
S | 单一功能原则 | 对象应该仅具有一种单一功能。 |
O | 开闭原则 | 软件体应该是对于扩展开放的,但是对于修改封闭的。 |
L | 里氏替换原则 | 程序中的对象应该是可以在不改变程序正确性的前提下被它的子类所替换。 参考契约式设计。 |
I | 接口隔离原则 | 多个特定客户端接口要好于一个宽泛用途的接口。 |
D | 依赖反转原则 | 一个方法应该遵从“依赖于抽象而不是一个实例”。依赖注入是该原则的一种实现方式。 |
关于 C# 设计原则的更多知识,推荐《Agile Principles, Patterns, and Practices in C#》。
1.为做基类而生的“抽象类”与“开放/关闭原则”
抽象类和开闭原则有密切的联系。
设计原则的重要性和它在敏捷开发中扮演的重要角色:
- 之前学了类的封装与继承,理论上爱怎么用怎么用,但要写出高质量、工程化的代码,就必须遵循一些规则
- 写代码就必须和人合作,即使是你一个人写的独立软件,未来的你也会和现在的你合作
- 这些规则就如同交通规则,是为了高效协作而诞生的
硬性规定:例如变量名合法,语法合法
软性规则
1.1.抽象类
一个类里面一旦有了 abstract 成员,类就变成了抽象类,就必须在声明类时标 abstract。
抽象类内部至少有一个函数成员(指方法,属性,事件,索引器,自定义的操作符,构造器,析构器这些具有行为的代码块)未完全实现。
abstract class Student
{
//抽象方法,只有返回值,甚至连方法体的花括号都没有
abstract public void Study();
}
abstract 成员即暂未实现的成员,因为它必须在子类中被实现,所以不能是 private 。
一个类不允许实例化,它就只剩两个用处了:
- 作为基类,生成派生类,在派生类里面实现基类中的 abstract 成员
- 声明基类(抽象类)类型变量去引用子类(已实现基类中的 abstract 成员)类型的实例,这又称为多态
抽象方法的实现,看起来和 override 重写 virtual 方法有些类似,所以抽象方法在某些编程语言(如 C++)中又被称为“纯虚方法”。virtual(虚方法)还是有方法体的,只不过是等着被子类重写,abstract(纯虚方法)却连方法体都没有。
PS:我们之前学的非抽象类又称为 Concrete Class。
1.2.开闭原则
如果不是为了修复 bug 和添加新功能,别总去修改类的代码,特别是类当中函数成员的代码。
我们应该封装那些不变的、稳定的、固定的和确定的成员,而把那些不确定的,有可能改变的成员声明为抽象成员,并且留给子类去实现。
开放修复 bug 和添加新功能,关闭对类的更改。
示例
示例演示如何添加交通工具类,通过版本的迭代来讲解开闭原则、抽象类和接口。
初始版本:从 Car 直接 copy 代码到 Truck:
class Car
{
public void Run()
{
Console.WriteLine("Car is running");
}
public void Stop()
{
Console.WriteLine("Stopped");
}
}
class Truck
{
public void Run()
{
Console.WriteLine("Truck is running");
}
public void Stop()
{
Console.WriteLine("Stopped");
}
}
这就已经违反了设计原则:不能 copy paste。
提取父类版:将相同的方法提取出来放在父类里面:
class Vehicle
{
public void Stop()
{
Console.WriteLine("Stopped");
}
}
class Car : Vehicle
{
public void Run()
{
Console.WriteLine("Car is running");
}
}
class Truck : Vehicle
{
public void Run()
{
Console.WriteLine("Truck is running");
}
}
但这样会有一个问题就是 Vehicle 类型变量无法调用 Run 方法,有两种解决方法:
- Vehicle 里面添加一个带参数的 Run 方法
- 虚方法
添加带参数的 Run:
class Vehicle
{
public void Stop()
{
Console.WriteLine("Stopped!");
}
public void Run(string type)
{
if (type == "car")
{
Console.WriteLine("Car is running...");
}
else if (type == "truck")
{
Console.WriteLine("Truck is running...");
}
}
}
这就违反了开闭原则,既没有修 bug 又没有添新功能就多了个 Run 方法。而且一旦以后再添加别的交通工具类,你就又得打开(Open) Vehicle 类,修改 Run 方法。
虚方法:
class Program
{
static void Main(string[] args)
{
Vehicle v = new Car();
v.Run();
// Car is running...
}
}
class Vehicle
{
public void Stop()
{
Console.WriteLine("Stopped!");
}
public virtual void Run()
{
Console.WriteLine("Vehicle is running...");
}
}
class Car : Vehicle
{
public override void Run()
{
Console.WriteLine("Car is running...");
}
}
class Truck : Vehicle
{
public override void Run()
{
Console.WriteLine("Truck is running...");
}
}
虚方法解决了 Vehicle 类型变量调用子类 Run 方法的问题,也遗留下来一个问题:Vehicle 的 Run 方法的行为本身就很模糊,且在实际应用中也根本不会被调到。而且从测试的角度来看,测试一段你永远用不到的代码,也是不合理的。
抽象类版
要不就干脆 Run 方法里面什么都不写,进而直接把 Run 的方法体干掉,Run 就变成了一个抽象方法。于是 Vehicle 也变成了抽象类。当 Vehicle 变成抽象类后,再添加新的继承于 Vehicle 的类就很简单了,也无需修改 Vehicle 的代码。
abstract class Vehicle
{
public void Stop()
{
Console.WriteLine("Stopped!");
}
public abstract void Run();
}
class Car : Vehicle
{
public override void Run()
{
Console.WriteLine("Car is running...");
}
}
...
class RaceCar : Vehicle
{
public override void Run()
{
Console.WriteLine("Race car is running...");
}
}
不光要掌握最后虚方法的用法,还有理解之前过程中的问题,进而识别并改善工作中的代码。
纯抽象类版(接口)
有没有一种可能,一个抽象类里面的所有方法都是抽象方法?
VehicleBase 是纯虚类,它将成员的实现向下推,推到 Vehicle。Vehicle 实现了 Stop 和 Fill 后将 Run 的实现继续向下推。
// 特别抽象
abstract class VehicleBase
{
public abstract void Stop();
public abstract void Fill();
public abstract void Run();
}
// 抽象
abstract class Vehicle:VehicleBase
{
public override void Stop()
{
Console.WriteLine("Stopped!");
}
public override void Fill()
{
Console.WriteLine("Pay and fill...");
}
}
// 具体
class Car : Vehicle
{
public override void Run()
{
Console.WriteLine("Car is running...");
}
}
在 C++ 中能看到这种纯虚类的写法,但在 C# 和 Java 中,纯虚类其实就是接口。
- 因为 interface 已经表明其内部所有成员都一定默认是 public 的,所以就把 public 去掉了
- 接口本身就包含了“是纯抽象类”的含义(所有成员一定是抽象的),所以 abstract 也去掉了
- 因为 abstract 关键字去掉了,所以实现过程中的 override 关键字也去掉了
- 命名空间下接口的默认访问级别是intenal,嵌套在类中接口的默认访问级别是private;C# 8.0 之前所有接口成员隐式public,且不能显式指定修饰符,C#8.0之后允许为接口成员指定private,protected,internal等;
-
接口成员的访问级别不允许超过接口本身的访问级别。
这是为了保证:如果接口对外不可见,其成员也不应被外部直接访问(否则逻辑矛盾)。
//接口
interface VehicleBase
{
void Stop();
void Fill();
void Run();
}
//由借口下推的抽象类
abstract class Vehicle : VehicleBase
{
public void Stop()
{
Console.WriteLine("Stopped!");
}
public void Fill()
{
Console.WriteLine("Pay and fill...");
}
// Run 暂未实现,所以依然是 abstract 的
public abstract void Run();
}
//由抽象类下推的具体类
class Car : Vehicle
{
public override void Run()
{
Console.WriteLine("Car is running...");
}
}
纯虚类演变成了接口,现在的代码架构就有点像平时工作中用的了。
又因为接口在 C# 中的命名约定以 I 开头:
interface IVehicle
{
void Stop();
void Fill();
void Run();
}
1.3.总结
什么是接口和抽象类:
- 接口和抽象类都是“软件工程产物”
- 具体类 -> 抽象类 -> 接口:越来越抽象,内部实现的东西越来越少
对于一个方法来说,方法体就是它的实现;对于数据成员,如字段,它就是对类存储数据的实现。
- 抽象类是未完全实现逻辑的类(可以有字段和非 public 成员,它们代表了“具体逻辑”)
- 抽象类为复用而生:专门作为基类来使用。也具有解耦功能
解耦的具体内容留待下一节讲接口时讲
- 封装确定的,开放不确定的(开闭原则),推迟到合适的子类中去实观
- 接口是完全未实现逻辑的“类”(“纯虚类”;只有函数成员;成员全部隐式public)
抽象类中的方法只要求不是private就行,可以是protected和internal;但接口中的方法必须是public的,而且是强制隐式public
- 接口为解耦而生:“高内聚,低耦合”,方便单元测试
- 接口是一个“协约”。早已为工业生产所熟知(有分工必有协作,有协作必有协约)
- 它们都不能实例化。只能用来声明变量、引用具体类(concrete class)的实例
2.接口、依赖反转、单元测试
abstract 中的抽象方法只规定了不能是 private 的,而接口中的“抽象方法”只能是 public 的。
这样的成员访问级别就决定了接口的本质:接口是服务消费者和服务提供者之间的契约。既然是契约,那就必须是透明的,对双方都是可见的。
除了 public,abstract 的抽象方法还可以是 protected 和 internal,它们都不是给功能调用者准备的,各自有特定的可见目标。
接口即契约(contract)
契约使自由合作成为可能,所谓自由合作就是一份合同摆在这里,它即约束服务的使用者也约束服务的提供者。如果该契约的使用者和提供者有多个,它们之间还能自由组合。
2.1.接口契约实例
未使用接口时:
class Program
{
static void Main(string[] args)
{
int[] nums1 = new int[] { 1, 2, 3, 4, 5 };
ArrayList nums2 = new ArrayList { 1, 2, 3, 4, 5 };
Console.WriteLine(Sum(nums1));
Console.WriteLine(Average(nums1));
Console.WriteLine(Sum(nums2));
Console.WriteLine(Average(nums2));
}
//针对int[](强类型数组(仅存储 int))和ArrayList(非泛型集合(存储 object))的重载Sum和Average方法
static int Sum(int[] nums)
{
int sum = 0;
foreach (int num in nums)
{
sum += num;
}
return sum;
}
static double Average(int[] nums)
{
if (nums.Length == 0)
{
return 0;
}
return (double)Sum(nums) / nums.Length;
}
static int Sum(ArrayList nums)
{
int sum = 0;
foreach (var num in nums)
{
sum += (int)num;
}
return sum;
}
static double Average(ArrayList nums)
{
if (nums.Count == 0)
{
return 0;
}
return (double)Sum(nums) / nums.Count;
}
}
使用接口时:
服务提供方是 nums1 和 nums2,服务使用方是 Sum 和 Avg 这两函数。使用方需要传进来的参数可以迭代就行,别的不关心也用不到。整型数组的基类是 Array,Array 和 ArrayList 都实现了 IEnumerable接口。
static int Sum(IEnumerable nums)
{
int sum = 0;
foreach (var n in nums)
{
sum += (int)n;
}
return sum;
}
static double Avg(IEnumerable nums)
{
int sum = 0;
double count = 0;
foreach (var n in nums)
{
sum += (int)n;
count++;
}
return sum / count;
}
2.2.依赖与耦合
现实世界中有分工、合作,面向对象是对现实世界的抽象,它也有分工、合作。类与类、对象与对象间的分工、合作。在面向对象中,合作有个专业术语叫“依赖”,依赖的同时就出现了耦合。依赖越直接,耦合就越紧。
Car 与 Engine 紧耦合的示例:
class Program
{
static void Main(string[] args)
{
var engine = new Engine();
var car = new Car(engine);
car.Run(3);
Console.WriteLine(car.Speed);
}
}
class Engine
{
public int RPM { get; private set; }
public void Work(int gas)
{
this.RPM = 1000 * gas;
}
}
class Car
{
// Car 里面有个 Engine 类型的字段,它两就是紧耦合了
// Car 依赖于 Engine
private Engine _engine;
public int Speed { get; private set; }
public Car(Engine engine)
{
_engine = engine;
}
public void Run(int gas)
{
_engine.Work(gas);
this.Speed = _engine.RPM / 100;
}
}
紧耦合的问题:
- 基础类一旦出问题,上层类写得再好也没辙
- 程序调试时很难定位问题源头
- 基础类修改时,会影响写上层类的其他程序员的工作
所以程序开发中要尽量避免紧耦合,解决方法就是接口。
接口:
- 约束调用者只能调用接口中包含的方法
- 让调用者放心去调,不必关心方法怎么实现的、谁提供的
2.3.接口解耦示例
以老式手机举例,对用户来说他只关心手机可以接(打)电话和收(发)短信。对于手机厂商,接口约束了他只要造的是手机,就必须可靠实现上面的四个功能。用户如果丢了个手机,他只要再买个手机,不必关心是那个牌子的,肯定也包含这四个功能,上手就可以用。用术语来说就是“人和手机是解耦的”。
class Program
{
static void Main(string[] args)
{
//PhoneUser User = new PhoneUser(new NokiaPhone());
PhoneUser User = new PhoneUser(new EricssonPhone());
User.UsePhone();
}
}
//定义一个PhoneUser类,包含一个IPhone类型的成员变量
class PhoneUser
{
private IPhone _phone;
public PhoneUser(IPhone phone)
{
_phone = phone;
}
public void UsePhone()
{
_phone.Dial();
_phone.Pickup();
_phone.SendMessage();
_phone.ReceiveMessage();
}
}
//声明一个IPhone接口,包含拨打电话、接听电话、发送短信和接收短信的方法
interface IPhone
{
void Dial();
void Pickup();
void SendMessage();
void ReceiveMessage();
}
//在NokiaPhone类中实现IPhone接口
class NokiaPhone : IPhone
{
public void Dial()
{
Console.WriteLine("Nokia phone dialing...");
}
public void Pickup()
{
Console.WriteLine("Nokia phone picking up...");
}
public void SendMessage()
{
Console.WriteLine("Nokia phone sending message...");
}
public void ReceiveMessage()
{
Console.WriteLine("Nokia phone receiving message...");
}
}
//在EricssonPhone类中实现IPhone接口
class EricssonPhone : IPhone
{
public void Dial()
{
Console.WriteLine("Ericsson phone dialing...");
}
public void Pickup()
{
Console.WriteLine("Ericsson phone picking up...");
}
public void SendMessage()
{
Console.WriteLine("Ericsson phone sending message...");
}
public void ReceiveMessage()
{
Console.WriteLine("Ericsson phone receiving message...");
}
}
没有用接口时,如果一个类坏了,你需要 Open 它再去修改,修改时可能产生难以预料的副作用。引入接口后,耦合度大幅降低,换手机只需要换个类名,就可以了。等学了反射后,连这里的一行代码都不需要改,只要在配置文件中修改一个名字即可。
在代码中只要有可以替换的地方,就一定有接口的存在;接口就是为了解耦(松耦合)而生。
松耦合最大的好处就是让功能的提供方变得可替换,从而降低紧耦合时“功能的提供方不可替换”带来的高风险和高成本。
- 高风险:功能提供方一旦出问题,依赖于它的功能都挂
- 高成本:如果功能提供方的程序员崩了,会导致功能使用方的整个团队工作受阻
2.4.依赖反转原则
解耦在代码中的表现就是依赖反转。单元测试就是依赖反转在开发中的直接应用和直接受益者。
人类解决问题的典型思维:自顶向下,逐步求精。在面向对象里像这样来解决问题时,这些问题就变成了不同的类,且类和类之间紧耦合,它们也形成了这样的金字塔。依赖反转给了我们一种新思路,用来平衡自顶向下的单一思维方式。
平衡:不要一味推崇依赖反转,很多时候自顶向下就很好用,就该用。
2.5.单元测试
用例子来展示接口、解耦和依赖反转原则是怎么被单元测试应用的。
紧耦合:
class Program
{
static void Main(string[] args)
{
var fan = new DeskFan(new PowerSupply());
Console.WriteLine(fan.CheckWorkingStatus());
}
}
// 背景:电扇有个电源,电源输出电流越大电扇转得越快
// 电源输出有报警上限
class PowerSupply
{
public int GetPower()
{
//return 100;
return 210;
}
}
class DeskFan
{
private PowerSupply _powerSupply;
public DeskFan(PowerSupply powerSupply)
{
_powerSupply = powerSupply;
}
public string CheckWorkingStatus()
{
int power = _powerSupply.GetPower();
if (power <= 0)
{
return "Won't work.";
}
else if (power < 100)
{
return "Slow";
}
else if (power < 200)
{
return "Work fine";
}
else
{
return "Warning";
}
}
}
现在的问题是:我要测试电扇是否能按预期工作,我必须去修改 PowerSupply 里面的代码(即OPEN了PowerSupply类),这违反了开闭原则。而且可能有除了电扇外的别的电器也连到了这个电源上面(在其他位置也引用了 PowerSupply),为了测试电扇工作就去改电源,很可能会造成别的问题。
接口的产生:自底向上(重构)和自顶向下(设计)。只有对业务足够熟悉才能做到自顶向下,即一开始就知道如何设计接口,更多时候是一边写一边重构,现在我们就用接口去对电源和风扇进行解耦。
class Program
{
static void Main(string[] args)
{
var fan = new DeskFan(new PowerSupply());
Console.WriteLine(fan.Work());
}
}
public interface IPowerSupply
{
int GetPower();
}
public class PowerSupply : IPowerSupply
{
public int GetPower()
{
return 110;
}
}
public class DeskFan
{
private IPowerSupply _powerSupply;
public DeskFan(IPowerSupply powerSupply)
{
_powerSupply = powerSupply;
}
public string Work()
{
int power = _powerSupply.GetPower();
if (power <= 0)
{
return "Won't work.";
}
else if (power < 100)
{
return "Slow";
}
else if (power < 200)
{
return "Work fine";
}
else
{
return "Warning";
}
}
}
有接口后,我们就可以专门创建一个用于测试的电源类。
- 为了单元测试,将相关的类和接口都显式声明为 public
- 示例本身是个 .NET Core Console App,其相应的测试项目最好用 xUnit(官方之选)
- 测试项目命名:被测试项目名.Tests,例如 InterfaceExample.Tests
- 测试项目要引用被测试项目
- 测试项目里面的类和被测试项目的类一一对应,例如 DeskFanTests.cs
using Xunit;
namespace InterfaceExample.Tests
{
public class DeskFanTest
{
[Fact]
public void PowerLowerThanZero_OK()
{
var fan = new DeskFan(new PowerSupplyLowerThanZero());
var expected = "Won't work.";
var actual = fan.Work();
Assert.Equal(expected, actual);
}
[Fact]
public void PowerHigherThan200_Warning()
{
var fan = new DeskFan(new PowerSupplyHigherThan200());
// 注:此处为了演示,实际程序那边先故意改成了 Exploded!
var expected = "Warning";
var actual = fan.Work();
Assert.Equal(expected, actual);
}
}
class PowerSupplyLowerThanZero : IPowerSupply
{
public int GetPower()
{
return 0;
}
}
class PowerSupplyHigherThan200 : IPowerSupply
{
public int GetPower()
{
return 220;
}
}
}
每当有新的代码提交后,就将 TestCase 全部跑一遍,如果原来通过了的,这次却没有通过(称为回退),就开始 Debug。平时工作中写测试 case 和写代码的重要性是一样的,没有测试 case 监控的代码的正确性、可靠度都不能保证。
程序想要能被测试,就需要引入接口、松耦合、依赖反转。
十六.泛型,partial类,枚举,结构体
1.泛型
- 为什么需要泛型:避免成员膨胀或类型膨胀
- 正交性:泛型类型(类、接口、委托……) 泛型成员(属性、方法、字段……)
- 类型方法的参数推断
- 泛型与委托,Lambda 表达式
泛型在面向对象中的地位与接口相当。其内容很多,这里只介绍最常用最重要的部分。
1.1基本介绍
正交性:将其它的编程实体看作横轴,将泛型看作纵轴,则泛型和其它的编程实体(类,接口,委托,方法等)都有正交点,即有诸如泛型类,泛型接口等产物,导致泛型对编程的影响广泛而深刻。
如果给泛型一个全称,应该叫做泛化类型或泛化数据类型
泛化与特化或具体化是对立的概念,泛型的对象在编程的时候是不可以直接使用的,必须要经过特化才能用来编程。
1.1.1泛型类示例
示例背景:开了个小商店,一开始只卖苹果,卖的苹果用小盒子装上给顾客。顾客买到后可以打开盒子看苹果颜色。
class Program
{
static void Main(string[] args)
{
var apple = new Apple { Color = "Red" };
var box = new Box { Cargo = apple };
Console.WriteLine(box.Cargo.Color);
}
}
class Apple
{
public string Color { get; set; }
}
class Box
{
public Apple Cargo { get; set; }
}
后来小商店要增加商品(卖书),有下面几种处理方法。
一:我们专门为 Book 类添加一个 BookBox 类的盒子。
class Program
{
static void Main(string[] args)
{
var apple = new Apple { Color = "Red" };
var box = new AppleBox { Cargo = apple };
Console.WriteLine(box.Cargo.Color);
var book = new Book { Name = "New Book" };
var bookBox = new BookBox { Cargo = book };
Console.WriteLine(bookBox.Cargo.Name);
}
}
class Apple
{
public string Color { get; set; }
}
class AppleBox
{
public Apple Cargo { get; set; }
}
class Book
{
public string Name { get; set; }
}
class BookBox
{
public Book Cargo { get; set; }
}
现在代码就出现了“类型膨胀”的问题。未来随着商品种类的增多,盒子种类也须越来越多,类型膨胀,不好维护。
二:用同一个 Box 类,每增加一个商品时就给 Box 类添加一个属性。
class Program
{
static void Main(string[] args)
{
var apple = new Apple { Color = "Red" };
var book = new Book { Name = "New Book" };
var box1 = new Box { Apple = apple };
var box2 = new Box { Book = book };
}
}
class Apple
{
public string Color { get; set; }
}
class Book
{
public string Name { get; set; }
}
class Box
{
public Apple Apple { get; set; }
public Book Book { get; set; }
}
但这会导致每个 box 变量只有一个属性被使用,也就是“成员膨胀”(类中的很多成员都是用不到的)。
三:Box 类里面的 Cargo 改为 Object 类型。
class Program
{
static void Main(string[] args)
{
var apple = new Apple { Color = "Red" };
var book = new Book { Name = "New Book" };
var box1 = new Box { Cargo = apple };
var box2 = new Box { Cargo = book };
Console.WriteLine((box1.Cargo as Apple)?.Color);
//表示当box1里面装的确实是Apple时才打印Color
//如果不是,则会打印NULL值,即什么都没有
}
}
class Apple
{
public string Color { get; set; }
}
class Book
{
public string Name { get; set; }
}
class Box
{
public Object Cargo{ get; set; }
}
使用时必须进行强制类型转换或 as操作符,即这种解决办法向盒子里面装东西省事了,但取东西时很麻烦。