跳到主要内容
版本:Next

后端编码规范

要作为一名合格的开发者,最基本的素质就是要做到编码规范。从小我们就接受教导"字如其人",而写代码亦是如此。良好的代码风格,彰显了个人的工作素养。良好的代码规范,能够帮助我们进行更好的团队协作:

  • 方便代码的交流和维护
  • 不影响编码的效率,不与大众习惯冲突
  • 使代码更美观、阅读更方便
  • 使代码的逻辑更清晰、更易于理解

规范目的

一个软件的生命周期中,80%的花费在于维护。几乎没有任何一个软件,在其整个生命周期中,均由最初的开发人员来维护。

编码规范的核心价值在于:

  • 改善软件的可读性,让程序员尽快而彻底地理解新的代码
  • 使应用程序的结构和编码风格标准化,便于团队协作和知识传承
  • 使源代码严谨、可读性强且意义清楚
  • 与其它语言约定相一致,并且尽可能直观

为了充分发挥编码规范的价值,每个软件开发人员必须一致遵守编码规范。

高质量的代码特质

高质量的代码应该具备以下核心特质:

  • 易懂性:代码必须易读且简单明确,能够展示出重点所在。代码应该易于重用,不包含多余代码,并附带相应文档说明。
  • 正确性:代码必须正确实现其预期功能,经过充分测试,可以按照文档描述进行编译和正确运行。
  • 一致性:代码应该遵循统一的编程风格和设计原则,确保不同代码之间风格一致,便于团队协作和维护。一致性体现了对细节的追求,传递了代码库的优良品质。
  • 流行性:代码应当采用现代编程实践,如使用Unicode、错误处理、防御式编程以及可移植性设计。使用当下推荐的运行时库、API函数和项目设置。
  • 可靠性:代码必须符合法律法规、隐私政策和安全标准。避免展示入侵性或低质量的编程实践,确保安装和执行过程可撤销,不永久改变机器状态。
  • 安全性:代码应该展示安全编程实践,如遵循最低权限原则、使用运行时库函数的安全版本,以及采用SDL(安全开发生命周期)推荐的项目设置。

合理运用编程实践、设计模式和语言特性,是实现上述代码特质的关键。

基本代码格式

缩进

规则:所有代码都应使用4个空格来表示缩进,严禁使用制表符。

原因:不同的编辑器会使用不同数量的空格来显示制表符,这会导致代码格式混乱,降低可读性。

建议:配置Visual Studio文字编辑器,启用"以空格代替制表符"选项。

花括号

花括号⼀定独占⼀⾏。关于花括号的格式问题,也是很多⼈争论的点,有些⼈习惯于Java的左花括号跟在圆括号后⾯,但微软官⽅推荐的是花括号应该独占⼀⾏,不与任何语句并列⼀⾏。

左花括号 "{" 放于关键字或⽅法名的下⼀⾏并与之对⻬。左花括号 "{" 要与相应的右花括号 "}"对⻬。

错误的示范:

public static void Main(string[] args) { }

正确示例

public static void Main(string[] args)
{
}

特殊要求:if、while、do语句后必须使用 {},即使 {} 中为空或只有一条语句。

示例

if (somevalue == 1)
{
somevalue = 2;
}

建议:右花括号 } 后建议添加注释,便于找到与之对应的左花括号。

示例

{
while(1)
{
if(valid)
{
} // if valid
else
{
} // not valid
} // end forever
}

例外情况:类的自动属性花括号可与代码合占一行

示例

public string Name { get; set; }

using排序

引⼊的命名空间应该按照字⺟⾳序排列,这样做的⽬的在于⽅便在引⼊的多个命名空间中直接快速的找到命名空间。

换⾏

当表达式超出或即将超出显示器⼀⾏显示范围的时候,遵循以下规则进⾏换⾏:

  1. 在逗号后换⾏。
  2. 在操作符前换⾏。

规则1优先于规则2。

当以上规则会导致代码混乱的时候⾃⼰采取更灵活的换⾏规则。

空⾏

目的:空行用于将逻辑上相关联的代码分块,提高代码的可阅读性。

使用两个空行的情况

  • 当接口和类定义在同一文件中时,接口和类的定义之间
  • 当枚举和类定义在同一文件中时,枚举和类的定义之间
  • 当多个类定义在同一文件中时,类与类的定义之间

使用一个空行的情况

  • 方法与方法、属性与属性之间
  • 方法中变量声明与语句之间
  • 方法中不同的逻辑块之间
  • 方法中的返回语句与其他语句之间
  • 属性与方法、属性与字段、方法与字段之间
  • 语句控制块之后,如if、for、while、switch
  • 注释与它注释的语句间不换行,但与其他语句间空一行

空格

规则:在以下情况中需要使用空格:

  • 关键字和左括符 ( 之间应使用空格隔开。如:while (true)

    注意:方法名和左括符 ( 之间不要使用空格,这样有助于辨认代码中的方法调用与关键字。

  • 多个参数用逗号隔开时,每个逗号后都应加一个空格

  • 除了 . 之外,所有的二元操作符都应使用空格与它们的操作数隔开

    • 一元操作符、++-- 与操作数间不需要空格
  • 语句中的表达式之间用空格隔开。如:for (expr1; expr2; expr3)

示例

a += c + d;
a = (a + b) / (c * d);
while (d++ == s++)
{
n++;
}
PrintSize("size is " + size + "\n");

文件定义

规则

  • 通常情况下,一个.cs文件只能定义一个类、接口、枚举或结构体
  • 特殊情况可将多个类定义在同一.cs文件,如代码生成器生成的代码或紧密关联的两个类
  • 类名应该与.cs文件名保持一致,以便于通过文件名查找类名

示例UserInfo类应该在UserInfo.cs文件中

语句

规则:不要在同一行内放置一句以上的代码语句。

原因:多行语句会使得调试器的单步调试变得更为困难。

错误示例

a = 1; b = 2;

正确示例

a = 1;
b = 2;

命名规范

基本命名规范

核心原则:为各种类型、函数、变量、特性和数据结构选取有意义的命名,使其能直接反映其作用。自注释的代码就是好代码。

具体规范

  • 名称应该说明"什么"而不是"如何",避免使用公开基础实现的名称,保留简化复杂性的抽象层
    • 示例:使用GetNextStudent(),而不是GetNextArrayElement()
  • 不要在标识符名中使用不常见的或有歧义的缩短或缩略形式的词
    • 示例:使用GetTemperature而不是GetTemp(Temp可能是Temperature或Temporary的缩写)
  • 公共类型或大家都知道的缩写可以使用缩略词
    • 示例:线程过程(ThreadProc)、窗口过程(WndProc)、对话框过程函数(DialogProc)
  • 不要使用下划线、连字号或其他任何非字母数字的字符
  • 不要使用计算机领域中未被普遍接受的缩写
  • 在适当的时候,使用众所周知的缩写替换冗长的词组名称
    • 示例:UI(User Interface)、OLAP(On-line Analytical Processing)
  • 在使用缩写时,遵循以下规则:
    • 超过两个字符长度的缩写请使用Pascal命名法或驼峰命名法(如HtmlButton或HTMLButton)
    • 仅有两个字符的缩写应大写(如System.IO,而不是System.Io)

命名原则总结

  • 选择正确名称时的困难可能表明需要进一步分析或定义项的目的
  • 使名称足够长以便有一定的意义,并且足够短以避免冗长
  • 唯一名称在编程上仅用于将各项区分开
  • 表现力强的名称是为了帮助人们阅读,提供人们可以理解的名称是有意义的
  • 确保选择的名称符合适用语言的规则和标准

推荐的命名法

Pascal命名法

  • 定义:将标识符的首字母和后面连接的每个单词的首字母都大写
  • 适用范围:可以对三字符或更多字符的标识符使用Pascal命名法
  • 示例BackColorUserInfoGetData

驼峰命名法

  • 定义:标识符的首字母小写,而每个后面连接的单词的首字母都大写
  • 示例backColoruserInfogetData

不推荐的命名法

匈牙利命名法

  • 定义:匈牙利命名法是一名匈牙利程序员发明的命名规范,基本原则是:变量名=属性+类型+对象描述
  • 示例m_bFlag(m表示成员变量,b表示布尔,合起来为:"某个类的成员变量,布尔型,是一个状态标志")
  • 要求:严禁使用匈牙利命名法(不要在变量名称内带有其类型指示符)

常用的命名规范

1. 语言规范

  • 规则:严禁使用拼音与英文混合的方式命名,更不允许直接使用中文
  • 原因:正确的英语拼写和语法可以让阅读者易于理解,避免歧义
  • 注意:即使纯拼音命名的方式也要避免采用
  • 正例nameorderbaidualibaba等国际通用的名称可视为英文
  • 反例zhekou(折扣)、Shuliang(数量)、int 变量=1

2. 类名规范

  • 规则:类名使用Pascal命名法
  • 例外情况:DTO/UID等模块功能缩写或接口定义(如IInterface)
  • 正例UserDTOXmlServiceTFlowInfoTTouchInfoIUserService
  • 反例userDtoXMLServicetflowInfottouchInfo

3. 方法与变量命名规范

  • 规则:方法名、参数名、成员变量、局部变量都统一使用驼峰命名法
  • 正例namegetUserInfo()userId

4. 常量命名规范

  • 规则:常量的命名使用Pascal命名法
  • 建议:单词力求语义表达要完整,不要嫌名字长
  • 正例MaxStockCount
  • 反例Max_Count

5. 特殊类命名规范

  • 抽象类:推荐使用Base结尾
  • 异常类:使用Exception结尾
  • 测试类:以它要测试的类的名称开始,以Test结尾
  • 要求:杜绝不规范的缩写,避免望文不知义
  • 反例:将NotFoundException缩写命名为NotFoundEx

6. 自定义元素命名原则

  • 目的:为了达到代码自注释的目的
  • 要求:任何自定义编程元素在命名时,尽量使用完整的单词组合来表达其意思
  • 反例int a的随意命名方式

7. 设计模式命名规范

  • 要求:如果模块、接口、类、方法使用了设计模式,在命名时需体现出具体模式
  • 目的:将设计模式体现在名字中,有利于阅读者快速理解架构设计理念
  • 正例public class OrderFactorypublic class LoginProxy

8. 接口和实现类命名规范

  • 规则1:对于Service和DAO类,暴露出来的服务一定是接口
    • 正例CacheService实现自ICacheService接口
  • 规则2:如果形容能力的接口名称,取对应的形容词为接口名(一般为-able结尾)
    • 正例IDisposeable接口

9. 枚举类命名规范

  • 规则:枚举类的成员名称使用Pascal命名法
  • 说明:枚举为特殊的类,成员均为常量
  • 建议
    • 为枚举成员显式指定枚举值,防止将来在中间插入枚举变量导致枚举值混乱
    • 最好为每个枚举值打上Description标签
  • 示例
public enum PaymentStatus : sbyte
{
/// <summary>
/// 进⾏中
/// </summary>
[Description("进⾏中")]Processing = 1,

/// <summary>
/// 成功
/// </summary>
[Description("成功")]Succeed = 2
}

各层命名规约

A) Service/DAO 层方法命名规约

  • 获取单个对象:方法用 Get 做前缀
  • 获取多个对象:方法用 List 做后缀,如:GetOrdersList
  • 获取统计值:方法用 Count 做后缀
  • 添加或更新:方法用 SaveAdd
  • 删除:方法用 RemoveDelete
  • 修改:方法用 Update

B) 领域模型命名规约

  • 实体对象:如 UserInfo,实体名称即为数据库表名
  • 数据传输对象xxxDTO,xxx 为业务领域相关的名称
  • 展示对象xxxViewModel,xxx 一般为网页名称

命名空间和程序集命名

  • 命名规则:命名空间名称采用Pascal命名法,且首字符大写
  • 命名格式:命名空间名称尽量反映其内容所提供的整体功能,一般以"域名.项目名.模块名"的命名方式
    • 示例Masuit.MyBlogs.Models
  • 文件夹结构一致性:命名空间应该与文件夹层级结构保持一致
    • 示例:在项目Masuit.MyBlogs.Core中,Masuit.MyBlogs.Core.Infrastructure.Services命名空间对应项目的Masuit.MyBlogs.Core/Infrastructure/Services文件夹

常见标识符的命名规范

标识符规范命名结构示例
类,结构体Pascal命名法名词public class ComplexNumber {...}
public struct ComplextStruct {...}
命名空间Pascal命名法
一定不要以相同的名称来命名命名空间和其内部的类型。
名词namespace Microsoft.Sample.Windows7
枚举Pascal命名法
一定要以复数名词或名词短语来命名标志枚举,以单数名词或名词短语来命名简单枚举。
名词[Flags]
public enum ConsoleModifiers { Alt, Control }
方法Pascal命名法动词或动词短语public void Print() {...}
public void ProcessItem() {...}
Public属性Pascal命名法
一定要以集合中项目的复数形式命名该集合,或者单数名词后面跟 "List" 或者 "Collection"。
一定要以表肯定的短语来命名布尔属性,(CanSeek,而不是CantSeek)。当以 "Is" "Can" 或 "Has" 作布尔属性的前缀有意义时,也可以这样做。
名词或形容词public string CustomerName
public ItemCollection Items
public bool CanRead
非Public属性驼峰命名法
一定要使用'_' 前缀,保持代码一致性。
名词或形容词private string _name;
事件Pascal命名法
一定要用现在式或过去式来表明事件之前或是之后的概念。
一定不要使用 "Before"或者"After" 前缀或后缀来指明事件的先后。
动词或动词短语// 关闭窗口后引发的关闭事件。
public event WindowClosed
// 在关闭窗口之前引发的关闭事件。
public event WindowClosing
委托Pascal命名法
⼀定要为⽤于事件的委托增加'EventHandler'后缀。
⼀定要为除了⽤于事件处理程序之外的委托增加'Callback'后缀。
⼀定不要为委托增加"Delegate"后缀。
public delegate WindowClosedEventHandler
接口Pascal命名法,并带有'I' 前缀名词public interface IDictionary
常量Pascal命名法用于Public常量;
驼峰命名法用于Internal常量;
只有1或2个字符的缩写需全部字符大写。
名词public const string MessageText = "A";
private const string messageText = "B";
public const double PI = 3.14159...;
参数,变量驼峰命名法名词int customerID;
泛型参数Pascal命名法,带有'T' 前缀
一定以描述性名称命名泛型参数,除非单字符名称已有足够描述性。
一定以T作为描述性类型参数的前缀。
应该使用 T 作为单字符类型参数的名称。
名词T,TItem, TPolicy
资源Pascal命名法
一定要提供描述性强的标识符。同时,尽可能保持简洁,但是不应该因空间而牺牲可读性。
一定要为命名资源使用字母数字字符和下划线。
名词ArgumentExceptionInvalidName

变量的使用

全局变量

  • 原则:尽量少用全局变量
  • 使用规范:为了正确使用全局变量,一般是将它们作为参数传入函数
  • 注意事项
    • 永远不要在函数或类内部直接引用全局变量,因为这会引起副作用:在调用者不知情的情况下改变了全局变量的状态
    • 这一原则同样适用于静态变量
  • 修改规范:如果需要修改全局变量,应该将其作为一个输出参数,或返回其一份全局变量的拷贝

变量的声明和初始化

  • 作用域原则:一定是在最小的、包含该局部变量的作用域块内声明它

  • 声明时机

    • 如果语言允许,就仅在使用前声明它们
    • 否则就在作用域块的顶端声明
  • 正确示例

void MyMethod()
{
int int1 = 0;
if (condition)
{
int int2 = 0;
...
}
}
  • 避免变量重名:避免在不同层次作用域间使用相同的变量名

    int count;
    ...
    void MyMethod()
    {
    if (condition)
    {
    int count = 0; // 避免:与外部作用域变量重名
    ...
    }
    ...
    }
  • 初始化默认值:一定要在声明变量时初始化它们的默认值,降低被抛空引用异常的概率

  • 声明与初始化合并:在语言允许的情况下,将局部变量的声明和初始化或赋值置于同一行代码内

    • 好处:减少代码的垂直空间,确保变量不会处在未初始化的状态
  • 错误示范

string str;
str="123";
  • 正确示范
string str="123";
  • 单行声明规则:一行只建议作一个声明,并按字母顺序排列
    int level; // 推荐
    int size; // 推荐
    int x, y; // 不推荐

常量定义

  • 禁止硬编码:不允许任何未经预先定义的常量直接出现在代码中

    • 反例string name = "tflow" + userId;
  • 数值类型后缀:在long或者float赋值时,数值后使用大写L或者F

    • 注意:L不能写成小写的l,小写的l容易跟数字1混淆,造成误解
  • 常量分类维护:不要使用一个常量类维护所有常量,要按常量功能进行归类,分开维护

    • 原因:大而全的常量类杂乱无章,使用查找才能定位到常量,不利于理解和维护
    • 正例:缓存相关的常量放在类CacheConstants下;系统配置常量放ConfigConstants
  • 使用枚举类型:如果变量值仅在一个固定范围内变化,使用enum类型来定义

  • 常量定义原则:一定要将那些永远不会改变的值定义为常量字段

    • 编译器行为:编译器直接将常量字段嵌入调用代码处
    • 优势:常量值永远不会被改变,且并不会打破兼容性
  • 示例

public class Int32
{
public const int MaxValue = 0x7fffffff;
public const int MinValue = unchecked((int)0x80000000);
}
  • 预定义对象实例:一定要为预定义的对象实例使用public static readonly字段
    • 如果有预定义的类型实例,也将该类型定义为public static readonly
    • 示例
public class ShellFolder
{
...
}

public static readonly ShellFolder ProgramData = new ShellFolder("ProgramData");
public static readonly ShellFolder ProgramFiles = new ShellFolder("ProgramData");

区别readonly和const的使用方法

  • 使用const的理由:使用const的主要理由是效率

    • 效率原理:经过编译器编译后,代码中引用const变量的地方会被替换为const变量所对应的实际值
    • 示例const int MAX = 100;,MAX和100在使用时是等价的
    • 特点:const自带static特性
  • 本质区别

    • const是编译期常量,readonly是运行期常量
    • const只能修饰基元类型、枚举类型或字符串类型,readonly没有限制
  • readonly特殊用法:在构造方法内,可以多次对readonly字段赋值(仅在初始化时)

注释规范

文档注释要求

  • 强制使用:所有的类、接口、方法、属性,都必须使用VS支持的文档注释
  • 格式规范:采用.Net已定义好的Xml标签标记,在声明接口、类、方法、属性、字段时使用
  • 生成方式:在方法名或类名的上方,输入///即可生成文档注释块
/// <summary>
/// 注释
/// </summary>
/// <returns></returns>

特定元素注释规范

  • 抽象方法注释:所有抽象方法(包括接口中的方法)必须使用VS注释,除了返回值、参数、异常说明外,还必须指出该方法做什么事情,实现什么功能
  • 枚举注释:所有枚举类型字段必须有注释,并说明每个枚举值的用途

方法内部注释

  • 单行注释:在被注释语句上方另起一行,尽量使用//注释
  • 避免使用:避免使用/* */注释
  • 格式要求:注意与代码对齐

注释语言与更新

  • 语言选择:若团队没有要求使用某种特定语言进行注释,使用本地化语言进行代码注释即可
  • 同步更新:代码修改的同时,注释也要进行相应修改,尤其是参数、返回值、异常、核心逻辑等的修改

代码删除与注释管理

  • 谨慎删除:谨慎直接删掉代码。在上方详细说明,而不是简单地注释掉。如果无用,则删除
  • 代码注释的意义:代码被注释掉有两种可能性:
    • 后续会恢复此段代码逻辑
    • 永久不用。前者如果没有备注信息,难以知晓注释动机。后者建议直接删掉(因为代码仓库保存了历史代码可供回滚)

注释风格要求

  • 避免杂乱:避免杂乱的注释,如一整行星号做分割线。而是应该使用空白将注释同代码分开
  • 避免印刷框:避免在块注释的周围加上印刷框。这样看起来可能很漂亮,但是难以维护

发布前处理

  • 清理临时注释:在部署发布之前,移除所有临时或无关的注释,以避免在日后的维护工作中产生混乱

代码简化原则

  • 优先重构:如果需要用注释来解释复杂的代码节,需检查此代码以确定是否应该重写它。尽一切可能不注释难以理解的代码,而应该重写它
  • 平衡性能与可维护性:尽管一般不应该为了使代码更简单以便于人们使用而牺牲性能,但必须保持性能和可维护性之间的平衡

注释时效性

  • 及时注释:在编写代码时就注释,因为以后很可能没有时间这样做。另外,如果有机会复查已编写的代码,在今天看来很明显的东西六周以后或许就不明显了

注释内容要求

  • 避免多余:避免多余的或不适当的注释,不应包含个人情绪内容,如幽默的不必要的备注(虽然博主经常在项目中这样干)
  • 完整句子:在编写注释时使用完整的句子。注释应该阐明代码,而不应该增加多义性

注释强制性要求

  • 难理解代码必注:难以理解的代码一定要写注释!!

注释统一规范

  • 统一样式:在整个应用程序中,使用具有一致的标点和结构的统一样式来构造注释
  • 空白分隔:用空白将注释同注释分隔符分开。在没有IDE的情况下通过其他文本编辑器查看注释时,这样做会使注释很明显且容易被找到

版本控制原则

  • 版本管理:代码修改变更记录不应使用注释标明修改日期和修改人,注释应只针对代码不记录版本,代码版本应该使用代码版本系统进行管理

注释核心要求

注释应满足以下核心要求:

  1. 反映设计思想:能够准确反应设计思想和代码逻辑
  2. 描述业务含义:能够描述业务含义,使别的开发者能够迅速了解到代码背后的信息

注释的意义

  • 自我文档化:注释是给自己看的,即使隔很长时间,也能清晰理解当时的思路
  • 知识传承:注释也是给继任者看的,使其能够快速接替自己的工作

注释的平衡

  • 自注释优先:好的命名、代码结构是自注释的,注释力求精简准确、表达到位
  • 避免极端:避免出现注释的一个极端:过多过滥的注释,代码的逻辑一旦修改,修改注释是相当大的负担
  • 聚焦意图:注释是用来解释一段代码的设计意图的。一定不要让注释仅仅是一些无用的代码

TODO 待办注释

TODO注释是Visual Studio支持的一种特殊注释类型,具有以下特点:

  • 自动识别:当注释打上TODO前缀后,VS会自动感知到该注释处有未完成的任务
  • 任务列表集成:自动将TODO项加入到VS的任务列表中,方便开发者快速定位和管理待办事项

示例

// TODO: 完善错误处理逻辑
public void ProcessData(List<string> data)
{
// 临时实现
foreach(var item in data)
{
Console.WriteLine(item);
}
}

类和接口的声明

基本格式规范

  • 方法名与括号:在方法名与其后的左括号间没有任何空格
  • 左花括号位置:左花括号 { 出现在声明的下一行并与之对齐,单独成行
  • 方法间隔:方法间用一个空行隔开

类的声明规范

命名规则

  • 命名法:使用 Pascal命名法
  • 命名方式:用名词或名词短语命名类
  • 缩写规则:使用全称避免缩写,除非缩写已是一种公认的约定,如URL、HTML
  • 前缀要求:不要使用类型前缀,如在类名称上对类使用 C 前缀。例如,使用类名称 FileStream,而不是 CFileStream
  • 下划线使用:不要使用下划线字符 _

特殊情况处理

  • I开头的类名:有时候需要提供以字母 I 开始的类名称,虽然该类不是接口。只要 I 是作为类名称组成部分的整个单词的第一个字母,这是适当的。例如,类名称 IdentityStore 是适当的

派生类命名

  • 复合命名:在适当的地方,使用复合单词命名派生的类
  • 基类包含:派生类名称的第二个部分应当是基类的名称。例如,ApplicationException 对于从名为 Exception 的类派生的类是适当的名称,因为ApplicationException 是一种Exception
  • 合理判断:请在应用该规则时进行合理的判断。例如,Button 对于从 Control 派生的类是适当的名称。尽管按钮是一种控件,但是将 Control 作为类名称的一部分将使名称不必要地加长

类声明示例

public class FileStream
public class Button
public class String

构造函数

构造函数工作内容

  • 最小化工作量:尽量减少构造函数的工作量。除了获取构造函数参数,设置主要数据成员,构造函数不应该有太多的工作量
  • 延迟初始化:其余工作量应该被推迟,直到必须

异常处理

  • 恰当抛出异常:在恰当的时候,从实例构造函数内抛出异常

默认构造函数

  • 显式声明:在需要默认构造函数的情况下,显式的声明它。即使有时编译器会自动为您的类增加一个默认构造函数,但是显式的声明使得代码更易维护
  • 确保可用性:这样即使您增加了一个带有参数的构造函数,也能确保默认构造函数仍然会被定义

虚方法调用限制

  • 禁止调用虚方法:一定不要在对象构造函数内部调用虚方法
  • 风险说明:调用虚方法时,实际调用了继承体系最底层的覆盖(override)方法,而不考虑定义了该方法的类的构造函数是否已被调用

字段的声明

访问修饰符规范

  • 禁止公共字段:不要使用public或protected的实例字段
  • 版本控制优势:避免将字段直接公开给开发人员,可以更轻松地对类进行版本控制,原因是在维护二进制兼容性时字段不能被更改为属性
  • 属性访问器替代:考虑为字段提供get和set属性访问器,而不是使它们成为公共的
  • 灵活性提升:get和set属性访问器中可执行代码的存在使得可以进行后续改进,如在使用属性或者得到属性更改通知时根据需要创建对象

私有字段示例

public class Control : Component
{
private int _handle;

public int Handle
{
get
{
return _handle;
}
}
}

字段命名规范

  • 私有/保护字段:private、protected使用驼峰命名法
  • 公共字段:public使用Pascal命名法
  • 缩写使用:拼写出字段名称中使用的所有单词。仅在开发人员一般都能理解时使用缩写
class SampleClass
{
private string _url;
private string _destinationUrl;
}

匈牙利语表示法禁止

  • 禁止使用:不要对字段名使用匈牙利语表示法
  • 命名原则:好的名称描述语义,而非类型

预定义对象实例

  • 使用公共静态只读字段:对预定义对象实例使用公共静态只读字段
  • 声明位置:如果存在对象的预定义实例,则将它们声明为对象本身的公共静态只读字段
  • 命名规范:使用Pascal命名法,原因是字段是公共的
public struct Color
{
public static readonly Color Red = new Color(0x0000FF);

public Color(int rgb)
{
// Insert code here.
}

public Color(byte r, byte g, byte b)
{
// Insert code here.
}

public byte RedValue
{
get
{
// 正确返回红色值
return _red; // 假设_red是私有字段
}
}

private byte _red; // 私有字段示例
}

静态字段

命名规范

  • 命名方式:使用名词、名词短语或者名词的缩写命名静态字段
  • 大小写规则:使用Pascal命名法

使用建议

  • 优先使用属性:建议尽可能使用静态属性而不是公共静态字段

参数的声明

参数命名规范

  • 使用描述性名称:使用描述性参数名称。参数名称应当具有足够的描述性,以便参数的名称及其类型可用于在大多数情况下确定它的含义
  • 驼峰命名法:对参数名称使用驼峰命名法
  • 描述含义而非类型:使用描述参数的含义的名称,而不要使用描述参数的类型的名称。开发工具将提供有关参数的类型的有意义的信息。因此,通过描述意义,可以更好地使用参数的名称

参数命名限制

  • 禁止匈牙利语表示法:不要给参数名称加匈牙利语类型表示法的前缀
  • 避免保留参数:不要使用保留的参数。保留的参数是专用参数,如果需要,可以在未来的版本中公开它们。相反,如果在类库的未来版本中需要更多的数据,请为方法添加新的重载

示例

Type GetType(string typeName)
string Format(string format, object args)

方法的声明

方法命名规范

  • 使用动词或动词短语:使用动词或动词短语命名方法
  • Pascal命名法:使用Pascal命名法
RemoveAll()
GetCharArray()
Invoke()

需要参数校验的情况

  • 低调用频次:调用频次低的方法
  • 高执行开销:执行时间开销很大的方法。此情形中,参数校验时间几乎可以忽略不计,但如果因为参数错误导致中间执行回退,或者错误,那得不偿失
  • 高稳定性要求:需要极高稳定性和可用性的方法
  • 开放接口:对外提供的开放接口,不管是RPC/API/HTTP接口
  • 敏感权限入口:敏感权限入口

不需要参数校验的情况

  • 循环调用方法:极有可能被循环调用的方法。但在方法说明里必须注明外部参数检查要求
  • 底层高频方法:底层调用频度比较高的方法。毕竟是像纯净水过滤的最后一道,参数错误不太可能到底层才会暴露问题。一般DAO层与Service层都在同一个应用中,部署在同一台服务器中,所以DAO的参数校验,可以省略
  • 内部私有方法:被声明成private只会被自己代码所调用的方法,如果能够确定调用方法的代码传入参数已经做过检查或者肯定不会有问题,此时可以不校验参数

异步方法

命名与标识规范

  • 命名约定:异步方法的命名一般以Async结尾
  • 方法签名:方法签名带有async标识
  • await使用:方法体内部有出现await关键字

返回值类型

异步方法的返回值有三种:

  1. void:没有任何返回值
  2. Task:返回一个Task任务,可以获得该异步方法的执行状态
  3. Task<T>:返回Task<T>可以获得异步方法执行的结果和执行状态

使用建议

  • 避免void返回:如果你认为你的异步任务不需要知道它的执行状态(是否出现异常等)可以使用没有返回值的void签名(强烈建议不要在正式项目中使用void的异步方法)

示例:使用void返回的异步方法

public static async void FireAndForgetAsync()
{
var myTask = Task.Run(() =>
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine("DoWork : {0}", i);
Thread.Sleep(500);
}
});
await myTask;
}

示例:使用Task返回的异步方法

如果你需要知道任务的执行状态则使用Task的签名

static Task SayHello(string name)
{
return Task.Run(() =>
{
Console.WriteLine("你好:{0}", name);
});
}

static async Task SayHelloAsync(string name)
{
await SayHello(name);
}

示例:使用Task<T>返回的异步方法

如果你需要获得异步方法的结果可以使用Task<T>的签名

static Task<int> SumArray(int[] arr)
{
return Task.Run(() => arr.Sum());
}

static async Task<int> GetSumAsync(int[] arr)
{
int result = await SumArray(arr);
return result;
}

static async void GetTaskOfTResult()
{
int[] arr = Enumerable.Range(1, 100).ToArray();
Console.WriteLine("result={0}", GetSumAsync(arr).Result);
int result = await GetSumAsync(arr);
Console.WriteLine("result={0}", result);
}

异步Lambda表达式

除了上面的三个异步方法的例子,还可以使用lambda表达式来创建异步方法。只要在lambda表达式参数前加async,在表达式内部使用await即可

public static void TestAsyncLambda()
{
Action act = async () =>
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine("Do Work {0}", i);
await Task.Delay(500);
}
};
act();
}

属性 (property)的声明

属性命名规范

  • 使用名词或名词短语:使用名词或名词短语命名属性
  • Pascal命名法:使用Pascal命名法
  • 类型与名称匹配:考虑用与属性的基础类型相同的名称创建属性。例如,如果声明名为Color的属性,则属性的类型同样应该是Color

示例:基本属性声明

public class SampleClass{
public Color BackColor{get;set;}
}

示例:类型与名称相同的属性

以下代码示例阐释提供其名称与类型相同的属性。

public enum Color
{
Red,
Green
}

public class Control
{
public Color Color { get; set; }
}

事件的声明

事件处理程序命名

  • 后缀规范:对事件处理程序名称使用EventHandler后缀

事件委托参数规范

  • 参数数量与名称:指定两个名为sender和e的参数
  • sender参数类型:sender参数表示引发事件的对象,始终是object类型的,即使在可以使用更为特定的类型时也如此
  • e参数规范:与事件相关联的状态封装在名为e的事件类的实例中,对e参数类型使用适当而特定的事件类

事件参数类命名

  • 后缀规范:用EventArgs后缀命名事件参数类

事件命名规范

  • 动词命名:考虑用动词命名事件
  • 时态使用:使用动名词(动词的"ing"形式)创建表示事件前的概念的事件名称,用过去式表示事件后
  • 示例:可以取消的Close事件应当具有Closing事件和Closed事件
  • 避免使用:不要使用BeforeXxx/AfterXxx命名模式
  • 避免前后缀:不要在类型的事件声明上使用前缀或者后缀。例如,使用Close,而不要使用OnClose

事件重写支持

  • OnXxx方法:通常情况下,对于可以在派生类中重写的事件,应在类型上提供一个受保护的方法(称为OnXxx)
  • 参数简化:此方法只应具有事件参数e,因为发送方总是类型的实例
public delegate void MouseEventHandler(object sender, MouseEventArgs e);

public class MouseEventArgs : EventArgs
{
int x;
int y;

public MouseEventArgs(int x, int y)
{
this.x = x;
this.y = y;
}

public int X
{
get
{
return x;
}
}

public int Y
{
get
{
return y;
}
}
}

集合

集合定义

集合是一组组合在一起的类似的类型化对象,如哈希表、查询、堆栈、字典和列表

集合命名建议

  • 使用复数形式:集合的命名建议用复数

成员方法重载

重载优先原则

  • 使用方法重载:使用成员方法重载,而不是定义带有默认参数的成员方法
  • 默认参数缺点:默认参数并不是CLS兼容的,所以不能被某些语言重用

默认参数版本问题

  • 版本不一致风险:带有默认参数的成员方法存在一个版本问题
  • 示例场景
    1. 成员方法的版本1将可选参数默认设置为123
    2. 当编译代码调用该方法,且没有指定可选参数时,编译器在调用处直接将123嵌入代码中
    3. 当版本2将默认参数修改为863,如果调用代码没有重新编译,那么它会调用版本2的方法,并传递123作为其参数

参数命名规范

  • 保持参数名一致:一定不要任意改动重载方法中的参数名
  • 同名参数位置:如果一个重载函数中的参数代表着另一个重载函数中相同的参数,该参数则应该有相同的命名,并且在重载函数中出现在同一位置

虚函数设置规则

  • 虚函数限制:一定请仅将最长重载函数设为虚函数(为了拓展性考虑)
  • 调用链要求:短重载函数应该一直调用到长重载函数

虚成员方法

性能比较

  • 相对回调和事件:相较于回调和事件,虚成员方法性能上有更好的表现
  • 相对非虚方法:虚成员方法比非虚方法在性能上低一点

虚方法使用原则

  • 避免随意使用:一定不要在没有合理理由的情况下,将成员方法设置为虚方法
  • 成本意识:您必须意识到相关设计、测试、维护虚方法带来的成本

访问性建议

  • 优先Protected:更应该倾向于为虚成员方法设置为Protected的访问性,而不是Public访问性
  • 拓展性实现:Public成员应该通过调用Protected的虚方法来提供拓展性(如果需要的话)

创建对象时需要考虑是否实现比较器

比较器实现原则

  • 按需实现:有特殊需要比较的时候就考虑实现比较器

替代方案

  • LINQ排序:集合排序比较通过LINQ也可以解决

区别对待==和Equals

基本比较原则

  • 值类型比较:对于值类型,如果类型的值相等,就应该返回True
  • 引用类型比较:对于引用类型,如果类型指向同一个对象,则返回True

比较方法的可重载性

  • 重载可能性:由于操作符"=="和"Equals"方法从语法实现上来说,都可以被重载为表示"值相等性"和"引用相等性"
  • 明确引用比较:为了明确有一种方法肯定比较的是"引用相等性",FCL中提供了Object.ReferenceEquals方法
  • ReferenceEquals功能:该方法比较的是两个实例是否是同一个实例

string类型的特殊性

  • 特殊处理:对于string这样一个特殊的引用类型,微软觉得它的现实意义更接近于值类型
  • 比较行为:在FCL中,string的比较被重载为针对"类型的值"的比较,而不是针对"引用本身"的比较

重写Equals时也要重写GetHashCode

重写Equals的前提

  • 谨慎重写:除非考虑到自定义类型会被用作基于散列的集合的键值,否则不建议重写Equals方法
  • 风险提示:重写Equals方法会带来一系列的问题

集合查找机制

  • 查找流程:集合找到值的时候本质上是先去查找HashCode,然后才查找该对象来比较Equals

类型安全接口实现

  • 接口要求:重写Equals方法的同时,也应该实现一个类型安全的接口IEquatable<T>
  • 示例class Person : IEquatable\<Person\>

为类型输出格式化字符串

格式化方法概述

有两种方法可以为类型提供格式化的字符串输出

主动实现方式(IFormattable接口)

  • 实现要求:让类型继承接口IFormattable
  • 适用场景:当开发者可以预见类型在格式化方面的要求时
  • 特点:这对类型来说是一种主动实现的方式

自定义格式化器方式

  • 使用场景:更多的时候,类型的使用者需为类型自定义格式化器
  • 优势:这是最灵活多变的方法,可以根据需求的变化为类型提供多个格式化器

格式化器接口要求

  • 必要接口:一个典型的格式化器应该继承接口IFormatProvider和ICustomFormatter

正确实现浅拷贝和深拷贝

浅拷贝定义与特点

  • 基本概念:将对象中的所有字段复制到新的对象(副本)中
  • 值类型处理:值类型字段的值被复制到副本中后,在副本中的修改不会影响到源对象对应的值
  • 引用类型处理:引用类型的字段被复制到副本中的是引用类型的引用,而不是引用的对象
  • 修改影响:在副本中对引用类型的字段值做修改会影响到源对象本身

深拷贝定义与特点

  • 基本概念:同样将对象中的所有字段复制到新的对象中
  • 处理方式:无论是对象的值类型字段,还是引用类型字段,都会被重新创建并赋值
  • 修改隔离:对于副本的修改,不会影响到源对象本身

克隆接口实现建议

  • 接口推荐:微软建议用类型继承ICloneable接口的方式明确告诉调用者:该类型可以被拷贝
  • 接口特点:ICloneable接口只提供了一个声明为Clone的方法
  • 实现灵活性:可以根据需求在Clone方法内实现浅拷贝或深拷贝

示例:浅拷贝实现代码

class Employee:ICloneable
{
public string IDCode {get;set;}
public int Age {get;set; }
public Department Department{get;set;}

#region ICloneable成员
public object Clone()
{
return this.MemberwiseClone();
}
#endregion
}

class Department
{
public string Name {get;set;}
public override string ToString()
{
return this.Name;
}
}

string类型在浅拷贝中的特殊性

注意到Employee的IDCode属性是string类型。理论上string类型是引用类型,但是由于该引用类型的特殊性(无论是实现还是语义),Object.MemberwiseClone方法仍旧为其创建了副本。也就是说,在浅拷贝过程,我们应该将字符串看成是值类型。

示例:深拷贝实现样例

(建议使用序列化的形式来进行深拷贝)

class Employee:ICloneable
{
public string IDCode{get;set;}
public int Age{get;set;}
public Department Department{get;set;}

#region ICloneable成员
public object Clone()
{
using(Stream objectStream=new MemoryStream())
{
IFormatter formatter=new BinaryFormatter();
formatter.Serialize(objectStream,this);
objectStream.Seek(0,SeekOrigin.Begin);
return formatter.Deserialize(objectStream)as Employee;
}
}
#endregion
}

同时实现深拷⻉和浅拷⻉:

由于接⼝ICloneable只有⼀个模棱两可的Clone⽅法,所以,如果要在⼀个类中同时实现深拷⻉和浅拷⻉,只能由我们⾃⼰实现两个额外的⽅法,声明为DeepClone和Shallow。Em-ployee的最终版本看起来应该像如下的形式:

[Serializable]
class Employee:ICloneable
{
public string IDCode{get;set;}
public int Age{get;set;}
public Department Department{get;set;}

#region ICloneable成员
public object Clone()
{
return this.MemberwiseClone();
}
#endregion

public Employee DeepClone()
{
using(Stream objectStream=new MemoryStream())
{
IFormatter formatter=new BinaryFormatter();
formatter.Serialize(objectStream,this);
objectStream.Seek(0,SeekOrigin.Begin);
return formatter.Deserialize(objectStream)as Employee;
}
}

public Employee ShallowClone()
{
return Clone()as Employee;
}
}

接⼝的声明

⽤名词或名词短语,或者描述⾏为的形容词命名接⼝。例:

接⼝名称 IComponent 使⽤描述性名词 接⼝名称 ICustomAttributeProvider 使⽤名词短语 接⼝名称 IPersistable 使⽤形容词。

使⽤ Pascal 命名法。 少⽤缩写。 前缀。 不要使⽤下划线字符"_"。 给接⼝名称加上字⺟ I 前缀,以指示该类型为接⼝。在定义类/接⼝对(其中类是接⼝的标准实现)时使⽤相似的名称。两个名称的区别应该只是接⼝名称上有字⺟ I

public interface IServiceProvider
public interface IFormatable

以下代码示例阐释如何定义 IComponent 接⼝及其标准实现 Component 类。

public interface IComponent{
// Implementation code goes here.
}

public class Component: IComponent {
// Implementation code goes here.
}

关于枚举

枚举的每个值均代表了具体的含义,它是⼀个强类型化的常量参数;

⼀定要使⽤enum来定义枚举,⽽⾮使⽤常量类。枚举类型是⼀个具有⼀个静态常量集合的结构体。如果遵守这些规范,定义枚举类型,⽽不是带有静态常量的结构体,会得到额外的编译器和反射⽀持。

错误示范:

public static class Color
{
public const int Red = 0;
public const int Green = 1;
public const int Blue = 2;
}

正确示范:

public enum Color
{
Red,
Green,
Blue
}

显式指定枚举值,防⽌将来在中间插⼊枚举变量导致枚举值混乱。

⼀定不要在.NET中使⽤ Enum.IsDefined 来检查枚举范围。Enum.IsDefined有2个问题。⾸先,它加载反射和⼤量类型元数据,代价极其昂贵。第⼆,它存在版本的问题。

正确示范

if (c > Color.Black || c < Color.White){
throw new ArgumentOutOfRangeException("...");
}

错误示范:

if (!Enum.IsDefined(typeof(Color), c)){ throw new InvalidEnumArgumentException(...);}

将0值作为枚举的默认值

允许使⽤的枚举类型有byte、sbyte、short、ushort、int、uint、long和ulong。应该始终将0值作为枚举类型的默认值。不过,这样做不是因为允许使⽤的枚举类型在声明时的默认值是0值,⽽是有⼯程上的意义。

既然枚举类型从0开始,这样可以避免⼀个星期多出来⼀个0值。

避免给枚举类型的元素提供显式的值

不要给枚举设定值。有时候有某些增加的需要,会为枚举添加元素,在这个时候,就像我们为枚举增加元素ValueTemp⼀样,极有可能会⼀不⼩⼼增加⼀个⽆效值。

字符串

⼀定不要使⽤'+'操作符来拼接⼤量字符串。我们应该使⽤StringBuilder来实现拼接⼯作。然⽽,拼接少量的字符串时,推荐使⽤C#6的$插值字符串。

正确示范:

StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10; i++){
sb.Append(i.ToString());
}

⼀定要使⽤显式地指定了字符串⽐较规则的重载函数。⼀般来说,需要调⽤带有StringComparison类型参数的重载函数。

⼀定要在对⽂化未知的字符串做⽐较时,使⽤StringComparison.Ordinal 和StringComparison.OrdinalIgnoreCase 作为安全默认值,以提⾼性能表现。

⼀定要在向⽤户输出结果时,使⽤基于StringComparison.CurrentCulture 的字符串操作。

⼀定要在⽐较语⾔⽆关字符串(符号,等)时,使⽤⾮语⾔学的StringComparison.Ordinal 或者StringComparison.OrdinalIgnoreCase 值,⽽不是基于CultureInfo.InvariantCulture 的字符串操作。⼀般⽽⾔,不要使⽤基于StringComparison.InvariantCulture的字符串操作。⼀个例外是当你坚持其语⾔上有意义,⽽与具体⽂化⽆关的情况。

⼀定要使⽤ String.Equals 的重载版本来测试2个字符串是否相等。⽐如,忽略⼤⼩写后,判断2个字符串是否相等,

if (str1.Equals(str2, StringComparison.OrdinalIgnoreCase))

⼀定不要使⽤ String.Compare 或CompareTo 的重载版本来检验返回值是否为0,来判断字符串是否相等。这2个函数是⽤于字符串排序,⽽⾮检查相等性。

⼀定要在字符串⽐较时,以String.ToUpperInvariant函数使字符串规范化,⽽不⽤String.ToLowerInvariant。

正确操作字符串

拼接字符串⼀定要考虑使⽤ StringBuilder,默认⻓度为16,实际看情况设置。

StringBuilder本质:是以⾮托管⽅式分配内存。

同时StringFormat⽅法内部也是使⽤StringBuilder进⾏字符串格式化。

数组和集合

我们应该在低层次函数中使⽤数组,来减少内存消耗,增强性能表现。对于公开接⼝,则偏向选择集合。

集合提供了对于其内容更多的控制权,可以随着时间改善,提⾼可⽤性。另外,不推荐在只读场景下使⽤数组,因为数组克隆的代价太⾼。不过对于只读场景使⽤数组也是个不错的主意。数组的内存占⽤⽐较低,并因为运⾏时的优化能更快的访问数组元素。

⼀定不要使⽤只读的数组字段。字段本身只读,不能被修改,但是其内部元素可以被修改。以下示例展示了使⽤只读数组字段的陷阱:

错误示范:

public static readonly char[] InvalidPathChars = { '\"', '<', '>', '|'};

调⽤者可以修改数组内的值:

InvalidPathChars[0] = 'A';

我们可以使⽤⼀个只读集合(只要其元素也是不可变的),或者在返回之前进⾏数组克隆。然⽽,数组克隆的代价可能过⾼:

public static ReadOnlyCollection<char> GetInvalidPathChars()
{
return Array.AsReadOnly(badChars);
}

public static char[] GetInvalidPathChars()
{
return (char[]) badChars.Clone();
}

我们应该使⽤不规则数组来代替使⽤多维数组。⼀个不规则数组是指其元素本身也是⼀个数组。构成元素的数组可能有不同⼤⼩,这样相较于多维数组能减少⼀些数据集的空间浪费(例如,稀疏矩阵)。另外,CLR能够对不规则数组的索引操作进⾏优化,所以在某些情景下,具有更好的性能表现。

// 不规则数组
int[][] jaggedArray =
{
new int[] {1, 2, 3, 4},
new int[] {5, 6, 7},
new int[] {8},
new int[] {9}
};

// 多维数组
int[,] multiDimArray =
{
{1, 2, 3, 4},
{5, 6, 7, 0},
{8, 0, 0, 0},
{9, 0, 0, 0}
};

⼀定要将代表了读/写集合的属性或返回值声明为 Collection<T>或其⼦类,将代表了只读集合的属性或返回值声明为ReadOnlyCollection<T> 或其⼦类。

我们应该重新考虑对于 ArrayList 的使⽤,因为所有添加⾄其中的对象都被当做 System.Object,当从ArrayList 取回值时,这些对象都会拆箱,并返回其真实的值类型。所以我们推荐您使⽤定制类型的集合,⽽不是ArrayList。⽐如,.NET 在System.Collection.Specialized命名空间内为String提供了强类型集合 StringCollection。

我们应该重新考虑对于Hashtable的使⽤。相反,您应该尝试其他字典类,例如StringDictionary,NameValueCollection。除⾮Hashtable 只存储少量值,最好不要使⽤Hashtable。

我们应该在实现集合类型时,为其实现IEnumerable接⼝,这样该集合便能⽤于LINQ to Objects。

⼀定不要在同⼀个类型上同时实现 IEnumerator<T>IEnumerable<T>接⼝。同样,也不要同时实现⾮泛型接⼝IEnumerator 和 IEnumerable。所以,⼀个类型只能成为⼀个集合或者⼀个枚举器,⽽不可⼆者皆得。

⼀定不要返回数组或集合的null引⽤。空值数组或集合的含义在代码环境中很难被理解。⽐如,⼀个⽤户可能假定如下代码能够正常运⾏,所以应该返回⼀个空数组或集合,⽽不是null引⽤。

int[] arr = SomeOtherFunc();
foreach (int v in arr)
{
...
}

关于 HashCode 和 Equals 的处理,遵循如下规则:

  1. 只要重写 Equals,就必须重写 GetHashCode。
  2. 因为 HashSet存储的是不重复的对象,依据 HashCode和Equals进⾏判断,所以 HashSet 存储的对象必须重写这两个⽅法。

不要在 foreach 循环⾥进⾏元素的 Remove/Add 操作。Remove 元素需使⽤ Linq⽅式,如果并发操作,需要对 List对象加锁。

List<string> list = new List<string>();
list.Add("1");
list.Add("2");
list.RemoveAll(o => o == "0");

结构体

⼀定确保将所有实例数据设置为0值,false或者是null。当创建结构体数组时,这样能防⽌意外创建了⽆效实例。

⼀定要为值类型实现 IEquatable\<T\> 接⼝。值类型的Object.Equals ⽅法会引起装箱操作。且因为使⽤了反射特性,所以其默认实现效率不⾼。IEquatable\<T\>.Equals较其有相当⼤的性能提升,且其实现可以不引发装箱操作。

结构体vs类

没有特殊情况⼀定不要定义结构体,除⾮其具有如下特性:

  • 它在逻辑上代表了⼀个单值,类似于原始类型(例如,int、 double,等等)。
  • 其⼤⼩⼩于16字节。
  • 它是不可变的。
  • 它不会引发频繁的装箱拆箱操作。

其余情况下,您应该定义类,⽽不是结构体。

静态类

⼀定要合理使⽤静态类。静态类应该被⽤于框架内基于对象的核⼼⽀持辅助类。

抽象类

⼀定不要在抽象类中定义Public或Protected-Internal的构造函数。 ⼀定要为抽象类定义⼀个Protected,或Internal的构造函数。 Protected构造函数更常⻅,因为其允许当⼦类创建时,基类可以完成⾃⼰的初始化⼯作。

public abstract class Claim{
protected Claim()
{
...
}
}

internal 构造函数⽤于限制将抽象类的实现具化到定义该类的程序集。

public abstract class Claim{
internal Claim()
{
...
}
}

泛型委托与事件

总是优先考虑泛型

泛型的优点是多⽅⾯的,⽆论是泛型类还是泛型⽅法都同时具备可重⽤性、类型安全和⾼效率等特性,这都是⾮泛型类和⾮泛型⽅法⽆法具备的

避免在泛型类型中声明静态成员

实际上,随着你为T指定不同的数据类型,MyList<T>相应地也变成了不同的数据类型,在它们之间是不共享静态成员的。

但是若T所指定的数据类型是⼀致的,那么两个泛型对象间还是可以共享静态成员的,如局部的List<string>List<string>的变量。但是,为了规避因此⽽引起的混淆,仍旧建议在实际的编码⼯作中,尽量避免声明泛型类型的静态成员。

⾮泛型类型中的泛型⽅法并不会在运⾏时的本地代码中⽣成不同的类型。

例如:

static void Main(string[]args)
{
Console.WriteLine(MyList.Func<int>());
Console.WriteLine(MyList.Func<int>());
Console.WriteLine(MyList.Func<string>());
}

class MyList
{
static int count;
public static int Func<T>()
{
return count++;
}
}

输出 0 ;1;2

为泛型参数设定约束

编码过程中,应该始终考虑为泛型参数设定约束。约束使泛型参数成为⼀个实实在在的"对象",让它具有了我们想要的⾏为和属性,⽽不仅仅是⼀个object。

指定约束示例:

  • 指定参数是值类型。(除Nullable外) where T:struct
  • 指定参数是引⽤类型。 where T:class
  • 指定参数具有⽆参数的公共构造⽅法。 where T:new()
    • 注意,CLR⽬前只⽀持⽆参构造⽅法约束。
  • 指定参数必须是指定的基类,或者派⽣⾃指定的基类。
  • 指定参数必须是指定的接⼝,或者实现指定的接⼝。
  • 指定T提供的类型参数必须是为U提供的参数,或者派⽣⾃为U提供的参数。 where T:U

可以对同⼀类型的参数应⽤多个约束,并且约束⾃身可以是泛型类型。

使⽤default为泛型类型变量指定初始值

有些算法,⽐如泛型集合List<T>的Find算法,所查找的对象有可能会是值类型,也有可能是引⽤类型。在这种算法内部,我们常常会为这些值类型变量或引⽤类型变量指定默认值。于是,问题来了:值类型变量的默认初始值是0值,⽽引⽤类型变量的默认初始值是null值,显然,这会导致下⾯的代码编译出错:

public T Func<T>()
{
T t=null;
T t=0;
return t;
}

代码"T t=null;"在Visual Studio编译器中会警示:错误1不能将Null转换为类型形参"T",因为它可能是不可以为null值的类型。请考虑改⽤"default(T)". 代码"T t=0;"会警示:错误1⽆法将类型"int"隐式转换为"T"。

改进

public T Func<T>()
{
T t=default(T);
return t;
}

使⽤FCL中的委托声明

要注意FCL中存在三类这样的委托声明,它们分别是:Action、Func、Predicate。尤其是在它们的泛型版本出来以后,已经能够满⾜我们在实际编码过程中的⼤部分需要。

我们应该习惯在代码中使⽤这类委托来代替⾃⼰的委托声明。

除了Action、Func和Predicate外,FCL中还有⽤于表示特殊含义的委托声明。

//如⽤于表示注册事件⽅法的委托声明:
public delegate void EventHandler(object sender,EventArgs e);
public delegate void EventHandler<TEventArgs>(object sender,TEventArgs e);

//表示线程⽅法的委托声明:
public delegate void ThreadStart();
public delegate void ParameterizedThreadStart(object obj);

//表示异步回调的委托声明:
public delegate void AsyncCallback(IAsyncResult ar);

在FCL中每⼀类委托声明都代表⼀类特殊的⽤途,虽然可以使⽤⾃⼰的委托声明来代替,但是这样做不仅没有必要,⽽且会让代码失去简洁性和标准性。在我们实现⾃⼰的委托声明前,应该⾸先查看MSDN,确信有必要之后才这样做。

使⽤Lambda表达式代替⽅法和匿名⽅法

在实际的编码⼯作中熟练运⽤它,避免写出烦琐且不美观的代码。

⼩⼼闭包中的陷阱

如果匿名⽅法(Lambda表达式)引⽤了某个局部变量,编译器就会⾃动将该引⽤提升到该闭包对象中,即将for循环中的变量i修改成了引⽤闭包对象(编译器⾃动创建)的公共变量i。

示例如下:

static void Main(string[]args)
{
List<Action>lists=new List<Action>();
for(int i=0;i<5;i++)
{
Action t=()=>
{
Console.WriteLine(i.ToString());
};
lists.Add(t);
}
foreach(Action t in lists)
{
t();
}
}

以上结果全部输出5;

另外⼀种实现⽅式;

static void Main(string[]args)
{
List<Action>lists=new List<Action>();
TempClass tempClass=new TempClass();
for(tempClass.i=0;tempClass.i<5;tempClass.i++)
{
Action t=tempClass.TempFuc;
lists.Add(t);
}
foreach(Action t in lists)
{
t();
}
}

class TempClass
{
public int i;
public void TempFuc()
{
Console.WriteLine(i.ToString());
}
}

这段代码所演示的就是闭包对象。所谓闭包对象,指的是上⾯这种情形中的TempClass对象(在第⼀段代码中,也就是编译器为我们⽣成的"<>c__DisplayClass2"对象)。如果匿名⽅法(Lambda表达式)引⽤了某个局部变量,编译器就会⾃动将该引⽤提升到该闭包对象中,即将for循环中的变量i修改成了引⽤闭包对象的公共变量i。这样⼀来,即使代码执⾏后离开了原局部变量i的作⽤域(如for循环),包含该闭包对象的作⽤域也还存在。理解了这⼀点,就能理解代码的输出了。

了解委托的本质

理解C#中的委托需要把握两个要点:

  1. 委托是⽅法指针。
  2. 委托是⼀个类,当对其进⾏实例化的时候,要将引⽤⽅法作为它的构造⽅法的参数。

使⽤event关键字为委托施加保护

⾸先没有event加持的委托,我们可以对它随时进⾏修改赋值,以⾄于⼀个⽅法改动了另⼀个⽅法的委托链引⽤,⽐如赋值为null,另外⼀个⽅法中调⽤的时候将抛出异常。

如果有event加持的时候,我们修改的时候,⽐如:

fl.FileUploaded=null;
fl.FileUploaded=Progress;
fl.FileUploaded(10);

以上代码编译会出现错误警告:

事件 "ConsoleApplication1.FileUploader.FileUploaded"只能出现在+=或-=的左边(从类型"ConsoleApplication1.FileUploader"中使⽤时除外)

实现标准的事件模型

有了上⾯的event加持,但是还不能够规范。

EventHandler的原型声明:

public delegate void EventHandler(object sender,EventArgs e);

微软为事件模型设定的⼏个规范:

  1. 委托类型的名称以EventHandler结束;
  2. 委托原型返回值为void;
  3. 委托原型具有两个参数:sender表示事件触发者,e表示事件参数;
  4. 事件参数的名称以EventArgs结束。

使⽤泛型参数兼容泛型接⼝的不可变性

让返回值类型返回⽐声明的类型派⽣程度更⼤的类型,就是"协变"。

编译器对于接⼝和委托类型参数的检查是⾮常严格的,除⾮⽤关键字out特别声明,不然这段代码只会编译失败。⽐如下例

例如:

class Program{
static void Main(string[]args)
{
ISalary<Programmer>s=new BaseSalaryCounter<Programmer>();
PrintSalary(s);
}

static void PrintSalary(ISalary<Employee>s)
{
s.Pay();
}
}

interface ISalary<T>
{
void Pay();
}

class BaseSalaryCounter<T>:ISalary<T>
{
public void Pay()
{
Console.WriteLine("Pay base salary");
}
}

class Employee
{
public string Name{get;set;}
}

class Programmer:Employee{}

class Manager:Employee{}

报错:⽆法从"ConsoleApplication4.ISalary<ConsoleApplication4.Programmer>"转换为"ConsoleApplication4.ISalary<ConsoleApplication4.Employee>"

要让PrintSalary完成需求,我们可以使⽤泛型类型参数:

static void PrintSalary<T>(ISalary<T>s)
{
s.Pay();
}

实际上,只要泛型类型参数在⼀个接⼝声明中不被⽤来作为⽅法的输⼊参数,我们都可姑且把它看成是"返回值"类型的。所以,泛型类型参数这种模式是满⾜"协变"的定义的。但是,只要将T作为输⼊参数,便不满⾜"协变"的定义了。如:

interface ISalary<out T>
{
void Pay(T t);
}

编译会提示:差异⽆效:类型参数"T"必须是在"ISalary.Pay(T)"上有效的逆变式。"T"为协变。

让接⼝中的泛型参数⽀持协变

除了11中提到的使⽤泛型参数兼容泛型接⼝的不可变性外,还有⼀种办法就是为接⼝中的泛型声明加上out关键字来⽀持协变。

out关键字是FCL 4.0中新增的功能,它可以在泛型接⼝和委托中使⽤,⽤来让类型参数⽀持协变性。通过协变,可以使⽤⽐声明的参数派⽣类型更⼤的参数。通过下⾯例⼦我们应该能理解这种应⽤。

⽐如:

static void Main(string[]args)
{
ISalary<Programmer>s=new BaseSalaryCounter<Programmer>();
ISalary<Manager>t=new BaseSalaryCounter<Manager>();
PrintSalary(s);
PrintSalary(t);
}

static void PrintSalary(ISalary<Employee>s)//⽤法正确
{
s.Pay();
}
}

interface ISalary<out T> //使⽤了out关键字
{
void Pay();
}

FCL 4.0对多个接⼝进⾏了修改以⽀持协变,如IEnumerable<out T>、IEnumerator<out T>、IQuerable<out T>等。由于IEnumerable<out T>现在⽀持协变,所以上段代码在FCL 4.0中能运⾏得很好。

在我们⾃⼰的代码中,如果要编写泛型接⼝,除⾮确定该接⼝中的泛型参数不涉及变体,否则都建议加上out关键字。协变增⼤了接⼝的使⽤范围,⽽且⼏乎不会带来什么副作⽤。

理解委托中的协变

委托中的泛型变量天然是部分⽀持协变的。

⽐如:

public delegate T GetEmployeeHanlder<T>(string name);

static void Main(){
GetEmployeeHanlder<Employee>getAEmployee=GetAManager;
Employee e=getAEmployee("Mike");
}

因为存在下⾯这样⼀种情况,所以编译通不过:

GetEmployeeHanlder<Manager>getAManager=GetAManager;
GetEmployeeHanlder<Employee>getAEmployee=getAManager;
static Manager GetAManager(string name)
{
Console.WriteLine("我是经理:"+name);
return new Manager(){Name=name};
}

static Employee GetAEmployee(string name)
{
Console.WriteLine("我是雇员:"+name);
return new Employee(){Name=name};
}

要让上⾯的代码编译通过,同样需要为委托中的泛型参数指定out关键字:

public delegate T GetEmployeeHanlder<out T>(string name);

FCL 4.0中的⼀些委托声明已经⽤out关键字来让委托⽀持协变了,如我们常常会使⽤到的:

public delegate TResult Func<out TResult>()和
public delegate TOutput Converter<in TInput,out TOutput>(TInput input)

为泛型类型参数指定逆变

逆变是指方法的参数可以是委托或泛型接口的参数类型的基类。FCL 4.0中支持逆变的常用委托有:

Func<in T, out TResult>
Predicate<in T>

常用泛型接口有:

IComparer<in T>

举例:

class Program
{
static void Main()
{
Programmer p = new Programmer{Name="Mike"};
Manager m = new Manager{Name="Steve"};
Test(p, m);
}

static void Test<T>(IMyComparable<T> t1, T t2)
{
// 省略
}
}

public interface IMyComparable<in T>
{
int Compare(T other);
}

public class Employee : IMyComparable<Employee>
{
public string Name { get; set; }
public int Compare(Employee other)
{
return Name.CompareTo(other.Name);
}
}

public class Programmer : Employee, IMyComparable<Programmer>
{
public int Compare(Programmer other)
{
return Name.CompareTo(other.Name);
}
}

public class Manager : Employee
{
}

在上面的这个例子中,如果不为接口IMyComparable的泛型参数T指定in关键字,将会导致Test(p, m)编译错误。由于引入了接口的逆变性,这让方法Test支持了更多的应用场景。在FCL4.0之后版本的实际编码中应该始终注意这一点。

集合与LINQ

元素数量可变的情况下不应使用数组

在C#中,数组一旦被创建,长度就不能改变。如果我们需要一个动态且可变长度的集合,就应该使用ArrayList或List<T>来创建。而数组本身,尤其是一维数组,在遇到要求高效率的算法时,则会专门被优化以提升其效率。一维数组也称为向量,其性能是最佳的,在IL中使用了专门的指令来处理它们(如newarr、ldelem、ldelem a、ldlen和stelem)。

从内存使用的角度来讲,数组在创建时被分配了一段固定长度的内存。如果数组的元素是值类型,则每个元素的长度等于相应的值类型的长度;如果数组的元素是引用类型,则每个元素的长度为该引用类型的IntPtr.Size。数组的存储结构一旦被分配,就不能再变化。而ArrayList是数组结构,可以动态地增减内存空间,如果ArrayList存储的是值类型,则会为每个元素增加12字节的空间,其中4字节用于对象引用,8字节是元素装箱时引入的对象头。List<T>是ArrayList的泛型实现,它省去了拆箱和装箱带来的开销。

注意: 由于数组本身在内存上的特点,因此在使用数组的过程中还应该注意大对象的问题。所谓"大对象",是指那些占用内存超过85,000字节的对象,它们被分配在大对象堆里。大对象的分配和回收与小对象相比,都不太一样,尤其是回收,大对象在回收过程中会带来效率很低的问题。所以,不能肆意对数组指定过大的长度,这会让数组成为一个大对象。

如果一定要动态改变数组的长度,一种方法是将数组转换为ArrayList或List<T>,需要扩容时,内部数组将自动翻倍扩容。

还有一种方法是用数组的复制功能。数组继承自System.Array,抽象类System.Array提供了一些有用的实现方法,其中就包含了Copy方法,它负责将一个数组的内容复制到另外一个数组中。无论是哪种方法,改变数组长度就相当于重新创建了一个数组对象。

多数情况下使用foreach进行循环遍历

采用foreach最大限度地简化了代码。它用于遍历一个继承了IEnumerable或IEnumerable<T>接口的集合元素。借助于IL代码可以看到foreach还是本质就是利用了迭代器来进行集合遍历。如下:

List<object> list = new List<object>();
using (List<object>.Enumerator CS$5$0000 = list.GetEnumerator())
{
while (CS$5$0000.MoveNext())
{
object current = CS$5$0000.Current;
}
}

除了代码简洁之外,foreach还有两个优势:

  1. 自动将代码置入try-finally块
  2. 若类型实现了IDispose接口,它会在循环结束后自动调用Dispose方法。

foreach不能代替for

foreach存在的一个问题是:它不支持循环时对集合进行增删操作。取而代之的方法是使用for循环。

不支持原因: foreach循环使用了迭代器进行集合的遍历,它在FCL提供的迭代器内部维护了一个对集合版本的控制。那么什么是集合版本?简单来说,其实它就是一个整型的变量,任何对集合的增删操作都会使版本号加1。foreach循环会调用MoveNext方法来遍历元素,在MoveNext方法内部会进行版本号的检测,一旦检测到版本号有变动,就会抛出InvalidOperationException异常。

如果使用for循环就不会带来这样的问题。for直接使用索引器,它不对集合版本号进行判断,所以不存在因为集合的变动而带来的异常(当然,超出索引长度这种情况除外)。

public bool MoveNext()
{
List<T> list = this.list;
if ((this.version == list._version) && (this.index < list._size))
{
this.current = list._items[this.index];
this.index++;
return true;
}
return this.MoveNextRare();
}

无论是for循环还是foreach循环,内部都是对该数组的访问,而迭代器仅仅是多进行了一次版本检测。事实上,在循环内部,两者生成的IL代码也是差不多的。

使用更有效的对象和集合初始化

举例:

class Program {
static void Main(string[] args)
{
Person person = new Person(){Name="Mike", Age=20};
}
}

class Person
{
public string Name { get; set; }
public int Age { get; set; }
}

对象初始化设定项支持在大括号中对自动实现的属性进行赋值。以往只能依靠构造方法传值进去,或者在对象构造完毕后对属性进行赋值。现在这些步骤简化了,初始化设定项实际相当于编译器在对象生成后对属性进行了赋值。

集合初始化也同样进行了简化:

List<Person> personList = new List<Person>()
{
new Person() {Name="Rose", Age=19},
mike,
null
};

重点:初始化设定项绝不仅仅是为了对象和集合初始化的方便,它更重要的作用是为LINQ查询中的匿名类型进行属性的初始化。由于LINQ查询返回的集合中匿名类型的属性都是只读的,如果需要为匿名类型属性赋值,或者增加属性,只能通过初始化设定项来进行。初始化设定项还能为属性使用表达式。

举例:

List<Person> personList2 = new List<Person>()
{
new Person(){Name="Rose", Age=19},
new Person(){Name="Steve", Age=45},
new Person(){Name="Jessica", Age=20}
};

var pTemp = from p in personList2
select new { p.Name, AgeScope = p.Age > 20 ? "Old" : "Young" };

foreach (var item in pTemp)
{
Console.WriteLine(string.Format("{0}:{1}", item.Name, item.AgeScope));
}

使用泛型集合代替非泛型集合

注意,非泛型集合在System.Collections命名空间下,对应的泛型集合则在System.Collections.Generic命名空间下。

泛型的好处不言而喻,如果对大型集合进行循环访问、转型或拆箱和装箱操作,使用ArrayList这样的传统集合对效率的影响会非常大。鉴于此,微软提供了对泛型的支持。泛型使用一对<>括号将实际的类型括起来,然后编译器和运行时会完成剩余的工作。

选择正确的集合

要选择正确的集合,首先需要了解一些数据结构的知识。所谓数据结构,就是相互之间存在一种或多种特定关系的数据元素的集合。

说明: 直接存储结构的优点是:向数据结构中添加元素是很高效的,直接放在数据末尾的第一个空位上就可以了。它的缺点是:向集合插入元素将会变得低效,它需要给插入的元素腾出位置并顺序移动后面的元素。

如果集合的数目固定并且不涉及转型,使用数组效率高,否则就使用List<T>(该使用数组的时候,还是要使用数组)

顺序存储结构,即线性表。线性表可动态地扩大和缩小,它在一片连续的区域中存储数据元素。线性表不能按照索引进行查找,它是通过对地址的引用来搜索元素的,为了找到某个元素,它必须遍历所有元素,直到找到对应的元素为止。所以,线性表的优点是插入和删除数据效率高,缺点是查找的效率相对来说低一些。

队列Queue<T>遵循的是先入先出的模式,它在集合末尾添加元素,在集合的起始位置删除元素。

Stack<T>遵循的是后入先出的模式,它在集合末尾添加元素,同时也在集合末尾删除元素。

字典Dictionary<TKey, TValue>存储的是键值对,值在基于键的散列码的基础上进行存储。字典类对象由包含集合元素的存储桶组成,每一个存储桶与基于该元素的键的哈希值关联。如果需要根据键进行值的查找,使用Dictionary<TKey, TValue>将会使搜索和检索更快捷。

双向链表LinkedList<T>是一个类型为LinkedListNode<T>的元素对象的集合。当我们觉得在集合中插入和删除数据很慢时,就可以考虑使用链表。如果使用LinkedList<T>,我们会发现此类型并没有其他集合普遍具有的Add方法,取而代之的是AddAfter、AddBefore、AddFirst、AddLast等方法。双向链表中的每个节点都向前指向Previous节点,向后指向Next节点。

在FCL中,非线性集合实现得不多。非线性集合分为层次集合和组集合。层次集合(如树)在FCL中没有实现。组集合又分为集和图,集在FCL中实现为HashSet<T>,而图在FCL中也没有对应的实现。

集的概念本意是指存放在集合中的元素是无序的且不能重复的。

除了上面提到的集合类型外,还有其他几个要掌握的集合类型,它们是在实际应用中发展而来的对以上基础类型的扩展:SortedList<T>SortedDictionary<TKey, TValue>SortedSet<T>。它们所扩展的对应类分别为List<T>Dictionary<TKey, TValue>HashSet<T>,作用是将原本无序排列的元素变为有序排列。

除了排序上的需求增加了上面3个集合类外,在命名空间System.Collections.Concurrent下,还涉及几个多线程集合类。它们主要是:

  • ConcurrentBag<T>对应List<T>
  • ConcurrentDictionary<TKey, TValue>对应Dictionary<TKey, TValue>
  • ConcurrentQueue<T>对应Queue<T>
  • ConcurrentStack<T>对应Stack<T>

确保集合的线程安全

集合线程安全是指在多个线程上添加或删除元素时,线程之间必须保持同步。

泛型集合一般通过加锁来进行安全锁定,如下:

static object sycObj = new object();
static void Main(string[] args)
{
//object sycObj = new object();
Thread t1 = new Thread(() => {
// 确保等待t2开始之后才运行下面的代码
autoSet.WaitOne();
lock(sycObj)
{
foreach(Person item in list)
{
Console.WriteLine("t1:" + item.Name);
Thread.Sleep(1000);
}
}
});
}

避免将List<T>作为自定义集合类的基类

如果要实现一个自定义的集合类,不应该以一个FCL集合类为基类,而应该扩展相应的泛型接口。FCL集合类应该以组合的形式包含至自定义的集合类,需扩展的泛型接口通常是IEnumerable<T>ICollection<T>(或ICollection<T>的子接口,如IList<T>),前者规范了集合类的迭代功能,后者则规范了一个集合通常会有的操作。

List<T>基本上没有提供可供子类使用的protected成员(从object中继承来的Finalize方法和MemberwiseClone方法除外),也就是说,实际上,继承List<T>并没有带来任何继承上的优势,反而丧失了面向接口编程带来的灵活性。而且,稍加不注意,隐含的Bug就会接踵而至。

迭代器应该是只读的

FCL中的迭代器只有GetEnumerator方法,没有SetEnumerator方法。所有的集合类也没有一个可写的迭代器属性。

原因有二:

  1. 这违背了设计模式中的开闭原则。被设置到集合中的迭代器可能会直接导致集合的行为发生异常或变动。一旦确实需要新的迭代需求,完全可以创建一个新的迭代器来满足需求,而不是为集合设置该迭代器,因为这样做会直接导致使用到该集合对象的其他迭代场景发生不可知的行为。
  2. 现在,我们有了LINQ。使用LINQ可以不用创建任何新的类型就能满足任何的迭代需求。

谨慎集合属性的可写操作

如果类型的属性中有集合属性,那么应该保证属性对象是由类型本身产生的。如果将属性设置为可写,则会增加抛出异常的几率。一般情况下,如果集合属性没有值,则它返回的Count等于0,而不是集合属性的值为null。

使用匿名类型存储LINQ查询结果(最佳搭档)

从.NET 3.0开始,C#开始支持一个新特性:匿名类型。匿名类型由var、赋值运算符和一个非空初始值(或以new开头的初始化项)组成。匿名类型有如下的基本特性:

  • 既支持简单类型也支持复杂类型。简单类型必须是一个非空初始值,复杂类型则是一个以new开头的初始化项;
  • 匿名类型的属性是只读的,没有属性设置器,它一旦被初始化就不可更改;
  • 如果两个匿名类型的属性值相同,那么就认为两个匿名类型相等;
  • 匿名类型可以在循环中用作初始化器;
  • 匿名类型支持智能感知;
  • 还有一点,虽然不常用,但是匿名类型确实也可以拥有方法。

在查询中使用Lambda表达式

LINQ实际上是基于扩展方法和Lambda表达式的,理解了这一点就不难理解LINQ。任何LINQ查询都能通过调用扩展方法的方式来替代,如下面的代码所示:

foreach(var item in personList.Select(person => new { PersonName = person.Name, CompanyName = person.CompanyID == 0 ? "Micro" : "Sun" }))
{
Console.WriteLine(string.Format("{0}\t:{1}", item.PersonName, item.CompanyName));
}

针对LINQ设计的扩展方法大多应用了泛型委托。System命名空间定义了泛型委托Action、Func和Predicate。可以这样理解这三个委托:

  • Action用于执行一个操作,所以它没有返回值;
  • Func用于执行一个操作并返回一个值;
  • Predicate用于定义一组条件并判断参数是否符合条件。

Select扩展方法接收的就是一个Func委托,而Lambda表达式其实就是一个简洁的委托,运算符"=>"左边代表的是方法的参数,右边的是方法体。

理解延迟求值和主动求值之间的区别

样例如下:

List<int> list = new List<int>(){0,1,2,3,4,5,6,7,8,9};
var temp1 = from c in list where c > 5 select c;
var temp2 = (from c in list where c > 5 select c).ToList<int>();

在使用LINQ to SQL时,延迟求值能够带来显著的性能提升。举个例子:如果定义了两个查询,而且采用延迟求值,CLR则会合并两次查询并生成一个最终的查询。

区别LINQ查询中的IEnumerable<T>IQueryable<T>

LINQ查询方法一共提供了两类扩展方法,在System.Linq命名空间下,有两个静态类:

  • Enumerable类,它针对继承了IEnumerable<T>接口的集合类进行扩展;
  • Queryable类,它针对继承了IQueryable<T>接口的集合类进行扩展。

稍加观察我们会发现,接口IQueryable<T>实际也是继承了IEnumerable<T>接口的,所以,致使这两个接口的方法在很大程度上是一致的。那么,微软为什么要设计出两套扩展方法呢?

我们知道,LINQ查询从功能上来讲实际上可分为三类:LINQ to OBJECTS、LINQ to SQL、LINQ to XML(本建议不讨论)。设计两套接口的原因正是为了区别对待LINQ to OBJECTS、LINQ to SQL,两者对于查询的处理在内部使用的是完全不同的机制。

针对LINQ to OBJECTS时,使用Enumerable中的扩展方法对本地集合进行排序和查询等操作,查询参数接受的是Func<>Func<>叫做谓语表达式,相当于一个委托。

针对LINQ to SQL时,则使用Queryable中的扩展方法,它接受的参数是Expression<>Expression<>用于包装Func<>。LINQ to SQL引擎最终会将表达式树转化成为相应的SQL语句,然后在数据库中执行。

那么,到底什么时候使用IQueryable<T>,什么时候使用IEnumerable<T>呢?简单表述就是:本地数据源用IEnumerable<T>,远程数据源用IQueryable<T>

注意: 在使用IQueryable<T>IEnumerable<T>的时候还需要注意一点,IEnumerable<T>查询的逻辑可以直接用我们自己所定义的方法,而IQueryable<T>则不能使用自定义的方法,它必须先生成表达式树,查询由LINQ to SQL引擎处理。在使用IQueryable<T>查询的时候,如果使用自定义的方法,则会抛出异常。

使用LINQ取代集合中的比较器和迭代器

LINQ提供了类似于SQL的语法来实现遍历、筛选与投影集合的功能。借助于LINQ的强大功能,我们通过两条语句就能实现上述的排序要求。foreach实际会隐含调用的是集合对象的迭代器。以往,如果我们要绕开集合的Sort方法对集合元素按照一定的顺序进行迭代,则需要让类型继承IEnumerable接口(泛型集合是IEnumerable<T>接口),实现一个或多个迭代器。现在从LINQ查询生成匿名类型来看,相当于可以无限为集合增加迭代需求。

有了LINQ之后,我们是否就不再需要比较器和迭代器了呢?答案是否定的。我们可以利用LINQ的强大功能简化自己的编码,但是LINQ功能的实现本身就是借助于FCL泛型集合的比较器、迭代器、索引器的。LINQ相当于封装了这些功能,让我们使用起来更加方便。在命名空间System.Linq下存在很多静态类,这些静态类存在的意义就是为FCL的泛型集合提供扩展方法。

强烈建议你利用LINQ所带来的便捷性,但我们仍需掌握比较器、迭代器、索引器的原理,以便更好地理解LINQ的思想,写出更高质量的代码。最好是能看懂Linq源码。

在LINQ查询中避免不必要的迭代

比如常使用First()方法,First方法实际完成的工作是:搜索到满足条件的第一个元素,就从集合中返回。如果没有符合条件的元素,它也会遍历整个集合。

与First方法类似的还有Take方法,Take方法接收一个整型参数,然后为我们返回该参数指定的元素个数。与First一样,它在满足条件以后,会从当前的迭代过程直接返回,而不是等到整个迭代过程完毕再返回。如果一个集合包含了很多的元素,那么这种查询会为我们带来可观的时间效率。

会运用First和Take等方法,都会让我们避免全集扫描,大大提高效率。

第三方库的使用

一定不要引用不必要的库,或引用不必要的程序集。移除不必要的引用能够减少项目生成时间,最小化出错几率,减小项目生成后的体积,并给读者留下一个良好的印象。

控制语句块

复合语句

复合语句是指包含"父语句{子语句;子语句;}"的语句,使用复合语句应遵循以下几点:

  1. 子语句要缩进。
  2. 左花括号"{" 在复合语句父语句的下一行并与之对齐,单独成行。
  3. 即使只有一条子语句要不要省略花括号""。如:

return语句

return语句中不使用括号,除非它能使返回值更加清晰。如:

条件语句

在 if/else/for/while/do 语句中必须使用大括号。即使只有一行代码,避免采用单行的编码方式。

如果非得使用 if()...else if()...else...方式表达逻辑,避免后续代码维护困难,请勿超过 3 层。

除常用方法(如 getXxx/isXxx)等外,不要在条件判断中执行其它复杂的语句,将复杂逻辑判断的结果赋值给一个有意义的布尔变量名,以提高可读性。很多if语句内的逻辑相当复杂,阅读者需要分析条件表达式的最终结果,才能明确什么样的条件执行什么样的语句,那么,如果阅读者分析逻辑表达式错误呢?

正确示范:

bool existed = (file.open(fileName, "w") != null) && (...) || (...);
if (existed){ ...}

错误示范:

if ((file.open(fileName, "w") != null) && (...) || (...))
{
...
}

避免采用取反逻辑运算符。取反逻辑不利于快速理解,并且取反逻辑写法必然存在对应的正向逻辑写法。

正例:使用 if (x < 628) 来表达 x 小于 628。 反例:使用 if (!(x >= 628)) 来表达 x 小于 628。

for、foreach 语句

空的 for 语句(所有的操作都在initialization、condition 或 update中实现)使用格式:

for (initialization; condition; update);

foreach 语句使用格式:

foreach (object obj in array)
{
statements;
}

注意:

  1. 在循环过程中不要修改循环计数器。
  2. 对每个空循环体给出确认性注释。
  3. 循环体中的语句要考量性能,以下操作尽量移至循环体外处理:如定义对象、变量、获取数据库连接,进行不必要的 try-catch 操作(这个 try-catch 是否可以移至循环体外)。

while 语句

空的 while 语句使用格式:

while (condition);

do - while 语句

do
{
statements;
} while (condition);

switch - case 语句

在一个 switch 块内,每个 case 要么通过 break/return 等来终止,要么注释说明程序将继续执行到哪一个 case 为止;在一个 switch 块内,都必须包含一个 default 语句并且放在最后,即使空代码。

注意:

  1. 语句switch中的每个case各占一行。
  2. 为所有switch语句提供default分支。
  3. 所有的非空 case 语句必须用 break; 语句结束。

try - catch 语句

try
{
statements;
}
catch (ExceptionClass e)
{
statements;
}
finally
{
statements;
}

using 块语句

using (object)
{
statements;
}

或使用C#8的using语法:

using var ms = new MemoryStream();

DRY

避免出现重复的代码(Don't Repeat Yourself),即 DRY 原则。

随意复制和粘贴代码,必然会导致代码的重复,在以后需要修改时,需要修改所有的副本,容易遗漏。必要时抽取共性方法,或者抽象公共类,甚至是组件化。

正确示范:一个类中有多个 public 方法,都需要进行数行相同的参数校验操作,这个时候请抽取:

private bool CheckParam(DTO dto) { ... }

习惯重载运算符

比如:

Salary familyIncome = mikeIncome + roseIncome;

阅读一目了然。通过使用operator关键字定义静态成员函数来重载运算符,让开发人员可以像使用内置基元类型一样使用该类型。

异常处理

异常不要用来做流程控制,条件控制。异常设计的初衷是解决程序运行中的各种意外情况,且异常的处理效率比条件判断方式要低很多。

catch 时请分清稳定代码和非稳定代码,稳定代码指的是无论如何不会出错的代码。对于非稳定代码的 catch 尽可能进行区分异常类型,再做对应的异常处理。对大段代码进行try-catch,使程序无法根据不同的异常做出正确的应激反应,也不利于定位问题,这是一种不负责任的表现。

正确示范:用户注册的场景中,如果用户输入非法字符,或用户名称已存在,或用户输入密码过于简单,在程序上作出分门别类的判断,并提示给用户。

捕获异常是为了处理它,不要捕获了却什么都不处理而抛弃之,如果不想处理它,请将该异常抛给它的调用者。最外层的业务使用者,必须处理异常,将其转化为用户可以理解的内容。

try块放到了事务代码中,catch异常后,如果需要回滚事务,一定要注意手动回滚事务。

finally 块必须对资源对象、流对象进行关闭,有异常也要做 try-catch。

方法的返回值可以为 null,不强制返回空集合,或者空对象等,必须添加注释充分说明什么情况下会返回 null 值。

对于公开给外部调用的 http/api 开放接口必须使用"错误码";而应用内部推荐异常抛出;

一定要通过抛出异常来告知执行失败。异常在框架内是告知错误的主要手段。如果一个成员方法不能成功的如预期般执行,便应该认为是执行失败,并抛出一个异常。一定不要返回一个错误代码。

一定要抛出最明确,最有意义的异常(继承体系最底层的)。比如,如果传递了一个null实参,则抛出ArgumentNullException,而不是其基类ArgumentException。抛出并捕获System.Exception异常通常都是没有意义的。

一定不要将异常用于常规的控制流。除了系统故障或者带有潜在竞争条件的操作,您编写的代码都不应该抛出异常。比如,在调用可能失败或抛出异常的方法前,您可以检查其前置条件,举例:

一定不要从异常过滤器块内抛出异常。当一个异常过滤器引发了一个异常时,该异常会被CLR捕获,该过滤器返回false。该行为很难与过滤器显式的执行并返回错误区分开,所以会增加调试难度。

一定不要显式的从finally块内抛出异常。从调用方法内隐式的抛出异常是可以接受的。

所有捕获到的异常都应该通过日志系统记录下来,方便后期排查系统故障。

不应该通过捕获笼统的异常,例如System.Exception,System.SystemException,或者 .NET 代码中其他异常,来隐藏错误。一定要捕获代码能够处理的、明确的异常。您应该捕获更加明确的异常,或者在Catch块的最后一条语句处重新抛出该普通异常。以下情况隐藏错误是可以接受的,但是其发生几率很低:

正确示范:

try
{
SaveUser(user);
}
catch(IOException)
{
//IO异常,通知当前用户
}
catch(UnauthorizedAccessException)
{
//权限失败,通知客户端管理员
}
catch(CommunicationException)
{
//网络异常,通知发送E-mail给网络管理员
}

错误示范:

try
{
SaveUser(user);
}
catch(Exception ex)
{
...
}

在捕获并重新抛出异常时,倾向使用throw。保持异常调用栈的最佳途径:

正确示范:

try
{
... // Do some reading with the file
}
catch
{
file.Position = position; // Unwind on failure
throw; // Rethrow
}

错误示范:

try
{
... // Do some reading with the file
}
catch (Exception ex)
{
file.Position = position; // Unwind on failure
throw ex; // Rethrow
}

用抛出异常代替返回错误代码

在异常机制出现之前,应用程序普遍采用返回错误代码的方式来通知调用者发生了异常。本建议首先阐述为什么要用抛出异常的方式来代替返回错误代码的方式。对于一个成员方法而言,它要么执行成功,要么执行失败。成员方法执行成功的情况很容易理解,但是如果执行失败了却没有那么简单,因为我们需要将导致执行失败的原因通知调用者。抛出异常和返回错误代码都是用来通知调用者的手段。

但是当我们想要告诉调用者更多细节的时候,就需要与调用者约定更多的错误代码。于是我们很快就会发现,错误代码飞速膨胀,直到看起来似乎无法维护,因为我们总在查找并确认错误代码。

在没有异常处理机制之前,我们只能返回错误代码。但是,现在有了另一种选择,即使用异常机制。如果使用异常机制,那么最终的代码看起来应该是下面这样的:

使用CLR异常机制后,我们会发现代码变得更清晰、更易于理解了。至于效率问题,还可以重新审视"效率"的立足点:throw exception产生的那点效率损耗与等待网络连接异常相比,简直微不足道,而CLR异常机制带来的好处却是显而易见的。

这里需要稍加强调的是,在catch(CommunicationException)这个代码块中,代码所完成的功能是"通知发送"而不是"发送"本身,因为我们要确保在catch和finally中所执行的代码是可以被执行的。换句话说,尽量不要在catch和finally中再让代码"出错",那会让异常堆栈信息变得复杂和难以理解。

在本例的catch代码块中,不要真的编写发送邮件的代码,因为发送邮件这个行为可能会产生更多的异常,而"通知发送"这个行为稳定性更高(即不"出错")。

以上通过实际的案例阐述了抛出异常相比于返回错误代码的优越性,以及在某些情况下错误代码将无用武之地,如构造函数、操作符重载及属性。语法特性决定了其不能具备任何返回值,于是异常机制被当做取代错误代码的首要选择。

不要在不恰当的场合下引发异常

程序员,尤其是类库开发人员,要掌握的两条首要原则是:

  1. 正常的业务流程不应使用异常来处理。
  2. 不要总是尝试去捕获异常或引发异常,而应该允许异常向调用堆栈往上传播。

那么,到底应该在怎样的情况下引发异常呢?

第一类情况 如果运行代码后会造成内存泄漏、资源不可用,或者应用程序状态不可恢复,则应该引发异常。

在微软提供的Console类中有很多类似这样的代码:

if((value<1)||(value>100))
{
throw new ArgumentOutOfRangeException("value", value, Environment.GetResourceString("ArgumentOutOfRange_CursorSize"));
}

或者:

if(value==null)
{
throw new ArgumentNullException("value");
}

在开头首先提到的就是:对在可控范围内的输入和输出不引发异常。没错,区别就在于"可控"这两个字。所谓"可控",可定义为:发生异常后,系统资源仍可用,或资源状态可恢复。

第二类情况 在捕获异常的时候,如果需要包装一些更有用的信息,则引发异常。

这类异常的引发在UI层特别有用。系统引发的异常所带的信息往往更倾向于技术性的描述;而在UI层,面对异常的很可能是最终用户。如果需要将异常的信息呈现给最终用户,更好的做法是先包装异常,然后引发一个包含友好信息的新异常。

第三类情况 如果底层异常在高层操作的上下文中没有意义,则可以考虑捕获这些底层异常,并引发新的有意义的异常。

例如在下面的代码中,如果抛出InvalidCastException,则没有任何意义,甚至会造成误解,所以更好的方式是抛出一个ArgumentException:

private void CaseSample(object o)
{
if(o==null)
{
throw new ArgumentNullException("o");
}
User user = null;
try
{
user = (User)o;
}
catch(InvalidCastException)
{
throw new ArgumentException("输入参数不是一个User", "o");
}
//do something
}

需要重点介绍的正确引发异常的典型例子就是捕获底层API错误代码,并抛出。查看Console这个类,还会发现很多地方有类似的代码:

int errorCode = Marshal.GetLastWin32Error();
if(errorCode==6)
{
throw new InvalidOperationException(Environment.GetResourceString("InvalidOperation_ConsoleKeyAvailableOnFile"));
}

Console为我们封装了调用Windows API返回的错误代码,而让代码引发了一个新的异常。

很显然,当需要调用Windows API或第三方API提供的接口时,如果对方的异常报告机制使用的是错误代码,最好重新引发该接口提供的错误,因为你需要让自己的团队更好地理解这些错误。

重新引发异常时使用Inner Exception

当捕获了某个异常,将其包装或重新引发异常的时候,如果其中包含了Inner Exception,则有助于程序员分析内部信息,方便代码调试。

以一个分布式系统为例,在进行远程通信的时候,可能会发生的情况有:

  1. 网卡被禁用或网线断开,此时会抛出SocketException,消息为:"由于目标计算机积极拒绝,无法连接。"
  2. 网络正常,但是要连接的目标机没有端口没有处在侦听状态,此时,会抛出SocketException,消息为:"由于连接方在一段时间后没有正确答复或连接的主机没有反应,连接尝试失败。"
  3. 连接超时,此时需要通过代码实现关闭连接,并抛出一个SocketException,消息为:"连接超过约定的时长。"

发生以上三种情况中的任何一种情况,在返回给最终用户的时候,我们都需要将异常信息包装成为"网络连接失败,请稍候再试"。

所以,一个分布式系统的业务处理方法,看起来应该是这样的:

try
{
SaveUser5(user);
}
catch(SocketException err)
{
throw new CommucationFailureException("网络连接失败,请稍后再试", err);
}

但是,在提示这条消息的时候,我们可能需要将原始异常信息记录到日志里,以供开发者分析具体的原因(因为如果这种情况频繁出现,这有可能是一个Bug)。那么,在记录日志的时候,就非常有必要记录导致此异常出现的内部异常或是堆栈信息。

上文代码中的:就是将异常重新包装成为一个CommucationFailureException,并将SocketException作为Inner Exception(即err)向上传递。

此外还有一个可以采用的技巧,如果不打算使用Inner Exception,但是仍然想要返回一些额外信息的话,可以使用Exception的Data属性。如下所示:

try
{
SaveUser5(user);
}
catch(SocketException err)
{
err.Data.Add("SocketInfo", "网络连接失败,请稍后再试");
throw err;
}

在上层进行捕获的时候,可以通过键值来得到异常信息:

catch(SocketException err)
{
Console.WriteLine(err.Data["SocketInfo"].ToString());
}

避免在finally内撰写无效代码

你应该始终认为finally内的代码会在方法return之前执行,哪怕return是在try块中。

C#编译器会清理那些它认为完全没有意义的C#代码。

private static int TestIntReturnInTry()
{
int i;
try
{
return i = 1;
}
finally
{
i = 2;
Console.WriteLine("\t将int结果改为2,finally执行完毕");
}
}

避免嵌套异常

应该允许异常在调用堆栈中往上传播,不要过多使用catch,然后再throw。过多使用catch会带来两个问题:

  1. 代码更多了。这看上去好像你根本不知道该怎么处理异常,所以你总在不停地catch。
  2. 隐藏了堆栈信息,使你不知道真正发生异常的地方。

嵌套异常会导致调用堆栈被重置了。最糟糕的情况是:如果方法捕获的是Exception。所以也就是说,如果这个方法中还存在另外的异常,在UI层将永远不知道真正发生错误的地方。

除了第3点提到的需要包装异常的情况外,无故地嵌套异常是我们要极力避免的。当然,如果真的需要捕获这个异常来恢复一些状态,然后重新抛出,代码看起来应该是这样的:

try
{
MethodTry();
}
catch(Exception)
{
//工作代码
throw;
}

或者:

try
{
MethodTry();
}
catch
{
//工作代码
throw;
}

尽量避免像下面这样引发异常:

catch(Exception err)
{
//工作代码
throw err;
}

直接throw err而不是throw将会重置堆栈信息。

避免"吃掉"异常

嵌套异常是很危险的行为,一不小心就会将异常堆栈信息,也就是真正的Bug出处隐藏起来。但这还不是最严重的行为,最严重的就是"吃掉"异常,即捕获,然后不向上层throw抛出。如果你不知道如何处理某个异常,那么千万不要"吃掉"异常,如果你一不小心"吃掉"了一个本该往上传递的异常,那么,这里可能诞生一个Bug,而且,解决它会很费周折。

避免"吃掉"异常,并不是说不应该"吃掉"异常,而是这里面有个重要原则:该异常可被预见,并且通常情况它不能算是一个Bug。比如有些场景存在你可以预见但不重要的Exception,这个就不算一个bug。

为循环增加Tester-Doer模式而不是将try-catch置于循环内

如果需要在循环中引发异常,你需要特别注意,因为抛出异常是一个相当影响性能的过程。应该尽量在循环当中对异常发生的一些条件进行判断,然后根据条件进行处理。

总是处理未捕获的异常

处理未捕获的异常是每个应用程序应具备的基本功能,C#在AppDomain提供了UnhandledException事件来接收未捕获到的异常的通知。常见的应用如下:

static void Main(string[] args)
{
AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);
}

static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
Exception error = (Exception)e.ExceptionObject;
Console.WriteLine("MyHandler caught:" + error.Message);
}

未捕获的异常通常就是运行时期的Bug,我们可以在AppDomain.CurrentDomain.UnhandledException的注册事件方法CurrentDomain_UnhandledException中,将未捕获异常的信息记录在日志中。值得注意的是,UnhandledException提供的机制并不能阻止应用程序终止,也就是说,执行CurrentDomain_UnhandledException方法后,应用程序就会被终止。

正确捕获多线程中的异常

多线程的异常处理需要采用特殊的方法。以下的处理方式会存在问题:

try
{
Thread t = new Thread((ThreadStart)delegate
{
throw new Exception("多线程异常");
});
t.Start();
}
catch(Exception error)
{
MessageBox.Show(error.Message + Environment.NewLine + error.StackTrace);
}

应用程序并不会在这里捕获线程t中的异常,而是会直接退出。从.NET 2.0开始,任何线程上未处理的异常,都会导致应用程序的退出(先会触发AppDomain的UnhandledException)。上面代码中的try-catch实际上捕获的还是当前线程的异常,而t属于新起的异常,所以,正确的做法应该是把 try-catch放在线程里面:

Thread t = new Thread((ThreadStart)delegate
{
try
{
throw new Exception("多线程异常");
}
catch(Exception error) { .... });
t.Start();

慎用自定义异常

除非有充分的理由,否则一般不要创建自定义异常。如果要对某类程序出错信息做特殊处理,那就自定义异常。需要自定义异常的理由如下:

  1. 方便调试。通过抛出一个自定义的异常类型实例,我们可以使捕获代码精确地知道所发生的事情,并以合适的方式进行恢复。
  2. 逻辑包装。自定义异常可包装多个其他异常,然后抛出一个业务异常。
  3. 方便调用者编码。在编写自己的类库或者业务层代码的时候,自定义异常可以让调用方更方便处理业务异常逻辑。例如,保存数据失败可以分成两个异常"数据库连接失败"和"网络异常"。
  4. 引入新异常类。这使程序员能够根据异常类在代码中采取不同的操作。

从System.Exception或其他常见的基本异常中派生异常

这个不说了,自定义异常一般是从System.Exception派生的。事实上,现在如果你在Visual Studio中输入Exception,然后使用快捷键Tab,VS会自动创建一个自定义异常类。

应使用finally避免资源泄漏

前面已经提到过,除非发生让应用程序中断的异常,否则finally总是会先于return执行。finally的这个语言特性决定了资源释放的最佳位置就是在finally块中;另外,资源释放会随着调用堆栈由下往上执行(即由内到外释放)。

避免在调用栈较低的位置记录异常

即避免在内部深处处理记录异常。最适合记录异常和报告的是应用程序的最上层,这通常是UI层。

并不是所有的异常都要被记录到日志,一类情况是异常发生的场景需要被记录,还有一类就是未被捕获的异常。未被捕获的异常通常被视为一个Bug,所以,对于它的记录,应该被视为系统的一个重要组成部分。

如果异常在调用栈较低的位置被记录或报告,并且又被包装后抛出;然后在调用栈较高位置也捕获记录异常。这就会让记录重复出现。在调用栈较低的情况下,往往异常被捕获了也不能被完整的处理。所以,综合考虑,应用程序在设计初期,就应该为开发成员约定在何处记录和报告异常。

安全与性能

使用默认转型方法

类型的转换运算符:每个类型内部都有一个方法(运算符),分为隐式转换和显示转换。

自己实现隐式转换:

使用类型内置的Parse、TryParse、 ToString、ToDouble、 ToDateTime

使用帮助类提供的方法: System.Convert类、 System.BitConverter类来进行类型的转换。

使用CLR支持的类型:父类和子类之间的转换。

区别对待强制转型与as和is

为了编译更强壮的代码,建议更常使用as和is。

什么时候使用as: 如果类型之间都上溯到了某个共同的基类,那么根据此基类进行的转型(即基类转型为子类本身)应该使用as。子类与子类之间的转型,则应该提供转换操作符,以便进行强制转型。

as操作符永远不会抛出异常,如果类型不匹配(被转换对象的运行时类型既不是所转换的目标类型,也不是其派生类型),或者转型的源对象为null,那么转型之后的值也为null。

什么时候使用is: as操作符有一个问题,即它不能操作基元类型。如果涉及基元类型的算法,就需要通过is转型前的类型来进行判断,以避免转型失败。

TryParse比Parse好

这个肯定好,不说了。安全

使用int?来确保值类型也可以为null

基元类型为什么需要为null?考虑两个场景:

  1. 数据库支持整数可为空
  2. 数据在传输过程中存在丢失问题,导致传过来的值为null

写法:

int? i = null;

语法T?是Nullable<T>的简写,两者可以相互转换。可以为null的类型表示其基础值类型正常范围内的值再加上一个null值。例如,Nullable<Int32>,其值的范围为-2,147,483,648~2,147,483,647,再加上一个null值。

?经常和??配合使用,比如:

int? i = 123;
int j = i ?? 0;

隐私保护和风险控制

  1. 隶属于用户个人的页面或者功能必须进行权限控制校验。防止没有做水平权限校验就可随意访问、修改、删除别人的数据,比如查看他人的私信内容、修改他人的订单。

  2. 用户敏感数据禁止直接展示,必须对展示数据进行脱敏。中国大陆个人手机号码显示为:156****1234,隐藏中间4位,防止隐私泄露。

  3. 用户输入的SQL参数严格使用参数绑定或者METADATA字段值限定,防止SQL注入,禁止字符串拼接SQL访问数据库。

  4. 用户请求传入的任何参数必须做有效性验证。忽略参数校验可能导致:

    • page size过大导致内存溢出
    • 恶意order by导致数据库慢查询
    • 任意重定向
    • SQL注入
    • 反序列化注入
    • 正则输入源串拒绝服务ReDoS

    说明:代码用正则来验证客户端的输入,有些正则写法验证普通用户输入没有问题,但是如果攻击人员使用的是特殊构造的字符串来验证,有可能导致死循环的结果。

  5. 禁止向HTML页面输出未经安全过滤或未正确转义的用户数据。

  6. 表单、AJAX提交必须执行CSRF安全验证。

    说明:CSRF(Cross-site request forgery)跨站请求伪造是一类常见编程漏洞。对于存在CSRF漏洞的应用/网站,攻击者可以事先构造好URL,只要受害者用户一访问,后台便在用户不知情的情况下对数据库中用户参数进行相应修改。

  7. 在使用平台资源,譬如短信、邮件、电话、下单、支付,必须实现正确的防重放的机制,如数量限制、疲劳度控制、验证码校验,避免被滥刷而导致资损。如注册时发送验证码到手机,如果没有限制次数和频率,那么可以利用此功能骚扰到其它用户,造成短信资源浪费。

  8. 发贴、评论、发送即时消息等用户生成内容的场景必须实现防刷、文本内容违禁词过滤等风控策略。

依赖注入

对于实例化调用链较长的类,推荐使用依赖注入容器进行对象的实例化操作。

NullReferenceException

对于任何的Get操作,都应该做判空处理:

var user = users.FirstOrDefault(...);
if(user != null)
{
string name = user.Name;
}

或使用C#6的null值表达式:

var user = users.FirstOrDefault(...);
string name = user?.Name;

利用dynamic来简化反射实现

dynamic是Framework 4.0的新特性。dynamic的出现让C#具有了弱语言类型的特性。编译器在编译的时候不再对类型进行检查,编译器默认dynamic对象支持开发者想要的任何特性。

比如,即使你对GetDynamicObject方法返回的对象一无所知,也可以像如下这样进行代码的调用,编译器不会报错:

dynamic dynamicObject = GetDynamicObject();
Console.WriteLine(dynamicObject.Name);
Console.WriteLine(dynamicObject.SampleMethod());

当然,如果运行时dynamicObject不包含指定的这些特性(如上文中带返回值的方法SampleMethod),运行时程序会抛出一个RuntimeBinderException异常:"System.Dynamic.ExpandoObject"未包含"SampleMethod"的定义。

var与dynamic有巨大的区别

var是编译器的语法糖 dynamic是运行时解析,在编译期时,编译器不对其做任何检查。

反射使用: 不使用dynamic方式:

DynamicSample dynamicSample = new DynamicSample();
var addMethod = typeof(DynamicSample).GetMethod("Add");
int re = (int)addMethod.Invoke(dynamicSample, new object[] {1,2});

使用dynamic方式:

dynamic dynamicSample2 = new DynamicSample();
int re2 = dynamicSample2.Add(1,2);

在使用dynamic后,代码看上去更简洁了,并且在可控的范围内减少了一次拆箱的机会。经验证,频繁使用的时候,消耗时间更少。

建议:始终使用dynamic来简化反射实现。

资源释放

一定要使用try-finally块来清理资源,try-catch块来处理错误恢复。一定不要使用catch块来清理资源。一般来说,清理的逻辑是回滚资源(特别是原生资源)分配。举例:

FileStream stream = null;
try
{
stream = new FileStream(...);
...
}
finally
{
if (stream != null)
{
stream.Close();
}
}

为了清理实现了IDisposable接口的对象,C#提供了using语句来替代try-finally块。

using (FileStream stream = new FileStream(...))
{
...
}

using FileStream stream = new FileStream(...);

许多语言特性都会自动的为您写入try-finally块。例如C#的using语句,lock语句。

Dispose模式

该模式的基础实现包括实现System.IDisposable接口,声明实现了所有资源清理逻辑的Dispose(bool)方法,该方法被Dispose方法和可选的终结器所共享。请注意,本章节并不讨论如何编写一个终结器。可终结类型是该简单模式的拓展,我们会在下个章节中讨论。如下展示了基础模式的简单实现:

using System.Runtime.InteropServices;

public class DisposableResourceHolder : IDisposable
{
private bool disposed = false;
private SafeHandle resource; // 处理一个资源

public DisposableResourceHolder()
{
this.resource = ... // 分配非托管资源
}

public void DoSomething()
{
if (disposed)
{
throw new ObjectDisposedException(...);
}
// 使用资源调用一些本机方法
...
}

public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

protected virtual void Dispose(bool disposing)
{
// 防止被多次调用。
if (disposed)
{
return;
}

if (disposing)
{
// 清理所有托管资源。
if (resource != null)
{
resource.Dispose();
}
}

disposed = true;
}
}

一定要为包含了可释放类型的类型实现基础Dispose模式。

一定要给拓展基础Dispose模式来提供一个终结器。比如,为存储非托管内存的缓冲实现该模式。

我们应该为即使类本身不包含非托管代码或可清理对象,但是其子类可能带有的类实现该基础Dispose模式。一个绝佳的例子就是System.IO.Stream类。虽然它是一个不带任何资源的抽象类,大多数子类却带有资源,所以应该为其实现该模式。

一定要声明一个protected virtual void Dispose(bool disposing)方法来集中所有释放非托管资源的逻辑。所有资源清理都应该在该方法中完成。用终结器和IDisposable.Dispose方法来调用该方法。如果从终结器内调用,则其参数为false。它应该用于确保任何在终结中运行的代码不应该被其他可终结对象访问到。

一定要通过简单的方式调用Dispose(true),以及GC.SuppressFinalize(this)来实现IDisposable接口。仅当Dispose(true)成功执行完后才能调用SuppressFinalize。

一定不要将无参Dispose方法定义为虚函数。Dispose(bool)方法应该被子类重写。

不应该从Dispose(bool)内抛出异常,除非包含它的进程被破坏(内存泄露,不一致的共享状态,等等)等极端条件。用户不希望调用Dispose会引发异常。比如,考虑在C#代码内手动写入try-finally块:

TextReader tr = new StreamReader(File.OpenRead("foo.txt"));
try
{
// Do some stuff
}
finally
{
tr.Dispose(); // More stuff
}

如果Dispose可能引发异常,那么finally块的清理逻辑不会被执行。为了解决这一点,用户需要将所有对于Dispose(在它们的finally块内!)的调用放入try块内,这将导致一个非常复杂的清理处理程序。如果执行Dispose(bool disposing)方法,即使终结失败也不会抛出异常。如果在一个终结器环境内这样做会终止当前流程。

一定要在对象被终结之后,为任何不能再被使用的成员抛出一个ObjectDisposedException异常。

可终结类型

可终结类型是通过重写终结器并在Dispose(bool)中提供终结代码路径来拓展基础Dispose模式的类型。如下代码是一个可终结类型的示例:

using System.Runtime.InteropServices;

public class ComplexResourceHolder : IDisposable
{
bool disposed = false;
private IntPtr buffer; // 非托管内存缓冲区
private SafeHandle resource; // 处理非托管资源

public ComplexResourceHolder()
{
this.buffer = ... // 分配内存
this.resource = ... // 分配资源
}

public void DoSomething()
{
if (disposed)
{
throw new ObjectDisposedException(...);
}
// 使用资源调用一些本机方法
...
}

~ComplexResourceHolder()
{
Dispose(false);
}

public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

protected virtual void Dispose(bool disposing)
{
// 防止被多次调用。
if (disposed)
{
return;
}

if (disposing)
{
// 清理所有托管资源
if (resource != null)
{
resource.Dispose();
}
}

// 清理所有本机资源
ReleaseBuffer(buffer);
disposed = true;
}
}

一定要在类型应该为释放非托管资源负责,且自身没有终结器的情况下,将该类型定义为可终结的。当实现终结器时,简单的调用Dispose(false),并将所有资源清理逻辑放入Dispose(bool disposing)方法。

public class ComplexResourceHolder : IDisposable
{
~ComplexResourceHolder()
{
Dispose(false);
}

protected virtual void Dispose(bool disposing)
{
...
}
}

一定要谨慎的定义可终结类型。仔细考虑任何一个您需要终结器的情况。带有终结器的实例从性能和复杂性角度来说,都需付出不小的代价。

一定请要为每一个可终结类型实现基础Dispose模式。该模式的细节请参考先前章节。这给予该类型的使用者以一种显式的方式去清理其拥有的资源。

我们应该在终结器即使面临强制的应用程序域卸载或线程中止的情况也必须被执行时,创建并使用临界可终结对象(一个带有包含了CriticalFinalizerObject的类型层次的类型)。

尽量使用基于SafeHandle或SafeHandleZeroOrMinusOneIsInvalid(对于Win32资源句柄,其值如果为0或者-1,则代表其为无效句柄)的资源封装器,而不是自己来编写终结器。这样,我们便无需终结器,封装器会为其资源清理负责。安全句柄实现了IDisposable接口,并继承自CriticalFinalizerObject,所以即使面临强制的应用程序域卸载或线程中止,终结器的逻辑也会被执行。

/// <summary>
/// 表示管道句柄的包装器类。
/// </summary>
using Microsoft.Win32.SafeHandles;
using System.Runtime.ConstrainedExecution;
using System.Runtime.InteropServices;
using System.Security;
using System.Security.Permissions;

[SecurityCritical(SecurityCriticalScope.Everything), HostProtection(SecurityAction.LinkDemand, MayLeakOnAbort = true), SecurityPermission(SecurityAction.LinkDemand, UnmanagedCode = true)]
internal sealed class SafePipeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
private SafePipeHandle() : base(true)
{
}

public SafePipeHandle(IntPtr preexistingHandle, bool ownsHandle) : base(ownsHandle)
{
base.SetHandle(preexistingHandle);
}

[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success), DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr handle);

protected override bool ReleaseHandle()
{
return CloseHandle(base.handle);
}
}

/// <summary>
/// 表示本地内存指针的包装器类。
/// </summary>
[SuppressUnmanagedCodeSecurity, HostProtection(SecurityAction.LinkDemand, MayLeakOnAbort = true)]
internal sealed class SafeLocalMemHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public SafeLocalMemHandle() : base(true)
{
}

public SafeLocalMemHandle(IntPtr preexistingHandle, bool ownsHandle) : base(ownsHandle)
{
base.SetHandle(preexistingHandle);
}

[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success), DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr LocalFree(IntPtr hMem);

protected override bool ReleaseHandle()
{
return (LocalFree(base.handle) == IntPtr.Zero);
}
}

一定不要在终结器代码路径内访问任何可终结对象,因为这样会有一个极大的风险:它们已经被终结了。例如,一个可终结对象A,其拥有一个指向另一个可终结对象B的对象,在A的终结器内并不能很安心的使用B,反之亦然。终结器是被随机调用的。但是操作拆箱的值类型字段是可以接受的。

同时也请注意,在应用程序域卸载或进程退出的某些时间点上,存储于静态变量的对象会被回收。如果Environment.HasShutdownStarted返回True,则访问引用了一个可终结对象的静态变量(或调用使用了存储于静态变量的值的静态函数)可能会不安全。

一定不要从终结器的逻辑内抛出异常,除非是系统严重故障。如果从终结器内抛出异常,CLR可能会停止整个进程来阻止其他终结器被执行,并阻止资源以一个受控制的方式释放。

重写Dispose

如果您继承了实现IDisposable接口的基类,您必须也实现IDisposable接口。记得要调用您基类的Dispose(bool)。

public class DisposableBase : IDisposable
{
~DisposableBase()
{
Dispose(false);
}

public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

protected virtual void Dispose(bool disposing)
{
// ...
}
}

public class DisposableSubclass : DisposableBase
{
protected override void Dispose(bool disposing)
{
try
{
if (disposing)
{
// 清理托管资源
}
// 清理本机资源
}
finally
{
base.Dispose(disposing);
}
}
}

在Dispose模式中应区别对待托管资源和非托管资源

Dispose模式设计的思路基于:如果调用者显式调用了Dispose方法,那么类型就该按部就班地将自己的资源全部释放。如果调用者忘记调用了Dispose方法了,那么类型就假定自己的所有托管资源会全部交给垃圾回收器回收,所以不进行手工清理。理解了这一点,我们就理解了为什么在Dispose方法中,虚方法传入的参数是true,而在终结器中,虚方法传入的参数是false。

具有可释放字段的类型或拥有本机资源的类型应该是可释放的

我们将C#中的类型分为:普通类型和继承了IDisposable接口的非普通类型。非普通类型除了那些包含托管资源的类型外,还包括类型本身也包含一个非普通类型的字段的类型。

在标准的Dispose模式中,我们对非普通类型举了一个例子:一个非普通类型AnotherResource。由于AnotherResource是一个非普通类型,所以如果现在有这么一个类型,它组合了AnotherResource,那么它就应该继承IDisposable接口,代码如下所示:

class AnotherSampleClass : IDisposable
{
private AnotherResource managedResource = new AnotherResource();
private bool disposed = false;

public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}

类型AnotherSampleClass虽然没有包含任何显式的非托管资源,但是由于它本身包含了一个非普通类型,所以我们仍旧必须为它实现一个标准的Dispose模式。

除此以外,类型拥有本机资源(即非托管类型资源),它也应该继承IDisposable接口。

及时释放资源

很多人会注意到:垃圾回收机制自动为我们隐式地回收了资源(垃圾回收器会自动调用终结器),于是不禁会问:为什么还要主动释放资源呢?我们来看以下这个例子:

private void buttonOpen_Click(object sender, EventArgs e)
{
FileStream fileStream = new FileStream(@"c:\test.txt", FileMode.Open);
}

private void buttonGC_Click(object sender, EventArgs e)
{
System.GC.Collect();
}

如果连续两次单击打开文件按钮,系统就会报错,如下所示: IOException:文件"c:\test.txt"正由另一进程使用,因此该进程无法访问此文件。

现在来分析:在打开文件的方法中,方法执行完毕后,由于局部变量fileStream在程序中已经没有任何地方引用了,所以它会在下一次垃圾回收时被运行时标记为垃圾。那么,什么时候会进行下一次垃圾回收呢,或者说垃圾回收器什么时候才开始真正进行回收工作呢?微软官方的解释是,当满足以下条件之一时将发生垃圾回收:

  1. 系统具有低的物理内存。
  2. 由托管堆上已分配的对象使用的内存超出了可接受的范围。
  3. 调用GC.Collect方法。几乎在所有情况下,我们都不必调用此方法,因为垃圾回收器会负责调用它。

但在本实例中,为了体会一下不及时回收资源的危害,所以进行了一次GC.Collect方法的调用,大家可以仔细体会运行这个方法所带来的不同。

垃圾回收机制中还有一个"代"的概念。一共分为3代:0代、1代、2代。第0代包含一些短期生存的对象,如示例代码中的局部变量fileStream就是一个短期生存对象。当buttonOpen_Click退出时,fileStream就被丢到了第0代,但此刻并不进行垃圾回收,当第0代满了的时候,运行时会认为现在低内存的条件已满足,那时才会进行垃圾回收。所以,我们永远不知道fileStream这个对象(或者说资源)什么时候才会被回收。在回收之前,它实际已经没有用处,却始终占据着内存(或者说资源)不放,这对应用系统来说是一种极大的浪费,并且,这种浪费还会干扰程序的正常运行(如在本实例中,由于它始终占着文件资源,导致我们不能再次使用这个文件资源了)。

不及时释放资源还带来另外一个问题。在上面中我们已经了解到,如果类型本身继承了IDisposable接口,垃圾回收机制虽然会自动帮我们释放资源,但是这个过程却延长了,因为它不是在一次回收中完成所有的清理工作。本实例中的代码因为fileStream继承了IDisposable接口,故第一次进行垃圾回收的时候,垃圾回收器会调用fileStream的终结器,然后等待下一次的垃圾回收,这时fileStream对象才有可能被真正的回收掉。

了解了不及时释放资源的危害后,现在来改进这个程序,如下所示:

private void buttonOpen_Click(object sender, EventArgs e)
{
FileStream fileStream = new FileStream(@"c:\test.txt", FileMode.Open);
fileStream.Dispose();
}

这确实是一种改进,但是我们没考虑到方法中的第一行代码可能会抛出异常。如果它抛出异常,那么fileStream.Dispose()将永远不会执行。于是,再一次改进,如下所示:

try
{
FileStream fileStream = null;
fileStream = new FileStream(@"c:\test.txt", FileMode.Open);
fileStream.Dispose();
}

为了更进一步简化语句,还可以使用语法糖"using"关键字。

其它约束

  1. 一个方法只完成一个任务。单一职责原则,不要把多个任务组合到一个方法中,即使那些任务非常小。
  2. 使用C#的特有类型,而不是System命名空间中定义的别名类型。如字符串推荐使用string而非String类型。
  3. 别在程序中使用固定数值,用常量代替。
  4. 避免使用很多成员变量。声明局部变量,并传递给方法。不要在方法间共享成员变量。如果在几个方法间共享一个成员变量,那就很难知道是哪个方法在什么时候修改了它的值。
  5. 不在代码中使用具体的路径和驱动器名。使用相对路径,并使路径可编程。
  6. 应用程序启动时作些"自检"并确保所需文件和附件在指定的位置。必要时检查数据库连接。出现任何问题给用户一个友好的提示。
  7. 如果需要的配置文件找不到,应用程序需能自己创建使用默认值的一份。
  8. 如果在配置文件中发现错误值,应用程序要抛出错误,给出提示消息告诉用户正确值。
  9. 在一个类中,字段定义全部统一放在class的头部、所有方法或属性的前面。
  10. 在一个类中,所有的属性全部定义在一个属性块中。