id | title | sidebar_label |
---|---|---|
abstract |
Abstract Classes and Interfaces |
Abstract Classes & Interfaces |
Sorbet supports abstract classes, abstract methods, and interfaces. Abstract methods ensure that a particular method gets implemented anywhere the class or module is inherited, included, or extended. An abstract class or module is one that contains one or more abstract methods. An interface is a class or module that must have only abstract methods.
Keep in mind:
abstract!
can be used to prevent a class from being instantiated.- Both
abstract!
andinterface!
allow the class or module to haveabstract
methods. - Mix in a module (via
include
orextend
) to declare that a class implements an interface.
Note: Most of the abstract and override checks are implemented statically, but some are still only implemented at runtime, most notably variance checks.
To create an abstract method:
- Add
extend T::Helpers
to the class or module (in addition toextend T::Sig
). - Add
abstract!
orinterface!
to the top of the class or module. (All methods must be abstract to useinterface!
.) - Add a
sig
withabstract
to any methods that should be abstract, and thus implemented by a child. - Declare the method on a single line with an empty body.
module Runnable
extend T::Sig
extend T::Helpers # (1)
interface! # (2)
sig {abstract.params(args: T::Array[String]).void} # (3)
def main(args); end # (4)
end
To implement an abstract method, define the method in the implementing class or
module with an identical signature as the parent, except replacing abstract
with override
.
class HelloWorld
extend T::Sig
include Runnable
# This implements the abstract `main` method from our Runnable module:
sig {override.params(args: T::Array[String]).void}
def main(args)
puts 'Hello, world!'
end
end
There are some additional stipulations on the use of abstract!
and
interface!
:
- All methods in a module marked as
interface!
must have signatures, and must be markedabstract
.- Note: this applies to all methods defined within the module, as well as any that are included from another module
- A module marked
interface!
can't haveprivate
orprotected
methods. - Any method marked
abstract
must have no body.sorbet-runtime
will take care to raise an exception if an abstract method is called at runtime. - Classes without
abstract!
orinterface!
must implement allabstract
methods from their parents. extend MyAbstractModule
works just likeinclude MyAbstractModule
, but for singleton methods.abstract!
classes cannot be instantiated (will raise at runtime).
Certain abstract classes or interfaces want to provide methods that provide a reasonable default implementation of a method, allowing individual children to override the method with a more specific implementation.
This is done with overridable
:
module Countable
extend T::Helpers
# 1: `abstract!` instead of `interface!`
abstract!
sig { abstract.returns(T.nilable(Integer)) }
def to_count; end
# 2: Use `overridable` to provide default implementation of `to_count!`
sig { overridable.returns(Integer) }
def to_count!
T.must(self.to_count)
end
end
As the example shows, there are two main steps:
-
If the module is not already
abstract!
(i.e., if it's aninterface!
), change it to useabstract!
. Modules declared withinterface!
are constrained to only have abstract methods, which prevents adding methods with a default implementation. -
Use
overridable
to declare the default implementation of a method. Usingoverridable
opts the method into static override checking, which will ensure that children define a type-compatible override.
Note: if you want to provide functionality in an abstract class or module that must not be possible to override in a child, use a final method.
Sorbet allows abstract methods in modules to be implemented by an ancestor of the class or module they're eventually mixed into. Consider this example:
class Parent
sig { void }
def foo = puts 'Hello!'
end
module IFoo
extend T::Helpers
abstract!
sig { abstract.void }
def foo; end
end
class Child < Parent # ✅ okay
include IFoo
end
class NotAParent # ❌ Missing definition for `foo`
include IFoo
end
Breaking down this example:
-
The
IFoo
module declares a single, abstractfoo
method. All classes that include this module must either be marked abstract or define this method. -
The
Parent
method does not depend onIFoo
, but does happen to define a method calledfoo
. -
Both
Child
andNotAParent
haveinclude IFoo
, but neither define afoo
method. -
Despite this: only
NotAParent
has an error saying that a concrete implementation offoo
is missing. Sorbet allows thefoo
method to be implemented inChild
because it inherits afoo
method fromParent
.
This technique is particularly useful as a way to approximate "duck typing," where you depend on "anything type, so long as it has this method."
For example:
module ShortName
extend T::Helpers
abstract!
sig { abstract.returns(T.nilable(String)) }
def name; end
sig { returns(T.nilable(String)) }
def short_name
self.name&.split('::')&.last
end
end
This module provides a short_name
method (which computes the "short name" of a
something like a Module
by splitting the full name into ::
-delimited tokens
and returning the last one. Like C
for module A::B::C
).
The module's implementation depends on the name
method existing. If we don't
declare it as an abstract
method, Sorbet reports an error saying "Method
name
does not exist," which is true--there's no guarantee someone mixes this
module into a context where name
is defined.
But by declaring name
as an abstract method, Sorbet will check this property.
In particular, this has the effect of catching someone who accidentally uses
include
instead of extend
when mixing this module into a class:
class A
include ShortName # ❌ error: Must define abstract method `name`
end
class B
extend ShortName # ✅
end
This technique is particularly effective when it's not possible to refactor some
upstream dependency's code to expose an explicit interface. The Module
class
in the Ruby stdlib doesn't have some sort of public INameable
interface with
the name
method. A handful of database model classes in an application might
share a set of related fields, without explicitly implementing some interface.
And yet, using this technique Sorbet allows writing modules which depend on
those implicit interfaces.
This technique is also quite flexible: the ShortName
module can be used in
any context where a name
method is available. So for example, if you had
some T::Struct
that stores a name
, this ShortName
mixin could also be
used:
class C < T::Struct
include ShortName
prop :name, String
end
C.new(name: "Some::Long::Namespace").short_name # => "Namespace"
Letting abstract methods be implemented by inherited methods relies on the fact
that method signatures are checked at runtime. To explain why this
feature requires runtime support, let's look at the resolved ancestors of
Child
:
irb> Child.ancestors
=> [Child, IFoo, Parent, <...>]
This shows that Ruby resolves a call like child.foo
by first checking whether
Child
defines foo
, then whether IFoo
defines foo
, and then finally
whether Parent
does. Since it looks in IFoo
before Parent
, Ruby
actually calls the IFoo#foo
method. But this method would normally have an
empty method body—it's abstract!
So at runtime, the sig
method replaces the implementation of foo
with a
method that does something like this:
def foo
if defined?(super)
super
else
raise NotImplementedError.new("Call to unimplemented abstract method")
end
end
This allows IFoo#foo
to dispatch up the ancestor chain, letting child.foo
result in a call to Parent#foo
.
If runtime signature checking is disabled, a call like child.foo
will silently
produce nil
instead of calling the appropriate method.
abstract
singleton methods on a module are not allowed, as there's no way to
implement these methods.
module M
extend T::Sig
extend T::Helpers
abstract!
sig {abstract.void}
def self.foo; end
end
M.foo # error: `M.foo` can never be implemented
Abstract singleton methods on a class are allowed, but are unsound (i.e.,
they can lead to runtime, type-related exceptions like TypeError
and
NameError
even when there is no T.untyped
involved):
class AbstractParent
abstract!
sig { abstract.void } # ❌ BAD: abstract singleton class method!
def self.foo; end
end
class ConcreteChild < AbstractParent
sig { override.void }
def self.foo = puts("hello!")
end
sig { params(klass: T.class_of(AbstractParent)).void }
def example(klass)
klass.foo
end
example(ConcreteChild) # ✅ okay
example(AbstractParent) # static: ✅ no errors
# runtime: 💥 call to abstract method foo
For more information, see this blog post:
Abstract singleton class methods are an abomination →
The blog post above discusses the problem and three alternatives to avoid using abstract singleton class methods. To summarize:
-
Declare an interface or abstract module with abstract instance methods, and
extend
that module onto a class. -
Use the above approach, but with
mixes_in_class_methods
, discussed below. -
Make the method
overridable
instead ofabstract
, effectively giving the method a default implementation.
There are also some runtime escape hatches to work around this problem. See Runtime reflection on abstract classes below.
A somewhat common pattern in Ruby is to use an included
hook to mix class
methods from a module onto the including class:
module M
module ClassMethods
def foo
self.bar
end
end
def self.included(other)
other.extend(ClassMethods)
end
end
class A
include M
end
# runtime error as `bar` is not defined on A
A.bar
This is hard to statically analyze, as it involves looking into the body of the
self.included
method, which might have arbitrary computation. As a compromise,
Sorbet provides a new construct: mixes_in_class_methods
. At runtime, it
behaves as if we'd defined self.included
like above, but will declare to srb
statically what module is being extended.
We can update our previous example to use mixes_in_class_methods
, which lets
Sorbet catch the runtime error about bar
not being defined on A
:
# typed: true
module M
extend T::Helpers
interface!
module ClassMethods
extend T::Sig
extend T::Helpers
abstract!
sig {void}
def foo
bar
end
sig {abstract.void}
def bar; end
end
mixes_in_class_methods(ClassMethods)
end
class A # error: Missing definition for abstract method
include M
extend T::Sig
sig {override.void}
def self.bar; end
end
# Sorbet knows that `foo` is a class method on `A`
A.foo
We can also call mixes_in_class_methods
with multiple modules to mix in more
methods. Some Ruby modules mixin more than one module as class methods when they
are included, and some modules mixin class methods but also include other
modules that mixin in their own class modules. In these cases, you will need to
declare multiple modules in the mixes_in_class_methods
call or make multiple
mixes_in_class_methods
calls.
For a more comprehensive resource on how mixes_in_class_methods
builds on
existing Ruby inheritance features, see this blog post:
Inheritance in Ruby, in pictures →
From time to time, it's useful to be able to ask whether a class or module object is abstract at runtime.
This can be done with
sig {params(mod: Module).void}
def example(mod)
if T::AbstractUtils.abstract_module?(mod)
puts "#{mod} is abstract"
else
puts "#{mod} is concrete"
end
end
Note that in general, having to ask whether a module is abstract is a code
smell. There is usually a way to reorganize the code such that calling
abstract_module?
isn't needed. In particular, this happens most frequently
from the use of modules with
abstract singleton class methods (abstract
self.
methods), and the fix is to stop using abstract singleton class methods.
Here's an example:
# typed: true
# --- This is an example of what NOT to do ---
extend T::Sig
class AbstractFoo
extend T::Sig
extend T::Helpers
abstract!
sig {abstract.void}
def self.example; end
end
class Foo < AbstractFoo
sig {override.void}
def self.example
puts 'Foo#example'
end
end
sig {params(mod: T.class_of(AbstractFoo)).void}
def calls_example_bad(mod)
# even though there are no errors,
# the call to mod.example is NOT always safe!
# (see comments below)
mod.example
end
sig {params(mod: T.class_of(AbstractFoo)).void}
def calls_example_okay(mod)
if !T::AbstractUtils.abstract_module?(mod)
mod.example
end
end
calls_example_bad(Foo) # no errors
calls_example_bad(AbstractFoo) # no static error, BUT raises at runtime!
calls_example_okay(Foo) # no errors
calls_example_okay(AbstractFoo) # no errors, because of explicit check
In the example above, calls_example_bad
is bad because mod.example
is not
always okay to call, despite Sorbet reporting no errors. In particular,
calls_example_bad(AbstractFoo)
will raise an exception at runtime because
example
is an abstract method with no implementation.
An okay, but not great, fix for this is to call abstract_module?
before the
call to mod.example
, which is demonstrated in calls_example_okay
.
Most other languages simply do not allow defining abstract singleton class
methods (for example, static
methods in TypeScript, C++, Java, C#, and more
are not allowed to be abstract). For historical reasons attempting to make
migrating to Sorbet easier in existing Ruby codebases, Sorbet allows abstract
singleton class methods.
A better solution is to make an interface with abstract methods, and extend
that interface into a class:
# typed: true
extend T::Sig
module IFoo
extend T::Sig
extend T::Helpers
abstract!
sig {abstract.void}
def example; end
end
class Foo
extend T::Sig
extend IFoo
sig {override.void}
def self.example
puts 'Foo#example'
end
end
sig {params(mod: IFoo).void}
def calls_example_good(mod)
# call to mod.example is always safe
mod.example
end
calls_example_good(Foo) # no errors
calls_example_good(IFoo) # doesn't type check
In this example, unlike before, we have a module IFoo
with an abstract
instance method, instead of a class AbstractFoo
with an abstract singleton
class method. This module is then extend
'ed into class Foo
to implement the
interface.
This fixes all of our problems:
- We no longer need to use
abstract_module?
to check whethermod
is abstract. - Sorbet statically rejects
calls_example_good(IFoo)
(intuitively: becauseIFoo.example
is not a method that even exists).
Another benefit is that now we have an explicit interface that can be documented
and implemented by any class, not just subclasses of AbstractFoo
.
-
Sorbet has more ways to check overriding than just whether an abstract method is implemented in a child. See this doc to learn about the ways to declare what kinds of overriding should be allowed.
-
Abstract classes and interfaces are frequently used with sealed classes to recreate a sort of "algebraic data type" in Sorbet.