如何正确传递参数?


108

我是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
Meta:我不敢相信有人问了一个很好的C ++问题。+1。

23
仅供参考,std::string是一个类,就像CreditCard,不是原始类型。
克里斯

7
由于字符串混乱,尽管无关紧要,但您应该知道字符串文字,"abc"不是类型std::string,不是类型char */const char *,而是类型const char[N](在这种情况下,由于三个字符和一个空值,因此N = 4)。这是一个很好的,常见的误解,可以避免。
克里斯

10
@chuex:乔恩·斯基特(Jon Skeet)到处都是C#问题,很少是C ++。
史蒂夫·杰索普

Answers:


158

最重要的问题首先:

有没有关于如何在C ++中发送参数的最佳实践,因为我确实发现它,而不是琐碎的

如果您的函数需要修改要传递的原始对象,以便在调用返回后,调用者可以看到对该对象的修改,那么您应该通过左值引用传递:

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

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

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

这将允许您同时使用左值(左值是具有稳定标识的对象)和右值(例如,左值是temporaries或由于调用而将要移出的对象)来调用函数std::move()

人们也可以说,对于基本类型,或类型,其复制速度很快,例如intbool或者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...
}

调用上述函数将在传递左值时始终导致一个副本,而在传递左值时将导致一个动作。如果你的函数需要存储这个对象的地方,你可以执行额外的举措从它(例如,在案件foo()一个成员函数,需要存储在数据成员的值)。

如果对于类型的对象而言移动昂贵my_class,那么您可以考虑重载,foo()并为lvalues提供一个版本(接受对的lvalue引用const),为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!
}

还要注意,除非有充分的理由,否则通常不求助于基于模板的解决方案,因为这会使您的代码更难阅读。通常,您应该专注于清晰和简单

以上只是简单的指导原则,但是在大多数情况下,它们会将您引向正确的设计决策。


关于您的帖子的其余部分:

如果我将其重写为[...],将有2步动作且没有副本。

这是不正确的。首先,右值引用不能绑定到左值,因此仅在将类型的右值传递CreditCard给构造函数时才编译。例如:

// 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值取值时生成的移动数量,则可以定义两个构造函数重载,一个重载左值引用constCreditCard 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,正如你所想。另一方面,当传递一个右值时,C将推导为CreditCard,并且将实例化此函数:

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

这将导致移动建设creditCard,这是你想要的(因为正在传递的价值是一个右值,这意味着我们被授权从它移动)。


可以肯定地说,对于非原始参数类型,始终将模板版本与forward一起使用是可以的吗?
杰克·威尔森

1
@JackWillson:我想说的是,仅当您确实对移动性能感到担忧时,才应诉诸模板版本。有关更多信息,请参见我的这个问题,该问题有一个很好的答案。通常,如果您不必复制并且只需要观察,请通过ref到const。如果需要修改原始对象,请通过引用引用非`const。如果您需要复制并且搬家很便宜,请先取值再搬家。
Andy Prowl

2
我认为没有必要继续介绍第三个代码示例。您可以使用obj而不是制作本地副本来迁移,不是吗?
juanchopanza

3
@AndyProwl:您已经+1个小时了,但是重新阅读(出色的)答案时,我想指出两件事:a)按值并不总是创建副本(如果不移动)。可以在调用者站点上就地构建该对象,尤其是使用RVO / NRVO时,它的工作频率甚至比人们最初想到的要高。b)请指出,在最后一个示例中,std::forward只能调用一次。我见过人们将其放入循环等等,由于许多初学者都会看到此答案,因此恕我直言,应该有一个肥胖的“警告!”标签来帮助他们避免这种陷阱。
丹尼尔·弗雷

1
@SteveJessop:除此之外,转发函数(尤其是带有一个参数的构造函数)还存在技术问题(不一定是问题),大多数情况是它们接受任何类型的参数并且可能会std::is_constructible<>破坏类型特征,除非它们正确地通过SFINAE-受限制-对于某些人来说可能并不重要。
Andy Prowl 2013年

11

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

将有2个动作,没有副本。

那是错误的。绑定到右值引用绝非易事。只有一招。

此外,由于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})
2013年

@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))
{ }

这正是我建议实现构造函数的方式。它导致成员number并被creditCard移动构造,而不是副本构造。当使用此构造函数时,当对象传递到构造函数时,将有一个副本(或移动,如果是临时的),然后在初始化成员时移动一个。

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

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))

在这里,您显示了对右值引用和的误解std::forward。仅std::forward当将要转发的对象声明T&&为某种推断类型 时,才应该真正使用T。这里CreditCard没有推论(我假设),因此std::forward正在错误地使用。查找通用参考


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 2013年

好的,这就是我添加的内容。可能是一个太快的答案。
David Fleury

1

对于我来说,最重要的一点还不清楚:传递参数。

  • 如果要修改在函数/方法内部传递的变量
    • 你通过参考
    • 您将其作为指针传递(*)
  • 如果要读取在函数/方法内传递的值/变量
    • 您通过const reference传递它
  • 如果要修改在函数/方法内部传递的值
    • 通常通过复制对象(**)来传递它

(*)指针可能指向动态分配的内存,因此,即使最后引用通常被实现为指针,也应尽可能使用引用而不是指针。

(**)“通常”是指通过复制构造函数(如果您传递参数的相同类型的对象)或通过常规构造函数(如果您为该类传递兼容的类型)。myMethod(std::string)例如,当您将对象传递为时,如果将std::string传递给,则将使用复制构造函数,因此必须确保一个存在。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.