From 61990f524fb939f4fdb00ac7b1f093656f2a3928 Mon Sep 17 00:00:00 2001 From: Alexander Momchilov Date: Thu, 5 Feb 2026 16:37:33 -0500 Subject: [PATCH 1/2] Implement abstract classes --- README.md | 45 ++ lib/type_toolkit.rb | 2 + lib/type_toolkit/abstract_class.rb | 92 ++++ lib/type_toolkit/abstract_method_receiver.rb | 40 +- lib/type_toolkit/ext/class.rb | 7 + lib/type_toolkit/has_abstract_methods.rb | 42 +- spec/abstract_class_spec.rb | 469 +++++++++++++++++++ 7 files changed, 670 insertions(+), 27 deletions(-) create mode 100644 lib/type_toolkit/abstract_class.rb create mode 100644 lib/type_toolkit/ext/class.rb create mode 100644 spec/abstract_class_spec.rb diff --git a/README.md b/README.md index 39f312b..dab741d 100644 --- a/README.md +++ b/README.md @@ -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™ diff --git a/lib/type_toolkit.rb b/lib/type_toolkit.rb index 1994c4a..89493dc 100644 --- a/lib/type_toolkit.rb +++ b/lib/type_toolkit.rb @@ -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" diff --git a/lib/type_toolkit/abstract_class.rb b/lib/type_toolkit/abstract_class.rb new file mode 100644 index 0000000..10037a6 --- /dev/null +++ b/lib/type_toolkit/abstract_class.rb @@ -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" + 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 diff --git a/lib/type_toolkit/abstract_method_receiver.rb b/lib/type_toolkit/abstract_method_receiver.rb index eab9238..a0ae8ee 100644 --- a/lib/type_toolkit/abstract_method_receiver.rb +++ b/lib/type_toolkit/abstract_method_receiver.rb @@ -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 @@ -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 diff --git a/lib/type_toolkit/ext/class.rb b/lib/type_toolkit/ext/class.rb new file mode 100644 index 0000000..4a14ffe --- /dev/null +++ b/lib/type_toolkit/ext/class.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Class + def abstract! + TypeToolkit.make_abstract!(self) + end +end diff --git a/lib/type_toolkit/has_abstract_methods.rb b/lib/type_toolkit/has_abstract_methods.rb index b4874b0..9e90f39 100644 --- a/lib/type_toolkit/has_abstract_methods.rb +++ b/lib/type_toolkit/has_abstract_methods.rb @@ -22,27 +22,18 @@ def __register_abstract_method(method_name) # :nodoc: def declared_abstract_instance_methods(include_super = true) #: self as HasAbstractMethods & Module[top] - result = @__abstract_methods + result = @__abstract_methods #: Set[Symbol]? - return result.to_a unless include_super - - if defined?(super) && (super_abstract_methods = super) - if result - result.merge(super_abstract_methods) - else - result = super_abstract_methods - end - end - - abstract_methods_in_interfaces = included_modules.flat_map do |m| - m.is_a?(HasAbstractMethods) ? m.declared_abstract_instance_methods : [] - end - - if abstract_methods_in_interfaces.any? - if result&.any? - result.merge(abstract_methods_in_interfaces) - else - result = abstract_methods_in_interfaces + if include_super + ancestors.each do |m| + methods = m.instance_variable_get(:@__abstract_methods) + if methods&.any? + if result + result.merge(methods) + else + result = methods + end + end end end @@ -76,9 +67,14 @@ def abstract_instance_methods(include_super = true) def abstract_method_declared?(method_name) #: self as Module[top] - @__abstract_methods&.include?(method_name) || - included_modules.any? { |m| m.is_a?(HasAbstractMethods) && m.abstract_method_declared?(method_name) } || - (defined?(super) && super) + # FIXME: Allocating the `ancestors` array is not great. + # I tried a recursive approach, but that didn't quite work. + # There is only one implementation of `abstract_method_declared?` in the ancestor chain, so there is no `super` to call. + # This method always checked the ivar of the current class, which might not be set. What we actually want is to + # walk up the ancestor chain, and check the ivar of each ancestor. + ancestors.any? do |m| + m.instance_variable_get(:@__abstract_methods)&.include?(method_name) + end end # Returns true if the given method is abstract, and has not been implemented. diff --git a/spec/abstract_class_spec.rb b/spec/abstract_class_spec.rb new file mode 100644 index 0000000..816668e --- /dev/null +++ b/spec/abstract_class_spec.rb @@ -0,0 +1,469 @@ +# frozen_string_literal: true + +require "spec_helper" + +module TypeToolkit + class AbstractClassSpec < Minitest::Spec + # A class that has some abstract methods. + # *Note* this is _not_ an abstract class. + class AbstractClass + abstract! + + abstract def m1; end + abstract def m2; end + + def concrete_method = "AbstractClass#concrete_method" + end + + # A class that does not implement any of `AbstractClass`'s abstract methods. + class NonImpl < AbstractClass + end + + class PartialImpl < AbstractClass + def m1 = "PartialImpl#m1" + # Does not implement `m2` + end + + class FullImpl < AbstractClass + def m1 = "FullImpl#m1" + def m2 = "FullImpl#m2" + end + + describe "An abstract class" do + it "cannot be instantiated" do + assert_raises(CannotInstantiateAbstractClassError) { AbstractClass.new } + end + + describe ".abstract_instance_methods" do + it "only contains the abstract methods" do + assert_equal [:m1, :m2], AbstractClass.abstract_instance_methods + assert_equal [:m1, :m2], AbstractClass.abstract_instance_methods(true) + assert_equal [:m1, :m2], AbstractClass.abstract_instance_methods(false) + end + end + + describe ".abstract_method?" do + it "returns true for abstract methods" do + assert AbstractClass.abstract_method?(:m1) + assert AbstractClass.abstract_method?(:m2) + end + + it "returns false for non-abstract methods" do + refute AbstractClass.abstract_method?(:concrete_method) + end + end + + describe ".abstract_method_declared?" do + it "returns true for abstract methods" do + assert AbstractClass.abstract_method_declared?(:m1) + assert AbstractClass.abstract_method_declared?(:m2) + end + + it "returns false for non-abstract methods" do + refute AbstractClass.abstract_method_declared?(:concrete_method) + end + end + end + + describe "A subclass that does not implement any abstract methods" do + before do + @class = NonImpl + end + + it "can be instantiated" do + # ...despite not implementing all the abstract methods. This matches sorbet runtime's behaviour. + # + # The Sorbet static typechecker ensures that when you subclass an abstract class, you must either: + # 1. Implement all of its abstract methods. + # 2. Mark the subclass as abstract! as well. + # + # Attempting to call actually any of the abstract methods will still raise, like usual. + refute_nil @class.new + end + + it "does not respond to .__original_new_impl" do + refute_respond_to @class, :__original_new_impl + assert_raises(NoMethodError) { @class.__original_new_impl } + end + + describe ".abstract_method?" do + it "returns true for abstract methods that have not been implemented" do + assert @class.abstract_method?(:m1) + assert @class.abstract_method?(:m2) + end + + it "returns false for non-abstract methods" do + refute @class.abstract_method?(:concrete_method) + end + end + + describe ".abstract_method_declared?" do + it "is true for all abstract methods" do + assert @class.abstract_method_declared?(:m1) + assert @class.abstract_method_declared?(:m2) + end + + it "is false for non-abstract methods" do + refute @class.abstract_method_declared?(:concrete_method) + end + end + + describe ".abstract_instance_methods" do + it "returns all abstract methods" do + assert_equal [:m1, :m2], @class.abstract_instance_methods + assert_equal [:m1, :m2], @class.abstract_instance_methods(true) + assert_equal [], @class.abstract_instance_methods(false) + end + end + end + + describe "A subclass that partially implements the abstract methods" do + before do + @class = PartialImpl + @x = PartialImpl.new + end + + describe "calling an implemented abstract method" do + it "calls the concrete implementation" do + assert_respond_to @x, :m1 + assert_equal "PartialImpl#m1", @x.m1 + assert_equal "PartialImpl#m1", @x.method(:m1).call + refute_predicate @x.method(:m1), :abstract? + refute_predicate @x.method(:m1).unbind, :abstract? + end + end + + describe "calling an unimplemented abstract method" do + it "raises AbstractMethodNotImplementedError" do + assert_respond_to @x, :m2 + + # Notice it's not `NoMethodError`, so we can give a better error message. + e = assert_abstract { @x.m2 } + + # Do not rely on this message content! Its content is subject to change! + # We only test it to ensure it's formatted correctly. + assert_equal "Abstract method `#m2` was never implemented.", e.message + + m2 = @x.method(:m2) + assert_kind_of Method, m2 + assert_predicate m2, :abstract? + assert_predicate m2.unbind, :abstract? + end + end + + describe "calling a non-abstract method" do + it "calls the concrete implementation" do + assert_respond_to @x, :inspect + assert_kind_of String, @x.inspect + assert_kind_of String, @x.method(:inspect).call + refute_predicate @x.method(:inspect), :abstract? + refute_predicate @x.method(:inspect).unbind, :abstract? + end + end + + describe ".abstract_method?" do + it "returns false for abstract methods that have been implemented" do + refute @class.abstract_method?(:m1) + end + + it "returns true for abstract methods that have not been implemented" do + assert @class.abstract_method?(:m2) + end + + it "returns false for non-abstract methods" do + refute @class.abstract_method?(:inspect) + end + + it "is not defined on instances of the class" do + refute_respond_to @x, :abstract_method? + end + end + + describe ".abstract_method_declared?" do + it "is true for all abstract methods" do + assert @class.abstract_method_declared?(:m1) # Even the one that's been implemented + assert @class.abstract_method_declared?(:m2) + end + + it "is false for non-abstract methods" do + refute @class.abstract_method_declared?(:inspect) + end + + it "is not defined on instances of the class" do + refute_respond_to @x, :abstract_method_declared? + end + end + + describe ".declared_abstract_instance_methods" do + it "returns all declared abstract methods, even those that have been implemented" do + assert_equal [:m1, :m2], @class.declared_abstract_instance_methods + assert_equal [:m1, :m2], @class.declared_abstract_instance_methods(true) + assert_equal [], @class.declared_abstract_instance_methods(false) + end + + it "is not defined on instances of the class" do + refute_respond_to @x, :declared_abstract_instance_methods + end + end + + describe ".abstract_instance_methods" do + it "returns only unimplemented abstract methods" do + assert_equal [:m2], @class.abstract_instance_methods + assert_equal [:m2], @class.abstract_instance_methods(true) + assert_equal [], @class.abstract_instance_methods(false) + end + + it "is not defined on instances of the class" do + refute_respond_to @x, :abstract_instance_methods + end + end + end + + describe "A subclass that fully implements the abstract methods" do + before do + @class = FullImpl + @x = FullImpl.new + end + + describe "calling an implemented abstract method" do + it "calls the concrete implementation" do + assert_respond_to @x, :m1 + assert_equal "FullImpl#m1", @x.m1 + assert_equal "FullImpl#m1", @x.method(:m1).call + refute_predicate @x.method(:m1), :abstract? + refute_predicate @x.method(:m1).unbind, :abstract? + + assert_respond_to @x, :m2 + assert_equal "FullImpl#m2", @x.m2 + assert_equal "FullImpl#m2", @x.method(:m2).call + refute_predicate @x.method(:m2), :abstract? + refute_predicate @x.method(:m2).unbind, :abstract? + end + end + + describe ".abstract_method?" do + it "returns false for abstract methods that have been implemented" do + refute @class.abstract_method?(:m1) + refute @class.abstract_method?(:m2) + end + + it "returns false for non-abstract methods" do + refute @class.abstract_method?(:inspect) + end + + it "is not defined on instances of the class" do + refute_respond_to @x, :abstract_method? + end + end + + describe ".abstract_method_declared?" do + it "returns true for all abstract methods" do + assert @class.abstract_method_declared?(:m1) + assert @class.abstract_method_declared?(:m2) + end + + it "is not defined on instances of the class" do + refute_respond_to @x, :abstract_method_declared? + end + end + + describe ".declared_abstract_instance_methods" do + it "returns all declared abstract methods, even those that have been implemented" do + assert_equal [:m1, :m2], @class.declared_abstract_instance_methods + assert_equal [:m1, :m2], @class.declared_abstract_instance_methods(true) + assert_equal [], @class.declared_abstract_instance_methods(false) + end + + it "is not defined on instances of the class" do + refute_respond_to @x, :declared_abstract_instance_methods + end + end + + describe ".abstract_instance_methods" do + it "returns only unimplemented abstract methods" do + assert_equal [], @class.abstract_instance_methods + assert_equal [], @class.abstract_instance_methods(true) + assert_equal [], @class.abstract_instance_methods(false) + end + + it "is not defined on instances of the class" do + refute_respond_to @x, :abstract_instance_methods + end + end + end + + class PartialParentClass + def m1 = "PartialParentClass#m1" + end + + class AbstractSubclass < PartialParentClass + abstract! + + abstract def m1; end + abstract def m2; end + end + + class PartiallyInheritsItsImpl < AbstractSubclass + def m2 = "PartiallyInheritsItsImpl#m2" + end + + describe "A subclass that fully implements the abstract methods, some via inheritance" do + before do + @class = PartiallyInheritsItsImpl + @x = PartiallyInheritsItsImpl.new + end + + describe "calling an abstract method with an inherited implementation" do + it "calls the inherited implementation" do + assert_respond_to @x, :m1 + assert_equal "PartialParentClass#m1", @x.m1 + assert_equal "PartialParentClass#m1", @x.method(:m1).call + refute_predicate @x.method(:m1), :abstract? + refute_predicate @x.method(:m1).unbind, :abstract? + end + end + + describe "calling an abstract method implemented by the subclass" do + it "calls the child implementation" do + assert_respond_to @x, :m2 + assert_equal "PartiallyInheritsItsImpl#m2", @x.m2 + assert_equal "PartiallyInheritsItsImpl#m2", @x.method(:m2).call + refute_predicate @x.method(:m2), :abstract? + refute_predicate @x.method(:m2).unbind, :abstract? + end + end + + describe ".abstract_method?" do + it "returns false for abstract methods that have been implemented" do + refute @class.abstract_method?(:m1) + refute @class.abstract_method?(:m2) + end + + it "returns false for non-abstract methods" do + refute @class.abstract_method?(:inspect) + end + end + + describe ".abstract_method_declared?" do + it "returns true for all abstract methods" do + assert @class.abstract_method_declared?(:m1) + assert @class.abstract_method_declared?(:m2) + end + end + + describe ".declared_abstract_instance_methods" do + it "returns all declared abstract methods, even those that have been implemented" do + assert_equal [:m1, :m2], @class.declared_abstract_instance_methods + assert_equal [:m1, :m2], @class.declared_abstract_instance_methods(true) + assert_equal [], @class.declared_abstract_instance_methods(false) + end + end + + describe ".abstract_instance_methods" do + it "returns only unimplemented abstract methods" do + assert_equal [], @class.abstract_instance_methods + assert_equal [], @class.abstract_instance_methods(true) + assert_equal [], @class.abstract_instance_methods(false) + end + end + end + + class OverridesNew < AbstractClass + # Overriding `.new` is pretty rare, but let's make sure we didn't break it. + class << self + def new(...) + instance = super + instance.instance_variable_set(:@custom_new_was_called, true) + instance + end + end + + def initialize(arg, kwarg:, &block) + @custom_initialize_was_called = true + @arg = arg + @kwarg = kwarg + @block = block + super() + end + end + + describe "A subclass that overrides .new" do + before do + @class = OverridesNew + end + + describe "calling .new" do + it "calls the overridden implementation of `.new` and `#initialize`" do + block = -> { "example" } + arg = "positional" + kwarg = "keyword" + x = OverridesNew.new(arg, kwarg:, &block) + + assert_instance_of OverridesNew, x + + assert_same arg, x.instance_variable_get(:@arg) + assert_same kwarg, x.instance_variable_get(:@kwarg) + assert_same block, x.instance_variable_get(:@block) + + assert_equal true, x.instance_variable_get(:@custom_new_was_called) + assert_equal true, x.instance_variable_get(:@custom_initialize_was_called) + end + end + + describe "calling .allocate" do + it "calls the normal implementation" do + x = OverridesNew.allocate + assert_instance_of OverridesNew, x + assert_nil x.instance_variable_get(:@custom_allocate_was_called) + end + end + end + + class OverridesAllocate < AbstractClass + class << self + # Overriding `.allocate` is exceptionally rare, but still, let's not break it. + def allocate + "custom allocator result" + end + end + + def initialize(arg, kwarg:, &block) + @custom_initialize_was_called = true + @arg = arg + @kwarg = kwarg + @block = block + super() + end + end + + describe "A subclass that overrides .allocate" do + before do + @class = OverridesAllocate + end + + describe "calling .new" do + it "calls the overridden implementation of `.allocate` and `#initialize`" do + block = -> { "example" } + arg = "positional" + kwarg = "keyword" + x = OverridesAllocate.new(arg, kwarg:, &block) + + assert_instance_of OverridesAllocate, x + + assert_same arg, x.instance_variable_get(:@arg) + assert_same kwarg, x.instance_variable_get(:@kwarg) + assert_same block, x.instance_variable_get(:@block) + + assert_equal true, x.instance_variable_get(:@custom_initialize_was_called) + end + end + + describe "calling .allocate" do + it "calls the overridden implementation of `.allocate`" do + x = OverridesAllocate.allocate + assert_equal "custom allocator result", x + end + end + end + end +end From 8b250d5c2588d1ef7078f351a5a6abe26afcef2f Mon Sep 17 00:00:00 2001 From: Alexander Momchilov Date: Mon, 23 Feb 2026 20:26:48 -0500 Subject: [PATCH 2/2] Benchmark abstract class instantiation --- benchmark/abstract_class_new.rb | 150 ++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 benchmark/abstract_class_new.rb diff --git a/benchmark/abstract_class_new.rb b/benchmark/abstract_class_new.rb new file mode 100644 index 0000000..fefc7f6 --- /dev/null +++ b/benchmark/abstract_class_new.rb @@ -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