Repository navigation
feat: FrozenSet module with the vocabulary of the FSharp.Core Set module - #36
Open
xperiandri wants to merge 3 commits into
Open
xperiandri wants to merge 3 commits into
xperiandri wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
🔵 Needs a closer look
The broad public API and framework-specific lookup behavior need final human validation; build and tests were not independently verified.
No open issues 🎉
What changed in this PR
Adds FrozenSet bindings following ADR 0001, extending the per-collection API with build-once, read-many sets.
Changes:
- Adds set operations, comparer-aware creation, and .NET 10 alternate lookups.
- Provides Option/Tuple variants and computation expressions.
- Adds contract and feature tests, project integration, and documentation.
| File | Description |
|---|---|
| tests/FSharp.Collections.Immutable.Tests/FSharp.Collections.Immutable.Tests.fsproj | Registers FrozenSet tests. |
| tests/FSharp.Collections.Immutable.Tests/FrozenSet/FrozenSetTupleTests.fs | Tests reference-tuple variants. |
| tests/FSharp.Collections.Immutable.Tests/FrozenSet/FrozenSetTests.fs | Tests comparers, lookups, ordering, and null handling. |
| tests/FSharp.Collections.Immutable.Tests/FrozenSet/FrozenSetOptionTests.fs | Tests option variants. |
| tests/FSharp.Collections.Immutable.Tests/FrozenSet/FrozenSetOps.fs | Supplies shared-contract operations. |
| tests/FSharp.Collections.Immutable.Tests/FrozenSet/FrozenSetContractTests.fs | Applies shared set contracts. |
| tests/FSharp.Collections.Immutable.Tests/FrozenSet/FrozenSetComputationExpressionTests.fs | Tests computation expressions. |
| src/FSharp.Collections.Immutable/FSharp.Collections.Immutable.fsproj | Registers library files in compile order. |
| src/FSharp.Collections.Immutable/FrozenSet/FrozenSetTuple.fsi | Declares tuple variants. |
| src/FSharp.Collections.Immutable/FrozenSet/FrozenSetTuple.fs | Implements tuple variants. |
| src/FSharp.Collections.Immutable/FrozenSet/FrozenSetOption.fsi | Declares option variants. |
| src/FSharp.Collections.Immutable/FrozenSet/FrozenSetOption.fs | Implements option variants. |
| src/FSharp.Collections.Immutable/FrozenSet/FrozenSetCE.fs | Adds the computation-expression builder. |
| src/FSharp.Collections.Immutable/FrozenSet/FrozenSet.fsi | Defines and documents the public API. |
| src/FSharp.Collections.Immutable/FrozenSet/FrozenSet.fs | Implements FrozenSet operations. |
| src/FSharp.Collections.Immutable/FrozenSet/CustomComparerFrozenSet.fsi | Declares comparer-aware creation. |
| src/FSharp.Collections.Immutable/FrozenSet/CustomComparerFrozenSet.fs | Implements comparer-aware creation. |
| src/FSharp.Collections.Immutable/ComputationExpressions.fs | Exposes frozenSet. |
| README.md | Lists FrozenSet support. |
| docsSrc/index.md | Lists FrozenSet support. |
| docsSrc/Explanations/Background.md | Adds FrozenSet to the collection table. |
| CHANGELOG.md | Records the new APIs. |
Review effort: Balanced
xperiandri
force-pushed
the
feat/hash-set
branch
from
October 18, 2026 18:49
9722553 to
8f55c4b
Compare
xperiandri
force-pushed
the
feat/frozen-set
branch
from
October 18, 2026 18:58
c289b89 to
bc37109
Compare
xperiandri
force-pushed
the
feat/hash-set
branch
from
October 18, 2026 20:13
8f55c4b to
93c99b5
Compare
xperiandri
force-pushed
the
feat/frozen-set
branch
from
October 18, 2026 20:19
bc37109 to
04ab29d
Compare
xperiandri
force-pushed
the
feat/hash-set
branch
from
October 18, 2026 22:13
4a1836d to
3dc6fb8
Compare
xperiandri
force-pushed
the
feat/frozen-set
branch
from
October 18, 2026 22:17
04ab29d to
e591393
Compare
xperiandri
force-pushed
the
feat/hash-set
branch
from
October 18, 2026 22:38
3dc6fb8 to
9a6be57
Compare
xperiandri
force-pushed
the
feat/frozen-set
branch
2 times, most recently
from
October 19, 2026 11:03
29a16f6 to
b6a3d4b
Compare
xperiandri
force-pushed
the
feat/frozen-set
branch
from
October 19, 2026 12:31
0942d33 to
ac81c22
Compare
xperiandri
force-pushed
the
feat/hash-set
branch
from
October 19, 2026 14:42
58d1f96 to
c5af422
Compare
xperiandri
force-pushed
the
feat/frozen-set
branch
from
October 19, 2026 14:45
ac81c22 to
b8187c4
Compare
xperiandri
force-pushed
the
feat/hash-set
branch
from
October 19, 2026 14:47
c5af422 to
cbf0a56
Compare
xperiandri
force-pushed
the
feat/frozen-set
branch
from
October 19, 2026 14:47
b8187c4 to
3e06605
Compare
xperiandri
force-pushed
the
feat/hash-set
branch
from
October 19, 2026 14:48
cbf0a56 to
008d0d0
Compare
xperiandri
force-pushed
the
feat/frozen-set
branch
from
October 19, 2026 14:48
3e06605 to
f76ee73
Compare
xperiandri
force-pushed
the
feat/hash-set
branch
from
October 19, 2026 15:55
008d0d0 to
d31c12e
Compare
xperiandri
force-pushed
the
feat/frozen-set
branch
from
October 19, 2026 15:55
f76ee73 to
283f111
Compare
xperiandri
force-pushed
the
feat/hash-set
branch
from
October 19, 2026 17:31
d31c12e to
8ed4dc7
Compare
xperiandri
force-pushed
the
feat/frozen-set
branch
from
October 19, 2026 17:31
283f111 to
73840d8
Compare
xperiandri
force-pushed
the
feat/hash-set
branch
from
October 19, 2026 17:40
8ed4dc7 to
7d3f26f
Compare
xperiandri
force-pushed
the
feat/frozen-set
branch
from
October 19, 2026 17:41
73840d8 to
f986ca8
Compare
… module
Adds the `FrozenSet` module over `System.Collections.Frozen.FrozenSet<'T>`
in `FrozenSet/FrozenSet.fsi` and `FrozenSet/FrozenSet.fs`: 37 functions,
39 on `net10.0`, in the canonical category order, each documented in the
signature file, with the names, the argument order and the exceptions of
the `Set` module.
- A frozen set is built once, so the module offers creation, lookup,
iteration, folds, conversions and the transformations of a whole set,
but no `add`, `remove`, `clear`, builder or `withComparer`. Every
transformation collects its result in a `HashSet` with the comparer of
the result and freezes it once with `ToFrozenSet`, which takes such a
hash set over without another copy.
- The reading functions call the members of `FrozenSet` and pass the
array behind `Items` to the FSharp.Core `Array` functions without a
copy; `toFlatList` returns `Items` in O(1).
- `filter`, `partition` and the set algebra keep the comparer of their
input, the set algebra that of the first set; `intersect` and `union`
hold the elements of the first set where both sets have equal ones.
`map`, `choose` and `partitionWith` compare with the default comparer
of the result type. `symmetricDifference` is an extra, because
`FrozenSet` has no `SymmetricExcept`.
- `intersectMany` reuses `Sets.reduce`: an intersection never grows, so
its rebuilds at each step stay linear in total. `unionMany` folds every
set into one hash set and freezes it once, because rebuilding a growing
union at each step would take quadratic time.
- On `net10.0`, `alternateLookup` and `tryAlternateLookup` wrap
`GetAlternateLookup` and `TryGetAlternateLookup`. The alternate type
comes first, as in `FrozenSet.alternateLookup<int, _> set`. Their
documentation points span lookups to the BCL methods, because an F#
generic function cannot be instantiated with `ReadOnlySpan<char>`.
- The module coexists with the BCL class `FrozenSet`: `FrozenSet.ofSeq`
and `FrozenSet.ToFrozenSet` both resolve, whichever namespace is opened
last.
- `tryGetValue`, `choose` and `tryAlternateLookup` use `voption`, and
`partition` and `partitionWith` struct tuples; the `Option` and `Tuple`
namespaces repeat them with options and reference tuples.
- `frozenSet { }` collects its elements in a `ResizeArray` and freezes
them once; `FrozenSetCE` created with an equality comparer builds
frozen sets that compare with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `FrozenSetOps` records the functions of `FrozenSet` for the reading,
transformation and creation contracts; a frozen set has no mutation
facet. Three test classes run the shared set contracts against the
FSharp.Core `Set` oracle.
- `FrozenSetTests` covers what the contracts leave out: the comparers
that the functions keep or reset, `toFlatList` sharing the array of
`Items`, the order of enumeration of large frozen sets, which the BCL
lays out differently from small ones, `null` inputs, and the module
resolving next to the class `System.Collections.Frozen.FrozenSet`
whichever namespace is opened last.
- On `net10.0`, `FrozenSetAlternateLookupTests` looks up a small and a
large set of `int64` values by `int` values through a comparer that
implements `IAlternateEqualityComparer<int, int64>`, and expects
`InvalidOperationException` and `ValueNone` for comparers without the
alternate type, such as `HashIdentity.Structural`.
- Further tests cover `frozenSet { }` and the `Option` and `Tuple`
namespaces.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xperiandri
force-pushed
the
feat/hash-set
branch
from
October 20, 2026 16:28
7d3f26f to
0939b28
Compare
xperiandri
force-pushed
the
feat/frozen-set
branch
from
October 20, 2026 16:28
f986ca8 to
f5a07ec
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Proposed Changes
Adds the
FrozenSetmodule overSystem.Collections.Frozen.FrozenSet<'T>after ADR 0001 (#23). This pull request is stacked on #29 (feat/hash-set).Library files: everything is in
FrozenSet/, in this compile order:FrozenSet.CustomComparer.fsiand.fs: the struct thatforComparercreates (D12). They nest underFrozenSet.fsiandFrozenSet.fsin Visual Studio through<DependentUpon>.FrozenSet.fsiandFrozenSet.fs: 39 functions, 41 onnet10.0, in the canonical category order. They are theFrozenSetcolumn of ADR Appendix C, with the parameter names and the argument order of the FSharp.CoreSetmodule.FrozenSetOption.fsi/.fsandFrozenSetTuple.fsi/.fs: theOptionandTuplenamespaces.FrozenSetCE.fsiand.fs, plus thefrozenSetinstance inComputationExpressions.fsiand.fs, right afterflatList.System.Collections.Frozen.FrozenSet:FrozenSet.ofSeqcalls the module andFrozenSet.ToFrozenSetcalls the class, whichever namespace is opened last.Read and transform (D1): a frozen set is built once and read many times.
add,remove,clearor builder.System.Collections.Generic.HashSetwith the comparer of the result.ToFrozenSetthen takes that hash set over without another copy.FrozenSet(D5).exists,forall,fold,foldBack,iterandtoListread the array behindItemsthrough the FSharp.CoreArrayfunctions, without a copy.toFlatListreturnsItemsin O(1).filter,partitionand the set algebra keep the comparer of the input, or of the first set.map,chooseandpartitionWithuse the default comparer of the result type.intersectholds the element of the second set, asImmutableHashSet.IntersectandImmutableSortedSet.Intersectdo, andunionthe element of the first set. The relations of two sets answer with set semantics under the comparer of the first set;FrozenSet.IsProperSupersetOfalready does.symmetricDifferenceis an extra built onHashSet.SymmetricExceptWith, becauseFrozenSethas noSymmetricExcept.intersectManyreusesSets.reduceof feat:HashSetandISetmodules with the vocabulary of the FSharp.CoreSetmodule #29.unionManycollects every set in one hash set and freezes it once.Set(D6). Anullfrozen set, sequence, list or array throwsArgumentNullExceptionwith the name of the parameter. A default flat list given as a sequence is notnulland fails only when it is enumerated, soofSeq,intersectMany,unionMany, theofSeqmember of the struct, andyield!andforoffrozenSet { }reject it too, before anything else, with the name of their parameter.ofReadOnlySpan spanandofSpan span:FrozenSet.Createwith a span, andofSpanconverts itsSpan<'T>. A frozen set is expensive to build, so a span needs noofArray (span.ToArray ())and its extra copy. An empty span gives an empty frozen set.Createis in the three assets of theSystem.Collections.Immutablepackage that the library references, but not in the shippednet8.0reference assembly. The package reference stays (D10), so both target frameworks build and run.CustomComparerFrozenSetof the same names wrapCreate (comparer, span). The functions of the module call them with the default comparer.build,update,addRangeorremoveRange(D1).CustomComparerFrozenSetandforComparer(D12):FrozenSet.forComparer comparercreates the struct. Its membersempty,ofArray,ofList,ofReadOnlySpan,ofSeq,ofSpanandsingletoncreate frozen sets that compare with the comparer:(FrozenSet.forComparer StringComparer.OrdinalIgnoreCase).ofSeq names. Of the elements that the comparer considers equal, the set holds the first one.ofSeqalso gives an existing frozen set the comparer. It returns the set itself when its comparerEqualsthe struct's, asToFrozenSetdoes (D14).forComparersay that anullcomparer throws, where the BCL methodsCreateandToFrozenSettake it as the default comparer. They point toFrozenSet.ofSeqand to the default value of the struct for that meaning.[<Struct; NoComparison; NoEquality>]and allocates nothing. Its constructor checks the comparer:forComparerand the constructor throwArgumentNullExceptionwith the parameter namecomparer. The default value of the struct compares withEqualityComparer<'T>.Default, the comparer that the BCL takes fornull.buildermember.FrozenSet.emptyisFrozenSet<'T>.Empty, and theemptymember returns the same instance for the default comparer.Alternate lookup,
net10.0only (D11):alternateLookupwrapsGetAlternateLookup. It throwsInvalidOperationExceptionwhen the comparer does not implementIAlternateEqualityComparer<'Alternate, 'T>.tryAlternateLookupwrapsTryGetAlternateLookupand returnsValueNonein that case.AlternateLookup<'Alternate>struct. ItsContainsandTryGetValuedo the lookups.<'Alternate, 'T>, so a call readsFrozenSet.alternateLookup<int, _> set. F# does not accept explicit type arguments for only some of the type parameters.#if NET10_0_OR_GREATER: only thenet9.0and later assets of the package have the API.ReadOnlySpan<char>, the alternate type of strings underStringComparer.Ordinal. Their documentation points span lookups toset.GetAlternateLookup<ReadOnlySpan<char>> (), which F# can call. The wrappers serve alternate types that are not byref-like, such asintforint64elements with a custom comparer.Option and Tuple namespaces (D3):
Option.FrozenSetcontainschoose,tryGetValueand, onnet10.0,tryAlternateLookup.Tuple.FrozenSetcontainspartitionandpartitionWith.Computation expression (D7):
frozenSet { }collects its elements in aResizeArrayand freezes them once.FrozenSetCE comparer { }builds frozen sets that compare with the comparer. Anullcomparer throwsArgumentNullExceptionwhen the builder is created.frozenSetis a type function, ashashSetis.Documentation: XML documentation on every function and member. The changelog lists
FrozenSetunder[Unreleased]and says what a user of the BCL type does differently:Create (comparer, ...)andToFrozenSet (source, comparer), and anullcomparer throws;TryGetValueistryGetValue,ItemsistoFlatList, andCopyToistoFlatListfollowed byFlatList.blitorFlatList.asSpan;Setdo. A sequence goes through(FrozenSet.forComparer (FrozenSet.comparer set)).ofSeq elements, which gives its elements the comparer of the first set, as the members do.FrozenSet.ofSeq elementsgives them the default comparer, which merges the elements that it finds equal first: it answers as the member when the comparer of the first set finds equal every pair that the default comparer finds equal (the default comparer,OrdinalIgnoreCase), and can answer differently when the comparer tells such elements apart (HashIdentity.Referenceon two strings with the same text).OverlapsandIsSupersetOfneed no conversion: they areSeq.existsandSeq.forallwithcontains, for every comparer;Set.addandSet.removeareunionanddifferencewith a singleton, each an O(n) rebuild;NotSupportedExceptionon the BCL type and have no function;The recipes of the changelog were checked against the BCL members twice. A first check ran 20 000 random inputs (120 000 comparisons), with the default comparer and with a comparer that ignores case: the conversion with the comparer of the first set and the two streaming forms never differed. A second check ran every subset of four strings (two instances of "a", "A" and "b") against every sequence of up to three of them, each as an array and as a lazy sequence, with the default comparer, a comparer that ignores case and
HashIdentity.Reference: the conversion with the comparer of the first set never differed,FrozenSet.ofSeqdiffered only with the reference comparer (80 to 128 of 2,720 inputs per relation), and the streaming forms never differed. Tests pin the recipes (below).README.md,docsSrc/index.mdanddocsSrc/Explanations/Background.mdget aFrozenSetrow in the collection table.Tests: 5,294 on
net8.0and 5,306 onnet10.0. 856 and 868 of them are new; the 12 more onnet10.0test the alternate lookups. The tests compare arrays and take their cases as data rows, so every case runs and reports as a test of its own.FrozenSetderives the reading, transformation and creation contracts of feat:HashSetandISetmodules with the vocabulary of the FSharp.CoreSetmodule #29. It has no mutation facet, because a frozen set is built once.FrozenSet.forComparer SetInputs.SameRemainder, a comparer that considers two numbers equal when their remainders in the division by 5 are equal. The creation contract runs the members of the same struct.ofReadOnlySpanandofSpan, of the module and of the struct, on a copy of every input that the test changes after the call (20 tests).FrozenSetOpsfills the two new fields of the ops records.FrozenSetTestscovers:intersect,intersectManyandunionkeep;isProperSupersetof words that the second set tells apart by case;forComparerand the constructor with a comparer and withnull, the first of equal elements for every member, an existing frozen set that gets the comparer or comes back unchanged, and the default value of the struct;ofSeqof the struct returns a frozen set whose comparer is equal to its comparer, not only the same instance (D14);toFlatList, which shares the array ofItems;nullinputs of every function, with the parameter names;nullsequence, a default flat list and a default flat list of strings given as a sequence of objects, forofSeq,intersectMany,unionManyand theofSeqmember of the struct (10 rows), and a flat list that is not default, which the functions take;OverlapsandIsSupersetOfasSeq.existsandSeq.forallwithcontainsanswer as the members for all three comparers (6 rows);FrozenSetside by side, withSystem.Collections.Frozenopened before and after this library.FrozenSetTestsare printable: the names of the functions in tables of functions, and cases ofFrozenSetSamplefor the large sets, which a test builds.net10.0,FrozenSetAlternateLookupTestslook up a small and a large set ofint64values byintvalues. The comparer implementsIAlternateEqualityComparer<int, int64>. The tests expectInvalidOperationExceptionandValueNonefor comparers without it, such asHashIdentity.Structural, and show a span lookup through the BCL method.frozenSet { },FrozenSetCEand both namespaces.yield!andforreject anullsequence, a default flat list, a default flat list of strings as objects, and, foryield!, anullarray and anullfrozen set (8 rows), each with the parameter of the expression,itemsorsource.ofSeqwas broken once in the code (10 mutations: the comparer and the element that stays of the four creators, a dropped element, and the instance test ofofSeq), and a test failed each time. So was each check of the sequence inputs (8 mutations: the check of the struct, ofintersectMany, ofunionMany, ofyield!for a sequence, an array and a frozen set, offor, and the recognition of a flat list of strings as a sequence of objects in the helper). So was each pinned part of the sequence forms of the relations (3 mutations:ofSeqof the struct converting with the default comparer,isSubsetcomparing in the comparer of the second set, andcontainscomparing in the default comparer).Types of changes
What types of changes does your code introduce to FSharp.Collections.Immutable?
Put an
xin the boxes that applyThe pull request adds a module and changes no existing function.
Checklist
Put an
xin the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code.Further comments
These decisions need a review:
map,chooseandpartitionWithuse the default comparer of the result type, also when that type is the element type. Keeping the comparer would need a type test.HashSetdoes the same.intersectManyintersects the sets one by one: an intersection never grows, so the rebuilds take linear time in total.unionManyfreezes once, because a union at each step would rebuild a growing frozen set, in quadratic time.unionManyof no sets is the empty frozen set with the default comparer.ReadOnlySpan<char>, the most common alternate type, until F# can declareallows ref struct(fslang-suggestions#1476, RFC FS-1356). They stay for the alternate types that are not byref-like. Span users call the BCL method.frozenSet { }collects in aResizeArray, as D7 prescribes where the BCL has no builder.ToFrozenSetthen copies the elements into a hash set. AHashSetbuffer with the comparer of the expression would save that copy.CreatenorToFrozenSetthat way; it is how both behave today, and the tests pin it for the module and the struct, so a new version of the package that changed it fails them.net8.0andFrozenSet.Create: the shippednet8.0reference assembly has noCreate. The library compiles against the package, which declares the dependencySystem.Collections.Immutable10.0.13 or later for both target frameworks, as before this change (emptyof the struct already calledCreate). Anet8.0application therefore gets the package through NuGet.🤖 Generated with Claude Code