条款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::string
、std::vector
、std::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 进行授权