agenda
演習時間を使ってチューターに質問して下さい。
- 事前準備
- チューターの自己紹介
- Haskellの基本
- 演習
- I/Oアクション
- 演習
- Turtleを使ったShellプログラミング
- 演習
問題が解けたら、 演習問題の回答例 も参照してみて下さい。
Haskellとは
- 普通のプログラミング言語
- 他の言語で書ける全てのプログラムは書くことができる
- 一方で、特徴的な側面もある
Haskellの特徴
- 純粋関数型言語
- 強力な型推論
- 遅延評価
関数型言語とは
- 全てを式で表す
- プログラムの実行 = 式の評価
- 副作用のある式を評価すると副作用が起こる
手続き型との違い
手続き型の言語では、1行目を実行し、2行目を実行する。
main = do
putStr "Hello, "
putStrln "world!"
文法メモ
-
putStr
、putStrLn
: 文字列(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
-
cabal
、ghc
は直接使わない(直接使ってる記事は古い可能性大) - Haskell Platform ・・・? (内部で
stack
を使っている)
-
- ライブラリのバージョンを固定
- hackage や hoogle は常に最新版
- stackageのAPIドキュメント を見た方がよい
- hackageにしかないものは自分でバージョンを選んで採用する
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
のインスタンスでよい -
IO
はMonadIO
のインスタンス
-
- 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.Text の
pack
とunpack
関数
関数の型は ->
を使って記述。カリー化されている(部分適用すると関数が返る)。
-
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 Int
、Nothing :: Maybe a
-
Right 1 :: Either e Int
、Left "Some error" :: Either Str a
-
- リスト :
[1,2,3] :: [Int]
- タプル :
(1, True) :: (Int, Bool)
- ファイル
FilePath
- 日付
UTCTime
- 終了コード
ExitCode
printfとformat
Turtle.Format
を利用。
-
format
とprintf
-
s
やd
やf
と文字列"..."
を%
演算子でつなぐと%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
)にする
【ヒント】arguments
とdatefile
は副作用のある関数で、<-
で結果を受け取れます。一方、 head
とfromText
、repr
は副作用のない関数であり、戻り値は 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] & (
foldIO Fold.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
正規表現との対応は ドキュメント を見る。
has
、prefix
、suffix
を使うと簡単になる。
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
-
Shell
はMonadIO
のインスタンス-
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
を、外部コマンドを使わないように書き換えて下さい。
【ヒント】最初のfind
は find
関数で簡単に置き換えることができます。
最後の wc -l
は、 Control.Foldl
を import
した上で、 Fold.length
を使った畳み込みで置き換えられます。畳み込まれた数値を表示するため、最後に view
へ流し込むといいでしょう。
xargs
は、 Shell ...
型を do
記法で書くことで実現できます。次のような関数を作り、Shell FilePath
から <-
で値を取ると、find
で見つかったすべてのファイルに対してループ処理させることができます。grep
は input
でファイルから読んだ各行を 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
文は qualified
や as
を伴い、 import qualified Mod as Foo (x,y)
となることがあります。sed
関数を使い、余分な部分を削除してモジュール名 Mod
のみを表示させましょう。
【ヒント】ON/OFFできるオプションは switch
によって作成することができます。 --show
の指定によって動作を分けるには、2つの出力関数 wc
と dump
と、sink :: Shell Text -> IO ()
なる変数を用意し、指定によって中身を切り替えればいいでしょう。wc
が今までの挙動を実現するための関数です。
let sink = if isShow then dump else wc
dump
の実装には sed
を使います。モジュール名までをパースできるよう Pattern
を作成し、 *>
を用いてモジュール名だけを返せるようにします。全体を prefix
で修飾しておけば、import
構文の as ...
の部分は捨てられるので明示的にパースする必要はありません。