LoginSignup
0
0

More than 3 years have passed since last update.

オブジェクト指向の本質とは何か考え、SOLID原則を理解する(Goのサンプルコード付き)

Last updated at Posted at 2021-01-16

何事も、何らかの問題を解決するために生み出された。今理解したい対象も。とすると...

ある抽象的な概念の本質は何かを明らかにするには、「それはどのような問題をどうやって解決したか。」という視点を持つと良い。

理解したい対象をAとすると、Aが解決した問題PへのAによる解決策Sに、Aの本質が表れている。
というのも、もし解決策Sが誕生した時点で存在が知られているものの中で解決策SAに特有でなければA以外の何かが既にその問題PSによって解決していたはずであるため、Aによる解決策SAが生まれるまでに存在しておらず、また解決策SAに特有であるからこそ、それは他のものとAを区別しうるAの特徴であり、それはつまり性質・本質という言葉の定義通りAの本質的な性質である。

上の文章のAをOOP、解決策SをOOPによるソフトウェア開発、Pをソフトウェア危機と読み替えてみると、以下のようになる。

OOPが解決したソフトウェア危機へのプログラムの部品化と再利用によりソフトウェア開発の効率を上げるというOOPによる解決策に、OOPの本質が表れている。
というのも、もしそのプログラムの部品化と再利用によるソフトウェア開発手法が誕生した時点で存在が知られているものの中でプログラムの部品化と再利用によるソフトウェア開発手法OOPに特有でなければOOP以外の何かが既にソフトウェア危機プログラムの部品化と再利用によるソフトウェア開発手法によって解決していたはずであるため、OOPによるプログラムの部品化と再利用によるソフトウェア開発手法OOPが生まれるまでに存在しておらず、またプログラムの部品化と再利用によるソフトウェア開発手法OOPに特有であるからこそ、それは他のものとOOPを区別しうるOOPの特徴であり、それはつまり性質・本質という言葉の定義通りOOPの本質かつ性質である。

本質
それなしにはその物が存在し得ない性質・要素
性質
他のものと区別しうる,そのもの本来のありかた。
要素
物事を成り立たせているもの。また、物事の成り立ちに関与している成分や性質。
物事
物と事。一切の有形、一切の無形の事柄。

ある抽象的な概念の本質は何かを明らかにするには、「それはどのような問題をどうやって解決したか。」という視点を持つと良い。
理解したい対象をAとすると、Aが解決した問題PへのAによる解決策Sに、Aの本質が表れている。
というのを閃いたけど、オブジェクト指向言語がそれまでに存在していた技術を再評価して生まれたということを考えると、ちょっと微妙な気もするがこの視点は新しいことを学ぶときなど何かと役立つ気がする。
少なくとも、こうやって考えるとGo言語にはクラスがないからオブジェクト指向言語ではないという意見はOOPの本質を意識していない的外れな意見であるように思える。

オブジェクト指向プログラミング

1 2
理解したい対象A オブジェクト指向プログラミング
Aが解決した問題P ソフトウェア危機、ソフトウェアの開発効率の低さ
PへのAによる解決策 プログラムの部品化と部品化したプログラムの再利用
実際にAでPを解決するための方法 OOP言語のクラス、ポリモーフィズム、継承などの機能

ソフトウェア危機
ソフトウェア開発に特有の困難性のために、開発者(ソフトウェア業界)が利用者(市場)の求める需要を満たすだけのソフトウェアを生産量・品質・費用・時間の面できちんと供給できない状況を示す言葉。
https://www.itmedia.co.jp/im/articles/0807/15/news127.html より引用

ソフトウェアの開発効率を高めることでこのソフトウェア危機を乗り越える必要があり、その解決に大きく貢献したのがオブジェクト指向プログラミング

オブジェクト指向プログラミングでは、プログラムの部品化と部品化したプログラムの再利用という方針でプログラムを作成することによって、開発効率を向上しようとした。オブジェクト指向でなぜ作るのかという有名な書籍でも、オブジェクト指向はソフトウェア開発を楽にする技術と言っている(第1章のタイトルになってる)。

OOPが生まれたはいいものの、オブジェクト指向プログラミングの概念を実現するために開発されたオブジェクト指向言語で柔軟なソフトウェアを開発することは可能ではあったが簡単ではなかった。
そこで、開発者が柔軟性・保守性・可読性を維持しながらのソフトウェア開発に役立つガイドラインがUncle Bobにより作成された。
Uncle Bobとは別の人物がこのガイドラインとUncle Bobの著作から5つの原則を抜き出し、頭文字を取ってSOLIDとしてまとめたものがSOLID原則である。

SOLID原則

1 2
理解したい対象(A) SOLID原則
Aが解決した問題P OOP言語を使用しても、柔軟性・保守性が高いソフトウェアの開発をするのが難しい
PへのAによる解決策 ソフトウェアの柔軟性・保守性の向上のために作成されたOOP言語での開発で従うべきガイドラインから特に重要なものを5つ選んでまとめた
実際にAでPを解決するための方法 原則に従ってコーディングをする、コードレビューする時に原則に従っているか確認する

SOLID原則の場合、Uncle BobがDesign Principles and Design PatternsでSOLIDより多くの原則を示し、他の人がDesign Principles and Design PatternsとUnble Bonの著作から5つを抜き出しSOLIDという名前でまとめたという経緯がありSOLID原則が概念というより具体的な原則を指すため、その本質部分もかなり具体的。OOPと比較するとその差は明確。

つまり、流れとしてはソフトウェア危機(のため開発効率上げたい)→オブジェクト指向プログラミング(は良いけど難しい)→SOLID原則(でOOPでの開発のルールを作ってOOPでの開発を簡単にしよう)

SOLID原則

ここからソースコードを例にSOLID原則の解説をしますが、例はほとんどこのYouTubeの動画から借りてきました。
参考になるので是非見てみてください。
https://www.youtube.com/watch?v=AKdvlr-RzEA

S: 単一責任の原則

1 2
理解したい対象(A) 単一責任の原則
Aが解決した問題 変更を加えようとすると多くの箇所に影響が出てしまい、変更に多くの時間がかかる(柔軟性がない)
Aによる解決方法(Aの本質) 1つのクラスは1つだけの責任を持つようにする
解決方法を実現するための具体的な手段 Goのサンプルコードで言うと以下のような感じ↓

A class should have one, and only one, reason to change.
–Robert C Martin

Goはオブジェクト指向言語?

Uncle Bobはクラスと言っているが、Goにはクラスがない。そのため継承もない。GoにはクラスがないからOOPではないという意見を見たことがあるが、クラスや継承とはOOPの本質の現象に過ぎず、OOPの本質はプログラムの部品化と部品化したプログラムの再利用によりソフトウェア開発を効率化することであり、Go言語はクラスや継承を使用せずともプログラムの再利用性を高める方法を持っている。

公式のFAQにはYes and noと書いてあり、GoはPost OOP言語だという意見やGoはOOP言語だと言い切っている記事もあって、個人的にはOOPだと思ったが公式のFAQでYes and noって言っているのでOOPだと言い切るつもりは現時点ではないが、少なくともクラスがないからというだけでGo言語はオブジェクト指向言語でないと言うのは乱暴だと思う。

ソースコード例

Goにはクラスがないので、GoでSRPを順守するにはtypeとfunctionが一つだけの仕事・役割を持つようにする。
より実務に近い例題を考えたのですが力不足で、不甲斐ないですが円の面積を求める例題にします。

まずは、違反している例です。

type circle struct {
  radius float64
}

func (c circle) area() {
  fmt.Printf("%v", c.radius * c.radius * math.Pi)
}

どこが違反しているかというと、area()が出力・計算という2つの役割を負っています。
これもダメなんだって感じかもですが、以下のように書き換えると良いでしょう。

type circle struct {
  radius float64
}

//責務を計算のみに変更
func (c circle) area() float64 {
  return c.radius * c.radius * math.Pi
}

type shape interface {
  area() float64
}

type outputter struct {}

//出力する責務を請け負う関数を作成
func (o outputter) Text(s shape) string {
  fmt.Println("area is %v", s.area())
}

area()が計算と出力の2つの責務を抱えていたため、計算のみに変更し出力を分離しました。
ちなみに、func (o outputter) Text(s shape) stringが引数にインターフェースを受け取るようになっていて、これにより変更への柔軟性が増します。
メソッド名でメソッドの中身を表現しようとしたらメソッド名が長くなり過ぎていたら、単一責任の原則を守れているか確認してみると良いでしょう。メソッド名にAndがついている時は要注意です。

単一責任の原則(Single responsibility principle)
1つのクラスは1つだけの責任を持たなければならない。すなわち、ソフトウェアの仕様の一部分を変更したときには、それにより影響を受ける仕様は、そのクラスの仕様でなければならない。

変更したいのはここだけなのに、ここを変更しようとするとあっちも変わってしまってあっちが変わってしまうとまた別のところも変わってしまって1つ直すのに3つも変更して更にテストもしないといけなくて時間がかかってしまって効率が悪い、、ということを防げる。

O:開放閉鎖の原則

Objects/entities should be open for extension but closed for modification.
オブジェクトやエンティティは拡張は受け付けるが修正は受け付けるべきではない

エンティティってことは関数はダメなのか?そもそもエンティティって?って思って調べてみたが、エンティティという言葉の持つ深みにハマるよりは、この原則の主語をソフトウェアの構成要素(クラス、モジュール、関数など)に置き換えて理解した方が良さそう。

オープンクローズドの原則とも。これは結構簡単。


func calculate(shapes ...shape) float64 {
  for _, shape := range shapes {
    case circle:
    ... circleの処理
    case square:
    ... squareの処理
  }
}

これだと変更する際にはどうぞcase文を追加して私を変えてくださいという感じで修正を受け付ける気満々なコードになっている。
しかし、修正、つまり中身のコードを変更せずにcircle、square以外の面積も計算させようとすると、

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

このように関数を修正して、あとはarea()を持つinterfaceを定義して構造体にそれぞれのarea()を定義してその構造体がinterfaceを満たすようにする。すると、以下のようになる。

type square struct {
  length float64
}
type triangle struct {
  base float64
  height float64
}
func (t triagle) area() float64 {
  return t.base * t.height / 2
}
func (s square) area() float64 {
  return s.length * s.length
}

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

ここに新しく図形を追加するときも、area()を満たすようにしてあげるだけでcalculateメソッドはいじらないで済む。

type square struct {
  length float64
}
type triangle struct {
  base float64
  height float64
}
type rectangle struct {
  height float64
  width float64
}
func (t triagle) area() float64 {
  return t.base * t.height / 2
}
func (s square) area() float64 {
  return s.length * s.length
}
func (r rectangle) area() float64 { //追加(長方形の計算機能追加という拡張)
  return r.height * r.width
}

func calculate(shapes ...shape) float64 { //修正されていない
  var sum float64
  for _, shape := range shapes {
    sum += shape.area()
  }
  return sum
}

開放閉鎖の原則(Open–closed principle)
「ソフトウェアのエンティティは(中略)拡張に対して開かれていなければならないが、変更に対しては閉じていなければならない。」

リスコフの置換原則

リスコフの置換原則(Liskov substitution principle)
「プログラムの中にある任意のオブジェクトは、プログラムの正しさを変化させることなく、そのサブクラスのオブジェクトと置換できなければならない。」

  • Goの場合、ここでのクラスは構造体と読み替える
  • そして親クラスとサブクラスの関係は埋め込みによって表現する
  • 置換するためにinterfaceを定義して親クラスがそれを満たすようにする

type human struct {
    name string
}

func (h human) getName() string { //これでhuman構造体はpersonインターフェースを満たす
    return h.name
}

type teacher struct {
    human //personインターフェースを満たしているhuman構造体を埋め込むことでstudentもpersonインターフェースを満たす
    degree string
    salary float64
}

type student struct {
    human //personインターフェースを満たしているhuman構造体を埋め込むことでstudentもpersonインターフェースを満たす
    grades map[string]int
}

type person interface {
    getName() string
}

type printer struct {
}

func (printer) info(p person) {
    fmt.Println("Name: ", p.getName())
}

func main() {
    h := human{name: "Alex"}
    s := student{
        human:  human{name: "Mike"},
        grades: map[string]int{
            "Math":    8,
            "English": 9,
        },
    }
    t := teacher{
        human:  human{name: "John"},
        degree: "CS",
        salary: 2000,
    }

    p := printer{}
    p.info(h) //human構造体を、human構造体を埋め込んでいるstudent、teacher構造体へと交換できている
    p.info(s)
    p.info(t)
}
//実行結果
//$ go run main
//Name:  Alex
//Name:  Mike
//Name:  John

I: インターフェイス分離の原則

インターフェイス分離の原則(Interface segregation principle)
「汎用的な目的のインターフェイスが1つだけあるよりも、特定のクライアント向けのインターフェイスが多数あった方がよりよい。」


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

// square = 正方形
type square struct {
  length float64
}

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

//square構造体には必要のないメソッドだが、インターフェースを満たすためだけに中身のないvolumeメソッドを実装しておりインターフェイス分離の原則に違反している。正方形に体積はない。
func (s square) volume() float64 {
  return 0
}

// cube = 立方体 area, volume(体積)を二つともちゃんと使用するので問題ないが、squareがcubeに引きずられている
type cube struct {
    length float64
}

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

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

違反している部分を直した例

type shape interface {
    area() float64
    volume() float64
}
// square = 正方形
type square struct {
  length float64
}

func (s square) area() float64 {
  return s.length * s.length
}
//square構造体には必要のないメソッドだが、インターフェースを満たすためだけに中身のないvolumeメソッドを実装しておりインターフェイス分離の原則に違反している
func (s square) volume() float64 {
  return 0
}


インターフェースを満たすためだけに実装されているメソッドができてしまったら、分離するサインだと思います。
インターフェースが汎用的で多くの構造体がそれを満たしていると、インターフェースへの変更の影響が多くの構造体に出てしまい、結果的にインターフェースを満たすためだけに構造体のメソッドを修正する必要が出てしまいます。
もし適切にインターフェースを分離できていればそのような必要のない変更を強いられることが減るため、この原則を順守したコードは、より変更に強く柔軟になります。

D: 依存性逆転の原則

依存性逆転の原則(Dependency inversion principle)
「具体ではなく、抽象に依存しなければならない」

具体ではなく抽象、をGoで言うなら特定の構造体ではなくinterfaceに依存することで依存先を柔軟にして変更の容易さを上げよう、というところでしょうか。
DBの接続先を呼び出し側で柔軟に変更したりできます。

main.go
func main() {
    d := db.Init()
    es := store.NewEmployeeGormStore(d)
    //es := store.NewEmployeeMySQLStore(d) store/employee_mysql.goなどを作成してコメントアウトを外せば、簡単にDBの接続先を変更できる

    h := NewEmployeeHandler(es) //抽象に対して依存している
    http.Handle("/employees/", http.StripPrefix("/employees/", http.HandlerFunc(h.getEmployee)))
    http.ListenAndServe(":8080", nil)
}

type EmployeeHandler struct {
    es employee.Store //抽象(Store interface)に対して依存している
}

//employee.Storeはinterface
func NewEmployeeHandler(es employee.Store) *EmployeeHandler {
    return &EmployeeHandler{
        es: es,
    }
}

func (h EmployeeHandler) getEmployee(w http.ResponseWriter, r *http.Request) {
    id := r.URL.Path
    employee, err := h.es.GetEmployeeByID(id)

    if err != nil {
        panic(err)
    }
    json.NewEncoder(w).Encode(employee)
}
employee/employee.go
//func NewEmployeeHandler(es employee.Store)の引数
type Store interface {
    GetEmployeeByID(id string) (*model.Employee, error) 
}
store/employee_gorm.go
type EmployeeGormStore struct {
    db *gorm.DB
}

func NewEmployeeGormStore(db *gorm.DB) employee.Store {
    return &EmployeeGormStore{
        db: db,
    }
}

func (es *EmployeeGormStore) GetEmployeeByID(id string) (*model.Employee, error) {
    var e model.Employee
    if err := es.db.Where(&model.Employee{ID: id}).First(&e).Error; err != nil {
        return nil, nil
    }

    return &e, nil
}

最後に

SOLID原則はOOP同様、大体プログラマ3年目くらいから知っていて当然、できていて当然くらいが求められる基礎的な部分であるように思う。
が、だからといってちゃんと理解・意識していないと平気でSOLID原則を破ったコードを書いてしまうし、どこかで意識を改革してSOLID原則を日頃から徹底的に意識するようにしないと経験年数だけが増えて一向にSOLID原則を順守したコードが書けるようにならないということも起こりうる。
自分も例外ではなく、SOLID原則について過去に学んだことはあったが忘れていた部分が多くあった。結局、y=axに例えると経験年数などxの部分でしかなくaが1以下であれば能力は向上しない。
それまでに得た知識や経験、習慣で毎日をやり過ごすのではなく、昨日の自分よりちょっと良い自分になるという心がけ、もっと良いやり方はないだろうか、どうやるのが一番良いやり方なのだろうかと常に考え調べる姿勢を持って業務にあたり、日々の生活を送ることがとてもとても大切だと思うようになった。

以上です。

参考

Design Principles and Design Patterns
https://fi.ort.edu.uy/innovaportal/file/2032/1/design_principles.pdf

本記事のサンプルコードはここから借りました。参考になるので是非見てみてください。感謝。
https://www.youtube.com/watch?v=AKdvlr-RzEA
https://repl.it/@steevehook/liskov-substitution-principle#main.go
https://repl.it/@steevehook/interface-segregation-principle-after#main.go
https://repl.it/@steevehook/interface-segregation-principle-before#main.go

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0