65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (35投票s)

2019 年 3 月 31 日

CPOL

16分钟阅读

viewsIcon

53980

想现代化你的 C# 代码库吗?让我们继续讲方法。

Modernizing C# Code

目录

引言

近年来,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

方法和函数之间有两个关键区别

  1. 方法需要加载它所属的实例。这将是隐式的第一个参数。
  2. 方法总是使用 `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` 的人只需要负责一个方法,并且可以免费获得我们的便捷方法。

适用于 避免用于
  • 通用接口方法
  • 至少有 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

我们看到生成的类实际上包含相当多的功能(还有 `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;
}

问题解决了——仍然是强类型,并且没有使用魔术字符串。

适用于 避免用于
  • 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` 并带有最后的特殊部分(在前面的例子中,我们会称之为清理),或者
  • 使用 `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` 不能转换为 `Func`,即使它们表示相同的签名)。也许这些问题将在更丰富的类型系统的支持下得到解决。

结论

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 日
© . All rights reserved.