Skip to content

CargoSense/compare_chain

Repository files navigation

CompareChain

Description

Provides convenience macros for comparisons which do:

  • chained comparisons like a < b < c
  • semantic comparisons using the structural operators: <, >, <=, >=, ==, !=, ===, and !==
  • combinations using and, or, and not

Examples

iex> import CompareChain

# Chained comparisons
iex> compare?(1 < 2 < 3)
true

# Semantic comparisons
iex> compare?(~D[2017-03-31] < ~D[2017-04-01], Date)
true

# Chained, semantic comparisons
iex> compare?(~D[2017-03-31] < ~D[2017-04-01] < ~D[2017-04-02], Date)
true

# Semantic comparisons with logical operators
iex> compare?(~T[16:00:00] <= ~T[16:00:00] and not (~T[17:00:00] <= ~T[17:00:00]), Time)
false

# More complex expressions
iex> compare?(%{a: ~T[16:00:00]}.a <= ~T[17:00:00], Time)
true

Installation

Add compare_chain to your list of dependencies in mix.exs:

def deps do
  [
    {:compare_chain, "~> 0.5"}
  ]
end

Documentation can be found at https://hexdocs.pm/compare_chain.

Usage

Once installed, you can add:

import CompareChain

to your defmodule and you will have access to CompareChain.compare?/1 and CompareChain.compare?/2.

Background and motivation

CompareChain was originally motivated by the following situation:

You have an interval of time bounded by a two %Date{} structs: start_date and end_date. You want to know if some third date falls in that interval. How do you write this?

In Elixir, we'd write this as follows:

Date.compare(start_date, date) == :lt and
  Date.compare(date, end_date) == :lt

This is verbose and therefore a little hard to read. It's also potentially incorrect. What if date is considered "within" the interval even if it equals start_date or end_date? To include the bounds in our comparison, we'd instead write the expression like this:

Date.compare(start_date, date) != :gt and
  Date.compare(date, end_date) != :gt

(We could have written Date.compare(start_date, date) in [:lt, :eq], but != :gt is faster.)

In order to spot the difference between these two cases, you have to keep several things in mind:

  • The order of the arguments passed to Date.compare/2
  • The specific comparison operators for each clause: == vs. !=
  • The specific comparison atoms for each clause: :lt vs. :gt

Since this is hard to read, it's easy to introduce bugs. Contrast this with how you'd write the equivalent code in Python:

start_date <  date <  end_date # excluding bounds
start_date <= date <= end_date # including bounds

This is much easier to read. So why can't we write this in Elixir? Two reasons:

  • Structural comparison operators
  • Chained vs. nested comparisons

Structural comparison operators

Operators like < do structural comparison instead of semantic comparison. From the Kernel docs:

... comparisons in Elixir are structural, as it has the goal of comparing data types as efficiently as possible to create flexible and performant data structures. This distinction is specially important for functions that provide ordering, such as >/2, </2, >=/2, <=/2, min/2, and max/2. For example:

~D[2017-03-31] > ~D[2017-04-01]

will return true because structural comparison compares the :day field before :month or :year. In order to perform semantic comparisons, the relevant data-types provide a compare/2 function, such as Date.compare/2:

iex> Date.compare(~D[2017-03-31], ~D[2017-04-01])
:lt

In other words, although ~D[2017-03-31] > ~D[2017-04-01] is perfectly valid code, it does not tell you if ~D[2017-03-31] is a later date than ~D[2017-04-01] like you might expect. Instead, you need to use Date.compare/2.

Chained vs. nested comparisons

Additionally, even if ~D[2017-03-31] > ~D[2017-04-01] did do semantic comparison, you still couldn't write the interval check like you do in Python. This is because in Python, an expression like 1 < 2 < 3 is syntactic sugar for (1 < 2) and (2 < 3), aka a series of "chained" expressions.

Elixir does not provide an equivalent syntactic sugar. Instead, 1 < 2 < 3 is evaluated as (1 < 2) < 3, aka a series of "nested" expressions. Since (1 < 2) < 3 simplifies to true < 3, that's probably not what you want!

Elixir will even warn you when you attempt an expression like that:

warning: Elixir does not support nested comparisons. Something like

 x < y < z

is equivalent to

 (x < y) < z

which ultimately compares z with the boolean result of (x < y). Instead, consider joining together each comparison segment with an "and", for example,

 x < y and y < z

CompareChain

CompareChain attempts to address both of these issues with the macro CompareChain.compare?/2. Its job is to take code similar to how you'd like to write it and rewriting it to be semantically correct.

For our motivating example, we'd write this:

import CompareChain
compare?(start_date <  date <  end_date, Date) # excluding bounds
compare?(start_date <= date <= end_date, Date) # including bounds

And at compile time, CompareChain.compare?/2 rewrites those to be:

# excluding bounds
Date.compare(start_date, date) == :lt and
  Date.compare(date, end_date) == :lt

# including bounds
Date.compare(start_date, date) != :gt and
  Date.compare(date, end_date) != :gt

This way your code is more readable while still remaining correct.

CompareChain.compare?/1 is also available in case you only need chained comparison using the structural operators:

compare?(1 < 2 < 3)

Though I find this case comes up less often.

One last selling point

As a happy accident, CompareChain.compare?/2 always uses fewer characters than its compare/2 counterpart:

compare?(a <= b, Date)
# vs.
Date.compare(a, b) != :gt

(Assuming you've already included import CompareChain, of course!)

Because it's shorter and more readable, these days I always use CompareChain for any semantic comparison, chained or not.