From eb3dbe3ab83cbd68ce4647be72582c6620673766 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 --- .rubocop.yml | 3 + README.md | 46 +- lib/type_toolkit.rb | 2 + lib/type_toolkit/abstract_class.rb | 116 +++++ lib/type_toolkit/ext/class.rb | 11 + lib/type_toolkit/has_abstract_methods.rb | 42 +- lib/type_toolkit/method_def_recorder.rb | 1 + spec/abstract_class_spec.rb | 604 +++++++++++++++++++++++ 8 files changed, 802 insertions(+), 23 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/.rubocop.yml b/.rubocop.yml index 76c00dd..8056af6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -14,5 +14,8 @@ Naming/FileName: # This file matches RuboCop naming conventions, like `rubocop-rails`, `rubocop-sorbet`, etc. - lib/rubocop-type_toolkit.rb +Style/EmptyElse: + AllowComments: true + Style/Semicolon: AllowAsExpressionSeparator: true diff --git a/README.md b/README.md index 39f312b..a91d6bc 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Unimplemented abstract methods cannot be called, and the Type Toolkit runtime wi ```ruby class EmailNotifier include Notifier - + # Oops, forgot to implement `#send_notification`! end @@ -113,6 +113,50 @@ 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! + + #: -> 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..7627336 --- /dev/null +++ b/lib/type_toolkit/abstract_class.rb @@ -0,0 +1,116 @@ +# typed: true +# frozen_string_literal: true + +require "type_toolkit/dsl" +require "type_toolkit/method_def_recorder" +require "type_toolkit/has_abstract_methods" +require "type_toolkit/abstract_method_receiver" + +module TypeToolkit + class << self + #: (Module[top]) -> void + def make_abstract!(mod) + case mod + when Class + if mod.singleton_class.method_defined?(:__type_toolkit_private_original_new_impl, false) + raise AlreadyDeclaredAbstractError, "#{mod.inspect} is already declared abstract" + end + + if mod.singleton_class? + raise NotImplementedError, "Declaring `abstract!` from a singleton class is not supported yet." + end + + if TypeToolkit::AbstractClass > mod.singleton_class # Check if AbstractClass was already extended up in mod's ancestor chain. + raise NotImplementedError, "Declaring a subclass of an abstract class as abstract is not supported yet." + end + + # We need to save the original implementation of `new`, so we can restore it on the subclasses later. + mod.singleton_class.alias_method(:__type_toolkit_private_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?(:__type_toolkit_private_original_new_impl) # This is true for the abstract classes themselves, and false for their subclasses. + raise CannotInstantiateAbstractClassError, "#{inspect} 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: + if subclass.singleton_class.method_defined?(:__type_toolkit_private_original_new_impl) + # 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. + + if AbstractClass == subclass.singleton_class.instance_method(:new).owner + # The raising `new` implementation is still in place, so we need to restore the original implementation we stashed away. + subclass.singleton_class.alias_method(:new, :__type_toolkit_private_original_new_impl) + else + # The parent class defined its own `new` after being declared `abstract!`. + # We just inherit that implementation without needing to do anything. + end + + # 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(:__type_toolkit_private_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 + + # Raised when you attempt to call `abstract!` twice on the exact same class. + class AlreadyDeclaredAbstractError < Exception # rubocop:disable Lint/InheritException + end +end diff --git a/lib/type_toolkit/ext/class.rb b/lib/type_toolkit/ext/class.rb new file mode 100644 index 0000000..1ddf2ec --- /dev/null +++ b/lib/type_toolkit/ext/class.rb @@ -0,0 +1,11 @@ +# typed: strict +# frozen_string_literal: true + +require "type_toolkit/abstract_class" + +class Class + #: -> void + 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..2618d91 100644 --- a/lib/type_toolkit/has_abstract_methods.rb +++ b/lib/type_toolkit/has_abstract_methods.rb @@ -22,27 +22,20 @@ 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 include_super + result = result&.dup # We might modify it in the loop, so make a copy first. - 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 + ancestors.each do |m| + methods = m.instance_variable_get(:@__abstract_methods) + if methods&.any? + if result + result.merge(methods) + else + result = methods.dup + end + end end end @@ -76,9 +69,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/lib/type_toolkit/method_def_recorder.rb b/lib/type_toolkit/method_def_recorder.rb index 27d04b6..3199468 100644 --- a/lib/type_toolkit/method_def_recorder.rb +++ b/lib/type_toolkit/method_def_recorder.rb @@ -42,6 +42,7 @@ def method_added(m) # @override #: (Symbol) -> void def singleton_method_added(m) + # This hook gets called about itself, but we don't care about it https://bugs.ruby-lang.org/issues/12131 return super if m == :singleton_method_added is_singleton_method = true diff --git a/spec/abstract_class_spec.rb b/spec/abstract_class_spec.rb new file mode 100644 index 0000000..181249c --- /dev/null +++ b/spec/abstract_class_spec.rb @@ -0,0 +1,604 @@ +# frozen_string_literal: true + +require "spec_helper" + +module TypeToolkit + class AbstractClassSpec < Minitest::Spec + # + # ┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ + # ╎ AbstractClass ╎ + # └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ + # ↑ ↑ ↑ + # │ │ │ + # │ │ │ + # ┌╌╌╌╌╌╌╌╌╌┐ ┌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ ╔════╧═════╗ + # ╎ NonImpl ╎ ╎ PartialImpl ╎ ║ FullImpl ║ + # └╌╌╌╌╌╌╌╌╌┘ └╌╌╌╌╌╌╌╌╌╌╌╌╌┘ ╚══════════╝ + # ↑ + # │ + # │ + # ╔═════════════════════╧════╗ + # ║ PartiallyInheritsItsImpl ║ + # ╚══════════════════════════╝ + + 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. + # Sorbet's static type-checker would report an error for this. + # It should either implement all the methods, or be marked `abstract!` itself. But at runtime, this is allowed. + class NonImpl < AbstractClass + end + + # Sorbet's static type-checker would report an error for this. + # It should either implement all the methods, or be marked `abstract!` itself. But at runtime, this is allowed. + class PartialImpl < AbstractClass + def m1 = "PartialImpl#m1" + # Does not implement `m2` + end + + class FullImpl < AbstractClass + def m1 = "FullImpl#m1" + def m2 = "FullImpl#m2" + end + + class PartiallyInheritsItsImpl < PartialImpl + def m2 = "PartiallyInheritsItsImpl#m2" + end + + describe "AbstractClass, an abstract class" do + it "cannot be instantiated" do + e = assert_raises(CannotInstantiateAbstractClassError) { AbstractClass.new } + + assert_equal "TypeToolkit::AbstractClassSpec::AbstractClass is declared as abstract; it cannot be instantiated", e.message + end + + it "can still be allocated via `.allocate`" do + # Look, if you call `allocate`, you're on your own. We'll probably eventually make this raise an error. + x = AbstractClass.allocate + assert_instance_of AbstractClass, x + 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 "NonImpl, 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 .__type_toolkit_private_original_new_impl" do + refute_respond_to @class, :__type_toolkit_private_original_new_impl + assert_raises(NoMethodError) { @class.__type_toolkit_private_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 "PartialImpl, 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 "FullImpl, 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 + + describe "PartiallyInheritsItsImpl, 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 "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 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 + + describe ".abstract!" do + it "raises an error if called twice on the same class" do + test_case = self + + Class.new do + abstract! + + test_case.assert_raises(AlreadyDeclaredAbstractError) do + abstract! + end + end + end + + it "raises an error when attempting to mark the subclass as Abstract" do + test_case = self + + Class.new(FullImpl) do + test_case.assert_raises(NotImplementedError) do + abstract! + end + end + end + end + + describe "Anonymous abstract classes" do + it "raises with a message that uses `inspect`" do + cls = Class.new do + abstract! + end + + e = assert_raises(CannotInstantiateAbstractClassError) { cls.new } + assert_match(/# is declared as abstract; it cannot be instantiated/, e.message) + end + end + + describe "An abstract class with an inherited hook that doesn't call super" do + it "leads to a broken subclass that can't be instantiated" do + # https://github.com/Shopify/type_toolkit/issues/45 + + abstract_class = Class.new do + abstract! + + class << self + def inherited(_subclass) # rubocop:disable Lint/MissingSuper + # Intentionally doesn't call super, to test what happens when we have a misbehaved `inherited` hook. + end + end + + abstract def m; end + end + + implementation = Class.new(abstract_class) do + def m; end + end + + assert_raises(CannotInstantiateAbstractClassError) { implementation.new } + end + end + + class OverridesNewAndAllocate < 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 + + # Overriding `.allocate` is exceptionally rare, but still, let's not break it. + def allocate + instance = super + instance.instance_variable_set(:@custom_allocate_was_called, true) + instance + end + end + + def initialize(arg, kwarg:, &block) + @custom_initialize_was_called = true + @arg = arg + @kwarg = kwarg + @block = block + super() + end + + class TestSubclass < OverridesNewAndAllocate; end + end + + describe "A subclass that overrides `.new` and `.allocate`" do + describe "calling .new" do + it "calls the overridden implementation of `.new` and `#initialize`" do + block = -> { "example" } + arg = "positional" + kwarg = "keyword" + x = OverridesNewAndAllocate::TestSubclass.new(arg, kwarg:, &block) + + assert_instance_of OverridesNewAndAllocate::TestSubclass, 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 + + it "calls the Class#allocate implementation, not the overridden one" do + # `Class#new` method could be approximated as the Ruby below, except the `allocate` call is always statically + # dispatched to `Class#allocate`. So if `allocate` was overridden, it *won't* be called. + # + # class Class + # def new(*args, **kwargs, &block) + # instance = allocate # this allocate call is *not* dynamically dispatched! + # instance.initialize(*args, **kwargs, &block) + # end + # end + # https://github.com/ruby/ruby/blob/a8cb7292c6790d12a72000c1e19e62f05ea63f6a/object.c#L2370 + + x = OverridesNewAndAllocate::TestSubclass.new("arg", kwarg: "kwarg") + + assert_instance_of OverridesNewAndAllocate::TestSubclass, x + + # Precondition: let's confirm our `new` and `initialize` overrides were called. + assert_equal true, x.instance_variable_get(:@custom_new_was_called) + assert_equal true, x.instance_variable_get(:@custom_initialize_was_called) + + refute x.instance_variable_defined?(:@custom_allocate_was_called) + end + end + + describe "calling .allocate" do + it "calls the overridden `.allocate`" do + x = OverridesNewAndAllocate::TestSubclass.allocate + assert_instance_of OverridesNewAndAllocate::TestSubclass, x + assert_equal true, x.instance_variable_get(:@custom_allocate_was_called) + end + end + end + + class CustomNewAfterAbstract + abstract! + + class << self + def new(...) + instance = super + instance.instance_variable_set(:@parent_new_was_called, true) + instance + end + end + end + + class CustomNewBeforeAbstract + class << self + def new(...) + instance = super + instance.instance_variable_set(:@parent_new_was_called, true) + instance + end + end + + abstract! + end + + class InheritsNewDefinedBefore < CustomNewBeforeAbstract; end + + describe "CustomNewAfterAbstract, An abstract class that overrides `.new` after `abstract!`" do + it "still cannot be instantiated directly" do + assert_raises(CannotInstantiateAbstractClassError) { CustomNewAfterAbstract.new } + end + + it "runs the parent's `.new` when a subclass is instantiated" do + concrete_subclass = Class.new(CustomNewAfterAbstract) + + instance = concrete_subclass.new + assert_equal true, instance.instance_variable_get(:@parent_new_was_called) + end + end + + describe "CustomNewBeforeAbstract, An abstract class that overrides `.new` before `abstract!`" do + it "still cannot be instantiated directly" do + assert_raises(CannotInstantiateAbstractClassError) { CustomNewBeforeAbstract.new } + end + + it "runs the parent's `.new` when a subclass is instantiated" do + instance = InheritsNewDefinedBefore.new + + assert_equal true, instance.instance_variable_get(:@parent_new_was_called) + end + end + + describe "Abstract class methods" do + it "are not yet supported" do + test_context = self + + Class.new do + abstract! + + test_context.assert_raises(NotImplementedError) do + abstract def self.abstract_class_method; end # rubocop:disable Style/ClassMethodsDefinitions + end + end + end + end + + describe "Abstract singleton classes" do + it "are not yet supported" do + test_context = self + + Class.new do + singleton_class.class_eval do # Like `class << self`, but lets us access `test_context`. + test_context.assert_raises(NotImplementedError) do + abstract! + end + end + end + end + end + end +end From 4dfac159dbd084743df66e1d5eb53f5bfbea048b 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 | 141 ++++++++++++++++++++++++++++++++ 1 file changed, 141 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..a449177 --- /dev/null +++ b/benchmark/abstract_class_new.rb @@ -0,0 +1,141 @@ +# typed: ignore +# frozen_string_literal: true + +# Benchmark the time it takes to instantiate a subclass of an abstract class + +############################################# Results ############################################# +# +# ruby 4.0.0 (2025-12-25 revision 553f1675f3) +PRISM [arm64-darwin23] +# +# Time to instantiate a subclass of an abstract class +# | | Interpreter | YJIT | +# |-------------------|-------------------------:|------------------------:| +# | sorbet-runtime | 118.95 ns | 97.93 ns | +# | type_toolkit | (3.32x faster) 35.82 ns | (4.63x faster) 21.14 ns | +# +# Time to instantiate a subclass of an abstract class with a custom implementation of `new` +# | | Interpreter | YJIT | +# |-------------------|-------------------------:|------------------------:| +# | sorbet-runtime | 140.45 ns | 119.00 ns | +# | type_toolkit | (1.17x faster) 119.89 ns | (1.40x faster) 84.75 ns | +# +#################################################################################################### + +require "bundler" +Bundler.setup(:default, :benchmark) +Bundler.require(:benchmark) + +# Intentionally not requiring "type_toolkit/ext/class", so we don't monkey-patch in our `Class#abstract!`. +# If we did, Sorbet runtime's `abstract!` would call it (since delegates up the chain), and break things. +require "type_toolkit/abstract_class" + +# 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 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 + + 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:, suite:) + + x.report("sorbet-runtime".rjust(width)) do |times| + i = 0 + while (i += 1) < times + SorbetRuntimeDemo::Child.new + end + end + + x.report("type_toolkit".rjust(width)) do |times| + i = 0 + while (i += 1) < times + TypeKitDemo::Child.new + end + end + + x.compare!(order: :baseline) + 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("sorbet-runtime".rjust(width)) do |times| + i = 0 + while (i += 1) < times + SorbetRuntimeDemo::Child_OverridesNew.new + end + end + + x.report("type_toolkit".rjust(width)) do |times| + i = 0 + while (i += 1) < times + TypeKitDemo::Child_OverridesNew.new + end + end + + x.compare!(order: :baseline) + end +end