>
>
>
Ненавижу, _____, C++ массивы!

Антон Третьяков
Статей: 5

Ненавижу, _____, C++ массивы!

Или почему мне кажется, что про них нужно знать, но не нужно использовать.

Вступление

Друзья, вы помните момент, когда впервые сунули указатель на первый элемент массива в оператор sizeof, и ваш код перестал работать так, как вы задумывали? Это, конечно, и близко не сравнится с эмоциями от засовывания пальцев в розетку, но...

Вот массив:

int arr[5] = {1, 2, 3, 4, 5};

А вот он уже указатель:

int *ptr = arr;

Произошёл array-to-pointer conversion, а мы потеряли информацию о его размере и пару нервных клеток в придачу. Не поймите меня превратно — прежде чем использовать массив, его нужно сначала научиться готовить, как и любую другую особенность нашего любимого языка. Но мне не нравится, что правила языка как будто бы постоянно пытаются обмануть программиста и напрасно усложнить рецепт.

Проблема, как мне кажется, заключается в том, что этот пресловутый array-to-pointer convertion фактически навязывает систему мировоззрения, в которой использование массива и указателя на его первый элемент абсолютно эквивалентно и всегда ведёт к одному и тому же поведению при использовании одинакового кода. Тем не менее массивы и указатели далеко не взаимозаменяемы. Более того, в языке C++ встречаются такие контексты, в которых можно пожалеть об использовании одного вместо другого.

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

Во-первых, давайте с самого начала определимся с совсем уж базовыми вещами. Указатель имеет свой собственный тип, а именно compound type вида pointer-to-T. Массив — сущность отдельная, значит, и тип он тоже имеет отличный от типа указателя. Почему бы нам не проверить это прямо в лоб?

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

Да, typeid отбрасывает ссылочность и cv-квалификацию. Оба этих концепта не используются нами конкретно в этой части статьи. Если бы мы использовали typeid для того, чтобы узнать, как какой тип выводится в шаблонах, то мы бы наступили на большущие грабли. К счастью, в нашем огороде таких граблей нет, и сейчас мы всего лишь смотрим на тип явно объявленной переменной.

Объявляем массив, смотрим на его тип:

int arr[3] = { 1, 2, 3 };
std::cout << typeid(arr).name() << std::endl;

Наш компилятор его определяет как A3_i, то есть массив из трёх элементов типа int.

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

int *ptr = arr;
std::cout << typeid(ptr).name() << std::endl;

В этом случае в stdout выводится Pi, то есть указатель на int.

Если типы разные, то откуда путаница?

Array-to-pointer conversion

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

Это называется array-to-pointer conversion и происходит в определённых стандартом языка С++ случаях, вызывая путаницу у новичков. Осложняется дело тем, что стандарт языка С++ не содержит единого перечисления случаев, в которых эта конвертация всё же происходит. Их приходится собирать по всему тексту, будто выполняя очередной квест в корейской MMORPG.

Когда конвертация не происходит

Давайте сначала посмотрим на случаи, когда конвертации не происходит.

discarded-value expression

discarded-value expression или выражение, результат которого не используется. В примере ниже конвертации не происходит:

int arr[3] = { 1, 2, 3 };
arr;

typeid

Как мы уже видели в самом начале статьи, оператор typeid умеет в массивы и возвращает разные строки для массивов и указателей.

sizeof

То же касается и оператора sizeof. Он умеет считать размеры как массивов, так и указателей. Код ниже отработает нормально:

int arr[3] = { 1, 2, 3 };
auto ptr   = arr;
static_assert(
    sizeof(arr) != sizeof(ptr)
);

Ссылки

Ссылки. Операции, которые без ссылки приведут к конвертации массива в указатель, при её использовании будут происходить с самим массивом. Ссылку на массив можно передать в функцию. Ссылку на массив можно вывести в шаблоне. Ссылки крутые. Будь как ссылка!

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

void foo(int (&a)[3]) {};
void foo(int *p) {};

int main()
{
    int arr[3] = { 1, 2, 3 };
    foo(arr);
}

Когда конвертация происходит

В языке С было проще. Там установлено несколько случаев, в которых array-to-pointer conversion не происходит. Стало быть, во всех остальных случаях —происходит. С++ язык несколько более сложный, поэтому этим правилом больше обойтись возможности не стало. Деваться некуда, давайте смотреть на случаи, в которых конвертация происходит.

Встроенные операторы

Начнём с операторов. Всякие плюсы, минусы, деления, умножения, индексация и прочие встроенные бинарные и унарные операторы не умеют работать с массивами. Если вы попробуете сложить два массива, то они сконвертируются в указатели. Это не значит, что два указателя можно складывать — операция эта, как минимум, бессмысленна, а как максимум, не предусмотрена стандартом.

Но если оно так работает со встроенными операторами, то как обстоят дела с операторами, определёнными пользователями?

Функции

Перегруженные операторы, как и другие функции, принимают аргументы. Стандарт говорит о том, что над аргументами функции, когда аргумент этот — массив, применяется array-to-pointer conversion. Это означает, что сам по себе массив в функцию передать нельзя — только указатель на его элемент.

Даже если вы запишите параметр функции как массив:

int foo(int arr[3]);

Внутри функции параметр arr будет именно указателем. Сам массив не скопируется.

Операторы приведения

При использовании static_cast и reinterpret_cast сконвертировать что-то в массив не получится — на выходе будет получаться указатель на элемент.

Строго говоря, в массив нельзя сконвертировать и с помощью const_cast и dynamic_cast. Другое дело, что эти двое, в свою очередь, просто не скомпилируются при попытке такой конвертации.

Тернарный оператор

Если второй или третий операторы тернарного оператора (который с вопросиком!) — массивы, то оператор вернёт указатель вместо массива.

Параметры шаблона

В контексте рассмотрения массивов non-type параметры шаблонов схожи с параметрами функций. Если объявить параметр как массив, то он на самом деле будет указателем:

template <int arr[3]>
void foo();

Вывод аргументов шаблонов

Массивы, используемые как аргументы шаблонов, из вызова функции выводятся тоже как указатели. В примере ниже параметр шаблона будет выведен как указатель:

template <typename T>
void foo(T arr) {};
//....
int arr[3] = { 1, 2, 3};
foo(arr);

Шаблон оператора приведения

То же валидно и для шаблона оператора приведения. Массивы становятся указателями:

template <typename T>
struct A {
    operator T() {
        static int arr[3];
        return arr;
    }
};

Не вините меня в этом, я вас плюсы учить не заставлял.

Хватит теории. Давайте стрелять по ногам

Навряд вы начали читать эту статью ради теоретических изысканий, которые вы, читатель, наверняка уже и так знаете. Но они были необходимостью, чтобы плавно подвести повествование к практическим моментам, которые нет-нет да и возникнут случайно в том или ином виде в текстовом редакторе программиста, оставив его с одной фразой: "Ненавижу C++ массивы!".

Функция, принимающая указатель на базовый класс

Давайте представим, что у меня есть два класса. Какой сам скомпилируешь, а какой компилятору отдашь? Один — базовый, второй — от него производный:

struct B {
    char i;
};

struct D : B {
    int y;
};

Помимо этого, где-то рядом определена функция, которая знай себе, да проходит по элементам массива объектов базового класса:

void foo(B *b, size_t size)
{
    for(auto &&el : std::span(b, size)) {
        std::cout << el.i << std::endl;
    }
}

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

int main()
{
    D arr[3] = { {'a', 1}, {'b', 2}, {'c', 3} };
    foo(arr, 3);
}

Это — безоговорочное UB. Но проблема не в этом, точнее не только в этом.

В примере выше внутри цикла последующий элемент будет вычисляться неправильно. Вместо того, чтобы напечатать печатать переменную i последующего объекта класса B, программа выводит padding bytes, следующие за этой переменной и добавленные компилятором для целей выравниваниявыравнивания. Действительно, на нашем компиляторе sizeof(B) равен одному, а sizeof(D) — восьми.

Теперь допустим ситуацию, в которой оба этих класса полиморфны. Добавим в них виртуальные функции:

struct B {
    char i;
    B(char i) : i(i) {};
    virtual void print() { std::cout << "BASE " << i << "\n"; }
};

struct D : B {
    int y;
    D(char i, int y) : B(i), y(y) {};
    void print() { std::cout << "DERIVED " << i << " " << y << "\n"; }
};

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

В указанном примере причина кроется в реализации полиморфных классов, используемой в компиляторе. Логичным, но не обязательным способом реализации полиморфизма является добавление указателя vtable в объекты полиморфного класса, который указывает на таблицу, содержащую различную информацию о классе объекта. Видимо, в нашем случае указатель добавляется в конец структуры, что заставляет компилятор выравнивать размер всей структуры по этому указателю. Для этого компилятор добавляет 7 padding bytes после переменной i в объектах класса B, и 3 после этой же переменной, но при использовании объектов класса D (так как 4 байта уходят на переменную y). В итоге размер обоих структур становится одинаковым, и итерация проходит корректно. Если, например, поменять тип переменной y на long, то такой щедрости мы уже не получим.

Большое неудобство здесь заключается в том, что компилятор не выдаёт никакого предупреждения на это, поскольку конвертация указателя на производный класс в указатель на базовый поддерживается правилами языка. Поэтому можно представить ситуацию, в которой код работает с одним компилятором и платформой (хотя работать не должен), и падает в других условиях. Принимай функция параметры типа std::array или std::span, проблемы бы не возникло.

Лямбда

Взглянем на следующий код:

#include <iostream>

int main()
{
    int arr[3] = {1, 2, 3};

    auto sizeof_1 = [arr] {
        return sizeof(arr);
    };

    auto sizeof_2 = [arr = arr] {
        return sizeof(arr);
    };

    auto sizeof_3 = [=] {
        return sizeof(arr);
    };

    std::cout << sizeof_1() << std::endl;
    std::cout << sizeof_2() << std::endl;
    std::cout << sizeof_3() << std::endl;
}

Что мы знаем про лямбда-выражения? Например, у них есть захваты. Захваты захватывают (!sic) переменные по значению (=) или по ссылке (&). Реализуется захват путём создания компилятором служебного класса, в котором каждая захваченная переменная является нестатическим полем класса.

Если переменная записана без знака амперсанда и при этом не является переменной this, то она передаётся по значению. В коде выше все массивы передаются по значению. Таким образом, все они будут приведены благодаря array-to-pointer conversion. Значит, программа выведет три раза одинаковое число.

Либо мы можем прочитать на том же cppreference, что члены-данные лямбды, находящиеся в выражении-захвате без инициализатора, проходят через direct-initialization. Если захватывается массив, то каждый его элемент инициализируется через direct-initialization в восходящем направлении индекса. Значит, программа выведет одинаковые числа, только не те, что мы думали раньше.

Либо мы можем прочитать там же, что при наличии инициализатора захватываемая переменная инициализируется таким образом, как предписывает инициализатор. Инициализировать ранее объявленным массивом можно только переменную типа указатель на элемент этого массива. Следовательно, при использовании в захвате записи [arr = arr] захвачен будет всё-таки указатель на первый элемент в отличии от других способов нотации захвата по значению.

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

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

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

Итерация

Впрочем, о копировании массива сказано достаточно. Давайте затронем и итерирование по нему.

В языке С++ на данный момент есть два способа итерации: классический цикл for и его range-based вариация. Оба можно использовать для итерации по массивам. В обоих есть известные сложности с итерацией через указатель.

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

int arr[3] = {1, 2, 3};
for(auto &&element : arr) std::cout << element << std::endl;

Под капотом это дело развернётся в обычный цикл for, который оперирует итераторами. При этом сия конструкция будет успешно работать и в случае создания на месте переменной arr в цикле prvalue массива. Временный массив будет привязан к forwarding ссылке и останется существовать до конца цикла.

Возможно, кто-то из читателей уже строчит комментарий о том, что пример с range-for циклом некорректен, так как задействует библиотечные функции для получения итераторов. Дескать, параметр element будет получен через библиотечную функцию std::begin (или std::cbegin, в зависимости от константности элемента), а итератор, указывающий на границу массива, через std::end (или std::cend). Действительно, у этих функций есть перегрузки на массивы. Но будь внимателен, программист, по той же ссылке на стандарт можно прочитать, что итерация по массивам итераторы не использует: только старые добрые указатели.

В то же время при замене массива на указатель паровоз перестанет заводиться. Следующий код даже не скомпилируется:

int *ptr = arr;
for(auto &&element : ptr) std::cout << element << std::endl;

И если в ситуации с range-based циклом использование в нём указателей приведёт к несобираемому коду, то старший брат for может быть более суров в плане последствий.

Итерация по многомерному массиву

Предположим, есть у нас многомерный массив. Например, матрица целых чисел:

int arr[2][2][2] = { 0, 1, 2, 3, 4, 5, 6, 7 };

И вдруг нам понадобилось проитерироваться по этому массиву, чтобы, например, изменить каждое значение. Мы знаем, что при индексации массива типа T[N] нам возвращается T. В нашем случае T — это int[2][2]. К полученной конструкции мы также можем применить операцию индексации, получив при этом объект типа int[2], и ещё раз, дойдя, наконец, до заветного int. По уму, если делать это через обычные циклы for, то таких циклов понадобится три.

А ещё мы знаем, что стандарт гарантирует последовательное расположение элементов массива один за другим. Фактически это правило применяется рекурсивно ко всем частям многомерного массива — все элементы типа int[2][2] расположены последовательно, а внутри них последовательно расположены все элементы типа int[2] и так далее.

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

#include <iostream>

int main()
{
    int arr[2][2][2] = { 0, 1, 2, 3, 4, 5, 6, 7 };
    for(size_t i = 0; i < 8; ++i) {
        std::cout << arr[0][0][i] << std::endl;
    }
}

В нём мы пытаемся обращаться к элементам типа int через самый первый подмассив int[2] самого первого подмассива int[2][2], таким образом заходя за границы массива аж трёх объектов. Но элементы типа int всё равно лежат один за другим в памяти, это же сам стандарт гарантирует! Действительно, гарантирует, равно как и гарантирует, что поведение не определено для приведённого кода.

UB плохо само по себе. Ещё хуже то, что этот код может вполне себе прилично работать, ведь элементы правда расположены последовательно. А может и грохаться весьма шумно. Если же вы нам не верите, то можете убедиться сами.

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

Может, у тёмных сил всё-таки есть какое-то колдовство, какая-то магия, которая нам позволит обойти ограничение? Переписываем код следующим образом:

#include <iostream>
#include <type_traits>

int main()
{
    int arr[2][2][2] = { 0, 1, 2, 3, 4, 5, 6, 7 };
    
    auto ptr = reinterpret_cast<std::remove_all_extents_t<decltype(arr)>*>(arr);
    for(size_t i = 0; i < 8; ++i) {
        std::cout << ptr[i] << std::endl;
    }
}

Предположим, некий злой гений решил получить тип конечного элемента многомерного массива через std::remove_all_extents_t, после чего привести этот массив с помощью reinterpret_cast к указателю на этот элемент. Фактически такие хитросплетения приводят к образованию аналога функции flat() из других языков программирования, которая сплющивает многомерный массив в одномерный. И мы даже экономим на арифметике указателей, прибавляя к ptr только один индекс, а не три, как в случае с arr.

К сожалению, это такое же UB. В этом случае, помимо выхода за границы массива, свой вклад вносят правила strict aliasing: нельзя обращаться к объекту через тип, отличный от того, с которым он был создан. Другое дело, что с тем же компилятором, что использовался в предыдущем примере, пример текущий не только компилируется, но и работает без падений, причём даже со включённым санитайзером.

Аккуратнее, друзья, произвольные конвертации между типами массивов до добра не доводят!

Размер массива

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

А что дальше?

Надеемся, что эта статья не воспринялась вами как критика built-in массивов языка С. В конце концов там он имел своё место и своё применение, обусловленные спецификой этого языка.

В то же время говорим мы сейчас именно о языке С++, и было бы неправильно нам под конец этой заметки оставить один крайне логичный вопрос без ответа. Вопрос этот звучит примерно таким образом: "А что нам делать в языке С++ с такой проблемной фичей из языка С?"

Строго говоря, лучшего ответа, чем "используйте std::array или std::span", нам найти не удалось.

Проблема с использованием указателя на базовый класс при итерации по массиву объектов производного класса решится использованием std::array или std::span. Компилятор не позволит толкнуть массив производных элементов в массив элементов базовых.

В "лямбдах" мы поговорили о вырожденном случае передачи массива в захваты по значению, в котором поведение отличалось. И снова std::array решает проблему — во всех случаях передачи его по значению происходит полное копирование всех элементов. В случае со std::span сами элементы копироваться не будут, опять же, во всех трёх случаях.

"Итерация" по std::array и std::span работает лучше некуда. Хочешь — обычный for, хочешь — range-based, а можно запросто и библиотечные функции.

В части "итерации по многомерному массиву", правда, std::array и std::span практически ничем не могут помочь. При желании и с ними можно допустить аналогичные ошибки, а объявлять многомерный std::array и std::span замучаешься. Ну, не у всего же должны быть свои плюсы, правильно? Но если объявлять многомерные вещи хочется компактно, а 23-е плюсы уже используются в проекте, можно посмотреть в сторону std::mdspan.

Заключение

Такие вот дела. Встроенные массивы нужно уметь готовить, но вот вопрос: а стоит ли, если в С++ есть более безопасные альтернативы? Вопрос этот, скорее, риторический, может быть даже и философский. Оставим себе лишь надежду на то, что после этой небольшой заметки ответить на этот вопрос вам, читатель, стало чуточку легче!

А если легче не стало, то обязательно пишите ваши вопросы в комментарии!

Благодарим, что дошли до конца! El Psy Kongroo.