この記事はC++ Advent Calendar 2021の17日目の記事です。
Heterogeneous Overload?
Heterogeneous Overloadというのは、(非順序)連想コンテナ(C<Key, ...>
)の操作においてKey
と異なる型のオブジェクトをとることのできるオーバーロードのことです。
例えばstd::map::find()
はC++14から次の2つのオーバーロードを持っています(const
は無視します)
iterator find(const key_type& x); // (1) template <class K> iterator find(const K& x); // (2) C++14
ここでkey_type
とはstd::map<Key, Value>
のKey
型であり、2つ目のオーバーロードはKey
と異なる型の任意のオブジェクトを受け取るようになっています。これがHeterogeneous Overloadであり、key
と比較可能な別の型のオブジェクトを直接用いて値を検索することができ、使いやすさが向上するだけでなく、Key
の一時オブジェクトを作る必要がないため効率的です。
ところが、Heterogeneous Overloadを使用するためにはKey
と比較可能な型の値を渡すだけではだめで、連想コンテナならば比較クラスCompare
に、非順序連想コンテナならばそれに加えてハッシュクラスHash
に、is_transparent
メンバ型が定義されていて、Key
と異なる型との直接比較および一貫したハッシュ生成をサポートしている必要があります。。
悲しいことに、互換性のためにデフォルトの比較クラス(std::less<Key>, std::equal_to<Key>
)は比較がテンプレートではないため異なる型との直接比較ができず、ハッシュクラス(std::hash<Key>
)にはis_transparent
メンバ型が定義されていないため、デフォルトではHeterogeneous Overloadは有効になりません。そのため、Heterogeneous Overloadを有効化するためには、自分で要件が満たされるように一工夫しなければなりません・・・
ここでは、一番需要が高くてよく出会いそうなstd::string
をキーとする(非順序)連想コンテナについて見ていきます。
std::map<std::string, T>
#include <iostream> #include <string> #include <unordered_map> #include <map> using namespace std::literals; int main() { std::cout << std::boolalpha; std::map<std::string, int> map = { {"1", 1}, {"2", 2}, {"3", 3}}; std::cout << (map.find("1"sv) != map.end()) << std::endl; // エラー std::cout << (map.find("4") != map.end()) << std::endl; // OK、ただし一時オブジェクトが作成されている }
std::string_view
をそのまま受け取れないのは、std::string_view
から変換するstd::string
のコンストラクタにexplicit
が付加されているためです。静かなパフォーマンス低下を長文コンパイルエラーで教えてくれるのでとても親切だといえるでしょう・・・
std::unordered_map<std::string, T>
#include <iostream> #include <string> #include <unordered_map> #include <map> using namespace std::literals; int main() { std::cout << std::boolalpha; std::map<std::string, int> map = { {"1", 1}, {"2", 2}, {"3", 3}}; std::cout << (map.find("1"sv) != map.end()) << std::endl; // エラー std::cout << (map.find("4") != map.end()) << std::endl; // OK、ただし一時オブジェクトが作成されている }
これもエラーが起きてるのはmap
と同じ理由です。
連想コンテナ
連想コンテナ(std::set, std::map
とそのmulti
バージョン)の場合、問題となるのはデフォルトの比較クラスがテンプレートパラメータに指定された型の比較しかできずis_transparent
を持っていないことだけです。デフォルトの比較クラスstd::less
等はなぜかvoid
に対する特殊化に対してのみis_transparent
が定義される(比較がテンプレートになる)のでそれを使ってもいいですし、C++20からならstd::ranges
にある比較クラスを使ってもいいでしょう。
#include <iostream> #include <string> #include <unordered_map> #include <map> #include <functional> // ranges::lessとかはここにある using namespace std::literals; int main() { std::cout << std::boolalpha; std::map<std::string, int, std::ranges::less> map = { {"1", 1}, {"2", 2}, {"3", 3} }; std::cout << (map.find("1"sv) != map.end()) << std::endl; // OK std::cout << (map.find("4") != map.end()) << std::endl; // OK }
これだけで連想コンテナはHeterogeneous Overloadを有効化できます。当然どちらのケースでもstd::string
の一時オブジェクトは生成されておらず、直接比較されています(はずです)。std::ranges::less
の代わりにstd::less<void>
を使用しても大丈夫です、お好みでどうぞ。
std::ranges::map
みたいな感じでこれを標準で用意してほしいものです。
非順序連想コンテナ
非順序連想コンテナ(std::unorderd_map, std::unorderd_set
とそのmulti
バージョン)の場合、比較クラスに加えてハッシュクラスも問題となります。残念ながらハッシュクラスに関しては自分で定義をするしかありません・・・
#include <iostream> #include <string> #include <unordered_map> #include <map> #include <functional> using namespace std::literals; struct string_hash { using hash_type = std::hash<std::string_view>; using is_transparent = void; std::size_t operator()(std::string_view str) const { return hash_type{}(str); } }; int main() { std::cout << std::boolalpha; std::unordered_map<std::string, int, string_hash, std::ranges::equal_to> map = { {"1", 1}, {"2", 2}, {"3", 3} }; std::cout << (map.find("1"sv) != map.end()) << std::endl; // OK std::cout << (map.find("4") != map.end()) << std::endl; // OK }
少し面倒ですが、std::hash
をそのまま利用してやればハッシュを独自実装する必要はありません。ハッシュクラス(string_hash
)のoperator()
の引数型をstd::string_view
にしておけば、const char*, std::string, std::string_view
の3つを受け取ることができます。
std::string
系の文字列クラスならこうすればいいのですが、他の型の場合はハッシュ共通化が難しい場合もあるかもしれません。そういう場合は自分でハッシュ実装をする必要がありそうです。
const char*, std::string, std::string_view
のハッシュ一貫性保証
ところで、上記のようにconst char*, std::string
のハッシュ値をstd::string_view
から計算することに問題はないのでしょうか?
これは保証されていて、[basic.string.hash]/1によれば
If
S
is one of these string types,SV
is the corresponding string view type, ands
is an object of typeS
, thenhash<S>()(s) == hash<SV>()(SV(s))
.
とあります。
要は、事前定義されている各種std::basic_string
のハッシュ値と、それに対応するstring_view
のハッシュ値は一致すると言っています。
また、[string.view.hash]/1にも
The hash value of a string view object is equal to the hash value of the corresponding string object ([basic.string.hash]).
と、同じことを逆方向から確認しています。const char*
の文字列もstd::string/std::string_view
を経由して考えれば同じ結論となるので、各種文字列のハッシュ値はその型によらず一致することが分かります。
どうやらこれはC++17から規定されたもののようですが、この規定によって変更が必要になるとすればそれは破壊的となるはずなので、おそらく実装がそうなっていたことを規格に反映しただけで、以前から同様の保証は暗黙的に存在していたと思われます。
(非順序)連想コンテナのHeterogeneous Overload
Heterogeneous Overloadは、連想コンテナの検索系の関数(find(), count(), lower_bound(), upper_bound(), equal_range()
)に対してC++14で追加され(N3657)、続いてC++20にて非順序連想コンテナの検索系関数に対しても追加されました(P0919)。
C++23ではさらに、全ての連想コンテナの削除(erase()
)とノードハンドルの取り出し(extract()
)に対してもHeterogeneous Overloadが追加されました(P2077)。
さらに、挿入系の操作(insert(), insert_or_assign(), try_emplace(), bucket()
)についてもHeterogeneous Overloadを追加する提案が進行中で(P2363)、まだ入ってはいませんがC++23を目指しています。これが入るとKey
を指定するタイプの操作のほぼ全てでHeterogeneous Overloadを使用可能となるため、それにアダプトする方法およびstd::string
とstd::string_view
使用時の問題について頭の隅にとどめておく重要性が高まるでしょう。
参考文献
- N3657 Adding heterogeneous comparison lookup to associative containers (rev 4)
- P0919R3 Heterogeneous lookup for unordered containers
- P2077R3 Heterogeneous erasure overloads for associative containers
- P2363R1 Extending associative containers with the remaining heterogeneous overloads
std::unordered_map<Key, T, Hash, KeyEqual, Allocator>::find
- cppreferencestd::hash (std::string, std::wstring, std::u16string, std::u32string, std::u8string, std::pmr::string, std::pmr::wstring, std::pmr::u16string, std::pmr::u32string, std::pmr::u8string)
- cppreference