Skip to content

Latest commit

 

History

History
256 lines (189 loc) · 12.6 KB

elixir_patternmatching.md

File metadata and controls

256 lines (189 loc) · 12.6 KB

Elixir и паттернматчинг

В Эликсире много возможностей для эффективного написания кода. Новички, осваивая язык, проникаются его различными возможностями и начинают их применять повсеместно.

В этой статье речь пойдет про перегрузку функций с паттернматчингом аргументов

Эликсир позволяет осуществлять перегрузку функций, как, например, Java. Достаточно объявить две одноименные функции в одном модуле, например, рекурсивный обход коллекции с умножением каждого элемента на 2.

defmodule Math do
  def double_each([head | tail]) do
    [head * 2 | double_each(tail)]
  end

  def double_each([]) do
    []
  end
end

Разберем еще один пример: создадим функцию, которая сортирует разные типы данных, в данном случае — list и tuple.

defmodule Utils do
  def sort(data) when is_list(data) do
    data
    |> Enum.sort()
  end

  def sort(data) when is_tuple(data) do
    data
    |> Tuple.to_list()
    |> sort()
    |> List.to_tuple()
  end
end

Я не просто так выделил фразу “разные типы данных”, ведь в первую очередь перегрузка функций появилась в типизированных языках и используется при передаче аргументов разных типов или разном количестве аргументов.

Это нормальные случаи использования перегрузки функции и паттернматчинга.

Теперь я хочу показать несколько примеров из реальных проектов, в которых паттернматчинг и перегрузка функций использовались во зло.

Пример №1

Модуль Helper, который в зависимости от контекста и переданного кода ошибки возвращает текстовое описание:

defmodule ExternalApi.ErrorHelper do
  @errors_grouped_by_action %{...}
  @unknown_error_message "unknown"

  def get_description(:email_sending, status_code) do
    @errors_grouped_by_action
    |> Map.get(:email_sending)
    |> Map.get(status_code, @unknown_error_message)
  end
  def get_description(:put_user_info, status_code) do
    @errors_grouped_by_action
    |> Map.get(:put_user_info)
    |> Map.get(status_code, @unknown_error_message)
  end
  def get_description(:get_user_info, status_code) do
    @errors_grouped_by_action
    |> Map.get(:get_user_info)
    |> Map.get(status_code, @unknown_error_message)
  end
end

В таком коде есть пара проблем:

  1. Между определениями функций нет пустой строки — всё слилось в однонепонятночто ;)
  2. При необходимости расширения придется добавлять копипасту функции;

Рефакторинг примера

defmodule ExternalApi.ErrorHelper do
  @error_status_codes %{...}
  @unknown_error_message "unknown"

  def get_description(action_name, status_code) do
    @error_status_codes
    |> Map.get(action_name)
    |> Map.get(status_code, @unknown_error_message)
  end
end

Вместо трёх функций у нас теперь одна, которая выполняет все те же функции, сосредотачивает всю логику в одном месте, выглядит просто и элегантно. Если необходимо, чтобы в случае передачи несуществующего action_name возвращалась ошибка — достаточно добавить guard, и перечислить все возможные значения, например:

def get_description(action_name, status_code) when action_name in [:email_sending, :put_user_info, :get_user_info] do
  ...
end

Пример №2

Есть сервис Cbr-xml-daily, дающий API для получения курсов валют ЦБ РФ. Соответственно все курсы валют представлены по отношению к рублю.

Пример полученных данных по курсу валют:

Формат: {Номинал} {Символ Валюты} = {Сумма в рублях}р 1 USD = 68.95р 1 EUR = 79.32р 100 JPY = 61.21р и т.п. В итоговом хеше ключ — символ валюты, значение — тоже хеш, где value — сумма в рублях, nominal — номинал

%{
    usd: %{nominal: 1, value: 68.95},
    eur: %{nominal: 1, value: 79.32},
    jpy: %{nominal: 100, value: 61.21},
    ...
}

В одном проекте есть модуль, который переводит сумму из валюты в рубли по полученному курсу.

defmodule Currency do
  # в данном примере courses - это map-структура, описанная выше.
  # courses => %{usd: %{nominal: 1, value: 68.95}, eur: %{...}}

  def exchange(_courses, amount, :rub, :rub) do
    amount
  end

  def exchange(courses, amount, from, :rub) do
    %{nominal: nominal, value: value} = courses[from]
    (amount * nominal) * value
  end

  def exchange(courses, amount, :rub, to) do
    %{nominal: nominal, value: value} = courses[to]
    (amount * nominal) / value
  end

  def exchange(courses, amount, from, to) do
    amount_in_rub = exchange(courses, amount, from, :rub)
    exchange(courses, amount_in_rub, :rub, to)
  end
end

Обилие перегруженных с паттернатчингом функций “размазывает” логику на несколько реализаций, и, честно говоря, я не понимаю, как это все вместе работает. Но из названия ясно, что функция должна выполнять, это и стало толчком к рефакторингу.

Рефакторинг примера

На самом деле упрощенный вариант не так уж и прост, вся логика находится в одной функции, но усложнился алгоритм подсчета. Для коллег такая алгебра может показаться сложной, поэтому стоит добавить комментарий, описывающий алгоритм.

defmodule Currency do

  def exchange(courses, amount, from, to) do
    {nominal_from, rub_from} = get_nominal_and_value(courses, from)
    {nominal_to, rub_to} = get_nominal_and_value(courses, to)

    # Когда получили соотношения валют к рублю,
    # надо получить их соотношения друг к другу и умножить
    # на количество исходной валюты.

    amount * (rub_from / nominal_from) / (rub_to / nominal_to)
  end
end

**Пример использования: **переведем 10 долларов США в бразильские реалы

CurrencyExchange.exchange(courses, 10, :usd, :brl)
#   => 40.20

Пример описания алгоритма: Так как все курсы валют представлены по отношению к рублю, то нам неизвестно отношение доллара к евро. Но мы можем это отношение высчитать, через рубли. Для корректной конвертации надо получить соотношение каждой валюты к рублю. Для этого надо поделить курс валюты на ее номинал. Когда получили соотношения валют к рублю, надо получить их соотношения друг к другу и умножить на количество исходной валюты.

Хочу привести еще один пример, когда не стоит использовать паттернматчинг с перегрузкой функций.

Давайте решим задачу:

Необходимо реализовать геометрический модуль, благодаря которому можно вычислять площади следующих фигур: круг, прямоугольник и квадрат.

Задача звучит просто, и вы, возможно, сразу захотите написать модуль, в котором будет одна функция с несколькими реализациями. Давайте попробуем:

defmodule Geometry do
  # Площадь круга с радиусом r равна πr2.
  def square(:cirle, radius) do
    pi = 3.14
    pi * radius * radius
  end

  # Площадь квадрата равна квадрату длины его стороны.
  def square(:square, length) do
    length * length
  end

  # Площадь прямоугольника равна произведению его длины и ширины
  def square(:rectangle, height, width) do
    height * width
  end
end

Все работает, проверять не будем. Перейдем к проблемам:

  1. Автодополнение не может подсказать, какие у нас есть реализации подсчета площади. Так же отображается 2 функции, вместо трех, из-за различного количества аргументов. Если бы у всех трех функций была бы одинаковая арность — отобразилась бы 1 функция;

  1. Проблема вытекает из первой, необходимо смотреть реализацию, чтобы понять, площади каких фигур умеет считать модуль.

Альтернативное решение

Избавимся от паттерматчинга и перегрузки функций. Название функции должно отображать суть, абстрагировать нас от реализации и помогать писать программы, чтобы мы не тратили время на изучение сторонних фукнций и как они устроены.

Поэтому мы дадим функциям явные названия, которые будут отражать суть — площадь какой фигуры находим.

defmodule Geometry do
  # Площадь круга с радиусом r равна πr2.
  def circle_area(radius) do
    pi = 3.14
    pi * radius * radius
  end

  # Площадь квадрата равна квадрату длины его стороны.
  def square_area(length) do
    length * length
  end

  # Площадь прямоугольника равна произведению его длины и ширины
  def rectangle_area(height, width) do
    height * width
  end
end

Теперь автодополнение будет нам помогать. Мы сразу видим, площади каких фигур считает модуль и какие аргументы принимает функция.

Итог:

Если подытожить, то паттернматчинг с перегрузкой функций допустим, когда:

  1. Функция принимает разное количество аргументов и контекст понятен без чтения исходного кода;

  2. Тип передаваемых аргументов отличается, как в примере фукнции сортировки в начале статьи;

  3. Необходима реализация рекурсии.