C#作为一种面向对象开发语言,它为我们提供了强大的抽象能力。其中 class、struct 为我们提供了对现实世界的抽象;interface 提供了对 class、struct 的抽象;delegate 提供了对方法的抽象;attribute 提供了对类型元数据的抽象;generics 给上述因素进一步、进两步、直至进 n 步的抽象机会。
结构与类为我们提供了对现实世界抽象的能力,是我们最常使用的类型。它们实际上都是创建对象的模板,在很多地方它们都存在着共性,本文中对“类”的认识往往也适用于“结构”。所以我只说明了类与结构的选择问题,因为类比结构更常用,所以本文剩余部分都是以类来说明的,但其观点同样适用于结构。
在类和结构之间的选择时我们要知道一些知识:
-
- 值类型是分配在栈上,引用类型是分配在堆上,值类型别的分配和释放开销低。
- 引用类型数组的数组元素只是一些引用,指向那些位于堆中的引用类型的实例;而值类型数组的元素,就是值类型的真正实例。
- 值类型在执行转型操作时(转换成引用类型或它们实现的接口)发生装箱,而引用类型不会。
- 值类型复制整个值,而引用类型只复制引用。大量值类型复制的开销要比引用类型高。
- 值类型是不可变的,在传递值类型时,实例隐式地被创建并复制原值,因此改变一个值不会改变其它副本;而引用类型传递的是引用,改变引用类型的一个实例会影响所有指向该实例的引用。
何时定义结构:
-
- 如果该类型的实例比较小而且生命期比较短,或者经常被内嵌在其它对象中。
- 它逻辑上代表一个独立的值,与基本类型(int、double等)相似。
- 它的实例的大小小于16字节(32位系统)。
- 它是不可变的。
- 它不需要经常被装箱。
在其它情况下,应该将类型定义为类。
如果你认为创建的唯一理由是对现实世界中的物体建模,那么你的想法就是片面的。创建类的理由远远不止一个。
- 为现实世界中的对象建模 这是类最基本的用途。
- 为抽象的对象建模 对现实世界中并不存在的,比如“形状”建模。
- 信息隐藏与降低复杂度 创建一个类把实现细节隐藏起来,并在无需了解内部工作原理的情况下使用这个类。如果你的类设计的仍然需要了解内部实现才能使用,那么你的类设计的就是失败的。我们就像需要无脑地调用类公开的方法。
- 隔离变化源并建立中心控制节点 用一个类来读写数据就是中心控制的一种形式,通过将算法用类隔离起来,可以很容易控制变动的影响范围,方便我们在修正错误时不会影响其它代码。
- 隐藏全局数据 如果你用到全局数据,就可以把它的实现细节隐藏到某个类里,这样带来的好处是你可以改变数据结构而不要改变程序本身,你还何以监视对这些数据的访问。
- 把相关方法包装在一起让参数传递更顺畅 这里并不是说要把在多个子程序中传递的参数定义成一类,而是说如果某个参数在多个子程序中传递,就应该考虑把这些子程序重构到一个类里面,把这个参数当成对象数据来共享。
- 让代码易于重用 将代码放到精心分解的一组类中,比起把代码无组织的分布在程序里,前者更易于重用。
- 实现某种特定的重构 重构的很多特定方法都会产生新类。
应该避免的类:
- 避免创建万能类 类应该是高内聚的,不要去试图创建万能的类,那只不过是“圣杯”。
- 消除无关紧要的类 如果一个类只包含数据而没有行为,那么可以考虑把这个类的数据当成是其它类的数据成员来处理(适配器模式除外)。同理,一个只有行为而没有数据的类,往往也不是一个类。
一个类型可以有两种特性:积累的和非积累的。积累特性是指一个类型中的每个部分都可以添加的东西,例如接口派生、属性、索引器、方法和成员变量。非积累特性是指类型中的每个部分必须一致的东西,如类型是一个类或一个结果,类型的可访问性,以及基类。在一个类型中,可以包容如下成员:
- 常量 数据值恒定不变的一个符号。
- 字段 表示一个只读/可写的数据值。
- 构造器 将对象的实例字段或类型的静态字段初始化为良好初始状态的一种特殊方法。
- 终结器 类似于构造器,在 CLR 检测到不再需要某个对象时调用。
- 索引器 允许对象以数组或集合的方式进行索引。
- 方法 一种特殊的函数,用于更改或查询一个类型(静态方法)或对象(实例方法)的状态。
- 操作符重载 是一个方法,它定义了将一个特定的操作符作用于对象时,如何操作这个对象,不是 CLS 的一部分。
- 转换操作符 定义如何隐式或显示第将对象从一种类型转换为另一种类型,不是 CLS 的一部分。
- 属性 属性提供了一种简单的、字段风格的语法来设置或查询类型(静态属性)或对象(实例属性)的部分逻辑状态,同时保证状态不遭到破坏。
- 事件 利用事件,一个类型可以向一个或多个静态或实例方法发送通知。而利用实例(非静态)事件,一个对象可以向一个或多个静态或实例方法发送通知。提供事件的类型或对象的状态发送改变,通常会引发事件。事件包含两个方法,允许静态或实例方法登记或注销对该事件的关注。除了这两个方法,事件通常还使用一个委托字段来维护已登记的方法集。
- 类型 内嵌其它类型。
C# 支持的类型修饰符如下:
类型修饰符 | 修饰类 | 修饰结构 | 修饰成员 |
public | 公有,没有访问限制 | 公有,没有访问限制 | 公有,没有访问限制 |
private | 私有,只能由定义它们的类型进行访问 | 不支持 | 私有,只能由定义它们的类型进行访问 |
protected | 受保护,可以被定义它们的类型或者派生类来访问 | 不支持 | 受保护,可以被定义它们的类型或者派生类来访问 |
internal | 在当前程序集中访问 | 在当前程序集中访问 | 在当前程序集中访问 |
abstract | 定义抽象类 | 不支持 | 定义抽象方法 |
partial | 拆分类 | 拆分结构 | 拆分方法 |
sealed | 定义密封类 | 不支持 | 方法和属性 |
static | 定义静态类 | 不支持 | 成员不在类的具体实例上执行 |
new | 不支持 | 不支持 | 隐藏继承的成员 |
virtual | 不支持 | 不支持 | 成员可以由派生类重写 |
override | 不支持 | 不支持 | 重写继承的虚拟或抽象成员 |
readonly | 不支持 | 不支持 | 定义为只读 |
const | 不支持 | 不支持 | 定义为常量 |
extern | 不支持 | 不支持 | 仅静态[DllImport]方法 |
volatile | 不支持 | 不支持 | 修饰类或结构的字段,表示该字段可以由多个同时执行的线程修改 |
下面分组介绍各种修饰符的用法。
(一) 访问修饰符
C# 访问修饰符如下:
CLR 术语 | C# 访问修饰符 | 应用场景 | 描述 |
Public | public | 类型、类型成员、结构 | 没有限制 |
Private | private | 类型成员、嵌套类型 | 只能由定义它们的类型进行访问 |
Family | protected | 类型成员、嵌套类型 | 对象变量不能直接访问,可以被定义它们的类型或者派生类来访问 |
Assembly | internal | 类型、类型成员、结构 | 在当前程序集中访问 |
Family or Assembly | protected internal | 类型成员、嵌套类型 | 在定义它们的程序集、类型以及派生类型中可用 |
1 类型的可见性
默认情况下,类型是 internal 的。C# 支持有元程序集。可以使用 System.Runtime.CompilerServices 命名空间中定义的一个名为 InternalsVisibleTo 的特性来标明它认为是“友元”的其它程序集。当确认了自己的友元程序集之后,那些友元程序集就能访问该程序集中的所有 internal 类型,以及类型的 internal 成员。
声明一个友元程序集可能轻易被滥用,妨碍程序集内部的本质封装,增加耦合度。
注意:“友元程序集”功能只适合发布时间相同的程序集,最好是打包到一起发布。这是因为友元程序集的相互依赖程度高,所以间隔时间过长易引起兼容性问题。
2 成员的可访问性
默认情况下,成员是 private 的。一个派生类型重写在它的基类型中定义的一个成员时,C# 编译器要求原始成员与重写成员具有相同的可访问性。但是,CLR 并没有强制要求。CLR 允许放宽成员的可访问性,但不允许紧收。这样做是因为,CLR 承诺派生类总是可以转型为基类,并获取对基类方法的访问权。
(二)abstract 修饰符
abstract 修饰符可以和类、方法、属性、索引器和事件一起使用。
抽象类和抽象成员具有以下特征:
- 抽象类不能被实例化。
- 抽象类可以包含抽象成员,反之,如果类包含抽象成员,则该类也必须是抽象的。
- 抽象类和成员不能是密封或静态的。
- 抽象类的派生类必须重写所有抽象成员。
- 抽象成员是隐式虚拟的,所有不需要 virtual 修饰。
- 抽象类必须是 public 或 internal 的,抽象成员不能是私有的。
(三)partial 修饰符
partial 是一个纯设计时结构,只有方法、类、结构或接口才能为分部形式。partial 表明我们可以在同一项目中使用相同的类签名在其它位置对该类进行扩展。分部方法保留了方法的签名,并让我们自己决定是否实现,即我们可以根据需要决定是否向该类添加行为。所有类都可以通过分部类机制进行扩展。
分部形式具有以下特点:
- 类型的各个部分必须在相同命名空间下,且都必须声明为分部形式。
- 在分部形式的各个部分应用不同特性、接口和修饰符,最终编译时,这些都会被累加,所以必须注意一致性。
- 分部方法为隐式 private 不能具有访问修饰符或 virtual、abstract、override、new、sealed 或 extern 修饰符。
- 分部方法只能返回 void。
- 分部方法不能有 out 参数。
- 如果未提供分部方法的实现,则会在编译时移除方法以及对方法的所有调用。
分部形式适用于以下情况:
- 代码量特别大,单独文件不易于维护。
- 配合代码生成器使用。
- 更为灵活的扩展。
(四)sealed 修饰符
密封类和密封的方法或属性不能被扩展,主要为了防止他人进行扩展。
密封具有以下特点:
- 密封类不能被继承。
- 对于属性和方法,继承该类的任何类都不能重写该成员,且必须与 override 一起使用。
(五)static 修饰符
static 用于定义静态类和静态成员,可用于类、字段、方法、属性、运算符、事件和构造函数,但不能用于索引器、析构函数或类以外的类型。静态类和静态成员用于创建无需创建类的实例就能够访问的数据和方法。静态类用于定义独立于特定对象的方法。静态成员可用于描述独立于对象实例或者说所有对象的交集部分——这些数据和方法,不随新对象的创建而改变。
静态类与静态成员的特点:
- 静态类仅能包含静态成员。
- 静态类是隐式抽象的不能被实例化。
- 静态类是隐式密封的。
- 静态类不能包含实例构造函数。
- 即使没有创建类实例,也可以访问类中的静态成员。
- 不能使用实例来访问静态成员。
- 静态类和静态成员只存在一个副本,静态方法和属性只能访问静态字段和静态事件。
(六)new 修饰符、virtual 修饰符、override 修饰符
virtual 用于修饰方法、属性、索引器或事件,虚成员可以在派生类中被重写。override 用于重写继承的虚拟或抽象成员。new 隐藏继承的成员。
new 与 override 的区别在于——new 会隐藏继承的成员,并新建一个独立于基类的成员,而 override 则是重写基类的虚方法。
(七)readonly 修饰符与 const 修饰符
const 用于修饰字段和局部变量,将其定义为常量。readonly 比 const 更为灵活,用于定义只读字段。只读字段可以是实例字段,这样类的每个实例可以有不同的值,也可以定义为静态字段。
const 与 readonly 的区别在于:
- const 只能修饰简单类型,要求在声明时就对常量进行初始化,而 readonly 可以修饰任意类型,可以将初始化过程推迟到构造函数中。
- 常量是隐式静态的,但如果要把只读字段设置为静态,必须显式声明为 static。
- const 修饰的字段在编译期间就被解析,而 readonly 修饰的字段则在运行的时候被解析。
- const 既可以声明在类中也可以在方法内部,但是 readonly 只能声明在类中。
(八)extern 修饰符与 volatile 修饰符
extern 修饰在外部实现的方法,常与[DllImport] 特性一起使用,此时,其修饰的方法必须为 static。
volatile 修饰符指示一个字段可以由多个同时执行的线程修改,看到访问这种字段的代码,编译器、CLR 或硬件就不会执行一些“线程不安全”的优化措施。
(一) 命名规范
1 类型、结构的命名
我们命名类和结构时应该劲量达到两个目的:
- 暗示了应用的场景。
- 反映出继承的层次(对于类来说)。
一般的命名规范如下:
- 使用名词或名词短语。
- 使用 Pascal 方式。
- 考虑在派生类末尾使用基类的名字。
- 如果该类仅仅为了实现某个接口,那么请保持其与接口命名的统一。
- 如果从 .NET 框架中存在的类型派生的类型,应该遵循以下规范:
基类 | 派生类 |
System.Attribute | 要给自定义的特性添加“Attribute”后缀 |
System.Delegate | 要给用于事件处理的委托添加“EventHandler”后缀 要给用于事件处理之外的那些委托添加“Callback”后缀 不要给委托添加“Delegate”后缀 |
System.EventArgs | 要添加“EventArgs”后缀 |
System.Enum | 不要派生自该类,而是使用 enum 定义枚举,枚举类型也不要添加“Enum”或“Flag”后缀 |
System.Exception | 要添加“Exception”后缀 |
IDictionary IDictionary<T,V> | 要添加“Dictionary”后缀 |
IEnumerable ICollection IList IEnumerable<T> ICollection<T> IList<T> | 添加“Collection”后缀 |
System.IO.Stream | 添加“Stream”后缀 |
CodeAccessPermission IPermission | 添加“Permission”后缀 |
2 成员的命名
成员命名的一般规范如下:
成员 | 大小写 | 规范 |
方法 | Pascal(公开)、Camel(私有) | 用动词或动词短语命名 |
属性 | Pascal | 用名词、名词短语或形容词来命名 集合属性应该使用复数形式,而不是添加后缀 用“Is”、“Can”、“Has”等表示布尔属性 可以用属性的类型名来命名属性 |
事件 | Pascal | 使用动词或动词短语来命名事件 用现在时和过去时来区分前置和后置事件 |
字段 | Pascal(公开)、Camel(私有) | 要用名词、名词短语或形容词来命名 不要加任何前缀 |
(二) 设计规范
1 抽象类的设计
- 要为抽象类定义受保护的构造函数或内部构造函数。
- 要为每个抽象类提供至少一个继承自该抽象类的具体类型。
- 不要在抽象类型中定义公有或内部受保护(protected-internal)构造函数。
2 静态类的设计
C# 编译器对静态类进行了如下限制:
- 静态类必须直接从 System.Object 派生,从其它任何基类派生没有任何意义,继承只适用于对象,而静态类不能实例化。
- 静态类不能实现任何接口,这是因为只有使用类的实例时才能调用类的接口方法。
- 静态类只能定义静态成员。
- 静态类不能作为字段、方法参数或局部变量使用,因为它们表示的都是一个实例化对象。
使用静态类时应该注意:
- 要尽量避免使用静态类。静态类应该仅被用作辅助类,来对框架的面向对象的核心部分提供支持。
- 不要把静态类当做杂物箱。
- 不要声明或覆盖静态类中的实例成员。
3 结构的设计
- 要确保当结构实例的所有数据都为0、false或null时,结构仍处于有效状态。
- 要为值类型实现IEquatable<T>。(值类型的Object.Equals方法会导致装箱,而且因为它使用了反射,所以它的默认实现也并不非常高效。IEquatable<T>.Equals的性能要好的多,实现得当可以避免装箱。)
- 不要定义可变值类型。(只能get,不要set)
4 嵌套类型的设计
- 要想让一个类型能够访问外层类型的成员时才使用嵌套类型。
- 尽量避免使用嵌套类型。
- 不要用公共嵌套类型来处理逻辑分组,应该使用命名空间来达到这一目的。
- 尽量避免公开地暴露嵌套类型,除非是只需在极少数的情况下(派生子类等)声明嵌套类型的变量。
- 嵌套类型应该由外层类型来创建、操作、销毁,而不应该被外层类型之外的地方被广泛地使用。
- 不要使用嵌套类型——如果该类型可能会被除了他的外层类型之外的类型引用。
- 不要使用嵌套类型——如果需要让客户代码来实例化它们。(具有公有构造函数)
- 不要把嵌套类型定义为接口的成员。(多数编程语言不支持)
5 如何防止类型被实例化。
我们有3种方法可以防止类型实例化:
- 将默认构造函数定义为私有。
- 将类定义为抽象。
- 使用静态类。