Skip to content

Instantly share code, notes, and snippets.

@rranelli
Created May 25, 2021 20:40
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rranelli/430ddbb2b682f20b3fd2d981e4786f3d to your computer and use it in GitHub Desktop.
Save rranelli/430ddbb2b682f20b3fd2d981e4786f3d to your computer and use it in GitHub Desktop.

Writing extensible Elixir

whoami

  • Milhouse (Renan Ranelli)
  • Currently @ Podium.com
  • Working with Elixir since 2015
  • Organizes ElugSP

Introduction

  • We already have “lots” of people working with functional programming …
  • … however, we still did not have enough time to consolidate “best practices” [citation needed]
  • We will show one specific and not-so-trivial example and a naive non-extensible solution to it. After that, we will try to fix it
  • This talk is about code. Its about stuff we do in the trenches. (and because of that, its not really “introduction” material)

Agenda

  • The Why
  • Names & Verbs
  • Protocols
  • Data abstraction
  • SOLID
  • Conclusion

Why

  • We want our software/system to have certain properties in order to make our lives as developers less miserable
  • We want:
    • Comprehensibility (legibility++)
    • Flexibility
    • ((((*((Extensibility))))*))
    • Production Stability
    • Google-ability
    • Scalability
    • **-ity
  • And of course, you can’t have it all. You gotta choose your trade-off.

Extensibility

> Extensible, or modular code, is code that can be modified, > interacted with, added to, or manipulated … all > without ever modifying the core code base.

  • A good trick is to think about code that is easy to delete instead of code that is easy to write
  • That is: code that hurts you less if it is wrong. (and surprise, the code will eventually be wrong)

Names & Verbs

  • OO e FP are not that different.
  • The main difference resides in how you organize programs
    • In OO’s case, we do it around names
    • In FP’s case, we do it around verbs
  • Small objects gravitate towards closures; Rich closures gravitate towards objects;
  • (Steve Yegge has an awesome & classic text on this. Google up: “execution in the kingdom of nouns”)
  • Well take an example in simple “geometry” to base our discussion.

Example: Points, Lines and such.

  • Lets take a very simple plane geometry system:
    • Three entities: Lines (infinite), Points and _”Nothing”_.
    • One operation : Intersection
“Functional” Code
defmodule Geometry do
  defmodule Point, do: (defstruct [:x, :y])
  defmodule Line, do: (defstruct [:a, :b]) # y = ax + b
  defmodule Nothing, do: (defstruct [])

  # ordering *is* important
  def intersection(thing, ^thing),
    do: thing

  def intersection(p = %Point{}, _oto_p = %Point{}),
    do: %Nothing{}

  def intersection(p = %Point{}, l = %Line{}) do
    if (p.y == (l.a * p.x) + l.b) do
      p
    else
      %Nothing{}
    end
  end

  def intersection(%Line{a: a}, %Line{a: a}),
    do: %Nothing{}

  def intersection(l = %Line{}, l2 = %Line{}) do
    # You can ignore the next two lines of math
    x_intersection = (l.b - l2.b) / (l.a - l2.a)
    y_intersection = (l2.a * x_intersection) + l2.b
    %Point{x: x_intersection, y: y_intersection}
  end

  def intersection(%Nothing{}, _other),
    do: %Nothing{}

  # trick: intersection is commutative! (a <> b = b <> a)
  def intersection(right, left),
    do: intersection(left, right)
end
  
“Object Oriented” Code
class Nothing
  def intersection(_other_shape)
    Nothing.new
  end
end

class Line
  # y=ax+b
  def initialize(a, b)
    @a, @b = a, b
  end
  attr_reader :a, :b

  def intersection(other_shape)
    # This technique is called *double dispatch*
    other_shape.intersect_with_line(self)
  end

  def intersect_with_line(other_line)
    if other_line.a == self.a && other_line.b == self.b then
      self
    else
      Nothing.new
    end
  end

  def intersection_with_point(other_point)
    if other_point.y == self.a * other_point.x + self.b then
      other_point
    else
      Nothing.new
    end
  end
end

class Point
  def initialize(x, y)
    @x, @y = x, y
  end
  attr_reader :x, :y

  def intersection(other_shape)
    other_shape.intersection_with_point(self)
  end

  def intersection_with_point(other_point)
    if other_point.x == self.x && other_point.y == self.y then
      self
    else
      Nothing.new
    end
  end

  def intersect_with_line(other_line)
    other_line.intersection_with_point(self)
  end
end
  
Surprise! A new feature request!
  • Now we need line segments.
Functional Code
defmodule Geometry do
  # ...
  defmodule Segment, do: (defstruct [:a, :b, :xmin, :xmax])

  # ...
  def intersection(p = %Point{}, s = %Segment{}) do
    in_x? = (p.x >= s.xmin && p.x <= s.xmax)
    in_line? = (p.y == (s.a * p.x) + s.b)
    if in_x? && in_line? do
      p
    else
      %Nothing{}
    end
  end

  def intersection(l = %Line{a: a, b: b}, s = %Segment{a: a, b: b}),
    do: s

  def intersection(l = %Line{a: a, b: b}, s = %Segment{a: a, b: _b}),
     do: %Nothing{}

  def intersection(l = %Line{}, s = %Segment{}) do
    # ra*x + rb = sa*x + sb => x = (sb - rb) / (ra-sa)
    x_intersection = (s.b - l.b) / (l.a - s.a)
    if x_intersection >= s.xmin && x_intersection <= s.xmax do
      y_intersection = (s.a * x_intersection) + s.b
      %Point{x: x_intersection, y: y_intersection}
    else
      %Nothing{}
    end
  end
  # ...
end
  
“Object Oriented” Code
class Nothing
end

class Line
  def intersection_with_segment(segment)
    # ...
  end
end

class Point
  def intersection_with_segment(segment)
    # ...
  end
end

class Segment
  def intersection(other_shape)
    other_shape.intersection_with_segment(self)
  end

  def intersection_with_point(other_point)
    # ...
  end

  def intersect_with_line(other_line)
    # ...
  end
end
  
Surprise[2]! A new feature!
  • Now we need to compute the “length” of the geometric shape
“Functional” code
defmodule Geometry do
  # ...
  def length(%Point{}), do: 0
  def length(%Nothing{}), do: 0
  def length(%Line{}), do: 1_000_000
  def length(s = %Segment{}) do
    adjacent_catet =
      (s.xmax - s.xmin)
    opposed_catet =
      s.a * (s.xmax - s.xmin)
    hypotenuse =
      :math.sqrt(:math.pow(adjacent_catet, 2) +
        :math.pow(opposed_catet, 2))

    hypotenuse
  end
  # ...
end
  
“Object Oriented” Code
class Nothing
  def length
    0
  end
end

class Line
  def length
    1_000_000 # because 1 million is big enough
  end
end

class Point
  def length
    0
  end
end

class Segment
  def length
    # ...
  end
end
  

What can we see?

  • When adding a new operation (that is, a verb)
    • FP approach needs to add a single function
    • OO approach needs to change all objects
  • When adding a new entity (that is, a name)
    • FP approach needs to change all functions
    • OO approach needs to add a single object

Our functional code until now…

  • If you have control over both data & behaviour, than this is not gonna hurt you that much (yet)
  • Now, consider that this code is to be part of a library
  • The FP version is not extensible. The OO version is.

Protocols

  • Is a solution to the problem we described.
  • It is a way to extend the behaviour of a function without touching its definition, based on the type of its first argument
  • Its basically “dynamic dispatch one level deep”
Simple example
# "escopo/biblioteca/application/outro time cuida"
defmodule X, do: (defstruct [:x])
defmodule Y, do: (defstruct [y: 1])

defprotocol StarsEncoder do
  def encode(data)
end
# ...

defimpl StarsEncoder, for: Z do
  def encode(x), do: " * "
end

defimpl StarsEncoder, for: X do
  def encode(x), do: " * "
end

defimpl StarsEncoder, for: Y do
  def encode(y) do
    stars = Range.new(0, y.y) |> Enum.map(fn _ -> "*" end) |> Enum.join
    " #{stars} "
  end
end
Now, lets re-implement “length” using Protocols
# biblitoeca
defprotocol Measurable do
  def length(forma)
end

# these can be in completely different files
defimpl Measurable, for: Line do
  def length(_line_), do: 1_000_000
end

defimpl Measurable, for: Point do
  def length(_point), do: 0
end

defimpl Measurable, for: Nothing do
  def length(_nothing), do: 0
end

defimpl Measurable, for: Segment do
  def length(segment) do
    adjacent_catet = (s.xmax - s.xmin)
    opposed_catet = s.a * (s.xmax - s.xmin)
    hypotenuse =
      :math.sqrt(:math.pow(adjacent_catet, 2) + :math.pow(opposed_catet, 2))
    hypotenuse
  end
end
And now implementing “Intersection”
defprotocol Intersectable do
  def intersection(forma, other_shape)
end

defimpl Intersectable, for: Nothing do
  def intersection(%Nothing{}, _other), do: %Nothing{}
end

defimpl Intersectable, for: Point do
  def intersection(p, p), do: p
  def intersection(p, _oto), do: %Nothing{}
  # cachorragem
  def intersection(left, right),
    do: Intersectable.intersection(right, left)
end

defimpl Intersectable, for: Line do
  def intersection(l, l), do: l
  def intersection(%Line{a: a}, %Line{a: a}), do: %Nothing{}
  def intersection(l, l2 = %Line{}) do
    x_intersection = (l.b - l2.b) / (l.a - l2.a)
    y_intersection = (l2.a * x_intersection) + l2.b
    %Point{x: x_intersection, y: y_intersection}
  end
  def intersection(l, p = %Point{}) do
    if (p.y == (l.a * p.x) + l.b) do
      p
    else
      %Nothing{}
    end
  end
  # trick
  def intersection(left, right),
    do: Intersectable.intersection(right, left)
end

defimpl Intersectable, for: Segment do
  def intersection(s = %Segment{a: a, b: b}, l = %Line{a: a, b: b}), do: s
  def intersection(s = %Segment{a: a}, l = %Line{a: a}), do: %Nothing{}
  def intersection(s = %Segment{}, l = %Line{}) do
    # ra*x + rb = sa*x + sb => x = (sb - rb) / (ra-sa)
    x_intersection = (s.b - l.b) / (l.a - s.a)
    if x_intersection >= s.xmin && x_intersection <= s.xmax do
      y_intersection = (s.a * x_intersection) + s.b
      %Point{x: x_intersection, y: y_intersection}
    else
      %Nothing{}
    end
  end
  def intersection(s = %Segment{}, p = %Point{}) do
    in_x? = (p.x >= s.xmin && p.x <= s.xmax)
    in_line? = (p.y == (s.a * p.x) + s.b)
    if in_x? && in_line? do
      p
    else
      %Nothing{}
    end
  end
end
Usage Example
defmodule Drawing do
  defstruct [:pieces] # what are pieces? Doesn't matter

  # The only thing we care is that they are *measurable*
  def perimeter(drawing) do
    drawing.pieces |> Enum.map(&Measurable.length/1) |> Enum.sum
  end
end

defmodule Poligon do
  defstruct [:segments]

  def new(segments) do
    assert Enum.all?(segments, &match(%Segment{}, &1))
    %Poligon{segments: segments}
  end

  def perimeter(poligon) do
    %Drawing{pieces: poligon.segments} |> Drawing.perimeter
  end
end

Data abstraction

  • As you can see, our new solution is naturally prepared to deal with new data types it doesn’t know. (e.g. the commutative trick)
  • It is possible to extend existing behaviour with new data. And it is trivial to create new data types that are extensible with new behaviours. Its a win-win situation

    (Ruby solves this with “open classes & duck typing”. A much less elegant solution)

  • Our software is now more abstract, and relies less on concrete implementations. (Program towards interfaces)
  • We just recovered one of the main benefits of OO back into our FP code.

    **IT IS IMPORTANT TO UNDERSTAND WHY THINGS ARE THE WAY THEY ARE**

SOLID

> wat. isn’t SOLID an OO thing?

  • Nah.
    • [S]ingle responsibility principle (Basic higiene)
    • [O]pen closed principle (Protocols)
    • [L]iskov substitution principle (…)
    • [I]nterface segregation principle (Data abstraction)
    • [D]ependency injection principle (Composition)

Conclusion

  • Throwing in a new paradigm into your programming practice will not magically make you write good code.
  • Your knowledge in X can be useful in Y. First principles first.
  • Parsimony. If you use protocols for absolutely everything, your program will end up as a bad replica of an OO solution.
  • Indirection, encapsulation, abstraction, extensibility … its all trade-offs; (Like everything else in engineering.)

Obrigado (e desculpa qualquer coisa)

Twitter: @renanranelli Github: /rranelli Blog: milhouse.dev

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment