В Эликсире много возможностей для эффективного написания кода. Новички, осваивая язык, проникаются его различными возможностями и начинают их применять повсеместно.
В этой статье речь пойдет про перегрузку функций с паттернматчингом аргументов
Эликсир позволяет осуществлять перегрузку функций, как, например, 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
Я не просто так выделил фразу “разные типы данных”, ведь в первую очередь перегрузка функций появилась в типизированных языках и используется при передаче аргументов разных типов или разном количестве аргументов.
Это нормальные случаи использования перегрузки функции и паттернматчинга.
Теперь я хочу показать несколько примеров из реальных проектов, в которых паттернматчинг и перегрузка функций использовались во зло.
Модуль 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
В таком коде есть пара проблем:
- Между определениями функций нет пустой строки — всё слилось в однонепонятночто ;)
- При необходимости расширения придется добавлять копипасту функции;
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
Есть сервис 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
Все работает, проверять не будем. Перейдем к проблемам:
- Автодополнение не может подсказать, какие у нас есть реализации подсчета площади. Так же отображается 2 функции, вместо трех, из-за различного количества аргументов. Если бы у всех трех функций была бы одинаковая арность — отобразилась бы 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
Теперь автодополнение будет нам помогать. Мы сразу видим, площади каких фигур считает модуль и какие аргументы принимает функция.
Если подытожить, то паттернматчинг с перегрузкой функций допустим, когда:
-
Функция принимает разное количество аргументов и контекст понятен без чтения исходного кода;
-
Тип передаваемых аргументов отличается, как в примере фукнции сортировки в начале статьи;
-
Необходима реализация рекурсии.