Разработка простого генератора отчетов с помощью Nemerle и System.Xml.Linq

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

Источник: RSDN Magazine #1-2008
Опубликовано: 17.07.2008
Версия текста: 1.0
О чем речь?
Спецификация
Раздел Template
Раздел Includes: Импорт других спецификаций
Спецификация в целом
$-нотация
Функции в $-выражениях
Как?
Язык реализации
Бесклассовое общество :)
Описание реализации
Чтение спецификации
Основной класс реализации
Модуль Program
Главный метод приложения DoReport
loadProperties
loadProperties
Чтение свойств
loadTemplateInfo
makeReport
calcVarsValues
calcOneVarValue
calcSplice
calcPExpr
doReplace
Выдача сообщений об ошибках
Заключение

Исходные коды к статье

О чем речь?

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

Казалось бы, что сложного напечатать подобные документы? Однако сложности есть. Документов много (так как много фирм), поэтому сделать их вручную, даже с использованием некой системы автоматизации крайне муторно. К тому же учитывать их специальным образом нет нужды (учет ведется по приходу и расходу денег). Дело осложняется еще и тем, что постоянно меняется как исходная информация, так и формы документов. Одно время я пытался пользоваться учетными программами, но они все как одна не гибки. Потом я перешел на печать отчетов в Excel-е, но нарвался на все неприятности технологии Copy & Past. Например, изменение формы отчетов превращалось в кошмар, так как приходилось копировать массу информации. Но я ленивый и поэтому довольно долгое время не предпринимал никаких серьезных действий.

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

В качестве спецификации подходил любой текстовый файл, но вследствие того, что XML в последнее время стал почти что стандартом де-факто, я выбрал его.

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

Форм для Excel море. Все современные справочно-правовые системы (вроде Референта, Гаранта и Консультанта) обычно предоставляют формы документов в Excel или Word. Кроме того, такие формы почти всегда можно найти через Google.

Итак, решено! Надо создать генератор отчетов, принимающий на вход спецификацию в формате XML и генерирующий (а затем и печатающий) ряд отчетов в формате Excel.

Спецификация

Первым моим решением было создать спецификацию под эту конкретную задачу – печать накладных, счетов-фактур и счетов на оплату. Однако, поразмыслив немного, я понял, что лучше создать более универсальное решение. Вместо того чтобы «хардкодить» имена тегов (типа «НашиРеквизиты») я решил, что лучше, чтобы спецификация могла содержать любые теги, а мой генератор отчетов просто читал ее и на основе содержимого этих тегов формировал отчет (по шаблону в Excel).

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

Я решил назвать общие данные свойствами (Properties), а конкретные данные для каждого отчета – элементами (Items). Эту идею я подсмотрел в MSBuild/Ant.

Таким образом спецификация, в моем представлении, стала содержать два тега <Properties></Properties> и <Items></Items>:

<?xmlversion="1.0"encoding="utf-8"?>
<Specification>
  <Properties>
    Список тегов свойств
  </Properties>
  <Items>
    Список тегов где каждый тег описывает данные для
    отдельного отчета
  </Items>
</Specification>

Раздел Template

Оказалось удобным добавить в спецификацию тег, описывающий шаблон документа:

<Template>
  <Path>..\..\ШаблонСчетаНакладнойСчФактуры.xml</Path>
  <Info>
    <Worksheet copies="2">Накладная</Worksheet>
    <Worksheet copies="1">Счет-фактура</Worksheet>
    <Worksheet copies="1">Счет</Worksheet>
  </Info>
</Template>

В данном примере описывается ссылка на файл Microsoft Excel, используемый в качестве шаблона. На сегодня кроме Microsoft Excel поддерживается Microsoft Word и текстовый формат. Однако печать реализована только для Excel и Word (текстовый формат пока что используется только в отладочных целях). Однако добавить поддержку других текстовых форматов не сложно. Как это сделать, вы поймете, прочитав раздел, посвященный реализации печати. Единственное требование к шаблону заключается в том, что он должен быть сохранен в формат .XML или .TXT, так как генератор отчетов просто производит текстовую подстановку специальным образом оформленных имен их значениями.

Во вложенном теге Path должен находиться путь к файлу шаблона.

Во вложенном теге Info может содержаться расширенная информация о шаблоне. Содержимое этого тега должен разбирать объект-printer в своем методе ReadTemplateInfo(). Это позволяет инкапсулировать логику печати в отдельных классах, реализующих интерфейс IPrinter.

Тег Info в данном примере содержит описания печатаемых страниц (worksheets). Каждая страница описывается отдельным тегом Worksheet. Содержимое тега задает название страницы, а атрибут copies – количество копий, которое необходимо распечатать.

Для отчета на базе Microsoft Word тег Info должен содержать только тег Copies, содержимое которого задает количество копий.

Вот как выглядит раздел Template, если шаблоном является документ Microsoft ворд:

<Template>
  <Path>..\..\ШаблонПлатежногоПоручения.xml</Path>
  <Info>
    <Copies>2</Copies>
  </Info>
</Template>

Раздел Includes: Импорт других спецификаций

Вынесение вычислений в раздел Properties – это здорово, так как решает проблему дублирования данных на уровне одной спецификации, но, к сожалению, это не решает проблему дублирования данных в разных спецификациях. Так, почти любая спецификация должна содержать реквизиты фирмы, от чьего имени генерируются документы. Логично было бы вынести их в отдельный файл-спецификацию и импортировать его при необходимости в другие спецификации. Именно так я и поступил (после того как скопипастил очередной блок с реквизитами).

Для реализации этой возможности я ввел в формат спецификации еще один коревой тег – Includes. В него должны быть вложены ноль или более тегов Include, которые должны содержать путь к вставляемым (импортируемым) файлам. Если путь не полный, то он считается относительным по отношению к пути файла основной спецификации. Вот как может выглядеть этот раздел:

<Includes>
  <Include>Реквизиты-К-Пресс.xml</Include>
  <Include>ФормулыДляЖурнальныхДокументов.xml</Include>
</Includes>

Раздел Includes – не обязательный.

Я долго думал, нужно ли загружать из импортируемых файлов что-либо кроме раздела Properties. В конце концов я решил не делать этого (возможно, пока), так как мне попросту этого не надо. Потенциально же можно импортировать и другие разделы, за исключением, пожалуй, раздела Template. Даже раздел Includes можно загружать рекурсивно... Однако все это требует усилий от программиста (то есть от меня любимого) и усложняет код. А ведь нет ничего более вредного, чем бесполезная работа :).

Спецификация в целом

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

<?xml version="1.0" encoding="utf-8"?>
<Specification>
  <Template>
    <Path>..\..\ШаблонСчетаНакладнойСчФактуры.xml</Path>
    <Info>
      <Worksheet copies="2">Накладная</Worksheet>
      <Worksheet copies="1">Счет-фактура</Worksheet>
      <Worksheet copies="1">Счет</Worksheet>
    </Info>
  </Template>
  <Includes>
    <Include>Реквизиты-К-Пресс.xml</Include>
    <Include>ФормулыДляЖурнальныхДокументов.xml</Include>
  </Includes>
  <Properties>
    <НомерЖурнала>2</НомерЖурнала>
    <ГодЖурнала>2008</ГодЖурнала>
    <Дата>08.04.$ГодЖурнала</Дата>
    <ПрефиексНомера>КП-ЧД</ПрефиексНомера>
    <Товар>
      <Цена>500</Цена>
      <ИмяЖурнала>«"Черные дыры" в Российском законодательстве»</ИмяЖурнала>
    </Товар>
  </Properties>
  <Items>
    <Фирма id="АртосГал">
      <Имя>ООО  «Агентство «Артос-ГАЛ»</Имя>
      <ЮрАдрес>107564, г. Москва, ул. 3-я гражданская, д. 3, стр. 2.</ЮрАдрес>
      <ИНН>7718142678</ИНН>
      <КПП>771801001</КПП>
      <Скидка>3</Скидка>
      <Количество>$(if (Фирма_Скидка > 3) 5 else 7)</Количество>
    </Фирма>
    <Фирма id="ВсяПресса">
      <Имя>ООО «Вся пресса»</Имя>
      <ЮрАдрес>109507 г. Москва Ферганский пр., д.10В, стр.1.</ЮрАдрес>
      <ИНН>7721155194</ИНН>
      <КПП>772101001</КПП>
      <Скидка>5</Скидка>
      <Количество>3</Количество>
    </Фирма>
    <Фирма id="ИнтерПочта">
      <Имя>ООО "Интер-Почта-2003"</Имя>
      <ЮрАдрес>119415, г. Москва, ул. Кравченко, д.7</ЮрАдрес>
      <ИНН>7706278427</ИНН>
      <КПП>772901001</КПП>
      <Скидка>6</Скидка>
      <Количество>4</Количество>
    </Фирма>
  </Items>
</Specification>

Базовые теги выделены красным. Далее в статье я буду называть их «разделами».

К спецификации предъявляются следующие требования:

  1. Она должна содержать описание шаблона отчета (XML- или TXT-файла), приведенное выше.
  2. Она должна содержать теги Items и Properties.
  3. Теги, непосредственно (т.е. на один уровень вложенности) вложенные в тег Items, обязаны иметь атрибут «id», содержимое которого используется для формирования имени файла отчета для этого элемента (Item-а). При этом к имени самого вложенного тега не предъявляется никаких особых требований.
  4. Тег Items должен содержать хотя бы один вложенный тег. Иначе просто нечего будет печатать. Каждый вложенный в Items тег рассматривается как описание для одного отчета.
  5. Отчет может иметь необязательный тег/раздел Includes, позволяющий импортировать в данную спецификацию свойства из других спецификаций.

Задача генератора отчетов – прочесть файл спецификации и на его основе построить словарь (ассоциативный массив), в котором именем выступает композиция имен тегов (вложенных в разделы Properties и Items), а значением – содержимое тегов.

Для вложенных тегов формируются композитные имена. Например, для:

    <Мы>
      <Имя>ООО "К-Пресс"</Имя>
    </Мы>

будет сформирована строка:

Мы_Имя=ООО "К-Пресс"

А для:

    <Фирма id="УралПресс">
      <Имя>ООО "Урал-Пресс XXI"</Имя>
    </Фирма>

будет сформирована строка:

Фирма_Имя=ООО "Урал-Пресс XXI"

Отдельные части имен, как видите, разделяются знаком «_» (для лучшей читаемости). Знак «=» используется здесь для отделения ключа от значения.

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

Для каждого отчета (т.е. для каждого тега, непосредственно вложенного в тег Items) формируется свой, уникальный набор переменных состоящий из переменных полученных по разделу Properties, и переменных, полученных по тегу (и его вложенным тегам), для которого генерируется отчет. Так для приведенной выше спецификации для «Корсо-М» (тега Фирма атрибут id которого равен «Корсо-М») набор переменных (за исключением импортированных из включаемых спецификаций) будет таким:

      НомерЖурнала=2
ГодЖурнала=2008
Дата=08.04.$ГодЖурнала
ПрефиексНомера=КП-ЧД

Товар_ТоварЦена=500
Товар_ИмяЖурнала=«"Черные дыры" в российском законодательстве»

Фирма_Имя=ООО  «Агентство «Корсо-М»
Фирма_ЮрАдрес=107564, г. Москва, ул. 2-я гражданская, д. 7, стр. 2.
Фирма_ИНН=7718112678
Фирма_КПП=771808001
Фирма_Скидка=4
Фирма_Количество=235

А для «ВсяПресса» таким:

      НомерЖурнала=2
ГодЖурнала=2008
Дата=08.04.$ГодЖурнала
ПрефиексНомера=КП-ЧД

Товар_ТоварЦена=500
Товар_ИмяЖурнала=«"Черные дыры" в российском законодательстве»

Фирма_Имя=ООО «Всемирная пресса»
Фирма_ЮрАдрес=109507 г. Москва Ферганский бр., д.12А, стр.1.
Фирма_ИНН=7731155194
Фирма_КПП=772106001
Фирма_Скидка=5
Фирма_Количество=3

Красным выделены переменные, общие для обоих отчетов.

После формирования словаря переменных производится банальная замена по контексту. Для каждого тега непосредственно вложенного в раздел Items создается копия шаблона в памяти, в которой производится контекстная замена. Далее результат помещается в файл, имя которого берется из атрибута «id» этого тега (расширение берется у файла шаблона). После этого полученные файлы открываются и печатаются объектом-принтером. Тем, как будет использоваться печать, заведуют классы, реализующие специальный интерфейс:

interface IPrinter : IDisposable
{
  ReadTemplateInfo(info : XElement) : void;
  Print(reportPath : string) : void;
}

За то, какой класс создать, отвечает метод Program.GetPrinter(), который пытается прочесть файл шаблона и определить его тип. Для XML-файлов это делается путем анализа управляющих инструкций, например, в файле Microsoft Excel второй строкой идет инструкция:

<?mso-application progid="Excel.Sheet"?>

Program.GetPrinter() пытается прочесть файл шаблона как XML-файл и найти в нем управляющие инструкции (заключенные в «скобки»: <? и ?>). Если находится управляющая инструкция <?mso-application progid="..."?>, где вместо троеточия содержится формат файла, то производится анализ формата. В случае файла Word progid будет равен «Word.Document», а в случае файла Excel – «Excel.Sheet».

$-нотация

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

Решение проблемы дублирования данных я так же подсмотрел в MSBuild/Ant (в прочем аналогичное решение используется и в строках Nemerle) – это использование $-нотации.

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

<Properties>
  <Мы>
    <Имя>ООО "К-Пресс"</Имя>
    <ИНН>7704563531</ИНН>
    <КПП>770401001</КПП>
    <РассчетныйСчет>40702810738090117201</РассчетныйСчет>
    <КорСчет>30101810400000000225</КорСчет>
    <ВБанке>
      Сбербанк России, ОАО. г. Москва, Мещанское отделение № 7811/068. г. Москва
    </ВБанке>
    <БИК>044525225</БИК>
    <Город>Москва</Город>
    
    <БанковскиеРеквизиты>
      р.с. $Мы_РассчетныйСчет, в $Мы_ВБанке, к.с. $Мы_КорСчет, БИК $Мы_БИК
    </БанковскиеРеквизиты>
  </Мы>

Кроме того, после знака $ может идти выражение, заключенное в скобки:

<ЗарплатаДоВычетаНалогов>10000</ЗарплатаДоВычетаНалогов>
<ПодоходныйНалог>$(ЗарплатаДоВычетаНалогов * 13 / 100)</ ПодоходныйНалог >

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

Выражение может содержать стандартные арифметические операции: +, -, *, /; выражение «if (условие) ... else ...»; операторы сравнения: ==, !=, >, <, >=, <=; скобки и функции.

ПРИМЕЧАНИЕ

Впрочем, выражение «if (условие) ... else ...» и операторы сравнения: ==, !=, >, <, >=, <= я (на всякий случай) «прикрутил» к генератору отчетов во время написания предыдущего абзаца. Вдруг спросят, а у нас их нет! :) Собственно, это увеличило код всего лишь где-то на 10 строк.

Интересной особенностью данной реализации является то, что значения выражений и ссылки на переменные вычисляются непосредственно перед генерацией каждого отчета. В этот момент доступны значения всех переменных, как полученных из раздела Properties, так и из текущего элемента, вложенного из раздела Items. Это позволяет в разделе Properties ссылаться на переменные, которые объявляются только в разделе Items. Это позволяет выносить вычисляемые элементы в раздел Properties, устранив тем самым их дублирование.

Кроме того, становится не важен порядок объявления отдельных тегов/переменных.

И последним преимуществом такого подхода является возможность переопределения переменных, объявленных в разделе Properties в разделе Items. Это позволяет задавать значения, используемые в большинстве элементов раздела Items, один раз в разделе Properties, и переопределять его в тех элементах раздела Items, в которых эти значения должны отличаться.

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

Функции в $-выражениях

На данный момент доступны следующие функции:

Как?

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

Под «как» я понимаю набор технологий и их сочетаний.

Начнем по порядку.

Язык реализации

В принципе решение получилось столь простым, что его без особых проблем можно было бы реализовать на многих языках. Скажем, C# 3.0 для этих целей подходит довольно неплохо. Однако решение выкристаллизовалось только в процессе работы над программой, а поначалу программа была всего лишь прототипом, реализующим мутные и не до конца продуманные идеи. А для подобных задач нужен язык максимально гибкий и минимально напрягающий. Любители скриптов, возможно, предпочли бы для решения подобных задач Python или Ruby, но я не поклонник оных. Зато не для кого ни секрет, что в последнее время я увлекаюсь Nemerle. Так вот этот язык и был выбран для реализации. И мне кажется, что не зря. Реализация первой версии заняла один вечер (порядка 2.5 часов) и принесла мне массу удовольствия. В этот же вечер я с успехом применил генератор на пользу обществу. Конечно, в первой версии не было и половины возможностей, и в дальнейшем пришлось еще долго «шлифовать» и дорабатывать код, но это позволило опробовать идею в бою и признать сам подход верным.

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

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

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

В первой версии генератора отчетов не было ни поддержки раздела Includes, ни возможности печати файлов Word, ни вычислений в полях. Когда эти возможности потребовались, я смог быстро их добавить. Настолько быстро, что мне проще было добавить возможность, нежели пытаться обходиться без нее.

Бесклассовое общество :)

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

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

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

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

Это действительно проблема. Но эта проблема устраняется поддержкой свертки (outlining-а) в Microsoft Visual Studio. Вы можете «свернуть» все локальные функции, получив при этом общую картину происходящего. Затем вы можете разворачивать те функции, к изучению которых вы приступаете, и получать общую картину этих функций. Ну и так далее, пока вы не дойдете до самых «глубин знания» :).

Еще один аргумент критиков подобного подхода – отсутствие типов у параметров локальных функций и переменных. Слава богу, этот предрассудок уходит в прошлое по мере того, как все больше людей начинают использовать C# 3.0, в котором доступен вывод типов (хотя и весьма ограниченный). Думаю, после выхода новой версии стандарта C++, в которой тоже появится аналогичная возможность, данный аргумент окончательно канет в Лету. Для тех же, кто пока не проникся данной возможностью, приведу только некоторые аргументы в пользу локального вывода типов:

  1. IDE (Microsoft VS) позволяет узнать точный (полный) тип переменной, просто подведя курсор мыши к имени переменной или ссылке на нее.
  2. Зачастую тип дублируется (например, при создании объекта). Вывод типов позволяет избавиться от дубляжа.
  3. Код, в котором не указаны типы, априори полиморфен и при изменении типов обрабатываемых данных существенно реже требуется его модификация. Это ускоряет процесс исследовательского программирования, делая его реально применимым.
  4. Ну и наконец, вывод типов не обязателен. Вы вольны везде, где считаете нужным (а не только при объявлении переменных) указать, что ожидаете тот или иной тип, и если это не так, компилятор скажет вам об этом.

Естественно, что, закончив прототипирование, вы вольны переписать код в «классовой» манере, если, конечно, захотите. Впрочем, я считаю, что классы нужны там, где есть полиморфизм. А если его нет, то они только захламляют код или в лучшем случае выступают в роли модуля.

Исходя из вышесказанного я решил не приводить код генератора отчетов целиком (хотя он весьма невелик). Кому надо, скачает проект, прилагающийся к статье (или возьмет его с диска) и посмотрит код в IDE (которую тоже можно взять на диске или по ссылке в начале статьи). Код же в статье будет приводиться в «свернутом» виде. Сначала я покажу вам общую картину, а затем отдельные функции.

Описание реализации

Чтение спецификации

Заниматься разбором содержимого XML-файлов вручную – задача неблагодарная. Собственно, сам выбор XML в качестве формата в первую очередь определялся наличием массы API для его парсинга. Однако до недавнего времени удобных API в .NET, как ни странно, не было.

Меж тем на горизонте появился новый API – System.Xml.Linq, который и стал объектом исследования в данном проекте. Прямо в его описании сказано, что этот API создан в функциональном стиле, а функциональный стиль – это конек Nemerle. Разумно было бы предположить, что новый API должен оказаться удобным для использования в Nemerle, и очень хотелось верить, что новый API окажется просто удобным. Причем не то чтобы просто удобнее, нежели предыдущие, а удобным без каких бы то ни было оговорок. Забегая вперед, скажу, что так и оказалось. Впрочем, не обошлось и без ложки дегтя. Так, мне очень не понравилось, что в случае отсутствия некоторого тега метод Element(имя_тега) возвращает null. Это приводит к тому, что если значение тега не важно, приходится городить дополнительные проверки. А если их не делать, получаются невнятные сообщения об ошибках. Лучше было бы возвращать некий пустой элемент, у которого нет ничего (даже имени), или использовать другой, удобный для ФП подход.

Для использования System.Xml.Linq нужно подключить практически одноименную сборку System.Xml.Linq.dll, идущую в поставке .NET Framework 3.5.

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

Основной класс реализации

Как я уже говорил раньше, я не буду приводить полный код. Вместо этого я буду приводить код в том виде, в котором вы сможете увидеть его в IDE. Одной из возможностей интеграции Nemerle с VS 2008 является поддержка сворачивания (outlining-а) фрагментов кода. Эта возможность как нельзя кстати подходит для Nemerle, так как позволяет сворачивать тела локальных функций и выражений, идущих после => в операторе match.

Модуль Program

Основная логика программы реализована в модуле Program (модуль – это статический класс, в котором просто не надо везде писать ключевое слово static). Этот модуль содержит всего 4 функции. Прежде чем приступить к их описанию, я приведу список using-ов используемый в этом модуле:

        using Nemerle.Compiler;
using Nemerle.Imperative;
using Rsdn.Janus.Framework;
using System.Collections.Generic;
using System.Console; // Это позволяет использовать методы Console напрямуюusing System.Convert; // Это позволяет использовать методы Convert напрямуюusing System.IO.Path; // Это позволяет использовать методы Path напрямуюusing System.IO;
using System.Text.RegularExpressions;
using System.Xml;
using System;
// Провайдеры методов-расширений:
using Nemerle.Utility; // Родные методы-расширения Nemerle
using System.Linq;     // Пространство имен LINQ to Object
using System.Xml.Linq; // Пространство имен LINQ to XML

using PT = Nemerle.Compiler.Parsetree;
usingHashtable = Nemerle.Collections.Hashtable;

namespace NReporter
{
...

Одна из четырех функций – это статический конструктор:

        static
        this()
{
  // RusNumber - класс (написанный на C#), преобразующий числа в строки 
// прописью на русском языке.
  def t = typeof(RusNumber);
  // Добавляем описание функций, используемых в скрипте.
  // Если вам понадобится расширить список поддерживаемых функций,
// просто добавьте описание функции здесь.
  //                     тип  Реальное имя     Имя в скрипте  кол.парам.
  ScriptFuncs.RegistrFunc(t, "ToRubles",      "РублиПрописью", 1);
  ScriptFuncs.RegistrFunc(t, "RusSpelledOut", "СуммаПрописью", 2);
  ScriptFuncs.RegistrFunc(t, "RusSpelledOut", "СуммаПрописью", 5);
}

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

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

Вторым методом является метод Main, который тоже не содержит ничего сложного или выдающегося:

Main(args : array[string]) : void
{
  match (args.Length)
  {
      // Если аргумент один, то не печатаем документы
    | 1 with skipPrint = false  
    | 2 with skipPrint = args[1].ToLower() == "skip-print" => 
        try { DoReport(args[0], skipPrint) }
        catch
        {
          | e isException =>
            def printExceptionsList(exc, prefix = " ")
            {
              WriteLine(prefix + exc.Message);
              when (e.InnerException != null)
                printExceptionsList(e.InnerException, prefix + " ");
            }
            
            Console.ForegroundColor = ConsoleColor.Yellow;
            WriteLine("Во время работы программы произошло исключение:");
            Console.ForegroundColor = ConsoleColor.Red;
            printExceptionsList(e);
            Console.ResetColor();
        }
        
    | _ => WriteLine(
      "Usage: NReporter.exe path-to-specification-file [skip-print]\n"
"skip-print - skip print of a generated documents (only generate it)")
  }
  
  WriteLine("Готово!...");
  _ = ReadLine();
}

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

Третьим методом является основной метод программы – DoReport. Именно потому, что он основной и самый огромный (непривычно огромный для программиста, выросшего на принципах ООП), его описание я приведу последним.

Четвертый метод – GetPrinter. В его задачи входит определение подтипа (скажем так) файла шаблона и создание соответствующего объекта-принтера. Вот код этого метода:

GetPrinter(templatePath : string) : IPrinter
{
  when (GetExtension(templatePath).ToLower() == "txt")
    return null; // Печать txt-файлов пока не поддерживается
    
  // Читаем XML-теги, и если среди них есть ProcessingInstruction 
// с именем mso-application, пытаемся определить тип приложения, 
// создавшего этот документ.
  def res = RawXml.ReadLazy(templatePath).Find(data => 
         data.NodeType == XmlNodeType.ProcessingInstruction
      || data.NodeType == XmlNodeType.Element);

  match (res)
  {
    | Some(x) when x.NodeType == XmlNodeType.Element => null// Нет <? ?>
    | Some(x) when x.Name == "mso-application" => // Файл MS Ofice
      if      (x.Value == <#progid="Excel.Sheet"#>)   ExcelPrinter()
      else if (x.Value == <#progid="Word.Document"#>) WordPrinter()
      else                                            null
    // Здесь можно добавить другие приложения-принтеры
    | _ => null 
  }
}

В начале проверяется, не является ли файл простым текстовым файлом, и если это так, возвращается null. Возврат null этим методом означает, что для данного типа файла не удалось определить объект-принтер. Таким образом, печать плоского текста не поддерживается. Ее нетрудно добавить, но в этом, на мой взгляд, попросту нет нужды.

Зато поддерживается печать фалов форматов Microsoft Excel и Microsoft Word, сохраненных в формате XML. Именно эти форматы и были мне нужны.

Определить, что файл является файлом Microsoft Office, можно, проанализировав его содержимое и попытавшись найти управляющую инструкцию (processing instruction) XML с именем"mso-application". Если при этом значение инструкции будет progid="Excel.Sheet", то это файл Excel, а если progid="Word.Document", то Word.

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

Первое, что требуется пояснить – это что такое RawXml и его метод ReadLazy(). RawXml – это простенькая обертка над XmlReader, которая позволяет превратить обработку сырого (не форматированного, где не производится построение некой объектной модели вроде XmlDom) XML из большого и неуклюжего цикла в набор запросов а-ля LINQ (являющихся на самом деле функциональной записью). Вот код этого класса:

        using System.Xml;
using System.Collections.Generic;

namespace NReporter
{
  /// Этот класс-помошник позволяет работать с информацией, предоставляемой/// XmlReader в функциональном стиле (как с перечислением).
  [Record]
  structRawXml
  {
    public NodeType : XmlNodeType;
    public Name     : string; 
    public Value    : string;
    
    publicstatic ReadLazy(path : string) : IEnumerable[RawXml]
    {
      using (def reader = XmlReader.Create(path))
        while (reader.Read())
          yieldRawXml(reader.NodeType, reader.Name, reader.Value)
    }

    // Вывод в строку для отладочных целей.publicoverride ToString() : string
    {
      $"NodeType: $NodeType Name: '$Name' Value: '$Value'"
    }
  }
}

Думаю, что пояснять тут нечего. Все и так очевидно.

Так вот, используя этот класс можно производить последовательный поиск в XML. В данном случае метод Find ищет первую ProcessingInstruction или первый XML-тег. Инструкции идут раньше тегов, поэтому, если найден тег, то инструкции отсутствуют. К тому же в форматах XML из Microsoft Office инструкции, определяющие формат файла, всегда идут первыми (если это не так, код придется переписывать :) ).

Далее найденная инструкция анализируется и определяется тип файла. Может возникнуть вопрос, что же такое Some?. Дело в том, что Find может ничего и не найти. Поэтому Find возвращает результат, запакованный в тип option[T]. Это простой вариант с двумя вхождениями None() и Some(value : T). Если ничего не найдено, возвращается None(). В обратном случае возвращается экземпляр Some, в который помещается найденное значение. Это, можно сказать, аналог nullable-тиов, но который может работать как с типами-значениями, так и со ссылочными типами. Таким образом, строка:

| Some(x) when x.NodeType == XmlNodeType.Element => null// Нет <? ?>

означает, что найден некий элемент, отвечающий критериям поиска, его значение помещено (сопоставлено с) в переменную x, и значение поля NodeType у этого элемента равно XmlNodeType.Element, то есть был найден XML-тег, что свидетельствует, что XML-инструкций в файле нет.

Далее, я думаю, все понятно. Единственное, о чем стоит сказать – это о формате строк. <#progid="Excel.Sheet"#> аналогичен "progid=\"Excel.Sheet\"" в С или @"progid=""Excel.Sheet""" в C#, но не требует ломать глаза.

В общем, на выходе у функции GetPrinter() имеется ссылка на объект-принтер или null, если не удалось распознать тип файла шаблона.

Главный метод приложения DoReport

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

СОВЕТ

Сразу хочется предупредить, что положение локальных функций важно. Их нельзя передвинуть куда-то выше или ниже или поменять местами с другими локальными функциями или локальными переменными. Дело в том, что локальные функции захватывают контекст, в котором они объявлены, создавая так называемые замыкания. Это означает, что локальные функции могут использовать локальные переменные и функции, объявленные выше. В каком-то смысле это заменяет классы и позволяет реализовывать нечто похоже на публичные поля, не объявляя ни новых типов, ни полей, и даже не создавая их экземпляров. Конечно, такой подход хорош далеко не во всех случаях, но он очень хорош для быстрого старта или для решения относительно простых задач.

В данном проекте содержится всего 9 типов, один из которых – интерфейс, два – реализации этого интерфейса, а четыре – и вовсе обертки. Если писать этот же код в ООП-стиле, то типов были бы многие десятки.

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

Ниже приводится код этого метода, так, как бы вы увидели его в IDE ():

  DoReport(specPath : string, skipPrint : bool) : void
  {
    def messages = HashSet();
    def error  (msg) { _ = messages.Add($"Ошибка: $msg"); }
    def warning(msg) { _ = messages.Add($"Предупреждение: $msg"); }
    def spec = XElement.Load(specPath); // Загружаем спецификацию из файла
    def specDir = GetDirectoryName(specPath);
    def specFileName = GetFileNameWithoutExtension(specPath);
    
    /// Функция, получающая текстовое значение XML-элемента с заданным именем
def val(elem, tagName) { elem.Element(tagName).Value }

    def propertyVars   = Hashtable(); // список свойств из файла спецификации
    def rxOptions      = RegexOptions.Compiled | RegexOptions.Singleline;
    def findWhiteSpace = Regex(@" {2,}", rxOptions);
    /// Считывает вложенные теги и формирует из них словарь (хэш-таблицу),
    /// где ключом является сумма имен тегов (имя переменной), 
/// а значением - его содержимое.
/// Например, если есть теги:
/// <Товар><Имя>Товар 1</Имя><Количество>2</Количество></Товар>,
/// то эта функция заполнит словарь переменными:
/// "Товар_Имя"="Товар 1"
/// "Товар_Количество"="2"
/// где до знака '=' идет имя переменной, а после - ее значение.
+def fillDic(elem : XElement, dictionary, prefix = "")...+def loadProperties(spec) ...+def loadIncludePaths()...

    foreach(includePath in loadIncludePaths())
      loadProperties(XElement.Load(includePath));
      
    loadProperties(spec); // Загружаем свойства из главной спецификации

+def loadTemplateInfo()...
    def (templateText, templatePath, templateInfo) = loadTemplateInfo();
    def extention = GetExtension(templatePath);
    // С помощью этого регулярного выражения в тексте шаблона будут 
    // находиться заполнители, подлежащие замене.
// Заполнители - это имена переменных, обрамленные знаками ##.
// Имена могут состоять из букв, цифр и знаков '_'. 
    def findPlaceholders    = Regex(@"##((?:\w|\d|_)+)##", rxOptions);
    // Переменные, используемые внутри значений других переменных
    def referencedVars      = HashSet(); 
    def unknownPlaceholders = HashSet(); // Найденные неизвестные заполнители
    // compilerHost - это движок компилятора Nemerle. Он 
// требуется для реализации интерпретатора выражений (ниже).
    def compilerHost        = CompilerHost();
    // Формирует отчет для заданного элемента (Item-а) и, если skipPrint
    // равен true, печатает его содержимое.
+def makeReport(item : XElement, num : int)...


    using (def appPrinter = if (skipPrint) null 
                            else GetPrinter(templatePath))
    {
      when (appPrinter != null)
        appPrinter.ReadTemplateInfo(templateInfo);
      
      // Перебираем все элементы, вложенные в элемент Items, формируем
// и печатаем для каждого из них отдельный отчет.
def items = spec.Element("Items").Elements().ToList();
      foreach (i in [0..items.Count - 1])
      {
        def reportPath = makeReport(items[i], i + 1);

        when (appPrinter != null)
          appPrinter.Print(reportPath); // Печатаем отчет...
      }
    }

    // Выводим на консоль список заполнителей, не совпадающих ни с 
// одним именем переменной.
    foreach (placeholder in unknownPlaceholders.OrderBy(x => x))
      warning($"Найден неизвестный заполнитель '$placeholder'!");
    
+def printMessagese(filter)...// Выводим список ошибок (красным цветом)
    Console.ForegroundColor = ConsoleColor.Red;
    printMessagese(_.StartsWith("Ошибка:"));
    // Выводим список предупреждений (стандартным цветом)
    Console.ResetColor();
    printMessagese(x => !x.StartsWith("Ошибка:"));
      
    WriteLine("Готово!...");
    _ = ReadLine();
  }

Первые же строки этого метода демонстрируют описанный в примечании подход:

def messages = HashSet();
def error  (msg) { _ = messages.Add($"Ошибка: $msg"); }
def warning(msg) { _ = messages.Add($"Предупреждение: $msg"); }

В данном случае вводятся две локальных функции error и warning, которые «замкнуты» на переменную messages. Далее по коду можно использовать эти функции, даже не догадываясь, что на самом деле они производят добавление элементов в messages. Можно даже передать ссылку на эти функции в любое другое место, и все, кто ими воспользуется, также смогут добавить элементы в messages, тоже не подозревая об этом. Другими словами, таким образом можно добиться локальной инкапсуляции. Грамотно пользуясь данным приемом, можно сделать код существенно понятнее, а значит, проще в поддержке.

Кстати, HashSet используется здесь, чтобы избежать появления множества однотипных сообщений. HashSet – это новый тип, появившийся в .NET Framework. Ранее вместо него приходилось использовать Dictionary/Hashtable, но при этом приходилось придумывать какое-то ненужное значение.

Локальные функции также прекрасно подходят для уменьшения шума в исходниках. Например, если вы не хотите каждый раз писать:

elem.Element("tagName").Value

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

        def val(elem, tagName) { elem.Element(tagName).Value }

и далее писать:

val(elem, "tagName")

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

Далее по коду следуют декларации локальных переменных. В переменную propertyVars в дальнейшем будут помещены значения переменных, полученных по разделу Properties. Как вы помните, эти переменные имеют одинаковые значения для всех отчетов, генерируемых по одной спецификации. Обратите внимание, что Hashtable – это наследник типа Dictionary[K,V], а не нетипизированная Hashtable из первого Framework-а (как это может показаться на первый взгляд).

Параметры типов для этого типа автоматически выводятся компилятором. В данном случае выводится тип Dictionary[string, string], что можно увидеть, подведя курсор к имени переменной:


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

fillDic

Локальная фунция fillDic – это первая более-менее сложная функция, относящаяся к логике программы.

Задача fillDic – читать переданный ему XML-элемент и его вложенные элементы, и формировать по ним набор переменных. Сформированные переменные, после удаления из их значений незначимых пробелов, добавляются в словарь, ссылка на который передается через параметр dictionary. Вот код этой функции:

          def fillDic(elem : XElement, dictionary, prefix = "")
{
  def name = prefix + elem.Name.LocalName;
  if (elem.HasElements)
  {
    def prefixSubElem = name + "_";
    foreach (subElem in elem.Elements())
      fillDic(subElem, dictionary, prefixSubElem);
  }
  else// Удаляем дублирующиеся пробелы перед тем как запомнить значение.
    dictionary[name] = findWhiteSpace.Replace(elem.Value, " ").Trim();
}

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

loadProperties

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

Фактически задача чтения переменных едина и реализована в локальной функции fillDic. Так что различия заключаются только в том, откуда их считывают. Таким образом, логичным выглядит создать метод или локальную функцию, которые получали бы из спецификации раздел Properties и скармливали его содержимое функции fillDic. Именно этим и занимается функция loadProperties:

        def loadProperties(spec)
{
  // Считываем значения переменных, расположенных в разделе Properties, 
// и формируем из них словарь. Значения этого словаря будут 
// подставляться при каждой обработке шаблона (для каждого Item).
foreach(propertyElem in spec.Element("Properties").Elements())
    fillDic(propertyElem, propertyVars);
}

Уверен, что пояснять тут нечего. Зато имеется одна загвоздка. Нам нужно загрузить свойства не только из основной спецификации, но и из импортируемых спецификаций, ссылки на которые содержатся в основной спецификации. Процессом выявления путей импортируемых спецификаций занимается функция loadProperties.

loadProperties

        def loadIncludePaths()
{
  def includes = spec.Element("Includes").ElementsEx("Include");
  includes.Map(elem => Combine(specDir, elem.Value))
}

Несколько лет назад я написал бы ее с использованием циклов и if-ов. Уверен, что в результате получился бы весьма объемный и запутанный код. Но теперь я могу мыслить функционально, а значит выражать свои мысли более кратко и более понятно. Что же значит «функционально»? В данном случае это значит, что данная функция разделяется (для меня сейчас) на два этапа:

  1. Получение списка тегов, описывающих импортируемые (включаемые) спецификации. Это делает первая строка.
  2. Преобразование (отображение) списка тегов в список строк, представляющих собой относительные (по отношению к файлу основной спецификации) пути к включаемым файлам. Отображение осуществляется функцией Map. Ей передается список тегов и функция, которая должна применяться к каждому элементу этого списка (для их преобразования). В данном случае это безымянная функция (лямбда), читающая значения тегов и присоединяющая их с путем к основной спецификации при помощи функции System.IO.Path.Combine. Путь получается и помещается в локальную переменную path в предыдущей строке. Эта переменная захватывается лямбдой.
ПРИМЕЧАНИЕ

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

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

В этом коде, однако, есть одна неочевидная хитрость. Обратите внимание на метод ElementsEx(), выделенный в коде красным. Это не метод XElement, а метод-расширение, написанный мной. Дело в том, что если первую строчку написать с использованием Elements(), то код будет генерировать исключение (NullReferenceException), если в файле спецификации не будет содержаться элемента Includes. Помните, выше я говорил про ложку дегтя? Это как раз она. Разработчики LINQ to XML утверждают, что данная библиотека предназначена для использования в функциональном стиле, но возврат null (в случае отсутствия данных) резко препятствует этому. В общем, на мой взгляд, это явная ошибка проектировщиков библиотеки. Чтобы обойти эту проблему, есть два пути:

  1. Отказаться от функционального стиля и напичкать свой код if-ами, проверяющими на null все опасные места. Впрочем, в Nemerle if-ы – тоже выражения, но их применение сделает код более «рыхлым» и уж точно менее понятным.
  2. Попытаться обойти эту проблему.

На мой взгляд, второй вариант более приемлем. Обойти возврат null можно, воспользовавшись оператором «??» и возвратив в случае null объект-фальшивку, не имеющий вложенных элементов:

        def includesElem  = spec.Element("Includes") ?? XElement("null");
def includes      = includesElem.Elements("Include");
def path          = GetDirectoryName(specPath);
includes.Map(elem => Combine(path, elem.Value))

или воспользоваться интересным побочным эффектом методов-расширений – возможностью обрабатывать ситуацию, когда первый параметр переданный методу, равен null.

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

        public ElementsEx(this elem : XElement, name : XName) : IEnumerable[XElement]
{
  if (elem == null) []
  else              elem.Elements(name)
}

Применение этого метода приводит к тому, что если элемент (в данном случае Includes) отсутствует и метод Element() возвратит null, то функция ElementsEx() просто возвратит пустой список, что как раз то, что нужно!

Библиотеки, которые действительно рассчитаны на применение в функциональном стиле, обычно пишутся так, чтобы не допускать возврата null. Вместо этого или возвращаются некие объекты-заместители, или результат помещается в специальный тип (вариант) option[T]. Этот вариант содержит два вхождения option[T].Some(x : T) и option[T].None(). Последний возвращается, если результат недоступен (например, запрошенный тег отсутствует). Это гарантирует, что во время исполнения не будет сгенерировано исключение NullReferenceException. При этом компилятор сам подскажет, что требуется проверить возвращаемое значение на «существование». Кроме того, это позволяет создать перегруженные методы-расширения, которые позволят добиться того же эффекта, что и приведенный выше метод-расширение ElementsEx():

        public Elements(this elemOpt : option[XElement], name : XName) 
: IEnumerable[XElement]
{
  match (elemOpt)
  {
    | Some(elem) => elem.Elements(name)
    | None()     => [] // пустой список приводится к IEnumerable[XElement]
  }
}

Применение option[T] делает код более безлопастным и предсказуемым, однако приводит к созданию временных объектов-оберток, что может быть нежелательно в некоторых случаях (например, если метод, возвращающий option[T], используется в циклах с большим объемом итераций).

Чтение свойств

При наличии двух описанных выше функций чтение переменных из раздела Properties как основной, так и импортируемых спецификаций не представляет проблем:

        foreach(includePath in loadIncludePaths())
  loadProperties(XElement.Load(includePath));
  
loadProperties(spec); // Загружаем свойства из главной спецификации

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

loadTemplateInfo

Локальная функция loadTemplateInfo загружает из раздела Template данные, описывающие файл шаблона, используемый для генерации отчетов по данной спецификации:

        def loadTemplateInfo()
{
  def template = spec.Element("Template");
   // Путь к файлу шаблона
def templatePath = Combine(specDir, val(template, "Path"));def templateText = IO.File.ReadAllText(templatePath); // Текст шаблона
def templateInfo = template.Element("Info"); // Доп. информация
  (templateText, templatePath, templateInfo)
}
def (templateText, templatePath, templateInfo) = loadTemplateInfo();
def extention           = GetExtension(templatePath);

Код фактически самодокументирован. Пояснения требует только способ возврата результатов этой функции. Эта функция возвращает сразу несколько результатов. В C# я был бы вынужден или создать класс/структуру и вернуть данные через нее, или создать метод, возвращающий данные через out-параметры, или (если все возвращаемые значения имеют один тип) возвратить список объектов. Все перечисленные варианты приводят к существенному увеличению кода. Кроме того, они требуют передачи ссылки на спецификацию через отдельный параметр. В Nemerle же для возврата нескольких значений из функции можно воспользоваться кортежем. Идея кортежа очень похожа на идею списка аргументов функции. Если мы можем передать набор неименованных, разнотипных значений функции, то почему бы не делать то же самое при возврате значений из нее? Кортеж и является таким набором. В отличие от списка, количество элементов кортежа предопределено. Зато каждое значение кортежа может иметь уникальный тип.

Последняя строка функции loadTemplateInfo – это формирование и возврат кортежа:

(templateText, templatePath, templateInfo)

А первая строка за ней – это вызов этой функции и одновременная декомпозиция возвращаемого ею кортежа:

        def (templateText, templatePath, templateInfo) = loadTemplateInfo();

Кстати, декомпозиция не является обязательной при использовании кортежей. Если некая другая функция принимает список параметров, совпадающий по количеству и типам с кортежем, возвращаемым другой функцией, то можно просто передать результат одной функции другой. Декомпозиция при этом будет осуществлена автоматически. Передать результат можно как напрямую (func1(fanc2())), так и через переменную или поле.

Описать тип кортежа можно, перечислив типы входящих в него элементов через звездочку – «*». Так, тип для loadTemplateInfo() будет: «string * string * XElement». Именно это вы увидите, если подведете курсор к имени функции.

makeReport

Предыдущие функции подготавливали необходимую информацию для функции makeReport. Она принимает XML-элемент из раздела Items вместе с его порядковым номером и формирует отчет. Ниже приведен код этой функции и код, ее вызывающий.

        // Формирует отчет для заданного элемента (Item-а) и, если skipPrint

        // равен true, печатает его содержимое.

        def makeReport(item : XElement, num : int)
 {
   // Вычисляет $-выражения в переменных.
// $-выражение может быть двух видов: $ИмяПеременной или $(выражение).
// Их вхождения заменяются значениями соответствующих переменных 
// или результатом вычисления выражений.
+def calcVarsValues(dic, num : int)...// Производит замену заполнителей (в копии шаблона) значениями 
// переменных, совпадающих с именем заполнителя.
// dictionary - словарь, содержащий ассоциативный список переменных
// и их значений (для обрабатываемого элемента Item-а).
+def doReplace(dictionary) : string...// Инициализируем значение словаря переменных, подлежащих замене,
// списком свойств (общих для всех элементов).
def allVars = Hashtable(propertyVars);
   // Добавляем в словарь переменные, специфичные для отдельного отчета.
// Таким образом, после этой операции allVars будет содержать
// список переменных, полученных из раздела Properties, объединенный 
// со списком переменных, полученых для конкретного отчета из 
// раздела Items.
foreach (reportElem in item.Elements())
     fillDic(reportElem, allVars, item.Name.LocalName + "_");
     
   calcVarsValues(allVars, num);
     
   // Формируем полный путь к файлу отчета.
def dir = Combine(specDir, specFileName);
   
   when (Directory.Exists(dir))
     Directory.Delete(dir, true);
   
   _ = Directory.CreateDirectory(dir);
   // Значение атрибута "id" будет использоваться для формирования 
// имени файла, генерируемого для данного элемента.
def reportPath = Combine(dir, item.Attribute("id").Value + extention);
   // Производим замену заполнителей значениями соответствующих 
// переменных.
def resultText = doReplace(allVars);
   // Записываем сгенерированный отчет в файл.
File.WriteAllText(reportPath, resultText, Text.Encoding.UTF8);
   
   reportPath
 }

 using (def appPrinter = if (skipPrint) nullelse GetPrinter(templatePath))
 {
   when (appPrinter != null)
     appPrinter.ReadTemplateInfo(templateInfo);
   
   // Перебираем все элементы, вложенные в элемент Items, формируем
// и печатаем для каждого из них отдельный отчет.
def items = spec.Element("Items").Elements().ToList();
   foreach (i in [0..items.Count - 1])
   {
     def reportPath = makeReport(items[i], i + 1);

     when (appPrinter != null)
       appPrinter.Print(reportPath); // Печатаем отчет...
   }
 }

На мой взгляд, в этом коде нечего объяснять, разве что конструкцию:

foreach (i in [0..items.Count - 1])

Она заменяет цикл вида:

        for (mutable i = 0; i < items.Count; i++)

Другими словами, этот код перечисляет индексы коллекции. Первая запись лучше отражает суть алгоритма. Так что если вам она кажется непонятной, не спешите с выводами. Скорее всего, вы просто к ней не привыкли.

calcVarsValues

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

        // Вычисляет все переменные (вхождения словаря), содержащие $-выражения
// $-выражение может быть двух видов: $ИмяПеременной или $(выражение).
// Их вхождения заменяются значениями соответствующих переменных 
// или результатом вычисленичя выражений.

        def calcVarsValues(dic, num : int)
{
+def calcOneVarValue(key, recursionSet = HashSet())...foreach (varName in dic.Keys.ToArray())
    calcOneVarValue(varName);
}

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

calcOneVarValue

Как видите, все, что делает calcVarsValues – вызывает calcOneVarValue для каждого имени переменной (вхождения словаря). Однако код calcOneVarValue уже намного сложнее. Вот он:

        def calcOneVarValue(key, recursionSet = HashSet())
{
  def value = dic[key];
  when (value.Contains('$'))
  {
+def calcSplice(_spliceExpr : PT.PExpr) : object...def startLoc = Location(specPath, 1, 1, 1, 1);
    // make_splice_distribution - производит парсинг строки,
// выявляя в ней $-выражения. На выходе он возвращает список 
// состоящий из строковых литералов (запакованных в 
// вариант-вхождение StrPart.Lit), выражений (запакованных 
// в StrPart.Expr) и др. вхождения StrPart, которые не интересны 
// в данном случае.
def res = StringTemplate.Helper.make_splice_distribution(
      value, startLoc, compilerHost.CoreEnv);
    def sb = Text.StringBuilder();
    
    foreach (part in res.Rev())
    {
      | Lit(str   : string)   => _ = sb.Append(str)
      | Expr(expr : PT.PExpr) => _ = sb.Append(calcSplice(expr))
      | NewLine               => _ = sb.AppendLine()
      | _ => error($"Конструкция '$part' не поддерживается");
    }
    
    if (!recursionSet.Add(key))
    {
      dic[key] = "!!!Рекурсивное определение переменной!!!";
      error($"Переменная '$key' имеет рекурсивное объявление! "
          + $"В рекурсии участвуют: ..$recursionSet");
    } // Вычисленное значение помещаем обратно в словарь.
else dic[key] = sb.ToString();
  }
}

Эта функция проверяет, содержится ли в строке символ «$» (именно им помечаются ссылки на переменные и вычисляемые выражения); если символ не найден, то ничего не делается. Если же символ найден, то производится разбор строки на предмет распознавания в ней тех самых ссылок на переменные и выражения. Код парсинга подобных конструкций очень сложен. Фактически даже код выявления $-выражений весьма непрост, а уж парсинг самих выражений и подавно. К счастью, всю эту работу можно переложить на функцию make_splice_distribution(), находящуюся в модуле Helper из пространства имен StringTemplate, которое является частью стандартной библиотеки макросов Nemerle. Изначально эта функция была создана для реализации $-строк в Nemerle (вы не раз видели их в коде выше), а потом была адаптирована для использования в библиотеке Nemerle.StringTemplate (типизированный аналог широко известной в узких кругах скриптовой библиотеки StringTemplate, доступной на Ява и .NET).

Функция make_splice_distribution() позволяет разобрать строку и вычленить из нее части. Части представляются вариантом StrPart (из того же пространства имен). Вот его описание:

        public
        variant StrPart
{
  | Lit           { str    : string; }
  | Expr          { expr   : PT.PExpr; }
  | NewLine
  | IndentedExpr  { indent : string; expr : PT.PExpr; }
  
  publicoverride ToString() : string
  {
    match (this)
    {
      | Lit(str)                   => $"Lit: '$str'"
      | Expr(expr)                 => $"Expr: $expr"
      | NewLine                    => "<\n>"
      | IndentedExpr(indent, expr) => $"IndentedExpr: '$expr' ('$indent')"
    }
  }
}

Функция make_splice_distribution использует компилятор. Поэтому для ее использования придется создать специальный класс CompilerHost:

        class
        CompilerHost : ManagerClass
{
  publicthis()
  {
    base(CompilationOptions());
    InitCompiler();
    LoadExternalLibraries();
  }
}

Экземпляр CompilerHost находится в практически одноименной переменной compilerHost, которая объявлена выше по коду (вне вычислительных функций), и которая захватывается контекстом данной функции (это обеспечивает возможность ссылаться на один экземпляр данного класса, сколько бы вычислений выражений не производилось).

Собственно, функции make_splice_distribution требуется не сам CompilerHost, а создаваемый внутри него объект CoreEnv, хранящий контекст парсинга. Кроме того, make_splice_distribution требуется начальное местоположение (относительно файла с кодом) и строковое значение, которое предстоит разобрать. Конечно местоположение (Location) желательно было бы задать поточнее, но лень снова взяла верх, и я отказался от этой прекрасной идеи.

После выполнения make_splice_distribution нужно разобрать возвращаемый ею список. Сначала я написал рекурсивную функцию, которая делала это, но потом, немного подумав, заменил ее на foreach, так как он более точно отражает суть происходящего. Внутри цикла (foreach) производится обратная сборка строки (которая была разобрана с помощью make_splice_distribution). Но при этом производится вычисление выражений. Это делается с помощью функции calcSplice, описанной выше по коду. Ее код я приведу чуть ниже. А пока я объясню, зачем нужен if, идущий за циклом.

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

Рекурсия возникает вследствие того, что функция calcOneVarValue может быть вызвана из calcSplice (а та исходно вызывается из calcOneVarValue).

Так вот, для предотвращения рекурсии имена переменных значения, которых вычисляются в данный момент, помещаются в параметр recursionSet (типа HashSet). HashSet позволяет быстро определить, содержится ли в нем некий ключ, и не допускает дублирования ключей. Фактически HashSet – это упрощенная HashSet (именно поэтому в .NET Framework долгое время не было класса, аналогичного HashSet, но слава богу, теперь он есть).

Перед тем, как поместить вычисленное значение переменной обратно в словарь, производится проверка наличия имени этой переменной в recursionSet, и если оно там есть, сообщение о рекурсивном определении, а вместо вычисленного значения в переменную помещается строка "!!!Рекурсивное определение переменной!!!". Это, с одной стороны, привлекает внимание человека, создающего отчет (если он забыл поглядеть на предупреждения), а с другой, позволяет избежать повторного зацикливания и других проблем.

Пора вернуться к описанию пропущенной функции calcSplice.

calcSplice

Эта функция вычисляет значение одного $-вхождения:

        def calcSplice(_spliceExpr : PT.PExpr) : object
{
  | <[ Convert.ToString($expr) ]> =>
+def calcPExpr(expr) : object...and calcOper[T](e1 : PT.PExpr, oper : double * double -> T, e2 : PT.PExpr)
     : object
    {
      oper(ToDouble(calcPExpr(e1)), ToDouble(calcPExpr(e2)))
    }
    
    calcPExpr(expr)
    
  | term => 
    error($"Конструкция '$term' не поддерживается");
    "#Ошибка!#"
}

Функция make_splice_distribution возвращает выражения, обернутые в вызов функции Convert.ToString(), а для интерпретатора это совершенно лишнее, так что первое, что требуется сделать – это вынуть само выражение из параметра Convert.ToString(). Для этого используется, пожалуй, самая мощная возможность Nemerle – сопоставление с образцом. Более того, в качестве образца используется кусок кода!... точнее квази-цитата.

Квази-цитата – это участок кода ограниченный скобками <[ и ]>. Этот код не превращается в MSIL. Он превращается в AST языка Nemerle. AST этого языка создан из вариантов. Именно этот факт позволяет использовать квази-цитаты не только для конструирования (порождения) кода, но и для его распознавания. Чтобы можно было распознавать неполные образцы, в квази-цитаты можно добавлять $-вхождения. В них могут находиться имена переменных, которые связываются с любыми значениями. Так вот, образец <[ Convert.ToString($expr) ]> сопоставляется с выражением, состоящим из вызова метода Convert.ToString, и любым подвыражением, передаваемым ему в качестве аргумента. При этом с именем expr связывается значение этого подвыражение. Таким образом, за знаком => и вплоть до следующего образца или до конца тела оператора match можно использовать это имя для получения доступа к подвыражению. Второй образец «term» сопоставляется вообще с любым выражением и связывает имя term с ним. Это позволяет удобно сообщить об ошибке (впрочем, ошибка в данном случае маловероятна).

calcPExpr

Локальная функция calcSplice – это по сути маленький интерпретатор выражений, встроенный (посредством механизма локальных функций) прямо в середину программы. Это самая большая функция, которая практически не содержит вложенных функций (одна маленькая не в счет!). Она принимает на вход выражение в формате PExpr (это формат представления кода в компиляторе Nemerle) и вычисляет (интерпретирует) его.

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

        def calcPExpr(expr) : object
{
  | <[ $e1 == $e2 ]> => calcOper(e1, _ == _, e2)
  | <[ $e1 != $e2 ]> => calcOper(e1, _ != _, e2)
  | <[ $e1 >  $e2 ]> => calcOper(e1, _ >  _, e2)
  | <[ $e1 <  $e2 ]> => calcOper(e1, _ <  _, e2)
  | <[ $e1 >= $e2 ]> => calcOper(e1, _ >= _, e2)
  | <[ $e1 <= $e2 ]> => calcOper(e1, _ <= _, e2)
  | <[ $e1 +  $e2 ]> => calcOper(e1, _ +  _, e2)
  | <[ $e1 -  $e2 ]> => calcOper(e1, _ -  _, e2)
  | <[ $e1 *  $e2 ]> => calcOper(e1, _ *  _, e2)
+ | <[ $e1 /  $e2 ]> => ...+ | <[ if ($cond) $trueExpr else $falseExpr ]> => ...
  | <[ $name() ]>when$"$name" == "НомерЭлемента" => num
+ | <[ $name(..$args) ]> => ...+ | Ref(name) => ...
  | Literal(Literal.Double(x))                 => x
  | Literal(Literal.String(x))                 => x
  | Literal(Literal.Bool(x))                   => x
+ | Literal(Literal.Integer(x, isNegative, _)) => ...
  | _                                          => ""
}

В этом виде функция не кажется сложной. Но все же стоит разобрать ее поподобнее.

<[ $e1 == $e2 ]>

Образцы вида «<[ $e1 оператор $e2 ]>» разбирают бинарные выражения. Переменные e1 и e2 при этом связываются с подвыражениями, стоящими соответственно справа и слева.

Все операторы вычисляются одной универсальной, обобщенной локальной функцией calcOper. Перед этой функцией стоит ключевое слово «and», а не «def», как обычно. Это вызвано тем, что эта функция взаимно рекурсивна с предыдущей функцией – calcPExpr. Таким образом, с одной стороны, calcPExpr должна знать о существовании calcOper, но и calcPExpr должна знать (и мочь вызвать) calcOper. Но в Nemerle локальные функции «видят» только то, что объявлено выше них. Так вот, объявление одной из функций через «and» решает эту проблему.

Так как calcOper очень мала, ее код приведен выше, в описании функции calcSplice. Функция calcOper производит вычисление обоих аргументов (для этого рекурсивно вызывая calcPExpr), преобразует полученный результат к double и передает их в функцию, переданную ей (функции calcOper) в качестве второго аргумента. Этой функцией являются операторы. За счет того, что calcOper является обобщенной функцией, она может принимать в качестве параметра как операторы сравнения, так и арифметические операторы.

Кстати, сами операторы преобразуются в функции с помощью очень приятной возможности Nemerle – частичного применения. Так если взять, например, оператор «==», то запись «_ == _» будет эквивалентна записи «(x, y) => x == y». Правда, короче и понятнее? И это в сравнении с почти идеальным синтаксисом лямбд Nemerle и C# 3.0, а по сравнению с анонимными методами C# 2.0 или обычными методами это вообще сестра таланта :)

Единственным исключением из правила (применения частичного применения) является оператор деления. Дело в том, что при делении на ноль будет сгенерировано исключение, и без дополнительной помощи тому, кто создает отчет, будет непросто узнать, где оно возникло. Поэтому вместо оператора деления функции calcOper передается локальная функция div:

          def div(x, y)
{
  if (y == 0.0) 
    { error($"дление на ноль в переменной '$name'."); 0.0 }
  else
    x / y;
}

Думаю, пояснения здесь излишни.

<[ if ($cond) $trueExpr else $falseExpr ]>

Данная конструкция распознает оператор if (что, в общем-то, и так очевидно для тех, кто хорошо знаком с квази-цитированием и сопоставлением с образцом). Остается только интерпретировать подвыражения и поместить полученные значения в «реальный» if:

| <[ if ($cond) $trueExpr else $falseExpr ]> => 
  if (ToBoolean(calcPExpr(cond))) calcPExpr(trueExpr)
  else                            calcPExpr(falseExpr)

<[ $name() ]> when $"$name" == "НомерЭлемента"

Данный образец распознает вызов функции без параметров, имя которой – «НомерЭлемента». Точнее, сам образец распознает вызов любой функции с пустым списком аргументов, а gurd-выражение «when $"$name" == "НомерЭлемента"» уже определяет что название функции именно «НомерЭлемента». Вам может показаться непонятным выражение «$"$name"». На самом деле это всего лишь $-строка, содержащая имя одной переменной. Я использую такую запись, чтобы не писать name.ToString(), так как последняя запись длиннее (а gurd-выражение и так не короткое). Преобразование к строке требуется, так как имена в парсере Nemerle хранятся запакованными в специальный класс (сопоставление с образцом для которого будет выглядеть чересчур громоздко).

Таким образом, данный образец «найдет» вызов функции НомерЭлемента(). Так как это встроенная функция, возвращающая порядковый номер отчета (элемента), то все что требуется сделать – это вернуть значение переменной num.

<[ $name(..$args) ]>

Этот образец распознает все остальные вызовы функций (как с параметрами, так и без). При этом имя, как и в прошлом случае, связывается с переменной name, а список аргументов связывается с переменной args.

Список аргументов содержит выражения в формате PExpr. Для вызова функции требуется преобразовать их в массив значений типа object. Это делается одним вызовом метода расширения MapToArray (код которого находится в модуле Utils):

          /// Позволяет отобразить (преобразовать) два массива в один, применив
/// функцию convert к элементам (с одинаковыми индексами) обоих массивов.

          public MapToArray[T1, T2, TResult](this arrays : array[T1] * array[T2],
  convert : T1 * T2 -> TResult) : array[TResult]
{
  def (a1, a2) = arrays;
  def minLen = Math.Min(a1.Length,  a2.Length);
  def res = array(minLen);
  foreach (i in [0..minLen - 1])
    res[i] = convert(a1[i], a2[i]);
  
  res
}

Ему в качестве параметра передается локальная функция calcPExpr, производящая вычисление одного выражения, и MapToArray применяет ее ко всем значениям, тем самым выполняя требуемую задачу. Обратите внимание на это, так как многие императивные программисты могут в подобной ситуации начать лепить циклы. Это не только значительно длиннее, но и провоцирует ошибки.

За вызов функции отвечает метод Call, расположенный в модуле ScriptFuncs. Этот модуль будет описан ниже.

          ScriptFuncs.Call($"$name", args.MapToArray(calcPExpr), messages)

Модуль ScriptFuncs

Задача этого модуля – предоставить интерфейс для регистрации методов, написанных на .NET-языках, в качестве функций скрипта. Реализуется это довольно просто. На стадии загрузки с помощью вызова метода RegistrFunc (см. выше) производится регистрация необходимых методов. При этом получаемая информация о типах этих методов закладывается в хэш-таблицу. При этом ключом служит пара («имя метода» * «количество параметров»). При вызове производится поиск метода с заданным именем и соответствующим количеством параметров. Если такой метод есть, производится преобразование типов аргументов таким образом, чтобы они соответствовали типам, описанным в информации о типах вызываемого метода, и производится вызов. Вызов производится динамически через рефлексию. Это, конечно, не быстро, зато полюбуйтесь, как это просто реализовать:

          module
          ScriptFuncs
{
  /// Добавляет описание функции в словарь поддерживаемых функций
public RegistrFunc(
    ty : Type, 
    realName : string, 
    scriptName : string, 
    paramsCnt : int
  ) : void
  {
    // Ищем методы с именем "name" и количеством параметров paramsCnt
match (ty.GetMethods().Filter(m => m.Name == realName
                  && m.GetParameters().Length == paramsCnt))
    {
      | [methodInfo] => _funcMap.Add((scriptName, paramsCnt), methodInfo)
      // Паттерн _ :: _ :: _ сопоставляется со списком, состоящим из двух
// или более элементов. Последний "_" может сопоставляться как с пустым
// списком - [], так и с непустым.
      | _ :: _ :: _ => WriteLine(
          $"В типе $(ty.FullName) имеется более одного метода $realName()"
        + $"с количеством параметров, равным $paramsCnt.")
      | _ => WriteLine(
          $"Метод $(ty.FullName).$realName() не существует или не "
        + $"поддерживает $paramsCnt параметров.")
    }
  }
    
  /// Отображает (имя * кол-во параметров) => MethodInfo
  _funcMap : Hashtable[string * int, Reflection.MethodInfo] = Hashtable();
  
  
  public Call(
    name : string, 
    args : array[object], 
    messages : ICollection[string]) : object
  {
    match (_funcMap.TryGetValue(name, args.Length))
    {
      | (methInfo, true) => // Функция найдена...
// Преобразуем типы параметров к указанным в описании метода.
def convertedArgs = (args, methInfo.GetParameters()).MapToArray(
          (arg, pi) => Convert.ChangeType(arg, pi.ParameterType));
          
        methInfo.Invoke(null, convertedArgs) // Вызываем метод (динамически)
        
      | _ => messages.Add($"Ошибка: Функция $name с $(args.Length) "
                         + "параметром[ами] не поддерживается.");
             "0"
    }
  }
}

Ref(name)

Этот паттерн сопоставляется со ссылкой на переменную. Так что надо «залезть» в словарь переменных и посмотреть, есть ли она там. Если есть, вернуть ее значение (предварительно вычислив), а если нет – выдать сообщение об ошибке:

| Ref(name) => // Ссылка на переменную
def varName = $"$name";
  
  if (dic.Contains(varName))
  {
    _ = referencedVars.Add(varName);
    calcOneVarValue(varName, recursionSet);
    dic[varName];
  }
  else 
  {
    error($"Переменная '$varName' не объявлена!");
    "0"
  }

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

Literal(...)

Данный паттерн распознает литералы.

| Literal(Literal.Double(x))                 => x
| Literal(Literal.String(x))                 => x
| Literal(Literal.Bool(x))                   => x
| Literal(Literal.Integer(x, isNegative, _)) =>
  def x = ToInt64(x);
  if (isNegative) -x else x

Значения литералов практически без изменений возвращаются функцией calcPExpr. Единственное исключение – это Literal.Integer, который хранится в несколько сложном виде. Его значение хранится как ulong, знаковое число или беззнаковое определяется отдельным полем, и еще в одном поле тип данных в формате компилятора Nemerle. Это приводит к тому, что требуется преобразовать тип числа и возвратить в него знак. Чтобы не мучиться, я решил использовать тип long (он же Int64).

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

doReplace

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

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

        def doReplace(dictionary) : string
{
  // Выводим список неиспользованных переменных. 
// Переменная считается используемой, если ее имя совпадает с 
// заполнителем из шаблона или на нее есть ссылка внутри другой 
// переменной.
// Это позволит пользователям выявить ошибки в именах заполнителей.
def notRefVars = HashSet(dictionary.Keys);
  notRefVars.ExceptWith(referencedVars);
  foreach (var when !templateText.Contains($"##$var##") in notRefVars)
    warning($"В шаблоне не найдена переменная: $var");

  // Производим замену заполнителей значениями переменных, имеющих имя
// совпадающее с именем заполнителя. Для этого используется 
// перегруженный вариант метода Regex.Replace() принимающий лямбду 
// "evaluator". Ей передается найденное значение (тип Match). 
// Ожидается, что она вернет значение, которое надо подставить 
// вместо найденного вхождения.
  findPlaceholders.Replace(templateText, matchPlaceholder =>
    match (dictionary.TryGetValue(matchPlaceholder.Groups[1].Value))
    { // TryGetValue возвращает кортеж, состоящий из значения переменной
// и true, если значение найдено, и false, если нет.
      | (varValue, true)  => varValue
      | (_,       false) => _ = 
        unknownPlaceholders.Add(matchPlaceholder.Value);
        matchPlaceholder.Value
    });
}

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

Думаю, что код этого метода содержит столько комментариев, что пояснять его еще раз не имеет смысла. Хочу сказать только о том, что за счет использования метода Replace, принимающего лямбду, и поиска по хэш-таблице внутри этой лямбды удалось достичь практически линейной скоростной характеристики. Так что даже большие отчеты будут по зубам этому генератору. И все это буквально несколькими строчками хорошо читаемого кода.

Выдача сообщений об ошибках

В конце работы метода DoReport() производится вывод предупреждений и сообщений об ошибках.

Сначала на консоль выводится список заполнителей, не совпадающих ни с одним именем переменной.

        foreach (placeholder in unknownPlaceholders.OrderBy(x => x))
  warning($"Найден неизвестный заполнитель '$placeholder'!");

А затем выводится список ошибок, сформированный за время генерации отчета. В списке присутствуют как предупреждения, так и ошибки (начинающиеся со строки «Ошибка:»). Чтобы пользователь мог лучше выделить ошибки, они выделяются красным. Для этого сообщения фильтруются с помощью локальной функции printMessagese.

        def printMessagese(filter)
{
  foreach (msg in messages.FilterLazy(filter).OrderBy(x => x))
    WriteLine(msg);
}

// Выводим список ошибок (красным цветом)
Console.ForegroundColor = ConsoleColor.Red;
printMessagese(_.StartsWith("Ошибка:"));
// Выводим список предупреждений (стандартным цветом)
Console.ResetColor();
printMessagese(x => !x.StartsWith("Ошибка:"));

Обратите внимание, что этой функции в качестве параметра передается фильтр. Это ни что иное, как функция, которая принимает строку и возвращает булево значение, говорящее, удовлетворяет ли строка условиям фильтрации.

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

Заключение

Эта статья имела несколько целей.

Во-первых, хотелось продемонстрировать, как разрабатывать приложения на Nemerle. Описать создание большого приложения практически невозможно. Маленького – неинтересно. Мне кажется, что данный генератор отчетов как раз балансирует на грани этих понятий, и как нельзя лучше подходит для демонстрации возможностей языка (хотя, конечно, ни в коей мере не демонстрирует их полностью).

Во-вторых, хотелось показать, как легко можно работать с XML средствами LINQ to XML.

В-третьих, она предоставляет код (ну, или на худой конец приемы и подходы), который может быть в той или иной мере использован при создании собственных систем. Например, подобный скриптовый движок может быть очень удобен в системе управления предприятием для формирования текстовых значений документов. При этом словари могут наполняться не только из XML, но и из БД, а то и из других источников.

И, в-четвертых, это готовое приложение, которое может кому-то пригодиться. Конечно, у него есть масса недостатков, и кто-то, возможно, видит его части иначе. Но оно полностью описано и доступно в исходных кодах. Вы можете использовать это приложение и менять его код как вам будет угодно. Ссылки на источник кода, взятого за основу, приветствуются ;).


Эта статья опубликована в журнале RSDN Magazine #1-2008. Информацию о журнале можно найти здесь