🍩

Haskellを雑に使う

2024/12/07に公開

はじめに

Haskell Advent Calender 2024の7日めの記事です。

さて、Haskellはアカデミックやプロダクションやホビーや日常使いなど様々なシーンで利用できます。

この記事ではHaskellを電卓くらいの気軽さでカジュアルに日常使いする一例を紹介します。(昨年のAdvent Calenderの記事と内容が重複気味ですが、今年はワークフロー視点で記載しています。)

なんとなく、アルゴリズムを手軽に試したい

日常的に、ちょっとしたアルゴリズムを手早く確認したり、書き捨てのプログラムをささっと作って試したいシーンがあるでしょう。あるものとします。

ここでは話を小さくするために、簡単な例として整数ソートのアルゴリズムを試したいとしましょう。

とりあえず書き捨ててみる

頭の中や紙の上などである程度考えたら、とりあえず雑に書き下してみましょうか。なんらかのテキストエディタで、ざっとコードを書いてしまいましょう。(テキストエディタは、メモ帳などのシンプルなもので構いません。手早く進めるために、IDEのセットアップなども必須ではありません。)

Sort1.hs (全文)
qsort :: [Int] -> [Int]
qsort []     = []
qsort (x:xs) = qsort smaller ++ [x] ++ qsort larger
  where
    smaller = [a | a <- xs, a <= x]
    larger  = [b | b <- xs, b > x]

まずは、注目しているコードを書いてしまいましょう。現時点では、ファイル先頭のmodule宣言などは不要です。アイデアを試すときは、スモールスタートで素早く動ける事にも価値があります。

雑に動かしてみる

さて、Haskellの良い点の一つは、対話的な実行環境(REPL(Read-Eval-Print-Loop))が提供されている点です。(もちろん他にも、例えばPython, Ruby, Scala, JavaScript, ... などのように対話環境が利用可能な言語の処理系もあります。)

Haskellのデファクト標準的な対話環境はGHCi (GHC’s interactive environment)です。GHCiは、Haskellの代表的なコンパイラであるGHCに付属している対話環境です[1]
GHCiを用いることで、先程のコード(Sort1.hs)の挙動を手軽かつ試行錯誤的に確認できます。コンパイルやテストコードの記述は不要です。

次のようにGHCiを起動して、先程のコードを動かしてみましょう。GHCiのコマンド名は、UbuntuやWSL2環境においてはghciです[2]

$ ghci Sort1.hs

以下のように該当関数(qsort)に適当な入力を与えることで、関数の動作を対話的に確認できます。
コードを書く時点において既に充分に設計を行えている場合であっても、試行錯誤的な確認はコードの理解度と品質をさらに深める上で有効です。

ghci> qsort [1..10]
[1,2,3,4,5,6,7,8,9,10]

ghci> qsort [10,1,5,8,3,2]
[1,2,3,5,8,10]

せっかく入出力の代表例が得られたので、メモっておく

GHCiを用いて確認した該当関数の入力と出力の関係は、関数の挙動を理解する上で有益です。特に、動作実績のある入出力の関係には価値があります。後からコードを読み返す際に便利なので、コメントにメモしておきましょう。いや、メモしておかないと10分後には絶対に忘れている自信があります。

Sort2.hs (全文)
-- |
--
-- >>> qsort [10,1,5,8,3,2]
-- [1,2,3,5,8,10]
qsort :: [Int] -> [Int]
qsort []     = []
qsort (x:xs) = qsort smaller ++ [x] ++ qsort larger
  where
    smaller = [a | a <- xs, a <= x]
    larger  = [b | b <- xs, b > x]

先頭の4行のコメント行を追加しました。これで、この関数を後で読み返す際に関数の挙動をすぐに理解できます。たかだか2行のコメントの追加ですが、先程のSort1.hsとこのSort2.hsを見比べれば、qsort関数に込めた作者の意図が少し明確になりました。

あれ、自動テストできますね

先程追加したコメントのうち、3行目の>>>で始まる行は、doctestコマンドによる自動テストの対象になります。と言いますか、そのためにdoctestの記法でコメントを書きました。(doctestは、Python等からHaskellエコシステムに取り入れられた、コメント中にテスト指示と結果を埋め込む形式によるテストツールです。)

doctestコマンドを用いて、ファイル(Sort2.hs)に記載されたコードをテストしてみましょう。

$ doctest Sort2.hs
Examples: 1  Tried: 1  Errors: 0  Failures: 0

上記doctestの実行結果からは、テストが成功したことが分かります。

doctestは、ソースファイルのコメント中の>>>で始まる行をHaskellのプログラムとして解釈して実行し、その次からの行を期待値としてテスト結果を判定します。

ということは、リファクタリングしやすいのではないか

自動テストできるということは、安心してリファクタリングやアイデアの試行を行えそうですね。

ここでは一例として、9〜10行目のsmallerlargerの定義箇所について、リスト内包表記による表現からfilter 関数による表現に変更するアイデアを試してみます。(リスト内包表記がダメな訳ではありません。あくまで、書き換えを示すための一例です。)

Sort3.hs (全文)
-- |
--
-- >>> qsort [10,1,5,8,3,2]
-- [1,2,3,5,8,10]
qsort :: [Int] -> [Int]
qsort []     = []
qsort (x:xs) = qsort smaller ++ [x] ++ qsort larger
  where
    smaller = filter (<= x) xs
    larger  = filter (> x) xs

doctestを用いて、テストが引き続き成功するかを確認してみましょう。

$ doctest Sort3.hs
Examples: 1  Tried: 1  Errors: 0  Failures: 0

テストは成功しています。Sort3.hsにおけるコードの書き換えは、ある程度正しそうです。

それにしても軽量なテストで素早く不備を検出できる安心感。これでリファクタリングし放題です。

しかし、こうなると、もうちょっとテストしたくなってきた

なにやらいい感じになったのですが、該当関数(qsort)をもっと色々な入力でテストしたくなってきました。

しかし、テスト用の入力パターンを手で作るのは面倒です。ラクをするために雑にHaskellを使っているので、入力パターンを大量に手作りすることは絶対に避けたい。

ということでテストデータの生成を、自動テスト用のツールであるQuickCheckに任せましょう。さらに、テスト結果の正しさの判定までもQuickCheckに任せてしまいましょう。

次の例の6行目のように、コメント行において prop> で始まる行を記述することにより、入力データの生成と実行結果の判定を自動で行えます。

doctestは、prop>で始まる行をプロパティ[4]であると解釈して、その処理をQuickCheckに委ねます。QuickCheckは、prop>の行に記載されたコードに対して、ランダムな入力データを生成して、その実行結果を判定します。(実行結果の判定は、実行結果がBool型におけるTrue値か否かによって行います。)

Sort4.hs (全文)
-- |
--
-- >>> qsort [10,1,5,8,3,2]
-- [1,2,3,5,8,10]
--
-- prop> qsort xs == Data.List.sort xs
qsort :: [Int] -> [Int]
qsort []     = []
qsort (x:xs) = qsort smaller ++ [x] ++ qsort larger
  where
    smaller = filter (<= x) xs
    larger  = filter (> x) xs

上記の例では、6行目のprop>の行における、qsort xs == Data.List.sort xs の部分が、QuickCheckに渡されるプロパティの部分です。

該当コードのqsort xs == Data.List.sort xsにおける変数xsに対する入力データは、QuickCheckが自動的にランダムに生成します。
なおこの例では、qsort関数の実行結果を標準ライブラリのソート関数(Data.List.sort)の結果と比べることで、結果の正しさを判定させています。

では、doctestを実行してみましょう。

$ doctest Sort4.hs
Examples: 2  Tried: 2  Errors: 0  Failures: 0

テストは成功しています。qsort関数は、ランダムな入力データに対して常にqsort xs == Data.List.sort xsが成立しています。なおQuickCheckはデフォルトでは、ランダムな入力データ列を用いて100回のテストを行います。

いやいや、本当にテストできているのか不安になってきた

しかし、QuickCheckによる自動テストがあまりにも手軽すぎて、本当にテストできているのか心配になってきました。
そこで、QuickCheckがバグを本当に検出できるのかを確認するために、あえてテストを失敗させてみましょう。

11行目のsmallerの定義箇所における条件式を、(<= x) から (< x) に変えてみます。

Sort4b.hs (全文)
-- |
--
-- >>> qsort [10,1,5,8,3,2]
-- [1,2,3,5,8,10]
--
-- prop> qsort xs == Data.List.sort xs
qsort :: [Int] -> [Int]
qsort []     = []
qsort (x:xs) = qsort smaller ++ [x] ++ qsort larger
  where
    smaller = filter (< x) xs
    larger  = filter (> x) xs

doctestコマンドを用いて、テストを実行します。

$ doctest Sort4ng.hs
Sort4ng.hs:7: failure in expression `qsort xs == Data.List.sort xs'
*** Failed! Falsified (after 6 tests and 2 shrinks):
[3,3]

Examples: 2  Tried: 2  Errors: 0  Failures: 1

期待通り、テストが失敗しました。さらに、テストが失敗した場合の入力値が[3,3]であったことも示してくれています。便利ですね。

念の為に、バグを混入させたqsort関数がどのように振る舞うかをGHCiを使って対話的に確認してみましょう。テストが失敗した場合の入力値である[3,3]を、qsort関数に与えてみます。

$ ghci Sort4ng.hs
ghci> qsort [3,3]
[3]

qsort関数が盛大にバグっています。入力データの中に同じデータが含まれていると、重複するデータが失われるというバグですね。この不具合をQuickCheckを用いて検出できていることが分かりました。

ということで、わずかprop>の1行を追加するだけで、ランダムな入力によるテストを実現できました。

以上のように、Haskellを雑に使ってアイデアを手軽に試せるとともに、ある程度の品質を容易に確保できそうであることが分かりました。手短にアイデアを試す上で、Haskellとその環境は便利ですね。

いやしかし、入力データが正しく生成されているのかも心配になってきた

(この節は、本記事の趣旨としては細かすぎる内容となるため、飛ばしてもらって構いません。)

今回は雑にHaskellを使う趣旨の記事ですので、前節までの内容で充分と言えます。

GHCiは試行錯誤的な開発に向いており、doctestとQuickCheckは記述が軽量であるためテストを身近にします。特にQuickCheckは魔術的で、次の1行を追加するだけで、ランダムな入力データの生成と結果のチェックを全て行ってくれました。

-- prop> qsort xs == Data.List.sort xs

しかし上記のテストにおいて、QuickCheckが正しい範囲の入力データを本当に生成したのかが心配になってきました。例えば、入力値として0は生成されているのか?、負の値も生成したのか?、最大値の生成は? そもそも、生成されたリストの長さはどれくらいなのか?

いやいやいや、ここに至り、雑な書き捨てゆえに目を背けていた事実が白日の下に晒されてしまいました。

QuickCheckがどのような範囲で入力値を自動生成したかの問題ではなく、設計者である自分は、qsort関数の入力値の範囲をどのように期待して設計したのか?

この点をシビアに考えることで、設計の品質は一段上がりそうですし、早い段階で不具合の芽もつめそうです。ランダムな入力による自動テストは、否が応にもこの視点に誘導してくれます。

本記事は、雑にHaskellを使う紹介記事であるため、入力値の範囲をどのように設計し、そして検証するかについては深く立ち入らずに、ここまでとします。
QuickCheckを使って雑にテストを始めるだけでも投資対効果が高いので、気負わずにQuickCheckを雑に使ってみましょう。たった1行のprop>で始まる行を追加しようと考えるだけでも、それから得られる効果は意外と大きいです。

以下では技術的な参考として、QuickCheckがランダムな入力値としてどのような値を生成しているかを確認する方法をいくつか簡単に紹介しておきます。

QuickCheckはテスト対象である関数の入力の型に応じて、適切なランダム値を自動で生成します。例えばInt型のリストに対しては、QuickCheckはデフォルトで次のような傾向のランダム値を生成します。

$ ghci
ghci> import Test.QuickCheck
ghci> sample' (arbitrary :: Gen [Int])
[[],[0],[-1],[6],[-1,0],[-8,1,0,-4,5,-3,-8,-4],[9,6,3,-10,11,11,0,7],[8,-8,-8,11,-5,5,10,-11,-5,7,6,3],[-9,-11,6],[],[-16,12,-18,-16,0,13,1,5,-15,6,-3,5,-13,13,12]]

また、今回のprop>に記述したプロパティに対して自動的に生成される入力値の傾向は、例えば次のようにverboseCheckなどを用いて確認できます。

$ ghci Sort5.hs
ghci> import Test.QuickCheck
ghci> verboseCheck $ (\xs -> qsort xs == Data.List.sort xs)
Passed:
[]

Passed:
[0]

(中略)

Passed:
[10,-57,-24,16,-41,-16,52,-93,90,-62,-36,-79,46,73,43,6,73,-21,88,-57,-18,54,-42,0,-69,80,76,79,-55,1,-75,98,-98]

+++ OK, passed 100 tests.

さらに、やや込み入ってきますが、入力値の統計のようなものをcollectなどを用いて確認できます。

$ ghci Sort5.hs
ghci> import Test.QuickCheck
ghci> quickCheck $ (\xs -> collect xs (qsort xs == Data.List.sort xs))
+++ OK, passed 100 tests:
 4% []
 2% [1]
 1% [-10,18,18,-17,10,-16,-28,-25,22,27,-27,25,28,-12,8,-12,-20,-8,29,3,16,17,20,27,-5,-28]
 1% [-10,4,15,39,11,-29,51,25,-57,29,-4,-46,-8,-31,22]
  :

本記事ではQuickCheckの詳細にはこれ以上立ち入りませんが、入力値の制御方法などを詳しく知りたい方は、記事末尾の参考情報のリンクからQuickCheckの仕様などを参照してください。

なんか良い感じになってきた。 使い捨てだけど、ドキュメント化しておこう

ということで、Haskellのコードを雑に書いて、挙動を試して、簡易なテストによってある程度の品質を素早く得る、という雑な使い方の紹介は以上となります。

ここからは必須の内容では無いのですが、せっかくですので綺麗な見栄えのドキュメントを生成してこの記事の結びとします。ドキュメント生成ツールのhaddockを用いて、例示したコードについてHTML形式のドキュメントを生成してみましょう。

以下のように、コードのモジュール構造をhaddockが理解できるように、ファイルの冒頭にmodule宣言の行を追加しておきます。

Sort5.hs (全文)
module Sort where

-- | Sort with pivot
--
-- >>> qsort [10,1,5,8,3,2]
-- [1,2,3,5,8,10]
--
-- prop> qsort xs == Data.List.sort xs
qsort :: [Int] -> [Int]
qsort []     = []
qsort (x:xs) = qsort smaller ++ [x] ++ qsort larger
  where
    smaller = filter (<= x) xs
    larger  = filter (> x) xs

その上で、次のようにhaddockコマンドを実行します。
この例では、distディレクトリの下にHTML形式のドキュメントが生成されます。

$ haddock --html --hyperlinked-source -o dist Sort5.hs

次のような綺麗な見栄えで、HTML形式のドキュメントが生成されます。

そしてSource表示のページでは、マウスカーソルを関数などの上に乗せると、型の情報などが表示されて便利です。

見栄えが良いので、さらにコメントをちゃんと書きたくなってきました。

いや、雑な使い捨てコードなので、この記事ではここまでとします。

(少しだけ補足) そういえば、プログラム全体を雑に実行したいこともある

前節で最後と言ったのですが、あと1点だけHaskellを雑に使うための補足です。

関数単位ではなくて、プログラム全体を雑に実行したい場合もあるかもしれません。

main関数を含むプログラム全体を対話的に試したい場合には、次のようにGHCi内で:mainコマンドを使うと良いでしょう。(実行時に引数を与える必要がある場合には、:mainに続けて記述できます[5]。)

$ ghci Hello.hs
ghci> :main
hello, world

プログラム全体を雑に実行する他の方法として、次のようにrunhaskellコマンドを用いてプログラムをスクリプト的に実行する方法もあります[6]runhaskellコマンドは、Haskellの代表的なコンパイラであるGHCに付属しているスクリプト実行用のコマンドです。
次のように、明示的なコンパイル過程を経ずにHaskellプログラムを直接的に実行できます。Haskellプログラムを雑にCLIコマンドとして使う場合などに適しています。

$ runhaskell Hello.hs
hello, world

さて本当の最後の最後として、おまけです。
もはやGHCiの起動も面倒くさいし、テキストエディタを開くのも面倒なとき。いや、main関数を書くのさえ面倒くさい!

そんな素晴らしく雑な使い方のために、GHCコンパイラの式評価モード(Expression evaluation mode)があります。

HaskellのGHCコンパイラを-eオプション付きで実行すると、-eオプションに続くHaskellのコードを直接的に実行できます。以下のようにHaskellをワンライナー風に実行できます[7]

$ ghc -e 'putStrLn "hello, world"'
hello, world

この-eに続く引数には、Haskellの式なら何でも記述できます[8]。 もはや本筋の内容から離れてすぎてGHCiもdoctestもQuickCheckも登場していない例ですが、とにかくHaskellは雑に使えます。しかもここでは型すら明示していませんが、雑なので良し。

$ ghc -e "[n^2 | n <- [0..4]]"
[0,1,4,9,16]
$ ghc -e '"hello, world"'
"hello, world"

さらに次のようにファイルを読み込ませて、そのファイル内の関数を実行することもできます。 main関数は不要で、純粋関数だけのコードを直接的に実行できます。

$ ghc -e "qsort [5,1,2]" Sort1.hs
[1,2,5]

すこし話がそれました。 気をとりなおして、まとめです。

まとめ

Haskellは、雑な日常使いにも良い感じで使えます。

とにかくghciコマンドとdoctestコマンドさえ手元にあれば、様々なアイデアをいつでも気軽に試せます。

テスト用のドライバ等を必要とせずにコードの挙動を試行錯誤的に確認できる対話環境は、アイデアをとりあえず試してみようという気持ちを後押ししてくれます。
複雑な記述を必要とせずに軽量にテストを行える仕組みは、リファクタリングを容易にするとともに、日常的なテストに対するハードルを下げてくれます。テストに必要なのは、コメント中に >>>prop> の行を記述することだけです。

ということで本記事を締めくくります。
アイデアを気軽に試したり書き捨てのプログラムを素早く作りたいシーンでは、以下の機能などが有効です。

  • GHCi (対話的な実行環境)
  • doctest (コメント形式による自動テスト)
  • QuickCheck (テストデータの自動生成とテストの自動判定)

これらは、Haskell(の言語仕様と処理系)だけの特徴ではありませんが、Haskellも良い感じで雑に使えます。Haskellを使い始める上で型システムやモナド等の理解は(面白いけれども)必須では無いので、カジュアルな軽い感じで、Haskellを雑に使ってみましょう[9]

では、Happy Haskelling!

参考情報など

脚注
  1. GHCiをインストールする方法は、例えば、https://zenn.dev/mod_poppo/articles/haskell-setup-2023 を参照してください。 ↩︎

  2. 本記事のコマンドの実行例は、UbuntuまたはWindowsのWSL2環境における操作例です。 ↩︎

  3. tinfoライブラリの不足によるエラーが出る場合には、次のようにしてlibtinfoをインストールすることでエラーを解消できる場合があります。$ sudo apt install libtinfo-dev ↩︎

  4. ここでは、プロパティとは、満たすべき仕様を真偽値によって判定可能に記述した実行可能なコードとしておきます。 ↩︎

  5. ghciの各コマンドの使い方を忘れた場合には、ghci内で ghci> :helpのようにコマンドを実行すると簡単なヘルプが表示されます。 ↩︎

  6. runhaskellコマンドには、runghcコマンドという別名もあります。 ライブラリの依存関係が多様な状況でrunhaskellをうまく使う方法については、こちらの記事が参考になります。https://zenn.dev/mod_poppo/articles/haskell-script ↩︎

  7. ghc -eでHaskellをワンライナー風に使用する例は、こちらも参照してください。https://github.com/takenobu-hs/commandline-haskell ↩︎

  8. 実はghc -eは内部的にGHCiを使用しているので、-eに続く文字列にはGHCiに渡すコマンドなら何でも与えられます。 例えば、ghc -e ':t foldl'のようGHCiのサブコマンドも利用可能です。 ↩︎

  9. なんと、Haskell(GHC)の処理系をインストールすることさえ面倒くさい? いいですね、それでこそ雑!そのような場合は、Haskell.orgが公式に提供しているWeb版のPlayground環境で、雑に遊び始めましょう。https://play.haskell.org/ ↩︎

Discussion