Day 045 — unsafeパッケージの基礎(概念編: 安全性を捨てる4つの場面と代償)

2026-09-11 🟡 上級者 / Phase 3 概念理解 unsafeパッケージの基礎

📚 背景知識(読んでから問題へ)

Day 043〜044ではreflectパッケージを扱い、「型安全性の保証をコンパイル時から実行時に後退させる」代償を学びました。reflectは代償を払いつつも、メモリ安全性そのものは保ち続けます(CanSet()がfalseのフィールドへの書き込みは、メモリを壊す代わりにpanicします)。今回のテーマunsafeパッケージは、さらに一段踏み込み、メモリ安全性そのものをコンパイラ・ランタイムの保護から外す仕組みです。

Goの通常のポインタ(*T)は必ず特定の型Tに紐づいており、コンパイラは「*intを*stringとして扱う」ような操作を型エラーとして拒否します。この制約のおかげで、Goのプログラムは(データ競合を除けば)メモリ破壊を起こしにくい設計になっています。しかし現実には、この制約の外側でしか実現できない処理が存在します。

  • OSのシステムコール(syscallパッケージ)に、Goの型を持たない生のメモリアドレスを渡す
  • C言語のライブラリとメモリを共有する(cgo)
  • コピーを避けて[]byteとstringを相互変換する(ゼロコピー変換)
  • 構造体のメモリレイアウト(フィールドのオフセット・パディング)を検査する

unsafe.Pointerは、これらの場面のために用意された「型を持たない生ポインタ」です。unsafeというパッケージ名そのものが、「ここから先はコンパイラがあなたを守れません」という明示的な警告になっている点が、Goの設計思想(暗黙の危険より明示的な警告)らしいところです。

📌
reflectとunsafeの根本的な違い: reflectで書き込み不可能な値にSetするとpanicで止まります(プロセスは異常終了しますが、メモリは壊れません)。unsafeの誤用は、panicすら保証されません。プロセスがクラッシュすることもあれば、何のエラーも出さずにメモリが静かに破壊され、無関係な変数の値が書き換わることすらあり得ます。この「安全網の有無」が両者の代償の質的な違いです。

📝 問題

以下の5つの問いに答えてください。(2)と(3)はgo run main.goでそのまま実行できる完全なコードを提示すること。

  1. unsafe.Pointerは通常の*Tと何が違うのか。また、なぜGoの型安全性の原則から見て「特別扱い」されているのかを説明してください。
  2. []byteをコピーせずにstringに変換する(ゼロコピー変換)ユースケースについて、通常のstring(b)変換と比較したときのパフォーマンス上の利点と、それによって破られる不変条件(Goのstringが暗黙に前提としている性質)を説明した上で、unsafe.String・unsafe.SliceData(Go 1.20+)を使った変換関数を実装し、その不変条件が実際に破られる様子をmain関数で実演してください。
  3. unsafe.Sizeofとunsafe.Offsetofを使い、構造体のフィールドの並び順を変えるとメモリサイズがどう変わるか(パディングの発生)を実際に確認するコードを書いてください。
  4. 「unsafe.Pointerをuintptrに変換し、何らかの演算をしてから再びunsafe.Pointerに戻す」というパターンを、複数の文に分けて書くことがなぜ危険なのか、Goのガベージコレクションおよびgoroutineのスタック伸長の仕組みと関連づけて説明してください(このパターンのコードを実際に書く必要はありません)。
  5. reflect(Day 043〜044)とunsafeは、どちらも「型安全性のコンパイル時保証を手放す」という点で似ていますが、両者が実際に手放しているものの違いは何かを説明してください。

🔍 ヒント(段階的開示)

ヒント1 — 方向性

unsafe.PointerのGoDocコメントには、有効な変換パターンが明示的に列挙されています。「*T1をunsafe.Pointer経由で*T2に変換する」「unsafe.Pointerとuintptrを相互変換する」という2種類の操作が、それぞれどんな条件下でのみ安全とされているかを読んでみてください。

ヒント2 — アプローチ

Go 1.20からunsafeパッケージに追加されたunsafe.String(ptr *byte, len int) string・unsafe.StringData(s string) *byte・unsafe.Slice(ptr *T, len int) []T・unsafe.SliceData(slice []T) *Tは、以前はreflect.StringHeader/reflect.SliceHeaderを手動で組み立てる必要があった処理を、型安全な形で置き換えたAPIです。[]byteの先頭ポインタをunsafe.SliceDataで取り出し、それをunsafe.Stringに渡すことでコピーなしの変換ができます。構造体のパディングは、unsafe.Sizeof(型全体のサイズ)とunsafe.Offsetof(特定フィールドの構造体先頭からのオフセット)をfmt.Printfで出力して観察してください。

ヒント3 — コード骨格
package main

import (
	"fmt"
	"unsafe"
)

func BytesToStringUnsafe(b []byte) string {
	if len(b) == 0 {
		return ""
	}
	return unsafe.String(unsafe.SliceData(b), len(b))
}

type BadOrder struct {
	A bool
	B int64
	C bool
}

func main() {
	b := []byte("hello")
	s := BytesToStringUnsafe(b)
	fmt.Println(s)
	// b を書き換えると s はどうなるか? 実際に試してみる

	var bo BadOrder
	fmt.Println(unsafe.Sizeof(bo)) // 期待した値と一致するか?
}

✅ 模範解答

main.go((2) ゼロコピー変換と不変条件の破壊の実演)

package main

import (
	"fmt"
	"unsafe"
)

// BytesToStringUnsafe は b をコピーせずに string に変換する。
// 戻り値の string は b と同じメモリ領域(配列)を指す。
// Go の string は「一度作られたら中身は変わらない」ことが言語仕様レベルで
// 保証されている前提だが、この関数が返す string はその前提を満たさない。
// 呼び出し後に元の b を書き換えると、変換済みの string の中身も一緒に変わる。
func BytesToStringUnsafe(b []byte) string {
	if len(b) == 0 {
		return ""
	}
	return unsafe.String(unsafe.SliceData(b), len(b))
}

// StringToBytesUnsafe は s をコピーせずに []byte に変換する。
// こちらはさらに危険: 戻り値の []byte を書き換えると、
// 本来書き換え不可能なはずの元の string の中身が書き換わる。
// s がGoの文字列リテラル(読み取り専用メモリに配置されうる)の場合、
// 書き込もうとすると実行時にクラッシュする可能性がある。
func StringToBytesUnsafe(s string) []byte {
	if len(s) == 0 {
		return nil
	}
	return unsafe.Slice(unsafe.StringData(s), len(s))
}

func main() {
	fmt.Println("--- ゼロコピー変換: b と s がメモリを共有することの実演 ---")
	b := []byte("hello, unsafe")
	s := BytesToStringUnsafe(b)
	fmt.Println("変換直後の s:", s)

	b[0] = 'H' // b を書き換える
	fmt.Println("b[0]='H' に書き換えた後の s(本来stringは不変のはず):", s)

	fmt.Println("\n--- 比較対象: 通常の string(b) 変換(コピーが発生し安全) ---")
	b2 := []byte("safe copy")
	s2 := string(b2) // 標準の変換はコピーを作るため、s2 は b2 から独立している
	b2[0] = 'S'
	fmt.Println("b2 を書き換えた後の s2(コピーなので影響を受けない):", s2)
}

実行結果(実測。go1.26.2 darwin/amd64で確認済み):

--- ゼロコピー変換: b と s がメモリを共有することの実演 ---
変換直後の s: hello, unsafe
b[0]='H' に書き換えた後の s(本来stringは不変のはず): Hello, unsafe

--- 比較対象: 通常の string(b) 変換(コピーが発生し安全) ---
b2 を書き換えた後の s2(コピーなので影響を受けない): safe copy

main_padding.go((3) 構造体パディングの実測。上のmain.goと同じディレクトリで動かす場合はfuncやtype名の重複に注意し、別ファイル/別mainとして実行すること)

package main

import (
	"fmt"
	"unsafe"
)

// BadOrder は bool(1バイト) → int64(8バイト) → bool(1バイト) の順で並べており、
// int64 のアライメント要求(8バイト境界)を満たすために A の後に7バイトの
// パディングが挿入され、さらに構造体全体のサイズも8の倍数に切り上げられる。
type BadOrder struct {
	A bool
	B int64
	C bool
}

// GoodOrder はサイズの大きい型から並べることでパディングを最小化した例。
type GoodOrder struct {
	B int64
	A bool
	C bool
}

func main() {
	var bo BadOrder
	var g GoodOrder

	fmt.Printf("BadOrder:  Sizeof=%d bytes\n", unsafe.Sizeof(bo))
	fmt.Printf("  A offset=%d, B offset=%d, C offset=%d\n",
		unsafe.Offsetof(bo.A), unsafe.Offsetof(bo.B), unsafe.Offsetof(bo.C))

	fmt.Printf("GoodOrder: Sizeof=%d bytes\n", unsafe.Sizeof(g))
	fmt.Printf("  B offset=%d, A offset=%d, C offset=%d\n",
		unsafe.Offsetof(g.B), unsafe.Offsetof(g.A), unsafe.Offsetof(g.C))
}

実行結果(実測・64bitアーキテクチャの場合。32bit環境では数値が変わる):

BadOrder:  Sizeof=24 bytes
  A offset=0, B offset=8, C offset=16
GoodOrder: Sizeof=16 bytes
  B offset=0, A offset=8, C offset=9

(1)(4)(5) の説明:

📌
(1) unsafe.Pointerと通常の*Tの違い: 通常の*Tはコンパイル時に型Tが確定しており、コンパイラはその型を通してしかメモリにアクセスさせません(*int型の値を*string型の変数に代入しようとすればコンパイルエラーになる)。unsafe.Pointerは「どんな型の*Tからも変換でき、どんな型の*Tにも変換できる、型を持たない生ポインタ」です。この型を持たないという性質のせいで、コンパイラは変換先の型が変換元と本当にメモリレイアウト互換かどうかを検証できません。検証をプログラマの責任に完全に委ねる、という一点において特別です。
📌
(4) uintptrへの変換を複数文に分けてはいけない理由: unsafe.PointerはGoのランタイム・GCに「これはヒープ上のオブジェクトを指すポインタである」と認識され続けます。オブジェクトがヒープ上で移動した場合(現在のGoの実装ではヒープ上のオブジェクトは基本的に移動しませんが、goroutineのスタックは関数呼び出しの深さに応じて伸長する際にまるごと新しい領域へコピーされ、古いスタック上の全ポインタが新しいアドレスに書き換えられます)、GCとランタイムはunsafe.Pointer型の値であれば、その参照を正しく新しいアドレスに更新できます。しかしuintptrは単なる整数型であり、GCから見ればただの数値です。ptr := unsafe.Pointer(x); addr := uintptr(ptr)のように一度uintptrに変換して別の文として保持している間にスタック伸長やGCのタイミングが挟まると、そのuintptrはもう存在しないかもしれないアドレスを指す「ただの数値」のままになります。そのため、unsafe.Pointer → uintptr → 演算 → unsafe.Pointerの変換は、途中に他の処理を挟まない1つの式の中で完結させることが必須とされています。
📌
(5) reflectとunsafeが手放すものの違い: reflectが手放すのは「コンパイル時の型検査」だけです。実行時にはreflect自身が改めて型を検査し(Kind()の一致確認など)、条件を満たさなければpanicという形で安全に停止します。つまりreflectはメモリ安全性を維持したまま、型検査のタイミングだけを遅らせています。一方unsafeが手放すのは「メモリ安全性そのもの」です。unsafe.Pointerを誤った型で再解釈しても、コンパイラはもちろんランタイムも多くの場合それを検出できず、panicすら発生しないまま不正なメモリアクセスが実行されます(クラッシュする場合もあれば、無関係な変数を静かに書き換えてしまう場合すらあります)。

🪜 Step-by-Step 解説

1
unsafe.Pointerは「型のない共通の窓口」
Goのポインタ変換ルールは通常、同じ型同士かインターフェース経由でしか許されません。unsafe.Pointerは例外的に、あらゆる*T型と相互変換できる唯一の型として定義されています(*T1 → unsafe.Pointer → *T2)。これにより「型システムの外側を一度経由して、別の型として再解釈する」ことが可能になります。この変換が安全かどうかは、T1とT2のメモリレイアウトが互換であることをプログラマが保証する責任にかかっています。
2
unsafe.String / unsafe.SliceDataによるゼロコピー変換
[]byteとstringは、どちらも内部的に「先頭ポインタ + 長さ」という似た構造を持っています(stringはさらにcapacityを持ちません)。通常のstring(b)変換は、stringの不変性を守るために必ずデータをコピーします。unsafe.SliceData(b)で[]byteの先頭ポインタを取り出し、unsafe.String(ptr, len(b))にそのまま渡すことで、コピーを発生させずに同じメモリ領域を指すstringを作れます。コピーがない分速いですが、上のコード例で示した通りstring側の値が元のスライスの変更に追従してしまうという、Goの言語仕様が保証しているはずの不変性の破壊を招きます。
3
unsafe.Sizeof / unsafe.Offsetofで構造体レイアウトを可視化する
CPUは多くのアーキテクチャで「Nバイトの型はNバイト境界に配置されている方が高速にアクセスできる」という制約(アライメント)を持ちます。Goのコンパイラは、構造体の各フィールドをその型のアライメント要求に合わせて配置するため、フィールドの並び順によってはフィールドとフィールドの間に使われない隙間(パディング)が生まれます。unsafe.Sizeofは型全体の(パディングを含めた)サイズを、unsafe.Offsetofは特定フィールドの構造体先頭からのバイトオフセットを、どちらもコンパイル時定数として返します。この2つを組み合わせることで、目に見えないパディングの発生箇所を数値として確認できます。
4
uintptrとunsafe.Pointerの「GCからの見え方」の違い
unsafe.Pointer型の値はGoランタイムにとって「生きているオブジェクトへの参照」として認識され、GCの到達可能性解析やスタック伸長時のポインタ書き換えの対象になります。uintptr型の値はただの整数であり、たとえ元はポインタのアドレスだったとしても、GCはそれを参照として扱いません。したがってuintptrに変換した瞬間から、そのオブジェクトはGCから見て「参照されていない」ものとして回収される可能性があり、かつアドレス自体も(スタック伸長で)古い値のまま取り残される可能性があります。「1つの式の中で完結させる」というルールは、この危険な期間をコンパイラが命令を並べ替えたり途中でGCが割り込んだりする隙を与えないようにするための制約です。
5
reflectとunsafeを「安全性のグラデーション」として位置づける
Day 043〜045を通して見ると、Goが提供する3段階の安全性のグラデーションが見えてきます。①通常のコード(コンパイル時に型・メモリ安全性の両方を保証)→ ②reflect(型検査を実行時に遅延させるが、メモリ安全性はpanicという形で維持)→ ③unsafe(型検査もメモリ安全性の保証も手放し、プログラマの責任に完全に委ねる)。それぞれのコストと引き換えに得られるもの(柔軟性・実行速度)を理解した上で、必要最小限の範囲でしか使わない、という判断軸が実務では重要です。

💡 設計思想・なぜこう書くのか

📌
パッケージ名そのものが警告になっている: Goには「危険な操作にはあえて長く・目立つ名前をつけ、コードレビューやgrepで見つけやすくする」という設計哲学があります。unsafe.Pointerという名前は、コードを読む人に対して「ここでGoの安全性保証が及ばなくなる」ことを一目で伝える、いわばコード上の警告ラベルです。Rustのunsafeブロックが視覚的に危険な範囲を明示するのと同じ発想です。
📌
go vetとcheckptrによる部分的な安全網: unsafeの誤用はコンパイラが検出できませんが、go vetは一部の明らかに誤ったunsafe.Pointerの使い方(reflect.SliceHeaderを誤った手順で組み立てる等)を静的に警告できます。また実行時にはGODEBUG=checkptr=1(go testでは既定で有効)を付けることで、一部の不正なunsafe.Pointer操作を実行時にpanicとして検出できます。これらは「安全性を手放す」判断を完全な自己責任にはせず、可能な範囲でツールが支援するというGoらしいバランス感覚の表れです。

🌐 他言語との比較

言語同等の概念Goとの違い
C / C++生ポインタ(void*, reinterpret_cast)C/C++はデフォルトで型安全性の保証がなく、どこでも自由にポインタ演算・キャストができる。Goは通常のポインタ操作をコンパイラが型で厳密に制限し、unsafeパッケージという明示的な「裏口」を通らない限り同種の操作を許さない
RustunsafeブロックRustのunsafeブロックは借用チェッカーの一部制約を無効化するだけで、多くの不変条件(データ競合の禁止・エイリアシングルールなど)は依然としてプログラマが満たす責任があり、Miriのような検証ツールで確認できる文化が根付いている。Goのunsafeパッケージにはこうした検証ツールの文化がまだ薄く、checkptrのような限定的な実行時チェックに頼る部分が大きい
Javasun.misc.Unsafe / Foreign Function & Memory API(Java 22+)JavaのUnsafeは非公式APIとして長年使われてきたが、近年のバージョンでは正式なForeign Function & Memory APIへの置き換えが進み、段階的に利用が制限されつつある。Goのunsafeは逆に、標準パッケージとして最初から公式に提供され、今後も維持される前提である点が対照的

🏆 実務での使いどころ

  • 高性能なHTTPルータ・パーサー: valyala/fasthttpのようなライブラリは、リクエストのバイト列をコピーせずに文字列として扱うためにunsafeを活用し、アロケーションを削減している
  • 標準ライブラリ自身の内部実装: strings.BuilderのString()メソッドや、syscallパッケージのシステムコール呼び出しなど、Go標準ライブラリの内部にもunsafeが使われている箇所がある(公開APIの利用者はその存在を意識せずに安全に使える設計になっている)
  • cgoによるC言語とのメモリ共有: CのライブラリにGoのメモリを渡す、またはCが確保したメモリをGo側で読み書きする場合、型を持たない生ポインタとしての受け渡しが必要になる

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
reflectと同じ感覚で「unsafeも遅いけど安全(最悪panicするだけ)」だと思ってしまうDay 043〜044で学んだreflectの「実行時に安全性を後退させるが破綻はしない」という経験がそのまま適用されると誤解するunsafeの誤用にはpanicという安全網すら保証されない。クラッシュすることもあれば、無関係な変数の値が静かに書き換わることすらある
BytesToStringUnsafeで変換したstringを、元の[]byteのライフタイムを意識せず長期間保持してしまう変換後のstringが独立した値に見えるが、実際には元のバッファを参照し続けているゼロコピー変換した値のライフタイムは、変換元のスライスのライフタイムに暗黙的に縛られる。長期保持や別goroutineへの受け渡しが必要な場合は明示的にコピーする
unsafe.Pointerからuintptrに変換した値を、通常の整数のように変数に代入して複数の文にまたがって使い回してよいと思うuintptrが「アドレスを表す整数」に見えるため、ポインタと同じ安全性を持つと錯覚するuintptrはGCの追跡対象外のただの数値。unsafe.Pointer → uintptr → 演算 → unsafe.Pointerは必ず1つの式の中で完結させる

🚀 次のステップ

  • 発展: GODEBUG=checkptr=1 go run main.go(またはgo test)を実際に実行し、正しい変換パターンではpanicが起きないことを確認してみましょう。さらに、意図的にunsafe.Sliceのlenを元のバッファより大きい値で呼び出し、範囲外アクセスがどのように検出(または検出されないか)を観察すると理解が深まります
  • 次回予告: ジェネリクス応用(制約・型セット) — Day 022で学んだジェネリクスの基礎を発展させ、独自の型制約インターフェースを設計する実装問題を扱います

🎯 自己評価

自分の回答

気づき・メモ