Open7

Design Pattern in Go

nabetsunabetsu

GolangにおけるDesign Pattern

Design Patternはオブジェクト指向言語をベースに考えられたものだが、GoはOOPではない。
(No Ingeritance, Weak Encapsulation)

そのため、OOPを前提とした言語と比べると少し実装の仕方が異なる。

nabetsunabetsu

Solid Principles

Overview

  • Robert C.Martinによって導入された設計原則
  • 当初はC#を使った書籍で説明されたが、原則自体はユニバーサルで言語に関わらず役に立つもの
  • Design Patternの中でSOLID原則が参照されることが多い

Single Responsibility Principles

Type(Object?)は一つの責務だけを持つべきであり、変更される理由は一つだけであるべきというもの。

Separation of Concernsが関連している。
Separation of ConvernsのアンチパターンはGod objectで全て(異なる関心事を扱っている処理)のコードが一つのパッケージに収められていること。

例えば以下のようにJournal(日誌)を管理するアプリケーションを作ったとする。
基本的な機能としてJournalに日誌を登録したり、削除したりする機能をまずは追加する。

var entryCount = 0

type Journal struct {
	entries []string
}

func (j *Journal) String() string {
	return strings.Join(j.entries, "\n")
}

func (j *Journal) AddEntry(text string) int {
	entryCount++
	entry := fmt.Sprintf("%d: %s", entryCount, text)
	j.entries = append(j.entries, entry)

	return entryCount
}

func (j *Journal) RemoveEntry(index int) {
	// ...
}

SRPを破るのは非常に簡単。
例えばJournalの内容を永続的に保持したくなったとする。
以下のようにFileに保存したりWeb上から読み込む処理を追加した時、SRPの原則は破られてしまう。

JournalのResponsibilityはJournalの内容を管理することにあり、それを永続的に保持することではない。

永続的に保持する部分を別のComponentとして持つことで、再利用性や変更時の影響範囲を限定することができる?

func (j *Journal) Save(filename string) {
	_ = ioutil.WriteFile(filename, []byte(j.String()), 0644)
}

func (j *Journal) Load(filename string) {
	//
}

func (j *Journal) LoadFromWeb(url *url.URL) {
	//
}

SRPを守った実装

関数として切り出すか、Structで定義を分けることでJournalのSRP原則を守ったまま機能を追加することができる。

// 関数を切り出すパターン
var LineSeparator = "\n"

func SaveToFile(j *Journal, filename string) {
	_ = ioutil.WriteFile(filename, []byte(strings.Join(j.entries, LineSeparator)), 0644)
}

// Structで定義するパターン
type Persistence struct {
	lineSeparator string
}

func (p *Persistence) SaveToFile(j *Journal, filename string) {
	_ = ioutil.WriteFile(filename, []byte(strings.Join(j.entries, p.lineSeparator)), 0644)
}

実際に呼び出すときは実装のパターンによって以下のように呼び出すことができる。

func main() {
	j := Journal{}
	j.AddEntry("牛乳を買った")
	j.AddEntry("ゴルフに行った")
	fmt.Println(j.String())

	// 関数で切り出す場合
	SaveToFile(&j, "journal.txt")

        // Structで定義した場合
	p := Persistence{"\r\n"}
	p.SaveToFile(&j, "journal_p.txt")
}

nabetsunabetsu

OCP

Open for extension, closed for modificationという言葉で表される。
SpeficationというEnterprise Patternとも関連がある。

サイズや値段でフィルタリングできるアプリケーションを考える。

基本的な色でフィルタリングする仕組みだけなら以下の実装でできる。

type Color int

const (
	red Color = iota
	green
	blue
)

type Size int

const (
	small Size = iota
	medium
	large
)

type Product struct {
	name  string
	color Color
	size  Size
}

type Filter struct {
	//
}

func (f *Filter) FilterByColor(
	products []Product, color Color) []*Product {
	result := make([]*Product, 0)
	for i, v := range products {
		if v.color == color {
			result = append(result, &products[i])
		}
	}
	return result
}

func main() {
	apple := Product{"Apple", green, small}
	tree := Product{"Tree", green, large}
	house := Product{"House", blue, large}

	products := []Product{apple, tree, house}
	fmt.Printf("Green products (old):\n")
	f := Filter{}
	for _, v := range f.FilterByColor(products, green) {
		fmt.Printf(" - %s in green\n", v.name)
	}
}

サイズでのフィルタリング、色とサイズでのフィルタリングを新しいメソッドを作って追加すると以下のようになる。そしてこれはOCPに違反している。

func (f *Filter) FilterByColor(
	products []Product, color Color) []*Product {
	result := make([]*Product, 0)
	for i, v := range products {
		if v.color == color {
			result = append(result, &products[i])
		}
	}
	return result
}

func (f *Filter) FilterBySize(
	products []Product, size Size) []*Product {
	result := make([]*Product, 0)
	for i, v := range products {
		if v.size == size {
			result = append(result, &products[i])
		}
	}
	return result
}

func (f *Filter) FilterBySizeAndColor(
	products []Product, size Size, color Color) []*Product {
	result := make([]*Product, 0)
	for i, v := range products {
		if v.size == size && v.color == color{
			result = append(result, &products[i])
		}
	}
	return result
}

Specification Pattern

Specification Patternを使って以下のように実装を変える。

type Specification interface {
	IsSatisfied(p *Product) bool
}

type SizeSpecification struct {
	size Size
}

func (s SizeSpecification) IsSatisfied(p *Product) bool {
	return p.size == s.size
}

type ColorSpecification struct {
	color Color
}

func (c ColorSpecification) IsSatisfied(p *Product) bool {
	return p.color == c.color
}

type BetterFilter struct{}

func (f *BetterFilter) Filter(
	products []Product, spec Specification) []*Product {
	result := make([]*Product, 0)
	for i, v := range products {
		if spec.IsSatisfied(&v) {
			result = append(result, &products[i])
		}
	}
	return result
}

呼び出す際にはフィルタリングの条件に応じてFilterにSpecificationを渡す。
以下の例では色でのフィルタリングのみを実装した場合。

func main() {
	// 最初に作成したバージョン
	apple := Product{"Apple", green, small}
	tree := Product{"Tree", green, large}
	house := Product{"House", blue, large}

	products := []Product{apple, tree, house}
	fmt.Printf("Green products (old):\n")
	f := Filter{}
	for _, v := range f.FilterByColor(products, green) {
		fmt.Printf(" - %s in green\n", v.name)
	}

	// Better Filter
	fmt.Printf("Green products (new):\n")
	greenSpec := ColorSpecification{green}
	bf := BetterFilter{}
	for _, v := range bf.Filter(products, greenSpec) {
		fmt.Printf(" - %s in green\n", v.name)
	}
}

以下の通り全く同じ結果が得られる。
しかし、Better Filterの方が新しいフィルタリングの条件を追加するときにSpecificationを追加するだけでよく、柔軟性が高く開発時の効率性も上がる。(コードの重複も少ない)

% go run ocp.go
Green products (old):
 - Apple in green
 - Tree in green
Green products (new):
 - Apple in green
 - Tree in green

OCPの文脈で上記の実装を考えてみると

  • Specificationという型は拡張に対しては開かれている(機能追加は自由にできる)が、変更に対しては閉じられている(interfaceの型が変わることはない)。同様にBetterFilterもそう。

複数の条件に対応する方法

Design Patternの一つであるComposite Patternを使う。

以下のように複数のフィルタリング条件を受け付けるSpecificationを定義し、それを受け取るようにする。

type AndSpecification struct {
	first, second Specification
}

func (a AndSpecification) IsSatisfied(p *Product) bool {
	return a.first.IsSatisfied(p) && a.second.IsSatisfied(p)
}
...

func main() {
	...
	// Composite
	largeSpec := SizeSpecification{large}
	lgSpec := AndSpecification{greenSpec, largeSpec}
	fmt.Printf("Large green products:\n")
	for _, v := range bf.Filter(products, lgSpec) {
		fmt.Printf(" - %s is green and large\n", v.name)
	}
}

まとめ

  • 機能追加するときに既存のメソッドをコピペして少しだけ変えているときはOCPに違反している匂いがする
  • 一つの型を定義してそれを拡張していくことで、コードの重複も防げるし既存機能への影響を気にしなくても良くなる?

参考資料

nabetsunabetsu

Liskov Substitution Principle

Inheritanceの考えを元にしているのでそのままGoには適用できない。
Baseクラスで動作するならDeriveクラスでも動作するはずというのが基本的な考え。

package main

import "fmt"

type Sized interface {
	GetWidth() int
	SetWidth(width int)
	GetHeight() int
	SetHeight(height int)
}

type Rectangle struct {
	width, height int
}

func (r *Rectangle) GetWidth() int {
	return r.width
}

func (r *Rectangle) SetWidth(width int) {
	r.width = width
}

func (r *Rectangle) GetHeight() int {
	return r.height
}

func (r *Rectangle) SetHeight(height int) {
	r.height = height
}

func UseIt(sized Sized) {
	width := sized.GetWidth()
	sized.SetHeight(10)
	expectedArea := 10 * width
	actualArea := sized.GetWidth() * sized.GetHeight()
	fmt.Print("Expected an area of ", expectedArea,
		", but got ", actualArea, "\n")

}

func main() {
	rc := &Rectangle{2, 3}
	UseIt(rc). // Expected an area of 20, but got 20

}

問題なし

四角形の計算を加えようとしたとき。


type Square struct {
	Rectangle
}

func NewSquare(size int) *Square {
	sq := Square{}
	sq.width = size
	sq.height = size
	return &sq
}

func (s *Square) SetWidth(width int) {
	s.width = width
	s.height = width
}

以下のように想定外の値が出力される。
原因としてはSetHeightを実行したときにHeightだけでなく、Widthも設定しているから。

% go run lsp.go
Expected an area of 20, but got 20
Expected an area of 50, but got 100
nabetsunabetsu

Interface Segregation Principle

Interfaceに多くの情報を詰め込みすぎないこと?

type Document struct {

}

type Machine interface {
	Print(d Document)
	Fax(d Document)
	Scan(d Document)
}

type MultiFunctionPrinter struct {
	// interfaceを実装すればいい
}

func (m MultiFunctionPrinter) Print(d Document) {
	
}

func (m MultiFunctionPrinter) Fax(d Document) {
	
}

func (m MultiFunctionPrinter) Scan(d Document) {
	
}

しかし、もしFaxやScanの機能がないプリンターを実装する場合、どうすればいいだろうか。
以下のように実際には使い物にならないメソッドを実装する必要が出てきてしまう。

type OldFashionedPrinter struct {

}

func (o OldFashionedPrinter) Print(d Document) {
	// ok
}

func (o OldFashionedPrinter) Fax(d Document) {
	panic("この操作はサポートしていません")
}

func (o OldFashionedPrinter) Scan(d Document) {
	panic("この操作はサポートしていません")
}

ISPの導入

以下のようにインタフェースを分離することで用途に応じたものを定義することができる。

// ISP
type Printer interface {
	Print(d Document)
}

type Scanner interface {
	Scan(d Document)
}

type MyPrinter struct {
}

func (m MyPrinter) Print(d Document) {

}

// PrinterとScannerの機能を持つ
type Photocopier struct{}

func (p Photocopier) Scan(d Document) {

}
func (p Photocopier) Print(d Document) {

}

複数のものに対応するパターン

type MultiFunctionDevice interface {
	Printer
	Scanner
}

Decoratorパターン

Decoratorパターンを使って複数の機能を実装することもできる。

// decorator
type MultiFunctionMachine struct {
	printer Printer
	scanner Scanner
}

func (m MultiFunctionMachine) Print(d Document) {
	m.printer.Print(d)
}
nabetsunabetsu

Dependency Invesion Principle

type Relationship int

const (
	Parent Relationship = iota
	Child
	Sibling
)

type Person struct {
	name string
	// other property
}

// 人と人の関係性をモデルとして定義する
type Info struct {
	from         *Person
	relationship Relationship
	to           *Person
}

// low-level
// store relationship data
type Relationships struct {
	relations []Info
}

func (r *Relationships) AddParentAndChild(
	parent, child *Person) {
	r.relations = append(r.relations, Info{parent, Parent, child})
	r.relations = append(r.relations, Info{child, Child, parent})
}

// high-level
type Research struct {
	// break DIP
	relationships Relationships
}

func (r *Research) Investigate() {
	relations := r.relationships.relations
	for _, rel := range relations {
		if rel.from.name == "John" &&
			rel.relationship == Parent {
			fmt.Println("John has a child called", rel.to.name)
		}
	}
}

func main() {
	parent := Person{"John"}
	child1 := Person{"Chris"}
	child2 := Person{"Tom"}
	relationships := Relationships{}
	relationships.AddParentAndChild(&parent, &child1)
	relationships.AddParentAndChild(&parent, &child2)

	r := Research{relationships}
	r.Investigate()
}

以下の箇所が問題。
Research module(High-level)がRelationships module(low-level)の内部要素に依存している。
Relationships moduleがデータの保存方法を外部ストレージ(例えばDB)に変更するとき、Research moduleに変更が必要になる。(Low-levelの具体に依存しているから)

具体的には以下の例だとforループは使えなくなる。このように具体に抽象することで、変更によりHigh Levelまでコードの変更が必要になる

// high-level
type Research struct {
	// break DIP
	relationships Relationships
}

func (r *Research) Investigate() {
	relations := r.relationships.relations
	for _, rel := range relations {
		if rel.from.name == "John" &&
			rel.relationship == Parent {
			fmt.Println("John has a child called", rel.to.name)
		}
	}
}

DIPでの書き方

ResearchRelationshipsに依存するのではなく、RelationshipBrowserという新しいinterfaceを定義し、そこに依存するようにする。
その上でRelationshipsにメソッドを実装する。

...
// low-level
// store relationship data

type RelationshipBrowser interface {
	FindAllChildrenOf(name string) []*Person
}

func (rs *Relationships) FindAllChildrenOf(name string) []*Person {
	result := make([]*Person, 0)

	for i, v := range rs.relations {
		if v.relationship == Parent &&
			v.from.name == name {
			result = append(result, rs.relations[i].to)
		}
	}
	return result
}

type Relationships struct {
	relations []Info
}

func (rs *Relationships) AddParentAndChild(
	parent, child *Person) {
	rs.relations = append(rs.relations, Info{parent, Parent, child})
	rs.relations = append(rs.relations, Info{child, Child, parent})
}

// high-level
type Research struct {
	// break DIP
	// relationships Relationships
	browser RelationshipBrowser // 
}

func (r *Research) Investigate() {
	for _, p := range r.browser.FindAllChildrenOf("John") {
		fmt.Println("John has a child called", p.name)
	}
}

func main() {
	parent := Person{"John"}
	child1 := Person{"Chris"}
	child2 := Person{"Tom"}
	relationships := Relationships{}
	relationships.AddParentAndChild(&parent, &child1)
	relationships.AddParentAndChild(&parent, &child2)

	r := Research{&relationships} // Interfaceに依存するように変更
	r.Investigate()
}