filterをインライン化するSkipデータ構成子

 再帰関数がインライン化されないという事実は,filter関数のようにデータ構造の中の値を取り除く関数を定義するときに重要な意味を持ちます。

 再帰を利用して単純なfilter関数を定義してみましょう。

-- Data.List.Stream モジュールでの定義
filter :: (a -> Bool) -> [a] -> [a]

~ 略 ~

{-# NOINLINE [1] filter #-}
{-# RULES
"filter -> fusible" [~1] forall f xs.
    filter f xs = unstream (Stream.filter f (stream xs))
--"filter -> unfused" [1] forall f xs.
--    unstream (Stream.filter f (stream xs)) = filter f xs
  #-}

filter :: (a -> Bool) -> Stream a -> Stream a
filter p (Stream next0 s0) = Stream next s0
  where
    {-# INLINE next #-}
    next !s = case next0 s of
      Done                   -> Done
      Skip    s'             -> Skip    s'
      Yield x s' | p x       -> Yield x s'
                 | otherwise -> next    s'

 filter関数は,状態を使って処理を行うnext関数と状態s0をStream型に格納して返します。ここまでは今まで紹介した関数の定義と同じです。

 注目してほしいのは,filter関数の定義に使われているnext関数です。nextでは,状態sを使った式「next0 s」が「Yield x s'」を返すとき,値xを取り除くべきかどうかを関数pを使って判断します。「p x」の結果がTrueであれば「Yield x s'」をそのまま返します。そうでない場合には「next s'」を再帰的に呼び出し,次の状態s'に対する処理を行うことで値xを取り除きます。

 このnext関数は,再帰関数として定義されているため,インライン化されないという欠点があります。next関数がインライン化されなければ,「next関数を呼び出す処理」の内部にnext関数を展開することを前提とする,より積極的な最適化を行うことはできません。「next関数を呼び出す処理」とnext関数が別々に最適化されるだけです。つまり,filter関数の内部にあるnextが再帰関数として定義されている場合には,filter関数を利用する処理はあまり効率化されません。

 この問題を回避するには,filter関数の内部で使われるnextを,再帰を使わずに定義する必要があります。これを可能にするのが,Stream FusionでStep型に提供されているSkipデータ構成子です。Skipデータ構成子を利用することで,「s -> Step a s」型の関数であるnextを,再帰を使わない形で定義できます。

data Step a s = Yield a !s
              | Skip    !s
              | Done

-- 実際の Data.Stream モジュールでの定義
filter :: (a -> Bool) -> Stream a -> Stream a
filter p (Stream next0 s0) = Stream next s0
  where
    {-# INLINE next #-}
    next !s = case next0 s of
      Done                   -> Done
      Skip    s'             -> Skip    s'
      Yield x s' | p x       -> Yield x s'
                 | otherwise -> Skip    s'
{-# INLINE [0] filter #-}

{-# RULES
    "Stream filter/filter fusion" forall p q s.
        filter p (filter q s) = filter (\x -> q x && p x) s
 #-}

 先に示したnext関数との違いは,「next0 s」が返す「Yiled x s'」の値xを関数pで判定した結果がFalseになる場合だけです。この場合,「next0 s」が返す「Yield x s'」という値のうちxを切り捨てて,更新した状態「s'」だけをSkipデータ構成子に格納して返します。こうして作られた「Skip s'」が,最終的には「Stream型を引数として取り,Stream以外の型を返す関数」の内部にある再帰関数に渡されます。

unstream :: Stream a -> [a]
unstream (Stream next s0) = unfold_unstream s0
  where
    unfold_unstream !s = case next s of
      Done       -> []
      Skip    s' -> expose s' $     unfold_unstream s'
      Yield x s' -> expose s' $ x : unfold_unstream s'

sum :: Num a => Stream a -> a
sum (Stream next s0) = loop_sum 0 s0
  where
    loop_sum !a !s = case next s of   -- note: strict in the accumulator!
      Done       -> a
      Skip    s' -> expose s' $ loop_sum a s'
      Yield x s' -> expose s' $ loop_sum (a + x) s'

 再帰関数のloop_sumでは,Skipデータ構成子が現れた場合,状態だけを更新して再帰呼び出しを行います。このようにYieldデータ構成子の代わりにSkipデータ構成子を返すようにすれば,明示的に再帰を使わなくても値を取り除く処理を記述できるのです。

 こうして定義されたnext関数は再帰を使用しないため,インライン化が可能になります。filterの内部で使われるnextをインライン化できるようになれば,filterを利用する式を最適化できるようになります。

 このように,Stream Fusionは単に中間データ構造を取り除くだけでなく,中間データ構造を取り除いたコードをどう効率化するかも考慮して定義されています。ただし,GHCの最適化機能はまだ完全ではなく,現在の実装では最適化しきれないコードもあります(参考リンク1参考リンク2)。

 このため,現在のPreludeやData.Listでは,Stream Fusionではなくfold/buildが使われています。GHCの最適化機能の改善が進めば,将来はPreludeやData.ListでもStream Fusionが使われるようになるかもしれません。

 今回は,Stream Fusionの基礎を説明しました。ほかにも,Stream Fusionにはプログラムの処理を効率化するうえで重要な特徴があります。これについては次回説明する予定です。

著者紹介 shelarcy

 2010年6月に「The Fun of Programming」を翻訳した「関数プログラミングの楽しみ」が発売されました。この本の「第3章 おりがみプログラミング」では,融合変換の理論的な背景を扱っています。また「第5章 融合変換を自動化する」では,GHCの書き換え規則とは異なる「MAGというシステムを使ってソースコードを直接変換する」という形式の融合変換を扱っています。第5章で使われているMAGというシステムはもうメンテナンスされていないようですが,この章で示されている考え方自体はほかにも応用が利くものです。融合変換に興味のある方は,これらの章を読んでみるとよいでしょう。

 この本では,ほかにも様々な話題を扱っています。章ごとに独立した話題を取り上げる論稿集の構成を採っているため,基本的には読みたい章だけを読めるようになっています。融合変換以外にも,様々な興味深い話題が取り上げられており,おすすめです。