Skip to content
Closed
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
218 changes: 218 additions & 0 deletions benchmark/patching_new_vs_allocate_benchmark.rb
Original file line number Diff line number Diff line change
@@ -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
20 changes: 10 additions & 10 deletions lib/type_toolkit/abstract_class.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions spec/abstract_class_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading