Effective C# 之 C#的编程习惯
目前网络上的《Effective C#》笔记都是基于07年出版的原书第二版的翻译版本,如今很多理论已经过时,而最新的第三版的中文版已经在18年出版,因此这里分享一下第一章节C#的编程习惯的读书笔记。
这本书其实主要讲的是改善C#代码的50个方法,相比《Effective C++》是有差距的,但是同样很有用。第一章节的C#编程习惯讲了一些C#编程规范。
1.优先使用隐式类型的局部变量
优先使用隐式类型的局部变量(注意不是总是使用),即使用var
声明变量。
1 | //int count = 0; |
这样不需要考虑变量的类型,因为通过初始化语句往往可以直接看出。而且即使是通过方法初始化,也可以通过使用合适的变量名、方法名来方便判断。
举一个具体的例子,在使用LINQ
语句时,某些查询操作所获得结果是IQueryable<T>
,而有些则返回IEnumberable<T>
。如果都当做后者处理,性能上会差一些,因为IQueryable<T>
对数据的查询做了优化。使用var
来让编译器推断类型,反而获得了性能优势。
具体代码如下:
1 | public IEnumerable<string> FindCustomersStartingWith(stirng start){ |
这段代码有严重的性能问题。其中的查询语句会把每个人的姓名从数据库中取出,返回值实际上应该是IQueryable<T>
,但开发者却保存为IEnumberable<T>
,而由于IQueryable<T>
继承自IEnumberable<T>
,因此不会报错,但这样却导致后续代码无法使用IQueryable<T>
的特性。比如下面q2
本可以使用Querable.Where
去查询,却使用了Enumerable.Where
。实际上IQueryable<T>
能够把数据查询相关的多个表达式树组合成一项操作,使得一次性在存放数据的远程服务器上执行完毕再返回,而IEnumberable<T>
却需要查询到本地之后才能再执行第二条查询语句,一方面浪费了网络流量、另一方面大大降低了查询效率。
修改为下面这样,是更简洁也更好的写法:
1 | public IEnumerable<string> FindCustomersStartingWith(stirng start){ |
当然,var
也不能滥用,如果过多使用var会导致其他开发者难以理解代码类型。同时在使用数值型类型变量int、float、double等时,如果涉及到数值转换(尤其是窄化转换,如long到int),请不要使用var
,它可能会导致精度降低。
例如:
1 | var f = GetMagicNumber(); |
假设GetMagicNumber()
返回值是10,那么total
的值是多少取决于GetMagicNumber()
的返回值类型。
如果你不清楚GetMagicNumber()
的返回值类型,就有可能计算出不符合需求的total
。比如返回值是Double和Int32,那么最终的total
分别是166.666666666667和166。
这个问题其实不是var f
和var total
引发的,而是阅读代码的人不知道GetMagicNumber()
的返回值类型,也不知道运行过程中GetMagicNumber()
导致的默认的数值转换。
这个例子下,使用var
很显然可能导致代码维护困难,这类场合就不适合使用。
而一般情况下,即使你明确了变量类型,也未必能确保类型安全或者保证代码更易读懂,而选择了错误的类型反而会导致程序效率降低,这样不如使用var
让编译器自动选择。
2.考虑用readonly
代替const
C#有两种常量,一种是编译期常量,使用const
;另一种是运行期常量,使用readonly
。
编译期常量虽然会使得程序运行稍快那么一点点,但是远不如运行期常量灵活。只有程序性能极端重要,并且常量取值不会随版本变化时,才考虑使用const
。
1 | //编译期常量 |
const
可以在方法里声明,而readonly
不可以。
同时,两种常量支持的值也不一样,const
只支持内置的整数、浮点数、枚举、字符串。并且const
常量只能用数字、字符串、null来初始化。readonly
常量执行完构造函数之后就不能再修改了,但是它是在程序运行时初始化的,因此更加灵活。
其中一个好处在于,readonly
常量的类型不受限制。比如DateTime
类型常量,不能用const
声明,但是可以用readonly
声明。
此外,在跨程序集使用常量时,使用readonly
可以避免不必要的编译。
例子:
一个名为Infra
的程序集中同时使用const
和readonly
定义常量:
1 | public class Constants{ |
另一个名为app
的程序集里引用了这两个字段:
1 | Console.WriteLine("start is {0}", Constants.start); |
测试输出结果是
1 | start is 5 |
但如果过段时间修改Infra
程序集的代码:
1 | public class Constants{ |
只发布新版Infra
程序集,而app
程序集不重新构建,那么会出现问题。
你想看到的是;
1 | start is 6 |
然而结果是:
1 | start is 6 |
这是因为const
常量在app
程序集中调用时,编译器直接写入了10这个字面量,而非引用end
常量存放的空间。如果修改值需要重新编译所有用到该常量的代码。
而start
是readonly
声明的,运行时才加以解析,使得不需要重新编译app
程序集就可以看到新的常量值。
不过对于版本号这种跟随程序集的常量,还是使用const
比较好,这样修改的话不会导致其他程序集引用该值的地方都变成最新的值。
总结来说,const
适合必须在编译期确定的值,如attribute参数、switch case标签、enum定义。除此之外,都应该考虑更加灵活的readonly
常量。
3.优先考虑is或as运算符,少用强制类型转换
如果参数类型写成了object
,那么可能需要把改参数转换成需要的类型才能继续编写代码。
这时有两种办法,一种是使用as
运算符,另一种是强制类型转换(cast)来绕过编译器检查。在这之前,可以使用is
判断该操作是否合理,再使用这两种办法。
其中,应该优先使用as
运算符。这样会比强制类型转换更安全、更有效率。as
与is
运算符不会考虑用户定义的转换,只有当类型与要转换到的类型符合时才会顺利进行。这种类型转换操作很少会为了类型转换构建新的对象(但如果使用as运算符把装箱的值类型转换成未装箱且可为null的值类型,则会创建新对象)。
下面看个具体例子:
方法一:
1 | object o = Factory.GetObject(); |
方法二:
1 | object o = Factory.GetObject(); |
方法一很显然比第二种更简洁,更好理解,且不需要使用try/catch
结构,开销与代码量更低。
使用方法二不仅要捕获异常,而且要判断t是不是null。因为强制类型转换遇到null并不抛出异常。而使用as操作遇到这两种情况都会返回null,这样使用if(t != null)
就可以概括地处理了。
as
运算符和强制类型转换的最大区别在于如何对待用户定义的转换。as
与is
运算符只会判断操作的对象在运行期是何种类型,除了必要的装箱、拆箱操作,不会执行其他额外操作。反之,强制类型转换则有可能发生用户预期之外的转换,比如数值类型之间的转换。例如可能发生long到short的转换,导致信息丢失。
1 | t = (MyType)st; |
上面这种写法,如果st声明是object
那么可以编译,但运行会抛出异常。而换用下面这种写法,运行时不会抛出异常,只会返回null,这样也不需要额外的try/catch
判断。
1 | t = st as MyType; |
下面看一下什么情况不能用as
。下面这种写法就无法通过编译:
1 | object o = Factory.GetValue(); |
因为int
是值类型,无法保存null。当o不是int
时,as
执行结果时null,而i
是int
,无法保存null的结果,所以编译就发生错误了。有人会认为这里必须使用强制类型转换,并编写异常捕获,实际上并不需要。
依然使用as
,但是不要转为int
,而是可以保存null的int?
类型,同时使用var
让编译器自己选择类型:
1 | object o = Factory.GetValue(); |
也就是说,如果as
运算符左侧的变量是值类型或者可以为null的值类型,那么可以使用这个技巧来实现类型转换。
现在再来考虑一个问题,foreach
循环在转换类型时用的是as
还是cast
?这里的循环指的是非泛型IEnumerable
序列,它会在迭代过程中自动转换类型。当然在可选的情况下,还是要尽量采用类型安全的泛型版本。
答案是cast
。它会把对象从object
类型转换成需要的类型。
最后,要判断对象是不是某个类型,可以使用is
运算符,而且该运算符遵循多态原则。
总结一下,在面向对象语言中,应该尽量避免类型转换,而在必要的时刻,应该采用as
与is
运算符清晰地表达代码的意图。
4.用内插字符串取代string.Format()
内插字符串用法:
1 | Console.WriteLine($"Pi = {Math.PI}"); |
好处在于:
- 相比
format
更符合人的逻辑 - 不需要检查变量个数与顺序
要注意的是,上述代码字符串内插操作会调用一个参数为params
对象数组的格式化方法。而Math.PI
是double
类型,也是值类型,因此必须自动转换为Object
。这样转换会导致装箱,为了避免装箱,下面的写法是更佳的操作:
1 | Console.WriteLine($"Pi = {Math.PI.ToString()}"); |
如果需要格式化数值:
1 | Console.WriteLine($"Pi = {Math.PI.ToString("F2")}"); |
如果需要把返回值格式化,使用C#格式说明符如:F2
即可:
1 | Console.WriteLine($"Pi = {Math.PI:F2}"); |
如果需要用三目表达式,这样会编译错误,因为会把冒号认为是格式说明符的前导字符:
1 | Console.WriteLine($"Pi = {round ? Math.PI.ToString() : Math.PI.ToString("F2") }"); |
改成下面这样即可,加上小括号强制让编译器将其理解为条件表达式:
1 | Console.WriteLine($"Pi = {(round ? Math.PI.ToString() : Math.PI.ToString("F2"))}"); |
5.避免装箱和拆箱
C#中值类型和引用类型的最终基类都是Object类型(它本身是一个引用类型)。
也就是说,值类型也可以当做引用类型来处理。而这种机制的底层处理就是通过装箱和拆箱的方式来进行,利用装箱和拆箱功能,可通过允许值类型的任何值与Object 类型的值相互转换,将值类型与引用类型链接起来 。
例如:
1 | int val = 100; |
这是一个装箱的过程,是将 值类型 转换为 引用类型 的过程。
1 | int val = 100; |
这是一个拆箱的过程,是将值类型转换为引用类型,再由引用类型转换为值类型的过程 。
注:被装过箱的对象才能被拆箱。
也就是说,要避免编译器把值类型转换为Object。可行的办法是手工地将值类型转为string,再传给Console.WriteLine
。
换句话说,不要在本来应该使用Object的地方使用值类型的值。