5. 関数定義を極める
5.1. 名前
変数名と関数名をHaskellでは単に名前(name)と呼びます.名前は小文字で始まり,文字,数字,アンダースコア,シングルクォートで構成されます.アンダースコアは小文字として扱われるるので,名前をアンダースコアで開始することもできます.ただし,記号文字だけで構成される名前は演算子として扱われ,アンダースコアのみで構成される名前は使えません.アンダースコアのみの識別子はパターンマッチのワイルドカード用に予約されているからです.
5.2. スコープ
Haskellのコードはインデントによってブロック化されます.ファイルの最もインデントが浅いブロックで定義された名前は,トップレベル(top level)で定義されているといいます.トップレベルで定義された名前は,ファイル内のどこからでも参照可能です.ファイル外から参照するには,モジュール機能を使ってエクスポートする必要があります.したがって,トップレベルで定義された名前の可視範囲は,定義されたファイル内です.名前の可視範囲をスコープ(scope)と呼びます.モジュールは別の章で学びます.
以下の例ではトップレベル変数xがhundredとadd100の定義内で参照されています.
x = 99
hundred = x + 1 -- > 100
add100 n = n + x + 1 -- トップレベルのxを参照している.引数nに100を足して返す関数
名前は関数内で新たに定義することもできます.これらは関数内でのみ有効です.関数という局所的なスコープを持つ変数と関数をそれぞれ,局所変数(local variable),局所関数(local function)と呼びます.局所変数や局所関数を定義するには,この章でこれから学ぶlet式(let expression)とwhere節(where clause)を使います.
局所的に定義された名前が,上位レベルのインデントブロックと同じ名前だった場合,上位レベルにある同名の関数や変数は隠れて見えなくなります.これをシャドウイング(shadowing)と呼びます.シャドウイングによって上位レベルの名前は上書きされるわけではなく,局所定義のブロックの外では,上位レベルの名前が引き続き使えます.以下の例は,add50関数内で局所的に定義されたxがトップレベル変数をシャドウイングしています.
x = 99
add50 n = n + x + 1 -- このxは下のwhere節で定義される局所変数.引数に50を足して返す
where x = 49 -- 局所変数xを定義している.トップレベルのxをシャドウイングする
5.3. 関数パラメータのスコープ
関数は新しいスコープを形成します.つまり,関数パラメータ(引数を受け取る変数)は関数内でのみ有効な局所変数になります.もし上位レベルに同名の変数や関数があった場合には,それらをシャドウイングします.例えば,以下のコードでは,add50の右辺のxはトップレベルの99ではなく,引数に与えられた値に束縛され,関数パラメータのxがトップレベル変数をシャドウイングしています.関数の外では相変わらずトップレベルのxを参照できます.
x = 99
add50 x = 50 + x -- 右辺のxは引数のx.トップレベルのxとは別物
add50 1 -- > 51を返す
5.4. let式と局所定義
関数内で一時的に使用する変数や関数をトップレベルで定義すると,名前の衝突が起こりやすくなる上,名前が氾濫して管理が難しくなります.局所的にしか使わない名前のスコープは,使用するブロック内に留めておくことが大切です.
let式を使うと,「式」を記述できる場所ならどこでも名前(局所変数や局所関数)を定義できます.書式は以下の通りです.letの後ろで名前を定義し,in以下の式が評価されてlet式の値として返ります.in以下の式ではlet以下で定義した名前が使えます.
let 名前1 = 定義1
名前2 = 定義2
⋮
名前n = 定義n
in
上の名前を利用する式 -- この式の評価結果がlet式の値として返る
インデントをブロックで揃えれば,letとinの後ろには改行を入れても入れなくても大丈夫です.名前を定義する順番は関係なく,それぞれの定義で,前後の名前を参照できます.
以下の2式をghciに入力するといずれも9に評価されます.ローカル変数bは後ろで定義されるcの値を使っています.let式内で定義したa,b,cのスコープはlet式内にとどまります.let式外からは見えません.評価して9が返った後はこれらの変数にアクセスできません.
let a = 1
b = c + 2 -- 後ろのcを参照
c = 3
in a + b + c
let
a = 1
b = c + 2 -- 後ろのcを参照
c = 3
in
a + b + c
let式を使ってリストを作ってみましょう.
lst =
let a = 1
b = c + 2
c = 3
in [a + b + c, a * b * c, a - b - c]
-- > [9,15,-7]
式を記述できる場所ならどこでも使えるので,リストの要素部分にlet式を書くこともできます.
lst' =
[ let a = 1
b = c + 2
c = 3
in a + b + c,
let a = 1
b = c + 2
c = 3
in a * b * c,
let a = 1
b = c + 2
c = 3
in a - b - c
]
-- > [9,15,-7]
Section 4.10のコード 4で,リストを反転させるreverse''関数を定義するのに,reverseHelperという補助関数を作成しました.この補助関数はreverse''関数内でしか利用されない関数です.こうした関数は局所関数として定義すると,トップレベルの名前空間を汚さずに済みます.
reverse'' :: [a] -> [a]
reverse'' x =
let reverseHelper :: [a] -> [a] -> [a]
reverseHelper [] accum = accum
reverseHelper (x : xs) accum = reverseHelper xs (x : accum)
in reverseHelper x []
上の例ではreverseHelperにも型アノテーションを付けていますが,局所変数の型アノテーションは省略されることが多いです.自分で読み返すときには書いてあった方が読みやすいかもしれません.
一般的には再帰のために局所的に作られる補助関数の名前はgoやaux(auxiliaryの略)などの短い名前が使われます.
与えられたリストから標準偏差を計算する関数stdevを定義しなさい.標準偏差は 「平均からの乖離の2乗」の平均を求めて平方根を取ったものです.不偏推定量の標準偏差の定義は以下で与えられます.
\[
\sqrt{\frac{\sum_{i=1}^N (x_i - \bar{x})^2}{N-1}}
\]
実装例(クリックすると開きます)
stdev :: [Double] -> Double
stdev xs =
let size :: Double = fromIntegral (length xs) (1)
ave = sum xs / size (2)
sqrSum :: [Double] -> Double (3)
sqrSum [] = 0
sqrSum (x : xs) = (x - ave) ^ 2 + sqrSum xs
in sqrt (sqrSum xs / (size - 1)) (4)
| 1 | 平均を求めるためにデータ数を保持する局所変数sizeを作成. |
| 2 | 平均を保持する局所変数aveを作成. |
| 3 | 「平均からの乖離の2乗」の和を再帰で求める局所関数sqrSumを作成. |
| 4 | let式本体で,局所定義された関数と変数を用いて標準偏差を求めて返す. |
5.5. where節
where節は,let式と同じように局所的に名前を定義する記法です.違いは,let式が最初に局所定義をするのに対し,where節では式本体の後に局所定義を記述する点です.書式は以下の通りです.比較のために隣にlet式を併記しています.
|
|
以下ではlet式の節の例をwhere節を使って書き換えたものと併記しています.どちらを使うかは好みによりますが,慣れてくるとwhereを使った方が,数学と同じ形式で式を書けるのですっきりと見えます.
|
|
以下は,let式を使って書いたreverse''関数をwhere節を使って書き換えたものです.reverse''はトップレベルで定義されたので,名前の衝突を避けて以下ではreverse2に名前を変えていますが,局所関数reverseHelperはスコープが限定されているため同じ名前になっています,
reverse2 :: [a] -> [a]
reverse2 x = reverseHelper x []
where
reverseHelper :: [a] -> [a] -> [a]
reverseHelper [] accum = accum
reverseHelper (x : xs) accum = reverseHelper xs (x : accum)
let式を用いて定義した標準偏差を計算する関数stdevをwhere節を用いて再定義しなさい.
実装例(クリックすると開きます)
stdev' :: [Double] -> Double
stdev' xs = sqrt (sqrSum xs / (size - 1))
where
size :: Double = fromIntegral (length xs)
ave = sum xs / size
sqrSum :: [Double] -> Double
sqrSum [] = 0
sqrSum (x : xs) = (x - ave) ^ 2 + sqrSum xs
5.6. フロー制御
ここでは,条件や引数の値に応じて処理の流れを変えるフロー制御の方法として,if-else式とガード,case式の三つを学びます.
5.6.1. if-else式
if-else式はすでにSection 4.5で一度でてきました.フロー制御の最も基本的な式で,let式と同じく「式」を記述できるところであればどこでも使うことができます.Haskellのif-else式ではelse節を省略できません.書式は以下の通りです.
if 条件式 then 条件が真の時の式 else 条件が偽の時の式
インデントして以下のように書いても同じです.以下は絶対値を返すabs'関数を定義しています.
abs' n = if n >= 0
then n
else -n
5.6.2. ガード
関数定義では,引数のパターンに応じてケース分けして定義を書きました.しかし,パターンマッチでは「値そのもの」かデータの「構造」しか照合できません.例えば,引数の値が正であるか負であるかといった,値が満たす条件に応じてケース分けすることはできません.ガード(guard)を使うと,パターンに加えてBool型を返す条件式によってさらにケース分けすることができます.
ガードは以下のように,パターンと=の間に「| 条件式」を挿入します.以下の例は,リストから偶数だけ取り出してリストにして返す関数の定義です.
takeEven :: Integral a => [a] -> [a] -- even関数を使うためにIntegral型クラス制約
takeEven [] = []
takeEven (x:xs)
| even x = x : takeEven xs -- evenは引数が偶数ならTrueを返す
| otherwise = takeEven xs -- otherwiseはTrueと同値
-- takeEven [1..10]
-- --> [2,4,6,7,10]
上の定義ではパターンマッチを使い,引数が空リストのケースとそうでないケースに分けて定義しています.さらに,空リストではないケースでは,ガードを用いてリストのheadが偶数かどうかテストしています.ガードは上から評価され,式の値がTrueの時,右辺の定義が採用されます.otherwiseは常にTrueに評価されるので,どの式もFalseだった時の場合に最後に配置します.
もう一つ例として,西暦と月をペア(2要素のタプル)で与えると,その月の日数を返すdaysOfMonth関数を定義してみましょう.まず,補助関数として,閏年を判定するisLeapYear関数を以下のように定義します.閏年は,西暦年が4で割り切れる年ですが,例外として100で割り切れて400で割り切れない年は平年となります.
isLeapYear :: Int -> Bool
isLeapYear year = year `mod` 4 == 0 && (year `mod` 100 /= 0 || year `mod` 400 == 0)
isLeapYearを使うと,月の日数を返すdaysOfMonth関数は以下のように定義できます.パターンマッチの各ケースにガードを設定している点に注目してください.
daysOfMonth :: (Int, Int) -> Int
daysOfMonth (year, 2)
| isLeapYear year = 29
| otherwise = 28
daysOfMonth (_, month)
| month `elem` [4, 6, 9, 11] = 30
| otherwise = 31
まず,引数が2月のケースの場合はガードを使って閏年判定をしています.2月以外のケースでもガードを使って,4,6,9,11月かどうかチェックしています.
5.6.3. ガードとwhere節
前節のdaysOfMonth関数では,閏年判定をするisLeapYearをガード内で使いました.isLeapYearはdaysOfMonthの補助関数で,局所的にしか使われません.ガード内で使われる局所変数や局所関数を以下のようにガードの後ろに設置したwhere節で定義することができます.
daysOfMonth :: (Int, Int) -> Int
daysOfMonth (year, 2)
| isLeapYear year = 29
| otherwise = 28
where
isLeapYear :: Int -> Bool
isLeapYear year =
year `mod` 4 == 0
&& (year `mod` 100 /= 0 || year `mod` 400 == 0)
daysOfMonth (_, month)
| month `elem` [4, 6, 9, 11] = 30
| otherwise = 31
where節内で局所的に定義された名前は,where節が属するガードのどの条件式からも参照できます.例えば以下のコードのwhere節内の「局所定義」はn個のどのガードからも参照できます.
p | ガード1 = 式1 -- パターンpにマッチした後,ガードがTrueの式が評価される
| ガード2 = 式2
⋮
| ガードn = 式n
where
局所定義 -- ここで定義された名前は上のn個のどのガードからも使える
実際,上のコードはlet式を用いて以下のように書き換えたものと同じです.
p = let 局所定義 in -- この局所定義はin以下の全てのガードから参照できる
case () of
() | ガード1 = 式1
| ガード2 = 式2
⋮
| ガードn = 式n
_ -> error "Unmatched pattern"
5.6.4. case式
パターンマッチは,これまでさまざまな場面で登場しましたが,それらは全てここで学ぶcase式のシンタックスシュガーです.case式はパターンに基づいて条件分岐し,評価する式を選べます.さらに,パターンとともにガードも使えるので複雑な条件分岐が可能です.case式は,let式と同じように式が使える場所ならどこでも使えます.
case 式 of
パターン1 マッチ1
パターン2 マッチ2
⋮
パターンn マッチn
ただし\(i\)番目の「マッチi」は以下の書式になります.
| ガードi1 -> 式i1
| ガードi2 -> 式i2
⋮
| ガードim -> 式im
where 局所定義i
関数内の処理が複雑になると,引数に対するパターンマッチだけでなく,関数の定義式本体でもパターンマッチが使いたい場合があります.そのような時にcase式が役に立ちます.ここでは,前節で定義したdaysOfMonth'関数の2月以外のケースをcase式を用いて書き換えてみます.
daysOfMonth'' :: (Int, Int) -> Int
daysOfMonth'' (year, 2)
| isLeapYear year = 29
| otherwise = 28
where
isLeapYear :: Int -> Bool
isLeapYear year =
year `mod` 4 == 0
&& (year `mod` 100 /= 0 || year `mod` 400 == 0)
daysOfMonth'' (_, month) =
case month of
4 -> 30
6 -> 30
9 -> 30
11 -> 30
_ -> 31 -- ワイルドカードを使用
ガードの時はいずれの条件式にも当てはまらない時のためにotherwiseを最後に設置しました.パターンマッチの時は,いずれのパターンにもマッチするワイルドカードを最後に設置するのを忘れずにしましょう.
5.7. 関数合成
関数型プログラミングの醍醐味は,関数を組み合わせることで,より複雑な処理をする関数を構築することです.関数合成(function composition)は関数を組み合わせて新たな関数を作り出します.数学では,\(z = f(y)\)と\(y = g(x)\)が与えられたとき,二つの関数の合成関数(composed function)\(z = f(g(x))\)は,\(f \cdot g\)と書きます.Haskellではピリオドを使ってf . gと書きます.
例えば,与えられたリストを反転させて最初の要素を取り出す関数はhead . reverseと書けます.
reverseHead = head . reverse
関数型プログラミング言語では関数は値と同じように変数に代入できます.例えば,reverse関数をrev変数に束縛すれば,revを呼び出すことはreverseを呼び出すことと同じです.上のreverseHeadも,新しく構築した合成関数head . reverseに新しく名前をつけたのと同じことになります.
上のように,関数に名前をつけて定義すると,引数パラメータは書きませんが,reverseHeadはリストを引数にとります.このように,引数パラメータを書かずに関数を定義する書き方をポイントフリースタイル(point-free style)と呼びます.「ポイント」とは引数パラメータのことです.
引数を明示して関数を定義するなら,以下のようになります.
reverseHead' xs = (head . reverse) xs
上のように定義すると,合成関数head . reverseに引数のxsを渡していることが分かります.
ポイントフリースタイルは,記述が簡潔なのでコードが美しく見えますが,複雑な関数定義の場合には可読性が下がることもあります.そうした時は,冗長でも引数を明示的に記述した方が良いでしょう.
ポイントフリースタイルの見分け方は,関数シグニチャー(型アノテーション)の引数の個数と,関数定義の引数の個数を比べることです.ポイントフリーで書かれている場合,定義式の引数の個数がシグニチャの引数の個数より1つ少なくなります.例えばポイントフリーで定義されたreverseHeadのシグニチャと定義式を比べてみてください.
reverseHead :: [a] -> a -- シグニチャでは引数を1つとる
reverseHead = head . reverse -- 定義式では引数が無い
5.8. $ 演算子
引数が1つでポイントフリースタイルで関数定義が書ける場合,関数合成が使えますが,実際には関数を入れ子にして呼び出す必要が多くあります.例えば,3つの関数\(x = f(m, n)\),\(y = g(x)\),\(z = h(y)\)があったとき,\(z = h(g(f(m, n)))\)というように関数が入れ子で呼び出されます.これをHaskellで書くと以下のように括弧が必要です.
z = h (g (f m n))
このような入れ子状の括弧を$演算子を使って以下のように簡潔に書くことができます.
z = h $ g $ f m n
合成関数のh . g . fと似た形式で,引数に対して右から順に関数適用されます.$演算子を使うと,例えば,真ん中の関数gが2つの引数を取る場合でも使えます.例えば,\(x = f(m, n)\),\(y = g(k, x)\),\(z = h(y)\)のとき,\(z = h(g(k, f(m, n)))\)は$演算子を用いて以下のように書けます.
z = h $ g k $ f m n -- z = h (g k (f m n)) と同じ
つまり$演算子を見たら,$演算子から式の右端までを括弧で囲むイメージで式を読むと良いでしょう.慣れないうちは,そのようなイメージで十分ですが,$演算子の定義を理解すると,そのようなイメージではなく,定義に従って式を読めるようになります.
$演算子は特別な記号ではなく通常の関数です.型クラス制約を省略すると以下のように定義されています.
($) :: (a -> b) -> a -> b
($) f = f
型シグニチャを見ると,第1引数が「a型をとりb型を返す」関数です.第2引数がa型をとります.定義には第1引数の関数しかないので,前節で学んだポイントフリースタイルで定義されているのが分かります.引数を明示して書き直してみましょう.
($) :: (a -> b) -> a -> b
($) f x = f x
$は記号文字なので$関数は中置演算子になります.つまり関数呼び出しはf $ xです.このように呼び出すと,f xを実行せよというのが上の定義式です.f xを実行するのにわざわざf $ xと書く意味が分かりませんね.実は優先順位を理解するとこのように書く利点が分かります.
`ghciで:info ($)を実行すると,$``演算子は右結合で優先順位が最も低い0であることが分かります.
ghci> :info ($)
($) :: (a -> b) -> a -> b -- Defined in ‘GHC.Base’
infixr 0 $ (1)
| 1 | infixrが右結合の中置演算子を意味している.優先順位は0から9の値のうち最低順位. |
g $ f xという式は,(g $ f) xではなく,関数fの右結合が勝り,g $ (f x)と解釈されます.すると定義から,g (f x)が実行されます.つまり,括弧を用いずにg $ f xと記述すると,g (f x)が実行されます.
h $ g $ fという式は$が右結合なので,(h $ g) $ fではなく,h $ (g $ f)と解釈されます.
以上の2点を組み合わせると,h $ g $ f xと記述すると,h (g (f x))が実行されます.
Haskellは他のプログラミング言語のように文(ステートメント)が無く,全てが式から構成されるため,必然的にプログラムは関数の入れ子呼び出しで構成されます.$演算子や前節で学んだ合成関数をうまく使いこなすことで,多数の入れ子を囲んだ括弧だらけのコードを回避することができます.
5.9. 本章のまとめ
-
名前が定義される場所とスコープの関係を学びました.
-
関数パラメータが上のレベルの名前をシャドウイングすることを学びました.
-
局所変数と局所関数を定義する方法としてlet式とwhere式を学びました.
-
局所関数を使うと,再帰で用いる補助関数を再帰関数内で定義することができました.
-
フロー制御の方法として,if-else式,ガード,case式の3つを学びました.
-
合成関数の作り方とポイントフリースタイルについて学びました.
-
関数の入れ子呼び出しを,括弧を使わずに
$演算子を使って書く方法を学びました.
5.10. 練習問題
-
コード 5をロードして
abs' -10と呼び出したところ以下のエラーを得ました.理由を説明しなさい.<interactive>:43:1: error: • No instance for (Show (Integer -> Integer)) arising from a use of ‘print’ (maybe you haven't applied a function to enough arguments?) • In a stmt of an interactive GHCi command: print it -
与えられた数値リストから偶数だけを抜き出したリストを返す
takeEven関数を定義しなさい.takeEvenの型シグニチャはtakeEven :: Integral a => [a] -> [a]を使いなさい. -
与えられた数値リストを偶数のリストと奇数のリストに分けて,2つのリストをタプルにして返す
splitEvenOdd関数を定義しなさい.