最近、現在時刻が関わるプログラムを題材に、高テスタビリティなプログラミング作法を解説した素晴らしい記事が復刻されて、感想などがTLに流れてきたので、自分もそのお題を関数型プログラミングで解いてみた記事。
はじめに
最近、こんな引用ツイートをした。
関数型界隈だと、参照透過な部分とそうでない部分(現在時刻, 乱数, etc.)を分離しといて使うところで合成する作法が尊重されてて、simplicity と composability の結果として、テスタビリティや柔軟性が高くなる(低くならない)ということがよく謳われている。あとで自分もFPでお題解いてみよう。 https://t.co/00TwqXmtC7
— yasuabe (@yasuabe2613) September 30, 2019
元記事は、t-wadaさんの『現在時刻が関わるユニットテストから、テスト容易性設計を学ぶ 』で、めちゃくちゃシンプルなお題なのに、解説がすばらしいのはもちろんのこと、寄せられた解答例も面白くて、自分でも書いてみたくなった。
記事の内容は概ねオブジェクト指向寄りなスタイルって気がするけど、個人的にはこの数年、関数型な Scala をやってるので、どちらかというと関数型・宣言型を意識する趣向でやってみたい。
考え方としては、FP界隈ではもともと、シンプルなものをシンプルなまま分けておくことで、自ずとテスタビリティが向上するということがよく言われているので、先にテスタビリティを意識しながら書くというより、性質や関心事が異なるものを分離して書いたら、結果的にテストも書きやすくなる(書きにくくならない)という様子を見てみたい。
(関数型になれてない人でもなるべく読めるようにしたい。。。)
お題
もとのお題は以下のようなものだった。
【仕様1】
「現在時刻」に応じて、挨拶の内容を下記のようにそれぞれ返す機能を作成したい。
(タイムゾーンは Asia/Tokyo とする)
- 朝(05:00:00以上 12:00:00未満)の場合、「おはようございます」と返す
- 昼(12:00:00以上 18:00:00未満)の場合、「こんにちは」と返す
- 夜(18:00:00以上 05:00:00未満)の場合、「こんばんは」と返す
例: 13時に `greeter.greet()` を呼ぶと "こんにちは" と返す
よく考えるとこの一つの機能の中に、3つの観点があるように見える。
以下の実装で、それぞれを分けて書いてみる。
実装
- 言語は Scala 3.1.2 を使用
- 関数型プログラミングの補助のため、Cats / Cats Effect を使用
- 時刻にはシンプルに
java.time.LocalTime
を使用(明示的な Asia/Tokyo 指定は省略した) - テスティングフレームワークは munit ベースで、プロパティテストのために ScalaCheck を併用した
バージョン等の詳細はこの辺り。
1. 時刻からあいさつへのマッピング
LocalTime
型のどの値がどのあいさつ文字列に対応付けられるかにのみ着目して、それ以外の関心事を含まない関数3が以下。
def greetingOf(t: LocalTime): String =
val before = (h: Int, m: Int, s: Int) => t isBefore LocalTime.of(h, m, s)
if before( 5, 0, 0) then "こんばんは"
else if before(12, 0, 0) then "おはようございます"
else if before(18, 0, 0) then "こんにちは"
else "こんばんは"
まったく一つのことしか扱っていないので、シンプルに書ける。もちろん参照透過も成立する。
テストコード
境界値テストは以下のように書ける。
test("与えられた時刻があいさつに正しく対応付けられる") {
List(
// 時 | 分 | 秒 | ナノ | 挨拶
(( 0 , 0 , 0 , 0), "こんばんは" ),
(( 4 , 59 , 59 , 999999999), "こんばんは" ),
(( 5 , 0 , 0 , 0), "おはようございます"),
(( 11 , 59 , 59 , 999999999), "おはようございます"),
(( 12 , 0 , 0 , 0), "こんにちは" ),
(( 17 , 59 , 59 , 999999999), "こんにちは" ),
(( 18 , 0 , 0 , 0), "こんばんは" ),
(( 23 , 59 , 59 , 999999999), "こんばんは" ),
) foreach { case ((h, m, s, n), expected) =>
val t = LocalTime.of(h, m, s, n)
assertEquals(greetingOf(t), (expected))
}
}
実際は、テストを先に書いて Red-Green-Refactor を何度か繰り返して簡単にしたが、見たままのわかりやすいテストコードになっていると思う。
※ 元記事でも、最小単位が秒でよいかと問われてたけど、最近見た BigQuery の集計でミリ秒部分が抜けていて毎日1秒分のデータが無視されていたケースがあったので、ここでは LocalTime
の解像度ぎりぎりの境界値を設定しておいた。
2. 現在という文脈上の時刻を得る
Scala では例えば以下のように書ける。
trait Now[F[_]]:
def time: F[LocalTime]
言葉にすると「F[_]
は抽象的に変数化されたある種の文脈であり、Now[F].time
と書けば、その文脈の中で LocalTime
が得られる」といったものになる。また、Now[F].time
をさらに言い換えれば、「現在(Now)という文脈(F)における時刻(time)」を、Scalaの文法でそのままくっつけた感じになる。
F[_]
には、同じような型のパターンを持つ Id[_]
や IO[_]
のような具体的なデータタイプが入る(こうした構成自体が「記述と実行の分離」となっていて、関心事の分離の例だったりする)。
デフォルト実装は後回しにして、この Now
が、参照透過な greetingOf
とどのように合成されるかを次に見る。
3. 合成
F[_]: Functor: Now
の部分を解説すると、greetNow
が使われるスコープ内に Functor[F]
(函手4)と Now[F]
のインスタンスがあることがコンパイルできる条件ということになる。言い換えれば、Functor[F]
と Now[F]
のインスタンスが暗黙に渡されることになる。
def greetNow[F[_]: Functor: Now]: F[String] =
Now[F].time map greetingOf
上のコードを命令型パラダイム的に解釈すると、
- 暗黙に与えられた
Now[F]
インスタンスのtime
メソッドを呼び出して - 返された
F[LocalTime]
型の戻り値の中身に、関数greetingOf
を適用して -
F[String]
として返しなさい
のように読めるが、宣言型/関数型っぽく捉えれば5「F[_]
が函手なので greetingOf
から F[greetingOf]
が得られ、これを Now[F].time
に合成すると greetNow
が得られる。」とも読める。図に書くと以下のようになる。
テストコード
先に書いた greetingOf
を使うと、以下のような自明なプロパティ(性質)が成り立つ。
Now[F].time が返す現在時刻がどんな値でも、その値を greetingOf に与えて得られた挨拶と、greetNow で得た挨拶は等しい。
プロパティが得られると、関数型プログラミングでよく使われるプロパティベーステストに持ち込めるので、ScalaCheck を使って以下のように書ける
property("現在時刻があいさつに正しく対応付けられる") {
given Arbitrary[LocalTime] = Arbitrary {
val hourGen = Gen.choose(0, 23)
val minGen = Gen.choose(0, 59)
val secGen = Gen.choose(0, 59)
val nanoGen = Gen.choose(0, 999999999)
(hourGen, minGen, secGen, nanoGen) mapN LocalTime.of
}
forAll { (t: LocalTime) =>
given Now[Id] with
def time: Id[LocalTime] = t
greetNow[Id] == greetingOf(t)
}
}
これもプロパティをそのまま Scalaコードに落とした感じで、わかりやすいのではと思う。
Id
についてだけざっくり解説すると、型A
と型F[A]
を区別せずに扱えるような F[_]
の一種で、このサンプルの具体例だと LocalTime
が Id[LocalTime]
として使えるようになって、テストコードが簡単になっている。これも「記述と実行を分離」した結果得られる、テスタビリティ向上の一例と言えると思う。
デモ
ここまでのコードを使った少しだけリアルな使用例を書くと、以下のような感じになる。
object GreeterDemo extends IOApp.Simple:
import greet._
given Now[IO] with
def time: IO[LocalTime] = IO.delay(LocalTime.now)
def run: IO[Unit] =
((Now[IO].time map greetingOf) >>= IO.println) >>
(greetNow[IO] >>= IO.println)
- ここで初めて、プロダクトコード中の
Now
インスタンスを作ったが、通常は、デフォルト(本物)実装をどこか適当なところにおいておくことになる。 -
F[_]
には、非同期・遅延実行を意識して Cats Effect のIO
を使ってみた。サンプルなのでLocalTime.now
としたが、例えば現在時刻取得サーバのような外部サービスにアクセスする場合でも、同じシグネーチャで使える。もちろん Monix や ZIO でもかまわない。
自己レビュー
元記事のテストコードのポイントと照らし合わせてセルフチェックしてみる。
良いユニットテストは Repeatable (繰り返し可能、再現可能)
- 参照透過性が無くなる境界に
Now[Id]
型のテストダブルがあり、テスト内で制御できるようになっている - ScalaCheck を使ったところはランダムに値が生成されるので、常に同じ結果が再現されるとは言えないが、どの値で失敗したかは分かるようにレポートされる。
良いユニットテストは Independent (独立している)
- テストコード含めて副作用のないピュアな関数型コードなので、テストの順序による影響や、後始末忘れによる挙動の変化などはない。
アサーションルーレット(Assertion Roulette)に注意する
-
greetingOf
のテストでは、TableDrivenPropertyChecks
を使ってパラメータ化テストぽく書いてみたが、失敗時にどのパラメータ×期待結果のペアで失敗したのかレポートされるようになっているものの、一つコケると次に進むことなく止まってしまうのがやや難。
脆いテスト(Fragile Test)に注意する
-
greetingOf
が private じゃなくてよいか一瞬迷ったが、内部ロジックというより明らかに仕様の一部なので、むしろ public で良しとした。その他、実装依存で壊れやすいテストコードは特にない気がする。
おわりに/補足
- シンプルなお題ながら、実にいろいろと考えさせられる。この問題自体が シンプルだけど必ずしもイージーでないもの の一つの例のような気もする
- 今回は「合成」の部分での
Now
の与え方として、シンプルな型制約で implicit に渡す方式としたが、他の関数型DI の技法もいくつかある。 - 特に、仕様2のロケールのような文脈依存値が追加されたりすると、
trait Locale[F[_]]
を追加してから型制約にF[_]: Now: Locale: ...
のように書き足すことになり、増えていくと取り回しやらいろいろ不便になってくることもある。回避策は若干高度な関数型プログラミングの技法になる。 - 今回は Scala でやったけど、他の関数型言語(Lisp系、ML系)でやっても面白いと思う。
- 元記事で言及されている『xUnit Test Patterns』は、かなり分厚い本だけど、すくなくともアンチパターンのところだけでも、もっと広く読まれるべき価値がある気がする。
- 2022/06/21 に、サンプルコードを Scala 3 系と Cats Effect 3 系で書き直した。
-
関数呼び出し(式)をその結果の値に置き換えても常にプログラムの振る舞いが変わらないこと ↩
-
total function。定義域のどの要素をとっても値域のなんらかの要素に対応づけることができる関数。逆に、定義域のある要素を与えると対応付けが得られなくなる(プログラム的には値を返す代りに例外を送出するような)関数は、部分関数という。 ↩
-
Scala 用語では、def で定義したものをクラスの構成要素であることを示してメソッドと言って、関数(値)(function value)と区別したりするけど、ここではもっと一般的な用語法の、集合から集合への写像の同義語としての呼び方の「関数」を採用した。 ↩
-
圏論的な定義はおいといて、プログラミングに関係するところだけあえて不完全に説明すると、型Aを型Bに対応付ける関数から、型F[A]から型F[B]に対応付ける関数が得られるような構造、というか前提といったもの。Haskell だと
fmap :: (a -> b) -> f a -> f b
のようなシグネーチャの関数が函手ごとに提供される。 ↩ -
宣言型なスタイル(declarative style)は Domain-Driven Design(DDD)でも、むかしから結構強めに称揚されている。 ↩