⚙️

ポインタを使わずに書いてから分かった、Goのポインタが必要な理由

に公開

Goのポインタレシーバーはなぜ便利なのか?実装比較で理解する

はじめに

Goを学んでいると「ポインタレシーバーを使うべき」という話をよく聞きます。しかし、なぜポインタレシーバーが便利なのか、実際のコードで比較してみないと理解しにくいかもしれません。

この記事では、同じウォレットシステムをポインタなし版とポインタあり版で実装し、比較することで、ポインタレシーバーの利点を明確に示します。

実装するシステム

小さなウォレットシステムを実装します:

  • Account: 1口座の状態(残高)とルール(バリデーション)を管理
  • Wallet: 全体の窓口として機能し、Accountのルールに委譲、履歴管理を担当
  • 入金・出金機能
  • 取引履歴の管理

ポインタなし版の実装

まず、ポインタを使わない実装を見てみましょう。

Accountの実装

type Account struct {
    balance int // 残高(非公開)
}

// 値レシーバーを使用
func (a Account) Deposit(amount int) (Account, error) {
    if amount <= 0 {
        return a, errors.New("入金額は0より大きい必要があります")
    }
    // 新しいAccountを作成して返す必要がある
    return Account{balance: a.balance + amount}, nil
}

func (a Account) Withdraw(amount int) (Account, error) {
    if amount <= 0 {
        return a, errors.New("出金額は0より大きい必要があります")
    }
    if a.balance < amount {
        return a, errors.New("残高が不足しています")
    }
    // 新しいAccountを作成して返す必要がある
    return Account{balance: a.balance - amount}, nil
}

Walletの実装

type Wallet struct {
    account      Account       // 値型
    transactions []Transaction // 値のスライス
}

// 値レシーバーを使用
func (w Wallet) Deposit(amount int) (Wallet, error) {
    // 新しいAccountを受け取る
    newAccount, err := w.account.Deposit(amount)
    if err != nil {
        return w, err
    }

    // スライスをコピーして新しいスライスを作成
    newTransactions := make([]Transaction, len(w.transactions), len(w.transactions)+1)
    copy(newTransactions, w.transactions)
    newTransactions = append(newTransactions, NewTransaction(...))

    // 新しいWalletを作成して返す
    return Wallet{
        account:      newAccount,
        transactions: newTransactions,
    }, nil
}

使用例

wallet, err := NewWallet(1000)

// 毎回新しいWalletを受け取る必要がある
wallet, err = wallet.Deposit(500)
wallet, err = wallet.Withdraw(300)
wallet, err = wallet.Deposit(200)

ポインタなし版の問題点

  1. 毎回新しい構造体を返す必要がある

    • メモリ効率が悪い
    • コードが冗長になる
  2. 呼び出し側で毎回代入が必要

    • wallet, err = wallet.Deposit(500)のように毎回代入が必要
    • 代入を忘れると状態が更新されない(バグの原因)
  3. スライスのコピーが発生

    • 履歴追加のたびに既存のスライスをコピーする必要がある
    • パフォーマンスが悪い
  4. コードが複雑になる

    • 新しい構造体を作成する処理が必要
    • エラーハンドリングが複雑になる

ポインタあり版の実装

次に、ポインタを使った実装を見てみましょう。

Accountの実装

type Account struct {
    balance int // 残高(非公開)
}

// ポインタレシーバーを使用
func (a *Account) Deposit(amount int) error {
    if amount <= 0 {
        return errors.New("入金額は0より大きい必要があります")
    }
    // 直接状態を変更できる
    a.balance += amount
    return nil
}

func (a *Account) Withdraw(amount int) error {
    if amount <= 0 {
        return errors.New("出金額は0より大きい必要があります")
    }
    if a.balance < amount {
        return errors.New("残高が不足しています")
    }
    // 直接状態を変更できる
    a.balance -= amount
    return nil
}

Walletの実装

type Wallet struct {
    account      *Account       // ポインタ
    transactions []*Transaction // ポインタのスライス
}

// ポインタレシーバーを使用
func (w *Wallet) Deposit(amount int) error {
    // Accountのルールに委譲
    if err := w.account.Deposit(amount); err != nil {
        return err
    }
    // 直接スライスに追加できる
    w.transactions = append(w.transactions, NewTransaction(...))
    return nil
}

使用例

wallet, err := NewWallet(1000)

// シンプルに呼び出せる
err = wallet.Deposit(500)
err = wallet.Withdraw(300)
err = wallet.Deposit(200)

ポインタあり版の利点

  1. 直接状態を変更できる

    • 新しい構造体を作る必要がない
    • メモリ効率が良い
  2. 呼び出しがシンプル

    • err = wallet.Deposit(500)のようにシンプル
    • 代入を忘れる心配がない
  3. スライスのコピーが不要

    • appendで直接追加できる
    • パフォーマンスが良い
  4. コードが簡潔

    • 新しい構造体を作成する処理が不要
    • エラーハンドリングがシンプル

実装の比較

コードの比較

項目 ポインタなし版 ポインタあり版
レシーバー 値レシーバー (w Wallet) ポインタレシーバー (w *Wallet)
戻り値 (Wallet, error) error
使用例 wallet, err = wallet.Deposit(500) err = wallet.Deposit(500)
メモリ効率 毎回コピーが発生 ポインタで効率的
コードの簡潔性 冗長 シンプル

Deposit メソッドの比較

ポインタなし版(13行)

func (w Wallet) Deposit(amount int) (Wallet, error) {
    newAccount, err := w.account.Deposit(amount)
    if err != nil {
        return w, err
    }
    newTransactions := make([]Transaction, len(w.transactions), len(w.transactions)+1)
    copy(newTransactions, w.transactions)
    newTransactions = append(newTransactions, NewTransaction(TransactionTypeDeposit, amount))
    return Wallet{
        account:      newAccount,
        transactions: newTransactions,
    }, nil
}

ポインタあり版(7行)

func (w *Wallet) Deposit(amount int) error {
    if err := w.account.Deposit(amount); err != nil {
        return err
    }
    w.transactions = append(w.transactions, NewTransaction(TransactionTypeDeposit, amount))
    return nil
}

ポインタあり版の方が約2分の1のコード量で実装できています。

なぜポインタレシーバーを使うべきか

1. メモリ効率

ポインタを使うことで、構造体のコピーを避けられます。特に大きな構造体や、頻繁に状態を変更する場合に効果的です。

2. コードの簡潔性

状態変更時に新しい構造体を返す必要がなく、直接状態を変更できます。コードがシンプルになり、可読性が向上します。

3. バグの防止

呼び出し側で代入を忘れる心配がありません。ポインタあり版では、メソッドを呼び出すだけで状態が更新されます。

4. パフォーマンス

スライスのコピーが不要で、appendで直接追加できます。大量のデータを扱う場合にパフォーマンスの差が顕著になります。

まとめ

この記事では、同じウォレットシステムをポインタなし版とポインタあり版で実装し、比較しました。

ポインタなし版の問題点:

  • 毎回新しい構造体を返す必要がある
  • 呼び出し側で毎回代入が必要
  • スライスのコピーが発生
  • コードが複雑になる

ポインタあり版の利点:

  • 直接状態を変更できる
  • 呼び出しがシンプル
  • スライスのコピーが不要
  • コードが簡潔

実務では、状態を変更するメソッドにはポインタレシーバーを使うことが推奨されます。この比較を通じて、ポインタレシーバーの利点を理解していただければ幸いです。

参考コード

実際のコードは以下のリポジトリで確認できます:

Discussion