#69559likely_accept
golang.org/x/exp/collection/hashed: Map, a generic hash table with custom hash function and equivalence relation
ステータス変更: active likely_accept
要約
AIによる要約であり、誤りを含む場合があります。
概要
golang.org/x/exp/collection/hashed パッケージに、汎用のハッシュテーブル型 Map[K, V] を追加する提案です。組み込みの map はキーを == でしか比較できませんが、Map ではハッシュ関数とキーの同値関係(どのキー同士を「同じ」とみなすか)を利用者が指定できます。
ステータス変更
active → likely_accept
2024年9月に始まった議論は、Collections ワーキンググループ(umbrella issue #80590)でまとめられました。API は段階的に絞り込まれ、2026年10月7日に @jba が最終版を提示しています。配置先は当初案の標準ライブラリ container/hash から x/exp に移り、SetAll・ContainsAll・DeleteAll は削除されました。名前は At が Get に、旧 Get が Lookup に変わっています。直前の指摘は、ドキュメントにある「set 操作は collection/set パッケージのヘルパーを使え」という記述が古いのではないか(@aclements)という点だけでした。@adonovan もこれを誤記と認めています。これ以上の大きな反対がないことから、@aclements がレビューグループを代表して likely accept を宣言しました。
技術的背景
現状の問題点
組み込みの map[K]V は、K が comparable(== で比較できる型)で、比較方法も == である場合しか使えません。次のようなケースには対応できません。
go/typesのtypes.Type。構造的に同一な型でも別のポインタになるため、==では比較できずtypes.Identicalで比べる必要があります。- 大文字・小文字を区別しない文字列キー
- スライスを含む構造体など、comparable でないキー
これまではx/tools/go/types/typeutil.Mapのような専用実装を作るか、キーを正規化した文字列に変換してmapに入れるしかありませんでした(#69420 が出発点)。
提案された解決策
package hashed // golang.org/x/exp/collection/hashed
func NewMap[K, V any](hasher maphash.Hasher[K]) *Map[K, V]
func (m *Map[K, V]) All() iter.Seq2[K, V]
func (m *Map[K, V]) Clear()
func (m *Map[K, V]) Clone() *Map[K, V]
func (m *Map[K, V]) Contains(key K) bool
func (m *Map[K, V]) Delete(key K) (prev V, deleted bool)
func (m *Map[K, V]) DeleteFunc(del func(K, V) bool) bool
func (m *Map[K, V]) Get(key K) V
func (m *Map[K, V]) Keys() iter.Seq[K]
func (m *Map[K, V]) Len() int
func (m *Map[K, V]) Lookup(key K) (V, bool)
func (m *Map[K, V]) Set(key K, value V) (prev V, present bool)
func (m *Map[K, V]) String() string
func (m *Map[K, V]) Values() iter.Seq[V]
- ハッシュ関数と同値関係は、#70471 で標準化された
maphash.Hasher[K]インターフェース(Hash(*maphash.Hash, K)とEqual(K, K) bool)で渡します。シードは Map 側が管理するため、ハッシュフラッディング攻撃(同じハッシュ値になるキーを大量に送り込んで性能を落とす DoS 攻撃)への耐性があります。 NewMapで作成する必要があります。ゼロ値や nil ポインタは使えず、コピーにはCloneを使います。- 反復順序は組み込み map と同じくランダムであることが保証されます。反復中の変更も組み込み map と同じ意味論で許されます。一方、並行アクセスは禁止です。
Stringだけは、順序は規定しないものの、決定的な(毎回同じ)順序で出力します。
これによって何ができるようになるか
- 型チェッカーやツールの開発:
types.Typeをキーにした表を、types.Identicalに沿って扱えます。typeutil.Mapを汎用の部品で置き換えられます。 - 独自の同値関係を持つキー: 大文字・小文字を無視する文字列、正規化したパス、浮動小数点の誤差を許容する座標などをキーにできます。
- comparable でないキー: スライスを含む構造体なども、
Hasherを書けばキーとして使えます。
なお、ドキュメントには「通常の comparable 型と==で済むなら組み込み map を使うこと」と明記されています。
コード例
// Before: キーを正規化した文字列に変換して組み込み map に入れる
m := map[string]int{}
m[strings.ToLower("Foo")] = 1
_, ok := m[strings.ToLower("FOO")] // 毎回の変換が必要で、元のキーも失われる
// After: 同値関係を Hasher として定義する
type foldHasher struct{}
func (foldHasher) Hash(h *maphash.Hash, s string) { h.WriteString(strings.ToLower(s)) }
func (foldHasher) Equal(a, b string) bool { return strings.EqualFold(a, b) }
m := hashed.NewMap[string, int](foldHasher{})
m.Set("Foo", 1)
v, ok := m.Lookup("FOO") // v == 1, ok == true
for k, v := range m.All() {
fmt.Println(k, v) // 元のキー "Foo" を保持している
}
議論のハイライト
- 第3の型パラメータ H の却下: 当初は
Map[K, V, H Hasher[K]]として静的に呼び出す案がありました。目的は、152バイトのmaphash.Hashがエスケープして毎回ヒープ確保されるのを避けることです。しかし、Go のジェネリクス実装(stenciling)ではこの方法でも解決しませんでした。さらに、実装の詳細が型に漏れて C++ の allocator 引数のように煩雑になる、という理由で却下されました。sync.PoolでHashを再利用すれば、型特化した実装との性能差は10%以内に収まることも確認されています。 - Hasher をゼロ値にしない理由: @cherrymui が、Hasher が初期化時に状態を持つべきケースを示しました。そのため Hasher は値としてコンストラクタに渡す設計になり、Map のゼロ値は無効になりました。
- メソッド名: @benhoyt らの提案に従い、
reflect.StructTagのGet/Lookupやos.Getenv/LookupEnvにならって、ゼロ値を返すGetと ok も返すLookupのペアにしました。 - ヘルパーの削減:
SetAll/ContainsAll/DeleteAllは普通の for ループで代替できるという指摘を受けて削除されました。NewMapのサイズヒントも「たいてい間違っている」という理由で採用されていません。Grow(n)メソッドの追加案(#81698 との整合)は提案されましたが、未解決です。 - JSON 対応:
json.Marshalerを実装するか、reflect.DeepEqualにどう対応するかも議論されました。結論としては、今回のスコープには含まれていません。