如何正确传递参数?

115

我是一个C++初学者,但不是编程初学者。 我正在尝试学习C++(c++11),但对于我来说最重要的事情有点不清楚:传递参数。

我考虑了这些简单的例子:

  • 一个所有成员都是原始类型的类:
    CreditCard(std::string number, int expMonth, int expYear, int pin): number(number), expMonth(expMonth), expYear(expYear), pin(pin)

  • 一个成员包含1个复合类型的原始类型类:
    Account(std::string number, float amount, CreditCard creditCard): number(number), amount(amount), creditCard(creditCard)

  • 一个成员包含1个或多个复合类型的原始类型类:
    Client(std::string firstName, std::string lastName, std::vector<Account> accounts): firstName(firstName), lastName(lastName), accounts(accounts)

当我创建一个帐户时,我会这样做:

    CreditCard cc("12345",2,2015,1001);
    Account acc("asdasd",345, cc);

显然,在这种情况下,信用卡会被复制两次。 如果我将构造函数重写为

Account(std::string number, float amount, CreditCard& creditCard) 
    : number(number)
    , amount(amount)
    , creditCard(creditCard)

将会有一份副本。

如果我改写为

Account(std::string number, float amount, CreditCard&& creditCard) 
    : number(number)
    , amount(amount)
    , creditCard(std::forward<CreditCard>(creditCard))

将会有2个变动且不会复制。

我认为有时你可能想要复制一些参数,有时在创建该对象时你不想复制。
我来自C#,习惯于引用,所以我觉得这对我有点奇怪,而且我认为每个参数应该有2个重载,但我知道我是错误的。
在C++中如何发送参数是否有任何最佳实践,因为我真的发现它不是那么简单。你会如何处理我上面提到的例子?


9
元信息:我简直不敢相信有人会问一个好的C++问题。+1. - user529758
23
FYI,“std::string”是一个类,就像“CreditCard”一样,不是原始类型。 - chris
8
由于字符串的混淆,尽管无关,您应该知道一个字符串字面值"abc"不是std::string类型,也不是char */const char *类型,而是const char[N]类型(在这种情况下N=4,由于三个字符和一个空字符)。这是一个很好的、常见的误解需要澄清。 - chris
10
Jon Skeet 非常活跃于 C# 相关的问题,而在 C++ 相关问题上则较少。 - Steve Jessop
如何在C++中将对象传递给函数? - legends2k
5个回答

165

首先最重要的问题:

在C++中,有没有关于如何发送参数的最佳实践,因为我觉得这并不是一件容易的事情。

如果您的函数需要修改传递的原始对象,以便在调用返回后,对该对象的修改将对调用者可见,则应通过左值引用进行传递:

void foo(my_class& obj)
{
    // Modify obj here...
}

如果您的函数不需要修改原始对象,也不需要创建它的副本(换句话说,它只需要观察其状态),那么应该通过const的左值引用进行传递

void foo(my_class const& obj)
{
    // Observe obj here
}

这将允许您使用左值(具有稳定标识的对象)和右值(例如临时对象或通过调用std :: move()即将移动的对象)调用函数。

对于基本类型或复制速度快的类型(例如int,bool或char),可以争论说如果函数仅需要观察值,则不需要通过引用传递,并且应优先考虑按值传递。如果不需要引用语义,那么是正确的,但是如果函数想要在某个地方存储指向该输入对象的指针,以便通过该指针进行的将来读取将看到在代码的其他部分中执行的值修改怎么办?在这种情况下,通过引用传递是正确的解决方案。
如果您的函数不需要修改原始对象,但需要存储该对象的副本(可能返回输入的转换结果而不更改输入),则可以考虑采用按值传递:
void foo(my_class obj) // One copy or one move here, but not working on
                       // the original object...
{
    // Working on obj...

    // Possibly move from obj if the result has to be stored somewhere...
}

调用上述函数时,传递lvalues将始终产生一个副本,传递rvalues将始终产生一个移动。如果您的函数需要在某个地方存储此对象,可以从中执行额外的移动(例如,在foo()需要将值存储在数据成员中的成员函数的情况下)。

如果类型为my_class的对象移动代价昂贵,则可以考虑重载foo()并为lvalues提供一个版本(接受对const的lvalue引用),并为rvalues提供一个版本(接受rvalue引用):

// Overload for lvalues
void foo(my_class const& obj) // No copy, no move (just reference binding)
{
    my_class copyOfObj = obj; // Copy!
    // Working on copyOfObj...
}

// Overload for rvalues
void foo(my_class&& obj) // No copy, no move (just reference binding)
{
    my_class copyOfObj = std::move(obj); // Move! 
                                         // Notice, that invoking std::move() is 
                                         // necessary here, because obj is an
                                         // *lvalue*, even though its type is 
                                         // "rvalue reference to my_class".
    // Working on copyOfObj...
}

上述函数实际上非常相似,事实上,您可以将它们合并为一个单一的函数:foo()可以成为一个函数模板,并且您可以使用完美转发来确定传递的对象是移动还是复制:

template<typename C>
void foo(C&& obj) // No copy, no move (just reference binding)
//       ^^^
//       Beware, this is not always an rvalue reference! This will "magically"
//       resolve into my_class& if an lvalue is passed, and my_class&& if an
//       rvalue is passed
{
    my_class copyOfObj = std::forward<C>(obj); // Copy if lvalue, move if rvalue
    // Working on copyOfObj...
}

您可能想通过观看 Scott Meyers 的这个演讲(请注意,他使用的“通用引用”术语是非标准的)来更多了解这个设计:链接
需要记住的一件事是,std::forward 对于右值引用通常会导致移动操作。因此,即使它看起来相对简单,多次转发同一个对象可能会导致问题,例如从同一个对象中移动两次!所以,请小心不要将其放入循环中,并且在函数调用中不要多次转发同一个参数。
template<typename C>
void foo(C&& obj)
{
    bar(std::forward<C>(obj), std::forward<C>(obj)); // Dangerous!
}

请注意,通常情况下,除非你有充分的理由,否则不要采用基于模板的解决方案,因为它会使你的代码更难阅读。通常情况下,你应该专注于清晰和简洁。
以上只是简单的指导方针,但大多数情况下,它们会引导你做出良好的设计决策。
关于您帖子的其余部分:
如果我重写代码为[...],那么就没有复制了,只有两次移动。
这是不正确的。首先,rvalue 引用不能绑定到 lvalue,因此只有在将类型 CreditCard 的 rvalue 传递给构造函数时,才能编译该代码。例如:
// Here you are passing a temporary (OK! temporaries are rvalues)
Account acc("asdasd",345, CreditCard("12345",2,2015,1001));

CreditCard cc("12345",2,2015,1001);
// Here you are passing the result of std::move (OK! that's also an rvalue)
Account acc("asdasd",345, std::move(cc));

但是,如果你尝试这样做,它就不起作用了:
CreditCard cc("12345",2,2015,1001);
Account acc("asdasd",345, cc); // ERROR! cc is an lvalue

因为cc是一个左值,而右值引用无法绑定到左值。此外,当将引用绑定到对象时,不会执行移动操作:它只是一个引用绑定。因此,只会有一个移动。
基于本答案第一部分提供的指南,如果您关心在按值接受CreditCard时生成的移动次数,可以定义两个构造函数重载,一个接受对const的左值引用(CreditCard const&),另一个接受一个右值引用(CreditCard&&)。
当传递一个左值时,重载决议将选择前者(在这种情况下,将执行一次复制),当传递一个右值时,重载决议将选择后者(在这种情况下,将执行一次移动)。
Account(std::string number, float amount, CreditCard const& creditCard) 
: number(number), amount(amount), creditCard(creditCard) // copy here
{ }

Account(std::string number, float amount, CreditCard&& creditCard) 
: number(number), amount(amount), creditCard(std::move(creditCard)) // move here
{ }

当你想实现完美转发时,通常会使用std::forward<>。在这种情况下,你的构造函数实际上将是一个构造函数模板,看起来大致如下:

template<typename C>
Account(std::string number, float amount, C&& creditCard) 
: number(number), amount(amount), creditCard(std::forward<C>(creditCard)) { }

从某种意义上讲,这将之前展示的两种重载合并成一个函数:C会在你传递左值的情况下被推断为CreditCard&,由于引用折叠规则,它将导致该函数被实例化:

Account(std::string number, float amount, CreditCard& creditCard) : 
number(num), amount(amount), creditCard(std::forward<CreditCard&>(creditCard)) 
{ }

这将导致一个creditCard拷贝构造,正如你所希望的那样。另一方面,当传递一个rvalue时,C会被推断为CreditCard,并且将实例化该函数:
Account(std::string number, float amount, CreditCard&& creditCard) : 
number(num), amount(amount), creditCard(std::forward<CreditCard>(creditCard)) 
{ }

这将导致creditCard进行移动构造,这正是您想要的(因为传递的值是一个右值,这意味着我们有权从中移动)。


1
@JackWillson:我认为只有在你真正担心移动操作的性能时,才应该使用模板版本。请参见我的这个问题,其中有一个很好的答案,以获取更多信息。一般来说,如果您不必复制并且只需要观察,请按引用传递给const。如果您需要修改原始对象,请按非const引用传递。如果您需要复制并且移动操作便宜,请按值传递,然后移动。 - Andy Prowl
2
我认为在第三个代码示例中没有必要移动。你可以直接使用 obj 而不是创建一个本地副本进行移动,对吧? - juanchopanza
3
@AndyProwl: 你在一个小时前就得到我的赞同了,但是重新阅读你的(优秀)回答后,我想指出两件事:a)按值传递并不总是创建副本(如果未移动)。对象可以在调用者的位置原地构造,特别是在RVO / NRVO的情况下,这种方法比人们最初想象的经常有效。b)请指出在最后一个示例中,std::forward 只能被调用一次。我发现有些人将其放入循环中等等,由于许多初学者将看到这个答案,因此应该有一个明显的“警告!”标签,以帮助他们避免这种陷阱。 - Daniel Frey
@StereoMatching:完全正确,我忘记在那里添加std::move了。我会尽快编辑(现在正在手机上输入)。谢谢你指出! - Andy Prowl
1
@SteveJessop: 除此之外,转发函数还存在技术问题(不一定是问题),尤其是只接受一个参数的构造函数(http://flamingdangerzone.com/cxx11/2012/06/05/is_related.html)。主要问题在于它们可以接受任何类型的参数,并且可能会破坏`std::is_constructible<>`类型特征,除非它们被适当地SFINAE约束 - 这对于某些人来说可能并不容易。 - Andy Prowl
显示剩余7条评论

11

首先,让我纠正一些细节。当你说以下内容时:

there will be 2 moves and no copy.

那是错误的。绑定到右值引用并不是移动操作。只有一个移动操作。

此外,由于 CreditCard 不是模板参数,因此 std::forward<CreditCard>(creditCard) 只是一种冗长的方式来表达 std::move(creditCard)

现在...

如果你的类型具有“廉价”的移动操作,你可能希望通过值来获取所有内容,并“沿着”使用 std::move,以简化生活。

Account(std::string number, float amount, CreditCard creditCard)
: number(std::move(number),
  amount(amount),
  creditCard(std::move(creditCard)) {}

这种方法将使您得到两个移动操作,而不是一个,但如果这些操作很便宜,它们可能是可以接受的。

在讨论“便宜操作”时,我应提醒您,std::string通常使用所谓的小字符串优化来实现,因此其移动操作可能并不比复制某些指针便宜。关于优化问题,是否要考虑取决于您的分析器,而不是我。

如果您不想发生额外的移动操作怎么办?也许它们太昂贵了,或者更糟糕的是,类型实际上不能移动,您可能会发生额外的拷贝操作。

如果只有一个有问题的参数,则可以提供两个重载版本:T const&T&&。这将始终绑定引用,直到实际成员初始化,在那里进行拷贝或移动操作。

但是,如果有多个参数,则会导致重载版本数量成指数级增长,这是一个问题。

这个问题可以通过完美转发来解决。这意味着您编写一个模板,并使用std::forward将参数的值类别带到它们的最终目的地作为成员。

template <typename TString, typename TCreditCard>
Account(TString&& number, float amount, TCreditCard&& creditCard)
: number(std::forward<TString>(number),
  amount(amount),
  creditCard(std::forward<TCreditCard>(creditCard)) {}

模板版本存在问题:用户无法再编写 Account("",0,{brace, initialisation}) - ipc
@ipc 啊,没错。这真的很烦人,我认为没有一个易于扩展的解决方法。 - R. Martinho Fernandes

6

首先,std::string就像std::vector一样,是相当沉重的类类型。它肯定不是原始类型。

如果您通过值将任何大型可移动类型传递给构造函数,则应该将它们std::move到成员中:

CreditCard(std::string number, float amount, CreditCard creditCard)
  : number(std::move(number)), amount(amount), creditCard(std::move(creditCard))
{ }

这正是我推荐实现构造函数的方式。它使成员numbercreditCard被移动构造,而不是复制构造。当您使用此构造函数时,将有一个副本(如果是临时对象,则为移动)作为对象传递到构造函数中,然后在初始化成员时进行一次移动。

现在让我们考虑这个构造函数:

Account(std::string number, float amount, CreditCard& creditCard)
  : number(number), amount(amount), creditCard(creditCard)

没错,这将涉及到 creditCard 的一个副本,因为它是首先通过引用传递给构造函数的。但现在你不能将 const 对象传递给构造函数(因为引用是非 const 的),也不能传递临时对象。例如,你不能这样做:

Account account("something", 10.0f, CreditCard("12345",2,2015,1001));

现在让我们考虑一下:
Account(std::string number, float amount, CreditCard&& creditCard)
  : number(number), amount(amount), creditCard(std::forward<CreditCard>(creditCard))

您在理解rvalue引用和std::forward时出现了误解。只有当您要转发的对象声明为某个推导类型TT&&时,您才应该真正使用std::forward。在这里,CreditCard是没有被推导的(我假设),因此错误地使用了std::forward。请查阅通用引用


1
对我来说最重要的事情有点不清楚:传递参数。
- 如果您想修改函数/方法内部传递的变量: - 您通过引用传递它。 - 您将其作为指针 (*) 传递。 - 如果您想读取函数/方法内部传递的值/变量: - 您通过const引用传递它。 - 如果您想修改函数/方法内部传递的值: - 您通过复制对象(**)正常传递它。
(*) 指针可能指向动态分配的内存,因此尽可能使用引用而不是指针,即使在最后,引用通常被实现为指针。 (**) "正常"意味着通过复制构造函数(如果传递了与参数类型相同的对象)或通过普通构造函数(如果传递了类的兼容类型)。例如,当您传递myMethod(std::string)时,如果传递给它一个std::string,则将使用复制构造函数,因此您必须确保存在一个。

1
我在一般情况下使用一个非常简单的规则: 对于POD类型(int,bool,double等),使用复制; 对于其他所有类型,使用const &。 是否需要复制取决于参数的使用方式,而不是方法签名的答案。
struct A {
  A(const std::string& aValue, const std::string& another) 
    : copiedValue(aValue), justARef(another) {}
  std::string copiedValue;
  const std::string& justARef; 
};

指针的精度:我几乎从未使用过它们。与 & 的唯一优势是它们可以为 null 或重新分配。

2
我在一般情况下使用一个非常简单的规则:对于POD(int,bool,double等),使用复制,对于其他所有内容,请使用const&。不行,绝对不行。 - Shoe
如果你想使用 & 修改一个值(没有 const),那么可以添加。否则我认为简单就足够了。但是如果你坚持的话… - David Fleury
有时你想通过引用原始类型进行修改,有时你需要复制对象,有时你必须移动对象。你不能仅仅将一切都简化为POD >按值和UDT >按引用。 - Shoe
好的,这只是我添加的部分。也许答复有点过快。 - David Fleury

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接