Всем привет! Меня зовут Дима. Я являюсь Backend Python Developer'ом. Хочу оставить здесь скомпонованную информацию, которой когда-то давно не хватало мне. А именно, расскажу Вам про основные типы данных в Python, как они устроены и в чём их отличие.

Оглавление

  1. Что за язык такой, этот Python?

  2. Как связана динамическая типизация и куча?

  3. Что такое тип данных?

  4. Виды типов данных.

  5. Как устроены неизменяемые типы данных?

  6. Как устроены изменяемые типы данных?

  7. Почему не стоит использовать изменяемые объекты как параметры по умолчанию?

  8. Итог

Что за язык такой, этот Python?

Далеко не секрет, что Python - это объектно-ориентированный язык программирования со строгой динамической типизацией.

Под «строгой» подразумевается, что язык не производит неявные преобразования типов и не создаёт проблем при их случайном смешении.

Под «динамической» подразумевается, что типы объектов определяются в процессе исполнения программы (runtime). Поэтому типы переменных указывать не обязательно, но не сказал бы я, что это хороший тон. Переменные в Python - это всего лишь указатели на объекты, они не содержат информации о типе.

На пункте о динамичности хочу сделать акцент (см. ниже).

Как связана динамическая типизация и куча?

Куча (Heap) - это хранилище памяти в ОЗУ, которое допускает динамическое выделение памяти, подобие склада для переменных. При выделении в куче участка памяти для хранения переменной, к ней можно обратиться не только в потоке, но и во всём приложении (так определяются глобальные переменные). По завершении работы приложения все выделенные участки памяти освобождаются.

Размер кучи устанавливается при запуске приложения (процесса) и ограничен лишь физически, что позволяет создавать динамические переменные. Также появляется возможность определять и изменять тип переменных во время выполнения программы.

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

По факту данной областью управляет интерпретатор. Пользователь никак не контролирует данный процесс, даже когда манипулирует ссылками объектов на блоки памяти внутри динамической области. Менеджер памяти Python распределяет пространство динамической области среди объектов и другие внутренние буферы по требованию.

Теперь можно сказать о типах данных (см. ниже).

Что такое тип данных?

Тип данных - это атрибут, определяющий, какого рода данные могут храниться в объекте. Это могут быть целые числа, символы, данные денежного типа, метки времени и даты, двоичные строки и так далее.

Выделим основные (и не только) типы данных (см. ниже).

Виды типов данных

  1. Неизменяемые (немутабельные, immutable) типы данных: None, bool, int, float, complex, str, tuple. Также bytes и frozenset;

  2. Изменяемые (мутабельные, muttable) типы данных: list, dict, set. Также байтовый массив bytearray;

Тип данных

Описание

None

экземпляр типа объекта NoneType и особая переменная, которая не имеет целевого значения

bool

булевы значения (True, False)

int

представление целых чисел, как положительных, так и отрицательных

float

числа, которые могут иметь десятичную часть (с плавающей точкой)

complex

комплексные числа

str

текстовая информация (строка, последовательность символов)

tuple

неизменяемые упорядоченные коллекции элементов (кортежи)

bytes

байтовые последовательности, которые используются для работы с бинарными файлами

frozenset

функция, которая возвращает неизменяемый объект frozenset, инициализированный элементами из заданного итерируемого объекта

list

изменяемые упорядоченные коллекции элементов (списки)

dict

ассоциативный массив, пары «ключ-значение», где каждый ключ является уникальным

set

неупорядоченная и неиндексированная коллекция уникальных элементов

bytearray

массив заданных байтов

Как устроены неизменяемые типы данных?

Рассмотрим пример с неизменяемыми типами данных и функции id.

Функция id() позволяет получить уникальный целочисленный идентификатор объекта (его адрес в памяти).

Проверяем, что переменные ссылаются на одну ячейку в памяти.

def test_heap_function() -> None:
	a = 100
	b = a
	print(f"a: id({id(a)})")
	print(f"b: id({id(b)})")

test_heap_function()
# a: id(140088361431424)
# b: id(140088361431424)

В случае использования стандартного оператора присваивания = или копирования copy происходит копирование ссылки на объект. В результате получается, что две переменные будут ссылаться на одну и туже ячейку в памяти (см. изображение 01).

01. Разные переменные с одинаковым значением ссылаются на одну ячейку в памяти;
01. Разные переменные с одинаковым значением ссылаются на одну ячейку в памяти;

Если мы попытаемся переменной a присвоить другое значение (в данном случае посредствам инкрементирования значения), то после операции += переменная a будет ссылаться на другой объект в памяти.

Проверяем, что переменные ссылаются на разные ячейки в памяти.

def test_heap_function() -> None:
	a = 100
	b = a
	print(f"a: id({id(a)})")
	print(f"b: id({id(b)})")
	a += 1
	print(f"a: id({id(a)})")

test_heap_function()
# a: id(140088361431424)
# b: id(140088361431424)
# a: id(140088361431456)

При присвоении одной из переменных другого значения переменная станет ссылаться на другую ячейку в памяти (см. изображение 02). В результате в памяти создаётся новая ячейка со значением 101, на которую переменная a будет ссылаться.

02. Попытка изменить значение неизменяемого объекта;
02. Попытка изменить значение неизменяемого объекта;

Как устроены изменяемые типы данных?

Рассмотрим пример с изменяемыми типами данных и функции id.

def test_heap_function() -> None:
	list_a = [100]
	list_b = list_a
	print(f"list_a: id({id(list_a)})")
	print(f"list_b: id({id(list_b)})")
	list_a.append(101)
	print(f"list_a: id({id(list_a)})")
	print(f"list_b: id({id(list_b)})")

test_heap_function()
# list_a: id(140088361431311)
# list_b: id(140088361431311)
# list_a: id(140088361431311)
# list_b: id(140088361431311)

Две переменные ссылаются на один и тот же список. Оператор присваивания = одинаково работает как с неизменяемыми, так и с изменяемыми типами данных.

03. Разные переменные с одинаковым значением ссылаются на одну ячейку в памяти;
03. Разные переменные с одинаковым значением ссылаются на одну ячейку в памяти;

Суть здесь в том, что при попытке добавления элемента в изменяемый список не будет создан новый объект, а лишь произойдёт дополнение текущего. А именно, в текущем списке появится ещё одна ссылка на объект с значением 101.

04. Изменяемые типы данных ссылаются на один и тот же объект (например, при добавлении нового элемента в список ссылка останется та же);
04. Изменяемые типы данных ссылаются на один и тот же объект (например, при добавлении нового элемента в список ссылка останется та же);

Получается, что в случае с изменяемым типом данных, мы будем постоянно работать с одним объектом. Объект, в таком случае, мы можем модифицировать по любой из существующих ссылок. И по любой ссылке мы всегда будем получать актуальное состояние этого объекта.

Обратите внимание!

Копирование и глубокое копирование. Здесь стоит уточнить, что при использовании copy и deepcopy из модуля copy указанный выше пример работать не будет. Так как при использовании copy создастся копия объекта со всеми его ссылками на внутренние объекты. А при использовании deepcopy создастся новый объект со всеми вложенными ссылками независимо от объекта, с которого он был скопирован.

Тема не совсем большая, поэтому стоит её изучить как можно быстрее :0

Ещё немного про изменяемые типы данных. Их не стоит (если Вы не уверены и не знаете как это правильно работает) использовать как параметры функции по умолчанию (см. ниже).

Почему не стоит использовать изменяемые объекты как параметры по умолчанию?

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

  1. Значения по умолчанию вычисляются 1 раз при определении функции, а не при каждом вызове;

  2. Если использовать изменяемый объект (список, словарь), то изменения в нём будут сохраняться между вызовами функции. Это может привести к неочевидному поведению и трудноуловимым ошибкам при многократном вызове функции;

В качестве альтернативы можно задавать значение по умолчанию None, в теле функции создавать новый изменяемый объект, если значение не передано. Такое решение делает поведение программы понятным и предсказуемым.

Рассмотри, как это работает на практике.

# Плохой пример
def test_function_one(listing=[]) -> None:
    listing.append(1)
    print(listing)

test_function_one()
# [1]
test_function_one()
# [1, 1]
test_function_one([8, 3, 6])
# [8, 3, 6, 1]
test_function_one()
# [1, 1, 1]

# Хороший пример
def test_function_two(listing=None) -> None:
    if listing is None:
        listing = []
    listing.append(1)
    print(listing)

test_function_two()
# [1]
test_function_two()
# [1]
test_function_two([8, 3, 6])
# [8, 3, 6, 1]
test_function_two()
# [1]

На этой ноте стоит подвести итоги, так как на мой взгляд тема раскрыта в полном объёме (см. ниже).

Итог

В общем, вот всё то, что я хотел рассказать. Материал достаточно глубокий, но я постарался достаточно доходчиво и в небольшом объёме донести суть изменяемых и неизменяемых типов данных, как они устроены и как с ними работать. Спасибо.

Комментарии (1)


  1. evgenyk
    01.07.2024 13:27

    Размер кучи устанавливается при запуске приложения (процесса) и ограничен лишь физически

    Поправка: размер памяти кучи ограничен размером памяти доступной для процесса за вычетом памяти, испольуемой для других нужд. Скажем для 32 битной ОС рамер памяти процесса будет ИМХО 4 гига.