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/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 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..7518d18 --- /dev/null +++ b/lib/type_toolkit/abstract_class.rb @@ -0,0 +1,93 @@ +# 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(:__type_toolkit_private_original_new_impl, :new) + + 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 + + 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, "#{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: + 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. + + subclass.singleton_class.alias_method(:new, :__type_toolkit_private_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(:__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 +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..cefcb03 --- /dev/null +++ b/lib/type_toolkit/ext/class.rb @@ -0,0 +1,9 @@ +# typed: strict +# frozen_string_literal: true + +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..889b40a 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&.dup #: 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/lib/type_toolkit/interface.rb b/lib/type_toolkit/interface.rb index a548f8b..6caff7e 100644 --- a/lib/type_toolkit/interface.rb +++ b/lib/type_toolkit/interface.rb @@ -26,10 +26,38 @@ def make_interface!(mod) module Interface #: (Module[top]) -> void def included(target_module) + install_onto(target_module) + end + + #: (Module[top]) -> void + def extended(target_module) + install_onto(target_module.singleton_class) + end + + private + + #: (Module[top]) -> void + def install_onto(target_module) # Including/extending a module is idempotent, so we don't have to worry these were already included/extended. # Potentially abstract methods will be called on instances of `self`, so we need the `method_missing` hooks. - target_module.include(TypeToolkit::AbstractInstanceMethodReceiver) + if target_module.singleton_class? + # Handle cases like: + # + # class C + # class << self + # include TheInterface + # end + # end + target_module.include(TypeToolkit::AbstractClassMethodReceiver) + else + # Handle the usual cases like: + # + # class C + # include TheInterface + # end + target_module.include(TypeToolkit::AbstractInstanceMethodReceiver) + end # The `method_missing` hooks need to be able to look up the abstract methods (from the interface). target_module.extend(TypeToolkit::HasAbstractMethods) 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..4af792a --- /dev/null +++ b/spec/abstract_class_spec.rb @@ -0,0 +1,472 @@ +# 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 + + 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 subclasses of abstract parent classes" do + 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 + + 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" 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 + end +end diff --git a/spec/interface_spec.rb b/spec/interface_spec.rb index 24ddc54..0ee387b 100644 --- a/spec/interface_spec.rb +++ b/spec/interface_spec.rb @@ -401,5 +401,264 @@ def something_else; end end end end + + # A class that extends SimpleInterface, and implements some of its SimpleInterface's abstract methods via class methods. + class MetaPartialImpl + extend SimpleInterface + + class << self + def m1 = "MetaPartialImpl#m1" + # Does not provide an implementation for `m2` + end + end + + class MetaFullImpl + extend SimpleInterface + + class << self + def m1 = "MetaFullImpl.m1" + def m2 = "MetaFullImpl.m2" + end + end + + describe "MetaPartialImpl, A class whose singleton class partially implements the interface" do + describe "calling an implemented abstract method" do + it "calls the concrete implementation" do + assert_respond_to MetaPartialImpl, :m1 + assert_equal "MetaPartialImpl#m1", MetaPartialImpl.m1 + assert_equal "MetaPartialImpl#m1", MetaPartialImpl.method(:m1).call + refute_predicate MetaPartialImpl.method(:m1), :abstract? + refute_predicate MetaPartialImpl.method(:m1).unbind, :abstract? + end + end + + describe "calling an unimplemented abstract method" do + it "raises AbstractMethodNotImplementedError" do + assert_respond_to MetaPartialImpl, :m2 + + # Notice it's not `NoMethodError`, so we can give a better error message. + e = assert_abstract { MetaPartialImpl.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 class method `.m2` was never implemented.", e.message + + m2 = MetaPartialImpl.method(:m2) + assert_kind_of Method, m2 + assert_predicate m2, :abstract? + assert_predicate m2.unbind, :abstract? + end + end + + it "doesn't leak the abstract class methods as instance methods" do + refute_respond_to MetaPartialImpl.new, :m1 + refute_respond_to MetaPartialImpl.new, :m2 + end + + describe "calling a non-abstract method" do + it "calls the concrete implementation" do + assert_respond_to MetaPartialImpl, :inspect + assert_kind_of String, MetaPartialImpl.inspect + assert_kind_of String, MetaPartialImpl.method(:inspect).call + refute_predicate MetaPartialImpl.method(:inspect), :abstract? + refute_predicate MetaPartialImpl.method(:inspect).unbind, :abstract? + end + end + + describe ".abstract_method?" do + it "returns false for abstract methods that have been implemented" do + refute MetaPartialImpl.singleton_class.abstract_method?(:m1) + end + + it "returns true for abstract methods that have not been implemented" do + assert MetaPartialImpl.singleton_class.abstract_method?(:m2) + end + + it "returns false for non-abstract methods" do + refute MetaPartialImpl.singleton_class.abstract_method?(:inspect) + end + end + + describe ".abstract_method_declared?" do + it "is true for all abstract methods" do + assert MetaPartialImpl.singleton_class.abstract_method_declared?(:m1) # Even the one that's been implemented + assert MetaPartialImpl.singleton_class.abstract_method_declared?(:m2) + end + + it "is false for non-abstract methods" do + assert MetaPartialImpl.singleton_class.public_method_defined?(:inspect) # precondition + refute MetaPartialImpl.singleton_class.abstract_method_declared?(:inspect) + 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], MetaPartialImpl.singleton_class.declared_abstract_instance_methods + assert_equal [:m1, :m2], MetaPartialImpl.singleton_class.declared_abstract_instance_methods(true) + assert_equal [], MetaPartialImpl.singleton_class.declared_abstract_instance_methods(false) + end + end + + describe ".abstract_instance_methods" do + it "returns only unimplemented abstract methods" do + assert_equal [:m2], MetaPartialImpl.singleton_class.abstract_instance_methods + assert_equal [:m2], MetaPartialImpl.singleton_class.abstract_instance_methods(true) + assert_equal [], MetaPartialImpl.singleton_class.abstract_instance_methods(false) + end + end + end + + describe "MetaFullImpl, A class whose singleton class fully implements the interface" do + describe "calling an implemented abstract method" do + it "calls the concrete implementation" do + assert_respond_to MetaFullImpl, :m1 + assert_equal "MetaFullImpl.m1", MetaFullImpl.m1 + assert_equal "MetaFullImpl.m1", MetaFullImpl.method(:m1).call + refute_predicate MetaFullImpl.method(:m1), :abstract? + refute_predicate MetaFullImpl.method(:m1).unbind, :abstract? + + assert_respond_to MetaFullImpl, :m2 + assert_equal "MetaFullImpl.m2", MetaFullImpl.m2 + assert_equal "MetaFullImpl.m2", MetaFullImpl.method(:m2).call + refute_predicate MetaFullImpl.method(:m2), :abstract? + refute_predicate MetaFullImpl.method(:m2).unbind, :abstract? + end + end + + it "doesn't leak the abstract class methods as instance methods" do + refute_respond_to MetaFullImpl.new, :m1 + refute_respond_to MetaFullImpl.new, :m2 + end + + describe ".abstract_method?" do + it "returns false for abstract methods that have been implemented" do + refute MetaFullImpl.singleton_class.abstract_method?(:m1) + refute MetaFullImpl.singleton_class.abstract_method?(:m2) + end + + it "returns false for non-abstract methods" do + refute MetaFullImpl.singleton_class.abstract_method?(:inspect) + end + end + + describe ".abstract_method_declared?" do + it "returns true for all abstract methods" do + assert MetaFullImpl.singleton_class.abstract_method_declared?(:m1) + assert MetaFullImpl.singleton_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], MetaFullImpl.singleton_class.declared_abstract_instance_methods + assert_equal [:m1, :m2], MetaFullImpl.singleton_class.declared_abstract_instance_methods(true) + assert_equal [], MetaFullImpl.singleton_class.declared_abstract_instance_methods(false) + end + end + + describe ".abstract_instance_methods" do + it "returns only unimplemented abstract methods" do + assert_equal [], MetaFullImpl.singleton_class.abstract_instance_methods + assert_equal [], MetaFullImpl.singleton_class.abstract_instance_methods(true) + assert_equal [], MetaFullImpl.singleton_class.abstract_instance_methods(false) + end + end + end + + class MetaIncludesSimpleInterface + class << self + include SimpleInterface + + def m1 = "MetaIncludesSimpleInterface#m1" + # Does not provide an implementation for `m2` + end + end + + describe "MetaIncludesSimpleInterface, A class whose singleton class partially implements the interface" do + describe "calling an implemented abstract method" do + it "calls the concrete implementation" do + assert_respond_to MetaIncludesSimpleInterface, :m1 + assert_equal "MetaIncludesSimpleInterface#m1", MetaIncludesSimpleInterface.m1 + assert_equal "MetaIncludesSimpleInterface#m1", MetaIncludesSimpleInterface.method(:m1).call + refute_predicate MetaIncludesSimpleInterface.method(:m1), :abstract? + refute_predicate MetaIncludesSimpleInterface.method(:m1).unbind, :abstract? + end + end + + describe "calling an unimplemented abstract method" do + it "raises AbstractMethodNotImplementedError" do + assert_respond_to MetaIncludesSimpleInterface, :m2 + + # Notice it's not `NoMethodError`, so we can give a better error message. + e = assert_abstract { MetaIncludesSimpleInterface.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 class method `.m2` was never implemented.", e.message + + m2 = MetaIncludesSimpleInterface.method(:m2) + assert_kind_of Method, m2 + assert_predicate m2, :abstract? + assert_predicate m2.unbind, :abstract? + end + end + + it "doesn't leak the abstract class methods as instance methods" do + refute_respond_to MetaIncludesSimpleInterface.new, :m1 + refute_respond_to MetaIncludesSimpleInterface.new, :m2 + end + + describe "calling a non-abstract method" do + it "calls the concrete implementation" do + assert_respond_to MetaIncludesSimpleInterface, :inspect + assert_kind_of String, MetaIncludesSimpleInterface.inspect + assert_kind_of String, MetaIncludesSimpleInterface.method(:inspect).call + refute_predicate MetaIncludesSimpleInterface.method(:inspect), :abstract? + refute_predicate MetaIncludesSimpleInterface.method(:inspect).unbind, :abstract? + end + end + + describe ".abstract_method?" do + it "returns false for abstract methods that have been implemented" do + refute MetaIncludesSimpleInterface.singleton_class.abstract_method?(:m1) + end + + it "returns true for abstract methods that have not been implemented" do + assert MetaIncludesSimpleInterface.singleton_class.abstract_method?(:m2) + end + + it "returns false for non-abstract methods" do + refute MetaIncludesSimpleInterface.singleton_class.abstract_method?(:inspect) + end + end + + describe ".abstract_method_declared?" do + it "is true for all abstract methods" do + assert MetaIncludesSimpleInterface.singleton_class.abstract_method_declared?(:m1) # Even the one that's been implemented + assert MetaIncludesSimpleInterface.singleton_class.abstract_method_declared?(:m2) + end + + it "is false for non-abstract methods" do + assert MetaIncludesSimpleInterface.singleton_class.public_method_defined?(:inspect) # precondition + refute MetaIncludesSimpleInterface.singleton_class.abstract_method_declared?(:inspect) + 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], MetaIncludesSimpleInterface.singleton_class.declared_abstract_instance_methods + assert_equal [:m1, :m2], MetaIncludesSimpleInterface.singleton_class.declared_abstract_instance_methods(true) + assert_equal [], MetaIncludesSimpleInterface.singleton_class.declared_abstract_instance_methods(false) + end + end + + describe ".abstract_instance_methods" do + it "returns only unimplemented abstract methods" do + assert_equal [:m2], MetaIncludesSimpleInterface.singleton_class.abstract_instance_methods + assert_equal [:m2], MetaIncludesSimpleInterface.singleton_class.abstract_instance_methods(true) + assert_equal [], MetaIncludesSimpleInterface.singleton_class.abstract_instance_methods(false) + end + end + end end end