std::minmax は参照を返すので右辺値を渡してはいけない

ABC353 終了後にTLに流れてきた内容がためになったのでメモ。

罠について

以下のコードの実行結果について考えます。

#include <iostream>
#include <algorithm>
int main() {
    {
        int a = 1, b = 2;
        auto [mn, mx] = std::minmax(a, b);
        std::cout << mn << ", " << mx << std::endl;
    }
    {
        auto [mn, mx] = std::minmax(1, 2);
        std::cout << mn << ", " << mx << std::endl;
    }
    {
        int a = 1, b = 2;
        auto [mn, mx] = std::minmax(a + 0, b + 0);
        std::cout << mn << ", " << mx << std::endl;
    }
    return 0;
}

AtCoder のコードテストで実行すると以下のようになります。(言語は C++ 23 (gcc 12.2) を選択)

1, 2
0, 0
0, 0

3行とも 1, 2 が出力されると嬉しいですが、後半2つの実行結果が 0, 0 になってしまいました。 (なお、実行環境によっては3行とも 1, 2 となる場合もあります。)

原因

はじめに std::minmax についてですが、2つの値を渡した際には最小値と最大値への参照を、 initializer_list を渡した際には最小値と最大値を返す仕様になっています。

namespace std {
  template <class T>
  pair<const T&, const T&> minmax(const T& a, const T& b);

  template <class T>
  pair<T, T> minmax(initializer_list<T> t);
}

この参照を返すというのが原因となっています。

後半2つのケースについては、12 などの一時的な値や、a + 0b + 0 などの計算途中の値を関数に渡しています。一般にこれらを右辺値と言います。 また、std::minmax を含む式の評価が終わった時点で、参照の寿命が切れてしまいます。

したがって mnmx といった変数がすでに消えた右辺値を参照していることになり、未定義動作が発生するためにこのようなことが発生します。

なお、左辺値と右辺値について大雑把に説明すると、左辺値は変数に格納された値であり、右辺値は一時的な値であると考えれば良い気がします。

以下の記事が参考になります。

こちらは具体例も多くわかりやすいです。

対策

  • std::minmaxinitializer_list を渡す

以下のように std::minmax({}) とすれば引数として initializer_list が与えられるので大丈夫です。

#include <iostream>
#include <algorithm>
int main() {
    {
        int a = 1, b = 2;
        auto [mn, mx] = std::minmax({a, b});
        std::cout << mn << ", " << mx << std::endl;
    }
    {
        auto [mn, mx] = std::minmax({1, 2});
        std::cout << mn << ", " << mx << std::endl;
    }
    {
        int a = 1, b = 2;
        auto [mn, mx] = std::minmax({a + 0, b + 0});
        std::cout << mn << ", " << mx << std::endl;
    }
    return 0;
}
1, 2
1, 2
1, 2
  • コンパイルオプションに -fsanitize=undefined,address -g を付ける

実行時にエラーが出るので気づけると思います。

余談

ちなみに std::minmax だけではなく std::minstd::max についても、 2つの値を渡した際には最小値と最大値への参照を、 initializer_list を渡した際には最小値と最大値を返す仕様になっています。

ですがこれらは返り値が1つであり、mx = std::max(a, b) によって mx にコピーが行われてから引数 ab の寿命が切れるため、std::minmax のような罠を踏むことはありません。(このあたりは若干怪しいですが、構造化束縛の場合は参照を受け取っているだけで中身のコピーは発生していないと理解しています。)

感想

AddressSanitizer はやはり偉大。

参考文献

のいみさんのツイート