В последнее время на рынке появилось огромное количество новых языков программирования: Go, Swift, Rust, Dart, Julia, Kotlin, Hack, Bosque – и это только из числа тех, которые на слуху.
Ценность того, что эти языки привносят в мир программирования, тяжело переоценить, но, как правильно в прошлом году отмечал Y Combinator, говоря про инструменты разработки:
Фреймворки становятся лучше, языки немного умнее, но в основном мы делаем то же самое.
В данной статье будет рассказано о языке, построенном на подходе, принципиально отличающемся от подходов, используемых во всех существующих языках, в том числе вышеперечисленных. По большому счету, этот язык можно считать языком общего назначения, хотя некоторые его возможности и текущая реализация платформы, построенной на нем, все же, наверное, ограничивают его применение немного более узкой областью – разработкой информационных систем.
Сразу оговорюсь, речь пойдет не об идее, прототипе, и даже не о MVP, а о полноценном production-ready языке со всей необходимой языку инфраструктурой – от среды разработки (с отладчиком) до автоматической поддержки нескольких версий языка (с автоматическими merge багфиксов между ними, release-note и т.п.). Кроме того, с использованием этого языка уже реализовано несколько десятков проектов сложности уровня ERP, с сотнями одновременных пользователей, терабайтными базами, сроками «нужно вчера», ограниченными бюджетами и разработчиками без опыта в IT. Причем все это одновременно. Ну и, конечно, следует учесть, что сейчас не 2000 год, и все эти проекты реализовывались поверх существующих систем (чего там только не было), а значит, сначала нужно было постепенно, без остановки бизнеса сделать «как было», а потом, также постепенно, сделать «как должно быть». В общем, это как продавать первые электромобили не богатым хипстерам в Калифорнии, а лоукост-службам такси где-нибудь в Омске.
Платформа, построенная на этом языке, выпускается под лицензией LGPL v3. Честно, не хотел это писать прямо во вступлении, так как это далеко не самое главное ее преимущество, но, пообщавшись с людьми, работающими на одном из ее основных потенциальных рынков – ERP платформ, заметил одну особенность: все эти люди без исключения говорят, что даже если вы сделаете то же самое, что уже есть на рынке, но бесплатно, то это уже будет очень круто. Так что оставлю это тут.
Немного теории
Начнем с теории, чтобы обозначить разницу в фундаментальных подходах, используемых в этом и других современных языках.
Небольшой дисклеймер, дальнейшие рассуждения в какой-то степени являются попыткой натянуть сову на глобус, но с фундаментальной теорией в программировании в принципе, скажем прямо, не очень, поэтому приходится использовать то, что есть.
Одной из самых первых и главных задач, решаемых программированием, является задача вычисления значений функций. С точки зрения теории вычислений, в решении этой задачи существует два принципиально разных подхода.
Первым таким подходом являются различные машины (самой известной из которых является машина Тьюринга) – модель, которая состоит из текущего состояния (памяти) и машины (процессора), которая на каждом шаге тем или иным образом изменяет это текущее состояние. Этот подход также принято называть архитектурой Фон Неймана, и именно он лежит в основе всех современных компьютеров и 99 процентов существующих языков.
Второй подход основан на использовании операторов, его используют так называемые частично-рекурсивные функции (далее ЧРФ). При этом самое главное отличие этого подхода не в использовании операторов как таковых (операторы, к примеру, есть и в структурном программировании, использующим первый подход), а в возможности итерирования по всем значениям функции (см. оператор минимизации аргумента) и в отсутствии состояния в процессе вычисления.
Как и машина Тьюринга, частично-рекурсивные функции полны по Тьюрингу, то есть с их помощью можно задать любое возможное вычисление. Здесь сразу уточним, что и машина Тьюринга, и ЧРФ – это только минимальные базисы, а дальше речь пойдет о них именно как о подходах, то есть о модели с памятью-процессором и о модели с операторами без использования переменных и возможностью итерирования по всем значениям функций соответственно.
У ЧРФ как подхода есть три основных преимущества:
- Он гораздо лучше оптимизируется. Это касается как непосредственно оптимизации самого процесса вычисления значения, так и возможности параллелизма такого вычисления. В первом же подходе эффект последействия, наоборот, вносит очень большую сложность в эти процессы.
- Он гораздо лучше инкрементируется, то есть для построенной функции можно гораздо эффективнее определить, как будут изменяться ее значения при изменении значений функций, которые эта построенная функция использует. Строго говоря, это преимущество является частным случаем первого, но именно оно дает огромное количество возможностей, которых принципиально не может быть в первом подходе, поэтому выделено отдельным пунктом.
- Он значительно проще для понимания. То есть, грубо говоря, описание функции подсчета суммы одного показателя в разрезе двух других показателей гораздо проще для понимания, чем если то же самое описать в терминах первого подхода. Впрочем, в алгоритмически сложных задачах ситуация диаметрально противоположная, но тут стоит отметить, что алгоритмически сложных задач в абсолютном большинстве областей хорошо если 5%. Вообще, если немного обобщить, то ЧРФ – это математика, а машины Тьюринга – это информатика. Соответственно, математику изучают чуть ли не в детском саду, а информатику факультативно и со старших классов. Так себе сравнение, конечно, но все же какую-то метрику в данном вопросе дает.
У машин Тьюринга есть как минимум два преимущества:
- Уже упомянутая лучшая применимость в алгоритмически сложных задачах
- Все современные компьютеры построены на этом подходе.
Плюс, в этом сравнении речь идет только о задачах вычисления данных, в задачах изменения данных без машин Тьюринга все равно не обойтись.
Дочитав до этого места, любой внимательный читатель задаст резонный вопрос: “Если ЧРФ подход так хорош, почему он не используется ни в одном распространенном современном языке?”. Так вот, на самом деле, это не так, он используется, причем в языке, который применяется в подавляющем большинстве существующих информационных систем. Как легко догадаться, этим языком является SQL. Тут, конечно, тот же внимательный читатель резонно возразит, что SQL – это язык реляционной алгебры (то есть работы с таблицами, а не функциями), и будет прав. Формально. Фактически же можно вспомнить, что таблицы в СУБД обычно находятся в третьей нормальной форме, то есть имеют колонки-ключи, а значит, любую оставшуюся колонку этой таблицы можно рассматривать как функцию от ее колонок-ключей. Не очевидно, прямо скажем. И то, почему SQL так и не перерос из языка реляционной алгебры в полноценный язык программирования (то есть работы с функциями) – большой вопрос. На мой взгляд, причин тому много, самая главная из которых – «русский (на самом деле любой) человек на голодный желудок работать не может, а на сытый не хочет», в том смысле, что, как показывает практика, необходимая для этого работа поистине титаническая и несет слишком большие риски для небольших компаний, а у крупных компаний – во-первых, и так все хорошо, а во-вторых, эту работу невозможно форсировать деньгами – здесь важнее качество, а не количество. Собственно, самой наглядной иллюстрацией того, что бывает, когда проблему пытаются решать количеством, а не качеством, является Oracle, который даже самое базовое применение инкрементальности – обновляемые материализованные представления – ухитрился реализовать так, что у этого механизма количество ограничений размером с несколько страниц (справедливости ради, у Microsoft все еще хуже). Впрочем, это уже отдельная история, возможно, про нее будет отдельная статья.
В то же время речь не идет о том, что SQL плох. Нет. На своем уровне абстрагирования он отлично выполняет свои функции, и текущая реализация платформы использует его чуть менее, чем полностью (во всяком случае, значительно больше, чем все остальные платформы). Другое дело, что сразу после своего рождения SQL фактически остановился в развитии и так и не стал тем, кем мог стать, а именно языком, о котором сейчас пойдет речь.
Но хватит теории, пора переходить непосредственно к языку.
Итак, встречаем:
Конкретно эта статья будет первой частью из трех (так как материала все же слишком много даже для двух статей), и в ней будет рассказано только про логическую модель – то есть только про то, что связано непосредственно с функционалом системы и никак не связано с процессами разработки и выполнения (оптимизации производительности). Более того, речь пойдет только об одной из двух частей логической модели – логике предметной области. Эта логика определяет, какую информацию хранит система, и что с этой информацией можно делать (при разработке бизнес-приложений ее также часто называют бизнес-логикой).
Графически все понятия логики предметной области в lsFusion можно представить следующей картинкой:
Стрелки на этой картинке обозначают направления использования понятиями друг друга, таким образом, понятия образуют своего рода стек, и, соответственно, именно в порядке этого стека я и буду про них рассказывать.
Свойства
Свойство – это абстракция, которая принимает на вход один или несколько объектов в качестве параметров и возвращает некоторый объект в качестве результата. Свойство не имеет последействия, и, по сути, является чистой функцией, однако, в отличие от последней, может не только вычислять значения, но и хранить их. Собственно, само название “свойство” позаимствовано из других современных языков программирования, где оно используется приблизительно для тех же целей, но при этом гвоздями прибито к инкапсуляции и, соответственно, поддерживается только для функций с одним параметром. Ну и в пользу использования именно этого термина сыграло то, что это само слово “свойство” короче, чем «чистая функция», плюс не имеет ненужных ассоциаций.
Свойства задаются рекурсивно при помощи предопределенного набора операторов. Этих операторов достаточно много, поэтому рассмотрим только основные из них (эти операторы покрывают 95% любого среднестатического проекта).
Первичное свойство (DATA)
Первичное свойство – это свойство, значение которого хранится в базе данных и может изменяться в результате выполнения соответствующего действия (о нем чуть позже). По умолчанию значение каждого такого свойства для любого набора параметров равно специальному значению NULL.
quantity = DATA INTEGER (Item); isDayOff = DATA BOOLEAN (Country, DATE); |
При использовании оператора первичного свойства необходимо задать, какие классы создаваемое свойство принимает на вход (о самих классах также чуть позже), и какой класс значения это свойство может возвращать.
Фактически этот оператор обобщает поля и коллекции в современных языках. Так:
class X {
Y y;
Map<Y, Z> f;
Map<Y, Map<M, Z>> m;
List<Y> l;
static Set<Y> s;
}
Эквивалентно:
y = DATA Y (X); f = DATA Z (X, Y); m = DATA Z (X, Y, M); l = DATA INTEGER (X,Y); s = DATA BOOLEAN (Y); |
Композиция (JOIN), Константа, Арифметические (+,-,/,*), Логические (AND, OR), Строковые (+, CONCAT), Сравнение (>,<,=), Выбор (CASE, IF), Принадлежность классу (IS)
f(a) = IF g(h(a)) > 5 AND a IS X THEN ‘AB’ + ‘CD’ ELSE x(5); |
Тут все более-менее стандартно, поэтому останавливаться на этих операторах подробно особенного смысла нет. Единственное, что, наверное, все же стоит отметить:
- В логических операторах и операторах выбора в качестве условий можно использовать не только свойства со значениями логических типов, а вообще любые свойства. Соответственно, условием в этом случае будет определенность значения свойства (то есть отличие от NULL). Собственно, сам логический тип в lsFusion – это, по сути, константа, то есть его множество значений состоит из ровно одного элемента – значения TRUE (роль FALSE выполняет значение NULL), никаких крышесносящих 3-state’ов.
- Для арифметических и строковых операторов есть специальные формы работы с NULL: (+), (-), CONCAT с сепаратором. При использовании этих форм:
- в арифметических операторах: NULL на входе интерпретируется как 0, а на выходе – наоборот, 0 заменяется на NULL (то есть 5 (+) NULL = 5, 5 (-) 5 = NULL, но 5 + NULL = NULL и 5 — 5 = 0).
- в строковых операторах: NULL на входе игнорируется и соответственно сепаратор не добавляется (то есть CONCAT ‘ ‘, ‘John’,’Smith’ = ‘John Smith’, а CONCAT ‘ ‘, ‘John’, NULL = ‘John’, но ‘John’ + ‘ ‘ + NULL = NULL).
- Для оператора простого выбора (IF) существует (и очень часто используется) постфиксная форма: f(a) IF g(a), которая возвращает f(a) если g(a) не NULL, и NULL – в обратном случае.
Группировка (GROUP)
Группировка – самый часто используемый оператор работы со множествами. Этот оператор берет свойство и для всех его значений вычисляет некоторую агрегирующую функцию (например, сумму) в разрезе значений других свойств.
С точки зрения синтаксиса есть две формы этого оператора:
- Функциональный:
sum(Invoice i) = GROUP SUM sum(InvoiceDetail id) IF invoice(id) = i;
currentBalance(Sku sk) = GROUP SUM currentBalance(sk, Stock st);Эта форма допускает замыкания на лексический контекст, то есть внутри оператора можно использовать параметры внешнего контекста (в примерах выше параметры i и sk). Особенность функциональной формы в том, что ее можно использовать в выражениях, то есть писать что-то вроде:
x() = (GROUP SUM f(a)) + 5; - SQL-стиль:
sum = GROUP SUM sum(InvoiceDetail id) BY invoice(id);
currentBalance = GROUP SUM currentBalance(Sku sk, Stock st) BY sk;В отличие от функциональной эту форму оператора можно использовать только при объявлении свойств (как и, скажем, оператор создания первичного свойства)
С точки зрения лаконичности кода первую форму имеет смысл использовать, когда группировка идет по параметрам (пример с остатком), вторую – по свойствам (пример с инвойсом). Хотя, по большому счету, это все же дело вкуса, кому как привычнее (для людей больше работавших с функциональным программированием, скорее будет привычна первая форма, для работавших с SQL – вторая). Кстати говоря, при желании можно использовать смесь этих форм (то есть когда можно и обращаться к верхним параметрам и использовать опцию BY), что-то вроде:
// BY отображается только на неиспользованные параметры, то есть s sum(DATE from, Stock s, DATE to) = GROUP sum(Invoice i) IF date(i) >= from AND date(i) <=to BY stock(i); |
но, если честно, я бы не рекомендовал так делать, так как такое отображение, на мой взгляд, слишком неявно.
В качестве агрегирующей функции кроме суммы также поддерживаются:
- Максимум/минимум,
- Строковое объединение в заданном порядке
- Последнее значение в заданном порядке.
Разбиение / Упорядочивание (PARTITION … ORDER)
Описанный выше оператор группировки разбивает все объекты (а точнее, наборы объектов) в системе на группы, после чего для каждой группы вычисляет некоторое значение. Однако в некоторых случаях значение нужно вычислять не для самой группы, а для непосредственно группируемых наборов объектов (но делать это в контексте группы, в которую этот набор входит). Для выполнения такого рода вычислений в языке существует специальный оператор разбиения / упорядочивания.
place(Team t) = PARTITION SUM 1 ORDER DESC points(t) BY conference(t); |
Отметим, что, вообще говоря, разбиение можно выполнять без упорядочивания, а упорядочивания без разбиения, но все же в абсолютном большинстве случаев эти операции выполняются вместе, поэтому они объединены в один оператор.
Аналогом этого оператора в SQL (и то, при помощи чего он реализуется) являются оконные функции (OVER PARTITION BY… ORDER BY).
Рекурсия (RECURSION)
Рекурсия – наверное, самый сложный для понимания оператор работы с множествами. Он нужен для реализации вычислений с неизвестным заранее количеством итераций, в частности, для работы с графами.
Для оператора рекурсии необходимо задать начальное свойство и свойство шага. Соответственно, алгоритм вычисления этого оператора состоит в следующем (далее почти дословная цитата из документации):
- Сначала рекурсивно строится промежуточное свойство (result) с дополнительным первым параметром (номером операции) следующим образом:
- result(0, o1, o2, ..., oN) = initial(o1, ..., oN), где initial – начальное свойство
- result(i+1, o1, o2, ..., oN) = step(o1, ..., oN, $o1, $o2, ..., $oN) IF result(i, $o1, $o2, ..., $oN), где step – свойство шага.
- Затем для всех значений полученного свойства вычисляется сумма в разрезе всех его параметров, за исключением номера операции (то есть o1, o2, …, oN). Теоретически вместо суммы может быть любая агрегирующая функция, но в текущей реализации поддерживается только сумма.
Не самое очевидное определение, скажем прямо, поэтому суть этого оператора, наверное, все же проще понять по примерам:
// итерация по integer от from до to (это свойство по умолчанию входит в модуль System) iterate(i, from, to) = RECURSION i=from STEP i=$i+1 AND i<=to CYCLES IMPOSSIBLE; // считает количество различных путей от a до b в графе (то есть, в частности, определяет достижимость) edge = DATA BOOLEAN (Node, Node); pathes 'Кол-во путей' (a, b) = RECURSION 1 IF b=a STEP 1 IF edge(b, $b); // определяет, на каком уровне находится child от parent, и null, если не является потомком (тем самым это свойство можно использовать для определения всех child'ов) parent = DATA Group (Group); level 'Уровень' (Group child, Group parent) = RECURSION 1 AND child IS Group AND parent = child STEP 1 IF parent = parent($parent); // числа Фибоначчи, свойство высчитывает все числа Фибоначи до значения to, (после будет возвращать NULL) fib(i, to) = RECURSION 1 IF (i=0 OR i=1) STEP 1 IF (i=$i+1 OR i=$i+2) AND i<to CYCLES IMPOSSIBLE; |
Отметим, что если разбиение / упорядочивание можно реализовать при помощи, скажем, группировки и композиции, то задачи этого оператора решить при помощи других операторов не получится в принципе.
Кстати, забавно, что хотя определение этого оператора очень похоже на определение оператора примитивной рекурсии в ЧРФ, в ЧРФ примитивную рекурсию можно применять, только если количество итераций заранее известно, а в lsFusion – наоборот.
Аналогом оператора рекурсии в SQL являются рекурсивные CTE, правда, при выполнении платформа редко их использует, так как там очень большое количество ограничений. В частности, в Postgres там нельзя использовать GROUP BY для шага, что, по сути, означает, что при пробеге по графу для вершин нельзя использовать пометки, а значит, количество итераций растет экспоненциально. Поэтому на практике платформа, как правило, использует табличные функции с WHILE’ом внутри.
На этом с описанием операторов создания свойств закончим. Это не все операторы, но остальные либо значительно реже используются, либо относятся к другим уровням абстракции языка и будут рассмотрены там.
Действия
Действие – это некоторая абстракция, которая принимает на вход некоторые объекты в качестве параметров, и, используя их тем или иным способом, изменяет состояние системы (как той, в которой это действие выполняется, так и состояние любой другой внешней системы). Тут конечно, наверное, можно было бы использовать термин “процедура”, но, во-первых, он уже достаточно давно устарел, а, во-вторых, само слово более громоздкое и непонятное, чем “действие”.
Вообще, свойства и действия – это своего рода Инь и Янь программирования в lsFusion. Свойства используют подход ЧРФ, действия – подход машины Тьюринга. Свойства обрабатываются на сервере БД, действия – на сервере приложений (тут на самом деле есть достаточно много магии, когда платформа перемещает эти обработки между серверами, поэтому речь тут скорее идет о том, где эти абстракции обрабатываются по умолчанию). Свойства отвечают за хранение и вычисление данных, действия – за изменение. И так далее.
Стоит отметить, что разбиение на свойства и действия неявно есть и в других языках. Так арифметические / логические операторы, переменные, поля и вообще все, что можно использовать в выражениях, можно отнести к логике свойств, все остальное к логике действий. Но если в других языках это соотношение хорошо если 3 на 97, то в lsFusion в среднестатистическом проекте – минимум 60 на 40.
Действия, как и свойства, задаются рекурсивно при помощи предопределенного набора операторов. Этих операторов, опять-таки, достаточно много (на самом деле их в несколько раз больше, чем операторов создания свойств), поэтому также рассмотрим только основные из них.
Начнем с операторов, отвечающих за порядок выполнения:
Цикл (FOR), Рекурсивный цикл (WHILE)
Несмотря на такое же название, цикл в lsFusion существенно отличается от аналогичного понятия в других языках программирования, и построен на упомянутой ранее операции итерирования по всем наборам объектов, для которых значение заданного свойства не NULL (будем называть это свойство условием цикла).
FOR selected(Team team) DO MESSAGE 'Team ' + name(team) + ' was selected'; |
По умолчанию итерирование идет в недетерминированном порядке, однако при необходимости этот порядок можно задать явно:
showAllDetails(Invoice i) { FOR invoice(InvoiceDetail id) = i ORDER index(id) DO MESSAGE 'Sku : ' + nameSku(id) + ', ' + quantity(id); } |
Отметим, что при создании цикла его условие обязано вводить новый параметр, в противном случае платформа выдаст ошибку и предложит использовать оператор ветвления (IF).
Рекурсивный цикл (WHILE) отличается от обычного цикла только тем, что:
- продолжает выполнения до тех пор, пока для условия цикла есть хоть одно не NULL значение (в этом смысле он очень похож на оператор рекурсии в свойствах)
- не обязан вводить новый параметр
Вызов (EXEC), Последовательность ({…}), Ветвление (CASE, IF), Прерывание (BREAK), Выход (RETURN)
f(a) { FOR t=x(b,a) DO { do(b); IF t>5 THEN BREAK; } MESSAGE 'Succeeded'; } |
Эти операторы более-менее стандартны и мало отличаются от аналогичных операторов в других языках программирования. Понятно, что есть небольшие нюансы по синтаксису, но по сравнению с отличиями в остальных операторах подробно останавливаться на них смысла нет.
Изменение свойства (CHANGE)
Этот оператор позволяет изменять значения первичных свойств. При этом, делать это, он может не только для одного набора значений объектов, но и для всех наборов объектов, для которых значение заданного свойства не равно NULL. Например:
// изменить скидку для выбранных товаров для клиента setDiscount(Customer c) { discount(c, Item i) <- 15 WHERE selected(i); } |
Отметим, что описанное выше действие эквивалентно:
setDiscount(Customer c) { FOR selected(Item i) DO discount(c, i) <- 15; } |
И на самом деле, платформа, если видит, что в теле цикла нет рекурсивных зависимостей (то есть когда читаемые свойства зависят от изменяемых, как в данном случае, предполагая, к примеру, что selected и discount – первичные свойства), то платформа сама автоматически преобразует второй вариант в первый и выполняет одним запросом. Впрочем такая оптимизация, это отдельная тема, на которой подробнее остановимся в следующих статьях.
Добавление объектов (NEW)
Этот оператор добавляет объект заданного класса (про классы вот уже совсем скоро, хотя ничего особенного, во всяком случае, в их способе задания, нет). Так же как и для оператора изменения свойства, можно добавлять не один, а сразу много объектов для заданного условия.
Синтаксис оператора добавления объектов похож на синтаксис оператора изменения свойства:
newSku () { LOCAL addedSkus = Sku (INTEGER); NEW Sku WHERE iterate(i, 1, 3) TO addedSkus(i); FOR Sku s = addedSkus(i) DO { id(s) <- 425; name(s) <- 'New Sku : ' + i; } } |
Впрочем, явно этот синтаксис обычно не используется, для добавления объектов есть специальный синтаксический сахар — опция NEW в операторе цикла (FOR), которая сразу вводит новый параметр для добавленного объекта (что гораздо удобнее):
FOR iterate(i, 1, 3) NEW s=Sku DO { id(s) <- i; name(s) <- 'New Sku : ' + i; } |
Если нужно добавить ровно один объект, FOR можно не указывать:
NEW s=Sku DO { id(s) <- 425; name(s) <- 'New Sku'; } |
Добавление объекта с физической точки зрения – это не более чем генерация уникального идентификатора, при этом, если объектов несколько, платформа умеет генерировать эти идентификаторы одним запросом сразу для всех объектов.
Удаление объектов (DELETE)
Тут все достаточно просто и во многом аналогично двум верхним операторам – оператор удаления объектов удаляет один или множество объектов для заданного условия:
DELETE Sku s WHERE name(s) = 'MySku'; |
Так же как и для изменения свойства, для оператора удаления работает «магия» переноса условия цикла в условие удаления.
Перед тем как перейти к следующим операторам, необходимо рассказать еще об одном важном понятии, используемом в логике действий.
Сессии изменений
Как уже упоминалось ранее, действие в результате своего выполнения может изменять состояние системы, в которой оно выполняется. Записывать эти изменения сразу в базу данных не всегда желательно, как с точки зрения целостности, так и с точки зрения эргономики системы. Поэтому в платформе существует возможность накапливать эти изменения локально в так называемых сессиях изменений.
Изменениями в сессии могут быть изменения первичных свойств, а также изменения классов объектов. Первые осуществляются при помощи описанного выше оператора изменения свойства, вторые — при помощи операторов добавления / удаления объектов.
Каждый раз, когда действие выполняется, в зависимости от контекста выполнения для него определяется текущая сессия. Например, если действие вызывается как обработчик некоторого события формы (наиболее частый случай), то текущей сессией для него будет сессия этой формы.
Если действие в процессе выполнения обращается к некоторому свойству, то его значение вычисляется с учетом изменений, сделанных в текущей сессии этого действия. Так, например:
LOCAL f = INTEGER (INTEGER, INTEGER);
f(1,3) <- 6; MESSAGE GROUP CONCAT a + ',' + b + '->' + f(a, b),' ; '; // выдаст 1,3->6 ; 6,4->5 |
Для сессии поддерживаются две основные операции: применение (APPLY) и отмена (CANCEL). Тут важно не путать сессии с транзакциями, это, строго говоря, перпендикулярные понятия. Так, сессии могут существовать достаточно долгое время, накапливая изменения во временных таблицах или сервере приложений, и начинают транзакцию только при применении изменений сессий в общую БД. Как при этом поддерживается целостность – отдельная тема, но если вкратце, то сразу после старта транзакции идет проверка на возможные изменения классов значений всех существующих изменений, соответственно, некорректные изменения удаляются. Отмена сессии — это просто очистка ее от всех накопленных в ней изменений.
Создание сессий (NEWSESSION, NESTEDSESSION)
Сессии создаются автоматически в самых верхних по стеку операциях (например, вызов действия из навигатора, через http-запрос и т.п.). Однако в процессе выполнения одного действия часто возникает необходимость выполнить другое действие в новой, отличной от текущей, сессии. Обычно такая необходимость возникает, если неизвестен контекст выполнения действия, и, применяя изменения текущей сессии «вслепую», можно случайно применить «чужие» изменения (то есть те, которые не надо было применять). Для реализации такой возможности в платформе есть специальный оператор NEWSESSION, при оборачивании в который действие выполнится в новой сессии (при этом по окончании выполнения этого действия сессия автоматически закроется). Например:
run() { f(1) <- 2; APPLY; f(1) <- 1; NEWSESSION { MESSAGE f(1); // выдаст 2, то есть без учета изменений верхней сессии f(2) <- 5; APPLY; } MESSAGE f(1); // выдаст 1, то есть изменение на 1 как было, так и осталось } |
Правда, при использовании новых сессий возникает вопрос, как передавать данные между текущей и создаваемой сессиями, если это все же необходимо. Так, если параметры передаются по стеку автоматически:
run(Store s) { NEWSESSION MESSAGE 'I see that param, its name is: ' + name(s); } |
то изменения свойств, по умолчанию, никуда не передаются (смотри пример выше). Для решения этой проблемы в платформе есть специальная опция NESTED, которая позволяет при создании сессии скопировать в нее изменения заданных свойств, и, наоборот, при закрытии сессии скопировать изменения этих свойств обратно в текущую сессию. Эта опция поддерживается как непосредственно в операторах создания сессий, так и глобально для свойства (в этом случае оно работает, как если бы в каждом операторе создания сессий это свойство указывалось бы явно как NESTED). Например:
g = DATA LOCAL NESTED INTEGER (); run() { f(1) <- 1; g() <- 5; NEWSESSION NESTED (f) { MESSAGE f(1) + ' ' + g(); // выдаст 1 5 f(1) <- 5; g() <- 7; } MESSAGE f(1) + ' ' + g(); // выдаст 5 7 } |
Также в платформе поддерживается создание так называемых вложенных сессий. Для вложенной сессии:
- все изменения текущей сессии автоматически копируются в создаваемую сессию, то есть, грубо говоря, вложенная сессия < — текущая сессия
- при отмене изменений во вложенной сессии, она не очищается, а возвращается в состояние на момент создания: вложенная сессия < — текущая сессия
- при применении изменений во вложенной сессии, все ее изменения копируются обратно в текущую сессию: текущая сессия < — вложенная сессия.
Механизм вложенных сессий очень удобен, когда нужно организовать ввод большого количества информации (возможно, состоящий из нескольких этапов), но при этом надо либо применить все изменения в конце одновременно, либо не применить вообще ничего. Так, например, если нужно ввести какой-нибудь большой документ, в процессе ввода которого, в свою очередь, необходимо иметь возможность вводить товар, если его нет, но при этом чтобы:
- пользователь мог отменить ввод этого товара и продолжить ввод документа
- если пользователь отменит ввод всего документа, ввод этого товара также должен быть отменен
Применение изменений (APPLY), Отмена изменений (CANCEL)
Применение и отмена изменений – операции, для которых сессии собственно и создавались. В описании сессий они уже были упомянуты, и их семантика следует из их названия. Единственное, что стоит отметить:
- При применении и отмене изменений все изменения локальных первичных свойств удаляются. Иногда такое поведение нежелательно, поэтому, как и для создания сессий, для этих операторов поддерживается опция NESTED (с аналогичным поведением).
- При применении изменений есть возможность указать дополнительное действие, которое будет выполнено сразу после начала транзакции. Главное отличие выполнения этого дополнительного действия внутри транзакции от его выполнения сразу перед применением изменения заключается в том, что если применение по какой-либо причине будет отменено, то и изменения, сделанные в этом дополнительном действии, также будут отменены. Более того, если причиной отмены применения был конфликт записи (update conflict), а значит, применение будет автоматически выполнено еще раз, то в этом случае указанное дополнительное действие также будет выполнено еще раз. К примеру, такое поведение можно использовать для реализации долгосрочной пессимистичной блокировки:
// -------------------------- Object locks ---------------------------- // locked = DATA User (Object); lockResult = DATA LOCAL NESTED BOOLEAN (); lock(Object object) { NEWSESSION { lockResult() < - NULL; APPLY SERIALIZABLE { IF locked(object) THEN { CANCEL; } ELSE { locked(object) <- currentUser(); lockResult() <- TRUE; } } } } unlock(Object object) { NEWSESSION APPLY locked(object) <- NULL; } |
PS: приведенные выше свойства и действия уже объявлены в системном модуле Authentication, поэтому, при необходимости, можно (и рекомендуется) использовать именно их (тут они приведены только в качестве примера). Хотя, вообще говоря, пессимистичные блокировки в lsFusion в принципе не рекомендуется использовать, так как платформа сама автоматически отлично разруливает абсолютное большинство ситуаций конкурентного доступа (например, одновременное редактирование одного документа).
Следующий набор операторов – это операторы создания свойств, а не действий, но они по своей природе ближе к логике изменений, а не вычислений, поэтому описываются тут (а не в свойствах).
Операторы работы с изменениями (PREV, CHANGED, SET, DROPPED)
Для сессии поддерживается набор операторов работы с изменениями: получение предыдущего значения в сессии (PREV), определение изменилось ли значение свойства в сессии (CHANGED), изменилось ли оно с NULL на не NULL значение (SET) и т.п. Вообще эти операторы в основном используются в логике событий (о них чуть позже), но при необходимости их можно применять внутри действий, вызываемых откуда угодно, например:
f = DATA INTEGER (INTEGER); run() { f(1) <- 2; APPLY; f(1) <- 5; f(2) <- 3; MESSAGE GROUP SUM 1 IF CHANGED(f(a)); // определяет, для скольких значений f были изменения в этой сессии, выдаст 2 MESSAGE 'Тек. значение: ' + f(1) + ', Пред. значение: ' + PREV(f(1)); // выдаст Тек. значение: 5, Пред. значение: 2 } |
На этом с операторами создания действий закончим. Не потому что они закончились, просто, как и со свойствами, остальные либо очень редко используются, либо тесно связаны с понятиями других уровней абстракции языка и будут рассмотрены там.
События
Действия отвечают на вопрос “Что делать?”, но не отвечают на вопрос “Когда это делать?”. Для определения моментов, когда нужно выполнять те или иные действия, в платформе существуют события.
Сразу оговорюсь, дальше речь пойдет о событиях предметной области, помимо них в логике представления также существуют события формы. Это два совершенно не связанных механизма, и на событиях формы мы подробнее остановимся в статье про логику представления. Но в дальнейшем события без уточнения их вида будем считать событиями предметной области.
События предметной области бывают двух типов:
- Синхронные – происходят непосредственно после изменения данных.
- Асинхронные – происходят в произвольные моменты времени по мере того, как сервер успевает выполнить все заданные обработки и / или по истечению некоторого периода времени.
В свою очередь, с точки зрения области видимости изменений, события можно разделить на:
- Локальные – происходят локально для каждой сессии изменений.
- Глобальные – происходят глобально для всей базы данных.
Таким образом, события могут быть синхронными локальными, синхронными глобальными, асинхронными локальными и асинхронными глобальными.
Преимущества синхронных событий:
- При необходимости в обработках можно выполнять отмену изменений, если, например, эти изменения не удовлетворяют необходимым условиям.
- Они гарантируют большую целостность, так как после окончания записи изменений пользователь гарантированно будет работать уже с обновленными данными.
Преимущества асинхронных событий:
- Можно сразу отпустить пользователя, а обработки выполнять «на фоне». Это улучшает эргономику системы, правда, возможно, только когда обновление данных не критично для дальнейшей работы пользователя (для глобальных событий, например, в течение ближайших 5-10 минут, пока сервер не успеет выполнить очередной цикл обработок).
- Обработки группируются для большого количества изменений, в том числе сделанных различными пользователями (в случае глобальных событий), и, соответственно, выполняются меньшее число раз, тем самым улучшая общую производительность системы.
Преимущества локальных событий:
- Пользователь видит результаты обработок событий сразу, а не только после того, как он сохранил их в общую базу.
Преимущества глобальных событий:
- Обеспечивают лучшую производительность и целостность, как за счет того, что обработки выполняются только после сохранения изменений в общую базу (то есть существенно реже), так и за счет использования многочисленных возможностей СУБД, связанных с работой с транзакциями.
Пока в платформе поддерживаются только синхронные глобальные и асинхронные локальные (как самые часто используемые, поддержка остальных видов событий также планируется в будущем), поэтому дальше будем говорить просто о глобальных и локальных событиях.
ON { // по умолчанию глобальное, то есть будет выполняться при каждом APPLY MESSAGE 'Something changed'; } |
Впрочем, так, как написано выше, на практике лучше не делать, так как сообщение ‘Something changed’ будет выдаваться при любом (!) применении изменений (независимо от того, что изменилось в данной сессии). Как правило же, в событиях нужно проверять, что изменилось что-то конкретное, и тут на помощь приходят операторы работы с изменениями (CHANGED, SET, DROPPED и т.п.). Более того, на практике большинство событий сводятся к простой причинно-следственной связи, когда изменилось что-то, нужно сделать то-то. Для реализации этого сценария в платформе существует специальный вид событий – простые события:
// отправить email, когда остаток в результате применения изменений сессии стал меньше нуля WHEN SET(balance(Sku s, Stock st) < 0) DO EMAIL SUBJECT 'Остаток стал отрицательным по товару ' + name(s) + ' на складе ' + name(st); WHEN LOCAL CHANGED(customer(Order o)) AND name(customer(o)) == 'Best customer' DO |
На самом деле, простые события – это не более, чем синтаксический сахар. Так, первое событие эквивалентно:
ON { FOR SET(balance(Sku s, Stock st) < 0) DO EMAIL SUBJECT 'Остаток стал отрицательным по товару ' + name(s) + ' на складе ' + name(st); } |
Но так как используя простые события, выстрелить себе в ногу значительно сложнее, да и писать / читать их разработчику проще, по умолчанию рекомендуется использовать именно простые события, а обычные события использовать только для оптимизации выполнения действительно сложных случаев.
Некоторым аналогом простых событий в SQL (а точнее его расширениях) являются триггеры. Впрочем, триггеры ограничены одной таблицей, работают целиком для записи, выполняются для каждой записи таблицы отдельно (то есть не умеют выполняться одним запросом) ну и много чего еще, и для реализации простых событий никак платформой не используются.
Важно отметить, что внутри обработки событий предметной области изменяется поведение некоторых операторов:
- Отмена изменений – отменяет применение изменений, а не очищает сессию (этот оператор можно использовать только внутри синхронных событий)
- Операторы работы с изменениями – возвращают значение на момент окончания обработки предыдущего события, а не текущее значение в базе. Впрочем, для глобальных событий эти значения совпадают, плюс при помощи специальной опции можно «вернуть» эти операторы в стандартный режим и возвращать текущее значение в базе.
Ограничения
Ограничения в платформе определяют, какие значения могут иметь первичные свойства, а какие нет. В общем случае, ограничение задается как свойство, значение которого всегда должно быть NULL:
// остаток не меньше 0 CONSTRAINT balance(Sku s, Stock st) < 0 MESSAGE 'Остаток не может быть отрицательным'; // «эмуляция» политики безопасности // в заказе можно выбирать только товары, доступные данному покупателю |
Фактически, ограничение – это простое событие, в котором условием является изменение на не NULL (SET) значения ограничиваемого свойства, а обработкой – показ всех его не NULL значений и отмена сделанных изменений. То есть ограничение – это не более, чем синтаксический сахар, но так же как и простые события, ограничения проще читать / писать, поэтому по возможности рекомендуется использовать именно их.
Так же как и для событий, для ограничений есть специальный подвид – простые ограничения (синтаксический сахар для наиболее распространенных случаев ограничений), но как показала практика, кроме ограничения, что заданное свойство должно быть задано (тут все более менее очевидно), простые ограничения используются очень редко, поэтому подробно останавливаться на них не будем.
Классы
Ну вот мы и подошли к классам. Обычно с них принято начинать, но, строго говоря, логически классы – это не более чем один из видов ограничений. Так, например:
f = DATA A (INTEGER); |
обозначает, что если f имеет не NULL значение, то это значение должно быть класса A. То есть верхний пример эквивалентен:
f = Object (INTEGER); CONSTRAINT f(i) AND NOT f(i) IS A MESSAGE 'Неправильный класс'; // f(i) => f(i) IS A |
В то же время, если логически классы находятся на верхнем уровне стека, то физически – все с точностью наоборот. Классы не являются синтаксическим сахаром (то есть реализуются не через ограничения, как в примере выше, а «нативно»), соответственно, работа с ними очень хорошо оптимизируется, а значит, общий принцип таков: если какую-то задачу можно решить при помощи классов, ее лучше решить при помощи классов.
Вообще, концепция классов в lsFusion не сильно отличается от аналогичной в ООП. Правда, в отличие от ООП в lsFusion нет инкапсуляции. Во всяком случае, пока. Но даже если инкапсуляция в lsFusion и появится, то только в виде синтаксического сахара, что-то вроде:
CLASS A { f = DATA LONG (INTEGER); // эквивалентно f = DATA LONG (A, INTEGER) } |
Так же как и в ООП в lsFusion поддерживается наследование классов, в том числе множественное:
CLASS Animal; CLASS Transport; CLASS Car : Transport; CLASS Horse : Transport, Animal; |
Наследование само по себе не сильно полезно, основное его предназначение – это использование в механизмах полиморфизма.
Полиморфизм
В текущей версии lsFusion полиморфизм – явный. Для его реализации сначала объявляется абстрактное свойство или действие для некоторого, возможно абстрактного, класса:
speed = ABSTRACT LONG (Transport); |
Затем при появлении конкретного класса для него можно / нужно задать реализацию объявленного абстрактного свойства, например:
CLASS Breed; speed = DATA LONG (Breed) breed = DATA Breed (Animal); speed(Horse h) += speed(breed(h)); // для лошади скорость берем из ее породы |
Полиморфизм поддерживается также для нескольких параметров (так называемый множественный полиморфизм):
CLASS Thing; CLASS Ship : Thing; CLASS Asteroid : Thing; collide ABSTRACT (Thing, Thing); |
Полиморфизм, строго говоря, относится к физической модели (процессу разработки), а не логической. Так сервер сразу после парсинга превращает оператор ABSTRACT в оператор выбора:
speed(Transport t) = CASE WHEN t IS Horse THEN speed(breed(t)) // другие реализации END |
но как уже говорилось без полиморфизма наследование имеет мало смысла, поэтому мы немного забежали вперед.
В будущем планируется, что кроме явного полиморфизма в языке будет поддерживаться и неявный полиморфизм, то есть:
speed(Horse h) = speed(breed(h)); |
будет одновременно и создавать свойство для лошади, и добавлять реализации во все абстрактные свойства с тем же именем, которые подходят по классам (как это делается в большинстве современных языков). Более того, для этого в платформе уже есть вся необходимая инфраструктура, но по различным причинам этот функционал решили все же не включать в первую публичную версию платформы.
Встроенные классы
Выше речь шла только о пользовательских классах, то есть классах, которые создают разработчики. Вместе с тем в платформе также поддерживаются так называемые встроенные (примитивные) классы: числа, строки, даты и так далее. Ничего сильно особенного по сравнению с другими языками в них нет, нужно, правда, учитывать, что в текущей реализации их нельзя смешивать ни друг с другом, ни с пользовательскими классами. То есть свойство не может возвращать не NULL значение одновременно и для некоторого числа, и некоторого объекта, то есть вот так делать нельзя:
f = DATA LONG (LONG); g = DATA LONG (A); h(a) = OVERRIDE f(a), g(a); // платформа выдаст ошибку |
Статические объекты
Статические (или встроенные) объекты – объекты, которые создаются при старте сервера и которые нельзя удалить. Кроме того, к статическим объектам можно обращаться, как к константам, прямо в языке:
CLASS Direction 'Направление' { left 'Налево', right 'Направо', forward 'Прямо' } result(dir) = CASE |
В остальном статические объекты ничем не отличаются от других объектов, созданных пользователем.
Аналогом статических объектов в современных языках программирования являются enum’ы, соответственно, обычно статические объекты используются ровно для тех же целей.
Агрегации
У механизма классов (как в lsFusion, так и в других языках) есть как минимум три ограничения:
- Принадлежность классу не может вычисляться (только задаваться явно при добавлении и изменении класса объекта).
- Класс определяется только для одного объекта (а не для набора объектов).
- Невозможно несколько раз наследовать один и тот же класс.
Для обхода этих ограничений в платформе есть механизм так называемых агрегаций.
Под агрегацией понимается создание уникального (агрегируемого) объекта, соответствующего каждому не NULL значению некоторого агрегируемого свойства. Для такого объекта предполагается наличие свойств, которые отображают этот объект на каждый из параметров агрегируемого свойства, и наличие свойства, которое, наоборот, отображает параметры агрегируемого свойства на этот объект.
Например:
// для каждого A создается объект класса B b(A a) = AGGR B WHERE a IS A; // также неявно создается свойство a с одним параметром класса B и значением класса A, при этом b(a(b)) = b createC = DATA BOOLEAN (A, B) |
Теперь возьмем более жизненный пример, и покажем, как агрегации можно использовать вместе с наследованием и полиморфизмом (что на практике и делается в абсолютном большинстве случаев):
CLASS Shipment 'Поставка'; date = ABSTRACT DATE (Shipment); CLASS Invoice 'Инвойс'; createShipment 'Создавать поставку' = DATA BOOLEAN (Invoice); date 'Дата накладной' = DATA DATE (Invoice); CLASS ShipmentInvoice 'Поставка по инвойсу' : Shipment; // создаем поставку по инвойсу, если для инвойса задана опция создавать поставку shipment(Invoice invoice) = AGGR ShipmentInvoce WHERE createShipment(invoice); date(ShipmentInvoice si) += sum(date(invoice(si)),1); // дата поставки = дата инвойса + 1 |
Вообще, эти три механизма (агрегации, наследование и полиморфизм), а также события и расширения (о расширениях позже в статье о физической модели) позволяют достичь если не идеальной, то очень близкой к ней модульности. К примеру, сейчас ERP состоит из приблизительно 1100 модулей. Соответственно, из них можно выбрать любое подмножество модулей и собрать из этого подмножества решение, в котором будет ровно то, что нужно заказчику. Так, у нас у некоторых заказчиков работает только непродовольственная розница (около 50 модулей), у некоторых только производство и опт, а у некоторых практически все 1100 плюс еще 300 своих.
На этом с логикой предметной области закончим, это конечно далеко не все, но и так, возможно, слишком много для одной статьи. Впрочем, скоро будет еще минимум две статьи с описанием возможностей языка, одна – про логику представления, вторая – про физическую модель, и там, к сожалению или к счастью, будет тяжело обойтись фразами «тут все более-менее стандартно», так что, как говорится, не уходите далеко от своих экранов.
Заключение
Конечно, в противопоставлении lsFusion языкам общего назначения во вступлении есть определенная доля лукавства. Да, классы, агрегации, ограничения, события и остальные абстракции языка, по большому счету, действительно не принадлежат никакой конкретной предметной области, и в том или ином виде существуют, в том числе, в системном программировании (то есть, к примеру, при разработке условных ОС или СУБД). Но реализовать виртуальную машину, поддерживающую всю спецификацию lsFusion (даже без ACID), которая не будет настолько тяжеловесной, как современные SQL-сервера, будет очень тяжело. Как следствие, избавиться от ярлыка DSL lsFusion вряд ли удастся, а значит, и рассчитывать на благосклонность большинства системных программистов – основных потребителей языков общего назначения – вряд ли приходится. Строго говоря, и SQL большинство из них недолюбливают, слишком уж там много магии под капотом, а в lsFusion этой магии еще больше. Мы, конечно, по максимуму будем пытаться сгладить этот эффект – свободная лицензия, исходники на github (как самой платформы, так и всей ее инфраструктуры), максимальное использование существующих экосистем (IDE, отчетности, VCS, автоматических сборок), slack и telegram-каналы общения, наличие в общедоступных репозиториях (linux и maven, опять-таки с исходниками), ну и, в принципе, общая открытость во взаимодействии с разработчиками, но будем реалистами, если среднестатистический системный программист будет просто не любить lsFusion меньше, чем SQL, ABAP и 1С – это уже успех.
С другой стороны, понятно, что в ближайшее время основным рынком lsFusion, будет не системное, а прикладное программирование (уже упомянутая разработка ИС), и тут сейчас есть пять основных игроков: ERP-платформы, SQL-сервера с процедурными расширениями, ORM-фреймворки, RAD-фреймворки, и просто электронные таблицы. Первый, четвертый и пятый типы платформ имеют пользовательский интерфейс в комплекте, во втором и третьем для этого используются сторонние технологии.
У каждого из этих пяти типов платформ есть своя ниша, где они в основном обитают:
- SQL-сервера с процедурными расширениями – бизнес-приложения с относительно сложной логикой и большими объемами данных – это, как правило, ритейл и банки.
- ERP-платформы – остальные бизнес-приложения со сложной логикой – оптовая торговля, производство, финансы и т.п.
- ORM-фреймворки – веб-приложения (сервисы, порталы), ну и очень высоконагруженные приложения с относительно несложной логикой.
- RAD – узкоспециализированные низконагруженные бизнес-приложения с простой логикой, там где, как правило, сильно ограничен IT-бюджет.
- Электронные таблицы – используются там же, где и RAD, правда, из-за низкого порога вхождения их можно встретить везде, где только можно, начиная от крупных корпораций и заканчивая полной автоматизацией малого бизнеса чисто на Excel (да, такое тоже встречается, и даже не знаю, какие ощущения это больше вызывает – восторг или страх).
На мой сугубо субъективный взгляд, в глобальной перспективе lsFusion под силу полностью заменить ERP, RAD и SQL платформы, которые lsFusion превосходит по всем нефункциональным требованиям (а по многим из них превосходит на порядок). Правда, что касается SQL, тут речь скорее идет не о замене, а о надстройке, то есть так же, как, скажем, Fortran и C пришли на смену ассемблеру (на ассемблере по прежнему можно писать, но непонятно зачем). С ORM-фреймворками очевидно будет тяжело конкурировать в предельных гибкости и масштабируемости, а с электронными таблицами – с порогом вхождения в очень простых задачах и в работе с неструктурированными данными. Хотя все же, возможно, какую-то часть рынка удастся отвоевать и у них.
Ну и в среднесрочной перспективе фокус в основном будет сделан на SME (у которых ограничены человеческие ресурсы и бюджеты на ИТ, но при этом большие потребности в гибкости и эргономике используемых решений), а также на нестандартные задачи (где мало готовых решений и их кастомизация по объему превосходит сами эти решения). То есть занять ту нишу, которую сейчас занимает 1С в России, но только сделать это в мировом масштабе.
Это все, конечно, звучит чересчур амбициозно, но после того пути, который уже удалось пройти, чтобы просто заставить всю эту технологию работать (а на это ушло без малого 12 лет), такая задача уже не кажется настолько невыполнимой.
Автор: NitroJunkie