现代化你的 C# 代码 - 第一部分:属性






4.91/5 (60投票s)
想现代化你的 C# 代码库吗?让我们从属性开始。
目录
引言
近年来,C# 从一种只有一个解决方案来解决问题的语言,发展成一种针对单一问题有多种潜在(语言)解决方案的语言。这有好有坏。好,因为它给了我们开发者自由和强大的能力(在不损害向后兼容性的前提下),坏,是因为这带来了相关的决策认知负荷。
在这个系列中,我们想探索有哪些选项存在,以及这些选项之间有什么区别。当然,在某些条件下,一些选项可能有优缺点。我们将探讨这些场景,并提出一个指南,让我们的生活在翻新现有项目时更加轻松。
背景
过去,我写了许多特别针对 C# 语言的文章。我写过 入门系列,高级指南,以及关于特定主题的文章,如 async / await 或 即将推出的功能。在这个系列文章中,我想以一种连贯的方式将所有先前的主题结合起来。
我觉得讨论新语言功能在哪里大放异彩,以及旧的——让我们称之为成熟的——功能在哪里仍然更受欢迎,这一点很重要。我可能不总是有权(特别是,因为我的一些观点肯定会更主观/是品味问题)。一如既往,欢迎留下评论进行讨论!
让我们从一些历史背景开始。
什么是属性?
属性的概念并非 C# 首创。实际上,为字段创建修改器方法(getter/setter)的想法与软件诞生一样古老,并在面向对象编程语言中非常流行。
从 Java 到 C#
在类 Java 的 C# 中,我们不会为这样的修改器方法包含任何特殊语法。取而代之的是,我们会选择类似以下的代码:
class Sample
{
private string _name;
public string GetName()
{
return _name;
}
public void SetName(string value)
{
_name = value;
}
}
按照约定,我们会始终在“常规”标识符前面加上 Get
(getter 方法的前缀)或 Set
(setter 方法的前缀)。我们还可以根据使用的签名识别一个常见模式。
总的来说,我们可以说以下接口可以描述这样一个由 getter 和 setter 组成的属性:
interface Property<T>
{
T Get();
void Set(T value);
}
当然,这样的接口并不存在,即使存在,它也只会是一个由两个独立接口组成的复合接口——一个用于 getter,一个用于 setter。
实际上,例如,仅具有 getter 是非常有意义的。这就是我们经常寻找的封装。在下面的示例中,只有类本身可以确定 _name
字段的值。任何“外部”都不能执行任何修改,这使得使用的修改器已经很有用了。
class Sample
{
private string _name;
public string GetName()
{
return _name;
}
}
然而,因为我们已经可以看到很多东西都是通过约定和非常重复的,C# 语言团队认为我们需要一些语法糖来处理“经典的”修改器方法:属性!
适用于 | 避免用于 |
---|---|
|
|
经典方式
从 C# 语言的第一版开始,我们就有了编写属性的(显式,即经典的)方式。它们修复了前面介绍的约定,但没有给我们带来任何其他好处。我们仍然需要显式地编写方法体(getter 和 setter 方法)。更糟的是,我们有很多花括号要处理,并且不能,例如,重命名 setter 值。
class Sample
{
private string _name;
public string Name
{
get { return _name; }
set { _name = value; }
}
}
C# 中的属性看起来像一个方法,但是省略了括号(即方法参数)。它还强制我们编写一个包含 get
方法、set
方法或两者的代码块。
尽管这看起来写起来稍微方便一些(至少更一致),但最终结果是相同的。
这是由类 Java 程序生成的 MSIL(编译后的中间语言)。
Sample.GetName:
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.SetName:
IL_0000: nop
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: stfld Sample._name
IL_0008: ret
以及使用我们新的 C# 属性产生的相同结果。
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.set_Name:
IL_0000: nop
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: stfld Sample._name
IL_0008: ret
注意到区别了吗?它们是相同的,除了名称。这实际上是至关重要的。当我们拥有这样的属性时,尝试获取这个名称将不再可能。
因此,对于 C# 属性中的每个修改器,我们还移除了一些不直接可见的名称。乍一看这似乎很简单,但它带来了一些复杂性(设计时名称 vs. 编译时名称),这可能不是我们真正想要的或理解的。
适用于 | 避免用于 |
---|---|
|
|
现代方式
正如我们所见,经典的属性只提供了一些语法糖来固定创建修改器方法时通常使用的约定。最终,我们得到的结果与我们无论如何都会写的东西 100% 相同。是的,在元数据中,属性也被标记为属性,使得区分来自属性的方法和显式编写的方法成为可能,但是,执行的代码看不到任何区别。
随着 C# 近年来的版本(从第三个版本开始),添加了一些新概念,以提供更多的开发便利性。在下文中,我们将 C# 中所有这些 v1 之后的添加称为“现代方式”。
自动属性
自动属性提供了一种消除“标准”属性带来的大部分样板代码的方法。标准属性是指由一个具有 getter 和 setter 的字段组成的属性。这里重要的是需要两种方法,即使修饰符(即 public
, protected
, internal
, 和 private
)可能不同。
考虑以下直接的示例来替换我们之前的实现:
class Sample
{
public string Name
{
get;
set;
}
}
是的,出于性能原因,我们可能不喜欢这样。原因是字段实际上是“隐藏的”(即,由编译器插入,我们无法访问)。因此,对字段的唯一访问是通过属性。
这也可以在生成的 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 纯粹主义者会告诉我们,字段访问无论如何都不应该直接进行,而应始终通过修改器方法。因此,从这个角度来看,这实际上是一种好习惯。.NET 性能专家还会告诉我们,这类自动属性不会有任何损失,因为 JIT 会内联方法,从而直接进行修改。
那么这种方法一切都好吗?不完全是。无法将此方法与 setter 中的自定义逻辑混合(例如,仅在值“有效”时设置)。要不就是我们有一个标准实现中的 getter 和 setter 方法,要不就是我们需要明确说明两者。
关于此修饰符
class Sample
{
protected string Name
{
get;
private set;
}
}
外部修饰符(在此例中是 protected
)将应用于两个修改器方法。我们在这里不能降低限制,例如,不能为 getter 提供 public
修饰符,因为它比已指定的 protected
修饰符限制更少。然而,两者都可以根据需要调整为更严格,但只有一个修改器方法可以相对于外部(属性)修饰符进行重新调整。
注意:虽然 public
、protected
和 private
的修饰符情况很明显,但 internal
修饰符有些特殊。它比 public
限制更严格,比 private
限制更宽松,但比 protected
限制更严格,也比 protected
限制更宽松。原因很简单:虽然 protected
可以在当前程序集外部访问(即限制更少),但它也阻止了在当前程序集内非继承类的访问(即限制更多)。
虽然理论上我们可以对两个修改器方法应用修饰符,但 C# 语言出于好原因禁止这样做。我们应该指定一个清晰且合理的访问模式,排除混合访问器。
适用于 | 避免用于 |
---|---|
|
|
赋值属性
很多时候,我们唯一想要的只是一个反映特定字段的属性。我们不想要它的 setter 修改器。不幸的是,通过前面的方法,我们无法获得字段,也无法删除或省略 setter。
幸运的是,第一个提议已经解决了这个问题。我们可以自由地省略这两个修改器方法中的一个。
让我们看看实际效果:
class Sample
{
private string _name = "Foo";
public string Name
{
get { return _name; }
}
}
嗯,这有什么用?除了现在被限制为只能通过字段设置值(这使得设置值时没有隐藏的魔法显而易见)之外,我们现在看不到任何明显的优势。
为了完整起见,构建的 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
字段/属性的愿望和自动属性。
只读属性
在 C# 6 中,语言设计团队采纳了这个想法并提供了一个解决方案。C# 现在允许只读的 getter 属性。
在实践中,这些属性可以像只读字段一样赋值,例如,在构造函数中或声明时直接赋值。
让我们看看实际效果:
class Sample
{
public string Name { get; } = "Foo";
}
生成的 MSIL 与自动属性类似(谁能想到呢),但没有 setter 方法。相反,我们看到的是对已生成的基础字段的赋值。
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# 编译器负责生成代码(即,具有访问权限的后备字段),它将跳过一些验证/安全调用。出于性能原因,我们可以说这是当前版本的一个优势,但请记住,这里我们只看到未优化的非 JIT 代码。JIT 实际上可能会移除所有之前的样板代码并内联剩余的字段加载。
适用于 | 避免用于 |
---|---|
|
|
考虑到这一点,我们能否变得更简单,同时提高灵活性?
属性表达式
C# 3 的一个重要特性是引入了 LINQ。有了它,引入了一整套新的语言特性。其中一个伟大的特性是用于编写匿名函数的 lambda 语法(在 C# 中,我们也可以称这些函数引用为委托,而在例如 C++ 中,它们被称为函数对象)。这个 lambda 语法一直是 C# 7 及更高版本中的一个核心元素,用于使 C# 更具函数式/对函数式编程(FP)中发现的模式更友好。
这些增强功能之一就是 C# 属性,现在可以使用“胖箭头”(即 lambda 语法)作为表达式来解析。
它可以像下面的代码一样简单:
class Sample
{
private readonly string _name = "Foo";
public string Name => _name;
}
我们也可以(滥用)这种语法来产生更微不足道的东西,例如 public string Name => "Foo"
,这在这种特殊情况下效果更好,但通常不相同或不推荐。
然而,在某些场景下,当一个属性只是对某些其他功能的浅层包装(例如,延迟加载)时,这种语法可能是理想的。
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 和 Set 表达式
幸运的是,C# 语言设计团队也考虑了这种情况。我们实际上可以使用标准属性(如 C# 1.0 中所见的)与表达式语法相结合。
在实践中,这看起来像这样:
class Sample
{
private string _name = "Foo";
public string Name
{
get => _name;
set => _name = value;
}
}
修饰符也不是问题,并且像原始 C# 规范一样自然地添加。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 也从不处于块语句中(4 条指令而不是 5 条)。
适用于 | 避免用于 |
---|---|
|
|
Outlook(展望)
在本次系列文章的下一部分,我们将继续讨论方法,作为属性的后续。虽然起初可能有些枯燥,但我们将特别包含委托的演变,以及像局部函数这样更新的结构。
就属性的未来而言,下一个层次可能是为扩展方法提供一个对应的属性(即,读取 *扩展属性*)。目前,这通过再次回退到类 Java 的语法/约定来解决。
结论
C# 的演变并未止步于属性。一旦它们作为一种稍微更安全的约定被添加到语言中,它们就演变成了暴露字段的函数式结构,并使得诸如延迟加载之类的操作变得愉快。
我们只能希望这条旅程还没有结束。可以添加许多有益的功能,并且过去许多功能一直是人们所期望的(例如,为了减轻 WPF 绑定所需的样板代码)。
兴趣点
我一直展示的是未优化的 MSIL 代码。一旦 MSIL 代码被优化(或者即使正在运行),它可能看起来会有些不同。在这里,不同方法之间的实际观察到的差异可能会消失。然而,由于我们在本文中专注于开发者的灵活性和效率(而不是应用程序性能),所有建议仍然有效。
如果您在其他模式(例如,发布模式,x86,...)中发现一些有趣的东西,请写评论。任何额外的见解总是受欢迎的!
历史
- v1.0.0 | 初始发布 | 2019 年 3 月 3 日
- v1.1.0 | 添加目录 | 2019 年 3 月 5 日
- v1.2.0 | 添加自动只读属性 | 2019 年 5 月 7 日