Collection expressions and initializers
It has been more than a year Microsoft released C# 12. With this version came a bunch of really nice things, that include collection expressions. You know, instead of writing ICollection<int> x = new Collection<int> { 1, 3, 8 }
, you can now write ICollection<int> x = [1, 3, 8]
, nice and sweet.
And sweet it is, until you start finding things that may not be immediately intuitive. For instance, I was at some moment puzzled at the IL code I spotted when checking what exactly collection expressions get translated to. With the help of several Stack Overflow users, it made everything clear. But I could have spent a long time trying to guess it myself.
Now, such nice new language features have a drawback: once you start using them, you just can't stop. When I was writing this article, I had at some point to move a tiny little piece of code from C# 13 back to C# 3—that was a rather unpleasant experience to be deprived of all the good things, and to have to write a lot of extra code. As I got an habit of relying a bit too much on collection expressions, I found myself staring at the following piece of code:
var line = new Line
{
Flagged = new Dictionary<int, bool> { [-1] = false, [1] = true, [2] = true },
MarkHints = [-1, 1],
};
As Flagged
is of type IDictionary<int, bool>
, there is no way for me to remove that ugly type wasting all this space in the middle of the initialization. Or is it?
I decided to try a little change.
var line = new Line
{
Flagged = { [-1] = false, [1] = true, [2] = true },
MarkHints = [-1, 1],
};
To my surprise, it compiled. And ran. But it only worked by chance, as it was not doing what I was thinking it is doing.
Down the rabbit hole
Let's reproduce this thing through a more complete but short example. Something that anyone could run by simply doing dotnet new console
.
using System;
using System.Collections.Generic;
var r = new R() { D = { [2] = 4 } };
foreach (var x in r.D)
{
Console.WriteLine($"{x.Key}: {x.Value}");
}
class R
{
public Dictionary<int, int> D { get; set; } = [];
}
Don't mind the foreach
block: it just displays the values from the dictionary, and, in this case, shows 2: 4
. What matters is that the class contains a dictionary property, and that when the instance of this class is initialized, it looks like the dictionary is being initialized as well.
Except that this is not what the code actually does. Here's the interesting part of IL that corresponds to the initialization of r
. I cheated a bit and removed fully qualified types to make the IL a bit shorter.
IL_0000: newobj instance void R::.ctor()
IL_0005: dup
IL_0006: callvirt instance class [...]Dictionary`2<int32, int32> R::get_D()
IL_000b: ldc.i4.2
IL_000c: ldc.i4.4
IL_000d: callvirt instance void class [...]Dictionary`2<int32, int32>::set_Item(!0, !1)
IL_0012: nop
IL_0013: stloc.0
See? It doesn't set the property D
. At no point does it call the setter. What it calls, however, is the getter of D
! And then it manipulates the dictionary that is being returned by the getter.
There is a way to check it even without looking at IL. If I replace:
public Dictionary<int, int> D { get; set; } = [];
by:
public Dictionary<int, int> D { get; set; }
without the default value, the application would crash with a null reference exception. What's even funnier is when I try to fill the initial dictionary with values.
public Dictionary<int, int> D { get; set; } = new() { [1] = 6, [2] = 5 };
The output would be:
1: 6
2: 4
So instead of being a sort of dictionary expression similar to a collection expression, this thing is something else. But what?
Back to year 2007
I thought that it should be some relatively new language feature that I was unaware of, so I tried to get back in time, trying to lower the version of C#, until it would say that this syntax is not valid any longer. I was pretty sure I would find it around C# 10, but the voyage took much, much longer.
Eighteen years ago, C# 3 was released. As any respectable version of this language, C# 3 brought its own dose of loveliness, including object and collection initializers. You know, that thing that we take for granted, which makes it possible to write new Point { X = 5, Y = 10 }
or to do new Collection<int> { 7, 9 }
—that didn't always exist.
And it's exactly this language feature that appears in my original piece of code. Let's illustrate it with a simple object:
using System;
var r = new R() { P = { Y = 6 } };
Console.WriteLine($"{r.P.X}, {r.P.Y}");
class R
{
public Point P { get; set; } = new() { X = 8, Y = 3 };
}
class Point
{
public int X { get; set; }
public int Y { get; set; }
}
Here, an object initializer does exactly the same thing: it grabs the existing object by calling the getter P
, and then mutates it. This piece of code outputs 8, 6
—8 being the value that was unchanged, and 6 being the one that was reassigned. The documentation is very clear (well, maybe not very, but still) about that (emphasis mine):
This syntax,
Property = { ... }
, allows you to initialize members of existing nested objects
The documentation continues by pointing to a dedicated section that—this time it's true—is absolutely clear about the difference between object initializer with new()
, and one without it.
Naturally, the same works with collection initializers:
using System;
using System.Collections.ObjectModel;
using System.Linq;
var r = new R() { C = { 9 } };
Console.WriteLine(string.Join(", ", r.C.Select(x => x.ToString())));
class R
{
public Collection<int> C { get; set; } = [5, 5];
}
would output 5, 5, 9
. An existing collection is not replaced—rather, a new value is appended to it.