В предыдущих частях мы сравнивали типы только по размеру, и получается, что любые типы это всего лишь манипуляции числами длиной 8, 16, 32, 64 бита.
Но ведь в жизни программировании мы встречаем гораздо больше типов. "Строка", "Массив", "Объект" в конце концов. Если мы практикуем ООП, то постоянно создаем объекты разных классов. Каждый класс считается отдельным типом.
Давайте разъясним этот момент. Типы делятся на "простые" и "сложные". То, что мы обсуждали до сих пор – простые типы. Они четко отображаются на компьютерную архитектуру. Когда переменной присваивают число, оно пишется прямо в адрес, назначенный переменной, и занимает столько байт, сколько заявлено в типе переменной.
Как переменной присваивается строка? Строка – это последовательность байт любой длины. Допустим, тысяча. Как присвоить тысячу байт одной переменной? В коде (допустим, Java) это выглядит просто:
String a = "Hello world ... +1000";
И вроде как оно присвоилось. Потом пишем:
String b = a;
Содержимое переменной a (то есть строка) присвоено переменной b. Вроде все счастливы.
Но как процессор выполнит такую операцию? Ведь он может за один раз прочитать или записать только 1, 2, 4 или 8 байт. А строка гораздо длиннее.
Операции чтения, записи, сложения, вычитания и т.д. можно производить только с теми данными, которые можно прочитать за один раз. Вы, условно говоря, берете их в руки и держите в руках. Если они не помещаются в руках, то операции с ними вести нельзя.
То, что вы можете удержать – и есть простые типы. Вы могли бы обращаться и со строками как с простыми типами, если бы их длина была не больше 8 байт. Собственно говоря, в языке C можно сделать присваивание целочисленной 64-битной 32-битной переменной:
unsigned long a = 'Helo';
Эта 4-символьная строка, как вы уже догадались, есть не более чем 32-битное число, записанное в символьном виде. Но это, конечно, не выход.
Выходом является следующая техника:
Любая структура, будь то строка, массив или объект, сначала подготавливается отдельно. Если строка написана в коде в кавычках, то транслятор переносит её в память заранее. Если массив – он также формируется заранее, либо динамически. Если создаем объект – под него выделяется память и запускается конструктор, который сделает всё, что надо. Это, естественно, потребует не одной операции, а многих. Чтобы подготовить структуру данных, процессору надо работать с ней по частям, порциями, которые он может удержать.
Когда структура готова, её осталось присвоить переменной. Но так как мы не можем присвоить переменной всю структуру, то ей присваивается только адрес, где расположена эта структура. А так как адрес является обыкновенным числом, то и присвоение его абсолютно тривиально.
Теперь делаем:
String a = "Hello world";
Транслятор знает, что эта строка уже заранее создана в памяти. Он её там находит. Допустим, она расположена по адресу 100. Всё, он записал в переменную a число 100.
Как видим, куда солдата ни целуй какой тип ни объявляй, а получается всё равно число. Строковая переменная a эквивалентна целочисленной, в которой хранится просто число 100.
Если теперь повторить
String b = a;
То в переменную b попадет тоже никакая не строка, а то же самое число 100. Теперь и a и b указывают на адрес 100, где находится одна и та же строка.
Поэтому любые сложные типы, хоть строки, хоть массивы, хоть объекты – являются одним и тем же простым типом-указателем. Указатель просто хранит адрес. Всё, под что маскируются указатели – чисто языковые внутренние изобретения, про которые будет позже. Транслятор именно по типу переменной понимает, какая она – простая или указатель. Если она простая, то он совершает операции непосредственно с ней, потому что она помещается в руках. Если она указатель, то операции совершаются не с переменной, а с тем адресом, который в ней записан. И эти операции могут быть весьма затратными.
Например, чтобы скопировать строку, её нельзя просто присвоить куда-то. Нужно во-первых выделить участок памяти для копии, а затем брать из строки столько, сколько можешь унести (по 8 байт например) и переносить в копию, пока не скопируется вся строка. "Бесплатных" операций со сложными типами не существует!
Вернемся к языку C. Что там с указателями?
char * a = "Hello world";
В отличие от других языков, в C всё честно и просто: берем любой тип (char, int, long), дописываем к нему звездочку, и вот уже получился тип-указатель. В примере выше char * a указывает на адрес, где хранится строка. Если бы нам был известен этот адрес, мы могли бы прямо его и написать, приведя к соответствующему типу, чтобы транслятор не ругался:
char * a = (char *) 100;
Вообще говоря, указателям в C плевать, на что они показывают. Присваивайте им любые адреса и берите последствия на себя. Важен, опять же, только размер типа. Он указывает, какими порциями отсчитывать данные от указателя.
Например:
char * a = "Hello world";
char b = a[6];
Запись a[6] означает "прибавь 6 размеров к указателю a". Размер типа char – 1 байт. Значит, к адресу начала строки "Hello world" (допустим, 100) надо прибавить 6 байт. Мы получим адрес 106, где лежит буква "w". Значит, переменной b присвоится 'w'.
А вот если бы переменная a была объявлена как int *, то её размер был бы 2 байта, и соответственно к адресу прибавилось бы не 6, а 12 байт.
И нетрудно заметить, что a[6] выглядит как доступ к массиву. Конечно. Можете считать это строкой, можете считать это массивом, списком, вектором, коллекцией, объектом, да хоть чёртом лысым. Разницы никакой нет. Перед вами сияющая истина программирования. Это просто указатель, и просто смещение. Берите там столько, сколько сможете удержать!
А в заключительной части мы обсудим сложные типы, которые придуманы в "высокоразвитых" языках, насколько они полезны или вредны, и как с ними бороться, в случае чего.