Skip to content

Instantly share code, notes, and snippets.

@JoelQ
Last active April 13, 2024 16:27
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save JoelQ/3056a0a6e8b5488faa5caeef630cd702 to your computer and use it in GitHub Desktop.
Save JoelQ/3056a0a6e8b5488faa5caeef630cd702 to your computer and use it in GitHub Desktop.
Demonstration of using a rich object to represent a composite count and how it makes the math easier to deal with
class Tally
def self.empty
new({})
end
def initialize(raw)
@raw = empty_hash.merge raw.slice(:small, :medium, :large)
end
# Accessors
def small
raw.fetch(:small)
end
def medium
raw.fetch(:medium)
end
def large
raw.fetch(:large)
end
# This is a value object so #==, #eql?, and #hash compare by value, not by
# identity
def ==(other)
self.raw == other.raw
end
alias_method :eql?, :==
def hash
[self.class, raw].hash
end
# Combinators
def +(other)
self.class.new(
small: self.small + other.small,
medium: self.medium + other.medium,
large: self.large + other.large,
)
end
def -(other)
self.class.new(
small: self.small - other.small,
medium: self.medium - other.medium,
large: self.large - other.large,
)
end
# Return a new tally that only includes the negative numbers, and make them
# positive. Useful when you want to answer the question "how many missing
# items do I need?" after diffing two tallies.
def missing_items
Tally.new(
small: [self.small, 0].min.abs,
medium: [self.medium, 0].min.abs,
large: [self.large, 0].min.abs,
)
end
protected
# This acessor is only needed internally and when comparing with other
# instances. Users of this object should not use it directly, hence this being
# marked as "protected".
attr_reader :raw
private
def empty_hash
{small: 0, medium: 0, large: 0}
end
end
require_relative "./tally"
require_relative "./value_object_shared_examples"
RSpec.describe Tally do
it_behaves_like "value object" do
let(:item) { Tally.new(small: 1) }
let(:same) { Tally.new(small: 1) }
let(:different) { Tally.new(small: 2) }
end
describe ".empty" do
it "returns an empty tally" do
expect(Tally.empty).to eq Tally.new(small: 0, medium: 0, large: 0)
end
end
describe ".new" do
it "cleans unwanted keys" do
tally = Tally.new(small: 1, medium: 1, large: 1, other: 1)
expect(tally).to eq Tally.new(small: 1, medium: 1, large: 1)
end
it "defaults unspecified keys" do
tally = Tally.new(small: 1)
expect(tally).to eq Tally.new(small: 1, medium: 0, large: 0)
end
end
describe "#small" do
it "returns the small count" do
tally = Tally.new(small: 1)
expect(tally.small).to eq 1
end
end
describe "#medium" do
it "returns the medium count" do
tally = Tally.new(medium: 1)
expect(tally.medium).to eq 1
end
end
describe "#large" do
it "returns the large count" do
tally = Tally.new(large: 1)
expect(tally.large).to eq 1
end
end
describe "#+" do
it "combines the two hashes" do
tally1 = Tally.new(small: 1, medium: 2, large: 3)
tally2 = Tally.new(small: 3, medium: 2, large: 1)
tally3 = tally1 + tally2
expect(tally3).to eq Tally.new(small: 4, medium: 4, large: 4)
end
end
describe "monoid laws" do
it "is associative" do
tally1 = Tally.new(small: 1)
tally2 = Tally.new(medium: 1)
tally3 = Tally.new(large: 1)
expect(tally1 + (tally2 + tally3)).to eq((tally1 + tally2) + tally3)
end
it "follows left identity" do
tally = Tally.new(small: 1)
expect(tally + Tally.empty).to eq tally
end
it "follows right identity" do
tally = Tally.new(small: 1)
expect(Tally.empty + tally).to eq tally
end
end
describe "#-" do
it "diffs the two hashes" do
tally1 = Tally.new(small: 4, medium: 4, large: 4)
tally2 = Tally.new(small: 1, medium: 2, large: 3)
expect(tally1 - tally2).to eq Tally.new(small: 3, medium: 2, large: 1)
end
end
describe "#missing_items" do
it "turns a diff into a tally positive tally" do
diff = Tally.new(small: 1, medium: 0, large: -1)
expect(diff.missing_items).to eq Tally.new(large: 1)
end
end
end
# Consider a scenario where you want to buy t-shirts for your team. You already
# have some shirts in stock but not enough. How many new shirts do you need to
# order?
#
# When calculating the number of t-shirts you need to buy, you might do so using
# the following math:
#
# need_to_buy = [(shirts_on_hand - desired_shirts), 0].min.abs
#
# But what if you need to handle multiple sizes? The same math applies, we just
# need an object to represent the composite count and implement the correct math
# operations.
# Tally is such a class that represents a composite count.
require_relative "./tally.rb"
User = Struct.new(:name, :t_shirt_size)
team = [
User.new("Alice", :small),
User.new("Bob", :medium),
User.new("Carol", :large),
User.new("Dan", :large),
User.new("Emily", :small)
]
# We track what t-shirt size each team-member prefers. Because each item in the
# array represents a single count, we can use Enumerable#tally to get a hash of
# counts. This can be passed to the Tally constructor to get a rich Tally
# object.
desired_shirts = Tally.new(team.map(&:t_shirt_size).tally)
Warehouse = Struct.new(:stock_json)
warehouses = [
Warehouse.new({small: 1}),
Warehouse.new({small: 1, medium: 2, large: 1})
]
# Because warehouses already contain compound counts, we can't use the same
# strategy of aggregating a *single* tally hash and passing it to Tally.new like
# we did for the array of users.
#
# Instead, we construct a Tally for *each* warehouse and then reduce them down
# to a single Tally represents the composite sum of them all. This is possible
# because Tally implements `+` and obeys the Monoid laws.
shirts_on_hand = warehouses.map { |w| Tally.new(w.stock_json) }.reduce(:+)
# Tally#missing_items does the min/abs math
need_to_buy = (shirts_on_hand - desired_shirts).missing_items
binding.irb
RSpec.shared_examples_for "value object" do
describe "#hash" do
it "is the same as another instance of the same value" do
expect(item.hash).to eq same.hash
end
it "is the different from another instance of a different value" do
expect(item.hash).not_to eq different.hash
end
end
describe "#eql?" do
it "two instances are eql if they have the same value" do
expect(item).to be_eql same
end
it "two instances are not eql if they have a different value" do
expect(item).not_to be_eql different
end
end
describe "#==" do
it "two instances are == if they have the same value" do
expect(item).to eq same
end
it "two instances are not == if they have a different value" do
expect(item).not_to eq different
end
end
describe "#equal?" do
it "is equal if two object share the same identity" do
expect(item).to be_equal item
end
it "is not equal if two different objects share the same value" do
expect(item).not_to be_equal same
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment