C# 代码现代化 - 第三部分:值






4.95/5 (43投票s)
想让你的 C# 代码库现代化吗?让我们继续讨论值。
目录
引言
近年来,C# 从一种只能通过一种特性解决一个问题的语言,成长为一种可以通过多种潜在(语言)解决方案解决一个问题的语言。这既有好处也有坏处。好处是,它赋予我们开发者自由和力量(而不损害向后兼容性);坏处是,它带来了决策的认知负担。
在本系列中,我们将探讨存在哪些选项以及这些选项有何不同。当然,有些选项在特定条件下可能具有优势和劣势。我们将探索这些场景,并提供一个指南,以便在翻新现有项目时让我们的生活更轻松。
这是本系列的第三部分。您可以在 CodeProject 上找到第一部分和第二部分。
背景
过去,我写过许多专门针对 C# 语言的文章。我写过入门系列、高级指南,以及关于特定主题的文章,例如异步/等待或即将推出的功能。在本系列文章中,我希望将所有以前的主题以连贯的方式结合起来。
我觉得讨论新语言功能在哪里大放异彩,以及旧的——我们称之为已有的——功能仍然更受青睐的地方很重要。我可能并不总是对的(特别是,我的一些观点肯定会更主观/品味问题)。像往常一样,欢迎留下评论进行讨论!
让我们从一些历史背景开始。
我所说的“值”是什么意思?
本文不会讨论值类型(struct
)与引用类型(class
),尽管这种区别将发挥关键作用。我们将看到,struct
因各种原因而受到青睐,以及它们如何自然地被引入(已在语言层面)以提高性能。
相反,在本文中,我们主要关注使用的基本类型——从 static readonly
vs. const
的争论开始,然后讨论 string
值;特别是新的内插 string
。
本文的另一个重要支柱将是元组的分析(标准元组与值元组,“匿名”元组与命名元组)。因为 C# 仍在从 F# 和其他语言中汲取灵感,以引入更多函数式概念。这些概念通常以记录(几乎是不可变的 DTO)的形式伴随着值类型数据传输。
有很多内容需要了解——所以让我们从一些基础知识开始,然后再深入细节。
Const 是 Readonly 吗?
C# 包含两种不允许变量重新赋值的方式。我们有 const
和 readonly
。然而,使用这两者之间存在非常重要的区别。
const
是一种编译时机制,仅通过元数据传输。这意味着它没有运行时效应——声明为 const
的原始类型在其实际使用时本质上是原地替换的。没有加载指令。它很像一个内联替换常量。
这也是其应用领域。由于没有加载,确切的值仅在编译时确定,几乎要求所有使用该常量的库在发生更改时重新编译。因此,除非在非常罕见的情况下(例如,像 π
或 e
这样的自然常数),const
不应暴露在库之外。
好处是 const
可以在多个层面工作,例如在类或函数中。由于其内联替换的特性,只允许少数原始类型(例如,字符串、字符、数字、布尔值)。
另一方面,readonly
仅保护变量不被重新赋值。它将被正确引用并生成加载指令。仅凭这一点,可能的值就不受限于某些原始类型。但是,请注意 readonly
与不可变并非相同。事实上,可变性完全由存储的值决定。如果该值是一个允许其字段可变的引用类型(class
),那么我们对 readonly
就无能为力了。
此外,其使用领域与 const
不同。在这种情况下,我们仅限于字段,例如 struct
或 class
的成员。
让我们回顾一下什么时候使用 const
,什么时候更倾向于 readonly
。
readonly | const |
---|---|
|
|
现代方式
值就是值。这一点始终(并将永远)成立。然而,高效地编写和使用值无疑属于编程语言的范畴。因此,在这些领域看到一些增强是自然的。
近年来,C# 更多地被推向函数式领域。由于其命令式特性,我们永远不会看到纯粹的函数式 C# 版本(但是,如果你想更激进,还有 F#),但这并不排除我们可以获得函数式编程的一些优点。
然而,我们在这里所能做的大多数改进都与函数式编程及其所用的模式完全无关。
让我们从普通的数字开始,看看这将走向何方。
数字字面量
标准数字确实是最无聊却又最有趣的话题。这个话题之所以无聊,是因为其核心,数字只是最原始和最基本的信息之一。你面前的这个东西之所以被称为“计算机”是有原因的。数字是基础。通常让数字变得有趣的是引入的抽象或它们所应用的特定算法。
后缀
尽管如此,从编译器的角度来看,数字并非真正的数字。它首先被视为一系列字节(字符),这些字节被正确识别以生成一个特殊的标记——数字字面量。这个标记已经提供了关于数字类型的信息。我们处理的是整数吗?有符号还是无符号?它是浮点数吗?固定精度?机器中数字的大小或特定类型是什么?
虽然整数和浮点数可以通过 .
轻松区分,但所有其他提出的问题都难以回答。因此,C# 引入了特殊后缀。例如,2.1f
与 2.1
在所使用的原始类型方面是不同的。前者是 Single
,而后者是 Double
。
我个人喜欢尽可能使用 var
—— 因此,我总是使用正确的字面量。所以,我不会写...
double sample = 2;
...我总是建议写
double sample = 2.0;
遵循这种方法,我们最终可以轻松地进行正确的类型推断。
var sample = 2.0;
标准的后缀,如 m
(定点浮点数)、d
(双精度浮点数,相当于没有任何后缀的点分数字)、f
(单精度浮点数)、u
(无符号整数)或 l
(“大”整数)都相当为人所知。u
和 l
也可以组合使用(例如,56ul
)。大小写无关紧要,因此根据字体,大写 L
可能更易读。
那么,立即指定正确后缀的原因是什么呢?仅仅是为了满足我个人使用 VIP(尽可能使用 var)风格的习惯吗?我认为还有更多原因。编译器直接“知道”如何使用数字字面量标记。这产生了巨大的影响,因为有些数字无法用非固定精度浮点数表示。最好的例子是 0.1
。通常,这里的舍入误差在序列化(调用 ToString
)中隐藏得很好,但一旦我们执行 0.1 + 0.1 + 0.1
这样的操作,误差就会变得足够大,以至于在输出中无法再隐藏。
使用 0.1m
会将找到的标记在运行时放入 decimal
中。因此,精度是固定的,并且在此过程中不会丢失任何信息(例如,通过使用强制转换 - 因为无法可靠地恢复丢失的信息)。特别是在处理分数非常重要的数字(例如,所有与货币相关的事物)时,我们应该专门使用 decimal
实例。
适用于 | 避免用于 |
---|---|
| |
分隔符
大多数数字字面量应该相当“小”或“简单”,例如 2.5
、0
或 12
。然而,特别是在处理较长的二进制或十六进制整数(例如 0xfabc1234
)时,可读性可能会受到影响。十六进制数字的经典对分组(或十进制数字的3位)。
为了提高可读性,现代 C# 引入了下划线(_
)形式的数字字面量分隔符。
private const Int32 AwesomeVideoMinimumViewers = 4_200_000;
private const Int32 BannedPrefix = 0x_1F_AB_20_FF;
适用于 | 避免用于 |
---|---|
|
|
不幸的是,目前还没有 BigInt
或 Complex
的字面量。Complex
仍然最好通过两个 double
数字(实部和虚部)创建,而 BigInt
最可能最好通过在运行时解析 string
来创建...
简单字符串
string
是最简单数据类型中最复杂的。它是迄今为止使用最多的数据类型——有专门围绕 string
构建的完整语言——并且需要大量的内部技巧才能高效使用。字符串驻留、哈希和固定等特性对于良好运行的 string
实现至关重要。幸运的是,在 .NET 中,我们理所当然地认为所有这些事情都由框架为我们完成了繁重的工作。
.NET string
的分配将始终为每个字符需要 2 字节。这是 UTF-16 模型。一个字符可能是代理项,因此需要另一个字符(换句话说:另外 2 字节)来表示一个可打印字符。
分配还使用了一些技巧,例如加快查找速度。对象头用于存储此类元信息。
C# 中的 string
字面量本身允许两种开关,可以组合使用。一种是“逐字字符串”——前缀为 @
var myString = @"This
is a very long strong
that may contain even ""quotes"" and other things if properly escaped...
See you later!";
另一个开关是用于“内插 string
”,将在下一节中讨论。
从 MSIL 的角度来看,逐字 string
字面量和标准 string
字面量的行为完全相同。区别仅在于 C# 编译器的处理方式。
让我们看一个 C# 代码示例来确认这一点
var foo = @"foo
bar";
var FOO = foo.ToUpper();
生成的 MSIL 包含一个标准的 MSIL string
字面量。在 MSIL 中,没有 string
字面量的开关。
IL_0001: ldstr "foo\r\nbar"
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: callvirt System.String.ToUpper
在逐字字面量中,转义序列不起作用。因此,字面量 string
非常适合 Windows 路径
var myPathDoesNotWork = "C:\Foo\Bar"; //Ouch, has to be C:\\Foo\\Bar - makes
//copy / paste difficult ...
var myPathWorks = @"C:\Foo\Bar";
由于双引号也需要转义序列,因此必须引入一种在 string
中书写双引号的新方法。在逐字 string
中,两个双引号(“双重双引号”或“四重引号”)表示一个双引号。
var withDoubleQuotes = @"This is ""madness"", isn't it?";
大多数情况下,我们不会使用逐字字面量,尽管通常它们可能最有意义(由于支持换行符字面量而非转义序列,对标准文本的复制/粘贴支持更简单)。
逐字 | 标准版 |
---|---|
|
|
通常,string
类型是 UTF-16(即,固定编码)表示的字符串片段的理想选择,它不需要任何编译时替换,并表示一个简单的字符序列。
我们应该避免将 String
类型用于多次(可能未知多次)连接。这会产生大量垃圾,应替换为专门的类型(StringBuilder
)来完成这项工作。
内插字符串
除了 @
之外,string
字面量还有另一个潜在的开关:$
切换到内插 string
模式。这两个开关也可以协同工作
var foo = $@"foo
bar{'!'}";
如果是内插 string
,编译器会进行更严格的评估。如果没有指定替换(在花括号 {}
中给出),将生成一个简单的 string
。
如果存在替换,每个替换都将被评估并放入一个传统的格式化 string
中。因此,编译器会生成上述代码
var arg0 = '!';
var foo = string.Format(@"foo
bar{0}", arg0);
自然地,编译器可以在描述的情况下进行更多优化(毕竟,我们有一个常量字符),但是,编译器不是为直截了当的情况而构建,而是为常见情况而构建
void Example(string input)
{
var foo = $"There is the input: {input}!";
}
生成的 MSIL 显示与之前生成的代码没有区别——使得它通常可以独立于插入的表达式(常量或非常量)使用。
Example:
IL_0000: nop
IL_0001: ldstr "There is the input: {0}!"
IL_0006: ldarg.1
IL_0007: call System.String.Format
IL_000C: stloc.0
到目前为止,这相当无趣。只是 Format
函数的一个简单包装。然而,如果我们不选择 string
(或 var
),还有另一种选择。如果我们将目标设置为 FormattableString
或 IFormattable
,C# 编译器将生成不同的代码
void Example(string input)
{
FormattableString foo = $"There is the input: {input}!";
}
在这种情况下,生成的 MSIL 看起来相当不同
Example:
IL_0000: nop
IL_0001: ldstr "There is the input: {0}!"
IL_0006: ldc.i4.1
IL_0007: newarr System.Object
IL_000C: dup
IL_000D: ldc.i4.0
IL_000E: ldarg.1
IL_000F: stelem.ref
IL_0010: call System.Runtime.CompilerServices.FormattableStringFactory.Create
IL_0015: stloc.0
请注意,参数需要以(对象)数组的形式传递。stelem.ref
代码将用堆栈上的元素替换索引为 0
的元素。
这种数据结构可以用于自定义格式化格式化 string
中使用的不同值。
例如,以下代码(假设只有一个参数——迭代 ArgumentCount
更合理)将第一个参数转换为大写。
void Example(string input)
{
var foo = CustomFormatter($"There is the input: {input}!");
}
string CustomFormatter(FormattableString str)
{
var arg = str.GetArgument(0).ToString().ToUpper();
return string.Format(str.Format, arg);
}
否则,给定的格式化程序可以很容易地与格式化字面量一起使用(并且与 string.Format
调用完全相同)。
同样,我们可以将 IFormattable
的 ToString
与 CultureInfo
或通用的 IFormatProvider
一起使用,以获取特定于文化的序列化。
var speedOfLight = 299792.458;
FormattableString message = $"The speed of light is {speedOfLight:N3} km/s.";
var specificCulture = System.Globalization.CultureInfo.GetCultureInfo("de-DE");
var germanMessage = message.ToString(specificCulture);
// germanMessage is "The speed of light is 299.792,458 km/s."
内插 string
是一种在保持可读性的同时,至少与之前使用普通 String.Format
函数一样强大的绝佳方式。
由于花括号内的表达式替换对冒号三元表达式(如 a ? b : c
)具有特殊含义,因此应避免在内插 string
中使用此类表达式。相反,此类表达式应事先求值,并且只引用一个简单变量。
重要:用于多个 string
连接的传统 String.Concat()
调用不应被强制替换,例如,
// don't, but better than a + b + c + d + e + f
var str = $"{a}{b}{c}{d}{e}{f}";
// better ...
var str = string.Concat(a, b, c, d, e, f);
// best - maybe ...
var str = new StringBuilder()
.Append(a)
.Append(b)
.Append(c)
.Append(d)
.Append(e)
.Append(f)
.ToString()
其中 StringBuilder
是最后的选择——要么是未知数量的添加,要么是 String.Concat(...)
本质上等同于 String.Join(String.Empty, new String [] { ... })
。
适用于 | 避免用于 |
---|---|
|
|
字符串视图
有时,我们只想在 string
的一部分中导航。以前,有两种方法可以解决这个问题
- 使用
Substring
方法获取所需的子string
作为新的string
- 这将分配一个新的string
- 创建中间数据结构或局部变量来表示当前索引——这将分配临时变量
使用新 string
的方法可能非常占用内存,因此不受欢迎。使用局部变量的方法无疑效率很高,但是,它需要更改算法,并且可能很难正确实现。
由于这是一个在各种解析器中都会出现的问题,C# / Roslyn 团队考虑了一个潜在的解决方案。结果是一个名为 Span
的新数据类型,它可以用于任何内存视图。
本质上,这种类型以最有效的方式为我们提供了所需的快照。更好的是,这个简单的值类型允许我们处理任何类型的连续内存
- 非托管内存缓冲区
- 数组和子数组
- 字符串和子字符串
开始使用 Span
需要两个先决条件。我们需要安装最新的 System.Memory
NuGet 包,并将语言版本设置为 C# 7.2(或更高版本)。
由于内存视图需要 ref
返回值,因此只有 .NET Core 2+ 本身支持此功能(即,预计 .NET Core 2+ 与具有旧版 GC 和 .NET 运行时的其他平台之间存在性能差异)。
Span
背后的魔力在于我们实际上可以返回直接引用。通过 ref
返回,它看起来如下
ref T this[int index] => ref ((ref reference + byteOffset) + index * sizeOf(T));
这个表达式与我们在旧 C 语言中学习迭代内存数组的方式非常相似。
回到拥有 string
视图的话题。让我们考虑一下旧示例
public static string MySubstring(this string text, int startIndex, int length)
{
var result = new string(length);
Memory.Copy(source: text, destination: result, startIndex, length);
return result;
}
当然,这不是真正的代码。这只是为了说明想法。真正的代码看起来更像
public static string MySubstring(this string text, int startIndex, int length)
{
var result = new char[length];
Array.Copy(text.ToCharArray(), startIndex, result, 0, length);
return new string(result);
}
这更糟糕。我们不仅有 1 次分配(new string
),还有两次额外的分配(new char[]
,ToCharArray()
)。千万不要在家里这样做!
MSIL 也看起来不怎么样。特别是,我们混合了 callvirt
和构造函数。
IL_0000: nop
IL_0001: ldarg.2
IL_0002: newarr System.Char
IL_0007: stloc.0 // result
IL_0008: ldarg.0
IL_0009: callvirt System.String.ToCharArray
IL_000E: ldarg.1
IL_000F: ldloc.0 // result
IL_0010: ldc.i4.0
IL_0011: ldarg.2
IL_0012: call System.Array.Copy
IL_0017: nop
IL_0018: ldloc.0 // result
IL_0019: newobj System.String..ctor
IL_001E: stloc.1
IL_001F: br.s IL_0021
IL_0021: ldloc.1
IL_0022: ret
那么,使用 str.AsSpan().Slice()
代替 str.Substring()
,我们牺牲了什么呢?灵活性。由于 Span
保持着直接引用,我们不允许将其放在堆上。因此,存在一些规则
- 不能装箱
- 没有泛型类型
- 它不能是类或非
ref struct
类型中的字段 - 不能在
async
方法中使用 - 它不能实现任何现有接口
这太长了!因此,最终,我们只能直接或间接将其用作参数。
适用于 | 避免用于 |
---|---|
|
|
内存视图
虽然 Span
是与 stackalloc
关键字一起引入的,但它也允许直接使用任何 T[]
数组类型。
Span<byte> stackMemory = stackalloc byte[256];
如前所述,这与堆栈生命周期密切相关,因此在使用上存在严重限制。
尽管如此,通过这种机制使用 .NET 堆放置数组的可能性给了我们一个想法——为什么没有一个具有类似目的但没有这些限制的类型呢?
让我们先看看与 .NET 标准数组的使用情况
Span<char> array = new [] { 'h', 'e', 'l', 'l', 'o' };
现在,有了新的 Memory
类型,我们实际上可以传递一个最终在需要时进行转换的 Span
。
这个想法是,内存被传递并在堆上存储,但最终我们希望获得性能,并在一个定义明确的短时间内获取视图(跨度)。
API 相当简单。我们可以将 .NET 数组以及此内存区域的“视图”(起始和长度)传递给例如 ReadOnlyMemory
的构造函数。通过 Span
属性,我们可以获得一个直接引用视图而无需任何额外分配的中间表示。
void StartParsing(byte[] buffer)
{
var memory = new ReadOnlyMemory<byte>(buffer, start, length);
ParseBlock(memory);
}
void ParseBlock(ReadOnlyMemory<byte> memory)
{
ReadOnlySpan<byte> slice = memory.Span;
//...
}
结果,即使是托管内存视图也相当容易实现,并且最终比以前性能更好。
适用于 | 避免用于 |
---|---|
|
|
简单元组
C# 中一个常见的问题是(或曾经是)无法返回多个值。为了首先解决这个问题,C# 团队提出了 out
参数,它在 ref
参数的基础上提供了一些额外的语法糖。
简而言之,一旦一个参数被定义为 out
参数,它就需要在函数内部被赋值。为了方便,C# 团队添加了内联声明这种变量的能力。
这是我们通常可以编写的代码
void Test()
{
var result1 = default(int);
var result2 = ReturnMultiple(out result1);
}
bool ReturnMultiple(out int result)
{
result = 0;
return true;
}
一个更现代的版本看起来像
void Test()
{
var result2 = ReturnMultiple(out var result1);
}
虽然预初始化的 MSIL 如下所示,但更现代的 MSIL 省略了两条指令
// pre-initialization
IL_0000: nop
IL_0001: ldc.i4.0
IL_0002: stloc.0 // result1
IL_0003: ldarg.0
IL_0004: ldloca.s 00 // result1
IL_0006: call ReturnMultiple
IL_000B: stloc.1 // result2
IL_000C: ret
// modern version
IL_0000: nop
IL_0001: ldarg.0
IL_0002: ldloca.s 01 // result1
IL_0004: call ReturnMultiple
IL_0009: stloc.0 // result2
IL_000A: ret
诚然,out
变量确实有帮助,但它们带来了一些其他缺点,并且不能提高可读性。为此,我们也可以引入中间数据类型,但是,一旦我们大量使用返回多个值的模式,中间类型的数量就会比必要的多得多。
解决这个问题的一种方法是通过“泛型”中间数据类型——元组——来包含一些值。在 .NET 中,我们有各种形式的 Tuple
类型(例如,Tuple
、Tuple
等)。我们可以将其视为与 Func
及其泛型变体等效的数据载体。
上述问题可以因此改为
void Test()
{
var result = ReturnMultiple();
var result1 = result.Item1;
var result2 = result.Item2;
}
Tuple<bool, int> ReturnMultiple() => Tuple.Create(true, 0);
很明显,上述 Test
方法中的代码从 MSIL 的角度来看更复杂
IL_0000: nop
IL_0001: ldarg.0
IL_0002: call ReturnMultiple
IL_0007: stloc.0 // result
IL_0008: ldloc.0 // result
IL_0009: callvirt System.Tuple<System.Boolean,System.Int32>.get_Item1
IL_000E: stloc.1 // result1
IL_000F: ldloc.0 // result
IL_0010: callvirt System.Tuple<System.Boolean,System.Int32>.get_Item2
IL_0015: stloc.2 // result2
IL_0016: ret
然而,ReturnMultiple
方法中的代码看起来稍微简单一些。尽管如此,我们仍然要支付分配 Tuple
对象的成本。对象分配仍然是需要尽量减少的事情之一。
另一个明显的缺点是奇怪的命名。什么是 Item1
,什么是 Item2
?如果元组仅在被调用者中使用,则问题不大,但是一旦我们传递元组,问题就会很快出现。在给定情况下,不同的类型有所帮助,但通常两个或更多项将具有相同的类型。
适用于 | 避免用于 |
---|---|
|
|
值元组
以前方法的一个缺点是对象分配。因此,值类型可能很有用。这就是 ValueTuple
被引入的原因。
有了这种新类型,我们只需将上一个示例更改为
void Test()
{
var result = ReturnMultiple();
var result1 = result.Item1;
var result2 = result.Item2;
}
ValueTuple<bool, int> ReturnMultiple() => ValueTuple.Create(true, 0);
这种数据类型非常有趣,以至于 C# 引入了一些语法糖来指定、创建和使用它。让我们从规范开始
(bool, int) ReturnMultiple() => ValueTuple.Create(true, 0);
太棒了,所以我们可以通过使用括号轻松指定多个返回值(或者更具体地说,一个 ValueType
)!接下来是创建。我们能做得更好吗?
(bool, int) ReturnMultiple() => (true, 0);
哇,感觉几乎是函数式的!太棒了,那么现在,我们如何更优雅地使用它呢?
void Test()
{
var (result1, result2) = ReturnMultiple();
}
(bool, int) ReturnMultiple() => (true, 0);
几乎太简单了,对吗?这种语法被称为解构。我想我们将在 C# 的后续版本中看到更多这种用法(当然,在其他变体和用例中)。
我们完成了!尽管如此,对于直接使用,即非解构用例,命名仍然不尽如人意。
适用于 | 避免用于 |
---|---|
|
|
命名元组
到目前为止,不同项目的命名一直是需要解决的主要缺点。为此,C# 语言设计团队引入了“命名元组”,它为我们提供了一种在 ValueType
上声明(假)名称的方式。这些名称仅在编译时解析。
元组项的名称可以添加到元组规范中的任何位置,例如,如果只希望命名第一个项,则可以自由这样做
(bool success, int) ReturnMultiple() => (true, 0);
此处保留了标准的“左侧类型”规则。在 C# 中,我们仍然(像在其他 C 语言中一样)遵循“类型标识符”的模式(例如,与 TypeScript 比较,后者使用“identifier: type
”并基于右侧)。
命名不能用于值的创建,它必须是顺序的。此外,它对解构没有影响。
void Test()
{
var (foo, bar) = ReturnMultiple();
}
(bool Success, int Value) ReturnMultiple() => (true, 0);
这样的命名元组可以任意传递。考虑这个例子
void Test()
{
var result = ReturnMultiple();
var (foo, bar) = result;
AnotherFunction(result);
}
void AnotherFunction((bool Success, int Value) tuple)
{
// ...
}
这种语法糖(在普通 ValueTuple
之上)提供了性能和便利性,使其成为理想的替代方案,而在此之前,只有 out
才能提供帮助。
适用于 | 避免用于 |
---|---|
|
|
Outlook(展望)
在本系列的下一部分(很可能是最后一部分)中,我们将讨论异步代码和特殊的代码结构,例如模式匹配或可空类型。
就值而言,C# 的演变似乎尚未完成。像 F# 等语言中存在的真正记录和其他基本类型非常有用,它们很难被错过。我个人缺少的是在语言层面处理这些视图(Span
)的基本类型。
此外,如果能为 BigInt
提供一个字面量(后缀)(也许是 b
?),那就太好了。Complex
也一样,自然会用 i
,这样 5i
就等同于 new Complex(0, 5.0)
。
结论
C# 的演进并未停留在所使用的值上。我们看到 C# 为我们提供了一些更高级的技术,可以在不影响性能的情况下获得灵活性。框架在切片方面的帮助也派上了用场。
以前使用 String.Format
格式化 string
的方式不应再使用。内插 string
提供了许多不错的优势。返回多个值从未如此简单。从中产生哪些模式仍有待确定。结合局部函数和属性领域的演进,C# 语言已经感觉充满活力。
兴趣点
我总是展示未优化的 MSIL 代码。一旦 MSIL 代码被优化(或甚至正在运行),它可能会看起来有点不同。在这里,不同方法之间实际观察到的差异实际上可能会消失。然而,由于本文侧重于开发人员的灵活性和效率(而不是应用程序性能),因此所有建议仍然适用。
如果您在其他模式(例如,发布模式、x86 等)中发现有趣的东西,请发表评论。任何额外的见解总是受欢迎的!
历史
- v1.0.0 | 首次发布 | 2019年5月17日
- v1.1.0 | 添加目录 | 2019年5月18日
- v1.1.1 | 更正代码中的拼写错误 | 2019年5月22日
- v1.1.2 | 更正拼写 | 2019年5月23日
- v1.1.3 | 修复代码中的拼写错误 | 2019年5月25日