Skip to content

Latest commit

 

History

History
311 lines (230 loc) · 9.38 KB

final.md

File metadata and controls

311 lines (230 loc) · 9.38 KB
id title sidebar_label
final
Final Methods, Classes, and Modules
Final Methods & Classes

Sorbet supports marking methods, classes, and modules "final", which limits how they can be overridden and extended, making some patterns easier to reason about and depend on.

In this doc we'll answer:

  • What does it mean for a method to be final?
  • What about final classes and modules?
  • What are some use cases for final method and classes?

Before we get into the in-depth details, here's a full snippet that demonstrates the syntax for all these features at once:

# typed: true
require 'sorbet-runtime'

# (0) One-time setup for final:
T::Configuration.enable_final_checks_on_hooks

class A
  extend T::Sig      # (Brings `sig` into scope)
  extend T::Helpers  # (Brings `final!` into scope)

  final!             # (1) Final class (can't be subclassed)

  sig(:final) {void} # (2) Final method (can't be overridden / redefined)
  def foo; end
end

Final methods

Final methods can't be overridden or redefined. This is a powerful guarantee: it means that inheritance can't affect what code will be run when calling a method on a class.

For example, it might be a good idea to mark methods that acquire a lock to a shared resource as final so that the class can't be subclassed and tampered with to avoid acquiring the locks. Marking these methods final reduces the scope of correctness the original author has to worry about.

To start using final, there's some one-time, per-project setup:

  1. Call T::Configuration.enable_final_checks_on_hooks at least once.

Once that's done, making a method final is straightforward:

  1. Use the syntax sig(:final) above a method to declare it final:

Here's a full example:

require 'sorbet-runtime'
# (1) Call this once per project, ideally right after `require 'sorbet-runtime'`
T::Configuration.enable_final_checks_on_hooks

module HasFinalMethod
  extend T::Sig

  # (2) The special `sig(:final)` syntax declares this method final:
  sig(:final) {void}
  def foo; end
end

Final methods must be defined exactly once on their enclosing module or class, and can never be overridden in a subclass or module. For example, here are things that are errors:

Note: Some of the features of final methods are only implemented in the runtime system. Support for these checks in the static system is planned for the future.

T::Configuration.enable_final_checks_on_hooks

class Parent
  extend T::Sig
  sig(:final) {void}
  def foo; end

  def foo; end # error: Redefining final method
end

class Child < Parent
  def foo; end # error: Overriding final method
end

Note in particular that stubbing a method is considered a method redefinition in the Ruby runtime, and is therefore not allowed on final methods. This is a feature, and is unlikely to change:

class MyFeature
  extend T::Sig
  sig(:final) {returns(T::Boolean)}
  def self.enabled; false; end
end

# error: Redefining final method
MyFeature.stubs(:enabled).returns(true)

Final classes and modules

In the same spirit as final methods, final classes cannot be subclassed, and final modules cannot be included or extended. But more than that, every method in a final class or module must be made into a final method.

It might seem redundant to require final classes to mark all methods final too, ("How could a method be overridden in a subclass if the act of subclassing is prohibited?") but the answer is (like many) that this protects against untyped code. This guarantees that untyped or ignored code can't redefine methods at runtime in a class that is marked final! statically.

Final classes are good for "plain old data" classes, as well as classes or modules that are meant be no more than namespaces for functions (i.e., those which don't want to have to worry about being affected by inheritance).

As mentioned above, to start using final, there's some one-time, per-project setup:

  1. Call T::Configuration.enable_final_checks_on_hooks at least once.

And once that's done, making a class or module final is straightforward:

  1. Add extend T::Helpers to the class (or module) body.
  2. Call final! at the top-level of the class (or module).

Here's a full example:

require 'sorbet-runtime'
# (1) Call this once per project, ideally right after `require 'sorbet-runtime'`
T::Configuration.enable_final_checks_on_hooks

class FinalParentClass
  # (2) Bring `final!` into scope:
  extend T::Helpers

  # (3) Use `final!` to declare this class final
  final!

  # (4) If there are any methods in this class, they must be final
  sig(:final) {void}
  def foo; end
end

# (5) Final classes can't be subclassed:
class ChildClass < FinalParentClass; end # error!

module FinalModule
  extend T::Helpers
  final!
end

class MixesInFinalModule
  # (6) Final modules can't be included or extended:
  include FinalModule # error!
  extend  FinalModule # error!
end

A note on syntax

Note: This section is rather technical and is not relevant to the question of "how do I use final methods and classes?"

The syntax for final methods is different from the syntax for things like abstract methods:

# These attributes are inside the block:
sig {overridable.void}
sig {override.void}
sig {abstract.void}

# But this one is outside the block:
sig(:final) {void}

The reason for this difference is that this gives us stronger runtime guarantees. In general, any Ruby method might be overridden at any time, with no warning. So the absence of an overridable or abstract attribute on a method signature does not guarantee that a method is never overridden. Given these circumstances, it's fine for override / abstract checks to be done lazily, because the stakes for eliding an error for them wrong is relatively low.

But for final methods, the stakes are higher for missing an error. We aspire to have Sorbet's type annotations be strong enough to one day enable code to run faster. In particular, known final methods can be made to execute much faster than otherwise, because calling the method shouldn't need to do complex virtual dispatch. Also, final methods often must be final from a correctness standpoint (recall our earlier example of methods which acquire locks).

Thus, we've taken special care to make sure final methods can't be tampered with, even in the runtime. The sig(:final) syntax is an artifact of this implementation (method signatures are usually lazily evaluated, to avoid circular constant dependencies and make code load faster).

By moving the final attribute outside the block, sorbet-runtime can learn that a method is final without having to force the block to execute.

More on T::Configuration.enable_final_checks_on_hooks

Some runtime final checks can only be checked by installing global monkey patches. Users who want 100% confidence that final methods and classes behave like described in this doc will want to opt into these checks. But for example libraries or other users may not want to enable these monkey patches for interoperability concerns.

Specifically, calling enable_final_checks_on_hooks will install runtime hooks on Module#included, Module#extended, and Class#inherited.

If these checks are enabled, any classes or modules that define their own included, extended, or inherited hooks should take care to always call super!

If these checks are not enabled, then some but not all of the runtime checks for final will be run. For instance, the following example violates the requirements of final, but the violation is not reported at runtime:

module M
  extend T::Sig
  sig(:final) {returns(Integer)}
  def foo; 1; end
end

class C
  include M

  def foo; 2; end
end

puts C.new.foo

At runtime, this does not raise and prints 2, showing that a final method has been overridden. This is why we strongly recommend calling T::Configuration.enable_final_checks_on_hooks before using final.

Known static limitations

Some of the guarantees of final are only implemented at runtime. This section aims to document those which are most likely to be encountered in normal usage:

  1. When a method is redefined with matching arguments, as in:

    # typed: true
    T::Configuration.enable_final_checks_on_hooks
    
    module Bad
      extend T::Sig
      sig(:final) {void}
      def foo; end
      def foo; end # runtime-only error: Redefining final method
    end
  2. When a method is overridden via including two conflicting modules, as in:

    # typed: true
    T::Configuration.enable_final_checks_on_hooks
    
    module A
      extend T::Sig
      sig(:final) {void}
      def foo; end
    end
    
    module B
      extend T::Sig
      sig(:final) {void}
      def foo; end
    end
    
    module Bad
      include A
      include B # runtime-only error: Overriding final method from `A`
    end

What's next?

  • Sealed Classes and Modules

    Sealed classes are similar to final classes in that they restrict who's allowed to subclass a given class, but they can be used to enforce slightly different guarantees.

  • Abstract Classes and Interfaces

    Marking methods as abstract and requiring child classes to implement them is a powerful tool for code organization and correctness. Learn more about Sorbet's support for abstract classes and interfaces.

  • Override Checking

    When override checking is desired, Sorbet has ways to declare that intent as well.