Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Change MemoWise public API #247

Closed
wants to merge 30 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
beb6e07
Revert internal data structure to simple hash
JacobEvelyn Dec 13, 2021
68a58b2
Fixed rubocop
jemmaissroff Dec 13, 2021
a561d30
Change `prepend MemoWise` to `extend MemoWise`
paracycle Dec 9, 2021
b6a249c
Remove `initialize` override in MemoWise module
paracycle Dec 9, 2021
6687f2d
Move `memo_wise` method top-level in `MemoWise` module
paracycle Dec 9, 2021
4f2882f
Add `memo_wise_module` builder method
paracycle Dec 9, 2021
b7ee80e
Implement the `memo_wise self: :foo` code path
paracycle Dec 9, 2021
0f39b35
Add internal memo wise state to memo wise module
paracycle Dec 9, 2021
fcfe38c
Add support for frozen objects to memo wise module
paracycle Dec 9, 2021
b3dac0e
Remove internal state setup related code from `memo_wise`
paracycle Dec 9, 2021
dd0adf5
Refactor `next_index!` to be an instance method on `InternalAPI`
paracycle Dec 9, 2021
d7ea3fd
Implement `memo_wise` method fully
paracycle Dec 9, 2021
ca088cd
Move and simplify the `instance_method` override
paracycle Dec 9, 2021
4bf4e63
Get rid of state initialization in `self.prepended` method
paracycle Dec 9, 2021
503e842
Remove `self.prepended` method completely
paracycle Dec 10, 2021
244ca97
Move `memo_wise` documentation to the correct place
paracycle Dec 10, 2021
a3f7fd8
Make `build_module` a private method
paracycle Dec 10, 2021
4ddb91f
Change `validate_memo_wised!` method to `memo_wised_method`
paracycle Dec 10, 2021
40a1e1c
Move `preset_memo_wise` method inside the MemoWise methods module
paracycle Dec 10, 2021
5294212
Move `reset_memo_wise` method inside the MemoWise methods module
paracycle Dec 10, 2021
8c6db07
Name the internal module `MemoWiseMethods`
paracycle Dec 10, 2021
71ff3dc
Be a good citizen and return method name from `memo_wise`
paracycle Dec 10, 2021
0d34696
Starting to rebase off main
jemmaissroff Dec 10, 2021
4b14648
WIP
jemmaissroff Dec 10, 2021
d08f1f4
Still failing
jemmaissroff Dec 10, 2021
95f57ae
WIP
jemmaissroff Dec 13, 2021
d046d0a
Fixing specs
jemmaissroff Dec 13, 2021
6d979c1
All tests passing 🎉
jemmaissroff Dec 14, 2021
07f8096
Removed unused code
jemmaissroff Dec 14, 2021
ea14b66
Fixed rubocop
jemmaissroff Dec 14, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Nothing yet!
- Switch to a simpler internal data structure to fix several classes of bugs
that the previous few versions were unable to sufficiently address

## [1.4.0] - 2021-12-10

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Or install it yourself as:

## Usage

When you `prepend MemoWise` within a class or module, `MemoWise` exposes three
When you `extend MemoWise` within a class or module, `MemoWise` exposes three
methods:

- [`memo_wise`](https://rubydoc.info/github/panorama-ed/memo_wise/MemoWise#memo_wise-class_method)
Expand All @@ -50,7 +50,7 @@ methods:

```ruby
class Example
prepend MemoWise
extend MemoWise

def slow_value(x)
sleep x
Expand Down Expand Up @@ -86,7 +86,7 @@ The same three methods are exposed for class methods as well:

```ruby
class Example
prepend MemoWise
extend MemoWise

def self.class_slow_value(x)
sleep x
Expand Down
2 changes: 1 addition & 1 deletion benchmarks/benchmarks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def benchmark_name
# NOTE: Some gems do not yet work in Ruby 3 so we only test with them if they've
# been `require`d.
BENCHMARK_GEMS = [
BenchmarkGem.new(MemoWise, "prepend MemoWise", :memo_wise),
BenchmarkGem.new(MemoWise, "extend MemoWise", :memo_wise),
(BenchmarkGem.new(DDMemoize, "DDMemoize.activate(self)", :memoize) if defined?(DDMemoize)),
(BenchmarkGem.new(Dry::Core, "include Dry::Core::Memoizable", :memoize) if defined?(Dry::Core)),
(BenchmarkGem.new(Memery, "include Memery", :memoize) if defined?(Memery)),
Expand Down
910 changes: 325 additions & 585 deletions lib/memo_wise.rb

Large diffs are not rendered by default.

171 changes: 23 additions & 148 deletions lib/memo_wise/internal_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,61 +2,6 @@

module MemoWise
class InternalAPI
# Create initial mutable state to store memoized values if it doesn't
# already exist
#
# @param [Object] obj
# Object in which to create mutable state to store future memoized values
#
# @return [Object] the passed-in obj
def self.create_memo_wise_state!(obj)
# `@_memo_wise` stores memoized results of method calls. The structure is
# slightly different for different types of methods. It looks like:
# [
# :memoized_result, # For method 0 (which takes no arguments)
# { arg1 => :memoized_result, ... }, # For method 1 (which takes an argument)
# { [arg1, arg2] => :memoized_result, ... } # For method 2 (which takes multiple arguments)
# ]
# This is a faster alternative to:
# {
# zero_arg_method_name: :memoized_result,
# single_arg_method_name: { arg1 => :memoized_result, ... },
#
# # Surprisingly, this is faster than a single top-level hash key of: [:multi_arg_method_name, arg1, arg2]
# multi_arg_method_name: { [arg1, arg2] => :memoized_result, ... }
# }
# because we can give each method its own array index at load time and
# perform that array lookup more quickly than a hash lookup by method
# name.
obj.instance_variable_set(:@_memo_wise, []) unless obj.instance_variable_defined?(:@_memo_wise)

# For zero-arity methods, memoized values are stored in the `@_memo_wise`
# array. Arrays do not differentiate between "unset" and "set to nil" and
# so to handle this case we need another array to store sentinels and
# store `true` at indexes for which a zero-arity method has been memoized.
# `@_memo_wise_sentinels` looks like:
# [
# true, # A zero-arity method's result has been memoized
# nil, # A zero-arity method's result has not been memoized
# nil, # A one-arity method will always correspond to `nil` here
# ...
# ]
# NOTE: Because `@_memo_wise` stores memoized values for more than just
# zero-arity methods, the `@_memo_wise_sentinels` array can end up being
# sparse (see above), even when all methods' memoized values have been
# stored. If this becomes an issue we could store a separate index for
# zero-arity methods to make every element in `@_memo_wise_sentinels`
# correspond to a zero-arity method.
# NOTE: Surprisingly, lookups on an array of `true` and `nil` values
# appear to outperform even bitwise operators on integers (as of Ruby
# 3.0.2), allowing us to avoid more complex sentinel structures.
unless obj.instance_variable_defined?(:@_memo_wise_sentinels)
obj.instance_variable_set(:@_memo_wise_sentinels, [])
end

obj
end

NONE = :none
ONE_REQUIRED_POSITIONAL = :one_required_positional
ONE_REQUIRED_KEYWORD = :one_required_keyword
Expand Down Expand Up @@ -144,64 +89,18 @@ def self.key_str(method)
end
end

# Find the original class for which the given class is the corresponding
# "singleton class".
#
# See https://stackoverflow.com/questions/54531270/retrieve-a-ruby-object-from-its-singleton-class
#
# @param klass [Class]
# Singleton class to find the original class of
#
# @return Class
# Original class for which `klass` is the singleton class.
#
# @raise ArgumentError
# Raises if `klass` is not a singleton class.
#
def self.original_class_from_singleton(klass)
raise ArgumentError, "Must be a singleton class: #{klass.inspect}" unless klass.singleton_class?

# Since we call this method a lot, we memoize the results. This can have a
# huge impact; for example, in our test suite this drops our test times
# from over five minutes to just a few seconds.
@original_class_from_singleton ||= {}

# Search ObjectSpace
# * 1:1 relationship of singleton class to original class is documented
# * Performance concern: searches all Class objects
# But, only runs at load time and results are memoized
@original_class_from_singleton[klass] ||= ObjectSpace.each_object(Module).find do |cls|
cls.singleton_class == klass
end
end

# Convention we use for renaming the original method when we replace with
# the memoized version in {MemoWise.memo_wise}.
#
# @param method_name [Symbol]
# Name for which to return the renaming for the original method
#
# @return [Symbol]
# Renamed method to use for the original method with name `method_name`
#
def self.original_memo_wised_name(method_name)
:"_memo_wise_original_#{method_name}"
# @param target [Class, Module]
# The class to which we are prepending MemoWise to provide memoization;
# the `InternalAPI` *instance* methods will refer to this `target` class.
def initialize(target)
@target = target
end

# @param method_name [Symbol] the name of the memoized method
# @return [Integer] the array index in `@_memo_wise_indices` to use to find
# the memoization data for the given method
def self.index(target, method_name)
klass = target_class(target)
indices = klass.instance_variable_get(:@_memo_wise_indices)
indices&.[](method_name) || next_index!(klass, method_name)
end
# @return [Class, Module]
attr_reader :target

# Returns visibility of an instance method defined on class `target`.
#
# @param target [Class, Module]
# The class to which we are prepending MemoWise to provide memoization.
#
# @param method_name [Symbol]
# Name of existing *instance* method find the visibility of.
#
Expand All @@ -212,7 +111,7 @@ def self.index(target, method_name)
# Raises `ArgumentError` unless `method_name` is a `Symbol` corresponding
# to an existing **instance** method defined on `klass`.
#
def self.method_visibility(target, method_name)
def method_visibility(method_name)
if target.private_method_defined?(method_name)
:private
elsif target.protected_method_defined?(method_name)
Expand All @@ -224,25 +123,32 @@ def self.method_visibility(target, method_name)
end
end

# Validates that {.memo_wise} has already been called on `method_name`.
# Validates that `method_name` is a method defined by a call to {.memo_wise},
# and returns the method
#
# @param target [Class, Module]
# The class to which we are prepending MemoWise to provide memoization.
#
# @param method_name [Symbol]
# Name of method to validate has already been setup with {.memo_wise}
def self.validate_memo_wised!(target, method_name)
original_name = original_memo_wised_name(method_name)
# Name of method that should have been setup with {.memo_wise}
def memo_wised_method(method_name)
klass = target_class

method_defined = klass.method_defined?(method_name) || klass.private_method_defined?(method_name)

raise ArgumentError, "#{method_name} is not a memo_wised method" unless method_defined

unless target_class(target).private_method_defined?(original_name)
method = klass.instance_method(method_name)

unless method.owner == klass.memo_wise_module && method.super_method
raise ArgumentError, "#{method_name} is not a memo_wised method"
end

method
end

# @param target [Class, Module]
# The class to which we are prepending MemoWise to provide memoization.
# @return [Class] where we look for method definitions
def self.target_class(target)
def target_class
if target.instance_of?(Class)
# A class's methods are defined in its singleton class
target.singleton_class
Expand All @@ -251,36 +157,5 @@ def self.target_class(target)
target.class
end
end
private_class_method :target_class

# Increment the class's method index counter, and return an index to use for
# the given method name.
#
# @param klass [Class]
# Original class on which a method is being memoized
#
# @param method_name [Symbol]
# The name of the method being memoized
#
# @return [Integer]
# The index within `@_memo_wise` to store the method's memoized results
def self.next_index!(klass, method_name)
# `@_memo_wise_indices` stores the `@_memo_wise` indices of different
# method names. We only use this data structure when resetting or
# presetting memoization. It looks like:
# {
# single_arg_method_name: 0,
# other_single_arg_method_name: 1
# }
memo_wise_indices = klass.instance_variable_get(:@_memo_wise_indices)
memo_wise_indices ||= klass.instance_variable_set(:@_memo_wise_indices, {})

index = klass.instance_variable_get(:@_memo_wise_index_counter) || 0
memo_wise_indices[method_name] = index
klass.instance_variable_set(:@_memo_wise_index_counter, index + 1)

index
end
private_class_method :next_index!
end
end
27 changes: 16 additions & 11 deletions spec/adding_methods_spec.rb
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
# frozen_string_literal: true

RSpec.describe "adding methods" do # rubocop:disable RSpec/DescribeClass
let(:klass) { Class.new }
let(:klass) do
Class.new do
extend MemoWise
def self.no_args; end
def no_args; end
end
end

context "when class prepends MemoWise" do
subject { klass.send(:prepend, MemoWise) }
context "when class extends MemoWise" do
subject { klass.memo_wise(:no_args) }

let(:expected_public_instance_methods) do
%i[
preset_memo_wise
reset_memo_wise
_memo_wise
].to_set
end

let(:expected_public_class_methods) do
%i[
allocate
instance_method
memo_wise
_memo_wise
freeze
preset_memo_wise
reset_memo_wise
].to_set
Expand All @@ -35,7 +41,7 @@
end

it "adds expected public *class* methods only" do
expect { subject }.
expect { klass.memo_wise(self: :no_args) }.
to change { klass.singleton_methods.to_set }.
by(expected_public_class_methods)
end
Expand All @@ -50,7 +56,7 @@
unless RUBY_PLATFORM == "java"
context "when a class method is memoized" do
subject do
klass.send(:prepend, MemoWise)
klass.send(:extend, MemoWise)
klass.send(:memo_wise, self: :example)
end

Expand All @@ -63,9 +69,8 @@ def self.example; end
let(:expected_public_class_methods) { super() << :inherited }

it "adds expected public *instance* methods only" do
expect { subject }.
to change { klass.singleton_methods.to_set }.
by(expected_public_class_methods)
expect(klass.singleton_methods).to include(*klass.singleton_methods)
subject
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion spec/hash_collision_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

let(:class_with_memo) do
Class.new do
prepend MemoWise
extend MemoWise

def return_given_args(a, b) # rubocop:disable Naming/MethodParameterName
[a, b]
Expand Down
Loading