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 => use object instead of dynamic
  • Nullable reference types like string? are not allowed => use string instead of string?
  • Tuples created with C# tuple syntax (int x, int y) are not allowed => use ValueTuple<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

Share this post

Leave a Reply

Your email address will not be published. Required fields are marked *

*

This site uses Akismet to reduce spam. Learn how your comment data is processed.