memo_wise 1.0.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,114 +10,138 @@ module MemoWise
10
10
  #
11
11
  # @return [Object] the passed-in obj
12
12
  def self.create_memo_wise_state!(obj)
13
- unless obj.instance_variables.include?(:@_memo_wise)
14
- obj.instance_variable_set(:@_memo_wise, {})
13
+ # `@_memo_wise` stores memoized results of method calls. The structure is
14
+ # slightly different for different types of methods. It looks like:
15
+ # [
16
+ # :memoized_result, # For method 0 (which takes no arguments)
17
+ # { arg1 => :memoized_result, ... }, # For method 1 (which takes an argument)
18
+ # { [arg1, arg2] => :memoized_result, ... } # For method 2 (which takes multiple arguments)
19
+ # ]
20
+ # This is a faster alternative to:
21
+ # {
22
+ # zero_arg_method_name: :memoized_result,
23
+ # single_arg_method_name: { arg1 => :memoized_result, ... },
24
+ #
25
+ # # Surprisingly, this is faster than a single top-level hash key of: [:multi_arg_method_name, arg1, arg2]
26
+ # multi_arg_method_name: { [arg1, arg2] => :memoized_result, ... }
27
+ # }
28
+ # because we can give each method its own array index at load time and
29
+ # perform that array lookup more quickly than a hash lookup by method
30
+ # name.
31
+ obj.instance_variable_set(:@_memo_wise, []) unless obj.instance_variable_defined?(:@_memo_wise)
32
+
33
+ # For zero-arity methods, memoized values are stored in the `@_memo_wise`
34
+ # array. Arrays do not differentiate between "unset" and "set to nil" and
35
+ # so to handle this case we need another array to store sentinels and
36
+ # store `true` at indexes for which a zero-arity method has been memoized.
37
+ # `@_memo_wise_sentinels` looks like:
38
+ # [
39
+ # true, # A zero-arity method's result has been memoized
40
+ # nil, # A zero-arity method's result has not been memoized
41
+ # nil, # A one-arity method will always correspond to `nil` here
42
+ # ...
43
+ # ]
44
+ # NOTE: Because `@_memo_wise` stores memoized values for more than just
45
+ # zero-arity methods, the `@_memo_wise_sentinels` array can end up being
46
+ # sparse (see above), even when all methods' memoized values have been
47
+ # stored. If this becomes an issue we could store a separate index for
48
+ # zero-arity methods to make every element in `@_memo_wise_sentinels`
49
+ # correspond to a zero-arity method.
50
+ # NOTE: Surprisingly, lookups on an array of `true` and `nil` values
51
+ # appear to outperform even bitwise operators on integers (as of Ruby
52
+ # 3.0.2), allowing us to avoid more complex sentinel structures.
53
+ unless obj.instance_variable_defined?(:@_memo_wise_sentinels)
54
+ obj.instance_variable_set(:@_memo_wise_sentinels, [])
15
55
  end
16
56
 
17
57
  obj
18
58
  end
19
59
 
20
- # Determine whether `method` takes any *positional* args.
21
- #
22
- # These are the types of positional args:
23
- #
24
- # * *Required* -- ex: `def foo(a)`
25
- # * *Optional* -- ex: `def foo(b=1)`
26
- # * *Splatted* -- ex: `def foo(*c)`
27
- #
28
- # @param method [Method, UnboundMethod]
29
- # Arguments of this method will be checked
30
- #
31
- # @return [Boolean]
32
- # Return `true` if `method` accepts one or more positional arguments
33
- #
34
- # @example
35
- # class Example
36
- # def no_args
37
- # end
38
- #
39
- # def position_arg(a)
40
- # end
41
- # end
42
- #
43
- # MemoWise::InternalAPI.
44
- # has_arg?(Example.instance_method(:no_args)) #=> false
45
- #
46
- # MemoWise::InternalAPI.
47
- # has_arg?(Example.instance_method(:position_arg)) #=> true
48
- #
49
- def self.has_arg?(method) # rubocop:disable Naming/PredicateName
50
- method.parameters.any? do |param, _|
51
- param == :req || param == :opt || param == :rest # rubocop:disable Style/MultipleComparison
60
+ NONE = :none
61
+ ONE_REQUIRED_POSITIONAL = :one_required_positional
62
+ ONE_REQUIRED_KEYWORD = :one_required_keyword
63
+ MULTIPLE_REQUIRED = :multiple_required
64
+ SPLAT = :splat
65
+ DOUBLE_SPLAT = :double_splat
66
+ SPLAT_AND_DOUBLE_SPLAT = :splat_and_double_splat
67
+
68
+ # @param method [UnboundMethod] a method to categorize based on the types of
69
+ # arguments it has
70
+ # @return [Symbol] one of:
71
+ # - :none (example: `def foo`)
72
+ # - :one_required_positional (example: `def foo(a)`)
73
+ # - :one_required_keyword (example: `def foo(a:)`)
74
+ # - :multiple_required (examples: `def foo(a, b)`, `def foo(a:, b:)`, `def foo(a, b:)`)
75
+ # - :splat (examples: `def foo(a=1)`, `def foo(a, *b)`)
76
+ # - :double_splat (examples: `def foo(a: 1)`, `def foo(a:, **b)`)
77
+ # - :splat_and_double_splat (examples: `def foo(a=1, b: 2)`, `def foo(a=1, **b)`, `def foo(*a, **b)`)
78
+ def self.method_arguments(method)
79
+ return NONE if method.arity.zero?
80
+
81
+ parameters = method.parameters.map(&:first)
82
+
83
+ if parameters == [:req]
84
+ ONE_REQUIRED_POSITIONAL
85
+ elsif parameters == [:keyreq]
86
+ ONE_REQUIRED_KEYWORD
87
+ elsif parameters.all? { |type| type == :req || type == :keyreq }
88
+ MULTIPLE_REQUIRED
89
+ elsif parameters & %i[req opt rest] == parameters.uniq
90
+ SPLAT
91
+ elsif parameters & %i[keyreq key keyrest] == parameters.uniq
92
+ DOUBLE_SPLAT
93
+ else
94
+ SPLAT_AND_DOUBLE_SPLAT
52
95
  end
53
96
  end
54
97
 
55
- # Determine whether `method` takes any *keyword* args.
56
- #
57
- # These are the types of keyword args:
58
- #
59
- # * *Keyword Required* -- ex: `def foo(a:)`
60
- # * *Keyword Optional* -- ex: `def foo(b: 1)`
61
- # * *Keyword Splatted* -- ex: `def foo(**c)`
62
- #
63
- # @param method [Method, UnboundMethod]
64
- # Arguments of this method will be checked
65
- #
66
- # @return [Boolean]
67
- # Return `true` if `method` accepts one or more keyword arguments
68
- #
69
- # @example
70
- # class Example
71
- # def position_args(a, b=1)
72
- # end
73
- #
74
- # def keyword_args(a:, b: 1)
75
- # end
76
- # end
77
- #
78
- # MemoWise::InternalAPI.
79
- # has_kwarg?(Example.instance_method(:position_args)) #=> false
80
- #
81
- # MemoWise::InternalAPI.
82
- # has_kwarg?(Example.instance_method(:keyword_args)) #=> true
83
- #
84
- def self.has_kwarg?(method) # rubocop:disable Naming/PredicateName
85
- method.parameters.any? do |param, _|
86
- param == :keyreq || param == :key || param == :keyrest # rubocop:disable Style/MultipleComparison
98
+ # @param method [UnboundMethod] a method being memoized
99
+ # @return [String] the arguments string to use when defining our new
100
+ # memoized version of the method
101
+ def self.args_str(method)
102
+ case method_arguments(method)
103
+ when SPLAT then "*args"
104
+ when DOUBLE_SPLAT then "**kwargs"
105
+ when SPLAT_AND_DOUBLE_SPLAT then "*args, **kwargs"
106
+ when ONE_REQUIRED_POSITIONAL, ONE_REQUIRED_KEYWORD, MULTIPLE_REQUIRED
107
+ method.parameters.map do |type, name|
108
+ "#{name}#{':' if type == :keyreq}"
109
+ end.join(", ")
110
+ else
111
+ raise ArgumentError, "Unexpected arguments for #{method.name}"
87
112
  end
88
113
  end
89
114
 
90
- # Determine whether `method` takes only *required* args.
91
- #
92
- # These are the types of required args:
93
- #
94
- # * *Required* -- ex: `def foo(a)`
95
- # * *Keyword Required* -- ex: `def foo(a:)`
96
- #
97
- # @param method [Method, UnboundMethod]
98
- # Arguments of this method will be checked
99
- #
100
- # @return [Boolean]
101
- # Return `true` if `method` accepts only required arguments
102
- #
103
- # @example
104
- # class Ex
105
- # def optional_args(a=1, b: 1)
106
- # end
107
- #
108
- # def required_args(a, b:)
109
- # end
110
- # end
111
- #
112
- # MemoWise::InternalAPI.
113
- # has_only_required_args?(Ex.instance_method(:optional_args))
114
- # #=> false
115
- #
116
- # MemoWise::InternalAPI.
117
- # has_only_required_args?(Ex.instance_method(:required_args))
118
- # #=> true
119
- def self.has_only_required_args?(method) # rubocop:disable Naming/PredicateName
120
- method.parameters.all? { |type, _| type == :req || type == :keyreq } # rubocop:disable Style/MultipleComparison
115
+ # @param method [UnboundMethod] a method being memoized
116
+ # @return [String] the arguments string to use when calling the original
117
+ # method in our new memoized version of the method, i.e. when setting a
118
+ # memoized value
119
+ def self.call_str(method)
120
+ case method_arguments(method)
121
+ when SPLAT then "*args"
122
+ when DOUBLE_SPLAT then "**kwargs"
123
+ when SPLAT_AND_DOUBLE_SPLAT then "*args, **kwargs"
124
+ when ONE_REQUIRED_POSITIONAL, ONE_REQUIRED_KEYWORD, MULTIPLE_REQUIRED
125
+ method.parameters.map do |type, name|
126
+ type == :req ? name : "#{name}: #{name}"
127
+ end.join(", ")
128
+ else
129
+ raise ArgumentError, "Unexpected arguments for #{method.name}"
130
+ end
131
+ end
132
+
133
+ # @param method [UnboundMethod] a method being memoized
134
+ # @return [String] the string to use as a hash key when looking up a
135
+ # memoized value, based on the method's arguments
136
+ def self.key_str(method)
137
+ case method_arguments(method)
138
+ when SPLAT then "args"
139
+ when DOUBLE_SPLAT then "kwargs"
140
+ when SPLAT_AND_DOUBLE_SPLAT then "[args, kwargs]"
141
+ when MULTIPLE_REQUIRED then "[#{method.parameters.map(&:last).join(', ')}]"
142
+ else
143
+ raise ArgumentError, "Unexpected arguments for #{method.name}"
144
+ end
121
145
  end
122
146
 
123
147
  # Find the original class for which the given class is the corresponding
@@ -135,15 +159,20 @@ module MemoWise
135
159
  # Raises if `klass` is not a singleton class.
136
160
  #
137
161
  def self.original_class_from_singleton(klass)
138
- unless klass.singleton_class?
139
- raise ArgumentError, "Must be a singleton class: #{klass.inspect}"
140
- end
162
+ raise ArgumentError, "Must be a singleton class: #{klass.inspect}" unless klass.singleton_class?
163
+
164
+ # Since we call this method a lot, we memoize the results. This can have a
165
+ # huge impact; for example, in our test suite this drops our test times
166
+ # from over five minutes to just a few seconds.
167
+ @original_class_from_singleton ||= {}
141
168
 
142
169
  # Search ObjectSpace
143
170
  # * 1:1 relationship of singleton class to original class is documented
144
171
  # * Performance concern: searches all Class objects
145
- # But, only runs at load time
146
- ObjectSpace.each_object(Class).find { |cls| cls.singleton_class == klass }
172
+ # But, only runs at load time and results are memoized
173
+ @original_class_from_singleton[klass] ||= ObjectSpace.each_object(Module).find do |cls|
174
+ cls.singleton_class == klass
175
+ end
147
176
  end
148
177
 
149
178
  # Convention we use for renaming the original method when we replace with
@@ -159,56 +188,20 @@ module MemoWise
159
188
  :"_memo_wise_original_#{method_name}"
160
189
  end
161
190
 
162
- # @param target [Class, Module]
163
- # The class to which we are prepending MemoWise to provide memoization;
164
- # the `InternalAPI` *instance* methods will refer to this `target` class.
165
- def initialize(target)
166
- @target = target
167
- end
168
-
169
- # @return [Class, Module]
170
- attr_reader :target
171
-
172
- # Returns the "fetch key" for the given `method_name` and parameters, to be
173
- # used to lookup the memoized results specifically for this method and these
174
- # parameters.
175
- #
176
- # @param method_name [Symbol]
177
- # Name of method to derive the "fetch key" for, with given parameters.
178
- # @param args [Array]
179
- # Zero or more positional parameters
180
- # @param kwargs [Hash]
181
- # Zero or more keyword parameters
182
- #
183
- # @return [Array, Hash, Object]
184
- # Returns one of:
185
- # - An `Array` if only positional parameters.
186
- # - A nested `Array<Array, Hash>` if *both* positional and keyword.
187
- # - A `Hash` if only keyword parameters.
188
- # - A single object if there is only a single parameter.
189
- def fetch_key(method_name, *args, **kwargs)
190
- method = target_class.instance_method(method_name)
191
-
192
- if MemoWise::InternalAPI.has_only_required_args?(method)
193
- key = method.parameters.map.with_index do |(type, name), index|
194
- type == :req ? args[index] : kwargs[name]
195
- end
196
- key.size == 1 ? key.first : key
197
- else
198
- has_arg = MemoWise::InternalAPI.has_arg?(method)
199
-
200
- if has_arg && MemoWise::InternalAPI.has_kwarg?(method)
201
- [args, kwargs].freeze
202
- elsif has_arg
203
- args
204
- else
205
- kwargs
206
- end
207
- end
191
+ # @param method_name [Symbol] the name of the memoized method
192
+ # @return [Integer] the array index in `@_memo_wise_indices` to use to find
193
+ # the memoization data for the given method
194
+ def self.index(target, method_name)
195
+ klass = target_class(target)
196
+ indices = klass.instance_variable_get(:@_memo_wise_indices)
197
+ indices&.[](method_name) || next_index!(klass, method_name)
208
198
  end
209
199
 
210
200
  # Returns visibility of an instance method defined on class `target`.
211
201
  #
202
+ # @param target [Class, Module]
203
+ # The class to which we are prepending MemoWise to provide memoization.
204
+ #
212
205
  # @param method_name [Symbol]
213
206
  # Name of existing *instance* method find the visibility of.
214
207
  #
@@ -219,7 +212,7 @@ module MemoWise
219
212
  # Raises `ArgumentError` unless `method_name` is a `Symbol` corresponding
220
213
  # to an existing **instance** method defined on `klass`.
221
214
  #
222
- def method_visibility(method_name)
215
+ def self.method_visibility(target, method_name)
223
216
  if target.private_method_defined?(method_name)
224
217
  :private
225
218
  elsif target.protected_method_defined?(method_name)
@@ -227,25 +220,29 @@ module MemoWise
227
220
  elsif target.public_method_defined?(method_name)
228
221
  :public
229
222
  else
230
- raise ArgumentError,
231
- "#{method_name.inspect} must be a method on #{target}"
223
+ raise ArgumentError, "#{method_name.inspect} must be a method on #{target}"
232
224
  end
233
225
  end
234
226
 
235
227
  # Validates that {.memo_wise} has already been called on `method_name`.
236
228
  #
229
+ # @param target [Class, Module]
230
+ # The class to which we are prepending MemoWise to provide memoization.
231
+ #
237
232
  # @param method_name [Symbol]
238
233
  # Name of method to validate has already been setup with {.memo_wise}
239
- def validate_memo_wised!(method_name)
240
- original_name = self.class.original_memo_wised_name(method_name)
234
+ def self.validate_memo_wised!(target, method_name)
235
+ original_name = original_memo_wised_name(method_name)
241
236
 
242
- unless target_class.private_method_defined?(original_name)
237
+ unless target_class(target).private_method_defined?(original_name)
243
238
  raise ArgumentError, "#{method_name} is not a memo_wised method"
244
239
  end
245
240
  end
246
241
 
242
+ # @param target [Class, Module]
243
+ # The class to which we are prepending MemoWise to provide memoization.
247
244
  # @return [Class] where we look for method definitions
248
- def target_class
245
+ def self.target_class(target)
249
246
  if target.instance_of?(Class)
250
247
  # A class's methods are defined in its singleton class
251
248
  target.singleton_class
@@ -254,5 +251,36 @@ module MemoWise
254
251
  target.class
255
252
  end
256
253
  end
254
+ private_class_method :target_class
255
+
256
+ # Increment the class's method index counter, and return an index to use for
257
+ # the given method name.
258
+ #
259
+ # @param klass [Class]
260
+ # Original class on which a method is being memoized
261
+ #
262
+ # @param method_name [Symbol]
263
+ # The name of the method being memoized
264
+ #
265
+ # @return [Integer]
266
+ # The index within `@_memo_wise` to store the method's memoized results
267
+ def self.next_index!(klass, method_name)
268
+ # `@_memo_wise_indices` stores the `@_memo_wise` indices of different
269
+ # method names. We only use this data structure when resetting or
270
+ # presetting memoization. It looks like:
271
+ # {
272
+ # single_arg_method_name: 0,
273
+ # other_single_arg_method_name: 1
274
+ # }
275
+ memo_wise_indices = klass.instance_variable_get(:@_memo_wise_indices)
276
+ memo_wise_indices ||= klass.instance_variable_set(:@_memo_wise_indices, {})
277
+
278
+ index = klass.instance_variable_get(:@_memo_wise_index_counter) || 0
279
+ memo_wise_indices[method_name] = index
280
+ klass.instance_variable_set(:@_memo_wise_index_counter, index + 1)
281
+
282
+ index
283
+ end
284
+ private_class_method :next_index!
257
285
  end
258
286
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MemoWise
4
- VERSION = "1.0.0"
4
+ VERSION = "1.4.0"
5
5
  end