From 582f8074252d91bfe0b88a88a319b3de93e383da Mon Sep 17 00:00:00 2001 From: Aaron Pfeifer Date: Tue, 4 Mar 2025 12:30:55 -0500 Subject: [PATCH] Allow objects with memoized values to be marshaled/unmarshaled across Ruby processes (#51) --- README.md | 30 ++++++++++++++++++++++++++++++ benchmark.rb | 26 ++++++++++++++++++++++++++ lib/memery.rb | 19 +++++++++++++++---- spec/memery_spec.rb | 42 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3508fc2..9d36640 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,36 @@ a.memoized?(:call) # => true a.memoized?(:execute) # => false ``` +### Marshal-compatible Memoization + +In order for objects to be marshaled and loaded in a different Ruby process, +hashed arguments must be disabled in order for memoized values to be retained. +Note that this can have a performance impact if the memoized method contains +arguments. + +```ruby +Memery.use_hashed_arguments = false + +class A + include Memery + + memoize def call + puts "calculating" + 42 + end +end + +a = A.new +a.call + +Marshal.dump(a) +# => "\x04\bo:\x06A\x06:\x1D@_memery_memoized_values{\x06:\tcallS:3Memery::ClassMethods::MemoizationModule::Cache\a:\vresulti/:\ttimef\x14663237.14822323" + +# ...in another Ruby process: +a = Marshal.load("\x04\bo:\x06A\x06:\x1D@_memery_memoized_values{\x06:\tcallS:3Memery::ClassMethods::MemoizationModule::Cache\a:\vresulti/:\ttimef\x14663237.14822323") +a.call # => 42 +``` + ## Differences from Other Gems 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: diff --git a/benchmark.rb b/benchmark.rb index 20cad86..7696174 100644 --- a/benchmark.rb +++ b/benchmark.rb @@ -32,6 +32,10 @@ def base_find(char) memoize def find_new(char) base_find(char) end + + memoize def find_optional(*) + base_find("z") + end end end @@ -43,6 +47,10 @@ def test_with_args Foo.find_new("d") end +def test_empty_args + Foo.find_optional +end + Benchmark.ips do |x| x.report("test_no_args") { test_no_args } end @@ -51,6 +59,14 @@ def test_with_args x.report("test_no_args") { 100.times { test_no_args } } end +Benchmark.ips do |x| + x.report("test_empty_args") { test_empty_args } +end + +Benchmark.memory do |x| + x.report("test_empty_args") { 100.times { test_empty_args } } +end + Benchmark.ips do |x| x.report("test_with_args") { test_with_args } end @@ -59,4 +75,14 @@ def test_with_args x.report("test_with_args") { 100.times { test_with_args } } end +Memery.use_hashed_arguments = false +Benchmark.ips do |x| + x.report("test_with_args_no_hash") { test_with_args } +end + +Benchmark.memory do |x| + x.report("test_with_args_no_hash") { 100.times { test_with_args } } +end +Memery.use_hashed_arguments = true + puts "```" diff --git a/lib/memery.rb b/lib/memery.rb index c2e4c66..1944455 100644 --- a/lib/memery.rb +++ b/lib/memery.rb @@ -4,11 +4,15 @@ module Memery class << self + attr_accessor :use_hashed_arguments + def monotonic_clock Process.clock_gettime(Process::CLOCK_MONOTONIC) end end + @use_hashed_arguments = true + OUR_BLOCK = lambda do extend(ClassMethods) include(InstanceMethods) @@ -68,11 +72,12 @@ def fresh?(ttl) end end + # rubocop:disable Metrics/MethodLength def define_memoized_method!(klass, method_name, condition: nil, ttl: nil) - method_key = "#{method_name}_#{object_id}" - + # Include a suffix in the method key to differentiate between methods of the same name + # being memoized throughout a class inheritance hierarchy + method_key = "#{method_name}_#{klass.name || object_id}" original_visibility = method_visibility(klass, method_name) - original_arity = klass.instance_method(method_name).arity define_method(method_name) do |*args, &block| if block || (condition && !instance_exec(&condition)) @@ -80,7 +85,12 @@ def define_memoized_method!(klass, method_name, condition: nil, ttl: nil) end cache_store = (@_memery_memoized_values ||= {}) - cache_key = original_arity.zero? ? method_key : [method_key, *args].hash + cache_key = if args.empty? + method_key + else + key_parts = [method_key, *args] + Memery.use_hashed_arguments ? key_parts.hash : key_parts + end cache = cache_store[cache_key] return cache.result if cache&.fresh?(ttl) @@ -95,6 +105,7 @@ def define_memoized_method!(klass, method_name, condition: nil, ttl: nil) ruby2_keywords(method_name) send(original_visibility, method_name) end + # rubocop:enable Metrics/MethodLength private diff --git a/spec/memery_spec.rb b/spec/memery_spec.rb index dbc1f55..9d1e43a 100644 --- a/spec/memery_spec.rb +++ b/spec/memery_spec.rb @@ -153,6 +153,7 @@ class H before { CALLS.clear } before { B_CALLS.clear } + before { Memery.use_hashed_arguments = true } let(:unmemoized_class) do Class.new do @@ -263,6 +264,27 @@ class H end end + context "anonymous inherited class" do + let(:anonymous_class) do + Class.new(A) do + memoize def m_args(x, y) + B_CALLS << [x, y] + super(1, 2) + 100 + end + end + end + + subject(:b) { anonymous_class.new } + + specify do + values = [ b.m_args(1, 1), b.m_args(1, 2), b.m_args(1, 1) ] + expect(values).to eq([100, 100, 100]) + expect(CALLS).to eq([[1, 2]]) + expect(B_CALLS).to eq([[1, 1], [1, 2]]) + end + end + context "module" do subject(:c) { C.new } @@ -336,6 +358,26 @@ class H end end + context "without hashed arguments" do + before { Memery.use_hashed_arguments = false } + + context "methods without args" do + specify do + values = [ a.m, a.m_nil, a.m, a.m_nil ] + expect(values).to eq([:m, nil, :m, nil]) + expect(CALLS).to eq([:m, nil]) + end + end + + context "method with args" do + specify do + values = [ a.m_args(1, 1), a.m_args(1, 1), a.m_args(1, 2) ] + expect(values).to eq([[1, 1], [1, 1], [1, 2]]) + expect(CALLS).to eq([[1, 1], [1, 2]]) + end + end + end + describe ":condition option" do before do a.environment = environment