Skip to content

Commit b77fcb4

Browse files
authored
Merge pull request #40 from lofcz/feat-better-stable-hash-detection
improve stable hash detection
2 parents 8d2511a + 4944984 commit b77fcb4

7 files changed

Lines changed: 535 additions & 45 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,26 @@ public struct MyHandle
270270

271271
This is the default behavior for system types like `System.Net.Http.Headers.HeaderDescriptor` to prevent breaking internal framework logic. Use this attribute if your custom structs behave similarly.
272272

273+
### Stable Hash Opt-in
274+
275+
Hash-based collections (`HashSet<T>`, `Dictionary<TKey, TValue>`, …) are cloned via a fast memberwise path whenever the key/element type's `GetHashCode` is known to be value-based. FastCloner figures this out automatically (and falls back to rebuilding the collection for identity-based hashes), but it can also be told explicitly with `[FastClonerStableHash]`:
276+
277+
```csharp
278+
[FastClonerStableHash]
279+
public sealed class CompositeKey
280+
{
281+
public int Major { get; }
282+
public int Minor { get; }
283+
public override int GetHashCode() => HashCode.Combine(Major, Minor);
284+
public override bool Equals(object? obj)
285+
=> obj is CompositeKey other && other.Major == Major && other.Minor == Minor;
286+
}
287+
```
288+
289+
Use it when `GetHashCode` is a pure function of the type's fields (or returns a constant). The attribute skips the runtime probe entirely, which is useful for types the probe cannot construct (abstract bases, types whose default-state `GetHashCode` would throw, etc.) and as a way to lock in the fast path explicitly.
290+
291+
> **Do not** apply this attribute if `GetHashCode` depends on object identity (e.g. `RuntimeHelpers.GetHashCode(this)`, or hashes a per-instance handle that isn't preserved through cloning) — the cloned collection will be unable to find its own contents.
292+
273293
### Identity Preservation
274294

275295
By default, FastCloner prioritizes performance by not tracking object identity during cloning. This means if the same object instance appears multiple times in your graph, each reference becomes a separate clone.

src/FastCloner.Benchmark/FastCloner.Benchmark.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
<ItemGroup>
1212
<PackageReference Include="AnyClone" Version="1.1.6" />
13-
<PackageReference Include="AutoMapper" Version="16.1.0" />
13+
<PackageReference Include="AutoMapper" Version="16.1.1" />
1414
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
1515
<PackageReference Include="DeepCloner" Version="0.10.4" />
1616
<PackageReference Include="DeepCopier" Version="1.0.4" />

src/FastCloner.Tests/FailureHypothesisTests.cs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,155 @@
11
using System.Collections.Concurrent;
2+
using System.Collections.Frozen;
23
using System.Reflection;
4+
using System.Runtime.CompilerServices;
35
using System.Threading.Channels;
46
using System.Threading.Tasks.Dataflow;
57
using System.Threading.Tasks;
68

79
namespace FastCloner.Tests;
810
public class FailureHypothesisTests
911
{
12+
private sealed class IdentityHashedKey
13+
{
14+
public string Tag { get; set; } = "";
15+
public override int GetHashCode() => RuntimeHelpers.GetHashCode(this);
16+
public override bool Equals(object? obj) => ReferenceEquals(this, obj);
17+
}
18+
19+
[Test]
20+
public async Task HashSet_With_IdentityBased_OverriddenGetHashCode_Should_Be_Lookupable_After_Clone()
21+
{
22+
IdentityHashedKey item = new IdentityHashedKey { Tag = "a" };
23+
HashSet<IdentityHashedKey> original = [item];
24+
25+
HashSet<IdentityHashedKey> clone = original.DeepClone();
26+
27+
await Assert.That(clone).IsNotSameReferenceAs(original);
28+
await Assert.That(clone.Count).IsEqualTo(1);
29+
30+
IdentityHashedKey cloneItem = clone.Single();
31+
await Assert.That(cloneItem).IsNotSameReferenceAs(item)
32+
.Because("Element is a reference type and should be deep-cloned");
33+
34+
await Assert.That(clone.Contains(cloneItem)).IsTrue()
35+
.Because("Looking up the actual element of the cloned set must succeed; " +
36+
"FastCloner copies the original identity-based hash into the cloned bucket, " +
37+
"while the cloned element has a new identity hash, so lookup misses.");
38+
}
39+
40+
[Test]
41+
public async Task Dictionary_With_IdentityBased_OverriddenGetHashCode_Key_Should_Be_Lookupable_After_Clone()
42+
{
43+
IdentityHashedKey key = new IdentityHashedKey { Tag = "k" };
44+
Dictionary<IdentityHashedKey, int> original = new Dictionary<IdentityHashedKey, int> { [key] = 42 };
45+
46+
Dictionary<IdentityHashedKey, int> clone = original.DeepClone();
47+
48+
await Assert.That(clone).IsNotSameReferenceAs(original);
49+
await Assert.That(clone.Count).IsEqualTo(1);
50+
51+
IdentityHashedKey cloneKey = clone.Keys.Single();
52+
await Assert.That(cloneKey).IsNotSameReferenceAs(key);
53+
54+
await Assert.That(clone.TryGetValue(cloneKey, out int value)).IsTrue()
55+
.Because("The cloned dictionary must be able to find its own key. " +
56+
"FastCloner stores stale identity hashes from the original key in the cloned bucket.");
57+
await Assert.That(value).IsEqualTo(42);
58+
}
59+
60+
[FastClonerStableHash]
61+
private sealed class ProbeUnfriendlyButStableKey
62+
{
63+
public string Tag { get; set; } = "";
64+
public override int GetHashCode() => Tag.ToUpperInvariant().GetHashCode();
65+
public override bool Equals(object? obj)
66+
=> obj is ProbeUnfriendlyButStableKey other
67+
&& string.Equals(Tag, other.Tag, StringComparison.OrdinalIgnoreCase);
68+
}
69+
70+
private sealed class ProbeUnfriendlyKeyNoAttribute
71+
{
72+
public string Tag { get; set; } = "";
73+
public override int GetHashCode() => Tag.ToUpperInvariant().GetHashCode();
74+
public override bool Equals(object? obj)
75+
=> obj is ProbeUnfriendlyKeyNoAttribute other
76+
&& string.Equals(Tag, other.Tag, StringComparison.OrdinalIgnoreCase);
77+
}
78+
79+
[Test]
80+
public async Task FastClonerStableHashAttribute_Marks_Type_As_Stable()
81+
{
82+
await Assert.That(Code.FastClonerSafeTypes.HasStableHashSemantics(typeof(ProbeUnfriendlyButStableKey)))
83+
.IsTrue()
84+
.Because("[FastClonerStableHash] must short-circuit the probe and declare stable semantics, " +
85+
"even when GetHashCode would throw on default-state instances.");
86+
87+
await Assert.That(Code.FastClonerSafeTypes.HasStableHashSemantics(typeof(ProbeUnfriendlyKeyNoAttribute)))
88+
.IsTrue()
89+
.Because("The smarter probe substitutes string.Empty for stable-hash reference fields, " +
90+
"so an override of Tag.ToUpperInvariant().GetHashCode() no longer NREs and is " +
91+
"correctly classified as stable even without the [FastClonerStableHash] opt-in.");
92+
}
93+
94+
[Test]
95+
public async Task FastClonerStableHashAttribute_Allows_FastPath_With_Correct_Lookup()
96+
{
97+
ProbeUnfriendlyButStableKey key = new ProbeUnfriendlyButStableKey { Tag = "Alpha" };
98+
HashSet<ProbeUnfriendlyButStableKey> original = [key];
99+
100+
HashSet<ProbeUnfriendlyButStableKey> clone = original.DeepClone();
101+
102+
await Assert.That(clone).IsNotSameReferenceAs(original);
103+
await Assert.That(clone.Count).IsEqualTo(1);
104+
105+
ProbeUnfriendlyButStableKey cloneKey = clone.Single();
106+
await Assert.That(cloneKey).IsNotSameReferenceAs(key);
107+
await Assert.That(clone.Contains(cloneKey)).IsTrue();
108+
109+
// Equality is case-insensitive, so a fresh key with different casing must also resolve.
110+
await Assert.That(clone.Contains(new ProbeUnfriendlyButStableKey { Tag = "alpha" })).IsTrue()
111+
.Because("Hash is value-based on Tag (case-insensitive) and survives the clone unchanged.");
112+
}
113+
114+
private sealed class IdentityNode
115+
{
116+
public string Tag { get; set; } = "";
117+
}
118+
119+
private struct IdentityDelegatingStruct : IEquatable<IdentityDelegatingStruct>
120+
{
121+
public IdentityNode? Node;
122+
123+
public override int GetHashCode() => Node is null ? 0 : Node.GetHashCode();
124+
125+
public bool Equals(IdentityDelegatingStruct other) => ReferenceEquals(Node, other.Node);
126+
127+
public override bool Equals(object? obj)
128+
=> obj is IdentityDelegatingStruct other && Equals(other);
129+
}
130+
131+
[Test]
132+
public async Task HashSet_Of_Struct_With_IdentityHashed_Reference_Field_Should_Be_Lookupable_After_Clone()
133+
{
134+
IdentityNode node = new IdentityNode { Tag = "n" };
135+
IdentityDelegatingStruct entry = new IdentityDelegatingStruct { Node = node };
136+
137+
HashSet<IdentityDelegatingStruct> original = [entry];
138+
HashSet<IdentityDelegatingStruct> clone = original.DeepClone();
139+
140+
await Assert.That(clone).IsNotSameReferenceAs(original);
141+
await Assert.That(clone.Count).IsEqualTo(1);
142+
143+
IdentityDelegatingStruct cloneEntry = clone.Single();
144+
await Assert.That(cloneEntry.Node).IsNotSameReferenceAs(node)
145+
.Because("Reference field inside the struct must be deep-cloned to a fresh instance.");
146+
147+
await Assert.That(clone.Contains(cloneEntry)).IsTrue()
148+
.Because("FastCloner treats every value type as having stable hash semantics, but this struct's " +
149+
"hash delegates to an identity-hashed reference field that gets a new identity on clone, " +
150+
"so the bucket-stored hash no longer matches the element's actual hash and lookup misses.");
151+
}
152+
10153
[Test]
11154
public async Task BufferBlock_Should_Be_Deep_Cloned_Independently()
12155
{
@@ -227,6 +370,47 @@ public async Task ReaderWriterLockSlim_Should_Be_Deep_Cloned_Independently()
227370
// Ignore cleanup errors
228371
}
229372
}
373+
374+
[Test]
375+
public async Task FrozenSet_Should_Be_Cloned_Without_StackOverflow()
376+
{
377+
FrozenSet<string> original = new[] { "alpha", "beta", "gamma" }.ToFrozenSet();
378+
379+
FrozenSet<string> clone = original.DeepClone();
380+
381+
await Assert.That(clone.Count).IsEqualTo(3)
382+
.Because("All elements must survive the clone.");
383+
await Assert.That(clone.Contains("alpha")).IsTrue();
384+
await Assert.That(clone.Contains("beta")).IsTrue();
385+
await Assert.That(clone.Contains("gamma")).IsTrue();
386+
await Assert.That(clone.Contains("delta")).IsFalse();
387+
}
388+
389+
private sealed class StructWrappedSelfReferenceContainer
390+
{
391+
public List<int> Payload { get; set; } = [1, 2, 3];
392+
public StructBackRef BackRef;
393+
}
394+
395+
private struct StructBackRef
396+
{
397+
public StructWrappedSelfReferenceContainer? Owner;
398+
}
399+
400+
[Test]
401+
public async Task Container_With_StructMediated_SelfReference_Should_Clone_Without_Overflow()
402+
{
403+
StructWrappedSelfReferenceContainer original = new StructWrappedSelfReferenceContainer();
404+
original.BackRef = new StructBackRef { Owner = original };
405+
await Assert.That(original.BackRef.Owner).IsSameReferenceAs(original);
406+
407+
StructWrappedSelfReferenceContainer clone = original.DeepClone();
408+
409+
await Assert.That(clone).IsNotSameReferenceAs(original);
410+
await Assert.That(clone.Payload).IsEquivalentTo(new[] { 1, 2, 3 });
411+
await Assert.That(clone.BackRef.Owner).IsSameReferenceAs(clone)
412+
.Because("The cycle must be rebound to the clone, not left dangling at the original.");
413+
}
230414

231415
[Test]
232416
public async Task Task_Should_Not_Be_Deep_Cloned()

src/FastCloner.Tests/FastCloner.Tests.csproj

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@
1515
<ItemGroup>
1616
<PackageReference Include="FluentNHibernate" Version="3.4.1" />
1717
<PackageReference Include="FluentValidation" Version="12.1.1" />
18-
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
18+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
1919
<PackageReference Include="NHibernate" Version="5.6.0" />
20-
<PackageReference Include="ObjectDumper.NET" Version="4.4.10-pre" />
20+
<PackageReference Include="ObjectDumper.NET" Version="4.4.13-pre" />
2121
<PackageReference Include="System.Data.SqlClient" Version="4.9.1" />
2222
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" />
2323
<PackageReference Include="System.Drawing.Common" Version="10.0.1" />
2424
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.3" />
2525
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.3" />
26-
<PackageReference Include="TUnit" Version="1.22.6" />
26+
<PackageReference Include="TUnit" Version="1.43.11" />
2727
</ItemGroup>
2828

2929
<PropertyGroup>

src/FastCloner/Code/FastClonerGenerator.cs

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -270,8 +270,8 @@ private static FastClonerCache.TypeCloneMetadata BuildTypeMetadata(Type type)
270270

271271
if (cacheEntry.Cloner is not null)
272272
{
273-
return cacheEntry.CanUseNoTrackingState ?
274-
cacheEntry.Cloner(obj, FastCloneState.GetSimpleState()) :
273+
return cacheEntry.CanUseNoTrackingState ?
274+
cacheEntry.Cloner(obj, FastCloneState.GetSimpleState()) :
275275
CloneRootWithTrackedState(obj, cacheEntry.Cloner, cacheEntry.Metadata!);
276276
}
277277
}
@@ -371,7 +371,7 @@ private static bool TryCloneSafeArrayRoot<T>(T obj, Type runtimeType, out T? clo
371371
{
372372
return obj;
373373
}
374-
374+
375375
if (metadata.CanSkipReferenceTracking ||
376376
(metadata is { CyclePolicy: FastClonerCache.CyclePolicy.None, HasBehaviorSensitiveMembers: false } && !rootType.IsValueType))
377377
return cloner(obj, FastCloneState.GetSimpleState());
@@ -469,11 +469,14 @@ private static bool CalculateCanHaveCycles(Type type)
469469
Type? payloadType = GetCollectionPayloadType(type);
470470
if (payloadType is not null)
471471
{
472-
if (FastClonerSafeTypes.CanReturnSameObject(payloadType))
473-
return false;
472+
bool payloadTriviallyAcyclic =
473+
FastClonerSafeTypes.CanReturnSameObject(payloadType) ||
474+
(payloadType.IsValueType && !ValueTypeContainsReferenceFieldsCached(payloadType));
474475

475-
if (payloadType.IsValueType && !ValueTypeContainsReferenceFieldsCached(payloadType))
476-
return false;
476+
if (payloadTriviallyAcyclic)
477+
{
478+
return ContainerHasStructMediatedSelfReference(type);
479+
}
477480
}
478481

479482
Type[] fieldTypes = GetCycleFieldTypes(type);
@@ -492,6 +495,61 @@ private static bool CalculateCanHaveCycles(Type type)
492495

493496
return false;
494497
}
498+
499+
private static bool ContainerHasStructMediatedSelfReference(Type rootType)
500+
{
501+
HashSet<Type> visited = [];
502+
return HasStructMediatedSelfReferenceCore(rootType, rootType, visited);
503+
}
504+
505+
private static bool HasStructMediatedSelfReferenceCore(Type current, Type rootType, HashSet<Type> visited)
506+
{
507+
if (!visited.Add(current))
508+
return false;
509+
510+
Type[] fieldTypes = GetCycleFieldTypes(current);
511+
for (int i = 0; i < fieldTypes.Length; i++)
512+
{
513+
Type fieldType = fieldTypes[i];
514+
515+
if (FastClonerSafeTypes.CanReturnSameObject(fieldType))
516+
continue;
517+
518+
if (fieldType.IsValueType)
519+
{
520+
if (HasStructMediatedSelfReferenceCore(fieldType, rootType, visited))
521+
return true;
522+
continue;
523+
}
524+
525+
if (FieldTypeCouldReferenceRoot(fieldType, rootType))
526+
return true;
527+
}
528+
529+
return false;
530+
}
531+
532+
private static bool FieldTypeCouldReferenceRoot(Type fieldType, Type rootType)
533+
{
534+
if (fieldType == rootType ||
535+
fieldType.IsAssignableFrom(rootType) ||
536+
rootType.IsAssignableFrom(fieldType))
537+
{
538+
return true;
539+
}
540+
541+
if (fieldType.IsArray)
542+
{
543+
Type? element = fieldType.GetElementType();
544+
if (element is not null &&
545+
(element == rootType || element.IsAssignableFrom(rootType) || rootType.IsAssignableFrom(element)))
546+
{
547+
return true;
548+
}
549+
}
550+
551+
return false;
552+
}
495553

496554
private static bool ValueTypeContainsReferenceFields(Type valueType, HashSet<Type> visited)
497555
{

0 commit comments

Comments
 (0)