Python: Декларативное программирование
Теория: Функциональный и процедурный подход
Python создавался с прицелом на активное и эффективное использование встроенных коллекций. Поэтому при выборе подхода к решению задач Python склоняет программиста к определенному стилю работы. Сам язык подталкивает использовать императивный стиль в сочетании с процедурным и объектно-ориентированным программированием.
Те же коллекции в Python являются объектами и чаще всего модифицируются по месту — отсюда изменяемое состояние. Однако в языке нашлось место и для элементов функционального программирования. Его часто считают подвидом декларативного программирования, потому что функциональный код выглядит как конвейер для преобразования данных.
В этом уроке мы рассмотрим императивный и функциональный код на Python. В обоих случаях мы попробуем вывести на экран отсортированный список уникальных элементов из некоторого набора чисел.
Процедурное решение
В процедурном решении программа разбита на подпрограммы, изменяющие состояние вместо возврата значения. Код будет выглядеть так:
Это решение достаточно эффективно — оно расходует память только на копию исходного списка, потом сжимает ее и сортирует по месту. У этого решения предсказуемый расход памяти, не зависящий от входных данных. А еще после применения filter_and_sort() данные можно дальше обрабатывать так же по месту, но уже другими процедурами.
Этот код эффективен, но заставляет программиста многое держать в уме. Это повышает вероятность ошибки. Например, мы можем забыть получить копию списка INPUT перед началом обработки — тогда мы случайно отсортируем сам исходный список INPUT.
Такое ошибочное изменение не тех сущностей может привести к неприятным последствиям. Причем такие ошибки не лежат на поверхности — программа выведет нужные числа, поэтому ошибка будет не видна с первого взгляда.
Функциональное решение
Напишем функциональный вариант с элементами декларативного стиля:
Код описывает, какой результат мы хотим получить:
print()— напечатанное'\n'.join(...)— объединение через перевод строкиmap(str, ...)— последовательность строкsorted(set(...))— полученных из отсортированного множества исходных чисел
Обратите внимание, что в коде нет ни одной переменной. Ничего не нужно защитно копировать, и самого кода гораздо меньше.
Попробуем проанализировать, как этот код работает:
- Создается промежуточное множество
set() - Внутри
sorted()создается список — не обычныйlist(), но все равно копия - Для каждого итогового числа будет создано по строке
- Функция
'\n'.join()соберет строку, размер которой будет равен сумме длин всех строк с числами и количеству переводов строк
Как видите, за лаконичность кода приходится платить ресурсами.
Даже если исходный список содержит не слишком много повторов, то промежуточные структуры займут в памяти в разы больше места, чем исходный список. Так происходит из-за множества промежуточных строк, которые заметно тяжелее исходных чисел. Получается, что потребление памяти сильно зависит от состава входных данных.
Истина где-то посередине
Функциональное решение читается хорошо, если разработчик в принципе знаком с таким стилем. Читаемость кода — важная характеристика, поэтому при небольшом объеме входных данных можно выбирать такой вариант.
Процедурное решение читается достаточно тяжело: нужно буквально выполнять код в уме. Еще в такой код сложнее вносить изменения — он слишком специфичен и завязан на текущую задачу. А еще нужно помнить, что процедура filter_and_sort() модифицирует свой аргумент.
Зато процедурный код работает эффективно и предсказуемо. Если у вас много данных с небольшим количеством повторов, то этот вариант может оказаться предпочтительным. Однако оба решения можно и улучшить, немного отступив от парадигмы.
Например, в функциональном решении можно применить цикл:
Этот код не создает промежуточных строк, потому что команда print() и так умеет выводить числа. Большая строка не склеивается перед выводом — это самая заметная экономия.
Усложним ситуацию и представим, что все входные элементы уникальны. В таком случае множество будет копией исходного списка — внутри sorted() будет еще одна копия. В итоге у нас будет две копии.
Посмотрим, как еще можно улучшить императивный вариант:
Этот вариант читается гораздо лучше, потому что мы избавились от процедуры, которая модифицирует копию исходного списка. Нет нужды копировать входной список явно — неявная копия будет в sorted(). Но теперь, если понадобится еще поработать с результатом сортировки перед печатью, то придется накопить значения в промежуточный список.
В большинстве случаев программисты на Python выбирают функциональный подход и дорабатывают его. Так они приходят к балансу — код все еще остается достаточно компактным, и работает вполне эффективно.