memo_wise 0.3.0 → 1.2.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/.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 +51 -3
- data/Gemfile +2 -1
- data/Gemfile.lock +25 -16
- data/LICENSE.txt +1 -1
- data/README.md +103 -32
- data/benchmarks/Gemfile +5 -3
- data/benchmarks/benchmarks.rb +163 -100
- data/lib/memo_wise/internal_api.rb +252 -0
- data/lib/memo_wise/version.rb +1 -1
- data/lib/memo_wise.rb +314 -267
- 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,173 @@ 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
66
|
# rubocop:disable Security/Eval
|
61
|
-
# rubocop:disable Style/DocumentDynamicEvalDefinition
|
62
67
|
eval <<-CLASS, binding, __FILE__, __LINE__ + 1
|
68
|
+
# For these methods, we alternately return truthy and falsey values in
|
69
|
+
# order to benchmark memoization when the result of a method is falsey.
|
70
|
+
#
|
71
|
+
# We do this by checking if the first argument to a method is even.
|
63
72
|
class #{benchmark_gem.klass}Example
|
64
|
-
#{benchmark_gem.
|
73
|
+
#{benchmark_gem.activation_code}
|
65
74
|
|
66
75
|
def no_args
|
67
76
|
100
|
68
77
|
end
|
69
78
|
#{benchmark_gem.memoization_method} :no_args
|
70
79
|
|
80
|
+
# For the no_args case, we can't depend on arguments to alternate between
|
81
|
+
# returning truthy and falsey values, so instead make two separate
|
82
|
+
# no_args methods
|
83
|
+
def no_args_falsey
|
84
|
+
nil
|
85
|
+
end
|
86
|
+
#{benchmark_gem.memoization_method} :no_args_falsey
|
87
|
+
|
88
|
+
def one_positional_arg(a)
|
89
|
+
100 if a.positive?
|
90
|
+
end
|
91
|
+
#{benchmark_gem.memoization_method} :one_positional_arg
|
92
|
+
|
71
93
|
def positional_args(a, b)
|
72
|
-
100
|
94
|
+
100 if a.positive?
|
73
95
|
end
|
74
96
|
#{benchmark_gem.memoization_method} :positional_args
|
75
97
|
|
98
|
+
def one_keyword_arg(a:)
|
99
|
+
100 if a.positive?
|
100
|
+
end
|
101
|
+
#{benchmark_gem.memoization_method} :one_keyword_arg
|
102
|
+
|
76
103
|
def keyword_args(a:, b:)
|
77
|
-
100
|
104
|
+
100 if a.positive?
|
78
105
|
end
|
79
106
|
#{benchmark_gem.memoization_method} :keyword_args
|
80
107
|
|
81
108
|
def positional_and_keyword_args(a, b:)
|
82
|
-
100
|
109
|
+
100 if a.positive?
|
83
110
|
end
|
84
111
|
#{benchmark_gem.memoization_method} :positional_and_keyword_args
|
85
112
|
|
86
113
|
def positional_and_splat_args(a, *args)
|
87
|
-
100
|
114
|
+
100 if a.positive?
|
88
115
|
end
|
89
116
|
#{benchmark_gem.memoization_method} :positional_and_splat_args
|
90
117
|
|
91
118
|
def keyword_and_double_splat_args(a:, **kwargs)
|
92
|
-
100
|
119
|
+
100 if a.positive?
|
93
120
|
end
|
94
121
|
#{benchmark_gem.memoization_method} :keyword_and_double_splat_args
|
95
122
|
|
96
123
|
def positional_splat_keyword_and_double_splat_args(a, *args, b:, **kwargs)
|
97
|
-
100
|
124
|
+
100 if a.positive?
|
98
125
|
end
|
99
126
|
#{benchmark_gem.memoization_method} :positional_splat_keyword_and_double_splat_args
|
100
127
|
end
|
101
128
|
CLASS
|
102
|
-
# rubocop:enable Style/DocumentDynamicEvalDefinition
|
103
129
|
# rubocop:enable Security/Eval
|
104
130
|
end
|
105
131
|
|
106
132
|
# We pre-create argument lists for our memoized methods with arguments, so that
|
107
133
|
# our benchmarks are running the exact same inputs for each case.
|
108
|
-
|
134
|
+
#
|
135
|
+
# NOTE: The proportion of falsey results is 1/N_UNIQUE_ARGUMENTS (because for
|
136
|
+
# the methods with arguments we are truthy for all but the first unique argument
|
137
|
+
# set, and for zero-arity methods we manually execute `no_args` N_TRUTHY_RESULTS
|
138
|
+
# times per each execution of `no_args_falsey`). This number was selected as the
|
139
|
+
# lowest number such that this logic:
|
140
|
+
#
|
141
|
+
# output = hash[key]
|
142
|
+
# if output || hash.key?(key)
|
143
|
+
# output
|
144
|
+
# else
|
145
|
+
# hash[key] = _original_method(...)
|
146
|
+
# end
|
147
|
+
#
|
148
|
+
# is consistently faster for cached lookups than:
|
149
|
+
#
|
150
|
+
# hash.fetch(key) do
|
151
|
+
# hash[key] = _original_method(...)
|
152
|
+
# end
|
153
|
+
#
|
154
|
+
# as a result of `Hash#[]` having less overhead than `Hash#fetch`.
|
155
|
+
#
|
156
|
+
# We believe this is a reasonable choice because we believe most memoized method
|
157
|
+
# results will be truthy, and so that is the case we should most optimize for.
|
158
|
+
# However, we do not want to completely remove falsey method results from these
|
159
|
+
# benchmarks because we do want to catch performance regressions for that case,
|
160
|
+
# since it has its own "hot path."
|
161
|
+
N_UNIQUE_ARGUMENTS = 30
|
109
162
|
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.
|
163
|
+
N_TRUTHY_RESULTS = N_UNIQUE_ARGUMENTS - 1
|
164
|
+
N_RESULT_DECIMAL_DIGITS = 2
|
165
|
+
|
166
|
+
# Each method within these benchmarks is initially run once to memoize the
|
167
|
+
# result value, so our benchmark only tests memoized retrieval time.
|
168
|
+
benchmark_lambdas = [
|
169
|
+
lambda do |x, instance, benchmark_gem|
|
170
|
+
instance.no_args_falsey
|
121
171
|
instance.no_args
|
122
172
|
|
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
|
173
|
+
x.report("#{benchmark_gem.benchmark_name}: ()") do
|
174
|
+
instance.no_args_falsey
|
175
|
+
N_TRUTHY_RESULTS.times { instance.no_args }
|
176
|
+
end
|
177
|
+
end,
|
178
|
+
lambda do |x, instance, benchmark_gem|
|
179
|
+
ARGUMENTS.each { |a, _| instance.one_positional_arg(a) }
|
133
180
|
|
134
|
-
#
|
135
|
-
|
181
|
+
x.report("#{benchmark_gem.benchmark_name}: (a)") do
|
182
|
+
ARGUMENTS.each { |a, _| instance.one_positional_arg(a) }
|
183
|
+
end
|
184
|
+
end,
|
185
|
+
lambda do |x, instance, benchmark_gem|
|
136
186
|
ARGUMENTS.each { |a, b| instance.positional_args(a, b) }
|
137
187
|
|
138
188
|
x.report("#{benchmark_gem.benchmark_name}: (a, b)") do
|
139
189
|
ARGUMENTS.each { |a, b| instance.positional_args(a, b) }
|
140
190
|
end
|
141
|
-
end
|
142
|
-
|
143
|
-
|
144
|
-
end
|
191
|
+
end,
|
192
|
+
lambda do |x, instance, benchmark_gem|
|
193
|
+
ARGUMENTS.each { |a, _| instance.one_keyword_arg(a: a) }
|
145
194
|
|
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.
|
195
|
+
x.report("#{benchmark_gem.benchmark_name}: (a:)") do
|
196
|
+
ARGUMENTS.each { |a, _| instance.one_keyword_arg(a: a) }
|
197
|
+
end
|
198
|
+
end,
|
199
|
+
lambda do |x, instance, benchmark_gem|
|
153
200
|
ARGUMENTS.each { |a, b| instance.keyword_args(a: a, b: b) }
|
154
201
|
|
155
202
|
x.report("#{benchmark_gem.benchmark_name}: (a:, b:)") do
|
156
203
|
ARGUMENTS.each { |a, b| instance.keyword_args(a: a, b: b) }
|
157
204
|
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.
|
205
|
+
end,
|
206
|
+
lambda do |x, instance, benchmark_gem|
|
170
207
|
ARGUMENTS.each { |a, b| instance.positional_and_keyword_args(a, b: b) }
|
171
208
|
|
172
209
|
x.report("#{benchmark_gem.benchmark_name}: (a, b:)") do
|
173
210
|
ARGUMENTS.each { |a, b| instance.positional_and_keyword_args(a, b: b) }
|
174
211
|
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.
|
212
|
+
end,
|
213
|
+
lambda do |x, instance, benchmark_gem|
|
187
214
|
ARGUMENTS.each { |a, b| instance.positional_and_splat_args(a, b) }
|
188
215
|
|
189
216
|
x.report("#{benchmark_gem.benchmark_name}: (a, *args)") do
|
190
217
|
ARGUMENTS.each { |a, b| instance.positional_and_splat_args(a, b) }
|
191
218
|
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.
|
219
|
+
end,
|
220
|
+
lambda do |x, instance, benchmark_gem|
|
204
221
|
ARGUMENTS.each { |a, b| instance.keyword_and_double_splat_args(a: a, b: b) }
|
205
222
|
|
206
223
|
x.report(
|
@@ -210,18 +227,8 @@ Benchmark.ips do |x|
|
|
210
227
|
instance.keyword_and_double_splat_args(a: a, b: b)
|
211
228
|
end
|
212
229
|
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.
|
230
|
+
end,
|
231
|
+
lambda do |x, instance, benchmark_gem|
|
225
232
|
ARGUMENTS.each do |a, b|
|
226
233
|
instance.positional_splat_keyword_and_double_splat_args(a, b, b: b, a: a)
|
227
234
|
end
|
@@ -235,6 +242,62 @@ Benchmark.ips do |x|
|
|
235
242
|
end
|
236
243
|
end
|
237
244
|
end
|
245
|
+
]
|
246
|
+
|
247
|
+
# We benchmark different cases separately, to ensure that slow performance in
|
248
|
+
# one method or code path isn't hidden by fast performance in another.
|
249
|
+
benchmark_lambdas.map do |benchmark|
|
250
|
+
json_file = Tempfile.new
|
251
|
+
|
252
|
+
Benchmark.ips do |x|
|
253
|
+
x.config(suite: suite)
|
254
|
+
BENCHMARK_GEMS.each do |benchmark_gem|
|
255
|
+
instance = Object.const_get("#{benchmark_gem.klass}Example").new
|
256
|
+
|
257
|
+
benchmark.call(x, instance, benchmark_gem)
|
258
|
+
end
|
259
|
+
|
260
|
+
x.compare!
|
261
|
+
x.json! json_file.path
|
262
|
+
end
|
263
|
+
|
264
|
+
JSON.parse(json_file.read)
|
265
|
+
end.each_with_index do |benchmark_json, i|
|
266
|
+
# We print a comparison table after we run each benchmark to copy into our
|
267
|
+
# README.md
|
268
|
+
|
269
|
+
# MemoWise will not appear in the comparison table, but we will use it to
|
270
|
+
# compare against other gems' benchmarks
|
271
|
+
memo_wise = benchmark_json.find { _1["name"].include?("MemoWise") }
|
272
|
+
benchmark_json.delete(memo_wise)
|
273
|
+
|
274
|
+
# Sort benchmarks by gem name to alphabetize our final output table.
|
275
|
+
benchmark_json.sort_by! { _1["name"] }
|
276
|
+
|
277
|
+
# Print headers based on the first benchmark_json
|
278
|
+
if i.zero?
|
279
|
+
benchmark_headers = benchmark_json.map do |benchmark_gem|
|
280
|
+
# Gem name is of the form:
|
281
|
+
# "MemoWise (1.1.0): ()"
|
282
|
+
# We use this mapping to get a header of the form
|
283
|
+
# "`MemoWise` (1.1.0)
|
284
|
+
gem_name_parts = benchmark_gem["name"].split
|
285
|
+
"`#{gem_name_parts[0]}` #{gem_name_parts[1][...-1]}"
|
286
|
+
end.join("|")
|
287
|
+
puts "|Method arguments|#{benchmark_headers}|"
|
288
|
+
puts "#{'|--' * (benchmark_json.size + 1)}|"
|
289
|
+
end
|
238
290
|
|
239
|
-
|
291
|
+
output_str = benchmark_json.map do |bgem|
|
292
|
+
# "%.2f" % 12.345 => "12.34" (instead of "12.35")
|
293
|
+
# See: https://bugs.ruby-lang.org/issues/12548
|
294
|
+
# 1.00.round(2).to_s => "1.0" (instead of "1.00")
|
295
|
+
#
|
296
|
+
# So to round and format correctly, we first use Float#round and then %
|
297
|
+
"%.#{N_RESULT_DECIMAL_DIGITS}fx" %
|
298
|
+
(memo_wise["central_tendency"] / bgem["central_tendency"]).round(N_RESULT_DECIMAL_DIGITS)
|
299
|
+
end.join("|")
|
300
|
+
|
301
|
+
name = memo_wise["name"].partition(": ").last
|
302
|
+
puts "|`#{name}`#{' (none)' if name == '()'}|#{output_str}|"
|
240
303
|
end
|
@@ -0,0 +1,252 @@
|
|
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
|
+
# Search ObjectSpace
|
165
|
+
# * 1:1 relationship of singleton class to original class is documented
|
166
|
+
# * Performance concern: searches all Class objects
|
167
|
+
# But, only runs at load time
|
168
|
+
ObjectSpace.each_object(Module).find do |cls|
|
169
|
+
cls.singleton_class == klass
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
# Convention we use for renaming the original method when we replace with
|
174
|
+
# the memoized version in {MemoWise.memo_wise}.
|
175
|
+
#
|
176
|
+
# @param method_name [Symbol]
|
177
|
+
# Name for which to return the renaming for the original method
|
178
|
+
#
|
179
|
+
# @return [Symbol]
|
180
|
+
# Renamed method to use for the original method with name `method_name`
|
181
|
+
#
|
182
|
+
def self.original_memo_wised_name(method_name)
|
183
|
+
:"_memo_wise_original_#{method_name}"
|
184
|
+
end
|
185
|
+
|
186
|
+
# @param target [Class, Module]
|
187
|
+
# The class to which we are prepending MemoWise to provide memoization;
|
188
|
+
# the `InternalAPI` *instance* methods will refer to this `target` class.
|
189
|
+
def initialize(target)
|
190
|
+
@target = target
|
191
|
+
end
|
192
|
+
|
193
|
+
# @return [Class, Module]
|
194
|
+
attr_reader :target
|
195
|
+
|
196
|
+
# @param method_name [Symbol] the name of the memoized method
|
197
|
+
# @return [Integer] the array index in `@_memo_wise_indices` to use to find
|
198
|
+
# the memoization data for the given method
|
199
|
+
def index(method_name)
|
200
|
+
target_class.instance_variable_get(:@_memo_wise_indices)[method_name]
|
201
|
+
end
|
202
|
+
|
203
|
+
# Returns visibility of an instance method defined on class `target`.
|
204
|
+
#
|
205
|
+
# @param method_name [Symbol]
|
206
|
+
# Name of existing *instance* method find the visibility of.
|
207
|
+
#
|
208
|
+
# @return [:private, :protected, :public]
|
209
|
+
# Visibility of existing instance method of the class.
|
210
|
+
#
|
211
|
+
# @raise ArgumentError
|
212
|
+
# Raises `ArgumentError` unless `method_name` is a `Symbol` corresponding
|
213
|
+
# to an existing **instance** method defined on `klass`.
|
214
|
+
#
|
215
|
+
def method_visibility(method_name)
|
216
|
+
if target.private_method_defined?(method_name)
|
217
|
+
:private
|
218
|
+
elsif target.protected_method_defined?(method_name)
|
219
|
+
:protected
|
220
|
+
elsif target.public_method_defined?(method_name)
|
221
|
+
:public
|
222
|
+
else
|
223
|
+
raise ArgumentError, "#{method_name.inspect} must be a method on #{target}"
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
# Validates that {.memo_wise} has already been called on `method_name`.
|
228
|
+
#
|
229
|
+
# @param method_name [Symbol]
|
230
|
+
# Name of method to validate has already been setup with {.memo_wise}
|
231
|
+
def validate_memo_wised!(method_name)
|
232
|
+
original_name = self.class.original_memo_wised_name(method_name)
|
233
|
+
|
234
|
+
unless target_class.private_method_defined?(original_name)
|
235
|
+
raise ArgumentError, "#{method_name} is not a memo_wised method"
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
private
|
240
|
+
|
241
|
+
# @return [Class] where we look for method definitions
|
242
|
+
def target_class
|
243
|
+
if target.instance_of?(Class)
|
244
|
+
# A class's methods are defined in its singleton class
|
245
|
+
target.singleton_class
|
246
|
+
else
|
247
|
+
# An object's methods are defined in its class
|
248
|
+
target.class
|
249
|
+
end
|
250
|
+
end
|
251
|
+
end
|
252
|
+
end
|
data/lib/memo_wise/version.rb
CHANGED