在 C# 异步方法中使用 out/ref 参数机制

来源:互联网 发布:苹果系统mac系统下载 编辑:程序博客网 时间:2024/05/21 09:19

由于异步方法的特殊性,.NET 不允许异步方法带有 out 或 ref 参数。事实上这个设计是合理的,因为异步方法几乎都是立即返回,而此时传入方法内的 out/ref 参数可能还没有被赋值。那么,如果确实需要在异步方法内用到类似于 out/ref 的按引用传参的机制怎么办呢?

为什么需要 out/ref

简言之,为了让调用方法能够返回多个值。很常见的情况是,除了一般的返回值,我们还需要知道其他结果。例如,bool int.TryParse(String,out int) 这个方法用来将字符串转换为对于的 32 位整数,除了返回 true/false 表示转换是否成功之外,我们还需要知道转换后的值,于是就加了个 out 参数来存储这个值。(题外话:这应该算是.NET 的历史遗留问题,如果可空值类型一早就出现在 .NET 里,可能这个方法就不会这样难看了,签名完全可以变成更简洁的形式:int? int.TryParse(String)。)

示例

假设一个接口 IFileSystemProvider 提供了异步方法 ReadAsync。这个接口方法抽象了对文件系统的读取操作。调用时,需要提供一个标识文件资源的字符串。方法完成后,从这个方法中获得该文件的流、文件名和一些元数据(MD5 哈希值、原始文件名、创建时间等)。元数据用一张字典存储。这样,从一个方法中,我们需要给出一个输入,并获得三个输出。

如果这个方法是同步的,那么我们可以不假思索地这么写下签名:

Stream Read(string resource, out string originalFileName, out IDictionary<string, object> metadata);

但异步方法不能使用 out/ref 参数,此时又该如何定义这个接口方法的签名?

【方法 1】 使用一个类或结构体包含返回值

可以写一个不变的类或结构体,来封装这些返回值。

public sealed class ReadResult{    public ReadResult(Stream stream, string originalFileName, IDictionary<string, object> metadata)     {        this.Stream = stream;        this.OriginalFileName = originalFileName;        this.Metadata = metadata;    }    public Stream Stream { get; }    public string OriginalFileName { get; }    public IDictionary<string, object> Metadata { get; }}

【优点】

对调用方友好,代码的可读性和可维护性极强。

【缺点】

代码量过多。这种一次性使用的封装类(只是为一个方法使用的类)会导致代码库显得臃肿不堪。在一些人严重或许不算缺陷,但对于开发求快,惜字如金(当然是在不影响性能的情况下)的 .NET 开发者而言,这可算是个无法忍受的问题。

另外,过多临时封装类的频繁创建还会导致内存分配和 GC 的压力增大,对性能产生影响。


【方法 2】 使用元组 Tuple

该方法旨在缓解方法 1 的代码臃肿的问题。元组可以包含一个或多个值,直接用元组包括并返回你需要的对象:

Task<Tuple<Stream, string, IDictionary<string, object>>> ReadAsync(string resource, CancellationToken cancellationToken = default(CancellationToken));

【优点】

很简单,不用额外的代码。

【缺点】

可读性差。元组里的每一项的含义不明确(只能通过访问返回的元组的 Item1Item2 属性这样的方式获得),如果要返回参数很多,一定要有良好的注释,说明元组每一项的含义。


【方法 3】利用委托回调(Lambda 表达式)

Task<Stream> ReadAsync(string resource, Action<string, IDictionary<string, object>> fileNameMetadataAccessor = null, CancellationToken cancellationToken = default(CancellationToken));

其本质就是回调函数。实现方的代码在异步方法完成时,将文件名和元数据作为参数,传递给 fileNameMetadataAccessor 委托并调用。

实现方的示例代码:

public async Task<Stream> ReadAsync(string resource, Action<string, IDictionary<string, object>> fileNameMetadataAccessor = null, CancellationToken cancellationToken = default(CancellationToken)){    ...    var stream = await dfsClient.ReadAsync();    var entry = await dbContext.FileEntries.FirstOrDefaultAsync(c => c.Resource == resource);    if(entry != null) {        fileNameMetadataAccessor?.Invoke(entry.OriginalFleName, JSONHelper.AsDictionary(entry.Metadata));    }    ...}

调用方的示例代码:

IFileSystemProvider provider = ...;string resource = ...;string originalFileName = null;IDictionary<string, object> metadata = null;var stream = await provider.ReadAsync(resource, (o, m) => {    originalFileName = o;    metadata = m;});...

【优点】

简洁优雅,不用额外的代码。

【缺点】

如果要返回的值较多,可读性就较差;同样也要有良好的注释,说明 Action 里每一个参数的含义。(可以自定义一个委托类型而不是使用通用的 Action 委托来规避可读性差的问题,但这也会导致代码显得过于臃肿)

同时,这样做也有隐含的调用风险:因为是异步方法,传入的委托可能还没有被调用,如果委托影响了在 lambda 方法作用域外的状态,就可能出现问题。(传入的委托应该更多地视作常规的回调函数,而不应该是“设置返回值”的函数,因为这样的函数很容易就影响了闭包状态

例如,在上面的调用方代码里的 await 去掉。

var streamTask = provider.ReadAsync(resource, (o, m) => {    originalFileName = o;    metadata = m;});Console.WriteLine(metadata["FileSize"]);var stream = await streamTask;...

这会在最后一行引发 NullReferenceException 异常,因为 ReadAsync 返回的是只是一个任务,返回时并不保证传入的委托能被调用,所以metadata 变量在方法返回后还是保持 null

较好的用法是:

var streamTask = provider.ReadAsync(resource, (o, m) => {    Console.WriteLine(m["FileSize"]);});var stream = await streamTask;

总结

两点感悟。

  1. 多开拓思路。上面说的三个方法各有优缺点,具体选择哪个方法,一要根据实际情况来定,二要遵从一定的设计哲学,灵活也好、严谨也罢,不要太死板。代码是面向人的,时常想想你写的代码如果要给其他人调用,应当怎样设计,使用者才是最得心应手的。
  2. 看清 .NET 异步方法、lambda 表达式背后的实质,知道在所有这些语法糖后面,编译器都做了些什么,才能完全发挥 C# 这门语言利器的真正威力。
1 0
原创粉丝点击