C# 11.0: Generic Attributes
In the previous blog post you learned about C# 11.0 raw string literals. In this blog post, you will learn about another C# 11.0 feature that is called Generic Attributes.
What Are Generic Attributes in C# 11.0?
Since C# 1.0, you can define custom attributes. You can put those attributes on many different targets, like classes, properties, methods, parameters, enum members, and also at the assembly level. But what was not possible before C# 11.0 was to define a generic attribute.
Look at the Person
class in the following code snippet. It has a ConsoleWriter
attribute. The idea of this ConsoleWriter
attribute is to define a type that knows how to write a Person
instance nicely to the console. To do this, the ConsoleWriter
attribute’s constructor takes a Type
object. In the code snippet below C#’s typeof
keyword is used with the PersonConsoleWriter
class, so that the Type
object for this class is passed to the constructor of the ConsoleWriter
attribute.
[ConsoleWriter(typeof(PersonConsoleWriter))]
public class Person { }
With C# 11.0, you can define this ConsoleWriter
attribute as a generic attribute, and then you can use it like you see it in the code snippet below.
[ConsoleWriter<PersonConsoleWriter>]
public class Person { }
The generic attribute has the advantage that you can use a constraint for the generic type argument. In the code snippet above the generic type argument is the PersonConsoleWriter
class. This means that you can limit at compile time the types that can be used with the attribute. That’s great, as this is not possible with non-generic attributes.
So, that’s the feature. :)
If you haven’t worked a lot with attributes in C#, it might be hard to grasp why you want and need this. So, let’s look a bit into the details and let’s start with an example without attributes.
Coding without Attributes
Let’s assume you have defined a Person
class like you see it below with the two properties FirstName
and LastName
.
public class Person
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
}
Let’s assume you’ve also created a simple Address
class like below.
public class Address
{
public string? City { get; set; }
}
To write objects of these types to the console, you might want to define that logic in separate classes. Of course, we could just override the ToString method in those two classes to get the desired output, then there’s no need for separate classes. But instead of writing the objects just to the console, you could also think of converting them to another format, or serializing them, or storing them in a file or database etc., there are many uses cases. For simplicity reasons, we just write them in this blog post to the console. But in any case, it’s always good to ensure that every class has a single responsibility. In this case here, the responsibilty of the classes Person
and Address
shouldn’t be to know how to write them in a nice way to the console. So, let’s create that logic in separate classes.
First, let’s define an IConsoleWriter
interface as below. As you can see, it has a Write
method that takes an object
.
public interface IConsoleWriter
{
void Write(object obj);
}
Now let’s create an implementation of the IConsoleWriter
interface that works with a Person
object. As you can see, the following PersonConsoleWriter
class writes the person’s firstname and lastname to the console.
public class PersonConsoleWriter : IConsoleWriter
{
public void Write(object obj)
{
var person = (Person)obj;
Console.WriteLine($"Person => FirstName:{person.FirstName} LastName:{person.LastName}");
}
}
Let’s also create an implementation that works with an Address
object. You see it in the code snippet below.
public class AddressConsoleWriter : IConsoleWriter
{
public void Write(object obj)
{
var address = (Address)obj;
Console.WriteLine($"Address => City:{address.City}");
}
}
Now let´s see how to use these classes. In the following top-level program, a Person
object and an Address
object are created. Both are passed to a WriteObjectToConsole
method that works with any object. When you look at the WriteObjectToConsole
method in the code snippet below, you can see that it uses a PersonConsoleWriter
for Person
objects and an AddressConsoleWriter
for Address
objects. This is done with a simple switch
expression. All other objects are passed to the Console.WriteLine
method, which means the result of their ToString
method is written to the console.
var person = new Person { FirstName = "Thomas", LastName = "Huber" };
var address = new Address { City = "Frankfurt" };
WriteObjectToConsole(person);
WriteObjectToConsole(address);
Console.ReadLine();
void WriteObjectToConsole(object obj)
{
IConsoleWriter? consoleWriter = obj switch
{
Person => new PersonConsoleWriter(),
Address => new AddressConsoleWriter(),
_ => null
};
if(consoleWriter is not null)
{
consoleWriter.Write(obj);
}
else
{
Console.WriteLine(obj);
}
}
The code above has the disadvantage that you need to touch the WriteObjectToConsole
method every time you want to introduce a new type with a corresponding console writer type. It’s the switch
expression that you need to adjust. By default, it just supports the two classes Person
and Address
. Now let’s see how you can use a custom attribute, so that you never have to touch the WriteObjectToConsole
method respectively the switch
expression in this method again when you introduce new types.
Use an Attribute
In the code snippet below you see the ConsoleWriterAttribute
. It inherits from .NET’s Attribute
class, and by convention the class name ends with the suffix Attribute
. That suffix is optional when you use the attribute, which means ConsoleWriterAttribute
and ConsoleWriter
are valid. The ConsoleWriter
attribute’s constructor takes a Type
that gets stored in the attribute’s ConsoleWriterType
property. In addition, on the ConsoleWriterAttribute
class the AttributeUsage
attribute is set to ensure that the ConsoleWriter
attribute can only be set on classes, and not multiple times on the same class.
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class ConsoleWriterAttribute : Attribute
{
public ConsoleWriterAttribute(Type consoleWriterType)
{
ConsoleWriterType = consoleWriterType;
}
public Type ConsoleWriterType { get; }
}
Now you can use this ConsoleWriter
attribute on the classes Person
and Address
as you see it in the code snippet below. This means that this attribute points now to the IConsoleWriter
implementation that should be used for the class on which you set the ConsoleWriter
attribute.
[ConsoleWriter(typeof(PersonConsoleWriter))]
public class Person
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
}
[ConsoleWriter(typeof(AddressConsoleWriter))]
public class Address
{
public string? City { get; set; }
}
Now you can implement the WriteObjectToConsole
method differently than before. You can rely completely on the attribute to find a console writer. As you see in the codesnippet below, there is no reference to neither the Person
class nor to the Address
class. The whole switch
expression is gone. First, from the object
parameter, the type is grabbed by calling the GetType
method. On that Type
object, the GetCustomAttributes
method is called to get the attributes of type ConsoleWriterAttribute
.
If a ConsoleWriterAttribute
was found, then the value of the attribute’s ConsoleWriterType
property is stored in a local consoleWriterType
variable. This means at this point you have the console writer type for the object
that you get as a parameter in the WriteObjectToConsole
method. With the static CreateInstance
method of the Activator
class, an instance of the console writer type is created. With the as
operator, the returned object from the CreateInstance
method is casted into an IConsoleWriter
. If that cast was successful, the local consoleWriter
variable won’t be null, and so its Write
method is called to write the object to the console. The wasWritten
flag is also set to true
. At the end of the method the wasWritten
flag is checked. If it is false
, which means no console writer type was found for the object
parameter, then the object is written to the console by calling just Console.WriteLine
.
void WriteObjectToConsole(object obj)
{
var wasWritten = false;
var attributes = obj.GetType().GetCustomAttributes(
typeof(ConsoleWriterAttribute), inherit: false);
if (attributes.Length == 1
&& attributes[0] is ConsoleWriterAttribute consoleWriterAttribute)
{
var consoleWriterType = consoleWriterAttribute.ConsoleWriterType;
var consoleWriter = Activator.CreateInstance(consoleWriterType)
as IConsoleWriter;
if (consoleWriter is not null)
{
consoleWriter.Write(obj);
wasWritten = true;
}
}
if(!wasWritten)
{
Console.WriteLine(obj);
}
}
Now you don’t have to touch this WriteObjectToConsole
method anymore when you introduce a new type with a console writer type. This is because the mapping between the type and its console writer is done with the ConsoleWriter
attribute. Let’s look at an example.
To introduce for example a Department
class and a DepartmentConsoleWriter
, you write the code that you see in the code snippet below, and everything else will just work.
[ConsoleWriter(typeof(DepartmentConsoleWriter))]
public class Department
{
public string? Name { get; set; }
}
public class DepartmentConsoleWriter : IConsoleWriter
{
public void Write(object obj)
{
var department = (Department)obj;
Console.WriteLine($"Department => Name:{department.Name}");
}
}
Because of the ConsoleWriter
attribute, a DepartmentConsoleWriter
will be used when a Department
instance is passed to the WriteObjectToConsole
method. So you can just use a Department
instance like you see it in the code snippet below. There’s no need to change anything in the WriteObjectToConsole
method.
var person = new Person { FirstName = "Thomas", LastName = "Huber" };
var address = new Address { City = "Frankfurt" };
var department = new Department { Name = "Marketing" };
WriteObjectToConsole(person);
WriteObjectToConsole(address);
WriteObjectToConsole(department);
Console.ReadLine();
Gimme the Code
Before we look at the C# 11.0 version, let’s ensure you can look at all the code we created so far. The following snippet contains the whole code. When you create a new C# console application, you can paste this code into the Program.cs file to run it and to see it in action.
var person = new Person { FirstName = "Thomas", LastName = "Huber" };
var address = new Address { City = "Frankfurt" };
var department = new Department { Name = "Marketing" };
WriteObjectToConsole(person);
WriteObjectToConsole(address);
WriteObjectToConsole(department);
Console.ReadLine();
void WriteObjectToConsole(object obj)
{
var wasWritten = false;
var attributes = obj.GetType().GetCustomAttributes(
typeof(ConsoleWriterAttribute), inherit: false);
if (attributes.Length == 1
&& attributes[0] is ConsoleWriterAttribute consoleWriterAttribute)
{
var consoleWriterType = consoleWriterAttribute.ConsoleWriterType;
var consoleWriter = Activator.CreateInstance(consoleWriterType)
as IConsoleWriter;
if (consoleWriter is not null)
{
consoleWriter.Write(obj);
wasWritten = true;
}
}
if (!wasWritten)
{
Console.WriteLine(obj);
}
}
[ConsoleWriter(typeof(PersonConsoleWriter))]
public class Person
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
}
[ConsoleWriter(typeof(AddressConsoleWriter))]
public class Address
{
public string? City { get; set; }
}
[ConsoleWriter(typeof(DepartmentConsoleWriter))]
public class Department
{
public string? Name { get; set; }
}
public interface IConsoleWriter
{
void Write(object obj);
}
public class PersonConsoleWriter : IConsoleWriter
{
public void Write(object obj)
{
var person = (Person)obj;
Console.WriteLine($"Person => FirstName:{person.FirstName} LastName:{person.LastName}");
}
}
public class AddressConsoleWriter : IConsoleWriter
{
public void Write(object obj)
{
var address = (Address)obj;
Console.WriteLine($"Address => City:{address.City}");
}
}
public class DepartmentConsoleWriter : IConsoleWriter
{
public void Write(object obj)
{
var department = (Department)obj;
Console.WriteLine($"Department => Name:{department.Name}");
}
}
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class ConsoleWriterAttribute : Attribute
{
public ConsoleWriterAttribute(Type consoleWriterType)
{
ConsoleWriterType = consoleWriterType;
}
public Type ConsoleWriterType { get; }
}
The Disadvantage With This Attribute
The ConsoleWriter
attribute can be used with any type, and there’s no way to avoid this at compile time. This means that you can do for example what you see in the following code snippet, and I guess it’s obvious that the type int
is not a valid IConsoleWriter
. But anyway, the code compiles and runs, and in this case the WriteObjectToConsole
method will just call Console.WriteLine
for a Person
instance, as an int
cannot be casted to an IConsoleWriter
.
[ConsoleWriter(typeof(int))]
public class Person
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
}
Now exactly here is where generic attributes are strong, as they allow you to add a type constraint!
Generic Attributes with C# 11.0
With C# 11.0, you can define the ConsoleWriterAttribute
as a generic attribute like in the code snippet below. As you can see, you can use the where
keyword to define a generic type constraint, exactly like in any other generic class. In this case, the type T
must be of type IConsoleWriter
. That means when you use the attribute with any type that is not an IConsoleWriter
, like for example with an int
, then you get a compile error.
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class ConsoleWriterAttribute<T> : Attribute where T : IConsoleWriter { }
You can use this generic attribute on your classes like you see it in the code snippet below.
[ConsoleWriter<PersonConsoleWriter>]
public class Person
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
}
[ConsoleWriter<AddressConsoleWriter>]
public class Address
{
public string? City { get; set; }
}
[ConsoleWriter<DepartmentConsoleWriter>]
public class Department
{
public string? Name { get; set; }
}
The WriteObjectToConsole
method needs to be adjusted a bit. In the code snippet below I highlighted the changed parts. As you can see, the code checks for the generic ConsoleWriterAttribute<>
. If the attribute was found, it calls on the attribute type the GetGenericArguments
method and grabs with the indexer [0]
the first generic argument. As the generic ConsoleWriterAttribute
class is defined as ConsoleWriterAttribute<T>
, there is just one generic type argument (to define the type T
). That generic type argument is the console writer type that is then stored in the consoleWriterType
variable. The rest of the code is the same as before.
void WriteObjectToConsole(object obj)
{
var wasWritten = false;
var attributes = obj.GetType().GetCustomAttributes(
typeof(ConsoleWriterAttribute<>), inherit: false);
if (attributes.Length == 1
&& attributes[0].GetType()
.GetGenericTypeDefinition() == typeof(ConsoleWriterAttribute<>))
{
var consoleWriterType = attributes[0].GetType().GetGenericArguments()[0];
var consoleWriter = Activator.CreateInstance(consoleWriterType)
as IConsoleWriter;
if (consoleWriter is not null)
{
consoleWriter.Write(obj);
wasWritten = true;
}
}
if (!wasWritten)
{
Console.WriteLine(obj);
}
}
In the code snippet below you see all the code for C# 11.0. You can paste this code into the Program.cs file of a C# console application to see it in action.
var person = new Person { FirstName = "Thomas", LastName = "Huber" };
var address = new Address { City = "Frankfurt" };
var department = new Department { Name = "Marketing" };
WriteObjectToConsole(person);
WriteObjectToConsole(address);
WriteObjectToConsole(department);
Console.ReadLine();
void WriteObjectToConsole(object obj)
{
var wasWritten = false;
var attributes = obj.GetType().GetCustomAttributes(
typeof(ConsoleWriterAttribute<>), inherit: false);
if (attributes.Length == 1
&& attributes[0].GetType()
.GetGenericTypeDefinition() == typeof(ConsoleWriterAttribute<>))
{
var consoleWriterType = attributes[0].GetType().GetGenericArguments()[0];
var consoleWriter = Activator.CreateInstance(consoleWriterType)
as IConsoleWriter;
if (consoleWriter is not null)
{
consoleWriter.Write(obj);
wasWritten = true;
}
}
if (!wasWritten)
{
Console.WriteLine(obj);
}
}
[ConsoleWriter<PersonConsoleWriter>]
public class Person
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
}
[ConsoleWriter<AddressConsoleWriter>]
public class Address
{
public string? City { get; set; }
}
[ConsoleWriter<DepartmentConsoleWriter>]
public class Department
{
public string? Name { get; set; }
}
public interface IConsoleWriter
{
void Write(object obj);
}
public class PersonConsoleWriter : IConsoleWriter
{
public void Write(object obj)
{
var person = (Person)obj;
Console.WriteLine($"Person => FirstName:{person.FirstName} LastName:{person.LastName}");
}
}
public class AddressConsoleWriter : IConsoleWriter
{
public void Write(object obj)
{
var address = (Address)obj;
Console.WriteLine($"Address => City:{address.City}");
}
}
public class DepartmentConsoleWriter : IConsoleWriter
{
public void Write(object obj)
{
var department = (Department)obj;
Console.WriteLine($"Department => Name:{department.Name}");
}
}
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class ConsoleWriterAttribute<T> : Attribute where T : IConsoleWriter { }
Limitations of Generic Attributes
There are a few types that are not allowed as type arguments for generic attributes. Here’s the list with a recommended workaround for each:
dynamic
is not allowed => useobject
instead ofdynamic
- Nullable reference types like
string?
are not allowed => usestring
instead ofstring?
- Tuples created with C# tuple syntax
(int x, int y)
are not allowed => useValueTuple<int,int>
instead of the C# tuple syntax
Summary
As you saw in this blog post, generic attributes are a simple but great extension in C# 11.0. With a generic attribute you can add a type constraint to allow only specific types to be used with the attribute.
In the next blog post, you will learn about Generic Math, which is another feature of C# 11.0.
Thanks for reading!
Thomas
Leave a Reply