Auxiliary templates are templates designed to be called from other templates. When an auxiliary template is called from a template, the code generated by the auxiliary template is expanded at the point where it is called.
There are two primary reasons why you may want to use auxiliary templates:
- Code reuse: Moving repetitive code logic to an auxiliary template can reduce duplication. This aligns with the primary goal of Metalama, which is to streamline code writing.
- Abstraction: Since template methods can be
virtual
, you can allow the users of your aspects to modify the templates.
There are two ways to call a template: the standard way, just like you would call any C# method, and the dynamic way, which addresses more advanced scenarios. Both approaches will be covered in the subsequent sections.
Creating auxiliary templates
To create an auxiliary template, follow these steps:
Just like a normal template, create a method and annotate it with the [Template] custom attribute.
If you are creating this method outside of an aspect or fabric type, ensure that this class implements the ITemplateProvider empty interface.
Note
This rule applies even if you want to create a helper class that contains only
static
methods. In this case, you cannot mark the class asstatic
, but you can add a uniqueprivate
constructor to prevent instantiation of the class.Most of the time, you will want auxiliary templates to be
void
, as explained below.
A template can invoke another template just like any other method. You can pass values to its compile-time and run-time parameters.
Warning
An important limitation to bear in mind is that templates can be invoked only as statements and not as part of an expression. We will revisit this restriction later in this article.
Example: simple auxiliary templates
The following example is a simple caching aspect. The aspect is intended to be used in different projects, and in some projects, we want to log a message in case of cache hit or miss. Therefore, we moved the logging logic to virtual
auxiliary template methods, with an empty implementation by default. In CacheAndLog
, we override the logging logic.
1using Metalama.Framework.Aspects;
2using System;
3using System.Collections.Concurrent;
4
5namespace Doc.AuxiliaryTemplate;
6
7internal class CacheAttribute : OverrideMethodAspect
8{
9 [Introduce( WhenExists = OverrideStrategy.Ignore )]
10 private readonly ConcurrentDictionary<string, object?> _cache = new();
11
12 // This method is the usual top-level template.
13 public override dynamic? OverrideMethod()
14 {
15 // Naive implementation of a caching key.
16 var cacheKey =
17 $"{meta.Target.Method.Name}({string.Join( ", ", meta.Target.Method.Parameters.ToValueArray() )})";
18
19 if ( this._cache.TryGetValue( cacheKey, out var returnValue ) )
20 {
21 this.LogCacheHit( cacheKey, returnValue );
22 }
23 else
24 {
25 this.LogCacheMiss( cacheKey );
26 returnValue = meta.Proceed();
27 this._cache.TryAdd( cacheKey, returnValue );
28 }
29
30 return returnValue;
31 }
32
33 // This method is an auxiliary template.
34
35 [Template]
36 protected virtual void LogCacheHit( string cacheKey, object? value ) { }
37
38 // This method is an auxiliary template.
39 [Template]
40 protected virtual void LogCacheMiss( string cacheKey ) { }
41}
42
43internal class CacheAndLogAttribute : CacheAttribute
44{
45 protected override void LogCacheHit( string cacheKey, object? value )
46 {
47 Console.WriteLine( $"Cache hit: {cacheKey} => {value}" );
48 }
49
50 protected override void LogCacheMiss( string cacheKey )
51 {
52 Console.WriteLine( $"Cache hit: {cacheKey}" );
53 }
54}
1namespace Doc.AuxiliaryTemplate;
2
3public class SelfCachedClass
4{
5 [Cache]
6 public int Add( int a, int b ) => a + b;
7
8 [CacheAndLog]
9 public int Rmove( int a, int b ) => a - b;
10}
1using System;
2using System.Collections.Concurrent;
3
4namespace Doc.AuxiliaryTemplate;
5
6public class SelfCachedClass
7{
8 [Cache]
9 public int Add(int a, int b)
10 {
11 var cacheKey = $"Add({string.Join(", ", new object[] { a, b })})";
12 if (_cache.TryGetValue(cacheKey, out var returnValue))
13 {
14 string cacheKey_1 = cacheKey;
15 global::System.Object? value = returnValue;
16 }
17 else
18 {
19 string cacheKey_2 = cacheKey;
20 returnValue = a + b;
21 _cache.TryAdd(cacheKey, returnValue);
22 }
23
24 return (int)returnValue;
25 }
26
27 [CacheAndLog]
28 public int Rmove(int a, int b)
29 {
30 var cacheKey = $"Rmove({string.Join(", ", new object[] { a, b })})";
31 if (_cache.TryGetValue(cacheKey, out var returnValue))
32 {
33 string cacheKey_1 = cacheKey;
34 global::System.Object? value = returnValue;
35 Console.WriteLine($"Cache hit: {cacheKey_1} => {value}");
36 }
37 else
38 {
39 string cacheKey_2 = cacheKey;
40 Console.WriteLine($"Cache hit: {cacheKey_2}");
41 returnValue = a - b;
42 _cache.TryAdd(cacheKey, returnValue);
43 }
44
45 return (int)returnValue;
46 }
47
48 private readonly ConcurrentDictionary<string, object?> _cache = new();
49}
Using return statements in auxiliary templates
The behavior of return
statements in auxiliary templates can sometimes be confusing compared to normal templates. Their nominal processing by the T# compiler is identical (indeed the T# compiler does not differentiate auxiliary templates from normal templates as their difference is only in usage): return
statements in any template result in return
statements in the output.
In a normal non-void C# method, all execution branches must end with a return <expression>
statement. However, because auxiliary templates often generate snippets instead of complete method bodies, you don't always want every branch of the auxiliary template to end with a return
statement.
To work around this situation, you can make the subtemplate void
and call the meta.Return method, which will generate a return <expression>
statement while making the C# compiler satisfied with your template.
Note
There is no way to explicitly interrupt the template processing other than playing with compile-time if
, else
and switch
statements and ensuring that the control flow continues to the natural end of the template method.
Example: meta.Return
The following example is a variation of our previous caching example, but we abstract the entire caching logic instead of just the logging part. The aspect has two auxiliary templates: GetFromCache
and AddToCache
. The first template is problematic because the cache hit branch must have a return
statement while the cache miss branch must continue the execution. Therefore, we designed GetFromCache
as a void
template and used meta.Return to generate the return
statement.
1using Metalama.Framework.Aspects;
2using System.Collections.Concurrent;
3
4namespace Doc.AuxiliaryTemplate_Return;
5
6internal class CacheAttribute : OverrideMethodAspect
7{
8 [Introduce( WhenExists = OverrideStrategy.Ignore )]
9 private readonly ConcurrentDictionary<string, object?> _cache = new();
10
11 // This method is the usual top-level template.
12 public override dynamic? OverrideMethod()
13 {
14 // Naive implementation of a caching key.
15 var cacheKey =
16 $"{meta.Target.Method.Name}({string.Join( ", ", meta.Target.Method.Parameters.ToValueArray() )})";
17
18 this.GetFromCache( cacheKey );
19
20 var returnValue = meta.Proceed();
21
22 this.AddToCache( cacheKey, returnValue );
23
24 return returnValue;
25 }
26
27 // This method is an auxiliary template.
28
29 [Template]
30 protected virtual void GetFromCache( string cacheKey )
31 {
32 if ( this._cache.TryGetValue( cacheKey, out var returnValue ) )
33 {
34 meta.Return( returnValue );
35 }
36 }
37
38 // This method is an auxiliary template.
39 [Template]
40 protected virtual void AddToCache( string cacheKey, object? returnValue )
41 {
42 this._cache.TryAdd( cacheKey, returnValue );
43 }
44}
1namespace Doc.AuxiliaryTemplate_Return;
2
3public class SelfCachedClass
4{
5 [Cache]
6 public int Add( int a, int b ) => a + b;
7}
1using System.Collections.Concurrent;
2
3namespace Doc.AuxiliaryTemplate_Return;
4
5public class SelfCachedClass
6{
7 [Cache]
8 public int Add(int a, int b)
9 {
10 var cacheKey = $"Add({string.Join(", ", new object[] { a, b })})";
11 string cacheKey_1 = cacheKey;
12 if (_cache.TryGetValue(cacheKey_1, out var returnValue))
13 {
14 return (int)returnValue;
15 }
16
17 int returnValue_1;
18 returnValue_1 = a + b;
19 string cacheKey_2 = cacheKey;
20 global::System.Object? returnValue_2 = returnValue_1;
21 _cache.TryAdd(cacheKey_2, returnValue_2);
22 return returnValue_1;
23 }
24
25 private readonly ConcurrentDictionary<string, object?> _cache = new();
26}
Invoking generic templates
Auxiliary templates can be beneficial when you need to call a generic API from a foreach
loop and the type parameter must be bound to a type that depends on the iterator variable.
For instance, suppose you want to generate a field-by-field implementation of the Equals
method and you want to invoke the EqualityComparer<T>.Default.Equals
method for each field or property of the target type. C# does not allow you to write EqualityComparer<field.Type>.Default.Equals
, although this is what you would conceptually need.
In this situation, you can use an auxiliary template with a compile-time type parameter.
To invoke the template, use the meta.InvokeTemplate and specify the args
parameter. For instance:
meta.InvokeTemplate( nameof(CompareFieldOrProperty), args:
new { TFieldOrProperty = fieldOrProperty.Type, fieldOrProperty, other = (IExpression) other! } );
This is illustrated by the following example:
Example: invoking a generic template
The following aspect implements the Equals
method by comparing all fields or automatic properties. For the sake of the exercise, we want to call the EqualityComparer<T>.Default.Equals
method with the proper value of T
for each field or property. This is achieved by the use of an auxiliary template and the meta.InvokeTemplate method.
1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Code.SyntaxBuilders;
4using System.Collections.Generic;
5using System;
6using System.Linq;
7
8namespace Doc.StructurallyComparable;
9
10public class StructuralEquatableAttribute : TypeAspect
11{
12 [Introduce( Name = nameof(Equals), WhenExists = OverrideStrategy.Override )]
13 public bool EqualsImpl( object? other )
14 {
15 foreach ( var fieldOrProperty in meta.Target.Type.FieldsAndProperties.Where(
16 t => t is { IsAutoPropertyOrField: true, IsImplicitlyDeclared: false } ) )
17 {
18 meta.InvokeTemplate(
19 nameof(this.CompareFieldOrProperty),
20 args: new
21 {
22 TFieldOrProperty = fieldOrProperty.Type,
23 fieldOrProperty,
24 other = ExpressionFactory.Capture( other! )
25 } );
26 }
27
28 return true;
29 }
30
31 [Template]
32 private void CompareFieldOrProperty<[CompileTime] TFieldOrProperty>(
33 IFieldOrProperty fieldOrProperty,
34 IExpression other )
35 {
36 if ( !EqualityComparer<TFieldOrProperty>.Default.Equals(
37 fieldOrProperty.Value,
38 fieldOrProperty.With( other ).Value ) )
39 {
40 meta.Return( false );
41 }
42 }
43
44 [Introduce( Name = nameof(GetHashCode), WhenExists = OverrideStrategy.Override )]
45 public int GetHashCodeImpl()
46 {
47 var hashCode = new HashCode();
48
49 foreach ( var fieldOrProperty in meta.Target.Type.FieldsAndProperties.Where(
50 t => t is { IsAutoPropertyOrField: true, IsImplicitlyDeclared: false } ) )
51 {
52 hashCode.Add( fieldOrProperty.Value );
53 }
54
55 return hashCode.ToHashCode();
56 }
57}
1namespace Doc.StructurallyComparable;
2
3[StructuralEquatable]
4internal class WineBottle
5{
6 public string Cepage { get; init; }
7
8 public int Millesime { get; init; }
9
10 public WineProducer Vigneron { get; init; }
11}
12
13[StructuralEquatable]
14internal class WineProducer
15{
16 public string Name { get; init; }
17
18 public string Address { get; init; }
19}
1using System;
2using System.Collections.Generic;
3
4namespace Doc.StructurallyComparable;
5
6[StructuralEquatable]
7internal class WineBottle
8{
9 public string Cepage { get; init; }
10
11 public int Millesime { get; init; }
12
13 public WineProducer Vigneron { get; init; }
14
15 public override bool Equals(object? other)
16 {
17 if (!EqualityComparer<string>.Default.Equals(Cepage, ((WineBottle)other).Cepage))
18 {
19 return false;
20 }
21
22 if (!EqualityComparer<int>.Default.Equals(Millesime, ((WineBottle)other).Millesime))
23 {
24 return false;
25 }
26
27 if (!EqualityComparer<WineProducer>.Default.Equals(Vigneron, ((WineBottle)other).Vigneron))
28 {
29 return false;
30 }
31
32 return true;
33 }
34
35 public override int GetHashCode()
36 {
37 var hashCode = new HashCode();
38 hashCode.Add(Cepage);
39 hashCode.Add(Millesime);
40 hashCode.Add(Vigneron);
41 return hashCode.ToHashCode();
42 }
43}
44
45[StructuralEquatable]
46internal class WineProducer
47{
48 public string Name { get; init; }
49
50 public string Address { get; init; }
51
52 public override bool Equals(object? other)
53 {
54 if (!EqualityComparer<string>.Default.Equals(Name, ((WineProducer)other).Name))
55 {
56 return false;
57 }
58
59 if (!EqualityComparer<string>.Default.Equals(Address, ((WineProducer)other).Address))
60 {
61 return false;
62 }
63
64 return true;
65 }
66
67 public override int GetHashCode()
68 {
69 var hashCode = new HashCode();
70 hashCode.Add(Name);
71 hashCode.Add(Address);
72 return hashCode.ToHashCode();
73 }
74}
Encapsulating a template invocation as a delegate
Calls to auxiliary templates can be encapsulated into an object of type TemplateInvocation, similar to the encapsulation of a method call into a delegate. The TemplateInvocation can be passed as an argument to another auxiliary template and invoked by the meta.InvokeTemplate method.
This technique is helpful when an aspect allows customizations of the generated code but when the customized template must call a given logic. For instance, a caching aspect may allow the customization to inject some try..catch
, and therefore requires a mechanism for the customization to call the desired logic inside the try..catch
.
Example: delegate-like invocation
The following code shows a base caching aspect named CacheAttribute
that allows customizations to wrap the entire caching logic into arbitrary logic by overriding the AroundCaching
template. This template must by contract invoke the TemplateInvocation it receives. The CacheAndRetryAttribute
uses this mechanism to inject retry-on-exception logic.
1using Metalama.Framework.Aspects;
2using System;
3using System.Collections.Concurrent;
4
5namespace Doc.AuxiliaryTemplate_TemplateInvocation;
6
7public class CacheAttribute : OverrideMethodAspect
8{
9 [Introduce( WhenExists = OverrideStrategy.Ignore )]
10 private readonly ConcurrentDictionary<string, object?> _cache = new();
11
12 public override dynamic? OverrideMethod()
13 {
14 this.AroundCaching( new TemplateInvocation( nameof(this.CacheOrExecuteCore) ) );
15
16 // This should be unreachable.
17 return default;
18 }
19
20 [Template]
21 protected virtual void AroundCaching( TemplateInvocation templateInvocation )
22 {
23 meta.InvokeTemplate( templateInvocation );
24 }
25
26 [Template]
27 private void CacheOrExecuteCore()
28 {
29 // Naive implementation of a caching key.
30 var cacheKey =
31 $"{meta.Target.Method.Name}({string.Join( ", ", meta.Target.Method.Parameters.ToValueArray() )})";
32
33 if ( !this._cache.TryGetValue( cacheKey, out var returnValue ) )
34 {
35 returnValue = meta.Proceed();
36 this._cache.TryAdd( cacheKey, returnValue );
37 }
38
39 meta.Return( returnValue );
40 }
41}
42
43public class CacheAndRetryAttribute : CacheAttribute
44{
45 public bool IncludeRetry { get; set; }
46
47 protected override void AroundCaching( TemplateInvocation templateInvocation )
48 {
49 if ( this.IncludeRetry )
50 {
51 for ( var i = 0;; i++ )
52 {
53 try
54 {
55 meta.InvokeTemplate( templateInvocation );
56 }
57 catch ( Exception ex ) when ( i < 10 )
58 {
59 Console.WriteLine( ex.ToString() );
60 }
61 }
62 }
63 else
64 {
65 meta.InvokeTemplate( templateInvocation );
66 }
67 }
68}
1namespace Doc.AuxiliaryTemplate_TemplateInvocation;
2
3public class SelfCachedClass
4{
5 [Cache]
6 public int Add( int a, int b ) => a + b;
7
8 [CacheAndRetry( IncludeRetry = true )]
9 public int Rmove( int a, int b ) => a - b;
10}
1using System;
2using System.Collections.Concurrent;
3
4namespace Doc.AuxiliaryTemplate_TemplateInvocation;
5
6public class SelfCachedClass
7{
8 [Cache]
9 public int Add(int a, int b)
10 {
11 {
12 var cacheKey = $"Add({string.Join(", ", new object[] { a, b })})";
13 if (!_cache.TryGetValue(cacheKey, out var returnValue))
14 {
15 returnValue = a + b;
16 _cache.TryAdd(cacheKey, returnValue);
17 }
18
19 return (int)returnValue;
20 }
21
22 return default;
23 }
24
25 [CacheAndRetry(IncludeRetry = true)]
26 public int Rmove(int a, int b)
27 {
28 for (var i = 0; ; i++)
29 {
30 try
31 {
32 {
33 var cacheKey = $"Rmove({string.Join(", ", new object[] { a, b })})";
34 if (!_cache.TryGetValue(cacheKey, out var returnValue))
35 {
36 returnValue = a - b;
37 _cache.TryAdd(cacheKey, returnValue);
38 }
39
40 return (int)returnValue;
41 }
42 }
43 catch (Exception ex) when (i < 10)
44 {
45 Console.WriteLine(ex.ToString());
46 }
47 }
48
49 return default;
50 }
51
52 private readonly ConcurrentDictionary<string, object?> _cache = new();
53}
This example is contrived in two regards. First, it would make sense in this case to use two aspects. Second, the use of a protected
method invoked by AroundCaching
would be preferable in this case. The use of TemplateInvocation makes sense when the template to call is not a part of the same class — for instance, if the caching aspect accepts options that can be set from a fabric, and that would allow users to supply a different implementation of this logic without overriding the caching attribute itself.
Evaluating a template into an IStatement
If you want to use templates with facilities like SwitchStatementBuilder, you will need an IStatement. To wrap a template invocation into an IStatement, use StatementFactory.FromTemplate.
You can call UnwrapBlock to remove braces from the template output, which will return an IStatementList.
Example: SwitchExpressionBuilder
The following example generates an Execute
method which has two arguments: a message name and an opaque argument. The aspect must be used on a class with one or many ProcessFoo
methods, where Foo
is the message name. The aspect generates a switch
statement that dispatches the message to the proper method. We use the StatementFactory.FromTemplate method to pass templates to the SwitchStatementBuilder.
1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Code.SyntaxBuilders;
4using System;
5using System.Linq;
6
7namespace Doc.SwitchStatementBuilder_FullTemplate;
8
9public class DispatchAttribute : TypeAspect
10{
11 [Introduce]
12 public void Execute( string messageName, string args )
13 {
14 var switchBuilder = new SwitchStatementBuilder( ExpressionFactory.Capture( messageName ) );
15
16 var processMethods =
17 meta.Target.Type.Methods.Where(
18 m => m.Name.StartsWith( "Process", StringComparison.OrdinalIgnoreCase ) );
19
20 foreach ( var processMethod in processMethods )
21 {
22 var nameWithoutPrefix = processMethod.Name.Substring( "Process".Length );
23
24 switchBuilder.AddCase(
25 SwitchStatementLabel.CreateLiteral( nameWithoutPrefix ),
26 null,
27 StatementFactory.FromTemplate(
28 nameof(this.Case),
29 new { method = processMethod, args = ExpressionFactory.Capture( args ) } )
30 .UnwrapBlock() );
31 }
32
33 switchBuilder.AddDefault(
34 StatementFactory.FromTemplate( nameof(this.DefaultCase) ).UnwrapBlock(),
35 false );
36
37 meta.InsertStatement( switchBuilder.ToStatement() );
38 }
39
40 [Template]
41 private void Case( IMethod method, IExpression args )
42 {
43 method.Invoke( args );
44 }
45
46 [Template]
47 private void DefaultCase()
48 {
49 throw new ArgumentOutOfRangeException();
50 }
51}
1namespace Doc.SwitchStatementBuilder_FullTemplate;
2
3[Dispatch]
4public class FruitProcessor
5{
6 private void ProcessApple( string args ) { }
7
8 private void ProcessOrange( string args ) { }
9
10 private void ProcessPear( string args ) { }
11}
1using System;
2
3namespace Doc.SwitchStatementBuilder_FullTemplate;
4
5[Dispatch]
6public class FruitProcessor
7{
8 private void ProcessApple(string args) { }
9
10 private void ProcessOrange(string args) { }
11
12 private void ProcessPear(string args) { }
13
14 public void Execute(string messageName, string args)
15 {
16 switch (messageName)
17 {
18 case "Apple":
19 ProcessApple(args);
20 break;
21 case "Orange":
22 ProcessOrange(args);
23 break;
24 case "Pear":
25 ProcessPear(args);
26 break;
27 default:
28 throw new ArgumentOutOfRangeException();
29 }
30 }
31}