Python создавался с прицелом на активное и эффективное использование встроенных коллекций. Поэтому при выборе подхода к решению задач Python склоняет программиста к определенному стилю работы. Сам язык подталкивает использовать императивный стиль в сочетании с процедурным и объектно-ориентированным программированием.
Те же коллекции в Python являются объектами и чаще всего модифицируются по месту — отсюда изменяемое состояние. Однако в языке нашлось место и для элементов функционального программирования. Его часто считают подвидом декларативного программирования, потому что функциональный код выглядит как конвейер для преобразования данных.
В этом уроке мы рассмотрим императивный и функциональный код на Python. В обоих случаях мы попробуем вывести на экран отсортированный список уникальных элементов из некоторого набора чисел.
Процедурное решение
В процедурном решении программа разбита на подпрограммы, изменяющие состояние вместо возврата значения. Код будет выглядеть так:
INPUT = [1, 2, 5, 12, 3, 5, 2, 7, 12]
def main():
numbers = INPUT[:]
filter_and_sort(numbers) # Сортируем и фильтруем по месту
for number in numbers:
print(number) # Выводим поэлементно в цикле
def filter_and_sort(values):
values.sort() # Список сортируется по месту
previous_value = None
index = 0
while index < len(values):
value = values[index]
if value == previous_value and index > 0:
# Элемент удаляется из списка, то есть список опять модифицируется
values.pop(index)
else:
index += 1
previous_value = value
Это решение достаточно эффективно — оно расходует память только на копию исходного списка, потом сжимает ее и сортирует по месту. У этого решения предсказуемый расход памяти, не зависящий от входных данных. А еще после применения filter_and_sort()
данные можно дальше обрабатывать так же по месту, но уже другими процедурами.
Этот код эффективен, но заставляет программиста многое держать в уме. Это повышает вероятность ошибки. Например, мы можем забыть получить копию списка INPUT
перед началом обработки — тогда мы случайно отсортируем сам исходный список INPUT
.
Такое ошибочное изменение не тех сущностей может привести к неприятным последствиям. Причем такие ошибки не лежат на поверхности — программа выведет нужные числа, поэтому ошибка будет не видна с первого взгляда.
Функциональное решение
Напишем функциональный вариант с элементами декларативного стиля:
INPUT = [1, 2, 5, 12, 3, 5, 2, 7, 12]
def main():
print('\n'.join(map(str, sorted(set(INPUT)))))
Код описывает, какой результат мы хотим получить:
print()
— напечатанное'\n'.join(...)
— объединение через перевод строкиmap(str, ...)
— последовательность строкsorted(set(...))
— полученных из отсортированного множества исходных чисел
Обратите внимание, что в коде нет ни одной переменной. Ничего не нужно защитно копировать, и самого кода гораздо меньше.
Попробуем проанализировать, как этот код работает:
- Создается промежуточное множество
set()
- Внутри
sorted()
создается список — не обычныйlist()
, но все равно копия - Для каждого итогового числа будет создано по строке
- Функция
'\n'.join()
соберет строку, размер которой будет равен сумме длин всех строк с числами и количеству переводов строк
Как видите, за лаконичность кода приходится платить ресурсами.
Даже если исходный список содержит не слишком много повторов, то промежуточные структуры займут в памяти в разы больше места, чем исходный список. Так происходит из-за множества промежуточных строк, которые заметно тяжелее исходных чисел. Получается, что потребление памяти сильно зависит от состава входных данных.
Истина где-то посередине
Функциональное решение читается хорошо, если разработчик в принципе знаком с таким стилем. Читаемость кода — важная характеристика, поэтому при небольшом объеме входных данных можно выбирать такой вариант.
Процедурное решение читается достаточно тяжело: нужно буквально выполнять код в уме. Еще в такой код сложнее вносить изменения — он слишком специфичен и завязан на текущую задачу. А еще нужно помнить, что процедура filter_and_sort()
модифицирует свой аргумент.
Зато процедурный код работает эффективно и предсказуемо. Если у вас много данных с небольшим количеством повторов, то этот вариант может оказаться предпочтительным. Однако оба решения можно и улучшить, немного отступив от парадигмы.
Например, в функциональном решении можно применить цикл:
def main():
for number in sorted(set(INPUT)):
print(number)
Этот код не создает промежуточных строк, потому что команда print()
и так умеет выводить числа. Большая строка не склеивается перед выводом — это самая заметная экономия.
Усложним ситуацию и представим, что все входные элементы уникальны. В таком случае множество будет копией исходного списка — внутри sorted()
будет еще одна копия. В итоге у нас будет две копии.
Посмотрим, как еще можно улучшить императивный вариант:
def main():
previous_value = None
for value in sorted(INPUT):
if previous_value is None or value != previous_value:
previous_value = value
print(value)
Этот вариант читается гораздо лучше, потому что мы избавились от процедуры, которая модифицирует копию исходного списка. Нет нужды копировать входной список явно — неявная копия будет в sorted()
. Но теперь, если понадобится еще поработать с результатом сортировки перед печатью, то придется накопить значения в промежуточный список.
В большинстве случаев программисты на Python выбирают функциональный подход и дорабатывают его. Так они приходят к балансу — код все еще остается достаточно компактным, и работает вполне эффективно.
Дополнительные материалы
Остались вопросы? Задайте их в разделе «Обсуждение»
Вам ответят команда поддержки Хекслета или другие студенты
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.