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(str.join('\n', map(str, sorted(set(INPUT)))))
Код буквально можно прочитать как "(результат это) напечатанное (print()
) объединение через перевод строки str.join('\n', ...)
последовательности строк (map(str, ...)
), полученных из отсортированного множества (sorted(set(...))
) исходных чисел.". И ни одной переменной, ничего не нужно защитно копировать, и кода гораздо меньше!
Однако, если проанализировать то, как этот код работает, мы увидим, что здесь, как минимум
set()
sorted()
создаётся список — не обычный list()
, но всё равно копияstr.join()
соберёт строку размером в сумму длин всех строк с числами плюс переводы строкА в Python2
map()
ещё и список возвращал вместо итератора! Хорошо, что тут мы на этом списке экономим.
Как видите, за красоту мы платим ресурсами. А если исходный список содержит не слишком много повторов, то промежуточные структуры займут в памяти в разы больше места, чем исходный список: во многом — из-за множества промежуточных строк, которые заметно тяжелее исходных чисел. Получается, что потребление памяти сильно зависит от состава входных данных.
Функциональное решение читается хорошо, если читатель в принципе знаком с таким стилем. Читаемость кода — важная характеристика, поэтому при небольшом объёме входных данных можно выбирать такой вариант.
Процедурное решение читается достаточно тяжело: нужно буквально "выполнять код в уме". К тому же в такой код сложнее вносить изменения — слишком уж он специфичен и завязан на текущую задачу. А ещё нужно помнить о том, что процедура 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 выбирают варианты вроде улучшенного функционального: код всё ещё достаточно компактен и уже достаточно эффективен.
Вам ответят команда поддержки Хекслета или другие студенты.
Выделите текст, нажмите ctrl + enter и отправьте его нам. В течение нескольких дней мы исправим ошибку или улучшим формулировку.
Загляните в раздел «Обсуждение»:
Статья «Ловушки обучения»
Вебинар «Как самостоятельно учиться»
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.
Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно.
Наши выпускники работают в компаниях:
С нуля до разработчика. Возвращаем деньги, если не удалось найти работу.
Зарегистрируйтесь или войдите в свой аккаунт