How experience with Clojure has impacted my approach to Ruby

This talk is not ...

¡simplicity!

(do not) complect

readability

(not so simple)

readability is subjective / requires familiarity

Para falantes de português, este parágrafo é muito legível. Para falantes de català ou espanhol, provavelmente compreensível. Para as pessoas que só falam inglês, não tanto.

readability is contextual

# Everybody knows what + means, right?

1 + 2
# => 3

readability is contextual

# Everybody knows what + means, right?

1 + 2
# => 3

ruby + clojure
# => ???????
#
# what is ruby in this context?
# what is clojure in this context?
# what does it mean to add them?

simple | readable

  • simple: not compound
  • readable: familiar, context

  • orthogonal concepts
    • you can have both

looks confusing, let's refactor

def initialize(inclusion_patterns=nil, exclusion_patterns=DEFAULT_EXCLUSION_PATTERNS.dup)
  @exclusion_patterns = exclusion_patterns
  if inclusion_patterns.nil?
    @inclusion_patterns = matches_an_exclusion_pattern?(Dir.getwd) ?
                            [Regexp.new(Dir.getwd)] : []
  else
    @inclusion_patterns = inclusion_patterns
  end
end

def exclude?(line)
  @inclusion_patterns.none? {|p| p =~ line} and matches_an_exclusion_pattern?(line)
end

def matches_an_exclusion_pattern?(line)
  @exclusion_patterns.any? {|p| p =~ line}
end
  • first: assignment to @inclusion_patterns seems overly verbose

collapse assignment to @inclusion_patterns

def initialize(inclusion_patterns=nil, exclusion_patterns=DEFAULT_EXCLUSION_PATTERNS.dup)
  @exclusion_patterns = exclusion_patterns
  @inclusion_patterns = inclusion_patterns ||
    (matches_an_exclusion_pattern?(Dir.getwd) ? [Regexp.new(Dir.getwd)] : [])
end

def exclude?(line)
  @inclusion_patterns.none? {|p| p =~ line} and matches_an_exclusion_pattern?(line)
end

def matches_an_exclusion_pattern?(line)
  @exclusion_patterns.any? {|p| p =~ line}
end
  • next: handling nils in two different ways

align handling of nil args

def initialize(inclusion_patterns=nil, exclusion_patterns=nil)
  @exclusion_patterns = exclusion_patterns || DEFAULT_EXCLUSION_PATTERNS.dup
  @inclusion_patterns = inclusion_patterns ||
    (matches_an_exclusion_pattern?(Dir.getwd) ? [Regexp.new(Dir.getwd)] : [])
end

def exclude?(line)
  @inclusion_patterns.none? {|p| p =~ line} and matches_an_exclusion_pattern?(line)
end

def matches_an_exclusion_pattern?(line)
  @exclusion_patterns.any? {|p| p =~ line}
end
  • next: params are not handled in order

align the order of assignments and params

# boom!

def initialize(inclusion_patterns=nil, exclusion_patterns=nil)
  @inclusion_patterns = inclusion_patterns ||
    (matches_an_exclusion_pattern?(Dir.getwd) ? [Regexp.new(Dir.getwd)] : [])
    # ▲▲▲ has an order dependency on ▼▼▼
  @exclusion_patterns = exclusion_patterns || DEFAULT_EXCLUSION_PATTERNS.dup)
end

def exclude?(line)
  @inclusion_patterns.none? {|p| p =~ line} and matches_an_exclusion_pattern?(line)
end

def matches_an_exclusion_pattern?(line)
  @exclusion_patterns.any? {|p| p =~ line}
end

inline to clarify the order dependency

def initialize(inclusion_patterns=nil, exclusion_patterns=nil)
  @exclusion_patterns = exclusion_patterns || DEFAULT_EXCLUSION_PATTERNS.dup)
  @inclusion_patterns = inclusion_patterns ||
    (@exclusion_patterns.any? {|p| p =~ Dir.getwd} ? [Regexp.new(Dir.getwd)] : [])
end

def exclude?(line)
  @inclusion_patterns.none? {|p| p =~ line} and matches_an_exclusion_pattern?(line)
end

def matches_an_exclusion_pattern?(line)
  @exclusion_patterns.any? {|p| p =~ line}
end
  • still processing params in reverse order

reorder the parameters

def initialize(exclusion_patterns=nil, inclusion_patterns=nil)
  @exclusion_patterns = exclusion_patterns || DEFAULT_EXCLUSION_PATTERNS.dup)
  @inclusion_patterns = inclusion_patterns ||
    (@exclusion_patterns.any? {|p| p =~ Dir.getwd} ? [Regexp.new(Dir.getwd)] : [])
end

def exclude?(line)
  @inclusion_patterns.none? {|p| p =~ line} and matches_an_exclusion_pattern?(line)
end

def matches_an_exclusion_pattern?(line)
  @exclusion_patterns.any? {|p| p =~ line}
end
  • Next ...
    • exclude? violates SLAP
    • only one invocation of matches_an_exclusion_pattern?

inline the other invocation

def initialize(exclusion_patterns=nil, inclusion_patterns=nil)
  @exclusion_patterns = exclusion_patterns || DEFAULT_EXCLUSION_PATTERNS.dup)
  @inclusion_patterns = inclusion_patterns ||
    (@exclusion_patterns.any? {|p| p =~ Dir.getwd} ? [Regexp.new(Dir.getwd)] : [])
end

def exclude?(line)
  @inclusion_patterns.none? {|p| p =~ line} and @exclusion_patterns.any? {|p| p =~ line}
end
  • still handling ex/in out of order in exclude?

align the refs within the method

def initialize(exclusion_patterns=nil, inclusion_patterns=nil)
  @exclusion_patterns = exclusion_patterns || DEFAULT_EXCLUSION_PATTERNS.dup)
  @inclusion_patterns = inclusion_patterns ||
    (@exclusion_patterns.any? {|p| p =~ Dir.getwd} ? [Regexp.new(Dir.getwd)] : [])
end

def exclude?(line)
  @exclusion_patterns.any? {|p| p =~ line} and @inclusion_patterns.none? {|p| p =~ line}
end

duplication? extract method?

# operand operator operand

1 + 2
37 + 42

add(1,2)
add(37,42)
# object.method(arg)

special_formatter.format("this string")
special_formatter.format("that string")

specially_format("this string")
specially_format("that string")

probably not

duplication? extract method?

# object.method { |first_operand| first_operand operator second_operand }

@exclusion_patterns.any? {|p| p =~ Dir.getwd}
@exclusion_patterns.any? {|p| p =~ line}

# extract method wrapping entire expression
any_exclusion_patterns_match?(Dir.getwd)
any_exclusion_patterns_match?(line)
  • what value does the extracted method add?
  • is it more intention revealing?
    • Ruby already lets us express any_exclusion_patterns with @exclusion_patterns.any?
    • that leaves match? representing the block content

extract method with reduced scope?

def initialize(exclusion_patterns=nil, inclusion_patterns=nil)
  @exclusion_patterns = exclusion_patterns || DEFAULT_EXCLUSION_PATTERNS.dup)
  @inclusion_patterns = inclusion_patterns ||
    (@exclusion_patterns.any?(&match?(Dir.getwd)) ? [Regexp.new(Dir.getwd)] : [])
end

def exclude?(line)
  @exclusion_patterns.any?(&match?(line)) && @inclusion_patterns.none?(&match?(line))
end

def match?(string)
  lambda {|re| re =~ str}
end

smaller components are more composable

def match?(str)
  lambda {|re| re =~ str}
end

# use with any iterator

patterns.any? &match?(str)
patterns.none? &match?(str)
patterns.select &match?(str)
patterns.reject &match?(str)

which is more readable?

@exclusion_patterns.any? {|p| p =~ line}

@exclusion_patterns.any? {|p| p.match(line)}

@exclusion_patterns.any? &match?(line)

any_exclusion_patterns_match?(line)

which is simpler?

choose simplicity; choose Ruby

def initialize(exclusion_patterns=nil, inclusion_patterns=nil)
  @exclusion_patterns = exclusion_patterns || DEFAULT_EXCLUSION_PATTERNS.dup)
  @inclusion_patterns = inclusion_patterns ||
    (@exclusion_patterns.any? {|p| p =~ Dir.getwd} ? [Regexp.new(Dir.getwd)] : [])
end

def exclude?(line)
  @exclusion_patterns.any? {|p| p =~ line} and @inclusion_patterns.none? {|p| p =~ line}
end
  • readable (subjective)
    • intention revealing (even in the blocks)
    • symmetric
    • SLAP happy
  • simple (objective)
    • uses Ruby for domain of computation
    • no unnecessary delegation (to methods that don't really clarify intent)

summary

  • balance readability and simplicity
    • readability is subjective: depends on familiarity
      • is this really awkward or do I need more familiarity?
    • simplicity is objective: not compound
  • start with Ruby
  • extract methods to
    • break up long methods
    • reduce duplication
    • clarify confusing code
    • enhance a domain
  • generalize method extraction by reducing scope
    • more composable components

data pipelines

data pipelines

(1.day + 1.year + 2.months + 2.days).inspect
# => "1 year, 2 months and 3 days"

# Internally, (1.day + 1.year + 2.months + 2.days) is represented as
# [[:days, 1], [:years, 1], [:months, 2], [:days, 2]]

data pipelines

(defn format-duration [parts]
  (->> parts
       (map     (partial apply hash-map))
       (apply   merge-with +)
       (sort-by order-of-units)
       (map     format-unit)
       (map     format-unit-val-pair)
       (to-sentence)))

(format-duration [[:days 1] [:years 1] [:months 2] [:days 2]])
; => "1 year, 2 months and 3 days"

data pipelines

(defn format-duration [parts]
  (->> parts
       (map     (partial apply hash-map))
       (apply   merge-with +)
       (sort-by (fn [[u _]] (.indexOf [:years, :months :days :hours :minutes :seconds] u)))
       (map     (fn [[u v]] (if (= 1 v) [v (chop (name u))] [v (name u)])))
       (map     (partial clojure.string/join " "))
       (to-sentence)))

(format-duration [[:days 1] [:years 1] [:months 2] [:days 2]])
; => "1 year, 2 months and 3 days"

data pipelines

(defn format-duration [parts]
  (->> parts
       ; [[:days 1] [:years 1] [:months 2] [:days 2]]
       (map (partial apply hash-map))
       ; [{:days 1} {:years 1} {:months 2} {:days 2}]
       (apply merge-with +)
       ; {:days 3 :years 1 :months 2}
       (sort-by (fn [[u _]] (.indexOf [:years :months :days :hours :minutes :seconds] u)))
       ; [[:years 1] [:months 2] [:days 3]]
       (map     (fn [[u v]] (if (= 1 v) [v (chop (name u))] [v (name u)])))
       ; [[1 "year"] [2 "months" 2] [3 "days"]]
       (map     (partial clojure.string/join " "))
       ; ["1 year" "2 months" "3 days"]
       (to-sentence)))
       ; "1 year, 2 months and 3 days"

(format-duration [[:days 1] [:years 1] [:months 2] [:days 2]])

data pipelines

# Duration#inspect

def inspect
  consolidated = parts.inject(::Hash.new(0)) { |h,(l,r)| h[l] += r; h }
  parts = [:years, :months, :days, :minutes, :seconds].map do |length|
    n = consolidated[length]
    "#{n} #{n == 1 ? length.to_s.singularize : length.to_s}" if n.nonzero?
  end.compact
  parts = ["0 seconds"] if parts.empty?
  parts.to_sentence(:locale => :en)
end
  • map, then compact
    • builds a collection including nils, then removes them
  • special case for "0 seconds"
  • duplicates formatting logic

data pipelines

# Duration#inspect mid-refactoring

def inspect
  val_for = parts.inject(::Hash.new(0)) { |h,(l,r)| h[l] += r; h }
  [:years, :months, :days, :minutes, :seconds].
    select {|unit| val_for[unit].nonzero? }.
    map {|unit| [unit, val_for[unit]]}.
    map {|unit, val| "#{val} #{val == 1 ? unit.to_s.singularize : unit.to_s}"}.
    tap {|units| units << "0 seconds" if units.empty?}.
    to_sentence(:locale => :en)
end
  • reassembled as a series of small transformations
    • makes it easy to start moving parts around

data pipelines

# Duration#inspect refactored

def inspect
  parts.
    reduce(::Hash.new(0)) {|h,(unit,val)| h[unit] += val; h}.
    sort_by {|unit, _ | [:years, :months, :days, :minutes, :seconds].index(unit)}.
    map     {|unit,val| "#{val} #{val == 1 ? unit.to_s.chop : unit.to_s}"}.
    to_sentence(:locale => :en)
end

data pipelines

# Duration#inspect refactored

def inspect
  parts.
    # [[:days, 1], [:years, 1], [:months, 2], [:days, 2]]
    reduce(::Hash.new(0)) {|h,(unit,val)| h[unit] += val; h}.
    # {:days => 3, :years => 1, :months => 2}
    sort_by {|unit, _ | [:years, :months, :days, :minutes, :seconds].index(unit)}.
    # [[:years, 1], [:months, 2], [:days, 3]]
    map     {|unit,val| "#{val} #{val == 1 ? unit.to_s.chop : unit.to_s}"}.
    # ["1 year", "2 months", "3 days"]
    to_sentence(:locale => :en)
    # "1 year, 2 months and 3 days"
end
  • shorter series of small transformations

true story

summary (data pipelines)

Enumerable.public_instance_methods.sort - Object.new.methods
# => [:all?, :any?, :chunk, :collect, :collect_concat, :count, :cycle, :detect, :drop,  
#     :drop_while, :each_cons, :each_entry, :each_slice, :each_with_index,
#     :each_with_object, :entries, :find, :find_all, :find_index, :first, :flat_map,
#     :grep, :group_by, :include?, :inject, :lazy, :map, :max, :max_by, :member?, 
#     :min, :min_by, :minmax, :minmax_by, :none?, :one?,:partition, :reduce, 
#     :reject, :reverse_each, :select, :slice_before, :sort, :sort_by, :take,
#     :take_while, :to_a, :zip]
  • Enumerable provides a comprehensive language for data transformations
    • learn it!
  • compose pipelines of small transformations to build larger ones
    • output of one transformation is input to the next
  • creates ubiquitous language (even across languages)

tests

expectations / no test names

; using https://github.com/jaycfields/expectations

(expect "John Doe" (full-name (make-person "John" "Doe")))
  • puts pressure on design
  • works very well when design pressure is heeded
  • harder to understand when design pressure is not heeded
    • confusing tests get commented

RSpec / test names as input and output

describe Person do
  describe "#full_name" do
    it "concats first and last names" do
# ...
rspec --format documentation

Person
  #full_name
    concats first and last names
    handles blank first name gracefully
    handles blank last name gracefully
    raises when missing both first and last name
  • easily scan names
  • tells a story
  • exposes missing and duplicate examples

expectations/assertions

describe Person do
  it "concats first and last names to provide full_name" do
    person = Person.new("John", "Doe")

    # rspec-expectations with should
    person.full_name.should eq "John Doe"

    # rspec-expectations with expect
    expect(person.full_name).to eq "John Doe"

    # minitest/test
    assert_equal "John Doe", person.full_name

    # minitest/spec
    person.full_name.must_equal "John Doe"
  end
end
  • lots of monkey patching and methods to learn
  • lots of debates about words (should, must, expect, etc)

wrong!

expectations with wrong

describe Person do
  it "concats first and last names to provide full_name" do
    person = Person.new("John", "Doe")
    assert { person.full_name == "John Doe" }
  end
end
  • no monkey patching
  • one method: assert
  • no confusion over order of expected and actual
  • provides feedback by eval'ing block content on failure

"natural assertions" with rspec-given

describe Person do
  describe "#full_name" do
    Given(:person) { Person.new("John", "Doe") }
    Then { person.full_name == "John Doe" }
  end
end
  • G/W/T syntax
  • provides good failure messages by parsing block content on failure (using ripper)

right direction, but ...

rspec-given with many examples

describe Person do
  describe "#full_name" do
    context "with first and last name supplied" do
      Given(:person) { Person.new("John", "Doe") }
      Then { person.full_name == "John Doe" }
    end

    context "with nil first name" do
      Given(:person) { Person.new(nil, "Doe") }
      Then { person.full_name == "Doe" }
    end

    context "with blank first name" do
      Given(:person) { Person.new("", "Doe") }
      Then { person.full_name == "Doe" }
    end

    context "with a different kind of blank first name" do
      Given(:person) { Person.new(" ", "Doe") }
      Then { person.full_name == "Doe" }
    end

    context "with nil last name" do
      Given(:person) { Person.new("John", nil) }
      Then { person.full_name == "John" }
    end

  end
end

rspec + wrong with many examples

describe "Person#full_name" do
  it "concats first and last names" do
    assert { Person.new("John", "Doe").full_name == "John Doe" }
  end

  it "handles nils and blanks gracefully" do
    assert { Person.new(nil, "Doe").full_name == "Doe" }
    assert { Person.new("",  "Doe").full_name == "Doe" }
    assert { Person.new(" ", "Doe").full_name == "Doe" }
    assert { Person.new("John", nil).full_name == "John" }
    assert { Person.new("John", "" ).full_name == "John" }
    assert { Person.new("John", " ").full_name == "John" }
  end
end
  • readable input and output
  • violates one expectation per example

better?

describe "Person#full_name" do
  it "concats first and last names" do
    assert { Person.new("John", "Doe").full_name == "John Doe" }
  end

  it "handles nil or blank first_name gracefully" do
    assert { Person.new(nil, "Doe").full_name == "Doe" }
    assert { Person.new("",  "Doe").full_name == "Doe" }
    assert { Person.new(" ", "Doe").full_name == "Doe" }
  end

  it "handles nil or blank last_name gracefully" do
    assert { Person.new("John", nil).full_name == "John" }
    assert { Person.new("John", "" ).full_name == "John" }
    assert { Person.new("John", " ").full_name == "John" }
  end
end

better?

describe "Person#full_name" do
  it "concats first and last names" do
    assert { Person.new("John", "Doe").full_name == "John Doe" }
  end

  it "handles nils gracefully" do
    assert { Person.new(nil, "Doe").full_name == "Doe" }
    assert { Person.new("John", nil).full_name == "John" }
  end

  it "handles blanks gracefully" do
    assert { Person.new("",  "Doe").full_name == "Doe" }
    assert { Person.new(" ", "Doe").full_name == "Doe" }
    assert { Person.new("John", "" ).full_name == "John" }
    assert { Person.new("John", " ").full_name == "John" }
  end
end

better?

describe "Person#full_name" do
  it "concats first and last names" do
    assert { Person.new("John", "Doe").full_name == "John Doe" }
  end

  it "handles nil first_name gracefully" do
    assert { Person.new(nil, "Doe").full_name == "Doe" }
  end

  it "handles nil last_name gracefully" do
    assert { Person.new("John", nil).full_name == "John" }
  end

  it "handles blank first_name gracefully" do
    assert { Person.new("",  "Doe").full_name == "Doe" }
    assert { Person.new(" ", "Doe").full_name == "Doe" }
  do

  it "handles blank last_name gracefully" do
    assert { Person.new("John", "" ).full_name == "John" }
    assert { Person.new("John", " ").full_name == "John" }
  end
end

we've complected
specification
with
description

decouple descriptions from examples

# DOES NOT EXIST YET. SOMEBODY PLEASE MAKE IT SO!

describe "Person#full_name" do
  it "concats first and last names" do
    example { Person.new("John", "Doe").full_name == "John Doe" }
  end

  it "handles nils and blanks gracefully" do              # description: general
    example { Person.new(nil, "Doe").full_name == "Doe" }
    example { Person.new("",  "Doe").full_name == "Doe" } # examples: specific
    example { Person.new(" ", "Doe").full_name == "Doe" }
    example { Person.new("John", nil).full_name == "John" }
    example { Person.new("John", "" ).full_name == "John" }
    example { Person.new("John", " ").full_name == "John" }
  end
end
Person#full_name
  concats first and last names
  handles nils and blanks gracefully

decouple descriptions from examples

Person#full_name
  concats first and last names
    example at ./person_spec.rb:6 (FAILED - 1)
  handles nils and blanks gracefully

Failures:

  1) Person#full_name
     Failure/Error: example { assert { person.full_name == "John Doe" } }
       Expected (person.full_name == "John Doe"), but
           person.full_name is "JohnDoe"
     # ./person_spec.rb:6:in `block (3 levels) in <top (required)>'

summary (testing)

  • test names add value
    • tradeoff: they're also names, and require thought and maintenance
  • expectation DSLs require monkey patching and learning a bunch of methods without providing much value in return
    • use them if you like them, but please, let's stop building new ones
    • this does not include test double frameworks (stubs, mocks, etc)
  • homework: decouple descriptions from examples

conclusion

Ruby is a DSL
for the domain of
general programming tasks

readable && simple

if you're writing Ruby and wish to choose simplicity ...

choose Ruby


Thank you!