我曾经在一次开发中遇到过这样的问题,我有一个User class,他里面有string:id, name, passward,以及其他内置类型.如果是在C++11之前,写出一个好的构造函数应当是很容易的,但是C++11之后,有了移动语义,写出一个完美的构造函数确实不容易。
这样想,我想尽可能的利用好移动语义,假设我们的name等等成员不是一个短字符串,这时候移动语义对性能会有优化。现在又3个string类型的成员,考虑到每个成员的左值和右值,有8种构造函数的写法(不算默认构造)。要是这种可以进行移动的成员再多几个呢——这是一个指数级增长的趋势。这种写法显然是不行的。那就用模板——还要是万能模板。可以初步设计出这样的构造函数。
class User {
public:
template
User(T && _name, T && _id, T && _password) :
name(forward(_name), forward(_id),forward(_password)) { }
}
这样哪个无论什么样的组合,我们均可用一个函数来完成。而且对于其他有移动语义的类型来书,再加上一个F &&即可,这是线性增加的,还算不错。
当形参只有一个的时候,会有些微妙的变化。
现在假设User中只有一个string类型的name,如果你依旧考虑像上面一样实现构造函数,会有一些隐晦的错误。
class User {
public:
template
User(T && _name): name(std::forward(_name)) { }
private:
string name;
};
int main()
{
User user("hello"); //很好,这个甚至是直接再类中用const char * 直接构造了一个字符串
User user1(user); // error
return 0;
}
编译器会给出我们这样的一个错误
No matching constructor for initialization of ‘std::string’ (aka ‘basic_string’)
编译器说无法匹配string的构造函数,莫名奇妙有些。
实际上,编译器给出的错误一点问题也没有。
我们先来回复一个拷贝构造的函数原型 User(const User & user)
我们传递的user不具有const性质,而模板会生成一个non-const的形参,看起来像这样User(User & _name): name(_name)
(此处省略forward,下同),编译器觉得这个non-const的版本更加匹配,随后使用一个User对象来构造string,那肯定是不行的。
如果我们的user具有const性质,编译器就不会报错。
int main()
{
const User user("hello");
User user1(user); // 工作的很好
return 0;
}
这种和直觉上不服的行为肯定不是我们想要的,如果你留意编译器的警告,或者说是clang-tidy的静态分析,你会看见这样的警告
Clang-Tidy: Constructor accepting a forwarding reference can hide the copy and move constructors
Clang-Tidy:接受转发引用的构造函数可以隐藏复制和移动构造函数
还有这样的情况,假设我们只需要构造一个int类型的数据成员,其他的数据成员都调用默认构造。
class User {
public:
template
explicit User(T && _name): name(std::forward(_name)) {
cout << "T&&" << endl;
}
explicit User(int _age): age(_age), name() { }
private:
string name;
int age;
};
int main()
{
User user(25);
//...假设这里经过算法的计算,得出的一个double类型来当作age, 我们也期望这种隐式转换double -> int(即向下取整)
double ans = 18.63;
User user1(ans)
return 0;
}
不好了,编译器又给出了错误!
原因是万能引用为我们实例化了一个User (double & _name): name(_name)
,精确度高于手写的int版本的构造函数——用一个double构造一个string,当然是不行的了。
类似的成员函数应用万能模板也有这样的重载问题。
现在Reader继承User
class User {
public:
template
explicit User(T && _name): name(std::forward(_name)) {
cout << "T&&" << endl;
}
explicit User(int _age): age(_age), name() { }
User(const User & user) = default;
private:
string name;
int age;
};
class Reader : public User {
public:
Reader(const Reader & reader) :
User(reader), phone_number(reader.phone_number) { }
private:
string phone_number;
};
编译器依旧还是这个错误
No matching constructor for initialization of ‘std::string’ (aka ‘basic_string’)
原因是Reader & cast -> User & 是一种隐式转换。
万能引用又出来作怪,生成这样的版本User(Reader & _name): name(_name))
显然,这个版本更加精确,使用Reader & 构造 string当然不行。
《Modern Effective C++》,对些情况是这样总结的
解决这个问题的方法还是有很多的,我们可以权衡利弊的来考虑。
不是谁都有精力去学习复杂的Modern C++(这里大部分的意思是关于TMP),使用一些简单的新特性也是不错的。我们可以完全不用万能引用,因为相比代码可读性极具下降而带来不稳定的性能提升,如果你不是一个完美主义者,代码可读性应当是应该的选择。
大可像Rust那样,舍弃重载,也是解决办法之一,但是由于语法问题,构造函数时语言固有的,或许你也可以麻烦一点,用static函数实现?就使用默认构造,然后一一修改值——这样效率反而低了,舍本逐末不可取。总之,舍弃重载可以暂时的解决问题,但不是一个长久之计。
这就是98时候的写法了,经典永不过时,这种写法简洁又不失高性能,并且不会又其他的问题出现,这种方式大家应当都很熟悉,还是很值得考虑的。
如果你选择了使用现代方法解决此问题,就代表了你踏进了Modern C++的大门,不仅仅是关于&&和&的语法游戏,你必须具有TMP(模板元编程的基础),才可以游刃有余的写出奇妙的Modern C++代码,如果你还不具备这项技能,又想使用高级手法来解决问题,那你应当做好准备迎接一轮又一轮的新特性学习。
题外话:我把Modern C++代码说做是“奇妙的”,而不是“精美的”、“简洁的”这类词是有原因的,一个基本的事实,Modern C++代码并不简洁,甚至是丑陋和复杂,但这些复杂的代码背后的工作原理,了解之后没有人不会惊叹,不会大呼奇妙。
可以使用值传递+移动构造的方式来代替万能引用和转发。如果对象的移动语义有较低的成本使用 pass by value and use std::move也是一个比较好的选择。
比如说像这样
class User {
public:
template
explicit User(string _name): name(std::move(_name)) {
}
explicit User(int _age): age(_age), name() { }
User(const User & user) = default;
private:
string name;
int age{};
};
但是这个选择不够通用,如果对象没有移动语义,或者是POD类型,这样做性能反而会下降。你可以阅读这个文章https://jan6055.github.io/2022/09/17/pass-by-value-and-use-std-move/ 来了解相关信息。
如果你看过一些STL的源代码,可能会熟悉这种方式,大致的思路是这样的,我们把构造函数委派给其他函数,这些函数做具体的实现,并且他们有不同的重载版本,分别接受构造所需要的参数和一个名为true_type/false_type类型的对象
true_type和false_type由标准库提供,仅仅是定义,并未添加任何数据和行为, 实现标签的效果。
判断T是不是整形,我们可以使用std::is_integral
,但是考虑左值的情况,T被推断为int &,我们应当先把引用性质给移除,可以使用std::remove_reference
得出的代码就是这样std::is_integral
, 我们使用的_t后缀的模板(TMP中叫做元函数)来直接获得类型——这是C++14支持的,但是,别忘了使用()
来生成一个对象,才可用于重载。
具体的实现如下
class User {
public:
template
explicit User(T && arg) {
init(std::forward(arg),
typename std::is_integral>()
);
}
template
void init(T && _name, false_type) {
name = std::forward(_name); //这里是赋值操作,如果使用这样的手法对于构造函数而言,就只能这样
age = 0;
}
void init(int _age, true_type) {
//name会隐式的初始化
age = _age;
}
User(const User & user) = default;
private:
string name;
int age{};
};
这确实够复杂,先别放松,更复杂的还在后面!
先说明一点,你应当了解TMP并且熟悉其最基本构成,还应当知道SFINAE,如果你不知道,快去找相关书籍看吧,如果我把这里展开说明,并且把用到的TMP技巧细节全部说一遍,那就偏离主题了。并且,我也没有足够的能力讲清TMP中的所有细节和问题,这件事情你应当请教C++标准委员会(bushi
我们可以让万能引用拒绝User类的对象,也拒绝int类的对象。在现代C++中,这是可以实现的——你甚至能在98里实现,但是需要点技巧。
使用enable_if来让编译器类型替换失败,也就是SFINAE, 但是我们应当考虑到左值的情况T , T&并不是一种类型,比如int, int&, 还有const T, volatile T, const volatileT
这些和T均不是一种类型。可以使std::decay来去掉cvr性质。
具体的实现是这样
class User {
public:
template>::value &&
!std::is_integral>::value
>::type>
explicit User(T && _name) : name(std::forward(_name)) { }
explicit User(int _age) : age(_age), name(){ }
User(const User & user) = default;
private:
string name;
int age{};
};
这样实现还真是复杂,如果没有C++14_t
的元函数,那么将会更加复杂,感谢C++14!
大功告成了吗,并没有。对于继承关系来说,还是存在问题。如果子类中调用基类的构造函数,就是我们讨论过的哪个问题。在实例化的时候,在经过类型退化后,T被推断为Reader,这就又回到了我们之前所说的问题。
好在STL中提供了is_base_of
这个元函数,能够判断一个类型是否是另一个类型的基类,is_base_of
于是我们可以简单的修改一下代码。
class User {
public:
template>::value &&
!std::is_integral>::value
>::type>
explicit User(T && _name) : name(std::forward(_name)) { }
explicit User(int _age) : age(_age), name(){ }
User(const User & user) = default;
private:
string name;
int age{};
};
class Reader : public User {
public:
Reader(const Reader & reader) :
User(reader), phone_number(reader.phone_number) { }
private:
string phone_number;
};
问题迎刃而解!你现在是否感叹Modern C++的巧妙了呢?
你会说,能有什么不足呢?这么高级的技巧,多么的神奇。实际上,万能引用的强大如此,不足也是如此。回头看看经典版本
class User {
public:
explicit User(const string & _name): name(_name) { }
explicit User(int _age) : age(_age), name() { }
User(const User &) = default;
private:
string name;
int age{};
};
正所谓洗去铅华只剩金,实现了同样的功能,难道我们要为性能舍弃掉如此的可读性和简洁性吗。同样的,如果是团队合作,小伙伴们可都不一定会这样的高级技巧。到头来还要你自己维护。如果没有注释,等个几天——你就不知道自己写的是什么啦,真是太棒了!
使用模板的一个不可避免的问题就是当错误发生时,不好定位。你应该有没有按照规范使用过STL容器的经历,看见了编译器给你报告的错误。很吓人,如果没有经验的人看到这些错误(可能有几百行甚至几千行)。该怎么定位问题。虽然我们可以使用statci_assert
来未雨绸缪。但也不是所有的时候情况都很客观。
总之,要不要使用如此晦涩复杂的特性,还是取决于程序员本身,你也可以使用const &快快乐乐的写代码,正因为
你没有用到的东西,不应当给你增添任何负担,你用到的东西,没有什么比C++提供的更好了
使用Modern C++也是你自己的选择,除非写库,很少有人能用到模板开发,真正把这些Modern C++特性用到生产环境的,并且用好的,我想都是在C++领域发光发热的大牛们吧。