c++ map中的Insert和emplace和操作符[]

我第一次使用映射,我意识到有很多种方法来插入一个元素。你可以使用emplace()operator[]insert(),加上类似value_typemake_pair的变体。虽然有很多关于他们所有人的信息和关于特定案例的问题,但我仍然不能理解大局。 所以,我的两个问题是:

  1. 它们各自的优势是什么?

  2. 是否有必要在标准中增加放置位置?在没有它之前,有什么事情是不可能的吗?

131267 次浏览

放置:利用右值引用来使用您已经创建的实际对象。这意味着没有复制或移动构造函数被调用,这对大型对象很好!O (log (N))的时间。

Insert:具有标准左值引用和右值引用的重载,以及指向要插入元素列表的迭代器,以及关于元素所属位置的“提示”。使用“hint”迭代器可以将插入所花费的时间降低到常数时间,否则就是O(log(N))时间。

操作符[]:检查对象是否存在,如果存在,则修改对该对象的引用,否则使用提供的键和值对这两个对象调用make_pair,然后执行与insert函数相同的工作。这是O(log(N))次。

make_pair:只做一对。

“没有必要”在标准中增加安放位置。在c++11中我相信&&添加了引用类型。这消除了移动语义的必要性,并允许优化某些特定类型的内存管理。特别是右值引用。重载的insert(value_type &&)操作符没有利用in_place语义,因此效率低得多。虽然它提供了处理右值引用的功能,但忽略了它们的关键用途,即对象的就地构造。

除了优化机会和更简单的语法之外,插入和插入之间的一个重要区别是后者允许显式的转换。(这适用于整个标准库,而不仅仅是地图。)

下面是一个例子:

#include <vector>


struct foo
{
explicit foo(int);
};


int main()
{
std::vector<foo> v;


v.emplace(v.end(), 10);      // Works
//v.insert(v.end(), 10);     // Error, not explicit
v.insert(v.end(), foo(10));  // Also works
}

诚然,这是一个非常具体的细节,但是当您处理用户定义的转换链时,有必要记住这一点。

在映射的特殊情况下,旧的选项只有两个:operator[]insert (insert的不同风格)。我将开始解释这些。

operator[]是一个find-or-add操作符。它将尝试在映射中找到具有给定键的元素,如果它存在,它将返回对存储值的引用。如果没有,它将创建一个新元素插入到默认初始化的位置,并返回对该元素的引用。

insert函数(在单元素类型中)接受value_type (std::pair<const Key,Value>),它使用键(first成员)并尝试插入它。因为std::map不允许重复,如果有一个现有的元素,它将不会插入任何东西。

两者之间的第一个区别是operator[]需要能够构造一个默认初始化的价值,因此它不能用于不能默认初始化的值类型。两者之间的第二个区别是,当已经有一个具有给定键的元素时会发生什么。insert函数不会修改映射的状态,而是返回一个指向元素的迭代器(以及一个false,表示它没有被插入)。

// assume m is std::map<int,int> already has an element with key 5 and value 0
m[5] = 10;                      // postcondition: m[5] == 10
m.insert(std::make_pair(5,15)); // m[5] is still 10

对于insert,实参是value_type的对象,可以用不同的方式创建。你可以直接用适当的类型构造它,或者传递任何可以构造value_type的对象,这就是std::make_pair发挥作用的地方,因为它允许简单地创建std::pair对象,尽管它可能不是你想要的…

下面调用的净效果是类似的:

K t; V u;
std::map<K,V> m;           // std::map<K,V>::value_type is std::pair<const K,V>


m.insert( std::pair<const K,V>(t,u) );      // 1
m.insert( std::map<K,V>::value_type(t,u) ); // 2
m.insert( std::make_pair(t,u) );            // 3

但它们并不是完全一样的……[1]和[2]是等价的。在这两种情况下,代码都会创建一个相同类型的临时对象(std::pair<const K,V>),并将其传递给insert函数。insert函数将在二叉搜索树中创建相应的节点,然后将参数中的value_type部分复制到该节点。使用value_type的好处是,value_type总是匹配 value_type,你不能输入错误的std::pair参数的类型!

差值是[3]。函数std::make_pair是一个模板函数,它将创建一个std::pair。签名为:

template <typename T, typename U>
std::pair<T,U> make_pair(T const & t, U const & u );

我故意没有将模板参数提供给std::make_pair,因为这是常见的用法。这意味着模板参数是从调用中推导出来的,在本例中为T==K,U==V,因此对std::make_pair的调用将返回std::pair<K,V>(注意缺少const)。签名需要value_type,即关闭,但与调用std::make_pair返回的值不相同。因为它足够接近,它将创建一个正确类型的临时对象并复制初始化它。然后将其复制到节点,总共创建两个副本。

这可以通过提供模板参数来修复:

m.insert( std::make_pair<const K,V>(t,u) );  // 4

但这仍然很容易出错,就像显式地键入情况[1]中的类型一样。

到目前为止,我们有不同的调用insert的方法,需要在外部创建value_type对象,并将该对象复制到容器中。或者,如果类型是默认可构成的可转让的,你也可以使用operator[](故意只关注m[k]=v),它需要一个对象的默认初始化和该对象的值的复制

在c++ 11中,有了可变模板和完美转发,就有了一种通过安放(就地创建)向容器中添加元素的新方法。不同容器中的emplace函数基本上做相同的事情:该函数不是将复制获取到容器中,而是接受将被转发到容器中存储的对象的构造函数的参数。

m.emplace(t,u);               // 5

在[5]中,std::pair<const K, V>不会被创建并传递给emplace,而是将对tu对象的引用传递给emplace,后者将它们转发给数据结构内部value_type子对象的构造函数。在这种情况下,std::pair<const K,V>没有拷贝根本就完成了,这是emplace相对于c++ 03替代品的优势。与insert的情况一样,它不会覆盖映射中的值。


一个有趣的问题,我没有想到的是emplace如何实际实现一个映射,这不是一个简单的问题,在一般情况下。

假设你将Foo对象添加到set<Foo>对象中,而Foo(int)是一个构造函数。那么主要的区别是:

  • emplace有原型set::emplace(Args&&... my_args)。调用emplace(0) 转发给定参数(即0)到Foo构造函数的set::emplace方法定义中的某处(例如,在该方法的代码中某处有Foo(0)这样的调用)。

  • insert有原型set::insert(Foo && value)。就像任何其他具有此类原型的函数一样,insert(0)调用创建了Foo对象Foo(0)(因为0具有类型int,而insert需要类型Foo的对象作为输入),该对象用于方法的value参数。该对象随后被用作set::insert(Foo && value)1方法定义中某处的Foo构造函数的参数。

下面的代码显示了“大图想法”;通过跟踪每个构造函数调用并在它们发生时告诉你关于它们的信息,insert()emplace()有何不同。将此输出与代码进行比较可以明确insert()emplace()之间的差异。

代码很简单,但有点长,所以为了节省时间,阅读摘要并通过快速查看代码(这应该足以理解代码及其输出)。

代码摘要:

  • static int foo_counter4:使用static int foo_counter来跟踪迄今为止已构造(或移动、复制等)的Foo对象的总数。每个Foo对象在其局部变量val中存储创建时foo_counter的(唯一)值。具有val == 8的唯一对象被称为“__abc8”;或“Foo 8”;(同样适用于static int foo_counter0, static int foo_counter1等)。每个构造函数/析构函数调用都会打印关于调用的信息(例如,调用static int foo_counter2将输出“__abc13”;)
  • insert()1: insert()s和emplace()s Foo对象转换为unordered_map<Foo,int>对象umap,调用诸如"和“;umap.insert({12, 0})"(0只是一些任意的int,它可以是任何值)。每一行代码在执行之前都被打印到insert()0。

代码

#include <iostream>
#include <unordered_map>
#include <utility>
using namespace std;


//Foo simply outputs what constructor is called with what value.
struct Foo {
static int foo_counter; //Track how many Foo objects have been created.
int val; //This Foo object was the val-th Foo object to be created.
Foo() { val = foo_counter++;
cout << "Foo() with val:                " << val << '\n';
}
Foo(int value) : val(value) { foo_counter++;
cout << "Foo(int) with val:             " << val << '\n';
}
Foo(Foo& f2) { val = foo_counter++;
cout << "Foo(Foo &) with val:           " << val
<< " \tcreated from:      \t" << f2.val << '\n';
}
Foo(const Foo& f2) { val = foo_counter++;
cout << "Foo(const Foo &) with val:     " << val
<< " \tcreated from:      \t" << f2.val << '\n';
}
Foo(Foo&& f2) { val = foo_counter++;
cout << "Foo(Foo&&) moving:             " << f2.val
<< " \tand changing it to:\t" << val << '\n';
}
~Foo() { cout << "~Foo() destroying:             " << val << '\n'; }
Foo& operator=(const Foo& rhs) {
cout << "Foo& operator=(const Foo& rhs) with rhs.val: " << rhs.val
<< " \tcalled with lhs.val = \t" << val
<< " \tChanging lhs.val to: \t" << rhs.val << '\n';
val = rhs.val; return *this;
}
bool operator==(const Foo &rhs) const { return val == rhs.val; }
bool operator<(const Foo &rhs)  const { return val <  rhs.val; }
};
int Foo::foo_counter = 0;


//Create a hash function for Foo in order to use Foo with unordered_map
template<> struct std::hash<Foo> {
size_t operator()(const Foo &f) const { return hash<int>{}(f.val); }
};


int main() {
unordered_map<Foo, int> umap;
int d; //Some int that will be umap's value. It is not important.


//Print the statement to be executed and then execute it.


cout << "\nFoo foo0, foo1, foo2, foo3;\n";
Foo foo0, foo1, foo2, foo3;


cout << "\numap.insert(pair<Foo, int>(foo0, d))\n";
umap.insert(pair<Foo, int>(foo0, d));
//Side note: equivalent to: umap.insert(make_pair(foo0, d));


cout << "\numap.insert(move(pair<Foo, int>(foo1, d)))\n";
umap.insert(move(pair<Foo, int>(foo1, d)));
//Side note: equiv. to: umap.insert(make_pair(foo1, d));
    

cout << "\npair<Foo, int> pair(foo2, d)\n";
pair<Foo, int> pair(foo2, d);


cout << "\numap.insert(pair)\n";
umap.insert(pair);


cout << "\numap.emplace(foo3, d)\n";
umap.emplace(foo3, d);
    

cout << "\numap.emplace(11, d)\n";
umap.emplace(11, d);


cout << "\numap.insert({12, d})\n";
umap.insert({12, d});


cout.flush();
}

输出

Foo foo0, foo1, foo2, foo3;
Foo() with val:                0
Foo() with val:                1
Foo() with val:                2
Foo() with val:                3


umap.insert(pair<Foo, int>(foo0, d))
Foo(Foo &) with val:           4    created from:       0
Foo(Foo&&) moving:             4    and changing it to: 5
~Foo() destroying:             4


umap.insert(move(pair<Foo, int>(foo1, d)))
Foo(Foo &) with val:           6    created from:       1
Foo(Foo&&) moving:             6    and changing it to: 7
~Foo() destroying:             6


pair<Foo, int> pair(foo2, d)
Foo(Foo &) with val:           8    created from:       2


umap.insert(pair)
Foo(const Foo &) with val:     9    created from:       8


umap.emplace(foo3, d)
Foo(Foo &) with val:           10   created from:       3


umap.emplace(11, d)
Foo(int) with val:             11


umap.insert({12, d})
Foo(int) with val:             12
Foo(const Foo &) with val:     13   created from:       12
~Foo() destroying:             12


~Foo() destroying:             8
~Foo() destroying:             3
~Foo() destroying:             2
~Foo() destroying:             1
~Foo() destroying:             0
~Foo() destroying:             13
~Foo() destroying:             11
~Foo() destroying:             5
~Foo() destroying:             10
~Foo() destroying:             7
~Foo() destroying:             9

大局

主要的“大图”;insert()emplace()的区别是:

虽然使用insert() Foo2Foo3 Foo4需要在main()的作用域内构造或预先存在某个Foo对象(随后是复制或移动),但如果使用emplace(),则对Foo构造函数的任何调用都完全在unordered_map内部完成(即在emplace()方法定义的作用域内)。传递给emplace()的键的实参直接转发给unordered_map::emplace()定义中的Foo构造函数调用(可选的附加细节:这个新构造的对象立即被合并到unordered_map的成员变量之一中,以便在执行离开emplace()时不调用析构函数,也不调用移动或复制构造函数)。

__的原因是“;在“几乎总是"因为insert()的一次重载实际上是相当于 emplace()。如在cppreference.com页面所述,重载template<class P> pair<iterator, bool> insert(P&& value)(该页上insert()的重载(2))等价于emplace(forward<P>(value))。由于我们对差异感兴趣,我将忽略这个过载,不再提及这个特殊的技术细节。

逐步执行代码

现在我将详细介绍代码及其输出。

  1. 首先,注意unordered_map对象总是在内部存储Foo对象(而不是,比如说,__abc2)作为键,当unordered_map对象被销毁时,这些键都将被销毁。在这里,unordered_map的内部键是foos13、11、5、10、7和9。
  • 因此,从技术上讲,我们的unordered_map实际上存储了pair<const Foo, int>对象,而pair<const Foo, int>对象又存储了Foo对象。但是要理解“大局观念”;关于emplace()insert()的区别(参见上面突出显示的框),可以将暂时想象为完全被动的pair对象。一旦你理解了这个“大的概念”,接下来,我们有必要回顾并理解unordered_map使用中介对象pair是如何引入微妙但重要的技术细节的。
  1. __abc0__abc1, foo1,和foo2中的每一个都需要调用Foo的复制/移动构造函数和Foo的析构函数(正如我现在描述的):

    • insert()ing foo0foo1分别创建了一个临时对象(分别为foo4foo6),在插入完成后立即调用其析构函数。此外,unordered_map的内部__abc6(它们是foos 5和7)在main()执行结束时,当unordered_map被销毁时,它们的析构函数也会被调用。
    • 对于insert() foo2,我们首先显式地创建了一个非临时pair对象(称为pair),该对象在foo2上调用Foo的复制构造函数(将foo8创建为pair的内部成员)。然后我们insert()ed这个pair,这导致unordered_map再次调用复制构造函数(在foo8上)来创建它自己的内部副本(foo20)。与foo21s 0和1一样,最终结果是对这个insert()ion的两次析构函数调用,唯一的区别是,foo8的析构函数仅在到达foo24的末尾时被调用,而不是在insert()结束后立即被调用。
  2. emplace()ing foo3只导致1个复制/移动构造函数调用(在unordered_map内部创建foo10)和1个Foo的析构函数调用。调用umap.emplace(foo3, d)调用Foo的非const复制构造函数的原因如下:由于我们使用的是emplace(),编译器知道foo3(一个非const Foo对象)是某个Foo构造函数的实参。在这种情况下,最合适的Foo构造函数是非const复制构造函数foo32。这就是为什么umap.emplace(foo3, d)调用了复制构造函数,而foo34没有。

  3. 对于foo11,我们直接将整数11传递给emplace(11, d),以便unordered_mapemplace()方法内执行时调用Foo(int)构造函数。与(2)和(3)不同的是,我们甚至不需要一些预先退出的foo对象来做到这一点。重要的是,请注意,只发生了一次对Foo构造函数的调用(它创建了foo11)。

  4. 然后直接将整数12传递给insert({12, d})。与emplace(11, d)不同(回调只导致对Foo构造函数的一次调用),对insert({12, d})的调用导致对Foo构造函数的两次调用(创建foo12foo13)。


后记

从这里往哪里走?

a.玩玩上面的源代码,并研究网上找到的insert()(例如在这里)和emplace()(例如在这里)的文档。如果你正在使用eclipse或NetBeans这样的IDE,那么你可以很容易地让IDE告诉你正在调用insert()emplace()的哪个重载(在eclipse中,只需将鼠标光标稳定地停留在函数调用上一秒钟)。下面是一些可以尝试的代码:

cout << "\numap.insert(\{\{" << Foo::foo_counter << ", d}})\n";
umap.insert(\{\{Foo::foo_counter, d}});
//but umap.emplace(\{\{Foo::foo_counter, d}}); results in a compile error!


cout << "\numap.insert(pair<const Foo, int>({" << Foo::foo_counter << ", d}))\n";
umap.insert(pair<const Foo, int>({Foo::foo_counter, d}));
//The above uses Foo(int) and then Foo(const Foo &), as expected. but the
// below call uses Foo(int) and the move constructor Foo(Foo&&).
//Do you see why?
cout << "\numap.insert(pair<Foo, int>({" << Foo::foo_counter << ", d}))\n";
umap.insert(pair<Foo, int>({Foo::foo_counter, d}));
//Not only that, but even more interesting is how the call below uses all
// three of Foo(int) and the Foo(Foo&&) move and Foo(const Foo &) copy
// constructors, despite the below call's only difference from the call above
// being the additional { }.
cout << "\numap.insert({pair<Foo, int>({" << Foo::foo_counter << ", d})})\n";
umap.insert({pair<Foo, int>({Foo::foo_counter, d})});




//Pay close attention to the subtle difference in the effects of the next
// two calls.
int cur_foo_counter = Foo::foo_counter;
cout << "\numap.insert(\{\{cur_foo_counter, d}, {cur_foo_counter+1, d}}) where "
<< "cur_foo_counter = " << cur_foo_counter << "\n";
umap.insert(\{\{cur_foo_counter, d}, {cur_foo_counter+1, d}});


cout << "\numap.insert(\{\{Foo::foo_counter, d}, {Foo::foo_counter+1, d}}) where "
<< "Foo::foo_counter = " << Foo::foo_counter << "\n";
umap.insert(\{\{Foo::foo_counter, d}, {Foo::foo_counter+1, d}});




//umap.insert(initializer_list<pair<Foo, int>>(\{\{Foo::foo_counter, d}}));
//The call below works fine, but the commented out line above gives a
// compiler error. It's instructive to find out why. The two calls
// differ by a "const".
cout << "\numap.insert(initializer_list<pair<const Foo, int>>(\{\{" << Foo::foo_counter << ", d}}))\n";
umap.insert(initializer_list<pair<const Foo, int>>(\{\{Foo::foo_counter, d}}));

你很快就会看到pair构造函数的哪个重载(参见参考)最终被unordered_map使用,这对复制、移动、创建和/或销毁对象的数量以及这一切发生的时间有重要影响。

b.看看当你使用其他容器类(例如setunordered_multiset)而不是unordered_map时会发生什么。

c.现在使用Goo对象(只是Foo的重命名副本)而不是int作为unordered_map中的范围类型(即使用unordered_map<Foo, Goo>而不是unordered_map<Foo, int>),并查看调用了多少Goo构造函数以及调用了哪些Goo构造函数。(剧透:确实有影响,但不是很戏剧化。)

就功能或输出而言,它们都是相同的。

对于这两个大内存,对象放置是内存优化,不使用复制构造函数

简单详细的解释 https://medium.com/@sandywits/all-about-emplace-in-c-71fd15e06e44 < / p >

还有一个在其他答案中尚未讨论的附加问题,它适用于std::map以及std::unordered_mapstd::setstd::unordered_set:

  • insert与键对象一起工作,如果键已经存在于容器中,则表示它不需要分配节点

  • emplace需要首先构造键,通常每次调用时都要构造需要分配一个节点

从这个角度来看,如果键已经存在于容器中,emplace的效率可能比insert低。(这对于多线程应用程序来说可能很重要,例如在线程本地字典中,需要同步分配。)

现场演示:https://godbolt.org/z/ornYcTqW9。注意,对于libstdc + +emplace分配10次,而insert只分配一次。在libc + +中,emplace也只有一个分配;似乎有一些优化复制/移动keys。我用微软STL得到了相同的结果,所以实际上似乎在libstdc++中缺少一些优化。然而,整个问题可能不仅仅与标准容器有关。例如,Intel/oneAPI TBB中的concurrent_unordered_map在这方面的行为与libstdc++相同。


__abc3注意,此优化不能应用于键同时为不可复制不可移动的情况。在这个现场演示中,即使使用emplace和libc++: https://godbolt.org/z/1b6b331qf,我们也有10个分配。(当然,对于不可复制和不可移动的键,我们不能使用inserttry_emplace,因此没有其他选项。)