Effective C# 原则31:选择小而简单的函数

JerryXia 发表于 , 阅读 (869)

做为一个有经验的程序员,不管你在使用C#以前是习惯用什么语言的,我们综合了几个可以让你开发出有效代码的实际方法。有些时候,我们在先前的环境中所做的努力在.Net环境中却成了相反的。特别是在你试图手动去优化一些代码时尤其突出。你的这些行为往往会阻止JIT编译器进行最有效的优化。你的以性能为由的额外工作,实际上产生了更慢的代码。你最好还是以你最清楚的方法写代码,其它的让JIT编译器来做。最常见的一个例子就是预先优化,你创建一个很长很复杂的函数,本想用它来避免太多的函数调用,结果会导致很多问题。实际操作时,提升这样一个函数的逻辑到循环体中对.Net程序是有害的。这与你的真实是相反的,让我们来看一些细节。

这一节介绍一个简单的内容,那就是JIT编译器是如何工作的
。.Net运行时调用JIT编译器,用来把由C#编译器生成的IL指令编译成机器代码。这一任务在应用程序的运行期间是分步进行的。JIT并不是在程序一开始就编译整个应用程序,取而代之的是,CLR是一个函数接一个函数的调用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第一次调用时,两个选择项就都编译了。而实际上只有一个是须要的。但是假设你这样写代码:

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

因为函数体的每个分支被分解到了独立的小函数中,而JIT就是须要这些小函数,这比前面的BuildMsg调用要好。确实,这个例子只是人为的,而且实际上它也没什么太特别的。但想想,你是不是经常写更“昂贵”的例子呢:一个if
语句中是不是每个片段中都包含了20或者更多的语句呢?你的开销就是让JIT在第一次调用它的时候两个分支都要编译。如果一个分支不像是错误条件,那到你就招致了本可以简单避免的浪费。小函数就意味着JIT编译器只编译它要的逻辑,而不是那些沉长的而且又不会立即使用的代码。对于很长的switch分支,JIT要花销成倍的存储,因此把每个分支的内容定义成内联的要比分离成单个函数要好。

JIT编译器可以更简单的对小而简单的函数进行可登记(enregistration)处理。可登记处理是指进程选择哪些局部变量可以被存储到寄存器中,而这比存储到堆栈中要好。创建少的局部变量可以能JIT提供更好的机会把最合适的候选对象放到寄存器中。这个简单的控制流程同样会影响JIT编译能否如期的进行变量注册。如果函数只有一个循环,那么循环变量就很可能被注册。然而,当你在一个函数中使用过多的循环时,对于变量注册,JIT编译器就不得不做出一些困难的决择。简单就是好,小而简单的函数很可能只包含简单几个变量,这样可以让JIT很容易优化寄存器的使用。

JIT编译器同样决定内联方法。内联就是说直接使用函数体而不必调用函数。考虑这个例子:

// readonly name property:
private string _name;
public string Name
{
    get
    {
        return _name;
    }
}
// access:
string val = Obj.Name;

相对函数的调用开销来说,属性访问器实体包含更少数的指令:对于函数调用,要先在寄存器中存储它的状态,然后从头到尾执行,接着存储返回结果。这还不谈如果有参数时,把参数压到堆栈上还要更多的工作。如果你这样写,这会产生更多的机器指令:

string val = Obj._name;

当然,你应该不会这样做,因为你已经明白最好不要创建公共数据成员(参见原则1)。JIT编译器明白你即须要效率也须要简洁,所以它会内联属性访问器。JIT会在以速度或者大小为目标(或者两个同时要求)时,内联一些方法,用函数体来取代函数的调用会让它更有利。一般情况不用为内联定义额外的规则,而且任何已经实现的内联在将来都可能被改变。另外,内联函数并不是你的职责。正好C#语言没有提供任何关键字让你暗示编译器说你想内联某个函数。实际上,C#编译器也不支持任何暗示来让JIT编译进行内联。你可以做的就是确保你的代码尽可能的清楚,尽可能让JIT编译器容易的做出最好的决定。我的推荐现在就很熟悉了:越小的方法越有可能成为内联对象。请记住:任何虚方法或者含有try/catch块的函数都不可能成为内联的。

内联修改了代码正要被JIT的原则。再来考虑这个访问名字属性的例子:

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

JIT编译器内联了属性访问器,这必然会在相关的方法被调用时JIT代码。

你没有责任来为你的算法决定最好的机器级别上的表现。C#编译器以及JIT编译器一起为你完成了这些。C#编译器为每个方法生成IL代码,而JIT编译器则把这些IL代码在目标机器上翻译成机器指令。并不用太在意JIT编译器在各种情况下的确切原则;有这些时间可以开发出更好的算法。取而代之的,你应该考虑如何以一种好的方式表达你的算法,这样的方式可以让开发环境的工具以最好的方式工作。幸运的是,这些你所考虑的这些原则(译注:JIT工作原则)已经成为优秀的软件开发实践。再强调一次:使用小而简单的函数。

记住,你的C#代码经过了两步才编译成机器可执行的指令。C#编译器生成以程序集形式存在的IL代码。而JIT编译器则是在须要时,以每个函数为单元生成机器指令(当内联调用时,或者是一组方法)。小函数可以让它非常容易被JIT编译器分期处理。小函数更有可能成为内联候选对象。当然并不是足够小才行:简单的控制流程也是很重要的。函数内简单的控制分支可以让JIT以容易的寄存变量。这并不是只是写清晰代码的事情,也是告诉你如何创建在运行时更有效的代码。

添加新评论