memo_wise 1.1.0 → 1.5.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,138 +10,106 @@ module MemoWise
10
10
  #
11
11
  # @return [Object] the passed-in obj
12
12
  def self.create_memo_wise_state!(obj)
13
- # `@_memo_wise` stores memoized results of method calls. For performance
14
- # reasons, the structure differs for different types of methods. It looks
15
- # like:
13
+ # `@_memo_wise` stores memoized results of method calls in a hash keyed on
14
+ # method name. The structure is slightly different for different types of
15
+ # methods. It looks like:
16
16
  # {
17
- # no_args_method_name: :memoized_result,
17
+ # zero_arg_method_name: :memoized_result,
18
18
  # single_arg_method_name: { arg1 => :memoized_result, ... },
19
- # [:multi_arg_method_name, arg1, arg2].hash => :memoized_result
19
+ #
20
+ # # Surprisingly, this is faster than a single top-level hash key of: [:multi_arg_method_name, arg1, arg2]
21
+ # multi_arg_method_name: { [arg1, arg2] => :memoized_result, ... }
20
22
  # }
21
- unless obj.instance_variables.include?(:@_memo_wise)
22
- obj.instance_variable_set(:@_memo_wise, {})
23
- end
24
-
25
- # `@_memo_wise_hashes` stores the `Array#hash` values for each key in
26
- # `@_memo_wise` that represents a multi-argument method call. We only use
27
- # this data structure when resetting memoization for an entire method. It
28
- # looks like:
29
- # {
30
- # multi_arg_method_name: Set[
31
- # [:multi_arg_method_name, arg1, arg2].hash,
32
- # [:multi_arg_method_name, arg1, arg3].hash,
33
- # ...
34
- # ],
35
- # ...
36
- # }
37
- unless obj.instance_variables.include?(:@_memo_wise_hashes)
38
- obj.instance_variable_set(:@_memo_wise_hashes, {})
39
- end
23
+ obj.instance_variable_set(:@_memo_wise, {}) unless obj.instance_variable_defined?(:@_memo_wise)
40
24
 
41
25
  obj
42
26
  end
43
27
 
44
- # Determine whether `method` takes any *positional* args.
45
- #
46
- # These are the types of positional args:
47
- #
48
- # * *Required* -- ex: `def foo(a)`
49
- # * *Optional* -- ex: `def foo(b=1)`
50
- # * *Splatted* -- ex: `def foo(*c)`
51
- #
52
- # @param method [Method, UnboundMethod]
53
- # Arguments of this method will be checked
54
- #
55
- # @return [Boolean]
56
- # Return `true` if `method` accepts one or more positional arguments
57
- #
58
- # @example
59
- # class Example
60
- # def no_args
61
- # end
62
- #
63
- # def position_arg(a)
64
- # end
65
- # end
66
- #
67
- # MemoWise::InternalAPI.
68
- # has_arg?(Example.instance_method(:no_args)) #=> false
69
- #
70
- # MemoWise::InternalAPI.
71
- # has_arg?(Example.instance_method(:position_arg)) #=> true
72
- #
73
- def self.has_arg?(method) # rubocop:disable Naming/PredicateName
74
- method.parameters.any? do |param, _|
75
- param == :req || param == :opt || param == :rest # rubocop:disable Style/MultipleComparison
28
+ NONE = :none
29
+ ONE_REQUIRED_POSITIONAL = :one_required_positional
30
+ ONE_REQUIRED_KEYWORD = :one_required_keyword
31
+ MULTIPLE_REQUIRED = :multiple_required
32
+ SPLAT = :splat
33
+ DOUBLE_SPLAT = :double_splat
34
+ SPLAT_AND_DOUBLE_SPLAT = :splat_and_double_splat
35
+
36
+ # @param method [UnboundMethod] a method to categorize based on the types of
37
+ # arguments it has
38
+ # @return [Symbol] one of:
39
+ # - :none (example: `def foo`)
40
+ # - :one_required_positional (example: `def foo(a)`)
41
+ # - :one_required_keyword (example: `def foo(a:)`)
42
+ # - :multiple_required (examples: `def foo(a, b)`, `def foo(a:, b:)`, `def foo(a, b:)`)
43
+ # - :splat (examples: `def foo(a=1)`, `def foo(a, *b)`)
44
+ # - :double_splat (examples: `def foo(a: 1)`, `def foo(a:, **b)`)
45
+ # - :splat_and_double_splat (examples: `def foo(a=1, b: 2)`, `def foo(a=1, **b)`, `def foo(*a, **b)`)
46
+ def self.method_arguments(method)
47
+ return NONE if method.arity.zero?
48
+
49
+ parameters = method.parameters.map(&:first)
50
+
51
+ if parameters == [:req]
52
+ ONE_REQUIRED_POSITIONAL
53
+ elsif parameters == [:keyreq]
54
+ ONE_REQUIRED_KEYWORD
55
+ elsif parameters.all? { |type| type == :req || type == :keyreq }
56
+ MULTIPLE_REQUIRED
57
+ elsif parameters & %i[req opt rest] == parameters.uniq
58
+ SPLAT
59
+ elsif parameters & %i[keyreq key keyrest] == parameters.uniq
60
+ DOUBLE_SPLAT
61
+ else
62
+ SPLAT_AND_DOUBLE_SPLAT
76
63
  end
77
64
  end
78
65
 
79
- # Determine whether `method` takes any *keyword* args.
80
- #
81
- # These are the types of keyword args:
82
- #
83
- # * *Keyword Required* -- ex: `def foo(a:)`
84
- # * *Keyword Optional* -- ex: `def foo(b: 1)`
85
- # * *Keyword Splatted* -- ex: `def foo(**c)`
86
- #
87
- # @param method [Method, UnboundMethod]
88
- # Arguments of this method will be checked
89
- #
90
- # @return [Boolean]
91
- # Return `true` if `method` accepts one or more keyword arguments
92
- #
93
- # @example
94
- # class Example
95
- # def position_args(a, b=1)
96
- # end
97
- #
98
- # def keyword_args(a:, b: 1)
99
- # end
100
- # end
101
- #
102
- # MemoWise::InternalAPI.
103
- # has_kwarg?(Example.instance_method(:position_args)) #=> false
104
- #
105
- # MemoWise::InternalAPI.
106
- # has_kwarg?(Example.instance_method(:keyword_args)) #=> true
107
- #
108
- def self.has_kwarg?(method) # rubocop:disable Naming/PredicateName
109
- method.parameters.any? do |param, _|
110
- param == :keyreq || param == :key || param == :keyrest # rubocop:disable Style/MultipleComparison
66
+ # @param method [UnboundMethod] a method being memoized
67
+ # @return [String] the arguments string to use when defining our new
68
+ # memoized version of the method
69
+ def self.args_str(method)
70
+ case method_arguments(method)
71
+ when SPLAT then "*args"
72
+ when DOUBLE_SPLAT then "**kwargs"
73
+ when SPLAT_AND_DOUBLE_SPLAT then "*args, **kwargs"
74
+ when ONE_REQUIRED_POSITIONAL, ONE_REQUIRED_KEYWORD, MULTIPLE_REQUIRED
75
+ method.parameters.map do |type, name|
76
+ "#{name}#{':' if type == :keyreq}"
77
+ end.join(", ")
78
+ else
79
+ raise ArgumentError, "Unexpected arguments for #{method.name}"
111
80
  end
112
81
  end
113
82
 
114
- # Determine whether `method` takes only *required* args.
115
- #
116
- # These are the types of required args:
117
- #
118
- # * *Required* -- ex: `def foo(a)`
119
- # * *Keyword Required* -- ex: `def foo(a:)`
120
- #
121
- # @param method [Method, UnboundMethod]
122
- # Arguments of this method will be checked
123
- #
124
- # @return [Boolean]
125
- # Return `true` if `method` accepts only required arguments
126
- #
127
- # @example
128
- # class Ex
129
- # def optional_args(a=1, b: 1)
130
- # end
131
- #
132
- # def required_args(a, b:)
133
- # end
134
- # end
135
- #
136
- # MemoWise::InternalAPI.
137
- # has_only_required_args?(Ex.instance_method(:optional_args))
138
- # #=> false
139
- #
140
- # MemoWise::InternalAPI.
141
- # has_only_required_args?(Ex.instance_method(:required_args))
142
- # #=> true
143
- def self.has_only_required_args?(method) # rubocop:disable Naming/PredicateName
144
- method.parameters.all? { |type, _| type == :req || type == :keyreq } # rubocop:disable Style/MultipleComparison
83
+ # @param method [UnboundMethod] a method being memoized
84
+ # @return [String] the arguments string to use when calling the original
85
+ # method in our new memoized version of the method, i.e. when setting a
86
+ # memoized value
87
+ def self.call_str(method)
88
+ case method_arguments(method)
89
+ when SPLAT then "*args"
90
+ when DOUBLE_SPLAT then "**kwargs"
91
+ when SPLAT_AND_DOUBLE_SPLAT then "*args, **kwargs"
92
+ when ONE_REQUIRED_POSITIONAL, ONE_REQUIRED_KEYWORD, MULTIPLE_REQUIRED
93
+ method.parameters.map do |type, name|
94
+ type == :req ? name : "#{name}: #{name}"
95
+ end.join(", ")
96
+ else
97
+ raise ArgumentError, "Unexpected arguments for #{method.name}"
98
+ end
99
+ end
100
+
101
+ # @param method [UnboundMethod] a method being memoized
102
+ # @return [String] the string to use as a hash key when looking up a
103
+ # memoized value, based on the method's arguments
104
+ def self.key_str(method)
105
+ case method_arguments(method)
106
+ when SPLAT then "args"
107
+ when DOUBLE_SPLAT then "kwargs"
108
+ when SPLAT_AND_DOUBLE_SPLAT then "[args, kwargs]"
109
+ when MULTIPLE_REQUIRED then "[#{method.parameters.map(&:last).join(', ')}]"
110
+ else
111
+ raise ArgumentError, "Unexpected arguments for #{method.name}"
112
+ end
145
113
  end
146
114
 
147
115
  # Find the original class for which the given class is the corresponding
@@ -159,15 +127,18 @@ module MemoWise
159
127
  # Raises if `klass` is not a singleton class.
160
128
  #
161
129
  def self.original_class_from_singleton(klass)
162
- unless klass.singleton_class?
163
- raise ArgumentError, "Must be a singleton class: #{klass.inspect}"
164
- end
130
+ raise ArgumentError, "Must be a singleton class: #{klass.inspect}" unless klass.singleton_class?
131
+
132
+ # Since we call this method a lot, we memoize the results. This can have a
133
+ # huge impact; for example, in our test suite this drops our test times
134
+ # from over five minutes to just a few seconds.
135
+ @original_class_from_singleton ||= {}
165
136
 
166
137
  # Search ObjectSpace
167
138
  # * 1:1 relationship of singleton class to original class is documented
168
139
  # * Performance concern: searches all Class objects
169
- # But, only runs at load time
170
- ObjectSpace.each_object(Module).find do |cls|
140
+ # But, only runs at load time and results are memoized
141
+ @original_class_from_singleton[klass] ||= ObjectSpace.each_object(Module).find do |cls|
171
142
  cls.singleton_class == klass
172
143
  end
173
144
  end
@@ -185,77 +156,11 @@ module MemoWise
185
156
  :"_memo_wise_original_#{method_name}"
186
157
  end
187
158
 
188
- # @param target [Class, Module]
189
- # The class to which we are prepending MemoWise to provide memoization;
190
- # the `InternalAPI` *instance* methods will refer to this `target` class.
191
- def initialize(target)
192
- @target = target
193
- end
194
-
195
- # @return [Class, Module]
196
- attr_reader :target
197
-
198
- # Returns the "fetch key" for the given `method_name` and parameters, to be
199
- # used to lookup the memoized results specifically for this method and these
200
- # parameters.
201
- #
202
- # @param method_name [Symbol]
203
- # Name of method to derive the "fetch key" for, with given parameters.
204
- # @param args [Array]
205
- # Zero or more positional parameters
206
- # @param kwargs [Hash]
207
- # Zero or more keyword parameters
208
- #
209
- # @return [Array, Hash, Object]
210
- # Returns one of:
211
- # - An `Array` if only positional parameters.
212
- # - A nested `Array<Array, Hash>` if *both* positional and keyword.
213
- # - A `Hash` if only keyword parameters.
214
- # - A single object if there is only a single parameter.
215
- def fetch_key(method_name, *args, **kwargs)
216
- method = target_class.instance_method(method_name)
217
-
218
- if MemoWise::InternalAPI.has_only_required_args?(method)
219
- key = method.parameters.map.with_index do |(type, name), index|
220
- type == :req ? args[index] : kwargs[name]
221
- end
222
- key.size == 1 ? key.first : [method_name, *key].hash
223
- else
224
- has_arg = MemoWise::InternalAPI.has_arg?(method)
225
-
226
- if has_arg && MemoWise::InternalAPI.has_kwarg?(method)
227
- [method_name, args, kwargs].hash
228
- elsif has_arg
229
- args.hash
230
- else
231
- kwargs.hash
232
- end
233
- end
234
- end
235
-
236
- # Returns whether the given method should use an array's hash value as the
237
- # cache lookup key. See the comments in `.create_memo_wise_state!` for an
238
- # example.
239
- #
240
- # @param method_name [Symbol]
241
- # Name of memoized method we're checking the implementation of
242
- #
243
- # @return [Boolean] true iff the method uses a hashed cache key; false
244
- # otherwise
245
- def use_hashed_key?(method_name)
246
- method = target_class.instance_method(method_name)
247
-
248
- if MemoWise::InternalAPI.has_arg?(method) &&
249
- MemoWise::InternalAPI.has_kwarg?(method)
250
- return true
251
- end
252
-
253
- MemoWise::InternalAPI.has_only_required_args?(method) &&
254
- method.parameters.size > 1
255
- end
256
-
257
159
  # Returns visibility of an instance method defined on class `target`.
258
160
  #
161
+ # @param target [Class, Module]
162
+ # The class to which we are prepending MemoWise to provide memoization.
163
+ #
259
164
  # @param method_name [Symbol]
260
165
  # Name of existing *instance* method find the visibility of.
261
166
  #
@@ -266,7 +171,7 @@ module MemoWise
266
171
  # Raises `ArgumentError` unless `method_name` is a `Symbol` corresponding
267
172
  # to an existing **instance** method defined on `klass`.
268
173
  #
269
- def method_visibility(method_name)
174
+ def self.method_visibility(target, method_name)
270
175
  if target.private_method_defined?(method_name)
271
176
  :private
272
177
  elsif target.protected_method_defined?(method_name)
@@ -274,27 +179,29 @@ module MemoWise
274
179
  elsif target.public_method_defined?(method_name)
275
180
  :public
276
181
  else
277
- raise ArgumentError,
278
- "#{method_name.inspect} must be a method on #{target}"
182
+ raise ArgumentError, "#{method_name.inspect} must be a method on #{target}"
279
183
  end
280
184
  end
281
185
 
282
186
  # Validates that {.memo_wise} has already been called on `method_name`.
283
187
  #
188
+ # @param target [Class, Module]
189
+ # The class to which we are prepending MemoWise to provide memoization.
190
+ #
284
191
  # @param method_name [Symbol]
285
192
  # Name of method to validate has already been setup with {.memo_wise}
286
- def validate_memo_wised!(method_name)
287
- original_name = self.class.original_memo_wised_name(method_name)
193
+ def self.validate_memo_wised!(target, method_name)
194
+ original_name = original_memo_wised_name(method_name)
288
195
 
289
- unless target_class.private_method_defined?(original_name)
196
+ unless target_class(target).private_method_defined?(original_name)
290
197
  raise ArgumentError, "#{method_name} is not a memo_wised method"
291
198
  end
292
199
  end
293
200
 
294
- private
295
-
201
+ # @param target [Class, Module]
202
+ # The class to which we are prepending MemoWise to provide memoization.
296
203
  # @return [Class] where we look for method definitions
297
- def target_class
204
+ def self.target_class(target)
298
205
  if target.instance_of?(Class)
299
206
  # A class's methods are defined in its singleton class
300
207
  target.singleton_class
@@ -303,5 +210,6 @@ module MemoWise
303
210
  target.class
304
211
  end
305
212
  end
213
+ private_class_method :target_class
306
214
  end
307
215
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MemoWise
4
- VERSION = "1.1.0"
4
+ VERSION = "1.5.0"
5
5
  end