diff --git a/README.md b/README.md
index b8b8746..7830669 100644
--- a/README.md
+++ b/README.md
@@ -177,6 +177,24 @@ Animal pet = new Dog { Name = "Buddy", Breed = "Labrador" };
Animal clone = pet.FastDeepClone(); // Returns a cloned Dog
```
+For non-abstract base classes, you can opt into the same runtime subtype dispatch behavior:
+
+```cs
+[FastClonerClonable(IncludeSubtypes = true)]
+public class Device
+{
+ public string Name { get; set; }
+}
+
+public class Phone : Device
+{
+ public string OS { get; set; }
+}
+
+Device device = new Phone { Name = "Pixel", OS = "Android" };
+Device clone = device.FastDeepClone(); // Returns a cloned Phone
+```
+
### Explicitly Including Types
When a type is only used dynamically (not visible at compile time), use `[FastClonerInclude]` to ensure the generator creates cloning code for it:
diff --git a/src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs b/src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs
index 569fd3a..431858b 100644
--- a/src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs
+++ b/src/FastCloner.SourceGenerator.Shared/FastClonerClonableAttribute.cs
@@ -9,6 +9,21 @@ namespace FastCloner.SourceGenerator.Shared;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public class FastClonerClonableAttribute : Attribute
{
+ ///
+ /// Gets or sets whether subtype dispatch should be generated for this type.
+ /// When true, generated clone code dispatches by runtime type and clones known subtypes similarly to abstract roots.
+ /// This is ignored for abstract types, which always have subtype dispatch.
+ /// This is ignored for structs, which cannot have subtypes.
+ /// This is ignored for sealed classes, which cannot be subclassed.
+ ///
+ ///
+ ///
+ /// [FastClonerClonable(IncludeSubtypes = true)]
+ /// public class BaseType { }
+ ///
+ ///
+ public bool IncludeSubtypes { get; set; }
+
///
/// Initializes a new instance of the FastClonerClonableAttribute.
///
diff --git a/src/FastCloner.SourceGenerator/CloneCodeGenerator.cs b/src/FastCloner.SourceGenerator/CloneCodeGenerator.cs
index 09cee05..66a2eb4 100644
--- a/src/FastCloner.SourceGenerator/CloneCodeGenerator.cs
+++ b/src/FastCloner.SourceGenerator/CloneCodeGenerator.cs
@@ -208,7 +208,9 @@ private void WritePublicFastDeepCloneMethod(string typeName, string fullTypeName
return;
}
- if (_context.Model.IsAbstract)
+ bool needsSubtypeDispatcher = !_context.Model.IsStruct && (_context.Model.IsAbstract || _context.Model.IncludeSubtypes);
+
+ if (needsSubtypeDispatcher)
{
sb.AppendLine(" return InternalFastDeepClone(source, null);");
}
@@ -274,11 +276,19 @@ private void WritePrivateFastDeepCloneMethod(string typeName, string fullTypeNam
sb.AppendLine(" if (source == null) return null;");
}
- if (_context.Model.IsAbstract)
+ bool needsSubtypeDispatcher = !_context.Model.IsStruct && (_context.Model.IsAbstract || _context.Model.IncludeSubtypes);
+ if (needsSubtypeDispatcher)
{
- WriteAbstractTypeDispatcher(typeName);
+ WriteSubtypeDispatcher(typeName, fullTypeName, includeRootTypeGuard: !_context.Model.IsAbstract);
+ if (_context.Model.IsAbstract)
+ {
+ sb.AppendLine(" }");
+ sb.AppendLine();
+ return;
+ }
}
- else if (_context.NeedsStateTracking)
+
+ if (_context.NeedsStateTracking)
{
_context.NeedsStateClass = true;
sb.AppendLine(" var localState = state ?? new FcGeneratedCloneState();");
@@ -309,7 +319,7 @@ private void WritePrivateFastDeepCloneMethod(string typeName, string fullTypeNam
sb.AppendLine();
}
- private void WriteAbstractTypeDispatcher(string typeName)
+ private void WriteSubtypeDispatcher(string typeName, string fullTypeName, bool includeRootTypeGuard)
{
StringBuilder sb = _context.Source;
EquatableArray derivedTypes = _context.Model.DerivedTypes;
@@ -319,6 +329,14 @@ private void WriteAbstractTypeDispatcher(string typeName)
sb.AppendLine(" var runtimeType = source.GetType();");
sb.AppendLine();
+ string indent = " ";
+ if (includeRootTypeGuard)
+ {
+ sb.AppendLine($" if (runtimeType != typeof({fullTypeName}))");
+ sb.AppendLine(" {");
+ indent = " ";
+ }
+
foreach (TypeModel? derivedType in derivedTypes)
{
string derivedTypeName = derivedType.FullyQualifiedName;
@@ -331,29 +349,35 @@ private void WriteAbstractTypeDispatcher(string typeName)
extensionClassName = $"{derivedType.Name}FastDeepCloneExtensions";
}
- sb.AppendLine($" if (runtimeType == typeof({derivedTypeName}))");
- sb.AppendLine($" return ({typeName}){extensionClassName}.InternalFastDeepClone(({derivedTypeName})source, state);");
+ sb.AppendLine($"{indent}if (runtimeType == typeof({derivedTypeName}))");
+ sb.AppendLine($"{indent} return ({typeName}){extensionClassName}.InternalFastDeepClone(({derivedTypeName})source, state);");
}
else
{
string helperName = $"Clone{GetSafeTypeName(derivedType.Name)}";
_context.RegisterDerivedTypeHelper(derivedType, helperName);
- sb.AppendLine($" if (runtimeType == typeof({derivedTypeName}))");
- sb.AppendLine($" return ({typeName}){helperName}(({derivedTypeName})source, state);");
+ sb.AppendLine($"{indent}if (runtimeType == typeof({derivedTypeName}))");
+ sb.AppendLine($"{indent} return ({typeName}){helperName}(({derivedTypeName})source, state);");
}
sb.AppendLine();
}
if (_context.IsFastClonerAvailable)
{
- sb.AppendLine($" return ({typeName}){CloneGeneratorContext.FastClonerDeepCloneCall("source")}!;");
+ sb.AppendLine($"{indent}return ({typeName}){CloneGeneratorContext.FastClonerDeepCloneCall("source")}!;");
}
else
{
- sb.AppendLine($" throw new InvalidOperationException($\"Cannot clone unknown derived type {{runtimeType.FullName}} of {_context.Model.Name}. \" +");
- sb.AppendLine(" \"Either add the derived type to this assembly, use [FastClonerInclude] to register it, \" +");
- sb.AppendLine(" \"or install the FastCloner NuGet package for runtime fallback.\");");
+ sb.AppendLine($"{indent}throw new InvalidOperationException($\"Cannot clone unknown derived type {{runtimeType.FullName}} of {_context.Model.Name}. \" +");
+ sb.AppendLine($"{indent} \"Either add the derived type to this assembly, use [FastClonerInclude] to register it, \" +");
+ sb.AppendLine($"{indent} \"or install the FastCloner NuGet package for runtime fallback.\");");
+ }
+
+ if (includeRootTypeGuard)
+ {
+ sb.AppendLine(" }");
+ sb.AppendLine();
}
}
diff --git a/src/FastCloner.SourceGenerator/DerivedTypeCollector.cs b/src/FastCloner.SourceGenerator/DerivedTypeCollector.cs
index 6856d44..66dd3bc 100644
--- a/src/FastCloner.SourceGenerator/DerivedTypeCollector.cs
+++ b/src/FastCloner.SourceGenerator/DerivedTypeCollector.cs
@@ -137,11 +137,11 @@ private static TypeModel CreateMinimalTypeModel(
RelatedTypes: EquatableArray.Empty,
NestedTypes: EquatableArray.Empty,
DerivedTypes: EquatableArray.Empty,
- nullabilityEnabled,
- trustNullability,
+ NullabilityEnabled: nullabilityEnabled,
+ TrustNullability: trustNullability,
PreserveIdentity: null,
IsRefLikeType: false,
- hasParameterlessConstructor,
+ HasParameterlessConstructor: hasParameterlessConstructor,
CodeAnalysisAvailable: compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null,
TargetFramework: targetFramework);
}
@@ -332,10 +332,10 @@ private static TypeModel CreateMinimalTypeModel(
trustNullability,
preserveIdentity,
IsRefLikeType: false,
- hasParameterlessConstructor,
+ HasParameterlessConstructor: hasParameterlessConstructor,
CodeAnalysisAvailable: compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null,
TargetFramework: targetFramework,
- new EquatableArray(circRefLog.ToArray()));
+ CircularAnalysisLog: new EquatableArray(circRefLog.ToArray()));
}
private static bool? GetPreserveIdentityFromType(INamedTypeSymbol symbol)
diff --git a/src/FastCloner.SourceGenerator/ImplicitTypeAnalyzer.cs b/src/FastCloner.SourceGenerator/ImplicitTypeAnalyzer.cs
index 1d87917..22a9102 100644
--- a/src/FastCloner.SourceGenerator/ImplicitTypeAnalyzer.cs
+++ b/src/FastCloner.SourceGenerator/ImplicitTypeAnalyzer.cs
@@ -204,7 +204,7 @@ bool TryHandleComponent(ITypeSymbol componentType, out MemberModel? componentMem
trustNullability,
PreserveIdentity: null,
IsRefLikeType: false,
- hasParameterlessConstructor,
+ HasParameterlessConstructor: hasParameterlessConstructor,
CodeAnalysisAvailable: compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null,
TargetFramework: targetFramework);
diff --git a/src/FastCloner.SourceGenerator/TypeModel.cs b/src/FastCloner.SourceGenerator/TypeModel.cs
index 48db8b3..97de2c9 100644
--- a/src/FastCloner.SourceGenerator/TypeModel.cs
+++ b/src/FastCloner.SourceGenerator/TypeModel.cs
@@ -31,5 +31,6 @@ internal sealed record TypeModel(
bool IsRefLikeType = false, // Whether the type is a ref struct (cannot be boxed/used as generic)
bool HasParameterlessConstructor = true, // Whether the type has a public parameterless constructor (defaults to true for safety)
bool CodeAnalysisAvailable = false, // Whether System.Diagnostics.CodeAnalysis attributes are available
+ bool IncludeSubtypes = false, // Whether subtype dispatch should be generated for this type
TargetFramework TargetFramework = TargetFramework.NetStandard20, // Detected target framework for TFM-specific optimizations
EquatableArray CircularAnalysisLog = default) : IEquatable;
diff --git a/src/FastCloner.SourceGenerator/TypeModelFactory.cs b/src/FastCloner.SourceGenerator/TypeModelFactory.cs
index ee9065c..0d2ec48 100644
--- a/src/FastCloner.SourceGenerator/TypeModelFactory.cs
+++ b/src/FastCloner.SourceGenerator/TypeModelFactory.cs
@@ -23,6 +23,8 @@ public static bool TryCreate(
.Any(a => a.AttributeClass?.ToDisplayString() == "FastCloner.SourceGenerator.Shared.FastClonerSimulateNoRuntimeAttribute");
bool trustNullability = symbol.GetAttributes()
.Any(a => a.AttributeClass?.ToDisplayString() == "FastCloner.SourceGenerator.Shared.FastClonerTrustNullabilityAttribute");
+ bool requestedIncludeSubtypes = GetIncludeSubtypesFromType(symbol);
+ bool effectiveIncludeSubtypes = requestedIncludeSubtypes && !symbol.IsValueType && !symbol.IsSealed;
bool? preserveIdentity = GetPreserveIdentityFromType(symbol);
bool codeAnalysisAvailable = compilation.GetTypeByMetadataName("System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute") != null;
@@ -211,12 +213,12 @@ public static bool TryCreate(
bool isRefLikeType = TypeAnalyzer.IsRefStructType(symbol);
EquatableArray derivedTypes = EquatableArray.Empty;
- if (symbol.IsAbstract)
+ if (symbol.IsAbstract || effectiveIncludeSubtypes)
{
List derivedTypesList = DerivedTypeCollector.Collect(symbol, compilation, nullabilityEnabled, targetFramework);
derivedTypes = new EquatableArray(derivedTypesList.ToArray());
- if (derivedTypesList.Count == 0 && !isFastClonerAvailable)
+ if (symbol.IsAbstract && derivedTypesList.Count == 0 && !isFastClonerAvailable)
{
error = Diagnostic.Create(
new DiagnosticDescriptor(
@@ -257,6 +259,7 @@ public static bool TryCreate(
isRefLikeType,
hasParameterlessConstructor,
codeAnalysisAvailable,
+ effectiveIncludeSubtypes,
targetFramework,
new EquatableArray(circRefLog.ToArray()));
@@ -283,4 +286,23 @@ public static bool TryCreate(
}
return null;
}
+
+ private static bool GetIncludeSubtypesFromType(INamedTypeSymbol symbol)
+ {
+ foreach (AttributeData attr in symbol.GetAttributes())
+ {
+ if (attr.AttributeClass?.ToDisplayString() != "FastCloner.SourceGenerator.Shared.FastClonerClonableAttribute")
+ continue;
+
+ foreach (KeyValuePair namedArg in attr.NamedArguments)
+ {
+ if (namedArg is { Key: "IncludeSubtypes", Value.Value: bool includeSubtypes })
+ return includeSubtypes;
+ }
+
+ return false;
+ }
+
+ return false;
+ }
}
diff --git a/src/FastCloner.Tests/AbstractClassTests.cs b/src/FastCloner.Tests/AbstractClassTests.cs
index 3cffb85..3cceffe 100644
--- a/src/FastCloner.Tests/AbstractClassTests.cs
+++ b/src/FastCloner.Tests/AbstractClassTests.cs
@@ -20,6 +20,12 @@ public class ExternalBird : AbstractClassTests.Pet
public double Wingspan { get; set; }
public bool CanFly { get; set; }
}
+
+public class ExternalWatch : AbstractClassTests.IncludedDevice
+{
+ public string? FirmwareVersion { get; set; }
+}
+
[SourceGeneratorCompatible]
public class AbstractClassTests
{
@@ -93,6 +99,18 @@ public abstract class Shape
public string? Name { get; set; }
}
+ [FastClonerClonable(IncludeSubtypes = true)]
+ public sealed class SealedDevice
+ {
+ public string? Name { get; set; }
+ }
+
+ [FastClonerClonable(IncludeSubtypes = true)]
+ public struct StructDevice
+ {
+ public int Id { get; set; }
+ }
+
public abstract class Polygon : Shape
{
public int NumberOfSides { get; set; }
@@ -152,6 +170,40 @@ public class Address
#endregion
+ #region Test Classes - Concrete Base with IncludeSubtypes
+
+ [FastClonerClonable(IncludeSubtypes = true)]
+ public class Device
+ {
+ public string? Name { get; set; }
+ }
+
+ public class Phone : Device
+ {
+ public string? OperatingSystem { get; set; }
+ }
+
+ [FastClonerClonable]
+ public class PlainDevice
+ {
+ public string? Name { get; set; }
+ }
+
+ public class PlainPhone : PlainDevice
+ {
+ public string? OperatingSystem { get; set; }
+ }
+
+ [FastClonerClonable(IncludeSubtypes = true)]
+ [FastClonerDisableAutoDiscovery]
+ [FastClonerInclude(typeof(ExternalWatch))]
+ public class IncludedDevice
+ {
+ public string? Name { get; set; }
+ }
+
+ #endregion
+
#region Tests - Basic Abstract Class Cloning
[Test]
@@ -212,6 +264,111 @@ public async Task Abstract_Cat_Should_Clone_Correctly()
await Assert.That(clonedCat.IsIndoor).IsTrue();
}
+ [Test]
+ [SourceGeneratorCompatible]
+ public async Task ConcreteBase_WithIncludeSubtypes_Should_Dispatch_To_Derived_Cloner()
+ {
+ // Arrange
+ Phone phone = new Phone
+ {
+ Name = "MyPhone",
+ OperatingSystem = "Android"
+ };
+
+ // Act - Clone via concrete base type
+ Device device = phone;
+ Device? clone = device.FastDeepClone();
+
+ // Assert
+ await Assert.That(clone).IsNotNull();
+ await Assert.That(clone).IsTypeOf();
+ await Assert.That(clone).IsNotSameReferenceAs(phone);
+
+ Phone clonedPhone = (Phone)clone!;
+ await Assert.That(clonedPhone.Name).IsEqualTo("MyPhone");
+ await Assert.That(clonedPhone.OperatingSystem).IsEqualTo("Android");
+ }
+
+ [Test]
+ [SourceGeneratorCompatible]
+ public async Task ConcreteBase_WithoutIncludeSubtypes_Should_Keep_Default_Behavior()
+ {
+ // Arrange
+ PlainPhone phone = new PlainPhone
+ {
+ Name = "LegacyPhone",
+ OperatingSystem = "Symbian"
+ };
+
+ // Act - Clone via concrete base type without IncludeSubtypes
+ PlainDevice device = phone;
+ PlainDevice? clone = device.FastDeepClone();
+
+ // Assert
+ await Assert.That(clone).IsNotNull();
+ await Assert.That(clone).IsTypeOf();
+ await Assert.That(clone).IsNotSameReferenceAs(phone);
+ await Assert.That(clone is PlainPhone).IsFalse();
+ await Assert.That(clone!.Name).IsEqualTo("LegacyPhone");
+ }
+
+ [Test]
+ [SourceGeneratorCompatible]
+ public async Task ConcreteBase_WithIncludeSubtypes_Should_Clone_Device_Instance()
+ {
+ // Arrange
+ Device device = new Device
+ {
+ Name = "BaseDevice"
+ };
+
+ // Act
+ Device? clone = device.FastDeepClone();
+
+ // Assert
+ await Assert.That(clone).IsNotNull();
+ await Assert.That(clone).IsTypeOf();
+ await Assert.That(clone).IsNotSameReferenceAs(device);
+ await Assert.That(clone!.Name).IsEqualTo("BaseDevice");
+ }
+
+ [Test]
+ [SourceGeneratorCompatible]
+ public async Task SealedClass_WithIncludeSubtypes_Should_Clone_Normally()
+ {
+ // Arrange
+ SealedDevice device = new SealedDevice
+ {
+ Name = "Sealed"
+ };
+
+ // Act
+ SealedDevice? clone = device.FastDeepClone();
+
+ // Assert
+ await Assert.That(clone).IsNotNull();
+ await Assert.That(clone).IsTypeOf();
+ await Assert.That(clone).IsNotSameReferenceAs(device);
+ await Assert.That(clone!.Name).IsEqualTo("Sealed");
+ }
+
+ [Test]
+ [SourceGeneratorCompatible]
+ public async Task Struct_WithIncludeSubtypes_Should_Clone_Normally()
+ {
+ // Arrange
+ StructDevice device = new StructDevice
+ {
+ Id = 42
+ };
+
+ // Act
+ StructDevice clone = device.FastDeepClone();
+
+ // Assert
+ await Assert.That(clone.Id).IsEqualTo(42);
+ }
+
#endregion
#region Tests - Abstract with Collections
@@ -503,6 +660,31 @@ public async Task FastClonerInclude_Multiple_Types_Independence()
await Assert.That(((ExternalBird)clones[2]!).CanFly).IsTrue();
}
+ [Test]
+ [SourceGeneratorCompatible]
+ public async Task IncludeSubtypes_WithFastClonerInclude_ExternalSubtype_Should_Clone()
+ {
+ // Arrange
+ ExternalWatch watch = new ExternalWatch
+ {
+ Name = "Pixel Watch",
+ FirmwareVersion = "1.2.3"
+ };
+
+ // Act - clone via concrete base with IncludeSubtypes=true
+ IncludedDevice device = watch;
+ IncludedDevice? clone = device.FastDeepClone();
+
+ // Assert
+ await Assert.That(clone).IsNotNull();
+ await Assert.That(clone).IsTypeOf();
+ await Assert.That(clone).IsNotSameReferenceAs(watch);
+
+ ExternalWatch clonedWatch = (ExternalWatch)clone!;
+ await Assert.That(clonedWatch.Name).IsEqualTo("Pixel Watch");
+ await Assert.That(clonedWatch.FirmwareVersion).IsEqualTo("1.2.3");
+ }
+
#endregion
#region Tests - Null Handling