Skip to content

Commit 4d79d6f

Browse files
committed
Complete the case of foldr
1 parent d4f6bd5 commit 4d79d6f

File tree

3 files changed

+84
-30
lines changed

3 files changed

+84
-30
lines changed

examples/2020/strict-gotchas/stackoverflow-foldl.hs

Lines changed: 0 additions & 16 deletions
This file was deleted.
Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
{-
2-
stack exec ghc -- -O0 -rtsopts -with-rtsopts=-K1k ./stackoverflow.hs
3-
stack exec ghc -- -O0 -rtsopts -with-rtsopts=-K1k -XStrict ./stackoverflow.hs
4-
stack exec ghc -- -O1 -rtsopts -with-rtsopts=-K1k ./stackoverflow.hs
5-
stack exec ghc -- -O1 -rtsopts -with-rtsopts=-K1k -XStrict ./stackoverflow.hs
2+
GHCRTS=-K100k stack exec runghc -- ./stackoverflow-foldr.hs
3+
GHCRTS=-K100k stack exec runghc -- --ghc-arg=-XStrict ./stackoverflow-foldr.hs
64
-}
75

86
import Control.Exception
@@ -11,10 +9,9 @@ import Data.List
119
main :: IO ()
1210
main = do
1311
let size = 5000
14-
putStrLn "BEGIN"
1512

16-
evaluate $ foldr (:) [] [1 .. size]
13+
evaluate . length $ foldr (:) [] [1 .. size]
1714
putStrLn "DONE: foldr 1"
1815

19-
evaluate $ foldr (\x z -> x : z) [] [1 .. size]
16+
evaluate . length $ foldr (\x z -> x : z) [] [1 .. size]
2017
putStrLn "DONE: foldr 2"

preprocessed-site/posts/2020/strict-gotchas.md

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -259,19 +259,92 @@ MyTuple a b = MyTuple (error "a") (error "b")
259259
"Other value in MyTuple1"
260260
```
261261

262-
# Case 4: `foldl``foldr`
262+
# Case 4: `foldr`に渡す関数
263263

264-
ここの話はちょっと難しいので、先に守るべくルールを述べておきます。
265-
「遅延評価する関数を受け取る前提の高階関数に、
264+
サンプル: [stackoverflow-foldr.hs](https://github.com/haskell-jp/blog/blob/master/examples/2020/strict-gotchas/stackoverflow-foldr.hs)
266265

267-
より具体的には、`foldr`に<small>(`Strict`拡張などで)</small>引数を正格に評価するよう定義された関数を渡すのは止めましょう、という話です。
266+
ここの話はちょっと難しいので、先に守るべきルールを述べておきます。
267+
268+
「遅延評価する関数を受け取る前提の高階関数に、(`Strict`拡張などで)正格に評価するよう定義された関数を渡すのは止めましょう。」
269+
270+
なんだかこう書くと半ばトートロジーのようにも聞こえますが、より具体的には、例えば`foldr`に引数を正格に評価するよう定義された関数を渡すのは止めましょう、という話です。
268271
`Strict`拡張を有効にした状態ではラムダ式にも注意しないといけないもポイントです。
269272

270-
サンプル1: [stackoverflow-foldl.hs](https://github.com/haskell-jp/blog/blob/master/examples/2020/strict-gotchas/stackoverflow-foldl.hs)
273+
※あらかじめおことわり: この節のお話は、あくまでもリストに対する`foldr`の場合のお話です。
274+
他の`Foldable`な型では必ずしも当てはまらないのでご注意ください。
271275

272-
サンプル2: [stackoverflow-foldr.hs](https://github.com/haskell-jp/blog/blob/master/examples/2020/strict-gotchas/stackoverflow-foldr.hs)
276+
論より証拠で、サンプルコードの中身(抜粋)とその実行結果を見てみましょう。
273277

274-
hoge
278+
```main
279+
-- ...
280+
evaluate . length $ foldr (:) [] [1 .. size]
281+
putStrLn "DONE: foldr 1"
282+
283+
evaluate . length $ foldr (\x z -> x : z) [] [1 .. size]
284+
putStrLn "DONE: foldr 2"
285+
```
286+
287+
今回のサンプルコードを実行する際は、GHCのランタイムオプションを設定して、スタックのサイズを減らしてください。
288+
そうでなければ、処理するリストがあまり大きくないので`Strict`拡張を有効にしても問題の現象は再現されないでしょう[^bigger-list]
289+
[こちらのStackoverflowの質問](https://stackoverflow.com/questions/29339643/how-can-i-pass-rts-options-to-runghc)曰く、`runghc`で実行する際にランタイムオプションを設定する場合は、`GHCRTS`環境変数を使用するしかないそうです。
290+
291+
[^bigger-list]: 大きなリストにすると、今度はエラーが発生するまでに時間がかかってしまうので...。
292+
293+
実行結果(Strict拡張を有効にしなかった場合):
294+
295+
```bash
296+
> GHCRTS=-K100k stack exec runghc -- ./stackoverflow-foldr.hs
297+
DONE: foldr 1
298+
DONE: foldr 2
299+
```
300+
301+
実行結果(Strict拡張を有効にした場合):
302+
303+
```bash
304+
> GHCRTS=-K100k stack exec runghc -- --ghc-arg=-XStrict ./stackoverflow-foldr.hs
305+
DONE: foldr 1
306+
stackoverflow-foldr.hs: stack overflow
307+
```
308+
309+
はい、サンプルコードは整数のリストに対して特に何も変換せず`foldr`する<small>(そして、`length`関数でリスト全体を評価してから捨てる)</small>だけのことを2回繰り返したコードです。
310+
最初の`foldr``Strict`拡張があろうとなかろうと無事実行できたにもかかわらず、2つめの`foldr``stack overflow`というエラーを起こしてしまいました💥!
311+
312+
なぜこんなエラーが発生したのかを知るために、`foldr`の定義を見直しましょう。
313+
こちら👇は[GHC 8.10.1における、リストに対する`foldr`の定義](http://hackage.haskell.org/package/base-4.14.0.0/docs/src/GHC.Base.html#foldr)です<small>(コメントは省略しています)</small>。
314+
315+
```haskell
316+
foldr :: (a -> b -> b) -> b -> [a] -> b
317+
foldr k z = go
318+
where
319+
go [] = z
320+
go (y:ys) = y `k` go ys
321+
```
322+
323+
`go`という補助関数を再帰的に呼び出すことで、第一引数として渡した関数`k`でリストの要素(`y`)を一つずつ変換しています。
324+
呼び出す度にリストの残りの要素をチェックして、最終的に空のリストを受け取ったときは`foldr`の第二引数`z`を返していますね。
325+
326+
このとき`k`が第二引数を遅延評価する関数であった場合、 --- サンプルコード言えば`(:)`の場合 --- 受け取った`go ys`という式は直ちには評価されません。
327+
サンプルコードの`(:)`に置き換えると、`(:)`の第二引数、つまりリストの残りの要素を取り出そうとする度に`go ys`を一度計算して一個ずつ要素を作り出すイメージです。
328+
329+
一方、`k`が第二引数を正格評価する関数であった場合、 --- サンプルコードで言うところの、`Strict`拡張を有効にした`(\x z -> x : z)`の場合 --- 受け取った`go ys``k`はすぐに評価しようとします。
330+
このとき、GHCは`k``go`に渡されている引数をスタックに積みます[^rts]
331+
そうして`go`と、`go`に呼ばれた`k`が次々と引数をスタックに積んだ結果、スタックサイズの上限に達し、スタックオーバーフローが発生してしまうのです。
332+
333+
[^rts]: GHCがどのように評価し、スタックを消費するかは[GHC illustrated](https://takenobu-hs.github.io/downloads/haskell_ghc_illustrated.pdf)や、その参考文献をご覧ください。
334+
335+
これは他の多くのプログラミング言語で<small>(末尾再帰じゃない、普通の)</small>再帰呼び出しを行った場合とよく似た振る舞いです。
336+
間違って無限再帰呼び出しをしてしまってスタックがあふれる、なんて経験は多くのプログラマーがお持ちでしょう。
337+
つまり単純に、`Strict`拡張を有効にした場合の`foldr (\x z -> x : z) []`は、再帰呼び出しをしすぎてしまう関数なのです。
338+
339+
なお、今回は`length`関数を使ってリスト全体を使用するコードにしましたが、遅延リストらしく`foldr`の結果を一部しか使わない、という場合、`foldr`に渡した関数がリストを都度正格評価してしまうので、無駄な評価が占める割合はもっと増えることになります。
340+
`foldr`は遅延評価を前提とした高階関数と言えそうです。
341+
342+
以上のとおり、Haskellには`foldr`のような、遅延評価を前提とした関数が`Strict`拡張より遥か昔から存在しています。
343+
それらを`Strict`拡張を有効にした状態で使うと、思わぬ衝突が起きてしまうので、くれぐれも気をつけましょう。
344+
345+
こういう「使ってはいけない関数」を引いてしまわないための方法についても補足しましょう。
346+
HLintを細かく設定したり、カスタム`Prelude`を設定したりしてみるのは一つの作戦です。プロジェクト全体で、`foldr`を完全に禁止することができます<small>(一部のモジュールでは例外的に許可することもできます)</small>。
347+
詳しくは[「素晴らしき HLint を使いこなす」](https://haskell.e-bigmoon.com/posts/2018/01-29-awesome-hlint.html)[「Prelude を カスタムPrelude で置き換える」](https://haskell.e-bigmoon.com/posts/2018/05-23-extended-prelude.html)をご覧ください。
275348

276349
# Case 5: `undefined`を受け取るメソッド
277350

0 commit comments

Comments
 (0)