🫵

おまえらのFizzBuzzは間違っている(Go オブジェクト指向)

2024/06/25に公開

はじめに

釣りタイトルですまん。

この記事は社内勉強会向けに作成した内容をZenn向けに再編集したものです。

ソースコード
https://github.com/Mkamono/objective-fizz-buzz

種本

「ちょうぜつソフトウェア設計入門 PHPで理解するオブジェクト指向の活用」の5-3を参考にしました。

https://amzn.asia/d/ewM0dJ1

突然ですが、FizzBuzzを書いてみてください

はい。頑張ってください。

要求は以下のとおりです。

  • 1以上の整数値が入力として渡される
  • 3の倍数のときは"Fizz"と出力する
  • 5の倍数のときは"Buzz"と出力する
  • ただし、3の倍数かつ5の倍数のときは"FizzBuzz"と出力する
  • 3、5の倍数でないときは入力された数値をそのまま出力する

以下のような実装になると思います。

fizzbuzz/fizzbuzz.go
package fizzbuzz

import "fmt"

func FizzBuzz(n int) string {
	switch {
	case n%15 == 0:
		return "FizzBuzz"
	case n%3 == 0:
		return "Fizz"
	case n%5 == 0:
		return "Buzz"
	default:
		return fmt.Sprint(n)
	}
}

仕様変更が起きたら?

アプリケーションに仕様変更はつきものです。ここでいう3や5というのは人の都合で変わりうる数です。これが仕様です。

「じゃあ3や5を変数にして…コンストラクタで受け取るようにして…」とすぐにfizzbuzz.goに手を加えてしまうのは早計です。仕様変更が起こるたびにこのfizzbuzz.goを書き換えなければいけない、というのはモジュールとしての安定度が低くなってしまいます。

開放閉鎖原則

オブジェクト指向の考え方の1つに開放閉鎖原則(Open Closed Principle, OCP)というものがあります。これはあるモジュールの設計について「拡張に対してオープンであれ、変更に対してはクローズドであれ」というものです。仕様変更が起きた際にできる限り自分自身を変更せず、拡張として実装できるようにモジュールを設計すべきという考え方とも言えます。

本質と仕様

手を動かす前に、仕様が変わっても変更されないモジュールとしての本質とは何かを考えます。「3の倍数が入力されるとFizzが出てくる」というルールに着目すると、これは「数字が入力されると、それに応じた文字列が出力される」というところまで抽象化できると思います。では、"FizzBuzz"が出力される状況からはどのような本質が汲み取れるでしょうか?複数条件にマッチしたときは、変換が重複してかかる、という一般法則があるのではないかと推測できます。ここから、変わらないと期待できる本質はこのように抽出できそうです。

  • 整数を入力すると文字列を返す
  • 任意の変換ルールを複数定義できる
  • 変換ルールは何らかの判定条件を満たしたときに適用される
  • 変換結果は前のルールの結果を受けて累積される

この本質をコードにすると、次のようになります。

fizzbuzz/core/core.go
package core

type ReplaceRule interface {
	Match(carry string, n int) bool
	Apply(carry string, n int) string
}

type NumberConverter interface {
	Run(n int) string
}

var _ NumberConverter = (*numberConverter)(nil)

type numberConverter struct {
	rules []ReplaceRule
}

func NewNumberConvter(rules []ReplaceRule) *numberConverter {
	return &numberConverter{rules: rules}
}

func (c *numberConverter) Run(n int) string {
	var result string
	for _, rule := range c.rules {
		if rule.Match(result, n) {
			result = rule.Apply(result, n)
		}
	}
	return result
}

このように、本質を表したコードには具体性がないのです。
本質となるコードは何にも依存せず、それ単体で存在することができます。自分自身を成立させるために必要な外部の要素(この場合のルール)はすべてインターフェースとして定義してあります。仕様側はこのインターフェースを見て実装を作っていきます。

では、これを受けた仕様側のコードも見てみましょう。

ルールの定義

fizzbuzz/spec/spec.go
package spec

import (
	"fmt"

	"github.com/Mkamono/objective-fizz-buzz/answer/fizzbuzz/core"
)

// 倍数のときに指定した文字列を追加するReplaceRule
var _ core.ReplaceRule = (*cyclicNumberRule)(nil)

func NewCyclicNumberRule(divisor int, word string) *cyclicNumberRule {
	return &cyclicNumberRule{divisor: divisor, word: word}
}

type cyclicNumberRule struct {
	divisor int
	word    string
}

func (r *cyclicNumberRule) Match(carry string, n int) bool {
	return n%r.divisor == 0
}

func (r *cyclicNumberRule) Apply(carry string, n int) string {
	return carry + r.word
}

// 通常の数字をそのまま返すReplaceRule
var _ core.ReplaceRule = (*passThroughRule)(nil)

func NewPassThroughRule() *passThroughRule {
	return &passThroughRule{}
}

type passThroughRule struct {
}

func (r *passThroughRule) Match(carry string, n int) bool {
	return carry == ""
}

func (r *passThroughRule) Apply(carry string, n int) string {
	return carry + fmt.Sprint(n)
}

fizzbuzzの実行

fizzbuzz/fizzbuzz.go
package fizzbuzz

import (
	"github.com/Mkamono/objective-fizz-buzz/answer/fizzbuzz/core"
	"github.com/Mkamono/objective-fizz-buzz/answer/fizzbuzz/spec"
)

func FizzBuzz(n int) string {
	fizzRule := spec.NewCyclicNumberRule(3, "Fizz")
	buzzRule := spec.NewCyclicNumberRule(5, "Buzz")
	passThroughRule := spec.NewPassThroughRule()

	fizzbuzzConverter := core.NewNumberConvter([]core.ReplaceRule{
		fizzRule,
		buzzRule,
		passThroughRule,
	})

	return fizzbuzzConverter.Run(n)
}

これらの関係を図に表すと、以下のようになります。

本質となるCoreパッケージは、その構成要素となるReplaceRuleをインターフェースとして持ち、実際にルールを作っているSpecパッケージはそのインターフェースを参照しています。mainパッケージではSpecパッケージを参照して作ったFizz、Buzz、PassThroughルールを使ってCoreパッケージのNumberConverterを実行しています。

入力として渡された値は、繰り返しルールが適用されていくだけのフローに整理できます。

このようにすることで、要件が変わっても本質のコードを書き換えず、仕様側のコードを書き換えるだけで対応できるため、非常に管理がしやすくなります。3つ目のルールの拡張や、7や11の倍数への変更が、仕様側の変更によってのみ達成できるようになりました。

これこそが「拡張に対してオープンであれ、変更に対してはクローズドであれ」という原則です。

オブジェクト指向の考え方を使うことで、ロジックをシンプルに考えることができました。FizzBuzzも、しっかり設計しようとすると難しいものです。

Discussion