Skip to content

Commit 582f807

Browse files
authored
Allow objects with memoized values to be marshaled/unmarshaled across Ruby processes (#51)
1 parent fceb1dd commit 582f807

File tree

4 files changed

+113
-4
lines changed

4 files changed

+113
-4
lines changed

README.md

+30
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,36 @@ a.memoized?(:call) # => true
190190
a.memoized?(:execute) # => false
191191
```
192192

193+
### Marshal-compatible Memoization
194+
195+
In order for objects to be marshaled and loaded in a different Ruby process,
196+
hashed arguments must be disabled in order for memoized values to be retained.
197+
Note that this can have a performance impact if the memoized method contains
198+
arguments.
199+
200+
```ruby
201+
Memery.use_hashed_arguments = false
202+
203+
class A
204+
include Memery
205+
206+
memoize def call
207+
puts "calculating"
208+
42
209+
end
210+
end
211+
212+
a = A.new
213+
a.call
214+
215+
Marshal.dump(a)
216+
# => "\x04\bo:\x06A\x06:\x1D@_memery_memoized_values{\x06:\tcallS:3Memery::ClassMethods::MemoizationModule::Cache\a:\vresulti/:\ttimef\x14663237.14822323"
217+
218+
# ...in another Ruby process:
219+
a = Marshal.load("\x04\bo:\x06A\x06:\x1D@_memery_memoized_values{\x06:\tcallS:3Memery::ClassMethods::MemoizationModule::Cache\a:\vresulti/:\ttimef\x14663237.14822323")
220+
a.call # => 42
221+
```
222+
193223
## Differences from Other Gems
194224

195225
Memery is similar to [Memoist](https://github.com/matthewrudy/memoist), but it doesn't override methods. Instead, it uses Ruby 2's `Module.prepend` feature. This approach is cleaner, allowing you to inspect the original method body with `method(:x).super_method.source`, and it ensures that subclasses' methods function properly. If you redefine a memoized method in a subclass, it won't be memoized by default. You can memoize it normally without needing an awkward `identifier: ` argument, and it will just work:

benchmark.rb

+26
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ def base_find(char)
3232
memoize def find_new(char)
3333
base_find(char)
3434
end
35+
36+
memoize def find_optional(*)
37+
base_find("z")
38+
end
3539
end
3640
end
3741

@@ -43,6 +47,10 @@ def test_with_args
4347
Foo.find_new("d")
4448
end
4549

50+
def test_empty_args
51+
Foo.find_optional
52+
end
53+
4654
Benchmark.ips do |x|
4755
x.report("test_no_args") { test_no_args }
4856
end
@@ -51,6 +59,14 @@ def test_with_args
5159
x.report("test_no_args") { 100.times { test_no_args } }
5260
end
5361

62+
Benchmark.ips do |x|
63+
x.report("test_empty_args") { test_empty_args }
64+
end
65+
66+
Benchmark.memory do |x|
67+
x.report("test_empty_args") { 100.times { test_empty_args } }
68+
end
69+
5470
Benchmark.ips do |x|
5571
x.report("test_with_args") { test_with_args }
5672
end
@@ -59,4 +75,14 @@ def test_with_args
5975
x.report("test_with_args") { 100.times { test_with_args } }
6076
end
6177

78+
Memery.use_hashed_arguments = false
79+
Benchmark.ips do |x|
80+
x.report("test_with_args_no_hash") { test_with_args }
81+
end
82+
83+
Benchmark.memory do |x|
84+
x.report("test_with_args_no_hash") { 100.times { test_with_args } }
85+
end
86+
Memery.use_hashed_arguments = true
87+
6288
puts "```"

lib/memery.rb

+15-4
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44

55
module Memery
66
class << self
7+
attr_accessor :use_hashed_arguments
8+
79
def monotonic_clock
810
Process.clock_gettime(Process::CLOCK_MONOTONIC)
911
end
1012
end
1113

14+
@use_hashed_arguments = true
15+
1216
OUR_BLOCK = lambda do
1317
extend(ClassMethods)
1418
include(InstanceMethods)
@@ -68,19 +72,25 @@ def fresh?(ttl)
6872
end
6973
end
7074

75+
# rubocop:disable Metrics/MethodLength
7176
def define_memoized_method!(klass, method_name, condition: nil, ttl: nil)
72-
method_key = "#{method_name}_#{object_id}"
73-
77+
# Include a suffix in the method key to differentiate between methods of the same name
78+
# being memoized throughout a class inheritance hierarchy
79+
method_key = "#{method_name}_#{klass.name || object_id}"
7480
original_visibility = method_visibility(klass, method_name)
75-
original_arity = klass.instance_method(method_name).arity
7681

7782
define_method(method_name) do |*args, &block|
7883
if block || (condition && !instance_exec(&condition))
7984
return super(*args, &block)
8085
end
8186

8287
cache_store = (@_memery_memoized_values ||= {})
83-
cache_key = original_arity.zero? ? method_key : [method_key, *args].hash
88+
cache_key = if args.empty?
89+
method_key
90+
else
91+
key_parts = [method_key, *args]
92+
Memery.use_hashed_arguments ? key_parts.hash : key_parts
93+
end
8494
cache = cache_store[cache_key]
8595

8696
return cache.result if cache&.fresh?(ttl)
@@ -95,6 +105,7 @@ def define_memoized_method!(klass, method_name, condition: nil, ttl: nil)
95105
ruby2_keywords(method_name)
96106
send(original_visibility, method_name)
97107
end
108+
# rubocop:enable Metrics/MethodLength
98109

99110
private
100111

spec/memery_spec.rb

+42
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ class H
153153

154154
before { CALLS.clear }
155155
before { B_CALLS.clear }
156+
before { Memery.use_hashed_arguments = true }
156157

157158
let(:unmemoized_class) do
158159
Class.new do
@@ -263,6 +264,27 @@ class H
263264
end
264265
end
265266

267+
context "anonymous inherited class" do
268+
let(:anonymous_class) do
269+
Class.new(A) do
270+
memoize def m_args(x, y)
271+
B_CALLS << [x, y]
272+
super(1, 2)
273+
100
274+
end
275+
end
276+
end
277+
278+
subject(:b) { anonymous_class.new }
279+
280+
specify do
281+
values = [ b.m_args(1, 1), b.m_args(1, 2), b.m_args(1, 1) ]
282+
expect(values).to eq([100, 100, 100])
283+
expect(CALLS).to eq([[1, 2]])
284+
expect(B_CALLS).to eq([[1, 1], [1, 2]])
285+
end
286+
end
287+
266288
context "module" do
267289
subject(:c) { C.new }
268290

@@ -336,6 +358,26 @@ class H
336358
end
337359
end
338360

361+
context "without hashed arguments" do
362+
before { Memery.use_hashed_arguments = false }
363+
364+
context "methods without args" do
365+
specify do
366+
values = [ a.m, a.m_nil, a.m, a.m_nil ]
367+
expect(values).to eq([:m, nil, :m, nil])
368+
expect(CALLS).to eq([:m, nil])
369+
end
370+
end
371+
372+
context "method with args" do
373+
specify do
374+
values = [ a.m_args(1, 1), a.m_args(1, 1), a.m_args(1, 2) ]
375+
expect(values).to eq([[1, 1], [1, 1], [1, 2]])
376+
expect(CALLS).to eq([[1, 1], [1, 2]])
377+
end
378+
end
379+
end
380+
339381
describe ":condition option" do
340382
before do
341383
a.environment = environment

0 commit comments

Comments
 (0)