15 июля 2010 в 05:15

Компиляция. 1: лексер

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

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

Далее в посте:

  1. С какой стати писать компиляторы?
  2. Общий план
  3. Анализ текста
  4. Практический пример
  5. Как это работает?

С какой стати писать компиляторы?


Разве не все нужные языки программирования уже написаны? Кому в наш просвещённый век может понадобиться писать собственный компилятор?
Everything that can be invented has been invented.
--Charles H. Duell, Director of U.S. Patent Office, 1899 (attributed)
Что было, то и будет; и что делалось, то и будет делаться, и нет ничего нового под солнцем.
--Екклесиаст 1:9 (ок. 3 в. до н.э.)

Во-первых, языки постоянно создаются и развиваются. Из ныне популярных, Ruby, PHP, Java и C# были созданы на нашей памяти, а бурно применяться начали несколько лет назад. Прямо сейчас Майкрософт проталкивает новый язык F#, и он — учитывая мощь Майкрософт — наверняка также войдёт в число общеупотребимых.
До сих пор остаются ниши для новых языков: например, не прекращаются попытки придумать удобный императивный язык для параллельного программирования.

Во-вторых, используемые в компиляции приёмы (в первую очередь, парсинг по грамматике) имеют массу других приложений. Часто есть потребность в преобразованиях source-to-source (рефакторинг, перевод кода на другой язык, и т.п.), когда нужно разобрать текст на языке программирования, обработать его, и вывести обработанный текст (на том же или на другом языке).

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

Общий план


Процесс компиляции, в принципе, состоит из двух основных этапов:
  1. Анализ текста на исходном языке
  2. Генерация машинного кода
На первом этапе строится представление программы, с которым будет удобно работать дальше; обычно это дерево, но вовсе не обязательно. На втором этапе компилятор проходит по дереву, и для каждого узла генерирует окончательный код.

Самая challenging часть компилятора — оптимизация кода; на первом этапе выполняется высокоуровневая оптимизация, на уровне узлов дерева (например, разворачиваются циклы, вживляются inline-функции); на втором — низкоуровневая, на уровне потока команд (например, переупорядочиваются так, чтобы полнее нагрузить конвейеры конкретного процессора). До оптимизаций, по традиции, в вузовских курсах дело никогда не доходит; но самые простые (copy elimination, constant propagation) мы в нашем примере постараемся реализовать.

Старые посты на тему синтаксического разбора я на Хабре видел; но к генерации кода, вроде бы, авторы не приближались ни разу.

Анализ текста


Когда начинающий программист пытается написать парсер текста, его естественный подход — рекурсивное углубление: найти начало конструкции (например, {); найти её конец (например, } на том же уровне вложенности); выделить содержимое конструкции, и пропарсить её рекурсивно.

Проблемы с таким подходом — во-первых, избыточная сложность (по одному и тому же фрагменту текста гуляем взад-вперёд); во-вторых, неудобство поддержки (синтаксис языка оказывается рассредоточен по килобайтам и килобайтам ветвистого кода).

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

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

Практический пример


Упомянутый «универсальный парсер», конечно же, давно реализован, и не одиножды. Классическая реализация — lex — принимает описания действий для каждого регэкспа на Си. В стандартные утилиты GNU входит flex, совместимый с lex, — его мы и будем использовать для примеров. (Есть аналогичные утилиты для C#, Java и пр.)

По традиции, первым примером станет написание калькулятора:
%{
    #include <stdio.h>
    int reg = 0;
    char op = '+';
    int unmin = 0;
%}

%option main
%option yylineno

%%

[/][/].*\n      ; // comment
[0-9]           { int opnd = atoi(yytext);
                  if (unmin) opnd =- opnd; unmin=0;
                  switch(op) {
                    case '+': reg += opnd; break;
                    case '-': reg -= opnd; break;
                    case '*': reg *= opnd; break;
                    case '/': reg /= opnd; break;
                  }
                  op = 0;
                }
[-+*/]          { if (op) {
                    if (*yytext=='-')
                      unmin = 1;
                    else {
                      printf("Unexpected operator in line %d\n", yylineno);
                      exit(1);
                    }
                  } else
                    op = *yytext;
                }
[;]             { if (op) {
                    printf("Unexpected semicolon in line %d\n", yylineno);
                    exit(1);
                  }
                  printf("=%d\n", reg);
                  reg = 0;
                  op = '+';
                }
[ \t\r\n]       ; // whitespace
.               { printf("Syntax error in line %d\n", yylineno); exit(1); }
%%


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

Скомпилируем наш калькулятор, и попробуем с ним поиграть:
[tyomitch@home ~]$ lex 1.lex
[tyomitch@home ~]$ cc lex.yy.c
[tyomitch@home ~]$ ./a.out
2+2;
=4
2+2*2;
=8
2 + // hello
- 3
;
=-1
2 / /
Unexpected operator in line 6

Разбор кода

  • Вверху, в тегах %{ %}, идёт код, который скопируется напрямик в парсер. Объявляем три переменные: reg («регистр») для промежуточного результата, op для набранной операции, и unmin — флаг, что был набран минус после знака операции, и он должен трактоваться как знак второго операнда.
  • %option main указывает, что нас устроит стандартная main (которая читает с stdin до EOF). %option yylineno создаёт в парсере переменную int yylineno, где будет храниться номер текущей строки входного текста: он пригодится для диагностических сообщений.
  • %% разделяет область объявлений и область собственно правил языка
  • В каждом правиле слева пишется регэксп, справа — код на Си. В первом регэкспе код пуст; т.е. такая конструкция (а это коммментарий) будет просто игнорироваться. Аналогично, предпоследнее правило предписывает игнорировать пробелы и переводы строк между распознаваемыми конструкциями.
  • Во втором правиле пользуемся переменной yytext: в ней хранится совпавший с регэкспом текст (в нашем случае, число-операнд)
  • В третьем правиле — пример обработки ошибки в тексте программы (используем yylineno в тексте сообщения)
  • Правила пробуются в порядке их появления, от первого к последнему. Если ни одно не подошло, парсер просто напечатает текущий символ в stdout. Вместо этого, мы добавляем последним правилом . — оно подойдёт к любому символу, и напечатает сообщение об ошибке.
В настоящем компиляторе, разумеется, правила будут не печатать результаты вычислений на экран, а сохранять сами выражения для последующей генерации кода.

Как это работает?

У математиков есть понятие ДКА (детерминированный конечный автомат) — штука, которая может находиться в одном из N состояний; которая читает из входного потока символ за символом; и у которой есть таблица: для каждого сочетания текущего состояния и прочитанного символа — в которое состояние перейти. Работа flex заключается в том, что он строит ДКА по заданному набору регэкспов; в некоторых состояниях этот ДКА не только переходит в следующее, но вдобавок вызвает наши правила.

Увидеть построенный ДКА можно, заглянув внутрь сгенерированного парсера lex.yy.c. Потребовалось 15 состояний. Для экономии места, таблица переходов хранится не в явном виде (размером 15х256), а разбитая на мудрёные накладывающиеся списки. Чтобы увидеть её в наиболее наглядной форме, скомпилируем парсер с опцией -Cef («отключить сжатие таблиц»):

static yyconst short yy_nxt[][8] =
    {
    {    0,    0,    0,    0,    0,    0,    0,    0    },
    {    3,    4,    5,    6,    7,    8,    9,   10    },
    {    3,    4,    5,    6,    7,    8,    9,   10    },
    {   -3,   -3,   -3,   -3,   -3,   -3,   -3,   -3    },
    {    3,   -4,   -4,   -4,   -4,   -4,   -4,   -4    },
    {    3,   -5,   -5,   -5,   -5,   -5,   -5,   -5    },
    {    3,   -6,   -6,   -6,   -6,   -6,   -6,   -6    },
    {    3,   -7,   -7,   -7,   -7,   -7,   -7,   -7    },
    {    3,   -8,   -8,   -8,   -8,   11,   -8,   -8    },
    {    3,   -9,   -9,   -9,   -9,   -9,   12,   -9    },
    {    3,  -10,  -10,  -10,  -10,  -10,  -10,  -10    },
    {    3,   13,   13,   14,   13,   13,   13,   13    },
    {    3,  -12,  -12,  -12,  -12,  -12,   12,  -12    },
    {    3,   13,   13,   14,   13,   13,   13,   13    },
    {    3,  -14,  -14,  -14,  -14,  -14,  -14,  -14    },
    } ;

static yyconst short int yy_accept[15] =
    {   0,
        0,    0,    8,    6,    5,    5,    3,    3,    2,    4,
        0,    2,    0,    1
    } ;

Символы здесь разбиты на 8 классов, идентичных с точки зрения парсера (например, все цифры объединены в один класс). Отдельный массив static yyconst int yy_ec[256] ставит каждому символу в соответствие класс.

Основной цикл работы парсера весьма незамысловат:
yy_match:
   while ( (yy_current_state = yy_nxt[yy_current_state][yy_ec[(unsigned char)(*yy_cp)]]) > 0 )
       {
       if ( yy_accept[yy_current_state] )
           {
           yy_last_accepting_state = yy_current_state;
           yy_last_accepting_cpos = yy_cp;
           }

         yy_cp;
       }

   yy_current_state = -yy_current_state;

Положительные числа в таблице переходов означают «перейти в состояние и продолжить читать»; отрицательные — «перейти в состояние и выполнить действие». Номер действия, которое должно выполняться по приходу в состояние, хранится в yy_accept.

Рассмотрим пример: для цифр номер «класса символа» — 6.
В начальном состоянии (1) по символу 6 видим в таблице переходов 9. Переходим, читаем дальше.
В состоянии 9, если есть ещё одна цифра (6), переходим в состояние 12 и читаем дальше.
Из состояния 12, если есть ещё одна цифра, просто читаем дальше. (В таблице стоит 12)
Если увидели не-цифру (любой символ, кроме 6), нужно выполнить действие: видим в 9-ой строчке ряд из -9, и в 12-ой строчке ряд из -12.
Проверяем yy_accept: в обоих случаях применяем правило 2. (Вспомним, что правило, распознающее числа, в нашем парсере действительно второе.)
Непонятно, зачем flex решил разделить состояния 9 и 12, если в обоих делает одно и то же самое. Но он бездушная железяка, ему виднее.

Замечательно то, насколько готовый парсер прост: вместо ветвистого распознавания разных конструкций, у нас одна большая таблица, и цикл из десяти строк. Но есть существенная проблема. Вернёмся к примеру из самого начала поста: «найти начало конструкции (например, {); найти её конец (например, } на том же уровне вложенности)...» Как регэкспом обозначить условие «на том же уровне вложенности»?

Математики и тут постарались: доказали, что невозможно. Значит, для парсинга языков, поддерживающих вложенные конструкции (а это все языки сложнее BAT-файлов), нам потребуется более мощный инструмент, чем регэкспы. В следующий раз — о нём.
@tyomitch
карма
330,7
рейтинг 0,1
Самое читаемое Разработка

Комментарии (40)

  • +2
    Так и не увидел за весь пост фразы" LR-грамматика"
    • +3
      последний параграф как бы намекает, что в следующей части вы как раз и увидите эту фразу.
    • +2
      Грубо говоря, лексеру пофиг на грамматику: он разбирает поток символов на токены. Грамматика имеет смысл, когда уже есть токены.
  • +2
    К сожалению, российские вузы уделяют мало внимания сей интереснейшей теме

    не, ну ты смотри какие подлецы. и дисциплины «теория компиляторов» наверное нет нигде?
    • +2
      Нас на этой дисциплине учили только грамматикам.
      О применении грамматик в компиляции было вскользь.
      Об остальных этапах компиляции не было вовсе.
      • +7
        у нас и этого не было
  • +4
    Если не секрет, чем обусловлен выбор lex а не antlr?
    • 0
      lex по православнее будет. а вобще я пользуюсь другим средством — ragel
  • 0
    Отличная статья! По-моему тема создания компиляторов, или точнее, более узкая тема source-to-source преобразований должна быть близка всем разработчикам на крупных проектах. Меня до сих пор не отпускает идея создания Domain-specific языка для предметной области своего проекта. Но знаний по компиляции пока не хватает.

    С нетерпением жду продолжения. :)
    • +1
      Для создания DSL есть много готовых решений и подходов, вроде MPS для внешних DLS или Scala для внутренних. Вовсе не требуется знать про компиляторы или, ещё хуже, писать их.

      • 0
        Спасибо, посмотрю.
  • +2
    Хм.
    У нас (ВМК МГУ) был отдельный курс Системы Программирования, где половина материала была как раз о Формальных грамматиках и теории компиляции.
    + у одного из потоков еще отдельный курс Конструирование Компиляторов.
    Материал рассказывался довольно хорошо, хотя многие не понимали, зачем им это нужно будет. Ну, это как всегда)
    • +1
      Не всем повезло учиться в МГУ.
      • 0
        В НГУ есть спецкурсы на эту тема, есть даже отдельный спецкурс про оптимизирующие компиляторы. На МГУ свет клином не сошелся :)
        • +1
          хе-хе, НГУ повезло что под боком Excelsior и Intel [причем именно в таком порядке].

          не было бы их — кукишь, а не спецкурс по оптимизирующей компиляции.

  • +1
    если хочется начинать не с нуля, а на базе какого-нибудь современного языка, рекомендую посмотреть в сторону Scala.
    Scala позволяет писать плагины к компилятору, имеющие достаточно широкий простор действий.

    сам компилятор очень хорошо устроен, имеет чёткие стадии работы и вообще представляем из себя отличную иллюстрацию того, как надо работать =)

  • НЛО прилетело и опубликовало эту надпись здесь
  • НЛО прилетело и опубликовало эту надпись здесь
    • 0
      Ух какие парсеры ревнивые! Подрались!

      Вернул плюсики на место.
  • +1
    Автор, статья неплохая, но дайте перварительно общую картину — классы языков. Ту ж иерархию Хомского
    К примеру, не очень понятно почему регекспами нельзя обрабатывать вложенность (а если ими нельзя, то чем можно?).

    2Nostromo: читал какие-то мгушные лекции, не очень-то понятно. Не могли бы назвать хорошего лектора (по формальным языкам)?
    • +1
      Почему нельзя, и чем можно — обещаю во втором посте.

      Теорию собирался касаться по минимуму, всё-таки целюсь в программистов, а не в математиков.
    • +2
      Волкова. У нее, кстати, есть прекрасная (имхо) методичка конкретно по формальным грамматикам и языкам и элементам теории трансляции.
      Все это можно найти, например, вот здесь:
      cmcmsu.no-ip.info/2course/ в материалах за 4 семестр.
      там еще много полезной информации.
      • +1
        Спасибо, по этой ссылке есть занятные книжки.
  • +1
    Свердлов С. З. Языки программирования и методы трансляции, Питер, 2007, ISBN 978-5-469-00378-6.

    Отличная книга на данную тему, где хорошо и доступно раскрыты как исторические предпосылки, так в необходимом объеме теория, а так же практическая реализация на примере реализации минимального подмножества языка Оберон, на различных языках программирования (Паскаль, Ява, Си шарп, Сам оберон и прочие). Рекомендована российским мин. образования. Одна из немногих известных мне достойных книг, российского авторства, а не перевод, на тему АйТи.
  • 0
    www.ozon.ru/context/detail/id/3829076/
    Альфред В. Ахо, Моника С. Лам, Рави Сети, Джеффри Д. Ульман
    Компиляторы. Принципы, технологии и инструментарий
    Compilers: Principles, Techniques, & Tools
    Издательство: Вильямс, 2008 г.
    Твердый переплет, 1184 стр.
    ISBN 978-5-8459-1349-4, 0-321-48681-1

    Лучшая книга про разработку компиляторов.
  • +3
    Неужели следующая статья будет про yacc? Настоятельно не рекомендую.
    Вернее так — Вы определитесь, про какой подход рассказывать. Можно долго делать очень быстрый компилятор, а можно в разы быстрее сделать компилятор, который будет работать с приемлемой скоростью. Так вот lex/yacc для второго подхода использовать не советую — отлаживать замучаетесь. К тому же в больших языках всегда есть какие-нибудь исключительные ситуации, когда возможностей генератора сканеров/парсеров не хватает и приходится выкручиваться. В этой ситуации я тоже предпочту какой-нибудь antlr.

    В общем, задача настолько хорошо изучена, что есть смысл рассказывать только про современные технологии её решения, которые охватывают все этапы разработки. И тестирование, кстати.
    • +1
      Да, рассказываю о хорошо изученных вещах, и на первооткрывательство не претендую. Цель была — изложить популярно.

      Да, у меня нет опыта с antlr и с LL-парсерами вообще.
      В чём их преимущество? в удобстве отладки?
      • +1
        Гораздо больше возможностей и удобства для программиста. В частности, поддерживается работа с деревом, которое является результатом разбора. Впрочем, я предпочитаю пользоваться своим инструментом TreeDL. Также есть поддержка кодогенерации.

        Извините за некоторую резкость, «ревную» близкую мне тему :) Давайте лучше дополню статью примерами из реальной жизни. Вот наши фронтенды для расширений С и Java, написанные на JavaCC+TreeDL и Antlr/Java+TreeDL соответственно:
        сканер+парсер расширенного С
        дерево расширенного С
        сканер Java
        • 0
          рука дернулась…

          парсер Java
          дерево Java
        • +1
          Давайте лучше дополню статью примерами из реальной жизни.

          С удовольствием бы прочитал, если будет не сухой код, а с пояснениями, откуда что и зачем.
          Особенно интересует «сравнительный анализ» yacc vs. antlr. (Зарылся в гугл.)
          • +2
            Так это писалось не один год и не одним человеком :) Я ещё могу ответить на конкретные вопросы, но всё прокомментировать нереально.

            Главное отличие lex/yacc от antlr — вид используемой грамматики. regexp/LR(1) (или точнее LALR(1), если я правильно помню) vs LL(k)/LL(k). Да, для сканеров на antlr используется та же грамматика, что и для парсеров. k — произвольный размер lookahead, причем в отдельных сложных местах его можно увеличивать задавая синтаксические предикаты (а еще есть семантические!). LL парсеры похожи на те, которые пишутся руками — рекурсивные. Сгенерированный код легко читать и отлаживаться по нему.

            Disclaimer: моё плотное знакомство с antlr закончилось на 2й версии, 3я может отличаться.
            • 0
              Вечер добрый. Пытаюсь родить pmd-rule (http://pmd.sourceforge.net/) для примитивного случая sql injection:

              ResultSet rs = null;
              PreparedStatement ps = null;
              /* some code… */
              ps = con.prepareStatement(mySQL);
              /* again some code… */

              Как я понял, pmd основан на JavaCC.
              Я без проблем нахожу вызов «prepareStatement», но никак не могу получить доступ к аргументу метода, чтоб поискать его usage в method scope (упрощаю задачу).

              В общем, идея следующая: если аргумент метода «prepareStatement» является производным (через конкатенацию или StringBuilder, StringBuffer) от строкового (String) аргумента метода класса, внутри которого готовится стейтмент, то надо выдать предупреждение о потенциальном SQL-injection.

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

              Есть ли у вас опыт написания подобного, где можно посмотреть, почитать? форум pmd скорее мертв, чем жив.
              • 0
                Правил для PMD писать не приходилось, но общий принцип понятен. Самое главное — JavaCC практически ни при чем. Вы имеете дело с готовым деревом (AST), в котором надо найти узел, соответствующий вызову метода, проверить, что имя метода «prepareStatement» и в этом случае выполнить необходимую проверку типа параметра.
                Судя по примеру
                pmd.sourceforge.net/howtowritearule.html
                Вызову метода соответствует PrimaryExpression с детьми PrimaryPrefix и PrimarySuffix. Объект и имя метода сидят в префиксе, а аргументы — в суффиксе.
                Удобного описания дерева я что-то сходу не нашел (даже исходного *.jj в дистрибутиве не вижу), так что проще пользоваться предлагаемым дизайнером, который показывает дерево входного файла).
                Если вопросы останутся — пишите ICQ 740187 или allex@all-x.net
                • 0
                  Понял, спасибо!
  • +1
    Прочитал статью «Редкая профессия» Евгения Зуева, про то, как чуваки писали компилятор
    с++ когда-то давно. Как переводили стандарты, с какими проблемами сталкивались, про проблемы грамматики с++ и прочее.
    Вот нашел ссылку: www.interstron.ru/upload/images/pubs/Redkaya_professiya.pdf
    Тем, кто интересуется компиляторами рекомендую, можно узнать интересные вещи.
  • +3
    > Практический пример
    > В стандартные утилиты GNU входит flex, совместимый с lex, — его мы и будем использовать для примеров.
    > По традиции, первым примером станет написание калькулятора:

    Товарищ, ты слишком быстро перепрыгнул.

    Во-первых, ты не сказал толком, какой язык мы будем обрабатывать в примере. В нашем случае, мы будем обрабатывать «простейший язык математических выражений». В нем будут только цифры 0-9, четыре знака математических операций "+", "-", "*", "/"), символ завершения выражения ";".

    Во-вторых, ты ни слова не сказал, что вообще такое lex / flex.

    Коль рассказываешь основы, надо было бы пояснить, что lex — это генератор лексического анализатора, который на основе файла описания языка, создает c-файл с лексическим анализатором. Этот c-файл можно скомпилировать, после чего полученный бинарник сможет выполнять текст программы на придуманном нами языке. Грубо говоря, полученный бинарник находит в тексте программы на «придуманном» языке куски, попадающие под заданные нами маски, и вместо найденных кусков выполняет заданный нами код на языке C.

    Кроме того, надо сразу сказать (а не после), что первый листинг в статье — это код lex-файла, в котором описываются маски для поиска кусочков текста, и С-код, кторый должен выполняться вместо этих масок. Данный файл скармливается программе lex, на выходе имеем C-файл с лексическим анализатором. Компилируем этот файл, и получаем бинарник лексического анализатора.

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

    После таких пояснений можно уже и код lex-файла показывать, и все остальное объяснять.
    • 0
      Мне кажется, что все перечисленные факты в тексте есть.
      Хоть и не в таком порядке.
  • 0
    > Мне кажется, что все перечисленные факты в тексте есть.
    > Хоть и не в таком порядке.

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

    У вас вся статья написана задом-наперед. К середине статьи накапливается куча необъяснённых вопросов, которые кое-как разрешаются в конце. Это сложно для понимая обычному человеку. Такое впечатление, что вы перепрограммировали на каком-то функциональном языке, и функциональное мышление повлияло на стиль вашей речи.
  • 0
    Если не секрет, по каким соображениям была выбрана связка c+lex+yacc? Чем не понравились такие инструменты как Ocaml или Haskell на пример? К тому же они вроде как получили некое распространение на поприще компилятора-строения.
    • 0
      Изучал то, по чему проще найти информацию.
      Если расскажете о функциональном компиляторо-строении, уверен, не одному мне будет интересно.

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