Repository navigation
feat: FrozenMap module with the vocabulary of the FSharp.Core Map module - #38
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.
🟢 Approval recommended
Static review identified no blocking issues, with extensive contract and collection-specific coverage; builds and tests were not independently executed.
No open issues 🎉
What changed in this PR
Adds FrozenMap bindings over FrozenDictionary, following ADR 0001 and reusing the shared map helpers and contracts.
Changes:
- Adds read and transformation APIs, custom-comparer creation, and .NET 10 alternate lookups.
- Adds Option/Tuple variants, creation overloads, and
frozenMapcomputation expressions. - Integrates tests, package metadata, and documentation.
| File | Description |
|---|---|
| tests/FSharp.Collections.Immutable.Tests/FSharp.Collections.Immutable.Tests.fsproj | Registers FrozenMap tests. |
| tests/FSharp.Collections.Immutable.Tests/FrozenMap/FrozenMapTupleTests.fs | Tests reference-tuple variants. |
| tests/FSharp.Collections.Immutable.Tests/FrozenMap/FrozenMapTests.fs | Tests collection-specific behavior and alternate lookups. |
| tests/FSharp.Collections.Immutable.Tests/FrozenMap/FrozenMapOptionTests.fs | Tests option variants and shadowing. |
| tests/FSharp.Collections.Immutable.Tests/FrozenMap/FrozenMapOps.fs | Supplies shared contract adapters. |
| tests/FSharp.Collections.Immutable.Tests/FrozenMap/FrozenMapExtensionsTests.fs | Tests creation overloads and pipelines. |
| tests/FSharp.Collections.Immutable.Tests/FrozenMap/FrozenMapContractTests.fs | Applies shared map contracts. |
| tests/FSharp.Collections.Immutable.Tests/FrozenMap/FrozenMapComputationExpressionTests.fs | Tests computation expressions. |
| tests/FSharp.Collections.Immutable.Tests/FrozenMap/CustomComparersFrozenMapTests.fs | Tests comparer-aware creation. |
| src/FSharp.Collections.Immutable/FSharp.Collections.Immutable.fsproj | Registers sources and updates package description. |
| src/FSharp.Collections.Immutable/FrozenMap/FrozenMapTypes.fs | Defines the type abbreviation. |
| src/FSharp.Collections.Immutable/FrozenMap/FrozenMapTuple.fsi | Documents reference-tuple APIs. |
| src/FSharp.Collections.Immutable/FrozenMap/FrozenMapTuple.fs | Implements reference-tuple variants. |
| src/FSharp.Collections.Immutable/FrozenMap/FrozenMapOption.fsi | Documents option APIs. |
| src/FSharp.Collections.Immutable/FrozenMap/FrozenMapOption.fs | Implements option variants. |
| src/FSharp.Collections.Immutable/FrozenMap/FrozenMapExtensions.fsi | Declares creation overloads. |
| src/FSharp.Collections.Immutable/FrozenMap/FrozenMapExtensions.fs | Delegates overloads to comparer-aware creation. |
| src/FSharp.Collections.Immutable/FrozenMap/FrozenMapCE.fs | Collects bindings and freezes once. |
| src/FSharp.Collections.Immutable/FrozenMap/FrozenMap.fsi | Defines and documents the public API. |
| src/FSharp.Collections.Immutable/FrozenMap/FrozenMap.fs | Implements reads, transformations, and lookups. |
| src/FSharp.Collections.Immutable/FrozenMap/CustomComparersFrozenMap.fsi | Documents comparer-aware creation. |
| src/FSharp.Collections.Immutable/FrozenMap/CustomComparersFrozenMap.fs | Implements the comparer struct. |
| src/FSharp.Collections.Immutable/ComputationExpressions.fs | Exposes frozenMap. |
| README.md | Lists FrozenMap support. |
| docsSrc/index.md | Adds FrozenMap to the collection table. |
| docsSrc/Explanations/Background.md | Lists the new binding and expression. |
| CHANGELOG.md | Records FrozenMap functionality. |
Review effort: Balanced
xperiandri
force-pushed
the
feat/hash-map
branch
from
October 18, 2026 18:47
3f22fda to
8d763b8
Compare
xperiandri
force-pushed
the
feat/frozen-map
branch
from
October 18, 2026 18:58
94e3d86 to
1b2def6
Compare
xperiandri
force-pushed
the
feat/hash-map
branch
from
October 18, 2026 20:11
8d763b8 to
ed2f285
Compare
xperiandri
force-pushed
the
feat/frozen-map
branch
from
October 18, 2026 20:18
1b2def6 to
fbfc5e8
Compare
xperiandri
force-pushed
the
feat/hash-map
branch
from
October 18, 2026 22:36
d61c19f to
0e1a08a
Compare
xperiandri
force-pushed
the
feat/frozen-map
branch
from
October 18, 2026 22:40
fbfc5e8 to
5a78737
Compare
xperiandri
force-pushed
the
feat/frozen-map
branch
from
October 19, 2026 11:14
5a78737 to
f4deece
Compare
xperiandri
force-pushed
the
feat/hash-map
branch
3 times, most recently
from
October 19, 2026 14:48
53cd7b3 to
ec4d653
Compare
xperiandri
force-pushed
the
feat/frozen-map
branch
from
October 19, 2026 14:49
f4deece to
049135e
Compare
xperiandri
force-pushed
the
feat/hash-map
branch
from
October 19, 2026 15:55
ec4d653 to
40871e3
Compare
xperiandri
force-pushed
the
feat/frozen-map
branch
from
October 19, 2026 15:55
049135e to
5e9d866
Compare
xperiandri
force-pushed
the
feat/hash-map
branch
from
October 19, 2026 17:31
40871e3 to
b487c08
Compare
xperiandri
force-pushed
the
feat/frozen-map
branch
from
October 19, 2026 17:31
5e9d866 to
7abdaaa
Compare
xperiandri
force-pushed
the
feat/hash-map
branch
from
October 19, 2026 17:40
b487c08 to
02cb1b5
Compare
xperiandri
force-pushed
the
feat/frozen-map
branch
from
October 19, 2026 17:43
7abdaaa to
4997610
Compare
… module
Adds `FrozenMap` over `FrozenDictionary<'Key, 'Value>` after ADR 0001
in `FrozenMap/FrozenMap.fsi` and `FrozenMap/FrozenMap.fs`, each function
documented in the signature file.
- The module reads and transforms a map that is built once: it has no
`add`, `remove`, `change`, `clear`, builder or `with*Comparer*`.
`choose`, `filter`, `map` and `partition` rebuild the map in O(n)
with the key comparer of the input; `filter` and `partition` return
the input itself for a part that keeps every binding.
- The functions with a name of `Map` have its semantics and
exceptions: `ofArray`, `ofList` and `ofSeq` keep the last binding of
a key, `find` and `pick` throw the `KeyNotFoundException` of `Map`,
and `findKey` the one of `Seq.pick`. The wrappers keep the semantics
of the BCL: `ofKeyValuePairs` and the comparer variants keep the
first key and the last value of equal keys, as `ToFrozenDictionary`
does, and `ofSeqBy` rejects every repeated key.
- `keys` and `values` return flat lists over the arrays of the map
without a copy, and `foldBack` reads those arrays backwards.
`containsValue` compares with the default comparer of the values,
because a frozen map has no value comparer.
- On `net10.0`, `alternateLookup` and `tryAlternateLookup` wrap
`GetAlternateLookup` and `TryGetAlternateLookup`. The type of the
alternate keys is their first type argument, and their documentation
points span alternates to the methods of the map.
- `try*` functions return `voption`, tuples are struct tuples, and the
`Option` and `Tuple` namespaces repeat those functions with options
and reference tuples.
- `frozenMap { }` collects `yield struct (key, value)` and
`yield KeyValuePair` in a dictionary with the key comparer and
freezes it when it runs; `FrozenMapCE` takes a key comparer.
- The package description mentions System.Collections.Frozen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `FrozenMapOps` records the functions of the module, and `FrozenMap` derives the reading, transformation and creation facets; a frozen map has no mutation facet. - The tests of `FrozenMap` cover null inputs, the key comparer of the creation functions and of the transformations, the results that are the input itself, the arrays of the keys and the values, and the repeated keys of the functions that wrap `ToFrozenDictionary`. - On `net10.0`, the alternate lookups find `int64` keys by `int` alternates through a comparer that implements `IAlternateEqualityComparer<int, int64>`, find nothing for `HashIdentity.Structural` and for string keys without a span, and a span goes through `GetAlternateLookup` of the map. - The computation expression and the `Option` and `Tuple` namespaces have their own tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README, the index page and the background page list `FrozenMap`
with `frozenMap { }`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xperiandri
force-pushed
the
feat/hash-map
branch
from
October 20, 2026 16:28
02cb1b5 to
53d466c
Compare
xperiandri
force-pushed
the
feat/frozen-map
branch
from
October 20, 2026 16:28
4997610 to
9179273
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
FrozenMap, a module overFrozenDictionary<'Key, 'Value>, after ADR 0001 (#23). This pull request is stacked on #30 (feat/hash-map). It reuses the map contracts and theDictionarieshelpers of #30.FrozenMap/, in this compile order:FrozenMapTypes.fs: the abbreviationFrozenMap<'Key, 'Value>. A root module cannot declare a type of its namespace, so the abbreviation has its own file, without a signature file (D2).FrozenMap.CustomComparers.fsiand.fs: the struct thatforKeyComparercreates (D12).FrozenMap.fsiandFrozenMap.fs: 35 functions in the canonical category order, two of them onnet10.0only. 31 are theFrozenMapcolumn of ADR Appendix B, with the parameter names of the FSharp.CoreMapmodule. The other four wrap BCL members (see below).FrozenMapOption.fsi/.fsandFrozenMapTuple.fsi/.fs: theOptionandTuplenamespaces.FrozenMap.Extensions.fsiand.fs: the creation members for every binding shape (D13).FrozenMapCE.fsiand.fs, plus thefrozenMapinstance inComputationExpressions.fsiand.fs, right afterflatList.FrozenMap.fsiandFrozenMap.fsin Visual Studio through<DependentUpon>.FrozenMap.add,remove,setItem,change,clear,addRange,removeRangeor builder.choose,filter,mapandpartitionrebuild the map in O(n) with the key comparer of the input. Their remarks say so.filterandpartitionreturn the input itself for a part that keeps every binding.keysandvaluesreturn flat lists over the arrays of the map in O(1), without a copy.foldBackreads those arrays backwards.containsValuecompares withEqualityComparer<'Value>.Default, because a frozen map has no value comparer.Mapthrow whatMapthrows (D6).findandpickthrow theKeyNotFoundExceptionofMap,findKeythrows the message ofSeq.pick, and anullmap throwsArgumentNullException.seqargument throwsArgumentNullExceptionthat names the parameter fornulland for a default flat list given as a sequence, which would otherwise fail withInvalidOperationExceptioninside the BCL call. One call ofcheckNotNullOrDefaultSeqguards each of these before anything else:ofSeqBy, theofSeqandofSeqBymembers of the struct (the extension overloads call them), andyield!andforoffrozenMap { }, which check when the member runs and not when the buffer does. Arrays keep their null check.yield!of an array and of a frozen map checksnullthe same way (pairs,table), where it used to fail withNullReferenceExceptioninsideRun. The other inputs offrozenMap { }are functions that the compiler creates from the expression, and the resource ofuse, which F# accepts asnull.containsBinding key value tablewraps the explicitICollection<KeyValuePair>.Contains. It tells in O(1) on average whether a key is bound to a value. A frozen map has no value comparer, so the value is compared withEqualityComparer<'Value>.Default, ascontainsValuedoes.tryFindfollowed by=uses F# equality, which differs fornanand for arrays. Anulltable or key throwsArgumentNullException.ofReadOnlySpan spanandofSpan spanwrapFrozenDictionary.Create. They read a span, whereofArrayandofSeqneed an array or a sequence. They are members ofCustomComparersFrozenMaptoo, which create with its key comparer, and the module functions call the members. A repeated key never throws: the last value wins and the first key stays (D14).ofSpanexists because F# converts aSpanto theReadOnlySpanparameter only where the type arguments are known, with the warning FS3391. A call that leaves them to inference fails with FS0001.copyTo destination destinationIndex tablewraps the publicCopyTo (KeyValuePair[], int). It fills an array that the caller owns, wheretoArrayallocates a new one. It throws what the BCL throws:ArgumentNullExceptionfor anulltable or array,ArgumentOutOfRangeExceptionfor an index below zero or beyond the length, andArgumentExceptionwhen the array has no room. The BCL gives theArgumentOutOfRangeExceptionno parameter name. TheSpanoverload stays a call on the map, because a module function cannot be overloaded.tryFindisTryGetValue,tryFindfollowed byValueOption.defaultValueisGetValueOrDefault,(FrozenMap.forKeyComparer comparer).ofSeq tablechanges the key comparer,ofSeqof mapped pairs replaces an element selector, and the extension members compile toFrozenMapExtensions.OfArray,OfListandOfSeq.CustomComparersFrozenMapandforKeyComparer(D12):FrozenMap.forKeyComparer keyComparerreturns a struct whose members create frozen maps with that comparer:(FrozenMap.forKeyComparer StringComparer.OrdinalIgnoreCase).ofSeq pairs.empty,ofArray,ofListandofSeqin the three binding shapes,ofReadOnlySpan,ofSeqBy,ofSpan,singletonandKeyComparer.builder, and the module has noforComparers.nullcomparer throwsArgumentNullExceptionwith the parameter namekeyComparer. The default value of the struct stands for the default comparer, the one that the BCL takes fornull.ofSeqalso gives an existing map the key comparer of the struct. A frozen map whose comparerEqualsthe comparer of the struct comes back itself, asToFrozenDictionarydoes (D14). Two equal comparers need not be the same instance.FrozenMapExtensions(D13): an auto-opened module, inFrozenMap.Extensions.fsiand.fs, that extendsFrozenDictionary<'Key, 'Value>with staticofArray,ofListandofSeq. Each one is overloaded forKeyValuePairs, reference tuples and struct tuples.FrozenMap.ofSeq pairsreaches the overloads through the abbreviation, also aspairs |> FrozenMap.ofSeq.Mapand another frozen map are sources too.FrozenMapExtensions.OfArray,OfListandOfSeq, the struct or the BCL.MapandFrozenDictionarydiffer,FrozenMapfollowsFrozenDictionary, and the documentation of each function says so.Map.ofSeqdoes. Of keys that only the comparer finds equal, the map keeps the first key with the value of the last binding, as the indexer of aDictionarydoes.Mapkeeps the last key. The struct, the extension members, the span creations andfrozenMap { }behave alike.ofSeqBywrapsToFrozenDictionarywith a key selector. It rejects every repeated key withArgumentException, even for equal elements.ofSeqof an existing frozen map returns it when the comparers are equal, as above.net10.0only,alternateLookupandtryAlternateLookupwrapGetAlternateLookupandTryGetAlternateLookup.<'Alternate, 'Key, 'Value>. A call readsFrozenMap.alternateLookup<int, _, _> table.IAlternateEqualityComparer<'Alternate, 'Key>,alternateLookupthrowsInvalidOperationExceptionandtryAlternateLookupreturnsValueNone.ReadOnlySpan<char>for string keys, to the methods of the map.Option.FrozenMaprepeatschoose,pick,tryFind,tryFindKeyandtryPickwith options, andtryAlternateLookuponnet10.0.Tuple.FrozenMaprepeatspartition,toArray,toListandtoSeqwith reference tuples.frozenMap { }collectsyield struct (key, value)andyield KeyValuePairin aDictionaryand freezes it once, when it runs. A later binding of a key replaces the value and leaves the key.FrozenMapCE keyComparer { }takes a key comparer and rejectsnull.FrozenMapCE.fsicarries its documentation.Tests: 4,296 on
net8.0and 4,310 onnet10.0. 554 and 568 of them are new; the 14 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.FrozenMapderives the reading, transformation and creation contracts of feat:HashMapandIMapmodules with the vocabulary of the FSharp.CoreMapmodule #30. A frozen map has no mutation contract. The reading contract has a field forcontainsBinding; the tests of its value comparer stay with the maps that have one.CustomComparersFrozenMap, with a key comparer of absolute values. Every created map must find each key by its negation.CustomComparersFrozenMapTestscover:nullcomparer in the constructor and inforKeyComparer, with the parameter name;ofSeqof an existing map: it comes back itself exactly when its comparer equals the comparer of the struct. Two distinct comparers of one culture,StringComparer.Create (CultureInfo.InvariantCulture, true)twice, are the equal ones;ofReadOnlySpanandofSpanof the struct: they copy the span, keep the first key and the last value of keys that the comparer finds equal, and create the empty map of the comparer for an empty span.FrozenMapExtensionsTestscheck that the last value wins and the first key stays in every member and shape, with copies of a string that the default comparer finds equal, and that pipelines resolve the shape of their source.FrozenMapTestscover:nullinputs of every function, with the parameter names;keysandvalues;containsBinding: the key in the key comparer and the value in the default comparer,nan, which F# equality does not find, and anullkey on every kind of map;ofReadOnlySpanandofSpan: the bindings ofMap.ofSeq, a copy of the span, the first key with the last value, an array and aSpanwhere the types are known, and aSpanwhose type arguments the call infers;copyTo: the order of enumeration from the index, the elements around the copy, an empty map at the end of the array, and the exceptions of the BCL for an index outside the array, an array without room and anullarray;int64keys byintalternates.FrozenMapComputationExpressionTestscheck that every member offrozenMap { }keeps the earlier key and takes the later value, and thatFrozenMapCErejects anullcomparer.nulland for a default flat list, for the module, the members of the struct andfrozenMap { }; the extension overloads run through the module and the struct. A default flat list of strings given asseq<obj>has a row per module (FlatListOracles.defaultStringsAsObjects), and a flat list that is not default is checked to pass.yield!of anullarray and of anullfrozen map has a row each.open FSharp.Collections.Immutable.Tuple,FrozenMap.ofArray,ofListandofSeqstill reach the extensions.DataRowconstants, and the cases ofBindingPredicate.Types of changes
What types of changes does your code introduce to FSharp.Collections.Immutable?
Put an
xin the boxes that applyFrozenMapis a new module, and no existing function changes.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
ADR 0001, D14 settles the edge cases in which
MapandFrozenDictionarydiffer, so no question is open for this pull request. The module followsFrozenDictionaryin all three:ofSeqByrejects every repeated key, whereHashMap.ofSeqByaccepts a repeated equal element, asToImmutableDictionarydoes;ofSeqof an existing map returns it when its comparer equals the requested one, whereHashMapneeds the same comparer instance, asWithComparersdoes.Two notes for the review:
ToFrozenDictionaryof an empty source, so theemptymember of the struct calls it. It returnsFrozenDictionary.Emptyfor the default comparer.copyToandcontainsBindingleave the checks of the destination, the index and the key to the BCL, so their exceptions and parameter names are the BCL's. The one exception without a parameter name is theArgumentOutOfRangeExceptionfor the index. The tests pin it against the BCL method itself.🤖 Generated with Claude Code