Видео: YouTube
Мы продолжаем копаться в недрах объектно-ориентированного программирования. В прошлый раз были рассмотрены структуры. Это синтаксическая конструкция позволяющая объединить под одним именем данные разных типов. На этот раз поговорим об указателях.
Просто об указателях.
Для того чтобы понять мощь этого средства рассмотрим простой и наглядный пример. На складе лежат три кирпичных стопки.
Кладовщик знает, что забирать их нужно в порядке возрастания цифр на центральных кирпичах. Водитель погрузчика не посмотрит на номер, написанный на центральном кирпиче и загрузит по порядку, сначала левую стопку. Нужно написать подсказку, в которой указать порядок загрузки стопок. Для этого берем три кирпича и пишем на них номера стопок.
Потом выкладываем эти три кирпича в порядке погрузки стопок.
Сейчас не перемещались тяжелые стопки кирпичей. Перемещались только записки о том, в каком порядке отправлять стопки на погрузку. Как вы понимаете, масса стопок и кирпичей это отсылка к количеству данных, которые при перемещении должны будут пройти через регистры процессора. Стопки кирпичей это большие структуры. Отдельные кирпичи — записки это переменные, указывающие на структуры.
Операция взятия адреса переменной.
Немного вспомним устройство простейшего процессора архитектуры фон-Неймана. В общей памяти хранятся программы и данные. Выборка машинных инструкций и данных происходит по очереди. При выборке данных из памяти большую роль играет указатель стека. Так называемый stack pointer (SP).
При организации вызова функции, в специальную область памяти помещаются параметры и локальные переменные. Содержимое регистра SP при этом указывает на самый нижний элемент в стековом фрейме функции. Подробнее об этом можно узнать в выпуске про функции. Теперь про сами указатели. Первым делом это такая же переменная, как и другие. Тонкость в том, что в ней хранится адрес какой-либо ячейки памяти.
На простой программе посмотрим как это происходит.
В функции main определены переменная равная 10, массив из пяти элементов. Кроме того, еще два указателя на переменные типа char. Синтаксически записывается все просто. Главное не забыть звездочку между типом и именем указателя. Подготовка функции к работе включает в себя в первую очередь размещение указателя стека на какую либо из ячеек.
В этом примере в содержимое регистра SP заносится 255. Начиная с этой вершины в глубину стека помещается число 10. Потом размещается массив, начиная с последнего элемента, нулевой элемент при этом окажется по меньшему адресу. При размещении в стеке переменных, хранящих адреса в программе, нет конкретного указания о содержимом этих переменных. Можно просто уменьшить содержимое SP на 2. На то количество байт, содержимое которых заранее неизвестно. На этом подготовка к запуску функции закончена.
Первым действием в указатель p1 заносится адрес переменной variable. Синтаксически это почти как присвоение, только нужно не забыть про знак & (амперсанд) впереди имени переменной. Технически здесь происходит примерно вот что.
В регистре R1 будет вычислен адрес переменной variable. Первым делом очищается его содержимое командой загрузки числа в регистр. В регистр R2 заносится смещение переменной variable относительно указателя стека. Смещение равно 7. Третья инструкция добавляет в R1 содержимое указателя стека. К этому потом добавляется смещение, что в совокупности даст полный адрес ячейки с переменной. Этот адрес необходимо поместить в ячейку со смещением 1 от указателя стека. Поэтому в R2 заносится 1. Далее команда сохранения содержимого R1 в нужной ячейке памяти.
Теперь указатель p1 содержит адрес переменной variable. Чтобы в указателе p2 разместить адрес нулевого элемента массива проделываются те же действия. Обнуляется R1. Потом в R2 смещение, в R1 указатель стека (SP). Складываем, получая полный адрес, потом размещаем его в нужной ячейке.
Как можно заметить, не так уж мгновенно все происходит, хотя и операций на все уходит немного.
Копирование указателя. Адресная арифметика.
Вот что происходит очень быстро, так это копирование указателя.
Пара перемещений содержимого ячейки из памяти в регистр и обратно в память. После такой операции два указателя содержат одинаковые адреса, то есть указывают на одно и то же место в памяти. Еще одним интересным свойством указателей является их арифметика.
Наиболее полное понимание этого вопроса естественно за несколько минут получить невозможно, но можно показать как осуществляется доступ к содержимому памяти, используя манипуляции с указателем.
В этой строке кода в указатель p2 заносится адрес следующего за нулевым элемента массива. Порядок инструкций процессора как и раньше направлен на вычисление полного адреса, учитывая все смещения и также содержимого регистра указателя стека.
Доступ к элементам структуры по указателю.
Теперь вернемся к игровым юнитам и посмотрим как синтаксически и технически происходит доступ к элементам структуры.
В функции main происходит инициализация свойств персонажа. При этом уже становится привычным, что в стековый фрейм функции заносятся все свойства, начиная с последних. Также в функции определен указатель на структуру King. Под него также выделяется место в стеке.
В этой строке кода в указатель pKing заносится адрес первого элемента структуры. Синтаксически доступ к свойствам по указателю осуществляется при помощи стрелочки.
Передача параметра по значению.
Сейчас в исходный код добавлена функция damage() уменьшения здоровья (health) на единицу.
Для того чтобы понять преимущества указателей, сначала сделаем без них.
В функцию параметром должна быть передана структура, над которой будет произведена операция. Потом эта структура будет возращена из функции и ее полями будут заменены поля структуры в функции main. Это очень и очень плохая идея по той причине, что
в стек будет помещена не сама структура, а ее копия,
что раздует стек и будет сопровождаться большим количеством машинных инструкций.
Внутри функции операция будет совершена над копией.
А при возврате структуры обратно через стек, функции main прийдется копировать в свою собственную структуру все поля копии.
Еще раз повторюсь, это чрезвычайно плохая идея. Ситуацию усугубляет количество вызовов этой функции за все время работы программы. Чем их больше, тем более бесполезно работает процессор.
Передача параметра по указателю.
Улучшим исходный код. Теперь функция принимает не саму структуру, а указатель на нее.
Возвращаемое значение функции это void или пустота. Внутри функции тогда доступ будет осуществлен с помощью стрелки. От размещения структуры в стеке мы никуда не делись.
Но теперь во фрейм функции damage уходит указатель, что привело к уменьшению бесполезной работы процессора.
Внутри функции произошло изменение поля структуры, которая находится непонятно где с точки зрения функции damage. Главное это то, что есть указатель, а это значит, что со структурой можно производить манипуляции. Также необходимо отметить, что возврат результата через стек теперь не нужен, что еще больше ускорило программу.
Указатель на функцию.
Мы подошли к вишенке на торте. Это указатель на функцию. Обратим внимание на описание структуры.
Последним полем у нее появилась загадочная запись void (*hit)(King * k);. Так выглядит синтаксис указателя на функцию. Чисто технически это такая же переменная как и все остальные, но в ней хранится адрес первой инструкции функции. При вызове этой функции произойдет передача содержимого этой переменной в регистр указателя инструкции PC.
Теперь про синтаксис. Возвращает функция пустоту (void). Название этого указателя hit. Параметром функции должен быть указатель на структуру King. Обратим внимание также на то, что при инициализации указателя на функцию нужно присвоить ему конкретное значение. У нас это будет адрес функции damage. С этого самого момента структура кроме свойств обрела еще и метод работы с одним из своих свойств. А что это как не зачаток объектно-ориентированного подхода?
Конечно, мы при этом не окунаемся в условности вроде инкапсуляции, наследования, но у нас все впереди. Выполнение функции начинается после вызова метода нашего объекта через точку и имя метода hit.
Выполнение программы пойдет по машинным инструкциям функции damage и это все уже привычно.
Одно неудобство. Параметром метода объекта является указатель на сам объект k.hit(&k) . С точки зрения высокоуровневых языков выглядит чем-то чужеродным. Но стоит присмотреться, то указатель на сам объект передается в методы, явно или завуалированно. Указателем на объект является this в языках C++ и Java, также им является self в языке Python.