This document describes and motivates the use of static typing in netstack3.
Netstack3 makes heavy use of static typing largely for the same reasons that any program does - to catch bugs at compile time. But there are a number of benefits to static typing that are more specific to our use case that are worth calling out.
Often, there are invariants that we want to uphold which are not as simple as type safety. For example, consider the act of setting the address and subnet on an interface. Here's one way we might write a function to do this:
pub fn set_ip_addr(iface: Interface, addr: IpAddr, network: IpAddr, prefix: u8) { ... }
In this example, we‘ll assume that IpAddr
is either an IPv4 address or an IPv6 address, just like the standard library’s std::net::IpAddr
. We can observe a number of invariants which the caller must uphold:
addr
and network
must be of the same IP versionprefix
must be a valid prefix length for this IP version (no more than 32 bits for IPv4 or 128 bits for IPv6)network
must only have its uppermost prefix
bits setaddr
must be an address in the subnet identified by network
and prefix
Assuming we wanted to design the interface this way, the canonical way of enforcing these invariants would be a) to document them in a doc comment and, b) to verify them at runtime, panicking if they are not upheld.
Now let's assume that set_ip_addr
is called at the bottom of a long, complicated sequence of function calls that originate with a request from a local application client. How can we ensure that the invariants required by set_ip_addr
are bubbled all the way up through the application to the point of accepting external input? This is a crucial question because, if we get this wrong, a client of the netstack could pass invalid parameters, and have those parameters make it to set_ip_addr
, which would panic and crash the entire netstack.
To address this problem, we instead write set_ip_addr
like this:
pub fn set_ip_addr<A: IpAddress>(iface: Interface, addr_subnet: AddrSubnet<A>) { ... }
An AddrSubnet
is a type which guarantees all of the invariants we listed above. Instead of having to worry about documenting the invariants and hoping that the caller upholds them, we can simply assume that, by virtue of the fact that the caller has an AddrSubnet
value, the invariants must be upheld.
What's more, AddrSubnet
has the following constructor:
impl<A: IpAddress> AddrSubnet<A> { pub fn new(addr: A, prefix: u8) -> Option<AddrSubnet<A>> { ... } }
The caller must guarantee that prefix
is valid for the IP version as described above. However, instead of panicking if prefix
is invalid, new
simply returns None
. This forces the programmer to be cognizant of the potential for error even if they didn't read the documentation. If new
panicked on an invalid prefix
, we could write the innocuous code:
let (addr, prefix) = read_addr_prefix_from_client(); let addr_subnet = AddrSubnet::new(addr, prefix); ...
To someone reading this code, it‘s not at all clear that there’s a denial-of-service waiting to happen if a client passes an invalid prefix
. Instead, with new
returning an Option
, this code becomes:
let (addr, prefix) = read_addr_prefix_from_client(); let addr_subnet = AddrSubnet::new(addr, prefix).unwrap(); ...
The unwrap
makes it clear what's happening, and will hopefully tip off a code reviewer to the issue.
There's one final benefit: Function authors are encouraged to take these types as arguments, rather than the raw inputs from which the types are produced. This has the effect of naturally pushing input validation as close to client-provided input as possible, which is exactly where we want it.
To summarize, using static typing to uphold invariants gives us the following benefits: