**《C# 6.0 本质论》
========== ========== ==========
[作者] (美) Mark Michaelis (美) Eric Lippert
[译者] (中) 周靖 庞燕
[出版] 人民邮电出版社
[版次] 2017年02月 第5版
[印次] 2017年02月 第1次 印刷
[定价] 108.00元
========== ========== ==========
【前言】
成功学习 C# 的关键在于,要尽可能快地开始编程。不要等自己成为一名理论方面的 “专家” 之后,才开始写代码。
学习一门计算机语言最好的方法就是在动手中学习,而不是等熟知了它的所有 “理论” 之后再动手。
为了从简单程序过渡到企业级开发, C# 开发者必须熟练地从对象及其关系的角度来思考问题。
一名知道语法的程序员和一名能因时宜地写出最高效代码的专家的区别,关键就是这些编码规范。专家不仅让代码通过编译,还遵循最佳实践,降低产生 bug 的概率,并使代码的维护变得更容易。编码规范强调了一些关键原则,开发时务必注意。
总地说来,软件工程的宗旨就是对复杂性进行管理。
【第01章】
(P001)
学习新语言最好的办法就是动手写代码。
(P003)
一次成功的 C# 编译生成的肯定是程序集,无论它是程序还是库。
在 Java 中,文件名必须和类名一致。
从 C# 2.0 开始,一个类的代码可以拆分到多个文件中,这一特性称为 “部分类” 。
编译器利用关键字来识别代码的结构与组织方式。
(P004)
C# 1.0 之后没有引入任何新的保留关键字,但在后续版本中,一些构造使用了上下文关键字 (contextual keyword) ,它们在特定位置才有意义。除了那些位置,上下文关键字没有任何特殊意义。这样,大多数的 C# 1.0 代码都完全兼容于后续的版本。
分配标识符之后,以后就能用它引用所标识的构造。因此,开发人员应分配有意义的名称,不要随意分配。
好的程序员总能选择简洁而有意义的名称,这使代码更容易理解和重用。
(P005)
[规范]
-
要更注重标识符的清晰而不是简短;
-
不要在标识符名称中使用单词缩写;
- 不要使用不被广泛接受的首字母缩写词,即使被广泛接受,非必要时也不要用;
下划线虽然合法,但标识符一般不要包含下划线、连字符或其他非 字母 / 数字 字符。
[规范]
-
要把只包含两个字母的首字母缩写词全部大写,除非它是驼峰大小写风格标识符的第一个单词;
-
包含 3 个或更多字母的首字母缩写词,仅第一个字母才要大写,除非该缩写词是驼峰大小写风格标识符的第一个单词;
-
在驼峰大小写风格标识符开头的首字母缩写词中,所有字母都不要大写;
- 不要使用匈牙利命名法 (也就是,不要为变量名称附加类型前缀) ;
关键字附加 “@” 前缀可作为标识符使用。
C# 中所有代码都出现在一个类型定义的内部,最常见的类型定义是以关键字 class 开头的。
(P006)
对于包含 Main() 方法的类, Program 是个很好的名称。
[规范]
-
要用名词或名词短语命名类;
- 要为所有类名使用 Pascal 大小写风格;
一个程序通常包含多个类型,每个类型都包含多个方法。
方法可以重用,可以在多个地方调用,所以避免了代码的重复。
方法声明除了负责引入方法之外,还要定义方法名以及要传入和传出方法的数据。
C# 程序从 Main 方法开始执行,该方法以 static void Main() 开头。
程序会启动并解析 Main 的位置,然后执行其中第一条语句。
虽然 Main 方法声明可以进行某种程度的改变,但关键字 static 和方法名 Main 是始终都是程序必需的。
C# 要求 Main 方法的返回类型为 void 或 int ,而且要么不带参数,要么接收一个字符串数组作为参数。
(P007)
args 参数是一个字符串数组,用于接收命令行参数。
Main 返回的 int 值是状态码,标识程序执行是否成功。返回非零值通常意味着错误。
C# 的 Main 方法名使用大写 M ,以便与 C# 的 Pascal 大小写风格命名约定保持一致。
Main() 之前的 void 表明该方法不返回任何数据。
C# 通常用分号标识语句结束,每条语句都由代码要执行的一个或多个行动构成。
由于换行与否不影响语句的分隔,所以可以将多条语句放到同一行, C# 编译器会认为这一行包含多条指令。
C# 还允许一条语句跨越多行。同样地, C# 编译器会根据分号判断语句的结束位置。
(P008)
分号使 C# 编译器能忽略代码中的空白。除了少数例外情况, C# 允许在代码中随意插入空白而不改变其语义。
空白是一个或多个连续的格式字符 (如制表符、空格和换行符) 。删除单词间的所有空白肯定会造成歧义。删除引号字符串中的任何空白也会造成歧义。
程序员经常利用空白对代码进行缩进来增强可读性。
为了增强可读性,利用空白对代码进行缩进是非常重要的。写代码时要遵循已经建立的编码标准和约定,以增强代码的可读性。
(P009)
声明变量就是定义它,需要 :
-
指定变量要包含的数据的类型;
- 为它分配标识符 (变量名) ;
一个变量声明所指定的数据的类型称为数据类型。数据类型,或者简称为类型,是具有相似特征和行为的个体的分类。
在编程语言中,类型是被赋予了相似特性的一些个体的定义。
(P010)
局部变量名采用的是驼峰大小写风格命名 (即除了第一个单词,其他的每个单词的首字母大写) ,而且不包含下划线。
[规范]
- 要为局部变量使用 camel 大小写风格的命名;
局部变量声明后必须在引用之前为其赋值。
C# 允许在同一条语句中进行多个赋值操作。
(P011)
赋值后就能用变量标识符引用值。
所有 string 类型的数据,不管是不是字符串字面量,都是不可变的 (或者说是不可修改的) 。也就是说,不能修改变量最初引用的数据,只能重新为变量赋值,让它引用内存中的新位置。
System.Console.ReadLine() 方法的输出,也称为返回值,就是用户输入的文本字符串。
(P012)
System.Console.Read() 方法返回的是与读取的字符值对应的整数,如果没有更多的字符可用,就返回 -1 。为了获取实际字符,需要先将整数转型为字符。
除非用户按回车键,否则 System.Console.Read() 方法不会返回输入。按回车键之前不会对字符进行处理,即使用户已经输入了多个字符。
C# 2.0 以上的版本可以使用 System.Console.ReadKey() 方法。它和 System.Console.Read() 方法不同,用户每按下一个键就返回用户所按的键。可用它拦截用户按键操作,并执行相应行动,如校验按键,限制只能按数字键。
(P013)
在字符串插值中,编译器将字符串花括号中的部分解释为可以嵌入代码 (表达式) 的区域,编译器将对嵌入的表达式估值并将其转换为字符串。字符串插值不需要先逐个执行很多个代码片段,最后再将结果组合成字符串,它可以一步完成这些输出。这使得代码更容易理解。
C# 6.0 之前的版本利用的是复合格式化 (composite formatting) 来进行一次性输出。在复合格式化中,代码首先提供格式字符串 (format string) 来定义输出格式。
(P014)
占位符在格式字符串中不一定按顺序出现。
占位符除了能在格式字符串中按任意顺序出现之外,同一个占位符还能在一个格式字符串中多次使用。
(P015)
[规范]
-
不要使用注释,除非代码本身 “一言难尽” ;
- 要尽量编写清晰的代码,而不是通过注释澄清复杂的算法;
(P016)
在 .NET 中,一个程序集包含的所有类型 (以及这些类型的成员) 构成这个程序集的 API 。
同样,对于程序集的组合,例如 .NET Framework 中的程序集组合,每个程序集的 API 组合在一起构成一个更大的 API 。这个更大的 API 组通常被称为框架 (framework) , .NET Framework 就是指 .NET 包含的所有程序集对外暴露的 API 。
一般地, API 包括一系列接口和协议 (或指令) ,它们定义了程序和一组部件交互的规则。实际上,在 .NET 中,协议本身就是 .NET 程序集执行的规则。
(P017)
一个公共编程框架,称为基类库 (Base Class Library , BCL) ,提供开发者能够 (在所有 CLI 实现中) 依赖的大型代码库,使他们不必亲自编写这些代码。
(P018)
.NET Core 不同于完整的 .NET Framework 功能集,它包含了整个 (ASP.NET) 网站可以在 Windows 之外的操作系统上部署所需的功能以及 IIS (Internet Information Server , 因特网信息服务器) 。这意味着,同样的代码可以被编译和执行成跨平台运行的应用程序。
.NET Core 包含了 .NET 编译平台 (“Roslyn”) 、 .NET Core 运行时、 .NET 版本管理 (.NET Version Manager , DNVM) 以及 .NET 执行环境 (.NET Execution Environment , DNX) 等工具,可以在 Linux 和 OS X 上执行。
(P020)
事实上,一些免费工具 (如 Red Gate Reflector 、 ILSpy 、 JustDecompile 、 dotPeek 和 CodeReflect) 可以将 CIL 自动反编译成 C# 。
【第02章】
(P022)
C# 有几种类型非常简单,被视为其他所有类型的基础。这些类型称为预定义类型 (predefined type) 。
C# 语言的预定义类型包括 8 种整数类型、 2 种用于科学计算的二进制浮点类型、 1 种用于金融计算的十进制浮点类型、 1 种布尔类型以及 1 种字符类型。
decimal 是一种特殊的浮点类型,能够存储大数值而无表示错误。
(P023)
C# 的所有基本类型都有短名称和完整名称。完整名称对应于 BCL (Base Class Library , 基类库) 中的类型命名。
由于基本数据类型是其他类型的基础,所以 C# 为基本数据类型的完整名称提供了短名称或缩写的关键字。
C# 开发人员一般选择使用 C# 关键字。
[规范]
-
要在指定数据类型时使用 C# 关键字而不是 BCL 名称 (例如,使用 string 而不是 String) ;
- 要保持一致而不要变来变去;
(P024)
浮点数的精度是可变的。
与浮点数不同, decimal 类型保证范围内的所有十进制数都是精确的。
虽然 decimal 类型具有比浮点类型更高的精度,但它的范围较小。
decimal 的计算速度稍慢 (虽然这个差别可以忽略不计) 。
除非超过范围,否则 decimal 数字表示的十进制数都是完全准确的。
(P025)
默认情况下,输入带小数点的字面量,编译器会自动把它解释成 double 类型。
整数值 (没有小数点) 通常默认为 int ,但前提是该值不要太大,以至于无法用 int 来存储。
要显示具有完整精度的数字,必须将字面量显式声明为 decimal 类型,这是通过追加一个 M (或者 m) 后缀来实现的。
(P026)
d 表示 double ,之所以用 m 表示 decimal ,是因为这种数据类型经常用于货币 (monetary) 计算。
对于整数数据类型,相应的后缀是 U 、 L 、 LU 和 UL 。整数字面量的类型是像下面这样确定的 :
-
没有后缀的数值字面量按照以下顺序,解析成能够存储该值的第一种数据类型 : int 、 uint 、 long 、 ulong ;
-
具有后缀 U 的数值字面量按照以下顺序,解析成能够存储该值的第一种数据类型 : uint 、 ulong ;
-
具有后缀 L 的数值字面量按照以下顺序,解析成能够存储该值的第一种数据类型 : long 、 ulong ;
- 如果数值字面值的后缀是 UL 或 LU ,则解析成 ulong 类型;
注意,字面量的后缀不区分大小写。但对于 long ,一般推荐使用大写字母 L ,因为小写字母 l 和数字 1 不好区分。
[规范]
-
要使用大写的字面量后缀;
- 十六进制和十进制的相互转换不会改变数本身,改变的只是数的表示形式;
(P027)
要以十六进制形式输出一个数值,必须使用 x 或 X 数值格式说明符。大小写决定了十六进制字母的大小写。
(P028)
虽然从理论上说,一个二进制位就足以容纳一个布尔类型的值,但 bool 数据类型的实际大小是一个字节。
字符类型 char 表示 16 位字符,其取值范围对应于 Unicode 字符集。
从技术上说, char 的大小和 16 位无符号整数 (ushort) 相同,后者的取值范围是 0 ~ 65535 。
(P029)
为了输入 char 类型的字面量,需要将字符放到一对单引号中。
反斜杠和特殊字符代码组成转义序列 (escape sequence) 。
可以使用 Unicode 代码表示任何字符。为此,请为 Unicode 值附加 \u 前缀。
(P030)
零或多个字符组成的有限序列称为字符串。
为了将字面量字符串输入代码,要将文本放入双引号 (") 内。
字符串由字符构成,所以转义序列可以嵌入字符串内。
双引号要用转义序列输出,否则会被用于定义字符串开始与结束。
在 C# 中,可以在字符串前面使用 @ 符号,指明转义序列不被处理。
结果是一个逐字字符串字面量 (verbatim string literal) ,它不仅将反斜杠当作普通字符处理,还会逐字解释所有空白字符。
(P031)
在以 @ 开头的字符串中,唯一支持的转义序列是 "" ,它代表一个双引号,这个双引号不会终止字符串。
假如同一个字符串字面量在程序集中多次出现,编译器在程序集中只定义字符串一次,且所有变量都将指向同一个字符串。
通过使用字符串插值格式,字符串可以支持嵌入的表达式。字符串插值语法在一个字符串字面量前加上一个 $ 符号前缀,然后将表达式嵌入大括号中。
注意,字符串字面量可以通过在 “@” 符号前加上 “$” 符号的字符串插值组合而成。
(P032)
字符串插值是调用 string.Format() 方法的简写。
(P033)
string.Format() 不是在控制台窗口中显示结果,而是返回结果。
增加了字符串插值功能之后, string.Format() 的重要性减弱了不少 (除了对本地化功能的支持) 。在后台,字符串插值是利用了 string.Format() 编译成 CIL 的。
目前静态方法的调用通常是包含一个命名空间的前缀后面跟类型名。
(P034)
using static 指令必须放在文件的最开始。
using static 指令只对静态方法和属性有效,对于实例成员不起作用。
using 指令与 using static 指令类似,使用后也可以省略命名空间前缀。与 using static 指令不同的是, using 指令在文件 (或命名空间) 中应用非常普遍,不仅只应用于静态成员。无论是实例化,或是静态方法调用,抑或是使用 C# 6.0 中新增的 nameof 操作符时,使用 using 指令都可以随意地省略所有的命名空间引用。
无论是使用 string.Format() 还是用 C# 6.0 的字符串插值功能构造复杂格式的字符串,总要用一组丰富的、复杂的格式模板来显示数字、日期、时间、时间段等等。
如果想在一个插值的字符串或格式化的字符串中真正出现左大括号或者右大括号,可以通过连续输入两个大括号表明这个大括号不是引入的格式模板。
输出新行所需的字符取决于执行代码的操作系统。
(P035)
字符串的长度不能直接设置,它是根据字符串中的字符数计算得到的。此外,字符串的长度不能更改,因为字符串是不可变的。
string 类型的关键特征在于它是不可变的 (immutable) 。
(P036)
与类型有关的两个额外的关键字是 null 和 void 。 null 值由关键字 null 标识,表明变量不引用任何有效的对象。 void 表示没有类型,或者没有任何值。
(P037)
null 也可以作为字符串字面量的类型使用。 null 表示将变量设为 “无” 。 null 值只能赋给引用类型、指针类型和可空值类型。
将变量设为 null ,会显式地设置引用,使它不指向任何位置。
必须注意,和根本不赋值相比,将 null 赋给引用类型的变量是完全不同的概念。换言之,赋值为 null 的变量已被设置,而未赋值的变量未被设置,所以假如在赋值前使用变量会造成编译时错误。
将 null 值赋给一个 string 变量,并不等同于将空字符串 "" 赋给它。 null 意味着变量无任何值,而 "" 意味着变量有一个称为 “空字符串” 的值。这种区分相当有用。
在返回类型的位置使用 void 意味着方法不返回任何数据,同时告诉编译器不要期望会有一个值。 void 本质上并不是一个数据类型,它只是用于指出没有数据返回这一事实。
(P038)
C# 3.0 新增了上下文关键字 var 来声明隐式类型的局部变量。
虽然允许使用 var 取代显式的数据类型,但在数据类型已知的情况下最好不要使用 var 。
用 var 声明变量,右侧的数据类型应该是非常明显的;否则应该考虑避免使用 var 声明。
C# 3.0 添加 var 的目的是支持匿名类型。匿名类型是在方法内部动态声明的数据类型,而不是通过显式的类定义来声明的。
(P039)
所有类型都可以归为值类型或引用类型。它们的区别在于复制方式 : 值类型的数据总是进行值复制,而引用类型的数据总是进行引用复制。
值类型变量直接包含值。换言之,变量引用的位置就是值在内存中实际存储的位置。因此,将第一个变量的值赋给第二个变量会在新变量的位置创建原始变量的值的一个内存副本。相同值类型的第二个变量不能引用和第一个变量相同的内存位置。所以,更改第一个变量的值不会影响第二个变量的值。
由于值类型需要创建内存副本,因此定义时不要让它们占用太多内存 (通常应该小于 16 字节) 。
(P040)
引用类型的值存储的是对数据存储位置的引用,而不是直接存储数据。要去那个位置才能找到真正的数据。因此,为了访问数据, “运行时” 要先从变量中读取内存位置,再 “跳转” 到包含数据的内存位置。引用类型指向的内存区域称堆 (heap) 。
引用类型不像值类型那样要求创建数据的内存副本,所以复制引用类型的实例比复制大的值类型实例更高效。
将引用类型的变量赋给另一个引用类型的变量,只会复制引用而不需要复制所引用的数据。
事实上,每个引用总是处理器的 “原生大小” 。也就是, 32 位处理器只需复制 32 位引用, 64 位处理器只需复制 64 位引用,以此类推。
显然,复制对一个大数据块的引用,比复制整个数据块快得多。
由于引用类型只复制对数据的引用,所以两个不同的变量可引用相同的数据。
如果两个变量引用同一个对象,利用一个变量更改对象的字段,用另一个对象访问字段时将看到更改结果。无论赋值还是方法调用都会如此。
在决定定义引用类型还是值类型时,一个决定性的因素就是 : 如果对象在逻辑上是固定大小的不可变的值,就考虑定义成值类型;如果逻辑上是可引用的可变的对象,就考虑定义成引用类型。
(P041)
一般不能将 null 值赋给值类型。这是因为根据定义,值类型不能包含引用,即使是对 “无 (nothing)” 的引用。
为了声明可以存储 null 的变量,要使用可空修饰符 (?) 。
将 null 赋给值类型,这在数据库编程中尤其有用。
有可能造成大小变小或者引发异常 (因为转换失败) 的任何转换都需要执行显式转型 (explicit cast) 。相反,不会变小,而且不会引发异常 (无论操作数的类型是什么) 的任何转换都属于隐式转型 (implicit cast) 。
在 C# 中,可以使用转型操作符执行转型。通过在圆括号中指定希望变量转换成的类型,表明你已认可在发生显式转型时可能丢失精度和数据,或者可能造成异常。
(P043)
C# 还支持 unchecked 块,它强制不进行溢出检查,不会为块中溢出的赋值引发异常。
即使编译时打开了 checked 选项,在执行期间, unchecked 关键字也会阻止 “运行时” 引发异常。
(P044)
即使不要求显式转换操作符 (因为允许隐式转型) ,仍然可以强制添加转型操作符。
每个数值数据类型都包含一个 Parse() 方法,它允许将字符串转换成对应的数值类型。
可利用特殊类型 System.Convert 将一种类型转换成另一种类型。
System.Convert 只支持小的数据类型,而且是不可扩展的。它允许从 bool 、 char 、 sbyte 、 short 、 int 、 long 、 ushort 、 uint 、 ulong 、 float 、 double 、 decimal 、 DateTime 和 string 类型中的任何一种类型转换到另一种类型。
所有类型都支持 ToString() 方法,可以用它提供一个类型的字符串表示。
(P045)
对于大多数类型, ToString() 方法只是返回数据类型的名称,而不是数据的字符串表示。只有在类型显式实现了 ToString() 的前提下才会返回字符串表示。
从 C# 2.0 (.NET 2.0) 开始,所有基元数值类型都包含静态 TryParse() 方法。该方法与 Parse() 非常相似,只是转换失败的情况下,它不引发异常,而是返回 false 。
Parse() 和 TryParse() 的关键区别在于,假如转换失败, TryParse() 不会引发异常。
C# 中的数组是基于零的。
数组中每个数据项都使用名为索引的整数值进行唯一性标识。 C# 数组中的第一个数据项使用索引 0 访问。
程序员应确保指定的索引值小于数组的大小 (数组中的元素总数) 。
因为 C# 数组是基于零的,所以数组最后一个元素的索引值要比数组元素的总数小 1 。
(P046)
初学者可将索引想象成偏移量。第一项距离数组开头的偏移量是 0 ,第二项的偏移量是 1 ,依次类推。
数组是几乎每一种编程语言的基本组成部分,因此所有开发人员都要学会它。
在 C# 中,使用方括号声明数组变量。首先要指定数组元素的类型,后跟一对方括号,再输入变量名。
在 C# 中,作为数组声明一部分的方括号是紧跟在数据类型之后的,而不是出现在变量声明之后。
(P047)
使用更多的逗号,可以定义更多的维。数组总维数等于逗号数加 1 。
数组如果在声明后赋值,则需要使用 new 关键字。
(P048)
自 C# 3.0 起,不必在 new 后面指定数组的数据类型,只要编译器能根据初始化列表中的数据类型推断出数组元素的类型。但是,方括号仍然不可缺少。
只要将 new 关键字作为数组赋值的一部分,就可以同时在方括号内指定数组的大小。
在初始化语句中指定的数组的大小必须和大括号中包含的元素数量相匹配。
从 C# 2.0 开始可以使用 default() 表达式判断数据类型的默认值。 default() 获取数据类型作为参数。
由于数组大小不需要作为变量声明的一部分,所以可以在运行时指定数组大小。
(P050)
多维数组的每一维的大小都必须一致。
交错数组不使用逗号标识新的维。相反,交错数组定义由数组构成的数组。
注意,交错数组要求内部的每个数组都创建数组实例。
(P051)
数组的长度是固定的,不能随便更改,除非重新创建数组。
Length 成员返回数组中数据项的个数,而不是返回最高的索引值。
为了将 Length 作为索引来使用,有必要在它上面减 1 ,以避免越界错误。
(P052)
Length 返回数组中元素的总数。
对于交错数组, Length 返回的是外部数组的元素数。
(P053)
使用 System.Array.BinarySearch() 方法前要对数组进行排序。
System.Array.Clear() 方法不删除数组元素,而且不将长度设为零。
System.Array.Clear() 方法将数组中的每个元素都设为其默认值。
要获取特定维的长度不是使用 Length 属性,而是使用数组的 GetLength() 实例方法。
(P054)
可以访问数组的 Rank 成员来获取整个数组的维数。
默认情况下,将一个数组变量赋值给另一个数组变量只会复制数组引用,而不是数组中单独的元素。要创建数组的全新副本,需使用数组的 Clone() 方法。该方法返回数组的一个副本,更改这个新数组中的任何成员都不会影响原始数组的成员。
可以使用字符串的 ToCharArray() 方法,将整个字符串作为字符数组返回。
(P055)
用于声明数组的方括号放在数据类型之后,而不是在变量标识符之后。
(P056)
如果是在声明之后再对数组进行赋值,需要使用 new 关键字,并可选择指定数据类型。
不能在变量声明中指定数组大小。
除非提供数组字面量,否则必须在初始化时指定数组大小。
数组的大小必须与数组字面量中的元素个数相符。
【第03章】
(P058)
通常将操作符划分为 3 大类 : 一元操作符、二元操作符和三元操作符,它们对应的操作数分别是 1 个、 2 个和 3 个。
使用负操作符 (-) 等价于从零减去操作数。
一元正操作数 (+) 对值几乎没有影响。它在 C# 语言中是多余的,只是出于对称性的考虑才加进来。
二元操作符要求两个操作数。 C# 为二元操作符使用中缀表示法 : 操作符在左、右操作数之间。每个二元表达式的结果要么赋给一个变量,要么以某种方式使用 (例如用作为另一个表达式的操作数) 。
在 C# 中,只有调用、递增、递减和对象创建表达式才能作为独立的语句使用。
一元 (+) 操作符定义为获取 int 、 uint 、 long 、 ulong 、 float 、 double 和 decimal 类型 (及其可空版本) 的操作数。用于其他类型 (如 short ) 时,操作数会根据实际情况转换为上述某个类型。
算数操作符的每一边都有一个操作数,计算结果赋给一个变量。
(P059)
圆括号可以明确地将一个操作数与它所属的操作符相关联。
(P060)
C# 的大多数操作符都是左结合的,赋值操作符右结合。
有时候,圆括号操作符并不改变表达式的求值结果。不过,使用圆括号来提高代码的可读性依然是一良好的编程的习惯。
[规范]
- 要使用圆括号增加代码的易读性,尤其是在操作符优先级不是让人一目了然的时候;
在 C# 中,操作数总是从左向右求值。
操作符也可用于非数值类型。例如,可以使用加法操作符来拼接两个或者更多字符串。
(P061)
当必须进行本地化时,应该有节制地使用加法操作符,最好使用组合格式化。
[规范]
- 当必须进行本地化时,要用组合格式化而不是加法操作符来拼接字符串;
虽然 char 类型存储的是字符而不是数字,但它是整型 (意味着它基于整数) ,可以和其他整型一起参与算数运算。然而,不是基于存储的字符来解释 char 类型的值,而是基于它的基础值。
可以利用 char 类型的这个特点判断两个字符相距多远。
(P062)
二进制浮点类型实际存储的是二进制分数而不是十进制分数。所以,一次简单的赋值就可能引发精度问题。
[规范]
- 避免在需要准确的十进制算术运算时使用二进制浮点类型,而是使用 decimal 浮点类型;
比较两个值是否相等的时候,浮点类型的不准确性可能造成非常严重的后果。
(P063)
[规范]
- 避免将二进制浮点类型用于相等性条件式。要么判断两个值之差是否在容差范围之内,要么使用 decimal 类型;
(P064)
(+=) 操作符使左边的变量递增右边的值。
(P065)
赋值操作符还可以和减法、乘法、除法和取余操作符结合。
C# 提供了特殊的一元操作符来实现计数器的递增和递减。递增操作符 (++) 每次使一个变量递增 1 。
可以使用递减操作符 (--) 使变量递减 1 。
递增和递减操作符在循环中经常用到。
(P066)
递增和递减操作符用于控制特定操作的执行次数。
只要数据类型支持 “下一个值” 和 “上一个值” 的概念,就适合使用递增和递减操作符。
递增或递减操作符的位置决定了所赋的值是操作数计算之前还是之后的值。
(P067)
递增和递减操作符相对于操作数的位置影响了表达式的结果。前缀操作符的结果是变量 递增 / 递减 之后的值,而后缀操作符的结果是变量 递增 / 递减 之前的值。
[规范]
- 避免混淆递增和递减操作符的用法;
(P068)
常量表达式是 C# 编译器能在编译时完成求值的表达式 (而不是在程序运行时才能求值) ,因为其完全由常量操作数构成。
const 关键字的作用就是声明常量符号。由于常量和 “变量” 相反 —— “常” 意味着 “不可变” —— 以后在代码中任何修改它的企图都会造成编译时错误。
[规范]
- 不要使用常量表示将来可能改变的任何值;
(P072)
规范提倡除了单行语句之外都使用代码块。
使用大括号,可以将多个语句合并成代码块,允许在符合条件时执行多个语句。
(P074)
事实上,设计规范规定除非是单行语句,否则不要省略大括号。
[规范]
- 避免在 if 语句中省略大括号,除非只有一行语句;
总的来说,作用域决定一个名称引用什么事物,而声明空间决定同名的两个事物是否冲突。
(P075)
声明空间中的每个局部变量名称必须是唯一的。声明空间覆盖了包含在最初声明局部变量的代码块中的所有子代码块。
(P076)
相等性操作符使用两个等号,赋值操作符使用一个等号。
(P077)
关系和相等性操作符总是生成 bool 值。
逻辑操作符 (logic operator) 获取布尔操作数并生成布尔结果。可以使用逻辑操作符合并多个布尔表达式来构成更复杂的布尔表达式。
(P078)
^ 符号是异或 (exclusive OR , XOR) 操作符,若应用于两个布尔操作数,那么只有在两个操作数中仅有一个为 true 的前提下, XOR 操作符才会返回 true 。
条件操作符是三元操作符,因为它需要 3 个操作数,即 condition 、 consequence 和 alternative 。
作为 C# 中唯一的三元操作符,条件操作符也经常被称为 “三元操作符” 。
(P079)
和 if 语句不同,条件操作符的结果必须赋给某个变量 (或者作为参数传递) 。它不能单独作为一个语句使用。
[规范]
- 考虑使用 if / else 语句,而不是使用过于复杂的条件表达式;
空接合操作符 (null coalescing operator) ?? 能简单地表示 “如果这个值为空,就使用另一个值” 。
?? 操作符支持短路求值。
(P080)
空接合操作符能完美地 “链接” 。
空结合操作符是 C# 2.0 和可空值类型一起引入的,它的操作数既可以是可空值类型,也可以是引用类型。
C# 6.0 引入了一种更为简化的 null 条件操作符 (null-condition operator) ?. 。
(P083)
两个移位操作符是 >> 和 << ,分别称为右移位和左移位操作符。除此之外,还有复合移位和赋值操作符 <<= 和 >>= 。
AND 和 OR 操作符的按位版本不进行 “短路求值” 。
(P086)
按位取反操作符 (~) 是对操作数的每一位取反,操作数可以是 int 、 uint 、 long 和 ulong 类型。
(P087)
斐波那契数 (Fibonacci number) 是斐波那契数列 (Fibonacci series) 的成员,这个数列中的所有数都是数列中前两个数之和。数列最开头两个数是 1 和 1 。
for 主要用于重复次数已知的循环,比如从 0 ~ n 的计数。 do / while 类似于 while 循环,区别在于它至少会循环一次。
do / while 循环与 while 循环非常相似,只是它最适合需要循环 1 ~ n 次的情况,而且 n 在循环开始前无法确定。 do / while 循环的一个典型应用就是反复提醒用户输入。
(P088)
由于递增操作在循环语法中有一席之地,所以递增和递减操作符经常作为 for 循环的一部分使用。
(P089)
[规范]
- 如果发现正在写的 for 循环包含了复杂条件和多个循环变量,要考虑重构方法,以使控制流更容易理解;
for 循环只不过是一种比写 while 循环更方便的方法。 for 循环能改写成 while 循环。
(P090)
[规范]
-
假如事先知道循环次数,而且循环中需要用到控制循环次数的 “计数器” ,那么要使用 for 循环;
- 假如事先不知道循环次数,而且不需要计数器,那么要使用 while 循环;
foreach 循环的特点是每一项只被遍历一次 : 不会像其他循环那样出现计数错误,也不可能越过集合边界。
(P092)
将一个值和许多不同的常量值比较时, switch 语句比 if 语句更容易理解。
switch 的 “主导类型” (governing type) 允许的主导数据类型包括 bool 、 sbyte 、 byte 、 short 、 ushort 、 int 、 uint 、 long 、 ulong 、 char 、 任何枚举 (enum) 类型、上述所有值类型的可空类型以及 string 。
[规范]
- 不要使用 continue 作为跳转语句退出 switch 小节。如果 switch 语句是在一个循环中使用的,这样写是合法的。但是,这样做很容易对之后的 switch 小节中出现的 break 语句的意义感到迷惑;
(P093)
switch 语句至少要有一个 switch 小节。
虽然在之前的规范中提到,在一般情况下应该避免省略大括号,但有一个例外,就是要省略 case 和 break 语句的大括号,因为它们的作用是指示一个块的开始与结束。
(P094)
switch 小节可以以任意顺序出现, default 小节不一定非要出现在 switch 语句的最后。事实上, default 的 switch 小节完全可以省略;它是可选的。
C# 要求每个 switch 小节 (包括最后一个小节) 的结束点 “不可到达” 。这意味着 switch 小节通常以 break 、 return 、 throw 或 goto 结尾。
如果希望 switch 小节执行另一个 switch 小节中的语句,可以显式使用 goto 语句来实现。
C# 使用 break 语句退出循环或者 switch 语句。任何时候遇到 break 语句,控制都会立即离开循环或 switch 。
(P097)
一般都可以使用 if 语句代替 continue 语句,这样做还能增强可读性。
continue 语句的问题在于,它在一次循环中提供了多个出口,从而影响了可读性。
C# 确实支持 goto ,而且只能利用 goto 在 switch 语句中实现贯穿。
(P098)
C# 禁止通过 goto 跳转到代码块内部。只能用 goto 在代码块内部跳转,或者跳到一个封闭的代码块。
[规范]
- 避免使用 goto ;
控制流语句中的条件表达式在运行时求值。相反,C# 预处理器在编译时调用。
每个预处理指令都以 # 开头,而且必须在一行中写完。换行符 (而不是分号) 标志着预处理指令的结束。
(P102)
C# 允许使用 #region 指令声明代码区域。 #region 和 #endregion 必须成对使用,两个指令都可以选择在指令后面跟随一个描述性的字符串。除此之外,还可以将一个区域嵌套到另一个区域中。
【第04章】
(P106)
[规范]
- 要为方法名使用动词或动词短语;
方法总是和类型 —— 通常是类 —— 关联。类型将相关的方法分为一组。
方法通过返回值将数据返回给调用者。
(P107)
方法调用由方法名称和实参列表和返回值构成。
命名空间是一种分类机制,用于组合功能相关的所有类型。
命名空间是分级的,级数可以任意,但是很少见到超过 6 级的命名空间。
命名空间主要用于按照功能领域组织类型,以便更容易地查找和理解它们。
[规范]
-
要为命名空间使用 Pascal 大小写风格;
- 考虑将源代码的文件目录结构组织成与命名空间的层级结构相匹配的形式;
类型本质上是对方法及其相关数据进行组合的一种方式。
(P109)
在方法名称之后是圆括号中的实参列表,每个实参以逗号分隔,对应于声明方法时指定的形参。
方法可接收任意数量的形参,每个形参都具有特定的数据类型。调用者为形参提供的值称为实参;每个实参都要和一个形参对应。
可以将方法的返回值作为另一个方法的实参使用。
[注意]
- 通常,开发者应侧重于可读性,而不是在写出更短的代码方面耗费心机。为了使代码一目了然,进而在长时间里更容易维护,可读性是关键;
(P111)
C# 的每个方法都必须在某个类型中。
将一组相关语句转移到一个方法中,而不是把它们留在一个较大的方法中,这是重构 (refactoring) 的一种形式。
与简单地为一个代码块加上注释相比,重构的效果更好,因为只需看方法名就可清楚地知道这个方法要做的事情。
(P112)
- 要为参数名使用驼峰大小写风格;
虽然方法可以指定多个参数,但返回类型只能有一个。
如果方法有返回类型,它的主体必须有 “不可到达的结束点” 。
换言之,一个具有返回类型的方法不允许在不返回任何值的情况下将控制贯穿到方法的末尾。
为了保证这一点,最简单的办法就是将 return 语句作为方法的最后一个语句。
(P113)
注意, return 语句将控制转移出 switch ,所以,在以 return 语句作为方法最后一个语句的方法中,不需要用 break 语句防止非法 “贯穿” switch 小节。
虽然 C# 允许一个方法有多个返回语句,但为了增强代码的可读性,以及使代码更容易维护,应该尽可能地确定单一的退出位置,而不是在方法的多个代码中散布多个 return 语句。
为了支持不带方法主体的最简单的方法声明, C# 6.0 引入了表达式主体方法 (expression bodied method) ,使用表达式而不是一个完整的方法主体来声明一个方法。
与使用大括号包含方法主体不同,表达式主体方法使用 Lambda 操作符 (=>) ,结果数据类型必须与方法的返回类型匹配。也就是说,尽管在表达式主体方法实现中并没有显式的返回语句,表达式的返回类型仍然必须与方法声明的返回类型匹配。
表达式主体方法是完整方法主体声明的语法简化表示。因此,表达式主体方法的使用应限于最简化的方法实现,通常用于单行可表示的方法。
和 C++ 不同, C# 类从来不将实现与声明分开。 C# 不区分头文件 (.h) 和实现文件 (.cpp) 。相反,声明和实现总是出现在同一个文件中。
(P114)
重名的两个或更多类型只要在不同命名空间中,就没有歧义。
using 指令不会导入任何嵌套命名空间 (nested namespace) 中的类型。嵌套命名空间 (由命名空间中的句点符号来标识) 必须显式导入。
与 Java 相比, C# 不允许在 using 指令中使用通配符,每个命名空间都必须显式地导入。
(P115)
不仅可以在文件顶部使用 using 指令,还可以在命名空间声明的顶部包含它们。
在文件顶部放置 using 指令和在命名空间声明的顶部位置 using 指令的区别在于,后者的 using 指令只在声明的命名空间内有效。
(P116)
using static 指令允许省略规定类型的任何成员之前的命名空间和类型名称。
别名的两个最常见的用途是消除两个同名类型的歧义和缩写长名称。
(P120)
调用者中的变量名与被调用方法中的参数名相匹配。这种匹配纯粹是为了增强可读性,名称是否匹配与方法调用的行为无关。被调用方法的参数和发出调用的方法的局部变量在不同声明空间中,相互之间没有任何关系。
(P123)
out 参数在功能上和 ref 参数完全一致,唯一的区别是, C# 语言对别名变量的读写有不同的规定。
开发人员可以通过声明一个或多个 out 参数来克服方法只有一个返回类型的限制。
[注意]
- 每个正常返回的代码路径都必须对所有 out 参数进行赋值;
(P124)
参数数组不一定是方法的唯一参数,但必须是方法声明中的最后一个参数。由于只有最后一个参数才可能是参数数组,所以方法最多只能有一个参数数组。
(P125)
[规范]
- 当一个方法需要处理任意数量 (包括零个) 额外实参时,要使用参数数组;
(P127)
[注意]
C# 依据方法名、参数数据类型或者参数数量的不同来定义方法的唯一性。
(P129)
实现重载方法时经常采用的一种模式,它的基本思路是 : 开发者只需在一个方法中实现核心逻辑,其他所有重载版本都调用那个方法。如果核心实现发生了改变,那么只需要在一个位置修改,而不必在每个实现中都进行修改。
[注意]
- 在一个方法中实现核心功能,所有其他重载的方法都调用这个方法。这意味着你可以只修改核心方法的实现,其他重载的方法就会自动地享受到修改;
从 C# 4.0 开始,语言的设计者增添了对可选参数 (optional parameters) 的支持。声明方法时将常量值赋给参数,以后调用方法时就不必每个参数都指定。
(P130)
可选参数一定放在所有必须的参数 (无默认值的参数) 后面。另外,默认值必须是常量,或者说必须是能在编译时确定的值,这一点极大限制了 “可选参数” 的应用。
(P131)
[规范]
-
要尽量为所有参数提供好的默认值;
-
要提供简单的方法重载,其必需的参数的数量要少;
- 考虑从最简单到最复杂来组织重载;
C# 4.0 新增的另一个方法调用功能是命名参数 (named arguments) 。利用命名参数,调用者可显式地为一个参数赋值,而不是像以前那样只能依据参数顺序来决定哪个值赋给哪个参数。
添加了命名参数后,参数名就成为方法接口的一部分。更改名称会导致使用命名参数的代码无法编译。
[规范]
- 要将参数名视为 API 的一部分。如果 API 之间的版本兼容性很重要,就要避免更改参数名;
(P135)
try 关键字告诉编译器 : 开发者认为块中的代码有可能引发异常;如果真的引发了异常,那么某个 catch 块要尝试处理这个异常。
try 块之后必须紧跟着一个或多个 catch 块 (或 / 和一个 finally 块) 。 catch 块可选择指定异常的数据类型。只要数据类型与异常类型匹配,对应的 catch 块就会执行。但是,假如一直找不到合适的 catch 块,引发的异常就会变成一个未处理的异常,就好像没有进行异常处理一样。
(P136)
处理异常的顺序非常重要。 catch 块必须按照从最具体到最不具体排列。
无论控制是正常地离开 try 块还是由于 try 块中的代码引发异常而离开的,只要控制离开 try 块, finally 块就会执行。
finally 块的作用是提供一个最终位置,在其中放入无论是否发生异常都要执行的代码。
finally 块最适合用来执行资源清理。
事实上,完全可以只写一个 try 块和一个 finally 块,而不写任何 catch 块。
无论 try 块是否引发异常,甚至无论是否写了一个 catch 块来处理异常, finally 块都会执行。
(P137)
[规范]
-
避免从 finally 块显式地引发异常 (因方法调用而隐式地引发的异常可以被接受) ;
-
要优先使用 try / finally 而不是 try / catch 块来实现资源清理代码;
- 要在抛出的异常中描述异常为什么发生。如有可能,还要说明如何防范;
(P138)
可以指定一个不获取任何参数的 catch 块。
(P139)
没有指定数据类型的 catch 块称为常规 catch 块 (generic catch block) ,它等价于获取 object 数据类型的 catch 块。由于所有类最终都从 object 派生,所以没有数据类型的 catch 块必须放到最后。
常规 catch 块很少使用,因为没有办法捕获有关异常的任何信息。
[规范]
-
避免使用常规 catch 块,而应该使用捕获 System.Exception 的 catch 块来代替;
-
避免捕获无法获知其正确行动的异常。对这种异常不进行处理比处理地不正确要好;
- 避免在重新引发前捕获和记录异常。要允许异常逃脱,直至它被正确处理;
(P140)
有时 catch 块能捕获到异常,但不能正确或者完整地处理它。在这种情况下,可以让这个 catch 块重新引发异常,具体的办法是使用一个单独的 throw 语句,不要在它后面指定任何异常。
(P141)
[规范]
-
要在捕获并重新引发异常时使用空的 throw 语句,以便保持调用栈;
-
要通过引发异常而不是返回错误码来报告执行失败;
- 不要让公共成员将异常作为返回值或者 out 参数。要通过异常来指明错误;不要通过它们作为返回值来指明错误;
异常是专门为了跟踪例外的、事先没有预料到的、而且可能造成严重后果的情况而设计的。为预料之中的情况使用异常,会造成代码难以阅读、理解和维护。
[规范]
- 不要用异常来处理正常的、预期的情况;用异常处理异常的、非预期的情况;
(P142)
从 .NET Framework 4 开始,枚举类型也添加了 TryParse() 方法;
【第05章】
(P144)
面向对象编程的关键优势之一是不需要完全从头创建新的程序。而是可以将现有的一系列对象组装到一起,并用新的功能扩展类,或者添加更多的类。
为了支持封装, C# 必须支持类、属性、访问修饰符以及方法。
开发人员一旦熟悉了面向对象编程,除非写一些极为简单程序,否则很难回到结构化编程。
(P146)
虽然并非必须,但一般应该将每个类都放到它自己的文件中,用类名对文件进行命名。这样可以更容易地寻找定义了一个特定类的代码。
[规范]
-
不要在一个源代码文件中放置多个类;
- 要用所含公共类型的名称来命名源代码文件;
定义好新类后,就可以像使用 .NET Framework 内置的类那样使用它了。
换言之,可以声明那个类型的变量,或者定义方法来接收新类型的参数。
类是模板,定义了对象在实例化的时候看起来像什么样子。所以,对象是类的实例。
从类创建对象的过程称为实例化 (instantiation) ,因为对象是类的实例 (instance) 。
C# 使用 new 关键字实例化对象。
(P147)
面向对象编程将方法和数据装入对象。这提供了所有类成员 (类的数据和方法) 的一个分组,使它们不再需要单独处理。
程序员应将 new 的作用理解成实例化对象而不是分配内存。在堆和栈上分配对象都支持 new 操作符,这进一步强调了 new 不是关于内存分配的,也不是关于是否有必要进行回收的。
和 C++ 不同, C# 不支持隐式确定性资源清理 (在编译时确定的位置进行隐式对象析构) 。幸好, C# 通过 using 语句支持显式确定性资源清理,通过终结器支持隐式非确定性资源清理。
(P148)
面向对象设计的一个核心部分是对数据进行分组,以提供一个特定的结构。
在面向对象术语中,在类中存储数据的变量称为成员变量。
实例字段是在类的级别上声明的变量,用于存储与对象关联的数据。因此,关联 (association) 是字段类型和包容类型之间的联系。
注意,字段不包含 static 修饰符,这意味着它是实例字段。只能从其包容类的实例 (对象) 中访问实例字段,无法直接从类中访问 (换言之,不创建实例就不能访问) 。
(P150)
静态方法不能直接访问类的实例字段,必须获取类的实例才能调用实例成员 —— 无论该实例成员是方法还是字段。
在类的实例成员内部,可以获取对这个类的引用。在 C# 中,为了显式指出当前访问的字段或方法是包容类的实例成员,可以使用关键字 this 。调用任何实例成员时 this 都是隐式的,它返回对象本身的实例。
(P151)
虽然可为所有本地类成员引用添加 this 前缀,但规范的原则是,如果不会带来更多的价值就不要在代码中“添乱”。所以,只在必要时才使用 this 关键字。
(P152)
C# 关键字 this 完全等价于 Visual Basic 关键字 Me 。
假如存在与字段同名的局部变量或参数,省略 this 将访问局部变量或参数,而不是字段。所以,在这种情况下, this 是必须的。
还可使用 this 关键字显式访问类的方法。
有时需要使用 this 传递对当前正在执行的对象的引用。
(P156)
在类的外部不可见的成员称为私有成员。
(P157)
如果不为类成员添加访问修饰符,那么默认使用的是 private 。也就是说,成员默认为私有成员。公共成员必须显式指定。
(P161)
在 C# 6.0 之前的版本中,属性初始化只能通过方法进行。但到了 C# 6.0 ,就可以使用类似字段初始化的语法,在声明时自动初始化实现的属性。
[规范]
-
要使用属性简化对简单数据 (进行少量计算) 的访问;
-
避免从属性的取值方法中引发异常;
-
要在属性引发异常时保留原始属性值;
- 如果没有额外的实现逻辑,要优先使用自动实现的属性,而不是带有简单支持字段的属性;
[规范]
-
考虑为支持字段和属性使用相同的大小写风格,为支持字段附加 “_” 前缀。但不要使用双下划线,因为以双下划线开头的标识符是为 C# 编译器保留的;
-
要使用名词、名词短语或形容词来命名属性;
-
考虑让属性和它的类型同名;
-
避免用驼峰大小写风格命名字段;
-
如果有用的话,要为布尔属性附加 “Is” “Can” 或 “Has” 前缀;
-
不要声明 public 或 protected 的实例字段 (而是通过属性来公开字段) ;
-
要用 Pascal 大小写风格命名属性;
-
要优先使用自动实现的属性而不是字段;
- 如果没有额外的实现逻辑,要优先使用自动实现的属性,而不是自己编写完整版本;
(P163)
[规范]
-
避免从属性外部 (即使是在包容属性的类中) 访问属性的支持字段;
- 调用 ArgumentException() 或 ArgumentNullException() 构造器时,要为 paramName 参数传递 “value” (“value” 是属性赋值方法隐含的参数名) ;
(P165)
[规范]
-
如果不想调用者更改属性的值,要创建只读属性;
- 在 C# 6.0 (或以后的版本) 中,如果不想调用者更改属性的值,要创建只读的自动实现的属性,而不是带有后备字段的只读属性;
(P167)
[规范]
-
要为所有属性的取值方法和赋值方法的实现应用适当的可访问性修饰符;
- 不要提供只写属性,也不要让属性的赋值方法的可访问性比取值方法更宽松;
(P170)
构造器是 “运行时” 用来初始化对象实例的方法。
(P171)
假如类没有显式定义的构造器, C# 编译器会在编译时自动添加一个。该构造器不获取参数,称为默认构造器。
一旦为类显式添加了构造器, C# 编译器就不再自动提供默认构造器。
C# 3.0 新增了对象初始化器,用于初始化对象中所有可以访问的字段和属性。
总之,构造器退出时,所有属性都应该初始化成合理的默认值。
(P172)
[规范]
-
要为所有属性提供有意义的默认值,确保默认值不会造成安全漏洞或造成代码效率大幅下降。对于自动实现的属性,要通过构造器设置默认值;
- 要允许以任意顺序设置属性,即使这会造成对象临时处于无效状态;
(P173)
[规范]
-
如果使用构造器参数来设置属性,构造器参数 (驼峰大小写风格) 要使用和属性 (Pascal 大小写风格) 相同的名称,区别仅仅是大小写风格;
-
要为构造器提供可选参数,或者提供便利的重载构造器,用有意义的默认值初始化属性;
- 要允许以任意顺序设置属性,即使这会造成对象临时处于无效状态;
(P177)
在 C# 中,与全局字段或函数等价的是静态字段或方法。
(P178)
实例字段,也就是非静态字段,可以在声明的同时进行初始化。静态字段也可以。
和实例字段不同,未初始化的静态字段将获得默认值 (0 、 null 、 false 等) ,即 default(T) 的结果,其中 T 是类型名。所以,即使没有显式赋值的静态字段也能被访问。
静态字段不从属于实例,而是从属于类。
(P180)
由于静态方法不通过实例引用,所以 this 关键字在静态方法中无效。
静态构造器不显式调用,而是 “运行时” 在首次访问类时自动调用静态构造器。
由于静态构造器不能显式调用,所以不允许任何参数。
(P181)
使用静态构造器将类中的静态数据初始化成特定的值,尤其是无法通过声明时的一次简单赋值来获得初始值的时候。
在静态构造器中进行的赋值,将优先于声明时的赋值,这和实例字段的情况一样。注意,没有 “静态终结器” 的说法。
[规范]
- 考虑以内联方式初始化静态字段,不要使用静态构造器或者在声明时赋值;
还可以将属性声明为 static 。
(P182)
使用静态属性几乎肯定要比使用公共静态字段好,因为公共静态字段在任何地方都能调用,而静态属性则至少提供了一定程度的封装。
(P183)
在声明类时使用 static 关键字,具有两个方面的意义。首先,它防止程序员写代码来实例化静态类;其次,它防止在类的内部声明任何实例字段或方法。
静态类的另一个特点是 C# 编译器自动在 CIL 代码中把它标记为 abstract 和 sealed 。这会将类指定为不可扩展;换言之,不能从它派生出其他类。
(P184)
如果扩展方法的签名已经和被扩展类型中的签名匹配,扩展方法永远不会得到调用,除非是作为一个普通的静态方法。
(P185)
扩展方法要慎用。
[规范]
- 避免轻率地定义扩展方法,尤其是要避免为自己没有所有权的类型定义扩展方法;
和 const 值一样, const 字段 (称为常量字段) 包含在编译时确定的值,它不可以在运行时改变。
常量字段自动成为静态字段,因为不需要为每个对象实例都生成新的字段实例。但是,将常量字段显式声明为 static 会造成编译错误。
[规范]
-
要为永远不变的值使用常量字段;
- 不要为将来会发生变化的值使用常量字段;
(P186)
和 const 不同, readonly 修饰符只能用于字段 (不能用于局部变量) 。
它指出字段值只能从构造器中更改,或者在声明时通过初始化器修改。
和 const 字段不一样,每个实例的 readonly 字段都可以不同。
由于 readonly 字段必须从构造器中设置,所以编译器要求这种字段能从其属性外部访问。
(P187)
将 readonly 应用于数组不会冻结数组的内容,而是冻结数组实例 (也冻结了数组中的元素数量) ,这是因为无法将值重新赋给新的实例。但数组中的元素仍然是可写的。
[规范]
-
在 C# 6.0 (及之后版本) 中,要优先使用只读的自动实现的属性,而不是定义只读字段;
-
在 C# 6.0 之前的版本中,要为预定义对象实例使用 public static readonly 字段;
- 如果 API 版本的兼容性有要求,要避免将 C# 6.0 之前版本中的公共的 readonly 字段修改为 C# 6.0 (及之后版本) 中的只读的自动实现的属性;
在类中除了定义方法和字段,还可以定义另一个类。这称为嵌套类 (nested class) 。假如一个类在它的包容类外部没有多大意义,就适合把它设计成嵌套类。
(P188)
嵌套类的独特之处是可以为类自身指定 private 访问修饰符。
(P189)
嵌套类中的 this 成员代表嵌套类而不是包容类的实例。嵌套类要想访问包容类的实例,一个办法是显式传递包容类的实例,比如通过构造器或者方法参数。
嵌套类的另一个有趣的特点是它能访问包容类的任何成员,其中包括私有成员。反之则不然,包容类不能访问嵌套类的私有成员。
嵌套类用得很少。要从包容类型外部引用,就不能定义成嵌套类。另外要警惕 public 嵌套类,它们意味着不良的编码风格,可能造成混淆和难以阅读。
[规范]
- 避免声明公共嵌套类型。唯一的例外是在这种类型的声明没有多大意义的时候,或者这种类型的声明是与一种高级的自定义场景有关;
分部类主要用于将一个类的定义划分到多个文件中。
分部类对代码生成或修改工具来说意义重大。
C# 2.0 (和更高版本) 使用 class 前的上下文关键字 partial 来声明分部类。
除了用于代码生成器,分部类另一个常见的应用是将每个嵌套类都放到它们自己的文件中。这是为了与编程规范 “将每个类定义都放到它自己的文件中” 保持一致。
(P190)
分部类不允许对编译好的类 (或其他程序集中的类) 进行扩展。分部类只是在同一个程序集中将一个类的实现拆分到多个文件中。
(P192)
分部方法必须返回 void 。
【第06章】
(P193)
派生类型总是隐式地属于基类型。
[注意]
- 代码中的继承用于定义 “属于” 关系,派生类是对基类的特化;
(P195)
每个派生类都拥有由其所有基类公开的全部成员。
[注意]
- 通过继承,基类的每个成员都会出现在派生类的链条中;
所有类都隐式地派生于 object ,不管是否这样指定。
[注意]
- 除非明确指定了基类,否则所有类都默认从 object 派生;
(P196)
从基类型转换为派生类型,要求执行显式转型,而显式转型在运行时可能会失败。
[注意]
- 派生对象可隐式转型为它的基类。相反,基类向派生类的转换要求显式的转型操作符,因为转换可能会失败。虽然编译器允许可能有效的显式转型,但 “运行时” 会坚持进行检查,如果在执行时出现非法的转型,会引发异常;
(P197)
派生类继承了除构造器和析构器之外的所有基类成员。但是,继承并不意味着一定能访问。
(P198)
根据封装原则,派生类不能访问基类的 private 成员。
[注意]
- 派生类不能访问基类的私有成员;
(P199)
[注意]
- 基类中的受保护成员只能从基类以及其派生链中的其他类访问;
基本规则是,要从派生类中访问受保护成员,必须在编译时确定是从派生类 (或者它的某个子类) 的实例中访问受保护成员。
由于每个派生类都可作为它的任何基类的实例使用,所以对一个类型进行扩展的方法也可扩展它的任何派生类型。
如果扩展基类,所有扩展方法在派生类中也可以使用。
很少为基类写扩展方法。扩展方法的一个基本原则是,假如手上有基类的代码,直接修改基类会更好。
(P201)
密封类要求使用 sealed 修饰符,这样做的结果是不能从它们派生出其他类。 string 类型就用 sealed 修饰符禁止了派生。
基类除构造器和析构器之外的所有成员都会在派生类中继承。
(P202)
C# 支持重写实例方法和属性,但不支持重写字段或者任何静态成员。
在基类中,必须将允许重写的每个成员标记为 virtual 。
默认情况下, Java 中的方法都是虚方法。假如希望方法具有非虚的行为,就必须显式密封它。相反, C# 的方法默认为非虚方法。
C# 要求显式使用 override 关键字来重写方法。换句话说, virtual 标志着方法或属性可在派生类中被替换 (重写) 。
(P203)
为了重写方法,基类和派生类成员必须匹配,而且要有对应的 virtual 和 override 关键字。此外, override 关键字意味着派生类的实现会替换基类的实现。
对成员进行重载,会造成 “运行时” 调用最深的或者说派生得最远的实现。
“运行时” 遇到虚方法时,它会调用虚成员派生得最远的、重写的实现。
创建类时必须谨慎选择是否允许重写方法,因为控制不了派生的实现。虚方法不应包含关键代码,因为如果派生类重写了它,那些代码就永远得不到调用。
(P204)
虚方法只提供默认实现,这种实现可由派生类完全重写。然而,由于继承设计的复杂性,所以请事先想好是否需要虚方法。
(P205)
最后要说的是,只有实例成员才可以是 virtual 的。 CLR 根据具体化的类型 (在实例化期间指定) 来判断将虚方法调用调度到哪里。所以 static virtual 方法毫无意义,编译器也不允许。
(P208)
就 CIL 来说, new 修饰符对编译器生成的代码没有任何影响。然而,一个 “新” 方法会生成方法的 newslot 元数据特性。从 C# 的角度看,它唯一的作用就是移除编译器警告。
一般很少将整个类标记为密封,除非是遇到迫切需要这种限制的情况。
(P209)
为了调用基类的实现,要使用 base 关键字。它的语法几乎和 this 一样,包括支持将 base 作为构造器的一部分使用。
用 override 修饰的任何成员都自动成为虚成员,其他子类能进一步 “特化” 它的实现。
[注意]
- 用 override 修饰的任何方法都自动成为虚方法。只能对基类的虚方法进行重写,所以重写获得的方法也是虚方法;
实例化一个派生类时, “运行时” 首先调用基类的构造器,以避免绕过对基类的初始化。
(P210)
抽象类是仅供派生的类。无法实例化抽象类,只能实例化从它派生的类。不抽象、可直接实例化的类称为具体类。
抽象类代表抽象的实体。其抽象成员定义了从抽象实体派生的对象应包含什么,但这种成员不包含实现。通常,抽象类中的大多数功能都没有实现。一个类要从抽象类成功地派生,必须为抽象基类中的抽象方法提供具体的实现。
(P211)
不可实例化只是抽象类的一个较次要的特征。其主要特征是它包含抽象成员。抽象成员是没有实现的方法或属性,其作用是强制所有派生类提供实现。
(P212)
由于抽象成员应当被重写,所以自动成为虚成员 (但不能用 virtual 关键字显式地这样声明) 。除此之外,抽象成员不能声明为私有,否则派生类看不见它们。
[注意]
- 抽象成员必须被重写,因此会自动成为虚成员,但不能用 virtual 关键字显式声明;
(P213)
抽象成员是实现多态性的一个手段。基类指定方法的签名,而派生类提供具体的实现。
(P214)
所有对象最终都从 object 派生 (不管是直接派生还是通过继承链派生) 。
(P215)
即使类定义没有显式地指明自己从 object 派生,也肯定是从 object 派生的。
C# 提供了 is 操作符来判断基础类型。
is 操作符的优点在于,它允许验证一个数据项是否属于特定类型。 as 操作符则更进一步,它会像一次转型所做的那样,尝试将对象转换为特定数据类型。但和转型不同的是,如果对象不能转换, as 操作符会返回 null 。这一点相当重要,因为它避免了可能因为转型而造成的异常。
(P216)
使用 as 操作符可避免用额外的 try-catch 代码处理转换无效的情况,因为 as 操作符提供了尝试执行转型但转型失败后不引发异常的一个办法。
is 操作符相较于 as 操作符的一个优点是后者不能成功判断基础类型。 as 操作符能在继承链中向上或向下隐式转型,也支持提供了转型操作符的类型。 as 不能判断基础类型而 is 能。
【第07章】
(P218)
接口是非常有用的,因为和抽象类不同,接口能将实现细节和提供的服务完全隔离开。
(P219)
接口只允许共享成员签名,不允许共享实现。
接口订立了契约,类必须履行这个契约,才能同实现该接口的其他类进行交互。
接口的关键特点是既不包含实现,也不包含数据。注意其中的方法声明,它用一个分号取代了大括号。字段 (数据) 不能在接口声明中出现。如果接口要求派生类包含特定数据,会声明属性而不是字段。由于没有属性的任何实现可以作为接口声明的一部分,所以属性不引用支持字段。
接口声明的成员描述了在实现该接口的类型中必须能够访问的成员。而所有非公共成员的目的是阻止其他代码访问成员。所以, C# 不允许为接口成员使用访问修饰符。所有成员都自动定义为公共成员。
(P220)
[规范]
- 接口名称要使用 Pascal 大小写风格,并以 “I” 作为前缀;
(P223)
声明类以实现接口,类似于从基类派生 —— 要实现的接口和基类名称以逗号分隔,基类 (如果有的话) 在前,接口顺序任意。类可实现多个接口,但只能从一个基类直接派生。
(P224)
一旦某个类声明自己要实现接口,接口的所有成员都必须实现。抽象类允许提供接口成员的抽象实现。
接口的一个重要特征是永远不能实例化。
接口没有构造器或终结器。
(P225)
只有实例化实现接口的类型,才能使用接口实例。
显式实现的方法只能通过接口本身调用。
声明显式接口成员实现要在成员名之前附加接口名前缀。
(P227)
[规范]
- 避免显式实现接口成员,除非有很好的理由。但如果不确定成员的用途,就先选择显式实现;
与派生类和基类的关系相似,从实现类型向它的已实现接口的转换是隐式转换,不需要转型操作符。实现类型的实例总是包含接口的全部成员,所以对象总是能成功转换为接口类型。
从接口转换为它的某个实现类型,需要执行一次显式的强制转型。
一个接口可以从另一个接口派生,派生的接口将继承 “基接口” 的所有成员。
(P230)
扩展方法的一个重要特点是除了能作用于类,还能作用于接口。
(P231)
C# 不仅允许为特定类型的实例添加扩展方法,还允许为那些对象的集合添加扩展方法。
(P232)
[规范]
- 考虑通过定义接口来获得和多继承相似的效果;
(P233)
接口在负责实现的类和使用接口的类之间订立了契约,改动接口相当于改动契约,会使基于接口编写的代码失效。
实现接口的任何类都必须完整地实现,必须提供针对所有成员的实现。
[规范]
- 不要为已交付的接口添加成员;
(P234)
接口引入了另一个类别的数据类型 (是少数不对终极基类 System.Object 进行扩展的类型之一) 。但和类不同的是,接口永远不能实例化。只能通过对实现接口的一个对象的引用来访问接口实例。不能用 new 操作符创建接口实例,所以接口不能包含任何构造器或终结器。此外,接口中不允许静态成员。
[规范]
-
一般要优先选择类而不是接口。用抽象类将契约 (类型做什么) 与实现细节 (类型怎么做) 分离开;
- 想在已从其他类型派生的类型上支持接口所定义的功能时,就考虑定义接口;
(P235)
接口应该用于表示类型能提供的功能,而非陈述关于某个类型的事实。
[规范]
- 避免使用无成员的标记接口,而是使用特性;
【第08章】
(P237)
[规范]
- 不要创建消耗内存大于 16 字节的值类型;
值类型的值一般只是短时间存在。很多情况下,这样的值只是作为表达式的一部分,或用于激活方法。在这些情况下,值类型的变量和临时值经常是存储在称为栈的临时存储池中。
临时池清理起来的代价低于需要进行垃圾回收的堆。不过,值类型要比引用类型更频繁地复制,这种复制操作会增加性能的开销。
引用类型的变量关联了两个存储位置 : 直接和变量关联的存储位置,以及由变量中存储的值引用的存储位置。
(P238)
复制引用类型的值时,复制的只是引用,这个引用非常小。 (一个引用的大小就是处理器的 “bit size” ; 32 位机器是 4 字节的引用, 64 位机器是 8 字节的引用,以此类推) 。
复制值类型的值会复制所有的数据,这些数据可能很大。
有时复制引用类型的效率更高,这正是编码规范要求值类型不得大于 16 字节的原因。如果复制值类型的代价比作为引用复制时高出 4 倍,就应该把它设计成引用类型了。
(P239)
除了 string 和 object 是引用类型,所有 C# “内建” 类型都是值类型。
(P240)
[注意]
虽然语言本身未作要求,但对于使用值类型的一种良好的规范是确保值类型是不可变的。换言之,一旦实例化值类型,实例就不能修改。要修改,应该创建新实例。
[规范]
- 要创建不可变的值类型;
除了属性和字段,结构还可包含方法和构造器。结构不允许包含用户定义的默认 (无参) 构造器。在没有提供默认的构造器时, C# 编译器自动地产生一个默认的构造器将所有字段初始化为各自的默认值。引用数据类型字段的默认值是 null ,数值类型字段的默认值是零,布尔类型字段的默认值是 false 。
为了确保值类型的局部变量能被完全初始化,结构的每个构造器都必须初始化结构中的所有字段 (和只读的自动实现的属性) 。
(P241)
[规范]
- 要确保结构的默认值有效,总是可以获得结构默认的 “全零” 值;
(P242)
所有值类型都有自动定义的无参构造器将值类型的实例初始化成默认状态。所以,总是可以合法地使用 new 操作符创建值类型的实例。除此之外,还可使用 default 操作符生成结构的默认值。
表达式 default(int) 和 new int() 都生成一样的值。
所有值类型都隐式密封。除此之外,除了枚举之外的所有值类型都派生自 System.ValueType 。因此,结构的继承链总是从 object 到 System.ValueType 到结构。
值类型也能实现接口。
(P243)
[规范]
- 如果值类型的相等性有意义,要重载值类型的相等性操作符 (Equals() 、 == 和 !=) 。还要考虑实现 IEquatable<T> 接口;
装箱和拆箱之所以重要,是因为装箱会影响性能和行为。
(P245)
每个装箱操作都涉及内存分配和复制,每个拆箱操作都涉及类型检查和复制。
不允许在 lock() 语句中使用值类型。
(P247)
[规范]
- 避免可变的值类型;
(P249)
枚举是可由开发者声明的值类型。枚举的关键特征是在编译时声明了一组可以通过名称来引用的常量值,这使代码更易读。
[注意]
- 用枚举替代布尔值能改善可读性;
(P250)
枚举值实际是作为整数常量实现的。默认第一个枚举值是 0 ,后续每一项都递增 1 。然而,可以显式地为枚举赋值。
枚举总是具有一个基础类型,这可以是除了 char 之外的任意整型。事实上,枚举类型的性能完全取决于基础类型的性能。默认基础类型是 int ,但可以使用继承语法指定其他类型。
[规范]
- 考虑使用默认的 32 位整型作为枚举的基础类型。只有出于互操作性或者性能方面的考虑才使用较小的类型,只有创建标志 (flag) 数超过 32 个的标志枚举时才使用较大的类型;
(P251)
[规范]
-
考虑在现有枚举中添加新成员,但要注意兼容性风险;
-
避免创建代表 “不完整” 值 (如版本号) 集合的枚举;
-
避免在枚举中创建 “保留给将来使用” 的值;
-
避免包含单个值的枚举;
- 要为简单枚举提供 0 值 (代表无) 。若不显式地进行初始化,就默认从 0 开始;
枚举和其他值类型稍有不同,因为枚举类型派生自 System.Enum ,而 System.Enum 又是从 System.ValueType 派生的。
(P252)
枚举的一个好处是 ToString() 方法会输出枚举值标识符。
(P253)
[规范]
- 如果字符串必须本地化成用户语言,避免枚举和字符串之间的直接转换;
[注意]
- 位标志枚举名称通常是复数,因为它的值代表一组标志;
使用按位 OR 操作符联结枚举值,使用按位 AND 操作符测试特定位是否存在。
(P255)
[规范]
-
要用 FlagsAttribute 指出枚举包含标志;
-
要为所有标志枚举提供等于 0 的 None 值;
-
避免标志枚举中的零值是除了 “所有标志都未设置” 之外的其他意思;
-
考虑为常用标志组合提供特殊值;
-
不要包含 “哨兵” 值,这种值会使用户感到困惑;
- 要用 2 的幂确保所有标志组合都不重复;
如果决定使用位标志枚举,枚举的声明应该用 FlagsAttribute 进行标记。这个特性应包含在一对方括号中,并放在枚举声明之前。
(P257)
[规范]
- 除非它在逻辑上代表单个值,消耗 16 字节或更少的存储空间,不可变,而且很少装箱,否则不要定义结构;
【第09章】
(P258)
默认情况下,在任何对象上调用 ToString() 会返回类的完全限定名称。
(P259)
Console.WriteLine() 和 System.Diagnostics.Trace.Write() 等方法会调用对象的 ToString() 方法,所以可重写 ToString() 输出比默认实现更有意义的信息。
[规范]
-
要重写 ToString() 以返回有用的、面向开发人员的诊断字符串;
-
要使 ToString() 返回的字符串简短;
-
不要从 ToString() 返回空字符串代表 “空” (null) ;
-
避免 ToString() 引发异常或造成可观察到的副作用 (改变对象状态) ;
-
如果返回值与语言文化相关或需要格式化,就要重载 ToString(string format) 或实现 IFormattable ;
- 考虑从 ToString() 返回独一无二的字符串以标识对象实例;
(P261)
引用的相等性并不是唯一 “相等性” 。两个对象实例的成员值部分或全部相等,也可以说它们相等。
(P263)
两个同一的引用显然是相等的,然而,两个引用不相等的对象也可能是相等的对象。对象标识不同,不一定标识数据不同。
[注意]
- 为值类型调用 ReferenceEquals() 将总是返回 false ;
(P264)
判断两个对象是否相等 (即,它们包含相同的标识数据) 是使用对象的 Equals() 方法。在 object 中,这个虚方法只是用 ReferenceEquals() 判断相等性。这显然并不充分,所以一般都有必要用更恰当的实现重写 Equals() 。
[注意]
- object.Equals() 的实现只是简单地调用了一下 ReferenceEquals() ;
(P267)
[规范]
-
要一起实现 GetHashCode() 、 Equals() 、 == 操作符和 != 操作符,缺一不可;
-
要用相同的算法实现 Equals() 、 == 和 != ;
-
避免在 GetHashCode() 、 Equals() 、 == 和 != 的实现中引发异常;
-
避免重载可变的引用类型的相等性操作符,对于重载的实现速度过慢的相等性操作符,也要避免重载;
- 要在实现 IComparable 时,实现与相等性有关的所有方法;
(P267)
除非目的是使类型表现得像是一种基元类型 (如数值类型) ,否则就不要去重载操作符。
== 默认也是执行引用相等性检查。
(P268)
[注意]
- 在 == 操作符的重载实现中避免使用相等性比较操作符 (==) ;
(P269)
- 、 - 、 * 、 / 、 % 、 & 、 | 、 ^ 、 << 和 >> 操作符都被实现成二元静态方法,其中至少有一个参数的类型是包容类型 (当前正在实现该操作符的类型) 。
(P271)
从技术上说,实现显式和隐式转换操作符并不是对转型操作符 (()) 进行重载。但由于效果一样,所以一般都将 “实现显式或隐式转换” 说成 “定义转型操作符” 。
(P272)
[注意]
- 实现转换操作符时,为了保证封装性,要么返回值,要么参数必须是封闭类型。 C# 不允许在被转换类型的作用域之外指定转换;
[规范]
-
不要为有损转换提供隐式转换操作符;
- 不要从隐式转换中引发异常;
(P273)
开发者可以将程序的不同部分转移到单独的编译单元中,这些单元称为类库,或者简称为库。然后,程序可以引用和依赖类库来提供自己的一部分功能。这样一来,两个程序就可以依赖同一个类库,从而在两个程序中共享那个类库的功能,并减少所需的编码量。
省略 /target 或者指定 /target:exe 都将创建一个控制台可执行程序。
要在多个应用程序中共享的程序集通常编译成类库。
为了访问不同程序集中的代码, C# 编译器允许开发者在命令行上引用程序集。这种情况下使用的选项是 /reference (/r 是缩写) ,后跟一个引用列表。
(P274)
类封装了一系列相关的行为和数据,程序集则封装了一系列相关的类型。开发者可以将一个系统分解成多个程序集,然后在多个应用程序中共享那些程序集,或者将它们与第三方提供的程序集集成。
默认情况下,没有任何访问修饰符的类被定义成 internal 。结果是该类型无法从程序集外部访问。即使另一个程序集引用了该类所在的程序集,被引用程序集中的所有 internal 类都是无法访问的。
(P275)
类似于为类成员使用 private 和 protected 访问修饰符来指定不同的封装级别, C# 允许为类使用访问修饰符,从而控制类在程序集中的封装级别。可用的访问修饰符是 public 和 internal 。类要在程序集外部可见,必须标记成 public 。
internal 访问修饰符并非仅适用于类型声明,它还适用于类型的成员。
成员的可访问性无法超过它所在的类型的可用性。
protected internal 是另一种类型成员访问修饰符。这种成员可从包容程序集的任何位置以及类型的派生类中访问 (即使派生类不在同一个程序集中) 。
由于默认是 private ,所以随便指定别的一个访问修饰符 (public 除外) ,成员的可见性都会稍微提高。
添加两个修饰符,可访问性会复合到一起,变得更大。
[注意]
- protected internal 成员可以从包容程序集的任何位置以及类型的派生类中访问 (即使派生类不在同一个程序集中) ;
(P276)
任何数据类型都用命名空间与名称的组合来标识;
CLR 对 “命名空间” 一无所知, CLR 中的类型名称都是完全限定的,包含命名空间。
(P277)
命名空间大括号之间的所有内容都从属于该命名空间。
[注意]
- CLR 中没有 “命名空间” 这种东西。类型名称必然完全限定;
和类相似,命名空间也可以嵌套。
(P278)
由于命名空间是对类型进行组织的关键,所以使用命名空间来组织所有的类文件通常都是有益的。
有鉴于此,可以为每个命名空间都创建一个文件夹。
[规范]
-
要为命名空间名称附加公司名前缀,防止不同公司的命名空间使用相同的名称;
-
要为命名空间名称中的二级名称使用稳定的、不随版本升级而变化的产品名称;
-
不要定义没有明确放到一个命名空间中的类型;
- 考虑创建与命名空间层次结构相匹配的文件夹结构;
(P282)
[规范]
- 如果 API 签名不能完全说明问题,要为公共 API 提供 XML 注释,其中包括成员说明、参数说明和 API 调用示例;
垃圾回收时是 “运行时” 的核心功能,作用是回收不再被引用的对象所占用的内存。这句话的重点是 “内存” 和 “引用” 。垃圾回收器只回收内存,不处理其他资源。
垃圾回收器根据是否存在任何引用来决定要清理什么。这暗示垃圾回收器处理的是引用对象,只回收堆上的内存。
为了定位和移动所有可达对象,系统要在垃圾回收器运行期间维持状态的一致性。为此,进程中的所有托管线程都会在垃圾回收期间暂停。这显然会造成应用程序出现短暂的停顿。不过,除非垃圾回收周期特别长,否则这个停顿是不太引人注意的。
(P284)
终结器不能从代码中显式调用。
[注意]
- 编译时不能确定终结器的确切执行时间;
(P285)
终结器不允许传递任何参数,因此终结器不能重载。此外,终结器不能显式调用。调用终结器的只能是垃圾回收器。因此,为终结器添加访问修饰符没有意义 (也不支持) 。基类中的终结器作为对象终结调用的一部分被自动调用。
[注意]
- 终结器不能显式调用,只有垃圾回收器才能调用终结器;
由于垃圾回收器负责所有内存管理工作,所以终结器不负责回收内存。
终结器在自己的线程中执行,这使它们的执行变得更不确定。
终结器是对资源进行清理的备用机制。
很有必要提供进行确定性终结的方法,避免依赖终结器不确定的计时行为。
(P287)
using 语句只是提供了 try / finally 块的语法快捷方式。
(P289)
[规范]
-
要只为使用了稀缺或昂贵资源的对象实现终结器方法,即使终结会推迟垃圾回收;
-
要为有终结器的类实现 IDisposable 接口以支持确定性终结;
-
要为实现了 IDisposable 的类实现终结器方法,以防 Dispose() 没有被显式调用;
-
要重构终结方法来调用与 IDisposable 相同的代码,可能就是调用一下 Dispose() 方法;
-
不要在终结器方法中引发异常;
-
要从 Dispose() 中调用 System.CC.SuppressFinalize() ,使垃圾回收更快地发生,并避免重复性的资源清理;
-
要保证 Dispose() 具有幂等性 (可以被多次调用) ;
-
要保证 Dispose() 的简单性,把重点放在终结所要求的资源清理上;
-
避免为自己拥有的、带终结器的对象调用 Dispose() 。相反,依赖终结队列清理实例;
-
避免在终结方法中引未被终结的其他对象;
-
要在重写 Dispose() 时调用基类的实现;
-
考虑在调用 Dispose() 之后确保对象状态变为不可用。对象被 dispose 之后,调用除 Dispose() 之外的方法都应该引发 ObjectDisposedException 异常。 (Dispose() 应该能多次调用) ;
- 要为含有可 dispose 字段 (或属性) 的类型实现 IDisposable 接口,并 dispose 这些实例;
【第10章】
(P292)
要引发异常,只需为要引发的异常实例附加关键字 throw 作为前缀。
(P293)
C# 6.0 的总的规范是,对于参数类型异常中的参数名称应该使用 nameof 操作符。
(P294)
[规范]
-
要在向成员传递了错误参数时引发 ArgumentException 或者它的某个子类型。引发尽可能具体的异常 (如 ArgumentNullException) ;
-
要在引发 ArgumentException 或者它的某个子类时设置 ParamName 属性;
-
要对传入参数异常类型 (如 ArgumentException 、 ArgumentOutRangeException 和 ArgumentNullException) 的 ParamName 实参使用 nameof 操作符;
-
要引发能说明问题的、最具体的异常 (派生得最远的异常);
-
不要引发 NullReferenceException 。相反,在值意外为空时引发 ArgumentNullException ;
-
不要引发 System.SystemException 或者从它派生的异常类型;
-
不要引发 System.Exception 或者 System.ApplicationException ;
- 考虑在程序继续执行会变得不安全时调用 System.Environment.FailFast() 来终止进程;
(P297)
C# 还支持常规 catch 块,即 catch{} ,它在行为上和 catch(System.Exception exception) 块完全一致,只是没有类型名或变量名。除此之外,常规 catch 块必须是所有 catch 块的最后一个。
C# 允许写一个无参数的 catch 块, C# 团队将这个 catch 块称为常规 catch 块。
(P298)
常规 catch 块捕获先前的 catch 块没有捕获到的所有异常,无论它们是不是从 System.Exception 派生。
(P299)
常规 catch 块 (空 catch 块) 不仅能捕获非托管类型的异常,还能捕获非 System.Exception 托管类型的异常。
[规范]
-
避免在调用栈较低的位置报告或记录异常;
-
不要捕获不应该捕获的异常。要允许异常在调用栈中向上传播,除非能非常清楚地知道如何通过程序准确地定位栈中较低位置的错误;
-
如果理解特定异常在给定的上下文中为何引发,并能通过程序响应错误,就考虑捕获该异常;
-
避免捕获 System.Exception 或 System.SystemException ,除非是在顶层异常处理程序中在重新引发异常之前执行最后的清理操作;
-
要在 catch 块中使用 throw ;而不是 throw <异常对象> 语句;
-
重新引发不同的异常时要谨慎;
-
不要引发 NullReferenceException 。相反,在值意外为空时引发 ArgumentNullException ;
-
避免通过异常条件引发异常;
-
避免使用会经常改变的异常条件;
(P303)
[规范]
-
如果异常不以有别于现有 CLR 异常的方式进行处理,就不要创建新异常。相反,应该引发现有的框架异常;
-
要创建新异常类型来描述特别的程序错误。这种错误用现有的 CLR 异常无法描述,而且能通过程序以不同于现有 CLR 异常类型的方式进行处理;
-
要为所有自定义异常类型提供无参构造器。还要提供获取消息和内部异常作为参数的构造器;
-
要为异常类的名称附加 “Exception” 后缀;
-
要使异常能由 “运行时” 序列化;
-
考虑提供异常属性,以便通过程序访问异常的额外信息;
- 避免使用过深的异常继承层次结构;
-
(P304)
[规范]
-
如果低层引发的异常在高层操作的上下文中没有意义,考虑将低层异常封装到更恰当的异常中;
-
要在封装异常时设置内部异常属性;
-
要将开发人员作为异常的接收者,尽量说清楚问题和解决问题的机制;
- 要在重新引发相同的异常时使用空的 throw 语句 (throw;) ,而不是向 throw 传递异常作为参数;
【第11章】
(P307)
C# 通过泛型 (generics) 来促进代码重用,尤其是算法的重用。
(P313)
泛型允许开发人员把精力放在创建算法和模式上,并确保代码能由不同数据类型重用。
在类名之后,需要在一对尖括号中指定类型参数。
可以向泛型提供类型实参,它将 “替换” 类中出现的每个 T 。
(P314)
最核心的是,泛型允许写代码来实现模式,并在以后出现这种模式的时候重用那个实现。模式描述了在代码中反复出现的问题,而泛型类型为这些反复出现的模式提供了单一的实现。
(P315)
[规范]
-
要为类型参数选择有意义的名称,并为名称附加 “T” 前缀;
- 考虑在类型名称中指明约束;
C# 支持在语言中全面地使用泛型,其中包括接口和结构。
要声明包含类型参数的接口,将类型参数放到接口名称后面的一对尖括号中即可。
注意,一个泛型的类型实参可以成为另一个泛型类型的类型参数。
相同泛型接口的不同构造被就看成是不同的类型,所以类或结构能多次实现 “同一个” 泛型接口。
(P316)
[规范]
- 避免在类型中实现同一个泛型接口的多个构造;
泛型类或结构的构造器 (和终结器) 不要求类型参数。
(P317)
default 操作符可提供任意类型的默认值,包括类型参数。
(P318)
类型参数的数量 (或者称为元数,即 arity) 对类进行了唯一性的区别。
[规范]
- 要将只是类型参数数量不同的多个泛型类放到同一个文件中;
(P320)
[规范]
- 避免在嵌套类型中用同名参数隐藏外层类型的类型参数;
(P323)
对于任何给定的类型参数,都可以指定任意数量的接口约束,但类类型约束只能指定一个,因为一个类可以实现任意数量的接口,但肯定只能从一个类继承。每个新约束都在一个以逗号分隔的列表中声明,约束列表跟在泛型类型名称和一个冒号之后。如果有多个类型参数,每个类型参数前面都要使用 where 关键字。
(P324)
注意,在两个 where 字句之间,并不存在逗号。
(P329)
泛型方法要使用泛型类型参数,这一点和泛型类型一样。
在泛型或非泛型类型中都能声明泛型方法。
如果在泛型类型中声明泛型方法,其类型参数和泛型类型的类型参数是有区别的。
为了声明泛型方法,要按照与泛型类型一样的方式指定泛型类型参数,也就是在方法名之后添加类型参数声明。
使用泛型类型时,是在类型名之后提供类型实参。类似地,调用泛型方法时,是在方法的类型名之后提供类型实参。
(P330)
为了避免多余的编码,可以在调用时不指定类型实参。这就是所谓的类型推断。
类型推断要想成功,方法实参的类型必须与泛型方法的形参 “匹配” 以推断出正确的类型实参。
泛型方法的类型参数也允许指定约束,其方式与在泛型类型中指定类型参数的方式相同。
约束在参数列表和方法主体之间指定。
(P332)
[规范]
- 避免用表面上看是类型安全的但实际并不是类型安全的泛型方法误导调用者;
(P333)
从 C# 4 开始加入了对安全协变性的支持。为了指出泛型接口应该对它的某个类型参数协变,就用 out 修饰符来修饰该类型参数。
(P334)
用 out 修饰泛型接口的类型参数,会导致编译器验证 T 真的只用作 “输出” ,即只用于方法的返回类型和只读属性的返回类型,永远不用于形参或者属性的赋值方法。
协变转换有一些重要的限制 :
-
只有泛型接口和泛型委托才可以是协变的。泛型类和结构永远不是协变的;
-
提供给 “来源” 和 “目标” 泛型类型的类型实参必须是引用类型,不能是值类型;
- 接口或委托必须声明为支持协变,编译器必须验证协变所针对的类型参数确实只用在 “输出” 位置;
(P335)
与协变性相似,逆变性要求在声明接口的类型参数时使用修饰符 in 。它指示编译器核实 T 从未在属性的取值方法 (get 访问器方法) 中出现,也没有作为方法的返回类型使用。如果检查无误,就启用接口的逆变转换。
逆变转换存在与协变转换相似的限制 : 只有泛型接口和委托类型才能是逆变的,发生变化的类型实参只能是引用类型,而且编译器必须能验证接口对于逆变转换是安全的。
(P336)
[规范]
- 避免不安全的数组协变。而是考虑将数组转换成只读接口 IEnumerable<T> ,以便通过协变转换来安全地转换;
(P337)
泛型类编译后与普通类并无区别。编译的结果只有元数据和 CIL 。 CIL 是参数化的,接受在代码中别的地方由用户提供的类型。
除了在类的头部包含元数和类型参数,并在代码中用感叹号指出类型参数之外,泛型类和非泛型类的 CIL 代码并无多大区别。
【第12章】
(P343)
就像类能嵌套在其他类中一样,委托也能嵌套在类中。假如委托声明出现在另一个类的内部,委托类型就会成为嵌套类型。
(P345)
从 C# 2.0 开始,从方法组 (为方法命名的表达式) 向委托类型的转换会自动创建一个新的委托对象。
委托实际是特殊的类。
.NET 中的委托类型总是派生自 System.MulticastDelegate ,后者又从 System.Delegate 派生。
(P348)
语句 Lambda 由形参列表,后跟 Lambda 操作符 (=>) ,然后跟一个代码块构成。
(P349)
通常,只要编译器能从 Lambda 表达式所转换成的委托推断出类型,所有 Lambda 表达式都不需要显式声明参数类型。然而,若指定类型能使代码更易读, C# 也允许这样做。在不能推断出类型的情况下, C# 要求显式地指定 Lambda 参数类型。只要显式指定了一个 Lambda 参数类型,所有参数类型都必须被显式指定,而且必须和委托参数类型完全一致。
[规范]
- 考虑在 Lambda 形参列表中省略类型,只要类型对于读者是显而易见的,或者是无关紧要的细节;
当只有单个参数,而且类型可以推断时,这种 Lambda 表达式可省略围绕参数列表的圆括号。
如果 Lambda 没有参数,或者有不止一个参数,或者显式指定了类型的单个参数,那么就必须将参数列表放到圆括号中。
(P350)
空参数列表要求圆括号。
语句 Lambda 的语法比完整的方法声明简单得多,可以不指定方法名、可访问性和返回类型,有时甚至可以不指定参数类型。
表达式 Lambda 只要返回的表达式,完全没有语句块。
(P351)
不能对一个匿名方法使用 typeof() 操作符。
只有在将匿名方法转换成一个特定类型后才能调用 GetType() 。
C# 2.0 不支持 Lambda 表达式,而是使用称为匿名方法的语法。
匿名方法很像语句 Lambda ,但缺少许多使 Lambda 变得简洁的特性。
匿名方法必须显式指定每个参数的类型,而且必须有一个语句块。参数列表和代码块之间不使用 Lambda 操作符 (=>) ,而是在参数列表前面添加关键字 delegate ,以强调匿名方法必须转换成一个委托类型。
[规范]
- 避免在新代码中使用匿名方法语法,应该优先使用更简洁的 Lambda 表达式语法;
有一个小特性是匿名方法支持而 Lambda 表达式不支持的,匿名方法在某些情况下可以彻底省略参数列表。
和 Lambda 表达式不同,匿名方法允许彻底省略参数列表,前提是主体中不使用任何参数,而且委托类型只要求 “值” 参数 (也就是说,不要求将参数标记为 out 或 ref) 。
(P353)
为了减少自定义委托类型的必要, .NET 3.5 “运行时” 库 (对应 C# 3.0) 包含了一组通用的委托,其中大多数都是泛型。
System.Func 系列委托代表有返回值的方法,而 System.Action 系列委托代表返回 void 的方法。
(P354)
Func 委托的最后一个类型参数总是委托的返回类型。其他类型参数依次对应于委托参数的类型。
在许多情况下, .NET Framework 3.5 添加的 Func 委托都能完全避免定义自己的委托类型。然而,如果要想显著增强代码的可读性,还是应该声明自己的委托类型。
[规范]
- 考虑定义自己的委托类型对于可读性的提升是否比使用预定义泛型委托类型所带来的便利性来得重要;
(P355)
实现泛型委托类型的引用转换,这是 C# 4.0 添加协变和逆变转换的关键原因之一。 (另一个原因是提供 IEnumerable<out T> 的协变性支持) 。
(P359)
[规范]
- 避免在匿名函数中捕捉循环变量;
(P360)
转换成表达式树的 Lambda 表达式对象代表的是对 Lambda 表达式进行描述的数据,而不是编译好的、用于实现匿名函数的代码。
表达式树并非只能转换成 SQL 语句;还可以构造一个表达式树计算程序 (evaluator) ,将表达式转换成任意查询语言。
【第13章】
(P366)
委托本身又是一个更大的模式 (pattern) 的基本单元,这个模式称为 publish-subscribe (发布-订阅) 。
一个委托值是可以引用一系列方法的,这些方法将顺序调用。这样的委托称为多播委托 (multicast delegate) 。利用多播委托,单一事件的通知就可以发布给多个订阅者。
(P368)
只需一个委托字段即可存储所有订阅者。
(P369)
只需执行一个调用,即可向多个订阅者发出通知 —— 这正是将委托更明确地称为 “多播委托” 的原因。
(P371)
[规范]
-
要在调用委托前检查它的值是不是 null 值;
- 从 C# 6.0 开始,要在调用 Invoke() 之前使用 null 条件操作符;
(P373)
无论 + 、 - 还是它们的复合赋值版本 (+= 和 -=) ,在内部都是使用静态方法 System.Delegate.Combine() 和 System.Delegate.Remove() 来实现的。
(P380)
event 关键字提供了必要的封装来防止任何外部类发布一个事件或者取消之前不是由其添加的订阅者。
(P381)
System.EventArgs 唯一重要的属性是 Empty ,它用于指出不存在事件数据。
(P382)
[规范]
-
要在调用委托前检查它的值不为 null (在 C# 6.0 中要使用 null 条件操作符) ;
-
不要为非静态事件的 sender 传递 null 值;
-
要为静态事件的 sender 传递 null 值;
-
不要为 eventArgs 参数传递 null 值;
-
要为事件使用 EventHandler<TEventArgs> 委托类型;
-
要为 TEventArgs 使用 System.EventArgs 类型或者它的派生类型;
- 考虑使用 System.EventArgs 的子类作为事件的实参类型 (TEventArgs) ,除非完全确定事件永远不需要携带任何数据;
(P382)
为事件定义类型的规范是使用 EventHandler<TEventArgs> 委托类型。
(P383)
通常应优先使用 EventHandler<TEventArgs> 。
在 C# 2.0 和之后使用事件的大多数情形中,都没必要声明自定义委托数据类型。
[规范]
- 要为事件处理程序使用 System.EventHandler<T> 而非手动创建新的委托类型,除非自定义类型的参数名能提供有意义的说明;
事件限制外部类只能通过 “+=” 操作符向发布者添加订阅方法,并用 “-=” 操作符取消订阅,除此之外的任何事情都不允许做。
【第14章】
(P387)
匿名类型是由编译器声明的数据类型。
(P388)
匿名类型纯粹是一个 C# 语言特性,不是 “运行时” 中的一种新类型。当编译器遇到匿名类型语法时,自动生成一个 CIL 类,其属性对应于在匿名类型声明中命名的值和数据类型。
虽然 C# 匿名类型没有名称,但它仍然是强类型的。
(P389)
[注意]
- 除非赋给变量的类型能一眼看出,否则应该只有在声明匿名类型 (具体类型只有在编译时才能确定) 时,才使用隐式类型的变量。不要不分青红皂白地使用隐式类型的变量;
两个匿名类型要在同一个程序集中做到类型兼容,属性名称、数据类型和属性顺序都必须完全匹配。
(P390)
编译器在生成匿名类型的代码时,重写了 ToString() 方法。
(P394)
根据定义, .NET 中的集合本质上是一个类,它最起码实现了 IEnumerbale<T> (或非泛型类型 IEnumerable) 。这个接口非常关键,因为要想支持对集合执行的遍历操作,最起码的要求就是实现 IEnumerable<T> 规定的方法。
(P396)
泛型集合的一个关键特征就是将一种特定类型的对象全都收集到一个集合中。
集合类不直接支持 IEnumerator<T> 和 IEnumerator 接口。
(P398)
IEnumerable<T> 上的每个方法都是一个标准查询操作符 (standard query operator) ,用于为所操作的集合提供查询功能。
(P401)
获取一个实参并返回一个布尔值的委托表达式称为 “谓词” 。
predicate 在 .NET Framework SDK 文档中翻译成 “谓词” 。
(P403)
使用 Select() 进行 “投射” ,这是非常强大的一个功能。
Where() 标准查询操作符在 “垂直” 方向上筛选集合 (减少集合中项的数量) 。
Select() 标准查询操作符在 “水平” 方向上减小集合的规模 (减少列的数量) 或者对数据进行彻底的转换。
综合运用 Where() 和 Select() ,可以获得原始集合的一个子集,从而满足当前算法的要求。
(P404)
.NET Framework 4 引入了标准查询操作符 AsParallel() ,它是静态类 System.Linq.ParallelEnumerable 的成员。
对数据项集合执行的另一个常见的操作是获取计数。为了支持这种类型的查询, LINQ 提供了 Count() 扩展方法。
(P405)
如果计数的目的只是为了看这个计数是否大于 0 ,那么首选的做法是使用 Any() 操作符。 Any() 只尝试遍历集合中的一个项,如果成功就返回 true ,而不会遍历整个序列。
[规范]
-
要在检查集合中是否有项的时候使用 System.Linq.Enumerable.Any() 而不是调用 Count() 方法;
- 要使用集合的 Count 属性 (如果有的话) ,而不是调用 System.Linq.Enumerable.Count() 方法;
使用 LINQ 时,要记住的一个重要概念就是推迟执行。
(P406)
通常,任何谓词都只应做一件事情 : 对一个条件进行求值。它不应该有任何 “副作用” 。
Lambda 表达式在声明时不执行。Lambda 表达式除非被调用,否则其中的代码不会执行。
(P409)
OrderBy() 获取一个 Lambda 表达式,该表达式标识了要据此进行排序的键。
OrderBy() 只会获取一个称为 keySelector 的参数来排序。要依据第 2 列来排序,需要使用一个不同的方法 ThenBy() 。类似地,更多的排序要使用更多的 ThenBy() 。
OrderBy() 返回的是一个 IOrderedEnumerable<T> 接口,而不是一个 IEnumerable<T> 。除此之外, IOrderedEnumerable<T> 是从 IEnumerable<T> 派生的,所以能为 OrderBy() 的返回值使用全部标准查询操作符 (包括 OrderBy()) 。但是,假如重复调用 OrderBy() ,会撤销上一个 OrderBy() 的工作,只有最后一个 OrderBy() 的 keySelector 才真正起作用。所以,注意不要在上一个 OrderBy() 调用的基础上再调用 OrderBy() 。
为了指定额外的排序条件,应该使用 ThenBy() 。虽然 ThenBy() 是一个扩展方法,但它扩展的不是 IEnumerable<T> ,而是 IOrderedEnumerable<T> 。
总之,要先使用 OrderBy() ,再执行零个或者多个 ThenBy() 调用来提供额外的排序 “列” 。
(P410)
[规范]
- 不要为 OrderBy() 的结果再次调用 OrderBy() 。附加的排序依据用 ThenBy() 来指定;
(P416)
GroupBy() 返回的是 IGrouping<TKey, TElement> 类型的数据项,该类型有一个属性指定了作为分组依据的键。
由于 IGrouping<TKey, TElement> 是从 IEnumerable<T> 派生的,所以可以用 foreach 语句枚举组中的项,或者将数据聚合成像计数这样的东西。
(P422)
LINQ Provider 的作用是将表达式分解成各个组成部分。一经分解,表达式就可以转换成另一种语言,可以序列化以便在远程执行,可以通过一个异步执行模式来注入。
(P423)
LINQ Provider 为一个标准集合 API 提供了一种 “解释” 机制。利用这种几乎没有任何限制的功能,可以注入与查询和集合有关的行为。
【第15章】
(P426)
查询表达式总是以 “from 子句” 开始,以 “select 子句” 或者 “group by 子句” 结束。这些子句分别用上下文关键字 from 、 select 或 group 来标识。 from 子句中的标识符 word 称为范围变量 (range variable) ,代表集合中的每一项,这就像是 foreach 循环中的循环变量代表集合中的每一项。
C# 查询表达式的顺序其实更接近各个操作在逻辑上的顺序。对查询进行求值时,首先指定集合 (from 子句) ,再筛选出想要的项 (where 子句) ,最后描述希望的结果 (select 子句) 。
查询表达式的结果是 IEnumerable<T> 或 IQueryable<T> 类型的集合。 T 的实际类型是从 select 或 group by 子句推导的。
(P427)
查询表达式的 select 子句可以将 from 子句的表达式所收集到的东西投射到完全不同的数据类型中。
(P428)
利用匿名类型,执行查询时可以不必获取全部数据,而是只在集合中存储和获取需要的数据。
(P431)
推迟执行通过委托和表达式树来实现。委托允许创建和操纵方法的引用,方法中含有可在以后调用的表达式。类似地,利用表达式树,可创建和操纵与表达式有关的信息,这种表达式能在以后检查和处理。
筛选条件 (filter criteria) 用谓词表示。所谓谓词,本质上就是返回布尔值的 Lambda 表达式。
(P432)
在查询表达式中对数据项进行排序的是 orderby 子句。
多个排序条件以逗号分隔。
(P433)
ascending 和 descending 是上下文关键字,分别指定以升序或降序排序。将排序顺序指定为升序或降序是可选的。如果没有指定排序指定,就默认为 ascending 。
let 子句引入了一个新的范围变量,它容纳的表达式值可以在查询表达式剩余的部分使用。可添加任意数量的 let 表达式,只需把它们每一个作为一个附加的子句,放在第一个 from 子句之后、最后一个 select / group by 子句之前,加入查询即可。
(P435)
由于含有 group by 子句的查询会产生一系列组合,所以对结果进行迭代的常用模式是创建嵌套的 foreach 循环。
(P436)
group 子句使查询返回由 IGrouping<TKey, TElement> 对象构成的集合。
into 子句引入的范围变量成为查询剩余部分的范围变量;之前的任何范围变量在逻辑上成为之前查询的一部分,不可在查询延续中使用。
(P437)
into 相当于一个管道操作符,它将第一个查询的结果 “管道传送” 给第二个查询。用这种方式可以链接起任意数量的查询。
(P439)
每个查询表达式都能 (而且必须能) 转换成方法调用,但不是每一系列的方法调用都有对应的查询表达式。
[规范]
-
要用查询表达式语法使查询更易读,尤其是涉及复杂的 from 、 let 、 join 或 group 子句时;
- 考虑在查询所涉及的操作没有查询表达式语法时,使用标准查询操作符 (方法调用形式) ;
【第16章】
(P440)
.NET Framework 中有许多非泛型集合类和接口,但它们主要是为了向后兼容。
泛型集合类不仅更快 (因为避免了装箱开销) ,还更加类型安全。所以,新代码应该总是使用泛型集合类。
(P442)
选择集合类来解决数据存储或者数据获取问题时,首先要考虑的两个接口就是 IList<T> 和 IDictionary<TKey, TValue> 。这两个接口决定了集合类型是侧重于通过位置索引来获取值,还是侧重于通过键来获取值。
List<T> 类具有与数组相似的属性。关键区别是随着元素数量的增多,这种类会自动扩展 (与之相反,数组的长度是固定的) 。
(P444)
如果元素类型实现了泛型 IComparable<T> 接口或者非泛型 IComparable 接口,排序算法默认就用它来决定排序顺序。
IComparable<T> 和 IComparer<T> 的区别很细微,但却很重要。前者说 “我知道如何将我自己和我的类型的另一个实例进行比较” ,后者说 “我知道如果比较给定类型的两个实例” 。
(P446)
[规范]
- 要确保自定义比较逻辑产生一致的 “全序” ;
集合类不要求集合中的所有元素都是唯一的。假如集合中有两个或者更多的元素相同,则 IndexOf() 返回的是第一个索引, LastIndexOf() 返回的是最后一个索引。
BinarySearch() 采用的是快得多的二分搜索算法,但它要求元素已经排好序。
BinarySearch() 方法的一个有用的功能是假如元素没有找到,会返回一个负整数。
(P447)
键可为任意数据类型,而非仅能为字符串或数值。
(P449)
Dictionary<TKey, TValue> 是作为 “散列表” 实现的;这种数据结构在根据键来查找值时速度非常快 —— 无论字典中存储了多少值。相反,检查特定值是否在字典集合中相当花时间,性能和搜索无序列表一样是 “线性” 的。
(P450)
[规范]
- 不要对集合元素的顺序进行任何假定。如果集合的说明文档没有指明它是按特定顺序枚举的,就不能保证以任何特定顺序生成元素;
(P452)
已排序集合类的元素是已经排好序的。对于 SortedDictionary<TKey, TValue> 元素是按照键排序的;对于 SortedList<T> ,元素则是按照值类排序的。
为了在不修改栈的前提下访问栈中的元素,要使用 Peek() 和 Contains() 方法。 Peek() 方法返回 Pop() 将获取的下一个元素。
(P454)
System.Collections.Generic 还支持链表集合,它允许正向和反向遍历。
数组、字典和列表都提供了索引器 (indexer) 以便根据键或索引来获取或者设置成员。
(P457)
[规范]
-
不要用 null 引用表示空集合;
- 考虑使用 Enumerable.Empty<T>() 方法生成空集合;
(P464)
[规范]
- 考虑在迭代较深的数据结构时使用非递归算法;
(P467)
yield 关键字是上下文关键字,不是保留的关键字。可以合法地声明名为 yield 的局部变量 (虽然这样做有时会令人混淆) 。
只有在返回 IEnumerator<T> 或者 IEnumerable<T> 类型 (或者它们的非泛型版本) 的成员中,才能使用 yield return 语句。
【第17章】
(P470)
反射是指对程序集中的元数据进行检查的过程。
通过 System.Type 的实例访问类型的元数据,该对象包含了对类型实例的成员进行枚举的方法。除此之外,可以调用被检查类型的特定对象的成员。
读取类型的元数据,关键在于获得 System.Type 的一个实例,它代表了目标类型实例。
object 包含一个 GetType() 成员,因此,所有类型都包含该方法。调用 GetType() 可获得与原始对象对应的 System.Type 实例。
(P471)
获得 Type 对象的另一个办法是使用 typeof 表达式。 typeof 在编译时绑定到特定的 Type 实例,并直接获取类型作为参数。
typeof 表达式在编译时解析,这样,类型比较 (也许是比较 GetType() 调用的返回类型) 就可以判断一个对象是否是指定类型。
反射并非仅可以用来获取元数据。下一步是获取元数据,并动态调用它引用的成员。
(P475)
MethodInfo 和 PropertyInfo 都是从 MemberInfo 继承的 (虽然并非直接) 。
(P481)
自定义特性很容易定义。特性是对象,定义特性就要定义类。
从 System.Attribute 派生之后,一个普通的类就成为了特性。
[规范]
- 要为自定义特性类添加 “Attribute” 后缀;
(P486)
[规范]
-
要为必需的参数提供只能取值的属性 (提供私有赋值函数) ;
-
要提供构造器参数来初始化与必需的参数对应的属性。每个参数的名称都应该和对应的属性同名 (大小写不同) ;
- 避免提供构造器参数来初始化与可选参数对应的属性 (因此,还要避免重载自定义属性构造器) ;
(P487)
[规范]
- 要对自定义特性应用 AttributeUsageAttribute 类;
(P492)
为了执行序列化,只需实例化一个 formatter ,然后为合适的流对象调用 Serialization() 。为了执行反序列化,只需调用 formatter 的 Deserialize() 方法,并指定包含了已序列化对象的流作为参数。然而,由于 Deserialize() 返回的是 object 类型,所以还需要把它转型为最初的类型。
不可序列化的字段应使用 System.NonSerializable 特性来修饰。它告诉序列化框架忽略这些字段。不应持久化的字段也应使用这个特性来修饰。
(P497)
dynamic 数据类型的几个特征 :
-
dynamic 是告诉编译器生成代码的指令;
-
任何类型都会转换成 dynamic ;
-
从 dynamic 到一个替代类型的成功转换要依赖于基础类型的支持;
-
dynamic 类型的基础类型在每次赋值时都可能改变;
-
验证基础类型上是否存在指定的签名要推迟到运行时才进行 —— 但至少会进行;
-
任何 dynamic 成员调用返回的都是一个 dynamic 对象;
-
如果指定的成员在运行时不存在, “运行时” 会引发 Microsoft.CSharp.RuntimeBinder.RuntimeBinderException ;
-
用 dynamic 实现的反射不支持扩展方法;
- 究其根本, dynamic 是一个 System.Object ;
【第18章】
(P505)
为了保证 UI 响应迅速,同时高效利用 CPU ,标准技术是编写多线程程序, “并行” 执行多个计算。
(P506)
进程是给定程序当前正在执行的实例;操作系统的一个基本功能就是管理进程。每个进程都包含一个或多个线程。进程由 System.Diagnostics 命名空间的 Process 类的实例表示。
C# 编程在语句和表达式的级别上根本就是在描述控制流。
线程由 System.Threading.Thread 类的实例表示。 Thread 类和操纵 Thread 的 API 都在 System.Threading 命名空间中。
任务和线程的区别是 : 任务代表需要执行的作业,而线程代表做这个作业的工作者。
任务由 Task 类的实例表示。
(P507)
[规范]
-
不要以为多线程总是会使代码更快;
- 要在通过多线程来更快解决处理器受限问题时,谨慎地衡量性能;
(P509)
[规范]
-
不要无根据地以为普通代码中原子性的操作在多线程代码中也是原子性的;
-
不要以为所有线程看到的都是一致的共享内存;
-
要确保同时拥有多个锁的代码总是以相同的顺序获取它们;
- 避免所有竞态条件,即程序行为不能受操作系统调度线程的方式的影响;
可以将线程想象成一名 “工作者” ,它独立地按照你的程序指令工作。
(P511)
调用 Thread.Start() 是告诉操作系统开始并发地执行一个新线程。
(P512)
不要将 Thread.Sleep() 作为高精度计时器使用,因为它不是。
[规范]
- 避免在生产代码中调用 Thread.Sleep() ;
(P513)
[规范]
- 避免在生产代码中终止线程,因为可能发生不可预测的结果,使程序不稳定;
(P514)
[规范]
-
要用线程池向处理器受限任务高效地分配处理器时间;
- 避免将池中的工作者线程分配给 I / O 受限或者长时间运行的任务,而是改为使用 TPL ;
(P515)
任务是对象,其中封装了以异步方式执行的工作。
委托是同步的,而任务是异步的。
任务将委托从同步执行模式转变成异步。
(P518)
C# 编程其实就是在延续的基础上构造延续,直到整个程序的控制流结束。
(P519)
异步任务使我们能将较小的任务合并成较大的任务,只需描述好异步延续就可以了。
可用 ContinueWith() “链接” 两个任务,这样当先驱任务完成后,第二个任务 (延续任务) 自动以异步方式开始。
(P520)
由于 ContinueWith() 方法也返回一个 Task ,所以可以作为另一个 Task 的先驱使用。以此类推,便可以建立起任意长度的连续任务链。
(P527)
[规范]
-
避免程序在任何线程上产生未处理异常;
-
考虑登记 “未处理异常” 事件处理程序以进行调试、记录和紧急关闭;
- 要取消未完成的任务而不要在程序关闭期间允许其运行;
(P529)
在 .NET 4.0 中,获取任务的一般方式是调用 Task.Factory.StratNew() 。
.NET 4.5 提供了更简单的调用方式 Task.Run() 。
Task.Factory.StratNew() 用于调用一个要求创建额外线程的 CPU 密集型方法。而在 .NET 4.5 中,应该默认使用 Task.Run() ,除非它满足不了一些特殊要求。
(P530)
[规范]
-
要告诉任务工厂新建的任务可能长时间运行,使其能恰当地管理它;
- 要尽量少用 TaskCreationOptions.LongRunning ;
(P536)
用 async 关键字修饰的方法必须返回 Task 、 Task<T> 或 void 。
(P538)
事实上, async 关键字最主要的作用有两方面。其一,向阅读代码的人清楚说明,它所修饰的方法将自动由编译器重写;其二,告诉编译器,方法中的上线问关键字 await 要被视为异步控制流,不能当成普通的标识符。
(P541)
async 方法的另一个重要特点是要求提供取消机制。
(P542)
通常, await 关键字后面的表达式是 Task 类型或 Task<T> 类型。
从语法的角度看,作用于 Task 类型的 await 相当于返回 void 的表达式。
(P549)
[规范]
- 要在很容易将一个计算分解成大量相互独立的、处理器受限的小计算,而且这些小计算能在任何线程上以任何顺序执行时,使用并行循环;
【第19章】
(P563)
为了同步多个线程,阻止它们同时执行特定的代码段,需要用监视器 (monitor) 阻止第二个线程进入受保护的代码段,直到第一个线程退出那个代码段。监视器功能由 System.Threading.Monitor 类提供。为了标识受保护代码段的开始和结束位置,需要分别调用静态方法 Monitor.Enter() 和 Monitor.Exit() 。
要记住的一个重点是,在 Monitor.Enter() 和 Monitor.Exit() 这两个调用之间,所有代码都要用一个 try / finally 块包围起来。
(P566)
同步是以牺牲性能为代价的。
无论是使用 lock 关键字,还是显式使用 Monitor 类,都必须小心地选择 lock 对象。
同步对象不能是值类型,这一点很重要。
(P567)
[规范]
-
避免锁定 this 、 typeof() 或者字符串;
- 要为同步目标声明 object 类型的一个单独的只读同步变量;
[规范]
- 避免使用 MethodImplAttribute 同步;
(P571)
lock 关键字 (通过底层的 Monitor 类) 生成的代码是可重入的。
[规范]
-
不要以不同的顺序请求对相同两个或更多同步目标的排他所有权;
-
要确保同时持有多个锁的代码总是以相同的顺序获得这些锁;
-
要将可变的静态数据封装到具有同步逻辑的公共 API 中;
- 避免对不大于本机 (指针大小) 整数的值的简单读写操作进行同步,这种操作本来就是原子性的;
System.Threading.Mutex 在概念上和 System.Threading.Monitor 类几乎完全一致 (没有 Pulse() 方法支持) ,只是 lock 关键字用的不是它,而且可以命名不同的 Mutex 来支持多个进程之间的同步。可用 Mutex 类同步对文件或者其他跨进程资源的访问。由于 Mutex 是一个跨进程资源,所以 .NET 2.0 开始允许通过一个 System.Security.AsscessControl.MutexSecurity 对象设置访问控制。
Mutex 类的一个用处是限制应用程序不能同时运行多个实例。
【第20章】
(P583)
extern 方法永远不包含任何主体,而且几乎总是静态方法。是由附加在方法声明之前的 DllImport 特性 (而不是方法主体) 指向实现。该特性至少需要定义了函数的 DLL 的名称。 “运行时” 根据方法名来判断函数名。也可以用 EntryPoint 命名参数来重写此默认行为,明确地提供一个函数名。
(P588)
[规范]
- 要围绕非托管方法创建公共托管包装器;这种非托管方法使用了托管代码约定,比如结构化的异常处理;
(P592)
[规范]
-
不要无谓地重复现有的、能执行非托管 API 功能的托管类;
-
要将外部方法声明为私有或内部;
-
要提供使用了托管约定的公共包装器方法,包括结构化的异常处理、为特殊值使用枚举等;
-
要为不必要的参数选择默认值来简化包装器方法;
-
要用 SetLastErrorAttribute 将使用 SetlastError 错误码的 API 转换成引发异常 Win32Exception 的方法;
-
要扩展 SafeHandle 或实现 IDisposable 并创建终结器来确保非托管资源被高效率地地清理;
-
要在非托管 API 需要函数指针的时候,使用和所需方法的签名匹配的委托类型;
- 要尽量使用 ref 参数而不是指针类型;
可将 unsafe 用作类型或者类型内部的特定成员的修饰符。
(P593)
[注意]
- 必须向编译器显式指明要支持不安全的代码;
(P594)
C# 总是把 * 和数据类型放在一块儿。
指针是一种全新的类型。有别于结构、枚举和类,指针的终极基类不是 System.Object ,甚至不能转换成 System.Object 。相反,它们能转换成 System.IntPtr (能转换成 System.Object) 。
(P596)
栈是一种宝贵的资源,耗尽栈空间会造成程序崩溃。
【第21章】
(P610)
[注意]
- 程序集是可以版本化和安装的最小单元。构成程序集的单独模块则不是最小单元;
(P611)
[注意]
- CLI 的一个强大功能是支持多种语言。这就允许使用多种语言来编写一个程序,并允许用一种语言写的代码访问用另一种语言写的库;
**
来源:51CTO
作者:GATTACA2011
链接:https://blog.51cto.com/tianjing/2472035