😉

GoのPrintfをハックする

2021/12/06に公開

この記事はGo アドベントカレンダー2の7日目の記事です。

導入

思い込んでいたことが、実は違うということはよくあります。

例えば以下のコードの一部を見て、ここのPrintfは255を出力するに決まっていると思っていると

doThing(func(out interface{}) {
    val := 255
    if val == 255 {
        fmt.Printf("val is 255, right? %d\n", val)
    }
})
val is 255, right? 666

不吉な数字が現れてしまいました。

お察しの通り、これはdoThing関数が何やら良くないことをしています。

環境

$ go version
go version go1.17.2 darwin/amd64

コードの全容

Go Playground Link

package main

import (
	"fmt"
	"unsafe"
)

func run() {
	doThing(func(out interface{}) {
		val := 255
		if val == 255 {
			fmt.Printf("val is 255, right? %d\n", val) // Prints 666
		}
	})
}

func doThing(f func(out interface{})) {
	var i int = 255
	var out interface{}
	out = i
	p := (*eface)(unsafe.Pointer(&out)).data
	*(*int)(p) = 666
	f(out)
}

type eface struct {
	rtype unsafe.Pointer
	data  unsafe.Pointer
}

func main() {
	run()
}

doThingが何をやっているか

outinterface{}型の値として定義され、その後にout = iとしてint型の値にアサインされています。

このときに起こっていることを整理します。

Goにおいてinterface型の値の内部は以下の2つで構成されています。

  • 型データポインタ
  • 実データポインタ

interface型の内部に関してはGoの公式FAQでの以下の内容が理解の助けになります。

Go Official FAQ | Why is my nil error value not equal to nil?

Under the covers, interfaces are implemented as two elements, a type T and a value V. V is a concrete value such as an int, struct or pointer, never an interface itself, and has type T.

そのため、outはInt値を持っていてもただのInt型値ではなく、上記のような2つのデータを持つ値として保持されます。

コード内のefaceはunsafe.Pointerを使うことでinterface型をユーザ側で型定義しています。

p := (*eface)(unsafe.Pointer(&out)).data によってoutの「実データポインタ」が変数pへアサインされています。

そんなp*(*int)(p) = 666 とすることで型データがintであるinterface型データoutの実データが666になります。

しかし、これだけではただoutの値が書き換わっただけです。 doThingが呼ぶことになる関数f(out)内のvalまでもが666でPrintf表示されてしまった理由はなぜでしょう。

理由として、interface型内のint型の場合、uint8サイズ分つまり0-255までの範囲は共通メモリ領域が存在しており、特定スコープ範囲内では常にその領域が参照されるようになっているためのようです。[1]

そしてfmt.Printfの引数としてvalが渡されるとき、Printf内では値はdoThingと同様にinterface型値でのintの形式で渡されます。上述のとおりdoThing内でinterface型内のint型共通メモリ領域の255の中身が666に書き換えられてしまっているため、Printfでは666が表示されてしまいました。逆に、interface型ではないピュアなint型値であるval自体はきちんと255として参照されます。

上述の再現条件について、実際に上述のコードにて255から256に置換した場合で実行すると書き換えが行われません。

val is 256, right? 256

また、doThingが引数で受け取る関数に渡すinterface型の値をoutではない値にした場合(例えばf(out)からf(i)のように変更する)でも、実行すると書き換えが行われません。outが共通メモリ領域と紐付いており、それを引数として関数に渡すと副作用としてその関数も影響されるようになることがわかります。

val is 255, right? 255

まとめ

interface型の変数へ値をアサインした場合の特徴を突いたGoのハックでした。

参考

https://philpearl.github.io/post/anathema/

https://syslog.ravelin.com/what-is-an-interface-ee67b3cc9e97

https://pkg.go.dev/cmd/compile#hdr-Command_Line

脚注
  1. この部分に関する証拠を調査したのですが見つけられていません。 ↩︎

GitHubで編集を提案

Discussion