Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,51 @@ EmailNotifier.new.send_notification("Hello, world!") # ❌ TypeToolkit::Abstract
# => Abstract method #send_notification was never implemented.
```

### Abstract Classes

Abstract classes are partially-implemented classes that leave some abstract methods to be implemented by subclasses.

Example:

```ruby
class Widget
abstract!

# @override
#: -> void
abstract def draw; end
end

class Button < Widget
# @override
#: -> void
def draw
puts "Drawing a button"
end
end

Button.new.draw # ✅
# => Drawing a button
```

Just like abstract methods on interfaces, the Type Toolkit runtime will raise an error if you try to call an unimplemented abstract method on an abstract class:

```ruby
class TextBox < Widget
# Oops, forgot to implement `#draw`!
end

TextBox.new.draw # ❌ TypeToolkit::AbstractMethodNotImplementedError
# => Abstract method #draw was never implemented.
```

Abstract classes are incomplete, so it wouldn't make sense to instantiate them directly. The Type Toolkit runtime will raise an error if you try to do so:

```ruby
Widget.new # ❌ TypeToolkit::CannotInstantiateAbstractClassError
# => Widget is declared as abstract; it cannot be instantiated
```

## Guiding Principles

### Blazingly fast™
Expand Down
150 changes: 150 additions & 0 deletions benchmark/abstract_class_new.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# typed: ignore
# frozen_string_literal: true

# Benchmark the time it takes to instantiate a subclass of an abstract class

############################################# Results #############################################
#
# ruby 3.4.3 (2025-04-14 revision d0b7e5b6a0) +PRISM [arm64-darwin23]
#
# Time to instantiate a subclass of an abstract class
# | | Interpreter | YJIT |
# |-------------------|-------------------------:|-------------------------:|
# | sorbet-runtime | (2.29x slower) 109.28 ns | (2.49x slower) 90.09 ns |
# | type_toolkit | 47.80 ns | 36.23 ns |
#
# Time to instantiate a subclass of an abstract class with a custom implementation of `new`
# | | Interpreter | YJIT |
# |-------------------|-------------------------:|-------------------------:|
# | sorbet-runtime | (1.10x slower) 132.95 ns | (1.32x slower) 104.45 ns |
# | type_toolkit | 121.12 ns | 79.33 ns |
#
####################################################################################################

require "bundler"
Bundler.require(:default, :benchmark)

require "type_toolkit"

# This benchmark has pretty high variance (it depends on the GC's allocation patterns),
# so we run it for a longer time to get a more stable result.
warmup = 10
time = 30

width = ["type_toolkit", "sorbet-runtime", "manual delegation"].max_by(&:length).length

# module PreventConflictingAbstractPatch
# def abstract!
# # Prevent Sorbet's definition of `abstract!` from calling the TypeToolkit implementation of `abstract!`
# return if singleton_class.include?(T::Helpers)

# super
# end
# end

# Class.prepend(PreventConflictingAbstractPatch)

module TypeKitDemo
class Parent
TypeToolkit.make_abstract!(self)
end

class Child < Parent; end

class Child_OverridesNew < Parent
def self.new(...) = super
end
end

module SorbetRuntimeDemo
class Parent
extend T::Helpers

# binding.irb
abstract!
end

class Child < Parent; end

class Child_OverridesNew < Parent
def self.new(...) = super
end
end

# Run GC before each job run.
#
# Inspired by https://www.omniref.com/ruby/2.2.1/symbols/Benchmark/bm?#annotation=4095926&line=182
class GCSuite
def warming(*)
GC.start
end

def running(*)
GC.start
end

def warmup_stats(*)
end

def add_report(*)
end
end

suite = GCSuite.new

[:interpreter, :yjit].each do |mode|
if mode == :yjit
puts <<~MSG


================================================================================
Enabling YJIT...
================================================================================


MSG
RubyVM::YJIT.enable
end

puts "\nBenchmark the time to instantiate a subclass of an abstract class..."
Benchmark.ips do |x|
x.config(warmup:, time:)

x.report("type_toolkit".rjust(width)) do |times|
i = 0
while (i += 1) < times
TypeKitDemo::Child.new
end
end

x.report("sorbet-runtime".rjust(width)) do |times|
i = 0
while (i += 1) < times
SorbetRuntimeDemo::Child.new
end
end

x.compare!
end

puts "\nBenchmark the time to instantiate a subclass of an abstract class with a custom implementation of `new`..."
Benchmark.ips do |x|
x.config(warmup:, time:, suite:)

x.report("type_toolkit".rjust(width)) do |times|
i = 0
while (i += 1) < times
TypeKitDemo::Child_OverridesNew.new
end
end

x.report("sorbet-runtime".rjust(width)) do |times|
i = 0
while (i += 1) < times
SorbetRuntimeDemo::Child_OverridesNew.new
end
end

x.compare!
end
end
2 changes: 2 additions & 0 deletions lib/type_toolkit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
# frozen_string_literal: true

require_relative "type_toolkit/version"
require_relative "type_toolkit/abstract_class"
require_relative "type_toolkit/interface"
require_relative "type_toolkit/ext/class"
require_relative "type_toolkit/ext/method"
require_relative "type_toolkit/ext/module"
require_relative "type_toolkit/ext/nil_assertions"
Expand Down
92 changes: 92 additions & 0 deletions lib/type_toolkit/abstract_class.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# typed: true
# frozen_string_literal: true

module TypeToolkit
class << self
#: (Module[top]) -> void
def make_abstract!(mod)
case mod
when Class
# We need to save the original implementation of `new`, so we can restore it on the subclasses later.
mod.singleton_class.alias_method(:__original_new_impl, :new)

mod.extend(TypeToolkit::AbstractClass)
mod.extend(TypeToolkit::DSL)
mod.extend(TypeToolkit::MethodDefRecorder)
mod.extend(TypeToolkit::HasAbstractMethods)

mod.include(TypeToolkit::AbstractInstanceMethodReceiver)
when Module
raise NotImplementedError, "Abstract modules are not implemented yet."
end
end
end

# This module is extended onto every class marked `abstract!`.
# Abstract classes can't be instantiated, only subclassed.
# They should contain abstract methods, which must be implemented by subclasses.
#
# Example:
#
# class Widget
# abstract!
#
# #: -> void
# abstract def draw; end
# end
#
# class Button < Widget
# # @override
# #: -> void
# def draw
# ...
# end
# end
#
# class TextField < Widget
# # @override
# #: -> void
# def draw
# ...
# end
# end
#
module AbstractClass
# An override of `new` which prevents instantiation of the class.
# This needs to be overridden again in subclasses, to restore the real `.new` implementation.
def new(...) # :nodoc:
#: self as Class[top]

if respond_to?(:__original_new_impl) # This is true for the abstract classes themselves, and false for their subclasses.
raise CannotInstantiateAbstractClassError, "#{self.class.name} is declared as abstract; it cannot be instantiated"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's a bug here: self inside AbstractClass#new is the abstract class itself (e.g. Widget), so self.class is Class, not the abstract class. The error message would print "Class is declared as abstract" instead of the actual class name.

I wonder if we should use name instead?

Suggested change
raise CannotInstantiateAbstractClassError, "#{self.class.name} is declared as abstract; it cannot be instantiated"
raise CannotInstantiateAbstractClassError, "#{name} is declared as abstract; it cannot be instantiated"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True. I have to add tests for abstract class methods, and I'll verify their name is correct in the message.

end

# This is hit in the uncommon case where a subclass of an abstract class overrides `.new` and calls `super`.
super
end

# Restores the original `.new` implementation for the direct subclasses of an abstract class.
#: (Class[AbstractClass]) -> void
def inherited(subclass) # :nodoc:
superclass = subclass.singleton_class.superclass #: as !nil

if superclass.include?(TypeToolkit::AbstractClass) &&
!superclass.singleton_class.include?(TypeToolkit::AbstractClass)
# We only need to restore the original `.new` implementation for the direct subclasses of the abstract class.
# That's then inherited by the indirect subclasses.

subclass.singleton_class.alias_method(:new, :__original_new_impl)

# We don't need a reference to the original implementation anymore,
# so let's undef it to limit namespace pollution.
subclass.singleton_class.undef_method(:__original_new_impl)
end

super
end
end

# Raised when an attempt is made to instantiate an abstract class.
class CannotInstantiateAbstractClassError < Exception # rubocop:disable Lint/InheritException
end
end
40 changes: 36 additions & 4 deletions lib/type_toolkit/abstract_method_receiver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,50 @@
module TypeToolkit
# Raised when a call is made to an abstract method that never had a real implementation.
class AbstractMethodNotImplementedError < Exception # rubocop:disable Lint/InheritException
def initialize(method_name:)
def initialize(method_name:, is_class_method:)
qualifier = is_class_method ? "class " : ""
prefix = is_class_method ? "." : "#"
# Do not rely on this message content! Its content is subject to change.
super("Abstract method `##{method_name}` was never implemented.")
super("Abstract #{qualifier}method `#{prefix}#{method_name}` was never implemented.")
end
end

# This module is included on a class whose instances can be receivers of calls to abstract methods.
# This module is included on a class that can be the receiver of calls to abstract singleton ("class") methods.
#
# Since abstract methods are removed at runtime (see `TypeToolkit::DSL#abstract`), attempting to call
# an unimplemented abstract method would usually raise a `NoMethodError`.
# This module uses `method_missing` to raise `AbstractMethodNotImplementedError` instead.
# @requires_ancestor: Kernel
module AbstractClassMethodReceiver
# This method_missing is hit when calling a potentially abstract method on a class that contains abstract methods
# E.g. TheClass.maybe_abstract_method
# (Symbol, ...) -> untyped
def method_missing(method_name, ...)
sc = singleton_class #: as HasAbstractMethods

if sc.abstract_method_declared?(method_name)
raise AbstractMethodNotImplementedError.new(method_name:, is_class_method: true)
end

raise "This doesn't make sense" if sc.abstract_method?(method_name) # sanity check

super
end

#: (Symbol, ?bool) -> bool
def respond_to_missing?(method_name, include_private = false)
sc = singleton_class #: as HasAbstractMethods
sc.abstract_method_declared?(method_name) || super
end
end

# This module is included on a class whose instances can be receivers of calls to abstract methods.
#
# This is just like `AbstractClassMethodReceiver`, but with a performance optimization to call `self.class`
# instead of `singleton_class`.
# In theory we could just always use implementation from `AbstractClassMethodReceiver`,
# but touching the `singleton_class` would allocate the singleton class for every object that hits the `#method_missing` code path.
# @requires_ancestor: Kernel
module AbstractInstanceMethodReceiver
# This `#method_missing` is hit when calling a potentially abstract method on an instance
# E.g. TheClass.new.maybe_abstract_method
Expand All @@ -25,7 +57,7 @@ def method_missing(method_name, ...)
c = self.class #: as Class[top] & HasAbstractMethods

if c.abstract_method_declared?(method_name)
raise AbstractMethodNotImplementedError.new(method_name:)
raise AbstractMethodNotImplementedError.new(method_name:, is_class_method: false)
end

super
Expand Down
7 changes: 7 additions & 0 deletions lib/type_toolkit/ext/class.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class Class
def abstract!
TypeToolkit.make_abstract!(self)
end
end
Loading
Loading