Чистые функции и побочные эффекты
- Объяснить, что такое чистая функция (Pure Function) — тот же ввод, всегда тот же результат, без изменения чего-либо снаружи
- Распознавать побочные эффекты в коде — global, изменение полученного списка, печать, запись в файл
- Превращать функцию, которая меняет свой ввод, в чистую версию, которая возвращает новое значение
- Объяснить, почему чистые функции легче тестировать и переиспользовать
Что мы изучим?
- Что такое чистая функция (Pure Function).
- Что такое побочный эффект (Side Effect) и какие они бывают.
- Ловушку изменения списка, полученного как параметр.
- Как написать чистую версию функции.
Зачем это нужно?
Вот функция, которая выглядит невинно:
total = 0
def add_to_total(price):
global total
total += price
return total
print(add_to_total(10))
print(add_to_total(10))Вывод:
10
20Абсолютно одинаковый вызов — и разный результат. Функция зависит от внешней переменной и к тому же меняет её. Чтобы знать, что она вернёт, нужно знать всю историю программы. Её трудно тестировать и ей трудно доверять.
Объяснение с основ
Что такое чистая функция
Чистая функция удовлетворяет двум условиям:
- Тот же ввод → всегда тот же результат. Результат зависит только от параметров — не от глобальных переменных, не от времени, не от ввода пользователя.
- Без побочных эффектов. Она ничего не меняет вне себя. Всё, что она «выдаёт», выходит через
return.
Что считается побочным эффектом
| Побочный эффект | Пример |
|---|---|
| Изменение глобальной переменной | global total, а затем total += price |
| Изменение полученного списка или словаря | prices[i] = ... для списка, пришедшего как параметр |
| Ввод и вывод с пользователем | print(...), input(...) |
| Запись в файл или базу данных | open(..., "w"), INSERT |
Чистая функция — как соковыжималка: кладёте 3 апельсина — выходит стакан сока. Всегда одно и то же количество, и соковыжималка больше ничего на кухне не трогает. Нечистая функция — соковыжималка, которая иногда ещё и двигает холодильник, — и вдруг в доме что-то не работает, а вы понятия не имеете почему.
Синтаксис
Специального синтаксиса нет — это способ писать функции:
# pure: result depends only on the parameters
def add_price(total, price):
return total + price
# not pure: reads and changes something outside
def add_to_total(price):
global total
total += price
return totalПервый пример кода
Та же функция из начала урока в чистой версии. week-2/pure_total.py:
def add_price(total, price):
return total + price
total = 0
total = add_price(total, 10)
total = add_price(total, 10)
print(total)
print(add_price(0, 10))
print(add_price(0, 10))Вывод:
20
10
10Разбор кода построчно
| Строка | Объяснение |
|---|---|
def add_price(total, price): | Всё, что нужно функции, приходит через параметры. |
return total + price | Она ничего не меняет — только вычисляет и возвращает. |
total = add_price(total, 10) | Вызывающий код решает, что делать с результатом: здесь — сохранить его в total. |
print(add_price(0, 10)) дважды | Тот же ввод → тот же результат, 10, каждый раз. |
Ещё один пример
Ловушка: изменение полученного списка. week-2/discount_trap.py:
def add_discount(prices):
for i in range(len(prices)):
prices[i] = prices[i] * 0.9
return prices
original = [100, 200]
discounted = add_discount(original)
print(discounted)
print(original)Вывод:
[90.0, 180.0]
[90.0, 180.0]original изменился! Как мы изучили в уроке об изменяемости, список передаётся в функцию как ссылка — prices и original — два имени для одного списка. Тот, кто позже в программе использует original, вдруг получает другие цены.
Чистая версия — построить новый список:
def add_discount(prices):
result = []
for price in prices:
result.append(price * 0.9)
return result
original = [100, 200]
discounted = add_discount(original)
print(discounted)
print(original)Вывод:
[90.0, 180.0]
[100, 200]Теперь original остаётся ровно таким, каким был, а discounted — совершенно новый список.
Типичная ошибка
Ошибка: думать, что каждая функция обязана быть чистой. Программа, которая не печатает, не сохраняет в файл и не пишет в базу данных, ничего полезного не делает. Побочные эффекты — необходимая часть любой программы.
Правило — разделять:
- Вычисление — в чистых функциях (
calculate_total,apply_discount). - Общение с миром — в нескольких понятных местах (
print, сохранение в файл).
def calculate_total(prices): # pure: easy to test
return sum(prices)
def show_total(prices): # side effect: printing
print("Total:", calculate_total(prices))Практика
Упражнения опираются только на то, что пройдено в этом уроке и в предыдущих. Сначала попробуйте сами, и только потом открывайте решение.
Упражнение 1: Чистая или нет?
Для каждой функции — чистая ли она? Если нет, в чём побочный эффект?
def square(n):
return n * n
def log_square(n):
print("squaring", n)
return n * n
def add_item(cart, item):
cart.append(item)
return cart
def with_item(cart, item):
return cart + [item]Показать решение
| Функция | Чистая? | Почему |
|---|---|---|
square | Да | Результат зависит только от n, и ничего не меняется |
log_square | Нет | print — побочный эффект: вывод на экран |
add_item | Нет | append меняет полученный список (мутация) |
with_item | Да | cart + [item] создаёт новый список, а оригинал не тронут |
Упражнение 2: Тот же вызов — другой результат
total = 0
def add_to_total(price):
global total
total += price
return totalПроблема: каждый вызов add_to_total(10) возвращает другой результат (10, потом 20, потом 30), потому что функция зависит от глобальной переменной и меняет её.
Перепишите функцию как чистую версию с именем add_price(total, price) — она получает текущую сумму как параметр и возвращает новую сумму, без global. Запустите проверочный код, который показывает, что тот же ввод всегда даёт тот же результат:
total = 0
total = add_price(total, 10)
total = add_price(total, 10)
print(total)
print(add_price(0, 10))
print(add_price(0, 10))Ожидаемый вывод:
20
10
10Показать решение
def add_price(total, price):
return total + price
total = 0
total = add_price(total, 10)
total = add_price(total, 10)
print(total)
print(add_price(0, 10))
print(add_price(0, 10))20
10
10Всё, что нужно функции, приходит через параметры, а результат выходит только через return. Вызывающий код решает, что с ним делать.
Упражнение 3: Не меняйте полученный список
TAX = 1.17
def apply_tax(prices):
for i in range(len(prices)):
prices[i] = prices[i] * TAXПроблема: функция меняет полученный список, поэтому после вызова исходные цены теряются.
Напишите чистую версию apply_tax(prices), которая возвращает новый список с ценами с учётом налога (каждая цена округлена до двух знаков) и не трогает полученный список. Запустите проверочный код — первая строка доказывает, что исходный список не изменился:
prices = [100, 50]
with_tax = apply_tax(prices)
print(prices)
print(with_tax)Ожидаемый вывод:
[100, 50]
[117.0, 58.5]Показать решение
TAX = 1.17
def apply_tax(prices):
result = []
for price in prices:
result.append(round(price * TAX, 2))
return result
prices = [100, 50]
with_tax = apply_tax(prices)
print(prices)
print(with_tax)[100, 50]
[117.0, 58.5]TAX — константа, которая не меняется, поэтому её чтение не нарушает чистоту: тот же ввод по-прежнему всегда даёт тот же результат.
Упражнение 4: Отделяем вычисление от печати
def show_total(prices):
total = 0
for price in prices:
total = total + price
print("Total:", total)Функция и вычисляет, и печатает. Разделите её на две:
calculate_total(prices)— чистая: только вычисляет и возвращает сумму;show_total(prices)— вызываетcalculate_totalи печатает результат.
Проверочный код:
show_total([10, 20, 30])Ожидаемый вывод:
Total: 60Показать решение
def calculate_total(prices):
return sum(prices)
def show_total(prices):
print("Total:", calculate_total(prices))
show_total([10, 20, 30])Total: 60Вычисление (calculate_total) чистое, и его легко тестировать. Побочный эффект (print) сосредоточен в одном понятном месте.
Упражнение 5: Тройка лучших — ничего не меняя
Напишите чистую функцию top_three(numbers), которая возвращает три наибольших числа, от большего к меньшему, не меняя исходный список. Напечатайте и исходный список, чтобы доказать, что он не изменился.
scores = [70, 95, 88, 60, 99]
print(top_three(scores))
print(scores)Ожидаемый вывод:
[99, 95, 88]
[70, 95, 88, 60, 99]Показать решение
def top_three(numbers):
return sorted(numbers, reverse=True)[:3]
scores = [70, 95, 88, 60, 99]
print(top_three(scores))
print(scores)[99, 95, 88]
[70, 95, 88, 60, 99]sorted возвращает новый отсортированный список, а срез ([:3]) возвращает ещё один новый список — исходный список не тронут, поэтому функция чистая.
Итоги
- Чистая функция = тот же ввод → всегда тот же результат, и без изменения чего-либо снаружи.
- Побочные эффекты: изменение глобальной переменной, изменение полученного списка или словаря,
print/input, запись в файл или базу данных. - Список передаётся как ссылка — функция, которая его меняет, меняет оригинал. Чистая версия строит новый список.
- Чистые функции легко тестировать, они предсказуемы, и их легко переиспользовать.
- Не всё может быть чистым — отделяйте чистое вычисление от общения с миром.