Last active
April 13, 2024 16:27
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment