目录
介绍
近年来,C#已经从一种具有一个功能的语言发展成为一种语言,其中包含针对单个问题的许多潜在(语言)解决方案。这既好又坏。很好是因为它给了我们成为开发人员的自由和权力(不会影响向后兼容性),并且由于与决策相关的认知负荷而导致不好。
在本系列中,我们希望了解存在哪些选项以及这些选项的不同之处。当然,在某些条件下,某些人可能有优点和缺点。我们将探索这些场景,并提出一个指南,以便在翻新现有项目时让我们的生活更轻松。
这是该系列的第三部分。您可以在我的博客上找到第一部分和第二部分。
背景
我觉得重要的是要讨论新的语言特征在何处闪耀,以及旧的——我们称之为已建立的——特征在哪里仍然是首选。我可能并不总是对的(特别是,因为我的一些观点肯定会更主观/更具品位)。像往常一样留下评论讨论将不胜感激!
让我们从一些历史背景开始。
我说的值是什么意义?
本文将不涉及值类型(struct)与引用类型(class),即使这种区别将起到至关重要的作用。我们将看到structs的需求有各种原因,以及它们是如何被自然地引入(已经在语言级别)来提高性能的。
相反,在本文中,我们通常关注使用的原语——从static readonly与const辩论开始,然后讨论string 值; 特别是使用新的插值字符串。
本文的另一个重要支柱是元组分析(标准元组与值元组,“匿名”与命名元组)。由于C#仍在从F#和其他语言中引入一些灵感来引入更多功能概念。这些概念通常伴随着记录形式的值类型数据传输(几乎不可变的DTO)。
有很多东西要看——所以让我们先从一些基础知识开始,然后再详细介绍。
Const是只读的吗?
C#包含两种不允许重新分配变量的方法。我们有const和readonly。然而,使用这两者有非常重要的区别。
const是一种编译时机制,仅通过元数据传输。这意味着它没有运行时效果——const声明的原语基本上被放在它的使用中。没有加载指令。它几乎是一个内联替换常量。
这也是作用域发挥作用的地方。由于没有加载,确切的值仅在编译时确定,几乎要求所有使用该常量的库在发生更改时重新编译。因此,const除非在极少数情况下(例如,自然常数,如π或e),否则不应暴露在库外。
好处是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(如果可能的话)风格的习惯?我认为还有更多。编译器直接“看到”如何使用数字文字标记。这具有巨大的影响,因为某些数字不能用非固定精度浮点数表示。最好的例子是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仍然通过两个双重数字(实部和虚部)创建最佳,而BigInt最有可能最好通过在运行时解析字符串来创建...
简单的字符串
string是最复杂的最简单的数据类型。它是迄今为止最常用的数据类型——只有围绕字符串构建的整个语言——并且需要大量内部技巧才能有效使用。字符串interning、hashing和pinning等特性对于功能良好的字符串实现至关重要。幸运的是,在.NET中,我们认为所有这些都是理所当然的,因为框架对我们来说是繁重的。
.NET字符串分配始终需要每个字符2个字节。这是UTF-16模式。字符可以是代理,因此需要另一个字符(换句话说:另外2个字节)来表示可打印字符。
分配还使用一些技巧来加速查找。对象头用于存储这样的元信息。
C#中的字符串文字本身允许两个可以组合的开关。一个是“逐字字符串”——前缀为@:
var myString = @"This
is a very long strong
that may contain even ""quotes"" and other things if properly escaped...
See you later!";
另一个开关用于“插值字符串”,将在下一节中讨论。
从MSIL的角度来看,逐字字符串文字和标准字符串文字具有完全相同的行为。区别仅在于C#编译器的处理。
让我们看一个示例C#代码来确认这一点:
var foo = @"foo
bar";
var FOO = foo.ToUpper();
生成的MSIL包含标准的MSIL字符串文字。在MSIL中,字符串文字没有开关。
IL_0001: ldstr "foo\r\nbar"
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: callvirt System.String.ToUpper
在逐字文字中,转义序列不起作用。因此,文字字符串非常适合Windows路径:
var myPathDoesNotWork = "C:\Foo\Bar"; //Ouch, has to be C:\\Foo\\Bar - makes copy / paste difficult ...
var myPathWorks = @"C:\Foo\Bar";
由于双引号也需要转义序列,因此必须引入在字符串中编写双引号的新方法。在逐字字符串中,两个双引号(“双引号”或“四重引号”)表示单引号。
var withDoubleQuotes = @"This is ""madness"", isn't it?";
大多数情况下,我们不会使用逐字文字,尽管通常它们可能最有意义(由于支持换行文字而不是转义序列,支持标准文本的复制/粘贴更简单)。
逐字 | 标准 |
|
|
通常,string类型非常适用于UTF-16(即固定编码)表示的字符串片段,它不需要任何编译时替换,并且表示简单的字符序列。
我们应该应避免的多个(可能是未知的很多)连接的String类型。这将产生大量垃圾,应该用作业的专用类型(StringBuilder)替换。
插值字符串
除了@之外,字符串文字还有另一个可能的开关:$切换到插值字符串模式。两个开关也可以一起玩:
var foo = $@"foo
bar{'!'}";
在插值字符串的情况下,编译器进行更仔细的评估。如果未指定替换(在花括号中给出{}),则将生成一个简单的字符串。
通过可用的替换,每个替换将被评估为一个好的旧格式化字符串。因此编译器生成如上:
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索引处的元素。
可以使用此数据结构,以便格式化字符串中使用的不同值是自定义格式的。
以下面的代码为例(假设有一个参数——迭代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.Format函数一样强大。
由于花括号内的表达式替换表示冒号三元表达式的特殊含义,例如a ? b : c在插值字符串中应该应避免的。相反,应预先评估此类表达式,并仅引用一个简单变量。
重要提示:例如,多个字符串连接的好且旧的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 [] { ... })。
有用的 | 应避免的 |
|
|
字符串视图
有时我们想要的只是在字符串的一部分内导航。以前,有两种方法可以解决这个问题:
- 使用Substring方法将所需的子字符串作为新字符串——这将分配一个新字符串
- 创建一个中间数据结构或局部变量来表示当前索引——这将分配临时变量
使用新字符串的方法可能非常耗费内存,因此不需要。使用局部变量的方法肯定非常有效,但是,需要对算法进行更改,并且正确实现可能非常复杂。
由于这是一个特别出现在任何类型的解析器中的问题,C#/ Roslyn团队考虑了潜在的解决方案。结果是一个名为Span<T>的新数据类型,可用于有关内存的任意视图。
从本质上讲,这种类型为我们提供了所需的快照,但是以最有效的方式。更好的是,这种简单的值类型允许我们使用任何类型的连续内存:
- 非托管内存缓冲区
- 数组和子阵列
- 字符串和子串
开始使用Span<T>有两个先决条件。我们需要安装最新的System.MemoryNuGet包并将语言版本设置为C#7.2(或更新的版本)。
由于内存视图需要ref返回值,因此.NET Core 2+本身支持此功能(即,期望.NET Core 2+与具有较旧GC和.NET运行时的某些其他平台之间的性能差异)。
Span<T>背后的魔力是我们实际上可以返回直接引用。有了ref返回,这看起来如下:
ref T this[int index] => ref ((ref reference + byteOffset) + index * sizeOf(T));
该表达式与我们学习在旧C中迭代内存数组的方式非常相似。
回到有字符串视图的主题。让我们考虑一下旧例子:
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<T>保持直接引用,我们不允许将它放在堆上。因此,存在某些规则:
- 没有装箱
- 没有泛型类型
- 它不能是类或非ref struct类型的字段
- async方法内部没有用
- 它不能实现任何现有的接口
那是个很长的名单!因此,最后我们只能直接或间接地将其用作参数。
有用的 | 应避免的 |
|
|
内存视图
虽然Span<T>与stackalloc关键字一起引入,但它也允许直接使用任何T[]数组类型。
Span<byte> stackMemory = stackalloc byte[256];
如已经讨论过的,这严重地限制了堆栈寿命,因此在使用中具有严重的限制。
此外,还有可能通过这种机制使用.NET堆放置数组给我们启示——为什么没有类型具有类似目的但又没有这些限制?
让我们首先看看.NET标准数组的用法:
Span<char> array = new [] { 'h', 'e', 'l', 'l', 'o' };
现在使用新Memory<T>类型,我们实际上可以传递最终在需要时进行转换的Span<T>。
这个想法是内存被传递并存储在堆上,但最终我们想要性能并将视图(span)用于定义明确的短时间。
API非常简单。我们可以将这个存储区域的“视图”(起始和长度)一起移交给ReadOnlyMemory<T> .NET数组的构造函数。通过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<T1, T2>,Tuple<T1, T2, T3>,...)。我们可以将其视为与其通用变体Func等效的数据载体。
因此,上述问题可以改为:
void Test()
{
var result = ReturnMultiple();
var result1 = result.Item1;
var result2 = result.Item2;
}
Tuple<bool, int> ReturnMultiple() => Tuple.Create(true, 0);
从逻辑上讲,从MSIL的角度来看,上述Test方法中的代码更复杂:
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);
此处保留标准的“LHS类型”。在C#中,我们仍然(与其他C语言一样)遵循“类型标识符”(例如,与使用“标识符:类型”并且基于RHS的TypeScript进行比较)。
命名不能用于值创建,必须按顺序。此外,它对解构没有影响。
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帮助的理想替代候选者。
有用的 | 应避免的 |
|
|
展望
在本系列的下一个(也是最后一个)部分中,我们将介绍异步代码和特殊代码构造,例如模式匹配或可空类型。
关于值的展望,C#的演变似乎尚未完成。在F#等语言中找到的真实记录和其他原语非常有用,很难被遗漏。我个人缺少的是在语言层面处理这些视图(Span<T>)的原语。
此外,BigInt的文字(后缀)将被赞赏(可能b?)。Complex同样如此,这自然会发生i,这样5i相当于new Complex(0, 5.0)。
结论
C#的演变并未停止在使用的值上。我们看到C#为我们提供了一些更先进的技术,以获得灵活性而不会影响性能。框架对切片的帮助也非常方便。
String.Format不应再使用以前格式化字符串的方法。插值字符串提供了一些很好的优点。返回多个值从未如此简单。从中出现的模式还有待确定。结合本地函数和属性空间的演变,C#语言已经感受到了更多的活力。
兴趣点
我总是展示非优化的MSIL代码。一旦MSIL代码得到优化(或甚至运行),它可能看起来有点不同。在这里,实际观察到的不同方法之间的差异实际上可能会消失。然而,由于我们在本文中关注开发人员的灵活性和效率(而不是应用程序性能),所有建议仍然有效。
原文地址:https://www.codeproject.com/Articles/4114267/Modernize-Your-Csharp-Code-Part-III-Values