Haskellでの名前空間管理

ここ最近、性懲りもなくまたHaskellを書いてみている。少しずつコツがつかめてきたのか、以前よりストレスなく書けるようになってきたと思う。(Perlに比べると書くスピードは半分くらいだが)

ただ、ある程度の大きさのプログラムを1枚のファイルに書いていると、そのファイルの名前空間がどんどん散らかってきてキツいと感じるようになってきた。


Haskellで外部のモジュールを使うときはimport文を使う。

import Hoge (hoge)

foo x = hoge (x + 1)

この場合、モジュールHogeからインポートしたシンボルhogeはファイル全体で有効になる。つまりファイルごとに名前空間があり、そこにシンボルが追加されるという格好である。

また、以下のようにすると、シンボルへのアクセスに修飾語が必須となる。

import qualified Hoge as H

foo x = H.hoge (x + 1)

これはファイルの名前空間にHという子空間を作り、そこにシンボルをインポートしているとみなせる。

こういったインポートの仕組みはPerlでもだいたい同じである。つまり、関数をインポートするときは基本的にファイル全体にインポートされる。しかし、Haskellでは様々な概念が関数としてフラットに扱われているので、Perlなどでは起こらない厄介な事情が起こる。

問題1: レコード構文

Haskellである程度複雑なデータ構造を定義するときはレコード構文がよく使われる。

data Person = Person {
  name :: String,
  age :: Int
}

レコード構文はC言語でいうところの構造体を定義するために便利な構文である。しかし、Haskellで独特なのはフィールド名に相当するシンボルがただの関数であるという点だ。

例えば、nameというシンボルは単にPerson -> Stringな関数であり、同じファイル中に定義された「普通の」関数となんら変わりはない。

レコード構文のフィールド名がフラットな関数であるということは、異なるデータ型の間でフィールド名が衝突しうるということである。

例えば

data Item = Item {
  name :: String,
  price :: Int
}

このようなデータ構造を上記のPersonと同じファイルに定義したり、あるいはPersonとItemを同じファイルに非修飾インポートしたりすると、nameシンボルが衝突してコンパイルエラーとなる(nameを使わなければコンパイル通るかも)。

このようなことから、実際のHaskellのコードではフィールド名にprefixをつけることが多いようだ。

data Person = Person {
  personName :: String,
  personAge :: Int
}

data Item = Item {
  itemName :: String,
  itemPrice :: Int
}

もうこの時点でだいぶイヤになってくる。

問題2: 独自演算子DSL

Haskellではユーザが比較的簡単に独自の演算子を定義して使うことができる。Haskellのライブラリにはこの性質をフル活用するようなものがいくつかある。

例えば、HUnitではテストのラベル付けやアサーション演算子が定義されている。

import Test.HUnit

testHoge = "hoge should prepends 'hoge'" ~: (hoge "foo" ~?= "hoge foo")

AesonではJSONオブジェクトのエンコードやデコードのための演算子が定義されている。

import Data.Aeson

instance FromJSON Person where
  parseJSON (Object v) = Person <$>
                         v .: "name" <*>
                         v .: "age"
  parseJSON _          = mzero

Lensでは複雑なデータ構造の内部にアクセスするための演算子が定義されている。

import Control.Lens

setFstToHello pair = pair & _1 .~ "hello"

こういったモジュールはHaskellのコードの上にDSLを構築するようなものであり、演算子とその使い方さえ覚えれば簡潔にコードを書けるようになる。

しかし、こういった発想のモジュールを複数同じファイルにインポートすると何が何だか分からないコードになりやすくなると思う。一体この演算子はどこからやってきたものだろうか?よほど使い慣れたものでないと分からなくなるだろう。

このような問題は何も独自演算子に限ったことではなく、モジュールの全シンボルをごそっとインポートするとだいたい起こることだが、独自演算子に関しては特に混乱しがちだと思う。

対処1: OverloadedRecordFields拡張, lens, record, extensible

レコード構文の問題については、以下の記事がほぼ最新の状況だろうか。

GHC 7.12からはOverloadedRecordFields拡張というものが導入されるようだが、いまいち使いづらいところがあるらしい。

recordライブラリはQuasiQuoteを使ってレコード型の定義、データ構築、アクセサ(lens)作成を可能とするものなようだ。コード中にQuasiQuoteが大量出現するのがちょっと気持ち悪いが、性能上のデメリットも少ないらしく、現状でオススメな解らしい。

また、lensにもTemplateHaskellを使って普通のレコードフィールドからシンプルなlens関数を自動生成する機能があるらしい。

extensibleは「拡張可能レコード」を実現するものらしいが、正直、自分の理解の範疇を超えている。機会(というか必要性)があれば調べてみたい。

対処(案)2: ローカル(あるいはブロック / レキシカル)インポート

特に上記問題2について自分が真っ先に考えたのが、インポートの範囲をファイル単位ではなくもう少し狭いスコープでできないかということだ。

例えば以下のような書き方ができないものか。

import Data.Aeson where
  instance FromJSON Person where
    parseJSON (Object v) = Person <$>
                           v .: "name" <*>
                           v .: "age"
    parseJSON _          = mzero

importしたシンボルはwhere以下のブロック内でのみ有効、ということである。

しかし、ざっと調べてみた限りではあまりこういったことを実現する動きはないようだ。実際やろうとするといろいろ気持ち悪いコーナーケースが生じるのかもしれない。

ただ、Reddit上では多少議論されていた。

Agdaではこのような仕組みがあるらしい。

対処3: モジュールを細かく分ける

GHC拡張も便利なライブラリも極力使わないスタンスであれば、やはりモジュールを細かく分けるというのが現実的なアプローチなのだろう。その上でimport qualifiedすれば名前の衝突は起こらない。

「基本的に全てのimportを統一的にqualifiedとすべき」という考え方の人もいる。

正論だと思うが・・・ さすがにControl.MonadとかControl.Applicativeくらいは非修飾でいいんじゃないだろか。