第9章 常用的设计模式
9.1 聚合组件(aggregate component)
聚合组件将多个 底层 类型集中到一个 简化的高层 类型中,用于支持常用场景。例如,E-mail 组件,把简单邮件传输协议(SMTP)、套接字、编码(encoding)等等集中在一起。
聚合组件的目的是提供一个 更高层的抽象 ,便于开发者完成(通常很简单的)工作,不仅仅是提供另一种做事情的方式。
一个简单的例子:
string[] lines = File.ReadAllLines(@"c:\foo.txt");
聚合组件应提供所有默认实现,默认参数,以 降低用户使用门槛 。
9.1.1 面向组件的设计(component-oriented design)
面向组件的设计:一种设计方法,通过类型的构造函数、属性、方法及事件定义 API。它的设计要从使用模式出发:① 用 默认/相对简单的构造函数 创建一个实例;② 通过 属性 配置参数;③ 调用方法。这种使用模式被称为“ Create-Set-Call ”。
一个简单的例子:
// Create
var queue = new MessageQueue();// Set
queue.Path = queuePath;
queue.EncryptionRequired = EncryptionRequired.Body;
queue.Formatter = new BinaryMessageFormatter();// Call
queue.Send("Hello World");
queue.Close();
调用方法(Call)时,如果实例工作状态无效,应通过抛出 InvalidOperationException
异常告知用户属性未被正常设置(Set)。
聚合组件是一个 facade,它基于面向组件的设计,并满足下列条件:
- 默认构造函数:聚合组件应该有默认构造函数,便于 创建实例 。
- 非默认构造函数:非默认构造函数的所有参数应该与 属性 相对应,并用来对 属性 进行初始化,以保证 属性 的一致性和完整性。
- 属性:大多数属性应该有 getter 和 setter ,并且所有属性都有合理的默认值。
避免空值或无效值的出现。 - 方法:如果在(主要场景的)方法调用之间 参数不会改变 ,那么方法就不应该带这样的参数。这样的参数应该通过属性来指定。
这样可以简化方法的签名和调用。 - 事件:方法不以 委托 为参数。所有回调函数都通过 事件 来实现。
9.1.2 因子类型(factored type)
因子类型:聚合组件的 内部类型 ,它不应该有 工作状态 (即不存在无效状态),且有非常明确的 生命周期 。它可以通过聚合组件的 属性 或 方法 进行访问。换句话说,他是聚合组件的参数类型。
9.1.3 聚合组件规范
-
CONSIDER
:考虑为 常用的功能 领域提供聚合组件。
-
DO
:聚合组件应该对 高层概念(物理对象) 建模,而非 系统级任务 建模。例如,组件应该对文件、目录、驱动器建模,不应该对流(stream)、格式化器(formatter)、比较器(comparer)进行建模。
-
DO
:通过 响亮的名字 提高聚合组件的可见性。名字要和系统中的实体相对应,比如 MessageQueue、Process、EventLog。这样的类型名更引人注目。
-
DO
:聚合组件要支持 Create-Set-Call 使用模式,且每个步骤都要尽可能地简单,不要让用户在一个场景中显式地实例化多个对象。如果开发者使用方式不符合要求,通过抛出异常告知用户应该怎么做。
聚合组件的 Create-Set-Call 的设计要遵循如下准则:
-
Create
要提供 默认 构造函数或 非常简单的 构造函数,也要提供有参构造函数用于设置 属性 ; -
Set
要提供可读写的属性,便于用户对属性 二次设置 ; -
Call
要使用 事件 ,而非 委托 ;优先使用 事件 ,而非需要被覆盖的虚函数。- 聚合组件的目的是易用, 事件 比 委托 更易于使用。
常用场景中则要求:
-
DON'T
: 不可要求 用户使用继承、覆盖方法及实现接口。组件应该主要靠 属性 及 属性 的组合来改变自己的行为。
-
DON'T
:不可要求用户进行 额外工作 。例如,让用户用配置文件来配置组件、让用户生成资源文件等。
-
CONSIDER
:让聚合组件能够 自动 切换状态。- 例如,
MessageQueue
实例既可以发送消息,也可以接受消息,但不应该让用户感受到模式切换的存在。
- 例如,
-
DON'T
:不要设计有 多种状态 的因子类型因子类型在整个生命周期内应该只有一种状态。例如,Stream 的实例表示一个已经打开的流,要么只能读取,要么只能写入。
-
CONSIDER
:将聚合组件集成到 Visual Studio 的 设计器 中。通过集成可以把组件放到 Visual Studio 的工具箱中,从而增加对拖放、属性网格(property grid)、事件挂接(event hookup)等支持。
只需实现IComponent
接口,或继承实现了该接口的类型,如Component
或Control
,组件便可以与 Visual Studio 集成。 -
CONSIDER
:考虑将聚合组件和因子类型放在 不同 的程序集中。这样组件不会被单一因子类型限制,且不会导致循环依赖性。
该规则要求:因子类型实现相应接口,组件通过接口调用因子类型,该接口应该放在第三方程序集中,避免循环依赖。
-
CONSIDER
:建议 把聚合组件内部的因子类型暴漏给外界访问。因子类型最适于把不同特性域集成在一起。例如,
SerialPort
组件向外界暴漏了它在内部使用的流,这样用户就可以将该组件与其他的可重用 API(比如对流进行压缩的 API)结合起来使用。
9.2 异步模式
.NET 中有三种方式实现异步:
- 经典 异步模式
- 基于 事件 的异步模式
- 基于 任务 的异步模式
9.2.1 选择异步模式
-
DO
:要使用基于 任务 的异步模式来实现新的异步 API。
-
CONSIDER
:建议将经典异步模式或事件异步模式 API 升级为基于 任务 的异步模式。我们可以使用
TaskFactory
和 TaskFacotry<TResult>
类型包装现有的经典异步模式和基于事件的异步模式 API。
9.2.2 基于任务的异步模式
-
DO
:异步方法的命名要使用 Async
后缀。
-
CONSIDER
:建议为异步方法添加 同步 变体。// 异步方法 public Task<ParsedData> ParseAsync(string filename, CancellationToken = default) { ... } // 同步变体 public ParsedData Parse(string filename) { ... }
-
DO
:异步方法要接收 CancellationToken
参数,并命名为“ cancellationToken ”,且提供默认值。它最好作为 最后 一个参数,这样可以更好地与同步方法对齐。// 错误,调用者无法取消 public Task WriteAsync(string text) { ... } // 正确,支持CancellationToken作为可选参数 public Task WriteAync(string text, CancellationToken cancellationToken = default) {... }// 错误,CancellationToken应该作为最后一个参数,即使它没有默认参数 public Task WriteAync(string text, CancellationToken cancellationToken, Encoding encoding) {... }
-
CONSIDER
:需要长时间运行或阻塞的同步方法建议接收 CancellationToken
参数。但该方法不应该返回Task
。
CancellationToken
类型与异步执行或 Task 无关,它同样适用于那些 超时 、由用户 发出中止信号 的同步(阻塞)方法。最常见的是等待线程同步和 I/O 操作,如下载文件。此外,该方法不是异步方法,不应该返回
Task
,也不应该使用 Async 后缀。
-
DON'T
:不要在异步方法中使用 ref 或 out 参数修饰符。.NET 运行时只允许栈上的值引用(即方法参数或本地变量),异步方法需要让渡当前执行,无法继续维持值引用。
对于标注了 async 的方法,C#编译器可以发现 ref 和 out 的错误使用,但未标注 async 的 Task(异步)方法,不会报错。
-
DON'T
:不要在 虚的(抽象) 的异步方法中使用 in 参数修饰符。
-
AVOID
:避免在 非虚 的异步方法中使用 in 参数修饰符。与 ref、out 相同,标注了 async 的方法使用 in 参数修饰符 C# 编译器会报错,未标注 async 的异步方法不会报错。
在模板方法模式中,模板方法(非虚)会调用虚方法,而虚方法禁止使用 in 参数修饰符(见上一条),即使模板方法用了 in,向虚方法传参时仍会发生 值 拷贝,脱离了预期。
// 模板方法(非虚),使用in修饰参数 public Task TestMethodAsync(in int value){// 此时传入的value发生了值拷贝TestMethodAsyncCore(value); } // 虚方法,未使用in修饰参数 protected abstract Task TestMethodAsyncCore(int value);// 这里进行调用 int value = 0; var task = TestMethodAsync(int value) // 此处我期望通过修改值改变TestMethodAsync的行为,但已发生了值拷贝,不会符合预期 value = 1; await task;
当然,不像 ref 和 out,in 修饰的参数不能被修改,且非虚方法不会覆写,不存在混淆的可能性,所以可以酌情使用 in 修饰符。
9.2.3 异步方法的返回类型
-
DO
:不具有返回值的异步方法,要使用 Task
(非泛型)作为返回类型,而非 ValueTask
。
Task
比 ValueTask
有更好的可用性,如Task.WaitAny(Task[])
不支持 ValueTask
,对于标注了 async 的方法,两者性能相近
-
DO
:具有返回值的异步方法,要使用 Task<TResult>
作为返回类型,而非 ValueTask<TResult>
。相比节省的内存,可用性更重要。
-
CONSIDER
:如果你的异步方法通常是同步完成的,建议使用 ValueTask<TResult>
。例如,
System.IO.Stream
的ReadAsync
方法在调用时,由于缓冲的存在,该方法经常同步返回,且该方法返回的是Int32
。如果使用Task<TResult>
,在循环调用时会频繁创建Task<Int32>
实例,它产生的内存影响可能会很明显。此时使用ValueTask<TResult>
更为合适。上述情况仅适用于同步完成,如果该方法(标识为 async)未能同步完成,将会创建一个新的
Task<TResult>
实例进行跟踪剩余部分,反而导致性能下降。
9.2.4 为现有的同步方法制作一个异步变体
-
DO
:同一个方法的同步版本和异步版本,其参数顺序尽可能保持相同。若同步方法没有 ref、out 参数,两者的差别应该仅有
CancellationToken
参数和 Task
返回值:public partial class XDocument {public static XDocument Load(TextReader textReader, LoadOptions options) { ... }public static Task<XDocument> LoadAsync(TextReader textReader, LoadOptions options, CancellationToken cancellationToken = default) { ... } }
-
DO
:如果同步方法有 ref 或 out 参数,适当调整异步方法的参数,以匹配同步方法。
例如,返回值为 void 的同步方法有一个 out 参数作为返回值:// 同步方法 public void CalculateValue(int input, out int result) { ... } // 异步方法 public Task<int> CalculateValueAsync(int input, CancellationToken cancellationToken = default) { ... }
-
CONSIDER
:建议在异步方法中使用 异 步回调代替 同 步回调,或通过 重载 同时接收两者。// 同步方法 public void DoWork(Action<State> callback) { ... } // 具有同步回调的异步变体 public void DoWorkAsync(Action<State> callback,CancellationToken cancellationToken = default) { ... } // 具有异步回调的异步变体 public void DoWorkAsync(Func<State, CancellationToken, Task> asyncCallback,CancellationToken cancellationToken = default) { ... }
9.2.5 异步模式一致性的实现准则
-
DON'T
: 不要 为Task
或Task<TResult>
返回 null。为什么要返回 null?是因为无法执行还是执行结果有误?无论如何, 抛出异常 都是更好的方案。
9.2.5.1 Task.Status
的一致性
-
DON'T
:不要返回处于 Created 状态的Task
。用
Task
构造函数创建实例时,该实例的Status
为 Created (即未启动),此时我们可以设置该Task
。但是,如果不启动该 task,直接进行 wait,该 wait 将永远不会结束。
通过async
、Task.Run()
和Task.Factory.StartNew()
创建任务,则会在启动后才返回Task
。
-
DO
:当任务因CancellationToken
中断,要抛出 OperationCanceledException
。抛出该异常,会使
Task.Status
处于 Canceled 状态。try{await SaveData(data, cancellationToken);QueueNotification("Data saved successfully!"); } catch (OperationCanceledException){QueueNotification("Save was canceled."); } ... private async Task SaveData(byte[] data, CancellationToken cancellationToken) {// 错误:这会产生任务成功的消息if (cancellationToken.IsCancellationRequested) {return;}// 正确:这会为调用者提供任务已取消的消息if (cancellationToken.IsCancellationRequested) {throw new OperationCanceledException(cancellationToken);}// 正确:这会为调用者提供任务已取消的消息cancellationToken.ThrowIfCancellationRequested();... }
9.2.5.2 等待正确的上下文
-
DO
:在等待一个异步操作时, 要 使用await task.ConfigureAwait(false)
,除非是在 依赖同步上下文的 应用程序模型中。
9.2.5.3 避免死锁
-
DON'T
: 不要 在异步方法中调用Task.Wait()
或读取Task.Result
属性;相反, 要 使用 await。
Task.Wait()
方法和Task.Result
属性是阻塞性的,在某些情况下会导致死锁。
-
DO
:要在异步方法的实现中调用 异 步方法变体,而不是 同 步方法变体。如果一个方法既有同步变体又有异步变体,那么同步方法有可能导致阻塞、资源耗尽和死锁。
9.2.5.4 正确处理 ValueTask
和 ValueTask<TResult>
-
DON'T
: 禁止 对ValueTask
或ValueTask<TResult>
的实例进行一次以上的操作,或者保存它;只应该 await 或返回它。简而言之:永远不要有
ValueTask
或ValueTask<TResult>
局部变量。如果你需要该Task
,可以用 AsTask()
方法获取相应的Task
对象:Task<int> task = SomeMethodAsync(...).AsTask();
9.2.5.5 异步方法的异常
-
DO
:三种异常中的 使用错误 异常,要直接从异步方法中抛出。
使用错误 异常可以在 Task 运行前进行校验、抛出,这样异常不会被包装在 Task 中,有以下好处:- 调用栈可以清晰地显式错误来源;
- 即使不等待(Task.Wait),也不会忽略异常;
- 若输入无效,则任务不会执行。
-
DO
:三种异常中的 执行错误 异常,要从异步方法返回的Task
值中抛出。
9.2.9 IAsyncEnumerable<T>
IAsyncEnumerable<T>
接口主要用于生成 异步迭代器 ,用于 C#8.0 的 await foreach 语句。
-
DO
:要为返回IAsyncEnumerable<T>
的方法使用“ Async ”后缀。
-
DO
:使用了 yield return 的IAsyncEnumerable<T>
方法,其CancellationToken
形参要应用 [EnumeratorCancellation]
特性。否则
GetAsyncEnumerator
的CancellationToken
值在编译时会被忽略public static async IAsyncEnumerable<int> ValueGenerator(int start,int count,[EnumeratorCancellation] CancellationToken cancellationToken) {int end = start + count;for (int i = start; i < end; i++){await Task.Delay(i, cancellationToken).ConfigureAwait(false);yield return i;} }
-
DON'T
:除非是作为 GetAsyncEnumerator
方法的返回类型,否则不要使用IAsyncEnumerator<T>
及其派生类型。
-
DON'T
:不要 为同一个公开类型同时实现IAsyncEnumerator<T>
和IAsyncEnumerable<T>
。
9.2.10 await foreach 的使用准则
-
DO
:await foreach 作用于IAsyncEnumerable<T>
时,要使用 WithCancellation
修改器 ,以便在枚举器中使用CancellationToken
。// 如下,cancellationToken被添加了特性标记 public Task<int> MaxAsync(IAsyncEnumerable<int> source,CancellationToken cancellation = default) {int max = int.MinValue;bool hasValue = false;await foreach (int value in source.WithCancellation(cancellationToken).ConfigureAwait(false)) {hasValue = true;if (value > max){max = value;}}return hasValue ? max : throw new InvalidOperationException(); }// 如果是如下这种形式,则不需要使用WithCancellation修改器了 await foreach (int value inValueGenerator(10, 5, cancellationToken).ConfigureAwait(false)) {... }
-
DO
:在使用 await foreach 时要使用 ConfigureAwait
修改器,与使用 await 时相同。示例代码见上一条准则。
9.3 依赖属性
-
DO
:如果你需要属性来支持 WPF 的功能,如样式、触发器、数据绑定、动画、动态资源和继承,则要提供 依赖 属性。
9.3.1 依赖属性设计
-
DO
:在实现依赖属性时,要继承 DependencyObject
或其子类。该类型提供了一个非常高效的属性存储实现,并自动支持 WPF 数据绑定。
-
DO
:要提供一个常规的 CLR 属性和公开的静态 只读 字段,为每个依赖属性存储一个 System.Windows.DependencyProperty
的实例。public class TextButton : DependencyObject {public string Text{get => (string)GetValue(TextProperty);set => SetValue(TextProperty, value);}public static readonly DependencyProperty TextProperty = DependencyProperty.Register("Text", typeof(string), typeof(TextButton)); }
-
DO
:要通过调用 DependencyObject.GetValue()
和 DependencyObject.SetValue()
实例方法来实现依赖属性。public class TextButton : DependencyObject {public string Text{get => (string)GetValue(TextProperty);set => SetValue(TextProperty, value);}public static readonly DependencyProperty TextProperty = DependencyProperty.Register("Text", typeof(string), typeof(TextButton)); }
-
DO
:在命名依赖属性的静态字段时,要以属性名称加上“ Property ”后缀作为其名称。
DependencyProperty.Register()
方法的第一个参数应该是属性名称。
-
DON'T
:不要在代码中显式地为依赖项属性设置默认值;相应的,应该在 元数据 ( PropertyMetadata
)中进行设置。显式设置的默认值极有可能无法通过某些隐式方式进行设置(比如样式)
public class TextButton : DependencyObject {public TextButton(){// 不要显式设置依赖属性的默认值Text = String.Empty; // 错误做法}public static readonly DependencyProperty TextProperty =DependencyProperty.Register("Text", typeof(string), typeof(TextButton),new PropertyMetadata(String.Empty)); // 正确做法 }
-
DON'T
:除了访问静态字段的标准代码,不要将其他代码放在 属性的访问 器中。如果该属性是通过隐式方式设置的(比如样式,它会绕过属性访问器,直接访问静态只读的
DependencyProperty
),这些“其他代码”就不会执行。public string Text {get => (string)GetValue(TextProperty);set{SetValue(TextProperty, value);DoWorkOnTextChanged(); // 错误做法 } }
-
DON'T
:不要使用依赖属性来存储与 安全 相关的数据,即使是私有的依赖属性也可以被 公开 访问。public class BadType : DependencyObject {// 切勿这么做,这不安全private static readonly DependencyProperty SecretProperty =DependencyProperty.Register( "Secret", typeof(string), typeof(BadType));public BadType(){SetValue(SecretProperty, "password");}private static void Main(){var b = new BadType();var enumerator = b.GetLocalValueEnumerator();while (enumerator.MoveNext()){Console.WriteLine("{0}={1}", enumerator.Current.Property, enumerator.Current.Value);}} }
9.3.2 附加属性的设计
依赖属性的准则同样适用于附加属性,但附加属性还有一些额外的准则。
-
DO
:附加属性的定义方式和常规的依赖属性看起来非常相似,除了访问器是 静态 的Get*()
和Set*()
方法。public class Grid {public static int GetColumn(DependencyObject obj){return (int)obj.GetValue(ColumnProperty);}public static void SetColumn(DependencyObject obj, int value){obj.SetValue(ColumnProperty, value);}public static readonly DependencyProperty ColumnProperty =DependencyProperty.RegisterAttached("Column", typeof(int), typeof(Grid)); }
9.3.3 依赖属性校验
-
DON'T
:不要在依赖属性的访问器中添加任何校验逻辑。相反,应该在 DependencyProperty.Register()
方法中提供 校验回调 。// 错误做法 public string Text {get { ... }set {if (value == null)throw new ArgumentNullException(nameof(value));...} }
// 正确做法 public static readonly DependencyProperty TextProperty =DependencyProperty.Register("Text", typeof(string), typeof(TextButton),new PropertyMetadata(string.Empty), value => value != null); // 校验
9.3.4 依赖属性变更通知
-
DON'T
:不要在依赖属性的访问器中实现变更通知逻辑。依赖属性有内置的变更通知功能,可通过向 PropertyMetadata
传入回调进行变更通知。public static readonly DependencyProperty TextProperty =DependencyProperty.Register("Text", typeof(string), typeof(TextButton),new PropertyMetadata(string.Empty,(obj, args) => {// property changed...}));
9.3.5 依赖属性中的值强制
什么是“值强制”(Value Coercion)
给定的值因不符合要求,在属性内部被修改,这种行为称为“属性强制”。下面是一个简单的强制逻辑:
public string Text {set {if (value == null) {value = string.Empty;}_text = value;}
}
在依赖属性中,上述变化由回调函数完成,而非在属性设置器中进行。
约定
-
DON'T
:不要在依赖属性的设置器中实现强制逻辑。依赖属性有内置的强制功能,可通过向 PropertyMetadata
传入回调进行值强制。public static readonly DependencyProperty TextProperty =DependencyProperty.Register("Text", typeof(string), typeof(TextButton),new PropertyMetadata(string.Empty,(obj, args) => { /* change notification callback */ },(obj, value) => value ?? string.Empty // coercion));
9.4 Dispose 模式
-
DO
:要为包含 可处置(Dispose)类型实例的类型 实现基本的 Dispose 模式。
-
CONSIDER
:考虑为那些本身不持有,但其派生类可能会持有非托管资源或可处置对象的类,实现基本 Dispose 模式。例如:
System.IO.Stream
类,它本身是一个不持有任何资源的抽象基类,但它的大多数子类都持有资源,因此,它实现了 Dispose 模式。
9.4.1 基本 Dispose 模式
-
DO
:要声明一个受保护的 虚方法 void Dispose(bool disposing)
,用于集中释放资源有关的代码。该方法被终结器(析构函数)和
IDisposable.Dispose
调用。protected virtual void Dispose(bool disposing) {if(disposing) {_resource?.Dispose();} }
其 bool 参数用于标记是被显式调用(
IDisposable.Dispose
调用)还是隐式调用(终结器调用)。被显式调用时,实例参数仍可使用;反之则不可。
-
DO
:要通过简单地调用 Dispose(true)
和 GC.SuppressFinalize(this)
来实现IDisposable
接口的Dispose()
方法。public void Dispose() {Dispose(true);GC.SuppressFinalize(this); }
GC.SuppressFinalize(this)
会抑制终结器触发,如果Dispose(true)
抛出了异常,GC.SuppressFinalize(this)
未执行,则 GC 仍会调用终结器。
-
DON'T
: 不要 将无参数的Dispose
方法实现为虚方法。错误的设计 public class DisposableResourceHolder : IDisposable {public virtual void Dispose() { ... }protected virtual void Dispose(bool disposing) { ... } }正确的设计 public class DisposableResourceHolder : IDisposable {public void Dispose() { ... }protected virtual void Dispose(bool disposing) { ... } }
-
DON'T
:除了 Dispose()
和 Dispose(bool)
,不要重载其他Dispose
方法。
-
DO
:要允许Dispose(bool)
方法被多次调用,该方法可以选择在第一次调用之后 就什么都不做了 。-
public class DisposableResourceHolder : IDisposable {bool _disposed = false;protected virtual void Dispose(bool disposing) {if (_disposed) {return;}// 清理...disposed = true;} }
-
-
AVOID
: 避免 从Dispose(bool)
中抛出异常,除非 是在进程被破坏的关键情况下 。如内存泄漏、不一致的共享状态等。
在用户的预期中,调用
Dispose
方法不会抛出任何异常。尤其不要在Dispose(false)
时抛出异常(即终结器释放资源),否则会终止进程。
-
DO
:如果任何成员在 Dispose 后无法继续使用,则被调用时要抛出 ObjectDisposedException
异常。public class DisposableResourceHolder : IDisposable {bool _disposed;SafeHandle _resource;protected virtual void Dispose(bool disposing){if(_disposed)return;// 清理资源..._disposed = true;}public void DoSomething(){if(_disposed){throw new ObjectDisposedException(...);}} }
-
AVOID
:在调用Dispose()
方法后,要避免对象 重新获得有意义 的状态。使对象重写获取有意义的状态被称为“rehydration”。它会造成难以诊断的性能问题,特别是当对象重要的待释放部分是被惰性创建时,很容易产生 bug。
-
CONSIDER
:如果“ Close ”是该领域的标准术语,那么除了Dispose()
方法,还要提供一个 Close()
方法。
9.4.2 可终结类型
-
DON'T
:不要让 公开 类型成为可终结类型。- 可终结资源的持有者应该是内部(Internal)或私有嵌套类型。
- 要优先使用现成的资源包装器,如 SafeHandle。
Notice
这里说的是“可终结”,而非“可释放”!
-
DO
:在每个可终结类型上实现基本 Dispose 模式 。这样调用者可以根据自己的需要释放由终结器释放的资源。
-
DON'T
:不要访问终结器代码路径中 任何可终结的对象 ,因为有很大的风险,它们可能已经 被终结了 。
-
DON'T
:不要在终结器逻辑中 抛出异常 ,除非是系统关键性的故障。
-
DON'T
:如果非密封的公开类型(可以作为基类)具有终结器, 不要 移除它的终结器。即“非密封”若通过基本的 Dispose 模式实现了终结器,其派生类可能依靠它释放资源,切勿删除该终结器,以免内存泄漏;
而“密封”类型不能进行派生,删除终结器的后果是可控的;如果是内部类(internal),我们可以检查所有派生类后再移除终结器。
9.4.3 限定作用域的操作
-
CONSIDER
:考虑返回一个 IDisposable
实例,而不是让调用者手动管理“ 开始 ”方法和“ 结束 ”方法。我们可以使用 using 语句,将逻辑作用域和方法作用域对齐,调用者可以不再提供与“ 开始 ”方法相对应的“ 结束 ”方法。
// 我们经常这么使用using using (var reader = File.OpenRead(path)){... } // 其实还可以这么用。这里using语句仍会把File.OpenRead的返回值释放。 // 此处只是范例,具体的示例代码见P294,更有实用性。 using(File.OpenRead(path)){... }
-
CONSIDER
:为了程序的正确性,当“结束”方法必须位于 finally 块中时,考虑返回一个 IDisposable
实例,而不是让调用者手动管理“ 开始 ”方法和“ 结束 ”方法。-
如下代码
_lock.GetReadLock
返回了一个IDisposable
实例:public void DoStuff() {using(_lock.GetReadLock()){DoStuffCore();} }
-
9.4.4 IAsyncDisposable
#delay#这部分内容暂不进行学习,我没搞懂是干啥的
-
DO
:在实现IAsyncDisposable
时,除非另有说明,否则应遵循异步设计和同步 Dispose 中的准则。
-
DO
:不要为DisposeAsync()
声明任何重载。同步
Dispose()
方法有两个重载方法(Dispose()
和Dispose(bool)
),其中Dispose(bool)
方法被Dispose()
和终结器调用,因此有重载。而DisposeAsync()
不会被终结器调用,因此无需重载。
-
DO
:DisposeAsync
的实现方式如下:- 调用并等待
DiposeAsyncCore()
; - 调用
Dispose(false)
; - 调用
GC.SuppressFinalize(this)
。
代码如下:
public partial class SomeType : IDisposable, IAsyncDisposable {protected virtual void Dispose(bool disposing) { ... }public void Dispose(){Dispose(true);GC.SuppressFinalize(this);}public async ValueTask DisposeAsync(){await DisposeAsyncCore();Dispose(false);GC.SuppressFinalize(this);}protected virtual ValueTask DisposeAsyncCore() { ... } }
- 调用并等待
await using 的使用准则
-
DO
:在使用await using
时,要使用ConfigureAwait
修改器,这与使用await
的方式相同。
9.5 工厂
-
DO
:优先使用 构造函数 ,而非 工厂 。构造函数的可用性、一致性、易用性更好。工厂为了灵活性,牺牲了可发现性、可用性、一致性。
-
CONSIDER
:如果需要对创建实例的 过程进行 控制,可以考虑使用工厂。工厂方法可以轻松的实现执行对象缓存、节流和共享。
-
DO
:在开发者不知道构建哪种类型的情况下使用 工厂 。可以通过传入参数、上下文等方式,令工厂返回指定用途的实例:
public class Type {// 该工厂方法返回了大量类型的实例,// 包括 PropertyInfo、ConstructorInfo、MethodInfo 等public MemberInfo[] GetMember(string name); }
-
CONSIDER
:如果工厂的 语义化 表达比构造函数好,考虑使用工厂。构造函数不能有名称,而且有时候缺乏足够的上下文信息告知开发者一个操作的语义,如:
// 无法体现意图 public String(char c, int count);// better public static String Repeat(char c, int count);
-
DO
:转换式操作应使用 工厂 ,如Parse
或Decode
。
-
DO
:工厂 应 实现为方法,而 非 属性。
-
DO
:工厂创建的实例方法应通过 返回值 返回,而非 out 参数 。也有例外,如 Try-Parse 模式。
-
CONSIDER
:当工厂方法创建的是另一个类的实例,考虑将 Create 和 所创建的类型名拼接在一起来 命名工厂方法。如:创建按钮的工厂方法命名为
CreateButton
。在某些情况下,可以使用特定领域的名称,如:
File.Open
。
-
CONSIDER
:通过将所创建的类型的名称和 Factory 拼接到一起来 命名工厂类型。如:创建 Control 对象的工厂命名为
ControlFactory
。
9.6 LINQ 支持
9.6.2 实现 LINQ 支持的方法
9.6.2.1 通过 IEnumerable<T>
支持 LINQ
-
DO
:要通过实现 IEnumerable<T>
接口来启用基本的 LINQ 支持
-
CONSIDER
:考虑实现 ICollection<T>
来提升 LINQ 查询的性能
Enumerable.Cout()
方法默认通过迭代的方式统计数量。若集合实现了该接口,该方法会返回 ICollection<T>.Count
属性值,时间复杂度可以从 \(O(n)\) 降低为 \(O(1)\)。
-
CONSIDER
:可以在 当前集合类型 上自定义特定的 LINQ 方法,以覆写(覆盖)默认的System.Linq.Enumerable
实现(例如为了优化性能)当然也可以通过自定义扩展方法覆写默认的 LINQ 方法。例如
ImmutableArrayExtensions.Select()
扩展方法提供了性能更好的Select()
:public partial static class ImmutableArrayExtensions {public static IEnumerable<TResult> Select<T, TResult>(this ImmutableArray<T> immutableArray, Func<T, TResult> selector){ ... } }
9.6.2.2 通过 IQueryable<T>
支持 LINQ
-
CONSIDER
:若需要使用查询表达式,考虑实现 IQueryable<T>
-
DON'T
:若不清楚IQueryable<T>
带来的 性能 影响,不要实现它构建和解释表达式树的开销非常大,
IQueryable<T>
的执行实际很慢。不过这在 LINQ to SQL 中是完全可以接受的,表达式与 SQL 语句的转换等一系列的开销远小于数据库实际查询的开销
-
DO
:如果从逻辑上说某个接口方法不能被数据源支持,应该从IQueryable<T>
方法中抛出 NotSupportedException
异常。譬如某个媒体流所表示的
IQueryable<byte>.Cout()
方法在逻辑是不被支持(流可以被认为是无限的),此时Count()
方法就应该抛出上述异常。
9.6.2.3 通过查询模式支持 LINQ
查询模式
查询模式指:因没有实现 IQueryable<T>
/IEnumerable<T>
/ICollection<T>
等接口,开发者无法使用 LINQ,此时可以自定义 方法 / 扩展方法 ,以支持形如 LINQ 的数据查询。
查询模式的方法签名有:
S<T> Where<T>(this S<T>, Func<T, bool>)S<T2> Select(this S<T1>, Func<T1, T2>)
S<T3> SelectMany(this S<T1>, Func<T1, S<T2>>, Func<T1, T2, T3>)
S<T2> SelectMany(this S<T1>, Func<T1, S<T2>>)O<T> OrderBy(this S<T>, Func<T, K>), where K is IComparable
O<T> ThenBy(this O<T>, Func<T, K>), where K is IComparableS<T> Union(this S<T>, S<T>)
S<T> Take(this S<T>, int)
S<T> Skip(this S<T>, int)
S<T> SkipWhile(this S<T>, Func<T, bool>)S<T3> Join(this S<T1>, S<T2>, Func<T1, K1>, Func<T2, K2>, Func<T1, T2, T3>)T ElementAt(this S<T>, int)
上述签名中, S<T>
表示集合, O<T>
表示 IOrderedEnumerable<T>
或其子类。如果上述方法是成员方法,应省去第一个参数,内部使用 this
指针进行调用。
约定
-
DO
:如果该 LINQ 方法在 LINQ 之外对该类型也是有意义的,要使用 类型的实例成员 来实现查询模式,否则应将其实现为 扩展方法 。以如下两段代码为例,对于数据集合来说,第 二 种实现方式更为合理:
public partial class MyDataSet<T> : IEnumerable<T> { ... }public static partial class MyDataSetQueries{public static int Count(this MyDataSet<T> data) { ... } }
public partial class MyDataSet<T> : IEnumerable<T> {public int Count() { ... } }
-
DO
:要为实现查询模式的类型实现 IEnumerable<T>
接口。
-
CONSIDER
:考虑所设计的 LINQ 运算符返回 特定的可枚举 实例,而非简单的 IEnumerable<T>
实例。假设我们有一个自定义集合类型
MyType
,它内部定义了优化后的Count()
方法,但是用 LINQ 的Where()
方法查询后,将得到IEnumerable<T>
实例,此后再进行Count()
运算将调用 LINQ 的Count()
方法,优化失效:var result = myInstance.Where(query).Count();
对此我们应同步覆写 LINQ 的
Where()
等方法,使其仍返回MyType
类型,保证优化持续生效。
-
AVOID
:如果不希望回退到基本的 IEnumerable<T>
实现,要避免只实现查询模式的一部分。理由同上一条准则。
-
DO
:将有序序列表示为与其无序对应的独立类型。原因见下一条准则:
-
DO
:要为有序序列类型定义 ThenBy()
方法,或为其实现 IOrderedEnumerable<T>
接口。LINQ 中的
ThenBy()
方法是为 IOrderedEnumerable<T>
接口定义的.
-
-
DO
:要为有序序列类型定义 ThenBy()
方法,或为其实现 IOrderedEnumerable<T>
接口。LINQ 中的
ThenBy()
方法是为 IOrderedEnumerable<T>
接口定义的.
-
DO
:要推迟查询运算符实现的执行。即 LINQ 的延迟执行,见8.4 延迟执行。
不过自定义的查询模式怎么能延迟执行,我就不了解了。
-
DO
:令查询扩展方法的命名空间为主空间的“ Linq ”子命名空间。例如 System.Data 功能的扩展方法位于 System.Data.Linq 命名空间中。
-
DO
:若需要对查询(Query)进行检查,要使用 Expression<Func<...>>
作为参数,而非Func<...>
使用表达式可以:
-
使 Lambda 转为 SQL 表达式成为可能;
-
执行时可进行优化
例如对有序列表来说,可以使用二分查找加快
Where()
的执行速度。
-
9.7 可选功能模式
-
CONSIDER
:考虑使用 可选功能 模式为抽象提供可选功能。该模式替代了 因子 设计导致的动态转换,减少了框架的复杂性,提高了可用性。
如果只有一小部分派生类,甚至只有一个派生类,会用到该功能,则使用基于 接口 的设计更好。
另外,当可选功能的组合数量较少,且因子化提供的编译时安全性又很重要, 因子 设计是首选。
-
DO
:要提供一个简单的返回 布尔 类型的属性说明当前派生类是否支持某个可选功能。public abstract class Stream {public virtual bool CanSeek { get { return false; } }public virtual void Seek(int position) { ... } }
-
DO
:要在基类上为定义可选功能的虚方法抛出 NotSupportedException
异常。public abstract class Stream {public virtual bool CanSeek { get { return false; } }public virtual void Seek(int position) {throw new NotSupportedException(...);} }
9.8 协变和逆变
#todo#这部分内容待看完《深入理解 C#》再回头看
9.9 模板方法
该模式的目标是控制扩展性。
-
AVOID
:公开成员 要避免 成为虚成员。如果要设计虚成员,应遵循模板方法模式:创建一个 受保护的虚 成员,由 公开 成员调用,这种做法提供了更可控的扩展性。
public class Control{public void SetBounds(int x, int y, int width, int height){...SetBoundsCore(...);}public void SetBounds(int x, int y, int width, int height, BoundsSpecified specified){...SetBoundsCore(...);}protected virtual void SetBoundsCore(int x, int y, int width, int height, BoundsSpecified specified){// 真正的处理逻辑。} }
-
CONSIDER
:考虑使用 模板方法模式 提供更可控的可扩展性。该模式下,所有扩展的功能点都是通过受保护的虚成员提供的,被非虚成员调用。
-
CONSIDER
:模板方法模式中的protected virtual
方法应使用 非虚方法名 + Core
后缀。
-
DO
:模板方法中,非虚成员在调用虚成员前,要 执行参数 和 状态校验 。
-
DO
:模板方法中,虚成员仅需执行 派生类特有 的参数和状态校验。也就是说,不要重复执行已经在非虚成员中做过的校验。
通过将普通的检查转移到公开成员中,派生类型的开发者只需要关注其特定实现的逻辑。
9.10 超时
-
DO
: 优先选用 方法参数设置超时时间, 而非 属性。方法参数 和超时之间的关联更加明显。如果该类型用于 VS 组件,则 属性 更好。
-
DO
:优先选用 TimeSpan
表示超时时间。过去,超时时间是由整数表示的,其有以下劣势:
- 时间单位不明显;
- 将时间单位转换为代码中常用的毫秒较为麻烦。
如果满足下列条件,用整数也是可以接受的:
- 参数或属性名描述了时间单位,如参数名为
milliseconds
; - 最常用的数值换算起来很容易,如:单位是毫秒,常用的超时时间小于 1 秒。
-
DO
:当达到设置的超时时间时,要抛出 System.TimeoutException
异常。超时方法可以考虑实现如下两个逻辑:
-
当超时参数设置为
TimeSpan(0)
,则意味着操作应立即完成,否则直接抛出异常; -
当超时参数设置为
TimeSpan.MaxValue
,则意味着操作应该永远等待,且不会发生超时。实际上大部分会使用
Timeout.Infinite
作为永远等待。
如果超时并抛出了
System.TimeoutException
异常,服务器类应该取消背后的操作。 -
-
DON'T
: 不要 返回错误码来表示超时异常。
9.11 XAML 可读类型
-
CONSIDER
:自定义类型若想在 XAML 中工作,考虑为它提供 无参 构造函数。例如,下面的 XAML 标记等价于右侧的 C# 代码:
<Person Name="John" Age="22"/>
new Person() { Name = "John", Age = 22 };
-
DO
:若想在 XAML 解析器中使用不可变类型,应当提供相应的 标记扩展 。不可变类型不能使用 XAML 记录器,因此要通过 标记扩展语法 使用。以如下不可变类型为例,应提供相应的 标记扩展 类:
public class Person {public string Name { get; }public int Age { get; }public Person(string name, int age){Name = name;Age = age;} }
[MarkupExtensionReturnType] public class PersonExtension : MarkupExtension {public string Name { get; set; }public int Age { get; set; }public override object ProvideValue(IServiceProvider serviceProvider){return new Person(Name, Age);} }
-
AVOID
: 避免 定义新的类型转换器,除非该转换是自然且直观的。一般来说,应当只使用 .NET 内置的类型转换器。尤其是通过类型转换器定义了新的“迷你语言”,会大大增加系统的复杂性。
-
CONSIDER
:考虑应用 ContentPropertyAttribute
为最常用的属性启用方便的 XAML 语法。关于该属性的用法,请参考机制 3:父元素使用 ContentProperty 特性进行修饰。
9.12 操作缓冲区
#todo#在程序考虑切换至.net 8 平台后再看