原则11:理解小函数的魅力

优质
小牛编辑
127浏览
2023-12-01

作为一个有经验的程序员,不管在喜欢 C# 之前用的是什么语言,都会积累开发更高效代码的经验。有时,能在之前的环境有效的方法在 .NET 环境中会起反作用。当你想手动优化 C# 编译器的算法时就会体会到。你的行为经常会阻止 JIT 编译器更高效的优化。你额外提升性能的工作实际会产生更慢的代码。你最好的让你的代码写的尽可能地清晰。让 JIT 做剩下的工作。最常见的一个例子就是创建一个更长更复杂的函数希望能避免函数调用的过早优化会引起问题。像这样组装函数逻辑到一个循环体中实际伤害了 .NET 程序的性能。这是很直观的,所以下面我们对细节进行详细说明。

.NET 运行时调用 JIT 编译器翻译 C# 翻译器产生的的 IL 代码为机器码。这个过程是评价摊销在程序执行的整个生命周期。在程序启动时, JITing 不会全部载入你的程序,而是 CLR 是基于一个一个函数调用 JITer 。这最大限度地减少启动时的开销至一个合理的水平上。但如果更多代码需要被 JIT 程序会变得更迟钝。函数只有被 JIT 后才能被调用。你可以通过拆分代码成更多更小的函数而不是直接使用更少而大的函数来最大减小被 JIT 产生额外的代码数量。考虑下面的例子,尽管有点刻意:

 public string BuildMsg(bool takeFirstPath) 
{
    StringBuilder msg = new StringBuilder(); 
    if (takeFirstPath) 
    {
        msg.Append("A problem occurred."); 
        msg.Append("\nThis is a problem."); 
    msg.Append("imagine much more text");
    } 
    else 
    {
        msg.Append("This path is not so bad."); 
        msg.Append("\nIt is only a minor inconvenience."); 
        msg.Append("Add more detailed diagnostics here.");
    } 
    return msg.ToString();
}

BuildMsg 第一个调用,两个分支代码都会被 JIT 。但只有一个是需要的。但是假设你按下面方式重写函数:

public string BuildMsg2(bool takeFirstPath) 
{
    if (takeFirstPath) 
    {
        return FirstPath(); 
    } 
    else 
    {
        return SecondPath(); 
    }
}

不过,这个例子有点牵强,而且不会有太大的区别。但是考虑下你经常写的更广泛的例子:一个 if 的结构有超过 20 的条件状态。函数第一个载入是你需要为所有分支花费 JIT 的开销。如果你分支不大可能是错误条件,你可以很容易避免这个开销。短小的函数意味着 JIT 编译器只编译逻辑上需要的函数,而不是你没有立即使用整个一长串代码。 switch 结构可以节省好几被的 JIT 开销,如果每个 case 条件定义为 inline 而不是 单独的函数。

短小而简单的函数可以让 JIT 编译器很容易支持寄存器化(enregistration) 。寄存器化是指处理器选择寄存器而不是栈存储局部变量。创建更少的局部变量使得 JIT 编译器更好的找到可用的寄存器。控制流的简化同一会影响 JIT 编译使用寄存器存储变量。如果函数有一个循环体,这个循环变量很可能存储在寄存器中。然而,当你在一个函数中创建了好几个循环体, JIT 编译就要做一个艰难地选择哪一个循环变量存在寄存器中。越简单越好。一个简短的函数很可能包含更少的局部变量,可以使得 JIT 编译器更容易使用寄存器优化程序。考虑下面的例子:

// readonly name property:
public string Name { get;private set;}

// access:
string val = Obj.Name;

属性的访问器比函数调用包含更少的指令:保持寄存器状态,执行开始很结尾的代码,存储函数的返回值。如果需要有参数的话,还需要在栈上压人参数这一个步骤。如果使用 public 域的用到指令会变得更少。

当然,你不会那样做,因为你知道尽少使用 public 数据成员(原则1)。 JIT 编译器知道你需要兼顾代码的效率和优雅,所以属性的访问器是被内联调用的。当速度和大小的好处有利于用函数体代替函数调用时, JIT 才会将函数内联调用。该标准没有定义任何额外的内联规则,在将来任何实现都可能改变。而且,内联函数不是你的责任。 C# 语言甚至没有提供关键用以提示编译器某个方法应该被声明为内联。 C# 编译器不会通过任何 JIT 有关内联的提示。(你可以使用 System.Runtime.CompilerServices.MethodImpl 特性,指定方法不被内联。这是在调试时在函数调用栈保留函数名字的典型做法。

[MethodImpl(MethodImplOptions.NoInlining)]

所有你需要做的就是保证你的代码尽可能的清晰,使得 JIT 编译器可以很容易做最好的决定。现在这个建议应该变得更熟悉:小函数优于更容易被内联调用。但是记住虚函数或包含 try/catch 块的函数式不会被内联的。

内联修改了执行代码被 JIT 的原则。再看下 Name 属性的访问:

string val = "Default Name"; 
if (Obj != null)
    val = Obj.Name;

如果 JIT 编译器将属性访问器置为内联,这必须会 JIT 包含属性调用的方法的代码。

建议构建更小,组合的方法在LINQ查询和函数式编程的世界更为重要。所有的LINQ查询方法是相当小的。此外,大多数传递给LINQ查询的谓词,行为,和函数都是很小的代码块。小的,更可组合自然意味着这些方法,行为,谓词和函数更容易重用。此外, JIT 编译器更有机会优化代码使得在运行时执行的更有效率。

你不要负责决定最好机器级展示你的算法。 C# 编译器和 JIT编译器以前为你做这个。 C# 为每个方法产生 IL 代码, JIT 在宿主机器上将 IL 代码方法为机器码。你不用关心 JIT 编译器在不同情况下使用的准确规则,这些将随着时间的推移开发出更好的算法。相反,你应该关心你的表达

算法的方式,使得它容易在环境中的工具做最好的工作。幸运的是,这些规则您已经遵循良好的软件开发实践的规则是一致的。更多的时候:更小,更简单的函数。

JIT 编译器同时会对是否使用内联函数做决定。内联意味着用函数体代替函数的调用。

因为不同分支代码被分解成各自的函数,这些函数只有在需要的是才被 JIT 而不是第一次 BuildMsg 被调用时。

记住翻译 C# 代码到机器可执行码需要两个步骤: C# 编译器产生发布 在程序的 IL 代码。 JIT 编译器根据需要为每个方法(或者是内联调用的函数组)产生机器码。小函数使得 JIT 编译器可以更容易分摊开销。小函数还更有可能使用内联方式调用。这不只是短小:简单的控制流会出现更多问题。较少的控制分支可以让 JIT 编译器使用寄存器变量。这不仅是一个让你代码写的更加清晰的好方法,这更是如何创建再运行时更加高效的代码

小结:

终于写翻译完第一章共11个原则,哎,晚上脖子两侧开始疼了,希望会没事的。经常容易心情不好,前几天一个朋友的鼓励:就算失去所有,不是还有明天吗,虽然我现在已经不需要心灵鸡汤了,但其中的鼓励还是可以感知到的,也不早了(又一点了)

欢迎各种不爽,各种喷,写这个纯属个人爱好,秉持”分享“之德!