Haskell

Stackを使って楽しくHaskellスクリプティング

More than 3 years have passed since last update.

今までいまいちモチベが上がらなかったHaskellでスクリプトを書くというのが、急に現実的になってしまったので、紹介します。

Haskellでスクリプティングする上での問題点

Haskellはもともと簡単なテキスト処理を書きやすいプログラミング言語ではあるのですが、標準で提供されているライブラリはあまり多くないので、必要に応じてコミュニティーパッケージを導入しなければその力を存分に発揮することができません。

通常のパッケージなら、cabalに依存関係を書けばパッケージマネージャで自動的に(コケることもありますが、理想的には)管理できるのですが、シェルスクリプトやPerl、あるいは最近ならPythonでやるような、コードを直接インタプリタで実行するような形のコードでは、そのような依存関係を自動で解決することは難しく、その上、仮にやろうとしたところで、いつまでもその依存パッケージが新しいコンパイルできる補償もありませんし、あるいはインストールしているパッケージを壊さずにインストールできるとは限りません。

そういうわけで、言語的なポテンシャルはありつつも、なかなかそういう周辺的な事情によってHaskellを選びづらいところはありました。Haskellではそういうスクリプティングのための、優れたDSLがいくつかの分野にありまして、例えば、シェルプログラミングのためのEDSLであるところのShelly、ビルドスクリプトを書くためのEDSLshake、Webサイト構築のためのEDSLhakyllなどがありますが、本来のポテンシャルを発揮するにはちょっと面倒なところがありました。

かくいう私も、tanakh.jpというサイトをHakyllで作っていたのですが、1年ぶりぐらいに記事を書こうかと思うと、そのたびにコンパイルできない状況になっていたりして、本来の記事を書く前段階で躓いてしまうことも多いのでした。

Stackによる解決

Stackというのが最近開発されていて、少し前に私も紹介記事を書いたのですが、これが最近のバージョンでインタプリタで実行する場合における機能拡張が行われて、この用途で利用が非常に便利になりました。

https://github.com/commercialhaskell/stack/wiki/Script-interpreter

詳しくはここに書いてあるとおり、非常にシンプルなので、それこそもう全く解説することはないんですが、要するに、shebangでインタプリタにstackを指定すればそれでいいんですが、shebangではインタプリタの引数二つ目以降の扱い方がシェルによってまちまちなので、二行目に引数を書けるように拡張したということのようです。

例としてShellyを用いたスクリプトを書いて実行してみましょう。

script.hs
#!/usr/bin/env stack
-- stack --resolver=lts-2.16 runghc --package=shelly

{-# LANGUAGE ExtendedDefaultRules #-}
{-# LANGUAGE OverloadedStrings    #-}
{-# OPTIONS_GHC -fno-warn-type-defaults #-}
import           Control.Monad
import qualified Data.Text     as T
import           Shelly
default (T.Text, Int)

main :: IO ()
main = shelly $ do
    withTmpDir $ \temp -> do
        forM_ [1..10] $ \i -> do
            touchfile $ temp </> show i <.> "txt"
        inspect temp
        cmd "ls" "-alF" temp

とても短いスクリプトなので半分はShellyのテンプレートみたいなものですが、mainの部分でやっていることは、

  • テンポラリディレクトリの中で (withTmpDir)
  • 1.txt, 2.txt .. 10.txt を作る (touchfile)
  • パス名を表示する (inspect)
  • lsコマンドを実行する

といったあまり意味のないことです。

stackで起動するために一行目に#!/usr/bin/env stackと書いています。二行目にstackに与える引数を書きます。これは普通にシェルから起動するときと同じコマンドを書けば良いようです。

--resolverで使いたいresolverを指定できます。ここではlts-2.16を指定しています。これを指定することによって、いつでも同じパッケージの集合を依存解決に使えるので、将来にわたって依存関係が壊れないことが保証できます。また、resolverはコンパイラのバージョンも固定するので(lts-2.16で使われるのはghc-7.8.4)、処理系の互換性問題も考えずにすみます。

--packageに、依存するパッケージを書きます。ここではShellyを使うので--package=shellyとしています。複数のパッケージを使いたい場合は、必要なだけ--packageを並べることになるようです。カンマ区切りリストなどでは渡せないようです(今のところでは)。

ではこのファイルに実行権限を与えてシェルから起動してみます。

$ chmod +x script.hs
$ ./script.hs
Using resolver: lts-2.16 specified on command line
newtype-0.2: configure
newtype-0.2: build
unix-compat-0.4.1.4: configure
newtype-0.2: install
unix-compat-0.4.1.4: build
stm-2.4.4: configure
stm-2.4.4: build
mtl-2.1.3.1: configure
mtl-2.1.3.1: build
text-1.2.0.6: configure
stm-2.4.4: install
text-1.2.0.6: build
constraints-0.4.1.3: configure
unix-compat-0.4.1.4: install
mtl-2.1.3.1: install
async-2.0.2: configure
constraints-0.4.1.3: build
async-2.0.2: build
transformers-compat-0.4.0.3: configure
constraints-0.4.1.3: install
transformers-compat-0.4.0.3: build
async-2.0.2: install
transformers-compat-0.4.0.3: install
transformers-base-0.4.4: configure
transformers-base-0.4.4: build
exceptions-0.8.0.2: configure
transformers-base-0.4.4: install
exceptions-0.8.0.2: build
monad-control-1.0.0.4: configure
exceptions-0.8.0.2: install
monad-control-1.0.0.4: build
monad-control-1.0.0.4: install
lifted-base-0.2.3.6: configure
lifted-base-0.2.3.6: build
lifted-base-0.2.3.6: install
enclosed-exceptions-1.0.1.1: configure
enclosed-exceptions-1.0.1.1: build
lifted-async-0.7.0.1: configure
enclosed-exceptions-1.0.1.1: install
lifted-async-0.7.0.1: build
lifted-async-0.7.0.1: install
text-1.2.0.6: install
system-filepath-0.4.13.4: configure
system-filepath-0.4.13.4: build
system-filepath-0.4.13.4: install
system-fileio-0.3.16.3: configure
system-fileio-0.3.16.3: build
system-fileio-0.3.16.3: install
shelly-1.6.1.2: configure
shelly-1.6.1.2: build
shelly-1.6.1.2: install
Completed all 17 actions.
FilePath "C:\\Users\\tanakh\\AppData\\Local\\Temp\\tmpThreadId5116784"
total 11000
drwxr-xr-x 1 tanakh tanakh 0 Jul  8 00:51 ./
drwxr-xr-x 1 tanakh tanakh 0 Jul  8 00:51 ../
-rw-r--r-- 1 tanakh tanakh 0 Jul  8 00:51 1.txt
-rw-r--r-- 1 tanakh tanakh 0 Jul  8 00:51 10.txt
-rw-r--r-- 1 tanakh tanakh 0 Jul  8 00:51 2.txt
-rw-r--r-- 1 tanakh tanakh 0 Jul  8 00:51 3.txt
-rw-r--r-- 1 tanakh tanakh 0 Jul  8 00:51 4.txt
-rw-r--r-- 1 tanakh tanakh 0 Jul  8 00:51 5.txt
-rw-r--r-- 1 tanakh tanakh 0 Jul  8 00:51 6.txt
-rw-r--r-- 1 tanakh tanakh 0 Jul  8 00:51 7.txt
-rw-r--r-- 1 tanakh tanakh 0 Jul  8 00:51 8.txt
-rw-r--r-- 1 tanakh tanakh 0 Jul  8 00:51 9.txt

最初にパッケージの依存関係がチェックされて、必要な物があればインストールされます。なお、ここでは行われていませんが、必要な処理系がインストールされていなければ、それもここでインストールされます。新しいghcや古いghcが必要な場合も安心です。インストールが成功すれば(必ず成功するはずですが)、スクリプトが実行されます。確かに記述したようなコードが実行されているのがわかります。

例:ビルドスクリプト

せっかくなのでもう一つ、ビルドシステムを書いてみることにします。shakeと、C/C++を扱うためのパッケージshake-language-cを使って、C++プログラムをコンパイルするスクリプトを書きます。

build.hs
#!/usr/bin/env stack
-- stack --resolver=lts-2.16 runghc --package=shake --package=shake-language-c

import           Development.Shake
import           Development.Shake.Language.C
import qualified Development.Shake.Language.C.Target.Windows as Windows

main :: IO ()
main = shakeArgs shakeOptions { shakeFiles = "build" } $ do
    exe <- executable
        (return $ Windows.toolChain GCC)
        ("build" </> "test.exe")
        (return $ append compilerFlags [(Nothing, ["-O2"])])
        (getDirectoryFiles "" ["src//*.cpp"])

    want [exe]

多分shakeの書き方がよくわかってないので、変なことになっていると思うのですが、とりあえず、

  • src/ 以下の.cppファイルをコンパイルして
  • test.exeを作る

だけのスクリプトです。

$ ls src/
main.cpp  test.cpp  test.h
$ ./build.hs
Using resolver: lts-2.16 specified on command line
data-default-class-0.0.1: configure
fclabels-2.0.2.2: download
data-default-class-0.0.1: build
()
# gcc (for build/test_exe_obj/src/test.cpp.rel.o)
# gcc (for build/test_exe_obj/src/main.cpp.rel.o)
# g++ (for build/test.exe)
Build completed in 0:01m

というわけで、同じようにシェルから実行して、うまく動いているようです。

まとめ

Stackによって、スクリプトからの安定したパッケージ管理が簡単にできるようになったので、これからは安心してHaskellでスクリプトを書くことができますね。皆さんもぜひHaskellとStackで、楽しいハッキングライフを!