Fluent Blog
首页
归档
友链
关于
Fluent Blog
现在向第一缕阳光宣誓,走出尘埃与那茫然彷徨。
首页
归档
友链
关于
Effective C# 阅读笔记(二)
技术相关
余弦G
2022/1/21
84
0
## 前言 为了提升一下自己的姿势水平,买了 *Effective C#* 和 *More Effective C#* 两本书,想一边阅读一边强迫自己做点笔记(主要是自己的理解),不然怕是看了就忘。另外手头还有一本《深入解析C# ( *C# in Depth* )》其实我已经阅读了一遍,但感觉没咋读懂,下次有机会也用这种方式重读一遍。 这半个月要回家过年,得等年后才能继续更新了。今天这篇是 *Effective C#* 的第二章。 ## 第二章 .NET的资源管理 ### 第11条 理解并善用.NET的资源管理机制 简单来说.Net提供了垃圾回收器GC,帮助控制托管内存,让开发者不用担心内存泄漏等问题。GC在运行时会判断不在使用的对象为垃圾,将其回收,并压缩托管堆,把剩余的活动对象转移到连续的内存区域上。而非托管资源回收需要由开发者来管理控制。NET中提供释放非托管资源的方式主要是:finalizer(终结器)和`IDisposeable`接口。 finalizer在C#中和C++类似,是通过`Destructor()`析构函数实现的,实际函数名为类名前面加上一个`~`,比如: ```csharp class UnManagedClass { ~UnManagedClass() { Console.WriteLine("被释放了"); } } ``` 但这种方式适用于C++,并不适用于C#,因为GC回收得并不及时,只能保证finalizer会被执行,但不确定何时执行。而且当GC回收垃圾时,发现该对象需要运行finalizer,就不得不先挂起回收操作来运行finalizer,等到下一次才能把其回收掉。同时GC还有代(generation)的概念,分为第0,1,2代,每运行完一次GC,剩余留在内存里的对象就会长一代(用来区分短期变量和长期变量,短期变量更有可能是垃圾),GC对于越后面的代,检测的次数越少,这会导致拥有finalizer的对象会长时间占在内存里得不到回收。 因此释放资源首选实现`IDisposeable`接口,然后主动调用`Dispose()`方法来释放资源。 ```csharp // 定义了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的变量的生命周期结束后将自动释放。 ```csharp public void DoSomeWork() { using var unManaged = new UnManagedClass() // 操作unManaged }// 到此,unManaged生命周期结束,自动释放 ``` 详细的`IDisposeable`接口实现将在第17条讲述。 ### 第12条 声明字段时,尽量直接为其设定初始值 如果都把字段的初始值设定放在构造函数里做的话,在构造函数多了之后可能就会忘记给部分字段设定初始值,所以不如在声明字段的时候就直接初始化。 但书中也给了三种不应该使用初始化语句的情况: 第一种:把对象初始化成0和null。 因为默认的初始化逻辑就是把对象初始化成0或null,自己手动再度设0和null没有意义。 第二种:构造函数都各自以不同方式初始化了字段。 ```csharp 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条 用适当的方式初始化类中的静态成员 创建某类型的实例之前,应先初始化该类型的静态成员,这个工作交由静态构造函数进行(当然简单的初始化直接用初始化语句就可以了)。在初次访问这个类的方法、成员之前会执行静态构造函数。静态构造函数最常见的用途是实现单例模式(单例模式的[介绍](https://www.runoob.com/design-pattern/singleton-pattern.html)、[实现](https://www.cnblogs.com/zh7791/p/7930342.html))。 所以给类中的静态成员设置初始值,简单的话直接用初始化语句,复杂的话,比如会抛出异常要做处理的,那么就使用静态构造函数。 ### 第14条 尽量删减重复的初始化逻辑 有些时候需要实现各种输入参数的构造函数,里面有很大一部分逻辑是相同的,为了图方便有的人可能就直接把相同代码复制粘贴(书里还特别写了:笔者觉得你应该不是这种人吧)。正确的写法应该是把重复的逻辑放到一个共同的构造函数中,然后其他构造函数来调用该构造函数。这样写能减少重复代码,而且编译器也不会因为这种写法而反复调用基类的构造函数。下面是示例 ```csharp 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`变量只能通过初始化语句或者构造函数进行初始化,无法在其他方法中赋值,因此要么选择链式调用初始化,要么只能在不同构造函数里分别初始化。 另外,除了重载,也可以利用默认参数实现: ```csharp 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()`这种调用方法在所有场合都是可以使用的。 ```csharp 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的行为,在编译时就会用实际值替换写死在程序中。如果调用的是外部的带有默认值参数的方法,某天被调用的该方法更新了默认值,但调用的程序没有重新编译,那么调用时仍然会使用旧的默认值,类似下面这样 ```csharp // 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时,就知道该使用默认参数,于是在方法中替换为真正的默认值。这样就能避免上面外部库中方法默认值更新带来的问题。 ```csharp 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条 不要创建无谓的对象 在堆上创建和销毁对象都是需要时间的,创建过多无谓的对象会大幅度降低性能。防止频繁创建局部对象有几个技巧。 第一个,如果在频繁调用的方法中需要创建同一个类型的对象,请考虑把它从局部变量改为成员变量,以实现复用。只有调用相当频繁的时候才值得这样做。 ```csharp 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(); } } ``` 第二个,采用依赖注入。追求的是复用需要使用的对象,同时可以避免创建未使用的对象。不过我感觉这里有点问题,书上举的例子其实更类似单例模式? ```csharp // 书上的例子 // Brushes类型中包含成员Black // 调用Brushes.Black时,如果balckBrush未创建,则新创建并返回 // 否则返回已有的成员变量balckBrush private static Brush balckBrush; public static Brush Black { get { if (balckBrush == null) { balckBrush = new SolidBrush(Color.Black); } return balckBrush; } } ``` 依赖注入的话,我是在ASP.NET Core中接触到的,确实也能实现对象的复用,不过感觉更重要的作用还是解耦合。原理就是类型`A`中的方法或成员需要依赖于类型`B`的对象,现在改为依赖对应接口`IB`。这样类型`A`的对象`a`不需要控制该`B`对象的生成,只需要在外部生成`b`然后传入`a`中使用即可。一旦后面替换成使用`B`对象`b2`,直接给`a`传入`b2`即可,从而实现了解耦合。 ```csharp // 原设计 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条 绝对不要在构造函数里面调用虚函数 简单来说就是如果在构造函数里面调用虚函数逻辑很混乱,细节不一定能搞清楚,最终导致出现问题,书上举了个例子: ```csharp 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"); } } ``` 请问输出是什么? ```csharp 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模式实现如下: ```csharp 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(); } } } ``` 如果有子类继承该类的话,子类的实现为: ```csharp 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模式时尽可能按照标准模板去编写。 第二章完。
公告栏
欢迎来到Fluent Blog,一个基于ASP.NET Core 5.0 MVC的博客。前往主站请访问www.cosineg.com
文章目录
文章标签
#C#
#编程
热门文章
阅读量
评论量
1 - WPF控件LiveCharts——实现动态折线图
590
2 - AX200断流断网解决方法
427
3 - 校园网实现自动网页认证
322
4 - Fluent Blog上线了
276
5 - 记一个ASP.NET调用原生C++ DLL的坑
264
1 - 写了一个OpenCC.NET
2
2 - Fluent Blog上线了
2
3 - WPF控件LiveCharts——实现动态折线图
1
4 - 正在用ASP.NET Core写一个博客框架
1
5 - 校园网实现自动网页认证
1