diff --git a/benchmark/patching_new_vs_allocate_benchmark.rb b/benchmark/patching_new_vs_allocate_benchmark.rb new file mode 100644 index 0000000..3043778 --- /dev/null +++ b/benchmark/patching_new_vs_allocate_benchmark.rb @@ -0,0 +1,218 @@ +# typed: ignore +# frozen_string_literal: true + +# This benchmarks compares the cost of patching `new` vs `allocate`, in order to pick the best +# technique to raise an error when instantiating an abstract class. + +############################################# Results ############################################# +# +# ruby 3.4.3 (2025-04-14 revision d0b7e5b6a0) +PRISM [arm64-darwin23] +# +# | | Interpretter | YJIT | +# |--------------------|-------------:|----------:| +# | PatchingNew_NoArgs | 147.36 ns | 108.85 ns | +# | PatchingNew | 152.52 ns | 117.19 ns | +# | PatchingAllocate | 128.18 ns | 88.85 ns | +# +#################################################################################################### + +require "bundler" +Bundler.require(:default, :benchmark) + +require "type_toolkit" +require "type_toolkit/ext" + +# The forwarding of arguments might skew results, so let's compare patching a 0-arity `new` +# for a more equal comparison to to `allocate` (which is already 0-arity). +module PatchingNew_NoArgs + class Parent + def self.new = Parent.equal?(self) ? raise("Cannot instantiate abstract class") : super + end + + class Child < Parent + def self.new = super + end +end + +module PatchingNew + class Parent + def self.new(...) = Parent.equal?(self) ? raise("Cannot instantiate abstract class") : super + end + + class Child < Parent + def self.new(...) = super + end +end + +module PatchingNew_NoArgs_Reimpl + class Parent + def self.new = Parent.equal?(self) ? raise("Cannot instantiate abstract class") : allocate.send(:initialize) + end + + class Child < Parent + def self.new = super + end +end + +module PatchingNew_Reimpl + class Parent + def self.new(...) = Parent.equal?(self) ? raise("Cannot instantiate abstract class") : allocate.send(:initialize, ...) + end + + class Child < Parent + def self.new(...) = super + end +end + +module ReimplViaModule_NoArgs + module NewReimpl + def new(...) + allocate.send(:initialize, ...) + end + end + + class Parent + def self.new(...) = raise("Cannot instantiate abstract class") + end + + class Child < Parent + extend NewReimpl + + def self.new(...) = super + end +end + +module ReimplViaModule + module NewReimpl + def new(...) + allocate.send(:initialize, ...) + end + end + + class Parent + def self.new(...) = raise("Cannot instantiate abstract class") + end + + class Child < Parent + extend NewReimpl + + def self.new(...) = super + end +end + + +module PatchingAllocate + class Parent + def self.allocate = Parent.equal?(self) ? raise("Cannot instantiate abstract class") : super + end + + class Child < Parent + def self.allocate = super + end +end + +# Enable and start GC before each job run. Disable GC afterwards. +# +# Inspired by https://www.omniref.com/ruby/2.2.1/symbols/Benchmark/bm?#annotation=4095926&line=182 +class GCSuite + def warming(*) + run_gc + end + + def running(*) + run_gc + end + + def warmup_stats(*) + end + + def add_report(*) + end + + private + + def run_gc + GC.enable + GC.start + GC.disable + end +end + +suite = GCSuite.new + +[:interpretter, :yjit].each do |mode| + if mode == :yjit + puts <<~MSG + + + ================================================================================ + Enabling YJIT... + ================================================================================ + + + MSG + RubyVM::YJIT.enable + end + + warmup = 5 + time = 10 + suite = GCSuite.new + + width = ["PatchingNew_NoArgs_Reimpl", "ReimplViaModule_NoArgs", "PatchingAllocate"].max_by(&:length).length + + puts "Benchmark the performance of calling the concrete implementation directly..." + Benchmark.ips do |x| + x.config(warmup:, time:, suite:) + + x.report("PatchingNew_NoArgs".rjust(width)) do |times| + i = 0 + while (i += 1) < times + PatchingNew_NoArgs::Child.new + end + end + + x.report("PatchingNew".rjust(width)) do |times| + i = 0 + while (i += 1) < times + PatchingNew::Child.new + end + end + + x.report("PatchingNew_NoArgs_Reimpl".rjust(width)) do |times| + i = 0 + while (i += 1) < times + PatchingNew_NoArgs_Reimpl::Child.new + end + end + + x.report("PatchingNew_Reimpl".rjust(width)) do |times| + i = 0 + while (i += 1) < times + PatchingNew_Reimpl::Child.new + end + end + + x.report("ReimplViaModule_NoArgs".rjust(width)) do |times| + i = 0 + while (i += 1) < times + ReimplViaModule_NoArgs::Child.new + end + end + + x.report("ReimplViaModule".rjust(width)) do |times| + i = 0 + while (i += 1) < times + ReimplViaModule::Child.new + end + end + + x.report("PatchingAllocate".rjust(width)) do |times| + i = 0 + while (i += 1) < times + PatchingAllocate::Child.allocate + end + end + + x.compare! + end +end diff --git a/lib/type_toolkit/abstract_class.rb b/lib/type_toolkit/abstract_class.rb index 10037a6..c564ea8 100644 --- a/lib/type_toolkit/abstract_class.rb +++ b/lib/type_toolkit/abstract_class.rb @@ -7,8 +7,8 @@ class << self 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) + # We need to save the original implementation of `allocate`, so we can restore it on the subclasses later. + mod.singleton_class.alias_method(:__original_allocate_impl, :allocate) mod.extend(TypeToolkit::AbstractClass) mod.extend(TypeToolkit::DSL) @@ -52,16 +52,16 @@ def make_abstract!(mod) # 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: + # An override of `allocate` which prevents instantiation of the class. + # This needs to be overridden again in subclasses, to restore the real `.allocate` implementation. + def allocate # :nodoc: #: self as Class[top] - if respond_to?(:__original_new_impl) # This is true for the abstract classes themselves, and false for their subclasses. + if respond_to?(:__original_allocate_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`. + # This is hit in exceptionally uncommon case where a subclass of an abstract class overrides `.allocate`. super end @@ -72,14 +72,14 @@ def inherited(subclass) # :nodoc: 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. + # We only need to restore the original `.allocate` 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) + subclass.singleton_class.alias_method(:allocate, :__original_allocate_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) + subclass.singleton_class.undef_method(:__original_allocate_impl) end super diff --git a/spec/abstract_class_spec.rb b/spec/abstract_class_spec.rb index 816668e..454b24b 100644 --- a/spec/abstract_class_spec.rb +++ b/spec/abstract_class_spec.rb @@ -81,9 +81,9 @@ def m2 = "FullImpl#m2" 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 } + it "does not respond to .__original_allocate_impl" do + refute_respond_to @class, :__original_allocate_impl + assert_raises(NoMethodError) { @class.__original_allocate_impl } end describe ".abstract_method?" do