|
|
@@ -0,0 +1,3941 @@
|
|
|
+# 高质量 C# 手册
|
|
|
+
|
|
|
+> 本文档由两本书合并提炼而成,共 **207 条**可执行规则:
|
|
|
+>
|
|
|
+> | 原书 | 作者 | 结构 | 写作年代 |
|
|
|
+> |---|---|---|---|
|
|
|
+> | 《编写高质量代码:改善 C# 程序的 157 个建议》 | 陆敏技(机械工业出版社) | 12 章 / 157 条「建议」 | 2011,基于 .NET 4.0 |
|
|
|
+> | 《Effective C#:C# 高效编程》(第 2 版) | Bill Wagner(人民邮电出版社) | 6 章 / 50 条「条目」 | 2010,基于 C# 4.0 |
|
|
|
+>
|
|
|
+> 每条保留四段式结构:**问题 → 规则 → 要点 → 陷阱**。凡书中结论已被现代 .NET 推翻或废弃的,在该条末尾附一行「时代注」。
|
|
|
+
|
|
|
+## 目录
|
|
|
+
|
|
|
+- [一、两本书的定位与差异](#sec-compare)
|
|
|
+- [二、十条共同原则](#sec-principles)
|
|
|
+- [三、主题交叉索引](#sec-index)
|
|
|
+- [四、怎么用这份手册](#sec-usage)
|
|
|
+- [第一部分 《编写高质量代码》157 个建议](#part-a)
|
|
|
+ - 第 1 章 基本语言要素(建议 1–15)
|
|
|
+ - 第 2 章 集合和 LINQ(建议 16–31)
|
|
|
+ - 第 3 章 泛型、委托和事件(建议 32–45)
|
|
|
+ - 第 4 章 资源管理和序列化(建议 46–57)
|
|
|
+ - 第 5 章 异常与自定义异常(建议 58–70)
|
|
|
+ - 第 6 章 异步、多线程、任务和并行(建议 71–89)
|
|
|
+ - 第 7 章 成员设计(建议 90–101)
|
|
|
+ - 第 8 章 类型设计(建议 102–112)
|
|
|
+ - 第 9 章 安全性设计(建议 113–121)
|
|
|
+ - 第 10 章 命名规范(建议 122–139)
|
|
|
+ - 第 11 章 代码整洁(建议 140–153)
|
|
|
+ - 第 12 章 规范开发行为(建议 154–157)
|
|
|
+- [第二部分 《Effective C#》50 个条目](#part-b)
|
|
|
+ - 第 1 章 C# 语言习惯(条目 1–11)
|
|
|
+ - 第 2 章 .NET 资源管理(条目 12–20)
|
|
|
+ - 第 3 章 使用 C# 表达设计(条目 21–29)
|
|
|
+ - 第 4 章 使用框架(条目 30–37)
|
|
|
+ - 第 5 章 C# 中的动态编程(条目 38–44)
|
|
|
+ - 第 6 章 杂项(条目 45–50)
|
|
|
+- [附录 A 合并检查清单](#appendix-a)
|
|
|
+- [附录 B 两书的重叠、分歧与时代变化](#appendix-b)
|
|
|
+
|
|
|
+## 一、两本书的定位与差异
|
|
|
+<a id="sec-compare"></a>
|
|
|
+
|
|
|
+| 维度 | 编写高质量代码(157 建议) | Effective C#(50 条目) |
|
|
|
+|---|---|---|
|
|
|
+| 出发点 | 代码里的具体坑:一段 IL、一次装箱、一个命名错误 | 语言与框架的设计意图:为什么这样设计、API 该怎么长 |
|
|
|
+| 粒度 | 小、密、可立即照做 | 大、讲权衡、常给出取舍条件 |
|
|
|
+| 语言特性覆盖 | 几乎全:字符串、集合、LINQ、泛型、序列化、异常、并发、安全、命名、整洁 | 精选:属性、常量、相等性、资源、接口/委托/事件、泛型变体、PLINQ、dynamic/表达式树 |
|
|
|
+| 设计/架构内容 | 少,偏编码规范 | 多:可见性、接口 vs 继承、粗粒度 API、程序集边界、CLS 兼容 |
|
|
|
+| 时效性 | 大量 .NET 4.0 细节已过时(WPF/WinForm、CAS、BinaryFormatter) | 原则基本不过时,少数 API 已废弃 |
|
|
|
+| 最佳用途 | 日常编码 + Code Review 清单 | 设计类库/公共 API 时的决策依据 |
|
|
|
+
|
|
|
+**结论**:写业务代码时翻第一部分当检查表;设计公共类型、库、跨程序集边界时先读第二部分。两书观点冲突极少,冲突处基本是「原则 vs 细节」,见附录 B。
|
|
|
+
|
|
|
+## 二、十条共同原则
|
|
|
+<a id="sec-principles"></a>
|
|
|
+
|
|
|
+两书 207 条规则反复指向同一批判断,先记住这十条,剩下的都是它们的实例。
|
|
|
+
|
|
|
+1. **用语言现成的机制,别手写等价物。** 属性代替公有字段、泛型代替 `object`、`Action`/`Func` 代替自建委托、查询语法代替手写循环、`using` 代替手写 `try/finally`。(高效 1/3/8/24;改善 20/36/91)
|
|
|
+2. **编译期能查出来的错误,不要留到运行时。** 泛型集合、约束、`TryParse`、`is`/`as`、枚举从 0 开始。(改善 4/20/34;高效 18/19)
|
|
|
+3. **二进制兼容与版本演进,优先于那一点点性能。** 默认 `readonly` 而非 `const`,字段改属性要重编所有调用方,可选参数默认值烧进调用方 IL。(高效 2/10/33)
|
|
|
+4. **封装不是形式,是修改的自由。** 字段一律私有、可见性取最小、返回基类型/接口、集合属性不暴露内部可变对象。(改善 25/91/96/146;高效 21/26)
|
|
|
+5. **资源释放必须确定。** `IDisposable` + `using` + 标准 Dispose 模式 + 终结器只作兜底;Dispose 可重入;托管/非托管分开处理。(改善 46–53;高效 15/17)
|
|
|
+6. **异常是契约的一部分。** 不用返回码代替异常、不吞、不嵌套、重抛保留 `InnerException`、循环外做 Tester-Doer、自定义异常要克制且有层级。(改善 58–70;高效 46/47)
|
|
|
+7. **默认不共享状态,共享必加锁,锁要私有且范围最小。** 线程用 `Task`/`Parallel`/PLINQ 而非裸 `Thread`;并行体内的异常必须观察,并行不一定更快。(改善 71–89;高效 35–37)
|
|
|
+8. **相等性、哈希、排序、格式化是一组契约,必须成套实现。** `Equals` 与 `GetHashCode` 同改,`==` 与 `Equals` 语义一致,`IComparable<T>` 与 `IComparer<T>` 分开看。(改善 10–13;高效 6/7/31)
|
|
|
+9. **性能来自「少做事」,且必须测量。** 少装箱、少分配、延迟求值、避免多余迭代;优化前先量,并行化前先确认瓶颈在工作而非在等。(改善 1/28/31/88;高效 16/45)
|
|
|
+10. **可读性优先于技巧。** 方法短、抽象层次一致、命名达意、表驱动代替长分支、注释解释「为什么」、用测试固定行为。(改善 142–157;高效 11)
|
|
|
+
|
|
|
+## 三、主题交叉索引
|
|
|
+<a id="sec-index"></a>
|
|
|
+
|
|
|
+按主题查,不按书查。编号前缀:**A** = 改善建议,**B** = 高效条目。
|
|
|
+
|
|
|
+| 主题 | A:改善建议 | B:高效条目 |
|
|
|
+|---|---|---|
|
|
|
+| 封装、可见性、成员暴露 | 91, 92, 96, 97, 140, 146 | 1, 21, 26 |
|
|
|
+| 常量、静态成员与初始化 | 6, 106 | 2, 13, 14 |
|
|
|
+| 类型转换、类型判断、解析 | 2, 3, 4, 5 | 3, 9 |
|
|
|
+| 相等性、比较、哈希 | 10, 11, 12, 30 | 6, 7, 31 |
|
|
|
+| 格式化与 `ToString` | 13 | 5 |
|
|
|
+| 拷贝、克隆 | 14 | 32 |
|
|
|
+| 值类型、可空、枚举语义 | 5, 7, 8, 110, 113 | 18, 19, 20 |
|
|
|
+| 字符串与装箱 | 1 | 16, 45 |
|
|
|
+| 集合选择与遍历 | 16–25 | 8 |
|
|
|
+| LINQ 与延迟求值 | 26–31 | 8, 35–37 |
|
|
|
+| 泛型与协变/逆变 | 32–35, 42–45, 129 | 29, 39 |
|
|
|
+| 委托、事件、Lambda、闭包 | 36–41, 137–139, 150, 151 | 24, 25, 30 |
|
|
|
+| 资源管理与对象生命周期 | 46–53, 69 | 15, 16, 17 |
|
|
|
+| 序列化 | 54–57 | 27 |
|
|
|
+| 异常与错误处理 | 58–70, 153 | 46, 47 |
|
|
|
+| 异步、并发、并行 | 71–89 | 35, 36, 37 |
|
|
|
+| 类型设计、接口/继承/组合 | 90–112 | 21–23, 28, 33, 34 |
|
|
|
+| 方法设计、参数与重载 | 98, 99, 100, 143–145, 149 | 10, 11, 34 |
|
|
|
+| 动态编程、反射、表达式树 | 15 | 38–44 |
|
|
|
+| 安全、加密、权限 | 113–121 | 48 |
|
|
|
+| 命名 | 122–139 | — |
|
|
|
+| 整洁代码与重构 | 140–153 | 11, 30 |
|
|
|
+| 程序集、版本与兼容 | 120, 123, 156 | 2, 33, 49, 50 |
|
|
|
+| 测试与工程行为 | 154–157 | — |
|
|
|
+
|
|
|
+## 四、怎么用这份手册
|
|
|
+<a id="sec-usage"></a>
|
|
|
+
|
|
|
+- **第一遍通读第一部分 1–3 章 + 第二部分全部**:这两块是两书交集最大、收益最高的部分。
|
|
|
+- **按需查**:用主题索引定位到编号,再跳到条目;每条都自足,不需要顺序阅读。
|
|
|
+- **团队用法**:附录 A 的检查清单可直接搬进 Code Review 模板;命名部分(A 122–139)可直接当规范条文。
|
|
|
+- **看「时代注」**:凡是涉及 `BinaryFormatter`、`Thread.Abort`、CAS、`SecureString`、WPF/WinForm 线程模型的结论,现代 .NET 已有替代方案,附录 B 末尾给了对照表。
|
|
|
+- **别照抄性能建议**:书中很多「更快」是 .NET 4.0 的实测结论,现代 JIT/RyuJIT 下未必成立,遵守原则、实测细节。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+# 第一部分 《编写高质量代码》157 个建议
|
|
|
+<a id="part-a"></a>
|
|
|
+
|
|
|
+> 陆敏技。原文按「问题—示例—结论」组织,这里统一为四段式。这一部分偏具体 API 与性能细节,可直接当编码检查表。
|
|
|
+
|
|
|
+## 第 1 章 基本语言要素
|
|
|
+
|
|
|
+### 建议1:正确操作字符串
|
|
|
+
|
|
|
+- **问题**:字符串拼接与转换中的装箱、以及 string 不可变导致的额外内存分配。
|
|
|
+- **规则**:值类型转字符串用其 `ToString()`,不要用 `+` 隐式拼接;运行期多次拼接用 `StringBuilder` 并预估容量;纯字面量拼接交给编译器。
|
|
|
+- **要点**:
|
|
|
+ - `"str1" + 9` 编译为 `String.Concat(object, object)`,9 处生成 `box` 指令;`9.ToString()` 调用 `Number.FormatInt32`(InternalCall,直接操作内存),无装箱。
|
|
|
+ - 装箱三步:托管堆分配(含类型对象指针与同步块索引)、值复制入堆、返回对象地址。
|
|
|
+ - string 不可变,任何赋值/拼接/方法调用都产生新对象;`const` 与字面量在编译期折叠为单个字符串。
|
|
|
+ - `StringBuilder` 默认容量 16,超出按上次容量加倍(16→32→64);容量过小频繁扩容,过大浪费内存。
|
|
|
+ - `string.Format` 内部使用 `StringBuilder`;`a+b+c+d` 只调用一次 `Concat`,而 `a+=b` 连写三次调用三次。
|
|
|
+- **陷阱**:`a += "e"; a += "s"; a += "t";` 并不比 `a+b+c+d` 省对象,反而多两次 `Concat`;`value + ""` 同样触发装箱。
|
|
|
+```csharp
|
|
|
+string a = "t";
|
|
|
+a += "e"; // 每次 += 都新建字符串
|
|
|
+var sb = new StringBuilder(32); // 预估容量,避免扩容
|
|
|
+sb.Append(a).Append("st");
|
|
|
+string result = sb.ToString();
|
|
|
+```
|
|
|
+- 时代注:现代 C# 的 `$"..."` 插值由 `DefaultInterpolatedStringHandler` 处理,对值类型走 `AppendFormatted<T>`,不再产生 `params object[]` 与装箱;高频路径仍应优先插值或 `StringBuilder`。
|
|
|
+
|
|
|
+### 建议2:使用默认转型方法
|
|
|
+
|
|
|
+- **问题**:面对 FCL 类型时该选哪种转型手段。
|
|
|
+- **规则**:优先用类型自带的转换运算符、`Parse/TryParse`、`ToString/ToDouble/ToDateTime`、`Convert`/`BitConverter`,最后才是 CLR 的继承转型。
|
|
|
+- **要点**:
|
|
|
+ - 基元类型(sbyte…decimal、object、string 等编译器直接支持的类型)普遍提供隐式/显式转换运算符:`int→float` 隐式,`float→int` 必须显式。
|
|
|
+ - `System.Convert` 支持基元类型互转,也能转换实现了 `IConvertible` 的自定义类型;`BitConverter` 负责基元类型与字节数组互转。
|
|
|
+ - 上溯(子类→基类)隐式转换,下溯(基类→子类)必须显式转换。
|
|
|
+ - 自定义类型实现 `IConvertible` 时,不支持的转换应抛 `InvalidCastException`,不要抛 `NotImplementedException`。
|
|
|
+- **陷阱**:不要轻易为自己的类型重载转换运算符;类型间有继承/实现关系时应走 CLR 转型,转换运算符只适合无继承关系的类型。`as` 不能用于自定义转换运算符,编译期即失败。
|
|
|
+
|
|
|
+### 建议3:区别对待强制转型与as和is
|
|
|
+
|
|
|
+- **问题**:`(T)x`、`as`、`is` 三种写法语义不同,混用会把编译期能发现的问题拖到运行期。
|
|
|
+- **规则**:同一继承体系内“基类→子类”用 `as`;无继承关系、靠转换运算符沟通的兄弟类型用强制转型。通用方法参数为 `object` 时优先 `as` + null 判断。
|
|
|
+- **要点**:
|
|
|
+ - 强制转型只有两种合法前提:两者间存在转换运算符,或存在继承关系;二者不能同时成立。
|
|
|
+ - `as` 只适用于引用类型和可空类型,永不抛异常:类型不符或源为 null 时结果为 null。
|
|
|
+ - `(SecondType)obj` 中 `obj` 的静态类型是 object,编译器只检查 object 与目标类型的继承关系,随后发出运行时类型检查,从而绕过转换运算符 → `InvalidCastException`。
|
|
|
+ - `is` 先判类型、`as` 再转一次,等于两次类型检测,效率低于单用 `as`;基元类型不能用 `as`,只能用 `is` 判断后再强转。
|
|
|
+- **陷阱**:`(SecondType)firstType` 能成功、`(SecondType)(object)firstType` 却抛异常,多一层 object 就换了一条转换路径。
|
|
|
+```csharp
|
|
|
+static void DoWithSomeType(object obj)
|
|
|
+{
|
|
|
+ var second = obj as SecondType; // 失败为 null,不抛异常
|
|
|
+ if (second != null)
|
|
|
+ {
|
|
|
+ // ...
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+- 时代注:C# 7 起可用模式匹配 `if (obj is SecondType second)`,一次检测同时完成判断与赋值,优于 `is` + `as` 两次检测。
|
|
|
+
|
|
|
+### 建议4:TryParse比Parse好
|
|
|
+
|
|
|
+- **问题**:字符串转数值失败时,`Parse` 抛异常的开销远大于返回 false。
|
|
|
+- **规则**:外部输入(用户输入、配置、网络报文)一律用 `TryParse`;只有“格式错误即程序 bug”的内部数据才用 `Parse`。
|
|
|
+- **要点**:
|
|
|
+ - `double.Parse(string)` 失败抛 `FormatException`(null 抛 `ArgumentNullException`);`TryParse(string, out double)` 返回 false 并把 result 置 0。
|
|
|
+ - .NET 2.0 起基元类型才提供 `TryParse`;此前只能 try/catch。
|
|
|
+ - 原文实测 1000 次:成功时 Parse 6661 ticks、TryParse 2886 ticks;失败时 Parse 2062347 ticks、TryParse 3379 ticks——失败路径差三个数量级。
|
|
|
+ - TryParse 模式:`Do` 失败抛异常,`TryDo` 返回 bool + `out` 结果;只有 `Do` 确有明显性能损耗时才值得提供。
|
|
|
+- **陷阱**:忽略返回值会让结果静默变成 0;`TryParse` 默认用当前区域文化,解析协议/文件中的小数点应传 `CultureInfo.InvariantCulture`(`NumberStyles` 重载)。
|
|
|
+- 时代注:.NET Core 2.1+ 提供 `TryParse(ReadOnlySpan<char>, out T)`,可对字符串切片解析而不分配子串。
|
|
|
+
|
|
|
+### 建议5:使用int?来确保值类型也可以为null
|
|
|
+
|
|
|
+- **问题**:数据库可空列、分布式报文中缺失的数值,需要“无值”状态而不是 0。
|
|
|
+- **规则**:用 `Nullable<T>`(简写 `T?`)表达可空值类型;取实际值用 `HasValue/Value` 或 `??` 提供默认值。
|
|
|
+- **要点**:
|
|
|
+ - `public struct Nullable<T> where T : struct`,因此只有值类型能作为 T;引用类型本身已可为 null。
|
|
|
+ - `T` 可隐式转 `T?`;`T?` 不能隐式转 `T`,必须显式取 `Value` 或强转。
|
|
|
+ - `int j = i ?? 0;` 等价于 `i.HasValue ? i.Value : 0`;`GetValueOrDefault()` 同义。
|
|
|
+ - `Nullable<T>` 的范围是基础类型全部值再加一个 null。
|
|
|
+- **陷阱**:对 null 的 `Nullable<T>` 访问 `.Value` 或执行 `(int)i` 会抛 `InvalidOperationException`;`i.HasValue && i > 0` 这类判断别漏掉 HasValue。
|
|
|
+```csharp
|
|
|
+int? i = null;
|
|
|
+int j = i ?? 0; // 无值时取 0
|
|
|
+int k = i.GetValueOrDefault(); // 同上
|
|
|
+// int m = (int)i; 抛 InvalidOperationException
|
|
|
+```
|
|
|
+- 时代注:C# 8 的可空引用类型(`string?`)是编译期静态标注,与 `Nullable<T>` 这个运行期结构体是两套机制,别混为一谈。
|
|
|
+
|
|
|
+### 建议6:区别readonly和const的使用方法
|
|
|
+
|
|
|
+- **问题**:两者都表“不可变”,但编译期/运行期语义、可修饰类型、版本行为完全不同。
|
|
|
+- **规则**:默认用 `readonly`(或 `static readonly`);只有确需编译期内联且类型受限时才用 `const`。
|
|
|
+- **要点**:
|
|
|
+ - `const` 是编译期常量,天然 static,再写 `static const` 编译失败;只能修饰基元类型、枚举、string。
|
|
|
+ - `const` 在调用处被替换为字面值,`Console.WriteLine(ConstValue)` 与 `Console.WriteLine(100)` 生成相同 IL。
|
|
|
+ - `readonly` 是运行时常量,可在声明处或构造函数中赋值(构造函数内可多次赋值,初始化器相当于构造函数的一部分),每个实例可有不同值。
|
|
|
+ - `readonly` 对值类型锁的是值,对引用类型锁的是引用:引用不能改指向,但所指对象的成员可改。
|
|
|
+- **陷阱**:`const` 的值被内嵌进调用方程序集,只改定义方而不重编译调用方,调用方仍用旧值——跨程序集常量应改用 `static readonly`;`readonly` 字段若是可变集合,集合内容仍可被修改。
|
|
|
+- 时代注:C# 7.2 起有 `readonly struct`,C# 8 起有 `readonly` 成员,用于声明“不修改状态”的结构体与方法,语义比字段级 readonly 更强。
|
|
|
+
|
|
|
+### 建议7:将0值作为枚举的默认值
|
|
|
+
|
|
|
+- **问题**:枚举变量的默认值是 0,若没有值为 0 的成员,就会出现“第 8 个值”。
|
|
|
+- **规则**:枚举中始终显式提供一个值为 0 的成员(如 `None`/`Unknown`/`Default`),并让它表示“未设置”。
|
|
|
+- **要点**:
|
|
|
+ - 枚举可用基础类型:byte、sbyte、short、ushort、int、uint、long、ulong,默认 int。
|
|
|
+ - `static Week week;` 未赋值即 `default(Week)` = 0;若成员从 1 开始,`Console.WriteLine(week)` 输出 `0`,且不抛异常。
|
|
|
+ - 显式去掉所有赋值后,编译器从 0 开始逐个 +1,自然获得合法的 0 值成员。
|
|
|
+ - 枚举变量可被强转为任意基础类型值:`week = (Week)9;` 合法,输出 9。
|
|
|
+- **陷阱**:`Enum.IsDefined(typeof(Week), week)` 对 0 返回 false,反序列化/ORM 映射时容易把“未赋值”当成脏数据;接口参数用枚举时尤其要为 0 留语义。
|
|
|
+```csharp
|
|
|
+enum Week { None = 0, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday }
|
|
|
+```
|
|
|
+
|
|
|
+### 建议8:避免给枚举类型的元素提供显式的值
|
|
|
+
|
|
|
+- **问题**:显式赋值后新增成员,极易与既有值撞号,产生别名。
|
|
|
+- **规则**:普通枚举不写 `=`,让编译器顺序编号;只有 `[Flags]` 才必须显式写 2 的幂。
|
|
|
+- **要点**:
|
|
|
+ - 在 `Tuesday = 2` 后插入未赋值的 `ValueTemp`,编译器取 2+1=3,与 `Wednesday = 3` 重复。
|
|
|
+ - 重复值互为别名:`week == Week.Wednesday` 为 True,`ToString()` 输出先声明的名字 `Wednesday`,`Equals` 为 True、`CompareTo` 为 0。
|
|
|
+ - 枚举比较全部基于基础类型数值,不看名字,因此别名在 `switch`、序列化、日志中都会造成误判。
|
|
|
+ - `[Flags]` 是例外:每个成员必须是 2 的幂且指数递增,并保留 `None = 0`。
|
|
|
+- **陷阱**:`Week.ValueTemp` 打印出 `Wednesday` 却查不到定义,是最典型的线上排错噩梦;给已有枚举“中间插一个成员”属于破坏性变更。
|
|
|
+```csharp
|
|
|
+[Flags]
|
|
|
+enum Week { None = 0x0, Monday = 0x1, Tuesday = 0x2, Wednesday = 0x4, Thursday = 0x8 }
|
|
|
+```
|
|
|
+- 时代注:现代分析器(CA1069 等)会直接报“枚举成员值重复”,CI 开启分析器可在编译期拦住此类问题。
|
|
|
+
|
|
|
+### 建议9:习惯重载运算符
|
|
|
+
|
|
|
+- **问题**:自定义类型用 `Add(a, b)` 之类的静态方法表达运算,可读性远不如 `a + b`。
|
|
|
+- **规则**:语义上确实是“相加/相等/比较”的值类型或不可变类型,应提供 `operator` 重载,让调用方式与基元类型一致。
|
|
|
+- **要点**:
|
|
|
+ - 运算符重载是用 `operator` 关键字定义的 **public static** 方法,CLR 层面就是普通静态方法。
|
|
|
+ - 至少一个操作数必须是包含该运算符的类型;`==`/`!=`、`<`/`>`、`<=`/`>=` 必须成对重载。
|
|
|
+ - 不能重载 `=`、`&&`、`||`、`?:`、`new`、`is`、`as` 等;`+=` 由 `+` 自动推导。
|
|
|
+ - 重载 `==` 后必须同时重写 `Equals` 与 `GetHashCode`,否则集合与比较行为不一致。
|
|
|
+- **陷阱**:原文示例 `s2.RMB += s1.RMB; return s2;` 会篡改右操作数,`a + b` 之后 b 也变了;运算符应返回新对象、不改动操作数。
|
|
|
+```csharp
|
|
|
+class Salary
|
|
|
+{
|
|
|
+ public int RMB { get; set; }
|
|
|
+ public static Salary operator +(Salary a, Salary b)
|
|
|
+ => new Salary { RMB = a.RMB + b.RMB }; // 不修改任一操作数
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议10:创建对象时需要考虑是否实现比较器
|
|
|
+
|
|
|
+- **问题**:集合排序需要默认比较规则,也需要可替换的自定义规则。
|
|
|
+- **规则**:类型有天然排序语义时实现 `IComparable<T>`;需要按其他字段排序时另写 `IComparer<T>`,通过 `Sort(comparer)` 传入。
|
|
|
+- **要点**:
|
|
|
+ - `CompareTo` 只需返回负数/0/正数表示大小关系,可直接委托 `BaseSalary.CompareTo(other.BaseSalary)`。
|
|
|
+ - `List<T>.Sort()` 无参时使用 `Comparer<T>.Default`,它优先走 `IComparable<T>`,否则走非泛型 `IComparable`。
|
|
|
+ - `List<T>.Sort(IComparer<T>)` 不产生装箱与运行时转型;`ArrayList.Sort` 的 `Compare(object, object)` 每次比较都要转型。
|
|
|
+ - `CompareTo(object)` 必须处理类型不符与 null,否则 `as` 得到 null 后解引用直接 `NullReferenceException`。
|
|
|
+- **陷阱**:`IComparable` 只能有一个默认序,第二種排序就不得不新增比较器类;`List<T>.Sort` 是不稳定排序,相等元素的原始顺序不保证保留。
|
|
|
+```csharp
|
|
|
+class Salary : IComparable<Salary>
|
|
|
+{
|
|
|
+ public int BaseSalary { get; set; }
|
|
|
+ public int CompareTo(Salary other) => BaseSalary.CompareTo(other.BaseSalary);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议11:区别对待==和Equals
|
|
|
+
|
|
|
+- **问题**:两者都可能表示“值相等”或“引用相等”,取决于类型是否重载。
|
|
|
+- **规则**:引用类型若需“值相等”,只重写 `Equals`,让 `==` 保持引用语义;需要确定比较引用时用 `object.ReferenceEquals`。
|
|
|
+- **要点**:
|
|
|
+ - 约定:值类型值相等返回 true;引用类型指向同一对象返回 true。
|
|
|
+ - `==` 是静态解析的运算符,按操作数的 **编译期类型** 选择;`Equals` 是虚方法,按运行时类型分派。
|
|
|
+ - string 是特例:`==` 与 `Equals` 都被重载为按内容比较。
|
|
|
+ - `object a = 1; object b = 1; a == b` 为 False(两次装箱是不同对象),`a.Equals(b)` 为 True。
|
|
|
+ - 值类型默认的 `ValueType.Equals` 依赖反射逐字段比较,性能差;自定义 struct 应重写。
|
|
|
+- **陷阱**:`Person.Equals` 写成 `IDCode == (obj as Person).IDCode`,传入 null 或异类型时抛 NRE;`==` 对 string 是 null 安全的,`a.Equals(b)` 在 a 为 null 时抛异常。
|
|
|
+```csharp
|
|
|
+public override bool Equals(object obj)
|
|
|
+{
|
|
|
+ var other = obj as Person;
|
|
|
+ return other != null && IDCode == other.IDCode;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议12:重写Equals时也要重写GetHashCode
|
|
|
+
|
|
|
+- **问题**:基于散列的集合(Dictionary、HashSet)先比哈希再比 Equals,哈希不一致时永远找不到“值相等”的键。
|
|
|
+- **规则**:重写 `Equals` 必须同步重写 `GetHashCode`;哈希只依赖只读字段;同时实现 `IEquatable<T>` 避免装箱。
|
|
|
+- **要点**:
|
|
|
+ - `object.GetHashCode` 默认取对象创建时分配的固定标识,两个内容相同的实例哈希不同。
|
|
|
+ - `Dictionary<Person, PersonMoreInfo>` 中,用另一个 `new Person("NB123")` 调 `ContainsKey` 会得到 False,即使 `Equals` 已重写。
|
|
|
+ - 相等对象必须有相等哈希;哈希相等不要求对象相等(允许碰撞)。
|
|
|
+ - 把类型全名拼进哈希源可降低不同类型间的碰撞概率。
|
|
|
+- **陷阱**:哈希字段可变会让对象放入字典后再也找不到;`Equals` 为 true 而 `GetHashCode` 不同,属于最隐蔽的集合 Bug。
|
|
|
+```csharp
|
|
|
+public override int GetHashCode() => IDCode.GetHashCode(); // IDCode 只读
|
|
|
+public bool Equals(Person other) => other != null && IDCode == other.IDCode;
|
|
|
+```
|
|
|
+- 时代注:.NET Core 起 string 哈希按进程随机化,不能持久化或跨进程比较;多字段哈希用 `HashCode.Combine(a, b)`。
|
|
|
+
|
|
|
+### 建议13:为类型输出格式化字符串
|
|
|
+
|
|
|
+- **问题**:`ToString()` 只能给一种输出,无法按场景/文化切换格式。
|
|
|
+- **规则**:类型自己能预见格式需求就实现 `IFormattable`;无法改动类型或格式需求多变时,另写 `IFormatProvider` + `ICustomFormatter`。
|
|
|
+- **要点**:
|
|
|
+ - `IFormattable.ToString(string format, IFormatProvider formatProvider)` 按 format 分支返回不同结果;未重写 `ToString` 时默认输出类型全名。
|
|
|
+ - 格式化器需实现 `IFormatProvider.GetFormat(Type)`,当请求 `typeof(ICustomFormatter)` 时返回自身,否则返回 null。
|
|
|
+ - `ICustomFormatter.Format(string format, object arg, IFormatProvider provider)` 承担实际格式化。
|
|
|
+ - 在 `IFormattable` 的 default 分支里转调 `formatProvider as ICustomFormatter`,可让 `person.ToString("ChM", formatter)` 这种简洁语法生效。
|
|
|
+- **陷阱**:格式化器里用 `arg as Person` 后不判 null 就访问属性;格式串不匹配时应回退到默认格式而不是返回空串掩盖问题;`string.Format(provider, "{0:Ch}", person)` 才会走 `ICustomFormatter`,直接 `person.ToString()` 不会。
|
|
|
+
|
|
|
+### 建议14:正确实现浅拷贝和深拷贝
|
|
|
+
|
|
|
+- **问题**:`MemberwiseClone` 只复制引用,副本与源共享引用对象,改一个动两个。
|
|
|
+- **规则**:浅拷贝用 `MemberwiseClone`;深拷贝优先序列化(或逐字段手工复制);`ICloneable.Clone` 语义含糊,另提供 `DeepClone`/`ShallowClone` 两个明确方法。
|
|
|
+- **要点**:
|
|
|
+ - 浅拷贝:值类型字段逐位复制,引用类型字段只复制引用;副本改引用对象会影响源对象。
|
|
|
+ - 深拷贝:引用对象也重新创建,两者完全独立。
|
|
|
+ - `MemberwiseClone` 是 `object` 的 protected 方法,不调用构造函数。
|
|
|
+ - string 虽为引用类型,但不可变,浅拷贝时按值类型看待,副本与源各自持有同一字符串也无副作用。
|
|
|
+ - 深拷贝要求类型及所有引用字段标 `[Serializable]`,用 `BinaryFormatter` 序列化到 `MemoryStream` 再反序列化。
|
|
|
+- **陷阱**:手工逐字段复制在增删字段时容易漏;`[NonSerialized]` 字段不会被复制;序列化深拷贝性能差且要求类型可序列化。
|
|
|
+```csharp
|
|
|
+public object Clone() => MemberwiseClone(); // 浅拷贝
|
|
|
+public Employee DeepClone()
|
|
|
+{
|
|
|
+ using var ms = new MemoryStream();
|
|
|
+ var formatter = new BinaryFormatter();
|
|
|
+ formatter.Serialize(ms, this);
|
|
|
+ ms.Seek(0, SeekOrigin.Begin);
|
|
|
+ return (Employee)formatter.Deserialize(ms);
|
|
|
+}
|
|
|
+```
|
|
|
+- 时代注:`BinaryFormatter` 在 .NET 5+ 标记过时、.NET 9 起移除,且存在反序列化安全风险;深拷贝改用 `System.Text.Json`、`record` 的 `with` 或手写复制。
|
|
|
+
|
|
|
+### 建议15:使用dynamic来简化反射实现
|
|
|
+
|
|
|
+- **问题**:`MethodInfo.Invoke` 写法冗长、每次调用都有反射与装箱开销。
|
|
|
+- **规则**:需要动态调用成员时优先 `dynamic`,比裸反射快一到两个数量级,且代码接近普通调用。
|
|
|
+- **要点**:
|
|
|
+ - `dynamic` 编译后是 `object`,编译器跳过类型检查,把绑定推迟到运行时由 DLR 完成;成员不存在抛 `RuntimeBinderException`。
|
|
|
+ - `var` 是编译期语法糖,类型由编译器推断并写死;`dynamic` 是运行期绑定,二者不可类比。
|
|
|
+ - 原文 100 万次调用:反射 2575 ms、dynamic 76 ms、`Delegate.CreateDelegate` 优化的反射 12 ms。
|
|
|
+ - DLR 会缓存调用点,首次调用慢、后续调用接近委托。
|
|
|
+- **陷阱**:dynamic 无智能感知、无编译期检查,错误留到运行时;扩展方法不参与 dynamic 绑定;不能用于表达式树,`typeof(dynamic)` 非法。
|
|
|
+```csharp
|
|
|
+dynamic sample = new DynamicSample();
|
|
|
+int re = sample.Add(1, 2); // 运行时绑定,缺失成员抛 RuntimeBinderException
|
|
|
+```
|
|
|
+
|
|
|
+## 第 2 章 集合和 LINQ
|
|
|
+
|
|
|
+### 建议16:元素数量可变的情况下不应使用数组
|
|
|
+
|
|
|
+- **问题**:数组长度固定,扩容只能新建数组并复制,代价远高于 `List<T>`。
|
|
|
+- **规则**:数量固定且不涉及转型用数组;数量会变一律用 `List<T>`(或 `ArrayList`,但不推荐)。
|
|
|
+- **要点**:
|
|
|
+ - 一维数组是向量,IL 有 `newarr/ldelem/ldelema/ldlen/stelem` 专用指令,性能最佳。
|
|
|
+ - 引用类型数组每元素占 `IntPtr.Size`;`ArrayList` 存值类型时每元素多 12 字节(4 字节引用 + 8 字节对象头)。
|
|
|
+ - 超过 85000 字节的对象进入大对象堆(LOH),分配与回收代价高,不要随意声明超长数组。
|
|
|
+ - `List<T>` 的 `Add` 按容量加倍扩容,均摊 O(1);数组“扩容”必须整体复制。
|
|
|
+ - 原文实测把长度 7 扩到 10:数组复制 4441 ticks,`List<T>` 三次 Add 仅 36 ticks。
|
|
|
+- **陷阱**:自己写 `ReSize` 扩展方法纯属重复造轮子——BCL 已有 `Array.Resize(ref array, newSize)`,语义相同(新建 + 复制)。
|
|
|
+```csharp
|
|
|
+int[] arr = { 0, 1, 2, 3, 4, 5, 6 };
|
|
|
+Array.Resize(ref arr, 10); // 内部新建数组并复制
|
|
|
+var list = new List<int>(capacity: 100); // 预估容量,减少扩容
|
|
|
+```
|
|
|
+
|
|
|
+### 建议17:多数情况下使用foreach进行循环遍历
|
|
|
+
|
|
|
+- **问题**:直接针对具体集合类型写 for/while 会让调用方依赖内部结构,集合一换代码全改。
|
|
|
+- **规则**:只依赖 `IEnumerable<T>`/`IEnumerator<T>` 抽象,遍历统一用 `foreach`;自定义集合实现 `GetEnumerator`。
|
|
|
+- **要点**:
|
|
|
+ - 迭代器模式把“遍历”从集合中剥离,客户端只依赖 `MoveNext()` 与 `Current`。
|
|
|
+ - `foreach` 编译为 `GetEnumerator()` + `while(MoveNext())` + `Current`,并自动包进 try/finally。
|
|
|
+ - 枚举器实现 `IDisposable` 时,循环结束(含 break/异常)一定会调用 `Dispose`。
|
|
|
+ - 泛型集合的枚举器是 struct,`foreach` 直接使用可避免装箱;通过 `IEnumerator<T>` 接口使用则会装箱。
|
|
|
+ - `foreach` 并非必须实现接口:只要类型有 public `GetEnumerator()`,返回类型有 `MoveNext()` 与 `Current` 即可(模式匹配)。
|
|
|
+- **陷阱**:`foreach (var x in list)` 的迭代变量只读,不能赋值;通过非泛型 `IEnumerable` 遍历值类型集合会逐元素装箱。
|
|
|
+- 时代注:C# 5 起 `foreach` 变量每轮迭代都是新变量,闭包捕获得到当轮值;此前捕获的是同一个变量,循环结束后全为最后一个值。
|
|
|
+
|
|
|
+### 建议18:foreach不能代替for
|
|
|
+
|
|
|
+- **问题**:遍历过程中需要增删元素时,`foreach` 的版本检测会直接抛异常。
|
|
|
+- **规则**:需要在循环中增删集合元素时用 `for` + 索引(删除建议倒序);只读遍历优先 `foreach`。
|
|
|
+- **要点**:
|
|
|
+ - 集合内部维护 int 型 `_version`,任何 Add/Remove/Clear 都使其 +1。
|
|
|
+ - `List<T>.Enumerator.MoveNext()` 先比较 `version == list._version`,不等即抛 `InvalidOperationException`(“集合已修改”)。
|
|
|
+ - `for` 走索引器,只做 `index >= _size` 越界检查,不检查版本号。
|
|
|
+ - 两者底层都是访问 `List<T>` 内部的 `T[] _items`,泛型集合下性能同一数量级,迭代器仅多一次版本比较。
|
|
|
+- **陷阱**:正序 `for` 删除元素会因索引前移而漏掉下一个元素;应倒序删除或删除后 `i--`。
|
|
|
+```csharp
|
|
|
+for (int i = list.Count - 1; i >= 0; i--)
|
|
|
+{
|
|
|
+ if (ShouldRemove(list[i])) list.RemoveAt(i);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议19:使用更有效的对象和集合初始化
|
|
|
+
|
|
|
+- **问题**:构造对象后逐个赋属性、逐个 `Add`,代码冗长且易漏。
|
|
|
+- **规则**:用对象初始化器 `new T { Prop = v }` 与集合初始化器 `new List<T> { a, b }`;LINQ 投影用匿名类型初始化器。
|
|
|
+- **要点**:
|
|
|
+ - 对象初始化器本质是编译器在构造完成后按书写顺序调用各属性 setter,不改变构造函数语义。
|
|
|
+ - 集合初始化器要求类型实现 `IEnumerable` 并有可访问的 `Add`,编译器对每个元素调用一次 `Add`。
|
|
|
+ - 初始化列表可混用新对象、已有变量和 null。
|
|
|
+ - 匿名类型属性只读,只能在初始化器中赋值;初始化器支持表达式,可在投影时计算新属性。
|
|
|
+- **陷阱**:没有无参构造函数的类型必须先传构造参数再写初始化器:`new Person("id") { Age = 20 }`;初始化器抛异常会留下半初始化对象(构造函数已执行);集合初始化器依赖 `Add`,只读集合无法使用。
|
|
|
+- 时代注:C# 6 增加索引初始化器 `new Dictionary<int,string> { [1] = "a" }`;C# 9 支持目标类型 `new()`;C# 11 的 `required` 成员可强制调用方必须初始化。
|
|
|
+
|
|
|
+### 建议20:使用泛型集合代替非泛型集合
|
|
|
+
|
|
|
+- **问题**:`ArrayList` 以 object 存储,值类型反复装箱拆箱,且丢失编译期类型检查。
|
|
|
+- **规则**:新代码一律用 `System.Collections.Generic` 下的泛型集合,不用 `System.Collections` 下的非泛型集合。
|
|
|
+- **要点**:
|
|
|
+ - `ArrayList.Add(1)` 装箱,读取时拆箱/强转;`List<int>` 全程无装箱。
|
|
|
+ - `ArrayList` 可同时塞入 int 与 string,编译通过、运行时 `foreach (int item in al)` 抛 `InvalidCastException`。
|
|
|
+ - `new ArrayList(intArr)` 内部经 `InsertRange` 先 `CopyTo` 到 `object[]`,同样逐元素装箱。
|
|
|
+ - 原文 1000 万次 Add + 索引读取:ArrayList 2375 ms / 26 次 GC,`List<int>` 220 ms / 5 次 GC。
|
|
|
+- **陷阱**:`List<object>` 只是“泛型外壳”,值类型照样装箱,类型安全性也没恢复;API 暴露 `List<T>` 会让调用方随意增删,考虑 `IReadOnlyList<T>`。
|
|
|
+- 时代注:现代还可用 `IReadOnlyCollection<T>`/`ImmutableArray<T>` 表达只读集合,C# 12 的集合表达式 `[1, 2, 3]` 可直接初始化。
|
|
|
+
|
|
|
+### 建议21:选择正确的集合
|
|
|
+
|
|
|
+- **问题**:不同集合的插入、查找、删除复杂度差异巨大,选错会让程序“反预期”运行。
|
|
|
+- **规则**:定长且无转型用数组,否则 `List<T>`;按 FIFO 用 `Queue<T>`,LIFO 用 `Stack<T>`,按键查找用 `Dictionary<TKey,TValue>`,频繁中间插入删除用 `LinkedList<T>`,去重用 `HashSet<T>`。
|
|
|
+- **要点**:
|
|
|
+ - 直接存储(数组、`List<T>`)可按下标 O(1) 访问,尾部追加高效,中间插入需搬移元素。
|
|
|
+ - `Dictionary` 基于键的哈希分桶,按键查找接近 O(1);`LinkedList` 节点含 Previous/Next,只有 `AddFirst/AddAfter` 等,没有 `Add`。
|
|
|
+ - 非线性集合中 FCL 只实现了 `HashSet<T>`(无序、不重复),树与图没有内置实现。
|
|
|
+ - 有序版本:`SortedList<T>`、`SortedDictionary<TKey,TValue>`、`SortedSet<T>`,分别对应 List、Dictionary、HashSet 的排序版。
|
|
|
+ - 并发版本在 `System.Collections.Concurrent`:`ConcurrentBag/Dictionary/Queue/Stack<T>`。
|
|
|
+- **陷阱**:`LinkedList<T>` 没有索引器,按下标访问是 O(n);`SortedList` 插入是 O(n)(数组搬移),`SortedDictionary` 是 O(log n);`HashSet<T>`/`Dictionary` 的 correctness 完全依赖元素的 `Equals`/`GetHashCode`。
|
|
|
+- 时代注:.NET 6 增加 `PriorityQueue<TElement,TPriority>`,.NET 8 增加 `FrozenDictionary`/`FrozenSet`(只读、查询更快),并发场景还可用 `Channel<T>` 做生产者-消费者队列。
|
|
|
+
|
|
|
+### 建议22:确保集合的线程安全
|
|
|
+
|
|
|
+- **问题**:一个线程枚举、另一个线程增删,会触发版本检测异常或读到不一致数据。
|
|
|
+- **规则**:多线程共享集合时,所有读写(含枚举全过程)放在同一把锁内;或直接换用 `System.Collections.Concurrent` 下的并发集合。
|
|
|
+- **要点**:
|
|
|
+ - `List<T>` 自身不提供同步机制;`ArrayList` 有 `SyncRoot` 属性可作锁对象,泛型集合没有。
|
|
|
+ - 锁对象应是 `private static readonly object`,不要锁 `this`、`typeof(T)` 或字符串。
|
|
|
+ - 锁必须覆盖整个枚举过程,只锁单次 `Add`/`Remove` 不能防止“枚举中被修改”。
|
|
|
+ - 并发集合只保证单个操作原子,复合操作(先查后改)仍需 `AddOrUpdate`、`GetOrAdd` 或额外同步。
|
|
|
+- **陷阱**:原文示例在持锁的枚举里 `Thread.Sleep(1000)`,会把写线程阻塞数秒,是锁粒度过大的反面教材;`ConcurrentDictionary.GetOrAdd` 的工厂委托可能被执行多次。
|
|
|
+```csharp
|
|
|
+private static readonly object SyncObj = new object();
|
|
|
+
|
|
|
+lock (SyncObj)
|
|
|
+{
|
|
|
+ foreach (var item in list) { /* ... */ }
|
|
|
+}
|
|
|
+```
|
|
|
+- 时代注:现代优先 `ConcurrentDictionary`/`ConcurrentQueue`/`ImmutableArray`/`Channel<T>`;只读共享数据可完全免锁。
|
|
|
+
|
|
|
+### 建议23:避免将List<T>作为自定义集合类的基类
|
|
|
+
|
|
|
+- **问题**:`List<T>` 的 `Add` 等成员不是 virtual,继承后无法真正改变多态行为。
|
|
|
+- **规则**:自定义集合用组合持有 `List<T>`,实现 `IEnumerable<T>` 与 `ICollection<T>`(或 `IList<T>`),对外只暴露接口。
|
|
|
+- **要点**:
|
|
|
+ - `List<T>` 几乎没有 protected 成员供子类复用,继承得不到任何实现红利,只失去面向接口的灵活性。
|
|
|
+ - 用 `new` 隐藏 `Add` 后,只有静态类型为子类时才会调用新方法;通过 `IList<T>` 或基类引用调用仍走 `List<T>.Add`。
|
|
|
+ - 组合 + 接口实现可保证所有入口(自身、接口、泛型算法)行为一致。
|
|
|
+ - 需要可继承的集合基类时,用 `System.Collections.ObjectModel.Collection<T>`,它的 `InsertItem/RemoveItem` 等是 virtual。
|
|
|
+- **陷阱**:原文示例通过 `IList<Employee> employees = employees1; employees.Add(...)` 添加的元素没有加 "Changed!" 后缀,输出与设计者预期不符。
|
|
|
+```csharp
|
|
|
+class Employees : ICollection<Employee>
|
|
|
+{
|
|
|
+ private readonly List<Employee> items = new List<Employee>();
|
|
|
+ public void Add(Employee item)
|
|
|
+ {
|
|
|
+ item.Name += "Changed!";
|
|
|
+ items.Add(item);
|
|
|
+ }
|
|
|
+ // GetEnumerator、Count、Contains 等转调 items
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议24:迭代器应该是只读的
|
|
|
+
|
|
|
+- **问题**:若允许外部替换集合的迭代器,一个业务模块会悄悄改变另一个模块的遍历结果。
|
|
|
+- **规则**:集合只暴露 `GetEnumerator()`,不提供 `SetEnumerator` 或可写迭代器属性;每次调用返回新的枚举器。
|
|
|
+- **要点**:
|
|
|
+ - 可写迭代器违背开闭原则:修改集合行为却不需要改集合代码,影响范围不可控。
|
|
|
+ - 需要新遍历方式时应新建迭代器对象,而不是把它塞回集合。
|
|
|
+ - LINQ 让“新遍历需求”无需新建类型即可满足(`Where/Select/OrderBy`)。
|
|
|
+ - 枚举器是有状态对象,必须每次 `GetEnumerator()` 新建;缓存单个实例会让嵌套遍历、并发遍历互相污染。
|
|
|
+- **陷阱**:原文 `MyList` 把 `MyEnumerator` 缓存在字段里,第二次 `GetEnumerator()` 返回同一实例,两个循环同时遍历会共享游标。
|
|
|
+
|
|
|
+### 建议25:谨慎集合属性的可写操作
|
|
|
+
|
|
|
+- **问题**:可写集合属性会让外部持有同一引用的另一个变量,随时把属性置 null 或换掉集合。
|
|
|
+- **规则**:集合属性只暴露 getter(`{ get; private set; }` 或只读属性),集合对象由类型自己在构造函数中创建,永不为 null;需要导入数据用构造函数或 `AddRange`。
|
|
|
+- **要点**:
|
|
|
+ - 空集合应返回 Count 为 0 的实例,而不是 null,调用方无需判空。
|
|
|
+ - 可写属性使集合至少有两个引用(属性本身与调用方变量),任一方修改都影响另一方。
|
|
|
+ - 只读属性阻止“换引用”,但不能阻止调用方 `Add` 非法元素,必要时返回 `ReadOnlyCollection<T>`。
|
|
|
+ - 构造函数接收 `IEnumerable<T>` 并 `AddRange`,可安全复制外部数据而不共享引用。
|
|
|
+- **陷阱**:`public List<Student> Students { get; set; }` 被外部赋 null 后,另一线程访问 `Students.Count` 直接 `NullReferenceException`;即便改成只读,外部仍可通过 `Students.Clear()` 破坏内部状态。
|
|
|
+- 时代注:对外暴露 `IReadOnlyList<T>`/`IReadOnlyCollection<T>`,或返回 `ImmutableArray<T>`,可从类型层面表达“只读”。
|
|
|
+
|
|
|
+### 建议26:使用匿名类型存储LINQ查询结果
|
|
|
+
|
|
|
+- **问题**:为每个临时投影结果都定义实体类,会让代码膨胀且难以维护。
|
|
|
+- **规则**:只在当前方法内使用、用于承载查询结果的临时形状,用 `new { ... }` 匿名类型;需要跨方法传递时再定义具名类型。
|
|
|
+- **要点**:
|
|
|
+ - 匿名类型由 `var` + `new { 属性 = 表达式 }` 创建,属性只读、无 setter。
|
|
|
+ - 编译器生成一个内部类,自动实现 `Equals`、`GetHashCode`、`ToString`;`ToString` 输出 `{PersonName=Mike, CompanyName=Sun}`。
|
|
|
+ - 属性值全部相同的两个匿名对象视为相等(值语义)。
|
|
|
+ - 投影时重命名可消除歧义:join 两边都有 `Name` 时写成 `PersonName = person.Name, CompanyName = company.Name`。
|
|
|
+- **陷阱**:匿名类型不能作为字段、属性、方法返回值或参数类型,只能在方法内用 `var`/`object` 承接;跨程序集的同名同形状匿名类型不保证是同一类型。
|
|
|
+```csharp
|
|
|
+var query = from person in personList
|
|
|
+ join company in companyList on person.CompanyID equals company.CompanyID
|
|
|
+ select new { PersonName = person.Name, CompanyName = company.Name };
|
|
|
+```
|
|
|
+- 时代注:C# 7 的元组 `(string PersonName, string CompanyName)`、C# 9 的 `record` 可替代部分匿名类型场景,尤其需要跨方法返回时。
|
|
|
+
|
|
|
+### 建议27:在查询中使用Lambda表达式
|
|
|
+
|
|
|
+- **问题**:查询语法(from/where/select)只是语法糖,不理解其方法调用本质就无法灵活组合。
|
|
|
+- **规则**:任意查询语法都能改写成扩展方法 + Lambda;简单投影/筛选直接用 `Select`、`Where`、`OrderByDescending` 更短。
|
|
|
+- **要点**:
|
|
|
+ - 查询语法编译后调用 `System.Linq.Enumerable`(或 `Queryable`)的扩展方法。
|
|
|
+ - `Action` 无返回值,`Func` 有返回值,`Predicate<T>` 返回 bool 表示条件;`Select` 接收 `Func<TSource,TResult>`。
|
|
|
+ - Lambda `x => expr` 左侧是参数、右侧是方法体;单表达式即表达式 Lambda,可转换为委托或表达式树。
|
|
|
+ - `orderby` 对应 `OrderBy/OrderByDescending`,多级排序用 `ThenBy`。
|
|
|
+- **陷阱**:在 `IQueryable` 上写含任意方法调用的 Lambda 会导致表达式树无法翻译(见建议29);闭包捕获循环变量(C# 5 前)会全部取到最后一个值。
|
|
|
+```csharp
|
|
|
+var sunStaff = personList
|
|
|
+ .Where(p => p.CompanyID == 1)
|
|
|
+ .OrderByDescending(p => p.Name)
|
|
|
+ .Select(p => new { p.Name });
|
|
|
+```
|
|
|
+- 时代注:C# 9 的 `static` Lambda(`static x => ...`)禁止捕获外部变量,可避免闭包分配;局部函数也可替代复杂 Lambda。
|
|
|
+
|
|
|
+### 建议28:理解延迟求值和主动求值之间的区别
|
|
|
+
|
|
|
+- **问题**:LINQ 查询变量保存的是“查询”而非“结果”,源集合变化会改变再次遍历的输出。
|
|
|
+- **规则**:需要快照或要立即执行时调用 `ToList()`/`ToArray()`/`First()`/`Count()`;需要组合多条查询、让数据库合并条件时保持延迟。
|
|
|
+- **要点**:
|
|
|
+ - 延迟求值:定义查询时不执行,每次 `foreach` 都重新遍历源集合。
|
|
|
+ - 主动求值:`ToList/ToArray` 立即遍历并缓存结果,之后源集合的修改不再影响它。
|
|
|
+ - 原文示例:`temp1` 延迟,修改 `list[0]=11` 后输出含 11;`temp2` 已 `ToList`,输出仍是原来的 6~9。
|
|
|
+ - LINQ to SQL 中延迟求值能让多个 `where` 合并成一条 SQL,只取回满足全部条件的行。
|
|
|
+- **陷阱**:对延迟查询多次 `foreach` 会重复查询数据库;异常在枚举时抛出而非定义时,try/catch 放错位置会漏掉;在 `IQueryable` 上提前 `ToList` 会把全表拉到本地再过滤。
|
|
|
+```csharp
|
|
|
+var lazy = list.Where(x => x > 5); // 延迟
|
|
|
+var eager = list.Where(x => x > 5).ToList(); // 立即执行
|
|
|
+list[0] = 11; // 只影响 lazy
|
|
|
+```
|
|
|
+
|
|
|
+### 建议29:区别LINQ查询中的IEnumerable<T>和IQueryable<T>
|
|
|
+
|
|
|
+- **问题**:两套扩展方法签名相似,但一个在本地执行,一个要翻译成 SQL 在远端执行。
|
|
|
+- **规则**:本地集合用 `IEnumerable<T>`;数据库/远程数据源保持 `IQueryable<T>`,直到最后一刻再物化,不要中途 `AsEnumerable()`。
|
|
|
+- **要点**:
|
|
|
+ - `Enumerable` 的方法接收 `Func<T>`(委托),`Queryable` 的方法接收 `Expression<Func<T>>`(表达式树)。
|
|
|
+ - `IQueryable<T>` 继承 `IEnumerable<T>`,所以两者方法看起来几乎一样。
|
|
|
+ - `AsEnumerable()` 把远程查询截断为本地序列,其后的 `Where` 全在内存中执行,前面的条件才进 SQL。
|
|
|
+ - `IQueryable` 只能用可翻译为表达式树的操作,自定义方法会导致 `NotSupportedException`(“方法不支持转换为 SQL”);`IEnumerable` 可随意调用本地方法。
|
|
|
+- **陷阱**:仓储层返回 `IQueryable<T>` 会让调用方在任意位置追加条件、泄漏 Provider 生命周期;在循环里对 `IQueryable` 反复枚举会反复查库。
|
|
|
+```csharp
|
|
|
+var remote = persons.Where(p => p.Age > 20); // 进 SQL
|
|
|
+var local = persons.Where(p => p.Age > 20).AsEnumerable()
|
|
|
+ .Where(p => OlderThan20(p.Age)); // 内存过滤
|
|
|
+```
|
|
|
+- 时代注:EF Core 对表达式翻译更严格,客户端求值默认抛异常;流式读取大量数据可用 `AsAsyncEnumerable()` 配合 `await foreach`。
|
|
|
+
|
|
|
+### 建议30:使用LINQ取代集合中的比较器和迭代器
|
|
|
+
|
|
|
+- **问题**:每种排序需求都要新增 `IComparer<T>`,排序逻辑侵入实体类型。
|
|
|
+- **规则**:用 `orderby`/`OrderBy` 在查询处声明排序键,实体类保持纯数据;多级排序用 `orderby a, b` 或 `ThenBy`。
|
|
|
+- **要点**:
|
|
|
+ - `orderby s.BaseSalary` 编译为 `Enumerable.OrderBy(source, s => s.BaseSalary)`,键选择器是 `Func<TSource,TKey>`。
|
|
|
+ - 无需类型实现 `IComparable`,只要排序键本身可比较;`OrderBy` 内部仍使用 `Comparer<TKey>.Default`。
|
|
|
+ - 排序是延迟执行的,每次枚举重新排序,不修改原集合。
|
|
|
+ - LINQ 可无限增加投影与排序组合,不必为每种需求写新类型。
|
|
|
+- **陷阱**:`OrderBy` 是稳定排序(相等键保持原顺序),而 `List<T>.Sort` 不稳定,替换实现时输出顺序可能变化;`orderby` 不能替代索引器/迭代器的底层实现,LINQ 本身建立在它们之上。
|
|
|
+```csharp
|
|
|
+var byBase = from s in companySalary orderby s.BaseSalary select s;
|
|
|
+var byBonus = companySalary.OrderBy(s => s.Bonus).ThenBy(s => s.Name);
|
|
|
+```
|
|
|
+
|
|
|
+### 建议31:在LINQ查询中避免不必要的迭代
|
|
|
+
|
|
|
+- **问题**:`where` 会遍历完整个集合才返回,而业务往往只需要第一个/前 N 个结果。
|
|
|
+- **规则**:明确只要一条就用 `First`/`FirstOrDefault`,只要前 N 条就用 `Take(n)`,让枚举提前终止。
|
|
|
+- **要点**:
|
|
|
+ - `First` 找到第一个满足条件的元素立即返回,不再 `MoveNext`;无匹配时才遍历全部。
|
|
|
+ - `Take(n)` 取满 n 个即停止;与 `Where` 组合时先过滤后截断。
|
|
|
+ - 原文实测:`where Age==20` + `ToList` 迭代 5 次;`where Age>=20` + `First` 只迭代 1 次;`Take(2)` 迭代 2 次。
|
|
|
+ - 判断“是否存在”用 `Any()` 而非 `Count() > 0`,前者命中即返回。
|
|
|
+- **陷阱**:`First` 无匹配抛 `InvalidOperationException`,可空场景用 `FirstOrDefault`;`Single` 要求唯一,多于一条也抛异常;对延迟查询重复调用 `First`/`Count` 会重复遍历或重复查库。
|
|
|
+```csharp
|
|
|
+var first = list.FirstOrDefault(p => p.Age == 20);
|
|
|
+var top2 = list.Where(p => p.Name == "Mike").Take(2).ToList();
|
|
|
+```
|
|
|
+- 时代注:EF Core 会把 `First`/`Take` 翻译为 `TOP 1`/`LIMIT`,提前终止发生在数据库端;判断存在性用 `AnyAsync()` 可避免取回数据。
|
|
|
+
|
|
|
+## 第 3 章 泛型、委托和事件
|
|
|
+
|
|
|
+### 建议32:总是优先考虑泛型
|
|
|
+
|
|
|
+- **问题**:同一套集合/算法逻辑要支持多种元素类型,非泛型写法要么复制类,要么退化为 `object`,牺牲类型安全与性能。
|
|
|
+- **规则**:设计类型和方法时默认泛型化,只有确实需要用 `object` 承载异质类型时才放弃。
|
|
|
+- **要点**:
|
|
|
+ - 泛型同时提供可重用性、编译期类型安全、无转型开销三点,FCL 2.0 起才有。
|
|
|
+ - 编译后 `T` 只是 IL 中的占位符,JIT 生成原生代码时用实参类型替换;引用类型实参共享同一份 JIT 代码,值类型实参各自生成一份。
|
|
|
+ - `MyList<int>` 与 `MyList<string>` 在运行时是两个封闭类型(各自拥有静态成员与静态构造器)。
|
|
|
+ - `object` 版本写入值类型必装箱、读出必拆箱/强转,且 `list[0]=123; list[1]="123";` 这类错误只能运行时暴露。
|
|
|
+ - LINQ 的查询能力建立在泛型 + 扩展方法之上,避免了为每种元素类型生成一套查询代码。
|
|
|
+- **陷阱**:用 `ArrayList`/`object[]` 求“通用”,编译器不再校验元素类型,错误推迟到运行时 `InvalidCastException`,且值类型反复装箱。
|
|
|
+
|
|
|
+### 建议33:避免在泛型类型中声明静态成员
|
|
|
+
|
|
|
+- **问题**:泛型类型的静态成员到底由谁共享,容易被误读为“整个 `MyList<T>` 共用一份”。
|
|
|
+- **规则**:不要在泛型类型中声明静态字段/属性等有状态成员;需要全局状态时放到非泛型类型或独立缓存类中。
|
|
|
+- **要点**:
|
|
|
+ - 静态成员属于封闭类型:`MyList<int>.Count` 与 `MyList<string>.Count` 互不相干。
|
|
|
+ - 同一 `T` 的多个实例仍共享静态成员,`new MyList<int>()` 两次后 `Count == 2`。
|
|
|
+ - 每个封闭类型各执行一次静态构造器,泛型类型被多个 `T` 实例化时静态初始化会重复发生。
|
|
|
+ - 非泛型类型中的泛型方法不生成新类型,其静态字段被所有 `T` 共享(输出 0、1、2)。
|
|
|
+- **陷阱**:把静态计数器/缓存放进泛型类,以为能统计“所有元素”,实际得到每个 `T` 一份,数据被静默拆分。
|
|
|
+```csharp
|
|
|
+class MyList<T> { public static int Count; public MyList() { Count++; } }
|
|
|
+new MyList<int>(); new MyList<int>(); new MyList<string>();
|
|
|
+// MyList<int>.Count == 2 ; MyList<string>.Count == 1
|
|
|
+```
|
|
|
+
|
|
|
+### 建议34:为泛型参数设定约束
|
|
|
+
|
|
|
+- **问题**:无约束的 `T` 只有 `object` 的成员,方法体内几乎无法对参数做有意义的操作。
|
|
|
+- **规则**:只要方法体需要访问 `T` 的成员,就为 `T` 加最小必要的约束(基类/接口/`new()` 等)。
|
|
|
+- **要点**:
|
|
|
+ - 六类约束:`where T : struct`(非 `Nullable<T>` 的值类型)、`where T : class`(引用类型)、指定基类、指定接口、`where T : new()`、`where T : U`。
|
|
|
+ - 可组合:`where T : class, IDisposable, new()`;`new()` 必须写在最后,且 CLR 只支持无参构造约束。
|
|
|
+ - `object` 不能作为约束;`struct` 与 `class` 互斥。
|
|
|
+ - 约束可自我引用:`where T : IComparable<T>`;约束本身也可以是泛型类型。
|
|
|
+ - 基类约束让 `t1.BaseSalary` 这类成员访问无需强转,且编译期即可验证。
|
|
|
+- **陷阱**:`where T : struct` 不包含 `Nullable<int>`;`struct` 已隐含 `new()`,再写 `new()` 是编译错误(CS0451);用具体基类约束后忘了它同时限定了引用类型。
|
|
|
+
|
|
|
+### 建议35:使用default为泛型类型变量指定初始值
|
|
|
+
|
|
|
+- **问题**:值类型默认是 0/false,引用类型默认是 null,`T` 未约束时 `T t = null` 与 `T t = 0` 都无法编译。
|
|
|
+- **规则**:泛型代码中统一用 `default(T)` 表达“类型默认值”。
|
|
|
+- **要点**:
|
|
|
+ - `default(T)` 由 JIT 按实参类型落地:值类型零初始化,引用类型为 `null`。
|
|
|
+ - FCL 的 `List<T>.Find` 未命中时 `return default(T)`,这是该模式的典型用法。
|
|
|
+ - `default(T)` 也适用于泛型字段初始化、`out`/`ref` 场景。
|
|
|
+ - 对值类型,默认值可能是合法业务值(如 `int` 的 0),不能仅凭它判断“未找到”。
|
|
|
+- **陷阱**:写 `if (result == null)` 判断泛型返回值,`T` 为值类型时编译失败;应改用 `bool TryFind(..., out T value)` 或约束 `where T : class`。
|
|
|
+```csharp
|
|
|
+public T FindFirst<T>(T[] items, Func<T, bool> match)
|
|
|
+{
|
|
|
+ foreach (T item in items) if (match(item)) return item;
|
|
|
+ return default(T); // int→0,string→null
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议36:使用FCL中的委托声明
|
|
|
+
|
|
|
+- **问题**:为每个签名自建 `delegate`,造成大量重复类型,且与 FCL API 不兼容。
|
|
|
+- **规则**:优先用 `Action`、`Func`、`Predicate<T>` 及语义专用委托,确无合适者才自定义。
|
|
|
+- **要点**:
|
|
|
+ - `Action` 共 17 个重载(0~16 个参数),无返回值;`Func` 同为 17 个,最后一个类型参数是返回值。
|
|
|
+ - `Predicate<T>` 等价于 `Func<T,bool>`,专用于条件判断(`List<T>.Find/Exists` 等)。
|
|
|
+ - 语义专用委托:`EventHandler`/`EventHandler<TEventArgs>`、`ThreadStart`/`ParameterizedThreadStart`、`AsyncCallback`、`Converter<in TInput, out TOutput>`。
|
|
|
+ - 参数超过 16 个应先怀疑设计;确需多参可用 `params int[]` 收敛签名。
|
|
|
+ - 自定义委托仅当需要语义化命名或特殊签名(如 `ref`/`out` 参数)时才有价值。
|
|
|
+- **陷阱**:签名相同的两个委托类型不能互相赋值(`Func<int,int,int>` 与自定义 `AddHandler` 不兼容),自建委托会让 API 与 Lambda/方法组难以对接。
|
|
|
+```csharp
|
|
|
+Func<int, int, int> add = (i, j) => i + j;
|
|
|
+Action<string> print = Console.WriteLine;
|
|
|
+print(add(1, 2).ToString());
|
|
|
+```
|
|
|
+
|
|
|
+### 建议37:使用Lambda表达式代替方法和匿名方法
|
|
|
+
|
|
|
+- **问题**:一两行的简单逻辑被写成独立私有方法或冗长的匿名方法,代码分散且噪音大。
|
|
|
+- **规则**:不复用且不超过约 3 行的方法体,直接写成 Lambda;只有需要复用或调试命名时才提取为方法。
|
|
|
+- **要点**:
|
|
|
+ - 委托是类型:`new Func<int,int,int>(Add)` 与方法组转换 `Func<int,int,int> add = Add;` 等价,前者更能看清“委托是类”。
|
|
|
+ - 匿名方法 `delegate(int i, int j) { return i + j; }` 可内联,但仍需写参数类型和 `delegate` 关键字。
|
|
|
+ - Lambda `(i, j) => i + j` 单表达式可省略 `return` 与花括号;语句体用 `{ }`。
|
|
|
+ - 编译后 Lambda 仍是编译器生成的匿名方法(可能缓存为静态字段,捕获变量时生成闭包类)。
|
|
|
+ - `List<T>.Find` 可从 `new Predicate<Student>(delegate(Student t){...})` 一路简化到 `Find(t => t.Name == name)`。
|
|
|
+- **陷阱**:Lambda 捕获外部变量会生成闭包并延长变量生命周期(见建议38);赋给 `Expression<Func<...>>` 时是表达式树,语句体不被支持。
|
|
|
+
|
|
|
+### 建议38:小心闭包中的陷阱
|
|
|
+
|
|
|
+- **问题**:循环中创建多个 Lambda 并捕获循环变量,全部输出同一个最终值。
|
|
|
+- **规则**:循环体内先用局部变量复制当前值,再让 Lambda 捕获该局部变量;或把 Lambda 的创建移入独立方法。
|
|
|
+- **要点**:
|
|
|
+ - 编译器把被捕获的局部变量提升为显示类(旧版名 `<>c__DisplayClass`)的字段,所有委托共享同一实例。
|
|
|
+ - `for` 的 `i` 只有一个变量,循环结束后为 5,故 5 个委托都打印 5。
|
|
|
+ - 捕获的是“变量”而非“当时的值”,这是闭包语义而非编译器 bug。
|
|
|
+ - 修复:循环体内 `int temp = i;` 使每次迭代生成新的闭包对象。
|
|
|
+ - 事件订阅、任务队列、延迟执行(LINQ 的延迟求值)中同样会出现。
|
|
|
+- **陷阱**:多线程下共享闭包变量还会引入数据竞争;把 `i` 声明在循环外与声明在 `for` 头部行为一致。
|
|
|
+```csharp
|
|
|
+for (int i = 0; i < 5; i++)
|
|
|
+{
|
|
|
+ int temp = i; // 每次迭代新建闭包对象
|
|
|
+ lists.Add(() => Console.WriteLine(temp));
|
|
|
+}
|
|
|
+```
|
|
|
+时代注:C# 5 起 `foreach` 的迭代变量改为每次迭代新建,`foreach` 不再有此坑;`for` 循环变量仍是共享的一个,行为至今不变。
|
|
|
+
|
|
|
+### 建议39:了解委托的实质
|
|
|
+
|
|
|
+- **问题**:只把委托当“函数指针”用,不理解它是类、不可变、可多播,导致对 `+=`、判空、调用链行为的误判。
|
|
|
+- **规则**:按“方法指针 + 继承 `MulticastDelegate` 的类”两层模型理解委托;对外只暴露必要操作。
|
|
|
+- **要点**:
|
|
|
+ - `delegate void FileUploadedHandler(int progress);` 被编译为一个继承 `System.MulticastDelegate` 的类,含 `.ctor(object, IntPtr)`、`Invoke`、`BeginInvoke`、`EndInvoke`。
|
|
|
+ - 实例内部保存目标对象与方法地址;静态方法的目标对象为 `null`。
|
|
|
+ - `FileUploaded(10)` 编译为 `FileUploaded.Invoke(10)`(IL 中为 `callvirt ...::Invoke`)。
|
|
|
+ - 委托不可变:`+=` 走 `Delegate.Combine` 生成新委托,`-=` 走 `Delegate.Remove`。
|
|
|
+ - 多播委托按添加顺序同步调用全部目标,有返回值时只取最后一个的结果。
|
|
|
+- **陷阱**:直接调用可能为 `null` 的委托字段抛 `NullReferenceException`;`-=` 一个未订阅的方法不会报错但也不生效。
|
|
|
+时代注:`BeginInvoke`/`EndInvoke` 在 .NET Core/.NET 5+ 抛 `PlatformNotSupportedException`,异步调用应改用 `Task.Run`。
|
|
|
+
|
|
|
+### 建议40:使用event关键字为委托施加保护
|
|
|
+
|
|
|
+- **问题**:把委托声明为 `public` 字段,外部代码可把它置空、整体替换,甚至代替发布者触发通知。
|
|
|
+- **规则**:对外暴露的通知点一律用 `public event`,只有声明类型内部才能赋值、置空和调用。
|
|
|
+- **要点**:
|
|
|
+ - `event` 本质是“委托字段 + `add`/`remove` 访问器”,用法类似属性。
|
|
|
+ - 外部只允许 `+=` / `-=`;`= null`、`= 方法`、`FileUploaded(10)` 均编译失败(CS0070)。
|
|
|
+ - 触发时机由声明类型掌握,符合“何时通知是发布者职责”的封装原则。
|
|
|
+ - 类型内部仍可直接赋值、判空、调用,因此触发前必须判空(C# 6 起可 `FileUploaded?.Invoke(...)`)。
|
|
|
+ - 多线程场景先把事件复制到局部变量再判空调用,避免判空与调用之间被退订。
|
|
|
+- **陷阱**:事件订阅只加不减,长生命周期的发布者会一直持有订阅者引用,造成内存泄漏;`event` 也不能阻止外部反复订阅同一方法(会重复触发)。
|
|
|
+```csharp
|
|
|
+public event FileUploadedHandler FileUploaded; // 外部 fl.FileUploaded = null; → CS0070
|
|
|
+```
|
|
|
+
|
|
|
+### 建议41:实现标准的事件模型
|
|
|
+
|
|
|
+- **问题**:自定义 `delegate void Xxx(int progress)` 式事件能跑,但不符合 .NET 约定,无法与通用事件基础设施对接。
|
|
|
+- **规则**:遵循 `EventHandler` 模式:`void` 返回、两个参数 `(object sender, EventArgs e)`、参数类继承 `EventArgs`。
|
|
|
+- **要点**:
|
|
|
+ - 命名规范:委托类型以 `EventHandler` 结尾,事件参数类以 `EventArgs` 结尾。
|
|
|
+ - 优先用泛型 `EventHandler<TEventArgs>`,无需再声明自定义委托。
|
|
|
+ - `sender` 传 `this`(触发者),业务数据全部放进 `e`。
|
|
|
+ - 订阅方签名必须完全匹配:`static void Progress(object sender, FileUploadedEventArgs e)`。
|
|
|
+ - 触发前判空;事件参数对象可复用,但不要把可变状态长期挂在 `e` 上。
|
|
|
+- **陷阱**:用非泛型 `EventHandler` 接收自定义参数,订阅方被迫强转 `e as FileUploadedEventArgs`;委托返回非 `void` 则根本不是标准事件模型;`sender` 传 `null` 破坏约定。
|
|
|
+```csharp
|
|
|
+class FileUploadedEventArgs : EventArgs { public int FileProgress { get; set; } }
|
|
|
+public event EventHandler<FileUploadedEventArgs> FileUploaded;
|
|
|
+// 触发: if (FileUploaded != null) FileUploaded(this, e);
|
|
|
+```
|
|
|
+
|
|
|
+### 建议42:使用泛型参数兼容泛型接口的不可变性
|
|
|
+
|
|
|
+- **问题**:`ISalary<Programmer>` 不能传给参数为 `ISalary<Employee>` 的方法,即使 `Programmer : Employee`。
|
|
|
+- **规则**:接口未声明变体时,把接收方法改成泛型方法 `PrintSalary<T>(ISalary<T> s)`,用类型参数承接具体类型。
|
|
|
+- **要点**:
|
|
|
+ - 泛型接口/委托默认不变(invariant),编译器对类型实参的检查非常严格。
|
|
|
+ - 类型参数只出现在返回值/输出位置时才具备协变资格;一旦作为方法输入参数就不再成立。
|
|
|
+ - 泛型方法让调用方各自闭合 `T`,从而绕开“接口类型必须完全相同”的限制。
|
|
|
+ - 这是 C# 4.0 之前没有声明式变体时的通用手法,今天仍适用于无法修改接口定义的场景。
|
|
|
+- **陷阱**:给含输入参数的接口硬加 `out` 会报“方差无效”;值类型不参与变体,`ISalary<int>` 与 `ISalary<object>` 无关。
|
|
|
+```csharp
|
|
|
+interface ISalary<T> { void Pay(); }
|
|
|
+static void PrintSalary<T>(ISalary<T> s) { s.Pay(); }
|
|
|
+PrintSalary(new BaseSalaryCounter<Programmer>());
|
|
|
+```
|
|
|
+
|
|
|
+### 建议43:让接口中的泛型参数支持协变
|
|
|
+
|
|
|
+- **问题**:自定义泛型接口默认不变,限制了“把派生程度更大的类型当基类用”的场景。
|
|
|
+- **规则**:若 `T` 只出现在输出位置(返回值、只读属性、`out` 参数),声明为 `interface IX<out T>`。
|
|
|
+- **要点**:
|
|
|
+ - `out` 是 C# 4.0 新增,只能用于泛型接口和泛型委托的类型参数。
|
|
|
+ - 协变方向:`ISalary<Programmer>` 可赋给 `ISalary<Employee>`(实参派生程度更大)。
|
|
|
+ - 判定标准:`T` 出现在输入位置(方法参数、可写属性)就不能标 `out`,编译器报 CS1961。
|
|
|
+ - FCL 4.0 已改造:`IEnumerable<out T>`、`IEnumerator<out T>`、`IQueryable<out T>`;`IList<T>` 因有 `Add(T)` 仍不变。
|
|
|
+ - 自写接口只要不涉及输入位置就应加 `out`,几乎无副作用且扩大适用范围。
|
|
|
+- **陷阱**:变体只对引用类型生效,`IEnumerable<int>` 不能当 `IEnumerable<object>`;给含 `void Add(T item)` 的接口加 `out` 直接编译失败。
|
|
|
+```csharp
|
|
|
+interface ISalary<out T> { void Pay(); } // T 仅在输出位置
|
|
|
+ISalary<Programmer> s = new BaseSalaryCounter<Programmer>();
|
|
|
+ISalary<Employee> e = s; // 协变
|
|
|
+```
|
|
|
+时代注:书中“FCL 4.0”即 .NET 4.0,此后 `out`/`in` 变体一直是标准能力。
|
|
|
+
|
|
|
+### 建议44:理解委托中的协变
|
|
|
+
|
|
|
+- **问题**:方法组能自然赋给“返回值更宽”的委托,但委托实例之间做同样赋值却编译失败,容易误以为委托泛型参数天生协变。
|
|
|
+- **规则**:需要委托间协变时,为委托的返回值类型参数声明 `out`。
|
|
|
+- **要点**:
|
|
|
+ - 方法组转换允许返回值协变:返回 `Manager` 的方法可赋给 `GetEmployeeHandler<Employee>` 变量。
|
|
|
+ - 委托实例之间是严格不变的:`GetEmployeeHandler<Manager>` 不能赋给 `GetEmployeeHandler<Employee>`。
|
|
|
+ - 声明 `delegate T GetEmployeeHandler<out T>(string name);` 后即可赋值。
|
|
|
+ - `out` 只允许 `T` 出现在返回值位置,作参数则编译失败。
|
|
|
+ - FCL 示例:`Func<out TResult>`、`Converter<in TInput, out TOutput>`。
|
|
|
+- **陷阱**:因为方法组能赋值就省略 `out`,一旦在 API 之间传递委托变量便暴露编译错误;协变仅对引用类型有效。
|
|
|
+
|
|
|
+### 建议45:为泛型类型参数指定逆变
|
|
|
+
|
|
|
+- **问题**:比较器/处理器这类“只消费 `T`”的泛型接口,若不声明逆变,`IComparer<Employee>` 无法用于 `IComparer<Programmer>`。
|
|
|
+- **规则**:`T` 只出现在输入位置时,为接口或委托的类型参数声明 `in`。
|
|
|
+- **要点**:
|
|
|
+ - 逆变方向与协变相反:`IComparer<Employee>` 可赋给 `IComparer<Programmer>`,即参数位置接受基类。
|
|
|
+ - FCL 常用逆变声明:`Func<in T, out TResult>`、`Predicate<in T>`、`IComparer<in T>`、`Action<in T>`。
|
|
|
+ - 同一泛型可对不同类型参数分别使用 `in`/`out`(如 `Converter<in TInput, out TOutput>`)。
|
|
|
+ - 逆变让 `Test<T>(IMyComparable<T> t1, T t2)` 这类方法能同时接收 `Programmer` 与 `Manager`。
|
|
|
+ - `in` 同样是 C# 4.0 能力,只适用于泛型接口与委托。
|
|
|
+- **陷阱**:`T` 既作参数又作返回值却标 `in`,编译报错;逆变不适用于值类型;数组协变是运行时检查的另一套机制,赋值成功仍可能抛 `ArrayTypeMismatchException`。
|
|
|
+```csharp
|
|
|
+IComparer<object> byName = Comparer<object>.Create((a, b) => 0);
|
|
|
+IComparer<string> cmp = byName; // 逆变:参数位置接受基类
|
|
|
+```
|
|
|
+
|
|
|
+## 第 4 章 资源管理和序列化
|
|
|
+
|
|
|
+### 建议46:显式释放资源需继承接口IDisposable
|
|
|
+
|
|
|
+- **问题**:类型持有非托管资源、或需要显式释放托管资源时,如何向调用者声明“用完必须清理”。
|
|
|
+- **规则**:只要类型需要显式释放资源,就必须实现 `IDisposable`,并实现标准 Dispose 模式(`Dispose()` + `Dispose(bool)` + 终结器 + `disposed` 标志),别无例外。
|
|
|
+- **要点**:
|
|
|
+ - 资源分两类:托管资源(CLR `new` 出来的对象)、非托管资源(内核对象、文件、数据库连接、套接字、COM 对象、`IntPtr`)。
|
|
|
+ - `using` 是语法糖,编译为 `try { ... } finally { x.Dispose(); }`,与手写 try-finally 的 IL 完全一致。
|
|
|
+ - `using` 可声明同类型的多个变量:`using (A a = new A(), b = new A())`;类型不同则嵌套写多个 `using`。
|
|
|
+ - 额外提供 `Close()` 只是照顾 C++ 习惯,内部仍调用 `Dispose()`。
|
|
|
+ - `Dispose(bool)` 在非密封类为 `protected virtual`,密封类为 `private`。
|
|
|
+- **陷阱**:`using` 要求类型实现 `IDisposable`(或 `IAsyncDisposable`),对普通类型无效;`Dispose()` 里忘了 `GC.SuppressFinalize(this)` 会让对象白走一次终结器。
|
|
|
+
|
|
|
+```csharp
|
|
|
+using (var fs = new FileStream("c:\\test.txt", FileMode.Open))
|
|
|
+{
|
|
|
+ // 离开作用域必定调用 fs.Dispose()
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议47:即使提供了显式释放方法,也应该在终结器中提供隐式清理
|
|
|
+
|
|
|
+- **问题**:调用者可能忘记调用 `Dispose`,资源如何兜底释放。
|
|
|
+- **规则**:实现 Dispose 模式的类型应提供终结器 `~T()`,内部调用 `Dispose(false)`;`Dispose()` 中调用 `Dispose(true)` 后必须 `GC.SuppressFinalize(this)`。
|
|
|
+- **要点**:
|
|
|
+ - 含终结器的对象在 `new` 时会被登记进终结列表;GC 先把指针移入 `freachable` 队列,由专用线程调用终结器,之后对象才真正可回收。
|
|
|
+ - 因此含终结器的对象至少要经历两次 GC 才能释放内存,回收成本明显高于普通对象。
|
|
|
+ - 终结器只是“补救措施”,不是释放时机保证:它运行在 GC 线程,时机不确定、顺序不确定。
|
|
|
+ - `GC.SuppressFinalize` 避免已显式释放的对象再进终结队列,省掉一次 GC 周期。
|
|
|
+ - 名称统一叫“终结器”(finalizer),不叫“析构器”(C++ 叫法,机制不同)。
|
|
|
+- **陷阱**:终结器中访问的其他托管对象可能已被回收,只能碰非托管资源;终结器抛出的异常会直接终止进程,无法被 catch。
|
|
|
+
|
|
|
+### 建议48:Dispose方法应允许被多次调用
|
|
|
+
|
|
|
+- **问题**:调用方可能重复 `Dispose`(如 `using` 与手动调用并存),不能因此抛异常。
|
|
|
+- **规则**:类型内部维护 `private bool disposed`,清理方法开头 `if (disposed) return;`,末尾置 `disposed = true`;重复调用变为空操作。
|
|
|
+- **要点**:
|
|
|
+ - `Dispose` 过后对象引用仍可能存在,但逻辑状态已失效,公开成员应先检查 `disposed`。
|
|
|
+ - 失效后调用公开方法应抛 `ObjectDisposedException`,构造参数为对象名与提示消息。
|
|
|
+ - 清理后把托管字段置 `null`、把 `IntPtr` 置 `IntPtr.Zero`,既防重复释放也断开引用。
|
|
|
+ - 幂等性是 Dispose 模式的契约,`Stream`、`SqlConnection` 等 FCL 类型均如此。
|
|
|
+- **陷阱**:只在 `Dispose()` 里判重、却没在 `Dispose(bool)` 里判重,会导致终结器与显式释放重复释放非托管内存(双击释放)。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public void SamplePublicMethod()
|
|
|
+{
|
|
|
+ if (disposed)
|
|
|
+ throw new ObjectDisposedException("SampleClass", "SampleClass is disposed");
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议49:在Dispose模式中应提取一个受保护的虚方法
|
|
|
+
|
|
|
+- **问题**:类型被继承时,子类容易漏掉父类的清理逻辑。
|
|
|
+- **规则**:把真正的清理代码放进 `protected virtual void Dispose(bool disposing)`,子类重写它并在自己的清理完成后调用 `base.Dispose(disposing)`。
|
|
|
+- **要点**:
|
|
|
+ - `IDisposable.Dispose()` 本身不写清理逻辑,只负责 `Dispose(true)` + `GC.SuppressFinalize(this)`。
|
|
|
+ - 父类的 `Dispose(bool)` 是虚方法,子类用 `override`,不能再声明同名 `virtual`(会隐藏而非重写)。
|
|
|
+ - 子类应维护自己的 `derivedDisposed` 标志,并在重写方法里判重。
|
|
|
+ - 密封类的 `Dispose(bool)` 用 `private`(或 `private protected`),非密封用 `protected virtual`。
|
|
|
+- **陷阱**:子类构造函数抛异常时终结器仍会运行,此时子类字段可能尚未初始化,`Dispose(false)` 必须能容忍 `null` 字段;子类若忘记 `base.Dispose(disposing)`,父类的非托管资源永久泄漏。
|
|
|
+
|
|
|
+### 建议50:在Dispose模式中应区别对待托管资源和非托管资源
|
|
|
+
|
|
|
+- **问题**:终结器与显式 Dispose 的清理范围不同,混在一起会访问已回收对象。
|
|
|
+- **规则**:`Dispose()` 传 `true`,托管与非托管资源都清理;终结器传 `false`,只清理本类型直接持有的非托管资源。
|
|
|
+- **要点**:
|
|
|
+ - 判定依据:普通类型(未实现 `IDisposable`)的托管对象无需手工清理;非普通类型才需要调用其 `Dispose`。
|
|
|
+ - 设计假设:调用者显式 Dispose 时,类型按部就班释放全部资源;调用者忘记时,假定所有托管资源都能交给 GC 回收,故不手工碰它们。
|
|
|
+ - 终结器运行时,它引用的托管对象可能已被判定为垃圾,调用其 `Dispose` 会访问无效状态。
|
|
|
+ - `disposed = true` 应放在 `Dispose(bool)` 末尾,两条路径都置位,保证幂等。
|
|
|
+- **陷阱**:在 `Dispose(false)` 中写 `managedResource.Dispose()`,是终结器阶段最常见的崩溃与“对象复活”来源;反过来在 `Dispose(true)` 中漏掉非托管释放,则显式释放也泄漏。
|
|
|
+
|
|
|
+### 建议51:具有可释放字段的类型或拥有本机资源的类型应该是可释放的
|
|
|
+
|
|
|
+- **问题**:类型自身没有 `IntPtr`,但是否也需要实现 `IDisposable`。
|
|
|
+- **规则**:两类类型必须实现 `IDisposable`:直接持有本机(非托管)资源的;字段中包含实现了 `IDisposable` 的成员的。
|
|
|
+- **要点**:
|
|
|
+ - 组合了可释放字段,就必须在自己的 `Dispose(true)` 分支里调用该字段的 `Dispose()` 并置 `null`。
|
|
|
+ - 这是“所有权传递”:谁创建/持有可释放对象,谁负责释放;容器类型把责任向上传递。
|
|
|
+ - 即使字段是“纯托管”的,只要它实现了 `IDisposable`,容器也必须可释放。
|
|
|
+ - 判断标准与字段类型是否 `sealed`、是否有终结器无关,只看它是否实现了 `IDisposable`。
|
|
|
+- **陷阱**:只释放自己 new 的字段,却把外部注入(构造函数传入)的可释放对象也 Dispose 掉,属于所有权误判;反之注入了却不释放则泄漏。需要明确约定所有权。
|
|
|
+
|
|
|
+### 建议52:及时释放资源
|
|
|
+
|
|
|
+- **问题**:既然 GC 会自动回收,为什么还要主动 Dispose。
|
|
|
+- **规则**:确定性释放优于等待 GC;凡是实现 `IDisposable` 的局部对象,一律用 `using`(或 try-finally)包裹。
|
|
|
+- **要点**:
|
|
|
+ - GC 只在三种情况启动:物理内存低、托管堆分配超出可接受范围、显式 `GC.Collect()`;时机不可预期。
|
|
|
+ - 分代回收:0 代放短期对象,0 代满才回收;局部变量退出方法后并不立即释放。
|
|
|
+ - 未及时释放的直接后果是资源被独占:示例中连续两次打开同一文件抛 `IOException`(文件正由另一进程使用)。
|
|
|
+ - 含终结器的类型还要多等一轮 GC,占用时间更长。
|
|
|
+ - `fileStream.Dispose()` 裸调用不安全,构造抛异常时不会执行;必须 try-finally 或 `using`。
|
|
|
+- **陷阱**:`GC.Collect()` 不是解决方案,频繁强制回收会打乱分代假设、严重损伤性能;它只能“证明”问题,不能修复问题。
|
|
|
+
|
|
|
+### 建议53:必要时应将不再使用的对象引用赋值为null
|
|
|
+
|
|
|
+- **问题**:把引用置 `null` 到底能不能帮助 GC。
|
|
|
+- **规则**:局部变量、方法参数置 `null` 无意义(Release 下会被编译器删除);静态字段是永久根,大对象用完后应显式置 `null`。
|
|
|
+- **要点**:
|
|
|
+ - 根(root)包括静态字段、方法参数、局部变量(仅引用类型;值类型不能作根)。GC 沿线程栈上行扫描根,再扫描所有引用类型的静态字段。
|
|
|
+ - 局部变量在方法结束前就已可能不被任何根引用,赋不赋 `null` 结果一样;JIT 优化后 `s = null;` 在 Release 版 IL 中根本不存在。
|
|
|
+ - 静态字段一旦创建,其根一直存在,GC 永远认为它可达,因此必须手动 `asc = null`。
|
|
|
+ - 置 `null` 后要回收需两次 GC:第一次只跑终结器,第二次才真正回收内存。
|
|
|
+ - 结论延伸:尽量少用静态变量,尤其是占用大的静态引用。
|
|
|
+- **陷阱**:把“置 null”当成万能优化,在热路径里到处写,只增加噪音;真正的泄漏源通常是静态集合、事件订阅(`event` 持有 target 引用)、缓存无上限。
|
|
|
+
|
|
|
+### 建议54:为无用字段标注不可序列化
|
|
|
+
|
|
|
+- **问题**:`[Serializable]` 默认序列化全部字段,导致体积膨胀、无意义数据甚至异常。
|
|
|
+- **规则**:不需要保持状态的字段加 `[NonSerialized]`;事件与自动属性用 `[field:NonSerialized]`。
|
|
|
+- **要点**:
|
|
|
+ - 四类理由:节省空间;反序列化后无意义(内核句柄);业务禁止(明文密码);字段类型本身不可序列化(否则抛 `SerializationException`)。
|
|
|
+ - `[NonSerialized]` 只能作用于字段,不能作用于属性(属性本质是方法);因此要排除某属性,必须手写 backing field 并标注。
|
|
|
+ - 事件编译为委托字段,需 `[field:NonSerialized]` 才能作用到编译器生成的字段上。
|
|
|
+ - 事件订阅者属于运行时环境,跨环境反序列化没有意义,通常必须排除。
|
|
|
+ - 反序列化后 `[NonSerialized]` 字段为默认值(`null`/`0`),需要时在 `OnDeserialized` 中重建。
|
|
|
+- **陷阱**:`[NonSerialized]` 拼写易与 `[NonSerializable]` 混淆(后者不存在);漏标一个不可序列化类型的字段,序列化时才在运行期炸,编译期无提示。
|
|
|
+- 时代注:`BinaryFormatter` 自 .NET 5 起标记过时(SYSLIB0011),.NET 9 起已从实现中移除;新代码用 `System.Text.Json`/`MessagePack` 等,`[Serializable]`/`[NonSerialized]` 语义不再适用。
|
|
|
+
|
|
|
+### 建议55:利用定制特性减少可序列化的字段
|
|
|
+
|
|
|
+- **问题**:某些字段可由其他字段推导,不必存入流中。
|
|
|
+- **规则**:给推导字段标 `[NonSerialized]`,再用 `[OnDeserialized]` 标注一个回调方法,在反序列化完成后重新计算它。
|
|
|
+- **要点**:
|
|
|
+ - 四个序列化回调特性:`OnSerializing`(序列化前)、`OnSerialized`(序列化后)、`OnDeserializing`(反序列化前)、`OnDeserialized`(反序列化后),均在 `System.Runtime.Serialization`。
|
|
|
+ - 回调方法签名固定为 `void M(StreamingContext context)`,可以是 `private`,靠特性反射调用。
|
|
|
+ - 示例:`ChineseName` 由 `FirstName`、`LastName` 推导,序列化时省略,反序列化后拼回,输出仍为 `Lu Minji`。
|
|
|
+ - 好处是流更小且推导逻辑只有一处,避免两处数据不一致。
|
|
|
+- **陷阱**:原文示例中方法名叫 `OnSerialized` 却标了 `[OnDeserializedAttribute]`,名字与语义不符易误导;回调方法名任意,起决定作用的是特性。用错特性(如标 `OnSerializing`)会在字段尚未赋值时计算,得到错误结果。
|
|
|
+
|
|
|
+```csharp
|
|
|
+[Serializable]
|
|
|
+class Person
|
|
|
+{
|
|
|
+ public string FirstName;
|
|
|
+ public string LastName;
|
|
|
+ [NonSerialized] public string ChineseName;
|
|
|
+
|
|
|
+ [OnDeserialized]
|
|
|
+ void Rebuild(StreamingContext context)
|
|
|
+ => ChineseName = string.Format("{0}{1}", LastName, FirstName);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议56:使用继承ISerializable接口更灵活地控制序列化过程
|
|
|
+
|
|
|
+- **问题**:`[Serializable]` 加四个回调特性仍不够时,如何完全接管序列化/反序列化。
|
|
|
+- **规则**:实现 `ISerializable.GetObjectData(SerializationInfo, StreamingContext)` 写入字段,并提供 `protected T(SerializationInfo, StreamingContext)` 构造器读回字段。
|
|
|
+- **要点**:
|
|
|
+ - 格式化器一旦发现类型实现 `ISerializable`,会忽略所有序列化特性,只调用 `GetObjectData` 构造 `SerializationInfo`。
|
|
|
+ - 反序列化走受保护构造器;接口本身没有约束这个构造器,但缺了它流就无法还原(除非只序列化不反序列化)。
|
|
|
+ - `info.AddValue(name, value)` 写入,`info.GetString/GetInt32/...` 读取;读取顺序无关,按名字匹配。
|
|
|
+ - `info.SetType(typeof(PersonAnother))` 可让流在反序列化时还原成另一种类型——这是特性方案做不到的能力。
|
|
|
+ - 该能力可用于版本升级时迁移旧类型/旧字段布局。
|
|
|
+- **陷阱**:`GetObjectData` 中漏写某字段不会报错,只是静默丢失;读取时名字拼错抛 `SerializationException`;子类未处理父类字段见建议57。
|
|
|
+- 时代注:`ISerializable` 与序列化构造器在 .NET 8 起标记过时(SYSLIB0050),`BinaryFormatter` 已移除,此机制仅适用于旧有 `BinaryFormatter`/`SoapFormatter`/Remoting 场景。
|
|
|
+
|
|
|
+### 建议57:实现ISerializable的子类型应负责父类的序列化
|
|
|
+
|
|
|
+- **问题**:父类未实现序列化,子类实现 `ISerializable` 时,父类字段会丢失。
|
|
|
+- **规则**:子类的 `GetObjectData` 要显式 `AddValue` 父类字段,反序列化构造器要显式读回;若父类也实现了 `ISerializable`,则调用 `base.GetObjectData` 与 `base(info, context)`。
|
|
|
+- **要点**:
|
|
|
+ - 序列化器不会自动遍历父类字段,只处理子类 `GetObjectData` 中显式添加的内容。
|
|
|
+ - 反例输出“姓名:”(空)、“薪水:1000”,说明 `Name` 未被写入流。
|
|
|
+ - 父类实现 `ISerializable` 时,父类的 `GetObjectData` 必须为 `virtual`/可重写,子类 `override` 后先调 `base`。
|
|
|
+ - 父类反序列化构造器为 `protected`,子类构造器用 `: base(info, context)` 链式调用。
|
|
|
+ - 顺序:先 `base.GetObjectData`,再写子类字段;读取同理先 base 构造器再读子类字段。
|
|
|
+- **陷阱**:父类字段是 `private` 时子类无法直接读写,只能让父类暴露 `protected` 成员或让父类也实现 `ISerializable`;漏掉父类字段通常不抛异常,只是静默得到默认值,最难排查。
|
|
|
+
|
|
|
+## 第 5 章 异常与自定义异常
|
|
|
+
|
|
|
+### 建议58:用抛出异常代替返回错误代码
|
|
|
+
|
|
|
+- **问题**:失败原因多、需要差异化处理时,错误代码/`ref string` 方案不可维护。
|
|
|
+- **规则**:用异常类型表达失败类别,让调用者按 `catch` 的具体类型分别处理;不要把失败原因塞进返回值或 out 参数。
|
|
|
+- **要点**:
|
|
|
+ - 错误代码会随细节膨胀(无写权限、磁盘满、服务不存在、网络不通……),且调用者必须查表翻译。
|
|
|
+ - `bool + ref string errorMsg` 只能传递文案,无法让上层区分“该通知谁、该做什么”。
|
|
|
+ - 异常机制让不同失败走不同 `catch` 块:`IOException` 提示当前用户、`UnauthorizedAccessException` 通知客户机管理员、`CommunicationException` 通知发送邮件给网络管理员。
|
|
|
+ - 构造函数、属性、操作符重载没有返回值,天然只能用异常报错。
|
|
|
+ - 分布式场景下 throw 的开销相对网络等待可忽略。
|
|
|
+- **陷阱**:`catch`/`finally` 内不要再做易失败的动作(如真的发邮件),应只做“通知发送”这类稳定操作,否则堆栈被二次异常污染;捕获后不处理也不上抛,等于把错误代码方案的最坏处(静默失败)也继承了下来。
|
|
|
+
|
|
|
+```csharp
|
|
|
+try { SaveUser(user); }
|
|
|
+catch (UnauthorizedAccessException) { NotifyLocalAdmin(); }
|
|
|
+catch (CommunicationException) { QueueAdminMail(); } // 只入队,不真发
|
|
|
+```
|
|
|
+
|
|
|
+### 建议59:不要在不恰当的场合下引发异常
|
|
|
+
|
|
|
+- **问题**:把正常业务校验(如年龄为负)当成异常抛出,滥用异常。
|
|
|
+- **规则**:可控范围内(发生后资源仍可用、状态可恢复)的输入输出错误用 Tester-Doer 校验,不抛异常;只在会造成资源泄漏/状态不可恢复、需要包装更有用信息、或底层异常在高层语境下无意义时抛。
|
|
|
+- **要点**:
|
|
|
+ - Tester-Doer:先提供 `bool CheckAge(int age, ref string msg)` 之类的检查方法,调用方判断通过后再执行 Doer。
|
|
|
+ - 两条首要原则:正常业务流程不用异常;不要到处 catch/throw,允许异常沿调用栈上浮。
|
|
|
+ - “可控”的判定:异常发生后系统资源仍可用,或资源状态可恢复。
|
|
|
+ - `Console` 对参数抛 `ArgumentOutOfRangeException` 是合理的:参数错误可能导致句柄占用、内存泄漏等系统级后果,且无法假设调用者了解调用顺序。
|
|
|
+ - 把 `InvalidCastException` 转成 `ArgumentException("输入参数不是一个User", "o")`,让异常在高层语境下有意义。
|
|
|
+ - 调用 Win32/第三方 API 拿到错误码(`Marshal.GetLastWin32Error()`)后,应转抛 .NET 异常。
|
|
|
+- **陷阱**:只记“不要对输入抛异常”而忽略“可控”前提,会在类库中放过真正危险的参数;`ArgumentOutOfRangeException("Age不能为负数。")` 这种写法还把消息当成了参数名,参数名应为 `"age"`。
|
|
|
+
|
|
|
+### 建议60:重新引发异常时使用Inner Exception
|
|
|
+
|
|
|
+- **问题**:包装底层异常后,原始异常信息与堆栈丢失,无法定位根因。
|
|
|
+- **规则**:`throw new XxxException("面向用户的消息", originalException);`,把原异常放进 `InnerException`;日志中记录完整异常链。
|
|
|
+- **要点**:
|
|
|
+ - `Exception.InnerException` 保留原始异常对象,`ToString()` 会输出整条链与堆栈。
|
|
|
+ - 典型场景:`SocketException`(目标积极拒绝/连接超时/无响应)统一包装为 `CommunicationFailureException("网络连接失败,请稍后再试", err)`。
|
|
|
+ - 不想用 InnerException 时,可用 `Exception.Data`(`IDictionary`)附加自定义键值,如 `err.Data.Add("SocketInfo", ...)`,上层用 `err.Data["SocketInfo"]` 读取。
|
|
|
+ - `Data` 适合附加诊断上下文,不替代异常链。
|
|
|
+- **陷阱**:原文示例在 `catch` 中用 `throw err;` 重抛,会重置堆栈(见建议62),应改为 `throw;`;`Data.Add` 遇到重复键会抛 `ArgumentException`,用索引器赋值更安全。
|
|
|
+
|
|
|
+```csharp
|
|
|
+catch (SocketException err)
|
|
|
+{
|
|
|
+ throw new CommunicationFailureException("网络连接失败,请稍后再试", err);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议61:避免在finally内撰写无效代码
|
|
|
+
|
|
|
+- **问题**:误以为 `finally` 中修改局部变量能改变已经确定的返回值。
|
|
|
+- **规则**:`try` 中 `return` 时返回值已存入编译器生成的隐藏局部变量(值类型为副本),`finally` 里的赋值无效,不要写。
|
|
|
+- **要点**:
|
|
|
+ - `return i = 1;` 等价于先把 1 存入隐藏变量 `CS$1$0000`,再 `leave` 到 finally;方法最终返回该隐藏变量,故返回 1 而非 2。
|
|
|
+ - 若 `return` 在 try 之后(`TestIntReturnBelowFinally`),finally 先改 `i`,再 `return i`,返回 2。
|
|
|
+ - 返回引用类型时,隐藏变量保存的是引用副本,与 `user` 指向同一对象,因此 finally 中改 `user.Name = "Rose"` 会影响返回值;把 `user = null` 只改副本,不影响返回对象。
|
|
|
+ - Release 编译会删除 `finally` 中对返回值无影响的赋值(IL 中不存在),Debug 下存在但同样无效。
|
|
|
+ - 打破 try-finally 的唯一情形是进程直接终止:如 native 代码空指针写入触发 `AccessViolationException`,catch 与 finally 都不执行。
|
|
|
+- **陷阱**:把“finally 一定执行”绝对化——`Environment.FailFast`、栈溢出、`StackOverflowException`、进程被 kill、损坏状态异常都会跳过;依赖 finally 做关键持久化不可靠。
|
|
|
+
|
|
|
+```csharp
|
|
|
+static int TestIntReturnInTry()
|
|
|
+{
|
|
|
+ int i;
|
|
|
+ try { return i = 1; } // 返回值已复制到隐藏变量
|
|
|
+ finally { i = 2; } // 无效:改的是 i,不是隐藏变量
|
|
|
+} // 返回 1
|
|
|
+```
|
|
|
+
|
|
|
+### 建议62:避免嵌套异常
|
|
|
+
|
|
|
+- **问题**:层层 `catch` 再 `throw err`,堆栈被重置,真正的出错位置消失。
|
|
|
+- **规则**:能处理才 catch;需要恢复状态后继续上抛时,用 `throw;`(或裸 `catch { ... throw; }`),绝不用 `throw err;`。
|
|
|
+- **要点**:
|
|
|
+ - `throw err;` 把异常重新抛出,堆栈从当前方法重新开始,原始 `Method1()` 中的 `10/i` 位置丢失。
|
|
|
+ - `throw;` 保留原始堆栈,IL 为 `rethrow`。
|
|
|
+ - 嵌套 catch 的两个害处:代码膨胀;隐藏堆栈,且 `catch (Exception)` 会吞掉方法内所有异常类型。
|
|
|
+ - 需要附加信息时用包装(建议60),而不是原样重抛。
|
|
|
+ - 只在确实要恢复状态、记录必要上下文、或转换异常类型时才 catch。
|
|
|
+- **陷阱**:`catch (Exception err) { throw err; }` 是最常见的“看起来无害”写法,静态分析工具(如 CA2200)会直接告警;`throw new Exception(ex.Message)` 同样丢堆栈且丢类型,比 `throw err` 更糟。
|
|
|
+
|
|
|
+### 建议63:避免"吃掉"异常
|
|
|
+
|
|
|
+- **问题**:捕获后既不处理也不上抛,Bug 被永久掩埋。
|
|
|
+- **规则**:不知道如何处理就不要 catch;只有“可预见且通常不算 Bug”的异常才可以吞掉,并且一般要记日志。
|
|
|
+- **要点**:
|
|
|
+ - 合理吞掉的场景一:批量解密上万份来自不同客户端的文件,个别文件损坏导致解密失败,应跳过并记录,保证整体流程继续,便于事后统一处理。
|
|
|
+ - 合理吞掉的场景二:分布式心跳检测,500ms 内没收到信号即判定受控端离线;每次 `SocketException` 都记日志没有意义,可以直接忽略。
|
|
|
+ - 判断标准:异常是否属于预期内的常态分支;吞掉后系统状态是否仍然一致。
|
|
|
+ - 吞异常时至少要留下可观测痕迹(日志、计数器),否则故障无法定位。
|
|
|
+- **陷阱**:`catch { }` 空块、`catch (Exception) { }` 是最严重的反模式;把“为了避免程序崩溃”当理由吞掉所有异常,会让数据处于半写入的不一致状态却继续运行。
|
|
|
+
|
|
|
+### 建议64:为循环增加Tester-Doer模式而不是将try-catch置于循环内
|
|
|
+
|
|
|
+- **问题**:循环内高频抛异常,性能塌陷。
|
|
|
+- **规则**:把异常条件提前判断(Tester),条件不满足时 `continue`/跳过,而不是让每次迭代都抛异常再捕获。
|
|
|
+- **要点**:
|
|
|
+ - 抛异常开销大:示例 10000 次 `DivideByZeroException` 约 796ms,改成 `if (x == 0) continue;` 后为 0ms(数量级差异,非固定值)。
|
|
|
+ - 异常开销主要来自堆栈展开、异常对象分配与 SEH 处理,而非 `try` 块本身;无异常发生时 try 几乎零成本。
|
|
|
+ - Tester-Doer 同样适用于 API 设计:`int.Parse` 配 `int.TryParse`、`ICollection` 配 `Contains` 预判。
|
|
|
+ - 循环内把 `try` 提到循环外只能减少进入开销,无法避免每次抛出的代价,正解仍是条件预判。
|
|
|
+- **陷阱**:把 Tester 写成与 Doer 不一致的重复逻辑,条件漂移后漏判;多线程下“检查后使用”存在竞态,此时仍需 try-catch 兜底。
|
|
|
+
|
|
|
+```csharp
|
|
|
+for (int i = 0; i < 10000; i++)
|
|
|
+{
|
|
|
+ if (x == 0) continue; // Tester:预判,避免抛异常
|
|
|
+ int j = i / x;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议65:总是处理未捕获的异常
|
|
|
+
|
|
|
+- **问题**:未捕获异常直接终止进程,用户只看到崩溃,开发者拿不到现场。
|
|
|
+- **规则**:在入口注册 `AppDomain.CurrentDomain.UnhandledException` 记录日志;WinForms 另注册 `Application.ThreadException`,WPF 另注册 `Application.DispatcherUnhandledException`。
|
|
|
+- **要点**:
|
|
|
+ - `UnhandledException` 只是通知,不能阻止进程终止;`UnhandledExceptionEventArgs.ExceptionObject` 为 `object`,需转成 `Exception`。
|
|
|
+ - WinForms:`Application.ThreadException += ...` 配合 `Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException)`;该事件可阻止应用退出,只覆盖 UI 线程。
|
|
|
+ - WinForms 非 UI 线程异常仍走 `UnhandledException`,并导致进程退出。
|
|
|
+ - WPF:`DispatcherUnhandledException` 处理 UI 线程异常,必须设置 `e.Handled = true`,否则会继续触发 `AppDomain.UnhandledException`。
|
|
|
+ - 处理器内部要再包一层 try-catch,兜底失败时提示并 `Application.Exit()`/`Application.Current.Shutdown()`。
|
|
|
+- **陷阱**:把 `UnhandledException` 当成“异常已被处理”,之后继续运行是不成立的;处理器中弹 `MessageBox` 在服务/无界面环境会阻塞或抛新异常,日志才是主路径。
|
|
|
+
|
|
|
+```csharp
|
|
|
+AppDomain.CurrentDomain.UnhandledException += (s, e) =>
|
|
|
+ Logger.Fatal((Exception)e.ExceptionObject);
|
|
|
+```
|
|
|
+
|
|
|
+### 建议66:正确捕获多线程中的异常
|
|
|
+
|
|
|
+- **问题**:在新线程外层包 try-catch 捕获不到线程内异常,进程直接退出。
|
|
|
+- **规则**:线程体内部自己 try-catch 全部代码;需要上报主线程时,用 `Control.BeginInvoke` / `Dispatcher.Invoke` 把异常 marshal 回 UI 线程,或改用事件回调。
|
|
|
+- **要点**:
|
|
|
+ - `Thread.Start()` 只是启动,异常发生在新线程的调用栈上,外层 `try` 属于原线程,捕获不到。
|
|
|
+ - .NET 2.0 起,任何线程上未处理的异常都会终止整个应用(先触发 `AppDomain.UnhandledException`)。
|
|
|
+ - 线程内异常必须在线程内处理完;跨线程传递用 `BeginInvoke((Action)delegate { throw ex; })`,最终触发 UI 线程的 `Application.ThreadException`。
|
|
|
+ - WPF 用 `Dispatcher.Invoke`;`Invoke` 同步等待,`BeginInvoke` 异步投递。
|
|
|
+ - 推荐事件回调方式统一上报,避免每个线程各写一套。
|
|
|
+- **陷阱**:`BeginInvoke` 里 `throw ex` 会重置堆栈,应 `throw;`(但跨线程 lambda 中 `throw;` 在 catch 外非法,需保留异常对象并接受堆栈损失,或直接传异常给统一处理器);`Task` 的异常不会主动抛出,需 `await`/`Wait()` 才会以 `AggregateException` 形式暴露。
|
|
|
+
|
|
|
+### 建议67:慎用自定义异常
|
|
|
+
|
|
|
+- **问题**:动辄为每种错误定义异常类型,导致异常类爆炸、调用方无从处理。
|
|
|
+- **规则**:除非有充分理由,不创建自定义异常;需要按业务类别统一捕获时,才在数据层/边界层定义并转换。
|
|
|
+- **要点**:
|
|
|
+ - 四个正当理由:精确定位并恢复;包装多个底层异常为业务异常;方便调用者写业务 catch;引入新的异常类别以驱动不同处理。
|
|
|
+ - 典型例子:数据访问层同时支持 SQL Server 与 SQLite,业务层不愿写 `catch (SQLException)` + `catch (SQLiteException)` 两套,且未来还要加 Oracle。
|
|
|
+ - 做法:各数据层捕获自己的厂商异常,统一 `throw new DataAccessException()`,业务层只 catch 一种。
|
|
|
+ - 自定义异常应携带足够上下文(内部异常、关键参数),否则等于把信息抹平。
|
|
|
+- **陷阱**:只抛 `new DataAccessException()` 而不带 inner exception,会丢掉根因(违背建议60);用自定义异常表达正常业务分支(如“用户不存在”)属于滥用。
|
|
|
+
|
|
|
+### 建议68:从System.Exception或其他常见的基本异常中派生异常
|
|
|
+
|
|
|
+- **问题**:自定义异常的基类选择与可序列化要求。
|
|
|
+- **规则**:从 `System.Exception` 或 `IOException`、`ArgumentException` 等常见基类派生;标 `[Serializable]`,提供四个标准构造器(含序列化构造器)。
|
|
|
+- **要点**:
|
|
|
+ - 历史上的 `ApplicationException`/`SystemException` 二分已被微软自己放弃,现建议直接从 `Exception` 或语义相近的常见异常派生。
|
|
|
+ - 标准四构造器:无参、`(string message)`、`(string message, Exception inner)`、`(SerializationInfo, StreamingContext)`。
|
|
|
+ - `[Serializable]` 是必需的,异常要能跨 `AppDomain` 边界传递。
|
|
|
+ - 新增字段时必须实现 `ISerializable` 并重写 `GetObjectData`,把新字段写入 `SerializationInfo`,否则跨域/序列化后字段丢失。
|
|
|
+ - 需要格式化消息时可重写 `Message` 属性(如追加试卷/学生信息),`base.Message + _paperInfo`。
|
|
|
+- **陷阱**:重写 `Message` 后忘记在 `GetObjectData` 中保存新增字段,反序列化得到的异常 `Message` 会缺一段;序列化构造器应为 `protected`,写成 `public` 无害但破坏封装一致性。
|
|
|
+
|
|
|
+```csharp
|
|
|
+[Serializable]
|
|
|
+public class DataAccessException : Exception
|
|
|
+{
|
|
|
+ public DataAccessException() { }
|
|
|
+ public DataAccessException(string message) : base(message) { }
|
|
|
+ public DataAccessException(string message, Exception inner) : base(message, inner) { }
|
|
|
+ protected DataAccessException(
|
|
|
+ System.Runtime.Serialization.SerializationInfo info,
|
|
|
+ System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- 时代注:.NET 8 起序列化构造器与 `ISerializable` 标记过时(SYSLIB0050),`BinaryFormatter` 于 .NET 9 移除;现代做法是不再写序列化构造器,异常跨进程用结构化数据传递。
|
|
|
+
|
|
|
+### 建议69:应使用finally避免资源泄漏
|
|
|
+
|
|
|
+- **问题**:异常路径上资源释放代码被跳过。
|
|
|
+- **规则**:把释放代码放进 `finally`,或用等价的 `using`;释放顺序由调用栈决定,内层先释放。
|
|
|
+- **要点**:
|
|
|
+ - `finally` 在 `return` 之前执行,也在 `catch` 之后执行;异常不会阻止 `finally` 运行(除进程终止类异常)。
|
|
|
+ - 调用栈越深的资源越先释放:示例输出 `Method2` 先于 `Method1`,`Method4` 先于 `Method3`。
|
|
|
+ - 内层 `catch` 记录后 `throw;`,外层 `catch` 仍能捕获,两层 `finally` 依次执行。
|
|
|
+ - 释放语句应写在 `finally` 而非 `try` 末尾,否则 `try` 中抛异常时被跳过。
|
|
|
+ - `using` 是 `try-finally` 的语法糖,编译器生成的 IL 一致。
|
|
|
+- **陷阱**:`c` 在 `try` 内才 `new`,若构造抛异常,`finally` 中 `c.Dispose()` 会触发 `NullReferenceException` 掩盖原始异常;应判空或用 `using`。`finally` 中本身抛异常会覆盖原始异常。
|
|
|
+
|
|
|
+### 建议70:避免在调用栈较低的位置记录异常
|
|
|
+
|
|
|
+- **问题**:底层与高层重复记录同一异常,日志噪音大且丢失“谁该负责”的边界。
|
|
|
+- **规则**:异常记录与用户提示统一放在应用最上层(通常是 UI 层);底层只做包装与上抛,不写日志。
|
|
|
+- **要点**:
|
|
|
+ - 最上层才知道呈现方式:BLL 可能被 WinForms 调用,也可能被控制台调用,底层无法决定用 `MessageBox` 还是 `Console.Write`。
|
|
|
+ - 底层记录后再包装上抛,会导致同一条异常被记两次(`MethodLow` 与 `MethodHigh` 各一次)。
|
|
|
+ - 底层往往“捕获了也无法完整处理”,记录之后仍然要上抛,记录本身没有增加价值。
|
|
|
+ - 设计初期就应约定:谁记录(顶层/宿主)、谁提示(UI)、谁包装(层边界)。
|
|
|
+ - 未捕获异常必须在顶层记录,它通常代表一个 Bug。
|
|
|
+- **陷阱**:为了“方便排查”在每层都加日志,结果日志量随层数翻倍且时间戳误导;用日志代替异常传播,会让上层失去做补偿/重试的机会。
|
|
|
+
|
|
|
+## 第 6 章 异步、多线程、任务和并行
|
|
|
+
|
|
|
+### 建议71:区分异步和多线程应用场景
|
|
|
+
|
|
|
+- **问题**:初学者把"不卡界面"等同于"开新线程",用 `Thread` 包裹网络 I/O,占用一个线程全程空等。
|
|
|
+- **规则**:计算密集型工作用多线程(CPU 并行才有收益);I/O 密集型工作用异步(等待期间把线程还给线程池)。
|
|
|
+- **要点**:
|
|
|
+ - 异步的理论基础是硬件 DMA:数据在内存与外设间搬运不经 CPU,CLR 的异步模型就是利用这一点释放 CPU。
|
|
|
+ - 线程池模式下,发起 I/O 后工作线程被归还;I/O 完成时由完成端口回调通知线程池,全程不占 CPU。
|
|
|
+ - 原文的 `BeginGetResponse/EndGetResponse` 是 APM 模型;现代等价物是 `await` + `XxxAsync`(TAP)。
|
|
|
+ - 一个线程栈 1MB,用线程等 I/O 等于用 1MB 内存 + 上下文切换换"看起来没卡"。
|
|
|
+- **陷阱**:`Thread` 里访问 WinForm 控件仍会因跨线程操作抛异常(见建议87),异步省线程不等于省掉线程亲和性约束。
|
|
|
+- 时代注:APM(Begin/End)已被 async/await 取代;判断标准不变——有 `XxxAsync` 且是 I/O,就用 await。
|
|
|
+
|
|
|
+```csharp
|
|
|
+// I/O 密集型:等待期间不占线程
|
|
|
+private async void buttonGetPage_Click(object sender, EventArgs e)
|
|
|
+{
|
|
|
+ using var client = new HttpClient();
|
|
|
+ textBoxPage.Text = await client.GetStringAsync("http://www.sina.com.cn");
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议72:在线程同步中使用信号量
|
|
|
+
|
|
|
+- **问题**:需要"通知另一个线程可以继续"而不是"独占资源"时,`lock` 无能为力,应使用信号机制。
|
|
|
+- **规则**:引用类型的等待分两类——锁定(`lock`/`Monitor`)与信号同步(`WaitHandle` 家族);需要"放行/通知"时选后者。
|
|
|
+- **要点**:
|
|
|
+ - 继承 `WaitHandle` 的三类:`EventWaitHandle`(子类 `AutoResetEvent`、`ManualResetEvent`)、`Semaphore`、`Mutex`,底层都是一个内核句柄。
|
|
|
+ - `EventWaitHandle` 维护布尔"阻滞状态":false 则 `WaitOne()` 阻塞,`Set()` 置 true 放行。
|
|
|
+ - `Semaphore` 维护整型计数:>0 放行并减 1,=0 阻塞;`Release()` 归还计数,可限制并发数为 N。
|
|
|
+ - `AutoResetEvent.Set()` 只唤醒一个等待者并自动复位;`ManualResetEvent.Set()` 保持有信号,唤醒全部等待者,需手动 `Reset()`。
|
|
|
+ - `Mutex` 可跨应用程序域(命名 Mutex 跨进程),Event/Semaphore 只在单 AppDomain 内。
|
|
|
+ - `WaitOne(3000)` 带超时返回 `bool`,可用于心跳超时检测这类"等不到就认为断线"的场景。
|
|
|
+- **陷阱**:用 `AutoResetEvent` 想让两个线程同时继续,只有一个能过,另一个永远阻塞——这正是要换 `ManualResetEvent` 的信号。
|
|
|
+
|
|
|
+```csharp
|
|
|
+var gate = new AutoResetEvent(false); // false:初始为阻塞状态
|
|
|
+for (int n = 0; n < 3; n++)
|
|
|
+{
|
|
|
+ var th = new Thread(() => { gate.WaitOne(); Console.WriteLine("放行一个"); }) { IsBackground = true };
|
|
|
+ th.Start();
|
|
|
+}
|
|
|
+gate.Set(); // 只放行 1 个;改成 ManualResetEvent 才会 3 个全过
|
|
|
+```
|
|
|
+
|
|
|
+- 时代注:纯托管场景优先 `ManualResetEventSlim`/`SemaphoreSlim`,不创建内核对象,短等待时自旋更省。
|
|
|
+
|
|
|
+### 建议73:避免锁定不恰当的同步对象
|
|
|
+
|
|
|
+- **问题**:锁错对象等于没锁,代码看起来同步了,实际仍在并发修改共享数据。
|
|
|
+- **规则**:同步对象必须满足三条:多线程可见且是同一个对象、是引用类型、可见性尽可能低(`private static readonly object`)。
|
|
|
+- **要点**:
|
|
|
+ - 非静态方法里的实例字段做锁,多实例时每个实例一把锁,锁不住 `static` 字段;改成 `static` 才行。
|
|
|
+ (原文示例:两个 `SampleClass` 实例各自 `lock(syncObj)` 去改 `static TempList`,遍历中删除抛 `InvalidOperationException`。)
|
|
|
+ - `lock(this)` 锁的是当前实例:两个实例互不排斥;且外部代码也能 `lock` 同一对象,等于把锁暴露给调用方。
|
|
|
+ - 值类型不能作锁对象:装箱后每次 `lock` 得到不同副本,两个线程锁的不是同一对象。
|
|
|
+ - 字符串不能作锁对象:字面量被 CLR 暂存(interning),`lock("abc")` 两处会锁同一对象,可能把整个应用卡死。
|
|
|
+ - `lock(typeof(X))` 锁的是全局唯一的 Type 对象,进程内所有 X 实例全部串行,且完全对外开放。
|
|
|
+ - 原则:类型的静态方法应保证线程安全,非静态方法不必。`ArrayList.SyncRoot` 是"由调用方决定是否同步"的历史设计,不是鼓励公开同步对象。
|
|
|
+ - `lock` 是 `Monitor.Enter/Exit` 的语法糖(`try/finally`)。
|
|
|
+- **陷阱**:把锁对象声明为 `public`、属性、或基类可继承字段,等于邀请第三方参与你的锁,死锁风险不可控。
|
|
|
+
|
|
|
+```csharp
|
|
|
+class Sample
|
|
|
+{
|
|
|
+ private static readonly object SyncObj = new object(); // 静态 + 私有 + readonly
|
|
|
+ public void Add(string item) { lock (SyncObj) { TempList.Add(item); } }
|
|
|
+ public static List<string> TempList = new List<string>();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议74:警惕线程的IsBackground
|
|
|
+
|
|
|
+- **问题**:前台线程不退出,进程就不退出;误用会让程序"关不掉"或"活干到一半被杀"。
|
|
|
+- **规则**:默认用后台线程;只有必须完成的事务、必须释放的非托管资源才用前台线程。
|
|
|
+- **要点**:
|
|
|
+ - `new Thread(...)` 默认 `IsBackground = false`,即前台线程;`ThreadPool` 与 `Task` 的线程默认都是后台线程。
|
|
|
+ - 前台线程只要还有一个在跑,`Main` 返回后进程仍然存活;全部前台线程结束进程才退出。
|
|
|
+ - 后台线程不阻止进程退出:进程一结束,后台线程被直接终止,`finally` 不保证执行。
|
|
|
+ - 原文示例中工作线程里 `Console.ReadKey()`,设 `IsBackground=false` 时进程会等到按键,设 `true` 则主线程结束即退出。
|
|
|
+- **陷阱**:把"写日志/落盘/提交事务"放在后台线程上,用户关窗口就丢数据;这类工作应前台执行或显式等待完成。
|
|
|
+
|
|
|
+### 建议75:警惕线程不会立即启动
|
|
|
+
|
|
|
+- **问题**:`Start()` 只是把线程置为就绪,真正执行时刻由 OS 调度决定,于是闭包捕获的循环变量会拿到"未来值"。
|
|
|
+- **规则**:启动线程前把需要的值复制到局部变量,或通过参数传入;不要在线程体里读可变的循环变量/字段。
|
|
|
+- **要点**:
|
|
|
+ - Windows 是抢占式、非实时调度:线程按时间片(约几十毫秒)轮转,启动顺序不等于执行顺序。
|
|
|
+ - 启动 10 个线程,输出顺序可能完全乱序;`Thread9` 打印的 `_id` 可能是 10 而不是 9。
|
|
|
+ - 修复手法是在循环体内做一次同步赋值(局部变量/参数),使值在 `Start()` 之前固定。
|
|
|
+ - 这个坑的本质是闭包捕获变量(引用)而非值,与线程调度共同作用。
|
|
|
+- **陷阱**:`for (int i = ...)` 的 `i` 在 C# 5 之后仍是整个循环共用的一个变量;只有 `foreach` 的迭代变量被改成每轮一份。别把 `foreach` 的修复当成 `for` 也安全。
|
|
|
+
|
|
|
+```csharp
|
|
|
+for (int i = 0; i < 10; i++, _id++)
|
|
|
+{
|
|
|
+ int id = _id; // 先固定值,再交给线程
|
|
|
+ string name = $"Thread{i}";
|
|
|
+ new Thread(() => Console.WriteLine($"{name}:{id}")).Start();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议76:警惕线程的优先级
|
|
|
+
|
|
|
+- **问题**:误以为调高优先级能"保证"关键线程及时执行,结果造成低优先级线程饿死、整体更难预测。
|
|
|
+- **规则**:一般不要改优先级;仅对"运行时间极短、能立刻进入等待"的关键线程适度提升。
|
|
|
+- **要点**:
|
|
|
+ - 五档:`Highest`、`AboveNormal`、`Normal`、`BelowNormal`、`Lowest`,通过 `Thread.Priority` 设置。
|
|
|
+ - Windows 是基于优先级的抢占式调度:高优先级线程一旦就绪就优先获得 CPU,低优先级只能等它阻塞。
|
|
|
+ - `Thread` 和 `ThreadPool` 新建线程默认都是 `Normal`。
|
|
|
+ - 原文单核测试:同为死循环计数,`Highest` 的 t1 计数几乎总大于 `Normal` 的 t2。
|
|
|
+ - 提优先级只影响"分到 CPU 的概率",不改变"不实时"这一事实。
|
|
|
+- **陷阱**:把长计算或含锁等待的线程设为 `Highest`,会长时间压制 UI 线程,界面直接假死。
|
|
|
+
|
|
|
+### 建议77:正确停止线程
|
|
|
+
|
|
|
+- **问题**:没有"立刻停"的 API,强行中止会破坏状态;停线程必须由工作线程自己配合。
|
|
|
+- **规则**:使用协作式取消(`CancellationTokenSource` + `CancellationToken`),工作线程按固定频率检查取消标志并自行退出。
|
|
|
+- **要点**:
|
|
|
+ - `Thread.Abort()` 不是立即生效:正在执行非托管代码时不会抛 `ThreadAbortException`,要等回到托管代码才抛;即便在托管代码中也不保证立刻抛出。
|
|
|
+ - `CancellationTokenSource.Token` 是 `CancellationToken`(值类型);核心属性 `IsCancellationRequested`(bool)。
|
|
|
+ - `cts.Cancel()` 只置标志,不杀线程;`ThrowIfCancellationRequested()` 则把标志转成 `OperationCanceledException`。
|
|
|
+ - `token.Register(Action)` 注册取消回调,`Cancel()` 时同步执行,可用于清理/通知。
|
|
|
+ - 检查频率是设计参数:太密浪费 CPU,太疏退出迟滞;原文示例约 1000ms 一次。
|
|
|
+- **陷阱**:只在循环开头检查一次 `IsCancellationRequested`,长耗时的单次迭代仍会让"停止"延迟很久;I/O 阻塞中的线程无法靠标志唤醒,需用带取消令牌的异步 API 或超时。
|
|
|
+- 时代注:.NET Core / .NET 5+ 中 `Thread.Abort` 直接抛 `PlatformNotSupportedException`,协作式取消是唯一选项。
|
|
|
+
|
|
|
+```csharp
|
|
|
+var cts = new CancellationTokenSource();
|
|
|
+var t = new Thread(() =>
|
|
|
+{
|
|
|
+ while (!cts.Token.IsCancellationRequested)
|
|
|
+ {
|
|
|
+ Console.WriteLine(DateTime.Now);
|
|
|
+ Thread.Sleep(1000);
|
|
|
+ }
|
|
|
+});
|
|
|
+t.Start();
|
|
|
+cts.Token.Register(() => Console.WriteLine("工作线程被终止了。"));
|
|
|
+cts.Cancel();
|
|
|
+```
|
|
|
+
|
|
|
+### 建议78:应避免线程数量过多
|
|
|
+
|
|
|
+- **问题**:一连接一线程的模型在连接数上升后耗尽内存与地址空间,且调度开销让所有线程都变慢。
|
|
|
+- **规则**:不要为每个 I/O 任务开线程;改用线程池/异步,让少量线程管理大量连接。
|
|
|
+- **要点**:
|
|
|
+ - 32 位进程用户态地址空间约 2GB,每线程栈 1MB,理论上限约 1000 多个线程,加上进程自身开销很快耗尽。
|
|
|
+ - 上下文切换本身消耗 CPU:线程越多,每个线程真正干活的时间比例越低。
|
|
|
+ - 原文双核实测:先起 200 个死循环线程,第 201 个线程约 5 分钟后才得到执行,其后续每次执行同样要等很久。
|
|
|
+ - 现实参照:任务管理器里线程最多的应用也就百余个,多数进程约 10 个线程。
|
|
|
+ - 1000 个客户端的心跳管理,用异步后只需几个线程(数量取决于心跳频率)。
|
|
|
+ - 线程数的合理值取决于"阻塞型工作占比",纯计算型大致等于核数。
|
|
|
+- **陷阱**:把"客户端数不超过 500"写进需求就以为安全;500 个线程的上下文切换开销已足以拖垮吞吐。
|
|
|
+
|
|
|
+### 建议79:使用ThreadPool或BackgroundWorker代替Thread
|
|
|
+
|
|
|
+- **问题**:`new Thread` 的创建/销毁成本高(内存 + 内核对象 + DLL 通知 + 上下文切换),频繁创建纯属浪费。
|
|
|
+- **规则**:需要多线程时先想线程池 `ThreadPool.QueueUserWorkItem`;WinForm/WPF 里需要进度与完成回调时用 `BackgroundWorker`。
|
|
|
+- **要点**:
|
|
|
+ - 空间开销:线程内核对象约 700B、TEB 约 4KB、用户模式栈 1MB、内核模式栈约 12KB(32 位数据)。
|
|
|
+ - 时间开销:初始化上述内存 → CLR 调用所有已加载 DLL 的 `DLLMain`(附线程终止时再调一次)→ 上下文切换。
|
|
|
+ - 上下文切换五步:进内核 → 保存寄存器到线程内核对象 → 取 Spinlock 选下一个线程(跨进程还要切虚拟地址)→ 载入新上下文 → 出内核。
|
|
|
+ - 线程池:工作完成后不销毁线程,保留一段时间等新工作,扩容/回收由 CLR 算法决定。
|
|
|
+ - `BackgroundWorker` 内部用线程池,并通过事件提供进度报告、完成回调、取消支持——`Thread`/`ThreadPool` 都没有。
|
|
|
+ - 用法:`RunWorkerAsync()` 启动,`DoWork` 里做耗时活,`ReportProgress(i)` 触发 `ProgressChanged`(在 UI 线程),`RunWorkerCompleted` 收尾。
|
|
|
+- **陷阱**:`ReportProgress` 默认不生效——必须先把 `WorkerReportsProgress = true`,否则进度事件不会触发;同理取消需 `WorkerSupportsCancellation = true`。
|
|
|
+- 时代注:新代码用 `Task.Run` + `IProgress<T>` + `CancellationToken` 替代 `BackgroundWorker`,后者在 .NET Core 后基本不再演进。
|
|
|
+
|
|
|
+```csharp
|
|
|
+var worker = new BackgroundWorker { WorkerReportsProgress = true };
|
|
|
+worker.DoWork += (s, e) =>
|
|
|
+{
|
|
|
+ for (int i = 0; i <= 100; i += 10) { ((BackgroundWorker)s).ReportProgress(i); Thread.Sleep(100); }
|
|
|
+};
|
|
|
+worker.ProgressChanged += (s, e) => label1.Text = e.ProgressPercentage.ToString();
|
|
|
+worker.RunWorkerAsync();
|
|
|
+```
|
|
|
+
|
|
|
+### 建议80:用Task代替ThreadPool
|
|
|
+
|
|
|
+- **问题**:`ThreadPool` 无法取消、无法通知完成/失败、无法表达先后次序,只能"丢进去就不管"。
|
|
|
+- **规则**:FCL 4.0 起用 `Task`/`Task<T>`;需要串联、取消、取结果、等全部/任一完成时都用 Task 的 API。
|
|
|
+- **要点**:
|
|
|
+ - 三个状态属性:`IsCanceled`(被取消)、`IsCompleted`(已结束)、`IsFaulted`(因异常结束);`IsCompleted` 在取消和异常时同样为 true。
|
|
|
+ - 完成通知用 `ContinueWith`(续体任务),而非事件回调;`TaskContinuationOptions.OnlyOnFaulted` 只在失败时执行续体。
|
|
|
+ - 取消用 `CancellationTokenSource`:任务体内调 `ThrowIfCancellationRequested()` 才会让 `IsCanceled == true`;只用 `if (!token.IsCancellationRequested) break;` 正常返回,则 `IsCanceled == false`、`IsCompleted == true`。
|
|
|
+ - 取消导致的异常是 `OperationCanceledException`,被 CLR 特殊对待,不算故障(`IsFaulted == false`);真抛 `Exception` 才会 `IsFaulted == true`。
|
|
|
+ - 取 `task.Result` 会把内部异常包成 `AggregateException` 抛出,需 `catch (AggregateException e) { e.Handle(err => err is OperationCanceledException); }`。
|
|
|
+ - `TaskFactory` 可让一组任务共享同一个 `CancellationTokenSource`,配合 `ContinueWhenAll(tasks, ...)` 实现"整组取消/整组完成"。
|
|
|
+ - `Task.Start()` 是异步启动,不阻塞调用者;`RunSynchronously()` 才在当前线程同步执行。
|
|
|
+- **陷阱**:`Task<int>` 构造函数签名是 `Task<T>(Func<T> function, CancellationToken token)`,令牌只影响"未启动即取消";启动后再 `Cancel()` 必须靠任务体内的检查生效,否则任务照跑完。
|
|
|
+
|
|
|
+```csharp
|
|
|
+var cts = new CancellationTokenSource();
|
|
|
+var t = Task.Run(() =>
|
|
|
+{
|
|
|
+ int r = 0;
|
|
|
+ for (int i = 0; i < 1000; i++)
|
|
|
+ {
|
|
|
+ cts.Token.ThrowIfCancellationRequested();
|
|
|
+ r++;
|
|
|
+ Thread.Sleep(100);
|
|
|
+ }
|
|
|
+ return r;
|
|
|
+}, cts.Token);
|
|
|
+cts.Cancel();
|
|
|
+try { t.Wait(); }
|
|
|
+catch (AggregateException e) { e.Handle(err => err is OperationCanceledException); }
|
|
|
+Console.WriteLine($"IsCanceled={t.IsCanceled}, IsFaulted={t.IsFaulted}"); // True, False
|
|
|
+```
|
|
|
+
|
|
|
+### 建议81:使用Parallel简化同步状态下Task的使用
|
|
|
+
|
|
|
+- **问题**:对数组/集合做"每个元素跑一段"的并行循环时,手写 Task 数组 + 等待太啰嗦。
|
|
|
+- **规则**:数据并行用 `Parallel.For`/`Parallel.ForEach`,一组异构操作并行用 `Parallel.Invoke`。
|
|
|
+- **要点**:
|
|
|
+ - 三个方法都在 `System.Threading.Tasks.Parallel`(静态类):`For`(索引)、`ForEach`(泛型集合)、`Invoke(params Action[])`。
|
|
|
+ - `Parallel.For(0, nums.Length, i => ...)` 的委托参数是索引,不是元素。
|
|
|
+ - 内部自动分配/复用 Task,并把工作交给线程池;线程数由运行时调度决定。
|
|
|
+ - 所有方法都不保证执行次序,输出顺序与索引顺序无关。
|
|
|
+ - `Invoke` 接受 `params Action[]`,适合"几个互不依赖的初始化并行做"。
|
|
|
+- **陷阱**:需要严格顺序输出(写文件、拼字符串、打印报表)时不能用 Parallel;要顺序就用 `for`/`foreach`。
|
|
|
+
|
|
|
+```csharp
|
|
|
+int[] nums = { 1, 2, 3, 4 };
|
|
|
+Parallel.For(0, nums.Length, i => Console.WriteLine($"索引{i} → 元素{nums[i]}"));
|
|
|
+Parallel.ForEach(new List<int> { 1, 2, 3, 4 }, item => Console.WriteLine(item));
|
|
|
+Parallel.Invoke(() => Console.WriteLine("任务1"), () => Console.WriteLine("任务2"));
|
|
|
+```
|
|
|
+
|
|
|
+### 建议82:Parallel简化但不等同于Task默认行为
|
|
|
+
|
|
|
+- **问题**:把 `Parallel.For` 当异步用,以为它会立刻返回,结果主线程被卡住甚至永远不返回。
|
|
|
+- **规则**:`Parallel` 是同步阻塞的(调用者等所有并行体完成);要"启动即返回"用 `Task.Start`/`Task.Run`。
|
|
|
+- **要点**:
|
|
|
+ - `Parallel.For/ForEach/Invoke` 返回时,所有循环体/委托都已执行完。
|
|
|
+ - `Parallel` 只提供 `Invoke`,没有 `BeginInvoke`——从 API 设计上就表明它是同步语义。
|
|
|
+ - `Task.Start()` 把任务排入线程池后立即返回,调用者继续执行。
|
|
|
+ - "在后台用 Task 管理"不等于"异步":并行关注吞吐,异步关注不阻塞调用者。
|
|
|
+ - 原文对照实验:`Task` 里死循环,主线程照常打印"主线程即将结束";`Parallel.For` 里死循环,永远等不到输出。
|
|
|
+- **陷阱**:在 UI 线程直接调 `Parallel.For` 处理大数据,界面照样冻结——需要 `await Task.Run(() => Parallel.For(...))`。
|
|
|
+
|
|
|
+### 建议83:小心Parallel中的陷阱
|
|
|
+
|
|
|
+- **问题**:`Parallel.For<TLocal>` 的 `localInit`/`localFinally` 是"每线程一份"而非"每任务/每循环一份",聚合结果因此随线程数变化。
|
|
|
+- **规则**:把局部状态当"线程局部累加器"理解;跨线程汇总必须用 `Interlocked` 或并发集合,不要假设初始化执行了几次。
|
|
|
+- **要点**:
|
|
|
+ - 签名:`For<TLocal>(int from, int to, Func<TLocal> localInit, Func<int, ParallelLoopState, TLocal, TLocal> body, Action<TLocal> localFinally)`。
|
|
|
+ - `localInit` 在运行时**为每个参与的后台线程**执行一次并返回初始值;`body` 的第三个参数是上一轮的局部值,返回值传给下一轮。
|
|
|
+ - `localFinally` 在该线程退出循环时执行一次,参数是该线程最后一次 `body` 的返回值。
|
|
|
+ - 原文例:`localInit` 返回 1、数组 `{1,2,3,4}`,一个线程跑完得 11,两个线程各得一份再加和得 12,因此输出不确定。
|
|
|
+ - 把 `localInit` 改为返回 0 就能掩盖这个陷阱(结果恒为 10),更难发现。
|
|
|
+ - 汇总用 `Interlocked.Add(ref total, x)`,因为 `localFinally` 在多个线程上并发执行。
|
|
|
+- **陷阱**:在 `localFinally` 里写 `result += ...` 或 `List.Add`,既是竞态又丢了部分结果;`total = total + subtotal` 必须换成原子操作。
|
|
|
+
|
|
|
+```csharp
|
|
|
+int[] nums = { 1, 2, 3, 4 };
|
|
|
+int total = 0;
|
|
|
+Parallel.For(0, nums.Length,
|
|
|
+ () => 1, // 每个线程一份局部累加器,初值为 1
|
|
|
+ (i, state, subtotal) => subtotal + nums[i],
|
|
|
+ subtotal => Interlocked.Add(ref total, subtotal));
|
|
|
+Console.WriteLine(total); // 一个线程跑完为 11,两个线程各加一次初值则为 12
|
|
|
+```
|
|
|
+
|
|
|
+### 建议84:使用PLINQ
|
|
|
+
|
|
|
+- **问题**:LINQ 查询天然是"逐元素处理",正好适合并行,手写 Parallel 循环既啰嗦又难读。
|
|
|
+- **规则**:集合上加 `.AsParallel()` 即得 PLINQ;需要保序时加 `.AsOrdered()`,并用 `foreach` 消费结果而非 `ForAll`。
|
|
|
+- **要点**:
|
|
|
+ - `ParallelEnumerable`(`System.Linq`)提供扩展方法,`AsParallel()` 把 `IEnumerable<T>` 转成 `ParallelQuery<T>`。
|
|
|
+ - 普通 LINQ 单线程、按索引顺序输出;PLINQ 多线程、输出顺序不确定。
|
|
|
+ - `ForAll(Action<T>)` 是并行消费,**会忽略** `AsOrdered()` 的排序请求,输出仍无序。
|
|
|
+ - 要保留顺序必须 `foreach (var x in query)`,PLINQ 会在消费端做归并排序,牺牲部分性能。
|
|
|
+ - `OrderBy/OrderByDescending/ThenBy/ThenByDescending` 本身就会引入排序开销。
|
|
|
+ - `Take(5)` 在顺序查询里是"前 5 个",在 PLINQ 里只是"任意 5 个"。
|
|
|
+ - 不是所有查询并行都更快:`ElementAt` 这类按索引定位的操作,顺序查询反而更快。
|
|
|
+- **陷阱**:用 `ForAll` 里改共享变量(计数、写 List)会引入竞态;并行查询适合"无副作用的投影/过滤",聚合请用 `Interlocked` 或线程安全集合。
|
|
|
+
|
|
|
+```csharp
|
|
|
+var query = from p in Enumerable.Range(0, 100).AsParallel().AsOrdered()
|
|
|
+ where p % 2 == 0
|
|
|
+ select p;
|
|
|
+foreach (int p in query) Console.WriteLine(p); // 保序
|
|
|
+// query.ForAll(p => Console.WriteLine(p)); // 无序,AsOrdered 失效
|
|
|
+```
|
|
|
+
|
|
|
+### 建议85:Task中的异常处理
|
|
|
+
|
|
|
+- **问题**:任务体里的异常若无人观察,会被吞掉或在不确定的时刻(GC 时)才暴露,程序"莫名其妙"出问题。
|
|
|
+- **规则**:主动观察任务异常:用 `Wait/WaitAll/WaitAny/Result` 或 `ContinueWith(..., OnlyOnFaulted)`;需要回到主线程时用事件通知而不是死等。
|
|
|
+- **要点**:
|
|
|
+ - TPL 把所有任务异常聚合进 `AggregateException`,通过 `InnerExceptions` 遍历,`Handle(Func<Exception,bool>)` 可逐条标记已处理。
|
|
|
+ - `Wait()`/`Result` 会阻塞调用线程,不适合等待长任务;只适合"续体任务很短"的场合。
|
|
|
+ - 用 `ContinueWith` + `OnlyOnFaulted` 可无阻塞地拿到 `task.Exception`,但回调仍在线程池线程上,没回主线程。
|
|
|
+ - 回到主线程的两种办法:其一,在续体里 `throw task.Exception;` 再对短续体 `Wait()`,异常被重新包装为 `AggregateException`;其二,捕获后触发事件,把异常对象交给主线程/UI 线程处理(推荐,完全不阻塞)。
|
|
|
+ - 事件方式要多写一个 `EventArgs` 子类承载 `AggregateException`,但换来非阻塞与线程模型兼容。
|
|
|
+ - `TaskScheduler.UnobservedTaskException` 只在任务被 GC 且异常从未被观察时触发,时点不可控;需 `GC.Collect(0)` 才能稳定看到,`e.SetObserved()` 标记已处理。
|
|
|
+- **陷阱**:`catch (AggregateException e) { }` 空捕获等于没处理;`e.Handle` 返回 false 的异常会被重新抛出,别把不该吞的也吞了。
|
|
|
+- 时代注:.NET 4.5 起未观察的任务异常默认不再终止进程(`ThrowUnobservedTaskExceptions` 默认关闭),"静默丢失"更隐蔽,必须显式观察。
|
|
|
+
|
|
|
+```csharp
|
|
|
+Task t = Task.Run(() => { throw new InvalidOperationException("任务并行编码中产生的未知异常"); });
|
|
|
+t.ContinueWith(task =>
|
|
|
+{
|
|
|
+ foreach (var e in task.Exception.InnerExceptions)
|
|
|
+ Console.WriteLine($"{e.GetType()}: {e.Message}");
|
|
|
+}, TaskContinuationOptions.OnlyOnFaulted);
|
|
|
+```
|
|
|
+
|
|
|
+### 建议86:Parallel中的异常处理
|
|
|
+
|
|
|
+- **问题**:并行循环里多个线程同时抛异常,主线程需要一次性拿到全部异常而不是只看到第一个。
|
|
|
+- **规则**:`Parallel` 是同步的,直接把 `For/ForEach/Invoke` 包在 `try/catch (AggregateException)` 里即可,不需要续体。
|
|
|
+- **要点**:
|
|
|
+ - `Parallel` 会把循环体中未处理的异常聚合成 `AggregateException`,待所有迭代结束后在调用线程上抛出。
|
|
|
+ - 需要"收集全部异常再统一处理"时,可在循环体内 `try/catch` 后 `Enqueue` 到 `ConcurrentQueue<Exception>`,再在循环体内抛 `AggregateException`,由外层捕获。
|
|
|
+ - `ConcurrentQueue<T>` 是线程安全集合,避免并发写入破坏内部状态(见建议22)。
|
|
|
+ - 因调用线程会等待全部完成,异常天然"回到主线程",无需事件或 `Wait`。
|
|
|
+- **陷阱**:把异常存进普通 `List<Exception>` 会因并发 `Add` 丢数据或抛索引越界;`Parallel.For` 的循环体不要再 `throw` 普通异常指望"只中断当前迭代",它会终结整个循环。
|
|
|
+
|
|
|
+```csharp
|
|
|
+var errors = new ConcurrentQueue<Exception>();
|
|
|
+try
|
|
|
+{
|
|
|
+ Parallel.For(0, 4, i =>
|
|
|
+ {
|
|
|
+ try { if (i == 2) throw new InvalidOperationException("并行任务中出现的异常"); }
|
|
|
+ catch (Exception e) { errors.Enqueue(e); }
|
|
|
+ if (!errors.IsEmpty) throw new AggregateException(errors);
|
|
|
+ });
|
|
|
+}
|
|
|
+catch (AggregateException err) { foreach (var e in err.InnerExceptions) Console.WriteLine(e.Message); }
|
|
|
+```
|
|
|
+
|
|
|
+### 建议87:区分WPF和WinForm的线程模型
|
|
|
+
|
|
|
+- **问题**:工作线程直接改 UI 控件;WinForm 有时能跑、有时崩,WPF 必崩,代码在不同框架下行为不一致。
|
|
|
+- **规则**:UI 元素只能由创建它的线程更新;工作线程一律通过 `Control.BeginInvoke`(WinForm)或 `Dispatcher.BeginInvoke`(WPF)投递。
|
|
|
+- **要点**:
|
|
|
+ - WinForm:所有控件实现 `ISynchronizeInvoke`,用 `InvokeRequired` 判断当前线程是否创建线程,`Invoke`(同步)/`BeginInvoke`(异步)把消息投进消息队列。
|
|
|
+ - WPF:控件最终继承 `DispatcherObject`,提供 `Dispatcher`(调度)、`CheckAccess()`(能否访问)、`VerifyAccess()`(不能访问就抛 `InvalidOperationException`)。
|
|
|
+ - 关键差异:WinForm 内部用类似 `CheckAccess` 的"判断后放行",WPF 用 `VerifyAccess` 的"判断后抛异常",所以同一段工作线程改控件的代码 WinForm 可能侥幸成功,WPF 必抛。
|
|
|
+ - 两者底层都靠 `GetMessage`/`PeekMessage` 消息队列,由 UI 线程消费。
|
|
|
+ - 稳定做法:在 WinForm 里自己维护 `mainThread = Thread.CurrentThread`,实现 `CheckAccess`/`VerifyAccess` 并强制走 `BeginInvoke`,可提取为扩展方法复用。
|
|
|
+ - `BeginInvoke` 接受 `Delegate`,常用 `new Action(() => ...)` 包装。
|
|
|
+- **陷阱**:`InvokeRequired == false` 时也必须直接赋值(不能省 else 分支),否则 UI 线程自己调用反而漏更新;`Invoke` 是同步的,在工作线程里对 UI 线程 `Invoke` 而 UI 线程正等该工作线程,立刻死锁——优先 `BeginInvoke`。
|
|
|
+- 时代注:现代写法用 `Control.InvokeAsync`(.NET 9+)/`Dispatcher.InvokeAsync`,或直接 `await` 回到 UI 上下文(`SynchronizationContext`),无需手工判断。
|
|
|
+
|
|
|
+```csharp
|
|
|
+// WinForm 工作线程内的安全更新
|
|
|
+if (label1.InvokeRequired)
|
|
|
+ label1.BeginInvoke(new Action(() => label1.Text = DateTime.Now.ToString()));
|
|
|
+else
|
|
|
+ label1.Text = DateTime.Now.ToString();
|
|
|
+```
|
|
|
+
|
|
|
+### 建议88:并行并不总是速度更快
|
|
|
+
|
|
|
+- **问题**:给一个小循环套上 `Parallel.For`,结果比顺序执行慢一个数量级。
|
|
|
+- **规则**:循环体足够"重"(远大于调度开销)才并行;先用 `Stopwatch` 实测,再决定是否并行。
|
|
|
+- **要点**:
|
|
|
+ - 并行有固定开销:任务分配、线程池调度、线程间同步、结果合并。
|
|
|
+ - 原文双核实测:循环体只做 10 次自增时,同步 0.55ms,并行 3.36ms(慢约 6 倍)。
|
|
|
+ - 循环体改为 1000 万次后:同步 1.31s,并行 0.66s(接近 2 倍,受核数限制)。
|
|
|
+ - 经验阈值:单次迭代工作量远大于微秒级调度成本时才有收益;否则顺序更快。
|
|
|
+ - 加速上限受物理核数约束,且随核数增加边际递减。
|
|
|
+- **陷阱**:在基准测试里把 JIT 预热、首次线程池扩容的开销算进并行耗时,或反过来只测一次就下结论;至少预热一轮、多跑几次取稳定值。
|
|
|
+
|
|
|
+### 建议89:在并行方法体中谨慎使用锁
|
|
|
+
|
|
|
+- **问题**:并行循环体里用 `lock` 保护共享资源,等于把并发强行串行化,还要额外付出锁与上下文切换开销。
|
|
|
+- **规则**:优先用无锁原子操作(`Interlocked`、`volatile`);若方法体整体都要同步,就不要用并行。
|
|
|
+- **要点**:
|
|
|
+ - `SomeCount++` 不是原子操作(读-改-写三步),并发下会丢更新:原文 1000 万次并行自增只得到 8,322,580。
|
|
|
+ - 非原子写的风险包括 torn read:跨字段内存边界时可能读到"半个旧值 + 半个新值"。
|
|
|
+ - `Interlocked.Add/Increment/CompareExchange` 对整型提供原子操作,开销远低于 `lock`。
|
|
|
+ - `volatile` 只保证可见性与禁止重排,**不保证**复合操作原子性。
|
|
|
+ - 加锁后结果正确,但吞吐可能不如单线程 `for`——锁让多线程退化为单线程。
|
|
|
+ - 判断标准:临界区占比越高,并行的收益越接近 0;全部同步则并行纯属负收益。
|
|
|
+- **陷阱**:以为把 `lock` 放在 `Interlocked` 外面更"保险",实际是双重开销;也不要锁 `this`、字符串、`typeof(T)`(见建议73)。
|
|
|
+
|
|
|
+```csharp
|
|
|
+long count = 0;
|
|
|
+Parallel.For(0, 10_000_000, i => Interlocked.Increment(ref count)); // 正确且快
|
|
|
+// Parallel.For(0, 10_000_000, i => { lock (syncObj) count++; }); // 正确但通常比 for 更慢
|
|
|
+```
|
|
|
+
|
|
|
+## 第 7 章 成员设计
|
|
|
+
|
|
|
+### 建议90:不要为抽象类提供公开的构造方法
|
|
|
+
|
|
|
+- **问题**:抽象类只能被继承,公开构造方法会把"可以创建实例"的错觉暴露给调用方,并扩大无意义的 API 面。
|
|
|
+- **规则**:抽象类的构造方法声明为 `protected`;只有嵌套派生类才用 `private`;仅程序集内部继承时可用 `internal` / `protected internal`。
|
|
|
+- **要点**:
|
|
|
+ - 抽象类可以有构造方法,用于初始化基类字段;即使不写,编译器也会生成一个 `protected` 无参构造方法。
|
|
|
+ - 有 `public` 构造方法也不能 `new` 抽象类,编译器直接报"无法创建抽象类的实例",所以公开只带来误导。
|
|
|
+ - 抽象类不能是 `static` 或 `sealed`;`abstract` 成员只能出现在抽象类中,派生类必须实现全部抽象成员(除非自身也是抽象类)。
|
|
|
+ - 构造方法承担基类字段的初始化职责,派生类通过 `: base(...)` 显式调用。
|
|
|
+- **陷阱**:把构造方法写成 `private` 会挡住所有派生类(只有嵌套类能访问外部类型的私有成员),抽象类随即无法被继承。
|
|
|
+
|
|
|
+### 建议91:可见字段应该重构为属性
|
|
|
+
|
|
|
+- **问题**:公开字段无法在读写时插入校验、通知、同步或延迟加载,且字段与属性的二进制形态不同,后期改造代价高。
|
|
|
+- **规则**:对外可见(public/protected/internal)的字段一律改为属性;只在类型内部使用、且不需要任何附加逻辑时才保留字段。
|
|
|
+- **要点**:
|
|
|
+ - 属性是方法:`public string Name { get; set; }` 编译为 `get_Name()`、`set_Name(string)` 两个方法加一个私有实例字段 `<Name>k__BackingField`(书上说"静态字段"是笔误)。
|
|
|
+ - 因为属性是方法,才能加校验、日志、`INotifyPropertyChanged`、`lock`、懒加载;字段做不到。
|
|
|
+ - 字段访问是 `ldfld/stfld`,属性访问是 `call/callvirt`:字段改属性属于二进制不兼容,所有调用方必须重新编译。
|
|
|
+ - C# 3.0 起有自动属性;匿名类型只支持只读自动属性。C# 9 的 `init`、C# 11 的 `required` 可表达"初始化后不可变/必须初始化"。
|
|
|
+ - 属性可拆分访问级别:`public string Name { get; private set; }`。
|
|
|
+- **陷阱**:字段能作为 `ref`/`out` 实参,属性不行;在 getter 里做耗时操作或抛异常会坑到调用方,调试器求值也会触发 getter。属性应无副作用、可重复读取。
|
|
|
+
|
|
|
+### 建议92:谨慎将数组或集合作为属性
|
|
|
+
|
|
|
+- **问题**:只读的集合属性只保证"引用不变",元素仍可被增删改,封装被击穿。
|
|
|
+- **规则**:集合类属性改为 private/protected 字段,对外只暴露必要的方法;必须暴露时返回只读视图或快照。
|
|
|
+- **要点**:
|
|
|
+ - `public IList<T> Items { get; private set; }` 调用方仍能 `Add`、`Clear`、`items[0].X = ...`,`private set` 只挡住重新赋值。
|
|
|
+ - `ReadOnlyCollection<T>`(`List<T>.AsReadOnly()`、`Array.AsReadOnly()`)是包装器,底层集合变化会透传;`ToArray()`、`ImmutableArray<T>` 是快照。
|
|
|
+ - .NET 4.5 起用 `IReadOnlyList<T>` / `IReadOnlyCollection<T>` 表达只读契约,比 `IEnumerable<T>` 语义更准确。
|
|
|
+ - 索引器/元素是可变引用类型时,元素自身仍可被修改;彻底不可变要求元素也不可变。
|
|
|
+- **陷阱**:`IEnumerable<T>` 属性看似只读,调用方可强转回 `List<T>` 再改;延迟查询每次枚举结果还可能不同。
|
|
|
+```csharp
|
|
|
+class Company {
|
|
|
+ private readonly List<Employee> employees = new();
|
|
|
+ public IReadOnlyList<Employee> Employees => employees.AsReadOnly();
|
|
|
+ public void AddEmployee(Employee e) => employees.Add(e);
|
|
|
+}
|
|
|
+class Employee { public string Name { get; set; } }
|
|
|
+```
|
|
|
+
|
|
|
+### 建议93:构造方法应初始化主要属性和字段
|
|
|
+
|
|
|
+- **问题**:对象构造完成后仍存在 null 字段/属性,调用方被迫到处判空,类型不变量被破坏。
|
|
|
+- **规则**:引用类型字段在声明处用初始化器赋值,或在构造方法中赋值;构造方法返回时对象应处于完整可用状态。
|
|
|
+- **要点**:
|
|
|
+ - 字段初始化器是语法糖,被编译器插到构造方法体最前面,并且**先于基类构造方法**执行(与 Java 相反)。
|
|
|
+ - 执行顺序:派生类字段初始化器 → 基类字段初始化器 → 基类 ctor 体 → 派生类 ctor 体。
|
|
|
+ - `readonly` 字段只能在声明处或构造方法内赋值,之后不可变,适合注入依赖。
|
|
|
+ - 值类型字段默认是 0/false,不会为 null;需要显式初始化的只有引用类型字段和引用类型属性。
|
|
|
+- **陷阱**:把"必须初始化"交给调用方(靠文档约定)迟早出事;用 `null!` 或 `= null!` 压制可空警告只是掩盖问题。构造方法中调用虚方法会让"已初始化"假设失效(见建议95)。
|
|
|
+
|
|
|
+### 建议94:区别对待override和new
|
|
|
+
|
|
|
+- **问题**:混用 `override` 和 `new` 会让"通过基类引用调用"落到与预期不同的方法上。
|
|
|
+- **规则**:要改写基类行为、需要多态分派时用 `virtual` + `override`;确实要隐藏基类同名成员才用 `new`,并加注释说明。
|
|
|
+- **要点**:
|
|
|
+ - `override` 改写虚方法表槽位,运行时按对象实际类型分派(动态绑定);`new` 只是声明一个同名新方法,切断与基类槽位的关系(按变量声明类型静态绑定)。
|
|
|
+ - 非虚方法永远按声明类型调用,`new` 与否都不影响基类引用调用基类版本。
|
|
|
+ - 不加修饰符隐藏基类成员等于隐式 `new`,编译器给 CS0108 警告;补上 `new` 只消除警告,不改变行为。
|
|
|
+ - `new` 只能隐藏,不能阻止基类引用调用基类实现;基类日后新增同名虚成员会让隐藏关系静默改变。
|
|
|
+ - `sealed override` 可终止后续派生类继续重写某个方法。
|
|
|
+- **陷阱**:以为写了 `new` 就能"覆盖"父类方法,结果 `Shape s = new Triangle(); s.MethodVirtual()` 仍输出基类实现;把 `new` 用在虚方法上尤其危险,因为它同时切断了已有多态链。
|
|
|
+```csharp
|
|
|
+Shape a = new Circle(); a.M(); // circle
|
|
|
+Shape b = new Triangle(); b.M(); // base
|
|
|
+Triangle c = new Triangle(); c.M(); // triangle
|
|
|
+
|
|
|
+class Shape { public virtual void M() => Console.WriteLine("base"); }
|
|
|
+class Circle : Shape { public override void M() => Console.WriteLine("circle"); }
|
|
|
+class Triangle : Shape { public new void M() => Console.WriteLine("triangle"); }
|
|
|
+```
|
|
|
+
|
|
|
+### 建议95:避免在构造方法中调用虚成员
|
|
|
+
|
|
|
+- **问题**:基类构造方法调用虚方法时,实际执行的是子类重写版本,而子类字段尚未初始化。
|
|
|
+- **规则**:构造方法中只调用非虚方法、`private` 方法或 `sealed` 方法;需要初始化钩子时,改由工厂方法或显式 `Initialize()` 在构造完成后调用。
|
|
|
+- **要点**:
|
|
|
+ - 基类 ctor 执行时,派生类字段还是 default 值(null/0),因为派生类 ctor 体尚未运行。
|
|
|
+ - 虚调用按实际类型分派,`this` 已存在但对象处于"半构造"状态,任何依赖派生类状态的重写都会读到未初始化值。
|
|
|
+ - 同理不要在 ctor 中发布 `this`:注册事件、启动线程、加入静态集合、传给外部方法都会让半成品对象逃逸。
|
|
|
+ - `sealed` 类中的方法、`private` 方法、静态方法不参与虚分派,可在 ctor 中安全调用。
|
|
|
+- **陷阱**:异常只是最明显的表现;更隐蔽的是重写方法读到 0/false 后做了错误初始化,程序不崩溃但状态永久错误。反序列化/`FormatterServices` 不经过构造方法,也会绕过这类初始化假设。
|
|
|
+```csharp
|
|
|
+class Person {
|
|
|
+ protected Person() => InitSkin(); // 危险:构造期虚调用
|
|
|
+ protected virtual void InitSkin() { }
|
|
|
+}
|
|
|
+class American : Person {
|
|
|
+ private Race race;
|
|
|
+ public American() { race = new Race { Name = "White" }; }
|
|
|
+ protected override void InitSkin() => Console.WriteLine(race.Name); // NullReferenceException
|
|
|
+}
|
|
|
+class Race { public string Name { get; set; } }
|
|
|
+```
|
|
|
+
|
|
|
+### 建议96:成员应优先考虑公开基类型或接口
|
|
|
+
|
|
|
+- **问题**:成员签名写死具体类型(`List<T>`、`Dictionary<K,V>`),同类功能要为每种集合重复实现,调用方也被绑死。
|
|
|
+- **规则**:公开成员(返回值、属性、字段)的类型尽量声明为基类或接口,如 `IEnumerable<T>`、`IList<T>`、`IDisposable`。
|
|
|
+- **要点**:
|
|
|
+ - `Enumerable.Empty<T>()` 返回 `IEnumerable<T>`,所有集合实现都能复用;内部返回按 `T` 缓存的单例 `EmptyEnumerable<T>.Instance`。
|
|
|
+ - 返回接口/基类让实现可替换(数组 → `List<T>` → 迭代器),也便于 mock 和后续优化。
|
|
|
+ - LINQ 的全部操作符都建立在 `IEnumerable<T>` 上,这正是"面向抽象"带来的复用规模。
|
|
|
+ - 需要"只读 + 索引"时选 `IReadOnlyList<T>`;需要"可枚举 + 计数"选 `IReadOnlyCollection<T>`,契约比 `IEnumerable<T>` 更精确。
|
|
|
+- **陷阱**:返回 `IEnumerable<T>` 会把延迟执行、多次枚举的坑丢给调用方(每次枚举可能重新查库);返回接口也丢失具体类型能力,调用方被迫强转。接口新增成员会破坏所有实现者。
|
|
|
+
|
|
|
+### 建议97:优先考虑将基类型或接口作为参数传递
|
|
|
+
|
|
|
+- **问题**:参数写成具体集合类型时,同一方法无法接收数组、`List<T>`、查询结果等其他集合。
|
|
|
+- **规则**:参数类型声明为基类或接口,并按方法真正需要的最小能力选择接口。
|
|
|
+- **要点**:
|
|
|
+ - `Enumerable.Take<TSource>(this IEnumerable<TSource> source, int count)` 因为参数是接口 + 扩展方法,才能作用于所有泛型集合。
|
|
|
+ - 只遍历用 `IEnumerable<T>`;要 `Count`/索引用 `IReadOnlyCollection<T>`/`IReadOnlyList<T>`;要修改才用 `ICollection<T>`/`IList<T>`。
|
|
|
+ - 参数接口越窄,可传入的类型越多,单元测试和 mock 越容易。
|
|
|
+ - 接口参数进入方法前先判 null 并抛 `ArgumentNullException`,这是 FCL 的惯例。
|
|
|
+- **陷阱**:参数声明为 `IEnumerable<T>` 后内部多次 `foreach`,传入延迟查询会重复执行;随手 `ToList()` 又改变了"是否复制"的语义并增加分配。参数类型过宽还会让调用方误以为方法会修改集合。
|
|
|
+
|
|
|
+### 建议98:用params减少重复参数
|
|
|
+
|
|
|
+- **问题**:参数个数不定、类型相同的多个重载被逐一重复声明,方法签名爆炸。
|
|
|
+- **规则**:用 `params T[]` 合并为一个方法;`params` 必须是最后一个参数,且是一维数组。
|
|
|
+- **要点**:
|
|
|
+ - 调用 `M()` 不传参时编译器传入长度为 0 的数组,不是 null;但调用方仍可显式传 `null`,需要时可判空。
|
|
|
+ - 每次调用都会新建数组(堆分配);热路径可保留固定参数重载,`params` 版本作兜底。
|
|
|
+ - 直接把已有数组传给 `params` 参数不会复制,方法内修改会影响调用方。
|
|
|
+ - 重载决议优先精确匹配的非 `params` 版本,`params` 版本优先级最低。
|
|
|
+ - C# 13 / .NET 9 起 `params` 支持 `ReadOnlySpan<T>`、`IEnumerable<T>` 等集合类型,`params ReadOnlySpan<T>` 可做到零堆分配。
|
|
|
+- **陷阱**:`params object[]` 会让值类型装箱;参数多到需要 `params` 往往说明应把参数封装成一个类型。`params` 版本与固定参数重载并存时,调用方可能意外命中不同实现。
|
|
|
+
|
|
|
+### 建议99:重写时不应使用子类参数
|
|
|
+
|
|
|
+- **问题**:在派生类里用"更派生的参数类型"写同名方法,实际是重载/隐藏而非重写,基类签名仍会被选中。
|
|
|
+- **规则**:重写必须保持参数列表完全一致;需要不同参数就另起方法名,或显式重载并说明调用规则。
|
|
|
+- **要点**:
|
|
|
+ - `SetSalary(Manager)` 与 `SetSalary(Employee)` 参数类型不同,构成重载;写在派生类中会隐藏基类方法,`new` 只消除 CS0108 警告。
|
|
|
+ - 重载决议在编译期按实参的静态类型选最匹配签名,`new Employee()` 只能匹配 `Employee` 版本。
|
|
|
+ - 强行加 `override` 会编译失败:签名不匹配,找不到可重写的成员(基类方法还必须是 `virtual`/`abstract`)。
|
|
|
+ - C# 9 的返回类型协变允许 override 返回更派生的类型,但参数类型不支持协变/逆变式重写。
|
|
|
+- **陷阱**:设计者以为在"设置经理薪水",实际输出"职员被设置了薪水";即使传 `new Manager()`,若变量声明为 `Employee` 仍会走基类版本。
|
|
|
+```csharp
|
|
|
+class Salary {
|
|
|
+ public virtual void SetSalary(Employee e) => Console.WriteLine("职员");
|
|
|
+}
|
|
|
+class ManagerSalary : Salary {
|
|
|
+ public override void SetSalary(Employee e) => Console.WriteLine("经理"); // 签名不变
|
|
|
+}
|
|
|
+class Employee { } class Manager : Employee { }
|
|
|
+```
|
|
|
+
|
|
|
+### 建议100:静态方法和实例方法没有区别
|
|
|
+
|
|
|
+- **问题**:误以为静态方法在加载/内存上更优,或误以为静态方法天然线程安全。
|
|
|
+- **规则**:按"是否依赖实例状态"选择 static / instance;线程安全靠显式同步实现,与 static 无关。
|
|
|
+- **要点**:
|
|
|
+ - 两者都只是类型对象方法表里的记录项,JIT 后的代码只有一份,不随实例复制;`new` 只分配实例字段。
|
|
|
+ - 唯一实质区别:实例方法有隐藏的 `this`,必须经实例调用;静态方法无 `this`,不能访问实例成员。
|
|
|
+ - 静态方法不能是 `virtual`/`abstract`/`override`;C# 11 起可实现接口的 `static abstract` 成员,但普通静态方法仍不参与实例多态。
|
|
|
+ - 静态字段是类型级状态,生命周期跟随 AppDomain/AssemblyLoadContext,比实例字段更容易演变成全局可变状态。
|
|
|
+ - 静态方法因无需解引用 `this` 更易被内联,但性能差异通常可忽略,不应作为设计依据。
|
|
|
+- **陷阱**:`static` 只表示"不依赖实例",不表示"无状态"或"线程安全";`static List<T> cache` 这类静态可变字段是最常见的并发 Bug 来源。`lock` 要锁私有静态对象,不能锁 `typeof(T)` 或字符串。
|
|
|
+
|
|
|
+### 建议101:使用扩展方法,向现有类型“添加”方法
|
|
|
+
|
|
|
+- **问题**:想给 sealed 类型、第三方类型或接口增加方法,却无法改源码,也不愿写包装器类。
|
|
|
+- **规则**:在非嵌套的静态类中定义 `public static 返回类型 M(this 目标类型 x, ...)`,调用方 `using` 该命名空间后即可 `x.M()`。
|
|
|
+- **要点**:
|
|
|
+ - 编译后就是普通静态调用 `StaticClass.M(x)`,只是语法上允许写成实例方法形式;无继承关系、无运行时开销。
|
|
|
+ - 可扩展 sealed 类、第三方类型和接口;LINQ 的 `Enumerable`/`Queryable` 正是扩展 `IEnumerable<T>`/`IQueryable<T>`。
|
|
|
+ - 约束:宿主必须是静态类且不能嵌套、方法必须静态、第一个参数加 `this`、不能扩展属性/事件/索引器(C# 14 前)。
|
|
|
+ - 实例方法优先:同名同签名的实例方法永远赢,扩展方法无法"覆盖"已有成员。
|
|
|
+ - `this` 参数可以是 null,扩展方法内部必须自行判空。
|
|
|
+- **陷阱**:调用方没 `using` 所在命名空间时方法"消失";扩展方法访问不到目标类型私有成员;给 `object` 或常用接口加扩展方法会污染所有类型的智能提示,且 FCL 未来新增同名实例方法时调用会静默改绑。
|
|
|
+时代注:C# 14(.NET 10,2025)引入 extension members,可在 `extension(T)` 块中声明扩展属性、事件和静态成员;此前只能扩展方法。
|
|
|
+
|
|
|
+## 第 8 章 类型设计
|
|
|
+
|
|
|
+### 建议102:区分接口和抽象类的应用场合
|
|
|
+
|
|
|
+- **问题**:接口与抽象类选错,要么版本升级破坏所有实现者,要么本该复用的代码无法复用。
|
|
|
+- **规则**:关系紧密、有多个相近版本、需要共享实现的用抽象类;关系松散、只需共同契约的用接口,且接口职责单一。
|
|
|
+- **要点**:
|
|
|
+ - 接口支持多实现,抽象类只能单继承;接口不能有实例字段和实例构造方法,抽象类可以有。
|
|
|
+ - 抽象类可提供默认实现,子类零代码获得共性行为;给抽象类加非抽象方法不破坏任何子类。
|
|
|
+ - 接口加成员会破坏所有实现者(C# 8 默认接口实现可给默认体,但要求 .NET Core 3.0+)。
|
|
|
+ - 判据:抽象类 = "Is a"(`FileStream` is a `Stream`),接口 = "Can do"(`IDisposable`、`IComparable<T>`)。
|
|
|
+ - 接口越小越可复用:`IEnumerable<T>` 只有 `GetEnumerator()`,`IDisposable` 只有 `Dispose()`。
|
|
|
+- **陷阱**:为了复用代码把无关类型塞进同一抽象基类,造出错误的 Is-a 关系;接口塞十几个成员,任何实现者都被迫实现无关功能。`Stream` 这类"多版本 + 大量共性实现"的场景用接口会丢掉落脚点。
|
|
|
+
|
|
|
+### 建议103:区分组合和继承的应用场合
|
|
|
+
|
|
|
+- **问题**:滥用继承导致继承链过深、子类被动继承大量无关公开成员、基类改动波及所有子类。
|
|
|
+- **规则**:优先组合;只有确实是 Is-a 且需要多态替换时才继承;继承层次到 3 层左右就该停下。
|
|
|
+- **要点**:
|
|
|
+ - 继承 = Is-a,组合 = Has-a(`Thread` 持有 `Context`、`CultureInfo`)。
|
|
|
+ - 继承的核心收益是多态:`Stream` 作返回值/参数可接受 `FileStream`、`MemoryStream`;组合不具备这种替换能力。
|
|
|
+ - 继承的成本:子类自动获得基类全部 public/protected 成员,破坏封装;基类新增成员会"污染"所有子类。
|
|
|
+ - 组合可组合任意多个对象,并精确控制暴露哪些行为(手写转发);组合过多说明当前类职责过多,应拆分。
|
|
|
+ - 既要多态又要组合时,可实现接口并在内部持有对象、转发调用。
|
|
|
+- **陷阱**:为了"看起来面向对象"而继承;把"代码复用"当继承理由——复用应优先用组合或工具类。C# 单继承 + 接口多实现,继承链一旦定错很难回头。
|
|
|
+
|
|
|
+### 建议104:用多态代替条件语句
|
|
|
+
|
|
|
+- **问题**:用 `if`/`switch` 按枚举分支处理行为,每加一种命令都要改同一个方法,违反开闭原则。
|
|
|
+- **规则**:把每种分支抽成实现同一抽象(抽象类或接口)的类型,调用方只依赖抽象并调用其方法。
|
|
|
+- **要点**:
|
|
|
+ - `abstract class Commander { public abstract void Execute(); }`,`StartCommander`/`StopCommander` 各自实现;`Drive(Commander c) => c.Execute()` 不再随命令增加而修改。
|
|
|
+ - 新增命令 = 新增子类 + 在创建处注册,`Drive` 保持不变(对扩展开放、对修改关闭)。
|
|
|
+ - 分支逻辑分散到各自类内,避免上百行同构的 `if 命令 then 行动`。
|
|
|
+ - 创建端仍需选择具体类型:可用工厂或 `Dictionary<Command, Commander>` 收敛最后一处 switch。
|
|
|
+ - 只有两三个分支且行为稳定时,`if`/`switch` 更直观,不要为模式而模式。
|
|
|
+- **陷阱**:多态只是把"分支选择"从方法内挪到创建处,创建处仍是修改点;命令若需要不同参数/返回值,抽象方法签名会迅速臃肿。
|
|
|
+
|
|
|
+### 建议105:使用私有构造函数强化单例
|
|
|
+
|
|
|
+- **问题**:只提供 `Instance` 属性却没封住构造方法,外部仍可 `new`,单例失效。
|
|
|
+- **规则**:`sealed` 类 + `private` 构造方法 + 静态只读实例(或 `Lazy<T>`)对外暴露。
|
|
|
+- **要点**:
|
|
|
+ - 不写构造方法时编译器生成 public 无参 ctor,必须显式写 `private Singleton() { }`。
|
|
|
+ - `static readonly Singleton Instance = new Singleton();` 由 CLR 类型初始化器保证线程安全且只执行一次,是最简正确写法。
|
|
|
+ - 经典双检锁必须用 `volatile` 修饰实例字段,否则弱内存模型下可能读到未完全构造的对象。
|
|
|
+ - `sealed` 防止子类通过继承绕过私有构造、产生额外实例。
|
|
|
+ - .NET 4+ 可用 `Lazy<T>(LazyThreadSafetyMode.ExecutionAndPublication)`,语义更清晰。
|
|
|
+- **陷阱**:书上 `return instance == null ? new Singleton() : instance;` 既没写回 `instance` 也非线程安全——每次访问都会新建对象;多线程下无 `volatile` 的双检锁同样不严谨。
|
|
|
+```csharp
|
|
|
+public sealed class Singleton {
|
|
|
+ private static readonly Singleton instance = new Singleton();
|
|
|
+ private Singleton() { }
|
|
|
+ public static Singleton Instance => instance;
|
|
|
+}
|
|
|
+```
|
|
|
+时代注:现代项目更常用 DI 容器注册单例(`AddSingleton<T>()`),可测试性和生命周期管理更好;`Lazy<T>` 适合确需延迟初始化的场景。
|
|
|
+
|
|
|
+### 建议106:为静态类添加静态构造函数
|
|
|
+
|
|
|
+- **问题**:静态字段初始化器抛异常时,调用方收到 `TypeInitializationException`,类型内部没有机会处理。
|
|
|
+- **规则**:静态引用类型字段的初始化放进静态构造方法,用 try/catch 在类型内部消化可预期异常并降级。
|
|
|
+- **要点**:
|
|
|
+ - 静态构造方法:无访问修饰符、无参数、不能显式调用,在首次访问任何静态成员(或创建实例)前由 CLR 执行一次,每个封闭泛型类型各执行一次。
|
|
|
+ - 静态字段初始化器会被编译进静态构造方法,按声明顺序执行;存在显式静态 ctor 时类型不加 `beforefieldinit`,初始化时机更确定。
|
|
|
+ - 静态 ctor 抛出的异常被包装为 `TypeInitializationException`,真实原因在 `InnerException`;类型此后不可用,再次访问会重复抛出。
|
|
|
+ - 只有值类型静态字段(默认值安全、初始化不会失败)时,可以省掉静态 ctor。
|
|
|
+- **陷阱**:把可能失败的 I/O、配置读取塞进静态字段初始化器,一次失败就让整个类型报废。catch 后字段是 default 值,后续使用仍需判空。
|
|
|
+```csharp
|
|
|
+static class Config {
|
|
|
+ private static readonly string Text;
|
|
|
+ static Config() {
|
|
|
+ try { Text = File.ReadAllText("config.json"); }
|
|
|
+ catch (IOException) { Text = ""; } // 记录并降级,异常不外泄
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议107:区分静态类和单例
|
|
|
+
|
|
|
+- **问题**:把静态类当作单例使用,丢掉继承、多态、传参和依赖注入能力。
|
|
|
+- **规则**:需要"唯一实例"且要参与多态/注入/传参时用单例类;纯工具函数才用静态类。
|
|
|
+- **要点**:
|
|
|
+ - 静态类不能继承、不能实现接口、不能作为类型参数/参数/返回值/字段类型,编译期直接报错。
|
|
|
+ - 静态类没有实例,无法延迟初始化(首次访问即触发类型初始化),也无法被替换或 mock。
|
|
|
+ - 单例是"被限制为一个实例的普通对象",可继承、可实现接口、可传参、可交给 DI 管理。
|
|
|
+ - 静态类适合无状态纯函数集合(`Math`、`Enumerable`)和扩展方法宿主。
|
|
|
+- **陷阱**:`static class SampleClass : ISample` 编译失败,`void M(SampleClass s)` 也编译失败——静态类不是"真正的对象"。用静态类持有可变状态会让测试相互污染、无法并行。
|
|
|
+
|
|
|
+### 建议108:将类型标识为sealed
|
|
|
+
|
|
|
+- **问题**:可被继承却从未被继承的类型,留下无谓的继承点、protected 成员和虚调用开销。
|
|
|
+- **规则**:确认不需要被继承的类型标记 `sealed`;密封类中不要再声明 protected 成员。
|
|
|
+- **要点**:
|
|
|
+ - `sealed class` 禁止派生,编译器报"无法从密封类型派生"。
|
|
|
+ - `sealed override` 只密封某个重写方法,阻止更深层继续重写,其余成员仍可被继承。
|
|
|
+ - 密封类让 JIT 更容易做去虚化和内联;更重要的价值是表达设计意图、限制继承深度。
|
|
|
+ - 值类型(struct)隐式 sealed,不能作为基类;`sealed` 也不能与 `abstract` 同时使用。
|
|
|
+- **陷阱**:给密封类写 protected 成员没有任何意义(没有子类能访问),编译器不报错,属于死代码和误导。后期真需要继承时去掉 `sealed` 要重新编译所有引用方。
|
|
|
+
|
|
|
+### 建议109:谨慎使用嵌套类
|
|
|
+
|
|
|
+- **问题**:随意嵌套类型造成可见性混乱、外部无法引用、测试困难。
|
|
|
+- **规则**:只有内层类型需要访问外层类型的私有成员时才嵌套,且嵌套类型声明为 `private`。
|
|
|
+- **要点**:
|
|
|
+ - C# 嵌套类没有隐式的外层实例引用(相当于 Java 的 static nested class),访问外层实例必须显式传入引用。
|
|
|
+ - 嵌套类型可访问外层类型的私有/受保护成员,这是 `ArrayList` 内部迭代器 `ArrayListEnumeratorSimple` 的用法。
|
|
|
+ - 嵌套类型作为成员默认可见性是 `private`,顶层类型默认是 `internal`。
|
|
|
+ - 嵌套类型的全名是 `Outer+Nested`,反射、序列化、`Type.GetType` 都要注意这个 `+`。
|
|
|
+- **陷阱**:把嵌套类当命名空间用,导致外部无法引用、无法单独测试;外层持有嵌套实例、嵌套又持有外层引用时容易形成生命周期泄漏。`public` 嵌套类会直接出现在外部类型的 API 面上。
|
|
|
+
|
|
|
+### 建议110:用类来代替enum
|
|
|
+
|
|
|
+- **问题**:枚举无法携带方法、中文描述或附加行为,要显示"星期一"只能靠特性 + 反射。
|
|
|
+- **规则**:需要行为、描述、可扩展时用"密封类 + private 构造 + static readonly 实例"模拟枚举;否则保留 enum。
|
|
|
+- **要点**:
|
|
|
+ - 枚举是值类型:无堆分配、可比较、可 `switch`、可作 `const` 和特性参数、可直接存数据库;类方案全部失去。
|
|
|
+ - 类方案可重写 `ToString()`、实现 `IComparable`、添加方法和属性,行为更丰富。
|
|
|
+ - enum 想带描述需自定义 `Attribute` + `FieldInfo.GetCustomAttributes` 反射,运行时开销大且难以本地化。
|
|
|
+ - 类方案实例是引用类型,默认按引用比较,必须重写 `Equals`/`GetHashCode`(或用 `record`/`readonly struct`)才有值语义。
|
|
|
+ - 适合 enum 的场景:高频调用、仅内部使用、不需要附加行为或特性。
|
|
|
+- **陷阱**:`static readonly` 字段不是编译期常量,不能用于 `case` 标签、特性参数或 `const`;反序列化/跨进程序列化后可能得到不同实例,`==` 判定失败。
|
|
|
+```csharp
|
|
|
+public sealed class Week {
|
|
|
+ public static readonly Week Monday = new(0, "星期一");
|
|
|
+ public static readonly Week Tuesday = new(1, "星期二");
|
|
|
+ private Week(int id, string name) { Id = id; Name = name; }
|
|
|
+ public int Id { get; }
|
|
|
+ public string Name { get; }
|
|
|
+ public override string ToString() => Name;
|
|
|
+}
|
|
|
+```
|
|
|
+时代注:C# 9 的 `record` / `record struct` 可用位置记录简化"值对象枚举",`readonly record struct Week` 同时具备值语义和自定义方法;C# 7.3 起可用 `where T : struct, Enum` 约束,enum 工具方法也更易写。
|
|
|
+
|
|
|
+### 建议111:避免双向耦合
|
|
|
+
|
|
|
+- **问题**:两个类型互相引用,在同一程序集内只是设计问题,拆成两个项目后直接编译失败(循环依赖)。
|
|
|
+- **规则**:把被依赖的行为抽成接口,放到独立程序集/命名空间,双方都只依赖抽象。
|
|
|
+- **要点**:
|
|
|
+ - CLR/.NET 不允许程序集循环引用(项目引用成环直接报错),类型级双向引用在单程序集内合法。
|
|
|
+ - 解耦方式:定义 `ISample`,`A : ISample`,`B` 的字段类型改为 `ISample` 并调用 `a.MethodA()`。
|
|
|
+ - 接口所在程序集被 A、B 共同引用,依赖方向变为单向:A → 抽象 ← B。
|
|
|
+ - 更彻底的做法是事件/消息、mediator 或 DI 容器,让 A 根本不持有 B 的具体引用。
|
|
|
+- **陷阱**:A、B 放在同一项目里"能编译"就以为没问题,按层拆分时才暴露;抽了接口但 B 的字段仍声明为 `A`,等于没解耦。
|
|
|
+
|
|
|
+### 建议112:将现实世界中的对象抽象为类,将可复用对象圈起来就是命名空间
|
|
|
+
|
|
|
+- **问题**:类型无组织地堆在同一命名空间/文件夹,项目变大后无法定位、复用和分层。
|
|
|
+- **规则**:把具有共同属性和行为的对象抽象为类型;把职责相近、可复用的类型归入同一命名空间(对应文件夹)。
|
|
|
+- **要点**:
|
|
|
+ - 类型 = 属性(字段/属性)+ 行为(方法)+ 事件;对象 = 类型的实例,`new` 才在 CLR 中真正分配内存。
|
|
|
+ - 现实分类是树形结构,命名空间就是这棵树:`Company.Product.Module`,每个节点是一组高内聚类型。
|
|
|
+ - 命名空间与程序集正交:一个程序集可含多个命名空间,一个命名空间也可跨程序集;命名空间只影响类型全名和 `using`。
|
|
|
+ - 划分应跟随复用边界和依赖方向,而不是单纯按"所有 Model 放一起"这类技术分类。
|
|
|
+- **陷阱**:命名空间过深(超过 4~5 层)或与文件夹不一致会造成引用混乱;全塞进默认命名空间,后期移动/重命名的代价极高。敏捷项目不必一次定死,但每次迭代都应重构整理。
|
|
|
+
|
|
|
+## 第 9 章 安全性设计
|
|
|
+
|
|
|
+### 建议113:声明变量前考虑最大值
|
|
|
+
|
|
|
+- **问题**:为业务数值选了过小的整型,累加/自增到边界后静默回绕,把工资算成 0 却没有任何报错。
|
|
|
+- **规则**:声明存储量、金额、计数、求和结果的变量前先估算上界,按上界选类型;无法确定时用 `long`/`decimal`,不要用 `ushort`、`byte`、`short` 省几个字节。
|
|
|
+- **要点**:
|
|
|
+ - 整数运算默认是 `unchecked`:`ushort 65535 + 1` 回绕为 0,`int.MaxValue + 1` 回绕为 `int.MinValue`,全程无异常。
|
|
|
+ - `checked { ... }` / `checked(expr)` 让溢出与显式数值转换抛 `System.OverflowException`;也可用编译器开关 `/checked+` 全局开启,默认是 `/checked-`。
|
|
|
+ - `ushort + int` 的结果类型是 `int`,必须显式 `(ushort)` 回转;这个显式转换同样受 `checked` 保护,这才是本建议的关键点。
|
|
|
+ - `checked` 对 `float`/`double` 无效(溢出得到 `±Infinity`、`NaN`),对 `decimal` 也无需开启(`decimal` 溢出永远抛 `OverflowException`)。
|
|
|
+ - 边界判定也可用 `Convert.ToInt32`、`Math.BigMul`、`decimal` 或 `BigInteger` 替代,但会带来装箱/分配开销。
|
|
|
+- **陷阱**:真正的高危场景不是这种极端初值,而是"千万级数据表求和"——`int` 装 `SUM` 结果、`int` 装文件字节数、`int` 装毫秒时间戳,都属同类错误。
|
|
|
+```csharp
|
|
|
+ushort salary = 65534;
|
|
|
+checked
|
|
|
+{
|
|
|
+ salary = (ushort)(salary + 1); // 65535
|
|
|
+ salary = (ushort)(salary + 1); // OverflowException:算术运算导致溢出
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议114:MD5不再安全
|
|
|
+
|
|
|
+- **问题**:直接把密码的 MD5 存库,看似不可逆,实则被字典表和穷举在毫秒级还原。
|
|
|
+- **规则**:密码不做"快速哈希",改用带随机 salt、迭代次数可调的慢哈希(PBKDF2/bcrypt/scrypt/Argon2);每个用户独立 salt,迭代次数随硬件提升。
|
|
|
+- **要点**:
|
|
|
+ - MD5 输出 128 位(16 字节、32 个十六进制字符);`MD5.ComputeHash` 对任意长度输入做压缩映射,理论不可逆,但"不可逆"不等于"不可猜"。
|
|
|
+ - 攻击成本来自候选空间而非算法:`0000`–`9999` 的 1 万个候选,原文实测 271 ms 即破解。
|
|
|
+ - 加盐只挡预计算彩虹表,挡不住单条口令的穷举;迭代(如 1000 次以上、现代建议 10 万次量级)才把每次尝试的成本抬上去。
|
|
|
+ - 原文的"MD5( MD5(pwd) + MD5(固定hashKey) )"只是硬编码 pepper + 固定次数,salt 全局共享,无法抵御针对整库的批量破解。
|
|
|
+ - 比对哈希要定长比较(`CryptographicOperations.FixedTimeEquals`),避免按字节短路造成的计时侧信道。
|
|
|
+- **陷阱**:反例:`StringComparer.OrdinalIgnoreCase` 比对哈希(语义上无错但引入计时差异);把 salt 写成 `static byte[]` 全库共用;用 `UTF8Encoding.Default.GetBytes` 之外的编码导致跨平台校验不一致。 - 时代注:MD5 的抗碰撞性早在 2004 年被攻破,`MD5CryptoServiceProvider` 在 .NET 6+ 已标记过时(SYSLIB0021);新代码用 `SHA256.HashData` 或 `Rfc2898DeriveBytes.Pbkdf2`。
|
|
|
+```csharp
|
|
|
+static byte[] Derive(string password, byte[] salt, int iterations)
|
|
|
+{
|
|
|
+ // salt 每用户随机 16 字节,和迭代次数一起入库;升级算法时随哈希值存版本号
|
|
|
+ using var kdf = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256);
|
|
|
+ return kdf.GetBytes(32);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议115:通过HASH来验证文件是否被篡改
|
|
|
+
|
|
|
+- **问题**:需要判断收到的文件是否与发送方发出的完全一致(传输损坏或被人替换)。
|
|
|
+- **规则**:对文件整体求加密哈希,接收端重算并比对;哈希值必须通过独立可信渠道获得,不能和文件放在一起。
|
|
|
+- **要点**:
|
|
|
+ - `HashAlgorithm.ComputeHash(Stream)` 流式读取,内存占用与文件大小无关;不要 `File.ReadAllBytes` 后再算。
|
|
|
+ - `new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)` 允许其他进程同时只读打开,避免校验时锁死文件。
|
|
|
+ - 雪崩效应:1 GB 文件只改 1 字节,哈希值也完全不同(原文示例 MD5 由 `8F3A…` 变为 `AB46…`)。
|
|
|
+ - 哈希只保证"内容与某个值一致",不保证"来源可信";要与数字签名/Authenticode 或 TLS 通道配合,否则攻击者可同时替换文件和哈希值。
|
|
|
+ - 大文件/多次校验可用 `IncrementalHash` 分块喂入,避免一次性持有流。
|
|
|
+- **陷阱**:反例:拿 MD5 当"防篡改"用于安全场景(可构造碰撞);把期望哈希硬编码进同一份可被替换的配置文件;用 `==` 逐字符比对长哈希。
|
|
|
+```csharp
|
|
|
+static string FileHash(string path)
|
|
|
+{
|
|
|
+ using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
|
|
+ return Convert.ToHexString(SHA256.HashData(fs)); // .NET 5+
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议116:避免用非对称算法加密文件
|
|
|
+
|
|
|
+- **问题**:用 RSA 直接加密大文件,速度极慢且受"单块长度上限"限制,实际上根本加不完。
|
|
|
+- **规则**:文件/大数据用对称算法(AES),非对称算法只用来加密那把对称密钥(混合加密)。
|
|
|
+- **要点**:
|
|
|
+ - 对称:加解密共用一个密钥,效率高、开销小,适合大数据量;密钥需通过秘密通道分发。
|
|
|
+ - 非对称:公钥加密、私钥解密,私钥永不出门,但算法复杂、慢若干数量级,且明文分块上限为 `KeySize/8 - 11`(PKCS#1 v1.5 填充;1024 位约 117 字节/块),OAEP 开销更大。
|
|
|
+ - salt 用于抵御字典攻击(密钥导出前混入随机字节);IV 用于让相同明文块产生不同密文,它不是密钥,可以公开,但必须每条消息随机且不重复。
|
|
|
+ - 原文用 `Rijndael.Create()` + `KeySize=256`,实际就是 AES-256;`PaddingMode.PKCS7` 是块加密的标准填充。
|
|
|
+ - 密钥派生用 `PasswordDeriveBytes`(PBKDF1 变体)强度不足,应改 `Rfc2898DeriveBytes`(PBKDF2)并提高迭代次数。
|
|
|
+- **陷阱**:反例:原文把 `salt` 和 `iv` 写成 `static readonly` 固定值——同一密钥下重复 IV 会让相同明文前缀产生相同密文,泄露内容相等性;解密端按 `inFileStream.Length / bufferSize` 手工分块,遇到 PKCS7 填充会算错长度,正确做法是循环 `Read` 到 0 或直接 `CopyTo`。 - 时代注:`RijndaelManaged`/`Rijndael.Create` 在 .NET Core 起不推荐(SYSLIB0022),用 `Aes.Create()`;`PasswordDeriveBytes` 在 .NET 6+ 过时(SYSLIB0041)。
|
|
|
+```csharp
|
|
|
+using var aes = Aes.Create();
|
|
|
+aes.KeySize = 256;
|
|
|
+aes.GenerateIV(); // 每条消息新 IV,随密文一起存储
|
|
|
+byte[] key = aes.Key; // 这段 32 字节只用 RSA-OAEP 加密后传输
|
|
|
+using var enc = aes.CreateEncryptor();
|
|
|
+```
|
|
|
+
|
|
|
+### 建议117:使用SSL确保通信中的数据安全
|
|
|
+
|
|
|
+- **问题**:明文 Socket 传输可被嗅探和篡改,需要一条"密钥协商用非对称、数据加密用对称"的安全通道。
|
|
|
+- **规则**:不要手写握手协议,直接用 TLS(`SslStream`)或 HTTPS;让框架完成证书校验、密钥协商、MAC 与重放保护。
|
|
|
+- **要点**:
|
|
|
+ - 握手本质:客户端生成随机密钥 K → 用服务器公钥加密 → 服务器私钥解密得到 K → 之后双方用 K 做对称加解密。
|
|
|
+ - 非对称只保护"密钥分发"这一小段,大数据仍走对称算法,这正是建议116 结论的延伸。
|
|
|
+ - 现代 TLS 用 ECDHE 做密钥交换,具备前向保密:服务器私钥日后泄露也解不开历史流量;TLS 1.3 已删除 RSA 密钥交换。
|
|
|
+ - `SslStream.AuthenticateAsClient/Async`、`AuthenticateAsServer/Async` 完成握手;证书校验回调返回 `true` 等于关掉身份认证。
|
|
|
+ - 原文示例用 `<EOF>`、`<KEY>` 文本标记切分消息,且无 MAC、无序号、无重放防护,只是"演示原理",不是可用协议。
|
|
|
+- **陷阱**:反例:`ServicePointManager.ServerCertificateValidationCallback = (a,b,c,d) => true`;只用公钥加密消息却不校验对方身份(中间人可以换公钥);把密钥硬编码成 `"key123"`;`BeginReceive` 里假定一次接收就是一条完整消息。 - 时代注:`ServicePointManager.SecurityProtocol` 在 .NET Core 起已无效,改用 `SslClientAuthenticationOptions`;SSL 3.0/TLS 1.0/1.1 已被主流平台禁用。
|
|
|
+```csharp
|
|
|
+using var ssl = new SslStream(networkStream, leaveInnerStreamOpen: false,
|
|
|
+ (s, cert, chain, errors) => errors == SslPolicyErrors.None); // 不要无条件返回 true
|
|
|
+await ssl.AuthenticateAsClientAsync("example.com", null,
|
|
|
+ SslProtocols.Tls12 | SslProtocols.Tls13, checkCertificateRevocation: true);
|
|
|
+```
|
|
|
+
|
|
|
+### 建议118:使用SecureString保存密钥等机密字符串
|
|
|
+
|
|
|
+- **问题**:`string` 不可变,密码用完后原文仍留在托管堆(字面量还在 intern 池),可被内存扫描工具抓走。
|
|
|
+- **规则**:机密文本用 `SecureString` 逐字符追加,用完立即 `Dispose`;必须取出明文时,缩短存活时间并主动清零非托管缓冲。
|
|
|
+- **要点**:
|
|
|
+ - `string` 的任何"修改"都产生新对象,旧对象只等 GC;GC 只回收不擦除,进程崩溃转储里仍是明文。
|
|
|
+ - `SecureString` 在 Windows 上用 DPAPI 加密内容,`AppendChar` 逐字符写入,`MakeReadOnly()` 之后不可再改,`Dispose()` 释放。
|
|
|
+ - `Marshal.SecureStringToBSTR` 生成非托管 BSTR,用 `Marshal.PtrToStringBSTR` 读出;用完必须 `Marshal.ZeroFreeBSTR(addr)` 清零并释放。
|
|
|
+ - 托管侧那份 `string temp` 无法真正擦除,只能用 `fixed` 固定地址后 `WriteProcessMemory` 覆写(原文做法),或在设计上避免产生它。
|
|
|
+ - 相关 API:`SecureStringToGlobalAllocUnicode`/`SecureStringToCoTaskMemUnicode` 及对应的 `ZeroFree*`。
|
|
|
+- **陷阱**:反例:先把密码存进 `string` 再 `AppendChar`(明文早已进堆);忘记 `ZeroFreeBSTR` 造成非托管内存泄漏且长期驻留;把 `SecureString` 当跨平台方案。 - 时代注:微软文档已明确不推荐在新代码中使用 `SecureString`——.NET Core 在非 Windows 平台上并不加密其内容,"加密"只是 Windows 上的实现细节;现代替代是 `Span<char>` + `CryptographicOperations.ZeroMemory`,并尽量缩短机密数据生命周期。
|
|
|
+```csharp
|
|
|
+using var secret = new SecureString();
|
|
|
+foreach (char c in Console.ReadLine()) secret.AppendChar(c);
|
|
|
+secret.MakeReadOnly();
|
|
|
+
|
|
|
+IntPtr p = Marshal.SecureStringToBSTR(secret);
|
|
|
+try { /* 尽量只在这一小段内使用明文 */ }
|
|
|
+finally { Marshal.ZeroFreeBSTR(p); }
|
|
|
+```
|
|
|
+
|
|
|
+### 建议119:不要使用自己的加密算法
|
|
|
+
|
|
|
+- **问题**:以为"算法保密 = 安全",自创一套换表/异或/移位算法,被逆向或差分分析后全线失守。
|
|
|
+- **规则**:算法用公开且经过审查的标准实现(AES、RSA、ECDSA、SHA-2),把精力全部放在密钥的生成、存储、轮换和销毁上。
|
|
|
+- **要点**:
|
|
|
+ - 柯克霍夫原则:系统安全应只依赖密钥,不依赖算法保密;DES、AES、RSA、RC4、TEA、MD5 的算法全部公开。
|
|
|
+ - 反编译与内存分析工具可以直接还原程序里的算法与硬编码密钥,混淆只是提高阅读成本,不是加密。
|
|
|
+ - 密钥管理必须落到流程:HSM/KMS/DPAPI/证书存储,按环境隔离,定期轮换,离职/泄露可吊销。
|
|
|
+ - 优先用 FCL 高层 API(`Aes`、`RSA`、`ECDsa`、`AesGcm`),而不是自己拼块模式、填充、IV 和 MAC。
|
|
|
+ - 认证加密优先 `AesGcm`(.NET Core 3.0+),避免"先加密再自己算 MAC"的顺序错误。
|
|
|
+- **陷阱**:自写"异或密钥流"复用密钥流即等于两次密文异或出明文;自创 Base64/字符替换当加密;把密钥写在源码、配置或数据库中与密文放在一起;用 `Random` 生成密钥或 IV(必须 `RandomNumberGenerator`)。
|
|
|
+
|
|
|
+### 建议120:为程序集指定强名称
|
|
|
+
|
|
|
+- **问题**:同名 DLL 被不同版本覆盖或冒名替换,出现 "DLL Hell" 与加载错程序集。
|
|
|
+- **规则**:为程序集生成 `.snk` 密钥文件并签名,使程序集标识包含公钥与签名;`.snk` 按机密文件管理。
|
|
|
+- **要点**:
|
|
|
+ - 强名称标识 = 程序集名称 + 版本号 + 区域性 + 公钥 + 数字签名;任一不同即视为不同程序集,可并存。
|
|
|
+ - 生成密钥:`sn -k yourprofile.snk`;导出公钥 `sn -p`;查看 `sn -tp`;VS 项目属性"签名"页可直接勾选。
|
|
|
+ - 项目文件中通过 `<SignAssembly>true</SignAssembly>` + `<AssemblyOriginatorKeyFile>` 指定;也可延迟签名(`DelaySign`)后用 `sn -R` 补签。
|
|
|
+ - 强名称只保证"这个程序集确实由持私钥者签发",不保证代码无恶意;它防的是替换与版本混淆,不是防破解。
|
|
|
+ - 引用方可通过 `PublicKeyToken` 精确绑定版本,避免被同名低版本劫持。
|
|
|
+- **陷阱**:把 `.snk` 提交进公开仓库(私钥泄露即可伪造签名);以为强名称能阻止反编译(只能防篡改标识);在 .NET Core/.NET 5+ 上仍把强名称当安全边界。
|
|
|
+- 时代注:.NET Core 及 .NET 5+ 运行时不再校验强名称签名,`sn -Vr` 跳过验证只在 .NET Framework 有意义;真实来源信任应使用 Authenticode/代码签名证书。
|
|
|
+
|
|
|
+### 建议121:为应用程序设定运行权限
|
|
|
+
|
|
|
+- **问题**:需要限制某些功能只允许管理员或特定角色使用,而不是靠界面隐藏按钮。
|
|
|
+- **规则**:用基于角色的主体(Principal)做声明式或命令式权限检查,把校验放在被保护的类型/方法上,而不是调用方。
|
|
|
+- **要点**:
|
|
|
+ - 先设置主体策略:`AppDomain.CurrentDomain.SetPrincipalPolicy(PrincipalPolicy.WindowsPrincipal)`,否则 `Thread.CurrentPrincipal` 不是 Windows 主体。
|
|
|
+ - 声明式:`[PrincipalPermission(SecurityAction.Demand, Role = "Administrator")]` 可加在类或方法上;同类多条属性之间是 OR,类级与方法级之间是 AND(两层都要过)。
|
|
|
+ - 命令式:`new PrincipalPermission("ExamUser", "Teacher").Demand()`,失败抛 `SecurityException`,可自行捕获降级。
|
|
|
+ - 自定义身份:`new GenericPrincipal(new GenericIdentity("ExamUser"), new[] { "Teacher" })` 赋给 `Thread.CurrentPrincipal`,再配合 `Demand()` 做业务角色校验。
|
|
|
+ - 声明式检查在 JIT 时/调用进入时生效,无法被调用方绕过;仅在前端隐藏控件不构成授权。
|
|
|
+- **陷阱**:只校验角色不校验身份;在 `try/catch` 里吞掉 `SecurityException` 后继续执行敏感逻辑;把角色名写成可配置字符串导致拼写错误即"永远拒绝"或"永远放行";忘记 `SetPrincipalPolicy` 导致 `Demand` 对空主体失败。
|
|
|
+- 时代注:CAS 与 `PrincipalPermission` 在 .NET Core/.NET 5+ 中已移除(相关 API 过时,`Demand` 在部分平台抛 `PlatformNotSupportedException`);现应使用 `WindowsIdentity`/`ClaimsPrincipal.IsInRole`、ASP.NET Core 授权策略或 `[Authorize(Roles = "...")]`。
|
|
|
+
|
|
|
+## 第 10 章 命名规范
|
|
|
+
|
|
|
+### 建议122:以<Company>.<Component>为命名空间命名
|
|
|
+
|
|
|
+- **问题**:只用模块名(如 `Utility`)作命名空间,与第三方同名模块撞车,导致类型冲突。
|
|
|
+- **规则**:命名空间用 `<Company>.<Component>` 两级起步,公司/组织名在前;个人项目用倒序域名。
|
|
|
+- **要点**:
|
|
|
+ - 示例:`Microsoft.Windows.Design`、`Contoso.Ordering`;子模块继续分层 `Contoso.Ordering.Domain`。
|
|
|
+ - 域名法:`www.microsoft.com` → `Com.Microsoft.<Component>`(Java 世界常用,.NET 中也可,但主流仍是 `Microsoft.xxx` 顺序)。
|
|
|
+ - 公司名 + 组件名能保证全局唯一性,避免与 FCL、第三方库的同名类型冲突。
|
|
|
+ - 命名空间是逻辑分组,应与程序集/目录结构大致对应,便于定位。
|
|
|
+ - 不要占用 `System`、`Microsoft` 等保留前缀,也不要自造单段命名空间(`Utility`、`Common`、`Helpers`)。
|
|
|
+- **陷阱**:`namespace Utility` 与第三方 `Utility` 同时 `using` 时出现 CS0104 引用不明确;把公司名写成产品名,产品改名后全库命名空间返工;命名空间层级与物理目录完全脱节。
|
|
|
+
|
|
|
+### 建议123:程序集不必与命名空间同名
|
|
|
+
|
|
|
+- **问题**:误以为程序集名必须等于命名空间名,或反过来为一个命名空间强拆多个奇怪命名的 dll。
|
|
|
+- **规则**:程序集是物理分组、命名空间是逻辑分组,二者无强制关系;类库建议同名,可执行程序按产品名命名。
|
|
|
+- **要点**:
|
|
|
+ - 类库同名好处:`System.Data` ↔ `System.Data.dll`,开发者只凭文件名就能猜出命名空间,便于查找与引用。
|
|
|
+ - 可执行程序通常不同名:`Microsoft.Office.Word.exe` 很别扭,实际是 `Word.exe`;入口程序集名对使用者不可见。
|
|
|
+ - 一个程序集可包含多个命名空间,一个命名空间也可跨多个程序集(如 `System` 分散在 mscorlib、System.dll 等)。
|
|
|
+ - 程序集名过泛(`Data.dll`、`Common.dll`)会重蹈建议122 的冲突问题。
|
|
|
+ - 程序集名还参与绑定标识(配合强名称的版本/公钥),改名等于换标识,需同步更新引用。
|
|
|
+- **陷阱**:为了"同名"把多个不相关命名空间硬塞进一个 dll;把 `RootNamespace` 与 `AssemblyName` 混淆(VB 的根命名空间会自动加前缀,C# 不会);dll 改名后忘记更新 `AssemblyInfo`/项目引用。
|
|
|
+
|
|
|
+### 建议124:考虑在命名空间中使用复数
|
|
|
+
|
|
|
+- **问题**:一组功能相近的类型放在同一命名空间时,单数命名无法表达"这是一类东西的集合"。
|
|
|
+- **规则**:命名空间里装的是同类类型的集合时用复数;单个概念/模块用单数。
|
|
|
+- **要点**:
|
|
|
+ - FCL 范例:`System.Collections` 收纳所有非泛型集合类型,`System.Collections.Generic` 收纳泛型集合。
|
|
|
+ - 项目示例:一组 `XxxProcessor` 类型 → 命名空间 `Processors`;一组 `XxxRepository` → `Repositories`。
|
|
|
+ - 复数让调用方未见类型定义也能推断内容,属于"命名即文档"。
|
|
|
+ - 避免 `System.AllCollections`、`System.TheCollection` 这类冗余冠词或过度修饰的命名。
|
|
|
+ - 并非所有命名空间都要复数:`System.IO`、`System.Text` 是领域名,不是类型集合。
|
|
|
+- **陷阱**:`Collections` 里只放一个 `Collection` 类(过度概括);复数形式拼错(`Repositores`);同一层混用 `Processor` 与 `Processors` 两套命名。
|
|
|
+
|
|
|
+### 建议125:避免用FCL的类型名称命名自己的类型
|
|
|
+
|
|
|
+- **问题**:自定义 `Socket` 与 `System.Net.Sockets.Socket` 同名,调用方在多数情况下不会报错,而是静默用错类型。
|
|
|
+- **规则**:自己的公开类型名不得与 FCL(及常用第三方库)类型重名;拿不准就先查文档。
|
|
|
+- **要点**:
|
|
|
+ - 编译器只在同一作用域出现两个同名候选时报 CS0104"引用不明确";若调用方未 `using System.Net.Sockets`,自定义 `Socket` 会被静默选中,代码照常编译运行。
|
|
|
+ - 名称解析优先级:当前命名空间内的类型 > `using` 导入的类型,所以"能用"恰恰是最危险的情况。
|
|
|
+ - 高冲突名单:`Socket`、`Timer`、`Path`、`File`、`Directory`、`Task`、`Console`、`Environment`、`Attribute`、`Exception`、`EventArgs`、`Image`、`Point`。
|
|
|
+ - 规避手段:加业务前缀(`OrderSocket`)、换名(`MessageChannel`),调用方可用 `using MySocket = MyLib.Socket;` 消歧。
|
|
|
+ - 判断依据是类型的"可见范围":`internal` 类型冲突面小,`public` 类型必须严格避免。
|
|
|
+- **陷阱**:更常见的反例是只 `using MyLib;`:编译器一声不响地绑到自定义类型,直到运行时才发现行为不对;此外还有 `namespace MyLib { class Socket { } }` 把 FCL 类型"盖住"后,同命名空间内所有代码都无法再用 FCL 的 `Socket`。
|
|
|
+```csharp
|
|
|
+using System.Net.Sockets;
|
|
|
+using MyLib; // 两个命名空间都定义了 Socket
|
|
|
+Socket s; // CS0104:Socket 是模糊引用
|
|
|
+```
|
|
|
+
|
|
|
+### 建议126:用名词和名词组给类型命名
|
|
|
+
|
|
|
+- **问题**:类型名写成动词,读起来像一次操作而不是一个对象,调用处语义混乱。
|
|
|
+- **规则**:类型代表现实对象,用名词或名词组命名;动词留给方法。
|
|
|
+- **要点**:
|
|
|
+ - 推荐:`OrderProcessor`、`ScoreManager`、`CourseRepository`、`UserControl`、`DomainService`。
|
|
|
+ - 不推荐:`OrderProcess`、`ScoreManage`、`CourseSave`、`ControlInit`、`DomainProvide`——这些更像类型里的某个方法。
|
|
|
+ - 常用角色后缀:`Processor`(处理)、`Manager`(协调)、`Repository`(持久化)、`Service`(领域服务)、`Factory`(创建)、`Provider`(供给)、`Builder`(构建)。
|
|
|
+ - 抽象基类可用 `Base` 后缀或直接用领域名词;泛型类型名仍应是名词(`ReadOnlyCollection<T>`)。
|
|
|
+ - 判断法:在调用处读 `new Xxx()` 与变量声明 `Xxx x`,能自然读成"一个 Xxx"才合格。
|
|
|
+- **陷阱**:`OrderProcess` 与 `OrderProcessor` 混用造成两个近义类型;用 `DoSomething`、`Handle`、`Run` 作类型名;名词组顺序颠倒(`ProcessOrder` 仍是动词短语)。
|
|
|
+
|
|
|
+### 建议127:用形容词组给接口命名
|
|
|
+
|
|
|
+- **问题**:接口描述"能做什么"(Can do),用名词命名会让调用方误以为是类或实体。
|
|
|
+- **规则**:接口名 = `I` + 形容词/形容词性短语(多为 `-able` 结尾),表达能力而非事物。
|
|
|
+- **要点**:
|
|
|
+ - FCL 范例:`IDisposable`(可释放)、`IEnumerable`(可迭代)、`IComparable`(可比较)、`ICloneable`、`ISerializable`。
|
|
|
+ - 一个类型可实现多个接口,正说明接口是行为切面而非身份,如 `class SampleClass : IDisposable, IEnumerable<SampleClass>`。
|
|
|
+ - `I` 前缀是 .NET 硬约定,CLS 与多数工具链都依赖它区分接口与类。
|
|
|
+ - 接口成员只描述能力,不暴露实现细节;接口名不加 `Impl`、`Interface`、`Helper` 后缀。
|
|
|
+ - 常见例外:`IEnumerator`、`IList`、`ICollection`、`IDictionary` 是名词,属 FCL 历史遗留,新代码不要效仿。
|
|
|
+- **陷阱**:`IDisposal`、`IFrom`、`ITo` 这类名词/介词命名;`IOrderService` 把"服务"这个名词包进接口(可接受但语义上更偏角色);给接口加 `I` 又给实现类加同名后缀导致 `IFoo`/`FooImpl` 满屏。
|
|
|
+- 代码:
|
|
|
+```csharp
|
|
|
+class SampleClass : IDisposable, IEnumerable<SampleClass>
|
|
|
+{
|
|
|
+ public void Dispose() { }
|
|
|
+ public IEnumerator<SampleClass> GetEnumerator() => throw new NotImplementedException();
|
|
|
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
|
|
|
+ => GetEnumerator();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议128:考虑让派生类的名字以基类名字作为后缀
|
|
|
+
|
|
|
+- **问题**:继承体系从类型名上看不出来,读代码时要反复跳转确认父子关系。
|
|
|
+- **规则**:派生类名以基类名结尾:`XxxException : Exception`、`XxxAttribute : Attribute`、`XxxEventArgs : EventArgs`。
|
|
|
+- **要点**:
|
|
|
+ - 后缀让继承链自解释:`SqlException`、`InvalidOperationException` 一眼可知是异常;`DescriptionAttribute` 一眼可知是特性。
|
|
|
+ - `Exception` 派生类应提供三个公共构造函数(无参、`string`、`string + inner`)和一个受保护的序列化构造函数,并标 `[Serializable]`。
|
|
|
+ - `Attribute` 使用时省略后缀(`[Description]` 对应 `DescriptionAttribute`),编译器自动补全,命名仍要带后缀。
|
|
|
+ - 中间抽象层可省略重复后缀,但末端具体类型应保留,如 `DbException` → `SqlException`。
|
|
|
+ - 后缀必须与真实基类一致,不能借后缀"伪装"继承关系。
|
|
|
+- **陷阱**:反例:`class OrderError : Exception`(换了词根,体系断裂);`class OrderExceptionBase` 把 `Base` 放在后缀之后;序列化构造函数漏掉导致跨 AppDomain/序列化失败。 - 时代注:二进制序列化在 .NET 8 起过时(SYSLIB0051),新异常类型可不再提供序列化构造函数。
|
|
|
+```csharp
|
|
|
+[Serializable]
|
|
|
+public class OrderException : Exception
|
|
|
+{
|
|
|
+ public OrderException() { }
|
|
|
+ public OrderException(string message) : base(message) { }
|
|
|
+ public OrderException(string message, Exception inner) : base(message, inner) { }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议129:泛型类型参数要以T作为前缀
|
|
|
+
|
|
|
+- **问题**:泛型参数用 `Arg1`、`Person` 之类命名,调用方在类型内部无法分辨它是类型参数还是真实类型。
|
|
|
+- **规则**:泛型参数一律 `T` 开头:单参数 `T`,多参数 `TKey`/`TValue`/`TResult`/`TItem`。
|
|
|
+- **要点**:
|
|
|
+ - 正确:`Action<T1, T2>`、`Dictionary<TKey, TValue>`、`Func<T, TResult>`;错误:`Action<Arg1, Arg2>`。
|
|
|
+ - 命名不影响编译,只影响可读性;类型级 `T` 在使用处不可见,方法级 `<T1>` 会在调用时显式写出(`SampleMethod2<string>("123")`),所以方法级更需规范。
|
|
|
+ - 约束应写在参数名之后:`where T : class, new()`;协变/逆变 `out T`/`in T` 也只作用于类型参数。
|
|
|
+ - 多参数时用有意义的 `T` + 角色名,而非 `T1/T2`(除 `Action`/`Func` 这类纯位置语义的委托)。
|
|
|
+ - 不要用 `T` 前缀命名普通类型或字段(`TOrder` 作类名会误导为泛型参数)。
|
|
|
+- **陷阱**:`class SampleClass<Person>` 后在方法体里写 `Person p = new Person();`,维护者误以为 `Person` 是具体类;泛型参数与真实类型同名导致遮蔽;嵌套泛型复用同一个 `T` 造成语义混淆。
|
|
|
+
|
|
|
+### 建议130:以复数命名枚举类型,以单数命名枚举元素
|
|
|
+
|
|
|
+- **问题**:枚举类型用单数、元素用复数,或类型名与元素名重复,调用处读起来别扭且易混淆。
|
|
|
+- **规则**:枚举类型用复数/集合语义(`Week`、`Colors`、`FileAttributes`),元素用单数。
|
|
|
+- **要点**:
|
|
|
+ - 范例:`Week.Monday` 比 `Day.Monday` 更清楚——类型表达"一组星期",元素表达"其中一天"。
|
|
|
+ - 位标志枚举必须复数并用 `[Flags]`,值取 2 的幂(`1, 2, 4, 8`),以便按位组合。
|
|
|
+ - 元素名不要重复类型名(`Week.Week`),也不要加类型前缀(`WeekMonday`)。
|
|
|
+ - 枚举底层默认 `int`,可显式指定 `: byte`/`: long` 控制大小与互操作;显式赋值避免插入元素后序号漂移。
|
|
|
+ - `Enum.Parse`/`Enum.TryParse`、`ToString()` 都依赖元素名,改名属于破坏性变更。
|
|
|
+- **陷阱**:反例:`enum Color { Red, Green }` 单数类型(可接受但非推荐);`enum Week { Monday = 1, Tuesday }` 插入新元素后序列化值全变;把 `[Flags]` 枚举当普通枚举用 `==` 判断组合值。
|
|
|
+```csharp
|
|
|
+[Flags]
|
|
|
+public enum FileAccesses // 复数 + Flags
|
|
|
+{
|
|
|
+ None = 0,
|
|
|
+ Read = 1,
|
|
|
+ Write = 2,
|
|
|
+ ReadWrite = Read | Write
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议131:用PascalCasing命名公开元素
|
|
|
+
|
|
|
+- **问题**:公开成员用小写/下划线命名,调用方无法从命名判断可访问性,甚至误以为是私有字段。
|
|
|
+- **规则**:公开的类型、方法、属性、事件、常量、字段一律 PascalCasing(每个单词首字母大写)。
|
|
|
+- **要点**:
|
|
|
+ - 示例:`FirstName`、`LastName`、`Name`、`GetName()`、`IsEnabled`;调用处 `person.Name` 一眼可知是公开成员。
|
|
|
+ - 反例 `person.name` 会让读者怀疑:这是字段还是属性?是不是 `internal`?可访问性被命名污染。
|
|
|
+ - 缩写规则:两字母缩写全大写(`IO`、`UI`),三字母以上只首字母大写(`Html`、`Xml`、`Http`),如 `HtmlWriter` 而非 `HTMLWriter`。
|
|
|
+ - 不使用下划线、连字符、匈牙利前缀;泛型类型与委托类型同样适用。
|
|
|
+ - 常量也用 PascalCasing(`MaxRetryCount`),全大写+下划线是 C 风格,.NET 公共 API 不采用(内部 const 见建议134)。
|
|
|
+- **陷阱**:把 `public` 字段写成 `public string firstName;` 与私有字段无法区分;`ID`/`Id` 混用;`URL`、`HTML` 全大写造成单词边界难读;接口成员默认公开却也写成 camelCasing。
|
|
|
+
|
|
|
+### 建议132:考虑用类名作为属性名
|
|
|
+
|
|
|
+- **问题**:属性类型是 `Company`,却命名成 `TheCompany`、`CompanyInfo`,调用处啰嗦且与类型脱节。
|
|
|
+- **规则**:属性对应一个类型时,直接用该类型名作属性名;同名合法且是 FCL 惯例。
|
|
|
+- **要点**:
|
|
|
+ - 推荐 `public Company Company { get; set; }`,调用处 `person.Company.Name` 自然。
|
|
|
+ - 避免 `TheCompany`、`CompanyObj`、`CompanyData` 这类无信息量的修饰。
|
|
|
+ - 一个类型里有多个同类型属性时才需要区分:`Company`、`SecondCompany`、`ParentCompany`。
|
|
|
+ - 属性名与类型名相同在 C# 中完全合法(`public Color Color { get; set; }` 是 FCL 既有模式),编译器按上下文区分。
|
|
|
+ - 集合属性用复数(`Orders`),单个实体属性用单数类型名。
|
|
|
+- **陷阱**:`public Company Company { get; set; } = new Company();` 中属性名与类型名同名,在属性内部引用类型时需写全名或 `global::`;`SecondCompany` 这种命名在实体增多后会失控,应考虑集合或角色化命名(`BillingCompany`)。
|
|
|
+
|
|
|
+### 建议133:用camelCasing命名私有字段和局部变量
|
|
|
+
|
|
|
+- **问题**:私有字段、参数、局部变量也用 PascalCasing,读者无法快速区分"对外契约"与"内部实现"。
|
|
|
+- **规则**:私有字段、方法参数、局部变量用 camelCasing(首字母小写),与公开成员的 PascalCasing 形成对照。
|
|
|
+- **要点**:
|
|
|
+ - 示例:`private string firstName;`、`private int doSomething(int a, int b)`、`int iTemp = 10;`。
|
|
|
+ - 私有方法本身仍用 PascalCasing(它是成员,不是变量),camelCasing 只管"数据"。
|
|
|
+ - 参数与字段同名时,本建议的写法需要 `this.firstName = firstName;` 或前缀区分(见建议134)。
|
|
|
+ - 局部变量名要表达含义,`iTemp`、`tmp`、`a`/`b` 只在极短作用域可接受。
|
|
|
+ - `const` 局部常量同样 camelCasing;`readonly` 私有字段同私有字段。
|
|
|
+- **陷阱**:`private string FirstName;` 与公开属性 `FirstName` 只差访问修饰符,重构时极易误改;`private int _count;` 属另一套约定,团队内必须统一(见建议134);把 camelCasing 用到公开方法上(`public void getName()`)。
|
|
|
+
|
|
|
+### 建议134:有条件地使用前缀
|
|
|
+
|
|
|
+- **问题**:.NET 规范说"不要前缀",但大型类型里实例字段、静态字段、常量、参数互相遮蔽,不靠前缀根本读不懂。
|
|
|
+- **规则**:默认不用前缀;仅在类型较大或存在命名遮蔽时使用 `m_`(实例)、`s_`(静态)、`NOUN_`(const)或 `_`(私有字段)。
|
|
|
+- **要点**:
|
|
|
+ - `m_` = 实例字段(member),`s_` = 静态字段(static),`const` 用全大写+下划线:`internal const int TASK_STATE_CANCELED = 0x400000;`(出自 `Task` 源码)。
|
|
|
+ - `Task` 这类两千多行的类型不靠前缀无法区分字段种类,这是前缀存在的现实理由;小类型不该用。
|
|
|
+ - 参数与字段同名时前缀是必要的:`SetPrice(int price) { m_price = price; }`。
|
|
|
+ - 绝对禁止匈牙利式类型前缀:`int_price`、`strName`、`btnOK`——强类型语言里类型由声明给出,重复标注只增加噪音。
|
|
|
+ - 团队只能选一套(`m_`/`s_` 或 `_camelCase`),混用比不用更糟。
|
|
|
+- **陷阱**:反例:`int int_price;` 用类型当前缀;在公开 API 上使用前缀(`public int m_Count`);同一个类里 `m_price` 与 `_price` 并存。
|
|
|
+```csharp
|
|
|
+class SampleClass
|
|
|
+{
|
|
|
+ static int s_price;
|
|
|
+ int m_price;
|
|
|
+ const int BASE_PRICE = 1000;
|
|
|
+ public void SetPrice(int price) => m_price = price; // 参数遮蔽字段,前缀必要
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议135:考虑使用肯定性的短语命名布尔属性
|
|
|
+
|
|
|
+- **问题**:布尔属性用过去分词或否定式命名,调用处出现 `if (!IsNotDisabled)` 这类双重否定,且与事件名混淆。
|
|
|
+- **规则**:布尔成员用肯定性短语,以 `Is`、`Can`、`Has`、`Allow`、`Supports`、`Should` 开头。
|
|
|
+- **要点**:
|
|
|
+ - 推荐:`IsEnabled`、`IsTabStop`、`IsActive`、`IsChecked`、`AllowDrop`(动作表达可否,WPF 实例)。
|
|
|
+ - 避免:`Checked`、`Loaded`、`Enabled`——这些词在 .NET 中通常留给事件/委托(`Loaded` 事件),读者会以为它是事件。
|
|
|
+ - 禁止否定式:`IsNotEnabled`、`DisableXXX`,调用方取反后语义翻转,易出逻辑错误。
|
|
|
+ - 布尔字段、布尔局部变量同样适用;方法返回 `bool` 时用 `IsXxx()`/`HasXxx()` 或动词(`TryParse`)。
|
|
|
+ - 命名要与默认值一致:默认 `true` 的属性不要叫 `IsDisabled`。
|
|
|
+- **陷阱**:`public bool Checked { get; set; }` 与 `Checked` 事件同名冲突;`IsEnabled` 与 `Enabled` 两个属性并存;三元表达式里 `x ? true : false` 式命名导致调用处 `flag == true`。
|
|
|
+
|
|
|
+### 建议136:优先使用后缀表示已有类型的新版本
|
|
|
+
|
|
|
+- **问题**:给类型升级版本时叫 `XxxNew`,第三个版本出现后只能叫 `XxxNewNew`,且 IntelliSense 里新旧版本不相邻。
|
|
|
+- **规则**:新版本用数字后缀 `Xxx2`、`Xxx3`,不用 `New`/`Old` 前缀或形容词。
|
|
|
+- **要点**:
|
|
|
+ - FCL 范例:`X509Certificate`(.NET 1.0/1.1)→ `X509Certificate2`(.NET 2.0),后者不是前者的子类,而是替代实现。
|
|
|
+ - 后缀排序让 IntelliSense 把 `X509Certificate` 与 `X509Certificate2` 排在一起,容易被发现。
|
|
|
+ - 数字后缀可无限扩展(`Xxx2`、`Xxx3`),`New` 无法表达第三版。
|
|
|
+ - 新版本应尽量保持源兼容或提供转换/适配,避免调用方两套 API 并存。
|
|
|
+ - 版本后缀不是命名空间或程序集版本号的替代品,后者用于绑定,前者用于 API 演进。
|
|
|
+- **陷阱**:`SampleClassNew` 之后出现 `SampleClassNew2`;用 `SampleClassV2` 与 `SampleClass2` 混用;把 `2` 加到接口上(`IFoo2`)却不说明差异;以为 `X509Certificate2` 继承自 `X509Certificate` 而写多态代码。
|
|
|
+
|
|
|
+### 建议137:委托和事件类型应添加上级后缀
|
|
|
+
|
|
|
+- **问题**:委托类型命名成 `DoSomething`、`MouseClick`,从名字看不出它是委托,调用处与普通方法混淆。
|
|
|
+- **规则**:委托/事件类型以 `Delegate`、`EventHandler`、`Callback` 结尾;事件用 `EventHandler`,回调用 `Callback`。
|
|
|
+- **要点**:
|
|
|
+ - 范例:`HttpContinueDelegate`、`ValidateValueCallback`、`AsyncCallback`、`PropertyChangedCallback`、`MouseEventHandler`、`SizeChangedEventHandler`、`UnhandledExceptionEventHandler`。
|
|
|
+ - `delegate`/`event` 关键字已表明基类是 `Delegate`,后缀是为了让"使用处"也能识别,尤其在方法签名、泛型参数、字段声明中。
|
|
|
+ - 区分语义:普通委托以 `Delegate` 结尾;框架事件以 `EventHandler` 结尾;回调性质以 `Callback` 结尾。
|
|
|
+ - 事件处理器委托的签名约定为 `(object sender, XxxEventArgs e)`,返回 `void`。
|
|
|
+ - 自定义 `EventArgs` 同样以 `EventArgs` 结尾(与建议128 一致)。
|
|
|
+- **陷阱**:`public delegate void DoSomething(int a, int b);` 与同名方法 `DoSomething` 冲突;`MouseClick` 看起来像事件变量;`Func<T>`/`Action<T>` 能表达时不要另造委托;`EventHandler` 与 `Callback` 后缀混用导致语义不明。
|
|
|
+
|
|
|
+### 建议138:事件和委托变量使用动词或形容词短语命名
|
|
|
+
|
|
|
+- **问题**:事件变量名与委托类型名相同(`SizeChangedEventHandler`),调用处分不清这是类型还是成员。
|
|
|
+- **规则**:事件/委托变量用动词或形容词短语:`Click`、`SizeChanged`、`PropertyChanged`、`Closing`。
|
|
|
+- **要点**:
|
|
|
+ - 范例:WPF `Button` 的 `public event RoutedEventHandler Click;`、`public event SizeChangedEventHandler SizeChanged;`。
|
|
|
+ - 事件访问器 `add`/`remove` 本质是方法,所以事件名取动词/形容词短语与"方法用动词"一致。
|
|
|
+ - 事件名用现在分词/过去分词表达时机:`Closing`(可取消,关闭前)、`Closed`(已关闭)、`Click`(动作)。
|
|
|
+ - 委托字段同样命名:`public Action<string> Validate;`、`public Func<int, bool> Filter;`。
|
|
|
+ - 变量名不加 `EventHandler`/`Delegate` 后缀,后缀属于类型(建议137),两者职责不同。
|
|
|
+- **陷阱**:反例的后果是 `button.SizeChangedEventHandler += ...` 读起来像在引用类型;`ClickEvent`、`OnClick` 这类命名也不妥(`OnXxx` 留给处理器方法,见建议139)。
|
|
|
+```csharp
|
|
|
+public event SizeChangedEventHandler SizeChanged; // 变量名与类型名区分开
|
|
|
+// 反例:public event SizeChangedEventHandler SizeChangedEventHandler;
|
|
|
+```
|
|
|
+
|
|
|
+### 建议139:事件处理器命名采用组合方式
|
|
|
+
|
|
|
+- **问题**:事件处理器方法名与事件/委托无关联(`NameChanged`),维护时无法从方法名定位它响应谁。
|
|
|
+- **规则**:`+=` 注册的事件处理器用"所属对象 + 下划线 + 事件名";委托/回调处理器用"所属对象 + On + 委托名";类型内部可省略对象名,直接用 `On` 前缀。
|
|
|
+- **要点**:
|
|
|
+ - VS 默认生成:`button_Click`、`button_SizeChanged`、`MainWindow_MouseDown`,即"事件变量所属对象 + `_` + 事件变量名"。
|
|
|
+ - 委托回调:`NameOnValidateValue`、`NameOnPropertyChanged`,即"委托变量所属对象 + `On` + 委托变量名"。
|
|
|
+ - 处理器签名固定为 `void Handler(object sender, XxxEventArgs e)`;`sender` 用 `object`,需要具体类型时在方法内 `as`/模式匹配。
|
|
|
+ - 处理器在类型内部时可简化为 `OnClick`、`OnSizeChanged`,作为可重写的受保护虚方法时这是标准做法。
|
|
|
+ - 一个处理器响应多个事件时,应按语义命名而非罗列事件名,并在方法内分支。
|
|
|
+- **陷阱**:反例:`public void NameChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)` —— 看起来是事件/委托变量而不是处理器;`button_Click` 与 `Button_Click` 大小写不一致导致跨文件搜索不到;`OnClick` 与基类虚方法同名却未 `override`。
|
|
|
+```csharp
|
|
|
+button.Click += button_Click;
|
|
|
+button.SizeChanged += button_SizeChanged;
|
|
|
+
|
|
|
+void button_Click(object sender, RoutedEventArgs e) { }
|
|
|
+void button_SizeChanged(object sender, SizeChangedEventArgs e) { }
|
|
|
+```
|
|
|
+
|
|
|
+## 第 11 章 代码整洁
|
|
|
+
|
|
|
+### 建议140:使用默认的访问修饰符
|
|
|
+
|
|
|
+- **问题**:成员上堆满 `private`、类型上堆满 `internal`,代码变长,还容易让人忘记语言本来的默认可见性。
|
|
|
+- **规则**:能省略就省略:类成员默认 `private`,顶层类/接口默认 `internal`;只有需要更宽可见性时才显式写 `public`、`protected`、`internal`。
|
|
|
+- **要点**:
|
|
|
+ - 字段、方法、属性、嵌套类型默认都是 `private`;顶层 `class`、`interface`、`struct`、`enum` 默认是 `internal`。
|
|
|
+ - 嵌套类型默认 `private` 而非 `internal`,这是最容易被记错的一条。
|
|
|
+ - 接口成员隐式 `public`,传统语法下不能再写访问修饰符。
|
|
|
+ - 省略修饰符的前提是理解“默认即最严”,`public` 应当是一次有意的 API 决策。
|
|
|
+- **陷阱**:以为 `class SampleClass` 对外可见而跨程序集引用;或反过来给每个成员补 `private`,用噪声换“安心”。
|
|
|
+- 时代注:C# 8 起接口可有默认实现,并允许 `private`/`internal`/`protected` 等修饰符,旧结论“接口成员不能写修饰符”已不成立。
|
|
|
+
|
|
|
+### 建议141:不知道该不该用大括号时,就用
|
|
|
+
|
|
|
+- **问题**:单行 `if`/`else` 省略大括号,后续加一行代码时极易落到条件之外。
|
|
|
+- **规则**:`if`、`else`、`for`、`foreach`、`while`、`using`、`lock` 一律加大括号,即使体内只有一条语句。
|
|
|
+- **要点**:
|
|
|
+ - 大括号换来结构一致性,阅读时不必靠缩进猜作用域。
|
|
|
+ - 可避免“悬挂 `else`”与重构时新语句脱离条件。
|
|
|
+ - 省括号引发的错误编译器不一定会报:只有 `throw` 等少数情况可能提示不可达。
|
|
|
+ - 行数略增不是问题,真正妨碍阅读的是缩进混乱和嵌套过深。
|
|
|
+- **陷阱**:
|
|
|
+```csharp
|
|
|
+void Check(object d)
|
|
|
+{
|
|
|
+ if (d == null)
|
|
|
+ Console.WriteLine("null"); // 缩进具有欺骗性
|
|
|
+ throw new ArgumentNullException(nameof(d)); // 实际无条件执行
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议142:总是提供有意义的命名
|
|
|
+
|
|
|
+- **问题**:`theID`、`a`、`b`、`iTemp` 这类命名不表达用途,读代码只能靠猜。
|
|
|
+- **规则**:除非有特殊理由,命名必须表达“是什么/做什么”;宁可长,也不要无意义。
|
|
|
+- **要点**:
|
|
|
+ - 字段用能自解释的名字,如 `m_CurrentCulture`、`m_ManagedThreadId`;方法用动词短语,如 `GetDomainID`、`JoinInternal`。
|
|
|
+ - 参数名要能让调用者知道该传什么,`GetID(int a, int b)` 是反例。
|
|
|
+ - 单字母 `i` 只适合 `for` 循环计数器,其他位置没有理由出现。
|
|
|
+ - 好命名能消掉大部分“解释做什么”的注释。
|
|
|
+- **陷阱**:用匈牙利式后缀 `plistObj` 标注类型;后缀既不符合匈牙利命名法(类型应作前缀),也没有增加语义。
|
|
|
+
|
|
|
+### 建议143:方法抽象级别应在同一层次
|
|
|
+
|
|
|
+- **问题**:一个方法里一半是细节代码、一半是封装好的子方法,读者无法一眼看出它完成了哪几件事。
|
|
|
+- **规则**:同一个方法体内的语句应处于同一抽象层;高层方法只负责组合下一层的方法调用。
|
|
|
+- **要点**:
|
|
|
+ - `Init()` 若要同时完成本地初始化和远程初始化,就应写成 `LocalInit(); RemoteInit();`。
|
|
|
+ - 这是“组合方法”(Composed Method)的核心:每个方法都短,且只调用相邻下一层。
|
|
|
+ - 同一层次的操作要么都内联,要么都提炼,不能一半一半。
|
|
|
+ - 抽象层次统一后,方法名就能准确概括方法体,不必再写分节注释。
|
|
|
+- **陷阱**:
|
|
|
+```csharp
|
|
|
+public void Init()
|
|
|
+{
|
|
|
+ LocalInit(); // 与 RemoteInit 对称
|
|
|
+ RemoteInit();
|
|
|
+}
|
|
|
+void LocalInit() { /* 本地初始化 */ }
|
|
|
+void RemoteInit() { /* 远程初始化 */ }
|
|
|
+```
|
|
|
+
|
|
|
+### 建议144:一个方法只做一件事
|
|
|
+
|
|
|
+- **问题**:方法同时校验、读取、转换、抛异常,职责过多,难以复用和测试。
|
|
|
+- **规则**:把单一职责原则(SRP)扩展到方法:一个方法只完成一个可命名的职责。
|
|
|
+- **要点**:
|
|
|
+ - “一件事”取决于抽象层次:`Init` 组合 `LocalInit` 与 `RemoteInit` 仍可算一件事。
|
|
|
+ - `CheckDogAndGetKey` 既检测加密狗又读取密钥,应拆成 `CheckDog` 与 `GetKeyFormDog`。
|
|
|
+ - 拆分后异常信息更精确,调用者也能只复用需要的部分。
|
|
|
+ - 判断标准:能否用一个不含“和/并/然后”的方法名概括它。
|
|
|
+- **陷阱**:按行数机械拆分,拆出来的方法仍混合 UI、校验和 IO;或拆完不改名,职责依旧看不清。
|
|
|
+
|
|
|
+### 建议145:避免过长的方法和过长的类
|
|
|
+
|
|
|
+- **问题**:违反“一个方法只做一件事”和类型单一职责后,方法、类会膨胀到无法在一屏内理解。
|
|
|
+- **规则**:方法以“Visual Studio 中不必滚屏”为实用上限(常见建议 10~30 行);类型除非有特殊理由不超过 300 行。
|
|
|
+- **要点**:
|
|
|
+ - 过长信号:出现分节注释、嵌套超过三层、局部变量成排、需要滚屏才能读完。
|
|
|
+ - 重构手段:提炼方法(Extract Method)、搬移方法(Move Method)、提炼类(Extract Class)。
|
|
|
+ - 行数只是提示,不是硬指标;关键是方法是否只剩一个职责。
|
|
|
+ - 类型过大通常意味着存在多组可分离的字段和行为。
|
|
|
+- **陷阱**:为凑行数把一行代码拆成一个方法,产生大量无意义的转发方法;只拆不命名,复杂度原样保留。
|
|
|
+
|
|
|
+### 建议146:只对外公布必要的操作
|
|
|
+
|
|
|
+- **问题**:`public` 成员越多,API 表面积越大,使用者查找成本和后续兼容负担越高。
|
|
|
+- **规则**:非必要一律 `private`;跨程序集才 `public`,仅本项目使用则保持默认 `internal`。
|
|
|
+- **要点**:
|
|
|
+ - 原文以 VS 默认 IntelliSense 窗口计:公开成员超过 9 个就需要滚动,且其中还混有从 `Object` 继承的成员。
|
|
|
+ - 类型本身也应默认 `internal`,不要因习惯把所有类型写成 `public`。
|
|
|
+ - 测试程序集需要访问内部类型时,用 `InternalsVisibleTo`,不要为此把类型改成 `public`。
|
|
|
+ - 一旦公开就成为兼容性承诺,改名、改签名都可能破坏调用方。
|
|
|
+- **陷阱**:为图省事把字段、辅助方法全部公开;公开可变字段而非属性,外部可随意破坏对象状态。
|
|
|
+
|
|
|
+### 建议147:重构多个相关属性为一个类
|
|
|
+
|
|
|
+- **问题**:一个类型里堆着地址、邮编、手机、邮箱等相关属性,参数传递和校验都分散。
|
|
|
+- **规则**:类型中相关属性超过 3 个,就考虑提炼成一个独立类型。
|
|
|
+- **要点**:
|
|
|
+ - `Person` 的 `Address`、`ZipCode`、`Mobile`、`Hotmail` 可归并为 `Contact`。
|
|
|
+ - 归并后方法参数从四个变成一个,调用点更短,也不易传错顺序。
|
|
|
+ - 校验、格式化、显示逻辑可以集中到 `Contact` 上,避免散落。
|
|
|
+ - 新类型名应来自领域语言,如 `Contact`,而不是 `PersonInfo2`。
|
|
|
+- **陷阱**:把互不相关的属性硬凑成一个“大杂烩”类;只为两个属性造类,导致层层包装和到处判空。
|
|
|
+```csharp
|
|
|
+class Person
|
|
|
+{
|
|
|
+ public Contact Contact { get; set; }
|
|
|
+}
|
|
|
+class Contact
|
|
|
+{
|
|
|
+ public string Address { get; set; }
|
|
|
+ public int ZipCode { get; set; }
|
|
|
+ public string Mobile { get; set; }
|
|
|
+ public string Hotmail { get; set; }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议148:不重复代码
|
|
|
+
|
|
|
+- **问题**:同一段逻辑存在多份拷贝,修 Bug 时只改到一处,另一处继续带着错误运行。
|
|
|
+- **规则**:发现重复片段就提取成共享方法,让所有调用点走同一份实现。
|
|
|
+- **要点**:
|
|
|
+ - 重复代码会让同类操作产生不一致行为,且差异往往在出事后才被发现。
|
|
|
+ - 两份拷贝可能相距很远,甚至在不同命名空间、不同项目中,重构时容易漏掉。
|
|
|
+ - 提取 `Encrypt(string input)` 后,`PaperEncrypt` 与 `AnswerEncrypt` 都调用它。
|
|
|
+ - 除方法外,重复的条件分支、SQL、正则、映射代码同样应合并。
|
|
|
+- **陷阱**:只重构眼前看到的一份,留下“看起来一样、实现不同”的接口;或把语义不同、未来会分叉的代码强行合并,制造错误耦合。
|
|
|
+
|
|
|
+### 建议149:使用表驱动法避免过长的if和switch分支
|
|
|
+
|
|
|
+- **问题**:一长串 `switch`/`if` 分支重复、难扩展,每加一种情况都要改原方法。
|
|
|
+- **规则**:把“输入到结果”的映射放进表(数组、字典),用索引或键直接查表。
|
|
|
+- **要点**:
|
|
|
+ - 枚举底层整数值可直接作数组下标,`chineseWeek[(int)week]` 两行替代七段 `switch`,查询为 O(1)。
|
|
|
+ - 动态行为可把方法名放进 `string[]`,再用 `GetMethod` + `Invoke` 调用。
|
|
|
+ - 另一条消除分支的路线是多态:新增子类即可扩展,符合开闭原则。
|
|
|
+ - 键不连续或不是整数时改用 `Dictionary<TKey, TValue>`,不要硬凑数组下标。
|
|
|
+- **陷阱**:枚举显式赋值不连续或含负数时数组越界;反射调用无编译期检查、性能差,混淆或裁剪后方法名可能失效;查表后丢掉原 `default` 的合法性校验。
|
|
|
+```csharp
|
|
|
+enum Week { Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday }
|
|
|
+
|
|
|
+static string GetChineseWeek(Week week)
|
|
|
+{
|
|
|
+ string[] chineseWeek =
|
|
|
+ { "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日" };
|
|
|
+ return chineseWeek[(int)week];
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议150:使用匿名方法、Lambda表达式代替方法
|
|
|
+
|
|
|
+- **问题**:只有一两行的判断逻辑也单独定义一个方法,调用点与实现分离,读起来绕。
|
|
|
+- **规则**:方法体小于 3 行且只在此处使用时,用匿名方法或 Lambda 内联;超过 3 行仍应提炼为方法。
|
|
|
+- **要点**:
|
|
|
+ - `List<T>.Find` 接收 `Predicate<T>` 委托,可直接传 `delegate(string value) { ... }` 或 `value => value.Length == 5`。
|
|
|
+ - 匿名方法经编译后仍是普通方法,编译器只是替开发者省掉声明;Lambda 是更短的语法。
|
|
|
+ - Lambda 捕获外部变量时会生成闭包类,被捕获变量的生命周期延长到委托被回收为止。
|
|
|
+ - 表达式 Lambda 适合单表达式,语句 Lambda 适合多语句,但后者一长就该提取。
|
|
|
+- **陷阱**:在 `for` 循环中捕获循环变量,所有委托共享同一个变量;用 Lambda 订阅事件后没有保存委托引用,无法 `-=` 取消订阅;把大段业务塞进回调,调试堆栈和可读性都变差。
|
|
|
+```csharp
|
|
|
+void FindMike()
|
|
|
+{
|
|
|
+ var list = new List<string> { "Mike", "Rose", "Steve" };
|
|
|
+ string mike = list.Find(value => value.Length == 5);
|
|
|
+ Console.WriteLine(mike);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议151:使用事件访问器替换公开的事件成员变量
|
|
|
+
|
|
|
+- **问题**:公开的事件字段无法控制订阅过程,外部还能直接赋值把订阅列表清空。
|
|
|
+- **规则**:公开事件使用 `add`/`remove` 访问器,像属性封装字段一样封装事件。
|
|
|
+- **要点**:
|
|
|
+ - 访问器本质是方法,可在其中加锁、去重、记日志、懒创建存储。
|
|
|
+ - `EventHandlerList` 用一个容器存多个事件,靠不同的 key 区分,避免每个事件占一个字段。
|
|
|
+ - 字段式事件(`public event EventHandler Click;`)由编译器生成私有委托字段和 `add`/`remove`,类外只能 `+=`/`-=`。
|
|
|
+ - 自定义访问器后,触发事件仍只能由声明类型内部进行。
|
|
|
+- **陷阱**:原文示例用 `null` 作 `EventHandlerList` 的 key,多个事件会落进同一个槽互相覆盖,应为每个事件定义 `static readonly object` key;`EventHandlerList` 本身不保证线程安全,跨线程订阅需自行加锁;`add`/`remove` 必须成对实现。
|
|
|
+```csharp
|
|
|
+class SampleClass
|
|
|
+{
|
|
|
+ EventHandlerList events = new EventHandlerList();
|
|
|
+ static readonly object ClickKey = new object();
|
|
|
+ public event EventHandler Click
|
|
|
+ {
|
|
|
+ add { events.AddHandler(ClickKey, value); }
|
|
|
+ remove { events.RemoveHandler(ClickKey, value); }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议152:最少,甚至是不要注释
|
|
|
+
|
|
|
+- **问题**:注释常被用来弥补糟糕的命名和结构,而且重构后往往忘记同步,最终变成误导。
|
|
|
+- **规则**:优先用命名和代码结构表达意图;只有代码本身说不清“为什么”时才写注释。
|
|
|
+- **要点**:
|
|
|
+ - 再详细的注释也不能优化糟糕的代码,注释不改变复杂度。
|
|
|
+ - 必须保留的注释:版权/许可证信息、公共 API 的 XML 文档、复杂算法或 workaround 的原因。
|
|
|
+ - 注释应解释“为什么这样做”,而不是逐行复述“这行做了什么”。
|
|
|
+ - 被注释掉的旧代码应删除,历史交给版本控制。
|
|
|
+- **陷阱**:用注释解释一个本应改名的变量;保留大段注释代码形成“僵尸逻辑”;注释与实现相反,后来者按注释理解必然出错。
|
|
|
+
|
|
|
+### 建议153:若抛出异常,则必须要注释
|
|
|
+
|
|
|
+- **问题**:命名和签名都无法说明方法会抛出哪些异常,调用者无法决定捕获什么。
|
|
|
+- **规则**:API 抛出异常时,用 XML 文档的 `<exception>` 标注异常类型和触发条件。
|
|
|
+- **要点**:
|
|
|
+ - 标准写法同时给出 `<summary>`、`<param>`、`<returns>`、`<exception cref="...">`。
|
|
|
+ - `cref` 中的类型由编译器校验,写错类型名会得到警告。
|
|
|
+ - 专有异常尤其要写清楚,调用者才知道该捕获具体类型还是让其冒泡。
|
|
|
+ - 异常类型或触发条件改变时,注释必须同步更新。
|
|
|
+- **陷阱**:只写 `<summary>` 不写异常;用 `<exception cref="System.Exception">` 泛泛带过;注释声称抛 `IOException`,实现却抛 `ArgumentException`。
|
|
|
+```csharp
|
|
|
+/// <summary>读取配置值。</summary>
|
|
|
+/// <param name="key">配置键。</param>
|
|
|
+/// <returns>配置值。</returns>
|
|
|
+/// <exception cref="System.IO.IOException">读取失败时抛出。</exception>
|
|
|
+public string ReadConfig(string key)
|
|
|
+{
|
|
|
+ if (key == null) throw new ArgumentNullException(nameof(key));
|
|
|
+ throw new IOException("读取失败");
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 第 12 章 规范开发行为
|
|
|
+
|
|
|
+### 建议154:不要过度设计,在敏捷中体会重构的乐趣
|
|
|
+
|
|
|
+- **问题**:瀑布式开发要求前期把设计定死,但软件需求几乎必然变化,过度设计最终变成废弃文档和僵化代码。
|
|
|
+- **规则**:设计停留在高层次、起指导作用;功能模块通过短迭代和持续重构逐步改进。
|
|
|
+- **要点**:
|
|
|
+ - 瀑布流程为分析、设计、实现、测试,假设阶段可清晰分割;软件需求受人员、竞争、架构质量影响,经常被迫变更。
|
|
|
+ - 敏捷以 2 周到 1 个月为迭代,用用户故事/Backlog 核定需求,迭代末交付可运行版本,根据反馈修正需求与设计。
|
|
|
+ - 重构应作为正式 Task 进入开发流程,而不是“有空再做的精益求精”。
|
|
|
+ - 原文案例的坏味道:业务与 UI 耦合、方法过长、匿名方法超过 3 行、命名不规范。
|
|
|
+ - 重构路径:用 MVVM 把 UI 交互抽成 `PaperLoad`、`PaperSelect`、`PaperShow` 命令;`GoExercise` 拆为 `ShowBusy`、`GetPaperById`、`ShowPaper`;三个异步回调提取为 `GetPaperCallBack`、`GetPaperPartCallBack`、`GetQuestionCallBack`;`tempList` 改为 `paperGeted`,`plistObj` 改为 `paperList`。
|
|
|
+- **陷阱**:开工前写出所有类型和公共 API 的详细设计;把重构与功能修改混在一个提交;只拆方法不消除 UI 与业务逻辑的耦合。
|
|
|
+```csharp
|
|
|
+void GoExercise()
|
|
|
+{
|
|
|
+ ShowBusy();
|
|
|
+ GetPaperById();
|
|
|
+ ShowPaper();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议155:随生产代码一起提交单元测试代码
|
|
|
+
|
|
|
+- **问题**:没有测试保护时,重构像是在赌运气,改完只能等测试部或用户发现问题。
|
|
|
+- **规则**:提交生产代码时同时提交对应单元测试;没有测试的功能视为未完成。
|
|
|
+- **要点**:
|
|
|
+ - TDD 三定律:先写一个不能通过的测试再写生产代码;只写恰好失败的测试(编译不过也算失败);只写刚好让当前测试通过的生产代码。
|
|
|
+ - 单元测试三不:不引入人机交互、不依赖多线程等待、不跨应用程序域;数据访问和远程通信属于集成测试。
|
|
|
+ - 分基于状态的测试与基于交互的测试;后者把操作提炼成接口,用 test double 替代,常用 Moq、RhinoMocks。
|
|
|
+ - MSTest 用 `[TestMethod]` 标记、`Assert.AreEqual` 断言;TestDriven.NET 可在编辑器右键直接运行/调试单个测试。
|
|
|
+ - MVC、MVP、MVVM 等架构模式把 UI 逻辑从界面中解耦,最终目的都是让单元测试可行;UI 测试重点是逻辑,不是布局和视觉。
|
|
|
+- **陷阱**:测试连真实数据库、网络或弹窗,速度慢且结果不稳定;为覆盖率写没有断言的测试;改了生产代码却不更新测试,让红色长期挂着。
|
|
|
+```csharp
|
|
|
+[TestMethod]
|
|
|
+public void AddTest()
|
|
|
+{
|
|
|
+ var target = new SampleUtility();
|
|
|
+ Assert.AreEqual(3, target.Add(1, 2));
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议156:利用特性为应用程序提供多个版本
|
|
|
+
|
|
|
+- **问题**:体验版/完整版若维护两套源码,功能同步和修复都会分叉。
|
|
|
+- **规则**:用 `[Conditional("符号")]` 标记可选功能,通过条件编译符号在编译期裁剪调用点,一套代码输出多个版本。
|
|
|
+- **要点**:
|
|
|
+ - `[Conditional]` 作用于调用方:符号未定义时,编译器直接不生成对该方法的调用,方法本身仍编译进程序集。
|
|
|
+ - 同一方法可标多个 `[Conditional]`,彼此是“或”关系,任一符号定义即保留调用。
|
|
|
+ - `#define` 必须位于文件最开头,且只对当前文件有效;全局符号在项目属性的“生成”页配置,多个用逗号分隔,如 `ONLINE,OFFLINE`。
|
|
|
+ - `[Conditional]` 方法必须返回 `void`,不能用于需要返回值的功能开关。
|
|
|
+- **陷阱**:把 `[Conditional]` 当授权或安全机制,它只是编译期裁剪,方法仍可被反射调用;只在入口文件 `#define`,其他程序集/文件的调用点没有符号,功能会被静默删掉;用 `#if` 包大段代码,导致多版本源码结构分叉。
|
|
|
+```csharp
|
|
|
+class MyService
|
|
|
+{
|
|
|
+ [Conditional("ONLINE")]
|
|
|
+ public void OnlineOnly() { Console.WriteLine("完整版功能"); }
|
|
|
+
|
|
|
+ [Conditional("OFFLINE")]
|
|
|
+ [Conditional("ONLINE")]
|
|
|
+ public void OfflineOrOnline() { Console.WriteLine("单机版功能"); }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 建议157:从写第一个界面开始,就进行自动化测试
|
|
|
+
|
|
|
+- **问题**:GUI 回归靠人工点击,机器多、版本多时既慢又容易漏测。
|
|
|
+- **规则**:从第一个界面起就用 UI 自动化工具录制/编写黑盒测试,并让测试能脱离 IDE 独立运行。
|
|
|
+- **要点**:
|
|
|
+ - VS2010 的“编码的 UI 测试”(Coded UI Test)可录制操作并生成 `UIMap.RecordedMethod1()`,底层仍通过 Windows API 获取控件句柄。
|
|
|
+ - 生成的测试方法标 `[TestMethod]`,测试项目可被独立的 WinForms 黑盒工具引用。
|
|
|
+ - 脱离 VS 运行需引用 `Common7\IDE\PrivateAssemblies` 下的 UI 测试程序集;启动时调用 `Playback.Initialize()`,结束时调用 `Playback.Cleanup()`。
|
|
|
+ - 录制代码把查找句柄的繁杂工作交给测试引擎,开发者只需组织测试业务逻辑。
|
|
|
+- **陷阱**:不调用 `Playback.Initialize()`,拿到的句柄全部无效;只引用部分 DLL,编译通过但运行报“未能加载程序集 …UITest.Extension…”;录制脚本依赖固定坐标、机器路径或窗口标题,换机器就失败;界面频繁改动时生成代码维护成本高,必要时手写定位逻辑。
|
|
|
+```csharp
|
|
|
+void RunUiTest()
|
|
|
+{
|
|
|
+ TestInit.Init();
|
|
|
+ var test = new CodedUITest1();
|
|
|
+ test.CodedUITestMethod1();
|
|
|
+ TestInit.Cleanup();
|
|
|
+}
|
|
|
+```
|
|
|
+- 时代注:Coded UI 自 VS2019 起已弃用,跨平台/现代桌面 UI 自动化可改用 Appium、WinAppDriver、Playwright 等工具。
|
|
|
+
|
|
|
+# 第二部分 《Effective C#》50 个条目
|
|
|
+<a id="part-b"></a>
|
|
|
+
|
|
|
+> Bill Wagner。这一部分偏设计意图与 API 演进,条目之间常互相引用,建议按章连着读。
|
|
|
+
|
|
|
+## 第 1 章 C# 语言习惯
|
|
|
+
|
|
|
+### 条目1:使用属性而不是可访问的数据成员
|
|
|
+
|
|
|
+- **问题**:公有字段无法封装、无法参与数据绑定,且日后改成属性会破坏二进制兼容。
|
|
|
+- **规则**:凡是在公有或受保护接口上暴露数据,一律用属性;序列/字典用索引器;所有字段私有,无例外。
|
|
|
+- **要点**:
|
|
|
+ - 属性编译成 `get_X`/`set_X` 两个方法,因此可 `virtual`、`abstract`、可声明在接口中、可给 get/set 不同访问级别(`public string Name { get; private set; }`)。
|
|
|
+ - 隐式属性由编译器生成私有后台字段;接口中的"隐式属性"语法不生成任何实现,只是契约。
|
|
|
+ - WPF、Windows Forms、Silverlight 的数据绑定只认属性,公有字段不被支持——绑定机制用反射找属性。
|
|
|
+ - 字段改属性:源码层兼容,二进制层不兼容。属性访问生成 `call`/`callvirt` 指令,字段访问生成 `ldfld`/`stfld`,必须重编所有引用方。
|
|
|
+ - 属性访问器通常被 JIT 内联,性能与字段持平;未内联也只差一次函数调用,极端场景才有影响。
|
|
|
+ - 索引器统一用 `this` 声明,不可命名;可多维、可用非整数参数(图/字典);一维数字索引器可参与数据绑定;不能写成隐式形式。
|
|
|
+- **陷阱**:
|
|
|
+ - "先用公有字段,以后需要时再改成属性"不成立——那等于要求所有调用方重编,破坏了"只发布单个程序集"的能力。
|
|
|
+ - 调用者把属性当数据看:getter 不得有可观察副作用,不得做数据库查询或长计算;setter 必须让状态变化可见。
|
|
|
+
|
|
|
+### 条目2:用运行时常量(readonly)而不是编译期常量(const)
|
|
|
+
|
|
|
+- **问题**:`const` 的值被直接烧进调用方 IL,改库不重编调用方就等于没改。
|
|
|
+- **规则**:默认用 `readonly`;只在值永不变化且性能极敏感、或编译期必须确定时用 `const`。
|
|
|
+- **要点**:
|
|
|
+ - `const` 仅支持基元类型、枚举、string;`readonly` 支持任意类型,可在声明处或构造函数中赋值。
|
|
|
+ - `const` 隐含 `static`,是类型级;`readonly` 可为实例级,每个实例不同值。
|
|
|
+ - `const` 可用于方法内局部声明,`readonly` 不能(只能作字段)。
|
|
|
+ - 跨程序集:引用 `const` 生成的是字面量,改 `const` 属于修改类型接口,必须重编所有引用者;改 `readonly` 只是修改实现,二进制兼容。
|
|
|
+ - 版本化序列化场景:历史版本号用 `const`(永不改变),当前版本号用 `readonly`(每次发布变)。
|
|
|
+ - 可选参数默认值与 `const` 同机制,被放在调用端(见条目10)。
|
|
|
+- **陷阱**:
|
|
|
+ - 库把 `const EndValue = 10` 改成 105、`readonly StartValue` 改成 105 后只替换 DLL:循环变成 `for (int i = 105; i < 10; i++)`,程序静默地什么都不输出。
|
|
|
+ - `const` 的微弱性能优势必须实测后再决定放弃灵活性。
|
|
|
+
|
|
|
+### 条目3:推荐使用is或as操作符而不是强制类型转换
|
|
|
+
|
|
|
+- **问题**:从 `object` 转到具体类型时,cast / `is` / `as` 的失败行为、是否执行自定义转换、能否用于值类型都不同。
|
|
|
+- **规则**:引用类型优先 `as` + null 检查;值类型或不支持 `as` 时用 `is` + 强转;需要精确运行时类型时用 `GetType()`。
|
|
|
+- **要点**:
|
|
|
+ - `as`/`is` 只做 `isinst` 运行时类型检查:不调用用户自定义转换、不装箱、不创建新对象;失败时 `as` 返回 `null`,`is` 返回 `false`。
|
|
|
+ - 强制转换会调用 `op_Explicit` 和框架内建数值转换(`long`→`short` 会丢信息),失败抛 `InvalidCastException`。
|
|
|
+ - 自定义转换按**编译期类型**选择:`o` 声明为 `object` 时编译器找不到 `object`→`MyType` 的转换,退化为运行时类型检查,于是 `SecondType` 失败——即使它定义了到 `MyType` 的转换。
|
|
|
+ - `as` 不能用于值类型(`int` 无法用 `null` 表示失败),只能 `is` 判断后强转(装箱/拆箱)。
|
|
|
+ - `if (o is MyType) { var t = o as MyType; }` 是冗余的,等价于 `var t = o as MyType; if (t != null)`。
|
|
|
+ - 非泛型 `foreach` 内建强制转换,元素类型不符会抛 `InvalidCastException`;`Enumerable.Cast<T>()` 同样用强制转换(因此不需要 `class` 约束)。
|
|
|
+- **陷阱**:
|
|
|
+ - 两类型无继承关系、仅有自定义转换时,`st as MyType` 直接编译错误。
|
|
|
+ - `is` 对 `null` 永远返回 `false`;`is` 对派生类型返回 `true`,要精确匹配必须 `GetType() == typeof(T)`。
|
|
|
+
|
|
|
+```csharp
|
|
|
+object o = Factory.GetObject();
|
|
|
+var t = o as MyType; // 引用类型:一次类型检查,失败为 null
|
|
|
+if (t != null) t.DoSomething();
|
|
|
+
|
|
|
+if (o is int i) { /* 值类型只能 is + 强转 */ }
|
|
|
+```
|
|
|
+
|
|
|
+### 条目4:使用Conditional特性而不是#if条件编译
|
|
|
+
|
|
|
+- **问题**:`#if/#endif` 把条件代码与主逻辑混在一起,release 版会留下空方法或漏掉必要语句。
|
|
|
+- **规则**:把条件行为抽成独立的 `void` 方法,用 `[Conditional("DEBUG")]` 标注,由编译器决定调用点是否生成。
|
|
|
+- **要点**:
|
|
|
+ - `Conditional` 只影响**调用**,不影响方法编译:方法体始终进程序集,但没被调用就不会加载、不会被 JIT,成本只是一点磁盘空间。
|
|
|
+ - 只能用于返回 `void` 的方法;不能标注代码块,不能用于有返回值的方法。
|
|
|
+ - 多个 `Conditional` 之间是 OR;要 AND 必须自己 `#define` 一个组合符号。
|
|
|
+ - 符号来源:编译命令行 `/define`、操作系统环境变量、源文件 `#define`。
|
|
|
+ - 典型用途:`Debug.Assert`、`Trace.WriteLine`、`CheckState` 之类前置/后置条件检查。
|
|
|
+- **陷阱**:
|
|
|
+ - 给 `Conditional` 方法传参:符号未定义时整个调用(含实参表达式)都不求值,`SomeMethod(names.Dequeue())` 里的 `Dequeue()` 副作用会消失。
|
|
|
+ - 在 `#if` 块里写业务赋值语句,release 版编译后语句消失,行为差异极难诊断。
|
|
|
+
|
|
|
+```csharp
|
|
|
+[Conditional("DEBUG")]
|
|
|
+private void CheckState()
|
|
|
+{
|
|
|
+ Trace.WriteLineIf(string.IsNullOrEmpty(lastName), "lastName 为空");
|
|
|
+ Debug.Assert(!string.IsNullOrEmpty(lastName));
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 条目5:类型提供 ToString() 方法
|
|
|
+
|
|
|
+- **问题**:`Object.ToString()` 默认返回类型全名(如 `MyNamespace.Point`),对用户和调试都没有价值。
|
|
|
+- **规则**:每个类型都覆写 `ToString()` 返回简明摘要;需要多种输出格式时实现 `IFormattable`。
|
|
|
+- **要点**:
|
|
|
+ - `Console.WriteLine`、`string.Format`、WPF/WinForms 控件显示内部都调用 `ToString()`,写一次全体受益。
|
|
|
+ - `IFormattable.ToString(string format, IFormatProvider provider)` 必须支持 `"G"`、`""`、`null` 三种格式,且结果与 `ToString()` 一致,否则破坏 BCL 的字符串转换约定。
|
|
|
+ - 可自定义格式字符并允许组合(如 `n` 名称、`r` 收入、`p` 电话,`"nr"` 同时输出)。
|
|
|
+ - `provider` 参数应转交 `ICustomFormatter`:`provider.GetFormat(typeof(ICustomFormatter))`,让外部在不改类的前提下定制格式,用 `string.Format(provider, ...)` 触发。
|
|
|
+ - 匿名类型由编译器生成了显示各属性值的 `ToString()`(序列属性只显示类型信息)。
|
|
|
+- **陷阱**:
|
|
|
+ - 想穷举所有格式是不可能的;只实现最可能用的几种,其余交给 `ICustomFormatter`。
|
|
|
+ - 只实现 `IFormattable` 而不处理 `null`/`"G"`,会让 BCL 拿不到默认表示。
|
|
|
+
|
|
|
+### 条目6:理解几个等同性判断之间的关系
|
|
|
+
|
|
|
+- **问题**:`ReferenceEquals`、静态 `Equals`、实例 `Equals`、`operator==`(外加 `IEquatable<T>`、`IStructuralEquatable`)语义不同,改一个会牵连其他。
|
|
|
+- **规则**:永不覆写两个静态方法;值类型总覆写实例 `Equals` 和 `operator==`;引用类型仅在需要值语义时覆写 `Equals`,并同时实现 `IEquatable<T>` 与 `GetHashCode`。
|
|
|
+- **要点**:
|
|
|
+ - `Object.ReferenceEquals(a,b)` 比对象标识,对值类型永远 `false`(各自装箱)。
|
|
|
+ - 静态 `Object.Equals(a,b)` 把判断委托给 `a.Equals(b)`,因此使用 `a` 的运行时类型规则;两者都不该重定义。
|
|
|
+ - 等同性必须满足自反、对称、可传递。
|
|
|
+ - 实例 `Equals` 的标准检查顺序:`null`→`false`;`ReferenceEquals`→`true`;`GetType() != right.GetType()`→`false`;逐字段比较;任何情况都不抛异常。
|
|
|
+ - 类型检查必须用精确类型,不能用 `is`/`as`:否则 `base.Equals(derived)` 可能为 `true` 而 `derived.Equals(base)` 为 `false`,对称性被破坏。
|
|
|
+ - 若基类 `Equals` 不是 `Object`/`ValueType` 的实现,派生类必须调用 `base.Equals`。
|
|
|
+ - 值类型默认 `ValueType.Equals` 用反射逐字段比较,极慢,必须自己覆写;引用类型不要覆写 `operator==`(框架期望引用语义)。
|
|
|
+ - `IStructuralEquatable` 用于 `Array`、`Tuple`,让容器本身按值比较。
|
|
|
+- **陷阱**:
|
|
|
+ - 用 `right is Foo` 做类型检查,会在继承层次上产生"比较顺序决定结果"的诡异 bug。
|
|
|
+ - 只覆写 `Equals` 不覆写 `GetHashCode`,字典/HashSet 立即失效(见条目7)。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public override bool Equals(object? obj) => Equals(obj as Foo);
|
|
|
+
|
|
|
+public bool Equals(Foo? other)
|
|
|
+{
|
|
|
+ if (other is null) return false;
|
|
|
+ if (ReferenceEquals(this, other)) return true;
|
|
|
+ if (GetType() != other.GetType()) return false;
|
|
|
+ return id == other.id;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 条目7:理解GetHashCode()的陷阱
|
|
|
+
|
|
|
+- **问题**:`Object.GetHashCode` 效率低(递增对象标识),`ValueType.GetHashCode` 在首字段可变或高度重复时直接算错。
|
|
|
+- **规则**:类型不当字典键就不要实现;要当键,则用参与等同性判断的**不可变**字段计算,通常对各字段哈希做异或。
|
|
|
+- **要点**:
|
|
|
+ - 三条规则:相等对象哈希必须相同;同一对象哈希终生不变;哈希在整数范围内均匀分布。
|
|
|
+ - `Object.GetHashCode` 返回内部对象标识(从 1 递增),满足前两条、违反第三条,分布挤在低位。
|
|
|
+ - `ValueType.GetHashCode` 返回**第一个字段**的哈希:首字段不参与相等判断→违反规则1;首字段可变→违反规则2;首字段取值重复→违反规则3。
|
|
|
+ - 用于哈希的字段必须都参与相等判断;反之不必须,但不参与会损害分布。
|
|
|
+ - 类型要当哈希键就必须不可变:改属性等于换了桶,对象在字典中"丢失"。
|
|
|
+- **陷阱**:
|
|
|
+ - 可变键:以 `Name` 计算哈希存入字典后改 `Name`,`ContainsKey`/索引器再也找不到它。
|
|
|
+ - 修法是让 `ChangeName` 返回新对象,并从字典移除旧键、添加新键,而不是原地改。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public override int GetHashCode() =>
|
|
|
+ firstName.GetHashCode() ^ lastName.GetHashCode() ^ id.GetHashCode();
|
|
|
+```
|
|
|
+
|
|
|
+### 条目8:推荐使用查询语法而不是循环
|
|
|
+
|
|
|
+- **问题**:命令式循环把"要什么"埋在"怎么做"里,嵌套加条件后迅速不可读、不可组合。
|
|
|
+- **规则**:需要遍历/筛选/投影时先想查询语法,其次方法语法,都不行再用循环。
|
|
|
+- **要点**:
|
|
|
+ - 查询语法编译成方法调用(`Where`/`Select`/`OrderBy`),与方法语法等价,仅可读性不同。
|
|
|
+ - 延迟执行:过滤、排序、投影可组合成一次遍历,无需中间集合;循环版本必须自己建临时存储。
|
|
|
+ - 每个子句只做一件事,天然可组合、可复用。
|
|
|
+ - `Take`、`TakeWhile`、`Skip`、`SkipWhile`、`Min`、`Max` 没有查询关键字,只能方法语法。
|
|
|
+ - 性能担忧先测量;确实热点可加 `.AsParallel()` 走 PLINQ。
|
|
|
+ - `List<T>.ForAll` 只存在于 `List<T>`,可为 `IEnumerable<T>` 自写 `ForAll` 扩展方法。
|
|
|
+- **陷阱**:
|
|
|
+ - 为降序而在 `Comparison` 里颠倒参数顺序,是把"目的"藏进"细节"的典型,极易写错且看不出错。
|
|
|
+ - 嵌套循环 + 和约束 + 距离排序,会让读者完全忘记原始意图。
|
|
|
+
|
|
|
+### 条目9:避免在API中使用转换操作符
|
|
|
+
|
|
|
+- **问题**:转换操作符制造"可替换"假象,修改落在临时对象上,原对象毫无变化。
|
|
|
+- **规则**:需要从 A 得到 B 时用 B 的构造函数显式 `new`,不要在 API 中定义 `implicit`/`explicit operator`。
|
|
|
+- **要点**:
|
|
|
+ - 隐式转换在任何期待目标类型的位置自动发生,生成临时对象;`Flatten(circle)` 修改的是临时 `Ellipse`,`circle` 不变,临时对象随即成为垃圾。
|
|
|
+ - 改成 `explicit` 只是让调用点多一个 `(Ellipse)`,临时对象照旧,问题没解决。
|
|
|
+ - 构造函数让"创建新对象"在语法上可见,读者立刻明白改动会丢;把返回值存进变量即可修复。
|
|
|
+ - 转换操作符返回内部字段会直接破坏封装(见条目26)。
|
|
|
+ - 转换按编译期类型解析,调用者常需多次强转才能命中操作符,代码难维护。
|
|
|
+ - C# 不会把构造函数当转换用(与 C++ 不同),只有显式 `new` 才创建对象,所以不需要 `explicit` 关键字。
|
|
|
+- **陷阱**:
|
|
|
+ - 认为"隐式改显式"更安全——行为完全相同,只是把陷阱摆到明面上。
|
|
|
+ - 返回内部引用字段做转换,等于把私有状态交给调用者。
|
|
|
+
|
|
|
+### 条目10:使用可选参数减少方法重载的数量
|
|
|
+
|
|
|
+- **问题**:参数名和默认值都会进入调用方,成为公有契约;随意改动会造成编译失败或运行时异常。
|
|
|
+- **规则**:首次发布可自由使用可选/具名参数与任意重载;后续版本只**新增重载**,不改参数名、不改默认值。
|
|
|
+- **要点**:
|
|
|
+ - 默认值在调用点展开(与 `const` 同理),改默认值必须重编调用方;只发 DLL 补丁时,老调用方仍用旧默认值。
|
|
|
+ - 参数名存于被调用方元数据,但具名参数在编译期按名字绑定:改名会让重新编译的使用者失败,已编译程序集仍可运行。
|
|
|
+ - 给现有方法**新增参数**(即使带默认值)会让已编译调用方在运行时抛 `MissingMethodException`——这是二进制不兼容。
|
|
|
+ - n 个可选参数等价于 2^n − 1 个重载;4 参数的 Office `Add` 要 15 个重载才能等价。
|
|
|
+ - C# 4 起 COM 调用可省略 `ref`,`Type.Missing` 噪音基本消失。
|
|
|
+- **陷阱**:
|
|
|
+ - 调用方一用具名参数就永久绑定了参数名,所以参数名是 API 的一部分。
|
|
|
+ - `string.Empty` 不能作默认值(不是编译期常量),只能写 `""`。
|
|
|
+
|
|
|
+### 条目11:理解短小方法的优势
|
|
|
+
|
|
|
+- **问题**:为"省一次方法调用"把逻辑塞进大函数,反而妨碍 JIT 按需编译、寄存器分配和内联。
|
|
|
+- **规则**:写短小、控制流简单的方法,把优化决策留给 JIT。
|
|
|
+- **要点**:
|
|
|
+ - JIT 以方法为粒度按需编译:未被调用的方法不加载、不编译。大函数里的 `if/else` 两支在首次调用时都会被编译,包括只在出错时才走的分支;`switch` 更严重。
|
|
|
+ - 小函数局部变量少、分支少,JIT 更容易决定哪些变量放寄存器。
|
|
|
+ - 内联由 JIT 全权决定,C# 没有任何"请内联"的关键字或提示;只有 `[MethodImpl(MethodImplOptions.NoInlining)]` 能禁止内联(常用于调试时保留栈帧)。
|
|
|
+ - 虚方法和含 `try/catch` 的方法不会被内联;属性访问器通常会被内联。
|
|
|
+ - 内联会把"只编译被调用方法"变成"调用者所在方法也要编译被内联的代码"。
|
|
|
+ - 短小可组合的方法同时契合 LINQ 与函数式风格。
|
|
|
+- **陷阱**:
|
|
|
+ - 把异常/错误处理分支写进热路径大方法,启动时被迫 JIT 一堆冷代码。
|
|
|
+ - 手工"优化"(合并方法、展开循环)通常生成更慢的代码,因为扰乱了 JIT 的启发式。
|
|
|
+
|
|
|
+## 第 2 章 .NET 资源管理
|
|
|
+
|
|
|
+### 条目12:推荐使用成员初始化器而不是赋值语句
|
|
|
+
|
|
|
+- **问题**:构造函数一多,重复的初始化语句就容易漏写、写错。
|
|
|
+- **规则**:所有构造函数都要赋同一个值的成员,在声明处用初始化器初始化。
|
|
|
+- **要点**:
|
|
|
+ - 编译器把初始化器插到**每个**构造函数的最前面,且在调用基类构造函数之前,按字段声明顺序执行;自动生成的默认构造函数也包含。
|
|
|
+ - 因此新增构造函数、新增字段都不会漏初始化。
|
|
|
+ - 三种应避免初始化器的情况:初始化为 `0`/`null`(CLR 已整块清零,值类型还会走 `initobj` 引起装箱/拆箱);不同构造函数需要不同值(会重复创建对象);需要 `try/catch` 包裹(初始化器无法捕获异常)。
|
|
|
+ - 隐式属性无法用初始化器——后台字段不可访问,只能在构造函数中赋值;若改成显式属性,应把初始化搬回声明处。
|
|
|
+- **陷阱**:
|
|
|
+ - `List<int> labels = new List<int>();` 加一个 `MyClass2(int size)` 构造函数再 `labels = new List<int>(size)`,第一个 List 立刻变垃圾。
|
|
|
+ - 在初始化器里做重活,异常会直接从构造函数冒出去,类内无法补救。
|
|
|
+
|
|
|
+### 条目13:正确地初始化静态成员变量
|
|
|
+
|
|
|
+- **问题**:静态成员的初始化时机与异常处理都有特殊规则。
|
|
|
+- **规则**:简单赋值用静态初始化器;需要逻辑或异常处理用静态构造函数。
|
|
|
+- **要点**:
|
|
|
+ - 静态构造函数由 CLR 在类型首次被访问前自动调用,无参、每个 AppDomain 只执行一次,不能手动调用。
|
|
|
+ - 执行顺序:静态字段清零 → 静态初始化器 → 基类静态构造函数 → 静态构造函数。
|
|
|
+ - 单例最简写法:私有实例构造函数 + `private static readonly MySingleton _instance = new MySingleton();`。
|
|
|
+ - 静态构造函数抛异常 → `TypeInitializationException`,该类型在此 AppDomain 内永久不可用,后续访问直接失败且不会重试。
|
|
|
+- **陷阱**:
|
|
|
+ - 在静态构造函数里 `try/catch` 吞掉异常更糟:类型处于半初始化状态,后续所有使用都拿到未构造完整的对象。
|
|
|
+ - 用实例构造函数或某个 `Initialize()` 私有方法初始化静态成员,调用顺序无法保证。
|
|
|
+
|
|
|
+### 条目14:尽量减少重复的初始化逻辑
|
|
|
+
|
|
|
+- **问题**:多个构造函数复制粘贴,导致重复的字段初始化和重复的基类构造调用。
|
|
|
+- **规则**:用构造函数初始化器 `: this(...)` 把公共逻辑收拢到一个构造函数;或(C# 4+)用默认参数合并成一个构造函数。
|
|
|
+- **要点**:
|
|
|
+ - 一个构造函数只能有一个初始化器:要么 `this(...)`,要么 `base(...)`,不可兼得;因此基类构造函数只被调用一次,实例字段初始化器也不会被复制多份。
|
|
|
+ - 用私有辅助方法代替构造函数链做不到这点:辅助方法里不能给 `readonly` 字段赋值,且每个构造函数仍会各自执行字段初始化器与 `base()`。
|
|
|
+ - n 个可选参数可替代 2^n 个重载;但若允许 `new MyClass()`,应显式提供无参构造函数,否则带 `new()` 约束的泛型无法使用该类型。
|
|
|
+ - 默认值必须是编译期常量,故只能用 `""` 而非 `string.Empty`。
|
|
|
+ - 默认参数耦合更紧:改默认值需重编调用方(见条目10);重载则可通过新增重载平滑演进。
|
|
|
+ - 实例创建顺序(首次):静态字段清零 → 静态初始化器 → 基类静态构造函数 → 静态构造函数 → 实例字段清零 → 实例初始化器 → 基类实例构造函数 → 本类实例构造函数。
|
|
|
+- **陷阱**:
|
|
|
+ - 把 `readonly` 字段的赋值放进公共辅助方法 → 编译错误。
|
|
|
+ - 默认参数版本必须能接受调用者传入的任何值,且默认值永远合理、不抛异常。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public MyClass() : this(0, string.Empty) { }
|
|
|
+
|
|
|
+public MyClass(int initialCount) : this(initialCount, string.Empty) { }
|
|
|
+
|
|
|
+public MyClass(int initialCount, string name)
|
|
|
+{
|
|
|
+ labels = initialCount > 0 ? new List<int>(initialCount) : new List<int>();
|
|
|
+ this.name = name;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 条目15:使用using和try/finally清理资源
|
|
|
+
|
|
|
+- **问题**:`IDisposable` 对象在异常路径上容易被漏掉 `Dispose`。
|
|
|
+- **规则**:每个 `IDisposable` 对象都用 `using` 包裹(编译器生成 `try/finally`);同一方法有多个对象时用多个 `using` 或一个 `try/finally`。
|
|
|
+- **要点**:
|
|
|
+ - `using` 要求变量的**编译期类型**实现 `IDisposable`,否则编译错误;类型不确定时用 `obj as IDisposable` 再 `using`,为 `null` 时什么也不做。
|
|
|
+ - `Dispose()` 会调用 `GC.SuppressFinalize(this)`,`Close()` 通常不会——能选就选 `Dispose`。
|
|
|
+ - `Dispose` 不释放内存,只释放非托管资源;销毁后的对象仍在堆上,但已不可用。
|
|
|
+ - `SqlConnection`/`SqlCommand` 继承自 `Component`,带终结器,忘记 `Dispose` 会一直活到终结。
|
|
|
+- **陷阱**:
|
|
|
+ - 手写 `conn.Dispose(); cmd.Dispose();`,中间语句抛异常就两个都不释放。
|
|
|
+ - 把两个对象的创建塞进一个 `using (...)` 头部:若第二个构造函数抛异常,第一个对象还没进入 `using`,不会被释放。
|
|
|
+
|
|
|
+```csharp
|
|
|
+using var conn = new SqlConnection(connectionString);
|
|
|
+using var cmd = new SqlCommand(query, conn);
|
|
|
+conn.Open();
|
|
|
+cmd.ExecuteNonQuery();
|
|
|
+```
|
|
|
+时代注:原文用嵌套 `using` 语句块;C# 8 的 `using` 声明(`using var`)语义相同,作用域为所在块末尾。异步资源另有 `IAsyncDisposable` + `await using`。
|
|
|
+
|
|
|
+### 条目16:避免创建非必要的对象
|
|
|
+
|
|
|
+- **问题**:堆分配与回收都有成本,热路径上反复创建相同对象会显著拖慢程序。
|
|
|
+- **规则**:不创建非必要对象——热方法中的常用引用类型局部变量提升为成员,常用实例做静态缓存,不可变类型配可变 builder。
|
|
|
+- **要点**:
|
|
|
+ - 所有引用类型(含局部变量)都在堆上;方法返回后其局部对象立即成为垃圾。
|
|
|
+ - `OnPaint` 每次重绘 `new Font(...)` 是典型浪费;提为成员即可复用(此时类要实现 `IDisposable`,见条目17)。
|
|
|
+ - 只对**频繁调用**的方法做提升,不要把所有局部变量都成员化。
|
|
|
+ - `Brushes.Black` 是延迟初始化的静态缓存:首次用时创建,之后返回同一实例;没用到颜色不分配。
|
|
|
+ - `string` 不可变,`msg += ...` 每次都新建字符串;简单拼接用 `string.Format`/插值,多次累积用 `StringBuilder`。
|
|
|
+ - 不可变类型可配一个可变创建器(`StringBuilder` → `string`),既保证最终对象不可变,又避免中间垃圾。
|
|
|
+- **陷阱**:
|
|
|
+ - `msg = "Hello"; msg += " "; msg += "World";` 产生 3 个中间串全部变垃圾。
|
|
|
+ - 把 `IDisposable` 局部变量提为成员却忘了实现 `Dispose`,资源泄漏比垃圾更严重。
|
|
|
+
|
|
|
+### 条目17:实现标准的销毁模式
|
|
|
+
|
|
|
+- **问题**:自己持有非托管资源的类型,需要一套"使用者调用则及时释放、忘记调用也能兜底"的固定写法。
|
|
|
+- **规则**:`IDisposable.Dispose()` → `Dispose(true)` + `GC.SuppressFinalize(this)`;终结器 → `Dispose(false)`;实际清理放在 `protected virtual void Dispose(bool)`。
|
|
|
+- **要点**:
|
|
|
+ - 只有**直接**持有非托管资源的类型才需要终结器;仅持有其他 `IDisposable` 成员的类不需要。
|
|
|
+ - 有终结器的对象至少要熬过一次 GC(进终结队列、由独立线程执行),常被提升到 gen1,极端情况多活 9 个以上 GC 周期。
|
|
|
+ - `Dispose()` 要做的四件事:释放非托管资源;释放托管资源(含取消事件订阅);置 `disposed` 标志,之后公有方法抛 `ObjectDisposedException`;`SuppressFinalize`。
|
|
|
+ - `Dispose(bool)`:`true` 清托管 + 非托管,`false` 只清非托管;无论哪种都要调用 `base.Dispose(isDisposing)`。
|
|
|
+ - 派生类覆写 `Dispose(bool)` 后必须调基类版本;基类与派生类各自维护 `disposed` 标志,把错误限制在单个类型内。
|
|
|
+ - `Dispose` 必须可重入(重复调用无副作用);不要检查成员是否为 `null`(对象图仍在内存),但成员可能已被销毁。
|
|
|
+- **陷阱**:
|
|
|
+ - 在终结器里把 `this` 存入全局列表让对象"复活":它已被终结,不会再被终结,成员可能已失效,等于制造一个半死对象。
|
|
|
+ - 在 `Dispose`/终结器里做资源释放之外的逻辑(发事件、调外部服务),会把对象生命周期搅乱。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public class MyResourceHog : IDisposable
|
|
|
+{
|
|
|
+ private bool disposed;
|
|
|
+ public void Dispose()
|
|
|
+ {
|
|
|
+ Dispose(true);
|
|
|
+ GC.SuppressFinalize(this);
|
|
|
+ }
|
|
|
+ protected virtual void Dispose(bool isDisposing)
|
|
|
+ {
|
|
|
+ if (disposed) return;
|
|
|
+ if (isDisposing) { /* 释放托管资源、退订事件 */ }
|
|
|
+ /* 释放非托管资源 */
|
|
|
+ disposed = true;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 条目18:区分值类型和引用类型
|
|
|
+
|
|
|
+- **问题**:`struct` 还是 `class` 必须在设计时定死,事后切换会改变所有调用点的语义。
|
|
|
+- **规则**:值类型存数据,引用类型定义行为;拿不准就选 `class`。
|
|
|
+- **要点**:
|
|
|
+ - 值类型不支持继承(隐式 `sealed`),可实现接口但通过接口调用需要装箱。
|
|
|
+ - 内存布局:值类型内联在容器里。`class C { MyType a, b; }` 若 `MyType` 是值类型只分配 1 次(2 倍大小),是引用类型则分配 3 次。
|
|
|
+ - `new MyType[100]`:值类型一次分配 100 倍大小;引用类型只分配数组(元素全 `null`),初始化元素需 101 次分配。
|
|
|
+ - 返回值类型给的是副本,安全;返回引用类型字段会暴露内部引用(见条目26)。
|
|
|
+ - 判定值类型的四个问题全为"是"才用 `struct`:职责是数据存储?公有接口都是数据属性?绝不会有派生类型?永不需要多态?
|
|
|
+ - C++ 全值传递有对象切片问题,Java 全引用有堆压力,C# 让你按用途选。
|
|
|
+- **陷阱**:
|
|
|
+ - `Employee` 从 `struct` 改成 `class` 后,原本"一次性分红"的 `emp.Raise()` 变成永久加薪——编译器不会报错,行为已变。
|
|
|
+ - 给值类型加职责(发薪、校验、访问数据库)是把它当对象用,早晚要改成类。
|
|
|
+
|
|
|
+### 条目19:保证0为值类型的有效状态
|
|
|
+
|
|
|
+- **问题**:CLR 把新分配的内存全部清零,`default(T)`/`new T()` 一定得到 0,若 0 不是合法值,类型一创建就非法。
|
|
|
+- **规则**:枚举显式把 0 定义成合法值(如 `None`/`Unknown`);`[Flags]` 枚举的 0 表示"未设置任何标志"。
|
|
|
+- **要点**:
|
|
|
+ - `enum` 默认从 0 开始;显式从 1 开始定义会让 `new ObservationData()` 的字段为非法值。
|
|
|
+ - 结构的默认构造函数无法禁用,用户总能创建全零实例;要禁止只能改成 `class`。
|
|
|
+ - 若没有合适的"默认"语义,把 0 定义成 `None`/未初始化,允许稍后赋值。
|
|
|
+ - 含引用的值类型默认字段为 `null`:用属性在 getter 里把 `null` 换成 `string.Empty`,把检查收敛到一处,getter 通常会被内联。
|
|
|
+- **陷阱**:
|
|
|
+ - `[Flags]` 里把某个真实标志设为 0,`(flags & Flat) != 0` 永远为 `false`,判断静默失效。
|
|
|
+ - 依赖"用户一定会显式初始化"的枚举设计,在数组、泛型容器、反序列化场景全部崩掉。
|
|
|
+
|
|
|
+### 条目20:保证值类型的常量性和原子性
|
|
|
+
|
|
|
+- **问题**:可变值类型在逐字段赋值过程中会短暂处于无效状态,线程与异常安全都无法保证。
|
|
|
+- **规则**:数据型值类型做成不可变:所有字段 `readonly`、全参构造函数、修改时返回新实例。
|
|
|
+- **要点**:
|
|
|
+ - 不可变带来的好处:天生线程安全、异常安全(要么旧值要么新值)、可安全暴露、哈希稳定(见条目7)。
|
|
|
+ - 隐式属性的私有 setter 仍是修改入口;要完全不可变须改成显式属性 + `readonly` 后台字段。
|
|
|
+ - 防御性复制:构造函数接收数组/可变引用类型时要复制;属性返回这类字段时也要复制。
|
|
|
+ - 原子性:`Address` 是一个整体(改一个字段通常要连带改其他),`Customer` 不是(名字、地址、电话可独立变更)——非原子类型应由原子类型组合而成。
|
|
|
+ - 三种构造策略:构造函数(`Address`)、工厂方法(`Color.FromKnownColor`)、可变 builder(`StringBuilder` → `string`)。
|
|
|
+- **陷阱**:
|
|
|
+ - `a1.City = "AnnArbor"` 后 `ZipCode` 还没改就抛异常,对象停在 City 与 State/Zip 不匹配的无效状态。
|
|
|
+ - 多线程在 `City` 改完、`State` 未改的瞬间切换,另一个线程看到不一致的数据。
|
|
|
+ - 构造函数只做防御性复制的一半(只复制数组引用),外部仍能通过原数组改内部状态。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public readonly struct Address
|
|
|
+{
|
|
|
+ public string City { get; }
|
|
|
+ public string State { get; }
|
|
|
+ public Address(string city, string state) => (City, State) = (city, state);
|
|
|
+ public Address WithCity(string city) => new Address(city, State);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 第 3 章 使用 C# 表达设计
|
|
|
+
|
|
|
+### 条目21:限制类型的可见性
|
|
|
+
|
|
|
+- **问题**:默认写 `public class` 会把实现细节变成永久契约,日后无法替换。
|
|
|
+- **规则**:在能完成工作的前提下给类型最小可见性:优先 `internal`/`private`,用公有接口暴露功能。
|
|
|
+- **要点**:
|
|
|
+ - 内部类也能实现公有接口,使用者只见接口,不见实现类型。
|
|
|
+ - `List<T>` 内嵌私有的 `Enumerator<T>` 实现 `IEnumerator<T>`;实现类型可随时替换,且因为不是公有类型,无需 CLS 兼容。
|
|
|
+ - 公有类型越少:升级改动面越小、单元测试越少、越容易用替身替换。
|
|
|
+ - 用工厂按需返回不同 `internal` 实现(如美国/国际电话号码验证器),程序集外只暴露 `IPhoneValidator`。
|
|
|
+ - 共享实现多时可用 `public abstract` 基类代替接口,同样能减少公有类型数量。
|
|
|
+- **陷阱**:
|
|
|
+ - 无脑接受 VS 向导生成的 `public class`;注释指出新版 VS 已不再默认加 `public`。
|
|
|
+ - 一旦发布公有类型,改名/删除都是破坏性变更,因为使用者可能已直接依赖它。
|
|
|
+
|
|
|
+### 条目22:通过定义并实现接口替代继承
|
|
|
+
|
|
|
+- **问题**:继承与接口都能复用/抽象,但演化能力完全不同。
|
|
|
+- **规则**:相关类型用抽象基类共享实现,不相关类型用接口暴露能力;方法参数与返回值尽量用接口类型。
|
|
|
+- **要点**:
|
|
|
+ - 基类可新增成员而不破坏派生类;接口新增成员会破坏**所有**实现者(C# 8 之前)。
|
|
|
+ - 接口无实现、无数据成员;扩展方法可"附加"实现——`System.Linq.Enumerable` 在 `IEnumerable<T>` 上定义了 30 多个扩展方法。
|
|
|
+ - 返回接口而非具体集合:既能换实现,又限制调用者能做的操作(`IEnumerable<T>` 而非 `List<T>`)。
|
|
|
+ - 不相关类型(`Employee`/`Customer`/`Vendor`)可共同实现 `IContactInfo`,一个方法处理全部。
|
|
|
+ - `struct` 实现 `IComparable<T>`/`IComparable` 后,通过接口调用不必拆箱。
|
|
|
+- **陷阱**:
|
|
|
+ - 返回 `List<WeatherData>`:调用者可 `Add`/`Remove`/`Clear`,破坏封装,且以后换 `SortedList` 会破坏所有调用方。
|
|
|
+ - 把接口当"没有实现的基类"用,结果每个实现者重复写相同代码;此时应改为抽象基类。
|
|
|
+- 时代注:C# 8 起接口可有默认实现(DIM),"给接口加成员必破坏实现者"不再绝对;但默认实现不能带来实例状态,也不能被继承。
|
|
|
+
|
|
|
+### 条目23:理解接口方法和虚方法的区别
|
|
|
+
|
|
|
+- **问题**:实现接口成员默认不是虚方法,派生类写同名方法只是隐藏,不会改变通过接口引用的行为。
|
|
|
+- **规则**:需要派生类改变行为时,在基类把接口实现写成 `virtual`、`abstract`,或"接口方法调用 `protected virtual` 钩子"。
|
|
|
+- **要点**:
|
|
|
+ - 接口成员默认非虚;派生类同名方法必须 `new`,且通过 `IMsg` 引用仍调用基类实现。
|
|
|
+ - 派生类可重新声明实现接口(再次写 `: IMsg`),但通过基类引用仍走旧实现。
|
|
|
+ - 模板方法模式:`public void Message() => OnMessage();` + `protected virtual void OnMessage()`,与 `IDisposable` 的 `Dispose()`→`Dispose(bool)` 同构。
|
|
|
+ - 显式接口实现可把成员从公有接口隐藏(见条目31),并让强类型重载优先。
|
|
|
+ - 派生类可把 `override` 方法 `sealed` 掉,终止进一步覆写。
|
|
|
+ - 派生类只要有一个签名匹配的公有方法,就能满足基类已声明的接口契约(但无法显式实现)。
|
|
|
+- **陷阱**:
|
|
|
+ - 以为 `new` 隐藏等于覆写;通过接口调用时仍是基类版本,行为分裂。
|
|
|
+ - 在基类实现接口却不留任何虚钩子,派生类只能重新实现接口,调用路径变得依赖引用的静态类型。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public interface IMsg { void Message(); }
|
|
|
+
|
|
|
+public class MyClass : IMsg
|
|
|
+{
|
|
|
+ public void Message() => OnMessage(); // 契约入口
|
|
|
+ protected virtual void OnMessage() { } // 派生类钩子
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 条目24:用委托实现回调
|
|
|
+
|
|
|
+- **问题**:需要比接口更松的耦合、且目标要在运行时绑定(可能多个)时,接口过于笨重。
|
|
|
+- **规则**:用委托(`Action`/`Func`/`Predicate<T>` + lambda)实现回调,而不是要求调用方实现某个接口。
|
|
|
+- **要点**:
|
|
|
+ - 委托类型安全,可指向静态或实例方法,可在运行时配置目标、通知多个订阅者。
|
|
|
+ - `Predicate<T>` 与 `Func<T,bool>` 语义相同但**不是同一类型**,不能互换。
|
|
|
+ - `List<T>.Find`/`TrueForAll`/`RemoveAll`/`ForEach` 都是回调式 API。
|
|
|
+ - 多播委托按添加顺序调用;某个目标抛异常会终止整条链,返回值只取最后一个目标的。
|
|
|
+ - 需要每个目标的结果或短路时,手动遍历 `GetInvocationList()` 逐个 `DynamicInvoke`。
|
|
|
+- **陷阱**:
|
|
|
+ - 在多播委托上用返回值做"是否继续"的判断,除最后一个外全部被忽略。
|
|
|
+ - 以为多播调用是事务性的:前一个目标已产生副作用,后一个抛异常不会回滚。
|
|
|
+
|
|
|
+```csharp
|
|
|
+bool CheckWithUser(string msg) => Ask(msg);
|
|
|
+
|
|
|
+Func<string, bool> checker = CheckWithUser;
|
|
|
+checker += s => Log(s);
|
|
|
+
|
|
|
+foreach (Func<string, bool> t in checker.GetInvocationList())
|
|
|
+ if (!t("继续?")) break; // 自己遍历才能拿到每个返回值
|
|
|
+```
|
|
|
+
|
|
|
+### 条目25:用事件模式实现通知
|
|
|
+
|
|
|
+- **问题**:对象状态变化要广播给数量未知、编译期未知的订阅者。
|
|
|
+- **规则**:用 `event` 声明(编译器生成 add/remove 访问器),触发前把事件复制到局部变量。
|
|
|
+- **要点**:
|
|
|
+ - `public event EventHandler<LoggerEventArgs> Log;` 编译器生成私有委托字段 + `add_Log`/`remove_Log`;需要额外逻辑时才手写访问器。
|
|
|
+ - 触发:`var handler = Log; if (handler != null) handler(this, e);` 复制引用是为了避免"检查非空"与"调用"之间被退订的竞态。
|
|
|
+ - 事件不要求有订阅者,发送者无需知道接收者,二者可独立开发——这就是观察者模式的语法化。
|
|
|
+ - 事件很多时用 `EventHandlerList` 按 key 延迟创建(无泛型版,需转型);也可自建 `Dictionary<object, Delegate>` 泛型版本。
|
|
|
+ - `System.Windows.Forms.Control` 内部用更复杂的事件键集合隐藏字段操作。
|
|
|
+- **陷阱**:
|
|
|
+ - 直接 `if (Log != null) Log(this, e);` 在多线程下有竞态。
|
|
|
+ - 订阅者不退订,发布者会长期持有其引用,造成"事件泄漏"(第2章引言明确点名)。
|
|
|
+- 时代注:`Log?.Invoke(this, e)` 同样安全(编译器生成局部副本);现代 `EventHandler<T>` 的 `T` 不再要求继承 `EventArgs`。
|
|
|
+
|
|
|
+### 条目26:避免返回对内部类对象的引用
|
|
|
+
|
|
|
+- **问题**:只读属性若返回引用类型,调用者仍能修改对象内部状态,封装被绕过。
|
|
|
+- **规则**:不要把内部可变对象的引用交出去;用值类型、不可变类型、受限接口或只读包装器。
|
|
|
+- **要点**:
|
|
|
+ - 返回引用等于交出句柄,调用者无需经过你的方法就能改内部数据。
|
|
|
+ - 策略一:值类型——返回的是副本,改副本不影响原对象。
|
|
|
+ - 策略二:不可变类型——`string` 等天然安全。
|
|
|
+ - 策略三:接口——只暴露功能子集(`IEnumerable<T>` 代替 `List<T>`);仍可被强转破解,但成本高。
|
|
|
+ - 策略四:包装器——`ReadOnlyCollection<T>` 是标准只读封装。
|
|
|
+ - 若要允许编辑又需校验:内部用 `BindingList<T>` 并订阅其 `ListChanged` 做验证/联动;但 `AllowEdit`/`AllowNew`/`AllowRemove` 是公有可改属性,所以不能直接把 `BindingList<T>` 暴露出去。
|
|
|
+- **陷阱**:
|
|
|
+ - `public DataSet Data => _data;` 外部可任意改表结构。
|
|
|
+ - `public List<T> Items { get; }` 外部可 `Clear()`,且以后换实现会破坏调用方。
|
|
|
+
|
|
|
+### 条目27:让类型支持序列化
|
|
|
+
|
|
|
+- **问题**:类型不支持序列化,使用者几乎无法从外部替它补上(访问不到私有状态)。
|
|
|
+- **规则**:除 UI 控件/窗体外都加 `[Serializable]`;默认机制不够时实现 `ISerializable`。
|
|
|
+- **要点**:
|
|
|
+ - `[Serializable]` 会递归要求所有成员类型可序列化,否则序列化时抛运行时异常;对象图中的循环引用会被正确重建,每个对象只存一次。
|
|
|
+ - `[NonSerialized]` 字段反序列化后为 `0`/`null`——构造函数和字段初始化器都不会执行;需要 `IDeserializationCallback.OnDeserialization` 补救。
|
|
|
+ - `OnDeserialization` 的调用顺序不保证,期间其他对象可能访问本对象的公有成员,因此所有公有方法都要能容忍 `NonSerialized` 字段未初始化。
|
|
|
+ - 版本演进:增删字段会让默认机制抛异常;`ISerializable` 需 `GetObjectData` + 序列化构造函数,键名用字段名、顺序按声明顺序,改名或重排即破坏旧文件兼容。
|
|
|
+ - 非 `sealed` 基类实现 `ISerializable`:序列化构造函数改 `protected`,加 `virtual WriteObjectData` 钩子,派生类必须配套;读写顺序在整条继承链上必须一致。
|
|
|
+ - 隐式属性的后台字段名不是合法 C# 标识符,访问不到 → 要自定义序列化就必须用显式字段。
|
|
|
+- **陷阱**:
|
|
|
+ - 直接给带隐式属性的类加 `[Serializable]`,等发现需要版本兼容时旧文件已经存在,无法补救。
|
|
|
+ - 漏掉对象图里任意一个类型,整张图都序列化失败。
|
|
|
+- 时代注:现代 .NET(Core 3.0+)已弃用 `BinaryFormatter`,.NET 8/9 默认禁用;持久化应改用 `System.Text.Json`、protobuf 等。条目中的 `ISerializable`/`NonSerialized` 语义仍然成立,但绑定在过时的二进制格式化器上。
|
|
|
+
|
|
|
+### 条目28:提供粗粒度的因特网服务API
|
|
|
+
|
|
|
+- **问题**:把本地 API 原样包成 Web 服务,会导致每次属性读写都一次网络往返。
|
|
|
+- **规则**:按"一次交互完成一件事"设计远程接口:一次传输完整对象或相关对象图,客户端脱机工作一段时间后批量提交。
|
|
|
+- **要点**:
|
|
|
+ - 远程调用最贵的是传输,不是计算;API 粒度越细,等待时间占比越高。
|
|
|
+ - 传整个 `Customer` 而不是逐个 `get_Name`/`set_Name`。
|
|
|
+ - 粒度要按业务场景定:一次通话需要"一个客户 + 其未完成订单",而不是全部客户与全部订单(百万客户 × 15 订单无法一次下发)。
|
|
|
+ - 进一步优化:按团队/区号预取,提交时只返回自上次请求以来的增量(delta),把两次往返压成一次。
|
|
|
+ - 通信频率与单次数据量通常互斥,取折中:宁可次数少、单次多。
|
|
|
+- **陷阱**:
|
|
|
+ - 极端一:聊天式细粒度调用,网络延迟主导全部耗时。
|
|
|
+ - 极端二:为了"一次传完"把整库拉到客户端,传输量与内存都爆炸。
|
|
|
+
|
|
|
+### 条目29:支持泛型协变和逆变
|
|
|
+
|
|
|
+- **问题**:泛型默认不变,`IEnumerable<Derived>` 不能赋给 `IEnumerable<Base>`,即使这样做类型安全。
|
|
|
+- **规则**:类型参数只出现在输出位置用 `out`(协变),只出现在输入位置用 `in`(逆变);接口与委托都应标注。
|
|
|
+- **要点**:
|
|
|
+ - 协变 `out`:返回值、属性 getter;逆变 `in`:方法参数。
|
|
|
+ - `IEnumerable<out T>`、`IEnumerator<out T>`、`IReadOnlyList<out T>` 支持协变;`IComparable<in T>`、`IEqualityComparer<in T>` 支持逆变。
|
|
|
+ - `IList<T>` 不变(既可读又可写),所以 `IList<Planet>` 不能当 `IList<CelestialBody>` 用。
|
|
|
+ - `IEquatable<T>` 必须不变:类型不同就谈不上相等(见条目6)。
|
|
|
+ - 数组协变是运行时检查,写入错误类型抛 `ArrayTypeMismatchException`;泛型协变是编译期安全的。
|
|
|
+ - 委托:`Func<out T>`、`Action<in T>`;委托出现在参数位置会把变体方向翻转(`Action<Action<T>>` 相对外层是协变)。
|
|
|
+ - 编译器会校验 `in`/`out` 的使用位置,误用直接编译失败。
|
|
|
+- **陷阱**:
|
|
|
+ - 声明 `out T` 却又有接受 `T` 的方法 → 编译错误;反过来 `in T` 出现在返回值同样不行。
|
|
|
+ - 看到 `Action<T>` 支持逆变就以为接口整体逆变;委托嵌在参数/返回位置时方向会翻转,容易判断错。
|
|
|
+- 时代注:现代 BCL 已为绝大多数接口补上 `in`/`out`(`IReadOnlyList`、`IComparable`、`IEqualityComparer` 等),原文"很多接口尚未标注"的情况已不存在。
|
|
|
+
|
|
|
+## 第 4 章 使用框架
|
|
|
+
|
|
|
+### 条目30:使用覆写而不是事件处理函数
|
|
|
+
|
|
|
+- **问题**:框架对同一事件提供两条响应路径——挂接事件处理函数,或覆写基类的受保护虚方法(如 `OnMouseDown`),需要判断该用哪条。
|
|
|
+- **规则**:在派生类内部响应自身关心的事件时,覆写对应的 `protected virtual OnXxx` 方法;事件处理函数只用于不相关对象之间的交互(包括 XAML/设计器生成的声明式行为)。
|
|
|
+- **要点**:
|
|
|
+ - 基类的虚方法负责触发事件:覆写中必须调用 `base.OnMouseDown(e)`,否则所有挂接的处理函数都不会被调用;不调用 base 正是"吞掉"事件的合法手段。
|
|
|
+ - 性能:事件基于多播委托,触发前要判空并遍历整个调用列表逐个调用;覆写只是一次虚方法分派,路径更短。
|
|
|
+ - 正确性:事件处理函数链中前面的处理函数抛异常会阻断后续处理函数;覆写能保证自己的逻辑先执行且行为确定。
|
|
|
+ - 可维护性:覆写只有一个方法;事件要维护"处理函数 + 挂接代码"两处。
|
|
|
+ - 事件不可替代的场景:运行时切换处理策略(绘图程序按当前模式决定 MouseDown 是画线还是选中)、同一事件挂多个处理函数(一个执行操作、一个更新状态栏)、非派生类与设计器生成的代码。
|
|
|
+- **陷阱**:在派生类里既覆写又挂接自己的处理函数,导致同一逻辑执行两次;覆写时忘记 `base` 调用,界面看似正常但外部订阅者全部失效。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public class MyForm : Form
|
|
|
+{
|
|
|
+ protected override void OnMouseDown(MouseEventArgs e)
|
|
|
+ {
|
|
|
+ base.OnMouseDown(e); // 先让基类触发已挂接的处理函数
|
|
|
+ if (e.Button == MouseButtons.Left)
|
|
|
+ StartDrag(e.Location);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 条目31:使用IComparable<T>和IComparer<T>实现顺序关系
|
|
|
+
|
|
|
+- **问题**:类型要能被集合排序、搜索(`Array.Sort`、`List<T>.Sort`、`BinarySearch`),必须提供顺序关系;而"自然顺序"与"临时排序规则"需要分开表达。
|
|
|
+- **规则**:用 `IComparable<T>` 定义自然顺序;同时显式实现非泛型 `IComparable` 以兼容旧 API 和反射;重载 `<`、`>`、`<=`、`>=` 且语义与 `CompareTo` 一致;其他顺序用 `IComparer<T>`(老 API)或 `Comparison<T>` 委托(新 API)。
|
|
|
+- **要点**:
|
|
|
+ - `CompareTo` 沿用 `strcmp` 约定:小于返回负数、等于返回 0、大于返回正数,调用方只应判断符号。
|
|
|
+ - 非泛型 `IComparable.CompareTo(object)` 每次比较都要做运行时类型检查,并对值类型装箱/拆箱;排序约需 n·log n 次比较,1000 个元素约 7000 次比较、每次 3 次装箱/拆箱,合计 2 万次以上。
|
|
|
+ - 用显式接口实现把 `CompareTo(object)` 藏起来:`customer.CompareTo(wrongType)` 直接编译失败,只有显式转成 `IComparable` 才能调用。
|
|
|
+ - 强类型重载对"编译期已知具体类型"的代码更快;但 `Sort` 内部仍通过接口指针调用,这部分收益拿不到。
|
|
|
+ - 顺序关系与等同性无关:`Equals` 返回 false 时 `CompareTo` 仍可返回 0(两个不同对象可以排序位置相同);实现顺序不需要实现 `==`/`Equals`。
|
|
|
+ - 无法修改类型源码时(如给 BCL 类型自定义排序)才在外部写 `IComparer<T>`;能改源码就用类型内部的比较器或 `Comparison<T>`。
|
|
|
+- **陷阱**:只实现泛型接口,导致 `ArrayList`、非泛型 `Sort`、反射调用路径在运行时抛 `ArgumentException`;比较操作符与 `CompareTo` 结果不一致,出现 `a < b` 与 `a.CompareTo(b) < 0` 不同真。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public struct Customer : IComparable<Customer>, IComparable
|
|
|
+{
|
|
|
+ public string Name { get; set; }
|
|
|
+ public int CompareTo(Customer other) =>
|
|
|
+ string.Compare(Name, other.Name, StringComparison.Ordinal);
|
|
|
+ int IComparable.CompareTo(object obj) => CompareTo((Customer)obj);
|
|
|
+ public static bool operator <(Customer l, Customer r) => l.CompareTo(r) < 0;
|
|
|
+ public static bool operator >(Customer l, Customer r) => l.CompareTo(r) > 0;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 条目32:避免使用ICloneable接口
|
|
|
+
|
|
|
+- **问题**:`ICloneable` 只声明 `object Clone()`,不说明是深复制还是浅复制;一旦基类实现它,所有派生类都被迫正确实现,而基类的 `Clone` 往往复制不出派生类的字段。
|
|
|
+- **规则**:值类型永不实现 `ICloneable`,直接用赋值;密封的叶子类只在确实需要复制时实现;非密封基类不实现 `ICloneable`,改为提供 `protected` 复制构造函数,让派生类自行决定。
|
|
|
+- **要点**:
|
|
|
+ - 深/浅复制混用会造成语义不一致:浅复制共享引用成员,深复制递归克隆;接口本身无法表达选了哪种。
|
|
|
+ - 值类型 `Clone()` 要把返回值装箱成 `object`,调用方再拆箱,比赋值慢且多余。
|
|
|
+ - `struct` 内含引用成员时,赋值只做浅复制;要深复制则要求被含类型也支持深复制,链条很快就失控。
|
|
|
+ - 非密封基类的 `Clone` 返回的是 `BaseType` 实例:`(Derived)baseObj.Clone()` 得到 `null`(`as`)或抛 `InvalidCastException`(强制转换),派生类新增的数组等字段也不会被复制。
|
|
|
+ - 正确姿势是复制构造函数链:`Derived(Derived other) : base(other)`,基类只复制自己那段;叶子类按需实现 `ICloneable`。
|
|
|
+ - `string` 是不可变类型,浅复制共享字符串引用是安全的特例。
|
|
|
+ - BCL 在泛型化时没有提供 `ICloneable<T>`,本身就说明这个接口设计失败。
|
|
|
+- **陷阱**:在基类实现 `ICloneable` 后假定派生类"自动"支持克隆;用 `Clone()` 代替复制构造函数,丢失派生类状态。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public class BaseType
|
|
|
+{
|
|
|
+ private readonly int[] values;
|
|
|
+ protected BaseType(BaseType other) => values = (int[])other.values.Clone();
|
|
|
+ public BaseType() => values = Array.Empty<int>();
|
|
|
+}
|
|
|
+public sealed class Derived : BaseType
|
|
|
+{
|
|
|
+ private readonly int[] dValues;
|
|
|
+ public Derived(Derived other) : base(other) => dValues = (int[])other.dValues.Clone();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 条目33:仅用new修饰符处理基类更新
|
|
|
+
|
|
|
+- **问题**:基类发布新版本后新增了一个与派生类已有成员同名的方法,编译器报警告,需要决定改名还是用 `new` 压制。
|
|
|
+- **规则**:只有在"成员已发布、无法让所有调用方改名,且必须保留旧语义"时,才用 `new` 声明这是基类同名成员的新版本;其他一切情况都不要用 `new` 重定义非虚成员。
|
|
|
+- **要点**:
|
|
|
+ - 非虚方法是静态绑定的:调用哪个方法由引用的编译期类型决定,运行时不看对象实际类型;`new` 不会把方法变成虚方法,只是在派生类作用域里新增了一个独立方法。
|
|
|
+ - 同一个对象换一个"标签"(引用类型)调用同名方法会执行不同代码,这是二义性的来源,而不是多态。
|
|
|
+ - 若基类成员本来是 `virtual`,派生类用 `new` 会切断多态:通过基类引用调用仍走基类实现。
|
|
|
+ - 若基类成员是 `virtual` 且派生类用 `override`,才是"用不同方式实现同一语义"的正确做法。
|
|
|
+ - 能改所有调用方源码时优先改名:长期更简单;`new` 只是二进制兼容的权宜之计,将来用户想调用基类版本时冲突会再次出现。
|
|
|
+ - "避免 `new`"不等于"把所有成员都设为 virtual":virtual 是设计者主动给出的扩展契约,只应给那些预期被多态改写的成员。
|
|
|
+- **陷阱**:`BaseWidget w = new MyWidget(); w.NormalizeValues();` 调的是 `BaseWidget` 版本,而 `MyWidget m = ...; m.NormalizeValues();` 调的是新版本——同一对象两种行为。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public class BaseWidget // 新版本中新增
|
|
|
+{
|
|
|
+ public void NormalizeValues() { /* 基类默认实现 */ }
|
|
|
+}
|
|
|
+public class MyWidget : BaseWidget // 已发布,不能改名
|
|
|
+{
|
|
|
+ public new void NormalizeValues() { /* 保持原有语义 */ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 条目34:避免重载基类中定义的方法
|
|
|
+
|
|
|
+- **问题**:派生类给基类已有的方法名添加一个不同参数的重载,会让重载解析偏向派生类,行为与"参数更匹配者胜出"的直觉相反。
|
|
|
+- **规则**:派生类的新功能换一个名字;需要改变同一语义的实现就用 `override` 虚方法;不要靠强制转型去"修好"重载选择。
|
|
|
+- **要点**:
|
|
|
+ - 候选集包含目标类、基类、扩展方法、所实现接口中的同名方法,再叠加泛型方法与可选参数,解析规则复杂到难以目测。
|
|
|
+ - 核心规则:只要派生类型中存在同名候选,即使基类有更精确的匹配,也优先选派生类的版本。`new D().Foo(d)` 与 `new D().Foo(b)` 都输出 `D.Foo`。
|
|
|
+ - 非虚方法按编译期类型绑定:`B obj3 = new D(); obj3.Foo(...)` 走 `B.Foo`,因为 `Foo` 不是虚方法。
|
|
|
+ - 可选参数版同理:`D.Bar(int, int = 0)` 会让 `d.Bar(1)` 选中派生类版本,尽管 `B.Bar(int)` 看起来更匹配。
|
|
|
+ - 泛型协变/逆变会扩大候选集:C# 4.0 起 `IEnumerable<D2>` 可传给 `IEnumerable<B2>` 参数,于是 `D.Foo2(IEnumerable<D2>)` 成为候选并胜出;不支持变型的旧版本则只有 `B.Foo2` 可选。
|
|
|
+ - 用户唯一"修复"手段是 `((B)d).Foo(d)`,需要转型才能得到预期重载,说明 API 设计失败。
|
|
|
+- **陷阱**:以为"名字相同、参数不同"只是方便;实际是把基类的命名契约重新解释了一遍,调用方无法预测结果。
|
|
|
+- 时代注:原文以 C# 4.0 的泛型变型为分界,现代编译器一律按支持协变/逆变的规则解析,旧版本行为已不再出现。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public class B
|
|
|
+{
|
|
|
+ public void Foo(B b) => Console.WriteLine("B.Foo");
|
|
|
+}
|
|
|
+public class D : B
|
|
|
+{
|
|
|
+ public void Foo(D d) => Console.WriteLine("D.Foo"); // 不要这样重载
|
|
|
+}
|
|
|
+// new D().Foo(new D()) 输出 D.Foo,而不是更"匹配"的 B.Foo
|
|
|
+```
|
|
|
+
|
|
|
+### 条目35:PLINQ如何实现并行算法
|
|
|
+
|
|
|
+- **问题**:怎样用 PLINQ 把 LINQ to Objects 查询并行化,并知道它在什么情况下会退化为顺序执行、什么时候会改变求值模型。
|
|
|
+- **规则**:在数据源上调用 `AsParallel()` 开启并行(返回 `IParallelEnumerable<T>`,扩展方法与 `Enumerable` 一一对应);有副作用或必须顺序的片段用 `AsSequential()`;结果顺序无关时用 `AsUnordered()`,必须保序用 `AsOrdered()`;用 `WithDegreeOfParallelism`/`WithExecutionMode`/`WithMergeOptions` 调优,且以实测为准,没有测试机就用默认值。
|
|
|
+- **要点**:
|
|
|
+ - 四种分区:范围分区(要求可索引且知道元素个数,即 `IList<T>`/数组,按任务数均分);区块分区(按需领取,块大小随负载与过滤率增长,目标是所有任务同时结束);条带分区(跳过 N 处理 M,避免 `TakeWhile`/`SkipWhile` 的线程同步);散列分区(专供 `Join`/`GroupJoin`/`GroupBy`/`Distinct`/`Except`/`Union`/`Intersect`,保证同散列码元素由同一任务处理,减少跨任务通信)。
|
|
|
+ - 三种执行模型:管道(默认,一个枚举线程 + 每核一个工作线程,通常是核数+1);停止并进行(需要完整结果时使用:`ToList`/`ToArray`/排序,速度略升但内存占用更大);反向枚举(`ForAll`,不生成结果序列,内存最省,有时最快)。
|
|
|
+ - 求值模型与 LINQ to Objects 不同:后者每次 `MoveNext` 只处理到下一个结果;PLINQ 在请求第一个结果时就让所有线程开始尽量多地生成结果,更像 LINQ to SQL 的预求值。
|
|
|
+ - `Skip`/`Take` 倾向顺序执行(生成 20 个元素比 300 个便宜);一旦加 `orderby`,必须先物化全部元素再排序,PLINQ 才会改用并行生成。排序键要选编译器无法优化掉的表达式(如 `n.ToString().Length`)。
|
|
|
+ - 受 Amdahl 定律约束:`OrderBy`/`ThenBy`/`Skip`/`Take` 等需要任务间协调的方法会拉低并行度。
|
|
|
+ - PLINQ 只作用于 LINQ to Objects;LINQ to SQL / EF 的并行由数据库引擎负责,`AsParallel` 无从发挥。
|
|
|
+- **陷阱**:在查询里写副作用(打印、累加共享变量)——执行顺序不可预测,结果不可重现;以为加了 `AsParallel()` 一定更快,小数据源或强顺序依赖的查询会更慢。
|
|
|
+
|
|
|
+```csharp
|
|
|
+var results = Enumerable.Range(1, 150)
|
|
|
+ .AsParallel() // 之后的操作可能并行
|
|
|
+ .Where(n => n % 2 == 0)
|
|
|
+ .Select(n => n * n)
|
|
|
+ .AsSequential() // 需要顺序的收尾工作
|
|
|
+ .ToArray();
|
|
|
+```
|
|
|
+
|
|
|
+### 条目36:理解PLINQ在I/O密集场景中的应用
|
|
|
+
|
|
|
+- **问题**:并行库看起来是为 CPU 密集计算设计的,但真实程序大量时间花在等待网络/磁盘;I/O 密集需要比核数更多的线程,选错 API 就拿不到加速。
|
|
|
+- **规则**:I/O 密集优先用 `Parallel.ForEach`(按阻塞情况动态增减线程),而不是线程数固定的 PLINQ;要把"发起—完成"解耦,用 `Task` + `TaskCompletionSource` 把 EAP/APM 异步模式包装成任务,再用 `ContinueWith` 挂后续处理。
|
|
|
+- **要点**:
|
|
|
+ - 默认情况下并行库就会根据线程忙碌程度让 `ThreadPool` 增加线程:阻塞等待 I/O 的线程越多,注入的线程越多。
|
|
|
+ - 同步 `WebClient.DownloadData` 的线程绝大部分时间在等待;改成 `Parallel.ForEach` 后,双核机器上的加速比近似与 URL 数量成正比。
|
|
|
+ - PLINQ 使用固定线程数(可用 `WithDegreeOfParallelism` 调整),`Parallel.ForEach` 自己管理线程数;I/O 与 CPU 混合的场景更适合后者。
|
|
|
+ - EAP(基于事件):注册 `DownloadDataCompleted`,在事件处理函数里设置 `TaskCompletionSource` 的结果;`TaskCompletionSource<T>` 把"创建任务"和"标记完成"分离,`TaskScheduler` 负责调度。
|
|
|
+ - APM(`BeginFoo`/`EndFoo` + `IAsyncResult`):用 `Task<TResult>.Factory.FromAsync` 包装,只是委托不同。
|
|
|
+ - 这些做法仍是"并行"而非"异步":调用方要等所有请求结束;`ForAll`/等待全部任务会阻塞到最后一个完成。
|
|
|
+- **陷阱**:用 PLINQ 发起大量阻塞下载,线程数被固定住,等待时间无法重叠;在 UI 线程上同步等待全部任务,界面卡死甚至死锁。
|
|
|
+- 时代注:`WebClient` 已被 `HttpClient` 取代,现代写法是 `async/await` + `Task.WhenAll`,I/O 密集不需要为每个请求占一个线程。
|
|
|
+
|
|
|
+```csharp
|
|
|
+static Task<byte[]> StartDownload(string url)
|
|
|
+{
|
|
|
+ var tcs = new TaskCompletionSource<byte[]>();
|
|
|
+ var client = new WebClient();
|
|
|
+ client.DownloadDataCompleted += (s, e) =>
|
|
|
+ tcs.TrySetResult(e.Result); // 错误路径见条目37
|
|
|
+ client.DownloadDataAsync(new Uri(url));
|
|
|
+ return tcs.Task; // 创建与完成分离
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 条目37:注意并行算法中的异常
|
|
|
+
|
|
|
+- **问题**:后台线程抛出的异常无法被发起线程直接捕获,也不会保留原调用栈;并行算法还必须考虑"部分任务已修改状态"后的回滚。
|
|
|
+- **规则**:外层捕获 `AggregateException` 并递归/扁平化检查 `InnerExceptions`;只处理能恢复的异常类型,其余重新抛出整个聚合异常;更优的做法是让后台任务根本不把异常抛出去——能就地恢复的(如 `WebException`)处理掉并 `TrySetResult`,其余按致命错误处理。
|
|
|
+- **要点**:
|
|
|
+ - `AggregateException.InnerExceptions` 可嵌套 `AggregateException`,并行层次越深嵌套越深;同一异常还可能出现多份副本(多个并行操作各自失败)。
|
|
|
+ - `ex.Flatten()` 可一次性去掉嵌套,等价于原文手写的递归展开。
|
|
|
+ - 只能重新抛出整个 `AggregateException`,不能只抛其中一个内部异常:可能有多个不可处理的异常,只抛一个会丢失信息。
|
|
|
+ - 用"异常类型 → `Action<T>`"的字典写 `HandleAggregateError`:命中就处理,遇到没有处理器的异常立即返回 false,由调用方重抛原聚合异常。
|
|
|
+ - 一旦某个后台线程抛出异常,其他后台操作也会停止;因此并行体里应尽量不抛异常。
|
|
|
+ - `TaskCompletionSource` 上不要调用 `TrySetException`(会让异常在任务上重现),改为保证每个任务最终 `TrySetResult`;只有确实无法恢复时才让它以异常结束。
|
|
|
+ - PLINQ 的三种执行模型都会在"定义查询"阶段就启动后台工作,因此 `try/catch` 必须同时包住查询的定义和枚举,且捕获的是 `AggregateException`;普通 LINQ 只需包住使用结果的代码。
|
|
|
+- **陷阱**:`catch (Exception ex)` 后只处理 `InnerExceptions[0]`;假设集合里只有一个异常;在 `ForAll` 的委托里吞掉异常却不记录,导致"少了几条结果"却查不出原因。
|
|
|
+
|
|
|
+```csharp
|
|
|
+try
|
|
|
+{
|
|
|
+ var data = source.AsParallel().Select(Compute).ToArray();
|
|
|
+}
|
|
|
+catch (AggregateException ex)
|
|
|
+{
|
|
|
+ foreach (var inner in ex.Flatten().InnerExceptions)
|
|
|
+ Log(inner); // 只记录/处理能恢复的
|
|
|
+ throw; // 保留完整集合
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 第 5 章 C# 中的动态编程
|
|
|
+
|
|
|
+### 条目38:理解动态类型的优劣
|
|
|
+
|
|
|
+- **问题**:泛型约束无法表达"该类型必须有 `+` 运算符"这类成员要求,`dynamic` 可以绕过;但代价是放弃编译期检查和运行效率。
|
|
|
+- **规则**:只在编译期无法描述所需成员时用 `dynamic`(鸭子类型、与其他动态语言/COM 交互);能控制所有类型时改用接口;调用方知道具体类型时改用 lambda/泛型委托;操作数与返回类型一致、类型组合有限时改用表达式树并缓存编译结果。
|
|
|
+- **要点**:
|
|
|
+ - `dynamic` 等价于"带运行时绑定的 `System.Object`":编译期只能看到 `object` 的成员,每次成员访问生成一个调用站点(call site),运行时按实际类型绑定并缓存绑定结果。
|
|
|
+ - 鸭子类型:不需要接口声明,只要运行时存在所需成员即可;`Add(dynamic, dynamic)` 对 `int`、`double`、`string`(`+` 拼接)、`DateTime + TimeSpan` 都成立。
|
|
|
+ - 任一操作数为 `dynamic`,结果就是 `dynamic`;赋回静态类型必须显式转型,转错在运行时抛 `RuntimeBinderException`。
|
|
|
+ - 编译器生成的动态绑定代码比手写反射快,但远慢于静态代码;这是"用运行时工作换灵活性"的必然代价。
|
|
|
+ - 表达式树路线:`Expression.Parameter` → `Expression.Add`(`BinaryExpression`)→ `Expression.Lambda` → `Compile()`;每次 `Compile` 都很昂贵,必须缓存委托(如按类型做 `Dictionary` 缓存)。
|
|
|
+ - 表达式版本的局限:默认只能处理"两操作数与结果同类型",`int + double`、`string + int` 需要手写转换节点;一旦把各种转换都补上,等于重新实现 C# 的动态分发。
|
|
|
+- **陷阱**:把 `dynamic` 当成"万能 `var`",把类型错误全部推迟到运行时;在热路径里反复 `Compile()` 同一个表达式;明明可以定义 `IAdd` 接口却用 `dynamic`。
|
|
|
+
|
|
|
+```csharp
|
|
|
+static dynamic Add(dynamic left, dynamic right) => left + right;
|
|
|
+
|
|
|
+dynamic sum = Add(3, 4); // int
|
|
|
+dynamic later = Add(DateTime.Now, TimeSpan.FromDays(1)); // DateTime
|
|
|
+string label = Add("Here is ", "a label"); // 运行时转回 string
|
|
|
+```
|
|
|
+
|
|
|
+### 条目39:使用动态类型表达泛型类型参数的运行时类型
|
|
|
+
|
|
|
+- **问题**:`Enumerable.Cast<T>()` 对每个元素只做引用转换/装箱拆箱,不会调用元素类型上定义的 `implicit`/`explicit` 转换运算符,于是 `Cast<MyType>()` 在"看起来该成功"的地方抛 `InvalidCastException`。
|
|
|
+- **规则**:需要任意(含用户自定义)转换时,写一个 `Convert<T>` 扩展,内部用 `(T)(dynamic)item` 交给运行时绑定选择转换;或者干脆去掉转换运算符,改用属性/构造函数显式构造。
|
|
|
+- **要点**:
|
|
|
+ - 无约束的泛型方法在 IL 里对 `T` 一无所知,只能假定它拥有 `System.Object` 的成员;`System.Object` 没有任何用户自定义转换,所以 `Cast<T>` 也用不了。
|
|
|
+ - `from v in strings select (MyType)v` 会被编译器改写成 `Cast<MyType>()`,同样失败;把转换写进 `Select` 的 lambda 里则正常,因为 lambda 参数静态类型是 `string`,转换在编译期就绑定好了。
|
|
|
+ - `(T)(dynamic)item` 走运行时 binder,显式/隐式转换都能选中;但它仍是转型,目标类型不匹配时照样在运行时失败。
|
|
|
+ - `Convert<T>` 比 `Cast<T>` 通用,代价是每个元素都要走一次动态绑定,做的工作更多、更慢。
|
|
|
+ - 根源是"泛型方法不了解类型参数的具体能力";凡是这类落差,一小段动态代码就能补上。
|
|
|
+- **陷阱**:以为 `Cast<T>` 会调用 `implicit operator T`;为了绕开 `Cast<T>` 而在 API 里大量使用转换运算符(条目9 已建议避免)。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public static IEnumerable<T> Convert<T>(this IEnumerable source)
|
|
|
+{
|
|
|
+ foreach (object item in source)
|
|
|
+ yield return (T)(dynamic)item; // 运行时选择显式/隐式转换
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 条目40:将接受匿名类型的参数声明为dynamic
|
|
|
+
|
|
|
+- **问题**:匿名类型由编译器生成、无法写成参数或返回值类型,但确实需要让一个方法处理"属性名相同、来源不同"的多种匿名对象。
|
|
|
+- **规则**:把参数声明为 `dynamic`,方法内按约定属性名(如 `Name`、`Price`)访问;对性能敏感或类型已知的路径再提供一个静态类型重载,让编译器优先选静态版本。
|
|
|
+- **要点**:
|
|
|
+ - 匿名类型只能用于泛型类型参数、`object` 参数或 `dynamic` 参数;`dynamic` 参数同时能接收匿名类型、普通类及其派生类。
|
|
|
+ - 属性类型可以不同(匿名类型里 `Price` 是 `decimal`,某个类里是 `double`),运行时按实际对象解析,不会"搞错类型"。
|
|
|
+ - 动态调用站点会缓存绑定结果,同一调用点的后续调用直接复用,开销被摊薄但不会消失。
|
|
|
+ - 不能为 `dynamic` 定义扩展方法,所以无法让这些辅助方法"看起来像匿名类型的成员"。
|
|
|
+ - 适配器模式是静态替代方案:把各种商品抽象成统一类型,性能更好,但每来一种新数据源就要加一个适配器。
|
|
|
+ - 判断标准:一两个配合匿名类型的辅助方法可以用 `dynamic`;一旦到处是动态调用,说明应该提炼具名类型。
|
|
|
+- **陷阱**:方法签名写成 `dynamic` 后仍期待编译器检查属性名拼写;把整条数据管道都写成 `dynamic`,只为迁就一个匿名类型。
|
|
|
+
|
|
|
+```csharp
|
|
|
+static void WritePricingInformation(dynamic product) =>
|
|
|
+ Console.WriteLine($"{product.Name}: {product.Price:C}");
|
|
|
+
|
|
|
+var item = new { Name = "Widget", Price = 9.99m }; // 匿名类型
|
|
|
+WritePricingInformation(item); // 运行时按属性名绑定
|
|
|
+```
|
|
|
+
|
|
|
+### 条目41:用DynamicObject或IDynamicMetaObjectProvider实现数据驱动的动态类型
|
|
|
+
|
|
|
+- **问题**:有些类型的成员集合要到运行时才由数据决定(属性包、任意层级 XML 访问),静态类无法表达。
|
|
|
+- **规则**:首选继承 `System.Dynamic.DynamicObject`,覆写 `TryGetMember`/`TrySetMember`/`TryGetIndex`/`TrySetIndex`/`TryInvokeMember` 等;只有必须继承其他基类时,才手工实现 `IDynamicMetaObjectProvider`,编写 `DynamicMetaObject` 子类的 `BindGetMember`/`BindSetMember` 来构造表达式树。
|
|
|
+- **要点**:
|
|
|
+ - `DynamicObject` 内部用私有嵌套类实现 `IDynamicMetaObjectProvider`,把 binder 请求转发给这些虚方法,因此绝大多数场景不需要碰表达式树。
|
|
|
+ - 每个 `Try*` 用 `binder.Name` 取成员名,用 `out` 参数回传结果,返回 `bool` 表示是否处理;返回 false 会让运行时抛异常。
|
|
|
+ - `TryGetMember` 找不到键可返回 false(抛异常),也可返回一个"空元素"让链式访问继续——动态 XML 包装正是靠这一点做到缺失节点不炸。
|
|
|
+ - `TrySetMember` 可以任意创建键,所以恒返回 true;`TryGetIndex` 支持 `dynamicXML["Planet", 2]` 这种多参数索引器。
|
|
|
+ - 手工实现时 `GetMetaObject` 每次成员访问都会被调用(访问同一成员 10 次就调用 10 次),返回的 `DynamicMetaObject` 必须带 `Expression` 和 `BindingRestrictions`,否则绑定缓存会错误命中。
|
|
|
+ - `BindSetMember` 里赋值右侧必须用 `Expression.Conversion` 延迟求值:`bag.Date = DateTime.Now`、`bag.Magic = GetMagicNumber()` 的右值只能在真正赋值时执行一次,不能提前求值,也不能丢失副作用;同时要把右值作为表达式的结果返回,才能支持 `DateTime d = bag.Date = DateTime.Now;`。
|
|
|
+ - 性能:每次动态调用都要建 `DynamicMetaObject` 并走一次 Bind;手工实现的损失通常比继承 `DynamicObject` 更大。
|
|
|
+- **陷阱**:`TryGetMember` 里直接 `throw` 而不是 `return false`,破坏 binder 的协议;手工实现时忘记限制条件,导致第一次绑定结果被错误复用。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public class DynamicPropertyBag : DynamicObject
|
|
|
+{
|
|
|
+ private readonly Dictionary<string, object> values = new();
|
|
|
+ public override bool TryGetMember(GetMemberBinder binder, out object result)
|
|
|
+ => values.TryGetValue(binder.Name, out result);
|
|
|
+ public override bool TrySetMember(SetMemberBinder binder, object value)
|
|
|
+ { values[binder.Name] = value; return true; }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 条目42:如何使用表达式API
|
|
|
+
|
|
|
+- **问题**:反射 API 太底层、难用;很多"运行时才需要决定调什么/生成什么"的问题,用表达式树能写得更清楚。
|
|
|
+- **规则**:两个方向——把 `Expression<Func<T, TResult>>` 当参数收进来,解析 `MethodCallExpression` 得到方法名与实参;或在运行时拼 `Parameter`/`Variable`/`Property`/`Assign`/`Block`/`Lambda` 再 `Compile()` 成委托,并缓存结果。
|
|
|
+- **要点**:
|
|
|
+ - 表达式是"代码的数据表示"(抽象语法树),可以检查、修改、编译;`Expression<TDelegate>` 与 `LambdaExpression` 是两套入口。
|
|
|
+ - 解析实参不要试图枚举所有节点类型(常量、字段、属性、索引器、方法调用、嵌套调用);把每个参数表达式包成 `LambdaExpression` 再 `Compile()` 执行,统一得到值,还天然支持嵌套的代理调用。
|
|
|
+ - `MethodCallExpression` 的 `Method.Name` 即方法名,`Arguments` 即实参表达式;`CallInterface<T, TResult>` 借泛型类把服务接口作为类型参数,从而保持强类型。
|
|
|
+ - 生成映射代码时:`Parameter(typeof(TSource), "source")` 声明入参,`Variable(typeof(TDest), "dest")` 声明局部变量,`Block` 接收 `Expression[]`(先用 `List<Expression>` 收集再 `ToArray()`),最后 `Expression.Lambda<Func<TSource, TDest>>(...)` + `Compile()`。
|
|
|
+ - 编译很贵:一次编译、长期复用;把委托缓存在静态字段/字典里。
|
|
|
+ - 表达式 API 仍比反射 API 简单,遇到"运行时建代码"的需求应先试它,再考虑反射。
|
|
|
+- **陷阱**:以为 lambda 参数在构造表达式时就求值——它只是树节点,`Compile()` 后才执行;在循环里反复 `Compile()` 同一个形状的表达式。
|
|
|
+
|
|
|
+```csharp
|
|
|
+var source = Expression.Parameter(typeof(TSource), "source");
|
|
|
+var dest = Expression.Variable(typeof(TDest), "dest");
|
|
|
+var body = new List<Expression> { Expression.Assign(dest, Expression.New(typeof(TDest))) };
|
|
|
+// 逐个同名属性追加 Expression.Assign(Expression.Property(dest, p), Expression.Property(source, p))
|
|
|
+body.Add(dest);
|
|
|
+var map = Expression.Lambda<Func<TSource, TDest>>(
|
|
|
+ Expression.Block(new[] { dest }, body), source).Compile();
|
|
|
+```
|
|
|
+
|
|
|
+### 条目43:使用表达式将延迟绑定转换为预先绑定
|
|
|
+
|
|
|
+- **问题**:`INotifyPropertyChanged`/`INotifyPropertyChanging` 的事件参数是字符串属性名,硬编码字符串在重构或改名后会静默失效。
|
|
|
+- **规则**:写一个扩展方法,接收属性表达式 `Expression<Func<T, TProperty>>` 和新值;从 `MemberExpression` 取出 `PropertyInfo`,用它的 `Name` 触发事件、用它(或表达式的编译结果)读写属性;属性 setter 缩成一行调用。
|
|
|
+- **要点**:
|
|
|
+ - 标准四步:比较新旧值(相同则直接返回)→ 触发 `PropertyChanging` → 更新值 → 触发 `PropertyChanged`。
|
|
|
+ - `() => UsedMemory` 是 `MemberExpression`,`Member` 属性就是 `PropertyInfo`;`Name` 提供事件参数,`PropertyInfo` 还能用于反射赋值。
|
|
|
+ - 扩展方法挂在 `PropertyChanged` 事件(类型为 `PropertyChangedEventHandler`)上,而不是挂在接口上:挂在接口上更"正统",但在扩展方法内部触发事件更麻烦;`PropertyChanging` 同理,需要另一个挂在 `PropertyChanging` 上的扩展。
|
|
|
+ - 产品代码要补:转型是否成功、setter 是否存在、部分信任/沙箱环境下的安全异常。
|
|
|
+ - 旧值通过编译并执行表达式的 get 部分获得;这一步本身也可能抛异常,需要按条目47 的思路处理。
|
|
|
+ - 同一思路可推广到任何"用字符串表示成员名"的延迟绑定 API;LINQ to SQL 与 EF 就建立在 `System.Linq.Expression` 之上(代码即数据)。
|
|
|
+- **陷阱**:属性类型与 `TProperty` 不一致时,表达式体可能被包一层 `Convert`(`UnaryExpression`),直接取 `MemberExpression` 会失败,需要先剥掉;只对 `INotifyPropertyChanged` 生效却忘了同时触发 Changing。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public static void SetProperty<T, TValue>(
|
|
|
+ this PropertyChangedEventHandler handler, object sender,
|
|
|
+ Expression<Func<T, TValue>> expr, TValue value)
|
|
|
+{
|
|
|
+ var prop = (PropertyInfo)((MemberExpression)expr.Body).Member;
|
|
|
+ if (Equals(prop.GetValue(sender), value)) return; // 1. 值未变则退出
|
|
|
+ prop.SetValue(sender, value); // 2. 更新值
|
|
|
+ handler?.Invoke(sender, new PropertyChangedEventArgs(prop.Name)); // 3. 通知
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 条目44:尽量减少在公有API中使用动态对象
|
|
|
+
|
|
|
+- **问题**:`dynamic` 会传染——参数或返回值是 `dynamic`,调用链上所有使用者都被迫进入动态世界,失去类型安全并背上绑定开销。
|
|
|
+- **规则**:把 `dynamic` 关在方法内部(局部变量),出口立刻转成静态类型;公有接口只在该成员确实需要动态行为时才暴露 `dynamic`;用泛型包装把动态代码的生成收敛到一处;来自其他动态环境(IronPython 等)的对象用静态类型封装后再对外。
|
|
|
+- **要点**:
|
|
|
+ - 任一操作数为 `dynamic`,结果就是 `dynamic`;方法返回 `dynamic`,所有使用返回值的地方都变成动态调用。
|
|
|
+ - 每个动态调用点都会让编译器生成一大段调用站点代码(binder、规则缓存、转型),这段代码按调用点重复生成,影响程序尺寸与启动/执行性能。
|
|
|
+ - 泛型包装 `static T Add<T>(T l, T r) => (dynamic)l + (dynamic)r;` 让动态代码只在一个方法里生成一次,且返回静态 `T`,调用方无需转型。
|
|
|
+ - 可再提供一个重载单独控制返回类型,保持"入参静态、出参静态"。
|
|
|
+ - CSV 例子:列名编译期不可知,所以"行对象"必须是动态的;但 `CSVDataContainer` 本身、`RowCount`/`ColumnCount`/`GetAt(row, column)` 全部用静态类型实现。
|
|
|
+ - 只有"依赖于动态行为"的成员才配用 `dynamic`,其余成员一律静态。
|
|
|
+- **陷阱**:公有方法签名直接写 `dynamic`,导致整个调用方图变动态;把整个容器/整条管道都做成 `dynamic`,只因其中一行需要列名。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public static T Add<T>(T left, T right) => (dynamic)left + (dynamic)right;
|
|
|
+
|
|
|
+int sum = Add(3, 4); // 调用方全程静态,动态代码只生成在 Add 内部
|
|
|
+```
|
|
|
+
|
|
|
+## 第 6 章 杂项
|
|
|
+
|
|
|
+### 条目45:尽量减少装箱和拆箱
|
|
|
+
|
|
|
+- **问题**:值类型要当引用类型用(存进 `object`、通过接口调用、传给 `object` 形参)时,运行时会装箱;装箱产生堆分配与副本,还会引发难以察觉的语义 bug。
|
|
|
+- **规则**:警惕值类型到 `System.Object` 的隐式转换;用泛型集合/泛型方法替代 .NET 1.x 的非泛型集合与 `object` 形参 API;让值类型尽量不可变,避免"改副本"类 bug。
|
|
|
+- **要点**:
|
|
|
+ - 装箱在堆上分配一个"箱子",里面存放值类型的一份副本;拆箱再返回另一份副本。关键点:存进去的是副本,取出来的也是副本。
|
|
|
+ - 装箱自动发生、无任何警告:传给 `object` 形参、存进 `ArrayList`/`Hashtable`、转成 `object`、通过接口指针使用值类型(非泛型 `IComparable`、`IEnumerable` 遍历 struct)都会触发。
|
|
|
+ - 形如 `Console.WriteLine("{0}", i)` 的格式化重载接收 `params object[]`,`int` 必须装箱;先 `i.ToString()` 再传可避免。
|
|
|
+ - `List<Person>` 对值类型 `Person` 会生成专用的封闭泛型类型,存入不装箱;但取出的是副本,`list[0].Name = ...` 无法通过编译(CS1612),必须取到局部变量改完再放回。
|
|
|
+ - 值类型以多态方式使用必然付性能代价;这也是"值类型应当是小型、不可变的数据容器"的理由之一。
|
|
|
+ - 装箱副本导致的 bug 编译器查不出来:改了副本,原对象/集合元素纹丝不动。
|
|
|
+- **陷阱**:在新代码里继续用 `ArrayList`/`Hashtable`;把 struct 存进非泛型集合后修改元素,误以为集合内容变了。
|
|
|
+- 时代注:非泛型集合在 .NET 2.0 之后已被泛型集合取代,现代 BCL 的公开 API 基本不再要求 `object` 形参,本条的主要遗留风险是接口装箱与 `params object[]`。
|
|
|
+
|
|
|
+```csharp
|
|
|
+int i = 42;
|
|
|
+object boxed = i; // 装箱:堆分配 + 复制
|
|
|
+int copy = (int)boxed; // 拆箱:再复制一份
|
|
|
+Console.WriteLine("{0}", i); // params object[] → 装箱
|
|
|
+```
|
|
|
+
|
|
|
+### 条目46:为应用程序创建专门的异常类
|
|
|
+
|
|
|
+- **问题**:抛出 `System.Exception` 或让第三方库的底层异常直接冒泡时,调用方只能靠错误消息猜测成因,无法按类型分别恢复。
|
|
|
+- **规则**:仅当调用方会"用不同方式处理"时才新增异常类;类名以 `Exception` 结尾、继承 `Exception` 或合适的基类;提供 4 个标准构造函数;底层异常用 `InnerException` 包装(异常转换)。
|
|
|
+- **要点**:
|
|
|
+ - 4 个构造函数:无参、`(string message)`、`(string message, Exception inner)`、序列化构造 `(SerializationInfo, StreamingContext)`;全部转调基类实现即可。
|
|
|
+ - 分类依据是"恢复方式",不是"错误来源":文件缺失、权限不足、网络资源不可达各自需要不同恢复逻辑,才值得三个类;否则只是给使用者增加 catch 分支。
|
|
|
+ - 不是所有错误都该用异常:不报告就会长期恶化的(如数据库完整性错误)才抛;保存窗口位置失败返回错误码就够。
|
|
|
+ - 异常转换:捕获第三方异常后抛自己的异常并把原异常放进 `InnerException`,调用方既能看到业务上下文,也能追溯根因。
|
|
|
+ - 覆写 `ToString()` 输出对象状态(条目5),让异常自带诊断信息。
|
|
|
+ - 不要删除基类已有的构造函数与成员;继承其他异常类时也要带上该基类的构造函数。
|
|
|
+- **陷阱**:为每种错误都造一个异常类,结果没人写对应的 catch;包装时丢掉 `InnerException`,根因永久丢失。
|
|
|
+- 时代注:二进制序列化在 .NET 5+ 已过时(SYSLIB0051),现代异常类通常只保留前 3 个构造函数。
|
|
|
+
|
|
|
+```csharp
|
|
|
+public class EmployeeListNotFoundException : Exception
|
|
|
+{
|
|
|
+ public EmployeeListNotFoundException() { }
|
|
|
+ public EmployeeListNotFoundException(string message) : base(message) { }
|
|
|
+ public EmployeeListNotFoundException(string message, Exception inner)
|
|
|
+ : base(message, inner) { }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 条目47:使用强异常安全保证
|
|
|
+
|
|
|
+- **问题**:异常会打断控制流,操作可能只完成一半,捕获方无法判断程序状态是否可信。
|
|
|
+- **规则**:默认追求强异常保证——先防御性复制,再在副本上执行所有可能抛异常的操作,最后用不会抛异常的交换提交;终结器、`Dispose()`、委托目标方法必须做到无抛出保证。
|
|
|
+- **要点**:
|
|
|
+ - 三级保证(Abrahams/Sutter):基本保证(无资源泄漏,对象仍可用)、强保证(失败则状态完全不变,全做或全不做)、无抛出保证(绝不失败)。
|
|
|
+ - CLR 已托管内存,唯一的资源泄漏源是异常发生时仍持有的 `IDisposable` 资源(条目15)。
|
|
|
+ - 强保证的实现依赖不可变值类型与函数式风格(LINQ):所有修改都产生新值,天然"要么全成、要么不变"。
|
|
|
+ - 循环中复制代价过高时可降级为基本保证,这是权衡而非定规;托管环境下复制堆对象的成本远低于原生环境。
|
|
|
+ - 交换只对值类型/字段本身有效:交换引用类型的引用,不会改变已经持有旧引用的调用方视图(条目26),他们仍看到旧数据。
|
|
|
+ - 信封—信纸(envelope-letter)模式:外层包装器实现 `IBindingList<T>` 等接口并转发给内层集合,`SafeUpdate` 在内部用 `Interlocked.Exchange` 原子替换内层引用,外部只认信封,交换才对外可见。
|
|
|
+ - `Dispose()`/终结器中抛异常会让原异常被吞掉、甚至终止进程;多播委托中一个目标抛异常会阻断其后所有目标,因此委托目标(含事件处理函数)不应抛异常。
|
|
|
+- **陷阱**:以为"先 `Clear()` 再 `AddRange()`"是强保证——中途失败会留下空集合;在 `Dispose()` 里做可能失败的清理并放任异常逃出。
|
|
|
+
|
|
|
+```csharp
|
|
|
+private BindingList<PayrollData> data = new();
|
|
|
+public void SafeUpdate(IEnumerable<PayrollData> rows)
|
|
|
+{
|
|
|
+ var copy = new BindingList<PayrollData>(rows.ToList()); // 1. 防御性复制
|
|
|
+ Interlocked.Exchange(ref data, copy); // 2. 不会失败的提交
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 条目48:尽量使用安全的代码
|
|
|
+
|
|
|
+- **问题**:通过 Web 分发或在部分信任环境运行的代码,会被 CLR 限制行为;使用指针或高权限资源会让程序集要求完全信任,从而无法在这些环境加载。
|
|
|
+- **规则**:默认只写可验证的安全代码(不开 `/unsafe`);必须用指针/P-Invoke/COM 时把算法隔离到独立程序集;文件访问用隔离存储;不申请用不到的权限。
|
|
|
+- **要点**:
|
|
|
+ - 安全程序集 = 不使用指针访问托管或非托管内存;只要开了 `/unsafe`,整个程序集都变成不可验证,影响面是整个程序集而非某个方法。
|
|
|
+ - 指针访问原始内存可以比安全引用快很多(经典数组场景可达 10 倍以上),但应把这类代码放进单独的小程序集,让影响范围最小。
|
|
|
+ - `IsolatedStorage` 按程序集、应用程序域、当前用户划分虚拟目录,部分受信代码只能访问自己那块;`IsolatedStorageFile` 的用法与 `File`/`FileStream` 类似,配额由运行时限制,防止恶意代码占满磁盘。
|
|
|
+ - 隔离存储对其他程序/用户不可见,但对受信用户和非托管代码不设防,不能存机密(除非另行加密),也不适合放需要管理员手工维护的部署/配置信息。
|
|
|
+ - 注册表、数据库、网络端口、打印子系统等受保护资源基本要求完全信任;不能完全信任就只能不用。
|
|
|
+ - 权限越大,抛 `SecurityException` 的面越大;降低权限需求比加权限检查更有效。
|
|
|
+- **陷阱**:在可能部分信任运行的程序集里直接读写任意路径或注册表;为"保险"到处调用 CAS 权限检查,只换来性能损失。
|
|
|
+- 时代注:.NET Core / .NET 5+ 已移除 CAS 与沙箱化部分信任,隔离边界改为进程/容器;`IsolatedStorage` 仍存在,但不再是安全边界。
|
|
|
+
|
|
|
+### 条目49:实现与CLS兼容的程序集
|
|
|
+
|
|
|
+- **问题**:.NET 语言无关,其他语言(F#、VB、IronPython 等)要调用你的程序集;若公有接口里出现 CLS 不兼容的类型,这些成员对它们不可用。
|
|
|
+- **规则**:给程序集加 `[assembly: CLSCompliant(true)]` 让编译器强制检查;不兼容的公有成员提供 CLS 兼容的替代实现;实现了 CLS 不兼容的接口时用显式接口实现把它从公有接口上藏起来。
|
|
|
+- **要点**:
|
|
|
+ - CLS 是语言互操作的最小公分母;约束只作用于程序集外部可见的成员(public/protected),internal/private 里可以随便用 `uint`、`ulong`。
|
|
|
+ - `uint`/`ulong` 不兼容 CLS;`public MyClass TheProperty { get; set; }` 是否兼容取决于 `MyClass` 所在程序集是否标记了 CLS 兼容。
|
|
|
+ - 操作符重载在 CLS 里没有语法,只有 `op_Addition`、`op_Equality` 这样的函数名;重载运算符时同时提供一个同语义的命名方法(如 `Add`),供不支持运算符的语言调用。
|
|
|
+ - 多态会偷偷带进不兼容类型:事件声明为 `EventHandler`(参数 `EventArgs`)是兼容的,但实际传入的派生 `BadEventArgs` 含 `uint`,其他语言的处理函数就拿不到;事件参数类型必须一起检查。
|
|
|
+ - 接口本身也要定义在 CLS 兼容的程序集中才算兼容;成员兼容但接口所在程序集没标记,则该接口被视为不兼容。
|
|
|
+ - 实现不兼容接口必须显式实现:类仍是 CLS 兼容,但使用者只能通过不兼容的接口引用访问那些成员。
|
|
|
+ - 检查清单:基类、公有/受保护成员的返回值、参数、事件参数、公有接口(声明与实现)都必须是 CLS 兼容类型。
|
|
|
+- **陷阱**:以为加了程序集特性就万事大吉——特性只检查签名,不检查运行时多态实际传入的类型;公开实现不兼容接口,把不兼容成员暴露到公有接口上。
|
|
|
+
|
|
|
+```csharp
|
|
|
+[assembly: System.CLSCompliant(true)]
|
|
|
+
|
|
|
+public struct Point
|
|
|
+{
|
|
|
+ public int X { get; set; }
|
|
|
+ public static Point operator +(Point a, Point b) => new Point { X = a.X + b.X };
|
|
|
+ public static Point Add(Point a, Point b) => a + b; // 不支持运算符的语言用这个
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 条目50:实现小尺寸、高内聚的程序集
|
|
|
+
|
|
|
+- **问题**:把所有类型塞进一个程序集,导致无法局部更新、无法按需分发、组件难以复用;拆得过碎又会失去 `internal` 共享并增加跨程序集开销。
|
|
|
+- **规则**:以"一句话能说清职责"为粒度划分程序集;把同基类的公有类放在一起,配上它们必需的 internal 工具类;公有接口可单独成程序集;全应用通用的类放公共辅助库;拿不准时略偏向"小而多"。
|
|
|
+- **要点**:
|
|
|
+ - 程序集是相关类的二进制包,外部只能看到 public/protected;internal 工具类只有留在同一程序集内才能既共享实现又不对外暴露。
|
|
|
+ - 同一程序集内 JIT 更容易做内联;跨程序集内联受限,还有加载器解析函数地址、安全检查的开销——但相对收益,"性能损失微乎其微"。
|
|
|
+ - 程序集整体加载,CLR 要为每个方法生成占位符,只有被调用的方法才 JIT;程序集越大,启动时的加载与占位符开销越高。
|
|
|
+ - 多程序集便于按部署位置拆分(客户端放验证子集、服务器放完整规则),复用二进制而非复制源码再编译,更新时只需替换对应文件。
|
|
|
+ - 组件化的目标是"乐高":接口相同即可整块替换(条目22),因此参数和返回值尽量用接口。
|
|
|
+ - 反例:`Common.Storage.dll` 同时管离线缓存和用户设置,应拆成 `Common.Data.dll` 与 `Common.Settings.dll`,加密存储再加 `Common.EncryptedStorage.dll`。
|
|
|
+ - "小"是相对的:`mscorlib.dll` 约 2 MB 与几十 KB 的正则表达式程序集都算合格,判据是"是否只包含相关的类与接口",不是绝对体积。
|
|
|
+- **陷阱**:建一个 `MyApplication.dll` 包打天下;按文件或按层机械拆分,制造大量跨程序集调用与循环依赖;为省事把本可 internal 的工具类改成 public 以便跨程序集使用。
|
|
|
+
|
|
|
+# 附录 A 合并检查清单
|
|
|
+<a id="appendix-a"></a>
|
|
|
+
|
|
|
+把两书 207 条压缩成可勾选的清单。编号对应正文,`A` = 改善建议,`B` = 高效条目。
|
|
|
+
|
|
|
+### 类型与成员
|
|
|
+
|
|
|
+- [ ] 对外暴露的数据一律是属性,字段全部私有(A91, B1)
|
|
|
+- [ ] 可见性取能满足需求的最小级别;能 `internal` 就不 `public`(A140, B21)
|
|
|
+- [ ] 成员签名优先用基类型或接口,而非具体实现(A96, A97)
|
|
|
+- [ ] 不把数组/集合属性直接交出去;需要时给只读视图或副本(A92, A25, B26)
|
|
|
+- [ ] 构造方法负责初始化主要状态;不在构造方法里调用虚成员(A93, A95)
|
|
|
+- [ ] 抽象类不提供公开构造方法;`sealed` 用在无意被继承的类型上(A90, A108)
|
|
|
+- [ ] 区分 `override` 和 `new`;不在子类重载基类方法(A94, A99, B33, B34)
|
|
|
+- [ ] 能用组合/接口解决就不继承;用多态代替 `switch` 分支(A102–A104, B22, B23)
|
|
|
+- [ ] 静态类与单例分清用途;单例用私有构造 + 静态只读实例(A105, A107)
|
|
|
+- [ ] 参数过多用 `params` 或可选参数收敛,而不是堆重载(A98, B10)
|
|
|
+- [ ] 方法短小、只做一件事、抽象层次一致(A143–A145, B11)
|
|
|
+- [ ] 为类型提供有意义的 `ToString()`;需要格式化就实现 `IFormattable`(A13, B5)
|
|
|
+
|
|
|
+### 相等性、比较与哈希
|
|
|
+
|
|
|
+- [ ] 重写 `Equals` 必须同时重写 `GetHashCode`(A12, B7)
|
|
|
+- [ ] `==`、`Equals`、`CompareTo` 三者语义一致(A11, B6)
|
|
|
+- [ ] 需要排序/比较时实现 `IComparable<T>` 与 `IComparer<T>`,不依赖默认比较(A10, B31)
|
|
|
+- [ ] 值类型相等性实现 `IEquatable<T>`,避免装箱(B6, B45)
|
|
|
+- [ ] 可变类型不要用作字典键或哈希集合元素(B7)
|
|
|
+
|
|
|
+### 值类型、字符串与装箱
|
|
|
+
|
|
|
+- [ ] 枚举有 `0` 值且代表「未设置/默认」(A7, B19)
|
|
|
+- [ ] 不给枚举元素显式赋值,除非值有外部契约含义(A8, B19)
|
|
|
+- [ ] 值类型保持小而不可变(常量性),赋值/读取是原子的(B20)
|
|
|
+- [ ] 需要「无值」时用 `Nullable<T>`/`T?`,不要用魔数(A5, B19)
|
|
|
+- [ ] 循环内不做值类型↔`object` 的反复装箱(A1, B45)
|
|
|
+- [ ] 字符串拼接在循环中改用 `StringBuilder`;比较用 `StringComparison`(A1)
|
|
|
+- [ ] 解析用 `TryParse`,不用「`Parse` + `catch`」控制流程(A4)
|
|
|
+
|
|
|
+### 类型转换与解析
|
|
|
+
|
|
|
+- [ ] 引用类型转换优先 `as` + `null` 检查;值类型用 `is` + 强转(A3, B3)
|
|
|
+- [ ] 明确 `as`/`is` 不触发用户自定义转换,强制转换才会(A3, B3)
|
|
|
+- [ ] 不在公共 API 暴露转换操作符(B9)
|
|
|
+- [ ] 数字转换注意溢出与精度;需要时用 `checked`/`TryParse`(A2, A113)
|
|
|
+
|
|
|
+### 集合与 LINQ
|
|
|
+
|
|
|
+- [ ] 元素数量可变时不用数组(A16)
|
|
|
+- [ ] 只读遍历用 `foreach`;需要索引/修改/删除时用 `for`(A17, A18)
|
|
|
+- [ ] 用泛型集合代替 `ArrayList`/`Hashtable`(A20)
|
|
|
+- [ ] 按访问模式选集合:查找用字典、有序用 Sorted、频繁插删用 LinkedList(A21)
|
|
|
+- [ ] 多线程共享集合用并发集合或加锁,不裸共享 `List<T>`(A22)
|
|
|
+- [ ] 自定义集合不继承 `List<T>`,实现 `IEnumerable<T>`/`ICollection<T>`(A23)
|
|
|
+- [ ] 迭代器方法只读;不在迭代器中修改源集合(A24)
|
|
|
+- [ ] 查询用查询语法/Lambda 表达转换与筛选,循环只用于副作用(B8, A27)
|
|
|
+- [ ] 清楚延迟求值:`Where/Select` 不执行,`ToList/Count/First` 才执行(A28)
|
|
|
+- [ ] 面向数据库时区分 `IEnumerable<T>` 与 `IQueryable<T>`(A29)
|
|
|
+- [ ] 避免在查询中重复枚举、重复排序、无谓的多次遍历(A31)
|
|
|
+
|
|
|
+### 泛型、委托与事件
|
|
|
+
|
|
|
+- [ ] 默认泛型化,不用 `object` 求通用(A32)
|
|
|
+- [ ] 泛型类型中不放有状态的静态成员(A33)
|
|
|
+- [ ] 泛型参数加最小必要约束;`new()` 放最后(A34)
|
|
|
+- [ ] 泛型默认值用 `default(T)`(A35)
|
|
|
+- [ ] 委托优先用 `Action`/`Func`/`Predicate<T>`(A36)
|
|
|
+- [ ] 匿名方法与 Lambda 优先于具名小方法(A37, A150)
|
|
|
+- [ ] 闭包捕获的是变量而非值;循环中捕获要小心(A38)
|
|
|
+- [ ] 事件用 `event` 关键字封装,不暴露委托字段;需要时用事件访问器(A40, A151)
|
|
|
+- [ ] 事件模型符合 `EventHandler`/`EventArgs` 标准签名(A41, B25)
|
|
|
+- [ ] 接口/委托签名用 `in`/`out` 表达协变逆变(A42–A45, B29)
|
|
|
+- [ ] 用委托实现回调,用事件实现通知(B24, B25)
|
|
|
+
|
|
|
+### 资源管理与生命周期
|
|
|
+
|
|
|
+- [ ] 持有非托管资源或可释放字段的类型实现 `IDisposable`(A46, A51, B17)
|
|
|
+- [ ] 用 `using`/`try-finally` 保证释放(A52, B15)
|
|
|
+- [ ] Dispose 可重复调用且不抛异常(A48)
|
|
|
+- [ ] 标准 Dispose 模式:`protected virtual void Dispose(bool)` + `GC.SuppressFinalize`(A49, B17)
|
|
|
+- [ ] 只在需要时写终结器;终结器中不触碰其他托管对象(A47, A50)
|
|
|
+- [ ] 长生命周期引用及时置空,避免意外延长对象寿命(A53)
|
|
|
+- [ ] 不创建非必要对象:复用、缓存、避免临时分配(B16)
|
|
|
+- [ ] 初始化逻辑集中,成员初始化器优先于构造方法里的重复赋值(B12, B14)
|
|
|
+- [ ] 静态成员初始化注意顺序与线程安全,必要时用静态构造方法(B13, A106)
|
|
|
+
|
|
|
+### 序列化
|
|
|
+
|
|
|
+- [ ] 明确哪些字段需要序列化,不需要的标 `[NonSerialized]`(A54)
|
|
|
+- [ ] 用定制特性/序列化回调减少参与序列化的字段(A55)
|
|
|
+- [ ] 需要完全控制格式时用 `ISerializable`,且子类负责父类数据(A56, A57, B27)
|
|
|
+- [ ] 序列化格式视为契约:加字段要向后兼容,版本号要演进(B27)
|
|
|
+- [ ] 现代项目优先 `System.Text.Json`/protobuf,不用 `BinaryFormatter`(时代注)
|
|
|
+
|
|
|
+### 异常
|
|
|
+
|
|
|
+- [ ] 用异常表达失败,不用返回错误码(A58)
|
|
|
+- [ ] 不在不该抛的地方抛:正常控制流、可预见校验失败(A59, A64)
|
|
|
+- [ ] 包装异常时保留 `InnerException`;重抛用 `throw;`(A60)
|
|
|
+- [ ] `finally` 里不写可能抛异常的代码,不放 `return`(A61)
|
|
|
+- [ ] 不嵌套异常、不吞异常(A62, A63)
|
|
|
+- [ ] 全局兜底:`AppDomain.UnhandledException`、`TaskScheduler.UnobservedTaskException`(A65, A85)
|
|
|
+- [ ] 循环内不用 `try-catch`,改为 Tester-Doer(先判断再操作)(A64)
|
|
|
+- [ ] 自定义异常克制、从标准异常派生、命名以 `Exception` 结尾(A67, A68, B46)
|
|
|
+- [ ] 异常安全:操作要么成功要么完全回滚(强保证),至少不留半成品(B47)
|
|
|
+- [ ] 低层只抛出/包装,不记录日志;日志在边界统一做(A70)
|
|
|
+
|
|
|
+### 并发、异步与并行
|
|
|
+
|
|
|
+- [ ] 区分 CPU 密集(并行)与 I/O 密集(异步),别用线程做等待(A71, B36)
|
|
|
+- [ ] 线程同步用信号量/锁/`Interlocked`,锁对象私有且不被外部引用(A72, A73)
|
|
|
+- [ ] 不依赖线程 `IsBackground`、优先级、启动时机(A74–A76)
|
|
|
+- [ ] 线程停止用取消协作,不用 `Thread.Abort`(A77, 时代注)
|
|
|
+- [ ] 限制线程数量,避免每任务一线程(A78)
|
|
|
+- [ ] 用 `Task` 代替 `ThreadPool`/`BackgroundWorker`(A79, A80)
|
|
|
+- [ ] `Parallel.For/ForEach` 只用于 CPU 密集且无共享写(A81, A82)
|
|
|
+- [ ] 并行体内不用共享可变状态;必须共享时锁最小范围(A83, A89)
|
|
|
+- [ ] PLINQ 用于数据并行,注意排序、分区与开销(A84, B35)
|
|
|
+- [ ] 观察 `Task` 异常:`await`、`Wait`、`Task.WhenAll`(A85, A86, B37)
|
|
|
+- [ ] 并行/异步版本不一定更快,先测量(A88)
|
|
|
+- [ ] UI 线程模型:跨线程更新 UI 要回到调度线程(A87)
|
|
|
+- [ ] 只有对内部对象集合加锁,不锁 `this`、`typeof(T)`、字符串(A73)
|
|
|
+
|
|
|
+### API 设计与版本兼容
|
|
|
+
|
|
|
+- [ ] 公共成员先问「能不能不公开」(A146, B21)
|
|
|
+- [ ] 面向接口/基类编程,参数与返回值都放宽类型(A96, A97)
|
|
|
+- [ ] 默认 `readonly`,只在值永不变且需编译期常量时用 `const`(A6, B2)
|
|
|
+- [ ] 改动 `const`/可选参数默认值等于改接口,必须重编调用方(B2, B10)
|
|
|
+- [ ] 用 `new` 修饰符只用于应对基类新增成员,不用于日常隐藏(B33, A94)
|
|
|
+- [ ] 不重载基类已有方法;不改变重载解析的语义(B34)
|
|
|
+- [ ] 避免在公共 API 暴露 `dynamic`(B44)
|
|
|
+- [ ] 避免返回内部可变对象引用;需要时返回副本或只读包装(B26)
|
|
|
+- [ ] 服务接口粗粒度,减少往返(B28)
|
|
|
+- [ ] 程序集小而内聚;命名空间按 `Company.Component` 组织(B50, A122–A124)
|
|
|
+- [ ] 类型/程序集对 CLS 兼容,公开成员不用 `uint`/`sbyte` 等(B49)
|
|
|
+- [ ] 需要强名称时用于标识与引用,不要当作安全边界(A120, 时代注)
|
|
|
+- [ ] 多版本共存用特性/条件编译,而不是复制代码(A156, B4)
|
|
|
+
|
|
|
+### 安全
|
|
|
+
|
|
|
+- [ ] 变量选型考虑取值范围,防溢出与截断(A113)
|
|
|
+- [ ] 不用 MD5/SHA-1 做安全用途;口令用 PBKDF2/Argon2/bcrypt(A114, 时代注)
|
|
|
+- [ ] 文件完整性用 SHA-256 等哈希,并保护哈希本身(A115)
|
|
|
+- [ ] 非对称算法只加密密钥/小数据,大数据用对称加密 + 密钥封装(A116)
|
|
|
+- [ ] 传输安全用 TLS,不自己实现协议(A117)
|
|
|
+- [ ] 不保存明文机密;用平台密钥库而非 `SecureString`(A118, 时代注)
|
|
|
+- [ ] 不自己发明加密算法(A119)
|
|
|
+- [ ] 最小权限运行;不用 CAS/`PrincipalPermission` 做沙箱(A121, 时代注)
|
|
|
+- [ ] 输入在信任边界校验;输出按上下文编码(A113, B48)
|
|
|
+- [ ] 优先托管安全代码,`unsafe` 只限于经审计的热点(B48)
|
|
|
+
|
|
|
+### 命名与可读性
|
|
|
+
|
|
|
+- [ ] 类型用名词/名词组,接口用形容词或 `I` + 名词(A126, A127)
|
|
|
+- [ ] 派生类名以基类名结尾;泛型参数 `T` 前缀(A128, A129)
|
|
|
+- [ ] 枚举类型复数、元素单数(A130)
|
|
|
+- [ ] 公开成员 PascalCase,私有字段/局部变量 camelCase(A131, A133)
|
|
|
+- [ ] 布尔成员用肯定式(`IsEnabled`,不用 `IsNotDisabled`)(A135)
|
|
|
+- [ ] 事件/委托用动词或形容词;处理器用 `OnXxx`/`XxxHandler`(A137–A139)
|
|
|
+- [ ] 不用 FCL 类型名命名自己的类型(A125)
|
|
|
+- [ ] 新版类型用后缀区分(`Xxx2` 慎用,优先语义化命名)(A136)
|
|
|
+- [ ] 命名空间用复数、不必与程序集同名(A123, A124)
|
|
|
+- [ ] 名字表达意图,不靠注释补救(A142)
|
|
|
+- [ ] 方法只做一件事,长度可控;类职责单一(A143–A145)
|
|
|
+- [ ] 多个相关属性聚成一个类型(A147)
|
|
|
+- [ ] 不重复代码;重复出现三次就抽取(A148)
|
|
|
+- [ ] 长 `if`/`switch` 改表驱动或字典分派(A149)
|
|
|
+- [ ] 注释写「为什么」,不写「做什么」;抛异常处必须注释(A152, A153)
|
|
|
+- [ ] 大括号不省略;访问修饰符能用默认值就省略(A140, A141)
|
|
|
+
|
|
|
+### 工程实践
|
|
|
+
|
|
|
+- [ ] 不过度设计;在可工作的代码上小步重构(A154)
|
|
|
+- [ ] 生产代码同时提交单元测试(A155)
|
|
|
+- [ ] UI 从第一屏开始自动化测试(A157)
|
|
|
+- [ ] 用特性/配置支持多版本、多环境(A156)
|
|
|
+- [ ] 用 `Conditional` 特性代替 `#if` 条件编译(B4)
|
|
|
+- [ ] 用分析器/编译器警告把规范自动化,而不是靠记忆(现代补充)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+# 附录 B 两书的重叠、分歧与时代变化
|
|
|
+<a id="appendix-b"></a>
|
|
|
+
|
|
|
+## B.1 同一结论,两本书都讲
|
|
|
+
|
|
|
+| 结论 | 改善 | 高效 | 谁更细 |
|
|
|
+|---|---|---|---|
|
|
|
+| 公有数据用属性而非字段 | A91 | B1 | B(二进制兼容、绑定、索引器) |
|
|
|
+| 默认 `readonly` 而非 `const` | A6 | B2 | B(版本化、序列化) |
|
|
|
+| `as`/`is` 优于强制转换 | A3 | B3 | B(自定义转换按编译期类型选) |
|
|
|
+| `Equals` 必配 `GetHashCode` | A12 | B7 | B(哈希容器、可变键) |
|
|
|
+| 标准 Dispose 模式 | A46–A50 | B17 | A(虚方法、托管/非托管分离) |
|
|
|
+| `using`/`try-finally` 释放 | A52, A69 | B15 | B(异常安全、嵌套) |
|
|
|
+| 泛型协变/逆变 | A42–A45 | B29 | A(四类变体逐一给例子) |
|
|
|
+| 委托回调、事件通知 | A39–A41 | B24, B25 | B(设计意图) |
|
|
|
+| 自定义异常要克制 | A67, A68 | B46 | 互补:A 讲派生与命名,B 讲何时才需要 |
|
|
|
+| 并行异常要观察 | A85, A86 | B37 | 互补:A 讲 API,B 讲聚合语义 |
|
|
|
+| 查询语法优于手写循环 | A17, A27 | B8 | B(把「做什么」和「怎么做」分开) |
|
|
|
+| 缩短方法、单一职责 | A143–A145 | B11 | B(JIT 内联与可读性) |
|
|
|
+
|
|
|
+## B.2 表面冲突,实际互补
|
|
|
+
|
|
|
+**1. `ICloneable`:A 教怎么实现,B 说别用。**
|
|
|
+A14 讲浅拷贝/深拷贝的正确实现(含 `ICloneable`);B32 反对在公共 API 暴露 `ICloneable`,因为它不承诺深浅。两者不矛盾:内部实现可以用,公共契约应改为拷贝构造或显式 `DeepClone()`。现代 C# 用 `record` 的 `with` 或拷贝构造。
|
|
|
+
|
|
|
+**2. 自定义异常:A 说慎用,B 说该建就建。**
|
|
|
+A67 反对滥用自定义异常(只为换个名字);B46 主张库要提供可被调用方区分的专用异常类型。合起来的判据:调用方需要**按类型分别处理**时才新建,且必须从标准异常派生(A68),携带足够上下文。
|
|
|
+
|
|
|
+**3. 循环 vs 查询:A 说 `foreach` 不能代替 `for`,B 说别用循环。**
|
|
|
+A18 讨论的是「需要索引、删除、修改」的场景,`foreach` 不适用;B8 讨论的是「筛选/投影/聚合」,应写成查询。判据:有副作用逐项执行 → 循环;产生新序列 → 查询。
|
|
|
+
|
|
|
+**4. `const`:A 说注意性能,B 说注意兼容。**
|
|
|
+结论一致(默认 `readonly`),只是 A 强调 `const` 在热路径上省一次读取,B 强调改 `const` 必须重编所有调用方。现代 JIT 下 `readonly` 与 `const` 的性能差异通常可忽略。
|
|
|
+
|
|
|
+**5. 单例:A 给模式,B 讲可见性。**
|
|
|
+A105/A107 用私有构造 + 静态字段实现单例;B21 要求把类型可见性降到最小。两者叠加才是完整做法:`sealed` + 私有构造 + `static readonly Lazy<T>`(现代写法)。
|
|
|
+
|
|
|
+## B.3 时代变化速查
|
|
|
+
|
|
|
+书中不少结论已被现代 .NET 取代。遇到下列主题,按右列执行,原文只当作原理参考。
|
|
|
+
|
|
|
+| 书中做法 | 现代替代 |
|
|
|
+|---|---|
|
|
|
+| `Thread`、`ThreadPool`、`BackgroundWorker` | `Task` + `async/await`;后台队列用 `Channel<T>` |
|
|
|
+| APM(`BeginInvoke`/`EndInvoke`)、EAP(`XxxCompleted`) | TAP(返回 `Task`/`ValueTask`) |
|
|
|
+| `Thread.Abort` 停止线程 | `CancellationToken` 协作式取消 |
|
|
|
+| `[Serializable]`、`ISerializable`、`BinaryFormatter` | `System.Text.Json`、protobuf;`BinaryFormatter` 已禁用 |
|
|
|
+| MD5 / SHA-1 校验或签名 | SHA-256/SHA-3;口令用 PBKDF2/Argon2/bcrypt |
|
|
|
+| `SecureString` 保存机密 | 平台密钥库/DPAPI;`SecureString` 在 .NET Core 仅剩壳 |
|
|
|
+| CAS、`PrincipalPermission`、强名称作安全边界 | 操作系统级隔离与最小权限;强名称只作标识 |
|
|
|
+| `ArrayList`、`Hashtable` | `List<T>`、`Dictionary<TKey,TValue>`、`HashSet<T>` |
|
|
|
+| `ICloneable` | 拷贝构造、`record` + `with`、显式 `DeepClone()` |
|
|
|
+| `.NET Remoting`、WCF | gRPC、HTTP API、SignalR |
|
|
|
+| 反射 + `dynamic` 做绑定 | 源生成器、表达式树、`UnsafeAccessor`(.NET 8+) |
|
|
|
+| Ngen 预编译 | ReadyToRun、AOT |
|
|
|
+| 手写参数校验 | `ArgumentNullException.ThrowIfNull` 等 guard API |
|
|
|
+| 手写相等性 | `record` / `record struct` 自动生成,必要时仍手写 `GetHashCode` |
|
|
|
+| `IEnumerable<T>` 暴露可变集合 | `IReadOnlyList<T>`/`IReadOnlyCollection<T>`、`FrozenDictionary` |
|
|
|
+| WPF/WinForm 线程模型 | 加上 `SynchronizationContext`/`Dispatcher` 抽象;现代 UI 框架同理 |
|
|
|
+
|
|
|
+**书里仍然有效、且现代更重要的原则**:属性优先、`readonly` 优先、`Equals`/`GetHashCode` 成对、`using` 释放、异常不吞、锁的范围最小、API 可见性最小、命名达意。这些是两书真正的价值所在,API 会换,判断不会。
|