How would I do it in Haskell?

Posted on November 26, 2020 by Riccardo

Since I started playing with functional programming, not only I've dot-chained the hell out of object-oriented code, but I often catch myself thinking: how would I do this in Haskell?

Here's a couple of examples that come to mind where I contaminated Ruby with functional intuitions.

Nested Loops and Filtering

During the Global Day Of Code Retreat, together with Joanna, we were writing a method to find the eight neighbors of a cell. In other words, given [ 1, 1 ] as an input we wanted to generate the following output:

[ [ 0, 0 ], [ 0, 1 ], [ 0, 2 ]
  [ 1, 0 ],         , [ 1, 2 ]
  [ 2, 0 ], [ 2, 1 ], [ 2, 2 ]
]

We started with a simple implementation:

def neighbors(cell)
  [ -1, 0, 1 ].map do |y|
    [ -1, 0, 1 ].map do |x|
       [ cell.first + y, cell.last + x ]
    end
  end
end

# [1, 1]
# [[[0, 0], [0, 1], [0, 2]], [[1, 0], [1, 1], [1, 2]], [[2, 0], [2, 1], [2, 2]]]

To remove the double nesting in the output, we reached for flatten:

def neighbors(cell)
  [ -1, 0, 1 ].map do |y|
    [ -1, 0, 1 ].map do |x|
       [ cell.first + y, cell.last + x ]
    end
  end.flatten # <=
end

# [1, 1]
# [0, 0, 0, 1, 0, 2, 1, 0, 1, 1, 1, 2, 2, 0, 2, 1, 2, 2]

Oops, that was one flatten too much. We tried again with flat_map:

def neighbors(cell)
  [ -1, 0, 1 ].flat_map do |y| # <=
    [ -1, 0, 1 ].map do |x|
       [ cell.first + y, cell.last + x ]
    end
  end
end

# [1, 1]
# [[0, 0], [0, 1], [0, 2], [1, 0], [1, 1], [1, 2], [2, 0], [2, 1], [2, 2]]

Yay! The last piece was to remove the current cell from the output:

def neighbors(cell)
  [ -1, 0, 1 ].flat_map do |y|
    [ -1, 0, 1 ].map do |x|
       [ cell.first + y, cell.last + x ]
    end
  end.reject { |neighbor| neighbor == cell } # <=
end

# [1, 1]
# [[0, 0], [0, 1], [0, 2], [1, 0], [1, 2], [2, 0], [2, 1], [2, 2]]

There was something inelegant about that code, though. To hell with the exercise, we wasted the rest of the session refactoring the method.

The nested loop looked ugly. Not to count we were generating an invalid neighbor, only to filter it out later. Here's what we came up with:

def neighbors(cell)
  [ -1, 0, 1 ]
    .repeated_permutation(2)
    .reject { |permutation| permutation == [ 0, 0 ] }
    .map { |y, x| [ cell.first + y, cell.last + x ] }
end

# [1, 1]
# [[0, 0], [0, 1], [0, 2], [1, 0], [1, 2], [2, 0], [2, 1], [2, 2]]

A Semigroup in the Wild

A few days ago, I stumbled upon the following code in a production codebase:

def group_by_product(items_grouped_by_template)
  items_grouped_by_template.select { |item| item.fetch(:variation_type) == 'quantity' }
                           .group_by { |item| item[:product_id] }
                           .each_with_object([]) do |(_product_id, order_items), array|
    array << item_grouped_by_product(order_items)
  end
end

def items_grouped_by_template
  @order_items_grouped_by_template.each_with_object([]) do |(template, order_items), array|
    array << item_grouped_by_template(template, order_items)
  end
end

def item_grouped_by_template(template, order_items)
   {
     id: template.id,
     variation_type: template.product_template.template_type,
     variation_name: template.product_template.name,
     product_id: template.product.id,
     product_name: template.product.name,
     total_quantity: product_template_total_quantity(template, order_items),
     reporting_category: reporting_category(template),
   }
end

def item_grouped_by_product(order_items)
   {
     id: order_items.first.fetch(:id),
     variation_type: order_items.first.fetch(:variation_type),
     product_id: order_items.first.fetch(:product_id),
     product_name: order_items.first.fetch(:product_name),
     total_quantity: product_total_quantity(order_items),
     reporting_category: order_items.first.fetch(:reporting_category),
   }
end

If you don't understand it, don't worry, I'm there with you. I speculate the author didn't understand it, either.

However, after massaging the code for a while, I noticed one thing: there's a hidden data structure that gets combined with itself: I found a semigroup!

I created a Summary that can be concated with another one:

Summary = Struct.new(
  :id,
  :variation_type,
  :variation_name,
  :product_id,
  :product_name,
  :total_quantity,
  :category,
  keyword_init: true
) do
  def concat(other)
    Summary.new(
      id: id,
      variation_type: variation_type,
      variation_name: "#{variation_name} + #{other.variation_name}",
      product_id: product_id,
      product_name: product_name,
      total_quantity: [self, other].map(&:total_quantity).reduce(:+),
      category: category
    )
  end
end

With that in place, I could refactor to something similar to what follows:

product_product_templates
  .map { |product_product_template| to_summary(product_product_template) }
  .reduce { |acc, summary| acc.concat(summary) }

def to_summary(product_product_template)
  Summary.new(
    id: product_product_template.id,
    variation_type: product_product_template.product_template.template_type,
    variation_name: product_product_template.product_template.name,
    product_id: product_product_template.product.id,
    product_name: product_product_template.product.name,
    total_quantity: product_template_total_quantity(product_product_template),
    category: category(product_product_template)
  )
end

Still ugly, but at least I can reason about it.

PinkLetter

It's one of the selected few I follow every week – Mateusz

Tired of RELEARNING webdev stuff?

  • A 100+ page book with the best links I curated over the years
  • An email once a week full of timeless software wisdom
  • Your recommended weekly dose of pink
  • Try before you buy? Check the archives.