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).
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
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.
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.