memo_wise 1.1.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fa12aeb35a6c4ca923eb23d70867b41600795143570f3d7c5b6444c76547980
4
- data.tar.gz: bac4074e758731ac5d20925ab32ef3cd78d10a1c030228df76c1868e104e1a40
3
+ metadata.gz: 3a2fbbb2a403502d6a492068a9a0c8c103f717ea5b32e5029ba1a0aebab61db6
4
+ data.tar.gz: c2c8118caa1621670b6cd666f92147eb5c3f069c7b910062ed0f635568764dd7
5
5
  SHA512:
6
- metadata.gz: 52e8b1543bca54371a2743fbc22b3cee32ba68a050ac414e373ca2027faba214956bca3aef61a05d25ca1d339b3e4ae01a417cdb5d1a4a407fba16b0a3cae56e
7
- data.tar.gz: 1bc40ccaab12ed598cc86d62d51ca8081a386b208559ac49788ff87601c45809958c2e936692fbb2e78f3ff0f04b124636f795d941558239fc20edf04f7b979b
6
+ metadata.gz: 699415e3445e60bf6037fdb6887a064a4d082159be15c8c52a0711be5b3b233e3a7caa974f6d31df769d9bd0c3825d7a9f11b17b1e3b98ec9202ee8224801204
7
+ data.tar.gz: f6f377da39902d30310e7304e0caa7195e372c5fe0666e05ea30d2c2f936f3cc7d00c3588e453d5ab4ba7393a4f747f5c328acd4f4024cfa126ecb5f683fd24c
@@ -1,4 +1,4 @@
1
1
  **Before merging:**
2
2
 
3
- - [ ] Copy the latest benchmark results into the `README.md` and update this PR
4
- - [ ] If this change merits an update to `CHANGELOG.md`, add an entry following Keep a Changelog [guidelines](https://keepachangelog.com/en/1.0.0/) with [semantic versioning](https://semver.org/)
3
+ - [ ] Copy the table printed at the end of the latest benchmark results into the `README.md` and update this PR
4
+ - [ ] If this change merits an update to `CHANGELOG.md`, add an entry following Keep a Changelog [guidelines](https://keepachangelog.com/en/1.0.0/) with [semantic versioning](https://semver.org/)
data/.rubocop.yml CHANGED
@@ -1,5 +1,17 @@
1
1
  inherit_gem:
2
2
  panolint: rubocop.yml
3
3
 
4
+ Layout/LineLength:
5
+ Max: 120
6
+
7
+ Metrics/ModuleLength:
8
+ Enabled: false
9
+
10
+ Metrics/PerceivedComplexity:
11
+ Enabled: false
12
+
4
13
  Style/DocumentDynamicEvalDefinition:
5
14
  Enabled: false
15
+
16
+ Style/MultipleComparison:
17
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -6,24 +6,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ## Unreleased
9
+ - Nothing yet!
10
+
11
+ ## [1.2.0] - 2021-11-10
12
+
9
13
  ### Updated
10
- - (Nothing, yet)
14
+ - Improved performance of all methods by using an outer Array instead of a Hash
15
+ - Improved performance for multi-argument methods and simplify internal data
16
+ structures
17
+
11
18
  ### Fixed
12
- - (Nothing, yet)
19
+ - Removed use of #hash due to potential of hash collisions
20
+ - Updated internal local variable names to avoid name collisions with method
21
+ arguments
22
+
13
23
  ### Breaking Changes
14
24
  - None
15
25
 
16
26
  ## [1.1.0] - 2021-07-29
17
27
  ### Updated
18
- - Improve performance across the board by:
28
+ - Improved performance across the board by:
19
29
  - removing `Hash#fetch`
20
30
  - using `Array#hash`
21
31
  - avoiding multi-layer hash lookups for multi-argument methods
22
32
  - optimizing for truthy results
23
33
  - Add `dry-core` to benchmarks in README
34
+
24
35
  ### Fixed
25
- - Fix usage on module singleton classes
26
- - Fix usage on module which would be extended by other classes
36
+ - Fixed usage on module singleton classes
37
+ - Fixed usage on module which would be extended by other classes
38
+
27
39
  ### Breaking Changes
28
40
  - None
29
41
 
@@ -31,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
31
43
  ### Added
32
44
  - Support for `.preset_memo_wise` on class methods
33
45
  - Support for `.reset_memo_wise` on class methods
46
+
34
47
  ### Updated
35
48
  - Improved performance for common cases by reducing array allocations
36
49
 
data/Gemfile.lock CHANGED
@@ -14,7 +14,7 @@ GIT
14
14
  PATH
15
15
  remote: .
16
16
  specs:
17
- memo_wise (1.1.0)
17
+ memo_wise (1.2.0)
18
18
 
19
19
  GEM
20
20
  remote: https://rubygems.org/
@@ -27,7 +27,7 @@ GEM
27
27
  ansi (1.5.0)
28
28
  ast (2.4.2)
29
29
  brakeman (5.1.1)
30
- codecov (0.5.2)
30
+ codecov (0.6.0)
31
31
  simplecov (>= 0.15, < 0.22)
32
32
  concurrent-ruby (1.1.9)
33
33
  diff-lcs (1.4.4)
@@ -117,4 +117,4 @@ DEPENDENCIES
117
117
  yard-doctest (~> 0.1)
118
118
 
119
119
  BUNDLED WITH
120
- 2.2.3
120
+ 2.2.28
data/README.md CHANGED
@@ -56,6 +56,15 @@ class Example
56
56
  x
57
57
  end
58
58
  memo_wise :slow_value
59
+
60
+ private
61
+
62
+ # maintains privacy of the memoized method
63
+ def private_slow_method(x)
64
+ sleep x
65
+ x
66
+ end
67
+ memo_wise :private_slow_method
59
68
  end
60
69
 
61
70
  ex = Example.new
@@ -102,44 +111,38 @@ For more usage details, see our detailed [documentation](#documentation).
102
111
 
103
112
  ## Benchmarks
104
113
 
105
- Benchmarks measure memoized value retrieval time using
106
- [`benchmark-ips`](https://github.com/evanphx/benchmark-ips). Benchmarks are
107
- run in GitHub Actions and updated in every PR that changes code.
108
-
109
- **Values >1.00x represent how much _slower_ each gem’s memoized value retrieval
110
- is than the baseline.**
111
-
112
- Benchmarks using Ruby 3.0.2:
113
-
114
- |Method arguments|`memo_wise` (latest)|`dry-core` (0.7.1)|`memery` (1.4.0)|
115
- |--|--|--|--|
116
- |`()` (none)|baseline|0.98-1.44x|10.40-14.95x|
117
- |`(a)`|baseline|2.10x|8.39x|
118
- |`(a, b)`|baseline|baseline\*|4.45x|
119
- |`(a:)`|baseline|1.89x|17.37x|
120
- |`(a:, b:)`|baseline|baseline\*|9.66x|
121
- |`(a, b:)`|baseline|baseline\*|8.78x|
122
- |`(a, *args)`|baseline|1.88x|4.07x|
123
- |`(a:, **kwargs)`|baseline|1.41x|4.98x|
124
- |`(a, *args, b:, **kwargs)`|baseline|baseline\*|2.50x|
125
-
126
- \*Indicates a run that was slower than the baseline but the difference was not
127
- significant.
128
-
129
- The following benchmarks are run on Ruby 2.7.4 because these gems raise errors
130
- in Ruby 3.0.2 due to their incorrect handling of keyword arguments:
131
-
132
- |Method arguments|`memo_wise` (latest)|`ddmemoize` (1.0.0)|`memoist` (0.16.2)|`memoized` (1.0.2)|`memoizer` (1.0.3)|
133
- |--|--|--|--|--|--|
134
- |`()` (none)|baseline|20.96-25.44x|2.49-3.31x|1.06-1.48x|2.96-3.83x|
135
- |`(a)`|baseline|19.37x|13.28x|9.31x|10.78x|
136
- |`(a, b)`|baseline|3.65x|2.64x|2.00x|2.28x|
137
- |`(a:)`|baseline|25.88x|21.74x|17.68x|19.17x|
138
- |`(a:, b:)`|baseline|5.72x|4.57x|3.90x|4.42x|
139
- |`(a, b:)`|baseline|5.63x|5.06x|4.20x|4.18x|
140
- |`(a, *args)`|baseline|5.13x|3.89x|3.25x|3.31x|
141
- |`(a:, **kwargs)`|baseline|3.23x|2.55x|2.33x|2.47x|
142
- |`(a, *args, b:, **kwargs)`|baseline|2.51x|2.00x|1.84x|1.88x|
114
+ Benchmarks are run in GitHub Actions, and the tables below are updated with every code change. **Values >1.00x represent how much _slower_ each gem’s memoized value retrieval is than the latest commit of `MemoWise`**, according to [`benchmark-ips`](https://github.com/evanphx/benchmark-ips) (2.9.1).
115
+
116
+ Results using Ruby 3.0.2:
117
+
118
+ |Method arguments|`Dry::Core`\* (0.7.1)|`Memery` (1.4.0)|
119
+ |--|--|--|
120
+ |`()` (none)|1.51x|19.82x|
121
+ |`(a)`|2.30x|11.38x|
122
+ |`(a, b)`|0.45x|2.10x|
123
+ |`(a:)`|2.20x|22.83x|
124
+ |`(a:, b:)`|0.49x|4.53x|
125
+ |`(a, b:)`|0.46x|4.35x|
126
+ |`(a, *args)`|0.89x|2.03x|
127
+ |`(a:, **kwargs)`|0.82x|3.18x|
128
+ |`(a, *args, b:, **kwargs)`|0.60x|1.62x|
129
+
130
+ \* `Dry::Core`
131
+ [may cause incorrect behavior caused by hash collisions](https://github.com/dry-rb/dry-core/issues/63).
132
+
133
+ Results using Ruby 2.7.4 (because these gems raise errors in Ruby 3.x):
134
+
135
+ |Method arguments|`DDMemoize` (1.0.0)|`Memoist` (0.16.2)|`Memoized` (1.0.2)|`Memoizer` (1.0.3)|
136
+ |--|--|--|--|--|
137
+ |`()` (none)|35.29x|3.46x|1.67x|4.27x|
138
+ |`(a)`|25.04x|16.96x|12.83x|14.68x|
139
+ |`(a, b)`|3.20x|2.28x|1.84x|2.04x|
140
+ |`(a:)`|34.17x|27.77x|24.07x|25.39x|
141
+ |`(a:, b:)`|5.22x|4.29x|3.74x|4.00x|
142
+ |`(a, b:)`|4.81x|3.99x|3.49x|3.66x|
143
+ |`(a, *args)`|3.21x|2.30x|1.97x|2.00x|
144
+ |`(a:, **kwargs)`|2.84x|2.39x|2.12x|2.19x|
145
+ |`(a, *args, b:, **kwargs)`|2.10x|1.80x|1.67x|1.66x|
143
146
 
144
147
  You can run benchmarks yourself with:
145
148
 
@@ -207,6 +210,7 @@ We've written more about MemoWise in a series of blog posts:
207
210
 
208
211
  - [Introducing: MemoWise](https://medium.com/building-panorama-education/introducing-memowise-51a5f0523489)
209
212
  - [Optimizing MemoWise Performance](https://ja.cob.land/optimizing-memowise-performance)
213
+ - [Esosteric Ruby in MemoWise](https://jemma.dev/blog/esoteric-ruby-in-memowise)
210
214
 
211
215
  ## Logo
212
216
 
@@ -2,6 +2,7 @@
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
@@ -49,7 +50,6 @@ end
49
50
  # using it to minimize the chance that our benchmarks are affected by ordering.
50
51
  # NOTE: Some gems do not yet work in Ruby 3 so we only test with them if they've
51
52
  # been `require`d.
52
- # rubocop:disable Layout/LineLength
53
53
  BENCHMARK_GEMS = [
54
54
  BenchmarkGem.new(MemoWise, "prepend MemoWise", :memo_wise),
55
55
  (BenchmarkGem.new(DDMemoize, "DDMemoize.activate(self)", :memoize) if defined?(DDMemoize)),
@@ -59,7 +59,6 @@ BENCHMARK_GEMS = [
59
59
  (BenchmarkGem.new(Memoized, "include Memoized", :memoize) if defined?(Memoized)),
60
60
  (BenchmarkGem.new(Memoizer, "include Memoizer", :memoize) if defined?(Memoizer))
61
61
  ].compact.shuffle
62
- # rubocop:enable Layout/LineLength
63
62
 
64
63
  # Use metaprogramming to ensure that each class is created in exactly the
65
64
  # the same way.
@@ -87,42 +86,42 @@ BENCHMARK_GEMS.each do |benchmark_gem|
87
86
  #{benchmark_gem.memoization_method} :no_args_falsey
88
87
 
89
88
  def one_positional_arg(a)
90
- 100 if a.even?
89
+ 100 if a.positive?
91
90
  end
92
91
  #{benchmark_gem.memoization_method} :one_positional_arg
93
92
 
94
93
  def positional_args(a, b)
95
- 100 if a.even?
94
+ 100 if a.positive?
96
95
  end
97
96
  #{benchmark_gem.memoization_method} :positional_args
98
97
 
99
98
  def one_keyword_arg(a:)
100
- 100 if a.even?
99
+ 100 if a.positive?
101
100
  end
102
101
  #{benchmark_gem.memoization_method} :one_keyword_arg
103
102
 
104
103
  def keyword_args(a:, b:)
105
- 100 if a.even?
104
+ 100 if a.positive?
106
105
  end
107
106
  #{benchmark_gem.memoization_method} :keyword_args
108
107
 
109
108
  def positional_and_keyword_args(a, b:)
110
- 100 if a.even?
109
+ 100 if a.positive?
111
110
  end
112
111
  #{benchmark_gem.memoization_method} :positional_and_keyword_args
113
112
 
114
113
  def positional_and_splat_args(a, *args)
115
- 100 if a.even?
114
+ 100 if a.positive?
116
115
  end
117
116
  #{benchmark_gem.memoization_method} :positional_and_splat_args
118
117
 
119
118
  def keyword_and_double_splat_args(a:, **kwargs)
120
- 100 if a.even?
119
+ 100 if a.positive?
121
120
  end
122
121
  #{benchmark_gem.memoization_method} :keyword_and_double_splat_args
123
122
 
124
123
  def positional_splat_keyword_and_double_splat_args(a, *args, b:, **kwargs)
125
- 100 if a.even?
124
+ 100 if a.positive?
126
125
  end
127
126
  #{benchmark_gem.memoization_method} :positional_splat_keyword_and_double_splat_args
128
127
  end
@@ -132,155 +131,93 @@ end
132
131
 
133
132
  # We pre-create argument lists for our memoized methods with arguments, so that
134
133
  # our benchmarks are running the exact same inputs for each case.
135
- N_UNIQUE_ARGUMENTS = 100
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
136
162
  ARGUMENTS = Array.new(N_UNIQUE_ARGUMENTS) { |i| [i, i + 1] }
163
+ N_TRUTHY_RESULTS = N_UNIQUE_ARGUMENTS - 1
164
+ N_RESULT_DECIMAL_DIGITS = 2
137
165
 
138
- # We benchmark different cases separately, to ensure that slow performance in
139
- # one method or code path isn't hidden by fast performance in another.
140
-
141
- Benchmark.ips do |x|
142
- x.config(suite: suite)
143
- BENCHMARK_GEMS.each do |benchmark_gem|
144
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
145
-
146
- # Run once to memoize the result value, so our benchmark only tests memoized
147
- # retrieval time.
148
- instance.no_args
149
-
150
- x.report("#{benchmark_gem.benchmark_name}: () => truthy") do
151
- instance.no_args
152
- end
153
- end
154
-
155
- x.compare!
156
- end
157
-
158
- Benchmark.ips do |x|
159
- x.config(suite: suite)
160
- BENCHMARK_GEMS.each do |benchmark_gem|
161
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
162
-
163
- # Run once to memoize the result value, so our benchmark only tests memoized
164
- # retrieval time.
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|
165
170
  instance.no_args_falsey
171
+ instance.no_args
166
172
 
167
- x.report("#{benchmark_gem.benchmark_name}: () => falsey") do
173
+ x.report("#{benchmark_gem.benchmark_name}: ()") do
168
174
  instance.no_args_falsey
175
+ N_TRUTHY_RESULTS.times { instance.no_args }
169
176
  end
170
- end
171
-
172
- x.compare!
173
- end
174
-
175
- Benchmark.ips do |x|
176
- x.config(suite: suite)
177
- BENCHMARK_GEMS.each do |benchmark_gem|
178
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
179
-
180
- # Run once with each set of arguments to memoize the result values, so our
181
- # benchmark only tests memoized retrieval time.
177
+ end,
178
+ lambda do |x, instance, benchmark_gem|
182
179
  ARGUMENTS.each { |a, _| instance.one_positional_arg(a) }
183
180
 
184
181
  x.report("#{benchmark_gem.benchmark_name}: (a)") do
185
182
  ARGUMENTS.each { |a, _| instance.one_positional_arg(a) }
186
183
  end
187
- end
188
-
189
- x.compare!
190
- end
191
-
192
- Benchmark.ips do |x|
193
- x.config(suite: suite)
194
- BENCHMARK_GEMS.each do |benchmark_gem|
195
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
196
-
197
- # Run once with each set of arguments to memoize the result values, so our
198
- # benchmark only tests memoized retrieval time.
184
+ end,
185
+ lambda do |x, instance, benchmark_gem|
199
186
  ARGUMENTS.each { |a, b| instance.positional_args(a, b) }
200
187
 
201
188
  x.report("#{benchmark_gem.benchmark_name}: (a, b)") do
202
189
  ARGUMENTS.each { |a, b| instance.positional_args(a, b) }
203
190
  end
204
- end
205
-
206
- x.compare!
207
- end
208
-
209
- Benchmark.ips do |x|
210
- x.config(suite: suite)
211
- BENCHMARK_GEMS.each do |benchmark_gem|
212
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
213
-
214
- # Run once with each set of arguments to memoize the result values, so our
215
- # benchmark only tests memoized retrieval time.
191
+ end,
192
+ lambda do |x, instance, benchmark_gem|
216
193
  ARGUMENTS.each { |a, _| instance.one_keyword_arg(a: a) }
217
194
 
218
195
  x.report("#{benchmark_gem.benchmark_name}: (a:)") do
219
196
  ARGUMENTS.each { |a, _| instance.one_keyword_arg(a: a) }
220
197
  end
221
- end
222
-
223
- x.compare!
224
- end
225
-
226
- Benchmark.ips do |x|
227
- x.config(suite: suite)
228
- BENCHMARK_GEMS.each do |benchmark_gem|
229
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
230
-
231
- # Run once with each set of arguments to memoize the result values, so our
232
- # benchmark only tests memoized retrieval time.
198
+ end,
199
+ lambda do |x, instance, benchmark_gem|
233
200
  ARGUMENTS.each { |a, b| instance.keyword_args(a: a, b: b) }
234
201
 
235
202
  x.report("#{benchmark_gem.benchmark_name}: (a:, b:)") do
236
203
  ARGUMENTS.each { |a, b| instance.keyword_args(a: a, b: b) }
237
204
  end
238
- end
239
-
240
- x.compare!
241
- end
242
-
243
- Benchmark.ips do |x|
244
- x.config(suite: suite)
245
- BENCHMARK_GEMS.each do |benchmark_gem|
246
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
247
-
248
- # Run once with each set of arguments to memoize the result values, so our
249
- # benchmark only tests memoized retrieval time.
205
+ end,
206
+ lambda do |x, instance, benchmark_gem|
250
207
  ARGUMENTS.each { |a, b| instance.positional_and_keyword_args(a, b: b) }
251
208
 
252
209
  x.report("#{benchmark_gem.benchmark_name}: (a, b:)") do
253
210
  ARGUMENTS.each { |a, b| instance.positional_and_keyword_args(a, b: b) }
254
211
  end
255
- end
256
-
257
- x.compare!
258
- end
259
-
260
- Benchmark.ips do |x|
261
- x.config(suite: suite)
262
- BENCHMARK_GEMS.each do |benchmark_gem|
263
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
264
-
265
- # Run once with each set of arguments to memoize the result values, so our
266
- # benchmark only tests memoized retrieval time.
212
+ end,
213
+ lambda do |x, instance, benchmark_gem|
267
214
  ARGUMENTS.each { |a, b| instance.positional_and_splat_args(a, b) }
268
215
 
269
216
  x.report("#{benchmark_gem.benchmark_name}: (a, *args)") do
270
217
  ARGUMENTS.each { |a, b| instance.positional_and_splat_args(a, b) }
271
218
  end
272
- end
273
-
274
- x.compare!
275
- end
276
-
277
- Benchmark.ips do |x|
278
- x.config(suite: suite)
279
- BENCHMARK_GEMS.each do |benchmark_gem|
280
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
281
-
282
- # Run once with each set of arguments to memoize the result values, so our
283
- # benchmark only tests memoized retrieval time.
219
+ end,
220
+ lambda do |x, instance, benchmark_gem|
284
221
  ARGUMENTS.each { |a, b| instance.keyword_and_double_splat_args(a: a, b: b) }
285
222
 
286
223
  x.report(
@@ -290,18 +227,8 @@ Benchmark.ips do |x|
290
227
  instance.keyword_and_double_splat_args(a: a, b: b)
291
228
  end
292
229
  end
293
- end
294
-
295
- x.compare!
296
- end
297
-
298
- Benchmark.ips do |x|
299
- x.config(suite: suite)
300
- BENCHMARK_GEMS.each do |benchmark_gem|
301
- instance = Object.const_get("#{benchmark_gem.klass}Example").new
302
-
303
- # Run once with each set of arguments to memoize the result values, so our
304
- # benchmark only tests memoized retrieval time.
230
+ end,
231
+ lambda do |x, instance, benchmark_gem|
305
232
  ARGUMENTS.each do |a, b|
306
233
  instance.positional_splat_keyword_and_double_splat_args(a, b, b: b, a: a)
307
234
  end
@@ -315,6 +242,62 @@ Benchmark.ips do |x|
315
242
  end
316
243
  end
317
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
290
+
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("|")
318
300
 
319
- x.compare!
301
+ name = memo_wise["name"].partition(": ").last
302
+ puts "|`#{name}`#{' (none)' if name == '()'}|#{output_str}|"
320
303
  end
@@ -10,138 +10,138 @@ 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. 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:
16
21
  # {
17
- # no_args_method_name: :memoized_result,
22
+ # zero_arg_method_name: :memoized_result,
18
23
  # single_arg_method_name: { arg1 => :memoized_result, ... },
19
- # [:multi_arg_method_name, arg1, arg2].hash => :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, ... }
20
27
  # }
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
- # ],
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
35
42
  # ...
36
- # }
37
- unless obj.instance_variables.include?(:@_memo_wise_hashes)
38
- obj.instance_variable_set(:@_memo_wise_hashes, {})
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, [])
39
55
  end
40
56
 
41
57
  obj
42
58
  end
43
59
 
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
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
76
95
  end
77
96
  end
78
97
 
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
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}"
111
112
  end
112
113
  end
113
114
 
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
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
145
  end
146
146
 
147
147
  # Find the original class for which the given class is the corresponding
@@ -159,9 +159,7 @@ module MemoWise
159
159
  # Raises if `klass` is not a singleton class.
160
160
  #
161
161
  def self.original_class_from_singleton(klass)
162
- unless klass.singleton_class?
163
- raise ArgumentError, "Must be a singleton class: #{klass.inspect}"
164
- end
162
+ raise ArgumentError, "Must be a singleton class: #{klass.inspect}" unless klass.singleton_class?
165
163
 
166
164
  # Search ObjectSpace
167
165
  # * 1:1 relationship of singleton class to original class is documented
@@ -195,63 +193,11 @@ module MemoWise
195
193
  # @return [Class, Module]
196
194
  attr_reader :target
197
195
 
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
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]
255
201
  end
256
202
 
257
203
  # Returns visibility of an instance method defined on class `target`.
@@ -274,8 +220,7 @@ module MemoWise
274
220
  elsif target.public_method_defined?(method_name)
275
221
  :public
276
222
  else
277
- raise ArgumentError,
278
- "#{method_name.inspect} must be a method on #{target}"
223
+ raise ArgumentError, "#{method_name.inspect} must be a method on #{target}"
279
224
  end
280
225
  end
281
226
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MemoWise
4
- VERSION = "1.1.0"
4
+ VERSION = "1.2.0"
5
5
  end
data/lib/memo_wise.rb CHANGED
@@ -25,7 +25,7 @@ require "memo_wise/version"
25
25
  # - {.memo_wise} for API and usage examples.
26
26
  # - {file:README.md} for general project information.
27
27
  #
28
- module MemoWise # rubocop:disable Metrics/ModuleLength
28
+ module MemoWise
29
29
  # Constructor to set up memoization state before
30
30
  # [calling the original](https://medium.com/@jeremy_96642/ruby-method-auditing-using-module-prepend-4f4e69aacd95)
31
31
  # constructor.
@@ -91,7 +91,7 @@ module MemoWise # rubocop:disable Metrics/ModuleLength
91
91
  # prepend MemoWise
92
92
  # end
93
93
  #
94
- def self.prepended(target) # rubocop:disable Metrics/PerceivedComplexity
94
+ def self.prepended(target)
95
95
  class << target
96
96
  # Allocator to set up memoization state before
97
97
  # [calling the original](https://medium.com/@jeremy_96642/ruby-method-auditing-using-module-prepend-4f4e69aacd95)
@@ -111,7 +111,7 @@ module MemoWise # rubocop:disable Metrics/ModuleLength
111
111
  end
112
112
 
113
113
  # NOTE: See YARD docs for {.memo_wise} directly below this method!
114
- def memo_wise(method_name_or_hash) # rubocop:disable Metrics/PerceivedComplexity
114
+ def memo_wise(method_name_or_hash)
115
115
  klass = self
116
116
  case method_name_or_hash
117
117
  when Symbol
@@ -156,100 +156,87 @@ module MemoWise # rubocop:disable Metrics/ModuleLength
156
156
  klass = klass.singleton_class
157
157
  end
158
158
 
159
- unless method_name.is_a?(Symbol)
160
- raise ArgumentError, "#{method_name.inspect} must be a Symbol"
161
- end
159
+ raise ArgumentError, "#{method_name.inspect} must be a Symbol" unless method_name.is_a?(Symbol)
162
160
 
163
161
  api = MemoWise::InternalAPI.new(klass)
164
162
  visibility = api.method_visibility(method_name)
165
- original_memo_wised_name =
166
- MemoWise::InternalAPI.original_memo_wised_name(method_name)
163
+ original_memo_wised_name = MemoWise::InternalAPI.original_memo_wised_name(method_name)
167
164
  method = klass.instance_method(method_name)
168
165
 
169
166
  klass.send(:alias_method, original_memo_wised_name, method_name)
170
167
  klass.send(:private, original_memo_wised_name)
171
168
 
172
- # Zero-arg methods can use simpler/more performant logic because the
173
- # hash key is just the method name.
174
- if method.arity.zero?
169
+ method_arguments = MemoWise::InternalAPI.method_arguments(method)
170
+ # `@_memo_wise_indices` stores the `@_memo_wise` indices of different
171
+ # method names. We only use this data structure when resetting or
172
+ # presetting memoization. It looks like:
173
+ # {
174
+ # single_arg_method_name: 0,
175
+ # other_single_arg_method_name: 1
176
+ # }
177
+ memo_wise_indices = klass.instance_variable_get(:@_memo_wise_indices)
178
+ memo_wise_indices ||= klass.instance_variable_set(:@_memo_wise_indices, {})
179
+ index = klass.instance_variable_get(:@_memo_wise_index_counter) || 0
180
+
181
+ memo_wise_indices[method_name] = index
182
+ klass.instance_variable_set(:@_memo_wise_index_counter, index + 1)
183
+
184
+ case method_arguments
185
+ when MemoWise::InternalAPI::NONE
186
+ # Zero-arg methods can use simpler/more performant logic because the
187
+ # hash key is just the method name.
175
188
  klass.module_eval <<-END_OF_METHOD, __FILE__, __LINE__ + 1
176
189
  def #{method_name}
177
- output = @_memo_wise[:#{method_name}]
178
- if output || @_memo_wise.key?(:#{method_name})
179
- output
190
+ _memo_wise_output = @_memo_wise[#{index}]
191
+ if _memo_wise_output || @_memo_wise_sentinels[#{index}]
192
+ _memo_wise_output
180
193
  else
181
- @_memo_wise[:#{method_name}] = #{original_memo_wised_name}
194
+ @_memo_wise_sentinels[#{index}] = true
195
+ @_memo_wise[#{index}] = #{original_memo_wised_name}
182
196
  end
183
197
  end
184
198
  END_OF_METHOD
185
- else
186
- if MemoWise::InternalAPI.has_only_required_args?(method)
187
- args_str = method.parameters.map do |type, name|
188
- "#{name}#{':' if type == :keyreq}"
189
- end.join(", ")
190
- args_str = "(#{args_str})"
191
- call_str = method.parameters.map do |type, name|
192
- type == :req ? name : "#{name}: #{name}"
193
- end.join(", ")
194
- call_str = "(#{call_str})"
195
- fetch_key_params = method.parameters.map(&:last)
196
- if fetch_key_params.size > 1
197
- fetch_key_init =
198
- "[:#{method_name}, #{fetch_key_params.join(', ')}].hash"
199
- use_hashed_key = true
200
- else
201
- fetch_key = fetch_key_params.first.to_s
202
- end
203
- else
204
- # If our method has arguments, we need to separate out our handling
205
- # of normal args vs. keyword args due to the changes in Ruby 3.
206
- # See: <link>
207
- # By only including logic for *args, **kwargs when they are used in
208
- # the method, we can avoid allocating unnecessary arrays and hashes.
209
- has_arg = MemoWise::InternalAPI.has_arg?(method)
210
-
211
- if has_arg && MemoWise::InternalAPI.has_kwarg?(method)
212
- args_str = "(*args, **kwargs)"
213
- fetch_key_init = "[:#{method_name}, args, kwargs].hash"
214
- use_hashed_key = true
215
- elsif has_arg
216
- args_str = "(*args)"
217
- fetch_key_init = "args.hash"
218
- else
219
- args_str = "(**kwargs)"
220
- fetch_key_init = "kwargs.hash"
221
- end
222
- end
199
+ when MemoWise::InternalAPI::ONE_REQUIRED_POSITIONAL, MemoWise::InternalAPI::ONE_REQUIRED_KEYWORD
200
+ key = method.parameters.first.last
223
201
 
224
- if use_hashed_key
225
- klass.module_eval <<-END_OF_METHOD, __FILE__, __LINE__ + 1
226
- def #{method_name}#{args_str}
227
- key = #{fetch_key_init}
228
- output = @_memo_wise[key]
229
- if output || @_memo_wise.key?(key)
230
- output
231
- else
232
- hashes = (@_memo_wise_hashes[:#{method_name}] ||= Set.new)
233
- hashes << key
234
- @_memo_wise[key] = #{original_memo_wised_name}#{call_str || args_str}
235
- end
202
+ klass.module_eval <<-END_OF_METHOD, __FILE__, __LINE__ + 1
203
+ def #{method_name}(#{MemoWise::InternalAPI.args_str(method)})
204
+ _memo_wise_hash = (@_memo_wise[#{index}] ||= {})
205
+ _memo_wise_output = _memo_wise_hash[#{key}]
206
+ if _memo_wise_output || _memo_wise_hash.key?(#{key})
207
+ _memo_wise_output
208
+ else
209
+ _memo_wise_hash[#{key}] = #{original_memo_wised_name}(#{MemoWise::InternalAPI.call_str(method)})
236
210
  end
237
- END_OF_METHOD
238
- else
239
- fetch_key ||= "key"
240
- klass.module_eval <<-END_OF_METHOD, __FILE__, __LINE__ + 1
241
- def #{method_name}#{args_str}
242
- hash = (@_memo_wise[:#{method_name}] ||= {})
243
- #{"key = #{fetch_key_init}" if fetch_key_init}
244
- output = hash[#{fetch_key}]
245
- if output || hash.key?(#{fetch_key})
246
- output
247
- else
248
- hash[#{fetch_key}] = #{original_memo_wised_name}#{call_str || args_str}
249
- end
211
+ end
212
+ END_OF_METHOD
213
+ # MemoWise::InternalAPI::MULTIPLE_REQUIRED, MemoWise::InternalAPI::SPLAT,
214
+ # MemoWise::InternalAPI::DOUBLE_SPLAT, MemoWise::InternalAPI::SPLAT_AND_DOUBLE_SPLAT
215
+ else
216
+ # NOTE: When benchmarking this implementation against something like:
217
+ #
218
+ # @_memo_wise.fetch(key) do
219
+ # ...
220
+ # end
221
+ #
222
+ # this implementation may sometimes perform worse than the above. This
223
+ # is because this case uses a more complex hash key (see
224
+ # `MemoWise::InternalAPI.key_str`), and hashing that key has less
225
+ # consistent performance. In general, this should still be faster for
226
+ # truthy results because `Hash#[]` generally performs hash lookups
227
+ # faster than `Hash#fetch`.
228
+ klass.module_eval <<-END_OF_METHOD, __FILE__, __LINE__ + 1
229
+ def #{method_name}(#{MemoWise::InternalAPI.args_str(method)})
230
+ _memo_wise_hash = (@_memo_wise[#{index}] ||= {})
231
+ _memo_wise_key = #{MemoWise::InternalAPI.key_str(method)}
232
+ _memo_wise_output = _memo_wise_hash[_memo_wise_key]
233
+ if _memo_wise_output || _memo_wise_hash.key?(_memo_wise_key)
234
+ _memo_wise_output
235
+ else
236
+ _memo_wise_hash[_memo_wise_key] = #{original_memo_wised_name}(#{MemoWise::InternalAPI.call_str(method)})
250
237
  end
251
- END_OF_METHOD
252
- end
238
+ end
239
+ END_OF_METHOD
253
240
  end
254
241
 
255
242
  klass.send(visibility, method_name)
@@ -275,8 +262,7 @@ module MemoWise # rubocop:disable Metrics/ModuleLength
275
262
  # (`...` or `*args, **kwargs`), making reflection on method parameters
276
263
  # useless without this.
277
264
  def target.instance_method(symbol)
278
- original_memo_wised_name =
279
- MemoWise::InternalAPI.original_memo_wised_name(symbol)
265
+ original_memo_wised_name = MemoWise::InternalAPI.original_memo_wised_name(symbol)
280
266
 
281
267
  super.tap do |curr_method|
282
268
  # Start with calling the original `instance_method` on `symbol`,
@@ -368,7 +354,6 @@ module MemoWise # rubocop:disable Metrics/ModuleLength
368
354
  # Example.method_called_times #=> nil
369
355
  ##
370
356
 
371
- # rubocop:disable Layout/LineLength
372
357
  ##
373
358
  # @!method self.reset_memo_wise(method_name = nil, *args, **kwargs)
374
359
  # Implementation of {#reset_memo_wise} for class methods.
@@ -402,7 +387,6 @@ module MemoWise # rubocop:disable Metrics/ModuleLength
402
387
  #
403
388
  # Example.reset_memo_wise # reset "all methods" mode
404
389
  ##
405
- # rubocop:enable Layout/LineLength
406
390
 
407
391
  # Presets the memoized result for the given method to the result of the given
408
392
  # block.
@@ -455,26 +439,35 @@ module MemoWise # rubocop:disable Metrics/ModuleLength
455
439
  # ex.method_called_times #=> nil
456
440
  #
457
441
  def preset_memo_wise(method_name, *args, **kwargs)
458
- unless block_given?
459
- raise ArgumentError,
460
- "Pass a block as the value to preset for #{method_name}, #{args}"
461
- end
442
+ raise ArgumentError, "Pass a block as the value to preset for #{method_name}, #{args}" unless block_given?
462
443
 
463
444
  api = MemoWise::InternalAPI.new(self)
464
445
  api.validate_memo_wised!(method_name)
465
446
 
466
- if method(method_name).arity.zero?
467
- @_memo_wise[method_name] = yield
468
- else
469
- key = api.fetch_key(method_name, *args, **kwargs)
470
- if api.use_hashed_key?(method_name)
471
- hashes = @_memo_wise_hashes[method_name] ||= []
472
- hashes << key
473
- @_memo_wise[key] = yield
474
- else
475
- hash = @_memo_wise[method_name] ||= {}
476
- hash[key] = yield
447
+ method = method(method_name)
448
+ method_arguments = MemoWise::InternalAPI.method_arguments(method)
449
+ index = api.index(method_name)
450
+
451
+ if method_arguments == MemoWise::InternalAPI::NONE
452
+ @_memo_wise_sentinels[index] = true
453
+ @_memo_wise[index] = yield
454
+ return
455
+ end
456
+
457
+ hash = (@_memo_wise[index] ||= {})
458
+
459
+ case method_arguments
460
+ when MemoWise::InternalAPI::ONE_REQUIRED_POSITIONAL then hash[args.first] = yield
461
+ when MemoWise::InternalAPI::ONE_REQUIRED_KEYWORD then hash[kwargs.first.last] = yield
462
+ when MemoWise::InternalAPI::SPLAT then hash[args] = yield
463
+ when MemoWise::InternalAPI::DOUBLE_SPLAT then hash[kwargs] = yield
464
+ when MemoWise::InternalAPI::MULTIPLE_REQUIRED
465
+ key = method.parameters.map.with_index do |(type, name), idx|
466
+ type == :req ? args[idx] : kwargs[name]
477
467
  end
468
+ hash[key] = yield
469
+ else # MemoWise::InternalAPI::SPLAT_AND_DOUBLE_SPLAT
470
+ hash[[args, kwargs]] = yield
478
471
  end
479
472
  end
480
473
 
@@ -543,45 +536,66 @@ module MemoWise # rubocop:disable Metrics/ModuleLength
543
536
  #
544
537
  # ex.reset_memo_wise # reset "all methods" mode
545
538
  #
546
- def reset_memo_wise(method_name = nil, *args, **kwargs) # rubocop:disable Metrics/PerceivedComplexity
539
+ def reset_memo_wise(method_name = nil, *args, **kwargs)
547
540
  if method_name.nil?
548
- unless args.empty?
549
- raise ArgumentError, "Provided args when method_name = nil"
550
- end
551
-
552
- unless kwargs.empty?
553
- raise ArgumentError, "Provided kwargs when method_name = nil"
554
- end
541
+ raise ArgumentError, "Provided args when method_name = nil" unless args.empty?
542
+ raise ArgumentError, "Provided kwargs when method_name = nil" unless kwargs.empty?
555
543
 
556
544
  @_memo_wise.clear
557
- @_memo_wise_hashes.clear
545
+ @_memo_wise_sentinels.clear
558
546
  return
559
547
  end
560
548
 
561
- unless method_name.is_a?(Symbol)
562
- raise ArgumentError, "#{method_name.inspect} must be a Symbol"
563
- end
564
-
565
- unless respond_to?(method_name, true)
566
- raise ArgumentError, "#{method_name} is not a defined method"
567
- end
549
+ raise ArgumentError, "#{method_name.inspect} must be a Symbol" unless method_name.is_a?(Symbol)
550
+ raise ArgumentError, "#{method_name} is not a defined method" unless respond_to?(method_name, true)
568
551
 
569
552
  api = MemoWise::InternalAPI.new(self)
570
553
  api.validate_memo_wised!(method_name)
571
554
 
572
- if args.empty? && kwargs.empty?
573
- @_memo_wise.delete(method_name)
574
- @_memo_wise_hashes[method_name]&.each do |hash|
575
- @_memo_wise.delete(hash)
555
+ method = method(method_name)
556
+ method_arguments = MemoWise::InternalAPI.method_arguments(method)
557
+ index = api.index(method_name)
558
+
559
+ case method_arguments
560
+ when MemoWise::InternalAPI::NONE
561
+ @_memo_wise_sentinels[index] = nil
562
+ @_memo_wise[index] = nil
563
+ when MemoWise::InternalAPI::ONE_REQUIRED_POSITIONAL
564
+ if args.empty?
565
+ @_memo_wise[index]&.clear
566
+ else
567
+ @_memo_wise[index]&.delete(args.first)
568
+ end
569
+ when MemoWise::InternalAPI::ONE_REQUIRED_KEYWORD
570
+ if kwargs.empty?
571
+ @_memo_wise[index]&.clear
572
+ else
573
+ @_memo_wise[index]&.delete(kwargs.first.last)
574
+ end
575
+ when MemoWise::InternalAPI::SPLAT
576
+ if args.empty?
577
+ @_memo_wise[index]&.clear
578
+ else
579
+ @_memo_wise[index]&.delete(args)
576
580
  end
577
- @_memo_wise_hashes.delete(method_name)
578
- else
579
- key = api.fetch_key(method_name, *args, **kwargs)
580
- if api.use_hashed_key?(method_name)
581
- @_memo_wise_hashes[method_name]&.delete(key)
582
- @_memo_wise.delete(key)
581
+ when MemoWise::InternalAPI::DOUBLE_SPLAT
582
+ if kwargs.empty?
583
+ @_memo_wise[index]&.clear
583
584
  else
584
- @_memo_wise[method_name]&.delete(key)
585
+ @_memo_wise[index]&.delete(kwargs)
586
+ end
587
+ else # MemoWise::InternalAPI::MULTIPLE_REQUIRED, MemoWise::InternalAPI::SPLAT_AND_DOUBLE_SPLAT
588
+ if args.empty? && kwargs.empty?
589
+ @_memo_wise[index]&.clear
590
+ else
591
+ key = if method_arguments == MemoWise::InternalAPI::SPLAT_AND_DOUBLE_SPLAT
592
+ [args, kwargs]
593
+ else
594
+ method.parameters.map.with_index do |(type, name), i|
595
+ type == :req ? args[i] : kwargs[name] # rubocop:disable Metrics/BlockNesting
596
+ end
597
+ end
598
+ @_memo_wise[index]&.delete(key)
585
599
  end
586
600
  end
587
601
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: memo_wise
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Panorama Education
@@ -11,7 +11,7 @@ authors:
11
11
  autorequire:
12
12
  bindir: bin
13
13
  cert_chain: []
14
- date: 2021-07-30 00:00:00.000000000 Z
14
+ date: 2021-11-10 00:00:00.000000000 Z
15
15
  dependencies: []
16
16
  description:
17
17
  email:
@@ -69,7 +69,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
69
69
  - !ruby/object:Gem::Version
70
70
  version: '0'
71
71
  requirements: []
72
- rubygems_version: 3.2.3
72
+ rubygems_version: 3.2.22
73
73
  signing_key:
74
74
  specification_version: 4
75
75
  summary: The wise choice for Ruby memoization