【读书笔记】《Effective C#》50条建议笔记整理

简介: 对《Effective C#:改善C#代码的50个有效方法》一书整理的读书笔记。

@[TOC]

前言

《Effective C#》是.NET专家Bill Wanger给出我们50条利用C#优点以及特性来写出健壮的,高效的,易于维护的代码的高效法则;自己在阅读完这本书后对这本书中的50条建议较为精华的结论进行整理,方便自己学习的同时分享出来。

第一章、C#语言的编程习惯

  • 能用的东西为什么要改?因为改了之后效果更好。开发者换用其他工具或语言来编程也是这个道理,因为换了之后工作效率更高。
  • 如果不肯改变现有的习惯,那么就体会不到新技术的好处。
  • 如果你是从其他语言转入C#的,那么需要学习C#语言自己的编程习惯,使得这门语言能够促进你的工作,而不是阻碍你的工作。

第1条:优先使用隐式类型的局部变量

  • 优先使用隐式类型var来声明变量而不指明其类型,这样的好处有
  • 可以令开发者把注意力更多地集中在名称上面,从而更好地了解其含义。
  • 不用去操心程序中使用了不合适的类型,编译器会自动选择合适的类型。
  • 好的变量命名可以提高可读性,合适的推导类型会提高开发效率。
var HighestSellingProduct = someObject.DoSomeWork(anotherParameter);
  • var类型不能盲目使用,对于int、float、double等数值型的变量,就应该明确指出其类型。

第2条:考虑用readonly代替const

  • C#有两种常量,编译期的常量const和运行期的常量readonly。
  • 编译期的常量const只能用来表示内置的整数、浮点数、枚举或字符串。
  • 编译期的常量const虽然性能高点,但却远不如运行期的常量readonly来的灵活。
  • const关键字用来声明那些必须在编译期得以确定的值,例如attribute的参数、switch case语句的标签、enum的定义等,偶尔还用来声明那些不会随着版本而变化的值。除此之外的值则应该考虑声明成更加灵活的readonly常量。

    • 开发者确实想把某个值在编译期固定下来就使用const类型,否则就使用更灵活,兼容性更好的readonly类型。
// 编译时常量:
public const int Millennium = 2000;

// 运行时常量:
public static readonly int ThisYear = 2004;

第3条:优先考虑is或as运算符,尽量少用强制类型转换

  • 使用面向对象语言来编程序的时候,应该尽量避免类型转换操作,但总有一些场合是必须转换类型的。
  • 采用as运算符来实现类型转换更加安全可读性更高,而且在运行的时候也更有效率。
  • 尽量采用as来进行类型转换,因为这么做不需要编写额外的try/catch结构来处理异常。
  • 如果想判断对象是不是某个具体的类型而不是看它能否从当前类型转换成目标类型,那么可以使用is运算符。
t = (MyType)st;
t = st as MyType; 

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

  • 内插字符串以$开头,相比较于String.Format()方法的序号数量和参数个数不相等就会出错的情况,内插字符串代码的可读性更高。
  • 内插字符串不像传统的格式字符串那样把序号放在一对花括号里面,并用其指代params数组中的对应元素,而是可以直接在花括号里面编写C#表达式。
Console.WriteLine($"The customer's name is {c?.Name ?? "Name is missing"}");

第5条:用FormattableString取代专门为特定区域而写的字符串

  • 如果程序只是针对当前区域而生成文本,那么直接使用内插字符串就够了,这样反而可以避免多余的操作。
  • 如果需要针对特定的地区及语言来生成字符串,那么就必须根据内插字符串的解读结果来创建FormattableString,并将其转换成适用于该地区及该语言的字符串。
FormattableString second = $"It's the {DateTime.Now.Day} of the {DateTime.Now.Month} month"; 

第6条:不要用表示符号名称的硬字符串来调用API

  • nameof()表达式这个关键字可以根据变量来获取包含其名称的字符串,使得开发者不用把变量名直接写成字面量。
  • 使用nameof运算符的好处是 ,如果符号改名了,那么用nameof来获取符号名称的地方也会获取到修改之后的新名字。
  • 这种写法可以保留较多的符号信息,使得自动化工具能够多发现并多修复一些错误,从而令开发者可以专心解决那些更为困难的问题。如果不这样做,那么有些错误就只能通过自动化测试及人工检查才能寻找出来。
  • 在下面的代码中,如果属性名变了,那么用来构造Property-ChangedEventArgs对象的参数也会随之变化。
Public String Name
{
    get { return name; }
    set 
    {
        if (value != name)
        { 
            name = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
        }
    }
}

第7条:用委托表示回调

  • 回调就是这样一种由服务端向客户端提供异步反馈的机制,它可能会涉及多线程(multithreading),也有可能只是给同步更新提供入口。C#语言用委托来表示回调。
  • 通过委托,可以定义类型安全的回调。最常用到委托的地方是事件处理,然而除此之外,还有很多地方也可以用。
  • 委托是一种对象,其中含有指向方法的引用,这个方法既可以是静态方法,又可以是实例方法。开发者可以在程序运行的时候配置一个或多个客户对象,并与之通信。
  • 可以直接用lambda表达式来表示委托。此外,.NET Framework库也用Predicate\<T>、Action\<T>及Func\<T>定义了很多常见的委托形式。
  • 由于历史原因,所有的委托都是多播委托(multicast delegate),也就是会把添加到委托中的所有目标函数(target function)都视为一个整体去执行。
  • 总之,如果要在程序运行的时候执行回调,那么最好的办法就是使用委托,因为客户端只需编写简单的代码,即可实现回调。委托的目标可以在运行的时候指定,并且能够指定多个目标。在.NET程序里面,需要回调客户端的地方应该考虑用委托来做。

第8条:用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);
}

public void RaiseUpdates()
{
    counter++;
    Updated?.Invoke(this, counter);
}
  • 第一种写法有个明显的问题:如果在对象上面触发Updated事件时并没有事件处理程序与之相关,那么就会发生NullReferenceException。
  • 第二种写法有个隐藏的bug,当程序中的线程执行完那行if语句并发现Updated不等于null之后,可能会有另一个线程打断该线程,并将唯一的那个事件处理程序解除订阅,这样的话,等早前的线程继续执行Updated(this,counter);语句时,事件处理程序就变成了null,调用这样的处理程序会引发NullReferenceException。当然,这种情况较为少见,而且不容易重现。
  • 第三种写法没有错,但是.NET开发新手却很难看懂,而且以后凡是要触发事件的地方就都得按这种写法重复一遍才行。
  • 第四种写法是最正统的,这段代码采用null条件运算符(也就是?.)安全地调用事件处理程序。该运算符首先判断其左侧的内容,如果发现这个值不是null,那就执行右侧的内容。反之,若为null,则跳过该语句,直接执行下一条语句。
  • 有了这种简单而清晰的新写法(第四种)之后,原来的老习惯就需要改一改了。以后在触发事件的时候,都应该采用这种写法。

第9条:尽量避免装箱与取消装箱这两种操作

  • 装箱的过程是把值类型放在非类型化的引用对象中,使得那些需要使用引用类型的地方也能够使用值类型。取消装箱则是把已经装箱的那个值拷贝一份出来。
  • 如果要在只接受System.Object类型或接口类型的地方使用值类型,那就必然涉及装箱及取消装箱。但这两项操作都很影响性能。
  • 有的时候还需要为对象创建临时的拷贝,而且容易给程序引入难于查找的bug。
  • 值类型可以转换成指向System.Object或其他接口的引用,但由于这种转换是默默发生的,因此一旦出现问题就很难排查。
  • 把值类型的值放入集合、用值类型的值做参数来调用参数类型为System.Object的方法以及将这些值转为System.Object等。这些做法都应该尽量避免。

在这里插入图片描述

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

  • 重新定义非虚方法可能会使程序表现出令人困惑的行为,以导致出现难以排查的bug;
  • 虚方法是动态绑定的,也就是说,要到运行的时候才会根据对象的实际类型来决定应该调用哪个版本;
  • 应该花时间想想:究竟有哪些方法与属性是应该设置成多态的,然后仅仅把这些内容用virtual加以修饰;
  • 唯一一种应该使用new修饰符的情况是:新版的基类里面添加了一个方法,而那个方法与你的子类中已有的方法重名了
  • 只有当基类所引入的新成员与子类中的现有成员冲突时,才可以考虑运用该修饰符,但即便在这种特殊的情况下,也得仔细想想使用它所带来的后果。除此之外的其他情况决不应该使用new修饰符。
public class BaseWidget
{
    public void NormalizerValues()
    {
        // 省略细节
    }
}

public class MyWidget : BaseWidget
{
    public new void NormalizerValues()
    {
        // 省略细节
        // 只在(运气)情况下调用基类        
        // 新方法做同样的操作
        base.NormalizerValues();
    }
}

第二章、.NET的资源管理

  • .NET程序运行在托管环境(managed environment)中,这对C#程序的高效设计方式有很大的影响。
  • 开发者必须从.NET CLR(Common Language Runtime,公共语言运行时)的角度来思考,才可以充分发挥这套环境的优势,而不能完全沿用其他开发环境下的想法。

第11条:理解并善用.NET的资源管理机制

  • 要想写出高效的程序,开发者就需要明白程序所在的这套环境是如何处理内存与其他重要资源的。
  • 与资源管理功能较少的环境相比,.NET环境会提供垃圾回收器(GC)来帮助你控制托管内存,这使得开发者无须担心内存泄漏、迷途指针(dangling pointer)、未初始化的指针以及其他很多内存管理问题。
  • 为了防止资源泄漏,非内存型的资源(nonmemory resource)必须由开发者释放,于是会促使其创建finalizer来完成该工作。
  • 考虑实现并运用IDisposable接口,以便在不给GC增加负担的前提下把这些资源清理干净。

第12条:声明字段时,尽量直接为其设定初始值

  • 类的构造函数通常不止一个,构造函数变多了之后,开发者就有可能忘记给某些成员变量设定初始值。为了避免这个问题,最好是在声明的时候直接初始化,而不要等实现每个构造函数的时候再去赋值。
  • 成员变量的初始化语句可以方便地取代那些本来需要放在构造函数里面的代码,这些语句的执行时机比基类的构造函数更早,它们会按照本类声明相关变量的先后顺序来执行。
public class MyClass
{
    //声明集合,并初始化它。
    private List<string> labels = new List<string>();       
}

有三种情况是不应该编写初始化语句的:

  • 第一种情况:对象初始化为0或null的时候,系统在运行时本身就会初始化逻辑,生成指令会把整块内存全都设置成0,初始化会显得多余和降低性能。
  • 第二种情况:如果不同的构造函数需要按照各自的方式来设定某个字段的初始值,那么这种情况下就不应该再编写初始化语句了,因为该语句只适用于那些总是按相同方式来初始化的变量。
  • 第三种情况:如果初始化变量的过程中有可能出现异常,那么就不应该使用初始化语句,而是应该把这部分逻辑移动到构造函数里面。

第13条:用适当的方式初始化类中的静态成员

  • 创建某个类型的实例之前,应该先把静态的成员变量初始化好,这在C#语言中可以通过静态初始化语句及静态构造函数来做。
  • 静态构造函数是特殊的函数,会在初次访问该类所定义的其他方法、变量或属性之前执行,可以用来初始化静态变量、实现单例(singleton)模式,或是执行其他一些必要的工作,以便使该类能够正常运作。
  • 如果静态字段的初始化工作比较复杂或是开销比较大,那么可以考虑运用Lazy\<T>机制,将初始化工作推迟到首次访问该字段的时候再去执行。
  • 若是必须通过复杂的逻辑才能完成初始化,则应考虑创建静态构造函数
public class MySingleton2
{
    private static readonly MySingleton2 theOneAndOnly;
    
    static MySingleton2()
    {
        theOneAndOnly = new MySingleton2();  
    }

    public static MySingleton2 TheOnly
    {
        get { return theOneAndOnly; }   
    }

    private MySingleton2()
    {

    }
    // 剩余部分省略
}
  • 用静态构造函数取代静态初始化语句一般是为了处理异常,因为静态初始化语句无法捕捉异常,而静态构造函数却可以。
static MySingleton2()
{
    try
    {
        theOneAndOnly = new MySingleton2();
    }
    catch
    {
        // 这里尝试恢复
    }
}
  • 要想为类中的静态成员设定初始值,最干净、最清晰的办法就是使用静态初始化语句及静态构造函数,因为这两种写法比较好懂,而且不容易出错。

第14条:尽量删减重复的初始化逻辑

  • 如果这些构造函数都会用到相同的逻辑,那么应该把这套逻辑提取到共用的构造函数中(并且令其他构造函数直接或间接地调用该函数)。这样既可以减少重复的代码,又能够令C#编译器根据这些初始化命令生成更为高效的目标代码。
public class MyClass
{
    // 收集数据
    private List<ImportantData> coll;
    // 实例的名称;
    private string name;    

    // 需要满足new()约束;
    public MyClass() : this(0, string.Empty)
    { 
    }

    public MyClass(int initialCount = 0, string name = "")
    {
        coll = (initialCount > 0) ?
            new List<ImportantData>(initialCount) :
            new List<ImportantData>();
        this.name = name; 
    }
}
  • 采用默认参数机制来编写构造函数是比较好的做法,但是有些API会使用反射(reflection)来创建对象,它们需要依赖于无参的构造函数,这种函数与那种所有参数都具备默认值的构造函数并不是一回事,因此可能需要单独提供。
  • 下面列出构建某个类型的首个实例时系统所执行的操作:

    • 把存放静态变量的空间清零。
    • 执行静态变量的初始化语句。
    • 执行基类的静态构造函数。
    • 执行(本类的)静态构造函数。
    • 把存放实例变量的空间清零。
    • 执行实例变量的初始化语句。
    • 适当地执行基类的实例构造函数。
    • 执行(本类的)实例构造函数。

第15条:不要创建无谓的对象

  • 垃圾回收器可以帮你把内存管理好,并高效地移除那些用不到的对象,但这并不是在鼓励你毫无节制地创建对象。
  • 因为创建并摧毁一个基于堆(heap-based)的对象无论如何都要比根本不生成这个对象耗费更多的处理器时间。在方法中创建很多局部的引用对象可能会大幅降低程序的性能。
  • 如果局部变量是引用类型而非值类型,并且出现在需要频繁运行的例程(routine)中,那就应该将其提升为成员变量;要避免的是频繁创建相同的对象,而不是说必须把每个局部变量都转化为成员变量。
  • 这两项技巧都可以令程序在运行过程中尽量少分配一些对象

    • 第一项技巧是把经常使用的局部变量提升为成员变量.
    • 第二项技巧是采用依赖注入(dependency injection)的办法创建并复用那些经常使用的类实例。
  • 如果最终要构建的字符串很复杂,不太方便用内插字符串实现,那么可以考虑改用StringBuilder处理,这是一种可变的字符串,提供了修改其内容的机制,使得开发者能够以此来构建不可变的string对象。
string msg = string.Format("Hello,{0}. Today is {1}", thisUser.Name, DateTime.Now.ToString());
  • 垃圾回收器能够有效地管理应用程序所使用的内存,但是要注意,在堆上创建并销毁对象仍需耗费一定的时间,因此,不要过多地创建对象,也不要创建那些根本不用去重新构建的对象。

第16条:绝对不要在构造函数里面调用虚函数

  • 在构建对象的过程中调用虚函数总是有可能令程序中的数据混乱。
  • 在(基类的)构造函数里面调用虚函数会令代码严重依赖于派生类的实现细节,而这些细节是无法控制的,因此,这种做法很容易出问题
  • Visual Studio所附带的FxCop及Static Code Analyzer等工具都会将其视为潜在的问题。(这两款插件可能会解决问题)

第17条:实现标准的dispose模式

  • 前面说过(第11条),如果对象包含非托管资源,那么一定要正确地加以清理。
  • 这样做虽然有可能令程序的性能因执行finalizer而下降,但毕竟可以保证垃圾回收器能够把资源回收掉。
  • 如果你的类本身不包含非托管资源,那就不用编写finalizer,但若是包含这种资源的话,则必须提供finalizer,因为你不能保证该类的使用者总是会调用Dispose()方法。
  • 实现IDisposable.Dispose()方法时,要注意以下四点:

    • 把非托管资源全都释放掉。
    • 把托管资源全都释放掉(这也包括不再订阅早前关注的那些事件)。
    • 设定相关的状态标志,用以表示该对象已经清理过了。
    • 阻止垃圾回收器重复清理该对象。这可以通过GC.SuppressFinalize(this)来完成。
  • 正确实现IDisposable接口是一举两得的事情,因为它既提供了适当的机制使得托管资源能够及时释放,又令客户端可以通过标准的Dispose()方法来释放非托管型的资源。
  • 编写finalizer时,一定要仔细检查代码,而且最好能把Dispose方法的代码也一起检查一遍。如果发现这些代码除了释放资源之外还执行了其他的操作,那就要再考虑考虑了。这些操作以后有可能令程序出bug,最好是现在就把它们从方法中删掉,使得finalizer与Dispose()方法只用来释放资源。
public class BadClass
{
    //存储全局对象的引用:
    private static readonly List<BadClass> finalizedList = new List<BadClass>();
    private string msg;   

    public BadClass(string msg)
    {
        //捕获引用:        
        msg = (string)msg.Clone();  
    }
    
    ~BadClass() 
    {
        //将该对象添加到列表
        //该对象是可达的
        finalizedList.Add(this);
    }
}

第三章、合理地运用泛型

  • 泛型还有很多种用法,例如可以用来编写接口、事件处理程序以及通用的算法,等等。
  • 定义泛型类型可能会增加程序的开销,但也有可能给程序带来好处。用泛型来编程有的时候会令程序码更加简洁,有的时候则会令其更加臃肿。
  • 泛型类的定义(generic class definition)属于完全编译的MSIL类型,其代码对于任何一种可供使用的类型参数来说都必须完全有效。
  • 对于泛型类型来说,若所有的类型参数都已经指明,那么这种泛型类型称为封闭式泛型类型(closed generic type),反之,仅指出了某些参数,则称为开放式泛型结构(open generic type)。
  • 与真正的类型相比,IL形式的泛型只是定义好了其中的某一部分而已。必须把里面的占位符替换成具体的内容才能令其成为完备的泛型类型(completed generic type)。

第18条:只定义刚好够用的约束条件

  • 泛型定义太宽或太严都不合适。
  • 你可以用约束来表达自己对泛型类型中的类型参数所提的要求,这些要求对编译器与使用该类的其他开发者都会带来影响。
  • 还有一种约束条件需要谨慎地使用,那就是new约束,有的时候可以去掉这条约束,并将代码中的new()改为default()。后者是C#的运算符,用来针对某个类型产生默认值,值类型则为0,引用类型则为null。对于引用类型来说,new()与default()有很大的区别。
public static T FirstOrDefault<T>(this IEnumerable<T> sequence, Predicate<T> test)
{
    foreach(T value in sequence)
    {
        if (test(value))
            return value; 
    }
    
    return default(T); 
}
  • 编译器能够保证使用这个泛型类型的人所提供的类型参数一定会满足这些条件。例如你可以规定类型参数必须是值类型(struct)或必须是引用类型(class),还可以规定它必须实现某些接口或是必须继承自某个基类(这当然意味着它必须首先是个类才行)。
  • 如果不采用约束来表达这些要求,那么就得执行大量的强制类型转换操作,并在程序运行的时候做很多测试。

第19条:通过运行期类型检查实现特定的泛型算法

  • 只需要指定新的类型参数,就可以复用泛型类,这样做会实例化出一个功能相似的新类型。
  • 但问题在于,其实很多时候在复用泛型类时会出现功能高度相似的时候,这显得完全没有必要,于是开发者需要在复用泛型类同时加上特定的泛型算法,示例代码如下:
public ReverseEnumerable(IEnumerable<T> sequence)
{
    sourceSequence = sequence;
    // 如果序列没有实现IList<T>
    // originalSequence是null,所以这是可行的
    // 实现
    originalSequence = sequence as IList<T>; // as的用法在第3条
}
  • 开发者既可以对泛型参数尽量少施加一些硬性的限制,又能够在其所表示的类型具备丰富的功能时提供更好的实现方式。为了达到这种效果,你需要在泛型类的复用程度与算法面对特定类型时所表现出的效率之间做出权衡。

第20条:通过IComparable及IComparer定义顺序关系

  • .NET Framework引入了两种用来定义执行排序与搜索关系的接口,即IComparable\<T>及IComparer\<T>。前者用来规定某类型的各对象之间所具备的自然顺序(natural order),后者用来表示另一种排序机制可以由需要提供排序功能的类型来实现。
  • IComparable接口只有一个方法,就是CompareTo(),该方法遵循长久以来所形成的惯例:若本对象小于另一个受测对象,则返回小于0的值;若相等,则返回0;若大于那个对象,则返回大于0的值。
  • 在.NET环境中,比较新的API大都使用泛型版的IComparable接口,但老一些的API用的则是不带泛型的IComparable接口,因此,实现前者的时候应该同时实现后者。
  • 既然非泛型版的IComparable有这么多缺点,那为什么还要实现它呢?这有两个原因。第一个原因很简单:为了保持向后兼容(backward compatibility)。第二个原因在于,这样写,可以满足那些确实需要使用该方法的人,同时又能够把无意中的错误用法拦截下来。

第21条:创建泛型类时,总是应该给实现了IDisposable的类型参数提供支持

  • 为泛型类指定约束条件会对开发者自身及该类的用户产生两方面影响。

    • 第一,会把程序在运行的时候有可能发生的错误提前暴露于编译期。
    • 第二,相当于明确告诉该类的用户在通过泛型类来创建具体的类型时所提供的类型参数必须满足一定的要求。
  • 如果你在泛型类里面根据类型参数创建了实例,那么就应该判断该实例所属的类型是否实现了IDisposable接口。如果实现了,就必须编写相关的代码,以防程序在离开泛型类之后发生资源泄漏。
  • 泛型类本身也可能需要以惰性初始化的形式根据类型参数去创建实例,并实现IDisposable接口,这需要多写一些代码,然而如果想创建出实用的泛型类,有时就必须这么做才行。

第22条:考虑支持泛型协变与逆变

  • 变体(type variance)机制,尤其是协变(covariance)与逆变(contravariance)确定了某类型的值在什么样的情况下可以转换成其他类型的值。
  • 协变与逆变是指能否根据类型参数之间的兼容情况在两个泛型类之间化约。对于以T为类型参数的泛型类型C\<T>来说,如果在X可以转换为Y的前提下能够把C\<X>当成C\<Y>来用,那么该泛型对T协变。如果在Y可以转换为X的前提下能够把C\<X>当成C\<Y>来用,那么该泛型对T逆变。
  • C#语言允许开发者在泛型接口与委托中运用in与out修饰符,以表达它们与类型参数之间的逆变及协变关系。
public interface ICovariantDelegates<out T>
{
    T GetAnItem(); 
    Func<T> GetAnItemFactory(); 
    void GiveItemLater(Action<T> whatToDo);
}

public interface IContravariantDelegates<in T>
{
    void ActOnAnItem(T item); 
    void GetAnItemFactory(Func<T> item);
    Action<T> ActOnAnItemLater(); 
} 

第23条:用委托要求类型参数必须提供某种方法

  • C#为开发者所提供的约束似乎比较有限,你只能要求某个泛型参数所表示的类型必须继承自某个超类、实现某个接口、必须是引用类型、必须是值类型或是必须具备无参数的构造函数。
  • 你可能会要求用户提供的类型必须支持某种运算符、必须拥有某个静态方法、必须与某种形式的委托相符或是必须能够以某种方式来构造,这些要求其实都可以用委托来表示。也就是说,你可以定义相应的委托类型,并要求用户在使用泛型类的时候必须提供这样的委托对象。
  • 现在就以Add()为例来谈谈这个问题,首先,创建IAdd接口,其次,编写代码,给类型参数施加约束,规定其必须实现该接口。
public static class Example
{
   public static T Add<T>(T left, T right, Func<T, T, T> AddFunc) => AddFunc(left, right);
}
    int a = 6; 
    int b = 7;  
    int sum = Example.Add(a, b, (x, y) => x+y);
  • 总之,如果你在设计泛型的时候需要对用户所提供的类型提出要求,但这种要求又不便以C#内置的约束条件来表达,那么就应该考虑通过其他办法(委托)来保证这一点,而不能放弃这项要求。

第24条:如果有泛型方法,就不要再创建针对基类或接口的重载版本

  • 与基类版本同泛型版本之间的优先顺序相似,接口版本与泛型版本之间的优先顺序也有可能令人困惑。
  • 一般来说,在已经有了泛型版本的前提之下,即便想要给某个类及其子类提供特殊的支持,也不应该轻易去创建专门针对该类的重载版本。这条原则同样适用于接口。
  • 如果想专门针对某个接口创建与已有的泛型方法相互重载的方法,那么也必须同时为实现了该接口的所有类型都分别创建对应的方法(使得编译器能够把调用该方法的代码解析到合适的版本上面)。

第25条:如果不需要把类型参数所表示的对象设为实例字段,那么应该优先考虑创建泛型方法,而不是泛型类

  • 用包含大量泛型方法的非泛型工具类实现可能会更加清晰(可读性更高)
  • 用户可能会给出很多套符合约束的泛型参数,而C#编译器则必须针对每一套泛型参数都生成一份完整的IL码,用以表示与这套参数相对应的泛型类。
public static class Utils
{
    public static T Max<T>(T left, T right) => 
        Comparer<T>.Default.Compare(left, right) < 0 ? right : left;

    public static double Max(double left, double right) =>
        Math.Max(left, right);
    // 省略了其他数字类型的版本

    public static T Min<T>(T left, T right) =>
        Comparer<T>.Default.Compare(left, right) < 0 ? left : right; 

    public static double Min(double left, double right) => 
        Math.Min(left, right);
    // 其他数字类型的版本被省略
}
  • 这样做的好处:首先,调用起来比较简单。由于编译器会自动判断出最为匹配的版本,因此无须调用方明确指定。
  • 其次,对于程序库的开发者来说,这样写可以令将来的工作更加灵活。
  • 在两种情况下,必须把类写成泛型类:

    • 第一种情况,该类需要将某个值用作其内部状态,而该值的类型必须以泛型来表达(例如集合类);
    • 第二种情况,该类需要实现泛型版的接口。除此之外的情况,都应该考虑使用包含泛型方法的非泛型类来实现。

第26条:实现泛型接口的同时,还应该实现非泛型接口

  • 由于各种各样的原因,开发者还是必须考虑怎样与非泛型的内容打交道;
  • 这条建议适用于三项内容:一,要编写的类以及这些类所支持的接口;二,public属性;三,打算序列化(serialize)的那些元素。
  • 在绝大多数情况下,如果想给旧版接口提供支持,那么只需要在类里面添加签名正确的方法就可以了。
  • 在实现这些接口时,应该明确加以限定,以防用户在本来打算使用新版接口的时候无意间调用了旧版接口。

第27条:只把必备的契约定义在接口中,把其他功能留给扩展方法去实现

  • 定义接口的时候,只把必备的功能列出来就行了,而其他一些功能则可以在别的类里面以扩展方法的形式去编写,那些方法能够借助原接口所定义的基本功能来完成自身的任务。
  • 这样做使得实现该接口的人只需要给少数几个方法编写代码,而客户端则既可以使用这几个基本方法,又可以使用基于这些方法所开发出来的扩展方法。
  • 有一个问题需要注意:如果已经针对某个接口定义了扩展方法,而其他一些类又想要以它们自己的方式来实现这个扩展方法,那么就有可能产生奇怪的结果。
  • 在实际的编程工作中,应该保证扩展方法的行为与类里面的同名方法相一致。也就是说,如果想在类中以更为高效的算法重新实现早前所定义的扩展方法,那么应该保证其行为与之相同。保证了这一点,就不会影响程序正常运行。

第28条:考虑通过扩展方法增强已构造类型的功能

  • 编写应用程序时,可能需要使用一些采用特定类型参数构造的泛型类型,例如可能需要使用List及Dictionary<EmployeeID,Employee>等形式的集合。之所以创建这种形式的集合,是因为应用程序要向集合中放入特殊类型的元素,因而需要专门针对这样的元素给集合定义一些特殊的功能。
// 下面以IEnumerable<int>为例来列举其中的几个: 
public static class Enumerable
{
    public static int Average(this IEnumerable<int> sequence);
    public static int Max(this IEnumerable<int> sequence);
    public static int Min(this IEnumerable<int> sequence);  
    public static int Sum(this IEnumerable<int> sequence);
    // 省略其他方法 
}
  • 若能将这些方法实现成针对某个泛型类型或泛型接口的扩展方法,则会令那个以特定参数而构造的泛型类型或接口具备丰富的功能。
  • 这样做还可以最大限度地将数据的存储模型(storage model)与使用方式相解耦。

第四章、合理地运用LINQ

  • LINQ的一个目标是令语言中的元件能够在各种数据源上面执行相同的操作。
  • 合理地运用LINQ更加顺畅地处理各种数据源,如果有需要的话,还可以创建自己的数据提供程序(data provider)。

第29条:优先考虑提供迭代器方法,而不要返回集合

  • 迭代器方法是一种采用yield return语法来编写的方法,它会等到调用方请求获取某个元素的时候再去生成序列中的这个元素。
// 下面是个简单的迭代器方法,用来生成由小写英文字母所构成的序列:
public static IEnumerable<char> GenerateAlphabet()
{
    var letter = 'a'; 

    while(letter <= 'z')
    {
        yield return letter;
        letter++;
    }
}
  • 生成该元素的操作只有在调用方真正使用这个元素时才会发生。
  • 只有当调用方真正用到序列中的某个元素时程序才会通过那个对象创建该元素。这使得程序在调用生成器方法(generator method)时只需要执行少量的代码。
  • 缺点:给迭代器方法传入了错误的参数,那么这个错误要等到程序真正使用函数的返回值时才能够暴露,而无法在传入错误参数的时候就直接以异常的形式表现出来。
  • 有没有哪种场合是不适宜用迭代器方法来生成序列的?其实这些问题应该留给调用迭代器方法的人去考虑,你不用刻意去猜测别人会怎样使用你创建的这个方法,因为他们可以自己去决定如何使用该方法所返回的结果。
  • 有了这样的方法,开发者就可以自由选择是通过ToList或ToArray将整个序列都提早生成出来,还是通过你所提供的方法逐个生成并处理序列中的每个元素。

第30条:优先考虑通过查询语句来编写代码,而不要使用循环语句

  • 与采用循环语句所编写的命令式结构相比,查询语句(也包括实现了查询表达式模式(query expression pattern)的查询方法)能够更为清晰地表达开发者的想法。
// 循环语句 
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.Create(x, y);
}
  • 这两种写法看上去差不多,但是后一种写法(查询语句)在问题变得复杂之后依然能够保持简洁。
  • 命令式的写法则必须创建存储空间来保存中间结果。
  • 还有一条理由也能说明查询语句比循环结构要好,因为前者可以创建出更容易拼接的API。
  • 如果你怀疑查询式的写法在某种特定情况下运行得不够快,可以通过.AsParallel()方法来并行地执行这些查询。
  • 编写循环结构时,总是应该想想能不能改用查询语句来实现相同的功能,如果不行,那再想想能不能改用查询方法来写。

第31条:把针对序列的API设计得更加易于拼接

  • 针对整个集合中的每一个元素执行操作,那么程序的效率会很低。
  • 把通用的IEnumerable\<T>或针对某种类型的IEnumerable\<T>设计成方法的输入及输出参数是一种比较少见的思路,因此,很多开发者都不会这样去做,但是这种思路确实能带来很多好处。
  • 迭代器方法会等调用方真正用到某个元素时才去执行相应的代码,与传统的命令式方法相比,这种延迟执行(deferred execution,参见第37条)机制可以降低算法所需的存储空间,并使算法的各个部分之间能够更为灵活地拼接起来(参见第40条)。
// 为了演示迭代器方法的好处,笔者先举一个简单的例子,然后用迭代器方法改写。 
public static void Unique(IEnumerable<int> nums)
{
    var uniqueVals = new HashSet<int>(); 

    foreach(var num in nums)
    {
        if(!uniqueVals.Contains(num))
        {
            uniqueVals.Add(num);
            Console.WriteLine(num); 
        }
    }
}

// 为此,可以考虑改用迭代器方法来实现:
public static IEnumerable<int> UniqueV2(IEnumerable<int> nums)
{
    var uniqueVals = new HashSet<int>();
    foreach (var num in nums)
    {
        if (!uniqueVals.Contains(num))
        {
            uniqueVals.Add(num);
            yield return num; 
        }
    }
}
  • 迭代器方法真正强大之处在于它可以把多个步骤拼接成一整套流程。
  • 如果能把复杂的算法拆解成多个步骤,并把每个步骤都表示成这种小型的迭代器方法,那么就可以将这些方法拼成一条管道,使得程序只需把源序列处理一遍即可对其中的元素执行许多种小的变换。

第32条:将迭代逻辑与操作、谓词及函数解耦

  • 要想把这种算法内部的某些逻辑开放给调用方去定制,只能将这些逻辑表示成方法或函数对象,并传给表示该算法的那个外围方法。具体到C#来说,就是要把那个可供定制的内部逻辑定义成delegate
  • 匿名的委托主要有两种习惯用法,一种是用来表示函数,另一种是用来表示操作。
  • 这样做的的好处主要在于可以把迭代序列时所用的逻辑与处理序列中的元素时所用的逻辑分开。
public static IEnumerable<T> Transform<T>(IEnumerable<T> sequence, Func<T,T> method)
{
    // null检查序列和方法被省略 
    foreach(T element in sequence)
    {
        yield return method(element); 
    }
}
// 写好这个方法之后,可以用下面这行代码对序列中的每个整数取平方,从而令这些平方值构成新的序列: 
foreach (int i in Transform(myInts, value => value * value))
    Console.WriteLine(i);
public static IEnumerable<Tout> Transform<Tin, Tout>(IEnumerable<Tin> sequence, Func<Tin, Tout> method)
{
    // null检查序列和方法被省略
    foreach (Tin element in sequence)
        yield return method(element);
}

foreach (string s in Transform(myInts, value => value.ToString()))
    WriteLine(s); 

第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;
    }
}
  • 在消费该序列的代码真正用到某个元素时再去生成此元素是一种很好的做法,因为如果整个算法只需执行一小部分即可满足消费方的要求,那么就不用再花时间去执行其余那一部分了。
  • 这样做可能只会小幅提升程序的效率,但如果创建元素所需的开销比较大,那么提升的幅度也会很大。

第34条:考虑通过函数参数来放松耦合关系

  • 如果使用委托或其他一些通信机制来放松耦合关系,那么编译器可能就不会执行某些检查工作了,因此,你需要自己设法来做这些检查。
  • 设计组件时,首先还是应该考虑能否把本组件与客户代码之间的沟通方式约定成接口或者采用委托来描述本组件所要使用的方法,那么用起来会更加灵活。根据具体的情况来选择是接口还是委托。
  • 设计组件时,首先还是应该考虑能否把本组件与客户代码之间的沟通方式约定成接口。如果有一些默认的实现代码需要编写,那么可以考虑将其放入抽象基类中,使得调用方无须重新编写这些代码。
  • 如果采用委托来描述本组件所要使用的方法,那么用起来会更加灵活,但开发工具对此提供的支持也会更少,因此,你需要编写更多的代码才能确保这种灵活的设计能够正常运作。

第35条:绝对不要重载扩展方法

  • 在第27与28条说过,针对接口或类型来创建扩展方法有三个好处:

    - 第一,能够为接口实现默认的行为;
    - 第二,能够针对封闭的泛型类型实现某些逻辑;
    - 第三,能够创建出易于拼接的接口方法。
  • 通过扩展方法来编写默认代码是专门针对接口而言的,如果要扩展的是类,那么还有更好的办法可供选用。滥用或误用扩展方法很容易使方法之间产生冲突,从而令代码难于维护
  • 扩展方法并不是根据对象的运行期类型而派发的,它依据的是编译期类型,这本身就容易出错,再加上有些人又想通过切换命名空间(作者很不推荐这么做)来切换扩展方法的版本,这就更容易出问题了。
  • 如果你发现自己正在编写很多个签名相同的扩展方法,那么赶紧停下来,把方法的签名改掉,并考虑将其设计成普通的静态方法,而不要做出那种通过切换using指令来影响程序行为的设计方案,因为那样会令开发者感到困惑。

第36条:理解查询表达式与方法调用之间的映射关系

  • LINQ构建在两个概念之上:一是查询语言(query language)本身,二是该语言与查询方法之间的转换关系。
  • 在设计某个类时,你必须清楚由系统所提供的那些查询方法是否合适,自己能不能针对当前这个类实现出更好的版本。
  • 完整的查询表达式模式(query expression pattern)包含11个方法。
  • 编译器只能保证你创建的接口方法符合语法规定,但无法保证它能满足用户的要求。
  • 如果你觉得自己可以利用该类内部的某些特性编写出比默认方式更为高效的实现代码,那么就必须保证该类完全遵从查询表达式模式所做出的约定。

第37条:尽量采用惰性求值的方式来查询,而不要及早求值

  • 每迭代一遍都产生一套新的结果,这叫作惰性求值(lazy evaluation),反之,如果像编写普通的代码那样直接查询某一套变量的取值并将其立刻记录下来,那么就称为及早求值(eager evaluation)。
  • 首先通过一段代码来理解惰性求值与及早求值之间的区别:
private static IEnumerable<TResult>
    Generate<TResult>(int number, Func<TResult> generator)
{
    for(var i = 0; i < number; i++)
    {
        yield return generator(); 
    }
}

private static void LazyEvaluation()
{
    WriteLine($"Start time for Test One: {DateTime.Now:T}");
    var sequence = Generate(10, () => DateTime.Now);

    WriteLine("Waiting.... tPress Return");
    ReadLine();

    WriteLine("Iterating...");
    foreach (var value in sequence)
        WriteLine($"{value:T}");

    WriteLine("Waiting.... tPress Return");
    ReadLine();
    WriteLine("Iterating...");
    foreach (var value in sequence)
        WriteLine($"value:T}");
}
  • 通过这种办法可以很有效地了解C#系统如何对LINQ查询求值。
  • 如果代码写得较为合理,那么程序只需检查序列的开头部分即可,因为它可以在找到所需的答案时停下来。
  • 编写算法的时候,如果能把那种需要处理整个序列的操作放在合适的时机去执行,那么算法可能会执行得很快,反之,则有可能耗费极长的时间。
  • 在个别情况下,你可能确实想给序列中的值做一份快照,这时可以考虑ToList()及ToArray()这两个方法,它们都能够立刻根据查询结果来生成序列,并保存到容器中。其区别在于,前者用List\<T>保存,后者用Array保存。
  • 总之,与及早求值的方式相比,惰性求值基本上都能减少程序的工作量,而且使用起来也更加灵活。在少数几种需要及早求值的场合,可以用ToList()或ToArray()来执行查询并保存结果,但除非确有必要,否则还是应该优先考虑惰性求值

第38条:考虑用lambda表达式来代替方法

  • 涉及查询表达式与lambda的地方应该用更为合理的办法去创建可供复用的代码块。
var allEmployees = FindAllEmployees();

// 寻找第一批员工:
var earlyFolks = from e in allEmployees
                where e.Classification == EmployeeType.Salary 
                where e.YearsOfService > 20 
                where e.MonthlySalary< 4000 
                select e;
// 找到最新的人:
var newest = from e in allEmployees
             where e.Classification == EmployeeType.Salary 
             where e.YearsofService< 20 
             where e.MonthlySalary< 4000 
             select e;    
  • 你可以把查询操作分成许多个小方法来写,其中一些方法在其内部用lambda表达式处理序列,而另一些方法则可以直接以lambda表达式做参数。把这些小方法拼接起来,就可以实现整套的操作。这样写既可以同时支持IEnumerable与IQueryable,又能够令系统有机会构建出表达式树,以便高效地执行查询。

第39条:不要在Func与Action中抛出异常

  • 例如下面这段代码,要给每位员工加薪5%:
var allEmployees = FindAllEmployees();
allEmployees.ForEach(e => e.MonthlySalary *= 1.05M);
  • 假如这段代码在运行过程中抛出异常,那么该异常很有可能既不是在处理第一位员工之前抛出的,又不是在处理完最后一位员工之后抛出的,导致你无法掌握程序的状态,因此必须把所有数据都手工检查一遍,才能令其保持一致。
  • 要想解决此问题,你可以设法做出强异常保证,也就是在这段代码未能顺利执行完毕的情况下,确保程序状态(与执行之前相比)不会出现明显的变化。
  • 需要仔细定义相关的函数及谓词,以确保方法所订立的契约在各种情况下都能得到满足,即便是在发生错误的情况下也是如此。
  • 首先你的做法可以过滤到那些可能加薪失败的员工(比如不在数据库内),但是这并不彻底。其次,你可以先复制一份,等到副本可以全部成功加薪之后,再赋给原对象,但是,开销会变大。同时使算法的拼接性变差。
  • 与一般的写法相比,用lambda表达式来编写Action及Func会令其中的异常更加难以发觉。因此,在返回最终结果之前,必须确定这些操作都没有出现异常,然后才能用处理结果把整个源序列替换掉。

第40条:掌握尽早执行与延迟执行之间的区别

  • 声明式的代码(declarative code)的重点在于把执行结果定义出来,而命令式的代码(imperative code)则重在详细描述实现该结果所需的步骤。
//命令式写法
var answer = DoStuff(Method1(), Method2(), Method3()); 

//声明式写法 
var answer = DoStuff(()=>Method1(), ()=>Method2(), ()=>Method3());
  • 总之,只有当程序确实要用到某个方法的执行结果时,才会去调用这个方法。这是声明式写法与命令式写法之间的重要区别。如果把两种写法混起来用,那么程序可能就会出现严重的问题。
  • 这两种写法之间最重要的区别在于:前者必须先把数据算好,而后者则可以等到将来再去计算。
  • 如果采用第一种写法(命令式),那么必须提前调用相关方法,以获取该方法所计算出的数据,而不像第二种写法(声明式)那样,可以按照函数式编程的风格,用包含该方法本身的lambda表达式来暂时代替这个方法,等真正要用到该方法的执行结果时再去计算。
  • 在某些情况下,把这两种求值方式混起来用的效果是最好的。也就是说,其中某些结果可以尽早计算并缓存起来,而另一些结果则等到用的时候再去计算。
  • 编写C#算法时,先要判断用数据(也就是算法的结果)当参数与用函数(也就是算法本身)当参数会不会导致程序的运行结果有所区别。
  • 在难以判断的情况下,不妨优先考虑把算法当成参数来传递,这样做可以令编写函数的人更为灵活,因为他既可以采用惰性求值的方式稍后再去调用该算法,也可以采用及早求值的方法立刻获取该算法的执行结果。

第41条:不要把开销较大的资源捕获到闭包中

  • 闭包(closure)会创建出含有约束变量(bound variable)的对象,但是这些对象的生存期可能与你想的不一样,而且通常会给程序带来负面效果。
  • 如果算法使用了一些查询表达式,那么编译器在编译这个方法时,就会把同一个作用域内的所有表达式合起来纳入同一个闭包中,并创建相应的类来实现该闭包。这个类的实例会返回给方法的调用者。对于迭代器方法来说,这个实例有可能是实现了迭代逻辑的那个对象中的成员。只有当该实例的使用方全都从系统中移除之后,它才有可能得到回收,这就会产生很多问题。
  • 如果程序从方法中返回的是一个用来实现闭包的对象,那么与闭包相关的那些变量就全都会出现在该对象里面。你需要考虑程序此后是否真的需要用到这些变量。如果不需要使用其中的某些变量,那么就应该调整代码,令其在方法返回的时候能够及时得到清理,而不会随着闭包泄漏到方法之外。

第42条:注意IEnumerable与IQueryable形式的数据源之间的区别

  • IQueryable\<T>与IEnumerable\<T>这两个类型在API签名上面很像,而且前者继承自后者,因此很多情况下它们可以互换。
var q = from c in dbContext.Customers
    where c.City == "London"
    select c;
var finalAnswer = from c in q
                    orderby c.Name
                    select c;
// 迭代被省略的最终答案序列的代码

var q = (from c in dbContext.Customers
            where c.City == "London"
            select c).AsEnumerable();
var finalAnswer = from c in q
                    orderby c.Name
                    select c;
//迭代最终答案的代码
  • IQueryable\<T>内置的LINQ to SQL机制,IEnumerable\<T>是把数据库对象强制转为IEnumerable形式的序列,并把排序等工作放在本地完成。
  • 有些功能用IQueryable实现起来要比用IEnumerable快得多。
  • 用IEnumerable所写的那个版本必须在本地执行,系统要把lambda表达式编译到方法里面,并在本地计算机上面运行,这意味着无论有待处理的数据在不在本地,都必须先获取过来才行。
  • 用IQueryable实现出来的版本则会解析表达式树,在解析的时候,系统会把这棵树所表示的逻辑转换成provider能够操作的格式,并将其放在离数据最近的地方去执行
  • 如果在性能与健壮这两项因素之间更看重后者,那么可以把查询结果明确转换成IEnumerable,这样做的缺点是LINQ to SQL引擎必须把dbContext.Products中的所有内容都从数据库中获取过来。
  • 可以使用AsEnumerable()与AsQueryable()进行相互转换。IQueryable更适合远程执行(数据库)。

第43条:用Single()及First()来明确地验证你对查询结果所做的假设

  • 如果你要确保查询结果里面有且仅有一个元素,那么就应该使用Single()来表达这个意思,因为这样做是很清晰的。只要查询结果中的元素数量与自己的预期不符,程序就会立刻抛出异常。
var answer = (from p in somePeople where p.FirstName == "Larry" select p).Single();
  • 由这段代码的写法可以看出,开发者希望能够查到一位名叫Larry的人。
var answer = (from p in somePeople where p.FirstName == "Larry" select p).SingleOrDefault(); 
  • 如果你想表达的意思是要么查不到任何元素,要么只能查到一个元素,那么可以用SingleOrDefault()来验证,然而要注意,如果查到的元素不只一个,那么该方法也会像Single()那样抛出异常。
  • 有的时候,你并不在乎查到的元素是不是有很多,而只是想取出这样的一个元素而已,这种情况下,可以考虑用First()或FirstOrDefault()方法来表达这个意思。
  • 除了这些方法之外,尽量不要用别的方法来获取查询结果中的特定元素,而是应该考虑通过更好的写法来寻找那个元素,使得其他开发者与代码维护者能够更为清晰地理解你想找的究竟是什么。

第44条:不要修改绑定变量

  • 编译器创建的这个嵌套类会把lambda表达式所访问或修改的每个变量都囊括进来,而且原来访问局部变量的那些地方现在也会改为访问该嵌套类中的字段。
  • 如果在定义查询表达式的时候用到了某个局部变量,而在执行之前又修改了它的值,那么程序就有可能出现奇怪的错误,因此,捕获到闭包中的那些变量最好不要去修改。

第五章、合理地运用异常

  • 程序总是会出错的,因为即便开发者做得再仔细,也还是会有预料不到的情况发生。
  • 令代码在发生异常时依然能够保持稳定是每一位C#程序员所应掌握的关键技能。
  • 本章中的各条会讲解怎样通过异常来清晰而精准地表达程序在运行中所发生的错误,而且还会告诉大家怎样管理程序的状态才能令其更容易地从错误中恢复。

第45条:考虑在方法约定遭到违背时抛出异常

  • 如果方法不能够完成其所宣称的操作,那么就应该通过异常来指出这个错误, 同时用异常来表示程序在运行过程中所遇到的状况要比用错误码更好。
  • 与采用错误码相比,通过异常来报告错误是更加恰当的做法,因为这样做有很多好处。

    - 错误码必须由调用该方法的人来处理,而异常则可以沿着调用栈向上传播,直至到达合适的catch子句。
    - 此外还有一个好处,就是异常不会轻易为人所忽视。如果没有适当的catch子句能够处理异常,那么应用程序就会(明确地)终止,而不会悄无声息地继续运行下去,以防数据受损。
  • 由于异常本身也是类,因此,你可以从其中派生自己的异常类型,从而表达出较为丰富的错误信息。
  • 由于异常并不适合当作控制程序流程的常规手段,因此,还应该同时提供另外一套方法,使得开发者可以在执行操作之前先判断该操作能否顺利执行,以便在无法顺利执行的情况下采取相应的措施,而不是等到抛出了异常之后再去处理。

第46条:利用using与try/finally来清理资源

  • 如果某个类型用到了非托管型的系统资源,那么就需要通过IDisposable接口的Dispose()方法来明确地释放。
  • 拥有非托管资源的那些类型,都实现了IDisposable接口,此外还提供了finalizer(终结器/终止化器),以防用户忘记释放该资源。
  • using语句能够确保Dispose()总是可以得到调用。
  • 如果函数里面只用到了一个IDisposable对象,那么要想确保它总是能够适当地得到清理,最简单的办法就是使用using语句,该语句会把这个对象放在try/finally结构里面去分配。
  • 凡是实现了IDisposable接口的对象都应该放在using语句中或者try块中去实现,否则就有可能泄露资源。
SqlConnection myConnection = null;

// 示例: 
using (myConnection = new SqlConnection(connString))
{
    myConnection.Open();
}

// Try / Catch块: 
try
{
    myConnection = new SqlConnection(connString);
    myConnection.Open();
}
finally
{
    myConnection.Dispose();
}
  • 如果你不清楚某个对象是否实现了IDisposable接口,那么可以通过as子句来安全地处置它:
//正确的修复
//对象是否支持IDisposable
object obj = Factory.CreateResource();
using (obj as IDisposable)
    Console.WriteLine(obj.ToString());
  • Dispose()方法并不会把对象从内存中移除,它只是提供了一次机会,令其能够释放非托管型的资源。
  • 最好是把这样的对象包裹在using语句或try/finally结构里面,总之,无论采用什么样的写法,你都要保证这些资源能够正确地释放。

第47条:专门针对应用程序创建异常

  • 编写应用程序(或程序库)时,必须把那些需要用不同方式来处理的情况设计成不同的异常类型。
  • 第一,并不是所有的错误都必须表示成异常。至于什么样的错误应该表示成异常,什么样的错误不必表示成异常,则没有固定的规律可循。但笔者认为:如果某种状况必须立刻得到处理或汇报,否则将长期影响应用程序,那么就应该抛出异常。
  • 第二,并不是每写一条throw语句就要新建一种异常类。应该仔细想想,能不能创建一种新的异常类,以促使调用方更为清晰地理解这个错误,从而试着把应用程序恢复到正常状态。
  • 之所以要创建不同的异常类,其原因很简单,就是为了令调用API的人能够通过不同的catch子句去捕获那些状况,从而采用不同的办法加以处理。
//以Exception类为例子
//默认构造函数
public Exception();

//创建一个消息
public Exception(string);

//使用消息和内部异常创建。
public Exception(string, Exception);

//从输入流创建
protected Exception(SerializationInfo, StreamingContext);
  • 一旦决定自己来创建异常类,就必须遵循相应的原则。这些类都要能够追溯到Exception才行,如果从Exception中派生子类,那么应该创建四个构造函数,以便与上述四者相对应。
  • 异常转换(exception translation),用来将底层的异常转化成高层的异常,从而提供更贴近于当前情境的错误信息(有利于调式)。
  • 在某些情况下,确实有必要抛出异常,此时应该专门做一些处理,而不要把你在调用核心框架时由.NET Framework所产生的那个异常原封不动地抛出去。

第48条:优先考虑做出强异常保证

  • 异常所做的保证分成三种,即基本保证(basic guarantee)、强保证(strong guarantee)及no-throw保证(不会抛出异常的保证)。在这三种态度中,强保证是较为折中的,它既允许程序抛出异常并从中恢复,又使得开发者能够较为简便地处理该异常。
  • 应用程序中有很多种操作都会在未能顺利执行完毕的情况下令程序陷入无效的状态。这些状况很难完全顾及,因为没有哪一套标准的流程能够自动地应对它们。为此,你可以考虑做出强异常保证来避开其中的很多问题。
  • 强异常保证这种做法规定:如果某操作抛出异常,那么应用程序的状态必须和执行该操作之前相同。也就是说,这项操作要么完全成功,要么彻底失败。
  • 除了基本保证与强保证之外,还有一种最为严格的保证,叫作no-throw保证。它指的就是字面上的意思,即保证方法肯定能够运行完毕而绝对不会从中抛出异常。对于大型的程序来说,要求其中的所有例程都达到这种地步是不太现实的,但在其中的某几个地方确实不能令方法抛出异常,比方说,finalizer(终结器/终止化器)与Dispose就是如此。你可以把那种较为复杂的方法包裹在try/catch结构里面去调用,从而将该方法所抛出的异常吞掉,以此来做出no-throw保证。
  • 还有一个地方也应该做出no-throw保证,那就是委托目标(delegate target)。
  • 笔者再说一遍:包括事件处理程序在内的各种委托目标都不应该抛出异常,如果抛出,那么触发事件的那段代码就无法做出强异常保证。
  • finalizer、Dispose()方法、when子句及委托目标是四个特例,在这些场合,绝对不应该令任何异常脱离其范围。
  • 如果在拷贝出来的临时数据上面执行完操作之后想用它把原数据替换掉,而原来的数据又是引用类型,那么要多加小心,因为这可能引发很多微妙的bug。

第49条:考虑用异常筛选器来改写先捕获异常再重新抛出的逻辑

  • 如果改用异常筛选器来捕获并处理异常,那么以后诊断起来就会容易一些,而且不会令应用程序的开销增大。
  • 异常筛选器是针对catch子句所写的表达式,它出现在catch右侧那个when关键字之后,用来限定该子句所能捕获的异常:
var retryCount = 0;
var dataString = default(String);

while(dataString == null) 
{ 
    try
    {
        dataString = MakeWebRequest();
    }
    catch(TimeoutException e) when(retryCount++ < 3)
    {
        WriteLine("Operation timed out. Trying again");
        //再次尝试前暂停。
        Task.Delay(1000 * retryCount);
    }
}
  • 采用异常筛选器来写,那么诊断信息里面就会带有程序的状态,从而令你能够判断出问题的根源。
  • .NET CLR对带有when关键字的try/catch结构做了优化,使得程序在无须进入该结构时其性能尽量不受影响。
  • 如果异常筛选器无法处理某个异常,那么程序就无须展开调用栈,也不用进入catch块,这使得其性能要比先捕获再重新抛出的办法更高,总之无论如何,也不会比它差。
  • 使用了异常筛选器之后,可以调整原有的异常处理代码,把多余的判断逻辑去掉,只用catch子句来捕获你能够完全应对的那些异常。
  • 如果仅通过异常的类型不足以判断出自己到底能不能处理该异常,那么可以考虑给相关的catch子句添加筛选器,使得程序只有在筛选条件得以满足时才会进入这个catch块。

第50条:合理利用异常筛选器的副作用来实现某些效果

  • 系统在寻找catch子句的过程中会执行这些筛选器,而此时,调用栈还没有真正展开(于是,不妨利用这一特性来实现某些效果)。
  • 可以把catch(Exception e)when log(e){}这样的写法随时套用到已有的代码中,因为它并不会干扰程序正常运行。
  • 如果合理地利用异常筛选器所引发的某些副作用,那么很容易就能观察到程序究竟是在什么样的状况下抛出异常的。

注意

本章图文内容均来源于《Effective C#:改善C#代码的50个有效方法》一书, 自己整理收集,方便学习参考, 版权属于原作者。
目录
相关文章
|
6月前
|
C# Python
C# 笔记1 - 操作目录
C# 笔记1 - 操作目录
55 0
|
1月前
|
算法 安全 测试技术
C#——刘铁猛笔记
C#——刘铁猛笔记
47 0
|
5月前
|
关系型数据库 C# 数据库
技术笔记:MSCL超级工具类(C#),开发人员必备,开发利器
技术笔记:MSCL超级工具类(C#),开发人员必备,开发利器
54 3
|
5月前
|
Java BI C#
技术笔记:SM4加密算法实现Java和C#相互加密解密
技术笔记:SM4加密算法实现Java和C#相互加密解密
87 0
|
6月前
|
C# Python
C# 笔记3 - 重载一系列像python那样的print()方法
C# 笔记3 - 重载一系列像python那样的print()方法
50 1
|
6月前
|
存储 C# C++
C# 笔记2 - 数组、集合与与文本文件处理
C# 笔记2 - 数组、集合与与文本文件处理
80 0
|
存储 网络协议 Java
C# 快速入门笔记
C# 快速入门笔记
C# 快速入门笔记
|
C# 开发工具
C#滑动拼图验证码实现笔记
C# 是一个现代的、通用的、面向对象的编程语言,它是由微软(Microsoft)开发的,由 Ecma 和 ISO 核准认可的。突发奇想,动手开发一个C#滑动拼图验证码,下面是我开发过程的记录。
C#滑动拼图验证码实现笔记
|
存储 算法 编译器
|
C# 数据库
C#编程-65:读取数据库DataReader对象复习笔记
C#编程-65:读取数据库DataReader对象复习笔记
C#编程-65:读取数据库DataReader对象复习笔记