《Effective C#》笔记 使用框架

阅读《Effective C#》一书的总结,第四部分,使用框架。

Working with the Framework

30 使用覆写而不是事件处理函数

.NET类处理系统中触发的事件,有两种方法:使用事件处理函数或覆写基类中的虚方法。在派生类中,只应该覆写虚方法,而事件处理函数则应该仅用在不相关对象的交互中;

如果事件处理函数中抛出了异常,那么事件处理函数链上的后续函数则无法得到调用(委托的多播,可参考条目24);

从效率角度考虑,覆写比事件处理函数更快。事件构建于多播委托之上,任何的事件源都可以支持多个侦听者。事件处理机制与处理器相比将执行更多的工作,如坚持事件、判断是否有处理函数挂接于其上。如有责必须便利整个调用列表,执行其中的每个方法。运行时判断是否存在事件处理函数并遍历调用每一个要比调用一个虚函数花费更多的时间;

覆写虚方法代码更容易维护,事件处理函数需要同时维护事件处理函数本身以及挂接该事件的代码;

使用事件处理函数而非覆写基类方法的原因:

  1. 覆写只能用在派生类中;
  2. 事件是在运行时绑定的,灵活性更好;
  3. 可以为同一个事件提供多个事件处理函数;

31 使用IComparable和IComparer实现顺序关系

应使用泛型版本的IComparable<T>。非泛型的IComparable接口只包含一个方法CompareTo(),遵循C类库strcmp函数的传统。.NET中很多旧的API依然使用IComparable接口,它接受的参数类型时System.Object,因此必须在比较时检查参数的类型,传入的参数必须装箱拆箱。

使用非泛型版本IComparable的理由:

  1. 兼容旧的API;
  2. 便于实现反射的逻辑(反射中使用泛型难度大大增加);

实现IComparable时,请使用显示接口实现,并提供一个强类型版本的重载。强类型的重载不仅提高性能,还能够降低使用者误用.CompareTo()方法的可能(这里的性能提升并不能在.NET的Sort方法中体现出来,因为Sort方法通过接口指针访问CompareTo()方法);

下边是一个同时实现了IComparable<T>IComparable,并且重载了标准关系操作符的类的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public struct Customer : IComparable<Customer>, IComparable
{
private readonly string name;
public Customer(string name)
{
this.name = name;
}

#region IComparable<Customer> Members
public int CompareTo(Customer other)
{
return name.CompareTo(other.name);
}
#endregion

#region IComparable Members
int IComparable.CompareTo(object obj)
{
if (!(obj is Customer))
throw new ArgumentException(
"Argument is not a Customer", "obj");
Customer otherCustomer = (Customer)obj;
return this.CompareTo(otherCustomer);
}
#endregion

// Relational Operators.
public static bool operator <(Customer left, Customer right)
{
return left.CompareTo(right) < 0;
}
public static bool operator <=(Customer left, Customer right)
{
return left.CompareTo(right) <= 0;
}
public static bool operator >(Customer left, Customer right)
{
return left.CompareTo(right) > 0;
}

public static bool operator >=(Customer left, Customer right)
{
return left.CompareTo(right) >= 0;
}
}

自定义的排序,可以通过Comparison<T>委托来实现,通常做法是在类型中创建一个静态属性,如:

1
2
3
4
5
6
7
8
public static Comparison<Customer> CompareByReview
{
get
{
return (left,right) =>
left.revenue.CompareTo(right.revenue);
}
}

更早的类库中会通过IComparer来获取此类型的比较功能,下边是示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public struct Customer : IComparable<Customer>, IComparable
{
// ... 略去重复代码

private static RevenueComparer revComp = null;

// return an object that implements IComparer
// use lazy evaluation to create just one.
public static IComparer<Customer> RevenueCompare
{
get
{
if (revComp == null)
revComp = new RevenueComparer();
return revComp;
}
}

// Class to compare customers by revenue.
// This is always used via the interface pointer,
// so only provide the interface override.
private class RevenueComparer : IComparer<Customer>
#region IComparer<Customer> Members
int IComparer<Customer>.Compare(Customer left,
{
return left.revenue.CompareTo(right.revenue);
}
#endregion

32 避免使用ICloneable接口

一旦某个类型实现了ICloneable接口,那么其派生类也必须同样实现ICloneable,该类型的所有成员也必须支持ICloneable,或者提供其它方式来创建一个副本;

所有仅包含内建类型(int等)的值类型(struct)都不需要支持ICloneable,赋值语句就能完整地复制结构中所有的值,且比clone()更加高效;

一种基类与衍生类的复制的实现可以参考以下的方式,基类不实现ICloneable,而是提供一个受保护的复制构造函数,让衍生类可以复制属于基类中的那一部分,叶子类则应该都是密封的,根据需要实现ICloneable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class BaseType
{
private string label;
private int[] values;
protected BaseType()
{
label = "class name";
values = new int[10];
}
// Used by derived values to clone
protected BaseType(BaseType right)
{
label = right.label;
values = right.values.Clone() as int[];
}
}
sealed class Derived : BaseType, ICloneable
{
private double[] dValues = new double[10];
public Derived()
{
dValues = new double[10];
}
// Construct a copy
// using the base class copy ctor
private Derived(Derived right) : base(right)
{
dValues = right.dValues.Clone() as double[];
}

public object Clone()
{
Derived rVal = new Derived(this);
return rVal;
}
}

33 仅用new修饰符处理基类更新

new修饰符的滥用会造成对象调用方法的二义性;

new修饰符只是用来解决升级基类所造成的基类方法和衍生类方法冲突的问题;

34 避免重载基类中定义的方法

与条目33类似,重载基类中的方法会使使用者产生疑惑,在使用时可能需要额外的强制类型转换才能得到希望的结果;

35 PLINQ如何实现并行算法

首先是一组示例,获取源数据中小于150的数字的阶乘:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 使用方法调用语法来查询:
var nums = data.Where(m => m < 150).
Select(n => Factorial(n));

// 增加`AsParallel()`使其成为并行查询:
var numsParallel = data.AsParallel().
Where(m => m < 150).Select(n => Factorial(n));

// 使用查询语法来查询:
var nums = from n in data
where n < 150
select Factorial(n);

// 并行版的查询语法:
var numsParallel = from n in data.AsParallel()
where n < 150
select Factorial(n);

AsParallel()的后续操作会以多线程形式在多个核中执行,其返回的是一个IParallelEnumerable而不是IEnumerable

并行查询始于一个分区(partitioning)操作。PLINQ的分区操作有4种策略:

  • 范围分区(range partitioning): 把输入元素按照任务的数目平分,然后平分给每个任务。只有在输入序列支持索引,并报告其中具体元素个数时,才能使用范围分区(如List、数组等实现了IList接口的数据源上);
  • 区块分区(chunk partitioning):在每个任务请求更多工作时,这个算法将给该任务一个“区块”的输入元素。拆分区块的内部算法会随着时间推移而变化,可以理解为一开始区块的尺寸比较小,接下来随着工作进行,区块的大小可能增加,这样可以降低线程引入的额外开销,增加吞吐量。区块大小还可能随着查询处理的时间和where子句中过滤掉的元素数量而变化。其总体目标时尽可能让所有的任务同时完成,进而提高整体的吞吐量;
  • 条带分区(striped partitioning):属于一种特殊的范围分区,对处理序列开头部分的一些元素进行了优化。每个工作线程将跳过N个元素,然后处理接下来的M个元素,处理完M个元素之后,这个工作线程会再次跳过接下来的N个元素。条带分区能够避免对整个查询执行TakeWhile()和SkipWhile()时所需要的线程同步。此外该算法用来寻找下一块待处理元素的计算也非常简单;
  • 散列分区(Hash partitioning):专门用来处理Join、GroupJoin、GroupBy、Distinct、Except、Union和Intersect操作。这些操作代价高昂,用一个专门的分区算法能提高其并行程度。散列分区能保证所有生成同样散列码的元素都由同一个任务处理。这就降低了任务之间交互通信带来的性能损失;

除了分区算法之外,PLINQ还使用了3种不同的算法来让任务并行执行:

  • 管道(Pipelining):默认使用的算法,该算法中使用一个线程用来处理枚举(foreach循环或查询序列),其它多个线程用来处理对序列中的各个元素的查询。在请求序列中的一个新元素时,该元素将由一个不同的线程处理;
  • 停止并进行(Stop&Go):进行枚举的线程将联结所有运行查询表达式的线程。在请求某个查询完整的执行结果(如调用ToList()、ToArray()或需要完整的结果来进行下一步操作如排序)时,将用到该方法。停止并进行会略微提升一些性能,但却以占用更多的内存为代价;
  • 反向枚举(Inverted Enumeration):反向枚举并不会生成结果,而是在每个查询表达式的结果上执行一些操作;

PLINQ会尝试为你编写的查询创建出最好的实现,用最少的工作和最短的时间来生成你需要的结果。需要对底层的不同实现有一定的理解,才能尽量确保发挥底层技术的最大功效;

对于并行算法,使用多处理器带来的程序性能提升受限于程序的顺序执行部分(Amdahl定律),可以使用AsOrdered()AsUnordered()来告知PLINQ结果序列中的顺序是否重要;

有时算法依赖于一些副作用(side effects)而无法并行执行,此时可以使用ParallelEnumerable.AsSequential()扩展方法来强制顺序执行;

ParallelEnumerable还包含了一些方法,用于控制PLINQ如何执行并行查询:

  • WithExecutionMode()可以用来强制并行执行。默认情况下PLINQ仅会在可以预料到并行会提高性能的查询中应用并行算法;
  • WithDegreeOfParallelism()可以用来指定并行算法中将用到的线程数目;
  • WithMergeOptions()可以用来控制PLINQ如何在查询中缓存结果;

36 理解PLINQ在I/O密集场景中的应用

并行任务库不仅可以优化CPU密集操作,也可以在I/O密集场景中发挥作用。可以使用方法调用或LINQ查询语法来使用并行执行模型,通常I/O密集的并行执行行为会比CPU核数更多的线程,因为I/O密集的线程将花费更多的时间来等待外部的事件;

一个简单的示例:

1
2
3
4
5
foreach (var url in urls)
{
var result = new WebClient().DownloadData(url);
UseResult(result);
}

其中DownloadData将发出一个同步的web请求并等待接收到所有的数据。可以使用并行的for循环将其改为并行执行:

1
2
3
4
5
Parallel.ForEach(urls, url =>
{
var result = new WebClient().DownloadData(url);
UseResult(result);
});

也可以使用PLINQ和查询语法实现同样的结果:

1
2
3
var results = from url in urls.AsParallel()
select new WebClient().DownloadData(url);
results.ForAll(result => UseResult(result));

PLINQ执行方式将使用固定数目的线程,而Parallel.ForEach将调整线程的数量来增加吞吐量;

上述代码虽然使用了多个线程来并行执行任务,但程序的其它部分仍需等待所有的web请求结束才能继续其它的工作。并行任务库还支持这样的机制:执行一系列的I/O密集操作,同时对其结果进行处理,例如:

1
2
3
4
urls.RunAsync(
url => startDownload(url),
task => finishDownload(task.AsyncState.ToString(),
task.Result));

其中涉及到的函数的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public static void RunAsync<T, TResult>(
this IEnumerable<T> taskParms,
Func<T, Task<TResult>> taskStarter,
Action<Task<TResult>> taskFinisher)
{
taskParms.Select(parm => taskStarter(parm)).
AsParallel().
ForAll(t => t.ContinueWith(t2 => taskFinisher(t2)));
}

private static void finishDownload(string url, byte[] bytes)
{
Console.WriteLine("Read {0} bytes from {1}",
bytes.Length, url);
}

private static Task<byte[]> startDownload(string url)
{
var tcs = new TaskCompletionSource<byte[]>(url);
var wc = new WebClient();
wc.DownloadDataCompleted += (sender, e) =>
{
if (e.UserState == tcs)
{
if (e.Cancelled)
tcs.TrySetCanceled();
else if (e.Error != null)
tcs.TrySetException(e.Error);
else
tcs.TrySetResult(e.Result);
}
};
wc.DownloadDataAsync(new Uri(url), tcs);
return tcs.Task;
}

使用并行任务库和PLINQ,和可支持多种异步模式的Task类,配合I/O密集操作或者混合了I/O和CPU密集的操作来执行。

37 注意并行算法中的异常

注意事项:

  • 后台线程中发生的异常会在不同方面增加复杂性;
  • 异常不能穿过线程边界保留调用栈,在异常传递到开始线程的方法时,线程就会终止;
  • 调用线程无法捕获这个错误,也不能进行什么处理;
  • 如果并行算法需要支持在错误时回滚,那么就需要更多的精力来了解已经做出了哪些修改以及如何从错误中恢复;

并行操作使用一个新的AggregateException类型来处理并行操作中的异常。它将作为一个容器,在其InnerExceptions属性中包含了并行操作中生成的所有异常。通常有两种做法来处理这些异常:

  1. 在外层处理子任务抛出的异常;
  2. 在后台线程中处理异常以保证没有异常会离开此线程;

第一种做法,需要注意InnerExceptions中也可能会包含AggregateException对象,对于可能出现的异常必须区分对待,可以处理和恢复的直接处理,其它的再向外层抛出。上一条目中的RunAsync()方法若考虑异常则可以写为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
try
{
urls.RunAsync(
url => startDownload(url),
task => finishDownload(task.AsyncState.ToString(),
task.Result));
}
catch (AggregateException problems)
{
var handlers = new Dictionary<Type, Action<Exception>>();
handlers.Add(typeof(WebException),
ex => Console.WriteLine(ex.Message));
if (!HandleAggregateError(problems, handlers))
throw;
}

private static bool HandleAggregateError(
AggregateException aggregate,
Dictionary<Type, Action<Exception>> exceptionHandlers)
{
foreach (var exception in aggregate.InnerExceptions)
if (exception is AggregateException)
return HandleAggregateError(
exception as AggregateException,
exceptionHandlers);
else if (exceptionHandlers.ContainsKey(
exception.GetType()))
{
exceptionHandlers[exception.GetType()]
(exception);
}
else
return false;
return true;
}

第二种做法,为保证没有异常离开线程,必须修改执行后台任务的代码,即是说使用TaskCompletionSource<>时永远不要调用TrySetException()方法,而是必须保证每个任务都会调用到TrySetResult(),表示任务完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private static Task<byte[]> startDownload(string url)
{
var tcs = new TaskCompletionSource<byte[]>(url);
var wc = new WebClient();
wc.DownloadDataCompleted += (sender, e) =>
{
if (e.UserState == tcs)
{
if (e.Cancelled)
tcs.TrySetCanceled();
else if (e.Error != null)
{
if (e.Error is WebException) tcs.TrySetResult(new byte[0]); else tcs.TrySetResult(e.Result);
}
else
tcs.TrySetException(e.Error);
}
};
wc.DownloadDataAsync(new Uri(url), tcs);
return tcs.Task;
}

注意,这里的异常仍是分为两类。当出现异常是WebException时,则认为是读取到0字节的数据(在后台线程中可以处理),而其它的异常则还是需要抛出AggregateException(致命错误,必须外部处理)。

REFERENCE

《C#高效编程 改进C#代码的50个行之有效的办法》

https://docs.microsoft.com/zh-cn/dotnet/standard/parallel-programming/parallel-linq-plinq