From 62dea3d5f451bb16be41446047899077715c4eb8 Mon Sep 17 00:00:00 2001 From: Repo Assist Date: Sat, 7 Mar 2026 21:22:05 +0000 Subject: [PATCH] feat: add TaskSeq.reduce and TaskSeq.reduceAsync (ref #289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new combinators that fold a task sequence without requiring an explicit initial state — the first element is used instead. Raises ArgumentException on an empty sequence, matching Seq.reduce semantics. - TaskSeq.reduce : synchronous folder - TaskSeq.reduceAsync: asynchronous folder 75 new tests covering: null/empty guards, singleton sources, call-count verification (folder called n-1 times), string concatenation, and side-effecting sequences. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../FSharp.Control.TaskSeq.Test.fsproj | 1 + .../TaskSeq.Reduce.Tests.fs | 156 ++++++++++++++++++ src/FSharp.Control.TaskSeq/TaskSeq.fs | 2 + src/FSharp.Control.TaskSeq/TaskSeq.fsi | 32 ++++ src/FSharp.Control.TaskSeq/TaskSeqInternal.fs | 31 ++++ 5 files changed, 222 insertions(+) create mode 100644 src/FSharp.Control.TaskSeq.Test/TaskSeq.Reduce.Tests.fs 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 118a9417..e0e6df08 100644 --- a/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj +++ b/src/FSharp.Control.TaskSeq.Test/FSharp.Control.TaskSeq.Test.fsproj @@ -25,6 +25,7 @@ + diff --git a/src/FSharp.Control.TaskSeq.Test/TaskSeq.Reduce.Tests.fs b/src/FSharp.Control.TaskSeq.Test/TaskSeq.Reduce.Tests.fs new file mode 100644 index 00000000..11429a37 --- /dev/null +++ b/src/FSharp.Control.TaskSeq.Test/TaskSeq.Reduce.Tests.fs @@ -0,0 +1,156 @@ +module TaskSeq.Tests.Reduce + +open Xunit +open FsUnit.Xunit + +open FSharp.Control + +// +// TaskSeq.reduce +// TaskSeq.reduceAsync +// + +module EmptySeq = + [] + let ``Null source is invalid`` () = + assertNullArg + <| fun () -> TaskSeq.reduce (fun a _ -> a) null + + assertNullArg + <| fun () -> TaskSeq.reduceAsync (fun a _ -> Task.fromResult a) null + + [)>] + let ``TaskSeq-reduce raises on empty`` variant = + fun () -> + Gen.getEmptyVariant variant + |> TaskSeq.reduce (fun a b -> a + b) + |> Task.ignore + + |> should throwAsyncExact typeof + + [)>] + let ``TaskSeq-reduceAsync raises on empty`` variant = + fun () -> + Gen.getEmptyVariant variant + |> TaskSeq.reduceAsync (fun a b -> task { return a + b }) + |> Task.ignore + + |> should throwAsyncExact typeof + +module Immutable = + [)>] + let ``TaskSeq-reduce folds from first element`` variant = task { + // items are 1..10; sum = 55 + let! sum = + Gen.getSeqImmutable variant + |> TaskSeq.reduce (fun acc item -> acc + item) + + sum |> should equal 55 + } + + [)>] + let ``TaskSeq-reduceAsync folds from first element`` variant = task { + let! sum = + Gen.getSeqImmutable variant + |> TaskSeq.reduceAsync (fun acc item -> task { return acc + item }) + + sum |> should equal 55 + } + + [] + let ``TaskSeq-reduce returns single element without calling folder`` () = task { + let mutable called = false + + let! result = + TaskSeq.singleton 42 + |> TaskSeq.reduce (fun _ _ -> + called <- true + failwith "should not be called") + + result |> should equal 42 + called |> should equal false + } + + [] + let ``TaskSeq-reduceAsync returns single element without calling folder`` () = task { + let mutable called = false + + let! result = + TaskSeq.singleton 42 + |> TaskSeq.reduceAsync (fun _ _ -> task { + called <- true + return failwith "should not be called" + }) + + result |> should equal 42 + called |> should equal false + } + + [)>] + let ``TaskSeq-reduce uses first element as initial accumulator`` variant = task { + // reduce must use element[0] as initial state; for 1..10 summing gives 55 + // if it used 0 as initial, sum would also be 55 — but we verify the folder is called n-1 times + let mutable callCount = 0 + + let! sum = + Gen.getSeqImmutable variant + |> TaskSeq.reduce (fun acc item -> + callCount <- callCount + 1 + acc + item) + + sum |> should equal 55 + callCount |> should equal 9 // 10 elements => 9 reduce calls + } + + [)>] + let ``TaskSeq-reduce can concatenate strings`` variant = task { + // items 1..10 as chars: ABCDEFGHIJ + let! letters = + Gen.getSeqImmutable variant + |> TaskSeq.map (fun i -> string (char (i + 64))) + |> TaskSeq.reduce (fun acc item -> acc + item) + + letters |> should equal "ABCDEFGHIJ" + } + + [)>] + let ``TaskSeq-reduceAsync can concatenate strings`` variant = task { + let! letters = + Gen.getSeqImmutable variant + |> TaskSeq.map (fun i -> string (char (i + 64))) + |> TaskSeq.reduceAsync (fun acc item -> task { return acc + item }) + + letters |> should equal "ABCDEFGHIJ" + } + +module SideEffects = + [)>] + let ``TaskSeq-reduce folds correctly with side-effecting sequences`` variant = task { + let ts = Gen.getSeqWithSideEffect variant + + let! sum = ts |> TaskSeq.reduce (fun acc item -> acc + item) + + sum |> should equal 55 + + // second enumeration produces next 10 elements: 11..20, sum = 155 + let! sum2 = ts |> TaskSeq.reduce (fun acc item -> acc + item) + + sum2 |> should equal 155 + } + + [)>] + let ``TaskSeq-reduceAsync folds correctly with side-effecting sequences`` variant = task { + let ts = Gen.getSeqWithSideEffect variant + + let! sum = + ts + |> TaskSeq.reduceAsync (fun acc item -> task { return acc + item }) + + sum |> should equal 55 + + let! sum2 = + ts + |> TaskSeq.reduceAsync (fun acc item -> task { return acc + item }) + + sum2 |> should equal 155 + } diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fs b/src/FSharp.Control.TaskSeq/TaskSeq.fs index 32c159fb..4406b713 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fs @@ -406,3 +406,5 @@ type TaskSeq private () = static member zip source1 source2 = Internal.zip source1 source2 static member fold folder state source = Internal.fold (FolderAction folder) state source static member foldAsync folder state source = Internal.fold (AsyncFolderAction folder) state source + static member reduce folder source = Internal.reduce (FolderAction folder) source + static member reduceAsync folder source = Internal.reduce (AsyncFolderAction folder) source diff --git a/src/FSharp.Control.TaskSeq/TaskSeq.fsi b/src/FSharp.Control.TaskSeq/TaskSeq.fsi index 76e90c2f..f552d83e 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeq.fsi +++ b/src/FSharp.Control.TaskSeq/TaskSeq.fsi @@ -1350,6 +1350,38 @@ type TaskSeq = static member foldAsync: folder: ('State -> 'T -> #Task<'State>) -> state: 'State -> source: TaskSeq<'T> -> Task<'State> + /// + /// Applies the function to each element of the task sequence, threading an accumulator + /// argument through the computation. The first element is used as the initial state. If the input function is + /// and the elements are , then computes + /// . Raises when the + /// sequence is empty. + /// If the accumulator function is asynchronous, consider using . + /// + /// + /// A function that updates the state with each element from the sequence. + /// The input sequence. + /// The final state value after applying the reduction function to all elements. + /// Thrown when the input task sequence is null. + /// Thrown when the input task sequence is empty. + static member reduce: folder: ('T -> 'T -> 'T) -> source: TaskSeq<'T> -> Task<'T> + + /// + /// Applies the asynchronous function to each element of the task sequence, threading + /// an accumulator argument through the computation. The first element is used as the initial state. If the input + /// function is and the elements are , then computes + /// . Raises when the + /// sequence is empty. + /// If the accumulator function is synchronous, consider using . + /// + /// + /// A function that updates the state with each element from the sequence. + /// The input sequence. + /// The final state value after applying the reduction function to all elements. + /// Thrown when the input task sequence is null. + /// Thrown when the input task sequence is empty. + static member reduceAsync: folder: ('T -> 'T -> #Task<'T>) -> source: TaskSeq<'T> -> Task<'T> + /// /// Return a new task sequence with a new item inserted before the given index. /// diff --git a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs index a8f2a4bf..c2c16373 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs @@ -371,6 +371,37 @@ module internal TaskSeqInternal = return result } + let reduce folder (source: TaskSeq<_>) = + checkNonNull (nameof source) source + + task { + use e = source.GetAsyncEnumerator CancellationToken.None + let! hasFirst = e.MoveNextAsync() + + if not hasFirst then + raiseEmptySeq () + + let mutable result = e.Current + let! step = e.MoveNextAsync() + let mutable go = step + + match folder with + | FolderAction folder -> + while go do + result <- folder result e.Current + let! step = e.MoveNextAsync() + go <- step + + | AsyncFolderAction folder -> + while go do + let! tempResult = folder result e.Current + result <- tempResult + let! step = e.MoveNextAsync() + go <- step + + return result + } + let toResizeArrayAsync source = checkNonNull (nameof source) source