Вся правда об указателях. Часть 3: Завершающая

Поговорив в первой части о сути указателей, а во второй о том, как можно выделять память под них, пришло время завершающей статьи, в которой мы поговорим о том, какие ещё применения указателей существуют. Настоящая статья, на мой взгляд, является наиболее сложной из 3-х, поэтому знания из предыдущих 2-х здесь обязательны.

Одномерные массивы и указатели

Одной из основных возможностей языков программирования, без которой немыслимо программирование вообще, является использование массивов. Все мы когда-то использовали массивы и знаем, ещё с языка C, что массив можно объявить следующим образом:

int array[55];

Отлично, но, как мы говорил ранее, не всегда достаточно массива статического размера, иногда на этапе компиляции мы не знаем, сколько реально элементов нам нужно, поэтому к нам приходят на выручку динамические массивы. Динамические массивы реализуется через указатели, т.к. только посредством указателей мы можем достучаться до выделенной динамически памяти. Вот как мы можем создать такой же массив, только в куче:

int n = 55;
int* dynArray = new int[n];

Хотя я мог бы использовать 55 прямо в new выражении, я не стал этого делать, чтобы показать, что мы используем переменную, а не константу для создания массива и наша n может приходить откуда угодно — это и есть динамическая часть выражения.

В прошлой статье мы не рассматривали выражение new с квадратными скобками, и если, по какой-то причине, вы не знаете, что оно делает, то это выражение выделяет память под массив элементов, в отличии от его собрата без квадратных скобок. Т.е. вместо выделения одного элемента, выделяется память под N элементов, и у каждого элемента вызывается конструктор по умолчанию(если он есть). Соответственно, так выделенную память удалять простым delete мы не можем, т.к. обычный delete вызывает только один деструктор, а, в общем случае, это некорректно. Поэтому, вызов delete dynArray будет считаться неопределённым поведением. Правильно удалять его следующим образом:

delete[] dynArray;

Легко запомнить, что то, что выделено с квадратными скобками, должно быть освобождено с ними же, а то, что было выделено без оных, должно быть освобождено обычным delete. Это, кстати, является частой ошибкой новичков: они используют неправильную форму delete и получают «странное» поведение программы, вплоть до падения оной.

Но мы немного отвлеклись, итак, мы имеем два массива одинакового размера: array и dynArray — один выделен в стеке, другой в куче. Но они также имеют разный тип, array имеет тип int[55], тогда как dynArray имеет тип int*. И это очень важно различие, которое мы сейчас увидим на примере:

#include <iostream>
using namespace std;

int main()
{
    int array[55];
    int n = 55;
    int* dynArray = new int[n];
    std::cout << "Size of the static array: " << sizeof(array) << "\n";
    std::cout << "Size of the dynamic array: " << sizeof(dynArray) << "\n";
    delete[] dynArray;
    return 0;
}

Каков будет вывод этой программы? На моей, 64-битной, платформе вывод будет таким:

Size of the static array: 220
Size of the dynamic array: 8

Разница в выводе объясняется просто: в типе array содержится информация о том, сколько элементов в нём содержится(55), а также какие элементы он содержит(int), поэтому мы имеем корректный размер: sizeof(int)*55 = 4*55 = 220. Но с dynArray совсем другая история — это простой указатель, поэтому его размер равен размеру указателя. Никакой дополнительной информации мы из него извлечь не можем, и если мы «забыли» сколько элементов мы выделил, тогда у нас нет ни одного способа «вспомнить», за исключением тех случаев, когда в массиве содержится некий набор данных, который можно посчитать, не зная размер заранее(например, строка, которая оканчивается нуль-символом). Вот вам первая разница в использовании, между массивами двух типов. Вообще, строго говоря, массивом является только array, а вот dynArray это просто указатель, который мы уже используем в качестве массива, выделив память под несколько элементов.

Обращение к элементам

Давайте пойдём дальше и инициализируем наши массивы какими-то числами:

for(int i = 0; i < n; ++i)
{
    array[i] = i;
    *(dynArray + i) = i;
}

Как вы можете видеть, массив инициализируется просто и понятно: array[i] = i, т.е. мы применяем оператор индексации к массиву, что каждый раз даёт нам конкретный элемент массива: array[0], array[1] и т.д. Инициализация динамического массива выглядит несколько сложнее, и подобная запись часто пугает новичков. Но, как мы помним из первой статьи, это ничто иное как использование арифметики указателей. Т.е. на каждой итерации цикла мы смещаем указатель на i позиций и разыменовываем его, получая доступ к i-му элементу в куче. Так что всё просто, ничего нового. Но даже от этого мы можем избавиться, нам вовсе необязательно использовать арифметику указателей! Мы можем написать и так:

for(int i = 0; i < n; ++i)
{
    array[i] = i;
    dynArray[i] = i;
}

В этом случае инициализация двух массивов выглядит идентично и только по этим строкам невозможно оценить, что является статическим массивом, а что указателем. Но с какой стати мы можем так делать, ведь указатель это не массив?! Всё правильно, не массив, но оператор индексации для простых типов(не классов, где он переопределён)  это просто синтаксический сахар над арифметикой указателей, поэтому dynArray[i] это на самом деле *(dynArray + i), просто для удобство использования язык допускает запись с оператором индексации.

Но вы не подумайте, что это правило действует только с указателем — нет, я же сказал,— для всех простых типов, а это значит, что array[i] это ничто иное как *(array + i)! Автор, ты нас запутал, array ведь не является указателем, почему к нему применяется арифметика указателей?! Безусловно, array не является указателем, но в этом выражение(как и почти в любом, собственно) array преобразуется в указатель, т.е. его тип, в этом выражении, становится int*. Это может быть сложно для понимания, чтобы упростить его вы можете думать об имени массива как об адресе его первого элемента(а адрес это ничто иное как значение указателя) — это технически не совсем корректно, но в сущности это так и есть: array == &array[0]. Кстати, возможно вы ранее встречали последнюю форму записи, когда где-то требуется адрес массива, некоторые люди предпочитают именно её(по причине мне не известной, хотя сам так делал; это почему-то добавляло коду «взрослости» в моих глазах).


Кстати, небольшое отступление: как я уже сказал, array[i] это ничто иное как *(array + i), но это правило симметрично, а это значит, что i[array] это *(i + array)! Вот как это может выглядеть в коде:

for(int i = 0; i < n; ++i)
{
    i[array] = i;
    i[dynArray] = i;
}

Выглядит дико, не правда ли? Поэтому так ни один ментально здоровый человек и не пишет в реальном коде, но некоторые «сильно умные» собеседники на собеседовании могут подобное спросить. Им же надо потешить своё ЧСВ, не так ли?


Итак, если вы всё же запутались и не понимаете почему происходит такое жонглирование между указателями и массивам, то давайте ещё немного разберёмся. Дело в том, как мы уже говорили в первых двух частях, память есть массив байт, а все эти типы нужны для интерпретации массива байт программой(попробуйте поработать просто с массивом байт, без имён и типов — то ещё удовольствие). Поэтому, в основе любого обращения к массиву байт лежит адрес, а, как нам известно, именно указатель является тем типом, который содержит адрес. Поэтому указатель есть основа многих других конструкций, включая и массивы. Массив это абстракция над указателем, который присутствует в языке просто потому, что людям проще работать с массивами данных и операторами индексации(это похоже на то, как происходит работа с массивом данных в математике), а не задумываться каждый раз об адресах, арифметике указателей и прочем. Поэтому, вы можете с определённой долей уверенности(хотя это не совсем верно с точки зрения стандарта) рассуждать об одномерном массива как об указателе, с той лишь разницей, что sizeof даст разное значение для массива и указателя, а также указатель, наверняка, будет указывать на память в куче, тогда как массив на куче создан быть не может — только на стеке или в статической области.

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

Сравнение массива и указателя

Как вы можете видеть, указатель может располагаться в одной части памяти, а сам массив, на который он указывает, удалён от него и находится в совсем другом участке памяти. С другой стороны, массив есть массив и он не может быть отделён от себя, массив и есть участок памяти, просто его имя это адрес его первого элемента. Это имя нигде отдельно не хранится — адрес массива есть неотъемлемая часть самого массива. Другими словами, получается, что имея указатель мы имеем как бы две сущности: сам указатель и безымянный массив(может быть единичного размера), на который этот указатель указывает. А имея массив мы имеем единую сущность. Это различие очень важно понимать. Хотя это может показаться и не столь важным в случае одномерного массива, далее вы увидите, что это очень важно для многомерных.

Печать массива

Чтобы продемонстрировать единение в использовании одномерного массива и указателя, давайте напишем функцию печати значений массива:

#include <iostream>
using namespace std;

void printPointer(int* array, int n)
{
    std::cout << "Printing pointer: " << array << "\n";
    for(int i = 0; i < n; ++i)
        std::cout << array[i] << ", ";
    std::cout << "\n";
}

int main()
{
    int array[55];
    int n = 55;
    int* dynArray = new int[n];
    for(int i = 0; i < n; ++i)
    {
        array[i] = i;
        dynArray[i] = i;
    }
    int arraySize = sizeof(array) / sizeof(array[0]);
    printPointer(array, arraySize);
    printPointer(&array[0], arraySize);
    printPointer(dynArray, n);
    
    delete[] dynArray;
    return 0;
}

Если вы запустите этот код, то увидите идентичный вывод с той лишь разницей, что третий «принтер» отобразить другой адрес содержащийся в указателе. Вот так вот функция, которая не знает, что ей передали массив распечатала его точно так же, как сделало эт�� с указателем. Заметьте, что в качестве параметра функции нам пришлось передавать размер входного массива, т.к. мы никак не можем узнать его из указателя. Но внимательный читатель возмутиться — «мы можем узнать размер из массива, давайте напишем отдельную функцию для массива и не будем превращать наш массив в указатель!». И вот какую реализацию мог бы предложить наш внимательный читатель:

void printArray(int array[])
{
    printPointer(array, sizeof(array) / sizeof(array[0]));
}

Или, скорее, вот такую:

void printArray(int array[55])
{
    printPointer(array, sizeof(array) / sizeof(array[0]));
}

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

printArray(array);

Вот какой вывод получился на моей 64-х битной системе:

Printing pointer: 000000708FD2FA10
0, 1,

Занимательно, не правда ли? вместо 55 элементов мы вывели всего 2! Но почему так произошло, почему вывод такой? Если немного подумать, то станет ясно, что выведено 2 элемента потому, что выражение sizeof(array) / sizeof(array[0]) равно двум(неужели?!), а так как sizeof(array[0]) это sizeof(int), значит, что оно всегда равно 4(мы так условились). А это, в свою очередь значит, что sizeof(array)  вернул 8. Уже поняли, что произошло? Нет? Так вот, 8 это как раз размер указателя на 64-х битной машине. Так почему в функции появился указатель, мы ведь явно указали, что тип аргумента — массив? Дело в том, что здесь вступает в силу одно идиотское C++ правило — аргумент функции, вида T arg[] и T arg[N] всегда интерпретируются как T* arg, т.е. нет смысла и даже вредно использовать такие типы в аргументах функции — они только запутают того, кто будет читать код, нужно явно использовать указатель, чтобы не оставалось иллюзий. На этом, кстати, горят почти все новички, да и не новички тоже — не удивительно, функционал настолько не интуитивен, насколько вообще это может быть. Почему так сделано? Мне сложно сказать, я могу лишь гадать: вероятно это правило появилось вследствие того, что массивы в C нельзя было копировать(можно скопировать явно, через memcpy, но не как с другими переменными), а тогда передача массива по значению выливается непонятно во что — что должно происходить? Но то, что сделали все равно не оправдывает этого, лучше бы вообще запретили массивы в аргументах функции, чем делать черти что. Ну да ладно, мне легко рассуждать, возможно были какие-то причины, которые мне не известны, поэтому давайте оставим гадания и просто примем как данность данную особенность.

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

void printArray(int (*array)[55])
{
    printPointer(*array, sizeof(*array) / sizeof((*array)[0]));
}

Вызываем:

printArray(&array);

Или через ссылку:

void printArray(int (&array)[55])
{
    printPointer(array, sizeof(array) / sizeof(array[0]));
}

Вызываем:

printArray(array);

Но всё это далеко от идеала, конечно. Мы вынуждены задавать размер массива статически. Поэтому, канонической версией использования одномерного массива в функции,  является передача его по указателю, с передачей его размера вторым аргументом. Т.е. так, как мы делали изначально.

Смешивание

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

int* array[13];

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

int* array[5];
for(int i = 0; i < 5; ++i)
{
    if(i % 2 == 0)
        array[i] = new int[3];
    else
        array[i] = new int(5);
}

В коде выше, четные элементы массива array указывают на массивы int’ов, а нечетные на одиночные int’ы(каждый равен 5). Картинкой:

Массив указателей

С таким же успехом мы могли добавить массив двойных указателей, тройных и т.д. — не важно.

С другой стороны, указатель тоже может указывать на что угодно, поэтому можно написать так:

int main()
{
    int (*ptrToArray)[5];
    int array[5] = {0, 1, 2, 3, 4};
    ptrToArray = &array;
    std::cout << (*ptrToArray)[2] << "\n";
    return 0;
}

Получается, что наш указатель указывает на массив:

Указатель на массив

Синтаксис, конечно, заставляет желать лучшего, но тем не менее — мы можем так сделать. Почему такой синтаксис? Потому что если убрать скобки, то получим следующее: int *ptrToArray[5], а это уже массив из 5-и указателей на int, что является совершенно другим типом. Поэтому такой компромисс.

Подводя краткий итог, хотелось бы сказать, что все те знания, что были вами получены в данной статье должны быть усвоены, но не более того. Код приведённый в данной статье вы можете встретить в старом коде C++, но ни в коем случае не должны писать его в новом. Если вам нужен динамический массив в куче — используйте std::vector, если нужен статический — std::array. Они полностью покрывают все случаи использования своих «голых» аналогов унаследованных из C. При этом они лишены почти всех болезней, которые имеют оные. Я не буду расписывать их тут, не о том статья. Но вы должны понимать, что стоит использовать, а что нет.

Многомерные массивы и указатели

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

int array2d[5][5];

Размер такого массива будет равен:

std::cout << sizeof(array2d) << "\n";

5*5*sizeof(int) == 100. Логически, двумерный массив представляет собой матрицу, где первые квадратные скобки задают количество строк, а вторые — количество столбцов. Давайте, создадим не квадратный массив и заполним его:

int main()
{
    int array2d[3][5];
    int columns = sizeof(array2d[0]) / sizeof(int);
    int rows = sizeof(array2d) / columns / sizeof(int);
    for(int i = 0; i < rows; ++i)
        for(int j = 0; j < columns; ++j)
            array2d[i][j] = j + columns*i;
    return 0;
}

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

Матрица

Хотя логическое представление весьма интересно, куда интереснее узнать, как же такой массив расположен в памяти. С одномерным массивом всё просто — разложили каждое значение последовательно и всё, а что же с многомерными? А с многомерными не сильно сложнее, вот так вот наш, представленный выше, массив будет выглядеть в памяти:

Двумерный массив в памяти

Стоп, но ведь он не отличим от одномерного! Совершенно верно, не отличим и давайте продемонстрируем это кодом:

#include <iostream>
using namespace std;

int main()
{
    int array2d[3][5];
    int columns = sizeof(array2d[0]) / sizeof(int);
    int rows = sizeof(array2d) / columns / sizeof(int);
    for(int i = 0; i < rows; ++i)
        for(int j = 0; j < columns; ++j)
            array2d[i][j] =   j + columns*i;
    cout << "Print as a 2D array: \n";
    for(int i = 0; i < rows; ++i)
        for(int j = 0; j < columns; ++j)
        {
            cout << "array(" << i << "," << j << ") = "
                << array2d[i][j] << "\n";
        }
    cout << "Print as a 1D array: \n";
    int* array1d = reinterpret_cast<int*>(array2d);
    for(int i = 0; i < rows; ++i)
        for(int j = 0; j < columns; ++j)
        {
            cout << "array(" << i << "," << j << ") = "
                << array1d[j + i*columns] << "\n";
        }
    return 0;
}

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

Далее, вы можете видеть, что для одномерного массива я использую следующее выражение, для обращения к элементу массива: j + i*columns. Я мог бы просто пробежаться по массиву, c i от 0 до 14 и получил бы тот же самый вывод, но я предпочёл показать, что используя подобное вычисление, мы можем обратиться к ячейке матрицы с помощью одномерного массива и такого вот нехитрого вычисления(например, когда нам нужно получить 3 элемент из 4-й строки, то без формулы не обойтись).

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

for(int i = 0; i < rows; ++i)
    for(int j = 0; j < columns; ++j)
        *(*(array2d + i) + j) = j + columns*i;

Т.е. именно это, на самом деле, скрывается за строчкой array2d[i][j] = j + columns*i;. Как вы можете видеть в коде выше, разыменовывание указателя происходит 2 раза, а это значит, что двумерный массив является или же эквивалентен двойному указателю, так? Нет не так, и я сейчас объясню почему. Как мы уже говорили, имя массива, когда используется в выражениях, неявно преобразуется в указатель, но так было с одномерным массивом, неужели у двумерного то же самое, и теряется полностью информация о N-мерности массива? Да, всё точно так же, только вот не вся информация о N-мерности пропадает, как мы сейчас и увидим. Так, имя нашего массива array2d преобразуется в следующий тип: int (*)[5] — в указатель на массив из 5 элементов. Получается, что при участии имени массива в выражении его старшая разрядность как-бы «откусывается», а на её место становится «безразмерный» указатель. Это можно рассмотреть на более сложном примере:

float arrayND[1][2][3][4][5][6][7];
auto onceDecayedArray = arrayND;
auto twiceDecayedArray = *onceDecayedArray;
auto thriceDecayedArray = *twiceDecayedArray;
auto noDecay = onceDecayedArray;

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

  • onceDecayedArray и noDecay будут иметь один тип — float (*)[2][3][4][5][6][7]
  • twiceDecayedArrayfloat (*)[3][4][5][6][7]
  • thriceDecayedArray float (*)[4][5][6][7]

Как вы можете видеть, тип noDecay не отличается от onceDecayedArray, потому что указатель всегда остаётся указателем, если его не преобразовать явно. С другой стороны, разрядности у двух других переменных продолжают убывать, хотя мы не используем имени массива. Всё это происходит потому, что мы разыменовываем указатель и получаем «голый» тип массива: для выражения *onceDecayArray этот тип будет float [2][3][4][5][6][7], но, как мы уже говорили, такой тип всегда вырождается в указатель в выражениях(не важно, есть имя или нет — вырождение происходит для типа, а не имени!). Поэтому мы «откусываем» [2], добавляем указатель и результирующий тип будет float (*)[3][4][5][6][7]. Всё просто и с одномерным массивом происходит ровно то же самое, просто у него одна размерность, которая «откусывается», поэтому и получается, что одномерный массив и одинарный указатель очень похожи.


Если кто-то хочет поиграться с типами и посмотреть, какие типы получаются в той или иной ситуации, вы может воспользоваться тем фактом, что компилятор в Visual Studio очень хорошо отображает имена типов; к примеру, вот как можно было бы вывести тип noDecay:

cout << typeid(noDecay).name() << "\n";

Теперь, рассмотрев как в общем виде происходит вырождение многомерного массива в указатель на оставшиеся измерения, пришло время вернуться к нашему примеру и разобраться как работает синтаксис арифметики указателей с многомерными массивами:  *(*(array2d + i) + j) = j + columns*i;. Для этого давайте распишем это выражение построчно, чтобы стало легко понять, как и что происходит здесь:

int(*ptrToRow)[5] = array2d + i;
int* row = *ptrToRow;
int* ptrToColumn = row + j;
*ptrToColumn = j + columns*i;

Итак, разбор начинается изнутри наружу. Первый действием у нас является array2d + i, как мы уже знаем array2d в выражении имеет тип int(*)[5], а прибавляя к нему i мы получаем i-ю строку. Как мы это получаем? Очень просто, как мы помним, сумма указателя и числа даёт на выходе адрес, смещённый на число*sizeof(тип), где тип это то, на что указывает указатель. У нас указатель указывает на массив из 5 int’ов, поэтому смещение указателя array2d на i, даст следующий абсолютный прирост в байтах к адресу array2d: i*sizeof(int)*5. Таким образом, каждое прибавление i пропускает 5 int’ов, а это и есть наши колонки. Поэтому при такой сумме мы получаем адрес первой колонки в i-й строке, что эквивалентно адресу i-й строки.

Далее, мы получаем одномерный указатель на int из ptrToRow — это и есть адрес нашей строки(первого столбца). Важно понимать, что ptrToRow и row указывают на один и тот же адрес, просто в ptrToRow содержится информация о том, что это указатель на массив, а в row мы её уже потеряли(откусили). Последним действием остаётся сместить наш полученный указатель на j позиций, получив нужную колонку и присвоить ей значение. Вот и всё — всё просто, и я надеюсь, что роспись по действиям помогла вам в понимании, а не наоборот. В любом случае, подобные выражения только поначалу могут показаться страшными, разобрав несколько штук, вам станет проще понимать их.

Двойной указатель

Т.к. мы переключились с одномерного массива на двумерный, то было бы логичным переключиться с одинарного указателя на двойной. Но, как вы могли заметить, в разговоре по двумерным указателям, мы ни разу не упоминали двойной указатель. Более того, при разборе N-мерных массивов мы не видели более одной звёздочки! Что всё это значит? А значит это следующее: только одинарные указатели могут работать с массивами. Поднимаясь выше, на двойные, тройные и т.д. указатели, мы полностью теряем связь с массивами. Это очень важно понимать и мы сейчас разберём почему так. Для этого давайте перепишем наш пример с массивами, на двойной указатель:

#include <iostream>
using namespace std;

int main()
{
    int rows = 3;
    int columns = 5;
    int** doublePtr = new int*[rows];
    for(int i = 0; i < rows; ++i)
    {
        doublePtr[i] = new int[columns];
        for(int j = 0; j < columns; ++j)
            doublePtr[i][j] = j + columns*i;
    }
    cout << "Print as a 2D array: \n";
    for(int i = 0; i < rows; ++i)
        for(int j = 0; j < columns; ++j)
        {
            cout << "array(" << i << "," << j << ") = "
                << doublePtr[i][j] << "\n";
        }

    for(int i = 0; i < rows; ++i)
        delete[] doublePtr[i];
    delete[] doublePtr;
    return 0;
}

Первое, на что следует обратить внимание это выделение памяти. Мы начинаем с того, что выделяем память под массив из 5 элементов, каждый из которых будет содержать int. Т.е. мы имеем указатель на массив указателей — двойной указатель. Как вы помните, указатель по умолчанию ничем не инициализирован, поэтому мы в цикле инициализируем каждый наш элемент,— для этого мы выделяем память под каждый элемент(указатель) и только потом пробегаемся по выделенному массива, инициализируя его значениями. Освобождение памяти выглядит похоже, только в обратном порядке:

for(int i = 0; i < rows; ++i)
    delete[] doublePtr[i];
delete[] doublePtr;

Как вы можете видеть, сначала освобождаются все указатели, которые содержаться в массиве указателей, а затем освобождается и сам массив. В остальном код выглядит идентично тому, что мы имели с двумерным массивом. И это не с проста — логически, мы получаем точно такую же матрицу, что и раньше. Так в чём же разница? Те, кто внимательно читал все статья, я полагаю, уже догадался — разница в том, как всё это дело лежит в памяти. В отличии от двумерного массива, который единым куском лежит в памяти, области памяти, на которые указывают указатели могут располагаться где угодно в памяти:

Двойной указатель в памяти

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

Ну ладно, располагаются «настоящие» двумерные массивы и двумерные массивы, которые имитируются посредством двойного указателя по разному, что с того? Да, в целом, ничего, кроме того, что конвертации между этими двумя сущностями быть не может, а следовательно нельзя написать единую функцию печати матрицы, которая бы принимали и двойной указатель и двумерный массив. Это просто невозможно. И это очень важно: если одномерные массивы и одинарный указатель могут показаться одинаковыми и их поведение, в целом, совпадает, то начиная с двумерных массивов и двойных указателей это совершенно разные сущности. Между ними нет ничего общего, кроме того, что они могут представлять одну и ту же логическую абстракцию(матрицу, к примеру). Я бы не стал заострять внимания на этом, если бы не видел множество кода новичков, которые пытались передать двумерный массив по двойному указателю в функцию — это невозможно. И вы теперь понимаете почему. С тройными и далее указателями, а также многомерными массивами дела обстоят ровно так же, поэтому отдельно говорить о них мы не станем.

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

Указатели и ссылки

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

Согласитесь, что гораздо приятнее читать такой код:

void increment(int& value)
{
    value++;
}

чем такой:

void increment(int* value)
{
    (*value)++;
}

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

  • Ссылка не может быть создана неинициализированной.
  • Ссылка не может быть «нулевой».
  • Ссылку невозможно изменить.
  • Ссылка может продлевать жизнь временным объектам.

Но, вообще говоря, сравнивая ссылки с указателями, нужно помнить, что с точки зрения языка это две совершенно разных сущности, поэтому это сравнение условно. Другое дело, что ссылки, на деле, являются не более чем «синтаксическим сахаром» поверх указателей. Если посмотреть ассемблерный код предыдущих двух функций, то он будет идентичен. Вот, к примеру, какой код я получил для обеих функций в gcc:

add     DWORD PTR [rdi], 1
ret

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

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

int passByReference(const int& value)
{
    return value + 1;
}

Мало того, что это бесполезно, это ещё можно считать и пессимизацией. Возьмём ещё функцию, куда int передаётся по значению и сравним сгенерированный код:

passByReference(int const&):
        mov     eax, DWORD PTR [rdi]
        add     eax, 1
        ret
passByValue(int):
        lea     eax, [rdi+1]
        ret

Как вы можете видеть, первая функция загружает число из памяти и только потом его инкрементирует, тогда как вторая выполняет все вычисления в регистрах и вообще не обращается к памяти. Это очень показательный пример, когда бездумное использование ссылок может сказаться на производительности. Поэтому, нужно не забывать, что использование передачи по ссылке на константу необходимо лишь тогда, когда копирование объекта является затратной операцией, и сам объект является довольно большим.

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

Указатели и функции

До этого момента мы говорил только об указателях на данные, данные, которые не могли быть исполнены. Пришло время восполнить этот пробел и поговорить об указателях на функции. В целом, тут нет ничего необычного — как и в случае со всем остальным, чтобы создать указатель, нужно объявить тип того, на что он будет указывать и добавить звёздочку. Но что за тип имеет функция? Тип функции получить очень просто  — уберите имя функции и вы получите её тип. К примеру, типом std::strcmp является int(const char *, const char *), т.е. функция, которая принимает два аргумента типа const char* и возвращает int. Давайте попробуем создать такой объект:

int (var)(const char *, const char *) = std::strcmp;

Не получается, компилятор не даёт нам создать такой объект. А всё почему? Потому что int var(const char *, const char *)(со скобками и без) является декларацией функции, а пытаясь присвоить значение std::strcmp мы уже делаем попытку определить var, но для определения функций существует специальный синтаксис, который все вы прекрасно знаете. Поэтому, такой синтаксис запрещён. Но мы можем пойти по другому пути и определить указатель на функцию:

int (*var)(const char *, const char *) = std::strcmp;

Теперь всё компилируется, но что нам это даёт? Теперь мы можем вызывать strcmp посредством нашего указателя:

int result = (*var)("Hello!", "Hello");
std::cout << result << "\n";

Ладно, ладно можем мы так делать, но кому это нужно? Я же могу вызвать функцию без указателя, а напрямую по имени — это удобнее и лучше выглядит! Всё это так, но не всегда удобно вызывать конкретную функцию, иногда хочется вызвать  одну из семейства функций. И здесь нам на выручку приходит указатели на функции, ведь они именно это и описывают — семейство функций, с жёстко заданной сигнатурой. Можно считать, что обычная функция это как литерал(константа) в обычных типах, а указатель на функцию это переменная.

В качестве примера предлагаю ничего не выдумывать, а посмотреть на std::qsort — четвёртым аргументом эта функцию как раз принимает указатель на функцию, с заданной сигнатурой. И эта функция используется для сравнения аргументов при сортировке. Таким образом, можно написать одну функцию сортировки, а сортировать там всё, что угодно — int’ы, объекты классов — не важно, главное напишите функцию сравнения. Кроме этого, такой подход позволяет сортировать как в восходящем, так и в нисходящем порядке — всё зависит от функции сравнения.

Как это работает

Хорошо, мы разобрались кому это нужно, но почему это работает? Почему имя функции превращается в указатель? А тут действует схоже правило с массивами — имя функции неявно преобразовывается в указатель. Но тут ситуация ещё веселее: разыменовав массив, мы получаем первый элемент массива, а что происходит с разыменованной функцией? А собственно ничего и не происходит: разыменовав указатель на функцию, мы получаем «голый» тип функции, который в чистом виде существовать не может и опять превращается в указатель, поэтому мы можем написать такое «красивое» выражение:

int(*var)(const char *, const char *) = std::strcmp;
int result = (************var)("Hello!", "Hello");

По желанию, можно добавить ещё звёздочек. Вот такая вот особенность. Ещё одной особенностью является то, что если типом аргумента функции является функция, то это означает, что на самом деле это указатель(«голый» тип вырождается в указатель) — точь в точь ситуация с аргументом, чей тип является массивом:

void function(void (arg)(int, int))
{
    //arg имеет тип void(*)(int, int)
}

В целом, данное поведение очень похоже у функций и массивов — оно и понятно, и то, и то непонятно как копировать. Но если с массивом копирование ещё можно придумать, то что такое копия функции?

Функции-члены

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

class SimpleClass
{
public:
    static SimpleClass& staticFunction() noexcept
    {
        static SimpleClass object;
        return object;
    }
    
    int nonStaticFunction() const
    {
        return 0;
    }
};

Так вот, со статическими функциями дело обстоит ровно так же, как и со свободными:

SimpleClass& (*ptr)() noexcept = SimpleClass::staticFunction;
(*ptr)();

С нестатическими дело обстоит чуть сложнее, несколько усложняется запись типа указателя:

int (SimpleClass::*ptr)() const = &SimpleClass::nonStaticFunction;

Заметьте, мы теперь ссылаемся на класс при объявлении указателя, это означает, что такому указателю можно присвоить указатели только из этого класса.

На самом деле не только, есть ещё «родственные» отношения между классами, которые могут быть использованы в том числе и в таких указателях. Мы не будем на этом останавливаться. Это уже выходит за рамки статьи.

Кроме того, в отличии от свободных и статических функций, нестатической функции нужен объект, для которого она будет вызвана. Поэтому, что вызвать нестатическую функцию через указатель, мы должны написать так:

SimpleClass instance;
(instance.*ptr)();

Здесь использует специальный оператор «.*», а не последовательность точки и звёздочки. Вообще, для использования нестатических функций этих знаний достаточно, но по ним одним можно написать целую статьи, потому как они значительно выделяются на фоне всех других(как раз из-за тех самых «родственных» отношений, упомянутых выше). К примеру, размер указателя на любую функцию, кроме нестатической, скорее всего будет равен sizeof(void*), а вот с нестатическими совсем другая история(к примеру, clang и gcc показывают размер 16). Но это на самом деле отдельная тема, на которой мы не будет останавливаться — это не важно, в плане использования.

Указатели на функции являются очень востребованным механизмом в C, без которого невозможно представить ни одной мало-мальски сложной программы. В C++ ситуация несколько иная, т.к. в C++ куда чаще вы увидите функторы(классы с реализованным operator()), лямбды(те же функторы) и  std::function — все они используются вместо обычных указателей на функции, т.к. часто они гораздо удобнее, как минимум за счёт того, что могут хранить состояние. Тем не менее, в C++ коде указатели на функцию тоже встречаются часто, т.к. очень многие библиотеки являются либо полностью сишными, либо имеют сишный интерфейс, поэтому в WinAPI, ffmpeg и многих других API функции обратного вызова(callback) передаются именно по указателю. Кроме того, в общем случае, указатель на функцию может иметь меньший размер, чем функтор, что так же вынуждает программистов останавливать свой выбор на таких указателях.


Другие статьи цикла:

Часть 1. Вводная

Часть 2. Памятная