你的C++最佳实践该刷新了

“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关键字”。

虽然constexprconst看起来很像,但其实它们并无直接关系。
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而非0NULL

曾经的最佳实践告诉我们,“不要直接使用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++各种编译器版本和语法特性的对照表

作为一名合格的程序员,我们以这样一个最佳实践结束:保持持续学习和实践

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349