目录
介绍
背景
什么是方法?
现代方式
扩展方法
委托
Lambda表达式
LINQ表达式
方法表达式
局部函数
结论
近年来,C#已经从一种具有一个功能的语言发展成为一种语言,其中包含针对单个问题的许多潜在(语言)解决方案。这既好又坏。很好是因为它给了我们成为开发人员的自由和权力(不会影响向后兼容性),并且由于与决策相关的认知负荷而导致不好。
在本系列中,我们希望了解存在哪些选项以及这些选项的不同之处。当然,在某些条件下,某些人可能有优点和缺点。我们将探索这些场景,并提出一个指南,以便在翻新现有项目时让我们的生活更轻松。
这是该系列的第二部分。您也可以查看第一部分。
背景我觉得重要的是要讨论新语言功能的发展方向以及旧语言的位置——让我们称之为已建立 ——这些仍然是首选。我可能并不总是对的(特别是,因为我的一些观点肯定会更主观/是品味问题)。像往常一样,留下评论讨论将不胜感激!
让我们从一些历史背景开始。
什么是方法?我们常常看到人们对术语——“方法”与“功能”感到困惑。实际上,每个方法都是一个函数,但并不是每个函数都是一个方法。方法是一种特殊的函数,它具有隐含的第一个参数,称为“上下文”。上下文被赋予this并且与指定方法的类的实例相关联。因此,(真实)方法只能存在于类中,而static方法应该被称为函数。
虽然人们通常认为this保证非null的,但实际上是人为的限制。实际上,运行时对第一个参数执行一些隐式(通过使方法的调用成为callvirt指令)检查,但是,理论上这可以很容易地避开。
使用隐式this参数,可以派生出一种特殊的语法。我们发现c.f(a, b, ...) 而不是调用一个函数f如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关键字是指类的当前实例,并且也用作扩展方法的第一个参数的修饰符。
我们稍后将介绍扩展方法。现在让我们通过以下示例概述标准方法与函数(即静态“方法”):
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的东西,隐式的(至少在开始时)仅在编译器的处理中。运行时可以稍后使用此信息进行进一步优化,但由于某些情况,不会产生直接影响。
让我们回顾一下函数应该优于方法。
非常有用
应避免的
- 小的帮助类
- 独立于特定(类)实例
- 没有与virtual 调用相关的成本/检查
- 如果你寻找继承
标准方法和功能在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);
}
在这里,我们已经告诉实现方有两种方法而不是一种方法。我想这个接口的几乎所有实现实际上看起来如下:
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只需要处理单一方法并免费获得我们的便利方法。
非常有用
应避免的
- 通用接口方法
- 具有至少1个参数的辅助方法
- 替换普通的类实例方法
在上一节中,我们触及了扩展方法的重要性及其对可读代码的含义(从左到右而不是由内向外)。该示例使用类似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
我们看到生成的类实际上包含了一些功能(也是静态成员)。更重要的是,还有另外两种方法——BeginInvoke和EndInvoke。最后,创建委托不是自由的——它实际上是生成的类的对象创建。调用委托实际上与调用Invoke类上的方法相同。因此,这是virtual 调用并且比调用(例如,函数)更昂贵。
到目前为止,我们只看到了委托是什么以及它是如何声明的。实际上,大多数时候我们不需要自己声明委托。我们可以使用内置的通用声明:
- 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一起引入(C#3非常好,对吧?):它是LINQ表达式!这不是关于查询语法与直接使用扩展方法或类似方法,而是ORM如何使用LINQ。
问题如下:在将LINQ引入C#之前,我们主要是在C#中直接编写SQL查询。虽然这肯定有一些优点(完全访问我们的数据库中提供的所有功能),但缺点是非常真实的:
- 没有编译器的帮助
- 潜在的安全问题
- 结果没有是静态类型的
使用LINQ,这已经通过引入LINQ表达式来解决,LINQ表达式是一种不向MSIL编译匿名函数的方法,而是将生成的AST转换为对象。
尽管这是一个编译器功能,但这一切都归结为使用正确的类型。早些时候,我们已经看到了泛型委托,例如Func或Action允许我们避免再次编写它们。如果我们将这样的委托打包到Expression类型中,我们最终会得到一个AST持有者。
关于这样一个简单示例(实际上,下面的并没有真正编译,因为我们需要右侧的表达式,但这个想法应该是可见的):
Expression 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
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
IL_0058: stloc.0 // foo
IL_0059: ret
从本质上讲,此调用的整个生成的AST现在以对象格式提供——因此包含在MSIL中。
ORM可以检查此信息以创建优化的查询,从而安全地传输变量和特殊字段,而不会以任何形式劫持。由于委托仍然是强类型的,因此结果可以强类型化(并由ORM声明)。但是,即使不编写ORM,我们也可以使用LINQ表达式吗?
在许多情况下,LINQ表达式可以派上用场。一个例子是它们在ASP.NET MVC / Razor视图中的使用方式。在这里,我们需要从给定模型中选择一个属性。现在,由于C#的类型系统相当有限,因此没有办法减少(并帮助)开发人员缩小潜在字符串(对所有属性名称)。相反,使用LINQ表达式“选择”该属性。
Expression selectedProperty = model => model.PropertyName;
现在我们仍然需要一些魔术来评估它,但是,一般来说,从上面给定的表达式获取属性名称或信息是非常简单的:
static PropertyInfo GetPropertyInfo
(this T model, Expression 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;
}
问题解决了——仍然是强类型的,没有使用魔术字符串。
非常有用
应避免的
- ORM映射
- 与外部系统通信
- 规避型系统限制
- 实际调用的函数
从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(在前一个例子中我们称之为清理),或者
- 在正在实现的IDisposable类的Dispose方法中使用using语句和清理代码。
后者可能带来其他问题(例如,传输所有必需的值)。
所以,这已经是一个巨大的胜利,我们可以在可重用代码中定义一个可重用代码块。但就像匿名函数一样,这样的局部函数能够从外部作用域捕获值。
让我们看一下使用匿名函数捕获:
var s = "Hello, ";
var call = new Action(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.b__0
IL_0019: newobj System.Action..ctor
IL_001E: stloc.1 // call
IL_001F: ldloc.1 // call
IL_0020: ldstr "world"
IL_0025: callvirt System.Action.Invoke
IL_002A: nop
IL_002B: ret
c__DisplayClass0_0.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 g__call|0_0
IL_001A: nop
IL_001B: ret
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的不再是类,而是结构!作为结构,我们不需要任何构造函数调用,因为(实际)默认构造函数存在。
我们可以拥有结构而不是类的原因是局部函数仍然是局部的。没有必要担心它在代码块结束时被销毁。是的,局部函数可以自己捕获,但是,在本例中,我们有不同的结构,我们不会失去一致性。
非常有用
应避免的
- 可重复使用的代码块
- 匿名函数
C#的演变并没有停止在函数上。我们从简单的方法到完整的函数,增加了可扩展性,AST生成,匿名函数的简单语法和局部可重用代码块。我们已经看到C#从最初版本开始如何发展。
下一篇:使C#代码现代化——第三部分:值
原文地址:https://www.codeproject.com/Articles/1342509/Modernize-Your-Csharp-Code-Part-II-Methods