C# complaints

Programming

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

enums 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 enums are really just type aliases for the integral type that represents them. enums also cannot implement interfaces.

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 structs 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 structs). Why is this so? My only guess is that they didn’t think nested nullable structs 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 nulls, 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 delegates …

Delegates

My knock on delegates is really more my frustration for there not being an alternative to them. delegates 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 traits representing the different kinds of closures that can occur which not only provides better performance than what delegates provide, but you don’t have to worry about invoking such a closure and it invoking a whole chain of functions.

Interfaces

interfaces initially began as a way to provide dynamic dispatch for structs as well as a way for a type to be a subtype of multiple supertypes. As the language grew, interfaces 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 classes in Haskell, traits in Rust, or protocols 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 interfaces 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 interfaces 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 interfaces 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 panics; and when panics 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 structs cannot implement interfaces makes them rather useless. I know why they cannot implement interfaces, but it is quite frustrating. Yet again we see how interfaces being types and not purely a higher-level construct hurts us. Had interfaces not been conflated with dynamic dispatch, ref structs 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 structs 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 structs, 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 classes 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 structs 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.