Go のポインタと関数 - 値渡しとポインタ渡しの違い

Go の関数は引数を常に「値渡し」で受け取ります。これは変数のコピーが作られるということであり、関数の中で引数を変更しても呼び出し元には影響が及びません。ポインタはこの制約を越えるための仕組みです。

値渡しの挙動

まず値渡しの基本動作を確認します。

package main

import "fmt"

func double(n int) {
    n = n * 2
    fmt.Println("関数内:", n)
}

func main() {
    x := 10
    double(x)
    fmt.Println("呼び出し元:", x)
}

出力は「関数内: 20」「呼び出し元: 10」になります。double に渡された nx のコピーであり、n を変更しても x には一切反映されません。

x の値 10 が double に渡される

x のコピーとして n が作られる

n を 20 に変更する

x は 10 のまま変わらない

この挙動は Go のすべての型に共通です。構造体であっても配列であっても、関数に渡す時点で丸ごとコピーされます。

ポインタ渡しで呼び出し元を変更する

呼び出し元の変数を関数から変更したい場合は、変数のアドレスを渡します。

package main

import "fmt"

func double(p *int) {
    *p = *p * 2
}

func main() {
    x := 10
    double(&x)
    fmt.Println(x) // 20
}

&xx のアドレスを渡し、関数側で *p を通じてそのアドレスに格納された値を直接書き換えています。ポインタ自体はコピーされますが、コピーされたポインタも同じアドレスを指しているため、デリファレンス先は同一のメモリ領域です。

構造体の値渡しとポインタ渡し

構造体はフィールドが多いほどコピーのコストが大きくなります。

type Config struct {
    Host    string
    Port    int
    Debug   bool
    Timeout int
}

func enableDebug(c Config) {
    c.Debug = true
}

func main() {
    cfg := Config{Host: "localhost", Port: 8080}
    enableDebug(cfg)
    fmt.Println(cfg.Debug) // false
}

enableDebugConfig のコピーを受け取るため、c.Debug = true としても元の cfg には反映されません。ポインタを渡せば解決します。

func enableDebug(c *Config) {
    c.Debug = true
}

func main() {
    cfg := Config{Host: "localhost", Port: 8080}
    enableDebug(&cfg)
    fmt.Println(cfg.Debug) // true
}

Go ではポインタ経由の構造体フィールドアクセスに自動デリファレンスが働くため、(*c).Debug と書く必要はなく、c.Debug で直接アクセスできます。

値渡し

構造体全体のコピーが作られる。関数内の変更は呼び出し元に影響しない。フィールド数が多いとコピーコストが増す。

ポインタ渡し

アドレス(8 バイト程度)のみがコピーされる。関数内の変更が呼び出し元に反映される。構造体のサイズに関係なくコストは一定。

ポインタ渡しが適さないケース

ポインタ渡しは万能ではありません。値が変更されないことを保証したい場合は、あえて値渡しにするほうが安全です。

type Point struct {
    X, Y int
}

func distance(a, b Point) float64 {
    dx := float64(a.X - b.X)
    dy := float64(a.Y - b.Y)
    return math.Sqrt(dx*dx + dy*dy)
}

distance は 2 つの Point を読み取るだけで変更しません。値渡しにすれば、呼び出し元のデータが意図せず壊れる心配がなくなります。小さな構造体であればコピーのコストも無視できる程度です。

スライスやマップは値渡しでも共有される

スライスやマップは内部にポインタを持つ「参照型」です。値渡しであっても、背後のデータは共有されます。

package main

import "fmt"

func appendItem(s []int) {
    s[0] = 999
}

func main() {
    nums := []int{1, 2, 3}
    appendItem(nums)
    fmt.Println(nums) // [999 2 3]
}

スライスのヘッダ(ポインタ・長さ・容量の 3 つ組)はコピーされますが、ヘッダ内のポインタが指す配列本体は同じものです。そのため、要素の書き換えは呼び出し元にも反映されます。

ただし、append で容量を超えた場合は新しい配列が確保されるため、呼び出し元のスライスとは別のデータを指すようになります。スライスの長さを関数内で変更して呼び出し元にも反映したい場合は、スライスのポインタを渡すか、戻り値として返す必要があります。

func appendItem(s *[]int) のようにポインタを渡すか、s = append(s, item) の結果を return で返すパターン。

関数の戻り値としてのポインタ

Go では関数内で作ったローカル変数のポインタを返しても問題ありません。

func newConfig() *Config {
    cfg := Config{
        Host: "localhost",
        Port: 8080,
    }
    return &cfg
}

C 言語ではローカル変数のアドレスを返すとダングリングポインタになりますが、Go のコンパイラはエスケープ解析によって cfg をヒープに配置します。関数のスタックフレームが消えてもデータは残り続けるため、返されたポインタは安全に使えます。

この仕組みのおかげで、Go ではコンストラクタ的な関数で &T{...} を返すパターンが広く使われています。値渡しとポインタ渡しの使い分けは、「変更が必要か」「コピーコストは許容範囲か」「データの所有権を明確にしたいか」という 3 つの観点で判断するのが実践的です。