在探讨了移动语义的机制之后,我们面临一个更实际的问题:如何在实际的函数设计中充分利用这些机制?​ 移动语义、右值引用、完美转发为我们提供了强大的工具,但这些工具的最佳使用场景是什么?不同的参数传递方式又有怎样的性能特性和设计考量?

三种传参方案

现代C++提供了多种参数传递策略:左值引用重载、右值引用重载、万能引用完美转发、按值传递移动。每种策略都有其适用场景和权衡点。理解这些选择背后的性能模型和设计哲学,是编写高效、健壮C++代码的关键。

对于函数传参的参数设计,常见的有以下几种形式:

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;
};

class Widget {       // 途径二:
public:              // 使用万能引用
    template<typename T>
    void addName(T&& newName)
    { names.push_back(std::forward<T>(newName)); }
    ...
};

class Widget {        // 途径三:
public:               // 按值传递
    void addName(std::string newName)
    { names.push_back(std::move(newName)); }
    ...
};

//考虑三种调用方式:
Widget w;
std::string name("Aixheee");
w.addName("Aixheee"); //传入左值 但类型不同 
w.addName(name);      //传入左值
w.addName(name + "hello"); //传入右值

对于途径一(重载) 下的三种调用方式:

  • w.addName(“Aixheee”):会产生临时字符串对象,再调用右值引用版本,由于是右值引用,所以不会再次构造,最后在push_back()时触发移动构造。一次直接构造(临时对象创建)+一次析构(临时对象析构)+一次移动构造(push_back)

  • w.addName(name):不产生临时对象,调用const左值引用版本,push_back()时触发拷贝构造。一次拷贝构造(push_back)

  • w.addName(name + “hello”):不产生额外的临时对象(即传参过程中不产生临时对象,operator+产生临时对象带来的一次构造+一次析构不考虑),调用右值引用版本,最后在push_back()时触发移动构造。一次移动构造(push_back)


对于途径二(万能引用) 下的三种调用方式:

  • w.addName(“Aixheee”):”Aixheee”的类型为const char[]数组类型,推导时退化为const char指针类型。此时,由于是万能引用+完美转发,所以不会产生临时对象。但是在push_back时,由于是const char 右值类型,所以会产生**一次string构造

  • w.addName(name):不产生临时对象,万能引用接收+完美转发,push_back()时触发拷贝构造。一次拷贝构造(push_back)

  • w.addName(name + “hello”):不产生额外的临时对象(即传参过程中不产生临时对象,operator+产生临时对象带来的一次构造+一次析构不考虑),万能引用接收+完美转发,最后在push_back()时触发移动构造。一次移动构造(push_back)


对于途径三(值传递+std::move) 下的三种调用方式:

由于是值传递,所以一定涉及到一次构造。

  • w.addName(“Aixheee”):”Aixheee”的类型为const char[]数组类型,推导时退化为const char*指针类型。此时,会先直接构造一个string类型对象,push_back()时触发移动构造。一次直接构造(形参创建)+一次移动(push_back)+一次析构(形参析构)

  • w.addName(name):由于是左值,所以有一次拷贝构造用于创建形参,接着在push_back()时触发一次移动构造。一次拷贝构造(形参创建)+一次移动(push_back)+一次析构(形参析构)

  • w.addName(name + “hello”):不产生额外的临时对象(即传参过程中不产生临时对象,operator+产生临时对象带来的一次构造+一次析构不考虑)。由于是一个右值,所以有一次移动构造用于创建形参,接着在push_back()时又触发一次移动构造。一次移动构造(形参创建)+一次移动(push_back)+一次析构(形参析构)


方案二

可以看出方案二的成本是最低的,但是并不推荐使用方案二:

【Effective Modern C++ 26条款】不要把将万能引用应用于函数重载,尤其是构造函数!由于万能引用是模板推导+值类型万能匹配,所以其匹配能力过强,在编译器重载决议时往往调用不到预期函数,从而产生意想不到的错误。该问题在重载构造函数的场景下尤为严重,其会劫持拷贝构造和移动构造函数

具体来说:比如,同时提供了根据形参类型为int获取结果的函数以及通过万能引用获取结果函数。如果用户想调用参数为int版本的重载函数,但却传入了一个long,那么就会调用到万能引用版本的函数,而不是int版本的,因为万能引用版本更匹配

template<typename T>
void logAndAdd(T&& name)
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward<T>(name));
}

void logAndAdd(int idx)            // 新的重载函数
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(nameFromIdx(idx));
}

在构造函数的场景下尤为严重:万能引用重载构造函数会劫持拷贝构造函数,因为拷贝构造函数一般会用常引用作为参数,而传入的待拷贝对象通常都是非const对象,这就导致万能引用重载版本的匹配更为精准,从而”劫持“调用

class Person {
public:
    template<typename T>             // 完美转发构造函数
    explicit Person(T&& n)
        :name(std::forward<T>(n)) {}
    explicit Person(int idx);     // 形参为 int 的构造函数
    Person(const Person& rhs);    // 复制构造函数(由编译器生成)
    Person(Person&& rhs);         // 移动构造函数(由编译器生成)
    ...
};

Person p("Aixheee");
Person p2(p); //居然无法通过编译!!!

如果存在继承关系,则会更为严重:

class SpecialPerson : public Person {
public:
    SpecialPerson(const SpecialPerson& rhs)  // 复制构造函数;
        :Person(rhs)   //调用的是基类的完美转发构造函数!
    { ... }        

    SpecialPerson(SpecialPerson&& rhs)   // 移动构造函数;
        :Person(std::move(rhs)) //调用的是基类的完美转发构造函数!
    { ... }      

解决方案之一:

std::enable_if对万能引用重载做约束,使其只在满足特定条件时才参与重载,从而避免”过度匹配“的问题。 其核心原理是利用 SFINAE,是不满足条件的模板在重载决议阶段被移除,不生成对应的模板函数。常见的做法比如排除自身以及派生类型、限制可转换类型等等,以及区分不同类型的参数

//排除自身以及派生类型
class Person {
public:
    template<typename T,
             typename = std::enable_if_t<
                 !std::is_base_v<std::decay_t<T>, Person>
                 &&!std::is_integral<std::remove_reference_t<T>>
             >> //decay_t用于去除const volatile修饰(cv饰词)
    explicit Person(T&& name);
};

这种方案的优势是,保留了万能引用重载,其效率较高;缺点是,如果出现错误,报错冗长难以分辨,易用性不好


方案一与方案三

对于方案一(左值重载和右值重载):

其虽然匹配精准。其缺点在于:

写起来较为麻烦;在发生隐式类型转换的情况下,成本略高。比如调用setName(“aaaa”),字符串常量是const char*类型,对于重载来说,需要先进行隐式类型转换,构造出临时字符串对象,匹配右值引用版本;而对于万能引用来说,可直接进行模板推导,没有中间的临时对象。少了一次构造、一次移动构造、一次析构

对于方案三(按值传递+std::move()):

其核心优点在于,通过引入一次额外的移动操作来统一处理了左值和右值,用些许性能换取了简洁性。

具体来说,对于按值传递,其既可以通过拷贝构造接收左值,也可以通过移动构造接收右值。但是对比左值右值分别重载版本,其至少会多引入一次move,具体分析:不过这是在函数内部一定需要“消费(存储/拷贝)”参数的前提下,否则引入的额外开销会更大

void setName(std::string name);
//传左值:s->name->std::move(name) 1copy+1move
//传右值:临时对象->name->std::move(name) 2move
void setName(const std::string& name) {
    this->name = name; // 1 copy
}
void setName(std::string&& name) {
    this->name = std::move(name); // 1 move
}

所以,在使用按值传递+std::move()时,需要考虑两方面:一是在当前场景下,是否真的可以发挥这种写法的优势?二是这种优势的代价会不会过大? 对于前者,我们应该考虑——是否真的需要同时支持左值和右值重载该支持类型的传递;对于后者,我们应该考虑——什么时候这种写法带来的代价相较于左值右值重载较小

  • 仅仅对于支持拷贝复制的形参类型,才推荐使用按值传递。(从是否真的可以发挥优势来考虑) 因为对于可复制类型,按值传递可以统一copy和move;但是对于只移类型(无拷贝,只有移动)来说,其不存在copy路径,按值传递只会引入额外的一次move,直接使用右值引用作为参数即可。这既是效率上的提升,更是设计原则上的完善——按值传递版本的优势就在于,只需要写一个函数即可同时接收左值与右值,但对于“只移类别”来说,根本不需要提供对左值的重载版本,只提供接收右值的版本完全足够。 所以,不推荐使用按值传递,只提供右值版本即可具体来说,比如对于unique_ptr,如果按值传递,会先通过move构造形参,再通过move来传递参数,共两次

    void setPtr(std::unique_ptr<std::string> ptr) {
        p = std::move(ptr);
    }
    

    如果按右值引用传递,则不会构造形参,只有一次move

    void setPtr(std::unique_ptr<std::string>&& ptr) {
        p = std::move(ptr);
    }
    w.setPtr(std::make_unique<std::string>("Modern C++"));
    
  • 在函数一定会消费参数,比如存储、拷贝的场景下才适用于按值传递。(从代价的角度考虑) 如果参数可能不会被使用,比如带条件的插入或校验逻辑,则按值传递可能会引入不必要的构造、析构成本,此时应该优先使用const引用。

    void addName(std::string newName) {
        //无论是否真的使用newName 都会付出构造+析构的代价
        if ((newName.length() >= minLen) && (newName.length() <= maxLen))
            names.push_back(std::move(newName));
    }
    

    但要注意区分“赋值”和“拷贝构造”。对于基于赋值的参数复制,相比于左值右值重载,如果本来可以在“赋值阶段”做优化(比如复用内存),那么“传值”会让你提前失去这个机会,破坏对象内部资源复用优化的机会,其性能开销远大于一次移动操作。具体来说,传入左值一定会发生1copy+1move;但是对于引用,其存在优化点,比如下面的text在text.capacity >= newPwd.size() 的条件下,完全可以memcpy复用原有内存,而不是赋值分配新内存。在比较之下,此时按值传递+std::move()付出的成本较大实际上,此时的成本取决于对象当前的状态,性能变成”数据相关“——需要考虑数值的大小、是否有优化策略(比如std::string的SSO,对于小字符串来说,move ≈ copy)等等

    void changeTo(const std::string& newPwd) {
        text = newPwd;
    }
    
  • 在移动构造成本较低时,才使用该方式(从代价的角度考虑)。 否则,这种额外移动的开销将抵消其带来的简洁性优势。因为按值传递通过引入了一次额外的移动操作来统一处理左值和右值。

  • 如果在有切片风险的场景下,不能使用按值传递


总结:

按值传递的虽然带来了代码的简洁性、避免了完美转发的复杂性(误匹配、报错可读性差),属于一种折中方案,但是其性能取决于多种上下文因素,比如是否拷贝/存储参数、是否重新分配内存、move代价等,我们在写代码时很难保证某些条件一直成立,有较大的不确定性。相比之下,左值右值重载的方式更为更稳定和可控。

因此,应该默认采取左值右值重载的方式,仅在完全保证按值传递不会带来额外的较大性能开销时,才将其作为简化接口的替代方案

而对于万能引用+完美转发,其应用场景不在函数重载和普通处理函数,但是在保持参数值类别并转发给其他函数的的场景下,其是我们唯一的选择。 具体来说,由于其匹配能力过强且类型推导复杂,容易导致”劫持“目标函数的调用,不应该用于普通的处理函数其真正的应用场景在”转发“,即该函数本身不”消费“(存储/拷贝)参数,只是负责将这些参数“转交”给目标函数进行处理。比如工厂函数make_shared,该函数本身不关心这批参数的一切信息,只是负责转交:

template<typename T, typename... Args>
std::unique_ptr<T> make(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

function中的包装函数:

template<typename F, typename... Args>
auto call(F&& f, Args&&... args) {
    return std::forward<F>(f)(std::forward<Args>(args)...);
}

emplace系列:

template<typename... Args>
void emplace_back(Args&&... args);

至此,我们完成了C++内存管理系列中关于移动语义与参数传递策略的深入探讨。从malloc的底层内存分配,到new与构造函数的关系,再到RAII、智能指针、值语义与引用语义的辨析,最后落脚于移动语义及其在函数传参中的实际应用——这一路走来,我们始终围绕着一个核心命题:如何以最小且可控的开销,高效、安全管理内存与资源的生命周期。

在接下来的文章中,我们将正式进入Unity的内存管理世界,从它的双轨制内存架构开始,深入剖析托管内存的分配与回收机制。