The Go Blog
新しいユニークなパッケージ
Go 1.23 の標準ライブラリには、新しい unique パッケージが含まれるようになりました。このパッケージの目的は、比較可能な値の正規化を可能にすることです。つまり、このパッケージは、値を重複排除し、単一の正規のユニークなコピーを指すようにしながら、内部で正規コピーを効率的に管理します。この概念は、「インターニング」と呼ばれ、すでにご存知かもしれません。どのように機能し、なぜそれが有用なのか、詳しく見ていきましょう。
インターニングの単純な実装
高レベルでは、インターニングは非常に単純です。下のコード例をご覧ください。これは、通常のマップだけを使用して文字列を重複排除します。
var internPool map[string]string
// Intern returns a string that is equal to s but that may share storage with
// a string previously passed to Intern.
func Intern(s string) string {
pooled, ok := internPool[s]
if !ok {
// Clone the string in case it's part of some much bigger string.
// This should be rare, if interning is being used well.
pooled = strings.Clone(s)
internPool[pooled] = pooled
}
return pooled
}
これは、テキスト形式を解析する際など、重複する可能性のある多数の文字列を構築する場合に役立ちます。
この実装は非常にシンプルで、一部のケースでは十分に機能しますが、いくつかの問題があります。
- プールから文字列を削除しない。
- 複数のゴルーチンで同時に安全に使用できない。
- 考え方自体は非常に一般的であるにもかかわらず、文字列でのみ機能する。
また、この実装には見過ごされている機会があり、それは微妙です。内部的には、文字列はポインタと長さを組み合わせた不変の構造体です。2つの文字列を比較するとき、ポインタが等しくない場合、等価性を判断するためにその内容を比較する必要があります。しかし、2つの文字列が正規化されているとわかっている場合、ポインタをチェックするだけで十分です。
unique パッケージの登場
新しい unique パッケージは、Intern に似た Make という関数を導入します。
これは Intern とほぼ同じように機能します。内部的にもグローバルマップ(高速なジェネリック並行マップ)があり、Make はそのマップで提供された値を検索します。しかし、Intern とは2つの重要な点で異なります。第一に、あらゆる比較可能な型の値を受け入れます。第二に、ラッパー値である Handle[T] を返し、そこから正規値を取得できます。
この Handle[T] が設計の鍵です。Handle[T] は、作成に使用された値が等しい場合に限り、2つの Handle[T] 値が等しいというプロパティを持っています。さらに、2つの Handle[T] 値の比較は安価です。ポインタ比較に帰着するからです。長い2つの文字列を比較するのに比べて、一桁安価です!
今のところ、これは通常のGoコードではできないことではありません。
しかし、Handle[T] には2つ目の目的もあります。値に対して Handle[T] が存在する限り、マップはその値の正規コピーを保持します。特定の値にマップされるすべての Handle[T] 値がなくなると、パッケージはその内部マップエントリを削除可能としてマークし、近い将来に回収されます。これにより、マップからエントリを削除する明確なポリシーが設定されます。正規エントリが使用されなくなると、ガベージコレクタはそれらを自由にクリーンアップできます。
以前にLispを使用したことがある方なら、このすべてが非常に馴染み深く聞こえるかもしれません。Lispのシンボルはインターンされた文字列ですが、文字列自体ではなく、すべてのシンボルの文字列値は同じプールにあることが保証されています。シンボルと文字列の関係は、Handle[string] と string の関係に似ています。
現実世界の例
では、unique.Make はどのように使用されるのでしょうか?標準ライブラリの net/netip パッケージをご覧ください。これは、netip.Addr 構造体の一部である addrDetail 型の値をインターンします。
以下は、unique を使用する net/netip の実際のコードを短縮したものです。
// Addr represents an IPv4 or IPv6 address (with or without a scoped
// addressing zone), similar to net.IP or net.IPAddr.
type Addr struct {
// Other irrelevant unexported fields...
// Details about the address, wrapped up together and canonicalized.
z unique.Handle[addrDetail]
}
// addrDetail indicates whether the address is IPv4 or IPv6, and if IPv6,
// specifies the zone name for the address.
type addrDetail struct {
isV6 bool // IPv4 is false, IPv6 is true.
zoneV6 string // May be != "" if IsV6 is true.
}
var z6noz = unique.Make(addrDetail{isV6: true})
// WithZone returns an IP that's the same as ip but with the provided
// zone. If zone is empty, the zone is removed. If ip is an IPv4
// address, WithZone is a no-op and returns ip unchanged.
func (ip Addr) WithZone(zone string) Addr {
if !ip.Is6() {
return ip
}
if zone == "" {
ip.z = z6noz
return ip
}
ip.z = unique.Make(addrDetail{isV6: true, zoneV6: zone})
return ip
}
多くのIPアドレスは同じゾーンを使用する可能性があり、このゾーンはそのIDの一部であるため、それらを正規化することは非常に理にかなっています。ゾーンの重複排除により、各 netip.Addr の平均メモリフットプリントが削減され、それらが正規化されているという事実により、ゾーン名の比較が単純なポインタ比較になるため、netip.Addr 値の比較がより効率的になります。
文字列のインターニングに関する補足
unique パッケージは有用ですが、Make は文字列の Intern とはかなり異なります。なぜなら、内部マップから文字列が削除されるのを防ぐために Handle[T] が必要だからです。これは、ハンドルと文字列の両方を保持するようにコードを変更する必要があることを意味します。
しかし、文字列は特殊です。値のように振る舞いますが、先に述べたように、実際には内部にポインタを含んでいます。これは、文字列の基になるストレージのみを正規化し、Handle[T] の詳細を文字列自体の中に隠すことができる可能性があることを意味します。したがって、将来的に、Handle[T] 型なしで文字列をインターンできる、私が「透過的な文字列インターニング」と呼ぶものの場所がまだあります。これは Intern 関数に似ていますが、Make に近いセマンティクスを持っています。
その間、unique.Make("my string").Value() が1つの可能な回避策です。ハンドルを保持しないと、文字列が unique の内部マップから削除されることになりますが、マップのエントリはすぐに削除されません。実際には、次のガベージコレクションが完了するまでエントリは削除されないため、この回避策はコレクション間の期間にある程度の重複排除を可能にします。
歴史と未来への展望
実のところ、net/netip パッケージは導入当初からゾーン文字列をインターンしていました。使用していたインターニングパッケージは、go4.org/intern パッケージの内部コピーでした。unique パッケージと同様に、Value 型(ジェネリックス以前の Handle[T] に非常によく似ています)を持ち、そのハンドルが参照されなくなると内部マップのエントリが削除されるという顕著なプロパティを持っています。
しかし、この動作を実現するには、いくつかの安全でないことを行う必要があります。特に、ランタイム外で弱いポインタを実装するために、ガベージコレクタの動作についていくつかの仮定を置いていました。弱いポインタとは、ガベージコレクタが変数を回収するのを妨げないポインタです。これが起こると、ポインタは自動的にnilになります。たまたま、弱いポインタは unique パッケージの基盤となるコア抽象化でもあります。
その通りです。unique パッケージを実装する際に、ガベージコレクタに適切な弱いポインタのサポートを追加しました。そして、弱いポインタに付随する残念な設計上の決定(例えば、弱いポインタはオブジェクトの復活を追跡すべきか?いや!)という地雷原を踏み越えた後、そのすべてがいかにシンプルで簡単であるかに驚きました。十分に驚いたので、弱いポインタは現在公開提案となっています。
この作業は、ファイナライザーを再検討することにもつながり、より使いやすく効率的なファイナライザーの代替案に関する別の提案をもたらしました。比較可能な値のハッシュ関数も進行中であり、Goでメモリ効率の高いキャッシュを構築する未来は明るいです!
次の記事:Go 1.23以降のテレメトリー
前の記事:関数型を反復処理する
ブログインデックス