Haskellは他の多くのプログラミング言語と異なった特徴を備えており、しばしばそれらが議論を呼ぶことがあります。その中でも特によく俎上に上がるのが、遅延評価です。遅延評価は、適切に扱えば不要な計算を行わず、計算資源を節約してくれるステキな仕組みですが、一歩使い方を間違うと「サンク」という「これから実行する(かも知れない)計算」を表すオブジェクトが無駄に作られてしまい、却ってメモリー消費量が増えてしまう、などといった問題を抱えています。この現象は「スペースリーク」と呼ばれ、かつて専門のAdvent Calendarが作られたことがあるほど、Haskeller達の関心を集めてきました。
そんなHaskeller達の悩みの種を軽減しようと、GHC 8.0以降、Strict
とStrictData
という言語拡張が搭載されました。これらの拡張は、大雑把に言うと、
StrictData
: 値コンストラクターにおいて、引数の値が弱頭正規形(Weak Head Normal Form。以降慣習に従い「WHNF」と呼びます)まで評価されるようになるStrict
:StrictData
の効果に加え、あらゆる関数の引数やローカル変数の定義において、パターンマッチで代入した変数の値がWHNFまで評価されるようになる
というものです。
このうち、StrictData
は比較的リスクが少なく大変有用(もはや標準であって欲しいぐらい)という声をよく聞きますが1、Strict
については様々な問題点があることが知られています。今回はその各種問題点をまとめて共有することで、思い切ってStrict
を有効にするときに参考になる情報を提供したいと思います!
Link to
here前提知識とその参考資料
以下の知識について、ざっくり理解しているものとして進めます。参考になりそうな日本語のページも付記したので、ご覧ください。
- Haskellの遅延評価について
- Haskellの正格評価、および
BangPatterns
について Strict
とStrictData
について- その他、Haskellスペースリーク Advent Calendar 2015 - Qiitaの記事にも有用なものがたくさんあります。
Link to
hereサンプルコードの試し方
これから紹介するコードは、すべてこのブログのリポジトリーの、examples
ディレクトリーに置いておきました。下記のコマンドを実行すれば実際に試すことができます(一部実行する際のコマンドが異なりますので、適宜例示します)。
git clone https://github.com/haskell-jp/blog.git
cd blog/examples/2020/strict-gotchas
stack exec runghc -- <これから紹介するコードのファイル>.hs
実際に試すときは--ghc-arg=-XStrict
というオプションをrunghc
に付けた場合と付けなかった場合両方で実行して、違いを確かめてみてください。
なお、使用したGHCのバージョンは8.10.1で、OSはWindows 10 ver. 1909です。
Link to
hereCase 1: where
句だろうとなんだろうと評価
サンプル: where.hs
最初のケースは、遅延評価で当たり前に享受できていたメリットが、Strict
を有効にしている状態では得られなくなってしまう、というものです。pxfncさんのStrict拡張でハマったお話という記事でも紹介されてはいますが、まとめ記事なのでここでも改めて取り上げます。
= print $ div10 0
main
div10 :: Int -> Int
div10 n| n == 0 = 0
| otherwise = result
where
= 10 `div` n result
ご覧のとおり、本当にほとんどpxfncさんの記事のサンプルそのままで恐縮ですが、このプログラム、👇のようにStrict
拡張を有効にして実行するとエラーが起こります。
> stack exec -- runghc --ghc-arg=-XStrict where.hs
where.hs: divide by zero
一方、Strict
拡張を有効にしなかった場合、エラーは起こりません。
> stack exec -- runghc where.hs
0
なぜこんなことが起こるのでしょう?
これは、Strict
拡張がパターンマッチで代入したあらゆる変数の値をWHNFまで評価するようになった結果、where
句で代入した変数まで必ずWHNFまで評価してしまうために発生したエラーです。すなわち、where
における、
= 10 `div` n result
までもが、
!result = 10 `div` n
とBangパターンを付けた代入であるかのように解釈されたのです2。
こうなると、result
を使用しないケース、すなわちn == 0
の場合であってもresult
に (WHNFまで評価した)値を代入するのに必要な計算は実行され、結果10 `div` 0
が計算されようとしてdivide by zero
が発生するのです。
⚠️where
句は関数定義の後ろの方に書くという性格上、見落としがちかも知れません。注意しましょう。
Link to
hereCase 2: ポイントフリースタイルかどうかで変わる!
サンプル: const.hs
続いて、Haskellに慣れた方なら誰もが一度は試したくなる、ポイントフリースタイルに関する落とし穴です。まずは次の二つの関数をご覧ください。
dontReferArgs :: a -> b -> a
= const
dontReferArgs
referArgs :: a -> b -> a
= x referArgs x _
この関数、どちらもやっていることはconst
と変わりません。dontReferArgs
はconst
をそのまま使うことでポイントフリースタイルにしていますが、referArgs
は自前で引数に言及することでconst
と同等の定義となっています。ポイントフリースタイルに変えると言うことは原則として元の関数の挙動を変えないワケですから、dontReferArgs
とreferArgs
の意味は変わらないはず、ですよね3?
ところがこれらの関数をStrict
拡張を有効にした上で定義すると、なんと挙動が異なってしまいます!
使用例:
main :: IO ()
= do
main print $ dontReferArgs "dontReferArgs" (undefined :: Int)
print $ referArgs "referArgs" (undefined :: Int)
実行結果(Strict拡張を有効にしなかった場合):
> stack exec runghc const.hs
"dontReferArgs"
"referArgs"
実行結果(Strict拡張を有効にした場合):
> stack exec -- runghc --ghc-arg=-XStrict const.hs
"dontReferArgs"
const.hs: Prelude.undefined
CallStack (from HasCallStack):
error, called at libraries\base\GHC\Err.hs:79:14 in base:GHC.Err
undefined, called at const.hs:10:34 in main:Main
はい、where
句のケースと同様、Strict
拡張を有効にした場合、例外が発生してしまいました❗️Strict
拡張を有効にした結果、意図せず例外を発生させる値(今回の場合undefined
)が評価されてしまったのです。
例外を発生させた関数はそう、ポイントフリースタイルでない、referArgs
関数の方です!なぜreferArgs
でのみ例外が発生してしまったのかというと、referArgs
がStrict
拡張を有効にしたモジュールで、引数に言及(パターンマッチ)しているからです。Strict
拡張を有効にした結果「あらゆる関数やローカル変数の定義において、パターンマッチで代入した変数の値」が評価されるとおり、referArgs
の引数x
・_
も必ず評価されるようになり、このような例外が発生したのです。たとえ使用しない変数_
でも関係ありません!
そのため、原因の本質は引数に言及(パターンマッチ)しているか否かであり、Prelude
のconst
を使用しているか否かではありません。こちら👇のように引数に言及した上でconst
を使っても、結果は同じなのです。
referArgsByConst :: a -> b -> a
= const x y referArgsByConst x y
print $ referArgsByConst "referArgsByConst" (undefined :: Int)
一方、dontReferArgs
については、引数に言及せず、Prelude
にあるconst
をそのまま使っています。Strict
拡張はあくまでも「パターンマッチした変数」のみをWHNFまで評価するものであり、あらゆる関数が正格に呼び出されるわけではありません。なので通常のPrelude
におけるconst
と同様、dontReferArgs
も第2引数は評価しないため、undefined
を渡しても例外は起こらなかったのです。
このことは、「Strict
拡張を有効にしているモジュールの中でも、Strict
を有効にしていないモジュール(この場合はPrelude
)からimport
した関数は、引数を正格に評価しない」という忘れてはならないポイントも示しています。例えばconst
よりももっと頻繁に使われるであろう、言及する引数を一つ削除する演算子の代表、関数合成.
を使ったケースを考えてみてください。
ポイントフリースタイルに慣れた方なら、関数適用$
を次👇のように使って定義したf
を見ると、
= map (+ 3) $ filter (> 2) xs
f xs
-- あるいは、`$`を使わないでこのように書いた場合も:
= map (+ 3) (filter (> 2) xs) f xs
こちら👇のように書き換えたくなってうずうずするでしょう。
= map (+ 3) . filter (> 2) f
しかし、Strict
を有効にしたモジュールでこのような書き換えを行うと、f
の挙動が変わってしまいます。引数.
を使って書き換える前は、引数xs
に言及していたところ.
を使って引数xs
に言及しなくなったからです。filter
もmap
もStrict
拡張を有効にしたモジュールで定義されているわけではないので、引数を正格に評価しないんですね。結果、こうした書き換えによって、Strict
拡張を有効にしていても意図せず遅延評価してしまう、というリスクがあるので、リファクタリングの際はくれぐれも気をつけてください4。ざっくりまとめると、Strict
拡張を有効にしているモジュールでは、「引数や変数を宣言することすなわちWHNFまで評価すること」、あるいは「引数や変数を宣言しなければ、評価されない」と意識しましょう。
ちなみに、referArgs
における_
のように「Strict
拡張を有効にした場合さえ、使用していない引数が評価されてしまうのは困る!」という場合は、引数名の前にチルダ~
を付けてください。
referArgs :: a -> b -> a
~_ = x referArgs x
Link to
hereCase 3: 内側のパターンはやっぱりダメ
サンプル: 今回はGHCiですべて紹介するのでサンプルはありません。
続いては、Strict
拡張のドキュメントでも触れられている、入れ子になったパターンマッチにおける問題を紹介します。一言で言うと、let (a, b) = ...
のような、データ構造(この場合タプルですね)の「内側」に対するパターンマッチは、Strict
拡張を有効にしていても正格に評価しないよ、という話です。
例えば、下記のコードをStrict
拡張付きで実行しても、パターンマッチしているa
・b
ともに代入した時点では正格評価されず、error "a"
・error "b"
による例外はいずれも発生しません。次のコードをGHCiで試してみてください。
> :set -XStrict
> (a, b) = (error "a", error "b")
-- 何も起きない
先ほどの節における「Strict
拡張を有効にしているモジュールでは、『引数や変数を宣言することすなわちWHNFまで評価すること」』、あるいは『引数や変数を宣言しなければ、評価されない』と意識しましょう」という主張を真に受けてしまうと、意図せず遅延評価させてしまい、ハマりそうです😰。⚠️繰り返しますが「内側のパターンにおける変数は正格評価されない」ということも意識してください。
一方、StrictData
や正格性フラグを用いるなどして、各要素を正格評価するよう定義した値コンストラクターでは、ちゃんと評価して例外を発生させます。
> :set -XStrict
> data MyTuple a b = MyTuple a b deriving Show
> let MyTuple a b = MyTuple (error "a") (error "b")
*** Exception: b
CallStack (from HasCallStack):
error, called at <interactive>:10:40 in interactive:Ghci7
Strict
拡張を有効にするとStrictData
も自動的に有効になるので、👆におけるMyTuple
値コンストラクターは各要素を正格評価するようになったわけです。なのでStrict
拡張を有効にしたモジュールにおいて、なおかつそこで定義した型で完結している限りは平和でしょう。
ただし、GHCiで試す場合に特に注意していただきたいのですが、GHCiでlet
をつけないでパターンマッチした場合は正格評価されない、という点に注意してください。let
をつけないとトップレベルでの定義と見なされるからです。Strict拡張のドキュメントにも、「Top level bindings are unaffected by Strict
」とありますとおり、トップレベルでの定義は例外扱いされているのです。
> :set -XStrict
> data MyTuple a b = MyTuple a b deriving Show
> MyTuple a b = MyTuple (error "a") (error "b")
-- 何も起きない
Link to
hereCase 4: foldr
に渡す関数
サンプル: stackoverflow-foldr.hs
ここの話はちょっと難しいので、先に守るべきルールを述べておきます。
「遅延評価する関数を受け取る前提の高階関数に、(Strict
拡張などで)引数を正格に評価するよう定義された関数を渡すのは止めましょう。」
なんだかこう書くと半ばトートロジーのようにも聞こえますが、より具体的には、例えばfoldr
に引数を正格に評価するよう定義された関数を渡すのは止めましょう、という話です。Strict
拡張を有効にした状態では、ラムダ式にも注意しないといけないもポイントです。
※あらかじめおことわり: この節のお話は、あくまでもリストに対するfoldr
の場合のお話です。他のFoldable
な型では必ずしも当てはまらないのでご注意ください。
論より証拠で、サンプルコードの中身(抜粋)とその実行結果を見てみましょう。
-- ...
evaluate . length $ foldr (:) [] [1 .. size]
putStrLn "DONE: foldr 1"
evaluate . length $ foldr (\x z -> x : z) [] [1 .. size]
putStrLn "DONE: foldr 2"
今回のサンプルコードを実行する際は、GHCのランタイムオプションを設定して、スタック領域のサイズを減らしてください。そうでなければ、処理するリストがあまり大きくないのでStrict
拡張を有効にしても問題の現象は再現されないでしょう5。こちらのStackoverflowの質問曰く、runghc
で実行する際にランタイムオプションを設定する場合は、GHCRTS
環境変数を使用するしかないそうです。
実行結果(Strict拡張を有効にしなかった場合):
> GHCRTS=-K100k stack exec runghc -- ./stackoverflow-foldr.hs
DONE: foldr 1
DONE: foldr 2
実行結果(Strict拡張を有効にした場合):
> GHCRTS=-K100k stack exec runghc -- --ghc-arg=-XStrict ./stackoverflow-foldr.hs
DONE: foldr 1
stackoverflow-foldr.hs: stack overflow
サンプルコードは整数のリストに対して特に何も変換せずfoldr
する(そして、length
関数でリスト全体を評価してから捨てる)だけのことを2回繰り返したコードです。最初のfoldr
はStrict
拡張があろうとなかろうと無事実行できたにもかかわらず、Strict
拡張を有効にした二つめのfoldr
は、stack overflow
というエラーを起こしてしまいました💥!
なぜこんなエラーが発生したのかを知るために、foldr
の定義を見直しましょう。こちら👇はGHC 8.10.1における、リストに対するfoldr
の定義です(コメントは省略しています)。
foldr :: (a -> b -> b) -> b -> [a] -> b
foldr k z = go
where
= z
go [] :ys) = y `k` go ys go (y
go
という補助関数を再帰的に呼び出すことで、第一引数として渡した関数k
を用いてリストの要素(y
)を一つずつ変換しています。呼び出す度にリストの残りの要素をチェックして、最終的に空のリストを受け取ったときはfoldr
の第二引数z
を返していますね。
このときk
が第二引数を遅延評価する関数であった場合、 — サンプルコードで言えば(:)
の場合 — 受け取ったgo ys
という式は直ちには評価されません。サンプルコードの(:)
に置き換えると、(:)
の第二引数、つまりリストの残りの要素を取り出す度にgo ys
を一回計算して、一個ずつ要素を作り出すイメージです。
一方、k
が第二引数を正格評価する関数であった場合、 — サンプルコードで言うところの、Strict
拡張を有効にした(\x z -> x : z)
の場合 — k
は受け取ったgo ys
をすぐに評価しようとします。このとき、GHCはk
やgo
に渡されている引数をスタック領域に積みます6。そうしてgo
と、go
に呼ばれたk
が次々と引数をスタック領域に積んだ結果、スタックサイズの上限に達し、スタックオーバーフローが発生してしまうのです。
これは他の多くのプログラミング言語で(末尾再帰じゃない、普通の)再帰呼び出しを行った場合とよく似た振る舞いです。間違って無限再帰呼び出しをしてしまってスタック領域があふれる、なんて経験は多くのプログラマーがお持ちでしょう。つまり単純に、Strict
拡張を有効にした場合のfoldr (\x z -> x : z) []
は、再帰呼び出しをしすぎてしまう関数となるのです。
なお、今回はlength
関数を使ってリスト全体を使用するコードにしましたが、遅延リストらしくfoldr
の結果を一部しか使わない、という場合、foldr
に渡した関数がリストを都度正格評価してしまうので、無駄な評価が占める割合はもっと増えることになります。やはりfoldr
は遅延評価を前提とした高階関数と言えるでしょう。
以上のとおり、Haskellにはfoldr
のような、遅延評価を前提とした関数がStrict
拡張より遥か昔から存在しています。それらをStrict
拡張を有効にした状態で使うと、思わぬ衝突が起きてしまうので、くれぐれも気をつけましょう。
こういう「使ってはいけない関数」を引いてしまわないための方法についても一点補足します。HLintを細かく設定したり、カスタムPrelude
を設定したりしてみるのは、一つの作戦です。なんとプロジェクト全体で、foldr
を禁止することができます(一部のモジュールでは例外的に許可する、なんてこともできます)。詳しくは「素晴らしき HLint を使いこなす」や「Prelude を カスタムPrelude で置き換える」をご覧ください。
Link to
hereCase 5: undefined
を受け取るメソッド
サンプル: storable.hs
最後はちょっとレアケースではありますが、こちら👇のIssueで発覚した問題を解説しましょう。
問題を簡単に再現するために、次のサンプルコードを用意しました。
-- importなどは当然省略!
data Test = Test Int Int deriving Show
instance Storable Test where
= sizeOf (1 :: Int) * 2
sizeOf _ = 8
alignment _ = error "This should not be called in this program"
peek = error "This should not be called in this program"
poke
= alloca $ \(_ :: Ptr Test) -> putStrLn "This won't be printed when Strict is enabled" main
はい、適当な型を定義してStorable
のインスタンスにして、それに対してalloca
を呼ぶだけのコードです。インスタンス定義をはじめかなり手抜きな感じになっちゃってますが、まぁ今回の問題を再現するのにはこれで十分なので、ご了承ください🙏。
このコード、残念ながらStrict
拡張を有効にした状態で実行すると、undefined
による例外が発生してしまいます💥。
> stack exec -- runghc --ghc-arg=-XStrict storable.hs
storable.hs: Prelude.undefined
CallStack (from HasCallStack):
error, called at libraries\base\GHC\Err.hs:79:14 in base:GHC.Err
undefined, called at libraries\base\Foreign\Marshal\Alloc.hs:117:31 in base:Foreign.Marshal.Alloc
こちらはStrict
を有効にしなかった場合。やはり例外は起きてませんね😌。
> stack exec -- runghc storable.hs
This won't be printed when Strict is enabled
さてこの、Strict
拡張を有効にした場合に発生した、undefined
による例外はどこからやってきたのでしょう?上記のコードにはいくつかerror
関数を使用している箇所がありますが、発生した例外はあくまでもundefined
です。見た限り上記のコードそのものから発生した例外ではなさそうですね…🤔。
その答えはなんと、main
関数で呼んでいるalloca
の定義にありました!
alloca :: forall a b . Storable a => (Ptr a -> IO b) -> IO b
=
alloca undefined :: a)) (alignment (undefined :: a)) allocaBytesAligned (sizeOf (
確かに、sizeOf
メソッドやalignment
メソッドにundefined
を渡しています。これらはいずれもStorable
型クラスのメソッドなので、上記のTest
型でももちろん実装しています。そう、実はこのsizeOf
メソッドとalignment
メソッドの実装で、下👇のように引数_
を宣言しているのが問題なのです!
instance Storable Test where
= sizeOf (1 :: Int) * 2
sizeOf _ = 8
alignment _ -- ...
「Case 2: ポイントフリースタイルかどうかで変わる!」の節で、「Strict
拡張を有効にしているモジュールでは、『引数や変数を宣言することすなわちWHNFまで評価すること」』、あるいは『引数や変数を宣言しなければ、評価されない』」と述べたことを再び思い出してください。こちらのsizeOf
・alignment
の定義でも同様に、引数_
を宣言しているため、引数を必ずWHNFまで評価することになっています。結果、alloca
関数がそれぞれを呼ぶ際undefined
を渡しているため、undefined
を評価してしまい、undefined
による例外が発生してしまうのです💥。
なぜこのように、alloca
関数ではsizeOf
やalignment
にundefined
をわざわざ渡しているのでしょう?それは、これらのメソッドがそもそもundefined
を渡して使うことを前提に設計されているからです。sizeOf
・alignment
はともにStorable a => a -> Int
という型の関数なので、第一引数にStorable
のインスタンスである型a
の値を受け取るのですが、このとき渡されるa
型の値は、使わないこととなっています。それぞれのメソッドの説明にも「The value of the argument is not used.」と書かれていますね。これは、sizeOf
もalignment
も、型毎に一意な値として定まる(引数の値によってsizeOf
やalignment
の結果が変わることがない)ので、第一引数のa
は、単に「この型のsizeOf
を呼んでくださいね」という型の情報を渡すためのものでしかないからです。だから値には関心がないのでundefined
を渡しているわけです。そもそも、alloca
関数のように引数としてStorable a => a
型の値をとらない関数では、a
型の値を用意することができませんし。
現代では通常、このように「値に関心がなく、何の型であるかという情報だけを受け取りたい」という場合は、Proxy
型を使うのが一般的です。Storable
は恐らくProxy
が発明される前に生まれたため、undefined
を渡すことになってしまっているのでしょう。なので、Storable
型クラスのインスタンスを自前で定義したりしない限り、こうしたケースに出遭うことはまれだと思います。ただ、それでもProxy
をimport
するのを面倒くさがってundefined
を代わりに渡す、なんてケースはありえるので、Proxy
を使って定義した型クラスでも同じ問題にハマることはあるかも知れません…。
⚠️結論として、Storable
型クラスや、Proxy
を受け取るメソッドを持つ型クラスのインスタンスを、Strict
拡張を有効にした状態で定義する場合は、Proxy
にあたる引数を評価しないよう、~_
などを使って定義しましょう。
Link to
hereおわりに: やっぱりStrict
は使う?使わない?
さて、ここまでStrict
拡張を有効にすることによって犯しうる、数々のミスを紹介してきました。ここまで書いた個人的な印象としては、「敢えて有効にする必要はないんじゃないか」といったところです(まぁ、悪いところばかり調べた結果のため、とてもフェアな視点での判断とは言えないのですが…)。foldr
の例でも触れたとおり、Haskellには遅延評価を前提とした、遅延評価を存分に活かした機能が溢れています。当然それらはStrict
拡張ができるよりはるか昔からあり、Strict
拡張のことなど一切考えないで作られたものです。動的型付け言語に後から静的型検査を導入するのが大変なように、相対する機能を後付けすると衝突が起こるのは仕方のないことですが、ことStrict
拡張については想像以上に大きな衝突のようです😞。
それでも使いたいという方に、今回の記事が助けになれば幸いです💪それではStrict
な方もNoStrict
な方もHappy Haskell Hacking!!
例えばfumievalさんによるこの記事より: 「もっとも、日常ではここまで気にしなければいけない場面は少ないので、ほとんどの場合は気にせず感嘆符をつけて大丈夫だろう。GHC 8.0からは、全フィールドをデフォルトで正格にする
StrictData
という拡張が入るため、こちらを使おう」↩︎BangPatterns
言語拡張を有効にした上で上記のように書き換えてみると、Strict
拡張の有無に関わらずエラーが発生します。試してみましょう。↩︎実際のところ今回紹介するケース以外にも、ポイントフリースタイルにするかしないかで実行効率などが変わる場合があります。例えば、Evaluation of function calls in Haskellをご覧ください。↩︎
もっとも、この例では引数はリストでしょうから、WHNFまでのみ正格評価するメリットは少なそうですが。↩︎
大きなリストにすると、今度はエラーが発生するまでに時間がかかってしまうので…。ちなみに、このようにスタック領域を小さくすることでスペースリークを検出する手法は、ndmitchell/spaceleak: Notes on space leaksでも紹介されています。↩︎
GHCがどのように評価し、スタック領域を消費するかはGHC illustratedや、その参考文献をご覧ください。↩︎