为啥 String.Contains 不直接调用最终重载?

Posted

技术标签:

【中文标题】为啥 String.Contains 不直接调用最终重载?【英文标题】:Why doesn't String.Contains call the final overload directly?为什么 String.Contains 不直接调用最终重载? 【发布时间】:2013-07-09 00:39:49 【问题描述】:

String.Contains 方法内部是这样的

public bool Contains(string value)

   return this.IndexOf(value, StringComparison.Ordinal) >= 0;

调用的IndexOf 重载如下所示

public int IndexOf(string value, StringComparison comparisonType)

   return this.IndexOf(value, 0, this.Length, comparisonType);

这里再次调用最终重载,然后调用相关的CompareInfo.IndexOf 方法,并带有签名

public int IndexOf(string value, int startIndex, int count, StringComparison comparisonType)

因此,调用最终重载将是最快的(尽管在大多数情况下可能被视为微优化)。

我可能遗漏了一些明显的东西,但是考虑到在中间调用中没有完成其他工作并且在两个阶段都可用相同的信息,为什么 Contains 方法不直接调用最终重载?

唯一的好处是,如果最终重载的签名发生变化,只需要进行一次更改(中间方法的更改),还是设计不止于此?

从 cmets 编辑(有关速度差异说明,请参阅更新 2)

为了澄清我得到的性能差异,以防我在某处犯了错误: 我跑了this benchmark(循环5次以避免抖动偏差)并使用此扩展方法与String.Contains方法进行比较

public static bool QuickContains(this string input, string value)

   return input.IndexOf(value, 0, input.Length, StringComparison.OrdinalIgnoreCase) >= 0;

循环看起来像这样

for (int i = 0; i < 1000000; i++)

   bool containsStringRegEx = testString.QuickContains("STRING");

sw.Stop();
Console.WriteLine("QuickContains: " + sw.ElapsedMilliseconds);

在基准测试中,QuickContains 在我的机器上似乎比 String.Contains 快 50%。

更新 2(解释性能差异)

我在基准测试中发现了一些不公平的东西,这可以解释很多。基准测试本身是测量不区分大小写的字符串,但由于String.Contains 只能执行区分大小写的搜索,因此包含了ToUpper 方法。这会导致结果出现偏差,而不是在最终输出方面,但至少在简单地衡量 String.Contains 在不区分大小写的搜索中的性能方面。

那么现在,如果我使用这个扩展方法

public static bool QuickContains(this string input, string value)

   return input.IndexOf(value, 0, input.Length, StringComparison.Ordinal) >= 0;

在2重载IndexOf调用中使用StringComparison.Ordinal并删除ToUpperQuickContains方法实际上变得最慢。 IndexOfContains 在性能方面几乎不相上下。很明显,ToUpper 调用歪曲了为什么ContainsIndexOf 之间存在如此差异的结果。

不确定为什么QuickContains 扩展方法变得最慢。 (可能与Contains 具有[__DynamicallyInvokable, TargetedPatchingOptOut("Performance critical to inline across NGen image boundaries")] 属性有关?)。

关于为什么不直接调用 4 重载方法的问题仍然存在,但该决定似乎并未影响性能(正如 Adrian 和 delnan 在 cmets 中指出的那样)。

【问题讨论】:

编译器可能会内联方法调用,因此在代码运行时,任何出现的String.Contains(string value) 很可能已经被重写为更复杂的版本(留下唯一的原因是您提到的优势,没有劣势)。然而,这显然只是猜测。 这是个好主意。我刚刚尝试通过创建一个直接调用最终重载的自定义Contains 方法对其进行基准测试,它最终比现有的Contains 方法快50%,因此性能受到明显影响(尽管绝对值很小; 200ms 超过 1m 循环)。 @keyboardP 我觉得这有点难以置信;即使是最简单的 inlining heuristic 和最简单的 inliner 也应该使这两个变体无法区分。你确定你没有提交benchmarking mistakes? @delnan - 感谢您的链接,现在通过它,但我使用的基准是this one(很高兴解决那里的任何问题)。我创建了一个新方法,它简单地调用具有四个重载的IndexOf,并将其与其他测试一起抛出)。 @keyboardP 还有一件事,第二篇文章似乎没有链接到third。您似乎已经涵盖了第一部分和第二部分,代码方面(我无法告诉您 如何 测量它,即在 VS 之外,在发布模式下编译等)。 【参考方案1】:

我已经有一段时间(几年)了,因为我对 MSIL 和 JIT 几乎一无所知,所以这将是一个很好的练习 - 无法抗拒,所以这里只是一点点,可能是多余的, 经验数据。 IndexOf 重载是否内联?

这是一个小型控制台应用程序:

class Program

    static void Main(string[] args)
    
        "hello".Contains("hell");
    

JIT 在优化的 Release 版本中生成这个,任何 CPU,以 32 位运行。我已经缩短了地址,并删除了一些不相关的行:

--- ...\Program.cs 
            "hello".Contains("hell");
[snip]
17  mov         ecx,dword ptr ds:[0320219Ch] ; pointer to "hello"
1d  mov         edx,dword ptr ds:[032021A0h] ; pointer to "hell"
23  cmp         dword ptr [ecx],ecx 
25  call        680A6A6C                     ; String.Contains()
[snip]

位于 0x00000025 的 call 位于此处:

String.Contains

00  push        0                 ; startIndex = 0
02  push        dword ptr [ecx+4] ; count = this.Length (second DWORD of String)
05  push        4                 ; comparisonType = StringComparison.Ordinal
07  call        FF9655A4          ; String.IndexOf()
0c  test        eax,eax 
0e  setge       al                ; if (... >= 0)
11  movzx       eax,al 
14  ret 

果然,它似乎直接调用了带有四个参数的最终String.IndexOf重载:三个pushed; edx中的一个(value:“地狱”); this(“你好”)在ecx。为了确认,这是 0x00000005 处的 call 所在的位置:

00  push        ebp 
01  mov         ebp,esp 
03  push        edi 
04  push        esi 
05  push        ebx 
06  mov         esi,ecx                  ; this ("hello")
08  mov         edi,edx                  ; value ("hell")
0a  mov         ebx,dword ptr [ebp+10h] 
0d  test        edi,edi                  ; if (value == null)
0f  je          00A374D0 
15  test        ebx,ebx                  ; if (startIndex < 0)
17  jl          00A374FB 
1d  cmp         dword ptr [esi+4],ebx    ; if (startIndex > this.Length)
20  jl          00A374FB 
26  cmp         dword ptr [ebp+0Ch],0    ; if (count < 0)
2a  jl          00A3753F 
[snip]

...这将是:

的主体
public int IndexOf(string value, 
                   int startIndex, 
                   int count, 
                   StringComparison comparisonType)

  if (value == null)
    throw new ArgumentNullException("value");
  if (startIndex < 0 || startIndex > this.Length)
    throw new ArgumentOutOfRangeException("startIndex",
             Environment.GetResourceString("ArgumentOutOfRange_Index"));
  if (count < 0 || startIndex > this.Length - count)
    throw new ArgumentOutOfRangeException("count",
             Environment.GetResourceString("ArgumentOutOfRange_Count"));
  ...

【讨论】:

哇,感谢您的努力!似乎性能差异可以忽略不计(对于不区分大小写的搜索),因此归结为设计决策。如果 4 重载签名发生变化(但不太可能),我想这真的可能只是必须避免两次代码更改的情况。 当然,现在我意识到调用堆栈可以告诉我这一点。不过,我第一次真正了解 JIT 输出很有趣。 我没有深入研究组装,所以你的回答很有趣。

以上是关于为啥 String.Contains 不直接调用最终重载?的主要内容,如果未能解决你的问题,请参考以下文章

String.Contains 在 c# 中不需要参数吗?

为啥没有“通用事件”的调用堆栈

为啥不根据参数类型调用最具体的方法

为啥不应该直接调用收据验证端点

是否可以添加一个 String.contains 多个值?

为啥调用“应用”而不是直接调用函数?