📝

Goのポインタ・メソッド・インターフェースの基本

2023/12/16に公開

はじめに

Goについて勉強していくなかで、ポインタやメソッド、インターフェースに対する理解が曖昧だったのでまとめてみました。今回の記事はスターティングGo言語という書籍のChpter5 構造体とインターフェースの内容に添いながら気になった部分を深掘りしてまとめたものになります。
https://www.amazon.co.jp/スターティングGo言語-CodeZine-BOOKS-松尾-愛賀/dp/4798142417

Goのポインタとは

まずポインタについてですが、Goにおけるポインタとは値型に分類されるデータ構造のメモリ上のアドレスを保持する変数のことです。ポインタは、ポインタを使って操作する型の前に「*」を付けることで定義できます。

// int型のポインタ
var a *int

// float564型のポインタ
var b *float64

定義のみを行ったポインタの変数の初期値はnilになります。

var a *int
fmt.Println(a) // nilが出力される

アドレス演算子と言われる「&」を使って、任意の型からそのポインタ型を生成することもできます。

var a int
b := &a
fmt.Printf("%T", b) // *intが出力される。

ポインタ型の変数から、格納されている値を参照するためには「*」をポインタ型の変数の前に置く必要があります。この仕組みをデリファレンスと言います。

var a int
b := &a
a = 1
fmt.Println(*b) // 1が出力される
*b = 2 //*bとすることでb(aのメモリ)に格納されている値(a、この時点では1)を操作できる。
fmt.Println(a) // 2が出力される

このような性質のあるGoのポインタですが、実際にどのような場面で使われるのかについて書いていきます。

関数の引数

Goの関数で引数を取る場合、引数が値型の場合には値渡しになるため同じ領域のメモリの値を共有することができません。そのため、ポインタを介して1つのメモリ上の値を共有することになります。ここでデータの渡し方である値渡しと参照渡しについて解説します。

値渡しと参照渡し

値渡し
値渡しは、関数に引数を渡す際にその値のコピーを作成する方法です。このコピーは元の値とは独立しており、関数内での変更は元の値に影響しません。

参照渡し
参照渡しは、関数に引数のメモリのアドレス(参照)を渡す方法です。参照渡しの場合は、関数は元の値を参照するため、値を直接変更できます。

値渡しを行った場合は元の値が変更できないため、元の値を変更したい場合はポインタを利用して参照渡しを行う必要があります。以下が実際の動作例です。

// ポインタを利用していないので値渡しになる。引数はコピーされた値となり、元の値は更新されない。
func plus(a int) {
	a = a + 1
}

// ポインタを利用して参照渡しを行うため、元の値を更新できる。
func plusv2(a *int) {
	*a = *a + 1
}

func main() {
	x := 1
	plus(x)
	fmt.Println(x) // 値が更新されず、1が出力される
	plusv2(&x)
	fmt.Println(x) // 値が更新され、2が出力される
}

メモリに格納されている値を図に表すと以下のようになります。

図を参考にしながら実際に行われている動作の詳細を説明します。
xには実際の値1が格納されており、&xにはxのメモリ上のアドレス0xc000012028が格納されています。xをplus関数の引数に渡すとxに格納されている値である1のコピーが渡されるため、xの値は更新されません(plus関数はコピーされた値を処理しているだけであるため)。

一方、xのメモリ上のアドレスである&xを受け取ったplusv2関数は&xに格納されたアドレスが示すメモリの値(コピーされたものではなくアドレス0xc000012028に格納されている1という値)を更新します。*a = *a + 1というのは*&x = *&x + 1を処理しているイメージですね。直接メモリ上の値に変更を加えているため、plusv2関数を利用すればxの値を更新することができるのです。

あまり利用することは無いかもしれませんが、ポインタ型の値(アドレス)もfmtパッケージを利用して出力することは可能です。

a := 1
fmt.Println(&a) // 0xc000012028のようなメモリのアドレスが出力される

構造体

Goにおける構造体は複数の型のデータを1つにまとめたものです。構造体は、関連するデータをグループ化し、管理しやすくするために使用されることが多いです。

構造体はtype 構造体名 struct {任意のフィールド}のようにして定義します。
以下は具体例です。

type Person struct {
   Name   string 
   Gender string
   Age    int
}

型が同じフィールドであれば複数のフィールドを一括で定義することも可能です。

type Person struct {
   Height, Weight   int 
}

構造体のフィールドの値を参照するにはドット「.」を使用します。

type Person struct {
   Name   string 
   Gender string
   Age    int
}

func main() {
	var p Person
	p.Name = "Bob"
	p.Gender = "man"
	p.Age = 30
	fmt.Println(p.Name) // Bobが出力される
}

上のmain関数内の処理は、複合リテラルを使用して以下のように書くこともできます。

p := Person {
    Name: "Bob",
    Gender: "man",
    Age:  30,
}
fmt.Println(p.Name) // Bobが出力される

Goにおける複合リテラルとは、構造体型の値を作成するための構文です。複合リテラルを使用することでフィールドに個別に値を設定するよりも簡潔にコードを書くことができます。複合リテラルは上のコードのようにフィールド名を明示的に指定して書くこともできれば、フィールド名を省略して書くことも可能です。

p := Person {
    "Bob",
    "man",
    30,
}

この場合、構造体のフィールド定義の順番と、設定する値の順番を完全に一致させなければなりません。そのため、構造体の定義に変更があった場合に値がズレてしまう可能性があります。コードの柔軟性や保守性を担保するためにも特別な理由がなければフィールド名を明示的に指定して書くようにしましょう。

構造体を含む構造体

Goでは構造体の中に構造体を入れ込むこともできます。

type Hobby struct {
   Genre   string 
   Detail  string
}

type Person struct {
   Name   string 
   Gender string
   Age    int
   Hobby  Hobby  // Hobby型を入れ込む
}

func main() {
    p := Person {
         Name: "Bob",
         Gender: "man",
         Age:  30,
	 // 複合リテラル内で複合リテラルを定義
         Hobby: Hobby{
             Genre: "sports",
	     Detail: "baseball",
         },
    }
    fmt.Println(p.Name) // Bobが出力される
    fmt.Println(p.Hobby.Genre) // sportsが出力される
}

上のように入れ込まれた構造体型のフィールドはネストを追う(p.Hobby.Genreのように)ことでアクセスできます。また、Goの構造体ではフィールド名が一意になる場合に限ってフィールド名を省略して構造体の中に構造体を入れ込むことができます。上の例を書き換えてみます。

type Hobby struct {
   Genre   string 
   Detail  string
}

type Person struct {
   Name   string 
   Gender string
   Age    int
   Hobby  // フィールド名を省略してHobby型を入れ込む
}

func main() {
    p := Person {
         Name: "Bob",
         Gender: "man",
         Age:  30,
         Hobby: Hobby{
             Genre: "sports",
	     Detail: "baseball",
         },
    }
    fmt.Println(p.Name) // Bobが出力される
    fmt.Println(p.Genre) // sportsが出力される
}

こうすると、p.Hobby.Genreとしてアクセスしていた箇所をp.Genreと書くことができるようになります。ただ、もし仮にHobby構造体のなかにNameというフィールドが存在する場合はp.Hobby.Nameと書く必要があります(Person構造体の中にもNameというフィールドが存在し、全体としてNameという名前のフィールド名が一意では無くなるため)。

構造体とポインタ

構造体は値型のため関数の引数として渡した場合は値渡しとなり、元の構造体を操作することができません。そのため、構造体は基本的にポインタ型を経由して関数に渡されることになります。

type Point struct {
	A int
}

func double(p *Point) {
	p.A * 2
}

func main() {
	p := &Point{A: 1}
	result := double(p) // doubleの結果をresultに格納
	fmt.Println(result)  // 2が出力される
}

メソッド

Goにはメソッドという機能があります。Goにおけるメソッドとは、特定の型に関連付けられた関数のことを指し、関連付けられた型のインスタンスに対して操作を行うために使用されます。メソッドは、関数定義の一部としてレシーバーと呼ばれる特別な引数を持ちます。このレシーバーがメソッドを特定の型に関連付けるための重要な機能です。メソッドはレシーバーを通じて、関連付けられた型のインスタンスのフィールドにアクセスしたり、インスタンスの状態を変更したりすることができます。

type Person struct {
   Name   string 
   Gender string
   Age    int
}

// Renderメソッド (p *Person)のpがレシーバー
func (p *Person) Render() {
    fmt.Printf("%s, %s, %d\n", p.Name, p.Gender, p.Age)
}

func main() {
   p := &Person{Name: "Bob", Gender: "man", Age: 30}
   p.Render()
}

レシーバーには値レシーバーとポインタレシーバーの2種類が存在します。

値レシーバー

値レシーバーを使用すると、メソッド呼び出し時にレシーバーの値のコピーが作成されます。つまり、メソッド内でレシーバーを変更しても、元のインスタンスには影響しません。 小さな構造体や単純な値で、変更が不要な場合に適しています。

ポインタレシーバー

ポインタレシーバーを使用すると、レシーバーの値への参照が渡されます。これにより、メソッド内での変更がレシーバーの実際のインスタンスに影響します。 大きな構造体や変更が必要な場合に適しています。

値レシーバーとポインタレシーバーの違いはほとんど値渡しと参照渡しの違いと同じです。

定義されたメソッドはレシーバー.メソッド(上のコードでいうp.Render())という形式で呼び出すことができます。メソッドには任意の引数と戻り値を定義することも可能で、レシーバーの定義が必要なこと以外は基本的に関数と変わりはありません。

インターフェースとは

Goにおけるインターフェースは型の一種で、特定のメソッドを定義することで、異なる型に共通の振る舞いを提供するための機能を備えています。インターフェースを利用することでコードの柔軟性や再利用性を向上させることができます。

インターフェースの基本

宣言: インターフェースは、メソッドのシグネチャの集まりとして定義されます。これには具体的な実装は含まれません。
実装: Goでは、特定の型がインターフェースのすべてのメソッドを実装していれば、明示的に宣言することなくそのインターフェースを実装しているとみなされます。インターフェースを実装している特定の型の変数は、インターフェース型として使用することができるようになります。
多態性: インターフェースを使用すると、異なる型のオブジェクトを同じインターフェース型の変数に割り当てることができます。これにより、異なる型でも共通のインターフェースを介して操作することが可能になります。

Goの代表的なインターフェースにerrorインターフェースがあります。これは標準ライブラリに組み込まれており、エラー情報を表現するために用いられます。このerrorインターフェースを例にインターフェースの動きについて解説していきたいと思います。

// errorインターフェースの定義
type error interface {
    Error() string
}

errorインターフェースを実装した型を定義するためには以下のようなコードを書く必要があります。

type DevError struct { // --- ①
  Message string
}

func (e *DevError) Error() string { // --- ②
  return e.Message
}

func ErrorHandling() error { // --- ③
  return &DevError {Message: "エラーが発生しています"}
}

err := ErrorHandling() // --- ④
err.Error() // --- ⑤

このコードで何を行っているかを以下の表で解説しています。表の番号は番号は上のコード内の番号に対応しています。

番号 処理内容
errorインターフェースを実装するための構造体DevError型を定義しています。
errorインターフェースが要求するErrorメソッドをメソッドのシグネチャ(Error() string)通りに実装します。これでerrorインターフェースの実装は完了し、DevError型の変数はerror型として使用することができるようになります。
error型を返すErrorHandling関数を定義しています。インターフェースを実装している場合、インターフェース型(error)を返す関数(ErrorHandling)は、そのインターフェースを実装する任意の型の値(&DevError {Message: "エラーが発生しています"})を返すことができます。
ErrorHandlingが返す値をerr変数を定義して代入します。このerrはDevError型の値(&DevError {Message: "エラーが発生しています"})を保持していますが、実際はerror型の変数になります。(func ErrorHandling() error でエラー型として返されているため。)
errorインターフェースが保持するError関数を実行しています。ここで返る値は"エラーが発生しています"です。

インターフェースのメリット

インターフェースにはさまざまなメリットがあります。

異なる型に共通の性質を付与できる。

インターフェースを利用することで異なる型に共通の性質を持たせることができます。以下は三角形と円を定義した別々の型に面積を計算する共通のインターフェースを実装している例です。

type Shape interface {
    Area() float64
}

type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

type Triangle struct {
    Base   float64
    Height float64
}

func (t Triangle) Area() float64 {
    return 0.5 * t.Base * t.Height
}

こうすることで別々の型を一貫した方法で扱うことができます。たとえば異なる形状の集合をループしてそれぞれの面積を計算するような処理が簡単に行えるようになります。

func main() {
    shapes := []Shape{
        Circle{Radius: 5},
        Triangle{Base: 3, Height: 4},
        // ここに他の形状を追加することもできる
    }

    for _, shape := range shapes {
        fmt.Printf("面積: %v\n", shape.Area())
    }
}

コードの再利用と拡張性

インターフェースを利用することで、コードの再利用と拡張性が高まります。例えば、先ほどの例に四角形の面積を求める処理を追加したい場合も、既存のコードに大幅な変更を与えることなくスムーズに処理を追加することができます。以下のように四角形の型の情報とAreaインターフェースの実装、main関数への四角形の情報の追加を行うだけで処理を追加することができます。

// 四角形の型情報であるSquare構造体を定義
type Square struct {
    Vertical   float64
    Horizontal float64
}

// Square型にAreaインターフェースを実装
func (s Square) Area() float64 {
    return s.Vertical * s.Horizontal
}

func main() {
    shapes := []Shape{
        Circle{Radius: 5},
        Triangle{Base: 3, Height: 4},
        Square(Vertical:5, Horizontal:6) // Squareの情報を追加
    }

    for _, shape := range shapes {
        fmt.Printf("面積: %v\n", shape.Area())
    }
}

テストの容易さ

インターフェースを使用することで、モックを使用したテストが容易になります。たとえば、Shapeインターフェースを実装するモックを作成して、異なるシナリオでの面積計算ロジックをテストすることができます。具体的なテストの実装方法については今回は割愛します。

最後に

今回は書籍の内容をもとにGoの一部の機能について少し深掘ってみました。ポインタやメソッド、インターフェースの理解はGoを扱う上で必須なので、今後応用的な使い方も覚えていきたいです。

最後までご覧いただき、ありがとうございました。

参考

https://www.amazon.co.jp/スターティングGo言語-CodeZine-BOOKS-松尾-愛賀/dp/4798142417

Discussion