Strict拡張を使用する際の注意点

Posted by YAMAMOTO Yuji(@igrep) on June 11, 2020

Haskellは他の多くのプログラミング言語と異なった特徴を備えており、しばしばそれらが議論を呼ぶことがあります。その中でも特によく俎上に上がるのが、遅延評価です。遅延評価は、適切に扱えば不要な計算を行わず、計算資源を節約してくれるステキな仕組みですが、一歩使い方を間違うと「サンク」という「これから実行する(かも知れない)計算」を表すオブジェクトが無駄に作られてしまい、却ってメモリー消費量が増えてしまう、などといった問題を抱えています。この現象は「スペースリーク」と呼ばれ、かつて専門のAdvent Calendarが作られたことがあるほど、Haskeller達の関心を集めてきました。

そんなHaskeller達の悩みの種を軽減しようと、GHC 8.0以降、StrictStrictDataという言語拡張が搭載されました。これらの拡張は、大雑把に言うと、

  • StrictData: 値コンストラクターにおいて、引数の値が弱頭正規形(Weak Head Normal Form。以降慣習に従い「WHNF」と呼びます)まで評価されるようになる
  • Strict: StrictDataの効果に加え、あらゆる関数の引数やローカル変数の定義において、パターンマッチで代入した変数の値がWHNFまで評価されるようになる

というものです。

このうち、StrictDataは比較的リスクが少なく大変有用(もはや標準であって欲しいぐらい)という声をよく聞きますが1Strictについては様々な問題点があることが知られています。今回はその各種問題点をまとめて共有することで、思い切ってStrictを有効にするときに参考になる情報を提供したいと思います!

Link to
here
前提知識とその参考資料

以下の知識について、ざっくり理解しているものとして進めます。参考になりそうな日本語のページも付記したので、ご覧ください。

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で、OSWindows 10 ver. 1909です。

Link to
here
Case 1: where句だろうとなんだろうと評価

サンプル: where.hs

最初のケースは、遅延評価で当たり前に享受できていたメリットが、Strictを有効にしている状態では得られなくなってしまう、というものです。pxfncさんのStrict拡張でハマったお話という記事でも紹介されてはいますが、まとめ記事なのでここでも改めて取り上げます。

main = print $ div10 0

div10 :: Int -> Int
div10 n
  | n == 0    = 0
  | otherwise = result
 where
  result = 10 `div` n

ご覧のとおり、本当にほとんど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における、

  result = 10 `div` n

までもが、

  !result = 10 `div` n

Bangパターンを付けた代入であるかのように解釈されたのです2

こうなると、resultを使用しないケース、すなわちn == 0の場合であってもresultWHNFまで評価した)値を代入するのに必要な計算は実行され、結果10 `div` 0が計算されようとしてdivide by zeroが発生するのです。

⚠️where句は関数定義の後ろの方に書くという性格上、見落としがちかも知れません。注意しましょう。

Link to
here
Case 2: ポイントフリースタイルかどうかで変わる!

サンプル: const.hs

続いて、Haskellに慣れた方なら誰もが一度は試したくなる、ポイントフリースタイルに関する落とし穴です。まずは次の二つの関数をご覧ください。

dontReferArgs :: a -> b -> a
dontReferArgs = const

referArgs :: a -> b -> a
referArgs x _ = x

この関数、どちらもやっていることはconstと変わりません。dontReferArgsconstをそのまま使うことでポイントフリースタイルにしていますが、referArgsは自前で引数に言及することでconstと同等の定義となっています。ポイントフリースタイルに変えると言うことは原則として元の関数の挙動を変えないワケですから、dontReferArgsreferArgsの意味は変わらないはず、ですよね3

ところがこれらの関数をStrict拡張を有効にした上で定義すると、なんと挙動が異なってしまいます!

使用例:

main :: IO ()
main = do
  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でのみ例外が発生してしまったのかというと、referArgsStrict拡張を有効にしたモジュールで、引数に言及(パターンマッチ)しているからです。Strict拡張を有効にした結果「あらゆる関数やローカル変数の定義において、パターンマッチで代入した変数の値」が評価されるとおり、referArgsの引数x_も必ず評価されるようになり、このような例外が発生したのです。たとえ使用しない変数_でも関係ありません!

そのため、原因の本質は引数に言及(パターンマッチ)しているか否かであり、Preludeconstを使用しているか否かではありません。こちら👇のように引数に言及した上でconstを使っても、結果は同じなのです。

referArgsByConst :: a -> b -> a
referArgsByConst x y = const x y
print $ referArgsByConst "referArgsByConst" (undefined :: Int)

一方、dontReferArgsについては、引数に言及せず、Preludeにあるconstをそのまま使っています。Strict拡張はあくまでも「パターンマッチした変数」のみをWHNFまで評価するものであり、あらゆる関数が正格に呼び出されるわけではありません。なので通常のPreludeにおけるconstと同様、dontReferArgsも第2引数は評価しないため、undefinedを渡しても例外は起こらなかったのです。

このことは、「Strict拡張を有効にしているモジュールの中でも、Strictを有効にしていないモジュール(この場合はPreludeからimportした関数は、引数を正格に評価しない」という忘れてはならないポイントも示しています。例えばconstよりももっと頻繁に使われるであろう、言及する引数を一つ削除する演算子の代表、関数合成.を使ったケースを考えてみてください。

ポイントフリースタイルに慣れた方なら、関数適用$を次👇のように使って定義したfを見ると、

f xs = map (+ 3) $ filter (> 2) xs

-- あるいは、`$`を使わないでこのように書いた場合も:
f xs = map (+ 3) (filter (> 2) xs)

こちら👇のように書き換えたくなってうずうずするでしょう。

f = map (+ 3) . filter (> 2)

しかし、Strictを有効にしたモジュールでこのような書き換えを行うと、fの挙動が変わってしまいます。引数.を使って書き換える前は、引数xsに言及していたところ.を使って引数xsに言及しなくなったからです。filtermapStrict拡張を有効にしたモジュールで定義されているわけではないので、引数を正格に評価しないんですね。結果、こうした書き換えによって、Strict拡張を有効にしていても意図せず遅延評価してしまう、というリスクがあるので、リファクタリングの際はくれぐれも気をつけてください4。ざっくりまとめると、Strict拡張を有効にしているモジュールでは、「引数や変数を宣言することすなわちWHNFまで評価すること」、あるいは「引数や変数を宣言しなければ、評価されない」と意識しましょう。

ちなみに、referArgsにおける_のように「Strict拡張を有効にした場合さえ、使用していない引数が評価されてしまうのは困る!」という場合は、引数名の前にチルダ~を付けてください。

referArgs :: a -> b -> a
referArgs x ~_ = x

Link to
here
Case 3: 内側のパターンはやっぱりダメ

サンプル: 今回はGHCiですべて紹介するのでサンプルはありません。

続いては、Strict拡張のドキュメントでも触れられている、入れ子になったパターンマッチにおける問題を紹介します。一言で言うと、let (a, b) = ...のような、データ構造(この場合タプルですね)の「内側」に対するパターンマッチは、Strict拡張を有効にしていても正格に評価しないよ、という話です。

例えば、下記のコードをStrict拡張付きで実行しても、パターンマッチしているabともに代入した時点では正格評価されず、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で試す場合に特に注意していただきたいのですが、GHCiletをつけないでパターンマッチした場合は正格評価されない、という点に注意してください。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
here
Case 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回繰り返したコードです。最初のfoldrStrict拡張があろうとなかろうと無事実行できたにもかかわらず、Strict拡張を有効にした二つめのfoldrは、stack overflowというエラーを起こしてしまいました💥!

なぜこんなエラーが発生したのかを知るために、foldrの定義を見直しましょう。こちら👇はGHC 8.10.1における、リストに対するfoldrの定義です(コメントは省略しています)

foldr            :: (a -> b -> b) -> b -> [a] -> b
foldr k z = go
          where
            go []     = z
            go (y:ys) = y `k` go ys

goという補助関数を再帰的に呼び出すことで、第一引数として渡した関数kを用いてリストの要素(y)を一つずつ変換しています。呼び出す度にリストの残りの要素をチェックして、最終的に空のリストを受け取ったときはfoldrの第二引数zを返していますね。

このときkが第二引数を遅延評価する関数であった場合、 — サンプルコードで言えば(:)の場合 — 受け取ったgo ysという式は直ちには評価されません。サンプルコードの(:)に置き換えると、(:)の第二引数、つまりリストの残りの要素を取り出す度にgo ysを一回計算して、一個ずつ要素を作り出すイメージです。

一方、kが第二引数を正格評価する関数であった場合、 — サンプルコードで言うところの、Strict拡張を有効にした(\x z -> x : z)の場合 — kは受け取ったgo ysをすぐに評価しようとします。このとき、GHCkgoに渡されている引数をスタック領域に積みます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
here
Case 5: undefinedを受け取るメソッド

サンプル: storable.hs

最後はちょっとレアケースではありますが、こちら👇のIssueで発覚した問題を解説しましょう。

#16810: Use explicit lazy binding around undefined in inlinable functions · Issues · Glasgow Haskell Compiler / GHC · GitLab

問題を簡単に再現するために、次のサンプルコードを用意しました。

-- importなどは当然省略!
data Test = Test Int Int deriving Show

instance Storable Test where
  sizeOf _ = sizeOf (1 :: Int) * 2
  alignment _ = 8
  peek = error "This should not be called in this program"
  poke = error "This should not be called in this program"

main = alloca $ \(_ :: Ptr Test) -> putStrLn "This won't be printed when Strict is enabled"

はい、適当な型を定義して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  =
  allocaBytesAligned (sizeOf (undefined :: a)) (alignment (undefined :: a))

確かに、sizeOfメソッドやalignmentメソッドにundefinedを渡しています。これらはいずれもStorable型クラスのメソッドなので、上記のTest型でももちろん実装しています。そう、実はこのsizeOfメソッドとalignmentメソッドの実装で、下👇のように引数_を宣言しているのが問題なのです!

instance Storable Test where
  sizeOf _ = sizeOf (1 :: Int) * 2
  alignment _ = 8
  -- ...

Case 2: ポイントフリースタイルかどうかで変わる!」の節で、「Strict拡張を有効にしているモジュールでは、『引数や変数を宣言することすなわちWHNFまで評価すること」』、あるいは『引数や変数を宣言しなければ、評価されない』」と述べたことを再び思い出してください。こちらのsizeOfalignmentの定義でも同様に、引数_を宣言しているため、引数を必ずWHNFまで評価することになっています。結果、alloca関数がそれぞれを呼ぶ際undefinedを渡しているため、undefinedを評価してしまい、undefinedによる例外が発生してしまうのです💥。

なぜこのように、alloca関数ではsizeOfalignmentundefinedをわざわざ渡しているのでしょう?それは、これらのメソッドがそもそもundefinedを渡して使うことを前提に設計されているからです。sizeOfalignmentはともにStorable a => a -> Intという型の関数なので、第一引数にStorableのインスタンスである型aの値を受け取るのですが、このとき渡されるa型の値は、使わないこととなっています。それぞれのメソッドの説明にも「The value of the argument is not used.」と書かれていますね。これは、sizeOfalignmentも、型毎に一意な値として定まる(引数の値によってsizeOfalignmentの結果が変わることがない)ので、第一引数のaは、単に「この型のsizeOfを呼んでくださいね」という型の情報を渡すためのものでしかないからです。だから値には関心がないのでundefinedを渡しているわけです。そもそも、alloca関数のように引数としてStorable a => a型の値をとらない関数では、a型の値を用意することができませんし。

現代では通常、このように「値に関心がなく、何の型であるかという情報だけを受け取りたい」という場合は、Proxy型を使うのが一般的です。Storableは恐らくProxyが発明される前に生まれたため、undefinedを渡すことになってしまっているのでしょう。なので、Storable型クラスのインスタンスを自前で定義したりしない限り、こうしたケースに出遭うことはまれだと思います。ただ、それでもProxyimportするのを面倒くさがってundefinedを代わりに渡す、なんてケースはありえるので、Proxyを使って定義した型クラスでも同じ問題にハマることはあるかも知れません…。

⚠️結論として、Storable型クラスや、Proxyを受け取るメソッドを持つ型クラスのインスタンスを、Strict拡張を有効にした状態で定義する場合は、Proxyにあたる引数を評価しないよう、~_などを使って定義しましょう。

Link to
here
おわりに: やっぱりStrictは使う?使わない?

さて、ここまでStrict拡張を有効にすることによって犯しうる、数々のミスを紹介してきました。ここまで書いた個人的な印象としては、「敢えて有効にする必要はないんじゃないか」といったところです(まぁ、悪いところばかり調べた結果のため、とてもフェアな視点での判断とは言えないのですが…)foldrの例でも触れたとおり、Haskellには遅延評価を前提とした、遅延評価を存分に活かした機能が溢れています。当然それらはStrict拡張ができるよりはるか昔からあり、Strict拡張のことなど一切考えないで作られたものです。動的型付け言語に後から静的型検査を導入するのが大変なように、相対する機能を後付けすると衝突が起こるのは仕方のないことですが、ことStrict拡張については想像以上に大きな衝突のようです😞。

それでも使いたいという方に、今回の記事が助けになれば幸いです💪それではStrictな方もNoStrictな方もHappy Haskell Hacking!!


  1. 例えばfumievalさんによるこの記事より: 「もっとも、日常ではここまで気にしなければいけない場面は少ないので、ほとんどの場合は気にせず感嘆符をつけて大丈夫だろう。GHC 8.0からは、全フィールドをデフォルトで正格にするStrictDataという拡張が入るため、こちらを使おう」↩︎

  2. BangPatterns言語拡張を有効にした上で上記のように書き換えてみると、Strict拡張の有無に関わらずエラーが発生します。試してみましょう。↩︎

  3. 実際のところ今回紹介するケース以外にも、ポイントフリースタイルにするかしないかで実行効率などが変わる場合があります。例えば、Evaluation of function calls in Haskellをご覧ください。↩︎

  4. もっとも、この例では引数はリストでしょうから、WHNFまでのみ正格評価するメリットは少なそうですが。↩︎

  5. 大きなリストにすると、今度はエラーが発生するまでに時間がかかってしまうので…。ちなみに、このようにスタック領域を小さくすることでスペースリークを検出する手法は、ndmitchell/spaceleak: Notes on space leaksでも紹介されています。↩︎

  6. GHCがどのように評価し、スタック領域を消費するかはGHC illustratedや、その参考文献をご覧ください。↩︎