std::optionalとの比較演算子に潜む見落としがちな罠
C++ Advent Calender 2022
この記事はC++ Advent Calender 2022 21日目の記事です。・・・もうクリスマス終わってしまったorz
<<16日目 | 本当のコンパイル時variant || 22日目 | C++にモナドはいらない >>
はじめに
C++17でstd::optional
が追加されてからもう5年も立ちました。皆さん使っていますか?
std::optional<int> hoge()
{
int ret = /*somehing*/;
if (/*なんかの条件*/) return {};
return ret;
}
int main()
{
const auto ret = hoge();
if (!ret) return 1;
std::cout << *ret << std::endl;
}
雑に言えばこんな感じに無効値を持てるクラスでした。
上の例で言えば、return {}
する場合、std::optional
クラスのデフォルトコンストラクタが呼び出されるために無効値として初期化されたstd::optional
クラスが関数の戻り値となり、return ret;
する場合は変数ret
に入っていた値を持つstd::optional
クラスが関数の戻り値となるのでした。
比較演算子
std::optional
クラスは比較演算子を持ちます。
ということは自分でoperator*
で中の値を取ってから比較しなくても良さそう、そんなふうに思いますよね?
作り込んだバグ
#include <optional>
#include <chrono>
#include <string>
#include <ranges>
#include <vector>
#include <regex>
#include <iostream>
namespace ch = std::chrono;
namespace views = std::ranges::views;
std::optional<ch::sys_days> to_date(const std::string& s) noexcept
{
try {
static const std::regex reg(R"A((\d{4})(\d{2})(\d{2}))A");
std::smatch matched;
if (!std::regex_search(s, matched, reg) || matched.size() != 4) return std::nullopt;
const ch::year_month_day ymd(ch::year{ std::stoi(matched[1].str()) }, ch::month(std::stoi(matched[2].str())), ch::day(std::stoi(matched[3].str())));
return ymd;
}
catch (const std::exception&) {
return std::nullopt;
}
}
const auto filter_by_date = [](const std::string& s) noexcept -> bool
{
using namespace std::chrono_literals;
constexpr ch::sys_days threshold = 2022y/12/01;
const auto date = to_date(s);
return date <= threshold;
};
int main()
{
std::vector<std::string> inputs = { "20221124", "20221201", "20221202", "20221205", "20221A05" };
for (const auto& input : inputs | views::filter(filter_by_date)) {
std::cout << input << std::endl;
}
}
to_date
は文字列をstd::chrono::sys_days
に変換することを試みて、そのoptionalを返す関数です。
filter_by_date
の中でstd::optional<std::chrono::sys_days>
型の変数date
とstd::chrono::sys_days
型の定数threshold
を比較するoperator<=
を呼び出しています。
ではどこが問題なのでしょうか?
問題点
上記のプログラムを実行すると出力は次のようになります
20221124
20221201
20221A05
20221A05
は(\d{4})(\d{2})(\d{2})
という正規表現にマッチしないはずの入力です。にも関わらずなぜ出力されてきているのでしょうか。
ここで改めてcpprefjpを見てみます。
template <class T, class U>
constexpr bool operator<=(const optional<T>& x, const U& y); // (2) C++17
return x.has_value() ? x.value() <= v : true;
を返すとあります。
20221A05
が入力として与えられたとき、to_date
での変換は失敗するのでstd::nullopt
を返します。
ところが、値を持たないoptionalとその内部型との比較演算子operator<=
は、true
を返してしまうのです。
修正方法
const auto filter_by_date = [](const std::string& s) noexcept -> bool
{
using namespace std::chrono_literals;
constexpr ch::sys_days threshold = 2022y/12/01;
const auto date = to_date(s);
// std::nulloptとの比較はtrueになるため弾く
return date && date <= threshold;
};
予めoptionalが値を持つか検査すればいいのでこのようになります(explicit operator bool
の呼び出し)。