Зачем вообще создавать новый язык программирования? Их уже существует невероятное количество — по моему твёрдому убеждению, значительно больше, чем надо. И наверняка далеко не последнюю роль в данном положении вещей играет то, что создание компиляторов — это невероятно увлекательный процесс. С поправкой на арбузы и свиные хрящики — это вообще одна из самых «вкусных» работ, о которых может мечтать увлечённый программист.
Непередаваемо здоровским является цветочно-конфетный период — первый этап изучения теории компиляторов по толстым умным книжкам, и — тут же! — её применения на практике, в своём собственном языке. Даже печальная перспектива того, что создатель языка вполне может оказаться его единственным пользователем, не способна перевесить радость творчества и остановить сферического-в-вакууме компиляторного Кулибина. Разумеется, если удовлетворение собственного интереса является не только важной, но и единственной движущей силой всего процесса — вышеописанная перспектива с неизбежностью будет воплощена в жизнь. Но даже если это и НЕ единственная причина создания нового языка — перспектива стать одиноким пользователем своего творения всё равно имеет шансы реализоваться.
Мой цветочно-конфетный период романа с компиляторами и радости от ваяния своих первых языков закончился уже очень давно. Можно сказать, что свои отношения я узаконил узами священного брака: компиляторы, отладчики и средства разработки — это моя основная работа в Tibbo со всеми вытекающими последствиями (да-да, в том числе и в виде насыщения предметом, увеличения процента рутинных задач и т.д.) Поэтому мотивация создания своего скриптового языка программирования у меня была отличная от банального удовлетворения собственного интереса.
Если максимально кратко сформулировать практическую сторону моей лично и нашей (как компании) мотивации, то она будет звучать так: мы хотели иметь встраиваемый скриптовый движок с указателями на структуры и безопасной адресной арифметикой. Такого не нашлось. И мы сделали язык Jancy («between-Java-and-C»), в котором есть и С-совместимые структуры, и указатели с безопасной арифметикой, и многое-многое другое:
Уникальные возможности
Принципы дизайна
Другие значимые особенности
Более полный список возможностей с примерами использования можно посмотреть тут: http://tibbo.com/jancy/features.html
Прежде всего, мы писали язык для самих себя — Jancy используется в проекте IO Ninja в качестве встроенного скриптового языка. Однако, если он оказался полезен нам, мы скромно надеемся, что он вполне может помочь и другим. Надежда эта прежде всего опирается на три сильнейшие стороны Jancy, в которых наш язык обладает реальным преимуществом перед аналогами.
1. Высокий уровень совместимости с C/C++
Это относится и к бинарной ABI-совместимости, и к совместимости на уровне исходных кодов. Плюсов здесь много: это и бесшовное подключение существующих C-библиотек, и портирование кода с C/C++ с помощью копипастинга и последующих косметических правок (а иногда и вообще без них), и лёгкость создания на C/C++ новых библиотек для использования из Jancy-скриптов, и эффективность встраивания Jancy-движка в C/C++ приложение и т.д.
2. Удобные средства для IO-программирования
Тут я прежде всего говорю, во-первых, о поддержке указателей и адресной арифметики, идеально подходящих для разбора и генерации бинарных пакетов, и, во-вторых, о генераторе лексеров (причём инкрементальных, т.е. применимых к разбору IO потоков, приходящих по кускам). Сюда же можно отнести частичное применение и оператор планирования, которые вместе позволяют, например, создать обработчик завершения (completion routine) с захваченными контекстыми аргументами; при этом он будет автоматически вызван в нужном рабочем потоке.
3. Удобные средства для UI-программирования
Два слова: реактивное программирование. Уверен, что в ближайшем будущем поддержка реактивности — на уровне языка или же в форме костылей вроде препроцессоров и библиотек — станет неотъемлемой частью любой системы разработки пользовательских интерфейсов (UI). Jancy предлагает реактивность «из коробки», причём, по моему мнению, в совершенно интуитивной форме. Помимо реактивности, Jancy поддерживает всевозможные вариации свойств и событий, что также помогает строить красивые фреймворки пользовательского интерфейса.
При этом, несмотря на примечательные возможности пункта номер три, мы пока не позиционируем Jancy как язык разработки пользовательского интерфейса. Задача-максимум на данный момент — стать скриптовым языком для низкоуровневого IO программирования, т.е. инструментом системного/сетевого программиста/хакера.
А теперь — слайды! ©
Совместимость это всегда хорошо, а совместимость с де-факто языком-стандартом системного программирования — это просто здорово, не правда ли?
Jancy-скрипты JIT-компилируются и могут быть напрямую вызваны из программы на C/С++, равно как и напрямую вызывать C/C++ функции. Это означает, что после правильного описания типов данных и прототипов функций в скриптах Jancy и приложении на C++ становится возможно передавать данные естественным образом, через аргументы функций и возвращаемые значения.
Объявляем и используем функции из скрипта на Jancy:
Пишем реализацию на C/C++:
Подключаем перед JIT-компиляцией скрипта:
Готово! Никакой упаковки/распаковки variant-подобных контейнеров, явного заталкивания аргументов на стек виртуальной машины и т.д. — всё работает напрямую. На сегодняшний момент Jancy поддерживает все основные модели вызовов (calling conventions):
В обратную сторону — вызов Jancy из C++ — всё так же просто:
Как насчёт вызова системных функций и динамических библиотек (dll/so)? Не вопрос! Jancy предлагает бесшовную интеграцию с динамическими библиотеками:
При этом разрешение имён будет производиться по мере обращения, а найденные адреса будут кэшироваться (напоминает DELAYLOAD, с поправкой на явную загрузку самого модуля). Обработка ошибок при загрузке и разрешении имён производится стандартным для Jancy методом псевдо-исключений (подробнее см. следующий раздел).
Динамический поиск по имени (GetProcAddress/dlsym), разумеется, также возможен — хотя и не столь элегантен, как предыдущий подход.
Другим немаловажным следствием высокой степени совместимости между Jancy и C/C++ является возможность копипастить из общедоступных источников (таких как Linux, React OS или других проектов с открытым исходным кодом) и использовать определения заголовков коммуникационных протоколов на языке C:
Кстати, обратите внимание на поддержку целочисленных типов с обратным порядком следования байтов (bigendians). Это, конечно, далеко не масштабное нововведение, но оно здорово упрощает описание и работу с заголовками сетевых протоколов — здесь обратный порядок следования байтов встречается повсеместно.
Как это ни парадоксально, но одним из следствий ABI-совместимости с C/C++ стал отказ от привычной для C++ программистов модели исключений. Дело в том, что такие исключения совершенно не подходят для мультиязыкового стека вызовов (хотя, конечно, список объективных претензий к C++-подобным исключениям этим не исчерпывается — горячие споры «за» и «против» исключений всплывают на программистских ресурсах с регулярностью, которой можно только позавидовать).
Так или иначе, в Jancy используется гибридная модель. В основе её лежит проверка возвращаемых значений, но компилятор избавляет от необходимости делать это вручную. В итоге всё выглядит почти как исключения в C++ или Java, но при этом поведение программы при ошибках на порядок более прозрачно и предсказуемо, а поддержка исключений при межязыковых взаимодействиях (таких, как вызов функций C++ из скриптов на Jancy и наоборот) становится настолько простой, насколько это вообще возможно.
Возвращаемые значения функций, помеченных модификатором throws, будут трактоваться как коды ошибок. В Jancy приняты интуитивные условия ошибки для стандартных типов: false для булева типа, null для указателей, -1 для беззнаковых целых, и < 0 для знаковых. Остальные типы приводятся к булеву (если это невозможно, то выдаётся ошибка компиляции). Очевидно, что функция, возвращающая void, в данной модели не может возвращать ошибки.
Помимо этого, в данной модели разработчик волен выбирать, как именно обрабатывать ошибки в каждом конкретном случае. Иногда это удобнее делать проверкой кода возврата вручную, иногда – использовать семантику исключений. В Jancy — при вызове одной и той же функции! — можно делать и так и так, в зависимости от ситуации.
Конструкция finally в большинстве языков традиционно ассоциируется с исключениями. Но в Jancy finally может быть добавлен в любой блок по желанию разработчика. В конце концов, убирать за собой надо даже если никаких ошибок не возникало, не правда ли?
Конечно, допускается и более традиционное использование конструкции finally в случаях, когда исключения-таки ожидаются.
Адресная арифметика в скриптовом языке — это то, ради чего всё собственно и затевалось.
Указатели, при всей своей врождённой небезопасности, в явном или неявном виде являются частью любого языка. Ограничением доступных разработчику видов указателей и операций над ними можно значительно обезопасить язык, упростить обработку неблагоприятных ситуаций во время исполнения и даже отлавливать некорректные операции в момент компиляции при помощи статического анализа. Но если в игру вступает адресная арифметика, полностью переложить анализ на этап компиляции просто невозможно.
Чтобы всегда была возможность проверять корректность операций, указатели в Jancy по умолчанию толстые. Помимо адреса они также содержат валидатор – специальную структуру мета-данных, из которой можно получить информацию о разрешённом диапазоне адресов, типе данных, а также целочисленном уровне вложенности (scope level).
Формула безопасности указателей и адресной арифметики в Jancy такова:
Проверки допустимого диапазона адресов производятся как в случае явного использования указателя, так и в случае оператора индексации:
В случае указателей на стековые и потоковые переменные, необходимы также проверки уровня вложенности — для предотвращения утечки адресов за границы времён их жизни. Этот механизм работает даже в случае многоуровневых указателей, вроде указателей-на-структуры-с-указателями-на-структуры-и-т-д:
Наконец, проверки приводимости призваны предотвратить разрушение самих валидаторов. Действительно, что если мы приведём создадим указатель на указатель, приведём его к указателю на char и затем байт за байтом затрём валидатор мусором? Jancy просто не даст это сделать: компилятор и runtime разрешают приведения только там, где это безопасно.
Уважаемые хабровчание приглашаются поиграться с нашим online-компилятором и на деле опробовать, как всё это работает (читайте: попытаться подсунуть компилятору пример скрипта с указателями, который его свалит ;)
Подробнее про указатели в Jancy читать тут: http://tibbo.com/jancy/features/pointers.html
Безопасные указатели и адресная арифметика идеально подходят для разбора и генерации бинарных пакетов:
Но существует и другой класс протоколов — протоколов, не полагающихся на бинарные заголовки и вместо этого использующих некий язык запросов/ответов. В этом случае для анализа IO-потоков требуется писать парсер данного языка. При этом надо озаботиться предварительной буферизацией данных — часто нет гарантии, что транспорт доставил сообщение целиком, а не по кусками.
Поскольку данная задача является типовой в IO-программировании, Jancy предлагает встроенный инструмент для её решения. Функции-автоматы Jancy призваны облегчить первую и самую рутинную стадию написания любого парсера — создание лексера/сканнера. Работает это по принципу известных лексер-генераторов типа Lex, Flex, Ragel:
Внутри функции-автомата описан список распознаваемых лексем в виде регулярных выражений. После описания каждой лексемы следует блок кода, который надо выполнить при её обнаружении во входном потоке. Вся эта кухня компилируется в табличный ДКА, состояние которого хранится во внешнем объекте jnc.Recognizer (указатель на этот объект передаётся в аргументе recognizer). В нём же накапливаются символы потенциальной лексемы, и он же неявно вызывает нашу функцию-автомат, выполняя при этом необходимые переходы между состояниями.
Совокупность функции-автомата и этого управляющего объекта-распознавателя и представляет собой наш лексер. При этом данный лексер будет инкрементальным, то есть способным разбирать сообщения, приходящие по частям:
Отметим, что, как и в Ragel, возможно переключаться между разными функциями-автоматами, что позволяет, в частности, создавать контекстно-зависимые ключевые слова (или, если сказать по-другому, разбирать мульти-языковый вход).
Функции-автоматы с одной стороны, и безопасные указатели с адресной арифметикой с другой позволяют с удобством разбирать протоколы и IO-потоки любого типа.
Несмотря на то, что программирование пользовательских интерфейсов (UI) не является главным назначением Jancy на данный момент, мы всё равно хотели бы продемонстрировать подход к реактивному программированию, который используется в Jancy — я считаю, что нам удалось найти оптимальный компромисс в сосуществовании императивного и декларативного начал в реактивном программировании. Рассказ об этом пойдёт в следующей статье.
Тем временем мы приглашаем вас опробовать возможности языка Jancy (как описанные в данной статье, так и многие другие) на страничке живой демки компилятора. Также вы можете скачать, собрать и поиграться с библиотекой JIT-компилятора Jancy и примерами её использования — всё это доступно на страничке downloads.
Непередаваемо здоровским является цветочно-конфетный период — первый этап изучения теории компиляторов по толстым умным книжкам, и — тут же! — её применения на практике, в своём собственном языке. Даже печальная перспектива того, что создатель языка вполне может оказаться его единственным пользователем, не способна перевесить радость творчества и остановить сферического-в-вакууме компиляторного Кулибина. Разумеется, если удовлетворение собственного интереса является не только важной, но и единственной движущей силой всего процесса — вышеописанная перспектива с неизбежностью будет воплощена в жизнь. Но даже если это и НЕ единственная причина создания нового языка — перспектива стать одиноким пользователем своего творения всё равно имеет шансы реализоваться.
Мой цветочно-конфетный период романа с компиляторами и радости от ваяния своих первых языков закончился уже очень давно. Можно сказать, что свои отношения я узаконил узами священного брака: компиляторы, отладчики и средства разработки — это моя основная работа в Tibbo со всеми вытекающими последствиями (да-да, в том числе и в виде насыщения предметом, увеличения процента рутинных задач и т.д.) Поэтому мотивация создания своего скриптового языка программирования у меня была отличная от банального удовлетворения собственного интереса.
Так зачем же?
Если максимально кратко сформулировать практическую сторону моей лично и нашей (как компании) мотивации, то она будет звучать так: мы хотели иметь встраиваемый скриптовый движок с указателями на структуры и безопасной адресной арифметикой. Такого не нашлось. И мы сделали язык Jancy («between-Java-and-C»), в котором есть и С-совместимые структуры, и указатели с безопасной арифметикой, и многое-многое другое:
Уникальные возможности
- Безопасные указатели и адресная арифметика;
- Беспрецедентный для скриптовых языков уровень совместимости исходных кодов с C/C++;
- Реактивное программирование (reactive programming) – одна из первых реализаций в императивном языке (а не в виде библиотек);
- Встроенный генератор лексеров/сканнеров.
Принципы дизайна
- Объектно-ориентированный язык с C-подобным синтаксисом;
- Бинарная ABI (application-binary-interface) совместимость с C/C++;
- Автоматическое управление памятью через точную сборку мусора (accurate garbage collection);
- LLVM в качестве backend.
Другие значимые особенности
- Исключения как синтаксический сахар над моделью кодов ошибок.
- Свойства (properties) – самая полная реализация;
- Мультикасты (multicasts) и события (events), включая слабые, от которых необязательно отписываться;
- Множественное наследование;
- Const-корректность;
- Поддержка парадигмы RAII (resource-acquisition-is-initialization);
- Локальная память потоков (thread local storage);
- Частичное применение (partial application) для функций и свойств;
- Оператор планирования (schedule operator) для создания указателей на функции, которые гарантированно будут вызваны в заданном окружении (например, в нужном рабочем потоке);
- Перечисления для битовых флагов (bitflag enums);
- Perl-подобное форматирование строк;
Более полный список возможностей с примерами использования можно посмотреть тут: http://tibbo.com/jancy/features.html
Кому это может пригодиться?
Прежде всего, мы писали язык для самих себя — Jancy используется в проекте IO Ninja в качестве встроенного скриптового языка. Однако, если он оказался полезен нам, мы скромно надеемся, что он вполне может помочь и другим. Надежда эта прежде всего опирается на три сильнейшие стороны Jancy, в которых наш язык обладает реальным преимуществом перед аналогами.
1. Высокий уровень совместимости с C/C++
Это относится и к бинарной ABI-совместимости, и к совместимости на уровне исходных кодов. Плюсов здесь много: это и бесшовное подключение существующих C-библиотек, и портирование кода с C/C++ с помощью копипастинга и последующих косметических правок (а иногда и вообще без них), и лёгкость создания на C/C++ новых библиотек для использования из Jancy-скриптов, и эффективность встраивания Jancy-движка в C/C++ приложение и т.д.
2. Удобные средства для IO-программирования
Тут я прежде всего говорю, во-первых, о поддержке указателей и адресной арифметики, идеально подходящих для разбора и генерации бинарных пакетов, и, во-вторых, о генераторе лексеров (причём инкрементальных, т.е. применимых к разбору IO потоков, приходящих по кускам). Сюда же можно отнести частичное применение и оператор планирования, которые вместе позволяют, например, создать обработчик завершения (completion routine) с захваченными контекстыми аргументами; при этом он будет автоматически вызван в нужном рабочем потоке.
3. Удобные средства для UI-программирования
Два слова: реактивное программирование. Уверен, что в ближайшем будущем поддержка реактивности — на уровне языка или же в форме костылей вроде препроцессоров и библиотек — станет неотъемлемой частью любой системы разработки пользовательских интерфейсов (UI). Jancy предлагает реактивность «из коробки», причём, по моему мнению, в совершенно интуитивной форме. Помимо реактивности, Jancy поддерживает всевозможные вариации свойств и событий, что также помогает строить красивые фреймворки пользовательского интерфейса.
При этом, несмотря на примечательные возможности пункта номер три, мы пока не позиционируем Jancy как язык разработки пользовательского интерфейса. Задача-максимум на данный момент — стать скриптовым языком для низкоуровневого IO программирования, т.е. инструментом системного/сетевого программиста/хакера.
А теперь — слайды! ©
ABI-совместимость с C/C++
Совместимость это всегда хорошо, а совместимость с де-факто языком-стандартом системного программирования — это просто здорово, не правда ли?
Jancy-скрипты JIT-компилируются и могут быть напрямую вызваны из программы на C/С++, равно как и напрямую вызывать C/C++ функции. Это означает, что после правильного описания типов данных и прототипов функций в скриптах Jancy и приложении на C++ становится возможно передавать данные естественным образом, через аргументы функций и возвращаемые значения.
Объявляем и используем функции из скрипта на Jancy:
bool foo (
char charArg,
int intArg,
double doubleArg
);
bar (int x)
{
bool result = foo ('a', x, 3.1415);
// ...
}
Пишем реализацию на C/C++:
bool foo (
char charArg,
int intArg,
double doubelArg
)
{
// ...
return true;
}
Подключаем перед JIT-компиляцией скрипта:
class MyLib: public jnc::StdLib
{
public:
JNC_BEGIN_LIB ()
JNC_FUNCTION ("foo", foo)
JNC_LIB (jnc::StdLib)
JNC_END_LIB ()
};
// ...
MyLib::mapFunctions (&module);
Готово! Никакой упаковки/распаковки variant-подобных контейнеров, явного заталкивания аргументов на стек виртуальной машины и т.д. — всё работает напрямую. На сегодняшний момент Jancy поддерживает все основные модели вызовов (calling conventions):
- cdecl (Microsoft/gcc)
- stdcall (Microsoft/gcc)
- Microsoft x64
- System V
В обратную сторону — вызов Jancy из C++ — всё так же просто:
typedef void Bar (int);
Bar* bar = (Bar*) module.getFunctionByName ("bar")->getMachineCode ();
bar (100);
Как насчёт вызова системных функций и динамических библиотек (dll/so)? Не вопрос! Jancy предлагает бесшовную интеграцию с динамическими библиотеками:
library User32
{
int stdcall MessageBoxA (
intptr hwnd,
char const thin* text,
char const thin* caption,
int flags
);
// ...
}
// ...
User32 user32;
user32.load ("user32.dll");
user32.lib.MessageBoxA (0, "Message Text", "Message Caption", 0x00000040);
При этом разрешение имён будет производиться по мере обращения, а найденные адреса будут кэшироваться (напоминает DELAYLOAD, с поправкой на явную загрузку самого модуля). Обработка ошибок при загрузке и разрешении имён производится стандартным для Jancy методом псевдо-исключений (подробнее см. следующий раздел).
Динамический поиск по имени (GetProcAddress/dlsym), разумеется, также возможен — хотя и не столь элегантен, как предыдущий подход.
Пример
typedef int cdecl Printf (
char const thin* format,
...
);
jnc.Library msvcrt;
msvcrt.load ("msvcrt.dll");
Printf thin* printf;
unsafe
{
printf = (Printf thin*) msvcrt.getFunction ("printf");
}
printf ("function 'printf' is found at 0x%p\n", printf);
Другим немаловажным следствием высокой степени совместимости между Jancy и C/C++ является возможность копипастить из общедоступных источников (таких как Linux, React OS или других проектов с открытым исходным кодом) и использовать определения заголовков коммуникационных протоколов на языке C:
enum IpProtocol: uint8_t
{
Icmp = 1,
Tcp = 6,
Udp = 17,
}
struct IpHdr
{
uint8_t m_headerLength : 4;
uint8_t m_version : 4;
uint8_t m_typeOfService;
bigendian uint16_t m_totalLength;
uint16_t m_identification;
uint16_t m_flags;
uint8_t m_timeToLive;
IpProtocol m_protocol;
bigendian uint16_t m_headerChecksum;
uint32_t m_srcAddress;
uint32_t m_dstAddress;
}
Кстати, обратите внимание на поддержку целочисленных типов с обратным порядком следования байтов (bigendians). Это, конечно, далеко не масштабное нововведение, но оно здорово упрощает описание и работу с заголовками сетевых протоколов — здесь обратный порядок следования байтов встречается повсеместно.
Псевдо-исключения
Как это ни парадоксально, но одним из следствий ABI-совместимости с C/C++ стал отказ от привычной для C++ программистов модели исключений. Дело в том, что такие исключения совершенно не подходят для мультиязыкового стека вызовов (хотя, конечно, список объективных претензий к C++-подобным исключениям этим не исчерпывается — горячие споры «за» и «против» исключений всплывают на программистских ресурсах с регулярностью, которой можно только позавидовать).
Так или иначе, в Jancy используется гибридная модель. В основе её лежит проверка возвращаемых значений, но компилятор избавляет от необходимости делать это вручную. В итоге всё выглядит почти как исключения в C++ или Java, но при этом поведение программы при ошибках на порядок более прозрачно и предсказуемо, а поддержка исключений при межязыковых взаимодействиях (таких, как вызов функций C++ из скриптов на Jancy и наоборот) становится настолько простой, насколько это вообще возможно.
bool foo (int a) throws
{
if (a < -100 || a > 200) // invalid argument
{
jnc.setStringError ("detailed-description-of-error");
return false;
}
// ...
return true;
}
Возвращаемые значения функций, помеченных модификатором throws, будут трактоваться как коды ошибок. В Jancy приняты интуитивные условия ошибки для стандартных типов: false для булева типа, null для указателей, -1 для беззнаковых целых, и < 0 для знаковых. Остальные типы приводятся к булеву (если это невозможно, то выдаётся ошибка компиляции). Очевидно, что функция, возвращающая void, в данной модели не может возвращать ошибки.
Помимо этого, в данной модели разработчик волен выбирать, как именно обрабатывать ошибки в каждом конкретном случае. Иногда это удобнее делать проверкой кода возврата вручную, иногда – использовать семантику исключений. В Jancy — при вызове одной и той же функции! — можно делать и так и так, в зависимости от ситуации.
bar ()
{
foo (10); // can use exception semantics...
foo (-20);
catch:
printf ($"error caught: $(jnc.getLastError ().m_description)\n");
// handle error
}
baz (int x)
{
bool result = try foo (x); // ...or manual error-code check
if (!result)
{
printf ($"error: $(jnc.getLastError ().m_description)\n");
// handle error
}
}
Конструкция finally в большинстве языков традиционно ассоциируется с исключениями. Но в Jancy finally может быть добавлен в любой блок по желанию разработчика. В конце концов, убирать за собой надо даже если никаких ошибок не возникало, не правда ли?
foo ()
{
// nothing to do with exceptions here, just a 'finally' block to clean up
finally:
printf ("foo () finalization\n");
}
Конечно, допускается и более традиционное использование конструкции finally в случаях, когда исключения-таки ожидаются.
Пример
foo (char const* address)
{
try
{
open (address);
transact (1);
transact (2);
transact (3);
catch:
addErrorToLog (jnc.getLastError ());
finally:
close ();
}
}
Безопасные указатели и адресная арифметика
Адресная арифметика в скриптовом языке — это то, ради чего всё собственно и затевалось.
Указатели, при всей своей врождённой небезопасности, в явном или неявном виде являются частью любого языка. Ограничением доступных разработчику видов указателей и операций над ними можно значительно обезопасить язык, упростить обработку неблагоприятных ситуаций во время исполнения и даже отлавливать некорректные операции в момент компиляции при помощи статического анализа. Но если в игру вступает адресная арифметика, полностью переложить анализ на этап компиляции просто невозможно.
Чтобы всегда была возможность проверять корректность операций, указатели в Jancy по умолчанию толстые. Помимо адреса они также содержат валидатор – специальную структуру мета-данных, из которой можно получить информацию о разрешённом диапазоне адресов, типе данных, а также целочисленном уровне вложенности (scope level).
Формула безопасности указателей и адресной арифметики в Jancy такова:
- проверка диапазонов при косвенных обращениях по указателям;
- проверка уровня вложенности при присвоениях указателей;
- проверка приводимости при присвоениях указателей.
А как же производительность?
Данный механизм не бесплатен и действительно выливается в определённые накладные расходы во время исполнения.
Но во-первых, даже в самом наивном варианте, без каких-либо оптимизаций, два целочисленных сравнения для проверки диапазона или одно для проверки уровня вложенности – это не так страшно, особенно принимая во внимание JIT-компиляцию и тот факт, что Jancy – это всё-таки скриптовый язык.
Во-вторых, в дальнейшем с помощью статического анализа можно будет избавиться от многих ненужных проверок ещё на этапе компиляции. Ну и в-третьих, для критических по производительности участков кода уже сейчас можно использовать небезопасные (тонкие, thin) указатели без валидаторов – проверки при операциях с тонкими указателями не производятся.
Но во-первых, даже в самом наивном варианте, без каких-либо оптимизаций, два целочисленных сравнения для проверки диапазона или одно для проверки уровня вложенности – это не так страшно, особенно принимая во внимание JIT-компиляцию и тот факт, что Jancy – это всё-таки скриптовый язык.
Во-вторых, в дальнейшем с помощью статического анализа можно будет избавиться от многих ненужных проверок ещё на этапе компиляции. Ну и в-третьих, для критических по производительности участков кода уже сейчас можно использовать небезопасные (тонкие, thin) указатели без валидаторов – проверки при операциях с тонкими указателями не производятся.
Проверки допустимого диапазона адресов производятся как в случае явного использования указателя, так и в случае оператора индексации:
foo (
char* p,
size_t i
)
{
p += i;
*p = 10; // <-- range is checked
static int a [] = { 10, 20, 30 };
int x = a [i]; // <-- range is checked
}
В случае указателей на стековые и потоковые переменные, необходимы также проверки уровня вложенности — для предотвращения утечки адресов за границы времён их жизни. Этот механизм работает даже в случае многоуровневых указателей, вроде указателей-на-структуры-с-указателями-на-структуры-и-т-д:
int* g_p;
bar (
int** dst,
int* src
)
{
*dst = src; // <-- scope level is checked
}
baz ()
{
int x;
bar (g_p, &x); // <-- runtime error: scope level mismatch
}
Наконец, проверки приводимости призваны предотвратить разрушение самих валидаторов. Действительно, что если мы приведём создадим указатель на указатель, приведём его к указателю на char и затем байт за байтом затрём валидатор мусором? Jancy просто не даст это сделать: компилятор и runtime разрешают приведения только там, где это безопасно.
Подробнее
Jancy делит все типы на категории POD (plain-old-data) и не-POD. Понятие POD в Jancy несколько отличается от аналогичного в C++. Возможно, в этой связи стоило придумать новый термин, чтобы избежать путаницы, но в итоге я решил не плодить новые сокращения. Кроме того, мне кажется, что POD в Jancy гораздо точнее отражает смысл понятия “plain-old-data”.
В Jancy POD – это данные без мета-данных. Их можно смело побайтно копировать и модифицировать и при этом ничего не сломать. Агрегация POD данных, будь то включения полей, наследование (тут отличие от C++) или объединение в массивы – тоже приводит к POD. Всё, что содержит мета-данные, а именно классы, безопасные указатели на данные и их любые агрегаты – это не-POD.
Компилятор Jancy разрешает приведения не-POD типов тогда и только тогда, когда в результате приведения не появляется возможность разрушить или подменить мета-данные. Для ситуаций, в которых на этапе компиляции это неизвестно (например, мы производим приведение к дочернему типу, так называемый downcast) – существует специальный оператор динамического приведения. Оператор динамического приведения компилируется в вызов встроенной функции, которая возвращает указатель на запрошенный тип, или null, если приведение невозможно.
Для примера подготовим тестовые типы, которые мы будем приводить друг к другу:
Здесь A, B, C – это POD (причём последний тип не был бы POD в C++), D – не POD, т.к. этот тип содержит мета-данные в виде валидатора указателя m_s. Теперь рассмотрим возможные операции приведения.
Приведения к родительским типам (upcast) всегда разрешены и не требуют явного оператора приведения ни для POD, ни для не-POD:
POD-типы могут быть произвольно приведены друг к другу с помощью оператора приведения:
Приведения от POD-типов к не-POD-типам разрешены только в случае результирующего константного указателя:
Приведение к дочерним типам (downcast) возможно с помощью оператора динамического приведения:
Динамическое приведение возможно благодаря содержащемуся в указателе валидатору, а значит и информации о типе. Помимо динамического приведения, Jancy также предлагает операцию динамического определения размера, который доступен из того же валидатора — хотя это и не имеет отношения к безопасности указателей, в определенных ситуациях это очень удобно:
В Jancy POD – это данные без мета-данных. Их можно смело побайтно копировать и модифицировать и при этом ничего не сломать. Агрегация POD данных, будь то включения полей, наследование (тут отличие от C++) или объединение в массивы – тоже приводит к POD. Всё, что содержит мета-данные, а именно классы, безопасные указатели на данные и их любые агрегаты – это не-POD.
Компилятор Jancy разрешает приведения не-POD типов тогда и только тогда, когда в результате приведения не появляется возможность разрушить или подменить мета-данные. Для ситуаций, в которых на этапе компиляции это неизвестно (например, мы производим приведение к дочернему типу, так называемый downcast) – существует специальный оператор динамического приведения. Оператор динамического приведения компилируется в вызов встроенной функции, которая возвращает указатель на запрошенный тип, или null, если приведение невозможно.
Для примера подготовим тестовые типы, которые мы будем приводить друг к другу:
struct A
{
int m_a;
}
struct B
{
int m_b;
}
struct C: A, B
{
int m_c;
}
struct D: C
{
char const* m_s;
}
Здесь A, B, C – это POD (причём последний тип не был бы POD в C++), D – не POD, т.к. этот тип содержит мета-данные в виде валидатора указателя m_s. Теперь рассмотрим возможные операции приведения.
Приведения к родительским типам (upcast) всегда разрешены и не требуют явного оператора приведения ни для POD, ни для не-POD:
foo (D* d)
{
C* c = d;
A* a = с;
}
POD-типы могут быть произвольно приведены друг к другу с помощью оператора приведения:
bar (B* b)
{
char* p = (char*) b;
C* c = (C*) b; // <-- unlike C++ no pointer shift
}
Приведения от POD-типов к не-POD-типам разрешены только в случае результирующего константного указателя:
foo (D* d)
{
char* p = (char*) d; <-- error
char const* p2 = (char const*) d; // OK
}
Приведение к дочерним типам (downcast) возможно с помощью оператора динамического приведения:
bar (B* b)
{
D* d = dynamic (D*) b;
A* a = dynamic (A*) b; // not a downcast, but still OK
}
Динамическое приведение возможно благодаря содержащемуся в указателе валидатору, а значит и информации о типе. Помимо динамического приведения, Jancy также предлагает операцию динамического определения размера, который доступен из того же валидатора — хотя это и не имеет отношения к безопасности указателей, в определенных ситуациях это очень удобно:
foo (int* p)
{
size_t size = dynamic sizeof (*p);
size_t count = dynamic countof (*p);
}
//...
bar ()
{
int a [100];
foo (a);
}
Уважаемые хабровчание приглашаются поиграться с нашим online-компилятором и на деле опробовать, как всё это работает (читайте: попытаться подсунуть компилятору пример скрипта с указателями, который его свалит ;)
Подробнее про указатели в Jancy читать тут: http://tibbo.com/jancy/features/pointers.html
Функции-автоматы
Безопасные указатели и адресная арифметика идеально подходят для разбора и генерации бинарных пакетов:
dissectEthernet (void const* p)
{
io.EthernetHdr const* hdr = (io.EthernetHdr const*) p;
switch (hdr.m_type)
{
case io.EthernetType.Ip:
dissectIp (hdr + 1);
break;
case io.EthernetType.Ip6:
dissectIp6 (hdr + 1);
break;
case io.EthernetType.Arp:
dissectArp (hdr + 1);
break;
// ...
}
}
Но существует и другой класс протоколов — протоколов, не полагающихся на бинарные заголовки и вместо этого использующих некий язык запросов/ответов. В этом случае для анализа IO-потоков требуется писать парсер данного языка. При этом надо озаботиться предварительной буферизацией данных — часто нет гарантии, что транспорт доставил сообщение целиком, а не по кусками.
Поскольку данная задача является типовой в IO-программировании, Jancy предлагает встроенный инструмент для её решения. Функции-автоматы Jancy призваны облегчить первую и самую рутинную стадию написания любого парсера — создание лексера/сканнера. Работает это по принципу известных лексер-генераторов типа Lex, Flex, Ragel:
jnc.AutomatonResult automaton scanRx (jnc.Recognizer* recognizer)
{
%% "getOption"
createToken (Token.GetOption);
%% "setOption"
createToken (Token.SetOption);
%% "exit"
createToken (Token.Exit);
%% [_\w][_\w\d]*
createToken (Token.Identifier, recognizer.m_lexeme);
// ...
}
Внутри функции-автомата описан список распознаваемых лексем в виде регулярных выражений. После описания каждой лексемы следует блок кода, который надо выполнить при её обнаружении во входном потоке. Вся эта кухня компилируется в табличный ДКА, состояние которого хранится во внешнем объекте jnc.Recognizer (указатель на этот объект передаётся в аргументе recognizer). В нём же накапливаются символы потенциальной лексемы, и он же неявно вызывает нашу функцию-автомат, выполняя при этом необходимые переходы между состояниями.
Совокупность функции-автомата и этого управляющего объекта-распознавателя и представляет собой наш лексер. При этом данный лексер будет инкрементальным, то есть способным разбирать сообщения, приходящие по частям:
jnc.Recognizer recognizer (scanRx); // create recognizer object
try
{
recognizer.write ("ge");
recognizer.write ("tOp");
recognizer.write ("tion");
recognizer.eof (); // notify recognizer about eof (this can trigger actions or errors)
catch:
// handle recognition error
}
Отметим, что, как и в Ragel, возможно переключаться между разными функциями-автоматами, что позволяет, в частности, создавать контекстно-зависимые ключевые слова (или, если сказать по-другому, разбирать мульти-языковый вход).
Пример
jnc.AutomatonResult automaton scanGlobal (jnc.Recognizer* recognizer)
{
%% '#'
recognizer.m_automatonFunc = scanPreprocessor; // switch to pp-specific keywords
// ...
}
jnc.AutomatonResult automaton scanPreprocessor (jnc.Recognizer* recognizer)
{
%% "if"
createToken (Token.If);
%% "ifdef"
createToken (Token.Ifdef);
// ...
%% '\n'
recognizer.m_automatonFunc = scanGlobal; // switch back
}
Функции-автоматы с одной стороны, и безопасные указатели с адресной арифметикой с другой позволяют с удобством разбирать протоколы и IO-потоки любого типа.
Заключение
Несмотря на то, что программирование пользовательских интерфейсов (UI) не является главным назначением Jancy на данный момент, мы всё равно хотели бы продемонстрировать подход к реактивному программированию, который используется в Jancy — я считаю, что нам удалось найти оптимальный компромисс в сосуществовании императивного и декларативного начал в реактивном программировании. Рассказ об этом пойдёт в следующей статье.
Тем временем мы приглашаем вас опробовать возможности языка Jancy (как описанные в данной статье, так и многие другие) на страничке живой демки компилятора. Также вы можете скачать, собрать и поиграться с библиотекой JIT-компилятора Jancy и примерами её использования — всё это доступно на страничке downloads.