延迟执行及其问题






4.16/5 (4投票s)
讨论关于延迟执行的问题以及我们如何解决它。
引言
在本文中,我们将看到 IEnumerable<T>
的实例如果被延迟执行会引起什么样的问题。
问题所在
System.Linq.Enumerable
类提供的扩展方法非常出色且优雅。该类与 System.Linq.Queryable
一起是 LINQ 的精髓。它们使我们的代码能够从更高的抽象层面访问集合,从而保持更大的灵活性。但是,如果使用不当,它们可能会导致性能灾难。以下代码片段就是一个完美的例子。你能猜到方法 ListAllFileInfos()
内部的 foreach
循环将被执行多少次吗?
// Namespaces omitted.
class Program
{
static void Main(string[] args)
{
var files = ListAllFileInfos();
if (!files.Any())
{
Console.WriteLine("No file is found in current directory.");
}
else
{
Console.WriteLine($"{files.Count()} files are found in current directory:");
Console.WriteLine("-------");
foreach(var file in files)
{
Console.WriteLine(file.Name);
Console.WriteLine($"{file.Length} bytes");
Console.WriteLine($"Modified at {file.LastWriteTime}");
Console.WriteLine();
}
}
}
static IEnumerable<FileInfo> ListAllFileInfos()
{
foreach(var filename in Directory.EnumerateFiles(Environment.CurrentDirectory))
{
yield return new FileInfo(filename);
}
}
}
答案是 3 次,分别由 Any()
、Count()
和 foreach
调用。这是因为方法 ListAllFileInfos()
以 延迟执行 的方式生成 IEnumerable<T>
实例。只有在调用 GetEnumerator()
方法时才会生成来自该实例的结果,这正是 Any()
、Count()
和 foreach
在幕后所做的。 像这样的代码会导致不必要的 IO 访问,因为来自 ListAllFileInfos()
的结果应该只生成一次。
让我们看看我们如何解决这个问题。
扩展方法
我们需要做的第一件事是确定一个 IEnumerable<T>
实例是否以延迟执行的方式生成。为此,我们可以简单地测试该实例是否实现了具有 Count
属性的接口。这是因为 以延迟执行方式生成的集合在枚举所有项目之前无法知道项目的总数。
有三个提供 Count
属性的基本集合接口,它们是
System.Collection.Generic.ICollection<T>
System.Collection.Generic.IReadOnlyCollection<T>
System.Collection.ICollection
所有其他具有 Count
属性的集合接口(例如,IReadOnlyList<T>
)都派生自这三个。因此,这些接口对我们来说已经足够了。现在我们可以有以下扩展方法
public static partial class DeferredEnumerable
{
public static bool Deferred<T>(this IEnumerable<T> source)
{
return !(source is ICollection<T>
|| source is IReadOnlyCollection<T>
|| source is ICollection);
}
}
接下来是根据上述结果查看我们是否需要 ToArray()
或 ToList()
。 这两种方法都会将来自延迟执行的项目缓冲到数组或 List<T>
实例中。
public static partial class DeferredEnumerable
{
public static IEnumerable<T> ExecuteIfDeferred<T>(this IEnumerable<T> source)
{
if (source is null) throw new ArgumentNullException(nameof(source));
return source.Deferred() ? source.ToList() : source;
// You may replace ToList() with ToArray().
}
}
只有在我们需要时才会调用 ToList()
。 如果 source
尚未以延迟执行的方式生成,我们可以什么都不做并返回它。
现在我们可以将原始示例更改为以下代码片段
// Namespaces omitted.
class Program
{
static void Main(string[] args)
{
var files = ListAllFileInfos().ExecuteIfDeferred(); // Here is the change!
if (!files.Any())
{
Console.WriteLine("No file is found in current directory.");
}
else
{
Console.WriteLine($"{files.Count()} files are found in current directory:");
Console.WriteLine("-------");
foreach(var file in files)
{
Console.WriteLine(file.Name);
Console.WriteLine($"{file.Length} bytes");
Console.WriteLine($"Modified at {file.LastWriteTime}");
Console.WriteLine();
}
}
}
static IEnumerable<FileInfo> ListAllFileInfos()
{
foreach(var filename in Directory.EnumerateFiles(Environment.CurrentDirectory))
{
yield return new FileInfo(filename);
}
}
}
在添加 ExecuteIfDeferred()
调用之后,ListAllFileInfos()
内部的 foreach 循环现在只会运行一次。
此更改带来的其他好处是
- 解耦:无论
ListAllFileInfos()
如何实现(也许它由第三方库或来自其他部门的同事实现),我们的代码都可以正常工作。 - 最小更改:这非常适合重构,特别是对于冗长、遗留代码,同时代码仍然保持灵活性。
关注点
给定的示例是关于文件的。但是,如果在使用数据库访问(例如 Entity Framework)时,您可能会遇到同样的问题,这也会导致冗余查询。您可以将相同的解决方案应用于您的代码。
历史
- 2018-02-04 初始发布
- 2018-02-05 添加示例下载链接
- 2018-02-06 修正示例代码