Modernize Your C# Code - Part I: Properties属性
我觉得讨论新的语言特性在哪里有亮点,旧的语言特性在哪里有亮点是很重要的-我们称之为已建立的-它们仍然是首选。在文章的末尾你将学习到属性是什么,还有更多关于自动属性、赋值属性、只读属性、属性表达式和Get、Set表达式的细节特性。
前言
近些年,C#已经从一种只有一个功能来解决问题的语言发展成为一种对一个问题有许多潜在(语言)解决方案的语言。这样有好处也有坏处。好处是,给予开发人员自由和权利(不影响向后的兼容性);而坏的是与决策相关的认知负荷。
在这个系列中,我们想要探索存在哪些选项以及这些选项的不同之处。当然,在某些条件下,有些可能有利有弊。我们将探索这些场景,并提出一个指南,使我们在改造现有项目时更加轻松。
背景
在过去 ,我已经写过很多专门针对C#语言的文章。我也不会一直都是正确的,特别是,因为我的一些观点肯定会更主观/是个人喜好的问题。通常来说,在评论区进行讨论会更受欢迎。
让我们用一些历史内容来开始。
什么是属性?
属性不是诞生于C#语言。事实上,实际上,字段的mutator函数(getter / setter)的想法和软件一样古老,并且在面向对象编程语言中非常流行。
从JAVA到C#
在JAVA-C#中,人们不会为这类mutator函数提供任何特殊语法。相反,人们会选择像下面这样书写代码:
1 C# 2 3 4 5 6 7 class Sample 8 { 9 private string _name; 10 11 public string GetName() 12 { 13 return _name; 14 } 15 16 public void SetName(string value) 17 { 18 _name = value; 19 } 20 }
按照惯例,我们总是将Get(getter方法的前缀)或Set(setter方法的前缀)放在“usual”标识符前面。我们还可以在这里识别与使用的签名相关的一种共同模式。
一般来说,我们可以说下面的接口可以描述成这样一个由getter和setter组成的属性:
1 interface Property<T> 2 { 3 T Get(); 4 5 void Set(T value); 6 }
当然,这样的接口并不存在,即使存在,它也只是一个由两个独立接口组成的复合接口——一个用于getter,一个用于setter。
实际上,例如,拥有一个getter-only很有意义。这就是我们经常寻找的封装。
在下面的示例中,只有类本身才能决定_name字段的值;不允许任何“局外人”进行任何改变,这使得已使用的设置器变得很有用。
1 class Sample 2 { 3 private string _name; 4 5 public string GetName() 6 { 7 return _name; 8 } 9 }
尽管如此,因此我们已经可以看到,这里的很多东西都是约定俗成的,而且非常重复,所以C#语言团队认为我们需要在“经典的”mutator函数之上添加一些语法糖:属性
用于:
扩展属性(这些就是函数而已)
用于你需要完全的自由且想要明确的地方
避免于:
类属性(为此,我们有C#属性)
经典方式
从C#语言的第一个版本开始,我们就有了(显式的,也就是经典的)编写属性的方式。
然而,他们修正了早先介绍的惯例,没有给我们带来任何其它好处。我们仍然需要显式地编写函数方法体(getter和setter函数)。更糟糕的是,我们在相当多的花括号需要处理,并且不能,比如,重命名setter值的名称。
1 class Sample 2 { 3 private string _name; 4 5 public string Name 6 { 7 get { return _name; } 8 set { _name = value; } 9 } 10 }
然而,在C#中,属性看起来像一个函数,但是省略了括号(即方法参数)。它会强制我们编写或者包含get函数,或者包含set函数,或者两者均包含的语句块。
尽管这样看起来书写更方便一点(至少更一致),但它的计算结果还是一样的。
这是Java-ish程序生成的MSIL(编译中间语言)。
1 MSIL 2 3 Sample.GetName: 4 IL_0000: nop 5 IL_0001: ldarg.0 6 IL_0002: ldfld Sample._name 7 IL_0007: stloc.0 8 IL_0008: br.s IL_000A 9 IL_000A: ldloc.0 10 IL_000B: ret 11 12 Sample.SetName: 13 IL_0000: nop 14 IL_0001: ldarg.0 15 IL_0002: ldarg.1 16 IL_0003: stfld Sample._name 17 IL_0008: ret
当我们使用C#中的属性时会产生同样的结果。
1 MSIL 2 3 Sample.get_Name: 4 IL_0000: nop 5 IL_0001: ldarg.0 6 IL_0002: ldfld Sample._name 7 IL_0007: stloc.0 8 IL_0008: br.s IL_000A 9 IL_000A: ldloc.0 10 IL_000B: ret 11 12 Sample.set_Name: 13 IL_0000: nop 14 IL_0001: ldarg.0 15 IL_0002: ldarg.1 16 IL_0003: stfld Sample._name 17 IL_0008: ret
注意到不同之处了嘛?除了名称不同,其它都是一样的。这一点其实很关键。当我们有了这样一个属性时,试图提取这个名称就不太可能了:

因此,对于C#属性中的每个赋值函数,我们也删除了一些不能直接看到的名字。乍一看,这似乎很简单直接,但是它带来了一些复杂性(设计时名称VS编译时名称),这可能不是真正想要的或是想理解的。
Useful for
在赋值函数中用于更加复杂的逻辑。
具有更多逻辑的计算属性(无支持字段)。
在遵循惯例时非常明确。
Avoid for
字段的简单包装
现代方式
正如我们已经看到的那样,当创建mutator方法时,经典属性只提供了一点语法糖来修复通常使用的约定。最后,我们得到的结果和我们最终要写的内容是100%一样的。是的,在元数据中,属性也被标记为这样,从而可以区分来自属性的方法和显式编写的方法,但是,执行的代码没有看到任何区别。
在C#的最新版本中,已经添加了一些新的概念来提供更多的开发便利。在接下来的文章中,我们将C#中所涉及的这些称为“the Modern Way”.
Auto Properties自动属性
自动属性提供了一种方式,用于消除“标准”属性所带来的大部分样板模式。标准属性就是由具有getter和setter的字段所组成的属性。重要的是它们都要包含方法函数,尽管只是修饰符(public,protected,internal,private)不同而已。
考虑一下下面这个简单的示例来取代我们之前执行的:
1 class Sample 2 { 3 public string Name 4 { 5 get; 6 set; 7 } 8 }
对的,考虑性能原因的话,我们可能不希望这样。原因是字段(field)实际上是“隐藏”了(也就是说,嵌入编译器中无法从外部访问)。因此,唯一访问字段的方式就是经过属性。
在创建的MSIL中也可以看得到:
MSIL Sample.get_Name: IL_0000: ldarg.0 IL_0001: ldfld Sample.<Name>k__BackingField IL_0006: ret Sample.set_Name: IL_0000: ldarg.0 IL_0001: ldarg.1 IL_0002: stfld Sample.<Name>k__BackingField IL_0007: ret
然而,OOP坚持论者会告诉我们,字段的访问无论如何都不应该直接进行,而应该是通过mutator方法访问。因此,从这个角度来看,这实际上是一个很好的做法。.NET性能专家还会告诉我们,这样的自动属性不会有任何损失,因为JIT会内联此函数方法,导致可以直接修改。
这种方法一直都很好嘛?也不尽然。没有办法将这种方式与setter中的自定义逻辑混合在一起(例如,只有在值"有效"时才会设置它)。要么我们在一个标准实现中同时拥有两个(getter和setter)方法,要么我们需要明确这两个方法。
下面是相关的修改:
1 class Sample 2 { 3 protected string Name 4 { 5 get; 6 private set; 7 } 8 }
外部修饰符(在本示例中,是protected)将应用于这两个mutator方法。我们在这里不能放开,比如说,不能给getter一个public修饰符,因为它比已经指定的protected修饰符更加放开。然而,这两个 方法都 可以随意调整得更加严格受限,然而,仅有一个mutator方法可以依据外部(属性)修饰符进行调整。
备注:
虽然public,protected和private修饰符的可访问性是显而易见的,但是internal修饰符是有点特殊。它比public可访问性,比private可访问性高,然而,public可访问性高于protected,private可访问性低于protected。原因很简单:因为protected能在当前程序集之外访问(也就是说,更少的限制性),但它也能阻止当前程序集中非继承类的访问(也就是说,更多的限制性)。
虽然在理论上,我们可以对两个mtator方法都应用一个修饰符,但是是C#语言有充分的理由禁止这样做。我们应该指定一个清晰可行的可访问模式,排队混合访问器。
Assigned Properties赋值属性
很多时候 ,我们唯一的愿望就是拥有一个可以映射某个字段的属性。我们不想要任何的setter mutator。不幸的是,使用前面的方法,我们不能获得字段,也不能删除和省略setter。
幸运的是,第一个提议已经解决了这个问题。我们可以随意省略两个mutator方法中的一个。
如下所示:
class Sample { private string _name = "Foo"; public string Name { get { return _name; } } }
这么做有什么用呢?除了通过字段来限制value值的设定(很明显,在设置值时没有隐藏的魔法),我们目前还看不到任何明显的优势。
为了完整起见,构建的MSIL如下所示:
MSIL
Sample.get_Name: IL_0000: nop IL_0001: ldarg.0 IL_0002: ldfld Sample._name IL_0007: stloc.0 IL_0008: br.s IL_000A IL_000A: ldloc.0 IL_000B: ret Sample..ctor: IL_0000: ldarg.0 IL_0001: ldstr "Foo" IL_0006: stfld Sample._name IL_000B: ldarg.0 IL_000C: call System.Object..ctor IL_0011: nop IL_0012: ret
这个示例的主要优点就是能够将字段设置为只读(赋予它任意特性、修饰符或是初始化逻辑)。
class Sample { // will always have the value "Foo" private readonly string _name = "Foo"; public string Name { get { return _name; } } }
对比public string Name { get; private set; }这种方式,我们可以确定地排除了在初始化后进行赋值的可能性(这个承诺不仅传达给了未来的自己,也传达给任何其它可能跨越这个领域的开发人员)。
现在我们所需要的方法就是将我们编写只读字段/属性的需求与自动属性结合起来。
Readonly Properties只读属性
在C# 6 中,语言设计团队采纳了这个想法,并给出了问题的解决方案。现在,C#允许getter only属性。
在实践中,这些属性可以像一个只读字段那样进行赋值,比如,在构造函数中或在声明它们时直接赋值。
像下面这样:
class Sample { public string Name { get; } = "Foo"; }
生成的MSIL类似于auto属性(谁会想到),但是没有任何setter方法。相反,我们看到的是已经生成的对底层字段的赋值。
MSIL
Sample.get_Name: IL_0000: ldarg.0 IL_0001: ldfld Sample.<Name>k__BackingField IL_0006: ret Sample..ctor: IL_0000: ldarg.0 IL_0001: ldstr "Foo" IL_0006: stfld Sample.<Name>k__BackingField IL_000B: ldarg.0 IL_000C: call System.Object..ctor IL_0011: nop IL_0012: ret
如果我们将这段代码与显式(只读)字段的代码进行比较,我们会发现两者在初始化时是相同的。这里没有功能上的区别,但是对于getter来说,代码要小得多,也更直接。原因是,由于C#编译器负责生成代码(例如,支持具体访问权限的字段),因此它将跳过一些验证/安全调用。对于性能原因来说,我们可能会说这是当前版本的一个优势,但是请记住,我们在这里只能看到未优化的non-jitted代码。JIT实际上可能会删除所有以前的样板,并内联剩余的字段加载。
考虑到这一点,我们能否在提高灵活性的同时变得更加简单?
Property Expression
C# 3 的一个很大的特点是引入了LINQ。随着它的推出,引入了大量新的语言特性。其中,最大的特性之一就是lamba语法,用于编写匿名函数(在C#中,我们也叫这些函数引用为委托,而在C++中,它们叫仿函数(functors))。这种Lambda语法是C# 7中的核心元素,它使得C#对于函数式编程(FP)中的模式更加函数化/友好。这些增强之一是对C#属性的改进,现在可以使用“粗箭头”即lambda语法将其解析为表达式。
这个看起来像下列代码一样简单:
class Sample { private readonly string _name = "Foo"; public string Name => _name; }
我们还可以使用这种语法来提供一些更简单的东西,例如,public string Name =>"Foo",在这种特例中运行效果会更好,但是一般来说不建议。
然而,在某些场景中,属性只是一些其他功能(例如,延迟加载)的浅包装,这种语法可能是理想的。
MSIL
Sample.get_Name: IL_0000: ldarg.0 IL_0001: ldfld Sample._name IL_0006: ret Sample..ctor: IL_0000: ldarg.0 IL_0001: ldstr "Foo" IL_0006: stfld Sample._name IL_000B: ldarg.0 IL_000C: call System.Object..ctor IL_0011: nop IL_0012: ret
请注意,MSIL看起来和只读属性的情况一样直接。我们讨论的另一个验证是通过使用块语句提供的安全保证。现在我们只需要使用一个表达式且可以省略块语句。这在之前是不可能的,所以MSIL必须反映块语句,现在可以更简单了。
如果我们想使用上面显示的表达式语法,但属性也需要setter方法,该怎么办?
Get and Set Expression
幸运的是,C#语言设计团队也想到了这个问题。我们实际上可以使用标准属性(如C#1.0中所示)与表达式语法的组合。
如下所示:
class Sample { private string _name = "Foo"; public string Name { get => _name; set => _name = value; } }
修饰符也不是一个问题,它们是按照原始C#规范自然添加的。MSIL也没有给出任何惊喜:
MSIL
Sample.get_Name: IL_0000: ldarg.0 IL_0001: ldfld Sample._name IL_0006: ret Sample.set_Name: IL_0000: ldarg.0 IL_0001: ldarg.1 IL_0002: stfld Sample._name IL_0007: ret Sample..ctor: IL_0000: ldarg.0 IL_0001: ldstr "Foo" IL_0006: stfld Sample._name IL_000B: ldarg.0 IL_000C: call System.Object..ctor IL_0011: nop IL_0012: ret
事实上,getter比原始版本中的更简单,甚至setter也受益于没有使用block语句。

浙公网安备 33010602011771号