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.
- checksums.yaml +4 -4
- data/.github/PULL_REQUEST_TEMPLATE.md +2 -2
- data/.github/workflows/main.yml +1 -1
- data/.gitignore +1 -0
- data/.rubocop.yml +16 -1
- data/CHANGELOG.md +55 -2
- data/Gemfile.lock +30 -30
- data/README.md +74 -19
- data/benchmarks/Gemfile +4 -2
- data/benchmarks/benchmarks.rb +149 -132
- data/lib/memo_wise/internal_api.rb +183 -155
- data/lib/memo_wise/version.rb +1 -1
- data/lib/memo_wise.rb +241 -114
- data/memo_wise.gemspec +1 -0
- metadata +3 -4
- data/benchmarks/.ruby-version +0 -1
- data/benchmarks/Gemfile.lock +0 -29
@@ -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
|
-
|
14
|
-
|
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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
# @param method [
|
29
|
-
#
|
30
|
-
#
|
31
|
-
#
|
32
|
-
#
|
33
|
-
#
|
34
|
-
#
|
35
|
-
#
|
36
|
-
#
|
37
|
-
#
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
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
|
-
#
|
56
|
-
#
|
57
|
-
#
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
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
|
-
#
|
91
|
-
#
|
92
|
-
#
|
93
|
-
#
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
#
|
109
|
-
#
|
110
|
-
#
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
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
|
-
|
140
|
-
|
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(
|
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
|
163
|
-
#
|
164
|
-
# the
|
165
|
-
def
|
166
|
-
|
167
|
-
|
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 =
|
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
|
data/lib/memo_wise/version.rb
CHANGED