Subject:

C#'s broken type system


Date: Message-Id: https://www.5snb.club/posts/2020/csharps-broken-type-system/
Tags: #rant(3)

C# has a broken type system. And by that I mean the vast majority of function signatures that you write in C# are lies, and your function won’t be able to do meaningful work with all values that compile, and that there’s nothing you can do to fix this.

Focus on OrderDrink. We ask for a Customer (which is a class, as is standard in C#) and a DrinkType enum.

using System;

class Program
{
    class Customer {
        public Customer(string name, int age) {
            if (string.IsNullOrWhiteSpace(name)) {
                throw new InvalidOperationException("name is required");
            }

            if (age < 18) {
                throw new InvalidOperationException("you must be 18 or older");
            }

            Name = name;
            Age = age;
        }

        public string Name { get; }
        public int Age { get; }
    }

    enum DrinkType {
        Vodka,
        Rum,
        Beer,
    }

    static void OrderDrink(Customer customer, DrinkType drinkType) {
        Console.WriteLine($"{customer.Name}, age {customer.Age} is ordering a drink.");
        Console.WriteLine($"Give them their {drinkType.ToString()}.");
    }

    public static void Main(string[] args)
    {
        // here's where the magic will happen
    }
}

First off, we can just try passing null to Customer.

public static void Main(string[] args)
{
    OrderDrink(null, DrinkType.Vodka);
}

What do we get?

Unhandled Exception:
System.NullReferenceException: Object reference not set to an instance of an object
  at Program.OrderDrink (Program+Customer customer, Program+DrinkType drinkType) [0x00001] in <f40dc6945abf46fe8ff95b5af113af55>:0 
  at Program.Main (System.String[] args) [0x00001] in <f40dc6945abf46fe8ff95b5af113af55>:0 
[ERROR] FATAL UNHANDLED EXCEPTION: System.NullReferenceException: Object reference not set to an instance of an object
  at Program.OrderDrink (Program+Customer customer, Program+DrinkType drinkType) [0x00001] in <f40dc6945abf46fe8ff95b5af113af55>:0 
  at Program.Main (System.String[] args) [0x00001] in <f40dc6945abf46fe8ff95b5af113af55>:0

Oh. Well, that’s not good.

We’re accessing customer.Name, but customer is null.

I’ve heard of these things called “structs”, maybe they’ll help us?

struct Customer {
    // Everything else stays the same, all we changed is
    //   class to struct
}

Now what happens?

test.cs(36,20): error CS1503: Argument 1: cannot convert from '<null>' to 'Program.Customer'

This is what we want!

Now we need to go through the proper constructor, right?

Nope.

public static void Main(string[] args)
{
    OrderDrink(new Customer(), DrinkType.Vodka);
}
, age 0 is ordering a drink.
Give them their Vodka.

Well, fuck. We didn’t ask for a parameterless constructor, but we get one anyways. A constructor that does nothing, where we can’t enforce any of our invariants.

Let’s try to stop this, just by adding a parameterless constructor. We can at least throw, right? It’s still a runtime error, but one closer to the bug.

struct Customer {
    public Customer() {
        throw new InvalidOperationException("don't call me!");
    }

    // Rest of customer here
}
test.cs(19,17): error CS0568: Structs cannot contain explicit parameterless constructors

Oh.

See: https://github.com/dotnet/roslyn/issues/1029 where this issue was discussed.

And even if you could block that, you could always do default(Customer), or create an array of Customer’s, all of which will be zero initialised (A zero initialised number is, surprise surprise, zero, and a zero initialised reference type like string will be null.

Okay okay, so classes don’t forbid null being passed in, and structs can’t enforce invariants when you’re making them. Anything else broken in this cursed language?

Yes!

Notice how we’re asking for a DrinkType enum?

Well, you can do this.

OrderDrink(new Customer("David", 21), (DrinkType) 420);
David, age 21 is ordering a drink.
Give them their 420.

That’s right, enums are Just A Number! To be fair, enums can be used as bitfields, so that means all non-bitfield uses of them should be this broken.

Breaking invariants through other means, like untagged unions (arbitrary memory read/writes without unsafe code let’s go!) or reflection is outside the scope of this post. Maybe a future one.

For the interest of equality, let’s break Rust’s type system.

struct Customer {
    name: String,
    age: i32,
}

#[derive(Debug)]
enum CustomerError {
    NoName,
    Underage,
}

impl Customer {
    fn new(name: String, age: i32) -> Result<Customer, CustomerError> {
        if name.trim().is_empty() {
            return Err(CustomerError::NoName);
        }

        if age < 18 {
            return Err(CustomerError::Underage);
        }

        Ok(Customer { name, age })
    }

    fn name(&self) -> &str {
        &self.name
    }

    fn age(&self) -> i32 {
        self.age
    }
}

#[derive(Debug)]
enum DrinkType {
    Vodka,
    Rum,
    Beer,
}

fn order_drink(customer: &Customer, drink: DrinkType) {
    println!("{}, age {} is ordering a drink.", customer.name(), customer.age());
    println!("Give them their {:?}", drink);
}

fn main() {
    let customer = Customer::new("David".to_string(), 21).unwrap();
    order_drink(&customer, DrinkType::Rum);
}

Okay, the stage is set. So uhh… What do we do? I guess we can try passing in null?

error[E0425]: cannot find value `null` in this scope
  --> test.rs:48:17
   |
48 |     order_drink(null, DrinkType::Rum);
   |                 ^^^^ not found in this scope
   |
help: consider importing this function
   |
1  | use std::ptr::null;
   |

error: aborting due to previous error

For more information about this error, try `rustc --explain E0425`.

Oh, maybe we can try the null function?

error[E0308]: mismatched types
  --> test.rs:48:17
   |
48 |     order_drink(std::ptr::null(), DrinkType::Rum);
   |                 ^^^^^^^^^^^^^^^^ expected `&Customer`, found *-ptr
   |
   = note: expected reference `&Customer`
            found raw pointer `*const _`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0308`.

What’s the difference between a & and a *?

Well, a & is a reference. If you have a &Customer, you know you can access a perfectly valid Customer using it. It is a pointer, but it’s a pointer that carries with it guarantees that for as long as you have access to it, the thing it points to will be valid.

A * is more like a C pointer. It’s just a number. Is there something on the other side of it? Who knows??

What this all means is that Rust won’t let us create a reference to a Customer if we don’t actually have a customer to make a reference to.

Note how in this simplified example, we can just create a Customer directly the same way the new method did, but in real code, as long as the fields of Customer are private, you won’t be able to do this. Any struct with public fields is just a way to organise related variables together, and can’t have any invariant that it relies on.

Well, what about DrinkType? Rust has casting, can we cast anything into that?

error[E0605]: non-primitive cast: `i32` as `DrinkType`
  --> test.rs:49:28
   |
49 |     order_drink(&customer, 420 as DrinkType);
   |                            ^^^^^^^^^^^^^^^^
   |
   = note: an `as` expression can only be used to convert between primitive types. Consider using the `From` trait

error: aborting due to previous error

For more information about this error, try `rustc --explain E0605`.

Nope.

In rust, if you ask for an object, you know you both got that object, but the object can also keep its own invariants safe.

In C#, this is only true if you ask for a very small set of types only. Anything else, and you can be lied to, and you’ll end up needing to throw if you are indeed lied to.

Is this a problem in real code?

While being handed invalid enums is not a practical problem, not being able to rely on enums values being values that you know is a problem, as you have to always add default cases that throw exceptions or do nothing. A better system would be to have known exhaustive enums, where if you handle all the enum values that currently exist, and each of the branches return, then code after the enum is considered unreachable. Under all situations, if the enum and the match is in the same project, allow exhaustive matching.

As for the nulls… Yes. Trying to operate on null values is one of the most common bugs I’ve written. A stronger type system and ideally sum types would fix that bug.

Another common bug is mixing up database ids from different tables. If you’re lucky, this just leads to queries that never match, but it’s easy to imagine a serious security vulnerability here. A strongly typed id would make this bug much harder. Note how there’s nothing preventing you from doing this in C#, but it’s definitely not the default, and doing it in Entity Framework is not all that easy. This is more a point about how types are good, actually, and the more types you have, the better your code is.