C++常见避坑指南

作者 | gouglegou

C++ 从入门到放弃?本文主要总结了在C++开发或review过程中常见易出错点做了归纳总结,希望借此能增进大家对C++的了解,减少编程出错,提升工作效率,也可以作为C++开发的避坑攻略。

一、空指针调用成员函数会crash??

当调用一个空指针所指向的类的成员函数时,大多数人的反应都是程序会crash。空指针并不指向任何有效的内存地址,所以在调用成员函数时会尝试访问一个不存在的内存地址,从而导致程序崩溃。

事实上有点出乎意料,先来看段代码:

class MyClass {
public:
  static void Test_Func1() {
    cout HandleUrl(param);
}

二、字符串相关

1.字符串查找

对字符串进行处理是一个很常见的业务场景,其中字符串查找也是非常常见的,但是用的不好也是会存在各种坑。常见的字符串查找方法有:std::string::find、std::string::find_first_of、std::string::find_first_not_of、std::string::find_last_of,各位C++ Engineer都能熟练使用了吗?先来段代码瞧瞧:

bool IsBlacklistDllFromSrv(const std::string& dll_name) {
    try {
        std::string target_str = dll_name;
 std::transform(target_str.begin(), target_str.end(), target_str.begin(), ::tolower);
        if (dll_blacklist_from_srv.find(target_str) != std::string::npos) {
            return true;
        }
    }
    catch (...) {
    }
    return false;
}

上面这段代码,看下来没啥问题的样子。但是仔细看下来,就会发现字符串比对这里逻辑不够严谨,存在很大的漏洞。std::string::find只是用来在字符串中查找指定的子字符串,只要包含该子串就符合,如果dll_blacklist_from_srv = "abcd.dll;hhhh.dll;test.dll" 是这样的字符串,传入d.dll、hh.dll、dll;test.dll也会命中逻辑,明显是不太符合预期的。

这里顺带回顾下C++ std::string常见的字符串查找的方法:

  • std::string::find 用于在字符串中查找指定的子字符串。如果找到了子串,则返回子串的起始位置,否则返回std::string::npos。用于各种字符串操作,例如判断子字符串是否存在、获取子字符串的位置等。通过结合其他成员函数和算法,可以实现更复杂的字符串处理逻辑。
  • std::string::find_first_of 用于查找字符串中第一个与指定字符集合中的任意字符匹配的字符,并返回其位置。可用来检查字符串中是否包含指定的某些字符或者查找字符串中第一个出现的特定字符
  • std::string::find_first_not_of 用于查找字符串中第一个不与指定字符集合中的任何字符匹配的字符,并返回其位置。
  • std::string::find_last_of 用于查找字符串中最后一个与指定字符集合中的任意字符匹配的字符,并返回其位置。可以用来检查字符串中是否包含指定的某些字符,或者查找字符串中最后一个出现的特定字符
  • std::string::find_last_not_of 用于查找字符串中最后一个不与指定字符集合中的任何字符匹配的字符,并返回其位置。

除了以上几个方法外,还有查找满足指定条件的元素std::find_if。

std::find_if 是 C++ 标准库中的一个算法函数,用于在指定范围内查找第一个满足指定条件的元素,并返回其迭代器。需要注意的是,使用 std::find_if 函数时需要提供一个可调用对象(例如 lambda 表达式或函数对象),用于指定查找条件。

std::vector vec = {1, 2, 3, 4, 5};
auto it = std::find_if(vec.begin(), vec.end(), [](int x) { return x % 2 == 0; });
if (it != vec.end()) {
    std::cout  100 && num % 2 != 0;
});
vec.erase(it);

上面代码,查找std::vector中大于 100 并且为奇数的整数并将其删除。std::find_if 将从容器的开头开始查找,直到找到满足条件的元素或者遍历完整个容器,并返回迭代器it,然后去删除该元素。但是这里没有判断it为空的情况,直接就erase了,如果erase一个空的迭代器会引发crash。很多新手程序员会犯这样的错误,随时判空是个不错的习惯。

删除元素不得不讲下std::remove 和 std::remove_if,用于从容器中移除指定的元素, 函数会将符合条件的元素移动到容器的末尾,并返回指向新的末尾位置之后的迭代器,最后使用容器的erase来擦除从新的末尾位置开始的元素。

std::vector vecs = { "A", "", "B", "", "C", "hhhhh", "D" };
vecs.erase(std::remove(vecs.begin(), vecs.end(), ""), vecs.end());

// 移除所有偶数元素
vec.erase(std::remove_if(vec.begin(), vec.end(), [](int x) { return x % 2 == 0; }), vec.end());

这里的erase不用判空,其内部实现已经有判空处理。

_CONSTEXPR20 iterator erase(const_iterator _First, const_iterator _Last) noexcept(
        is_nothrow_move_assignable_v) /* strengthened */ {
    const pointer _Firstptr = _First._Ptr;
    const pointer _Lastptr  = _Last._Ptr;
    auto& _My_data          = _Mypair._Myval2;
    pointer& _Mylast        = _My_data._Mylast;
    // ....
    if (_Firstptr != _Lastptr) { // something to do, invalidate iterators
        _Orphan_range(_Firstptr, _Mylast);
        const pointer _Newlast = _Move_unchecked(_Lastptr, _Mylast, _Firstptr);
        _Destroy_range(_Newlast, _Mylast, _Getal());
        _Mylast = _Newlast;
    }
    return iterator(_Firstptr, _STD addressof(_My_data));
}

此外,STL容器的删除也要小心迭代器失效,先来看个vector、list、map删除的例子:

// vector、list、map遍历并删除偶数元素
std::vector elements = { 1, 2, 3, 4, 5 };
for (auto it = elements.begin(); it != elements.end();) {
 if (*it % 2 == 0) {
        elements.erase(it++);
    } else {
        it++;
    }
}
// Error
std::list cont{ 88, 101, 56, 203, 72, 135 };
for (auto it = cont.begin(); it != cont.end(); ) {
    if (*it % 2 == 0) {
        cont.erase(it++);
    } else {
        it++;
    }
}
// Ok
 std::map myMap = { {1, "one"}, {2, "two"}, {3, "three"}, {4, "four"}, {5, "five"} };
// 遍历并删除键值对,删除键为偶数的元素
for (auto it = myMap.begin(); it != myMap.end(); ) {
    if (it->first % 2 == 0) {
        myMap.erase(it++);
    } else {
        it++;
    }
}
// Ok

上面几类容器同样的遍历删除元素,只有vector报错crash了,map和list都能正常运行。其实vector调用erase()方法后,当前位置到容器末尾元素的所有迭代器全部失效了,以至于不能再使用。

迭代器的失效问题:对容器的操作影响了元素的存放位置,称为迭代器失效。迭代器失效的情况:

  • 当容器调用erase()方法后,当前位置到容器末尾元素的所有迭代器全部失效。
  • 当容器调用insert()方法后,当前位置到容器末尾元素的所有迭代器全部失效。
  • 如果容器扩容,在其他地方重新又开辟了一块内存,原来容器底层的内存上所保存的迭代器全都失效。

迭代器失效有三种情况,由于底层的存储数据结构,分三种情况:

  • 序列式迭代器失效,序列式容器(std::vector和std::deque),其对应的数据结构分配在连续的内存中,对其中的迭代器进行insert和erase操作都会使得删除点和插入点之后的元素挪位置,进而导致插入点和删除掉之后的迭代器全部失效。可以利用erase迭代器接口返回的是下一个有效的迭代器。
  • 链表式迭代器失效,链表式容器(std::list)使用链表进行数据存储,插入或者删除只会对当前的节点造成影响,不会影响其他的迭代器。可以利用erase迭代器接口返回的是下一个有效的迭代器,或者将当前的迭代器指向下一个erase(iter++)。
  • 关联式迭代器失效,关联式容器,如map, set,multimap,multiset等,使用红黑树进行数据存储,删除当前的迭代器,仅会使当前的迭代器失效。erase迭代器的返回值为 void(C++11之前),可以采用erase(iter++)的方式进行删除。值得一提的是,在最新的C++11标准中,已经新增了一个map::erase函数执行后会返回下一个元素的iterator,因此可以使用erase的返回值获取下一个有效的迭代器。

在实现上有两种模板,其一是通过 erase 获得下一个有效的 iterator,使用于序列式迭代器和链表式迭代器(C++11开始关联式迭代器也可以使用)

for (auto it = elements.begin(); it != elements.end(); ) {
    if (ShouldDelete(*it)) {
        it = elements.erase(it); // erase删除元素,返回下一个迭代器
    } else {
        it++;
    }
}

其二是,递增当前迭代器,适用于链表式迭代器和关联式迭代器。

for (auto it = elements.begin(); it != elements.end(); ) {
    if (ShouldDelete(*it)) {
        elements.erase(it++); 
    } else {
        it++;
    }
}

四、对象拷贝

在众多编程语言中C++的优势之一便是其高性能,可是开发者代码写得不好(比如:很多不必要的对象拷贝),直接会影响到代码性能,接下来就讲几个常见的会引起无意义拷贝的场景。

1.for循环:

std::vector vec;
for(std::string s: vec) {
}
// or
for(auto s: vec) {
}

这里每个string都会被拷贝一次,为避免无意义拷贝可以将其改成:

for(const auto& s: vec) 或者 for (const std::string& s: vec)

2.lambda捕获

// 获取对应消息类型的内容
std::string GetRichTextMessageXxxContent(const std::shared_ptr& message,
 const std::map& related_user_names,
 const model::UserId& login_userid,
 bool for_message_index) {
 // ...
 // 解析RichText内容
 return DecodeRichTextMessage(message, [=](uint32_t item_type, const std::string& data) {
  std::string output_text;
  // ...
  return output_text;
  });
}

上述代码用于解析获取文本消息内容,涉及到富文本消息的解析和一些逻辑的计算,高频调用,他在解析RichText内容的callback中直接简单粗暴的按值捕获了所有变量,将所有变量都拷贝了一份,这里造成不必要的性能损耗,尤其上面那个std::map。这里可以改成按引用来捕获,规避不必要的拷贝。

lambda函数在捕获时会将被捕获对象拷贝,如果捕获的对象很多或者很占内存,将会影响整体的性能,可以根据需求使用引用捕获或者按需捕获:

auto func = &a{};

auto func = a = std::move(a){}; (限C++14以后)

3.隐式类型转换

std::map myMap = {{1, "One"}, {2, "Two"}, {3, "Three"}};
for (const std::pair& pair : myMap) {
    //...
}

这里在遍历关联容器时,看着是const引用的,心想着不会发生拷贝,但是因为类型错了还是会发生拷贝,std::map 中的键值对是以 std::pair 的形式存储的,其中key是常量。因此,在每次迭代时,会将当前键值对拷贝到临时变量中。在处理大型容器或频繁遍历时,这种拷贝操作可能会产生一些性能开销,所以在遍历时推荐使用const auto&,也可以使用结构化绑定:for(const auto& [key, value]: map){} (限C++17后)

4.函数返回值优化

RVO是Return Value Optimization的缩写,即返回值优化,NRVO就是具名的返回值优化,为RVO的一个变种,此特性从C++11开始支持。为了更清晰的了解编译器的行为,这里实现了构造/析构及拷贝构造、赋值操作函数,如下:

class Widget {
public:
Widget() {
std::cout