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

В этой публикации я хочу поговорить о некоторых популярных языках и технологиях, включающих элементы декларативного программирования — PL/SQL, MS LINQ и GraphQL. Попытаюсь разобраться, какие задачи в них решаются с помощью декларативного программирования, насколько тесно переплетены декларативный и императивный подходы, какие это дает преимущества, и какие идеи можно из них почерпнуть.

Процедурные расширения языка SQL


Начнем с области, в которой это объединение давно стало промышленным стандартом — с языков доступа к данным. Наиболее известным из них является PL/SQL — процедурное расширение языка SQL. Этот язык позволяет обрабатывать данные в реляционной базе данных, используя как императивный (переменные, операторы управления, функции, объекты) так и декларативный стили программирования (SQL-выражения). С помощью SQL запроса мы можем описать какими свойствами обладают требуемые нам данные — какие поля нужны, из каких таблиц их брать, как они между собой связаны, каким ограничениям они должны соответствовать, как должны быть агрегированы и т.д. А сервер базы данных самостоятельно составит план выполнения запроса и найдет все возможные наборы полей, которые соответствуют заданным условиям. Процедурная часть языка PL/SQL позволяет реализовать те задачи, которые тяжело или невозможно выразить в декларативном виде — обработать результат выполнения запроса в цикле, выполнить произвольные вычисления, структурировать код в функциях и классах.

Процедурный и декларативный компоненты языка тесно интегрированы. PL/SQL позволяет объявлять функции, выполнять внутри них запросы и возвращать их результат, использовать функции внутри запроса, передавая им значения полей таблицы в качестве аргументов. Можно получить доступ к результатам выполнения запроса с помощью курсоров и затем организовать императивный цикл по всем полученным записям. Курсоры дают больше контроля над содержимым таблиц и позволяют реализовать значительно более сложную логику обработки данных, чем с помощью только SQL. Курсор может быть присвоен курсорной переменной и передан в качестве аргумента функциям, процедурам или даже в клиентское приложение. Сам код запроса может быть сформирован динамически последовательностью императивных команд. Комбинация процедур и запросов с помощью некоторых ухищрений позволяет реализовать рекурсивные запросы. Есть даже объектно-ориентированные возможности в PL/SQL, позволяющие объявлять композитные типы данных для полей таблиц, включать в них методы и создавать классы путем наследования.

PL/SQL позволяет реализовать бизнес-логику на стороне сервера БД. Причем реализация модели предметной области будет довольно близка к ее описанию. Основные понятия модели предметной области будут отображены на реляционную модель данных. Понятиям будут соответствовать таблицы, атрибутам — их поля. В описания таблиц можно встроить ограничения на значения полей. А взаимосвязи с другими таблицами задать с помощью внешних ключей. Абстрактным понятиям, конструируемым на основе базовых, будут соответствовать представления (view). Их можно использовать в запросах наравне с таблицами, в том числе для построения других представлений. Представления строятся на основе запросов, что позволяет задействовать всю мощь и гибкость SQL. Таким образом, из таблиц и представлений можно построить довольно сложную и многоуровневую модель предметной области полностью в декларативном стиле. А все, что плохо вписывается в декларативный стиль, — реализовать с помощью процедур и функций.

Главная проблема заключается в том, что код PL/SQL исполняется исключительно на стороне сервера БД. Это создает сложности при масштабировании такого решения. Кроме того, полученная модель будет жестко привязана к реляционной базе данных и включить в нее данные из других источников будет проблематично.

Language Integrated Query


Language Integrated Query (LINQ) – это популярный компонент платформы .NET, позволяющий естественным образом включать выражения SQL запросов в код программы на основном объектно-ориентированном языке. В противоположность PL/SQL, который добавляет императивную парадигму к языку SQL на стороне сервера баз данных, LINQ выносит SQL на уровень приложения. Благодаря этому запросы в LINQ могут применяться для получения данных не только из реляционных баз данных, но и из коллекций объектов, документов XML, других LINQ запросов.

Архитектура LINQ довольно гибкая, а определения запросов глубоко интегрированы с ООП моделью. LINQ позволяет создавать свои провайдеры для доступа к новым источникам данных. Так же можно задать свой способ выполнения запроса и, например, преобразовать дерево выражений LINQ запроса в запрос к нужному источнику данных. В тексте запроса можно использовать лямбда-выражения и функции, определенные в коде приложения. Правда, в случае LINQ to SQL запрос будет выполнен на стороне сервера баз данных, где эти функции будут недоступны, но зато вместо них можно использовать хранимые процедуры. Запрос является сущностью языка первого уровня, с ним можно работать как с обычным объектом. Компилятор способен автоматически вывести тип результата запроса и сгенерировать соответствующий класс, даже если он не был объявлен в явном виде.

Попробуем с помощью LINQ построить модель предметной области в виде набора запросов. Исходные факты можно поместить в списки на стороне приложения или в таблицы на стороне БД, а абстрактные понятия оформим в виде LINQ запросов. LINQ позволяет строить запросы на основе других запросов, указав их в секции FROM. Э то позволяет сконструировать новое понятие на основе существующих Поля в секции SELECT будут соответствовать атрибутам понятия. А секция WHERE будет содержать зависимости между понятиями. Пример со счетами из прошлой публикации будет выглядеть следующим образом.

Объекты со счетами и информацией о клиентах поместим в списки:

List<Bill> bills = new List<Bill>() { ... };
List<Client> clients = new List<Client>() { ... };

А затем построим для них запросы для получения неоплаченных счетов и должников:

IEnumerable<Bill> unpaidBillsQuery =
from bill in bills
where bill.AmountToPay > bill.AmountPaid 
select bill;
IEnumerable<Client> debtorsQuery =
from bill in unpaidBillsQuery 
join client in clients on bill.ClientId equals client.ClientId
select client;

Модель предметной области, реализованная с помощью LINQ, приняла довольно причудливую форму — задействовано сразу несколько стилей программирования. Верхний уровень модели имеет императивную семантику. Ее можно представить в виде цепочек преобразований объектов, построения коллекций объектов поверх коллекций. Объекты запросов являются элементами мира ООП. Их нужно создавать, присваивать переменным, передавать в другие запросы ссылки на них. На среднем уровне объект запроса реализует процедуру выполнения запроса, которая в функциональном стиле кастомизируется лямбда-выражениями, позволяющими сформировать структуру результата в секции SELECT и отфильтровать записи в секции WHERE. Внутренний уровень представлен процедурой выполнения запроса, которая имеет логическую семантику и основана на реляционной алгебре.

Хоть LINQ и позволил описать модель предметной области, но все-таки синтаксис SQL нацелен в первую очередь на выборку и манипулирование данными. Ему не хватает некоторых конструкций, которые были бы полезными при моделировании. Если в PL/SQL структура базовых понятий была очень наглядно представлена в виде таблиц и представлений, то в LINQ она оказалась вынесенной в ООП код. Кроме того, если к таблицам и представлениям можно было обращаться по имени, то к запросам LINQ — по ссылке в императивном стиле. Кроме того, SQL ограничен рамками реляционной модели и имеет ограниченные возможностями при работе со структурами в форме графов или деревьев.

Параллели между реляционной моделью и логическим программированием


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

Данное сходство не случайно. Хоть теоретическая основа SQL – реляционная алгебра разрабатывалась параллельно с логическим программированием, но позже между ними была выявлена теоретическая связь. Они имеют общую математическую основу — логику первого порядка. Реляционная модель данных описывает правила построения отношений между таблицами данных, логическое программирование — между высказываниями. Обе теории используют разные термины, применяются в разных областях, разрабатывались параллельно, но математическая основа у них оказалась общая.

Строго говоря, реляционное исчисление является адаптацией логики первого порядка для работы с табличными данными. Более подробно этот вопрос разобран здесь. Т. е. любое выражение реляционной алгебры (любой SQL запрос) можно переформулировать в выражение логики первого порядка, а затем реализовать на Prolog. Но не наоборот. Реляционное исчисление является подмножеством логики первого порядка. Это значит, что для некоторых видов высказываний, допустимых в логике первого порядка, мы не можем подобрать аналогии в реляционной алгебре. Например, возможности рекурсивных запросов в SQL очень ограничены, построение транзитивных отношений тоже не всегда доступно. Такие операции из мира Prolog как дизъюнкция целей и отрицание как отказ реализовать с помощью SQL гораздо сложнее. Гибкий синтаксис Prolog дает больше возможностей для работы со сложными вложенными структурами и поддерживает операции «сопоставления по образцу» (pattern matching) над ними. Это делает его удобным при работе с такими сложными структурами данных как деревья и графы.

Но за все нужно платить. Встроенные алгоритмы выполнения запросов в реляционных СУБД проще и менее универсальны по сравнению с алгоритмами логического вывода в Prolog. Это дает возможность оптимизировать их и добиться гораздо большей производительности. Prolog не способен также быстро обработать миллионы строк в реляционных базах данных. Кроме того, алгоритм логического вывода в Prolog вообще не гарантирует окончания выполнения программы — вывод некоторых утверждений может привести к бесконечной рекурсии.

Кстати, на пересечении баз данных и логического программирования существует еще такая технология как дедуктивные базы данных и язык правил и запросов к ним Datalog. Вместо записей в таблицах дедуктивные базы данных хранят большие объемы фактов и правил в логическом стиле. А Datalog выглядит как Prolog, но ориентирован на работу с фактами, объединенными в множества, а не с единичными фактами. Кроме того, некоторые возможности логики первого порядка в нем были урезаны ради оптимизации алгоритма логического вывода под быструю работу с большими объемами данных. Так что менее выразительный синтаксис логического языка тоже имеет свои преимущества.

Декларативный подход при описании слоя API


SQL привязывает построение модели к слою доступа к данным. Но декларативное программирование активно развивается и на противоположном конце приложения — в слое API. Его особенностью является то, что информация о структуре запросов должна быть доступна тем, кто использует этот API. Наличие формального описания структуры запросов и ответов — это правило хорошего тона. Соответственно, возникает желание синхронизировать это описание с кодом приложения, например, сгенерировать на его основе классы запросов и ответов. В которые затем нужно будет вписать логику обработки запросов.

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

  1. описать типы данных (объекты) приложения, которые входят в состав запросов и ответов;
  2. описать структуру запросов и ответов;
  3. реализовать функции, реализующие логику создания объектов получения значений их полей.

Типы данных представляют собой описания полей объектов. Поддерживаются такие типы как скалярные типы, списки, перечисления, а также ссылки на вложенные типы. Поскольку поля типов могут содержать ссылки на другие типы, то всю схему данных можно представить в виде графа. Запрос представляет собой описание структуры данных, запрашиваемой у API. Описание запроса включает список требуемых объектов, их полей а также входных атрибутов. Каждый тип данных и каждое его поле должны быть связаны с функцией — резолвером. Резолвер типа (объекта) описывает алгоритм получения его объектов, резолвер поля — значения поля объекта. Они представляют собой функции на одном из функциональных или объектно-ориентированных языков. Среда исполнения GraphQL получает запрос, определяет требуемые типы данных, вызвает их резолверы, в том числе по цепочке вложенных объектов, собирает объект ответа.

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

Таким образом, язык GraphQL позволяет выразить модель предметной области довольно ясным образом, выделить ее из всего остального кода, сблизить модель и ее реализацию. К сожалению, декларативная компонента языка ограничена только описанием композиции типов данных, все остальные отношения между элементами модели приходится реализовывать с помощью резолверов. С одной стороны, резолверы позволяют разработчику самостоятельно реализовать любой способ получения данных для объекта и любые отношения между ними. Но, с другой стороны, придется постараться, чтобы реализовать более сложные варианты запросов, чем, например, доступ к записи по ключу. С одной стороны, схема данных в GraphQL наглядно показывает взаимосвязь слоя API и слоя доступа к данным. Но, с другой стороны, ведущим слоем, к которому привязывается схема данных, является слой API. Содержимое схемы данных подстраивается под него, она не будет содержать сущностей, которые не участвуют в обработке запросов. Хоть выразительная сила языка описания данных GraphQL уступает таким полноценным декларативным языкам как SQL и Prolog, но популярность этого фреймворка показывает, что инструменты для декларативного описания модели могут и должны быть частью современных языков программирования.

Подведу итоги


PL/SQL – это язык, который удобен как для описания модели предметной области в виде таблиц и представлений, так и логики работы с ней. Декларативный и процедурный компоненты тесно интегрированы и дополняют друг друга. Главная проблема заключается в том, что этот язык тесно привязан к месту хранения данных, может выполняться только на стороне сервера БД, а логика выполнения запросов ограничена реляционной моделью данных.

На стороне приложения для описания модели в декларативном виде можно воспользоваться такими технологиями как LINQ и GraphQL. С помощью схемы данных GraphQL можно ясно и очень наглядно описать структуру модели предметной области, вложенность ее понятий. А среда выполнения способна автоматически собрать требуемые объекты. К сожалению, все остальные отношения и связи между понятиями кроме их вложенности приходится реализовывать в слое функций-резолверов. LINQ обладает противоположными достоинствами и недостатками. Гибкий синтаксис SQL дает более широкие возможности для описания взаимосвязей между понятиями. Но за пределами запроса декларативность заканчивается, объекты запросов являются элементами ООП мира. Их нужно создавать, присваивать переменным и использовать в императивном стиле.

Хотелось бы совместить достоинства и LINQ, и GraphQL. Чтобы описание структуры понятий было наглядным как в GraphQL, а взаимосвязи между ними можно было задать на основе логики как в SQL. И чтобы определения понятий были доступны непосредственно по имени как классы, без необходимости в явном виде создавать их объекты, присваивать их переменным, передавать ссылки на них и т. п.

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

Для тех, кто не хочет ждать выхода всех публикаций на Хабре, есть полный текст в научном стиле на английском языке, доступный по ссылке: Hybrid Ontology-Oriented Programming for Semi-Structured Data Processing.

Ссылки на предыдущие публикации:
Проектируем мульти-парадигменный язык программирования. Часть 1 — Для чего он нужен?