《Effective C#》笔记(1) – 编程习惯

  • A+
所属分类:.NET技术
摘要

推荐优先使用隐式类型的局部变量,即用var来声明,因为这可以令人把注意力放在最为重要的部分,也就是变量的语义上面,而不用分心去考虑其类型.


1.优先使用隐式类型的局部变量

推荐优先使用隐式类型的局部变量,即用var来声明,因为这可以令人把注意力放在最为重要的部分,也就是变量的语义上面,而不用分心去考虑其类型.

有时隐式类型比自己指定类型表现更好

用var来声明的变量不是动态变量,隐式类型的局部变量的类型推断也不等于动态类型检查。只是编译器会根据赋值符号右侧的表达式来推断变量的类型。var的意义在于不用专门指定变量的类型,而是交给编译器来判断,所以局部变量的类型推断机制并不影响C#的静态类型检查。
有时隐式类型会有比专门指定类型更好的表现,比如下面这段指定变量q为IEnumerable的代码便存在严重的性能问题。

public IEnumerable<string> FindCustomerStartWith(string start) {     IEnumerable<string> q =     from c in db.Customers     select c.ContactName;     var q2 = q.Select(a => a.StartsWith(start));     return q2; } 

第一行查询语句会把每一个人的姓名都从数据库里取出来,由于它要查询数据库,因此其返回值实际上是IQueryable类型,但是开发者却把保存该返回值的变量q声明成了IEnumerable类型。由于IQueryable继承自IEnumerable,因此编译器并不会报错,但是这样做将导致后续的代码无法使用由IQueryable所提供的特性。接下来的那行查询语句,就受到了这样的影响,它本来可以使用Queryable.Where去查询,但是却用了Enumerable.Where。这会导致程序把从数据库中获取到的客户姓名全都拿到本地,然后才能执行第二条查询语句。

而只需要改用var来声明变量,就可以避免这个问题:

public IEnumerable<string> FindCustomerStartWith(string start) {     var q =     from c in db.Customers     select c.ContactName;     var q2 = q.Select(a => a.StartsWith(start));     return q2; } 

因为q变成了IQueryable类型,系统会首先把第二条筛选语句第一条查询语句相结合,创建一棵更为完备的表达式树,然后只有在调用方真正去使用查询结果里面的内容时,这棵树所表示的查询操作才会得到执行。

隐式类型可能带来的问题

虽然推荐大多数时候使用var,但也不能盲目地使用var来声明一切局部变量。有时隐式类型可能带来一些隐秘的问题。因为如果用var来声明,则编译器会自行推断其类型,而其他开发者却看不到编译器所推断出的类型。因此,他们所认定的类型可能与编译器推断出的类型不符。这会令代码在维护过程中遭到错误地修改,并产生一些本来可以避免的bug。
典型的如值类型,在计算过程中可能会触发各种形式的转换。有些转换是宽化转换(widening conversion),这种转换肯定是安全的,例如从float到double就是如此,但还有一些转换是窄化转换(narrowing conversion),这种转换会令精确度下降,例如从long到int的转换就会产生这个问题。如果明确地写出数值变量所应具备的类型,那么就可以更好地加以控制,而且编译器也会把有可能把因转换而丢失精度的地方给指出来。
比如下面这段代码:

var f = GetMagicNumber(); var total = 100 * f / 6; Console.WriteLine($"Type: {total.GetType().Name}, Value: {total}"); 

下面这5种输出结果分别对应5个GetMagicNumber版本,每个版本的返回值类型都不一样:

Type: Double, Value: 1666.6666666666667 Type: Single, Value: 1666.6666 Type: Decimal, Value: 1666.6666666666666666666666667 Type: Int32, Value: 1666 Type: Int32, Value: 1666 

total变量在这5种情况下会表现出5种不同的类型,这是因为该变量的类型由变量f来确定,而变量f的类型又是编译器根据GetMagicNumber()的返回值类型推断出来的。计算total值的时候,会用到一些常数,由于这些常数是以字面量的形式写出的,因此,编译器会将其转换成和f一致的类型,并按照那种类型的规则加以计算。于是,不同的类型就会产生不同的结果。

总结

如果发现编译器自动选择的类型有可能令人误解代码的含义,使人无法立刻看出这个局部变量的准确类型,那么就应该把类型明确指出来,而不要采用var来声明。反之,在其它的场景,都应该优先用var来声明局部变量。用隐式类型的局部变量来表示数值的时候要多加小心,因为可能会发生很多隐式转换,这不仅容易令阅读代码的人产生误解,而且其中某些转换还会令精确度下降。

2.考虑用readonly代替const

C#的常量有两种:

  • 编译期(compile-time)常量,关键字const
  • 运行期(runtime)常量,关键字readonly

两者的区别主要有:

  • readonly和const常量都可以在class、struct的范围内声明;此外const常量还可以在方法里面声明,readonly则不可以
  • const常量的取值会嵌入目标代码,必须在声明时赋值; readonly常量可以在声明时赋值,也可以在构造函数赋值
  • const常量只能用数字、字符串或null来初始化;readonly常量的类型则不受限制
  • readonly可以用来声明实例级别的常量,以便给同一个类的每个实例设定不同的常量值,而编译期的常量则是静态常量。

可见readonly比const更加灵活。此外,const在编译时解析值的特性还会对影响程序的维护工作。
比如在程序集A中有这样的代码:

public class ValueInfo{     public static readonly int Start = 5;     public const int End = 10; } 

然后程序集B引用了程序集A中的这两个常量:

for(var i = valueInfo.Start; i < valueInfo.End; i++)     Console.Writeline(i); 

则输出结果为:

5 6 7 8 9 

随后修改了程序集A:

public class ValueInfo{     public static readonly int Start = 105;     public const int End = 110; } 

此后如果只发布程序集A,而不去构建程序集B,是不会下面这样得到期望的结果的:

105 106 ... 109 

因为在程序集B中,valueInfo.End的值仍然是上一次编译是的10,要想让修改生效,需要重新编译程序集B。

总结

推荐优先使用readonly,因为它比const更灵活,但const也不是一无是处,首先它的性能更好,此外有时使用const仅仅是为了消除魔数增加可读性,这种情况使用const也未尝不可,另外还有些确实需要在编译器把常量值固定下来的需求,那么也是必须使用const。

3.优先考虑is和as运算符,尽量少用强制类型转换

在C#中实现类型转换可以使用as运算符,或者使用强制类型转换(cast)来绕过编译器的类型检查。
使用as运算符的写法:

private static void As() {     // object a = null;      object a = new TypeB();     var b = a as TypeA;     if (b != null)     {         Console.WriteLine("convert succeed");     }     else     {         Console.WriteLine("convert failed");     } } 

使用cast的写法:

private static void Cast() {     //object a = null;     object a= new TypeB();     try     {       var b = (TypeA) a;       if (b != null)       {         Console.WriteLine("convert succeed");       }       else       {         Console.WriteLine("convert failed");       }     }     catch (InvalidCastException e)     {       Console.WriteLine("convert failed");     } } 

TypeA与TypeB没有任何联系,因此两种写法的转换都会失败,但两者的区别在于:

  • 在将TypeB转换为TypeA时,as写法的结果为null,但cast写法会报InvalidCastException异常
  • 在将object a = null转换为TypeA时,两者的结果都是null

所以a s写法在两种情况下的结果都是null,但cast写法需要判断null并catch InvalidCastException异常才能涵盖两种情况。可见as写法相比cast写法省了try/catch结构,程序的开销与代码量都比较低。除了判断转换结果是否为null,也可以先用Is来判断转换能否成功。

as与cast最大的区别在于它们如何对待由用户所定义的转换逻辑:

  • as与is运算符只会判断待转换的那个对象在运行期是何种类型,并据此做出相应的处理,除了必要的装箱与取消装箱操作,它们不会执行其他操作。如果待转换的对象既不属于目标类型,也不属于由目标类型所派生出来的类型,那么as操作就会失败。
  • cast操作则有可能使用某些类型转换逻辑来实现类型转换,这不仅包含由用户所定义的类型转换逻辑,而且还包括内置的数值类型之间的转换。例如可能发生从long至short的转换,这种转换可能导致信息丢失。

如果在TypeB类中定义如下运算符:

public class TypeB {   private TypeA _typeA =new TypeA();   public static implicit operator TypeA(TypeB typeB)   {     return typeB._typeA;   } } 

那么前面的cast方式的代码应该就会把由用户所定义的转换逻辑也考虑进去,但运行后发现转换仍然失败,这是为什么呢?
这是因为虽然cast方式会考虑自定义转换逻辑,但它针对的是源对象的编译期类型,而不是实际类型。具体到本例来说,由于待转换的对象其编译期的类型是object,因此,编译器会把它当成object看待,而不考虑其在运行期的类型。
如果改成在cast前先转换为TypeB,则转换会成功:

... object a= new TypeB(); try {   var a1 = a as TypeB;   var b = (TypeA) a1;   if (b != null) ... 

但不推荐这种别扭的写法,应该优先考虑采用as运算符来实现类型转换,因为这样做要比盲目地进行类型转换更加安全,而且在运行的时候也更有效率。

不能使用as的情况

类似下面这样的代码,将object转换为值类型,是无法通过语法检查的,因为值类型无法表示null:

object a = null; var b = a as int; 

为此只需将转换目标修改为可空值类型就可以了:

object a = null; var b = a as int?; 

总结

使用面向对象语言来编程序的时候,应该尽量避免类型转换操作,但总有一些场合是必须转换类型的。此时应该采用as及is运算符来更为清晰地表达代码的意图。

4.用内插字符串取代string.Format()

string.Format()可以用来设置字符串的格式,但C#6.0之后提供了内插字符串(Interpolated String)特性,更推荐使用后者。

内插字符串的好处

  • 使代码更容易阅读、维护
  • 编译器也可以用它实现出更为完备的静态类型检查机制,从而降低程序出错的概率
  • 内插字符串还提供了更加丰富的语法

string.Format()可能造成的问题

  • 如果格式字符串后面的参数个数与待替换的序号数量是否相等,编译器是不会发现这个问题的
  • 如果格式字符串中的序号与params数组中的位置没有相对应,这个错误可能很难被发现

内插字符串的用法

  • 不能使用if/else或while等控制流语句,如果必须使用,可以把这些逻辑写成方法,然后在内插字符串调用该方法
  • 内插字符串会在必要的时候将变量转换为string,比如$"the value of PI is {Math.PI}" ,会将double转换为string,由于double是值类型,必须先通过装箱操作转为object,如果这段代码频繁执行,就会严重影响性能。
    这可以通过强制调用Math.PI.ToString()来避免。
  • 字符串内插机制支持很多种语法,只要是有效的C#表达式,都可以出现在字符串里面,比如三元表达式、null条件运算符、null传播运算符、LINQ查询,还可以在内插字符串里面继续编写内插字符串。

内插字符串是一种语法糖

内插字符串实际上是一种语法糖,生成的是FormattableString,将接收内插字符串的变量指定为FormattableString可以看到其Format属性的值,通过GetArguments可以看到对应的参数:

 FormattableString a1 = $"the value of PI is {Math.PI}, E is {Math.E}";  Console.WriteLine("Format: " + a1.Format);  Console.WriteLine("Arguments: ");  foreach (var arg in a1.GetArguments())  {    Console.WriteLine($"t{arg}");  } 

运行结果为:

Format: the value of PI is {0}, E is {1} Arguments:          3.141592653589793         2.718281828459045 

只是在实际使用时系统会自动将其解读为string结果。

7.用委托表示回调

回调是一种由被调用端向调用端提供异步反馈的机制,它可能会涉及多线程(multithreading),也有可能只是给同步更新提供入口。
C#用委托来表示回调。通过委托,可以定义类型安全的回调。类型安全代码指访问被授权可以访问的内存位置,类型安全直观来说意味着编译器将在编译时验证类型,如果尝试将错误的类型分配给变量,则抛出错误。

最常用到委托的地方是事件处理,此外,还可用于多种场合,比如想采用比接口更为松散的方式在类之间沟通时,就应该考虑委托。这种机制可以在运行的时候配置回调目标,并且能够通知给多个客户端。

委托是一种对象,其中含有指向方法的引用,这个方法既可以是静态方法,又可以是实例方法。

C#提供了一种简便的写法,可以直接用lambda表达式来表示委托。此外,还可以用Predicate、Action<>及Func<>表示很多常见的委托形式,LINQ就是用这些机制构建起来的。predicate(谓词)是用来判断某条件是否成立的布尔(Boolean)函数,而Func<>则会根据一系列的参数求出某个结果。其实Func<T,bool>与Predicate是同一个意思,只不过编译器会把两者分开对待而已,也就是说,即便两个委托是用同一套参数及返回类型来定义的,也依然要按照两个来算,编译器不允许在它们之间相互转换。

由于历史原因,所有的委托都是多播委托(multicast delegate),也就是会把添加到委托中的所有目标函数(target function)都视为一个整体去执行。
这就需要注意下面两个问题:

  • 程序在执行这些目标函数的过程中可能发生异常;但多播委托在执行的时候,会依次调用这些目标函数,且不捕获异常。因此,只要其中一个目标抛出异常,调用链就会中断,从而导致其余的那些目标函数都得不到调用。

  • 程序会把最后执行的那个目标函数所返回的结果当成整个委托的结果。

对于这两个问题,必要的时候可以通过委托的GetInvocationList方法获取目标函数列表,然后手动遍历来处理异常和返回值。

8.用null条件运算符调用事件处理程序

关于事件处理程序,有很多陷阱要注意,比如,如果没有处理程序与这个事件相关联,那会出现什么情况?如果有多个线程都要检测并调用事件处理程序,而这些线程之间相互争夺,那又会出现什么情况?

触发事件的基本写法可以是这样:

public class EventSource {   public event Action<int> Update;   public void RaiseUpdate()   {     Update(2);   } } 

但如果没有为Update注册事件处理程序,这种写法就会报NullReferenceException,为此可以改进为触发前先检查事件处理程序是否存在:

public void RaiseUpdate() {   if(Update!=null)     Update(2);   } 

这种写法基本上可以应对各种状况,但还是有个隐藏的bug。因为当程序中的线程执行完那行if语句并发现Updated不等于null之后,可能会有另一个线程打断该线程,并将唯一的那个事件处理程序解除订阅,这样等早前的线程继续执行Updated(2)语句时,事件处理程序就变成了null,仍然会引发NullReferenceException。
为了预防这种情况出现,可以将代码继续改进为:

public void RaiseUpdate() {   var handler = Update;   if(handler!=null)     handler(2);   } 

这种写法是线程安全的,因为将handler赋值为Update会执行浅拷贝,也就是创建新的引用,将handler指向原来Update的事件处理程序。这样即使另外一个线程把Update事件清空,handler中还是保存着事件处理程序的引用,并不会受到影响。

这种写法虽然没什么问题,但看起来冗长而费解。使用c#6.0引入的null条件运算符可以改用更为清晰的写法:

public void RaiseUpdate() {   Update?.Invoke(2); } 

这段代码采用null条件运算符(?.)首先判断其左侧的内容,如果不是null,那就执行右侧的内容,反之则跳过该语句。从语义上来看,这与前面的if结构类似,但区别在于条件运算符左侧的内容只会被计算一次。

9. 尽量避免装箱与拆箱操作

值类型是盛放数据的容器,它们不应该设计成多态类型,但另一方面,.NET又必须设计System.Object这样一种引用类型,并将其放在整个对象体系的根部,使得所有类型都成为由Object所派生出的多态类型。这两项目标是有所冲突的。
为了解决该冲突,.NET引入了装箱与拆箱的机制。装箱的过程是把值类型放在非类型化的引用对象中,使得那些需要使用引用类型的地方也能够使用值类型。拆箱则是把已经装箱的那个值拷贝一份出来。
如果要在只接受System.Object类型或接口类型的地方使用值类型,那就必然涉及装箱及取消装箱。
但这两项操作都很影响性能,有的时候还需要为对象创建临时的拷贝,而且容易给程序引入难于查找的bug。
因此,应该尽量避免装箱与取消装箱这两种操作。
就连下面这条简单内插字符串写法都会用到装箱:

var firstNumber = 1; var a = $"the first number is: {firstNumber}"; 

因为系统在解读内插字符串时,需要创建由System.Object所构成的数组,以便将调用方所要输出的值放在这个数组里面,并交给由编译器所生成的方法去解读。但firstNumber变量却是值类型,要想把它当成System.Object来用,就必须装箱。
此外,该方法的代码还需要调用ToString(),而这实际上相当于在箱子所封装的原值上面调用,也就是说,相当于生成了这样的代码:

var firstNumber = 1; object o = firstNumber; var str = firstNumber.ToString(); 

要避开这一点,需要提前把这些值手工地转换成string:

var a = $"the first number is: {firstNumber.ToString()}"; 

总之,要避免装箱与拆箱操作,就应注意那些会把值类型转换成System.Object类型的地方,例如把值类型的值放入集合、用值类型的值做参数来调用参数类型为System.Object的方法以及将这些值转为System.Object等。

10.只有在应对新版基类与现有子类之间的冲突时才应该使用new修饰符

new修饰符可以重新定义从基类继承下来的非虚成员,但要慎用这个特性,因为重新定义非虚方法可能会使程序表现出令人困惑的行为。
假设MyOtherClass继承自MyClass,那么初看起来下面这两种写法的效果应该是相同的:

object c = new MyOtherClass(); var c1 =c as MyClass; c1.MagicMethod();  var c2 =c as MyOtherClass; c2.MagicMethod(); 

但如果使用了new修饰符就不会相同了:

public class MyClass {   public void MagicMethod()   {     Console.WriteLine("MyClass");   } }  public class MyOtherClass : MyClass {   public new void MagicMethod()   {     Console.WriteLine("MyOtherClass");   } } 

c2.MagicMethod()的结果是"MyOtherClass",
new修饰符并不会把本来是非虚的方法转变成虚方法,而是会在类的命名空间里面另外添加一个方法。非虚的方法是静态绑定的,所以凡是引用MyClass.MagicMethod()的地方到了运行的时候执行的都是MyClass类里面的那个MagicMethod,即便派生类里面还有其他版本的同名方法也不予考虑。
反之,虚方法则是动态绑定的,要到运行的时候才会根据对象的实际类型来决定应该调用哪个版本。

不推荐new修饰符重新定义非虚的方法,但这并非是在鼓励把基类的每个方法都设置成虚方法。程序库的设计者如果把某个函数设置成虚函数,那相当于在制定契约,也就是要告诉使用者:该类的派生类可能会以其他的方式来实现这个虚函数。虚函数应该用来描述那些子类与基类可能有所区别的行为。如果直接把类中的所有函数全都设置成虚函数,那么就等于在说这个类的每一种行为都有可能为子类所修改。这表现出类的设计者根本就没有仔细去考虑其中到底有哪些行为才是真正可能会由子类来修改的。

本书的作者认为唯一一种可能使用new修饰符的情况是:新版的基类里面添加了一个方法,而那个方法与你的子类中已有的方法重名了。作者提到的原因是:在这种情况下,你所写的代码里面可能已经有很多地方都用到了子类里面的这个方法,而且其他程序集或许也用到了这个方法,因此,想要给子类的方法改名可能比较麻烦。但是现在的IDE可以方便地重命名,并不会麻烦,所以new修饰符基本失去了使用场景,事实上,在平时也确实鲜有需要用到这个修饰符的情况。

参考书籍

《Effective C#:改善C#代码的50个有效方法(原书第3版)》 比尔·瓦格纳