Disclaimer
A programming language theorist would likely object to this post, and I would likely agree with their reasoning. Languages serve different purposes whether they are natural languages or programming languages. Having said that, one will always have their own preferences for things; and for me, I strongly lean towards languages whose grammar rules are strict enough that expressions in the language are proofs themselves. This fact in conjunction with the fact that my last job required the use of C# is the reason for this post: venting about using C# in a way that it really isn’t intended to be used.
Enums
enum
s don’t really achieve what one would hope. It is bad enough they are not sum types like they are in Rust, but they don’t even restrict the possible values to those defined. In reality enum
s are really just type aliases for the integral type that represents them. enum
s also cannot implement interface
s.
using Std;
using System;
namespace Example {
enum Foo: byte {
Var0 = byte.MinValue,
Var1 = 1,
Var2 = 2,
}
static class Program {
static Unit Bar(Foo foo) {
Console.Write($"{foo.ToString()}\n");
return new();
}
// Clearly there should only be three possible values for Foo, but here we
// show that any byte works.
static void Main() => Bar((Foo)byte.MaxValue);
}
}
This means that one still has to account for values they clearly never intended for.
Struct type bound
Pop quiz. What does the struct
type bound mean? If you thought “any type that is a struct
”, you are forgiven as that is the only reasonable answer. You might know about ref struct
s and how they cannot be used as type arguments—we will get to them later—so perhaps your answer was “any non-ref struct
”. Unfortunately you would still be wrong. The actual answer is any enum
or non-ref struct
that is not returned from the System.Nullable
type constructor (which returns struct
s). Why is this so? My only guess is that they didn’t think nested nullable struct
s would be useful which unfortunately is symptomatic of one not generalizing enough which I find to be rather prevalent in software engineering—I know I have used nested nullable types before.
Null
I know what you are already saying, “C# 8 actually added non-nullable reference types; so if you don’t want to deal with null
s, only use non-nullable reference types”. Non-nullable reference types are an illusion. There is a reason compiler lints will warn you when defining a public
function with at least one non-nullable reference type parameter where you don’t explicitly check the parameter is not null
. If it were truly part of the language, you wouldn’t need to enable them and you certainly wouldn’t be able to disable them. Now the language added a few other aspects to make the illusion harder to spot. A few examples are the notnull
type bound and ?
to denote that the type is nullable. The reality is though only value types can be properly declared as nullable while reference types always are. Let’s look at some examples shall we?
using Std;
using System;
#pragma warning disable
namespace Example {
static class Program {
static void Main() {
// Displays '0', but Foo appears to call default(uint)?.ToString()
// which of course is not valid.
_ = Foo<uint>();
// Displays 'null' despite string being non-nullable.
_ = Foo<string>();
// Displays 'null' as expected.
_ = Foo<uint?>();
// Displays 'null' as expected.
_ = Foo<string?>();
// Displays '0', but Bar appears to call default(uint)?.ToString()
// which of course is not valid.
_ = Bar<uint>();
// Displays 'null' despite string being non-nullable.
_ = Bar<string>();
// Why does this even compile? int? does not conform
// to the type bound notnull.
// It displays 'null'.
_ = Bar<uint?>();
// Why does this even compile? string? does not conform to the
// type bound notnull.
// It displays 'null'.
_ = Bar<string?>();
// Displays '0' as expected.
_ = Fizz<uint>();
// Does not compile as expected since string does not conform to
// the type bound struct.
_ = Fizz<string>();
// Does not compile as expected since uint? does not conform to
// the type bound struct.
_ = Fizz<uint?>();
// Does not compile as expected since string? does not conform to
// the type bound struct.
_ = Fizz<string?>();
// Displays '0' despite Buzz appearing to get the default value of
// uint?.
_ = Buzz<uint>();
// Displays 'null' as expected.
_ = Buzz<string>();
// Displays 'null', but Buzz appears to get the default value of
// uint?? which is not a valid type.
_ = Buzz<uint?>();
// Displays 'null', but Buzz appears to get the default value of
// string?? which is not a valid type.
_ = Buzz<string?>();
// Displays 'True' despite the underlying type being string and not
// string?.
Console.Write($"{((new string[1])[0] is null).ToString()}\n");
}
// Why does this compile?
// What happens if T is not nullable?
// default(T)?.ToString() is not valid when T is not nullable.
static Unit Foo<T>() {
Console.Write($"{default(T)?.ToString() ?? "null"}\n");
return new();
}
// Why does this compile?
// default(T)?.ToString() is not valid when T is not nullable.
static Unit Bar<T>() where T: notnull {
Console.Write($"{default(T)?.ToString() ?? "null"}\n");
return new();
}
static Unit Fizz<T>() where T: struct {
Console.Write($"{default(T).ToString()}\n");
return new();
}
// Why does this compile?
// What happens if T is nullable?
// T?? is not valid.
static Unit Buzz<T>() {
Console.Write($"{default(T?)?.ToString() ?? "null"}\n");
return new();
}
// Without disabling warnings, this should complain that we accessed
// Length before verifying s was not null even though s has type string
// and not string?.
public static int Fuzz(string s) => s.Length;
}
}
What can we conclude? When ?
is associated with a type parameter that is not bound by struct
, it is completely removed as if it didn’t exist. Ditto for the notnull
constraint. This is a result of a more general problem with C#, and that is that it was not designed with higher-level programming in mind. When C# 1 was released, there wasn’t even the ability to parameterize over types; and it is clear that is something it will always be hindered by.
Void
Ah, void
. How I despise you. Instead of letting math guide us to what is sound, we decided to create what I call a fake type. You cannot create a variable of type void
. You cannot pass void
as a type argument. In reality it is nothing special except it is designed to be “returned” from a function that is only to be called for its side effects. As we see in a language like Rust, it is far more sound to designate a canonical unit type for this. This would allow us to use it just like any other type. It would also mean there would be no need to have separate delegate
type constructors like System.Action
and System.Func
. Speaking of delegate
s …
Delegates
My knock on delegate
s is really more my frustration for there not being an alternative to them. delegate
s are fine when you want a linked list of functions to be invoked when an event happens; however there are times when you simply want a plain function. In Rust you have trait
s representing the different kinds of closures that can occur which not only provides better performance than what delegate
s provide, but you don’t have to worry about invoking such a closure and it invoking a whole chain of functions.
Interfaces
interface
s initially began as a way to provide dynamic dispatch for struct
s as well as a way for a type to be a subtype of multiple supertypes. As the language grew, interface
s became more a higher-level construct representing the “structure” of a type allowing one to define polymorphic functions based on types with certain structure. As a result, they will never be like class
es in Haskell, trait
s in Rust, or protocol
s in Swift. They will never have an implicit TSelf
type parameter allowing one to properly define an interface
that is able to reference the implementing type.
Another issue with interface
s that is more about how developers use them and define them as opposed to them in the language is that they are defined with more functions than they should have as well as coupling them with other interface
s via subtyping. It is too common to see functions defined on an interface
where the documentation explicitly states it is OK to throw
an exception instead of implement it. You will see interface
s like System.Collections.Generic.IEnumerator<T>
coupled with System.IDisposable
when one can certainly have one without the other.
Exceptions
Ugh! I cannot overstate how much I hate the prevalence of System.Exception
subtypes. They are thrown with abandon causing one to always be fearful that any function they call may cause an explosion. Yes languages like Rust have panic
s; and when panic
s are not compiled to lead to an abortion, one still has to worry about them. They are used significantly less frequently though, and the language has so much syntactic sugar to make proper error handling pleasant there is very little reason to not define true functions instead of partial functions that plague a lot of software.
Ref structs
The fact ref struct
s cannot implement interface
s makes them rather useless. I know why they cannot implement interface
s, but it is quite frustrating. Yet again we see how interface
s being types and not purely a higher-level construct hurts us. Had interface
s not been conflated with dynamic dispatch, ref struct
s would be able to implement them since implementing them would not necessarily require them to be able to be used via dynamic dispatch.
Mutable structs are evil™
Let me tell you a quick story about the very first bug I wrote. I was learning Python by doing some of the problems on Project Euler. Problem 12 is about highly divisible triangular numbers. I wanted to solve that problem by beginning the search on a “reasonable” lower bound. I decided to use the work on highly composite numbers from one of the greatest minds of all time, Srinivasa Ramanujan. Anyway, my implementation relied on mutating lists; and I was perplexed why a mutation in one area affected the list elsewhere. It wasn’t until I was informed about the notion of reference types that I understood what was happening.
What is the point of that story? In C# communities and perhaps others, mutable reference types are something a developer is expected to understand and a bug caused by that misunderstanding is a failure of the developer; yet bugs related to mutating a value type are not viewed the same. In particular the issue lies within the mutable nature of the value type and not the misunderstanding of its use by the developer. I find that crazy. As any Haskeller would tell you, mutable types are evil with no further qualification; yet in C#, it is only mutable value types that are evil. Personally, I find mutable reference types to be just as confusing; and I personally think bugs caused by them are worse than bugs caused by mutable value types. When one incorrectly mutates a reference type, that mutation will cascade throughout the Universe; in contrast when one incorrectly mutates a value type, the issue is far more localized. Personally, I decided to shun the whole “mutable struct
s are evil” idea. I try to make types (value or reference) immutable; but when I don’t, I have no issue making the type a struct
.
Another point of contention I have with this whole idea is that what makes mutable value types dubious is the language itself. Surely it was clear almost immediately that mutable value types might cause problems due to the way the language was designed. The team very easily could have made mutable value types grammatically incorrect and prevented their existence from the get-go, or even better changed the language such that mutations weren’t that bad at all. They didn’t though which means “gotchas” like below exist:
using System;
namespace Example {
struct Foo {
internal uint x;
internal uint Mutate() => ++x;
}
sealed class Bar {
internal readonly Foo foo;
}
static class Program {
static void Main() {
var bar = new Bar();
// Displays '1'.
Console.Write($"{bar.foo.Mutate().ToString()}\n");
// Displays '1' still.
Console.Write($"{bar.foo.Mutate().ToString()}\n");
// This does not compile since the readonly nature of
// the field would be violated, and this is "obvious"
// to the compiler as opposed to the above calls where the
// mutation occurs one level deeper.
bar.foo.x = 2u;
}
}
}
Clearly the readonly
nature of the field foo
in Bar
contradicts the non-readonly
nature of the type Foo
itself; but instead of designing the language such that this would not be possible, we have to live with the fact that the mutation occurs on a temporary copy of the field. Insane.
Coupling of a type’s definition with its implementation
This is yet another problem of not having higher-level programming in mind. Coupling the definition of a type with its implementation is not bad for simple types (i.e., nullary type constructors); but for type constructors with at least one parameter, it blows. It is very common to want to imbue a type, Foo<T>
, with additional structure based on the structure that exists for T
. For example, it may make sense for Foo<T>
to implement Std.Hashing.IHashable
if T
implements that interface
; however that is not possible. You are forced to either make the type less generally useful by adding the Std.Hashing.IHashable
type bound to T
or preclude Foo<T>
from ever being used in a higher-level context that requires hashable types.
Lack of associated types
Associated types really aid with the ergonomics of higher-level programming. When one replaces their use with a type parameter in the type constructor, higher-level programming quickly starts to get ugly. Associated types are a compromise between generalizing over an arbitrary type and not generalizing at all. You can see how ugly code gets by checking out some of my C# code I have hosted in my Git repos.
Parameterless constructors in structs
In C# 10 they added the ability to explicitly define and implement public
parameterless constructors for struct
s, but that doesn’t change the fact that having parameterless constructors is not opt-in or even opt-out. This can be frustrating as there are many times you don’t want a type to have a parameterless constructor. More generally though, all types have default values in C#. This allows for things like T[]
to be easily populated with values without having to explicitly do so, but it comes at the cost of having to deal with instances of types that ideally would not be possible.
Non-readonly and non-sealed by default
All fields in types are not readonly
by default. Similarly class
es are not sealed
by default. Unfortunate reality is that not only is mutability a thing in C#, but it is the norm. C# 8 introduced the ability to declare functions and properties as readonly
in struct
s which means functions and properties are by default non-readonly
too. While most languages are not pure like Haskell, it is far more sane to make types and variables read-only by default à la Rust.
Lack of lifetimes
Some people think that lifetimes in a language brings garbage collection at compilation time, and that is it. That is far too reductive. Lifetimes solve a problem at the logic level, so it is not surprising that you get thread-safety as well but also additional semantics. Any time you want to “consume” something in C#, it is on you to avoid using that thing. It is not part of the grammar; so whether it is not re-using a variable that has been disposed or trying to implement something like Serde, you are on your own.
Subtyping
Too many bugs can happen due to subtyping. As we already went over though, C# 1 was released before type parameterization was a thing; and it was easy to implement dynamic dispatch via subtyping, so it was inevitable. I like knowing that when I pass in a type, T
, into a polymorphic function that returns T
, I will be getting precisely that type.
Closing thoughts
Writing C# that is as correct or performant as you can in a language like Rust is not only not possible, but the closest you can get to achieving it requires substantially more boilerplate and a big hit in ergonomics. If you don’t desire that level of correctness or performance, then C# might be a fine language for your needs.