Clone example, step 3: allowing handmade customizations
In the previous articles, we built a Cloneable
aspect that worked well with simple classes and one-to-one relationships. But what if we need to support external types for which we cannot add a Clone
method, or one-to-many relationships, such as collection fields?
Ideally, we would build a pluggable cloning service for external types, as we did for caching key builders of external types (see Caching example, step 4: cache key for external types) and supply cloners for system collections. But before that, an even better strategy is to design an extension point that the aspect's users can use when our aspect has limitations. How can we allow the aspect's users to inject their custom logic?
We will let users add their custom logic after the aspect-generated logic by allowing them to supply a method with the following signature, where T
is the current type:
private void CloneMembers(T clone)
The aspect will inject its logic before the user's implementation.
Let's see this pattern in action. In this new example, the Game
class has a one-to-many relationship with the Player
class. The cloning of the collection is implemented manually.
1[Cloneable]
2internal class Game
3{
4 public List<Player> Players { get; private set; } = new();
5
6 [Child]
7 public GameSettings Settings { get; private set; }
8
9 private void CloneMembers( Game clone ) => clone.Players = new List<Player>( this.Players );
10}
1using System;
2
3[Cloneable]
4internal class Game: ICloneable
5{
6 public List<Player> Players { get; private set; } = new();
7
8 [Child]
9 public GameSettings Settings { get; private set; }
10
11 private void CloneMembers( Game clone )
12{
13 clone.Settings = this.Settings.Clone();
14 clone.Players = new List<Player>(this.Players);
15 }
16
17 public virtual Game Clone()
18 {
19 var clone = (Game)base.MemberwiseClone();
20 this.CloneMembers(clone);
21 return clone;
22 }
23
24 object ICloneable.Clone()
25 {
26 return Clone();
27 }
28}
29
1[Cloneable]
2internal class GameSettings
3{
4 public int Level { get; set; }
5 public string World { get; set; }
6}
1using System;
2
3[Cloneable]
4internal class GameSettings: ICloneable
5{
6 public int Level { get; set; }
7 public string World { get; set; }
8
9 public virtual GameSettings Clone()
10 {
11 var clone = (GameSettings)base.MemberwiseClone();
12 this.CloneMembers(clone);
13 return clone;
14 }
15
16 private void CloneMembers(GameSettings clone)
17 {
18 }
19
20 object ICloneable.Clone()
21 {
22 return Clone();
23 }
24}
25
Aspect implementation
Here is the updated CloneableAttribute
class:
1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Diagnostics;
4using Metalama.Framework.Project;
5
6[Inheritable]
7[EditorExperience( SuggestAsLiveTemplate = true )]
8public class CloneableAttribute : TypeAspect
9{
10 private static readonly DiagnosticDefinition<(DeclarationKind, IFieldOrProperty)> _fieldOrPropertyCannotBeReadOnly =
11 new("CLONE01", Severity.Error, "The {0} '{1}' cannot be read-only because it is marked as a [Child].");
12
13 private static readonly DiagnosticDefinition<(DeclarationKind, IFieldOrProperty, IType)> _missingCloneMethod =
14 new("CLONE02", Severity.Error,
15 "The {0} '{1}' cannot be a [Child] because its type '{2}' does not have a 'Clone' parameterless method.");
16
17 private static readonly DiagnosticDefinition<IProperty> _childPropertyMustBeAutomatic =
18 new("CLONE03", Severity.Error, "The property '{0}' cannot be a [Child] because is not an automatic property.");
19
20 public override void BuildAspect( IAspectBuilder<INamedType> builder )
21 {
22 // Verify that child fields are valid.
23 var hasError = false;
24 foreach ( var fieldOrProperty in GetClonableFieldsOrProperties( builder.Target ) )
25 {
26 // The field or property must be writable.
27 if ( fieldOrProperty.Writeability != Writeability.All )
28 {
29 builder.Diagnostics.Report(
30 _fieldOrPropertyCannotBeReadOnly.WithArguments( (fieldOrProperty.DeclarationKind,
31 fieldOrProperty) ), fieldOrProperty );
32 hasError = true;
33 }
34
35 // If it is a field, it must be an automatic property.
36 if ( fieldOrProperty is IProperty property && property.IsAutoPropertyOrField == false )
37 {
38 builder.Diagnostics.Report( _childPropertyMustBeAutomatic.WithArguments( property ), property );
39 hasError = true;
40 }
41
42 // The type of the field must be cloneable.
43 if ( !MetalamaExecutionContext.Current.ExecutionScenario.IsDesignTime )
44 {
45 var fieldType = fieldOrProperty.Type as INamedType;
46
47 if ( fieldType == null ||
48 !(fieldType.AllMethods.OfName( "Clone" ).Where( p => p.Parameters.Count == 0 ).Any() ||
49 (fieldType.BelongsToCurrentProject &&
50 fieldType.Enhancements().HasAspect<CloneableAttribute>())) )
51 {
52 builder.Diagnostics.Report(
53 _missingCloneMethod.WithArguments( (fieldOrProperty.DeclarationKind, fieldOrProperty,
54 fieldOrProperty.Type) ), fieldOrProperty );
55 hasError = true;
56 }
57 }
58 }
59
60 // Stop here if we have errors.
61 if ( hasError )
62 {
63 builder.SkipAspect();
64 return;
65 }
66
67 // Introduce the Clone method.
68 builder.Advice.IntroduceMethod(
69 builder.Target,
70 nameof(this.CloneImpl),
71 whenExists: OverrideStrategy.Override,
72 args: new { T = builder.Target },
73 buildMethod: m =>
74 {
75 m.Name = "Clone";
76 m.ReturnType = builder.Target;
77 } );
78 builder.Advice.IntroduceMethod(
79 builder.Target,
80 nameof(this.CloneMembers),
81 whenExists: OverrideStrategy.Override,
82 args: new { T = builder.Target } );
83
84 // Implement the ICloneable interface.
85 builder.Advice.ImplementInterface(
86 builder.Target,
87 typeof(ICloneable),
88 OverrideStrategy.Ignore );
89 }
90
91 private static IEnumerable<IFieldOrProperty> GetClonableFieldsOrProperties( INamedType type )
92 => type.FieldsAndProperties.Where( f => f.Attributes.OfAttributeType( typeof(ChildAttribute) ).Any() );
93
94 [Template]
95 public virtual T CloneImpl<[CompileTime] T>()
96 {
97 // This compile-time variable will receive the expression representing the base call.
98 // If we have a public Clone method, we will use it (this is the chaining pattern). Otherwise,
99 // we will call MemberwiseClone (this is the initialization of the pattern).
100 IExpression baseCall;
101
102 if ( meta.Target.Method.IsOverride )
103 {
104 baseCall = (IExpression) meta.Base.Clone();
105 }
106 else
107 {
108 baseCall = (IExpression) meta.This.MemberwiseClone();
109 }
110
111 // Define a local variable of the same type as the target type.
112 var clone = (T) baseCall.Value!;
113
114 // Call CloneMembers, which may have a hand-written part.
115 meta.This.CloneMembers( clone );
116
117
118 return clone;
119 }
120
121 [Template]
122 private void CloneMembers<[CompileTime] T>( T clone )
123 {
124 // Select clonable fields.
125 var clonableFields = GetClonableFieldsOrProperties( meta.Target.Type );
126
127 foreach ( var field in clonableFields )
128 {
129 // Check if we have a public method 'Clone()' for the type of the field.
130 var fieldType = (INamedType) field.Type;
131
132 field.With( clone ).Value = meta.Cast( fieldType, field.Value?.Clone() );
133 }
134
135 // Call the hand-written implementation, if any.
136 meta.Proceed();
137 }
138
139 [InterfaceMember( IsExplicit = true )]
140 private object Clone() => meta.This.Clone();
141}
We added the following code in the BuildAspect
method:
78 builder.Advice.IntroduceMethod(
79 builder.Target,
80 nameof(this.CloneMembers),
81 whenExists: OverrideStrategy.Override,
82 args: new { T = builder.Target } );
The template for the CloneMembers
method is as follows:
1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Diagnostics;
4using Metalama.Framework.Project;
5
6[Inheritable]
7[EditorExperience( SuggestAsLiveTemplate = true )]
8public class CloneableAttribute : TypeAspect
9{
10 private static readonly DiagnosticDefinition<(DeclarationKind, IFieldOrProperty)> _fieldOrPropertyCannotBeReadOnly =
11 new("CLONE01", Severity.Error, "The {0} '{1}' cannot be read-only because it is marked as a [Child].");
12
13 private static readonly DiagnosticDefinition<(DeclarationKind, IFieldOrProperty, IType)> _missingCloneMethod =
14 new("CLONE02", Severity.Error,
15 "The {0} '{1}' cannot be a [Child] because its type '{2}' does not have a 'Clone' parameterless method.");
16
17 private static readonly DiagnosticDefinition<IProperty> _childPropertyMustBeAutomatic =
18 new("CLONE03", Severity.Error, "The property '{0}' cannot be a [Child] because is not an automatic property.");
19
20 public override void BuildAspect( IAspectBuilder<INamedType> builder )
21 {
22 // Verify that child fields are valid.
23 var hasError = false;
24 foreach ( var fieldOrProperty in GetClonableFieldsOrProperties( builder.Target ) )
25 {
26 // The field or property must be writable.
27 if ( fieldOrProperty.Writeability != Writeability.All )
28 {
29 builder.Diagnostics.Report(
30 _fieldOrPropertyCannotBeReadOnly.WithArguments( (fieldOrProperty.DeclarationKind,
31 fieldOrProperty) ), fieldOrProperty );
32 hasError = true;
33 }
34
35 // If it is a field, it must be an automatic property.
36 if ( fieldOrProperty is IProperty property && property.IsAutoPropertyOrField == false )
37 {
38 builder.Diagnostics.Report( _childPropertyMustBeAutomatic.WithArguments( property ), property );
39 hasError = true;
40 }
41
42 // The type of the field must be cloneable.
43 if ( !MetalamaExecutionContext.Current.ExecutionScenario.IsDesignTime )
44 {
45 var fieldType = fieldOrProperty.Type as INamedType;
46
47 if ( fieldType == null ||
48 !(fieldType.AllMethods.OfName( "Clone" ).Where( p => p.Parameters.Count == 0 ).Any() ||
49 (fieldType.BelongsToCurrentProject &&
50 fieldType.Enhancements().HasAspect<CloneableAttribute>())) )
51 {
52 builder.Diagnostics.Report(
53 _missingCloneMethod.WithArguments( (fieldOrProperty.DeclarationKind, fieldOrProperty,
54 fieldOrProperty.Type) ), fieldOrProperty );
55 hasError = true;
56 }
57 }
58 }
59
60 // Stop here if we have errors.
61 if ( hasError )
62 {
63 builder.SkipAspect();
64 return;
65 }
66
67 // Introduce the Clone method.
68 builder.Advice.IntroduceMethod(
69 builder.Target,
70 nameof(this.CloneImpl),
71 whenExists: OverrideStrategy.Override,
72 args: new { T = builder.Target },
73 buildMethod: m =>
74 {
75 m.Name = "Clone";
76 m.ReturnType = builder.Target;
77 } );
78 builder.Advice.IntroduceMethod(
79 builder.Target,
80 nameof(this.CloneMembers),
81 whenExists: OverrideStrategy.Override,
82 args: new { T = builder.Target } );
83
84 // Implement the ICloneable interface.
85 builder.Advice.ImplementInterface(
86 builder.Target,
87 typeof(ICloneable),
88 OverrideStrategy.Ignore );
89 }
90
91 private static IEnumerable<IFieldOrProperty> GetClonableFieldsOrProperties( INamedType type )
92 => type.FieldsAndProperties.Where( f => f.Attributes.OfAttributeType( typeof(ChildAttribute) ).Any() );
93
94 [Template]
95 public virtual T CloneImpl<[CompileTime] T>()
96 {
97 // This compile-time variable will receive the expression representing the base call.
98 // If we have a public Clone method, we will use it (this is the chaining pattern). Otherwise,
99 // we will call MemberwiseClone (this is the initialization of the pattern).
100 IExpression baseCall;
101
102 if ( meta.Target.Method.IsOverride )
103 {
104 baseCall = (IExpression) meta.Base.Clone();
105 }
106 else
107 {
108 baseCall = (IExpression) meta.This.MemberwiseClone();
109 }
110
111 // Define a local variable of the same type as the target type.
112 var clone = (T) baseCall.Value!;
113
114 // Call CloneMembers, which may have a hand-written part.
115 meta.This.CloneMembers( clone );
116
117
118 return clone;
119 }
120
121 [Template]
122 private void CloneMembers<[CompileTime] T>( T clone )
123 {
124 // Select clonable fields.
125 var clonableFields = GetClonableFieldsOrProperties( meta.Target.Type );
126
127 foreach ( var field in clonableFields )
128 {
129 // Check if we have a public method 'Clone()' for the type of the field.
130 var fieldType = (INamedType) field.Type;
131
132 field.With( clone ).Value = meta.Cast( fieldType, field.Value?.Clone() );
133 }
134
135 // Call the hand-written implementation, if any.
136 meta.Proceed();
137 }
138
139 [InterfaceMember( IsExplicit = true )]
140 private object Clone() => meta.This.Clone();
141}
As you can see, we moved the logic that clones individual fields to this method. We call meta.Proceed()
last, so hand-written code is executed after aspect-generated code and can fix whatever gap the aspect left.
Summary
We updated the aspect to add an extensibility mechanism allowing the user to implement scenarios that lack genuine support by the aspect. The problem with this approach is that users may easily forget that they have to supply a private void CloneMembers(T clone)
method. To remedy this issue, we will provide them with suggestions in the code refactoring menu.