6.6 属性
上一节演示了如何使用private关键字封装密码,禁止从类的外部访问。但这种形式的封装通常过于严格。例如,可能希望字段在外部只读,但内部可以更改。又例如,可能希望允许对类中的一些数据执行写操作,但需要验证对数据的更改。再例如,可能希望动态构造数据。为满足这些需求,传统方式是将字段标记为私有,再提供取值和赋值方法(getter和setter)来访问和修改数据。代码清单6.16将FirstName和LastName更改为私有字段。每个字段的公共取值和赋值方法用于访问和更改它们的值。
代码清单6.16 声明取值和赋值方法
遗憾的是,这一更改会影响Employee类的可编程性。无法再用赋值操作符来设置类中的数据。此外也导致只能调用方法来访问数据。
6.6.1 声明属性
考虑到经常都会用到这种编程模式,C#的设计者决定为它提供显式的语法支持。新语法称为属性(property),如代码清单6.17和输出6.5所示。
代码清单6.17 定义属性
输出6.5
在这个代码清单中,最引人注目的不是属性本身,而是Program类的代码。现在其实已经没有FirstName和LastName字段了,但这一点从Program类本身看不出来。访问员工名字和姓氏所用的代码根本没有改变。仍然可以使用简单的赋值操作符对姓或名进行赋值,例如employee.FirstName="Inigo"。
属性的关键在于,它提供了从编程角度看类似于字段的API。但事实上并不存在这样的字段。属性声明看起来和字段声明一样,但跟随在属性名之后的是一对大括号,要在其中添加属性的实现。属性的实现由两个可选的部分构成。其中,get标志属性的取值方法(getter),直接对应代码清单6.16定义的GetFirstName()和GetLastName()方法。访问FirstName属性需调用employee.FirstName。类似地,set标志属性的赋值方法(setter),它实现了字段的赋值语法:
属性的定义使用了三个上下文关键字。其中,get和set关键字分别标识属性的取值和赋值部分。此外,赋值方法可用value关键字引用赋值操作的右侧部分。所以,当Program.Main()调用employee.FirstName="Inigo"时,赋值方法中的value被设为"Inigo",该值可以赋给_FirstName字段。代码清单6.17的属性实现是最常见的。调用取值方法时,比如Console.WriteLine(employee2.FirstName),会获取字段(_FirstName)值并将其写入控制台。
从C# 7.0起可用表达式主体方法声明属性的取值和赋值方法,如代码清单6.18所示。
代码清单6.18 用表达式主体成员定义属性
代码清单6.18用两种不同的语法实现属性,实际编程时请统一。
6.6.2 自动实现的属性
从C# 3.0起属性语法有了简化版本。在属性中声明支持字段(比如上例的_FirstName),并用取值方法和赋值方法来获取和设置该字段——由于这是十分常见的设计,而且代码比较琐碎(参考FirstName和LastName的实现就知道了),所以现在编译器允许在声明属性时不添加取值或赋值方法,也不声明任何支持字段。一切都自动实现。代码清单6.19展示如用何该语法定义Title和Manager属性,输出6.6是结果。
代码清单6.19 自动实现的属性
输出6.6
自动实现的属性简化了写法,也使代码更易读。此外,如未来需添加一些额外的代码,比如要在赋值方法中进行验证,那么虽然要修改现在的属性声明来包含实现,但调用它们的代码不必进行任何修改。
本书剩余部分主要使用这种语法,而不强调它是从C# 3.0才引入的。
关于自动实现的属性,最后要注意从C# 6.0开始可以像代码清单6.19最后一行进行初始化:
在C# 6.0之前,只能通过方法(包括构造函数,本章稍后会讲到)来初始化属性。但现在可以用字段初始化那样的语法在声明时初始化自动实现的属性。
6.6.3 属性和字段的设计规范
由于可以写显式的赋值和取值方法而不是属性,所以有时会疑惑该用属性还是方法。一般原则是方法代表行动,而属性代表数据。属性旨在简化对简单数据的访问。调用属性的成本不应比访问字段高出太多。
至于命名,注意在代码清单6.19中属性名是FirstName,它的支持字段名变成了_FirstName。其实就是添加了下划线前缀的PascalCase大小写。对于为属性提供支持的私有字段,其他常见的命名规范还有_firstName和像局部变量那样[1]的camelCase大小写规范。不过,应尽量避免camelCase大小写,因为局部变量和参数也经常采用这种大小写,会造成名称的重复。另外,为符合封装原则,属性的支持字段不应声明为public或protected。
设计规范
·要使用属性简化对简单数据的访问(只进行简单计算)。
·避免从属性取值方法抛出异常。
·要在属性抛出异常时保留原始属性值。
·如果不需要额外逻辑,要优先使用自动实现的属性,而不是属性加简单支持字段。
无论私有字段使用哪一种命名方案,属性都要使用PascalCase大小写规范。因此,属性应使用LastName和FirstName等形式的名词、名词短语或形容词。事实上,属性和类型同名的情况也不罕见,例如Person对象中的Address类型的Address属性。
设计规范
·考虑为支持字段和属性使用相同的大小写风格,为支持字段附加“_”前缀。但不要使用双下划线,它是为C#编译器保留的。
·要使用名词、名词短语或形容词命名属性。
·考虑让某个属性和它的类型同名。
·避免用camelCase大小写风格命名字段。
·如果有意义的话,要为Boolean属性附加“Is”“Can”或“Has”前缀。
·要将所有实例字段声明为私有(并通过属性公开)。
·要用PascalCase大小写风格命名属性。
·要优先使用自动实现的属性而不是字段。
·如果没有额外的实现逻辑,要优先使用自动实现的属性而不是自己写完整版本。
6.6.4 提供属性验证
在代码清单6.20中,注意Employee的Initialize()方法使用属性而不是字段进行赋值。虽然并非必须如此,但这样做的结果是,无论在类的内部还是外部,属性的赋值方法中的任何验证都会得到调用。例如,假定更改LastName属性,在把value赋给_LastName之前检查它是否为null或空字符串,那么会发生什么?(实际上,即使字段的类型为不可空,这一步检查仍然有必要,因为调用者有可能将可空引用类型特性禁用了,或者方法有可能在C# 7.0或更早期版本中被调用,那时可空引用类型尚未出现。)
代码清单6.20 提供属性验证
在新实现中,如果为LastName赋了无效的值(要么从同一个类的另一个成员赋值,要么在Program.Main()内直接向LastName赋值),代码就会抛出异常。拦截赋值,并通过字段风格的API对参数进行验证,这是属性的优点之一。
一个好的实践是只从属性的实现中访问属性的支持字段。换言之,要一直使用属性,不要直接调用字段。许多时候,即使在属性所在的类中,也不应该从属性实现的外部访问其支持字段。这样只要为属性添加了验证代码,整个类就能马上利用这个逻辑[2]。
虽然很少见,但确实能在赋值方法中对value进行赋值。如代码清单6.20所示,调用value.Trim()会移除新姓氏值左右的空白字符。
在C# 6.0之前,程序员需要使用字面量"value"作为异常的paramName参数。从C# 6.0开始,可以使用nameof(value)代替字面量。下面的“高级主题:nameof操作符”将做更详细的介绍。在本章后面,我将统一使用nameof(value)的写法。如果想要在C# 5.0或更早期版本中测试示例代码,请将其改为字面量"value"。
高级主题:nameof操作符
属性验证时如判断新赋值无效,就需要抛出ArgumentException()或Argument-NullException()类型的异常。两个异常都获取string类型的实参paramName来标识无效参数的名称。代码清单6.20为该参数传递"value",但从C# 6.0起可用nameof操作符来改进。该操作符获取一个标识符(比如value变量)作为参数,返回该名称的字符串形式(本例是"value")。代码清单6.20在报告第二个错误时也使用了nameof的方法。
nameof操作符的优点在于,以后若标识符名称发生改变,重构工具能自动修改nameof的实参。如果不用重构工具,代码将无法编译,强迫开发者手动修改实参。对于属性验证代码,参数始终是value,不可修改。所以,这里使用nameof操作符的意义不大。但不管怎样,所有paramName参数都应坚持使用nameof操作符,保持与以下设计规范一致:对于ArgumentNullException和ArgumentNullException等要获取paramName参数的异常,总是为该参数使用nameof操作符。更多信息将在第18章介绍。
设计规范
·避免从属性外部(即使是从属性所在的类中)访问属性的支持字段。
·创建ArgumentException()或ArgumentNullException()类型的异常时,要为paramName参数使用nameof (value)(解析为"value"),"value"是属性赋值方法隐含的参数名。
6.6.5 只读和只写属性
通过移除属性的取值方法或赋值方法,可以改变属性的可访问性。只有赋值方法的属性是只写属性,这种情况较罕见。类似地,只提供取值方法会得到只读属性,任何赋值企图都会造成编译错误。例如,为了使Id只读,可以像代码清单6.21那样编码。
代码清单6.21 C# 6.0之前定义只读属性
代码清单6.21从Employee的Initialize()方法(而不是属性)中对字段赋值(_Id=id)。通过属性来赋值会造成编译错误,如Program.Main()中注释掉的employee1.Id="490";代码所示。
C# 6.0开始支持只读自动实现的属性,如下所示:
这对C# 6.0之前的方式是一项重大改进,尤其是需要处理太多只读属性的时候,例如数组或代码清单6.21的Id。
只读自动实现属性的一个重点在于,和只读字段一样,编译器要求通过一个初始化器(或通过构造函数)来初始化。上例使用了初始化器(初始化列表),但稍后就会讲到,也可在构造函数中对Cells进行赋值。
由于规范是不要从属性外部访问支持字段,所以在C# 6.0之后,几乎永远用不着之前的语法(比如代码清单6.21)。相反,应总是使用只读自动实现属性。唯一例外是在字段和属性类型不匹配的时候。例如字段是int类型,只读属性是double类型。
设计规范
·如属性值不变,要创建只读属性。
·如属性值不变,从C# 6.0起要创建只读自动实现的属性而不是只读属性加支持字段。
6.6.6 属性作为虚字段
可以看出属性的行为与虚字段相似。有时甚至根本不需要支持字段。相反,可让属性的取值方法返回计算好的值,而让赋值方法解析值,并将值持久存储到其他成员字段中。注意代码清单6.22中Name属性的实现。输出6.7展示了结果。
代码清单6.22 定义属性
输出6.7
Name属性的取值方法连接FirstName和LastName属性的返回值。事实上,所赋的姓名并没有真正存储下来。向Name属性赋值时,右侧的值会解析成名字和姓氏部分。
6.6.7 取值和赋值方法的访问修饰符
如前所述,好的实践是不要从属性外部访问其字段,否则为属性添加的验证逻辑或其他逻辑可能失去意义。遗憾的是,C# 1.0不允许为属性的取值和赋值方法指定不同封装级别。换言之,不能为属性创建公共取值方法和私有赋值方法,使外部类只能对属性进行只读访问,而允许类内的代码向属性写入。
C# 2.0的情况发生了变化,允许在属性的实现中为get或set部分指定访问修饰符(但不能为两者都指定),从而覆盖为声明属性指定的访问修饰符。代码清单6.23展示了一个例子。
代码清单6.23 为赋值方法指定访问修饰符
为赋值方法指定private修饰符,属性对于除Employee的其他类来说就是只读的。在Employee类内部,属性可读且可写,所以可在构造函数中对属性进行赋值。为取值或赋值方法指定访问修饰符时,注意该访问修饰符的“限制性”必须比应用于整个属性的访问修饰符更“严格”。例如,将属性声明为较严格的private,但将它的赋值方法声明为较宽松的public,就会发生编译错误。
设计规范
·要为所有属性的取值和赋值方法应用适当的可访问性修饰符。
·不要提供只写属性,也不要让赋值方法的可访问性比取值方法更宽松。
6.6.8 属性和方法调用不允许作为ref或out参数值
C#允许属性像字段那样使用,只是不允许作为ref或out参数值传递。ref和out参数内部要将内存地址传给目标方法。但由于属性可能是无支持字段的虚字段,也有可能只读或只写,所以不可能传递存储地址。同样的道理也适用于方法调用。如需将属性或方法调用作为ref或out参数值传递,首先必须将值拷贝到变量再传递该变量。方法调用结束后,再将变量的值赋回属性。
高级主题:属性的内部工作机制
代码清单6.24展示了取值方法和赋值方法在CIL代码中以get_FirstName()和set_FirstName()的形式出现。
代码清单6.24 属性的CIL代码
除了外观与普通方法无异,注意属性在CIL中也是一种显式的构造。如代码清单6.25所示,取值方法和赋值方法由CIL属性调用,而CIL属性是CIL代码中的一种显式构造。因此,语言和编译器并非总是依据一个惯例来解释属性。相反,正是由于反正最后都会回归CIL属性,所以编译器和代码编辑器能随便提供自己的特殊语法。
代码清单6.25 属性是CIL的显式构造
注意在代码清单6.24中,作为属性一部分的取值方法和赋值方法包含了specialname元数据。IDE(比如Visual Studio)根据该修饰符在“智能感知”(IntelliSense)中隐藏成员。
自动实现的属性在CIL中看起来和显式定义支持字段的属性几乎完全一样。C#编译器在CIL中生成名为<PropertyName>k_BackingField的字段。该字段应用了名为System.Runtime.CompilerServices.CompilerGeneratedAttribute的特性(参见第18章)。无论取值还是赋值方法都用同一个特性修饰(与代码清单5.23和5.24的实现相同)。
[1] 我个人更喜欢_FirstName,下划线就足够了,名称前的m太多余。另外,使用与属性名称相同的大小写规范,Visual Studio代码模板扩展工具中就可以只设置一个字符串,而不必为属性名和字段名各设一个。
[2] 本章后面会讲到,一个例外是:在字段被标记为只读时,只能在构造函数中设置值。从C# 6.0起可直接对只读属性赋值,完全用不着只读字段了。