The Singleton pattern requires that a single instance of a class exists throughout the application's lifetime.
In its classic version, the Singleton pattern in C# involves the following elements:
- A private constructor, or more precisely, the absence of any public constructor, which ensures that the class cannot be externally instantiated.
- A static read-only property or a field-backed method exposing the unique instance of the class.
A performance counter manager serves as an ideal example for the Singleton pattern, as its metrics must be consistently gathered across the entire application.
1using System.Collections.Concurrent;
2
3public partial class PerformanceCounterManager
4{
5 private readonly ConcurrentDictionary<string, int> _counters = new();
6
7 private PerformanceCounterManager()
8 {
9 }
10
11 public static PerformanceCounterManager Instance { get; } = new();
12
13 public void IncrementCounter(string name)
14 => this._counters.AddOrUpdate(name, 1, (_, value) => value + 1);
15}
If you frequently use the Singleton pattern, you might start noticing several issues with this code:
- Clarity: It's not immediately clear that the type is a Singleton. You need to parse more code to understand the pattern the class follows.
- Consistency: Different team members may implement the Singleton pattern in slightly different ways, making the codebase harder to learn and understand. For instance, the
Instance
property could have a different name, or it could be a method instead. - Boilerplate: Each Singleton class repeats the same code, which is tedious and could potentially lead to bugs due to inattention.
- Safety: There's nothing preventing someone from making the constructor public and then creating multiple instances of the class. You would typically rely on code reviews to detect violations of the pattern.
Step 1: Generating the Instance property on the fly
To ensure consistency and avoid boilerplate code, we'll add code to SingletonAttribute
. This will implement the repetitive part of the Singleton pattern for us by introducing the Instance
property (see Introducing members), with an initializer that invokes the constructor.
First, we add a template property to the aspect class, which outlines the shape of the Instance
property (we can't reference the type of the Singleton here, so we use object
as the property type instead and replace it later):
13[Template] public static object Instance { get; }
Then, we add code to the BuildAspect
method to actually introduce the Instance
property:
27// Introduce the property.
28builder.Advice.IntroduceProperty(
29 builder.Target,
30 nameof(Instance),
31 buildProperty: propertyBuilder =>
32 {
33 propertyBuilder.Type = builder.Target;
34
35 var initializer = new ExpressionBuilder();
36 initializer.AppendVerbatim("new ");
37 initializer.AppendTypeName(builder.Target);
38 initializer.AppendVerbatim("()");
39
40 propertyBuilder.InitializerExpression = initializer.ToExpression();
41 });
Here, we call IntroduceProperty, specifying the type into which the property should be introduced, the name of the template, and a lambda that is used to customize the property further. Inside the lambda, we replace the object
type with the actual type of the Singleton class and set the initializer to invoke the constructor. We use ExpressionBuilder to build the expression that calls the constructor, including the AppendTypeName method, which ensures that the type name is correctly formatted.
The resulting Singleton class is a bit simpler, and doing this automatically ensures that all Singletons in the codebase are implemented in the same way:
1using System.Collections.Concurrent;
2
3[Singleton]
4public partial class PerformanceCounterManager
5{
6 private readonly ConcurrentDictionary<string, int> _counters = new();
7
8 public void IncrementCounter(string name)
9 => this._counters.AddOrUpdate(name, 1, (_, value) => value + 1);
10}
1using System.Collections.Concurrent;
2
3[Singleton]
4public partial class PerformanceCounterManager
5{
6 private readonly ConcurrentDictionary<string, int> _counters = new();
7
8 public void IncrementCounter(string name)
9 => this._counters.AddOrUpdate(name, 1, (_, value) => value + 1);
10private PerformanceCounterManager()
11 { }
12 public static PerformanceCounterManager Instance { get; } = new global::PerformanceCounterManager();
13}
Step 2: Verifying that constructors are private
To ensure safety, we can verify that all constructors are private and produce a warning (see Reporting and suppressing diagnostics) if they are not. To do this, we first add a definition of the warning as a static
field to the aspect class:
17private static readonly DiagnosticDefinition<(IConstructor, INamedType)>
18 _constructorHasToBePrivate = new(
19 "SING01",
20 Severity.Warning,
21 "The '{0}' constructor must be private because the class is [Singleton].");
The type of the field is DiagnosticDefinition<T>, where the type argument specifies the types of parameters used in the diagnostic message as a tuple. The message uses the same format string syntax as the String.Format method.
We then add code to the BuildAspect
method to check if the constructor is private and produce a warning if it isn't:
45// Verify constructors.
46foreach (var constructor in builder.Target.Constructors)
47{
48 if (constructor.Accessibility != Accessibility.Private &&
49 !constructor.IsImplicitlyDeclared)
50 {
51 builder.Diagnostics.Report(
52 _constructorHasToBePrivate.WithArguments((constructor, builder.Target)),
53 constructor);
54 }
55}
To do this, we iterate over all constructors of the type, check the Accessibility for each of them, and then report the warning specified above if the accessibility is not Private. Note that we are skipping the implicitly defined default constructor because this case will be covered in step 3. We specify the formatting arguments for the diagnostic message as a tuple using the WithArguments method. We also explicitly set the location of the diagnostic to the constructor; otherwise, the warning would be reported by default at the type level, because we're reporting it through the IAspectBuilder<TAspectTarget> for the Singleton type.
Notice the warning on the public constructor in the following code.
1[Singleton]
2internal class PublicConstructorSingleton
3{
Warning SING01: The 'PublicConstructorSingleton.PublicConstructorSingleton()' constructor must be private because the class is [Singleton].
4 public PublicConstructorSingleton()
5 {
6 }
7}
Step 3. Adding a private constructor
If the target class does not contain any user-defined constructor, the C# language implicitly defines a default public constructor. When the type is a Singleton, we want to introduce a private default constructor instead.
59// If there is no explicit constructor, add one.
60if (builder.Target.Constructors.All(c =>
61 c.IsImplicitlyDeclared))
62{
63 builder.IntroduceConstructor(nameof(this.ConstructorTemplate),
64 buildConstructor: c => c.Accessibility = Accessibility.Private);
65}
Aspect implementation
The complete aspect implementation is provided below:
1using Metalama.Framework.Advising;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Code.SyntaxBuilders;
5using Metalama.Framework.Diagnostics;
6using Metalama.Framework.Eligibility;
7
8#pragma warning disable CS8618
9
10public class SingletonAttribute : TypeAspect
11{
12 //
13 [Template] public static object Instance { get; }
14 //
15
16 //
17 private static readonly DiagnosticDefinition<(IConstructor, INamedType)>
18 _constructorHasToBePrivate = new(
19 "SING01",
20 Severity.Warning,
21 "The '{0}' constructor must be private because the class is [Singleton].");
22 //
23
24 public override void BuildAspect(IAspectBuilder<INamedType> builder)
25 {
26 //
27 // Introduce the property.
28 builder.Advice.IntroduceProperty(
29 builder.Target,
30 nameof(Instance),
31 buildProperty: propertyBuilder =>
32 {
33 propertyBuilder.Type = builder.Target;
34
35 var initializer = new ExpressionBuilder();
36 initializer.AppendVerbatim("new ");
37 initializer.AppendTypeName(builder.Target);
38 initializer.AppendVerbatim("()");
39
40 propertyBuilder.InitializerExpression = initializer.ToExpression();
41 });
42 //
43
44 //
45 // Verify constructors.
46 foreach (var constructor in builder.Target.Constructors)
47 {
48 if (constructor.Accessibility != Accessibility.Private &&
49 !constructor.IsImplicitlyDeclared)
50 {
51 builder.Diagnostics.Report(
52 _constructorHasToBePrivate.WithArguments((constructor, builder.Target)),
53 constructor);
54 }
55 }
56 //
57
58 //
59 // If there is no explicit constructor, add one.
60 if (builder.Target.Constructors.All(c =>
61 c.IsImplicitlyDeclared))
62 {
63 builder.IntroduceConstructor(nameof(this.ConstructorTemplate),
64 buildConstructor: c => c.Accessibility = Accessibility.Private);
65 }
66 //
67 }
68
69 [Template]
70 private void ConstructorTemplate()
71 {
72 }
73
74 public override void BuildEligibility(IEligibilityBuilder<INamedType> builder) =>
75 builder.MustSatisfy(t => t.TypeKind is TypeKind.Class,
76 t => $"{t} must be a class");
77}