memo_wise 1.1.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 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