文章

条款27:熟悉万能引用重载的替代方法

用enable_if或概念限制模板参数,实现更安全的重载选择。

条款27:熟悉万能引用重载的替代方法

27:熟悉万能引用重载的替代方法

背景示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::multiset<std::string> names;

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 nameFromIdx(int idx);

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

问题logAndAdd(short) 会调用 T&& 版本,而不是 int 重载,导致错误或不符合预期行为。

条款26 中提到,万能引用T&&,即同时能绑定左值和右值的引用)用于函数重载(尤其构造函数)时,极易造成重载解析混乱或意外调用。本条款探索几种替代手段来避免这种困境。

问题本质

  • 万能引用 T&& 太“贪婪”,几乎能匹配所有类型(包括左值、右值、非目标类型如 short)。
  • 容易导致意外的重载解析结果。
  • 尤其在构造函数中(如 Person(T&&)Person(int)),容易引发歧义甚至编译失败。

替代方案

放弃重载,分开命名

做法:将函数名拆开,例如 logAndAddName()logAndAddIndex()

1
2
3
4
5
6
7
void logAndAddName(std::string name) {
    names.emplace(std::move(name));
}

void logAndAddIndex(int idx) {
    names.emplace(nameFromIdx(idx));
}
  • 适用场景:函数非构造函数时。
  • 限制:构造函数名称不能改,不适用。

const T&

1
2
3
void logAndAdd(const std::string& name) {
    names.emplace(name);
}
  • 优点:无重载问题,行为稳定。
  • 缺点:性能较差,不能完美转发右值。
    • const T& 接收右值时,不会触发移动构造,而是执行拷贝构造。
    • 对于像 std::string 这种 可以移动优化 的类型,会浪费一次资源分配。
    • 所以它的性能低于支持完美转发(如 T&&)或按值传递(可以 std::move)的写法。

传值(按值传参)

1
2
3
void logAndAdd(std::string name) {
    names.emplace(std::move(name));
}
  • 优点:可接收左值/右值,内部手动 move,效率不差。

    • 可接收左值或右值:不像 const std::string& 只能“延长生命周期”或避免拷贝,按值传参的版本会对左值执行拷贝,对右值执行移动(由编译器自动判断调用时该拷还是移)。
    • 内部 std::move 提升效率:即使我们传了个左值,name 是局部变量了,我们可以放心地对它 std::move(),减少一次拷贝。举个例子:
    1
    2
    3
    
    std::string s = "hello";
    logAndAdd(s);                     // 拷贝构造 name,然后 move 进 names
    logAndAdd(std::string("world"));  // 移动构造 name,然后再 move 进 names
    

    在 C++ 中,无论是调用函数还是构造对象,实参总是先被用来初始化形参(函数的局部变量)。这个“初始化”的过程,就是:

    • 如果实参是左值 → 拷贝构造形参
    • 如果实参是右值 → 尝试使用移动构造形参(如果可用)
    • 效率不差:过去很多人觉得“按值传参”性能低,其实这在现代 C++ 里不一定成立(尤其对 std::string 这种移动代价远低于拷贝的类型)。
  • 推荐用于值语义明确的类型。所谓值语义明确,指的是像 std::string 这样的类型:

    • 拷贝/移动语义清晰(不像 std::unique_ptr 这种只能移动);
    • 没有共享资源;
    • 拷贝或移动是安全和常见的。
    • 因此,对于 std::stringstd::vectorstd::optional 这类类型,按值传参 + std::move() 使用是现代 C++ 的推荐写法。

标签分派(Tag Dispatch)

分发入口

1
2
3
4
5
6
7
8
9
10
template<typename T>
void logAndAdd(T&& name) {
    // 根据 T 是否为整型类型选择对应的重载版本(true_type 或 false_type)
    logAndAddImpl(
        std::forward<T>(name),  // 完美转发实参(可能是左值或右值)
        std::is_integral<typename std::remove_reference<T>::type>()  
        // std::remove_reference 移除引用修饰符,确保能正确识别基础类型
        // is_integral 是 type trait,判断是否为整型类型
    );
}

非整型重载

1
2
3
4
5
6
7
template<typename T>
void logAndAddImpl(T&& name, std::false_type) {
    // 非整型类型走这里,比如 std::string、const char* 等
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");  // 记录日志
    names.emplace(std::forward<T>(name));  // 完美转发 name 到 emplace,提高效率
}

整型重载

1
2
3
4
5
void logAndAddImpl(int idx, std::true_type) {
    // 整型类型走这里(如 int、short 等)
    // 通过索引查找对应名字,然后调用 logAndAdd(name)
    logAndAdd(nameFromIdx(idx));  // 递归调用,实参现在是 std::string,走 false_type 分支
}
  • 优点:实现完美转发,兼容重载。
  • 缺点:模板复杂,学习成本高。

使用 std::enable_if 限制模板适用范围

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Person {
public:
    // 完美转发构造函数(模板)
    template<
        typename T,
        // 使用 SFINAE 限制该模板仅在以下条件下启用:
        typename = typename std::enable_if<
            // ✅ 条件1:T 不是 Person 或其派生类
            !std::is_base_of<Person, typename std::decay<T>::type>::value &&
            // ✅ 条件2:T 不是整型类型(如 int、short 等)
            !std::is_integral<typename std::remove_reference<T>::type>::value
        >::type
    >
    explicit Person(T&& n)
        // 使用 std::forward 完美转发 n,避免不必要拷贝
        : name(std::forward<T>(n)) {}

    // 非模板重载:用于整型参数,通过索引查找对应姓名
    explicit Person(int idx)
        : name(nameFromIdx(idx)) {}

    // 拷贝构造函数(用于 Person 左值对象)
    Person(const Person& rhs) = default;

    // 移动构造函数(用于 Person 右值对象)
    Person(Person&& rhs) = default;

private:
    std::string name;
};

解释:

  • 屏蔽了 int 类型等整型。
  • 屏蔽了 Person 自身和其派生类(防止拷贝/移动调用错 T&& 构造)。
  • 保留了对 std::string、字符串字面量等类型的完美转发。

加分项:添加 static_assert,提高错误信息友好性

在模板构造函数体内添加一个编译时断言 static_assert,用于检查传入的参数 T 是否能用来构造一个 std::string 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<
    typename T,
    typename = typename std::enable_if<
        !std::is_base_of<Person, typename std::decay<T>::type>::value &&
        !std::is_integral<typename std::remove_reference<T>::type>::value
    >::type
>
explicit Person(T&& n)
    : name(std::forward<T>(n))
{
    static_assert(
        std::is_constructible<std::string, T>::value,
        "Parameter n can't be used to construct a std::string"
    );
    // 其他构造函数体内容(如果有)
}
  • std::is_constructible<std::string, T>::value 判断是否可以用类型 T 的对象来构造一个 std::string
  • 如果 不能构造,就会触发编译错误,编译器会输出下面自定义的错误信息: "Parameter n can't be used to construct a std::string"
  • 这样做的目的是:
    • 避免用户遇到非常冗长、难以理解的模板错误(可能会有 100+ 行报错)
    • 直接给出一个明确且简洁的错误提示,方便定位问题

万能引用构造函数的陷阱:

1
2
Person p("Nancy");        // T = const char(&)[6] → 允许
Person p(u"Zuse");        // T = const char16_t(&)[5] → 无法构造 string → 巨大错误信息

总结

技术方案是否支持完美转发是否支持重载性能错误信息友好度
放弃重载(改名)✅(绕过)
const T&一般
按值较高
标签分派一般
enable_if 限制模板一般~低(可用 static_assert 改善)
本文由作者按照 CC BY 4.0 进行授权