Effective C#

本章节目录

一、C# 语言习惯

1 使用属性而不是可访问的数据成员

2 使用运行时常量(readonly)而不是编译时常量(const)

  • C# 有两种类型的常量:编译时常量和运行时常量。
  • 尽量使用运行时常量,而不是编译时常量。
/// <summary>
        /// 编译时常量
        /// </summary>
        public const int Num = 100;

        /// <summary>
        /// 运行时常量
        /// </summary>
        public static readonly int Year = 2017;
  • 编译时常量只能用于数字和字符串,运行时常量也是一种常量,因为在构造函数执行后它不能被再次修改。
  • const 比 readonly 效率高,但灵活性低。

3 推荐使用 is 或 as 操作符而不是强制类型转换

  • as 比强转更加高效、安全。
  • as 操作符不能配合值类型使用,因为值类型永远不可能为 null。

4 使用 Conditional 特性而不是 #if 条件编译

public static void Test()
        {
            string msg = null;

            #if DEBUG
            msg = "Hi";
            #endif

            Console.WriteLine(msg);
        }

假如你是将这块代码在 Release 版本中执行的话,就会输出空行。出现 Bug 的原因是我们把程序中的主要逻辑代码和条件编译代码混在一块了。这会让我们很难察觉不同版本间的差异,导致错误的行为发生。

5 为类型提供 ToString() 方法

  • 应该为类型提供一个合适的 ToString() 版本,否则使用者会根据类的一些属性来自行构造并用于显示。
  • object 默认提供的 ToString() 方法会返回类型的完整名称,意义不大。如:System.Drawing.Rect。
  • 重写所有类型的 ToString(),可以简单明了的显示对象的摘要信息。

6 理解几个等同性判断之间的关系

  • 系统提供 4 种函数判断两个对象是否“相等”。
  • 对于前两种方法,我们永远不要重新定义,我们通常要重写 Equals 方法。
  • 重写 Equals 的类型也要实现 IEquatable<T>,如果是结构体的话需要实现 IStructuralEquatable。
  • 引用同一个 DataRow,会认为相等,如果想比较内容的话,而不是引用地址,那么就应该重写 Equals() 实例方法。
  • Equals() 实例方法的重写原则:对于所有的值类型,都应该重写 Equals() 方法,对于引用类型,如果不能满足需要时才去重写该方法。重写该方法的同时也需要重写 GetHashCode() 方法。
  • operator == ():只要创建的是值类型,都必须重新定义 operator == (),因为系统默认是通过反射来比较两个值是否相等,效率过低。

7 理解 GetHashCode() 的陷阱

  1. 对于我们实现的大多数类型来说,避免实现 GetHashCode()。
  2. GetHashCode() 的重载版本必须遵循以下三条原则:
  • 如果两个对象相等(由 operator == 定义),那么它们必须生成相同的散列码。
  • 对于任何一个对象 A,A.GetHashCode() 必须保持不变。
  • 对于所有的输入,散列函数应该在所有整数中按照随机分布生成散列码。

8 推荐使用查询语法而不是循环

  示例:

//1.使用循环
            var foo = new int[100];

            for (int i = 0; i < 100; i++)
            {
                foo[i] = i * i;
            }

            //使用查询语法
            var foo2 = (from n in Enumerable.Range(0, 100) select n * n).ToArray();
  • 有些方法语法没有对应的查询语法,如 Take、TaskWhile、Skip、SkipWhile、Min、Max 等,就需要使用方法语法。

9 避免在 API 中使用转换操作符

10 使用可选参数减少方法重载的数量

  • 对于程序集的第一次发布,可以随意使用可选参数和命名参数。而在进行后续发布时,必须为额外的参数创建重载。这样才能保证现在的程序仍能正常运行。此外,在任何的后续发布中,都要避免修改参数的名称,因为参数名称已经成为公有接口的一部分。

11 理解短小方法的优势

  • 我们最好尽可能地编写出最清晰的代码,将优化工作交给 JIT 完成。一个常见的错误优化是,我们将大量的逻辑放在一个函数中,以为这样可以减少额外的方法调用开销。
public string Test(bool isTrue)
        {
            var sb = new StringBuilder();

            if (isTrue)
            {
                sb.AppendLine("A");
                sb.AppendLine("B");
                sb.AppendLine("C");
            }
            else
            {
                sb.AppendLine("E");
                sb.AppendLine("F");
                sb.AppendLine("G");
            }

            return sb.ToString();
        }

  在第一次调用 Test 方法时, if-else 的两个分支都被 JIT 编译,而实际上只需要编译其中一个,修改后:

public string Test2(bool isTrue)
        {
            var sb = new StringBuilder();

            if (isTrue)
            {
                return Method1();
            }
            else
            {
                return Method2();
            }
        }

  现在进行了方法拆分,这两个方法就可以根据需要进行 JIT 编译,而不必第一次进行全部编译。

  • 可以将 if-else 分支中有超过几十条的语句,或者某个分支专门用来处理程序发生的错误,或者 switch 语句中的每个 case 中的代码进行选择性的提取。
  • 短小精悍的方法(一般包含较少的局部变量)会让 JIT 更容易地进行寄存器选择工作,即选择哪些局部变量放在寄存器中,而不是栈上。
  • 尽量编写短小精悍的方法。

二、.NET 资源托管

12 推荐使用成员初始化器而不是赋值语句

成员初始化器:在声明变量时就进行初始化,而不是在每个构造函数中进行。

以下 3 种情况,应避免使用成员初始化器:

  • 当你想要初始化对象为 0 或 null 时。因为系统默认的初始化工作(在所有代码执行前)会将一切设置为 0 或 null,我们做的是一步多余的操作。而且,如果是值类型,那么性能非常差。
MyValueType myVal1; //初始化为 0
MyValueType myVal2 = new MyValueType(); //也是 0

这两条语句都将变量初始化为 0,但第一条是通过设置包含 myVal1 的这一块内存为 0 实现的,而第二条使用的是 initobj 这条 IL 指令,导致了对 myVal2 变量的一次装拆箱操作,这将占用额外性能与时间。

  • 需要对同一个变量执行不同的初始化方式:
class Program
    {
        /// <summary>
        /// 声明并初始化
        /// </summary>
        private List<string> _lables = new List<string>();

        public Program() { }

        public Program(int capacity)
        {
            _lables = new List<string>(capacity);
        }
    }

初始化该类时,假如使用的是带 capacity 的构造函数,那么 List<string> 对象表示初始化了 2 次,头一个就成为了垃圾对象。

  • 将初始化代码放在构造函数的合适理由:可以方便异常管理 try-catch。

13 正确地初始化静态成员变量

  • 在使用类型的实例之前,就应该初始化该类型的所有静态成员变量。静态构造函数是一个特殊的函数,将在其他所有方法、变量或属性被第一次访问之前执行。你可以使用这个函数来初始化静态变量和实现单例模式等操作。
  • 静态初始化器和静态构造函数是初始化类的静态成员的最佳选择。
  • 使用静态构造函数而不是静态初始化器最常见的理由是可以捕捉和处理异常。

14 尽量减少重复的初始化逻辑

1、如果多个构造函数包含类似的逻辑,我们应将其提取到一个公共的构造函数中,这样可以避免代码重复,也可以利用构造函数初始化器生成更高效的代码。

class MyClass
    {
        private List<string> _lables;
        private string _name;

        public MyClass() : this(0, string.Empty)
        {

        }

        public MyClass(int capacity = 0, string name = "")
        {
            _lables = new List<string>(capacity);
            _name = name;
        }
    }

第二个构造函数使用了 “” 来给出 name 的默认值,而不是采用 string.Empty ,因为 string.Empty 并不是一个编译期的常量,而是一个定义在 string 类中的静态属性,所以不能用作参数的默认值。

2、创建某个类型的第一个实例时所进行的操作顺序:

  • 静态变量设置为 0 ;
  • 执行静态变量初始化器;
  • 执行基类的静态构造函数;
  • 执行静态构造函数;
  • 实例变量设置为 0;
  • 执行实例变量初始化器;
  • 执行基类中合适的实例构造函数;
  • 执行实例构造函数。

3、使用初始化器来初始化简单的资源,使用构造函数来初始化需要复杂逻辑的成员,同事不要忘记将调用抽取到一个构造函数中,以便减少重复。

4、构造函数定义中只能使用一个初始化器,要么使用 This() 委托给另一个构造函数,要么使用 base() 调用基类的构造函数。

15 使用 using 和 try/finally 清理资源

  • 使用了非托管系统资源的类型必须显示地使用 IDisposable 接口的 Dispose() 来释放,Using() 语句将生成一个Try/finally 块。

16 避免创建非必要的对象

1、GC 可以很好地管理内存,但不管多高效,分配和销毁堆上的对象总会花费很长时间,如果过多的创建引用对象,那么会对程序的性能产生严重的影响。

public void Paint()
        {
            using (var myFont = new Font("Arial", 10.0f))
            {
                Console.WriteLine($"使用 {myFont} 进行绘画");
            }
        }

假如该方法被非常频繁地调用。每次调用时都会创建另一个 Font 对象,但它包含的内容和之前的是完全一样。GC 每次都要为你清理这些垃圾,显然是非常低效的。

  可以把 myFont 提升为静态变量。

private readonly static Font _myFont = new Font("Arial", 10.0f);

        public void Paint()
        {
            Console.WriteLine($"使用 {_myFont} 进行绘画");
        }

2、降低程序中创建对象数量的方法。

  • 将常用的局部变量提升为成员变量;
  • 提供一个类,存放某个类型常用实例的单例对象。

3、用 StringBuilder 进行复杂的字符串操作

17 实现标准的销毁模式

IDisposable.Dispose() 方法的实现中需要完成如下 4 个任务:

  • 释放所有非托管资源;
  • 释放所有托管资源,包括释放事件监听程序;
  • 设定一个状态标志,表示该对象已经被销毁;
  • 跳过终结操作,调用 GC.SuppressFinalize(this) 即可。

18 区分值类型和引用类型

1、一般来说,我们创建的大部分是引用类型。

2、确定创建值类型的条件有 4 个 

  • 该类型的主要职责在于数据存储;
  • 该类型的公有接口都是由访问其数据成员属性定义的吗?
  • 你确定该类型绝不会有派生类型吗?
  • 你确定该类型永远都不需要多态支持吗?

3、用值类型表示底层存储数据的类型,用引用类型来封装程序的行为。

4、如果你对类型未来的用途不确定,应选择引用类型。

19 保证 0 为值类型的有效状态

  • NET 系统的默认初始化过程会将所有的对象设置为 0,建议将 0 作为枚举类型的默认值。
  • 枚举(enum)必须将 0 设定为枚举值的一个有效选择。所有的枚举值都派生自 System.ValueType。枚举的默认值开始于 0。
  • 在创建自定义枚举值时,请确保 0 是一个有效的选项。若你定义的是标识(flag),那么可将 0 定义为没有选中任何的标志。
enum Week
    {
        None = 0,
        Monday = 1,
        Tuesday = 2,
        Wednesday = 3,
        Thursday = 4,
        Friday = 5,
        Saturday = 6,
        Sunday = 7
    }

20 保证值类型的常量性和原子性

1、常量性:自创建后,其值保持不变。因为不能更改内部状态,就可以省去许多不必要的错误检查,它也是线程安全的,也可以安全地暴露给外界,因为调用者不能改变对象的内部状态。

2、设计常量类型时,要确保没有任何漏洞会导致内部状态被外界更改。因为值类型不能派生,所以不必担心会受到派生类影响。

  不过,如果常量中是可变的引用类型字段的话,我们就应该对这些可变类型进行防御性的复制。

class MyClass
    {
        private readonly object[] _objs;

        public MyClass(ICollection<object> objs)
        {
            _objs = new object[objs.Count];
            objs.CopyTo(_objs, 0);  //复制
        }

        public IEnumerable<object> Objs => _objs;
    }
static void Main(string[] args)
        {
            var objs = new object[10];
            var myClass = new MyClass(objs);
            objs[1] = "hi";

            Console.WriteLine(myClass.Objs.ToArray()[1]);

            Console.Read();
        }

因为数组是引用类型,如果不使用 CopyTo 复制一个副本的话,在外部的 objs 修改就会直接影响 MyClass 中的 _objs,因为他们指向的都是同一个引用。

3、不要盲目地对每一个属性都加上 { get; set; }。

三、使用 C# 表达设计

21 限制类型的可见性

  • 在保证类型可以完成工作的前提下,应该尽可能地给类型分配最小的可见性。
  • 我们经常下意识的创建公有类型。可见性越低,以后升级或更改时所需要的变化就越小,因为能访问你功能模块的代码越少。
  • 创建内部类是一种常被忽略限制类型作用域的做法,我们经常习惯不假思索地创建公有类。你应该仔细思考这个一个类型的作用范围,即它是将被所有的客户使用,还是仅用在这个程序集的内部。
  • 更少的公有类型可以减少单元测试的数量。
  • 以公有形式暴露给外界的类和接口将成为你的组件的契约。接口越冗余,日后的修改就越受限。暴露的公有类型越少,以后更新扩展的时候周旋的余地就会越大。

22 通过定义并实现接口替代继承

  • 抽象基类为类的继承体系提供了一个共用的祖先,接口描述了一组原子性的功能。接口是一种契约,抽象基类则为一组相关的类型提供了一个共用的抽象。基类描述了对象是什么,接口描述了对象如何表现它的行为。
  • 我们可以将可重用的行为提取出来,定义在接口中。由于不相关的类型均可以实现一个接口,这表示代码的重用率将大大增加。
  • 如果向基类中添加一个方法,所有派生类都将自动包含该方法。也就是说,随着时间的推移,仍可以有效扩展多个类型功能的途径。通过向基类添加并实现某种功能,所有的派生类都将立即拥有该功能。而向接口中添加一个成员,会破坏所有实现该接口的类。因为这些类不包含新方法,每一个实现都需要进行更新,然后重新编译。
  • 在抽象基类和接口之间做选择,实际上就表示了对日后可能发生变化的不同处理态度。接口是固定的:我们将一组功能封装在一个接口,作为其他类型的契约。而基类则可以在日后扩展,这些扩展也会成为每个派生类的一部分。
  • 也可以使用扩展方法进行扩展。
public static class Extendsions
    {
        public static void ForAll<T>(this IEnumerable<T> sequence, Action<T> action)
        {
            foreach (var item in sequence)
            {
                action(item);
            }
        }
    }

  方法调用:

IEnumerable<object> objects = new List<string>();
objects.ForAll(x => Console.WriteLine(x));
objects.ForAll(Console.WriteLine);
  • 有时候,使用接口可以帮助我们避免 struct 类型的拆箱所带来的代价。

23 理解接口方法和虚方法的区别

  • 在基类中实现一个接口时,派生类需要使用 new 来隐藏对基类方法的使用。
  • 可以将基类接口的方法申明为虚方法,然后在派生类中实现。

24 用委托实现回调

1、类之间需要通信时,并且我们期望一种比接口所提供的更为松散的耦合机制时,委托就是最佳的选择。

2、多播委托会把所有添加到该委托中的目标函数组合成一个单一的调用。需要注意的是:

  • 如果有委托调用出现异常,那么就不能保证安全;
  • 整个调用的返回值将为最后一个函数调用的返回值。

3、在多播委托调用的过程中,每个目标会被依次调用。委托对象本身不会捕捉任何异常。因此,任何目标抛出的异常都会结束委托链的调用。

25 用事件模式实现通知

  • .NET 中的事件就是观察者模式的一个语法上的快捷实现。
  • 使用 System.ComponentModel.EventHandlerList 容器来存储各个事件处理器,在类型中包含大量事件时可以使用他来隐藏所有事件的复杂性。
  • EventHandlerList并没有提供内建的泛型实现,可以自行基于 Dictionary 构建。

26 避免返回对内部类对象的引用

1、种策略可以防止类型的内部数据结构遭受有意或无意的修改:值类型、常量类型、接口和包装器(wrapper):

  • 当客户代码通过属性来访问值类型成员时,实际返回的是值类型的副本。
  • 常量类型,如:System.String 也是安全的。
  • 通过接口向外界暴露类的功能,即可尽量地避免内部数据遭受无意的更改,定义接口将访问限制在一个子集中从而最小化对对象内部状态的破坏。
  • 仅暴露包装器,定义一个包装器对象来限制另一个对象的访问。

2、通过使用接口、包装器对象或值类型向外界提供内部的私有数据,即可限制外界对这些数据的访问能力。

27 让类型支持序列化

  • [Serializable] 特性支持序列化。
  • [NonSerialized] 特性阻止序列化。

28 提供组粒度的因特网服务 API

  • 程序中最耗时的是与远程服务器之间的数据传输,每次通过网络获取一小段数据时,应用程序都需要等待网络传输的过程,API 的粒度越细,所花费在等待数据返回上的额外时间也就越多。
  • 我们希望同时降低通信的频率以及每次通信时所传递的数据量。但这两个目标往往不可兼得,因此必须做出取舍。尽量不要走两个极端,可以适当选择较少的通信次数,尽量一次传输更多的数据。

29 支持泛型协变和逆变

  • 类型变体:协变和逆变。定义了在某种情况下,某个类型可以代替另一个类型进行使用。若不能将一个类型替换成另一个,则这个类型就叫不变量,在 C# 4.0 之前,所有泛型类型都不变量。你应该尽可能地让泛型接口和泛型委托支持协变和逆变。
  • 协变和逆变是两种不同形式的类型替换。若某个返回的类型可以由派生类型替换,那么这个类型就是支持协变的。若某个参数类型可以由其基类替换,那么这个类型就是支持逆变的。
  • 在可能的情况下为泛型接口和委托添加上 in 和 out 参数进行修饰。
  • 因为 IList<T> 没有添加 in 或 out 修饰 T,所以必须使用精确的类型匹配。

四、使用框架

30 使用重写而不是事件处理函数

  • 处理系统之中触发的事件:要么使用事件处理函数,要么重写基类中的虚方法。在派生类中,你只应该重写虚方法,而事件处理函数则应该使用在对象没有关系的交互中。
  • 从效率角度,重写也比事件处理函数更快,事件处理器需要迭代整个请求列表,这样占用了更多的CPU时间。
  • 但是,事件是在运行时绑定的,因此会带来更好的灵活性。
  • 一个事件处理器抛出异常,则事件链上的其他处理器将不会被调用,而重写的虚方法则不会出现这种情况。
  • 重写只能用于派生类中,其他类型必须使用事件机制。

31 使用 IComparable<T> 和 IComparer<T> 实现顺序关系

  • .NET 提供了两个接口 IComparable<T> 和 IComparer<T> 表示顺序关系。IComparable 定义了类型的自然顺序,而 IComparer 则表示描述其他顺序。
  • IComparable 接口包含一个方法:CompareTo() 。如果当前对象 < 被比较对象,返回值 < 0;当前对象 > 被比较对象,返回值 > 0;两者相等,返回 0。
  • 实现非泛型的 IComparable 接口的原因:保证向后兼容,反射中使用泛型会加大难度。
  • 实现 IComparable 时请使用显示接口实现,并提供一个强类型版本的重载,这个强类型的重载能提高性能,并降低使用者误用 CompareTo 方法的可能。

32 避免使用 ICloneable 接口

  • 当对象关系复杂时,深复制会带来不必要的麻烦。
  • 对于内建类型,如整数,深复制和浅复制的结果一样。
  • 內建的值类型不需要支持 ICloneable。赋值语句就可以复制结构中所有的值,且比 Clone() 更高效;而子类,仅在真正需要复制操作时再添加 ICloneable 支持。
  • 对于值类型,永远不要实现 ICloneable,直接使用赋值操作即可。

33 仅用 new 修饰符处理基类更新

  • new 修饰符必须小心谨慎的使用。如果它是有歧意的,就等于在类上创建了个模糊的方法。
  • 只有在特殊情况下才使用,那就是升级基类时与你的类产生冲突时。即使在这种情况下,也应该小心的使用它。最重要的是,其它任何时候都不要用它。

34 避免重载基类中定义的方法

  1.为基类中定义的方法创建重载增加了重载解析时的可选项,也就是增加了二义性。很可能你对重载选择的理解和编译期的解析并不相同,从而造成了用户的困惑。解决办法:选择不同的名称,因为这个类是你设计的,自然就可以给出更好,不同的的方法名称。

  2.不要重载那些定义于基类中的方法,这不能带来丝毫意义,只能给使用者平添烦恼,但不针对重写。

35 PLINQ 如何实现并行算法

  • 使用时简单的添加 AsParallel() 即可。
  • PLNQ 在能够保证正确性的前提下,让程序得到多核环境下的性能提升。
  • 需要理解何时数据访问是必须同步的,也需要衡量 ParallelEnumerable 中并行和顺序版本方法带来的影响。
  • PLINQ 无法并行化 LINQ to SQL 或 EF 的执行,因为这两样东西会借助数据库引擎来执行并行查询。
  • 每个并行查询都开始于一个分区的操作,PLINQ 需要对输入元素分区,然后指派给负责执行查询的任务。
  • 4 种分区算法:单位分区、区块分区、条带分区和散列分区。
  • 3 种其它算法:管道(Pipelining)、停止并进行(Stop&Go)和反向枚举。
  • 通过在查询开始时添加 AsParallel() 方法,将查询表达式转换成并行执行。
var list = new List<int>();
var query=list.Where(x=>x<150).Select(x=>x.ToString());

//并行查询
var queryParallel = list.AsParallel().Where(x => x < 150).Select(x => x.ToString());

36 理解 PLINQ 在 I/O 密集场景

var urls = new List<string>();
            foreach (var url in urls)
            {
                var result = new WebClient().DownloadData(url); //发出一个同步的 Web 请求,然后等待接收数据,主要会将时间浪费在等待上
                Console.WriteLine(result);
            }

            //使用并行处理模型
            Parallel.ForEach(urls, url =>
            {
                var result = new WebClient().DownloadData(url);
                Console.WriteLine(result);
            });

            //使用 PLINQ
            var results = from url in urls.AsParallel()
                          select new WebClient().DownloadData(url);
            results.ForAll(Console.Write);
  • PLINQ 的执行方式和并行任务库的 Parallel.ForEach() 不同。PLINQ 使用固定数目的线程,而 Parallel.ForEach() 会调整线程的数量来增加吞吐量。
  • 那些混合了 I/O 密集和 CPU 密集的操作来说,Parallel.ForEach() 更适合。Parallel.ForEach() 会根据当前的负载动态调整线程数量。当很多线程因为等待 I/O 操作而阻塞时,Parallel.ForEach() 会创建更多的线程提高吞吐量。当很多线程都在工作时,Parallel.ForEach() 也会限制活动线程的数量,降低上下文切换的代价。
  • 对于那些需要访问其他计算机,并等待远程响应的程序来说,并行任务库和 PLINQ 起到很重要的作用。

37 注意并行算法中的异常

  • 后台线程中发生的异常会在不同的方面增加复杂度。异常不能穿过线程边界保留调用栈,当异常传递到开始线程的方法时,线程就会中止。调用线程无法捕获这个错误,也就不能进行对应的处理。
  • 一旦后台线程抛出异常,其它的后台操作也会停止。最好是不要在并行算法中抛出异常。不过其它意料之外的异常也可能会出现。

五、C# 中的动态编程

38 理解动态类型的优劣

  • C# 动态类型是为了让静态代码能够更加平滑地与其他使用动态类型的环境进行交互,而不是鼓励在一般场景中使用 dynamic 进行动态编程。
  • 只要对象在运行时包含成员,那么即可正常使用。
  • 若是一个操作数(包括 this)为动态类型,那么返回结果也会是动态类型。不过,最后依然要转换成静态类型,以便被其它 C# 代码所使用,以及被编译器感知。
  • 当你需要不知道具体类型的运行时解析方法的时候,动态类型是最佳的工具。如果你能在编译期间明确类型,那么可以使用 lambda 表达式和函数式编程来解决问题。
  • 表达式树,一种在运行时创建代码的方法(下面提供示例)。
  • 大多数情况,可以使用 Lambda 表达式创建泛型 API,让调用者自己动态定义所需要执行的代码即可。
  • 优先使用静态类型,静态类型比动态类型更高效,动态类型和在运行时创建表达式树都会带来性能上的影响,即便这点影响微不足道。
  • 若你能控制程序中所有涉及的类型时,可以引入一个接口,而不是动态类型,即基于接口编程,并让所有需要支持该接口行为的类型都实现该接口。通过 C# 类型系统可以减少代码在运行时所产生的错误,编译器也能够生成更加高效的代码。
  • 动态类型做法的效率比纯粹的静态类型下降挺大,但实现的难度却比解析表达式树要简单地挺多。

这里,我使用 3 种数字相加的方法,dynamic 动态、Func 委托以及使用表达式树进行相加的区别:

[TestMethod]
        public void Test()
        {
            var result1 = AddDynamic(2, 3);
            Console.WriteLine((int)result1);

            var result2 = AddFunc(3, 4, (x, y) => x + y);
            Console.WriteLine(result2);

            var result3 = AddExpressionTree(4, 5);
            Console.WriteLine(result3);
        }

        /// <summary>
        /// Add,动态
        /// </summary>
        /// <param name="a"></param>
        /// <param name="b"></param>
        /// <returns></returns>
        private dynamic AddDynamic(dynamic a, dynamic b)
        {
            return a + b;
        }

        /// <summary>
        /// Add,使用委托
        /// </summary>
        /// <typeparam name="T1"></typeparam>
        /// <typeparam name="T2"></typeparam>
        /// <typeparam name="TR"></typeparam>
        /// <param name="a"></param>
        /// <param name="b"></param>
        /// <param name="func"></param>
        /// <returns></returns>
        private TR AddFunc<T1, T2, TR>(T1 a, T2 b, Func<T1, T2, TR> func)
        {
            return func(a, b);
        }

        /// <summary>
        /// Add,使用表达式树
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="a"></param>
        /// <param name="b"></param>
        /// <returns></returns>
        private T AddExpressionTree<T>(T a, T b)
        {
            ParameterExpression leftOperand = Expression.Parameter(typeof(T), "left");
            ParameterExpression rightOperand = Expression.Parameter(typeof(T), "right");
            BinaryExpression body = Expression.Add(leftOperand, rightOperand);
            Expression<Func<T, T, T>> adder = Expression.Lambda<Func<T, T, T>>(body, leftOperand, rightOperand);

            Func<T, T, T> theDelegate = adder.Compile();
            return theDelegate(a, b);
        }
    }

39 使用动态类型表达泛型类型参数的运行时类型

  • System.Linq.Enumerable.Cast<T> 将序列中的对象转换成 T,从而使得 LINQ 可以配合 IEnumerable 进行工作。
  • Convert<T> 要比 Cast<T> 适用性更广,但同时也会执行更多的工作。

40 将接受匿名类型的参数声明为 dynamic

  • 不要过度使用动态类型,因为动态调用会增加系统的额外开销,即便不大。
  • 长远来看,具体类型更易于维护,编译器和类型系统也会为其提供更好的支持。
  • 扩展方法不能基于动态对象定义。

41 用 DynamicObject 或 IDynamicMetaObjectProvider 实现数据驱动的动态类型

  • 创建带有动态功能的类型的最简单的方法就是继承 System.Dynamic.DynamicObject。若能直接继承 DynamicObject,那么创建动态类就会比较简单。
  • 实现 IDynamicMetaObjectProvider 就意味着需要实现方法 GetmetaObject()。
  • 创建动态类型时首选继承,如果必须使用其他基类,可以手工实现 IDynamicMetaObjectProvider 接口,虽然所有的动态类型都会带来性能上的损失,但这种手工实现接口的方式所带来的损失往往更大一些。

这是一个实现动态类型模型的一个示例。除了需要继承 DynamicObject,还需要重写 TryGetMemebr() 和 TrySetMemebr()。

[TestMethod]
        public void Test1()
        {
            dynamic propDynamic = new DynamicPropertyBag();
            propDynamic.Now = DateTime.Now;

            Console.WriteLine(propDynamic.Now);
        }

        /// <summary>
        /// 动态属性绑定模型
        /// </summary>
        internal class DynamicPropertyBag : DynamicObject
        {
            private readonly Dictionary<string, object> _storage = new Dictionary<string, object>();

            /// <summary>
            /// 获取属性值
            /// </summary>
            /// <param name="binder"></param>
            /// <param name="result"></param>
            /// <returns></returns>
            public override bool TryGetMember(GetMemberBinder binder, out object result)
            {
                var key = binder.Name;

                if (_storage.ContainsKey(key))
                {
                    result = _storage[key];
                    return true;
                }

                result = null;
                return false;
            }

            /// <summary>
            /// 设置属性值
            /// </summary>
            /// <param name="binder"></param>
            /// <param name="value"></param>
            /// <returns></returns>
            public override bool TrySetMember(SetMemberBinder binder, object value)
            {
                var key = binder.Name;

                try
                {
                    if (_storage.ContainsKey(key))
                    {
                        _storage[key] = value;
                    }
                    else
                    {
                        _storage.Add(key, value);
                    }

                    return true;
                }
                catch (Exception e)
                {
                    Console.WriteLine(e);
                    return false;
                }
            }

42 如何使用表达式 API

  • 传统的反射 API 可以用表达式和表达式树进行更好的替代,表达式可以直接编译为委托。
  • 接口使我们可以得到一个更为清晰、也更具可维护性的系统,反射是一个很强大的晚期绑定机制(虽然效率有所降低),.NET 框架使用它来实现 Windows 控件和 Web 控件的数据绑定。

这里提供了一个示例:

[TestMethod]
        public void Test2()
        {
            var result = Call<int, string>((x) => (x * 2).ToString("D"));

            Console.WriteLine(result);
        }

        /// <summary>
        /// 调用
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <typeparam name="TResult"></typeparam>
        /// <param name="op"></param>
        /// <returns></returns>
        private TResult Call<T, TResult>(Expression<Func<T, TResult>> op)
        {
            var exp = op.Body as MethodCallExpression;
            var result = default(TResult);

            if (exp == null)
            {
                return result;
            }

            var methodName = exp.Method.Name;
            var parameters = exp.Arguments.Select(ProcessArgument);

            Console.WriteLine($"方法名 {methodName}");

            foreach (var parameter in parameters)
            {
                Console.WriteLine("参数:");
                Console.WriteLine($"\t{parameter.Item1}:{parameter.Item2}");
            }

            return result;
        }

        /// <summary>
        /// 处理参数
        /// </summary>
        /// <param name="expression"></param>
        /// <returns></returns>
        private Tuple<Type, object> ProcessArgument(Expression expression)
        {
            object arg = default(object);
            LambdaExpression l = Expression.Lambda(Expression.Convert(expression, expression.Type));

            Type parmType = l.ReturnType;
            arg = l.Compile().DynamicInvoke();

            return Tuple.Create(parmType, arg);
        }

43 使用表达式将延迟绑定转换为预先绑定

  • 延迟绑定API要使用符号(symbol)信息来实现,而预先编译好的 API 则无需这些信息,表达式 API 正是二者之间的桥梁。
  • 延迟绑定常见于 Silverlight 和 WPF 中使用的属性通知接口,通过实现 INotifyPropertyChanged 和 INotifyPropertyChanging 接口来实现属性变更的预绑定。

44 尽量减少在公有 API 中使用动态类型

  • 优先使用 C# 的静态类型,并尽可能地降低动态类型的作用范围。若是想一直使用动态特性,你应该直接选用一种动态语言,而非 C#。
  • 若要在程序中使用动态特性,请尽量不要在公有接口中使用,这样会将动态类型限制在一个单独的对象(或类型)中。

六、C# 高效编程要点补充

45 尽量减少装箱拆箱

  • 值类型是数据的容器,不支持多态。
  • 装箱把一个值类型放在一个未确定类型的引用对象中,让该值作为引用类型所使用。拆箱指从引用类型的位置取出值的一个副本。
  • 装箱和拆箱会在无意中创建许多副本,导致许多难以发现的 Bug。
  • 使用接口而不是使用类型可以避免装箱,即将值类型从接口实现,然后通过接口调用成员。
  • 装箱拆箱都是比较影响性能的手段,应该尽可能的避免装拆箱操作。
  • 泛型可以避免装拆箱操作。
  • 值类型装箱是隐式的,难以发现,需要自己多加注意。

46 为应用程序创建专门的异常类

  • 只有当用户会以不同的形式去处理错误时,才应该创建不同的异常类。
  • 自定义的异常类应该以“Exception”结尾,并且继承 System.Exception 或其他异常类。不过你也需要适当地包含该基类中的构造函数,内容直接交给基类实现即可。
/// <summary>
    /// 自定义的异常类
    /// </summary>
    public class MyException : Exception
    {
        public MyException() : base()
        {

        }

        public MyException(string s) : base(s)
        {

        }

        public MyException(string s, Exception e) : base(s, e)
        {

        }

        protected MyException(SerializationInfo info, StreamingContext cxt) : base(info, cxt)
        {
            
        }
    }
  • 在抛出自定义的异常时,应该将原始的异常存放在 InnerException 属性中,这样就可以显示足够友好,并且信息丰富的异常:
public void Do()
        {
            try
            {
                //DoException();
            }
            catch (DoException e)
            {
                var msg = $"该问题是博主故意引起的";
                throw new MyException(msg, e);
            }
        }
  • 只有在存在不同类型的恢复操作时,才应该抛出不同类型的异常,定义异常类时,还要提供基类中支持的所有构造函数。此外,不要忘记使用 InnerException 属性来保存低层次的异常信息。

47 使用强异常安全保证

  • Dave Abrahams 定义了 3 种安全异常来保证程序:基础保证、强保证,以及无抛出保证。
  • 强异常保证:从异常中恢复和简化异常处理之间提供了一个平衡点,如果一个操作因为某个异常中断,程序将维持原状态不变,操作要么彻底完成,要么就不会修改程序的任何状态。强异常保证的好处:任何时候若是捕获了异常,所有将要尝试的正常操作都不继续进行。程序当前的状态如尚未开始进行该操作一样。
  • 对将要修改的数据做防御性的复制,对这些数据的防御性复制进行修改,这中间的操作可能会引发异常,在出现异常时可将临时副本和原对象进行交换。
  • 终结器、Dispose() 方法和委托对象所绑定的目标方法在任何情况下都应当确保他们不会抛出异常。

48 尽量使用安全的代码

  • .NET 运行时可以保证一些怀有恶意的代码不能直接渗透到远程计算机上执行。
  • CLR 中的带有代码的访问安全设置,如:CLR 强制要求基于角色的安全认证,这样才能判断某些代码能否在基于一个特定的角色账号下运行。
  • CLR 可以检查 IL 代码,确保它不存在潜在的危险行为。如:直接访问原始内存。
  • 如果代码不需要任何的安全权限,就不需要使用 CAS 的 API 来判断访问权限,因为这样只会影响性能,额外增加 CLR 的工作量。
  • 你可以使用 CAS 的 API 来访问一些受保护的资源,一般需要额外的权限。如非托管的内存和文件系统、注册表等。
  • 一般来说,我们编写的 C# 代码都是安全的,除非你使用了 /unsafe。
  • 应该尽可能地避免访问非托管内存。确实有需要的话,应该将其隔离在独立的程序集中。

49 实现与 CLS 兼容的程序集

  • .NET 运行环境与语言无关,我们创建的程序集必须与 CLS 保持兼容,这样才能保证其他的开发人员可以用另一种语言来调用你的组件。

50 实现小尺寸、高内聚的程序集

  • 如果我们都将所有的代码放在一个程序集中,这不利于组件的重用,也不利于系统的局部更新,若是可以将其拆分成小程序集,以组件的形式进行重用,就可以从一定程度上简化后续的开发工作。
  • 我们用类进行功能的封装和数据的存储,只有公有的类、结构和接口才应该称为契约,被其它用户(或程序集)进行访问。
  • 建议将程序拆分成多个程序集,把相关的类型放在同一个程序集中。
  • 一个程序集应该是一个包含相关功能的,具有良好组织的一个库。如果不知道怎样的颗粒度最好,可以略微向小而多的方向倾斜。
  • 如果所有参数和返回值都是接口,那么任何一个程序集都可以很容易地用另一个实现了相同接口的程序集进行代替。
  • 更小的程序集同样可以降低程序启动时的开销。更大的程序集要花上更多的 CPU 时间来加载,且需要更多的时间来将必须的 IL 编译成机器指令。虽然只有启动时将被调用的代码会被 JIT 编译,但程序集是整体载入的。
  • 在跨越程序集时,安全性检查会成为一个额外的开销。程序跨越程序集的次数越小,其执行效率越高。
  • 性能的损耗对我们来说可以忽略不计,因为在开发时,我们着重的是代码的灵活度,所以不需要担心将大程序集拆分成小程序集的性能问题。
  • 常见的程序集:小且仅专注于某个特定功能的程序集,大一些但包含通用功能的程序集。无论何种情况,都应该保证其尽可能合理的小,但不应该过度。

分类:

发表回复