-
Notifications
You must be signed in to change notification settings - Fork 4
Implement abstract classes #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Relying on this class Base
abstract!
def self.inherited(_subclass)
# no super
end
abstract def call; end
end
class Impl < Base
def call; end
end
Impl.new # raises CannotInstantiateAbstractClassErrorI wonder if we should install this as a prepended singleton hook, or otherwise wrap/chain the existing hook, and add a regression test so subclass instantiation doesn't depend on user hooks calling
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good find. I'll come back to this, because:
It's doable, but not calling For now I just added a test case that documents the broken behaviour. Tracked in #45 |
||
| 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| # typed: strict | ||
| # frozen_string_literal: true | ||
|
|
||
| require "type_toolkit/abstract_class" | ||
|
|
||
| class Class | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @amomchilov is it meant to be required standalone? require "type_toolkit/ext/class"like we do for require "type_toolkit/ext/nil_assertions"If yes 2 comments:
I'm not a fan of requiring files under require "type_toolkit/abstract"
require "type_toolkit/nil_assertions"
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See https://github.com/Shopify/type_toolkit#cherry-picking-features
|
||
| #: -> void | ||
| def abstract! | ||
| TypeToolkit.make_abstract!(self) | ||
| end | ||
| end | ||
Uh oh!
There was an error while loading. Please reload this page.