From e60fcf411d9f9b89436dd1c0a9cd142a6246c8ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 9 Mar 2026 13:36:08 +0000 Subject: [PATCH 1/2] 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> --- README.md | 6 +- release-notes.txt | 2 + .../FSharp.Control.TaskSeq.Test.fsproj | 1 + .../TaskSeq.Map2Iter2.Tests.fs | 310 ++++++++++++++++++ src/FSharp.Control.TaskSeq/TaskSeq.fs | 4 + src/FSharp.Control.TaskSeq/TaskSeq.fsi | 64 ++++ src/FSharp.Control.TaskSeq/TaskSeqInternal.fs | 77 +++++ 7 files changed, 461 insertions(+), 3 deletions(-) create mode 100644 src/FSharp.Control.TaskSeq.Test/TaskSeq.Map2Iter2.Tests.fs diff --git a/README.md b/README.md index dcc1cbf..de10b90 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,7 @@ The `TaskSeq` project already has a wide array of functions and functionalities, - [x] `chunkBySize` / `windowed` (see [#258]) - [ ] `compareWith` - [ ] `distinct` - - [ ] `exists2` / `map2` / `fold2` / `iter2` and related '2'-functions + - [ ] `exists2` / `fold2` / `forall2` and remaining '2'-functions (`map2`, `iter2` ✅ now implemented) - [ ] `mapFold` - [x] `pairwise` (see [#293]) - [ ] `allpairs` / `permute` / `distinct` / `distinctBy` @@ -305,14 +305,14 @@ This is what has been implemented so far, is planned or skipped: | ✅ [#23][] | `isEmpty` | `isEmpty` | | | | ✅ [#23][] | `item` | `item` | | | | ✅ [#2][] | `iter` | `iter` | `iterAsync` | | -| | `iter2` | `iter2` | `iter2Async` | | +| ✅ | `iter2` | `iter2` | `iter2Async` | | | ✅ [#2][] | `iteri` | `iteri` | `iteriAsync` | | | | `iteri2` | `iteri2` | `iteri2Async` | | | ✅ [#23][] | `last` | `last` | | | | ✅ [#53][] | `length` | `length` | | | | ✅ [#53][] | | `lengthBy` | `lengthByAsync` | | | ✅ [#2][] | `map` | `map` | `mapAsync` | | -| | `map2` | `map2` | `map2Async` | | +| ✅ | `map2` | `map2` | `map2Async` | | | | `map3` | `map3` | `map3Async` | | | | `mapFold` | `mapFold` | `mapFoldAsync` | | | 🚫 | `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.") | diff --git a/release-notes.txt b/release-notes.txt index 5c0b97e..d32d109 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -2,6 +2,8 @@ Release notes: 0.6.0 + - adds TaskSeq.map2 and TaskSeq.map2Async + - adds TaskSeq.iter2 and TaskSeq.iter2Async - adds TaskSeq.scan and TaskSeq.scanAsync, #289 - adds TaskSeq.pairwise, #289 - adds TaskSeq.groupBy and TaskSeq.groupByAsync, #289 diff --git a/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj b/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj index 89f8d94..d46d8d7 100644 --- a/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj +++ b/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj @@ -40,6 +40,7 @@ + diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.Map2Iter2.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.Map2Iter2.Tests.fs new file mode 100644 index 0000000..5b4e889 --- /dev/null +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.Map2Iter2.Tests.fs @@ -0,0 +1,310 @@ +module TaskSeq.Tests.Map2Iter2 + +open System.Threading.Tasks +open Xunit +open FsUnit.Xunit + +open FSharp.Control + +// +// TaskSeq.iter2 +// TaskSeq.iter2Async +// TaskSeq.map2 +// TaskSeq.map2Async +// + +module Iter2EmptySeq = + [] + let ``Null source is invalid for iter2`` () = + assertNullArg + <| fun () -> TaskSeq.iter2 (fun _ _ -> ()) null TaskSeq.empty + + assertNullArg + <| fun () -> TaskSeq.iter2 (fun _ _ -> ()) TaskSeq.empty null + + [] + let ``Null source is invalid for iter2Async`` () = + assertNullArg + <| fun () -> TaskSeq.iter2Async (fun _ _ -> Task.fromResult ()) null TaskSeq.empty + + assertNullArg + <| fun () -> TaskSeq.iter2Async (fun _ _ -> Task.fromResult ()) TaskSeq.empty null + + [)>] + let ``TaskSeq-iter2 does nothing on two empty sequences`` variant = task { + let tq = Gen.getEmptyVariant variant + let mutable sum = 0 + do! TaskSeq.iter2 (fun a b -> sum <- sum + a + b) tq tq + sum |> should equal 0 + } + + [)>] + let ``TaskSeq-iter2 does nothing when first sequence is empty`` variant = task { + let tq = Gen.getEmptyVariant variant + let mutable sum = 0 + do! TaskSeq.iter2 (fun a b -> sum <- sum + a + b) tq (taskSeq { yield! [ 1..10 ] }) + sum |> should equal 0 + } + + [)>] + let ``TaskSeq-iter2 does nothing when second sequence is empty`` variant = task { + let tq = Gen.getEmptyVariant variant + let mutable sum = 0 + do! TaskSeq.iter2 (fun a b -> sum <- sum + a + b) (taskSeq { yield! [ 1..10 ] }) tq + sum |> should equal 0 + } + + [)>] + let ``TaskSeq-iter2Async does nothing on two empty sequences`` variant = task { + let tq = Gen.getEmptyVariant variant + let mutable sum = 0 + + do! + TaskSeq.iter2Async + (fun a b -> + sum <- sum + a + b + Task.fromResult ()) + tq + tq + + sum |> should equal 0 + } + +module Iter2Immutable = + [)>] + let ``TaskSeq-iter2 visits all paired elements in order`` variant = task { + let one = Gen.getSeqImmutable variant + let two = Gen.getSeqImmutable variant + let results = System.Collections.Generic.List() + do! TaskSeq.iter2 (fun a b -> results.Add(a, b)) one two + results.Count |> should equal 10 + + results + |> Seq.forall (fun (a, b) -> a = b) + |> should be True + + results + |> Seq.map fst + |> Seq.toArray + |> should equal [| 1..10 |] + } + + [)>] + let ``TaskSeq-iter2Async visits all paired elements in order`` variant = task { + let one = Gen.getSeqImmutable variant + let two = Gen.getSeqImmutable variant + let results = System.Collections.Generic.List() + + do! + TaskSeq.iter2Async + (fun a b -> + results.Add(a, b) + Task.fromResult ()) + one + two + + results.Count |> should equal 10 + + results + |> Seq.forall (fun (a, b) -> a = b) + |> should be True + + results + |> Seq.map fst + |> Seq.toArray + |> should equal [| 1..10 |] + } + + [] + let ``TaskSeq-iter2 truncates to shorter sequence when first is shorter`` () = task { + let short = taskSeq { yield! [ 1..3 ] } + let long = taskSeq { yield! [ 1..10 ] } + let mutable count = 0 + do! TaskSeq.iter2 (fun _ _ -> count <- count + 1) short long + count |> should equal 3 + } + + [] + let ``TaskSeq-iter2 truncates to shorter sequence when second is shorter`` () = task { + let long = taskSeq { yield! [ 1..10 ] } + let short = taskSeq { yield! [ 1..3 ] } + let mutable count = 0 + do! TaskSeq.iter2 (fun _ _ -> count <- count + 1) long short + count |> should equal 3 + } + + [] + let ``TaskSeq-iter2 can combine different element types`` () = task { + let ints = taskSeq { yield! [ 1; 2; 3 ] } + let strs = taskSeq { yield! [ "a"; "b"; "c" ] } + let results = System.Collections.Generic.List() + do! TaskSeq.iter2 (fun n s -> results.Add(n, s)) ints strs + results.Count |> should equal 3 + + results + |> Seq.toList + |> should equal [ (1, "a"); (2, "b"); (3, "c") ] + } + +module Map2EmptySeq = + [] + let ``Null source is invalid for map2`` () = + assertNullArg + <| fun () -> TaskSeq.map2 (fun _ _ -> ()) null TaskSeq.empty + + assertNullArg + <| fun () -> TaskSeq.map2 (fun _ _ -> ()) TaskSeq.empty null + + [] + let ``Null source is invalid for map2Async`` () = + assertNullArg + <| fun () -> TaskSeq.map2Async (fun _ _ -> Task.fromResult ()) null TaskSeq.empty + + assertNullArg + <| fun () -> TaskSeq.map2Async (fun _ _ -> Task.fromResult ()) TaskSeq.empty null + + [)>] + let ``TaskSeq-map2 returns empty on two empty sequences`` variant = + TaskSeq.map2 (fun a b -> a + b) (Gen.getEmptyVariant variant) (Gen.getEmptyVariant variant) + |> verifyEmpty + + [)>] + let ``TaskSeq-map2 returns empty when first sequence is empty`` variant = + TaskSeq.map2 (fun a b -> a + b) (Gen.getEmptyVariant variant) (taskSeq { yield! [ 1..10 ] }) + |> verifyEmpty + + [)>] + let ``TaskSeq-map2 returns empty when second sequence is empty`` variant = + TaskSeq.map2 (fun a b -> a + b) (taskSeq { yield! [ 1..10 ] }) (Gen.getEmptyVariant variant) + |> verifyEmpty + + [)>] + let ``TaskSeq-map2Async returns empty on two empty sequences`` variant = + TaskSeq.map2Async (fun a b -> Task.fromResult (a + b)) (Gen.getEmptyVariant variant) (Gen.getEmptyVariant variant) + |> verifyEmpty + +module Map2Immutable = + [)>] + let ``TaskSeq-map2 maps all paired elements in order`` variant = task { + let one = Gen.getSeqImmutable variant + let two = Gen.getSeqImmutable variant + + let! result = + TaskSeq.map2 (fun a b -> a + b) one two + |> TaskSeq.toArrayAsync + + result |> should haveLength 10 + + result + |> should equal (Array.init 10 (fun i -> (i + 1) * 2)) + } + + [)>] + let ``TaskSeq-map2Async maps all paired elements in order`` variant = task { + let one = Gen.getSeqImmutable variant + let two = Gen.getSeqImmutable variant + + let! result = + TaskSeq.map2Async (fun a b -> Task.fromResult (a + b)) one two + |> TaskSeq.toArrayAsync + + result |> should haveLength 10 + + result + |> should equal (Array.init 10 (fun i -> (i + 1) * 2)) + } + + [] + let ``TaskSeq-map2 truncates to shorter sequence when first is shorter`` () = task { + let short = taskSeq { yield! [ 1..3 ] } + let long = taskSeq { yield! [ 10..19 ] } + + let! result = + TaskSeq.map2 (fun a b -> a + b) short long + |> TaskSeq.toArrayAsync + + result |> should haveLength 3 + result |> should equal [| 11; 13; 15 |] + } + + [] + let ``TaskSeq-map2 truncates to shorter sequence when second is shorter`` () = task { + let long = taskSeq { yield! [ 10..19 ] } + let short = taskSeq { yield! [ 1..3 ] } + + let! result = + TaskSeq.map2 (fun a b -> a + b) long short + |> TaskSeq.toArrayAsync + + result |> should haveLength 3 + result |> should equal [| 11; 13; 15 |] + } + + [] + let ``TaskSeq-map2 can produce different types`` () = task { + let ints = taskSeq { yield! [ 1; 2; 3 ] } + let strs = taskSeq { yield! [ "a"; "b"; "c" ] } + + let! result = + TaskSeq.map2 (fun n s -> sprintf "%d%s" n s) ints strs + |> TaskSeq.toArrayAsync + + result |> should equal [| "1a"; "2b"; "3c" |] + } + + [] + let ``TaskSeq-map2 works with equal-length sequences`` () = task { + let s1 = taskSeq { yield! [ 1..5 ] } + let s2 = taskSeq { yield! [ 10..14 ] } + + let! result = + TaskSeq.map2 (fun a b -> a * b) s1 s2 + |> TaskSeq.toArrayAsync + + result |> should haveLength 5 + result |> should equal [| 10; 22; 36; 52; 70 |] + } + + [] + let ``TaskSeq-map2Async can use async work in mapping`` () = task { + let s1 = taskSeq { yield! [ 1..3 ] } + let s2 = taskSeq { yield! [ 4..6 ] } + + let! result = + TaskSeq.map2Async + (fun a b -> task { + do! Task.Delay(0) + return a + b + }) + s1 + s2 + |> TaskSeq.toArrayAsync + + result |> should equal [| 5; 7; 9 |] + } + +module Map2SideEffects = + [)>] + let ``TaskSeq-map2 works correctly with side-effect sequences`` variant = task { + let one = Gen.getSeqWithSideEffect variant + let two = Gen.getSeqWithSideEffect variant + + let! result = + TaskSeq.map2 (fun a b -> a + b) one two + |> TaskSeq.toArrayAsync + + result |> should haveLength 10 + + result + |> Array.forall (fun x -> x % 2 = 0) + |> should be True + } + + [)>] + let ``TaskSeq-iter2 works correctly with side-effect sequences`` variant = task { + let one = Gen.getSeqWithSideEffect variant + let two = Gen.getSeqWithSideEffect variant + let mutable count = 0 + do! TaskSeq.iter2 (fun _ _ -> count <- count + 1) one two + count |> should equal 10 + } diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fs b/src/FSharp.Control.TaskSeq/TaskSeq.fs index aced3a5..6a435d7 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fs @@ -369,10 +369,14 @@ type TaskSeq private () = static member iteri action source = Internal.iter (CountableAction action) source static member iterAsync action source = Internal.iter (AsyncSimpleAction action) source static member iteriAsync action source = Internal.iter (AsyncCountableAction action) source + static member iter2 action source1 source2 = Internal.iter2 action source1 source2 + static member iter2Async action source1 source2 = Internal.iter2Async action source1 source2 static member map (mapper: 'T -> 'U) source = Internal.map (SimpleAction mapper) source static member mapi (mapper: int -> 'T -> 'U) source = Internal.map (CountableAction mapper) source static member mapAsync mapper source = Internal.map (AsyncSimpleAction mapper) source static member mapiAsync mapper source = Internal.map (AsyncCountableAction mapper) source + static member map2 (mapping: 'T -> 'U -> 'V) source1 source2 = Internal.map2 mapping source1 source2 + static member map2Async (mapping: 'T -> 'U -> #Task<'V>) source1 source2 = Internal.map2Async mapping source1 source2 static member collect (binder: 'T -> #TaskSeq<'U>) source = Internal.collect binder source static member collectSeq (binder: 'T -> #seq<'U>) source = Internal.collectSeq binder source static member collectAsync (binder: 'T -> #Task<#TaskSeq<'U>>) source : TaskSeq<'U> = Internal.collectAsync binder source diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fsi b/src/FSharp.Control.TaskSeq/TaskSeq.fsi index fcf45cf..abfd9b9 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fsi +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fsi @@ -679,6 +679,37 @@ type TaskSeq = /// Thrown when the input sequence is null. static member iteriAsync: action: (int -> 'T -> #Task) -> source: TaskSeq<'T> -> Task + /// + /// Iterates over the two input task sequences simultaneously, applying the function + /// to each pair of items. When the sequences have different lengths, the shorter sequence determines how many + /// items are iterated; excess items in the longer sequence are ignored. + /// This function is non-blocking, but will exhaust both input sequences as soon as the task is evaluated. + /// If is asynchronous, consider using . + /// + /// + /// A function to apply to each pair of elements from the two input task sequences. + /// The first input task sequence. + /// The second input task sequence. + /// A . + /// Thrown when either input task sequence is null. + static member iter2: action: ('T -> 'U -> unit) -> source1: TaskSeq<'T> -> source2: TaskSeq<'U> -> Task + + /// + /// Iterates over the two input task sequences simultaneously, applying the asynchronous + /// function to each pair of items. When the sequences have different lengths, the shorter sequence determines how many + /// items are iterated; excess items in the longer sequence are ignored. + /// This function is non-blocking, but will exhaust both input sequences as soon as the task is evaluated. + /// If is synchronous, consider using . + /// + /// + /// An asynchronous function to apply to each pair of elements from the two input task sequences. + /// The first input task sequence. + /// The second input task sequence. + /// A . + /// Thrown when either input task sequence is null. + static member iter2Async: + action: ('T -> 'U -> #Task) -> source1: TaskSeq<'T> -> source2: TaskSeq<'U> -> Task + /// /// Builds a new task sequence whose elements are the corresponding elements of the input task /// sequence paired with the integer index (from 0) of each element. @@ -749,6 +780,39 @@ type TaskSeq = /// Thrown when the input task sequence is null. static member mapiAsync: mapper: (int -> 'T -> #Task<'U>) -> source: TaskSeq<'T> -> TaskSeq<'U> + /// + /// Builds a new task sequence whose elements are the results of applying the + /// function to corresponding pairs of elements from the two input task sequences. + /// When the sequences have different lengths, the shorter sequence determines how many elements are in + /// the result; excess elements in the longer sequence are ignored. + /// Does not evaluate the input sequences until requested. + /// If is asynchronous, consider using . + /// + /// + /// A function to transform pairs of items from the two input task sequences. + /// The first input task sequence. + /// The second input task sequence. + /// The resulting task sequence of mapped values. + /// Thrown when either input task sequence is null. + static member map2: mapping: ('T -> 'U -> 'V) -> source1: TaskSeq<'T> -> source2: TaskSeq<'U> -> TaskSeq<'V> + + /// + /// Builds a new task sequence whose elements are the results of applying the asynchronous + /// function to corresponding pairs of elements from the two input task sequences. + /// When the sequences have different lengths, the shorter sequence determines how many elements are in + /// the result; excess elements in the longer sequence are ignored. + /// Does not evaluate the input sequences until requested. + /// If is synchronous, consider using . + /// + /// + /// An asynchronous function to transform pairs of items from the two input task sequences. + /// The first input task sequence. + /// The second input task sequence. + /// The resulting task sequence of mapped values. + /// Thrown when either input task sequence is null. + static member map2Async: + mapping: ('T -> 'U -> #Task<'V>) -> source1: TaskSeq<'T> -> source2: TaskSeq<'U> -> TaskSeq<'V> + /// /// Builds a new task sequence whose elements are the results of applying the /// function to each of the elements of the input task sequence in , and concatenating the diff --git a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs index 26b02a6..01aecac 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs @@ -388,6 +388,44 @@ module internal TaskSeqInternal = go <- step } + let iter2 (action: 'T -> 'U -> unit) (source1: TaskSeq<'T>) (source2: TaskSeq<'U>) = + checkNonNull (nameof source1) source1 + checkNonNull (nameof source2) source2 + + task { + use e1 = source1.GetAsyncEnumerator CancellationToken.None + use e2 = source2.GetAsyncEnumerator CancellationToken.None + let mutable go = true + let! step1 = e1.MoveNextAsync() + let! step2 = e2.MoveNextAsync() + go <- step1 && step2 + + while go do + action e1.Current e2.Current + let! step1 = e1.MoveNextAsync() + let! step2 = e2.MoveNextAsync() + go <- step1 && step2 + } + + let iter2Async (action: 'T -> 'U -> #Task) (source1: TaskSeq<'T>) (source2: TaskSeq<'U>) = + checkNonNull (nameof source1) source1 + checkNonNull (nameof source2) source2 + + task { + use e1 = source1.GetAsyncEnumerator CancellationToken.None + use e2 = source2.GetAsyncEnumerator CancellationToken.None + let mutable go = true + let! step1 = e1.MoveNextAsync() + let! step2 = e2.MoveNextAsync() + go <- step1 && step2 + + while go do + do! action e1.Current e2.Current + let! step1 = e1.MoveNextAsync() + let! step2 = e2.MoveNextAsync() + go <- step1 && step2 + } + let fold folder initial (source: TaskSeq<_>) = checkNonNull (nameof source) source @@ -543,6 +581,45 @@ module internal TaskSeqInternal = yield result } + let map2 (mapping: 'T -> 'U -> 'V) (source1: TaskSeq<'T>) (source2: TaskSeq<'U>) : TaskSeq<'V> = + checkNonNull (nameof source1) source1 + checkNonNull (nameof source2) source2 + + taskSeq { + use e1 = source1.GetAsyncEnumerator CancellationToken.None + use e2 = source2.GetAsyncEnumerator CancellationToken.None + let mutable go = true + let! step1 = e1.MoveNextAsync() + let! step2 = e2.MoveNextAsync() + go <- step1 && step2 + + while go do + yield mapping e1.Current e2.Current + let! step1 = e1.MoveNextAsync() + let! step2 = e2.MoveNextAsync() + go <- step1 && step2 + } + + let map2Async (mapping: 'T -> 'U -> #Task<'V>) (source1: TaskSeq<'T>) (source2: TaskSeq<'U>) : TaskSeq<'V> = + checkNonNull (nameof source1) source1 + checkNonNull (nameof source2) source2 + + taskSeq { + use e1 = source1.GetAsyncEnumerator CancellationToken.None + use e2 = source2.GetAsyncEnumerator CancellationToken.None + let mutable go = true + let! step1 = e1.MoveNextAsync() + let! step2 = e2.MoveNextAsync() + go <- step1 && step2 + + while go do + let! result = mapping e1.Current e2.Current + yield result + let! step1 = e1.MoveNextAsync() + let! step2 = e2.MoveNextAsync() + go <- step1 && step2 + } + let zip (source1: TaskSeq<_>) (source2: TaskSeq<_>) = checkNonNull (nameof source1) source1 checkNonNull (nameof source2) source2 From 400e2376bdc3650483d23796ae87f2177c8095f1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 9 Mar 2026 13:42:47 +0000 Subject: [PATCH 2/2] ci: trigger checks