Skip to content

Commit 0713ed2

Browse files
committed
improve stable hash detection
1 parent 8d2511a commit 0713ed2

6 files changed

Lines changed: 244 additions & 22 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: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,134 @@
11
using System.Collections.Concurrent;
22
using System.Reflection;
3+
using System.Runtime.CompilerServices;
34
using System.Threading.Channels;
45
using System.Threading.Tasks.Dataflow;
56
using System.Threading.Tasks;
67

78
namespace FastCloner.Tests;
89
public class FailureHypothesisTests
910
{
11+
/// <summary>
12+
/// Demonstrates a weakness: <see cref="FastClonerSafeTypes"/> assumes any class that overrides
13+
/// <c>GetHashCode</c> has value-based hashing (<c>HasStableHashSemantics == true</c>). This drives
14+
/// hash-based collections through a memberwise (raw field) clone path that copies the internal
15+
/// <c>_slots</c>/<c>_buckets</c> arrays verbatim. When the override actually returns an identity-based
16+
/// hash (e.g. <c>RuntimeHelpers.GetHashCode(this)</c>), the cloned bucket entries store the *original*
17+
/// object's identity hash, but the elements inside are themselves deep-cloned and therefore have a
18+
/// brand-new identity hash. The cloned set/dictionary is structurally corrupt: lookups by the
19+
/// cloned key miss, even though the key is the very element stored in the clone.
20+
/// </summary>
21+
private sealed class IdentityHashedKey
22+
{
23+
public string Tag { get; set; } = "";
24+
public override int GetHashCode() => RuntimeHelpers.GetHashCode(this);
25+
public override bool Equals(object? obj) => ReferenceEquals(this, obj);
26+
}
27+
28+
[Test]
29+
public async Task HashSet_With_IdentityBased_OverriddenGetHashCode_Should_Be_Lookupable_After_Clone()
30+
{
31+
IdentityHashedKey item = new IdentityHashedKey { Tag = "a" };
32+
HashSet<IdentityHashedKey> original = [item];
33+
34+
HashSet<IdentityHashedKey> clone = original.DeepClone();
35+
36+
await Assert.That(clone).IsNotSameReferenceAs(original);
37+
await Assert.That(clone.Count).IsEqualTo(1);
38+
39+
IdentityHashedKey cloneItem = clone.Single();
40+
await Assert.That(cloneItem).IsNotSameReferenceAs(item)
41+
.Because("Element is a reference type and should be deep-cloned");
42+
43+
await Assert.That(clone.Contains(cloneItem)).IsTrue()
44+
.Because("Looking up the actual element of the cloned set must succeed; " +
45+
"FastCloner copies the original identity-based hash into the cloned bucket, " +
46+
"while the cloned element has a new identity hash, so lookup misses.");
47+
}
48+
49+
[Test]
50+
public async Task Dictionary_With_IdentityBased_OverriddenGetHashCode_Key_Should_Be_Lookupable_After_Clone()
51+
{
52+
IdentityHashedKey key = new IdentityHashedKey { Tag = "k" };
53+
Dictionary<IdentityHashedKey, int> original = new Dictionary<IdentityHashedKey, int> { [key] = 42 };
54+
55+
Dictionary<IdentityHashedKey, int> clone = original.DeepClone();
56+
57+
await Assert.That(clone).IsNotSameReferenceAs(original);
58+
await Assert.That(clone.Count).IsEqualTo(1);
59+
60+
IdentityHashedKey cloneKey = clone.Keys.Single();
61+
await Assert.That(cloneKey).IsNotSameReferenceAs(key);
62+
63+
await Assert.That(clone.TryGetValue(cloneKey, out int value)).IsTrue()
64+
.Because("The cloned dictionary must be able to find its own key. " +
65+
"FastCloner stores stale identity hashes from the original key in the cloned bucket.");
66+
await Assert.That(value).IsEqualTo(42);
67+
}
68+
69+
/// <summary>
70+
/// Type whose override would normally throw on a default-state probe instance (Tag is null, ToUpper NREs).
71+
/// Without an opt-in, the probe catches the throw and conservatively rebuilds the collection. With
72+
/// <see cref="FastClonerStableHashAttribute"/> the type author asserts the override is value-based, so
73+
/// FastCloner skips the probe and uses the fast memberwise path. Lookups in the cloned set must still work.
74+
/// </summary>
75+
[FastClonerStableHash]
76+
private sealed class ProbeUnfriendlyButStableKey
77+
{
78+
public string Tag { get; set; } = "";
79+
public override int GetHashCode() => Tag.ToUpperInvariant().GetHashCode();
80+
public override bool Equals(object? obj)
81+
=> obj is ProbeUnfriendlyButStableKey other
82+
&& string.Equals(Tag, other.Tag, StringComparison.OrdinalIgnoreCase);
83+
}
84+
85+
/// <summary>
86+
/// Same hash semantics as <see cref="ProbeUnfriendlyButStableKey"/> but without the attribute. Used to
87+
/// assert that the attribute really is what changes the verdict (not some unrelated probe success).
88+
/// </summary>
89+
private sealed class ProbeUnfriendlyKeyNoAttribute
90+
{
91+
public string Tag { get; set; } = "";
92+
public override int GetHashCode() => Tag.ToUpperInvariant().GetHashCode();
93+
public override bool Equals(object? obj)
94+
=> obj is ProbeUnfriendlyKeyNoAttribute other
95+
&& string.Equals(Tag, other.Tag, StringComparison.OrdinalIgnoreCase);
96+
}
97+
98+
[Test]
99+
public async Task FastClonerStableHashAttribute_Marks_Type_As_Stable()
100+
{
101+
await Assert.That(global::FastCloner.Code.FastClonerSafeTypes.HasStableHashSemantics(typeof(ProbeUnfriendlyButStableKey)))
102+
.IsTrue()
103+
.Because("[FastClonerStableHash] must short-circuit the probe and declare stable semantics, " +
104+
"even when GetHashCode would throw on default-state instances.");
105+
106+
// Unchanged behavior for the attribute-less twin: probe throws on null Tag, conservative rebuild.
107+
await Assert.That(global::FastCloner.Code.FastClonerSafeTypes.HasStableHashSemantics(typeof(ProbeUnfriendlyKeyNoAttribute)))
108+
.IsFalse()
109+
.Because("Without the opt-in, a probe that NREs on default state must fall back to rebuild.");
110+
}
111+
112+
[Test]
113+
public async Task FastClonerStableHashAttribute_Allows_FastPath_With_Correct_Lookup()
114+
{
115+
ProbeUnfriendlyButStableKey key = new ProbeUnfriendlyButStableKey { Tag = "Alpha" };
116+
HashSet<ProbeUnfriendlyButStableKey> original = [key];
117+
118+
HashSet<ProbeUnfriendlyButStableKey> clone = original.DeepClone();
119+
120+
await Assert.That(clone).IsNotSameReferenceAs(original);
121+
await Assert.That(clone.Count).IsEqualTo(1);
122+
123+
ProbeUnfriendlyButStableKey cloneKey = clone.Single();
124+
await Assert.That(cloneKey).IsNotSameReferenceAs(key);
125+
await Assert.That(clone.Contains(cloneKey)).IsTrue();
126+
127+
// Equality is case-insensitive, so a fresh key with different casing must also resolve.
128+
await Assert.That(clone.Contains(new ProbeUnfriendlyButStableKey { Tag = "alpha" })).IsTrue()
129+
.Because("Hash is value-based on Tag (case-insensitive) and survives the clone unchanged.");
130+
}
131+
10132
[Test]
11133
public async Task BufferBlock_Should_Be_Deep_Cloned_Independently()
12134
{

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/FastClonerSafeTypes.cs

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
using System.Collections.Concurrent;
2+
using System.Diagnostics.CodeAnalysis;
23
using System.Globalization;
34
using System.Numerics;
45
using System.Reflection;
56
using System.Runtime.CompilerServices;
7+
#if !MODERN
8+
using System.Runtime.Serialization;
9+
#endif
610
using System.Text;
711

812
namespace FastCloner.Code;
@@ -115,8 +119,6 @@ private static class TypePrefixes
115119
public const string SystemRuntimeType = "System.RuntimeType";
116120
public const string MicrosoftExtensions = "Microsoft.Extensions.DependencyInjection.";
117121
}
118-
119-
private static readonly Assembly propertyInfoAssembly = typeof(PropertyInfo).Assembly;
120122

121123
private static bool IsReflectionType(Type type)
122124
{
@@ -206,7 +208,8 @@ private static bool CanReturnSameType(Type type, HashSet<Type>? processingTypes
206208

207209
if (type.IsGenericType)
208210
{
209-
Type? genericDef = type.GetGenericTypeDefinition();
211+
Type genericDef = type.GetGenericTypeDefinition();
212+
210213
if (knownTypes.TryGetValue(genericDef, out bool isGenericSafe))
211214
{
212215
knownTypes.TryAdd(type, isGenericSafe);
@@ -271,7 +274,7 @@ internal static void ClearKnownTypesCache()
271274
}
272275

273276
/// <summary>
274-
/// Determines whether GetHashCode() result won't change after deep cloning (best effort).
277+
/// Determines whether GetHashCode() result won't change after deep cloning.
275278
/// </summary>
276279
internal static bool HasStableHashSemantics(Type type)
277280
{
@@ -280,29 +283,24 @@ internal static bool HasStableHashSemantics(Type type)
280283

281284
private static bool CalculateHasStableHashSemantics(Type type)
282285
{
283-
// Primitives are always stable - their hash is based on their value
284286
if (type.IsPrimitive)
285287
return true;
286288

287-
// String is immutable and has value-based hash
288289
if (type == typeof(string))
289290
return true;
290291

291-
// Enums are always stable - hash is based on underlying value
292292
if (type.IsEnum)
293293
return true;
294294

295-
// Value types: even if they don't override GetHashCode, their fields are copied
296-
// so the hash remains consistent after cloning
297295
if (type.IsValueType)
298296
return true;
299297

300-
// Known safe types from our dictionary are stable
301298
if (DefaultKnownTypes.ContainsKey(type))
302299
return true;
303-
304-
// Check if the type overrides GetHashCode (not using object.GetHashCode)
305-
// If a type has overridden GetHashCode, it's using value-based hashing
300+
301+
if (type.IsDefined(typeof(FastClonerStableHashAttribute), inherit: true))
302+
return true;
303+
306304
MethodInfo? getHashCodeMethod = type.GetMethod(
307305
"GetHashCode",
308306
BindingFlags.Public | BindingFlags.Instance,
@@ -312,13 +310,46 @@ private static bool CalculateHasStableHashSemantics(Type type)
312310

313311
if (getHashCodeMethod is not null && getHashCodeMethod.DeclaringType != typeof(object))
314312
{
315-
// Type has custom GetHashCode implementation
316-
// This indicates value-based equality semantics
317-
return true;
313+
return ProbeOverriddenHashIsValueBased(type);
318314
}
319315

320-
// Reference types using default GetHashCode use identity-based hash
321-
// These are NOT stable after cloning
322316
return false;
323317
}
318+
319+
[SuppressMessage("Usage", "CA1816:Dispose methods should call SuppressFinalize")]
320+
private static bool ProbeOverriddenHashIsValueBased(Type type)
321+
{
322+
if (type.IsAbstract || type.IsInterface || type.ContainsGenericParameters
323+
|| type.IsArray || type.IsPointer || type.IsByRef || type == typeof(string))
324+
{
325+
return false;
326+
}
327+
328+
try
329+
{
330+
object instance1 = CreateUninitialized(type);
331+
object instance2 = CreateUninitialized(type);
332+
333+
GC.SuppressFinalize(instance1);
334+
GC.SuppressFinalize(instance2);
335+
336+
int hash1 = instance1.GetHashCode();
337+
int hash2 = instance2.GetHashCode();
338+
339+
return hash1 == hash2;
340+
}
341+
catch
342+
{
343+
return false;
344+
}
345+
}
346+
347+
private static object CreateUninitialized(Type type)
348+
{
349+
#if MODERN
350+
return RuntimeHelpers.GetUninitializedObject(type);
351+
#else
352+
return FormatterServices.GetUninitializedObject(type);
353+
#endif
354+
}
324355
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
namespace FastCloner;
2+
3+
/// <summary>
4+
/// Declares that <see cref="object.GetHashCode"/> on this type is stable across deep clones,
5+
/// i.e. a deep-cloned instance will always produce the same hash code as the original.
6+
/// </summary>
7+
/// <remarks>
8+
/// <para>
9+
/// Applying this attribute lets FastCloner skip its runtime probe and use the fast memberwise
10+
/// clone path for hash-based collections (<see cref="System.Collections.Generic.HashSet{T}"/>,
11+
/// <see cref="System.Collections.Generic.Dictionary{TKey,TValue}"/>, etc.) keyed by this type,
12+
/// which is significantly cheaper than rebuilding the collection.
13+
/// </para>
14+
/// <para>
15+
/// Use this attribute when:
16+
/// <list type="bullet">
17+
/// <item><description>Your <c>GetHashCode</c> is a pure function of the type's fields, OR</description></item>
18+
/// <item><description>Your <c>GetHashCode</c> returns a constant, OR</description></item>
19+
/// <item><description>You know - by construction - that two equal-by-fields instances always hash the same.</description></item>
20+
/// </list>
21+
/// </para>
22+
/// <para>
23+
/// <b>Do not</b> apply this attribute if <c>GetHashCode</c> depends on object identity (e.g. uses
24+
/// <see cref="System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(object)"/>, or hashes a
25+
/// per-instance handle that isn't preserved through cloning). Marking such a type as stable will
26+
/// produce hash collections that lose the ability to look up their own contents after a clone.
27+
/// </para>
28+
/// <para>
29+
/// This attribute is a positive declaration: it marks <i>this</i> type as stable. It does not affect
30+
/// the cloning behavior of the type's members.
31+
/// </para>
32+
/// </remarks>
33+
/// <example>
34+
/// <code>
35+
/// [FastClonerStableHash]
36+
/// public sealed class CompositeKey
37+
/// {
38+
/// public int Major { get; }
39+
/// public int Minor { get; }
40+
/// public override int GetHashCode() => HashCode.Combine(Major, Minor);
41+
/// public override bool Equals(object? obj)
42+
/// =&gt; obj is CompositeKey other &amp;&amp; other.Major == Major &amp;&amp; other.Minor == Minor;
43+
/// }
44+
/// </code>
45+
/// </example>
46+
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
47+
public sealed class FastClonerStableHashAttribute : Attribute
48+
{
49+
}

0 commit comments

Comments
 (0)