“C makes it easy to shoot yourself in the foot; C++ makes it harder, but when you do it blows your whole leg off”
-- Bjarne Stroustrup's FAQ
使用C++的过程中要小心防备各种陷阱,这些陷阱不只是因为C++语言自身的复杂性,也是因为C++要解决的问题领域的复杂性。这导致C++是一门既要精通语法特征,还要能熟练使用各种最佳实践的语言。这些最佳实践能保证你在优雅的使用C++,并帮助你规避各种意料之外的陷阱。
自从C++11推出以后,C++的版本升级就进入了快车道。如今C++14,C++17标准都已发布,C++20也基本敲定,C++已然是一门崭新的语言(modern C++
)。新的C++标准不仅提供了更多强大的能力,也解决了很多历史遗留问题和不足。面对语言提供的更多选择,C++程序员们需要与时俱进,学习新的语法特性,还要同步刷新自己的C++最佳编码实践。这些新的实践可以帮你写出更简洁易读的代码,提高代码的安全性,甚至可以低成本的收获更高的运行时效率。
以下是一些常用的modern C++最佳实践,看看你的C++技能是否还在与时俱进中。
- 尽可能的使用
auto
代替显式类型声明
曾经我们的最佳实践是不要使用匈牙利命名,避免将变量的类型信息在变量名中重复。
如今我们更进一步:在变量声明的时候最好连类型也不要写出,而是尽量依赖编译器的自动类型推导。
这不仅能让你不必写出typename std::iterator_trait<T>::value_type it
这样的晦涩代码,还能避免你写出很多错误或者低效的代码。
auto
依赖于初始值进行类型推断,所以强制你定义变量时必须进行初始化,这将会避免很多使用未初始化变量所带来的悲剧。
auto x = 0 // Must be initialized as defined
下面的auto
则避免了手写类型不匹配时在循环过程中产生的大量临时对象的开销。
std::unordered_map<std::string, int> m;
for (const auto&p : m) {
...
}
在C++14中,lambda的形参类型可以使用auto
,这样可以把同一个lambda表达式复用在更多的地方(如不同元素类型容器的算法中)。
auto filter = [](const auto& item) {
...
}
在C++14中,普通函数或者模板函数的返回值可以使用auto
进行自动推导,这极大的简化了模板函数的写法。
template <typename A, typename B>
auto do_something(const A& a, const B& b)
{
return a.do_something(b);
}
记住,尽可能的使用auto
,可以让代码更简单、安全和高效。
- 尽量使用统一初始化
我们有个一直都很有用的最佳实践是“变量定义时即初始化”,现在补充一下初始化的方式:“尽量使用统一初始化
”。
曾经C++在不同场合下可以分别使用()
和=
进行变量初始化。
int x = 0;
int y(0);
class Foo {
public:
Foo(int value) : v(value){
}
private:
int v = 0; // 这里不能写作 int v(0)
};
Foo f1(5);
Foo f2(f1);
C++11引入了统一初始化:采用{}
为变量进行初始化。所以上述各种写法可以统一为:
int x{0};
class Foo {
public:
Foo(int value) : v{value}{
}
private:
int v{0};
};
Foo f{5};
Foo f2{f1};
另外统一初始化还可以为容器初始化:
std::vector<int> v{1, 2, 3};
遗憾的是在C++11版本中,当统一初始化应用于auto
时,auto x{3}
会被推导为std::initializer_list
类型,所以在以C++11标准为主流的社区中,大家还都习惯于优先使用传统的()
或者=
进行初始化,然后选择性的使用{}
。
如今C++17修复了这一问题。
auto x1{ 1, 2, 3 }; // error: not a single element
auto x2 = { 1, 2, 3 }; // decltype(x2) is std::initializer_list<int>
auto x3{ 3 }; // decltype(x3) is int
auto x4{ 3.0 }; // decltype(x4) is double
所以如果你的编译器已经支持C++17,现在则可以大胆的使用统一初始化了,这可以减少我们被各种不同初始化方式所带来的脑细胞损耗。
- 尽可能使用
constexpr
曾经我们说“尽可能多使用const关键字”,今天我们同样说“尽可能多使用constexpr关键字”。
虽然constexpr
和const
看起来很像,但其实它们并无直接关系。
const
承诺的是对状态的不修改,而constexpr
承诺的是对状态的计算可以发生在编译期。
在编译期进行计算,有诸多好处。除了可以把编译期计算结果应用于数组定义、模板参数中,还可以把计算结果放置在只读内存中,这对于嵌入式系统开发来说是非常重要的语言特性。
用constexpr
定义的函数,不仅可以发生在编译期,也能发生在运行期,这取决于调用它的语境。
constexpr int pow(int base, int exp) noexcept {
auto result = 1;
for (int i = 0; i < exp; ++i) {
result *= base;
}
return result;
}
上面的pow
函数既可以用在编译时int arr[pow(2, 3)]
,也可以用于运行时std::cout << pow(2, 3)
。
由于C++14放宽了对constexpr
的限制,所以pow
的写法和普通函数是一样的(C++11中需要靠递归实现)。
constexpr
不仅可以消除编译期和运行期的代码重复,它也是提高系统运行时性能的一种手段。虽然这种运行时效率是用编译时效率换来的,但是大多程序都是一次编译多次运行。因此,和const
关键字一样,如果有可能使用constexpr
,就使用它。
- 掌握
三法则
和五法则
,但是尽可能应用零法则
熟悉C++98的程序员都知道经典的C++三法则,即“若某个类需要用户自定义的析构函数、拷贝构造函数或赋值运算符,则它几乎肯定三者全部都需要自定义”。
class StringWrapper final {
public:
StringWrapper(const char* s) {
if (s == nullptr) return;
std::size_t n = std::strlen(s) + 1;
cstring = new char[n];
std::memcpy(cstring, s, n);
}
~StringWrapper() {
if (cstring != nullptr) delete[] cstring;
}
private:
char* cstring{nullptr};
};
以上是一个实现非常拙劣的字符串封装类,它违反了三法则,自定义了析构函数,但是没有对应的自定义拷贝构造函数和赋值运算符。
这将导致下面代码中s2使用编译器默认生成的拷贝构造函数,对s1进行浅拷贝。于是s2和s1共享了同一个指针地址,当s1或者s2中有一个被析构,另一个对象将会持有一个失效指针,这往往是系统灾难的开始。
StringWrapper s1{"hello"};
StringWrapper s2{s1};
所以谨记三法则的程序员会为StringWrapper
同时定义拷贝构造函数和赋值运算符。
class StringWrapper final {
public:
StringWrapper(const char* s) {
init(s);
}
StringWrapper(const StringWrapper& other)
{
init(other.cstring);
}
StringWrapper& operator=(const StringWrapper& other)
{
if(this != &other) {
delete[] cstring;
cstring = nullptr;
init(other.cstring);
}
return *this;
}
~StringWrapper() {
if (cstring != nullptr) delete[] cstring;
}
private:
void init(const char* s)
{
if (s == nullptr) return;
std::size_t n = std::strlen(s) + 1;
cstring = new char[n];
std::memcpy(cstring, s, n);
}
char* cstring{nullptr};
};
而C++11引入了移动语义!用户定义的析构函数、拷贝构造函数或赋值运算符会阻止移动构造函数和移动赋值运算符的隐式定义,所以任何想要移动语义的类必须定义全部五个特殊成员函数。
class StringWrapper final {
public:
StringWrapper(const char* s) {
init(s);
}
StringWrapper(const StringWrapper& other)
{
init(other.cstring);
}
StringWrapper(StringWrapper&& other) noexcept
: cstring(std::exchange(other.cstring, nullptr)){
}
StringWrapper& operator=(const StringWrapper& other)
{
if(this != &other) {
delete[] cstring;
cstring = nullptr;
init(other.cstring);
}
return *this;
}
StringWrapper& operator=(StringWrapper&& other) noexcept
{
std::swap(cstring, other.cstring);
other.cstring = nullptr;
return *this;
}
~StringWrapper() {
if (cstring != nullptr) delete[] cstring;
}
private:
void init(const char* s)
{
if (s == nullptr) return;
std::size_t n = std::strlen(s) + 1;
cstring = new char[n];
std::memcpy(cstring, s, n);
}
char* cstring{nullptr};
};
事实上想要全部正确的实现析构函数、拷贝构造函数、赋值运算符、移动构造函数和移动赋值运算符是需要花费一番心思的,上述例子中就隐藏着好几处缺陷。这就是为何C++核心规范中提出了C.20: If you can avoid defining default operations, do
,也就是我们说的零法则。具体实施的办法是:在实现你的类的时候,最好不要自定义析构函数、拷贝构造函数、赋值构造函数、移动构造函数和移动赋值函数,取而代之用C++智能指针和标准库中的类来管理资源。
所以针对上述例子,直接使用std::string类,或者可以采用标准库的容器辅助定义:
class StringWrapper final {
public:
StringWrapper(const char* s) {
if (s == nullptr) return;
std::size_t n = std::strlen(s) + 1;
data.resize(n)
for (int i = 0; i < n; i++) {
data[i] = s[i];
}
}
private:
std::vector<char> data;
};
因此,你应当熟悉modern C++的五法则,但是实践的时候尽量遵循零法则。
- 在某些必须拷贝的情况下,考虑用传值代替传递引用
曾经C++的最佳实践告诉我们,“为了避免函数传参时的对象拷贝开销,尽量选择传递引用,最好是传递const引用”。
C++11引入移动语义后,这一规则在某些时候需要做些调整:如果拷贝不能避免,那么为了能够统一代码,或者保证异常安全,优先考虑用传值代替传递引用。
class ResourceWrapper final {
public:
ResourceWrapper(const std::size_t size)
: resource {new char[size]}, size {size} {
}
~ResourceWrapper() {
if (resource != nullptr) {
delete [] resource;
}
}
ResourceWrapper(const ResourceWrapper& other)
: ResourceWrapper{other.size} {
std::copy(other.resource, other.resource + other.size, resource);
}
ResourceWrapper(ResourceWrapper&& other) noexcept {
swap(other);
}
ResourceWrapper& operator=(ResourceWrapper other) {
swap(other);
return *this;
}
private:
void swap(ResourceWrapper& other) noexcept {
std::swap(resource, other.resource);
std::swap(size, other.size);
}
char* resource{nullptr};
std::size_t size;
};
上面的代码中,operator=
函数采用按值传递参数,不仅统一了普通赋值和移动赋值函数的实现,而且还保证了异常安全性(先用临时对象统一申请内存,申请成功后才会进行swap)。
诚然,是否应用这一规则和场景有关。但是当你实现的函数既要处理左值也要处理右值,而处理左值时不可避免的要拷贝,这时请考虑设计成传值是否是个更好的选择。
- 使用
nullptr
而非0
或NULL
曾经的最佳实践告诉我们,“不要直接使用0作为指针的空值”,所以每个C++项目都会封装自己的NULL
实现。
当初C++11带来了标准的空指针nullptr
,就是为了结束各种千奇百怪的NULL
实现。
NULL
的最大问题在于每种实现的类型都不一样,有的是int
,有的是double
,还有的是enum
,这不仅导致了各种参数传递时出现的转型问题,还会影响模板的类型推演。
而nullptr
的类型是确定的std::nullptr_t
,并且实现了向每种兼容类型的隐式转换。它的安全性和兼容性要比过去的实现都好。
现在你需要做的是将原来的NULL
定义做下修改,例如将#define NULL int(0)
改为#define NULL nullptr
,然后让编译器帮你发现代码中的潜在错误并进行修改。
- 为覆写的虚函数加上
override
关键字
曾经,当子类中覆写的虚函数的方法名不小心拼写错误的时候,如果父类中又提供了默认实现,将会导致严重的逻辑错误。而这般严重的错误往往只能等到程序运行后才能被发现。
最终C++11为此提供了override
关键字。所以你应该毫不犹豫地在每个被你覆写的虚函数后面加上override
,然后让编译器帮你检查虚函数覆写的错误,将问题在程序发布前彻底解决掉。
- 保持持续学习和实践
前面介绍了一些日常比较容易用到的modern C++最佳实践,当然还有很多,包括一些高级语法的或者和标准库有关的实践,鉴于篇幅这里就不再介绍了。
C++的设计哲学是能更好的解决现实中存在的问题,新的语法的引入都是由现实问题驱动出来的。那些曾经不能解决的、或者解决得不够优雅的问题,今天在新的C++标准中很可能都有了更好的解决方案。你应该保持持续学习,时常看看曾经棘手的问题,是否已经有了更优解。
在写这篇文章的时候我看到C++17的通过构造函数进行类模板参数类型推导(CTAD)
,内心就不禁一阵喜悦,这个特性可以让我曾经构造的一个Promise断言库的DSL写的更加的精炼。
// Example of CTAD
template <typename T = float>
struct Container {
T val;
Container() : v() {}
Container(T v) : v(v) {}
// ...
};
Container c1{ 1 }; // Container<int>
Container c2; // Container<float>
除了不断关注C++的特性演进外,还需要经常检查自己的编译器,在可能的情况下更新编译器版本。尽量使用新的语法,写出更简洁、安全和高效的代码。(C++各种编译器版本和语法特性的对照表)
作为一名合格的程序员,我们以这样一个最佳实践结束:保持持续学习和实践。