Editing note: This post first appeared in a work-internal email. It was first cross-posted to this blog December 12, 2022.
Someone asked a question at work on Slack this week, and I thought it would be a neat chance to look at a small part of Sorbet’s flow-sensitive type checking algorithm. Paraphrasing, the question was:
I want to create some config classes that can inherit from each other. For example:
class BaseConfig; ...; end class SpecialConfig < BaseConfig; ...; end
SpecialConfig
could override some behaviors, etc. Since there’s no point creating multiple instances of the same config class, I’m thinking about using Ruby’ssingleton
module to enforce that there’s only one instance of each config class. Is this good or bad?
The Ruby standard library includes the singleton
gem as a drop-in implementation of the Singleton pattern. It allows people to write code like this:
require 'singleton'
class MySingleton
include Singleton
end
# all calls to `.instance` return a reference-equal object
puts MySingleton.instance
# it's impossible to construct two instances of this class
MySingleton.new # => raises `TypeError`
Maybe you already have your own opinions about the Singleton pattern: I’m not here to debate you. If you don’t have your own opinion, Wikipedia has you covered:
Critics consider the singleton to be an anti-pattern in that it is frequently used in scenarios where it is not beneficial […] and introduces global state into an application.
Instead, I’d like to convince you why that this specific combination (the Singleton pattern and inheritance) is a bad idea. I’m going to do it by reasoning from the perspective of the type system, and I’ll starts with the observation that if we let singletons be subclassed, we don’t really have a singleton anymore:
In this snippet, we’ve set up a parent / child relationship between two singleton classes, and defined a method that takes in an (the?) instance of the ParentSingleton
class. Ideally, this function would be trivial. Our sig
enforces that we’re given an instance of ParentSingleton
, so the only valid value for x
would be ParentSingleton.instance
, and then the equality comparison on line 8 would never fail.
But that’s not what happens, because x
could also be ChildSingleton.instance
:
ChildSingleton.instance) takes_parent_singleton(
ChildSingleton
is a subtype of ParentSingleton
, so this call site is perfectly fine types-wise. Sorbet has to reject the definition of takes_parent_singleton
statically so the T.absurd
never fails at runtime:
editor.rb:12: Control flow could reach `T.absurd` because the type `ParentSingleton` wasn't handled https://srb.help/7026
12 | T.absurd(x)
^^^^^^^^^^^
At a high-level, Sorbet’s flow-sensitive analysis takes steps like these to arrive at that error message:
Start by looking at
x == ParentSingleton.instance
. It’s used in anif
condition, so we might need to update our knowledge about the type ofx
under some hypotheticals.Hypothetical 1: the condition is
true
. If these values are equal, then certainly their types must be equal, so we record this implication in our set of knowledge:x == ParentSingleton.instance
\Longrightarrowx.is_a?(ParentSingleton)
We read this as “whenever the left side is true, then also the right side must be true.”
Hypothetical 2: the condition is
false
. If these values are not equal, we know nothing types-wise. We don’t record any new knowledge about the type ofx
.Finish type checking the rest of the method. When we’re type checking a different part of our method where we know whether
x == ParentSingleton.instance
istrue
orfalse
, we can look up the relevant knowledge we recorded earlier and apply that implication to the types of any variables still in scope.
Step 3 could look different, if only Sorbet had the extra knowledge that ParentSingleton
was a final class, i.e., one that can’t be subclassed. In this case, everything from earlier we wished were true actually is true: ParentSingleton
is now a real singleton, a type inhabited by only one value:
Here’s how Step 3 would look instead if ParentSingleton
was final:
Hypothetical 2:
x == ParentSingleton.instance
isfalse
. SinceParentSingleton
is a final class, we have exhaustively checked all values of this type and determined thatx
isn’t any of them. That means we can record this implication in our set of knowledge:x != ParentSingleton.instance
\Longrightarrow!x.is_a?(ParentSingleton)
Later on when the else
branch of our method is type checked, we’d look up this implication and apply it to the types of the variables in scope. If x
can’t be ParentSingleton
, then it can’t be anything, and Sorbet updates it’s knowledge of the type of x
to T.noreturn
, and it no longer reports an error on the T.absurd
.
In fact, this feature (in combination with a few other features) is actually how T::Enum
worked for almost a year! If you’re curious you can check out the original PR where I implemented it. (We ended up changing it to work differently for unrelated reasons.)
Hopefully this explanation answers the original question (don’t use inheritance with singletons!) but also gives a little insight into what’s happening when Sorbet type checks a program.