memo_wise 0.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.
data/bin/console ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "memo_wise"
6
+
7
+ require "irb"
8
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/lib/memo_wise.rb ADDED
@@ -0,0 +1,555 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "memo_wise/version"
4
+
5
+ # MemoWise is the wise choice for memoization in Ruby.
6
+ #
7
+ # - **Q:** What is *memoization*?
8
+ # - **A:** [via Wikipedia](https://en.wikipedia.org/wiki/Memoization):
9
+ #
10
+ # [Memoization is] an optimization technique used primarily to speed up
11
+ # computer programs by storing the results of expensive function
12
+ # calls and returning the cached result when the same inputs occur
13
+ # again.
14
+ #
15
+ # To start using MemoWise in a class or module:
16
+ #
17
+ # 1. Add `prepend MemoWise` to the top of the class or module
18
+ # 2. Call {.memo_wise} to implement memoization for a given method
19
+ #
20
+ # **See Also:**
21
+ #
22
+ # - {.memo_wise} for API and usage examples.
23
+ # - {file:README.md} for general project information.
24
+ #
25
+ module MemoWise # rubocop:disable Metrics/ModuleLength
26
+ # Constructor to set up memoization state before
27
+ # [calling the original](https://medium.com/@jeremy_96642/ruby-method-auditing-using-module-prepend-4f4e69aacd95)
28
+ # constructor.
29
+ #
30
+ # - **Q:** Why is [Module#prepend](https://ruby-doc.org/core-3.0.0/Module.html#method-i-prepend)
31
+ # important here
32
+ # ([more info](https://medium.com/@leo_hetsch/ruby-modules-include-vs-prepend-vs-extend-f09837a5b073))?
33
+ # - **A:** To set up *mutable state* inside the instance, even if the original
34
+ # constructor will then call
35
+ # [Object#freeze](https://ruby-doc.org/core-3.0.0/Object.html#method-i-freeze).
36
+ #
37
+ # This approach supports memoization on frozen (immutable) objects -- for
38
+ # example, classes created by the
39
+ # [Values](https://github.com/tcrayford/Values)
40
+ # [gem](https://rubygems.org/gems/values).
41
+ #
42
+ def initialize(*)
43
+ MemoWise.create_memo_wise_state!(self)
44
+ super
45
+ end
46
+
47
+ # @private
48
+ #
49
+ # Determine whether `method` takes any *positional* args.
50
+ #
51
+ # These are the types of positional args:
52
+ #
53
+ # * *Required* -- ex: `def foo(a)`
54
+ # * *Optional* -- ex: `def foo(b=1)`
55
+ # * *Splatted* -- ex: `def foo(*c)`
56
+ #
57
+ # @param method [Method, UnboundMethod]
58
+ # Arguments of this method will be checked
59
+ #
60
+ # @return [Boolean]
61
+ # Return `true` if `method` accepts one or more positional arguments
62
+ #
63
+ # @example
64
+ # class Example
65
+ # def no_args
66
+ # end
67
+ #
68
+ # def position_arg(a)
69
+ # end
70
+ # end
71
+ #
72
+ # MemoWise.has_arg?(Example.instance_method(:no_args)) #=> false
73
+ #
74
+ # MemoWise.has_arg?(Example.instance_method(:position_arg)) #=> true
75
+ #
76
+ def self.has_arg?(method) # rubocop:disable Naming/PredicateName
77
+ method.parameters.any? do |(param, _)|
78
+ param == :req || param == :opt || param == :rest # rubocop:disable Style/MultipleComparison
79
+ end
80
+ end
81
+
82
+ # @private
83
+ #
84
+ # Determine whether `method` takes any *keyword* args.
85
+ #
86
+ # These are the types of keyword args:
87
+ #
88
+ # * *Keyword Required* -- ex: `def foo(a:)`
89
+ # * *Keyword Optional* -- ex: `def foo(b: 1)`
90
+ # * *Keyword Splatted* -- ex: `def foo(**c)`
91
+ #
92
+ # @param method [Method, UnboundMethod]
93
+ # Arguments of this method will be checked
94
+ #
95
+ # @return [Boolean]
96
+ # Return `true` if `method` accepts one or more keyword arguments
97
+ #
98
+ # @example
99
+ # class Example
100
+ # def position_args(a, b=1)
101
+ # end
102
+ #
103
+ # def keyword_args(a:, b: 1)
104
+ # end
105
+ # end
106
+ #
107
+ # MemoWise.has_kwarg?(Example.instance_method(:position_args)) #=> false
108
+ #
109
+ # MemoWise.has_kwarg?(Example.instance_method(:keyword_args)) #=> true
110
+ #
111
+ def self.has_kwarg?(method) # rubocop:disable Naming/PredicateName
112
+ method.parameters.any? do |(param, _)|
113
+ param == :keyreq || param == :key || param == :keyrest # rubocop:disable Style/MultipleComparison
114
+ end
115
+ end
116
+
117
+ # @private
118
+ #
119
+ # Returns visibility of an instance method defined on a class.
120
+ #
121
+ # @param klass [Class]
122
+ # Class in which to find the visibility of an existing *instance* method.
123
+ #
124
+ # @param method_name [Symbol]
125
+ # Name of existing *instance* method find the visibility of.
126
+ #
127
+ # @return [:private, :protected, :public]
128
+ # Visibility of existing instance method of the class.
129
+ #
130
+ # @raise ArgumentError
131
+ # Raises `ArgumentError` unless `method_name` is a `Symbol` corresponding
132
+ # to an existing **instance** method defined on `klass`.
133
+ #
134
+ def self.method_visibility(klass, method_name)
135
+ if klass.private_method_defined?(method_name)
136
+ :private
137
+ elsif klass.protected_method_defined?(method_name)
138
+ :protected
139
+ elsif klass.public_method_defined?(method_name)
140
+ :public
141
+ else
142
+ raise ArgumentError, "#{method_name.inspect} must be a method on #{klass}"
143
+ end
144
+ end
145
+
146
+ # @private
147
+ #
148
+ # Find the original class for which the given class is the corresponding
149
+ # "singleton class".
150
+ #
151
+ # See https://stackoverflow.com/questions/54531270/retrieve-a-ruby-object-from-its-singleton-class
152
+ #
153
+ # @param klass [Class]
154
+ # Singleton class to find the original class of
155
+ #
156
+ # @return Class
157
+ # Original class for which `klass` is the singleton class.
158
+ #
159
+ # @raise ArgumentError
160
+ # Raises if `klass` is not a singleton class.
161
+ #
162
+ def self.original_class_from_singleton(klass)
163
+ unless klass.singleton_class?
164
+ raise ArgumentError, "Must be a singleton class: #{klass.inspect}"
165
+ end
166
+
167
+ # Search ObjectSpace
168
+ # * 1:1 relationship of singleton class to original class is documented
169
+ # * Performance concern: searches all Class objects
170
+ # But, only runs at load time
171
+ ObjectSpace.each_object(Class).find { |cls| cls.singleton_class == klass }
172
+ end
173
+
174
+ # @private
175
+ #
176
+ # Create initial mutable state to store memoized values if it doesn't
177
+ # already exist
178
+ #
179
+ # @param [Object] obj
180
+ # Object in which to create mutable state to store future memoized values
181
+ #
182
+ # @return [Object] the passed-in obj
183
+ def self.create_memo_wise_state!(obj)
184
+ unless obj.instance_variables.include?(:@_memo_wise)
185
+ obj.instance_variable_set(
186
+ :@_memo_wise,
187
+ Hash.new { |h, k| h[k] = {} }
188
+ )
189
+ end
190
+
191
+ obj
192
+ end
193
+
194
+ # @private
195
+ #
196
+ # Private setup method, called automatically by `prepend MemoWise` in a class.
197
+ #
198
+ # @param target [Class]
199
+ # The `Class` into to prepend the MemoWise methods e.g. `memo_wise`
200
+ #
201
+ # @see https://ruby-doc.org/core-3.0.0/Module.html#method-i-prepended
202
+ #
203
+ # @example
204
+ # class Example
205
+ # prepend MemoWise
206
+ # end
207
+ #
208
+ def self.prepended(target) # rubocop:disable Metrics/PerceivedComplexity
209
+ class << target
210
+ # Allocator to set up memoization state before
211
+ # [calling the original](https://medium.com/@jeremy_96642/ruby-method-auditing-using-module-prepend-4f4e69aacd95)
212
+ # allocator.
213
+ #
214
+ # This is necessary in addition to the `#initialize` method definition
215
+ # above because
216
+ # [`Class#allocate`](https://ruby-doc.org/core-3.0.0/Class.html#method-i-allocate)
217
+ # bypasses `#initialize`, and when it's used (e.g.,
218
+ # [in ActiveRecord](https://github.com/rails/rails/blob/a395c3a6af1e079740e7a28994d77c8baadd2a9d/activerecord/lib/active_record/persistence.rb#L411))
219
+ # we still need to be able to access MemoWise's instance variable. Despite
220
+ # Ruby documentation indicating otherwise, `Class#new` does not call
221
+ # `Class#allocate`, so we need to override both.
222
+ #
223
+ def allocate
224
+ MemoWise.create_memo_wise_state!(super)
225
+ end
226
+
227
+ # NOTE: See YARD docs for {.memo_wise} directly below this method!
228
+ def memo_wise(method_name_or_hash) # rubocop:disable Metrics/PerceivedComplexity
229
+ klass = self
230
+ case method_name_or_hash
231
+ when Symbol
232
+ method_name = method_name_or_hash
233
+
234
+ if klass.singleton_class?
235
+ MemoWise.create_memo_wise_state!(
236
+ MemoWise.original_class_from_singleton(klass)
237
+ )
238
+ end
239
+ when Hash
240
+ unless method_name_or_hash.keys == [:self]
241
+ raise ArgumentError,
242
+ "`:self` is the only key allowed in memo_wise"
243
+ end
244
+
245
+ method_name = method_name_or_hash[:self]
246
+
247
+ MemoWise.create_memo_wise_state!(self)
248
+
249
+ # In Ruby, "class methods" are implemented as normal instance methods
250
+ # on the "singleton class" of a given Class object, found via
251
+ # {Class#singleton_class}.
252
+ # See: https://medium.com/@leo_hetsch/demystifying-singleton-classes-in-ruby-caf3fa4c9d91
253
+ klass = klass.singleton_class
254
+ end
255
+
256
+ unless method_name.is_a?(Symbol)
257
+ raise ArgumentError, "#{method_name.inspect} must be a Symbol"
258
+ end
259
+
260
+ visibility = MemoWise.method_visibility(klass, method_name)
261
+ method = klass.instance_method(method_name)
262
+
263
+ original_memo_wised_name = :"_memo_wise_original_#{method_name}"
264
+ klass.send(:alias_method, original_memo_wised_name, method_name)
265
+ klass.send(:private, original_memo_wised_name)
266
+
267
+ # Zero-arg methods can use simpler/more performant logic because the
268
+ # hash key is just the method name.
269
+ if method.arity.zero?
270
+ klass.module_eval <<-END_OF_METHOD, __FILE__, __LINE__ + 1
271
+ # def foo
272
+ # @_memo_wise.fetch(:foo}) do
273
+ # @_memo_wise[:foo] = _memo_wise_original_foo
274
+ # end
275
+ # end
276
+
277
+ def #{method_name}
278
+ @_memo_wise.fetch(:#{method_name}) do
279
+ @_memo_wise[:#{method_name}] = #{original_memo_wised_name}
280
+ end
281
+ end
282
+ END_OF_METHOD
283
+ else
284
+ # If our method has arguments, we need to separate out our handling of
285
+ # normal args vs. keyword args due to the changes in Ruby 3.
286
+ # See: <link>
287
+ # By only including logic for *args or **kwargs when they are used in
288
+ # the method, we can avoid allocating unnecessary arrays and hashes.
289
+ has_arg = MemoWise.has_arg?(method)
290
+
291
+ if has_arg && MemoWise.has_kwarg?(method)
292
+ args_str = "(*args, **kwargs)"
293
+ fetch_key = "[args, kwargs].freeze"
294
+ elsif has_arg
295
+ args_str = "(*args)"
296
+ fetch_key = "args"
297
+ else
298
+ args_str = "(**kwargs)"
299
+ fetch_key = "kwargs"
300
+ end
301
+
302
+ # Note that we don't need to freeze args before using it as a hash key
303
+ # because Ruby always copies argument arrays when splatted.
304
+ klass.module_eval <<-END_OF_METHOD, __FILE__, __LINE__ + 1
305
+ # def foo(*args, **kwargs)
306
+ # hash = @_memo_wise[:foo]
307
+ # hash.fetch([args, kwargs].freeze) do
308
+ # hash[[args, kwargs].freeze] = _memo_wise_original_foo(*args, **kwargs)
309
+ # end
310
+ # end
311
+
312
+ def #{method_name}#{args_str}
313
+ hash = @_memo_wise[:#{method_name}]
314
+ hash.fetch(#{fetch_key}) do
315
+ hash[#{fetch_key}] = #{original_memo_wised_name}#{args_str}
316
+ end
317
+ end
318
+ END_OF_METHOD
319
+ end
320
+
321
+ klass.send(visibility, method_name)
322
+ end
323
+ end
324
+ end
325
+
326
+ ##
327
+ # @!method self.memo_wise(method_name)
328
+ # Implements memoization for the given method name.
329
+ #
330
+ # - **Q:** What does it mean to "implement memoization"?
331
+ # - **A:** To wrap the original method such that, for any given set of
332
+ # arguments, the original method will be called at most *once*. The
333
+ # result of that call will be stored on the object. All future
334
+ # calls to the same method with the same set of arguments will then
335
+ # return that saved result.
336
+ #
337
+ # Methods which implicitly or explicitly take block arguments cannot be
338
+ # memoized.
339
+ #
340
+ # @param method_name [Symbol]
341
+ # Name of method for which to implement memoization.
342
+ #
343
+ # @return [void]
344
+ #
345
+ # @example
346
+ # class Example
347
+ # prepend MemoWise
348
+ #
349
+ # def method_to_memoize(x)
350
+ # @method_called_times = (@method_called_times || 0) + 1
351
+ # end
352
+ # memo_wise :method_to_memoize
353
+ # end
354
+ #
355
+ # ex = Example.new
356
+ #
357
+ # ex.method_to_memoize("a") #=> 1
358
+ # ex.method_to_memoize("a") #=> 1
359
+ #
360
+ # ex.method_to_memoize("b") #=> 2
361
+ # ex.method_to_memoize("b") #=> 2
362
+ ##
363
+
364
+ # Presets the memoized result for the given method to the result of the given
365
+ # block.
366
+ #
367
+ # This method is for situations where the caller *already* has the result of
368
+ # an expensive method call, and wants to preset that result as memoized for
369
+ # future calls. In other words, the memoized method will be called *zero*
370
+ # times rather than once.
371
+ #
372
+ # NOTE: Currently, no attempt is made to validate that the given arguments are
373
+ # valid for the given method.
374
+ #
375
+ # @param method_name [Symbol]
376
+ # Name of a method previously setup with `#memo_wise`.
377
+ #
378
+ # @param args [Array]
379
+ # (Optional) If the method takes positional args, these are the values of
380
+ # position args for which the given block's result will be preset as the
381
+ # memoized result.
382
+ #
383
+ # @param kwargs [Hash]
384
+ # (Optional) If the method takes keyword args, these are the keys and values
385
+ # of keyword args for which the given block's result will be preset as the
386
+ # memoized result.
387
+ #
388
+ # @yieldreturn [Object]
389
+ # The result of the given block will be preset as memoized for future calls
390
+ # to the given method.
391
+ #
392
+ # @return [void]
393
+ #
394
+ # @example
395
+ # class Example
396
+ # prepend MemoWise
397
+ # attr_reader :method_called_times
398
+ #
399
+ # def method_to_preset
400
+ # @method_called_times = (@method_called_times || 0) + 1
401
+ # "A"
402
+ # end
403
+ # memo_wise :method_to_preset
404
+ # end
405
+ #
406
+ # ex = Example.new
407
+ #
408
+ # ex.preset_memo_wise(:method_to_preset) { "B" }
409
+ #
410
+ # ex.method_to_preset #=> "B"
411
+ #
412
+ # ex.method_called_times #=> nil
413
+ #
414
+ def preset_memo_wise(method_name, *args, **kwargs)
415
+ validate_memo_wised!(method_name)
416
+
417
+ unless block_given?
418
+ raise ArgumentError,
419
+ "Pass a block as the value to preset for #{method_name}, #{args}"
420
+ end
421
+
422
+ validate_params!(method_name, args)
423
+
424
+ if method(method_name).arity.zero?
425
+ @_memo_wise[method_name] = yield
426
+ else
427
+ @_memo_wise[method_name][fetch_key(method_name, *args, **kwargs)] = yield
428
+ end
429
+ end
430
+
431
+ # Resets memoized results of a given method, or all methods.
432
+ #
433
+ # There are three _reset modes_ depending on how this method is called:
434
+ #
435
+ # **method + args** mode (most specific)
436
+ #
437
+ # - If given `method_name` and *either* `args` *or* `kwargs` *or* both:
438
+ # - Resets *only* the memoized result of calling `method_name` with those
439
+ # particular arguments.
440
+ #
441
+ # **method** (any args) mode
442
+ #
443
+ # - If given `method_name` and *neither* `args` *nor* `kwargs`:
444
+ # - Resets *all* memoized results of calling `method_name` with any arguments.
445
+ #
446
+ # **all methods** mode (most general)
447
+ #
448
+ # - If *not* given `method_name`:
449
+ # - Resets all memoized results of calling *all methods*.
450
+ #
451
+ # @param method_name [Symbol, nil]
452
+ # (Optional) Name of a method previously setup with `#memo_wise`. If not
453
+ # given, will reset *all* memoized results for *all* methods.
454
+ #
455
+ # @param args [Array]
456
+ # (Optional) If the method takes positional args, these are the values of
457
+ # position args for which the memoized result will be reset.
458
+ #
459
+ # @param kwargs [Hash]
460
+ # (Optional) If the method takes keyword args, these are the keys and values
461
+ # of keyword args for which the memoized result will be reset.
462
+ #
463
+ # @return [void]
464
+ #
465
+ # @example
466
+ # class Example
467
+ # prepend MemoWise
468
+ #
469
+ # def method_to_reset(x)
470
+ # @method_called_times = (@method_called_times || 0) + 1
471
+ # end
472
+ # memo_wise :method_to_reset
473
+ # end
474
+ #
475
+ # ex = Example.new
476
+ #
477
+ # ex.method_to_reset("a") #=> 1
478
+ # ex.method_to_reset("a") #=> 1
479
+ #
480
+ # ex.method_to_reset("b") #=> 2
481
+ # ex.method_to_reset("b") #=> 2
482
+ #
483
+ # ex.reset_memo_wise(:method_to_reset, "a") # reset "method + args" mode
484
+ #
485
+ # ex.method_to_reset("a") #=> 3
486
+ # ex.method_to_reset("a") #=> 3
487
+ #
488
+ # ex.method_to_reset("b") #=> 2
489
+ # ex.method_to_reset("b") #=> 2
490
+ #
491
+ # ex.reset_memo_wise(:method_to_reset) # reset "method" (any args) mode
492
+ #
493
+ # ex.method_to_reset("a") #=> 4
494
+ # ex.method_to_reset("b") #=> 5
495
+ #
496
+ # ex.reset_memo_wise # reset "all methods" mode
497
+ #
498
+ def reset_memo_wise(method_name = nil, *args, **kwargs)
499
+ if method_name.nil?
500
+ unless args.empty?
501
+ raise ArgumentError, "Provided args when method_name = nil"
502
+ end
503
+
504
+ unless kwargs.empty?
505
+ raise ArgumentError, "Provided kwargs when method_name = nil"
506
+ end
507
+
508
+ return @_memo_wise.clear
509
+ end
510
+
511
+ unless method_name.is_a?(Symbol)
512
+ raise ArgumentError, "#{method_name.inspect} must be a Symbol"
513
+ end
514
+
515
+ unless respond_to?(method_name, true)
516
+ raise ArgumentError, "#{method_name} is not a defined method"
517
+ end
518
+
519
+ validate_memo_wised!(method_name)
520
+
521
+ if args.empty? && kwargs.empty?
522
+ @_memo_wise.delete(method_name)
523
+ else
524
+ @_memo_wise[method_name].delete(fetch_key(method_name, *args, **kwargs))
525
+ end
526
+ end
527
+
528
+ private
529
+
530
+ # Validates that {.memo_wise} has already been called on `method_name`.
531
+ def validate_memo_wised!(method_name)
532
+ original_memo_wised_name = :"_memo_wise_original_#{method_name}"
533
+
534
+ unless self.class.private_method_defined?(original_memo_wised_name)
535
+ raise ArgumentError, "#{method_name} is not a memo_wised method"
536
+ end
537
+ end
538
+
539
+ # Returns arguments key to lookup memoized results for given `method_name`.
540
+ def fetch_key(method_name, *args, **kwargs)
541
+ method = self.class.instance_method(method_name)
542
+ has_arg = MemoWise.has_arg?(method)
543
+
544
+ if has_arg && MemoWise.has_kwarg?(method)
545
+ [args, kwargs].freeze
546
+ elsif has_arg
547
+ args
548
+ else
549
+ kwargs
550
+ end
551
+ end
552
+
553
+ # TODO: Parameter validation for presetting values
554
+ def validate_params!(method_name, args); end
555
+ end