There’s a neat trick for using generic methods to get around some of the limitations that invariant type members in generic classes carry.
The problem I’m trying to solve:
Sometimes my generic class (say,
Box
) needs atype_member
(say,Elem
) to be invariant, because the type member is used in both input and output positions. For example, maybe this is a generic, mutable container (as contrasted with an immutable, read-only container).… but I still want to allow covariant subtyping in methods that take this generic type as an argument. For example, if I write a method that takes a
Box[Numeric]
, you should be able to call it if you have aBox[Integer]
. Normally, the fact thatElem
is invariant prevents this.
The solution is to change the parameter type from Box[Numeric]
to the more generic Box[T.all(T.type_parameter(:Elem), Numeric)]
.You’ll recognize this type (rather verbose) type as typical of a method which wants to place bounds on generic methods.
At this point, let’s just look at code.
Statement of the problem
Here’s our mutable, invariant Box
class:
class Box
extend T::Sig
extend T::Generic
# Needs to be invariant: this type supports reading
# and writing the `val` field
# (appears in both input and output positions)
Elem = type_member
sig { params(val: Elem).void }
def initialize(val)
@val = val
end
sig { returns(Elem) }
# ^^^^ output position
attr_reader :val
sig { params(val: Elem).returns(Elem) }
# ^^^^ input position
attr_writer :val
end
A method which operates on Box[Numeric]
is allowed to do things like this:
sig { params(box: Box[Numeric]).void }
def mutates_numeric_box(box)
# Can call arbitrary Numeric methods:
raise unless box.val.zero?
# Can set the value of the box to any Numeric,
# regardless of what was in it before.
box.val = (1 / 2r)
# ^^^^^^^^ instance of Rational
end
Recall that because of invariance, Sorbet has to reject things like this:
= Box[Complex].new(3 + 4i)
complex_box
mutates_numeric_box(complex_box)# ^^^^^^^^^^^ ❌ Box[Complex] is not a subtype of Box[Numeric]
= complex_box.val.polar.fetch(0)
radius # ^^^^^ runtime: 💥 Method `polar` does not exist on `Rational`
Sorbet must report an error on the call to mutates_numeric_box
: after the call, Sorbet still thinks that complex_box
has type Box[Complex]
but it actually holds a Rational
value. Allowing the program to continue is disastrous, and the program crashes with an exception on the call to polar
on the next line.
It’s frustrating because the only way to get from a Box[Complex]
to a Box[Numeric]
(so that we can call this method at all) is to make an entirely new box, with an explicitly wider type:
= Box[Numeric].new(complex_box.val) numeric_box
… which is annoying on its own (imagine having to do this for every call to mutates_numeric_box
!) but having done this, there’s no way to safely recover the fact that this Box[Numeric]
started with a Complex
. That fact has been forgotten.
What we can do instead, and what we give up
We can define our method using type_parameters
(with a pseudo bound), which allows being called with Box[Complex]
, but places more constraints on what we’re allowed to do inside the method itself.
sig do
type_parameters(:Elem)
.params(
box: Box[T.all(T.type_parameter(:Elem), Numeric)],
elem: T.all(T.type_parameter(:Elem), Numeric)
)
.void
end
def mutates_generic_numeric_box(box, elem)
initial_value = box.val
# Can still call arbitrary Numeric methods:
raise unless box.val.zero?
# CAN'T set val to an arbitrary Numeric value
# (It might not have been a Box that holds strings!)
box.val = (1 / 2r)
# ^^^^^^ ❌ Rational is not a subtype of
# T.all(T.type_parameter(:Elem), Numeric)
# (because Rational is not a subtype of
# T.type_parameter(:Elem))
# ... but we CAN set val to a user-provided value:
box.val = elem
# ... and we CAN set val to its original value:
box.val = initial_value
end
Note the new constraints on this implementation. We’re no longer able to overwrite val
with an arbitrary Numeric
value, like we could before with Rational
. It’s not like we can’t set this field at all: we just need something with the right type. As I discuss in Sorbet, Generics, and Parametricity, this limits us to only set val
to something we’ve been given as an argument. In our case, we’ve been given box.val
and elem
—those are the only two things we’re allowed to assign to val
.
This is… not all that limiting in practice? Especially considering that it means we’re now allowed to use subtyping at the call site:
= Box[Complex].new(3 + 4i)
complex_box 4 + 3i) # ✅
mutates_generic_numeric_box(complex_box, = complex_box.val.polar.fetch(0) # ✅ radius
The only real tradeoff with this approach is that the generic signature with type_parameters
is quite verbose.I have some ideas for what the new syntax should be, it’s mostly just an open question of whether the feature should be more or less syntactic sugar for the current syntax with T.all
and have bad error messages, or whether we should expand Sorbet’s type system to track bounds on type parameters, possibly introducing uncaught bugs.
Verbosity aside, the tradeoffs which limit what kinds of method implementations are allowed are not typically show-stopping limitations in real-world code.
For more information on variance and generics in Sorbet, see the docs:
Generic Classes and Methods →For the full code in this post in sorbet.run:
View in sorbet.run →