🐈

Haskellプロジェクトのベストプラクティス

2023/08/08に公開

Haskellプロジェクトの「良い習慣」と考えられるやつをまとめてみます。あくまで私の個人的な意見です。

プロジェクト固有のPrelude

Prelude に相当するモジュールをプロジェクト独自に持っておくと便利ではないか、という話をします。代替Preludeの話ではありません。

プロジェクト固有のPreludeがあると便利な理由

理由の一つは、標準 Prelude の変化です。直近では次のような変化がありました:

  • GHC 9.4: ~ 型演算子が追加(これまでは構文だった)
  • GHC 9.6: liftA2 が追加
  • GHC 9.10(見込み): foldl' が追加

もっと昔に遡ると、Semigroup((<>)) が増えるやつなどがありました。

この帰結として、

  • 新しいGHCで名前の衝突が起きやすくなる
  • 新しいGHCで「冗長なインポート」の警告が出やすくなる

ことが言えます。これらの問題を避ける(避けやすくする)ために、標準の Prelude をラップしたプロジェクト固有の Prelude を用意することが考えられます。

別の理由は、デバッグの都合です。Prelude には部分関数が多く含まれます。headtail はあまりにも有名ですが、そのほかにも (^) みたいなやつもあります。

汎用的な関数でエラーが起きた際に原因箇所を特定するのに役立つのが HasCallStack です。これは自分で用意した関数に対しては使えますが、標準ライブラリーの関数には使えません。headtail にはGHC 9.4 (base-4.17) で HasCallStack がついたので良いのですが、 (^) にはついていないままです。

プロジェクト固有の Prelude があると、デバッグの際に独自 Prelude で一時的に HasCallStack 適用済みの定義に差し替えることができて便利ではないでしょうか。

あとは、頻繁に使う名前をプロジェクト固有の Prelude に追加しておくと便利かもしれません。

最近、Int8 とか Word64 みたいなサイズ指定整数型を Prelude からエクスポートしようという提案がありました(却下されましたが)。

また、この記事を執筆している時点では、 Data.Kind.TypePrelude に追加しようという議論が行われています。

議論の結果がどう転ぶとしても、サイズ指定整数型や Type カインドを毎回 import するのは面倒だというプロジェクトはそれなりにありそうなので、そういうプロジェクトであれば独自 Prelude を用意してそれらの名前をデフォルトで使えるようにするのはアリでしょう。

プロジェクト固有のPreludeの用意の仕方

最小の独自 Prelude は次の通りです:

-- MyPrelude.hs
module MyPrelude (module Prelude) where
import Prelude

使用する側は、愚直にやるなら次のようになるでしょう:

{-# LANGUAGE NoImplicitPrelude #-}
import MyPrelude

NoImplicitPrelude.cabaldefault-extensions に書けばモジュールごとに書く必要はなくなります。

import MyPrelude を書くのすら面倒だという場合は、独自 PreludePrelude というモジュール名で用意すると良いでしょう。その場合、GHCが独自 Prelude を勝手に import してくれます。独自 Prelude では PackageImports 拡張を使って次のように書く必要があります:

-- Prelude.hs
{-# LANGUAGE PackageImports #-}
module Prelude (module Prelude) where
import "base" Prelude

参考:

実例

「プロジェクト固有の Prelude」を実践している例としては、llvm-hs-pure/llvm-hsパッケージがあります。これはApplicative-MonadとかSemigroup-Monoidの変化の前後で一貫した Prelude を使いたい、というモチベーションから実践しているようです。

これは NoImplicitPrelude 拡張を default-extensions で有効にして、他のモジュールでは import LLVM.Prelude を書いているようです。llvm-hs-pureパッケージで定義した独自 Prelude をllvm-hsパッケージでも使うために、LLVM.Prelude は公開モジュールとなっています。

拙作fp-ieeeパッケージでも独自 Prelude を用意しています。とは言っても、標準の Prelude を再エクスポートしているだけですが。

default-extensionsでGHC2021を模倣する

GHC2021、使っていますか?個人的には PolyKinds を除けば GHC2021 は良いものだと思います。

GHC2021 はGHC 9.2以降の比較的新しい機能なので、古いGHCも気にする汎用的なライブラリーでは使いにくいかもしれません。

そういう場合でも、.cabaldefault-extensions をガンガン使って GHC2021 に近い機能をパッケージ内ではデフォルトで使えるようにすると良いかもしれません。default-extensions に拡張を追加する基準として GHC2021 を使うわけですね。

これを実際にやっているパッケージとして、Google ResearchのDex言語があります:

Stackユーザー向け:.cabal ファイルをgitで管理する

Stackの特徴の一つが、package.yaml でプロジェクトを記述できることです(正確には、そういう機能を持ったHpackというツールを統合していることです)。

Stackはビルド時に自動で package.yaml から .cabal ファイルを生成するわけですが、生成された .cabal ファイルはgitリポジトリに追加するべきでしょうか?

昔はともかく、今は「追加するべき」というのが公式の見解です。

Cabalユーザー的には、Hackageにリリースされていないgitのバージョンを source-repository-package で参照して使うには .cabal がGitに入っている必要がある、という動機もあります。

開発時・CI時に有効にすると良いかもしれないオプション

開発時やCIを回すときは、なるべくバグを発見しやすくなるようなビルドオプションを指定すると良いでしょう。そういうオプションをいくつか挙げます:

  • -dlint (GHC 9.4以降): -dcore-lint -dstg-lint -dcmm-lint -dasm-lint -fllvm-fill-undef-with-garbage -debug
  • -fcheck-prim-bounds (GHC 9.2以降)
  • -fno-ignore-asserts

-dlint は従来の各種lintオプションをまとめたものです。-fcheck-prim-bounds はプリミティブな配列の範囲外アクセスを検知してくれるやつです。いずれも説明は

を参照してください。

-fno-ignore-asserts は最適化オプション -O が有効な状況でも assert を残すやつです。詳しくは

を参照してください。

これらのオプションを有効にするには、 cabal.project に以下のように書くと良いでしょう:

-- いつもの
packages: .

-- 開発用設定
program-options
  ghc-options: -dcore-lint -dstg-lint -dcmm-lint -dasm-lint -fllvm-fill-undef-with-garbage -debug -fno-ignore-asserts

-- この書き方はCabal 3.10以降が必要そうなので注意
if(impl(ghc >= 9.2))
  program-options
    ghc-options: -fcheck-prim-bounds

(ドキュメントは「トップレベルの ghc-options はローカルパッケージに適用される」と読めますが、実際は program-options の中に書く必要があるようです:Using ghc-options at the top-level in project encouraged by docs but unavailable. · Issue #8781 · haskell/cabal

あるいは、CIでcabalを呼び出す際に --ghc-options="-dlint -fcheck-prim-bounds -fno-ignore-asserts" などのオプションを指定すると良いでしょう。

ベンチマークを含むプロジェクトの場合は、cabal.project に書いてしまうと邪魔かもしれません。どこでオプションを有効化するか、適宜判断してください。

依存パッケージのバージョンの下限を検証する

あるパッケージが依存するパッケージのバージョン指定はなるべく広く取りたいです。依存パッケージの最新のバージョンで試すのはいいとして、バージョンの下限が正しいか試すのはなおざりになりがちです。

cabal-install 3.10では、「依存パッケージのなるべく古いバージョンを試す」--prefer-oldest というオプションが実装されました。

build-dependsの範囲を決定するのに使ったり、CIのmatrixとかに追加しておくと良いかもしれません。

その他

最小限のメンテナンスコストで長く使われるパッケージを作りたいなら、 -Werror.cabal に書くのはやめた方が良いでしょう。書くなら cabal.project とかCIの設定にしましょう。

.cabal ファイルを綺麗にしたいならcabal-fmtを使うといいかもしれません(と言いつつ私は導入できていない)。

Discussion