Предыдущая часть:
Продолжим проектировать воображаемый компьютер. Допустим, нам нужно в программе вывести строку "Hello". Для этого нужно (опустим детали) перебирать символы строки и записывать их в позицию курсора на экране.
Потом нам понадобилось вывести строку "World". Придётся повторить то же самое, что и для первой строки.
Очень быстро приходит понимание, что повторно писать один и тот же код – путь в никуда.
Первая мысль – нельзя ли уже написанный код использовать повторно? В общем-то можно. Если из любого места программы сделать переход на адрес этого кода, то он выполнится.
Читайте также:
Но как потом вернуться обратно и продолжить программу?
Программа всегда знает, в каком месте находится сейчас. У неё есть адрес исполнения, то есть адрес, где лежит следующая машинная инструкция, которую надо исполнить. Он находится в специальном регистре процессора ip (instruction pointer).
Поэтому чтобы вернуться обратно, надо этот адрес сохранить где-нибудь. Напишем условно так:
saved_addr = ip
Теперь мы можем перейти на другой адрес, а это ничто иное, как запись в ip другого адреса. Можно обозначить наш кусок кода меткой print, и написать, опять же условно, так:
ip = print
Что полностью эквивалентно:
goto print
И теперь код будет выполняться с метки print. Когда код закончил свою работу, нужно вернуться назад и продолжить программу. Для этого надо в ip записать обратно сохранённый адрес:
ip = saved_addr
И программа, начиная с этого адреса, продолжит выполняться дальше.
Такой кусок кода называется функцией. Переход на него называется вызовом функции, а переход обратно это возврат из функции.
Проблемы работы с функциями
Первая проблема это место, где сохранять адрес возврата. Да, мы можем выделить переменную saved_addr и жёстко прошить её в коде. Это не совсем удобно, но терпимо.
Но это позволит вызывать не более одной функции зараз. Очень часто одна функция может вызывать другую функцию, та третью и т.д. Так что если одна функция вызывает другую, она также обязана сохранить текущий адрес для возврата. Но ведь в переменной saved_addr уже сохранён адрес, и если мы сохраним туда текущий, то потеряем предыдущий и вернуться из функции не сможем.
Тогда очевидное решение – использовать больше переменных для сохранения. Их можно назвать saved_addr1, saved_addr2 и т.д. Но и тут возникает проблема. Мы никогда не будем знать, сколько всего нужно таких переменных, и в какой момент какая из них занята.
Поэтому требуется отдельная сущность, которая сможет управлять хранением адресов возврата и автоматически их учитывать.
Стек
Выделим условно-бесконечный отрезок памяти и будем использовать его как массив совместно с позицией в нём.
Сначала массив пуст, и позиция в нём равна 0. Мы делаем вызов функции и сохраняем текущий адрес исполнения в массиве в позиции 0. А позицию сдвигаем на 1.
Теперь если функция вызовет другую функцию, то сохранит текущий адрес возврата в позиции 1, а позиция сдвинется на 2.
Таким образом, текущий адрес всегда будет сохраняться на следующей свободной позиции в массиве. Адреса будут как бы складываться в стопку друг над другом – поэтому такая структура называется "стек", что и значит "стопка".
Читайте также:
Посмотрим теперь, как происходит возврат из функций. Чтобы вернуться, функция уменьшает позицию в стеке на 1 и берёт оттуда последний сохранённый адрес возврата. Иначе говоря, берёт элемент, лежащий на вершине стопки. Если эта функция вызывалась из другой функции, то программа попадёт в предыдущую функцию. Предыдущая функция тоже уменьшает позицию в стеке на 1 и получает предыдущий адрес возврата. И так далее. Все вложенные вызовы функций по очереди возвращаются друг в друга, пока не произойдёт возврат в основную программу и она продолжит работать.
При этом стек вызовов очистился – позиция в нём вернулась на 0 и теперь туда снова можно что-то сохранять.
Передача параметров
Адреса возврата в порядке, теперь вторая проблема. Сначала мы печатали слово "Hello", а затем "World". Это две разные строки, а функция вызывается одна и та же. Как ей пояснить, что надо печатать разные строки?
Конечно, адрес строки можно поместить в некую заранее известную переменную – например, str:
str = "Hello"
И функция печати будет пользоваться этой переменной. Что мы туда поместим, то она и будет печатать.
Собственно, этот способ называется "глобальные переменные" и реально существует во многих языках программирования. Переменные глобальные, потому что доступны любой части кода в программе.
Но грабли здесь те же самые: вызывая из одних функций другие, можно легко запутаться, какие глобальные переменные уже были использованы, какие ещё актуальны, а какие нет, и т.д. И как следствие, глобальные переменные должны существовать в течение всей жизни программы, даже если уже не нужны.
Поэтому использование глобальных переменных на практике либо запрещается, либо как минимум не поощряется (кроме очень специфичных случаев).
Но ведь у нас есть стек. Если там можно сохранить адрес возврата, то там же можно сохранить и параметр, передаваемый в функцию.
Как это может выглядеть:
- Сохранить в стеке адрес строки для печати
- Сохранить в стеке адрес возврата
- Вызвать функцию печати
Теперь, когда функция работает, что она видит со своей перспективы? На вершине стека лежит то, что было сохранено последним – адрес возврата. А под ним лежит тот параметр, который ей передали. Как получить доступ к этому параметру? Опять же вспомним про массив. Текущая позиция в стеке показывает на следующий свободный элемент. Предыдущая, со смещением -1, показывает на адрес возврата, а со смещением -2 показывает на переданный параметр.
Хотя мы действительно можем создать массив под стек и управлять им вручную, в реальной машинной архитектуре операции со стеком автоматизированы. Позиция стека хранится в процессорном регистре sp (stack pointer). У стека есть базовый адрес где-то в памяти, и sp при пустом стеке равен этому адресу.
Но кроме того, допустима такая адресация: sp[2] или sp[-2], то есть смещение относительно адреса в sp.
Так что функция всегда знает, что переданный ей параметр лежит в sp[-2], и ей не надо больше ничего отслеживать.
Примечание. Это довольно абстрактное описание, так как работу со стеком можно организовать по-разному, и порядок сохранения и восстановления задавать по-разному. И конечно смещения -1 и -2 тоже абстрактные. Это просто общий принцип.
Таким способом можно передать много параметров. Если функция знает, что ей должны прийти, к примеру, три параметра, то она будет искать их в sp[-2], sp[-3] и sp[-4].
Передача по значению и по указателю
Предположим, мы создали переменную с адресом foo и положили туда значение 5:
foo = 5
Теперь мы захотели передать значение этой переменной в функцию, к примеру, sign():
sign(foo)
Что конкретно передаётся? Исходя из описанной ранее схемы, foo это на самом деле [foo], то есть содержимое адреса foo, то есть 5.
Значит, в стек кладётся число 5:
[sp] = [foo]
Обратите внимание – меняем не адрес, хранящийся в sp, а его содержимое. После этого надо увеличить позицию стека на 1, то есть сделать
sp += 1
Теперь уже меняем адрес! Становится доступным следующий свободный.
В реальности и помещение в стек, и изменение адреса делаются одной машинной командой push, которая по умолчанию работает с sp:
push [foo]
Таким образом, значение 5 было скопировано из адреса foo в адрес sp. Функция sign() увидит его как sp[-2] (не забывайте, что всё условно).
Теперь передадим не содержимое foo, а его адрес:
push foo
Для функции не изменилось ничего. Она по-прежнему смотрит, что хранится в sp[-2], а там теперь хранится не число 5, а адрес foo.
Как с этим работает функция? У неё есть контракт, который гласит: тебе будут присылать значения. Контракт записывается в самой сигнатуре функции:
int sign(int param)
int это целочисленное значение, ну вот его и присылайте. Если вместо него прислать адрес – ну что ж, это тоже целочисленное значение, так что никакой разницы.
Зато если описать контракт вот так:
int sign(int* param)
то функция ожидает, что ей пришлют адрес. Так как её параметр имеет тип "указатель на int", функция будет знать, что в sp[-2] лежит адрес, и будет работать с ним или с его содержимым соответственно.
Особенности передачи по указателю
При передаче чего-либо в функцию в любом случае происходит копирование. Невозможно вырвать информацию со своего места и отправить куда-то. Поэтому всегда передаются только копии. Но вот копии чего?
Если взять переменную foo типа int, то её содержимое 5 копируется в содержимое адреса стека и фактически становится новой безымянной переменной с адресом на стеке. Если функция поменяет эту новую переменную на стеке, она никаким боком не повлияет на оригинальную foo.
Если же передать через стек адрес переменной foo, то копирование всё равно произойдёт. Просто вместо числа 5 скопируется адрес foo и всё так же на стеке появится новая переменная. Если функция изменит значение адреса, хранимого на стеке, это опять же никак не повлияет на оригинальную foo.
Но если функция использует значение как указатель, то через указатель она может получить доступ непосредственно к адресу foo и прочитать его содержимое, либо изменить его.
Если мы хотим присвоить переменной foo результат выполнения функции, то можем поступить так:
foo = sign(foo);
В этом случае мы передаём в функцию копию значения foo, функция возвращает результат, и копия этого результата помещается в foo.
Либо так:
sign(&foo);
В этом случае передали адрес foo, и функция сама прочитала значение из адреса и сама изменила значение через указатель.
Выглядит так, будто второй способ быстрее. Но на деле выгоды нет. Мы вообще не сэкономили на операциях корпирования (в любом случае в foo копируется результат, а делается это в функции или после неё – неважно). В то же время функция работает не с чистым адресом, а с адресом, получаемым как значение из другого адреса (на стеке), что может оказаться даже медленнее.
В целом манипуляции с указателями на скалярные переменные не приветствуются. Они меняют переменные неявно. Т.е. просто по виду функции sign(&foo) мы не можем сказать, изменит она значение foo или нет.
Где же тогда применять передачу указателей?
Что делать, если в функцию нужно передать не одну переменную типа int, а целый массив, скажем, длиной 100 элементов?
Если передавать значения массива, придётся их все скопировать и положить на стек. То есть на стеке резервируем сразу 100 адресов, копируем туда значения массива, и функция будет работать с этой копией как с самостоятельным массивом.
Чтобы записать результат обратно в оригинальный массив, нужно будет снова повторить копирование 100 элементов.
Очевидно, в этом случае всё уже гораздо тяжелее, и вместо копии массива мы можем передать в функцию указатель на массив. Тогда копирования можно избежать, и функция может изменять значения прямо в массиве.
Аналогичным образом, если в функцию надо передать много разных параметров, типа
calc(x, y, z, dx, dy, dz, i, u, w, n);
Можно объединить эти параметры в структуру и передать в функцию указатель на структуру:
calc(&struct);
Не стоит также забывать, что иногда нам НУЖНА копия. Например, мы хотим обработать массив, но не хотим его менять. Так что в этом случае понадобится сделать именно копию массива.
Читайте дальше: