memo_wise 0.3.0 → 1.2.0

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