Указатели, ссылки и массивы в C++

xkcd как всегда великолепен

Чтобы разобраться в том, что такое указатель, на первых порах приходится прикладывать нехилые усилия из-за слабого понимания принципов функционирования ОС1 в целом. Я постараюсь описать общие идеи работы с указателями, ссылками и массивами в Си++ безотносительно различных сценариев работы с ними.

Указатель

Определение2

Для заданного типа T, тип T* является «указателем на T». Это означает, что переменные типа T* содержат адреса объектов типа T.

До этого значения переменные, сохранённые как int a = 2; создавались и хранились в специально отведённом для них месте памяти, называемой стеком программы. Созданием и удалением таких переменных занимается сама ОС. Работа с указателем даёт возможность хранить переменные в другой области памяти, называемой кучей (англ. heap) и самостоятельно управлять занятием и освобождением этой памяти, тогда как в стек, вместо самого значения переменной, размер которой может быть от 1 байта и до +∞, записывается исключительно адрес, по которому можно взять значение этой переменной.

Таким образом, язык Си++ даёт возможность выделить и проинициализировать память значением, получить адрес значения, получить само значение и, конечно же, освободить занимаемую память:

int *a = new int(2); // Оператор new выделит память, 2 — значение, которым эта память будет проинициализирована
cout << a; // Выведет адрес в шестнадцатеричном формате, например 0x102AF2
cout << *a; // Выведет значение, которое располагается по адресу 0x102AF2. Т.е., 2
delete a; // Пометит занимаемую область памяти, как освобождённую

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

int *a = new int(2);
delete a;
cout << *a;

Ошибка не возникает лишь потому, что уже «мусорное» значение в памяти по-прежнему сохранено в первоначальном виде. Хотя, если предположить, что между моментами освобождения памяти и выводом значения память компьютера полностью переписывается, то возникнет всеми любимая сегментейшн фолт — ошибка доступа к памяти. Чтобы избежать возможных проблем рекомендуется помечать указатель как пустой. Следующий пример сразу выявит ошибку:

int *a = new int(2);
delete a;
a = 0; // Существуют противники инициализации 0, поэтому можно делать по старинке: a = NULL;
cout << *a;

Ссылка на значение

При инициализации переменной значением другой переменной неявно происходит вызов копирования одной переменной в другую. Таким образом на выходе будут существовать 2 различные переменные, значение которых одинаково, и изменение значение одной переменной не приведёт к изменению значение другой:

int a = 2;
int b = a;
b = 3;
cout << a; // Выведет 2

Ссылка & вместо копирования значения переменной задаст для этого значения ещё одно имя. Будут существовать 2 переменных, ссылающихся на одно и то же значение в памяти. Изменение значения через одну переменную приведёт к изменению значения второй. Вторая переменная будет алиасом первой, если такое определение будет понятней:

int a = 2;
int &b = a;
b = 3;
cout << a; // Выведет 3

Также с помощью этого же оператора возможно получить адрес переменной:

int a = 2;
int *b = &a;
cout << b; // Выведет адрес переменной a

Банковская метафора

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

Когда происходит оплата карточкой, магазин связывается с банком по номеру карточки и требует удержать всю доступную сумму (если тратить, так всё сразу). Деньги физически остаются привязаны к счёту некоторое время и списанная сумма помечается как «задержанная» (англ. hold), поэтому владелец может до окончательного снятия суммы увидеть на своём балансе эти деньги, хотя их на самом деле уже нет ;-).

Динамические массивы

Когда рассматривались статические массивы, я умолчал про один существенный недостаток — размер такого массива должен быть известен уже на этапе компиляции. Другими словами, размер массива определяется числовой константой, будь то числовой литерал или же константная переменная.

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

int mySize = 100;
int *array = new int[mySize]; // Создание массива на 100 элементов
array[2] = 5; // Получаем доступ как к обычному массиву
cout << array[9];
delete [] array; // Удаление слегка отличается от удаления обычного указателя

Особое внимание следует уделить конструкции delete [] array;, которая удалит весь массив. Это возможно благодаря тому, что массив хранится в памяти непрерывным куском. Т.е. каждое новое значение может быть легко найдено, если известен адрес предыдущего и размер одного элемента:

int *a = new int[10];
cout << (a == &a[0]);
cout << ((a + 3) == (&a[3]));
delete [] a;

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

Чтобы создать двумерный массив, необходимо выполнить примерно следующий код:

int** a = new int*[10];
for (int i = 0; i < 10; i++) {
    a[i] = new int[10];
    for (int j = 0; j < 10; j++) {
        a[i][j] = i * j;
    }
}
cout << a[5][9];
// .. очистка массивов

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

На полях

В спецификации языка C# различаются массив массивов массивов… и многомерные массивы уже на уровне синтаксиса. Как видно из примеров выше, Си++ такой потрясающей особенностью не обладает и для него всё это просто указатели указателей указателей…

Строки как динамические массивы

Есть одна особенность в Си — делать эффективно, но непонятно. Эта особенность перекочевала и в Си++. Одной из таких штук является определение строки, как динамического массива чаров (англ. char). Чтобы записать строку достаточно записать:

char *a = "Hello, me!";
cout << a;

Кстати, удалить такую строку через delete не получится (узнать почему). Массив строк в виде уже знаком, как аргумент главной функции char *argv[], т.е. аргументы программы считываются, как строки. Например, следующая программа будет приветствовать человека по имени, или же сообщать, что не знакома, если аргументы отсутствуют:

#include <iostream>
#include <cstring>

using namespace std;
int main(int argc, char *argv[]) {

	if (argc > 1) {
		cout << "Hello, ";
		for (int i = 1; i < argc; i++) {
			cout << argv[i] << " ";
		}
	} else {
		cout << "We didn't met before";
	}
	
    return 0;
}

Чтобы программа поприветствовала вас, её нужно запустить следующим образом: $ ./a Maksim Pelevim.


Отступление

Использования строки-си (c-string), вообщем-то, неудобно во всех случаях. Вместо неё в Си++ давно придумали класс string.


  1. хорошим началом будет чтение Э. Таненбаума «Современные операционные системы»
  2. Бьерн Страуструп «Язык программирования C++. Специальное издание», БИНОМ, 2011 г., раздел 5.1

Сообщить об опечатке

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