Effective Modern C++ 条款26 避免对通用引用进行重载

本文探讨了在C++中使用通用引用进行函数重载可能导致的意外行为,尤其是在构造函数和派生类构造中。作者提供了详细的案例分析,并强调了在特定情况下避免使用通用引用重载的重要性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

避免对通用引用进行重载

假如你要写一个函数,参数是name,它先记录当前日期和时间,然后把name添加到全局数据结构中。你可能会想出这样的一个函数:

std::multiset<std::string> names;    // 全局数据结构

void logAndAdd(const string& name)
{
    auto now = std::chrono::system_clock::now();  // 获取当前时间

    log(now, "logAndAdd");    // 记录日记

    names.emplace(name);      // 把name添加到全局数据结构中
}

这代码不是不合理,不过它不够效率。思考这三个可能的调用:

std::string petName("Darla");

logAndAdd(petName);    // 传递左值std::string

logAndAdd(std::string("Persephone"));   // 传递右值std::string

logAndAdd("Patty Dog");    // 传递字符串

在第一个调用中,logAndAdd的参数name绑定到变量petName上,而在logAndAdd内,name最终被传递给names.emplace。因为name是个左值,所以它被拷贝到names中。这个拷贝是无法避免的,因为传给logAndAdd的就是个左值(petName)。

在第二个调用中,参数name绑定的是一个右值(由字符串Persephone显示创建的临时std::string对象)。不过name本身是个左值,所以它还是会被拷贝到names,但是我们注意到,原则上,它的值可以被移到到names。在这个调用中,我们用的是拷贝,不过我们应该能够得到一次移动。

在第三个调用中,参数name又再一次绑定右值,不过这个临时std::string对象是由字符串隐式创建而来。和第二个调用一样,name是被拷贝到names,但在这个例子中,一开始传递给logAndAdd的参数是字符串。如果将字符串直接传递给emplace,是不需要创建临时的std::string对象的。取而代之的是,emplace会直接在std::multiset中用字符串构建std::string对象。在第三个调用中,我们还是要拷贝一个std::string对象的,不过我们真的没必要承担移动的开销,更何况移动。

通过重新写logAndAdd,让其接受一个通用引用,然后服从条款25对通用引用使用std::forward,我们可以消除第二个调用和第三个调用的低效率。代码是这样的:

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

std::string petName("Darla");    // 如前

logAndAdd(petName);      // 如前,将左值拷贝到multiset

logAndAdd(std::string("Persephone"));   // 移动右值来代替拷贝它

logAndAdd("Patty Dog");  // 在multisest内创建std::string,来代替创建临时std::string对象

yoooohu!最佳工作效率!

这就是故事的结尾了吗,我们可以功成身退了,不过,我没有告诉你,用户并不总是直接持有logAndAdd需要的name。一些用户只有名字表的索引,为了支持这些用户,我们重载了logAndAdd

std::string nameFromIdx(int idx);    // 根据idx放回name

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

这两个函数的重载决策的工作满足我们期望:

std::string petName("Darla");   // 如前

logAndAdd(petName);                // 如前,这三个都是使用T&&的重载
logAndAdd(std::string("Persephone"));
logAndAdd("PattyDog");

logAndAdd(22);    // 使用int重载

实际上,决策工作正常是因为你想的太少了。假如用户有个short类型持有索引,然后把它传递给logAndAdd

short nameIdx;
...               // 给nameIdx赋值
logAndAdd(nameIdx);   // 错误

最后一行的注释讲得不清楚,让我来解释这里发生了什么。

logAndAdd有两个重载,其中的接受通用引用的重载可以将T推断为short,因此产生了精确匹配。而接受int的重载需要提升才能匹配short参数。任何一个正常的重载决策规则,精确匹配都会打败需要提升的匹配,所以会调用接受通用引用的重载。

在那个重载中,参数name被绑定到传进来的short,然后name被完美转发到names(std::multiset<std::string>)的成员函数emplace中,在那里,相应地,emplace尽职地把short转发到std::string的构造函数。std::string不存在接收short的构造函数,因此multiset.emplace内的std::string构造调用会失败。这所有的所有都是因为对于short类型,通用引用的重载比int的重载更好。

接受通用引用作为参数的函数是C++最贪婪的函数,它们可以为几乎所有类型的参数实例化,从而创建的精确匹配。这就是为什么结合重载和通用引用几乎总是个糟糕的想法:通用引用重载吸收的参数类型远多于开发者的期望。


一种容易掉进这个坑的方法是写完美转发的构造函数。对logAndAdd这个例子进行小小的改动就可以展示这个问题,相比于写一个接受std::string或索引的函数,试着想象一个类Person,它的构造函数就是做那个函数的事情:

class Person {
public:
    template<typename T>
    explicit Person(T&& n)         // 完美转发构造函数
    : name(std::forward<T>(n)) {}  // 初始化成员变量

    explicit Person(int idx)   // 接受int的构造
    :name(nameFromIdx(idx)) {}
    ...
private:
    std::string name;
};

logAndAdd的情况一样,传递一个不是int的整型数会调用通用引用构造函数,然后那会导致编译失败。但是,这里问题更糟,因为比起看见的,Person会出现更多重载。条款17解释过在合适的条件下,C++会生成拷贝和移动构造,就算这个类有模板化的构造函数,它在实例化时也会生成拷贝和移动构造的签名。如果Person类生成移动和拷贝构造,Person看起来是这样的:

class Person {
public:
    template<typename T>
    explicit Person(T&& n)
    : name(std::forward<T>(n)) {}

    explicit Person(int idx);

    Person(const Person & rhs);   // 编译器生成的拷贝构造

    Person(Person&& rhs);   // 编译器生成的移动构造

    ...
};

当你花大量时间在编译器和写编译器的人上时,才能忘记常人的想法,知道这会导致个直观的问题::

Person p("Nancy");

auto cloneOfP(p);   // 从p创建一个新Person,这不能通过编译

在这里我们尝试用一个Person创建两一个Person,看起来明显是用拷贝构造的情况。(p是个左值,所以我们消除将“拷贝”换成移动的想法。)但这个代码不会调用拷贝构造函数,它会调用完美转发的构造函数,这个函数尝试用一个Person对象(p)来初始化另一个Person对象的std::string,而std::string没有接受Person为参数的构造函数,你的编译器很生气,后果很严重,发出一长串错误信息。

“为什么啊?”你可能很奇怪,“完美转发构造函数还能代替拷贝构造函数被调用?我是用一个Person对象初始化另一个Person对象啊!”事实上我们是这样做的,但是编译器是宣誓效忠于C++的规则的,而在这里使用的规则是重载决策规则。

编译器的理由是这样的:cloneOfP被一个非const左值(p)初始化,那意味着模板构造函数可以被实例化来接受一个非const左值Person,这样实例化之后,Person的代码变成这样:

class Person {
public:
    explicit Person(Person& n)      // 从完美转发模板构造实例化而来
    : name(std::forward<Person&>(n)) {} 

    explicit Person(int idx);

    Person(const Person& rhs); 

    ...
};

在这条语句中,

auto cloneOfP(p);

p既可以传递给拷贝构造,又可以传递给实例化模板。调用拷贝构造的话需要对p添加const才能匹配拷贝构造的参数类型,但是调用实例化模板不用添加什么。因此生成的模板是更加匹配的函数,所以编译器做了它们应该做的事情:调用更加匹配的函数。因此,“拷贝”一个非const的左值Person,会被完美转发构造函数处理,而不是拷贝构造函数。

如果我们稍稍改一下代码,让对象拷贝const对象,我们就会得到完全不一样结果:

const Person cp("Nancy");  // 对象是**const**的

auto cloneOfP(cp);      // 调用拷贝构造!

因为对象拷贝的对象是const的,它会精确匹配拷贝构造函数。模板化构造函数可以实例化出一样的签名,

class Person {
public:
    explicit Person(const Person& n);    // 实例化的模板构造

    Person(const Person& rhs);   // 编译器生成的拷贝构造

    ...
};

不过这没关系,因为C++重载决策的一个规则是:当一个模板实例化函数和一个非模板函数(即,一个普通函数)匹配度一样时,优先使用普通函数。因此,在相同的签名下,拷贝构造(普通函数)胜过实例化模板函数。

(如果你想知道为什么在实例化模板构造函数可以得到拷贝构造的签名的情况下,编译器还能生成拷贝构造函数,请去复习条例17。)

完美转发构造函数与编译器生成的拷贝和移动构造函数之间的纠纷在继承介入后变得更加杂乱。特别是,派生类的拷贝和移动构造的常规实现的行为让你大出所料。看这里:

class SpecialPerson :  public Person {
public:
    SpecialPerson(const SpecialPerson& rhs)  // 拷贝构造函数
    : Person(rhs)              // 调用基类的完美转发构造
    { ... }

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

就像注释表明那样,派生类的拷贝和移动构造并没有调用基类的拷贝和移动构造,而是调用了基类的完美转发构造!要理解为什么,你要注意到派生类的函数把类型SpecialPerson传递给基类,然后实例化模板,重载决策,对Person使用完美构造函数。最终,代码不能通过编译,因为std::string没有以SpecialPerson为参数的构造函数。

我希望我现在可以说服你应该尽可能地避免以通用引用重载函数。不过,如果对通用引用进行重载是个糟糕的想法,而你需要转发参数,或者特殊处理一些参数,该怎么做呢?方法有很多种,因为太多了,所以我决定把它放进一个条款中,它就是条款27,也就是下一条款,继续看吧,你会和它撞个满怀的。


总结

需要记住的2点:

  • 对通用引用进行重载几乎总是会导致这个重载函数频繁被调用,超出预期。
  • 完美转发构造函数是特别有问题的,因为在接受非const左值作为参数时,它们通常比拷贝构造匹配度高,然后它们还能劫持派生类调用的基类的拷贝和移动构造。
<think>好的,用户现在想了解《Effective Modern C++》这本书。之前他们问过C++新特性,我给出了一些版本特性的总结,现在他们可能想深入学习和应用这些特性,需要一本好的学习资料。 首先,我需要确认用户的需求。他们可能已经知道C++11/14/17等的新特性,但需要如何正确高效地使用这些特性的指导。Effective Modern C++正好是Scott Meyers写的,专门讲这方面的最佳实践,所以用户可能想了解这本书的结构、内容以及是否适合他们。 接下来,用户可能没有直接说出的是他们的实际水平。如果他们已经看过我之前的回答,可能对C++有一定的基础,但希望提升到现代C++的实践层面。需要强调这本书适合有一定C++基础,想要进阶的开发者。 然后,我需要介绍这本书的核心内容,比如条款数量、重点覆盖的特性,比如类型推导、智能指针、移动语义、lambda等,这些都是现代C++的关键点。还要提到这本书的写作风格,Scott Meyers以清晰易懂著称,所以应该指出这一点。 用户可能还想知道这本书是否有中文版,或者是否有电子版,但根据已有信息,暂时不需要回答这些,但可以建议他们查阅相关资源,比如C++ Reference或者在线文档。 另外,需要结合之前用户的问题,他们之前问的是新特性,现在这本书是关于如何有效使用这些特性,所以可以强调这本书如何帮助他们避免常见错误,写出更高效、安全的代码。比如智能指针的使用陷阱,移动语义的正确应用等。 可能还需要提到这本书的章节结构,比如分为类型推导、智能指针、右值引用等部分,这样用户能清楚书的内容组织。同时,给出一些具体的例子,比如auto和decltype的区别,或者完美转发的使用,这样用户能更直观地理解书中的内容。 最后,要给出学习建议,比如建议边读边实践,结合项目应用,或者参考其他资源。用户可能是在自学,或者准备面试,需要高效地掌握这些知识点,所以强调实践的重要性。 需要避免的是过于冗长的介绍,保持信息结构清晰,分点列出,让用户容易阅读。同时,确保没有错误信息,比如正确引用书中的条款和例子,避免误导用户。当然</think>可以! **《Effective Modern C++》** 是由 C++ 专家 **Scott Meyers** 撰写的经典书籍,专注于教授如何高效、安全地使用 **C++11 和 C++14** 的新特性。书中通过 **42 条实用条款**,帮助开发者规避陷阱,写出更现代化的 C++ 代码。 --- ### **核心内容概览** #### **1. 类型推导与 `auto`(条款1-4)** - **`auto` 的优势**:避免类型冗余,减少错误。 - **类型推导规则**:区分 `auto` 与模板类型推导的差异。 - **示例**: ```cpp auto x = {1, 2}; // x 推导为 std::initializer_list<int> template<typename T> void f(T param); f({1, 2}); // 错误!模板无法推导 initializer_list ``` #### **2. 智能指针与资源管理(条款18-22)** - **`std::unique_ptr`**:独占所有权,轻量高效。 - **`std::shared_ptr`**:共享所有权,注意循环引用(搭配 `weak_ptr` 解决)。 - **示例**: ```cpp auto p = std::make_unique<int>(42); // 优先使用 make_unique std::shared_ptr<Widget> sptr(new Widget, customDeleter); // 自定义删除器 ``` #### **3. 右值引用与移动语义(条款23-29)** - **移动语义的意义**:避免不必要的深拷贝(如临时对象传递)。 - **完美转发**:`std::forward` 保持值类别(左值/右值)。 - **示例**: ```cpp void process(Widget&& w); // 仅接受右值 template<typename T> void relay(T&& arg) { // 万能引用 process(std::forward<T>(arg)); // 完美转发 } ``` #### **4. Lambda 表达式(条款31- **闭包的生命周期管理**:注意捕获变量的悬空引用。 - **初始化捕获(C++14)**:移动捕获的替代方案。 - **示例**: ```cpp auto func = [data = std::move(data)] { /* 使用移动后的 data */ }; // C++14 初始化捕获 ``` #### **5. 并发与多线程(条款35-40)** - **`std::thread` 与 `std::async`**:任务启动策略的选择。 - **原子操作与内存序**:理解 `std::atomic` 和内存模型。 - **示例**: ```cpp std::future<int> fut = std::async(std::launch::async, computeValue); ``` --- ### **书中的经典建议** 1. **优先使用 `const_iterator` 而非 `iterator`**(条款13)。 2. **用 `std::call_once` 替代静态局部变量初始化**(解决线程安全问题,条款16)。 3. **避免重载万能引用模板函数**(条款26:可能导致意外匹配)。 4. **理解 `std::move` 和 `std::forward` 的区别**(条款23)。 --- ### **适合读者** - 已掌握 C++ 基础,希望深入 **现代 C++C++11/14)** 的开发者。 - 需要避免新特性误用(如错误使用移动语义、智能指针泄漏)的进阶者。 --- ### **与其他书籍的对比** - **《Effective C++》**:聚焦传统 C++ 的最佳实践。 - **《Effective Modern C++》**:专注 C++11/14 新特性,与前作互补。 --- ### **学习建议** 1. **边读边实践**:通过代码验证条款中的观点。 2. **结合官方文档**:查阅 [C++ Reference](https://en.cppreference.com) 理解标准库细节。 3. **关注 C++17/20 扩展**:书中未覆盖更新标准,需补充学习(如概念、协程)。 如果需要具体条款的代码示例或解释,可以告诉我! 😊
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值