Project

General

Profile

Feature #18597 » driver.rb

Show patterns for named method - danh337 (Dan H), 02/26/2022 11:20 PM

 
#!/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
let!(:a_bunch_of_results) { mutable_results(size: slow_timing_results_size) + [-"one frozen result"] }

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
fast_time = Benchmark.realtime { process_each_result }
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
let!(:a_bunch_of_results) { mutable_results(size: slow_timing_results_size) + [-"one frozen result"] }

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
fast_time = Benchmark.realtime { process_each_result }
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
(1-1/2)