Haskell チュートリアル (Haskell Day 2016)

  • 64
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

agenda

演習時間を使ってチューターに質問して下さい。

  • 事前準備
  • チューターの自己紹介
  • Haskellの基本
  • 演習
  • I/Oアクション
  • 演習
  • Turtleを使ったShellプログラミング
  • 演習

問題が解けたら、 演習問題の回答例 も参照してみて下さい。


Haskellとは

  • 普通のプログラミング言語
  • 他の言語で書ける全てのプログラムは書くことができる
  • 一方で、特徴的な側面もある

Haskellの特徴

  • 純粋関数型言語
  • 強力な型推論
  • 遅延評価

関数型言語とは

  • 全てを式で表す
  • プログラムの実行 = 式の評価
  • 副作用のある式を評価すると副作用が起こる

手続き型との違い

手続き型の言語では、1行目を実行し、2行目を実行する。

main = do
    putStr   "Hello, "
    putStrln "world!"

文法メモ

  • putStrputStrLn : 文字列(String)の表示

関数型言語の実行

関数型では、式の評価を進める。Haskellではmainの評価を進める。

    putStr   "Hello, " >>= (\_ ->
    putStrln "world!")

>>= の第一引数を評価すると () 。この評価の副作用で、 "Hello," が印字される。

    (\_ -> putStrln "world!") ()
    putStrln "world!"

評価結果は () 。この式の評価の副作用で、 "world!\n" が印字される。

    ()

もう評価できないので、終了。

文法メモ

  • () : 1つしか値がない型の値
  • \A -> B : 構文。引数がAで本体がBの無名関数
  • >>= : IO ...型の値へ関数を適用。(See also: Monad)

「純粋」関数型言語

  • 評価すると 暗黙的に 副作用を起こす式がない
  • 明示的に 副作用を起こす式だけがある (IO型コンストラクタ)
  • 明示的 == 副作用の有無を机上で判定できる
reverse "PATH" :: String
length "PATH" :: Int
getEnv "PATH" :: IO String
putStrLn "PATH" :: IO ()

強力な型推論

Hindley/Milner 型推論アルゴリズム という、よく知られたものがベース。

型を1つも書かなくてよい。プログラムだけ書けば型チェックしてもらえる。

Prelude> putStrLn (replicate '-' 4)

<interactive>:25:21:
    Couldn't match expected type Int with actual type Char
    In the first argument of replicate, namely '-'
    In the first argument of putStrLn, namely (replicate '-' 4)
    In the expression: putStrLn (replicate '-' 4)

文法メモ

  • replicate : 同じ文字を指定個数並べた文字列の生成

遅延評価

  • 関数の引数は必要となるまで評価されない
  • 評価しなくていい = 計算量を減らせる可能性
    • 例: 無限リスト
  • 評価できない = スペースリーク
    • 例: 1 + 2 + 3 + ... (計算できれば Int 型の値1つ)
  • いつ必要となるのか、が不定
    • 「副作用」を伴う式と相性が悪い
    • IO 型による副作用の発生順の制御が必要
print2nd x1 x2 = do
    print x2

-- 第一引数に渡るのは何行目? (コンパイルできない)
-- main = do
--     print2nd getLine getLine

main = do
    x1 <- getLine
    x2 <- getLine
    print2nd x1 x2

HaskellでShellプログラミング

副作用の塊なので、「純粋関数型言語」と相性が悪いと思われるかもしれませんが、全くそんなことはありません。


IO ... 型の関数の利用

  • 普通のプログラミング言語の関数は、全て副作用が起こりうる
  • Haskellの副作用が起こりうる関数は、戻り値が IO ...
    • x1 -> x2 -> ... -> xn -> IO y
    • このような関数だけを使うのであれば普通の言語と同じ!
  • 実際、IO 型は絶対に必要で超重要

Stack

  • Haskell のビルドツール : stack
    • cabalghc は直接使わない(直接使ってる記事は古い可能性大)
    • Haskell Platform ・・・? (内部で stack を使っている)
  • ライブラリのバージョンを固定

Stackのインタプリタ機能

今日はこの使い方(通常はプロジェクトを作ってビルドする)。

  • コマンドではなくファイルを渡すとインタプリタになる
  • 1行目にシェバング、2行目にstack runghcコマンド
  • stack runghc へオプションを渡すための機能
#!/usr/bin/env stack
-- stack --resolver lts-6.15 --install-ghc runghc --package turtle
...

Haskellのプログラム

  • main から始まる
  • do で I/O のブロックを表す
  • ブロックはインデントで
main = do
    putStr   "Hello, "
    putStrLn "World."

REPL

  • stack repl で起動
  • :l ソースコードの読み込み
  • :main 実行
  • :r リロード
  • :? ヘルプ、 :q 終了
$ stack repl
Prelude> :l pwd.hs
[1 of 1] Compiling Main             ( pwd.hs, interpreted )
Ok, modules loaded: Main.
*Main>

演習

演習1-1).

hello.hs を以下の内容で作成し、実行権限を与え、実行して下さい。

#!/usr/bin/env stack
-- stack runghc

main = do
    putStr   "Hello, "
    putStrLn "World."

演習1-2).

REPLからhello.hsを読み込み、:mainコマンドにより実行して下さい。

その後、 hello.hs で表示される文言を自由に編集し、REPLからリロードをして下さい。 :main を実行し、編集内容が結果に反映されていることを確認して下さい。

演習1-3).

REPLのヘルプを表示させ、興味のある機能を自由に試してみて下さい。


doブロックの決まり

  • IO ... という型しか書けない
  • ... の部分が戻り値
  • 戻り値は <- で受け取る
  • Prelude から書ける関数を探そう
main = do
    cs <- readFile "pwd.hs"
    putStrLn cs

文法メモ

  • readFile : ファイルを文字列(String)として読み込み

turtle パッケージ

  • Haskellでshell相当の関数を実装したもの
    • 今日から使える関数たくさん!
  • 2行のおまじないで使える (以下はシェバング付き)
#!/usr/bin/env stack
-- stack --resolver lts-7.0 --install-ghc runghc --package turtle

{-# LANGUAGE OverloadedStrings #-}
import Turtle

doブロックの決まり その2

型の先頭に MonadIO m => がある場合、最後が IO ... ではなく m ... でもよい。

  • MonadIO は型クラスと呼ばれるもの
    • MonadIO m => : m は任意の MonadIO のインスタンスでよい
    • IOMonadIO のインスタンス
  • Turtle.Preludeから探そう
main = do
    echo "Hello, World"

MonadIO io => Text -> io ()Text -> IO () と読みかえてよい。

文法メモ

  • echo : 文字列(Text)の表示

=の利用

doブロック内では let ... = ... で変数宣言できる。

main = do
    let greeting = "Hello, world!"
    echo greeting

<-=の違い

  • どちらも束縛
  • = はHaskellの言語仕様。let式、where句で使える
  • <-は糖衣構文。do専用
  • 右辺が IO ... なら <- と覚える
main = do
    let title = "now: "
    now <- date
    putStrLn (title <> show now)

文法メモ

  • date : 日付の取得
  • show : 様々な型を文字列(String)へ
  • <> : 文字列(StringまたはText)の連結 (See also: Monoid)

<-=の違い

完全に違うものなので使い分け必須。

脱糖すると、<-のおかげでIO型と>>=の特殊さが隠蔽されて普通の手続き型のように書けていることがわかる。

main =
    let title = "now: " in
    date >>= \now ->
    putStrLn (title <> show now)

<-=の違い

さらに関数型っぽく整理。<- による束縛は、後続の関数の引数名となる。

main = let title = "now: "
           f now = putStrLn (title <> show now)
        in date >>= f

実用上は脱糖は考えずにdo記法のルールを体に覚えこませる。

文法メモ

  • let A in B : 構文。Aで変数宣言する。Bが式の値となる

return

  • 糖衣構文をそれっぽく見せるためにreturnと命名された
  • ただの関数。関数から値を戻す効果はない
  • do記法ではIO ...しか書けない、という制約の回避に使うもの
main = do
    title <- return "now: "
    now <- date
    putStrLn (title <> show now)

return

do 式の最後(戻り値)も IO ... 型じゃなければならない。最後にreturnを使うとそれっぽくなる。

dateStr = do
    let title = "now: "
    now <- date
    return (title <> show now)

main = do
    str <- dateStr
    putStrLn str

全ての式は型付けされている。型の確認にはREPLが便利。

*Main> :t dateStr
dateStr :: IO String
*Main> :t main
main :: IO ()

文字列

  • "ABC" : String 言語仕様にある文字列
  • "ABC" : Text 効率のよい、外部ライブラリの文字列
  • 変換は Data.Textpackunpack

関数

関数の型は -> を使って記述。カリー化されている(部分適用すると関数が返る)。

  • a -> b -> c -> d
    • 引数が a b c の型の値3つ、戻り値が d
  • a -> (b -> c) -> IO d
    • 引数が a b -> c の型の値2つ、戻り値が IO d 型 (副作用あり)

その他の型

  • 数値 : Int Double
  • 真偽値 : True :: Bool, Flase :: Bool
  • NULL、エラーの表現
    • Just 1 :: Maybe IntNothing :: Maybe a
    • Right 1 :: Either e IntLeft "Some error" :: Either Str a
  • リスト : [1,2,3] :: [Int]
  • タプル : (1, True) :: (Int, Bool)
  • ファイル FilePath
  • 日付 UTCTime
  • 終了コード ExitCode

printfとformat

Turtle.Format
を利用。

  • formatprintf
  • sdfと文字列"..."%演算子でつなぐと%dっぽくなる
*Main> printf ("My name is "%s%". "%d%" years old.\n") "shu1" 0
My name is shu1. 0 years old.

演習

演習2-1).

hostname でマシン名を取得し、 echo でそれを画面に出力する hostname.hs というプログラムを作って下さい。

【ヒント】REPLから:lで対象のファイルを読み込み、 :r で再コンパイルをするとすぐフィードバックが得られます。

演習2-2).

コマンドライン引数を1つ受け取り、その最終更新日を表示する modified.hs というプログラムを作って下さい。ただし簡単のため、不正な引数を受け取った場合の例外処理は考えないものとします。

必要に応じて以下の関数を使って下さい。

  • arguments : コマンドライン引数を文字列(Text)のリストで取得する
  • head : リストの先頭要素を取得する
  • fromText : 文字列(Text)をファイル型にする
  • datefile : ファイル型から最終更新日付を取得する
  • repr : 日付を文字列(Text)にする

【ヒント】argumentsdatefileは副作用のある関数で、<-で結果を受け取れます。一方、 headfromTextreprは副作用のない関数であり、戻り値は let ... = ... で束縛します。

演習2-3).

コマンドライン引数を複数受け取り、その全てのファイル名と最終更新日を表示する modified-multi.hs というプログラムを作って下さい。簡単のため、例外処理は考えないものとします。

【ヒント】まず、引数を1つ受け取り、そのファイル名と更新日時を表示する echoModified という関数を定義して下さい。echoModified fname = do ... という形式で main 関数と同様の記述方法で定義することができます。ファイル名と更新日時を整形して表示するには、printf を使うといいでしょう。

mapM は、繰り返し処理を行う関数です。mapM func リスト という形式で呼び出すと、リストの各要素についてfuncを呼び出します。この関数の結果は IO ... という型であるため、 do ブロックに記述することができます。

演習2-4).

次のように定義される nestedIO という値は、 IO (IO ()) という型を持ちます。この値を使って、画面に "Hello, I/O!" を表示するプログラム hello-io.hs を作って下さい。

nestedIO = do
    putStr "Hello, "
    return (putStrLn "I/O!")

演習2-5).

Turtle.Prelude より、I/Oアクションとして使えそうな関数をピックアップして下さい。それらの関数を組み合わせてプログラムを作成し、実際の挙動を確認して下さい。

※ ファイルの上書きや削除型のコマンドで自分の環境を壊してしまわないよう、十分注意して下さい。


ストリーム処理

UNIXのパイプ | の実現に、IO ...では役者不足。 Shell ... という Turtle が提供する型を用いる。

  • IO ... : すべての結果を一度に返す
  • Shell ... : 複数行の結果を1行ずつ返す

入力

UNIXコマンド的に書くと、 入力 | パイプ | 出力

入力 は Shell ... という型で表現される。

  • 入力なし: empty :: Shell a
  • 標準入力: stdin :: Shell Text
  • ファイル: input :: FilePath -> Shell Text
  • リストを入力とする: select :: [a] -> Shell a
  • 文字列一行を入力に: "INPUT" :: Shell Text

出力

UNIXコマンド的に書くと、 入力 | パイプ | 出力

出力は Shell ... -> IO ... という型で表現される。最終的に IO ... になるので、do ブロックに書ける。

  • 出力を捨てる: sh :: Shell a -> IO ()
  • 出力を表示する: view :: Shell a -> IO ()
  • 標準出力: stdout, stderr :: Shell Text -> IO ()
  • ファイル: output :: FilePath -> Shell Text -> IO ()
  • 外部コマンドに流し込む: shell :: Text -> Shell Text -> IO ExitCode

パイプ

UNIXコマンド的に書くと、 入力 | パイプ | 出力

パイプ( || の間) は Shell ... -> Shell ... という型で表現される。

  • 何もしない: id :: Shell a -> Shell a
  • 先頭n行取る: limit :: Int -> Shell a -> Shell a
  • 外部コマンドを通す: inshell :: Text -> Shell Text -> Shell Text

関数適用による組み合わせ

入力、パイプ、出力は引数と関数の関係にあるため、単純に関数適用で組み合わせてよい。

  • input "your.txt" :: Shell Text
  • limit 10 :: Shell a -> Shell a
  • stdout :: Shell Text -> IO ()

stdout (limit 10 (input "your.txt"))

$ を使うとかっこを省略して記述でき、データが右から左に流れるように見える。

stdout $ limit 10 $ input "your.txt"

& を使うとこれを逆向きにできる。これがUNIXのパイプ的な記法。

input "your.txt" & limit 10 & stdout


組み合わせ例

REPLで試すことができる。

  • 何もしない empty & id & sh
  • 外部コマンドの利用 input "sample.txt" & inshell "uniq" & shell "wc -l"
  • リストを流し込む select ["To UNIX", "From Haskell"] & output "sample.txt"

出力:fold

Control.Foldl に定義された Fold を使い、Stream ...を回収できる。reduceみたいなもの。

  • fold :: Shell a -> Fold a r -> IO r
  • foldIO :: Shell a -> FoldM IO a r -> IO r

先頭で import qualified Control.Foldl as Fold のように import した上で使う。

select [1..6] & (foldIOFold.random)

wcの実装例 がすごく良い見本。

文法メモ

  • `f` : 2引数関数 f を中置演算子として使う記法
  • (* 1) : 中置演算子 * の第二引数に1 を部分適用 (セクション)

パイプ:fmap

全ての行を関数によって変換する。リストに対する map に相当。

  • fmap :: (a -> b) -> Shell a -> Shell b

ls "." & fmap (format fp) & stdout


パイプ:grep

パターンと Shell ... をとり、grepされたShell ...を作る。

  • grep :: Pattern a -> Shell Text -> Shell Text

select ["Haskell", "Turtle", "Shell"] & grep (plus dot <> "ll") & stdout

正規表現との対応は ドキュメント を見る。

hasprefixsuffix を使うと簡単になる。

select ["Haskell", "Turtle", "Shell"] & grep (suffix "ll") & stdout


Shell ...do記法

IO ... と同様、 Shell ... でも do 記法が使える。

  • 最終的な戻り値が Shell ... となっている関数のみ使える
  • doブロックの戻り値は Shell ...になる
  • 上から順に実行されるのは同じだが、戻り値のループ処理となる
lsPrintf = do -- Shell ... の do ブロック
    file <- ls "."
    -- 全ファイル分、ループ処理される
    printf (fp%"\n") file

main = do     -- IO    ... の do ブロック
    lsPrintf & sh

Shell ...MonadIO

  • ShellMonadIO のインスタンス
    • MonadIO m => ... -> m ...... -> Shell ... と読み替えてよい
  • IO ... の関数は使えない
    • liftIO :: IO ... -> Shell ...
lsPrintf = do
    file <- ls "."
    liftIO $ print file

main = do
    lsPrintf & sh

演習

演習3-1).

外部コマンドを利用し、./count-import.hs src と実行すると find src -name \*.hs | xargs grep '^import ' | wc -l と同じ結果を出力する count-import.hs を作成して下さい。

【ヒント】find に引数を渡すには、 format で文字列を連結するか、inshellの代わりにinprocを使ってリストで引数を渡すといいでしょう。リストは [x] <> ["hoge", "bar"] のように連結できます。

演習3-2).

3-2で作成した count-import.hs を、外部コマンドを使わないように書き換えて下さい。

【ヒント】最初のfindfind 関数で簡単に置き換えることができます。

最後の wc -l は、 Control.Foldlimport した上で、 Fold.length を使った畳み込みで置き換えられます。畳み込まれた数値を表示するため、最後に view へ流し込むといいでしょう。

xargs は、 Shell ... 型を do 記法で書くことで実現できます。次のような関数を作り、Shell FilePath から <- で値を取ると、findで見つかったすべてのファイルに対してループ処理させることができます。grepinput でファイルから読んだ各行を grep 関数でフィルタすることで実現できます。

xargsGrep :: Shell FilePath -> Shell Text
xargsGrep files = do
    file <- files
    ...

演習3-3).

Turtle.Prelude より、ストリーミングの入力、出力、パイプとして使えそうな関数をピックアップして下さい。それらの関数を組み合わせてプログラムを作成し、挙動を確認して下さい。


補講: Applicative

Applicative ファンクタ型クラス、と呼ばれるもの。

  • [...]Maybe ...IO ...Shell ...のようなコンテナライクなデータを指す
  • 通常の関数を使った演算が可能なコンテナのこと
  • 例えば、 [1, 2] + [3, 4] は?
  • 2つの演算子
    • (<*>) :: f (a -> b) -> f a -> f b
    • (<$>) :: (a -> b) -> f a -> f b
    • え? だからなに?

Applicativeの使い方

原理はともかく、使い方を覚えるほうがよい。

  • イディオム : f がn引数の関数のとき、 f <$> x1 <*> x2 <*> x3 <*> x4 <*> ... <*> xn
  • 引数の数を間違えてなければ、絶対に型はあう
    • 気になる人は書き下すとよい

(+) は2引数関数。

Prelude> (+) <$> [1, 2] <*> [3, 4]
[4,5,5,6]

FoldはApplicative

(,)やコンストラクタに適用すると、一度に複数の方法を使って畳み込める。

ghci> select [1..6] & (`fold` ((,) <$> Fold.minimum <*> Fold.maximum)) & view
(Just 1,Just 6)

Applicativeとパーサー

do記法できるIO ...Shell ...でも使われるが、特筆すべきはパーサーで使われることが多いこと。

  • 例: grep で用いた Pattern
  • パーサは「文字列の消費」と「結果の生成」からなる
  • 「結果の生成」をApplicativeで演算
  • match :: Pattern a -> Text -> [a] パースして結果を返す。
ghci> match ((,) <$> "a" <> star dot <*> "d" <> star dot) "abcdefg"
[("abc","defg")]

パーサの戻り値の差し替え

  • (*>) :: f a -> f b -> f b 右のパーサの結果のみ使う
  • (<*) :: f a -> f b -> f a 左のパーサの結果のみ使う
  • sed :: Pattern Text -> Shell Text -> Shell Text 置換コマンド
  • pure パースしない (結果のみ返す)
ghci> "abcdefg" & sed ("abc" *> pure "xyz") & stdout
xyzdefg
ghci> "abcdefg" & sed ("abc" <* "def") & stdout
abcg

コマンドライン引数のパーサ

Turtle.Optionsでコマンドライン引数をパースできる。 Options.Applicative がベース。

import Turtle

parser :: Parser (Text, Maybe Int)
parser = subcommand "byopt" "オプション形式で指定する"
            ((,) <$> optText "opt1" 'x' "オプションひとつめ"
                 <*> optional (optInt  "opt2" 'y' "オプションふたつめ"))
         <|> (,) <$> argText "arg1" "引数ひとつめ"
                 <*> optional (argInt  "arg2" "引数ふたつめ")

main = do
    (str, mInt) <- options "An argument parser test" parser
    echo str
    case mInt of
      Nothing  -> return ()
      Just n -> printf d n
  • Parser ... パーサ。...がパース結果
  • argXXX : 普通の引数
  • optXXX : --XXX 形式の引数
  • optional : 省略可能な引数
  • subcommand : サブコマンドの定義
  • func <$> n1 <*> n2 <*> n3 : パーサの組み立て (Applicative)
  • <|> 引数のパースに失敗した場合の代替 (Alteranative)
  • options : 定義した Parser ... の適用

演習

演習4-1).

Turtle.Options を利用し、演習3-3 で作成した count-import.hs のコマンドライン引数を、count-import.hs src という形式ではなく、count-import.hs --dir src または count-import.hs -d src という形式で実行できるように変更して下さい。さらに引数の省略時に、カレントディレクトリ . を利用できるようにして下さい。変更することができたら、 count-import.hs --help でヘルプメッセージが表示されることを確認して下さい。

【ヒント】デフォルト値を用意する場合は、 optional を使って引数をオプショナルの扱いにします。これを使って定義したパーサが返すMaybe Text 型は、 case ... of ... 構文を使って場合分けすることができます。

    let path = case maybePath of Nothing -> "."
                                 Just p  -> p

演習4-2).

count-import.hs--show (-s) オプションを追加し、このオプションが指定された場合はカウントではなくモジュール名を表示するようにして下さい。

Haskell の import 文は qualifiedas を伴い、 import qualified Mod as Foo (x,y) となることがあります。sed 関数を使い、余分な部分を削除してモジュール名 Mod のみを表示させましょう。

【ヒント】ON/OFFできるオプションは switch によって作成することができます。 --show の指定によって動作を分けるには、2つの出力関数 wcdump と、sink :: Shell Text -> IO () なる変数を用意し、指定によって中身を切り替えればいいでしょう。wcが今までの挙動を実現するための関数です。

let sink  = if isShow then dump else wc

dump の実装には sed を使います。モジュール名までをパースできるよう Pattern を作成し、 *> を用いてモジュール名だけを返せるようにします。全体を prefix で修飾しておけば、import 構文の as ... の部分は捨てられるので明示的にパースする必要はありません。