Goの†黒魔術†に対する防衛術 ~Defence Against the Go’s dark Arts~

ソフトウェアエンジニアリングの世界では、一見すると動作原理が不可解でありながら、言語仕様の巧みな(あるいは強引な)解釈によって機能する、芸術的で美しい、あるいは恐ろしいコードを、畏敬の念を込めて 『黒魔術』 と呼ぶことがあります

Goは、そのシンプルさと堅牢な型システムによって、本来このような『黒魔術』が生まれにくい言語ですが
パフォーマンスの極限的な追求や、言語仕様上通常は不可能な処理を実現するために、reflect package や unsafe package や go:linkname ディレクティブ のようないくつかの 『大いなる力』 を用意しています

日々真理の探究に勤しむ我々ソフトウェアエンジニアはそんな『黒魔術』が大好きです。

Go Conference 2025のワークショップでもnewmoさんが†黒魔術講座†のワークショップをするようですね
gocon.jp

しかし、『黒魔術』 を行使する際に利用する 『大いなる力』 にはお約束ではありますが 『大きな代償』 が伴います。

今回はそんな 『黒魔術』 を安全に利用するための防衛術

題して

黒魔術に対する防衛術 ~Defence Against the Go’s dark Arts~

(略して †DAGA† )

を勉強していきましょう


どうも、ANDPAD テックリードの tomtwinkle です

今度、9/30 のGo 1.25のリリパのLTで登壇する予定ですが、今回の内容は LTの事前学習(もしくはLT後の事後学習)の為の内容 になります

リリパの方も興味あったらみてみてね!!!

Go 1.25 リリースパーティ & GopherCon 2025 報告会 – connpass

9月はGoのイベントが連続していて “Go” lden Week ですね、9月なのに

Go 1.25 リリースパーティ & GopherCon 2025 報告会 はその最後のイベントになります

リリパは弊社の引越し直後の新オフィスでの開催になります!
(私も多分その日初めて新オフィスに行く予定です。)

ANDPADの新オフィス観光も兼ねて皆様の参加お待ちしています!!!


黒魔術をどうやって使うか

Goの黒魔術の中でも型を破壊したり生のpointerを触ったりするコードの対象は大抵黒魔術の実行者のコントロール外のコードに対して行われます。

何故なら自分でコントロール出来るならわざわざunsafe pointerなんて使わなくてもrefactoringなりなんなりでそのコードを修正すれば良いからですね。

よくある話では、1年くらい前に書いた go:linkname ディレクティブのようなruntimeのprivateな関数を自分のコード内に召喚して利用しようとしたり、runtime内のinternalなコードを改造して利用したいがために unsafe.Pointer を利用して型のカプセル化を破壊したりとかですね。

tech.andpad.co.jp

ここで注意しなければならないのは

黒魔術を実装する時には、”黒魔術を実装した時点でのコントロール外の実装” を元に黒魔術を構築すること です。

コントロール外の実装の現時点での実装を参考していると何がまずいか。

それは、 将来的にそのコードが変更される可能性がある ということです。

go:linkname ディレクティブ の時も参照元のruntimeのコードが変更された結果、それを参照していたサードパーティー製のライブラリが壊れて大騒ぎになりました。

特に危険なのは先ほど述べた unsafe.Pointerを利用して型のカプセル化を破壊するようなケースです。

type Foo struct {
    private string
}

func GetFoo() *Foo {
    return &Foo{
        private: "Foo!",
    }
}

func main() {
    foo := GetFoo()

    
    fmt.Printf("Before: %#v\n", foo)

    
    type cloneFoo struct {
        private string
    }
    
    type uP = unsafe.Pointer
    var ptr *cloneFoo
    *(*uP)(uP(&ptr)) = uP(foo)
    ptr.private = "Kuromajutsu!"

    
    fmt.Printf("After: %#v\n", foo)
}

https://go.dev/play/p/m2KbOT5BHEc

Foo と同じメモリ構造のstruct cloneFoo を実装した上で、 unsafe.Pointer を使って、*Foo 型を *cloneFoo 型として解釈させます。

変数 ptr は 変数 foo と全く同じメモリアドレスを指すようになるためprtの値を書き換えれば、本来書き換えが出来ないstruct の private filed を書き換える事ができます。

黒魔術の副作用

ではここでもし、 Fooの実装が変わってしまった場合 はどうなるでしょう?

type Foo struct {
    number  int32
    private string
}

func GetFoo() *Foo {
    return &Foo{
        number:  10,
        private: "Foo!",
    }
}

func main() {
    foo := GetFoo()

    fmt.Printf("Before: %#v\n", foo)

    type cloneFoo struct {
        private string
    }

    type uP = unsafe.Pointer
    var ptr *cloneFoo
    *(*uP)(uP(&ptr)) = uP(foo)
    ptr.private = "Kuromajutsu!"

    
    fmt.Printf("After: %#v\n", foo)
}

https://go.dev/play/p/E9bvSG12vRQ

コンパイル自体は正常に通りunsafe.Pointer の処理では特にエラーにならず 変数を参照する際にruntime error になります。

これは要するに コードが実行されるまでエラーに気づかない非常に危険な状態 です。

というわけで、今回の本題

参照元のコードが変更されて『黒魔術』の条件が変わってしまった時に 検知する仕組み

黒魔術に対する防衛術 ~Defence Against the Go’s dark Arts~

一般的には 「コンパイルアサーション」 と呼ばれる仕組みの一部を紹介していきます。

インターフェース適合アサーション

標準的な構文

type Example struct{}

func (e Example) Foo(b []byte) error { return nil }
func (e Example) Bar(s string) error { return nil }

type implemented interface {
    Foo([]byte) error
    Bar(string) error
}

var _ implemented = (*Example)(nil)

var _ implemented = Example{}

https://go.dev/play/p/Bj5KLkxjitk

動作メカニズム

Goでは割とどこでも一般的に利用されているインターフェースの実装を確認するアサーションですね。

  • var _ :
    ブランク識別子 _ は、宣言された変数がどこからも使用されないことをコンパイラに伝えます。
    これにより、「未使用変数」というコンパイルエラーを回避しつつ、代入の右辺を評価させることができます。
    この文脈では、変数の値を保持する必要はなく、代入という行為自体が目的です。

  • (*Type)(nil) :
    Typeへのnilポインターを生成します。Method Setの検証には実際のインスタンスは不要なのでnilポインターを利用した方がメモリ効率が良いです( 気にしないなら Type{} で定義しても問題ありません )。

  • Interface :
    型が満たすべき規約(Method Set)を定義するインターフェース

  • = :
    代入演算子です、これがコンパイラの型チェック機構を起動するトリガーとなります。
    コンパイラは、右辺の式の型が、左辺の型(この場合はInterface)に代入可能かどうかを厳密に検証します。

もしType が InterfaceのMethod Setを完全に満たしていない場合、コンパイラは

cannot use XXX as implemented value in variable declaration: XXX does not implement implemented (missing method XXX)

といった趣旨のエラーを生成し、コンパイルは失敗します。
これにより、型の規約遵守がコンパイル時に保証されます。

主な用途

公開APIや重要な内部コンポーネントでinterface型に変更があった時に開発者に実装の修正を促すために広く使われているイディオムです。

実装例

type onlyValuesCtx struct {
    context.Context
    lookupValues context.Context
}

var _ context.Context = (*onlyValuesCtx)(nil)
type ReaderFrom interface {
    ReadFrom(r Reader) (n int64, err error)
}

type discard struct{}
var _ ReaderFrom = discard{}
type closeWriter interface {
    CloseWrite() error
}

var _ closeWriter = (*net.TCPConn)(nil)

標準パッケーの中でもよく出てきます。

皆さんもよく見たことがあるのではないでしょうか。

配列長アサーション

標準的な構文

例1: x/toolscmd/stringer で enum の String() 関数を自動生成する例

package painkiller

type Pill int

const (
    Placebo Pill = iota
    Aspirin
    Ibuprofen
    Paracetamol
    Acetaminophen = Paracetamol
)
---
func _() {
    
    
    var x [1]struct{}
    _ = x[Placebo-0]
    _ = x[Aspirin-1]
    _ = x[Ibuprofen-2]
    _ = x[Paracetamol-3]
}

https://pkg.go.dev/golang.org/x/tools@v0.37.0/cmd/stringer

例2: 異なる型の同一性を確認する例

type Foo struct { F int64 }
type Bar struct { F int64 }

const X, Y = int64(unsafe.Sizeof(Foo{})), int64(unsafe.Sizeof(Bar{}))
var _ [X - Y]int
var _ [Y - X]int

const delta = int64(unsafe.Sizeof(Foo{})) - int64(unsafe.Sizeof(Bar{}))
var _ [-delta * delta]int

var _ [0]byte = [delta]byte{}

https://go.dev/play/p/CW5Hk3wBJEQ

動作メカニズム

Goの言語仕様では、配列の長さは負の定数であってはならないと定められています。
go.dev

この制約を逆手に取ったのが配列長アサーションです。

[X - Y]int のような式が配列の長さとして使用されると、コンパイラはその値をコンパイル時に計算します。

もし計算結果が負の数になった場合、invalid array length (不正な配列長) というエラーが発生し、コンパイルが失敗します。

例1 の stringer では、var x [1]struct{} という長さ1の配列を定義し、_ = x[Placebo-0] のようにアクセスしています。

もしPlaceboの値が0から変更されると、配列の境界外アクセスとなり invalid array index エラーがコンパイル時に発生します。
これにより、iotaで定義された定数の値が意図せず変更されていないことを保証します。

例2 では、unsafe.Sizeof 関数でstructのbyteサイズを取得しています。

unsafe package – unsafe – Go Packages

FooとBarのstructが等しい場合、両方とも0となり、var _ [0]int という有効な宣言になります。

しかし、FooとBarのフィールドが異なると、どちらかの式が必ず負数になるため、コンパイルエラーが発生します。

[0]byte のケースでは配列のlengthが0であることを期待するので0以外だと型不一致でコンパイルエラーが発生します。

これにより、FooとBarが等しいことを強制できます。

主な用途

  • iota定数値の不変性保証 :
    stringerのように、コード生成が定数の特定の値に依存している場合、定数が変更された際にコンパイルを失敗させ、開発者にコードの再生成を促すために使用されます。

  • 構造体のメモリレイアウト検証 :
    unsafeパッケージを使用するような低レベルなコードで、特定の構造体が期待通りのサイズやアライメントを持つことを保証するために不可欠です。

  • enumの最大値チェック :
    enumとして定義した定数群の最後にMaxEnumのような定数を置き、その値が実際の要素数と一致しているかを検証します。

実装例

Go製ゲームエンジンEbitengineの中から抜粋。

const (
    VertexFloatCount = 12
)

---


type Vertex struct {
    
}






var _ [0]byte = [unsafe.Sizeof(Vertex{}) - unsafe.Sizeof(float32(0))*graphics.VertexFloatCount]byte{}

定数演算と型定義によるアサーション

標準的な構文

const X = 10 
const Y = 5  

const _ = uint(X - Y)
type Foo struct{ F int64 }
type Bar struct{ F int64 }

const X = uint64(unsafe.Sizeof(Foo{}))
const Y = uint64(unsafe.Sizeof(Bar{}))



const _, _ uint64 = X - Y, Y - X

https://go.dev/play/p/_Oemph1JyU5

動作メカニズム

このアサーションは、負の定数を符号なし整数 (uint) 型に変換しようとするとコンパイルエラーになるというGoの言語仕様を利用しています。
go.dev

  • コンパイラは const 宣言の右辺を定数式として評価します。
  • X - YY - X という演算が行われます。
  • その結果を uint64() で符号なし整数に型変換しようとします。
  • もし X = Y であれば、X - YY - X の演算結果は0(同値)になり uint64 で表現できるためコンパイルは成功します。
  • もし X != Y であれば、X - YY - X どちらかの演算結果が負の数になり uint64 では表現できないため、コンパイラは (constant -4 of type uint64) overflows uint64のようなオーバーフロー(この文脈ではアンダーフロー)エラーを生成し、コンパイルが失敗します。

これは、前述の「配列長アサーション」による等価性検証の代替手法でもあります。

主な用途

  • 定数間の大小関係の保証 :
    バッファサイズがヘッダサイズ以上であることや、タイムアウト値が最小許容値以上であることなど、定数間に成り立つべき大小関係を静的に検証します。

  • 定数値の等価性検証 :
    2つの定数が同じ値を持つことを保証します。
    構造体のサイズ比較や、バージョン番号の整合性チェックなどで利用出来ます。

  • 非負定数の保証 :
    ある定数が0以上であることを保証する場合にも使えます (const _ = uint(MyConstant)) 。
    使い道は・・・ちょっと思いつかないですね。

実装例

const (
    Pxxx       Class = iota 
    PEXTERN                 
    PAUTO                   
    PAUTOHEAP               
    PPARAM                  
    PPARAMOUT               
    PTYPEPARAM              
    PFUNC                   

    
    _ = uint((1 << 3) - iota) 
    
    
    
    
    
    
    
    
    
    
    
    
)

マップキー重複アサーション

標準的な構文

const (
    MetricRequestTotal   = "http_requests_total"
    MetricRequestErrors  = "http_requests_errors"
    MetricRequestLatency = "http_requests_latency_seconds"
    
    
)


_ = map[string]struct{}{
    MetricRequestTotal:   {},
    MetricRequestErrors:  {},
    MetricRequestLatency: {},
    
}

動作メカニズム

他のコンパイルアサーションに比べると直感的です。

Goの言語仕様では、マップリテラル内でキーが重複することはコンパイルエラーと定められています。
go.dev

このアサーションは、このコンパイラの挙動をそのまま利用します。

  • 一意であることを保証したい定数群をキーとして持つマップリテラルを定義します、値は何でも良いですが、メモリを消費しない struct{} が一般的に使われます。
  • もし開発者が誤って2つの定数に同じ値を割り当ててしまうと、マップリテラル内でキーが重複することになります。
  • コンパイル時に duplicate key "..." in map literal というコンパイルエラーになります。

主な用途

  • 設定キーの重複防止 :
    config.yaml や環境変数などで使用するキー文字列の重複を防ぎます。

  • メトリクス名やラベルの一意性保証 :
    Prometheusなどの監視システムで用いるメトリクス名が重複していないことを保証します。

  • APIエンドポイントのパス重複防止 :
    WebフレームワークなどでAPIのパスを定数として定義する際に、重複がないことを確認します。

実装例

Protobufのコンパイラが生成するGoコードでは、enumの名前と値の対応をマップとして生成しますが、これは暗黙的にenum値の重複を防止する役割を果たしています。

syntax = "proto3";

package example;

option go_package = "example/userpb";

// ユーザーのステータスを表すenum
enum UserStatus {
  // 未指定
  USER_STATUS_UNSPECIFIED = 0;
  // 有効
  USER_STATUS_ACTIVE = 1;
  // 退会済み
  USER_STATUS_DEACTIVATED = 2;
}
type UserStatus int32

const (
    UserStatus_USER_STATUS_UNSPECIFIED UserStatus = 0
    UserStatus_USER_STATUS_ACTIVE      UserStatus = 1
    UserStatus_USER_STATUS_DEACTIVATED UserStatus = 2
)


var (
    UserStatus_name = map[int32]string{
        0: "USER_STATUS_UNSPECIFIED",
        1: "USER_STATUS_ACTIVE",
        2: "USER_STATUS_DEACTIVATED",
    }
    UserStatus_value = map[string]int32{
        "USER_STATUS_UNSPECIFIED": 0,
        "USER_STATUS_ACTIVE":      1,
        "USER_STATUS_DEACTIVATED": 2,
    }
)

リンカエラーアサーション

標準的な構文

package main

import "C"
import "unsafe"



func assertionFailed()


const is64bit = unsafe.Sizeof(uintptr(0)) == 8

func main() {
    
    
    if !is64bit {
        assertionFailed()
    }
}

Go playgroundでは動かないので試すならローカルで

動作メカニズム

コンパイラの最適化(特にデッドコード除去)とリンカのシンボル解決の挙動を意図的に悪用することで、コンパイルアサーションを実現しています。

これ自体が黒魔術の一種なので黒魔術を黒魔術で防御するような形ですね。

import “C” の副作用

この import 文はcgoを有効化しますが、真の目的はその副作用にあります。

通常、GoコンパイラはGoのソースファイル内で宣言された関数には必ず本体(実装)があることを要求します。

しかし import “C” が存在すると、コンパイラは「関数の実装が外部のCライブラリで定義されている可能性がある」と判断します。

これにより、assertionFailed のように本体を持たない関数宣言がコンパイルエラーにならずに許容されます。

コンパイラの定数評価とデッドコード除去 (DCE)

main 関数内の if !is64bit という条件式は、定数のみで構成されているため、コンパイル時に評価されます。
コンパイラはこの評価結果に基づいて最適化を行います。

条件が真の場合 (アサーション成功)

is64bit が true の場合、コンパイラはこの if ブロック内のコードが決して実行されない「デッドコード」であると判断します。

最適化の一環であるデッドコード除去 (Dead Code Elimination) によって、assertionFailed() への関数呼び出しを含む if ブロック全体がプログラムから完全に削除されます。

その結果、最終的なオブジェクトファイルには assertionFailed というシンボルへの参照が一切含まれなくなります。

条件が偽の場合 (アサーション失敗)

is64bit が false の場合、コンパイラはこの if ブロック内のコードを有効なものとして保持します。

その結果、assertionFailed() への関数呼び出しがオブジェクトファイルに残ります。

リンカのシンボル解決

ビルドプロセスの最終段階で、リンカはすべてのオブジェクトファイルを結合し、assertionFailed のような関数呼び出し(シンボル参照)に対応する関数の定義を見つけようとします。

アサーション成功時

前述の通り、assertionFailed への参照はDCEによって削除されているため、リンカの仕事には影響せず、ビルドは正常に完了します。

アサーション失敗時

リンカはオブジェクトファイル内に残された assertionFailed への参照を見つけますが、その定義がGoのコードにもCのコードにも存在しないため、シンボルを解決できません。

結果として、リンカは undefined: main.assertionFailed のような未解決シンボルエラーを報告し、ビルドプロセスが失敗します。

この一連の流れにより、is64bit という条件がコンパイル時に true であることが静的に保証されます。ビルドが成功したという事実そのものが、アサーションが通ったことの証明となります。

主な用途

正直なところ基本的に使うことはないでしょう。

ちなみに、アセンブリ実装の場合は import "C" は必要なく同じ階層に xxx.s というアセンブリファイルが存在する場合も同様に本体を持たない関数定義を許容します。

runtimeの中ではアセンブリ実装している箇所がいくつかありますが、そこら辺で使われていたような・・・?

//go:linkname の場合も同様に本体を持たない関数定義を許容します。

  • ビルド環境の検証 :
    amd64 や linux といった特定のビルドタグや環境でのみ成り立つべき条件(例: unsafe.Sizeof の値、構造体のメモリレイアウト)を保証します。

  • 複雑な定数条件のアサート :
    単純な四則演算では表現が難しい、より複雑な論理関係を持つ定数条件を検証します。

  • 機能フラグの強制 :
    ビルド時に特定の機能フラグの組み合わせが有効または無効であることを強制し、意図しないビルド構成を防ぎます。

  • アセンブリ実装の強制 :
    これは import "C"が書かれていないパターンですが、パフォーマンスが重要な処理において、特定のアーキテクチャ向けのアセンブリ実装が提供されていることをビルド時に保証します。

  • //go:linknameによる内部APIの結合保証 :
    runtimeパッケージとsyncパッケージのように、標準ライブラリ内部で密接に連携するパッケージ間で、プライベートな関数や変数が正しくリンクされることを保証します。

実装例

cryptoの中にあった気がしましたが今はもうないですね。

https://cs.opensource.google/go/go/+/refs/tags/go1.25.1:src/crypto/x509/internal/macos/security.go

要するにこの手法は もう使うな という事なのかもしれません。

特にpublicに公開されているサードパーティー製ライブラリでの黒魔術の実装には一定の責任が伴います。

unsafe.Pointer での実装は runtime error になってくれるならまだマシですが、場合によってはメモリが壊れた状態で正常に動いてしまう可能性もあり 非常に危険 です。

危険な実装には適切な防御術を施行していざ想定外の状態になったらコンパイル時にコケる安全な実装を心がけていきましょう。

もちろん必要がない限りは使わないのが最善ではありますね。

 

アンドパッドでは Go の 黒魔術探究が大好きな Gopher を募集しています!

hrmos.co




Source link

関連記事

コメント

この記事へのコメントはありません。