【C++】std::sortの比較関数に「<=」を書くとどうなる?|Debugだけ止まる「invalid comparator」と、Releaseで静かに崩れる並びを実測

【C++】std::sortの比較関数に「<=」を書くとどうなる?|Debugだけ止まる「invalid comparator」と、Releaseで静かに崩れる並びを実測 C++

並べ替えの比較関数を、a <= bと書いても、Releaseで動かすと、普通に並びます。落ちない、遅くならない、警告も出ない。ところが、同じコードをDebugで動かすと、invalid comparatorと言われて、止まります。

どちらが正しいのでしょうか。比べるために、<=の比較関数を、std::sortとstd::setで、DebugとReleaseの両方で動かしました。複数のキーで並べるときの、書き間違いも確かめています。

検証は、Visual Studio 2022付属のMSVC(19.44、x64)で、Release(/O2 /MD)とDebug(/Od /MDd)の両方を動かして行いました。他のコンパイラや、標準ライブラリでは、試していません。

比較関数に求められる約束とは?

std::sortやstd::setに渡す比較関数は、次の約束を守る必要があります。正式には、「狭義の弱順序(strict weak ordering)」と呼ばれる決まりです。

・同じ値どうしを比べたら、falseを返す(comp(a, a)はfalse)

・comp(a, b)がtrueなら、comp(b, a)はfalse(両方がtrueにならない)

<は、この約束を守ります。<=は、同じ値どうしを比べたときtrueを返すので、1つ目の約束を破っています。約束を破った比較関数を渡したときの動作は、標準では「未定義動作」で、何が起きるかは、実装しだいです。

実験①:

要素数を変えて、「全部同じ値」と「0〜9のランダム(重複だらけ)」の2通りのvector<int>を、<=と<で並べました。

▼e1_le.cpp
// 比較関数に < ではなく <= を書いて、std::sort を呼ぶ
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <random>
#include <vector>
#ifdef _DEBUG
#include <crtdbg.h>
static int ReportHook(int, char* message, int*)
{
    std::printf("[Debugの診断] %s\n", message);
    std::fflush(stdout);
    std::exit(77);
    return 1;
}
#endif

int main(int argc, char** argv)
{
    std::setvbuf(stdout, nullptr, _IONBF, 0);
#ifdef _DEBUG
    _CrtSetReportHook(ReportHook);
#endif
    int n = std::atoi(argv[1]);
    bool same = std::strcmp(argv[2], "same") == 0;     // same: 全部同じ値 / dup: 0〜9のランダム(重複だらけ)
    bool le = std::strcmp(argv[3], "le") == 0;         // le: <= / lt: <

    std::mt19937 rng(1);
    std::vector<int> v(n);
    for (int i = 0; i < n; ++i) v[i] = same ? 5 : (int)(rng() % 10);

    if (le)
        std::sort(v.begin(), v.end(), [](int a, int b) { return a <= b; });
    else
        std::sort(v.begin(), v.end(), [](int a, int b) { return a < b; });

    std::printf("n=%d  %s  %s : 最後まで実行できた  整列済み=%s\n", n, same ? "全部同じ値" : "重複だらけ", le ? "<=" : "< ", std::is_sorted(v.begin(), v.end()) ? "はい" : "いいえ");
    return 0;
}
比較要素数・中身Release(/O2)Debug(/Od /MDd)
<=10・32・33・100・1000個(全部同じ値/重複だらけ)全部、最後まで実行でき、正しく整列全部、invalid comparatorで停止
<=100000個(全部同じ値/重複だらけ)最後まで実行でき、正しく整列(実行していない)
<上と同じ全パターン正しく整列正しく整列
Debugの停止は、_CrtSetReportHook で診断メッセージを拾った結果(終了コード 77 は、フック内で付けた値)
[Debugの診断] C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.44.35207\include\xutility(1602) : Assertion failed: invalid comparator

Releaseでは、要素が10個でも100000個でも、<=のまま、正しく整列しました。一方、Debugは、要素が10個のときでも、<=なら停止です。xutility(1602)のAssertion failed: invalid comparatorは、比較関数が約束を破っていることを、Debugの確認機能が見つけたことを示しています。<に直せば、どちらも、正しく動きました。

【重要】Releaseで、壊れる条件を探した結果

「Releaseでも、いつかは壊れるのでは」と考えて、条件を探しました。要素数34〜5033個、値は1〜4種類だけ、というランダムな配列を作り、<=で、3000回並べています。アクセス違反は、SEH(構造化例外)で拾うようにしました。

▼e3_hunt.cpp
// <= の比較関数で、Release でも壊れる条件があるかを、たくさんの乱数で探す
#include <windows.h>
#include <algorithm>
#include <cstdio>
#include <random>
#include <vector>

// SEH(構造化例外)で、アクセス違反を拾う。オブジェクトを持たない関数に分ける
static int sortLE(int* p, int n)
{
    __try
    {
        std::sort(p, p + n, [](int a, int b) { return a <= b; });
        return 0;
    }
    __except (EXCEPTION_EXECUTE_HANDLER)
    {
        return (int)GetExceptionCode();
    }
}

int main()
{
    std::setvbuf(stdout, nullptr, _IONBF, 0);
    std::mt19937 rng(12345);
    const int trials = 3000;
    int crashes = 0, unsorted = 0, ok = 0;
    DWORD firstCode = 0;
    int firstN = 0, firstK = 0;
    for (int t = 0; t < trials; ++t)
    {
        int n = 34 + (int)(rng() % 5000);
        int k = 1 + (int)(rng() % 4);          // 値は 0 〜 k-1 の、k 種類だけ
        std::vector<int> v(n);
        for (int i = 0; i < n; ++i) v[i] = (int)(rng() % k);
        int rc = sortLE(v.data(), n);
        if (rc != 0) { ++crashes; if (!firstCode) { firstCode = rc; firstN = n; firstK = k; } }
        else if (!std::is_sorted(v.begin(), v.end())) ++unsorted;
        else ++ok;
    }
    std::printf("<= で std::sort を %d 回(要素 34〜5033 個・値は 1〜4 種類)\n", trials);
    std::printf("  正しく整列できた : %d 回\n", ok);
    std::printf("  整列できていない : %d 回\n", unsorted);
    std::printf("  落ちた           : %d 回", crashes);
    if (firstCode) std::printf("  (最初の例: n=%d, 値の種類=%d, 例外コード 0x%08X)", firstN, firstK, (unsigned)firstCode);
    std::printf("\n");
    return 0;
}
<= で std::sort を 3000 回(要素 34〜5033 個・値は 1〜4 種類)
  正しく整列できた : 3000 回
  整列できていない : 0 回
  落ちた           : 0 回

結果は、3000回すべて、落ちずに、正しく整列でした。このMSVCのReleaseでは、<=で壊れる条件を、見つけられませんでした。しかし、これは、「<=を書いても安全」という意味では、ありません。標準が、未定義動作と決めているものは、別の実装や、別のバージョンで、範囲の外を読んで落ちることが、あります(今回は、そうした環境で試していないので、確認はできていません)。Releaseで動いたことは、安全の証拠にならない、ということだけが、分かりました。

実験②:setで

並べ替えのsortなら、Releaseで、動いて見えました。では、比較関数の約束に、より強く頼っている、std::setでは、どうでしょうか。std::less_equalを比較に指定したsetに、5を3回入れました。

▼e4_set.cpp
// set の比較に <= (std::less_equal) を使うと、同じ値が見つからなくなる
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <set>
#ifdef _DEBUG
#include <crtdbg.h>
static int ReportHook(int, char* message, int*)
{
    std::printf("[Debugの診断] %s\n", message);
    std::fflush(stdout);
    std::exit(77);
    return 1;
}
#endif

int main()
{
    std::setvbuf(stdout, nullptr, _IONBF, 0);
#ifdef _DEBUG
    _CrtSetReportHook(ReportHook);
#endif
    std::set<int, std::less_equal<int>> s;
    s.insert(5);
    s.insert(5);
    s.insert(5);
    std::printf("5 を3回 insert したあと  : size = %zu (std::set は重複しないはず)\n", s.size());
    std::printf("find(5)                  : %s\n", s.find(5) != s.end() ? "見つかった" : "見つからない");
    std::printf("count(5)                 : %zu\n", s.count(5));
    size_t erased = s.erase(5);
    std::printf("erase(5) で消えた数      : %zu / 残り size = %zu\n", erased, s.size());

    std::set<int> ok;
    ok.insert(5); ok.insert(5); ok.insert(5);
    std::printf("(参考) 普通の set<int>    : size = %zu, find(5) = %s\n", ok.size(), ok.find(5) != ok.end() ? "見つかった" : "見つからない");
    return 0;
}
5 を3回 insert したあと  : size = 3 (std::set は重複しないはず)
find(5)                  : 見つからない
count(5)                 : 0
[Debugの診断] C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.44.35207\include\xutility(1602) : Assertion failed: invalid comparator

Releaseでは、setなのに、5を3回入れると、size = 3になりました。重複しない、というsetの約束が、守られていません。しかも、find(5)は「見つからない」、count(5)は0で、入れたはずの値が、探せません。そのあとのerase(5)は、60秒以上待っても戻らず、強制終了するしかありませんでした。Debugでは、入れた時点で、invalid comparatorで止まっています。

sortでは、静かに通ったReleaseでも、setでは、はっきりと、壊れました。「同じ値でも、trueを返す」比較関数は、同じ値を、「別のもの」として、扱います。setにとっては、一度入れた5が、二度と、5と一致しなくなる、ということです。

実験③:2つのキーで並べるときの、書き間違い

<=を書かなくても、約束が破れる書き方があります。「点数が高い順。同点なら、名前の順」と考えて、2つのキーを||でつないでしまうものです。

▼e2_multikey.cpp
// 「点数が高い順、同点なら名前順」を、間違った書き方と正しい書き方で比べる
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <random>
#include <string>
#include <tuple>
#include <vector>
#ifdef _DEBUG
#include <crtdbg.h>
static int ReportHook(int, char* message, int*)
{
    std::printf("[Debugの診断] %s\n", message);
    std::fflush(stdout);
    std::exit(77);
    return 1;
}
#endif

struct Player { std::string name; int score; };

// 間違い: 「点数が上」または「名前が前」なら、前に置く
static bool buggy(const Player& a, const Player& b) { return a.score > b.score || a.name < b.name; }

// 正しい書き方その1: 違うほうのキーだけで決める
static bool good1(const Player& a, const Player& b)
{
    if (a.score != b.score) return a.score > b.score;
    return a.name < b.name;
}
// 正しい書き方その2: std::tie で、辞書順に比べる(点数は降順なので、左右を入れ替える)
static bool good2(const Player& a, const Player& b) { return std::tie(b.score, a.name) < std::tie(a.score, b.name); }

// 「点数が高い順、同点なら名前順」に、なっているか
static int violations(const std::vector<Player>& v)
{
    int bad = 0;
    for (size_t i = 1; i < v.size(); ++i)
        if (good1(v[i], v[i - 1])) ++bad;
    return bad;
}

int main()
{
    std::setvbuf(stdout, nullptr, _IONBF, 0);
#ifdef _DEBUG
    _CrtSetReportHook(ReportHook);
#endif
    Player a{"zed", 10}, b{"amy", 5};
    std::printf("buggy(a,b) = %d, buggy(b,a) = %d   (a=zed/10点, b=amy/5点)\n", buggy(a, b), buggy(b, a));
    std::printf("good1(a,b) = %d, good1(b,a) = %d\n\n", good1(a, b), good1(b, a));

    std::mt19937 rng(7);
    std::vector<Player> base;
    for (int i = 0; i < 200; ++i)
    {
        char nm[8];
        std::snprintf(nm, sizeof nm, "p%03d", i);
        base.push_back({nm, (int)(rng() % 10)});
    }
    std::shuffle(base.begin(), base.end(), rng);

    auto v1 = base;
    std::sort(v1.begin(), v1.end(), good1);
    std::printf("good1 : 並びの食い違い %3d 件\n", violations(v1));
    auto v2 = base;
    std::sort(v2.begin(), v2.end(), good2);
    std::printf("good2 : 並びの食い違い %3d 件 (good1と同じ並び: %s)\n", violations(v2), (v1.size() == v2.size() && std::equal(v1.begin(), v1.end(), v2.begin(), [](const Player& x, const Player& y) { return x.name == y.name; })) ? "はい" : "いいえ");
    auto v3 = base;
    std::sort(v3.begin(), v3.end(), buggy);
    std::printf("buggy : 並びの食い違い %3d 件\n", violations(v3));
    return 0;
}
buggy(a,b) = 1, buggy(b,a) = 1   (a=zed/10点, b=amy/5点)
good1(a,b) = 1, good1(b,a) = 0

good1 : 並びの食い違い   0 件
good2 : 並びの食い違い   0 件 (good1と同じ並び: はい)
buggy : 並びの食い違い  64 件

1行目の、buggy(a,b) = 1, buggy(b,a) = 1に、注目してください。zed(10点)とamy(5点)を比べると、「zedが先」と「amyが先」が、両方ともtrueになっています。a.score > b.scoreがtrueなので、1つ目の条件で、zedが先になりますが、同時に、名前だけを見る2つ目の条件で、amyも先になるからです。

点数の高い順・同点は名前順に並べたはずの200人の並びの食い違い件数。正しい書き方と間違った書き方の比較
間違った書き方は、Releaseでは落ちも警告もしないまま、200人中64か所で、並びが崩れた

200人を、乱数で並べ替えて、できた並びを調べました。正しい2通りの書き方では、並びの食い違いは0件、間違いの書き方では、64件でした。落ちも、警告も出ずに、結果だけが、静かに崩れます。Debugでは、このときも、invalid comparatorで止まりました。

直し方:

▼直し方
// 1つのキー: 昇順は <、降順は >(<= や >= は使わない)
std::sort(v.begin(), v.end(), [](int a, int b) { return a < b; });

// 複数のキー(その1): 違うキーが見つかった時点で、そのキーだけで決める
bool good1(const Player& a, const Player& b)
{
    if (a.score != b.score) return a.score > b.score;   // 点数の高い順
    return a.name < b.name;                              // 同点なら、名前順
}

// 複数のキー(その2): std::tie で、タプルの辞書順比較にまかせる
// (降順にしたいキーは、左右を入れ替える)
bool good2(const Player& a, const Player& b)
{
    return std::tie(b.score, a.name) < std::tie(a.score, b.name);
}

実験③のとおり、どちらの書き方でも、並びの食い違いは0件でした。「同じとき」は、falseを返すと考えるのが、いちばん、間違えにくい覚え方です。

よくある失敗例と対処法

①「同じ値のときも、並べたい」と思って、

同じ値の順番は、そもそも、std::sortが保証しないものです。<=にしても、同じ値の順番は、そろいません。順番を保ちたいなら、std::stable_sortを使います。

②降順にしたくて、!(a < b)と書く

!(a < b)は、a >= bと同じ意味で、<=と同じ約束違反です。降順は、a > bと書きます。

③複数キーの比較を、||でつなぐ

実験③のとおり、a.x < b.x || a.y < b.yは、約束を破ります。違うキーが見つかった時点で、そのキーだけで決める形(ifで分ける)か、std::tieを使います。

④Releaseで動いたので、そのままにする

実験①②のとおり、Releaseでは、静かに通ります。Debugで、invalid comparatorが出ないかを、確かめてから、Releaseに進みます。

注意点

  • 比較関数は、同じ値どうしでfalseを返す必要がある。<=・>=は、この約束を破る
  • このMSVCでは、<=でも、Releaseのstd::sortは、3000回、落ちずに、正しく整列した。Debugは、要素10個でもinvalid comparatorで停止した
  • std::setにstd::less_equalを使うと、Releaseでもsize = 3・findで見つからない・eraseが戻らない、という結果になった
  • 複数キーを||でつなぐと、「aが先」と「bが先」が、両方trueになる。Releaseでは、200人中64か所で、並びが崩れた
  • 今回の検証は、MSVC 19.44(Visual Studio 2022付属、x64)だけで行った。他のコンパイラ・標準ライブラリで、同じ結果になるとは、限らない

この記事のポイント

  • 比較関数は、<の形で書く。同じ値のときは、false
  • Debugのinvalid comparatorは、Releaseでは見えない、約束違反の警告
  • 複数キーは、ifで「違うほう」だけを見るか、std::tieを使う
  • setやmapの比較関数が壊れると、入れた値が、見つからなくなる

Debugが止まって、Releaseが通るとき、先に疑うのは、Debugの方ではなく、自分のコードです。動いている側のほうが、約束を破ったまま、通り抜けているだけ、ということが、この実験でも、確かめられました。

あわせて読みたい