|
#!/usr/bin/env ruby
|
|
#
|
|
# Usage:
|
|
# 1. See if Ruby >= 3.0 is installed: ruby -v
|
|
# 2. See if "rspec" is installed: gem info rspec
|
|
# 3. If not installed: gem install rspec
|
|
# 4. Run: ruby driver.rb
|
|
|
|
require 'benchmark'
|
|
require 'rspec'
|
|
|
|
# Show cases where a "dup if immutable" method would improve life for Rubyists
|
|
# who are optimizing management of String objects. Mainly, this means making
|
|
# code avoid creating new objects whenever possible.
|
|
#
|
|
RSpec.describe "dup if immutable" do
|
|
context "for String" do
|
|
def mutable_results(size: 2)
|
|
(1..size).map { +"i am mutable" }
|
|
end
|
|
|
|
let(:a_bunch_of_results) { mutable_results + [-"one frozen result"] }
|
|
|
|
let(:check_results_mutable_count) do
|
|
-> do
|
|
# This proves we are dealing with results that may or may not be mutable,
|
|
# and where results are mutable, they outnumber the immutable ones,
|
|
# so always calling `String#dup` is a waste of time and memory.
|
|
results_that_need_dup = a_bunch_of_results.count { _1.frozen? }
|
|
results_that_need_no_dup = a_bunch_of_results.count { ! _1.frozen? }
|
|
expect(results_that_need_dup).to be > 0
|
|
expect(results_that_need_no_dup).to be > results_that_need_dup
|
|
end
|
|
end
|
|
|
|
# Next value works on my CPU, but YMMV
|
|
let(:slow_timing_results_size) { 10_000 }
|
|
|
|
context "without new method" do
|
|
context "without buffer" do
|
|
context "and not defensive" do
|
|
def process_each_result
|
|
a_bunch_of_results.map! do |result|
|
|
result.to_s.tap(&:strip!) << " (some extra info)" # Not defensive!
|
|
end
|
|
end
|
|
|
|
it "has mixed mutable results" do
|
|
check_results_mutable_count.call
|
|
end
|
|
|
|
it "fails" do
|
|
expect { process_each_result }.to raise_exception(FrozenError)
|
|
end
|
|
end
|
|
|
|
context "and defensive" do
|
|
def process_each_result
|
|
a_bunch_of_results.map! do |result|
|
|
(+result.to_s).tap(&:strip!) << " (some extra info)" # Awkward!
|
|
end
|
|
end
|
|
|
|
context "and fast" do
|
|
it "handles mixed mutable results" do
|
|
check_results_mutable_count.call
|
|
end
|
|
|
|
it "works but is awkward" do
|
|
expect { process_each_result }.to_not raise_exception
|
|
end
|
|
|
|
it "does not create new results collection" do
|
|
orig_results_address = a_bunch_of_results.__id__
|
|
processed_results_address = process_each_result.__id__
|
|
expect(processed_results_address).to eq orig_results_address
|
|
end
|
|
|
|
it "does not dup if not needed" do
|
|
mutable_addrs_before = a_bunch_of_results.select { ! _1.frozen? }.map(&:__id__)
|
|
mutable_addrs_after = process_each_result.select { ! _1.frozen? }.map(&:__id__)
|
|
expect(mutable_addrs_after & mutable_addrs_before).to eq mutable_addrs_before
|
|
end
|
|
end
|
|
|
|
context "and slow" do
|
|
# We want to allow uncaching this value to be fair to timing checks
|
|
def a_bunch_of_results
|
|
@my_memoized_results ||= mutable_results(size: slow_timing_results_size) + [-"one frozen result"]
|
|
end
|
|
|
|
def process_each_result_slow
|
|
a_bunch_of_results.map! do |result|
|
|
result.to_s.dup.tap(&:strip!) << " (some extra info)" # Slow!
|
|
end
|
|
end
|
|
|
|
it "handles mixed mutable results" do
|
|
check_results_mutable_count.call
|
|
end
|
|
|
|
it "works but is over 10% slower" do
|
|
@my_memoized_results = nil
|
|
fast_time = Benchmark.realtime { process_each_result }
|
|
@my_memoized_results = nil
|
|
slow_time = Benchmark.realtime { process_each_result_slow }
|
|
puts " " * 6 + "fast: %1.6fs, slow: %1.6fs" % [fast_time, slow_time]
|
|
expect(slow_time).to be > fast_time * 1.10
|
|
end
|
|
|
|
it "does not create new results collection" do
|
|
orig_results_address = a_bunch_of_results.__id__
|
|
processed_results_address = process_each_result_slow.__id__
|
|
expect(processed_results_address).to eq orig_results_address
|
|
end
|
|
|
|
it "dups when not needed" do
|
|
mutable_addrs_before = a_bunch_of_results.select { ! _1.frozen? }.map(&:__id__)
|
|
mutable_addrs_after = process_each_result_slow.select { ! _1.frozen? }.map(&:__id__)
|
|
expect(mutable_addrs_after & mutable_addrs_before).to be_empty
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
context "with buffer" do # E.g. passing through a pipeline/middleware
|
|
context "that is mistakenly frozen" do
|
|
let(:user_buffer) { -"" } # E.g. defined in some far removed user file
|
|
|
|
context "and not defensive" do
|
|
def buffer_all_results(buffer)
|
|
a_bunch_of_results.each do |result|
|
|
buffer << result.to_s << " (some extra info)\n" # Not defensive!
|
|
end
|
|
end
|
|
|
|
it "has frozen input" do
|
|
expect(user_buffer).to be_frozen
|
|
end
|
|
|
|
it "fails" do
|
|
expect { buffer_all_results(user_buffer) }.to raise_exception(FrozenError)
|
|
end
|
|
end
|
|
|
|
context "and defensive" do
|
|
def buffer_all_results(buffer)
|
|
(+buffer).tap do |buf| # Awkward!
|
|
a_bunch_of_results.each do |result|
|
|
buf << result << " (some extra info)\n"
|
|
end
|
|
end
|
|
end
|
|
|
|
it "has frozen input" do
|
|
expect(user_buffer).to be_frozen
|
|
end
|
|
|
|
it "works but is awkward" do
|
|
expect { buffer_all_results(user_buffer) }.to_not raise_exception
|
|
end
|
|
|
|
it "creates a new mutable buffer" do
|
|
buffer = buffer_all_results(user_buffer)
|
|
buffer_address_before = user_buffer.__id__
|
|
buffer_address_after = buffer.__id__
|
|
expect(buffer_address_after).to_not eq buffer_address_before
|
|
end
|
|
end
|
|
end
|
|
|
|
context "that is not frozen" do
|
|
let(:user_buffer) { +"" } # E.g. defined in some far removed user file
|
|
|
|
context "and defensive" do
|
|
def buffer_all_results(buffer)
|
|
(+buffer).tap do |buf| # Awkward!
|
|
a_bunch_of_results.each do |result|
|
|
buf << result << " (some extra info)\n"
|
|
end
|
|
end
|
|
end
|
|
|
|
it "has mutable input" do
|
|
expect(user_buffer).to_not be_frozen
|
|
end
|
|
|
|
it "works but is awkward" do
|
|
expect { buffer_all_results(user_buffer) }.to_not raise_exception
|
|
end
|
|
|
|
it "does not create a new buffer" do
|
|
buffer = buffer_all_results(user_buffer)
|
|
buffer_address_before = user_buffer.__id__
|
|
buffer_address_after = buffer.__id__
|
|
expect(buffer_address_after).to eq buffer_address_before
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
context "with new method" do
|
|
String.class_eval do
|
|
# Note that any clever method name will do, this name is here just for
|
|
# demonstration. The semantics are "return dup if frozen, else return self".
|
|
def new_method_here = frozen? ? dup : self
|
|
end
|
|
|
|
context "without buffer" do
|
|
def process_each_result
|
|
a_bunch_of_results.map! do |result|
|
|
result.to_s.new_method_here.tap(&:strip!) << " (some extra info)"
|
|
end
|
|
end
|
|
|
|
context "and fast" do
|
|
it "handles mixed mutable results" do
|
|
check_results_mutable_count.call
|
|
end
|
|
|
|
it "works in method chain" do
|
|
expect { process_each_result }.to_not raise_exception
|
|
end
|
|
|
|
it "does not create new results collection" do
|
|
orig_results_address = a_bunch_of_results.__id__
|
|
processed_results_address = process_each_result.__id__
|
|
expect(processed_results_address).to eq orig_results_address
|
|
end
|
|
|
|
it "does not dup if not needed" do
|
|
mutable_addrs_before = a_bunch_of_results.select { ! _1.frozen? }.map(&:__id__)
|
|
mutable_addrs_after = process_each_result.select { ! _1.frozen? }.map(&:__id__)
|
|
expect(mutable_addrs_after & mutable_addrs_before).to eq mutable_addrs_before
|
|
end
|
|
end
|
|
|
|
context "and slow" do
|
|
# We want to allow uncaching this value to be fair to timing checks
|
|
def a_bunch_of_results
|
|
@my_memoized_results ||= mutable_results(size: slow_timing_results_size) + [-"one frozen result"]
|
|
end
|
|
|
|
def process_each_result_slow
|
|
a_bunch_of_results.map! do |result|
|
|
result.to_s.dup.tap(&:strip!) << " (some extra info)" # Slow!
|
|
end
|
|
end
|
|
|
|
it "handles mixed mutable results" do
|
|
check_results_mutable_count.call
|
|
end
|
|
|
|
it "works but is over 10% slower" do
|
|
@my_memoized_results = nil
|
|
fast_time = Benchmark.realtime { process_each_result }
|
|
@my_memoized_results = nil
|
|
slow_time = Benchmark.realtime { process_each_result_slow }
|
|
puts " " * 5 + "fast: %1.6fs, slow: %1.6fs" % [fast_time, slow_time]
|
|
expect(slow_time).to be > fast_time * 1.10
|
|
end
|
|
|
|
it "does not create new results collection" do
|
|
orig_results_address = a_bunch_of_results.__id__
|
|
processed_results_address = process_each_result_slow.__id__
|
|
expect(processed_results_address).to eq orig_results_address
|
|
end
|
|
|
|
it "dups when not needed" do
|
|
mutable_addrs_before = a_bunch_of_results.select { ! _1.frozen? }.map(&:__id__)
|
|
mutable_addrs_after = process_each_result_slow.select { ! _1.frozen? }.map(&:__id__)
|
|
expect(mutable_addrs_after & mutable_addrs_before).to be_empty
|
|
end
|
|
end
|
|
end
|
|
|
|
context "with buffer" do # E.g. passing through a pipeline/middleware
|
|
context "that is mistakenly frozen" do
|
|
let(:user_buffer) { -"" } # E.g. defined in some far removed user file
|
|
|
|
context "and not defensive" do
|
|
def buffer_all_results(buffer)
|
|
a_bunch_of_results.each do |result|
|
|
buffer << result.to_s << " (some extra info)\n" # Not defensive!
|
|
end
|
|
end
|
|
|
|
it "has frozen input" do
|
|
expect(user_buffer).to be_frozen
|
|
end
|
|
|
|
it "fails" do
|
|
expect { buffer_all_results(user_buffer) }.to raise_exception(FrozenError)
|
|
end
|
|
end
|
|
|
|
context "and defensive" do
|
|
def buffer_all_results(buffer)
|
|
buffer.new_method_here.tap do |buf|
|
|
a_bunch_of_results.each do |result|
|
|
buf << result << " (some extra info)\n"
|
|
end
|
|
end
|
|
end
|
|
|
|
it "has frozen input" do
|
|
expect(user_buffer).to be_frozen
|
|
end
|
|
|
|
it "works in method chain" do
|
|
expect { buffer_all_results(user_buffer) }.to_not raise_exception
|
|
end
|
|
|
|
it "creates a new mutable buffer" do
|
|
buffer = buffer_all_results(user_buffer)
|
|
buffer_address_before = user_buffer.__id__
|
|
buffer_address_after = buffer.__id__
|
|
expect(buffer_address_after).to_not eq buffer_address_before
|
|
end
|
|
end
|
|
end
|
|
|
|
context "that is not frozen" do
|
|
let(:user_buffer) { +"" } # E.g. defined in some far removed user file
|
|
|
|
context "and defensive" do
|
|
def buffer_all_results(buffer)
|
|
buffer.new_method_here.tap do |buf|
|
|
a_bunch_of_results.each do |result|
|
|
buf << result << " (some extra info)\n"
|
|
end
|
|
end
|
|
end
|
|
|
|
it "has mutable input" do
|
|
expect(user_buffer).to_not be_frozen
|
|
end
|
|
|
|
it "works in method chain" do
|
|
expect { buffer_all_results(user_buffer) }.to_not raise_exception
|
|
end
|
|
|
|
it "does not create a new buffer" do
|
|
buffer = buffer_all_results(user_buffer)
|
|
buffer_address_before = user_buffer.__id__
|
|
buffer_address_after = buffer.__id__
|
|
expect(buffer_address_after).to eq buffer_address_before
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if File.expand_path($0) == File.expand_path(__FILE__)
|
|
exec(*%w[rspec --format doc --order defined].concat(ARGV).append($0))
|
|
end
|