🔙

emplace_back とは何か (C++)

2023/11/12に公開

はじめに

C++11 から、標準ライブラリのコンテナに emplace 系のメンバ関数 (emplace, emplace_back, emplace_front) が追加されました。
機能は既存の insert 系のメンバ関数 (insert, push_back, push_front) に相当しますが、どのような違いがあるのでしょうか。

すでに同様の内容の記事が多数公開されていると思いますが、先日職場で質問を受けましたので簡単に書いておきます。

要約

emplace 系の最大の特徴は、挿入する要素をコンテナの内部に直接構築するということです。
その狙いは効率化です[1]

insert 系のメンバ関数は要素型のオブジェクト[2]を受け取り、コンテナ内部にコピーまたはムーブするのに対し、 emplace 系のメンバ関数は要素型のコンストラクタの引数を受け取り、コンテナ内部に要素型のオブジェクトを直接構築します。

動作の違い

論より証拠ということで、実際の動作をみんな大好き std::vector クラスを例に見てみましょう。
insert 系は push_back, emplace 系は emplace_back, のメンバ関数をそれぞれ使います。

(準備) Element クラス

動作を見るために簡単なクラス Element を用意します。
コンストラクタやデストラクタが呼ばれたらそれを標準出力に吐くだけのものです。

Element クラス
Element.hpp
#include <iostream>

struct Element {
 public:
  // デフォルトコンストラクタ
  explicit Element() {
    std::cout << "default ctor\n";
  }
  // 引数つきコンストラクタ
  explicit Element(int) {
    std::cout << "ctor w/ 1 arg\n";
  }
  // 引数つきコンストラクタ
  explicit Element(int, int) {
    std::cout << "ctor w/ 2 args\n";
  }
  // コピーコンストラクタ
  Element(const Element &) {
    std::cout << "copy ctor\n";
  }
  // ムーブコンストラクタ
  Element(Element &&) {
    std::cout << "move ctor\n";
  }
  // デストラクタ
  ~Element() {
    std::cout << "dtor\n";
  }
};

動作比較 (1) — 引数なし

まずは、引数なしで Element オブジェクトを構築してベクタに追加します。

insert 系の動作 (1) — 引数なし

#include <iostream>
#include <vector>
#include "Element.hpp"

int main() {
  std::vector<Element> v;
  v.reserve(1);
  std::cout << "1\n";
  v.push_back(Element{});
  std::cout << "2\n";
}
1
default ctor
move ctor
dtor
2
dtor

emplace 系の動作 (1) — 引数なし

#include <iostream>
#include <vector>
#include "Element.hpp"

int main() {
  std::vector<Element> v;
  v.reserve(1);
  std::cout << "1\n";
  v.emplace_back();
  std::cout << "2\n";
}
1
default ctor
2
dtor

比較 (1) — 引数なし

push_backemplace_back の両操作で呼び出される Element クラスのコンストラクタ, デストラクタを比較すると次のとおりです:

  • insert (push_back) 版
    1. デフォルトコンストラクタ
    2. ムーブコンストラクタ
    3. デストラクタ
  • emplace (emplace_back) 版
    1. デフォルトコンストラクタ

オブジェクトの構築・破棄にはコストがかかりますが、それらの呼び出しが削減されて効率化されています。

動作比較 (2) — 引数あり

次は、引数つきで Element オブジェクトを構築してベクタに挿入します。
Element クラスには int 型を引数に取るコンストラクタを用意しておきましたので、整数 0 を渡します。

insert 系の動作 (2) — 引数あり

#include <iostream>
#include <vector>
#include "Element.hpp"

int main() {
  std::vector<Element> v;
  v.reserve(1);
  std::cout << "1\n";
  v.push_back(Element{0});
  std::cout << "2\n";
}
1
ctor w/ 1 arg
move ctor
dtor
2
dtor

emplace 系の動作 (2) — 引数あり

#include <iostream>
#include <vector>
#include "Element.hpp"

int main() {
  std::vector<Element> v;
  v.reserve(1);
  std::cout << "1\n";
  v.emplace_back(0);
  std::cout << "2\n";
}
1
ctor w/ 1 arg
2
dtor

比較 (2) — 引数あり

push_backemplace_back の両操作で呼び出される Element クラスのコンストラクタ, デストラクタを比較すると次のとおりです:

  • insert (push_back)
    1. 引数つきコンストラクタ
    2. ムーブコンストラクタ
    3. デストラクタ
  • emplace (emplace_back)
    1. 引数つきコンストラクタ

引数ありの場合も引数なしの場合と同様に、コンストラクタ, デストラクタの呼び出しは削減されています。

あまりにもおもしろくないのでコードや結果は掲載しませんが、引数 2 個のコンストラクタで試してみても同様です。

簡単な解説

std::vector クラスの emplace_back メンバ関数の宣言は次のようになっています[3]:

template <class... Args> constexpr reference emplace_back(Args&&... args);

可変長引数テンプレート (variadic template) を使ったメンバ関数テンプレートです。
これで 0 個以上の任意の型・個数の引数を受け取ることができます。
型および個数が一致するコンストラクタがなければきちんとコンパイルエラーになります。

emplace_back メンバ関数に渡された引数は T 型 (value_type 型) のコンストラクタに完全転送 (perfect forward) され、ベクタ内部の領域の末尾に placement-new によって直接構築されます[4]

性能比較

ほんとうに emplace 系は insert 系よりも効率化されているのか、簡単に試してみました。
要素の型は次のようなクラスです:

struct Element {
 public:
  std::uint32_t Key;
  std::string Value;
};

データメンバ Key は 32 bit の整数, Value は長さ 32 の文字列としました。
この Element オブジェクトを 10,000 個用意し、それらを std::vector にそれぞれ push_backemplace_back するのにかかる時間を測定しました。
全体のソースコードを次に示します:

性能比較のソースコード
#include <cstdint>
#include <algorithm>
#include <chrono>
#include <functional>
#include <iostream>
#include <random>
#include <string>
#include <vector>

struct Element {
 public:
  std::uint32_t Key;
  std::string Value;
};

void randomize(std::vector<Element> &v) {
  std::mt19937 engine{std::random_device{}()};
  std::uniform_int_distribution<std::uint32_t> ui32dist;
  std::uniform_int_distribution<char> chardist{0x20, 0x7F};

  std::ranges::generate(v, [&engine, &ui32dist, &chardist] {
    Element e;
    e.Key = ui32dist(engine);
    e.Value.resize(32UZ, ' ');
    std::ranges::generate(e.Value, std::bind(chardist, engine));
    return e;
  });
}

class AdhocStopwatch {
 public:
  using clock = std::chrono::steady_clock;
  AdhocStopwatch()
      : started_{clock::now()} {}
  ~AdhocStopwatch() {
    std::cout << std::chrono::duration_cast<std::chrono::microseconds>(clock::now() - started_) << '\n';
  }
 private:
  clock::time_point started_;
};

void push_back(const std::vector<Element> &src) {
  std::vector<Element> dst;
  dst.reserve(src.size());
  AdhocStopwatch asw;
  for (const auto &e : src) {
    dst.push_back(Element{e.Key, e.Value});
  }
}

void emplace_back(const std::vector<Element> &src) {
  std::vector<Element> dst;
  dst.reserve(src.size());
  AdhocStopwatch asw;
  for (const auto &e : src) {
    dst.emplace_back(e.Key, e.Value);
  }
}

int main() {
  const auto size = 10'000UZ;
  std::vector<Element> v{size};
  randomize(v);

  push_back(v);
  emplace_back(v);
}

5 回実行してみた結果は次のとおりです (単位は µs):

1 回目 2 回目 3 回目 4 回目 5 回目 平均
insert (push_back) 版 2,120 3,193 2,315 2,092 2,625 2,469
emplace (emplace_back) 版 1,268 1,826 1,353 1,213 1,573 1,447

Wandbox 上での測定ということもあって多少ばらつきがありますが、おおむね 40% ほど短縮されています。

今回のケースでは要素型 (Element クラス) はムーブ可ですが、仮に要素型がムーブ不可・コピー可だった場合は insert 系はより時間がかかるので、両者の差はさらに広がるはずです。

効率化を目的とした emplace 系への変更は効果ありと言えると思います。


なお、データメンバ Value の長さを std::basic_string の SSO バッファに収まる 15 に変えてみた結果は次のとおりです:

1 回目 2 回目 3 回目 4 回目 5 回目 平均
insert (push_back) 版 2,123 1,662 1,892 1,699 1,602 1,796
emplace (emplace_back) 版 1,259 949 1,108 970 1,008 1,059

おもしろい違いが出るかと期待しましたが、約 40% の短縮という傾向は同様でした。

emplace 系のデメリット

効率の点では確実にメリットがありそうな emplace 系ですが、デメリットはあるでしょうか?
筆者が認識しているのは、次の 2 点です:

  • 引数が縮小変換されてもコンパイルエラーにならない。
  • 引数の評価順序が不定。

どちらも中カッコ (波カッコ) による初期化 (uniform initialization) で解消されたものですが、それが使えません。

まぁ、コンストラクタの引数に縮小変換が発生するような数値型はあまり使わない気がするのと、引数に副作用がある式を渡すのもまずやらないと思うので、自分の場合はそれほどデメリットとは感じません。

参考文献

  1. N2680: Proposed Wording for Placement Insert (Revision 1)
    https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2008/n2680.pdf
  2. N4861: Working Draft, Standard for Programming Language C++
    http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/n4861.pdf
  3. std::vector<T,Allocator>::emplace_back - cppreference.com
    https://en.cppreference.com/w/cpp/container/vector/emplace_back
  4. vector::emplace_back - cpprefjp C++日本語リファレンス
    https://cpprefjp.github.io/reference/vector/vector/emplace_back.html
脚注
  1. 参考文献 [1] の Summary of Motivation より。 ↩︎

  2. const T & または T && ↩︎

  3. 参考文献 [2] の 22.3.11.5 Modifiers [vector.modifiers] (pp.843-844) より。 ↩︎

  4. std::allocator_traits::construct 経由でアロケータの construct が, それがなければ std::construct_at 経由で placement-new が使われるようです。 ↩︎

Discussion