matklad

Perils of Constructors

One of my favorite blog posts about Rust is Things Rust Shipped Without by Graydon Hoare. To me, footguns that don’t exist in a language are usually more important than expressiveness. In this slightly philosophical essay, I want to tell about a missing Rust feature I especially like: constructors.

What Is Constructor

Constructors are typically found in Object Oriented languages. The job of a constructor is to fully initialize an object before the rest of the world sees it. At the first blush, this seems like a really good idea:

  1. You establish invariants in the constructor.

  2. Each method takes care to maintain invariants.

  3. Together, these two properties mean that it is possible to reason about the object in terms of coarse-grained invariants, instead of fine-grained internal state.

The constructor plays a role of induction base here, as it is the only way to create a new object.

Unfortunately, there’s a hole in this reasoning: constructor itself observes an object in an inconsistent state, and that creates a number of problems.

Value of this

When the constructor initializes the object, it starts with some dummy state. But how do you define a dummy state for an arbitrary object?

The easiest answer is to set all fields to default values: booleans to false, numbers to 0, and reference types to null. But this requires that every type has a default value, and forces the infamous null into the language. This is exactly the path that Java took: at the start of construction, all fields are zero or null.

It’s really hard to paper over this if you want to get rid of null afterwards. A good case study here is Kotlin. Kotlin uses non-nullable types by default, but has to work with pre-exiting JVM semantics. The language-design heroics to hide this fact are really impressive and work well in practice, but are unsound. That is, with constructors it is possible to circumvent Kotlin null-checking.

Kotlin’s main trick is to encourage usage of so-called "primary constructors", which simultaneously declare a field and set it before any user code runs:

class Person(
  val firstName: String,
  val lastName: String
) { ... }

Alternatively, if the field is not declared in the constructor, the programmer is encouraged to immediately initialize it:

class Person(val firstName: String, val lastName: String) {
    val fullName: String = "$firstName $lastName"
}

Trying to use a field before initialization is forbidden statically on the best effort basis:

class Person(val firstName: String, val lastName: String) {
    val fullName: String
    init {
        println(fullName) // error: variable must be initialized
        fullName = "$firstName $lastName"
    }
}

But, with some creativity, one can get around these checks. For example, a method call would do:

class A {
    val x: Any
    init {
        observeNull()
        x = 92
    }
    fun observeNull() = println(x) // prints null
}

fun main() {
    A()
}

As well as capturing this by a lambda (spelled { args → body } in Kotlin):

class B {
    val x: Any = { y }()
    val y: Any = x
}

fun main() {
    println(B().x) // prints null
}

Examples like these seem contorted (and they are), but I did hit similar issues in real code (Kolmogorov’s zero–one law of software engineering: in a sufficiently large code base, every code pattern exists almost surely, unless it is statically rejected by the compiler, in which case it almost surely doesn’t exist).

The reason why Kotlin can get away with this unsoundness is the same as with Java’s covariant arrays: runtime does null checks anyway. All in all, I wouldn’t want to complicate Kotlin’s type system to make the above cases rejected at compile time: given existing constraints (JVM semantics), cost/benefit ratio of a runtime check is much better than that of a static check.

What if the language doesn’t have a reasonable default for every type? For example, in C++, where user defined types are not necessary references, one can not just assign nulls to every field and call it a day! Instead, C++ invents special kind of syntactic machinery for specifying initial values of the fields: initializer lists:

#include <string>
#include <utility>

class person {
  person(std::string first_name, std::string last_name)
    : first_name(std::move(first_name))
    , last_name(std::move(last_name))
  {}

  std::string first_name;
  std::string last_name;
};

Being a special syntax, the rest of the language doesn’t work completely flawlessly with it. For example, it’s hard to fit arbitrary statements in initializer lists, because C++ is not expression-oriented language (which by itself is OK!). Working with exceptions from initializer lists needs yet another obscure language feature.

Calling Methods From Constructor

As Kotlin examples alluded, all hell breaks loose if one calls a method from a constructor. Generally, methods expect that this object is fully constructed and valid (adheres to invariants). But, in Java or Kotlin, nothing prevents you from calling a method in constructor, and that way a semi-alive object can "escape". Constructor promises to establish invariants, but is actually the easiest place to break them!

A particularly bizarre thing happens when the base class calls a method overridden in the subclass:

abstract class Base {
    init {
        initialize()
    }
    abstract fun initialize()
}

class Derived: Base() {
    val x: Any = 92
    override fun initialize() = println(x) // prints null!
}

Just think about it: code for Derived runs before the its constructor! Doing a similar thing in C++ leads to even curiouser results. Instead of calling the function from Derived, a function from Base will be called. This makes some sense, because Derived is not at all initialized (remember, we can’t just say that all fields are null). However, if the function in Base happens to be pure virtual, undefined behavior occurs.

Constructor’s Signature

Breaking invariants isn’t the only problem with constructors. They also have signature with fixed name (empty) and return type (the class itself). That makes constructor overloads confusing for humans.

Quick, what is std::vector<int> xs(92, 2)?

  1. A vector of length 92 of twos

  2. [92, 92]

  3. [92, 2]

The problem with return type usually comes up if construction can fail. You can’t return Result<MyClass, io::Error> or null from a constructor!

This is often used as an argument that C++ with exceptions disabled is not viable, and that using constructors force one to use exceptions as well. I don’t think that’s a valid argument though: factory functions solve both problems, because they can have arbitrary names and can return arbitrary types. I actually this to be an occasionally useful pattern in OO-languages:

  • Make a single private constructor that accepts all the fields as arguments and just sets them. That is, this constructor acts almost like a record literal in Rust. It can also validate any invariants, but it shouldn’t do anything else with arguments or fields.

  • For public API, provide the necessary public factory functions, with appropriate naming and adjusted return types.

A similar problem with constructors is that, because they are a special kind of thing, it’s hard to be generic over them. In C++, "default constructable" or "copy constructable" can’t be expressed more directly than "certain syntax works". Contrast this with Rust, where these concepts have appropriate signatures:

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

trait Clone {
    fn clone(&self) -> Self;
}

Life Without Constructors

In Rust, there’s only one way to create a struct: providing values for all the fields. Factory functions, like the conventional new, play the role of constructors, but, crucially, don’t allow calling any methods until you have at least a basically valid struct instance on hand.

A perceived downside of this approach is that any code can create a struct, so there’s no the single place, like the constructor, to enforce invariants. In practice, this is easily solved by privacy: if struct’s fields are private it can only be created inside its declaring module. Within a single module, it’s not at all hard to maintain a convention like "all construction must go via the new method". One can even imagine a language extension that allows one to mark certain functions with a #[constructor] attribute, with the effect that the record literal syntax is available only in the marked functions. But, again, additional language machinery seems unnecessary: maintaining local conventions needs little effort.

I personally think that this tradeoff looks the same for first-class contract programming in general. Contracts like "not null" or "positive" are best encoded in types. For complex invariants, just writing assert!(self.validate()) in each method manually is not that hard. Between these two patterns there’s little room for language-level or macro-based #[pre] and #[post] conditions.

A Case of Swift

An interesting language to look at the constructor machinery is Swift. Like Kotlin, Swift is a null-safe language. Unlike Kotlin, Swift’s null-checking needs to be sound, so it employs interesting tricks to mitigate constructor-induced damage.

First, Swift embraces named arguments, and that helps quite a bit with "all constructors have the same name". In particular, having two constructors with the same types of parameters is not a problem:

Celsius(fromFahrenheit: 212.0)
Celsius(fromKelvin: 273.15)

Second, to solve "constructor calls virtual function from an object’s class that didn’t came into existence yet" problem, Swift uses elaborate two-phase initialization protocol. Although there’s no special syntax for initializer lists, compiler statically checks that constructor’s body has just the right, safe and sound, form. For example, calling methods is only allowed after all fields of the class and its ancestors are set.

Third, there’s special language-level support for failable constructors. A constructor can be declared nullable, which makes the result of a call to a constructor an option. A constructor can also have throws modifier, which works somewhat nicer with Swifts’s semantic two-phase initialization than with C++ syntactic initializer lists.

Swift manages to plug all of the holes in constructors I am ranting about. This comes at a price, however: the initialization chapter is one of the longest in Swift book!

When Constructors Are Necessary

However, I can think of at least two reasons why constructors can’t be easily substituted with Rust-style record literals.

First, inheritance more or less forces the language to have constructors. One can imagine extending the record syntax with support for base classes:

struct Base { ... }

struct Derived: Base { foo: i32 }

impl Derived {
    fn new() -> Derived {
        Derived {
            Base::new()..,
            foo: 92,
        }
    }
}

But this won’t work in a typical single-inheritance OO language object layout! Usually, an object starts with a header and continues with fields of classes, from the base one to the most derived one. This way, a prefix of an object of a derived class forms a valid object of a base class. For this layout to work though, constructor needs to allocate memory for the whole object at once. It can’t allocate just enough space for base, and than append derived fields afterwards. But such piece-wise allocation is required if we want a record syntax were we can just specify a value for a base class.

Second, unlike records, constructors have a placement-friendly ABI. Constructor acts on the this pointer, which points to a chunk of memory which a newborn object should occupy. Crucially, a constructor can easily pass pointer to subobject’s constructors, allowing to create a complex tree of values in-place. In contrast, in Rust constructing records semantically involves quite a few copies of memory, and we are at the mercy of the optimizer here. It’s not a coincidence that there’s still no accepted RFC for placement in Rust!

Discussion on /r/rust.