Skip to content

Commit 44849dd

Browse files
committed
sg: inlining codegen, capture locals, [FastClonerTrustNullability], [FastClonerSafeHandle], add mapperly to benchmark
1 parent e060839 commit 44849dd

24 files changed

+987
-77
lines changed

README.md

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ The fastest deep cloning library, supporting anything from <code>.NET 4.6</code>
1919
- **The Fastest** - [Benchmarked](https://github.com/lofcz/FastCloner?tab=readme-ov-file#performance) to beat all other libraries with third-party independent benchmarks verifying the performance. **300x** speed-up vs `Newtonsoft.Json` and **160x** vs `System.Text.Json`
2020
- **The Most Correct** - Cloning objects is hard: `<T>`, `abstract`, immutables, read-only, pointers, circular dependencies, deeply nested graphs.. we have over [600 tests](https://github.com/lofcz/FastCloner/tree/next/FastCloner.Tests) verifying correct behavior in these cases and we are transparent about the [limitations](https://github.com/lofcz/FastCloner?tab=readme-ov-file#limitations)
2121
- **Novel Algorithm** - FastCloner recognizes that certain cloning code cannot be generated in certain scenarios and uses highly optimized reflection-based approach instead for these types - this only happens for the members that need this, not entire objects
22+
- **Zero-Overhead Abstractions** - The generator uses call site analysis to eliminate indirection via inlining of generated methods. This ensures the generated code behaves like a single optimized block, just as if you hand-wrote it for maximum performance.
2223
- **Embeddable** - FastCloner has no dependencies outside the standard library. Source generator and reflection parts can be installed independently
2324
- **Gentle & Caring** - FastCloner detects standard attributes like `[NonSerialized]` making it easy to try without polluting codebase with custom attributes. Type usage graph for generics is built automatically producing performant cloning code without manual annotations
2425
- **Easy Integration** - `FastDeepClone()` for AOT cloning, `DeepClone()` for reflection cloning. That's it!
@@ -143,7 +144,7 @@ Animal pet = new Dog { Name = "Buddy", Breed = "Labrador" };
143144
Animal clone = pet.FastDeepClone(); // Returns a cloned Dog
144145
```
145146

146-
### Explicitly Including Types with `[FastClonerInclude]`
147+
### Explicitly Including Types
147148

148149
When a type is only used dynamically (not visible at compile time), use `[FastClonerInclude]` to ensure the generator creates cloning code for it:
149150

@@ -167,7 +168,7 @@ public abstract class Plugin
167168
}
168169
```
169170

170-
### Cloning Context with `FastClonerContext`
171+
### Custom Cloning Context
171172

172173
For advanced scenarios, create a custom cloning context to explicitly register types you want to clone. This is useful when you need a centralized cloning entry point or want to clone types from external assemblies:
173174

@@ -205,6 +206,37 @@ if (ctx.TryClone(obj, out var cloned))
205206
}
206207
```
207208

209+
### Nullability Trust
210+
211+
The generator can be instructed to fully trust nullability annotations. When `[FastClonerTrustNullability]` attribute is applied, FastCloner will skip null checks for non-nullable reference types (e.g., `string` vs `string?`), assuming the contract is valid.
212+
213+
```csharp
214+
[FastClonerClonable]
215+
[FastClonerTrustNullability] // Skip null checks for non-nullable members
216+
public class HighPerformanceDto
217+
{
218+
public string Id { get; set; } // No null check generated
219+
public string? Details { get; set; } // Null check still generated
220+
}
221+
```
222+
223+
This eliminates branching and improves performance slightly. If a non-nullable property is actually null at runtime, this may result in a `NullReferenceException` in the generated code.
224+
225+
### Safe Handles
226+
227+
When you have a struct that acts as a handle to internal state or a singleton (where identity matters), use `[FastClonerSafeHandle]`. This tells FastCloner to shallow-copy the readonly fields instead of deep-cloning them, preserving the original internal references.
228+
229+
```csharp
230+
[FastClonerSafeHandle]
231+
public struct MyHandle
232+
{
233+
private readonly object _internalState; // Preserved (shared), not deep cloned
234+
public int Value; // Cloned normally
235+
}
236+
```
237+
238+
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.
239+
208240
## Limitations
209241

210242
- Cloning unmanaged resources, such as `IntPtr`s may result in side-effects, as there is no metadata for the length of buffers such pointers often point to.
@@ -239,10 +271,19 @@ Intel Core i7-8700 CPU 3.20GHz (Max: 3.19GHz) (Coffee Lake), 1 CPU, 12 logical a
239271

240272
You can run the benchmark [locally](https://github.com/lofcz/FastCloner/blob/next/FastCloner.Benchmark/BenchMinimal.cs) to verify the results. There are also [third-party benchmarks](https://github.com/AnderssonPeter/Dolly?tab=readme-ov-file#benchmarks) in some of the competing libraries confirming these results.
241273

274+
### Build Times & IDE Performance
275+
276+
FastCloner's source generator is carefully engineered for zero impact on IDE responsiveness and swift build times.
277+
278+
- **Tiered Caching**: We use `ForAttributeWithMetadataName` for highly efficient filtering and strictly separate syntax analysis from code generation.
279+
- **Smart Models**: Roslyn symbols are immediately projected into lightweight, cache-friendly `TypeModel` records. The generator never holds onto compilation symbols, allowing the incremental pipeline to perfectly cache previous results.
280+
- **No Compilation Trashing**: We avoid expensive `CompilationProvider` combinations that break generator caching. Code generation only re-runs when your data models actually change, not on every keystroke or unrelated edit.
281+
- **Allocation Free**: `EquatableArray` collections ensure that change detection is instant and creates no garbage collection pressure.
282+
242283
## Contributing
243284

244285
If you are looking to add new functionality, please open an issue first to verify your intent is aligned with the scope of the project. The library is covered by over [600 tests](https://github.com/lofcz/FastCloner/tree/next/src/FastCloner.Tests), please run them against your work before proposing changes. When reporting issues, providing a minimal reproduction we can plug in as a new test greatly reduces turnaround time.
245286

246287
## License
247288

248-
This library is licensed under the [MIT](https://github.com/lofcz/FastCloner/blob/next/LICENSE) license. 💜
289+
This library is licensed under the [MIT](https://github.com/lofcz/FastCloner/blob/next/LICENSE) license. 💜

src/FastCloner.Benchmark/BenchMinimal.cs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using NClone;
88
using Newtonsoft.Json;
99
using ProtoBuf;
10+
using Riok.Mapperly.Abstractions;
1011
using System.Runtime.Serialization.Formatters.Binary;
1112
using System.Text.Json;
1213

@@ -15,10 +16,11 @@ namespace FastCloner.Benchmark;
1516
[RankColumn]
1617
[Orderer(BenchmarkDotNet.Order.SummaryOrderPolicy.FastestToSlowest)]
1718
[MemoryDiagnoser]
18-
public class BenchMinimal
19+
public partial class BenchMinimal
1920
{
2021
private TestObject testData;
2122
private IMapper mapper;
23+
private TestObjectMapper mapperlyMapper;
2224

2325
[GlobalSetup]
2426
public void Setup()
@@ -39,6 +41,8 @@ public void Setup()
3941
cfg.CreateMap<NestedObject, NestedObject>();
4042
}, new NullLoggerFactory());
4143
mapper = config.CreateMapper();
44+
45+
mapperlyMapper = new TestObjectMapper();
4246
}
4347

4448
[Benchmark(Baseline = true)]
@@ -133,9 +137,9 @@ public object DeepCopyExpression()
133137
}
134138

135139
[Benchmark]
136-
public object? AnyCloneBenchmark()
140+
public object? AnyClone()
137141
{
138-
return AnyClone.CloneExtensions.Clone(testData);
142+
return global::AnyClone.CloneExtensions.Clone(testData);
139143
}
140144

141145
[Benchmark]
@@ -144,9 +148,16 @@ public object DeepCopyExpression()
144148
return Clone.ObjectGraph(testData);
145149
}
146150

151+
[Benchmark]
152+
public object? Mapperly()
153+
{
154+
return mapperlyMapper.TestObjectToTestObject(testData);
155+
}
156+
147157

148158
[Serializable]
149159
[FastClonerClonable]
160+
[FastClonerTrustNullability]
150161
[MessagePackObject]
151162
[ProtoContract]
152163
public class TestObject
@@ -174,4 +185,10 @@ public class NestedObject
174185
[ProtoMember(2)]
175186
public string Description { get; set; }
176187
}
188+
189+
[Mapper(UseDeepCloning = true)]
190+
public partial class TestObjectMapper
191+
{
192+
public partial TestObject TestObjectToTestObject(TestObject testObject);
193+
}
177194
}

src/FastCloner.Benchmark/FastCloner.Benchmark.csproj

Lines changed: 2 additions & 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="15.1.0" />
13+
<PackageReference Include="AutoMapper" Version="16.0.0" />
1414
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
1515
<PackageReference Include="DeepCloner" Version="0.10.4" />
1616
<PackageReference Include="DeepCopier" Version="1.0.4" />
@@ -23,6 +23,7 @@
2323
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
2424
<PackageReference Include="ObjectCloner" Version="2.2.2" />
2525
<PackageReference Include="protobuf-net" Version="3.2.56" />
26+
<PackageReference Include="Riok.Mapperly" Version="3.7.0" />
2627
<PackageReference Include="System.Runtime.Serialization.Formatters" Version="10.0.0" />
2728
</ItemGroup>
2829

src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,14 @@ public class FastClonerClonableAttribute : Attribute
1515
public FastClonerClonableAttribute()
1616
{
1717
}
18+
}
19+
20+
/// <summary>
21+
/// Instructs FastCloner to trust the nullability annotations of reference types.
22+
/// If a reference type member is not annotated as nullable (e.g. string instead of string?),
23+
/// FastCloner will NOT generate a null check for it, assuming it will never be null.
24+
/// </summary>
25+
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
26+
public class FastClonerTrustNullabilityAttribute : Attribute
27+
{
1828
}

src/FastCloner.SourceGenerator/ClassCloneBodyGenerator.cs

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ public static void WriteClassCloneBody(
6666
// When tracking circular references, we must register the instance BEFORE cloning members
6767
// to avoid infinite recursion (StackOverflowException) in case of cycles.
6868
// This requires us to instantiate first, then register, then assign members.
69-
WriteInstanceCreation(sb, typeName, hasParameterlessConstructor, isRecord, sourceVarName);
69+
WriteInstanceCreation(ctx, sb, typeName, hasParameterlessConstructor, isRecord, sourceVarName);
7070

7171
var stateVarForAdd = stateVarName ?? "state";
7272
var nullConditional = useNullConditional ? "?" : "";
@@ -83,33 +83,55 @@ public static void WriteClassCloneBody(
8383
}
8484
else
8585
{
86-
// For non-circular types, we can use object initializer syntax if constructor exists
86+
// For non-circular types, we can use mixed statement/initializer syntax
8787
if (hasParameterlessConstructor)
8888
{
89-
sb.AppendLine($" var result = new {typeName}");
90-
sb.AppendLine(" {");
91-
92-
var memberAssignments = new List<string>();
89+
// Create instance start
90+
sb.Append($" var result = new {typeName}");
91+
92+
// Collect init-only and required properties for object initializer
93+
var initOnlyMembers = new List<string>();
9394
foreach (var member in ctx.Model.Members)
9495
{
95-
var assignment = MemberCloneGenerator.GetMemberAssignment(ctx, member, sourceVarName, "null");
96-
if (!string.IsNullOrEmpty(assignment))
96+
if ((member.IsProperty && member.IsInitOnly) || member.IsRequired)
9797
{
98-
memberAssignments.Add($" {assignment}");
98+
var assignment = MemberCloneGenerator.GetMemberAssignment(ctx, member, sourceVarName, "null", " ");
99+
if (!string.IsNullOrEmpty(assignment))
100+
{
101+
initOnlyMembers.Add($" {assignment}");
102+
}
99103
}
100104
}
101105

102-
if (memberAssignments.Count > 0)
106+
if (initOnlyMembers.Count > 0)
103107
{
104-
sb.AppendLine(string.Join(",\n", memberAssignments));
108+
sb.AppendLine();
109+
sb.AppendLine(" {");
110+
sb.AppendLine(string.Join(",\n", initOnlyMembers));
111+
sb.AppendLine(" };");
112+
}
113+
else
114+
{
115+
sb.AppendLine("();");
105116
}
106117

107-
sb.AppendLine(" };");
118+
// Use statements for everything else (better for JIT and null handling)
119+
foreach (var member in ctx.Model.Members)
120+
{
121+
// Skip if already handled in initializer (init-only or required)
122+
if ((member.IsProperty && member.IsInitOnly) || member.IsRequired)
123+
continue;
124+
125+
if (!member.IsProperty || !member.IsInitOnly)
126+
{
127+
MemberCloneGenerator.WriteMemberCloning(ctx, member, "result", sourceVarName, "null");
128+
}
129+
}
108130
}
109131
else
110132
{
111133
// Use FormatterServices.GetUninitializedObject to create instance without calling constructor
112-
WriteInstanceCreation(sb, typeName, hasParameterlessConstructor, isRecord, sourceVarName);
134+
WriteInstanceCreation(ctx, sb, typeName, hasParameterlessConstructor, isRecord, sourceVarName);
113135

114136
// Then assign members individually (no state needed for non-circular types)
115137
foreach (var member in ctx.Model.Members)
@@ -128,7 +150,7 @@ public static void WriteClassCloneBody(
128150
/// For records, uses the 'with' expression for shallow copy.
129151
/// </summary>
130152
/// <param name="sourceVarName">The name of the source variable (usually "source" for classes, "src" for structs after null check)</param>
131-
private static void WriteInstanceCreation(StringBuilder sb, string typeName, bool hasParameterlessConstructor, bool isRecord, string sourceVarName = "source")
153+
private static void WriteInstanceCreation(CloneGeneratorContext ctx, StringBuilder sb, string typeName, bool hasParameterlessConstructor, bool isRecord, string sourceVarName = "source")
132154
{
133155
if (isRecord)
134156
{
@@ -137,7 +159,29 @@ private static void WriteInstanceCreation(StringBuilder sb, string typeName, boo
137159
}
138160
else if (hasParameterlessConstructor)
139161
{
140-
sb.AppendLine($" var result = new {typeName}();");
162+
// Check for required members
163+
var requiredMembers = new List<string>();
164+
foreach (var member in ctx.Model.Members)
165+
{
166+
if (member.IsRequired)
167+
{
168+
// Assign default! to satisfy compiler contract
169+
// We will assign real values later
170+
requiredMembers.Add($" {member.Name} = default!");
171+
}
172+
}
173+
174+
if (requiredMembers.Count > 0)
175+
{
176+
sb.AppendLine($" var result = new {typeName}");
177+
sb.AppendLine(" {");
178+
sb.AppendLine(string.Join(",\n", requiredMembers));
179+
sb.AppendLine(" };");
180+
}
181+
else
182+
{
183+
sb.AppendLine($" var result = new {typeName}();");
184+
}
141185
}
142186
else
143187
{
@@ -167,7 +211,7 @@ private static void WriteRecordCloneBody(CloneGeneratorContext ctx, string typeN
167211
if (member.IsReadOnly)
168212
continue;
169213

170-
var assignment = MemberCloneGenerator.GetMemberAssignment(ctx, member, sourceVarName, "null");
214+
var assignment = MemberCloneGenerator.GetMemberAssignment(ctx, member, sourceVarName, "null", " ");
171215
if (!string.IsNullOrEmpty(assignment))
172216
{
173217
deepCloneAssignments.Add($" {assignment}");

0 commit comments

Comments
 (0)