オプションをコンテナに入れる必要がありますか?

タイトルがすべてを物語っています:std::optional<T> を入力する必要があります コンテナに?

その答えを得るには、まず少し回り道をしなければなりません。

std::optional<T>std::variant<T, std::monostate>

std::optional<T> の違いは何ですか そして std::variant<T, std::monostate> ?

簡単です:

std::optional<T> タイプ T の値を格納するクラスです

std::variant<T, std::monostate> タイプ T の値を格納するクラスです または std::monostate 型の値 .

std::monostate とは ?

std::variant を許可することを主な目的とするクラスです。 タイプの 1 つを保存するか、まったく保存しないかのいずれかです。

つまり、std::variant<T, std::monostate> タイプ T の値を格納するクラスです

したがって:

template <typename T>
using optional = std::variant<T, std::monostate>;

唯一の違いはインターフェイスにあります。

しかし、別の例を見てみましょう:

// the id of something
struct id { … }; // not really empty

// tag type to mark an invalid id
struct invalid_id {}; // really empty

// parses an id giving a str
std::variant<id, invalid_id> parse(std::string_view str);

すべての文字列が有効な ID であるとは限らないため、結果は有効な ID または無効な ID をマークするタグ タイプのいずれかを返します。

さて、std::variant<id, invalid_id> の違いは何ですか と std::variant<id, std::monostate> ?

空の状態の名前。

しかし、私の意見では、空の状態の名前はセマンティクスにとって重要です :std::variant<id, invalid_id> std::variant<id, std::monostate> に対して、無効な ID という特別な空の状態があります。 一般的なものです。

別の空の状態を追加すると、この違いはさらに大きくなる可能性があります:

std::variant<id, invalid_id, empty_string> parse(std::string_view str);

ID を取得するか、文字列が無効か、文字列が空でした。

だから std::variant<T, std::monostate> を使って と std::optional<T> 同じセマンティックな意味を持ちます:オブジェクトがあるか、または何もありません.なぜなら std::optional のほうが優れたインターフェイスを備えているので、代わりにそちらを使用することをお勧めします。

ただし、std::variant<T, std::monostate> には違いがあります。 と std::variant<T, U> どこで U は空の型です:後者は、単に「空の状態」ではなく、空の状態に特別なセマンティックな意味を与えます。

variant を使用することをお勧めします 状態に特別な名前を付けることができる場合、および/またはそれが何を意味するのか明確でない場合は、省略可能ではなく。

std::optional<T> シーケンスコンテナ内

これはコンテナと何の関係があるのでしょうか。

std::vector<std::optional<int>> を考えてみましょう :

std::vector<std::optional<int>> vec;
vec.push_back(42);
vec.push_back(std::nullopt);

これにより、2 つの要素を含むコンテナーが作成されます — 42 そして std::nullopt .

しかし、コンテナに空のオプションを入れるとしたら、なぜそこに入れるのでしょうか?

std::vector<int> vec;
vec.push_back(42);

これにより、1 つの要素 (42) を含むコンテナーが作成されます。 .これは前の例と同じで、使いやすくなっているだけだと思います。したがって、シーケンス コンテナーに空のオプションを入れないでください。代わりに何も入れないでください。

空のオプションがアルゴリズムなどにとって特別な意味を持っていると言う場合は、最初の部分を読んでください:std::optional<T> は必要ありません std::variant<T, special_meaning> が欲しい .

std::optional<T> セットで

同じことが std::set にも当てはまります ただし、空の状態を 1 回しか入れることができないため、ここでは特に愚かです:

std::set<std::optional<int>> set;
set.insert(42);
set.insert(std::nullopt);
set.insert(std::nullopt); // won't insert it again

したがって、std::optional<T> は使用しないでください。 セットのキー タイプとして。

繰り返しますが、「空のキー」が必要な場合は、std::variant<T, empty_key> を選択してください .これにより、複数の空のキーも許可されます (異なるタイプが必要なだけです)。

std::optional<T> マップで

std::map のような地図 オプションを配置できる場所が 2 つあります。キーとして、または値としてです。すでに説明したように、キーとしては意味がありません。

ただし、値が興味深いので:

std::map<int, std::optional<int>> map;
map[42] = 42; // map 42 to 42
map[3] = 5; // map 3 to 5
map[9] = std::nullopt; // map 9 to empty optional

ここで、int をマッピングできます int に 、または int これは、値が関連付けられているキーと関連付けられていないキーのセットをモデル化したい場合に便利です。

しかし、std::optional<T> で設計された地図を考えてみましょう おそらくルックアップ機能があるでしょう:

template <typename Key, typename Value>
std::optional<Value> map<Key, Value>::lookup(const Key& key) const;

しかし、与えられたマップでそれを呼び出すことを考えてみてください:

std::optional<std::optional<int>> result = map.lookup(i);

結果はオプションのオプションの int です 3 つの状態を持つことができます:

<オール>
  • 空のオプション — キーはマップにまったくありません
  • 空のオプションを含むオプション — キーはマップにありますが、関連付けられた値はありません
  • int を含むオプションを含むオプション — キーは、この関連付けられた値を持つマップにあります
  • if (!result)
    {
        // key is not in the map
    }
    else if (!result.value())
    {
        // key is in the map but without value
    }
    else
    {
        // key is in the map with this value
        auto value = result.value().value();
    }
    

    これはちょっと見苦しいです。名前があればいいのですが:

    std::map<int, std::variant<int, no_value>> map;
    
    std::optional<std::variant<int, no_value>> result = map.lookup(42);
    if (!result)
    {
        // key not in the map
    }
    else if (auto value = std::get_if<int>(&result.value()))
    {
        // key has this associated value
    }
    else
    {
        // key doesn't have an associated value
    }
    

    C++ でのバリアントの扱いがひどく醜いという事実を無視すると、これは std::optional<std::optional<int>> よりも読みやすくなります。

    ただし、完璧な解決策は特別な partial_map です。 コンテナ:

    // only some int's are mapped to others
    partial_map<int, int> map;
    
    std::variant<int, no_value, unknown_key> result = map.lookup(42);
    if (std::holds_alternative<unknown_key>(result))
    {
        // key not in the map
    }
    else if (std::holds_alternative<no_value>(result))
    {
        // key doesn't have a value
    }
    else
    {
        // key has this associated value
        auto value = std::get<int>(result);
    }
    

    楽しいメタ プログラミングの演習が必要な場合は、flatten を書いてみてください。 ネストされたオプションを取り、それをバリアントにアンパックする関数:

    std::optional<std::optional<int>> nested_opt;
    std::variant<outer_empty, inner_empty, int> variant = flatten(nested_opt, outer_empty{}, inner_empty{});
    

    投稿の最後に解決策があります。

    std::optional<T> コンテナ — パフォーマンス

    セマンティックと読みやすさの議論は気にしなくても、パフォーマンスの議論は気にするかもしれません。

    std::optional<T> がある場合 コンテナーでは、反復は次のようになります:

    std::vector<std::optional<T>> container;
    
    …
    
    for (auto& el : container)
    {
        if (el)
        {
            // handle element
        }
        else
        {
            // handle no element
        }
    }
    

    ホット ループの可能性がある分岐があります。既存の要素と存在しない要素が特定の順序で並んでいる可能性は低いため、分岐予測子はあまり役に立ちません。

    ここで、存在しない要素を既存の要素に対して正しい順序で処理する必要がある場合は運が悪いですが、それを行う必要がない場合は、これを最適化することができます:

    配列の構造体と同様のことを行う方がよいでしょう:

    std::vector<T> t_container;
    std::vector<std::nullopt> null_container;
    
    …
    
    for (auto& el : container)
    {
        // handle element
    }
    
    for (auto& null : null_container)
    {
        // handle no element
    }
    

    ここにはまったく分岐がありません。さらに、T std::optional<T> より小さい メモリも節約できます。

    std::nullopt を保存するのはばかげていることがわかるかもしれません。 まったく:

    std::vector<T> t_container;
    std::size_t null_container_size;
    
    …
    
    for (auto& el : container)
    {
        // handle element
    }
    
    for (auto i = 0u; i != null_container_size; ++i)
    {
        // handle no element
    }
    

    これは std::vector<std::variant<Ts...>> にも当てはまります 一般に、各バリアントに 1 つずつ、複数のベクトルを検討してください。可能な variant_vector<Ts...> これを自動的に実行する方法は、読者の課題として残しておきます。

    結論

    空のオプションをコンテナーに入れる場合は、代わりに何も入れないでください。これにより、コンテナーの処理が容易になります。

    空の状態に特別な意味がある場合は、std::optional<T> を使用しないでください 、 std::variant<T, special_meaning> を使用 .これにより、コードの推論が容易になります。

    考えられる例外の 1 つは、std::map<Key, std::optional<Value>> です。 一部のキーのみを値にマッピングするためのものです。ただし、より優れた実装の可能性があります。

    付録:flatten()

    flatten() の簡単な実装例を次に示します。 関数。

    まず、型を計算しましょう:

    // helper trait to check whether a type is an optional
    template <typename T>
    struct is_optional : std::false_type {};
    template <typename T>
    struct is_optional<std::optional<T>> : std::true_type {};
    
    // helper trait to convert a `std::variant<...>` to `std::variant<T, ...>`
    template <typename T, class Variant>
    struct append_variant;
    template <typename T, typename ... Types>
    struct append_variant<T, std::variant<std::variant<Types...>>>
    {
        using type = std::variant<T, Types...>;
    };
    
    
    template <class NestedOpt, class ... Empty>
    struct flatten_type_impl;
    
    // base case: optional not further nested
    template <typename T, class ... Empty>
    struct flatten_type_impl<std::enable_if_t<!is_optional<T>{}>, std::optional<T>, Empty...>
    {
        static_assert(sizeof...(Empty) == 1);
    
        // result is the empty type or T
        using type = std::variant<Empty..., T>;
    };
    
    // recursive case: nested optional
    template <class Opt, typename Head, class ... Empty>
    struct flatten_type_impl<std::enable_if_t<is_optional<Opt>{}>, std::optional<Opt>, Head, Empty...>
    {
        // variant for the value of the nested optional
        using recursive_type = typename flatten_type_impl<void, Opt, Empty...>::type;
        // put Head empty type in front
        using type = typename append_variant<Head, recursive_type>::type;
    };
    
    // convenience typedef
    template <class NestedOpt, class ... Empty>
    using flatten_type = typename flatten_type_impl<void, NestedOpt, Empty...>::type;
    

    次に、再帰的に展開して関数を記述します:

    // helper function to recursively fill the variant
    template <class Result, typename T, typename Empty, typename ... Rest>
    void flatten_impl(Result& result, const std::optional<T>& opt, Empty empty, Rest... rest)
    {
        if (opt)
        {
            // optional has a value, store the corresponding inner value
            if constexpr (is_optional<T>{})
                // nested optional, recurse
                flatten_impl(result, opt.value(), rest...);
            else
                // not a nested optional, store value directly
                result = opt.value();
        }
        else
            result = empty;
    }
    
    // actual flatten function
    template <class NestedOpt, class ... Empty>
    auto flatten(const NestedOpt& opt, Empty... empty)
    {
        // create the variant
        // it is always default constructible, as the first type is an empty type
        flatten_type<NestedOpt, Empty...> result;
        // fill it recursively
        flatten_impl(result, opt, empty...);
        return result;
    }