Sharp Regrets: Top 10 Worst C# Features
When I was on the C# design team, several times a year we would have "meet the team" events at conferences, where we would take questions from C# enthusiasts. Probably the most common question we consistently got was "Are there any language design decisions that you now regret?" and my answer is "Good heavens, yes!"
This article presents my "bottom 10" list of features in C# that I wish had been designed differently, with the lessons we can learn about language design from each decision.
Before I begin, a few caveats. First, my opinions are my own and not necessarily those of the whole C# design team. Second, all these design decisions were made by smart people who were trying to find a balance between many competing design goals. In every case, there were powerful arguments for the feature at the time, and it's very easy to criticize with decades of hindsight. C# gets almost everything right; all of these points are minor quibbles about details of a very impressive and successful language.
Let's rant on!
#10: The empty statement does nothing for me
Like many other languages based on the syntax of C, C# requires that statements end in either a closing brace (}, also known as a right curly bracket) or a semicolon (;). An easily overlooked feature of these languages is that a lone semicolon is a legal statement:
void M() { ; // Perfectly legal }
Why would you want a statement that does nothing? There are a couple of legitimate usages:
- You can set a breakpoint on an empty statement. In Visual Studio, it can sometimes be confusing whether a program in a break state is at the beginning or in the middle of a statement; it's unambiguous in an empty statement.
- There are contexts in which a statement is required, but you need a statement that does nothing:
while(whatever) { while(whatever) { while(whatever) { if (whatever) // break out of two loops goto outerLoop; [...] } } outerLoop: ; }
C# has no "labeled break," and a label requires a statement; here, the empty statement exists solely to be the target of the label. Of course, if someone asked me to review this code, I would immediately suggest that deeply nested loops with goto branching are a prime candidate for refactoring into a more readable, maintainable form. This sort of branching is pretty rare in modern code.
I've explained the points in favor of the feature—and of course consistency with other languages is also nice—but none of these points are particularly compelling. The following example shows the single point against this feature:
while(whatever); // Whoops! { [...] }
The feral semicolon in the first line is almost invisible, but it has a large impact on the meaning of the program. This loop's body is the empty statement, followed by a block that was probably intended to be the loop body. This code fragment goes into an infinite loop if the condition is true, and executes the loop body once if the condition is false.
The C# compiler gives a "possibly unintended empty statement" warning in this situation. A warning is an indication that code is both plausibly created and almost certainly wrong; ideally, the language would prevent idioms that are likely but wrong, rather than just warning about them! If a compiler team has to design, implement, and test a warning for a feature, that's evidence that the feature might have been questionable in the first place, and of course the costs of doing that design work make up for the relatively inexpensive empty statement feature. Fortunately, this defect is rare in production code; the compiler warns about it, and the resulting infinite loop is easily noticed in testing.
Finally, there is one more way to make an empty statement in C#; just make an empty block:
{ }
It's difficult to write an empty block accidentally, or to overlook one in the source code, so this is my preferred syntax for those rare cases when I want to introduce a "do nothing" statement.
The empty statement feature is redundant, rarely needed, and error-prone, and it creates work for the compiler team to implement a warning telling you not to use it. The feature could simply have been cut from C# 1.0.
The problem with all the features on this list is that once we have the feature, the language team has to keep it forever. Backward compatibility is a religion on the C# design team.
The lesson: When you're designing the first version of a language, consider every feature on its merits. Lots of other languages might have this trivial little feature, but that's not a good enough reason to include it in your new language.
#9: Too much equality
Suppose you want to implement a value type for some sort of exotic arithmetic—rational numbers, for example. Odds are good that a user will want to compare two rationals for equality and inequality. But how? Oh, that's easy. Just implement the following:
- User-defined >, <, >=, <=, ==, and != operators
- Override of the Equals(object) method
- That method will box the struct, so you'll also want an Equals(MyStruct) method, which can be used to implement this:
IEquatable<MyStruct>.Equals(MyStruct)
IComparable<MyStruct>.CompareTo(MyStruct)
I count nine (or ten) methods above, all of which must be kept consistent with each other; it would be bizarre if x.Equals(y) were true but x == y or x >= y were false. That seems like a bug.
The developer has to implement nine methods consistently, yet the output of just one of those methods—the generic CompareTo—is sufficient to deduce the values of the other eight methods. The burden on the developer is many times larger than it needs to be!
Moreover, for reference types it's easy to end up comparing reference equality accidentally when you intended to compare value equality, and get the wrong result.
The whole thing is unnecessarily complicated. The language design could have been something like "If you implement a CompareTo method, you get all the operators for free."
The moral: Too much flexibility makes code verbose and creates opportunities for bugs. Take the opportunity to stamp out, eliminate, and eschew unnecessary repetitive redundancy in the design.
#8: That operator is shifty
Like many other C-based languages, C# has << and >> operators that shift the bits in the integer left and right. They have a number of design problems.
First, what do you suppose happens if you shift a 32-bit integer left by 32 bits? That might seem like a pointless operation, and it is, but it has to do something, so what does it do? You might reason that shifting an integer left by 32 bits is the same as shifting an integer left by 1 bit, and then repeating that operation 31 more times, which would produce zero.
This entirely reasonable assumption is completely false. Shifting a 32-bit integer left by 32 is a no-op; it's the same as shifting by zero. More bizarre: Shifting by 33 bits is the same as shifting by 1. The C# specification says that the shift count operand is treated as though the user had written count & 0x1f. This is an improvement over C, which makes shifting too much to be undefined behavior, but that's small comfort.
This rule also implies that shifting left by –1 is not the same thing as shifting right by 1, which would again be a plausible conclusion—but false. In fact it's not clear why C# has two shift operators in the first place; why not just one operator that can take a positive or negative count operand? (Answering this rhetorical question involves digging into the history of C, which is fascinating, but would take us too far off-topic.)
Let's take an even bigger step back. Why are we treating integers—the name of which implies that they are to be treated as numeric quantities—as though they were in fact small arrays of bits? The vast majority of C# programmers today aren't writing bit-twiddling code at all; they're writing business logic when they use integers. C# could have created an "array of 32 bits" type that was an integer behind the scenes, and put the bit-twiddling operators on only that specific type. The C# designers already did something similar to restrict operations on integers for pointer-sized integers and enums.
There are two lessons here:
- Follow the rule of least astonishment. If a feature is surprising to almost everyone, it's probably not a great design.
- Use the type system to your advantage. If there seem to be two non-overlapping usage scenarios, such as representing "numbers" and "bags of bits," make two types.
#7: I'm a proud member of lambda lambda lambda
C# 2.0 added anonymous delegates:
Func<int, int, int> f = delegate (int x, int y) { return x + y; };
Note that this is quite a "heavy" syntax; it requires the keyword delegate, the argument list must be typed, and the body is a block containing statements. The return type is inferred. C# 3.0 needed a far more lightweight syntax to make LINQ work, where all types are inferred and the body can be an expression rather than a block:
Func<int, int, int> f = (x, y) => x + y;
I think all concerned would agree that it's unfortunate to have two inconsistent syntaxes for what is basically the same thing. C# is stuck with it, though, because existing C# 2.0 code still uses the old syntax.
The "heaviness" of the C# 2.0 syntax was seen at the time as a benefit. The thought was that users might be confused by this new feature of nested methods, and the design team wanted a clear keyword in there calling out that a nested method was being converted to a delegate. No one could see into the future to know that a much lighter-weight syntax would be needed in a couple of years.
The moral is simple: You can't see the future, and you can't break backward compatibility once you get to the future. You make rational decisions that reach reasonable compromises, and you'll still get it wrong when requirements change unexpectedly. The hardest thing about designing a successful language is balancing simplicity, clarity, generality, flexibility, performance, and so on.
#6: Bit twiddling entails parentheses
In item #8, I suggested that it would be nice if the bit-twiddling operators were isolated to a specific type; of course, enums are an example of just that sort of thing. It's very common with flags enums to see code like this:
if ( (flags & MyFlags.ReadOnly) == MyFlags.ReadOnly)
In modern code, we would use the HasFlag method added to version 4 of the .NET Framework, but this pattern is still seen very frequently in legacy code. Why are those parentheses necessary? Because in C#, the "and" operator has lower precedence than the equality operator. For example, these two lines have the same meaning:
if ( flags & MyFlags.ReadOnly == MyFlags.ReadOnly) if ( flags & ( MyFlags.ReadOnly == MyFlags.ReadOnly) )
Obviously that's not what the developer intends, and thankfully it doesn't pass type-checking in C#.
The && operator is also of lower precedence than equality, but that's a good thing. We want this:
if ( x != null && x.Y )
to be treated as this:
if ( (x != null) && x.Y )
and not this:
if ( x != (null && x.Y) )
Let me sum up:
- & and | are almost always used as arithmetic operators, and therefore should have higher precedence than equality, just like the other arithmetic operators.
- The "lazy" && and || have lower precedence than equality. That's a good thing. For consistency, the "eager" & and | operators should have lower precedence as well, right?
- By that argument, && and & should both have higher precedence than || and |, but that's not the case either.
Conclusion: It's a mess. Why does C# do it this way? Because that's how C does it. Why? I give you the words of the late designer of C, Dennis Ritchie:
In retrospect it would have been better to go ahead and change the precedence of & to higher than ==, but it seemed safer just to split & and && without moving & past an existing operator. (After all, we had several hundred kilobytes of source code, and maybe [three] installations....)
Ritchie's wry remark illustrates the lesson. To avoid the cost of fixing a few thousand lines of code on a handful of machines, we ended up with this design error repeated in many successor languages that now have a corpus of who-knows-how-many billion lines of code. If you're going to make a backward-compatibility-breaking change, no time is better than now; things will be worse in the future.
#5: Type first, ask questions later
As noted in item #6, C# borrows the "type first" pattern from C and many of its other successor languages:
int x; double M(string y) { ... }
Compare that to Visual Basic:
Dim x As Integer Function M(Y As String) As Double
or TypeScript:
var x : number; function m(y : string) : number
Okay, dim is a little weird in VB, but these and many more languages follow the very sensible pattern of "kind, name, type": What kind of thing is it? (A variable.) What is the variable's name? ("x") What is the type of the variable? (A number.)
By contrast, languages such as C, C#, and Java infer the kind of the thing from context, and consistently put the type before the name, as if the type is the most important thing.
Why is one better than the other? Think about how a lambda looks:
x => f(x)
What is the return type? The type of the thing to the right of the arrow. So, if we wrote this as a normal method, why would we put the return type as far to the left as possible? From both programming and mathematics, we have the convention that the result of the computation is notated to the right, so it's weird that in C-like languages the type is on the left.
Another nice property of the "kind, name, type" syntax is that it's easy for the beginner programmer, who can see right there in the source code that "this is a function, this is a variable, this is an event," and so on.
The lesson: When you're designing a new language, don't slavishly follow the bizarre conventions of predecessor languages. C# could have put type annotations to the right while still being entirely comprehensible to developers coming from a C background. Languages like TypeScript, Scala, and many more did just that.
#4: Flag me down
In C#, an enum is just a thin type-system wrapper over an underlying integral type. All operations on enums are specified as actually being operations on integers, and the names of enum values are like named constants. Therefore, it's perfectly legal to have this enum:
enum Size { Small = 0, Medium = 1, Large = 2 }
and assign any value you like:
Size size = (Size) 123;
This is dangerous because code consuming a value of type Size is only expecting one of three values, and it might behave very badly when given a value outside that range. It's too easy to write code that's not robust against unexpected input, which is precisely the problem that a type system is supposed to mitigate, not exacerbate.
Could we simply say that assigning a value out of range to such a variable is illegal? We'd have to generate code to perform a runtime check, but the benefit might justify the expense. The problem arises when flag enums get involved:
[Flags] enum Permissions { None = 0, Read = 1, Write = 2, Delete = 4 }
These can be combined with the bitwise operators to make combinations like "read or write but not delete." That would be the value 3, which isn't one of the choices available. With dozens of flags, listing all legal combinations would be burdensome.
As discussed earlier, the problem is that we've conflated two concepts—a choice from a set of discrete options, and an array of bits—into one kind of thing. It might have been conceptually nicer to have two kinds of enums, one with operators for a set of distinct options, and one with operators for a set of named flags. The former could have mechanisms for range checks, and the latter could have efficient bitwise operations. The conflation seems to leave us in the worst of both worlds.
The lesson here echoes that of item #8:
- The fact that values can be outside the range of an enum violates the principle of least astonishment.
- If two use cases have almost no overlap, don't conflate them into one concept in the type system.
#3: I rate plus-plus a minus-minus
Again we come back to features that C# has because they're in C, rather than because they're good ideas. The increment and decrement operators are in the terrible position of being commonly used, frequently misunderstood, and almost never needed.
First, the point of these operators is to be useful for both their value and their side-effect, which is automatically a big negative for me. Expressions should be useful for their values and can be computed without side-effects; statements should produce a single side-effect. Almost any use of the increment and decrement operators violates this guideline, with the following exception:
x++;
which can be written this way:
x += 1;
or, just as clearly, like this:
x = x + 1;
Next, almost no one can give you a precise and accurate description of the difference between prefix and postfix forms of the operators. The most common incorrect description I hear is this: "The prefix form does the increment, assigns to storage, and then produces the value; the postfix form produces the value and then does the increment and assignment later." Why is this description wrong? Because it implies an order of events in time that is not at all what C# actually does. When the operand is a variable, this is the actual behavior:
- Both operators determine the value of the variable.
- Both operators determine what value will be assigned back to storage.
- Both operators assign the new value to storage.
- The postfix operator produces the original value, and the prefix operator produces the assigned value.
It is simply false to claim that the postfix form produces the original value and then does the increment and assignment afterward. (This is possible in C and C++, but not in C#.) The assignment must be done before the value of the expression is provided in C#.
That hair-splitting subtle point rarely impacts real code, I freely admit, but still I find it worrisome that most developers who use this operator cannot tell you what it actually does.
What I find worse about these operators is my inability to remember which statement accurately describes "x++":
- The operator comes after the operand, so the result is the value it has after the increment.
- The operand comes before the operator, so the result is the value it had before the increment.
Both mnemonics make perfect sense—and they contradict each other.
When writing this article, I had to open the specification and double-check to make sure that I wasn't remembering it backward, and this is after using these operators for 25 years and writing their code generators in several compilers for several languages. I surely cannot be the only person who finds mnemonics for these operators to be utterly useless.
Finally, many people coming from a C++ background are completely surprised to discover that the way C# handles user-defined increment and decrement operators is completely different from how C++ does it. Perhaps more accurately, they're not surprised at all—they simply write the operators incorrectly in C#, unaware of the difference. In C#, the user-defined increment and decrement operators return the value to be assigned; they don't mutate the storage.
The lesson: A new language shouldn't include a feature just because it's traditional. Lots of languages do very well without such a feature, and C# already has lots of ways to increment a variable.
Extra special bonus rant!
I feel much the same about use of the assignment operator for both its value and side-effect:
M(x = N());
This means "Call N, assign the value to x, and then use the assigned value as the argument to M." The assignment operator is used here for its effect as well as the value produced, which is confusing.
C# could have been designed so that assignment was only legal in a statement context, rather than an expression context. Enough said.
#2 I want to destruct finalizers
A finalizer (also known as a destructor) in C# has the same syntax as a destructor in C++, but very different semantics. In May 2015 I wrote a pair of articles about the perils of finalizers, so I won't recap the whole thing here. Briefly, in C++, destructors run deterministically, run on the current thread, and never run on partially constructed objects. In C#, a finalizer runs possibly never, whenever the garbage collector decides it can run, on a separate thread, and on any object that was created—even if the constructor didn't complete normally due to an exception. These differences make it quite difficult to write a truly robust finalizer.
Moreover, any time a finalizer runs, you could argue that the program either has a bug or is in a dangerous state, such as being shut down unexpectedly via a thread abort. Objects that need finalization probably need deterministic finalization via the Dispose mechanism, which should suppress finalization, so a finalizer running is a bug. Objects in a process that's being destroyed unexpectedly probably shouldn't be finalizing themselves; you don't wash the dishes as the building is being demolished.
This feature is confusing, error-prone, and widely misunderstood. It has syntax very familiar to users of C++, but surprisingly different semantics. And in most cases, use of the feature is dangerous, unnecessary, or symptomatic of a bug.
Obviously I'm not a big fan of this feature. However, there are good use cases for finalizers in certain scenarios, where critical code must run to clean up resources. That code should be written by experts who understand all the dangers.
The lesson: Sometimes you need to implement features that are only for experts who are building infrastructure; those features should be clearly marked as dangerous—not invitingly similar to features from other languages.
#1 You can't put a tiger in the goldfish tank, but you can try
Suppose we have a base class Animal with derived types Goldfish and Tiger. This program fragment compiles:
Animal[] animals = new Goldfish[10]; animals[0] = new Tiger();
But of course it crashes horribly at runtime, saying that you cannot put a tiger into an array of goldfish. But isn't the whole point of the type system to give you a compile error if you make this mistake, so that it doesn't crash at runtime?
This feature is called "array covariance," and it allows developers to deal with the situation in which they have an array of goldfish in hand, they have a method they didn't write that takes an array of animals, the method only reads the array, and they don't want to have to allocate a copy of the array. Of course, the problem arises if the method actually does write to the array.
Clearly this is a dangerous "gotcha," but since we know about it we can avoid it, right? Sure, but the danger isn't the only downside of this feature. Think about how the exception at runtime must be generated in the program above. Every time you write an expression of one reference type to an array of a less-derived type, the runtime must do a type check to make sure that the array is not really of an incompatible element type! Almost every array write gets a little bit slower in order to make it a little bit faster to call a method that takes an array of a base type.
The C# team added type-safe covariance to C# 4.0, so that an array of goldfish can be safely converted to IEnumerable<Animal>. Since the sequence interface provides no mechanism for writing to the underlying array, this is safe. A method that only intends to read from the collection can take a sequence rather than an array.
C# 1.0 has unsafe array covariance not because the designers of C# thought that the scenario was particularly compelling, but rather because the Common Language Runtime (CLR) has the feature in its type system, so C# gets it "for free." The CLR has it because Java has this feature; the CLR team wanted to design a runtime that could implement Java efficiently, should that become necessary. I don't know why Java has it.
Three lessons arise here:
- Just because it's free doesn't mean it's a good idea.
- Had the designers of C# 1.0 known that C# 4.0 would have safe generic covariance on interface types, they would have had an argument against doing unsafe array covariance. But of course they didn't know that. (Designing for the future is hard, remember?)
- As Benjamin Franklin (never) said, language designers who give up a little type safety for a little performance will discover they have neither.
Dishonorable mentions
Some questionable features didn't fit into my top 10 list:
- The for loop has bizarre syntax and some very rarely used features, is almost completely unnecessary in modern code, and yet is still popular.
- Use of the += operator on delegates and events has always struck me as weird. It also works poorly with generic covariance on delegates.
- The colon (:) means both "Extends this base class" and "Implements this interface" in a class declaration. It's confusing for both the reader and the compiler writer. Visual Basic spells it out very clearly.
- The rules for resolving names after the aforementioned colon are not well founded; you can end up in situations where you need to know what the base class is in order to determine what the base class is.
- The void type has no values and cannot be used in any context that requires a type, other than a return type or a pointer type. It seems bizarre that we think of it as a type at all.
- Static classes are how C# does modules. Why not call them "modules"?
- No one would shed tears if the unary plus operator disappeared tomorrow.
Summing up
Programming language designers have a saying: "Every new language is a response to the successes and shortcomings of other languages." C# was specifically designed to be familiar to users of C, C++, and Java, while at the same time addressing the shortcomings of those languages. Looking back at my top 10 list, more than half of these annoyances are a direct result of including a feature primarily because it would be familiar to users of other languages. The overarching lesson is that long history and familiarity are not good enough reasons to include a dubious feature. When considering which features to include in a language, we should ask questions like these:
- If the feature is valuable, is a better syntax available? Developers are generally clever and flexible; they usually can learn new syntaxes quickly.
- What are the actual usage cases in modern line-of-business programs? How can we design features that target those cases directly?
- If the feature is a "sharp tool," how can we limit its danger for unwary developers?
Language design decisions are usually the result of smart people making a good-faith effort to balance many competing goals: power, simplicity, familiarity, conciseness, robustness, performance, predictability, extensibility—I could go on and on. But sometimes hindsight lets us look back at decisions and see that they could have gone another way.
Did I miss any of your "pet peeve" features? What design features of programming languages do you regret? Comment below or send email to let me know!