on master help sign in
Add documentation on Fold/Parser design
commit5e8f2c7a13433af2f39fb2971a2526a2e6ec7583 authorHarendra Kumar <harendra@composewell.com> dateThu 9 Apr 2020, 04:16 (7 years ago) parent88e55f7
3 files changed+220−37
src/Streamly/Internal/Data/Fold/Types.hs+121−9 src/Streamly/Internal/Data/Parser/Tee.hs+13−8 src/Streamly/Internal/Data/Parser/Types.hs+86−20
src/Streamly/Internal/Data/Fold/Types.hs+121−9
@@ -10,12 +10,123 @@
1010-- Maintainer : streamly@composewell.com
1111-- Stability : experimental
1212-- Portability : GHC
13+--
14+-- = Stream Consumers
15+--
16+-- We can classify stream consumers in the following categories in order of
17+-- increasing complexity and power:
18+--
19+-- == Accumulators
20+--
21+-- These are the simplest folds that never fail and never terminate, they
22+-- accumulate the input values forever and always remain @partial@ and
23+-- @complete@ at the same time. It means that we can keep adding more input to
24+-- them or at any time retrieve a consistent result. A
25+-- 'Streamly.Internal.Data.Fold.sum' operation is an example of an accumulator.
26+--
27+-- We can distribute an input stream to two or more accumulators using a @tee@
28+-- style composition. Accumulators cannot be applied on a stream one after the
29+-- other, which we call a @split@ style composition, as the first one itself
30+-- will never terminate, therefore, the next one will never get to run.
31+--
32+-- == Splitters
33+--
34+-- Splitters are accumulators that can terminate. When applied on a stream
35+-- splitters consume part of the stream, thereby, splitting it. Splitters can
36+-- be used in a @split@ style composition where one splitter can be applied
37+-- after the other on an input stream. We can apply a splitter repeatedly on an
38+-- input stream splitting and consuming it in fragments. Splitters never fail,
39+-- therefore, they do not need backtracking, but they can lookahead and return
40+-- unconsumed input. The 'Streamly.Internal.Data.Parser.take' operation is an
41+-- example of a splitter. It terminates after consuming @n@ items. Coupled with
42+-- an accumulator it can be used to split the stream into chunks of fixed size.
43+--
44+-- Consider the example of @takeWhile@ operation, it needs to inspect an
45+-- element for termination decision. However, it does not consume the element
46+-- on which it terminates. To implement @takeWhile@ a splitter will have to
47+-- implement a way to return unconsumed input to the driver.
48+--
49+-- == Parsers
50+--
51+-- Parsers are splitters that can fail and backtrack. Parsers can be composed
52+-- using an @alternative@ style composition where they can backtrack and apply
53+-- another parser if one parser fails. 'Streamly.Internal.Data.Parser.satisfy'
54+-- is a simple example of a parser, it would succeed if the condition is
55+-- satisfied and it would fail otherwise, on failure an alternative parser can
56+-- be used on the same input.
57+--
58+-- = Types for Stream Consumers
59+--
60+-- We use the 'Fold' type to implement the Accumulator and Splitter
61+-- functionality. Parsers are represented by the
62+-- 'Streamly.Internal.Data.Parser.Parser' type. This is a sweet spot to
63+-- balance ease of use, type safety and performance. Using separate
64+-- Accumulator and Splitter types would encode more information in types but it
65+-- would make ease of use, implementation, maintenance effort worse. Combining
66+-- Accumulator, Splitter and Parser into a single
67+-- 'Streamly.Internal.Data.Parser.Parser' type would make ease of use even
68+-- better but type safety and performance worse.
69+--
70+-- One of the design requirements that we have placed for better ease of use
71+-- and code reuse is that 'Streamly.Internal.Data.Parser.Parser' type should be
72+-- a strict superset of the 'Fold' type i.e. it can do everything that a 'Fold'
73+-- can do and more. Therefore, folds can be easily upgraded to parsers and we
74+-- can use parser combinators on folds as well when needed.
75+--
76+-- = Fold Design
77+--
78+-- A fold is represented by a collection of "initial", "step" and "extract"
79+-- functions. The "initial" action generates the initial state of the fold. The
80+-- state is internal to the fold and maintains the accumulated output. The
81+-- "step" function is invoked using the current state and the next input value
82+-- and results in a @Yield@ or @Stop@. A @Yield@ returns the next intermediate
83+-- state of the fold, a @Stop@ indicates that the fold has terminated and
84+-- returns the final value of the accumulator.
85+--
86+-- Every @Yield@ indicates that a new accumulated output is available. The
87+-- accumulated output can be extracted from the state at any point using
88+-- "extract". "extract" can never fail. A fold returns a valid output even
89+-- without any input i.e. even if you call "extract" on "initial" state it
90+-- provides an output. This is not true for parsers.
91+--
92+-- In general, "extract" is used in two cases:
93+--
94+-- * When the fold is used as a scan @extract@ is called on the intermediate
95+-- state every time it is yielded by the fold, the resulting value is yielded
96+-- as a stream.
97+-- * When the fold is used as a regular fold, @extract@ is called once when
98+-- we are done feeding input to the fold.
99+--
100+-- = Alternate Designs
101+--
102+-- An alternate and simpler design would be to return the intermediate output
103+-- via @Yield@ along with the state, instead of using "extract" on the yielded
104+-- state and remove the extract function altogether.
105+--
106+-- This may even facilitate more efficient implementation. Extract from the
107+-- intermediate state after each yield may be more costly compared to the fold
108+-- step itself yielding the output. The fold may have more efficient ways to
109+-- retrieve the output rather than stuffing it in the state and using extract
110+-- on the state.
111+--
112+-- However, removing extract altogether may lead to less optimal code in some
113+-- cases because the driver of the fold needs to thread around the intermediate
114+-- output to return it if the stream stops before the fold could @Stop@. When
115+-- using this approach, the @splitParse (FL.take filesize)@ benchmark shows a
116+-- 2x worse performance even after ensuring everything fuses. So we keep the
117+-- "extract" approach to ensure better perf in all cases.
118+--
119+-- But we could still yield both state and the output in @Yield@, the output
120+-- can be used for the scan use case, instead of using extract. Extract would
121+-- then be used only for the case when the stream stops before the fold
122+-- completes.
13123
14124module Streamly.Internal.Data.Fold.Types
15125 ( Fold (..)
16126 , Fold2 (..)
17127 , simplify
18128 , toListRevF -- experimental
129+ -- $toListRevF
19130 , lmap
20131 , lmapM
21132 , lfilter
@@ -52,13 +163,13 @@ import Streamly.Internal.Data.SVar (MonadAsync)
52163-- Monadic left folds
53164------------------------------------------------------------------------------
54165
55−-- | Represents a left fold over an input stream of values of type @a@ to a
56−-- single value of type @b@ in 'Monad' @m@.
166+-- | Represents a left fold over an input stream consisting of values of type
167+-- @a@ to a single value of type @b@ in 'Monad' @m@.
57168--
58169-- The fold uses an intermediate state @s@ as accumulator. The @step@ function
59−-- updates the state and returns the new updated state. When the fold is done
170+-- updates the state and returns the new state. When the fold is done
60171-- the final result of the fold is extracted from the intermediate state
61−-- representation using the @extract@ function.
172+-- using the @extract@ function.
62173--
63174-- @since 0.7.0
64175
@@ -66,7 +177,7 @@ data Fold m a b =
66177 -- | @Fold @ @ step @ @ initial @ @ extract@
67178 forall s. Fold (s -> a -> m s) (m s) (s -> m b)
68179
69−-- Experimental type to provide a side input to the fold for generating the
180+-- | Experimental type to provide a side input to the fold for generating the
70181-- initial state. For example, if we have to fold chunks of a stream and write
71182-- each chunk to a different file, then we can generate the file name using a
72183-- monadic action. This is a generalized version of 'Fold'.
@@ -208,9 +319,10 @@ instance (Monad m, Floating b) => Floating (Fold m a b) where
208319-- Internal APIs
209320------------------------------------------------------------------------------
210321
211−-- This is more efficient than 'toList'. toList is exactly the same as
212−-- reversing the list after toListRev.
213−--
322+-- $toListRevF
323+-- This is more efficient than 'Streamly.Internal.Data.Fold.toList'. toList is
324+-- exactly the same as reversing the list after 'toListRevF'.
325+
214326-- | Buffers the input stream to a list in the reverse order of the input.
215327--
216328-- /Warning!/ working on large lists accumulated as buffers in memory could be
@@ -283,7 +395,7 @@ lcatMaybes = lfilter isJust . lmap fromJust
283395
284396-- XXX These should become terminating folds.
285397--
286−-- | Take first 'n' elements from the stream and discard the rest.
398+-- | Take first @n@ elements from the stream and discard the rest.
287399--
288400-- @since 0.7.0
289401{-# INLINABLE ltake #-}
src/Streamly/Internal/Data/Parser/Tee.hs+13−8
@@ -1,13 +1,6 @@
11{-# LANGUAGE CPP #-}
22{-# LANGUAGE ScopedTypeVariables #-}
33
4−-- Somehow uni-pattern match using "let" produces better optimized code
5−-- compared to using cases and using explicit "error" messages in unreachable
6−-- cases. There seem to be no way to silence individual warning so we use this
7−-- global option for the file.
8−--
9−-- Disabling this has the potential to mask off some legit warnings, therefore
10−-- we have segregated code that uses uni-pattern matches in this file.
114#if __GLASGOW_HASKELL__ >= 800
125{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}
136#endif
@@ -22,7 +15,19 @@
2215-- Stability : experimental
2316-- Portability : GHC
2417--
25−-- Parsers.
18+-- Parallel parsers. Distributing the input to multiple parsers at the same
19+-- time.
20+--
21+-- For simplicity, we are using code where a particular state is unreachable
22+-- but it is not prevented by types. Somehow uni-pattern match using "let"
23+-- produces better optimized code compared to using @case@ match and using
24+-- explicit error messages in unreachable cases.
25+--
26+-- There seem to be no way to silence individual warnings so we use a global
27+-- incomplete uni-pattern match warning suppression option for the file.
28+-- Disabling the warning for other code as well has the potential to mask off
29+-- some legit warnings, therefore, we have segregated only the code that uses
30+-- uni-pattern matches in this module.
2631
2732module Streamly.Internal.Data.Parser.Tee
2833 (
src/Streamly/Internal/Data/Parser/Types.hs+86−20
@@ -11,32 +11,98 @@
1111--
1212-- Streaming and backtracking parsers.
1313--
14+-- Parsers just extend folds. Please read the 'Fold' design notes in
15+-- "Streamly.Internal.Data.Fold.Types" for background on the design.
16+--
17+-- = Parser Design
18+--
1419-- The 'Parser' type or a parsing fold is a generalization of the 'Fold' type.
15−-- The 'Fold' type always succeeds on each input. Therefore it does not need to
16−-- buffer the input. In contrast, a 'Parser' may fail and backtrack to replay
17−-- the input again to explore another branch of the parser. Therefore, it needs
18−-- to buffer the input.
19−--
20−-- The 'Parser' type is an extension of the 'Fold' type. The 'Step' functor
21−-- for the 'Parser' type has been specifically designed for stream parsing
22−-- applications, assuming the input to be a seuqentially read stream and
23−-- potentially buffering a contiguous segment of the stream based on the
24−-- parser's need.
25−--
26−-- To provide richer communication with the input stream, the 'Step' functor of
27−-- a 'Parser' provides more powerful commands to manipulate the input stream
28−-- after each step invocation. It allows the fold driver to:
29−--
30−-- 1. Buffer the input until explicitly asked to drop the input
31−-- 2. Drop the input stream beyond a "point of no return" in the history
32−-- because the input till that point has been consumed irrevocably.
33−-- 3. Go back in the buffered stream upto the point of no return.
20+-- The 'Fold' type /always/ succeeds on each input. Therefore, it does not need
21+-- to buffer the input. In contrast, a 'Parser' may fail and backtrack to
22+-- replay the input again to explore another branch of the parser. Therefore,
23+-- it needs to buffer the input. Therefore, a 'Parser' is a fold with some
24+-- additional requirements. To summarize, unlike a 'Fold', a 'Parser':
25+--
26+-- 1. may not generate a new value of the accumulator on every input, it may
27+-- generate a new accumulator only after consuming multiple input elements
28+-- (e.g. takeEQ).
29+-- 2. on success may return some unconsumed input (e.g. takeWhile)
30+-- 3. may fail and return all input without consuming it (e.g. satisfy)
31+-- 4. backtrack and start inspecting the past input again (e.g. alt)
32+--
33+-- These use cases require buffering and replaying of input. To facilitate
34+-- this, the step function of the 'Fold' is augmented to return the next state
35+-- of the fold along with a command tag using a 'Step' functor, the tag tells
36+-- the fold driver to manipulate the future input as the parser wishes. The
37+-- 'Step' functor provides the following commands to the fold driver
38+-- corresponding to the use cases outlined in the previous para:
39+--
40+-- 1. 'Skip': hold (buffer) the input or go back to a previous position in the stream
41+-- 2. 'Yield', 'Stop': tell how much input is unconsumed
42+-- 3. 'Error': indicates that the parser has failed without a result
43+--
44+-- = How a Parser Works?
45+--
46+-- A parser is just like a fold, it keeps consuming inputs from the stream and
47+-- accumulating them in an accumulator. The accumulator of the parser could be
48+-- a singleton value or it could be a collection of values e.g. a list.
49+--
50+-- The parser may build a new output value from multiple input items. When it
51+-- consumes an input item but needs more input to build a complete output item
52+-- it uses @Skip 0 s@, yielding the intermediate state @s@ and asking the
53+-- driver to provide more input. When the parser determines that a new output
54+-- value is complete it can use a @Stop n b@ to terminate the parser with @n@
55+-- items of input unused and the final value of the accumulator returned as
56+-- @b@. If at any time the parser determines that the parse has failed it can
57+-- return @Error err@.
58+--
59+-- A parser building a collection of values (e.g. a list) can use the @Yield@
60+-- constructor whenever a new item in the output collection is generated. If a
61+-- parser building a collection of values has yielded at least one value then
62+-- it considered successful and cannot fail after that. In the current
63+-- implementation, this is not automatically enforced, there is a rule that the
64+-- parser MUST use only @Stop@ for termination after the first @Yield@, it
65+-- cannot use @Error@. It may be possible to change the implementation so that
66+-- this rule is not required, but there may be some performance cost to it.
67+--
68+-- 'Streamly.Internal.Data.Parser.takeWhile' and
69+-- 'Streamly.Internal.Data.Parser.some' combinators are good examples of
70+-- efficient implementations using all features of this representation. It is
71+-- possible to idiomatically build a collection of parsed items using a
72+-- singleton parser and @Alternative@ instance instead of using a
73+-- multi-yield parser. However, this implementation is amenable to stream
74+-- fusion and can therefore be much faster.
75+--
76+-- = Error Handling
77+--
78+-- When a parser's @step@ function is invoked it may iterminate by either a
79+-- 'Stop' or an 'Error' return value. In an 'Alternative' composition an error
80+-- return can make the composed parser backtrack and try another parser.
81+--
82+-- If the stream stops before a parser could terminate then we use the
83+-- @extract@ function of the parser to retrieve the last yielded value of the
84+-- parser. If the parser has yielded at least one value then @extract@ MUST
85+-- return a value without throwing an error, otherwise it uses the 'ParseError'
86+-- exception to throw an error.
87+--
88+-- We chose the exception throwing mechanism for @extract@ instead of using an
89+-- explicit error return via an 'Either' type for keeping the interface simple
90+-- as most of the time we do not need to catch the error in intermediate
91+-- layers. Note that we cannot use exception throwing mechanism in @step@
92+-- function because of performance reasons. 'Error' constructor in that case
93+-- allows loop fusion and better performance.
94+--
95+-- = Future Work
96+--
97+-- It may make sense to move "takeWhile" type of parsers, which cannot fail but
98+-- need some lookahead, to splitting folds. This will allow such combinators
99+-- to be accepted where we need an unfailing "Fold" type.
34100--
35101-- Based on application requirements it should be possible to design even a
36102-- richer interface to manipulate the input stream/buffer. For example, we
37103-- could randomly seek into the stream in the forward or reverse directions or
38104-- we can even seek to the end or from the end or seek from the beginning.
39−
105+--
40106-- We can distribute and scan/parse a stream using both folds and parsers and
41107-- merge the resulting streams using different merge strategies (e.g.
42108-- interleaving or serial).