Goのポインタで混乱しないためのコツ

EN / JA

Goは「読みやすい言語」と言われますが、初学者の頃は読み解くのに時間がかかりました。

特に戸惑ったのがポインタです。

var u User
var p *User = &u

例えばTypeScriptでは、オブジェクトを扱う際に明示的なポインタ型を意識することはほとんどありません。オブジェクトは参照を通して扱われますが、Goのようにポインタ型を * で表すことはありません。そのため、Goの * や & が最初は余計な情報に見えました。

しかし、いくつか読み方を整理すると、Goのコードもかなり読みやすくなりました。

* は出てくる場所で判断する

まず、* は文脈ごとに機械的に読むのが分かりやすいです。

型の位置にある *User は「User へのポインタ」です。

var p *User

式の位置にある *p は「p が指している値そのもの」を表します。これをデリファレンスと呼びます。

u := *p

読み取りだけでなく、*p = User{Name: "new"} のように代入先としても使えます。ただし、p が nil の場合は、どちらもデリファレンスの時点で panic になります。

そして、二項演算子として使われていれば、単なる掛け算です。

x := a * b

つまり、普段は次の程度に分類すれば十分です。

*T     → Tへのポインタ型
*p     → pのデリファレンス
a * b  → 掛け算

ポインタを使いたくなる場面

そもそも、なぜ * が出てくるのかも整理しておきます。

Goのstructは値型なので、関数に渡すとコピーされます。そのため、次の関数は呼び出し元の値には影響しません。書き換えているのはコピーだからです。

func Rename(u User) {
    u.Name = "new"
}

一方、ポインタを受け取れば、呼び出し元の User が書き換わります。

func Rename(u *User) {
    u.Name = "new"
}

つまり * が付いているときは、「どこかにある値を、コピーではなく書き換えたい」という意図が込められていることが多いです。後で出てくるpointer receiverに * が付くのも、基本的には同じ理由です。

TypeScriptは「常にポインタ」なのか

こう考えると、TypeScriptは常にポインタを渡しているように見えます。オブジェクトに関しては、その理解でおおむね合っています。

function rename(u: User) {
  u.name = "new"; // 呼び出し元にも反映される
}

Goで同じことをするには、*User を受け取る必要があります。

ただし、「参照が渡る」ことと「呼び出し元の変数そのものが渡る」ことは別です。 関数の中の u は、呼び出し元の u とは別の変数で、たまたま同じオブジェクトを指しています。 そのため、u に別のオブジェクトを代入しても、呼び出し元には届きません。

function replace(u: User) {
  u = { name: "new" }; // 呼び出し元には反映されない
}

図にすると、次のようになります。

呼び出した直後
  呼び出し元の u ──┐
  関数の中の u ────┴──→ { name: "a" }

u = { name: "new" } を実行したあと
  呼び出し元の u ──────→ { name: "a" }
  関数の中の u ────────→ { name: "new" }

先ほどの u.name = "new" が呼び出し元にも届いたのは、矢印の先にあるオブジェクトを書き換えたからです。 u = { name: "new" } で付け替わるのは、関数の中の u の矢印だけです。 関数が受け取っているのは参照のコピーであって、呼び出し元の変数そのものではありません。

これはGoのポインタでも同じです。 *User を受け取っても渡されるのはポインタのコピーなので、関数の中で u の矢印を付け替えても、呼び出し元は変わりません。

func Replace(u *User) {
    u = &User{Name: "new"} // 呼び出し元には反映されない
}

Goで呼び出し元まで届くのは、デリファレンスして指し先を書き換えたときです。

func Replace(u *User) {
    *u = User{Name: "new"} // 呼び出し元にも反映される
}

*u = ... は、u の矢印を別の User に付け替える操作ではありません。 矢印の先にある User を、フィールドごと上書きする操作です。 TypeScriptでこれに対応するのは u.name = "new" の側であって、u = { name: "new" } ではありません。

また、number や string などのプリミティブは参照ではありません。 値そのものがコピーされるため、Goのstructを値で渡したときと同じ挙動になります。

まとめると、関数に渡したときの挙動は次のように対応します。

TypeScriptのオブジェクト  → Goの *T : 書き換えが呼び出し元に届く
TypeScriptのプリミティブ  → Goの T  : 書き換えは呼び出し元に届かない

逆に、Goの T(structの値渡し)に当たるものは、TypeScriptにはありません。 コピーを渡したければ、{ ...u } のように自分で作ることになります。 ただしこれは浅いコピーなので、入れ子のオブジェクトは元と共有されたままです。

TypeScriptでは選ぶ余地がないため書く必要がなく、Goでは選べるため * として書かれている、という違いだと考えると整理しやすいと思います。

ポインタには実際にアドレスが入っている

ここまで「指している」と書いてきましたが、ポインタの中身は具体的な数値です。つまり、メモリ上のアドレスが入っています。

u := User{Name: "a"}
p := &u

fmt.Printf("%p\n", p) // 実行ごとに変わるが 0x1b78016a2020 のような値

一方、nil のポインタはこうなります。

var q *User
fmt.Printf("%p\n", q) // 0x0

2つを並べると、違いはアドレス欄に入っている数値だけです。

p(64ビット環境なら8バイト)
┌────────────────────┐
│ 0x00001b78016a2020 │ → User{Name: "a"} が置かれている番地
└────────────────────┘

q(同じく8バイト)
┌────────────────────┐
│ 0x0000000000000000 │ → どこも指していないことを表す 0
└────────────────────┘

ここで注意したいのは、nil という特別な値がアドレス欄に入っているわけではない、という点です。アドレス欄に入るのは数値だけです。その数値が0である状態を、Goのコードでは nil と書きます。

Goのコードでの書き方  → nil
メモリ上の中身        → 0
%p で表示した結果     → 0x0

同じ状態を、別のレイヤーの言葉で呼んでいるだけです。「アドレスがnil」ではなく「アドレスが0で、それをGoでは nil と呼ぶ」と読むと、混乱しにくくなります。

p と q の違いも、この整理で説明できます。

p == nil  → false : アドレス欄が0ではない
q == nil  → true  : アドレス欄が0
*p        → OK    : その番地に User の実体がある
*q        → panic : 0番地には何も置かれていない

0が「どこも指していない」印として使えるのは、0番地付近に有効なデータが置かれないよう予約されているからです。そのため、nil のデリファレンスは確実に panic になります。指し先のない場所を見に行こうとしているからです。

ただし、この数値を実際に扱う場面はほとんどありません。通常のGoにはポインタ演算がなく、p + 1 のようには書けないためです(unsafe パッケージを使えば可能ですが、日常的に書くコードには出てきません)。「アドレスが入っていて、それが指す先に実体がある」という程度に捉えておけば十分だと思います。

interface自体をポインタだと考えない

次のコードも最初は分かりにくく感じました。

p := &User{}
var x any = p

p の型は *User ですが、x の静的な型は any です。any は空のinterfaceの別名です。

ここでは、static typeとdynamic typeを分けると理解しやすくなります。

x
├─ static type:   any
├─ dynamic type:  *User
└─ dynamic value: p

つまり、x 自体の静的な型はポインタではありません。

x はinterface値で、その中に *User 型の値が入っています。

この違いは、前の節で見たメモリ上の姿からも確認できます。

var p *User
var x any

fmt.Println(unsafe.Sizeof(p)) // 8
fmt.Println(unsafe.Sizeof(x)) // 16

ポインタはアドレス1つ分の8バイトですが、interface値は16バイトあります。dynamic typeとdynamic valueの2つの欄を持っているためです。

p(ポインタ)
┌──────────┐
│ アドレス │
└──────────┘

x(interface値)
┌──────────────┬───────────────┐
│ dynamic type │ dynamic value │
│ *User        │ アドレス      │
└──────────────┴───────────────┘

x は単にポインタが入っている変数ではなく、「どの型の値なのか」という情報を別の欄に持っています。静的な型がポインタではない、というのはこういうことです。

なお、unsafe.Sizeof は中身を確認するために使っただけで、普段書くコードでは必要ありません。

TypeScriptの interface と見た目は似ていますが、TypeScriptの interface 宣言は通常、実行時の型情報として残りません。一方、Goのinterface値は実行時にも具体的なdynamic typeを持つ点が異なります。

nil はポインタ専用の言葉ではない

ここまで nil をポインタの話として扱ってきましたが、nil はポインタだけのものではありません。次の値はすべて nil と等しくなります。

var p *User          // nilポインタ
var s []int          // nilスライス
var m map[string]int // nilマップ
var f func()         // nil関数値
var i any            // nilインターフェース

fmt.Println(p == nil, s == nil, m == nil, f == nil, i == nil) // すべて true

nil は、これらの型のゼロ値をまとめて表す書き方です。

nil ─┬─ nilポインタ
     ├─ nilスライス
     ├─ nilマップ
     ├─ nilチャネル
     ├─ nil関数値
     └─ nilインターフェース

同じ nil でも、できることは型ごとに違います。nilスライスは長さを取れますし、append もできます。

var s []int

fmt.Println(len(s)) // 0
s = append(s, 1)    // 問題なく追加できる

nilポインタでは、そもそもデリファレンスができません。メモリ上の表現も型ごとに違うため、「nil ならアドレスが0」と一般化することもできません。アドレスが0なのは、中身がポインタのときの話です。

また、nil 自体には型がありません。

x := nil // コンパイルエラー: use of untyped nil in assignment

どの型のゼロ値なのかが決まらないため、これは書けません。var p *User = nil のように型が決まって初めて、意味が確定します。

整理すると、次の関係です。

nil         → 型を持たない識別子。ゼロ値の書き方であり、総称
nilポインタ → *T 型の具体的な値。アドレス欄が0のポインタ

「nil」と「nilポインタ」は同じものではなく、後者は前者の一例です。この区別が、次に見る x == nil の話に直接効いてきます。

nil もこの考え方で理解できる

例えば次のコードがあります。

var p *User = nil
var x any = p

fmt.Println(p == nil) // true
fmt.Println(x == nil) // false

同じ nil と比較しているように見えますが、結果が異なります。

まず、x は概念的には次のような状態です。

dynamic type  = *User
dynamic value = nil

この nil は、*User 型のnilポインタ値です。

ここで効いてくるのが、前の節で見た「nil は総称である」という点です。nil 自体には型がないため、比較する相手によって、どの型の nil として扱われるかが決まります。

p == nil では、p は *User 型なので、nil も *User のnilポインタ値として比較されます。そのため true です。

一方、x == nil の x はinterface型なので、この nil は型も値も入っていないinterface値(interfaceのゼロ値)として扱われます。x には *User というdynamic typeが入っているため、両者は等しくならず false になります。

先ほどの2つの欄で見ると、次の状態です。

x   : dynamic type = *User (非ゼロ) , dynamic value = 0
nil : dynamic type = なし  (ゼロ)   , dynamic value = 0

値の欄はどちらも0ですが、型の欄が違います。interfaceがゼロ値と等しくなるのは両方の欄が空のときだけなので、x == nil は false になります。x の中身がnilポインタであることと、x そのものが空であることは別だ、というのはこの2欄の違いです。

さらに、比較相手を明示的に *User 型のnilポインタ値にすると、

fmt.Println(x == (*User)(nil)) // true

となります。

この場合、比較相手の型は *User です。x のdynamic typeも *User で、格納されている値もnilポインタなので、両者は等しいと判定されます。

interfaceとinterfaceでない値を比較するときは、概念的に、interfaceでない側を一度そのinterface型に変換してから比べていると考えると分かりやすいです。仕様上の説明は「x のdynamic typeが相手の型と同一で、dynamic valueが等しいか」を見るというものですが、結果は同じです。変換後の姿を並べると、違いが見えます。

x            : dynamic type = *User , dynamic value = nil   ← これと比べる
nil          : dynamic type = なし  , dynamic value = なし  ← 型が違うので false
(*User)(nil) : dynamic type = *User , dynamic value = nil   ← 完全に同じなので true

なお、(*User)(nil) そのものはinterfaceではなく、*User 型のnilポインタ値です。比較のためにinterfaceへ変換された結果として、3行目の形になります。

また、interfaceの比較が常に安全なわけではありません。dynamic typeがslice、map、funcのように比較できない型だった場合、コンパイルは通りますが実行時に panic になります。

var a any = []int{1}
var b any = []int{1}

fmt.Println(a == b) // panic: comparing uncomparable type []int

nil との比較では、型が違う時点で結果が決まるため panic は起きません。interface値同士を比較するときだけ、中身の型にも注意が必要です。

話を戻すと、x の中身が変わったわけではありません。比較相手の型が変わったことで、結果が変わっています。

x は「*User というラベルが付いた、中身がnilの箱」だと考えると整理しやすくなります。中身がnilであることと、箱そのものに何も入っていないことは別です。

そして、この「箱」と「中身」の区別は、コードの挙動だけでなく、言葉の使い方にも効いてきます。ここまで x について「中身がnil」と書いてきましたが、口頭では「x はnilポインタ」と言ってしまいがちです。この言い方が、x == nil が false になる理由を分かりにくくしています。

「x はnilポインタ」と言えるのか

結論から言うと、厳密には x 自体をnilポインタとは言いません。nilポインタなのは、箱の中身の方です。

var p *User = nil
var x any = p

このとき、それぞれは次のような状態です。

p:
  型 = *User
  値 = nil
  → p は nilポインタ

x:
  型 = any
  dynamic type  = *User
  dynamic value = nilポインタ
  → x は interface値

つまり、x は「nilポインタを格納しているinterface」です。nilポインタである p と、それを格納した x は、そもそも別の種類の値だということになります。

前のセクションで見た、

p == nil // true
x == nil // false

という違いも、こう捉え直すと自然です。p はnilポインタなので、nilポインタと比較すれば一致します。x はinterface値なので、interfaceのゼロ値と比較され、*User というdynamic typeが入っている分だけ一致しません。比較のたびに x の中身を思い出すのではなく、p と x は別物だと押さえておけば十分です。

ただし、箱から中身を取り出せば、それはnilポインタです。取り出す操作が型アサーションです。

q := x.(*User)

fmt.Println(q == nil) // true

整理すると、次のようになります。

p  = nilポインタ
x  = nilポインタを格納したinterface
q  = xから取り出したnilポインタ

q は箱から出した後なので、p と同じく *User 型のnilポインタです。そのため q == nil は true になります。同じnilポインタでも、箱に入っている間はinterface値として扱われ、出せばポインタとして扱われる、という違いだけです。

したがって、表現としては「x はnilポインタ」ではなく、「x のdynamic valueがnilポインタ」 と言うのが正確です。単なる言い回しの違いに見えますが、* が付いた型とinterfaceを混ぜて呼ばないという点で、最初に整理した「*T はTへのポインタ」「interfaceは中身を持つ箱」という読み方の延長にあります。

pointer receiverとフィールドのポインタは別の話

次のようなコードにも戸惑いました。

type StripePayment struct {
    client *stripe.Client
}

func (p *StripePayment) Charge(amount int) error {
    // 課金処理
    return nil
}

一見すると、ポインタが何重にも重なっているように見えます。

しかし、2つの * は別の話です。

client *stripe.Client

これは、

StripePayment が stripe.Client へのポインタをフィールドとして持つ

という意味です。

一方、

func (p *StripePayment) Charge(...)

は、

Charge のreceiverが StripePayment へのポインタである

という意味です。

図にすると単純です。

p
│
▼
StripePayment
    │
    └─ client
         │
         ▼
     stripe.Client

TypeScriptで、

class StripePayment {
  constructor(private client: StripeClient) {}
}

と書く場合にも、概念的には似た参照関係があります。

TypeScriptでも client フィールドはオブジェクトへの参照を保持しますが、Goのポインタと同じものではありません。Goでは *stripe.Client のようにポインタ型であることが明示され、そのポインタ値は nil を取り得ます。この違いを踏まえたうえで、参照関係を読む手がかりの一つが * だと考えると理解しやすくなりました。

p.client はポインタのポインタではない

例えば、

func (p *StripePayment) Charge(amount int) error {
    p.client.DoSomething() // 説明用の仮のメソッド
    return nil
}

では、

p        : *StripePayment
p.client : *stripe.Client

です。

p がポインタだからといって、p.client が **stripe.Client になるわけではありません。

Goでは p.client を概念的に、

(*p).client

として扱います。

まず p が指す StripePayment を見て、その中の client フィールドを取得しているだけです。

なお、p が nil の場合は、(*p).client の評価で panic になります。

この例のinterfaceは「契約」として読む

例えば、

type Payment interface {
    Charge(amount int) error
}

があり、

payment.Charge(order.Total)

と書かれていたとします。

このとき、内部でSQLが実行されるのか、Stripe APIが呼ばれるのかまで考える必要はありません。

この例の命名からは、まずは、

payment に金額を渡して課金処理を依頼し、結果として error を受け取る

という設計意図として読めば十分です。実際に課金が行われることや、error がどのような条件で返るかまでは、interfaceの宣言だけでは保証されません。

必要になった場合だけ具体的な実装を確認します。

呼び出し箇所
    ↓
何をするのか

interface
    ↓
どのような契約なのか

具体的な実装
    ↓
どのように実現しているのか

この順番で読むと、細部に引っ張られにくくなります。

:= だけで型を推測しない

例えば、

user, err := repo.Find(userID)

だけを見て、user は *User だろうと判断することはできません。

Find が、

Find(id int) (*User, error)

なのか、

Find(id int) (User, error)

なのかを確認する必要があります。

Goは「一行を見るだけですべて分かる」言語ではありません。

ただし、宣言まで辿れば、

Find(id int) (*User, error)

のように型が明確に定義されています。

この点はGoの読みやすさの一つだと思います。

Goの読みやすさとは何か

TypeScriptも十分に型情報を持つ言語です。そのため、Goの利点を単純に「型が明示されているから」と説明するのは適切ではありません。

Goの場合は、

  • 値とポインタを区別する
  • interfaceを必要な操作の小さな契約として表現する
  • error を戻り値として明示する
  • 言語機能や書き方の種類を比較的少なくする

といった方向で、コードの解釈に必要な推測を減らしているように感じます。

一方で、:= による型推論や、アドレス取得可能な値に対して pointer receiver のメソッドを呼び出す際の暗黙的なアドレス取得など、すべてが表記上明示されているわけではありません。

そのため、Goを読む際には、

「内部では本当は何が起きているのか」

を常に考えるのではなく、

*T
→ Tへのポインタ

interface
→ 必要な操作の契約(この例のような基本的なinterface)

struct内の *T
→ Tへのポインタを持つフィールド

pointer receiver
→ `*T` をreceiverに持つメソッド

くらいの粒度で読むのがよさそうです。

TypeScriptでオブジェクト同士の参照を毎回メモリ構造まで展開しないのと同じです。

Goの「読みやすさ」は、一行一行が他の言語より簡潔という意味ではなく、必要になった情報を型やinterfaceの宣言から比較的素直に確認できることにあるのだと思います。

この見方に変えてから、Goの * も「読みにくい記号」ではなく、「必要な情報を明示する記号」としてスッキリと読めるようになりました。