In the previous articles, we created an aspect that implements the Builder
pattern for properties of "plain" types. However, properties of collection types require different handling.
Since the Builder pattern is typically used to build immutable objects, it is good practice for properties of the immutable class to be of an immutable type, such as ImmutableArray<T> or ImmutableDictionary<TKey, TValue>. In the Builder
class, though, it's more convenient if the collections are mutable. For instance, for a source property of type ImmutableArray<string>
, the builder property could be an ImmutableArray<string>.Builder
.
In this article, we'll update the aspect so that the collection properties of the Builder
class are of the builder collection type.
Additionally, we want the collection properties in the Builder
type to be lazy, meaning we only allocate a collection builder if the collection is evaluated.
Here is an example of a transformation performed by the aspect.
1using System.Collections.Immutable;
2
3namespace Metalama.Samples.Builder3.Tests.SimpleExample._ImmutableArray;
4
5#pragma warning disable CS8618 // Non-nullable property must contain a non-null value when exiting constructor.
6
7[GenerateBuilder]
8public partial class ColorWheel
9{
10 public ImmutableArray<string> Colors { get; }
11}
1using System.Collections.Immutable;
2
3namespace Metalama.Samples.Builder3.Tests.SimpleExample._ImmutableArray;
4
5#pragma warning disable CS8618 // Non-nullable property must contain a non-null value when exiting constructor.
6
7[GenerateBuilder]
8public partial class ColorWheel
9{
10 public ImmutableArray<string> Colors { get; }
11
12 protected ColorWheel(ImmutableArray<string> colors)
13 {
14 Colors = colors;
15 }
16
17 public virtual Builder ToBuilder()
18 {
19 return new Builder(this);
20 }
21
22 public class Builder
23 {
24 private ImmutableArray<string> _colors = ImmutableArray<string>.Empty;
25 private ImmutableArray<string>.Builder? _colorsBuilder;
26
27 public Builder()
28 {
29 }
30
31 protected internal Builder(ColorWheel source)
32 {
33 _colors = source.Colors;
34 }
35
36 public ImmutableArray<string>.Builder Colors
37 {
38 get
39 {
40 return _colorsBuilder ??= _colors.ToBuilder();
41 }
42 }
43
44 public ColorWheel Build()
45 {
46 var instance = new ColorWheel(GetImmutableColors());
47 return instance;
48 }
49
50 protected ImmutableArray<string> GetImmutableColors()
51 {
52 if (_colorsBuilder == null)
53 {
54 return _colors;
55 }
56 else
57 {
58 return Colors.ToImmutable();
59 }
60 }
61 }
62}
Step 1. Setting up more abstractions
We'll now update the aspect to support two kinds of properties: standard ones and properties of an immutable collection type. We'll only support collection types from the System.Collections.Immutable namespace, but the same approach can be used for different types.
Since we have two kinds of properties, we'll make the PropertyMapping
class abstract. It will have two implementations: StandardPropertyMapping
and ImmutableCollectionPropertyMapping
. Any implementation-specific method must be abstracted in the PropertyMapping
class and implemented separately in derived classes.
These implementation-specific methods are as follows:
GetBuilderPropertyValue() : IExpression
returns an expression that contains the value of theBuilder
property. The type of the expression must be the type of the property in the source type, not in the builder type. For standard properties, this will return the builder property itself. For immutable collection properties, this will be the new immutable collection constructed from the immutable collection builder.ImplementBuilderArtifacts()
will be called for non-inherited properties and must add declarations required to implement the property. For standard properties, this is just the public property in theBuilder
type. For immutable collections, this is more complex and will be discussed later.TryImportBuilderArtifactsFromBaseType
will be called for inherited properties and must find the required declarations from the base type.SetBuilderPropertyValue
is the template used in the copy constructor to store the initial value of the property.
The BuilderProperty
property of the PropertyMapping
class now becomes an implementation detail and is removed from the abstract class.
Here is the new PropertyMapping
class:
1using Metalama.Framework.Advising;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Diagnostics;
5
6namespace Metalama.Samples.Builder3;
7
8[CompileTime]
9internal abstract partial class PropertyMapping : ITemplateProvider
10{
11 protected PropertyMapping( IProperty sourceProperty, bool isRequired, bool isInherited )
12 {
13 this.SourceProperty = sourceProperty;
14 this.IsRequired = isRequired;
15 this.IsInherited = isInherited;
16 }
17
18 public IProperty SourceProperty { get; }
19
20 public bool IsRequired { get; }
21
22 public bool IsInherited { get; }
23
24 public int? SourceConstructorParameterIndex { get; set; }
25
26 public int? BuilderConstructorParameterIndex { get; set; }
27
28 /// <summary>
29 /// Gets an expression that contains the value of the Builder property. The type of the
30 /// expression must be the type of the property in the <i>source</i> type, not in the builder
31 /// type.
32 /// </summary>
33 public abstract IExpression GetBuilderPropertyValue();
34
35 /// <summary>
36 /// Adds the properties, fields and methods required to implement this property.
37 /// </summary>
38 public abstract void ImplementBuilderArtifacts( IAdviser<INamedType> builderType );
39
40 /// <summary>
41 /// Imports, from the base type, the properties, field and methods required for
42 /// the current property.
43 /// </summary>
44 public abstract bool TryImportBuilderArtifactsFromBaseType(
45 INamedType baseType,
46 ScopedDiagnosticSink diagnosticSink );
47
48 /// <summary>
49 /// A template for the code that sets the relevant data in the Builder type for the current property.
50 /// </summary>
51 [Template]
52 public virtual void SetBuilderPropertyValue( IExpression expression, IExpression builderInstance )
53 {
54 // Abstract templates are not supported, so we must create a virtual method and override it.
55 throw new NotSupportedException();
56 }
57}
Note that the PropertyMapping
class now implements the (empty) ITemplateProvider interface. This is required because SetBuilderPropertyValue
is an auxiliary template, i.e. a template called from another top-level template. Note also that SetBuilderPropertyValue
cannot be abstract due to current limitations in Metalama, so we had to make it virtual. For details regarding auxiliary templates, see Calling auxiliary templates.
The implementation of PropertyMapping
for standard properties is directly extracted from the aspect implementation in the previous article.
1using Metalama.Framework.Advising;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Diagnostics;
5
6namespace Metalama.Samples.Builder3;
7
8internal class StandardPropertyMapping : PropertyMapping
9{
10 private IProperty? _builderProperty;
11
12 public StandardPropertyMapping( IProperty sourceProperty, bool isRequired, bool isInherited )
13 : base( sourceProperty, isRequired, isInherited ) { }
14
15 public override IExpression GetBuilderPropertyValue() => this._builderProperty!;
16
17 public override void ImplementBuilderArtifacts( IAdviser<INamedType> builderType )
18 {
19 this._builderProperty = builderType.IntroduceAutomaticProperty(
20 this.SourceProperty.Name,
21 this.SourceProperty.Type,
22 IntroductionScope.Instance,
23 buildProperty: p =>
24 {
25 p.Accessibility = Accessibility.Public;
26 p.InitializerExpression = this.SourceProperty.InitializerExpression;
27 } )
28 .Declaration;
29 }
30
31 public override bool TryImportBuilderArtifactsFromBaseType(
32 INamedType baseType,
33 ScopedDiagnosticSink diagnosticSink )
34
35 {
36 return this.TryFindBuilderPropertyInBaseType(
37 baseType,
38 diagnosticSink,
39 out this._builderProperty );
40 }
41
42 public override void SetBuilderPropertyValue(
43 IExpression expression,
44 IExpression builderInstance )
45 {
46 this._builderProperty!.With( builderInstance ).Value = expression.Value;
47 }
48}
The TryFindBuilderPropertyInBaseType
helper method is defined here:
9protected bool TryFindBuilderPropertyInBaseType(
10 INamedType baseType,
11 ScopedDiagnosticSink diagnosticSink,
12 [NotNullWhen( true )] out IProperty? baseProperty )
13{
14 baseProperty =
15 baseType.AllProperties.OfName( this.SourceProperty.Name )
16 .SingleOrDefault();
17
18 if ( baseProperty == null )
19 {
20 diagnosticSink.Report(
21 BuilderDiagnosticDefinitions.BaseBuilderMustContainProperty.WithArguments(
22 (
23 baseType, this.SourceProperty.Name) ) );
24
25 return false;
26 }
27
28 return true;
29}
Step 2. Updating the aspect
Both the BuildAspect
method and the templates must call the abstract methods and templates of PropertyMapping
.
Let's look, for instance, at the code that used to create the builder properties in the Builder
nested type. You can see how the implementation-specific logic was moved to PropertyMapping.ImplementBuilderArtifacts
and PropertyMapping.TryImportBuilderArtifactsFromBaseType
:
140// Add builder properties and update the mapping.
141foreach ( var property in properties )
142{
143 if ( property.SourceProperty.DeclaringType.Equals( sourceType ) )
144 {
145 // For properties of the current type, introduce a new property.
146 property.ImplementBuilderArtifacts( builderType );
147 }
148 else if ( baseBuilderType != null )
149 {
150 // For properties of the base type, import them.
151 if ( !property.TryImportBuilderArtifactsFromBaseType(
152 baseBuilderType,
153 builder.Diagnostics ) )
154 {
155 hasError = true;
156 }
157 }
158}
159
160if ( hasError )
161{
162 return;
163}
164
The aspect has been updated in several other locations. For details, please refer to the source code by following the link to GitHub at the top of this article.
Step 3. Adding the logic specific to immutable collections
At this point, we can run the same unit tests as for the previous article, and they should execute without any differences.
Let's now focus on implementing support for properties whose type is an immutable collection.
As always, we should first design a pattern at a conceptual level, and then switch to its implementation.
To make things more complex with immutable collections, we must address the requirement that collection builders should not be allocated until the user evaluates the public property of the Builder
type. When this property is required, we must create a collection builder from the initial collection value, which can be either empty or, if the ToBuilder()
method was used, the current value in the source object.
Each property will be implemented by four artifacts:
- A field containing the property initial value, which can be either empty or, when initialized from the copy constructor, set to the value in the source object.
- A nullable field containing the collection builder.
- The public property representing the collection, which lazily instantiates the collection builder.
- A method returning the immutable collection from the collection builder if it has been defined, or returning the initial value if undefined (which means that there can be no change).
These artifacts are built by the ImplementBuilderArtifacts
method of the ImmutableCollectionPropertyMapping
class and then used in other methods and templates.
1using Metalama.Framework.Advising;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Code.SyntaxBuilders;
5using Metalama.Framework.Diagnostics;
6
7namespace Metalama.Samples.Builder3;
8
9internal class ImmutableCollectionPropertyMapping : PropertyMapping
10{
11 private IField? _collectionBuilderField;
12 private IField? _initialValueField;
13 private IProperty? _collectionBuilderProperty;
14 private IMethod? _getImmutableValueMethod;
15 private readonly IType _collectionBuilderType;
16
17 public ImmutableCollectionPropertyMapping(
18 IProperty sourceProperty,
19 bool isRequired,
20 bool isInherited ) : base( sourceProperty, isRequired, isInherited )
21 {
22 this._collectionBuilderType =
23 ((INamedType) sourceProperty.Type).Types.OfName( "Builder" ).Single();
24 }
25
26 private IType ImmutableCollectionType => this.SourceProperty.Type;
27
28 public override void ImplementBuilderArtifacts( IAdviser<INamedType> builderType )
29 {
30 builderType = builderType.WithTemplateProvider( this );
31
32 this._collectionBuilderField = builderType
33 .IntroduceField(
34 NameHelper.ToFieldName( this.SourceProperty.Name + "Builder" ),
35 this._collectionBuilderType.ToNullable(),
36 buildField: f => f.Accessibility = Accessibility.Private )
37 .Declaration;
38
39 this._initialValueField = builderType
40 .IntroduceField(
41 NameHelper.ToFieldName( this.SourceProperty.Name ),
42 this.ImmutableCollectionType,
43 buildField: f =>
44 {
45 f.Accessibility = Accessibility.Private;
46
47 if ( !this.IsRequired )
48 {
49 // Unless the field is required, we must initialize it to a value representing
50 // a valid but empty collection, except if we are given a different
51 // initializer expression.
52 if ( this.SourceProperty.InitializerExpression != null )
53 {
54 f.InitializerExpression = this.SourceProperty.InitializerExpression;
55 }
56 else
57 {
58 var initializerExpressionBuilder = new ExpressionBuilder();
59 initializerExpressionBuilder.AppendTypeName( this.ImmutableCollectionType );
60 initializerExpressionBuilder.AppendVerbatim( ".Empty" );
61 f.InitializerExpression = initializerExpressionBuilder.ToExpression();
62 }
63 }
64 } )
65 .Declaration;
66
67 this._collectionBuilderProperty = builderType
68 .IntroduceProperty(
69 nameof(this.BuilderPropertyTemplate),
70 buildProperty: p =>
71 {
72 p.Name = this.SourceProperty.Name;
73 p.Accessibility = Accessibility.Public;
74 p.GetMethod!.Accessibility = Accessibility.Public;
75 p.Type = this._collectionBuilderType;
76 } )
77 .Declaration;
78
79 this._getImmutableValueMethod = builderType.IntroduceMethod(
80 nameof(this.BuildPropertyMethodTemplate),
81 buildMethod: m =>
82 {
83 m.Name = "GetImmutable" + this.SourceProperty.Name;
84 m.Accessibility = Accessibility.Protected;
85 m.ReturnType = this.ImmutableCollectionType;
86 } )
87 .Declaration;
88 }
89
90 [Template]
91 public dynamic BuilderPropertyTemplate
92 => this._collectionBuilderField!.Value ??= this._initialValueField!.Value!.ToBuilder();
93
94 [Template]
95 public dynamic BuildPropertyMethodTemplate()
96 {
97 if ( this._collectionBuilderField!.Value == null )
98 {
99 return this._initialValueField!.Value!;
100 }
101 else
102 {
103 return this._collectionBuilderProperty!.Value!.ToImmutable();
104 }
105 }
106
107 public override IExpression GetBuilderPropertyValue()
108 => this._getImmutableValueMethod!.CreateInvokeExpression( [] );
109
110 public override bool TryImportBuilderArtifactsFromBaseType(
111 INamedType baseType,
112 ScopedDiagnosticSink diagnosticSink )
113 {
114 // Find the property containing the collection builder.
115 if ( !this.TryFindBuilderPropertyInBaseType(
116 baseType,
117 diagnosticSink,
118 out this._collectionBuilderProperty ) )
119 {
120 return false;
121 }
122
123 // Find the method GetImmutable* method.
124 this._getImmutableValueMethod =
125 baseType.AllMethods.OfName( "GetImmutable" + this.SourceProperty.Name )
126 .SingleOrDefault();
127
128 if ( this._getImmutableValueMethod == null )
129 {
130 diagnosticSink.Report(
131 BuilderDiagnosticDefinitions.BaseBuilderMustContainGetImmutableMethod.WithArguments(
132 (baseType, this.SourceProperty.Name) ) );
133
134 return false;
135 }
136
137 return true;
138 }
139
140 public override void SetBuilderPropertyValue(
141 IExpression expression,
142 IExpression builderInstance )
143 {
144 this._initialValueField!.With( builderInstance ).Value = expression.Value;
145 }
146}
Conclusion
Handling different kinds of properties led us to use more abstraction in our aspect. As you can see, meta-programming, like other forms of programming, requires a strict definition of concepts and the right level of abstraction.
Our aspect now correctly handles not only derived types but also immutable collections.