Skip to content

Effective C# 改善 C# 程序的 50 种方法

更新: 2023-06-28 15:02:23   字数: 0 字   时长: 0 分钟

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

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

  • var 可以使开发者更多地关注在变量的命名上,而不用考虑变量的实际类型(编译器会自己推断,并选择最为合适的)。
  • 好的变量命名可以提高可读性,合适的推导类型会提高效率(例如里氏替换,你能选得过编译器?为自己省事。)
  • var 并不总是合适,当涉及 int、float、double 等数值类型时,请务必明确写出。

总之,在不会发生精度损失的前提下,请务必使用隐式类型声明。

第 2 条:考虑用 readonly 代替 const

  • C#有两种常量,编译期(compile-time)常量,和运行期(runtime)常量。
  • 运行期用 readonly,编译期用 const。
  • const 性能优于 readonly。
  • const 用来声明那些必须在编译期(静态编译?)得以确定的值,如 attribute 的参数、switch case 语句的标签、enum 的定义等,以及那些不会随版本而变化的值。除此之外的值则应该考虑声明成更加灵活的 readonly 常量。
  • 确实想把某个值在编译期固定下来就用 const,否则就用 readonly,因为其更灵活,兼容性更好。举例子,程序集 A 引用程序集 B,程序集 B 中两个属性分别使用 readonly 与 const,若两个属性均发生更改,在- 不编译程序集 A 的情况下(只编译 B,这种情况很常见),使用 const 声明的属性在 A 中调用时,并未发生改变,这就出现了兼容性问题。所以,尽量使用 readonly 代替 const。

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

首先,在使用面向对象语言来进行编程的时候,应尽量避免类型转换操作。也许有一些场合必须使用类型转换(反思,真的是必须吗?可否绕过去?),此时应该使用 is 及 as 运算符来更清晰地表达代码的意图(可读性高)。需要警惕自动类型转换(coercing type)操作,因为它们的规则各不相同,显式使用 is 及 as 总能正确地表达。

如果你实在不想纠结原因,记住结论也行。

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

所谓内插字符串指的就是使用$来拼接字符串,这是 C#(6.0)语法的最新特性。首先很明显的优点是可读性的提高。string.Format()方法的序号与变量的一一对应问题确实会令人感到困惑,顺序不对就会出错。

内插字符的另一个强大之处在于,可以直接使用表达式(方法),你可以理解为这是一个语法糖,但是可读性一直是面向对象编程语言自诞生以来一直追寻的目标。例如:

c#
Console.WriteLine($"The customer's name is {c?.Name ?? "Name is missing"}");

这段代码对变量 c 与 Name 均做了非空判断。(个人理解,是否存在错误?)

你甚至可以在内插字符串中使用 LINQ 语句,这里不再举例。

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

单凭字符串内插功能(第 4 条)还不足以是应用程序能够应对世界上所有的语言,或是能够专门为某种语言做出特殊的处理。如果程序只是针对当前区域而生成文本,那么直接使用内插字符串就够了,这样反而可以避免多余的操作。反之,如果需要针对特定的地区及语言来生成字符串,那么就必须根据内插字符串的解读结果来创建 FormattableString,并将其转化成适用于该地区及该语言的字符串。

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

C# 6.0 版本关键字 nameof()。这个关键字可以根据变量来获取包含其名称的字符串,使开发者不用把变量名直接写成字面量。实现 INotifyPropertyChanged 接口时,经常要用到 nameof()。

c#
Public String Name
{
    get { return name; }
    set
    {
        if (value != name)
        {
            name = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
        }
    }
}

很明显,使用 nameof 运算符的好处是,如果符号改名了,那么使用 nameof 来获取符号名称的地方也会获取到修改之后的新名字。这一步静态编译就能检查出来错误,使开发者可以更专心地解决更为困难的问题。

如果不这样做,那么有些错误就只能通过自动化测试及人工检查才能寻找出来。

第 7 条:用委托表示回调

  • 回调就是这样一种由服务端向客户端提供异步反馈的机制,它可能会涉及多线程(multithreading),也有可能只是给同步更新提供入口。C#语言用委托来表示回调。
  • 通过委托,可以定义类型安全的回调。最常用到委托的地方是事件处理。
  • 委托是一种对象,其中含有指向方法的引用,这个方法既可以是静态方法,又可以是实例方法。
  • 常见委托形式:Predicate<>Action<>Func<>Func<T, bool>Predicate<T>是同一个意思。
  • 由于历史的原因,所有的委托均为多播委托(multicast delegate)。
  • 总之,如果要在程序运行的时候执行回调,那么最好的办法就是使用委托,因为客户端只需编写简单的代码,即可实现回调。委托的目标可以在运行的时候指定,并且能够指定多个目标。在.NET 程序里面,需要回调客户端的地方应该考虑用委托来做。

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

  • null 条件运算符为:?.。
  • 比较以下三种代码的写法:
c#
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);
}

//使用null条件运算符
public void RaiseUpdates()
{
    counter++;
    Updated?.Invoke(this, counter);
}

分析以上代码:

第一种存在空引用异常;

第二种当多线程时,可能出现空引用异常;

第三种很好,不会引发异常。原理是因为 handler 对 Updated 进行了浅拷贝(shallow copy),防止多线程空引用问题,但是这种写法新手极难看懂。

第四种,正统写法,最佳写法。首先 null 条件运算符左侧内容只会计算一次,其次为类型安全的 Invoke()方法,而后这段代码可以安全地运行在多线程环境下。

请丢掉旧习惯,使用新方法。

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

  • 装箱的过程是把值类型放在非类型化的引用对象中,使得那些需要使用引用的地方也能够使用值类型。取消装箱则是把已经装箱的那个值拷贝一份出来。如果要在只接受 System.Object 类型或接口的地方使用值类型,那就必然涉及装箱及取消装箱。但这两项操作都很影响性能。
  • 装箱操作会把值类型转换成引用类型。
  • 把值类型的值放入集合、用值类型的值做参数来调用参数类型为 System.Object 的方法以及将这些值转为 System.Object 等。这些做法都应该尽量避免

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

  • 重新定义非虚方法会产生令人困惑的行为。
  • 非虚方法是静态绑定的。虚方法是动态绑定的。
  • 设计是需要花时间去思考的,什么时候应该用虚方法什么时候不应该用,如何恰到好处?
  • 本书第 40 页第一个示例代码印刷错误,多了一个关键字 new。
  • 笔者认为 new 修饰符需要慎重地使用,那么我的结论就是不用,尽最大的可能去绕过 new 修饰符的使用场景。貌似更多地情况发生在基类方法命名更新时,与子类发生了冲突。(是基类先动的手,结果却把锅甩给了子类。)
  • 在同一个对象上面,通过不同类型的引用来调用同一个方法会表现出不同的的行为。(这不就是虚方法吗?译文表达的貌似不是很确切。)

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

高效程序,需要明白内存处理与其他重要资源。.NET 的内存管理与垃圾回收必须理解。

垃圾回收(GC)来帮助你控制托管内存,无需担心内存泄漏、迷途指针(dangling pointer)、未初始化的指针以及其他很多内存管理问题。

但是,为了防止资源泄露,非内存型资源(nonmemory resource)必须由开发者释放,于是会促使其创建 finalizer 来完成该工作。然而 finalizer 会严重影响程序的性能。

应考虑使用 IDisposable 接口,以便在不给 GC 增加负担的前提下把这些资源清理干净。

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

  • 类的构造函数不止一个,构造变多了以后,开发者就可能会忘记给某些成员变量设定初始值,为了避免这个问题,最好在声明的时候直接初始化,而不要等到构造中赋值。(我之前一直觉得构造中赋值做法更正统一些,现在看来并不对。)注意,这里指的是初始值。
  • 成员变量的初始化语句可以方便地取代那些本来需要放在构造器中的代码,还有一个好处是,这些语句的机器码会在本类的构造函数之前,执行的时机比基类更早。

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

对象初始化为 0 或 null(多余,降低性能); 如果属性初始值在不同的构造器中不一样,请不要写初始化语句,同样多余,降低性能; 可能存在异常的初始化语句请务必写在构造函数中,因为初始化语句不能包含在 try 块中,应在构造中将异常处理完毕。

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

在创建某个类的实例对象之前,应该先把静态的成员变量初始化好。你有两种方式可以选择,一是静态初始化语句,而是静态构造函数。

静态构造函数是特殊的函数,会在初次访问该类的其他方法、变量或属性之前执行,你可以做这么三件事,初始化静态变量、实现单例模式、其他必要的工作。

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

静态字段初始化的原则与之前说过的成员字段初始化原则基本一致。具体参见第 12 条。

要想为类中的静态成员设定初始值,最干净、最清晰的办法就是使用静态初始化语句与静态构造函数。这是 C#语言对比其他语言的优点。

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

  • 不要在不同的构造器中复制粘贴同样的代码!
  • 注意使用 this 关键字,通过链式调用构造函数,将重复的代码写到一个构造器中,再通过其他的构造器调用。
  • 采用默认参数机制来编写构造函数是比较好的做法,但是有些 API 会使用反射(reflection)来创建对象,它们需要依赖于无参的构造函数,这种函数与那种所有参数都具备默认值的构造函数并不是一回事,因此可能需要单独提供。 构建某个类型的首个实例时系统所执行的操作(注意顺序):
  • 把存放静态变量的空间清零;
  • 执行静态变量的初始化语句;
  • 执行基类的静态构造函数;
  • 执行本类的静态构造函数;
  • 把存放实例变量的空间清零;
  • 执行实例变量的初始化语句;
  • 适当地执行基类的实例构造函数;
  • 执行本类的实例构造函数。

在强调一遍,如果初始化的逻辑较为复杂,则考虑通过构造函数来实现,此时注意要把逻辑放在其中一个构造函数中,并令其他构造函数直接或间接地调用该函数,以尽量减少重复代码。(消除重复)

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

  • 垃圾回收器可以帮你把内存管理好,并高效地移除那些用不到的对象,但这并不是在鼓励你毫无节制地创建对象,因为创建并摧毁一个基于堆(heap-based)的对象无论如何都要比根本不生成这个对象耗费更多的处理器时间。在方法中创建很多的局部引用对象可能会大幅降低程序的性能。
  • 惰性求值算法(lazy evaluation algorithm),我觉得现在编译器就能自己优化这件事了,但是代码该写还要写。
  • 两种减少对象创建的方式:1. 局部变量提升为成员变量;2. 依赖注入(dependency injection)的办法创建并复用那些经常使用到的对象。
  • 此外还有一种针对不可变类型(immutable type)的技巧,例如 String 类,每次相加都会删除之前那个,重新创建,看上去好像是在不断边长的过程。注意对比 StringBuilder 类,思考设计思路,如果要设计不可变的类型,需要提供相应的 builder(构建器),令开发者能够以分阶段的形式来指定不可变的对象最终所应具备的取值。
  • 我们应该思考的是应该如何尽量少地去创建对象,以任何方式。并不止局限于书中的几条。

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

  • 在构建对象的过程中调用虚函数会令程序表现出奇怪的行为,因为该对象此时并没有完全构造好,而且虚函数的效果与开发者所想的也未必相同。
  • 在基类的构造函数里面调用虚函数会令代码严重依赖于派生类的实现细节,而这些细节是无法控制的,因此这种做法很容易出问题。
  • VS 提供了 FxCop 与 Static Code Analyzer 可以识别出该潜在的问题。回头试一试两种插件的使用方法。

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

  • 如果对象包含非托管资源,那么一定要正确地加以清理。怎样编写自己的资源管理代码?
  • 在编写 finalizer 时,一定要仔细检查代码,而且最好能把 Dispose 方法的代码也一起检查一遍。如果发现这些代码除了释放资源之外还执行了其他的操作,那就要再考虑考虑了。这些操作在以后有可能令程序出现 bug,最好是现在就把它们从方法中删除,使得 finalizer 与 Dispose()方法只用来释放资源。
  • 对于运行在托管环境中的程序来说,开发者并不需要给自己所创建的每一个类型都编写 finalizer。只有当其中包含非托管资源或是带有实现了 IDisposable 接口的成员时,才需要添加 finalizer。注意:在只需要实现 IDisposable 接口但不需要 finalizer 的场合下,还是应该把整套模式实现出来。请把标准的 dispose 框架写好,否则子类就无法轻松实现标准的 dispose 方案。
  • finalizer 指的就是析构函数。

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

  • 泛型约束。太宽或太严都不合适。例如你可以规定类型参数必须是值类型(struct)或必须是引用类型(class),还可以规定它必须实现某些接口或是必须继承自某个基类(这当然意味着它必须首先是个类才行)。
  • 泛型约束需要依据使用场景来权衡,不能背离了初衷。
  • 新学习了运算符 default()。
  • 还有一种约束条件需要谨慎地使用,那就是 new 约束,有的时候可以去掉这条约束,并将代码中的 new()改为 default()。后者是 C#的运算符,用来针对某个类型产生默认值,值类型则为 0,引用类型则为 null。对于引用类型来说,new()与 default()有很大的区别。
  • 要以谨慎的态度来施加 new、struct 及 class 等约束。这样的约束会限定对象的构建方式。

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

  • 只需要指定新的类型参数,就可以复用泛型类,这样做会实例化出一个功能相似的新类型(废话)。这当然是好的,但是你仔细品,问题在于使用了泛型虽然方便了,但是功能却高度雷同,那么有没有办法损有余而补不足呢?就是在使用泛型的前提下,同时把该类型特有的算法(方法)使用上。看以下示例代码:
c#
public ReverseEnumerable(IEnumerable<T> sequence)
{
    sourceSequence = sequence;
    originalSequence = sequence as IList<T>;
}

这里 as 的使用非常地巧妙!

  • 开发者既可以对泛型参数尽量少施加一些硬性的限制,又能够在其所表示的类型具备丰富的功能时提供更好的实现方式。为了达到这种效果,你需要在泛型类的复用程度与算法面对特定类型时所表现出的效率之间做出权衡。
  • 今天是世界读书日,全称世界图书与版权日(World Book Day),由联合国教科文组织选定。4 月 23 日是西班牙文豪塞万提斯的忌日,也是加泰罗尼亚地区大众节日“圣乔治节”,是莎士比亚出生和去世的日子,还是许多作家(纳博科夫·美、莫里斯·德鲁昂·法、拉克斯内斯·冰岛·诺贝尔文学奖得主等)的生日。

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

  • 前者用来规定某类型的各对象之间所具备的自然顺序(natural order),后者用来表示另一种排序机制可以由需要提供排序功能的类型来实现。
  • IComparable 接口只有一个方法,就是 CompareTo(),该方法遵循长久以来所形成的惯例:若本对象小于另一个受测对象,则返回小于 0 的值,以此类推。
  • 非泛型的 IComparable 有许多缺点,为何还要实现它呢?一是为了向后兼容,二是满足那些确实需要这些方法的人。

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

  • 为泛型类指定约束条件会对开发者自身及该类的用户产生两方面的影响。第一,会把程序在运行时可能发生的错误提前暴露于编译期。第二,相当于明确告诉你该类的用户在通过泛型类来创建具体的类型时所提供的类型参数必须满足一定的条件。
  • 泛型类本身也可能需要以惰性初始化的形式根据类型参数去创建实例,并实现 IDisposable 接口,这需要多写一些代码,如果想创建出来实用的泛型类,必须这么做才行。

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

  • 变体(type variance)机制,尤其是协变(covariance)与逆变(contravariance)确定了某类型的值在什么样的情况下可以转换成其他类型的值。在定义泛型类与委托的时候,应该尽量令其支持协变与逆变。这样做可以使 API 运用得更为广泛,也更加安全。如果某个类型的值无法当成另外一种类型的值来使用,那么称为不变(invariant)。
  • C#语言允许开发者在泛型接口与委托中运用 in 与 out 修饰符,以表达他们与类型参数之间的逆变与协变关系。你在定义接口与委托的时候,应该充分地运用这两个修饰符,使得编译器能够根据这些定义把与变体有关的错误找出来。

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

  • C#为开发者所提供的约束似乎比较有限,你只能要求某个泛型参数所表示的类型必须继承自某个超类、实现某个接口、必须是引用类型、必须是值类型或者必须具备无参数的构造函数。此外还有很多要求无法通过这些约束来表达。比如你可能要求泛型参数所表示的类型必须提供某些静态方法,或者要求该类型必须具备某种其他形式的构造函数。
  • 你可能会要求用户提供的类型必须支持某种运算符、必须拥有某个静态方法、必须与某种形式的委托相符或是必须能够以某种方式来构造,这些要求其实都可以用委托来表示。也就是说,你可以定义相应的委托类型,并要求用户在使用泛型类的时候必须提供这样的委托对象。
  • 总之,如果你在设计泛型的时候需要对用户所提供的的类型提出要求,但这种要求又不便以 C#内置的约束条件来表达,那么就应该考虑通过其他办法来保证这一点,而不能放弃这项要求。

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

  • 一般来说,在已经有了泛型版本的前提之下,即便想要给某个类及其子类提供特殊的支持,也不应该轻易去创建专门针对该类的重载版本。这条原则同样适用于接口。但是数字类型(numeric type)不会有这个问题,因为整数与浮点数等数字类型之间是没有继承关系的。
  • 如果你想专门针对某个类型创建与已有的泛型方法相互重载的方法,那么必须同时为该类型的所有子类型也分别创建对应的方法(否则,在以子类型的对象为参数来调用方法时,编译器会把泛型方法视为最佳方法,而不去调用你针对基类所创建的那个版本)。

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

  • 用户可能会给出很多套符合约束的泛型参数,而 C#编译器则必须针对每一套泛型参数都生成一份完整的 IL 码,用以表示与这套参数相对应的泛型类。
  • 在两种情况下,必须把类写成泛型类:第一种情况,该类需要将某个值用作其内部状态,而该值的类型必须以泛型来表达(例如集合类);第二种情况,该类需要实现泛型版的接口。除此之外的情况,都应该考虑使用包含泛型方法的非泛型类来实现。
  • 好处:调用简单。修改灵活,类似于参数的重载,在这里是类型参数的重载。
你觉得这篇文章怎么样?
  • 0
  • 0
  • 0
  • 0
  • 0
  • 0
评论
  • 按正序
  • 按倒序
  • 按热度