现代化你的 C# 代码 - 第二部分:方法






4.89/5 (35投票s)
想现代化你的 C# 代码库吗?让我们继续讲方法。
目录
引言
近年来,C# 从一种只有一个特性来解决问题变成了有多种潜在(语言)解决方案来解决单个问题。这有好有坏。好,因为它给了我们开发者自由和力量(而不影响向后兼容性),坏,因为它带来了做决定时所需的认知负担。
在本系列文章中,我们想探讨有哪些选择,以及这些选择有什么不同。当然,在某些条件下,某些选择可能有优点和缺点。我们将探讨这些场景,并提供一份指南,让我们的生活在翻新现有项目时更容易。
这是本系列的第二部分。你可以在 CodeProject 上找到第一部分。
背景
过去,我写了很多专门针对 C# 语言的文章。我写了 入门系列、高级指南、关于特定主题的文章,如 async / await 或 即将推出的功能。在本系列文章中,我想将所有先前的主题以一种连贯的方式结合起来。
我认为讨论新语言特性在哪里大放异彩,以及在哪里旧的——我们称之为成熟的——特性仍然更受欢迎是很重要的。我可能不总是正确的(尤其是,因为我的一些观点肯定会更主观/取决于品味)。和往常一样,欢迎您留下评论进行讨论!
让我们从一些历史背景开始。
什么是方法?
很多时候,我们会看到人们对术语感到困惑——“方法”与“函数”。确实,每个方法都是一个函数,但并非每个函数都是一个方法。方法是一种特殊类型的函数,它有一个隐式的第一个参数,称为“上下文”。上下文由 `this` 提供,并与定义该方法的类的实例相关联。因此,(真正的)方法只能存在于类中,而 `static` 方法应该更被称为函数。
虽然人们经常认为 `this` 保证非 `null`,但实际上这种限制是人为的。确实,运行时会执行一些隐式的检查(通过将方法调用作为 `callvirt` 指令),但理论上这很容易被绕过。
有了隐式的 `this` 参数,就产生了一种特殊的语法。与其像 `f(a, b, ...)` 这样调用函数 `f`,不如找到 `c.f(a, b, ...)`,其中 `c` 是某个类的实例。所有其他情况都可以被视为完全限定名称,请参阅
// file a.cs
namespace A
{
public class Foo
{
public static void Hello()
{
// some code
}
}
}
// file b.cs
using A;
public class Bar
{
public static void Test()
{
Foo.Hello();
}
}
// file c.cs
using static A.Foo;
public class Bar
{
public static void Test()
{
Hello();
}
}
正如我们所见,自 C# 6 起,类也可以被视为命名空间——至少对于它们的 `static` 成员而言。因此,`static` 方法实际上总能像函数一样被调用,而无需任何前缀,区别仅在于完全限定名称和普通名称。
到目前为止,我们已经多次提到 `this`。C# 中的 `this` 是什么?
`this` 关键字引用类的当前实例,并用作扩展方法第一个参数的修饰符。
我们将在稍后介绍扩展方法。现在,让我们通过以下示例回顾标准方法与函数(即 `static` “方法”)
void Main()
{
var test = default(Test);
test.FooInstance();
Test.FooStatic();
}
public class Test
{
public void FooInstance()
{
}
public static void FooStatic()
{
}
}
产生以下 MSIL 代码
IL_0000: nop
IL_0001: ldnull
IL_0002: stloc.0 // test
IL_0003: ldloc.0 // test
IL_0004: callvirt Test.FooInstance
IL_0009: nop
IL_000A: call Test.FooStatic
IL_000F: nop
IL_0010: ret
方法和函数之间有两个关键区别
- 方法需要加载它所属的实例。这将是隐式的第一个参数。
- 方法总是使用 `callvirt` 调用(无论我们是否 `sealed` 了它或显式地将其设为 `virtual`)。
那么为什么要声明一个 `sealed` 或 `virtual` 方法呢?原因很简单:工程!这是告诉其他开发者代码应该如何使用的一种方式。就像 `private` 或 `readonly` 一样,(至少一开始)其含义仅在于编译器如何处理。运行时可能会在以后使用此信息进行进一步优化,但由于某些原因,可能不会产生直接影响。
让我们回顾一下何时应该优先使用函数而不是方法。
适用于 | 避免用于 |
---|---|
|
|
现代方式
标准方法和函数在 C# 中仍然得到了发展,尽管它们的目的保持不变(为什么会改变呢?)。我们得到了一些有用的语法糖,可以编写更少的(冗余的)代码。我们还拥有新的语言功能,有助于在 C# 中编写更具可重用性的函数。
让我们从查看扩展方法开始我们的旅程。
扩展方法
扩展方法是一种相当古老但简单的机制,可以广泛地重用函数。编写扩展方法的语法非常直接
- 我们需要一个 `static` 类(不能实例化,不能继承,也不允许实例成员)
- 我们需要一个 `static` 方法(即函数)
- 至少需要一个参数(称为扩展目标)
- 第一个参数必须用 `this` 关键字修饰
以下是一个扩展方法的示例。
public static class Test
{
public static void Foo(this object obj)
{
}
}
虽然方法有一个隐式的 `this` 参数(名为 `this`),但扩展方法有一个显式的“`this`”参数,它可以有一个我们决定的名称。由于 `this` 已经是关键字,所以我们无法使用它(不幸的是,或者幸运的是,因为它——至少乍一看——看起来像一个标准方法,而事实并非如此)。
与普通函数相比,扩展方法的优势仅在我们调用它时才显示出来。
var test = default(object);
Test.Foo(test);
test.Foo();
虽然第一次调用使用了显式语法(当然,通过在顶部添加 `using static Test;` 可以将其简化为 `Foo(test)`),第二次调用则使用了扩展方法。
正如我们可以从生成的 MSIL 中猜到的,它们之间没有任何区别!
IL_0000: nop
IL_0001: ldnull
IL_0002: stloc.0 // test
IL_0003: ldloc.0 // test
IL_0004: call Test.Foo
IL_0009: nop
IL_000A: ldloc.0 // test
IL_000B: call Test.Foo
IL_0010: nop
我们总是加载第一个参数,然后调用函数。中间没有任何魔术!但是,扩展方法看起来更漂亮,并且还有一个额外的优点……
考虑具有泛型函数,如 `Where`、`Select`、`OrderBy` 等。这些函数作用于 `IEnumerable
如果调用这些函数来引入条件、选择特定属性、并根据某个规则对可枚举集合进行排序,我们会这样写代码:
var result = MyLinq.OrderBy(MyLinq.Select(MyLinq.Where(source, ...), ...), ...);
因此,结果代码需要从内向外(洋葱式)阅读,而不是像上面描述的句子那样从左到右的自然方向(称为“链式”或“管道”顺序)。这是不幸的,因为它破坏了代码的可读性,使其难以理解……注释来救援?
不完全是,通过使用扩展方法,我们可以利用第一个参数由“调用实例”隐式提供的优势。结果是,代码看起来如下:
var result = source.Where(...).Select(...).OrderBy(...);
显式写出 `MyLinq` 类的表示法也消失了(而没有引入 `using static MyLinq`)。太棒了!
扩展方法也可以用作通用辅助函数。考虑以下 `interface`:
interface IFoo
{
Task FooAsync();
Task FooAsync(CancellationToken cancellationToken);
}
在这里,我们已经告诉实现方提供 2 个方法,而不是 1 个。我猜这个接口几乎所有的实现都会是这样的:
class StandardFoo : IFoo
{
public Task FooAsync()
{
return FooAsync(default(CancellationToken));
}
public Task FooAsync(CancellationToken cancellationToken)
{
// real implementation
}
}
这不好。实现者需要做更多的工作。相反,我们可以指定我们的接口和一个相关的辅助方法,如下所示:
interface IFoo
{
Task FooAsync(CancellationToken cancellationToken);
}
static class IFooExtensions
{
public static Task FooAsync(this IFoo foo)
{
return foo.FooAsync(default(CancellationToken));
}
}
太好了,现在实现 `IFoo` 的人只需要负责一个方法,并且可以免费获得我们的便捷方法。
适用于 | 避免用于 |
---|---|
|
|
委托
在上一节中,我们触及了扩展方法的重要性及其对可读代码的意义(从左到右而不是从内到外)。示例使用了类似 LINQ 的函数集来引入扩展方法。实际上,LINQ(语言集成查询的缩写)特性首先引入了(对扩展方法的需求)。但是,我们在前面的示例中也遗漏了一个重要部分……
LINQ 仅在我们有一套复杂的选项来定义各种函数(例如 `Select`)的选项时才起作用。然而,即使是最复杂的对象结构作为参数,也无法给 LINQ 提供所需的灵活性(并且会使其使用变得非常复杂)。因此,我们需要一种特殊的对象作为参数——一个函数。在 C# 中,传递函数的方式是通过所谓的委托间接实现的。
委托通过以下语法定义:
delegate void Foo(int a, int b);
因此,委托的编写方式与函数签名完全相同,只是函数名被替换为委托的名称,并且使用了 `delegate` 关键字来引入签名。
最终,委托被编译成一个带有 `Invoke` 方法的类。该方法的签名与我们刚才介绍的签名相同。
让我们看看调用带有示例实现的委托(空体)的 MSIL,以揭示更多信息:
IL_0000: nop
IL_0001: ldsfld <>c.<>9__0_0
IL_0006: dup
IL_0007: brtrue.s IL_0020
IL_0009: pop
IL_000A: ldsfld <>c.<>9
IL_000F: ldftn <>c.b__0_0
IL_0015: newobj Foo..ctor
IL_001A: dup
IL_001B: stsfld <>c.<>9__0_0
IL_0020: stloc.0 // foo
IL_0021: ldloc.0 // foo
IL_0022: callvirt Foo.Invoke
IL_0027: nop
IL_0028: ret
Foo.Invoke:
Foo.BeginInvoke:
Foo.EndInvoke:
Foo..ctor:
<>c.b__0_0:
IL_0000: nop
IL_0001: ret
<>c..cctor:
IL_0000: newobj c..ctor
IL_0005: stsfld c.<>9
IL_000A: ret
<>c..ctor:
IL_0000: ldarg.0
IL_0001: call System.Object..ctor
IL_0006: nop
IL_0007: ret
我们看到生成的类实际上包含相当多的功能(还有 `static` 成员)。更重要的是,还有另外两个方法——`BeginInvoke` 和 `EndInvoke`。最后,创建委托并非免费——它实际上是生成类的对象创建。调用委托实际上与在类上调用 `Invoke` 方法相同。因此,这是一个虚拟调用,比调用函数(例如)更昂贵。
到目前为止,我们只看到了委托是什么以及如何声明它。实际上,大多数时候,我们不需要自己声明委托。我们可以直接使用内置的泛型声明委托:
- `Action
` 用于所有返回 `void`(无)的委托 - `Func
` 用于所有返回*某个东西*的委托:`TReturn`
还有一些泛型结构用于事件委托、谓词(如 `Func`,但固定返回 `bool`)等。
我们如何实例化一个委托?让我们考虑上面的委托 `Foo`,它接受两个整数参数。
Foo foo = delegate (int a, int b) { /* body */ };
foo(2, 3);
或者,我们可能想指向一个现有的函数:
void Sample(int a, int b)
{
/* body */
}
Foo foo = new Foo(Sample);
foo(2, 3);
生成的 MSIL 实际上不完全相同,但这目前无关紧要。最后一个也可以简化为 `Foo foo = Sample`,它隐式处理了委托实例的创建。
适用于 | 避免用于 |
---|---|
|
|
到目前为止一切顺利。我们明显缺乏一种更方便地编写匿名函数的方法。幸运的是,C# 为我们提供了解决方案。
Lambda 表达式
正如我们已经看到的,委托可以非常方便地通过将它们很好地打包到类中来传递函数。然而,目前在原地编写一些逻辑,即在委托中打包匿名函数,看起来相当麻烦和丑陋。
幸运的是,随着 C# 3 的发布,不仅引入了 LINQ(以及扩展方法),还引入了一种新的语法,使用新的“胖箭头”(或 lambda)运算符 `=>` 来编写匿名函数。
如果我们修改之前的示例以使用 lambda 表达式,它可能看起来像这样:
Foo foo = (a, b) => { /* body */ };
foo(2, 3);
生成的 MSIL 与(匿名)委托的完全相同。因此,这真的只是语法糖,但正是我们想要的甜头!
适用于 | 避免用于 |
---|---|
|
|
LINQ 表达式
与 LINQ 一起引入的还有一件事(C# 3 真棒,不是吗?):LINQ 表达式!这并不是关于查询语法与直接使用扩展方法之类的,而是关于 ORM 如何采用 LINQ。
问题如下:在 LINQ 被引入 C# 之前,我们大多数时候不得不直接在 C# 中编写 SQL 查询。虽然这无疑有一些优点(可以完全访问数据库提供的所有功能),但缺点是相当真实的:
- 编译器不支持
- 潜在的安全问题
- 结果没有静态类型
通过 LINQ,这一点得到了解决,通过引入 LINQ 表达式,一种不将匿名函数编译为 MSIL,而是将生成的 AST 转换为对象的方法。
尽管这是一个编译器特性,但归根结底是使用正确的类型。前面我们看到,像 `Func` 或 `Action` 这样的泛型委托可以让我们避免再次编写它们。如果我们把这样的委托包装到 `Expression` 类型中,我们就会得到一个 AST 持有者。
这是一个快速示例(实际上,以下示例并非真正编译,因为我们需要右侧有一个表达式,但思路应该可见):
Expression<Foo> foo = (a, b) => { /* body */ };
生成的 MSIL 至少可以说是丑陋的(而且考虑到这是一个非常简短的示例,我们可以猜到真实代码可能是什么样子):
IL_0000: nop
IL_0001: ldtoken System.Int32
IL_0006: call System.Type.GetTypeFromHandle
IL_000B: ldstr "a"
IL_0010: call System.Linq.Expressions.Expression.Parameter
IL_0015: stloc.1
IL_0016: ldtoken System.Int32
IL_001B: call System.Type.GetTypeFromHandle
IL_0020: ldstr "b"
IL_0025: call System.Linq.Expressions.Expression.Parameter
IL_002A: stloc.2
IL_002B: ldnull
IL_002C: ldtoken Nothing
IL_0031: call System.Reflection.MethodBase.GetMethodFromHandle
IL_0036: castclass System.Reflection.MethodInfo
IL_003B: call System.Array.Empty<Expression>
IL_0040: call System.Linq.Expressions.Expression.Call
IL_0045: ldc.i4.2
IL_0046: newarr System.Linq.Expressions.ParameterExpression
IL_004B: dup
IL_004C: ldc.i4.0
IL_004D: ldloc.1
IL_004E: stelem.ref
IL_004F: dup
IL_0050: ldc.i4.1
IL_0051: ldloc.2
IL_0052: stelem.ref
IL_0053: call System.Linq.Expressions.Expression.Lambda<Foo>
IL_0058: stloc.0 // foo
IL_0059: ret
本质上,这次调用的整个生成的 AST 现在都以对象格式可用——因此被包含在 MSIL 中。
ORM 可以检查此信息来创建优化的查询,这些查询可以安全地传输变量和特殊字段,而不会有任何被劫持的风险。由于委托仍然是强类型的,结果也可以是强类型的(并由 ORM 断言)。但是,即使不编写 ORM,我们也能使用 LINQ 表达式吗?
LINQ 表达式在许多情况下都很有用。一个例子是它们在 ASP.NET MVC / Razor 视图中的使用方式。在这里,我们需要从给定的模型中选择一个属性。现在,由于 C# 的类型系统相当有限,没有什么办法可以减少(并帮助)开发人员缩小潜在字符串(到所有属性名)的范围。相反,使用了“选择”属性的 LINQ 表达式。
Expression<Func<TModel, TProperty>> selectedProperty = model => model.PropertyName;
现在,我们仍然需要一些魔法来评估它,但是,总的来说,从上面的表达式中获取属性名或信息非常直接:
static PropertyInfo GetPropertyInfo<T, TProperty>
(this T model, Expression<Func<T, TProperty>> propertyLambda)
{
var type = typeof(T);
var member = propertyLambda.Body as MemberExpression ??
throw new ArgumentException($"Expression
'{propertyLambda.ToString()}' refers to a method, not a property.");
var propInfo = member.Member as PropertyInfo ??
throw new ArgumentException($"Expression
'{propertyLambda.ToString()}' refers to a field, not a property.");
if (type != propInfo.ReflectedType && !type.IsSubclassOf(propInfo.ReflectedType))
throw new ArgumentException($"Expression
'{propertyLambda.ToString()}' refers to a property that is not from type {type}.");
return propInfo;
}
问题解决了——仍然是强类型,并且没有使用魔术字符串。
适用于 | 避免用于 |
---|---|
|
|
方法表达式
从 C# 7 开始,更多函数式元素被引入到语言中。这也意味着有更多的表达式(而不仅仅是语句)以及更简短/简洁的语法。这种“清理”并未止步于标准函数。
public static int Foo(int a, int b)
{
return a + b;
}
这是一个非常简单的例子,需要 4 行代码(至少如果我们遵循常见的样式指南)。编译的 MSIL 如下所示:
IL_0000: nop
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: add
IL_0004: stloc.0
IL_0005: br.s IL_0007
IL_0007: ldloc.0
IL_0008: ret
使用方法表达式,我们可以将其减少到 C# 中的一行(而不会违反任何样式指南):
public static int Foo(int a, int b) => a + b;
此外,生成的 MSIL 也有一些不同:
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: add
IL_0003: ret
我们已经在上一篇文章中看到了与属性(或 getter/setter)表达式类似的简化。丢失的 4 条指令都与标准语法中引入的范围有关。
适用于 | 避免用于 |
---|---|
|
|
局部函数
终于!局部函数是函数中的函数。这听起来比实际要简单,但让我们稍等片刻,看看真正的优势。
一个非常简单的例子:
void LongFunction()
{
void Cleanup()
{
// Define cleanup logic here
}
// ... many steps
if (specialCondition)
{
// ...
Cleanup();
return;
}
// ... many steps
if (specialCondition)
{
// ...
Cleanup();
return;
}
// ...many steps
Cleanup();
}
这可能看起来像“糟糕的风格”或函数实现出了问题,但函数可能有很多原因看起来像这样。尽管如此,过去我们不得不诉诸一些非常特殊的模式来实现这一点。我们不得不
- 使用 `goto` 并带有最后的特殊部分(在前面的例子中,我们会称之为清理),或者
- 使用 `using` 语句,并将清理代码放在实现 `IDisposable` 的类的 `Dispose` 方法中。
后者可能带来其他问题(例如,传输所有必需的值)。
所以,这已经是一个很大的进步了,我们可以在可重用代码块内定义一个可重用的代码块。但是,就像匿名函数一样,这样的局部函数能够捕获外部作用域的值。
让我们先看看使用匿名函数进行捕获:
var s = "Hello, ";
var call = new Action<string>(m => (s + m).Dump());
call("world");
生成的 MSIL 代码如下所示:
IL_0000: newobj <>c__DisplayClass0_0..ctor
IL_0005: stloc.0 // CS$<>8__locals0
IL_0006: nop
IL_0007: ldloc.0 // CS$<>8__locals0
IL_0008: ldstr "Hello, "
IL_000D: stfld <>c__DisplayClass0_0.s
IL_0012: ldloc.0 // CS$<>8__locals0
IL_0013: ldftn <>c__DisplayClass0_0.<Main>b__0
IL_0019: newobj System.Action<System.String>..ctor
IL_001E: stloc.1 // call
IL_001F: ldloc.1 // call
IL_0020: ldstr "world"
IL_0025: callvirt System.Action<System.String>.Invoke
IL_002A: nop
IL_002B: ret
<>c__DisplayClass0_0.<Main>b__0:
IL_0000: ldarg.0
IL_0001: ldfld <>c__DisplayClass0_0.s
IL_0006: ldarg.1
IL_0007: call System.String.Concat
IL_000C: call Dump
IL_0011: pop
IL_0012: ret
给定代码中没有太多有趣的部分。大部分部分我们已经知道了,比如一个委托需要先被实例化。然而,在临时(生成的)类中有一行是值得关注的。
在 `IL_000D` 中,我们将常量字符串 `"Hello, "` 赋值给字段 `s`。这就是对外部作用域中的变量 `s` 的捕获!
让我们重写上面的代码来使用局部函数代替:
var s = "Hello, ";
void call(string m)
{
(s + m).Dump();
}
call("world");
现在 MSIL 已经变成了:
IL_0000: nop
IL_0001: ldloca.s 00 // CS$<>8__locals0
IL_0003: ldstr "Hello, "
IL_0008: stfld <>c__DisplayClass0_0.s
IL_000D: nop
IL_000E: ldstr "world"
IL_0013: ldloca.s 00 // CS$<>8__locals0
IL_0015: call <Main>g__call|0_0
IL_001A: nop
IL_001B: ret
<Main>g__call|0_0:
IL_0000: nop
IL_0001: ldarg.1
IL_0002: ldfld <>c__DisplayClass0_0.s
IL_0007: ldarg.0
IL_0008: call System.String.Concat
IL_000D: call Dump
IL_0012: pop
IL_0013: ret
代码短了很多!如果我们仔细看,我们会发现节省的很大一部分来自于不必处理委托(即,没有委托实例,没有 `callvirt`,等等)。
但是等等——还有什么?以前,我们有更多的 `c__DisplayClass0_0` 调用,比如调用它的构造函数。所有这些现在都没了,为什么?原因很简单——生成的 `c__DisplayClass0_0` 不再是一个类,而是一个 `struct`!作为 `struct`,我们不需要任何构造函数调用,因为(实际的)默认构造函数已存在。
我们可以有一个 `struct` 而不是一个类的原因是局部函数始终保持局部。不必担心它在块结束时被销毁。是的,局部函数本身也可以被捕获,但是,在这种情况下,我们有一个不同的结构,我们不会失去一致性。
注意:这里的 `struct` 指的是捕获变量的“持有者”,而不是局部函数的“持有者”。对于 lambda 表达式,这个(捕获变量的)持有者将是一个类。局部函数与 lambda 表达式(已经是委托)的另一个区别是,局部函数可以用 `call` 调用(毕竟它是一个标准函数),而任何委托都将用 `callvirt` 调用(像方法一样)。一旦你把局部函数放入一个委托,后者就适用,你将像调用任何其他委托一样通过 `callvirt` 调用它。因此,在这种情况下,调用局部函数没有好处。它只在直接调用时提供优势。
适用于 | 避免用于 |
---|---|
|
|
Outlook(展望)
在本系列的下一部分中,我们将讨论 `string` 和其他数据类型。
就函数而言,未来可能将进一步减少一些开销(例如,对于委托)。此外,由于委托是类,它们不能简单地相互转换(例如,`Predicate
结论
C# 的演进并未止步于函数。我们从简单的方法发展到具有扩展性、AST 生成、匿名函数的简易语法以及局部可重用块的完整函数。我们已经看到了 C# 从初始版本以来是如何发展的。
兴趣点
我一直展示的是未优化的 MSIL 代码。一旦 MSIL 代码被优化(或者即使在运行时),它可能看起来略有不同。在这里,不同方法之间实际观察到的差异可能会消失。尽管如此,由于我们在本文中专注于开发人员的灵活性和效率(而不是应用程序性能),因此所有建议仍然有效。
如果您在其他模式(例如,发布模式、x86 等)中发现有趣的东西,请发表评论。任何额外的见解总是受欢迎的!
历史
- v1.0.0 | 初始发布 | 2019 年 3 月 31 日
- v1.0.1 | 修正了拼写错误 | 2019 年 4 月 1 日
- v1.1.0 | 添加了目录 | 2019 年 4 月 14 日
- v1.2.0 | 改进了局部函数 | 2019 年 5 月 7 日