【C++】mapの[]で「調べただけ」なのに要素が増える原因とは?|存在しないキーを調べたときの動きを実測して、find・at・try_emplaceで直す

【C++】mapの[]で「調べただけ」なのに要素が増える原因とは?|存在しないキーを調べたときの動きを実測して、find・at・try_emplaceで直す C++

if (score["carol"] == 0)と書けば、「carolの点数が0なら」という条件になります。ところが、この1行を実行したあとのmapには、carolという要素が増えています。調べただけのつもりのコードが、データを書き換えている、というのが、mapの[]の動きです。

どんな場面で、何が起きるのかを、mapとunordered_mapで動かして確かめました。調べるだけのときに使う道具(find・at・contains)と、登録するときに使う道具(insert・emplace・try_emplace・insert_or_assign)の違いも、コンストラクタの呼ばれた回数で比べています。

検証は、Visual Studio 2022付属のMSVC(19.44、x64)を、Release(/O2 /MD)で動かして行いました。containsを使う実験だけ、C++20でビルドしています。

mapのoperator[]とは?

mapの[]は、次のように動きます。

・キーが無いときは、値を既定値で作って、要素として追加してから、その値への参照を返す

・キーがあるときは、既存の値への参照を返す

つまり、[]は「調べる」道具ではなく、「無ければ作って返す」道具です。値を読むためだけに使うと、読んだキーが、ぜんぶ登録されます。

実験①:3人を調べたら、要素が4件になった

2人(alice、bob)が入ったmapで、いない人も含めて3人の点数を読みます。「いない人は0点」のつもりで、[]を使いました。

▼e1_bracket.cpp
// operator[] で「読んだだけ」のつもりが、要素が増える
#include <cstdio>
#include <map>
#include <string>
#include <unordered_map>

int main()
{
    std::map<std::string, int> score = {{"alice", 80}, {"bob", 65}};
    std::printf("最初                     : size = %zu\n", score.size());

    // 「いない人は、0点」のつもりで、3人を調べる
    int a = score["carol"];
    int b = score["dave"];
    int c = score["alice"];
    std::printf("score[\"carol\"] = %d, score[\"dave\"] = %d, score[\"alice\"] = %d\n", a, b, c);
    std::printf("[]で3人を調べたあと      : size = %zu\n", score.size());
    for (const auto& kv : score)
        std::printf("  %-6s -> %d\n", kv.first.c_str(), kv.second);

    std::map<std::string, int> score2 = {{"alice", 80}, {"bob", 65}};
    bool hasCarol = score2.find("carol") != score2.end();
    size_t daveCount = score2.count("dave");
    std::printf("find / count で調べたあと: size = %zu (carol: %s, dave: %zu 件)\n", score2.size(), hasCarol ? "いる" : "いない", daveCount);

    std::unordered_map<std::string, int> u = {{"alice", 80}};
    int d = u["erin"];
    std::printf("unordered_map でも       : size = %zu (u[\"erin\"] = %d)\n", u.size(), d);
    return 0;
}
最初                     : size = 2
score["carol"] = 0, score["dave"] = 0, score["alice"] = 80
[]で3人を調べたあと      : size = 4
  alice  -> 80
  bob    -> 65
  carol  -> 0
  dave   -> 0
find / count で調べたあと: size = 2 (carol: いない, dave: 0 件)
unordered_map でも       : size = 2 (u["erin"] = 0)

最初はsize = 2でしたが、[]で3人を調べたあとは、size = 4になりました。aliceは、もともといたので増えず、いなかったcarolとdaveの2人が、点数0で、登録されています。

findとcountで調べたときは、size = 2のまま、増えませんでした。unordered_mapでも、u["erin"]を読んだだけで、要素が作られています(size = 2)。

【重要】実験②:[]で調べたせいで、nullptrが残って、あとで落ちる

値がポインタのmapで、「ドラゴンはいるか」を[]で調べると、さらに困ったことが起きます。

▼e2_ghost.cpp
// [] で調べたせいで、nullptr の「幽霊」が残り、あとで落ちる
#include <cstdio>
#include <cstring>
#include <map>
#include <string>

struct Enemy { std::string name; int hp; };

int main(int argc, char** argv)
{
    std::setvbuf(stdout, nullptr, _IONBF, 0);
    const bool fixed = argc > 1 && std::strcmp(argv[1], "fixed") == 0;

    std::map<std::string, Enemy*> enemies;
    enemies["slime"] = new Enemy{"slime", 10};
    enemies["goblin"] = new Enemy{"goblin", 30};

    // 「ドラゴンはいるか」を調べる
    if (!fixed)
    {
        if (enemies["dragon"] != nullptr)
            std::printf("ドラゴンがいる\n");
    }
    else
    {
        auto it = enemies.find("dragon");
        if (it != enemies.end() && it->second != nullptr)
            std::printf("ドラゴンがいる\n");
    }

    std::printf("登録数 = %zu\n", enemies.size());
    for (const auto& kv : enemies)
        std::printf("%s の HP = %d\n", kv.first.c_str(), kv.second->hp);   // nullptr の要素で落ちる
    return 0;
}
調べ方登録数結果
enemies["dragon"] != nullptr([])3(2体のはずが、増えた)ループの途中で、0xC0000005で落ちた
find("dragon")で、存在を確かめる2goblinとslimeのHPが出て、正常終了
どちらも、同じコードを、引数で切り替えて実行した

[]のほうは、ドラゴンというキーに、nullptrが登録されました。そのあと、全員のHPを表示するループが、nullptrの要素に当たって、kv.second->hpでアクセス違反になっています。(std::mapは、キーの順に並ぶので、dragonが、先頭に来ました。)落ちる場所は、調べた場所ではなく、まったく別の、ループです。そのため、原因を探すのに、時間がかかります。

findで調べたほうは、登録数が2のまま、正常に終わりました。落ちたときのアドレスの読み方は、アクセス違反の記事で扱っています。

実験③:存在しないキーを調べ続けると、mapが太る

[]で調べるたびに、要素が増えるなら、存在しないキーを、たくさん調べると、どうなるでしょうか。map<int, int>で、存在しないキーを100万回調べました。

▼e4_cost.cpp
// 存在しないキーを [] で調べ続けると、map が太る
#include <windows.h>
#include <psapi.h>
#include <chrono>
#include <cstdio>
#include <map>
#include <string>
#pragma comment(lib, "psapi.lib")

static double workingSetMB()
{
    PROCESS_MEMORY_COUNTERS pmc{};
    GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc));
    return pmc.WorkingSetSize / (1024.0 * 1024.0);
}

static volatile int g_sink = 0;

int main()
{
    const int N = 1000000;
    std::map<int, int> byBracket, byFind;
    byBracket[0] = 1; byFind[0] = 1;

    double mem0 = workingSetMB();
    auto t0 = std::chrono::steady_clock::now();
    int s = 0;
    for (int i = 1; i <= N; ++i) s += byBracket[i];      // どれも存在しないキー
    auto t1 = std::chrono::steady_clock::now();
    g_sink = s;
    double mem1 = workingSetMB();
    std::printf("[] で、無いキーを %d 回調べた  : size = %7zu, %7.1f ms, メモリ %+6.1f MB\n", N, byBracket.size(),
                std::chrono::duration<double, std::milli>(t1 - t0).count(), mem1 - mem0);

    auto t2 = std::chrono::steady_clock::now();
    s = 0;
    for (int i = 1; i <= N; ++i) { auto it = byFind.find(i); if (it != byFind.end()) s += it->second; }
    auto t3 = std::chrono::steady_clock::now();
    g_sink = s;
    double mem2 = workingSetMB();
    std::printf("find で、無いキーを %d 回調べた: size = %7zu, %7.1f ms, メモリ %+6.1f MB\n", N, byFind.size(),
                std::chrono::duration<double, std::milli>(t3 - t2).count(), mem2 - mem1);
    return 0;
}
[] で、無いキーを 1000000 回調べた  : size = 1000001,   125.6 ms, メモリ  +46.1 MB
find で、無いキーを 1000000 回調べた: size =       1,     1.0 ms, メモリ   +0.1 MB
mapに存在しないキーを100万回調べたときの、時間と増えたメモリ。[]とfindの比較
[]で調べると、100万個の要素が作られて、時間は約125倍、メモリは約46MB増えた

[]は、100万個の要素を作ってしまい、sizeは1000001。かかった時間は約125.6ミリ秒で、増えたメモリは、約46.1MBでした。findは、要素を作らないので、約1.0ミリ秒、メモリの増加は、ほぼ0です。存在しないキーが、頻繁に来る処理(キャッシュの判定や、ゲームの、ID照会など)では、[]が、メモリを、じわじわ食います。

実験④:constのmapには、[]が使えない

値を読むだけの関数の引数を、constの参照にした場合、[]で、ビルドが通りません。

▼e5_const_bad.cpp
// const の map に、[] は使えない
#include <map>
#include <string>

int lookup(const std::map<std::string, int>& m, const std::string& key)
{
    return m["alice"];     // ビルドが通らない
}

int main()
{
    std::map<std::string, int> m = {{"alice", 80}};
    return lookup(m, "alice");
}
e5_const_bad.cpp(7): error C2678: 二項演算子 '[': 型 'const std::map<std::string,int,std::less<std::string>,std::allocator<std::pair<const std::string,int>>>' の左オペランドを扱う演算子が見つかりません (または変換できません) (新しい動作; ヘルプを参照)。
e5_const_bad.cpp(7): note: 変換で修飾子が失われます。

C2678で、constのmapの[]は、見つからないと言われました。[]は、要素を追加する可能性があるため、constのオブジェクトに対しては、呼べないからです。「読むだけの関数で、[]を書いたら、constが付けられなくなった」場合は、その[]が、本当は、読むだけではなかった、というサインです。

調べるだけの道具:find・contains・at

▼e6_lookup.cpp
// 「調べるだけ」の道具: find / count / contains / at
#include <cstdio>
#include <map>
#include <stdexcept>
#include <string>

int main()
{
    std::map<std::string, int> score = {{"alice", 80}, {"bob", 65}};

    std::printf("contains(\"alice\") = %s / contains(\"carol\") = %s\n",
                score.contains("alice") ? "true" : "false", score.contains("carol") ? "true" : "false");

    auto it = score.find("bob");
    if (it != score.end())
        std::printf("find(\"bob\")       = %d\n", it->second);

    try
    {
        std::printf("at(\"carol\")       = ");
        std::printf("%d\n", score.at("carol"));
    }
    catch (const std::out_of_range& e)
    {
        std::printf("out_of_range 例外 (%s)\n", e.what());
    }
    std::printf("調べたあとの size  = %zu\n", score.size());

    score.try_emplace("carol", 50);     // 無ければ追加
    score.try_emplace("alice", 0);      // あれば、何もしない
    std::printf("try_emplace のあと : alice=%d carol=%d size=%zu\n", score.at("alice"), score.at("carol"), score.size());
    return 0;
}
contains("alice") = true / contains("carol") = false
find("bob")       = 65
at("carol")       = out_of_range 例外 (invalid map<K, T> key)
調べたあとの size  = 2
try_emplace のあと : alice=80 carol=50 size=3
書き方無いキーを調べたとき要素は増えるか
m[key]既定値で要素を作って返す増える
m.find(key)end()が返る(it != m.end()で判定)増えない
m.count(key)0が返る増えない
m.contains(key)(C++20)falseが返る増えない
m.at(key)out_of_range例外(invalid map<K, T> key)増えない

「いるか」だけ知りたいなら、contains(C++17以前ならcount)。「いれば値を使いたい」なら、findです。「必ずいるはず」の場面なら、atにしておくと、いないときに、その場で例外になるので、気づけます。

実験⑤:登録・上書きの道具で、何が違うか

登録する側にも、道具がいくつかあります。値の型に、「既定コンストラクタ」「整数からの構築」「コピー」「ムーブ」「代入」の回数を数えるクラス(Probe)を使い、キーが、無いときと、あるときで、回数を比べました。

▼e3_methods.cpp
// 登録・上書きの方法ごとに、値の型のコンストラクタが何回呼ばれるか
#include <cstdio>
#include <map>
#include <string>
#include <utility>

struct Probe
{
    int v = 0;
    static int dflt, make, copy, move, assign;
    Probe() { ++dflt; }
    Probe(int x) : v(x) { ++make; }
    Probe(const Probe& o) : v(o.v) { ++copy; }
    Probe(Probe&& o) noexcept : v(o.v) { ++move; }
    Probe& operator=(const Probe& o) { v = o.v; ++assign; return *this; }
    Probe& operator=(Probe&& o) noexcept { v = o.v; ++assign; return *this; }
    static void reset() { dflt = make = copy = move = assign = 0; }
};
int Probe::dflt = 0, Probe::make = 0, Probe::copy = 0, Probe::move = 0, Probe::assign = 0;

static void report(const char* label, const std::map<int, Probe>& m, int key)
{
    std::printf("%-32s 既定 %d / 整数から %d / コピー %d / ムーブ %d / 代入 %d  → 値は %d\n", label, Probe::dflt, Probe::make, Probe::copy, Probe::move, Probe::assign, m.at(key).v);
}

int main()
{
    std::printf("【キーが、まだ無いとき】(値 7 を登録)\n");
    {
        std::map<int, Probe> m; Probe::reset();
        m[1] = Probe(7);                    report("m[1] = Probe(7)", m, 1);
    }
    {
        std::map<int, Probe> m; Probe::reset();
        m.insert({1, Probe(7)});            report("m.insert({1, Probe(7)})", m, 1);
    }
    {
        std::map<int, Probe> m; Probe::reset();
        m.emplace(1, 7);                    report("m.emplace(1, 7)", m, 1);
    }
    {
        std::map<int, Probe> m; Probe::reset();
        m.try_emplace(1, 7);                report("m.try_emplace(1, 7)", m, 1);
    }
    {
        std::map<int, Probe> m; Probe::reset();
        m.insert_or_assign(1, Probe(7));    report("m.insert_or_assign(1, Probe(7))", m, 1);
    }

    std::printf("\n【キーが、すでにあるとき】(元の値は 1 / 新しい値 7 を登録しようとする)\n");
    {
        std::map<int, Probe> m; m.emplace(1, 1); Probe::reset();
        m[1] = Probe(7);                    report("m[1] = Probe(7)", m, 1);
    }
    {
        std::map<int, Probe> m; m.emplace(1, 1); Probe::reset();
        m.insert({1, Probe(7)});            report("m.insert({1, Probe(7)})", m, 1);
    }
    {
        std::map<int, Probe> m; m.emplace(1, 1); Probe::reset();
        m.emplace(1, 7);                    report("m.emplace(1, 7)", m, 1);
    }
    {
        std::map<int, Probe> m; m.emplace(1, 1); Probe::reset();
        m.try_emplace(1, 7);                report("m.try_emplace(1, 7)", m, 1);
    }
    {
        std::map<int, Probe> m; m.emplace(1, 1); Probe::reset();
        m.insert_or_assign(1, Probe(7));    report("m.insert_or_assign(1, Probe(7))", m, 1);
    }
    return 0;
}
【キーが、まだ無いとき】(値 7 を登録)
m[1] = Probe(7)                  既定 1 / 整数から 1 / コピー 0 / ムーブ 0 / 代入 1  → 値は 7
m.insert({1, Probe(7)})          既定 0 / 整数から 1 / コピー 0 / ムーブ 2 / 代入 0  → 値は 7
m.emplace(1, 7)                  既定 0 / 整数から 1 / コピー 0 / ムーブ 0 / 代入 0  → 値は 7
m.try_emplace(1, 7)              既定 0 / 整数から 1 / コピー 0 / ムーブ 0 / 代入 0  → 値は 7
m.insert_or_assign(1, Probe(7))  既定 0 / 整数から 1 / コピー 0 / ムーブ 1 / 代入 0  → 値は 7

【キーが、すでにあるとき】(元の値は 1 / 新しい値 7 を登録しようとする)
m[1] = Probe(7)                  既定 0 / 整数から 1 / コピー 0 / ムーブ 0 / 代入 1  → 値は 7
m.insert({1, Probe(7)})          既定 0 / 整数から 1 / コピー 0 / ムーブ 1 / 代入 0  → 値は 1
m.emplace(1, 7)                  既定 0 / 整数から 0 / コピー 0 / ムーブ 0 / 代入 0  → 値は 1
m.try_emplace(1, 7)              既定 0 / 整数から 0 / コピー 0 / ムーブ 0 / 代入 0  → 値は 1
m.insert_or_assign(1, Probe(7))  既定 0 / 整数から 1 / コピー 0 / ムーブ 0 / 代入 1  → 値は 7

結果から、次のことが分かります。

  • m[1] = Probe(7)は、キーが無いとき、先に既定コンストラクタで値を作ってから、代入しました(既定1・代入1)。値の型に、既定コンストラクタが無いと、そもそも使えません
  • insertは、キーがすでにあると、元の値のまま(1)でした。上書きは、されません。しかも、渡したProbe(7)は、無駄に作られています
  • try_emplaceは、キーがあるとき、何も作らず、何もしません(整数からの構築も0回)
  • insert_or_assignは、キーがあれば代入(上書き)、無ければ、追加します。「上書きしたい」ときの、意図が分かりやすい書き方です

emplaceは、このMSVCでは、キーがあるときに、何も作りませんでした。ただし、標準では、キーがあっても、要素を作る場合がある、と決められているので、環境によって、動きが変わる可能性があります。キーがあれば何もしないことを保証したいときは、try_emplaceを使います。

直し方:やりたいことで、道具を選ぶ

やりたいこと使う書き方
いるかどうかだけ、知りたいcontains(C++20)・count
いれば値を使い、いなければ何もしないfind+it != end()
必ずいるはず。いなければバグat
無ければ追加し、あれば何もしないtry_emplace
あってもなくても、この値にしたいinsert_or_assign(またはm[key] = value)
出現回数を数える(++m[key])[](無ければ0から数え始めるのが目的なので、この場面は[]が便利)

よくある失敗例と対処法

①「いるか」を、[]で確かめる

実験①②のとおり、調べたキーが、すべて登録されます。containsかfindにします。

②値がポインタのmapで、[]の結果を、nullptrと比べる

実験②のとおり、nullptrの要素が、残って、別の場所で落ちます。ポインタのmapでは、特に、findを使います。

③constの関数で、[]を使おうとして、constを外す

実験④のとおり、[]が通らないのは、書き換えが入っているサインです。constを外すのではなく、findかatに直します。

④insertで、上書きできると思い込む

実験⑤のとおり、insertは、キーがあると、上書きしません。上書きしたいなら、insert_or_assignか、m[key] = valueです。

注意点

  • []は「調べる」道具ではなく、「無ければ作って返す」道具。調べただけで、要素が増える
  • 2人のmapで、3人を[]で調べたら、sizeが4になった(unordered_mapでも同じ)
  • 値がポインタのmapでは、nullptrが登録されて、別の場所のループで落ちた(0xC0000005)
  • 存在しないキーを100万回調べると、[]は約125倍遅く、メモリが約46MB増えた
  • constのmapでは[]が使えない(C2678)
  • insertは上書きしない。try_emplaceは、キーがあれば何も作らない。insert_or_assignは上書きする
  • 今回の検証は、MSVC 19.44(Visual Studio 2022付属、x64、/O2 /MD)で行った。emplaceの細かい動きは、実装で異なる場合がある

この記事のポイント

  • 調べるだけなら、[]を使わない。contains・find・atを使う
  • []が向いているのは、「無ければ既定値から始めたい」場面。出現回数の集計(++m[key])が、その例
  • 登録は、やりたいことに合わせて、try_emplaceとinsert_or_assignを選ぶ
  • constが付けられない[]は、「書き換えている」というサイン

[]は、短く書けて、便利です。便利な道具ほど、「読んだだけ」に見える行が、裏で何を書き換えているのかを、一度、実行結果で見ておくと、使いどころが、はっきりします。

あわせて読みたい