文章

条款41:对于移动成本低且总是被拷贝的可拷贝形参,考虑按值传递

对移动代价低、总是被拷贝的参数,按值传递更简洁高效,能统一处理左值与右值,避免重复代码和不必要的复制。

条款41:对于移动成本低且总是被拷贝的可拷贝形参,考虑按值传递

条款41:对于移动成本低且总是被拷贝的可拷贝形参,考虑按值传递

背景

许多函数参数是可拷贝类型(如 std::string),需要将参数拷贝或移动到类成员或容器中。如何高效且简洁地写这类函数是一个常见问题。

传统解决方案有两种:

  • 重载:对左值传引用,右值传右值引用。
  • 通用引用模板:用模板 T&& 接收任意值,转发实现。

但这两种方式代码重复或复杂,维护成本高。

典型示例

假设类 Widget 中有个 std::vector<std::string> 成员 names,想实现 addName,把传入名字添加进去。

重载版本(对左值和右值分别重载)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget {
public:
    void addName(const std::string& newName) {
        // 左值,拷贝到容器
        names.push_back(newName);
    }

    void addName(std::string&& newName) {
        // 右值,移动到容器
        names.push_back(std::move(newName));
    }

private:
    std::vector<std::string> names;
};

优点

  • 性能最佳,左值拷贝一次,右值移动一次。

缺点

  • 代码重复,维护麻烦。
  • 目标代码会生成两个函数,可能导致代码膨胀。

通用引用模板版本(万能转发)

1
2
3
4
5
6
7
8
9
10
11
class Widget {
public:
    template<typename T>
    void addName(T&& newName) {
        // 使用完美转发,左值拷贝,右值移动
        names.push_back(std::forward<T>(newName));
    }

private:
    std::vector<std::string> names;
};

优点

  • 一份代码,支持左值右值多种类型。

缺点

  • 模板实现必须放头文件,编译时生成多个实例。
  • 编译错误难理解。
  • 可能代码膨胀(多种类型、多种值类别实例化)。
  • 有些类型不能完美转发。

按值传递版本(只写一个普通函数)

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
    void addName(std::string newName) {
        // 形参 newName 是函数内独立副本
        names.push_back(std::move(newName));
    }

private:
    std::vector<std::string> names;
};

工作原理

  • 传入左值时,形参 newName 拷贝构造。
  • 传入右值时,形参 newName 移动构造。
  • 函数内对 newName 移动,效率较高。

优点

  • 只需写一个函数,维护简单。
  • 目标代码只有一个函数,不膨胀。
  • 避免模板复杂性。

缺点

  • 传左值时,额外多了一次移动开销。
  • 传右值时,可能多一次移动开销(两次移动)。
  • 不适合只可移动类型(如 unique_ptr)。
  • 可能引起对象切片问题(基类按值传派生类会丢失派生信息)。

性能对比

调用场景重载或通用引用开销按值传递开销
传递左值(如 std::string name拷贝一次(左值引用绑定)拷贝一次 + 移动一次
传递右值(如临时字符串)移动一次移动两次

移动通常比拷贝便宜很多,所以按值传递的性能差异通常不大。

按值传递的适用条件

  • 形参类型是可拷贝的(非只可移动)。
  • 移动构造和移动赋值操作成本很低(如 std::stringstd::vector 等带移动语义的标准容器)。
  • 函数无条件需要复制参数(总是要保存一份),不能避免。
  • 不涉及基类多态类型,避免对象切片。
  • 允许额外一次移动操作的开销。

示例:只可移动类型(unique_ptr)不适合按值传递

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Widget {
public:
    // 右值引用版本,开销最小
    void setPtr(std::unique_ptr<std::string>&& ptr) {
        p = std::move(ptr);
    }

    // 按值传递版本,额外多一次移动开销
    void setPtr(std::unique_ptr<std::string> ptr) {
        p = std::move(ptr);
    }

private:
    std::unique_ptr<std::string> p;
};

调用示例:

1
2
Widget w;
w.setPtr(std::make_unique<std::string>("Modern C++"));

按值传递会先移动构造 ptr,再移动赋值给 p,移动两次;右值引用版本移动一次,性能更优。

按值传递与赋值操作的复杂性

如果函数内部用赋值操作(非构造)拷贝形参,开销更复杂:

1
2
3
4
5
6
7
8
9
10
11
class Password {
public:
    explicit Password(std::string pwd) : text(std::move(pwd)) {}

    void changeTo(std::string newPwd) { // 传值
        text = std::move(newPwd);       // 赋值操作
    }

private:
    std::string text;
};

调用示例:

1
2
std::string newPassword = "Beware the Jabberwock";
p.changeTo(newPassword);
  • 传入左值时,newPwd 由拷贝构造构造,分配新内存。
  • 然后赋值操作 text = std::move(newPwd) 可能释放旧内存,分配新内存。
  • 整体造成两次内存分配和释放,开销较大。

相比之下,重载左值版本:

1
2
3
void changeTo(const std::string& newPwd) {
    text = newPwd;  // 可能复用已有内存,避免多次分配
}

能有效减少内存操作,提升性能。

对象切片问题

“切片”(Object Slicing)是C++中的一个经典问题,指的是将一个派生类对象按值传递(或赋值)给基类类型时,派生类对象中独有的成员和特性会被“切掉”,只保留基类部分。

按值传递基类对象会造成切片,导致派生类特征丢失:

1
2
3
4
5
6
7
class Widget { /* ... */ };
class SpecialWidget : public Widget { /* ... */ };

void processWidget(Widget w);  // 按值传递会切片

SpecialWidget sw;
processWidget(sw); // sw 会被切片成 Widget 部分

因此,按值传递不适合基类形参类型

总结与建议

  • 按值传递适合“移动开销小且总是需要复制”的可拷贝类型参数。
  • 它让代码简洁,维护方便,避免模板复杂和代码重复。
  • 性能上,左值实参会多一次移动,右值实参多一次移动,通常可以接受。
  • 不适合只可移动类型(unique_ptr等),也不适合多态基类形参(避免切片)。
  • 对于函数链调用,每层按值传递会累积移动开销,性能敏感时慎用。
  • 如果函数内部赋值(非构造)拷贝形参,可能导致额外内存分配释放,效率更低。

如果编写的函数总是复制形参,且拷贝开销不高,推荐采用按值传递+std::move的简洁方案。

如果对性能要求极高或参数为只可移动类型,则优先考虑重载或通用引用。

本文由作者按照 CC BY 4.0 进行授权