GoのPrintfをハックする
この記事は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
コードの全容
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
が何をやっているか
out
はinterface{}
型の値として定義され、その後に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のハックでした。
参考
-
この部分に関する証拠を調査したのですが見つけられていません。 ↩︎
Discussion