前言
为了提升一下自己的姿势水平,买了 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#中对象的初始化工作的整个顺序,当构建某个类型的第一个实例的时候:
- 把存放静态变量的空间清零
- 执行静态变量的初始化语句
- 执行基类的静态构造函数
- 执行(本类的)的静态构造函数
- 把存放实例变量的空间清零
- 执行实例变量的初始化语句
- 适当地执行基类的实例构造函数
- 执行(本类的)实例构造函数
之后再次构建该类型的其他实例,会直接从第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模式时尽可能按照标准模板去编写。
第二章完。
3 comments
您好~我是腾讯云+社区的运营,关注了您分享的技术文章,觉得内容很棒,我们诚挚邀请您加入腾讯云自媒体分享计划。完整福利和申请地址请见:https://cloud.tencent.com/developer/support-plan
作者申请此计划后将作者的文章进行搬迁同步到社区的专栏下,你只需要简单填写一下表单申请即可,我们会给作者提供包括流量、云服务器等,另外还有些周边礼物。
我们诚挚的邀请您并期待您的加入~
感谢给我的拙作点Star~
15条有个typo: balckBrush => blackBrush
IDisposable这接口嘛……如果类里边全是未实现这接口的成员的话, 可以考虑免写
必要时考虑Dispose方法的线程安全
在DI里边通过Get(Required)Service拿到的实例一般不需要自行调用Dispose方法, 其生命周期受DI管理
感谢指点!