返回与不返回功能?

时间:2013-12-26 09:07:41

标签: c++ function return performance code-readability

返回或不返回,这是一个功能问题!或者,这真的很重要吗?


以下是故事: 我曾经写过如下代码:

Type3 myFunc(Type1 input1, Type2 input2){}

但是最近我的项目学院告诉我,我应该尽可能地尝试避免编写这样的函数,并通过将返回的值放在输入参数中来建议以下方法。

void myFunc(Type1 input1, Type2 input2, Type3 &output){}

他们让我确信这是更好更快的,因为在第一种方法中返回时需要额外的复制步骤。


对我来说,我开始相信第二种方法在某些情况下更好,特别是我有多个要返回或修改的东西。例如:以下第二行将比第一行更好更快,因为避免在返回时复制整个vecor<int>

vector<int> addTwoVectors(vector<int> a, vector<int> b){}
void addTwoVectors(vector<int> a, vector<int> b, vector<int> &result){}:

但是,在其他一些情况下,我不能买它。例如,

bool checkInArray(int value, vector<int> arr){}

肯定比

void checkInArray(int value, vector<int> arr, bool &inOrNot){}

在这种情况下,我认为直接返回结果的第一种方法在更好的可读性方面更好。


总之,我很担心(强调C ++):

  • 哪些功能应该返回,哪些不应该(或尽量避免)?
  • 我有什么标准的方式或好的建议可供我使用吗?
  • 我们可以在可读性和代码效率方面做得更好吗?

修改: 我知道,在某些情况下,我们必须使用其中一个。例如,如果我需要实现return-type functions,我必须使用method chaining因此,请关注可以应用这两种方法来实现目标的情况。

我知道这个问题可能没有一个答案或确定的事情。此外,似乎需要在许多编码语言中做出此决定,例如CC++等。因此,任何意见或建议都非常受欢迎(更好地通过示例)。

7 个答案:

答案 0 :(得分:4)

就像有人提出的论点,即有一件事比另一件事快,你是否采取了时机?在完全优化的代码中,您计划使用的每种语言和每个编译器?没有它,基于表现的任何论据都没有实际意义。

我将在一秒钟内回到性能问题,让我先解决一下我认为更重要的问题:当然,有充分的理由通过引用传递函数参数。我现在能想到的主要问题是参数实际上是输入和输出,即该函数应该对现有数据进行操作。对我来说,这就是采用非const引用的函数签名所表明的。如果这样的函数然后忽略了该对象中已经存在的东西(或者更糟糕的是,显然希望只得到一个默认构造的那个),那么该界面会让人感到困惑。

现在,回到表演。我不能代表C#或Java(虽然我相信在Java中返回一个对象不会首先导致副本,只是传递一个引用),而在C中,你没有引用但可能需要求助于传递指针周围(然后,我同意传入指向未初始化内存的指针是可以的)。但是在C ++中,编译器已经做了很长时间的返回值优化,RVO,这基本上只意味着在大多数调用如A a = f(b);中,复制构造函数被绕过而f将直接创建对象。正确的地点。在C ++ 11中,我们甚至使用移动语义来使其显式化,并在更多地方使用它。

您应该只返回A*吗?只有你真的渴望过去的手动内存管理。至少,请返回std::shared_ptr<A>std::unique_ptr<A>

现在,有了多个输出,当然会出现其他并发症。首先要做的是你的设计是否合适:每个函数都应该有一个责任,通常,这意味着返回一个值。但当然有例外;例如,分区功能必须返回两个或多个容器。在这种情况下,您可能会发现使用非const引用参数更容易阅读代码;或者,您可能会发现返回元组是可行的方法。

我建议你双向编写代码,然后在第二天或周末之后回来看看这两个版本。然后,决定什么更容易阅读。最后,这是良好代码的主要标准。对于那些您可以从最终用户工作流程中看到性能差异的地方,这是一个需要考虑的额外因素,但只有在非常罕见的情况下才应该优先于可读代码 - 并且只需要更多的努力,您就可以无论如何通常都会工作。

答案 1 :(得分:4)

由于返回值优化,第二种形式(传递引用并对其进行修改)几乎肯定更慢,更难以修改,也不太容易辨认。

让我们考虑一个简单的示例函数:

return_value foo( void );

以下是可能发生的可能性:

  1. 返回值优化(RVO)
  2. 命名返回值优化(NRVO)
  3. 移动语义返回
  4. 复制语义返回
  5. 什么是返回值优化?考虑这个功能:

    return_value foo( void ) { return return_value(); }
    

    在此示例中,从单个出口点返回未命名的临时变量。因此,编译器可以轻松(并且可以自由地)完全删除此临时值的任何痕迹,而是在调用函数中直接构造它:

    void call_foo( void )
    {
        return_value tmp = foo();
    }
    

    在这个例子中,tmp实际上直接在foo中使用,就像foo定义它一样,删除所有副本。如果return_value是一个非平凡的类型,这是一个巨大的优化。

    什么时候可以使用RVO?这取决于编译器,但通常,使用单个返回代码点,它将始终使用。多个返回代码点使它更加不确定,但如果它们都是匿名的,那么你的机会就会增加。

    命名返回值优化

    怎么样?

    这个有点棘手;如果在返回变量之前命名变量,它现在是一个l值。这意味着编译器必须做更多工作来证明可以实现就地构造:

    return_type foo( void )
    {
        return_type bar;
        // do stuff
        return bar;
    }
    

    通常,这种优化仍然是可能的,但是对于多个代码路径的可能性较小,除非每个代码路径返回相同的对象;从多个不同的代码路径返回多个不同的对象往往不难以优化:

    return_type foo( void)
    {
        if(some_condition)
        {
            return_type bar = value;
            return bar;
        }
        else
        {
            return_type bar2 = val2;
            return bar2;
        }
    }
    

    这不会受到好评。 NRVO仍有可能启动,但它的可能性越来越小。如果可能的话,构造一个return_value并在不同的代码路径中调整它,而不是返回完全不同的代码路径。

    如果可以使用NRVO,这将消除任何开销;它就像是直接在调用函数中构造的。

    如果无法进行任何形式的返回值优化,则可以使用移动返回

    C ++ 11和C ++ 03都有可能进行移动语义;而不是将信息从一个对象复制到另一个对象,移动语义允许一个对象窃取另一个对象的数据,将其设置为某个默认状态。对于C ++ 03移动语义,你需要boost.move,但这个概念仍然是合理的。

    移动返回没有RVO返回的那么快,但它比副本快得多。对于兼容的C ++ 11编译器,今天有很多,所有STL和STD结构都应该支持移动语义。您自己的对象可能没有默认的移动构造函数/赋值运算符(MSVC当前没有用户定义类型的默认移动语义操作),但添加移动语义并不难:只需使用复制和交换习惯用法来添加它!

    What is the copy-and-swap idiom?

    最后,如果你的return_value不支持move并且你的函数对于RVO来说太难了,你将默认复制语义,这是你的朋友说要避免的。

    然而,在很多情况下,这不会明显变慢!

    对于原始类型,例如float或int或bool,复制是单个赋值或移动;几乎没有什么可抱怨的;通过引用传递这些东西没有一个很好的理由肯定会使你的代码变慢,因为引用是内部指针。对于像你的bool例子这样的东西,没有理由浪费时间或精力通过参考bool;返回它是最快的方式。

    当你返回适合寄存器的东西时,它通常会在寄存器中返回,正是出于这个原因;它很快,如上所述,最容易维护。

    如果您的类型是POD类型,例如简单的结构,这通常可以通过快速调用机制通过寄存器传递,或者优化为直接赋值。

    如果你的类型是一个庞大而强大的类型,比如std :: string或其后面有大量数据的东西,需要大量的深拷贝,而你的代码足够复杂以至于不太可能使RVO成为可能通过引用是一个更好的主意。

    <强>摘要

    1. 任何类型的匿名(rvalue)值都应按值
    2. 返回
    3. 应按值返回小型或原始类型。
    4. 任何支持移动语义的类型(STL,STD等)都应该按值返回
    5. 应该通过值
    6. 返回易于推理的命名(左值)值
    7. 复杂功能中的大数据类型应通过引用分析或传递
    8. 如果您使用的是C ++ 11,请尽可能按值返回。它更清晰,更快。

答案 2 :(得分:3)

这个问题没有一个答案,但正如你已经说过的那样,中心部分是:它取决于。

显然,对于简单类型,例如int或bools,返回值通常是首选解决方案。它更容易编写并且更不容易出错(即因为您无法将未定义的内容传递给函数,并且您不需要在调用指令之前单独定义变量)。对于复杂类型(例如集合),可能首选call-by-reference,因为它可以避免额外的复制步骤。但是你也可以返回一个vector<int>*,而不仅仅是vector<int>,它会归档相同的内容(但需要一些额外的内存管理费用)。然而,所有这些还取决于所使用的语言。上述内容大多适用于C或C ++,但对于托管类(如Java或C#),大多数复杂类型无论如何都是引用类型,因此返回向量不涉及任何复制。

当然,有些情况下你希望副本发生,即如果你想以这样的方式返回内部向量的(副本),使得调用者无法修改内部被调用类的数据结构。

再次:这取决于。

答案 3 :(得分:3)

这是方法和功能之间的区别。

调用方法(a.k.a.子程序)主要调用它们的副作用,即修改作为参数传递给它的一个或多个对象。在支持OOP的语言中,要修改的对象通常隐式传递为此/ self参数。

另一方面,函数主要被称为它们的返回值,它计算新的东西,不应该修改参数,应该避免副作用。在函数式编程意义上,函数应该是纯粹的。

如果函数/方法用于创建新对象(即工厂),则应返回该对象。如果传入对变量的引用,那么不清楚谁将负责清理以前包含在变量,调用者或工厂中的对象?使用工厂功能,很明显调用者负责确保清除前一个对象;使用工厂方法,它不是那么清楚,因为工厂可以进行清理,尽管出于各种原因这通常是个坏主意。

如果函数/方法是要修改一个或多个对象,那么对象应作为参数传入,不应返回已修改的对象(例外情况是如果您正在使用支持它们的语言设计流畅的界面/方法链接。)

如果你的对象是不可变的,那么你应该总是使用函数,因为不可变对象上的每个操作都必须创建新对象。

添加两个向量应该是一个函数(使用返回值),因为返回值是一个新向量。如果您要向现有向量添加另一个向量,那么这应该是一个方法,因为您正在修改现有向量而不是分配新向量。

在不支持异常的语言中,返回值通常用于表示错误值;但是对于支持异常的语言,错误条件应始终用异常信号通知,并且永远不应该有返回值的方法或修改其参数的函数。换句话说,不要做副作用并在同一个函数/方法中返回一个值。

答案 4 :(得分:2)

功能应该返回什么,不应该(或尽量避免)?  这取决于你的方法应该做什么。

当您的方法修改列表或返回新数据时,您应该使用返回值。除了使用ref参数之外,了解代码的功能要好得多。

返回值的另一个好处是能够使用方法链。

您可以编写这样的代码,将list参数从一个方法传递到另一个方法:

method1(list).method2(list)...

答案 5 :(得分:1)

如前所述,没有一般答案。但是没有人谈过机器级别,所以我会这样做并尝试一些例子。

对于适合寄存器的操作数,答案是显而易见的。我见过的每个编译器都会使用寄存器来返回值(即使它是一个struct)。这和你一样高效。

所以剩下的问题是大型操作数。

此时由编译器决定。确实有些(特别是较旧的)编译器会发出一个副本来实现一个大于寄存器的值的返回。但这是黑暗时代的技术。

现代编译器 - 主要是因为RAM现在要大得多,这让生活变得更好 - 并不是那么愚蠢。当他们在函数体中看到“return foo;”而foo不适合寄存器时,会将foo标记为对内存的引用。这是调用者为保存返回值而分配的内存。因此,编译器最终生成的几乎完全代码与您自己传递返回值的引用相同。

我们来验证一下。这是一个简单的程序。

struct Big {
  int a[10000];
};

Big process(int n, int c)
{
  Big big;
  for (int i = 0; i < 10000; i++)
    big.a[i] = n + i;
  return big;
}

void process(int n, int c, Big& big)
{
  for (int i = 0; i < 10000; i++)
    big.a[i] = n + i;
}

现在我将在MacBook上使用XCode编译器进行编译。以下是return版本的相关输出:

    xorl    %eax, %eax
    .align  4, 0x90
LBB0_1:                                 ## =>This Inner Loop Header: Depth=1
    leal    (%rsi,%rax), %ecx
    movl    %ecx, (%rdi,%rax,4)
    incq    %rax
    cmpl    $10000, %eax            ## imm = 0x2710
    jne     LBB0_1
## BB#2:
    movq    %rdi, %rax
    popq    %rbp
    ret

和参考版本:

    xorl    %eax, %eax
    .align  4, 0x90
LBB1_1:                                 ## =>This Inner Loop Header: Depth=1
    leal    (%rdi,%rax), %ecx
    movl    %ecx, (%rdx,%rax,4)
    incq    %rax
    cmpl    $10000, %eax            ## imm = 0x2710
    jne     LBB1_1
## BB#2:
    popq    %rbp
    ret

即使您没有阅读汇编语言代码,也可以看到相似性。也许有一条指令的区别。这是-O1。优化关闭后,代码更长,但仍然几乎完全相同。对于gcc版本4.2,结果非常相似。

所以你应该告诉你的朋友“不”。使用带有现代编译器的返回值没有任何惩罚。

答案 6 :(得分:0)

对我来说,传递一个非常量指针意味着两件事:

  • 参数可以就地更改(您可以将指针传递给结构成员并避免分配);
  • 如果传递null,则无需返回参数。

后者可能允许避免计算其输出值的整个可能昂贵的代码分支,因为无论如何都不需要。

我将此视为优化,即在衡量或至少估算绩效影响时执行的操作。否则,我更喜欢尽可能不可变的数据,并尽可能地使用纯函数,以简化关于程序流程的正确推理。

通常正确性胜过性能,所以我要明确分离(const)输入参数和返回结构,除非它明显或可证明地妨碍性能或代码可读性。

(免责声明:我通常不会用C语言写作。)