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