オブジェクト指向・関数型エクササイズ
オブジェクト指向エクササイズとは、マーティン・ファウラーが所属する
ThoughtWorks社による書籍
「ThoughtWorks アンソロジー アジャイルとオブジェクト指向によるソフトウェアイノベーション」
の5章「オブジェクト指向エクササイズ」で書かれている内容です。
章の内容をざっくりと言うと、9つのルールを使って、手続き型のコードをリファクタリングしオブジェクト指向脳になって、より良いソフトウェアの設計にしましょう、というものです。
こちら2008年の書籍で、なんで今さら?って思うかもしれませんが、ドメイン駆動設計 (DDD)が盛り上がってきて、その基礎部分として大事であるため見直されているようです。
DDDを採用しないとしても、オブジェクト指向言語を使ってプログラムを記述する上で、とても大事なエッセンスが詰まっています。
しかし、技術は常に進歩し続けるので、オブジェクト指向エクササイズの考えを鵜呑みにするのではなく、現代の考え、特に、関数型のプログラミングスタイル
を取り入れて改めて9つのルールを見直してみようと記事を書いてみました。記事には、いくつかの(僕の好みの)言語で、書かれます。
これは、カチカチの関数型言語(Haskell等)だけでなく、多くの言語で今日から実践出来ることを多くの人に知ってもらいたいからです! マサカリ大歓迎です!
ルール1: 1つのメソッドにつきインデントは1段階までにすること
これは、概ねルールには、同意です。ネストが深いコードは、コードを読み解くのが困難になるだけでなく、
メソッド一つに対する責務が多重になってしまうので、避けるべきです。
しかし、関数型スタイルを導入すれば、コードブロックそのものを減らすことが出来ます。
コードブロックを発生させる要因について考えていきましょう。要因となるのは以下の構文です。
- if
- switch
- for
- while
コードブロック、つまり制御構文は、手続き型のさらに言うと、
構造化プログラミングの考えから来ています。
制御構文は、プログラムの制御をする以上の意味を持てない為、コメントやメソッドに切り分けて名前を与えるなどの細工をして、
意味を与えるしか手段はありません。関数型言語では、2つのアプローチをすることで、これらの問題を解決します。
一つ目のアプローチは、if, switchを式として扱うことです。式とは何でしょうか? 答えは簡単です。
評価すると値を返すものを式と呼びます。
Scala
// 式が評価されて値を返す。
1 + 1 // => 2
// ifも式として評価されて値を返す。
if(true) 1 else 0 // => 1
さらに、式は、評価後、値になるため、変数に代入することが出来ます。
Ruby
# 三項演算子ならば、大抵の言語が適用可
dobleOver10 = x > 10 ? x * 2 : x
こうなってしまえば、ただの名前付き変数となるだけなので、意味を与え、メソッド分けをする必要も無くなります。
もちろん条件が複雑に重なり合っている場合は、従来通りメソッド分けを行いましょう。
switchも同様です。Rubyのcase式や
Scalaのパターンマッチ等を利用すれば、単なる値として扱うことが出来ます。
それでは、for, whileについてはどうでしょうか? この場合は、ループ中に行われている内容について着目します。
Java
// 配列の要素を二倍する。
List<Integer> doubleList = new ArrayList<>();
for(int element: list){
doubleList.add(element * 2);
}
// 特定の要素を取り除く。
List<Integer> evenList = new ArrayList<>();
for(int element: list){
if(element % 2 == 0){
evenList.add(element);
}
}
このように、ループの処理では、一定のパターンが生じます。これらは、高階関数を利用することで、forを取り除く事ができます。
Java8
List<Integer> doubleList = list.stream().map(x -> x * 2).collect(Collectors.toList());
List<Integer> eventList = list.stream().filter(x -> x % 2 == 0).collect(Collectors.toList());
Scala
// Scalaは、元々パラダイムが関数型のため、Listオブジェクトは、高階関数を扱うメソッドが備わっている。
// そのため記述が楽。
val doubleList = list.map(_ * 2)
val evenList 0 list.filter(_ % 2 == 0)
高階関数は、制御構文を無くすだけでなく、制御構文が持てなかった文脈の意味を、
関数名を以ってして表現することでコードの可読性を上昇させることができます。
Java8のラムダ式を用いた方法では、後に説明するルール4:「 1行につきドットは1つまでにすること」を破ってしまっているが、
これは、複数の責務を一つの行が持っているのではなく、一つの責務を行うための下準備(型変換)を行っている為であり、
これを一々メソッドに分割していたら高階関数を使う理由が本末転倒になってしまうと思います。
スマートに記述したい場合は、やはりScala等の関数型を最初から備えている言語を利用するのが、ベターだと思われます。
ルール2: else 句を使用しないこと
このルールは、ネストされた構文を避けるために、else句(if自体)を取り除くことを徹底します。
しかし、ルール1で述べた通り、ifが式であることと、関数型スタイルでは、関数の戻り値(型)を重視するため、状況が大きく変わってきます。
書籍で述べられている例のコードを挙げてみます。
リファクタリング前 (Java)
public static void endMe() {
if(status == DONE){
doSomething();
}else{
// その他のコード ...
}
}
リファクタリング後 (Java)
public static void endMe(){
if(status == DONE){
doSomething();
return;
}
// その他のコード ...
}
この例は、正直、状況が詳しく無い為、突っ込んで言及することが難しいです。
敢えて言うならば、成功・失敗値を返すメソッドと高階関数の組み合わせで回避をすることが出来ます。
Scala
// 三項演算子としての、if-else式
def evenFilter(x: Int): Option[Int] = if(x % 2 == 0) Some(x) else None
def printEven(x: Int): Unit = {
evenFilter(x).foreach(println)
}
成功(Some)か失敗(None)かの文脈を持つOptionは、高階関数を呼び出す際に、成功値のみを判断して実行するので、
foreach中のprintlnメソッドは、偶数のときのみ実行が行われます。
また、Either型では、成功(Right)と同様に、失敗(Left)時も値を持つことが出来ます。
Scala
def evenFilter(x: Int): Either[String, Int] = if(x % 2 == 0) Right(x) else Left("奇数でした。")
def printEven(x: Int): Unit = {
evenFilter(x) match {
case Right(x) => println(x)
case Left(err) => System.err.println(err)
}
}
パターンマッチにより、成功時・失敗時の分岐がわかりやすく、インデントも一つで抑えられているのが、わかります。
値を返すメソッドと、副作用を持つメソッドを切り分けられており、テストもしやすくなっています。
書籍に書いてある、Strategy・Nullオブジェクトパターンは、OptionとEitherを用いれば、もっと便利に再現可能です。
ルール3: すべてのプリミティブ型と文字列型をラップすること
このルールは、プリミティブ・文字列型に対し、独自クラスで意味付けし、さらにコンパイラへ型のチェックを促しています。
しかし、全ての型をラップするのは、骨ですし、Java等で書くと冗長になりがちです。Haskellでは、標準機能で簡単に記述することが出来ます。
Haskell
newtype Coin = Coin Int deriving (Show)
doubleCoin :: Coin -> Coin
doubleCoin (Coin x) = Coin (x * 2)
-- 単なる、整数は渡せない。
*Main> doubleCoin 1
<interactive>:3:12:
No instance for (Num Coin) arising from the literal ‘1’
In the first argument of ‘doubleCoin’, namely ‘1’
In the expression: doubleCoin 1
In an equation for ‘it’: it = doubleCoin 1
-- Coin型として、渡す。
*Main> doubleCoin (Coin 8)
Coin 16
Scalaでも、scalazと呼ばれるライブラリの
*Tagged type*を利用することで、
同様の機能を再現することが出来ます。
sealed trait Coin
def Coin(value: Int): Int @@ Coin = Tag[Int, Coin](value)
def doubleCoin(coin: Int @@ Coin): Int @@ Coin = Coin(scalaz.Tag.unsubst[Int, Id, Coin](coin) * 2)
Haskellと比べると、やや冗長さが否めませんが、それでもラッパクラスを1ファイル費やして書くよりは、シンプルですね。
その他のルール
突然ですが、以降のルールに関しては、元のルールと同様です。
しかし、熟考をしていない為、他のルールも改善できるかもしれません。その時は記事を追記するか、別記事を書くのでお待ち下さい。
一応最後に、残りのルールの列挙だけをします。
- ルール4: 1行につきドットは1つまでにすること
- ルール5: 名前を省略しないこと
- ルール6: すべてのエンティティを小さくすること
- ルール7: 1つのクラスにつきインスタンス変数は2つまでにすること
- ルール8: ファーストクラスコレクションを使用すること
- ルール9: Getter、 Setter、 プロパティを使用しない
まとめ
いかがでしたでしょうか。関数型スタイルを導入することで、可読性が高く、テストもしやすい、
頑強なコードが書けることが少しでもわかって頂けたでしょうか。
さらに言うと、関数型はオブジェクト指向と反する概念ではありません。上手く親和し、オブジェクト指向で書かれたコードをより良いものに昇華することが出来ます。
思いつきで駆け足で書いた記事なので、おかしな点が存在すると思いますが、是非素晴らしい「関数型・オブジェクト指向」エクササイズライフを!