dotnet / dotnet/runtime

[API Proposal]: MemoryExtensions LINQ-parity terminal operators

Open
#130,573 6 comments 3 reactions 0 assignees View on GitHub
api-suggestion area-System.Memory
Dominant language
C#
Stars
18.3k
Forks
5.6k
PR merge metrics
PR metrics pending

Description

## Background and motivation

[#127083](https://github.com/dotnet/runtime/issues/127083) exposed `MemoryExtensions.Min`/`Max`
so that some functionality available to `IEnumerable` via LINQ became available on spans without
requiring a heap allocation. During API review, it was called out that we should
"follow up to see what other `Enumerable` members should be copied over."

This is that follow-up. It proposes the set of LINQ-semantic **terminal** operators that map cleanly
onto `ReadOnlySpan` — i.e. operators that *reduce* a sequence to a scalar/`bool` or *fill a
caller-provided destination* — while explicitly excluding the lazy, sequence-*producing* operators
that cannot be represented without allocation or deferral.

### Design principles

A `ReadOnlySpan` is eager and non-allocating, and cannot be used as a generic type argument or as
`IEnumerable`. This shapes what belongs here:

* **In scope — terminal reductions:** `Any`, `All`, `Count(predicate)`, `MinBy`/`MaxBy`, `Aggregate`,
and the numeric reductions `Sum`/`Average` (see the availability rationale below).
* **In scope — destination-filling operators** (the caller sizes and owns the output span, exactly
like `CopyTo`/`TryFormat`/`TensorPrimitives`):
- *Fixed output length, known from the inputs* — `Select` (== source length) and `Zip`
(== min of input lengths). These are true 1-to-1 (or 1-to-1-of-the-shorter) `CopyTo`-style fills.
- *Bounded output length, returned as a written count* — `Where` and `Distinct`/`DistinctBy`
(output ≤ source length; the method returns how many elements were written).
* **Out of scope:** the remaining lazy producers (set/join/group operators, the `ToXxx` collectors,
generators, `SelectMany`/`Chunk`). These inherently allocate, defer, or produce jagged/variable
shapes with no flat-destination representation, and are out of scope for `MemoryExtensions`.

We must also position this against two existing surfaces:

* **`TensorPrimitives`** provides *arithmetic* numeric reductions
(`Sum`, `Average`, `Max`/`Min`/`MaxMagnitude`/…, `Product`) over `ReadOnlySpan where T : INumber`,
following arithmetic (IEEE) rules. It ships **out of band** in the `System.Numerics.Tensors` NuGet
package, so it is not available without an explicit package reference.
* **`MemoryExtensions`** already provides `Min`/`Max` (LINQ / `IComparer` semantics), `Contains`,
`Count` (of a value or subsequence), `Sort`, `Reverse`, `SequenceEqual`, `ToArray`, and the
`IndexOf*` family. `Span` instance methods and `Random.Shuffle(Span)` cover
`Fill`/`Clear`/`CopyTo`/`Shuffle`.

There are **two** independent reasons a LINQ operator belongs here even though a numeric equivalent may
exist elsewhere. Neither makes this a redundant copy — in both cases it's the same "not strictly
duplicative" logic that already justifies `Enumerable` existing alongside `INumber`:

**1. Different semantics.** Operators whose result **depends on equality/ordering semantics** must
follow the general-collection comparer/equality rules, not the arithmetic ones:

* Ordering operators (`Min`/`Max`/`MinBy`/`MaxBy`/`IndexOfMin`/`IndexOfMax`) follow
`IComparable`/`Comparer.Default` (the `Compare(x, y)` rules suitable for sorting, hashsets,
etc.), **not** the arithmetic `<`/`>`/`INumber.Max` rules that `TensorPrimitives` uses. These
differ for `NaN`, signed zero, and custom comparers.
* Equality operators (`Distinct`/`DistinctBy`) follow `IEquatable`/`EqualityComparer.Default`,
**not** raw `==`.

**2. Different availability (in-box vs out-of-band).** `Sum` and `Average` are *unambiguous*
arithmetically, so there is no semantic variant to mirror — but they still belong here because
`TensorPrimitives` ships **out of band** in the `System.Numerics.Tensors` NuGet package and is not
available without an explicit package reference. `Enumerable.Sum`/`Average` are in-box and ubiquitous;
the span equivalents are common enough that they should likewise be available in-box, without forcing
a dependency on `System.Numerics.Tensors` for the everyday case. This is exactly the sense in which
they are "not strictly duplicative" — they make an existing, common capability reachable by default on
the span surface.

The remaining predicate/projection convenience operators (`Any`/`All`/`Count(predicate)`/`Aggregate`/
`Select`/`Zip`/`Where`) don't hinge on comparison semantics or availability, but are genuine
terminal-operator gaps on the span surface and are included for completeness.

Everything below is positioned as a LINQ operator that neither `TensorPrimitives` (in-box) nor the
existing `MemoryExtensions` surface provides.

## API Proposal

```csharp
namespace System;

public static partial class MemoryExtensions
{
// ---- Tier 1: allocation-free terminal reductions ----

// Any / All (predicate forms). Parameterless Any() is intentionally omitted: it is just !span.IsEmpty.
public static bool Any(this ReadOnlySpan span, Func predicate);
public static bool All(this ReadOnlySpan span, Func predicate);

// Count with a predicate. Complements the existing Count(value) / Count(subsequence) overloads.
public static int Count(this ReadOnlySpan span, Func predicate);

// MinBy / MaxBy. Natural completion of the Min/Max work from #127083.
// Empty behavior matches Enumerable.MinBy/MaxBy: default(T) is null -> returns null (or default);
// otherwise throws InvalidOperationException.
public static TSource? MinBy(this ReadOnlySpan span, Func keySelector);
public static TSource? MinBy(this ReadOnlySpan span, Func keySelector, IComparer? comparer);
public static TSource? MaxBy(this ReadOnlySpan span, Func keySelector);
public static TSource? MaxBy(this ReadOnlySpan span, Func keySelector, IComparer? comparer);

// IndexOfMin / IndexOfMax. Not a direct LINQ member, but a strictly-better span primitive:
// returns -1 on an empty span (no throw-vs-null split), something Enumerable cannot express.
// MinBy/MaxBy and Min/Max can be layered on top of these.
public static int IndexOfMin(this ReadOnlySpan span, IComparer? comparer = null);
public static int IndexOfMax(this ReadOnlySpan span, IComparer? comparer = null);

// Aggregate. The general fold that every reduction above specializes.
public static TSource Aggregate(this ReadOnlySpan span, Func func);
public static TAccumulate Aggregate(this ReadOnlySpan span, TAccumulate seed, Func func);
public static TResult Aggregate(this ReadOnlySpan span, TAccumulate seed, Func func, Func resultSelector);

// Sum / Average. Included for in-box availability (TensorPrimitives is out-of-band); these are the
// unambiguous arithmetic reductions. Generic over INumber rather than LINQ's fixed int/long/
// float/double/decimal overloads. See open questions on constraint, overflow, and Average's return.
public static T Sum(this ReadOnlySpan span) where T : INumber;
public static TResult Sum(this ReadOnlySpan span, Func selector) where TResult : INumber;
public static T Average(this ReadOnlySpan span) where T : INumber;
public static TResult Average(this ReadOnlySpan span, Func selector) where TResult : INumber;

// ---- Tier 2: destination-filling operators (caller sizes/owns the output span) ----

// Fixed output length, known from the inputs (CopyTo-style). Throws ArgumentException if
// destination is too short. Index overloads mirror Enumerable.Select's (element, index) form.
public static void Select(this ReadOnlySpan source, Span destination, Func selector);
public static void Select(this ReadOnlySpan source, Span destination, Func selector);

// Zip: output length == min(first.Length, second.Length); destination must be at least that long.
public static int Zip(this ReadOnlySpan first, ReadOnlySpan second, Span destination, Func selector);

// Bounded output length, returned as the number of items written (first-seen order preserved).
public static int Where(this ReadOnlySpan source, Span destination, Func predicate);
public static int Where(this ReadOnlySpan source, Span destination, Func predicate);

// Distinct / DistinctBy into a caller-provided destination, returning the number of items written.
// Preserves first-seen order, matching Enumerable.Distinct.
public static int Distinct(this ReadOnlySpan source, Span destination, IEqualityComparer? comparer = null);
public static int DistinctBy(this ReadOnlySpan source, Span destination, Func keySelector, IEqualityComparer? comparer = null);
}
```

### Open questions for review

1. **`Count(predicate)` naming.** The existing `Count(span, T value)` / `Count(span, ReadOnlySpan value)`
overloads count *occurrences of a value/subsequence*. A `Func` overload reads naturally and
is unambiguous at the call site, but review should confirm we're comfortable with the two meanings
of `Count` coexisting.
2. **`Sum`/`Average` shape.** LINQ has fixed non-generic overloads (`int`/`long`/`float`/`double`/
`decimal` + nullable). The proposal instead uses a single generic constrained to `INumber`, which
is broader and in-box. Three sub-questions: (a) the exact minimal constraint (`INumber` vs
`INumberBase`/`IAdditionOperators`+`IAdditiveIdentity` as `TensorPrimitives` uses); (b) overflow
semantics — LINQ's integer `Sum` is *checked* and throws on overflow, which we'd need to match or
consciously diverge from; (c) `Average`'s return type — LINQ widens (`int` → `double`), whereas a
single-type-parameter generic returns `T`. A `TResult` parameter or a documented widening rule are
the options.
3. **`IComparer?` nullability.** All comparer/equality-comparer parameters are nullable for
consistency with LINQ, the rest of `MemoryExtensions`, and the nullability fix noted at the end of
#127083 (the shipped `Min`/`Max` comparer overloads take `IComparer?`).
4. **Destination-too-short contract** (`Select`/`Zip`/`Where`/`Distinct`). Proposed: throw
`ArgumentException`, consistent with `CopyTo`. For the *bounded* operators (`Where`/`Distinct`) the
required size isn't known until enumeration completes, so a `TryXxx(..., out int written)` shape is
an alternative worth weighing. The *fixed* operators (`Select`/`Zip`) can validate up front.
5. **`Select` return type.** `Select` always writes exactly `source.Length`, so it's proposed as
`void`. Returning the written count (like `Zip`/`Where`/`Distinct`) would be more uniform but is
redundant. Which do we prefer?
6. **`Where`/`Distinct` in-place aliasing.** Do we want to support `destination` overlapping `source`
(in-place compaction)? `Where`/`Distinct` write monotonically forward, so in-place is safe and
useful; worth stating explicitly in the contract.
7. **Scope splitting.** Tier 1 (reductions) is self-contained and low-risk. The Tier 2
destination-filling operators share a design axis (destination contract, count return, aliasing)
and could be taken as a group or split into their own proposal if the contract questions are
contentious.

## API Usage

```csharp
ReadOnlySpan orders = GetOrders();

// MaxBy — no allocation, no LINQ, no boxing
Order? mostExpensive = orders.MaxBy(static o => o.Total);

// Any / All / Count with predicates
bool anyPending = orders.Any(static o => o.Status == Status.Pending);
bool allPaid = orders.All(static o => o.IsPaid);
int refunds = orders.Count(static o => o.IsRefunded);

// Sum / Average — in-box, no System.Numerics.Tensors reference required
ReadOnlySpan quantities = [3, 5, 2, 8];
int total = quantities.Sum();
double avgTotal = orders.Average(static o => (double)o.Total);

// IndexOfMax — returns -1 on empty instead of throwing
ReadOnlySpan samples = GetSamples();
int peak = samples.IndexOfMax();
if (peak >= 0) { /* ... */ }

// Aggregate
ReadOnlySpan values = [1, 2, 3, 4];
int product = values.Aggregate(1, static (acc, x) => acc * x); // 24

// Distinct into a caller-owned buffer
ReadOnlySpan input = [1, 1, 2, 3, 3, 3, 4];
Span buffer = stackalloc int[input.Length];
int written = input.Distinct(buffer);
buffer = buffer[..written]; // [1, 2, 3, 4]

// Select — 1-to-1 projection into a caller-sized destination (CopyTo-style, no allocation)
ReadOnlySpan nums = [1, 2, 3, 4];
Span roots = stackalloc double[nums.Length];
nums.Select(roots, static x => Math.Sqrt(x));

// Zip — writes min(first, second) elements, returns the count written
ReadOnlySpan a = [1, 2, 3];
ReadOnlySpan b = [10, 20];
Span sums = stackalloc int[Math.Min(a.Length, b.Length)];
int n = a.Zip(b, sums, static (x, y) => x + y); // n == 2, sums == [11, 22]

// Where — filter into a destination, returns the count written (in-place compaction supported)
ReadOnlySpan src = [1, 2, 3, 4, 5, 6];
Span evens = stackalloc int[src.Length];
int count = src.Where(evens, static x => x % 2 == 0);
evens = evens[..count]; // [2, 4, 6]
```

## What we are intentionally NOT including (Tier 3)

These `Enumerable` members are deliberately excluded because they either have no allocation-free span
representation or already exist on the span / related surfaces.

### Already covered elsewhere — no new API needed

| LINQ member(s) | Existing span equivalent |
|----------------|--------------------------|
| `Skip`, `SkipLast`, `Take`, `TakeLast` | `span.Slice(...)` |
| `ElementAt`, `ElementAtOrDefault` | indexer `span[i]` |
| `First`, `Last`, `Single` (no predicate) | `span[0]`, `span[^1]` |
| `First`/`Last` (predicate) | `IndexOf` / `Any` + indexer |
| `Reverse`, `Order`/`OrderBy`/`OrderByDescending`/`OrderDescending` | `MemoryExtensions.Reverse`, `MemoryExtensions.Sort` |
| `Contains` | `MemoryExtensions.Contains` |
| `SequenceEqual` | `MemoryExtensions.SequenceEqual` |
| `Min`, `Max` (`IComparer` semantics) | `MemoryExtensions.Min`/`Max` (shipped in #127083) |
| `Shuffle` | `Random.Shuffle(Span)` |
| `ToArray` | `MemoryExtensions.ToArray` |
| `Count()` (no predicate), `LongCount()`, `TryGetNonEnumeratedCount` | `span.Length` |

> `Sum`/`Average` are **not** in this table — although `TensorPrimitives` provides an arithmetic
> equivalent, it is out-of-band, so these are proposed in-box in Tier 1 (see the availability rationale
> in Background).

### Lazy sequence producers — no non-allocating span representation

`SelectMany`, `Cast`, `OfType`, `DefaultIfEmpty`, `Append`, `Prepend`, `Concat`, `Chunk`.

A span cannot represent a *view*; these either produce a variable/jagged shape with no flat
destination (`SelectMany`, `Chunk`), or are degenerate over a caller-sized destination and reduce to
existing primitives (`Append`/`Prepend`/`Concat` ≡ one or two `CopyTo` calls plus an index write).
`Cast` is a reinterpret and `OfType` is a filter — if a filtered-projection destination shape is
wanted they collapse into the `Where`/`Select` operators above. Producing a lazy filtered/projected
*view* is out of scope for `MemoryExtensions`.

> Note: `Select`, `Zip`, and `Where` were **promoted out of this list** into Tier 2 because their
> output length is either fixed by the inputs (`Select`, `Zip`) or bounded by the source with a
> returned count (`Where`) — i.e. they fit the `CopyTo`-style caller-owned-destination pattern.

### Inherently allocating — set / join / group / collector operators

`Union`, `UnionBy`, `Intersect`, `IntersectBy`, `Except`, `ExceptBy`,
`Join`, `GroupJoin`, `LeftJoin`, `RightJoin`, `FullJoin`,
`GroupBy`, `CountBy`, `AggregateBy`,
`ToList`, `ToDictionary`, `ToHashSet`, `ToLookup`,
`AsEnumerable`.

(`Distinct`/`DistinctBy` are the one exception among these, promoted to Tier 2 via a destination-based
shape; the projection/filter operators `Select`/`Zip`/`Where` are likewise handled in Tier 2.)

### Generators — a span cannot be produced from nothing / cannot be infinite

`Range`, `Repeat`, `Sequence`, `InfiniteSequence`, `Empty`.

The finite "fill a destination with a sequence" case is already served by `Span.Fill` and targeted
helpers; no LINQ-shaped generator is proposed.

### Ordering combinators

`ThenBy`, `ThenByDescending` — these compose onto `IOrderedEnumerable`, which has no span analog.
Multi-key ordering on a span is done via `MemoryExtensions.Sort` with a custom comparer.

## Risks

* **Overload ambiguity with `Enumerable`.** As with `Min`/`Max`, first-class spans plus these
extension methods could create overload-resolution ambiguity in code that has both a span and
`using System.Linq`. This was accepted for `Min`/`Max`; the same reasoning applies.
* **`Count(predicate)` dual meaning** (see open question 1).
* **Delegate allocation / invocation cost.** The `Func<...>`-based overloads carry per-call delegate
invocation cost. This matches `Enumerable` and is acceptable for the convenience these provide; the
vectorizable value-based reductions already live on `MemoryExtensions`/`TensorPrimitives`.

> [!NOTE]
> This issue body was drafted with GitHub Copilot on @tannergooding's behalf.

Contributor guide

Open the contributing guide

Research direction

Start by reviewing the API Proposal and its open questions, especially the Tier 1/Tier 2 split and the proposed MemoryExtensions signatures. Compare the listed existing MemoryExtensions and TensorPrimitives surfaces. Done means the scope, signatures, semantics, and destination-size and aliasing contracts are agreed for implementation.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
api
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.