方法应将 R 值引用作为输入是否有任何正当理由



如标题所示,我想知道是否有一个有效的理由/示例,给定的类方法(不包括移动构造函数移动赋值运算符或自由函数)应该将R值引用作为输入参数。

保留值时避免不必要的复制。我喜欢将此函数称为"接收器函数"。这在定义二传手时经常发生。

class A
{
private:
std::string _s;
public:
void setS(const std::string& s) { _s = s; }
void setS(std::string&& s) { _s = std::move(s); }
};

int main()
{
A a;
std::string s{"some long string ......."};
a.setS(s); // copies and retains `s`
a.setS(std::move(s)); // moves and retains `s`
}

有两种方法可以对接收器参数进行建模。

第一种是按值取:

void foo(std::string);

第二个是通过右值引用:

void foo(std::string&&);

可能的变体是包含const&重载以简化调用方的工作。

inline void foo(std::string const& s){
auto tmp = s;
return foo(std::move(tmp));
}

与按&&const&获取值相比,按值接收具有单个std::move的额外开销(或要求调用方手动复制非临时值并将其自行移动)。 它不需要第二次重载。

因此,如果这一步值得考虑,那么通过const&&&来考虑汇参数可以节省您的移动。 另外,如果复制非常昂贵,您可以在呼叫站点使其变得尴尬,从而阻止它。


但这不是唯一的原因。 有时您想检测某些内容是右值还是左值,并且仅在它是右值时才复制。

例如,假设我们有一个范围适配器backwardsbackwards采用适当的范围(您可以for(:)的范围,并且可以反转其迭代器)并返回一个向后迭代的范围。

天真地,您所要做的就是从源范围获取beginend,然后制作反向迭代器并存储它们并从您自己的beginend方法返回它们。

可悲的是,这打破了:

std::vector<int> get_some_ints();
for( int x : backwards( get_some_ints() ) ) {
std::cout << x << "n";
}

因为从get_some_ints返回的临时的生存期不会因for(:)循环而延长!

for(:)大致扩展为:

{
auto&& __range_expression = backwards( get_some_ints() );
auto __it = std::begin( __range_expression );
auto __end = std::end( __range_expression );
for (; __it != __end; ++__it) {
int x = *__it;
std::cout << x << "n";
}
}

(上面有一些小谎言告诉孩子们,但对于这个讨论来说已经足够接近了)。

特别是这一行:

auto&& __range_expression = backwards( get_some_ints() );

backwards的返回值是生存期延长的;但其参数的生存期不是!

因此,如果backwards采用R const&,则vector在循环之前被静默销毁,并且所涉及的迭代器无效。

因此backwards必须存储vector的副本才能使上述代码有效。 这是我们让矢量持续足够长的唯一机会!

另一方面,在更传统的情况下:

auto some_ints = get_some_ints();
for( int x : backwards( some_ints ) ) {
std::cout << x << "n";
}

存储some_ints的额外副本将是一个可怕的想法,而且非常出乎意料。

所以在这种情况下,backwards需要检测它的参数是右值还是左值,如果它是右值,它需要复制它并将其存储在返回值中,如果它是一个左值,它只需要存储迭代器或对它的引用。

有时你想拥有像std::vector这样大的东西,但你想避免意外复制。

通过仅提供 r 值引用重载,想要传递副本的调用方必须显式执行此操作:

class DataHolder {
std::vector<double> a;
std::vector<int> b;
public:
DataHolder(std::vector<double>&& a, std::vector<int>&& b) : a(a), b(b) {}
};
auto a1 = makeLotsDoubles();
auto b1 = makeLotsInts();
DataHolder holder(std::move(a1), std::move(b1));  // No copies. Good.
auto a2 = makeLotsDoubles();
auto b2 = makeLotsInts();
DataHolder holder(a2, b2) // Forgot to move, compiler error.

相反,如果您使用了按值传递,那么如果您忘记在左值上使用std::move,则会创建副本。

不可复制的对象传输到类很有用。 某些对象没有复制构造函数,这意味着它们不能按值传递。 人们在RAII中看到了这一点,其中创建副本将获得一组新的资源。 或者一般来说,当复制构造函数和/或析构函数在被(反)构造的对象之外有副作用时。复制可能不仅仅是低效的,就像复制 std::string 一样,但完全不可能。

传递此类对象的一种方法是将其作为指针传递。 例如:

// Creates a file on construction, deletes on destruction
class File;
{    
// Create files
File* logfile1 = new File("name1");
File logfile2("name2");
// Give files to logger to use
logger.add_output(logfile1); // OK
logger.add_output(&logfile2);  // BAD!
}

File没有复制构造函数,因为副本会创建和删除与原始文件相同的文件,这毫无意义。 因此,我们将指向文件的指针传递给logger对象以避免复制。 有了logfile1它就可以正常工作,当然假设logger在完成后删除文件。 但logfile2有两个大问题。 一个是logger无法删除它,因为它没有与new一起分配。 另一种是logfile2将被破坏,删除文件并使保存在logger中的指针无效,当logfile2离开块末尾的作用域时。

我们可以给File一个移动构造函数,它将文件的所有权转移到目标文件并使源文件"为空"。 现在我们可以以一种有效的方式编写上面的代码。

{    
// Create files
File* logfile1 = new File("name1");
File logfile2("name2");
// Give files to logger to use
logger.add_output(std::move(*logfile1));
logger.add_output(std::move(logfile2));
logger.add_output(File("name3"));
delete logfile1;
}

我们现在可以使用 new 创建一个File,或者作为本地对象,甚至作为未命名的 pr-value。std::move的使用还清楚地表明,在呼叫站点,File的所有权已转移给logger。 由于指针所有权转移没有清楚地显示,这取决于logger.add_output()记录语义并检查该文档。

最新更新