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 →