From 69c62f9c411ad1d7e6497950c678e74fb53ee16d Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 11 Oct 2026 01:38:54 +0200 Subject: [PATCH 01/12] docs: add ADR 0001 for the per-collection module rewrite Records the decisions behind the rewrite of the collection modules: one folder and signature file per collection, `FrozenMap`/`FrozenSet` support, `voption` and struct-tuple defaults with `Option`/`Tuple` override namespaces, category-based function order, BCL/LINQ-first implementation, shared contract tests, one pull request per collection, and the function-by-collection support matrices. Co-Authored-By: Claude Fable 5.1 --- .../ADR_0001_Collection_Modules.md | 492 ++++++++++++++++++ 1 file changed, 492 insertions(+) create mode 100644 docsSrc/Explanations/ADR_0001_Collection_Modules.md diff --git a/docsSrc/Explanations/ADR_0001_Collection_Modules.md b/docsSrc/Explanations/ADR_0001_Collection_Modules.md new file mode 100644 index 00000000..d58c3b14 --- /dev/null +++ b/docsSrc/Explanations/ADR_0001_Collection_Modules.md @@ -0,0 +1,492 @@ +--- +title: ADR 0001 – Per-collection modules +category: Explanations +categoryindex: 3 +index: 2 +--- + +# ADR 0001: Per-collection modules, Frozen collections and value-type defaults + +| | | +|---|---| +| Status | Proposed | +| Date | 2026-10-11 | +| Deciders | maintainers | + +## Context + +`FSharp.Collections.Immutable` wraps `System.Collections.Immutable` with F#-style modules (`FlatList` = `ImmutableArray`, +`IndexedList` = `ImmutableList`, `Queue`, `Stack`, `HashMap`, `SortedMap`, `HashSet`, `SortedSet`). The published code still +has the 2020 single-file layout, placeholder tests and real defects in the map and set modules: `SortedMap.singleton` builds an +empty map (its argument binds to the comparer overload), `builderWith` ignores its argument, map `filter` returns a sequence +while `where` returns a map, transformations drop custom comparers, `check` helpers are public. + +An unmerged branch started the target shape – one folder per collection, implementation `.fs` plus signature `.fsi`, +interface modules, builders, computation expressions, `voption` results, struct tuples – but: + +* the order of functions differs in every module (for example `fold` is the 136th function in `FlatList.fsi`, the 56th in + `IndexedList.fsi` and the 40th in `Queue.fsi`; `item` and `tryItem` are 74 positions apart in `FlatList.fsi`); +* about sixty functions have no FSharp.Core counterpart (`iter3`, `foldi2`, `findDup`, twelve `index*` overloads, …) while + newer FSharp.Core functions are missing (`partitionWith`, the `random*` family, `removeManyAt`); +* `Stack` transformations flip the element order inconsistently (`rev` is a no-op), `init` runs the initializer through + `Parallel.For`, `splitInto` uses a different chunking than FSharp.Core, `take`/`skip` clamp instead of throwing; +* the test project does not compile on the current MSTest 4 setup (155 `[]` usages), none of its + 2 744 assertions carries a failure message, and several tests lock the defects in; +* `System.Collections.Frozen` (`FrozenDictionary`, `FrozenSet`), shipped in the same NuGet package since 8.0, is not covered. + +FSharp.Core itself (checked against the compiler sources, FSharp.Core 10.0.300 API) offers `List` 118, `Array` 131, +`Seq` 118, `Map` 31 and `Set` 33 public functions, none of them returning `voption` or struct tuples, and its signature +files follow no strict ordering rule. + +## Decision + +### D1. Scope: one module per collection with all relevant functions + +The vocabulary of every module is the union of the FSharp.Core functions that apply to the collection, wrappers of the +BCL members of the underlying type, and a small curated set of extras. The complete assignment is the matrix in +Appendix A (sequence-like collections), B (maps) and C (sets). + +New collections: `FrozenMap` (`FrozenDictionary<'Key, 'Value>`) and `FrozenSet` (`FrozenSet<'T>`). Their modules offer +creation, lookup, iteration, folds, conversions **and** whole-collection transformations (`map`, `filter`, `choose`, +`partition`, set algebra), each documented as an O(n) rebuild. They offer no incremental mutation (`add`, `remove`, +`setItem`, `change`, `clear`, `addRange`, `removeRange`): a frozen collection is built once and read many times. + +Curated extras kept beyond FSharp.Core: `indexOf`/`indexOfWith`/`lastIndexOf`/`lastIndexOfWith`, `binarySearch`, +`sortWithComparer`, `isDefault`/`isDefaultOrEmpty`, `asSpan`/`asMemory`/`ofSpan`/`ofReadOnlySpan`, the builder family +(`builder`, `builderWith`, `ofBuilder`, `toBuilder`, `moveFromBuilder`, `build`, `update`), `blit`, `add`, +`cons`/`uncons`/`tryUncons`/`(|Cons|Nil|)`, `tryTail`, `enqueue`/`enqueueMany`, `consMany`, `takeLast`/`skipLast`, `clear` +on interface modules, `tryGetKey`/`tryGetValue`, `setItem`/`setItems`/`addRange`/`removeRange`, `containsValue`, +`symmetricDifference`, `overlaps`/`setEquals`, comparer accessors and `with*Comparer` functions. + +Removed from the started branch: `iter3`/`iteri3`/`exists3`/`forall3`/`mapi3`/`foldBack3`, `foldi`/`foldi2`/`foldBacki`/ +`foldBacki2`, `findDup`/`findDupBy`, `pickBack`/`tryPickBack`/`chooseBack`, the `From`/`Range` variants of `index*`/ +`lastIndex*`, `removeAll*`, `sortRange*`, `fill`, `zeroCreate`, `copy`, `ofOption`/`toOption`/`ofValueOption`/ +`toValueOption`, `tryHeadAndTail`/`tryLastAndInit`, `enqueueArray`/`enqueueList`/`consArray`/`consList`, `unconsMany`, +and the modules `Seq` (cross conversions) and `IndexedSeq` (`IReadOnlyList`). + +### D2. Layout + +One folder per collection; every implementation file with public API has a signature file that carries the XML +documentation. Per folder, in compile order: `X.fsi`, `X.fs`, `XOption.fsi`, `XOption.fs`, `XTuple.fsi`, `XTuple.fs`, +`IX.fsi`, `IX.fs` (plus their `Option`/`Tuple` files), `XBuilder.fs` (`FlatList` and `IndexedList` only), `XCE.fs`. +`Helper.fs` comes first and `ComputationExpressions.fs` last. Interface modules compile after the concrete type they return. + +Names: `IMap` module and `IMap<'Key, 'Value>` abbreviation for `IImmutableDictionary`; `ISet` module **without** an +`ISet<'T>` abbreviation, because it would shadow `System.Collections.Generic.ISet<'T>`, which `FrozenSet<'T>` implements. +Maps and sets measure their size with `count` (FSharp.Core `Map.count`/`Set.count`), sequences with `length`. `Queue` keeps +`enqueue`/`head`/`tail`/`uncons` (compiled `Enqueue`/`Peek`/`Dequeue`/`PeekDequeue`), `Stack` keeps `cons`/`head`/`tail`/ +`uncons` (`Push`/`Peek`/`Pop`/`PeekPop`). Computation expression instances: `flatList`, `indexedList`, `queue`, `stack`, +`hashMap`, `sortedMap`, `hashSet`, `sortedSet`, `frozenMap`, `frozenSet`. + +### D3. Value-type defaults and the `Option`/`Tuple` override namespaces + +* Every `try*` function returns `voption`; `choose`, `pick`, `tryPick`, `unfold` and `change` take or return `voption`. +* Every tuple in a signature is a struct tuple: `zip`, `zip3`, `unzip`, `unzip3`, `pairwise`, `indexed`, `allPairs`, + `groupBy`, `countBy`, `partition`, `partitionWith`, `splitAt`, `mapFold`, `mapFoldBack`, `unfold`, `uncons`, map + `ofSeq`/`toSeq`/`addRange`/`minKeyValue`/`maxKeyValue`, and so on. +* The namespace `FSharp.Collections.Immutable.Option` contains, per collection, a module with the same name holding every + function whose signature mentions `voption`, converted to the FSharp.Core representation: `voption` becomes `option`, and + a struct tuple inside an option payload becomes a reference tuple (`unfold : ('State -> ('T * 'State) option) -> …`). +* The namespace `FSharp.Collections.Immutable.Tuple` holds every function whose signature mentions a struct tuple, with + reference tuples and `voption` kept. A function with a struct tuple inside a `voption`, such as `unfold` or `tryUncons`, + is in both namespaces. +* The conversion rules are about representation, not about FSharp.Core having the function. Where FSharp.Core has it + (`tryFind`, `tryHead`, `choose`, `pick`, `unfold`, `zip`, `partition`, `Map.tryFind`, `Map.change`, …), opening both + namespaces with `.Option` last yields exactly its signature. Extras without a counterpart (`tryTail`, `uncons`, + `tryUncons`, `tryGetKey`, `tryGetValue`, map and set `choose`, `SortedSet.tryItem`) get the same representation, which is + the one FSharp.Core would use, but there is no FSharp.Core signature to match. + +This relies on F# name resolution, verified in `src/Compiler/Checking/NameResolution.fs` of the compiler: opened modules are +prepended to the environment (most recently opened first), the resolver takes the first module that contains the member and +falls through otherwise (`CollectAtMostOneResult`), and module search precedes type search. Hence after +`open FSharp.Collections.Immutable` and `open FSharp.Collections.Immutable.Option`, `FlatList.tryFind` returns an `option` +while `FlatList.map` still resolves to the default module; `Option.map` keeps resolving to FSharp.Core; a module named +`FrozenSet` coexists with the BCL static class `FrozenSet`, exactly like the existing `Stack`, `Queue` and `HashSet` modules +coexist with `System.Collections.Generic`. + +The override modules delegate to the default modules and convert (`ValueOption.toOption`, `ValueOption.ofOption`, struct +pattern repacking). Per-element tuple producers (`zip`, `pairwise`, `indexed`, `allPairs`, `groupBy`, `countBy`) do not +delegate and remap; both modules call one internal inline helper parameterised by the tuple constructor, so neither pays for +a second pass. + +The project guideline "prefer anonymous struct records over tuples for return types" is deliberately not applied to the +FSharp.Core-mirrored shapes: the `Tuple` namespace needs a reference-tuple counterpart, and anonymous records have none. + +### D4. Order of functions + +Functions are grouped by category and sorted alphabetically (ordinal) inside each category; the order is identical in the +signature and the implementation file; each category is a banner comment. The canonical category sequence is +`Creation`, `Builder`, `Properties`, `Access`, `Search`, `Transformation`, `Folding`, `Partitioning and grouping`, `Sorting`, +`Random`, `Conversion`, `Type-specific`; an empty category is omitted, an active pattern is the last row of its category. +A script `build/check-order.fsx` parses every `.fsi`, verifies the banner sequence and the alphabetical order, checks that +the sibling `.fs` has the same banners and the same sequence of `[]` bindings, and runs as the FAKE target +`CheckOrder` before `DotnetBuild`. + +### D5. Implementation principle: BCL members and System.Linq first + +Priority for every function body: + +1. a member of the collection type itself (`ImmutableList.ConvertAll`/`FindAll`/`FindIndex`/`FindLastIndex`/`TrueForAll`/ + `Exists`/`ForEach`/`Reverse`/`GetRange`/`IndexOf`/`Insert`/`RemoveRange`/`SetItem`/`BinarySearch`, + `ImmutableArray.Slice`/`Sort`/`IndexOf`/`AsSpan`/`CopyTo`, set algebra, `ImmutableDictionary.SetItems`/`TryGetKey`/ + `WithComparers`, Frozen struct enumerators and `Keys`/`Values`/`Items`); +2. `System.Linq.ImmutableArrayExtensions` (`Any`, `All`, `ToArray`; its `First`/`Single`/`ElementAt` throw the wrong + exception type and are not used); +3. the zero-copy bridge `ImmutableCollectionsMarshal.AsArray`/`AsImmutableArray` plus FSharp.Core `Array.*` for `FlatList` + transformations (a default array is rejected first; the unwrapped array is never mutated; only freshly allocated arrays + are wrapped); +4. `System.Linq.Enumerable` and the `ToImmutableX`/`ToFrozenX` extension methods (`ToLookup` for `groupBy`/`countBy`, + `TakeLast`/`SkipLast`, `Chunk`, `DistinctBy`, `MinBy`/`MaxBy`, `Order*`); +5. FSharp.Core `Seq.*`/`Array.*` for shapes LINQ lacks (`mapFold`, `scanBack`, `windowed`, `pairwise`, `splitInto`, + `transpose`, `permute`, `random*`); +6. hand-written enumerator loops only where measured (an internal `SeqV` module for `Queue`/`Stack`/interface modules). + +The priority ranks only implementations that meet the documented contract (D6 exceptions, ordering, stability, comparison +semantics); a faster candidate that breaks the contract is skipped. Sorting is the main case. Stability follows the +FSharp.Core module that a collection mirrors: `FlatList` and `FlatList.Parallel` sort unstably, as `Array.sort*` and +`Array.Parallel.sort*` are documented ("This is not a stable sort"), through `ImmutableArray.Sort` or `Array.sort*` on the +bridge; `IndexedList`, `Queue`, `Stack` and their interface modules sort stably, as `List.sort*` and `Seq.sort*` are +documented, through LINQ `OrderBy`/`OrderByDescending` (documented stable), not through the unstable `ImmutableList.Sort`. +Every sort compares with F# structural comparison (`LanguagePrimitives.FastGenericComparer<'T>`, the comparer behind +`compare`), never with `Comparer<'T>.Default`, which compares strings culture-sensitively. + +Consequences for the types: `FlatList` range operations (`take`, `skip`, `truncate`, `splitAt`, `takeLast`, `skipLast`, +`chunkBySize`, `windowed`) copy the selected elements. An `ImmutableArray` is a single array reference without an offset or a +length, so `Slice` allocates and copies unless the range is empty (`Empty` is returned) or covers the whole input (the input +itself is returned); zero-copy views of a range are available through `asSpan`/`asMemory` and the span's own `Slice`. +`IndexedList` ranges use `GetRange`, which builds a new tree from the selected elements: its work and allocation grow with +the length of the range (48 B per element in a measurement), not with log n. `Stack` reverses only in its constructors: +the BCL `ImmutableStack.Create` and `CreateRange` push in order and leave the last element on top, so `ofSeq`, `ofArray`, +`ofList`, `ofReadOnlySpan` and `ofSpan` push from the end instead (`ofSeq [1; 2; 3]` has `1` on top), and every +transformation snapshots top-to-bottom, applies `Array.*` and rebuilds the same way. `Queue.length`/`Stack.length` are O(n) and say so. Interface modules keep the +caller's implementation in same-type transformations through `Clear()` plus `AddRange`/`Enqueue`/`Push`. +`FrozenMap.keys`/`values` and `FrozenSet.toFlatList` return the underlying `ImmutableArray` in O(1). + +**Comparers.** A transformation keeps each comparer whose type does not change. The key comparer of a map is always kept, +because the keys keep their type (`CreateRange(keyComparer, …)`, `CreateBuilder(keyComparer)`). The value comparer of a map +and the element comparer of a set are kept by `filter`, `partition` and set algebra. `map` and `choose` change the value or +element type, so their result gets the default comparer of the new type. The interface modules cannot read the comparers +of their input, so their type-changing transformations return concrete collections with default comparers. The defaults, +which creation functions without a comparer argument also use, are `EqualityComparer<'T>.Default` for hash-based +collections and the order of F# generic comparison +(`LanguagePrimitives.FastGenericComparer<'T>`) for sorted collections. Thus `SortedSet` is ordinal by default, as +F# `Set` is. + +### D6. Exceptions mirror FSharp.Core + +Each function validates its arguments as the FSharp.Core function of the mirrored module does for the same input, and +throws the same exception type with the same message. The mirrored module is `Array` for `FlatList`, +`List` for `IndexedList`, `Queue`, `Stack` and their interface modules, `Map` for the map modules and +`Set` for the set modules. The rules differ per operation and per module (FSharp.Core 10, measured): + +| Input | `Array` | `List` | +|---|---|---| +| `take 0`, `init 0`, `replicate 0` | valid | valid | +| `take -1`, `init -1`, `replicate -1` | `ArgumentException` | `ArgumentException` | +| `skip -1`, `truncate -1` | valid, as 0 | valid, as 0 | +| `take` or `splitAt` beyond the length | `InvalidOperationException` | `InvalidOperationException` | +| `skip` beyond the length | `ArgumentOutOfRangeException` | `ArgumentOutOfRangeException` | +| `item` out of range | `IndexOutOfRangeException` | `ArgumentException` | +| `insertAt`, `removeAt` out of range | `ArgumentException` | `ArgumentException` | +| `randomSample` beyond the length | `ArgumentException` | `ArgumentException` | +| `chunkBySize 0`, `windowed 0`, `splitInto 0` | `ArgumentException` | `ArgumentException` | +| empty input to `head`, `reduce`, `exactlyOne`, `average`, `min`, `max` | `ArgumentException` | `ArgumentException` | +| no match in `find*`, `findIndex*`, `pick` | `KeyNotFoundException` | `KeyNotFoundException` | + +A `null` input, and a default `FlatList`, throw `ArgumentNullException`, as a `null` array does in `Array`. Deliberate +deviations, each documented on the function: + +- `ParamName` is the name of the parameter in this library's signature, such as `list`, `queue` or `map`. FSharp.Core uses + `array`, `list`, `source` or `s`. +- The extras of D1 have no FSharp.Core counterpart. `takeLast` and `skipLast` follow `take` and `skip`; the others throw + what the BCL member that they wrap throws. + +### D7. Computation expressions + +One `CE` class per collection with the delayed-function design (`Buffer<'T> -> unit`), members `Zero`, `Yield`, +`YieldFrom` (sequence, array, same collection, buffer), `Combine`, `Delay`, `For`, `While`, `Run`, `TryWith`, `TryFinally`, +`Using`. Buffers are the BCL builders where they exist (`ImmutableArray.Builder` with `DrainToImmutable`, +`ImmutableList.Builder`, dictionary and set builders) and a `ResizeArray` otherwise. Map expressions accept +`yield struct (key, value)` and `yield KeyValuePair`; map and set expression classes take an optional comparer. The +duplicate `FlatListBuilderCE` is dropped. + +### D8. Tests: shared contract suites + +Tests are rewritten on MSTest 4 with the Microsoft.Testing.Platform runner. Each capability is an abstract generic base +class without `[]` (MSTEST0085/MSTEST0002) that takes an immutable record of the module's functions; every +collection derives one closed `[]` per capability it supports, so inapplicable tests do not exist rather than +being skipped. Facets: `SeqLike`, `SeqCreation`, `Indexed`, `StableSort` (every sequence-like module except `FlatList`, +which sorts unstably, see D5); `Map`, `MapTransform`, `MapMutation`, `MapCreation`; `Set`, `SetTransform`, `SetMutation`, +`SetCreation`. Expectations are computed with FSharp.Core `List` over the collection's +observed enumeration order, so one test body serves FIFO, LIFO and indexed collections. Collection-specific behaviour +(`isDefault`, spans, builders, `Stack.cons` direction, comparers, sorted-only functions, null inputs) lives in a per-collection +file. Expected exceptions come from the same oracle: the test calls the function of the mirrored FSharp.Core module (D6) +on the same input and asserts the same type and message with `Assert.ThrowsExactly`; `ParamName` is checked against the +ops record. Every assertion carries a message; categories come from `TestCategories.fs` attributes (collection level) and +`TestCategory` strings or `nameof` (operation level). Dedicated files per collection prove that opening the +`Option`/`Tuple` namespaces shadows the default functions and that the override functions are equivalent to the defaults +after conversion. + +### D9. Delivery: one pull request per collection, stacked + +`chore/foundation` (helper module, order check script, test categories and contract bases, changelog and documentation +skeleton, target frameworks), then `feat/flat-list`, `feat/indexed-list`, `feat/queue`, `feat/stack`, `feat/hash-map` (with +`IMap`), `feat/sorted-map`, `feat/hash-set` (with `ISet`), `feat/sorted-set`, `feat/frozen-map`, `feat/frozen-set`, and an +optional `chore/benchmarks` (BenchmarkDotNet, excluded from the solution filter). Each pull request deletes the legacy file +it replaces, keeps the build green and updates the changelog and the documentation table. No release is cut mid-chain. + +### D10. Target frameworks + +`net8.0;net10.0`. The published package already targeted `net8.0` only; `netstandard2.0` is not added (the LINQ surface +used in D5 would need conditional fallbacks); `net9.0` is not added (short-term support, ended 2026-05; the `net8.0` asset +serves it). `net10.0` enables `#if NET10_0_OR_GREATER` paths for newer LINQ members and, as a follow-up, the Frozen +alternate lookup. + +## Consequences + +Breaking changes (major version): `try*` return `voption`; tuples become struct tuples; `choose`/`pick`/`tryPick`/`unfold`/ +`change` callbacks use `voption`; `ImmutableList` module becomes `IndexedList`; `Seq`, `IndexedSeq`/`ReadOnlyList`, the +`ISet<'T>` abbreviation and the extras listed in D1 are removed; map and set `length` becomes `count`; map `filter`/`map` +follow FSharp.Core semantics; sorted maps and sets order by F# generic comparison unless a comparer is given; `Stack` +element order is defined (first element of `ofSeq` on top); `take`/`skip` throw on short input; `netstandard2.0` is not a +target. The `Option` and `Tuple` namespaces give consumers a one-line migration path. + +Benefits: one vocabulary across ten modules, consistent with FSharp.Core 10.0.300; defects of the current map and set modules +fixed by construction (comparer preservation, real `singleton`); fewer allocations (`voption`, struct tuples, span and memory views, zero-copy +bridge); `Frozen*` support; tests that cannot drift between collections; machine-checked function order. + +Costs: about 150 sequence functions × 7 modules plus maps, sets and frozen collections to implement and document; the +override namespaces double the number of signature files; contributors must learn the category order (enforced by the +script, so mistakes fail fast). + +## Alternatives considered + +* **Strictly alphabetical order** – trivially checkable but reads worse in generated documentation; rejected in favour of + categories with alphabetical order inside. +* **Keep every extra from the started branch** – about 190 matrix rows, more surface to document and test for functions + without precedent in FSharp.Core; rejected. +* **Read-only Frozen modules** (no `map`/`filter`) – honest about the type's purpose but forces every transformation + through `Seq` and a re-freeze; rejected in favour of documented O(n) transformations without incremental mutation. +* **Per-collection test files** (as on the started branch) – full independence but roughly tenfold duplication and the + drift that produced today's contradicting exception contracts; rejected. +* **`Assert.Inconclusive` or capability flags** for inapplicable contract tests – noisy reports and silently skipped tests + when a flag is forgotten; rejected in favour of one base class per capability. +* **`netstandard2.0`** – would keep .NET Framework consumers but requires conditional LINQ fallbacks; not a regression + because the published package is `net8.0` only; deferred. + +## Appendix A – sequence-like collections + +`FlatList` (`ImmutableArray`), `IndexedList` (`ImmutableList`), `IIndexedList` (`IImmutableList`), `Queue`, `IQueue`, +`Stack`, `IStack`. Legend: `✓` FSharp.Core semantics · `ᵇ` wraps a BCL-specific member · `ᵃ` extra beyond FSharp.Core · +`–` not provided. Shape: `Ⓥ` `voption` by default (`option` in `Option.*`) · `Ⓣ` struct tuple (reference tuple in `Tuple.*`). +Interface modules have no creation, builder or comparer functions; their same-type transformations return the interface, +their element-type-changing transformations return the concrete type. + +| Function | Shape | FlatList | IndexedList | IIndexedList | Queue | IQueue | Stack | IStack | Note | +|---|---|---|---|---|---|---|---|---|---| +| **Creation** | | | | | | | | | | +| `create` | | ✓ | – | – | – | – | – | – | Array-only name; others use `replicate` | +| `empty` | | ✓ | ✓ | – | ✓ | – | ✓ | – | `ImmutableX<'T>.Empty` | +| `init` | | ✓ | ✓ | – | ✓ | – | ✓ | – | sequential initializer | +| `ofArray` | | ✓ | ✓ | – | ✓ | – | ✓ | – | copies; Stack pushes from the end | +| `ofList` | | ✓ | ✓ | – | ✓ | – | ✓ | – | | +| `ofReadOnlySpan` | | ᵇ | ᵇ | – | ᵇ | – | ᵇ | – | `Create(ReadOnlySpan)`; Stack pushes from the end, because the BCL `Create` leaves the last element on top | +| `ofSeq` | | ✓ | ✓ | – | ✓ | – | ✓ | – | `CreateRange`; Stack pushes a snapshot from the end, so the first element is on top | +| `ofSpan` | | ᵇ | ᵇ | – | ᵇ | – | ᵇ | – | as `ofReadOnlySpan` | +| `replicate` | | ✓ | ✓ | – | ✓ | – | ✓ | – | | +| `singleton` | | ✓ | ✓ | – | ✓ | – | ✓ | – | | +| `unfold` | Ⓥ+Ⓣ | ✓ | ✓ | – | ✓ | – | ✓ | – | `'State -> struct ('T * 'State) voption` | +| **Builder** | | | | | | | | | | +| `build` | | ᵃ | ᵃ | – | – | – | – | – | `(Builder -> unit) -> X<'T>` | +| `builder` | | ᵇ | ᵇ | – | – | – | – | – | | +| `builderWith` | | ᵇ | – | – | – | – | – | – | capacity | +| `moveFromBuilder` | | ᵇ | – | – | – | – | – | – | `MoveToImmutable` | +| `ofBuilder` | | ᵇ | ᵇ | – | – | – | – | – | `ToImmutable` | +| `toBuilder` | | ᵇ | ᵇ | – | – | – | – | – | | +| `update` | | ᵃ | ᵃ | – | – | – | – | – | `toBuilder` → f → `ToImmutable` | +| **Properties** | | | | | | | | | | +| `isEmpty` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `length` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | Queue/Stack O(n) | +| **Access** | | | | | | | | | | +| `exactlyOne` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | `ArgumentException` | +| `head` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | Queue/Stack `Peek` | +| `item` | | ✓ | ✓ | ✓ | – | – | – | – | | +| `last` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | LINQ `Last` for Queue/Stack | +| `tail` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | `Dequeue`/`Pop` | +| `tryExactlyOne` | Ⓥ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `tryHead` | Ⓥ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `tryItem` | Ⓥ | ✓ | ✓ | ✓ | – | – | – | – | | +| `tryLast` | Ⓥ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `tryTail` | Ⓥ | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | | +| `tryUncons` | Ⓥ+Ⓣ | – | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | `struct ('T * X<'T>) voption` | +| `uncons` | Ⓣ | – | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | compiled `PeekDequeue`/`PeekPop` | +| `(\|Cons\|Nil\|)` | Ⓣ | – | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | | +| **Search** | | | | | | | | | | +| `binarySearch` | | ᵇ | ᵇ | – | – | – | – | – | BCL bitwise-complement contract | +| `contains` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `exists` / `exists2` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `find` / `findBack` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | `KeyNotFoundException` | +| `findIndex` / `findIndexBack` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `forall` / `forall2` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `indexOf` / `indexOfWith` | | ᵇ | ᵇ | ᵇ | – | – | – | – | `-1` when absent | +| `lastIndexOf` / `lastIndexOfWith` | | ᵇ | ᵇ | ᵇ | – | – | – | – | | +| `pick` | Ⓥ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `tryFind` / `tryFindBack` | Ⓥ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `tryFindIndex` / `tryFindIndexBack` | Ⓥ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `tryPick` | Ⓥ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| **Transformation** | | | | | | | | | | +| `add` | | ᵇ | ᵇ | ᵇ | – | – | – | – | append one | +| `allPairs` | Ⓣ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `append` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `choose` | Ⓥ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `chunkBySize` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | `X>` | +| `collect` / `concat` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `cons` | | – | ᵃ | ᵃ | – | – | ᵇ | ᵇ | Stack `Push` | +| `consMany` | | – | – | – | – | – | ᵃ | ᵃ | first element ends on top | +| `enqueue` / `enqueueMany` | | – | – | – | ᵇ/ᵃ | ᵇ/ᵃ | – | – | | +| `filter` / `where` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `indexed` | Ⓣ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `insertAt` / `insertManyAt` | | ✓ | ✓ | ✓ | – | – | – | – | | +| `map` / `map2` / `map3` / `mapi` / `mapi2` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `pairwise` | Ⓣ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `permute` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `removeAt` / `removeManyAt` | | ✓ | ✓ | ✓ | – | – | – | – | | +| `rev` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `transpose` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `unzip` / `unzip3` | Ⓣ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `updateAt` | | ✓ | ✓ | ✓ | – | – | – | – | | +| `windowed` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `zip` / `zip3` | Ⓣ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | `ArgumentException` on mismatch | +| **Folding** | | | | | | | | | | +| `average` / `averageBy` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `compareWith` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `fold` / `fold2` / `foldBack` / `foldBack2` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `iter` / `iter2` / `iteri` / `iteri2` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `mapFold` / `mapFoldBack` | Ⓣ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `max` / `maxBy` / `min` / `minBy` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `reduce` / `reduceBack` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `scan` / `scanBack` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `sum` / `sumBy` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| **Partitioning and grouping** | | | | | | | | | | +| `countBy` | Ⓣ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `distinct` / `distinctBy` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `except` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | FSharp.Core semantics | +| `groupBy` | Ⓣ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `partition` / `partitionWith` | Ⓣ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `skip` / `skipWhile` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `skipLast` / `takeLast` | | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | | +| `splitAt` | Ⓣ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `splitInto` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | FSharp.Core chunk sizes | +| `take` / `takeWhile` / `truncate` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | `take` throws when short | +| **Sorting** | | | | | | | | | | +| `sort` / `sortBy` / `sortByDescending` / `sortDescending` / `sortWith` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | FlatList unstable like `Array.sort*`; the others stable like `List.sort*` (LINQ `OrderBy`) | +| `sortWithComparer` | | ᵇ | ᵇ | ᵇ | ᵇ | ᵇ | ᵇ | ᵇ | `IComparer<'T>`; same stability rule | +| **Random** | | | | | | | | | | +| `randomChoice`, `randomChoices`, `randomSample`, `randomShuffle` (+ `By`, `With`) | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | 12 functions | +| **Conversion** | | | | | | | | | | +| `asMemory` / `asSpan` | | ᵇ | – | – | – | – | – | – | O(1) | +| `blit` | | ✓ | ✓ | – | – | – | – | – | `CopyTo` | +| `toArray` / `toList` / `toSeq` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| **Type-specific** | | | | | | | | | | +| `clear` | | – | – | ᵇ | – | ᵇ | – | ᵇ | keeps the implementation | +| `isDefault` / `isDefaultOrEmpty` | | ᵇ | – | – | – | – | – | – | accept a default `FlatList` | + +`Option.*`: `choose`, `pick`, `tryExactlyOne`, `tryFind`, `tryFindBack`, `tryFindIndex`, `tryFindIndexBack`, `tryHead`, +`tryItem`, `tryLast`, `tryPick`, `tryTail`, `tryUncons`, `unfold`. +`Tuple.*`: `allPairs`, `countBy`, `groupBy`, `indexed`, `mapFold`, `mapFoldBack`, `pairwise`, `partition`, `partitionWith`, +`splitAt`, `unfold`, `unzip`, `unzip3`, `zip`, `zip3`, `uncons`, `tryUncons`, `(|Cons|Nil|)` where present. + +## Appendix B – maps + +`HashMap` (`ImmutableDictionary`), `SortedMap` (`ImmutableSortedDictionary`), `IMap` (`IImmutableDictionary`), +`FrozenMap` (`FrozenDictionary`). + +| Function | Shape | HashMap | SortedMap | IMap | FrozenMap | Note | +|---|---|---|---|---|---|---| +| **Creation** | | | | | | | +| `empty` | | ✓ | ✓ | – | ✓ | | +| `emptyWithComparers` / `emptyWithKeyComparer` | | ᵇ | ᵇ | – | – | | +| `ofArray` / `ofList` / `ofSeq` | Ⓣ | ✓ | ✓ | – | ✓ | `seq` | +| `ofKeyValuePairs` / `ofKeyValuePairsWithKeyComparer` | | ᵇ | ᵇ | – | ᵇ | `seq` | +| `ofSeqBy` | | ᵇ | ᵇ | – | ᵇ | key selector | +| `ofSeqWithKeyComparer` | Ⓣ | ᵇ | ᵇ | – | ᵇ | | +| `singleton` | | ✓ | ✓ | – | ✓ | curried `key value` | +| **Builder** | | | | | | | +| `builder` / `builderWithComparers` / `builderWithKeyComparer` / `ofBuilder` / `toBuilder` | | ᵇ | ᵇ | – | – | | +| **Properties** | | | | | | | +| `count` / `isEmpty` | | ✓ | ✓ | ✓ | ✓ | | +| `keyComparer` / `valueComparer` | | ᵇ | ᵇ | – | ᵇ/– | | +| `keys` / `values` | | ✓ | ✓ | ✓ | ✓ | Frozen → `FlatList` O(1) | +| **Access** | | | | | | | +| `find` | | ✓ | ✓ | ✓ | ✓ | `KeyNotFoundException` | +| `maxKeyValue` / `minKeyValue` | Ⓣ | – | ✓ | – | – | sorted only | +| `tryFind` | Ⓥ | ✓ | ✓ | ✓ | ✓ | | +| `tryGetKey` | Ⓥ | ᵇ | ᵇ | ᵇ | – | | +| **Search** | | | | | | | +| `containsKey` / `containsValue` | | ✓/ᵇ | ✓/ᵇ | ✓/– | ✓/ᵃ | | +| `exists` / `forall` | | ✓ | ✓ | ✓ | ✓ | | +| `findKey` / `tryFindKey` | Ⓥ | ✓ | ✓ | ✓ | ✓ | | +| `pick` / `tryPick` | Ⓥ | ✓ | ✓ | ✓ | ✓ | | +| **Transformation** | | | | | | | +| `add` / `addRange` | Ⓣ (`addRange`) | ✓/ᵇ | ✓/ᵇ | ✓/ᵇ | – | | +| `change` | Ⓥ | ✓ | ✓ | ✓ | – | | +| `choose` | Ⓥ | ᵃ | ᵃ | ᵃ | ᵃ | Frozen rebuild | +| `clear` | | ᵇ | ᵇ | ᵇ | – | keeps comparers | +| `filter` | | ✓ | ✓ | ✓ | ✓ | returns a map | +| `map` | | ✓ | ✓ | ✓ | ✓ | keys kept | +| `remove` / `removeRange` | | ✓/ᵇ | ✓/ᵇ | ✓/ᵇ | – | | +| `setItem` / `setItems` | | ᵇ | ᵇ | ᵇ | – | add or replace | +| `withComparers` / `withKeyComparer` | | ᵇ | ᵇ | – | – | | +| **Folding** | | | | | | | +| `fold` / `foldBack` / `iter` | | ✓ | ✓ | ✓ | ✓ | | +| **Partitioning** | | | | | | | +| `partition` | Ⓣ | ✓ | ✓ | ✓ | ✓ | | +| **Conversion** | | | | | | | +| `toArray` / `toList` / `toSeq` | Ⓣ | ✓ | ✓ | ✓ | ✓ | `struct ('Key * 'Value)` | + +`Option.*`: `change`, `choose`, `pick`, `tryFind`, `tryFindKey`, `tryGetKey`, `tryPick`. `Tuple.*`: `addRange`, `ofArray`, +`ofList`, `ofSeq`, `ofSeqWithKeyComparer`, `partition`, `toArray`, `toList`, `toSeq` (+ `maxKeyValue`, `minKeyValue` for +`SortedMap`). + +## Appendix C – sets + +`HashSet` (`ImmutableHashSet`), `SortedSet` (`ImmutableSortedSet`), `ISet` (`IImmutableSet`), `FrozenSet` (`FrozenSet`). + +| Function | Shape | HashSet | SortedSet | ISet | FrozenSet | Note | +|---|---|---|---|---|---|---| +| **Creation** | | | | | | | +| `empty` / `emptyWithComparer` | | ✓/ᵇ | ✓/ᵇ | – | ✓/– | | +| `ofArray` / `ofList` / `ofSeq` | | ✓ | ✓ | – | ✓ | | +| `ofSeqWithComparer` | | ᵇ | ᵇ | – | ᵇ | | +| `singleton` | | ✓ | ✓ | – | ✓ | | +| **Builder** | | | | | | | +| `builder` / `builderWithComparer` / `ofBuilder` / `toBuilder` | | ᵇ | ᵇ | – | – | | +| **Properties** | | | | | | | +| `comparer` | | ᵇ | ᵇ | – | ᵇ | | +| `count` / `isEmpty` | | ✓ | ✓ | ✓ | ✓ | | +| **Access** | | | | | | | +| `item` / `tryItem` / `indexOf` | Ⓥ (`tryItem`) | – | ᵇ | – | – | sorted only | +| `maxElement` / `minElement` | | – | ✓ | – | – | sorted only | +| `tryGetValue` | Ⓥ | ᵇ | ᵇ | ᵇ | ᵇ | | +| **Search** | | | | | | | +| `contains` / `exists` / `forall` | | ✓ | ✓ | ✓ | ✓ | | +| `isProperSubset` / `isProperSuperset` / `isSubset` / `isSuperset` | | ✓ | ✓ | ✓ | ✓ | `isSubset set1 set2` = set1 ⊆ set2 | +| `overlaps` / `setEquals` | | ᵇ | ᵇ | ᵇ | ᵇ | | +| **Transformation** | | | | | | | +| `add` / `remove` / `clear` | | ✓ | ✓ | ✓ | – | | +| `choose` | Ⓥ | ᵃ | ᵃ | ᵃ | ᵃ | | +| `difference` / `intersect` / `union` | | ✓ | ✓ | ✓ | ✓ | FSharp.Core argument order | +| `filter` / `map` | | ✓ | ✓ | ✓ | ✓ | comparer kept where the element type allows | +| `intersectMany` / `unionMany` | | ✓ | ✓ | ✓ | ✓ | | +| `rev` | | – | ᵇ | – | – | | +| `symmetricDifference` | | ᵇ | ᵇ | ᵇ | ᵃ | | +| `withComparer` | | ᵇ | ᵇ | – | – | | +| **Folding** | | | | | | | +| `fold` / `foldBack` / `iter` | | ✓ | ✓ | ✓ | ✓ | | +| **Partitioning** | | | | | | | +| `partition` / `partitionWith` | Ⓣ | ✓ | ✓ | ✓ | ✓ | | +| **Conversion** | | | | | | | +| `toArray` / `toList` / `toSeq` | | ✓ | ✓ | ✓ | ✓ | | +| `toFlatList` | | – | – | – | ᵇ | O(1) via `Items` | + +`Option.*`: `choose`, `tryGetValue` (+ `tryItem` for `SortedSet`). `Tuple.*`: `partition`, `partitionWith`. From 5335ad147362e085d750b4c2fd4075d2cff66f8d Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 11 Oct 2026 01:43:16 +0200 Subject: [PATCH 02/12] docs: add `FlatList.Parallel` and the Frozen alternate lookup to ADR 0001 Moves both items from follow-ups into the decision: a nested `FlatList.Parallel` module mirroring `Array.Parallel` through the zero-copy bridge, delivered as its own pull request after `FlatList`, and `alternateLookup`/`tryAlternateLookup` on `FrozenMap`/`FrozenSet` under `#if NET10_0_OR_GREATER`, delivered inside the Frozen pull requests. The appendices gain the matching matrix rows. Co-Authored-By: Claude Fable 5.1 --- .../ADR_0001_Collection_Modules.md | 67 ++++++++++++++++--- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/docsSrc/Explanations/ADR_0001_Collection_Modules.md b/docsSrc/Explanations/ADR_0001_Collection_Modules.md index d58c3b14..006e832f 100644 --- a/docsSrc/Explanations/ADR_0001_Collection_Modules.md +++ b/docsSrc/Explanations/ADR_0001_Collection_Modules.md @@ -93,8 +93,8 @@ Maps and sets measure their size with `count` (FSharp.Core `Map.count`/`Set.coun * The conversion rules are about representation, not about FSharp.Core having the function. Where FSharp.Core has it (`tryFind`, `tryHead`, `choose`, `pick`, `unfold`, `zip`, `partition`, `Map.tryFind`, `Map.change`, …), opening both namespaces with `.Option` last yields exactly its signature. Extras without a counterpart (`tryTail`, `uncons`, - `tryUncons`, `tryGetKey`, `tryGetValue`, map and set `choose`, `SortedSet.tryItem`) get the same representation, which is - the one FSharp.Core would use, but there is no FSharp.Core signature to match. + `tryUncons`, `tryGetKey`, `tryGetValue`, `tryAlternateLookup`, map and set `choose`, `SortedSet.tryItem`) get the same + representation, which is the one FSharp.Core would use, but there is no FSharp.Core signature to match. This relies on F# name resolution, verified in `src/Compiler/Checking/NameResolution.fs` of the compiler: opened modules are prepended to the environment (most recently opened first), the resolver takes the first module that contains the member and @@ -175,8 +175,8 @@ F# `Set` is. ### D6. Exceptions mirror FSharp.Core Each function validates its arguments as the FSharp.Core function of the mirrored module does for the same input, and -throws the same exception type with the same message. The mirrored module is `Array` for `FlatList`, -`List` for `IndexedList`, `Queue`, `Stack` and their interface modules, `Map` for the map modules and +throws the same exception type with the same message. The mirrored module is `Array` for `FlatList`, `Array.Parallel` for +`FlatList.Parallel`, `List` for `IndexedList`, `Queue`, `Stack` and their interface modules, `Map` for the map modules and `Set` for the set modules. The rules differ per operation and per module (FSharp.Core 10, measured): | Input | `Array` | `List` | @@ -230,17 +230,48 @@ after conversion. ### D9. Delivery: one pull request per collection, stacked `chore/foundation` (helper module, order check script, test categories and contract bases, changelog and documentation -skeleton, target frameworks), then `feat/flat-list`, `feat/indexed-list`, `feat/queue`, `feat/stack`, `feat/hash-map` (with -`IMap`), `feat/sorted-map`, `feat/hash-set` (with `ISet`), `feat/sorted-set`, `feat/frozen-map`, `feat/frozen-set`, and an -optional `chore/benchmarks` (BenchmarkDotNet, excluded from the solution filter). Each pull request deletes the legacy file +skeleton, target frameworks), then `feat/flat-list`, `feat/flat-list-parallel` (the nested `Parallel` module of D11), +`feat/indexed-list`, `feat/queue`, `feat/stack`, `feat/hash-map` (with `IMap`), `feat/sorted-map`, `feat/hash-set` (with +`ISet`), `feat/sorted-set`, `feat/frozen-map`, `feat/frozen-set` (both with the alternate lookup of D11), and an optional +`chore/benchmarks` (BenchmarkDotNet, excluded from the solution filter). Each pull request deletes the legacy file it replaces, keeps the build green and updates the changelog and the documentation table. No release is cut mid-chain. ### D10. Target frameworks `net8.0;net10.0`. The published package already targeted `net8.0` only; `netstandard2.0` is not added (the LINQ surface used in D5 would need conditional fallbacks); `net9.0` is not added (short-term support, ended 2026-05; the `net8.0` asset -serves it). `net10.0` enables `#if NET10_0_OR_GREATER` paths for newer LINQ members and, as a follow-up, the Frozen -alternate lookup. +serves it). `net10.0` enables `#if NET10_0_OR_GREATER` paths for newer LINQ members and the Frozen alternate lookup (D11). + +### D11. `FlatList.Parallel` and the Frozen alternate lookup + +`FlatList` gets a nested module `Parallel` that mirrors `Array.Parallel` through the zero-copy bridge of D5: every function +unwraps the array, calls `Array.Parallel.*` and wraps the fresh result (`map`, `mapi`, `choose`, `collect`, `filter`, `init`, +`iter`, `iteri`, `zip`, `groupBy`, `partition`, `partitionWith`, `sort`, `sortBy`, `sortByDescending`, `sortDescending`, +`sortWith`, `reduce`, `reduceBy`, `sum`, `sumBy`, `average`, `averageBy`, `max`, `maxBy`, `min`, `minBy`, `exists`, `forall`, +`tryFind`, `tryFindIndex`, `tryPick`); the three `sortInPlace*` functions are omitted because they mutate. The nested module +lives in `FlatList.fsi`/`FlatList.fs` (as `Array.Parallel` lives in `array.fs`) with its own category banners, and has its +`Option` and `Tuple` counterparts (`choose`, `tryFind`, `tryFindIndex`, `tryPick`; `groupBy`, `partition`, `partitionWith`, +`zip`). Its tests compare every function with the sequential `FlatList` counterpart on the same inputs. + +`FrozenMap` and `FrozenSet` expose `alternateLookup<'Alternate>` (`GetAlternateLookup`) and `tryAlternateLookup<'Alternate>` +(`TryGetAlternateLookup`, returning `voption`) under `#if NET10_0_OR_GREATER`, because the API exists only in the `net9.0` +and later assets of the package. They return the BCL `AlternateLookup<'Alternate>` struct, whose `ContainsKey`, `TryGetValue` +and indexer (map) or `Contains` and `TryGetValue` (set) do the lookups; no `findAlternate`-style wrappers over those members +are added. The comparer must implement `IAlternateEqualityComparer<'Alternate, 'Key>`; `tryAlternateLookup` returns +`ValueNone` otherwise. The tests for these functions compile only for `net10.0`. + +The wrappers cannot serve `ReadOnlySpan`, the alternate key type that string keys use with `StringComparer.Ordinal`. +Since F# 9, F# can instantiate generic parameters that C# declares with the `allows ref struct` anti-constraint, so +`map.GetAlternateLookup> ()` and the members of the returned struct work when called directly. F# cannot +declare that anti-constraint on its own type parameters, though: the +[F# byrefs documentation](https://learn.microsoft.com/dotnet/fsharp/language-reference/byrefs) states that generic code +supporting byref-like types cannot be authored in F#, and [dotnet/fsharp#16800](https://github.com/dotnet/fsharp/issues/16800) +limited the F# 9 support to consumption. An F# wrapper instantiated with `ReadOnlySpan` therefore fails with FS0412 +("A type instantiation involves a byref type"). +[fslang-suggestions#1476](https://github.com/fsharp/fslang-suggestions/issues/1476) and RFC FS-1356 propose that F# declares +the anti-constraint. When that ships, the wrappers declare `'Alternate : allows ref struct` and accept spans. Until then, the +documentation of both functions points span users to the BCL methods, and the wrappers serve alternate key types that are +not byref-like, such as an `int` alternate for `int64` keys with a custom comparer. ## Consequences @@ -390,6 +421,12 @@ their element-type-changing transformations return the concrete type. | **Type-specific** | | | | | | | | | | | `clear` | | – | – | ᵇ | – | ᵇ | – | ᵇ | keeps the implementation | | `isDefault` / `isDefaultOrEmpty` | | ᵇ | – | – | – | – | – | – | accept a default `FlatList` | +| **Parallel** (nested module `FlatList.Parallel`, mirrors `Array.Parallel`; same category order inside) | | | | | | | | | | +| `average` / `averageBy` / `max` / `maxBy` / `min` / `minBy` / `reduce` / `reduceBy` / `sum` / `sumBy` | | ✓ | – | – | – | – | – | – | Folding | +| `choose` / `collect` / `filter` / `init` / `iter` / `iteri` / `map` / `mapi` / `zip` | Ⓥ (`choose`), Ⓣ (`zip`) | ✓ | – | – | – | – | – | – | Transformation (`init`: Creation) | +| `exists` / `forall` / `tryFind` / `tryFindIndex` / `tryPick` | Ⓥ (`try*`) | ✓ | – | – | – | – | – | – | Search | +| `groupBy` / `partition` / `partitionWith` | Ⓣ | ✓ | – | – | – | – | – | – | Partitioning and grouping | +| `sort` / `sortBy` / `sortByDescending` / `sortDescending` / `sortWith` | | ✓ | – | – | – | – | – | – | Sorting, unstable like `Array.Parallel.sort*`; `sortInPlace*` omitted | `Option.*`: `choose`, `pick`, `tryExactlyOne`, `tryFind`, `tryFindBack`, `tryFindIndex`, `tryFindIndexBack`, `tryHead`, `tryItem`, `tryLast`, `tryPick`, `tryTail`, `tryUncons`, `unfold`. @@ -443,8 +480,12 @@ their element-type-changing transformations return the concrete type. | `partition` | Ⓣ | ✓ | ✓ | ✓ | ✓ | | | **Conversion** | | | | | | | | `toArray` / `toList` / `toSeq` | Ⓣ | ✓ | ✓ | ✓ | ✓ | `struct ('Key * 'Value)` | +| **Type-specific** | | | | | | | +| `alternateLookup<'Alternate>` | | – | – | – | ᵇ | `GetAlternateLookup`; `#if NET10_0_OR_GREATER` | +| `tryAlternateLookup<'Alternate>` | Ⓥ | – | – | – | ᵇ | `TryGetAlternateLookup`; `#if NET10_0_OR_GREATER` | -`Option.*`: `change`, `choose`, `pick`, `tryFind`, `tryFindKey`, `tryGetKey`, `tryPick`. `Tuple.*`: `addRange`, `ofArray`, +`Option.*`: `change`, `choose`, `pick`, `tryFind`, `tryFindKey`, `tryGetKey`, `tryPick` (+ `tryAlternateLookup` for +`FrozenMap`, `net10.0` only). `Tuple.*`: `addRange`, `ofArray`, `ofList`, `ofSeq`, `ofSeqWithKeyComparer`, `partition`, `toArray`, `toList`, `toSeq` (+ `maxKeyValue`, `minKeyValue` for `SortedMap`). @@ -488,5 +529,9 @@ their element-type-changing transformations return the concrete type. | **Conversion** | | | | | | | | `toArray` / `toList` / `toSeq` | | ✓ | ✓ | ✓ | ✓ | | | `toFlatList` | | – | – | – | ᵇ | O(1) via `Items` | +| **Type-specific** | | | | | | | +| `alternateLookup<'Alternate>` | | – | – | – | ᵇ | `GetAlternateLookup`; `#if NET10_0_OR_GREATER` | +| `tryAlternateLookup<'Alternate>` | Ⓥ | – | – | – | ᵇ | `TryGetAlternateLookup`; `#if NET10_0_OR_GREATER` | -`Option.*`: `choose`, `tryGetValue` (+ `tryItem` for `SortedSet`). `Tuple.*`: `partition`, `partitionWith`. +`Option.*`: `choose`, `tryGetValue` (+ `tryItem` for `SortedSet`, `tryAlternateLookup` for `FrozenSet`, `net10.0` only). +`Tuple.*`: `partition`, `partitionWith`. From f141cd16aa1527b505827ea98db7621dd25d8f11 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 18 Oct 2026 00:44:17 +0200 Subject: [PATCH 03/12] docs: align ADR 0001 with ADR 0002 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `IndexedList`, `Queue`, `Stack` and their interface modules get the struct partial patterns `Cons` and `Nil` instead of the multi-case `(|Cons|Nil|)`, which allocates a `Choice` per match (ADR 0002 §5). - Sorts avoid culture-sensitive comparers such as `Comparer.Default`; `Comparer<'T>.Default` stays usable where it agrees with F# comparison, as ADR 0002 §3 uses it. - Operation test categories come from `nameof`, never from string literals; a contract base takes the name from any module. - `EquatableArray` is named in D1, in the computation expressions, in the pull request chain as `feat/equatable-array`, and in Appendix A; step 1 of ADR 0002 may ship from `main` before the chain. Co-Authored-By: Claude Opus 5.5 --- .../ADR_0001_Collection_Modules.md | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/docsSrc/Explanations/ADR_0001_Collection_Modules.md b/docsSrc/Explanations/ADR_0001_Collection_Modules.md index 006e832f..946cdb3d 100644 --- a/docsSrc/Explanations/ADR_0001_Collection_Modules.md +++ b/docsSrc/Explanations/ADR_0001_Collection_Modules.md @@ -50,13 +50,16 @@ New collections: `FrozenMap` (`FrozenDictionary<'Key, 'Value>`) and `FrozenSet` creation, lookup, iteration, folds, conversions **and** whole-collection transformations (`map`, `filter`, `choose`, `partition`, set algebra), each documented as an O(n) rebuild. They offer no incremental mutation (`add`, `remove`, `setItem`, `change`, `clear`, `addRange`, `removeRange`): a frozen collection is built once and read many times. +ADR 0002 – EquatableArray (`ADR_0002_EquatableArray.md`) adds `EquatableArray<'T>`, an immutable array with value +semantics, whose module mirrors `FlatList`. Curated extras kept beyond FSharp.Core: `indexOf`/`indexOfWith`/`lastIndexOf`/`lastIndexOfWith`, `binarySearch`, `sortWithComparer`, `isDefault`/`isDefaultOrEmpty`, `asSpan`/`asMemory`/`ofSpan`/`ofReadOnlySpan`, the builder family (`builder`, `builderWith`, `ofBuilder`, `toBuilder`, `moveFromBuilder`, `build`, `update`), `blit`, `add`, -`cons`/`uncons`/`tryUncons`/`(|Cons|Nil|)`, `tryTail`, `enqueue`/`enqueueMany`, `consMany`, `takeLast`/`skipLast`, `clear` -on interface modules, `tryGetKey`/`tryGetValue`, `setItem`/`setItems`/`addRange`/`removeRange`, `containsValue`, -`symmetricDifference`, `overlaps`/`setEquals`, comparer accessors and `with*Comparer` functions. +`cons`/`uncons`/`tryUncons`, the struct partial patterns `(|Cons|_|)`/`(|Nil|_|)`, `tryTail`, `enqueue`/`enqueueMany`, +`consMany`, `takeLast`/`skipLast`, `clear` on interface modules, `tryGetKey`/`tryGetValue`, +`setItem`/`setItems`/`addRange`/`removeRange`, `containsValue`, `symmetricDifference`, `overlaps`/`setEquals`, comparer +accessors and `with*Comparer` functions. Removed from the started branch: `iter3`/`iteri3`/`exists3`/`forall3`/`mapi3`/`foldBack3`, `foldi`/`foldi2`/`foldBacki`/ `foldBacki2`, `findDup`/`findDupBy`, `pickBack`/`tryPickBack`/`chooseBack`, the `From`/`Range` variants of `index*`/ @@ -76,7 +79,7 @@ Names: `IMap` module and `IMap<'Key, 'Value>` abbreviation for `IImmutableDictio Maps and sets measure their size with `count` (FSharp.Core `Map.count`/`Set.count`), sequences with `length`. `Queue` keeps `enqueue`/`head`/`tail`/`uncons` (compiled `Enqueue`/`Peek`/`Dequeue`/`PeekDequeue`), `Stack` keeps `cons`/`head`/`tail`/ `uncons` (`Push`/`Peek`/`Pop`/`PeekPop`). Computation expression instances: `flatList`, `indexedList`, `queue`, `stack`, -`hashMap`, `sortedMap`, `hashSet`, `sortedSet`, `frozenMap`, `frozenSet`. +`hashMap`, `sortedMap`, `hashSet`, `sortedSet`, `frozenMap`, `frozenSet`, and `equatableArray` of ADR 0002. ### D3. Value-type defaults and the `Option`/`Tuple` override namespaces @@ -147,8 +150,9 @@ FSharp.Core module that a collection mirrors: `FlatList` and `FlatList.Parallel` `Array.Parallel.sort*` are documented ("This is not a stable sort"), through `ImmutableArray.Sort` or `Array.sort*` on the bridge; `IndexedList`, `Queue`, `Stack` and their interface modules sort stably, as `List.sort*` and `Seq.sort*` are documented, through LINQ `OrderBy`/`OrderByDescending` (documented stable), not through the unstable `ImmutableList.Sort`. -Every sort compares with F# structural comparison (`LanguagePrimitives.FastGenericComparer<'T>`, the comparer behind -`compare`), never with `Comparer<'T>.Default`, which compares strings culture-sensitively. +Every sort compares in the order of F# generic comparison (`LanguagePrimitives.FastGenericComparer<'T>`, the comparer +behind `compare`), never with a culture-sensitive comparer such as `Comparer.Default`. ADR 0002 §3 shows the +effect on strings and uses the same order for the elements of `EquatableArray`. Consequences for the types: `FlatList` range operations (`take`, `skip`, `truncate`, `splitAt`, `takeLast`, `skipLast`, `chunkBySize`, `windowed`) copy the selected elements. An `ImmutableArray` is a single array reference without an offset or a @@ -168,7 +172,7 @@ and the element comparer of a set are kept by `filter`, `partition` and set alge element type, so their result gets the default comparer of the new type. The interface modules cannot read the comparers of their input, so their type-changing transformations return concrete collections with default comparers. The defaults, which creation functions without a comparer argument also use, are `EqualityComparer<'T>.Default` for hash-based -collections and the order of F# generic comparison +collections, as ADR 0002 uses for equality, and the order of F# generic comparison (`LanguagePrimitives.FastGenericComparer<'T>`) for sorted collections. Thus `SortedSet` is ordinal by default, as F# `Set` is. @@ -223,18 +227,21 @@ observed enumeration order, so one test body serves FIFO, LIFO and indexed colle file. Expected exceptions come from the same oracle: the test calls the function of the mirrored FSharp.Core module (D6) on the same input and asserts the same type and message with `Assert.ThrowsExactly`; `ParamName` is checked against the ops record. Every assertion carries a message; categories come from `TestCategories.fs` attributes (collection level) and -`TestCategory` strings or `nameof` (operation level). Dedicated files per collection prove that opening the -`Option`/`Tuple` namespaces shadows the default functions and that the override functions are equivalent to the defaults -after conversion. +`TestCategory (nameof …)` (operation level), never from string literals. In a contract base, the function of any module +gives the name, for example `nameof FlatList.map`. Dedicated files per collection prove that opening the `Option`/`Tuple` +namespaces shadows the default functions and that the override functions are equivalent to the defaults after conversion. ### D9. Delivery: one pull request per collection, stacked `chore/foundation` (helper module, order check script, test categories and contract bases, changelog and documentation skeleton, target frameworks), then `feat/flat-list`, `feat/flat-list-parallel` (the nested `Parallel` module of D11), -`feat/indexed-list`, `feat/queue`, `feat/stack`, `feat/hash-map` (with `IMap`), `feat/sorted-map`, `feat/hash-set` (with -`ISet`), `feat/sorted-set`, `feat/frozen-map`, `feat/frozen-set` (both with the alternate lookup of D11), and an optional -`chore/benchmarks` (BenchmarkDotNet, excluded from the solution filter). Each pull request deletes the legacy file -it replaces, keeps the build green and updates the changelog and the documentation table. No release is cut mid-chain. +`feat/equatable-array` (step 2 of ADR 0002: the mirror of the `FlatList` module, its builder functions and +`equatableArray { }`), `feat/indexed-list`, `feat/queue`, `feat/stack`, `feat/hash-map` (with `IMap`), `feat/sorted-map`, +`feat/hash-set` (with `ISet`), `feat/sorted-set`, `feat/frozen-map`, `feat/frozen-set` (both with the alternate lookup of +D11), and an optional `chore/benchmarks` (BenchmarkDotNet, excluded from the solution filter). Each pull request deletes +the legacy file it replaces, keeps the build green and updates the changelog and the documentation table. No release is +cut mid-chain. +Step 1 of ADR 0002 (the `EquatableArray` type, its patterns and JSON converter) MAY ship from `main` before the chain. ### D10. Target frameworks @@ -351,7 +358,8 @@ their element-type-changing transformations return the concrete type. | `tryTail` | Ⓥ | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | | | `tryUncons` | Ⓥ+Ⓣ | – | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | `struct ('T * X<'T>) voption` | | `uncons` | Ⓣ | – | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | compiled `PeekDequeue`/`PeekPop` | -| `(\|Cons\|Nil\|)` | Ⓣ | – | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | | +| `(\|Cons\|_\|)` | Ⓥ+Ⓣ | – | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | `[]`, `struct ('T * X<'T>) voption`; a multi-case pattern allocates a `Choice` per match (ADR 0002 §5) | +| `(\|Nil\|_\|)` | | – | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | ᵃ | returns `bool` | | **Search** | | | | | | | | | | | `binarySearch` | | ᵇ | ᵇ | – | – | – | – | – | BCL bitwise-complement contract | | `contains` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | @@ -429,9 +437,12 @@ their element-type-changing transformations return the concrete type. | `sort` / `sortBy` / `sortByDescending` / `sortDescending` / `sortWith` | | ✓ | – | – | – | – | – | – | Sorting, unstable like `Array.Parallel.sort*`; `sortInPlace*` omitted | `Option.*`: `choose`, `pick`, `tryExactlyOne`, `tryFind`, `tryFindBack`, `tryFindIndex`, `tryFindIndexBack`, `tryHead`, -`tryItem`, `tryLast`, `tryPick`, `tryTail`, `tryUncons`, `unfold`. +`tryItem`, `tryLast`, `tryPick`, `tryTail`, `tryUncons`, `unfold`, `(|Cons|_|)`. `Tuple.*`: `allPairs`, `countBy`, `groupBy`, `indexed`, `mapFold`, `mapFoldBack`, `pairwise`, `partition`, `partitionWith`, -`splitAt`, `unfold`, `unzip`, `unzip3`, `zip`, `zip3`, `uncons`, `tryUncons`, `(|Cons|Nil|)` where present. +`splitAt`, `unfold`, `unzip`, `unzip3`, `zip`, `zip3`, `uncons`, `tryUncons`, `(|Cons|_|)` where present. + +`EquatableArray<'T>` of ADR 0002 mirrors the `FlatList` column, without `isDefault` and `isDefaultOrEmpty`, and adds +`ofImmutableArray`, `toImmutableArray` and `unsafeOfArray`. ## Appendix B – maps From e49a394fc32bda613e92ee678163dabcee76a929 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 18 Oct 2026 14:01:59 +0200 Subject: [PATCH 04/12] docs: root modules, `Comparable*` structs and map creation overloads in ADR 0001 The review of the HashMap pull request (#30) changes three parts of the design: - D2: a file with one module declares it as the root module of the file; the type abbreviations of a folder move to `XTypes.fsi`/`XTypes.fs`, because a root module cannot declare a type of its namespace. Modules carry no `[]`. - D12 (new): the comparers that a map or a set keeps come only through one `Comparable*` struct per collection, whose members are the creation functions with the comparers applied. It replaces the `*WithComparer(s)`, `*WithKeyComparer` and `with*Comparer(s)` functions. - D13 (new): `ofArray`, `ofList` and `ofSeq` of the maps are static extension members in an `[]` `XExtensions` module, overloaded for `KeyValuePair`, reference tuples and struct tuples. `ofKeyValuePairs` and the `Tuple` creation functions go; `setItems` takes struct tuples like `addRange`. The ADR also records what the implementation found: the explicit type parameters of the alternate lookups (FS0687), the `comparison` constraint and the cached default comparer of the sorted collections, the O(n) `maxKeyValue` of `SortedMap`, and why `keys`/`values` return different types. Co-Authored-By: Claude Opus 5.5 --- .../ADR_0001_Collection_Modules.md | 202 ++++++++++++++---- 1 file changed, 164 insertions(+), 38 deletions(-) diff --git a/docsSrc/Explanations/ADR_0001_Collection_Modules.md b/docsSrc/Explanations/ADR_0001_Collection_Modules.md index 946cdb3d..b3961ba3 100644 --- a/docsSrc/Explanations/ADR_0001_Collection_Modules.md +++ b/docsSrc/Explanations/ADR_0001_Collection_Modules.md @@ -59,7 +59,8 @@ Curated extras kept beyond FSharp.Core: `indexOf`/`indexOfWith`/`lastIndexOf`/`l `cons`/`uncons`/`tryUncons`, the struct partial patterns `(|Cons|_|)`/`(|Nil|_|)`, `tryTail`, `enqueue`/`enqueueMany`, `consMany`, `takeLast`/`skipLast`, `clear` on interface modules, `tryGetKey`/`tryGetValue`, `setItem`/`setItems`/`addRange`/`removeRange`, `containsValue`, `symmetricDifference`, `overlaps`/`setEquals`, comparer -accessors and `with*Comparer` functions. +accessors, the `Comparable*` structs that create collections with comparers (D12) and the overloads of map creation for +every binding shape (D13). Removed from the started branch: `iter3`/`iteri3`/`exists3`/`forall3`/`mapi3`/`foldBack3`, `foldi`/`foldi2`/`foldBacki`/ `foldBacki2`, `findDup`/`findDupBy`, `pickBack`/`tryPickBack`/`chooseBack`, the `From`/`Range` variants of `index*`/ @@ -70,7 +71,23 @@ and the modules `Seq` (cross conversions) and `IndexedSeq` (`IReadOnlyList`). ### D2. Layout One folder per collection; every implementation file with public API has a signature file that carries the XML -documentation. Per folder, in compile order: `X.fsi`, `X.fs`, `XOption.fsi`, `XOption.fs`, `XTuple.fsi`, `XTuple.fs`, +documentation. + +**Root modules.** A source file that holds exactly one module declares it as the root module of the file, instead of a +namespace with one nested module: `[] module FSharp.Collections.Immutable.FlatList`, +`module FSharp.Collections.Immutable.Option.FlatList`, `module FSharp.Collections.Immutable.FlatListBuilder`, +`[] module FSharp.Collections.Immutable.HashMapExtensions`. The rule applies to the library and the tests (the ops +modules, `ExceptionAssert`, `Inputs`). A nested module such as `FlatList.Parallel` (D11) stays inside its root module. A +file with several modules (`Helper.fs`) or with types only (the type abbreviations, the `Comparable*` structs of D12, the +computation expression classes, the test classes) declares the namespace. Modules compile under their F# names +(`FSharp.Collections.Immutable.FlatList`, `FSharp.Collections.Immutable.Option.FlatList`): F# ignores `[]` on +a module, so no module carries it. + +A root module cannot declare a type of its namespace: `type HashMap<…>` inside `module FSharp.Collections.Immutable.HashMap` +would be `HashMap.HashMap<…>`. The type abbreviations of a folder therefore have their own file, `XTypes.fsi`/`XTypes.fs`. + +Per folder, in compile order: `XTypes.fsi`, `XTypes.fs`, `X.fsi`, `X.fs`, `XOption.fsi`, `XOption.fs`, `XTuple.fsi`, +`XTuple.fs`, `ComparableX.fsi`, `ComparableX.fs` (maps and sets, D12), `XExtensions.fsi`, `XExtensions.fs` (maps, D13), `IX.fsi`, `IX.fs` (plus their `Option`/`Tuple` files), `XBuilder.fs` (`FlatList` and `IndexedList` only), `XCE.fs`. `Helper.fs` comes first and `ComputationExpressions.fs` last. Interface modules compile after the concrete type they return. @@ -79,14 +96,18 @@ Names: `IMap` module and `IMap<'Key, 'Value>` abbreviation for `IImmutableDictio Maps and sets measure their size with `count` (FSharp.Core `Map.count`/`Set.count`), sequences with `length`. `Queue` keeps `enqueue`/`head`/`tail`/`uncons` (compiled `Enqueue`/`Peek`/`Dequeue`/`PeekDequeue`), `Stack` keeps `cons`/`head`/`tail`/ `uncons` (`Push`/`Peek`/`Pop`/`PeekPop`). Computation expression instances: `flatList`, `indexedList`, `queue`, `stack`, -`hashMap`, `sortedMap`, `hashSet`, `sortedSet`, `frozenMap`, `frozenSet`, and `equatableArray` of ADR 0002. +`hashMap`, `sortedMap`, `hashSet`, `sortedSet`, `frozenMap`, `frozenSet`, and `equatableArray` of ADR 0002. The structs of +D12 are `ComparableHashMap`, `ComparableSortedMap`, `ComparableFrozenMap`, `ComparableHashSet`, `ComparableSortedSet` and +`ComparableFrozenSet`; the extension modules of D13 are `HashMapExtensions`, `SortedMapExtensions` and +`FrozenMapExtensions`. ### D3. Value-type defaults and the `Option`/`Tuple` override namespaces * Every `try*` function returns `voption`; `choose`, `pick`, `tryPick`, `unfold` and `change` take or return `voption`. * Every tuple in a signature is a struct tuple: `zip`, `zip3`, `unzip`, `unzip3`, `pairwise`, `indexed`, `allPairs`, `groupBy`, `countBy`, `partition`, `partitionWith`, `splitAt`, `mapFold`, `mapFoldBack`, `unfold`, `uncons`, map - `ofSeq`/`toSeq`/`addRange`/`minKeyValue`/`maxKeyValue`, and so on. + `toSeq`/`addRange`/`setItems`/`minKeyValue`/`maxKeyValue`, and so on. Map creation takes bindings in every shape + instead (D13), so it has no `Tuple` counterpart. * The namespace `FSharp.Collections.Immutable.Option` contains, per collection, a module with the same name holding every function whose signature mentions `voption`, converted to the FSharp.Core representation: `voption` becomes `option`, and a struct tuple inside an option payload becomes a reference tuple (`unfold : ('State -> ('T * 'State) option) -> …`). @@ -105,7 +126,8 @@ falls through otherwise (`CollectAtMostOneResult`), and module search precedes t `open FSharp.Collections.Immutable` and `open FSharp.Collections.Immutable.Option`, `FlatList.tryFind` returns an `option` while `FlatList.map` still resolves to the default module; `Option.map` keeps resolving to FSharp.Core; a module named `FrozenSet` coexists with the BCL static class `FrozenSet`, exactly like the existing `Stack`, `Queue` and `HashSet` modules -coexist with `System.Collections.Generic`. +coexist with `System.Collections.Generic`. The overloads of D13 depend on the same order: `HashMap.ofSeq` reaches the static +members of the type only because the module `HashMap` has no `ofSeq`. The override modules delegate to the default modules and convert (`ValueOption.toOption`, `ValueOption.ofOption`, struct pattern repacking). Per-element tuple producers (`zip`, `pairwise`, `indexed`, `allPairs`, `groupBy`, `countBy`) do not @@ -123,7 +145,8 @@ signature and the implementation file; each category is a banner comment. The ca `Random`, `Conversion`, `Type-specific`; an empty category is omitted, an active pattern is the last row of its category. A script `build/check-order.fsx` parses every `.fsi`, verifies the banner sequence and the alphabetical order, checks that the sibling `.fs` has the same banners and the same sequence of `[]` bindings, and runs as the FAKE target -`CheckOrder` before `DotnetBuild`. +`CheckOrder` before `DotnetBuild`. The members of the `Comparable*` structs (D12) and of the extension modules (D13) follow +the same category order; the script checks the values of modules only. ### D5. Implementation principle: BCL members and System.Linq first @@ -171,10 +194,13 @@ because the keys keep their type (`CreateRange(keyComparer, …)`, `CreateBuilde and the element comparer of a set are kept by `filter`, `partition` and set algebra. `map` and `choose` change the value or element type, so their result gets the default comparer of the new type. The interface modules cannot read the comparers of their input, so their type-changing transformations return concrete collections with default comparers. The defaults, -which creation functions without a comparer argument also use, are `EqualityComparer<'T>.Default` for hash-based -collections, as ADR 0002 uses for equality, and the order of F# generic comparison -(`LanguagePrimitives.FastGenericComparer<'T>`) for sorted collections. Thus `SortedSet` is ordinal by default, as -F# `Set` is. +which every module function and extension member that creates a collection uses (comparers come in through the +`Comparable*` structs of D12 only), are `EqualityComparer<'T>.Default` for hash-based collections, as ADR 0002 uses for +equality, and the order of F# generic comparison (`LanguagePrimitives.FastGenericComparer<'T>`) for sorted collections. +Thus `SortedSet` is ordinal by default, as F# `Set` is. The sorted modules therefore require +`'T : comparison` and `'Key : comparison` in the functions that create a collection, as `Set` and `Map` do. The default +sorted comparer is one cached instance per type: `FastGenericComparer` creates a new comparer per call for some types, such +as tuples, and the BCL takes its fast paths for equal comparers (`Union`, `WithComparer`) only for the same instance. ### D6. Exceptions mirror FSharp.Core @@ -211,8 +237,11 @@ One `CE` class per collection with the delayed-function design (`Buffer<'T `YieldFrom` (sequence, array, same collection, buffer), `Combine`, `Delay`, `For`, `While`, `Run`, `TryWith`, `TryFinally`, `Using`. Buffers are the BCL builders where they exist (`ImmutableArray.Builder` with `DrainToImmutable`, `ImmutableList.Builder`, dictionary and set builders) and a `ResizeArray` otherwise. Map expressions accept -`yield struct (key, value)` and `yield KeyValuePair`; map and set expression classes take an optional comparer. The -duplicate `FlatListBuilderCE` is dropped. +`yield struct (key, value)` and `yield KeyValuePair`. Map and set expression classes have a constructor without arguments, +for the default comparers, and one that takes the key or element comparer, as the `Comparable*` structs (D12) do. Their +instances are type functions, `[] let hashSet<'T> = HashSetCE<'T> ()`, because the class holds a +comparer of the element type; the sorted ones require `'T : comparison` for their default comparer (D5). The duplicate +`FlatListBuilderCE` is dropped. ### D8. Tests: shared contract suites @@ -221,8 +250,9 @@ class without `[]` (MSTEST0085/MSTEST0002) that takes an immutable re collection derives one closed `[]` per capability it supports, so inapplicable tests do not exist rather than being skipped. Facets: `SeqLike`, `SeqCreation`, `Indexed`, `StableSort` (every sequence-like module except `FlatList`, which sorts unstably, see D5); `Map`, `MapTransform`, `MapMutation`, `MapCreation`; `Set`, `SetTransform`, `SetMutation`, -`SetCreation`. Expectations are computed with FSharp.Core `List` over the collection's -observed enumeration order, so one test body serves FIFO, LIFO and indexed collections. Collection-specific behaviour +`SetCreation`. The creation facets run every binding shape of D13 and the `Comparable*` structs of D12. Expectations are +computed with FSharp.Core `List` over the collection's observed enumeration order, so one test body serves FIFO, LIFO and +indexed collections. Collection-specific behaviour (`isDefault`, spans, builders, `Stack.cons` direction, comparers, sorted-only functions, null inputs) lives in a per-collection file. Expected exceptions come from the same oracle: the test calls the function of the mirrored FSharp.Core module (D6) on the same input and asserts the same type and message with `Assert.ThrowsExactly`; `ParamName` is checked against the @@ -262,10 +292,13 @@ lives in `FlatList.fsi`/`FlatList.fs` (as `Array.Parallel` lives in `array.fs`) `FrozenMap` and `FrozenSet` expose `alternateLookup<'Alternate>` (`GetAlternateLookup`) and `tryAlternateLookup<'Alternate>` (`TryGetAlternateLookup`, returning `voption`) under `#if NET10_0_OR_GREATER`, because the API exists only in the `net9.0` -and later assets of the package. They return the BCL `AlternateLookup<'Alternate>` struct, whose `ContainsKey`, `TryGetValue` -and indexer (map) or `Contains` and `TryGetValue` (set) do the lookups; no `findAlternate`-style wrappers over those members -are added. The comparer must implement `IAlternateEqualityComparer<'Alternate, 'Key>`; `tryAlternateLookup` returns -`ValueNone` otherwise. The tests for these functions compile only for `net10.0`. +and later assets of the package. Their type parameters are `<'Alternate, 'Key, 'Value>` (map) and `<'Alternate, 'T>` (set), +`'Alternate` first: F# does not accept explicit type arguments for only some of the type parameters (FS0687), so a call +reads `FrozenMap.alternateLookup table`. They return the BCL `AlternateLookup<'Alternate>` struct, whose +`ContainsKey`, `TryGetValue` and indexer (map) or `Contains` and `TryGetValue` (set) do the lookups; no +`findAlternate`-style wrappers over those members are added. The comparer must implement +`IAlternateEqualityComparer<'Alternate, 'Key>`; `tryAlternateLookup` returns `ValueNone` otherwise. The tests for these +functions compile only for `net10.0`. The wrappers cannot serve `ReadOnlySpan`, the alternate key type that string keys use with `StringComparer.Ordinal`. Since F# 9, F# can instantiate generic parameters that C# declares with the `allows ref struct` anti-constraint, so @@ -280,22 +313,91 @@ the anti-constraint. When that ships, the wrappers declare `'Alternate : allows documentation of both functions points span users to the BCL methods, and the wrappers serve alternate key types that are not byref-like, such as an `int` alternate for `int64` keys with a custom comparer. +### D12. Comparers: one `Comparable*` struct per collection + +No module function takes the comparers that a map or a set keeps. Every collection that can be created with comparers gets +a struct, in its own file of the collection's folder, that holds the comparers and creates the collection with them. +Functions that use a comparer for one operation only, such as `sortWithComparer`, `indexOfWith` and `lastIndexOfWith`, +keep their comparer argument. + +| Struct | Constructors | Comparer properties | +|---|---|---| +| `ComparableHashMap<'Key, 'Value>` | `(keyComparer : IEqualityComparer<'Key>, valueComparer : IEqualityComparer<'Value>)`, `(keyComparer)` | `KeyComparer`, `ValueComparer` | +| `ComparableSortedMap<'Key, 'Value>` | `(keyComparer : IComparer<'Key>, valueComparer : IEqualityComparer<'Value>)`, `(keyComparer)` | `KeyComparer`, `ValueComparer` | +| `ComparableFrozenMap<'Key, 'Value>` | `(keyComparer : IEqualityComparer<'Key>)` | `KeyComparer` | +| `ComparableHashSet<'T>` | `(comparer : IEqualityComparer<'T>)` | `Comparer` | +| `ComparableSortedSet<'T>` | `(comparer : IComparer<'T>)` | `Comparer` | +| `ComparableFrozenSet<'T>` | `(comparer : IEqualityComparer<'T>)` | `Comparer` | + +A map constructor with the key comparer alone gives the values `EqualityComparer<'Value>.Default`; a frozen dictionary has +no value comparer, so `ComparableFrozenMap` has one constructor. + +The members are the creation functions of the module with the comparers applied, under the names of those functions: +`empty`, `singleton`, `ofArray`, `ofList` and `ofSeq` (for maps with the overloads of D13), `ofSeqBy` (maps) and `builder` +(all collections except the frozen ones, which have no builder). The member names stay lowercase, so that a call reads +like the module function that it stands for: `(ComparableHashMap StringComparer.OrdinalIgnoreCase).ofSeq pairs`. Their +compiled names are PascalCase (`[]`), as for module functions. The structs are +`[]`: they only carry comparers, which have no meaningful equality, and creating one +allocates nothing. + +The structs replace the comparer variants of the creation functions: `emptyWithComparers`, `emptyWithKeyComparer`, +`emptyWithComparer`, `ofSeqWithComparer`, `ofSeqWithKeyComparer`, `ofKeyValuePairsWithKeyComparer`, +`builderWithComparers`, `builderWithKeyComparer`, `builderWithComparer`, `withComparers`, `withKeyComparer` and +`withComparer`. `ofSeq` of a struct also takes an existing collection, because a map is a sequence of `KeyValuePair`s and a +set a sequence of elements, so it gives that collection other comparers. The comparer properties of the modules +(`keyComparer`, `valueComparer`, `comparer`) stay: they read the comparers of a collection. + +### D13. Map creation takes bindings in every shape + +A map binding comes in three shapes: `KeyValuePair<'Key, 'Value>`, which every .NET dictionary enumerates (`dict` and +`Map` included); the reference tuple `'Key * 'Value` of most F# code (`Map.toList`); and the struct tuple of D3. A module +function takes one shape. F# types a tuple literal that is written as the argument as struct tuples, but a list of reference +tuples that is bound before, or any sequence of `KeyValuePair`s, needs a conversion first. + +Each map folder therefore has an `[]` root module `XExtensions` in its own file. It extends the BCL type +(`ImmutableDictionary<'Key, 'Value>`, `ImmutableSortedDictionary<'Key, 'Value>`, `FrozenDictionary<'Key, 'Value>`) with +the static members `ofArray`, `ofList` and `ofSeq`, each overloaded for the three shapes. Through the type abbreviation, +`HashMap.ofSeq pairs` reaches them and overload resolution picks the shape; a pipeline `pairs |> HashMap.ofSeq` works too. +This was checked with lists, arrays, `dict` and an existing map. The rules that follow from F#: + +* The map modules have no `ofArray`, `ofList` or `ofSeq`: module search precedes type search (D3), so a module function + with the same name hides the extension members. +* Only functions with one argument get overloads, because F# does not overload curried members (FS0816). `addRange` and + `setItems`, which take the bindings and the map, stay module functions over struct tuples, with reference-tuple + counterparts in `Tuple.*`. +* `SortedMapExtensions` members declare type parameters of their own (`ofSeq<'K, 'V when 'K : comparison>`): F# generic + comparison needs `comparison`, and a member cannot add a constraint to the type parameters of the extended type + (FS0340). The extensions repeat the constraints of the BCL types (`'Key : not null`). +* Every overload has the semantics of `Map.ofSeq`: a later binding of a key replaces an earlier one. The extension members + are the members of the `Comparable*` struct of D12 with the default comparers, so each shape has one implementation. + `ofKeyValuePairs` disappears: it is the `KeyValuePair` overload of `ofSeq`. +* Overload resolution needs the shape. Inside generic code, such as `let build pairs = HashMap.ofSeq pairs`, the argument + needs a type annotation (FS0041). + +Static extension members are an F# feature: C# does not see them as members of the BCL types. C# callers use the members +of the `Comparable*` structs, whose compiled names are PascalCase, or the BCL itself. Sets need no extensions, because +their elements have one shape. + ## Consequences Breaking changes (major version): `try*` return `voption`; tuples become struct tuples; `choose`/`pick`/`tryPick`/`unfold`/ `change` callbacks use `voption`; `ImmutableList` module becomes `IndexedList`; `Seq`, `IndexedSeq`/`ReadOnlyList`, the -`ISet<'T>` abbreviation and the extras listed in D1 are removed; map and set `length` becomes `count`; map `filter`/`map` -follow FSharp.Core semantics; sorted maps and sets order by F# generic comparison unless a comparer is given; `Stack` +`ISet<'T>` abbreviation and the extras listed in D1 are removed; the comparer variants of the creation functions and +`ofKeyValuePairs` give way to the `Comparable*` structs (D12) and to the overloads of map creation, which C# does not see +(D13); map and set `length` becomes `count`; map `filter`/`map` follow FSharp.Core semantics; sorted maps and sets order +by F# generic comparison unless a comparer is given; `Stack` element order is defined (first element of `ofSeq` on top); `take`/`skip` throw on short input; `netstandard2.0` is not a target. The `Option` and `Tuple` namespaces give consumers a one-line migration path. Benefits: one vocabulary across ten modules, consistent with FSharp.Core 10.0.300; defects of the current map and set modules fixed by construction (comparer preservation, real `singleton`); fewer allocations (`voption`, struct tuples, span and memory views, zero-copy -bridge); `Frozen*` support; tests that cannot drift between collections; machine-checked function order. +bridge); `Frozen*` support; tests that cannot drift between collections; machine-checked function order; the comparers of a +collection in one place; maps built from bindings of any shape without a conversion. Costs: about 150 sequence functions × 7 modules plus maps, sets and frozen collections to implement and document; the override namespaces double the number of signature files; contributors must learn the category order (enforced by the -script, so mistakes fail fast). +script, so mistakes fail fast); every folder gets a types file, and the map and set folders a struct file, the map folders +also an extensions file; an overloaded map `ofSeq` needs a type annotation inside generic code. ## Alternatives considered @@ -311,6 +413,15 @@ script, so mistakes fail fast). when a flag is forgotten; rejected in favour of one base class per capability. * **`netstandard2.0`** – would keep .NET Framework consumers but requires conditional LINQ fallbacks; not a regression because the published package is `net8.0` only; deferred. +* **A comparer variant of every creation function** (`emptyWithComparers`, `emptyWithKeyComparer`, `ofSeqWithKeyComparer`, + `builderWithComparers`, `withComparers`, …), the first design of D12 – one more function per creation function and per + combination of comparers; replaced by one struct per collection that holds the comparers and offers every creation + function with them. +* **One module function per binding shape** (`ofSeq` over struct tuples, `ofKeyValuePairs`, `Tuple.HashMap.ofSeq` over + reference tuples), the first design of D13 – three names or a namespace switch for one operation; replaced by overloads + on the type, which module functions cannot have. +* **A namespace with one nested module in every file**, the first layout of D2 – one more level of indentation and the + type abbreviation next to the module; replaced by root modules and a types file per folder. ## Appendix A – sequence-like collections @@ -453,21 +564,18 @@ their element-type-changing transformations return the concrete type. |---|---|---|---|---|---|---| | **Creation** | | | | | | | | `empty` | | ✓ | ✓ | – | ✓ | | -| `emptyWithComparers` / `emptyWithKeyComparer` | | ᵇ | ᵇ | – | – | | -| `ofArray` / `ofList` / `ofSeq` | Ⓣ | ✓ | ✓ | – | ✓ | `seq` | -| `ofKeyValuePairs` / `ofKeyValuePairsWithKeyComparer` | | ᵇ | ᵇ | – | ᵇ | `seq` | +| `ofArray` / `ofList` / `ofSeq` | | ✓ | ✓ | – | ✓ | static extension members (D13), each overloaded for `KeyValuePair`, `'Key * 'Value` and `struct ('Key * 'Value)` | | `ofSeqBy` | | ᵇ | ᵇ | – | ᵇ | key selector | -| `ofSeqWithKeyComparer` | Ⓣ | ᵇ | ᵇ | – | ᵇ | | | `singleton` | | ✓ | ✓ | – | ✓ | curried `key value` | | **Builder** | | | | | | | -| `builder` / `builderWithComparers` / `builderWithKeyComparer` / `ofBuilder` / `toBuilder` | | ᵇ | ᵇ | – | – | | +| `builder` / `ofBuilder` / `toBuilder` | | ᵇ | ᵇ | – | – | | | **Properties** | | | | | | | | `count` / `isEmpty` | | ✓ | ✓ | ✓ | ✓ | | | `keyComparer` / `valueComparer` | | ᵇ | ᵇ | – | ᵇ/– | | -| `keys` / `values` | | ✓ | ✓ | ✓ | ✓ | Frozen → `FlatList` O(1) | +| `keys` / `values` | | ✓ | ✓ | ✓ | ✓ | the cheapest view without a copy: `ICollection<_>` as `Map.keys` (hash, sorted), `seq<_>` (`IMap`: the interface has only `IReadOnlyDictionary.Keys`), `FlatList` O(1) (frozen) | | **Access** | | | | | | | | `find` | | ✓ | ✓ | ✓ | ✓ | `KeyNotFoundException` | -| `maxKeyValue` / `minKeyValue` | Ⓣ | – | ✓ | – | – | sorted only | +| `maxKeyValue` / `minKeyValue` | Ⓣ | – | ✓ | – | – | sorted only; `maxKeyValue` is O(n), because `ImmutableSortedDictionary` enumerates forwards only | | `tryFind` | Ⓥ | ✓ | ✓ | ✓ | ✓ | | | `tryGetKey` | Ⓥ | ᵇ | ᵇ | ᵇ | – | | | **Search** | | | | | | | @@ -483,8 +591,7 @@ their element-type-changing transformations return the concrete type. | `filter` | | ✓ | ✓ | ✓ | ✓ | returns a map | | `map` | | ✓ | ✓ | ✓ | ✓ | keys kept | | `remove` / `removeRange` | | ✓/ᵇ | ✓/ᵇ | ✓/ᵇ | – | | -| `setItem` / `setItems` | | ᵇ | ᵇ | ᵇ | – | add or replace | -| `withComparers` / `withKeyComparer` | | ᵇ | ᵇ | – | – | | +| `setItem` / `setItems` | Ⓣ (`setItems`) | ᵇ | ᵇ | ᵇ | – | add or replace | | **Folding** | | | | | | | | `fold` / `foldBack` / `iter` | | ✓ | ✓ | ✓ | ✓ | | | **Partitioning** | | | | | | | @@ -496,9 +603,20 @@ their element-type-changing transformations return the concrete type. | `tryAlternateLookup<'Alternate>` | Ⓥ | – | – | – | ᵇ | `TryGetAlternateLookup`; `#if NET10_0_OR_GREATER` | `Option.*`: `change`, `choose`, `pick`, `tryFind`, `tryFindKey`, `tryGetKey`, `tryPick` (+ `tryAlternateLookup` for -`FrozenMap`, `net10.0` only). `Tuple.*`: `addRange`, `ofArray`, -`ofList`, `ofSeq`, `ofSeqWithKeyComparer`, `partition`, `toArray`, `toList`, `toSeq` (+ `maxKeyValue`, `minKeyValue` for -`SortedMap`). +`FrozenMap`, `net10.0` only). `Tuple.*`: `addRange`, `partition`, `setItems`, `toArray`, `toList`, `toSeq` (+ `maxKeyValue`, +`minKeyValue` for `SortedMap`). + +The `Comparable*` structs of D12: + +| Member | `ComparableHashMap` | `ComparableSortedMap` | `ComparableFrozenMap` | +|---|---|---|---| +| constructor with both comparers | ✓ | ✓ | – | +| constructor with the key comparer | ✓ | ✓ | ✓ | +| `KeyComparer` / `ValueComparer` | ✓ | ✓ | `KeyComparer` | +| `empty` / `singleton` | ✓ | ✓ | ✓ | +| `ofArray` / `ofList` / `ofSeq`, each in the three shapes of D13 | ✓ | ✓ | ✓ | +| `ofSeqBy` | ✓ | ✓ | ✓ | +| `builder` | ✓ | ✓ | – | ## Appendix C – sets @@ -507,12 +625,11 @@ their element-type-changing transformations return the concrete type. | Function | Shape | HashSet | SortedSet | ISet | FrozenSet | Note | |---|---|---|---|---|---|---| | **Creation** | | | | | | | -| `empty` / `emptyWithComparer` | | ✓/ᵇ | ✓/ᵇ | – | ✓/– | | +| `empty` | | ✓ | ✓ | – | ✓ | | | `ofArray` / `ofList` / `ofSeq` | | ✓ | ✓ | – | ✓ | | -| `ofSeqWithComparer` | | ᵇ | ᵇ | – | ᵇ | | | `singleton` | | ✓ | ✓ | – | ✓ | | | **Builder** | | | | | | | -| `builder` / `builderWithComparer` / `ofBuilder` / `toBuilder` | | ᵇ | ᵇ | – | – | | +| `builder` / `ofBuilder` / `toBuilder` | | ᵇ | ᵇ | – | – | | | **Properties** | | | | | | | | `comparer` | | ᵇ | ᵇ | – | ᵇ | | | `count` / `isEmpty` | | ✓ | ✓ | ✓ | ✓ | | @@ -532,7 +649,6 @@ their element-type-changing transformations return the concrete type. | `intersectMany` / `unionMany` | | ✓ | ✓ | ✓ | ✓ | | | `rev` | | – | ᵇ | – | – | | | `symmetricDifference` | | ᵇ | ᵇ | ᵇ | ᵃ | | -| `withComparer` | | ᵇ | ᵇ | – | – | | | **Folding** | | | | | | | | `fold` / `foldBack` / `iter` | | ✓ | ✓ | ✓ | ✓ | | | **Partitioning** | | | | | | | @@ -546,3 +662,13 @@ their element-type-changing transformations return the concrete type. `Option.*`: `choose`, `tryGetValue` (+ `tryItem` for `SortedSet`, `tryAlternateLookup` for `FrozenSet`, `net10.0` only). `Tuple.*`: `partition`, `partitionWith`. + +The `Comparable*` structs of D12: + +| Member | `ComparableHashSet` | `ComparableSortedSet` | `ComparableFrozenSet` | +|---|---|---|---| +| constructor with the comparer | `IEqualityComparer<'T>` | `IComparer<'T>` | `IEqualityComparer<'T>` | +| `Comparer` | ✓ | ✓ | ✓ | +| `empty` / `singleton` | ✓ | ✓ | ✓ | +| `ofArray` / `ofList` / `ofSeq` | ✓ | ✓ | ✓ | +| `builder` | ✓ | ✓ | – | From d81372c176bd55dfa53906f9dd2b38995472efef Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 18 Oct 2026 14:24:51 +0200 Subject: [PATCH 05/12] docs: test categories on the test classes only in ADR 0001 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D8 put an operation category on every test method through `TestCategory (nameof …)`. The test category rules forbid categories written as strings and put the attribute of the examined component on the class (#32), so the categories of the contract and collection tests are the `TestCategories.fs` attributes on the test classes, and test methods carry none. Co-Authored-By: Claude Opus 5.5 --- docsSrc/Explanations/ADR_0001_Collection_Modules.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docsSrc/Explanations/ADR_0001_Collection_Modules.md b/docsSrc/Explanations/ADR_0001_Collection_Modules.md index b3961ba3..ced0024c 100644 --- a/docsSrc/Explanations/ADR_0001_Collection_Modules.md +++ b/docsSrc/Explanations/ADR_0001_Collection_Modules.md @@ -256,10 +256,11 @@ indexed collections. Collection-specific behaviour (`isDefault`, spans, builders, `Stack.cons` direction, comparers, sorted-only functions, null inputs) lives in a per-collection file. Expected exceptions come from the same oracle: the test calls the function of the mirrored FSharp.Core module (D6) on the same input and asserts the same type and message with `Assert.ThrowsExactly`; `ParamName` is checked against the -ops record. Every assertion carries a message; categories come from `TestCategories.fs` attributes (collection level) and -`TestCategory (nameof …)` (operation level), never from string literals. In a contract base, the function of any module -gives the name, for example `nameof FlatList.map`. Dedicated files per collection prove that opening the `Option`/`Tuple` -namespaces shadows the default functions and that the override functions are equivalent to the defaults after conversion. +ops record. Every assertion carries a message. Categories are the `TestCategoryBaseAttribute` descendants of +`TestCategories.fs` on the test classes: the collection and the component or area that the class examines, such as +`[]`. They are never strings, and test methods carry none. Dedicated +files per collection prove that opening the `Option`/`Tuple` namespaces shadows the default functions and that the override +functions are equivalent to the defaults after conversion. ### D9. Delivery: one pull request per collection, stacked From 4c822eb0ae08e8e4c43caacc30f51accc6a74165 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 18 Oct 2026 14:56:41 +0200 Subject: [PATCH 06/12] docs: `CustomComparers*` structs, `for*Comparer*` functions and `KeyValuePair` map functions in ADR 0001 - D12: the structs that create collections with comparers are named after what they hold, `CustomComparers*` for maps and `CustomComparer*` for sets. The modules create them with `forComparers`/`forKeyComparer` (maps) and `forComparer` (sets), in the Creation category. - D13: `addRange` and `setItems` take `KeyValuePair`s, the shape of every dictionary. The nested submodules `Tuple` and `ValueTuple` of the map modules hold them for reference and struct tuples; after `open FSharp.Collections.Immutable.Tuple` the submodules still resolve. They leave the `Tuple` namespace. - D2: the types file of a folder is `XTypes.fs` without a signature file, because a signature file without an implementation file fails with FS0240. Co-Authored-By: Claude Opus 5.5 --- .../ADR_0001_Collection_Modules.md | 138 ++++++++++-------- 1 file changed, 77 insertions(+), 61 deletions(-) diff --git a/docsSrc/Explanations/ADR_0001_Collection_Modules.md b/docsSrc/Explanations/ADR_0001_Collection_Modules.md index ced0024c..65989af3 100644 --- a/docsSrc/Explanations/ADR_0001_Collection_Modules.md +++ b/docsSrc/Explanations/ADR_0001_Collection_Modules.md @@ -59,8 +59,9 @@ Curated extras kept beyond FSharp.Core: `indexOf`/`indexOfWith`/`lastIndexOf`/`l `cons`/`uncons`/`tryUncons`, the struct partial patterns `(|Cons|_|)`/`(|Nil|_|)`, `tryTail`, `enqueue`/`enqueueMany`, `consMany`, `takeLast`/`skipLast`, `clear` on interface modules, `tryGetKey`/`tryGetValue`, `setItem`/`setItems`/`addRange`/`removeRange`, `containsValue`, `symmetricDifference`, `overlaps`/`setEquals`, comparer -accessors, the `Comparable*` structs that create collections with comparers (D12) and the overloads of map creation for -every binding shape (D13). +accessors, the `CustomComparers*`/`CustomComparer*` structs that create collections with comparers and the +`forComparers`/`forKeyComparer`/`forComparer` functions that create the structs (D12), and the map functions for every +binding shape (D13). Removed from the started branch: `iter3`/`iteri3`/`exists3`/`forall3`/`mapi3`/`foldBack3`, `foldi`/`foldi2`/`foldBacki`/ `foldBacki2`, `findDup`/`findDupBy`, `pickBack`/`tryPickBack`/`chooseBack`, the `From`/`Range` variants of `index*`/ @@ -71,24 +72,28 @@ and the modules `Seq` (cross conversions) and `IndexedSeq` (`IReadOnlyList`). ### D2. Layout One folder per collection; every implementation file with public API has a signature file that carries the XML -documentation. +documentation, except the types file of a folder (below). **Root modules.** A source file that holds exactly one module declares it as the root module of the file, instead of a namespace with one nested module: `[] module FSharp.Collections.Immutable.FlatList`, `module FSharp.Collections.Immutable.Option.FlatList`, `module FSharp.Collections.Immutable.FlatListBuilder`, `[] module FSharp.Collections.Immutable.HashMapExtensions`. The rule applies to the library and the tests (the ops -modules, `ExceptionAssert`, `Inputs`). A nested module such as `FlatList.Parallel` (D11) stays inside its root module. A -file with several modules (`Helper.fs`) or with types only (the type abbreviations, the `Comparable*` structs of D12, the -computation expression classes, the test classes) declares the namespace. Modules compile under their F# names +modules, `ExceptionAssert`, `Inputs`). A nested module such as `FlatList.Parallel` (D11) or the `Tuple`/`ValueTuple` +submodules of the maps (D13) stays inside its root module. A file with several modules (`Helper.fs`) or with types only +(the type abbreviations, the structs of D12, the computation expression classes, the test classes) declares the +namespace. Modules compile under their F# names (`FSharp.Collections.Immutable.FlatList`, `FSharp.Collections.Immutable.Option.FlatList`): F# ignores `[]` on a module, so no module carries it. A root module cannot declare a type of its namespace: `type HashMap<…>` inside `module FSharp.Collections.Immutable.HashMap` -would be `HashMap.HashMap<…>`. The type abbreviations of a folder therefore have their own file, `XTypes.fsi`/`XTypes.fs`. - -Per folder, in compile order: `XTypes.fsi`, `XTypes.fs`, `X.fsi`, `X.fs`, `XOption.fsi`, `XOption.fs`, `XTuple.fsi`, -`XTuple.fs`, `ComparableX.fsi`, `ComparableX.fs` (maps and sets, D12), `XExtensions.fsi`, `XExtensions.fs` (maps, D13), -`IX.fsi`, `IX.fs` (plus their `Option`/`Tuple` files), `XBuilder.fs` (`FlatList` and `IndexedList` only), `XCE.fs`. +would be `HashMap.HashMap<…>`. The type abbreviations of a folder therefore have their own file, `XTypes.fs`, without a +signature file. A signature file alone does not compile: F# needs an implementation file for every signature file +(FS0240). A file that holds only type abbreviations is its own signature, so it carries their XML documentation itself. + +Per folder, in compile order: `XTypes.fs`, `X.fsi`, `X.fs`, `XOption.fsi`, `XOption.fs`, `XTuple.fsi`, `XTuple.fs`, +`CustomComparersX.fsi`/`.fs` (maps) or `CustomComparerX.fsi`/`.fs` (sets, D12), `XExtensions.fsi`, `XExtensions.fs` +(maps, D13), `IX.fsi`, `IX.fs` (plus their `Option`/`Tuple` files), `XBuilder.fs` (`FlatList` and `IndexedList` only), +`XCE.fs`. `Helper.fs` comes first and `ComputationExpressions.fs` last. Interface modules compile after the concrete type they return. Names: `IMap` module and `IMap<'Key, 'Value>` abbreviation for `IImmutableDictionary`; `ISet` module **without** an @@ -97,8 +102,9 @@ Maps and sets measure their size with `count` (FSharp.Core `Map.count`/`Set.coun `enqueue`/`head`/`tail`/`uncons` (compiled `Enqueue`/`Peek`/`Dequeue`/`PeekDequeue`), `Stack` keeps `cons`/`head`/`tail`/ `uncons` (`Push`/`Peek`/`Pop`/`PeekPop`). Computation expression instances: `flatList`, `indexedList`, `queue`, `stack`, `hashMap`, `sortedMap`, `hashSet`, `sortedSet`, `frozenMap`, `frozenSet`, and `equatableArray` of ADR 0002. The structs of -D12 are `ComparableHashMap`, `ComparableSortedMap`, `ComparableFrozenMap`, `ComparableHashSet`, `ComparableSortedSet` and -`ComparableFrozenSet`; the extension modules of D13 are `HashMapExtensions`, `SortedMapExtensions` and +D12 are `CustomComparersHashMap`, `CustomComparersSortedMap`, `CustomComparersFrozenMap`, `CustomComparerHashSet`, +`CustomComparerSortedSet` and `CustomComparerFrozenSet`: the prefix names what the struct holds, the comparers of a map or +the one comparer of a set. The extension modules of D13 are `HashMapExtensions`, `SortedMapExtensions` and `FrozenMapExtensions`. ### D3. Value-type defaults and the `Option`/`Tuple` override namespaces @@ -106,8 +112,9 @@ D12 are `ComparableHashMap`, `ComparableSortedMap`, `ComparableFrozenMap`, `Comp * Every `try*` function returns `voption`; `choose`, `pick`, `tryPick`, `unfold` and `change` take or return `voption`. * Every tuple in a signature is a struct tuple: `zip`, `zip3`, `unzip`, `unzip3`, `pairwise`, `indexed`, `allPairs`, `groupBy`, `countBy`, `partition`, `partitionWith`, `splitAt`, `mapFold`, `mapFoldBack`, `unfold`, `uncons`, map - `toSeq`/`addRange`/`setItems`/`minKeyValue`/`maxKeyValue`, and so on. Map creation takes bindings in every shape - instead (D13), so it has no `Tuple` counterpart. + `toSeq`/`minKeyValue`/`maxKeyValue`, and so on. The map functions that take bindings take them in every shape instead + (D13): creation through overloads, `addRange` and `setItems` as `KeyValuePair`s with `Tuple`/`ValueTuple` submodules. + They have no counterpart in the `Tuple` namespace. * The namespace `FSharp.Collections.Immutable.Option` contains, per collection, a module with the same name holding every function whose signature mentions `voption`, converted to the FSharp.Core representation: `voption` becomes `option`, and a struct tuple inside an option payload becomes a reference tuple (`unfold : ('State -> ('T * 'State) option) -> …`). @@ -127,7 +134,9 @@ falls through otherwise (`CollectAtMostOneResult`), and module search precedes t while `FlatList.map` still resolves to the default module; `Option.map` keeps resolving to FSharp.Core; a module named `FrozenSet` coexists with the BCL static class `FrozenSet`, exactly like the existing `Stack`, `Queue` and `HashSet` modules coexist with `System.Collections.Generic`. The overloads of D13 depend on the same order: `HashMap.ofSeq` reaches the static -members of the type only because the module `HashMap` has no `ofSeq`. +members of the type only because the module `HashMap` has no `ofSeq`. The submodules of D13 resolve the same way: after +`open FSharp.Collections.Immutable.Tuple`, `HashMap.Tuple.addRange` falls through the `HashMap` module of the `Tuple` +namespace, which has no `Tuple` module, to the submodule of the default module. The override modules delegate to the default modules and convert (`ValueOption.toOption`, `ValueOption.ofOption`, struct pattern repacking). Per-element tuple producers (`zip`, `pairwise`, `indexed`, `allPairs`, `groupBy`, `countBy`) do not @@ -145,8 +154,8 @@ signature and the implementation file; each category is a banner comment. The ca `Random`, `Conversion`, `Type-specific`; an empty category is omitted, an active pattern is the last row of its category. A script `build/check-order.fsx` parses every `.fsi`, verifies the banner sequence and the alphabetical order, checks that the sibling `.fs` has the same banners and the same sequence of `[]` bindings, and runs as the FAKE target -`CheckOrder` before `DotnetBuild`. The members of the `Comparable*` structs (D12) and of the extension modules (D13) follow -the same category order; the script checks the values of modules only. +`CheckOrder` before `DotnetBuild`. The members of the structs of D12 and of the extension modules of D13 follow the same +category order; the script checks the values of modules only. ### D5. Implementation principle: BCL members and System.Linq first @@ -194,8 +203,8 @@ because the keys keep their type (`CreateRange(keyComparer, …)`, `CreateBuilde and the element comparer of a set are kept by `filter`, `partition` and set algebra. `map` and `choose` change the value or element type, so their result gets the default comparer of the new type. The interface modules cannot read the comparers of their input, so their type-changing transformations return concrete collections with default comparers. The defaults, -which every module function and extension member that creates a collection uses (comparers come in through the -`Comparable*` structs of D12 only), are `EqualityComparer<'T>.Default` for hash-based collections, as ADR 0002 uses for +which every module function and extension member that creates a collection uses (comparers come in through the structs +of D12 only), are `EqualityComparer<'T>.Default` for hash-based collections, as ADR 0002 uses for equality, and the order of F# generic comparison (`LanguagePrimitives.FastGenericComparer<'T>`) for sorted collections. Thus `SortedSet` is ordinal by default, as F# `Set` is. The sorted modules therefore require `'T : comparison` and `'Key : comparison` in the functions that create a collection, as `Set` and `Map` do. The default @@ -238,7 +247,7 @@ One `CE` class per collection with the delayed-function design (`Buffer<'T `Using`. Buffers are the BCL builders where they exist (`ImmutableArray.Builder` with `DrainToImmutable`, `ImmutableList.Builder`, dictionary and set builders) and a `ResizeArray` otherwise. Map expressions accept `yield struct (key, value)` and `yield KeyValuePair`. Map and set expression classes have a constructor without arguments, -for the default comparers, and one that takes the key or element comparer, as the `Comparable*` structs (D12) do. Their +for the default comparers, and one that takes the key or element comparer, as the structs of D12 do. Their instances are type functions, `[] let hashSet<'T> = HashSetCE<'T> ()`, because the class holds a comparer of the element type; the sorted ones require `'T : comparison` for their default comparer (D5). The duplicate `FlatListBuilderCE` is dropped. @@ -250,9 +259,9 @@ class without `[]` (MSTEST0085/MSTEST0002) that takes an immutable re collection derives one closed `[]` per capability it supports, so inapplicable tests do not exist rather than being skipped. Facets: `SeqLike`, `SeqCreation`, `Indexed`, `StableSort` (every sequence-like module except `FlatList`, which sorts unstably, see D5); `Map`, `MapTransform`, `MapMutation`, `MapCreation`; `Set`, `SetTransform`, `SetMutation`, -`SetCreation`. The creation facets run every binding shape of D13 and the `Comparable*` structs of D12. Expectations are -computed with FSharp.Core `List` over the collection's observed enumeration order, so one test body serves FIFO, LIFO and -indexed collections. Collection-specific behaviour +`SetCreation`. The map facets run every binding shape of D13, and the creation facets also the structs of D12. +Expectations are computed with FSharp.Core `List` over the collection's observed enumeration order, so one test body serves +FIFO, LIFO and indexed collections. Collection-specific behaviour (`isDefault`, spans, builders, `Stack.cons` direction, comparers, sorted-only functions, null inputs) lives in a per-collection file. Expected exceptions come from the same oracle: the test calls the function of the mirrored FSharp.Core module (D6) on the same input and asserts the same type and message with `Assert.ThrowsExactly`; `ParamName` is checked against the @@ -314,32 +323,35 @@ the anti-constraint. When that ships, the wrappers declare `'Alternate : allows documentation of both functions points span users to the BCL methods, and the wrappers serve alternate key types that are not byref-like, such as an `int` alternate for `int64` keys with a custom comparer. -### D12. Comparers: one `Comparable*` struct per collection +### D12. Comparers: one `CustomComparers*` or `CustomComparer*` struct per collection -No module function takes the comparers that a map or a set keeps. Every collection that can be created with comparers gets -a struct, in its own file of the collection's folder, that holds the comparers and creates the collection with them. -Functions that use a comparer for one operation only, such as `sortWithComparer`, `indexOfWith` and `lastIndexOfWith`, -keep their comparer argument. +No module function creates a collection with comparers. Every collection that can be created with comparers gets a struct, +in its own file of the collection's folder, that holds the comparers and creates the collection with them. The prefix +names what the struct holds: `CustomComparers` for maps, `CustomComparer` for sets, which have one comparer. Functions that +use a comparer for one operation only, such as `sortWithComparer`, `indexOfWith` and `lastIndexOfWith`, keep their comparer +argument. -| Struct | Constructors | Comparer properties | -|---|---|---| -| `ComparableHashMap<'Key, 'Value>` | `(keyComparer : IEqualityComparer<'Key>, valueComparer : IEqualityComparer<'Value>)`, `(keyComparer)` | `KeyComparer`, `ValueComparer` | -| `ComparableSortedMap<'Key, 'Value>` | `(keyComparer : IComparer<'Key>, valueComparer : IEqualityComparer<'Value>)`, `(keyComparer)` | `KeyComparer`, `ValueComparer` | -| `ComparableFrozenMap<'Key, 'Value>` | `(keyComparer : IEqualityComparer<'Key>)` | `KeyComparer` | -| `ComparableHashSet<'T>` | `(comparer : IEqualityComparer<'T>)` | `Comparer` | -| `ComparableSortedSet<'T>` | `(comparer : IComparer<'T>)` | `Comparer` | -| `ComparableFrozenSet<'T>` | `(comparer : IEqualityComparer<'T>)` | `Comparer` | +| Struct | Constructors | Module functions that create it | Comparer properties | +|---|---|---|---| +| `CustomComparersHashMap<'Key, 'Value>` | `(keyComparer : IEqualityComparer<'Key>, valueComparer : IEqualityComparer<'Value>)`, `(keyComparer)` | `HashMap.forComparers`, `HashMap.forKeyComparer` | `KeyComparer`, `ValueComparer` | +| `CustomComparersSortedMap<'Key, 'Value>` | `(keyComparer : IComparer<'Key>, valueComparer : IEqualityComparer<'Value>)`, `(keyComparer)` | `SortedMap.forComparers`, `SortedMap.forKeyComparer` | `KeyComparer`, `ValueComparer` | +| `CustomComparersFrozenMap<'Key, 'Value>` | `(keyComparer : IEqualityComparer<'Key>)` | `FrozenMap.forKeyComparer` | `KeyComparer` | +| `CustomComparerHashSet<'T>` | `(comparer : IEqualityComparer<'T>)` | `HashSet.forComparer` | `Comparer` | +| `CustomComparerSortedSet<'T>` | `(comparer : IComparer<'T>)` | `SortedSet.forComparer` | `Comparer` | +| `CustomComparerFrozenSet<'T>` | `(comparer : IEqualityComparer<'T>)` | `FrozenSet.forComparer` | `Comparer` | A map constructor with the key comparer alone gives the values `EqualityComparer<'Value>.Default`; a frozen dictionary has -no value comparer, so `ComparableFrozenMap` has one constructor. - -The members are the creation functions of the module with the comparers applied, under the names of those functions: -`empty`, `singleton`, `ofArray`, `ofList` and `ofSeq` (for maps with the overloads of D13), `ofSeqBy` (maps) and `builder` -(all collections except the frozen ones, which have no builder). The member names stay lowercase, so that a call reads -like the module function that it stands for: `(ComparableHashMap StringComparer.OrdinalIgnoreCase).ofSeq pairs`. Their -compiled names are PascalCase (`[]`), as for module functions. The structs are -`[]`: they only carry comparers, which have no meaningful equality, and creating one -allocates nothing. +no value comparer, so `CustomComparersFrozenMap` has one constructor. The module functions are in the `Creation` category +and take the comparers in the order of the constructors: `forComparers keyComparer valueComparer` and +`forKeyComparer keyComparer` for maps, `forComparer comparer` for sets. A call reads as one phrase, +`(HashMap.forKeyComparer StringComparer.OrdinalIgnoreCase).ofSeq pairs`. + +The members of a struct are the creation functions of the module with the comparers applied, under the names of those +functions: `empty`, `singleton`, `ofArray`, `ofList` and `ofSeq` (for maps with the overloads of D13), `ofSeqBy` (maps) and +`builder` (all collections except the frozen ones, which have no builder). The member names stay lowercase, so that a call +reads like the module function that it stands for. Their compiled names are PascalCase (`[]`), as +for module functions. The structs are `[]`: they only carry comparers, which have no +meaningful equality, and creating one allocates nothing. The structs replace the comparer variants of the creation functions: `emptyWithComparers`, `emptyWithKeyComparer`, `emptyWithComparer`, `ofSeqWithComparer`, `ofSeqWithKeyComparer`, `ofKeyValuePairsWithKeyComparer`, @@ -348,7 +360,7 @@ The structs replace the comparer variants of the creation functions: `emptyWithC set a sequence of elements, so it gives that collection other comparers. The comparer properties of the modules (`keyComparer`, `valueComparer`, `comparer`) stay: they read the comparers of a collection. -### D13. Map creation takes bindings in every shape +### D13. Map functions take bindings in every shape A map binding comes in three shapes: `KeyValuePair<'Key, 'Value>`, which every .NET dictionary enumerates (`dict` and `Map` included); the reference tuple `'Key * 'Value` of most F# code (`Map.toList`); and the struct tuple of D3. A module @@ -364,27 +376,29 @@ This was checked with lists, arrays, `dict` and an existing map. The rules that * The map modules have no `ofArray`, `ofList` or `ofSeq`: module search precedes type search (D3), so a module function with the same name hides the extension members. * Only functions with one argument get overloads, because F# does not overload curried members (FS0816). `addRange` and - `setItems`, which take the bindings and the map, stay module functions over struct tuples, with reference-tuple - counterparts in `Tuple.*`. + `setItems` take the bindings and the map, so they stay module functions: the default takes `KeyValuePair`s, the shape + that every dictionary already has, and the nested submodules `Tuple` and `ValueTuple` of the module hold the same + functions for reference and struct tuples, `HashMap.Tuple.addRange pairs table` and + `HashMap.ValueTuple.setItems pairs table`. The interface module `IMap` has the same submodules. * `SortedMapExtensions` members declare type parameters of their own (`ofSeq<'K, 'V when 'K : comparison>`): F# generic comparison needs `comparison`, and a member cannot add a constraint to the type parameters of the extended type (FS0340). The extensions repeat the constraints of the BCL types (`'Key : not null`). * Every overload has the semantics of `Map.ofSeq`: a later binding of a key replaces an earlier one. The extension members - are the members of the `Comparable*` struct of D12 with the default comparers, so each shape has one implementation. + are the members of the struct of D12 with the default comparers, so each shape has one implementation. `ofKeyValuePairs` disappears: it is the `KeyValuePair` overload of `ofSeq`. * Overload resolution needs the shape. Inside generic code, such as `let build pairs = HashMap.ofSeq pairs`, the argument needs a type annotation (FS0041). Static extension members are an F# feature: C# does not see them as members of the BCL types. C# callers use the members -of the `Comparable*` structs, whose compiled names are PascalCase, or the BCL itself. Sets need no extensions, because -their elements have one shape. +of the structs of D12, whose compiled names are PascalCase, or the BCL itself. Sets need no extensions and no submodules, +because their elements have one shape. ## Consequences Breaking changes (major version): `try*` return `voption`; tuples become struct tuples; `choose`/`pick`/`tryPick`/`unfold`/ `change` callbacks use `voption`; `ImmutableList` module becomes `IndexedList`; `Seq`, `IndexedSeq`/`ReadOnlyList`, the `ISet<'T>` abbreviation and the extras listed in D1 are removed; the comparer variants of the creation functions and -`ofKeyValuePairs` give way to the `Comparable*` structs (D12) and to the overloads of map creation, which C# does not see +`ofKeyValuePairs` give way to the structs of D12 and to the overloads of map creation, which C# does not see (D13); map and set `length` becomes `count`; map `filter`/`map` follow FSharp.Core semantics; sorted maps and sets order by F# generic comparison unless a comparer is given; `Stack` element order is defined (first element of `ofSeq` on top); `take`/`skip` throw on short input; `netstandard2.0` is not a @@ -565,6 +579,7 @@ their element-type-changing transformations return the concrete type. |---|---|---|---|---|---|---| | **Creation** | | | | | | | | `empty` | | ✓ | ✓ | – | ✓ | | +| `forComparers` / `forKeyComparer` | | ᵃ | ᵃ | – | –/ᵃ | create the `CustomComparers*` struct of D12 | | `ofArray` / `ofList` / `ofSeq` | | ✓ | ✓ | – | ✓ | static extension members (D13), each overloaded for `KeyValuePair`, `'Key * 'Value` and `struct ('Key * 'Value)` | | `ofSeqBy` | | ᵇ | ᵇ | – | ᵇ | key selector | | `singleton` | | ✓ | ✓ | – | ✓ | curried `key value` | @@ -585,14 +600,14 @@ their element-type-changing transformations return the concrete type. | `findKey` / `tryFindKey` | Ⓥ | ✓ | ✓ | ✓ | ✓ | | | `pick` / `tryPick` | Ⓥ | ✓ | ✓ | ✓ | ✓ | | | **Transformation** | | | | | | | -| `add` / `addRange` | Ⓣ (`addRange`) | ✓/ᵇ | ✓/ᵇ | ✓/ᵇ | – | | +| `add` / `addRange` | | ✓/ᵇ | ✓/ᵇ | ✓/ᵇ | – | `addRange` takes `KeyValuePair`s; `Tuple.addRange` and `ValueTuple.addRange` in the submodules (D13) | | `change` | Ⓥ | ✓ | ✓ | ✓ | – | | | `choose` | Ⓥ | ᵃ | ᵃ | ᵃ | ᵃ | Frozen rebuild | | `clear` | | ᵇ | ᵇ | ᵇ | – | keeps comparers | | `filter` | | ✓ | ✓ | ✓ | ✓ | returns a map | | `map` | | ✓ | ✓ | ✓ | ✓ | keys kept | | `remove` / `removeRange` | | ✓/ᵇ | ✓/ᵇ | ✓/ᵇ | – | | -| `setItem` / `setItems` | Ⓣ (`setItems`) | ᵇ | ᵇ | ᵇ | – | add or replace | +| `setItem` / `setItems` | | ᵇ | ᵇ | ᵇ | – | add or replace; `setItems` takes `KeyValuePair`s, `Tuple.setItems` and `ValueTuple.setItems` in the submodules (D13) | | **Folding** | | | | | | | | `fold` / `foldBack` / `iter` | | ✓ | ✓ | ✓ | ✓ | | | **Partitioning** | | | | | | | @@ -604,12 +619,12 @@ their element-type-changing transformations return the concrete type. | `tryAlternateLookup<'Alternate>` | Ⓥ | – | – | – | ᵇ | `TryGetAlternateLookup`; `#if NET10_0_OR_GREATER` | `Option.*`: `change`, `choose`, `pick`, `tryFind`, `tryFindKey`, `tryGetKey`, `tryPick` (+ `tryAlternateLookup` for -`FrozenMap`, `net10.0` only). `Tuple.*`: `addRange`, `partition`, `setItems`, `toArray`, `toList`, `toSeq` (+ `maxKeyValue`, -`minKeyValue` for `SortedMap`). +`FrozenMap`, `net10.0` only). `Tuple.*`: `partition`, `toArray`, `toList`, `toSeq` (+ `maxKeyValue`, `minKeyValue` for +`SortedMap`). Submodules `Tuple` and `ValueTuple` (D13): `addRange`, `setItems` (`HashMap`, `SortedMap`, `IMap`). -The `Comparable*` structs of D12: +The structs of D12: -| Member | `ComparableHashMap` | `ComparableSortedMap` | `ComparableFrozenMap` | +| Member | `CustomComparersHashMap` | `CustomComparersSortedMap` | `CustomComparersFrozenMap` | |---|---|---|---| | constructor with both comparers | ✓ | ✓ | – | | constructor with the key comparer | ✓ | ✓ | ✓ | @@ -627,6 +642,7 @@ The `Comparable*` structs of D12: |---|---|---|---|---|---|---| | **Creation** | | | | | | | | `empty` | | ✓ | ✓ | – | ✓ | | +| `forComparer` | | ᵃ | ᵃ | – | ᵃ | creates the `CustomComparer*` struct of D12 | | `ofArray` / `ofList` / `ofSeq` | | ✓ | ✓ | – | ✓ | | | `singleton` | | ✓ | ✓ | – | ✓ | | | **Builder** | | | | | | | @@ -664,9 +680,9 @@ The `Comparable*` structs of D12: `Option.*`: `choose`, `tryGetValue` (+ `tryItem` for `SortedSet`, `tryAlternateLookup` for `FrozenSet`, `net10.0` only). `Tuple.*`: `partition`, `partitionWith`. -The `Comparable*` structs of D12: +The structs of D12: -| Member | `ComparableHashSet` | `ComparableSortedSet` | `ComparableFrozenSet` | +| Member | `CustomComparerHashSet` | `CustomComparerSortedSet` | `CustomComparerFrozenSet` | |---|---|---|---| | constructor with the comparer | `IEqualityComparer<'T>` | `IComparer<'T>` | `IEqualityComparer<'T>` | | `Comparer` | ✓ | ✓ | ✓ | From 215cca795042e18b9d7a1f44f41d959a517c9899 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 18 Oct 2026 15:19:23 +0200 Subject: [PATCH 07/12] docs: compiled names of the Queue and Stack variants and the measured `rev` in ADR 0001 - D2: `tryHead`/`tryTail`/`tryUncons` compile as `TryPeek`/`TryDequeue`/`TryPeekDequeue` for `Queue` and as `TryPeek`/`TryPop`/`TryPeekPop` for `Stack`; `enqueueMany` as `EnqueueMany` and `consMany` as `PushMany`, so compiled code has one .NET vocabulary. - D5: a member gives way to a measured faster path that keeps the contract. `IndexedList.rev` copies, reverses and builds with `CreateRange`, about 2.5 times faster than `ImmutableList.Reverse` at the same allocation; Appendix A notes it. Co-Authored-By: Claude Opus 5.5 --- docsSrc/Explanations/ADR_0001_Collection_Modules.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docsSrc/Explanations/ADR_0001_Collection_Modules.md b/docsSrc/Explanations/ADR_0001_Collection_Modules.md index 65989af3..86b3ee15 100644 --- a/docsSrc/Explanations/ADR_0001_Collection_Modules.md +++ b/docsSrc/Explanations/ADR_0001_Collection_Modules.md @@ -100,7 +100,10 @@ Names: `IMap` module and `IMap<'Key, 'Value>` abbreviation for `IImmutableDictio `ISet<'T>` abbreviation, because it would shadow `System.Collections.Generic.ISet<'T>`, which `FrozenSet<'T>` implements. Maps and sets measure their size with `count` (FSharp.Core `Map.count`/`Set.count`), sequences with `length`. `Queue` keeps `enqueue`/`head`/`tail`/`uncons` (compiled `Enqueue`/`Peek`/`Dequeue`/`PeekDequeue`), `Stack` keeps `cons`/`head`/`tail`/ -`uncons` (`Push`/`Peek`/`Pop`/`PeekPop`). Computation expression instances: `flatList`, `indexedList`, `queue`, `stack`, +`uncons` (`Push`/`Peek`/`Pop`/`PeekPop`). Their other functions of that kind use the same .NET vocabulary in compiled +code: `tryHead`/`tryTail`/`tryUncons` compile as `TryPeek`/`TryDequeue`/`TryPeekDequeue` for `Queue` and as +`TryPeek`/`TryPop`/`TryPeekPop` for `Stack`, `enqueueMany` as `EnqueueMany` and `consMany` as `PushMany`; the interface +modules compile the same way. Computation expression instances: `flatList`, `indexedList`, `queue`, `stack`, `hashMap`, `sortedMap`, `hashSet`, `sortedSet`, `frozenMap`, `frozenSet`, and `equatableArray` of ADR 0002. The structs of D12 are `CustomComparersHashMap`, `CustomComparersSortedMap`, `CustomComparersFrozenMap`, `CustomComparerHashSet`, `CustomComparerSortedSet` and `CustomComparerFrozenSet`: the prefix names what the struct holds, the comparers of a map or @@ -177,7 +180,11 @@ Priority for every function body: 6. hand-written enumerator loops only where measured (an internal `SeqV` module for `Queue`/`Stack`/interface modules). The priority ranks only implementations that meet the documented contract (D6 exceptions, ordering, stability, comparison -semantics); a faster candidate that breaks the contract is skipped. Sorting is the main case. Stability follows the +semantics); a faster candidate that breaks the contract is skipped. In the other direction, a member gives way to a +measured faster path that keeps the contract: `IndexedList.rev` copies the elements to an array, reverses it and builds +the result with `ImmutableList.CreateRange`, about 2.5 times faster than `ImmutableList.Reverse` at 100 000 elements with +the same allocation, and `IIndexedList.rev` reverses the same array and keeps the implementation of its input. Sorting is +the main case of a contract that excludes a member. Stability follows the FSharp.Core module that a collection mirrors: `FlatList` and `FlatList.Parallel` sort unstably, as `Array.sort*` and `Array.Parallel.sort*` are documented ("This is not a stable sort"), through `ImmutableArray.Sort` or `Array.sort*` on the bridge; `IndexedList`, `Queue`, `Stack` and their interface modules sort stably, as `List.sort*` and `Seq.sort*` are @@ -516,7 +523,7 @@ their element-type-changing transformations return the concrete type. | `pairwise` | Ⓣ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | | `permute` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | | `removeAt` / `removeManyAt` | | ✓ | ✓ | ✓ | – | – | – | – | | -| `rev` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | +| `rev` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | IndexedList: array copy, `Array.Reverse`, `CreateRange`, measured faster than `ImmutableList.Reverse` (D5) | | `transpose` | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | | `unzip` / `unzip3` | Ⓣ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | | `updateAt` | | ✓ | ✓ | ✓ | – | – | – | – | | From c74f988b5c8f43744106607acaede6c7eeccb1c4 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 18 Oct 2026 15:30:50 +0200 Subject: [PATCH 08/12] docs: compile the comparer struct before its module in ADR 0001 D2 listed `CustomComparersX`/`CustomComparerX` after the module, but `forComparers`, `forKeyComparer` and `forComparer` of the module return the struct, so the struct compiles first; the extension module follows both, because its members are those of the struct with the default comparers. Co-Authored-By: Claude Opus 5.5 --- docsSrc/Explanations/ADR_0001_Collection_Modules.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docsSrc/Explanations/ADR_0001_Collection_Modules.md b/docsSrc/Explanations/ADR_0001_Collection_Modules.md index 86b3ee15..65cd4d2e 100644 --- a/docsSrc/Explanations/ADR_0001_Collection_Modules.md +++ b/docsSrc/Explanations/ADR_0001_Collection_Modules.md @@ -90,10 +90,11 @@ would be `HashMap.HashMap<…>`. The type abbreviations of a folder therefore ha signature file. A signature file alone does not compile: F# needs an implementation file for every signature file (FS0240). A file that holds only type abbreviations is its own signature, so it carries their XML documentation itself. -Per folder, in compile order: `XTypes.fs`, `X.fsi`, `X.fs`, `XOption.fsi`, `XOption.fs`, `XTuple.fsi`, `XTuple.fs`, -`CustomComparersX.fsi`/`.fs` (maps) or `CustomComparerX.fsi`/`.fs` (sets, D12), `XExtensions.fsi`, `XExtensions.fs` -(maps, D13), `IX.fsi`, `IX.fs` (plus their `Option`/`Tuple` files), `XBuilder.fs` (`FlatList` and `IndexedList` only), -`XCE.fs`. +Per folder, in compile order: `XTypes.fs`, `CustomComparersX.fsi`/`.fs` (maps) or `CustomComparerX.fsi`/`.fs` (sets, +D12), `X.fsi`, `X.fs`, `XOption.fsi`, `XOption.fs`, `XTuple.fsi`, `XTuple.fs`, `XExtensions.fsi`, `XExtensions.fs` (maps, +D13), `IX.fsi`, `IX.fs` (plus their `Option`/`Tuple` files), `XBuilder.fs` (`FlatList` and `IndexedList` only), `XCE.fs`. +The struct comes before the module, because `forComparers`, `forKeyComparer` and `forComparer` return it, and the +extension module after both, because its members are those of the struct with the default comparers. `Helper.fs` comes first and `ComputationExpressions.fs` last. Interface modules compile after the concrete type they return. Names: `IMap` module and `IMap<'Key, 'Value>` abbreviation for `IImmutableDictionary`; `ISet` module **without** an From 197c2b6663dc6c8d65ea97c1ee3916238e550391 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 18 Oct 2026 18:06:27 +0200 Subject: [PATCH 09/12] docs: nested struct and extension files, signatures for builders and computation expressions, and D14 in ADR 0001 D2: the comparer structs and the extension modules live in `X.CustomComparers.fs` (`X.CustomComparer.fs` for sets) and `X.Extensions.fs`, with `DependentUpon` on the files of the module, so that Visual Studio shows them under it. Every file with public API has a signature file, the builders and the computation expressions included; only `XTypes.fs` has none. D14: where FSharp.Core and the BCL disagree in an edge case, the modules follow .NET: which of two equal elements `intersect` and `union` keep, the implementation that `append` keeps, repeated keys in `ofSeqBy`, values that a value comparer finds equal, comparer identity of the frozen collections, and the default value of a comparer struct. `IsProperSupersetOf` of the immutable sets counts repeated elements, against its documentation; the modules answer with set semantics. Co-Authored-By: Claude Opus 5.5 --- .../ADR_0001_Collection_Modules.md | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/docsSrc/Explanations/ADR_0001_Collection_Modules.md b/docsSrc/Explanations/ADR_0001_Collection_Modules.md index 65cd4d2e..b2df36d2 100644 --- a/docsSrc/Explanations/ADR_0001_Collection_Modules.md +++ b/docsSrc/Explanations/ADR_0001_Collection_Modules.md @@ -90,12 +90,16 @@ would be `HashMap.HashMap<…>`. The type abbreviations of a folder therefore ha signature file. A signature file alone does not compile: F# needs an implementation file for every signature file (FS0240). A file that holds only type abbreviations is its own signature, so it carries their XML documentation itself. -Per folder, in compile order: `XTypes.fs`, `CustomComparersX.fsi`/`.fs` (maps) or `CustomComparerX.fsi`/`.fs` (sets, -D12), `X.fsi`, `X.fs`, `XOption.fsi`, `XOption.fs`, `XTuple.fsi`, `XTuple.fs`, `XExtensions.fsi`, `XExtensions.fs` (maps, -D13), `IX.fsi`, `IX.fs` (plus their `Option`/`Tuple` files), `XBuilder.fs` (`FlatList` and `IndexedList` only), `XCE.fs`. -The struct comes before the module, because `forComparers`, `forKeyComparer` and `forComparer` return it, and the -extension module after both, because its members are those of the struct with the default comparers. -`Helper.fs` comes first and `ComputationExpressions.fs` last. Interface modules compile after the concrete type they return. +Per folder, in compile order: `XTypes.fs`, `X.CustomComparers.fsi`/`.fs` (maps) or `X.CustomComparer.fsi`/`.fs` (sets, +D12), `X.fsi`, `X.fs`, `XOption.fsi`, `XOption.fs`, `XTuple.fsi`, `XTuple.fs`, `X.Extensions.fsi`, `X.Extensions.fs` +(maps, D13), `IX.fsi`, `IX.fs` (plus their `Option`/`Tuple` files), `XBuilder.fsi`/`.fs` (`FlatList` and `IndexedList` +only), `XCE.fsi`/`.fs`. Every file with public API has a signature file, the builders and the computation expressions +included; only the `XTypes.fs` files have none. The struct comes before the module, because `forComparers`, +`forKeyComparer` and `forComparer` return it, and the extension module after both, because its members are those of the +struct with the default comparers. The struct and extension files carry `DependentUpon` on the files of the module +(`X.CustomComparers.fsi` on `X.fsi`, `X.CustomComparers.fs` on `X.fs`), so that Visual Studio shows them under the +module; their names start with the name of the module for the same reason. `Helper.fs` comes first and +`ComputationExpressions.fsi`/`.fs` last. Interface modules compile after the concrete type they return. Names: `IMap` module and `IMap<'Key, 'Value>` abbreviation for `IImmutableDictionary`; `ISet` module **without** an `ISet<'T>` abbreviation, because it would shadow `System.Collections.Generic.ISet<'T>`, which `FrozenSet<'T>` implements. @@ -334,7 +338,8 @@ not byref-like, such as an `int` alternate for `int64` keys with a custom compar ### D12. Comparers: one `CustomComparers*` or `CustomComparer*` struct per collection No module function creates a collection with comparers. Every collection that can be created with comparers gets a struct, -in its own file of the collection's folder, that holds the comparers and creates the collection with them. The prefix +in its own file of the collection's folder (`X.CustomComparers.fs`, `X.CustomComparer.fs` for sets, D2), that holds the +comparers and creates the collection with them. The prefix names what the struct holds: `CustomComparers` for maps, `CustomComparer` for sets, which have one comparer. Functions that use a comparer for one operation only, such as `sortWithComparer`, `indexOfWith` and `lastIndexOfWith`, keep their comparer argument. @@ -375,11 +380,12 @@ A map binding comes in three shapes: `KeyValuePair<'Key, 'Value>`, which every . function takes one shape. F# types a tuple literal that is written as the argument as struct tuples, but a list of reference tuples that is bound before, or any sequence of `KeyValuePair`s, needs a conversion first. -Each map folder therefore has an `[]` root module `XExtensions` in its own file. It extends the BCL type -(`ImmutableDictionary<'Key, 'Value>`, `ImmutableSortedDictionary<'Key, 'Value>`, `FrozenDictionary<'Key, 'Value>`) with -the static members `ofArray`, `ofList` and `ofSeq`, each overloaded for the three shapes. Through the type abbreviation, -`HashMap.ofSeq pairs` reaches them and overload resolution picks the shape; a pipeline `pairs |> HashMap.ofSeq` works too. -This was checked with lists, arrays, `dict` and an existing map. The rules that follow from F#: +Each map folder therefore has an `[]` root module `XExtensions` in its own file, `X.Extensions.fs`. It +extends the BCL type (`ImmutableDictionary<'Key, 'Value>`, `ImmutableSortedDictionary<'Key, 'Value>`, +`FrozenDictionary<'Key, 'Value>`) with the static members `ofArray`, `ofList` and `ofSeq`, each overloaded for the three +shapes. Through the type abbreviation, `HashMap.ofSeq pairs` reaches them and overload resolution picks the shape; a +pipeline `pairs |> HashMap.ofSeq` works too. This was checked with lists, arrays, `dict` and an existing map. The rules +that follow from F#: * The map modules have no `ofArray`, `ofList` or `ofSeq`: module search precedes type search (D3), so a module function with the same name hides the extension members. @@ -401,6 +407,24 @@ Static extension members are an F# feature: C# does not see them as members of t of the structs of D12, whose compiled names are PascalCase, or the BCL itself. Sets need no extensions and no submodules, because their elements have one shape. +### D14. Edge cases follow .NET + +FSharp.Core and the BCL disagree in a few edge cases. Where they do, the modules follow the convention of .NET: the +semantics of the BCL member that a function wraps, or of the BCL family when there is no such member (decided by the +maintainer, 2026-10-18). A BCL behaviour that contradicts the BCL's own documentation is a defect, not a convention: the +modules answer as the documentation says. + +| Case | Behaviour | .NET source | +|---|---|---| +| `intersect` of two elements that the comparer finds equal | the element of the second set | `ImmutableHashSet.Intersect`, `ImmutableSortedSet.Intersect`; `FrozenSet` has no member and follows them | +| `union` of two such elements | the element of the first set | `ImmutableHashSet.Union`, `ImmutableSortedSet.Union` | +| `append` of the interface modules | the implementation of the collection that receives the elements: the first list or queue, the second stack | `IImmutableList.AddRange`, `IImmutableQueue.Enqueue`, `IImmutableStack.Push` | +| A repeated key in `ofSeqBy` | `HashMap` and `SortedMap` accept a repeated key with an equal value and reject another value; `FrozenMap` rejects every repeated key | `ToImmutableDictionary`, `ToImmutableSortedDictionary`, `ToFrozenDictionary` | +| A new value that the value comparer finds equal to the stored one (`add`, `change`, `setItem`, creation) | the stored value stays; a sorted map also keeps the stored key | `ImmutableDictionary.SetItem`, the indexers of the builders | +| An existing collection given to `ofSeq` of a struct of D12 | a frozen collection comes back when its comparer `Equals` the struct's; an immutable one only for the same comparer instance | `ToFrozenDictionary`, `ToFrozenSet`; `WithComparers`, `WithComparer` | +| `isProperSuperset` of a sequence with repeated elements, or with elements that the set's comparer finds equal | set semantics: `{1, 2}` is a proper superset of `[1; 1]` | the documentation of `IsProperSupersetOf`, as the mutable `HashSet` and `FrozenSet` answer; `ImmutableHashSet` and `ImmutableSortedSet` count the repetitions | +| The default value of a struct of D12 | the comparer that the BCL takes for `null`: `EqualityComparer<'T>.Default`, `Comparer<'T>.Default` | the BCL constructors | + ## Consequences Breaking changes (major version): `try*` return `voption`; tuples become struct tuples; `choose`/`pick`/`tryPick`/`unfold`/ From c724983297ce1a5590fc58d0d23165927c5ba04f Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 18 Oct 2026 18:46:14 +0200 Subject: [PATCH 10/12] docs: the proper superset case of D14 in ADR 0001 is about two sets The relations of the set modules take two sets, so the defect of `IsProperSupersetOf` shows when the second set holds elements that the first set's comparer finds equal; a sequence with repetitions is the BCL's own example of it. Co-Authored-By: Claude Opus 5.5 --- docsSrc/Explanations/ADR_0001_Collection_Modules.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docsSrc/Explanations/ADR_0001_Collection_Modules.md b/docsSrc/Explanations/ADR_0001_Collection_Modules.md index b2df36d2..7929ecd6 100644 --- a/docsSrc/Explanations/ADR_0001_Collection_Modules.md +++ b/docsSrc/Explanations/ADR_0001_Collection_Modules.md @@ -422,7 +422,7 @@ modules answer as the documentation says. | A repeated key in `ofSeqBy` | `HashMap` and `SortedMap` accept a repeated key with an equal value and reject another value; `FrozenMap` rejects every repeated key | `ToImmutableDictionary`, `ToImmutableSortedDictionary`, `ToFrozenDictionary` | | A new value that the value comparer finds equal to the stored one (`add`, `change`, `setItem`, creation) | the stored value stays; a sorted map also keeps the stored key | `ImmutableDictionary.SetItem`, the indexers of the builders | | An existing collection given to `ofSeq` of a struct of D12 | a frozen collection comes back when its comparer `Equals` the struct's; an immutable one only for the same comparer instance | `ToFrozenDictionary`, `ToFrozenSet`; `WithComparers`, `WithComparer` | -| `isProperSuperset` of a sequence with repeated elements, or with elements that the set's comparer finds equal | set semantics: `{1, 2}` is a proper superset of `[1; 1]` | the documentation of `IsProperSupersetOf`, as the mutable `HashSet` and `FrozenSet` answer; `ImmutableHashSet` and `ImmutableSortedSet` count the repetitions | +| `isProperSuperset` when the second set holds elements that the first set's comparer finds equal | set semantics under the comparer of the first set | the documentation of `IsProperSupersetOf`, as the mutable `HashSet` and `FrozenSet` answer; `ImmutableHashSet` and `ImmutableSortedSet` count such elements once each, so `{1, 2}.IsProperSupersetOf [1; 1]` is `false` | | The default value of a struct of D12 | the comparer that the BCL takes for `null`: `EqualityComparer<'T>.Default`, `Comparer<'T>.Default` | the BCL constructors | ## Consequences From 00b026037c0b1c9bf0d7851e4f294e8a2bdcd30d Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 18 Oct 2026 19:01:02 +0200 Subject: [PATCH 11/12] docs: hash maps also keep the stored key in D14 of ADR 0001 A probe of `ImmutableDictionary` and `ImmutableSortedDictionary` with case-insensitive comparers shows that both keep the stored key and value when the value comparer finds the new value equal, in `SetItem`, `SetItems`, `AddRange` and the builder indexers, and that both replace the key and the value for another value. Co-Authored-By: Claude Opus 5.5 --- docsSrc/Explanations/ADR_0001_Collection_Modules.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docsSrc/Explanations/ADR_0001_Collection_Modules.md b/docsSrc/Explanations/ADR_0001_Collection_Modules.md index 7929ecd6..0396171f 100644 --- a/docsSrc/Explanations/ADR_0001_Collection_Modules.md +++ b/docsSrc/Explanations/ADR_0001_Collection_Modules.md @@ -420,7 +420,7 @@ modules answer as the documentation says. | `union` of two such elements | the element of the first set | `ImmutableHashSet.Union`, `ImmutableSortedSet.Union` | | `append` of the interface modules | the implementation of the collection that receives the elements: the first list or queue, the second stack | `IImmutableList.AddRange`, `IImmutableQueue.Enqueue`, `IImmutableStack.Push` | | A repeated key in `ofSeqBy` | `HashMap` and `SortedMap` accept a repeated key with an equal value and reject another value; `FrozenMap` rejects every repeated key | `ToImmutableDictionary`, `ToImmutableSortedDictionary`, `ToFrozenDictionary` | -| A new value that the value comparer finds equal to the stored one (`add`, `change`, `setItem`, creation) | the stored value stays; a sorted map also keeps the stored key | `ImmutableDictionary.SetItem`, the indexers of the builders | +| A new value that the value comparer finds equal to the stored one (`add`, `change`, `setItem`, `setItems`, `addRange`, `ofSeqBy`, creation) | the stored binding stays, its key included; another value replaces the key and the value | `SetItem`, `SetItems`, `AddRange` and the builder indexers of `ImmutableDictionary` and `ImmutableSortedDictionary` | | An existing collection given to `ofSeq` of a struct of D12 | a frozen collection comes back when its comparer `Equals` the struct's; an immutable one only for the same comparer instance | `ToFrozenDictionary`, `ToFrozenSet`; `WithComparers`, `WithComparer` | | `isProperSuperset` when the second set holds elements that the first set's comparer finds equal | set semantics under the comparer of the first set | the documentation of `IsProperSupersetOf`, as the mutable `HashSet` and `FrozenSet` answer; `ImmutableHashSet` and `ImmutableSortedSet` count such elements once each, so `{1, 2}.IsProperSupersetOf [1; 1]` is `false` | | The default value of a struct of D12 | the comparer that the BCL takes for `null`: `EqualityComparer<'T>.Default`, `Comparer<'T>.Default` | the BCL constructors | From b64cd58f8b43e24b8e01dac706e7f21a07686705 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 18 Oct 2026 19:09:07 +0200 Subject: [PATCH 12/12] docs: which element union keeps in D14 of ADR 0001 `ImmutableHashSet.Union` always keeps the element of the first set. `ImmutableSortedSet.Union` keeps the element of the larger set when the other operand is a sorted set with the same comparer, because it then adds the smaller set to the larger one; `SortedSet.union` follows the member that it wraps. Co-Authored-By: Claude Opus 5.5 --- docsSrc/Explanations/ADR_0001_Collection_Modules.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docsSrc/Explanations/ADR_0001_Collection_Modules.md b/docsSrc/Explanations/ADR_0001_Collection_Modules.md index 0396171f..2ad700cc 100644 --- a/docsSrc/Explanations/ADR_0001_Collection_Modules.md +++ b/docsSrc/Explanations/ADR_0001_Collection_Modules.md @@ -417,7 +417,7 @@ modules answer as the documentation says. | Case | Behaviour | .NET source | |---|---|---| | `intersect` of two elements that the comparer finds equal | the element of the second set | `ImmutableHashSet.Intersect`, `ImmutableSortedSet.Intersect`; `FrozenSet` has no member and follows them | -| `union` of two such elements | the element of the first set | `ImmutableHashSet.Union`, `ImmutableSortedSet.Union` | +| `union` of two such elements | the element of the first set; `SortedSet` keeps the one of the larger set when both sets have the same comparer, because `ImmutableSortedSet.Union` then adds the smaller set to the larger one | `ImmutableHashSet.Union`, `ImmutableSortedSet.Union` | | `append` of the interface modules | the implementation of the collection that receives the elements: the first list or queue, the second stack | `IImmutableList.AddRange`, `IImmutableQueue.Enqueue`, `IImmutableStack.Push` | | A repeated key in `ofSeqBy` | `HashMap` and `SortedMap` accept a repeated key with an equal value and reject another value; `FrozenMap` rejects every repeated key | `ToImmutableDictionary`, `ToImmutableSortedDictionary`, `ToFrozenDictionary` | | A new value that the value comparer finds equal to the stored one (`add`, `change`, `setItem`, `setItems`, `addRange`, `ofSeqBy`, creation) | the stored binding stays, its key included; another value replaces the key and the value | `SetItem`, `SetItems`, `AddRange` and the builder indexers of `ImmutableDictionary` and `ImmutableSortedDictionary` |