Динамическая типизация#

Следующий код python исполняется без ошибок.

a = 0               # a - переменная типа int
print(a, type(a))
a = 0.0             # теперь a - переменная типа float
print(a, type(a))
a = "zero"          # теперь a - переменная типа str
print(a, type(a))
a = [0, 0.0, "zero"] # теперь a - переменная типа list
print(a, type(a))
0 <class 'int'>
0.0 <class 'float'>
zero <class 'str'>
[0, 0.0, 'zero'] <class 'list'>

Кажется, что переменная a меняет свой тип 3 раза. Как это возможно? Прежде чем ответить на этот вопрос, освежим в памяти, как устроена типизация в C/C++, и разберемся, почему это изначально может смущать.

Note

Функция type спрашивает у объекта его тип.

Статическая типизация в C++#

Чтобы разобрать, как устроена типизация в C/C++, рассмотрим следующий код на языке C/C++.

#include<iostream>

#define FIRSTBYTE   1
#define SECONDBYTE  256
#define THIRDBYTE   256*256
#define FOURTHBYTE  256*256*256


int main(){
	// 1
	int a = 65*FIRSTBYTE + 66*SECONDBYTE + 67*THIRDBYTE + 68*FOURTHBYTE;
	std::cout << a << std::endl;                // 1145258561

	// 2
	char *b = reinterpret_cast<char*>(&a);
	// 3
	for(size_t i=0; i<4; ++i)
		std::cout << b[i];                      // ABCD
	std::cout << std::endl;

	// 4
	a = 'A';
	std::cout << a;                             // 65

	return 0;
}

Если его скомпилировать и запустить, то вы должны получить следующее в стандартном потоке вывода.

1145258561
ABCD
65

Note

Приведенный выше код опирается на предположение, что в системе целочисленный тип int занимает 4 байта, что не гарантируется стандартами C++.

Разберем по шагам, что происходит в этой программе.

// 1#

В начале объявляется целочисленная переменная a типа int, которая инициализируется значением \(65 \cdot 256^0 + 66 \cdot 256^1 + 67 \cdot 256^{2} + 68 \cdot 256^{3} = 1145258561\).

// 1
int a = 65*FIRSTBYTE + 66*SECONDBYTE + 67*THIRDBYTE + 68*FOURTHBYTE;
std::cout << a << std::endl;                // 1145258561

В момент объявления выделяется достаточная для хранения переменной типа int (4 байта на большинстве платформ) область оперативной памяти, и переменная a связывается с этой областью памяти. Во момент инициализации в эту область памяти записывается число 1145258561. Это число намерено задано в разложении по степеням числа \(256\), чтобы явно выделить байты этого числа: 65, 66, 67 и 68 (см. статью в википедии про порядок байтов, если непонятно о чем идёт речь).

// 2#

Далее объявляется указатель b на символьную переменную типа char, который инициализируется адресом памяти переменной a.

// 2
char *b = reinterpret_cast<char*>(&a);

Чтобы провернуть такой трюк, необходимо сначала поменять тип указателя, т.к. переменная a имеет 32-битный целочисленный тип int, а переменная b является указателем 8-битного типа char (который формально тоже является целочисленным). Компилятор запретил бы такую операцию из-за несоответствия типов (и невозможности их приведения друг к другу безопасным образом), поэтому в коде используется reinterpret_cast, который вынуждает компилятор привести тип указателя.

Warning

Такие возможности языка C/C++ приводятся здесь только с целью демонстрации механизма работы переменных в C/C++. В настоящих программах рекомендуется избегать таких приёмов.

// 3#

Далее в цикле выводятся значения b[0], b[1], b[2] и b[3].

// 3
for(size_t i=0; i<4; ++i)
	std::cout << b[i];
std::cout << std::endl;

В результате видим строку ABCD. Чтобы разобраться с тем, как это произошло, необходимо учесть

  • b — указатель, который хранит в себе адрес переменной a;

  • однако b — указатель 8-битного типа, а значит при его разыменовании (b[0], что то же самое, что и *b) захватывается область размером всего в 1 байт.

  • b[i] эквивалентно *(b + i) , т.е. то же самое, что и b[0], но на i байт правее.

  • т.к. в и тоге выводится символьная переменная, то она выводится согласно таблице ASCII, в которой 65 соответствует символу “A”, 66 — символу “B”, 67 — символу “C”, 68 — символу “D”.

Следующий рисунок иллюстрирует примерное устройство памяти в этой программе на этот момент времени.

../../_images/cpp.png

// 4#

Далее в переменную a записывается значение “'A'”;

// 4
a = 'A';
std::cout << a; 

Из-за того, что C/C++ — статически типизированный язык, то компилятор знает, что a — переменная типа int (с таким типом она была объявлена), и A — значение типа char, которое может быть без потерь приведено к int (8 бит меньше 32 бит). Исходя из этой информации, компилятор допускает эту операцию, неявно приводя символьное значение 'A' к целочисленному значению 65 и записывая в соответствующую переменной a область памяти это значение.

Итого#

Итого, опуская технические подробности:

  • переменная соответствует области в памяти;

  • у переменной всегда есть определенный тип, он указывается при объявлении переменной и известен на этапе компиляции;

  • в самой области памяти нет информации о том, как правильно прочитать последовательность бит, которую она содержит;

  • при обращении к переменной, её значение получается в результате интерпретации последовательности бит в соответствующей области памяти на основании типа этой переменной;

  • технически возможно прочитать одну и ту же область памяти по-разному, обращаясь к ней переменными разных типов;

  • оператор присвоения “=” записывает значение справа от него в область памяти, именованную переменной слева от него с предварительной проверкой совместимости типов.

Динамическая типизация в python#

В python тип хранится не в переменной, а в самом объекте, а сама переменная всего лишь ссылается на объект (в python всё объекты), ничего не подозревая о типе этого самого объекта. На самом деле переменная в CPython представляет собой обертку над указателем C типа void, который всегда автоматически разыменовывается, а оператор присвоения “=” связывает переменную слева от него с объектом справа от него (изменяет значение указателя).

Note

В связи с этим, иногда термин “переменная” (variable) избегается в отношении python и заменяется терминами “имя” (name), “ссылка” (reference) или идентификатор.

Note

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

Вернемся к примеру из начала раздела и объясним, как он работает.

a = 0               # a - переменная типа int
print(a, type(a))
a = 0.              # теперь a - переменная типа float
print(a, type(a))
a = "zero"          # теперь a - переменная типа str
print(a, type(a))
a = [0, 0., "zero"] # теперь a - переменная типа list
print(a, type(a))

# 1#

Первая инструкция a = 0.

  1. Сначала вычисляется значение выражения справа: создается объект целочисленного типа int содержащий в себе значение 0.

  2. Так как имени a до этого объявлено не было, то такое имя создаётся и связывается с объектом справа.

a = 0               # a - переменная типа int
print(a, type(a))
0 <class 'int'>
../../_images/a_int.png

# 2#

Вторая инструкция a = 0.0.

  1. Создаётся объект типа float, содержащий значение 0.0.

  2. Так как имя a уже существует, то оно связывается с новым объектом.

a = 0.0            # теперь a - переменная типа float
print(a, type(a))
0.0 <class 'float'>
../../_images/a_float.png

# 3#

Третья инструкция a="zero".

  1. Создаётся объект строкового типа str, содержащий значение "zero".

  2. Существующее имя a связывается с новым объектом, который имеет другой тип.

a = "zero"          # теперь a - переменная типа str
print(a, type(a))
zero <class 'str'>
../../_images/a_str.png

# 4#

Четвертая инструкция a = [0, 0.0, "zero"].

a = [0, 0.0, "zero"] # теперь a - переменная типа list
print(a, type(a))
[0, 0.0, 'zero'] <class 'list'>

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

  1. Создаётся три объекта: целочисленный объект 0, действительнозначный объект 0.0 и строковый объект "zero".

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

  3. Существующее имя a связывается связывается с новым объектом, который имеет другой тип.

../../_images/a_list.png

Note

Иногда говорят, что тип объекта “живёт” в самом объекте, а не в “переменной”.

Итого#

Итого, опуская технические подробности:

  • вместо переменных в python имена;

  • у имени нет типа;

  • имя всегда ссылается на какой-то объект (не бывает неинициализированных имён), внутри которого кроме полезных данных хранится служебная информация о типе объекта и т.п.;

  • оператор присвоения “=” связывает имя слева от него с объектом с права от него: если такого имени ещё нет, то оно создаётся, если такое имя уже существует (связанно с каким-то объектом), то ссылка перекидывается на новый объект.