Skip to content

Commit 25abafe

Browse files
authored
Merge pull request #306 from fsprojects/repo-assist/feat-mapfold-2026-03-4abf0aa06ea5d884
[Repo Assist] feat: add TaskSeq.mapFold and TaskSeq.mapFoldAsync (56 tests)
2 parents 75402ae + 60ef258 commit 25abafe

File tree

7 files changed

+272
-6
lines changed

7 files changed

+272
-6
lines changed

global.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"sdk": {
3-
"version": "10.0.100",
4-
"rollForward": "latestPatch"
3+
"version": "10.0.102",
4+
"rollForward": "minor"
55
}
66
}

release-notes.txt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11

22
Release notes:
33

4-
0.5.0
5-
- update engineering to .NET 9/10
4+
0.6.0
65
- adds TaskSeq.scan and TaskSeq.scanAsync, #289
76
- adds TaskSeq.pairwise, #289
7+
- adds TaskSeq.mapFold and TaskSeq.mapFoldAsync
88
- adds TaskSeq.sum, sumBy, sumByAsync, average, averageBy, averageByAsync
99
- adds TaskSeq.reduce and TaskSeq.reduceAsync, #289
1010
- adds TaskSeq.unfold and TaskSeq.unfoldAsync, #289
1111
- adds TaskSeq.chunkBySize (closes #258) and TaskSeq.windowed, #289
1212
- fixes: CancellationToken passed to GetAsyncEnumerator is now honored in MoveNextAsync, #179
1313

14+
0.5.0
15+
- update engineering to .NET 9/10
16+
1417
0.4.0
1518
- overhaul all doc comments, add exceptions, improve IDE quick-info experience, #136, #220, #234
1619
- new surface area functions, fixes #208:

src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
<Compile Include="TaskSeq.Find.Tests.fs" />
2828
<Compile Include="TaskSeq.Fold.Tests.fs" />
2929
<Compile Include="TaskSeq.Scan.Tests.fs" />
30+
<Compile Include="TaskSeq.MapFold.Tests.fs" />
3031
<Compile Include="TaskSeq.Reduce.Tests.fs" />
3132
<Compile Include="TaskSeq.Forall.Tests.fs" />
3233
<Compile Include="TaskSeq.Head.Tests.fs" />
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
module TaskSeq.Tests.MapFold
2+
3+
open Xunit
4+
open FsUnit.Xunit
5+
6+
open FSharp.Control
7+
8+
//
9+
// TaskSeq.mapFold
10+
// TaskSeq.mapFoldAsync
11+
//
12+
13+
module EmptySeq =
14+
[<Fact>]
15+
let ``Null source is invalid`` () =
16+
assertNullArg
17+
<| fun () -> TaskSeq.mapFold (fun _ item -> string item, 0) 0 null
18+
19+
assertNullArg
20+
<| fun () -> TaskSeq.mapFoldAsync (fun _ item -> Task.fromResult (string item, 0)) 0 null
21+
22+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
23+
let ``TaskSeq-mapFold on empty returns empty array with initial state`` variant = task {
24+
let! results, finalState =
25+
Gen.getEmptyVariant variant
26+
|> TaskSeq.mapFold (fun state item -> item * 2, state + item) 0
27+
28+
results |> should equal [||]
29+
finalState |> should equal 0
30+
}
31+
32+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
33+
let ``TaskSeq-mapFoldAsync on empty returns empty array with initial state`` variant = task {
34+
let! results, finalState =
35+
Gen.getEmptyVariant variant
36+
|> TaskSeq.mapFoldAsync (fun state item -> task { return item * 2, state + item }) 0
37+
38+
results |> should equal [||]
39+
finalState |> should equal 0
40+
}
41+
42+
module Functionality =
43+
[<Fact>]
44+
let ``TaskSeq-mapFold maps elements while threading state`` () = task {
45+
// mapFold: map each element to its double, sum all originals as state
46+
let! results, finalState =
47+
TaskSeq.ofList [ 1; 2; 3; 4; 5 ]
48+
|> TaskSeq.mapFold (fun state item -> item * 2, state + item) 0
49+
50+
results |> should equal [| 2; 4; 6; 8; 10 |]
51+
finalState |> should equal 15 // 1+2+3+4+5
52+
}
53+
54+
[<Fact>]
55+
let ``TaskSeq-mapFoldAsync maps elements while threading state`` () = task {
56+
let! results, finalState =
57+
TaskSeq.ofList [ 1; 2; 3; 4; 5 ]
58+
|> TaskSeq.mapFoldAsync (fun state item -> task { return item * 2, state + item }) 0
59+
60+
results |> should equal [| 2; 4; 6; 8; 10 |]
61+
finalState |> should equal 15
62+
}
63+
64+
[<Fact>]
65+
let ``TaskSeq-mapFold returns array of same length as source`` () = task {
66+
let! results, _ =
67+
TaskSeq.ofList [ 'a'; 'b'; 'c' ]
68+
|> TaskSeq.mapFold (fun idx c -> string c, idx + 1) 0
69+
70+
results |> should equal [| "a"; "b"; "c" |]
71+
}
72+
73+
[<Fact>]
74+
let ``TaskSeq-mapFold single element returns singleton array and updated state`` () = task {
75+
let! results, finalState =
76+
TaskSeq.singleton 42
77+
|> TaskSeq.mapFold (fun state item -> item + 1, state + item) 10
78+
79+
results |> should equal [| 43 |]
80+
finalState |> should equal 52
81+
}
82+
83+
[<Fact>]
84+
let ``TaskSeq-mapFold state threads through in order`` () = task {
85+
// Build running index as state; mapped element is (index, item) pair
86+
let! results, finalState =
87+
TaskSeq.ofList [ 10; 20; 30 ]
88+
|> TaskSeq.mapFold (fun idx item -> (idx, item), idx + 1) 0
89+
90+
results |> should equal [| (0, 10); (1, 20); (2, 30) |]
91+
finalState |> should equal 3
92+
}
93+
94+
[<Fact>]
95+
let ``TaskSeq-mapFoldAsync state threads through in order`` () = task {
96+
let! results, finalState =
97+
TaskSeq.ofList [ 10; 20; 30 ]
98+
|> TaskSeq.mapFoldAsync (fun idx item -> task { return (idx, item), idx + 1 }) 0
99+
100+
results |> should equal [| (0, 10); (1, 20); (2, 30) |]
101+
finalState |> should equal 3
102+
}
103+
104+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
105+
let ``TaskSeq-mapFold accumulates correctly across variants`` variant = task {
106+
// Input 1..10; mapped = item*item; state = running sum
107+
let! results, finalState =
108+
Gen.getSeqImmutable variant
109+
|> TaskSeq.mapFold (fun acc item -> item * item, acc + item) 0
110+
111+
results
112+
|> should equal [| 1; 4; 9; 16; 25; 36; 49; 64; 81; 100 |]
113+
114+
finalState |> should equal 55 // 1+2+...+10
115+
}
116+
117+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
118+
let ``TaskSeq-mapFoldAsync accumulates correctly across variants`` variant = task {
119+
let! results, finalState =
120+
Gen.getSeqImmutable variant
121+
|> TaskSeq.mapFoldAsync (fun acc item -> task { return item * item, acc + item }) 0
122+
123+
results
124+
|> should equal [| 1; 4; 9; 16; 25; 36; 49; 64; 81; 100 |]
125+
126+
finalState |> should equal 55
127+
}
128+
129+
[<Fact>]
130+
let ``TaskSeq-mapFold result matches equivalent List.mapFold`` () = task {
131+
let items = [ 1; 2; 3; 4; 5 ]
132+
133+
let listResults, listState = List.mapFold (fun state item -> item + state, state + item) 0 items
134+
135+
let! taskResults, taskState =
136+
TaskSeq.ofList items
137+
|> TaskSeq.mapFold (fun state item -> item + state, state + item) 0
138+
139+
taskResults |> should equal (Array.ofList listResults)
140+
taskState |> should equal listState
141+
}
142+
143+
module SideEffects =
144+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
145+
let ``TaskSeq-mapFold second iteration sees next batch of side-effect values`` variant = task {
146+
let ts = Gen.getSeqWithSideEffect variant
147+
148+
let! first, firstState =
149+
ts
150+
|> TaskSeq.mapFold (fun acc item -> item * 2, acc + item) 0
151+
152+
first
153+
|> should equal [| 2; 4; 6; 8; 10; 12; 14; 16; 18; 20 |]
154+
155+
firstState |> should equal 55
156+
157+
// side-effect sequences yield next 10 items (11..20) on second consumption
158+
let! second, secondState =
159+
ts
160+
|> TaskSeq.mapFold (fun acc item -> item * 2, acc + item) 0
161+
162+
second
163+
|> should equal [| 22; 24; 26; 28; 30; 32; 34; 36; 38; 40 |]
164+
165+
secondState |> should equal 155 // 11+12+...+20
166+
}
167+
168+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
169+
let ``TaskSeq-mapFoldAsync second iteration sees next batch of side-effect values`` variant = task {
170+
let ts = Gen.getSeqWithSideEffect variant
171+
172+
let! first, firstState =
173+
ts
174+
|> TaskSeq.mapFoldAsync (fun acc item -> task { return item * 2, acc + item }) 0
175+
176+
first
177+
|> should equal [| 2; 4; 6; 8; 10; 12; 14; 16; 18; 20 |]
178+
179+
firstState |> should equal 55
180+
181+
let! second, secondState =
182+
ts
183+
|> TaskSeq.mapFoldAsync (fun acc item -> task { return item * 2, acc + item }) 0
184+
185+
second
186+
|> should equal [| 22; 24; 26; 28; 30; 32; 34; 36; 38; 40 |]
187+
188+
secondState |> should equal 155
189+
}

src/FSharp.Control.TaskSeq/TaskSeq.fs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,3 +518,5 @@ type TaskSeq private () =
518518
static member scanAsync folder state source = Internal.scan (AsyncFolderAction folder) state source
519519
static member reduce folder source = Internal.reduce (FolderAction folder) source
520520
static member reduceAsync folder source = Internal.reduce (AsyncFolderAction folder) source
521+
static member mapFold mapping state source = Internal.mapFold (MapFolderAction mapping) state source
522+
static member mapFoldAsync mapping state source = Internal.mapFold (AsyncMapFolderAction mapping) state source

src/FSharp.Control.TaskSeq/TaskSeq.fsi

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1537,8 +1537,43 @@ type TaskSeq =
15371537
folder: ('State -> 'T -> #Task<'State>) -> state: 'State -> source: TaskSeq<'T> -> TaskSeq<'State>
15381538

15391539
/// <summary>
1540-
/// Applies the function <paramref name="folder" /> to each element of the task sequence, threading an accumulator
1541-
/// argument through the computation. The first element is used as the initial state. If the input function is
1540+
/// Applies the function <paramref name="mapping" /> to each element of the task sequence, threading an accumulator
1541+
/// argument through the computation, while also generating a new mapped element for each input element.
1542+
/// If the input function is <paramref name="f" /> and the elements are <paramref name="i0...iN" />, then
1543+
/// computes both the mapped results <paramref name="r0...rN" /> and the final state in a single pass.
1544+
/// The result is a pair of an array of mapped values and the final state.
1545+
/// If the mapping function <paramref name="mapping" /> is asynchronous, consider using <see cref="TaskSeq.mapFoldAsync" />.
1546+
/// </summary>
1547+
///
1548+
/// <param name="mapping">A function that maps each element to a result while also updating the state.</param>
1549+
/// <param name="state">The initial state.</param>
1550+
/// <param name="source">The input task sequence.</param>
1551+
/// <returns>A task returning a pair of the array of mapped results and the final state.</returns>
1552+
/// <exception cref="T:ArgumentNullException">Thrown when the input task sequence is null.</exception>
1553+
static member mapFold:
1554+
mapping: ('State -> 'T -> 'Result * 'State) -> state: 'State -> source: TaskSeq<'T> -> Task<'Result[] * 'State>
1555+
1556+
/// <summary>
1557+
/// Applies the asynchronous function <paramref name="mapping" /> to each element of the task sequence,
1558+
/// threading an accumulator argument through the computation, while also generating a new mapped element for each input element.
1559+
/// If the input function is <paramref name="f" /> and the elements are <paramref name="i0...iN" />, then
1560+
/// computes both the mapped results <paramref name="r0...rN" /> and the final state in a single pass.
1561+
/// The result is a pair of an array of mapped values and the final state.
1562+
/// If the mapping function <paramref name="mapping" /> is synchronous, consider using <see cref="TaskSeq.mapFold" />.
1563+
/// </summary>
1564+
///
1565+
/// <param name="mapping">An asynchronous function that maps each element to a result while also updating the state.</param>
1566+
/// <param name="state">The initial state.</param>
1567+
/// <param name="source">The input task sequence.</param>
1568+
/// <returns>A task returning a pair of the array of mapped results and the final state.</returns>
1569+
/// <exception cref="T:ArgumentNullException">Thrown when the input task sequence is null.</exception>
1570+
static member mapFoldAsync:
1571+
mapping: ('State -> 'T -> #Task<'Result * 'State>) ->
1572+
state: 'State ->
1573+
source: TaskSeq<'T> ->
1574+
Task<'Result[] * 'State>
1575+
1576+
15421577
/// <paramref name="f" /> and the elements are <paramref name="i0...iN" />, then computes
15431578
/// <paramref name="f (... (f i0 i1)...) iN" />. Raises <see cref="T:System.ArgumentException" /> when the
15441579
/// sequence is empty.

src/FSharp.Control.TaskSeq/TaskSeqInternal.fs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ type internal InitAction<'T, 'TaskT when 'TaskT :> Task<'T>> =
4949
| InitAction of init_item: (int -> 'T)
5050
| InitActionAsync of async_init_item: (int -> 'TaskT)
5151

52+
[<Struct>]
53+
type internal MapFolderAction<'T, 'State, 'Result, 'TaskResultState when 'TaskResultState :> Task<'Result * 'State>> =
54+
| MapFolderAction of map_folder_action: ('State -> 'T -> 'Result * 'State)
55+
| AsyncMapFolderAction of async_map_folder_action: ('State -> 'T -> 'TaskResultState)
56+
5257
[<Struct>]
5358
type internal ManyOrOne<'T> =
5459
| Many of source_seq: TaskSeq<'T>
@@ -451,6 +456,37 @@ module internal TaskSeqInternal =
451456
return result
452457
}
453458

459+
let mapFold (folder: MapFolderAction<_, _, _, _>) initial (source: TaskSeq<_>) =
460+
checkNonNull (nameof source) source
461+
462+
task {
463+
use e = source.GetAsyncEnumerator CancellationToken.None
464+
let mutable go = true
465+
let mutable state = initial
466+
let results = ResizeArray()
467+
let! step = e.MoveNextAsync()
468+
go <- step
469+
470+
match folder with
471+
| MapFolderAction folder ->
472+
while go do
473+
let result, newState = folder state e.Current
474+
results.Add result
475+
state <- newState
476+
let! step = e.MoveNextAsync()
477+
go <- step
478+
479+
| AsyncMapFolderAction folder ->
480+
while go do
481+
let! (result, newState) = folder state e.Current
482+
results.Add result
483+
state <- newState
484+
let! step = e.MoveNextAsync()
485+
go <- step
486+
487+
return results.ToArray(), state
488+
}
489+
454490
let toResizeArrayAsync source =
455491
checkNonNull (nameof source) source
456492

0 commit comments

Comments
 (0)