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

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (43投票s)

2019年5月17日

CPOL

21分钟阅读

viewsIcon

52096

想让你的 C# 代码库现代化吗?让我们继续讨论值。

Modernizing C# Code

目录

引言

近年来,C# 从一种只能通过一种特性解决一个问题的语言,成长为一种可以通过多种潜在(语言)解决方案解决一个问题的语言。这既有好处也有坏处。好处是,它赋予我们开发者自由和力量(而不损害向后兼容性);坏处是,它带来了决策的认知负担。

在本系列中,我们将探讨存在哪些选项以及这些选项有何不同。当然,有些选项在特定条件下可能具有优势和劣势。我们将探索这些场景,并提供一个指南,以便在翻新现有项目时让我们的生活更轻松。

这是本系列的第三部分。您可以在 CodeProject 上找到第一部分第二部分

背景

过去,我写过许多专门针对 C# 语言的文章。我写过入门系列高级指南,以及关于特定主题的文章,例如异步/等待即将推出的功能。在本系列文章中,我希望将所有以前的主题以连贯的方式结合起来。

我觉得讨论新语言功能在哪里大放异彩,以及旧的——我们称之为已有的——功能仍然更受青睐的地方很重要。我可能并不总是对的(特别是,我的一些观点肯定会更主观/品味问题)。像往常一样,欢迎留下评论进行讨论!

让我们从一些历史背景开始。

我所说的“值”是什么意思?

本文不会讨论值类型(struct)与引用类型(class),尽管这种区别将发挥关键作用。我们将看到,struct 因各种原因而受到青睐,以及它们如何自然地被引入(已在语言层面)以提高性能。

相反,在本文中,我们主要关注使用的基本类型——从 static readonly vs. const 的争论开始,然后讨论 string 值;特别是新的内插 string

本文的另一个重要支柱将是元组的分析(标准元组与值元组,“匿名”元组与命名元组)。因为 C# 仍在从 F# 和其他语言中汲取灵感,以引入更多函数式概念。这些概念通常以记录(几乎是不可变的 DTO)的形式伴随着值类型数据传输。

有很多内容需要了解——所以让我们从一些基础知识开始,然后再深入细节。

Const 是 Readonly 吗?

C# 包含两种不允许变量重新赋值的方式。我们有 constreadonly。然而,使用这两者之间存在非常重要的区别。

const 是一种编译时机制,仅通过元数据传输。这意味着它没有运行时效应——声明为 const 的原始类型在其实际使用时本质上是原地替换的。没有加载指令。它很像一个内联替换常量。

这也是其应用领域。由于没有加载,确切的值仅在编译时确定,几乎要求所有使用该常量的库在发生更改时重新编译。因此,除非在非常罕见的情况下(例如,像 πe 这样的自然常数),const 不应暴露在库之外。

好处是 const 可以在多个层面工作,例如在类或函数中。由于其内联替换的特性,只允许少数原始类型(例如,字符串、字符、数字、布尔值)。

另一方面,readonly 仅保护变量不被重新赋值。它将被正确引用并生成加载指令。仅凭这一点,可能的值就不受限于某些原始类型。但是,请注意 readonly 与不可变并非相同。事实上,可变性完全由存储的值决定。如果该值是一个允许其字段可变的引用类型(class),那么我们对 readonly 就无能为力了。

此外,其使用领域与 const 不同。在这种情况下,我们仅限于字段,例如 structclass 的成员。

让我们回顾一下什么时候使用 const,什么时候更倾向于 readonly

readonly const
  • 通用“常量”
  • 在不同程序集之间共享值(例如,strings)
  • 类型中的字段以简化开发
  • 真正的常数(π,e,...)
  • 在库内部(internal)/类内部(private)用于高性能重用

现代方式

值就是值。这一点始终(并将永远)成立。然而,高效地编写和使用值无疑属于编程语言的范畴。因此,在这些领域看到一些增强是自然的。

近年来,C# 更多地被推向函数式领域。由于其命令式特性,我们永远不会看到纯粹的函数式 C# 版本(但是,如果你想更激进,还有 F#),但这并不排除我们可以获得函数式编程的一些优点。

然而,我们在这里所能做的大多数改进都与函数式编程及其所用的模式完全无关。

让我们从普通的数字开始,看看这将走向何方。

数字字面量

标准数字确实是最无聊却又最有趣的话题。这个话题之所以无聊,是因为其核心,数字只是最原始和最基本的信息之一。你面前的这个东西之所以被称为“计算机”是有原因的。数字是基础。通常让数字变得有趣的是引入的抽象或它们所应用的特定算法。

后缀

尽管如此,从编译器的角度来看,数字并非真正的数字。它首先被视为一系列字节(字符),这些字节被正确识别以生成一个特殊的标记——数字字面量。这个标记已经提供了关于数字类型的信息。我们处理的是整数吗?有符号还是无符号?它是浮点数吗?固定精度?机器中数字的大小或特定类型是什么?

虽然整数和浮点数可以通过 . 轻松区分,但所有其他提出的问题都难以回答。因此,C# 引入了特殊后缀。例如,2.1f2.1 在所使用的原始类型方面是不同的。前者是 Single,而后者是 Double

我个人喜欢尽可能使用 var —— 因此,我总是使用正确的字面量。所以,我不会写...

double sample = 2;

...我总是建议写

double sample = 2.0;

遵循这种方法,我们最终可以轻松地进行正确的类型推断。

var sample = 2.0;

标准的后缀,如 m(定点浮点数)、d(双精度浮点数,相当于没有任何后缀的点分数字)、f(单精度浮点数)、u(无符号整数)或 l(“大”整数)都相当为人所知。ul 也可以组合使用(例如,56ul)。大小写无关紧要,因此根据字体,大写 L 可能更易读。

那么,立即指定正确后缀的原因是什么呢?仅仅是为了满足我个人使用 VIP(尽可能使用 var)风格的习惯吗?我认为还有更多原因。编译器直接“知道”如何使用数字字面量标记。这产生了巨大的影响,因为有些数字无法用非固定精度浮点数表示。最好的例子是 0.1。通常,这里的舍入误差在序列化(调用 ToString)中隐藏得很好,但一旦我们执行 0.1 + 0.1 + 0.1 这样的操作,误差就会变得足够大,以至于在输出中无法再隐藏。

使用 0.1m 会将找到的标记在运行时放入 decimal 中。因此,精度是固定的,并且在此过程中不会丢失任何信息(例如,通过使用强制转换 - 因为无法可靠地恢复丢失的信息)。特别是在处理分数非常重要的数字(例如,所有与货币相关的事物)时,我们应该专门使用 decimal 实例。

适用于 避免用于
  • 与 VIP 风格结合使用
  • 避免不必要的类型转换
  • 表达力

分隔符

大多数数字字面量应该相当“小”或“简单”,例如 2.5012。然而,特别是在处理较长的二进制或十六进制整数(例如 0xfabc1234)时,可读性可能会受到影响。十六进制数字的经典对分组(或十进制数字的3位)。

为了提高可读性,现代 C# 引入了下划线(_)形式的数字字面量分隔符。

private const Int32 AwesomeVideoMinimumViewers = 4_200_000;

private const Int32 BannedPrefix = 0x_1F_AB_20_FF;
适用于 避免用于
  • 使大数字更易读
  • 以不同方式编码数字

不幸的是,目前还没有 BigIntComplex 的字面量。Complex 仍然最好通过两个 double 数字(实部和虚部)创建,而 BigInt 最可能最好通过在运行时解析 string 来创建...

简单字符串

string 是最简单数据类型中最复杂的。它是迄今为止使用最多的数据类型——有专门围绕 string 构建的完整语言——并且需要大量的内部技巧才能高效使用。字符串驻留、哈希和固定等特性对于良好运行的 string 实现至关重要。幸运的是,在 .NET 中,我们理所当然地认为所有这些事情都由框架为我们完成了繁重的工作。

.NET string 的分配将始终为每个字符需要 2 字节。这是 UTF-16 模型。一个字符可能是代理项,因此需要另一个字符(换句话说:另外 2 字节)来表示一个可打印字符。

分配还使用了一些技巧,例如加快查找速度。对象头用于存储此类元信息。

Is ASCII Flag in .NET String Allocation

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),还有另一种选择。如果我们将目标设置为 FormattableStringIFormattable,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 调用完全相同)。

同样,我们可以将 IFormattableToStringCultureInfo 或通用的 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 [] { ... })

适用于 避免用于
  • 编译时表达式替换
  • 灵活的序列化
  • 包含花括号的简单 strings
  • 使用内插 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 类型(例如,TupleTuple 等)。我们可以将其视为与 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日
© . All rights reserved.