< p > < em > TLDR:插值字符串总体上是最好的,它们只会在您使用旧的.NET和缓存数字字符串时在基准测试中分配更多内存
这里有很多要讨论的内容。
首先,很多人认为使用+
进行字符串拼接将始终为每个+
创建一个新字符串。在循环中可能是这种情况,但如果您连续使用大量+
,编译器实际上将用一个string.Concat
调用替换这些运算符,使复杂度为O(n),而不是O(n ^ 2)。您的DebugPrintConcat
实际上编译成了这样:
public void DebugPrintConcat()
{
profiler.PrineDebug(string.Concat("sometext_", five.ToString(), "_", six.ToString()));
}
需要翻译的内容:
需要注意的是,在您的特定情况下,您并未针对整数字符串分配进行基准测试,因为 .Net 会为小数字缓存字符串实例,所以那些在 five
和 six
上调用的 .ToString()
实际上不会分配任何内存。如果您使用更大的数字或格式(例如 .ToString("10:0000")
),内存分配将会有很大不同。
连接字符串的三种方法是 +
(即 string.Concat()
)、string.Format()
和 插值字符串。插值字符串曾经与 string.Format()
完全相同,因为 $"..."
只是 string.Format()
的语法糖,但自从 .Net 6 以来,它们通过 插值字符串处理程序 进行了重新设计。
我认为我必须解决的另一个谬论是,人们认为在结构体上使用 string.Format()
将始终导致首先将结构体装箱,然后通过调用装箱结构体上的 .ToString()
创建一个中间字符串。这是错误的,多年来,所有基元类型都实现了 ISpanFormattable
,它允许 string.Format()
跳过创建中间字符串并将对象的字符串表示形式直接写入内部缓冲区。随着 .Net 6 的发布,ISpanFormattalbe
已经公开,因此您也可以为自己的类型实现它(有关详细信息,请参见本答案末尾)。
关于每种方法的内存特性,从最差到最佳排序:
string.Concat()
(接受对象而非字符串的重载版本)是最差的,因为它将始终装箱结构体并创建中间字符串(来源:使用ILSpy反编译)。
+
和 string.Concat()
(接受字符串而非对象的重载版本)比前者略好一些,因为虽然它们使用了中间字符串,但它们不会装箱结构体。
string.Format()
通常比前面的方法更好,因为如前所述,它确实需要装箱结构体,但如果结构体实现了ISpanFormattable
(直到不久之前这个接口是内部的),则不会创建中间字符串。此外,与之前的方法相比,string.Format() 不太可能需要分配object[]
。
- 插值字符串是最好的,因为随着 .Net 6 的发布,它们不会装箱结构体,对于实现
ISpanFormattable
的类型,它们也不会创建中间字符串。你通常只会得到返回的字符串,没有其他任何分配。
为了支持上述论点,我在下面添加了一个基准类和基准结果,确保避免原始帖子中
+
之所以表现最佳的情况是因为对于小整数,字符串被缓存。
[MemoryDiagnoser]
[RankColumn]
public class ProfilerBench
{
private float pi = MathF.PI;
private double e = Math.E;
private int largeInt = 116521345;
[Benchmark(Baseline = true)]
public string StringPlus()
{
return "sometext_" + pi + "_" + e + "_" + largeInt + "...";
}
[Benchmark]
public string StringConcatStrings()
{
return string.Concat("sometext_", pi.ToString(), "_", e.ToString(), "_", largeInt.ToString(), "...");
}
[Benchmark]
public string StringConcatObjects()
{
return string.Concat("sometext_", pi, "_", e, "_", largeInt, "...");
}
[Benchmark]
public string StringFormat()
{
return string.Format("sometext_{0}_{1}_{2}...", pi, e, largeInt);
}
[Benchmark]
public string InterpolatedString()
{
return $"sometext_{pi}_{e}_{largeInt}...";
}
}
结果按分配的字节数排序:
方法 |
平均值 |
误差 |
标准偏差 |
排名 |
Gen 0 |
分配内存 |
StringConcatObjects |
293.9 ns |
1.66 ns |
1.47 ns |
4 |
0.0386 |
488 B |
StringPlus |
266.8 ns |
2.04 ns |
1.91 ns |
2 |
0.0267 |
336 B |
StringConcatStrings |
278.7 ns |
2.14 ns |
1.78 ns |
3 |
0.0267 |
336 B |
StringFormat |
275.7 ns |
1.46 ns |
1.36 ns |
3 |
0.0153 |
192 B |
InterpolatedString |
249.0 ns |
1.44 ns |
1.35 ns |
1 |
0.0095 |
120 B |
如果我编辑基准类以使用超过三个格式参数,则由于数组分配,
InterpolatedString
和
string.Format()
之间的差异将更大:
[MemoryDiagnoser]
[RankColumn]
public class ProfilerBench
{
private float pi = MathF.PI;
private double e = Math.E;
private int largeInt = 116521345;
private float anotherNumber = 0.123456789f;
[Benchmark]
public string StringPlus()
{
return "sometext_" + pi + "_" + e + "_" + largeInt + "..." + anotherNumber;
}
[Benchmark]
public string StringConcatStrings()
{
return string.Concat("sometext_", pi.ToString(), "_", e.ToString(), "_", largeInt.ToString(), "...", anotherNumber.ToString());
}
[Benchmark]
public string StringConcatObjects()
{
return string.Concat("sometext_", pi, "_", e, "_", largeInt, "...", anotherNumber);
}
[Benchmark]
public string StringFormat()
{
return string.Format("sometext_{0}_{1}_{2}...{3}", pi, e, largeInt, anotherNumber);
}
[Benchmark]
public string InterpolatedString()
{
return $"sometext_{pi}_{e}_{largeInt}...{anotherNumber}";
}
}
基准测试结果,按分配的字节数重新排序:
方法 |
平均值 |
误差 |
标准偏差 |
排名 |
Gen 0 |
分配的内存 |
StringConcatObjects |
389.3 ns |
2.65 ns |
2.34 ns |
4 |
0.0477 |
600 B |
StringPlus |
350.7 ns |
1.88 ns |
1.67 ns |
2 |
0.0329 |
416 B |
StringConcatStrings |
374.4 ns |
6.90 ns |
6.46 ns |
3 |
0.0329 |
416 B |
StringFormat |
390.4 ns |
2.01 ns |
1.88 ns |
4 |
0.0234 |
296 B |
InterpolatedString |
332.6 ns |
2.82 ns |
2.35 ns |
1 |
0.0114 |
144 B |
编辑:人们可能仍然认为在插入字符串处理程序参数上调用.ToString()
是一个好主意。它不是。如果这样做,性能将会受到影响,甚至Visual Studio也会警告你不要这样做。 这不仅适用于.net6,下面您可以看到,即使使用string.Format()
(插入字符串曾经是其语法糖),也仍然不应调用.ToString()
:
[MemoryDiagnoser]
[RankColumn]
public class ProfilerBench
{
private float pi = MathF.PI;
private double e = Math.E;
private int largeInt = 116521345;
private float anotherNumber = 0.123456789f;
[Benchmark]
public string StringFormatGood()
{
return string.Format("sometext_{0}_{1}_{2}...{3}", pi, e, largeInt, anotherNumber);
}
[Benchmark]
public string StringFormatBad()
{
return string.Format("sometext_{0}_{1}_{2}...{3}",
pi.ToString(),
e.ToString(),
largeInt.ToString(),
anotherNumber.ToString());
}
}
方法 |
平均值 |
误差 |
标准差 |
排名 |
第一代垃圾回收次数 |
分配内存 |
StringFormatGood |
389.0 纳秒 |
2.27 纳秒 |
2.12 纳秒 |
1 |
0.0234 |
296 字节 |
StringFormatBad |
442.0 纳秒 |
4.62 纳秒 |
4.09 纳秒 |
2 |
0.0305 |
384 字节 |
结果的解释是将结构体装箱并让
string.Format()
直接将其字符串表示形式写入其 char 缓冲区比显式创建中间字符串并强制
string.Format()
从中复制更便宜。
如果您想了解有关插值字符串处理程序的工作原理以及如何使自己的类型实现
ISpanFormattable
,这是一个不错的阅读材料:
link。
string.Format()
函数。 - Petrusionstring.Format
会创建一个新的字符串,并由该方法返回" - 这一点显然超出了常识,这也是该方法的全部意义所在。为什么你总是说我只谈论 .Net 6,而我在这个评论链中多次谈到了之前的版本?如果我理解正确,你是说$"sometext_{five}_{six}"
被翻译成多个string.Format()
调用,这对于任何 .Net 版本都是错误的。在 .Net 6 之前,$"sometext_{five}_{six}"
被翻译成string.Format("sometext_{0}_{1}", five, six)
,这是一个单独的string.Format()
。 - Petrusion