在创建对象时区分() 和 {}
c++11提供了大括号语法来创建对象,如 int x{0};
,创建一个int型变量且初始化为0。
它有很多好处:
- 大括号初始化可以表达以前做不到的事,如
std::vector<int> v{1, 2, 3};
- 大括号初始化禁止内建类型之间进行隐式窄化类型转换,如
int sum{x + y};// not allowed, x and y is double
- 函数声明不能使用大括号指定形参列表,所以可以使用大括号完成对象默认构造,不会与函数声明冲突
也有缺点:
- 如果有构造函数声明了任何一个具备std::initializer_list类型的形参,大括号初始化语法就会强烈优先选用该重载版本。
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<long double> il);
// ...
};
Widget w1(10, true); // 使用小括号,调用第一个构造函数
Widget w1{10, true}; // 使用大括号,调用第三个构造函数,10和true被强制转型为long double
Widget w1(10, 2.0); // 使用小括号,调用第二个构造函数
Widget w1{10, 2.0}; // 使用大括号,调用第三个构造函数,10和true被强制转型为long double
- 大括号初始化语法选择初始化列表重载版本的意愿可能会强烈到让人想吐,甚至平时会执行复制或移动的构造函数也被带歪
- 只有在没有任何办法把大括号初始化物中的实参转换为std::initializer_list模板中的类型时,编译器才会检查普通重载版本
- 这种恶劣影响的范围可能比想象的要广泛,如经常使用的std::vector:
std::vector<int> v1(10, 20); // 创建一个含有10个元素、每个元素值为20的vector
std::vector<int> v2{10, 20}; // 创建一个含有2个元素、值分别为10和20的vector
总之,使用小括号和使用大括号各有利弊,在使用大括号时三思而后行。
使用nullptr替换0和NULL
0和NULL是c/c++98中表示空指针的替代方法,因为0和NULL本身并不是指针,只是在只能使用指针的语境中把它们解释为指针。
这种情况在不同版本函数重载(模板函数亦是)中尤其显著:
void f(int);
void f(bool);
void f(void*);
f(0); // 调用 f(int),而不是f(void*)
f(NULL); // 可能编译失败,但一般调用 f(int),从不会是f(void*)
nullptr的优点在于,它不具备整型类型。其实它也不具备指针类型,但可以把它当作一种任意类型的指针。
使用using别名声明替换typedef
分别使用别名声明和typedef定义函数指针:
typedef void (*fp)(int, const std::string&); // 使用typedef
using fp = void (*)(int, const std::string&);// 使用别名声明
上述代码表明了如何使用别名声明,它的优越之处在于:
- 别名声明可以模板化,typedef不行
使用限定作用域的枚举(枚举类)替换通常的枚举(不限定作用域)
示例如下:
// 不限定作用域的枚举
enum Color { black, white, red }; // 通常使用方式,black, white, red所在作用域和Color相同
auto white = false; // 编译错误,已存在white
// 枚举类
enum class Color { black, white, red }; // 枚举类使用方式,black, white, red作用域在Color内
auto white = false; // ok
Color c = white; // 编译错误,没有white的枚举量
Color c = Color::white; // ok
auto c = Color::white; // ok, even better
正如注释,不限定作用域的枚举类型会造成名字空间污染,其作用域内不能再定义同名的变量,而枚举类则完全避免了这个缺点。
另外,枚举类是强类型的,不能隐式转换到整型,而不限定作用域枚举可以隐式转换到int,更进一步到double。
还有,枚举类可以前置声明(默认底层类型是int),而不限定作用域的枚举不能直接进行前置声明(默认底层类型未知)。
使用删除函数指定想要阻止被调用的函数
想要阻止函数被调用,不写这个函数不就完事了嘛,还要写出来,再告诉别人,不要使用它哦?
事实确实如此,考虑写一个单例类。
编译器会自动生成诸如构造、复制构造、赋值构造、析构等一系列特种函数,而这些函数是不能为外部调用的。
在之前,通常的做法是使用private,并不具体实现上述函数,以达到外部无法调用的目的,即使某些代码调用到了,也因为没有具体实现,链接阶段也会报错。
在c11中,使用删除函数即可。
class Foo {
public:
Foo() = delete;
Foo(const Foo&) = delete;
Foo& operator=(const Foo&) = delete;
}
几点需要注意:
- 删除函数为public,这是因为客户尝试使用某个成员函数时,c++会先校验可访问性,后检测删除状态。如果声明为private,得到的错误信息可能不准备。
- 一旦声明为delete,就无法通过任何方法使用,如果使用,会产生编译时错误,这要比链接时才产生错误要好的多
其实,任何函数都可以声明为删除函数,这比private的用处大的多了:
- 通过声明函数特定重载版本为删除函数,达到禁用某些类型的函数
- 阻止不应该具现的模板实现
为需要改写的函数添加override声明
子类改写父类的虚函数时,会达到多态的效果,这也使用通过基类接口调用派生类函数成为可能。
复习一下,要实现派生类对基类函数的改写,需要满足以下条件:
- 基类中的函数必须是虚函数
- 基类和派生类中的函数名字必须完全相同(析构函数例外)
- 基类和派生类中的函数形参必须完全相同
- 基类和派生类中的函数常量性必须完全相同
- 基类和派生类中的函数返回值和异常规格必须兼容
- 基类和派生类中的函数引用饰词必须完全相同(c++11中限制)
最后一条解释一下:
class Widget {
public:
void dowork() &; // 仅在*this是左值时调用
void dowork() &&;// 仅在*this是右值时调用
}
Widget makeWidget(); // 工厂函数(返回右值)
Widget w; // 普通对象(返回左值)
w.dowork(); // 左值调用,即dowork() &
makeWidget().dowork(); // 右值调用,即dowork() &&
重点来了:派生类中要改写的函数应该与基类中的函数完全一致,改写才会生效,否则就相当于派生类定义了一个新函数。
为了避免难以察觉的错误,c11提供了override关键字来标明派生类想要改写基类的虚函数,而不是定义一个新函数,这样,当派生类中写错时,编译器就会发生提醒。
ok,再也不用担心自己写错函数了。
实际上,如果派生类中都加上了override声明,在重构时也方便许多。当需要更改基类函数签名时,可以直接编译,编译器会告诉你它的影响面有多大。如果没有override,当要做这样的改动时,只能依靠你的单元测试集了。
优先选用const_iterator, 而非iterator
原则:只要可能,就要使用const饰词。
这样就不多说了,通常情况下,使用迭代器时使用auto即可。
为不会发射异常的函数加上noexcept声明
在接口设计中,对于不会发射异常的函数加上noexcept声明是应该做到的,否则就是接口有缺陷了。
增加声明也可以让编译器生成更好的目标代码。
尽量使用constexpr
constexpr用于对象时,表示对象具有const属性,且其值在编译时已知。
示例如下:
int sz;
constexpr auto sz1 = sz; // error,sz的值在编译时未知
const auto csz = sz; // 正确,csz是sz的一个副本
std::array<int, sz> data1; // error,sz的值在编译时未知
std::array<int, csz> datas; // error,csz的值在编译时未知
constexpr auto sz2 = 10; // 没问题,编译时已知
std::array<int, sz2> data2;// 没问题,编译时已知
可以说,所有constexpr对象都是const对象,而const对象未必是constexpr对象。
constexpr函数比较有意思:
- 可以用在编译期常量的语境中,若传入它的实参值是编译期已知的,是函数结果也会在编译期计算出来。若实参在编译期未知,则编译报错
- 在调用constexpr函数时,若传入的值有一个或多个在编译期未知,则它的运作方式和普通函数相同,即在运行期执行结果的计算
上述规则表明,如果函数执行的是同样的操作,仅仅应用的语境一个要求编译期常量,一个用于其他值的话,那么一个constexpr函数足够。
保证const成员函数的线程安全
const成员函数可以更改mutable声明的成员变量,当使用在多线程环境中时,应该使用std::atmoic或者锁保证线程安全。
特种成员函数的生成机制
特种成员函数就是c++会自行生成的成员函数:
- 默认构造函数:仅当类中不包含用户声明的构造函数时才会生成。
- 析构函数:仅当基类的析构函数为虚的时,派生类的析构函数才是虚的。c11中默认析构函数为noexcept。
- 复制构造函数:按成员进行非静态数据成员的复制构造。仅当类中不包含用户声明的复制构造函数时才生成。如果该类声明了移动操作,则复制构造函数将被删除。在已经存在复制赋值或析构函数的条件下,仍然生成复制构造函数的行为已经被废弃。
- 复制赋值运算符:按成员进行非静态数据成员的复制赋值。仅当类中不包含用户声明的复制赋值运算符时才生成。如果该类声明了移动操作,则复制赋值运算符将被删除。在已经存在复制构造或析构函数的条件下,仍然生成复制赋值运算符的行为已经被废弃。
- 移动构造和移动赋值运算符(c11):都按成员进行非静态数据成员的移动操作。仅当类中不包含用户声明的复制操作、移动操作和析构函数时才生成。
注意:
- 这些函数仅在需要时才会生成
- 生成的特种成员函数都具有public访问层级且是inline的
- 生成的都是非虚的(例外:如果基类的析构函数是个虚函数,而派生类的析构函数也会是虚的)
- 虽然有移动语义,但真正是否会执行移动操作也依赖于实现,如果实现不支持移动,则会使用复制替代
- 两种复制操作是彼此独立的,声明了一个不会阻止编译器生成另一个。而两个移动操作不独立,声明任何一个就会阻止编译器生成另一个
- 移动操作的生成条件是极其苛刻的,仅当上述三个条件全部同时成立进才会生成
- 可以使用“=default”来显式启用编译器默认生成相关函数
- 成员函数模板的存在不会阻止编译器生成任何特种成员函数
参考资料
《Effective Modern C++》