前言

为了提升一下自己的姿势水平,买了 Effective C#More Effective C# 两本书,想一边阅读一边强迫自己做点笔记(主要是自己的理解),不然怕是看了就忘。另外手头还有一本《深入解析C# ( C# in Depth )》其实我已经阅读了一遍,但感觉没咋读懂,下次有机会也用这种方式重读一遍。

这半个月要回家过年,得等年后才能继续更新了。今天这篇是 Effective C# 的第二章。

第二章 .NET的资源管理

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

简单来说.Net提供了垃圾回收器GC,帮助控制托管内存,让开发者不用担心内存泄漏等问题。GC在运行时会判断不再使用的对象为垃圾,将其回收,并压缩托管堆,把剩余的活动对象转移到连续的内存区域上。而非托管资源回收需要由开发者来管理控制。NET中提供释放非托管资源的方式主要是:finalizer(终结器)和IDisposeable接口。

finalizer在C#中和C++类似,是通过Destructor()析构函数实现的,实际函数名为类名前面加上一个~,比如:

class UnManagedClass
{
    ~UnManagedClass()
    {
        Console.WriteLine("被释放了");
    }
}

但这种方式适用于C++,并不适用于C#,因为GC回收得并不及时,只能保证finalizer会被执行,但不确定何时执行。而且当GC回收垃圾时,发现该对象需要运行finalizer,就不得不先挂起回收操作来运行finalizer,等到下一次才能把其回收掉。同时GC还有代(generation)的概念,分为第0,1,2代,每运行完一次GC,剩余留在内存里的对象就会长一代(用来区分短期变量和长期变量,短期变量更有可能是垃圾),GC对于越后面的代,检测的次数越少,这会导致拥有finalizer的对象会长时间占在内存里得不到回收。

因此释放资源首选实现IDisposeable接口,然后主动调用Dispose()方法来释放资源。

// 定义了class UnManagedClass : IDisposeable

// 在finally中调用Dispose()确保资源被释放
var unManaged = new UnManagedClass();
try
{
    // 操作unManaged
}
finally
{
    unManaged.Dispose();
}

// 更通常的情况是使用using
using (var unManaged = new UnManagedClass())
{
    // 操作unManaged

}//到此,using域结束,unManaged自动释放

注:C# 8.0提供了新的using声明语法,不需要原来的大括号了,在using的变量的生命周期结束后将自动释放。

public void DoSomeWork()
{
    using var unManaged = new UnManagedClass()
    // 操作unManaged

}// 到此,unManaged生命周期结束,自动释放

详细的IDisposeable接口实现将在第17条讲述。

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

如果都把字段的初始值设定放在构造函数里做的话,在构造函数多了之后可能就会忘记给部分字段设定初始值,所以不如在声明字段的时候就直接初始化。

但书中也给了三种不应该使用初始化语句的情况:

第一种:把对象初始化成0和null。

因为默认的初始化逻辑就是把对象初始化成0或null,自己手动再度设0和null没有意义。

第二种:构造函数都各自以不同方式初始化了字段。

public class MyClass
{
    private List<string> _labels = new List<string>();

    public MyClass()
    {
    }

    public MyClass(int size)
    {
        _labels = new List<string>(size);
    }
}

// 就类似于
public class MyClass
{
    private List<string> _labels;

    public MyClass()
    {
        _labels = new List<string>();
    }

    public MyClass(int size)
    {
        // 白白创建了一个List然后舍弃
        _labels = new List<string>();
        _labels = new List<string>(size);
    }
}

不过我觉得书本这里逻辑也有点问题,本来前面就是说为了防止因构造函数太多导致漏掉字段赋初值,所以要在声明时初始化。结果这里又说如果构造函数都进行了初始化,那么就不要在声明的时候初始化了,那万一漏掉了怎么办,有点矛盾。还是老老实实、规规矩矩检查代码最重要。

第三种:初始化变量的时候可能抛出异常。

因为在声明的时候初始化是没有办法使用try-catch语句的,所以碰到这种情况应该把初始化过程放到构造函数里进行。

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

创建某类型的实例之前,应先初始化该类型的静态成员,这个工作交由静态构造函数进行(当然简单的初始化直接用初始化语句就可以了)。在初次访问这个类的方法、成员之前会执行静态构造函数。静态构造函数最常见的用途是实现单例模式(单例模式的介绍实现)。

所以给类中的静态成员设置初始值,简单的话直接用初始化语句,复杂的话,比如会抛出异常要做处理的,那么就使用静态构造函数。

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

有些时候需要实现各种输入参数的构造函数,里面有很大一部分逻辑是相同的,为了图方便有的人可能就直接把相同代码复制粘贴(书里还特别写了:笔者觉得你应该不是这种人吧)。正确的写法应该是把重复的逻辑放到一个共同的构造函数中,然后其他构造函数来调用该构造函数。这样写能减少重复代码,而且编译器也不会因为这种写法而反复调用基类的构造函数。下面是示例

class Student
{
    public string Name { get; set; }
    public int Age { get; set; }

    // 用:修饰符调用其他的构造函数
    public Student() : this(string.Empty, 18)
    {
    }

    public Student(int age) : this(string.Empty, age)
    {
    }

    // 相同的逻辑放到一个构造函数里
    public Student(string name, int age)
    {
        Name = name;
        Age = age;
    }
}

看起来似乎跟把相同的初始化逻辑都放到一个单独的方法里,然后所有构造函数都调用这个方法的方式也没有什么不同?实际上这样做的效率要比链式调用构造函数低。因为这样的话,编译器无法合并构造函数中的相同操作,不得不在每个构造函数里都调用基类构造函数,然后执行这个共同用于初始化的方法。而且像readonly变量只能通过初始化语句或者构造函数进行初始化,无法在其他方法中赋值,因此要么选择链式调用初始化,要么只能在不同构造函数里分别初始化。

另外,除了重载,也可以利用默认参数实现:

class Student
{
    public string Name { get; set; }
    public int Age { get; set; }

    // 默认值必须为编译时常量,用string.Empty会报错
    public Student(string name = "", int age = 18)
    {
        Name = name;
        Age = age;
    }
}

使用默认参数的写法较为简洁,也会让用户使用起来更方便。如果参数很多,那么用重载就需要编写大量不同版本的构造函数。因此比较推荐使用默认参数来实现构造函数。

但是有部分问题需要注意,首先是比如带有new()约束的泛型类或泛型方法需要的是真正的无参构造函数,如果只有所有参数都有默认值的构造函数也是不满足条件的,因为实际上实现的还是有参构造函数。所以如果开发者允许用户通过new()来调用构造函数,那么即使有所有参数都有默认值的构造函数,也应该实现一个真正的无参构造函数,让new()这种调用方法在所有场合都是可以使用的。

class Student
{
    public string Name { get; set; }
    public int Age { get; set; }

    // 无参构造函数
    public Student() : this("", 18)
    {
    }

    public Student(string name = "", int age = 18)
    {
        Name = name;
        Age = age;
    }
}

其次是方法的默认值作为编译时常量,有点类似第一章提到的const的行为,在编译时就会用实际值替换写死在程序中。如果调用的是外部的带有默认值参数的方法,某天被调用的该方法更新了默认值,但调用的程序没有重新编译,那么调用时仍然会使用旧的默认值,类似下面这样

// Student类来自于其他库
var student = new Student();
// 实际编译和调用时类似于
var student = new Student("",18);

// 某天该库更新了版本,把Student构造函数中name的默认值改为了"unknown"
public Student(string name = "unknown", int age = 18)

// 但我们的程序未重新编译
var student = new Student();
// 实际运行时调用的还是
var student = new Student("", 18);

不过在网上看到一个解决方法,用0或null作为默认行为的哨兵值。当被调用方法检测到参数为null或0时,就知道该使用默认参数,于是在方法中替换为真正的默认值。这样就能避免上面外部库中方法默认值更新带来的问题。

class Student
{
    public string Name { get; set; }
    public int Age { get; set; }

    public Student() : this(null, 0)
    {
    }

    // null和0作为哨兵值
    public Student(string name = null, int age = 0)
    {
        // 如果传入的是默认值(哨兵值),就替换为真正的默认值
        Name = name ?? "unknown";
        Age = age == 0 ? 18 : age;
    }
}

回顾一下C#中对象的初始化工作的整个顺序,当构建某个类型的第一个实例的时候:

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

之后再次构建该类型的其他实例,会直接从第5步开始执行。另外,可以通过链式调用构造函数来优化第6、7步。

总的来说,C#的编译器一定会保证变量得到了某种初始化,至少是其使用的内存已经清空了。因此开发者需要做的就是保证变量得到初始化且只进行一次初始化。简单的初始化直接用初始化语句即可,复杂的初始化用构造函数实现,并且使用链式调用构造函数的方式来简化代码。

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

在堆上创建和销毁对象都是需要时间的,创建过多无谓的对象会大幅度降低性能。防止频繁创建局部对象有几个技巧。

第一个,如果在频繁调用的方法中需要创建同一个类型的对象,请考虑把它从局部变量改为成员变量,以实现复用。只有调用相当频繁的时候才值得这样做。

public class MyClass
{
    // 假设会频繁调用UseAnotherClass()
    // 每次调用都会创建一个新AnotherClass对象,调用完后销毁
    public void UseAnotherClass()
    {
        var anotherClass = new AnotherClass();
        anotherClass.DoSomeWork();
    }
}

// 改为下面这种

public class MyClass
{
    // 把局部变量改为成员变量
    AnotherClass anotherClass = new AnotherClass();

    public void UseAnotherClass()
    {
        // 每次调用同一对象,避免重复创建和销毁
        anotherClass.DoSomeWork();
    }
}

第二个,采用依赖注入。追求的是复用需要使用的对象,同时可以避免创建未使用的对象。不过我感觉这里有点问题,书上举的例子其实更类似单例模式?

// 书上的例子
// Brushes类型中包含成员Black
// 调用Brushes.Black时,如果blackBrush未创建,则新创建并返回
// 否则返回已有的成员变量blackBrush

private static Brush blackBrush;

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

        return blackBrush;
    }
}

依赖注入的话,我是在ASP.NET Core中接触到的,确实也能实现对象的复用,不过感觉更重要的作用还是解耦合。原理就是类型A中的方法或成员需要依赖于类型B的对象,现在改为依赖对应接口IB。这样类型A的对象a不需要控制该B对象的生成,只需要在外部生成b然后传入a中使用即可。一旦后面替换成使用B对象b2,直接给a传入b2即可,从而实现了解耦合。

// 原设计
public class Music
{
    // 跟LuoTianyi耦合了
    private LuoTianyi _luoTianyi = new LuoTianyi();

    public void Play(string content)
    {
        _luoTianyi.Sing(content);
    }
}

// 如果更换使用到的类型要大改
public class Music
{
    // 又跟HatsuneMiku耦合了
    private HatsuneMiku _hatsuneMiku = new HatsuneMiku();

    public void Play(string content)
    {
        _hatsuneMiku.Sing(content);
    }
}

// 改为依赖注入,实现解耦合
// 同时也可以达到复用对象的目的
public class Music
{
    // LuoTianyi和HatsuneMiku均实现了IVocaloid
    private IVocaloid _vocaloid;

    public Music(IVocaloid vocaloid)
    {
        _vocaloid = vocaloid;
    }

    public void Play(string content)
    {
        _vocaloid.Sing(content);
    }
}

第三个技巧是针对不可变对象的,大家最熟悉的就是字符串类型,不应该用+号频繁拼接字符串这个也是老生畅谈的问题了,因为会生成大量不需要的子字符串变成垃圾,增加GC压力。正确做法应该是,简单的用内插字符串,复杂的用StringBuilder。类似的,其他不可变类型也应该尽量使用对应的builder类来操作。

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

简单来说就是如果在构造函数里面调用虚函数逻辑很混乱,细节不一定能搞清楚,最终导致出现问题,书上举了个例子:

class B
{
    protected B()
    {
        VFunc();
    }

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

class Derived : B
{
    private readonly string _msg = "Set by initializer";

    public Derived(string msg)
    {
        _msg = msg;
    }

    protected override void VFunc()
    {
        Console.WriteLine(_msg);
    }

    public static void Main()
    {
        var d = new Derived("Constructed in main");
    }
}

请问输出是什么?

class B
{
    // 4. 在基类构造函数中调用了方法VFunc()
    protected B()
    {
        VFunc();
    }

    // 5. 发现VFunc()是虚函数,转而调用子类中覆写的VFunc()
    protected virtual void VFunc()
    {
        Console.WriteLine("VFunc is B");
    }
}

class Derived : B
{
    // 2. 先执行实例变量的初始化语句
    private readonly string _msg = "Set by initializer";

    // 3. 然后执行构造函数 
    //    在调用子类构造函数之前,会先调用基类的构造函数
    public Derived(string msg)
    {
        _msg = msg;
    }

    // 5. 此时的_msg已经被初始化语句所初始化,但还未被构造函数修改
    //    输出结果是"Set by initializer"
    protected override void VFunc()
    {
        Console.WriteLine(_msg);
    }

    public static void Main()
    {
        // 1. 创建Derived实例
        var d = new Derived("Constructed in main");
    }
}

所以答案是"Set by initializer"。可以看到非常绕且完全没有必要,因此绝对不要在构造函数里面调用虚函数。

第17条 实现标准的dispose模式

第11条中讲到释放资源首选实现IDisposeable接口,实际上我们要实现的不止IDisposeable接口,而应该实现标准的dispose模式。

要实现dispose模式,书上给出了一些规则:

根部的基类需要做到:

  • 实现IDisposeable接口,以便释放资源。
  • 如果本身包含非托管资源,就要添加finalizer,防止使用者忘记调用Dispose()。尽管11条中说过使用finalizer不利于性能,但至少能保证资源被释放。如果没有非托管资源就不用加。
  • Dispose()和finalizer都应该把释放资源的工作交给虚方法完成,这样子类能够重写该方法来释放他们自己的资源。

子类应该做到:

  • 如果子类有自己的资源要释放,就应该重写基类提供的释放资源的虚方法。
  • 如果子类自身的某个成员表示非托管资源,要实现finalizer,防止用户忘记调用Dispose()导致资源泄露。
  • 记得调用基类的同名函数。

IDisposeable接口中只包含了Dispose()这一个方法,实现Dispose()时需要注意四点:

  • 把非托管资源全部释放掉。
  • 把托管资源全部释放掉。
  • 设定相关的状态标志,表示该对象已被清理。如果访问该对象时从标志得知已被清理,抛出ObjectDisposedException
  • 阻止垃圾回收期重复清理该对象。通过GC.SuppressFinalize(this)来完成。

另外前面说过Dispose()和finalizer都应该把释放资源的工作交给虚方法完成,这样子类能够重写该方法来释放他们自己的资源。因此我们应当重载一个protected virtual void Dispose(bool isDisposing)来完成实际的资源释放工作,让Dispose()和finalizer都调用它。子类可以覆写这个虚方法,编写代码清理自身的资源并调用基类方法来清理基类资源。isDisposing代表了是否是通过Dispose()调用的,如果未true则同时清理托管和非托管资源;如果为false则表明是finalizer调用的,只需要清理非托管资源。如果是子类的话,还要调用基类的Dispose(bool)方法。

一个标准的dispose模式实现如下:

public class BaseClass : IDisposable
{
    private bool _isDisposed = false;

    // 虚Dispose(bool)方法,用于实际释放资源
    protected virtual void Dispose(bool isDisposing)
    {
        // 防止多次释放
        if (!_isDisposed)
        {
            if (isDisposing)
            {
                // 通过Dispose()调用 
                // 在这里释放托管资源
            }
            
            // 在这里释放非托管资源

            // 设置标志
            _isDisposed = true;
        }
    }

    // 实现IDisposable接口
    public void Dispose()
    {
        // 调用虚方法
        Dispose(true);
        // 防止被GC重复清理
        GC.SuppressFinalize(this);
    }

    // finalizer,只有在有非托管资源的情况下才应该实现
    ~BaseClass()
    {
        Dispose(false);
    }

    public void ExampleMethod()
    {
        // 在释放后尝试调用该对象的方法和成员
        // 抛出ObjectDisposedException异常
        if(_isDisposed)
        {
            throw new ObjectDisposedException();
        }
    }
}

如果有子类继承该类的话,子类的实现为:

public class ChildClass : BaseClass
{
    // 子类有自己的标志,要和基类的区分开来
    private bool _isChildDisposed = false;

    // 覆写虚Dispose(bool)方法
    protected override void Dispose(bool isDisposing)
    {
        // 防止多次释放
        if (!_isDisposed)
        {
            if (isDisposing)
            {
                // 在这里释放托管资源
            }
            
            // 在这里释放非托管资源

            // 调用基类的Dispose(bool)方法释放基类资源
            base.Dispose(isDisposing);

            // 设置标志
            _isChildDisposed = true;
        }
    }
}

Dispose()和finalizer最好只用来释放资源,如果加入其他操作的话请考虑清楚,防止导致本该已经宣告消亡的该对象重新被其他地方保留,因为理论上该对象已经被终结了,所以不会再被GC所清理,导致残留。

按照上面的模板编写标准的dispose模式,既方便了自己,也方便了用户和从该类中派生子类的开发者。因此应该让自己养成这个好习惯,在要实现dispose模式时尽可能按照标准模板去编写。

第二章完。

如果觉得我的文章对你有用,请随意赞赏