Goの型とランタイムをちょっとだけ深掘り
Goの型システム - structとinterfaceの違い
structとinterfaceのサイズ比較
まず、空の構造体と空のインターフェースのサイズを比較してみましょう。
package main
import (
"fmt"
"unsafe"
)
func main() {
// 空のstructのサイズ
var emptyStruct struct{}
fmt.Printf("空のstructのサイズ: %d bytes\n", unsafe.Sizeof(emptyStruct))
// 空のinterfaceのサイズ
var emptyInterface interface{}
fmt.Printf("空のinterfaceのサイズ: %d bytes\n", unsafe.Sizeof(emptyInterface))
}
出力結果:
空のstructのサイズ: 0 bytes
空のinterfaceのサイズ: 16 bytes
同じ「空」でも、struct{}は0バイト、interface{}は16バイトと大きな違いがあります。この違いはなぜ生まれるのでしょうか?
なぜinterfaceは16バイト必要なのか
Goのインターフェースは内部的にeface(empty interface)という構造体で表現されます。
type eface struct {
_type *_type // 型情報へのポインタ
data unsafe.Pointer // データへのポインタ
}
-
_typeポインタ: 格納されている値の型情報を指します -
dataポインタ: 実際の値が格納されているメモリ領域を指します
この2つのポインタにより、Goは実行時に型情報を保持しながら、任意の型の値を格納できるようになっています。つまり、interfaceはtype(型)とvalue(値)を持つのです。
一方、struct{}は0バイトです。空の構造体には何も保存する必要がないからです。
structの0バイト特性を活かしたシグナル通知
struct{}の0バイト特性を活かした実践的なパターンが、chan struct{}を使ったシグナル通知です。
package main
import (
"fmt"
"time"
)
func main() {
// シグナル通知のみが目的の場合空のstructをチャネルで使う
done := make(chan struct{})
go func() {
fmt.Println("作業中...")
time.Sleep(1 * time.Second)
fmt.Println("完了!")
done <- struct{}{} // 空のstructを送信
}()
<-done // 完了を待つ
fmt.Println("通知を受信")
}
出力結果:
作業中...
完了!
通知を受信
chan boolやchan intでも同じことはできますが、chan struct{}には以下の利点があります:
- メモリ効率: 値を送信してもメモリを消費しない
- 意図の明確化: 「値」ではなく「通知」が目的であることが明確
- 誤用の防止: boolの
true/falseのような余計な意味を持たない
interfaceのtype+valueを活かしたcontextのキー設計
前述のように、interfaceは型と値の両方を持ちます。この特性は、context.Contextに値を保存する際にも活用されます。
context.Contextに値を保存する際、下記のようにstring型を直接使うと、キーが衝突する危険性があります。
ctx = context.WithValue(ctx, "user", "Alice")
user := ctx.Value("user").(string)
推奨される方法は、独自の型を定義してキーとして使うことです。 このあたりはlinterなどでもよく警告されるポイントです。
重要なのは、型が違えば、同じパッケージ内でも衝突しないという点です。
同じパッケージ内で異なる型を定義した例:
package main
import (
"context"
"fmt"
)
// 同じパッケージ内で異なる型を定義
type contextKeyA string
type contextKeyB string
// 両方とも同じ文字列"user"をキーとして使用
const userKeyA contextKeyA = "user"
const userKeyB contextKeyB = "user"
func main() {
ctx := context.Background()
ctx = context.WithValue(ctx, userKeyA, "Alice")
ctx = context.WithValue(ctx, userKeyB, "Bob")
// 型が違うので衝突しない
valueA := ctx.Value(userKeyA)
valueB := ctx.Value(userKeyB)
fmt.Printf("contextKeyA で取得: %v\n", valueA)
fmt.Printf("contextKeyB で取得: %v\n", valueB)
}
出力:
contextKeyA で取得: Alice
contextKeyB で取得: Bob
なぜ衝突しないのか
context.Value()は、内部でキーをinterfaceとして保持しており、比較する際に型と値の両方をチェックします。
そのため、今回の例では値は同じですが型が違うため衝突しません。
別パッケージなら当然衝突しない
型が違えば衝突しないという原則から、別パッケージで定義した型を使えば当然衝突しません。
package pkga
import "context"
// 独自の型を定義
type contextKey string
const userKey contextKey = "user"
func SetUser(ctx context.Context, user string) context.Context {
return context.WithValue(ctx, userKey, user)
}
func GetUser(ctx context.Context) string {
if v := ctx.Value(userKey); v != nil {
return v.(string)
}
return ""
}
別のパッケージでも同様に:
package pkgb
import "context"
// pkgaと同じ名前だが、異なる型
type contextKey string
const userKey contextKey = "user"
func SetUser(ctx context.Context, user string) context.Context {
return context.WithValue(ctx, userKey, user)
}
func GetUser(ctx context.Context) string {
if v := ctx.Value(userKey); v != nil {
return v.(string)
}
return ""
}
両方のパッケージで同じ文字列"user"をキーとして使っても、型が違うため衝突しません。
func main() {
ctx := context.Background()
// 両方のパッケージで同じ文字列"user"をキーとして使用
ctx = pkga.SetUser(ctx, "Alice")
ctx = pkgb.SetUser(ctx, "Bob")
// それぞれ異なる値を取得できる
fmt.Println("pkga.GetUser:", pkga.GetUser(ctx)) // Alice
fmt.Println("pkgb.GetUser:", pkgb.GetUser(ctx)) // Bob
}
出力:
pkga.GetUser: Alice
pkgb.GetUser: Bob
つまり外部のpackageを利用する際には、型の衝突を気にせずにキーを定義できるという利点があります。
スライスの容量拡張アルゴリズム
スライス拡張の基本
Goのスライスは、長さ(length)と容量(capacity)という2つの概念を持っています。
s := make([]int, 3, 10) // 長さ3、容量10
- 長さ: 現在スライスに含まれている要素数
- 容量: 再割り当てなしで追加できる要素の最大数
appendで要素を追加する際、容量が不足すると、Goは内部で以下の処理を行います:
- より大きな新しいメモリ領域を確保
- 既存の要素をコピー
- 新しい要素を追加
この時どれくらいの大きさ領域を確保するかを深掘りします。
Go 1.18で変わったアルゴリズム
Go 1.17以前は、単純なルールでした:
// Go 1.17以前
if cap < 1024 {
newcap = cap * 2 // 1024未満は2倍
} else {
newcap = cap * 1.25 // 1024以上は1.25倍
}
Go 1.18からはメモリ効率とパフォーマンスのバランスを考慮し、より滑らかな拡張アルゴリズムに変更されました。
const threshold = 256
if oldCap < threshold {
return doublecap // 256未満は2倍
}
for {
// 2倍から1.25倍への滑らかな遷移
newcap += (newcap + 3*threshold) >> 2
if uint(newcap) >= uint(newLen) {
break
}
}
この式を展開すると:
newcap += (newcap + 3*256) >> 2
newcap += (newcap + 768) / 4
newcap = oldcap + (oldcap + 768) / 4
newcap = oldcap * 1.25 + 192
この式により、cap=256では2倍に近く、capが大きくなるにつれて滑らかに1.25倍に近づきます。
実際にスライスを拡張させて、拡張率を観察してみましょう。
package main
import "fmt"
func main() {
s := make([]int, 0)
for i := 1; i <= 2000; i++ {
oldCap := cap(s)
s = append(s, i)
newCap := cap(s)
if oldCap != newCap && newCap >= 256 {
ratio := float64(newCap) / float64(oldCap)
fmt.Printf("cap %4d → %4d (%.2fx)\n", oldCap, newCap, ratio)
}
}
}
出力:
cap 128 → 256 (2.00x)
cap 256 → 512 (2.00x)
cap 512 → 848 (1.66x)
cap 848 → 1280 (1.51x)
cap 1280 → 1792 (1.40x)
cap 1792 → 2560 (1.43x)
cap=256では2.00倍ですが、徐々に倍率が下がり、最終的には1.25倍に近づいていることがわかります。
メモリ再割り当ての確認
容量が拡張される際、スライスの内部ポインタが変わることを確認してみましょう。
package main
import "fmt"
func main() {
s := []int{}
for i := 1; i <= 5; i++ {
oldPtr := fmt.Sprintf("%p", s)
oldCap := cap(s)
s = append(s, i)
newPtr := fmt.Sprintf("%p", s)
newCap := cap(s)
fmt.Printf("old cap=%d, new cap=%d, oldptr=%s, new ptr=%s, moved=%v\n", oldCap, newCap, oldPtr, newPtr, oldPtr != newPtr)
}
}
出力:
old cap=0, new cap=1, oldptr=0x102330940, new ptr=0x14000106040, moved=true
old cap=1, new cap=2, oldptr=0x14000106040, new ptr=0x14000106070, moved=true
old cap=2, new cap=4, oldptr=0x14000106070, new ptr=0x1400012a020, moved=true
old cap=4, new cap=4, oldptr=0x1400012a020, new ptr=0x1400012a020, moved=false
old cap=4, new cap=8, oldptr=0x1400012a020, new ptr=0x1400012c040, moved=true
容量が拡張されるたびに、新しいメモリ領域に移動していることがわかります(moved=true)。
事前容量確保によるパフォーマンス改善
スライスの再割り当ては、パフォーマンスに大きな影響を与えます。ベンチマークで比較してみましょう。
package main
import "testing"
const size = 100000
// 事前に容量を確保しない
func BenchmarkAppendWithoutPrealloc(b *testing.B) {
for b.Loop() {
s := []int{}
for j := range size {
s = append(s, j)
}
}
}
// 事前に容量を確保する
func BenchmarkAppendWithPrealloc(b *testing.B) {
for b.Loop() {
s := make([]int, 0, size)
for j := range size {
s = append(s, j)
}
}
}
// 参考: makeで長さも指定してインデックスアクセス
func BenchmarkMakeWithLength(b *testing.B) {
for b.Loop() {
s := make([]int, size)
for j := range size {
s[j] = j
}
}
}
実行結果:
BenchmarkAppendWithoutPrealloc-12 1774 628235 ns/op 4101412 B/op 28 allocs/op
BenchmarkAppendWithPrealloc-12 8930 133892 ns/op 802823 B/op 1 allocs/op
BenchmarkMakeWithLength-12 10110 121580 ns/op 802822 B/op 1 allocs/op
事前に容量を確保することで、劇的なパフォーマンス改善が得られます。
インターフェース実装の静的チェック
Goのインターフェースは暗黙的に実装される
Goのインターフェースは、他の多くの言語とは異なり、暗黙的に実装されます。
type Writer interface {
Write(string) error
}
type MyWriter struct{}
func (m *MyWriter) Write(s string) error {
fmt.Println(s)
return nil
}
JavaやC#のようにimplementsキーワードで明示的に宣言する必要はありません。必要なメソッドを実装していれば、自動的にそのインターフェースを満たします。
確認手段が限られている課題
この暗黙的な実装は便利ですが、型が特定のインターフェースを実装しているかどうかを確認する手段が限られているという課題があります。
この課題を解決するために、Goでは以下のイディオムが広く使われています
var _ Writer = (*MyWriter)(nil)
このイディオムを使うことで、コンパイル時にインターフェース実装を保証できます。
package main
import "fmt"
type Writer interface {
Write(string) error
}
type MyWriter struct{}
func (m *MyWriter) Write(s string) error {
fmt.Println(s)
return nil
}
// コンパイル時チェック
var _ Writer = (*MyWriter)(nil)
func main() {
var w Writer = &MyWriter{}
w.Write("Hello, World!")
}
もしインターフェースの実装を忘れると、コンパイル時にエラーが出ます:
type BrokenWriter struct{}
// Writeメソッドを実装し忘れた
// func (b *BrokenWriter) Write(s string) error { ... }
// コンパイル時にエラーが出る
var _ Writer = (*BrokenWriter)(nil)
コンパイルエラー:
cannot use (*BrokenWriter)(nil) (type *BrokenWriter) as type Writer in assignment:
*BrokenWriter does not implement Writer (missing Write method)
このように、実行前にインターフェース実装の不備を検出できます。
Goの標準ライブラリでの実践例
このパターンは、Go標準ライブラリで広く使われています。
io/io.go より:
var _ ReaderFrom = discard{}
io/multi.go より:
var _ WriterTo = (*multiReader)(nil)
net/lookup.go より:
var _ context.Context = (*onlyValuesCtx)(nil)
おわりに
本記事では、Goの型の実装やランタイムに関するいくつかのTipsを紹介しました。
特にGoのインターフェースが暗黙的に実装される仕組みは、実はGoを利用する際のアーキテクチャの設計において非常に重要な役割を持っています。interfaceの実装を明示する必要のある言語では実現出来ないことなど、深掘りすると長くなるテーマです。また別の機会に解説できればと思います。
Discussion