Goのインタフェースを使って、共通のメソッド・属性を持つ構造体を作る

以前Goで開発をしている時に、インタフェースと構造体の理解が曖昧でハマることがあったので、理解を深めるためのサンプルコードを書いてみました。

要件

サンプルコードは、掃除機の共通のインタフェース(操作)

  • ①電源を入れる(TrunOn)
  • ②電源を切る(TrunOff)
  • ③掃除する(Clean)

を持つダイソン掃除機、マキタ掃除機の構造体を作り、構造体毎に異なるインタフェース(操作)の実装を持たせ、main関数から呼び出すというものです。

構造体のデータに関しては同じだと面白くないので、ダイソン掃除機だけワイヤレス掃除機という想定で、バッテリー残量という属性を持っているとします。

要件を簡単に以下の図にまとめてみました。

f:id:moritamorie:20210104205952p:plain

実装

以下のように実装してみました。

ポイントは最後のmain()関数の中でダイソン掃除機、マキタ掃除機の構造体ポインタのレシーバの型が共通のインタフェース(IVacuum)になっている点です。

package main

import "fmt"

// インタフェース
type IVacuum interface {
    TurnOn()
    TurnOff()
    Clean()
}

// ダイソン掃除機・マキタ掃除機共通の構造体
type Vacuum struct {
    Maker   string
    Model   string
    Serial  string
    Running bool
}

// ダイソン掃除機の構造体
type Dyson struct {
    Battery int
    Vacuum
}

// マキタ掃除機の構造体
type Makita struct {
    Vacuum
}

// ダイソン掃除機構造体のメソッド定義
func NewDyson(Maker string, Model string, Serial string) *Dyson {
    dyson := &Dyson{Battery: 100}
    dyson.Maker = Maker
    dyson.Model = Model
    dyson.Serial = Serial
    return dyson
}

func (d *Dyson) TurnOn() {
    d.Running = true
    d.Battery = 1
    fmt.Println("ダイソンの掃除機:電源ONになりました。")
}

func (d *Dyson) TurnOff() {
    d.Running = false
    d.Battery = 0
    fmt.Println("ダイソンの掃除機:電源OFFになりました。")
}

func (d *Dyson) Clean() {
    if d.Running && d.Battery > 0 {
        fmt.Println("ダイソンの掃除機で掃除する!")
        d.Battery -= 1
    } else if d.Battery <= 0 {
        fmt.Println("ダイソンの掃除機:バッテリーがありません充電してください。")
    } else {
        fmt.Println("ダイソンの掃除機:電源を入れてください。")
    }
}

// マキタ掃除機構造体のメソッド定義
func NewMakita(Maker string, Model string, Serial string) *Makita {
    makita := new(Makita)
    makita.Maker = Maker
    makita.Model = Model
    makita.Serial = Serial
    return makita
}

func (d *Makita) TurnOn() {
    d.Running = true
    fmt.Println("マキタの掃除機:電源ONになりました。")
}

func (m *Makita) TurnOff() {
    m.Running = false
    fmt.Println("マキタの掃除機:電源OFFになりました。")
}

func (m *Makita) Clean() {
    if m.Running {
        fmt.Println("マキタの掃除機で掃除する!")
    } else {
        fmt.Println("マキタの掃除機:電源を入れてください。")
    }
}

func main() {
    // ダイソン掃除機、マキタ掃除機を作り、同じ操作をする
    vacuums := []IVacuum{
        NewDyson("Dyshon", "V7 Fluffy Origin", "839YQBX"),
        NewMakita("Makita", "AC100V", "192IWID"),
    }
    for _, vacuum := range vacuums {
        vacuum.Clean()
        vacuum.TurnOn()
        vacuum.Clean()
        vacuum.Clean()
        vacuum.TurnOff()
        fmt.Println("")
    }
}

このコードを実行すると以下のように出力され、main関数から呼び出した際に同じ操作をしているにもかかわらず、異なる振る舞いをしていることがわかります。

$ go run main.go
ダイソンの掃除機:電源を入れてください。
ダイソンの掃除機:電源ONになりました。
ダイソンの掃除機で掃除する!
ダイソンの掃除機:バッテリーがありません充電してください。
ダイソンの掃除機:電源OFFになりました。

マキタの掃除機:電源を入れてください。
マキタの掃除機:電源ONになりました。
マキタの掃除機で掃除する!
マキタの掃除機で掃除する!
マキタの掃除機:電源OFFになりました。

値渡しと参照渡し

ハマっていたとき、値渡しと参照渡しを意識せずに以下のように書いていて、あれ、Runningがfalseになるのはなぜだ、、と思い、調べ始めたのでした。

type Dyson struct {
    Running bool
}

func NewDyson() Dyson {
    return Dyson{}
}

func (d Dyson) TurnOn() {
    d.Running = true
}

func (d Dyson) IsRugging() bool {
    return d.Running == true
}

func main() {
    vacuum := NewDyson()
    vacuum.TurnOn() // ここでRunningがtrueになる想定だった
    fmt.Println(vacuum.IsRugging()) // が、falseが返る
}

$ go run main.go
false

TurnOn, IsRunningメソッドのレシーバが値渡しになっているので、コンストラクで初期化したまま(Running: false)になっていて、メソッドが呼ばれた時にはコピーが渡されているので、変更が反映されないという現象が発生していました。

近年、RubyJavaのWeb開発案件が多かったせいか、値渡しなのか参照渡しなのかをあまり意識できなくなっているなと実感しました。。

f:id:moritamorie:20210106133659p:plain

これが何の役にたつのか?

最近goで作ったアプリで、とある商品のWebサイトをクロールして、実行結果を自分にメール通知するというアプリを作った際に似たような実装をしました。

クローラー自体は

  • インタフェース(操作)は同じ
  • 持つデータ構造もほぼ同じ
  • Webサイト毎にHTMLの構造が異なり、クロールの仕方が異なるのでクロールの仕方(Crawlメソッドの実装)は異なる

という特徴があり、ほぼサンプルと同じような実装にするとクローラを管理する機能をシンプルに実装できました。

f:id:moritamorie:20210105010235p:plain