Рассматриваются встроенные в Python возможности декларативного программирования и их развитие в библиотеках SQLAlchemy, NumPy, Pandas. Показывается применение трех видов декларативного программирования с помощью библиотеки DecPy: аналогов SQL, QBE и Prolog. В том числе приводятся рекурсивные запросы.

Краткое содержание:

  1. Введение: чем декларативное программирование отличается от императивного, исторический обзор.

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

  3. Развитие языковых конструкций Python в библиотеках  SQLAlchemy, NumPy, Pandas: ленивые выражения вместо анонимных функций, условия в операторе индексации, многомерная индексация.

  4. Виды встраивания микроязыков в Python: интерпретатор строк; встраивание на уровне библиотечных классов и их методов; глубинная интеграция – перепрограммирование поведения операторов Python.

  5. Библиотека DecPy как глубинная интеграция декларативного и логического программирования в Python – шаг вперед в эволюции языка. Аналоги запросов SQL, QBE и Prolog. Рекурсивные запросы.

Декларативное и императивное программирование

Когда говорят о декларативном программировании, обычно вспоминают самый популярный язык запросов к базам данных – SQL, а точнее, оператор запросов select, с помощью которого делаются выборки из таблиц баз данных. Например:

select sum(L) from Table where L>50;

Здесь в таблице Table имеется колонка L. Из таблицы выбираются те значения колонки, которые больше 50, и считается их сумма.

Смысл запроса интуитивно понятен, и, по идее, пользователь базы данных может и не быть программистом, чтобы делать выборки из баз данных. Кстати, название предка SQL, языка SEQUEL,появившегося в 1974г., расшифровывается как Structure English Query Language – язык структурированных запросов на английском языке, что намекает на то, что не надо быть программистом, чтобы писать запросы, достаточно знать английский язык.

С появлением языков запросов к базам данных (и еще языка искусственного интеллекта Prolog,  созданного в 1972, о котором речь пойдет ниже) языки программирования разделились на две группы – императивные и декларативные, у каждой из которых есть много разновидностей. Языки общего назначения (например, Python, Java, C#) в основе своей являются императивными, поддерживающими множество стилей (программисты говорят еще - парадигм) программирования. Приведем код, делающий эту же самую выборку, на языке Python в императивном стиле и посмотрим, в чем разница между императивным и декларативным программированием:

Table=[10,30,40,60,80]
s=0
for t in Table:
    if t>50:
        s=s+t
print(s)

Результат:

140

Императивное программирование – это программирование в повелительном наклонении (если использовать термины лингвистики). Оно состоит из команд-приказов, что нужно сделать. В нашем случае: «Перебери список Table, проверь значение его элемента, чтобы оно было больше 50. Если условие выполняется, то прибавь это значение к переменной s». Приведенная программа на Python – это разновидность императивного программирования, которая называется структурным программированием. Код в структурном стиле состоит из структурных блоков, относящихся к циклам и условиям, вложенным внутрь друг друга (не путайте с языковой конструкцией struct из С++, перекочевавшей также в C#).

Декларативное программирование – это программирование в изъявительном наклонении. В нем мы не пишем последовательность шагов, как мы хотим получить результат. Мы просто описываем результат – что мы хотим получить. Посмотрим еще раз на запрос SQL:

select sum(L) from Table where L>50;

В нашем примере приказ только один – select (выбери). А далее мы пишем, что хотим получить сумму элементов, больших 50.

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

Языки общего назначения продолжали развиваться, и они стали мультипарадигменными – на них программист может писать программы, являющиеся сочетанием нескольких стилей, наиболее распространенными из которых являются: структурное, процедурное, функциональное, объектно-ориентированное, а теперь и аспектно-ориентированное программирование. Так в 1979 язык Си был дополнен классами. В нем программировать стало возможным в структурном, процедурном и объектно-ориентированном стилях. В 1994 году появился Python, в котором были реализованы анонимные функции. Так открылась эра функционального программирования в языках общего назначения (анонимные функции были добавлены в C# в 2007, а в Java в 2014). Аспектно-ориентированное программирование появилось в Python в 2008 вместе с языковой конструкцией декоратор.

В 1990-х начался синтез баз данных и языков высокого уровня: возникла потребность в том, чтобы программы обращались к базам данных, делали из них выборки данных для дальнейшей обработки уже внутри самих программ. Вполне естественно хотеть делать выборки в декларативном стиле, например, на языке SQL. Первоначально запросы в программах делались как текстовые строки, которые передавались СУБД для исполнения. Но ведь возможны и запросы к запросам! Кроме того, данные в самой программе хранятся в виде разнообразных коллекций, к которым хотелось бы делать запросы в декларативном стиле программирования. И такая возможность появилась в языке C# в 2007г. виде технологии  LINQ, запросы которой очень напоминают язык SQL:

(from L in Table where L>50 select L).sum();

Получается, что язык запросов теперь встроен в С# на уровне операторов и является неотъемлемой частью самого языка!

Язык Python с каждым годом становится всё популярнее и составляет конкуренцию языкам семейства С++: С++, Java, C#, Kotlin. Я, первоначально скептически относившийся к Python, считая его простым языком «для детей», со временем стал ценить Python все больше, находя в языке глубину, изящность языковых конструкций и богатство возможностей. У меня все чаще возникал вопрос, когда же в Python появится аналог LINQ. На подобные вопросы создатель Python Гвидо Ван Россум отвечал примерно так: «Никогда, потому что в Python и так достаточно языковых конструкций, чтобы делать то же самое».

Встроенные в Python декларативные возможности

Посмотрим, какие есть языковые конструкции для декларативного программирования в Python.

Python «подталкивает» программиста к тому, чтобы он использовал встроенные коллекции. Заменим в примере рекуррентную формулу на помещение выбранных данных в новый список L и применим встроенную в Python функцию sum:

Table=[10,30,40,60,80]
L=[]
for t in Table:
    if t>50:
        L.append(t)
print(sum(L))

В Python есть краткие языковые конструкции – однострочники для формирования списков (позаимствованные из языка Haskell). Перепишем программу:

Table=[10,30,40,60,80]
L=[t for t in Table if t>50]
print(sum(L))

Список создавать не обязательно:

Table=[10,30,40,60,80]
print(sum(t for t in Table if t>50))

И это не что иное, как декларативное программирование! Конечно, оно, на первый взгляд, не похоже на SQL и LINQ. Но кто сказал, что декларативный  язык программирования должен быть SQL-подобным? Можно усомниться, что приведенный код является декларативным, потому что в нем есть цикл и условие. Но заменим операторы in и if на близкие им по смыслу from и where, а for на select, и мы получим гипотетический LINQ в Python:

sum(t select t from Table where t>50)

Язык Python экономен, и в него новые операторы вводятся в самом крайнем случае, когда польза от появления нового оператора очевидна. Здесь же во введении from, where и select нет никакой необходимости: мы можем обойтись «старыми проверенными» for, in и if, просто в несколько непривычном месте.

Приведу еще несколько примеров. Есть классическая задача с применением самого распространенного приема программирования – флага. Средневековье. Корабль прибывает в гавань. Если на борту есть больной, то на корабле вывешивается особый флаг. Портовые работники понимают, что на корабле карантин. В контакт с людьми, находящимися на корабле, вступать нельзя. Нужно принести на пирс воду и еду. Итак, есть список температур. Если хотя бы одна из них повышенная, нужно вывести сообщение «На борту - больной», иначе все здоровы:

T=[36,36,36,40,36]
F=False
for t in T:
    if t>37:
        f=True
if f:
    print("На борту – больной!")
else:
    print("Все здоровы")

Кстати, именно от этой задачи и происходит название приема программирования – флаг.

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

T=[36,36,36,40,36]
if len([t for t in T if t>37])>0:
    print("На борту – больной!")
else:
    print("Все здоровы")

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

T=[36,36,36,40,36]
L=[t>37 for t in T]
print(L)

Результат:

[False, False, False, True, False]

Наличие больного означает хотя бы один True в списке. Обнаружить True поможет квантор any. Он выдает истину, если в списке хотя бы один элемент – истина:

T=[36,36,36,40,36]
if any(t>37 for t in T):
    print("На борту – больной!")
else:
    print("Все здоровы")

В Python есть квантор all, который выдает истину только в том случае, если все элементы коллекции истинны:

T=[36,36,36,40,36]
L=[t>37 for t in T]
if all(L):
    print("Все больны")
elif any(L):
    print("Есть больные и здоровые")
else:
    print("Все здоровы")

Кванторы есть и в языке SQL. А их наличие в Python дополнительно свидетельствует о том, что на нем тоже есть декларативное программирование. Кстати, для if…else тоже есть однострочник:

T=[36,36,36,40,36]
print("На борту – больной!" if any(t>37 for t in T) else "Все здоровы")

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

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

L=[-3,-2,-1,0,1,2,3]
M=[el**2 for el in L]
print(M)
S={el**2 for el in L}
print(S)

Результат:

[9, 4, 1, 0, 1, 4, 9]
{0, 9, 4, 1}

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

s="abcaba"
D={k:s.count(k) for k in set(s)}
print(D)
for k in D:
    print(k,D[k])

Результат:

{'b': 2, 'a': 3, 'c': 1}
b 2
a 3
c 1

Подсчет количества вхождений элемента в коллекцию – это типовая задача, и в библиотеке collections есть счетчик Counter, который сделает то же самое:

from collections import Counter
s="abcaba"
print(dict(Counter(s)))

Результат:

{'a': 3, 'b': 2, 'c': 1}

В однострочник можно поместить несколько циклов:

L=[(i,j) for i in range(2,5) for j in range(2,5)]
print(L)

Результат:

[(2, 2), (2, 3), (2, 4), (3, 2), (3, 3), (3, 4), (4, 2), (4, 3), (4, 4)]

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

D={(i,j):i*j for i in range(2,5) for j in range(2,5)}
print(D)

Результат:

{(2, 2): 4, (2, 3): 6, (2, 4): 8, (3, 2): 6, (3, 3): 9, (3, 4): 12, (4, 2): 8, 
(4, 3): 12, (4, 4): 16}

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

Table=[10,30,40,60,80]
print(sum(filter(lambda t: t>50,Table)))

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

Если сравнить две строки эквивалентного кода:

print(sum(filter(lambda t: t>50,Table)))

и

print(sum(t select t from Table where t>50))

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

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

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

Библиотеки для декларативного программирования на Python: sqlalhemy, NumPy и Pandas

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

Естественно, что есть библиотеки для работы с базами данных. Популярной является библиотека SQLAlchemy, созданная в 2006г., в которой непосредственно в Python можно создать базу данных и писать к ней запросы в SQL-подобном стиле. Приведу фрагмент программы, считающий сумму элементов списка, больших 50, с помощью SQLAlchemy:

...
from SQLAlchemy import select,func
select(func.sum(Table.T)).where(Table.T>50)
...

Здесь программа напоминает SQL-запрос, но представляет собой смесь объектно-ориентированного и функционального стилей программирования. Здесь select(…).where(…) – это вызов метода (их может быть и больше, тогда они образуют цепочку) из объектно-ориентированного программирования, а Table.T>50ленивое выражение – прием функционального программирования, при котором выражение вычисляется не сразу, а потом, когда это действительно необходимо. В нашем случае Table.T>50 начинает вычисляться тогда, когда в него подставляются конкретные данные – элементы Table.

Ленивые выражения такого вида не встроены  в Python и реализуются в библиотеках. Как их реализовать, то есть вся «внутренняя кухня», как работают ленивые вычисления, описана в статье "Python. Выражения в методах и индексаторах".

 Для полноценного декларативного программирования остается только избавиться от оператора «точка». Это делается в LINQ, но не в Python. Кстати, в LINQ записи без точки являются синтаксическим сахаром (более удобной языковой конструкцией), который заменяется интерпретатором на начальной стадии компиляции в последовательность вызовов методов через точку.

Пример такого преобразования приведен в статье «Быстрое свидание» с LINQ. Там код:

var result = from n in nums where n*2 > 4 orderby n select n*2;

Преобразуется в:

var result = nums.Select( n => n*2 ).Where( n => n > 4 ).OrderBy( n => n );

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

С точки зрения декларативного программирования интерес представляет также библиотека NumPy (созданная в 2005г), которая содержит важный объект - многомерный массив (массив, в котором количество измерений не ограничено). Эта библиотека, несмотря на то, что она не входит в среды разработки на Python, фактически стала стандартом в сфере обработки данных, породив целый «куст» библиотек для интеллектуального анализа данных и нейронных сетей. В частности, на ней основана библиотека Pandas, предназначенная для обработки и анализа структурированных данных.

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

s="abcdef"
print(s)
print(s[2])  #Третий с начала
print(s[-2]) #Второй с конца
print(s[1:]) #Начиная с второго
print(s[:2]) #Первые два
print(s[1:4])#Со второго по четвертый
print(s[-2:])#Последние два
print(s[:-2])#Без последних двух
print(s[1:-1])#Без первого и последнего
print(s[1:-1:2])#Без первого и последнего через одного
print(s[::-1])#В обратном порядке

Результат:

abcdef
c
e
bcdef
ab
bcd
ef
abcd
bcde
bd
fedcba

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

from numpy import array
M=array([[1,2,3],
[4,5,6],
[7,8,9]])
print(M)
print()
print(M[1,:]) 
print()
print(M[:,1]) 
print()
print(M[:-1,:-1])

Результат:

[[1 2 3]
 [4 5 6]
 [7 8 9]]

[4 5 6]

[2 5 8]

[[1 2]
 [4 5]]

Таким образом, с помощью многомерных срезов мы можем получать не только строку матрицы (M[1,:]), но и столбец (M[:,1]).

Интересно, что Python предусмотрел многомерные срезы как языковую конструкцию, но ее не реализовал – оставил ее воплощение библиотекам. Это одно из эволюционных преимуществ Python по сравнению с другими языками. Если какая-то конструкция явно нужна, ее вводят, но не спешат реализовать в самом компиляторе. Далее «жизнь покажет», решение какой библиотеки будет лучшим. И, возможно, его включат уже и в компилятор.

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

from numpy import array
L=array([10,20,30,40,50])
M=L[[1,0,4,0,1,0]]
print(M)

Результат:

[20 10 50 10 20 10]

В NumPy возможно поэлементное сравнение массива с числом или даже поэлементное сравнение двух массивов:

from numpy import array
L=array([10,30,40,60,80])
print(L>50)

Результат:

[False False False  True  True]

Кванторы тоже применимы. Перепишем программу с больными на корабле:

from numpy import array
T=array([36,36,36,40,36])
if (T>37).all():
    print("Все больны")
elif (T>37).any():
    print("Есть больной")
else:
    print("Все здоровы")

Записи вида L>50 – это ленивые выражения, которые могут быть в дальнейшем использованы для фильтрации данных. В NumPy есть функция where, которая создает новый массив по условию, в котором элементы будут различаться в зависимости от того, выполняется условие или нет. Например, пусть в массиве заданы числа, которые могут быть отрицательными. В случае положительных чисел или нуля нужно извлечь квадратный корень. В случае отрицательного числа вывести объект NumPy nan (not a number – не число):

from numpy import array, where, nan
L=array([-2,-1,0,1,4,9])
M=where(L>=0,L**0.5,nan)
print(M)

Результат:

[nan nan  0.  1.  2.  3.]

Здесь для извлечения корня использовалась дробная степень. Заметим, что L**0.5 – это тоже ленивое выражение.

Для фильтрации данных с помощью оператора «квадратные скобки» могут быть использованы массивы со значениями «истина» и «ложь»:

from numpy import array
L=array([10,30,40,60,80])
M=L[[True,False,True,False,True]]
print(M)

Результат:

[10 40 80]

Причем сам фильтрующий массив может задаваться с помощью ленивого выражения:

from numpy import array
L=array([10,30,40,60,80])
M=L[L>50]
print(M)

Результат:

[60 80]

Сравните решения, уже приведенные в статье:

1)    Структурный стиль:

M=[]
for el in M:
	if el>50:
		M.append(el)

2)    Однострочник:

M=[el for el in M if el>50]

3)    Функциональный стиль:

M=list(filter(lambda el : el>50, L))

4)    Фильтрация NumPy:

M=L[L>50]

Последнее решение очень лаконично, и это большое достижение в развитии языка! Заметим, что формально все решения, кроме первого, относятся к декларативному программированию. Уже было отмечено, что функциональное программирование сильно отличается от других видов декларативного программирования. Если рассматривать однострочники как стандартное средство декларативного программирования, то элемент декларативного программирования NumPy – это явный прогресс.

В случае нескольких условий очень хотелось бы использовать оператор "and", но это невозможно по ряду технических причин (он работает только с булевыми типами данных и не перегружается, поэтому невозможно сделать его альтернативную реализацию в библиотеках). Вместо логических операторов можно использовать операторы работы со множествами «&» - пересечение множеств (вместо «логического и») и «|» - объединение множеств (вместо «логического или»):

from numpy import array
L=array([10,30,40,60,80])
M=L[(L>30) & (L<70)]
print(M)

Результат:

[40 60]

Агрегирующие операции также работают:

from numpy import array
L=array([10,30,40,60,80])
print(L[L>50].sum())

Результат:

140

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

Библиотека Pandas, созданная в 2008г., основана на библиотеке NumPy и содержит два класса: Series и DataFrame. Series создается на основе многомерного массива NumPy, словаря или скаляра (числа).  В случае словаря Series снимает ограничение NumPy на хранение данных одного вида:

from pandas import Series
student = Series({"Имя":"Иванов","Математика":5, "Физика":4})
print(student)

К Series применимы все языковые конструкции NumPy, связанные с многомерной индексацией и условиями в операторе «квадратные скобки».

DataFrame – это таблица, строками которой являются объекты Series. Если Series создана из объектов NumPy, то это не плоская таблица, а многомерный объект, «строками» которого являются многомерные массивы.

Таблицу студентов и оценок можно задать следующим образом:

from pandas import DataFrame
students=DataFrame({"Имя":["Иванов","Петров","Сидоров", "Смирнов"],
"математика":[5,4,3,5],
"физика":[4,3,3,2]})
print(students)

Результат:

       Имя  математика  физика
0   Иванов           5       4
1   Петров           4       3
2  Сидоров           3       3
3  Смирнов           5       2

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

from pandas import DataFrame
students=DataFrame({"Имя":["Иванов","Петров","Сидоров", "Смирнов"],
"математика":[5,4,3,5],
"физика":[4,3,3,2]})
L=students[(students["математика"]>3) & (students["физика"]>3)]
print(L)

Результат:

      Имя  математика  физика
0  Иванов           5       4

С помощью специальных методов можно сортировать и группировать данные. Например, добавим новую колонку «суммарный балл» и составим рейтинг студентов:

from pandas import DataFrame
students=DataFrame({"Имя":["Иванов","Петров","Сидоров",
"Смирнов"],
"математика":[5,4,3,5],
"физика":[4,3,3,2]})
students["суммарный балл"]=(students["математика"]+students["физика"])
L=students.sort_values("суммарный балл", ascending=False)
print(L)

Результат:

       Имя  математика  физика  суммарный балл
0   Иванов           5       4               9
1   Петров           4       3               7
3  Смирнов           5       2               7
2  Сидоров           3       3               6

Определим, сколько студентов набрало «5» по математике, сколько «4» и сколько «3». Используем группировку:

L=students.groupby(["математика"])["математика"].count()
print(L)

Результат:

математика
3    1
4    1
5    2
Name: математика, dtype: int64

Уровни интеграции и выбор парадигм

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

Первый уровень. Интерпретато�� строки. Устанавливается связь с источником данных, затем пишется запрос, который представляет собой текстовую строку. Гипотетический пример может быть таким:

L=source("students.csv")
Q=L.query("select count(имя) from студенты group by математика")

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

L=source("students.csv")
discipline="математика"
Q=L.query("select count(имя) from студенты group by "+discipline)

Второй уровень. Реализация команд микроязыка библиотечными средствами в рамках одной или нескольких парадигм языка общего назначения. Именно так устроен SQLAlchemy и другие библиотеки для работы с базами данных.

select(func.sum(Table.T)).where(Table.T>50)

Цепочка вызовов методов более сложного запроса могла бы выглядеть так:

select(…).where(…).groupby(…).sum()

Здесь нет единой строки – кода на микроязыке, соответственно, нет необходимости в её редактировании для взятия данных из внешней программы. То есть не надо использовать микроязык редактирования строк.

Недостатком этого уровня является то, что микроязык разрушается (например, SQL). Его мы можем узнать только по названиям методов.

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

select(…).where(lambda el : el>50).groupby(…).sum()

Здесь мы имеем гибрид объектно-ориентированного и функционального программирования.

Или ленивые выражения:

select(…).where(el>50).groupby(…).sum()

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

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

Посмотрим на программу с использованием NumPy:

from NumPy import array
L=array([10,30,40,60,80])
M=L[(L>30) & (L<70)]
print(M)

Здесь M=L[(L>30) & (L<70)] - это фактически глубинная интеграция. Если бы речь шла о втором уровне, то программа выглядела бы так:

L.where(lambda el: el>30).where(lambda el: el<70)

А программа на первом уровне:

query("select el from L where el>30 and el<70")

Как видно, на уровне глубинной интеграции от исходного SQL не остается и следа. Оператор where заменен стандартным оператором индексации Python «квадратные скобки», поведение которого дополнено и переосмыслено декларативным программированием.

Библиотека Pandas, с одной стороны, использует оператор «квадратные скобки» вместо where, с другой стороны, включает методы вроде groupby и sort_values. То есть Pandas занимает промежуточный уровень между глубинной интеграцией и интеграцией второго уровня.

Можно ли сформулировать задачу глубинной интеграции декларативного SQL-подобного языка в Python? Можно поставить и более широкую задачу – интеграции нескольких декларативных языков в Python. SQL реализует только один из типов декларативных языков. В его основе лежит математическое основание – исчисление на кортежах. Посмотрим на название массива L внутри квадратных скобок:

M=L[(L>30) & (L<70)]

Здесь название массива L используется как переменная-кортеж, которая принимает значения элементов самого массива.

Есть еще исчисление на доменах. На его основе строится язык запросов к базам данных QBE (Query By Example – запрос по образцу), созданный в 1975г. В чистом виде на нем мало кто пишет запросы, но он спрятан в визуальные мастера – построители запросов.

Третье исчисление – исчисление предикатов первого порядка. На нем строится язык искусственного интеллекта Prolog (1972г.). Декларативное программирование на Prolog имеет свое название – логическое программирование. Есть попытки интегрировать Prolog в Python (библиотеки pylog и pytolog), но все они находятся на первом уровне интеграции – уровне интерпретатора строки.

При глубинной интеграции правомочен вопрос, возможности какого языка встраивать в Python. Конечно, SQL – лидер по использованию. Но в QBE и Prolog есть свои «изюминки». Например, на Prolog можно писать очень изящные и лаконичные рекурсивные запросы.

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

Глубинная интеграция возможностей всех трех языков в Python была сделана автором этой статьи в 2025 году в библиотеке DecPy.

Обзор возможностей библиотеки DecPy

Библиотеку DecPy, реализующую три вида декларативного программирования, можно установить стандартным путем, как и библиотеки numpy и pandas:

pip install decpy

Запросы к одномерному списку похожи на запросы в NumPy:

from decpy import var
el=var()
L=[10,20,30,40,50]
M=var(L)[(el>15) & (el<45)]
print(M)

Результат:

[20, 30, 40]

Отличие от NumPy, в котором M=array(L)[(L>15) & (L<45)] переменная-кортеж L внутри квадратных скобок совпадает по имени с именем коллекции L. В DecPy для переменной-кортежа используется отдельная переменная el класса var, который мы импортируем из библиотеки DecPy. Решение в NumPy кажется более простым, но в запросах DecPy мы можем использовать несколько переменных, как мы увидим ниже.

Класс var реализует ленивые вычисления и содержит богатые возможности. Обратите внимание на начало объявления переменной М: M=var(L). Мы, фактически, инициируем ленивую переменную списком L. Далее М функционирует как ленивый запрос. Добавим в L новое значение уже после объявления M и убедимся, что коллекция М изменилась:

from decpy import var
el=var()
L=[10,20,30,40,50]
M=var(L)[(el>15) & (el<45)]
print(M)
L.append(35)
print(M)

Результат:

[20, 30, 40]
[20, 30, 40, 35]

Функционирование в ленивом режиме, как мне кажется, в большинстве случаев более полезно, чем в обычном. Но можно было бы строить библиотеку и без ленивого режима. Если же вам нужно отключить ленивый режим, добавьте в конец определения M круглые скобки:

from decpy import var
el=var()
L=[10,20,30,40,50]
M=var(L)[(el>15) & (el<45)]()
print(M)
L.append(35)
print(M)

Результат:

[20, 30, 40]
[20, 30, 40]

Более интересны запросы к составным коллекциям. Создадим список людей с именами и датами рождения. Введем понятие «молодежь» - люди, родившиеся после 1999 г.

from decpy import var
L=var([("Петр",1990),("Иван",2000),("Мария",2000),("Василий",2010)])
el=var()
M=L[el[1]>1999]
print(M)

Результат:

[('Иван', 2000), ('Мария', 2000), ('Василий', 2010)]

Здесь обращение к дате рождения через индексацию el[1] не очень удобно. Можно использовать мощь объектно-ориентированного программирования и сделать класс «Человек». В библиотеке DecPy есть декоратор queryclass, который меняет поведение декорированного класса – создает ленивую коллекцию из экземпляров класса, совпадающую по имени с самим классом.

from decpy import var, queryclass

@queryclass
class person:
    def __init__(self,name,year):
        self.name=name
        self.year=year
    def __repr__(self):
        return self.name+" "+str(self.year)

p=var()
youth=person[p.year>1999]

person("Петр",1990)
person("Иван",2000)
person("Мария",2000)
person("Василий",2010)
print(person)
print(youth)

Результат:

{Мария 2000, Петр 1990, Иван 2000, Василий 2010}
{Мария 2000, Иван 2000, Василий 2010}

Теперь к классу можно писать запросы!

Если создавать отдельный класс кажется вам нецелесообразным, но вы хотите пользоваться в условиях не индексами, а атрибутами, то это можно сделать с помощью средства DecPy table:

from DecPy import var, table

person=table("name","year")
p=var()
youth=person[p.year>1999]

person("Петр",1990)
person ("Иван",2000)
person ("Мария",2000)
person ("Василий",2010)
print(person)
print(youth)

Во всех этих запросах в переменную p подставлялись элементы коллекции. В терминах исчисления на кортежах это называется переменная-кортеж. Можно сделать аналогичный запрос с помощью переменных-доменов. Каждому элементу элемента исходной коллекции будет соответствовать своя переменная. Сравните:

1)    Переменная-кортеж:

p=var()
youth=person[p.year>1999]

2)    Переменные-домены:

name,year = var(2)
youth=person[name,year>1999]

C помощью переменных-доменов можно делать в квадратных скобках всё то, что в SQL-подобных системах, встроенных в Python, делается с помощью дополнительных функций, например, функций группировки и сортировки.

Например, сгруппируем людей по годам рождения:

from decpy import var, table

person=table("name","year")
person("Петр",1990)
person ("Иван",2000)
person ("Мария",2000)
person ("Василий",2010)

name, year = var(2)
L=person[name,year.group()]
print(L)

Результат:

{(('Петр',), 1990), (('Мария', 'Иван'), 2000), (('Василий',), 2010)}

Посчитаем, сколько людей родилось в каждом году:

...
name, year = var(2)
L=person[name.len(),year.group()]

Результат:

{(2, 2000), (1, 2010), (1, 1990)}

Порядок в запросе вас может не устроить, можно переставить колонки местами с помощью дублирования и удаления колонок:

...
name, year, x, y, z = var(5)
L=person[name.len(),year.group()][x,y,x]

Результат:

{(1, 1990, 1), (1, 2010, 1), (2, 2000, 2)}

Удалим теперь первую колонку:

...
name, year, x, y, z = var(5)
L=person[name.len(),year.group()][x,y,x][None,y,x]

Результат:

{(2010, 1), (2000, 2), (1990, 1)}

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

С помощью DecPy можно соединять данные из разных коллекций. Для этого предназначены операторы декартова произведения «*» и «**», между которыми есть разница в представлении результатов. Пусть будут таблицы «Человек» и «Собака», в которых мы будем хранить имя и год рождения, а у собаки еще и имя хозяина.

from decpy import var, table

person=table("name","year")
person("Иван",2000)
person("Мария",2000)

dog=table("name","year","owner")
dog("Шарик",2015, "Иван")
dog("Бобик",2016, "Иван")
dog("Жучка",2016, "Мария")

Посмотрим, как можно соединить собаководов с их собаками:

1)    Соединение с сохранением внутренней структуры, исчисление на кортежах:

el = var()
L = (person*dog)[el[0].name==el[1].owner]
print(L)

Результат:

{(('Мария', 2000), ('Жучка', 2016, 'Мария')), 
(('Иван', 2000), ('Бобик', 2016, 'Иван')), 
(('Иван', 2000), ('Шарик', 2015, 'Иван'))}

2)    Соединение с сохранением внутренней структуры, исчисление на доменах:

p,d = var(2)
L = (person*dog)[p,d,p.name==d.owner]
print(L)

Результат совпадает с предыдущим

3)    Соединение с разрушением внутренней структуры, исчисление на кортежах:

el = var()
L = (person**dog)[el[0]==el[4]]
print(L)

Результат:

{('Иван', 2000, 'Бобик', 2016, 'Иван'), 
('Иван', 2000, 'Шарик', 2015, 'Иван'), 
('Мария', 2000, 'Жучка', 2016, 'Мария')}

4)    Соединение с разрушением внутренней структуры, исчисление на доменах:

name1,year1,name2,year2 = var(4)
L = (person**dog)[name1,year1,name2,year2,name1]
print(L)

Результат:

{('Иван', 2000, 'Бобик', 2016), 
('Мария', 2000, 'Жучка', 2016), 
('Иван', 2000, 'Шарик', 2015)}

Заметим, что при соединении, разрушающем внутреннюю структуру, в исчислении на доменах дублирующая колонка («владелец») исчезает.

Интересны задачи о родословном древе. Хорошее решение для хранения исходных данных – это рекурсивная структура данных: в классе «Человек» сделать атрибуты «мать» и «отец». Это решение можно найти в документации по библиотеке DecPy, здесь же для экономии места приведу самое простое решение:

from decpy import table
parent=table()
parent("Вася","Иван") #Вася сын Ивана
parent("Иван","Григорий") #Иван сын Григория
print(parent)

Результат:

{('Вася', 'Иван'), ('Иван', 'Григорий')}

Напишем определение, кто такой дедушка – это родитель родителя. Формализуем это определение через переменные: Y является дедушкой для X, если родителем у X является человек Z, причем Z является ребенком Y:

from decpy import var,table
x,y,z=var(3)
parent,grandparent=table(2)
grandparent=(parent**parent)[x,y,y,z]
parent("Вася","Иван")  #Вася сын Ивана
parent("Иван","Григорий")  #Иван сын Григория
print(grandparent)

Результат:

{('Вася', 'Иван', 'Григорий')}

Уберем из результата промежуточное звено (Ивана). Пусть внук в grandparent будет связан напрямую с дедом:

from decpy import var,table
x,y,z=var(3)
parent,grandparent=table(2)
grandparent=(parent**parent)[x,z,z,y][x,None,y]
parent("Вася","Иван")  #Вася сын Ивана
parent("Иван","Григорий")  #Иван сын Григория
print(grandparent)

Результат:

{('Вася', 'Григорий')}

Этот запрос написан в стиле исчисления на доменах. В переменных можно запутаться. Гораздо проще запрос выглядит в стиле исчисления предикатов первого порядка. Мы можем перенести переменные в квадратных скобках к тем коллекциям, из которых они берутся, в том числе к определению деда grandparent:

from decpy import var,table
x,y,z=var(3)
parent,grandparent=table(2)
grandparent[x,y]|=parent[x,z]**parent[z,y]
parent("Вася","Иван")  #Вася сын Ивана
parent("Иван","Григорий")  #Иван сын Григория
print(grandparent)

Обратите внимание, что оператор «=» в определении деда заменен на оператор «|=». В Python смысл этого оператора – к множеству дописать новые элементы. В библиотеке DecPy к этому значению добавляется еще одно: «является по определению». Реализация именно этого оператора технически обеспечивает связывание переменных в запросе.

Введем понятие предок – это родитель или дедушка. Выведем предков Васи:

from decpy import var,table
x,y,z=var(3)
parent,grandparent,ancestor=table(3)
grandparent[x,y] |= parent[x,z]**parent[z,y]
ancestor |= parent | grandparent
parent("Вася","Иван")  #Вася сын Ивана
parent("Иван","Григорий")  #Иван сын Григория
print(ancestor["Вася",x])

Результат:

{('Вася', 'Иван'), ('Вася', 'Григорий')}

Заметим, что родители и дедушки в определении предка соединяются с помощью оператора объединения множеств «|».

Если определение дедушки не нужно, мы можем его удалить, скопировав код в определение предка:

from decpy import var,table
x,y,z=var(3)
parent,ancestor=table(2)
ancestor[x,y] |= parent[x,y] | parent[x,z]**parent[z,y]
parent("Вася","Иван")  #Вася сын Ивана
parent("Иван","Григорий")  #Иван сын Григория
print(ancestor["Вася",x])

Введем в родословное древо еще один уровень – сделаем Григория сыном Глеба:

from decpy import var,table
x,y,z=var(3)
parent,ancestor=table(2)
ancestor[x,y] |= parent[x,y] | parent[x,z]**parent[z,y]
parent("Вася","Иван")  #Вася сын Ивана
parent("Иван","Григорий")  #Иван сын Григория
parent("Григорий","Глеб")  #Григорий сын Глеба
print(ancestor["Вася",x])

Результат не изменился:

{('Вася', 'Иван'), ('Вася', 'Григорий')}

Глеб является прадедушкой Васи, но не входит в число предков, так как Васю и Глеба по цепочке родства связывает не один человек (переменная Z в программе), а два человека. Бесконечно увеличивать цепочку в определении предка мы не можем, но есть изящное решение. Пусть Z является родителем для X. Тогда кем является Y для Z в общем случае? Предком! Заменим второе вхождение parent в запрос на ancestor:

from decpy import var,table
x,y,z=var(3)
parent,ancestor=table(2)
ancestor[x,y] |= parent[x,y] | parent[x,z]**ancestor[z,y]
parent("Вася","Иван")  #Вася сын Ивана
parent("Иван","Григорий")  #Иван сын Григория
parent("Григорий","Глеб")  #Григорий сын Глеба
print(ancestor["Вася",x])

Результат:

{('Вася', 'Глеб'), ('Вася', 'Григорий'), ('Вася', 'Иван')}

Теперь все предки Васи найдены.

Здесь мы имеем дело с рекурсивным запросом. Рекурсии – это большая проблема в декларативных языках программирования. В SQL рекурсии появились в 2000-х годах, но они настолько громоздки, что выглядят некрасиво. Вместо рекурсивных запросов большинство разработчиков по-прежнему пользуются рекурсивными хранимыми процедурами, в которые встроены запросы. В LINQ рекурсий нет. Наиболее лаконично и изящно рекурсии реализованы в языке Prolog. Рекурсии в DecPy как языковые конструкции полностью повторяют рекурсии в Prolog и даже еще изящнее (нет необходимости в прерывании рекурсий при зацикливании). Сравните код на DecPy и Prolog:

ancestor[x,y] |= parent[x,y] | parent[x,z]**ancestor[z,y]

и

ancestor(x,y) :- parent(x,y) ; parent(x,z),ancestor(z,y)

Мы видим, что определения совпадают с точностью до замены операторов.

Больше красивых рекурсий в задачах о родословном древе вы найдете в документации по библиотеке DecPy.

Там же есть примеры, которые характеризуют Prolog как язык искусственного интеллекта, в частности, доказательство теоремы. Получается, что Python так же, как и Prolog, теперь с помощью библиотеки DecPy может доказывать теоремы!

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

Библиотека DecPy с помощью очень небольшого количества импортируемых средств: ленивой переменной var, таблицы table, декоратора queryclass, которые дополняют поведение встроенных в Python операторов возможностями декларативного программирования, встраивает на глубинном уровне в Python декларативное программирование трех видов: исчисление на кортежах, исчисление на доменах и логическое программирование. Таким образом, декларативное и логическое программирование становятся органичной частью языка Python, наряду со структурным, процедурным, функциональным, объектно-ориентированным и аспектно-ориентированным программированием. То есть библиотека DecPy помогает сделать следующий шаг в эволюционном развитии языка Python.

См. репозиторий библиотеки DecPy на githab.