Repository navigation
feat: FrozenSet module with the vocabulary of the FSharp.Core Set module - #36
xperiandri wants to merge 4 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
9722553 to
8f55c4b
Compare
c289b89 to
bc37109
Compare
8f55c4b to
93c99b5
Compare
bc37109 to
04ab29d
Compare
4a1836d to
3dc6fb8
Compare
04ab29d to
e591393
Compare
3dc6fb8 to
9a6be57
Compare
29a16f6 to
b6a3d4b
Compare
0942d33 to
ac81c22
Compare
58d1f96 to
c5af422
Compare
ac81c22 to
b8187c4
Compare
c5af422 to
cbf0a56
Compare
b8187c4 to
3e06605
Compare
cbf0a56 to
008d0d0
Compare
3e06605 to
f76ee73
Compare
008d0d0 to
d31c12e
Compare
f76ee73 to
283f111
Compare
d31c12e to
8ed4dc7
Compare
283f111 to
73840d8
Compare
8ed4dc7 to
7d3f26f
Compare
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>
7d3f26f to
0939b28
Compare
f986ca8 to
f5a07ec
Compare
0319d81 to
2f88861
Compare
f5a07ec to
1889450
Compare
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the
|
The review of #31 asked to unwrap options and value options with `Assert.WantSome` and `Assert.WantValueSome` in all such cases in all tests. The tests of `tryGetValue` and `tryAlternateLookup`, and the `Option` test of `tryAlternateLookup`, matched on `ValueNone` or `None` to fail; they unwrap the value with the assertions now. The test of the alternate lookup that reads a lookup with `TryGetValue` keeps its match: the member returns a `bool` and an out value, not an option. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

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