loading...

Embrace the static typing system: Strong types

technocoder profile image Technocoder ・6 min read

The following is about a 3 to 5 minute read.

First, what is strong typing?

It should be noted that "strong typing" isn't really an official technical term. I will however be using the following definition when I talk about strong typing.
Simply put, strong typing is the tendency for a compiler to enforce types and a typing "discipline". Some examples of strongly typed languages (in my opinion) are Haskell, Rust and C++.

The opposite of strong typing is weak typing. Weakly typed languages typically allow us to easily change the type of something or "coax" a type to be something else. For example in C we can force a pointer to become a void* or really any pointer we want.

Strong typing and weak typing aren't mutually exclusive. Instead, it's more of an axis. For example, in most languages we'd almost always find some sort of casting system.

Strong typing should not be conflated with static and dynamic typing. Those two terms refer to when types are checked and enforced. Static typing checks types at compile time whereas dynamic typing checks types at runtime. Static typing is orthogonal to strong typing.

A different way at viewing types

I'm assuming that everyone knows more or less how object oriented programming works. I want to emphasise the difference between what a particular user defined type is and what a user defined type is for. See the following contrived example:

struct Circle {
    radius: f32,
}

impl Circle {
    fn new(radius: f32) -> Circle {
        Circle {
            radius,
        }
    }
}

For those unfamiliar with Rust the "new" function is essentially the same as a constructor.

Notice how I haven't yet replaced "f32" with a user defined type yet. At the moment "f32" describes what the variable radius should be: A float. However, it doesn't describe what it's for. I'll (hopefully) show you what I mean.

fn new(f32) -> Circle {
    // function body
}

That's more or less the function signature. Can you tell, just by looking at that, what parameter we're meant to be putting inside there? It could be the radius, or it could be the diameter or even the circumference. It might not even describe the size aspect of the circle at all!

That's what I mean by "it doesn't describe what it's for". Or in other words, there's no meaning associated with that type. There's already a term for this: primitive obsession, but I don't think it's only applicable to primitives.

This issue is especially prevalent when we actually use this function in code. See the following:

fn main() {
    let some_random_circle = Circle::new(30.0);
}

We have no idea what this code is doing! The "30.0" is entirely meaningless. As said before, it could refer to anything. The only way to find out what's actually happening here is to look inside the "new" function itself.

(We could pull the "30.0" into a separate variable. But as we'll see later on we can fix this in other ways that might be more beneficial. Or you know, read the documentation. Alternatively, if you have a super fancy IDE it will automatically display the parameter name alongside the variable put in. cough cough CLion and Intellij. But generally the problem is we can't just scan over the code and understand what it does)

How we fix this

The way we fix this is actually quite simple. Simply create a new type that describes what 30.0 actually refers to. See the following:

struct Radius {
    radius: f32,
}

impl Radius {
    fn new(radius: f32) -> Radius {
        Radius {
            radius,
        }
    }
}

struct Circle {
    radius: Radius,
}

impl Circle {
    fn new(radius: Radius) -> Circle {
        Circle {
            radius,
        }
    }
}

Now Circle won't let us put anything other than a Radius inside there. There's no way we can mistake that for a circumference or a diameter. Because, the compiler won't let us make that mistake. Here's the updated main function:

fn main() {
    let some_random_circle = Circle::new(Radius::new(30.0));
}

Perfect! Now anyone looking over this code can immediately tell that this is a circle being constructed by a radius value.

Don't forget about diameter and circumference

You could overload the new constructor if you wanted to be able to construct a Circle by Diameter. Unfortunately, Rust doesn't have support for overloading at the moment (as far as I know). The way I fix this is by having Diameter explicitly cast to Radius.

struct Diameter {
    diameter: f32,
}

impl Diameter {
    pub fn new(diameter: f32) -> Diameter {
        Diameter {
            diameter,
        }
    }
}

use std::convert::From;
impl From<Diameter> for Radius {
    fn from(diameter: Diameter) -> Self {
        Radius::new(diameter.diameter / 2.0)
    }
}

fn main() {
    let circle = Circle::new(Diameter::new(30.0).into());
}

(Note that creating a "From" implementation for Radius automatically creates the into() function for any diameter variable. This is considered the preferred way for adding a cast instead of creating a "Into" implementation for Diameter)

As we scan over this code we can still immediately understand the Circle construction. The into() function invocation lets other programmers know that we thought about the code and confirmed that we really do want to convert this Diameter into a Radius.

An additional benefit is that every function that uses Radius no longer has to have an additional function that takes in a Diameter (had we taken the function overloading course). Imagine if we added a Circumference struct. We'd have to go over the entire codebase looking for usages of Radius and create an additional function that takes in Circumference.

Also, if we accidentally used Diameter without the into() invocation the compiler throws us an error (In Rust at least, other languages might implicitly cast it). That's a bug avoided if we really meant to use Radius.

There's no way I have 3000 different shape structs in my library

It's true that up until now I've been using a very contrived example. I can't imagine the average programmer creating a shape struct/class each time they write a program. It's time to show a real world example.

This example actually came up during the development of the game I'm working on. I was creating a virtual windowing system so that there would be separate windows inside of my game window. Additionally, I had to manage coordinates carefully so that I don't confuse my virtual windows and the real window. Here's what initially happened:

// Somewhere inside the depths of my MainMenu state
fn event(&mut self, event: &Event) {
    Event::MouseButtonPressed {x, y, ..} => {
        let cursor_position = some_function_that_uses(x, y);
        if let Some(position) = self.virtual_window.on_mouse(cursor_position) {
            if self.button_inside_virtual_window.contains(cursor_position) {
                // Do something cool
            }
        }
    }
}

Some of the variable and function names have been changed for clarity. Anyways, have you spotted the logic error yet?

The "contains" function is being passed "cursor_position" when in reality it was meant to take in "position" (The variable inside "if let Some(position) = ..."). Worse yet, I had even thought of this possibility and it still took me a good while to understand why my button was being activated halfway across the window.

Here's how I added two structs so this could never happen again:

struct RawPosition {
    x: f32,
    y: f32,
}

impl RawPosition {
    // Some constructor for RawPosition

    fn force_virtual(self) -> VirtualPosition {
        VirtualPosition {
            x: self.x,
            y: self.y,
        }
    }
}

struct VirtualPosition {
    x: f32,
    y: f32
}

impl VirtualPosition {
    // Some constructor for VirtualPosition
}

// some_function_that_uses(x, y) now returns a RawPosition
//
// on_mouse(cursor_position) now takes in a RawPosition and
// spits out a VirtualPosition
//
// contains(cursor_position) now takes in a VirtualPosition

Now if we try to run the same code from before the compiler throws us an error. We can't pass in a RawPosition to a parameter that's a VirtualPosition! Bug avoided.

What's the deal with the force_virtual function then? Well, I don't always want to draw a button onto a VirtualWindow. Sometimes I want to draw it onto the real window. In cases like those I don't need to process the RawPosition data. However, the compiler won't let us use it (if it did, that would defeat the entire purpose of this article) so we have to create a function that converts it.

I name it force_virtual because it lets other programmers know that I have thought this through and I really do want to convert RawPosition to VirtualPosition.

Conclusions

Strong typing may not be best for you. If you're working in a language where structs/classes have an impact on runtime performance you might want to consider the tradeoffs between safety and performance.

Strong typing won't suit everything. In my real world example I had to weigh how often I would draw a button in a virtual window instead of the real window. However, I highly recommend using it if you can. It can help to avoid a lot of headaches early on and improves readability. Let the compiler do as much work for you as it can.

I'm not very experienced in Rust (or programming in general although I guess it's relative) but I'll try my best to answer any questions you all may have. Otherwise, if you've made it this far, thanks for reading!

Posted on Nov 11 '17 by:

technocoder profile

Technocoder

@technocoder

17 year old programmer wannabe.

Discussion

markdown guide
 

This is a really good article, nicely explained!

One tip you might not be aware of: you could potentially take your From/Into implementation one step further by making the Circle constructor generic over any type that can be converted into a Radius:

impl Circle {
    fn new<T: Into<Radius>>(radius: T) -> Circle {
        let radius = radius.into();
        Circle {
            radius,
        }
    }
}

fn main() {
    let a = Circle::new(Radius::new(2.0));
    let b = Circle::new(Diameter::new(4.0));
}

This gives you the same nice interface you'd get from function overloading, but without, as you mentioned, having to go back and write a million new functions when you create a new type :) It does come at the expense of making it a little less obvious a conversion is happening, though.

 

Great post! Nowadays it's possible to go bug-hunting with static types in a variety of languages. In JavaScript for example, right now you don't even need to set up a typechecking step with a compiler like TypeScript or Flow--Visual Studio Code has TypeScript-based typechecking built right in! Here's an example bug I squashed recently. In React with Redux, Redux can pass a piece of the app's overall state to an individual UI component. But some of these state pieces can start off as null when the app starts up, so we need to account for that. In TypeScript we have the notion of union types, which capture this concept perfectly. E.g., a component that handles a possibly-null piece of state:

/**
 * @param {string | null} name
 * @returns {React.ReactElement<string>}
 */
function Greet(name) {
  return name && <p>Hi, {name}!</p>;
  /*     ^               ^
   *     |               But at this point, after it's been null-checked,
   *     name has type   it has type {string}, so it's safe to use!
   *     {string | null}
   *     at this point...
   */
}

This union type {string | null} (in A JSDoc comment) tells the typechecker to ensure we can use the name only after checking that it's not null. If we try to do otherwise, VSCode gives us an error message instantly!

Notice how the doc comment does double duty as a type hint and as documentation--a property of all good type systems in fact.

I'm planning to write up a bit more about this lightweight typechecking capability, but it's really easy to get started: twitter.com/yawaramin/status/92916...