Effective C# 之 C#的编程习惯

目前网络上的《Effective C#》笔记都是基于07年出版的原书第二版的翻译版本,如今很多理论已经过时,而最新的第三版的中文版已经在18年出版,因此这里分享一下第一章节C#的编程习惯的读书笔记。

这本书其实主要讲的是改善C#代码的50个方法,相比《Effective C++》是有差距的,但是同样很有用。第一章节的C#编程习惯讲了一些C#编程规范。

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

优先使用隐式类型的局部变量(注意不是总是使用),即使用var声明变量。

1
2
//int count = 0;
var count = 0;

这样不需要考虑变量的类型,因为通过初始化语句往往可以直接看出。而且即使是通过方法初始化,也可以通过使用合适的变量名、方法名来方便判断。

举一个具体的例子,在使用LINQ语句时,某些查询操作所获得结果是IQueryable<T>,而有些则返回IEnumberable<T>。如果都当做后者处理,性能上会差一些,因为IQueryable<T>对数据的查询做了优化。使用var来让编译器推断类型,反而获得了性能优势。

具体代码如下:

1
2
3
4
5
public IEnumerable<string> FindCustomersStartingWith(stirng start){
IEnumerable<string> q = from c in db.Customers select c.ContactName;
var q2 = q.Where(s => s.StartsWith(start));
return q2;
}

这段代码有严重的性能问题。其中的查询语句会把每个人的姓名从数据库中取出,返回值实际上应该是IQueryable<T>,但开发者却保存为IEnumberable<T>,而由于IQueryable<T>继承自IEnumberable<T>,因此不会报错,但这样却导致后续代码无法使用IQueryable<T>的特性。比如下面q2本可以使用Querable.Where去查询,却使用了Enumerable.Where。实际上IQueryable<T>能够把数据查询相关的多个表达式树组合成一项操作,使得一次性在存放数据的远程服务器上执行完毕再返回,而IEnumberable<T>却需要查询到本地之后才能再执行第二条查询语句,一方面浪费了网络流量、另一方面大大降低了查询效率。

修改为下面这样,是更简洁也更好的写法:

1
2
3
4
5
public IEnumerable<string> FindCustomersStartingWith(stirng start){
var q = from c in db.Customers select c.ContactName;
var q2 = q.Where(s => s.StartsWith(start));
return q2;
}

当然,var也不能滥用,如果过多使用var会导致其他开发者难以理解代码类型。同时在使用数值型类型变量int、float、double等时,如果涉及到数值转换(尤其是窄化转换,如long到int),请不要使用var,它可能会导致精度降低。

例如:

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

假设GetMagicNumber()返回值是10,那么total的值是多少取决于GetMagicNumber()的返回值类型。

如果你不清楚GetMagicNumber()的返回值类型,就有可能计算出不符合需求的total。比如返回值是Double和Int32,那么最终的total分别是166.666666666667和166。

这个问题其实不是var fvar total引发的,而是阅读代码的人不知道GetMagicNumber()的返回值类型,也不知道运行过程中GetMagicNumber()导致的默认的数值转换。

这个例子下,使用var很显然可能导致代码维护困难,这类场合就不适合使用。

而一般情况下,即使你明确了变量类型,也未必能确保类型安全或者保证代码更易读懂,而选择了错误的类型反而会导致程序效率降低,这样不如使用var让编译器自动选择。

2.考虑用readonly代替const

C#有两种常量,一种是编译期常量,使用const;另一种是运行期常量,使用readonly

编译期常量虽然会使得程序运行稍快那么一点点,但是远不如运行期常量灵活。只有程序性能极端重要,并且常量取值不会随版本变化时,才考虑使用const

1
2
3
4
//编译期常量
public const int Millennium = 2000;
//运行期常量
public static readonly int thisYear = 2004;

const可以在方法里声明,而readonly不可以。

同时,两种常量支持的值也不一样,const只支持内置的整数、浮点数、枚举、字符串。并且const常量只能用数字、字符串、null来初始化。readonly常量执行完构造函数之后就不能再修改了,但是它是在程序运行时初始化的,因此更加灵活。

其中一个好处在于,readonly常量的类型不受限制。比如DateTime类型常量,不能用const声明,但是可以用readonly声明。

此外,在跨程序集使用常量时,使用readonly可以避免不必要的编译。

例子:

一个名为Infra的程序集中同时使用constreadonly定义常量:

1
2
3
4
public class Constants{
public static readonly int start = 5;
public const int end = 10;
}

另一个名为app的程序集里引用了这两个字段:

1
2
Console.WriteLine("start is {0}", Constants.start);
Console.WriteLine("end is {0}", Constants.end);

测试输出结果是

1
2
start is 5
end is 10

但如果过段时间修改Infra程序集的代码:

1
2
3
4
public class Constants{
public static readonly int start = 6;
public const int end = 11;
}

只发布新版Infra程序集,而app程序集不重新构建,那么会出现问题。

你想看到的是;

1
2
start is 6
end is 11

然而结果是:

1
2
start is 6
end is 10

这是因为const常量在app程序集中调用时,编译器直接写入了10这个字面量,而非引用end常量存放的空间。如果修改值需要重新编译所有用到该常量的代码。

startreadonly声明的,运行时才加以解析,使得不需要重新编译app程序集就可以看到新的常量值。

不过对于版本号这种跟随程序集的常量,还是使用const比较好,这样修改的话不会导致其他程序集引用该值的地方都变成最新的值。

总结来说,const适合必须在编译期确定的值,如attribute参数、switch case标签、enum定义。除此之外,都应该考虑更加灵活的readonly常量。

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

如果参数类型写成了object,那么可能需要把改参数转换成需要的类型才能继续编写代码。

这时有两种办法,一种是使用as运算符,另一种是强制类型转换(cast)来绕过编译器检查。在这之前,可以使用is判断该操作是否合理,再使用这两种办法。

其中,应该优先使用as运算符。这样会比强制类型转换更安全、更有效率。asis运算符不会考虑用户定义的转换,只有当类型与要转换到的类型符合时才会顺利进行。这种类型转换操作很少会为了类型转换构建新的对象(但如果使用as运算符把装箱的值类型转换成未装箱且可为null的值类型,则会创建新对象)。

下面看个具体例子:

方法一:

1
2
3
4
5
object o = Factory.GetObject();
MyType t = o as MyType;
if(t != null){
// do sth
}

方法二:

1
2
3
4
5
6
7
8
9
10
object o = Factory.GetObject();
try{
MyType t = (MyType)o;
if(t != null){
// do sth
}
}
catch(InvalidCastException){
//
}

方法一很显然比第二种更简洁,更好理解,且不需要使用try/catch结构,开销与代码量更低。

使用方法二不仅要捕获异常,而且要判断t是不是null。因为强制类型转换遇到null并不抛出异常。而使用as操作遇到这两种情况都会返回null,这样使用if(t != null)就可以概括地处理了。

as运算符和强制类型转换的最大区别在于如何对待用户定义的转换。asis运算符只会判断操作的对象在运行期是何种类型,除了必要的装箱、拆箱操作,不会执行其他额外操作。反之,强制类型转换则有可能发生用户预期之外的转换,比如数值类型之间的转换。例如可能发生long到short的转换,导致信息丢失。

1
t = (MyType)st;

上面这种写法,如果st声明是object那么可以编译,但运行会抛出异常。而换用下面这种写法,运行时不会抛出异常,只会返回null,这样也不需要额外的try/catch判断。

1
t = st as MyType;

下面看一下什么情况不能用as。下面这种写法就无法通过编译:

1
2
object o = Factory.GetValue();
int i = o as int;//无法编译

因为int是值类型,无法保存null。当o不是int时,as执行结果时null,而iint,无法保存null的结果,所以编译就发生错误了。有人会认为这里必须使用强制类型转换,并编写异常捕获,实际上并不需要。

依然使用as,但是不要转为int,而是可以保存null的int?类型,同时使用var让编译器自己选择类型:

1
2
3
4
5
object o = Factory.GetValue();
var i = o as int?;
if(i != null){
// do sth
}

也就是说,如果as运算符左侧的变量是值类型或者可以为null的值类型,那么可以使用这个技巧来实现类型转换。

现在再来考虑一个问题,foreach循环在转换类型时用的是as还是cast?这里的循环指的是非泛型IEnumerable序列,它会在迭代过程中自动转换类型。当然在可选的情况下,还是要尽量采用类型安全的泛型版本。
答案是cast。它会把对象从object类型转换成需要的类型。

最后,要判断对象是不是某个类型,可以使用is运算符,而且该运算符遵循多态原则。

总结一下,在面向对象语言中,应该尽量避免类型转换,而在必要的时刻,应该采用asis运算符清晰地表达代码的意图。

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

内插字符串用法:

1
Console.WriteLine($"Pi = {Math.PI}");

好处在于:

  1. 相比format更符合人的逻辑
  2. 不需要检查变量个数与顺序

要注意的是,上述代码字符串内插操作会调用一个参数为params对象数组的格式化方法。而Math.PIdouble类型,也是值类型,因此必须自动转换为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
2
3
int val = 100; 
object obj = val;
Console.WriteLine ("对象的值 = {0}", obj); //对象的值 = 100

这是一个装箱的过程,是将 值类型 转换为 引用类型 的过程。

1
2
3
4
int val = 100; 
object obj = val;
int num = (int) obj;
Console.WriteLine ("num: {0}", num); //num: 100

这是一个拆箱的过程,是将值类型转换为引用类型,再由引用类型转换为值类型的过程 。
注:被装过箱的对象才能被拆箱。

也就是说,要避免编译器把值类型转换为Object。可行的办法是手工地将值类型转为string,再传给Console.WriteLine

换句话说,不要在本来应该使用Object的地方使用值类型的值。