memo_wise 0.3.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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