Указатели

2023-02-27
16 мин.

pointers-meme

Для начала разберемся, как именно мы хотим передавать и использовать данные (переменные). Глобально, есть лишь 2 подхода:

  1. Передать по значению (копировать данные).
    Это иногда долго, но мы не рискуем изменить другие переменные.
  2. Передать по ссылке
    Передаем те же данные. Это очень быстро, ведь оперируем только ссылками на ячейки в памяти. Но мы рискуем забыть о том, что данные поменялись в какой-то функции, куда мы их передаем. И это проблема!

Важно!
"По ссылке" здесь не означает: int &b = a (здесь мы будем называть это reference). Это значит, что мы хотим получить доступ к тому же участку памяти, что и другая переменная. То есть не создавать копии тех же данных, а, при необходимости, видоизменять их.

Копирование данных (передача по значению)

int a = 1;
int b = a; // копируем значение переменной a
b++;
cout << a << '\n'; // 1
cout << b << '\n'; // 2

Удобно. Здесь никаких плохих последствий. Изменения в b не влекут за собой изменений в a. Часто это ровно то, что нам нужно, но, иногда нам нужно что-то другое. Например, передавая массив чисел в функцию, скажем, setArrayWithRandomNumbers, мы хотим, чтобы массив изменился, поэтому нам ничего не остается, как передавать его не по значению, а по ссылке.

Передача по ссылке

Reference

Один из механизмов языка C++ - это использовать reference. Это мы уже умеем.

int a = 1;
int &r = a; // 👈 Внимание, магия!
r++;

cout << a; // 2
cout << r; // 2

Здесь, когда мы передали нашу переменную по ссылке, мы получили ссылку на значение другой переменной. Поэтому при изменении значения по этой ссылке, меняется и b, и a.

Указатели (pointers)

Но существует еще один древнейший (из языка C, тогда еще жили динозавры) способ работы с ячейками памяти. Этот способ работы с данными очень сложный. Не только лишь все понимают, что такое указатели, путают их со ссылками, отчасти поэтому в cpp есть reference.

pointer != reference

Выглядит работа с указателями так:

int a = 1;
int *p = &a; // 👈 Магия!
(*p)++;

cout << a << '\n'; // 2
cout << *p << '\n'; // 2

Разберемся, что же тут произошло.

  1. Мы сделали указатель pointer на участок памяти, в котором лежит переменная a
    1. Для этого мы сказали, что p - это указатель int *p
    2. И присвоили в него reference на участок памяти, в котором лежит a int *p = &a
  2. Теперь в переменной p лежит ссылка на участок памяти, в котором есть данные переменной a cout << p; // Ox13ba...
  3. Чтобы теперь обратиться за данными, нам необходимо воспользоваться оператором разыменовывания cout << *p; // 1, то есть (*p)++

Pointer vs Reference

#include <iostream>

using namespace std;

int addOneRef(int &r) {
  r++;      // Как обычная переменная
  return r; // Возвращаем значение
}

int addOnePointer(int *p) {
  (*p)++;    // Изменяем !значение!
  return *p; // Возвращаем !значение!
}

int main() {
  int a = 1;
  int b = addOneRef(a);
  cout << "Refs: " << a << " " << b << endl; // 2 2

  int c = 1;
  cout << &c << endl;
  int d = addOnePointer(&c);
  cout << "Pointers: " << c << " " << d << endl; // 2 2

  return 0;
}

Связь указателей и одномерных массивов

Указатель на массив данных - это просто указатель на первый элемент из списка. Добавляя к указателю 1, мы на самом деле прибавляем 1 * sizeof(тип данных). В случае int, мы добавим 4 байта.

Визуализировать этот процесс можно так:

pointers-visual

Каждый байт памяти пронумерован длинным 16ричным числом. Указатель p на картинке ссылается на начальный байт памяти, в котором лежит значение (в данном случае, байт номер 2000). Но значение целого числа занимает 4 байта, поэтому добавляя p + 1, мы получим не 2001, а 2004.

Пример (подразумевается, что читатель знает, сколько необходимо памяти для того или оного типа данных):

int main() {
  bool* a = new bool[4]; // Занимает 4 * (1 байт)
  cout << a     << '\n'; // 0x146e06a70
  cout << a + 1 << '\n'; // 0x146e06a71 👈 Сдвиг на 1 байт
  cout << a + 2 << '\n'; // 0x146e06a72
  cout << a + 3 << '\n'; // 0x146e06a73

  int* b = new int[4];   // Занимает 4 * (4 байт)
  cout << b     << '\n'; // 0x146e06a80
  cout << b + 1 << '\n'; // 0x146e06a84 👈 Сдвиг на 4 байта
  cout << b + 2 << '\n'; // 0x146e06a88
  cout << b + 3 << '\n'; // 0x146e06a8c
}

Имея указатель на начало массива, мы можем использовать его для итерирования, вместо стандартных int переменных:

#include <iostream>

using namespace std;

const int ARRAY_SIZE = 3;

int findMax(int *array) {
  int max = *array; // первое значение
  // Могли бы и так:
  // int max = array[0]; // 👈 это одно и то же

  // Берем адрес **второй** ячейки памяти
  // (первую же уже запомнили)
  // Сравниваем АДРЕСА, а не значения.
  for (
    int *value = array + 1;
    value <= array + ARRAY_SIZE - 1;
    value++
  ) {
    // не забываем разыменовать
    max = max > *value ? max : *value;
  }

  return max;
}

int main() {
  int *array = new int[ARRAY_SIZE];
  // Заполняем
  *array = 1;
  *(array + 1) = 2;
  *(array + 2) = 3;
  // Мы могли бы и так:
  // array[0] = 1;
  // array[1] = 2;
  // array[2] = 3;

  // Актуальная версия компилятора gcc может так:
  // int *array = new int[ARRAY_SIZE]{1, 2, 3};

  // Делаем свои дела с массивом
  cout << findMax(array) << endl;

  // не забываем про delete!
  delete[] array;
  return 0;
}

В программе выше мы не использовали обращений к индексам array[i]. Вместо этого мы брали значения в определенных ячейках памяти.

Чистим за собой

Очень важно помнить, что чистить за собой память - это наша ответственность. Поэтому помним золотое правило:

конфунций

В следующем коде для ARRAY_N и ARRAY_SIZE 100, скорее всего все сработает хорошо. Но вот для очень большого числа вы увидим ошибку.

#include <iostream>

using namespace std;

// const int ARRAY_N = 100;
// const int ARRAY_SIZE = 100;

const int ARRAY_N = 10000000;
const int ARRAY_SIZE = 10000000;

int main() {
  for (int i = 0; i < ARRAY_N; i++) {
    int *array = new int[ARRAY_SIZE];
    // ... делаем что-то полезное с массивом
    // delete[] array; // 👈 намеренно забыли
  }
  cout << "Done!" << endl;

  return 0;
}

В случае 10000000 мы увидим следующее:

➜  g++ code.cpp -o code && ./code
libc++abi: terminating with uncaught exception of type std::bad_alloc: std::bad_alloc
[1]    45584 abort      code.cpp

Это значит, что больше памяти мы уже не можем выделить, просто закончилось место.

Опасности, которые нас поджидают

Есть множество проблем, которые сопровождают нас на пути изучения указателей. Как мы увидим далее, большинство проблем можно решить, просто внимательно читая логи в консоли. Вот примеры самых частых проблем:

Возврат ссылки на локальную переменную функции

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

#include <iostream>
using namespace std;

int* getMax(int* x, int* y) {
  // tmp - локальная переменная функции,
  // т.к. она объявлена внутри этой функции.
  int tmp = *x > *y ? *x : *y;
  return &tmp;
}

int main() {
  int a = 3;
  int b = 7;

  int* max = getMax(&a, &b);
  cout << *max << '\n';  // 2 👹

  return 0;
}

Вы вряд ли пропустите это, так как в консоли вам об этом напишут:

code.cpp:8:11: warning: address of stack memory associated with local variable 'tmp' returned [-Wreturn-stack-address]
  return &tmp;

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

Но ведь мы же уже создавали как-то массивы в функциях и успешно работали с ними за пределами функции.

Давайте заставим пример выше работать так, как мы хотим. Для этого перепишем getMax так:

int* getMax(int* x, int* y) {
  // аллокация памяти в heap, используя new
  int* tmp = new int;
  *tmp = *x > *y ? *x : *y;
  return tmp;
}

Несмотря на то, что переменная temp - это все еще локальная переменная для функции getMax, память для этой переменной была выделена в heap (в куче, не в стэке). Поэтому указатель на данный участок памяти все еще будет ссылаться на правильные данные, даже после высвобождения всего стэка для фукнции getMax.

❗️ Важно ❗️
Теперь бы еще не забыть где-то за пределами getMax освободить выделенную в ней память, когда она нам больше не будет нужна.

Высвобождение памяти дважды

Описав функцию, которая возвращает указатель,очень хочется потом где-то написать delete... Это чувство хорошее, но иногда может привести к ошибкам:

#include <iostream>
using namespace std;

// В данном случае, функция не создает переменных и не выделяет памяти.
int* getMax(int* x, int* y) { return *x > *y ? x : y; }

int main() {
  int* a = new int(5);
  int* b = new int(3);
  cout << *a << " " << a << endl;
  cout << *b << " " << b << endl;

  int* max = getMax(a, b);
  cout << *max << " " << max << endl;

  delete a;
  delete b;
  // ну, раз max тоже указатель,
  // наверное там где-то тоже был new
  delete max;

  return 0;
}

И в консоли:

➜ g++ code.cpp -o code && ./code
5 0x14a606a70
3 0x14a606a80
5 0x14a606a70
code(19084,0x20a147a80) malloc: Double free of object 0x14a606a70
code(19084,0x20a147a80) malloc: *** set a breakpoint in malloc_error_break to debug

Мы пытаемся дважды почистить одно и то же место в памяти, и возникает ошибка. Освобождение одной и той же области динамически выделенной памяти во второй раз считается неопределенной операцией, которая, как правило (в зависимости от того, как реализована система динамического распределения памяти), вызывает неисправимую ошибку.

Заключение

Указатели - это очень сложная тема. Как мы уже убедились выше, в языке C++ мы можем самостоятельно выделять память и самостоятельно ее очищать, когда мы решим, что она уже не нужна. Можно использовать указатели, чтобы обращаться к данным в определенных ячейках памяти. Именно это и делает язык таким быстрым и позволяет писать очень производительные программы для слабого железа.

Но с большой силой приходит и большая ответственность.
Из-за того, что мы вольны делать с памятью, что хотим, нам нужно быть на порядок осторожнее. Забыв очистить память, выделенную ранее, мы заняли немного места, которое уже не получится использовать. В итоге, выделяя по кусочку памяти, мы получим ошибку, так как выделенная память на программу закончится. И это не беда сейчас, пока мы учимся, но может стать огромной проблемой, если такая ситуация случится у крупной компании.

Полезные ссылки

ММФ-ноутс, ммф нотс, ммф-ноутс, mmf, notes, mmf-notes