Skip to content

Commit e60fcf4

Browse files
feat: add TaskSeq.map2, map2Async, iter2, iter2Async (124 tests)
Implements the first batch of '2'-functions from the README roadmap: - TaskSeq.iter2 : apply a side-effecting action to pairs of elements - TaskSeq.iter2Async : async variant of iter2 - TaskSeq.map2 : build a new TaskSeq by mapping pairs of elements - TaskSeq.map2Async : async variant of map2 All four functions stop at the shorter sequence (matching zip semantics). Includes 124 new tests in TaskSeq.Map2Iter2.Tests.fs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5a03593 commit e60fcf4

File tree

7 files changed

+461
-3
lines changed

7 files changed

+461
-3
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ The `TaskSeq` project already has a wide array of functions and functionalities,
214214
- [x] `chunkBySize` / `windowed` (see [#258])
215215
- [ ] `compareWith`
216216
- [ ] `distinct`
217-
- [ ] `exists2` / `map2` / `fold2` / `iter2` and related '2'-functions
217+
- [ ] `exists2` / `fold2` / `forall2` and remaining '2'-functions (`map2`, `iter2` &#x2705; now implemented)
218218
- [ ] `mapFold`
219219
- [x] `pairwise` (see [#293])
220220
- [ ] `allpairs` / `permute` / `distinct` / `distinctBy`
@@ -305,14 +305,14 @@ This is what has been implemented so far, is planned or skipped:
305305
| &#x2705; [#23][] | `isEmpty` | `isEmpty` | | |
306306
| &#x2705; [#23][] | `item` | `item` | | |
307307
| &#x2705; [#2][] | `iter` | `iter` | `iterAsync` | |
308-
| | `iter2` | `iter2` | `iter2Async` | |
308+
| &#x2705; | `iter2` | `iter2` | `iter2Async` | |
309309
| &#x2705; [#2][] | `iteri` | `iteri` | `iteriAsync` | |
310310
| | `iteri2` | `iteri2` | `iteri2Async` | |
311311
| &#x2705; [#23][] | `last` | `last` | | |
312312
| &#x2705; [#53][] | `length` | `length` | | |
313313
| &#x2705; [#53][] | | `lengthBy` | `lengthByAsync` | |
314314
| &#x2705; [#2][] | `map` | `map` | `mapAsync` | |
315-
| | `map2` | `map2` | `map2Async` | |
315+
| &#x2705; | `map2` | `map2` | `map2Async` | |
316316
| | `map3` | `map3` | `map3Async` | |
317317
| | `mapFold` | `mapFold` | `mapFoldAsync` | |
318318
| &#x1f6ab; | `mapFoldBack` | | | [note #2](#note2 "Because of the async nature of TaskSeq sequences, iterating from the back would be bad practice. Instead, materialize the sequence to a list or array and then apply the 'Back' iterators.") |

release-notes.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
Release notes:
33

44
0.6.0
5+
- adds TaskSeq.map2 and TaskSeq.map2Async
6+
- adds TaskSeq.iter2 and TaskSeq.iter2Async
57
- adds TaskSeq.scan and TaskSeq.scanAsync, #289
68
- adds TaskSeq.pairwise, #289
79
- adds TaskSeq.groupBy and TaskSeq.groupByAsync, #289

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
<Compile Include="TaskSeq.IsEmpty.fs" />
4141
<Compile Include="TaskSeq.Item.Tests.fs" />
4242
<Compile Include="TaskSeq.Iter.Tests.fs" />
43+
<Compile Include="TaskSeq.Map2Iter2.Tests.fs" />
4344
<Compile Include="TaskSeq.Last.Tests.fs" />
4445
<Compile Include="TaskSeq.Length.Tests.fs" />
4546
<Compile Include="TaskSeq.Map.Tests.fs" />
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
module TaskSeq.Tests.Map2Iter2
2+
3+
open System.Threading.Tasks
4+
open Xunit
5+
open FsUnit.Xunit
6+
7+
open FSharp.Control
8+
9+
//
10+
// TaskSeq.iter2
11+
// TaskSeq.iter2Async
12+
// TaskSeq.map2
13+
// TaskSeq.map2Async
14+
//
15+
16+
module Iter2EmptySeq =
17+
[<Fact>]
18+
let ``Null source is invalid for iter2`` () =
19+
assertNullArg
20+
<| fun () -> TaskSeq.iter2 (fun _ _ -> ()) null TaskSeq.empty
21+
22+
assertNullArg
23+
<| fun () -> TaskSeq.iter2 (fun _ _ -> ()) TaskSeq.empty null
24+
25+
[<Fact>]
26+
let ``Null source is invalid for iter2Async`` () =
27+
assertNullArg
28+
<| fun () -> TaskSeq.iter2Async (fun _ _ -> Task.fromResult ()) null TaskSeq.empty
29+
30+
assertNullArg
31+
<| fun () -> TaskSeq.iter2Async (fun _ _ -> Task.fromResult ()) TaskSeq.empty null
32+
33+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
34+
let ``TaskSeq-iter2 does nothing on two empty sequences`` variant = task {
35+
let tq = Gen.getEmptyVariant variant
36+
let mutable sum = 0
37+
do! TaskSeq.iter2 (fun a b -> sum <- sum + a + b) tq tq
38+
sum |> should equal 0
39+
}
40+
41+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
42+
let ``TaskSeq-iter2 does nothing when first sequence is empty`` variant = task {
43+
let tq = Gen.getEmptyVariant variant
44+
let mutable sum = 0
45+
do! TaskSeq.iter2 (fun a b -> sum <- sum + a + b) tq (taskSeq { yield! [ 1..10 ] })
46+
sum |> should equal 0
47+
}
48+
49+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
50+
let ``TaskSeq-iter2 does nothing when second sequence is empty`` variant = task {
51+
let tq = Gen.getEmptyVariant variant
52+
let mutable sum = 0
53+
do! TaskSeq.iter2 (fun a b -> sum <- sum + a + b) (taskSeq { yield! [ 1..10 ] }) tq
54+
sum |> should equal 0
55+
}
56+
57+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
58+
let ``TaskSeq-iter2Async does nothing on two empty sequences`` variant = task {
59+
let tq = Gen.getEmptyVariant variant
60+
let mutable sum = 0
61+
62+
do!
63+
TaskSeq.iter2Async
64+
(fun a b ->
65+
sum <- sum + a + b
66+
Task.fromResult ())
67+
tq
68+
tq
69+
70+
sum |> should equal 0
71+
}
72+
73+
module Iter2Immutable =
74+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
75+
let ``TaskSeq-iter2 visits all paired elements in order`` variant = task {
76+
let one = Gen.getSeqImmutable variant
77+
let two = Gen.getSeqImmutable variant
78+
let results = System.Collections.Generic.List<int * int>()
79+
do! TaskSeq.iter2 (fun a b -> results.Add(a, b)) one two
80+
results.Count |> should equal 10
81+
82+
results
83+
|> Seq.forall (fun (a, b) -> a = b)
84+
|> should be True
85+
86+
results
87+
|> Seq.map fst
88+
|> Seq.toArray
89+
|> should equal [| 1..10 |]
90+
}
91+
92+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
93+
let ``TaskSeq-iter2Async visits all paired elements in order`` variant = task {
94+
let one = Gen.getSeqImmutable variant
95+
let two = Gen.getSeqImmutable variant
96+
let results = System.Collections.Generic.List<int * int>()
97+
98+
do!
99+
TaskSeq.iter2Async
100+
(fun a b ->
101+
results.Add(a, b)
102+
Task.fromResult ())
103+
one
104+
two
105+
106+
results.Count |> should equal 10
107+
108+
results
109+
|> Seq.forall (fun (a, b) -> a = b)
110+
|> should be True
111+
112+
results
113+
|> Seq.map fst
114+
|> Seq.toArray
115+
|> should equal [| 1..10 |]
116+
}
117+
118+
[<Fact>]
119+
let ``TaskSeq-iter2 truncates to shorter sequence when first is shorter`` () = task {
120+
let short = taskSeq { yield! [ 1..3 ] }
121+
let long = taskSeq { yield! [ 1..10 ] }
122+
let mutable count = 0
123+
do! TaskSeq.iter2 (fun _ _ -> count <- count + 1) short long
124+
count |> should equal 3
125+
}
126+
127+
[<Fact>]
128+
let ``TaskSeq-iter2 truncates to shorter sequence when second is shorter`` () = task {
129+
let long = taskSeq { yield! [ 1..10 ] }
130+
let short = taskSeq { yield! [ 1..3 ] }
131+
let mutable count = 0
132+
do! TaskSeq.iter2 (fun _ _ -> count <- count + 1) long short
133+
count |> should equal 3
134+
}
135+
136+
[<Fact>]
137+
let ``TaskSeq-iter2 can combine different element types`` () = task {
138+
let ints = taskSeq { yield! [ 1; 2; 3 ] }
139+
let strs = taskSeq { yield! [ "a"; "b"; "c" ] }
140+
let results = System.Collections.Generic.List<int * string>()
141+
do! TaskSeq.iter2 (fun n s -> results.Add(n, s)) ints strs
142+
results.Count |> should equal 3
143+
144+
results
145+
|> Seq.toList
146+
|> should equal [ (1, "a"); (2, "b"); (3, "c") ]
147+
}
148+
149+
module Map2EmptySeq =
150+
[<Fact>]
151+
let ``Null source is invalid for map2`` () =
152+
assertNullArg
153+
<| fun () -> TaskSeq.map2 (fun _ _ -> ()) null TaskSeq.empty<int>
154+
155+
assertNullArg
156+
<| fun () -> TaskSeq.map2 (fun _ _ -> ()) TaskSeq.empty<int> null
157+
158+
[<Fact>]
159+
let ``Null source is invalid for map2Async`` () =
160+
assertNullArg
161+
<| fun () -> TaskSeq.map2Async (fun _ _ -> Task.fromResult ()) null TaskSeq.empty<int>
162+
163+
assertNullArg
164+
<| fun () -> TaskSeq.map2Async (fun _ _ -> Task.fromResult ()) TaskSeq.empty<int> null
165+
166+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
167+
let ``TaskSeq-map2 returns empty on two empty sequences`` variant =
168+
TaskSeq.map2 (fun a b -> a + b) (Gen.getEmptyVariant variant) (Gen.getEmptyVariant variant)
169+
|> verifyEmpty
170+
171+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
172+
let ``TaskSeq-map2 returns empty when first sequence is empty`` variant =
173+
TaskSeq.map2 (fun a b -> a + b) (Gen.getEmptyVariant variant) (taskSeq { yield! [ 1..10 ] })
174+
|> verifyEmpty
175+
176+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
177+
let ``TaskSeq-map2 returns empty when second sequence is empty`` variant =
178+
TaskSeq.map2 (fun a b -> a + b) (taskSeq { yield! [ 1..10 ] }) (Gen.getEmptyVariant variant)
179+
|> verifyEmpty
180+
181+
[<Theory; ClassData(typeof<TestEmptyVariants>)>]
182+
let ``TaskSeq-map2Async returns empty on two empty sequences`` variant =
183+
TaskSeq.map2Async (fun a b -> Task.fromResult (a + b)) (Gen.getEmptyVariant variant) (Gen.getEmptyVariant variant)
184+
|> verifyEmpty
185+
186+
module Map2Immutable =
187+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
188+
let ``TaskSeq-map2 maps all paired elements in order`` variant = task {
189+
let one = Gen.getSeqImmutable variant
190+
let two = Gen.getSeqImmutable variant
191+
192+
let! result =
193+
TaskSeq.map2 (fun a b -> a + b) one two
194+
|> TaskSeq.toArrayAsync
195+
196+
result |> should haveLength 10
197+
198+
result
199+
|> should equal (Array.init 10 (fun i -> (i + 1) * 2))
200+
}
201+
202+
[<Theory; ClassData(typeof<TestImmTaskSeq>)>]
203+
let ``TaskSeq-map2Async maps all paired elements in order`` variant = task {
204+
let one = Gen.getSeqImmutable variant
205+
let two = Gen.getSeqImmutable variant
206+
207+
let! result =
208+
TaskSeq.map2Async (fun a b -> Task.fromResult (a + b)) one two
209+
|> TaskSeq.toArrayAsync
210+
211+
result |> should haveLength 10
212+
213+
result
214+
|> should equal (Array.init 10 (fun i -> (i + 1) * 2))
215+
}
216+
217+
[<Fact>]
218+
let ``TaskSeq-map2 truncates to shorter sequence when first is shorter`` () = task {
219+
let short = taskSeq { yield! [ 1..3 ] }
220+
let long = taskSeq { yield! [ 10..19 ] }
221+
222+
let! result =
223+
TaskSeq.map2 (fun a b -> a + b) short long
224+
|> TaskSeq.toArrayAsync
225+
226+
result |> should haveLength 3
227+
result |> should equal [| 11; 13; 15 |]
228+
}
229+
230+
[<Fact>]
231+
let ``TaskSeq-map2 truncates to shorter sequence when second is shorter`` () = task {
232+
let long = taskSeq { yield! [ 10..19 ] }
233+
let short = taskSeq { yield! [ 1..3 ] }
234+
235+
let! result =
236+
TaskSeq.map2 (fun a b -> a + b) long short
237+
|> TaskSeq.toArrayAsync
238+
239+
result |> should haveLength 3
240+
result |> should equal [| 11; 13; 15 |]
241+
}
242+
243+
[<Fact>]
244+
let ``TaskSeq-map2 can produce different types`` () = task {
245+
let ints = taskSeq { yield! [ 1; 2; 3 ] }
246+
let strs = taskSeq { yield! [ "a"; "b"; "c" ] }
247+
248+
let! result =
249+
TaskSeq.map2 (fun n s -> sprintf "%d%s" n s) ints strs
250+
|> TaskSeq.toArrayAsync
251+
252+
result |> should equal [| "1a"; "2b"; "3c" |]
253+
}
254+
255+
[<Fact>]
256+
let ``TaskSeq-map2 works with equal-length sequences`` () = task {
257+
let s1 = taskSeq { yield! [ 1..5 ] }
258+
let s2 = taskSeq { yield! [ 10..14 ] }
259+
260+
let! result =
261+
TaskSeq.map2 (fun a b -> a * b) s1 s2
262+
|> TaskSeq.toArrayAsync
263+
264+
result |> should haveLength 5
265+
result |> should equal [| 10; 22; 36; 52; 70 |]
266+
}
267+
268+
[<Fact>]
269+
let ``TaskSeq-map2Async can use async work in mapping`` () = task {
270+
let s1 = taskSeq { yield! [ 1..3 ] }
271+
let s2 = taskSeq { yield! [ 4..6 ] }
272+
273+
let! result =
274+
TaskSeq.map2Async
275+
(fun a b -> task {
276+
do! Task.Delay(0)
277+
return a + b
278+
})
279+
s1
280+
s2
281+
|> TaskSeq.toArrayAsync
282+
283+
result |> should equal [| 5; 7; 9 |]
284+
}
285+
286+
module Map2SideEffects =
287+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
288+
let ``TaskSeq-map2 works correctly with side-effect sequences`` variant = task {
289+
let one = Gen.getSeqWithSideEffect variant
290+
let two = Gen.getSeqWithSideEffect variant
291+
292+
let! result =
293+
TaskSeq.map2 (fun a b -> a + b) one two
294+
|> TaskSeq.toArrayAsync
295+
296+
result |> should haveLength 10
297+
298+
result
299+
|> Array.forall (fun x -> x % 2 = 0)
300+
|> should be True
301+
}
302+
303+
[<Theory; ClassData(typeof<TestSideEffectTaskSeq>)>]
304+
let ``TaskSeq-iter2 works correctly with side-effect sequences`` variant = task {
305+
let one = Gen.getSeqWithSideEffect variant
306+
let two = Gen.getSeqWithSideEffect variant
307+
let mutable count = 0
308+
do! TaskSeq.iter2 (fun _ _ -> count <- count + 1) one two
309+
count |> should equal 10
310+
}

src/FSharp.Control.TaskSeq/TaskSeq.fs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,10 +369,14 @@ type TaskSeq private () =
369369
static member iteri action source = Internal.iter (CountableAction action) source
370370
static member iterAsync action source = Internal.iter (AsyncSimpleAction action) source
371371
static member iteriAsync action source = Internal.iter (AsyncCountableAction action) source
372+
static member iter2 action source1 source2 = Internal.iter2 action source1 source2
373+
static member iter2Async action source1 source2 = Internal.iter2Async action source1 source2
372374
static member map (mapper: 'T -> 'U) source = Internal.map (SimpleAction mapper) source
373375
static member mapi (mapper: int -> 'T -> 'U) source = Internal.map (CountableAction mapper) source
374376
static member mapAsync mapper source = Internal.map (AsyncSimpleAction mapper) source
375377
static member mapiAsync mapper source = Internal.map (AsyncCountableAction mapper) source
378+
static member map2 (mapping: 'T -> 'U -> 'V) source1 source2 = Internal.map2 mapping source1 source2
379+
static member map2Async (mapping: 'T -> 'U -> #Task<'V>) source1 source2 = Internal.map2Async mapping source1 source2
376380
static member collect (binder: 'T -> #TaskSeq<'U>) source = Internal.collect binder source
377381
static member collectSeq (binder: 'T -> #seq<'U>) source = Internal.collectSeq binder source
378382
static member collectAsync (binder: 'T -> #Task<#TaskSeq<'U>>) source : TaskSeq<'U> = Internal.collectAsync binder source

0 commit comments

Comments
 (0)