DEV Community

Cover image for What's new in C# 11: overview
Unicorn Developer
Unicorn Developer

Posted on • Updated on • Originally published at pvs-studio.com

What's new in C# 11: overview

C# 11 is coming, so we're going to explore its new features in detail. You may find these new features pretty curious even though there are not that many of them. Today let's take a closer look at the generic math support, raw string literals, the required modifier, the type parameters in attributes, and more.

Image description

Generic attributes

C# 11 added support for generic attributes — now we can declare them similarly to generic classes and methods. Even though we have been able to pass a type as a parameter in a constructor before, we now can now use the where constraint to specify what types should be passed. Now we also don't have to use the typeof operator all the time.

This is how it works on the example of a simple implementation of the "decorator" pattern. Let's define the generic attribute:

[AttributeUsage(AttributeTargets.Class)]
public class DecorateAttribute<T> : Attribute where T : class
{
    public Type DecoratorType{ get; set; }
    public DecorateAttribute()
    {
        DecoratorType = typeof(T);
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, we implement a hierarchy (according to the pattern) and a factory to create decorated objects. Pay attention to the Decorate attribute:

public interface IWorker
{
    public void Action();
}
public class LoggerDecorator : IWorker
{
    private IWorker _worker;
    public LoggerDecorator(IWorker worker)
    {
        _worker = worker;
    }
    public void Action()
    {
        Console.WriteLine("Log before");
        _worker.Action();
        Console.WriteLine("Log after");
    }
}
[Decorate<LoggerDecorator>]
public class SimpleWorker : IWorker
{
    public void Action()
    {
        Console.WriteLine("Working..");
    }
}

public static class WorkerFactory
{
    public static IWorker CreateWorker()
    {
        IWorker worker = new SimpleWorker();

        if (typeof(SimpleWorker)
            .GetCustomAttribute<DecorateAttribute<LoggerDecorator>>() != null)
        {
            worker = new LoggerDecorator(worker);
        }

        return worker;
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's see how it works:

var worker = WorkerFactory.CreateWorker();

worker.Action();
// Log before
// Working..
// Log after
Enter fullscreen mode Exit fullscreen mode

Please note that the limitations have not been fully removed, the type should be specified. For example, you can't use the type parameter of the class:

public class GenericAttribute<T> : Attribute { }
public class GenericClass<T>
{
    [GenericAttribute<T>]
    //Error CS8968 'T': an attribute type argument cannot use type parameters
    public void Action()
    {
        // ....
    }
}
Enter fullscreen mode Exit fullscreen mode

You may ask, are GenericAttribute and GenericAttribute different attributes, or just multiple uses of the same one? Microsoft determined them to be the same attribute. This means, to use the attribute multiple times, you need to set the AllowMultiple property to true. Let's modify the above example:

[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class DecorateAttribute<T> : Attribute where T : class
{
    // ....
}
Enter fullscreen mode Exit fullscreen mode

Now the object can be decorated several times:

[Decorate<LoggerDecorator>]
[Decorate<TimerDecorator>]
public class SimpleWorker : IWorker
{
    // ....
}
Enter fullscreen mode Exit fullscreen mode

Obviously, this new feature is not a fundamental one, but library developers can now create a more user-friendly interface when working with generics in attributes.

Generic math support

In the new version of the C# language, we can use mathematical operations on generic types.

This new feature results in two general consequences:

  • A static member of an interface now can have the abstract modifier that requires the derivatives to implement a corresponding static method;
  • That's why we can now declare arithmetic operators in interfaces.

Now we got a static abstract construct that may seem a bit weird, but I wouldn't call it completely senseless. Let's inspect the actual result of this update. Take a look at this slightly contrived example of the natural number implementation (the number can be added up and parsed from a string):

public interface IAddable<TLeft, TRight, TResult>
    where TLeft : IAddable<TLeft, TRight, TResult>
{
    static abstract TResult operator +(TLeft left, TRight right);
}
public interface IParsable<T> where T : IParsable<T>
{
    static abstract T Parse(string s);
}
public record Natural : IAddable<Natural, Natural, Natural>, IParsable<Natural>
{
    public int Value { get; init; } = 0;
    public static Natural Parse(string s)
    {
        return new() { Value = int.Parse(s) };
    }
    public static Natural operator +(Natural left, Natural right)
    {
        return new() { Value = left.Value + right.Value };
    }
}
Enter fullscreen mode Exit fullscreen mode

We can use the specified operations the following way:

var one = new Natural { Value = 1 };
var two = new Natural { Value = 2 };
var three = one + two;
Console.WriteLine(three);
// Natural { Value = 3 }

var parsed = Natural.Parse("42");
Console.WriteLine(parsed);
// Natural { Value = 42 }
Enter fullscreen mode Exit fullscreen mode

The above example shows that generic interfaces for static abstract methods' deriving should be done through the curiously recurring template pattern of the Natural type*: IParsable. You have to be careful not to mix up the type parameter — *Natural : IParsable.

You can clearly see how static methods work with type parameters in the following example:

public IEnumerable<T> ParseCsvRow<T>(string content) where T : IParsable<T>
    => content.Split(',').Select(T.Parse);
// ....
var row = ParseCsvRow<Natural>("1,5,2");
Console.WriteLine(string.Join(' ', row.Select(x => x.Value)));
// 1 5 2
Enter fullscreen mode Exit fullscreen mode

We couldn't use generics this way before, since, in order to call a static method, we had to specify a type explicitly. Now the method can work with any derivative of IParsable.

Obviously, we can use generics not only to implement math from scratch. The operating mechanism of basic types has also been modified: 20 of basic types implement interfaces corresponding to basic operations. You can read about this in the documentation.

Library support facilitation is described as the main use case of generics. With generics, we can get rid of overloading the same methods to work with all possible data types. For example, here's how you can use the the INumber standard interface to implement an algorithm for summing up numbers from a collection:

static T Sum<T>(IEnumerable<T> values)
    where T : INumber<T>
{
    T result = T.Zero;
    foreach (var value in values)
    {
        result += T.CreateChecked(value);
    }
    return result;
}
Enter fullscreen mode Exit fullscreen mode

The method works with both natural and real numbers:

Console.WriteLine(Sum(new int[] { 1, 2, 3, 4, 5 }));
// 15

Console.WriteLine(Sum(new double[] { 0.5, 2.5, 3.0, 4.3, 3.2 }));
// 13.5
Enter fullscreen mode Exit fullscreen mode

And that's not all! The unsigned bitwise shift has also changed. I'm sure that an entire article could be written on this topic. Until then, if you are curious, take a look at the documentation.

Let's sum up all this a little. In fact, not so many people will use these new generics directly, as not everyone develops libraries or performs other tasks in some way related to this feature. For most projects, the static abstract construct may be too peculiar. Nevertheless, the new feature can make our lives better another way – the library developers now will need to spend fewer resources on supporting countless overloads.

Raw string literals

C# 11 allows us create raw string literals by repeatedly using quote marks (three or more times). This works similarly to the verbatim identifier (@), except for two important distinctions:

  • When a string is split into several lines, any whitespace preceding the closing quotes is removed from the resulting string;
  • Quote marks (and braces when interpolating) can now be interpreted as-is.

Let's take this method where a json string is formed:

string GetJsonForecast(DateTime date, int temperature, string summary)
    => $$"""
    {
        "Date": "{{date.ToString("yyyy-MM-dd")}}", 
        "TemperatureCelsius": "{{temperature}}",
        "Summary": "{{summary}}",
        "Note": ""
    }
    """;
Enter fullscreen mode Exit fullscreen mode

The method call returns a usual json string:

{
    "Date": "2022-09-16",
    "TemperatureCelsius": 10,
    "Summary": "Windy",
    "Note": ""
}
Enter fullscreen mode Exit fullscreen mode

The main differences between raw string literals and the verbatim identifier (@) are clear: the raw literal is taken "literally" with quotes and braces without any extra whitespace to the left. The syntax rules for "raw" strings are as follows:

  • The string should start with 3 or more quotes. Thus, if we need to place 3 quotes at the in a row in the literal, then we need to start and end the string with 4 characters, and so on;
  • The interpolation works in a similar way, but starting from a single character. In the above example, two interpolation symbols are used, so that braces could be written to describe the json structure;
  • The raw string can be single-line. Then it must contain at least one character between the quotes;
  • The raw string can be made multi-line. In this case, the opening and closing quotes should be placed in separate lines (no text can be added to them). Also, text indents from the edge of the screen cannot be less than indents of the closing quotes.

Oddly enough, but it seems to be one of the more significant new features. Now we can make multi-line literals without being afraid either for formatting the resulting string or for the cleanness of code. Only one thing remains unclear: why do we still have to use a traditional verbatim identifier when working with text?

Newlines in string interpolations

Another new feature that helps working with strings. Now the expression inside the interpolation can be moved to a new line:

Console.WriteLine(
    $"Roles in {department.Name} department are: {
        string.Join(", ", department.Employees
                                    .Where(x => x.Active)
                                    .Select(x => x.Role)
                                    .OrderBy(x => x)
                                    .Distinct())}");

// Roles in Security department are: Administrator, Manager, PowerUser
Enter fullscreen mode Exit fullscreen mode

That's the good news for those who have to place a long query of operators (such as Linq) inside the interpolation. But don't get too caught up in line breaks, now it's also easier to mess it all up:

Console.WriteLine(
    $"Employee count is {
        department.Employees
                  .Where(x => x.Active)
                  .Count()} in the {department.Name} department");

// Employee count is 20 in the Security department
Enter fullscreen mode Exit fullscreen mode

The required modifier

You can add the required modifier to indicate that fields or properties must be initialized inside a constructor or initializer. There are two main reasons for this update.

Firstly, when we work with big class hierarchies, boilerplate code may eventually accumulate. It accumulates because large amount of data is passed to base constructors. Let's take a peek at a typical example with shapes:

class Point
{
    public int X { get; }
    public int Y { get; }
    public Point(int x, int y) 
    { 
        X = x;
        Y = y;
    }
}
// ....
class Textbox : Rectangle
{
    public string Text { get; }
    public Textbox(int x, int y, int width, int height, string text) 
        : base(x, y, width, height)
    {
        Text = text;
    }
}
Enter fullscreen mode Exit fullscreen mode

We have omitted some intermediate steps for the sake of brevity, but the problem is still clear: the number of parameters has more than doubled. The example is contrived, but in domain the situation may be even worse (especially if it's complicated by poor architecture).

However, if we make a constructor without parameters and add the required modifier to the X and Y properties, then it changes who is to initialize the type from the developer to its user. Let's modify the above example:

class Point
{
    public required int X { get; set; }
    public required int Y { get; set; }
}
// ....
class Textbox : Rectangle
{
    public required string Text { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

The guarantee of initialization is the very fact of derivation. Now the client is responsible for the initialization:

var textbox = new Textbox() { Text = "button" };
// Error CS9035: Required member 'Point.X' be set in the object initializer
// or attribute constructor
// Error CS9035: Required member 'Point.Y' must be set in the object initializer
// or attribute constructor
// ....
Enter fullscreen mode Exit fullscreen mode

Secondly, it may come in handy when using ORM that require constructors without parameters. After all, you couldn't previously oblige a client to initialize a field with Id.

You can find more details on it in the documentation, but here are the most interesting ones:

  • If there's a guarantee that all the necessary class members are initialized in the constructor, you can apply the SetsRequiredMembers attribute to it, and then it's not necessary to use the initializer;
  • If the constructor refers to another one marked with this attribute (via this() or base()), then this constructor should also have this attribute;
  • Even if initialization is required, it can still be performed by assigning null;

Although this feature seems useful, there are still some moot points. First of all, the simultaneous presence of the required modifier and the Required attribute (they perform different tasks) may be confusing for newcomers. Then, we can easily make a mistake when using the SetsRequiredMembers attribute for constructors, since no one can assure that a developer didn't forget to initialize something in the constructor when adding this attribute. For example, if we forget about the parent mandatory constructor (and if the parent happens to have an empty constructor too), then we may write the following code:

class Textbox : Rectangle
{
    public required string Text { get; set; }
    [SetsRequiredMembers]
    public Textbox(string text)
    {
        Text = text;
    }
    public override string ToString()
    {
        return 
            $"{{ X = {X}, Y = {Y}, W = {Width}, H = {Height}, Text = {Text}} }";
    }
}
Enter fullscreen mode Exit fullscreen mode

Ta-da! It compiles! And even works:

var textbox = new Textbox("Lorem ipsum dolor sit.");
Console.WriteLine(textbox);
// { X = 0, Y = 0, Width = 0, Height = 0, Text = Lorem ipsum dolor sit. }
Enter fullscreen mode Exit fullscreen mode

If there are some reference types among the properties, then a warning about a potential null value is issued. But in this case, it's not. The properties are just initialized with default values. And that's not the only scenario – we may forget to add a field to the constructor in the class in which the field was declared. Anyway, I think the feature is a potential pitfall.

Auto-default struct

Another feature related to initialization. Now we don't have to initialize all structure members in structure constructors. Just as in the case of classes, they are now initialized with default values when a structure instance is created:

struct Particle
{
    public int X { get; set; }
    public int Y { get; set; }
    public double Angle { get; set; }
    public int Speed { get; set; }

    public Particle(int x, int y) 
    {
        X = x;
        Y = y;
    }
    public override string ToString()
    {
        return 
            $"{{ X = {X}, Y = {Y}, Angle = {Angle}, Speed = {Speed} }}";
    }
}

// ....

var particle = new Particle();
Console.WriteLine(particle);
// { X = 0, Y = 0, Angle = 0, Speed = 0 }
Enter fullscreen mode Exit fullscreen mode

Sure, this feature is not a major one, but it was actually needed to improve the internal mechanisms of the language (for example, to implement semi-auto properties in the future).

List patterns

One more enhancement of pattern matching. Now you can use these features for lists or arrays:

  • You can use any pattern for single elements to check whether they match certain conditions;
  • The discard pattern (_) matches a single element in the collection.
  • The range pattern (..) can match a number of elements from zero or more. It can only be used once;
  • You can use the var pattern to capture one or more elements of a collection (using the range pattern). The type can be specified explicitly.

We can use all these new features to check the collection using the is operator:

var collection = new int[] { 0, 2, 10, 5, 4 };
if (collection is [.., > 5, _, var even] && even % 2 == 0)
{
    Console.WriteLine(even);
    // 4
}
Enter fullscreen mode Exit fullscreen mode

In this case, we checked whether the third element is greater than 5, and whether the fifth element is an even number.

Obviously, list patterns can also be used in switch expressions. Capturing the elements from switch expressions is even more fascinating. For example, now you can have fun and write a palindrome check function in the following way:

bool IsPalindrome (string str) => str switch
{
    [] => true,
    [_] => true,
    [char first, .. string middle, char last]
         => first == last ? IsPalindrome (middle) : false
};

// ....

Console.WriteLine(IsPalindrome("civic"));
// True
Console.WriteLine(IsPalindrome("civil"));
// False
Enter fullscreen mode Exit fullscreen mode

Unfortunately, two-dimensional arrays are not supported in this update.

I'm not sure if this new feature could be commonly used, but it is quite curious, at least.

Extended nameof scope

The nameof operator has been slightly enhanced, it means that it can capture the name of a parameter in an attribute on the method or parameter declaration:

[ScopedParameter(nameof(parameter))]
void Method(string parameter)
// ....
[ScopedParameter(nameof(T))]
void Method<T>()
// ....
void Method([ScopedParameter(nameof(parameter))] int parameter)
Enter fullscreen mode Exit fullscreen mode

Actually, this new feature is quite useful for nullable analysis. Now we don't need to rely on strings when eliminating warnings.

[return: NotNullIfNotNull(nameof(path))]
public string? GetEndpoint(string? path)
    => !string.IsNullOrEmpty(path) ? BaseUrl + path : null;
Enter fullscreen mode Exit fullscreen mode

Unnecessary null value warnings are not issued when we use the result of calling the method:

var url = GetEndpoint("api/monitoring");
var data = await RequestData(url);
Enter fullscreen mode Exit fullscreen mode

The file access modifier

Another keyword was added. Now we can create a type whose visibility is scoped to the source file in which it is declared. The new feature meets the needs of code generation to avoid naming collisions.

Now, if you declare classes in different files (but in the same namespace), add modifier file to the first one:

// Generated.cs
file class Canvas
{
    public void Render()
    {
        // ....
    }
}
Enter fullscreen mode Exit fullscreen mode

And declare the second as usual:

// Canvas.cs
public class Canvas
{
    public void Draw()
    {
        // ....
    }
}
Enter fullscreen mode Exit fullscreen mode

Then no conflicts arise. Obviously, nobody limits to use this feature, so it is now possible to make some kind of private classes but without making them nested. If you are wondering why not simply allow to use the private modifier, it's not that difficult. Since the visibility is scoped to the source file (and not to the namespace), then this decision eliminates possible confusion.

Minor changes

C# 11 contains a few other minor changes, which I still should mention:

  • Classes with lower-case names now trigger warnings. This way, we are protected from incompatibility in case any new keywords are added in future C# releases;
  • The nint and nuint types now alias System.IntPtr and System.UIntPtr, respectively;
  • Pattern matching is now available for the Span and ReadOnlySpan types;
  • Method group conversion is now allowed to use an existing delegate instance that already contains the required references;
  • String literals are now can be encoded in UTF-8. We need to specify the u8 suffix on a string literal to specify it. UTF-8 literals are stored as ReadOnlySpan objects.

Conclusion

That's what C# 11 is like. Obviously, not everyone is equally excited about the new features of C# 11. Some of them are overly specific, others are not that significant, while some will probably provide a basis for further language development. However, the new C# release surely brought some useful features. In my opinion, these include the raw string literals and the required initialization. Already know which feature you prefer?

You can learn more about C# 11 in Microsoft documentation.

Have you already read about the features of the previous C# versions? You can read our articles about C# 9 and C# 10 here:

Top comments (1)

Collapse
 
auvansang profile image
Sang Au

Wow, I like the idea of decorate