C#简介
.NET Framework是Microsoft为开发应用程序而创建的一个具有革命意义的平台,它有运行在其他操作系统上的版本
.NET Framework的设计方式确保它可以用于各种语言,包括本书介绍的C#语言,以及C++、Visual Basic、JScript等
.NET Framework主要包含一个庞大的代码库,可以在客户语言中通过面向对象编程技术(OOP)来使用这些代码。这个库分为多个不同的模块,这样就可以根据希望得到的结果来选择使用其中的各个部分。例如,一个模块包含Windows应用程序的构件,另一个模块包含网络编程的代码块,还有一个模块包含Web开发的代码块。一些模块还分为更具体的子模块,例如,在Web开发模块中,有用于建立Web服务的子模块
其目的是,不同操作系统可以根据各自的特性,支持其中的部分 或全部模块
.NET Framework还包含.NET公共语言运行库 (Common Language Runtime,CLR),它负责管理用.NET库开发的所有应用程序的执行
在.NET Framework下,编译代码的过程有所不同,此过程包括两个阶段
-
把代码编译为通用中间语言(Common Intermediate Language)CIL代码,这些代码并非专门用于任何一种操作系统
-
(Just-In-Time)JIT编译器把CIL编译为专用于OS和目标机器结构的本机代码,这样OS才能执行应用程序。JIT的名称反映了CIL代码仅在需要时才编译的事实。这种编译可以在应用程序的运行过程中动态发生,编译过程会在后台自动进行
目前有几种JIT编译器,每种编译器都用于不同的结构,CIL会使用合适的JIT创建所需的本机代码
程序集
编译应用程序时,所创建的CIL代码存储在一个程序集中、程序集包含可执行应用程序文件(.exe)和其他应用程序使用的库(.dll)
除CIL外,程序集还包含元数据(程序集中包含的数据的信息)和可选的资源(CIL使用的其他数据,如文件和图片)。元信息允许程序集是完全自描述的。不需要其他信息就可以使用程序集
不必把运行应用程序需要的所有信息都安装到一个地方。可以编写一些代码来执行多个应用程序所要求的任务。此时通常把这些可重用的代码放在所有应用程序都可以访问的地方。在.NET Framework中 , 这个地方 是全局程序集缓存(Global Assembly Cache,GAC),把代码放在这个缓存中是很简单的,只需把包含代码的程序集放在包含该缓存的目录中即可
托管代码
在将代码编译为CIL,再用JIT编译器将它编译为本机代码后, CLR的任务尚未全部完成,还需要管理正在执行的用.NET Framework编写的代码(这个执行代码的阶段称为运行时)
CLR管理着应用程序,其方式是管理内存、处理安全性以及允许进行跨语言调试等。相反,不受CLR控制运行的应用程序属于非托管类型,某些语言(如C++)可以用于编写此类应用程序,例如,访问操作系统的底层功能。但是在C#中,只能编写在托管环境下运行的代码。我们将使用CLR的托管功能,让.NET处理与操作系统的任何交互
垃圾回收
托管代码最重要的一个功能是垃圾回收
这种.NET方法可以确保应用程序不再使用某些内存时,就会完全释放这些内存。.NET垃圾回收会定期检查计算机内存,从中删除不再需要的内容。执行垃圾回收的时间并不固定,可能一秒钟内会进行数千次的检查,也可能几秒钟才检查一次,不过一定会进行检查
[!info]
因为在不可预知的时间执行这项工作,所以在设计应用程序时,必须留意这一点。需要许多内存才能运行的代码应自行完成清理工作,而不是坐等垃圾回收
创建.NET应用程序的所需步骤:
- 使用某种.NET兼容语言(如C#)编写应用程序代码
- 把代码编译为CIL,存储在程序集中
- 在执行代码时(如果这是一个可执行文件就自动运行,或者在其他代码使用它时运行),首先必须使用JIT编译器将程序集编译为本机代码
- 在托管的CLR环境下运行本机代码,以及其他应用程序或进程
C#是类型安全的语言,在类型之间转换时,必须遵守严格的规则。执行相同的任务时,用C#编写的代码通常比用C++编写的代码长。但C#代码更健壮,调试起来也比较简单,.NET始终可以随时跟踪数据的类型
.NET Framework没有限制应用程序的类型。C#使用的是.NET Framework,所以也没有限制应用程序的类型
变量和表达式
C#代码的外观和操作方式与cpp和Java非常类似
- C#不考虑代码中的空白字符,C#代码由一系列语句组成,每条语句都用分号结束
- C#是块结构语言,块使用花括号界定,花括号不需要附带分号。代码块可以嵌套
- C#代码区分大小写
可以使用#region
和#endregion
关键字(以#开头实际上是预处理指令,并不是关键字)来定义要展开和折叠的代码区域的开头和结尾
#region /*注释*/ //代码块 #endregion
整数类型
//介于–128和127之间的整数 sbyte System.SByte //介于0和255之间的整数 byte System.Byte //介于–32 768和32 767之间的整数 short System.Int16 //介于0和65 535之间的整数 ushort System.UInt16 //介于–2 147 483 648和2 147 483 647之间的整数 int System.Int32 //介于0和4 294 967 295之间的整数 uint System.UInt32 //介于–9 223 372 036 854 775 808和9 223 372 036 854 775 807之间的整数 long System.Int64 //介于0和18 446 744 073 709 551 615 之间的整数 ulong System.UInt64
这些类型中的每一种都利用了.NET Framework中定义的标准类型,使用标准类型可以在语言之间交互操作,u是unsigned的缩写
浮点类型
前两种可以用+/–m×2^e的形式存储浮点数,m和e的值因类型而异。decimal
使用另一种形式:+/– m×10^e
//m:0~2^24,e:-149~104 float System.Single //m:0~2^53,e:-1075~970 double System.Double //m:0~2^96,e:-28-0 decimal System.Decimal
文本和布尔类型
//1个Unicode字符,存储0~65 535之间的整数 char System.Char //字符串,字符数量没有上限 string System.String //布尔值 bool System.Boolean
变量命名规则
- 首字符必须是字母、下划线或@
- 其后的字符可以是字母、下划线或数字
字面值转义字符
\0 //null 0x0000 \a //警告蜂鸣 0x0007 \b //退格 0x0008 \f //换页 0x000C \n //换行 0x000A \r //回车 0x000D \t //水平制表符 0x0009 \v //垂直制表符 0x000B //可以使用\u后跟一个4位16进制值来使用对应的Unicode转义字符 \u000D
也可以一字不变地指定字符串,即两个双引号之间的所有字符都包含在字符串中,包括行末字符和原本需要转义的字符
Console.WriteLine("Verbatim string literal: item 1");//error //开头使用@,一字不变地指定字符串,无需使用转义字符 Console.WriteLine(@"Verbatim string literal: item 1");
字符串是引用类型,可赋予null
值,表示字符串变量不引用字符串
表达式
把操作数(变量和字面值)与运算符组合起来,就可以创建表达式,它是计算的基本构件
var1 = +var2//var1的值等于var2的值 var1 += var2//var1的值等于var1加var2,不会把负值变为正数 var1 = -var2//var1的值等于var2乘以-1 var1 =- var2//var1的值等于var1减var2
注意区分它们,前者是一元运算符,结合的是操作数
class Entrance //用数学运算符处理变量 { static void Main(string[] args) { double firstNumber, secondNumber; string userName; Console.WriteLine("Enter your name:"); userName = Console.ReadLine(); Console.WriteLine($"Welcome {userName}!"); Console.WriteLine("Now give me a number:"); //Readline得到的是字符串,需要显式转换 firstNumber = Convert.ToDouble(Console.ReadLine()); Console.WriteLine("Now give me another number:"); secondNumber = Convert.ToDouble(Console.ReadLine()); Console.WriteLine($"The sum of {firstNumber} and {secondNumber} is " + $"{firstNumber + secondNumber}."); Console.WriteLine($"The result of subtracting {secondNumber} from " + $"{firstNumber} is {firstNumber - secondNumber}."); Console.WriteLine($"The product of {firstNumber} and {secondNumber} " + $"is {firstNumber * secondNumber}."); Console.WriteLine($"The result of dividing {firstNumber} by " + $"{secondNumber} is {firstNumber / secondNumber}."); Console.WriteLine($"The remainder after dividing {firstNumber} by " + $"{secondNumber} is {firstNumber % secondNumber}."); Console.ReadKey(); } }
[!tip]
和+运算符一样,+=运算符也可以用于字符串
运算符优先级
由高到底:
- ++、--(前缀),+、-(一元)
- *、/、%
- +、-
- <<、>>
- <、>、<=、>=
- ==、!=
- &
- ^
- |
- &&
- ||
- =、*=、/=、%=、+=、-=
- ++、--(后缀)
括号可用于重写优先级
命名空间
命名空间的主要目的是避免命名冲突,并提供一种组织代码的方式,使得代码更加清晰和易于管理
命名空间可以嵌套在其他命名空间中,形成一个层次结构
默认情况下,C#代码包含在全局名称空间中。这意味着对于包含在这段代码中的项,全局名称空间中的其他代码只需通过名称进行引用就可以访问它们
可以使用namespace
关键字为花括号中的代码块显式定义命名空间,如果在该命名空间代码的外部使用该命名空间中的名称,就必须写出该命名空间中的限定名称
如果一个命名空间中的代码需要使用在另一个命名空间中定义的名称,就必须包括对该命名空间的引用。限定名称在不同的命名空间级别之间使用点字符(.)
using
语句本身不能访问另一个命名空间,除非命名空间中的代码以某种方式链接到项目上,或者代码是在该项目的源文件中定义的,或者是在链接到该项目的其他代码中定义的,否则就不能访问其中包含的名称
如果包含名称空间的代码链接到项目上,那么无论是否使用using
,都可以访问其中包含的名称。using
语句便于我们访问这些名称,减少代码量,以及提高可读性
[!info]
C#6新增了using static
关键字,允许把静态成员直接包含到C#程序的作用域中
把using static System.Console
添加到名称空间列表中时,访问WriteLine()
方法就不再需要在前面加上静态类名
流程控制
19世纪中叶的英国数学家乔治●布尔为布尔逻辑奠定了基础
布尔赋值运算符可以把布尔比较与赋值组合起来,与数学赋值运算符相同
var1 &= var2//var1的值是var1&var2的结果 var1 |= var2//var1的值是var1|var2的结果 var1 ^= var2//var1的值是var1 ^ var2的结果
[!quote]
多数流程控制语句在cpp中已学习,无需笔记
switch
语句的基本结构如下:
switch (expression) { case value1: //当expression等于value1时执行的代码 break; case value2: //当expression等于value2时执行的代码 break; //可以有多个case语句 default: //如果expression的值与任何case都不匹配,则执行default部分的代码 break; }
[!caution]
switch
语句在c++中执行完一个case
语句可以继续运行其他case
语句,直到遇到break
但C#中不行,在执行完 一个case
块后,再执行第二个case
语句是非法的
也可以使用return
语句,中断当前函数的运行,不仅是中断switch
结构的执行。也可以使用goto
语句,因为case
语句实际上是在C#代码中定义的标签:goto case:...
这些条件也适用于default语句。default
语句不一定要放在比较操作列表的最后,还可以把它和case
语句放在一起。用break
或return
添加一个断点,可确保在任何情况下,该结构都有一条有效的执行路径
using static System.Console; using System; class Test { static void Main(string[] args) { const string myName = "god"; const string niceName = "pjl"; const string sillyName = "xwj"; string name; WriteLine("What is your name?"); name = ReadLine(); switch (name.ToLower()) { case myName: WriteLine("You have the same name as me!"); break; case niceName: WriteLine("My, what a nice name you have!"); break; case sillyName: WriteLine("That's a very silly name."); break; } WriteLine($"Hello {name}!"); } }
变量的更多内容
[!important] 隐式转换规则
任何类型A,只要其取值范围完全包含在类型B的取值范围内,就可以隐式转换为类型B如果类型A中的值在类型B的取值范围内,也可以转换该值,但必须使用显式转换
显式转换
//显式类型转换,彼此之间几乎没有什么关系的类型或根本没有关系的类型不能进行强制转换 (destinationType)sourceVar
当使用checked
上下文时,如果整数运算的结果超出了该整数类型的表示范围,则会引发一个OverflowException
异常。这通常用于确保算术运算不会导致数据丢失或错误的结果
设置溢出检查上下文:
int a = 281; byte b;//byte表示范围:0~255 b = (byte)a;//系统无视转换造成的数据丢失或错误 b = checked((byte)a);//会引发一个OverflowException异常
uncecked
则表示不检查,不会引发异常
可以配置应用程序,让这种类型的表达式都和包含checked
关键字一样,在vistual studio2022中的Solution Exploer打开Properties,选择Build中的Advanced,勾选Check for arithmetic overflow
此后只要不显示使用unchecked
都会默认检查整数类型的算术运算结果是否溢出
使用Convert命令进行显式转换
使用ToDouble()
把Number
字符串转换为double
值,将引发异常
为成功执行此类转换,所提供的字符串必须是数值的有效表达方式,该数还必须是不会溢出的数
数值的有效表达方式是:首先是一个可选符号(+/-),然后是0位或多位数字,一个可选的句点(.)后跟1位或多位数字,还有一个可选的e/E,后跟一个可选符号和1位或多位数字,除了还可能有空格(在这个序列之前或之后),不能有其他字符
利用这些可选的额外数据,可将–1.2451e–24
这样复杂的字符串识别为数值
对于此类转换,总是会进行溢出检查,unchecked
关键字和项目属性设置不起作用
//转换示例 using System; using static System.Console; using static System.Convert; class Test{ static void Main(string[] args) { short shortResult, shortVal = 4; int integerVal = 67; long longResult; float floatVal = 10.5F; double doubleResult, doubleVal = 99.999; string stringResult, stringVal = "17"; bool boolVal = true; WriteLine("Variable Conversion Examples\n"); //float和short相乘,double可以容纳它们,因此隐式转换 doubleResult = floatVal * shortVal; WriteLine($"Implicit, -> double: {floatVal} * {shortVal} -> {doubleResult}"); //float显式转换为short,会截断小数部分 shortResult = (short)floatVal; WriteLine($"Explicit, -> short: {floatVal} -> {shortResult}"); //Convert.string将bool和double类型显式转换为字符串并拼接 stringResult = Convert.ToString(boolVal) + Convert.ToString(doubleVal); WriteLine($"Explicit, -> string: \"{boolVal}\" + \" {doubleVal}\" -> " + $"{stringResult}"); //string显式转换为long,与int相加,自然long longResult = integerVal + ToInt64(stringVal); WriteLine($"Mixed, -> long: {integerVal} + {stringVal} - > {longResult}"); ReadKey(); } }
复杂的变量类型
枚举enum
枚举是值类型,枚举使用一个基本类型来存储,枚举类型可取的每个值都存储为该基本类型的一个值,默认情况下为int
,可使用enum 枚举名 : 类型名
来指定该枚举的底层类型
enum Days{ Sunday,//0 Monday,//1 Tuesday,//2,以此类推 Wednesday, Thursday, Friday, Saturday } class Test{ static void Main(){ //使用枚举 Days today = Days.Monday; //输出枚举的值(整数值) Console.WriteLine((int)today); //输出1 //输出枚举的名称 Console.WriteLine(today); //输出Monday //显式地将整数转换为枚举类型 Days day = (Days)2; Console.WriteLine(day); //输出Tuesday // 枚举类型的比较 if (today == Days.Monday) Console.WriteLine("Today is Monday."); } }
枚举的基本类型可以是byte、sbyte、short、ushort、int、uint、 long
和ulong
-
默认情况下,每个值都会根据定义的顺序被自动赋予对应的基本类型值。可以使用赋值运算符来指定每个枚举的实际值
-
可以使用一个值作为另一个枚举的基础值,为多个枚举指定相同的值
-
未赋值的任何值都会自动获得一个初始值,该值比上一个明确声明的值大1
结构struct
结构是值类型,可以组合多个数据成员到一个单一的类型中,通常用于表示小型的数据集合
struct Point { public int X; //公共字段 public int Y; //公共字段 //结构可以包含方法 public void Move(int deltaX, int deltaY) { X += deltaX; Y += deltaY; } } class Program { static void Main() { //创建结构的实例 Point point = new Point(); point.X = 10; point.Y = 20; //调用结构中的方法 point.Move(5, 5); //输出点的坐标 Console.WriteLine($"Point coordinates: ({point.X}, {point.Y})"); //由于结构是值类型,所以将它传递给方法时,会传递它的一个副本 //可以使用ref或out关键字来传递它本身 MovePoint(point); //输出点的坐标,它不会改变,因为MovePoint方法接收的是副本 Console.WriteLine($"Point coordinates after MovePoint: ({point.X}, {point.Y})"); } static void MovePoint(Point p) { p.X += 10; p.Y += 10; } }
[!warning]
cpp的结构体默认是public
,但C#不是
从C#7.2开始,结构体的成员默认是private
,结构体本身是类型,可见性取决于上下文
数组
//字面值形式初始化数组,不能声明大小 int[] Array = {1,3,5,7,9}; //指定数组大小的初始化,会给所有数组元素赋予同一个默认值,数值类型是0,C#允许使用非常量的变量初始化数组 int[] Array = new int[5]; //可以组合使用这两种初始化方式 int[] Array = new int[5] {1,3,5,7,9}; //使用这种方式,数组大小必须与元素个数相匹配,且必须使用常量定义大小
foreach循环
foreach
循环可以使用一种简便的语法来定位数组中的每个元素(和C++的范围for很像)
foreach(变量类型 变量名 in 数组名)
不过注意,foreach
循环对数组内容只读访问,不能改变任何元素的值
for
循环才可以给数组元素赋值
多维数组
多维数组只需要更多逗号
//零初始化 double[,]four = new double[3,4] //字面值初始化 double[,] hillHeight = { { 1, 2, 3, 4 }, { 2, 3, 4, 5 }, { 3, 4, 5, 6 } };
foreach
循环可以访问多维数组中的所有元素,其方式与访问一维数组相同
交错数组(数组的数组)
多维数组可称为矩形数组,因为每一行的元素个数都相同,而交错数组每行的元素个数可能不同,其中的每一个元素都是另一个数组,这些数组都必须具有相同的基本类型
交错数组的初始化比多维数组麻烦
//声明创建主数组 int[][] jaggedArray = new int[3][] //然后依次初始化子数组 jaggedArray[0] = new int[3]; jaggedArray[1] = new int[4]; jaggedArray[2] = new int[5]; //或者提供初始化表达式一次性初始化整个交错数组 jaggedArray = new int[][]{ new int[] {1,2,3}, new int[] {1,2,3,4}, new int[] {1,2,3,4,5} };
遍历交错数组也是复杂的多,若非必要无需使用
字符串的处理
string
类型的变量可以看成char
变量的只读数组
string myString = "A string"; char myChar = myString[1]; //但不能采用这种方式为各个字符赋值,它是只读数组 //使用数组变量的ToCharArray()可以将一个字符串转换为一个字符数组并返回,以此获得一个可写的char数组 char[] myChars = myString.ToCharArray(); //在foreach循环中使用字符串 foreach(var character in myString){ WriteLine(character); }
与数组一样,还可以使用.Length
获取元素个数,这将给出字符串中的字符数
.ToLower()
和.ToUpper()
可以分别把字符串转换为小写或大写形式
.Trim()
删除字符串前后的空格,也可以删除其他字符,只需在一个char
数组中指定这些字符即可
char[] trimChars = {' ', 'e', 's'}; userResponse = userResponse.Trim(trimChars);//删除trimChars数组指定的字符
.TrimStart()
和.TrimEnd()
命令可以把字符串前面或后面的空格删掉,使用这些命令时也可以指定char
数组
.PadLeft()
和.PadRight()
可以在字符串的左边或右边添加空格,使字符串达到指定的长度
.Replace("n1","n2")
用n2替换n1并返回
.Split()
用于将一个字符串拆分成一个子字符串数组。这个方法根据指定的分隔符将字符串分割成多个部分,并返回这些部分作为字符串数组
这些命令和之前的其他命令一样,不会真正改变应用它的字符串。把这个命令与字符串结合使用,就会创建一个新的字符串
函数
函数的定义包括函数名、返回类型以及一个参数列表,这个参数列表指定了该函数需要的参数数量和参数类型,函数的名称和参数共同定义了函数的签名
执行一行代码的函数可使用C#6引入的表达式体方法(expression-bodied method),使用=>(Lambda箭头)来实现这一功能
static double Multiply(double myVal1, double myVal2) { return myVal1 * myVal2; } //使用表达式体方法 static double Multiply(double myVal1, double myVal2) => myVal1 * myVal2;
参数数组
C#允许为函数指定一个(也只能指定一个)特殊参数,这个参数必须是函数定义中的最后一个参数,称为参数数组
参数数组允许使用数量不定的参数调用函数,可使用params
关键字定义它们
参数数组可以简化代码,因为在调用代码中不必传递数组,而是传递同类型的几个参数,这些参数会放在可在函数中使用的一个数组中
static 返回类型 函数名 (参数,params 类型名[] 数组名){ //codes } static int SumValues(params int[] vals) { int sum = 0; foreach (int val in vals) sum += val; return sum; }
引用参数和值参数
引用传递变量本身,值传递变量副本
//ref关键字指定参数既可引用传递 static void ShowDouble(ref int val) { val *= 2; WriteLine($"val doubled = {val}"); } ShowDouble(ref Number);//函数调用时也必须显式指定
ref
参数的变量不能是常量,且必须使用初始化过的变量,C#不允许ref
参数在使用它的函数中初始化
输出参数
可以使用out
关键字指定所给的参数是一个输出参数,out
关键字使用方式与ref
关键字相同(在函数定义和函数调用中用作参数的修饰符)
它的执行方式与引用参数几乎完全一样,因为在函数执行完毕后,该参数的值将返回给函数调用中使用的变量。但是二者存在一些重要区别:
- 把未赋值的变量用作
ref
参数是非法的,但可以把未赋值的变量用作out
参数,不过在方法内部必须对其进行赋值 - 在函数使用
out
参数时,必须把它看成尚未赋值,即调用代码可以把已赋值的变量用作out
参数,但存储在该变量中的值会在函数执行时丢失
使用场景:
ref
参数:用于方法内部需要读取和更新已知初始状态的参数out
参数:用于将一个或多个新生成的值从方法中传出
使用static
或const
关键字来定义全局变量。如果要修改全局变量的值,就需要使用static
,因为const
禁止修改变量的值
如果局部变量和全局变量同名,会屏蔽全局变量
Main()
是C#应用程序的入口点,执行这个函数就是执行应用程序,Main
函数可以返回void
或int
,有一个可选参数string[] args
Main
函数可使用如下4种版本:
static void Main() static void Main(string[] args) static int Main() static int Main(string[] args)
返回的int
值可以表示应用程序的终止方式,通常用作一种错误提示
可选参数args
是从应用程序外部接受信息的方法,这些信息在运行应用程序时以命令行参数的形式指定。在执行控制台应用程序时,指定的任何命令行参数都放在这个args
数组中
结构函数
结构除了数据还可以包含函数
struct CustomerName{ public string firstName,lastName; public string Name() => firstName + " " + lastName; }
把函数添加到结构中,就可以集中处理常见任务,从而简化这个过程
static
关键字不是结构函数所必需的
函数重载
函数的返回类型不是其签名的一部分,所以不能定义两个仅返回类型不同的函数,它们实际上有相同的签名
委托
委托是一种存储函数引用的类型
委托的声明类似于函数,但不带函数体,且要使用delegate
关键字。委托的声明指定了一个返回类型和参数列表
定义委托后,就可以声明该委托类型的变量,把这个变量初始化为与委托具有相同返回类型和参数列表的函数引用,就可以使用该委托变量调用该函数
有了引用函数的变量,就可以执行无法用其他方式完成的操作。例如,可以把委托变量作为参数传递给一个函数,该函数就可以使用委托调用它引用的任何函数,而且在运行之前不必知道调用的是哪个函数
class Test { //定义委托,接受两个double参数,返回double类型 //实际使用名称任意,因此可以给委托类型和参数指定任意名称 delegate double ProcessDelegate(double param1, double param2); //定义两个静态方法 static double Multiply(double param1, double param2) => param1 * param2; static double Divide(double param1, double param2) => param1 / param2; static void Main(string[] args) { //声明一个委托变量 ProcessDelegate process; WriteLine("Enter 2 numbers separated with a comma:"); string input = ReadLine(); int commaPos = input.IndexOf(','); double param1 = ToDouble(input.Substring(0, commaPos)); double param2 = ToDouble(input.Substring(commaPos + 1, input.Length - commaPos - 1)); WriteLine("Enter M to multiply or D to divide:"); input = ReadLine(); if (input == "M") //要把一个函数引用赋给委托变量,需要使用略显古怪的语法 /*类似于给数组赋值,必须使用new关键字创建一个新委托 在new后指定委托类型,提供引用所需函数的参数 参数是使用的函数名但不带括号 该参数与委托类型或目标函数的参数不匹配,这是委托赋值的特殊语法*/ process = new ProcessDelegate(Multiply); else process = new ProcessDelegate(Divide); //使用委托调用所选的函数 WriteLine($"Result: {process(param1, param2)}"); } }
也可以使用略微简单的语法来将一个函数引用赋给委托变量:
if (input == "M") process = Multiply; else process = Divide;
编译器会发现process
变量的委托类型匹配两个函数的签名,于是自动初始化一个委托。可以自行确定使用哪种语法
已引用函数的委托变量就像函数一样使用,但比起函数可以执行更多操作,例如可以通过参数将其传递给下一个函数
static void ExecuteFunction(ProcessDelegate process) => process(2.2, 3.3);
调试和错误处理
输出调试信息
Debug.WriteLine()
Trace.WriteLine()
这两个命令函数用法几乎完全相同,但一个命令仅在调试模式下运行,而第二个命令还可用于发布程序。Debug.WriteLine()
不能编译到可发布的程序在,在分布版本中,该命令会消失,编译好的代码文件会比较小
这两种方法包含在System.Diagnostics
命名空间内
它们唯一的字符串参数用于输出消息,而不使用{X}
语法插入变量值。这意味着必须使用+
串联运算符等方式在字符串中插入变量值
它们可以有第二个字符串参数,用于显示输出文本的类别
using System.Diagnostics; using static System.Console; namespace DeBug { class Program { static void Main(string[] args) { int[] testArray = { 4, 7, 4, 2, 7, 3, 7, 8, 3, 9, 1, 9 }; //存储最大值出现的所有索引 int[] maxValIndices; //存储返回的最大值 int maxVal = Maxima(testArray, out maxValIndices); WriteLine($"Maximum value {maxVal} found at element indices:"); foreach (int index in maxValIndices) WriteLine($"Maximum index:{index}"); } static int Maxima(int[] integers, out int[] indices) { Debug.WriteLine("Maximum value search started."); //初始化为长度为1的新数组 indices = new int[1]; //初始化最大值为数组第一个元素 int maxVal = integers[0]; //存储最大值索引 indices[0] = 0; //存储最大值个数 int count = 1; Debug.WriteLine(string.Format($"Maximum value initialized to {maxVal}, at element index 0.")); //循环忽略第一个值,因为已处理 for (int i = 1; i < integers.Length; i++) { Debug.WriteLine(string.Format($"Now looking at element at index {i}.") ); if (integers[i] > maxVal) { maxVal = integers[i]; count = 1; indices = new int[1]; indices[0] = i; Debug.WriteLine(string.Format($"New maximum found. New value is {maxVal}, at element index {i}.")); } else { if (integers[i] == maxVal) { ++count; //创建对现有数组indices的引用,它们指向同一块内存区域 int[] oldIndices = indices; indices = new int[count]; //从索引0开始把indices数组的内容复制到oldIndices数组 oldIndices.CopyTo(indices, 0); indices[count - 1] = i; Debug.WriteLine(string.Format($"Duplicate maximum found at element index {i}.")); } } } Trace.WriteLine(string.Format($"Maximum value {maxVal} found, with {count} occurrences.")); Debug.WriteLine("Maximum value search completed."); return maxVal; } } }
各个文本部分都使用Debug.WriteLine()
和Trace.WriteLine()
函数进行输出,这些函数使用string.Format()
函数把变量值嵌套在字符串中,其方式与WriteLine()
相同。这比使用+
串联运算符更高效
Debug.WriteLine(string.Format($"Duplicate maximum found at element index {i}.")); Debug.WriteLine(string.Format("Duplicate maximum found at element index {0}.",i)); //字符串差值 Debug.WriteLine($"Duplicate maximum found at element index {i}."); //传统字符串格式化 Debug.WriteLine("Duplicate maximum found at element index {0}.",i);
经本人测试,这四种方法都可以正常输出,如果在旧版不支持字符串插值C#或需要更复杂的格式化选项,如自定义数字、日期或其他类型格式时还是可选用string.Format
,一般情况字符串插值更间接明了
[!note] 跟踪点
vistual studio自带的,可以便捷地添加额外信息和删除,和打断点一样,只是要在actions里选择在output里输出的信息跟踪点和Trace命令并不等价,在应用程序的已编译版本中,跟踪点是不存在的,只有应用程序运行在VS调试器中时,跟踪点才起作用
中断模式
除了vs自带的断点,还可以生成一条判定语句时中断
判定语句是可以用用户定义的消息中断应用程序的指令。它们常用于应用程序的开发过程,作为测试程序能否平滑运行的一种方式
判定函数也有两个版本:
Debug.Assert()
Trace.Assert()
两个函数都是三参数:第1个参数是布尔值,其值为false
时触发判定语句,第2、3个参数是字符串,分别将信息写到弹出对话框和output窗口
Trace.Assert(i > 10, "Variable out of bounds.", "Please contact vendor with the error code KCW001.");
错误处理
预料到错误的发生,编写足够健壮的代码以处理这些错误,而不必中断程序的执行
C#包含结构化异常处理SEH(Structured Exception Handling)的语法。用3个关键字(try
、catch
、finally
)可以标记出能处理异常的代码和指令,如果发生异常,就使用这些指令处理异常
可以在catch
或finally
块内使用async/await
关键字,用于支持先进的异步编程技术,避免瓶颈,且可以提高应用程序的总体性能和响应能力
C#7.0引入了throw
表达式可以与catch
块配合使用
可以只有try
块和finally
块,而没有catch
块,或者有一个try
块和好几个catch
块。如果有一个或多个catch
块,finally
块就是可选的
-
try
包含抛出异常的代码(在谈到异常时,C#语言用“抛出”这个术语表示“生成”或“导致”) -
catch
包含抛出异常时要执行的代码,catch
块可以使用<exceptionType>
,设置为只响应特定的异常类型(如System.IndexOutOfRangeException)以便提供多个catch
块
还可以完全省略这个参数,让通用的catch
块响应所有异常
C#6引入了一个概念“异常过滤”,通过在异常类型表达式后添加when
关键字来实现。如果发生了该异常类型,且过滤表达式是true
, 就执行catch
块中的代码 -
finally
包含始终会执行的代码,如果没有产生异常,则在try
块之后执行,如果处理了异常,就在catch
块后执行,或者在未处理的异常“上移到调用堆栈”之前执行
“上移到调用堆栈”表示:SEH允许嵌套try…catch…finally
块,可以直接嵌套,也可以在try
块包含的函数调用中嵌套。例如如果在被调用的函数中没有catch
块能处理某个异常,就由调用代码中的catch
块处理。如果始终没有匹配的catch
块,就终止应用程序finally
块在此之前处理正是其存在的意义,否则也可以在try…catch…finally
结构的外部放置代码
[!info]
如果存在两个处理相同异常类型的catch
块,就只执行异常过滤器为true
的catch
块中的代码。如果还存在一个处理相同异常类型的catch
块,但没有异常过滤器或异常过滤器是false
,就忽略它。只执行一个catch
块的代码,catch
块的顺序不影响执行流
using static System.Console; class Test { /*none不抛出异常 simple生成一般异常 index生成IndexOutOfRangeException异常 nested index和filter生成异常和上者相同*/ //这些异常标识符包含在全局数组中 static string[] eTypes = { "none", "simple", "index", "nested index", "filter" }; static void Main(string[] args) { foreach (string eType in eTypes) { try { WriteLine("Main() try block reached."); WriteLine($"ThrowException(\"{eType}\") called."); ThrowException(eType); WriteLine("Main() try block continues."); } //仅当eType是filter时捕获越界异常 catch (System.IndexOutOfRangeException e) when (eType == "filter") { WriteLine("Main() FILTERED System.IndexOutOfRangeException" + $"catch block reached. Message:\n\" {e.Message}\""); } //捕获所有其他未被第一个catch块捕获的索引越界异常 catch (System.IndexOutOfRangeException e) { WriteLine("Main() System.IndexOutOfRangeException catch " + $"block reached. Message:\n\" {e.Message}\""); } //未指定异常类型,会捕获所有未被捕获的其他类型的异常 catch { WriteLine("Main() general catch block reached."); } //无论异常发生都会执行,表示异常块结束 finally { WriteLine("Main() finally block reached."); } } } //根据传递的异常执行相应的操作 static void ThrowException(string exceptionType) { WriteLine($"ThrowException(\"{exceptionType}\") reached."); switch (exceptionType) { case "none": WriteLine("Not throwing an exception."); break; case "simple": WriteLine("Throwing System.Exception."); /*System.Exception是.NET框架中的基类异常类型 所有自定义异常或系统内置的异常都继承自此类型 手动抛出该异常*/ throw new System.Exception(); case "index": //此处数组越界,会跳转到捕获数组越界的catch语句,因为不是filter,所以会交给第二个catch块处理 WriteLine("Throwing System.IndexOutOfRangeException."); eTypes[5] = "error"; break; case "nested index": try { WriteLine("ThrowException(\"nested index\") " + "try block reached."); WriteLine("ThrowException(\"index\") called."); //跳转,最后的执行和index相同 ThrowException("index"); } catch { WriteLine("ThrowException(\"nested index\") general" + " catch block reached."); } **finally****** { WriteLine("ThrowException(\"nested index\") finally" + " block reached."); } break; case "filter": try { WriteLine("ThrowException(\"filter\") " + "try block reached."); WriteLine("ThrowException(\"index\") called."); ThrowException("index"); } catch { WriteLine("ThrowException(\"filter\") general" + " catch block reached."); } break; } } }
[!info]
在case
块中使用throw
时,不需要break
语句,使用throw
就可以结束该块的执行
面向对象编程
C#中的对象从类型中创建,就像变量一样,对象的类型在面向对象编程中叫类,可以使用类的定义实例化对象,类的实例==对象
对象的生命周期
每个对象都有一个明确定义的生命周期,除了“正在使用”的正常状态之外,还有两个重要的阶段:
- 构造阶段:第一次实例化一个对象时,需要初始化该对象。这个初始化过程称为构造阶段,由构造函数完成
- 析构阶段:在删除一个对象时,常常需要执行一些清理工作,例如释放内存,这由析构函数完成
构造函数
对象的初始化过程是自动完成的,不需要自己寻找适用于存储新对象的内存空间
但在初始化对象的过程中有时需要执行一些额外工作,例如需要初始化对象存储的数据。构造函数就是用于初始化数据的函数
所有的类定义都至少包含一个构造函数。在这些构造函数中,可能有一个默认构造函数,该函数没有参数,与类同名
类定义还可能包含几个带有参数的构造函数,称为非默认的构造函数。代码可以使用它们以许多方式实例化对象,例如给存储在对象中的数据提供初始值
在C#中使用new
关键字来调用构造函数
类名 对象名 = new 类名() //可以使用非默认的构造函数来实例化对象 类名 对象名 = new 类名(参数列表)
构造函数与字段、属性和方法一样,可以是公共或私有的。在类外部的代码不能使用私有构造函数实例化对象,而必须使用公共构造函数。通过把默认构造函数设置为私有的,就可以强制类的用户使用非默认的构造函数
一些来没有公共的构造函数,外部的代码不可能实例化它们,这些类称为不可创建的类,不可创建的类不是完全没有用的
析构函数
.NET Framework使用析构函数来清理对象。一般情况下不需要提供析构函数的代码,而由默认的析构函数自动执行操作。但如果在删除对象实例前需要完成一些重要操作,就应提供具体的析构函数
例如如果变量超出范围,代码就不能访问它,但该变量仍存在于计算机内存的某个地方,只有.NET运行程序执行其垃圾回收,进行清理时,该实例才被彻底删除
静态成员和实例类成员
属性、字段和方法等成员是对象实例特有的
静态成员,也称共享成员(静态方法、静态属性、静态字段)
- 静态成员可以在类的实例之间共享,所以可以将它们看成类的全局对象
- 静态属性和静态字段可以访问独立于任何对象实例的数据
- 静态方法可以执行与对象类型相关但与对象实例无关的命令,在使用静态成员时,甚至不需要实例化对象
静态构造函数
使用类中的静态成员时,需要预先初始化,声明时可以给静态成员提供一个初始值,但有时需要执行更复杂的初始化操作,或者在赋值、执行静态方法之前执行某些操作
使用静态构造函数可以执行此类初始化任务,一个类只能有一个静态构造函数,该构造函数不能有访问修饰符,也不能有任何参数
静态构造函数不能直接调用,只能在下述情况下执行:
- 创建包含静态构造函数的类实例时
- 访问包含静态构造函数的类的静态成员时
在这两种情况下,会首先调用静态构造函数,之后实例化类或访问静态成员,无论创建多少个类实例,其静态构造函数都只调用一次,所有非静态构造函数也称实例构造函数
静态类
希望类只包含静态成员,且不能用于实例化对象(如Console
)。为此一种简单的方法是使用静态类,而不是把类的构造函数设置为私有
静态类只能只能包含静态成员,不能包含实例构造函数。只可以有一个静态构造函数
OOP技术
接口
接口是把公共实例(非静态)方法和属性组合起来,以封装特定功能的一个集合。定义了接口后就可以在类中实现它,这样类就可以支持接口所指定的所有属性和成员
[!caution]
- 接口不能单独存在,不能像实例化一个类那样实例化接口
- 接口不能包含实现其成员的任何代码 ,只能定义成员本身
- 实现过程必须在实现接口的类中完成
一个类可以支持多个接口,多个类也可以支持相同的接口。所以接口的概念让用户和其他开发人员更容易理解其他人的代码
可删除的对象
IDisposable
接口是.NET框架中一个非常重要的接口,它允许开发人员显式释放不再需要的对象所占用的资源。支持IDisposable
接口的对象必须实现Dispose()
方法,即它们必须提供这个方法的代码
C#允许使用一种可以优化使用这个方法的结构,using
关键字可以在代码块中初始化使用重要资源的对象,在该代码块的末尾会自动调用Dispose()
方法:
<ClassName><VariableName> = new<ClassName>(); ... using (<VariableName>) { ... } //也可以把初始化对象<VariableName>作为using语句的一部分 using (<ClassName><VariableName> = new<ClassName>()) { ... }
在这两种情况下,可在using
代码块中使用变量<VariableName>
,并在代码块的末尾自动删除(在代码块执行完毕后,调用Dispose()
方法)
继承
继承是OOP最重要的特性之一
任何类都可以从另一个类继承,C#中的对象只能直接派生于一个基类,基类可以有自己的基类
基类可以定义为抽象类,抽象类不能直接实例化,要使用抽象类,必须继承该类,抽象类可以有抽象成员,这些成员在基类中没有实现代码,所以派生类必须实现它们
类可以是密封的,密封类不能用作基类,所以没有派生类
在继承一个基类时派生类不能访问基类的私有成员,只能访问其公共成员,但外部代码也可以访问类的公共成员
因此C#提供了第三种可访问性:protected
,只有派生类才能访问protected
成员,外部代码不能访问private
成员和protected
成员
除定义成员的保护级别外,还可以为成员定义其继承行为。基类的成员可以是虚拟的,即成员可以在派生类中重写
派生类可以提供成员的另一种实现代码,这种实现代码不会删除原来的代码,仍可在类中访问原来的代码,但外部代码不能访问它们。如果没有提供其他实现方式,通过派生类使用成员的外部代码就自动访问基类中成员的实现代码
虚拟类不能是私有成员,因为不能既要求派生类重写成员,又不让派生类访问该成员
C#中所有对象都有一个共同的基类object
(在.NET Framework中,它是System.Object
类的别名)
接口可以继承自其他接口。与类不同的是,接口可以继承多个基接口
多态性
表示在不同的上下文中,同一个接口、函数或者类可以有不同的实现和表现形式。具体来说,多态性允许不同类型的对象对同一消息作出不同的响应
多态性的主要体现:
-
方法重写:子类继承父类时,可以重新定义父类中已经存在的非静态(virtual/abstract)方法,这样当通过父类引用指向子类对象并调用该方法时,实际执行的是子类重写后的方法版本
-
接口实现:不同的类可以实现相同的接口,每个类按照自己的逻辑来实现接口中的方法,从而实现多态
-
向上转型:父类引用指向子类对象,在运行时调用的实际方法取决于对象的实际类型,这就是所谓的动态绑定
-
抽象类与虚方法:在C#中,抽象类可以包含抽象方法(必须在派生类中实现),所有继承自抽象类的子类都必须提供相应的方法实现
继承的一个结果是派生于基类的类在方法和属性上有一定的重叠,因此可以使用相同的语法处理从同一个基类实例化的对象
例如,如果基类Animal
有一个EatFood()
方法,则在其派生类Cow
和Chicken
中调用这个方法的语法是类似的:
//Cow和Chicken派生于Animal Cow myCow = new Cow(); Chicken myChicken = new Chicken(); myCow.EatFood(); myChicken.EatFood();
多态性则更推进了一步,可以把某个派生类型的变量赋给基本类型的变量
Animal myAnimal = myCow;
不需要强制转换,就可以通过该变量调用基类的方法
myAnimal.EatFood();//调用派生类中的EatFood()实现代码 //注意不能以相同的方式调用派生类上定义的方法 myAnimal.M();//error //可以把基本类型变量转换为派生类变量,以此调用派生类的方法 Cow myNewCow = (Cow)myAnimal; myNewCow.M(); //如果原始变量的类型不是Cow或派生于Cow的类型,这个强制类型转换就会引发一个异常
在派生于同一个类的不同对象上执行任务时,多态性是一种极有效的技巧,其使用的代码最少
不是只有共享同一个基类的类才能利用多态性,只要派生类在继承层次结构中有一个相同的类,它们就可以使用同样的方法利用多态性
object
类是继承层次结构中的根,可以把所有对象看成object
类的实例。这就是在建立字符串时,WriteLine()
可以处理无数多种参数组合的原因,第一个参数后面的每个参数都可以看成一个object
实例,所以
可以把任何对象的输出结果写到屏幕上。为此,需要调用方法ToString()
接口的多态性
虽然不能像对象一样实例化接口,但可以建立接口类型的变量,然后就可以在支持该接口的对象上使用该变量来访问该接口提供的方法和属性
例如,假定不使用基类Animal
提供的EatFood()
方法,而是把该方法放在IConsume
接口上。Cow
和Chicken
类也支持这个接口,唯一的区别是它们必须提供EatFood()
方法的实现代码(因为接口不包含实现代码),接着就可以使用下述代码访问该方法
Cow myCow = new Cow(); Chicken myChicken = new Chicken(); IConsume consumeInterface; //将Cow对象赋值给接口类型的变量 consumeInterface = myCow; //通过consumeInterface调用Cow中实现的EatFood方法 consumeInterface.EatFood(); consumeInterface = myChicken; consumeInterface.EatFood();
派生类会继承其基类支持的接口。有共同基类的类不一定有共同接口,有共同接口的类也不一定有共同基类
对象之间的关系
继承是对象之间的一种简单关系,可以让派生类完整地获得基类的特性。对象之间还具有其他一些重要关系
包含关系
一个类包含另一个类,类似于继承关系,但包含类可以控制对被包含类的成员的访问,甚至在使用被包含类的成员前进行其他处理
用一个成员字段包含对象实例,就可以实现包含关系。这个成员字段可以是公共字段,此时与继承关系相同,容器对象的用户就可以访问它的方法和属性,但不能像继承关系那样通过派生类访问类的内部代码
可以让被包含的成员对象变为私有成员,用户就不能直接访问任何成员,即使这些成员是公共的,但可以使用包含类的成员访问这些私有成员
可以完全控制被包含的类对外提供什么成员或不提供任何成员,还可以在访问被包含类的成员前,在包含类的成员上执行其他处理
集合关系
一个类用作另一个类的多个实例的容器。这类似于对象数组,但集合具有其他功能,包括索引、排序和重新设置大小等
集合基本就是一个增加了功能的数组,集合以与其他对象相同的方式实现为类,通常以所存储的对象名称的复数形式来命名
数组与集合的主要区别是,集合通常实现额外的功能,例如Add()
和Remove()
方法可添加和删除集合中的项。且集合通常有一个Item
属性,它根据对象的索引返回该对象。通常这个属性还允许实现更复杂的访问方式
运算符重载
可以把运算符用于从类实例化而来的对象,因为类可以包含如何处理运算符的指令
只能采用这种方式重载现有的C#运算符,不能创建新的运算符
事件
对象可以激活和使用事件,作为它们处理的一部分。事件是非常重要的,可以在代码的其他部分起作用,类似于异常(但功能更强大)
例如可以在把Animal
对象添加到Animals
集合中时,执行特定的代码,而这部分代码不是Animals
类的一部分,也不是调用Add()
方法的代码的一部分。为此需要给代码添加事件处理程序,这是一种特殊类型的函数,在事件发生时调用。还需要配置这个处理程序,以监听自己感兴趣的事件
引用类型和值类型
- 值类型在内存的同一处(栈内)存储它们自己和它们的内容
- 引用类型存储指向内存中其他某个位置(堆内)的引用,实际内容存储在这个位置
在使用C#时不必过多考虑这个问题
值类型和引用类型的一个主要区别是:值类型总是包含一个值,而引用类型可以是null
,表示它们不包含值。但可以使用可空类型创建值类型,使值类型在这个方面的行为类似于引用类型(即可以为null
)
string
和object
类型是简单的引用类型,数组也是隐式的引用类型,创建的每个类都是引用类型
定义类
C#使用class
关键字来定义类,定义了一个类后,就可以在项目中能访问该定义的其他位置对该类进行实例化
默认情况下类声明为内部的,即只有当前项目中的代码才能访问它,可使用internal
访问修饰符关键字来显式地指定这一点,虽然没有必要
public
关键字指定类是公共的,可由其他项目中的代码来访问
[!hint]
internal
类强调的是封装性和内部复用,适合于隐藏内部实现细节;而public
类则允许跨程序集共享和重用,适用于对外公开的接口和组件
可以指定类是抽象的(不能实例化,只能继承,只有抽象类可以有抽象成员)或密封的(不能继承,只能实例化,密封成员不能被重写),使用两个互斥的关键字abstract
或sealed
抽象类可以是公共的,也可以是内部的;密封类也可以是公共或内部的
在类定义中指定继承,要在类名的后面加上一个冒号,后跟基类名
public class Test : Program
在C#的类定义中,只能有一个基类。如果继承了一个抽象类,就必须实现所继承的所有抽象成员(除非派生类也是抽象的)
编译器不允许派生类的可访问性高于基类,即内部类可以继承于一个公共基类,但公共类不能继承于一个内部基类
如果没有使用基类,被定义的类就只继承于基类System.Object
除了在冒号之后指定基类外,还可以指定支持的接口,基类只能有一个,但可以实现任意数量的接口
public class 类名 : 接口1,接口2 //当有基类时,需要先紧跟基类 public class 类名 : 基类,接口1,接口2
支持该接口的类必须实现所有接口成员,但如果不想使用给定的接口成员,可用提供一种“空”的实现方式(没有函数代码)。还可以把接口成员实现为抽象类中的抽象成员
接口的定义
声明接口使用interface
关键字
interface 接口
访问修饰符关键字public
和internal
的使用方式是相同的,与类一样,接口默认定义为内部接口,要使接口可以公开访问,必须使用public
关键字
不能在接口中使用关键字abstract
和sealed
,因为这两个修饰符在 接口定义中是没有意义的(它们不包含实现代码,所以不能直接实例化,且必须是可以继承的)
接口的继承可以使用多个基接口
public interface 接口 : 接口1,接口2
接口不是类,所以没有继承System.Object
,但System.Object
的成员可以通过接口类型的变量来访问。不能使用实例化类的方式来实例化接口
System.Object
因为所有类都继承于System.Object
,所以这些类都可以访问该类中受保护的成员和公共成员
下表是该类中的方法,未列出构造/析构函数,这些方法是.NET Framework中对象类型必须支持的基本方法
//返回bool,静态方法 /*调用该方法的对象和另一对象进行比较,相等返回true,默认实现代码查看对象是否引用同一个对象,可重写该方法*/ object1.Equals(object2) /*和上方法相同,但可以避免因object1为null而抛出的异常,如果两个对象都是空引用返回null*/ Object.Equals(object1,object2) //返回bool,静态方法 /*比较两个对象引用是否指向内存中的同一个位置,是则返回true*/ ReferenceEquals(object1,object2) //返回String,虚拟方法 /*将对象转换为实例并返回,默认代码返回的字符串通常包含类型名和哈希代码(内存地址)*/ object1.ToString() //返回object /*创建一个新对象实例,将原对象的所有字段值复制到新对象中,成员复制不会得到这些成员的新实例。新对象的任何引用类型成员都将引用与源类相同的对象,这个方法是受保护的,只能在类或派生的类中使用*/ MemberwiseClone() //返回System.Type /*可以获得关于对象类型的各种信息,如名称、基类型、接口实现、成员(属性、方法等)等*/ GetType() //返回int,虚拟方法 /*它的目的是为对象生成一个哈希码,通常用于基于哈希表的数据结构*/ GetHashCode()
在利用多态性时,GetType()
是一个有用的方法,允许根据对象的类型来执行不同的操作,而不是对所有对象都执行相同的操作
例如,如果函数接受一个object
类型的参数(表示可以给该函数传输任何信息),就可以在遇到某些对象时执行额外任务。结合使用GetType()和typeof
(这是一个C#运算符,可以把类名转换为System.Type
对象),就可以进行比较
if (myObj.GetType() == typeof(MyComplexClass)) { // myObj is an instance of the class MyComplexClass. }
构造函数和析构函数
构造函数名必须与包含它的类同名,没有参数则是默认构造函数。构造函数可以公共或私有,私有即不能用这个构造函数来创建这个类的对象实例
析构函数由一个波浪号~
后跟类名组成,没有参数和返回类型
析构函数不能被直接调用,它由垃圾回收器(GC)在确定对象不再被引用且需要回收内存时自动调用。调用这个析构函数后,还将隐式地调用基 类的析构函数,包括System.Object
根类中的Finalize()
调用
.NET框架中的大多数资源管理已经高度优化,使用using
语句和实现了IDisposable
的对象可以更有效地进行资源管理,对于非托管资源(文件、数据库连接),应优先考虑实现IDisposable
接口而非依赖析构函数
构造函数的执行序列
任何构造函数都可以配置为在执行自己的代码前调用其他构造函数
为了实例化派生的类,必须实例化它的基类。而要实例化这个基类,又必须实例化这个基类的基类,这样一直到实例化System.Object
为止。结果是无论使用什么构造函数实例化一个类, 总是首先调用System.Object.Object()
无论在派生类上使用默认/非默认构造函数,除非明确指定,否则就使用基类的默认构造函数
在C#中,构造函数初始化器允许在构造函数定义的冒号后面直接初始化类的成员变量。这样可以提高代码的可读性和减少冗余代码,特别是在需要对多个成员进行相同操作时
public class DerivedClass : BaseClass { ... public DerivedClass(int i, int j) : base(i) { } }
base
关键字指定.NET实例化过程使用基类中具有指定参数的构造函数(调用基类的构造函数)this
关键字指定在调用指定的构造函数前,.NET实例化过程对当前类使用非默认的构造函数(调用同一个类中的另一个构造函数)
这里使用一个int参数,因此会调用BaseClass
的BaseClass(int i)
构造函数初始化基类的成员变量,也可以使用这个关键字指定基类构造函数的字面值
```cs public class DerivedClass : BaseClass { public DerivedClass() : this(5, 6) { } public DerivedClass(int i, int j) : base(i) { } }
使用DerivedClass.DerivedClass()
构造函数,将得到如下执行顺序:
- 执行
System.Object.Object()
构造函数 - 执行
BaseClass.BaseClass(int i)
构造函数 - 执行
DerivedClass.DerivedClass(int i, int j)
构造函数 - 执行
DerivedClass.DerivedClass()
构造函数
注意在定义构造函数时,不要创建无限循环
类库项目
除了在项目中把类放在不同的文件中之外,还可以把它们放在完全不同的项目中。如果一个项目只包含类以及其他相关的类型定义,但没有入口点,该项目就称为类库
类库项目编译为.dll
程序集,在其他项目中添加对类库项目的引用,就可以访问它的内容。修改和更新类库不会影响使用它们的其他项目
接口和抽象类
接口和抽象类都包含可以由派生类继承的成员。接口和抽象类都不能直接实例化,但可以声明这些类型的变量。若这样做,就可以使用多态性把继承这两种类型的对象指定给它们的变量,接着通过这些变量来使用这些类型的成员,但不能直接访问派生对象的其他成员
派生类只能继承自一个基类,即只能直接继承自一个抽象类,但可以用一个继承链包含多个抽象类;而类可以使用任意多个接口
抽象类可以拥有抽象成员(没有代码体,且必须在派生类中实现,否则派生类本身必须也是抽象的)和非抽象成员(拥有代码体,可以是虚拟的,这样就可以在派生类中重写)
接口成员必须都在使用接口的类上实现,它们没有代码体。接口成员是公共的,但抽象类的成员可以是私有的(只要它们不是抽象的)、受保护的、内部的或受保护的内部成员(受保护的内部成员只能在应用程序的代码或派生类中访问)
此外接口不能包含字段、构造函数、析构函数、静态成员或常量
- 抽象类主要用作对象系列的基类,这些对象共享某些主要特性,例如共同的目的和结构
- 接口则主要用于类,这些类存在根本性的区别,但仍可以完成某些相同的任务
假定有一个对象系列表示火车,基类Train
包含火车的核心定义,例如车轮的规格和引擎的类型。但这个类是抽象的,因为并没有一般的火车
为创建一辆实际的火车,需要给该火车添加特性。为此派生一些类,Train
可以派生于一个相同的基类Vehicle
,客运列车可以运送乘客,货运列车可以运送货物,假设高铁两者都可以运送,为它们设计相应的接口
在进行更详细的分解之前,把对象系统以这种方式进行分解,可以清晰地看到哪种情形适合使用抽象类,哪种情形适合使用接口
结构类型
对象是引用类型,把对象赋给变量时,实际上是把带有一个指针的变量赋给了该指针所指向的对象
而结构是值类型,其变量包含结构本身,把结构赋给变量,是把一个结构的所有信息复制到另一个结构中
浅度和深度复制
简单地按照成员复制对象可以通过派生于System.Object
的MemberwiseClone()
方法来完成,这是一个受保护的方法,但很容易在对象上定义一个调用该方法的公共方法。该方法提供的复制功能称为浅度赋值,因为它未考虑引用类型成员。因此新对象中的引用成员就会指向源对象中相同成员引用的对象
如果想要创建成员的新实例(复制值,不复制引用),此时需要使用深度复制
可以实现一个ICloneable
接口,以标准方式进行深度赋值,如果使用这个接口,就必须实现它包含的Clone()
方法。这个方法返回一个类型为System.Object
的值。可以采用各种处理方式,实现所选的任何一个方法体来得到这个对象
定义类成员
定义成员
public
:成员可以由任何代码访问private
:成员只能由类中的代码访问(如果没有使用任何关键 字,就默认使用这个关键字)internal
:成员只能由定义它的程序集内部的代码访问protected
:成员只能由类或派生类中的代码访问
后两个关键字可以结合使用,所以也有protected internal
成员,它们只能由程序集中派生类的代码来访问
定义字段
用标准的变量声明格式(可以进行初始化)和前面介绍的修饰符来定义字段
class Test { public int Int; }
.NET Framework的公共字段使用驼峰命名法,私有字段一般全小写
字段可以使用关键字readonly
,表示该字段只能在执行构造函数的过程或初始化语句赋值
class Test { public readonly int Int = 16; }
[!important]
const
声明编译时常量,readonly
声明运行时常量const
成员必须是静态的,不需要实例即可访问,在整个应用程序域中是一致的readonly
字段可以是静态也可以是实例
使用static
关键字将字段声明为静态,静态字段必须通过定义它们的类来访问,而不是通过这个类的对象实例来访问
定义方法
方法使用标准函数格式、可访问性和可选static
修饰符来声明,与公共字段一样,公共方法也采用驼峰命名法
如果使用了static
关键字,这个方法就只能通过类来访问,不能通过对象实例来访问
可以在方法定义中使用下述关键字
virtual
:声明一个虚方法,允许派生类重写它override
:在派生类中重写基类的虚方法abstract
:声明一个抽象方法,必须在派生类中实现。sealed
(应用于override方法时):阻止方法被派生类重写static
:声明静态方法,不依赖于类实例进行调用async
:用于异步方法,表示方法包含异步操作并可能返回Task
或Task<T>
extern
:声明外部方法,通常用于P/Invoke调用非托管代码partial
:标识部分方法,用于拆分方法的定义到多个文件中
定义属性
属性提供对类或结构体内部私有字段的间接访问。属性允许控制对这些私有字段的读取和写入操作,从而实现数据验证、逻辑封装等目的
属性定义方式与字段,但包含的内容比较多,属性比字段复杂,因为它们在修改状态前还可以执行一些额外操作,也可能并不修改状态
属性拥有两个类似于函数的块,一个块用于获取属性的值,一个块用于设置属性的值。这两个块也称为访问器,分别使用get
和set
关键字来定义
访问器可以用于控制属性的访问级别。忽略其中一个块来创建只读或只写属性,这仅适用于外部代码,因为类中的其他代码可以访问这些代码块能访问的数据。可以在访问器上包含可访问修饰符
属性的基本结构包括标准的可访问修饰符,后跟类名、属性名和访问器
get
块必须有一个属性类型的返回值,简单属性一般与私有字段相关联,以控制对这个字段的访问,此时get
块可以直接返回该字段的值
// Field used by property. private int myInt; // Property. public int MyIntProp { get { return myInt; } set { // Property set code. } }
类外部的代码不能直接访问myInt
字段,因为其访问级别是私有的。外部代码必须使用属性来访问该字段。set
访问器采用类似方式把一个值赋给字段。可以使用关键字value
表示用户提供的属性值:
public int MyIntProp { get { return myInt; } set { myInt = value; } }
value
等于类型与属性相同的一个值,所以如果属性和字段使用相同的类型,就不必考虑数据类型转换
这个简单属性只是用来阻止对myInt
字段的直接访问。在对操作进行更多控制时,属性的真正作用才能发挥出来
set { if (value >= 0 && value<= 10) myInt = value; }
如果使用了无效值,通常继续执行,但记录下该事件,以备将来分析或直接抛出异常是比较好的选择,选择哪个选项取决于如何使用类以及给类的用户授予了多少控制权
set { if (value >= 0 && value<= 10) myInt = value; else throw (new ArgumentOutOfRangeException("MyIntProp", value, "MyIntProp must be assigned a value between 0 and 10.")); }
属性可以使用virtual、override
和abstract
关键字,就像方法一 样,但这几个关键字不能用于字段。访问器可以有自己的可访问性
只有类或派生类中的代码才能使用set
访问器
访问器可以使用的访问修饰符取决于属性的可访问性,访问器的可访问性不能高于它所属的属性,即私有属性对它的访问器不能包含任何可访问修饰符,而公共属性可以对其访问器使用所有的可访问修饰符
C#6引入了一个名为“基于表达式的属性”的功能,该功能可以把属性的定义减少为一行代码
下面的属性对一个值进行数学计算,使用Lambda箭头后跟等式来定义:
//Field used by property private int myDoubledInt = 5; //Property public int MyDoubledIntProp => (myDoubledInt * 2);
重构成员
“重构”表示使用工具修改代码,而不是手工修改。为此,只需要右击类图中的某个成员,或在代码视图中右击某个成员即可
public string myString;
右击该字段,选择快速操作和重构,选择需要的选项
private string myString; public string MyString { get => myString; set => myString = value; }
myString
字段的可访问性变成private
,同时创建了一个公共属性 MyString
,它自动链接到myString上
。显然这会减少为字段创建属 性的时间
自动属性
属性是访问对象状态的首选方式,因为它们禁止外部代码访问对象内部的数据存储机制的实现,还对内部数据的访问方式施加了更多控制
一般以非常标准的方式定义属性,即通过一个公共属性来直接访问一个私有成员
对于自动属性,可以使用简化的语法声明属性,C#编译器会自动添加未键入的内容,更确切的说,编译器会声明一个用于存储属性的私有字段,并在属性的get
和set
块中使用该字段
//会定义一个自动属性 public int MyIntProp { get; set; }
按照通常的方式定义属性的可访问性、类型和名称,但没有给get
和set
访问器提供实现代码。这些块的实现代码和底层的字段都由编译器提供
[!tip]
输入prop
后按Tab键两次,就可以自动创建public int MyProperty {get; set;}
使用自由属性时,只能通过属性访问数据,不能通过底层的私有字段来访问,因为不知道底层私有字段的名称,该名称是在编译期间定义的。但这并不是一个真正意义上的限制,因为可以直接使用属性名
自动属性的唯一限制是它们必须包含get
和set
访问器,无法使用这种方式定义只读或只写属性。但可以改变这些访问器的可访问性。例如,可采用如下方式创建一个外部只读属性
//只能在类定义的代码中访问该属性的值 public int MyIntProp { get; private set; }
C#6引入了只有get
访问器的自动属性和自动属性的初始化器。不变数据类型的简单定义是:一旦创建,就不会改变状态。使用不变的数据类型有很多优点,比如简化了并发编程和线程的同步
//只有get访问器的自动属性 public int MyIntProp { get; } //自动属性的初始化 public int MyIntProp { get; } = 9;
隐藏基类方法
当从基类继承一个非抽象成员时,也就继承了其实现代码,如果继承的成员是虚拟的,就可以使用override
关键字重写这段实现代码。无论继承成员是否为虚拟,都可以隐藏这些实现代码。无论继承的成员是否为虚拟,都可以隐藏这些实现代码
public class BaseClass { public void DoSomething() { //Base implementation. } } public class DerivedClass : BaseClass { public void DoSomething() { //Derived class implementation, hides base implementation. } }
这段代码可以正常运行,但会生成一个警告,说明隐藏了一个基类成员,如果确实要隐藏该成员,可以使用new
关键字显式地表明意图
new public void DoSomething() { //Derived class implementation, hides base implementation. }
其工作方式是完全相同的,但不会显示警告
注意隐藏基类成员和重写它们的区别
-
隐藏基类的实现代码,基类的实现依然可以被访问,取决于如何访问这个成员。若通过派生类的实例访问,则调用的是派生类中隐藏的新实现;若基类中有其他方式可以访问这个成员,则通过这种方式仍能访问到基类的原始实现
-
重写方法将替代基类中的实现代码,通过基类类型的引用调用该虚方法时,实际执行的是派生类中重写的方法。但在派生类内部还是可以直接访问基类中被重写的方法
/*隐藏基类*/ class Test { public class BaseClass { public virtual void DoSomething() => WriteLine("Base imp"); } public class DerivedClass : BaseClass { new public void DoSomething() => WriteLine("Derived imp"); } static void Main(string[] args) { DerivedClass myObj = new DerivedClass(); BaseClass BaseObj; BaseObj = myObj; BaseObj.DoSomething(); //结果:Base imp //基类方法不必是virtual,结果仍相同 } /*重写基类*/ class Test { public class BaseClass { public virtual void DoSomething() => WriteLine("Base imp"); } public class DerivedClass : BaseClass { public override void DoSomething() => WriteLine("Derived imp"); } static void Main(string[] args) { DerivedClass myObj = new DerivedClass(); BaseClass BaseObj; BaseObj = myObj; BaseObj.DoSomething(); //结果:Derived imp //基类中成员被声明为virtual或abstract即可在派生类中重写 }
调用重写或隐藏的基类方法
无论重写/隐藏成员,都可以在派生类的内部访问基类成员
这在许多情况下都是很有用的:
- 要对派生类的用户隐藏继承的公共成员,但仍能在类中访问其功能
- 要给继承的虚拟成员添加实现代码,而不是简单地用重写的新实现代码替换它
可使用base
关键字,它表示包含在派生类中的基类的实现代码
public class BaseDerivedClass { public virtual void DoSomething() { // Base implementation. } } public class DerivedClass : BaseDerivedClass { public override void DoSomething() { // Derived class implementation, extends base class implementation. base.DoSomething(); // More derived class implementation. } }
在DerivedClass
包含的DoSomething()
方法中,执行包含在BaseDerivedClass
中的DoSomething()
版本。base
使用的是对象实例,base
关键字不能用于访问非虚方法、静态方法或私有成员
也可以使用this
关键字,this
也可以用在类成员内部,也引用对象实例,,this
引用的是当前的对象实例,因此不能在静态成员中使用this
关键字,因为静态成员不是对象实例的一部分
this
关键字最常用的功能是把当前对象实例的引用传递给一个方法
public void doSomething() { TargetClass myObj = new TargetClass(); myObj.DoSomethingWith(this); /*this的类型与包含上述方法的类兼容。这个参数类型可以是类的类型、由这个类继承的类类型,或者由这个类或System.Object实现的一个接口*/ }
this
关键字的另一个常见用法是限定局部类型的成员
public class MyClass { private int someData; public int SomeData { get { return this.someData; } } }
许多开发人员都喜欢这个语法,它可以用于任意成员类型,因为可以一眼看出引用的是成员,而不是局部变量
嵌套的类型定义
除了在命名空间中定义类型,还可以在其他类中定义它们。这样就可以在定义中使用各种访问修饰符,也可以使用new
关键字来隐藏继承于基类的类型定义
public class MyClass { public class MyNestedClass { public int NestedClassField; } } //在MyClass的外部实例化myNestedClass,必须限定名称 MyClass.MyNestedClass myObj = new MyClass.MyNestedClass();
using System; using static System.Console; namespace Test { public class ClassA { //私有属性 private int State = -1; //只读属性 public int OnlyReadState { get { return State; } } public class ClassB { //嵌套类可以访问包含它类的底层字段,即使它是一个私有字段 //因此仍然可以修改私有属性的值 public void SetPrivateState(ClassA target, int newState) { target.State = newState; } } } class Program { static void Main(string[] args) { ClassA myObject = new ClassA(); WriteLine($"myObject.State = {myObject.OnlyReadState}"); ClassA.ClassB myOtherObject = new ClassA.ClassB(); myOtherObject.SetPrivateState(myObject, 999); WriteLine($"myObject.State = {myObject.OnlyReadState}"); } } }
接口的实现
接口成员的定义与类定义相似,但具有几个重要区别:
- 不允许使用访问修饰符,所有接口成员都是隐式公共的
- 接口成员不包含代码体,需要在实现该接口的类或结构中编写
- 接口不能定义字段成员
- 不能用关键字
static、virtual、abstract、sealed
来定义接口成员 - 禁止类型定义成员
要隐藏基接口中继承的成员,和隐藏继承的类成员一样使用关键字new
定义它们
接口中定义的属性可以定义访问器get
和set
中的哪一个或都用于该属性
接口没有指定应如何存储属性数据,接口不能指定字段,例如用于存储属性数据的字段,接口和类一样可以定义为类成员,但不能定义为其它接口的成员,因为接口不能包含类型定义
在类中实现接口
实现接口的类必须包含该接口所有成员的实现代码,且必须匹配指定的签名,包括匹配指定的get
和set
,且必须是公共的
public interface IMyInterface { void DoSomething(); void DoSomethingElse(); } public class MyClass : IMyInterface { public void DoSomething() { } public void DoSomethingElse() { } }
可使用关键字virtual
或abstract
来实现接口成员,但不能使用static
或const
。可以在基类上实现接口成员
public interface IMyInterface { void DoSomething(); void DoSomethingElse(); } public class MyBaseClass { public void DoSomething() { } } public class MyDerivedClass : MyBaseClass, IMyInterface { //基类实现了接口的一个成员,因此会继承过来,可以不用实现 public void DoSomethingElse() { } }
继承一个实现给定接口的基类,就意味着派生类隐式地支持这个接口
public interface IMyInterface { void DoSomething(); void DoSomethingElse(); } public class MyBaseClass : IMyInterface { public virtual void DoSomething() { } public virtual void DoSomethingElse() { } } public class MyDerivedClass : MyBaseClass { public override void DoSomething() { } }
在基类中把实现代码定义为virtual
,派生类就可以可选的使用override
关键字来重写实现代码,而不是隐藏它们
类显式实现接口成员
如果由类显式地实现接口成员,就只能通过接口来访问该成员,不能桶过类来访问,隐式成员可以通过类和接口来访问
class Test{ interface IAnimal { void Speak(); } public class Dog : IAnimal { //隐式实现IAnimal接口的Speak方法 public void Speak() { Console.WriteLine("Woof!"); } } public static void Main() { Dog dog = new Dog(); dog.Speak(); //输出 "Woof!" } } class Test{ interface IAnimal { void Speak(); } public class Cat : IAnimal { //显式实现IAnimal接口的Speak方法 void IAnimal.Speak() { Console.WriteLine("Meow!"); } } public static void Main() { Cat cat = new Cat(); ((IAnimal)cat).Speak(); //输出Meow! //cat.Speak()会报错 } }
在显式实现的情况下,Cat
类自身并没有名为Speak
的公共成员,只有通过类型转换为IAnimal
接口后才能调用到Speak
方法
其他属性访问器
如果在定义属性的接口中只包含set
,就可给类中的属性添加get
,反之亦然。但只有隐式实现接口时 才能这么做。
大多数时候,都想让所添加的访问器的可访问修饰符比接口中定义的访问器的可访问修饰符更严格。因为按照定义,接口定义的访问器是公共的,也就是说,只能添加非公共的访问器
如果将新添加的访问器定义为公共的,那么能够访问实现该接口的类的代码也可以访问该访问器。但是只能访问接口的代码就不能访问该访问器
部分类定义
如果所创建的类包含一种类型或其他类型的许多成员时,就很容易引起混淆,代码文件也比较长。这时就可以使用#region
和#endregion
来给代码定义区域,就可以折叠和展开各个代码区,使代码更具可读性
可按这种方式嵌套各个区域,这样一些区域就只能在包含它们的区域被展开后才能看到
另一种方法是使用部分类定义,把类的定义放在多个文件中,例如可将字段、属性和构造函数放在一个文件中,而把方法放在另一个文件中。在包含部分类定义的每个文件中对类使用partial
关键字即可
如果使用部分类定义,partial
关键字就必须出现在包含部分类定义的每个文件的与此相同的位置
对于部分类,要注意的一点是:应用于部分类的接口也会应用于整个类
public partial class MyClass : IMyInterface1 { ... } public partial class MyClass : IMyInterface2 { ... } //和下面是等价的 public class MyClass : IMyInterface1, IMyInterface2 { ... }
基类可以在多个定义文件中指定,但必须是同一个基类,因为C#中,类只能继承一个基类
部分方法定义
部部分方法在一个部分类中定义,在另一个部分类中实现。在这两个部分类中,都要使用partial
关键字
部分方法可以是静态,但它们总是私有的,且不能有返回值,它们只可以使用ref
参数,部分方法也不能使用virtual、abstract、override、new、sealed、extern
修饰符
部分方法的重要性体现在编译代码时,而不是使用代码时
using static System.Console; class Test { public partial class MyClass { partial void DoSomethingElse(); public void DoSomething() { WriteLine("DoSomething() execution started."); DoSomethingElse(); WriteLine("DoSomething() execution finished."); } } public partial class MyClass { partial void DoSomethingElse() => WriteLine("DoSomethingElse() called."); } public static void Main() { MyClass Object= new();//简化方式 Object.DoSomething(); } } /*output: DoSomething() execution started. DoSomethingElse() called. DoSomething() execution finished.*/
删除部分类的实现代码,输出就如下所示:
DoSomething() execution started. DoSomething() execution finished.
编译代码时,如果代码包含一个没有实现代码的部分方法,编译器会完全删除该方法,还会删除对该方法的所有调用。执行代码时,不会检查实现代码,因为没有要检查的方法调用。这会略微提高性能
与部分类一样,在定制自动生成的代码或设计器创建的代码时,部分方法很有用。设计器会声明部分方法,根据具体情形选择是否实现它。如果不实现它,就不会影响性能,因为在编译过的代码中并不存在该方法
示例应用程序
开发一个类模块,以便在后续章节中使用,该类模块包含两个类:
Card
:表示一张标准的扑克牌,包含梅花、方块、红心和黑桃,其顺序是从A到KDeck
:表示一副完整的52张扑克牌,在扑克牌中可以按照位置访问各张牌,并可以洗牌
规划应用程序
Card
类基本是由两个只读字段suit
和rank
的容器,字段指定为只读的原因是“空白”的牌是没有意义的,牌在创建好后也不能修改。把默认的构造函数指定为私有,并提供另一个构造函数,使用给定的suit
和rank
建立一副扑克牌
此外Card
类要重写System.Object
的ToString()
方法,这样才能获得人们可以理解的字符串来表示扑克牌。为使编码简单一些,为两个字段suit
和rank
提供枚举
Deck
类包含52个Card
对象,使用简单的数组类型,这个数组不能直接访问,对Card
对象的访问要通过GetCaed()
方法来实现,该方法返回指定索引的Card
对象,这个类有一个Shuffle()
方法,用于重新排列数组中的牌
编写类库
可以自己手动编写,也可以借助vs的类图来快速设计,以下为使用类图工具箱设计自动生成的代码:
//Suit.cs文件 using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace CardLib { public enum Suit { Club, Diamond, Heart, Spade } } //Rank.cs文件 using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace CardLib { public enum Rank { Ace = 1, Deuce, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King } }
添加Card类
//Card.cs文件 namespace CardLib { public class Card { public readonly Suit suit; public readonly Rank rank; public Card(Suit newSuit, Rank newRank) { suit = newSuit; rank = newRank; } private Card() { } //重写的ToString()方法将已存储的枚举值的字符串表示写入到返回 的字符串中,非默认的构造函数初始化suit和rank字段的值 public override string ToString() { return "The" + rank + "of" + suit + "s"; } } }
添加Deck类
namespace CardLib { public class Deck { //私有成员变量数组,存储扑克牌对象 private Card[] cards; //构造函数,在实例化Deck类时自动调用,初始化一副完整的扑克牌 public Deck() { cards = new Card[52]; //双层循环遍历4种花色和13种点数,生成所有牌并存入cards for (var suitVal = 0; suitVal < 4; ++suitVal) { for (var rankVal = 0; rankVal < 13; ++rankVal) { //每种花色占13个位置,因此需要将花色值*13再加上点数值来得到正确的数组下标 //传入参数分别转换为枚举类型的花色值和点数值 cards[suitVal * 13 + rankVal] = new Card((Suit)suitVal, (Rank)rankVal); } } } //返回cards数组中对应位置的card对象 public Card GetCard(int cardNum) { //检查索引是否在有效范围 if (cardNum >= 0 && cardNum <= 51) return cards[cardNum]; else throw (new System.ArgumentOutOfRangeException("cardNum", cardNum, "Value must be between 0 and 51.")); } //用于对当前牌堆进行随机洗牌 public void Shuffle() { //临时存储打乱顺序后的扑克牌 Card[] newDeck = new Card[52]; //记录新数组中的每个位置是否已经分配牌 bool[] assigned = new bool[52]; //创建Random对象,用于生成随机索引 Random sourceGen = new Random(); //遍历原数组的所有元素,将它们随机放入newDeck中 for (int i = 0; i < 52; i++) { int destCard = 0; bool foundCard = false; //循环查找未被分配的随机位置,直到找到为止 while (!foundCard) { //生成一个0到51之间的随机数作为目标索引 destCard = sourceGen.Next(52); //检查目标索引是否已占用,若未占用,则跳出循环 if (!assigned[destCard]) foundCard = true; } //将找到的位置标记为已分配,并从原数组复制相应的Card对象至新数组 assigned[destCard] = true; newDeck[destCard] = cards[i]; } //当所有牌都已随机分配后,将新数组的内容复制回原数组,完成洗牌操作 newDeck.CopyTo(cards, 0); } } }
这不是完成该任务的最高效方式,因为生成的许多随机数都可能找不到空位置以复制扑克牌
然后新建一个控制台应用程序,对它添加一个对类库项目CardLib
的引用。因为新项目是创建的第二个项目,所以还需要指定该项目是解决方法的启动项目
//新项目主文件代码 using static System.Console; using CardLib; namespace CardClient { internal class Program { private static void Main(string[] args) { Deck myDeck = new Deck(); myDeck.Shuffle(); for (int i = 0; i < 52; i++) { Card tempCard = myDeck.GetCard(i); Write(tempCard.ToString()); if (i != 51) Write("\n"); else WriteLine(); } } } }
集合、比较和转换
集合
使用数组可以创建包含许多对象或值的变量类型,但数组有一定的限制,最大的限制就是一旦创建好数组,它们的大小就不可改变
C#中数组实现为System.Array
类的实例,它们只是集合类中的一种类型。集合类一般用于处理对象列表,其功能比简单数组要多,功能大多是通过实现System.Collections
名称 空间中的接口而获得
集合的功能包括基本功能都可以通过接口来实现,所以不仅可以使用基本集合类,例如System.Array
,还可以创建自己的定制集合类。
这些集合可以专用于要枚举的对象(即要从中建立集合的对象)。这么做的一个优点是定制的集合类可以是强类型化的。也就是说,从集合中提取项时,不需要把它们转换为正确类型。另一个优点是提供专用的方法,例如,可以提供获得项子集的快捷方法
System.Collections
名称空间中的几个接口提供了基本的集合功能:
-
IEnumerable
可以迭代集合中的项 -
ICollection
(继承于IEnumerable
)可以获取集合中项的个数,并能把项复制到一个简单的数组类型中 -
IList
(继承于IEnumerable
和ICollection
)提供了集合的项列表,允许访问这些项,并提供其他一些与项列表相关的基本功能 -
IDictionary
(继承于IEnumerable
和ICollection
)类似于IList
,但提供了可通过键值而不是索引访问的项列表
System.Array
类实现了IList、ICollection、IEnumerable
,但不支持IList
的一些更高级功能,它表示大小固定的项列表
使用集合
Systems.Collections
名称空间中的类System.Collections.ArrayList
也实现了IList、ICollection、IEnumerable
接口,但实现方式比System.Array
更复杂。数组的大小是固定不变的,而这个类可以用于表示大小可变的项列表
//Animal.cs文件 using static System.Console; namespace arrayANDadvancedSet { public abstract class Animal { //受保护name字段用于存储动物名称 protected string name; //公共属性,提供对name字段的访问与修改 public string Name { get { return name; } set { name = value; } } //默认构造函数,表示未指定名称 public Animal() { name = "The animal with no name"; } //带参数构造函数,根据参数设置动物名称 public Animal(string newName) { name = newName; } //输出已喂食的动物名 public void Feed() => WriteLine($"{name} has been fed."); } } //Animals.cs文件,为了简洁,把Cow和Chicken放到了一起,书并没有这样做 using static System.Console; namespace arrayANDadvancedSet { public class Cow : Animal { //实例方法Milk,输出奶牛挤奶的信息 public void Milk() => WriteLine($"{name} has been milked."); //Cow类构造函数,调用基类Animal的带参数构造函数 public Cow(string newName) : base(newName) { } } public class Chicken : Animal { //实例方法LayEgg,输出母鸡下蛋的信息 public void LayEgg() => WriteLine($"{name} has laid an egg."); //Chicken类构造函数,调用基类Animal的带参数构造函数 public Chicken(string newName) : base(newName) { } } } //Program.cs文件 using System.Collections; using static System.Console; namespace arrayANDadvancedSet { internal class Program { private static void Main() { //输出创建Array类型集合的信息并创建,大小为2 WriteLine("Create an Array type collection of Animal objects and use it:"); Animal[] animalArray = new Animal[2]; //创建并实例化一个Cow对象和一个Chicken对象,添加到数组中 Cow myCow1 = new Cow("Lea"); animalArray[0] = myCow1; animalArray[1] = new Chicken("Noa"); //遍历Array输出每种动物的详细信息 foreach (Animal myAnimal in animalArray) { WriteLine($"New {myAnimal.ToString()} object added to Array collection, Name = {myAnimal.Name}"); } //输出Array中动物数量 WriteLine($"Array collection contains {animalArray.Length} objects."); //调用动物方法,第一个元素Cow被喂食,第二个元素Chinken下蛋 animalArray[0].Feed(); ((Chicken)animalArray[1]).LayEgg(); WriteLine(); //输出创建ArrayList类型集合的信息并创建 WriteLine("Create an ArrayList type collection of Animal objects and use it:"); ArrayList animalArrayList = new ArrayList(); //向ArrayList中添加一个Cow对象和一个Chicken对象 Cow myCow2 = new Cow("Rual"); animalArrayList.Add(myCow2); animalArrayList.Add(new Chicken("Andrea")); //遍历ArrayList输出每种动物的详细信息 foreach (Animal myAnimal in animalArrayList) { WriteLine($"New {myAnimal.ToString()} object added to ArrayList collection, Name = {myAnimal.Name}"); } //输出ArrayList中动物数量 WriteLine($"ArrayList collection contains {animalArrayList.Count} objects."); //调用动物方法,第一个元素Cow被喂食,第二个元素Chinken下蛋 ((Animal)animalArrayList[0]).Feed(); ((Chicken)animalArrayList[1]).LayEgg(); WriteLine(); //额外操作,移除第一个元素,喂食第二个元素 WriteLine("Additional manipulation of ArrayList:"); animalArrayList.RemoveAt(0); ((Animal)animalArrayList[0]).Feed(); //将animal的内容添加到animalArrayList,让第三个元素下单 animalArrayList.AddRange(animalArray); ((Chicken)animalArrayList[2]).LayEgg(); //输出原始Cow对象在animalArrayList中的索引 WriteLine($"The animal called {myCow1.Name} is at index {animalArrayList.IndexOf(myCow1)}."); //修改原始Cow对象的名字的名字,然后输出新名字 myCow1.Name = "Mary"; WriteLine($"The animal is now called {((Animal)animalArrayList[1]).Name}."); } } }
这个示例创建了两个对象集合,第一个集合使用System.Array
类,这是一个简单数组,第二个集合使用System.Collections.ArrayList
类。这两个集合都是Animal
对象,在Animal.cs
中定义。Animal
类是抽象类,所以不能进行实例化。但通过多态性可使集合中的项成为派生于Animal
类的Cow
和Chicken
类实例
有几个处理操作可以应用到Array
和ArrayList
集合上,但它们的语法略有区别。也有一些操作只能使用更高级的ArrayList
类型
//简单数组必须使用固定大小来初始化数组才能使用 Animal[] animalArray = new Animal[2]; //而ArrayList集合不需要初始化其大小 ArrayList animalArrayList = new ArrayList();
数组是引用类型,所以用一个长度初始化数组并没有初始化它所包含的项,要使用一个指定的项还需初始化
Cow myCow1 = new Cow("Lea"); animalArray[0] = myCow1; animalArray[1] = new Chicken("Noa");
而ArrayList
集合没有现成的项,也没有null
引用的项。这样就不能以相同的方式给索引赋予新实例,使用Add()
方法添加新项
Cow myCow2 = new Cow("Rual"); animalArrayList.Add(myCow2); animalArrayList.Add(new Chicken("Andrea"));
以这种方式添加项之后,就可以使用与数组相同的语法改写该项
nimalArrayList[0] = new Cow("Alma");
使用foreach
结构迭代一个数组是可以的,因为System.Array
类实现了IEnumerable
接口,这个接口的唯一方法GetEnumerator()
可以迭代集合中的各项
//它们使用foreach的语法是相同的 foreach (Animal myAnimal in animalArray) foreach (Animal myAnimal in animalArrayList)
数组使用Length
属性输出数组中个数,而ArrayList
集合使用Count
属性,该属性是ICollection
接口的一部分
//Array WriteLine($"Array collection contains {animalArray.Length} objects."); //ArrayList WriteLine($"ArrayList collection contains {animalArrayList.Count} objects.");
如果不能访问集合(无论是简单数组还是较复杂的集合中的项),它们就没有什么用途。简单数组是强类型化的,可以直接访问它们所包含的项类型,所以可以直接调用项的方法:
animalArray[0].Feed();
数组类型是抽象类型Animal
,因此不能直接调用由派生类提供的方法,而必须使用数据类型转换
((Chicken)animalArray[1]).LayEgg();
ArrayList
集合是System.Object
对象的集合(通过多态性赋给Animal
对象),所以必须对所有的项进行数据类型转换
((Animal)animalArrayList[0]).Feed(); ((Chicken)animalArrayList[1]).LayEgg();
ArrayList
集合比Array
集合多出一些功能,可以使用Remove()
和RemoveAt()
方法删除项,它们分别根据项的引用或索引从数组中删除项
animalArrayList.Remove(myCow2); animalArrayList.RemoveAt(0);
ArrayList
集合可以用AddRange()
方法一次添加好几项。这个方法接 受带有ICollection
接口的任意对象,包括前面的代码所创建的 animalArray
数组
animalArrayList.AddRange(animalArray);
AddRange()
方法不是ArrayList
提供的任何接口的一部分。这个方法专用于ArrayList
类,证实了可以在集合类中执行定制操作。
该类还提供了其他方法,如InsertRange()
,它可以把数组对象插入到列表中的任何位置,还有用于排序和重新排序数组的方法
定义集合
创建自己的强类型化的集合,一种方式是手动实现需要的方法,但这样较耗时间,在某些情况下也非常复杂。可以从一个类中派生自己的集合,例如System.Collections.CollectionBase
类,这个抽象类提供了集合类的大量实现代码。这是推荐使用的方式
CollectionBase
类有接口IEnumerable、ICollection、IList
,但只提供了一些必要的实现代码,主要是IList的Clear()
和RemoveAt()
方法,以及ICollection
的Count
属性。如果要使用提供的功能,就需要自己实现其他代码
CollectionBase
提供了两个受保护的属性,它们可以访问存储的对象本身。可以使用List、InnerList
,List
可以通过IList
接口访问项,InnerList
则是用于存储项的ArrayList
对象
例如,存储Animal
对象的集合类定义可以如下:
public class Animals : CollectionBase { public void Add(Animal newAnimal) { List.Add(newAnimal); } public void Remove(Animal oldAnimal) { List.Remove(oldAnimal); } public Animals() {} }
Add()
和Remove()
方法实现为强类型化的方法,使用IList
接口中用于访问项的标准Add()
方法。这些方法现在只用于处理Animal
类或派生于Animal
的类,而前面的ArrayList
实现代码可处理任何对象
CollectionBase
类可以对派生的集合使用foreach
语法
WriteLine("Using custom collection class Animals:"); Animals animalCollection = new Animals(); animalCollection.Add(new Cow("Lea")); foreach (Animal myAnimal in animalCollection) { WriteLine($"New { myAnimal.ToString()} object added to custom " + $"collection, Name = {myAnimal.Name}"); }
但不能使用下面的代码:
animalCollection[0].Feed();
要以这种方式通过索引来访问项,就需要使用索引符
索引符
索引符indexer是一种特殊类型的属性,可以把它添加到一个类中,以提供类似于数组的访问。可通过索引符提供更复杂的访问,因为可以用方括号语法和使用复杂的参数类型,它最常见的一个用法是对项实现简单的数字索引
在Animal
对象的Animals
集合中添加一个索引符
public class Animals : CollectionBase { ... public Animal this[int animalIndex] { get { return (Animal)List[animalIndex]; } set { List[animalIndex] = value; } } }
this
关键字需要和方括号中的参数一起使用,除此之外,索引符与其他属性十分类似。在访问索引符时,将使用对象名,后跟放在方括号中的索引参数
return (Animal)List[animalIndex];
对List
属性使用一个索引符,即在IList
接口上,可以访问CollectionBase
中的ArrayList
。ArrayList
存储了项。这里需要进行显式数据类型转换,因为IList.List
属性返回一个System.Object
对象
为索引符定义了一个类型,使用该索引符定义了一个类型,使用该索引符访问某项时,就可以得到这个类型,这种强类型化功能就可以编写下述代码
animalCollection[0].Feed(); //而不是: ((Animal)animalCollection[0]).Feed();
键控集合和IDictionary
除IList
接口外,集合还可以实现类似的IDictionary
接口,允许项 通过键值(如字符串名)进行索引,而不是通过索引。这也可以使用索引符来完成,但这次的索引符参数是与存储的项相关联的键,而不是int
索引,这样集合就更便于用户使用了
与索引的集合一样,可以使用一个基类简化IDictionary
接口的实现,这个基类就是DictionaryBase
,它也实现IEnumerable
和ICollection
,提供了对任何集合都相同的基本集合处理功能
DictionaryBase
与CollectionBase
一样,实现通过其支持的接口获得一些成员(不是全部成员)。DictionaryBase
也实现Clear
和Count
成员,但不实现RemoveAt()
。因为RemoveAt()
是IList
接口中的一 个方法,不是IDictionary
接口中的一个方法,但IDictionary
有一个Remove()
方法,这是一个应在基于DictionaryBase
的定制集合类上实现的方法
下面的代码是Animals
类的另一个版本,该类派生于DictionaryBase
。下面代码包括Add()、Remove()
和一个通过键访问的索引符的实现代码
public class Animals : DictionaryBase { //参数是键值 //继承于IDictionary接口,有自己的Add()方法,该方法带有两个object参数 public void Add(string newID, Animal newAnimal) { Dictionary.Add(newID, newAnimal); } //以一个键作为参数,与指定键值对应的项被删除 public void Remove(string animalID) { Dictionary.Remove(animalID); } public Animals() { } //索引符使用一个字符串键值,而不是索引,用于通过Dictionary的继承成员来访问存储的项,仍需进行数据类型转换 public Animal this[string animalID] { get { return (Animal)Dictionary[animalID]; } set { Dictionary[animalID] = value; } } }
基于DictionaryBase
的集合和基于CollectionBase
的集合之间的另一个区别是foreach
的工作方式稍有区别
foreach (Animal myAnimal in animalCollection) { WriteLine($"New {myAnimal.ToString()} object added to custom collection, Name = {myAnimal.Name}"); } //等价基于CollectionBase的集合的代码: foreach (DictionaryEntry myEntry in animalCollection) { WriteLine($"New {myEntry.Value.ToString()} object added to custom collection, Name = {((Animal)myEntry.Value).Name}"); }
有许多方式可以重写这段代码,以便直接通过foreach
访问Animal
对象,最简单的方式是实现一个迭代器
迭代器
IEnumerable
接口允许使用foreach
循环。在foreach
循环中并不是只能使用集合类,在foreach
循环中使用定制类通常有很多优点
但是重写使用foreach
循环的方式或者提供定制的实现方式并不简单。一个较简单的替代方法是使用迭代器,使用迭代器将有效地自动生成许多代码,正确地完成所有任务
迭代器的定义:它是一个代码块,按顺序提供要在foreach
块中使用的所有值。一般情况下,该代码块是一个方法,但也可以使用属性访问器和其他代码块作为迭代器
无论代码块是什么,其返回类型都是有限制的,这个返回类型与所枚举的对象类型不同,例如在表示Animal
对象集合的类中,迭代器返回类型不可能是Animal
,两种可能的返回类型是前面提到的接口类型IEnumerable
和IEnumerator
使用这两种类型的场合:
- 如果要迭代一个类,可使用方法
GetEnumerator()
,其返回类型是IEnumerator
- 如果要迭代一个类成员,例如一个方法,则使用
IEnumerable
在迭代器块中,使用yield
关键字选择要在foreach
循环中使用的值
yield return<value>;
使用迭代器:
using static System.Console; using System.Collections; class Test { public static IEnumerable SimpleList() { yield return "string 1"; yield return "string 2"; yield return "string 3"; } static void Main(string[] args) { foreach (string item in SimpleList()) WriteLine(item); } }
此处,静态方法SimpleList
就是迭代器块,因为是方法,所以使用IEnumberable
返回类型,使用yield
关键字为使用它的foreach
快提供了3个值,依次输出到屏幕上
实际上并没有返回string
类型的项,而是返回object
类型的值,因为object
是所有类型的基类,所以可以从yield
语句中返回任意类型
但编译器的智能程度很高,所以可以把返回值解释为foreach
循环需要的任何类型。这里代码需要string
类型的值,如果修改一行yield
代码使之返回整数,就会出现一个类型装换异常
可以使用yield break
将信息返回给foreach
循环的过程,遇到该语句时,迭代器的处理会立即中断,使用该迭代器的foreach
循环也一样
实现一个迭代器:
//primes.cs文件 using System.Collections; namespace Prime { //用于表示和生成指定范围内的所有质数 public class Primes { //存储范围内质数最小值 private long min; //存储范围内质数最大值 private long max; //默认构造函数,初始化一个从2到100的质数生成器 public Primes() : this(2, 100) { } //带参数构造函数,自定义质数查找范围 public Primes(long minimum, long maximum) { //最小的质数是2 if (minimum < 2) min = 2; else min = minimum; max = maximum; } //实现IEnumberable接口,提供迭代器以遍历质数序列 public IEnumerator GetEnumerator() { //遍历所有数 for (long possiblePrime = min; possiblePrime <= max; possiblePrime++) { //假定当前数为质数 bool isPrime = true; //检查小于或等于其平方根的数作为因子 for (long possibleFactor = 2; /*如果p可以分解为两个因数a和b,且a>b,则必定有a<=Sqrt(p) 因为a>Sqrt(p),那么b=p/a将小于Sqrt(p) 所以只需检查所有<=平方根的因子即可*/ //能否被2到该数平方根之间的所有数整除,能即素数 possibleFactor <= (long)Math.Floor(Math.Sqrt(possiblePrime)); possibleFactor++) { //如果找到可整除因子则不是质数 long remainderAfterDivision = possiblePrime % possibleFactor; if (remainderAfterDivision == 0) { isPrime = false; break; } } //若是质数则使用yield返回 if (isPrime) { yield return possiblePrime; } } } } } //测试文件 using static System.Console; using Prime; class Test { static void Main(string[] args) { Primes primesFrom2To1000 = new Primes(2, 1000); foreach (long i in primesFrom2To1000) Write($"{i} "); } }
深度复制
第9章介绍了使用受保护方法System.Object.MemberwiseClone()
进行浅度复制
public class Cloner { public int Val; public Cloner(int newVal) { Val = newVal; } //MemberwiseClone创建当前对象的一个浅复制 //对于引用类型成员复制引用而不是实际的对象内容 //对于值类型成员则直接复制其值 public object GetCopy() => MemberwiseClone(); }
深度复制:在创建对象的一个副本时,不仅复制了原始对象的所有基本数据类型的成员变量值,同时也复制了引用类型成员变量指向的对象,并且递归地对该对象所包含的引用类型成员也进行同样的复制操作。换句话说,深度复制会生成一个与原对象完全独立的新对象树
深度复制:
//简单类用于存储整数值 public class Content { public int Val; } //实现ICloneable接口以支持克隆功能 public class Cloner : ICloneable { //定义一个Content类型的成员变量 public Content MyContent = new Content(); //构造函数 public Cloner(int newVal) { MyContent.Val = newVal; } //实现ICloneable接口的Clone方法,用于创建当前对象的浅度复制 //在该实现中仅对Clone类本身进行复制,没有递归地复制引用类型成员 public object Clone() { //创建一个新的Cloner实例,将原Cloner实例中MyContent的Val属性值传递给实例 Cloner clonedCloner = new Cloner(MyContent.Val); //返回克隆后的Cloner对象,返回object类型 return clonedCloner; } }
使用包含在源Cloner
对象中的Content
对象(MyContent
)的Val
字段,创建一个新Cloner
对象。这个字段是一个值类型,所以不需要深度复制
如果Cloner
类的MyContent
字段也需要深度复制,就需要使用下面的代码:
public class Cloner : ICloneable { public Content MyContent = new Content(); ... public object Clone() { //创建一个新的Cloner实例 Cloner clonedCloner = new Cloner(); //调用Clone方法进行深度复制,确保内容也被复制一份新的副本 clonedCloner.MyContent = MyContent.Clone(); return clonedCloner; } }
为使这段代码能正常工作,还需要在Content
类上实现ICloneable
接口
比较
对象之间比较有两类:
- 类型比较
类型比较确定对象是什么,或者对象继承什么 - 值比较
类型比较
所有的类都从System.Object
中继承GetType()
方法,该方法和typeof()
运算符一起使用就可以确定对象的类型
if (myObj.GetType() == typeof(MyComplexClass)) //myObj is an instance of the class MyComplexClass.
封箱和拆箱
封箱boxing是把值类型转换为System.Object
类型或转换为由值类型实现的接口类型,拆箱unboxing是相反的转换过程
struct MyStruct { public int Val; } //可以把这种类型的结构放在object类型的变量中对其封箱 //创建新变量后赋值 MyStruct valType1 = new MyStruct(); valType1.Val = 5; //然后把它封箱在object类型的变量中 object refType = valType1;
当一个值类型变量被封箱时,实际上会创建一个新的对象实例,并将该值类型变量的值复制到这个新对象中。因此封箱后得到的对象包含的是原值类型的值的一个副本,而不是源值类型变量的引用
封箱后是创建了一个新的对象并存储了源值的副本,它们的内存空间并不相同,修改不会影响对方
[!important]
但要注意,当把一个引用类型赋予对象时,实际上复制的是对同一内存位置的引用,而不是复制整个对象的内容。这意味着新变量和原变量都指向同一个对象实例,修改会互相影响
class MyStruct//一旦改成类,在封箱后修改就会改变源值 { public int Val; } valType1.Val = 6; MyStruct valType2 = (MyStruct)refType; WriteLine($"valType2.Val = {valType2.Val}");//6 //如果是struct,那么拆箱后值还是初始值5
也可以把值类型封装到接口类型中,只要它们实现这个接口即可。例如假定MyStruct
类型实现IMyInterface
接口
interface IMyInterface {} struct MyStruct : IMyInterface { public int Val; }
接着把结构封箱到一个IMyInterface
类型中,然后拆箱:
MyStruct valType1 = new MyStruct(); IMyInterface refType = valType1; MyStruct ValType2 = (MyStruct)refType;
封箱是隐式执行的,但拆箱一个值需要进行显式数据类型转换
封箱非常有用,有两个重要的原因:它允许在项的类型是object
的集合中使用值类型,其次,有一个内部机制允许在值类型上调用object
方法
在访问值类型内容前必须进行拆箱
is运算符
is
运算符用于检查对象是否为给定类型或是否可转换为给定类型,如果是返回true
<expression>is<type>
- 如果是类类型,且表达式也是该类型或它继承该类型或它可以封装到该类型中,则结果为
true
- 如果是接口类型,且表达式也是该类型或它是实现该接口的类型,则结果为
true
- 如果是值类型,且表达式也是该类型或它可以拆箱到该类型中,则结果为
true
//checker.cs文件 using System; using static System.Console; namespace checker { class Checker { //Check方法接受一个object类型的参数 public void Check(object param1) { if (param1 is ClassA) WriteLine("Variable can be converted to ClassA."); else WriteLine("Variable can't be converted to ClassA."); if (param1 is IMyInterface) WriteLine("Variable can be converted to IMyInterface."); else WriteLine("Variable can't be converted to IMyInterface."); if (param1 is MyStruct) WriteLine("Variable can be converted to MyStruct."); else WriteLine("Variable can't be converted to MyStruct."); } } interface IMyInterface { } class ClassA : IMyInterface { } class ClassB : IMyInterface { } class ClassC { } class ClassD : ClassA { } struct MyStruct : IMyInterface { } class Program { static void Main(string[] args) { //创建Checker类实例 Checker check = new Checker(); ClassA try1 = new ClassA(); ClassB try2 = new ClassB(); ClassC try3 = new ClassC(); ClassD try4 = new ClassD(); MyStruct try5 = new MyStruct(); //将try封箱为object类型 object try6 = try5; WriteLine("Analyzing ClassA type variable:"); check.Check(try1); WriteLine("\nAnalyzing ClassB type variable:"); check.Check(try2); WriteLine("\nAnalyzing ClassC type variable:"); check.Check(try3); WriteLine("\nAnalyzing ClassD type variable:"); check.Check(try4); WriteLine("\nAnalyzing MyStruct type variable:"); check.Check(try5); WriteLine("\nAnalyzing boxed MyStruct type variable:"); check.Check(try6); } } }
如果一个类型没有继承一个类,该类型不会与该类兼容
MyStruct
类型本身的变量和该变量的封箱变量与MyStruct
兼容,因为不能把引用类型转换为值类型
值比较
运算符重载
通过运算符重载可以对设计的类使用标准运算符,因为在使用特定的参数类型时,为这些运算符提供了自己的实现代码,其方式与重载方法相同,也是为同名方法通过不同的参数
可以在运算符重载的实现中执行任何需要的操作
要重载运算符,可给类添加运算符类型成员,它们必须是static
。一些运算符有多种用途,因此要指定要处理多少个操作数,以及这些操作数的类型。
一般情况下,操作数类型和定义运算符的类相同。但也可以定义处理混合类型的运算符
重载+
运算符,可使用下述代码:
AddClass1 operator +(AddClass1 op1, AddClass1 op2) { AddClass1 returnVal = new AddClass1(); returnVal.val = op1.val + op2.val; return returnVal; }
运算符重载和标准静态方法声明类似,但使用关键字operator
和运算符本身代替方法名,现在使用该类就是相加就是加Val
值
AddClass1 op3 = op1 + op2;
重载所有的二元运算符都是一样的,一元运算符看起来也是类似的,但只有一个参数:
public static AddClass1 operator -(AddClass1 op1) { AddClass1 returnVal = new AddClass1(); //返回其相反数 returnVal.val = -op1.val; return returnVal; }
这两个运算符处理的操作数类型与类相同,返回值也是该类型
class Test { public class AddClass1 { public int val; public static AddClass3 operator +(AddClass1 op1, AddClass2 op2) { AddClass3 returnVal = new AddClass3(); returnVal.val = op1.val + op2.val; return returnVal; } } public class AddClass2 { public int val; } public class AddClass3 { public int val; } public static void Main() { AddClass1 op1 = new AddClass1(); op1.val = 5; AddClass2 op2 = new AddClass2(); op2.val = 5; AddClass3 op3 = op1 + op2; WriteLine(op3.val); } }
如果把相同的运算符添加到AddClass2
,代码就会出错,因为它弄不清要使用哪个运算符。因此要注意不要把签名相同的运算符添加到多个存在继承或包含关系的类中
还要注意,如果混合了类型,操作数的顺序必须与运算符重载的参数顺序相同。如果使用了重载运算符和顺序错误的操作数,操作就会失败
AddClass3 op3 = op2 + op1;//error
当然,可以提供另一个重载运算符和倒序的参数:
public static AddClass3 operator +(AddClass2 op1, AddClass1 op2) { AddClass3 returnVal = new AddClass3(); returnVal.val = op1.val + op2.val; return returnVal; }
可以重载下列运算符:
- 一元运算符:+,–, !, ~,++,––, true, false
- 二元运算符:+,–,*,/,%, &,|, ^,<<,>>
- 比较运算符:==, !=,<,>,<=,>=
如果重载true和false运算符,就可以在布尔表达式中使用类
不能重载赋值运算符,例如+=
,但这些运算符使用与它们对应的简单运算符,所以不必担心它们。重载+
意味着+=
如期执行
一些运算符必须成对重载,如果重载>
,就必须重载<
。许多情况下,可以在这些运算符中调用其他运算符,以减少需要的代码数量和可能发生的错误
public static bool operator >=(AddClass1 op1, AddClass1 op2) => (op1.val >= op2.val); public static bool operator<(AddClass1 op1, AddClass1 op2) => !(op1 >= op2);//这里使用取反,也可以直接比较 //Also need implementations for<= and > operators.
这同样适用于==和!=,但对于这些运算符,通常需要重写Object.Equals()
和Object.GetHashCode()
,因为这两个函数也可以用于比较对象。重写这些方法,可以确保无论类的用户使用什么技术,都能得到相同的结果。这不太重要,但应增加进来,以保证其完整性
//重写Equals方法以比较两个AddClass1实例的val属性是否相等 public override bool Equals(object op1) => this.val == ((AddClass1)op1).val; //重写GetHashCode方法,基于val属性生成哈希码 public override int GetHashCode() => val;
GetHashCode()
可根据其状态,获取对象实例的一个唯一int
值
注意Equals()
使用object
类型参数,我们需要使用这个签名,否则就将重载这个方式,而不是重写。类的用户仍可以访问默认的实现代码。这样就必须使用数据类型转换得到所需的结果,这常需要使用本章前面讨论的is
运算符检查对象类型
if (op1 is AddClass1) { return val == ((AddClass1)op1).val; } else { throw new ArgumentException($"Cannot compare AddClass1 objects with objects of type {op1.GetType().ToString()}"); }
如果传给Equals
的操作数类型有误或不能转换为正确类型,就会抛出一个异常,如果只允许对类型完全相同的两个对象进行比较,就需要对if语句进行修改
if (op1.GetType() == typeof(AddClass1))
IComparable和IComparer接口
这两个接口是.NET Framework中比较对象的标准方式。这两个接口之间的差别如下:
IComparable
在要比较的对象的类中实现,可以比较该对象和另一个对象IComparer
在一个单独的类中实现,可以比较任意两个对象
一般使用IComparable
给出类的默认比较代码,使用其他类给出非默认的比较代码
IComparable
提供了一个方法CompareTo()
,该方法接受一个对象,当前对象小于比较对象则返回负数,大于比较对象则返回正数
例如,在实现该方法时,使其可以接受一个Person
对象,以便确定这个人比当前的人更年老还是更年轻。实际上,这个方法返回一个int
,所以也可以确定第二个人与当前的人的年龄差:
if (person1.CompareTo(person2) == 0) { WriteLine("Same age"); } else if (person1.CompareTo(person2) > 0) { WriteLine("person 1 is Older"); } else { WriteLine("person1 is Younger"); }
IComparer
也提供一个方法Compare()
。这个方法接受两个对象, 返回一个整型结果,和ComparerTo()
相同
if (personComparer.Compare(person1, person2) == 0) { WriteLine("Same age"); } else if (personComparer.Compare(person1, person2) > 0) { WriteLine("person 1 is Older"); } else { WriteLine("person1 is Younger"); }
提供给这两种方法的参数是System.Object
类型。这意味着可以比较一个对象与其他任意类型的另一个对象。所以在返回结果之前,通常需要进行某种类型比较,如果使用了错误类型会抛出异常
.NET Framework在Comparer
类上提供了IComparer
接口的默认实现方式,Comparer
位于System.Collections
名称空间中,可以对简单类型以及支持IComparable
接口的任意类型进行特定文化的比较
可通过下面的代码使用它:
//这里使用Comparer.Default静态成员获取Comparer类的一个实例,接着使用Compare()方法比较前两个字符串 string firstString = "First String"; string secondString = "Second String"; WriteLine($"Comparing '{firstString}' and '{secondString}', " + $"result: {Comparer.Default.Compare(firstString, secondString)}"); int firstNumber = 35; int secondNumber = 23; WriteLine($"Comparing '{firstNumber}' and '{ secondNumber }', " + $"result: {Comparer.Default.Compare(firstNumber, secondNumber)}");
Compare
类注意事项:
- 检查传给
Comparer.Compare()
的对象,看看它们是否支持IComparable
。如果支持,就使用该实现代码 - 允许使用
null
值,它表示“小于”其他任意对象 - 字符串根据当前文化来处理。要根据不同的文化或语言处理字符串,
Comparer
类必须使用其构造函数进行实例化,以便传送用于指定所使用的文化的System.Globalization.CultureInfo
对象字符串在处理时要区分大小写。如果要以不区分大小写的方式来处理它们,就需要使用CaseInsensitiveComparer
类,该类以相同的方式工作
对集合排序
许多集合类可以用对象的默认比较方式进行排序,或者用定制方法来排序
ArrayList
包含方法Sort()
,该方法使用时可不带参数,此时使用默认的比较方式,也可给它传IComparer
接口,以比较对象对
给ArrayList
填充了简单类型时,例如整数或字符串,就会进行默认比较。对于自己的类,必须在类定义中实现IComparable
或创建一个支持IComparer
的类来进行比较
System.Collections
命名空间中的一些类(包括CollectionBase
)都没有提供排序方法。如果要对派生于这个类的集合排序,就必须多做一些工作,自己给内部的List
集合排序
下面的实例说明如何使用默认和非默认的比较方式给列表排序:
//Person.cs文件 namespace ListSort { //实现IComparable接口以支持排序功能 public class Person : IComparable { public string Name; public int Age; //构造函数 public Person(string name, int age) { Name = name; Age = age; } //实现IComparable接口的CompareTo方法,用于比较2个Person对象的年龄大小 //返回值为负数表示当前对象 < 传入对象,为正数表示当前对象 > 传入对象多少岁 public int CompareTo(object obj) { //检查传入对象是否为Person类型 if (obj is Person) { //将传入对象转换为Person类型以便访问其Age属性 Person otherPerson = obj as Person; //返回差值 return this.Age - otherPerson.Age; } else { throw new ArgumentException("Object to compare to is not a Person object."); } } } } //PersonComparerName.cs文件 using System.Collections; namespace ListSort { //实现IComparer接口用于比较两个对象 public class PersonComparerName : IComparer { //创建一个静态默认实例方便全局访问 public static IComparer Default = new PersonComparerName(); //实现IComparer接口的Compare方法 public int Compare(object x, object y) { //检查传入的对象是否是Person类型 if (x is Person && y is Person) { //将对象转换为Person类型,然后使用默认Comparer进行Name的比较 return Comparer.Default.Compare(((Person)x).Name, ((Person)y).Name); } else { throw new ArgumentException("One or both objects to compare are not Person objects."); } } } } //Program.cs文件 namespace ListSort { //实现IComparable接口以支持排序功能 public class Person : IComparable { public string Name; public int Age; //构造函数 public Person(string name, int age) { Name = name; Age = age; } //实现IComparable接口的CompareTo方法,用于比较2个Person对象的年龄大小 //返回值为负数表示当前对象 < 传入对象,为正数表示当前对象 > 传入对象多少岁 public int CompareTo(object obj) { //检查传入对象是否为Person类型 if (obj is Person) { //将传入对象转换为Person类型以便访问其Age属性 Person otherPerson = obj as Person; //返回差值 return this.Age - otherPerson.Age; } else { throw new ArgumentException("Object to compare to is not a Person object."); } } } }
转换
重载转换运算符
可以定义类型之间的隐式和显式转换。如果在不相关的类型之间转换,例如类型之间没有继承关系,也没有共享接口,就必须这么做
使用implicit
关键字来声明一个用户自定义类型的转换运算符时,编译器允许自动进行这种转换
使用explicit
关键字声明的转换运算符要求必须显式地使用类型转换操作符来进行转换
checked
关键字用于显式启用整数算术运算和转换时的溢出检查
as运算符
expression as type
只适用于下列情况
- expression的类型是type
- expression可以隐式转换为type类型
- expression可以封箱到type类型中
如果不能从expression转换到type,表达式结果就是null
泛型
一般情况下新的类型需要额外功能,所以常常需要用到新的集合类,因此创建集合类会花费大量时间,而泛型类是以实例化过程中提供的类型或类为基础建立的,可以轻易地对对象进行强类型化
CollectionClass items = new CollectionClass(); items.Add(new ItemClass()); //使用以下代码: CollectionClass<ItemClass> items = new CollectionClass<ItemClass>(); items.Add(new ItemClass());
尖括号是把类型参数传给泛型类型的方式,定义了一个名为CollectionClass
的泛型类,它允许存储任何与ItemClass
类型兼容的对象
泛型不只涉及集合。创建一个泛型类,就可以生成一些方法,它们的签名可以强类型化为需要的任何类型,该类型甚至可以是值类型或引用类型,处理各自的操作。 还可以把用于实例化泛型类的类型限制为支持某个给定的接口,或派生自某种类型,从而只允许使用类型的一个子集。泛型并不限于类,还可以创建泛型接口、泛型方法(可以在非泛型类上定义),甚至泛型委托。这将极大地提高代码的灵活性,正确使用泛型可以显著缩短开发时间
[!note] C++模板和C#泛型类的一个区别
C++中,编译器会检测出在哪里使用了模板的某个特定类型,然后编译需要的代码来创建这个类型
C#中,所有操作都在运行期间进行
可空类型
泛型使用System.Nullable<T>
类型提供了使值类型为空的一种方式
//这两个赋值是等价的 System.Nullable<int> nullableInt; System.Nullable<int> snullableInt = new System.Nullable<int>();
声明了一个变量nullableInt
,可以拥有int
变量能包含的任意值,还可以拥有值null
。和其他变量一样,不能在初始化之前使用它
if(nullableInt == null) //还可以使用HasValue类型,这不适用于引用类型 //true非空,false空 if(nullableInt.HasValue)
声明可空类型变量一般使用下面的语法
int? nullableInt;
int?
是System.Nullable<int>
的缩写
运算符和可空类型
int? op1 = 5; //不能直接将一个可空类型与非可空类型进行算术运算 int result = op1 * 2; //需要进行显式转换 int result = (int)op1 * 2; //或通过Value属性访问其值 int result = op1.Value * 2;
??运算符
称为空结合运算符,是一个二元运算符,允许给可能等于null
的表达式提供另一个值。如果第一个值不是null
,该运算符就等于第一个操作数,否则就等于第二个操作数
//这两个表达式作用等价 op1 ?? op2 op1 == null ? op2 : op1
op1可以是任意可空表达式,包括引用类型和可空类型
int? op1 = null; int result = op1 * 2 ?? 5; //在结果中放入int类型的变量不需要显式转换,??运算符会自动处理这个转换,还可以把??表达式的结果放在int?中
?.运算符
称为条件成员访问运算符或空条件运算符,有助于避免繁杂的空值检查造成的代码歧义
class Person { public string Name { get; set; } } Person person = null; string name = person?.Name; //如果person为null,则name也会被赋值为null
如果没有使用?.
运算符,尝试访问person.Name
将会导致NullReferenceException
异常。但使用了?.
后,当 erson
为null
时,name
会被赋予null
值,并且代码能够安全执行下去
空条件运算符的另一个用途是触发事件
//触发事件常见方法: var onChanged = OnChanged; if (onChanged != null) { onChanged(this, args); }
但这种模式不是线程安全的,因为有人会在null
检查已经完成后退订最后一个事件处理程序,此时会抛出异常,使用空条件符可以避免这种情况
//如果OnChanged不为null,则会调用它的Invoke方法来触发事件;若OnChanged为null,整个表达式会被评估为null OnChanged?.Invoke(this, args);
使用可空类型
using static System.Math; using static System.Console; namespace Vector { //定义一个表示向量的类 class Vector { //向量的极坐标属性:极径R和极角Theta public double? R = null; public double? Theta = null; //计算并返回极角的弧度值 public double? ThetaRadians { get { return (Theta * Math.PI / 180.0);//角度转换为弧度 } } //构造函数,根据给定的极径和极角创建一个新的向量实例 public Vector(double? r, double? theta) { //确保极径非负,并将极角限制在0~360度之间 if (r < 0) { r = -r; theta += 180; } theta = theta % 360; //设置向量的极径和极角属性 R = r; Theta = theta; } public static Vector operator +(Vector op1, Vector op2) { try { //检查两个向量的有效性并进行加法运算 double newX = op1.R.Value * Sin(op1.ThetaRadians.Value) + op2.R.Value * Sin(op2.ThetaRadians.Value); double newY = op1.R.Value * Cos(op1.ThetaRadians.Value) + op2.R.Value * Cos(op2.ThetaRadians.Value); //计算新向量的极径和极角 double newR = Sqrt(newX * newX + newY * newY); double newTheta = Atan2(newX, newY) * 180.0 / PI; //返回新的向量实例 return new Vector(newR, newTheta); } catch { //如果有无效数据,则返回一个包含null值的新向量 return new Vector(null, null); } } //一元减法运算符重载,取相反向量 public static Vector operator -(Vector op1) => new Vector(-op1.R, op1.Theta); //减法运算符重载 public static Vector operator -(Vector op1, Vector op2) => op1 + (-op2); //重写ToString方法,以字符串形式重写 public override string ToString() { string rString = R.HasValue ? R.ToString() : "null"; string thetaString = Theta.HasValue ? Theta.ToString() : "null"; //返回格式化的字符串表示形式 return string.Format($"({rString}, {thetaString})"); } } class Program { static void Main(string[] args) { //获取输入向量 Vector v1 = GetVector("vector1"); Vector v2 = GetVector("vector2"); //输出向量相加和相减的结果 WriteLine($"{v1} + {v2} = {v1 + v2}"); WriteLine($"{v1} - {v2} = {v1 - v2}"); } //获取向量极径和极角的方法 static Vector GetVector(string name) { WriteLine($"Input {name} magnitude:"); double? r = GetNullableDouble(); WriteLine($"Input {name} angle (in degrees):"); double? theta = GetNullableDouble(); //创建并转换为Vector类型返回 return new Vector(r, theta); } //获取输入的可空双精度浮点数的方法 static double? GetNullableDouble() { double? result; string userInput = ReadLine(); //尝试将输入转换为double类型 try { result = double.Parse(userInput); } catch { result = null; } //如果转换失败,返回null,否则返回转换结果 return result; } } }
System.Collections.Generic命名空间
该命名空间包含用于处理集合的泛型类型
//T类型对象的集合 List<T> //与K类型的键值相关的V类型的项的集合 Dictionary<K, V>
List<T>
泛型集合类型更快捷、更便于使用,会自动实现正常情况下需要实现的许多方法
//创建了一个T类型对象的集合 List<T> myCollection = new List<T>();
不需要定义类、实现方法或执行其他操作,可以把List<T>
传给构造函数,在集合中设置项的起始列表。List<T>
还有一个Item
属性,允许进行类似于数组的访问
T itemAtIndex2 = myCollectionOfT[2];
使用List<T>
:
static void Main(string[] args) { /*Animals animalCollection = new Animals();替换为下列代码*/ List<Animal> animalCollection = new List<Animal>(); animalCollection.Add(new Cow("Rual")); animalCollection.Add(new Chicken("Donna")); foreach (Animal myAnimal in animalCollection) { myAnimal.Feed(); } }
对泛型列表进行排序和搜索
和普通的接口有些区别,使用泛型接口IComparer<T>
和IComparable<T>
,它们提供了略有区别的、针对特定类型的方法
Comparison<T>
:这个委托类型用于排序方法,其返回类型和参数如下:int method(T objectA, T objectB)
Predicate<T>
:这个委托类型用于搜索方法,其返回类型和参数如下:bool method(T targetObject)
可以定义任意多个这样的方法,使用它们实现List<T>
的搜索和排序方法
Dictionary<K, V>
这个类型可定义键/值对的集合,需要实例化两个类型,分别用于键和值,以表示集合中的各项
使用强类型化的Add()
方法添加键/值对:
//初始化一个键为字符串类型、值为整数类型的新字典 Dictionary<string, int> things = new Dictionary<string, int>(); things.Add("Green Things", 29); things.Add("Blue Things", 94); things.Add("Yellow Things", 34); things.Add("Red Things", 52); things.Add("Brown Things", 27);
可以使用Key
和Values
属性迭代集合中的键和值:
foreach (string key in things.Keys) { WriteLine(key); } foreach (int value in things.Values) { WriteLine(value); }
还可以迭代集合中的各个项,把每项作为一个KeyValuePair<K, V>
实例来获取:
foreach (KeyValuePair<string, int> thing in things) { WriteLine($"{thing.Key} = {thing.Value}"); }
对于Dictionary<K, V>
要注意的一点是,每个项的键都必须是唯一的 。 如果要添加的项的键与已有项的键相同,就会抛出ArgumentException
异常
所以,Dictionary<K, V>
允许把IComparer<K>
接口传递给其构造函数。如果要把自己的类用作键, 且它们不支持IComparable
或IComparable<K>
接口,或者要使用非默认的过程比较对象,就必须把IComparer<K>
接口传递给其构造函数
C#6引入了一个新特性:索引初始化器,它支持在对象初始化器内部初始化索引:
var zahlen = new Dictionary<int, string>() { [1] = "eins", [2] = "zwei" };
可以使用表达式体方法
public ZObject ToGerman() => new ZObject() { [1] = "eins", [2] = "zwei"};
定义泛型类型
定义泛型类
只需在类定义中包含尖括号语法:
class GenericClass<T>
T可以是任意标识符,只需遵循通常的C#命名规则即可。泛型类可在其定义中包含任意多个类型参数,参数之间用逗号分隔:
class MyGenericClass<T1, T2, T3> { private T1 innerT1Object; public MyGenericClass(T1 item) { //innerT1Object = new T1(); //不能假定为类提供了什么类型,这样无法编译 innerT1Object = item; } public T1 InnerT1Object { get { return innerT1Object; } } }
类型T1的对象可以传递给构造函数,只能通过InnerT1Object
属性对这个对象进行只读访问
//使用typeof运算符获取类型参数的实际类型,并将其转换为字符串 public string GetAllTypesAsString() { return "T1 = " + typeof(T1).ToString() + ", T2 = " + typeof(T2).ToString() + ", T3 = " + typeof(T3).ToString(); }
可以做一些其他工作,尤其是对集合进行操作,因为处理对象组是非常简单的,不需要对对象类型进行任何假设
[!caution]
在比较为泛型类型提供的类型值和null
时,只能使用运算符==和!=
default关键字
要确定用于创建泛型类实例的类型,需要知道它们是引用还是值类型
如果是值类型不能取null
值
public MyGenericClass() { innerT1Object = default(T1); }
如果是引用类型赋予null
,值类型赋予默认值,default
关键字允许对必须使用的类型执行更多操作
约束类型
用于泛型类的类型称为无绑定类型,因为没有对它们进行任何约束。通过约束类型,可以限制可用于实例化泛型类的类型
//在类定义中,可以使用where关键字实现,可以提供多个约束,逗号隔开 class MyGenericClass<T> where T : constraint1, constraint2
可以使用多个where
语句,定义泛型类需要的任意类型或所有类型上的约束:
class MyGenericClass<T1, T2> where T1 : constraint1 where T2 : constraint2
约束必须出现在继承说明符的后面:
class MyGenericClass<T1, T2> : MyBaseClass, IMyInterface where T1 : constraint1 where T2 : constraint2
泛型类型约束
struct //必须是值类型 class //必须是引用类型 base-class //必须是基类或继承自基类,该结束可以是任意类名 interface //必须是接口或实现了接口 new() //必须有一个公共无参数构造函数
如果使用new()
作为约束,它必须是为类型指定的最后一个约束
可通过base-class
约束,把一个类型参数用作另一个类型参数的约束
class MyGenericClass<T1, T2> where T2 : T1
T2必须与T1的类型相同或继承自T1,这称为裸类型约束,表示一个泛型类型参数用作另一个类型参数的约束
class MyGenericClass<T1, T2> where T2 : T1 where T1 : T2 //类型参数不能循环,无法编译
从泛型类中继承
如果某个类型所继承的基类型中受到了约束,该类型就不能解除约束。也就是说,类型T在所继承的基类型中使用时,该类型必须受到至少与基类型相同的约束
//因为T在Farm<T>中被约束为Animal,把它约束为SuperCow就是把T约束为这些值的一个子集 class SuperFarm<T> : Farm<T> where T : SuperCow {} //以下代码是错误的 class SuperFarm<T> : Farm<T> where T : struct{}
泛型运算符
在C#中,可以像其他方法一样进行运算符的重写,这也可以在泛型类中实现此类重写
//定义一个静态运算符重载方法,用于将一个Farm<T>对象与一个List<T>对象中的动物合并到一个新的Farm<T>中 public static Farm<T> operator +(Farm<T> farm1, List<T> farm2) { //创建一个新的Farm<T>实例,用于存储合并后的动物集合 Farm<T> result = new Farm<T>(); //遍历第一个Farm<T>类型中的所有动物并将其添加到新农场中 foreach (T animal in farm1.Animals) { result.Animals.Add(animal); } //遍历第二个List<T>类型,仅将其中不存在于新农场的动物添加进去 foreach (T animal in farm2) { if (!result.Animals.Contains(animal)) { result.Animals.Add(animal); } } //返回合并后的新农场对象 return result; } //另一个重载版本,允许将List<T>对象放在前面进行合并操作。这里采用右结合律,实际调用的是上面定义的方法 public static Farm<T> operator +(List<T> farm1, Farm<T> farm2) => farm2 + farm1;
泛型结构
可以用与泛型类相同的方式创建泛型结构
public struct MyStruct<T1, T2> { public T1 item1; public T2 item2; }
定义泛型方法
泛型方法中,返回类型或参数类型由泛型类型参数来确定
public T GetDefault<T>() => default(T)
可以通过非泛型类来实现泛型方法:
public class Defaulter { public T GetDefault<T>() => default(T); }
但如果类是泛型的,就必须为泛型方法使用不同的标识符
//该代码无法编译,必须重命名方法或类使用的类型T public class Defaulter<T> { public T GetDefault<T>() => default(T); }
泛型方法参数可以采用与类相同的方式使用约束,可以使用任意的类类型参数
public class Defaulter<T1> { public T2 GetDefault<T2>() where T2 : T1 { return default(T2); } }
为方法提供的类型T2必须与给类提供的T1相同或者继承自T1。这是约束泛型方法的常用方式
定义泛型委托
定义委托
public delegate int MyDelegate(int op1, int op2);
定义泛型委托,只需要声明和使用一个或多个泛型类型参数
public delegate T1 MyDelegate<T1, T2>(T2 op1, T2 op2) where T1: T2;
这里也可以使用约束
变体
变体是协变和抗变的统称
多态性允许把派生类型的对象放在基类型的变量中,但这不适用于接口
//以下代码无法工作 IMethaneProducer<Cow> cowMethaneProducer = myCow; IMethaneProducer<Animal> animalMethaneProducer = cowMethaneProducer;
Cow
支持IMethaneProducer<Cow>
接口,第一行代码没有问题,但第二行代码预先假定两个接口类型有某种关系,但实际上这种关系并不存在,所以无法把一种类型转换为另一种类型
因为泛型类型的所有类型参数都是不变的,但可以在泛型接口和泛型委托上定义变体类型参数
为使上面的代码工作,IMethaneProducer<T>
接口的类型参数T必须是协变的,有了协变的类型参数,就可以在MethaneProducer<Cow>
和IMethaneProducer<Animal>
之间建立继承关系。这样一种类型的变量就可以包含另一种类型的值,这与多态性类似,但更复杂些
抗变和协变是类似的,但方向相反。抗变不能像协变那样把泛型接口值放在使用基类型的变量中,但可以把该接口放在使用派生类型的变量中
IGrassMuncher<Cow> cowGrassMuncher = myCow; IGrassMuncher<SuperCow> superCowGrassMuncher = cowGrassMuncher;
协变
要把泛型类型参数定义为协变,可在类型定义中使用out
关键字
public interface IMethaneProducer<out T>
对于接口定义,协变类型参数只能用作方法的返回值或属性get
访问器
协变意味着子类类型的集合可以被看作是父类类型的集合。在泛型上下文中,如果一个类型参数用out
关键字标记为协变,则该类型参数可以在派生类上进行隐式转换
抗变
要把泛型类型参数定义为抗变,可在类型定义中使用in
关键字
public interface IGrassMuncher<in T>
对于接口定义,抗变类型参数只能用作方法参数,不能用作返回类型
抗变允许父类类型的集合被视为子类类型的集合。在泛型上下文中,如果一个类型参数用in
关键字标记为抗变,则该类型参数可以在基类上进行隐式转换
- 协变 关注的是“输出”,即一个对象能够产出的数据类型。它允许我们向上转型泛型容器或委托,并且能够正确地获取其中包含的更基类类型的元素。
- 抗变 关注的是“输入”,即一个函数或委托期望接收的数据类型。它允许我们将能处理更基类类型参数的方法或委托向下转型,以便它们能处理更具体类型的参数
高级C#技术
::运算符和全局命名空间限定符
::
运算符提供了另一种访问命名空间中类型的方式。如果要使用一个命名空间的别名,但该别名与实际命名空间层次结构之间的界限不清晰,就必须使用::
运算符
using MyNamespaceAlias = MyRootNamespace.MyNestedNamespace; namespace MyRootNamespace { namespace MyNamespaceAlias { public class MyClass { } } namespace MyNestedNamespace { public class MyClass { } } }
MyRootNamespace
中的代码使用以下代码引用一个类:
MyNamespaceAlias.MyClass
这行代码表示的类是MyRootNamespace.MyNamespaceAlias.MyClass
,而不是MyRootNamespace.MyNestedNamespace.MyClass
也就是说,MyRootNamespace.MyNamespaceAlias
名称空间隐藏了由using
语句定义的别名,该别名指向MyRootNamespace.MyNestedNamespace
名称空间。仍然可以访问这个名称空间以及其中包含的类,但需要使用不同的语法:
MyNestedNamespace.MyClass //还可以使用::运算符 MyNamespaceAlias::MyClass
使用这个运算符会迫使编译器使用由using
语句定义的别名,因此代码指向MyRootNamespace. MyNestedNamespace.MyClass
::
运算符还可以与global
关键字一起使用,它实际上是顶级根名称空间的别名。这有助于更清晰地说明要指向哪个名称空间
//明确指定使用全局范围内的System命名空间 global::System.Collections.Generic.List<int>
定制异常
有时可以从包括异常的System.Exception
基类中派生自己的异常类,并使用它们,而不是使用标准的异常。这样就可以把更具体的信息发送给捕获该异常的代码,让处理异常的捕获代码更有针对性
例如,可以给异常类添加一个新属性,以便访问某些底层信息,这样异常的接收代码就可以做出必要的改变,或者仅给出异常起因的更多信息
using System; // 自定义异常类 public class CustomException : Exception { public CustomException() : base() { } public CustomException(string message) : base(message) { } public CustomException(string message, Exception inner) : base(message, inner) { } } class Program { static void Main() { try { // 模拟一个可能引发异常的操作 TriggerCustomException(); } catch (CustomException ex) { Console.WriteLine("Caught a custom exception: " + ex.Message); } catch (Exception ex) { Console.WriteLine("Caught an unexpected exception: " + ex.Message); } } static void TriggerCustomException() { // 抛出自定义异常 throw new CustomException("This is a custom exception."); } }
事件
事件类似于异常,因为它们都由对象抛出,并且都可以通过我们提供的代码来处理
但它们也有几个重要区别,最重要的区别是没有try...catch
类似的结构来处理事件,必须订阅它们,订阅一个事件的含义是提供代码,在事件发生时执行这些代码,它们称为事件处理程序
单个事件可供多个处理程序订阅,在该事件发生时,这些处理程序都会被调用,其中包含引发该事件的对象所在的类中的事件处理程序,事件处理程序也可能在其他类中
事件处理程序本身都是简单方法。对事件处理方法的唯一限制是它必须匹配事件所要求的返回类型和参数。这个限制是事件定义的一部分,由一个委托指定
在事件中使用委托是非常有用的
基本处理过程如下所示:
- 应用程序创建一个可以引发事件的对象
例如,假定一个即时消息传送应用程序创建的对象表示一个远程用户的连接。当接收到远程用户通过该连接传送来的消息时,这个连接对象会引发一个事件 - 应用程序订阅事件
为此,即时消息传送应用程序将定义一个方法,该方法可以与事件指定的委托类型一起使用,把这个方法的一个引用传送给事件,而事件的处理方法可以是另一个对象的方法,例如,当接收到消息时进行显示的显示设备对象 - 引发事件后,就通知订阅器
当接收到通过连接对象传来的即时消息时,就调用显示设备对象上的事件处理方法。因为使用的是一个标准方法,所以引发事件的对象可以通过参数传送任何相关的信息,这样就大大增加了事件的通用性
处理事件
要处理事件,需要提供一个事件处理方法来订阅事件,该方法的返回类型和参数应该匹配事件指定的委托
using System.Timers; using static System.Console; namespace Event { class Program { //记录当前显示到字符串中的字符 static int counter = 0; //要逐个显示的字符串 static string displayString = "This string will appear one letter at a time. "; static void Main(string[] args) { //创建一个新实例,设置间隔时间为100毫秒 System.Timers.Timer myTimer = new System.Timers.Timer(100); //当计时器触发Elapsed事件时,调用WriteChar方法 myTimer.Elapsed += new ElapsedEventHandler(WriteChar); //开始计时器 myTimer.Start(); //让主线程等待足够长的时间以确保所有字符都能被输出 System.Threading.Thread.Sleep(displayString.Length * 100 + 100); } //事件处理程序,设置间隔时间后逐个显示字符串中的字符 static void WriteChar(object source, ElapsedEventArgs e) { //显示字符串中的下一个字符,并更新counter值 Write(displayString[counter++]); //当counter大于等于字符串长度时,表示所有字符已经输出完毕,停止计时器 if (counter >= displayString.Length) { WriteLine($"字符数:{counter}"); ((System.Timers.Timer)source).Stop(); } } } }
用于引发事件的对象是System.Timers.Timer
类的一个实例。使用一个时间段来初始化该对象。当使用Start()
方法启动Timer
对象时,就引发一系列事件,Main()
用100毫秒初始化Timer
对象,所以在启动该对象后,1秒钟内将引发10次事件
把处理程序与事件关联起来,即订阅它。为此可以使用+=运算符,给事件添加一个处理程序,其形式是使用事件处理方法初始化的一个新委托实例
myTimer.Elapsed += new ElapsedEventHandler(WriteChar);
这行代码在列表中添加一个处理程序,当引发Elapsed
事件时,就会调用该处理程序。可给列表添加多个处理程序,只要它们满足指定的条件即可。当引发事件时会依次调用每个处理程序
可以使用方法组概念来简化添加事件处理程序的语法:
myTimer.Elapsed += WriteChar;
最终结果是完全相同的,但不必显式指定委托类型,编译器会根据使用事件的上下文来指定它。但它降低了可读性,不再能一眼看出使用了什么委托类型
定义事件
using System.Timers;//用于使用定时器类 using static System.Console; namespace DefineEvent { //委托类型,用于处理接收到消息事件的方法 /*string参数把Connection对象收到的即时消息发送给Display对象 定义了委托或者找到合适的现有委托后,就可以把事件本身定义为Connection类的一个成员*/ public delegate void MessageHandler(string messageText); //表示连接的类 public class Connection { //公共事件成员变量MessageArrived,MessageHandler是一个委托类型,用于指定触发事件时需要调用的方法签名 //当有新消息时触发此事件 public event MessageHandler MessageArrived; //私有变量,用于定期检查新消息的System.Timers.Timer实例 private System.Timers.Timer pollTimer; //构造函数,初始化定时器,并添加Elapsed事件处理程序 public Connection() { //设置定时器间隔为100毫秒 pollTimer = new System.Timers.Timer(100); //当计时器结束时执行CheckForMessage方法 pollTimer.Elapsed += new ElapsedEventHandler(CheckForMessage); } //开始检查新消息,即启动定时器 public void Connect() => pollTimer.Start(); //停止检查新消息,即停止定时器 public void Disconnect() => pollTimer.Stop(); //私有静态随机数生成器,用于模拟随机接收消息的情况 private static Random random = new Random(); //私有方法,作为定时器Elapsed事件的回调函数 private void CheckForMessage(object source, ElapsedEventArgs e) { //检查新消息的通知信息,并且仅在MessageArrived事件有订阅者时才触发事件 WriteLine("Checking for new messages."); if ((random.Next(9) == 0) && (MessageArrived != null)) { MessageArrived("Hello Mami!"); } } } public class Display { //公共方法,用于输出接收到的消息到控制台 public void DisplayMessage(string message) => WriteLine($"Message arrived: {message}"); } class Program { static void Main(string[] args) { //创建一个Connection实例 Connection myConnection = new Connection(); //创建一个Display实例 Display myDisplay = new Display(); //订阅Connection的MessageArrived事件,将myDisplay的DisplayMessage方法作为事件处理器 myConnection.MessageArrived += new MessageHandler(myDisplay.DisplayMessage); //启动检查新消息的过程 myConnection.Connect(); /*暂停主线程1500毫秒 由于主线程控制着整个程序的运行和输出,所以在暂停期间,定时器仍然继续工作并检查是否有新消息 这里暂停主线程是为了确保在程序退出之前有足够时间让定时器有机会触发事件并完成消息输出到控制台的操作 如果不进行暂停操作,可能主线程会立即结束,导致无法看到任何消息输出*/ //System.Threading.Thread.Sleep(1500); //阻塞,等待用户按键,防止控制台窗口立刻关闭 ReadKey(); } } }
声明事件时,使用event
关键字,并指定要使用的委托类型,以这种方式声明事件后,就可以引发它,做法是按名称来调用它,就像它是一个其返回类型和参数是由委托指定的方法一样
//声明了一个事件,委托类型 public event MessageHandler MessageArrived; //引发事件 MessageArrived("This is a message.");
匿名方法
匿名方法实际上并非传统意义上的方法,它不是某个类上的方法,而纯粹是为用作委托目的而创建的
要创建匿名方法,需要使用以下代码:
delegate(parameters) { // Anonymous method code. };
parameters
是一个参数列表,这些参数匹配正在实例化的委托类型,由匿名方法的代码使用
使用匿名方法时要注意,对于包含它们的代码块来说,它们是局部的,可以访问这个作用域内的局部变量。如果使用这样一个变量,它就成为外部变。外部变量在超出作用域时,是不会删除的,这与其他局部变量不同,在使用它们的匿名方法被销毁时,才会删除外部变量。这比我们希望的时间晚一些,所以要格外小心。如果外部变量占用了大量内存,或者使用的资源在其他方面是比较昂贵的,就可能导致内存或性能问题
特性
特性可以为代码段标记一些信息,而这样的信息又可以从外部读取,并通过各种方式来影响所定义类型的使用方式。这种手段通常被称为对代码进行装饰
例如,要创建的某个类包含一个极简单的方法,但即便简单,调试期间还是会对这一代码进行检查。这种情况下就可以对该方法添加一个特性,告诉VS在调试时不要进入该方法进行逐句调试,而是跳过该方法,直接调试下一条语句
[DebuggerStepThrough] public void DullMethod()
[DebuggerStepThrough]
就是该特性,所有特性的添加都是将特性名称用方括号括起来,并写在应用的目标代码前即可,可以为一段目标代码添加多个特性
上述特性是通过DebuggerStepThroughAttribute
这个类来实现的,而这个类位于System.Diagnostics
命名空间中,因此使用该特性必须使用using
语句来引用这一命名空间,可以使用完整名称,也可以去掉Attribute
后缀
通过上述方式添加特性后,编译器就会创建该特性类的一个实例,然后将其与类方法关联起来。某些特性可以通过构造函数的参数或属性进行自定义,并在添加特性的时候进行指定
[DoesInterestingThings(1000, WhatDoesItDo = "voodoo")] public class DecoratedClass {}
将值1000传递给了DoesInterestingThingsAttribute
的构造函数,并将WhatDoesItDo
属性的值设置为字符串"voodoo"
读取特性
读取特性值使用一种称为反射的技术,反射可以在运行时动态检查类型信息,甚至是在创建对象的位置或不必知道具体对象的情况下直接调用某个方法
反射可以取得保存在Type
对象中的使用信息,以及通过System.Reflection
名称空间中的各种类型来获取不同的类型信息
typeof
运算符从类中快速获取类型信息GetType()
方法从对象实例中获取信息- 反射技术从
Type
对象取得成员信息,基于该方法,就可以从类或类的不同成员中取得特性信息
最简单的方法是通过Type.GetCustomAttributes()
方法来实现。这个方法最多使用两个参数,然后返回一个包含一系列object
实例的数组,每个实例都是一个特性实例。第一个参数是可选的,即传递我们感兴趣的类型或若干特性的类型(其他所有特性均会被忽略)。如果不使用这一参数,将返回所有特性。第二个参数是必需的,即通过一个布尔值来指示,只想了解类本身的信息,还是除了该类之外还希望了解派生自该类的所有类
下面的代码列出DecoratedClass
类的特性
//获取指定类型的Type对象 Type classType = typeof(DecoratedClass); //获取该类型上应用的所有自定义特性,包括从父类继承的特性 object[] customAttributes = classType.GetCustomAttributes(true); foreach (object customAttribute in customAttributes) { WriteLine($"Attribute of type {customAttribute} found."); }
创建特性
通过System.Attribute
类进行派生,就可以自定义特性。一般来说,如果除了包含和不包含特定的特性外,我们的代码不需要获得更多信息就可以完成需要的工作,不必完成这些额外的工作。如果希望某些特性可以被自定义,则可以提供非默认的构造函数和可写属性
还需要为自定义特性做两个选择:要将其应用到什么类型的目标(类、属性或其他),以及是否可以对同一个目标进行多次应用
要指定上述信息,需要对特性应用AttributeUsageAttribute
特性,该特性带有一个类型为AttributeTargets
的构造函数参数值,通过|
运算符即可通过相应的枚举值组合出需要的值。该特性还有一个布尔值类型的属性AllowMultiple
,用于指定是否可以多次应用特性
下面的代码指定了一个特性可以应用到类或属性中
//一个预定义特性,用于指定自定义特性的使用规则和范围 [AttributeUsage(AttributeTargets.Class|AttributeTargets.Method, AllowMultiple = false)] //自定义特性类 class DoesInterestingThingsAttribute : Attribute { //构造函数 public DoesInterestingThingsAttribute(int howManyTimes) { HowManyTimes = howManyTimes; } //公共属性,用于储存或获取该特性所描述行为的具体内容 public string WhatDoesItDo { get; set; } //只读公共属性,表示该特性执行有趣行为的次数 public int HowManyTimes { get; private set; } }
初始化器
对象初始化器提供了一种简化代码的方式,可以合并对象的实例化和初始化。集合初始化器提供了一种简洁的语法,使用一个步骤就可以创建和填充集合
对象初始化器
public class Curry { public string MainIngredient { get; set; } public string Style { get; set; } public int Spiciness { get; set; } }
该类有3个属性,使用自动属性语法定义,如果希望实例化和初始化该类的一个对象实例,就必须执行如下语句
Curry tastyCurry = new Curry(); tastyCurry.MainIngredient = "panir tikka"; tastyCurry.Style = "jalfrezi"; tastyCurry.Spiciness = 8;
如果类定义中未包含构造函数,这段代码就使用C#编译器提供的默认无参数构造函数,为简化该初始化过程,可提供一个合适的非默认构造函数
public Curry(string mainIngredient, string style, int spiciness) { MainIngredient = mainIngredient; Style = style; Spiciness = spiciness; }
这样就可以把实例化和初始化合并起来
Curry tastyCurry = new Curry("panir tikka", "jalfrezi", 8);
代码可以工作,但它强制使用Carry
类的代码使用该构造函数,这将阻止使用无参数构造函数代码的运行,因此和C++一样都需要提供无参构造函数
public Curry() {}
对象初始化器是不必在类中添加额外代码就可以实例化和初始化对象的方式。实例化对象时,要为每个需要初始化、可公开访问的属性或字段使用名称-值对,来提供其值
<ClassName><variableName> = new<ClassName> { <propertyOrField1> = <value1>, <propertyOrField2> = <value2>, ... <propertyOrFieldN> = <valueN> };
重写前面的代码,实例化和初始化一个Curry
类型的对象
Curry tastyCurry = new Curry { MainIngredient = "panir tikka", Style = "jalfrezi", Spiciness = 8 };
常常可以把这样的代码放在一行上,而不会严重影响可读性
使用对象初始化器时,不必显式调用类的构造函数。如果像上述代码一样省略构造函数的括号,就自动调用默认的无参构造函数。这是在初始化器设置参数值前调用的,以便在需要时为默认构造函数中的参数提供默认值
另外可以调用特定的构造函数。同样,先调用这个构造函数,所以在构造函数中对公共属性进行的初始化可能会被初始化器中提供的值覆盖。只有能够访问所使用的构造函数(如果没有显式指出,就是默认的构造函数),对象初始化器才能正常工作
可以使用嵌套的对象初始化器
Curry tastyCurry = new Curry { MainIngredient = "panir tikka", Style = "jalfrezi", Spiciness = 8, Origin = new Restaurant { Name = "King's Balti", Location = "York Road", Rating = 5 } };
初始化了Restaurant
类型的Origin
属性
对象初始化器没有替代非默认的构造函数。在实例化对象时,可以使用对象初始化器来设置属性和字段值,但这并不意味着总是知道需要初始化什么状态。通过构造函数,可以准确地指定对象需要什么值才能起作用,再执行代码,以便立即响应这些值
使用嵌套的初始化器时,首先创建顶级对象,然后创建嵌套对象。如果使用构造函数,对象的创建顺序就反了过来
集合初始化器
使用值初始化数组
int[] myIntArray = new int[5] { 5, 9, 10, 2, 99 };
这是一种合并实例化和初始化数组的简洁方式,集合初始化器只是把该语法扩展到集合上
List<int> myIntCollection = new List<int> { 5, 9, 10, 2, 99 };
通过合并对象和集合初始化器,就可以使用简洁的代码(只能说可能增加了可读性)来配置集合
List<Curry> curries = new List<Curry>(); curries.Add(new Curry("Chicken", "Pathia", 6)); curries.Add(new Curry("Vegetable", "Korma", 3)); curries.Add(new Curry("Prawn", "Vindaloo", 9));
可以使用如下代码替换
List<Curry> moreCurries = new List<Curry> { new Curry { MainIngredient = "Chicken", Style = "Pathia", Spiciness = 6 }, new Curry { MainIngredient = "Vegetable", Style = "Korma", Spiciness = 3 }, new Curry { MainIngredient = "Prawn", Style = "Vindaloo", Spiciness = 9 } };
类型推理
强类型化语言表示每个变量都有固定类型,只能用于接收该类型的代码中
var
关键字会根据初始化表达式的类型推断变量的实际类型,在用var
声明变量时,必须同时初始化该变量,因为如果没有初始值,编译器就无法确定变量的类型
var
关键字还可以通过数组初始化器来推断数组的类型
var myArray = new[] { 4, 5, 2 };
在采用这种方式隐式指定数组类型时,初始化器中使用的数组元素必须是以下情况中的一种:
- 相同的类型
- 相同的引用类型或空
- 所有元素的类型都可以隐式地转换为一个类型
如果应用最后一条规则,元素可以转换的类型就称为数组元素的最佳类型。如果这个最佳类型有任何含糊的地方,即所有元素的类型都可以隐式转换为两种或更多的类型,代码就不会编译
要注意数字值不会解释为可空类型
//无法编译 var myArray = new[] { 4, null, 2 }; //但可以使用标准的数组初始化器创建一个可空类型数组 var myArray = new int?[] { 4, null, 2 };
var
关键字仅适用于局部变量的隐式类型化声明
匿名类型
常常有一系列类只提供属性,什么也不做,只是存储结构化数据,在数据库或电子表格中,可以把这个类看成表中的一行。可以保存这个类的实例的集合类应表示表或电子表格中的多个行
匿名类型是简化这个编程模型的一种方式,其理念是使用C#编译器根据要存储的数据自动创建类型,而不是定义简单的数据存储类型
//对象初始化器 Curry curry = new Curry { MainIngredient = "Lamb", Style = "Dhansak", Spiciness = 5 }; //使用匿名类型 var curry = new { MainIngredient = "Lamb", Style = "Dhansak", Spiciness = 5 };
匿名类型使用var
关键字,这是因为匿名类型没有可以使用的标识符,且在new
关键字的后面没有指定类型名,这是编译器确定我们要使用匿名类型的方式
创建匿名类型对象的数组
using static System.Console; class Test { static void Main() { var curries = new[] { new { MainIngredient = "Lamb", Style = "Dhansak", Spiciness = 5 }, new { MainIngredient = "Lamb", Style = "Dhansak", Spiciness = 5 }, new { MainIngredient = "Chicken", Style = "Dhansak", Spiciness = 5 } }; //输出为该类型定义的每个属性的值 WriteLine(curries[0].ToString()); /*根据对象的状态为对象返回一个唯一的整数 数组中的前两个对象有相同的属性值,所以其状态是相同的*/ WriteLine(curries[0].GetHashCode()); WriteLine(curries[1].GetHashCode()); WriteLine(curries[2].GetHashCode()); //由于匿名类型没有重写Equals,默认基于引用比较,这里返回false //即使属性完全相同,因为它们是不同的对象实例 //==操作符也是基于引用比较,因此即使属性值相同也会返回false WriteLine(curries[0].Equals(curries[1])); WriteLine(curries[0].Equals(curries[2])); WriteLine(curries[0] == curries[1]); WriteLine(curries[0] == curries[2]); } }
动态查找
var
关键字本身不是类型,只是根据表达式推导类型,C#虽然是强类型化语言,但从C#4开始就引入了动态变量的概念,即类型可变的变量
引入的目的是为了在许多情况下,希望使用C#处理另一种语言创建的对象,这包括对旧技术的交互操作。另一个使用动态查找的情况是处理未知类型的C#对象
在后台,动态查找功能由Dynamic Language Runtime(动态语言运行库,DLR)支持。与CLR一样,DLR是.NET4.5的一部分
使用dynamic
关键字定义动态类型,在声明动态类型时不必初始化它的值
[!important]
动态类型仅在编译期间存在,在运行期间会被System.Object
类型替代
高级方法参数
一些方法需要大量参数,但许多参数并不是每次调用都需要
可选参数
调用参数时,常常给某个参数传输相同的值,例如可能是一个布尔值,以控制方法操作中不重要部分
public List<string> GetWords(string sentence, bool capitalizeWords = false)
为参数提供一个默认值,就使其成为可选参数,如果调用此方法时没有为该参数提供值,就使用默认值
默认值必须是字面量、常量值或该值类型的默认初始值
使用可选参数时,它们必须位于方法参数列表的末尾,没有默认值的参数不能放在默认值的参数后
//非法代码 public List<string> GetWords(bool capitalizeWords = false, string sentence)
命名参数
使用可选参数时,可能发现某个方法有几个可选参数,但可能只想给第三个可选参数传输值
命名参数允许指定要使用哪个参数,这不需要在方法定义中进行任何特殊处理,它是一个在调用方法时使用的技术
method(参数名:值,参数名:值)
参数名是方法定义时使用的变量名,参数的顺序是任意的,命名参数也可以是可选的
可以仅给方法调用中的某些参数使用命名参数。当方法签名中有多个可选参数和一些必选参数时,这是非常有用的。可以首先指定必选参数,再指定命名的可选参数
如果混合使用命名参数和位置参数,就必须先包含所有的位置参数,其后是命名参数
Lambda表达式
复习匿名方法
给事件添加处理程序:
- 定义一个事件处理方法,其返回类型和参数匹配将订阅的事件需要的委托的返回类型和参数
- 声明一个委托类型的变量,用于事件
- 把委托变量初始化为委托类型的实例,该实例指向事件处理方法
- 把委托变量添加到事件的订阅者列表中
实际过程会简单一些,因为一般不使用变量来存储委托,只在订阅事件时使用委托的一个实例
Timer myTimer = new Timer(100); myTimer.Elapsed += new ElapsedEventHandler(WriteChar);
订阅了Timer
对象的Elapsed
事件。这个事件使用委托类型ElapsedEventHandler
,使用方法标识符WriteChar
实例化该委托类型。结果是Timer
对象引发Elapsed
事件时,就调用方法WriteChar()
。传给WriteChar()
的参数取决于由ElapsedEventHandler
委托定义的参数类型和Timer
中引发事件的代码传送的值
可以通过方法组语法用更简洁的代码获得相同的效果
方法组语法是指不直接实例化委托对象,而是通过指定一个方法名来隐式转换为委托类型。当某个方法的签名与委托类型的签名匹配时,可以直接将方法名用作该委托类型的实例
myTimer.Elapsed += WriteChar;
C#编译器知道Elapsed
事件需要的委托类型,所以可以填充该类型。但大多数情况下,最好不要这么做,因为这会使代码更难理解,也不清楚会发生什么
使用匿名方法时,该过程会减少为一步:
- 使用内联的匿名方法,该匿名方法的返回类型和参数匹配所订阅事件需要的委托的返回类型和参数
//Elapsed事件添加一个匿名方法作为事件处理器 myTimer.Elapsed += delegate(object source, ElapsedEventArgs e) { WriteLine("Event handler called after {0} milliseconds.", //获取当前计时器周期间隔的毫秒数 (source as Timer).Interval); };
这段代码像单独使用事件处理程序一样正常工作。主要区别是这里使用的匿名方法对于其余代码而言实际上是隐藏的。例如,不能在应用程序的其他地方重用这个事件处理程序。另外,为更好地加以描述,这里使用的语法有点沉闷。delegate
关键字会带来混淆,因为它具有双重含义,匿名方法和定义委托类型都要使用它
Lambda表达式用于匿名方法
Lambda表达式是简化匿名方法语法的一种方式,Lambda表达式还有其他用途
//使用Lambda表达式重写上面的代码 myTimer.Elapsed += (source, e) => WriteLine("Event handler called after " + $"{(source as Timer).Interval} milliseconds.");
Lambda表达式会根据上下文和委托签名自动推导出参数类型,所以在Lambda表达式中不需要明确指定类型名
using static System.Console; //委托类型,接受两个int参数返回一个int delegate int TwoIntegerOperationDelegate(int paramA, int paramB); class Program { //静态方法,接受一个委托作为参数 static void PerformOperations(TwoIntegerOperationDelegate del) { //两层循环遍历1到5之间的整数对 for (int paramAVal = 1; paramAVal <= 5; paramAVal++) { for (int paramBVal = 1; paramBVal <= 5; paramBVal++) { //调用传入的委托并获取运算结果 int delegateCallResult = del(paramAVal, paramBVal); //输出当前表达式的值 Write($"f({paramAVal}, " + $"{paramBVal})={delegateCallResult}"); //如果不是最后一列,则添加逗号和空格分隔各个运算结果 if (paramBVal != 5) { Write(", "); } } //每一次内层循环后换行 WriteLine(); } } static void Main(string[] args) { //使用Lambda表达式创建了三种运算的委托实例 WriteLine("f(a, b) = a + b:"); PerformOperations((paramA, paramB) => paramA + paramB); WriteLine(); WriteLine("f(a, b) = a * b:"); PerformOperations((paramA, paramB) => paramA * paramB); WriteLine(); WriteLine("f(a, b) = (a - b) % b:"); PerformOperations((paramA, paramB) => (paramA - paramB) % paramB); } }
上面的Lambda表达式分为3部分:
- 参数定义部分,这些参数都是未类型化的,因此编译器会根据上下文推断出它们的类型
- =>运算符把Lambda表达式的参数与表达式体分开
- 表达式体,指定了参数之间的操作,不需要指定这是返回值,编译器知道(编译器比我聪明多了,为什么还要我写代码啊啊啊!)
Lambda表达式的参数
Lambda表达式使用类型推理功能来确定所传递的参数类型,但也可以定义类型
(int paramA, int paramB) => paramA + paramB
优点是代码便于理解,缺点是不够简洁(我觉得还是可读性更重要)
不能在同一个Lambda表达式同时使用隐式和显式的参数类型
//错误的 (int paramA, paramB) => paramA + paramB
可以定义没有参数的Lambda表达式,使用空括号表示
() => Math.PI
当委托不需要参数,但需要一个double值时,就可以使用该Lambda表达式
Lambda表达式的语句体
可将Lambda表达式看成匿名方法语法的扩展,所以还可以在Lambda表达式的语句体中包含多个语句。只需要把代码块放在花括号中
如果使用Lambda表达式和返回类型不是void
的委托类型,就必须用return
关键字返回一个值,这与其他方法一样
(param1, param2) => { // Multiple statements ahoy! return returnValue; }
PerformOperations((paramA, paramB) => paramA + paramB); //可以改写为 PerformOperations(delegate(int paramA, int paramB) { return paramA + paramB; });
[!hint]
在使用单一表达式时,Lambda表达式最有用也最简洁
如果需要多个语句,则定义一个单独的非匿名方法更好,也使代码更便于复用
Lambda表达式用作委托和表达式树
可采用两种方式来解释Lambda表达式
第一,Lambda表达式是一个委托。即可以把Lambda表达式赋予一个委托类型的变量
第二,可以把Lambda表达式解释为表达式树。表达式树是Lambda表达式的抽象表示,因此不能直接执行。可使用表达式树以编程方式分析Lambda表达式,执行操作,以响应Lambda表达式
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· spring官宣接入deepseek,真的太香了~