Сообщений 5    Оценка 775        Оценить  
Система Orphus

Макросы в Nemerle

Автор: Владислав Чистяков
The RSDN Group

Источник: RSDN Magazine #2-2006
Опубликовано: 09.12.2006
Исправлено: 13.03.2007
Версия текста: 1.0
Квази-цитирование
Цитирование (quotation)
Квази-цитирование (quasi-quotation)
А причем тут Nemerle? :)
printf нового поколения
Выше гор могут быть только... птицы
Локализуемый сплайс-макрос
StringTemplate или мечты, мечты
Заключение

В прошлом номере журнала мы опубликовали перевод статьи «Метапрограммирование в Nemerle» Камила Скальски, Михала Москаля и Павла Ольшта. В ней в общих чертах рассказывается о том, что же такое макросы в Nemerle. Однако теоретические рассуждения далеко не всегда выдерживают испытание практикой. Единственный способ развеять скепсис — это попробовать. Я решил попробовать макросы Nemerle в более-менее реальной задаче. Целей две:

  1. Изучить собственно Nemerle и его макросы.
  2. Продемонстрировать его возможности.
ПРИМЕЧАНИЕ

Если вы читали сообщение «Изучаем макросы Nemerle» в форуме «Декларативное программирование» сайта RSDN, можете смело переходить ко второй части статьи, «printf».

Поскольку многие из вас захотят попробовать приводимый мною код, то начнем, пожалуй, с подготовительного этапа. Конечно, в реальных условиях многие из вас предпочтут полноценную IDE и какую-нибудь утилиту сборки (make, Ant или MSBuild), но, учитывая, что для данного случая скорее важна простота вхождения, я решил остановиться на Scintilla как редакторе и обычном batch-файле для сборки проекта. Ведь нам пока что достаточно компилировать два файла (сборку с макросами и целевую сборку).

Итак, для экспериментов вам понадобится:

  1. .NET Framework 2.0 (и возможно SDK). Лучше, конечно, если на машине просто установлена VS 2005.
  2. Компилятор Nemerle и сопутствующие ему файлы. Все это можно взять на сайте http://nemerle.org. Все необходимое для запуска примеров прилагается к статье.
  3. Scintilla или другой редактор, способный упростить ввод и тестирование кода. Scintilla можно взять здесь: http://prdownloads.sourceforge.net/scintilla/wscite167.zip?use_mirror=heanet

Файлы для обеспечения подсветки синтаксиса Nemerle для Scintilla можно взять здесь:

http://nemerle.org/svn/nemerle/trunk/misc/scintilla/SciTEGlobal.properties

http://nemerle.org/svn/nemerle/trunk/misc/scintilla/nemerle.properties

Их нужно скопировать в каталог, где лежат другие properties-файлы Scintilla.

  1. Для упрощения компиляции и запуска файлов я написал простенький cmd-файлик для ХР:
@echo off

SET FileName=%1

SET MacroOutFileName=%FileName%-macro.dll
SET MacroSourceFileName=%FileName%-macro.n

SET SourceFileName=%FileName%.n
SET OutFileName=%FileName%.exe

if exist %MacroSourceFileName% (
  @echo --# Compiling Macro-assembly #---
  @echo ---------------------------------
  ncc.exe -no-color -debug+ -r Nemerle.Compiler.dll -t:dll %MacroSourceFileName% -out %MacroOutFileName%
  if not errorlevel 1 (
    @echo --- Compiling target assembly ---
    @echo ---------------------------------
    ncc.exe -no-color  -debug+ -r %MacroOutFileName% -t exe %SourceFileName% -o %OutFileName%
    rem -r System.Windows.Forms -r System.Data 
    if errorlevel 1 (
      @echo -------------------------------------
      @echo !!! Error detected in targer code !!!
    ) else (
      @echo --------------- OK -----------------
      @echo --Run: %OutFileName%
      @echo .
      %OutFileName%
    )
  ) else (
    @echo ------------------------------------
    @echo !!! Error detected in macro-code !!!
  )
) else (
  @echo ----- No Macro-assembly found ------
  @echo ------------------------------------
  if not errorlevel 1 (
    @echo --- Compiling target assembly ---
    @echo ---------------------------------
    ncc.exe -no-color  -debug+ -t exe %SourceFileName% -o %OutFileName%
    rem -r System.Windows.Forms -r System.Data 
    if errorlevel 1 (
      @echo -------------------------------------
      @echo !!! Error detected in targer code !!!
    ) else (
      @echo --------------- OK -----------------
      @echo --- Run: %OutFileName%
      @echo .
      %OutFileName%
    )
  ) else (
    @echo ------------------------------------
    @echo --! Error detected in macro-code !--
  )
)

Этот файл нужно сохранить под именем build-and-run.cmd в каталог, где лежат бинарные файлы Nemerle (файл ncc.exe и т.п.). Далее нужно отредактировать файл nemerle.properties, добавив в него примерно такие строчки:

#Extra tool: Build With Macro and Run
#Build macro and target for console application"
command.name.6.*.n=Build With Macro and Run
command.6.*.n=build-and-run.cmd $(FileName)
command.6.subsystem.*.n=0
command.save.before.6.*.n=1
command.shortcut.6.*.n=F5

При этом нужно или изменить F5 на удобную для себя кнопку, или убрать/изменить аналогичное клавиатурное сокращение у имеющегося в nemerle.properties пункта. Теперь, открыв в Scintilla файл с расширением .n, вы можете просто нажатием на F5 скомпилировать его и запустить на выполнение.

Если при этом рядом с компилируемым файлом будет лежать файл, начальная часть имени которого совпадает с компилируемым, и оно оканчивается на «-macro», то сначала будет произведена компиляция этого файла. А потом, в случае успеха, будет откомплирован файл без суффикса «–macro». Причем при его компиляции будет подключаться макросборка, полученная на первом этапе. Допустим, что имеются файлы:

test-001.n
test-001-macro.n

Если активизировать в Scintilla файл test-001.n и нажать F5, то сначала будет произведена компиляция макросборки test-001-macro.dll (на базе файла test-001-macro.n), а потом – компиляция сборки test-001.exe (на базе файла test-001.n). При компиляции test-001.exe будет использована макро-сборка, полученная на первом этапе.

Если все скомпилируется, то будет запущен файл test-001.exe. Иначе будет выданы сообщения об ошибке.

Чтобы убедиться, что все работает, создайте простенькую тестовую пару файлов:

Test-001.n, содержащий следующий код:

// abstract, and, array, as, base, catch, class, def, delegate, do, else, 
// enum, event, extern, false, finally, for, foreach, fun, if, implements, 
// in, interface, internal, lock, macro, match, module, mutable, namespace, 
// new, null, out, override, params, 
// WriteLine public private protected internal

TestMacro();
ПРИМЕЧАНИЕ

Примечание: Эти комментарии нужны для того, чтобы можно было пользоваться недо-Intellisens-ом. Если в Scintilla написать первые буквы слова и нажать Ctrl+Enter, то она выдаст список слов, начинающихся на эти буквы.

И файл test-001-macro.n со следующим кодом:

using System.Console;
// abstract, and, array, as, base, catch, class, def, delegate, do, else,
// enum, event, extern, false, finally, for, foreach, fun, if, implements,
// in, interface, internal, lock, macro, match, module, mutable, namespace,
// new, null, out, override, params,  
// WriteLine public private protected internal

macro TestMacro()
{
  WriteLine("compile-time\n");
  <[ WriteLine("run-time\n") ]>;
}

Теперь откройте в Scintilla файл Test-001.n и нажмите F5. Если после этого вы увидите следующее, значит, вы все сделали верно, и первый этап знакомства с Nemerle и его макросами пройден:

>build-and-run.cmd test-001
--# Compiling Macro-assembly #---
---------------------------------
--- Compiling target assembly ---
---------------------------------
compile-time

--------------- OK -----------------
--Run: test-001.exe
.
run-time

>Exit code: 0
ПРИМЕЧАНИЕ

Если вы получаете сообщение о том, что файл не найден, пропишите путь к каталогу с бинарными файлами Nemerle в переменную окружения PATH.

Что же означает этот вывод? Строки:

---------------------------------
--- Compiling target assembly ---
---------------------------------
compile-time

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

run-time

вывел код, сгенерированный макросом. Если вы теперь откроете полученный исполняемый файл Reflector-ом, то увидите, что в функции Main() присутствует вызов:

Console.WriteLine("run-time\n");

а от макроса не осталось и следа. Это и есть чудесное действие макросов.

Квази-цитирование

Теперь я расскажу, что такое квази-цитирование (quasi-quotation). Квази-цитирование — это очень простая идея, но именно она делает макросы Nemerle-а мощнейшим, и в тоже время относительно безопасным инструментом по сравнению с текстуальными макросами С/С++.

Прежде чем разбираться с тем, что такое квази-цитирование, давайте сначала разберемся, что вообще в данном случае означает термин «цитирование».

Цитирование (quotation)

Компилятор преобразует текст программы сначала в список токенов, а потом в так называемое абстрактное синтаксическое дерево (Abstract Syntax Tree, AST). AST – это дерево, состоящее из объектов, описывающих код.

В AST каждый элемент синтаксиса представлен веткой AST-дерева. В общем-то, когда программист воспринимает код, то он тоже мысленно строит подобное дерево.

Я не специалист в AST Nemerle, так что продемонстрирую AST на базе AST R#-а. Вот как выглядит вот такой кусок кода:

if (a == 1)
  a = 2;

в AST R#-а:

RStatement statement = new RConditionStatement(new RBinaryOperatorExpression(
    new RUnresolvedReferenceExpression("a"), 
    RBinaryOperator.IdentityEquality, new RPrimitiveExpression("1")),
    new RExpressionStatement(new RAssignmentExpression(
    new RUnresolvedReferenceExpression("a"), RAssignmentOperator.Assign,
    new RPrimitiveExpression("1"))));

Переменная statement будет содержать AST-ветку описывающую if. Если теперь вызвать у statement метод ToString():

Console.WriteLine(statement.ToString())

то на консоль будет выведен текст:

if (a == 1)
  a = 2;

«Зачем такие сложности?» – спросят многие. А затем, что AST является объектной моделью, с которой намного удобнее работать программно, нежели с плоским текстом.

Преобразовав текст программы в AST, компилятор выполняет над AST множество действий. Например, он может преобразовать AST некоторой высокоуровневой конструкции вроде foreach в более низкоуровневую (например, в while). Компилятор также вычисляет множество семантических атрибутов. Так, он вычисляет типы переменных и выражений, производит их сопоставление и контроль, в общем, творит с AST все, что хочет. В итоге компилятор преобразует AST в промежуточное представление (например, в MSIL), а затем и в машинный код.

Зачем я рассказал обо все этом, спросите вы?

А затем, что цитирование (quotation) – это способ заставить компилятор преобразовать некоторый код в это самое AST. Мы, конечно, могли бы просто написать код, создающий AST (если бы компилятор предоставлял бы нам доступ хотя бы к нему), но это, как вы могли заметить, не очень приятное занятие.

Использование цитирования может резко упростить данный процесс. Вот как будет выглядеть получение AST, приведенного выше выражения (if) с использованием цитирования:

RStatement statement = <[
  if (a == 1)
    a = 2; ]>;

Не правда ли, значительно проще? Это синтаксис C#. Вернее гипотетического C#, в котором поддерживается цитирование. А вот как то же самое выглядит на Nemerle:

def expr = <[
    when (a == 1)
        a = 2; ]>;

Как видите, на Nemerle это выглядит даже проще (что не может не радовать).

Я использовал имя expr, так как в Nemerle if – это не предложение (statement) как в C#, а выражение (expression).

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

Фактически типом переменной expr является PExpr. Это можно указать явно:

def expr : Nemerle.Compiler.Parsetree.PExpr = <[
  when (a == 1)
      a = 2; ]>;
ПРИМЕЧАНИЕ

Естественно, что, как и в C#, указывать полный путь к типу не обязательно. Nemerle.Compiler.Parsetree можно было задать в using.

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

using System.Console;

macro TestMacro()
{
  WriteLine("compile-time");
  <[ WriteLine("run-time") ]>;
}

Что же мы видим в этом макросе? Два вызова метода WriteLine(), один просто внутри тела макроса, а второй – внутри «кавычек».

Так как макрос вызывается во время компиляции, то первый вызов WriteLine():

WriteLine("compile-time");

выведет строку "compile-time" в окно вывода компилятора. Важно понять, что это код, выполняемый во время компиляции. Я всего лишь вывел строчку в окно, но с тем же успехом я мог бы подсоединиться к БД и считать из нее метаинформацию. В общем, я волен делать в нем все что угодно.

Вторая строка кода не выполняется во время компиляции. Они заключена в «кавычки». Это приводит к тому, что компилятор преобразует ее в AST и выдает его нам.

Так как Nemerle ориентирован на выражения, в нем не нужно писать «return», чтобы возвратить значение функции. Последнее выражение в последовательности автоматически становится возвращаемым значением функции (в данном случае макроса TestMacro()). Если бы в C# поддерживались макросы и квази-цитирование, то этот код записывался бы на C# примерно так:

macro RExpr TestMacro()
{
  WriteLine("compile-time");
  return <[ WriteLine("run-time") ]>;
}

Думаю, вы уже без проблем сможете провести аналогии между C# и Nemerle. Так что оставлю вам самим задачу угадать, откуда взялся RExpr после ключевого слова macro. ;)

Квази-цитирование (quasi-quotation)

Теперь настала пора объяснить, что же означает приставка «квази». Изменим немного наш пример:

macro TestMacro(myName)
{
  WriteLine("Compile-time. myName = " + myName.ToString());
  <[ WriteLine("Run-time.\n Hello, " + $myName) ]>;
}

Заодно придется изменить и код применения макроса:

TestMacro("Vlad");
def a = "VladD2";
TestMacro(a);

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

>build-and-run.cmd test-001
--# Compiling Macro-assembly #---
---------------------------------
--- Compiling target assembly ---
---------------------------------
Compile-time. myName = "Vlad"
Compile-time. myName = a
--------------- OK -----------------
--Run: test-001.exe
.
Run-time.
 Hello, Vlad
Run-time.
 Hello, VladD2
>Exit code: 0

Разберемся с каждой строкой макроса по отдельности. Первое что изменилось в макросе –добавлен параметр «myName». Не думайте, что он – не типизированный. Его тип Nemerle выведет из дальнейшего использования. Чуть позже мы вернемся к вопросу о типе параметра этого макроса, а пока что разберем остальные строки.

В строке:

WriteLine("Compile-time. myName = " + myName.ToString());

мы выводим в окно компилятора значение, содержащееся в параметре myName, преобразовывая его в строку.

ПРИМЕЧАНИЕ

Все объекты Nemerle – это автоматически объекты .NET, так что не странно, что у них реализованы методы класса object.

Теперь поглядим, что же выводят эти строки. При вызове макроса следующим образом:

TestMacro("Vlad");

указанная строчка выводит:

Compile-time. myName = "Vlad"

А при таком вызове:

def a = "VladD2";
TestMacro(a);

мы получим:

Compile-time. myName = a

Почему же мы во втором случае не получили текстовое значение, помещенное в переменную «a»? Дело в том, что myName – это не строковая переменная, а переменная, содержащая AST-ветку, описывающую выражение, передаваемое в макрос. В первом случае нам был передан литерал, а во втором – переменная. Именно поэтому мы видим то, что видим.

Попробуем проверить мое предположение. Для этого добавим в макрос перед строчкой:

WriteLine("Compile-time. myName = " + myName.ToString());

еще одну строчку:

WriteLine(myName.GetType());

Она выведет реальный тип выражения. Выполним компиляцию и поглядим на результат:

---------------------------------
--- Compiling target assembly ---
---------------------------------
Nemerle.Compiler.Parsetree.PExpr+Literal
Compile-time. myName = "Vlad"
Nemerle.Compiler.Parsetree.PExpr+Ref
Compile-time. myName = a
...

Результат полностью подтверждает ожидания.

Остается разобраться, что же такое PExpr+Literal и PExpr+Ref.

Лезем в Reflector и видим, что это классы, вложенные в класс PExpr и являющиеся его наследниками, так физически реализуются variant-ы Nemerle. Другими словами, Literal и Ref являются значениями variant-а PExpr . Таким образом, мы можем сделать четкий вывод, что в макрос переданы AST-ветки, описывающие выражения Nemerle. Причем в первом случае это строковый литерал, а во втором – ссылка на переменную.

Теперь перейдем к строке макроса:

<[ WriteLine("Run-time.\n Hallo, " + $myName) ]>;

Эта строка и является конечной целью повествования этого раздела.

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

  1. Я не вызываю метод ToString().
  2. Перед именем параметра макроса стоит символ «$».

Возникает резонный вопрос – а почему, собственно?

Дело в том, что для предыдущих строк myName являлся переменной. А для цитируемого кода myName переменной являться не будет. Если я напишу:

<[ WriteLine("Run-time.\n Hallo, " + myName.ToString()) ]>;

то компиляция макроса пройдет «на ура», но при его применении я получу сообщения об ошибке:

...
test-001.n:4:1:4:10: error: unbound name `myName.ToString'
Nemerle.Compiler.Parsetree.PExpr+Ref
Compile-time. myName = a
test-001.n:6:1:6:10: error: unbound name `myName.ToString'
...

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

Так происходит потому, что области видимости имен макросов и области видимости кода, из которых эти макросы вызываются, не пересекаются. Макросы Nemerle являются так называемыми гигиеническими. Использовать имена из вызывающего кода можно, но для этого придется постараться. Это уберегает от ошибок, связанных с перекрытием имен. Так что даже если мы в вызывающем коде напишем:

def myName = "xxx";
TestMacro("Vlad");

то ничего не изменится, и мы получим все то же сообщение:

test-001.n:5:1:5:10: error: unbound name `myName.ToString'

Что же делать? Как получить доступ к внешним именам? Ответ – никак! Точнее, этого просто не нужно делать. Мы получили в качестве параметра выражение, и можем внедрить его внутрь выражения, возвращаемого макросом.

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

Именно этой возможностью я и воспользовался, чтобы внедрить ссылку на параметр макроса внутрь генерируемого кода. Символ «$» перед именем «myName» в следующей строке как раз и указывает компилятору Nemerle, что имя myName нужно рассматривать как этот самый активный контент.:

<[ WriteLine("Run-time.\n Hallo, " + $myName) ]>;

Получается, что это уже не просто цитирование. Вот поэтому его и назвали квази-цитированием.

Наверно, вам интересно, что же в итоге попадает в код исполняемого модуля? Мне тоже это интересно (хотя я и догадываюсь – что). Поэтому я декомпилировал полученный исполняемый файл. Вот что я увидел:

Console.WriteLine("Run-time.\n Hallo, Vlad");
string text1 = "VladD2";
Console.WriteLine("Run-time.\n Hallo, " + text1);

Обратите внимание, что в первом случае получилась константная строка, а во втором – код конкатенации переменной со строкой. Несомненно, первый случай является результатом оптимизаций компилятора Nemerle.

Нетрудно догадаться, что если в качестве параметра макроса передать выражение, то компилятор подставит его вместо параметра.

А что будет, если тип выражения окажется несовместимым со строкой? Проведем смелый научный эксперимент и передадим макросу в качестве параметра выражение «1 + 2»:

TestMacro(1 + 2);

Компилятор отреагировал довольно ожидаемым образом:

test-001.n:5:1:5:10: error: each overload has an error during call:
test-001.n:5:1:5:10: error:   overload #1, method System.String.op_Addition(left : string, right : string) : string
test-001.n:5:1:5:10: error: in argument #2 (right), needed a string, got int: System.Int32 is not a subtype of System.String [simple require]
...

С нашими знаниями мы без труда определим, что макрос просто хочет строку в качестве параметра. Можно сказать, что вывод типов в Nemerle-е опять «рулит», но откровенно говоря за такую «рулежку» можно и в бубен схлопотать если нашим макросом воспользуется кто-то довольно здоровый и невыдержанный (как я). :)

Что же делать?

Выхода есть два. И оба они хорошие. Самый простой – четко указать в месте применения, что мы хотим иметь дело со строкой. Для этого обсуждаемую строку макроса нужно переписать следующим образом:

<[ WriteLine("Run-time.\n Hallo, " + ($myName : string)) ]>;

Если после этого скомпилировать код, то мы получим совершенно вменяемое сообщение об ошибке:

test-001.n:5:1:5:10: error: expected string, got int in type-enforced expression: System.Int32 is not a subtype of System.String [simple require]

В принципе, этого достаточно в большинстве случаев, но душа, как всегда, хочет большего. Можно ли попытаться добиться этого большего? А как же? Мы же обладаем всей мощью компиляторостроителей! Просто изменим код следующим образом:

  <[ WriteLine("Run-time.\n Hallo, " + System.Convert.ToString($myName)) ]>;

и вуаля!

Теперь наш макрос стал значительно более гибким. Компилируем:

TestMacro(1 + 2);
TestMacro(1 + 2098098);
TestMacro("Vlad");
def a = "VladD2";
TestMacro(a);

и получаем:

>build-and-run.cmd test-001
--# Compiling Macro-assembly #---
---------------------------------
--- Compiling target assembly ---
---------------------------------
Nemerle.Compiler.Parsetree.PExpr+Call
Compile-time. myName = 1 + 2
Nemerle.Compiler.Parsetree.PExpr+Call
Compile-time. myName = 1 + 2098098
Nemerle.Compiler.Parsetree.PExpr+Literal
Compile-time. myName = "Vlad"
Nemerle.Compiler.Parsetree.PExpr+Ref
Compile-time. myName = a
--------------- OK -----------------
--Run: test-001.exe
.
Run-time.
 Hallo, 3
Run-time.
 Hallo, 2098099
Run-time.
 Hallo, Vlad
Run-time.
 Hallo, VladD2
>Exit code: 0

Что же у нас теперь «под капотом»?

Console.WriteLine("Run-time.\n Hallo, " + Convert.ToString((sbyte) 3));
Console.WriteLine("Run-time.\n Hallo, " + Convert.ToString(0x2003b3));
Console.WriteLine("Run-time.\n Hallo, " + Convert.ToString("Vlad"));
string text1 = "VladD2";
Console.WriteLine("Run-time.\n Hallo, " + Convert.ToString(text1));

А что если скормить нечто, что Convert-ору не по зубам:

TestMacro(System.Console);

Все в порядке! Мы получаем довольно разумное сообщение:

test-001.n:5:11:5:25: error: none of the meanings of `System.Console' meets the type ?:

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

printf

Будем считать, что экспресс-курс введения в макросы Nemerle закончен и настало время поговорить о чем-то более сложном и, как водится, более интересном.

Давайте отвлечемся от макросов Nemerle и обсудим, казалось бы, далекий от них вопрос – функцию printf языка C. Позже вы сами поймете, какое это отношение имеет к макросам Nemerle.

Я увлекся программированием в те времена, когда на планете уже во всю победно шествовал С. C++, видимо, тоже уже существовал, но он еще не вошел в поле зрения большинства программистов.

Думаю, что «C» занял такое заметное место в истории в немалой степени потому, что Кернихан (Brian Kernighan, http://en.wikipedia.org/wiki/Kernighan, или как его ошибочно называют у нас Керниган) в соавторстве с автором «С» Ричи (Dennis Ritchie, http://en.wikipedia.org/wiki/Dennis_Ritchie) написал знаменитейшую книгу «The C programming language» (http://en.wikipedia.org/wiki/The_C_Programming_Language_%28book%29). Страничка http://cm.bell-labs.com/cm/cs/cbook/ замечательно демонстрирует, что из книги и языка «это» превратилось в религию. Уверен, что ответственность за удачность книги целиком и полностью лежит на Кернихане. :)

В самом начале этой книги был пример, ставший впоследствие легендарным:

#include <stdio.h>

int main(void)
{
  printf("Hello, world!\n");
  return 0;
}

Легендарность данного примера подтверждается тем, что ему посвящена даже страница Википедии (http://ru.wikipedia.org/wiki/%D0%9F%D1%80%D0%BE%D0%B3%D1%80%D0%B0%D0%BC%D0%BC%D0%B0_Hello_world).

Что же интересного есть в этом примере? Функция printf()! Далее в книге printf используется направо и налево.

Неудивительно, что функция printf и ее аналоги (вроде sprintf и fprintf) стала для меня самым логичным способом вывода информации.

Легендарный пример, к сожалению, не показывает всех возможностей данной функции. Главным ее достоинством является то, что она позволяет выводить не только простые строковые значения, но и осуществлять так называемый форматированный вывод (о чем, видимо, и должна была говорить буква «f» в названии функций). Делается это очень просто. Есть набор так называемых тегов форматирования, которые можно указывать в строке форматирования, всегда идущей вторым параметром.

ПРИМЕЧАНИЕ

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

Каждому тегу форматирования, помещенному в строку формата, должен соответствовать параметр соответствующего типа. Обратите внимание на эти слова. Они ключевые – «соответствующего типа», то есть совпадающего с тегом форматирования. Для повествования достаточно знания о том, что «%d» является тегом, говорящим, что соответствующий параметр функции имеет тип int, «%h» говорит, что параметр имеет тип unsigned int и должен выводиться в шестнадцатеричном представлении, «%H» - то же самое, что в предыдущем случае, но шестнадцатеричные буквы (A-F) должны быть заглавными, «%f» говорит, что параметр имеет тип double, а «%s» - что параметр является указателем на строку. Всем, кто интересуется подробностями формата функции printf, желаю удачного поиска в Google.

Вот пример использования функции printf:

int i = 123;
double f = 123.23;
char* str = "Text";

printf("Text");             // Выводит слово "Text" (без кавычек)
printf("Value is %d", i);   // Выводит слово "Value is 123"
printf("Value is %f", f);   // Выводит слово "Value is 123.23"
printf("Value is %s", str); // Выводит слово "Value is Text"
printf("%s %d", str, i);    // Выводит слово "Text 123"

В этом примере есть два интересных момента.

1. Функция может принимать переменный список параметров, причем каждый параметр может иметь свой тип.

2. Даже если код скомпилируется без ошибок, никто не гарантирует, что он не «грохнет» при попытке его выполнить.

По тем временам идея Ричи была гениальна по простоте и получаемому удобству. Он просто немного изменил (по сравнению с более традиционным) соглашение о том, в каком порядке закладываются параметры, и о том, кто производит очистку стека после вызова.

Параметры в С должны были закладываться в обратном порядке, а освобождать стек должна была вызывающая сторона. Первое позволяло легко вычислять адрес первого параметра, а второе – освободить реализацию функции от необходимости во время своей компиляции «знать», сколько у нее должно быть параметров. Таким образом, вызывающая сторона могла заложить в стек столько параметров, столько она сочтет нужным, и освободить стек после вызова. Вызываемая функция, со своей стороны, могла обратиться к первому аргументу, чтобы неким, только ей известным образом, вычислить общее количество аргументов и их типы. Зная это, она могла «грязно» залезть в стек и вытащить из него переданные ей аргументы.

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

В принципе идеологически нет никаких причин, мешающих вызвать такую функцию динамически. Жаль, что C все-таки не умел это делать. Но преимущество было налицо! Ведь иначе код был бы не так краток, или пришлось бы писать целое море кода, создавая специализированные версии printf для различного сочетания параметров.

Еще одним преимуществом такого подхода было то, что параметры лежали в стеке очень компактно, и если забыть о том, что информацию о типах приходилось откуда-то читать, то можно заключить, что это было очень эффективно. Даже более поздние решения, типа функций Console.WriteLine() или string.Format(), появившихся в Java и .NET, были не столь эффективными, так как (если исключить небольшое количество перегруженных вариантов) передавались в виде массива с типом элемента object (то есть ссылок на объекты в управляемой куче). Чтобы поместить value-тип в object, .NET производит операцию boxing-а, что отнюдь не бесплатно.

В общем, это было круто! И по некоторым критериям остается круто даже сегодня.

Однако у этого решения было одно узкое место. Я недаром упомянул слово «грязно», описывая процесс извлечения параметров из стека. Ведь, хотя сам процесс и не был очень уж сложным (были сделаны специальные макросы va_list, va_start и т.п.), но он был крайне опасным. Главная опасность заключалась в том, что не было четко специфицированного, гарантирующего верный результат способа получить описание параметров. А без этого даже верно написанная функция могла без зазрения совести «пройтись по памяти», то есть угробить ее, и привести к так называемым ошибкам второго и более порядков (наложенным ошибкам, вызванным обращением к испорченной памяти). Отладка таких ошибок – пожалуй, одна из самых сложных задач. Такие ошибки могут жить десятилетиями.

Собственно, С/C++ и без того содержат немало возможностей со смаком угробить программу. Понимая это, многие создатели ОС и процессоров (читай, почти все) стали создавать защищенные адресные пространства. Делалось это потому, что каждый из С/C++-программистов является профессионалом и умеет программировать так, чтобы не портить свою и чужую память, но вот тот ламер, сидящий за соседним столом, непременно это сделает. Причем тот «ламер» думал точно так же. Но это уже совсем другая история :).

Так вот, хотя C и C++ являются опасными языками, наступать на грабли по пятому разу никому не хотелось. Оттого впоследствии и появился трюк с перегрузкой операторов побитового сдвига в C++ и безопасный вариант функции с переменным числом параметров в Java/C#. Решение C++ мы рассматривать не будем, так как оно совсем другое, да и привело к ухудшению читаемости кода (строка ведь стала разорванной кучей лишней информации), а вот решение C# мы сейчас рассмотрим.

На чем же основывается решение C#? Все очень просто. Во-первых, в язык ввели тип object. К этому типу можно привести любой тип C#, за исключением указателей, доступных в небезопасном режиме. Более того, приведение можно делать неявно. Ссылочные типы (изначально размещаемые в управляемой куче) при этом всего лишь приводятся к базовому типу (то есть фактически никаких действий не производится). А вот с value-типами приходится произвести операцию boxing-а. Фактически это красивое слово означает, что для объекта value-типа требуется создать объект в куче. В этот объект помещается содержимое value-объекта. Таким образом, он может жить в управляемой куче. Boxing, по большому счету – это всего лишь паттерн программирования, намертво вмонтированный в .NET (а затем и в Java). Ведь и без вмонтированного решения можно было бы создать для каждого value-типа ссылочный тип-обертку или пользоваться массивами с одним элементом.

Возможность приведения значения любого типа к object позволяет C#-программистам пользоваться единообразием, предоставляемым им полиморфизмом. Любые данные могут рассматриваться как object, а стало быть, могут быть переданы в универсальную функцию и обработаны ею.

Но остается еще одна проблема – проблема передачи в функцию переменного списка параметров. Эта проблема решается в C# путем передачи списка параметров в виде массива, передаваемого последним параметром. Этот параметр должен быть помечен специальным ключевым словом «params». Фактически такой массив может иметь тип отличный от object (например, string[] или int[]), но при этом такая функция не сможет принимать параметры разных типов, как это делала легендарная функция printf. Почему, собственно, делала? Делает! :)

Использование массива объектов (object[]) в качестве массива аргументов позволяет сделать функции с переменным числом параметров в C# такими же универсальными, как и в C. Ведь количество параметров можно узнать из размера массива (в .NET каждый экземпляр массива хранит свой размер), а типы конкретных элементов этого массива можно узнать через метод GetType(), объявленный в базовом типе object, и стало быть, доступном в каждом объекте.

Вот как выглядит последний из примеров на C, если его переписать на C#:

int i = 123;
string str = "Text";

Console.WriteLine("{0} {1}", str, i); // Выводит "Text 123"

Замете, что в C# нет нужды указывать типы параметров. Хотя, может быть, это и не было бы лишним, так как позволило бы сравнивать их с фактическими параметрами, тем самым усиливая контроль.

Решение C#, конечно, значительно лучше, чем решение C. Ведь в C# неверный формат или ошибка при формировании списка параметров не приводит к столь разрушительным последствиям, как в C. Но фактически C#-решение тоже является компромиссом. Причем компромиссом в двух областях. С одной стороны оно в некоторых случаях принципиально медленнее C-решения. С другой стороны, оно унаследовало от C другую неприятность. О ней мы поговорим чуть подробнее.

Как в C, так и в C# можно получить ошибку во время выполнения.

Почему?! Все очень просто. Ведь строка формата – это всего лишь строка и мы не можем проверить соответствие списка аргументов списку тэгов форматирования, заложенных в эту строку до момента, когда эта самая функция будет вызвана.

Разница между C и C# в данном случае заключается только в том, что в C будет «проход по памяти» (слава богу, если при этом приложение вылетит сразу!), а в C# мы получим исключение, которое с одной стороны, можно обработать и которое выдает исчерпывающую информацию, а с другой – никак не портит память и исключает наложенные ошибки. В общем, разница маленькая, но очень существенная. :)

Однако то, что возможность ошибки времени выполнения не исключается, все же печалит, и сильно. Так же печалит то, что эффективность решения с массивом object-ов далека от оптимальной.

Что же можно было бы придумать, чтобы устранить эти проблемы?

А причем тут Nemerle? :)

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

Если заложиться на то, что строка не изменяется на протяжении жизни программы, то можно сгенерировать последовательность формирования строки. Например, на C# это можно сделать так:

string str = "Value is";
int i = 123;
Console.WriteLine(str + " " + i.ToString());

Правда, здорово? Не очень? Согласен. :)

Формат уже не выглядит столь же целостно и лаконично, как в примере на C. Хотя одно преимущество все же есть. Теперь мы можем не указывать в строке формата (если ее теперь вообще можно так назвать) ни теги форматирования, ни типы параметров. За нас это делает компилятор, ведь каждая переменная имеет свой тип и компилятор знает о них. А их расположение определяет, в какое место будет подставлено значение. Практически то же самое происходит, когда в C++ используется перегруженный оператор побитового сдвига.

Можно ли объединить прелесть единого формата (располагающегося в одной строке), безопасность формата C# и скорость C?

Оказывается, можно! Только для этого нужно научить компилятор формировать выражение, подобное приведенному выше «str + " " + i.ToString()» на базе строки формата.

Научить компилятор этому можно двумя путями.

1. Вмонтировав преобразование непосредственно в компилятор.

2. Воспользовавшись синтаксическими макросами.

Первый способ плох по двум причинам. Первая заключается в том, что все создатели компиляторов недолюбливают подобные решения. Они терзают романтичную душу прагматика. :) Вторая заключается в том, что в подобных решениях зачастую бывает много нюансов, и вмонтированное решение почти всегда окажется недостаточно гибким.

А что же второй путь? Второй путь всем хорош, но доступен не везде.

Нетрудно догадаться, что Nemerle – как раз тот редкий случай, когда он доступен.

Создатели языка создали ряд решений в области вывода форматированного текста на базе макросов. Среди них имеет смысл выделить два. Первый является реинкарнацией легендарного printf-а. Второй – еще более забавный.

Пойдем по порядку.

printf нового поколения

В Nemerle имеется макрос, который, как бы странно это не выглядело, имеет название printf. Вот его код:

macro printf(format : string, params parms : array[expr]) 
{
  def (evals, refs) = make_evaluation_exprs(parse_format(format), parms);
  def seq = List.Append(evals, List.Map(refs, 
    fun(x) 
    { 
      <[ Console.Write($x) ]>
    }));
  <[ {.. $seq } ]>
}

Давайте подробно разберем, что же делает этот макрос.

Первое, с чем надо разобраться – что же передается в макрос в качестве параметров?

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

По сути, макрос является функцией, принимающей в качестве параметров список выражений языка программирования, и возвращающей одно-единственное выражение (то есть в терминах Nemerle list[PExpr] -> PExpr). Обратите внимание на эту мысль. Она очень важна для дальнейшего понимания!

Однако данный макрос принимает строку и массив параметров типа «expr». Это синтаксический сахар для создания макросов с переменным количеством параметров. Как и в C#, в Nemerle последний параметр, помеченный модификатором params, позволяет передать в функцию или макрос список параметров переменной длины (то есть любое количество параметров). Но на этом сходство с C# заканчивается. Дело в том, что в макрос передаются не значения переменных, а их описания в виде веток AST. PExpr – это базовый класс (точнее, вариант в терминах Nemerle) для всех выражений языка. На параметры можно накладывать дополнительные ограничения. Так, «format : string» означает, что в параметр format можно передать исключительно строку. Причем, так как на этапе компиляции строкой может являться только строковый литерал, стало быть, в параметр format можно передать только строковый литерал. С одной стороны это ограничение, ведь формат нельзя задать динамически, но с другой это позволяет гарантировать, что проверка, выполненная на базе этого литерала, будет всегда верной.

Как же работает этот макрос?

Сначала строка формата обрабатывается функцией parse_format(). Эта функция преобразует строку в список, содержащий описания отдельных частей строки формата.

Этот список содержит элементы типа FormatToken:

public variant FormatToken
{
  | Text { body : string; }
  | Number { unsigned : bool; longformat : bool; }
  | NumberFloat { longformat : bool; }
  | Str
  | Chr
}

Для участков строки формата, не содержащих форматирующих тегов, в список помещаются элементы типа FormatToken.Text. В его поле body помещается соответствующая подстрока. Для элементов формата в список помещаются значения Number, NumberFloat, Str или Chr (для целых чисел, чисел с плавающей точкой, строк и символов, соответственно). Вот код функции parse_format():

/// Разбирает строку форматирования printf-стиля (т.е. %-нотацию).
public parse_format(form : string) : list [FormatToken]
{
  def buf = StringBuilder();
  mutable result = [];
  mutable i = 0;
  def n = form.Length;

  // Добавляет токен, соответствующий участкам текста, не содержащим
  // тегов форматирования 
  def append_text() 
  {
    match (result)
    {
      | FormatToken.Text(t) :: rest =>
          result = FormatToken.Text(t + buf.ToString()) :: rest
      | _ =>
          result = FormatToken.Text(buf.ToString()) :: result
    }

    ignore (buf.Remove(0, buf.Length));
  };

  while (i < n)
  { 
    match (form[i])
    {
      | '%' =>
        // Если до символа '%' шел текст, создать соответствующий токен
        // и поместить в него этот текст.
        when (buf.Length > 0)
          append_text();

        // Флаг, указывающий что тег содержит данные типа long или double
        mutable longform = false; 

        def next_char()
        {
          // Анализируем следующий символ тега форматирования...
          ++i;

          if (i < n)
          {
            // анализируем символы тега, определяющие тип данных аргумента
            match (form[i]) 
            {
              | 'd' | 'i' 
                    => result = FormatToken.Number(false, longform) :: result
              | 'u' => result = FormatToken.Number(true, longform) :: result
              | 'f' => result = FormatToken.NumberFloat(longform) :: result
              | 's' => result = FormatToken.Str() :: result
              | 'c' => result = FormatToken.Chr() :: result
              | '%' => ignore (buf.Append('%'))
              | 'l' => 
                if (longform) 
                  Message.Error("'l' in printf-format specified twice")
                else
                {
                  longform = true;
                  next_char();
                }
              | _ => Message.Error(
                   "Unsupported formatting sequence after % character")
            }
          }
          else
            Message.Error("Unexpected end of format after % character")
        }
        
        // вызываем функцию, анализирующую содержимое тега форматирования
        next_char();

      | c => ignore(buf.Append(c))
    };

    ++i;
  };

  // Если между окончанием последнего тега форматирования и концом строки 
  // остался текст, помещаем его в текстовый токен.
  when (buf.Length > 0)
    append_text();

  // Результат функции получается развернутым в обратную сторону, но 
  // функции, использующие его, впоследствии разворачивают его еще раз.
  result
}

Например, если подать на вход этой функции строку:

"Value = %d"

то функция вернет следующий список:

[FormatToken.Number(false, false), FormatToken.Text("Value =")]

А если подать на вход:

"%f штук"

то функция вернет следующий список:

[FormatToken.Text("штук "), FormatToken.NumberFloat(false)]

В общем, функция parse_format() занимается лексическим разбором строки формата и преобразованием ее в токены (лексемы).

Результат работы этой функции (список токенов) вместе со списком параметров макроса передается функции make_evaluation_exprs():

// Осуществляет преобразование списка токенов строки формата и списка
// параметров в список выражений, декларирующих и инициализирующих 
// переменные, а также список этих переменных. Из второго списка 
// можно формировать различный код, выводящий значения переменных,
// например, на консоль, или преобразующий их значения в строку.
// PT – это алиас для пространства имен Nemerle.Compiler.Parsetree
public make_evaluation_exprs(
  toks : list[FormatToken], 
  parms : array[PT.PExpr]
  ) : list[PT.PExpr] * list[PT.PExpr] // функция возвращает пару списков
{
  // Перебирает токены и формирует два списка.
  def make_expressions(toks, i, acc_eval, acc_ref)
  {
    // Формирует переменную и добавляет ее к первому списку, добавляет
    // ссылку на переменную ко второму списку и рекурсивно вызывает 
    // функцию make_expressions(), тем самым переходя к следующему элементу
    // списка токенов. 
    def continue(x, xs)
    {
      // Создаем новое уникальное имя переменной.
      def sym = Macros.NewSymbol();
      
      make_expressions(xs, i - 1,
       // Формируем объявление переменной и инициализируем ее значением 
       // параметра. Добавляем это выражение в начало первого списка.
       <[ def $(sym : name) = $x ]> :: acc_eval,
       // Добавляем ссылку на переменную в начало второго списка.
       <[ $(sym : name) ]> :: acc_ref)
    }

    match (toks)
    {
      | [] when i == 0 => (acc_eval, acc_ref)

      | FormatToken.Text(t) :: xs => 
        // Для токена Text нужно просто добавить строковый литерал 
        // ко второму списку. Список переменных изменять не нужно.
        make_expressions(xs, i, acc_eval, <[ $(t : string) ]> :: acc_ref)

      | _ when i == 0 =>
        Message.Error("not enough arguments for printf macro");
        (acc_eval, acc_ref)

      // Для всех остальных токенов нужно:
      // 1. Сформировать переменную, которой присвоить значение аргумента.
      // 2. Поместить выражение, сформированное в пункте 1, в первый 
      //    список (acc_eval), и ссылку на эту переменную - во второй.
      // Все это делает функция continue. В качестве первого параметра
      // ей передается значение соответствующего параметра, приведенное к
      // типу в соответствии с типом тега форматирования.

      | FormatToken.Number(false, false) :: xs =>
          continue(<[ $(parms[i - 1]) : int   ]>, xs)

      | FormatToken.Number(true,  false) :: xs =>
          continue(<[ $(parms[i - 1]) : uint  ]>, xs)

      | FormatToken.Number(false, true)  :: xs =>
          continue(<[ $(parms[i - 1]) : long  ]>, xs)

      | FormatToken.Number(true,  true)  :: xs =>
          continue(<[ $(parms[i - 1]) : ulong ]>, xs)

      | FormatToken.NumberFloat(false) :: xs =>
        continue(<[ Convert.ToString(($(parms[i - 1]) : float),
                       Globalization.NumberFormatInfo.InvariantInfo) ]>, xs)

      | FormatToken.NumberFloat(true) :: xs =>
        continue(<[ Convert.ToString(($(parms[i - 1]) : double),
                       Globalization.NumberFormatInfo.InvariantInfo) ]>, xs)

      | FormatToken.Str :: xs => continue(<[ $(parms[i - 1]) : string ]>, xs)

      | FormatToken.Chr :: xs => continue(<[ $(parms[i - 1]) : char ]>, xs)

      | [] => 
        Message.Error("too many arguments for printf macro");
        (acc_eval, acc_ref)
    }
  };

  make_expressions(toks, parms.Length, [], []);
}

В результате вызова:

def (evals, refs) = make_evaluation_exprs(parse_format(format), parms);

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

Чтобы увидеть, что же за код породил макрос, можно воспользоваться одним из двух способов.

  1. Скомпилировать программу и декомпилировать ее с помощью Reflector-а.
  2. Воспользоваться макросом pretty_print_expr из пространства имен Nemerle.Macros. Этот макрос выведет на консоль значение выражения, переданного в качестве первого параметра. Если второй аргумент этого макроса – true, то перед отображением будут раскрыты макросы, содержащиеся в выражении. В противном случае выражение будет выведено как есть.

Так, если скомпилировать следующий код:

using Nemerle.IO;
Nemerle.Macros.pretty_print_expr(
  printf("Value = %d", 123 + 877), 
  true);

то в окно вывода компилятора или на консоль будет выведено нечто вроде этого:

    {
      def _N_1812 = (123 + 877 : int);
      Console.Write ("Value = ");
      Console.Write (_N_1812)
    }
ПРИМЕЧАНИЕ

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

ПРЕДУПРЕЖДЕНИЕ

Если вы используете VS 2005 или msbuild, то вывод макроса pretty_print_expr вы сможете увидеть, только если в настройках среды укажете высокий уровень «словесного наполнения» (verbosity). В VS 2005 эта настройка находится в меню «Tools\Options...» => «Projects and Solutions\Build and Run\MSBuild project build output verbosity». В этом свойстве нужно установить значение Detailed или выше. При этом на вашу голову будет вылит ушат малоинтересной диагностической информации. Проще обстоит дело при компиляции кода из консоли (без использования msbuild). В этом случае текст будет выведен без танцев с бубном.

С помощью макроса pretty_print_expr можно увидеть истинное лицо кода Nemerle-приложений и в других случаях. Если нужно вывести значение нескольких выражений, то можно взять их в фигурные скобки (то есть в блок).

Кроме того, можно использовать макрос ExprToString из того же пространства имен Nemerle.Macros. Этот макрос делает практически то же самое, что и pretty_print_expr, но вместо вывода на консоль возвращает строку, содержащую код выражения. Я специально добавил этот макрос, чтобы было проще анализировать код, порождаемый макросами. С его использованием можно написать:

using System.Console;
using Nemerle.IO;
using Nemerle.Macros;

WriteLine(ExprToString(
  printf("Value = %d", 123 + 877), 
  true));

printf("Value = %d", 123 + 877); 

При исполнении этот код выведет на консоль:

{
  def _N_1112 = (123 + 877 : int);
  Console.Write ("Value = ");
  Console.Write (_N_1112)
}
Value = 1000

Теперь ясно как работает макрос printf, и можно проанализировать, что же дает его применение.

А дает оно очень и очень многое. Главное, что мы получаем от его использования по сравнению с printf() из C или Console.WriteLine() из C# – это контроль типов аргументов во время компиляции. Так, если попытаться скомпилировать следующую строку:

printf("Value = %d", 1.3);

то получим сообщение об ошибке:

Main.n(9,1,9,7): error : expected int, got System.Double in type-enforced 
expression: System.Double is not a subtype of System.Int32 [simple require]

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

{
  def _N_1112 = (1.2d : int);
  Console.Write ("Value = ");
  Console.Write (_N_1112)
}

Обратите внимание на выделенный фрагмент. Это так называемое уточнение типа (type enforcement). Наверно, даже более точно будет перевести enforcement, как «принуждение». Данное выражение указывает, что левая часть выражения (идущая до двоеточия) должна иметь тип int. Однако она не может иметь этот тип, так как там стоит константа типа double. Если переписать пример следующим образом:

printf("Value = %lf", 1.3);

то зануда-компилятор отстанет от нас, так как получит то, что хотел.

Не пройдет также и такая ошибка:

printf("Value = %lf %d", 1.3);

Как, впрочем, и такая:

printf("Value = %lf", 1.3, 2);

В первом случае будет выдано сообщение об ошибке «not enough arguments for printf macro», а во втором – «too many arguments for printf macro».

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

Кроме статически проверяемой при компиляции корректности кода мы также получаем весьма шустрый код! И если для printf это не так актуально, так как консольный вывод может перекрыть любые затраты на динамическую возню с форматом, то в случае других алгоритмов (например, при реализации аналога функции sprintf()) мы получим ощутимый выигрыш. Да что там выигрыш?! Надо называть вещи своими именами. Мы имеем возможность породить наиболее оптимальный с нашей точки зрения код.

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

Выше гор могут быть только... птицы

Макросы оказались на удивление удобны для реализации безопасных и быстрых аналогов printf/sprintf. Но нет ли решений еще более интересных, чем старый и теперь уже действительно добрый (в смысле не злой) printf?

Оказывается, что есть, и как не странно, уже довольно давно. В скриптовых языках вроде Perl, Ruby и PHP есть презабавнейшая возможность – строки со сплайсами. В этих языках можно написать код вроде:

"Value $value"

или

"Value {value}"

и интерпретатор сделает хитрую вещь. Он распознает в «{value}» или «$value» запрос на преобразование переменной value в строку и ее вставку в то место, где находилось выражение «{value}» или «$value». Так вот, такое выражение называется сплайс (splice).

В перечисленных выше языках подобное поведение является встроенным, или другими словами, hardcoded. Однако, имея такой мощнейший механизм, как макросы Nemerle, можно реализовать подобную функциональность, не трогая сам язык.

Немудрено, что разработчики Nemerle не преминули сделать это.

Поначалу данная функциональность поддерживалась только в специальных макросах print() и sprint(). Этим макросам передавалась сплайс-строка, они производили ее разбор и генерацию соответствующего кода. Но потом было принято более универсальное решение – введен макрос с ультравыразительным и хорошо запоминающим названием «$». Этот макрос уже использовался в статьях по Nemerle в нашем журнале и на нашем сайте. Так что просто приведу пример его использования:

def value = 123 + 877;
System.Console.WriteLine($"Value $value");
// или... если нужно вычислить выражение внутри сплайса.
def value = 123;
System.Console.WriteLine($"Value $(value + 877)");

Этот макрос возвращает строку, и, естественно, может использоваться в любом месте, где требуется строка.

Реализация этого макроса значительно интереснее, чем реализация макроса printf(). Так, макрос «$» должен не просто выделить теги форматирования, но и произвести разбор выражений сплайсов.

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

using System.Console;

def f(value)
{
  $">> $({if (value % 2 != 0) f(value - 1); else value.ToString()}) <<"
}

WriteLine(f(123));

При выполнении он выведет на консоль:

>> >> 122 << <<

Обратите внимание на то, что этот пример вывел по две пары угловых скобок с каждой стороны. Это и есть результат рекурсивного вызова. То есть код внутри строки формата рекурсивно в середине выполнения вызывает функцию, в которой он же и определен. Так как в первый раз значение – нечетное, происходит рекурсивный вызов, перед которым значение уменьшается на единицу. Внутри рекурсивного вызова значение становится четным, и выполняется преобразование параметра функции в строку. Обратите также внимание, что для преобразования значения в строку потребовалось вызывать функцию ToString(). Это потребовалось потому, что значение возвращается в конструкции else, а if/else требует, чтобы тип обоих его выражений совпадал.

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

using System.Console;

def value = 123;
WriteLine($"Value $({WriteLine(\"Wow! Side effect!\"); value})");

Выведет на консоль:

Wow! Side effect!
Value 123

Причем, обратите внимание, код сплайс-выражений пользуется всеми внешними переменными и открытыми пространствами имен из текущего контекста.

К чему я все это говорю? Да к тому, что реализовать это без коррупционного сговора с господином компилятором практически невозможно. Решение моментально становится не таким гибким и мощным.

К тому же без использования кода компилятора придется писать много сложного кода парсинга выражений.

К счастью, Nemerle сам построен на базе компонентных принципов и предоставляет себя своим расширениям.

Наверно, самое время продемонстрировать реализацию этого макроса. Однако есть в этом макросе фатальный недостаток! Вы правильно догадались – «его писали не они». Точнее, не я. :)

Локализуемый сплайс-макрос

Если серьезно, то я отказался от демонстрации кода макроса «$», конечно же, не потому, что в нем есть «фатальный недостаток». Просто в процессе одного из обсуждений на форумах RSDN я услышал замечание о том, что у сплайс-макросов и перегрузки операторов сдвига в C++ есть один недостаток. Они неудобны, если программу требуется локализовать.

И правда, текст получается разбросанным по всему коду. Для переводчика это очень неудобно. Ему придется выискивать строки в исходных кодах программы. А ведь ко всему прочему может быть нежелательно, чтобы переводчик вообще получал доступ к коду.

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

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

Я задумался над тем, нельзя ли сделать так, чтобы программист видел строки со сплайсами в первозданном виде, а переводчик, тем не менее, имел бы дело с чем-то вроде ресурсных файлов? Немного поразмыслив, я нашел элегантное решение этой задачи.

Найдя это решение, я попытался воплотить его в жизнь. Это оказалось несложно, так как самая сложная часть кода уже была реализована в макросах «$» и «print», а их исходный код свободно доступен.

Именно потому, что код этого моего макроса основан на макросах «$» и «print», я и не привожу их исходный код. Если кому-то он все же интересен, то вы можете найти его в исходных кодах компилятора (в http://nemerle.org/svn/nemerle/trunk/macros/io.n макросы print и sprint, «$» реализован на базе макроса sprint).

Ну да пора перейти к описанию самого макроса локализуемых сплайс-строк.

В программе используются строки вида:

%%"ля-ля-ля $(выражение) тополя"

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

Можно изменить генерируемый файл, введя текст на других языках, и получить переведенную программу. Сама же программа при этом пишется, как будто ее не требуется переводить. Более того, любая имеющаяся программа на Nemerle может быть простой контекстной заменой «$"» на «%%"» превращена в переводимую на другие языки.

Вот как выглядит программа с использованием этого макроса:

using Localization;

[assembly: LocalizationFile("Strings.xml")]

using System.Console;

def nums = array[1, 2, 3];
def x = 5;

WriteLine(%%"a=$(nums[1]) x = $x");

def F(y : int){ y * y }

WriteLine(%%"F(3)=$(F(3))");

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

<strings>
    <ID_0>
        <en-US>a={0} x = {1}</en-US>
    </ID_0>
    <ID_1>
        <en-US>F(3)={0}</en-US>
    </ID_1>
</strings>

его можно изменить следующим образом:

<strings>
    <ID_0>
        <en-US>a={0} x = {1}</en-US>
    </ID_0>
    <ID_1>
        <en-US>F(3)={0}</en-US>
        <ru-RU>Функция F(3) вернула значение {0}.</ru-RU>
    </ID_1>
</strings>

Теперь, если запустить программу под Windows, где выбран русский язык, то второе сообщение будет выведено на русском.

A вот как выглядит макрос:

using System;
using System.Console;
using Nemerle.Compiler;
using Nemerle.Collections;
using Nemerle.Macros;
using PT = Nemerle.Compiler.Parsetree;
using System.Text;
using System.IO;
using System.Xml.XPath;

namespace Localization
{
  // Метамакрос (макрос-атрибут) будет вызван при загрузке сборки, в 
  // которой он описан.
  // Он принимает имя файла локализации (содержимое которого 
  // автоматически формируется при компиляции).
  [Nemerle.MacroUsage(Nemerle.MacroPhase.BeforeInheritance,
    Nemerle.MacroTargets.Assembly)
  ]
  macro LocalizationFile(fileName : string)
  {
    Helper._fileName = fileName;
    _ = Helper._builder.AppendLine("<strings>");
    // _holder save _builder content to a file when ncc closing.
    Helper._holder = Helper.Holder();
  }

  // Это макрос вводит локализуемую сплайс-строку. Каждое вхождение
  // этого макроса добавляет в файл локализации <ID_xxx>, где уникальный
  // идентификатор макроса.
  macro LocalizationString(str : string)
    syntax ("%%", str)
  {
    // make_splice_distribution разбивает строку формата 
    def exprs = Helper.make_splice_distribution(str, ImplicitCTX().Env);

    // Формируем строку формата (для функции string.Format) «str» и список 
    // аргументов (выражений из сплайсов). Каждый сплайс заменяется в строке 
    // формата на тег форматирования «{x}» (где x - это порядковый номер
    // сплайса), а содержащееся в нем выражение добавляется в список «args».
    def (str, args, _) = exprs.FoldRight(("", [], 0), fun(expr, accumulator)
    {
      // Выделяем из кортежа отдельные значения
      // str - формируемая строка формата.
      // args – формируемый список аргументов функции string.Format.
      // i – порядковый номер сплайса.
      def (str, args, i) = accumulator;
      def x = expr.ToString(); // Преобразуем текущее выражение в строку.
      // Функция Unquot(s) убирает обрамляющие кавычки из строки.
      def Unquot(s) { s.Substring(1).Substring(0, s.Length - 2) }
      // AddTwiceBraces(s) удваивает знаки "{" и "}".
      def AddTwiceBraces(s) { s.Replace("{", "{{").Replace("}", "}}") }
      
      if (x.Length > 0)
      {
        // Если строка начинается с «"», это просто подстрока, которую нужно
        // обработать и добавить в строку формата.
        if (x[0] == '"')
          (str + AddTwiceBraces(Unquot(x)), args, i)
        // иначе это «встроеное» подвыражение, выделенное из строки.
        // Для каждого такого подвыражения нужно добавить в форматную строку
        // тег вида «{x}» где, x - это номер подвыражения, а также добавить
        // выражение в список выражений, которые впоследствии будут 
        // формировать параметры.
        else
          (str + "{" + i.ToString() + "}", expr :: args, i + 1)
      }
      else // Если строка пуста, то ничего не делаем.
        accumulator
    });

    // Добавляем в начало списка параметров параметр, содержащий строку
    // формата. Сам список параметров при этом разворачиваем, так как он
    // формировался в обратном порядке.
    def args = <[ $(Helper.GenerateId() : string) ]> :: args.Reverse();

    // Добавляем тег в файл локализации.
    Helper.AddString(str);
    
    // Формируем выражение форматированного вывода и возвращаем его как 
    // результат работы макроса. Этот код заместит вызов макроса. 
    // Конструкция ( .. $args) сформирует параметры вызова, преобразуя 
    // список выражений в кортеж. В Nemerle кортежи и списки параметров
    // функций равнозначны.
    <[ Localiser.Instace.Format( .. $args) ]>
  }

  internal module Helper
  {
    public mutable _counter : int = 0;
    public mutable _fileName : string;
    public _builder : StringBuilder = StringBuilder();
    public  mutable _holder : Holder;
    
    public GenerateId() : string
    {
      "ID_" + _counter.ToString()
    }
    
    public AddString(str : string) : void
    {
      _ = _builder.AppendFormat("    <ID_{0}>", _counter);
      _ = _builder.AppendLine();
      _ = _builder.AppendLine(  "        <en-US>" + str + "</en-US>");
      _ = _builder.AppendFormat("    </ID_{0}>", _counter);
      _counter++;
      _ = _builder.AppendLine();
    }

    // Holder сохраняет содержимое _builder в файл (имя которого задается
    // в атрибуте сбрки «LocalizationFile» и содержится в _fileName)
    // в момент, когда завершается компиляция.
    [Record]
    internal class Holder
    {
      // В отличие от C#, в Nemerle финализатор имеет синтаксис обычного 
      // виртуального метода. Пользоваться финализатором приходится, так как
      // пока что нет события оповещающего об окончании компиляции.
      protected override Finalize() : void
      {
        // Завершаем формирование XML-файла и сохраняем его на диск.
        _ = Helper._builder.AppendLine("</strings>");
        File.WriteAllText(Helper._fileName, Helper._builder.ToString());
      }
    }
    
    /** Для выражений $(...):
        - вычисляем выражения;
        - помещаем промежуточные результаты в локальные переменные;
        - возвращаем список выражений, содержащих выражения из сплайсов 
          или разделяющие их строковые литералы.
    */
    public make_splice_distribution(str : string, _env : GlobalEnv) 
      : list [PT.PExpr]
    {
      mutable seen_non_alnum = false;
      
      // Ищет конец выражения, взятого в круглые скобки (с учетом 
      // вложенных скобок).
      def find_end(balance, idx)
      {
        when (idx >= str.Length)
          Message.FatalError("runaway $(...) in format string");

        def ch = str[idx];
        seen_non_alnum ||= !(System.Char.IsLetterOrDigit(ch) || ch == '_');

        match (ch)
        {
          | ')' when balance == 1 => idx
          | ')' => find_end(balance - 1, idx + 1)
          | '(' => find_end(balance + 1, idx + 1)
          | _ => find_end(balance, idx + 1)
        }
      }

      // Ищет конец идентификатора.
      def find_end_normal(idx)
      {
        if (idx >= str.Length) 
          idx
        else
          match (str[idx])
          {
            | '_'  
            | ch when System.Char.IsLetterOrDigit(ch) => 
              find_end_normal(idx + 1)

            | _ => idx
          }
      }

      // Основной цикл.
      def loop(res, idx)
      {
        if (idx < 0 || idx >= str.Length)
          res
        else if (str[idx] == '$') // найден символ сплайса...
        {
          when (idx + 1 >= str.Length)
            Message.FatalError("lone '$' at the end of the format string");

          if (str[idx + 1] == '(') // обнаружено сплайс-выражение...
          {
            def end = find_end(1, idx + 2); // ищем конец сплайс-выражение...
            // получаем текст сплайс-выражения
            def expr = str.Substring(idx + 2, end - idx - 2);
            def expr =
              if (expr == "" || expr == "_" || seen_non_alnum 
                  || System.Char.IsDigit(expr[0]))
              {
                MacroColorizer.PushUseSiteColor();
                // разбираем сплайс-выражение
                def expr = MainParser.ParseExpr(_env, expr);
                MacroColorizer.PopColor();
                expr
              }
              else if (expr == "this")
                <[ this ]>
              else // иначе сплайс-выражение - это ссылка на переменную
                <[ $(expr : usesite) ]>;

            // переходим к анализу следующего символа
            loop(expr :: res, end + 1) 
          }
          // два подряд идущих символа «$» расцениваются как
          // вставка символа «$».
          else if (str[idx + 1] == '$')
            loop(<[$("$" : string)]> :: res, idx + 2)
          // иначе мы имеем дело с переменной...
          else
          {
            // получаем подстроку, содержащую имя переменной.
            def end = find_end_normal(idx + 1);
            def variable_name = str.Substring(idx + 1, end - idx - 1);
            
            if (variable_name == "")
            {
              Message.Warning("expected variable name or expression "
                "enclosed with (..) after $ in splice string");
              loop(<[$("$" : string)]> :: res, idx + 1)
            }
            else 
            {
              // формируем выражение-ссылку на переменную.
              def expr =
                if (variable_name == "this") <[ this ]>
                else <[ $(variable_name : usesite) ]>;

              loop(expr :: res, end) // продолжаем разбор строки.
            }
          }
        }
        else // если следующий символ не '$'...
        {
          // выделяем подстроку, не содержащую '$'...
          def next_idx = str.IndexOf('$', idx);
          def next_str =
            if (next_idx == -1) str.Substring(idx)
            else str.Substring(idx, next_idx - idx);

          // формируем из этой подстроки литерал и добавляем его в 
          // список выражений
          loop(<[ $(next_str : string) ]> :: res, next_idx)
        }
      }

      loop([], 0)
    } 
  }

  /// Этот класс используется сгенерированным макросом «%%»-кодом.
  /// Он кэширует строки сообщений и предоставляет к ним доступ.
  public class Localiser
  {
    // Singleton
    public static Instace : Localiser = Localiser();
    
    public this()
    {
      // Получаем краткое имя языка, ассоциированного с текущим потоком.
      def lang = System.Threading.Thread.CurrentThread.CurrentCulture.Name;
      // По умолчанию используем имя "en-US".
      def defLang = "en-US";
      
      // Считываем XML-файл со строками локализации.
      def doc = XPathDocument("Strings.xml");
      def iter = doc.CreateNavigator().Select("/strings/*");

      // Перебираем все вхождения идентификаторов строк...
      foreach (vlue :> XPathNavigator in iter)
      {
        // Получаем строку для текущего языка.
        mutable langIter = vlue.Select(lang);
        
        when (!langIter.MoveNext()) // Нет строк для этого языка!
        {
          // Получаем строку для языка, используемого по умолчанию.
          langIter = vlue.Select(defLang);

          when (!langIter.MoveNext())
            throw ApplicationException("No string for ID={0} found.");
        }

        // Добавляем в ассоциативный массив ассоциацию между идентификатором
        // строки и ее локализованным значением.
        _msgMap.Add(vlue.Name, langIter.Current.Value);
      }
    }

    // Ключ – идентификатор строки, значение – локализованное значение.
    private _msgMap : Hashtable[string, string] = Hashtable();

    // Вызов этой функции подставляется макросом «%%» вместо своего тела.
    public Format(msgId : string, params args : array[object]) : string
    {
      string.Format(_msgMap[msgId], args);
    }
  }
}

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

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

StringTemplate или мечты, мечты

Есть на свете одна очень любопытная библиотека – StringTemplate (http://www.stringtemplate.org). С ее помощью очень удобно формировать разного рода текстовый контент, такой, как сложные SQL-запросы, рассчитанные на SQL-серверы разных типов, программный код, XML-файлы и даже HTML-страницы (хотя для этого, пожалуй, есть средства и получше).

Все бы ничего, но есть ряд проблем с этой библиотекой. Одна из них – надежность. StringTemplate по сути является интерпретируемым декларативным языком. Его парсер создан с помощью ANTLR. Так вот, качество этого парсера оставляет желать лучшего. Невнятные сообщения об ошибках и практически полное отсутствие отладочных средств сильно затрудняют использование StringTemplate в реальных проектах. Кроме того, интерпретация никогда еще не сулила ничего хорошего в области производительности. StringTemplate не быстр. Особенно сильно он тормозит, если использующее его приложение запускается под управлением отладчика.

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

Так вот, Nemerle сам по себе является функциональным языком (точнее сказать, на 100% поддерживает функциональную парадигму) и вкупе со своими макросами является идеальным средством для реализации библиотек вроде StringTemplate. При этом решаются практически все проблемы, вставшие перед автором StringTemplate. Производительность, надежность, отладка... Упрощается и реализация подобной библиотеки.

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

Заключение

Мне всегда казалось, что практические примеры в сто раз лучше любого, пусть даже самого логичного, обоснования. А примеры лучше всего делать на базе того, что используется повседневно. Именно поэтому в качестве примеров я выбрал работу со строками. Пожалуй, она есть практически в любой программе.

Надеюсь, что эти примеры наглядно продемонстрировали преимущества, предоставляемые метапрограммированием, хорошо интегрированным в язык Nemerle.

Приведенные примеры – это всего лишь демонстрация одной из возможных областей применения метапрограммирования в реальном прикладном коде.


Эта статья опубликована в журнале RSDN Magazine #2-2006. Информацию о журнале можно найти здесь
    Сообщений 5    Оценка 775        Оценить