Javaでもう一度学び直すオブジェクト指向プログラミングを関数型プログラミングで考え直す 〜第3章〜
この記事は前回に引き続き、Software Design 2021年3月号に掲載されているJavaでもう一度学び直すオブジェクト指向プログラミング
と言う記事を関数型プログラミング言語Elmで考えたらどうなるだろう?と考えてみた記事です。
元雑誌の該当記事の第3章ではJavaのクラスを利用した設計は自由度が高すぎるため、手続き的な手法で書かれたアンチパターンを避け、より良いオブジェクト指向の設計を目指しましょうと言う内容でした(ざっくりですぎて、すみません)。
結論から述べると、この章の内容だけに限って言えば、関数型プログラミング言語を用いた方がアンチパターンに陥りにくい設計になるかつ、例として書かれているコードが実行時エラーを起こしうる危険なコードも含んでいたため、より安全な言語に倒した方がいいのでは無いか?と言う印象でした。
今回のコード全体です。
優れたオブジェクト指向の設計方法とは
元記事では、以下の3点を考慮したクラスが優れたコードであると言う定義をしていました。
- データの種類でクラスを分ける
- データとロジックを同じクラスに持つ
- オブジェクトの値を変更しない(イミュータブルな設計をする)
詳しくは雑誌を購入し参照していただきたいのですが、1は振る舞いを意識してクラスを分けましょう。(継承については書かれていなかったのですが、インターフェースを軸とした開発ということでしょうか?どちらかと言うと次の2番を意識すれば1を満たすように感じました)2は、getter, setterを公開したデータクラスとそれを利用した機能クラスにしてしまうとコードの重複が増えたりカプセルかを壊してしまったり様々な不利益を被ると言う内容でした。3は関数型をむしろ意識した内容なので、言語仕様として状態を更新できない純粋関数型では意識する必要がありません。
シンプルな金額を表すモジュール
まずシンプルな例として、先ほどの3つのポイントを意識した、金額を扱うクラスをElmではどう書くか考えてみましょう。次のJavaの例では、プリミティブなint型で貨幣を扱ってしまうと様々な数値表現とぶつかってしまうため、クラスで表現しましょう。その結果、金額に関する知識が集約される。コードの重複がなくなる。などの利点が挙げられています。(例によって、詳しくは元記事を参照してください。)
class Amount {
int value;
Amount(int value){
this.value = value;
}
Amount add(Amount other) {
return new Amount(value, other.value);
}
}
Amountクラスは、カスタム型(じゃんけんの手を考えた物と同じです)とモジュールと関数群で再現されます。
AmountはAmount
と言うキーワードと実際の金額の数値(Int)を持っていることで表現できます。Amount Int
の(値)コンストラクタを公開してしまうと数値が自由に取り出されてしまうので、moudle Amount exposing (Amount)
とすることで型のみが外のモジュールから参照することができます。(もし、コンストラクタを公開したい場合は、exposing (Amount(..))
と表記します。)
コンストラクタを公開する代わりに、Intを受け取るファクトリ関数amount
を定義し、公開しています。
add
は整数を直接受け取らずに、Amount型を二つ受け取り、Amount型を返す関数になっています。(カプセル化)
module Amount exposing (Amount, add, amount)
type Amount
= Amount Int
amount : Int -> Amount
amount value =
Amount value
add : Amount -> Amount -> Amount
add (Amount v1) (Amount v2) =
Amount <| v1 + v2
使い方が想像できるように、テストコードも載せておきます。
module AmountTest exposing (..)
import Amount exposing (..)
import Expect
import Test exposing (..)
addTest : Test
addTest =
describe "#add"
[ describe "amount 1 + amount 2は、"
[ test "中の整数を足して、amount 3となる" <|
\_ ->
Amount.add (amount 1) (amount 1)
|> Expect.equal (amount 2)
]
]
オーバーフローを検知する
金額クラスの発展例として、数値を足した結果オーバーフローを起こす場合、ArithmeticException
の例外を投げるコードがありました。異常系が型で例外が記述されていないコードは、キャッチ忘れやチェック例外のメソッドとしない場合、実行時例外が起きてプログラムがダウンする可能性があるため避けるべきだと感じます。そのため、キチンと型で異常系を表現したElmコードを載せておこうと思います。これは今回のOOPを関数型で考えるとは違う趣旨の話かもしれませんが、OOPで書かれたコードを関数型で再現した上でJavaの危険なコードを回避できるのであれば、尚更、関数型プログラミング言語で記述した方が安全面に関しては優れていると断言できるため、是非と載せさせてください。
先ほどとの差分としては、Amountカスタム型の分岐として、Overflowが追加されました。add
関数では足し算の際に既に片方の値がOverflow
の場合は、Overflowとして分岐します。v1とv2を足した結果、Inf(JavaScriptと同様です)を検知した場合、Overflowとなります。Infでは無い時に限り、Amount Intを結果として返します。
module Amount exposing (Amount, add, amount, isOverflow)
type Amount
= Amount Int
| Overflow
amount : Int -> Amount
amount value =
Amount value
add : Amount -> Amount -> Amount
add amount1 amount2 =
case ( amount1, amount2 ) of
( Amount v1, Amount v2 ) ->
let
sum =
v1 + v2
in
if isInfinite <| toFloat sum then
Overflow
else
Amount sum
_ ->
Overflow
isOverflow : Amount -> Bool
isOverflow amnt =
case amnt of
Overflow ->
True
_ ->
False
べき乗を使って大きい数値同士を足すことでInfとなり、期待通りOverflowとなります。(詳しく、どれくらいの数値になるとInfになるかまでは調べておりません)
module AmountTest exposing (..)
import Amount exposing (..)
import Expect
import Test exposing (..)
addTest : Test
addTest =
describe "#add"
[ describe "amount 1 + amount 2は、"
[ test "中の整数を足して、amount 3となる" <|
\_ ->
Amount.add (amount 1) (amount 1)
|> Expect.equal (amount 2)
]
, describe "amount 大きすぎる数字 + amount 大きすぎる数字は、"
[ test "和がInfになり、オーバーフロー通貨となる" <|
\_ ->
Amount.add (amount <| 10 ^ 500) (amount <| 10 ^ 500)
|> isOverflow
|> Expect.true "オーバーフロー通過であることを期待します。"
]
]
映画館の料金(金額)
さらに発展内容として、映画の料金を条件によって出力するクラスと列挙型の組み合わせの例を関数型に翻訳してみました。先ほどのAmount
クラスをpriceの戻り値にすることで、単なる数値ではなく、料金であることがわかる制約を付けようと言う意図ですね。
今回もOOPと直接関係が無い点ですが、Map#getメソッドはnull
が変える可能性があり、また、prices
はCategory
とDayType
の組み合わせに対して網羅性を持たないことがコードを見た時に感じた懸念でした。その点もElmによって解決しているので、そちらも合わせて解説をしていきます。
enum Category{
一般,
子供,
高齢者
}
enum DayType{
平日,
休日
}
class PriceTable {
Map<Pair, Integer> prices;
{
prices = Map.ofEntries(
Map.entry(Pair.of(一般, 平日), 1000),
Map.entry(Pair.of(一般, 休日), 1500),
Map.entry(Pair.of(子供, 平日), 400),
Map.entry(Pair.of(子供, 休日), 600)
)
}
Amount price(Pair pair) {
return new Amount(prices.get(pair));
}
}
コーディングのルールとして、新しいものはありません。Amountが持つIntの値を公開していないため、MoviePriceモジュールは全てのカスタム型のコンストラクタと関数を公開しても問題ない設計になっています(OOPの例が表現したいことを完璧に再現できています)。
加えて、price関数で(category, dayType)のタプル(Pair)をパターンマッチすることで全ての組み合わせが網羅されているかどうかのチェックはコンパイラがしてくれます。この点がnullが生じず例外を吐くことがないことを保証されていて、より優れている点です。また、組み込み型のタプルを使うことでPairクラスを別途定義する必要がないと言う点も優れていると言えます。組み込みの型で済んでしまうのも、OOP例のPairクラスも単なるデータクラスに過ぎないからです。(Pair自体にロジックを持たせる必要がない)
module MoviePrice exposing (Category(..), DayType(..), price)
import Amount exposing (Amount, amount)
type
Category
-- 一般 | 子供。高齢者はpriceの組み合わせとして出ていなかったので定義していません。
= General
| Children
type
DayType
-- 平日 | 休日
= Weekdays
| Holiday
price : Category -> DayType -> Amount
price category dayType =
amount
(case ( category, dayType ) of
( General, Weekdays ) ->
1000
( General, Holiday ) ->
1500
( Children, Weekdays ) ->
400
( Children, Holiday ) ->
600
)
テスト例です。
module MoviePriceTest exposing (..)
import Amount exposing (..)
import Expect
import MoviePrice exposing (..)
import Test exposing (..)
addTest : Test
addTest =
describe "#price"
[ describe "子供・休日の料金は、"
[ test "600円です" <|
\_ ->
MoviePrice.price Children Holiday
|> Expect.equal (amount 600)
]
]
まとめ
元記事のJavaを使った例では、クラスを上手く設計することでカプセル化や値の種類としての型を使うことでロジックとデータの紐付きを強くし、オブジェクトの表現力を高める手法を紹介していました。一方で例外を吐いてしまう危うさや人間がイミュータブルな設計を意識しなければならないと言う辛い一面もあると思います。Elmを使った関数型のアプローチでは、モジュール・カスタム型・関数を使い定義することでOOPの利点と全く同じコードを表現することができました。加えて、パターンマッチの網羅性やカスタム型の表現力を活かして品質に考慮したコードを簡単に記述することができました。3章はJavaの基本機能のみにフォーカスした物なので、4, 5章のより実践的な表現を関数型でどう対応していくか、是非お楽しみしてください。
Discussion