memo_wise 1.0.0 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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