Исходный код Антипаттерны метапрограммирования
В данном документе описаны потенциальные антипаттерны, связанные с метапрограммированием.
Зависимости во время компиляции
Проблема
Этот антипаттерн связан с зависимостями между файлами в Elixir. Поскольку макросы используются во время компиляции, использование любого макроса в Elixir добавляет зависимость во время компиляции к модулю, который определяет макрос.
Однако, когда макросы используются в теле модуля, аргументы самих макросов могут стать зависимостями во время компиляции. Эти зависимости могут привести к графам зависимостей, где изменение одного файла приводит к перекомпиляции нескольких файлов.
Пример
Давайте рассмотрим библиотеку Plug в качестве примера. Проект Plug позволяет вам указать несколько модулей, также известных как плагины, которые будут вызываться всякий раз, когда есть запрос. Как пользователь Plug, вы будете использовать его следующим образом:
defmodule MyApp do use Plug.Builder plug MyApp.Authentication end
И предположим, что Plug имеет следующие определения макросов выше (упрощенные):
defmodule Plug.Builder do
defmacro __using__(_opts) do
quote do
Module.register_attribute(__MODULE__, :plugs, accumulate: true)
@before_compile Plug.Builder
end
end
defmacro plug(mod) do
quote do
@plugs unquote(mod)
end
end
...
end
Реализация накапливает все модули внутри атрибута модуля @plugs. Непосредственно перед компиляцией модуля Plug.Builder прочитает все модули, сохраненные в @plugs, и скомпилирует их в функцию, подобно этому:
def call(conn, _opts) do MyApp.Authentication.call(conn) end
Проблема с приведенным выше кодом заключается в том, что, поскольку plug MyApp.Authentication был вызван во время компиляции, модуль MyApp.Authentication теперь является зависимостью во время компиляции для MyApp, даже если MyApp.Authentication никогда не используется во время компиляции. Если MyApp.Authentication зависит от других модулей, даже во время выполнения, это может привести к большому графу перекомпиляции в случае изменений.
Рефакторинг
Для решения этой проблемы макрос может раскрывать литералы в контексте, в котором они должны использоваться, как показано ниже:
defmacro plug(mod) do
mod = Macro.expand_literals(mod, %{__CALLER__ | function: {:call, 2}})
quote do
@plugs unquote(mod)
end
end
В приведенном выше примере, поскольку mod используется только внутри функции call/2, мы преждевременно раскрываем ссылку на модуль, как если бы она находилась внутри функции call/2. Теперь MyApp.Authentication является только зависимостью во время выполнения для MyApp, а не во время компиляции.
Однако это необходимо делать только в том случае, если ваши макросы не пытаются вызвать какие-либо функции, получить доступ к каким-либо структурам или любой другой метаданной модуля во время компиляции. Если вы взаимодействуете с модулем, переданным макросу, где угодно, за пределами определения функции, тогда у вас есть зависимость во время компиляции. И, хотя вы обычно хотите их избегать, это не всегда возможно.
В реальных проектах разработчики могут использовать mix xref trace path/to/file.ex для выполнения файла и получения информации о том, от каких модулей он зависит, и являются ли эти модули зависимостями во время компиляции, выполнения или экспорта. Для получения дополнительной информации см. mix xref.
Генерация большого объема кода
Проблема
Этот антипаттерн связан с макросами, которые генерируют слишком много кода. Когда макрос генерирует большое количество кода, это влияет на работу компилятора и/или среды выполнения. Причина в том, что Elixir может иметь дело с многократным раскрытием, компиляцией и выполнением кода, что замедляет компиляцию и увеличивает размер скомпилированных артефактов.
Пример
Представьте, что вы определяете маршрутизатор для веб-приложения, где могут быть макросы, такие как get/2. При каждом вызове макроса (который может составлять сотни) код внутри get/2 будет раскрыт и скомпилирован, что в целом может привести к большому объему кода.
defmodule Routes do
defmacro get(route, handler) do
quote do
route = unquote(route)
handler = unquote(handler)
if not is_binary(route) do
raise ArgumentError, "route must be a binary"
end
if not is_atom(handler) do
raise ArgumentError, "handler must be a module"
end
@store_route_for_compilation {route, handler}
end
end
end
Рефакторинг
Для устранения этого антипаттерна разработчик должен упростить макрос, делегировав часть его работы другим функциям. Как показано ниже, путем инкапсуляции кода внутри quote/1 внутри функции __define__/3 вместо этого, мы уменьшаем код, который раскрывается и компилируется при каждом вызове макроса, и вместо этого мы передаем его в функцию для выполнения основной работы.
defmodule Routes do
defmacro get(route, handler) do
quote do
Routes.__define__(__MODULE__, unquote(route), unquote(handler))
end
end
def __define__(module, route, handler) do
if not is_binary(route) do
raise ArgumentError, "route must be a binary"
end
if not is_atom(handler) do
raise ArgumentError, "handler must be a module"
end
Module.put_attribute(module, :store_route_for_compilation, {route, handler})
end
end
Излишние макросы
Проблема
Макросы — это мощные механизмы метапрограммирования, которые могут использоваться в Elixir для расширения языка. Хотя использование макросов само по себе не является антипаттерном, этот механизм метапрограммирования следует использовать только в том случае, если это абсолютно необходимо. Всякий раз, когда макрос используется, но ту же задачу можно было бы решить с помощью функций или других имеющихся структур Elixir, код становится излишне сложным и менее читабельным. Поскольку макросы сложнее реализовать и понять, их неконтролируемое использование может нанести ущерб развитию системы, уменьшив ее поддерживаемость.
Пример
Модуль MyMath реализует макрос sum/2 для выполнения суммы двух чисел, полученных в качестве параметров. Хотя у этого кода нет синтаксических ошибок и он может быть выполнен корректно для получения желаемого результата, он излишне сложен. Реализовав эту функциональность как макрос вместо обычной функции, код стал менее понятным:
defmodule MyMath do
defmacro sum(v1, v2) do
quote do
unquote(v1) + unquote(v2)
end
end
end
iex> require MyMath MyMath iex> MyMath.sum(3, 5) 8 iex> MyMath.sum(3 + 1, 5 + 6) 15
Рефакторинг
Для устранения этого антипаттерна разработчик должен заменить необоснованный макрос структурами, которые проще писать и понимать, например, именованными функциями. Приведенный ниже код является результатом рефакторинга предыдущего примера. В основном, макрос sum/2 был преобразован в обычную именованную функцию. Обратите внимание, что вызов require/2 больше не нужен:
defmodule MyMath do
def sum(v1, v2) do # <= The macro became a named function
v1 + v2
end
end
iex> MyMath.sum(3, 5) 8 iex> MyMath.sum(3+1, 5+6) 15
Использование вместо импорта
Проблема
Elixir имеет механизмы, такие как import/1, alias/1, и use/1 для установления зависимостей между модулями. Реализованный с этими механизмами код сам по себе не характеризует проблему. Однако, в то время как директивы import/1 и alias/1 имеют лексический охват и просто позволяют одному модулю вызывать функции другого, директива use/1 имеет более широкий охват, что может быть проблематично.
Директива use/1 позволяет модулю вставлять любой тип кода в другой, включая распространение зависимостей. Таким образом, использование директивы use/1 затрудняет чтение кода, поскольку для понимания того, что произойдет при ссылке на модуль, необходимо знать внутренние детали ссылаемого модуля.
Пример
Приведенный ниже код является примером этого антипаттерна. Он определяет три модуля — ModuleA, Library, и ClientApp. ClientApp использует код из Library с помощью директивы use/1, но не знает его внутренних деталей. Это затрудняет автору ClientApp представить, какие модули и функции теперь доступны в его модуле. Что еще хуже, Library также импортирует ModuleA, которое определяет функцию foo/0, конфликтующую с локальной функцией, определенной в ClientApp:
defmodule ModuleA do
def foo do
"From Module A"
end
end
defmodule Library do
defmacro __using__(_opts) do
quote do
import Library
import ModuleA # <= propagating dependencies!
end
end
def from_lib do
"From Library"
end
end
defmodule ClientApp do
use Library
def foo do
"Local function from client app"
end
def from_client_app do
from_lib() <> " - " <> foo()
end
end
При попытке компиляции ClientApp, Elixir обнаруживает конфликт и выводит следующую ошибку:
error: imported ModuleA.foo/0 conflicts with local function └ client_app.ex:4:
Рефакторинг
Для устранения этого антипаттерна мы рекомендуем авторам библиотек избегать предоставления обратных вызовов __using__/1 всякий раз, когда их можно заменить директивами alias/1 или import/1. В следующем коде мы предполагаем, что use Library больше не доступен, и ClientApp был переработан таким образом, что код более ясен, а конфликт, как показано ранее, больше не существует:
defmodule ClientApp do
import Library
def foo do
"Local function from client app"
end
def from_client_app do
from_lib() <> " - " <> foo()
end
end
iex> ClientApp.from_client_app() "From Library - Local function from client app"
Дополнительные замечания
В ситуациях, когда вам нужно сделать больше, чем импортировать и алиасировать модули, может потребоваться предоставить use MyModule, поскольку оно предоставляет общую точку расширения в экосистеме Elixir.
Поэтому, чтобы обеспечить руководство и ясность, мы рекомендуем авторам библиотек включать блок предупреждения в их @moduledoc с описанием того, как use MyModule влияет на код разработчика. Например, документация GenServer описывает:
use GenServer
Когда вы use GenServer, модуль GenServer установит @behaviour GenServer и определит функцию child_spec/1, чтобы ваш модуль можно было использовать как дочерний в дереве контроля.
Подумайте об этом резюме как о "таблице пищевой ценности" для кода. Убедитесь, что указаны только изменения, внесенные в публичный API модуля. Например, если use Library устанавливает внутренний атрибут, называемый @_some_module_info, и этот атрибут никогда не должен быть публичным, избегайте его документирования в таблице пищевой ценности.
Для удобства, разметка для генерации приведенного выше блока предупреждения выглядит так:
> #### `use GenServer` {: .info}
>
> When you `use GenServer`, the `GenServer` module will
> set `@behaviour GenServer` and define a `child_spec/1`
> function, so your module can be used as a child
> in a supervision tree.
© 2012-2024 The Elixir Team
Licensed under the Apache License, Version 2.0.
https://hexdocs.pm/elixir/1.18.1/macro-anti-patterns.html