Регистрация | Войти
Lisp — программируемый язык программирования

Fare Rideau. Использовать EVAL-WHEN вредно для вашего психического здоровья. (перевод: Кальянов Дмитрий).

Оригинальная публикация: http://fare.livejournal.com/146698.html.

Fare Rideau. Использовать EVAL-WHEN вредно для вашего психического здоровья

В CL код, изначально представленный в виде исходного текста, прежде чем будет исполнен в виде программы, обрабатывается в несколько стадий вычисления (которые могут быть разделены во времени, проходить одновременно или же чередоваться): чтение кода (read), раскрытие макросов (macro expansion), компиляция (compilation), загрузка (loading), исполнение (execute). За первые два этапа отвечают читатель лиспа (lisp reader) и макросы. Форма EVAL-WHEN позволяет указывать, в какой из трех стадий будет выполняться код (компиляция, загрузка, исполнение).

Чередование стадий вычисления происходит от того, что код обрабатывается последовательно, форма за формой; каждая форма верхнего уровня (top-level form) проходит стадии обработки кода, и только затем читается следующая форма. Это дает возможность производить какие-либо побочные эффекты, которые могут повлиять на обработку следующей формы. Например, если файл компилируется с помощью compile-file, то каждая форма проходит следующие стадии: чтение, раскрытие макросов, компиляция, и только при вызове load для скомпилированного fasl'а будут произведены эффекты времени загрузки; если файл загружается с помощью load, то каждая форма проходит через стадии: чтение, раскрытие макросов, компиляция, загрузка; если формы набираются в REPL, то форма проходит все стадии от чтения до исполнения. Поэтому, в зависимости от способа ввода кода (ввод в REPL; загрузка с помощью LOAD; компиляция и загрузка с помощью (LOAD (COMPILE-FILE ..)); вызов EVAL или COMPILE для формы), эффекты от него могут быть различными, так как побочные эффекты от разных форм будут наступать в разное время (чаще всего, разница будет в том, что будут ошибки компиляции либо загрузки).

(Примечание: приведем примеры, когда происходят какие эффекты. Формы defpackage, in-package производят побочные эффекты (соответственно, defpackage создает пакет, а in-package изменяет значение переменной *package*) на стадиях компиляции и загрузки, поэтому во время компиляции файла компилятор уже имеет созданный пакет, и символы будут читаться в указанный пакет. Форма defun производит свой основной побочный эффект (определение функции) во время компиляции - поэтому при компиляции файла макросы не видят функции, определенные в этом же файле.)

Самая первая стадия обработки кода - это чтение. Из потока текста (фанаты Интерлиспа и Смоллтока скажут, что это - неудачный выбор спецификации) читается лисповый код, и возвращается в виде CONS-ячеек, содержащих S-выражения (фанаты PLT Scheme скажут, что это тоже неудачный выбор спецификации). Во время чтения может выполняться код, определяемый выражениями #. и текущей таблицей чтения (*READTABLE*). Это дает возможность (хотя и довольно неудобную) компилировать код, записанный каким-либо другим синтаксисом (см., например, http://kpreid.livejournal.com/14713.html).

Вторая стадия обработки кода (сразу после чтения формы) - раскрытие макросов. То, как проходит раскрытие макросов, определяется макросами, определенными через DEFMACRO, DEFINE-SYMBOL-MACRO и их лексическими вариантами MACROLET, SYMBOL-MACROLET, а также макросами, определенными с помощью DEFINE-SETF-EXPANDER и DEFINE-MODIFY-MACRO, макросами компиляции DEFINE-COMPILER-MACRO и динамической переменной *MACROEXPAND-HOOK*. Макросы лиспа являются одновренно и всемогущими (в принципе, способны осуществить любой преобразование кода), но также ничего не знающими (так как не могут анализировать окружающий лексический контекст, не прибегая к реализации полного code-walker'а для CL или к расширениям стандарта (примечание: в CLtL2 определены функции для анализа лексического контекста, но в CL они не включены; в ряде реализаций они присутствуют, например, в пакете SB-CLTL2)). Вследствие этого появляются неудобства, связанные с отсутствием гигиены, сложностью отслеживания ошибок, но, что самое важное, становится невозможно описывать нелокальные преобразования кода модульным образом, не прибегая к переписыванию системы обработки кода или к управлению ей (но это тоже проблематично: так как *MACROEXPAND-HOOK* не вызывается для специальных и обычных форм, то необходимо модифицировать читатель, чтобы можно было обрабатывать все формы, не заставляя пользователя оборачивать каждую форму в какой-нибудь "волшебный" макрос-обертку).

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

Если код вводится в REPLе или с помощью LOAD загружается исходный текст или с помощью EVAL либо вычисляется форма, то код проходит только стадию исполнения (и не проходит стадии компиляции или загрузки). Если встречается EVAL-WHEN с параметром :EXECUTE, то он превращается просто в PROGN, и иначе в NIL. Это же может происходить вперемешку с раскрытием макросов; например, SBCL может начать вычислять выражение (when nil (foo)) и вернуть nil, не раскрывая макрос (foo); поэтому, если ожидалось выполнения побочных эффектов от этого макроса, их не будет (мы тоже этому удивились, когда тестировали ASDF-DEPENDENCY-GROVEL).

Если вы компилирует код с помощью COMPILE, то этот код будет исполнен во время стадии исполнения (:EXECUTE), поэтому если он содержит EVAL-WHEN, то он ведет себя аналогично предыдущему случаю. Так как компилируемый код всегда является функцией (именованной или безымянной), то в этом коде нет формы верхнего уровня (toplevel form), поэтому указание стадий :COMPILE-TOPLEVEL и :LOAD-TOPLEVEL не имеет смысла и игнорируется. Если я правильно понимаю, то компилятор может не раскрывать макросы, если он может статически доказать, что они находятся в недостижимом коде; однако на практике компиляторы работают в несколько проходов, и макросы раскрываются полностью, прежде чем код анализируется на наличие недостижимых частей кода.

Иная ситуация наблюдается, когда EVAL-WHEN встречается в коде, который сперва компилируется с помощью COMPILE-FILE, и затем полученный FASL загружается с помощью LOAD. В этом случае, каждая форма после раскрытия макросов обрабатывается таким образом, что отделяются побочные эффекты, которые происходят во время компиляции от эффектов, происходящих во время загрузки. Если указать :COMPILE-TOPLEVEL в EVAL-WHEN, то побочные эффекта кода, заключенного в EVAL-WHEN, будут происходить во время компиляции (т.е., в текущем образе, а также сохранятся в CFASL (которые поддерживаются с SBCL-1.0.30.4) и будет воспроизведены при загрузке указанного CFASL). Если указать :LOAD-TOPLEVEL, то побочные эффекты кода будут происходить во время загрузки (т.е., они сохраняются в FASL и произойдут при загрузке FASL, но они не будут происходить в текущем образе, если также не указана стадия :COMPILE-TOPLEVEL). Некоторые специальные формы имеют побочные эффекты как во время компиляции, так и во время загрузки, например IN-PACKAGE, которая меняет текущий пакет (*PACKAGE*) во время компиляции и во время загрузки; DEFVAR объявляет переменную специальной как во время компиляции (в текущем образе), так и во время загрузки (в том образе, в который будет загружаться FASL), а также устанавливает значение во время загрузки. Указание :EXECUTE для форм верхнего уровня игнорируется (но во вложенном EVAL-WHEN имеет смысл использовать только :EXECUTE).

На практике, стоит запомнить, что единственная безопасная и полезная комбинация параметров - это (EVAL-WHEN (:COMPILE-TOPLEVEL :LOAD-TOPLEVEL :EXECUTE) ...), в который следует заворачивать вещи, которые должны быть доступны во время компиляции и во время работы кода такие: например, объявления функций, переменных и побочных эффектов, которые используются макросами.

Использовать (:LOAD-TOPLEVEL :EXECUTE) безопасно, но любая форма верхнего уровня уже неявно обернута в (EVAL-WHEN (:LOAD-TOPLEVEL :EXECUTE) ..), поэтому использовать эту комбинацию не имеет смысла (за исключением ситуации, когда форма расположена внутри EVAL-WHEN с другими параметрами).

Другая безопасная комбинация параметров - (:COMPILE-TOPLEVEL :EXECUTE), но польза от нее ограничена. Ее можно использовать для того, чтобы побочные эффекты от выполнения кода были только в среде компиляции; например, изменение таблицы чтения (readtable). Но если такой побочный эффект произойдет во время компиляции файла и сохранится в сеансе работы (например, если изменять значение какой-либо переменной, для которой создаются локальные привязки во время компиляции, например *READTABLE*, то изменения не сохранятся после компиляции), то во время загрузки скомпилированного FASLа этого изменения может не быть (если FASL загружен из другого сеанса), что может создать непонятные проблемы при компиляции и сборке программ. Недетерминированные действия во время компиляции (например, использование файловой системы) - это плохой вкус. Если требуется вычислить что-либо детерминированно, то это можно сделать и во время чтения, а если недетерминированно, то стоит отложить вычисления на более позднее время (например, провести вычисления во время сохранения образа). Один из разумных вариантов использования (:COMPILE-TOPLEVEL :EXECUTE) - это сохранение побочных эффектов времени компиляции, когда для сборки используется XCVB с поддержкой механизма CFASL (который поддерживается в SBCL >= 1.0.30.4); при этом гарантируется, что при компиляции всех файлов, которые зависят от данного файла, эти побочные эффекты будут воспроизведены. В итоге, хотя использование (:COMPILE-TOPLEVEL :EXECUTE) безопасно, оно годится лишь для очень ограниченного числа случаев. Если вы не эксперт, то даже не пытайтесь.

Другие комбинации параметров EVAL-WHEN можно не рассматривать. Они бессмыслены, и имеют смысл разве что лишь гипотетически внутри низкоуровневого макроса оптимизации; всегда будет возможность загрузить код каким-либо образом, что побочные эффекты наступят неожиданно и приведут к неожиданным последствиям. У пользователя должна быть возможность, в зависимости от его нужд, компилировать и загружать код так, как он захочет - просто LOAD'ом, или же (LOAD (COMPILE-FILE ...)), или же загрузка FASLа в новый образ или же инкрементальная рекомпиляция с помощью ASDF - код всегда должен загружаться и работать предсказуемо.

Когда загружается FASL или CFASL, происходят все сохраненные в нем эффекты: в пакеты добавляются символы, вычисляются выражения для LOAD-TIME-VALUE, добавляются определения переменных, макросов и функций, любые другие побочные эффекты от toplevel-форм. При этом, побочные эффекты стадии чтения и стадии раскрытия макросов не считаются эффектами времени компиляции или загрузки, и поэтому не проявляются при загрузке FASL или CFASL. На самом деле, это даже полезно, так как это позволяет делать что-либо во время чтения кода или при раскрытии макросов, и эти вычисления не будут заново производиться при загрузке кода. Например, SBCL (и другие вменяемые реализации) не будут повторять эффекты времени раскрытия макросов при загрузке кода (хотя, гипотетически, можно представить такую реализацию). Но если ваши макросы совершают какие-то побочные эффекты, которые не должны пропасть после компиляции, то макросы должны не только производить эти эффекты, но и раскрываться в код, который производит те же побочные эффекты во время компиляции и/или загрузки (используя EVAL-WHEN). В качестве примера: когда я переводил крупный проект с ASDF на XCVB, пришлось отлаживать макрос, который вызывал (EVAL (DEFCLASS ...)) и FINALIZE-INHERITANCE во время раскрытия макроса, чтобы иметь возможность использовать MOP для анализа сгенерированного класса, но не включал DEFCLASS в раскрываемый код; в результате, при компиляции "с нуля", макрос работал, но не работал при загрузке из FASLов (используя инкрементальную компиляцию в ASDF) или при детерминированной сборке (используя XCVB), так как другие макросы в других файлах ожидали, что класс будет определен (чего не происходило при загрузке из FASLов).

EVAL-WHEN легко использовать неправильно, и на самом деле у которого есть только одно разумное применение (если использовать XCVB, то два). Важно понимать, в каких случаях EVAL-WHEN нужен - прежде всего для объявления функций и переменных, которые используются макросами. Если вам нравится использовать нетривиальное метапрограммирование, то рекомендую избегать такого примитивного в этом отношении языка, как Common Lisp, и использовать современный язык с многостадийной компиляцией и четко определенной семантикой (например, язык модулей PLT Scheme, camlp4 для OCaml, и другие системы, обрабатывающие файлы детерминированным образом). У макросов PLT есть ряд преимуществ: гигеничность, совместимость с отладчиками и другими инструментами и т.п.

@2009-2013 lisper.ru