Go言語における式の評価文脈を理解する
Go言語では式を評価するときに、ある種の文脈のようなものが作用します。
i = 2
x = nil
y.(T)
例えば上のコードにおける、2
, nil
の型はなんでしょうか? y.(T)
は何個の値を産出するでしょうか?
答えは「文脈による」です。「相手による」といってもいいかもしれません。
Go言語ではいくつかのケースにおいて、式をそれ単体で評価することができず文脈を考慮しないといけない場面があります。
なおこの「文脈」という概念は仕様書にはっきりと書かかれておらず、Goコンパイラを自作していて発見しました。
nilにおける文脈の役割
事前定義された識別子 nil
は、コンピュータ上のハードウェア(つまりメモリやレジスタ)上でどんな姿かたち(値)をしているでしょうか?
答えは「文脈による」です。文脈によって、8バイト分のゼロだったり16バイト分のゼロだったり24バイト分のゼロだったりします。[1]
これはコンパイル後のアセンブリを見てみるとよくわかります。
x86-64環境でのアセンブリを例にとって見てみましょう。
slice に nil を代入するとどうなるのか
mySlice = nil
MOVQ $0, "".mySlice+64(SP)
XORPS X0, X0
MOVUPS X0, "".mySlice+72(SP)
これは何をしているかというと、mySlice
というローカル変数が示すメモアドレス(64(SP)番地)を基点にして、そこから24バイト分 (64(SP)から87(SP)まで)をゼロで埋めているだけです。
(MOVQで先頭8バイトを、MOVUPSで残り16バイトを書き込み)
スライスというのは実体は構造体なので、これは下記のような構造体を
type Slice struct {
ptr uintptr
len int64
cap int64
}
ゼロ値にセットするのと同じことです。
mySlice = Slice{
ptr : 0,
len: 0,
cap: 0,
}
スライスの nil は意外とでかいのです。
ポインタ変数 に nil を代入するとどうなるのか
ポインタ変数 に nil を代入すると 8byte分のメモリ領域がゼロになります。
p = nil
MOVQ $0, "".p+16(SP) // メモリ 16(SP)から23(SP)までをゼロ埋め
interface{} に nil を代入するとどうなるのか
16byte分のゼロになります。
ifc = nil
XORPS X0, X0
MOVUPS X0, "".ifc+16(SP) // メモリ 16(SP)から31(SP)までをゼロ埋め
nil は大きさがいろいろ
このように、見た目は同じ nil
なのですが実行時のコンピュータ上に登場するときは文脈に応じていろいろなサイズのデータに化けます。
nil には「相手」がいる
Goのコードに nil が出現するとき、必ずそれを受け取る相手がいます。そう、nil は単独では存在できないのです。ウィルスとか共生系の生物みたいですね。
var slice []int = nil
user := User{
repository: nil,
}
foo(nil)
return nil
上の例を見るとわかるように、 nil は必ず「誰か」に渡されています。誰かは 変数であったり構造体リテラルのフィールドであったり関数呼び出しの引数であったり 関数の戻り値であったりします。
そして渡される相手は必ず「型」が既に決まっています。
- nil が出現するとき、必ず相手がいる
- 相手は決定済みの型を持っている
ということはつまり nil とは常に型ありなのです。そう、nil の正体は「相手の型のゼロ値」なのです。
slice に nil を代入するとはこんな感じのイメージです。 (再掲)
mySlice = Slice{
ptr : 0,
len: 0,
cap: 0,
}
同様に、interface{}
型の変数 に nil を代入するのはこんな感じです。
ifc = Interface{
_type : 0,
data :0 ,
}
なので、代入において左辺型なしの変数に対して nil を代入できない理由は、nil にとって「相手の型」が決まらないと自分の存在が何なのかを決定できないから、と考えるとわかりやすいです。
x := nil // コンパイルエラー
var y = nil // コンパイルエラー
型なし定数における文脈の役割
10
とか 'a'
みたいなリテラルは「型なし定数」と呼ばれます。またそれらを型なし定数宣言で代入すると、それも「型なし定数」となります。
const THREE = 3.0 // 型なし定数
この 3.0
とか THREE
は型なし定数です。
型なし定数が何かに渡されると、「渡される相手の型」の値として解釈されます。(その型で解釈できない場合はコンパイルエラー)
var x int16 = THREE // THREE は int16 の 3 と解釈される
func takeUint8(x uint8) {
}
takeUint8('a') // 'a' は uint8 の 97 と解釈される
では「渡される相手の型」がない場合はどうなるでしょうか。
var x = THREE
y := 'a'
実はこの場合、型なし定数は内部的に密かにデフォルト型というのを持っているので、それを相手に伝えます。「型が決まらないだと?しょうがないなぁでは私のデフォルト型を教えてあげよう」となります。
var x = THREE // x は float64 になる
y := 'a' // y は int32 になる
-
THREE
つまり3.0
のデフォルト型は float64 -
'a'
のデフォルト型は int32
ということが言語仕様で決まっているのでこのようになります。
注意すべきは「型なし定数は必ずデフォルト型になるわけではない」ということです。デフォルト型が使われるのは
- var宣言で型指定のない変数に代入するとき 例:
var x = 3.0
-
:=
のとき 例:x := 3.0
- 受け取る相手の型が
interface{}
のとき 例:func f(x interface{}) に対して f(3.0) で呼び出し
- switch の条件部 例:
switch 3.0 {
のケースだけです。(他にもあったら教えてください)
よもやま話
この定数の仕様は自作コンパイラを作ってるときにハマりました。
下記の3つの例で、 var
宣言の左辺と右辺の型がどうやって決まるか注目してみてください。
const THREE int = 3
var x = THREE
↑型が右から左に伝わる (普通の型推論)
const THREE = 3
var x int = THREE
↑型が左から右に伝わる (型なし定数)
const THREE = 3
var x = THREE
↑型が左から右に伝わってこないので右から左に伝える (型なし定数のデフォルト型を使って型推論)
型の伝搬が双方向になっている...!!
コンパイラ開発の後半でこの事実に気づいて愕然としました。なんとか実装しましたが後付けで無理やりな感じに...w
ok構文における文脈の役割
Go言語にはいわゆるok構文というものがあって、ある種の式は2番目の値としてbool値を産出します。
v, ok := x.(T)
v, ok := mymap["key"]
x, ok := <-ch
このok構文も文脈によって振る舞いが変わります。
ok構文は、実は「代入文で左辺式が2個あるとき」だけが特例なのです。言語仕様書には "special form" という呼称で記述されています。
the special form yields an additional untyped boolean value.
なのでこの特例以外では必ず値1個です。(bool値を産出しない)
代入文で左辺式が1個の場合は ok値が発生しないです。
v := x.(T)
v := mymap["key"]
x := <-ch
代入文じゃない場合も ok値が発生しないです。
User{
name : mymap["key"], // ok値が発生しない
}
slice := []string{mymap["key"]} // ok値が発生しない
関数に渡したり関数からreturnするケースも、ok値が発生しません。
func f(a T, ok bool) {}
f(x.(T)) // コンパイルエラー
func g() (a int, ok bool) {
...
return mymap["key"] // コンパイルエラー
}
こういう文脈を「単一値文脈」と呼んだりします。
単一値文脈の場合、タイプアサーションx.(T)
が失敗するとpanicするので要注意です。
v := x.(T) // x が T じゃないときは panicする (vにゼロ値が代入されるわけではない)
おもしろネタ: 文脈はかっこを貫通する
ちなみに文脈はかっこを貫通して伝わってきます。
なので、下記のように右辺でかっこを使ってもちゃんとok値が発生します。
v, ok := (x.(T))
v, ok := (mymap["key"])
x, ok := (<-ch)
たくさんかっこで囲っても、ちゃんと「ok値必要文脈」が左から右に伝搬します。
v , ok := ((((((x.(T)))))))
コンパイラ書くのがちょっとめんどい かわいいですね
interface要求文脈
具象型の値がインタフェース型の値に化ける(Convert)のも、文脈よりinterface要求が伝わるることによって発生すると考えることができます。
var i int
var ifc interface{} = i // int が interface{} にコンバートされる
わかりやすい例は Printf でしょう。
var str string = "world"
fmt.Printf("hello %s", str)
Printf の2番目以降の引数は interface{}
であると 関数シグネチャで定義されているので、str は 関数呼び出しをする前にいったん interface{} 型に化けます。
つまり、実質的にこのようになっていると考えることができます。
var str string = "world"
fmt.Printf("hello %s", interface{}(str))
なお、既に interface{} になっているものを渡す場合はそのまま渡されます。
var ifc interface{} = "world"
fmt.Printf("hello %s", ifc)
コンパイラを作るときは最初から意識しておこう
この文脈によって決まるという振る舞いは、野良コンパイラ作者にとっては最初に考慮しておかないといけない問題です。何故かというと値は右から左に伝搬する一方で、文脈は左から右に伝搬するというのが意外と難しいからです。(意味解析とコード生成をちゃんと分離してないコンパイラだとカオスになりがち)
自作コンパイラでの実例
拙作コンパイラでは、実際に evalContext (評価文脈) という値を伝搬させて式の評価をしてます。
nil の評価 : https://github.com/DQNEO/babygo/blob/5bf54aa1f3ed7ae966d82c200c3d8b91e7cf2b43/main.go#L870
ok構文のok値:
interfaceへの暗黙の変換:
まとめ
Go言語の式が産出する値やその型は、文脈によって変わる。
以上の事柄は私がコンパイラを書いていて気づいたことですが、Goユーザにとっても仕様を深く理解する手がかりになると思います。
最後まで読んでいただきありがとうございます。
-
仕様上は必ずしもゼロ埋めしないといけないわけではありません。コンパイラの実装次第です。 ↩︎
Discussion
仕様によると blank identifier の場合もそうなるようです
あとは switch 文:
Hoshi さん ご指摘ありがとうございます!
the blank identifier への代入ということは値が捨てられるだけなのに、型の仕様がちゃんと明記されてるのが面白いですね。
見逃してました。追記しておきます。