What's new in C# 12: overview

pvsdev

Anastasiia Vorobeva

Posted on October 23, 2023

What's new in C# 12: overview

It's mid-fall which means a new version of C# is coming soon. It's time to find out what updates will soon appear in the language. Although C#12 has fewer features than previous versions, it still has some curious ones.

Image description

Primary constructors

Here is one of the most notable quality-of-life enhancements — we can create constructor in the class declaration:

class Point(int posX, int posY)
{
    private int X = posX;
    private int Y = posY;

    public bool IsInArea(int minX, int maxX, int minY, int maxY)
        => X <= maxX && X >= minX && Y <= maxY && Y >= minY;
}
// ....
var point = new Point(100, 50);
Console.WriteLine(point.IsInArea(30, 150, 50, 150)); // True
Enter fullscreen mode Exit fullscreen mode

We have to use the constructor — it replaces an empty constructor by default. Moreover, when we add another constructor, we should also add this(....):

class Point(int posX, int posY)
{
    private int X = posX;
    private int Y = posY;
    private Color color;

    public Point(int posX, int posY, Color color) : this(posX, posY)
    {
        this.color = color;
    }

    // ....
}
Enter fullscreen mode Exit fullscreen mode

Hot topic: now, when we use a standard library, the dependency injection syntax may not be so large.

Instead of several reps of the same thing:

public class AuthorizeService
{
    private readonly UserRepository _users;
    private readonly PasswordHasher<User> _hasher;

    public AuthorizeService(UserRepository repository,
                            PasswordHasher<User> hasher)
    {
        _users = repository;
        _hasher = hasher;
    }

    // ....
}
Enter fullscreen mode Exit fullscreen mode

We can make the code more concise:

public class AuthorizeService(UserRepository repository, 
                              PasswordHasher<User> hasher)
{
    private readonly UserRepository _users = repository;
    private readonly PasswordHasher<User> _hasher = hasher;

    // ....
}
Enter fullscreen mode Exit fullscreen mode

Once again, the hustle and bustle goes with this feature. Constructor parameters can be captured not only by fields and properties but by anything at all. So, we can do something like that:

class Point(int posX, int posY)
{
    private int X { get => posX; }
    private int Y { get => posY; }

    // ....
}
Enter fullscreen mode Exit fullscreen mode

Or like that:

class Point(int posX, int posY)
{
    public (int X, int Y) GetPosition()
        => (posX, posY);

    public void Move(int dx, int dy)
    {
        posX += dx;
        posY += dy;
    }

    // ....
}
Enter fullscreen mode Exit fullscreen mode

Or even like that:

class Point(int posX, int posY)
{
    private int X = posX; // CS9124
    private int Y = posY; // CS9124

    public bool IsInArea(int minX, int maxX, int minY, int maxY)
        => posX <= maxX && posX >= minX && posY <= maxY && posY >= minY;
}
Enter fullscreen mode Exit fullscreen mode

Yes, now we can not only inadvertently use a field instead of a property, but we can also use the captured parameter of the constructor instead of a property or field. The compiler will flag such an obvious error as the one above with a warning about parameter capture. Although, we can still use it as a field (but not via the this keyword!):

class Point(int posX, int posY)
{
    public int X { get => posX; }
    public int Y { get => posY; }

    public void Move(int dx, int dy)
    {
        posX += dx;
        posY += dy;
    }

    // ....
}
Enter fullscreen mode Exit fullscreen mode

The analyzer hasn't issued any warnings. It gets quite interesting when we replace class with record (where this syntax came from):

record Point(int posX, int posY)
{
    public int X { get; } = posX;
    public int Y { get; } = posY;

    // ....
}
// ....
var point = new Point(10, 20);
Console.WriteLine(point);
// Point { posX = 10, posY = 20, X = 10, Y = 20 }
Enter fullscreen mode Exit fullscreen mode

With a simple keystroke, we doubled the properties. This error is unlikely to be frequent, but the mere (very) chance of making it is unpleasant.

The first case has a compiler warning, but for the second one, the developer should take responsibility for the error. In this case, more specialized tools, like static code analyzers, will help you prevent an error. For example, PVS-Studio has several hundred diagnostic rules for finding code defects in C#. So, our team certainly study this case.

Overall, the feature seems very useful, but it can easily confuse developers (especially newcomers).

The terse syntax to work with collections

Let's continue the topic: how to enhance a developer's quality of life. Now the syntax to create the collection shouldn't be as cumbersome as before. Let's thank the collection expressions for that:

List<char> empty = [];
List<string> names = ["John", "Mike", "Bill"];
int[] numbers = [1, 2, 3, 4, 5];
Enter fullscreen mode Exit fullscreen mode

If you're experiencing déjà vu — don't worry. Indeed, there has been a very similar syntax with braces earlier, but it worked only with arrays:

char[] characters = { 'a', 'b', 'c' };
List<char> characters = { 'a', 'b', 'c' }; // CS0622
Enter fullscreen mode Exit fullscreen mode

Multidimensional arrays have also been enhanced (only jagged arrays, though):

double[][] jagged = [[1.0, 1.5], [2.0, 2.5], [3.0, 3.5, 4.0]];
Enter fullscreen mode Exit fullscreen mode

The features don't end with the option to drop the clumsy new. The spread operator ".." makes it possible to concatenate collections:

Color[] lightPalette = [Color.Orange, Color.Pink, Color.White];
Color[] darkPalette = [Color.Brown, Color.DarkRed, Color.Black];
Color[] mixedPalette = [.. lightPalette,
                        Color.Grey,
                        .. darkPalette];
Enter fullscreen mode Exit fullscreen mode

You'll have to manually teach your collection to work with this syntax, but it's no big deal. Just add a method, that takes ReadOnlySpan and returns an instance of its own class, then add the CollectionBuilder attribute to the class:

[CollectionBuilder(typeof(IdCache), nameof(Create))]
public class IdCache : IEnumerable<int>
{
    private readonly int[] _cache = new int[50];
    public IEnumerator<int> GetEnumerator()
        => _cache.AsEnumerable().GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator()
        => _cache.GetEnumerator();

    public static IdCache Create(ReadOnlySpan<int> source)
        => new IdCache(source);
    public IdCache(ReadOnlySpan<int> source)
    {
        for (var i = 0; i < Math.Min(_cache.Length, source.Length); i++)
            _cache[i] = source[i];
    }
}
// ....
var john = _userRepository.Get(x => x.UserName == "john");
var oldUsersIds = _userRepository
    .GetMany(x => x.RegistrationDate <= DateTime.Parse("01.01.2020"))
    .Select(x => x.Id);
IdCache cache = [.. oldUsersIds, john.Id];
Enter fullscreen mode Exit fullscreen mode

Anonymous function parameters by default

C# 12 has introduced another small enhancement for the anonymous functions. Now the lambda parameters can have the value by default:

var concat = (double x, double y, char delimiter = ',')
    => string.Join(delimiter, x.ToString(enUsCulture), y.ToString(enUsCulture));

Console.WriteLine(concat(5.42, 3.17)); // 5.42,3.17
Console.WriteLine(concat(1.0, 9.98, ':')); // 1:9.98
Enter fullscreen mode Exit fullscreen mode

In addition, now you can use the params keyword with them:

var buildCsv = (params User[] users) =>
{
    var sb = new StringBuilder();
    foreach (var user in users)
        sb.AppendLine(string.Join(",",
                                  user.FirstName,
                                  user.LastName,
                                  user.Birthday.ToString("dd.MM.yyyy")));
    return sb.ToString();
};

// ....
Console.WriteLine(buildCsv(john, mary));
// John,Doe,15.04.1997
// Mary,Sue,28.07.1995
Enter fullscreen mode Exit fullscreen mode

Alias for any type
Starting with C# 12, you can use using to alias any type without limitations. So, if you want to fool around, now you can do it:

using NullableInt = int?;
using Objects = object[];
using Vector2 = (double X, double Y);
using HappyDebugging = string;
Enter fullscreen mode Exit fullscreen mode

In many cases, using aliases can play a joke on code (if you're not working alone :) ). However, there are definitely some helpful use cases. For example, if we had a mess with tuples like this:

public class Square
{
    // ....
    public (int X, int Y, int Width, int Height) GetBoundaries()
        => new(X, Y, Width, Height);
    public void SetBoundaries(
        (int X, int Y, int Width, int Height) boundaries) { .... }
}
Enter fullscreen mode Exit fullscreen mode

We can enhance this code:

using Boundaries = (int X, int Y, int Width, int Height);
// ....
public class Square
{
    // ....
    public Boundaries GetBoundaries()
        => new (X, Y, Width, Height);
    public void SetBoundaries(Boundaries boundaries) { .... }
}
Enter fullscreen mode Exit fullscreen mode

Though, such tuples make us think whether they're needed or not. However, under certain circumstances (or when we refactor the code), these tuples can enhance readability.

Please don't get carried away. If we use the recently added global modifier, we can make the using directive global. This makes it easier to fill everything with tuples (instead of classic data structures).

I can't immediately come up with a case where we can use the static analyzer. This means that potential errors will show up later, be subtler, and be harder to find, because there is a problem is in the approach. If you find something interesting, feel free to send code snippets to our team.

The nameof refinement

Now the nameof expression can fully capture the instance class members from static methods, initializers, and attributes. There has been a strange limitation before: for example, it allows us to get the name of the class field itself but not its members:

public class User
{
    [Description($"Address format is {
        nameof(UserAddress.Street)} {nameof(UserAddress.Building)}")] // CS0120
    Address UserAddress { get; set; }
    // ....
}
Enter fullscreen mode Exit fullscreen mode

Now there is no such problem, and we can use nameof in all the previously mentioned contexts:

public class User
{
    [Description($"Address format is {
        nameof(UserAddress.Street)} {nameof(UserAddress.Building)}")]
    Address UserAddress { get; set; }

    public string AddressFormat { get; } =
           $"{nameof(UserAddress.Street)} {nameof(UserAddress.Building)}"; }

    public static string GetAddressFormat()
        => $"{nameof(UserAddress.Street)} {nameof(UserAddress.Building)}";
}
Enter fullscreen mode Exit fullscreen mode

Inline arrays

Let's continue with niche features —useful not to everyone, but still bringing changes to the language. In this case, we're talking about fixed-size arrays that are placed on the stack in a contiguous memory location. We expect to need them mainly for the AOT compiler and for developers who need to write truly high-performance code. To create such an array, you will need some magic. We need to declare a structure that has a single field that defines the array type. Then, mark it with the InlineArray attribute, which specifies the array size.

Here's what it looks like:

[System.Runtime.CompilerServices.InlineArray(5)]
public struct IntBuffer
{
    private int _element0;
}
// ....
var buf = new IntBuffer();
for (var i = 0; i < 5; i++)
    buf[i] = i;

foreach (var e in buf)
    Console.Write(e); // 01234
Enter fullscreen mode Exit fullscreen mode

The code interception

The following feature enables us to intercept calls to methods and change their behavior. This feature is available in preview mode with C# 12. The new syntax is appropriate for source generators, so don't be surprised by its clunkiness:

var worker = new Worker();
worker.Run("hello"); // Worker says: hello
worker.Run("hello"); // Interceptor 1 says: hello
worker.Run("hello"); // Interceptor 2 says: hello
// ....
class Worker
{
    public void Run(string phrase)
        => Console.WriteLine($"Worker says: {phrase}");
}

static class Generated
{
    [InterceptsLocation("Program.cs", line: 3, character: 7)]
    public static void Intercept1(this Worker worker, string phrase)
        => Console.WriteLine($"Interceptor 1 says: {phrase}");

    [InterceptsLocation("Program.cs", line: 4, character: 7)]
    public static void Intercept2(this Worker worker, string phrase)
        => Console.WriteLine($"Interceptor 2 says: {phrase}");
}
Enter fullscreen mode Exit fullscreen mode

We intercept by specifying the InterceptsLocation attribute. It should contain the file name, the string positions, and the character on which the method is called.

While there's a benefit to the AOT compiler as well, the focus is on code generation. For example, we can dream of libraries that make working with aspect-oriented programming easier. Unit testing frameworks sound even more tempting — finally, we can stop creating an interface for every class just to mock it in tests. Anyway, the community is very engaged in discussing this topic, which is very pleasant for me.

In any case, code generators have become an incredibly powerful tool, so the extension of their functionality is wonderful news.

Conclusion

Although at first glance the list of features doesn't seem huge (especially if we compare it with previous releases), I'm interested in almost all of them, even if sometimes I have some concerns :). To be honest, I haven't realized all the changes of the last years, and thoughtfully applied them in real-life circumstances. What do you think about previous updates? Let's discuss them along with the new C# 12.

You can learn more about the feature specification in the documentation. If you wish to read about the features of the previous C# versions, you can read our previous overview articles here:

💖 💪 🙅 🚩
pvsdev
Anastasiia Vorobeva

Posted on October 23, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related