一些我竟然不知道的C++相关知识 - 这都不知道「持续更新」

最近一段时间在工作中或看书遇到的一些关于C++有意思的知识记录成本文。包括i++和++i的左右值问题、无符号和有符号比较、C++正则库中 regex_match 的坑、shared_ptr 的妙用: copy-on-other-reading、协变返回类型、switch case 语句 break 的使用、将new对象初始化指针放在独立语句、std::stack为何提供pop和top两个函数而不是合二为一。

i++++i很常用,之前的理解仅限++i前置++会先自增后进行运算;而i++后置++则会在参与运算完后才会自增。

上述理解并不透彻,在《现代C++语言核心特征解析》一书右值引用一章中,解释到二者有左右值的区别,i++是右值,因为后置++操作中编译器会先生成一份i值的临时拷贝,然后对i自增,返回临时拷贝++i则是左值,直接对i进行递增后返回自身。如果对它们进行取地址操作,可以发现++i的取地址编译通过,而i++不可以。

c++

int i = 0;
int *p = &i++; // 编译失败
int *q = &++i; // 编译成功

i++在返回前就已经完成对i的自增并且返回的值是个自增前的临时拷贝。利用这一个特性可在STL中进行元素删除时防止关联容器迭代器失效。

在《C++标准库》第7.8节中举例:

c++

std::map<std::string, float> coll;
// ...
for (auto pos = coll.begin(); pos != coll.end();) {
    if (pos->sencode == value) {
        coll.erase(pos++);
    } else {
        ++pos;
    }
}

上述coll.erase(pos++)pos++会将pos指向下一个元素,返回原值的拷贝,因此当erase()调用时,pos已经不再指向被即将移除的元素,所以就不存在迭代器失效的问题。当然在C++11之后,关联容器的erase()会返回迭代器指向其后续元素,上述方法有更简单的替代版本:

c++

for (auto pos = coll.begin(); pos != coll.end();) {
    if (pos->sencode == value) {
        pos = coll.erase(pos);
    } else {
        ++pos;
    }
}

对于像vector等容器其erase()方法在C++11之前也是返回下个元素的迭代器,因为移除元素可能会导致后续所有元素迭代器失效。

当有符号和无符号进行比较时,需要特别注意编译器会隐式转换有符号数为无符号数,当有符号数为负值时转换成无符号数则是一个非常大的无符号数。即

c++

int i = -1;
uint ui = 1;
std::cout << (i < ui) << std::endl; // 0

上述代码中i < ui是false。所以当遇到无符号和有符号比较时,需要特别注意先对有符号数进行负值的判断

在工作的项目中使用std::regex_match正则匹配文件名,并且获取 match_results 来获取匹配结果中的值。最近一次使用 Valgrind 的 memcheck 工具检测程序内存问题发现这里报了 use after free 问题。经过 google 发现这里使用上的一个大坑。实际当时使用 std::regex_match 时我就看了一遍《C++标准库》1对应章节,很可惜其并没有明确指出这个问题,导致此次的踩坑。

cppreference的regex_match一章中的缺陷报告指出了这个问题。考虑如下代码:

c++

const regex r(R"(meow(\d+)\.txt)");
smatch m;
if (regex_match(dir_iter->path().filename().string(), m, r)) {
  DoSomethingWith(m[1]);
}

上述问题就在于dir_iter->path().filename().string()返回的是临时的string右值变量,而保存匹配结果的match_results(smatch)设计上为了节省内存是引用传入的string变量,这种情况下就会引用了已经释放的string。

std::regex_match函数的第一个入参类型为const std::string可以绑定右值,所以上述代码可以编译通过。而在后续的C++标准中通过显示删除右值引用参数来让上述代码编译报错,从而避免这类问题。关于右值引用,可以看这篇文章

所以,对于regex_match或regex_search传入的被匹配字符串数据的生命周期要长于匹配结果match_results,否则就会使用到已经被释放的内存。

多次阅读《Linux多线程服务端编程:使用muduo C++网络库》2,每次都能被书中提到的用shared_ptr实现copy-on-write的用法所惊艳到。一部分原因是该方法确实惊艳,另一部分原因也是本人记性较差,所以这次记录下来供日后翻阅。

在实际工作中我曾经写出类似如下代码: 对于拷贝复杂度比较高的多线程共享数据data,读数据getData函数加锁返回一份拷贝来保证线程安全,写数据时加锁修改数据。这里通过使用移动语义减少了拷贝,也最大限度减少了临界区的大小

c++

class Data {
private:
    std::mutex mutex_;
    std::vector<int> data_;

public:
    std::vector<int> getData() {
        std::lock_guard<std::mutex> guard(mutex_);
        return data_;
    }

    void addDataItem(int v) {
        std::lock_guard<std::mutex> guard(mutex_);
        data_.push_back(v);
    }

    void setData(std::vector<int>&& data) {
        // 右值直接move
        std::lock_guard<std::mutex> guard(mutex_);
        data_ = std::move(data);
    }

    void setData(const std::vector<int>& data) {
        // 左值先copy后move
        auto tmp = data;  // copy放在临界区外
        setData(std::move(tmp));
    }
};

上述代码主要问题在于每次读时会拷贝数据,在拷贝消耗大或者读多写少时可能就会成为性能瓶颈。通过这本书2的启发,上述代码可以改写为:

c++

class Data {
private:
    std::mutex mutex_;
    std::shared_ptr<std::vector<int>> data_;

public:
    Data() : data_(new std::vector<int>()) {}

    std::shared_ptr<const std::vector<int>> getData() { // 注意这里返回的是拥有const类型的shared_ptr
        std::lock_guard<std::mutex> guard(mutex_);
        // 返回shared_ptr的拷贝,引用计数器+1
        return data_;
    }

    void addDataItem(int v) {
        std::lock_guard<std::mutex> guard(mutex_);
        // 在有读时才copy
        if (!data_.unique()) data_.reset(new std::vector<int>(*data_));
        data_->push_back(v);
        assert(data_.unique());
    }

    void setData(std::vector<int>&& data) {
        // 右值直接move
        std::lock_guard<std::mutex> guard(mutex_);
        if (!data_.unique())
            data_.reset(new std::vector<int>(std::move(data))); // 外界有data_的拷贝,需要reset
        else
            *data_ = std::move(data);
        assert(data_.unique());
    }

    void setData(const std::vector<int>& data) {
        // 左值先copy后move
        auto tmp = data;  // copy放在临界区外
        setData(std::move(tmp));
    }
};

上述代码有几个关键点需要注意:

  • 读数据(getData)时返回shared_ptr的一份拷贝使其引用计数器+1,而并没有真正的拷贝数据,避免了读时会拷贝数据。当然调用方对于其返回的shared_ptr在使用后应马上释放,在一般场景下调用方不应该一直持有获取的数据。注意这里返回的是shared_ptr持有的是const std::vector<int>只读数据类型,使用编译器限制防止意外修改数据
  • 在写数据(addDataItem)时做到了在需要拷贝时才拷贝,即当shared_ptr引用计数器大于1时(data_.unique不成立)数据正在被其它地方读,此时拷贝数据并且reset shared_ptr到新拷贝的数据上,此时相当于写时复制(copy-on-write)。而当shared_ptr的引用计数器等于1时(data_.unique成立)数据并未被其它地方读,此时可以直接原地操作不必拷贝。
  • 对于直接替换数据的setData难免需要复制,在这个case下优势并不明显。

所以,准确说这不是copy-on-write而是copy-on-other-reading,符合C++哲学: “You don’t pay for what you don’t use”。

偶然从微信公众号看到的C++特性————协变返回类型(Covariant Return Types)。

简单来讲就是: 允许子类重写虚函数返回值为基类指针或引用协变成子类的指针或引用。如下例子

c++

class Base {
public:
    virtual Base* clone() const {
        std::cout << "Base::clone()" << std::endl;
        return new Base(*this);
    }
};
 
class Derived : public Base {
public:
    // 子类协变返回类型为子类的指针
    virtual Derived* clone() const override {
        std::cout << "Derived::clone()" << std::endl;
        return new Derived(*this);
    }
};

写switch case语句时确实很容易漏写break,在职业生涯中也多次踩坑被自己蠢哭。这次可能是同步代码导致的一处break被“意外删除”,导致代码未按照预期执行。

所以这次给我们的项目加上了-Wimplicit-fallthrough编译参数,对未加 break 的 case 报编译警告。如果逻辑上就是需要不加break,可以使用编译器属性[[fallthrough]]消除编译器警告,例如:

c++

int v = 1;
switch (v) {
  case 1: {
    std::cout << "fallthrough, 1";
    [[fallthrough]];
  }
  case 2: {
    std::cout << "2";
    break;
  }
  default: {
    std::cout << "default";
  }
}

微信文章中读到,记录下来。

对于如下的函数调用方式:

c++

processWidget(std::shared_ptr(new Widget()), priority());

可能会导致内存泄漏,编译器在产生对processWidget调用的命令前会生成如下命令,解析参数。

  1. 执行new Widget()
  2. 调用std::shared_ptr构造函数
  3. 调用priority()函数

而上述顺序编译器只保证1会先于2执行,因为2需要用到步骤1的结果。假设编译器生成的顺序为1、3、2,则在执行步骤3调用priority函数抛出异常,会导致步骤1申请的new Widget()未析构释放内存,发生内存泄漏

解决方法就是一个单独的语句创建智能指针或是使用std::make_shared()构造shared_ptr和new对象在一个函数中执行。

c++

std::shared_ptr<Widget> pw(new widget);
processWidget(pw, priority());
// or
processWidget(std::make_shared<Widget>(), priority());

在《C++并发编程实战(第2版)》3中读到,记录下来。

std::stack提供的pop函数就是单纯将一个值出栈并不返回出栈的值。所以想要”返回栈顶元素的值,并从栈上将其移除“的效果,需要首先判空,然后调用top获取栈顶元素的值,最后调用pop弹出栈顶数据。之前认为,采用这样的设计而不是pop既返回栈顶值也移除栈顶元素是为了更加灵活,在只需要移除栈顶元素而不获取栈顶元素时,不增加额外的拷贝。

其实还有更无法拒绝的原因: 当pop函数直接返回被出栈的值,有可能在已经将栈上元素移除后,返回复制元素时抛出异常,此时弹出元素已经从栈上移除,但复制不成功,导致数据丢失