Булевы выражения и короткое замыкание

Урок о том, как обычное if превращается в булеву функцию и почему and и or вычисляются лениво.

Булево выражение — выражение, значение которого есть истина или ложь; в Python это объекты True и False, а условие в if — это просто такое выражение, по результату которого ветвится программа.

В алгебре логики мы строили таблицы истинности для функций от переменных $A$, $B$, $C$. В программировании ровно те же функции прячутся в условиях. Когда вы пишете if age >= 18 and has_ticket:, вы задаёте булеву функцию двух переменных и спрашиваете у неё одно значение — для текущего набора данных. Понимать это важно: если вы умеете упрощать формулу на бумаге, вы умеете упрощать и условие в коде.

Зачем это нужно

Кривое условие — самая частая логическая ошибка новичка. Программа компилируется, запускается, но пускает не тех или считает не то. Поэтому условие нужно читать как формулу: какие переменные входят, в каком порядке применяются связки, что вернётся при каждом наборе. А ещё связки and и or в большинстве языков ленивые — это и оптимизация, и приём защиты от ошибок, который надо осознанно использовать.

Условие как булева функция

Любое сравнение (==, !=, <, >=) возвращает True или False. Связки соединяют такие значения: $\land$ — это and, $\lor$ — это or, $\neg$ — это not. Условие допуска к экзамену

$$ D = (age \ge 18) \land has\_ticket $$

истинно ровно в одном случае из четырёх возможных комбинаций двух входов — в точности как строка таблицы истинности конъюнкции.

def допуск(age, has_ticket):
    return age >= 18 and has_ticket

for age in (16, 20):
    for ticket in (False, True):
        print(age, ticket, "->", допуск(age, ticket))

Вывод:

16 False -> False
16 True -> False
20 False -> False
20 True -> True

Четыре строки — это и есть таблица истинности функции допуск. Единственная истина внизу: совершеннолетний и с билетом.

Порядок: not, потом and, потом or

У связок разный приоритет. Сильнее всех связывает not ($\neg$), затем and ($\land$), слабее всех or ($\lor$). То есть выражение

$$ \neg A \lor B \land C $$

читается как $(\neg A) \lor (B \land C)$, а вовсе не как $\neg(A \lor B) \land C$. Это прямой источник багов: программист думал одно, машина прочла другое. Проверим на числах, где приоритет меняет ответ.

A, B, C = True, False, True

print("без скобок :", not A or B and C)
print("как читает Python:", (not A) or (B and C))
print("а казалось бы :", not (A or B) and C)

Вывод:

без скобок : False
как читает Python: False
а казалось бы : False

Здесь значения совпали, но это везение конкретного набора. Возьмём $A$ ложь:

A, B, C = False, False, True
print("как читает Python:", (not A) or (B and C))  # True
print("а казалось бы :", not (A or B) and C)        # True
A, B, C = True, True, False
print("как читает Python:", (not A) or (B and C))  # False
print("а казалось бы :", not (A or B) and C)        # False... но логика разная

Вывод:

как читает Python: True
а казалось бы : True
как читает Python: False
а казалось бы : False

Правило простое: не полагайтесь на приоритет в спорных местах — ставьте скобки. Скобки бесплатны, а отладка дорогая.

Короткое замыкание (ленивое вычисление)

Главная особенность and и or — они вычисляют второй операнд не всегда. Для конъюнкции: если левый операнд ложь, результат уже ложь ($0 \land x = 0$), и правый не считается. Для дизъюнкции наоборот: если левый истина, результат уже истина ($1 \lor x = 1$), правый пропускается. Это и есть короткое замыкание.

$$ \text{False} \land f() = \text{False}, \qquad \text{True} \lor f() = \text{True} $$

Проверим, что правый операнд действительно не вызывается. Пусть функция при вызове печатает метку:

def дорого():
    print("  дорогая функция выполнилась")
    return True

print("Случай 1: False and дорого()")
x = False and дорого()
print("  результат:", x)

print("Случай 2: True or дорого()")
y = True or дорого()
print("  результат:", y)

Вывод:

Случай 1: False and дорого()
  результат: False
Случай 2: True or дорого()
  результат: True

Обратите внимание: строка «дорогая функция выполнилась» не напечаталась ни разу — в обоих случаях правый операнд был пропущен.

Зачем это на практике

Ленивость превращается в приём безопасности. Классика — проверка перед использованием:

data = {"name": "Аня"}
key = "age"

# Сначала убеждаемся, что ключ есть, и только потом читаем значение
if key in data and data[key] > 18:
    print("взрослый")
else:
    print("нет данных или несовершеннолетний")

Вывод:

нет данных или несовершеннолетний

Если бы and вычислял оба операнда всегда, вторая часть data[key] при отсутствии ключа уронила бы программу с ошибкой KeyError. Короткое замыкание гарантирует: до правой части дело дойдёт, только если левая истинна. Поэтому порядок операндов важен — «сначала проверка, потом использование».

Как это работает

Под капотом интерпретатор не вычисляет and/or как функцию двух готовых аргументов. Он генерирует условный переход: вычислил левый операнд, посмотрел на его истинность и решил, прыгать ли к вычислению правого. Это похоже на тернарный выбор: a and b эквивалентно «если a ложно — верни a, иначе верни b». Именно поэтому в Python and/or возвращают не обязательно True/False, а сам объект-операнд: 0 or "abc" вернёт строку "abc", а "" and 5 вернёт пустую строку. В булевом контексте (в if) это всё равно работает как логика, потому что у каждого объекта есть истинностное значение.

Частые ошибки

  • Перепутан приоритет and/or. a or b and c — это a or (b and c). Если имелось в виду другое — ставьте скобки.
  • Использование значения до проверки. data[key] > 0 and key in data — порядок перевёрнут, короткое замыкание не спасёт, будет KeyError. Проверку ставьте слева.
  • Двойное сравнение цепочкой ошибочно «оптимизируют». Удобная запись 0 <= x <= 100 в Python корректна, но 0 <= x and <= 100 — синтаксическая ошибка; правый операнд and должен быть полным выражением.
  • Путают = и ==. В условии нужно сравнение ==; присваивание = там — частый источник логических ошибок (в Python это просто синтаксическая ошибка, и хорошо).

Итоги

  • Условие в if — это булева функция; его можно читать и упрощать как формулу с $\land$, $\lor$, $\neg$.
  • Приоритет: сначала not, потом and, потом or; в спорных местах ставьте скобки.
  • Короткое замыкание: and не считает правый операнд при ложном левом, or — при истинном левом.
  • Ленивость — это приём защиты: ставьте проверку существования слева, использование значения справа.
Проверьте себя
1. В Python вычисляется выражение False and f(), где f() печатает сообщение и возвращает True. Что произойдёт?
Af() не вызовется, всё выражение равно False
Bf() вызовется, выражение равно True
Cf() вызовется, выражение равно False
DБудет ошибка, нельзя смешивать bool и вызов функции
2. Как Python прочитает выражение not A or B and C (по приоритету операций)?
Anot (A or (B and C))
B(not A) or (B and C)
C((not A) or B) and C
Dnot ((A or B) and C)