Go言語でSOLID原則

SOLID原則とは

SOLID原則とは、OOPにおいて、メンテナンス、拡張しやすいシステムを設計・構築するための原則のことです。 アメリカのソフトウェアエンジニアである、ロバート C. マーティンが提唱していた多くの設計原則の中からチョイスされた5つの原則の頭文字をとって命名された原則となります。

  • S :単一責任原則
  • O : オープン/クローズの原理
  • L : リスコフ置換原理
  • I : インターフェース分離の原理
  • D : 依存関係逆転の原理

単一責任原則

変更するための理由が、一つのクラスに対して一つ以上あってはならない

package main

import (
    "fmt"
    "math"
)

type circle struct {
    radius float32
}

func (c circle) area() {
    fmt.Printf("circle area: %f\n", math.Pi*c.radius*c.radius)
}

type square struct {
    sideLen float32
}

func (s square) area() {
    fmt.Printf("circle area: %f\n", s.sideLen*s.sideLen)
}

func main() {
    c := circle{radius: 5}
    c.area()

    s := square{sideLen: 2}
    s.area()
}

上記のコードは「単一責任の原則」を破っている箇所があります。 areaメソッドのfmt.Printf("circle area: %f\n",....この箇所です。 このコードは面積を計算し、その結果を表示しています。複数の責任があることがわかります。

それでは「単一責任の原則」を適用したいと思います。 メソッドを修正して、1つの責任(この場合は面積の計算)だけを行うように変更します。

package main

import (
    "math"
)

type circle struct {
    radius float32
}

func (c circle) area() float32 {
    return math.Pi * c.radius * c.radius
}

type square struct {
    sideLen float32
}

func (s square) area() float32 {
    return s.sideLen * s.sideLen
}

出力を処理する新しい型を定義します。そして、文字列の変換を新しい型とoutPrinterというメソッドに委ねます。

import (
    "fmt"
    "math"
)

type shape interface {
    area() float32
}

type outPrinter struct {}

func (op outPrinter) toText(s shape) string {
    return fmt.Sprintf("the area is: %f", s.area())
}

shapeインターフェースを引数として渡してますが、これはインターフェースのメソッドを実装している任意の型を(この場合、areaメソッドを定義しているsquareとcircle型)使用するのに役立ちます。

func main() {
    c := circle{radius: 5}
    c.area()

    s := square{sideLen: 2}
    s.area()

    out := outPrinter{}
    fmt.Println(out.toText(c))
    fmt.Println(out.toText(s))
}

mainでは次にように呼び出すことができます。outPrinter型は、必要な文字列出力を生成するために必要なすべての機能を定義します。

オープン/クローズの原理

package main

import (
    "fmt"
    "math"
)

type circle struct {
    radius float32
}

type square struct {
    sideLen float32
}

type calculator struct {
    total float32
}

func (c calculator) sumAreas(shapes ...interface{}) float32 {
    var sum float32

    for _, shape := range shapes {
        switch shape.(type) {
        case circle:
            r := shape.(circle).radius
            sum += math.Pi * r * r
        case square:
            l := shape.(square).sideLen
            sum += l * l
        }
    }

    return sum
}

func main() {
    c := circle{radius: 5}
    s := square{sideLen: 2}
    calc := calculator{}
    fmt.Println("total of areas:", calc.sumAreas(c, s))
}

上記のコードでは、「オープン/クローズの原理」を守れていません。 理由は、電卓型はsumAreasメソッドを持っていて、引数としてinterface{}shapesを定義しています。というのも、例えば新しshapeであるtriangleを定義するときにsumAreasメソッドを新しいタイプを処理するように修正する必要があるからです。 コードを改善するために、下記のアプローチをとりたいと思います。 - shapeインターフェースは、メソッド領域で定義 - サンプルの各型は、areaというメソッドを定義

type shape interface {
    area() float32
}

type circle struct {
    radius float32
}

func (c circle) area() float32 {
    return math.Pi * c.radius * c.radius
}

type square struct {
    sideLen float32
}

func (s square) area() float32 {
    return s.sideLen * s.sideLen
}

type triangle struct {
    height float32
    base   float32
}

func (t triangle) area() float32 {
    return t.base * t.height / 2
}

他の必要な変更は、電卓のsumAreasメソッドで、今回、引数はshapeとして定義され、swith文は削除され、代わりにコードは各shapeからareaメソッドを実行するようになりました。

type calculator struct {
    total float32
}

func (c *calculator) sumAreas(shapes ...shape) float32 {
    var sum float32

    for _, shape := range shapes {
        sum += shape.area()
    }
    return sum
}

func main() {
    c := circle{radius: 5}
    s := square{sideLen: 2}
    t := triangle{height: 10, base: 5}

    calc := calculator{}
    fmt.Println(calc.sumAreas(c, s, t))
}

リスコフ置換原理

S が T の派生型であれば、プログラム内で T 型のオブジェクトが使われている箇所は全て S 型のオブジェクトで置換可能

Goには継承はありませんが、コンポジションはあります。ケースによりますが、上手く「リスコフ置換原理」適用できるケースもあります。

package main

import "fmt"

type transport interface {
    getName() string
}

type vehicle struct {
    name string
}

func (c vehicle) getName() string {
    return c.name
}

type car struct {
    vehicle
    wheel int
    gates int
}

type motorcycle struct {
    vehicle
    wheel int
}

type printer struct {}

func (printer) printTransportName(p transport) {
    fmt.Println("Name: ", p.getName())
}

上記のコードでは、次のように解釈することが可能かと思います。 - このコードでは、インターフェースgetNameを定義するvehicle型を定義しています。 - 「車」と「オートバイ」はコンポジションを使用して「車両」にアクセスできます。つまり、メンバ変数にアクセスする以外に、車とオートバイは「車両」メソッドにアクセスできます。 - プリンターは、車両、車、オートバイに使用できます。 車とオートバイは乗り物として置き換えることができるため、「リスコフ置換原理」適用されてると言える。

インターフェース分離の原理

クライアントに、クライアントが利用しないメソッドへの依存を強制してはならない

package main

import "math"

type shape interface {
    area() float64
    volume() float64
}

type square struct {
    sideLen float64
}

func (s square) area() float64 {
    return s.sideLen * s.sideLen
}

func (s square) volume() float64 {
    return 0
}

type cube struct {
    sideLen float64
}

func (c cube) area() float64 {
    return math.Pow(c.sideLen, 2)
}

func (c cube) volume() float64 {
    return math.Pow(c.sideLen, 3)
}

上記のコードは、2つのメソッド(面積と体積)をもつインターフェースが定義され,正方形と立方体の2つの図形を定義し、それぞれにarea()volume()というメソッドを実装しています。

func areaSum(shapes ...shape) float64 {
    var sum float64
    for _, s := range shapes {
        sum += s.area()
    }
    return sum
}

func areaVolumeSum(shapes ...shape) float64 {
    var sum float64
    for _, s := range shapes {
        sum += s.area() + s.volume()
    }
    return sum
}

上記コードには、2つの関数があります。面積の合計と体積の合計を計算する関数です。 コードは正常に動作しますが、「クライアントは自分が使わないメソッドに依存することを強いられるべきではない」という原則から考えてみると、次のような点が浮き彫りになるかと思います。 - 正方形は平らな形状なので体積法は必要ない - オブジェクト形状であるため、立方体のみボリュームメソッドを必要とする 以上の点を踏まえてコードを次のように修正してみます。 - area()メソッドを定義するためにshapeという名前の新しいインターフェイスを追加する。 - volume()メソッドを定義するためのobjectsインターフェースを追加し、area()メソッドを使用できるようにshapeインターフェースと合成する。

type shape interface {
    area() float64
}

type object interface {
    shape
    volume() float64
}

type square struct {
    sideLen float64
}

func (s square) area() float64 {
    return math.Pow(s.sideLen, 2)
}

type cube struct {
    square
}

func (c cube) volume() float64 {
    return math.Pow(c.sideLen, 3)
}

上記のコードでは、squareはarea()メソッドを、cubeはarea()メソッドとvolume()メソッドを実装しており、「インターフェース分離の原則」に従っています。

依存関係逆転の原理

上位のモジュールは下位のモジュールに依存してはならない。どちらのモジュールも「抽象」に依存すべきである。「抽象」は実装の詳細に依存してはならない。実装の詳細が「抽象」に依存すべきである

おそらくコードで例を挙げたほうがわかりやすいかと思いますので、コードを見ながら説明したいと思います。

type MySQL struct {
    //
}

func (db MySQL) QuerySomeData() []string {
    return []string{"inf1", "inf2", "inf3"}
}

type MyRepository struct {
    db MySQL
}

func (r MyRepository) GetData() []string {
    return r.db.QuerySomeData()
}

func main() {
    mysqlDB := MySQL{}
    repo := MyRepository{db: mysqlDB}
    fmt.Println(repo.GetData())
}

上記のコードを、「依存関係逆転の原理」に当てはめて、コードを修正すると以下のようになります。 - データベースのメソッドを定義する新しいインターフェースを追加 - リポジトリを変更し、代わりにタイプ MySQLのメンバ変数を定義することで、インターフェイスである抽象化を定義

原則に基づいて適用したコードはこちらとなります。

type DBConn interface {
    QuerySomeData() []string
}
type MySQL struct {
    //
}

func (db MySQL) QuerySomeData() []string {
    return []string{"inf1", "inf2", "inf3"}
}

type MyRepository struct {
    db DBConn
}

func (r MyRepository) GetData() []string {
    return r.db.QuerySomeData()
}

func main() {
    mysqlDB := MySQL{}
    repo := MyRepository{db: mysqlDB}
    fmt.Println(repo.GetData())
}

まとめ SOLIDの原則を適用することで、アプリケーションの基礎を正しく定義しているため、より強固でスケーラブル、そして変更に耐えられるコードを書くことができるようになります。