Haskell と末尾再帰は、他の関数型言語や末尾再帰ほどうまくいきません。末尾再帰で何が起こっているかを確認するために、いくつかの非常に単純なコードを手動で縮小してみましょう。の末尾再帰実装を次に示しmap (1+)
ます。
go [] r = r
go (x:xs) r = go xs (r ++ [1+x])
また、次の定義を念頭に置く必要があります(++)
。
[] ++ ys = ys
(x:xs) ++ ys = x : (xs ++ ys)
を減らしましょうgo [1,2,3,4,5] []
。、または略して の[x,y,z]
表記であることに注意してください。x:(y:(z:[]))
x:y:z:[]
go [1,2,3,4,5] []
go [2,3,4,5] ([] ++ [2]) -- 2 here is actually the thunk 1+1, but
-- for compactness I am reducing earlier
go [3,4,5] (([] ++ [2]) ++ [3])
go [4,5] ((([] ++ [2]) ++ [3]) ++ [4])
go [5] (((([] ++ [2]) ++ [3]) ++ [4]) ++ [5])
go [] ((((([] ++ [2]) ++ [3]) ++ [4]) ++ [5]) ++ [6])
(((([] ++ [2]) ++ [3]) ++ [4]) ++ [5]) ++ [6]
((([2] ++ [3]) ++ [4]) ++ [5]) ++ [6]
(((2:([] ++ [3]) ++ [4]) ++ [5]) ++ [6]
((2:(([] ++ [3]) ++ [4]) ++ [5]) ++ [6]
(2:((([] ++ [3]) ++ [4]) ++ [5]) ++ [6]
2:(((([] ++ [3]) ++ [4]) ++ [5]) ++ [6]) -- first observable output
2:((([3] ++ [4]) ++ [5]) ++ [6])
2:((3:([] ++ [4]) ++ [5]) ++ [6])
2:(3:(([] ++ [4]) ++ [5]) ++ [6])
2:3:((([] ++ [4]) ++ [5]) ++ [6]) -- second observable output
2:3:(([4] ++ [5]) ++ [6])
2:3:(4:([] ++ [5]) ++ [6])
2:3:4:(([] ++ [5]) ++ [6]) -- third observable output
2:3:4:([5] ++ [6])
2:3:4:5:([] ++ [6]) -- fourth observable output
2:3:4:5:6:[] -- final output
出力内の各項目が、深くネストされた一連の括弧から外側に向かってどのように機能する必要があるかがわかりますか? これにより、すべての出力を取得するために、入力のサイズで 2 次時間がかかります。また、最初のいくつかの項目がゆっくりと生成され、リストの最後に到達するにつれてますます速くなるという動作も見られます。この削減はそれを説明しています。
ここでの主なパフォーマンスの問題は、新しい要素をリストの最後に追加することです。これには、追加するリストのサイズに比例して時間がかかります。より良い方法は、一定時間の操作であるフロントで cons を使用することです。これにより、出力が逆になるため、結果を逆にする必要があります。
go [] r = reverse r
go (x:xs) r = go xs ((1+x):r)
reverse xs = rev xs [] -- roughly from the report prelude
rev [] r = r
rev (x:xs) r = rev xs (x:r)
そして、削減しましょう:
go [1,2,3,4,5] []
go [2,3,4,5] [2]
go [3,4,5] [3,2]
go [4,5] [4,3,2]
go [5] [5,4,3,2]
go [] [6,5,4,3,2]
reverse [6,5,4,3,2]
rev [6,5,4,3,2] []
rev [5,4,3,2] [6]
rev [4,3,2] [5,6]
rev [3,2] [4,5,6]
rev [2] [3,4,5,6]
rev [] [2,3,4,5,6]
[2,3,4,5,6] -- first and all observable output!
したがって、これは明らかに最初のバージョンよりも作業が少なくなります。これは、Scheme や ML などの厳密な言語で使用されるスタイルであり、メモリ パフォーマンスが優れています。ただし、いくつかの欠点があります。
- 出力を生成する前に、すべての入力を消費する必要があります。実際、結果が生成される前に計算全体が実行されます。
- このため、無限リストが与えられた場合、何も出力されません。
- これには
reverse
余分なO(n)
時間がかかり、私たちがしていることとは何の関係もありません (すべての要素に 1 を追加して順序を維持することと、逆にする必要があるのは何ですか?)。
Haskell のような怠惰な言語では、もっとうまくやることができます。奇妙に、そして美しいことに、私たちのやり方はもっと単純に書くことです。
go [] = []
go (x:xs) = (1+x):go xs
そして減らす:
go [1,2,3,4,5]
2:(go [2,3,4,5]) -- first observable output
2:3:(go [3,4,5]) -- second observable output
2:3:4:(go [4,5]) -- third observable output
2:3:4:5:(go [6]) -- fourth observable output
2:3:4:5:6:(go []) -- fifth observable output
2:3:4:5:6:[] -- final output
必要な作業はさらに少なく、リストの残りを見る前に出力を生成し始めるため、ストリーム計算で優れたパフォーマンスを発揮し、無限の入力で動作します。そして、実装は、あなたが期待できるほど単純で明白です。
これにより、Haskell で末尾再帰がどのように機能するかについての直感が得られることを願っています。あなたの例では、末尾の再帰を削除し、 final に似た単純なスタイルで書き直すことをおgo
勧めします。この投稿から提案した直感を使用して、「できるだけ早く、入力のできるだけ大きな接頭辞」を生成することを願っています (計算のためにさらに作業が必要な場合でも、x:xs
すぐに yieldを返すことに注意してください。これは (非) アクションの怠惰です)。x
xs