Что ж, продолжаю традиционно готовить вас к новому большому ролику сильно заранее перед его выходом. Когда он будет? Пока рано говорить, не хочу вас обмануть. Точно не в ближайшее время. О чём? Можете догадаться 🥂
Представим, что мы проектируем язык программирования и нужно решить: где хранить локальные переменные функций?
Допустим, у нас есть функция
A() (main(), если хотите). У неё есть свои локальные переменные, которые больше никому не доступны, и их надо где-то разместить. Мы любим простоту, поэтому начнём с самого простого решения — давайте просто выдадим
A() какой-то адрес в памяти, например такой: 0x1000. Далее она сама распределит адреса для своих переменных, начиная с этой точки:func A() {
x := 1 // 0x1000
y := 2 // 0x1008
}И это отлично работает! Компилятор видит две переменные, резервирует 16 байт, всё просто.
————
Добавим вторую функцию
Теперь A вызывает B. Какой адрес дать B?
func A() {
x := 1 // 0x1000
y := 2 // 0x1008
B() // какой адрес?
}
func B() {
a := 10 // ???
b := 20 // ???
}0x1000 не подходит — там уже лежат x и y функции A, которые ей ещё понадобятся после возврата из B. Затрём их — сломаем программу 😩
Самое простое и очевидное решение: выделенный для B адрес должен начинаться после адреса A. A занимает 16 байт: 0x1000 + 15 = 0x100F, за ним идёт 0x1010 (hex представление). Значит B начнётся с 0x1010:
func A() {
x := 1 // 0x1000
y := 2 // 0x1008
B() // начнётся с 0x1010
}
func B() {
a := 10 // 0x1010
b := 20 // 0x1018
}Если в цепочке вызовов появляются новые функции, действуем по аналогии.
Итого получаем следующую картину:
// Указатель: 0x1000
func A() {
// Начинаем с текущей позиции: 0x1000
x := 1 // 0x1000
y := 2 // 0x1008
// Занимает 16 байт, сдвигаем указатель: 0x1000 → 0x1010
B()
}
func B() {
// Начинаем с текущей позиции: 0x1010
a := 10 // 0x1010
b := 20 // 0x1018
// Занимает 16 байт, сдвигаем: 0x1010 → 0x1020
C()
}
func C() {
// Начинаем с текущей позиции: 0x1020
k := 100 // 0x1020
}
Всё отлично работает, и нам даже не важно сколько функций будет в цепочке — A может даже рекурсивно вызывать саму себя, решение автоматически масштабируется сколь угодно долго.
Вернее, до того самого момента stack overflow 🙃
Теперь вы понимаете что это такое — если цепочка слишком длинная, стек не влезает в выделенную под него память и программа падает со знаменитой ошибкой.
————
А как освобождать память?
Когда C завершается, её память больше не нужна. Хотим вернуть указатель назад. Но на сколько? На размер C? Нет, потому что внутри C могли вызваться другие функции!
Правильный способ: каждая функция запоминает, где она начиналась, и при завершении возвращает указатель туда:
func A() {
saved := 0x1000 // запомнили начало A
// сдвинули: 0x1000 → 0x1010
x := 1
y := 2
B()
// B завершилась, возвращаем: 0x1010 → 0x1000
}
func B() {
saved := 0x1010 // запомнили начало B
// сдвинули: 0x1010 → 0x1020
a := 10
b := 20
C()
// C завершилась, возвращаем: 0x1020 → 0x1010
}
func C() {
saved := 0x1020
// сдвинули: 0x1020 → 0x1028
k := 100
// возвращаем: 0x1028 → 0x1020
}————
Таким образом указатель растёт при вызовах и возвращается при завершении. Последняя вызванная функция первой освобождает память. Узнаёте? Это же классическая структура LIFO — Last In, First Out!
И теперь нас можно поздравить, ведь мы изобрели тот самый стек — вот так просто, принимая очевидные минималистичные решения. Такая структура получилась естественным образом из логики адресации.
————
Но что если у нас появляется переменная, которая будет доступна в разных функциях? Такое часто случается когда мы работаем с указателями. И это уже другая история.. Обсудим это в отдельном посте.
#guide #memory