C#

《Effective C#》改善C#代码的50个有效方法

Posted by LudoArt on May 10, 2023

《Effective C#》改善C#代码的50个有效方法

C# 语言的编程习惯

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

隐式类型的局部变量加入C#语言的原因:

  • 为了支持匿名类型机制;
  • 某些查询操作所获得的结果是 IQueryable<T> ,而其他一些则返回 IEnumerable<T> ,如果硬要把前者当成后者来对待,那就无法使用由 IQueryProvider 所提供的很多增强功能了(会产生性能问题);

除非开发者必须看到变量的声明类型之后才能正确理解代码的含义,否则,就可以考虑用 var 来声明局部变量。如对 intfloatdouble 等数值型的变量,就应该明确指出其类型,而对其他变量则可以使用 var 来声明。

第2条:考虑用 readonly 代替 const

C#有两种常量,一种是编译期的常量,另一种是运行期的常量。编译期常量能令程序运行得稍快一点,但远不如运行期的常量那样灵活。

  • 运行期的常量用 readonly 关键字声明,在程序运行的时候解析,类型不受限制,可以声明实例级别的常量,以便给同一个类的每个实例设定不同的常量值,代码使用时是使用该值的引用

  • 编译期的常量用 const 关键字声明,在程序编译时确定(直接取其值嵌入代码),只能用数字、字符串或null来初始化,是静态常量,代码使用时是直接使用该值

此外,编译期常量可以在方法里面声明,而readonly常量则不行。

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

TODO:有点懵,需要再看看

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

对比 String.Format() ,内插字符更安全(不需要验证参数个数),更方便(括号内的只要是有效的C#表达式即可)。

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

感觉用不上,没有仔细看。

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

使用 nameof() 来代替硬字符串,好处是如果属性名变了,那么用来构造 PropertyChangedEventArgs 对象的参数也会随之变化。 nameof() 会根据符号求出表示该符号名称的字符串,这个符号可以指类型、变量、接口及命名空间。

第7条:用委托表示回调

所有的委托都是多播委托,也就是会把添加到委托中的所有目标函数都视为一个整体去执行。这就导致有两个问题需要注意:

  1. 程序在执行这些目标函数的过程中可能发生异常;
  2. 程序会把最后执行的那个目标函数所返回的结果当成整个委托的结果;

异常和返回值这两个问题可以通过手动执行委托来解决。由于每个委托都会以列表的形式来保存其中的目标函数,因此只要在该列表上面迭代,并把目标函数轮流执行一遍就可以了:

public void LengthyOperation2(Func<bool> pred)
{
    bool bContinue = true;
    foreach (var cl in container)
    {
        cl.DoLengthyOperation();
        foreach (Func<bool> pr in pred.GetInvocationList())
            bContinue &= pr();
        
        if (!bContinue)
            return;
	}
}

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

调用事件处理程序时,应先判断事件是否为空再进行处理。但若在多线程中,单纯先判断后处理也可能遇到另一个线程在判断结束后将事件设为null,从而引发 NullReferenecException 报错。正确的写法如下:

public void RaiseUpdates()
{
    counter++;
    var handler = Updated;
    if (handler != null)
        handler(this, counter);
}

通过一个浅拷贝创建新的引用来解决问题,但这种写法太过繁琐,使用null条件运算符后可以使得这种写法简单化:

public void RaiseUpdates()
{
    counter++;
    Updated?.Invoke(this, counter);
}

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

装箱和取消装箱操作都很影响性能,有的时候还需要为对象创建临时的拷贝,而且容易给程序引入难于查找的bug。

避免装箱操作的原则之一,就是要注意那些会隐式转换成 System.Object 的地方,尽量不要在需要使用 System.Object 的地方直接使用值类型的值。

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

new 修饰符并不会把原来是非虚的方法转变成虚方法,而是会在类的命名空间里面另外添加一个方法。

.NET的资源管理

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

.NET提供垃圾回收器来帮助控制托管内存,GC的Mark and Compact 算法会迅速地检测这些关系,并把那些不可达的对象视为一个整体从内存中清理出去。

GC的检测过程是从应用程序的根对象出发,把与该对象之间没有通路相连的那些对象判定为不可达的对象。

垃圾回收器每次运行的时候,都会压缩托管堆,以便把其中的活动对象安排在一起,使得空闲的内存能够形成一块连续的区域。

为了优化垃圾收集工作,GC定义了世代(generation)这样一个概念,以便尽快确定那些最有可能变成垃圾的对象。上一次收集完垃圾之后才创建出来的对象叫做第0代对象,如果其中的某些对象在这次清扫垃圾之后依然留在内存里面,那就变成第1代对象,若经过两次或更多次的清理之后它还留在内存里面,则变为第2代对象。为了优化垃圾收集工作,GC会以较低的频率来检测第1代与第2代的对象,也就是说,每次循环都会判断第0代的这些对象是不是垃圾,但每执行10次循环才会把第1代的对象连同第0代的对象检测一遍,而第2代的那些对象则是每100次循环才检测一遍

针对托管堆的内存管理工作完全是由垃圾回收器负责的,但除此之外的其他资源则必须由开发者来管理。有两种机制可以控制非托管资源的生存期,一种是finalizer,另一种是IDisposable。

  • finalizer是一种防护机制,只能保证由某个类型的对象所分配的非托管资源最终可以得到释放,但并不保证这些资源能够在确定的时间点上得到释放。如果GC发现某个对象已经垃圾,但该对象还有finalizer需要运行,那么就无法立刻把它从内存中移走,而是要等调用完finalizer之后,才能将其移除;
  • 需要释放资源推荐使用IDisposable接口以及标准的dispose模式来解决(参见第17条);

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

类的构造函数通常不止一个,构造函数变多了以后,开发者就可能忘记给某些成员变量设定初始值。为了避免这个问题,最好是在声明的时候直接初始化,而不要等实现每个构造函数的时候再去赋值。

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

  • 把对象初始化为0或null:底层CPU指令会把整块内存全都设置为0,此时初始化为0或null就显得多余;
  • 如果不同的构造函数需要按照各自的方式来设定某个字段的初始值;
  • 如果初始化变量的过程中有可能出现异常:这种情况应该把这部分逻辑移动到构造函数里面,因为初始化语句不能包裹在try块中;

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

创建某个类型的实例之前,应该先把静态的成员变量初始化好,在C#中可以通过静态初始化语句以及静态构造函数来做。

如果静态字段的初始化工作比较复杂或是开销比较大,那么可以考虑运用 Lazy<T> 机制,将初始化工作推迟到首次访问该字段的时候再去执行。

当程序码初次访问应用程序空间(application space, 也就是AppDomain)里面的某个类型之前,CLR会自动调用该类的静态构造函数。这种构造函数每个类只能定义一个,而且不能带有参数。如果其中的异常跑到了静态构造函数外面,那么CLR就会抛出 TypeInitializationException 以终止程序。CLR不会再次执行其构造函数,这导致该类型无法正确地加以初始化,并导致该类及其派生类的对象也无法获得适当的定义。

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

如果这些构造函数都会用到相同的逻辑,那么应该把这套逻辑提取到共用的构造函数中。这样既可以减少重复的代码,又能够令C#编译器根据这些初始化命令生成更为高效的目标代码。

下面列出构建某个类型的首个实例时系统所执行的操作:

  1. 把存放静态变量的空间清零;
  2. 执行静态变量的初始化语句;
  3. 执行基类的静态构造函数;
  4. 执行(本类的)静态构造函数;
  5. 把存放实例变量的空间清零;
  6. 执行实例变量的初始化语句;
  7. 适当地执行基类的实例构造函数;
  8. 执行(本类的)实例构造函数;

以后如果还要构造该类型的实例,那么会直接从第5步开始执行。

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

所有引用类型的对象都需要先分配内存,然后才能够使用,即便是局部变量也不例外,如果声明这些变量的那个方法不再活跃于程序中,那么很可能导致这些变量成为垃圾。

如果局部变量是引用类型,且出现在需要频繁运行的例程(routine)中,那就应该将其提升为成员变量。

其次可以采用依赖注入dependency injection)的办法创建并复用那些经常使用的类实例,如下:

private static Brush blackBrush;
public static Brush Black
{
    get
    {
        if (blackBrush == null)
            blackBrush = new SolidBrush(Color.Black);
        return blackBrush;
    }
}

此外,也可以把不可变类型的对象最终所应具备的取值分步骤地构建好(参考StringBuilder)。

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

在构建对象的过程中调用虚函数总是有可能令程序中的数据混乱,如下示例:

class B
{
    protected B()
    {
        VFunc();
    }
    
    protected virtual void VFunc()
    {
        Console.WriteLine("VFunc in B");
	}
}

class Derived: B
{
    private readonly string msg = "Set by initializer";
    
    public Derived(string msg)
    {
        this.msg = msg;
    }
    
    protected override VFunc()
    {
        Console.WriteLine(msg);
    }
    
    public static void Main()
    {
        var d = new Derived("Constructed in main");	// 输出Set by initializer
    }
}
  • 基类的构造函数调用了一个定义在本类中但是为派生类所重写的虚函数,于是程序在运行的时候调用的就是派生类的版本;
  • 在构造函数尚未把该对象初始化好之前,它的取值是由初始化语句设定的(Set by initializer),而执行完构造函数之后,其值却变成了由构造函数所设置的那个值(Constructed in main);

在(基类的)构造函数里面调用虚函数会令代码严重依赖于派生类的实现细节,而这些细节是无法控制的,因此,这种做法很容易出问题。

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

标准的 dispose (释放/处置)模式既会实现 IDisposable 接口,又会提供 finalizer (终结器/终止化器),以便在客户端忘记调用 IDisposable.Dispose() 的情况下也可以释放资源。

在类的继承体系中,位于根部的那个基类应该做到以下几点:

  • 实现 IDisposable 接口,以便释放资源
  • 如果本身含有非托管资源,那就添加finalizer,以防客户端忘记调用 Dispose() 方法。若是没有非托管资源,则不用添加finalizer
  • Dispose() 方法与finalizer都把释放资源的工作委派给虚方法,使得子类能够重写该方法, 以释放它们自己的资源

继承体系中的子类应该做到以下几点:

  • 如果子类有自己的资源需要释放,那就重写由基类所定义的那个虚方法,若是没有,则不用重写该方法
  • 如果子类自身的某个成员字段表示的是非托管资源,那么就实现finalizer ,若是没有这样的字段,则不用实现finalizer
  • 记得调用基类的同名函数

实现 IDisposable.Dispose() 方法时,要注意以下四点:

  • 把非托管资源全都释放掉
  • 把托管资源全都释放掉(这也包括不再订阅早前关注的那些事件)
  • 设定相关的状态标志,用以表示该对象已经清理过了
  • 阻止垃圾回收器重复清理该对象(这可以通过 GC.SuppressFinalize(this) 来完成)

在编写 Disposefinalizer 等资源清理的方法时,最重要的一点是:只应释放资源,而不应该做其他的处理,否则,就会产生一些涉及对象生存期的严重问题。

合理地运用泛型

泛型类的定义属于完全编译的MSIL类型,其代码对于任何一种可供使用的类型参数来说都必须完全有效。这样的定义叫做泛型类型定义

对于泛型类型来说,如果所有的类型参数都已经指明,那么这种泛型类型就称为封闭式泛型类型,反之,若仅仅指出了某些参数,则称为开放式泛型类型

与真正的类型相比,IL形式的泛型只是定义好了其中的某一部分而已,必须把里面的占位符替换成具体的内容才能令其成为完备的泛型类型,这项工作是由JIT编译器在创建机器码的时候完成的,这些机器码会在程序运行期根据早前的泛型定义实例化出封闭式的泛型类型

这样做会产生很多种封闭的泛型类型,从而增加了代码方面的开销,但其好处则是降低了执行程序所花的时间以及存储数据所需的空间

  • 如果泛型类的类型参数是引用类型,那么无论具体指的是什么,JIT编译器都会生成同样的机器码
  • 如果至少有一个类型参数是值类型,那么规则就变了,此时JIT编译器会根据不同的类型参数生成对应版本的机器指令

这种做法使得程序在运行的时候会占用更多的内存(因为每用到一种值类型,就需要对IL形式的定义做一次提画面,以便生成对应的封闭式泛型类型),但也有好处,因为可以避开值类型的装箱与拆箱等操作,使得与之相关的代码和数据能够变得少一些。

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

约束使得编译器能够知道类型参数除了具备由 System.Object 所定义的 public 接口之外还必须满足什么条件。

创建泛型类型的时候,C#编译器必须为这个泛型类型的定义生成有效的IL码。(默认是最基本的 System.Object

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

既可以对泛型参数尽量少施加一些硬性的限制,又能够在其所表示的类型具备丰富的功能时提供更好的实现方式。需要在泛型类的复用程度与算法面对特定类型时所表现出的效率之间做出权衡。

第20条:通过 IComparable<T>IComparer<T> 定义顺序关系

  • IComparable<T> 用来规定某类型的各对象之间所具备的自然顺序;
  • IComparer<T> 用来表示另一种排序机制可以由需要提供排序功能的类型来实现

IComparable 接口只有一个方法,就是 CompareTo() :若本对象小于另一个受测对象,则返回小于0的值;若相等,则返回0;若大于那个对象,则返回大于0的值。

比较新的API大都使用泛型版的IComparable<T> 接口,但老一些的API用的则是一带泛型的 IComparable 的接口。

IComparableCompareTo() 方法其参数类型是 System.Object ,因此需要检查它的运行期类型,每次比较之前,都必须先把参数转换成合适的类型。

public struct Customer : IComparable<Customer>, IComparable
{
    private readonly string name;
    
    public Customer(string name)
    {
        this.name = name;
	}
    
    // IComparable<Customer> Members
    public int CompareTo(Customer other) => name.CompareTo(other.name);
    
    // IComparable Members
    int IComparable.CompareTo(object obj)	// 限定该方法只能通过IComparable调用
    {
        if (!(obj is Customer))
            throw new ArgumentException("Argument is not a Customer", "obj");
        Customer otherCustomer = (Customer)obj;
        return this.CompareTo(otherCustomer);
    }
}

如需增加其他指标排列,有以下两种方式:

  1. Customer 类型里面创建静态属性,并采用其他指标来定义对象之间的顺序

    public static Comparison<Customer> CompareByRevenue => (left, right) => left.revenue.CompareTo(right.revenus);
    
  2. Customer 类型内部新建 private 级别的嵌套类,也就是 RevenueComparer ,并通过 Customer 结构体中的静态属性来公布这个嵌套类的对象

    public struct Customer : IComparable<Customer>, IComparable
    {
        ………… 
               
    	private static Lazy<RevenueComparer> revComp = new Lazy<RevenueComparer>(() => new RevenueComparer());
           
    	public static IComparer<Customer> RevenueComparer => revComp.value;
           
    	public static Comparison<Customer> CompareByRevenue => (left, right) => left.revenue.CompareTo(right.revenue);
           
        // Class to compare customers by revenue.
        // This is always used via the interface pointer,
        // so only provide the interface override.
        private class RevenueComparer : IComparer<Customer>
        {
            int IComparer<Customer>.Compare(Customer left, Customer right) => left.revenue.CompareTo(right.revenue);
        }
    }
    

注:确定先后顺序与判断是否相等(Equals() 或 == 运算符)是两个互不相同的操作,实现前者的时候不一定非得实现后者。判断先后顺序通常依据的是对象的内容,判断是否相等依据的则是对象的身份(identity),因此判断是否相等与判断先后顺序未必总是要产生相同的结果。

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

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

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

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

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

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

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

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

合理地运用LINQ

第29条:

合理地运用异常