memo_wise 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
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