Неочевидные ловушки Python для новичка

Python часто позиционируют как идеальный язык для начинающих. И это правда, он имеет простой синтаксис, динамическую типизацию и множество библиотек. Но иногда именно эта «простота» иногда играет злую шутку.

На первых порах всё кажется интуитивно понятным. Вы создаете список, добавляете в него элементы, сравниваете значения, делаете копии… Но в какой-то момент код начинает вести себя не так, как вы ожидаете. И в данном случае самое сложное — это понять, что ошибка не в логике, а в том, как Python интерпретирует код.

В этой статье мы разберем четыре «подводных камня», с которыми сталкивается практически каждый новичок. Вы узнаете:

  • почему метод append возвращает None
  • в чем разница между is и ==
  • как сделать копию списка, которая действительно будет копией
  • почему 0.1 + 0.2 не равно 0.3 и как с этим работать

Каждый раздел начинается с примера, затем разбирается причина, а в конце дается готовое решение. Это позволит вам не просто запомнить правильные паттерны, но и понять логику Python на более глубоком уровне.

Ловушка №1. Почему list.append возвращает None?

Ситуация

Вы пишете код, который собирает данные. Нужно добавить элемент в список и сохранить результат в переменную для дальнейшего использования. Выглядит логично:

numbers = [1, 2, 3] result = numbers.append(4) print(result) # None print(numbers) # [1, 2, 3, 4] — а список изменился

Вы ожидали, что result будет содержать обновленный список [1, 2, 3, 4]. Но вместо этого result стал None, а исходный список тайно изменился. Причем код выполнился, но не так, как вы задумали.

Почему так происходит?

Это связано с тем, что в Python есть два типа методов. Их различие в том, как они работают с данными. Их называют мутирующими и немутирующими. Типы методов:

  • Мутирующие методы или in-place методы изменяют исходный объект и возвращают None. Примерами таких методов может быть не только append, но также sort, reverse, add.
  • Немутирующие методы не трогают исходный объект, они возвращают новый. Примерами таких методов могут быть sorted, upper, copy.

Как правильно?

Для предотвращения такой ошибки надо точно знать, что метод работает на месте (in-place) и просто не сохранять результат лишний раз.

numbers = [1, 2, 3] numbers.append(4) # Все, просто добавляем print(numbers) # [1, 2, 3, 4]

Ловушка №2. Чем отличается is от ==?

Ситуация

Взгляните на этот код:

a = [1, 2, 3] b = [1, 2, 3] print(a == b) # True — логично print(a is b) # False — тут могут возникнуть вопросы

Вероятнее всего, новичок будет предполагать, что оба оператора сравнивают содержимое объектов, просто разными способами. Но понятное дело, что это не так.

А вот еще один сюрприз:

x = 256 y = 256 print(x is y) # True

По аналогии с предыдущим примером, тут результат должен быть False, но мы видим True. Это может выглядит, как крайнее несоответствие, но этому есть объяснение.

Почему так происходит?

Перед тем, как объяснить причины таких противоречивых результатов, стоит напомнить то, как работает память в Python.

Во многих языках переменная — это контейнер, в котором лежит значение. В Python же переменная — это ссылка, которая ссылается на объект в памяти.

x = 42 y = x

Итак, в данном примере Python создает объект 42 где-то в памяти и присваивает х его адрес. После Python не копирует объект 42 еще раз, а просто берет уже готовый адрес в х и копирует его в у. Следовательно, х и у ссылаются на один и тот же объект и имеют одинаковый адрес.

Также существуют изменяемые и неизменяемые типы данных:

  • Неизменяемыми типами данных являются int, str, tuple, float. Их название идет от того, что после создание объекта такого типа его нельзя больше изменить, но возможно создать новый.
a = 10 b = a a = a + 1 # Создается новый объект 11 print(b) # 10 – не изменилось
  • Изменяемыми типами данных называют объекты типа list, dict, set. Их уже можно изменять без создания нового объекта.
a = [1, 2, 3] b = a a.append(4) # Изменяем тот же самый объект print(b) # [1, 2, 3, 4] — изменилось, т.к. b и a ссылаются на один объект

Вернемся к операторам is и ==.

  • Оператор == сравнивает значения или содержимое. Два списка с одинаковыми элементами будут равны.
a = [1, 2, 3] b = [1, 2, 3] print(a == b) # True
  • А оператор is сравнивает адрес в памяти. Если у объектов разные адреса, то они различны.
a = [1, 2, 3] b = a print(a is b) # True

А с числом 256 происходит некоторый фокус. Для оптимизации Python кеширует малые целые числа от -5 до 256. Поэтому x и y ссылаются на один и тот же объект в памяти.

Как правильно?

Все довольно просто, оператор is предпочтительней использовать для сравнения с None, True, False и проверки на один и тот же объект.

value = None print(value is None) a = [1, 2, 3] b = a print(a is b) # True

А оператор == использовать для сравнения содержимого:

a = [1, 2, 3] b = a print(a == b) # True user_input = input() print(user_input == "exit")

Ловушка №3. Как правильно копировать списки?

Ситуация

Вам надо поменять пару значений в копии списка original. Вот вы пишете такой код:

original = [1, 2, [3, 4]] copy = original copy[0] = 99 copy[2][0] = 100 print(original) # [99, 2, [100, 4]]

Ожидалось, что original останется нетронутым, ведь зачем еще нужны копии, если не для защиты оригинала? Но в итоге оригинал все таки изменился. Может вы вспомнили или нашли еще один способ копирования списков:

original = [1, 2, [3, 4]] copy = original[:] # или list(original) или flat_list.copy() copy[0] = 99 copy[2][0] = 100 print(original) # [1, 2, [100, 4]]

Но проблема была только частично решена. Число 1 не изменилось, но внутренний список [3, 4] всё равно поменялся.

Почему так происходит?

Как уже ранее было рассмотрено, в Python переменные — это ссылки на объекты в памяти.

Когда вы пишете copy = original — вы копируете ссылку, а не объект. Обе переменные указывают на один и тот же список в памяти. Поэтому при изменении любой переменной список измениться для обоих.

При применении [:] или list() создаётся поверхностная копию (shallow copy). Новый контейнер в памяти создается, но внутрь кладутся те же самые значения и ссылки на объекты, что и в оригинале.

Для плоского списка из чисел или строк этого будет достаточно, потому что числа и строки неизменяемы. Но если внутри списка есть другой список или изменяемый объект, то копируется ссылка на него. Поэтому у оригинала и копии этот внутренний список будет общим.

Как правильно?

В данном случае надо знать какой у вас список. Если он плоский и без изменяемых объектов внутри, то можно делать просто поверхностную копию:

flat_list = [1, 2, 3] copy = flat_list[:] # или list(flat_list), или flat_list.copy() copy[0] = 99 print(flat_list) # [1, 2, 3] — не изменился

Но при наличии вложенных структур надо создавать глубокую копию. Для этого применяется метод deepcopy из библиотеки copy. Кстати одноименный метод в этой библиотеке можно применять для плоских списков. Вот как применяется deepcopy:

import copy original = [1, 2, [3, 4]] deep_copy = copy.deepcopy(original) deep_copy[0] = 99 deep_copy[2][0] = 100 print(original) # [1, 2, [3, 4]] — не изменился print(deep_copy) # [99, 2, [100, 4]]

Для словарей и множеств применяется такой же принцип, как и для списков.

Ловушка №4. Проблемы чисел с плавающей точкой

Ситуация

Рассмотрим некоторые математические несоответствия, с которыми можно столкнуться в Python:

result = 0.1 + 0.2 print(result) # 0.3000000000000004

Тут мы наблюдаем мелкую погрешность в расчете. Можно подумать, что это просто странный вывод и в основном проблема print. Для проверки можно сделать сравнение:

print(0.1 + 0.2 == 0.3) # False

После сравнения мы только получаем подтверждение несоответствия. Но похожая проблема наблюдается не только с арифметическими действиями:

print(round(2.675, 2)) # округляет как 2.67

Очевидно, что по правилам округления 2.675 ≈ 2.68, но мы получаем не тот результат.

Почему так происходит?

Человек мыслит в десятичной системе, а компьютер — в двоичной. Проблема заключается в том, что некоторые числа, которые выглядят круглыми в десятичной системе, в двоичной превращаются в бесконечные дроби.

При записи 1/3 в десятичной системе получится 0.3333… или 0.(3). Компьютер вынужден обрезать это периодическое значение, отсюда и погрешность.

0.1 при переводе в двоичную систему примерно равно 0.000110011… или 0.(00011). Компьютер хранит не точное значение, а приближенное. И понятно, что при применении арифметических действий с приближенными значениями будет неточный результат.

С округлением происходит та же проблема. Число 2.675 тоже не хранится точно. На самом деле оно хранится как примерно 2.67499999999999982236. При округлении до двух знаков получается 2.67, а не 2.68.

Как правильно?

Есть разные варианты для обхода этой проблемы в разных ситуациях. Например для сравнения можно применять метод isclose из библиотеки math. Применение метода:

import math print(0.1 + 0.2 == 0.3) # False print(math.isclose(0.1 + 0.2, 0.3)) # True

Для точных рассчетов можно применять метод Decimal из одноименной библиотеки. Важно отметить, что для данного метода надо передавать значения строкой, иначе float передаст неточное число еще до попадения в сам метод. Применение метода:

from decimal import Decimal result = Decimal('0.1') + Decimal('0.2') print(result) # 0.3 print(result == Decimal('0.3')) # True

Послесловие

Важно отметить, что ошибки, описанные в статье, случаются не потому, что вы плохой программист. Они случаются потому, что Python устроен не так, как интуитивно ожидает человек, привыкший к математике и реальной жизни.

Но эти ловушки конечны. Вы встретите каждую из них один-два раза, потратите пару часов на отладку, на зато запомните навсегда. А после этого сможете не только избегать их сами, но и объяснять другим.

1
1 комментарий