Effective C## 改善C#程序的50种方法
第1章 C#语言的编程习惯
能用的东西为什么要改?因为改了之后效果更好。开发者换用其他工具或语言来编程也是这个道理,因为换了之后工作效率更高。如果不肯改变现有的习惯,那么就体会不到新技术的好处,但如果这种新的技术与你熟悉的技术看上去很像,那么改起来就特别困难。例如C#语言就与C++或Java语言相似,由于它们都用一对花括号来表示代码块,因此,开发者即便切换到了C#语言,也总是会把使用那两门语言时所养成的习惯直接带过来,这样做其实并不能发挥出C#的优势。这门语言的首个商用版本发布于2001年,经过这些年的演变,当前这一版C#语言与C++或Java之间的差别已经远远大于那个年代。如果你是从其他语言转入C#的,那么需要学习C#语言自己的编程习惯,使得这门语言能够促进你的工作,而不是阻碍你的工作。本章会提醒大家把那些与C#编程风格不符的做法改掉,并培养正确的编程习惯。
第1条:优先使用隐式类型的局部变量
隐式类型的局部变量是为了支持匿名类型机制而加入C#语言的。之所以要添加这种机制,还有一个原因在于:某些查询操作所获得的结果是IQueryable<T>,而其他一些则返回IEnumerable<T>。如果硬要把前者当成后者来对待,那就无法使用由IQueryProvider所提供的很多增强功能了(参见第42条)。用var来声明变量而不指明其类型,可以令开发者把注意力更多地集中在名称上面,从而更好地了解其含义。例如,jobsQueuedByRegion这个变量名本身就已经把该变量的用途说清楚了,即便将它的类型Dictionary<int,Queue<string>>写出来,也不会给人提供多少帮助。
对于很多局部变量,笔者都喜欢用var来声明,因为这可以令人把注意力放在最为重要的部分,也就是变量的语义上面,而不用分心去考虑其类型。如果代码使用了不合适的类型,那么编译器会提醒你,而不用你提前去操心。变量的类型安全与开发者有没有把变量的类型写出来并不是同一回事。在很多场合,即便你费心去区分IQueryable与IEnumerable之间的差别,开发者也无法由此获 得有用的信息。如果你非要把类型明确地告诉编译器,那么有时可能会改变代码的执行方式(参见第42条)。在很多情况下,完全可以使用var来声明隐式类型的局部变量,因为编译器会自动选择合适的类型。但是不能滥用这种方式,因为那样会令代码难于阅读,甚至可能产生微妙的类型转换bug。
局部变量的类型推断机制并不影响C#的静态类型检查。这是为什么呢?首先必须了解局部变量的类型推断不等于动态类型检查。用var来声明的变量不是动态变量,它的类型会根据赋值符号右侧那个值的类型来确定。var的意义在于,你不用把变量的类型告诉编译器,编译器会替你判断。
笔者现在从代码是否易读的角度讲解隐式类型的局部变量所带来的好处和问题。其实在很多情况下,局部变量的类型完全可以从初始化语句中看出来:
var foo = new MyType();
懂C#的开发者只要看到这条语句,立刻就能明白foo变量是什么类型。此外,如果用工厂方法的返回值来初始化某个变量,那么其类型通常也是显而易见的:
var thing = AccountFactory.CreateSavingsAccount();
某些方法的名称没有清晰地指出返回值的类型,例如
var result = somebObject.DoSomeWork(anotherParameter);
这个例子当然是笔者刻意构 造的,大家在编写代码的时候应该把方法的名字起好,使得调用方可以据此推断出返回值的类型。对于刚才那个例子来说,其实只需要修改变量的名称,就能令代码变得清晰:
var HighestSellingProduct = somebObject.DoSomeWork(anotherParameter);
尽管方法名本身没有指出返回值的类型,但是像这样修改之后,很多开发者就可以通过变量的名称推断出该变量的类型应该是Product。
HighestSellingProduct变量的真实类型当然要由DoSomeWork方法的签名来决定,因此,它的类型可能并不是Product本身,而是继承自Product的类,或是Product所实现的接口。总之,编译器会根据DoSomeWork方法的签名来认定HighestSellingProduct变量的类型。无论它在运行期的实际类型是不是Product,只要没有明确执行类型转换操作,那么一律以编译器判断的类型为准。
用var来声明变量可能会令阅读代码的人感到困惑。比方说,如果像刚才那样用方法的返回值来给这样的变量做初始化,那么就会造成此类问题。查看代码的人会按照自己的理解来认定这个变量的类型,而他所认定的类型可能恰好与变量在运行期的真实类型相符。但是编译器却不会像人那样去考虑该对象在运行期的类型,而是会根据声明判定其在编译期的类型。如果声明变量的时候直接指出它的类型,那么编译器与其他开发者就都会看到这个类型,并且会以该类型为准,反之,若用var来声明,则编译器会自行推断其类型,而其他开发者却看不到编译器所推断出的类型。因此,他们所认定的类型可能与编译器推断出的类型不符。这会令代码在维护过程中遭到错误地修改,并产生一些本来可以避免的bug。
如果隐式类型的局部变量的类型是C#内置的数值类型,那么还会产生另外一些问题,因为在使用这样的数值时,可能会触发各种形式的转换。有些转换是宽化转换(widening conversion),这种转换肯定是安全的,例如从float到double就是如此,但还有一些转换是窄化转换(narrowing conversion),这种转换会令精确度下降,例如从long到int的转换就会产生这个问题。如果明确地写出数值变量所应具备的类型,那么就可以更好地加以控制,而且编译器也会把有可能因转换而丢失精度的地 方给你指出来。
现在看这段代码:
var f = GetMagicNumber();
var total = 100 * f / 6;
Console.WriteLine($"Declared Type: {total.GetType().Name},Value:{total}");
请问total的值是多少?这个问题取决于GetMagicNumber方法的返回值是什么类型。下面这5种输出结果分别对应5个GetMagicNumber版本,每个版本的返回值类型都不一样:
Declared Type: Double,Value: 166.666666666667
Declared Type: Single,Value: 166.6667
Declared Type: Int32,Value: 166
Declared Type: Int64,Value: 166
total变量在这5种情况下会表现出5种不同的类型,这是因为该变量的类型由变量f来确定,而变量f的类型又是编译器根据GetMagicNumber()的返回值类型推断出来的。计算total值的时候,会用到一些常数,由于这些常数是以字面量的形式写出的,因此,编译器会将其转换成和f一致的类型,并按照那种类型的规则加以计算。于是,不同的类型就会产生不同的结果。
这并不是C#编译器的缺陷,因为它只是按照代码的含义照常完成了任务而已。由于代码采用了隐式类型的局部变量,因此编译器会自己来设定变量的类型,也就是根据赋值符号右侧的那一部分做出最佳的选择。用隐式类型的局部变量来表示数值的时候要多加小心,因为可能会发生很多隐式转换,这不仅容易令阅读代码的人产生误解,而且其中某些转换还会令精确度下降。
这个问题当然也不是由var所引发的,而是因为阅读代码的人不清楚GetMagic-Number()的返回值究竟是什么类型,也不知道运行过程中会发生哪些默认的数值转换。把变量f的声明语句拿掉之后,问题依然存在:
var total = 100 * f / 6;
Console.WriteLine($"Declared Type: {total.GetType().Name},Value:{total}");
就算明确指出total变量的类型,也无法消除疑惑:
double total = 100 * GetMagicNumber() / 6;
Console.WriteLine($"Declared Type: {total.GetType().Name},Value:{total}");
total的类型虽然是double,但如果GetMagicNumber()返回的是整数值,那么程序就会按照整数运算的规则来计算100*GetMagicNumber()/6的值,而无法把小数部分也保存到total中。
代码之所以令人误解,是因为开发者看不到GetMagicNumber()的实际返回类型,也无法轻易观察出计算过程中所发生的数值转换。
如果把GetMagicNumber()的返回值保存在类型明确的变量中,那么这段代码就会好读一点,因为编译器会把开发者所犯的错误指出来。当GetMagicNumber()的返回值类型可以隐式地转换为变量f所具备的类型时,编译器不会报错。例如当方法返回的是int且变量f的类型是decimal时,就会发生这样的转换。反之,若不能执行隐式转换,则会出现编译错误,这会令开发者明白自己原来理解得不对,现在必须修改代码。这样的写法使得开发者能够仔细审视代码,从而看出正确的转换方式。
刚才那个例子说 明局部变量的类型推断机制可能会给开发者维护代码造成困难。与不使用类型推断的情况相比,编译器在这种情况下的运作方式其实并没有多少变化,它还是会执行自己应该完成的类型检查,只是开发者不太容易看出相关的规则与数值转换行为。在这些场合中,局部变量的类型推断机制起到了阻碍作用,使得开发者难以判断相关的类型。
但是在另外一些场合里面,编译器所选取的类型可能比开发者手工指定的类型更为合适。下面这段简单的代码会把客户姓名从数据库里面拿出来,然后寻找以字符串start开头的那些名字,并把查询结果保存到变量q2中:
public IEnumerable<string> FindCustomerStartingWith1(string start){
IEnumerable<string> q = from c in db.Customers select c.ContactName;
var q2 = q.Where(s => s.StartsWith(start));
return q2;
}
这段代码有严重的性能问题。第一行查询语句会把每一个人的姓名都从数据库里取出来,由于它要查询数据库,因此,其返回值实际上是IQueryable<string>类型,但是开发者却把保存该返回值的变量q声明成了IEnumerable<string>类型。由于IQueryable<T>继承自IEnumerable<T>,因此编译器并不会报错,但是这样做将导致后续的代码无法使用由IQueryable所提供的某些特性。接下来的那行查询语句,就受到了这样的影响,它本来可以使用Queryable.Where去查询,但是却用了Enumerable.Where。如果开发者不把变量q的类型明确指定为IEnumerable<string>,那么编译器就可以将其设为更加合适的IQueryable<string>类型了。假如IQueryable<string>不能隐式地转换成IEnumerable<string>,那么刚才那种写法会令编译器报错。但实际上是可以完成隐式转换的,因此编译器不会报错,这使得开发者容易忽视由此引发的性能问题。
第二条查询语句调用的并不是Queryable.Where,而是Enumerable.Where,这对程序性能有很大影响。第42条会讲到,IQueryable能够把与数据查询有关的多个表达式树组合成一项操作,以便一次执行完毕,而且通常是在存放数据的远程服务器上面执行的。刚才那段代码的第二条查询语句相当于SQL查询中的where子句,由于执行这部分查询时所针对的数据源是IEnumerable<string>类型,因此,程序只会把第一条查询语句所涉及的那部分操作放在远程电脑上面执行。接下来,必须先把从数据库中获取到的客户姓名全都拿到本地,然后才能执行第二条查询语句(相当于SQL查询中的where子句),以便从中搜索指定的字符串,并返回与之相符的结果。
下面这种写法比刚才那种写法要好:
public IEnumerable<string> FindCustomerStartingWith(string start){
var q = from c in db.Customers select c.ContactName;
var q2 = q.Where(s => s.StartsWith(start));
return q2;
}
这次的变量q是IQueryable<string>类型,该类型是编译器根据第一条查询语句的返回类型推断出来的。C#系统会把接下来那条用于表示Where子句的查询语句与第一条查询语句相结合,从而创建一棵更为完备的表达式树。只有调用方真正去列举查询结果里面的内容时,这棵树所表示的查询操作才会得到执行。由于过滤查询结果所用的那条表达式已经传给了数据源,因此,查到的结果中只会包含与过滤标准相符的联系人姓名,这可以降低网络流量,并提高查询效率。这段范例代码是笔者特意构造出来的,现实工作中如果遇到此类需求,直接把两条语句合起来写成一条就行了,不过这个例子所演示的情况却是真实的,因为工作中经常遇到需要连续编写多条查询语句的地方。
这段代码与刚才那段代码相比,最大的区别就在于变量q的类型不再由开发者明确指定,而是改由编译器来推断,这使得其类型从原来的IEnumerable<string>变成了现在的IQueryable<string>。由于扩展方法是静态方法而不是虚方法,因此,编译器会根据对象在编译期的类型选出最为匹配的调用方式,而不会按照其在运行期的类型去处理,也就是说,此处不会发生后期绑定。即便运行期的那种类型里面确实有实例成员与这次调用相匹配,编译器也看不到它们,因而不会将其纳入候选范围。
一定要注意:由于扩展方法可以看到其参数的运行期类型,因此,它能够根据该类型创建另一套实现方式。比方说,Enumerable.Reverse()方法如果发现它的参数实现了IList<T>或ICollection<T>接口,那就会改用另一种方式执行,以求提升效率(关于这一点,请参见本章稍后的第3条)。
写程序的时候,如果发现编译器自动选择的类型有可能令人误解代码的含义,使其无法立刻看出这个局部变量的准确类型,那么就应该把类型明确指出来,而不要采用var来声明。反之,如果读代码的人根据代码本身的语义所推测出的类型与编译器自动选择的类型相符,那就可以用var来声明 。比方说,在刚才那个例子里面,变量q用来表示一系列联系人的姓名,看到这条初始化语句的人肯定会把q的类型理解成字符串,而实际上,编译器所判定的类型也正是字符串。像这样通过查询表达式来初始化的变量,其类型通常是较为明确的,因此,不妨用var来声明。反之,若是初始化变量所用的那条表达式无法清晰地传达出适当的语义,从而令阅读代码的人容易误解其类型,那么就不应该用var来声明该变量了,而是应该明确指出其类型。
总之,除非开发者必须看到变量的声明类型之后才能正确理解代码的含义,否则,就可以考虑用var来声明局部变量(此处所说的开发者也包括你自己在内,因为你将来也有可能要查看早前写过的代码)。注意,笔者在标题里面用的词是优先,而非总是,这意味着不能盲目地使用var来声明一切局部变量,例如对int、float、double等数值型的变量,就应该明确指出其类型,而对其他变量则不妨使用var来声明。有的时候,即便你多敲几下键盘,把变量的类型打上去,也未必能确保类型安全,或是保证代码变得更容易读懂。如果你选用了不合适的类型,那么程序的效率就有可能会下降,这样做的效果还不如让编译器自动去选择。
第2条:考虑用readonly代替const
C#有两种常量,一种是编译期(compile-time)的常量,另一种是运行期(runtime)的常量,它们的行为大不相同。常量如果选得不合适,那么程序开发工作可能会受影响。编译期的常量虽然能令程序运行得稍快一点,但却远不如运行期的常量那样灵活。只有当程序性能极端重要且常量取值不会随版本而变化的情况下,才可以考虑选用这种常量。
运行期的常量用readonly关键字来声明,编译期的常量用const关键字来声明:
// 编译时常数
public const int Millennium = 2000;
// 运行时常数
public static readonly int ThisYear = 2004;
上面这段代码演示了怎样在class(类)或struct(结构体)的范围之内声明这两种常量。此外,编译期的常量还可以在方法里面声明,而readonly常量则不行。
这两种常量在行为上面的区别可以在访问常量的时候体现出来。编译期的常量其取值会嵌入目标代码。比方说,下面这种写法:
if(myDateTime.Year == Millennium)
编译成Microsoft Intermediate Language(微软中间语言,简称MSIL或IL)之后,就与直接使用字面量2000的写法是一样的:
if(myDateTime.Year == 2000)