2.2 Kinds of memory

Условно в первом приближении при запуске программы ей дают непрервыный кусок памяти по адресам которых она может обращаться.

Условно эта память делится на три части - data, text и stack.

data - область памяти, где хранится все, что определено на всей области программы - то есть, в частности, глобальные переменные.

text - машинный код вашей программы.

stack - по дефолту примерно 8 MiB. Область ответственная за уровни вложенности программы, которая “как бы” хранит все в том порядке в котором оно определяется. (Хотя на самом деле компилятор вам не обязан ничего и может переопределять порядок или добавление, видоизменение, игнорирование как хочет)

При какой-либо рекурсии мы на стек добавляем не только все плокальные переменные функции, но и указатель на то место, где он был вызыван, иначе мы не узнаем куда вернуться. Сам стек примерно вмещает 1 миллион запусков пустой “рекурсии”.

Динамическая память - память выделяемая в процессе выполнения программы

int *p = new int; // выделение памяти на int и получение его адреса

new – создает объект данного типа в динамической памяти.

new - это какое-то шаманство, которое пока разобрано не будет. Выполняется в 200-300 раз дольше чем сложение в хорошем случае. А в плохом сколь угодно плохо) ( ≥ 10000)

Утечка памяти(memory leak)

void foo() {
    int* p = new int(5);
    ...
    // without delete
    return;
}

Если не вызвать delete, то память останется условно занятой, но провзаимодействовать мы с ней никак не сможем, так как потеряли адреса.

В отличие от многих других языков в C++ нет сборщика мусора, поэтому надо убирать за собой.

Дважды удалять один и тот же указатель - UB(double free or corruption).

Провзаимодействовать с указателем и вызвать delete - UB.

Вызвать delete не от указателя созданного new - UB.

После delete p разыменовать p - UB.

Статическая память

{
    static int z;
}

Теперь z лежит в вышеупомянутой data, ей предвыделило чуть больше памяти на этапе компиляции, специально для z и оно будет храниться там на протяжении всей программы (но доступно только из своей области видимости).

Важно. Ключевое слово static имеет разный смысл для переменных внутри функций и глобальных переменных. Для глобальных переменных static влияет на линковку(internal linkage, что бы это ни значило), противоположность ключевому слову extern.

Ниже приведены примеры переполнения стека(из-за рекурсии)

#include <iostream>

int f(int x) {
    std::cout << '\n';
    ++x;
    f(x);
}

int main() {
    f(0);
}
// переполнение стека
#include <iostream>

int f() {
    static int x = 0;
    std::cout << '\n';
    ++x;
    f();
}

int main() {
    f();
}
// переполнение стека, but cooler
#include <iostream>

int f() {
    int *p = new int(5);
    std::cout << p << ' ' << *p << std::endl;
    delete (++p);
    f();
}

int main() {
    f(0);
}
// переполнение стека

2.3 Arrays

#include <iostream>

int main() {
    int a[10];
    int b[5] = {1, 2, 3, 4, 5};
    int c[5]{};
    std::cout << *(a + 3) << '\n'; // 4
    int *p = a + 3;
    // p[i] == *(p + i), причем i[p] == p[i], потому что в терминах указателей это буквально i + p == p + i
    std::cout << p[-2] << '\n'; // 2
}

int* и int[] взаимозаменяемы, поэтому следующий код вызовет ошибку компиляции

#include <iostream>

void f(int a[5]) {
    ;
}

void f(int* p) {
    ;
}

int main() {
    int a[5] = {1, 2, 3, 4, 5};
    int* b[5]; // массив из 5 указателей на int*, а не указатель на массив из 5 int 
}

Массив можно выделить в динамической памяти (который на самом деле указатель на начало) с помощью конструкции new T[].

#include <iostream>

int main() {
    int *p = new int[100];

    delete[] p;
}

Если пытаться сделать delete[] от обычного указателя или наоборот - это runtime error

#include <iostream>

int main() {
    std::vector<int> v(10);
    v[-1] = 10000; // надругательство
    return 0;
    delete[] &v[0]; // надругательство
    return 0;
}
#include <iostream>

int main() {
    // Variable lenght array (VLA)
    int n = 100;
    std::cin >> n;
    int a[n]; // с cin нельзя, с n = 100 - можно    
}

Убрана обратная совместимость с C, чтобы не сдвигать переменные на стеке на какое-то неопредленное число, которое мы узнаем только в рантайме. Но большинство компиляторов таки поддерживают VLA.

#include <iostream>

int main() {
    const char* s = "abcdef"; // сам строковый литерал всегда хранится в статической памяти, сам указатель на него - на стеке
    std::cout << (int)(s[4]) << '\n';
}

Идентификатор const.

Запись const char * значит, что мы вправе менять указатель, но не вправе менять то, что лежит под ним.

Здесь подробно описано, как читать такие объявления.

Null-terminated strings. В языке Си есть конвенция отождествлять строку с последовательностью байт, которая заканчивается на 0 (для записи NUL символа можно использовать литерал '\0').

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

size_t
my_strlen(const char *s)
{ 
    size_t len = 0;
    while (*s++) {
        len++;
    }
    // если гарантируется, что s заканчивается нулевым байтом, то
    // мы никогда не выйдем за границы выделенной памяти, то есть данный код корректный
    return len;
}

void
my_strcpy(char *dst, const char *src)
{
    while ((*dst++ = *src++));
    // Для того, чтобы не было UB, нужно, чтобы dst, src
    // заканчивались нулем и в dst было достаточно места
}
#include <iostream>
#include <cstring>

int main() {
    const char* s = "abc\0def"; // сам строковый литерал всегда хранится в статической памяти, сам указатель на него - на стеке
    std::cout << strlen(s) << ' ' << s << '\n'; // выведет '3 abc', потому что из-за поддержки C-style string все пытается прыгать вокруг присутствия /0 ровно в конце строки
}

Под каждым std::string лежит C-style null-terminated строка, указатель на начало которой получить вызвав метод .c_str().

2.4 Functions and pointers to functions

Указатель на функцию это “указатель на код”, его также можно передавать в другие функции. Объявляется он как return-type (*name)(arguments).

Опять же, по этой ссылке можно найти гайд по чтению этих записей.

#include <iostream>

int sqr(int x) {
    return x * x;
}

int f(int x);

void sort(int *begin, int *end, bool(*cmp)(int, int));

int main() {
    int x = 0;
    std::cout << &x << '\n';
    int(*p)(int) &sqr; // синтаксически корректное определние ссылки на функцию
    
    std::cout << (void*)&sqr; // адрес sqr в памяти

    int (*q)(int) = &f; // нельзя, так как функция не определена
    
}

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

#include <iostream>

int sqr(int x) {
    return x * x;
}

double sqr(double x) {
    return x * x;
}

int main() {
    int (*p)(int) = sqr;
    double (*pp)(double) = sqr;

    double (*ppp)(double) = (double(*)(double))(p);

    std::cout << (void*)p << ' ' << (void*)pp << '\n'; // выведет 2 разных адреса, потому что оно взяло разные функции 
}

Существуют также variadic функции. Ниже адаптирован пример отсюда

TL;DR Не пишите C-style variadic функции в C++. Будьте ОЧЕНЬ аккуратны при использовании variadic функций в C/C++.

// C-style variadic function

int
variadic_sum(size_t count, ...)
{
    va_list ap;
    size_t i;
    int sum;

    va_start(ap, count);

    sum = 0;
    for (i = 0; i < count; ++i) {
        sum += va_arg(ap, int); // макрос принимает va_list и тип аргумента, который мы ждём
    }

    va_end(ap); // может не делать ничего, но по стандарту нужно

    return sum;
}

Данный код очень небезопасен по многим причинам. Первый аргумент count лежит на стеке, поэтому компилятор знает, как его достать. А дальше могут лежать аргументы произвольных типов, которые могут занимать разное количество памяти.

Как тогда вытащить их со стека? ap указывает на текущую позицию(сокращение от argument pointer), а va_arg приводит ap к типу, в нашем случае int, и прибавляет количество байт, которое этот тип занимает.

Можно подумать, что в va_start мы передаем количество аргументов, но это неправда. va_start это макрос, а не функция, поэтому у него есть немного контекста, например, адрес переменной count. Он берет указатель на count и прибавляет к нему число байт, которое count занимает, чтобы указать на первый variadic аргумент.

Тем самым, вторым аргументом в va_start нужно указать последний не variadic аргумент функции.

Одна из самых известных variadic функций с которой вы наверняка сталкивались, это printf. Если передать в printf недостаточное число аргументов, слишком много аргументов, или тип аргумента не совпадет со спецификатором(например, вы передали char* в %d), то произойдет UB как раз из механизма работы va. Например, оно может брать аргументы выше по стеку, поэтому никогда не передавайте в printf недоверенный спецификатор формата, например, prinf(username); из соображений безопасности.