如何写一个遍历C++容器的循环?

今天要分析的主题,是一个看起来再简单不过的事情:如何写一个关于容器std::map的循环?这谁不会啊,懂点C++和STL不就手到擒来嘛,但是这个写不好的循环曾宕机无数(至少在我所在项目是这样)。

0.初印象

假设有数据结构Item及其容器对象items:

struct Item {
    uint32_t id;
    std::string name;
    uint32_t data[1024];
};

std::map<uint32_t, Item> items = {
        {1,  {1,  "David1"}},
        {6,  {6,  "David6"}},
        {8,  {8,  "David8"}},
        {11, {11, "David11"}},
        {13, {13, "David13"}},
        {15, {15, "David15"}},
        {17, {17, "David17"}},
        {22, {22, "David22"}},
        {25, {25, "David25"}},
        {27, {27, "David27"}},
};

写一个清理掉键值为偶数的元素的循环,这还不是小菜一碟嘛,来上代码:

for (auto it = items.begin(); it != items.end(); ++it) {
    if (it->first % 2 == 0)
        items.erase(it);
}

很不幸,宕掉了!还记得C++标准库手册和Effective STL怎么教导我们吗?在遍历map容器时,erase会使得当前迭代器失效,从而++it时就会有异常,宕机还是死循环就听天由命了! 正确的写法应该是:

1> C++11标准之前:先把当前的it值传递到erase,然后it指向下一个元素,最后erase清掉之前传递的迭代器指向的元素。

for (auto it = items.begin(); it != items.end();) {
    if (it->first % 2 == 0)
        items.erase(it++);
    else
        it++;
}

2> C++11标准之后:为了和其它容器erase形式保持一致,erase的返回指向下一个元素的迭代器。

for (auto it = items.begin(); it != items.end();) {
    if (it->first % 2 == 0)
        it = items.erase(it);
    else
        it++;
}

好了,好了!收工了,只要记住遍历map时,如果要删除元素就按上面形式写不就没事嘛!这么简单的代码,都能宕机,简直是一群呆瓜!

客官,稍等,好戏才刚开始上演,不妨继续听我分析!

2. 遍历容器时对当前容器都可以进行哪些操作?

首先来看下,STL中的map是用红黑树实现的,上面items对象的数据结构大概就张这个样子:

红黑树

我们想一下,在遍历一个map的循环体中都可以干什么?再往深了说,就是在遍历这个红黑树时,对当前这个树都能进行哪些操作?我把它归位下面几个操作:

  • 读写:读和写当前正在遍历的元素。
  • 添加:添加一个元素,可能位于正着遍历的元素之前或之后。
  • 删除:删除正在遍历的元素、之前的元素、之后的元素。
  • 清空:遍历时把自己给清空了。

这些操作的结果会是怎样?

1> 读写操作。对此不多说,极大多数遍历的目的就是此,代码极易理解。如:

for (auto it = items.begin(); it != items.end(); ++it) {
    it->second.name += ".Wang"; // 修改名字
    std::cout << it->second.id << " - "
              << it->second.name << std::endl; // 输出
}

2> 添加操作。添加在当前元素之后的,后面会循环到;添加在当前元素之前的,后面是循环不到的。

for (auto it = items.begin(); it != items.end(); ++it) {
    if (it->first == 8) {
        // 当前节点之后添加
        items[9].name = "David9";
        items[9].id = 9;

        // 当前节点之前添加
        items[7].name = "David7";
        items[7].id = 7;
    }

    // 节点7的的信息是不会被输出
    // 节点9的信息会输出
    std::cout << "loop: " << it->first << "-"
              << it->second.name << std::endl;
}

3> 删除操作。删除操作最易出错,当以读写操作式的循环写时,删掉当前元素就会有异常,删掉之前或之后的都没什么问题,删掉之后的后续的遍历当然不会循环到。

for (auto it = items.begin(); it != items.end(); ++it) {
    if (it->first == 8) {
        items.erase(6); // OK
        items.erase(11);// OK
        items.erase(8); // 宕机:删除了当前元素,迭代器失效
    }
}

4> 清空操作。清空操作可以看作是一种特殊的删除,当然包含了删除当前元素,所以下面形式的循环必定会有异常。

for (auto it = items.begin(); it != items.end(); ++it) {
    if (it->first == 8) {
      items.clear();  // 宕机:直接清空时也删除了当前元素
    }
}

综上示例,可以得出下面的结论:

在遍历map容器时,循环体里面执行的代码,对当然的容器进行读写和添加都没什么问题,删除时要谨慎,避免清空!

2. 藏比较深的隐患

上面的结论都记住了,能保证不出错吗?上面的代码片段都比较短小,很容易识别宕机隐患,但是设想一下一个底层框架,对map进行循环,循环体里面执行某种回调,这种回调完全就是由上层应用程序同学编写,随意嵌好几层函数,不小心动到底层正着遍历的容器(删除某个元素、清空整个容器),这样的循环代码就是一颗定时炸弹。运气好些,逻辑错误,运气差些,宕机死循环来招呼你。

假设要你封装一个对items的foreach,你会怎么做?

1> 最直接最基础的方法。隐患:回调删除当前遍历的元素就会异常

void foreach_items(const std::function<void(Item &)> &cb) {
    for (auto it = items.begin(); it != items.end(); ++it) {
        cb(it->second);
    }
}

有隐患的代码:

foreach_items([](Item &item) {
    if (item.id == 8) {
        items.erase(8); // 删掉当前的
    }
});

2> 听了Effective STL忠告的同学,可能会这样写:

void foreach_items(const std::function<void(Item &)> &cb) {
    for (auto it = items.begin(); it != items.end();) {
        auto tmp = it++;
        cb(tmp->second);
    }
}

高枕无忧吗?再看下下面有隐患的代码:

foreach_crash2([](const Item &item) {
    if (item.id == 8) {
        items.erase(11); // 删掉下一个
    }
});

8紧接着后面是11,为了防止it迭代器失效,在遍历前已经进行了it++。但是也保不准回调里面哪位踩雷的同学,删掉紧接着的元素,这也使得上层循环里面的it++也失效,再次异常!!

不好意思,让你烧脑了,这种情况一般隐藏的比较深,上层逻辑只要不删到下一个元素就没事。遍历一个map时删除map元素就像你在做一件事情时,老是有人捅刀子的感觉一样。

3.安全的遍历

继续思考,如何封装一个对items的foreach并保证没隐患?这也不行,那也不行,你妹的C++就是这么坑人?试问:没有垃圾回收的任何一种语言,在遍历一个容器时候删除当前容器的内容,合适吗?安全吗?我觉得这个锅C++不背。废话少说,既然选择了C++,继续想辙吧!

1> 上面第一种方法,最基础的。这种是最高效,复杂度O(n)。在循环体中不删除当前容器的任意一个元素,能保证这条铁律,那就不会有问题。当然,人为遵守约定是最不靠谱的事情,一般不同的框架有不同的方法,大概的思路都是要删除时打个标记,真正的删除延后处理,使用时判定一下当前元素是否有效即可。有点像自己实现一套垃圾回收的机制。

一个简单的示范(没有设标记,只是延后删除):

std::set<uint32_t> deleted;
for (auto it = items.begin(); it != items.end(); ++it) {
    if (it->first % 2 == 0)
      deleted.insert(it->first);
}

for (auto key: delted)
      items.erase(key);

2> 使用当前容器的拷贝进行遍历。

void foreach_item(const std::function<void(Item &)> &cb) {
    std::map<uint32_t, Item> copy = items;
    for (auto it = copy.begin(); it != copy.end(); ++it) {
        cb(it->second);
    }
}

优点:

  • 可以安全遍历,回调立马想干啥干啥。

缺点:

  • 容器内容较多时,一个COPY的代价也不低。
  • 回调修改it->second时,会导致逻辑错误,原始的items中的元素并没有发生变化。除非it->second是一个对象的指针。
  • 上一个回调对原始容器的操作,反应不到下一个回到执行时。比如对元素6的回调是删除items里面的8,但是由于做了一个临时拷贝,结果元素8照样执行,按理来说,6之前删掉了8,8后续就不能被执行。PS.往往回调依赖设计成这样的是非常不合理的,尽量避免。

3> 复制一份键直集合,然后使用键值集合进行遍历。上面的几个缺点得以改进:

  • 仅COPY键值
  • 修改内容也会立即有效
  • 回调依赖也不受影响
void foreach_item(const std::function<void(Item &)> &cb) {
    std::set<uint32_t> keys;
    for (auto &it : items)
        keys.insert(it.first);

    for (auto key : keys) {
        auto it = items.find(key);
        if (it != items.end())
            cb(it->second);
    }
}

缺点:复杂度变为O(n*logn),同时还多了一份键值拷贝。

4> 每次循环时根据键值找下一个元素的迭代器。之前不是说过循环体里面,出错的老是在下一个迭代器吗 ,那我们是否可以记下键值,执行完回调,再利用键值找到下一个元素的迭代器即可。正向循环使用upper_bound,逆向循环使用lower_bound。

void foreach_item(const std::function<void(Item &)> &cb) {
    for (auto it = items.begin(); it != items.end();) {
        uint32_t key = it->first;
        cb(it->second);
        it = items.upper_bound(key);
    }
}

缺点:复杂度变为O(n*logn)。
优点:安全简洁。

4. 小结

综上,写一个map的循环时,特别时在写底层框架时,一个简单的for循环变的隐患无数。要根据需求,容器结构,容器的大小,性能要求决定合适的方式,比较安全的方式我暂时也就总结以上四种。最后,再考虑下其它容器的情况?list、vector、unorderd_map?

有更好方案的可以留言或邮件:397157852@qq.com。示例代码地址:https://github.com/david-pp/david/blob/master/clion/cpp/loop.cpp

PS. 我们项目,由于这个本质问题导致的宕机多如牛毛,故总结并分享给各位,望慎重。

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

推荐阅读更多精彩内容