Behavioral.Iterator.ReplayableSequence
ReplayableSequence<T> is a fluent, allocation-light helper that lets you treat any forward IEnumerable<T> as a
multi-pass, lookahead, forkable stream – without pre-materializing everything into an array or repeatedly
re-enumerating the original source.
It augments the classic Iterator pattern by giving you struct cursors that:
- Advance independently (fork at any position)
- Support
Peek()and arbitrary positiveLookahead(offset) - Can be turned into (lazy)
IEnumerable<T>sequences at any time - Cooperatively fill a shared on-demand buffer (each underlying element is pulled at most once)
- Interop naturally with LINQ (and add a couple of extra fluent helpers like
Batch)
TL;DR
using PatternKit.Behavioral.Iterator;
var seq = ReplayableSequence<int>.From(Enumerable.Range(1, 5));
var c1 = seq.GetCursor();
// Read two values
c1.TryNext(out var a, out c1); // a = 1
c1.TryNext(out var b, out c1); // b = 2
// Fork (branch) without consuming more of c1
var c2 = c1.Fork();
// c2 can scan ahead independently
var la = c2.Lookahead(0).OrDefault(); // 3
var lb = c2.Lookahead(1).OrDefault(); // 4
// Enumerate remaining from c2 (3,4,5) with LINQ
var evens = c2.Where(x => x % 2 == 0).ToList(); // [4]
// c1 is still parked at element 3
c1.TryNext(out var third, out c1); // 3
Why not just use IEnumerable<T> directly?
Typical options when you need speculative / multi-pass logic:
| Need | Common Approach | Downsides |
|---|---|---|
| Lookahead | Queue<T> + manual buffering |
Manual complexity, error-prone indices |
| Backtracking / fork | Re-enumerate source multiple times | Re-runs expensive producers / I/O |
| Multiple cursors | Materialize to List<T> |
Upfront cost + full allocation |
ReplayableSequence<T> gives pay-as-you-go buffering: only what you actually touch is stored. Perfect for:
- Tokenizers / lightweight parsers
- Rule engines scanning the same prefix in different ways
- DSL interpreters
- Streaming transforms where limited rewind is handy
- Batch framing or chunked processing with optional lookahead
Core API
public sealed class ReplayableSequence<T>
{
public static ReplayableSequence<T> From(IEnumerable<T> source);
public Cursor GetCursor();
public IEnumerable<T> AsEnumerable();
public readonly struct Cursor
{
int Position { get; }
Cursor Fork();
bool TryNext(out T value, out Cursor next); // immutable advance
bool Peek(out T value); // no advance
Option<T> Lookahead(int offset); // offset >= 0
IEnumerable<T> AsEnumerable(); // enumerate from current position (cursor itself not moved)
}
}
public static class ReplayableSequenceExtensions
{
IEnumerable<TOut> Select<T,TOut>(Cursor c, Func<T,TOut> f);
IEnumerable<T> Where<T>(Cursor c, Func<T,bool> pred);
IEnumerable<IReadOnlyList<T>> Batch<T>(Cursor c, int size);
IEnumerable<T> AsEnumerable<T>(ReplayableSequence<T> seq); // convenience
}
Design Notes
- Cursor is a readonly struct → copying / forking is cheap.
TryNextreturns a new advanced cursor (functional style) to avoid hidden mutation.- All cursors share a single underlying buffer – thread confinement is assumed (not thread-safe).
Lookahead(n)ensures the buffer contains indexPosition + n(if possible) and returns anOption<T>.- After the source is fully drained the buffer becomes a random-access immutable snapshot for all cursors.
Examples
1. Token-style lookahead
var letters = ReplayableSequence<char>.From("abcde".ToCharArray());
var cur = letters.GetCursor();
// Need 2-char decision?
if (cur.Lookahead(0).OrDefault() == 'a' && cur.Lookahead(1).OrDefault() == 'b')
{
cur.TryNext(out _, out cur); // consume 'a'
cur.TryNext(out _, out cur); // consume 'b'
// ... parse AB token
}
2. Fork speculative parse branch
var seq = ReplayableSequence<int>.From(new[]{1,2,3,9,9});
var p = seq.GetCursor();
var attempt = p.Fork();
// Try read three numbers summing to 6
int sum = 0; int read = 0;
while (read < 3 && attempt.TryNext(out var v, out attempt)) { sum += v; read++; }
if (sum == 6) // success → commit (just adopt attempt cursor)
p = attempt; // original p now advanced logically
// else: discard attempt (p unchanged)
3. Batch processing (streaming window framing)
var seq = ReplayableSequence<int>.From(Enumerable.Range(1, 10));
var c = seq.GetCursor();
foreach (var batch in c.Batch(4))
{
Console.WriteLine(string.Join(',', batch));
}
// 1,2,3,4
// 5,6,7,8
// 9,10
4. Mixed LINQ + cursor ops
var seq = ReplayableSequence<int>.From(Enumerable.Range(1, 8));
var c = seq.GetCursor();
// Peek without moving
c.Peek(out var first); // 1
// Use Where on a cursor (does not move the original beyond enumeration copy)
var odds = c.Where(x => x % 2 == 1).Take(3).ToList(); // [1,3,5]
// c still at position 0 (functional enumeration)
Testing Invariants
| Invariant | Meaning |
|---|---|
| Single production | Underlying source element is produced (MoveNext true) at most once. |
| Idempotent forks | Forking does not mutate either cursor. |
| Safe lookahead | Lookahead(k) never advances state. |
| Lazy buffering | No elements buffered until requested. |
Gotchas & Tips
- Negative offsets →
ArgumentOutOfRangeException(failing fast clarifies bugs). - Avoid very large unbounded lookahead if your source is huge (each requested index must be buffered).
- Not thread-safe: confine a sequence + its cursors to one logical consumer (or add external synchronization).
Batchyields arrays per chunk (copy for immutability); if you need pooled buffers, add a specialized variant.
Comparison
| Approach | Pros | Cons |
|---|---|---|
| Plain re-enumeration | Simple | Re-runs side effects / I/O, duplicate work |
Materialize List<T> |
Random access | Upfront full allocation |
| ReplayableSequence | On-demand, multi-cursor, lookahead | Buffer growth unbounded if you read far |
See also
- Standard Iterator pattern (this is an enriched variant)
- Chain / Strategy patterns when composing behaviors over streamed elements
Option<T>(used forLookahead) for fluent presence/absence handling