Why you should be careful with the Default trait/typeclass

sshine

Simon Shine

Posted on October 25, 2022

Why you should be careful with the Default trait/typeclass

You heard it. Default considered harmful!

tl;dr:

  • Default has no defining or testable properties
  • Default is not even guaranteed to be the same between runs
  • Defaults are context-specific: One type does not always have one default
  • Defaults are useful for transitive derivation, but context drift risks causing errors
  • Your Default instance is dangerous if you cannot carelessly replace it with any other value
  • Haskellers: Use the default* pattern instead
  • Rustaceans: Use Default with care and respect

Default in Rust and in Haskell

Rust's standard library has a Default trait that lets you provide a default value for any type:

pub trait Default {
    fn default() -> Self;
}

Returns the "default value" for a type.

Default values are often some kind of initial value, identity value, or anything else that may make sense as a default. Sometimes, you want to fall back to some kind of default value, and don't particularly care what it is.

The Default type class never made it to Haskell's standard library; one lives in the data-default package, and another one with much more pleasing defaults (sic) lives in acme-default.

When asking Haskellers why, the typical reason is that "type classes should reflect mathematical properties (invariants), and Default does not have any properties": Testing an instance of Default without making additional assumptions about the type gives you no good things to assert.

The Rust ecosystem does not have a similar mathematical vigilance around the use of traits: You are much more free to define traits in Rust than type classes in Haskell, and they don't have to reflect mathematical depth. Both of these attitudes are fine, so to dig into why Default is also bad in Rust, we must dig a little deeper.

Defaults are useful, but Default is sketchy

Software configuration relies heavily on default values.

Defaults let you run complicated software without having read the complete manual.

The features that require custom configuration (such as external APIs) can be disabled by default.

  • The default database can be SQLite for evaluating the software or for a lighter setup.
  • The default logging can be on-screen or in an operating system default directory.
  • The default host/port can be 127.0.0.1:3000, so you don't accidentally expose the service when toying around on semi-public networks.
  • Using your current working directory as the default directory sometimes works.

The implied context here is "a user will run the software and react to any unintended behavior caused by defaults". When the notion of configuration defaults is taken to a programmatic extreme, the implied context is not necessarily true.

What is the default integer?

3000 seems like a good choice if it's a generic service port number. 0 or 1 both seem like good choices if it's a monoid. If it's a service port number, 0 will mean "kernel picks a randomly unused port number", which does not produce deterministic behavior. If it's an IPv4 address, listening on 0.0.0.0 means you will listen on all interfaces, including public ones. Should the default HTTP client parameters be with or without SSL? For simplicity: without. For security: with. It depends on context. You may think that your type has a well-defined default, but nesting it inside a bigger structure may cause that to no longer be the case, because defaultness does not transcend.

An example of this drift from the real world is when XMonad's grabKey grabbed all unbound keys, because "KeySym 0 (NoSymbol) gets mapped to every unbound KeyCode, since that's what XKeycodeToKeysym returns for those." So while KeySym 0 (NoSymbol) is an obvious pick, it isn't so good inside XKeycodeToKeysym.

Another example is when a default prime-field element, 0, caused .mul_inverse() to fail. What a poor implementation of multiplicative inverses when it doesn't even work for the default prime-field element! The default, I tell you! (...and I haven't told you much, because defaults are not characterised by anything in general.)

The bad examples of defaults are general to any programming language.

Does your Default do anything?

To check if you understand and appreciate how arbitrary defaults should be capable of being, try and see if you agree with some of the instances found in acme-default package:

  • The Default False is the answer to the question whether mniip has a favourite Bool.
  • The Default Char is '→' because arrows look fancy when you use them in a chat.
  • The Default Double is 1.1102230246251565e-16 as the difference between 1 and sum (replicate 10 0.1).
  • The Default Float is 388.38, which is approximately equal to twice the molar mass of caffeine in grams per mol.
  • The Default Int is 18871, which is the product of a Sophie-Germain prime and a safe prime. You know, for safety and for Sophie.
  • The Default Int8 is 29 for obvious reasons.

The mockery here serves to highlight the conceptual mistake that defaults are often embedded with meaning that is not warranted in light of being a default, because defaults don't have special meaning.

Rust: A Default case-study

An example of when Default is used in Rust can be found in the Axum web framework:

impl<S, B> Default for Router<S, B>
where
    B: HttpBody + Send + 'static,
    S: Default + Clone + Send + Sync + 'static,
{
    fn default() -> Self {
        Self::with_state(S::default())
    }
}
Enter fullscreen mode Exit fullscreen mode

It says that a web app's state must impl Default.

This makes you able to start a web app "from scratch".

Is this made carefully?

Because Axum is a web framework and not a web app, providing a generic way to pass the default starting state also passes on the responsibility of defining sensible defaults to the web app developer. Different web apps may use "default" to mean "initial", "empty", or "demo" at their own discretion.

Default's lack of any properties seems to make it a legit placeholder here.

Summary

Implicit assumptions about the properties of a default for some specific type may hold in some contexts. They may also eventually propagate into errors, like in the examples above. Any legal value of a type should be a valid default.

You may prefer one default over another, but you shouldn't have to.

The recommended pattern in Haskell is to have named value bindings:

-- | The default configuration for trying out the software
defaultConfig :: Config
defaultConfig = ...

-- | The default shortcut key for interacting with the software
defaultModMask :: ModMask
defaultModMask = controlMask
Enter fullscreen mode Exit fullscreen mode

so that any special context can be referred to in the name of the binding.

In Rust we're stuck with having Default in the standard library, so we cannot treat it with the same degree of aversion, since it may occur in the wild. Instead we have to treat it with respect and care, because its only pseudo-property is fragile: It shouldn't matter what the default is.

While avoiding Default in Rust is not a general recommendation, the practice of naming things transcends languages. If an impl Default demonstrates other properties (i.e. needs to be some value to work, or breaks something when changed), it is probably more than a default. It doesn't have to be the impl of any trait, and naming it may be a better option.

Do you have examples of when a sketchy Default caused problems down the line?

(Cover art generated by DreamStudio.ai asking it to draw "default")

💖 💪 🙅 🚩
sshine
Simon Shine

Posted on October 25, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related