函数的返回与不返回

Return vs. Not Return of functions?

本文关键字:返回 函数      更新时间:2023-10-16

是否返回,这是函数的问题!或者,这真的重要吗


故事如下:我曾经写过这样的代码:

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++):感到困惑

  • 函数应该返回什么,不应该返回什么(或尽量避免)
  • 有什么标准的方法或好的建议可以让我遵循吗
  • 我们能在可读性和代码效率方面做得更好吗

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

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

和往常一样,当有人提出一件事比另一件快的论点时,你有把握时间吗?在完全优化的代码中,在您计划使用的每种语言和每种编译器中?如果没有这一点,任何基于性能的争论都是没有意义的。

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

现在,回到表演上来。我不能代表C#或Java(尽管我相信在Java中返回对象一开始不会导致复制,只是传递引用),而在C中,你没有引用,但可能需要传递指针(然后,我同意将指针传递到未初始化的内存是可以的)。但在C++中,编译器长期以来一直在进行返回值优化RVO,这基本上意味着在大多数像A a = f(b);这样的调用中,复制构造函数被绕过,f将直接在正确的位置创建对象。在C++11中,我们甚至使用了move语义来明确这一点,并在更多地方使用它。

您应该只返回一个A*吗?只有当你真的渴望手动内存管理的旧时代。至少,返回一个std::shared_ptr<A>std::unique_ptr<A>

现在,有了多重输出,当然会带来额外的复杂性。首先要做的是你的设计是否正确:每个函数都应该有一个单一的责任,通常情况下,这也意味着返回一个值。但当然也有例外;例如,分区函数将不得不返回两个或多个容器。在这种情况下,您可能会发现使用非常量引用参数更容易阅读代码;或者,您可能会发现返回一个元组是可行的。

我敦促您以两种方式编写代码,第二天或周末后回来再次查看这两个版本。然后,决定什么更容易阅读。最终,这是良好代码的主要标准。对于那些你可以看到与最终用户工作流的性能差异的少数地方,这是一个需要考虑的额外因素,但只有在极少数情况下,它才应该优先于可读代码——只要多花一点力气,你通常都可以同时工作。

由于Return Value Optimization,第二种形式(传递引用并修改它)几乎可以肯定会更慢,对优化的修改更少,也更不清晰。

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

return_value foo( void );

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

  1. 返回值优化(RVO)
  2. 命名返回值优化(NRVO)
  3. 移动语义返回
  4. 复制语义返回

什么是返回值优化?考虑这个功能:

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

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

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

在本例中,tmp实际上是直接在foo中使用的,就好像foo定义了tmp一样,删除了所有副本。如果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目前没有用户定义类型的默认移动语义操作),但添加移动语义并不困难:只需使用复制和交换习惯用法即可添加它!

复制和交换习语是什么?

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

然而,在大量情况下,速度不会明显变慢!

对于基元类型,如float、int或bool,复制是单个赋值或移动;几乎不是什么值得抱怨的事情;由于引用是内部指针,因此在没有充分理由的情况下通过引用传递这些内容肯定会使代码变慢。对于类似bool的例子,没有理由浪费时间或精力通过引用传递bool;返回是最快的方式。

当你返回符合寄存器的东西时,它通常正是出于这个原因而在寄存器中返回的;它速度快,而且如前所述,最容易维护。

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

如果你的类型是一个大的、令人印象深刻的类型,比如std::string或后面有很多数据的东西,需要大量的深度复制,并且你的代码非常复杂,不太可能出现RVO,那么通过引用传递可能是一个更好的主意。

摘要

  1. 任何类型的匿名(rvalue)值都应按值返回
  2. 小型或基元类型应按值返回
  3. 任何支持移动语义的类型(STL、STD等)都应该通过值返回
  4. 易于推理的命名(左值)值应按值返回
  5. 复杂函数中的大型数据类型应进行分析或通过引用传递

如果使用C++11,请尽可能始终按值返回。它更清晰,速度更快。

这个问题没有单一的答案,但正如您已经说过的,核心部分是:这取决于情况。

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

当然,在某些情况下,确实希望复制发生,即,如果您希望以调用方无法修改被调用类的内部数据结构的方式返回内部向量的(副本)。

再说一遍:这取决于情况。

这是方法和函数之间的区别。

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

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

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

如果函数/方法旨在修改一个或多个对象,则应将这些对象作为参数传入,并且不应返回已修改的对象(如果您设计的语言支持流畅的接口/方法链接,则除外)。

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

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

在不支持异常的语言中,返回值通常用于表示错误值;然而,在支持异常的语言上,错误条件应该总是用异常来表示,并且永远不应该有返回值的方法或修改其参数的函数。换句话说,不要在同一个函数/方法中产生副作用并返回值。

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

当您的方法修改列表或返回新数据时,应该使用返回值。理解代码的作用比使用ref参数要好得多。

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

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

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

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

对于适合寄存器的操作数,答案是显而易见的。我见过的每个编译器都会为返回值使用一个寄存器(即使它是一个结构)。这是最有效的。

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

在这一点上,这取决于编译器。确实,一些(尤其是较旧的)编译器会发出一个副本来实现大于寄存器的值的返回。但这是黑暗时代的技术。

现代编译器——主要是因为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,结果非常相似。

所以你应该告诉你的朋友"不"。在现代编译器中使用返回值不会受到惩罚。

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

  • 参数可以就地更改(可以将指针传递给结构成员并避免赋值)
  • 如果传递了null,则不需要返回该参数

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

我认为这是一种优化,即在衡量或至少估计性能影响时所做的事情。否则,我更喜欢尽可能不可变的数据和尽可能纯的函数,以简化对程序流的正确推理。

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

(免责声明:我通常不写C。)