USP Magazine 2014年10月号「シェル芸勉強会後追い企画 Haskellでやってはいかんのか?

Mon May 4 12:46:00 JST 2015 (modified: Sun Oct 1 10:50:27 JST 2017)
views: 4574, keywords: この記事は最終更新日が6年以上前のものです。

7. シェル芸勉強会後追い企画: Haskellでやってはいかんのか?

産業技術大学院大学・USP研究所・USP友の会 上田隆一

USP友の会のシェル芸勉強会 (脚注:シェルのワンライナー勉強会)は、 日々、他の言語からの他流試合に晒されているのである。 そこで上田は、Haskellで自ら他流試合を行い、 さらにシェル芸勉強会をいじめる自傷行為に手を 染めるのであった。

7.1. はじめに

 こんにちは。富山の産んだ、 とれとれぴちぴちブラックエンジェル上田です [1] 。 先週、久々に国際学会に出かけていっていろいろ ロボット屋さんの情報を仕入れてきました。 アトムを誰かが作っているとか、 その電池(原子力)がかなりヤバいらしいとか、 ガンダムを作ってパチンコガンダム [2] に納品したとか、 いろいろ聞いてきました。

 ・・・すんません嘘です。ほぼ100%、人事の話でした・・・。 学者も食ってかないといけないので、いろいろ大変です。

7.2. 前回のおさらい

 かつてはサンクチュアリであった学者の世界も 最近はすっかり世知辛くなり、 我々がすがることができるのはせいぜいシェル芸とHaskell の世界のみです [3] 。 ということで、今回も第1回シェル芸勉強会の3問目の途中から Haskellをやってみまっしょい。問題はこれ。

/etc の下にあるすべてのbashスクリプト ( #!/bin/bash で始まるもの) について以下の操作をしてください。

  • ~/hoge というディレクトリにコピー
  • その際、 「 #!/bin/bash 」を「 #!/usr/local/bin/bash 」 に変更

 前回は /etc/ 下のファイルの一覧を作るところまで作りました。 コードをリスト1に示します。このコードに追加していきます。

  • リスト1: q1_3_4.hs
 1
    2
    3
    4
    5
    6
    7
    8
    9
   10
   11
   12
   13
   14
   15
   16
   17
ueda@remote:~/GIT/UspMagazineHaskell/Study1_Q3$ cat ./q1_3_4.hs
   import System.Directory

   main = digdir "/etc" >>= putStr . unlines

   ls :: String -> IO [FilePath]
   ls dir = getDirectoryContents dir >>= return . filter (`notElem` [".",".."])

   digdir :: FilePath -> IO [FilePath]
   digdir dir = ls dir
    >>= mapM (\\x -> return $ dir ++ "/" ++ x)
    >>= mapM digdir'
    >>= return . concat

   digdir' :: FilePath -> IO [FilePath]
   digdir' path = do b <- doesDirectoryExist path
    if b then digdir path else return [path]
   

 実行結果はリスト2の通りです。 /etc 下には一般ユーザで見られないファイルもあるので、 頭にsudo(1) [4] を付けます。

  • リスト2: q1_3_4の実行結果(headで省略しています)
1
   2
   3
   4
ueda@remote:~/GIT/UspMagazineHaskell/Study1_Q3$ sudo ./q1_3_4 | head -n 3
   /etc/blkid.tab
   /etc/tidy.conf
   /etc/logrotate.conf
   

7.3. ファイルをモジュールに分けてみる

 さて、今回は4行目の digdir から出力される ファイル名からファイルを順番に開いていく処理を書いていきます。 が、6行目以下は今後全然使いません。 全然使わないコードをリストにいちいち書いて原稿料を頂く方針もありますが、 それは大変申し訳ないのでコードを隠してみましょう。

  • リスト3: FileTools.hs
 1
    2
    3
    4
    5
    6
    7
    8
    9
   10
   11
   12
   13
   14
   15
   16
   17
   18
   19
   20
ueda@remote:~/GIT/UspMagazineHaskell/Study1_Q3$ cat FileTools.hs
   module FileTools (
   find,
   ls
   ) where

   import System.Directory

   ls :: String -> IO [FilePath]
   ls dir = getDirectoryContents dir >>= return . filter (`notElem` [".",".."])

   find :: FilePath -> IO [FilePath]
   find dir = ls dir
    >>= mapM (\\x -> return $ dir ++ "/" ++ x)
    >>= mapM find'
    >>= return . concat

   find' :: FilePath -> IO [FilePath]
   find' path = do b <- doesDirectoryExist path
    if b then find path else return [path]
   

 まず、 q1_3_4.hs をコピーして、リスト3のようなファイル FileTools.hs を作ります。 q1_3_4.hs からの変更は次の3点です。 digdir という名前を find に変更したのは、 使い回すならコマンドのfind(1)と名前を 一緒にしておいた方がよいだろうという判断です。

  • module ... where を追加
  • main 関数を削除
  • digdirfind に変更

2〜4行目の module ... where は、 「FileToolsというモジュールを定義して、 その中のfind関数とls関数を公開する」 という意味になります。 公開する関数は丸括弧の中にカンマ区切りで書きます。

 今度は作ったモジュールを使う側を書いてみましょう。 q1_3_4.hsq1_3_5.hs のように変更します。 main 以外の関数を削除し、 importFileTools に変更します。 そうするとリスト4のように3行になってしまいます。

  • リスト4: FileTools.hs を使う q1_3_5.hs
1
   2
   3
   4
ueda@remote:~/GIT/UspMagazineHaskell/Study1_Q3$ cat q1_3_5.hs
   import FileTools

   main = find "/etc" >>= putStr . unlines
   

 実行するときは q1_3_5.hs をコンパイルします。 FileTools.hs も勝手にコンパイルされます [5]

  • リスト5: 使うモジュールもろともコンパイルして実行
1
   2
   3
   4
   5
   6
   7
   8
   9
ueda@remote:~/GIT/UspMagazineHaskell/Study1_Q3$ ghc q1_3_5.hs
   [1 of 2] Compiling FileTools ( FileTools.hs, FileTools.o )
   [2 of 2] Compiling Main ( q1_3_5.hs, q1_3_5.o )
   Linking q1_3_5 ...
   ###実行!!!###
   ueda@remote:~/GIT/UspMagazineHaskell/Study1_Q3$ sudo ./q1_3_5 | head -n 3
   /etc/blkid.tab
   /etc/tidy.conf
   /etc/logrotate.conf
   

FileTools.hsq1_3_5.hs は同じディレクトリに置いておいてください。

 また、モジュールはGHCiからも使えます。

  • リスト6: モジュールをGHCiから利用
1
   2
   3
   4
   5
   6
   7
   8
###モジュールのあるところでGHCiを起動###
   ueda@remote:~/GIT/UspMagazineHaskell/Study1_Q3$ ghci
   Prelude> :load FileTools
   Prelude FileTools> ls "."
   ["q1_3_5.hi","q1_3_2","q1_3_2.hs","do.hs","FileTools.o","bind"
   ,"FileTools.hs","q1_3_5","FileTools.hi","q1_3_3","q1_3_1.hs","
   do","q1_3_5.o","bind.hs","q1_3_4","q1_3_4.hs","q1_3_3.hs","q1_
   3_5.hs"]
   

7.4. ファイルを開く関数の実装

 さて、次はいよいよファイルの中身を見て行くわけですが、 ここで FileTools.hs に ファイルを開いて中身を返す関数 cat を実装しましょう。 リスト7のように module 中に cat を書き入れ、 import を二つ書き込み、その下に cat 関数を実装します。

  • リスト7: cat関数を実装
 1
    2
    3
    4
    5
    6
    7
    8
    9
   10
   11
   12
   13
   14
ueda@remote:~/GIT/UspMagazineHaskell/Study1_Q3$ cat FileTools.hs
   module FileTools (
   find,
   ls,
   cat
   ) where

   import System.Directory
   import System.IO.Error
   import qualified Data.ByteString.Lazy.Char8 as B

   cat :: FilePath -> IO B.ByteString
   cat file = catchIOError (B.readFile file) (\\e -> return $ B.pack [])
   (以下略)
   

 この cat 関数が今回の山場になりそうなのでじっくり解説していきます。 まず、ファイルの中身は String でなくて ByteString という型で出力します。 バイナリファイルを String で読み込むと、 エラーが出てしまうからです。 ByteString はC言語の配列に近いものと考えるとよいでしょう。 で、この型を使うにはモジュールをインポートしないといけません。 それをやっているのが10行目です。 この10行目も相当ややこしいです。 まず、 Data.ByteString.Lazy.Char8 ですが、 これは ByteString を使うときの基本のモジュールです。 他にもいろいろありますが、これを指定します。 次に、 qualified なんとか as B ですが、 これは Data.ByteString.Lazy.Char8 の中で定義された関数には B. と頭につけないと使えないですよという指定です。 13行目の readFile には B. がついていますね。 先ほど q1_3_5.hs から FileTools を使ったときには qualified を指定しなかったので、 中に定義された find 関数はそのまま使えましたが、 qualified を使った方がどのモジュール由来の関数なのか 分かって良いという考え方もあります。

  B.readFile の型を確認しておきます。 リスト5のようにそのまま import qualified ... と書いて読み込むとよいでしょう。

  • リスト5: readFileの型
1
   2
   3
   4
ueda@remote:~/GIT/UspMagazineHaskell/Study1_Q3$ ghci
   Prelude> import qualified Data.ByteString.Lazy.Char8 as B
   Prelude B> :t B.readFile
   B.readFile :: FilePath -> IO B.ByteString
   

 さて、13行目にはもう一つややこしい catchIOError という関数がいます。この関数を使うために、 9行目で System.IO.Error というモジュールを読み込んでいます。

  catchIOError は、例外処理のための関数です。 先に型を確認しておきましょう。リスト9のようになります。

  • リスト9: catchIOErrorの型
1
   2
   3
   4
ueda@remote:~/GIT/UspMagazineHaskell/Study1_Q3$ ghci
   Prelude> import System.IO.Error
   Prelude System.IO.Error> :t catchIOError
   catchIOError :: IO a -> (IOError -> IO a) -> IO a
   

ただ、これは実際に使い方を見た方が分かりやすくて、 リスト7の13行目を見ると、 catchIOError の引数に、 最初に実際にやりたい処理を書いて、 次に例外が発生したときの処理を書きます。 例外が発生したときの処理 \\e -> return... は 先月号で出て来た無名関数です。 -> の左側の e が入力、右側が出力です。 ただ、この無名関数では入力を使っておらず、 ただ空文字を返しているだけです。 つまり例外が発生したら空文字を返すという適当実装ですが、 この連載では問題が解ければよいのでこれで十分です。 B.pack の型は次の通りです。 cat の出力型は IO B.ByteString なので、 return 関数で IOB.pack の出力にくっつけています。

  • リスト10: packの型
1
   2
   3
   4
ueda@remote:~$ ghci
   Prelude> import qualified Data.ByteString.Lazy.Char8 as B
   Prelude B> :t B.pack
   B.pack :: [Char] -> B.ByteString
   

7.5. シバンを検知

 さて、 cat が実装できましたので、 これを使ってbashスクリプトのファイルを見つけてみましょう。 リスト11のようなコードを書きました。

  • リスト11: bashのシバンを発見するq1_3_6.hs
1
   2
   3
   4
   5
   6
   7
   8
   9
ueda@remote:~/GIT/UspMagazineHaskell/Study1_Q3$ cat q1_3_6.hs
   import FileTools
   import qualified Data.ByteString.Lazy.Char8 as B

   main = find "/etc" >>= mapM judgeBashFile >>= print

   judgeBashFile :: FilePath -> IO (String,Bool)
   judgeBashFile file = cat file >>= return . ((,) file) . isBash
    where isBash bs = B.take 11 bs == B.pack "#!/bin/bash"
   

実行してみます。リスト12のように、 bashのシバン [6] のあるファイル名にはTrueがつきます。 ないものにはFalseがつきます。

  • リスト12: q1_3_6の実行(と若干のシェル芸)
 1
    2
    3
    4
    5
    6
    7
    8
    9
   10
   11
   12
   13
ueda@remote:~/GIT/UspMagazineHaskell/Study1_Q3$ sudo ./q1_3_6 | tr ')' '\\n' | head -n 3
   [("/etc/blkid.tab",False
   ,("/etc/tidy.conf",False
   ,("/etc/logrotate.conf",False
   ###どうやら4個bashのスクリプトが存在###
   ueda@remote:~/GIT/UspMagazineHaskell/Study1_Q3$ sudo ./q1_3_6 | tr ')' '\\n' | grep True
   ,("/etc/alternatives/fakeroot",True
   ,("/etc/alternatives/lzfgrep",True
   ,("/etc/alternatives/lzmore",True
   ,("/etc/alternatives/lzdiff",True
   ###覗き見###
   ueda@remote:~/GIT/UspMagazineHaskell/Study1_Q3$ head -n 1 /etc/alternatives/fakeroot
   #!/bin/bash
   

 リスト11を解説します。 まず9行目の isBash 関数ですが、これはファイルの中身がbashスクリプトかどうか 見分けるためのコアになる関数です。 == の右側がファイルの中身 bs から11バイト取り出す関数で、 右側がシバンです。 B.pack で型を B.ByteString に合わせています。 B.take ですが、ファイルの中身が11バイト未満の場合は、 ファイルの中身をそのまま返します。 isBash 関数のこの定義では、例えば #!/bin/bashx というシバンがあったらこれもbashスクリプトと判定していしまいますが、 まあそんなもの無いからいいでしょう。手抜きですが。

 で、8行目が若干変態です。 (,) ですが、 実はこんな関数として扱われます。

1
   2
Prelude> :t (,)
   (,) :: a -> b -> (a, b)
   

つまり引数二つをとってタプルを返す関数という解釈になります。 実は、「 (a,b) 」と「 (,) a b 」 は同じものとして扱われているのです。 (,) file というのは、一つ引数をとって (file,) となって欠けているタプルを完成させる関数という解釈ができます。 その欠けている引数は isBash の出力です。 で、 return は完成したタプルを引数にとって型の頭に IO をつけて出力を完成させます。

 5行目の mapM は前回も出てきました。 ここでは IO [FilePath] という型の入力から IO を取払い、リストに入った FilePath を一つずつ judgeBashFile 関数に渡す役割をしています。

7.6. おわりに

 さて今回は第一回シェル芸勉強会の第三問の途中までとなりました。 前回もそうでしたが、やはりファイルを操作しだすと IO だらけになって、なかなかややこしいコードになってしまいます。 こんなに苦労してファイルを扱うのが必要なのか、 という疑問もあります。 が、何でも厳密に扱おうとしたら 大変だということで納得し、 次回もHaskell道を逝くことにしましょう。

7.7. お知らせ:コード募集

 本稿で出た問題のHaskellのコードを、 名前あるいはハンドルネームと共に送ってください。 短いもの、あるいは変態的なものをお願いいたします。

email: 編集部のメールアドレス

脚注

[1]正確には、とれとれぴちぴちキダタロー。
[2]http://ja.wikipedia.org/wiki/パチンコガンダム駅
[3]なんだそりゃ?
[4]須藤。
[5]いいっすね。C++だと(ry
[6]シバンっつーのは、シェルスクリプト等にある #! から始まるアレです。 スクリプトを解釈して実行するためのインタプリタを指定する仕組みです。 ま、我々はHaskell使いだから必要ないんですけどね(おい)。
ノート   このエントリーをはてなブックマークに追加 
 

やり散らかし一覧

記事いろいろ