Чистые функции и побочные эффекты

Цели урока
  • Объяснить, что такое чистая функция (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

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

Объяснение с основ

Что такое чистая функция

Чистая функция удовлетворяет двум условиям:

  1. Тот же ввод → всегда тот же результат. Результат зависит только от параметров — не от глобальных переменных, не от времени, не от ввода пользователя.
  2. Без побочных эффектов. Она ничего не меняет вне себя. Всё, что она «выдаёт», выходит через 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, запись в файл или базу данных.
  • Список передаётся как ссылка — функция, которая его меняет, меняет оригинал. Чистая версия строит новый список.
  • Чистые функции легко тестировать, они предсказуемы, и их легко переиспользовать.
  • Не всё может быть чистым — отделяйте чистое вычисление от общения с миром.

Официальная документация