Feat cloning by member visibility#42
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a type-level member-visibility policy to FastCloner and updates both the runtime cloner and source generator to better handle cloning of non-public members (via UnsafeAccessor on .NET 8+ and a runtime-bridge proxy on older TFMs). This supersedes the prior approach of skipping private/protected members in the generator by enabling compatible access paths instead.
Changes:
- Introduces
[FastClonerVisibility]/FastClonerMemberVisibilityto filter eligible members and resets policy-excluded slots to default values during runtime cloning. - Extends the source generator to clone non-public fields/properties using
UnsafeAccessor(net8+) or a generated proxy that binds to a runtime “bridge” contract (older TFMs). - Adds/updates tests and documentation for the new visibility policy and non-public member cloning behavior.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/FastCloner/FastClonerSourceGeneratorBridgeContractAttributes.cs | Adds internal attributes that define the runtime bridge contract for the source generator. |
| src/FastCloner/FastClonerSourceGeneratorBridge.cs | Implements runtime bridge methods for non-public field/property cloning and member resolution. |
| src/FastCloner/Code/FastClonerVisibilityAttribute.cs | Introduces visibility policy attribute + flags enum for member eligibility. |
| src/FastCloner/Code/FastClonerExprGenerator.cs | Applies visibility filtering in runtime type-shape building and resets excluded members to defaults. |
| src/FastCloner/Code/FastClonerCache.cs | Adds caching for attributed type visibility and minor cache API cleanup. |
| src/FastCloner.Tests/VisibilityAttributeRuntimeTests.cs | Adds runtime tests covering visibility policies and override behavior. |
| src/FastCloner.Tests/SourceGeneratorEdgeCaseTests.cs | Expands SG edge-case coverage for private/protected/internal accessors and visibility policy. |
| src/FastCloner.Tests/FastCloner.Tests.csproj | Updates TUnit package version. |
| src/FastCloner.Tests/BridgeProxyEmitterTests.cs | Adds tests validating bridge proxy emission and stability. |
| src/FastCloner.SourceGenerator/StateRequirementAnalyzer.cs | Replaces target-typed new(...) with explicit generic constructions. |
| src/FastCloner.SourceGenerator/NonPublicAccessorEmitter.cs | Adds codegen for non-public member access (UnsafeAccessor or runtime bridge proxy). |
| src/FastCloner.SourceGenerator/MemberModel.cs | Adds visibility/access strategy metadata to the member model and accessibility mapping helpers. |
| src/FastCloner.SourceGenerator/MemberCollector.cs | Adds visibility-policy filtering and collects non-public members for accessor-based cloning. |
| src/FastCloner.SourceGenerator/MemberCloneGenerator.cs | Routes cloning through non-public accessor emitters when needed. |
| src/FastCloner.SourceGenerator/FastClonerIncrementalGenerator.cs | Collects bridge contract, emits proxy when applicable, and reports new diagnostics for skipped members. |
| src/FastCloner.SourceGenerator/ContextCodeGenerator.cs | Minor ctor invocation cleanup using named args. |
| src/FastCloner.SourceGenerator/CloneGeneratorContext.cs | Stores bridge contract + tracks generated non-public accessors and skipped members. |
| src/FastCloner.SourceGenerator/CloneCodeGenerator.cs | Emits accessor blocks (including generic shells) and threads bridge contract through generation. |
| src/FastCloner.SourceGenerator/BridgeProxyEmitter.cs | Generates a proxy class that binds to runtime bridge methods via reflection + delegates (older TFMs). |
| src/FastCloner.SourceGenerator/BridgeContractCollector.cs | Extracts bridge contract metadata from referenced FastCloner runtime assembly. |
| src/FastCloner.SourceGenerator/BridgeContract.cs | Defines bridge contract/method spec models used by emitter and generator. |
| src/FastCloner.SourceGenerator/AssemblyInfo.cs | Adds InternalsVisibleTo for generator tests. |
| README.md | Documents the new [FastClonerVisibility] behavior and override rules. |
| .bench-harness-current/Program.cs | Removes bench harness program. |
| .bench-harness-current/BenchHarness.csproj | Removes bench harness project. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private static FastClonerMemberVisibility GetVisibilityPolicyFromType(INamedTypeSymbol type) | ||
| { | ||
| foreach (AttributeData attr in type.GetAttributes()) | ||
| { | ||
| if (attr.AttributeClass?.ToDisplayString() != "FastCloner.Code.FastClonerVisibilityAttribute") |
| if (!HasExplicitMemberBehaviorAttribute(property, compilation)) | ||
| { | ||
| members.Add(new MemberAnalysis(MemberModel.Create(property, nullabilityEnabled, compilation, behavior), property.Type)); | ||
| FastClonerMemberVisibility memberMask = MemberModel.MapAccessibility(property.DeclaredAccessibility); | ||
| if ((visibilityPolicy & memberMask) == 0) | ||
| continue; |
| bool isPopulatableCollection = IsPopulatableCollectionType(property.Type); | ||
| bool hasSetter = property.SetMethod != null; | ||
|
|
||
| // Include property if it has an accessible setter OR it's a getter-only populatable collection | ||
| if (hasAccessibleSetter || isPopulatableCollection) | ||
| if (hasSetter || isPopulatableCollection) | ||
| { |
| private static bool HasExplicitMemberBehaviorAttribute(ISymbol member, Compilation compilation) | ||
| { | ||
| INamedTypeSymbol? behaviorAttribute = compilation.GetTypeByMetadataName("FastCloner.Code.FastClonerBehaviorAttribute"); | ||
| INamedTypeSymbol? shallowAttribute = compilation.GetTypeByMetadataName("FastCloner.Code.FastClonerShallowAttribute"); | ||
| INamedTypeSymbol? referenceAttribute = compilation.GetTypeByMetadataName("FastCloner.Code.FastClonerReferenceAttribute"); |
| : (accessor.IsBackingFieldStorage | ||
| ? $"{accessorPrefix}{accessor.AccessorMethodName}({structRefSource}{sourceVar})" | ||
| : throw new System.InvalidOperationException( | ||
| $"FastCloner SG: cannot read non-public, non-field member '{accessor.MemberName}' via UnsafeAccessor (would need a getter accessor).")); |
| private static FastClonerMemberVisibility PropertyVisibilityMask(PropertyInfo property) | ||
| { | ||
| MethodInfo? accessor = property.GetSetMethod(nonPublic: true) ?? property.GetGetMethod(nonPublic: true); | ||
| if (accessor is null) |
| "Either upgrade the consumer to .NET 8+, or install the FastCloner runtime package " + | ||
| ", or apply [FastClonerVisibility] / [FastClonerIgnore] to opt out explicitly.", |
Deep Clone Benchmarks
Current FastCloner vs DeepCloner
FastCloner vs latest
|
| Status | Benchmark | Delta Time | Delta Alloc |
|---|---|---|---|
| ⚪ | DynamicWithArray | +4% slower | ~same |
| ⚪ | DynamicWithDictionary | ~same | ~same |
| ⚪ | DynamicWithNestedObject | -3% faster | ~same |
| ⚪ | FileSpec | +3% slower | ~same |
| ⚪ | LargeEventDocument_10MB | +2% slower | ~same |
| ⚪ | LargeLogBatch_10MB | -2% faster | ~same |
| ⚪ | MediumNestedObject | ~same | ~same |
| ⚪ | ObjectDictionary_50 | -5% faster | ~same |
| ⚪ | ObjectList_100 | ~same | ~same |
| ⚪ | SmallObject | ~same | ~same |
| ⚪ | SmallObjectWithCollections | -3% faster | ~same |
| ⚪ | StringArray_1000 | ~same | ~same |
Regressions
- none
Improvements
- none
Mixed changes
- none
| } | ||
|
|
||
| [AttributeUsage(AttributeTargets.Method, Inherited = false)] | ||
| internal sealed class FastClonerSourceGeneratorBridgeMemberAttribute : Attribute; |
| if (skipReadonly) | ||
| continue; | ||
|
|
| bool participatesInInitializer = | ||
| (member is { IsProperty: true, IsInitOnly: true } || member.IsRequired); | ||
| if (!participatesInInitializer) | ||
| continue; | ||
|
|
||
| if (member.AccessorStrategy != NonPublicAccessorStrategy.None) | ||
| continue; | ||
|
|
| if (hasNonDefaultPolicy) | ||
| { | ||
| PropertyInfo? backedProperty = TryGetBackingPropertyForField(field); | ||
| bool hasExplicit = HasExplicitMemberBehavior(field) || | ||
| (backedProperty is not null && HasExplicitMemberBehavior(backedProperty)); | ||
| if (!hasExplicit) | ||
| { | ||
| FastClonerMemberVisibility memberMask = backedProperty is not null | ||
| ? PropertyVisibilityMask(backedProperty) | ||
| : FieldVisibilityMask(field); | ||
| if ((visibilityPolicy & memberMask) == 0) | ||
| { | ||
| (excludedByVisibility ??= []).Add(field); | ||
| continue; | ||
| } |
| private static void WriteRuntimeBridgeCall( | ||
| CloneGeneratorContext context, | ||
| StringBuilder sb, | ||
| MemberModel member, | ||
| NonPublicAccessor accessor, | ||
| string resultVar, | ||
| string sourceVar, | ||
| string indent) | ||
| { | ||
| string accessorPrefix = context.GetNonPublicAccessorPrefix(); | ||
| string proxyFqn = "global::" + context.BridgeContract.ProxyTypeFullName; | ||
|
|
||
| if (accessor.IsBackingFieldStorage) | ||
| { | ||
| string fiRef = $"{accessorPrefix}{accessor.AccessorMethodName}_FI"; | ||
| if (member.IsShallowClone) | ||
| { | ||
| sb.AppendLine($"{indent}{proxyFqn}.CopyField({sourceVar}, {resultVar}, {fiRef});"); | ||
| } | ||
| else | ||
| { | ||
| sb.AppendLine($"{indent}{proxyFqn}.DeepCloneField({sourceVar}, {resultVar}, {fiRef});"); | ||
| } | ||
| } | ||
| else | ||
| { | ||
| string piRef = $"{accessorPrefix}{accessor.AccessorMethodName}_PI"; | ||
| sb.AppendLine($"{indent}{proxyFqn}.DeepCloneProperty({sourceVar}, {resultVar}, {piRef});"); | ||
| } |
|
|
||
| if (member.AccessorStrategy != NonPublicAccessorStrategy.None) | ||
| return string.Empty; | ||
|
|
|
|
||
| expressionList.Add(Expression.Call( | ||
| Expression.Constant(fieldInfo), | ||
| fieldSetMethod, | ||
| Expression.Convert(toLocal, typeof(object)), | ||
| Expression.Convert(Expression.Default(fieldInfo.FieldType), typeof(object)))); |
| /// <summary> | ||
| /// Pure-internal members. Combines with <see cref="Protected"/> to also include | ||
| /// <c>protected internal</c> and <c>private protected</c>. | ||
| /// </summary> | ||
| Internal = 1 << 1, | ||
|
|
||
| /// <summary> | ||
| /// Protected members. Combines with <see cref="Internal"/> to also include | ||
| /// <c>protected internal</c> and <c>private protected</c>. | ||
| /// </summary> | ||
| Protected = 1 << 2, |
| [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] | ||
| public sealed class FastClonerVisibilityAttribute : Attribute |
|
The |
|
@MatthewSteeples sorry, there was some versioning drift, published SourceGenerator 1.2.1. |
Supersedes #41