Половина «странных» ошибок в скриптах загрузки и половина вопросов на собеседовании по Python сводятся к одной модели: в Python нет переменных-коробок, в которых лежат значения. Есть объекты в памяти и имена, которые на них ссылаются. Пока эта модель не в голове, код вроде b = a; b.append(4) ведёт себя как магия.
Всё объект, имя это ссылка
Каждое значение в Python это объект с тремя свойствами: идентичность (адрес, который возвращает id()), тип и содержимое. Присваивание не копирует объект, а привязывает имя к нему.
a = [1, 2, 3]
b = a
b.append(4)
print(a) # [1, 2, 3, 4]
print(a is b) # Truea и b это два имени одного списка. Метод append изменил объект, а не «переменную b», поэтому через a виден тот же результат. Это вопрос номер один на собеседованиях, и правильный ответ не «[1, 2, 3, 4]», а объяснение: присваивание создаёт вторую ссылку, а не второй список.
То же самое происходит при передаче в функцию, при добавлении в другой список, при сохранении в словарь. Объект один, ссылок на него много.
rows = []
row = {"id": 1}
rows.append(row)
row["id"] = 2
print(rows) # [{'id': 2}]== и is
== сравнивает содержимое, is сравнивает идентичность: один ли это объект в памяти.
x = [1, 2]
y = [1, 2]
print(x == y) # True: содержимое одинаковое
print(x is y) # False: два разных спискаis нужен ровно в двух случаях: сравнение с одиночками None, True, False (if value is None) и проверка, что две ссылки ведут на один объект. Для всего остального ==.
Классическая ловушка: is с числами и строками иногда «работает».
a = 256
b = 256
print(a is b) # True
a = 1000
b = 1000
print(a is b) # зависит от того, как выполнялся кодCPython держит готовые объекты для целых от −5 до 256 и для коротких строк-идентификаторов, поэтому is для них случайно даёт True. Это деталь реализации, а не правило языка. Сравнение значений через is это ошибка, даже когда тест проходит.
Изменяемые и неизменяемые типы
Объекты делятся на два вида. Изменяемые можно менять на месте: list, dict, set, bytearray, экземпляры своих классов. Неизменяемые нельзя: int, float, str, bytes, tuple, frozenset, bool, None.
У неизменяемого объекта любая «модификация» создаёт новый объект, а имя перепривязывается:
s = "abc"
t = s
s += "d"
print(s, t) # abcd abc
print(s is t) # Falses += "d" не изменил строку (строки не меняются), а создал новую и привязал к ней имя s. Имя t осталось на старой. Со списком тот же код ведёт себя иначе:
s = [1, 2]
t = s
s += [3]
print(s, t) # [1, 2, 3] [1, 2, 3]
print(s is t) # TrueДля списка += вызывает extend и меняет объект на месте. Один и тот же оператор, два разных поведения, и объясняются они только изменяемостью типа.
Кортеж неизменяем, но хранит ссылки. Если внутри кортежа список, список по-прежнему можно изменить:
t = (1, [2, 3])
t[1].append(4)
print(t) # (1, [2, 3, 4])Неизменяемость кортежа означает, что нельзя заменить его элементы, а не что содержимое элементов заморожено.
Копирование
Если нужен независимый объект, его надо скопировать явно. Способов три, и они различаются глубиной.
import copy
rows = [{"id": 1, "tags": ["a"]}]
shallow = list(rows) # или rows[:], или rows.copy()
deep = copy.deepcopy(rows)
rows[0]["tags"].append("b")
print(shallow[0]["tags"]) # ['a', 'b']: словарь внутри общий
print(deep[0]["tags"]) # ['a']: скопировано всё деревоПоверхностная копия создаёт новый список, но элементы в нём те же объекты. Для списка чисел или строк этого достаточно: их всё равно нельзя изменить. Для списка словарей, который вы собираетесь править, нужен deepcopy или сборка новых словарей.
В пайплайнах эта разница всплывает при «снимке» конфига или батча: функция получила список записей, решила его «почистить» и заодно изменила записи у вызывающего кода. Правило: функция либо возвращает новый объект, либо меняет переданный, и это написано в её имени или документации. Делать и то и другое нельзя.
int без переполнения
int в Python это объект произвольной длины: переполнения нет, 2 ** 100 считается точно. Цена этого в памяти и скорости: каждое число это объект размером от 28 байт, а не 8 байт в регистре процессора.
import sys
print(2 ** 100) # 1267650600228229401496703205376
print(sys.getsizeof(0)) # 28
print(sys.getsizeof(2 ** 100)) # 40Поэтому миллион чисел в списке Python занимают в разы больше памяти, чем те же числа в массиве NumPy или колонке pandas, где они лежат плотно, по 8 байт. Отсюда и ответ на вопрос «почему для больших данных Python использует библиотеки на C»: не потому что циклы в Python медленные сами по себе, а потому что каждый элемент это отдельный объект с заголовком.
Деньги в float хранить нельзя: 0.1 + 0.2 не равно 0.3. Для сумм, которые сверяются с бухгалтерией, нужен decimal.Decimal или целые копейки.
Типизация: динамическая, но строгая
Тип привязан к объекту, а не к имени: одно имя может по очереди ссылаться на число, строку и список. Это динамическая типизация. При этом Python не приводит типы молча: "1" + 1 это TypeError, а не "11" и не 2. Это строгая типизация. JavaScript, для сравнения, динамический и нестрогий.
Аннотации типов (def load(rows: list[dict]) -> int) ничего не проверяют во время выполнения. Они нужны редактору, mypy и людям, которые читают код. Если в функцию с аннотацией int передать строку, Python её не остановит.
Практическое следствие для инженера данных: проверять типы на границе, где данные входят в программу (файл, API, база), а не надеяться на аннотации. Об этом урок про исключения.
Как отвечать на собеседовании
Что выведет a = [1,2,3]; b = a; b.append(4); print(a)? [1, 2, 3, 4]: b = a создаёт вторую ссылку на тот же список, append меняет объект, и через a видно то же самое.
Чем == отличается от is? == сравнивает значения, is проверяет, что это один объект в памяти. is используется для None и для проверки идентичности, для сравнения чисел и строк он некорректен, даже если иногда возвращает True из-за кэша маленьких целых.
Назовите изменяемые и неизменяемые объекты. Изменяемые: list, dict, set, bytearray, объекты классов. Неизменяемые: int, float, str, bytes, tuple, frozenset, bool, None. Кортеж неизменяем, но элементы внутри него могут быть изменяемыми.
Что представляет из себя int? Объект произвольной длины без переполнения, от 28 байт в памяти. Маленькие целые от −5 до 256 кэшируются интерпретатором.
Какая типизация в Python? Динамическая и строгая: тип у объекта, а не у имени, неявных приведений между несовместимыми типами нет. Аннотации типов не проверяются во время выполнения.
Практика
Функция with_defaults(row, defaults) должна вернуть новый словарь: все пары из defaults, поверх них пары из row. Исходные row и defaults меняться не должны.
Заготовка меняет defaults на месте: после первого вызова значения из row остаются в defaults и портят следующие вызовы.
| Ввод | Ваш вывод | Ожидаемый вывод | |
|---|---|---|---|
| d = {"city": "-", "segment": "new"} print(with_defaults({"city": "Казань"}, d)) print(d) | — | {'city': 'Казань', 'segment': 'new'} {'city': '-', 'segment': 'new'} | |
| d = {"a": 1} r = {"a": 2} out = with_defaults(r, d) print(out is d, out is r, out) | — | False False {'a': 2} | |
| скрытый тест 3 | |||