Next month, another version of our beloved programming language is set to arrive. Nice reason to grab a cup of coffee and dwell on what is wrong with modern versions of C#, isn't it?
We published and translated this article with the copyright holder's permission. The author is Maxim Dobroselsky (melanchall).
Translator's note: The original article was written before the release of .NET 10.
Introduction
Back in my student years, I had a book by a rather famous Dane about a rather famous programming language. And although I harbored genuine warm feelings for C++, I couldn't get through even half of that book—the reading was that dreary. Well, it didn't stop me from continuing to believe in the destiny fate had in store for me and C++. So, in the summer of 2011, I went to interview for a C++ developer.
This is what I got from the interview: I didn't know C++. But I was offered a chance to try my hand at C#. My pride was wounded, but I agreed: I was eager to start working.
The test task was to write a "Snake" game. To make it more complex, the field was a map of Russia, movement was restricted to its borders, and the snake could only grow by eating major cities. The game was supposed to run in a browser using the now-departed Silverlight.
I wrote the application, it was approved, and I was hired for my first job, while C++ remained in the past. This is how, by the will of fate, I became a C# developer, grew to love this language, and remain inseparable from it to this day. Unfortunately, I sometimes get the feeling that not everything is quite right with it.
Is there a problem?
Sooner or later, any programmer fascinated by C++ concludes: "No one knows this language completely." Even though my path with C++ diverged many years ago, this thought has started resurfacing in recent years. Though in a slightly changed form: instead of ++, I now see #. Of course, C# is still very far from the complexity of Bjarne Stroustrup's creation. However, many innovations are beginning to raise concerns about the C# former elegance.
But before we start carefully dissecting syntactic constructs on the laboratory table, let me address a perfectly fair remark: "No one is forcing you to use new features." That is, there is no actual problem, so why start this whole discussion? The interest here is rather personal.
As a developer of the .NET library, I sometimes face user requests for the API extension. Sometimes I see potential in someone else's code for new functions or reworking old ones. But are these changes justified? How much will the API complexity increase? Is solving certain tasks with the library's current design really that inconvenient?
Balancing between concise contracts and solving real user problems with simplicity is a constant tightrope walk. And I'm very curious how the team at Microsoft handles this balancing act while developing a tool I use every day.
Of course, any programming language must evolve. And let's be honest, I myself use many of new features. But was it really that difficult and inconvenient without them? Even though my criticisms may sound ultra-conservative, like "Back in my day, bytes were more eight-bit," I'd like to share my thoughts and discuss them with you.
The starting point
According to the official chronicle, C# has had 17 revisions up to the time of writing this article. It's interesting to note that the first seven versions (up to and including the sixth) span a period of 13 years, while the remaining ten were released in just 8 years:
It's worth noting that, unlike the seemingly chaotic early release schedule, nowadays we can expect new language versions every November, as they are tied to .NET releases:
The explosive growth of features in C# began roughly between the seventh and eighth versions. Here's a graph showing the increase in the number of keywords:
How I collected data
- The source code for the C# Keywords page from the official documentation is in the file docs/docs/csharp/language-reference/keywords/index.md. So, I used commits to this file and compiled lists of keywords.
- But commits only go back to November 2016. Data on the list of keywords before this date was collected manually from MSDN archives.
- At certain points in time, the same keywords were counted differently depending on context—for example,
partialwhen declaring classes and methods. I didn't make this distinction during data collection.
We can also look at the contents of the Syntax.xml file in the dotnet/roslyn repository. It's used to generate the Roslyn compiler and contains all syntax rules for C#. Let's start counting from the Visual Studio 2015 RTM release, July 2015, when Roslyn became the default compiler in VS—it started supporting all language features. This is how the number of nodes in that file has changed over the years:
How I collected data
- I used commits of the Syntax.xml file. The file was moved/renamed twice in its history; changes were tracked back to the first version of the source document.
For each commit, the number of child nodes of the Tree element was collected.
In addition to Microsoft actively developing the language itself, the company also made it possible to propose features and vote for them in the corresponding GitHub repository. So many rushed to create requests describing their professional pain and ways to solve it through new language constructs. I confess, I also have such proposals to my name (first and second). And I'm very glad they were rejected.
If you go to the proposals directory, the subdirectories start from the sixth version. But that directory only contains three small files. Probably, these were the internal wishes of the language development team. However, in the directory for the seventh version, we already find documents with links to discussions. Here we arrive at the starting point when debatable new C# features began to appear. Well, it's just my completely subjective and non-authoritative point of view.
Another curious point worth noting is an ECMA-334 standard, describing full specification of C#. At the time of writing, the last published version is the seventh. And it doesn't contain, for example, the keyword and. This keyword was introduced back in C# 9 four versions ago. Moreover, in the official repository where the standard is developed, there are two branches: draft-v8 and draft-v9. Even in the draft of the ninth version, the language grammar description file lacks and. So, the official specification and even its new edition drafts can't keep up with the actual development of C#.
When you just can't stop
Let's look at local functions, added in C# 7.0. They look like ordinary methods, just without access modifiers. So, perhaps we can make them static? Starting from version eight, we can, says Microsoft. Also, method signatures allow the use of attributes on parameters or return values. How about that for local functions? Here's C# 9—wish granted, answers Microsoft.
But the language developers went further. In their opinion, namespaces and the Main method scare beginners so much that they run off to learn Python (unverified information). So, in the same ninth version, it became possible to discard these horrors. Top-level statements allowed writing code as if we were inside the Main method existing somewhere out there. And that's true, as the feature is nothing more than syntactic sugar. With this approach, these are local functions introduced earlier that help declare methods.
By the way, lambda functions are very similar to local ones. They have some inconveniences compared to the latter, but still. Local functions can declare optional parameters and use attributes, so why can't lambda functions do that? C# versions 10 and 12 fix these omissions.
Now let's move on to the ref keyword, which allows passing an object to a method by reference. Again, C# 7.0 expands the horizons of what's allowed. Now we can declare variables with ref—creating an alias for another variable—and even return a value by reference. Then comes more: ref readonly, ref struct, ref-fields, scoped ref. We also have to teach the compiler not to complain about using these ref structs in generics or attempts to implement interfaces with such structures.
An extensive layer of functionality relates to records. Initially, the record keyword allowed quickly declaring a class with a set of properties, with all the boilerplate like Equals and ToString generated by the compiler. But what if you want to use a value type, not a reference type? Meet record struct. This combination of keywords (and also record class as an alias for record) appeared in C# 10 and brought with it coupling with the readonly modifier, applicable to ordinary structures since C# 7.2. As a result, we can now declare records in these ways:
record A();
record class B();
record struct C();
readonly record struct D();
On dev.to, there is an article that includes code like this:
public ref readonly record struct Point(double X, double Y);
This seems to be AI-made, as such a construct won't compile. Although its existence would fit perfectly into the trend shown above: readonly ref struct does exist, after all.
At the same time, we can add methods and properties to records, override them, etc. In other words, they are very similar to ordinary classes and structures. It's just that the latter don't support that wonderful syntax where the type name is combined with the constructor. Here, C# history leads us to the concept of primary constructors, which appeared in the 12th version of the language. By the way, this feature is among the leaders in the number of negative reactions—186 votes "for" and 96 "against."
How I counted votes
- I took the documents with new feature proposals from the proposals folder in the dotnet/csharplang repository (files in subfolders csharp-).
- Inside the files, I found links to champion issues with the discussions.
- In each champion proposal, I counted user reactions (+1, heart, and hooray are votes "for"; -1 are votes "against"). Of course, if one person put, for example, both +1 and heart, only one "for" vote was added.
- Based on reactions for each issue, I calculated two parameters. The first is a like ratio—the ratio of positive votes to the total number of votes. The second is a dislike ratio—the ratio of negative votes to the total number of votes. After sorting the proposals, I got two lists of each parameter's value in the descending order.
- Also, I created two sets with proposals ordered by the absolute number of "for" and "against" votes.
- After comparing the lists with each other, I got leaders and outsiders among all features.
The method isn't very precise, but I couldn't think of another one. Moreover, it's unknown what other emojis people might use and what meaning they ascribe to them. Anyway, these are minor details that don't significantly change positions in the rankings.
Anyway all of these are examples of useful innovations. I myself occasionally use local functions and records. It's just that I couldn't warm up to top-level statements. It seems I'm not the only one, considering that Microsoft even added a checkbox _"Do not use top-level statement_s" when creating a new project in Visual Studio.
When we need more keywords
An interesting story happened with native sized integers. Introduced in C# 9, the keywords nint and nuint were "enhanced" versions of IntPtr and UIntPtr. The enhancement implied supporting arithmetic and implicit conversions between numeric types. In other words, this code wouldn't compile:
IntPtr a = 2;
IntPtr b = 3;
var c = a + b;
And neither would this code:
IntPtr a = (IntPtr)2;
IntPtr b = (IntPtr)3;
var c = a + b;
But with nint the code would compile with no problem:
nint a = 2;
nint b = 3;
var c = a + b;
However, in C# 11, Microsoft decided to add all these features to the long-standing IntPtr and UIntPtr. And if there's no difference, then let nint and nuint be just aliases for the old types. So, we have two keywords that add no value.
Fun fact: they look like ordinary aliases for simple types (like int or byte), but we can't declare a variable named int, while creating a nint is possible:
nint nint = 4;
It's because nint and nuint are contextual keywords. Moreover, if you don't know their history, you might wonder why exactly IntPtr and UIntPtr were honored to have aliases? Why not TimeSpan, for example? It appears even more often in most developers' lives I guess.
There is another example of a sudden addition to the keyword list—and not by two, but by three at once. It's about pattern matching. This is undoubtedly a useful feature that significantly simplifies life. Added in C# 7.0, pattern matching has undergone many extensions and now represents a very powerful tool.
In C# 9, new logical patterns and new keywords were introduced: not, and, and or. Just in case, I'll leave a link to a message explaining why common!, &&, and || couldn't be used. Moreover, pattern matching is available not only in switch constructs but also in if statements by using the is operator.
Although the logic behind adding new operators is clear, there's a feeling they are redundant. Yet, I can't come up with a better alternative—criticizing without offering a solution.
When it's hard to guess
At times, Microsoft wisely tries to avoid adding new keywords and reuses existing ones. The result of such care isn't always pleasing.
It all started long before our designated starting point. I think few programmers, upon first seeing the access modifier protected internal, guessed that the conjunction between these two words is not "and" but "or." It seems quite reasonable to read it as "accessible only from a derived class within the current assembly." But reality is cruel. The correct interpretation is: "Accessible from the current assembly (from any class) OR from a derived class (from any assembly)."
The cognitive dissonance and the need for an access modifier corresponding to the logical thought above led to the appearance of the no less strange private protected in C# 7.2. Who knows, perhaps in the future we'll see, for example, private protected internal. Let the interpretation be: "Accessible only from a derived class within the current namespace."
Another interesting object worth discussing is default. The operator, introduced long ago, allows getting the default value for any type. It's especially convenient for, for example, generics:
public T Foo<T>()
{
// ...
return default(T);
}
C# 7.1 simplifies the operator to a literal:
public T Foo<T>()
{
// ...
return default;
}
At the same time, since ancient times, a parameterless constructor was called a default constructor. And in both cases, the word default is used, so it seems both default and new T() should result in something identical. Or not?
Let's declare a structure–a number that can't be less than 10:
private struct A
{
private int _x = 10;
public A(int x)
{
if (x < 10)
throw new ArgumentException("X is too small.", nameof(x));
_x = x;
}
public override string ToString() => _x.ToString();
}
Next, let's create a default instance of this type in different ways:
A a1 = default(A);
Console.WriteLine($"A1 = {a1}");
A a2 = default;
Console.WriteLine($"A2 = {a2}");
A a3 = new();
Console.WriteLine($"A3 = {a3}");
A a4 = Activator.CreateInstance<A>();
Console.WriteLine($"A4 = {a4}");
The console prints the following:
A1 = 0
A2 = 0
A3 = 0
A4 = 0
Here we encounter an interesting feature of the default operator and literal: it ignores field initialization. Attentive readers pointed this out to me in comments to another article. Although, as with the example of protected internal, we can and should refer to the documentation, the behavior still seems non-obvious at first glance.
Starting with C# 10, we can add a parameterless constructor to the structure, and the console output will change:
A1 = 0
A2 = 0
A3 = 10
A4 = 10
This behavior caused controversy, and the feature landed in the top ranks of the anti-rating of innovations—40 positive votes and 29 negatives.
It turns out that the word default isn't quite applicable to both the corresponding operator/literal and new T(). Microsoft realized this too, so in the article "Use constructors" starting April 20, 2019, the company no longer uses the term default constructor—now it's a parameterless constructor. The last version of the text featuring the phrase default constructor is dated February 28, 2019.
Conclusion
Of course, there's more to grumble about. For example, default interface methods, introduced in C# 8. They caused an uproar—89 dislikes against 142 "for" votes.
The language is expanding, and developers get the opportunity to solve their tasks a little (and sometimes a lot) faster and easier. Still, sometimes there's a sense of lost elegance—something that made the language both simple and powerful.





Top comments (0)