Skip to content

Clone a collection subclass that implements IDeepCloneable by its own implementation in every overload [patch] - #150

Merged
matt-edmondson merged 2 commits into
mainfrom
fix/own-clone-for-collection-subclasses-148
Nov 13, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
fix/own-clone-for-collection-subclasses-148

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #148

What was wrong

#141 (for #140) made only the IEnumerable<T> extension defer to a source's own IDeepCloneable.DeepClone(). A type such as class Tags : List<Item>, IDeepCloneable has no instance DeepClone(), so tags.DeepClone() binds to the more specific List<T> overload. That overload cloned element by element into a plain List<T>, and the subclass's type and its Label were lost. The same happened for HashSet, Dictionary, Queue and the other concrete overloads.

The path #141 did add also called cloneable.DeepClone() without opening the clone context. So an item the collection held twice came back as two clones, against #132.

Change

  • New private helper CloneItself. It runs first in every collection overload whose type can be subclassed: IEnumerable, IDictionary, Dictionary, SortedDictionary, SortedList, OrderedDictionary (.NET 9+), ConcurrentDictionary, ReadOnlyDictionary, IReadOnlyDictionary, HashSet, List, Queue, LinkedList, SortedSet, Stack, ConcurrentStack, ConcurrentQueue and ConcurrentBag. Arrays and the immutable and frozen types can't be subclassed, so their overloads are unchanged.
  • The helper calls the source's own clone inside CloneContext.Enter(), so one item held twice clones to one shared clone.
  • Re-entrancy guard (beyond the issue): an implementation may build its clone by calling the base overload on itself, e.g. clone.AddRange(DeepCloneContainerExtensions.DeepClone<Item>(this)). Deferring to IDeepCloneable in every overload would make that recurse until the stack overflows. A [ThreadStatic] reference set of the sources currently running their own clone handles this. Such an inner call falls through to the element-wise clone, which is what it got before.
  • CloneContext.ReferenceComparer goes from private to internal so the guard can reuse it.

#149 (registering collections in the clone context) changes the top of the same overloads. The triage suggests doing it in this helper, but it is a separate behaviour change and is left to its own PR. CloneItself is the natural place to add its TryGetClone/Register.

Tests

New in CloneableCollectionTests:

  • List_, HashSet_, Dictionary_ and Queue_DeepClone_OfIDeepCloneableSubclass_ShouldUseItsOwnClone. Each is the issue's Tags repro through that overload. The clone must be the subclass, keep Label == "x" and hold cloned items.
  • Enumerable_DeepClone_OfIDeepCloneableListSubclass_ShouldUseItsOwnClone: the same subclass through IEnumerable<T>. This already passed and stays in as a guard.
  • Enumerable_DeepClone_OfIDeepCloneableCollectionHoldingOneObjectTwice_ShouldShareOneClone: the issue's Bag holding x twice.
  • List_DeepClone_OfSubclassWhoseCloneCallsTheBaseOverload_ShouldNotRecurse: the re-entrancy guard.

Without the library change, 6 of the 7 new tests fail (wrong runtime type, or two clones of the shared item). The guard test fails too, because before the change the type was lost. Full suite on Linux / .NET 10: 168/168 passed. The library builds for net10.0, net9.0, net8.0, netstandard2.1 and netstandard2.0.


Generated by Claude Code

matt-edmondson and others added 2 commits November 13, 2026 03:30
… implementation in every overload [patch]

#141 made only the IEnumerable<T> overload defer to a source's own
IDeepCloneable.DeepClone(). A List<T>, HashSet<T>, Dictionary<,>,
Queue<T> or other concrete-type subclass bound to its more specific
overload and was cloned element by element, losing its type and state.

A shared CloneItself helper now runs first in every collection overload
whose type can be derived from. It opens the clone context, so an item
the collection holds twice is cloned once, and it falls through to the
element-wise clone when the implementation calls the base overload on
itself, instead of recursing.

Fixes #148

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017vfJW3faRnes8WmPvx9zps
…s own clone

SonarCloud reported 71% coverage on new code: the early return in the
IDictionary, IReadOnlyDictionary, SortedDictionary, SortedList,
OrderedDictionary, ConcurrentDictionary, ReadOnlyDictionary, LinkedList,
SortedSet, Stack and Concurrent* overloads had no test. One test now
sends a marked subclass through each of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017vfJW3faRnes8WmPvx9zps
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 2b93293 into main Nov 13, 2026
15 checks passed
@matt-edmondson
matt-edmondson deleted the fix/own-clone-for-collection-subclasses-148 branch November 13, 2026 21:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant