1.10 stl容器新增的实用方法
下面讲解stl容器新增的实用方法。
1.10.1 原位构造与容器的emplace系列函数
在介绍emplace和emplace_back方法之前,我们先看一段代码:
以上代码在一个循环里产生一个对象,然后将这个对象放入集合中,这样的代码在实际开发中太常见了。但是这样的代码存在严重的效率问题:循环中的t对象在每次循环时,都分别调用了一次构造函数、拷贝构造函数和析构函数,如下所示。
以上总共循环10次,调用30次。但实际上,我们的初衷是创建一个对象t,将其直接放入集合中,而不是将t作为一个中间临时产生的对象,这样的话,总共需要调用t的构造函数 10次就可以了。C++11提供了一个在这种情形下替代 push_back 的方法——emplace_back。通过使用emplace_back,可以将main函数中的代码改写如下:
经过以上改写,在实际执行时只需调用Test类的构造函数10次,大大提高了执行效率。
同理,在这种情形下,对于像 std::list、std::vector 这样的容器,其 push、push_front方法在C++11中也有对应的改进方法,即emplace/emplace_front方法。在C++Reference上将这里的emplace操作称为“原位构造元素(EmplaceConstructible)”是非常贴切的。
除了使用emplace系列的函数原位构造元素,我们也可以为Test类添加移动构造函数(Move Constructor),复用产生的临时对象t以提高效率。
1.10.2 std::map的try_emplace方法与insert_or_assign方法
因为std::map中元素的key是唯一的,所以在实际开发中经常会有这样一类需求:向某个 map中插入元素时需要先检测 map中指定的 key是否存在,不存在时做插入操作,存在时直接取来使用;或者在指定的key不存在时做插入操作,存在时做更新操作。
以PC版的QQ为例,好友列表中的每个好友都对应一个userid,当我们双击某个QQ好友头像时,如果与该好友的聊天对话框(这里使用 ChatDialog 表示)已经存在,则直接将其激活并显示,如果不存在,则将其创建并激活、显示。假设我们使用std::map来管理这些聊天对话框,则在C++17之前的版本中,必须编写额外的逻辑去判断元素是否存在。可以将上述逻辑编写如下:
在C++17中,map提供了一个try_emplace方法,该方法会检测指定的key是否存在,如果存在,则什么也不做。函数签名如下:
在以上函数签名中,参数k表示需要插入的key;args参数是一个不定参数,表示构造value对象需要传给构造函数的参数;通过hint参数可以指定插入的位置。
在前两种签名形式中,try_emplace 的返回值是一个 std::pair<T1,T2>类型,其中 T2是一个 bool 类型,表示元素是否成功插入 map 中;T1 是一个 map 的迭代器,如果插入成功,则返回指向插入位置的元素的迭代器,如果插入失败,则返回 map 中已存在的相同key元素的迭代器。我们用 try_emplace改写上面的代码(这里不关心插入的位置,因此使用前两个签名):
使用 try_emplace 改写后的代码简洁了许多。但是在以上代码中需要注意:由于std::map<int64_t,ChatDialog*> m_ChatDialogs 的 value 是指针类型(ChatDialog*),而try_emplace的第 2个参数支持的是构造一个 ChatDialog对象,而不是指针类型,因此在某个 userid 不存在时,成功插入 map 后会导致相应的 value 为空指针。因此,我们利用inserted的值按需新建一个ChatDialog。当然,在新的C++规范(C++11及后续版本)提供了灵活而强大的智能指针以后,我们不应该再有任何理由去使用裸指针了,因此可以对以上代码使用std::unique_ptr智能指针类型来重构:
以上代码将 map 的类型从 std::map<int64_t,ChatDialog*>改为 std::map<int64_t,std::unique_ptr<ChatDialog>>,让程序自动管理聊天对话框对象。程序在gcc/g++7.3中编译并运行输出如下:
在以上代码中,构造函数和析构函数均被调用了3次,实际上,按最原始的逻辑(上文中普通版本)来讲,ChatDialog 应该只被构造和析构 2 次,多出来的一次是因为在try_emplace时,无论某个userid是否存在于map中,均创建一个ChatDialog对象(这是额外的用不上的对象)。由于这个对象并没有被用上,所以在出了 onDoubleClickFriendItem3函数的作用域后,智能指针对象 spChatDialog 被析构,进而导致这个额外的、用不上的ChatDialog对象被析构。这相当于做了一次无用功。为此,我们可以继续优化代码如下:
以上代码按照之前裸指针版本的思路,按需创建了一个智能指针对象,避免了一次ChatDialog对象无用的构造和析构。再次编译程序,执行结果如下:
在auto [iter,inserted]=m_ChatDialogs.try_emplace(userid,nullptr);语句中,m_ChatDialogs.try_emplace(userid,nullptr)函数返回两个值,第2个值inserted是一个布尔变量,表示操作是否成功,如果成功,则在第1个返回值iter中含有函数调用成功后的数据。这种函数存在多个返回值且其中一个值表示函数是否调用成功,我们称这种模式为ok-idiom模式,Golang开发者应该很熟悉这种ok-idiom模式。
为了方便验证try_emplace函数支持原位构造(上文已经介绍),我们将map的value类型改成 ChatDialog 类型。在实际开发中,对于非 POD 类型的复杂数据类型,在 stl 容器中应该存储其指针或者智能指针类型,而不是对象本身。修改后的代码如下:
在以上代码中,我们为 ChatDialog 类的构造函数增加了一个 userid 参数,因此当调用 try_emplace 方法时,需要传递一个参数,这样 try_emplace 就会根据 map 中是否已存在同样的 userid 按需构造 ChatDialog 对象了。程序的执行结果和上一个代码示例应该是一样的:
对于智能指针对象std::unique_ptr,在后面的小节中将详细介绍。
上文介绍了map中指定的key不存在则插入相应的value,存在则直接使用该key对应的 value的情形。这里再来介绍 map中指定的 key不存在则插入相应的 value,存在则更新其value的情形。C++17为map容器新增了一个insert_or_assign方法,让我们不再像C++17标准之前一样额外编写先判断是否存在,不存在则插入,存在则更新的代码了,这次我们可以一步到位。insert_or_assign的函数签名如下:
其各个函数参数的含义与try_emplace一样,这里不再赘述。
再来看一个例子:
在以上代码中尝试插入名为Tom的用户,由于该人名在map中不存在,因此插入成功;当插入人名为Alex的用户时,由于在map中已经存在该人名,因此只对其年龄进行更新,将Alex的年龄从45更新为27。程序执行结果如下:
本节介绍了 C++11/17 为 stl 容器新增的几个实用方法,合理利用这些新增的方法会让我们的程序变得更简洁、高效。其实,新的C++标准一直在不断改进和优化现有的 stl容器,如果经常需要与这些容器打交道,则建议留意C++新标准中这些容器的新动态。