このノートでは C++17 を扱います。パッチ的な更新だった C++14 とは異なり、C++17 には日々のコードの書き方を実質的に変える機能がいくつか含まれています。
構造化束縛
集成体、pair、tuple、配列のメンバを一度に名前付き変数へ展開します。
C++std::map<std::string, int> scores;
// C++14
for (const auto& kv : scores)
std::cout << kv.first << ": " << kv.second << '\n';
// C++17
for (const auto& [name, score] : scores)
std::cout << name << ": " << score << '\n';
first や second のような意味のない名前をようやく消せます。複数の値を返す関数でも同様に働きます。
C++auto [it, inserted] = scores.insert({"tony", 100});
if (inserted) { /* ... */ }
注意点が二つ。束縛の数はメンバ数と完全に一致していなければならず、一部だけを束縛することはできません。また構造化束縛が導入する名前はラムダでキャプチャできません(C++20 で緩和されました)。
初期化文つきの if / switch
C++if (auto it = cache.find(key); it != cache.end()) {
use(it->second);
}
// ここでは it は既にスコープ外
変数のスコープを実際に必要な範囲まで絞り込みます。for の初期化文と同じ発想です。「取得してから確認する」形——検索、ロック、ファイルのオープン——で特に読みやすくなります。
if constexpr
テンプレート内のコンパイル時分岐が、SFINAE やタグディスパッチなしで書けるようになりました。
C++template <typename T>
void print(const T& value) {
if constexpr (std::is_pointer_v<T>) {
std::cout << *value;
} else {
std::cout << value;
}
}
本質は、選ばれなかった枝が実体化されないことです。上のコードでは T がポインタでないとき *value は妥当である必要すらありません。通常の if では両方の枝がコンパイルを通る必要があるため、これはできません。
この機能ひとつでテンプレートメタプログラミングの定型句が大量に消えます。以前は二つのオーバーロードと std::enable_if が必要だったことが、いまは一つの関数内の一つの if です。
std::optional
番兵値や出力引数を使わずに「値がないかもしれない」を表現します。
C++std::optional<Config> loadConfig(const std::string& path);
if (auto cfg = loadConfig("app.toml")) {
apply(*cfg);
} else {
useDefaults();
}
-1 や nullptr を返す、あるいは bool tryGet(T& out) とするのに比べ、optional は「失敗しうる」ことを型に書き込みます。呼び出し側はそれを見落とせません。
ヒープ確保は行いません。値は optional 自身の内部に保持されるため、sizeof(std::optional<int>) は通常 8 です(int 4 バイト、フラグ 1 バイト、パディング)。
*opt と opt.value() の違いに注意してください。前者は空のとき未定義動作、後者は std::bad_optional_access を投げます。
std::variant と std::visit
型安全な共用体です。C++ #2 で扱った C 流の union と違い、variant は現在どの型を保持しているかを知っています。
C++using Value = std::variant<int, double, std::string>;
Value v = 3.14;
std::visit([](const auto& x) { std::cout << x << '\n'; }, v);
std::visit は現在保持している型に対応するハンドラへディスパッチします。visitor がすべての型を網羅していなければ、コンパイルが通りません。これこそが仮想継承の階層に対する利点です。閉じた型集合、ヒープ確保なし、vtable なし、そして新しい型を追加したときコンパイラが更新すべき箇所をすべて指し示してくれます。
よく使われる補助テンプレートと組み合わせれば、分岐の形で書けます。
C++template <class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template <class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
std::visit(overloaded{
[](int i) { std::cout << "int " << i; },
[](double d) { std::cout << "double " << d; },
[](const std::string& s) { std::cout << "string " << s; },
}, v);
std::string_view
所有権を持たない文字列ビュー。ポインタ一つと長さ一つです。
C++void log(std::string_view msg); // const char*、std::string、リテラルをすべてゼロコピーで受ける
log("literal"); // std::string は構築されない
log(someStdString); // コピーなし
log(std::string_view(buf, len)); // 部分文字列も無償
これ以前は、const std::string& を取る関数にリテラルを渡すと一時 string が構築され、ヒープ確保を伴うこともありました。string_view にすればそのコストは消えます。読み取り専用の文字列引数には、string_view を既定の選択にすべきです。
代償は寿命の管理が完全に自分の責任になることです。string_view は指している先の寿命を延ばしません。
C++std::string_view bad() {
std::string s = "temp";
return s; // ダングリング:s は return 後に消える
}
同じ理由で、指しているデータのほうが長生きすると確信できないかぎり、string_view をメンバ変数に保存してはいけません。
そのほか知っておきたいもの
クラステンプレート引数推論(CTAD)。 構築時にテンプレート引数を書かずに済みます。
C++std::pair p(1, 2.0); // std::pair<int, double> と推論
std::lock_guard lk(mutex); // std::lock_guard<std::mutex> と書かなくてよい
std::vector v{1, 2, 3}; // std::vector<int>
インライン変数。 ヘッダでグローバル変数を定義でき、.cpp 側の定義行が不要になります。
C++inline constexpr int kMaxUsers = 1024;
struct Config { inline static int instances = 0; };
畳み込み式。 可変長テンプレートを再帰展開なしで書けます。
C++template <typename... Args>
auto sum(Args... args) { return (args + ...); } // (a1 + (a2 + (a3 + ...)))
std::filesystem。 パス、ディレクトリ走査、ファイル状態が標準ライブラリに入り、プラットフォームごとに書き分ける必要がなくなりました。
入れ子名前空間。 三重の入れ子の代わりに namespace a::b::c { }。
[[nodiscard]]。 戻り値が無視されたときに警告します。エラーコードや optional を返す関数で有用です。
C++[[nodiscard]] bool tryConnect();
tryConnect(); // 警告:戻り値が破棄されている
保証されたコピー省略。 関数から prvalue を返すとき、規格はコピーもムーブも起きないことを要求するようになりました。つまりコピー不可・ムーブ不可の型もファクトリ関数から返せます。
まとめ
C++17 から実際に採用する機能を三つ選ぶなら、構造化束縛(可読性)、if constexpr(テンプレートの定型句の排除)、string_view(暗黙の文字列コピーの排除)です。この三つだけで、C++11 のコードベースは数年ぶんは若返って読めるようになります。