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

Вступление

Друзья, вы помните момент, когда впервые сунули указатель на первый элемент массива в оператор 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, поэтому строка будет различаться в зависимости от компилятора. Но тип тем не менее будет определяться как один и тот же.

NOTA BENE для тех, кому не нравится typeid.

Да, 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.

Комментарии (149)


  1. Daniel217D
    19.06.2024 10:37
    +5

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


    1. tntnkn Автор
      19.06.2024 10:37
      +3

      Тут, мне кажется, тоже ещё вопрос, а быстрее ли build-in массив, чем std::array? Учитывая, что последний, фактически, это тонкая прослойка над первым, даже этот плюс не факт, что перевешивает.


      1. mxr
        19.06.2024 10:37
        +2

        Да, быстрее, причём ощутимо. Писал backtracking, где миллиарды итераций, и все обёртки и абстракции оптимизировались в простые структуры, в основном массивы. Разница в рядовом приложении вряд ли будет ощутима, но там где большое количество повторений миллисекунда может превратится в лишний час, а то и не один, работы.


        1. unreal_undead2
          19.06.2024 10:37
          +3

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


          1. tolich_the_shadow
            19.06.2024 10:37

            Например, на передаче в функцию по значению.


            1. unreal_undead2
              19.06.2024 10:37
              +1

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


              1. tntnkn Автор
                19.06.2024 10:37

                Вот да. Проблема может быть, если был typdef массива, а потом стал тайпдефаться в то же самое уже std::array. Получится неприятно.


                1. unreal_undead2
                  19.06.2024 10:37
                  +1

                  Конечно нельзя просто так заменить одно на другое. Но если сразу сознательно использовать std::array, причин для просадки производительности не вижу.


        1. tntnkn Автор
          19.06.2024 10:37
          +11

          Против практики не попрёшь, но...

          И libstdc++, и libcxx в std::array в прямом смысле просто используют оператор индексации build-in массива в своём операторе индексации. Оно, скорее всего, заинлайнится. Поэтому мне пока что не очевидно, как std::array может проигрывать, во всяком случае в индексации. Да, там есть асерты даже в operator[], но они не должны отрабатывать в продакшн коде.

          Инициализация и там и там агрегатов, так что тоже разницы не должно быть.


    1. MiyuHogosha
      19.06.2024 10:37
      +1

      Плюс у него насамом деле в монотонности памяти и том что массив char может служить хранилищем любого объекта (по формулировке в стандарте)


      1. DancingOnWater
        19.06.2024 10:37
        +2

        В std::vector элементы также лежат в непрерывном виде.


        1. MiyuHogosha
          19.06.2024 10:37

          Верно, и реализовпн через динамически выделенный массив (с закидоном на UB). но стандарт говорит о другом. По факту, обьект можно разместить в таком массиве (placement new) или инспектировать через него (на совести программиста и реализации)


          1. tntnkn Автор
            19.06.2024 10:37
            +1

            А в чём закидон на UB? Если переаллокация сделается?


            1. MiyuHogosha
              19.06.2024 10:37
              +2

              Формально большинство "оптимальных" реализаций вектора использовали malloc() для выделения памяти без инициализации.

              https://stackoverflow.com/a/52997191/2742717

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

              В 23м это было предложено следующее https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/p0593r6.html


              1. tntnkn Автор
                19.06.2024 10:37

                Да, спасибо, что подсветили. Тут к вопросу о том, возвращает ли malloc указатель на элемент массива? Если нет, то итерировать по этой памяти формально нельзя, даже если это - массив char.


      1. tntnkn Автор
        19.06.2024 10:37
        +2

        И в std::array, и в std::vector память расположена последовательно. Если std::vector хотя бы может переаллоцировать память, то std::array - это просто обёртка на буфером, то есть массивом.


  1. IvanPetrof
    19.06.2024 10:37
    +10

    Можно ещё так

    5[arr]=10;

    ))


    1. tntnkn Автор
      19.06.2024 10:37

      Классика) Причины понятны. Но я вот сам не встречал случаев, когда так записать и правда удобнее. Если у кого-то они есть, здорово бы было посмотреть.


      1. MiyuHogosha
        19.06.2024 10:37
        +4

        Часто с литералами так ((n >> i)%16)["0123456789ABCDEF"] или похожие. у встроенного оператора чуть больше возможностей


        1. tntnkn Автор
          19.06.2024 10:37

          Тут хочешь не хочешь, но спросишь - почему не отвести отдельную переменную под этот литерал? Но об удобстве точно не спорят, пример хороший, спасибо!


        1. tixo
          19.06.2024 10:37
          +1

          Но привычный порядок на два символа короче ):
          "0123456789ABCDEF"[(n >> i)%16]


          1. tntnkn Автор
            19.06.2024 10:37

            По идее, если литерал ДЛИННЫЙ, то будет удобнее, если в начале) Но почему тогда не отвести отдельную переменную?


        1. eptr
          19.06.2024 10:37
          +1

          Часто с литералами так ((n >> i)%16)["0123456789ABCDEF"] или похожие.

          А что мешает записать так: "0123456789ABCDEF"[(n >> i) % 16]?
          Как раз на лишнюю пару круглых скобок короче.


      1. eptr
        19.06.2024 10:37
        +2

        Но я вот сам не встречал случаев, когда так записать и правда удобнее. Если у кого-то они есть, здорово бы было посмотреть.

        #include <cstdlib>
        #include <iostream>
        
        int main() {
        	int const a[]{1, 2, 3};
        
        	for (auto const *itr{a}, *const end{1[&a]}; itr != end; ++itr) {
        		std::cout << *itr << std::endl;
        	}
        
        	return EXIT_SUCCESS;
        }

        Вариант для C:

        #include <stdlib.h>
        #include <stdio.h>
        
        int main(void) {
        	int const a[] = {1, 2, 3};
        
        	for (int const *itr = a, *const end = 1[&a]; itr != end; ++itr) {
        		printf("%i\n", *itr);
        	}
        
        	return EXIT_SUCCESS;
        }
        


        1. domix32
          19.06.2024 10:37

          А вот так не понял. Как оно превращается в адрес конца? Я думал оно позволяет свапать индекс и имя переменной из-за ассоциативности.


          1. tntnkn Автор
            19.06.2024 10:37

            Чуть дополнил оригинальный пример, должно стать понятнее. Смотрите на приоритет операторов.


          1. eptr
            19.06.2024 10:37
            +4

            Я думал оно позволяет свапать индекс и имя переменной из-за ассоциативности.

            Да, в "обычном" виде пришлось бы использовать скобки, чтобы "преодолеть" приоритеты и заставить операции выполняться в нужном порядке: (&a)[1].

            А вот так не понял. Как оно превращается в адрес конца?

            Требуется понимание адресной арифметики и типов.

            Преобразуем к независимому виду: *(&a + 1).
            Приоритет & выше бинарного +.

            Итак, раз берём адрес, то &a — указатель, но на элемент — какого типа?
            Мы берём адрес массива, значит на массив.
            На массив из 3-х элементов типа int.

            Далее идёт адресная арифметика, поэтому жизненно необходимо знать sizeof элемента, к указателю на который применяется адресная арифметика, ибо указатель будет "шагать" именно на этот размер, и этот размер есть sizeof целого массива a, массива из 3-х элементов типа int.

            К указателю прибавляется 1, значит указатель "шагнёт" на 1 такой массив.
            И получится адрес сразу за концом массива.

            После этого данный адрес разыменовывается.
            Поскольку указатель — на массив, то, в результате разыменования получится массив (того же типа, что и массив a, то есть, из 3-х элементов типа int).

            При попытке проинициализировать массивом переменную типа указатель на int с именем end, этот массив неявно приводится к указателю на свой первый элемент, который как раз тоже имеет тип int, поэтому никаких дополнительных приведений не требуется, точно так же, как этого не потребовалось при "инициализации массивом a" указателя itr.

            Но адрес этого элемента как раз находится сразу за концом массива a, что и требовалось получить.

            Для единообразия можно написать так:

            	for (int const *itr = 0[&a], *const end = 1[&a]; itr != end; ++itr) {

            Или, в более привычном виде, так:

            	for (int const *itr = (&a)[0], *const end = (&a)[1]; itr != end; ++itr) {

            Типы выражений a, (&a)[0] и (&a)[1] — одинаковы.
            Это всё — массивы из 3-х элементов типа int.
            И каждый из них может быть неявно приведён к указателю на свой первый элемент.

            Теперь, думаю, всё должно быть полностью понятно.


        1. tntnkn Автор
          19.06.2024 10:37
          +1

          Огонь! Сохранил себе, спасибо!

          Если в плюсах можно (и нужно!) сказать, что лучше пользовать итераторы или ranged-for, то в С это выглядет вполне себе!


          1. eptr
            19.06.2024 10:37
            +2

            Огонь! Сохранил себе, спасибо!

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

            Это больше для собственного развития и только для своего личного кода.


            1. tntnkn Автор
              19.06.2024 10:37

              А я любознательный)

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


  1. NeoCode
    19.06.2024 10:37
    +5

    То что имя массива это адрес первого элемента - конечно же ошибка дизайна С/С++. Сейчас ее наверное не исправить, если только не пойти путем явного версионирования языка, что я периодически предлагаю в разных обсуждениях. В начале файла пишем #pragma version(2), что означает что данный файл содержит код на немного другом, улучшенном диалекте С++, имеющем несовместимости с первой (существующей в настоящий момент) версией. При этом оба файла могут сосуществовать в одном проекте, компилируются одним компилятором и линкуются одним линкером. Все нововведения вносить только в версию 2. А лет через 20 отменить версию 1.


    1. tntnkn Автор
      19.06.2024 10:37
      +2

      Или перейти на С++2!


    1. vanxant
      19.06.2024 10:37
      +3

      ошибка дизайна С

      Нет, конечно, это было осознанное и взвешенное решение со своими плюсами и минусами.

      Вот в плюсы тащить это было не нужно. Ну т.е. совместимость понятно, но надо было оставлять как есть. Зачем, например, завозили new[] и delete[]?


      1. tntnkn Автор
        19.06.2024 10:37
        +1

        Тут я бы зашёл с другой стороны. Если new[] и delete[] и так знают размер массива (иначе как бы он тогда удалялся), зачем было делать вид, что он не известен (в случае аллокации). Но это - философский вопрос о совместимости с Си.


        1. MiyuHogosha
          19.06.2024 10:37
          +2

          new[] и delete[] обязаны вызвать конструктор и деструктор для каждого элемента массива.

          На самом деле на целом ряде платформ в прошлом (старые Винды и VС или какой-то Borland) и сейчас (встраиваемые) можно использовать delete после new[] и ничего не ломалось. В винде GlobalAlloc всегда записывает сколько ему памяти попросили выделить, на "салфетке бургомистра", эту информацию можно узнать и вызов освобождения GlobalFree этого значения не требует. А вон на линхе узнать размер выделенного блока, насколько я знаю, нельзя.

          А в других случаях нужно передавать размер, но не массива а блока памяти на уровне ОС и он где-то хранится. В других это другая функция. Это зависит от того, как организован менеджер памяти в ОС. И поэтому стандарт разрешает рабочей части delete и delete[] иметь одну и ту же реализацию "рабочей" части, высвобождающей память, но мешать их - это UB.


          1. unreal_undead2
            19.06.2024 10:37

            можно использовать delete после new[] и ничего не ломалось.

            Память то оно может и корректно освободит, а что с вызовом деструкторов?


            1. hiewpoint
              19.06.2024 10:37

              Если известен аллоцированный объем, то и количество объектов, для которых надо вызвать деструктор, известно.


              1. unreal_undead2
                19.06.2024 10:37

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


                1. hiewpoint
                  19.06.2024 10:37
                  +2

                  стандартный delete не обязан делать, некоторые реализации могут это делать, чтобы иметь общий код для delete и delete[]


                  1. unreal_undead2
                    19.06.2024 10:37

                    Можно и так, по стандарту всё равно UB ) Чисто теоретически интересно посмотреть поведение delete на массиве на разных компиляторах.


    1. AKudinov
      19.06.2024 10:37
      +3

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


      1. tntnkn Автор
        19.06.2024 10:37
        +1

        Непрерывная память - это лучшее, что придумали программисты)

        Скорее, речь о правилах пользования этим концептом в нашем языке.


      1. NeoCode
        19.06.2024 10:37
        +1

        Именно ошибка дизайна языка. Есть стандартный оператор взятия адреса &. Вот его и нужно было использовать для получения адреса массива (т.е. первого элемента массива). А само имя массива должно было стать first-class сущностью, как имя объекта структуры.


        1. eptr
          19.06.2024 10:37
          +1

          Есть стандартный оператор взятия адреса &. Вот его и нужно было использовать для получения адреса массива (т.е. первого элемента массива).

          Адрес массива, полученный с помощью &, и адрес первого элемента массива дают совершенно разные типы указателя.


          1. NeoCode
            19.06.2024 10:37

            Кстати да:) И это было бы правильно и типобезопасно. Адрес массива был бы адресом объекта, адрес первого элемента - указателем в памяти для перебора элементов. Кажется, в стандарте MISRA требуют явно писать &arr[0] вместо arr.


            1. tntnkn Автор
              19.06.2024 10:37

              Это бы, кстати, не отменяло возможность каста такого указателя на такой массив-объект к указателю на его первый элемент, примерно как кастуется указателя на структуру к указателю на первое поле структуры. То бишь явно.

              Вообще, мне вот пришла мысль после публикации статьи, что было бы удобно в таком языке "Супер-Си" иметь отдельную штуку под массив элементов известного типа, в котором нет никаких array-to-pointer преобразований, и отдельно - указатель на какую-то "сырую" память. К массивам применять безопасную индексацию, а к указателям на "сырую" память - арифметику указателей. Пускай технически оно реализовывалось бы одинаково, но семантически разница станется огромная. Казалось бы, при чём тут `std::array`...


    1. ImagineTables
      19.06.2024 10:37
      +1

      Это не ошибка, а прямое следствие из принципа «Одинаковый синтаксис у указателей и массивов».

      А этот принцип делает удобной прямую работу с памятью (ниша, для которой Си и создавался).


      1. NeoCode
        19.06.2024 10:37
        +1

        Не имею ничего против прямой работы с памятью, это замечательно и я сам постоянно этим пользуюсь. А вот почему по аналогии с массивами имя объекта структуры не является адресом первого элемента структуры? Вот по этой же причине и имя массива не должно. Для взятия адреса есть оператор &. А имена переменных должны всегда быть объектами. Соответственно, многие чисто синтаксические неоднозначности исчезнут, язык станет логичнее.


        1. ImagineTables
          19.06.2024 10:37

          А вот почему по аналогии с массивами имя объекта структуры не является адресом первого элемента структуры?

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

          Соответственно, многие чисто синтаксические неоднозначности исчезнут, язык станет логичнее.

          А заодно исчезнет статический полиморфизм. В виде макросов, которым всё равно, что на входе. В таких формах мне он не нужен (или я не могу вспомнить, для чего нужен), но консистентность убер аллес.


          1. tntnkn Автор
            19.06.2024 10:37
            +1

            Индексы реализованы через арифметику указателей в С и С++. Но то, что это технически одно и то же не значит, что они должны быть синтаксически одним и тем же. Взять вот такой пример. Можно было бы представить гипотетический язык "Супер-Си", в котором в этом случае происходило бы копирование массива, и sizeof выводил был нормально именно, что размер массива. Не хочется копировать массив - передавай его по указателю. Это была бы нормальная удобная семантика, консистентная со всем остальным в Си.

            Но не буду становится еслибыдакабышником)


            1. ImagineTables
              19.06.2024 10:37

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


              1. tntnkn Автор
                19.06.2024 10:37
                +1

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


    1. eptr
      19.06.2024 10:37

      То что имя массива это адрес первого элемента - конечно же ошибка дизайна С/С++.

      Имя массива не является адресом его первого элемента.
      Ошибка дизайна C касательно массивов — в другом.


      1. tntnkn Автор
        19.06.2024 10:37

        *Приводится к указателю на первый элемент в подавляющем большинстве случаев.

        А в чём ещё ошибка?


        1. eptr
          19.06.2024 10:37
          +2

          А в чём ещё ошибка?

          В том, что это — недотип.

          Все нормальные агрегатные типы, вроде struct и union, но кроме массива, позволяют инициализацию переменной значением другой переменной того же типа, откуда сразу следует возможность передачи по значению в функцию и возврата из неё также по значению, потому что передача и возврат по значению — суть инициализация.

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

          Массив же нельзя инициализировать другим массивом того же типа, и нельзя одному массиву присвоить значение массива другого типа.

          При этом существует "особо специальный массив", которым массив проинициализировать всё-таки можно, — строковый литерал.

          Проинициализировать можно, а присвоить уже нельзя.

          Приводится к указателю на первый элемент в подавляющем большинстве случаев.

          Само по себе это не проблема, проблемой является слишком большой набор случаев.

          Приводится же функция неявно к указателю на такую функцию?
          Приводится же лямбда без захвата неявно к указателю на функцию?

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

          #include <cstdlib>
          #include <iostream>
          
          void fun() {std::cout << "Yes!" << std::endl;}
          
          int main() {
          	auto const f{fun};
          
          	(**********f)();
          	(**********[]{std::cout << "No!" << std::endl;})();
          
          	return EXIT_SUCCESS;
          }

          В 7-ой строке функция неявно приводится к указателю на неё и этим значением инициализируется указатель f.

          В 9-ой строке указатель на функцию разыменовывается сначала один раз, получается функция.

          Затем получившаяся функция неявно приводится к указателю на функцию и разыменовывается ещё раз.

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

          В 10-ой строке, для лямбды, после её неявного приведения к указателю на функцию, процесс аналогичен.

          Набор случаев, когда функция/лямбда приводится к указателю на функцию, сбалансирован.

          Например, для лямбд:

          #include <cstdlib>
          #include <iostream>
          
          typedef decltype([](int const x){std::cout << x << std::endl;}) l_t;
          
          void fun0(l_t const l) {
          	l(5);
          }
          
          void fun1(void (*const f)(int)) {
          	f(7);
          }
          
          int main() {
          	l_t const l1;
          	l_t const l2{l1};
          	l_t l3;
          
          	l3 = l2;
          
          	l1(1);
          	l2(2);
          	l3(3);
          
          	fun0(l1);
          	fun1(l1);
          
          	return EXIT_SUCCESS;
          }

          В 16-ой строке лямбда инициализируется значением другой лямбды.
          В 19-ой строке одна лямбда присваивается другой.

          В 25-ой строке лямбда передаётся в функцию fun0 по значению.
          В 26-ой строке лямбда неявно приводится к указателю на функцию и потом передаётся в функцию fun1.

          Нет вот этого "недотипства", как у массива, и неявное приведение не мешает, а когда надо — работает.

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

          При этом, если массив "обернуть" в struct или union, то это обёрнутое начинает прекрасно использоваться для инициализации переменной того же типа, а посему и прекрасно передаваться и возвращаться из функции по значению, а также прекрасно присваиваться одно другому.

          Но сделанного не воротишь, массив — недотип.
          Хоть он и недотип, но — никакой он не указатель.


          1. tntnkn Автор
            19.06.2024 10:37

            В очередной раз спасибо за подробный комментарий! Особенно нечего и добавить.

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

            НО!

            В случае с лямбдами всё иначе. Я дополнил ваш пример обычным вызовом лямды. В случае, когда к лямбде сначала применяется операторы разыменования, в ассемблер они всё-таки просачиваются (ну, одна операция). Код для оператора приведения к указателю на функцию (синтаксис, кстати, бодрящий!) генерируется. В отличии от случая, когда происходит просто вызов лямбы - там есть только один вызов call непосредственно сгенерированной функции.


  1. RichardMerlock
    19.06.2024 10:37
    +8

    А может просто не нагружать обычные указатели лишними смыслами и свойствами. Указатель - это всего лишь адрес начала объекта в памяти, а наличие у него типа предотвращает часть ошибок на этапе компиляции. Вот в принципе и всё. Это просто как косвенная адресации на ассемблере. Если хочется совсем уж умных указателей, то можно и свой тип навернуть.


    1. tntnkn Автор
      19.06.2024 10:37
      +1

      Да. Но речь про массивы.


      1. RichardMerlock
        19.06.2024 10:37

        Но и про указатели на массивы и сопутствующие проблемы...


        1. tntnkn Автор
          19.06.2024 10:37
          +3

          Да. Но в контексте массивов не программист наделяет указатели дополнительными смыслами, а стандарт С++ (и Си). С этой точки зрения как раз и можно сказать, что тот же std::array позволяет сделать то, о чём вы и говорите.


          1. RichardMerlock
            19.06.2024 10:37

            Я не понял в чем была проблема. Даже в последнем примере, где

            auto ptr = reinterpret_cast<std::remove_all_extents_t<decltype(arr)>*>(arr);

            применяется забористое заклинание вместо

            int* ptr = &arr[0][0][0];

            И все, дальше гоняйте по ptr[i] от нуля до sizeof(arr) / sizeof(int). Можете даже поставить указатель на доступную область памяти и прочитать блок хоть массивом, хоть одной переменной через адресную арифметику.


            1. tntnkn Автор
              19.06.2024 10:37

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

              UB есть и в вашем примере, строго говоря, по тем же причинам.


              1. MiyuHogosha
                19.06.2024 10:37
                +1

                А "свой" flat написать нельзя, так как Си++ предполагает очень обобщенную архитектуру. Я не знаю ни одного нативного компилятора на котором это бы не работало, но есть же и компиляторы Си++ в языки виртуальных машин.

                И если у нас в памяти гуськом идут одномерные массивы, это не одно и тоже что и один одномерным массив.

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

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


                1. tntnkn Автор
                  19.06.2024 10:37

                  Ну да, я об этом и говорю. Как говорится, указатели абстрактнее, чем кажется.

                  И чаще всего мы программируем опротив абстрактной машины С++, а не конкретной железяки.


                  1. RichardMerlock
                    19.06.2024 10:37
                    +1

                    Ну вот и да. УБ бояться, под МК не писать. Абстрактность указателей кореллирует с абстрактностью подхода.


                    1. tntnkn Автор
                      19.06.2024 10:37

                      Конечно, если бы idSoftware боялись UB, они бы не написали быстрый обратный квадратный корень. Но UB на то и UB, чтобы о работе программы нельзя было делать предположений после его возникновения.


                      1. RichardMerlock
                        19.06.2024 10:37

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


                      1. tntnkn Автор
                        19.06.2024 10:37
                        +1

                        Не совсем понял вашу мысль. UB вполне себе фигурирует и в Сишном, и в плюсовом стандартах. Так что это волне себе правило языка. "Вот ситуация, вот там нам меня не расчитывай", фактически. По хорошему, UB - это ограничения на способы написания кода, которые делают этот код более портабельным.


                      1. RichardMerlock
                        19.06.2024 10:37

                        Вот и я к этому же и веду, что УБ - именно, что правила написания переносимого кода, стандарт компиляции. То есть, под разные платформы список УБ должен быть разным в том числе и по архитектурным причинам. Про агрессивную УБ оптимизацию и так понятно.


                      1. tntnkn Автор
                        19.06.2024 10:37

                        Если посмотреть с этой стороны, то если вы пишите под конкретный компилятор на конкретной платформе - то и UB бояться нечего, так как вы знаете, что компилятор сделает.

                        А так единый список UB же и делает код переносимым, разве нет?


  1. ionicman
    19.06.2024 10:37
    +11

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

    Главная проблема - это понять что такое указатель в C, причём реально понять, а не проверхостно - это убирает большинство проблем и ошибок.

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


    1. tntnkn Автор
      19.06.2024 10:37
      +1

      Конечно, про понимание вы написали абсолютно правильно. Кстати, про понимание указателей есть отличная статья.


    1. dv0ich
      19.06.2024 10:37

      Главная проблема - это понять что такое указатель в C, причём реально
      понять, а не проверхостно - это убирает большинство проблем и ошибок.

      Нетрудно понять, что такое указатели, трудно удерживать в голове всю сеть указателей, указателей на указатели, указателей на указатели указателей...


      1. tntnkn Автор
        19.06.2024 10:37

        Если дело дошло до указателей на указатели на указатели, то это дело пахнет керосином.


        1. unreal_undead2
          19.06.2024 10:37
          +1

          Банальный список )


          1. tntnkn Автор
            19.06.2024 10:37

            Не, ну там же не `int ***********ptr`) Я не вдавался в подробности эффективной имплементации списков, но там, всё-таки, должна быть структура, в которой данные и указатель на структуру. А указателя на указатель ... на указатель там, вроде, нет.


            1. unreal_undead2
              19.06.2024 10:37
              +1

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


              1. tntnkn Автор
                19.06.2024 10:37

                Ну, сконвертировать указатель на структуру в указатель на первое поле структуры можно. Так что в этом случае вы правы)


              1. kenomimi
                19.06.2024 10:37
                +1

                чистого *** синтаксиса нет

                Видишь суслика? И я не вижу, а он есть :) Вот только на днях натыкался:

                extern SANE_Status sane_get_devices (const SANE_Device *** device_list,
                 SANE_Bool local_only);

                Фактически - указатель на указатель на массив указателей на пачку структур.


                1. tntnkn Автор
                  19.06.2024 10:37

                  Мне больше всего нравится слово "sane" в примере))

                  "a pointer to a NULL terminated array of pointers to SANE_Device structures in *device_list.". Забориста!


          1. AKudinov
            19.06.2024 10:37

            Список указателей на указатели?


      1. kenomimi
        19.06.2024 10:37
        +1

        А еще есть антипаттерн:

        typedef void *SANE_Handle;

        Без заглядывания в исходник попробуй сходу догадайся, что тут заныкан указатель, а не просто алиас или структура/перечисление.


        1. tntnkn Автор
          19.06.2024 10:37

          Как понимаю, sane - это API. Пользователю, по хорошему, не нужно думать, что во что тайпдефнуто, пока имплементация сама со всем хорошо разбирается. Но void * - штука опасная, если он кастится куда-то не туда, это точно.


  1. DimPal
    19.06.2024 10:37
    +8

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


    1. tntnkn Автор
      19.06.2024 10:37
      +3

      Под этим комментом пошло обсуждение скорости, думаю, лучше продолжить там.

      Но говоря об обёртках - зачем делать свою обёртку над массивом, если есть std::array?


    1. anonymous
      19.06.2024 10:37

      НЛО прилетело и опубликовало эту надпись здесь


      1. tntnkn Автор
        19.06.2024 10:37
        +4


      1. dv0ich
        19.06.2024 10:37

        Я звиняюсь, а какие аналоги есть у С++?


        1. vanxant
          19.06.2024 10:37

          Go, Rust и т.д.


          1. sha512sum
            19.06.2024 10:37
            +1

            Go слишком мал для такого. У него мало возможностей, за которые многие(и я тоже) любят C++. Вместо того, чтобы добавить возможностей в язык и сделать его чуть сложнее, они выбрали путь максимального упрощения, из-за чего много бойлерплейта, писать код неприятно. Я считаю такой подход в корне неправильным.


            1. vanxant
              19.06.2024 10:37

              Go по сравнению с С можно простить всё что угодно только за штатный способ возврата ошибки (т.е. result, error = f(x) )

              В современном многопоточном асинхронном мире сишный errno это даже не костыль, а якорь, не дающий всплыть.


          1. dv0ich
            19.06.2024 10:37
            +1

            Go не аналог плюсов. Rust - возможно, но меня напрягает его централизованная экосистема. Я таким уже не доверяю, какие бы благие цели они ни декларировали.

            Ну и чего-то аналогичного Qt у Раста пока нет. Вообще с GUI как-то не густо у него, я из графических программ на Rust встречал только amdgpu_top с простейшим GUI.

            А если чисто эстетически, то синтаксис у Раста просто ужас даже на фоне плюсов :)


            1. sha512sum
              19.06.2024 10:37
              +1

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


        1. anonymous
          19.06.2024 10:37

          НЛО прилетело и опубликовало эту надпись здесь


          1. tntnkn Автор
            19.06.2024 10:37

            Иф ю ноу, ю ноу


          1. dv0ich
            19.06.2024 10:37

            Вы решаете за меня, что мне нужно? Спасибо, но нет.


  1. unreal_undead2
    19.06.2024 10:37
    +3

    В заголовке стоило написать "C" - а то был уверен, что речь про std::array.


    1. tntnkn Автор
      19.06.2024 10:37
      +1

      Про std::array там речь тоже идёт.

      Изначальная идея текста была именно в том, чтобы рассмотреть build-in массив именно в рамках плюсов. И в практической, и в теоретической части рассматриваются случаи, которых нет в Си.

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


      1. unreal_undead2
        19.06.2024 10:37

        Про std::array там речь тоже идёт

        Но '_____' относилось именно к C массивам - а я зашёл посмотреть, что же не так с std::array.


        1. tntnkn Автор
          19.06.2024 10:37

          Если '_____' - которое в статье, то это задумывалось, как отсылка к Большому Кушу)

          А с std::array всё так)


  1. Taritsyn
    19.06.2024 10:37

    Не мог ли бы Вы указать источник цитаты из КДПВ?


    1. tntnkn Автор
      19.06.2024 10:37
      +1

      Могу сослаться только на голову автора, сиречь меня. Заглавка - адаптация мема с Кодзимой.


  1. dv0ich
    19.06.2024 10:37
    +2

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

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


    1. tntnkn Автор
      19.06.2024 10:37

      Modern C++ мы получили, осталось только дождаться С++2)

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


  1. vanxant
    19.06.2024 10:37
    +1

    Слушайте, а где почитать, почему gcc выдаёт такой стрёмный код для 3-мерного массива?

    Там же вообще нет нормального ret


    1. code_panik
      19.06.2024 10:37
      +2

      Код выглядит так из-за санитайзера. Компилятор с включенной опцией -fsanitize=undefined добавляет в исходный код нужные ему проверки. По теме есть лекция от разработчика asan https://www.lektorium.tv/lecture/23702


      1. vanxant
        19.06.2024 10:37
        +1

        Переформулирую вопрос - почему в принципе этот код падает? Ну т.е. что конкретно даёт компилятору творить такую дичь?


        1. ZirakZigil
          19.06.2024 10:37

          Потому что обращение к past-the-end, полагаю, и, как следствие, UB, которое и позволяет. Если есть arr[i][j][k], то выражение arr[i][j] есть некий массив sub_arr[k], и если за k выходим, то получаем UB.


          1. vanxant
            19.06.2024 10:37

            Нет, в случае многомерных массивов выход за пределы внутреннего массива UB не является.

            Есть куча либ, например, для обработки изображений, где этот паттерн очень активно используется. Когда нужно, ходят по [y][x], а в других случаях по [0][i]


            1. tntnkn Автор
              19.06.2024 10:37
              +1

              Является. В Сишном стандарте этот случай даже отдельно описан в списке UB из приложения.


              1. vanxant
                19.06.2024 10:37

                а можно цитату про UB? а то я что-то в упор не вижу, поиск тоже не помогает.


                1. tntnkn Автор
                  19.06.2024 10:37

                  An array subscript is out of range, even if an object is apparently accessible with the given subscript (as in the lvalue expression a[1][7] given the declaration int a[4][5]) (6.5.6).


                  1. vanxant
                    19.06.2024 10:37

                    Язык Си явно свернул куда-то не туда. Я бы ещё понял Unspecified Behaviour - например, на ряде платформ подобный трюк потенциально может вызвать аппаратный Alignment Fault. Но язык С тем и был ценен, что можно было сказать компилятору "заткнись, я знаю что делаю". Но вообще убирать ret, т.е. ветку штатного выхода из процедуры, как в примере выше, - это уж какой-то совсем лютейший перебор.

                    Не понимаю, как на этом ещё можно что-то писать в 2024. Софт не должен настолько ненавидеть кожаных мешков.


                    1. tntnkn Автор
                      19.06.2024 10:37

                      Ну. Сложно спекулировать о том, какие приколдесы могут случиться при UB, пока такие прикалдесы не встретились на практике. Например, в этом восхитительном выступлении на CppCon есть описание совершенно зубодробительного бага, следующего как раз из UB.

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


                      1. vanxant
                        19.06.2024 10:37

                        может ли вторая страница быть возвращена обратно ОС

                        нет, не может, потому что выделялся массив целиком (обе половины единым куском). Это тоже гарантия.

                        Ещё раз повторю, может быть AlignmentFault. Если допустим в самом внутреннем массиве нечётное количество элементов, и используется SIMD


            1. ZirakZigil
              19.06.2024 10:37

              C++20 § 9.3.3.4/9 говорит:

              [Note: When several “array of” specifications are adjacent, a multidimensional array type is created; only the
              first of the constant expressions that specify the bounds of the arrays may be omitted. [Example:
              int x3d[3][5][7];
              declares an array of three elements, each of which is an array of five elements, each of which is an array of
              seven integers. The overall array can be viewed as a three-dimensional array of integers, with rank 3 × 5 × 7.
              Any of the expressions x3d, x3d[i], x3d[i][j], x3d[i][j][k] can reasonably appear in an expression. The
              expression x3d[i] is equivalent to *(x3d + i); in that expression, x3d is subject to the array-to-pointer
              conversion (7.3.2) and is first converted to a pointer to a 2-dimensional array with rank 5 × 7 that points to
              the first element of x3d. Then i is added, which on typical implementations involves multiplying i by the
              length of the object to which the pointer points, which is sizeof(int)×5 × 7. The result of the addition
              and indirection is an lvalue denoting the ith array element of x3d (an array of five arrays of seven integers).
              If there is another subscript, the same argument applies again, so x3d[i][j] is an lvalue denoting the jth array element of the ith array element of x3d (an array of seven integers), and x3d[i][j][k] is an lvalue
              denoting the kth array element of the jth array element of the ith array element of x3d (an integer). — end
              example] The first subscript in the declaration helps determine the amount of storage consumed by an array
              but plays no other part in subscript calculations. — end note]

              Выделил интересное.

              C++20 § 7.6.6/4 говорит:

              When an expression J that has integral type is added to or subtracted from an expression P of pointer type,
              the result has the type of P.
              —(4.1) If P evaluates to a null pointer value and J evaluates to 0, the result is a null pointer value.
              —(4.2) Otherwise, if P points to an array element i of an array object x with n elements (9.3.3.4),77 the
              expressions P + J and J + P (where J has the value j) point to the (possibly-hypothetical) array
              element i + j of x if 0 ≤ i + j ≤ n and the expression P - J points to the (possibly-hypothetical) array
              element i − j of x if 0 ≤ i − j ≤ n.
              —(4.3) Otherwise, the behavior is undefined.

              Выделил интересное.

              Насколько я это понимаю, одназначное UB. Можете предложить интерпретацию без него?


        1. tntnkn Автор
          19.06.2024 10:37
          +1

          Если говорить о правилах языка - то выход за границу массива. Это UB, а при UB никаких гарантий поведения компилятора нет. Он может хоть "отформатировать диск", хоть запустить игрульку.


    1. vanxant
      19.06.2024 10:37

      Проверил этот всратый пример. Если заменить в условии цикла 8 на 2, то всё компилируется нормально.

      Другими словами, если разнести инициализацию массива и его использование по разным функциям, то всё будет работать, никаких UB не возникает.

      Говорите мне что хотите, но это 100% баг компилятора gcc


      1. tntnkn Автор
        19.06.2024 10:37

        Если в примере заменить условие цикла 8 на 2, то не будет UB в первую очередь.


  1. JordanCpp
    19.06.2024 10:37

    Что не так с std::array? Есть тип, размер. Что еще нужно то?


    1. tntnkn Автор
      19.06.2024 10:37
      +2

      С std::array всё так. Можно сказать, что статья про то, что с ним-то всё как раз в порядке.


  1. JordanCpp
    19.06.2024 10:37
    +1

    Массивы в С/С++ это указатель и все. Количество данных по указателю и их размер хранятся во вне с помощью дополнительных усилий программиста.


    1. tntnkn Автор
      19.06.2024 10:37

      Массивы в С++ - это не просто указатель. Как минимум, это указатель, к которому можно применять оператор сложения с числом. В противном случае получится UB.

      Плюс, размер даже build-in массива известен компилятору (то есть без усилий программиста) до того, как произошёл array-to-pointer.


      1. ionicman
        19.06.2024 10:37

        А разве к обычному указателю нельзя применять сложение с числом?

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


        1. tntnkn Автор
          19.06.2024 10:37

          Нет. Выше в комментарии дали как раз ссылку на стандарт.


          1. ionicman
            19.06.2024 10:37

            Т.е. это:

            #include <iostream>
            
            int main()
            {
                unsigned long a = 0xFFFEFDFC;
                unsigned char *p = (unsigned char *)&a;
                p += 2;
            
                printf( "%x", (unsigned char)*p );
                
                return 0;
            }

            UB? Или я что-то не так понимаю?


            1. tntnkn Автор
              19.06.2024 10:37

              Формально - да, так как lifetime массива не начался. Тут, вроде как, идёт дискуссия о внесении в том числе такого в стандарт. Это ещё к вопросу о том, что возвращает `malloc` - указатель на элемент массива, или нет?

              Другое дело что это везде работает. Но если хочется прямо формально безупречно, то можно сделать что-то такое.


              1. ionicman
                19.06.2024 10:37

                malloc - указатель на элемент памяти имхо, причём тут массив?

                А то, что выше я писал - в куче библиотек используется, очень сомневаюсь что стандарт, ломающий все это, примут.

                Ну и в страндарте есть странное possibly hypotetical array - так вот, любой кусок памяти это possibly hypotetical array of bytes.


                1. tntnkn Автор
                  19.06.2024 10:37

                  Там не "possibly hypotetical array", а "possibly hypotetical array element". Это костыль (имхо) для того, чтобы указатель мог указывать (sic) на элемент, следующий за последним в массиве, чтобы можно было писать `start < end' в цикле.

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

                  А так ясен-красен, что работает, как бы ещё тот же memset был сделан.


      1. aamonster
        19.06.2024 10:37

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


        1. tntnkn Автор
          19.06.2024 10:37

          Формально не можно. В соседнем трэде описано, почему.


          1. aamonster
            19.06.2024 10:37

            Что значит "не можно"? Выделил массив, взял указатель на любой элемент и алга!


            1. tntnkn Автор
              19.06.2024 10:37

              Ну, если вы делаете new[], то стандарт вам гарантирует наличие массива. Если вы делаете malloc, то такой гарантии у вас нет. В комментарии указали на целый пропоузал в стандарт, который в том числе с этим хочет разобраться.

              Опять же, мы говорим конкретно сейчас про то, что написано в стандарте.


              1. aamonster
                19.06.2024 10:37
                +1

                Ну да, но это всё касается не указателя как такового, а откуда вы можете его получить. Указатель после malloc – это вообще указатель просто на память, так что у вас будет C-style cast или ещё что – там и опасность, не в операциях с указателем.


    1. 26rus_mri
      19.06.2024 10:37
      +1

      как говорит ТС, они не просто указатель, но всех описанных проблем можно навсегда избежать если относиться как ним, как к указателям и делать как вы говорите: хранятся во вне с помощью дополнительных усилий программиста, или использовать контейнеры. Я так и делаю )


  1. nv13
    19.06.2024 10:37
    +3

    5 минут квалифицированного ворчания))

    А как надо понимать, допустим, l = (p+2) из исходного примера соответствующего 3-му элементу массива? И что должно возвращать sizeof от этого l? Или p это указатель на встроенный массив, а l - на его часть с 3 элемента?) То есть не просто int указатель?) А если всё таки просто, то значит между p и l тоже произошло какое то сужение исходного представления arr? А почему тогда не смириться, что оно происходит сразу при p=arr?

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


    1. tntnkn Автор
      19.06.2024 10:37

      Извините, я не очень понял, что вы имеете в виду) Если спросите другими словами, то отвечу.

      За похвалу спасибо)


      1. nv13
        19.06.2024 10:37
        +2

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


        1. tntnkn Автор
          19.06.2024 10:37

          А, ну... Я вот тут попытался немного порассуждать о языке "Супер-Си")


          1. nv13
            19.06.2024 10:37
            +1

            В Фортране, ну где то точно, массив это дескриптор из С массива и длины. И ОС, когда из кернела копирут данные, не в курсе про длину какого то инстанса в юзер спейс. И очень часто содержимое этого массива не интересует логику, которая его обрабатывает. Я всё к тому, что массив это массив, это реальность, оборачивание может случиться на другом уровне абстракции, если надо


  1. nickolaym
    19.06.2024 10:37

    https://gcc.godbolt.org/z/1bh4d616d

    Что-то с лямбдами неправда написана. array-to-pointer conversion выполняется только в случае [arr=arr]

    #include <iostream>
    
    int main() {
        int a[] = {1,2,3,4,5,6,7,8,9,10};
    
        [=]() { std::cout << sizeof(a) << std::endl; } ();    // 40
        [&]() { std::cout << sizeof(a) << std::endl; } ();    // 40
        [a]() { std::cout << sizeof(a) << std::endl; } ();    // 40
        [a=a]() { std::cout << sizeof(a) << std::endl; } ();  //  8
    }


    1. tntnkn Автор
      19.06.2024 10:37

      Там так и написано.


  1. aamonster
    19.06.2024 10:37
    +3

    Поднимите руки те, кто никогда не использовал sizeof для C-шных массивов.

    В ту же степь – многомерные массивы. Я изучал C после Паскаля, и изначально привык считать, что их нет вообще, и если тебе нужен многомерный массив (обычно нужны неизвестного заранее размера) – выделяй одномерный и играй с индексами.


    1. tntnkn Автор
      19.06.2024 10:37

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


      1. aamonster
        19.06.2024 10:37
        +1

        Именно!

        А поскольку обычно массив итерируешь по строкам/столбцам – один раз вычисляешь приращения индекса dx/dy и делаешь i+=dy для индекса в массиве или p+=dy для указателя.


        1. tntnkn Автор
          19.06.2024 10:37

          2d матрицы молотите?)


          1. aamonster
            19.06.2024 10:37
            +1

            Лет 25 назад по работе молотил (численное моделирование теплового взрыва). Ну и позже, когда анализом изображений занимался.


  1. r655
    19.06.2024 10:37

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

    template<size_t N>
    void func(int (&arr)[N])
    {
      ...
    }
    ...
    func({1,2,3});


    1. tntnkn Автор
      19.06.2024 10:37

      Только const int (&arr)[N]. Вы передаёте prvalue.

      А так да. Опускал сознательно. Про ссылки на массив вкратце упомянул в теоретической части.