日积月累——《Effective C#》
《Effective C#》读书笔记
写在前面
之前加入了公司的一个读书群,群规要求必须每天分享自己的读书(或其他)学习成果,在此途中,把《Effective C#》中文版读了一遍。在此把关于该书的每日分享整理一下,其实也就是个体力活儿,也算是对自己的一个回顾,也希望看到的人能有一些启发。有不足之处,还望大家多多指正,共同学习,共同进步。
读书笔记
第1条:优先使用隐式类型的局部变量
-
优先使用隐式类型(
var
)声明局部变量,注意,是优先,不是总是。好处有:-
var
可以使开发者更多地关注在变量的命名上,而不用考虑变量的实际类型(编译器会自己推断,并选择最为合适的)。 - 好的变量命名可以提高可读性,合适的推导类型会提高效率(例如里氏替换,你能选得过编译器?为自己省事。)
-
var
并不总是合适,当涉及int
、float
、double
等数值类型时,请务必明确写出。
总之,在不会发生精度损失的前提下,请务必使用隐式类型声明。
第2条:考虑用readonly
代替const
- C#有两种常量,编译期(compile-time)常量,和运行期(runtime)常量。
- 运行期用
readonly
,编译期用const
。 -
const
性能优于readonly
。 -
const
用来声明那些必须在编译期(静态编译?)得以确定的值,如attribute
的参数、switch case
语句的标签、enum
的定义等,以及那些不会随版本而变化的值。除此之外的值则应该考虑声明成更加灵活的readonly
常量。 - 确实想把某个值在编译期固定下来就用
const
,否则就用readonly
,因为其更灵活,兼容性更好。举例子,程序集A引用程序集B,程序集B中两个属性分别使用readonly
与const
,若两个属性均发生更改,在不编译程序集A的情况下(只编译B,这种情况很常见),使用const
声明的属性在A中调用时,并未发生改变,这就出现了兼容性问题。所以,尽量使用readonly
代替const
。
第3条:优先考虑is
或as
运算符,尽量少用强制类型转换
首先,在使用面向对象语言来进行编程的时候,应尽量避免类型转换操作。也许有一些场合必须使用类型转换(反思,真的是必须吗?可否绕过去?),此时应该使用is
及as
运算符来更清晰地表达代码的意图(可读性高)。需要警惕自动类型转换(coercing type)操作,因为它们的规则各不相同,显式使用is
及as
总能正确地表达。
如果你实在不想纠结原因,记住结论也行。
第4条:用内插字符串取代string.Format()
所谓内插字符串指的就是使用$来拼接字符串,这是C#(6.0)语法的最新特性。首先很明显的优点是可读性的提高。string.Format()
方法的序号与变量的一一对应问题确实会令人感到困惑,顺序不对就会出错。
内插字符的另一个强大之处在于,可以直接使用表达式(方法),你可以理解为这是一个语法糖,但是可读性一直是面向对象编程语言自诞生以来一直追寻的目标。例如:
Console.WriteLine($"The customer's name is {c?.Name ?? "Name is missing"}");
这段代码对变量c与Name均做了非空判断。(个人理解,是否存在错误?)
你甚至可以在内插字符串中使用LINQ语句,这里不再举例。
第5条:用FormattableString
取代专门为特定区域而写的字符串
单凭字符串内插功能(第4条)还不足以是应用程序能够应对世界上所有的语言,或是能够专门为某种语言做出特殊的处理。如果程序只是针对当前区域而生成文本,那么直接使用内插字符串就够了,这样反而可以避免多余的操作。反之,如果需要针对特定的地区及语言来生成字符串,那么就必须根据内插字符串的解读结果来创建FormattableString
,并将其转化成适用于该地区及该语言的字符串。
第6条:不要用表示符号名称的硬字符串来调用API
C# 6.0版本关键字nameof()
。这个关键字可以根据变量来获取包含其名称的字符串,使开发者不用把变量名直接写成字面量。实现INotifyPropertyChanged
接口时,经常要用到nameof()
。
少废话,看代码。
Public String Name
{
get { return name; }
set
{
if (value != name)
{
name = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
}
}
}
很明显,使用nameof
运算符的好处是,如果符号改名了,那么使用nameof
来获取符号名称的地方也会获取到修改之后的新名字。这一步静态编译就能检查出来错误,使开发者可以更专心地解决更为困难的问题。
如果不这样做,那么有些错误就只能通过自动化测试及人工检查才能寻找出来。
第7条:用委托表示回调
- 回调就是这样一种由服务端向客户端提供异步反馈的机制,它可能会涉及多线程(multithreading),也有可能只是给同步更新提供入口。C#语言用委托来表示回调。
- 通过委托,可以定义类型安全的回调。最常用到委托的地方是事件处理。
- 委托是一种对象,其中含有指向方法的引用,这个方法既可以是静态方法,又可以是实例方法。
- 常见委托形式:
Predicate<>
、Action<>
、Func<>
,Func<T, bool>
与Predicate<T>
是同一个意思。 - 由于历史的原因,所有的委托均为多播委托(multicast delegate)。
- 总之,如果要在程序运行的时候执行回调,那么最好的办法就是使用委托,因为客户端只需编写简单的代码,即可实现回调。委托的目标可以在运行的时候指定,并且能够指定多个目标。在.NET程序里面,需要回调客户端的地方应该考虑用委托来做。
第8条:用null条件运算符调用事件处理程序
- null条件运算符为:?.。
- 比较以下三种代码的写法:
public void RaiseUpdates()
{
counter++;
Updated(this, counter);
}
public void RaiseUpdates()
{
counter++;
if(Update != null)
Updated(this, counter);
}
public void RaiseUpdates()
{
counter++;
var handler = Updated;
if(handler != null)
Updated(this, counter);
}
//使用null条件运算符
public void RaiseUpdates()
{
counter++;
Updated?.Invoke(this, counter);
}
分析以上代码:
第一种存在空引用异常;
第二种当多线程时,可能出现空引用异常;
第三种很好,不会引发异常。原理是因为handler对Updated进行了浅拷贝(shallow copy),防止多线程空引用问题,但是这种写法新手极难看懂。
第四种,正统写法,最佳写法。首先null条件运算符左侧内容只会计算一次,其次为类型安全的Invoke()方法,而后这段代码可以安全地运行在多线程环境下。
请丢掉旧习惯,使用新方法。
第9条:尽量避免装箱与取消装箱这两种操作
- 装箱的过程是把值类型放在非类型化的引用对象中,使得那些需要使用引用的地方也能够使用值类型。取消装箱则是把已经装箱的那个值拷贝一份出来。如果要在只接受
System.Object
类型或接口的地方使用值类型,那就必然涉及装箱及取消装箱。但这两项操作都很影响性能。 - 装箱操作会把值类型转换成引用类型。
- 把值类型的值放入集合、用值类型的值做参数来调用参数类型为
System.Object
的方法以及将这些值转为System.Object
等。这些做法都应该尽量避免
第10条:只有在应对新版基类与现有子类之间的冲突时才应该使用new
修饰符
- 重新定义非虚方法会产生令人困惑的行为。
- 非虚方法是静态绑定的。虚方法是动态绑定的。
- 设计是需要花时间去思考的,什么时候应该用虚方法什么时候不应该用,如何恰到好处?
- 本书第40页第一个示例代码印刷错误,多了一个关键字
new
。 - 笔者认为
new
修饰符需要慎重地使用,那么我的结论就是不用,尽最大的可能去绕过new
修饰符的使用场景。貌似更多地情况发生在基类方法命名更新时,与子类发生了冲突。(是基类先动的手,结果却把锅甩给了子类。) - 在同一个对象上面,通过不同类型的引用来调用同一个方法会表现出不同的的行为。(这不就是虚方法吗?译文表达的貌似不是很确切。)
第11条:理解并善用.NET的资源管理机制
高效程序,需要明白内存处理与其他重要资源。.NET的内存管理与垃圾回收必须理解。
垃圾回收(GC)来帮助你控制托管内存,无需担心内存泄漏、迷途指针(dangling pointer)、未初始化的指针以及其他很多内存管理问题。
但是,为了防止资源泄露,非内存型资源(nonmemory resource)必须由开发者释放,于是会促使其创建finalizer来完成该工作。然而finalizer会严重影响程序的性能。
应考虑使用IDisposable
接口,以便在不给GC增加负担的前提下把这些资源清理干净。
第12条:声明字段时,尽量直接为其设定初始值
- 类的构造函数不止一个,构造变多了以后,开发者就可能会忘记给某些成员变量设定初始值,为了避免这个问题,最好在声明的时候直接初始化,而不要等到构造中赋值。(我之前一直觉得构造中赋值做法更正统一些,现在看来并不对。)注意,这里指的是初始值。
- 成员变量的初始化语句可以方便地取代那些本来需要放在构造器中的代码,还有一个好处是,这些语句的机器码会在本类的构造函数之前,执行的时机比基类更早。
有三种情况不应该写初始化语句:
- 对象初始化为0或null(多余,降低性能);
- 如果属性初始值在不同的构造器中不一样,请不要写初始化语句,同样多余,降低性能;
- 可能存在异常的初始化语句请务必写在构造函数中,因为初始化语句不能包含在try块中,应在构造中将异常处理完毕。
第13条:用适当的方式初始化类中的静态成员
在创建某个类的实例对象之前,应该先把静态的成员变量初始化好。你有两种方式可以选择,一是静态初始化语句,而是静态构造函数。
静态构造函数是特殊的函数,会在初次访问该类的其他方法、变量或属性之前执行,你可以做这么三件事,初始化静态变量、实现单例模式、其他必要的工作。
如果静态字段的初始化工作比较复杂或是开销比较大,那么可以考虑Lazy<T>
的机制,将初始化工作推迟到首次访问该字段的时候再去执行。
静态字段初始化的原则与之前说过的成员字段初始化原则基本一致。具体参见第12条。
要想为类中的静态成员设定初始值,最干净、最清晰的办法就是使用静态初始化语句与静态构造函数。这是C#语言对比其他语言的优点。
第14条:尽量删减重复的初始化逻辑
- 不要在不同的构造器中复制粘贴同样的代码!
- 注意使用
this
关键字,通过链式调用构造函数,将重复的代码写到一个构造器中,再通过其他的构造器调用。 - 采用默认参数机制来编写构造函数是比较好的做法,但是有些API会使用反射(reflection)来创建对象,它们需要依赖于无参的构造函数,这种函数与那种所有参数都具备默认值的构造函数并不是一回事,因此可能需要单独提供。
- 构建某个类型的首个实例时系统所执行的操作(注意顺序):
- 把存放静态变量的空间清零;
- 执行静态变量的初始化语句;
- 执行基类的静态构造函数;
- 执行本类的静态构造函数;
- 把存放实例变量的空间清零;
- 执行实例变量的初始化语句;
- 适当地执行基类的实例构造函数;
- 执行本类的实例构造函数。
- 在强调一遍,如果初始化的逻辑较为复杂,则考虑通过构造函数来实现,此时注意要把逻辑放在其中一个构造函数中,并令其他构造函数直接或间接地调用该函数,以尽量减少重复代码。(消除重复)
第15条:不要创建无谓的对象
- 垃圾回收器可以帮你把内存管理好,并高效地移除那些用不到的对象,但这并不是在鼓励你毫无节制地创建对象,因为创建并摧毁一个基于堆(heap-based)的对象无论如何都要比根本不生成这个对象耗费更多的处理器时间。在方法中创建很多的局部引用对象可能会大幅降低程序的性能。
- 惰性求值算法(lazy evaluation algorithm),我觉得现在编译器就能自己优化这件事了,但是代码该写还要写。
- 两种减少对象创建的方式:1. 局部变量提升为成员变量;2. 依赖注入(dependency injection)的办法创建并复用那些经常使用到的对象。
- 此外还有一种针对不可变类型(immutable type)的技巧,例如
String
类,每次相加都会删除之前那个,重新创建,看上去好像是在不断边长的过程。注意对比StringBuilder
类,思考设计思路,如果要设计不可变的类型,需要提供相应的builder(构建器),令开发者能够以分阶段的形式来指定不可变的对象最终所应具备的取值。 - 我们应该思考的是应该如何尽量少地去创建对象,以任何方式。并不止局限于书中的几条。
第16条:绝对不要在构造函数里面调用虚函数
- 在构建对象的过程中调用虚函数会令程序表现出奇怪的行为,因为该对象此时并没有完全构造好,而且虚函数的效果与开发者所想的也未必相同。
- 在基类的构造函数里面调用虚函数会令代码严重依赖于派生类的实现细节,而这些细节是无法控制的,因此这种做法很容易出问题。
- VS提供了FxCop与Static Code Analyzer可以识别出该潜在的问题。回头试一试两种插件的使用方法。
第17条:实现标准的dispose模式
- 如果对象包含非托管资源,那么一定要正确地加以清理。怎样编写自己的资源管理代码?
- 在编写finalizer时,一定要仔细检查代码,而且最好能把Dispose方法的代码也一起检查一遍。如果发现这些代码除了释放资源之外还执行了其他的操作,那就要再考虑考虑了。这些操作在以后有可能令程序出现bug,最好是现在就把它们从方法中删除,使得finalizer与Dispose()方法只用来释放资源。
- 对于运行在托管环境中的程序来说,开发者并不需要给自己所创建的每一个类型都编写finalizer。只有当其中包含非托管资源或是带有实现了
IDisposable
接口的成员时,才需要添加finalizer。注意:在只需要实现IDisposable
接口但不需要finalizer的场合下,还是应该把整套模式实现出来。请把标准的dispose框架写好,否则子类就无法轻松实现标准的dispose方案。 - finalizer指的就是析构函数。
第三章:合理地运用泛型
- 并非只有集合才能用到泛型,泛型还有许多用法,比如编写接口,事件处理程序以及通用的算法等。
- 不要将泛型与C++的模板方法混在一起,但是这样有助于你的理解,理解什么是泛型。
- 定义泛型类型可能会增加程序的开销,但也有可能给程序带来好处。用泛型有时候令程序更加简洁,有时候令其更加臃肿。具体要看你使用了什么样的类型参数(引用、值)以及创建出了多少个封闭的泛型类型。
- 泛型类的定义(generic class definition)属于完全编译的MSIL类型,其代码对于任何一种可供使用的类型参数来说都必须完全有效。这样的定义叫作泛型类型定义(generic type definition)。对于泛型类型来说,若所有的类型参数都已经指明,那么这种泛型类型称为封闭式泛型类型(closed generic type),反之,仅指出了某些参数,则称为开放式泛型结构(open generic type)。
- 与真正的类型相比,IL形式的泛型只是定义好了其中的某一部分而已。必须把里面的占位符替换成具体的内容才能令其成为完备的泛型类型(completed generic type)。
- 如果泛型参数是引用类型,那么无论是哪一种引用类型,JIT都会编译出同样的机器码,反之,在封闭式的泛型类型中,出现以值类型充当的泛型参数,JIT则会用不同的代码来应对。这种做法使得程序在运行时占用更多的内存,好处是避开了值类型的装箱与拆箱操作。
第18条:只定义刚好够用的约束条件
- 泛型约束。太宽或太严都不合适。例如你可以规定类型参数必须是值类型(struct)或必须是引用类型(class),还可以规定它必须实现某些接口或是必须继承自某个基类(这当然意味着它必须首先是个类才行)。
- 泛型约束需要依据使用场景来权衡,不能背离了初衷。
- 新学习了运算符
default()
。 - 还有一种约束条件需要谨慎地使用,那就是new约束,有的时候可以去掉这条约束,并将代码中的
new()
改为default()
。后者是C#的运算符,用来针对某个类型产生默认值,值类型则为0,引用类型则为null。对于引用类型来说,new()
与default()
有很大的区别。 - 要以谨慎的态度来施加new、struct及class等约束。这样的约束会限定对象的构建方式。
第19条:通过运行期类型检查实现特定的泛型算法
-
只需要指定新的类型参数,就可以复用泛型类,这样做会实例化出一个功能相似的新类型(废话)。这当然是好的,但是你仔细品,问题在于使用了泛型虽然方便了,但是功能却高度雷同,那么有没有办法损有余而补不足呢?就是在使用泛型的前提下,同时把该类型特有的算法(方法)使用上。看以下示例代码:
public ReverseEnumerable(IEnumerable<T> sequence) { sourceSequence = sequence; originalSequence = sequence as IList<T>; }
这里
as
的使用非常地巧妙! 开发者既可以对泛型参数尽量少施加一些硬性的限制,又能够在其所表示的类型具备丰富的功能时提供更好的实现方式。为了达到这种效果,你需要在泛型类的复用程度与算法面对特定类型时所表现出的效率之间做出权衡。
今天是世界读书日,全称世界图书与版权日(World Book Day),由联合国教科文组织选定。4月23日是西班牙文豪塞万提斯的忌日,也是加泰罗尼亚地区大众节日“圣乔治节”,是莎士比亚出生和去世的日子,还是许多作家(纳博科夫·美、莫里斯·德鲁昂·法、拉克斯内斯·冰岛·诺贝尔文学奖得主等)的生日。
第20条:通过IComparable<T>
及IComparer<T>
定义顺序关系
- 前者用来规定某类型的各对象之间所具备的自然顺序(natural order),后者用来表示另一种排序机制可以由需要提供排序功能的类型来实现。
-
IComparable
接口只有一个方法,就是CompareTo()
,该方法遵循长久以来所形成的惯例:若本对象小于另一个受测对象,则返回小于0的值,以此类推。 - 非泛型的
IComparable
有许多缺点,为何还要实现它呢?一是为了向后兼容,二是满足那些确实需要这些方法的人。
第21条:创建泛型类时,总是应该给实现了IDisposable
的类型参数提供支持
- 为泛型类指定约束条件会对开发者自身及该类的用户产生两方面的影响。第一,会把程序在运行时可能发生的错误提前暴露于编译期。第二,相当于明确告诉你该类的用户在通过泛型类来创建具体的类型时所提供的类型参数必须满足一定的条件。
- 泛型类本身也可能需要以惰性初始化的形式根据类型参数去创建实例,并实现
IDisposable
接口,这需要多写一些代码,如果想创建出来实用的泛型类,必须这么做才行。
第22条:考虑支持泛型协变与逆变
- 变体(type variance)机制,尤其是协变(covariance)与逆变(contravariance)确定了某类型的值在什么样的情况下可以转换成其他类型的值。在定义泛型类与委托的时候,应该尽量令其支持协变与逆变。这样做可以使API运用得更为广泛,也更加安全。如果某个类型的值无法当成另外一种类型的值来使用,那么称为不变(invariant)。
- C#语言允许开发者在泛型接口与委托中运用in与out修饰符,以表达他们与类型参数之间的逆变与协变关系。你在定义接口与委托的时候,应该充分地运用这两个修饰符,使得编译器能够根据这些定义把与变体有关的错误找出来。
第23条:用委托要求类型参数必须提供某种方法
- C#为开发者所提供的约束似乎比较有限,你只能要求某个泛型参数所表示的类型必须继承自某个超类、实现某个接口、必须是引用类型、必须是值类型或者必须具备无参数的构造函数。此外还有很多要求无法通过这些约束来表达。比如你可能要求泛型参数所表示的类型必须提供某些静态方法,或者要求该类型必须具备某种其他形式的构造函数。
- 你可能会要求用户提供的类型必须支持某种运算符、必须拥有某个静态方法、必须与某种形式的委托相符或是必须能够以某种方式来构造,这些要求其实都可以用委托来表示。也就是说,你可以定义相应的委托类型,并要求用户在使用泛型类的时候必须提供这样的委托对象。
- 总之,如果你在设计泛型的时候需要对用户所提供的的类型提出要求,但这种要求又不便以C#内置的约束条件来表达,那么就应该考虑通过其他办法来保证这一点,而不能放弃这项要求。
第24条:如果有泛型方法,就不要再创建针对基类或接口的重载版本
- 一般来说,在已经有了泛型版本的前提之下,即便想要给某个类及其子类提供特殊的支持,也不应该轻易去创建专门针对该类的重载版本。这条原则同样适用于接口。但是数字类型(numeric type)不会有这个问题,因为整数与浮点数等数字类型之间是没有继承关系的。
- 如果你想专门针对某个类型创建与已有的泛型方法相互重载的方法,那么必须同时为该类型的所有子类型也分别创建对应的方法(否则,在以子类型的对象为参数来调用方法时,编译器会把泛型方法视为最佳方法,而不去调用你针对基类所创建的那个版本)。
第25条:如果不需要把类型参数所表示的对象设为实例字段,那么应该优先考虑创建泛型方法,而不是泛型类
用户可能会给出很多套符合约束的泛型参数,而C#编译器则必须针对每一套泛型参数都生成一份完整的IL码,用以表示与这套参数相对应的泛型类。
在两种情况下,必须把类写成泛型类:第一种情况,该类需要将某个值用作其内部状态,而该值的类型必须以泛型来表达(例如集合类);第二种情况,该类需要实现泛型版的接口。除此之外的情况,都应该考虑使用包含泛型方法的非泛型类来实现。
好处:调用简单。修改灵活,类似于参数的重载,在这里是类型参数的重载。
第26条:实现泛型接口的同时,还应该实现非泛型接口
- 这条建议适用于三项内容:1. 要编写的类以及这些类所支持的接口;2. public属性;3. 打算序列化(serialize)的那些元素。
- 在绝大多数情况下,如果想给旧版接口提供支持,那么只需要在类里面添加签名正确的方法就可以了。
- 要考虑实现与泛型接口相对应的旧式接口,在实现这些接口时,应该明确加以限定,以防用户在本打算使用新版接口时无意间调用了旧版接口。
- 使用VS及其他一些工具时,可以通过向导(wizard)功能创建针对这些接口方法的样板代码。
第27条:只把必备的契约定义在接口中,把其他功能留给扩展方法其实现
- 这个方法对应了软件开发原则,对修改关闭,对新增(扩展)开放。
- 接口的功能需要尽可能的单一。
- 定义接口的时候,只把必备的功能列出来就行了,而其他一些功能则可以在别的类里面以扩展方法的形式去编写,那些方法能够借助原接口所定义的基本功能来完成自身的任务。
- 只把那些必备的功能定义到接口里面,以满足应用程序的需求,而不要在接口里面定义附加功能,因为那些功能可以留给扩展方法去实现,对使用者来讲,就产生了好似接口有该功能的错觉,其实是扩展方法的功劳。
- 注意,如果本类中实现了和扩展方法同名的方法,怎么办?调用有条件与先后顺序,太复杂,我们只讲怎么做。首先保证行为一致,类似于你通过同名方法做了重构(算法优化等等),这样可以保证程序不出大问题。
第28条:考虑通过扩展方法增强已构造类型的功能
- 我之前一直是这么做的,只是我没想到可以为接口添加扩展方法(用过,但未留意),有了这样一个思路,简直是无敌了。
- 同样,再为接口添加扩展方法的同时,可以直接调用接口所具有的方法,比如可以把一堆复杂的操作合并出来,从而只暴露出一个扩展方法,双倍无敌。
- 我们应该想一想这些类型目前已有的方法以及将来还有可能需要提供的方法里面有哪些可以改用扩展方法来实现。若能将这些方法实现成针对某个泛型类型或泛型接口的扩展方法,则会令那个以特定参数而构造的泛型类型或接口具备丰富的功能。此外,这样做还可以最大限度地将数据的储存模型(storage model)与使用方式相解耦。
第29条:优先考虑提供迭代器方法,而不要返回集合
- 促使C#语言升级到3.0版的一项动力就是LINQ。C#语言之所以会引入并实现这些新功能。是因为业界希望该语言能够支持延迟查询(deferred query)机制。
- 迭代器方法是一种采用
yield return
语法来编写的方法,它会等到调用方请求获取某个元素的时候再去生成序列中的这个元素。 - 对于较小的序列(集合)来说,这样做的优势并不明显,但对于较大的序列就不一样了。
var allNumbers = Enumerable.Range(0, int.MaxValue);
该方法所生成的对象可以在调用方真正用到某个整数时再去创建该数,这使得调用方不用把那么多数字全都放到某个庞大的集合中,除非确实需要。
- 这种按需生成(generate-as-needed)的策略还揭示出迭代器方法的另一个重要特点,那就是序列中的元素由该方法创建出来的那个对象生成。只有当调用方真正用到序列中的某个元素时程序才会通过那个对象创建该元素。这使得程序在调用生成器方法(generator method)时只需要执行少量的代码。
- 有一个缺点是,有些异常是需要调用时(使用函数的返回值)才会触发,而无法在传入错误参数的时候就抛出异常。
- 问:有没有哪种场合不需要(不适宜)用迭代器方法来生成序列?没有。这个是调用方需要考虑的问题,我们作为API的设计者,应当尽可能地使其灵活。具体怎么用更好,调用者会考虑,但我们首先得支持。
- 请开始提供迭代器方法。
第30条:优先考虑通过查询语句来编写代码,而不要使用循环语句
- 首先你得知道什么是查询语句。(区别于查询方法)
- 比较两段代码:
private static IEnumerable<Tuple<int, int>> ProduceIndices(){ for(var x = 0; x < 100; x++) for(var y = 0; y < 100; y++) yield return Tuple.Create(x, y); }
private static IEnumerable<Tuple<int, int>> QueryIndices(){ return from x in Enumerable.Range(0, 100) from y in Enumerable.Range(0, 100) select Tuple.Creat(x, y); }
第一种为普通的循环结构(区别于查询方法),第二种为查询语句。当你加一些过滤条件(需求)时,两者的变化更是千差万别。查询语句将无比简洁。
- 命令式的模型很容易过分强调怎样去实现操作,而令阅读代码的人忽视这些操作本身是打算做什么的。
- 查询语句要比循环结构好,前者可以创建出更容易拼接的API,用查询命令来编写算法促使开发者把该算法实现成很多小的代码块,小的代码块连续拼接实现大的操作。而,循环结构不能拼接,你必须把中间结构的结果保存起来,或者分别针对小操作创建对应的方法。
- 再说一遍,命令式的写法必须创建储存空间来保存中间结果。
- 查询语句天生支持并行化(parallel),当你觉得算法不够快时,可以使用
.AsParallel()
来执行查询。 - 总结:编写循环结构时,总是应该想想能不能改用查询语句来实现同样的功能,如果实在不行,再想想能不能改用查询方法,每一种命令式的循环结构几乎都可以通过查询式的写法更为清晰地表达出来。
写代码之前要多动脑子,不要拿来就用,要对自己的每一行代码负责,要本着写了就不再改的目标去下笔(当然这是不可能的,不然不会有《重构》这本书),这是我的理解。但归根结底代码的核心还是要实现需求,但我更希望在此之前,有这样一个原则,如果实在保不住原则,还是要以功能为首位,但总应该有这么一个过程。
第31条:把针对序列的API设计得更加易于拼接
- 序列的意思就是集合。
- 把通用的
IEnumerable<T>
或针对某种类型的IEnumerable<T>
设计成方法的输入及输出参数是一种比较少见的思路,很多人不愿意这样做,但这种思路确实能带来很多好处。 - 迭代器方法会等到调用方真正用到某个元素时才去执行相应的代码,而不会提前。这种延迟执行(deferred execution)机制可以降低算法所需的存储空间。并使算法的各个部分之间能够更为灵活地拼接起来。
第31条:把针对序列的API设计得更加易于拼接
-
yield return
语句,用这种语句写出来的方法,其输入值与输出值都是迭代器。这种方法属于可以从上次执行到的位置继续往下执行的方法(continuable method)。 - 改写为连续方法有两个很大的好处,首先,它推迟了每个元素求值的时机,更为重要的是,这种延迟机制使得开发者能够把多个这样的操作拼接起来,从而更灵活地复用它们,反之,若使用
foreach
循环的命令式方法来达成此效果则较为困难。 - 如果能够把复杂的算法拆解成多个步骤,并把每个步骤都表示成这种小型的迭代器方法,那么就可以将这些方法拼成一条管道,使得程序只需把源序列处理一遍(无需临时保存)即可对其中的元素执行许多种小的变换。
第32条:将迭代逻辑与操作、谓词及函数解耦
- 迭代器方法的代码通常由两部分组成,一部分是用来迭代该序列,另一部分用来对元素执行操作。
- 写一个类似过滤器的委托,匿名的委托有两种习惯的用法,一种表示函数,另一种表示操作。表示函数是有一个特殊的用法,充当谓词(predicate)。表示操作的委托则称为操作委托(action delegate),用来在集合中的元素上执行某项操作。
- 好处是可以把迭代序列时所用的逻辑与处理序列中的元素时所用的逻辑分开。我们在实际的项目中大量使用了这种用法。
第33条:等真正用到序列中的元素时再去生成
- 其实还是
yield return
的用法。
static IEnumerable<int> CreateSequence(int numOfElements, int startAt, int stepBy)
{
for (int i = 0; i < numOfElements; i++)
{
yield return startAt + i * stepBy;
}
}
- 可以提前终止序列的遍历过程,
TakeWhile
中的条件得不到满足,那么程序就不会再来获取元素了。生成的过程会立刻终止,极大地改善了程序的性能。按需生成序列中的元素,而不需要全部生成再从里面去取。创建元素的开销越大,这种写法的效率越明显。
第34条:考虑通过函数参数来放松耦合关系
- 函数参数(function parameter),其实就是委托。
- 需要注意的是,如果使用委托或其他一些通信机制来放松耦合关系,那么编译器可能就不会执行某些检查工作了,需要自己来设法做检查。
- 继承是耦合度最高的编程方式。其次是接口。最后是函数方法(委托)。
- 具体设计时需要定义接口还是委托,需要根据实际场景语境来判断。有些接口更合适(
IComparable<T>
),有些委托(RemoveAll(IPredicate<T>)
)更合适。 - 关注
Zip()
方法。
第34条:考虑通过函数参数来放松耦合关系
- 通过函数的参数确实可以把算法与其操作的具体数据有效地分隔开,但在放松耦合关系的同时,你要多做一些工作,比如异常处理等。
- 在设计组件时,首先应该考虑能否把本组件与客户代码之间的沟通方式约定成接口。如果有一些默认的实现代码需要编写,那么放到抽象基类中,使得调用方无需重新编写这些代码。如果采用委托,那么用起来会更加地灵活,你需要编写更多的代码才能确保这种灵活的设计能够正常运作。
第35条:绝对不要重载扩展方法
-
复习一下,针对接口或类型创建扩展方法有三个好处:
- 为接口实现默认的行为;
- 针对封闭的泛型类型实现某些逻辑;
- 能够创建出易于拼接的接口方法。
可以把接口定义得尽量简单些,然后编写扩展方法,利用接口提供的少数几个方法来组合出更多的常见操作。
作者的意思是,不要通过相同名字不同命名空间的方式来重载扩展方法(我并不认为这是重载),这很明显是对扩展方法的误用。
如果方法本身是类型的一部分,这样才应该去定义扩展方法。(注意,在面向对象的设计中,实际的使用场景很重要,其实就是真实世界的投影。语言逻辑组织不清楚,代码就一定写不清楚。)
扩展方法是可以重载的,去给它们不同的名字或者不同的参数列表。通过不同命名空间的做法,愚蠢至极。
第36条:理解查询表达式与方法调用之间的映射关系
- 完整的查询表达式模式(query expression pattern)包含11个方法。
- .NET基础类库为该模式提供了两套参考实现(reference implementation)。一个是位于
Syetem.Linq.Enumerable
中,是IEnumerable<T>
的扩展方法;一个是位于Syetem.Linq.Queryable
中,提供针对IQueryable<T>
的查询程序。
第37条:尽量采用惰性求值的方式来查询,而不要及早求值
定义查询操作时,程序并不会立刻把数据获取过来并填充到序列中,因为你定义的实际上只是一套执行步骤而已,等真正需要遍历查询结果时,才会得以执行。也就是说,对于查询结果做迭代的时候,程序总是会从头开始执行这套步骤,这样做通常是合理的。每迭代一遍都产生一套新的结果,这叫作惰性求值(lazy evaluation),反之,如果像编写普通的代码那样直接查询某一套变量的取值并将其立刻记录下来,那么就称为及早求值(eager evaluation)。
第37条:尽量采用惰性求值的方式来查询,而不要及早求值
- 惰性求值其实还是
yield return
的用法,感觉作者在水字数。 - 惰性求值的优点在于只求一遍。
- LINQ查询操作与那些代码不同,它会把代码当成数据来看,用作参数的lambda表达式要等到以后再去调用(而不是立刻就得以执行)。此外,如果provider使用的是表达式树(expression tree)而不是委托,那么稍后可能还会有新的表达式融入这棵树中。
- 你需要了解有哪些查询操作会导致程序必须处理整个序列,试着把这些操作放在查询表达式的尾部。
- 笔者一直在重复惰性求值的好处(优先考虑),在绝大多数情况下这都是最好的办法。在个别情况下,你可能确实需要一份快照,说用
ToList()
和ToArray()
这两个方法。他们能立刻根据查询结果来生成序列,并保存到容器中。 - 总之,与及早求值相比惰性求值基本上能减少程序的工作量,而且使用起来也更加灵活。除非确有必要,否则还是应该优先考虑惰性求值。
第38条:考虑用lambda表达式来代替方法
- 在涉及查询表达式与lambda的地方应该用更为合理的办法去创建可供复用的代码块。
- 你可以把查询操作分成许多个小方法来写,其中一些方法在其内部用lambda表达式处理序列,而另一些方法则可以直接以lambda表达式做参数。把这些小方法拼接起来,就可以实现整套的操作。这样写既可以同时支持
IEnumerable<T>
与IQueryable<T>
,又能够令系统有机会构建出表达式树,以便高效地执行查询。
第39条:不要在Func
与Action
中抛出异常
如下面的代码,给每位员工加薪30%。
var allEmployees = FindAllEmloyees().ForEach(e => e.MonthlySalary *= 1.30M);
如果代码在运行的过程中抛出异常,可能只有一部分员工加薪,而另一部分没有加薪,但是你无法确定是哪一部分。
怎样做,才能给每位员工正常加薪30%?
首先你的做法可以过滤到那些可能加薪失败的员工(比如不在数据库内),但是这并不彻底。其次,你可以先复制一份,等到副本可以全部成功加薪之后,再赋给原对象,但是,开销会变大。同时使算法的拼接性变差。
你可以考虑令这些查询操作返回新的元素,而不是直接在源序列上面修改,以确保该操作在无法完整执行的情况下不会破坏程序的状态。与一般写法相比,用lambda表达式来编写Action
及Func
会令其中的异常更加难以发觉。因此在返回最终结果之前,必须确定这些操作都没有出现异常,然后才能用处理结果把整个源序列替换掉。
第40条:掌握尽早执行与延迟执行之间的区别
- 声明式代码(declarative code)的重点在于把执行结果定义出来,而命令式代码(imperative code)则重在详细描述实现该结果所需的步骤。
- 只有当程序确实要用到某个方法的执行结果时,才会去调用这个方法,这是声明式写法与命令式写法之间的重要区别,如果这两种写法混用,程序可能会出现严重的问题。
- 从外部来看,只要方法不产生副作用(side effect),那么凡是出现该方法的地方都可以用其返回值来替换,反之亦然。
- 直接传入方法的结果(带()的传入)与传入委托(lambda,或方法名本身)相比,第一种模型是先调用某个方法,然后把执行结果当做参数在传回本方法,第二种模型则是把那个方法当成委托传给本方法,使得本方法可以根据自己的需要随时调用委托,从而实现第一种的效果。第一种更像是面向过程的,第二种更灵活(动态)。
- 由于C#引入lambda表达式、类型推断机制及enumerator等特性,因此,开发者在编写自己的类时,可以更加方便地运用函数式编程(functional programming)中的某些概念。
- 那么到底是提前计算好还是等到用的时候再去计算呢?你应该首要考虑的问题是程序的运行效果能否保持一致。其次,你应该在计算成本与储存成本之间考虑,还要考虑自己会怎样使用计算出来的结果。
- 在编写C#算法时,先要判断用数据(算法的结果)还是用函数(算法本身)当参数,这样会不会导致程序的运行结果有所区别。在难以判断的情况下,不妨优先考虑把算法当成参数来传递,这样做可以令编写函数的人更灵活,因为它既可以惰性求值,也可以及早求值。
第41条:不要把开销较大的资源捕获到闭包中
- 闭包(closure)会创建出含有约束变量(bound variable)的对象,但是这些对象的生存期可能与你想的不一样,而且通常会给程序带来负面效果。
- 如果算法使用了一些查询表达式,那么编译器在编译这个方法时,就会把同一个作用域内的所有表达式合起来纳入一个闭包中,并创建相应的类来实现该闭包。这个类的实例会返回给方法的调用者。只用当该实例的使用方全都从系统中移除之后,它才有可能得到回收。这就会产生很多问题。
- 如果程序从方法中返回的是一个用来实现闭包的对象,那么与闭包相关的那些变量就全都会出现在该对象里面。你需要考虑此后程序是否真的需要用到这些变量。如果不需要使用其中的某些变量,那么就应该调整代码,令其在方法返回的时候能够及时得到清理,而不会随着闭包泄漏到方法之外。
第42条:注意IEnumerable
与IQueryable
形式的数据源之间的区别
-
IQueryable<T>
内置LINQ to SQL机制,IEnumerable<T>
的查询,是把排序放到本地来完成的。 - 有些功能使用
IQueryable
要比IEnumerable
快得多。 - 用
IEnumerable<T>
编写的代码必须在本地运行,无论数据在不在本地,都要首先下载到本地。如果数据在云端,这就涉及到大量的信息传输。如果更看重健壮性,请使用IEnumerable<T>
。 - 可以使用
AsEnumerable()
与AsQueryable()
进行相互转换。IQueryable
更适合远程执行(数据库)。
第43条:用Single()
及First()
来明确地验证你对查询结果所做的假设
-
Single()
方法只会在有且仅有一个元素合乎要求时把该元素返回给调用方,如果没有或者很多,就会抛出异常。 - 如果你确定你的查询结果里面有且仅有一个元素,那么就应该使用
Single()
来表达这个意思,因为这样做是很清晰的。只要查询结果中的元素数量与自己的预期不符,程序就会立刻抛出异常。 - 如果你想表达的是要么查不到任何元素,要么只能查到一个元素,那么可以用
SingleOrDefault()
来验证。这两个方法都可以保证查询表达式所返回的结果绝对不会超过一个。 - 有时候,你并不在乎查到的元素是不是有很多,而只是想取出这样的一个元素而已,这种情况下,考虑用
First()
或者FirstOrDefault()
方法来表达这个意思。 - 应该考虑通过更好地写法来寻找那个元素,使得其他开发者与代码维护者能够更为清晰地理解你想找的究竟是什么。
第44条:不要修改绑定变量
- 编译器创建的嵌套类会把lambda表达式所访问或修改的每个变量都囊括进来,而且原来访问局部变量的那些地方现在也会改为访问该嵌套类中的字段。这意味着对于同一个局部变量来说,lambda表达式里面的代码与其外围方法中的代码访问的其实都是嵌套类中与该变量相对应的那个字段。表达式里面的逻辑会编译成嵌套类中的方法。
- 把延后执行机制与编译器实现闭包的方式等因素考虑进来,你就会发现:如果在定义查询表达式的时候用到了某个局部变量,而在执行之前又修改了它的值那么程序就有可能会出现奇怪的错误,因此,捕获到闭包中的那些变量最好不要去修改。
第五章:合理地运用异常
程序总是会出错的,因为即便开发者做得再仔细,也还是会有意料不到的情况发生。内置方法要么顺利执行完毕,要么抛出异常(Git产品理念:没有回答就是最好的回答),以表示自己无法完成工作。开发库与应用程序的人,也应该按照这样的风格来编程。令代码在发生异常时依然能够保持稳定是每一位C#程序员所应掌握的关键技巧。
接下来的一章会讲解怎样通过异常来清晰而准确地表达程序在运行中所发生的错误,而且还会告诉大家怎样管理程序的状态才能令其更容易从错误中恢复。
第45条:考虑在方法约定遭到违背时抛出异常
- 如果方法不能完成其所宣称的操作,那么就应该通过异常来指出这个错误。如果改用错误码(error code)来实现,这些代码很容易被调用方所忽视。反之,如果调用方专门用一些逻辑来检测这些代码,并把它们传播出去,那么后果很严重,这些逻辑还会干扰到程序的核心逻辑。
- 由于异常本身也是类,所以你可以从中派生出自己的异常类型,以此来表达更为丰富的错误信息。
- 使用异常的另一个好处是,异常不会轻易为人所忽视。若无适当的catch语句处理异常,程序会明确地终止。
- 方法与调用方的约定无法得到遵守,就应该抛出异常,并不是说遇到调用方不满意就得一定抛出异常。
- 由于异常并不适合当做控制程序流程的常规手段,所以还应该提供另一套方法,使开发者在执行操作前先做一个判断,判断不通过提前采取相应措施,而不是等到抛出异常时再去处理。(当然这也不是绝对的,检查的方法不可能全面)
第46条:利用using
与try/finally
来清理资源
- 如果某个类用到了非托管型的系统资源,那么就需要通过
IDisposable
接口的Dispose()
方法来明确地释放。.NET环境规定,这种资源并不需要由包含该资源的类型或系统来释放,而是应该由使用此类型的代码释放。 - 拥有非托管资源的那些类型,都实现了
IDisposable
接口,此外还提供了finalizer
(终结器/终止化器),以防用户忘记释放该资源。 -
using
语句能够确保Dispose()
总是可以得到调用。 - 如果函数里只用到一个
IDisposable
对象,那么想要确保它总是可以能够适当地得到清理,最简单的办法就是使用using
语句。 - 对象的编译期类型必须支持
IDisposable
接口才能够用在using
语句中,而不是任何一种对象都可以放在using
里面。 - 如果你不清楚一个对象是否实现了
IDisposable
接口,那么可以通过as
子句来安全地处置它。using(null)
不会产生任何效果,但是却可以令程序正常运行下去。 - 凡是实现了
IDisposable
接口的对象都应该放在using
语句中或者try
块中去实现,否则就有可能泄露资源。 - 尽量选用
Dispose()
,而不是Close()
。 -
Dispose()
方法并不会把对象从内存中移除,只是提供了一次机会,令其能够释放非托管型的资源。如果程序中的其他地方还需要引用该对象,就不要过早地将其释放。
第47条:专门针对应用程序创建异常
- 如果你要给自己所写的C#应用程序创建专门的异常类,那么必须考虑的特别周到才行。
- 必须要把那些需要用不同的方式来处理的情况设计成不同的异常类型。但是,只有那些确实需要有必要分开处理的状况才应该表示成不同的异常类,把明明可以合起来处理的情况硬是放在不同的异常类里面只会增加开发者的工作量,不能带来任何好处。
-
Exception e
的e.TargetSite.Name
可以获取抛出异常的方法的方法名。 - 如何判定是否应该抛出异常?
如果某种状况必须立刻得到处理或汇报,否则将长期影响应用程序,那么就应该抛出异常。
- 开发者应该仔细想想,能不能创建一种新的异常类,以促使调用方更为清晰地理解这个错误,从而试着把应用程序恢复到正常的状态。
- 之所以要创建不同的异常类,原因很简单,就是为了调用API的人能够通过不同的
catch
子句去捕获那些状况,从而采取不同的方法加以处理。 - 一旦决定自己来创建异常类,就必须遵照相应的原则。这些类都要能够追溯到
Exception
才行,你应该从System.Exception
类或其子类进行继承。你应该创建四个构造函数。 - 异常转换(exception translation),用来将底层的异常转换成高层的异常,从而提供更贴近与当前情景的错误信息。catch一种异常但其实throw另外一种异常。
第48条:优先考虑做出强异常保证
- 针对异常做出的保证分为三种,基本保证(basic guarantee)、强保证(strong guarantee)、no-throw保证(不会抛出异常的保证)。no-throw保证的意思是不能出任何问题,强保证比较折中,允许出问题,但是应该可以恢复或者不影响程序的整体运行,基本保证是,产生异常后,程序的资源不会泄露,所有的对象都处于有效状态。
- 应用程序中的许多操作,都会在未能完全执行完毕的情况下令程序陷入无效状态,这种情况无法避免,我们要考虑使用强保证来处理这些异常。做法规定:操作抛出异常,应用程序的状态必须和操作之前相同,要么完全成功,要么完全失败。不存在部分成功的情况。可以认为操作根本没生效。可以的做法之一是使用防御式的拷贝(defensive copy),在拷贝出来的数据上进行操作。
第48条:优先考虑做出强异常保证
- 能够从错误中恢复要比性能稍稍得到提升更为重要。
- 一般来说,要想安全地替换引用类型的数据,就得面对有些客户端无法看到最新数据的情况。这没有两全其美的办法。替换数据这一办法只对值类型有效。
- 异常筛选器(exception filter)的when子句里面绝不应该抛出异常。如果抛出,那么新异常会成为当前活动异常,从而无法获取原来那个异常中的信息。
- 包括事件处理程序在内的各种委托目标都不应该抛出异常。
- finalizer、Dispose()、when子句以及委托目标是四个特例,在这些场合绝对不应该令任何异常脱离其范围。如果在拷贝出来的历史数据上面执行完操作之后想用它把原数据替换掉,而原来那个数据又是引用类型,那么要多加小心,可能会引发很多微妙的bug。
第49条:考虑用异常筛选器来改写先捕获异常再重新抛出的逻辑
- 如果改用异常筛选器来捕获并处理异常,那么以后诊断起来就会容易一些,而不会令应用程序的开销增大。多使用异常筛选器,而不要在catch子句里面通过条件语句去分析异常。
- 异常筛选器是针对catch子句所写的表达式,它出现在catch右侧那个when关键字之后,用来限定该子句所能捕获的异常。
- 采用异常筛选器会给程序带来正面影响。.NET CLR对带有when关键字的try/catch结构做了优化,使得程序在无须进入该结构时其性能尽量不受影响。
- 如果仅通过异常的类型不足以判断出自己到底能不能处理该异常,那么可以考虑给相关的catch子句添加筛选器,使得程序只有在筛选条件得以满足时才会进入这个catch块。
第50条:合理利用异常筛选器的副作用来实现某些效果
- 系统在寻找
catch
子句的过程中会执行这些筛选器,而此时,调用栈还没有真正展开。(于是,不妨利用这一特性来实现某种效果) - 放在异常筛选器中的那个方法必须总是返回
false
,绝对不能返回true
,否则异常将不会继续传播(when之后的条件为true时,将展开catch之后的子句)。你在这用情况下可以使用Expection
基类作为类型,但属特例。一般情况下都应该使用Exception
的子类。 -
catch (Exception e) when log(e) {}
,异常可以继续传播,不会干扰到程序的正常运行。log(e)
返回false
。 - 只要程序进程与debugger相连,
Debugger.IsAttached
属性就返回true,无论你构建的是debug版还是release版都是如此。
写在后面
还真是个体力活,又过了一遍格式,整理了一下,如果当初写的时候就精益求精,现在的话也就只要复制粘贴就行了。不过好在改的过程中,可以发现自己写得是越来越好的。学习真的是一个反人性的事情,这些时间我本可以再去看一些新的东西,但是我觉得那样会更累,于是选择了相对轻松的事情(整理之前的文档)。
希望这篇文章能对你有所帮助,欢迎相互讨论!