This chapter is about some common types that are included with .NET for performing code reflection and applying and reading attributes, working with expression trees, and creating source generators.
This chapter covers the following topics:
Reflection is a programming feature that allows code to understand and manipulate itself. An assembly is made up of up to four parts:
The metadata comprises items of information about your code. The metadata is generated automatically from your code (for example, information about the types and members) or applied to your code using attributes.
Attributes can be applied at multiple levels: to assemblies, to types, and to their members, as shown in the following code:
// an assembly-level attribute
[assembly: AssemblyTitle("Working with reflection and attributes")]
// a type-level attribute
[Serializable]
public class Person
{
// a member-level attribute
[Obsolete("Deprecated: use Run instead.")]
public void Walk()
{
...
Attribute-based programming is used a lot in app models like ASP.NET Core to enable features like routing, security, and caching.
Version numbers in .NET are a combination of three numbers, with two optional additions. If you follow the rules of semantic versioning, the three numbers denote the following:
Optionally, a version can include these:
Good Practice: Follow the rules of semantic versioning, as described at the following link: http://semver.org.
Let’s explore working with attributes:
console
project named WorkingWithReflection
to a Chapter06
solution/workspace:WorkingWithReflection
as the active OmniSharp project.Console
class, as shown in the following markup:
<ItemGroup>
<Using Include="System.Console" Static="true" />
</ItemGroup>
Program.cs
, import the namespace for reflection, and add statements to get the console app’s assembly, output its name and location, and get all assembly-level attributes and output their types, as shown in the following code:
using System.Reflection; // Assembly
WriteLine("Assembly metadata:");
Assembly? assembly = Assembly.GetEntryAssembly();
if (assembly is null)
{
WriteLine("Failed to get entry assembly.");
return;
}
WriteLine($" Full name: {assembly.FullName}");
WriteLine($" Location: {assembly.Location}");
WriteLine($" Entry point: {assembly.EntryPoint?.Name}");
IEnumerable<Attribute> attributes = assembly.GetCustomAttributes();
WriteLine($" Assembly-level attributes:");
foreach (Attribute a in attributes)
{
WriteLine($" {a.GetType()}");
}
Assembly metadata:
Full name: WorkingWithReflection, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
Location: C:apps-services-net7Chapter06WorkingWithReflectioninDebug
et7.0WorkingWithReflection.dll
Entry point: <Main>$
Assembly-level attributes:
System.Runtime.CompilerServices.CompilationRelaxationsAttribute
System.Runtime.CompilerServices.RuntimeCompatibilityAttribute
System.Diagnostics.DebuggableAttribute
System.Runtime.Versioning.TargetFrameworkAttribute
System.Reflection.AssemblyCompanyAttribute
System.Reflection.AssemblyConfigurationAttribute
System.Reflection.AssemblyFileVersionAttribute
System.Reflection.AssemblyInformationalVersionAttribute
System.Reflection.AssemblyProductAttribute
System.Reflection.AssemblyTitleAttribute
Note that because the full name of an assembly must uniquely identify the assembly, it is a combination of the following:
WorkingWithReflection
1.0.0.0
neutral
null
Now that we know some of the attributes decorating the assembly, we can ask for them specifically.
AssemblyInformationalVersionAttribute
and AssemblyCompanyAttribute
classes and then output their values, as shown in the following code:
AssemblyInformationalVersionAttribute? version = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
WriteLine($" Version: {version?.InformationalVersion}");
AssemblyCompanyAttribute? company = assembly
.GetCustomAttribute<AssemblyCompanyAttribute>();
WriteLine($" Company: {company?.Company}");
Version: 1.0.0
Company: WorkingWithReflection
Hmmm, unless you set the version, it defaults to 1.0.0
, and unless you set the company, it defaults to the name of the assembly.
Let’s explicitly set this information. The legacy .NET Framework way to set these values was to add attributes in the C# source code file, as shown in the following code:
[assembly: AssemblyCompany("Packt Publishing")]
[assembly: AssemblyInformationalVersion("1.3.0")]
The Roslyn compiler used by .NET sets these attributes automatically, so we can’t use the old way. Instead, they must be set in the project file.
WorkingWithReflection.csproj
project file to add elements for version and company, as shown highlighted in the following markup:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>7.0.1</Version>
<Company>Packt Publishing</Company>
</PropertyGroup>
Assembly metadata:
Full name: WorkingWithReflection, Version=7.0.1.0, Culture=neutral, PublicKeyToken=null
...
Version: 7.0.1
Company: Packt Publishing
You can define your own attributes by inheriting from the Attribute
class:
CoderAttribute.cs
.CoderAttribute.cs
, define an attribute class that can decorate either classes or methods with two properties to store the name of a coder and the date they last modified some code, as shown in the following code:
namespace Packt.Shared;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method,
AllowMultiple = true)]
public class CoderAttribute : Attribute
{
public string Coder { get; set; }
public DateTime LastModified { get; set; }
public CoderAttribute(string coder, string lastModified)
{
Coder = coder;
LastModified = DateTime.Parse(lastModified);
}
}
Animal.cs
.Animal.cs
, add a class with a method, and decorate the method with the Coder
attribute with data about two coders, as shown in the following code:
namespace Packt.Shared;
public class Animal
{
[Coder("Mark Price", "22 August 2022")]
[Coder("Johnni Rasmussen", "13 September 2022")]
public void Speak()
{
WriteLine("Woof...");
}
}
Program.cs
, import namespaces for working with your custom attribute, as shown in the following code:
using Packt.Shared; // CoderAttribute
Program.cs
, add code to get the types in the current assembly, enumerate their members, read any Coder
attributes on those members, and output the information, as shown in the following code:
WriteLine();
WriteLine($"* Types:");
Type[] types = assembly.GetTypes();
foreach (Type type in types)
{
WriteLine();
WriteLine($"Type: {type.FullName}");
MemberInfo[] members = type.GetMembers();
foreach (MemberInfo member in members)
{
WriteLine("{0}: {1} ({2})",
member.MemberType, member.Name,
member.DeclaringType?.Name);
IOrderedEnumerable<CoderAttribute> coders =
member.GetCustomAttributes<CoderAttribute>()
.OrderByDescending(c => c.LastModified);
foreach (CoderAttribute coder in coders)
{
WriteLine("-> Modified by {0} on {1}",
coder.Coder, coder.LastModified.ToShortDateString());
}
}
}
* Types:
...
Type: Packt.Shared.Animal
Method: Speak (Animal)
-> Modified by Johnni Rasmussen on 13/09/2022
-> Modified by Mark Price on 22/08/2022
Method: GetType (Object)
Method: ToString (Object)
Method: Equals (Object)
Method: GetHashCode (Object)
Constructor: .ctor (Program)
...
Type: Program+<>c
Method: GetType (Object)
Method: ToString (Object)
Method: Equals (Object)
Method: GetHashCode (Object)
Constructor: .ctor (<>c)
Field: <>9 (<>c)
Field: <>9__0_0 (<>c)
What is the Program+<>c
type and its strangely named fields?
It is a compiler-generated display class. <>
indicates compiler-generated and c
indicates a display class. They are undocumented implementation details of the compiler and could change at any time. You can ignore them, so as an optional challenge, add statements to your console app to filter compiler-generated types by skipping types decorated with CompilerGeneratedAttribute
.
Hint: Import the namespace for working with compiler-generated code, as shown in the following code:
using System.Runtime.CompilerServices; // CompilerGeneratedAttribute
Over time, you might decide to refactor your types and their members while maintaining backward compatibility. To encourage developers who use your types to use the newer implementations, you can decorate the old types and members with the [Obsolete]
attribute.
Let’s see an example:
Animal.cs
, add a new method and mark the old method as obsolete, as shown highlighted in the following code:
[Coder("Mark Price", "22 August 2022")]
[Coder("Johnni Rasmussen", "13 September 2022")]
[Obsolete($"use {nameof(SpeakBetter)} instead.")]
public void Speak()
{
WriteLine("Woof...");
}
public void SpeakBetter()
{
WriteLine("Wooooooooof...");
}
Program.cs
, modify the statements to detect obsolete methods, as shown highlighted in the following code:
foreach (MemberInfo member in members)
{
ObsoleteAttribute? obsolete =
member.GetCustomAttribute<ObsoleteAttribute>();
WriteLine("{0}: {1} ({2}) {3}",
member.MemberType, member.Name,
member.DeclaringType?.Name,
obsolete is null ? "" : $"Obsolete! {obsolete.Message}");
Type: Packt.Shared.Animal
Method: Speak (Animal) Obsolete! use SpeakBetter instead.
-> Modified by Johnni Rasmussen on 13/09/2022
-> Modified by Mark Price on 22/08/2022
Method: SpeakBetter (Animal)
Method: GetType (Object)
Method: ToString (Object)
Method: Equals (Object)
Method: GetHashCode (Object)
Constructor: .ctor (Animal)
Normally, if a .NET project needs to execute in another .NET assembly, you reference the package or project, and then at compile time, the compiler knows the assemblies that will be loaded into the memory of the calling codebase during start up at runtime. But sometimes you may not know the assemblies that you need to call until runtime. For example, a word processor does not need to have the functionality to perform a mail merge loaded all the time. The mail merge feature could be implemented as a separate assembly that is only loaded into memory when it is activated by the user. Another example would be an application that allows custom plugins, perhaps even created by other developers.
You can dynamically load a set of assemblies into an AssemblyLoadContext
, execute methods in them, and then unload the AssemblyLoadContext
, which unloads the assemblies too. A side effect of this is reduced memory usage.
In .NET 7, the overhead of using reflection to invoke a member of a type, like calling a method or setting, or getting a property, has been made up to four times faster when it is done more than once on the same member.
Let’s see how to dynamically load an assembly and then instantiate a class and interact with its members:
classlib
project named DynamicLoadAndExecute.Library
to the Chapter06
solution/workspace.Console
class, and globally import the namespace for working with reflection, as shown highlighted in the following markup:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<Using Include="System.Reflection" />
<Using Include="System.Console" Static="true" />
</ItemGroup>
</Project>
Class1.cs
to Dog.cs
.Dog.cs
, define a Dog
class with a Speak
method that writes a simple message to the console based on a string
parameter passed to the method, as shown in the following code:
namespace DynamicLoadAndExecute.Library;
public class Dog
{
public void Speak(string? name)
{
WriteLine($"{name} says Woof!");
}
}
console
project named DynamicLoadAndExecute.Console
to the Chapter06
solution/workspace.Console
class, and globally import the namespace for working with reflection, as shown highlighted in the following markup:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<Using Include="System.Reflection" />
<Using Include="System.Console" Static="true" />
</ItemGroup>
</Project>
DynamicLoadAndExecute.Library
project to create the assembly in its bin
folder structure.DynamicLoadAndExecute.Console
project to create its bin
folder structure.DynamicLoadAndExecute.Library
project’s binDebug
et7.0
folder to the equivalent folder in the DynamicLoadAndExecute.Console
project, as shown in the following list:DynamicLoadAndExecute.Library.deps.json
DynamicLoadAndExecute.Library.dll
DynamicLoadAndExecute.Library.pdb
DynamicLoadAndExecute.Console
project, add a new class file named Program.Helpers.cs
and modify its contents to define a method to output information about an assembly and its types, as shown in the following code:
partial class Program
{
static void OutputAssemblyInfo(Assembly a)
{
WriteLine("FullName: {0}", a.FullName);
WriteLine("Location: {0}", Path.GetDirectoryName(a.Location));
WriteLine("IsCollectible: {0}", a.IsCollectible);
WriteLine("Defined types:");
foreach (TypeInfo info in a.DefinedTypes)
{
if (!info.Name.EndsWith("Attribute"))
{
WriteLine(" Name: {0}, Members: {1}",
info.Name, info.GetMembers().Count());
}
}
WriteLine();
}
}
DynamicLoadAndExecute.Console
project, add a new class file named DemoAssemblyLoadContext.cs
, and modify its contents to load a named assembly into the current context at runtime using an assembly dependency resolver, as shown in the following code:
using System.Runtime.Loader; // AssemblyDependencyResolver
internal class DemoAssemblyLoadContext : AssemblyLoadContext
{
private AssemblyDependencyResolver _resolver;
public DemoAssemblyLoadContext(string mainAssemblyToLoadPath)
: base(isCollectible: true)
{
_resolver = new AssemblyDependencyResolver(mainAssemblyToLoadPath);
}
}
Program.cs
, delete the existing statements. Then, use the load context class to load the class library and output information about it, and then dynamically create an instance of the Dog
class and call its Speak
method, as shown in the following code:
Assembly? thisAssembly = Assembly.GetEntryAssembly();
if (thisAssembly is null)
{
WriteLine("Could not get the entry assembly.");
return;
}
OutputAssemblyInfo(thisAssembly);
WriteLine("Creating load context for:
{0}
",
Path.GetFileName(thisAssembly.Location));
DemoAssemblyLoadContext loadContext = new(thisAssembly.Location);
string assemblyPath = Path.Combine(
Path.GetDirectoryName(thisAssembly.Location) ?? "",
"DynamicLoadAndExecute.Library.dll");
WriteLine("Loading:
{0}
",
Path.GetFileName(assemblyPath));
Assembly dogAssembly = loadContext.LoadFromAssemblyPath(assemblyPath);
OutputAssemblyInfo(dogAssembly);
Type? dogType = dogAssembly.GetType("DynamicLoadAndExecute.Library.Dog");
if (dogType is null)
{
WriteLine("Could not get the Dog type.");
return;
}
MethodInfo? method = dogType.GetMethod("Speak");
if (method != null)
{
object? dog = Activator.CreateInstance(dogType);
for (int i = 0; i < 10; i++)
{
method.Invoke(dog, new object[] { "Fido" });
}
}
WriteLine();
WriteLine("Unloading context and assemblies.");
loadContext.Unload();
FullName: DynamicLoadAndExecute.Console, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
Location: C:apps-services-net7Chapter06DynamicLoadAndExecute.ConsoleinDebug
et7.0
IsCollectible: False
Defined types:
Name: DemoAssemblyLoadContext, Members: 29
Name: Program, Members: 5
Creating load context for:
DynamicLoadAndExecute.Console.dll
Loading:
DynamicLoadAndExecute.Library.dll
FullName: DynamicLoadAndExecute.Library, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
Location: C:apps-services-net7Chapter06DynamicLoadAndExecute.ConsoleinDebug
et7.0
IsCollectible: True
Defined types:
Name: Dog, Members: 6
Fido says Woof!
Fido says Woof!
Fido says Woof!
Fido says Woof!
Fido says Woof!
Fido says Woof!
Fido says Woof!
Fido says Woof!
Fido says Woof!
Fido says Woof!
Unloading context and assemblies.
Note that the entry assembly (the console app) is not collectible, meaning that it cannot be removed from memory, but the dynamically loaded class library is collectible.
This is just a taster of what can be achieved with reflection. Reflection can also do the following:
Expression trees represent code as a structure that you can examine or execute. Expression trees are immutable so you cannot change one, but you can create a copy with the changes you want.
If you compare expression trees to functions, then although functions have flexibility in the parameter values passed to them, the structure of the function, what it does with those values, and how are all fixed. Expression trees provide a structure that can dynamically change, so what and how a function is implemented can be dynamically changed at runtime.
Expression trees are also used to represent an expression in an abstract way, so instead of being expressed using C# code, the expression is expressed as a data structure in memory. This then allows that data structure to be expressed in other ways, using other languages.
When you write a LINQ expression for the EF Core database provider, it is represented by an expression tree that is then translated into an SQL statement. But even the simplest C# statement can be represented as an expression tree.
Let’s look at a simple example, adding two numbers:
int three = 1 + 2;
This statement would be represented as the tree in Figure 6.1:
Figure 6.1: An expression tree of a simple statement adding two numbers
The System.Linq.Expressions
namespace contains types for representing the components of an expression tree. For example:
Type |
Description |
|
An expression with a binary operator. |
|
A block containing a sequence of expressions where variables can be defined. |
|
A catch statement in a try block. |
|
An expression that has a conditional operator. |
|
A lambda expression. |
|
Assigning to a field or property. |
|
Accessing a field or property. |
|
A call to a method. |
|
A call to a constructor. |
Only expression trees that represent lambda expressions can be executed.
Let’s see how to construct, compile, and execute an expression tree:
console
project named WorkingWithExpressionTrees
to the Chapter06
solution/workspace.Console
class, as shown in the following markup:
<ItemGroup>
<Using Include="System.Console" Static="true" />
</ItemGroup>
Program.cs
, delete the existing statements and then define an expression tree and execute it, as shown in the following code:
using System.Linq.Expressions; // Expression and so on
ConstantExpression one = Expression.Constant(1, typeof(int));
ConstantExpression two = Expression.Constant(2, typeof(int));
BinaryExpression add = Expression.Add(one, two);
Expression<Func<int>> expressionTree = Expression.Lambda<Func<int>>(add);
Func<int> compiledTree = expressionTree.Compile();
WriteLine($"Result: {compiledTree()}");
Result: 3
Source generators were introduced with C# 9 and .NET 5. They allow a programmer to get a compilation object that represents all the code being compiled, dynamically generate additional code files, and compile those too. Source generators are like code analyzers that can add more code to the compilation process.
A great example is the System.Text.Json
source generator. The classic method for serializing JSON uses reflection at runtime to dynamically analyze an object model, but this is slow. The better method uses source generators to create source code that is then compiled to give improved performance.
You can read more about the System.Text.Json
source generator at the following link: https://devblogs.microsoft.com/dotnet/try-the-new-system-text-json-source-generator/.
We will create a source generator that programmatically creates a code file that adds a method to the Program
class, as shown in the following code:
// source-generated code
static partial class Program
{
static partial void Message(string message)
{
System.Console.WriteLine($"Generator says: '{message}'");
}
}
This method can then be called in the Program.cs
file of the project that uses this source generator.
Let’s see how to do this:
console
project named GeneratingCodeApp
to the Chapter06
solution/workspace.Console
class, as shown in the following markup:
<ItemGroup>
<Using Include="System.Console" Static="true" />
</ItemGroup>
Program.Methods.cs
.Program.Methods.cs
, define a partial Program
class with a partial method with a string
parameter, as shown in the following code:
partial class Program
{
static partial void Message(string message);
}
Program.cs
, delete the existing statements and then call the partial method, as shown in the following code:
Message("Hello from some source generator code.");
classlib
project named GeneratingCodeLib
that targets .NET Standard 2.0 to the Chapter06
solution/workspace.
Currently, source generators must target .NET Standard 2.0. The default C# version used for class libraries that target .NET Standard 2.0 is C# 7.3, as shown at the following link: https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/configure-language-version#defaults.
using
statements), statically and globally import the Console
class, and add the NuGet packages Microsoft.CodeAnalysis.Analyzers
and Microsoft.CodeAnalysis.CSharp
, as shown highlighted in the following markup:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>10</LangVersion>
</PropertyGroup>
<ItemGroup>
<Using Include="System.Console" Static="true" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers"
Version="3.3.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers;
buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp"
Version="4.1.0" />
</ItemGroup>
</Project>
This project does not enable null warnings because the <Nullable>enable</Nullable>
element is missing. If you add it, then you will see some null warnings later.
GeneratingCodeLib
project.Class1.cs
as MessageSourceGenerator.cs
.GeneratingCodeLib
project, in MessageSourceGenerator.cs
, define a class that implements ISourceGenerator
and is decorated with the [Generator]
attribute, as shown in the following code:
using Microsoft.CodeAnalysis; // [Generator], GeneratorInitializationContext
// ISourceGenerator, GeneratorExecutionContext
namespace Packt.Shared;
[Generator]
public class MessageSourceGenerator : ISourceGenerator
{
public void Execute(GeneratorExecutionContext execContext)
{
IMethodSymbol mainMethod = execContext.Compilation
.GetEntryPoint(execContext.CancellationToken);
string sourceCode = $@"// source-generated code
static partial class {mainMethod.ContainingType.Name}
{{
static partial void Message(string message)
{{
System.Console.WriteLine($""Generator says: '{{message}}'"");
}}
}}
";
string typeName = mainMethod.ContainingType.Name;
execContext.AddSource($"{typeName}.Methods.g.cs", sourceCode);
}
public void Initialize(GeneratorInitializationContext initContext)
{
// this source generator does not need any initialization
}
}
Good Practice: Include .g.
or .generated.
in the filename of source-generated files.
GeneratingCodeApp
project, in the project file, add a reference to the class library project, as shown in the following markup:
<ItemGroup>
<ProjectReference Include="..GeneratingCodeLibGeneratingCodeLib.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
Good Practice: It is sometimes necessary to restart Visual Studio 2022 to see the results of working with source generators.
Build the GeneratingCodeApp
project and note the auto-generated class file:
Program.Methods.g.cs
file, as shown in Figure 6.2:Figure 6.2: The source-generated Program.Methods.g.cs file
Visual Studio Code does not automatically run analyzers. We must add an extra entry in the project file to enable the automatic generation of the source generator file.
GeneratingCodeApp
project, in the project file, in the <PropertyGroup>
, add an entry to enable the generation of the code file, as shown in the following markup:
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
GeneratingCodeApp
project.obj/Debug/net7.0
folder, note the generated
folder and its subfolder GeneratingCodeLib/Packt.Shared.MessageSourceGenerator
, and the auto-generated file named Program.Methods.g.cs
.Program.Methods.g.cs
file and note its contents, as shown in the following code:
// source-generated code
static partial class Program
{
static partial void Message(string message)
{
System.Console.WriteLine($"Generator says: '{message}'");
}
}
Generator says: 'Hello from some source generator code.'
You can control the path for automatically generated code files by adding a <CompilerGeneratedFilesOutputPath>
element.
Source generators are a massive topic.
To learn more, use the following links:
Test your knowledge and understanding by answering some questions, getting some hands-on practice, and exploring with deeper research into the topics in this chapter.
Use the web to answer the following questions:
Use the links on the following page to learn more detail about the topics covered in this chapter:
In this chapter, you:
In the next chapter, we will learn how to work with data stored in SQL Server.
Join the book’s Discord workspace for Ask me Anything sessions with the author.