lib/memo_wise.rb in memo_wise-0.4.0 vs lib/memo_wise.rb in memo_wise-1.0.0

- old
+ new

@@ -1,7 +1,8 @@ # frozen_string_literal: true +require "memo_wise/internal_api" require "memo_wise/version" # MemoWise is the wise choice for memoization in Ruby. # # - **Q:** What is *memoization*? @@ -55,173 +56,29 @@ # :nocov: class_eval <<-END_OF_METHOD, __FILE__, __LINE__ + 1 # On Ruby 2.7 or greater: # # def initialize(...) - # MemoWise.create_memo_wise_state!(self) + # MemoWise::InternalAPI.create_memo_wise_state!(self) # super # end # # On Ruby 2.6 or lower: # # def initialize(*) - # MemoWise.create_memo_wise_state!(self) + # MemoWise::InternalAPI.create_memo_wise_state!(self) # super # end def initialize(#{all_args}) - MemoWise.create_memo_wise_state!(self) + MemoWise::InternalAPI.create_memo_wise_state!(self) super end END_OF_METHOD # @private # - # Determine whether `method` takes any *positional* args. - # - # These are the types of positional args: - # - # * *Required* -- ex: `def foo(a)` - # * *Optional* -- ex: `def foo(b=1)` - # * *Splatted* -- ex: `def foo(*c)` - # - # @param method [Method, UnboundMethod] - # Arguments of this method will be checked - # - # @return [Boolean] - # Return `true` if `method` accepts one or more positional arguments - # - # @example - # class Example - # def no_args - # end - # - # def position_arg(a) - # end - # end - # - # MemoWise.has_arg?(Example.instance_method(:no_args)) #=> false - # - # MemoWise.has_arg?(Example.instance_method(:position_arg)) #=> true - # - def self.has_arg?(method) # rubocop:disable Naming/PredicateName - method.parameters.any? do |(param, _)| - param == :req || param == :opt || param == :rest # rubocop:disable Style/MultipleComparison - end - end - - # @private - # - # Determine whether `method` takes any *keyword* args. - # - # These are the types of keyword args: - # - # * *Keyword Required* -- ex: `def foo(a:)` - # * *Keyword Optional* -- ex: `def foo(b: 1)` - # * *Keyword Splatted* -- ex: `def foo(**c)` - # - # @param method [Method, UnboundMethod] - # Arguments of this method will be checked - # - # @return [Boolean] - # Return `true` if `method` accepts one or more keyword arguments - # - # @example - # class Example - # def position_args(a, b=1) - # end - # - # def keyword_args(a:, b: 1) - # end - # end - # - # MemoWise.has_kwarg?(Example.instance_method(:position_args)) #=> false - # - # MemoWise.has_kwarg?(Example.instance_method(:keyword_args)) #=> true - # - def self.has_kwarg?(method) # rubocop:disable Naming/PredicateName - method.parameters.any? do |(param, _)| - param == :keyreq || param == :key || param == :keyrest # rubocop:disable Style/MultipleComparison - end - end - - # @private - # - # Returns visibility of an instance method defined on a class. - # - # @param klass [Class] - # Class in which to find the visibility of an existing *instance* method. - # - # @param method_name [Symbol] - # Name of existing *instance* method find the visibility of. - # - # @return [:private, :protected, :public] - # Visibility of existing instance method of the class. - # - # @raise ArgumentError - # Raises `ArgumentError` unless `method_name` is a `Symbol` corresponding - # to an existing **instance** method defined on `klass`. - # - def self.method_visibility(klass, method_name) - if klass.private_method_defined?(method_name) - :private - elsif klass.protected_method_defined?(method_name) - :protected - elsif klass.public_method_defined?(method_name) - :public - else - raise ArgumentError, "#{method_name.inspect} must be a method on #{klass}" - end - end - - # @private - # - # Find the original class for which the given class is the corresponding - # "singleton class". - # - # See https://stackoverflow.com/questions/54531270/retrieve-a-ruby-object-from-its-singleton-class - # - # @param klass [Class] - # Singleton class to find the original class of - # - # @return Class - # Original class for which `klass` is the singleton class. - # - # @raise ArgumentError - # Raises if `klass` is not a singleton class. - # - def self.original_class_from_singleton(klass) - unless klass.singleton_class? - raise ArgumentError, "Must be a singleton class: #{klass.inspect}" - end - - # Search ObjectSpace - # * 1:1 relationship of singleton class to original class is documented - # * Performance concern: searches all Class objects - # But, only runs at load time - ObjectSpace.each_object(Class).find { |cls| cls.singleton_class == klass } - end - - # @private - # - # Create initial mutable state to store memoized values if it doesn't - # already exist - # - # @param [Object] obj - # Object in which to create mutable state to store future memoized values - # - # @return [Object] the passed-in obj - def self.create_memo_wise_state!(obj) - unless obj.instance_variables.include?(:@_memo_wise) - obj.instance_variable_set(:@_memo_wise, {}) - end - - obj - end - - # @private - # # Private setup method, called automatically by `prepend MemoWise` in a class. # # @param target [Class] # The `Class` into to prepend the MemoWise methods e.g. `memo_wise` # @@ -246,34 +103,34 @@ # we still need to be able to access MemoWise's instance variable. Despite # Ruby documentation indicating otherwise, `Class#new` does not call # `Class#allocate`, so we need to override both. # def allocate - MemoWise.create_memo_wise_state!(super) + MemoWise::InternalAPI.create_memo_wise_state!(super) end # NOTE: See YARD docs for {.memo_wise} directly below this method! def memo_wise(method_name_or_hash) # rubocop:disable Metrics/PerceivedComplexity klass = self case method_name_or_hash when Symbol method_name = method_name_or_hash if klass.singleton_class? - MemoWise.create_memo_wise_state!( - MemoWise.original_class_from_singleton(klass) + MemoWise::InternalAPI.create_memo_wise_state!( + MemoWise::InternalAPI.original_class_from_singleton(klass) ) end when Hash unless method_name_or_hash.keys == [:self] raise ArgumentError, "`:self` is the only key allowed in memo_wise" end method_name = method_name_or_hash[:self] - MemoWise.create_memo_wise_state!(self) + MemoWise::InternalAPI.create_memo_wise_state!(self) # In Ruby, "class methods" are implemented as normal instance methods # on the "singleton class" of a given Class object, found via # {Class#singleton_class}. # See: https://medium.com/@leo_hetsch/demystifying-singleton-classes-in-ruby-caf3fa4c9d91 @@ -282,14 +139,16 @@ unless method_name.is_a?(Symbol) raise ArgumentError, "#{method_name.inspect} must be a Symbol" end - visibility = MemoWise.method_visibility(klass, method_name) + api = MemoWise::InternalAPI.new(klass) + visibility = api.method_visibility(method_name) + original_memo_wised_name = + MemoWise::InternalAPI.original_memo_wised_name(method_name) method = klass.instance_method(method_name) - original_memo_wised_name = :"_memo_wise_original_#{method_name}" klass.send(:alias_method, original_memo_wised_name, method_name) klass.send(:private, original_memo_wised_name) # Zero-arg methods can use simpler/more performant logic because the # hash key is just the method name. @@ -306,26 +165,43 @@ @_memo_wise[:#{method_name}] = #{original_memo_wised_name} end end END_OF_METHOD else - # If our method has arguments, we need to separate out our handling of - # normal args vs. keyword args due to the changes in Ruby 3. - # See: <link> - # By only including logic for *args or **kwargs when they are used in - # the method, we can avoid allocating unnecessary arrays and hashes. - has_arg = MemoWise.has_arg?(method) - - if has_arg && MemoWise.has_kwarg?(method) - args_str = "(*args, **kwargs)" - fetch_key = "[args, kwargs].freeze" - elsif has_arg - args_str = "(*args)" - fetch_key = "args" + if MemoWise::InternalAPI.has_only_required_args?(method) + args_str = method.parameters.map do |type, name| + "#{name}#{':' if type == :keyreq}" + end.join(", ") + args_str = "(#{args_str})" + call_str = method.parameters.map do |type, name| + type == :req ? name : "#{name}: #{name}" + end.join(", ") + call_str = "(#{call_str})" + fetch_key = method.parameters.map(&:last) + fetch_key = if fetch_key.size > 1 + "[#{fetch_key.join(', ')}].freeze" + else + fetch_key.first.to_s + end else - args_str = "(**kwargs)" - fetch_key = "kwargs" + # If our method has arguments, we need to separate out our handling + # of normal args vs. keyword args due to the changes in Ruby 3. + # See: <link> + # By only including logic for *args, **kwargs when they are used in + # the method, we can avoid allocating unnecessary arrays and hashes. + has_arg = MemoWise::InternalAPI.has_arg?(method) + + if has_arg && MemoWise::InternalAPI.has_kwarg?(method) + args_str = "(*args, **kwargs)" + fetch_key = "[args, kwargs].freeze" + elsif has_arg + args_str = "(*args)" + fetch_key = "args" + else + args_str = "(**kwargs)" + fetch_key = "kwargs" + end end # Note that we don't need to freeze args before using it as a hash key # because Ruby always copies argument arrays when splatted. klass.module_eval <<-END_OF_METHOD, __FILE__, __LINE__ + 1 @@ -341,19 +217,66 @@ def #{method_name}#{args_str} hash = @_memo_wise.fetch(:#{method_name}) do @_memo_wise[:#{method_name}] = {} end hash.fetch(#{fetch_key}) do - hash[#{fetch_key}] = #{original_memo_wised_name}#{args_str} + hash[#{fetch_key}] = #{original_memo_wised_name}#{call_str || args_str} end end END_OF_METHOD end klass.send(visibility, method_name) end end + + unless target.singleton_class? + # Create class methods to implement .preset_memo_wise and .reset_memo_wise + %i[preset_memo_wise reset_memo_wise].each do |method_name| + # Like calling 'module_function', but original method stays public + target.define_singleton_method( + method_name, + MemoWise.instance_method(method_name) + ) + end + + # Override [Module#instance_method](https://ruby-doc.org/core-3.0.0/Module.html#method-i-instance_method) + # to proxy the original `UnboundMethod#parameters` results. We want the + # parameters to reflect the original method in order to support callers + # who want to use Ruby reflection to process the method parameters, + # because our overridden `#initialize` method, and in some cases the + # generated memoized methods, will have a generic set of parameters + # (`...` or `*args, **kwargs`), making reflection on method parameters + # useless without this. + def target.instance_method(symbol) + # TODO: Extract this method naming pattern + original_memo_wised_name = :"_memo_wise_original_#{symbol}" + + super.tap do |curr_method| + # Start with calling the original `instance_method` on `symbol`, + # which returns an `UnboundMethod`. + # IF it was replaced by MemoWise, + # THEN find the original method's parameters, and modify current + # `UnboundMethod#parameters` to return them. + if symbol == :initialize + # For `#initialize` - because `prepend MemoWise` overrides the same + # method in the module ancestors, use `UnboundMethod#super_method` + # to find the original method. + orig_method = curr_method.super_method + orig_params = orig_method.parameters + curr_method.define_singleton_method(:parameters) { orig_params } + elsif private_method_defined?(original_memo_wised_name) + # For any memoized method - because the original method was renamed, + # call the original `instance_method` again to find the renamed + # original method. + orig_method = super(original_memo_wised_name) + orig_params = orig_method.parameters + curr_method.define_singleton_method(:parameters) { orig_params } + end + end + end + end end ## # @!method self.memo_wise(method_name) # Implements memoization for the given method name. @@ -390,10 +313,72 @@ # # ex.method_to_memoize("b") #=> 2 # ex.method_to_memoize("b") #=> 2 ## + ## + # @!method self.preset_memo_wise(method_name, *args, **kwargs) + # Implementation of {#preset_memo_wise} for class methods. + # + # @example + # class Example + # prepend MemoWise + # + # def self.method_called_times + # @method_called_times + # end + # + # def self.method_to_preset + # @method_called_times = (@method_called_times || 0) + 1 + # "A" + # end + # memo_wise self: :method_to_preset + # end + # + # Example.preset_memo_wise(:method_to_preset) { "B" } + # + # Example.method_to_preset #=> "B" + # + # Example.method_called_times #=> nil + ## + + # rubocop:disable Layout/LineLength + ## + # @!method self.reset_memo_wise(method_name = nil, *args, **kwargs) + # Implementation of {#reset_memo_wise} for class methods. + # + # @example + # class Example + # prepend MemoWise + # + # def self.method_to_reset(x) + # @method_called_times = (@method_called_times || 0) + 1 + # end + # memo_wise self: :method_to_reset + # end + # + # Example.method_to_reset("a") #=> 1 + # Example.method_to_reset("a") #=> 1 + # Example.method_to_reset("b") #=> 2 + # Example.method_to_reset("b") #=> 2 + # + # Example.reset_memo_wise(:method_to_reset, "a") # reset "method + args" mode + # + # Example.method_to_reset("a") #=> 3 + # Example.method_to_reset("a") #=> 3 + # Example.method_to_reset("b") #=> 2 + # Example.method_to_reset("b") #=> 2 + # + # Example.reset_memo_wise(:method_to_reset) # reset "method" (any args) mode + # + # Example.method_to_reset("a") #=> 4 + # Example.method_to_reset("b") #=> 5 + # + # Example.reset_memo_wise # reset "all methods" mode + ## + # rubocop:enable Layout/LineLength + # Presets the memoized result for the given method to the result of the given # block. # # This method is for situations where the caller *already* has the result of # an expensive method call, and wants to preset that result as memoized for @@ -441,26 +426,25 @@ # ex.method_to_preset #=> "B" # # ex.method_called_times #=> nil # def preset_memo_wise(method_name, *args, **kwargs) - validate_memo_wised!(method_name) - unless block_given? raise ArgumentError, "Pass a block as the value to preset for #{method_name}, #{args}" end - validate_params!(method_name, args) + api = MemoWise::InternalAPI.new(self) + api.validate_memo_wised!(method_name) if method(method_name).arity.zero? @_memo_wise[method_name] = yield else hash = @_memo_wise.fetch(method_name) do @_memo_wise[method_name] = {} end - hash[fetch_key(method_name, *args, **kwargs)] = yield + hash[api.fetch_key(method_name, *args, **kwargs)] = yield end end # Resets memoized results of a given method, or all methods. # @@ -508,19 +492,17 @@ # # ex = Example.new # # ex.method_to_reset("a") #=> 1 # ex.method_to_reset("a") #=> 1 - # # ex.method_to_reset("b") #=> 2 # ex.method_to_reset("b") #=> 2 # # ex.reset_memo_wise(:method_to_reset, "a") # reset "method + args" mode # # ex.method_to_reset("a") #=> 3 # ex.method_to_reset("a") #=> 3 - # # ex.method_to_reset("b") #=> 2 # ex.method_to_reset("b") #=> 2 # # ex.reset_memo_wise(:method_to_reset) # reset "method" (any args) mode # @@ -548,42 +530,16 @@ unless respond_to?(method_name, true) raise ArgumentError, "#{method_name} is not a defined method" end - validate_memo_wised!(method_name) + api = MemoWise::InternalAPI.new(self) + api.validate_memo_wised!(method_name) if args.empty? && kwargs.empty? @_memo_wise.delete(method_name) else - @_memo_wise[method_name]&.delete(fetch_key(method_name, *args, **kwargs)) + @_memo_wise[method_name]&. + delete(api.fetch_key(method_name, *args, **kwargs)) end end - - private - - # Validates that {.memo_wise} has already been called on `method_name`. - def validate_memo_wised!(method_name) - original_memo_wised_name = :"_memo_wise_original_#{method_name}" - - unless self.class.private_method_defined?(original_memo_wised_name) - raise ArgumentError, "#{method_name} is not a memo_wised method" - end - end - - # Returns arguments key to lookup memoized results for given `method_name`. - def fetch_key(method_name, *args, **kwargs) - method = self.class.instance_method(method_name) - has_arg = MemoWise.has_arg?(method) - - if has_arg && MemoWise.has_kwarg?(method) - [args, kwargs].freeze - elsif has_arg - args - else - kwargs - end - end - - # TODO: Parameter validation for presetting values - def validate_params!(method_name, args); end end