memo_wise 0.4.0 → 1.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.dokaz +2 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +2 -2
- data/.github/dependabot.yml +20 -0
- data/.github/workflows/main.yml +21 -13
- data/.gitignore +1 -0
- data/.rubocop.yml +15 -0
- data/CHANGELOG.md +53 -2
- data/Gemfile +1 -0
- data/Gemfile.lock +17 -9
- data/LICENSE.txt +1 -1
- data/README.md +92 -31
- data/benchmarks/Gemfile +5 -3
- data/benchmarks/benchmarks.rb +165 -104
- data/lib/memo_wise/internal_api.rb +309 -0
- data/lib/memo_wise/version.rb +1 -1
- data/lib/memo_wise.rb +284 -272
- data/memo_wise.gemspec +6 -1
- metadata +9 -9
- data/.dependabot/config.yml +0 -13
- data/.github/workflows/auto-approve-dependabot.yml +0 -26
- data/.github/workflows/remove-needs-qa.yml +0 -35
- data/benchmarks/.ruby-version +0 -1
- data/benchmarks/Gemfile.lock +0 -26
data/benchmarks/benchmarks.rb
CHANGED
@@ -2,17 +2,21 @@
|
|
2
2
|
|
3
3
|
require "benchmark/ips"
|
4
4
|
|
5
|
+
require "tempfile"
|
5
6
|
require "memo_wise"
|
6
7
|
|
7
8
|
# Some gems do not yet work in Ruby 3 so we only require them if they're loaded
|
8
9
|
# in the Gemfile.
|
9
|
-
%w[memery memoist memoized memoizer].
|
10
|
+
%w[memery memoist memoized memoizer ddmemoize dry-core].
|
10
11
|
each { |gem| require gem if Gem.loaded_specs.key?(gem) }
|
11
12
|
|
12
13
|
# The VERSION constant does not get loaded above for these gems.
|
13
14
|
%w[memoized memoizer].
|
14
15
|
each { |gem| require "#{gem}/version" if Gem.loaded_specs.key?(gem) }
|
15
16
|
|
17
|
+
# The Memoizable module from dry-core needs to be required manually
|
18
|
+
require "dry/core/memoizable" if Gem.loaded_specs.key?("dry-core")
|
19
|
+
|
16
20
|
class BenchmarkSuiteWithoutGC
|
17
21
|
def warming(*)
|
18
22
|
run_gc
|
@@ -36,7 +40,7 @@ class BenchmarkSuiteWithoutGC
|
|
36
40
|
end
|
37
41
|
suite = BenchmarkSuiteWithoutGC.new
|
38
42
|
|
39
|
-
BenchmarkGem = Struct.new(:klass, :
|
43
|
+
BenchmarkGem = Struct.new(:klass, :activation_code, :memoization_method) do
|
40
44
|
def benchmark_name
|
41
45
|
"#{klass} (#{klass::VERSION})"
|
42
46
|
end
|
@@ -47,160 +51,171 @@ end
|
|
47
51
|
# NOTE: Some gems do not yet work in Ruby 3 so we only test with them if they've
|
48
52
|
# been `require`d.
|
49
53
|
BENCHMARK_GEMS = [
|
50
|
-
BenchmarkGem.new(MemoWise,
|
51
|
-
(BenchmarkGem.new(
|
52
|
-
(BenchmarkGem.new(
|
53
|
-
(BenchmarkGem.new(
|
54
|
-
(BenchmarkGem.new(
|
54
|
+
BenchmarkGem.new(MemoWise, "prepend MemoWise", :memo_wise),
|
55
|
+
(BenchmarkGem.new(DDMemoize, "DDMemoize.activate(self)", :memoize) if defined?(DDMemoize)),
|
56
|
+
(BenchmarkGem.new(Dry::Core, "include Dry::Core::Memoizable", :memoize) if defined?(Dry::Core)),
|
57
|
+
(BenchmarkGem.new(Memery, "include Memery", :memoize) if defined?(Memery)),
|
58
|
+
(BenchmarkGem.new(Memoist, "extend Memoist", :memoize) if defined?(Memoist)),
|
59
|
+
(BenchmarkGem.new(Memoized, "include Memoized", :memoize) if defined?(Memoized)),
|
60
|
+
(BenchmarkGem.new(Memoizer, "include Memoizer", :memoize) if defined?(Memoizer))
|
55
61
|
].compact.shuffle
|
56
62
|
|
57
63
|
# Use metaprogramming to ensure that each class is created in exactly the
|
58
64
|
# the same way.
|
59
65
|
BENCHMARK_GEMS.each do |benchmark_gem|
|
60
|
-
# rubocop:disable Security/Eval
|
61
|
-
|
62
|
-
|
66
|
+
eval <<~HEREDOC, binding, __FILE__, __LINE__ + 1 # rubocop:disable Security/Eval
|
67
|
+
# For these methods, we alternately return truthy and falsey values in
|
68
|
+
# order to benchmark memoization when the result of a method is falsey.
|
69
|
+
#
|
70
|
+
# We do this by checking if the first argument to a method is even.
|
63
71
|
class #{benchmark_gem.klass}Example
|
64
|
-
#{benchmark_gem.
|
72
|
+
#{benchmark_gem.activation_code}
|
65
73
|
|
66
74
|
def no_args
|
67
75
|
100
|
68
76
|
end
|
69
77
|
#{benchmark_gem.memoization_method} :no_args
|
70
78
|
|
79
|
+
# For the no_args case, we can't depend on arguments to alternate between
|
80
|
+
# returning truthy and falsey values, so instead make two separate
|
81
|
+
# no_args methods
|
82
|
+
def no_args_falsey
|
83
|
+
nil
|
84
|
+
end
|
85
|
+
#{benchmark_gem.memoization_method} :no_args_falsey
|
86
|
+
|
87
|
+
def one_positional_arg(a)
|
88
|
+
100 if a.positive?
|
89
|
+
end
|
90
|
+
#{benchmark_gem.memoization_method} :one_positional_arg
|
91
|
+
|
71
92
|
def positional_args(a, b)
|
72
|
-
100
|
93
|
+
100 if a.positive?
|
73
94
|
end
|
74
95
|
#{benchmark_gem.memoization_method} :positional_args
|
75
96
|
|
97
|
+
def one_keyword_arg(a:)
|
98
|
+
100 if a.positive?
|
99
|
+
end
|
100
|
+
#{benchmark_gem.memoization_method} :one_keyword_arg
|
101
|
+
|
76
102
|
def keyword_args(a:, b:)
|
77
|
-
100
|
103
|
+
100 if a.positive?
|
78
104
|
end
|
79
105
|
#{benchmark_gem.memoization_method} :keyword_args
|
80
106
|
|
81
107
|
def positional_and_keyword_args(a, b:)
|
82
|
-
100
|
108
|
+
100 if a.positive?
|
83
109
|
end
|
84
110
|
#{benchmark_gem.memoization_method} :positional_and_keyword_args
|
85
111
|
|
86
112
|
def positional_and_splat_args(a, *args)
|
87
|
-
100
|
113
|
+
100 if a.positive?
|
88
114
|
end
|
89
115
|
#{benchmark_gem.memoization_method} :positional_and_splat_args
|
90
116
|
|
91
117
|
def keyword_and_double_splat_args(a:, **kwargs)
|
92
|
-
100
|
118
|
+
100 if a.positive?
|
93
119
|
end
|
94
120
|
#{benchmark_gem.memoization_method} :keyword_and_double_splat_args
|
95
121
|
|
96
122
|
def positional_splat_keyword_and_double_splat_args(a, *args, b:, **kwargs)
|
97
|
-
100
|
123
|
+
100 if a.positive?
|
98
124
|
end
|
99
125
|
#{benchmark_gem.memoization_method} :positional_splat_keyword_and_double_splat_args
|
100
126
|
end
|
101
|
-
|
102
|
-
# rubocop:enable Style/DocumentDynamicEvalDefinition
|
103
|
-
# rubocop:enable Security/Eval
|
127
|
+
HEREDOC
|
104
128
|
end
|
105
129
|
|
106
130
|
# We pre-create argument lists for our memoized methods with arguments, so that
|
107
131
|
# our benchmarks are running the exact same inputs for each case.
|
108
|
-
|
132
|
+
#
|
133
|
+
# NOTE: The proportion of falsey results is 1/N_UNIQUE_ARGUMENTS (because for
|
134
|
+
# the methods with arguments we are truthy for all but the first unique argument
|
135
|
+
# set, and for zero-arity methods we manually execute `no_args` N_TRUTHY_RESULTS
|
136
|
+
# times per each execution of `no_args_falsey`). This number was selected as the
|
137
|
+
# lowest number such that this logic:
|
138
|
+
#
|
139
|
+
# output = hash[key]
|
140
|
+
# if output || hash.key?(key)
|
141
|
+
# output
|
142
|
+
# else
|
143
|
+
# hash[key] = _original_method(...)
|
144
|
+
# end
|
145
|
+
#
|
146
|
+
# is consistently faster for cached lookups than:
|
147
|
+
#
|
148
|
+
# hash.fetch(key) do
|
149
|
+
# hash[key] = _original_method(...)
|
150
|
+
# end
|
151
|
+
#
|
152
|
+
# as a result of `Hash#[]` having less overhead than `Hash#fetch`.
|
153
|
+
#
|
154
|
+
# We believe this is a reasonable choice because we believe most memoized method
|
155
|
+
# results will be truthy, and so that is the case we should most optimize for.
|
156
|
+
# However, we do not want to completely remove falsey method results from these
|
157
|
+
# benchmarks because we do want to catch performance regressions for that case,
|
158
|
+
# since it has its own "hot path."
|
159
|
+
N_UNIQUE_ARGUMENTS = 30
|
109
160
|
ARGUMENTS = Array.new(N_UNIQUE_ARGUMENTS) { |i| [i, i + 1] }
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
instance
|
118
|
-
|
119
|
-
# Run once to memoize the result value, so our benchmark only tests memoized
|
120
|
-
# retrieval time.
|
161
|
+
N_TRUTHY_RESULTS = N_UNIQUE_ARGUMENTS - 1
|
162
|
+
N_RESULT_DECIMAL_DIGITS = 2
|
163
|
+
|
164
|
+
# Each method within these benchmarks is initially run once to memoize the
|
165
|
+
# result value, so our benchmark only tests memoized retrieval time.
|
166
|
+
benchmark_lambdas = [
|
167
|
+
lambda do |x, instance, benchmark_gem|
|
168
|
+
instance.no_args_falsey
|
121
169
|
instance.no_args
|
122
170
|
|
123
|
-
x.report("#{benchmark_gem.benchmark_name}: ()")
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
end
|
128
|
-
|
129
|
-
|
130
|
-
x.config(suite: suite)
|
131
|
-
BENCHMARK_GEMS.each do |benchmark_gem|
|
132
|
-
instance = Object.const_get("#{benchmark_gem.klass}Example").new
|
171
|
+
x.report("#{benchmark_gem.benchmark_name}: ()") do
|
172
|
+
instance.no_args_falsey
|
173
|
+
N_TRUTHY_RESULTS.times { instance.no_args }
|
174
|
+
end
|
175
|
+
end,
|
176
|
+
lambda do |x, instance, benchmark_gem|
|
177
|
+
ARGUMENTS.each { |a, _| instance.one_positional_arg(a) }
|
133
178
|
|
134
|
-
#
|
135
|
-
|
179
|
+
x.report("#{benchmark_gem.benchmark_name}: (a)") do
|
180
|
+
ARGUMENTS.each { |a, _| instance.one_positional_arg(a) }
|
181
|
+
end
|
182
|
+
end,
|
183
|
+
lambda do |x, instance, benchmark_gem|
|
136
184
|
ARGUMENTS.each { |a, b| instance.positional_args(a, b) }
|
137
185
|
|
138
186
|
x.report("#{benchmark_gem.benchmark_name}: (a, b)") do
|
139
187
|
ARGUMENTS.each { |a, b| instance.positional_args(a, b) }
|
140
188
|
end
|
141
|
-
end
|
142
|
-
|
143
|
-
|
144
|
-
end
|
189
|
+
end,
|
190
|
+
lambda do |x, instance, benchmark_gem|
|
191
|
+
ARGUMENTS.each { |a, _| instance.one_keyword_arg(a: a) }
|
145
192
|
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
# Run once with each set of arguments to memoize the result values, so our
|
152
|
-
# benchmark only tests memoized retrieval time.
|
193
|
+
x.report("#{benchmark_gem.benchmark_name}: (a:)") do
|
194
|
+
ARGUMENTS.each { |a, _| instance.one_keyword_arg(a: a) }
|
195
|
+
end
|
196
|
+
end,
|
197
|
+
lambda do |x, instance, benchmark_gem|
|
153
198
|
ARGUMENTS.each { |a, b| instance.keyword_args(a: a, b: b) }
|
154
199
|
|
155
200
|
x.report("#{benchmark_gem.benchmark_name}: (a:, b:)") do
|
156
201
|
ARGUMENTS.each { |a, b| instance.keyword_args(a: a, b: b) }
|
157
202
|
end
|
158
|
-
end
|
159
|
-
|
160
|
-
x.compare!
|
161
|
-
end
|
162
|
-
|
163
|
-
Benchmark.ips do |x|
|
164
|
-
x.config(suite: suite)
|
165
|
-
BENCHMARK_GEMS.each do |benchmark_gem|
|
166
|
-
instance = Object.const_get("#{benchmark_gem.klass}Example").new
|
167
|
-
|
168
|
-
# Run once with each set of arguments to memoize the result values, so our
|
169
|
-
# benchmark only tests memoized retrieval time.
|
203
|
+
end,
|
204
|
+
lambda do |x, instance, benchmark_gem|
|
170
205
|
ARGUMENTS.each { |a, b| instance.positional_and_keyword_args(a, b: b) }
|
171
206
|
|
172
207
|
x.report("#{benchmark_gem.benchmark_name}: (a, b:)") do
|
173
208
|
ARGUMENTS.each { |a, b| instance.positional_and_keyword_args(a, b: b) }
|
174
209
|
end
|
175
|
-
end
|
176
|
-
|
177
|
-
x.compare!
|
178
|
-
end
|
179
|
-
|
180
|
-
Benchmark.ips do |x|
|
181
|
-
x.config(suite: suite)
|
182
|
-
BENCHMARK_GEMS.each do |benchmark_gem|
|
183
|
-
instance = Object.const_get("#{benchmark_gem.klass}Example").new
|
184
|
-
|
185
|
-
# Run once with each set of arguments to memoize the result values, so our
|
186
|
-
# benchmark only tests memoized retrieval time.
|
210
|
+
end,
|
211
|
+
lambda do |x, instance, benchmark_gem|
|
187
212
|
ARGUMENTS.each { |a, b| instance.positional_and_splat_args(a, b) }
|
188
213
|
|
189
214
|
x.report("#{benchmark_gem.benchmark_name}: (a, *args)") do
|
190
215
|
ARGUMENTS.each { |a, b| instance.positional_and_splat_args(a, b) }
|
191
216
|
end
|
192
|
-
end
|
193
|
-
|
194
|
-
x.compare!
|
195
|
-
end
|
196
|
-
|
197
|
-
Benchmark.ips do |x|
|
198
|
-
x.config(suite: suite)
|
199
|
-
BENCHMARK_GEMS.each do |benchmark_gem|
|
200
|
-
instance = Object.const_get("#{benchmark_gem.klass}Example").new
|
201
|
-
|
202
|
-
# Run once with each set of arguments to memoize the result values, so our
|
203
|
-
# benchmark only tests memoized retrieval time.
|
217
|
+
end,
|
218
|
+
lambda do |x, instance, benchmark_gem|
|
204
219
|
ARGUMENTS.each { |a, b| instance.keyword_and_double_splat_args(a: a, b: b) }
|
205
220
|
|
206
221
|
x.report(
|
@@ -210,18 +225,8 @@ Benchmark.ips do |x|
|
|
210
225
|
instance.keyword_and_double_splat_args(a: a, b: b)
|
211
226
|
end
|
212
227
|
end
|
213
|
-
end
|
214
|
-
|
215
|
-
x.compare!
|
216
|
-
end
|
217
|
-
|
218
|
-
Benchmark.ips do |x|
|
219
|
-
x.config(suite: suite)
|
220
|
-
BENCHMARK_GEMS.each do |benchmark_gem|
|
221
|
-
instance = Object.const_get("#{benchmark_gem.klass}Example").new
|
222
|
-
|
223
|
-
# Run once with each set of arguments to memoize the result values, so our
|
224
|
-
# benchmark only tests memoized retrieval time.
|
228
|
+
end,
|
229
|
+
lambda do |x, instance, benchmark_gem|
|
225
230
|
ARGUMENTS.each do |a, b|
|
226
231
|
instance.positional_splat_keyword_and_double_splat_args(a, b, b: b, a: a)
|
227
232
|
end
|
@@ -235,6 +240,62 @@ Benchmark.ips do |x|
|
|
235
240
|
end
|
236
241
|
end
|
237
242
|
end
|
243
|
+
]
|
244
|
+
|
245
|
+
# We benchmark different cases separately, to ensure that slow performance in
|
246
|
+
# one method or code path isn't hidden by fast performance in another.
|
247
|
+
benchmark_lambdas.map do |benchmark|
|
248
|
+
json_file = Tempfile.new
|
249
|
+
|
250
|
+
Benchmark.ips do |x|
|
251
|
+
x.config(suite: suite)
|
252
|
+
BENCHMARK_GEMS.each do |benchmark_gem|
|
253
|
+
instance = Object.const_get("#{benchmark_gem.klass}Example").new
|
254
|
+
|
255
|
+
benchmark.call(x, instance, benchmark_gem)
|
256
|
+
end
|
257
|
+
|
258
|
+
x.compare!
|
259
|
+
x.json! json_file.path
|
260
|
+
end
|
261
|
+
|
262
|
+
JSON.parse(json_file.read)
|
263
|
+
end.each_with_index do |benchmark_json, i|
|
264
|
+
# We print a comparison table after we run each benchmark to copy into our
|
265
|
+
# README.md
|
266
|
+
|
267
|
+
# MemoWise will not appear in the comparison table, but we will use it to
|
268
|
+
# compare against other gems' benchmarks
|
269
|
+
memo_wise = benchmark_json.find { _1["name"].include?("MemoWise") }
|
270
|
+
benchmark_json.delete(memo_wise)
|
271
|
+
|
272
|
+
# Sort benchmarks by gem name to alphabetize our final output table.
|
273
|
+
benchmark_json.sort_by! { _1["name"] }
|
274
|
+
|
275
|
+
# Print headers based on the first benchmark_json
|
276
|
+
if i.zero?
|
277
|
+
benchmark_headers = benchmark_json.map do |benchmark_gem|
|
278
|
+
# Gem name is of the form:
|
279
|
+
# "MemoWise (1.1.0): ()"
|
280
|
+
# We use this mapping to get a header of the form
|
281
|
+
# "`MemoWise` (1.1.0)
|
282
|
+
gem_name_parts = benchmark_gem["name"].split
|
283
|
+
"`#{gem_name_parts[0]}` #{gem_name_parts[1][...-1]}"
|
284
|
+
end.join("|")
|
285
|
+
puts "|Method arguments|#{benchmark_headers}|"
|
286
|
+
puts "#{'|--' * (benchmark_json.size + 1)}|"
|
287
|
+
end
|
238
288
|
|
239
|
-
|
289
|
+
output_str = benchmark_json.map do |bgem|
|
290
|
+
# "%.2f" % 12.345 => "12.34" (instead of "12.35")
|
291
|
+
# See: https://bugs.ruby-lang.org/issues/12548
|
292
|
+
# 1.00.round(2).to_s => "1.0" (instead of "1.00")
|
293
|
+
#
|
294
|
+
# So to round and format correctly, we first use Float#round and then %
|
295
|
+
"%.#{N_RESULT_DECIMAL_DIGITS}fx" %
|
296
|
+
(memo_wise["central_tendency"] / bgem["central_tendency"]).round(N_RESULT_DECIMAL_DIGITS)
|
297
|
+
end.join("|")
|
298
|
+
|
299
|
+
name = memo_wise["name"].partition(": ").last
|
300
|
+
puts "|`#{name}`#{' (none)' if name == '()'}|#{output_str}|"
|
240
301
|
end
|
@@ -0,0 +1,309 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MemoWise
|
4
|
+
class InternalAPI
|
5
|
+
# Create initial mutable state to store memoized values if it doesn't
|
6
|
+
# already exist
|
7
|
+
#
|
8
|
+
# @param [Object] obj
|
9
|
+
# Object in which to create mutable state to store future memoized values
|
10
|
+
#
|
11
|
+
# @return [Object] the passed-in obj
|
12
|
+
def self.create_memo_wise_state!(obj)
|
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, [])
|
55
|
+
end
|
56
|
+
|
57
|
+
obj
|
58
|
+
end
|
59
|
+
|
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
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
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}"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
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
|
145
|
+
end
|
146
|
+
|
147
|
+
# Find the original class for which the given class is the corresponding
|
148
|
+
# "singleton class".
|
149
|
+
#
|
150
|
+
# See https://stackoverflow.com/questions/54531270/retrieve-a-ruby-object-from-its-singleton-class
|
151
|
+
#
|
152
|
+
# @param klass [Class]
|
153
|
+
# Singleton class to find the original class of
|
154
|
+
#
|
155
|
+
# @return Class
|
156
|
+
# Original class for which `klass` is the singleton class.
|
157
|
+
#
|
158
|
+
# @raise ArgumentError
|
159
|
+
# Raises if `klass` is not a singleton class.
|
160
|
+
#
|
161
|
+
def self.original_class_from_singleton(klass)
|
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 ||= {}
|
168
|
+
|
169
|
+
# Search ObjectSpace
|
170
|
+
# * 1:1 relationship of singleton class to original class is documented
|
171
|
+
# * Performance concern: searches all Class objects
|
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
|
176
|
+
end
|
177
|
+
|
178
|
+
# Increment the class's method index counter, and return an index to use for
|
179
|
+
# the given method name.
|
180
|
+
#
|
181
|
+
# @param klass [Class]
|
182
|
+
# Original class on which a method is being memoized
|
183
|
+
#
|
184
|
+
# @param method_name [Symbol]
|
185
|
+
# The name of the method being memoized
|
186
|
+
#
|
187
|
+
# @return [Integer]
|
188
|
+
# The index within `@_memo_wise` to store the method's memoized results
|
189
|
+
def self.next_index!(klass, method_name)
|
190
|
+
# `@_memo_wise_indices` stores the `@_memo_wise` indices of different
|
191
|
+
# method names. We only use this data structure when resetting or
|
192
|
+
# presetting memoization. It looks like:
|
193
|
+
# {
|
194
|
+
# single_arg_method_name: 0,
|
195
|
+
# other_single_arg_method_name: 1
|
196
|
+
# }
|
197
|
+
memo_wise_indices = klass.instance_variable_get(:@_memo_wise_indices)
|
198
|
+
memo_wise_indices ||= klass.instance_variable_set(:@_memo_wise_indices, {})
|
199
|
+
|
200
|
+
# When a parent and child class both use `class << self` to define
|
201
|
+
# memoized class methods, the child class' singleton is not considered a
|
202
|
+
# descendent of the parent class' singleton. Because we store the index
|
203
|
+
# counter as a class variable that can be shared up the inheritance chain,
|
204
|
+
# we want to detect this case and store it on the original class instead
|
205
|
+
# of the singleton to make the counter shared correctly.
|
206
|
+
counter_class = klass.singleton_class? ? original_class_from_singleton(klass) : klass
|
207
|
+
|
208
|
+
# We use a class variable for tracking the index to make this work with
|
209
|
+
# inheritance structures. When a parent and child class both use
|
210
|
+
# MemoWise, we want the child class's index to not "reset" back to 0 and
|
211
|
+
# overwrite the behavior of a memoized parent method. Using a class
|
212
|
+
# variable will share the index data between parent and child classes.
|
213
|
+
#
|
214
|
+
# However, we don't use a class variable for `@_memo_wise_indices`
|
215
|
+
# because we want to allow instance and class methods with the same name
|
216
|
+
# to both be memoized, and using a class variable would share that index
|
217
|
+
# data between them.
|
218
|
+
index = if counter_class.class_variable_defined?(:@@_memo_wise_index_counter)
|
219
|
+
counter_class.class_variable_get(:@@_memo_wise_index_counter)
|
220
|
+
else
|
221
|
+
0
|
222
|
+
end
|
223
|
+
|
224
|
+
memo_wise_indices[method_name] = index
|
225
|
+
counter_class.class_variable_set(:@@_memo_wise_index_counter, index + 1) # rubocop:disable Style/ClassVars
|
226
|
+
|
227
|
+
index
|
228
|
+
end
|
229
|
+
|
230
|
+
# Convention we use for renaming the original method when we replace with
|
231
|
+
# the memoized version in {MemoWise.memo_wise}.
|
232
|
+
#
|
233
|
+
# @param method_name [Symbol]
|
234
|
+
# Name for which to return the renaming for the original method
|
235
|
+
#
|
236
|
+
# @return [Symbol]
|
237
|
+
# Renamed method to use for the original method with name `method_name`
|
238
|
+
#
|
239
|
+
def self.original_memo_wised_name(method_name)
|
240
|
+
:"_memo_wise_original_#{method_name}"
|
241
|
+
end
|
242
|
+
|
243
|
+
# @param target [Class, Module]
|
244
|
+
# The class to which we are prepending MemoWise to provide memoization;
|
245
|
+
# the `InternalAPI` *instance* methods will refer to this `target` class.
|
246
|
+
def initialize(target)
|
247
|
+
@target = target
|
248
|
+
end
|
249
|
+
|
250
|
+
# @return [Class, Module]
|
251
|
+
attr_reader :target
|
252
|
+
|
253
|
+
# @param method_name [Symbol] the name of the memoized method
|
254
|
+
# @return [Integer] the array index in `@_memo_wise_indices` to use to find
|
255
|
+
# the memoization data for the given method
|
256
|
+
def index(method_name)
|
257
|
+
target_class.instance_variable_get(:@_memo_wise_indices)[method_name]
|
258
|
+
end
|
259
|
+
|
260
|
+
# Returns visibility of an instance method defined on class `target`.
|
261
|
+
#
|
262
|
+
# @param method_name [Symbol]
|
263
|
+
# Name of existing *instance* method find the visibility of.
|
264
|
+
#
|
265
|
+
# @return [:private, :protected, :public]
|
266
|
+
# Visibility of existing instance method of the class.
|
267
|
+
#
|
268
|
+
# @raise ArgumentError
|
269
|
+
# Raises `ArgumentError` unless `method_name` is a `Symbol` corresponding
|
270
|
+
# to an existing **instance** method defined on `klass`.
|
271
|
+
#
|
272
|
+
def method_visibility(method_name)
|
273
|
+
if target.private_method_defined?(method_name)
|
274
|
+
:private
|
275
|
+
elsif target.protected_method_defined?(method_name)
|
276
|
+
:protected
|
277
|
+
elsif target.public_method_defined?(method_name)
|
278
|
+
:public
|
279
|
+
else
|
280
|
+
raise ArgumentError, "#{method_name.inspect} must be a method on #{target}"
|
281
|
+
end
|
282
|
+
end
|
283
|
+
|
284
|
+
# Validates that {.memo_wise} has already been called on `method_name`.
|
285
|
+
#
|
286
|
+
# @param method_name [Symbol]
|
287
|
+
# Name of method to validate has already been setup with {.memo_wise}
|
288
|
+
def validate_memo_wised!(method_name)
|
289
|
+
original_name = self.class.original_memo_wised_name(method_name)
|
290
|
+
|
291
|
+
unless target_class.private_method_defined?(original_name)
|
292
|
+
raise ArgumentError, "#{method_name} is not a memo_wised method"
|
293
|
+
end
|
294
|
+
end
|
295
|
+
|
296
|
+
private
|
297
|
+
|
298
|
+
# @return [Class] where we look for method definitions
|
299
|
+
def target_class
|
300
|
+
if target.instance_of?(Class)
|
301
|
+
# A class's methods are defined in its singleton class
|
302
|
+
target.singleton_class
|
303
|
+
else
|
304
|
+
# An object's methods are defined in its class
|
305
|
+
target.class
|
306
|
+
end
|
307
|
+
end
|
308
|
+
end
|
309
|
+
end
|
data/lib/memo_wise/version.rb
CHANGED