В этой части мы рассмотрим более продвинутые функции и методы метапрограммирования, уделяя особое внимание аннотациям. Эта информация, как правило, недостаточно хорошо документирована. Без дальнейших церемоний давайте рассмотрим, как использовать эти более продвинутые функции.
Эта часть содержит следующие главы:
• Глава 10, Работа с макросами
• Глава 11, Введение в аннотации
• Глава 12, Использование анализа типов во время компиляции
• Глава 13, Расширенное использование макросов
В этой главе мы собираемся исследовать мир метапрограммирования. Метапрограммирование может быть отличным способом «СУШИТЬ» (DRY) ваш код путем объединения шаблонного кода в фрагменты многократного использования или путем обработки данных во время компиляции для создания дополнительного кода. Сначала мы рассмотрим основную часть этой функции: макросы.
В этой главе мы рассмотрим следующие темы:
• Определение макросов
• Понимание API макросов.
• Изучение макросов.
К концу этой главы вы сможете понять, когда и как можно применять макросы, чтобы уменьшить количество шаблонного кода в приложении.
Для этой главы вам понадобится работающая установка Crystal.
Инструкции по настройке Crystal можно найти в Главе 1 «Введение в Crystal».
Все примеры кода в этой главе можно найти в папке Chapter 10 репозитория GitHub этой книги: https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter10.
В Crystal макрос имеет два значения. Как правило, это относится к любому коду, который запускается или расширяется во время компиляции. Однако, более конкретно, это может относиться к типу метода, который принимает узлы AST во время компиляции, тело которых вставляется в программу в момент использования макроса. Примером последнего является макрос
property
, который вы видели в предыдущих главах, который представляет собой простой способ определения как метода получения, так и метода установки для данной переменной экземпляра:
class Example
property age : Int32
def initialize(@age : Int32); end
end
Предыдущий код эквивалентен следующему:
class Example
@age : Int32
def initialize(@age : Int32); end
def age : Int32
@age
end
def age=(@age : Int32)
end
end
Как мы упоминали ранее, макросы принимают узлы AST во время компиляции и выводят код Crystal, который добавляется в программу, как если бы он был введен вручную. По этой причине
property age: Int32
не является частью конечной программы, а только тем, во что оно расширяется — объявлением переменной экземпляра, методом получения и методом установки. Аналогичным образом, поскольку макросы работают на узлах AST во время компиляции, аргументы/значения, используемые внутри макроса, также должны быть доступны во время компиляции. Сюда входит следующее:
• Переменные среды.
• Константы
• Жестко запрограммированные значения.
• Жестко закодированные значения, созданные с помощью другого макроса.
Поскольку аргументы должны быть известны во время компиляции, макросы не заменяют обычные методы, даже если результат в обоих случаях кажется одинаковым. Возьмем, к примеру, эту небольшую программу:
macro print_value(value)
{{pp value}}
pp {{value}}
end
name = "George"
print_value name
Запуск этой программы приведет к следующему выводу:
name
"George"
Главное, на что следует обратить внимание, — это вывод значения, когда оно находится в контексте макроса. Поскольку макросы принимают узлы AST, макрос не имеет доступа к текущему значению переменной времени выполнения, такой как имя. Вместо этого типом значения в контексте макроса является
Var
, который представляет локальную переменную или аргумент блока. Это можно подтвердить, добавив в макрос строку, состоящую из {{pp value.class_name}}
, которая в конечном итоге напечатает "Var”
. Мы узнаем больше об узлах AST позже в этой главе.
Макросами легко злоупотреблять из-за предоставляемых ими возможностей. Однако, как говорится: с большой силой приходит и большая ответственность. Эмпирическое правило заключается в том, что если вы можете добиться того, чего хотите, с помощью обычного метода, используйте обычный метод и используйте макросы как можно реже. Это не значит, что макросов следует избегать любой ценой, а скорее, что их следует использовать стратегически, а не как решение каждой проблемы, с которой вы сталкиваетесь.
Макрос можно определить с помощью ключевого слова макроса:
macro def_method(name)
def {{name.id}}
puts "Hi"
end
end
def_method foo
foo
В этом примере мы определили макрос с именем
def_method
, который принимает один аргумент. В целом макросы очень похожи на обычные методы с точки зрения их определения, при этом основные различия заключаются в следующем:
• Аргументы макроса не могут иметь ограничений типа.
• Макросы не могут иметь ограничений по типу возвращаемого значения.
• Аргументы макроса не существуют во время выполнения, поэтому на них можно ссылаться только в синтаксисе макроса.
Макросы ведут себя аналогично методам класса в отношении их области действия. Макросы могут быть определены внутри типа и вызываться вне его, используя синтаксис метода класса. Аналогично, вызовы макросов будут искать определение в цепочке предков типа, например, в родительских типах или включенных модулях. Также можно определить частные макросы, которые сделают их видимыми в том же файле только в том случае, если они объявлены на верхнем уровне или только в пределах определенного типа, в котором они были объявлены.
Синтаксис макроса состоит из двух форм:
{{ ... }} и {% ... %}
. Первый используется, когда вы хотите вывести какое-то значение в программу. Последний используется как часть потока управления макросом, например, циклы, условная логика, присвоение переменных и т. д. В предыдущем примере мы использовали синтаксис двойной фигурной скобки, чтобы вставить значение аргумента name в программу в качестве имени метода, которое в данном случае — foo
. Затем мы вызвали метод, в результате чего программа напечатала Hi
.
Макросы также могут расширяться до нескольких элементов и иметь более сложную логику для определения того, что будет сгенерировано. Например, давайте определим метод, который принимает переменное количество аргументов, и создадим метод для доступа к каждому значению, возможно, только для нечетных чисел:
macro def_methods(*numbers, only_odd = false)
{% for num, idx in numbers %}
{% if !only_odd || (num % 2) != 0 %}
# Returns the number at index {{idx}}.
def {{"number_#{idx}".id}}
{{num}}
end
{% end %}
{% end %}
{{debug}}
end
def_methods 1, 3, 6, only_odd: true
pp number_0
pp number_1
В этом примере происходит нечто большее, чем мы видим! Давайте разберемся. Сначала мы определили макрос под названием
def_methods
, который принимает переменное количество аргументов с необязательным логическим флагом, которому по умолчанию присвоено значение false
. Макрос ожидает, что вы предоставите ему серию чисел, с помощью которых он создаст методы для доступа к числу, используя индекс каждого значения для создания уникального имени метода. Необязательный флаг заставит макрос создавать методы только для нечетных чисел, даже если в макрос также были переданы четные числа.
Цель использования аргументов
splat
и именованных аргументов — показать, что макросы похожи на методы, которые могут быть написаны таким же образом. Однако разница становится более очевидной, когда вы попадаете в тело макроса. Обычно метод #each
используется для итерации коллекции. В случае макроса вы должны использовать синтаксис for item, index in collection
, который также можно использовать для итерации фиксированного количества раз или для перебора ключей/значений Hash/NamedTuple
через for i in (0.. 10)
, а для ключа — значение в hash_or_named_tuple
соответственно.
Основная причина, по которой
#each
нельзя использовать, заключается в том, что циклу необходим доступ к реальной программе, чтобы иметь возможность вставить сгенерированный код. #each
можно использовать внутри макроса, но он должен использоваться в синтаксисе макроса и не может использоваться для генерации кода. Лучше всего это продемонстрировать на примере:
{% begin %}
{% hash = {"foo" => "bar", "biz" => "baz"} %}
{% for key, value in hash %}
puts "#{{{key}}}=#{{{value}}}"
{% end %}
{% end %}
{% begin %}
{% arr = [1, 2, 3] %}
{% hash = {} of Nil => Nil %}
{% arr.each { |v| hash[v] = v * 2 } %}
puts({{hash}})
{% end %}
В этом примере мы перебирали ключи и значения хеша, генерируя вызов метода
puts
, который печатает каждую пару. Мы также использовали ArrayLiteral#each
для перебора каждого значения и установки вычисленного значения в хеш-литерал, который затем печатаем. В большинстве случаев синтаксис for in
можно использовать вместо #each
, но #each
нельзя использовать вместо for in
. Проще говоря, поскольку метод #each
использует блок, у него нет возможности вывод сгенерированного кода. Таким образом, его можно использовать только для итерации, а не генерации кода.
Следующее, что делает наш макрос
def_methods
, — это использует оператор if
, чтобы определить, должен ли он генерировать метод или нет для текущего числа. Операторы if/unless
в макросах работают идентично своим аналогам во время выполнения, хотя и в рамках синтаксиса макросов.
Далее обратите внимание, что у этого метода есть комментарий, включающий
{{idx}}
. Макровыражения оцениваются как в комментариях, так и в обычном коде. Это позволяет генерировать комментарии на основе расширенного значения макровыражений. Однако эта функция также делает невозможным комментирование кода макроса, поскольку он все равно будет оцениваться как обычно.
Наконец, у нас есть логика, создающая метод. В данном случае мы интерполировали индекс из цикла в строку, представляющую имя метода. Обратите внимание, что мы использовали для строки метод
#id
. Метод #id
возвращает значение как MacroId
, что по существу нормализует значение как один и тот же идентификатор, независимо от типа входных данных. Например, вызов #id
для “foo”
, :foo
и foo
приводит к возврату того же значения foo
. Это полезно, поскольку позволяет вызывать макрос с любым идентификатором, который предпочитает пользователь, при этом создавая тот же базовый код.
В самом конце определения макроса вы могли заметить строку
{{debug}}
. Это специальный метод макроса, который может оказаться неоценимым при отладке кода макроса. При использовании он выводит код макроса, который будет сгенерирован в строке, в которой он был вызван. В нашем примере мы увидим следующий вывод на консоли перед выводом ожидаемых значений:
# Returns the number at index 0.
def number_0
1
end
# Returns the number at index 1.
def number_1
3
end
Поскольку макрос становится все более и более сложным, это может быть невероятно полезно для обеспечения того, чтобы он генерировал то, что должно быть.
Макрос также может генерировать другие макросы. Однако при этом необходимо соблюдать особую осторожность, чтобы гарантировать правильное экранирование выражений внутреннего макроса. Например, следующий макрос аналогичен предыдущему примеру, но вместо непосредственного определения методов он создает другой макрос и немедленно вызывает его, в результате чего создаются связанные методы:
macro def_macros(*numbers)
{% for num, idx in numbers %}
macro def_num_{{idx}}_methods(n)
def num_\{{n}}
\{{n}}
end
def num_\{{n}}_index
{{idx}}
end
end
def_num_{{idx}}_methods({{num}})
{% end %}
end
def_macros 2, 1
pp num_1_index # => 1
pp num_2_index # => 0
В конце макросы расширяются и определяют четыре метода. Ключевым моментом, на который следует обратить внимание в этом примере, является использование
\{{
. Обратная косая черта экранирует выражение синтаксиса макроса, поэтому оно не оценивается внешним макросом, что означает, что оно расширяется только внутренним макросом. На переменные макроса из внешнего макроса по-прежнему можно ссылаться во внутреннем макросе, используя переменную во внутреннем макросе, не экранируя выражение.
Необходимость экранирования каждого выражения синтаксиса макроса внутри внутреннего макроса может быть довольно утомительной и подверженной ошибкам. К счастью, для упрощения этого можно использовать дословный вызов. Внутренний макрос, показанный в предыдущем примере, также можно записать следующим образом:
macro def_num_{{idx}}_methods(n)
{% verbatim do %}
def num_{{n}}
{{n}}
end
def num_{{n}}_index
{{idx}}
end
{% end %}
end
Однако если вы запустите это, вы увидите, что оно не компилируется. Единственным недостатком дословного перевода является то, что он не поддерживает интерполяцию переменных. Другими словами, это означает, что код внутри блока
verbatim
не может использовать переменные, определенные вне него, например idx
.
Чтобы иметь возможность доступа к этой переменной, нам нужно определить другую экранированную макропеременную за пределами блока
verbatim
внутри внутреннего макроса, для которого установлено расширенное значение переменной idx
внешнего макроса. Проще говоря, нам нужно добавить \{% idx = {{idx}} %}
над строкой {% verbatim do %}
. В конечном итоге это приводит к расширению {% idx = 1 %}
внутри внутреннего макроса в случае второго значения.
Поскольку макросы расширяются до кода Crystal, код, сгенерированный макросом, может создать конфликт с кодом, определенным в расширении макроса. Наиболее распространенной проблемой является переопределение локальных переменных. Решением этой проблемы является использование новых переменных как средства создания уникальных переменных.
Если макрос использует локальную переменную, предполагается, что эта локальная переменная уже определена. Эта функция позволяет макросу использовать предопределенные переменные в контексте раскрытия макроса, что может помочь уменьшить дублирование. Однако это также позволяет легко случайно переопределить локальную переменную, определенную в макросе, как показано в этом примере:
macro update_x
x = 1
end
x = 0
update_x
puts x
Макрос
update_x
расширяется до выражения x = 1
, которое переопределяет исходную переменную x
, в результате чего эта программа печатает значение 1
. Чтобы позволить макросу определять переменные, которые не будут конфликтовать, необходимо использовать новые переменные, например:
macro dont_update_x
%x = 1
puts %x
end
x = 0
dont_update_x
puts x
В отличие от предыдущего примера, здесь будет выведено значение
1
, за которым следует значение 0
, тем самым показывая, что расширенный макрос не изменил локальную переменную x
. Новые переменные определяются путем добавления символа %
к имени переменной. Новые переменные также могут быть созданы относительно другого значения макроса времени компиляции. Это может быть особенно полезно в циклах, где для каждой итерации цикла должна определяться новая переменная с тем же именем, например:
macro fresh_vars_sample(*names)
{% for name, index in names %}
%name{index} = {{index}}
{% end %}
{{debug}}
end
fresh_vars_sample a, b, c
Предыдущая программа будет перебирать каждый из аргументов, переданных макросу, и определит новую переменную для каждого элемента, используя индекс элемента в качестве значения переменной. На основе результатов отладки этот макрос расширяется до следующего:
__temp_24 = 0
__temp_25 = 1
__temp_26 = 2
Для каждой итерации цикла определяется одна переменная. Компилятор Crystal отслеживает все новые переменные и присваивает каждой из них номер, чтобы гарантировать, что они не конфликтуют друг с другом.
Весь код макроса, который мы написали/рассмотрели до сих пор, был представлен в контексте определения макроса . Хотя это одно из наиболее распространенных мест для просмотра кода макроса, макросы также могут использоваться вне определения макроса. Это может быть полезно для условного определения кода на основе некоторого внешнего значения, такого как переменная среды, флаг времени компиляции или значение константы. Это можно увидеть в следующем примере:
{% if flag? :release %}
puts "Release mode!"
{% else %}
puts "Non-release mode!"
{% end %}
Метод
flag?
— это специальный метод макроса, который позволяет нам проверять наличие либо предоставленных пользователем, либо встроенных флагов времени компиляции. Одним из основных вариантов использования этого метода является определение кода, специфичного для конкретной ОС и/или архитектуры. Компилятор Crystal включает в себя несколько встроенных флагов, которые можно использовать для этого, например {% if flag?(:linux) && flag?(:x86_64) %}
, которые будут выполняться только в том случае, если система, компилирующая программу, использует 64-битная ОС Linux.
Пользовательские флаги можно определить с помощью опций
--define
или -D
. Например, если вы хотите проверить наличие flag? :foo
, флаг можно определить, выполнив crystal run -Dfoo main.cr
. Флаги времени компиляции либо присутствуют, либо нет; они не могут включать значение. Однако переменные окружающей среды могут стать хорошей заменой, если требуется большая гибкость.
Переменные среды можно прочитать во время компиляции с помощью метода макроса env. Хорошим вариантом использования этого является возможность встраивания в двоичный файл информации о времени сборки, такой как эпоха сборки, время сборки и т. д. В этом примере во время компиляции значение константы будет установлено либо в значение переменной среды
BUILD_SHA_HASH
, либо в пустую строку, если она не была установлена (все это происходит во время компиляции):
COMMIT_SHA = {{ env("BUILD_SHA_HASH") || "" }}
pp COMMIT_SHA
При запуске этого кода обычно печатается пустая строка, а при установке связанной переменной
env
выводится это значение. Установка этого значения через переменную env
, а не генерация внутри самого макроса с помощью системного вызова, гораздо более переносима, поскольку не зависит от Git, а также гораздо проще интегрируется с внешними системами сборки, такими как Make.
Одним из ограничений макросов является то, что сгенерированный из макроса код также должен быть действительным кодом Crystal, как показано здесь:
def {{"foo".id}}
"foo"
end
Этот предыдущий код не является допустимой программой, поскольку метод неполный и не полностью определен в макросе. Этот метод можно включить в макрос, обернув все тегами
{% begin %}/{% end %}
, которые будут выглядеть следующим образом:
{% begin %}
def {{"foo".id}}
"foo"
end
{% end %}
На этом этапе вы должны иметь четкое начальное представление о том, что такое макросы, как их определять и для каких случаев использования они предназначены, что позволит вам сохранить ваш код СУХИМ (DRY). Далее мы рассмотрим API макросов, чтобы можно было создавать более сложные макросы.
В примерах из предыдущего раздела в контексте макроса использовались различные переменные разных типов, такие как числа, которые мы перебираем, строки, которые мы используем для создания идентификаторов, и логические значения, которые мы сравниваем для условной генерации кода. Было бы легко предположить, что это напрямую соответствует стандартным типам
Number
, String
и Bool
. Однако это не так. Как мы упоминали в разделе «Определение макросов» этой главы, макросы работают на узлах AST и, как таковые, имеют свой собственный набор типов, похожий на связанные с ними обычные типы Crystal, но с подмножеством API. Например, типы, с которыми мы до сих пор работали, включают NumberLiteral
, StringLiteral
и BoolLiteral
.
Все типы макросов находятся в пространстве имен
Crystal::Macros
в документации API, которая находится по адресу https://crystal-lang.org/api/Crystal/Macros.html. К наиболее распространенным/полезным типам относятся следующие:
•
Def
: описывает определение метода.
•
TypeNode
: описывает тип (класс, структура, модуль, библиотека).
•
MetaVar
: описывает переменную экземпляра.
•
Arg
: описывает аргумент метода.
•
Annotation
: представляет аннотацию, применяемую к типу, методу или переменной экземпляра (подробнее об этом в следующей главе).
Crystal предоставляет удобный способ получить экземпляр первых двух типов в виде макропеременных
@def
и @type
. Как следует из их названий, использование @def
внутри метода вернет экземпляр Def
, представляющий этот метод. Аналогично, использование @type
вернет экземпляр TypeNode
для связанного типа. Доступ к другим типам можно получить через методы, основанные на одном из этих двух типов. Например, запуск следующей программы выведет "Метод hello внутри Foo"
:
class Foo
def hello
{{"The #{@def.name} method within #{@type.name}"}}
end
end
pp Foo.new.hello
Другой, более продвинутый способ получения
TypeNode
— использование макрометода parse_type
. Этот метод принимает StringLiteral
, который может быть создан динамически, и возвращает один из нескольких типов макросов в зависимости от того, что представляет собой строка. Дополнительную информацию см. в документации по методу https://crystal-lang.org/api/Crystal/Macros.html.
Как мы упоминали ранее, API макросов позволяет нам вызывать фиксированное подмножество обычных методов API для литеральных типов. Другими словами, это позволяет нам вызывать
ArrayLiteral#select
, но не ArrayLiteral#each_repeated_permutation
, или StringLiteral#gsub
, но не StringLiteral#scan
.
В дополнение к этим примитивным типам ранее упомянутые типы макросов предоставляют свой собственный набор методов, чтобы мы могли получать информацию о связанном типе, например:
• Тип возвращаемого значения, его видимость или аргументы метода.
• Тип/значение по умолчанию аргумента метода.
• Какие аргументы объединения/обобщения имеет тип, если таковые имеются.
Конечно, их слишком много, чтобы их здесь упоминать, поэтому я предлагаю просмотреть документацию по API для получения полного списка. А пока давайте применим некоторые из этих методов:
class Foo
def hello(one : Int32, two, there, four : Bool, five :
String?)
{% begin %}
{{"#{@def.name} has #{@def.args.size} arguments"}}
{% typed_arguments = @def.args.select(&.restriction) %}
{{"with #{typed_arguments.size} typed
arguments"}}
{{"and is a #{@def.visibility.id} method"}}
{% end %}
end
end
Foo.new.hello 1, 2, 3, false, nil
Эта программа выведет следующее:
"hello has 5 arguments"
"with 3 typed arguments"
"and is a public method"
Первая строка выводит имя метода и количество его аргументов через
ArrayLiteral#size
, поскольку Def#args
возвращает ArrayLiteral(Arg)
. Затем мы используем метод ArrayLiteral#select
, чтобы получить массив, содержащий только аргументы, имеющие ограничение типа. Arg#restriction
возвращает TypeNode
на основе типа ограничения или Nop
, которое является ложным значением и используется для представления пустого узла. Наконец, мы используем Def#visibility
, чтобы узнать уровень видимости метода. Он возвращает символический литерал, поэтому мы вызываем для него #id
, чтобы получить его общее представление.
Существует еще одна специальная макропеременная
@top_level
, которая возвращает TypeNode
, представляющий пространство имен верхнего уровня. Если мы не воспользуемся этим, единственный другой способ получить к нему доступ — это вызвать @type
в пространстве имен верхнего уровня, что сделает невозможным ссылку на него внутри другого типа. Давайте посмотрим, как можно использовать эту переменную:
A_CONSTANT = 0
module Foo; end
{% if @top_level.has_constant?("A_CONSTANT") && @top_level
.has_constant?("Foo") %}
puts "this is printed"
{% else %}
puts "this is not printed"
{% end %}
В этом примере мы использовали
TypeNode#has_constant?
, который возвращает BoolLiteral
, если связанный TypeNode
имеет предоставленную константу, предоставленную в виде StringLiteral
, SymbolLiteral
или MacroId
(тип, который вы получаете при вызове #id
для другого типа). Этот метод работает как для реальных констант, так и для типов.
Понимание API макросов имеет решающее значение для написания макросов, использующих информацию, полученную из типа и/или метода. Я настоятельно рекомендую прочитать документацию по API для некоторых типов макросов, о которых мы говорили в этом разделе, чтобы полностью понять, какие методы доступны.
Прежде чем мы перейдем к следующему разделу, давайте применим все, что мы узнали, для воссоздания макроса
property
стандартной библиотеки.
Обычно макрос
property
принимает экземпляр TypeDeclaration
, который представляет имя, тип и значение по умолчанию, если таковое имеется, переменной экземпляра. Макрос использует это определение для создания переменной экземпляра, а также методов получения и установки для нее.
Макрос
property
также обрабатывает несколько дополнительных случаев использования, но сейчас давайте сосредоточимся на наиболее распространенном. Наша реализация этого макроса будет выглядеть так:
macro def_getter_setter(decl)
@{{decl}}
def {{decl.var}} : {{decl.type}}
@{{decl.var}}
end
def {{decl.var}}=(@{{decl.var}} : {{decl.type}})
end
end
Мы можем определить переменную экземпляра, используя
@{{decl}}
, потому что она автоматически расширится до нужного формата. Мы могли бы также использовать @{{decl.var}} : {{decl. type}}
, но другой путь был короче и лучше обрабатывал значения по умолчанию. Более длинная форма должна будет явно проверить и установить значение по умолчанию, если таковое имеется, тогда как более короткая форма сделает это за нас. Однако тот факт, что вы можете реконструировать узел вручную, используя предоставляемые им методы, не является совпадением. Узлы AST — это абстрактные представления чего-либо внутри программы, например, объявление типа, метода или выражение оператора if
, поэтому имеет смысл только то, что вы можете построить то, что представляет узел, используя сам узел.
Остальная часть нашего макроса
def_getter_setter
строит методы получения и установки для определенной переменной экземпляра. Отсюда мы можем пойти дальше и использовать его:
class Foo
def_getter_setter name : String?
def getter setter number : Int32 = 123
property float : Float64 = 3.14
end
obj = Foo.new
pp obj.name
obj.name = "Bob"
pp obj.name
pp obj.number
pp obj.float
Запуск этой программы приведет к следующему выводу:
nil
"Bob"
123
3.14
И вот оно! Успешная повторная реализация наиболее распространенной формы макроса
property
! Здесь легко увидеть, как можно использовать макросы, чтобы уменьшить количество шаблонов и повторений в вашем приложении.
Последняя концепция макросов, которую мы собираемся обсудить в этой главе, — это макро-хуки, которые позволяют нам подключаться к различным событиям Crystal.
Перехватчики макросов — это специальные определения макросов, которые в некоторых ситуациях вызываются компилятором Crystal во время компиляции. К ним относятся следующие:
• inherited вызывается, когда определен подкласс, где
@type
— это наследующий тип.
• included вызывается при включении модуля, где
@type
— включаемый тип.
• extended вызывается при расширении модуля, где
@type
— расширяемый тип.
• method_missing вызывается, когда метод не найден, и ему передается один аргумент Call.
• method_added вызывается, когда новый метод определен в текущей области и ему передается один аргумент
Def
.
• finished вызывается после этапа семантического анализа, поэтому известны все типы и их методы.
Первые три и законченные определения являются наиболее распространенными/полезными, поэтому мы сосредоточимся на них. Первые три хука работают по сути одинаково — они просто выполняются в разных контекстах. Например, следующая программа демонстрирует, как они работают, определяя различные перехватчики и печатая уникальное сообщение при выполнении этого перехватчика:
abstract class Parent
macro inherited
puts "#{{{@type.name}}} inherited Parent"
end
end
module MyModule
macro included
puts "#{{{@type.name}}} included MyModule"
end
macro extended
puts "#{{{@type.name}}} extended MyModule"
end
end
class Child < Parent
include MyModule
extend MyModule
end
Предыдущий код выведет следующий результат:
Child inherited Parent
Child included MyModule
Child extended MyModule
Эти перехватчики могут быть весьма полезны, если вы хотите добавить методы/переменные/константы к другому типу в случаях, когда обычная семантика наследования/модуля не работает. Примером этого может служить случай, когда вы хотите добавить к типу методы экземпляра и класса при включении модуля. Из-за того, как работает включение/расширение модулей, в настоящее время невозможно добавить оба типа методов к типу из одного модуля.
Обходной путь — вложить еще один модуль
ClassMethods
в основной. Однако для этого пользователю потребуется вручную включить основной модуль и расширить вложенный модуль, что не очень удобно для пользователя. Лучшим вариантом было бы определить в основном модуле макрос, включающий ловушку, которая расширяет модуль ClassMethods
. Таким образом, макрос будет расширяться внутри включенного класса, автоматически расширяя модуль методов класса. Это будет выглядеть примерно так:
module MyModule
module ClassMethods
def foo
"foo"
end
end
macro included
extend MyModule::ClassMethods
end
def bar
"bar"
end
end
class Foo
include MyModule
end
pp Foo.foo
pp Foo.new.bar
Таким образом, пользователю нужно только включить модуль, чтобы получить оба типа методов, что в целом улучшит взаимодействие с пользователем.
macro finished в основном используется, когда вы хотите выполнить какой-либо макрокод только после того, как Crystal узнает обо всех типах. В некоторых случаях отсутствие вашего макрокода в обработчике finished может привести к неверным результатам. Следите за обновлениями! Мы рассмотрим это более подробно в Главе 15 "Документирование кода".
Метапрограммирование — одна из областей, в которой Кристалл преуспевает. Он предоставляет нам довольно мощную систему, которую можно использовать для генерации кода и уменьшения количества шаблонов/повторений, при этом оставаясь при этом достаточно простой по сравнению с другими языками. Однако, когда это необходимо, эту силу следует использовать экономно.
В этой главе мы узнали, как и когда использовать макросы для сокращения шаблонного кода, как подключаться к различным событиям Crystal с помощью перехватчиков макросов, а также познакомились с API макросов для поддержки создания более сложных макросов.
В следующей главе мы рассмотрим аннотации и то, как их можно использовать в сочетании с макросами для хранения данных, которые можно прочитать во время компиляции.
Как упоминалось в предыдущей главе, макросы могут быть мощным инструментом для генерации кода, позволяющим уменьшить дублирование и сохранить ваше приложение DRY. Однако одно из ограничений макросов, особенно тех, которые находятся за пределами определения макроса, заключается в том, что сложно получить доступ к данным для использования внутри макроса, поскольку они должны быть доступны во время компиляции, как переменная среды или константа.
Ни один из этих вариантов в большинстве случаев не является отличным вариантом. Чтобы лучше решить эту проблему, нам нужно изучить следующую концепцию метапрограммирования Crystal: аннотации.
В этой главе мы рассмотрим следующие темы:
• Что такое аннотации?
• Хранение данных в аннотациях.
• Чтение аннотаций
К концу этой главы вы должны иметь четкое представление о том, что такое аннотации и как их использовать.
Требования к этой главе следующие:
• Рабочая установка Crystal.
Инструкции по настройке Crystal можно найти в Главе 1 «Введение в Crystal».
Все примеры кода, использованные в этой главе, можно найти в папке Chapter 11 на GitHub: https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter11.
Проще говоря, аннотация — это способ прикрепить метаданные к определенным функциям кода, к которым впоследствии можно получить доступ во время компиляции внутри макроса. Crystal поставляется в комплекте с некоторыми встроенными аннотациями, с которыми вы, возможно, уже работали, например
@[JSON::Field]
или аннотацией @[Link]
, которая была рассмотрена в Главе 7, «Взаимодействие C». Хотя обе эти аннотации включены по умолчанию, они различаются по своему поведению. Например, аннотация JSON::Field
существует в стандартной библиотеке Crystal и реализована/используется таким образом, что вы можете воспроизвести ее в своем собственном коде с помощью собственной аннотации. С другой стороны, аннотация Link
имеет особые отношения с компилятором Crystal, и часть ее поведения не может быть воспроизведена в пользовательском коде.
Пользовательские аннотации можно определить с помощью ключевого слова annotation:
annotation MyAnnotation; end
Вот и все. Затем аннотацию можно было применить к различным элементам, включая следующие:
• Методы экземпляра и класса.
• Переменные экземпляра
• Классы, структуры, перечисления и модули.
Аннотацию можно применять к различным объектам, помещая имя аннотации в квадратные скобки синтаксиса
@[]
, как в следующем примере:
@[MyAnnotation]
def foo
"foo"
end
@[MyAnnotation]
class Klass
end
@[MyAnnotation]
module MyModule
end
К одному и тому же элементу также можно применить несколько аннотаций:
annotation Ann1; end
annotation Ann2; end
@[Ann1]
@[Ann2]
@[Ann2]
def foo
end
В этом конкретном контексте на самом деле нет смысла использовать более одной аннотации, поскольку нет способа отличить их друг от друга; однако это будет иметь больше смысла, если вы добавите данные в аннотацию, что является темой следующего раздела.
Итак, аннотации — это то, что можно применять к различным вещам в коде для хранения метаданных о них. Но чем они на самом деле хороши? Основное преимущество, которое они предоставляют, заключается в том, что они не зависят от реализации. Другими словами, это означает, что вы можете просто аннотировать что-то, и соответствующая библиотека сможет читать из него данные без необходимости специального определения макроса для создания переменной экземпляра, метода или типа.
Примером этого может быть, скажем, у вас есть модель ORM, которую вы хотите проверить. Например, если одна из установленных вами библиотек использует собственный макрос, такой как
column id : Int64
, это может сделать другие библиотеки нефункциональными, поскольку аннотация может быть неправильно применена к переменной экземпляра или методу. Однако если все библиотеки используют аннотации, то все они работают со стандартными переменными экземпляра Crystal, поэтому у библиотек нет возможности конфликтовать, и это делает все более естественным.
Кроме того, аннотации более ориентированы на будущее и более гибки по сравнению с определениями макросов для этого конкретного варианта использования. Далее давайте поговорим о том, как хранить данные в аннотации.
Подобно методу, аннотация поддерживает как позиционные, так и именованные аргументы:
annotation MyAnnotation
end
@[MyAnnotation(name: "value", id: 123)]
def foo; end
@[MyAnnotation("foo", 123, false)]
def bar; end
В этом примере мы определили два пустых метода, к каждому из которых применена аннотация. Первый использует исключительно именованные аргументы, а второй использует исключительно позиционные аргументы. Лучший пример применения нескольких аннотаций одного и того же типа можно продемонстрировать, когда в каждую аннотацию включены данные. Вот пример:
annotation MyAnnotation; end
@[MyAnnotation(1, enabled: false)]
@[MyAnnotation(2)]
def foo
end
Поскольку значения в каждой аннотации могут быть разными, связанная библиотека может создать несколько методов или переменных, например, на основе каждой аннотации и данных в ней. Однако эти данные бесполезны, если вы не можете получить к ним доступ! Давайте посмотрим, как это сделать дальше.
В Crystal вы обычно вызываете метод объекта, чтобы получить доступ к некоторым данным, хранящимся внутри. Аннотации ничем не отличаются. Тип
Annotation
предоставляет три метода, которые можно использовать для доступа к данным, определенным в аннотации, различными способами. Однако прежде чем вы сможете получить доступ к данным в аннотации, вам необходимо получить ссылку на экземпляр Annotation
. Это можно сделать, передав тип Annotation
методу #annotation
, определенному для типов, поддерживающих аннотации, включая TypeNode
, Def
и MetaVar
. Например, мы можем использовать этот метод для печати аннотации, примененной к определенному классу или методу, если таковой имеется:
annotation MyAnnotation; end
@[MyAnnotation]
class MyClass
def foo
{{pp @type.annotation MyAnnotation}}
{{pp @def.annotation MyAnnotation}}
end
end
MyClass.new.foo
Метод
#annotation
вернет NilLiteral
, если аннотация указанного типа не применена. Теперь, когда у нас есть доступ к примененной аннотации, мы готовы начать чтение из нее данных!
Первый, наиболее простой способ — использование метода
#[]
, который может показаться знакомым, поскольку он также используется, среди прочего, как часть типов Array
и Hash
. Этот метод имеет две формы: первая принимает NumberLiteral
и возвращает позиционное значение по предоставленному индексу. Другая форма принимает StringLiteral
, SymbolLiteral
или MacroId
и возвращает значение с предоставленным ключом. Оба этих метода вернут NilLiteral
, если по указанному индексу или указанному ключу не существует значения.
Два других метода,
#args
и #named_args
, не возвращают конкретное значение, а вместо этого возвращают коллекцию всех позиционных или именованных аргументов в аннотации в виде TupleLiteral
и NamedTupleLiteral
соответственно.
Прежде всего, давайте посмотрим, как мы можем работать с данными, хранящимися в классе, используя данные из аннотации для создания вывода:
annotation MyClass; end
Annotation MyAnnotation; end
@[MyClass(true, id: "foo_class")]
class Foo
{% begin %}
{% ann = @type.annotation MyClass %}
{% pp "#{@type} has positional arguments of:
#{ann.args}" %}
{% pp "and named arguments of #{ann.named_args}" %}
{% pp %(and is #{ann[0] ? "active".id :
"not active".id}) %}
{% status = if my_ann = @type.annotation MyAnnotation
"DOES"
else
"DOES NOT"
end %}
{% pp "#{@type} #{status.id} have MyAnnotation applied." %}
{% end %}
end
Запуск этой программы выведет следующее:
"Foo has positional arguments of: {true}"
"and named arguments of {id: \"foo_class\"}"
"and is active."
"Foo DOES NOT have MyAnnotation applied."
Мы также можем сделать то же самое с аннотацией, примененной к методу:
annotation MyMethod; end
@[MyMethod(4, 1, 2, id: "foo")]
def my_method
{% begin %}
{% ann = @def.annotation MyMethod %}
{% puts "\n" %}
{% pp "Method #{@def.name} has an id of #{ann[:id]}" %}
{% pp "and has #{ann.args.size} positional arguments" %}
{% total = ann.args.reduce(0) { |acc, v| acc + v } %}
{% pp "that sum to #{total}" %}
{% end %}
end
my_method
Запуск этой программы выведет следующее:
"Method my_method has an id of \"foo\""
"and has 3 positional arguments"
"that sum to 7"
В обоих этих примерах мы использовали все три метода, а также некоторые сами типы коллекций. Мы также увидели, как обрабатывать необязательную аннотацию, следуя той же логике обработки
nil
, что и в коде Crystal, не являющемся макросом. Если бы к нашему классу была применена аннотация, мы могли бы получить доступ к любым дополнительным данным из него через переменную my_ann
, так же, как мы это делали с переменной ann
в предыдущих строках. Этот шаблон может быть невероятно полезен, позволяя влиять на логику макроса наличием или отсутствием аннотации. Это может привести к более читабельному коду, для которого в противном случае потребовалась бы одна аннотация со множеством различных полей.
Как и в предыдущем примере с несколькими аннотациями для одного элемента, метод
#annotation
возвращает последнюю аннотацию, примененную к данному элементу. Если вы хотите получить доступ ко всем примененным аннотациям, вместо этого вам следует использовать метод #annotations
. Этот метод работает почти идентично другому методу, но возвращает ArrayLiteral(Annotation)
вместо Annotation?
. Например, мы могли бы использовать этот метод для перебора нескольких аннотаций, чтобы напечатать индекс аннотации вместе со значением, которое она хранит:
annotation MyAnnotation; end
@[MyAnnotation("foo")]
@[MyAnnotation(123)]
@[MyAnnotation(123)]
def annotation_read
{% for ann, idx in @def.annotations(MyAnnotation) %}
{% pp "Annotation #{idx} = #{ann[0].id}" %}
{% end %}
end
annotation_read
Запуск этого приведет к печати следующего:
"Annotation 0 = foo"
"Annotation 1 = 123"
"Annotation 2 = 123"
Вот и все. Аннотации сами по себе являются довольно простой функцией, но могут быть весьма мощными в сочетании с некоторыми другими функциями метапрограммирования Crystal.
В этой главе мы рассмотрели, как определять и использовать аннотации для расширения различных Функции Crystal с дополнительными метаданными, включая способ хранения как именованных, так и позиционные аргументы, как читать одиночные и множественные аннотации и какие преимущества/Аннотации вариантов использования выполняются поверх макросов.
Аннотации — это жизненно важная функция метапрограммирования, которую мы обязательно будем использовать в следующих главах. До сих пор весь макрокод, который мы писали для доступа к данным типа или метода, находился в контексте этого типа или метода.
В следующей главе мы собираемся изучить функцию самоанализа типов во время компиляции Crystal, которая представит новые способы доступа к той же информации.
В предыдущих главах мы в основном использовали макросы внутри самих типов и методов для доступа к информации времени компиляции или чтения аннотаций. Однако это значительно снижает эффективность макросов, поскольку они могут динамически реагировать на добавление или аннотирование новых типов. Следующая концепция метапрограммирования Crystal, которую мы собираемся рассмотреть, — это интроспекция типов во время компиляции, которая будет охватывать следующие темы:
• Итерация переменных типа
• Итерационные типы
• Итерационные методы
К концу этой главы вы сможете создавать макросы, которые генерируют код, используя переменные экземпляра, методы и/или информацию о типе, а также данные, считываемые из аннотаций.
Требования к этой главе следующие:
• Рабочая установка Кристалла.
Инструкции по настройке Crystal можно найти в Главе 1 «Введение в Crystal».
Все примеры кода, использованные в этой главе, можно найти в папке Главы 12 на GitHub: https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter12.
Одним из наиболее распространенных случаев использования интроспекции типов является перебор переменных экземпляра типа. Простейшим примером этого может быть добавление метода
#to_h
к объекту, который возвращает хэш, используя переменные экземпляра типа для ключа/значений. Это будет выглядеть так:
class Foo
getter id : Int32 = 1
getter name : String = "Jim"
getter? active : Bool = true
def to_h
{
"id" => @id,
"name" => @name,
"active" => @active,
}
end
end
pp Foo.new.to_h
Который, когда будет выполнен, выведет следующее:
{"id" => 1, "name" => "Jim", "active" => true}
Однако это далеко не идеально, поскольку вам нужно не забывать обновлять этот метод каждый раз, когда добавляется или удаляется переменная экземпляра. Он также не обрабатывает случай, когда этот класс расширяется и добавляются дополнительные переменные экземпляра.
Мы могли бы улучшить его, используя макрос для перебора переменных экземпляра этого типа с целью построения хеша. Новый метод
#to_h
будет выглядеть так:
def to_h
{% begin %}
{
{% for ivar in @type.instance_vars %}
{{ivar.stringify}} => @{{ivar}},
{% end %}
}
{% end %}
end
Если вы помните из Главы 10 «Работа с макросами», нам нужно обернуть эту логику в начало/конец, чтобы сделать все допустимым синтаксисом Crystal. Затем мы используем метод
#instance_vars
для экземпляра TypeNode
, полученного с помощью специальной макропеременной @type
. Этот метод возвращает Array(MetaVar)
, который включает информацию о каждой переменной экземпляра, такую как ее имя, тип и значение по умолчанию.
Наконец, мы перебираем каждую переменную экземпляра с помощью цикла for, используя строковое представление имени переменной экземпляра в качестве ключа и, конечно же, ее значение в качестве значения хеша. Запуск этой версии программы дает тот же результат, что и раньше, но с двумя основными преимуществами:
• Он автоматически обрабатывает вновь добавленные/удаленные переменные экземпляра.
• Он будет включать переменные экземпляра, определенные для дочерних типов, поскольку макрос расширяется для каждого конкретного подкласса, поскольку он использует макропеременную
@type
.
Подобно итерации переменных экземпляра, доступ к переменным класса также можно получить с помощью метода
TypeNode#class_vars
. Однако есть одна серьезная ошибка при переборе переменных экземпляра/класса типа.
Доступ к переменным экземпляра возможен только в контексте метода. Попытка сделать это вне метода всегда приведет к получению пустого массива, даже если используется в ловушке завершения макроса.
По сути, это ограничение компилятора Crystal на данный момент, которое может быть реализовано в той или иной форме в будущем. Но до тех пор лучше иметь это в виду, чтобы не тратить время на отладку чего-то, что просто не будет работать. Посетите https://github.com/crystal-lang/crystal/issues/7504 для получения дополнительной информации об этом ограничении.
Другой вариант использования итерации переменных экземпляра — это добавление переменных экземпляра к некоторой внешней логике, которая может быть включена в модуль. Например, предположим, что у нас есть модуль
Incrementable
, который определяет один метод #increment
, который, как следует из названия, будет увеличивать определенные выбранные переменные. Реализация этого метода может использовать @type.instance_vars
вместе с ArrayLiteral#select
, чтобы определить, какие переменные следует увеличить.
Прежде всего, давайте посмотрим на код модуля
Incrementable
:
module Incrementable
annotation Increment; end
def increment
{% for ivar in @type.instance_vars.select &.annotation Increment %}
@{{ivar}} += 1
{% end %}
end
end
Сначала мы определяем наш модуль вместе с аннотацией внутри него. Затем мы определяем метод, который фильтрует переменные экземпляра типа только для тех, к которым применена аннотация. Для каждой из этих переменных мы увеличиваем ее на единицу. Далее давайте посмотрим на тип, который будет включать в себя этот модуль:
class MyClass
include Incrementable
getter zero : Int32 = 0
@[Incrementable::Increment]
getter one : Int32 = 1
getter two : Int32 = 2 @[Incrementable::Increment]
getter three : Int32 = 3
end
Это довольно простой класс, который просто включает в себя наш модуль, определяет некоторые переменные экземпляра с помощью макроса
getter
и применяет аннотацию, определенную в модуле, к паре переменных. Мы можем протестировать наш код, создав и запустив следующую небольшую программу:
obj = MyClass.new
pp obj
obj.increment
pp obj
В этой программе мы создаем новый экземпляр нашего класса, который мы определили в последнем примере, печатаем состояние этого объекта, вызываем метод
increment
, а затем снова печатаем состояние объекта. Первая строка вывода показывает, что значение каждой переменной экземпляра соответствует имени переменной. Однако вторая строка вывода показывает, что переменные номер один и три действительно были увеличены на единицу.
Конечно, этот пример довольно тривиален, но приложения могут быть гораздо более сложными и мощными, о чем мы подробнее поговорим в следующей главе. А пока давайте перейдем от итерации переменных экземпляра/класса к итерации типов.
Многое из того, о чем мы говорили и продемонстрировали в последнем разделе, также можно применить и к самим типам. Одним из основных преимуществ перебора типов является то, что они не ограничены теми же ограничениями, что и переменные экземпляра. Другими словами, вам не обязательно находиться в контексте метода, чтобы перебирать типы. Благодаря этому возможности практически безграничны!
Вы можете перебирать типы в контексте другого класса для генерации кода, перебирать на верхнем уровне для создания дополнительных типов или даже внутри метода, чтобы построить своего рода конвейер, используя аннотации для определения порядка.
В каждом из этих контекстов любые данные, доступные во время компиляции, могут использоваться для изменения способа генерации кода, например переменные среды, константы, аннотации или данные, извлеченные из самого типа. В общем, это очень мощная функция, имеющая множество полезных применений. Но прежде чем мы сможем начать исследовать некоторые из этих вариантов использования, нам сначала нужно узнать, как можно выполнять итерации типов. Существует четыре основных способа итерации типов:
1. По всем или прямым подклассам родительского типа.
2. Типы, включающие определенный модуль.
3. Типы, к которым применяются определенные аннотации*
4. Некоторая комбинация предыдущих трех способов.
Первые два довольно очевидны. Третий метод отмечен звездочкой, так как здесь есть одна проблема, которую мы обсудим чуть позже в этой главе. Четвертое заслуживает дальнейшего объяснения. По сути, это означает, что вы можете использовать комбинацию первых трех, чтобы отфильтровать нужные вам типы. Примером этого может быть перебор всех типов, которые наследуются от определенного базового класса и к которым применена определенная аннотация, имеющая поле с определенным значением.
Самый распространенный способ перебора типов — через подклассы родительского типа. Это могут быть либо все подклассы этого типа, либо только прямые подклассы. Давайте посмотрим, как бы вы это сделали.
Прежде чем мы перейдем к более сложным примерам, давайте сосредоточимся на более простом варианте использования перебора подклассов типа с использованием следующего дерева наследования:
abstract class Vehicle; end
abstract class Car < Vehicle; end
class SUV < Vehicle; end
class Sedan < Car; end
class Van < Car; end
Первое, что нам нужно, это
TypeNode
родительского типа, подклассы которого мы хотим перебрать. В нашем случае это будет Vehicle
, но это не обязательно должен быть самый верхний тип. Мы могли бы с тем же успехом выбрать Car
, если бы она лучше соответствовала нашим потребностям.
Если вы помните первую главу этой части, мы смогли получить
TypeNode
с помощью специальной макропеременной @type
. Однако это будет работать только в том случае, если мы хотим перебирать типы в контексте типа Vehicle
. Если вы хотите выполнить итерацию за пределами этого типа, вам нужно будет использовать полное имя родительского типа.
Когда у нас есть
TypeNode
, мы можем использовать два метода в зависимости от того, что именно мы хотим сделать. TypeNode#subclasses
можно использовать для получения прямых подклассов этого типа. TypeNode#all_subclasses
можно использовать для получения всех подклассов этого типа, включая подклассы подклассов и так далее. Например, добавьте в файл следующие две строки вместе с показанным ранее деревом наследования:
{{pp Vehicle.subclasses}}
{{pp Vehicle.all_subclasses}}
В результате компиляции программы на консоль будут выведены две строки: первая —
[Car, SUV]
, а вторая — [Car, Sedan, Van, SUV]
. Вторая строка длиннее, поскольку она также включает подклассы типа Car
, который не включен в первую строку, поскольку Van
и Sedan
не являются прямыми дочерними элементами типа Vehicle
.
Также обратите внимание, что массив содержит как конкретные, так и абстрактные типы. На это стоит обратить внимание, поскольку если бы вы захотели перебрать типы и создать их экземпляры, это не удалось бы, поскольку был бы включен абстрактный тип
Car
. Чтобы этот пример работал, нам нужно отфильтровать список типов до тех, которые не являются абстрактными. Оба метода в предыдущем примере возвращают ArrayLiteral(TypeNode)
. По этой причине мы можем использовать метод ArrayLiteral#reject
для удаления абстрактных типов. Код для этого будет выглядеть так:
{% for type in Vehicle.all_subclasses.reject &.abstract? %}
pp {{type}}.new
{% end %}
Запуск этого в конечном итоге приведет к печати нового экземпляра типов
Sedan
, Van
, и SUV
. Мы можем пойти дальше в этой идее фильтрации и включить более сложную логику, например, использование данных аннотаций для определения того, следует ли включать тип.
Например, предположим, что мы хотим получить подмножество типов, имеющих аннотацию, исключая те, у которых есть определенное поле аннотации. В этом примере мы будем использовать следующие типы:
annotation MyAnnotation; end
abstract class Parent; end
@[MyAnnotation(id: 456)]
class Child < Parent; end
@[MyAnnotation]
class Foo; end
@[MyAnnotation(id: 123)]
class Bar; end
class Baz; end
У нас пять занятий, включая одно реферативное. Мы также определили аннотацию и применили ее к некоторым типам. Кроме того, некоторые из этих аннотаций также включают поле
id
, в котором установлено некоторое число. Используя эти классы, давайте переберем только те, у которых есть аннотация и либо нет поля id
, либо ID
является четным числом.
Однако обратите внимание, что в отличие от предыдущих примеров здесь нет прямого родительского типа, от которого наследуются все типы, а также не существует конкретного модуля, включенного в каждый из них. Итак, как мы собираемся отфильтровать нужный нам тип? Здесь в игру вступает звездочка в начале главы. Пока не существует прямого способа просто получить все типы с определенной аннотацией. Однако мы можем использовать один и тот же шаблон перебора всех подклассов типа, чтобы воспроизвести это поведение.
В Crystal
Object
является самым верхним типом из всех типов. Поскольку все типы неявно наследуются от этого типа, мы можем использовать его в качестве базового родительского типа для фильтрации до нужных нам типов.
Однако, поскольку этот подход требует перебора всех типов, он гораздо менее эффективен, чем более целенаправленный подход. В будущем, возможно, появится лучший способ сделать это, но на данный момент, в зависимости от конкретного варианта использования/API, который вы хотите поддерживать, это достойный обходной путь.
Например, этот подход необходим, если типы, которые вы хотите перебрать, еще не имеют какого-либо общего определяемого пользователем типа и/или включенного модуля. Однако, поскольку этот тип также является родительским типом для типов в стандартной библиотеке, вам потребуется какой-то способ его фильтровать, например, с помощью аннотации.
Код, фактически выполняющий фильтрацию, похож на предыдущие примеры, только с немного более сложной логикой фильтрации. В конечном итоге это будет выглядеть следующим образом:
{% for type in Object.all_subclasses.select {|t| (ann =
t.annotation(MyAnnotation)) && (ann[:id] == nil || ann[:id]
% 2 == 0) } %}
{{pp type}}
{% end %}
В этом случае мы используем
ArrayLiteral#select
, потому что нам нужны только те типы, для которых этот блок возвращает true
. Логика отражает требования, которые мы упоминали ранее. Он выбирает типы, которые имеют нашу аннотацию и либо не имеют поля id
, либо поля id
с четным номером. При создании этого примера будут правильно напечатаны ожидаемые типы: Child
и Foo
.
Третий способ, которым мы можем перебирать типы, - это запросить те типы, которые включают определенный модуль. Это может быть достигнуто с помощью метода
TypeNode#includers
, где TypeNode
представляет модуль, например:
module SomeInterface; end
class Bar
include SomeInterface
end
class Foo; end
class Baz
include SomeInterface
end
class Biz < Baz; end
{{pp SomeInterface.includers}}
Построение этой программы выведет следующее:
[Bar, Baz]
При использовании метода
#includers
следует отметить, что он включает только типы, которые напрямую включают этот модуль, а не типы, которые затем наследуются от него. Однако затем можно было бы вызвать #all_subclasses
для каждого типа, возвращаемого через #includers
, если это соответствует вашему варианту использования. Конечно, здесь также применима любая из ранее упомянутых логик фильтрации, поскольку #includers
возвращает ArrayLiteral(TypeNode)
.
Во всех этих примерах мы начали с базового родительского типа и прошли через все подклассы этого типа. Также возможно сделать обратное; начните с дочернего типа и перебирайте его предков. Например, давайте посмотрим на предков класса
Biz
, добавив в нашу программу следующий код и запустив его:
{{pp Biz.ancestors}}
Это должно вывести следующее:
[Baz, SomeInterface, Reference, Object]
Обратите внимание, что мы получаем прямой родительский тип, модуль, который включает в себя его суперкласс, и некоторые неявные суперклассы этого типа, включая вышеупомянутый тип
Object
. И снова метод #ancestors
возвращает ArrayLiteral(TypeNode)
, поэтому его можно фильтровать, как мы это делали в предыдущих примерах.
Следующая особенность метапрограммирования, которую мы собираемся рассмотреть, — это перебор методов типа.
Итерирующие методы имеют много общего с итерирующими типами, только с другим типом макроса. Первое, что нам нужно для перебора методов, — это
TypeNode
, представляющий тип, методы которого нас интересуют. Отсюда мы можем вызвать метод #methods
, который возвращает ArrayLiteral(Def)
всех методов, определенных для этого типа. Например, давайте напечатаем массив всех имен методов внутри класса:
abstract class Foo
def foo; end
end
module Bar
def bar; end
end
class Baz < Foo
include Bar
def baz; end
def foo(value : Int32); end
def foo(value : String); end
def bar(x); end
end
baz = Baz.new
baz.bar 1
baz.bar false
{{pp Baz.methods.map &.name}}
Запуск этого приведет к следующему:
[baz, foo, foo, bar]
Обратите внимание, что, как и в случае с методом
#includers
, выводятся только методы, явно определенные внутри типа. Также обратите внимание, что метод #foo
включается один раз для каждой из его перегрузок. Однако, несмотря на то, что #bar вызывается с двумя уникальными типами, он включается только один раз.
Логика фильтрации, о которой мы говорили в последнем разделе, также применима к итеративным методам. Проверка аннотаций может быть простым способом отметить методы, на которые должна воздействовать другая конструкция. Если вы вспомните модуль
Incrementable
из первого раздела, вы легко можете сделать что-то подобное, но заменив переменные экземпляра методами. Методы также обладают дополнительной гибкостью, поскольку их не нужно повторять в контексте метода.
Если вы помните раздел об итерации переменных экземпляра ранее в этой главе, для доступа к переменным класса существовал специальный метод
TypeNode#class_vars
. В случае методов класса эквивалентного метода не существует. Однако их можно перебирать. В большинстве случаев TypeNode
будет представлять тип экземпляра типа, поэтому он используется для перебора переменных экземпляра или методов экземпляра этого типа. Однако существует метод, который можно использовать для получения другого TypeNode
, представляющего метакласс этого типа, из которого мы можем получить доступ к методам его класса. Существует также метод, который возвращает тип экземпляра, если TypeNode
представляет тип класса.
Этими методами являются
TypeNode#class
и TypeNode#instance
. Например, если у вас есть TypeNode
, представляющий тип MyClass
, первый метод вернет новый TypeNode
, представляющий MyClass.class
, тогда как последний метод превратит MyClass.class
в MyClass
. Когда у нас есть тип класса TypeNode
, это так же просто, как вызвать для него #methods
; например:
class Foo
def self.foo; end
def self.bar; end
end
{{pp Foo.class.methods.map &.name}}
Запуск этого приведет к следующему:
[allocate, foo, bar]
Вам может быть интересно, откуда взялся метод
allocate
. Этот метод автоматически добавляется Crystal для использования в конструкторе, чтобы выделить память, необходимую для его создания. Учитывая, что вы, скорее всего, не захотите включать этот метод в свою логику, обязательно предусмотрите способ его отфильтровать.
Поскольку сами типы можно повторять, вы можете объединить эту концепцию с методами итерации. Другими словами, можно перебирать типы, а затем перебирать каждый из методов этого типа. Это может быть невероятно мощным средством автоматической генерации кода, так что конечному пользователю нужно только применить некоторые аннотации или наследовать/включить какой-либо другой тип.
И вот оно у вас есть; как анализировать переменные, типы и методы экземпляра/класса во время компиляции! Этот метод метапрограммирования можно использовать для создания мощной логики генерации кода, которая может упростить расширение и использование приложений, одновременно делая приложение более надежным за счет снижения вероятности опечаток или ошибок пользователя.
Далее, в последней главе этой части, мы рассмотрим несколько примеров того, как все изученные до сих пор концепции метапрограммирования можно объединить в более сложные шаблоны/функции.
Как упоминалось ранее, в
TypeNode
есть гораздо больше методов, которые находятся за пределами области видимости. Однако я настоятельно рекомендую ознакомиться с документацией по адресу https://crystal-lang.org/api/Crystal/Macros/TypeNode.html, чтобы узнать больше о том, какие дополнительные данные могут быть извлечены.
В последних нескольких главах мы рассмотрели различные концепции метапрограммирования, такие как макросы, аннотации, и то, как их можно использовать вместе, чтобы обеспечить самоанализ типов, методов и переменных экземпляра во время компиляции. Однако по большей части мы использовали их самостоятельно. Эти концепции также можно комбинировать, чтобы создавать еще более мощные шаборны! В этой главе мы собираемся изучить некоторые из них, в том числе:
• Использование аннотаций для влияния на логику времени выполнения.
• Представление данных аннотаций/типов во время выполнения.
• Определение значения константы во время компиляции.
• Создание собственных ошибок времени компиляции.
К концу этой главы вы должны иметь более глубокое понимание метапрограммирования в Crystal. У вас также должны быть некоторые идеи о неочевидных вариантах использования метапрограммирования, которые позволят вам создавать уникальные решения проблем в вашем приложении.
Прежде чем мы углубимся в эту главу, в вашей системе должно быть установлено следующее:
• Рабочая установка Crystal.
Инструкции по настройке Crystal можно найти в Главе 1 «Введение в Crystal».
Все примеры кода, использованные в этой главе, можно найти в папке Chapter13 на GitHub: https://github.com/PacktPublishing/Crystal-Programming/tree/main/Chapter13.
Как мы узнали в Главе 11 «Введение в аннотации», аннотации — это отличный способ добавить дополнительные метаданные к различным функциям Crystal, таким как типы, переменные экземпляра и методы. Однако одним из их основных ограничений является то, что хранящиеся в них данные доступны только во время компиляции.
В некоторых случаях вам может потребоваться реализовать функцию с использованием аннотаций для настройки чего-либо, но логика, требующая этих данных, не может быть сгенерирована только с помощью макросов и должна выполняться во время выполнения. Например, предположим, что мы хотим иметь возможность печатать экземпляры объектов в различных форматах. Эта логика может использовать аннотации, чтобы отметить, какие переменные экземпляра следует предоставлять, а также настроить способ их форматирования. Высокоуровневый пример этого будет выглядеть так:
annotation Print; end
class MyClass
include Printable
@[Print]
property name : String = "Jim"
@[Print(format: "%F")]
property created_at : Time = Time.utc
@[Print(scale: 1)]
property weight : Float32 = 56.789
end
MyClass.new.print
Результатом этого может быть следующее:
---
name: Jim
created_at: 2021-11-16
weight: 56.8
---
Чтобы реализовать это, логика печати должна иметь доступ как к данным аннотации, так и к значению переменной экземпляра, которая должна быть напечатана. В нашем случае модуль
Printable
позаботится об этом, определяя метод, который обрабатывает итерацию и печатает каждую применимую переменную экземпляра. В конечном итоге это будет выглядеть так:
module Printable
def print(printer)
printer.start
{% for ivar in @type.instance_vars.select(&.annotation Print) %}
printer.ivar({{ivar.name.stringify}},
@{{ivar.name.id}},
{{ivar.annotation(Print).named_args.double_splat}})
{% end %}
printer.finish
end
def print(io : IO = STDOUT)
print IOPrinter.new(io)
end
end
Большая часть логики выполняется в методе
#print(printer)
. Этот метод напечатает начальный шаблон, которым в данном случае являются три тире. Затем он использует макрос цикла for
для перебора переменных экземпляра включающего типа. Переменные экземпляра фильтруются таким образом, что включаются только те, у которых есть аннотация Print
. Затем для каждой из этих переменных вызывается метод #ivar
на принтере с именем и значением переменной экземпляра, а также любых именованных аргументов, определенных в аннотации. Наконец, он печатает конечный образец, который также состоит из трех тире.
Для поддержки предоставления значений из аннотации мы также используем метод
NamedTupleLiteral#double_splat
вместе с Annotation#named_ args
. Эта комбинация предоставит любые пары ключ/значение, определенные в аннотации, в качестве именованных аргументов для вызова метода.
Метод
#print(io
) служит основной точкой входа для печати экземпляра. Он позволяет предоставить пользовательский I/O, на который должны выводиться данные, но по умолчанию это STDOUT
. I/O используется для создания другого типа, который фактически выполняет печать:
struct IOPrinter
def initialize(@io : IO); end
def start
@io.puts "---"
end
def finish
@io.puts "---"
@io.puts
end
def ivar(name : String, value : String)
@io << name << ": " << value
@io.puts
end
def ivar(name : String, value : Float32, *, scale :
Int32 = 3)
@io << name << ": "
value.format(@io, decimal_places: scale)
@io.puts
end
def ivar(name : String, value : Time, *, format : String
= "%Y-%m-%d %H:%M:%S %:z")
@io << name << ": "
value.to_s(@io, format)
@io.puts
end
end
Этот тип определяет начальный и конечный методы, а также перегрузку для каждого из поддерживаемые типы переменных экземпляра, каждый из которых имеет определенные значения и значения по умолчанию, связанные с этим тип. Используя отдельный тип с перегрузками, мы можем раньше отловить по ним ошибки. являются ошибками времени компиляции, например, если вы использовали аннотацию для неподдерживаемого введите или не указал значение в аннотации для обязательного аргумента. Этот пример показывает, насколько гибкими и мощными могут быть аннотации Crystal в сочетании с другими понятиями, такими как композиция и перегрузки. Однако бывают случаи, когда вы можете захотеть отделить логику от самого типа, например, чтобы сохранить вещи слабо связанный.
В следующем разделе мы рассмотрим, как мы можем сделать шаг вперед в том, что мы уже узнали, разрешив использование данных аннотаций/типов во время выполнения, чтобы их можно было использовать по мере необходимости.
Как мы закончили в предыдущем разделе, предоставление данных аннотации за пределами самого типа может быть хорошим способом сделать вещи менее связанными. Эта концепция фокусируется на определении структуры, которая представляет параметры связанной аннотации, а также другие метаданные, относящиеся к элементу, к которому была применена аннотация.
Если структура, представляющая данные аннотации, имеет обязательные параметры, которые, как ожидается, будут предоставлены через аннотацию, программа не будет компилироваться, если эти значения не будут предоставлены. Он также обрабатывает случай, когда параметры имеют значение по умолчанию. Кроме того, если в аннотации есть неожиданное поле или аргумент неправильного типа, она также не будет скомпилирована. Это значительно упрощает добавление / удаление свойств из структуры, поскольку все они не должны быть явно заданы в
StringLiteral
.
В настоящее время существует Crystal RFC, который предлагает сделать этот шаблон более встроенной функцией, сделав аннотацию и структуру одним и тем же. См. https://github.com/crystal-lang/crystal/issues/9802 для получения дополнительной информации.
Есть несколько способов фактически раскрыть структуры:
• Определите метод, который возвращает их массив.
• Определите метод, который возвращает хэш, который предоставляет их по имени переменной экземпляра.
• Определите метод, который принимает имя переменной экземпляра и возвращает его.
У каждого из этих подходов есть свои плюсы и минусы, но все они имеют что-то общее. В самом экземпляре/типе должна быть какая-то точка входа, которая предоставляет данные. Основная причина этого заключается в том, что переменные экземпляра можно повторять только в контексте метода.
Кроме того, существует два основных способа обработки самих структур. Один из вариантов — сделать метод методом экземпляра и включить значение каждой переменной экземпляра в структуру. У этого подхода есть несколько недостатков, например, его сложнее запомнить и он не очень хорошо обрабатывает обновления. Например, вы вызываете метод и получаете структуру для данной переменной экземпляра, но затем значение этой переменной экземпляра изменяется до того, как будет выполнена фактическая логика. Значение в структуре может представлять только значение на момент вызова метода.
Другой подход — сделать метод лениво инициализируемым запоминаемым методом класса. Этот подход идеален, потому что:
1. Он создает хэш/массив только для типов, которые используются вместо каждого типа/экземпляра.
2. Он кэширует структуры, поэтому их нужно создать только один раз.
3. Это имеет больше смысла, поскольку большая часть данных будет относиться к данному типу, а не к экземпляру этого типа.
Для целей этого примера мы собираемся создать модуль, который определяет лениво инициализированный метод класса, который будет возвращать хеш свойств этого типа. Но прежде чем мы это сделаем, давайте подумаем, какие данные мы хотим хранить в нашей структуре. Чаще всего структура представляет переменную экземпляра вместе с данными из примененной к ней аннотации. В этом случае наша структура будет иметь следующие поля:
1.
name
– название объекта недвижимости.
2.
type
– тип объекта недвижимости.
3.
class
– класс, частью которого является свойство.
4.
priority
– необязательное числовое значение из аннотации.
5.
id
– необходимое числовое значение из аннотации.
Конечно, то, какие данные вам нужны, во многом зависит от конкретного варианта использования, но, как правило, имя, тип и класс полезно иметь во всех случаях. Тип может быть, например, типом переменной экземпляра или типом возвращаемого значения метода.
Мы можем использовать макрос
record
, чтобы упростить создание нашей структуры. В конечном итоге это будет выглядеть так:
abstract struct MetadataBase; end
record PropertyMetadata(ClassType, PropertyType, Propertyldx)
< MetadataBase,
name : String,
id : Int32,
priority : Int32 = 0 do
def class_name : ClassType.class
ClassType
end
def type : PropertyType.class
PropertyType
end
end
Мы используем дженерики, чтобы указать тип класса и переменную экземпляра. У нас также есть еще одна универсальная переменная, с которой мы вскоре разберемся. Мы представили эти дженерики как методы, поскольку универсальные типы уже будут ограничены каждым экземпляром, и поэтому нет необходимости также хранить их как переменные экземпляра.
У каждой записи будет имя, и мы также добавили к ней два дополнительных свойства. Поскольку значение
priority
является необязательным, мы установили для него значение по умолчанию, равное 0
, тогда как идентификатор является обязательным, поэтому у него нет значения по умолчанию.
Далее нам нужно создать модуль, который будет создавать и предоставлять хеш метаданных свойств. Мы можем использовать некоторые концепции макросов, которые мы изучили несколько глав назад, такие как макроперехваты и дословное выполнение. В конечном итоге этот модуль будет выглядеть так:
annotation Metadata; end
module Metadatable
macro included
class_property metadata : Hash(String, MetadataBase) do
{% verbatim do %}
{% begin %}
{
{% for ivar, idx in @type.instance_vars.select &.
annotation Metadata %}
{{ivar.name.stringify}} => (PropertyMetadata(
{{@type}}, {{ivar.type.resolve}},{{idx}}
).new({{ivar.name.stringify}},
{{ivar.annotation(Metadata).named_args
.double_splat}}
)),
{% end %}
} of String => MetadataBase
{% end %}
{% end %}
end
end
end
Мы также используем блочную версию макроса
class_getter
для определения ленивого метода получения. Включенный хук используется для того, чтобы гарантировать, что метод получения определен внутри класса, в который включен модуль. Функции дословного макроса и начала также используются для обеспечения выполнения кода дочернего макроса в контексте включающего типа, а не самого модуля.
Фактическая логика макроса довольно проста и делает многое из того, что мы делали в предыдущем разделе. Однако в этом примере мы также передаем некоторые общие значения при создании экземпляра нашего экземпляра
PropertyMetadata
.
На этом этапе наша логика готова к испытанию. Создайте класс, включающий модуль и некоторые свойства, использующие аннотацию, например:
class MyClass
include Metadatable
@[Metadata(id: 1)]
property name : String = "Jim"
@[Metadata(id: 2, priority: 7)]
property created_at : Time = Time.utc
property weight : Float32 = 56.789
end
pp MyClass.metadata["created_at"]
Если бы вы запустили эту программу, вы бы увидели, что она выводит экземпляр
PropertyMetadata
со значениями из аннотации и самой переменной экземпляра, установленными правильно. Однако есть еще одна вещь, с которой нам нужно разобраться; как мы можем получить доступ к значению связанного экземпляра метаданных? Именно это мы и собираемся исследовать дальше.
Малоизвестный факт об обобщениях заключается в том, что в качестве значения универсального аргумента можно также передать число. В первую очередь это сделано для поддержки типа
StaticArray
, который использует синтаксис StaticArray(Int32, 3)
для обозначения статического массива из трех значений Int32
.
Как упоминалось ранее, наш тип
PropertyMetadata
имеет третью универсальную переменную, которой мы присваиваем индекс связанной переменной экземпляра. Основной вариант использования этого заключается в том, что мы можем затем использовать это для извлечения значения, которое представляет экземпляр метаданных, в сочетании с другим трюком.
Если вам интересно, нет, нет способа волшебным образом получить значение из воздуха только потому, что у нас есть индекс переменной экземпляра и
TypeNode
типа, которому оно принадлежит. Для извлечения нам понадобится реальный экземпляр MyClass
. Чтобы учесть это, нам нужно добавить в PropertyMetadata
несколько дополнительных методов:
def value(obj : ClassType)
{% begin %}
obj.@{{ClassType.instance_vars[PropertyIdx].name.id}}
{% end %}
end
def value(obj) i : NoReturn
raise "BUG: Invoked default value method."
end
Другая хитрость, которая делает эту реализацию возможной, — это возможность прямого доступа к переменным экземпляра типа, даже если у них нет метода получения через синтаксис
obj.@ivar_name
. В предисловии к этому я скажу, что вам не следует использовать это часто, если вообще когда-либо, за исключением очень специфических случаев использования, таких как этот. Это антишаблон, и его следует избегать, когда это возможно. В 99% случаев вам следует вместо этого определить метод получения, чтобы вместо этого предоставить значение переменной экземпляра.
С учетом вышесказанного реализация использует индекс переменной экземпляра для доступа к ее имени и использования его для создания предыдущего синтаксиса. Поскольку все это происходит во время компиляции, фактический метод, который добавляется, например, для переменной экземпляра name, будет выглядеть следующим образом:
def value(obj : ClassType)
obj.@name
end
Мы также определили еще одну перегрузку, которая вызывает исключение, если вы передаете экземпляр объекта, тип которого отличается от типа, представленного экземпляром метаданных. В основном это делается для того, чтобы компилятор был доволен, когда существует более одного типа
Metadatable
. На практике этого никогда не должно происходить, поскольку конечный пользователь не будет напрямую взаимодействовать с этими экземплярами метаданных, поскольку это будет внутренней деталью реализации.
Мы можем пойти дальше и опробовать это, добавив в нашу программу следующее и запустив ее:
my_class = MyClass.new
pp MyClass.metadata["name"].value my_class
Вы должны увидеть значение свойства name, напечатанное на вашем терминале, которое в данном случае будет
"Jim"
. У этой реализации есть один недостаток. Тип значения, возвращаемого методом #value
, будет состоять из объединения всех свойств, имеющих аннотацию данного типа. Например, typeof(name_value)
вернет (String | Time)
, что в целом приводит к менее эффективному представлению памяти.
Этот шаблон отлично подходит для реализации мощных внутренних API, но его следует использовать с осторожностью, не использовать в «горячем» пути приложения и даже не публиковать публично.
Если вы помните Главу 9 «Создание веб-приложения с помощью Athena», где вы применяли аннотации ограничений проверки, компонент Validator Athena реализован с использованием этого шаблона, хотя и с несколько большей сложностью.
Конечно, это, скорее всего, не тот шаблон, который вам понадобится очень часто, если вообще когда-либо понадобится, но полезно знать, если такая необходимость когда-нибудь возникнет. Это также хороший пример того, насколько мощными могут быть макросы, если вы мыслите немного нестандартно. В качестве дополнительного бонуса мы можем еще раз продвинуть эту модель на шаг дальше.
В предыдущем разделе мы рассмотрели, как можно использовать структуру для представления определенного элемента, например переменной экземпляра или метода, вместе с данными из примененной к нему аннотации. Другой шаблон предполагает создание специального типа для хранения этих данных вместо непосредственного использования массива или хеша. Этот шаблон может быть полезен для отделения метаданных о типе от самого типа, а также для добавления дополнительных методов/свойств без необходимости засорять фактический тип.
Чтобы это работало, вам нужно иметь возможность перебирать свойства и создавать хэш или массив внутри конструктора другого типа. Несмотря на то, что существует ограничение на чтение переменных экземпляра типа, оно не означает, что это должен быть метод внутри самого типа. Учитывая, что конструктор — это всего лишь метод, который возвращает
self
, это не будет проблемой. Несмотря на это, нам все равно нужна ссылка на TypeNode
интересующего нас типа.
Поскольку макросы имеют доступ к общей информации, даже в контексте метода мы можем заставить этот тип
ClassMetadata
принимать аргумент универсального типа, чтобы передать ссылку на TypeNode
. Кроме того, мы могли бы продолжать передавать общий тип другим типам/методам, которым он нужен.
Например, используя тот же тип
PropertyMetadata
, что и в последнем разделе:
annotation Metadata; end
annotation ClassConfig; end
class ClassMetadata(T)
def initialize
{{@type}}
{% begin %}
@property_metadata = {
{% for ivar, idx in T.instance_vars.select &.
annotation Metadata %}
{{ivar.name.stringify}} => (
PropertyMetadata({{@type}}, {{ivar.type.resolve}},
{{idx}}).new({{ivar.name.stringify}},
{{ivar.annotation(Metadata).named_args
.double_splat}})
),
{% end %}
} of String => MetadataBase
@name = {{(ann = T.annotation(ClassConfig)) ?
ann[:name] : T.name.stringify}}
{% end %}
end
getter property_metadata : Hash(String, MetadataBase)
getter name : String
end
Модуль
Metadatatable
теперь выглядит так:
module Metadatable
macro included
class_getter metadata : ClassMetadata(self)
{ ClassMetadata(self).new }
end
end
Большая часть логики такая же, как и в предыдущем примере, за исключением того, что вместо прямого возврата хеша метод
.metadata
теперь возвращает экземпляр ClassMetadata
, который предоставляет хеш. В этом примере мы также представили еще одну аннотацию, чтобы продемонстрировать, как предоставлять данные, когда аннотацию можно применить к самому классу, например настройку имени с помощью @[ClassConfig(name: "MySpecialName")]
.
В следующем разделе мы рассмотрим, как можно использовать макросы и константы вместе для регистрации вещей, которые можно будет использовать/перебирать в более поздний момент времени.
Константы в Crystal постоянны, но не заморожены. Другими словами, это означает, что если вы определите константу как массив, вы не сможете изменить ее значение на
String
, но вы можете вставлять/извлекать значения в/из массива. Это, в сочетании с возможностью макроса получать доступ к значению константы, приводит к довольно распространенной практике использования макросов для изменения констант во время компиляции, чтобы впоследствии значения можно было использовать/перебирать в готовом перехватчике.
С появлением аннотаций этот шаблон уже не так полезен, как раньше. Тем не менее, это все равно может быть полезно, если вы хотите предоставить пользователю возможность влиять на некоторые аспекты вашей макрологики, и нет места для применения аннотации. Одним из основных преимуществ этого подхода является то, что его можно вызвать в любом месте исходного кода и при этом применить, в отличие от аннотаций, которые необходимо применять к связанному элементу.
Например, скажем, нам нужен способ регистрации типов во время компиляции, чтобы можно было разрешать их по имени строки во время выполнения. Чтобы реализовать эту функцию, мы определим константу как пустой массив и макрос, который будет помещать типы в константу массива во время компиляции. Затем мы обновим логику макроса, чтобы проверить этот массив и пропустить переменные экземпляра с типами, включенными в массив. Первая часть реализации будет выглядеть так:
MODELS = [] of ModelBase.class
macro register_model(type)
{% MODELS << type.resolve %}
end
abstract class ModelBase
end
class Cat < ModelBase
end
class Dog < ModelBase
end
Здесь мы определяем изменяемую константу, которая будет содержать зарегистрированные типы, сами типы и макрос, который будет их регистрировать. Мы также вызываем
#resolve
для типа, переданного макросу, поскольку типом аргумента макроса будет Path
. Метод #resolve
преобразует путь в TypeNode
, который представляет собой типы переменных экземпляра. Метод #resolve
необходимо использовать только в том случае, если тип передается по имени, например, в качестве аргумента макроса, тогда как макропеременная @type
всегда будет TypeNode
.
Теперь, когда у нас определена сторона регистрации, мы можем перейти к стороне времени выполнения. Эта часть представляет собой просто метод, который генерирует оператор
case
, используя значения, определенные в константах MODELS
, например:
def model_by_name(name)
{% begin %}
case name
{% for model in MODELS %}
when {{model.name.stringify}} then {{model}}
{% end %}
else
raise "model unknown"
end
{% end %}
end
Отсюда мы можем пойти дальше и добавить следующий код:
pp {{ MODELS }}
pp model_by_name "Cat"
register_model Cat
register_model Dog
pp {{ MODELS }}
pp model_by_name "Cat"
После его запуска вы увидите следующее, напечатанное на вашем терминале:
[]
Cat
[Cat, Dog]
Cat
Мы видим, что первый массив пуст, поскольку ни один тип не был зарегистрирован, хотя строка
“Cat"
может быть успешно разрешена, даже если после нее зарегистрирован связанный тип. Причина этого в том, что регистрация происходит во время компиляции, а разрешение — во время выполнения. Другими словами, регистрация модели происходит до того, как программа начнет выполняться, независимо от того, в каком месте исходного кода зарегистрированы типы.
После регистрации двух типов мы видим, что массив
MODELS
содержит их. Наконец, это еще раз показывает, что его можно было разрешить при вызове до или после регистрации связанного типа. Как упоминалось ранее в этой главе, макросы не имеют такой же типизации, как обычный код Crystal. Из-за этого к макросам невозможно добавлять ограничения типов. Это означает, что пользователь может передать в макрос .register_model
все, что пожелает, что может привести к не столь очевидным ошибкам. Например, если они случайно передали "Time"
вместо Time
, это приведет к следующей ошибке: неопределенный метод макроса 'StringLiteral#resolve'
. В следующем разделе мы собираемся изучить способ сделать источник ошибки более очевидным.
Ошибки времени компиляции — одно из преимуществ компилируемого языка. Вы сразу же узнаете о проблемах, вместо того, чтобы ждать, пока этот код будет выполнен, чтобы обнаружить ошибку. Однако, поскольку Crystal не знает контекста конкретной ошибки, он всегда будет выводить одно и то же сообщение об ошибке одного и того же типа. Последняя функция, которую мы собираемся обсудить в этой главе, связана с выдачей ваших собственных ошибок во время компиляции.
Пользовательские ошибки времени компиляции могут быть отличным способом добавить дополнительную информацию к сообщению об ошибке, что значительно облегчает жизнь конечному пользователю, поскольку ему становится понятнее, что необходимо сделать для устранения проблемы. Возвращаясь к примеру в конце последнего раздела, давайте обновим наш макрос
.exclude_type
, чтобы обеспечить лучшее сообщение об ошибке в случае передачи неожиданного типа.
В последних нескольких главах мы использовали различные макрометоды верхнего уровня, такие как
#env
, #flag
и #debug
. Другой метод верхнего уровня — #raise
, который вызывает ошибку во время компиляции и позволяет предоставить собственное сообщение. Мы можем использовать это с некоторой условной логикой, чтобы определить, не является ли значение, переданное нашему макросу, Path
. Наш обновленный макрос будет выглядеть так:
macro exclude_type(type)
{% raise %(Expected argument to 'exclude_type' to be
'Path', got '#{type.class_name.id}'.) unless type.is_a?
Path %}
{% EXCLUDED_TYPES << type.resolve %}
end
Теперь, если бы мы вызвали макрос с
"Time"
, мы бы получили ошибку:
In mutable_constants.cr:43:1
43 | exclude_type "Time"
^-----------
Error: Expected argument to 'exclude_type' to be 'Path', got 'StringLiteral'.
Помимо отображения нашего специального сообщения, он также выделяет вызов макроса, вызвавший ошибку, и показывает номер строки. Однако есть кое-что, что мы можем сделать, чтобы еще больше улучшить эту ошибку.
Все типы макросов, с которыми мы работали, произошли от базового типа макроса
ASTNode
, который предоставляет базовые методы, общие для всех узлов, откуда и берет свое начало метод #id
, который мы использовали несколько раз. Этот тип также определяет свой собственный метод #raise
, который работает так же, как и метод верхнего уровня, но выделяет конкретный узел, на котором он был вызван.
Мы можем реорганизовать нашу логику, чтобы использовать это, используя
type.raise
вместо простого повышения. К сожалению, в этом случае результирующая подсветка ошибок такая же. В Crystal есть несколько серьезных ошибок, связанных с этим, так что, надеюсь, со временем ситуация улучшится. Тем не менее, следовать этой практике по-прежнему рекомендуется, поскольку она не только дает читателю более ясное представление о том, что такое недопустимое значение, но также делает код пригодным для будущего.
Обобщенные шаблоны в Crystal обеспечивают хороший способ уменьшения дублирования, позволяя параметризовать тип для поддержки его использования с несколькими конкретными типами. Хорошим примером этого могут быть типы
Array(T)
или Hash(K, V)
. Однако обобщенные типы Crystal в настоящее время не предоставляют встроенного способа ограничения типов, с помощью которых может быть создан универсальный тип. Возьмем, к примеру, следующий код:
abstract class Animal
end
class Cat < Animal
end
class Dog < Animal
end
class Food(T)
end
Food(Cat).new
Food(Dog).new
Food(Int32).new
В этом примере имеется общий тип еды, который должен принимать только подкласс
Animal
. Однако по умолчанию вполне нормально иметь возможность создавать экземпляр Food
, используя тип, отличный от Animal
, например Int32
. Мы можем использовать специальную ошибку времени компиляции в конструкторе Food
, чтобы гарантировать, что T
является дочерним элементом Animal
. В конечном итоге это будет выглядеть так:
class Food(T)
def self.new
{% raise "Non animal '#{t}' cannot be fed." unless T <=
Animal %}
end
end
В этом новом коде попытка выполнить
Food(Int32).new
вызовет ошибку во время компиляции.
Возможность определять собственные ошибки времени компиляции может существенно сократить время, необходимое для отладки проблемы. В противном случае неопределенные ошибки могут быть дополнены дополнительным контекстом/ссылками и в целом станут более удобными для пользователя.
Ура! Мы подошли к концу части книги, посвященной метапрограммированию, рассмотрели много нового и продемонстрировали, насколько мощными могут быть макросы Crystal. Я надеюсь, что вы сможете применить свое более глубокое понимание макросов и этих шаблонов для решения сложных задач, с которыми вы можете столкнуться в рамках ваших будущих проектов.
В следующей части мы рассмотрим различные инструменты поддержки Crystal, например, как тестировать, документировать и развертывать ваш код, а также как автоматизировать этот процесс!