Или почему мне кажется, что про них нужно знать, но не нужно использовать.
Вступление
Друзья, вы помните момент, когда впервые сунули указатель на первый элемент массива в оператор 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)
IvanPetrof
19.06.2024 10:37+10Можно ещё так
5[arr]=10;
))
tntnkn Автор
19.06.2024 10:37Классика) Причины понятны. Но я вот сам не встречал случаев, когда так записать и правда удобнее. Если у кого-то они есть, здорово бы было посмотреть.
MiyuHogosha
19.06.2024 10:37+4Часто с литералами так ((n >> i)%16)["0123456789ABCDEF"] или похожие. у встроенного оператора чуть больше возможностей
tntnkn Автор
19.06.2024 10:37Тут хочешь не хочешь, но спросишь - почему не отвести отдельную переменную под этот литерал? Но об удобстве точно не спорят, пример хороший, спасибо!
eptr
19.06.2024 10:37+1Часто с литералами так ((n >> i)%16)["0123456789ABCDEF"] или похожие.
А что мешает записать так:
"0123456789ABCDEF"[(n >> i) % 16]
?
Как раз на лишнюю пару круглых скобок короче.
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; }
domix32
19.06.2024 10:37А вот так не понял. Как оно превращается в адрес конца? Я думал оно позволяет свапать индекс и имя переменной из-за ассоциативности.
tntnkn Автор
19.06.2024 10:37Чуть дополнил оригинальный пример, должно стать понятнее. Смотрите на приоритет операторов.
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
.
И каждый из них может быть неявно приведён к указателю на свой первый элемент.Теперь, думаю, всё должно быть полностью понятно.
tntnkn Автор
19.06.2024 10:37+1Огонь! Сохранил себе, спасибо!
Если в плюсах можно (и нужно!) сказать, что лучше пользовать итераторы или ranged-for, то в С это выглядет вполне себе!
eptr
19.06.2024 10:37+2Огонь! Сохранил себе, спасибо!
Если вздумаете применять в коде, который потом отправляется на review, скорее всего, узнаете много новых слов.
Это больше для собственного развития и только для своего личного кода.
tntnkn Автор
19.06.2024 10:37А я любознательный)
Сам я на Си не пишу, а подход в плюсах и так обозначил выше. Но для собственного развития пример и правда очень хороший! Может, даже будет хорошей идеей посоветовать коллегам как вопрос для интервью, чтобы проверить границу понимания приоритетов.
NeoCode
19.06.2024 10:37+5То что имя массива это адрес первого элемента - конечно же ошибка дизайна С/С++. Сейчас ее наверное не исправить, если только не пойти путем явного версионирования языка, что я периодически предлагаю в разных обсуждениях. В начале файла пишем #pragma version(2), что означает что данный файл содержит код на немного другом, улучшенном диалекте С++, имеющем несовместимости с первой (существующей в настоящий момент) версией. При этом оба файла могут сосуществовать в одном проекте, компилируются одним компилятором и линкуются одним линкером. Все нововведения вносить только в версию 2. А лет через 20 отменить версию 1.
vanxant
19.06.2024 10:37+3ошибка дизайна С
Нет, конечно, это было осознанное и взвешенное решение со своими плюсами и минусами.
Вот в плюсы тащить это было не нужно. Ну т.е. совместимость понятно, но надо было оставлять как есть. Зачем, например, завозили
new[]
иdelete[]
?tntnkn Автор
19.06.2024 10:37+1Тут я бы зашёл с другой стороны. Если
new[]
иdelete[]
и так знают размер массива (иначе как бы он тогда удалялся), зачем было делать вид, что он не известен (в случае аллокации). Но это - философский вопрос о совместимости с Си.MiyuHogosha
19.06.2024 10:37+2new[]
иdelete[]
обязаны вызвать конструктор и деструктор для каждого элемента массива.На самом деле на целом ряде платформ в прошлом (старые Винды и VС или какой-то Borland) и сейчас (встраиваемые) можно использовать delete после new[] и ничего не ломалось. В винде GlobalAlloc всегда записывает сколько ему памяти попросили выделить, на "салфетке бургомистра", эту информацию можно узнать и вызов освобождения GlobalFree этого значения не требует. А вон на линхе узнать размер выделенного блока, насколько я знаю, нельзя.
А в других случаях нужно передавать размер, но не массива а блока памяти на уровне ОС и он где-то хранится. В других это другая функция. Это зависит от того, как организован менеджер памяти в ОС. И поэтому стандарт разрешает рабочей части delete и delete[] иметь одну и ту же реализацию "рабочей" части, высвобождающей память, но мешать их - это UB.
unreal_undead2
19.06.2024 10:37можно использовать delete после new[] и ничего не ломалось.
Память то оно может и корректно освободит, а что с вызовом деструкторов?
hiewpoint
19.06.2024 10:37Если известен аллоцированный объем, то и количество объектов, для которых надо вызвать деструктор, известно.
unreal_undead2
19.06.2024 10:37Теоретически да, но вот делает ли это явно стандартный delete - не факт. Корректная деаллокация памяти происходит бесплатно - низкоуровневые функции просто освобождают столько, сколько было выделено, не зная, что там вообще за объекты или массивы.
hiewpoint
19.06.2024 10:37+2стандартный delete не обязан делать, некоторые реализации могут это делать, чтобы иметь общий код для delete и delete[]
unreal_undead2
19.06.2024 10:37Можно и так, по стандарту всё равно UB ) Чисто теоретически интересно посмотреть поведение delete на массиве на разных компиляторах.
AKudinov
19.06.2024 10:37+3Почему ошибка? На мой взгляд, абсолютно логичное решение. Адрес первого элемента хранится в переменной, размер элемента компилятор знает в момент компиляции, больше ему не нужно ничего, чтобы адресовать любой элемент массива. С точки зрения эффективности использования машинных ресурсов решение великолепное.
tntnkn Автор
19.06.2024 10:37+1Непрерывная память - это лучшее, что придумали программисты)
Скорее, речь о правилах пользования этим концептом в нашем языке.
NeoCode
19.06.2024 10:37+1Именно ошибка дизайна языка. Есть стандартный оператор взятия адреса &. Вот его и нужно было использовать для получения адреса массива (т.е. первого элемента массива). А само имя массива должно было стать first-class сущностью, как имя объекта структуры.
eptr
19.06.2024 10:37+1Есть стандартный оператор взятия адреса &. Вот его и нужно было использовать для получения адреса массива (т.е. первого элемента массива).
Адрес массива, полученный с помощью
&
, и адрес первого элемента массива дают совершенно разные типы указателя.NeoCode
19.06.2024 10:37Кстати да:) И это было бы правильно и типобезопасно. Адрес массива был бы адресом объекта, адрес первого элемента - указателем в памяти для перебора элементов. Кажется, в стандарте MISRA требуют явно писать &arr[0] вместо arr.
tntnkn Автор
19.06.2024 10:37Это бы, кстати, не отменяло возможность каста такого указателя на такой массив-объект к указателю на его первый элемент, примерно как кастуется указателя на структуру к указателю на первое поле структуры. То бишь явно.
Вообще, мне вот пришла мысль после публикации статьи, что было бы удобно в таком языке "Супер-Си" иметь отдельную штуку под массив элементов известного типа, в котором нет никаких array-to-pointer преобразований, и отдельно - указатель на какую-то "сырую" память. К массивам применять безопасную индексацию, а к указателям на "сырую" память - арифметику указателей. Пускай технически оно реализовывалось бы одинаково, но семантически разница станется огромная. Казалось бы, при чём тут `std::array`...
ImagineTables
19.06.2024 10:37+1Это не ошибка, а прямое следствие из принципа «Одинаковый синтаксис у указателей и массивов».
А этот принцип делает удобной прямую работу с памятью (ниша, для которой Си и создавался).
NeoCode
19.06.2024 10:37+1Не имею ничего против прямой работы с памятью, это замечательно и я сам постоянно этим пользуюсь. А вот почему по аналогии с массивами имя объекта структуры не является адресом первого элемента структуры? Вот по этой же причине и имя массива не должно. Для взятия адреса есть оператор &. А имена переменных должны всегда быть объектами. Соответственно, многие чисто синтаксические неоднозначности исчезнут, язык станет логичнее.
ImagineTables
19.06.2024 10:37А вот почему по аналогии с массивами имя объекта структуры не является адресом первого элемента структуры?
Потому, что индексы — часть работы с массивами и указателями, но не с объектами структуры и самими структурами. Именно индексы — общий признак указателей и массивов, по причине которого им дали общий синтаксис.
Соответственно, многие чисто синтаксические неоднозначности исчезнут, язык станет логичнее.
А заодно исчезнет статический полиморфизм. В виде макросов, которым всё равно, что на входе. В таких формах мне он не нужен (или я не могу вспомнить, для чего нужен), но консистентность убер аллес.
tntnkn Автор
19.06.2024 10:37+1Индексы реализованы через арифметику указателей в С и С++. Но то, что это технически одно и то же не значит, что они должны быть синтаксически одним и тем же. Взять вот такой пример. Можно было бы представить гипотетический язык "Супер-Си", в котором в этом случае происходило бы копирование массива, и
sizeof
выводил был нормально именно, что размер массива. Не хочется копировать массив - передавай его по указателю. Это была бы нормальная удобная семантика, консистентная со всем остальным в Си.Но не буду становится еслибыдакабышником)
ImagineTables
19.06.2024 10:37Никто и не отталкивается от деталей реализации. Всё наоборот: на определённом уровне абстракции массивы и указатели — одно и то же, а значит нужен одинаковый синтаксис.
tntnkn Автор
19.06.2024 10:37+1А я и не говорил про детали реализации. Я говорил про то, что на уровне семантики программы они не должны быть одним и тем же.
eptr
19.06.2024 10:37То что имя массива это адрес первого элемента - конечно же ошибка дизайна С/С++.
Имя массива не является адресом его первого элемента.
Ошибка дизайна C касательно массивов — в другом.tntnkn Автор
19.06.2024 10:37*Приводится к указателю на первый элемент в подавляющем большинстве случаев.
А в чём ещё ошибка?
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
, то это обёрнутое начинает прекрасно использоваться для инициализации переменной того же типа, а посему и прекрасно передаваться и возвращаться из функции по значению, а также прекрасно присваиваться одно другому.Но сделанного не воротишь, массив — недотип.
Хоть он и недотип, но — никакой он не указатель.tntnkn Автор
19.06.2024 10:37В очередной раз спасибо за подробный комментарий! Особенно нечего и добавить.
Кстати, в приколе с приведением функции к указателю в вашем примере забавно то, что и в IR, и в ассемблере ничего этого нет даже без оптимизаций. В какой-то мере, операция это максимально бессмысленная, но всё-равно существующая.
НО!
В случае с лямбдами всё иначе. Я дополнил ваш пример обычным вызовом лямды. В случае, когда к лямбде сначала применяется операторы разыменования, в ассемблер они всё-таки просачиваются (ну, одна операция). Код для оператора приведения к указателю на функцию (синтаксис, кстати, бодрящий!) генерируется. В отличии от случая, когда происходит просто вызов лямбы - там есть только один вызов
call
непосредственно сгенерированной функции.
RichardMerlock
19.06.2024 10:37+8А может просто не нагружать обычные указатели лишними смыслами и свойствами. Указатель - это всего лишь адрес начала объекта в памяти, а наличие у него типа предотвращает часть ошибок на этапе компиляции. Вот в принципе и всё. Это просто как косвенная адресации на ассемблере. Если хочется совсем уж умных указателей, то можно и свой тип навернуть.
tntnkn Автор
19.06.2024 10:37+1Да. Но речь про массивы.
RichardMerlock
19.06.2024 10:37Но и про указатели на массивы и сопутствующие проблемы...
tntnkn Автор
19.06.2024 10:37+3Да. Но в контексте массивов не программист наделяет указатели дополнительными смыслами, а стандарт С++ (и Си). С этой точки зрения как раз и можно сказать, что тот же std::array позволяет сделать то, о чём вы и говорите.
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). Можете даже поставить указатель на доступную область памяти и прочитать блок хоть массивом, хоть одной переменной через адресную арифметику.
tntnkn Автор
19.06.2024 10:37Колдунство там для случаев, когда не известно, сколько скобочек ставить. Например, если бы я хотел написать свой flat(). Но это было бы UB.
UB есть и в вашем примере, строго говоря, по тем же причинам.
MiyuHogosha
19.06.2024 10:37+1А "свой" flat написать нельзя, так как Си++ предполагает очень обобщенную архитектуру. Я не знаю ни одного нативного компилятора на котором это бы не работало, но есть же и компиляторы Си++ в языки виртуальных машин.
И если у нас в памяти гуськом идут одномерные массивы, это не одно и тоже что и один одномерным массив.
"Конец" области памяти объекта одного массива должен следовать прямо перед "началом" следующего, но нигде не сказано что вычисленный указатель на элемент, следующий за последним является тем же адресом. Такой указатель разрешено вычислить (и потому конец массива не может упереться в "верхушку" адресного пространства, у нас случится overflow), но не разрешено разыменовывать.
Нигде не сказано что область памяти массива кончается там же, где конец области памяти его последнего элемента. Это де-факто так на железных реализациях. Там может быть что-то еще, например, та же информация выделенном блоке памяти. Ну и компилятору (или ВМ) будет сложно проследить корректность обращения к памяти, если границы массива где--то хранятся и проверяются во время исполнения.
tntnkn Автор
19.06.2024 10:37Ну да, я об этом и говорю. Как говорится, указатели абстрактнее, чем кажется.
И чаще всего мы программируем опротив абстрактной машины С++, а не конкретной железяки.
RichardMerlock
19.06.2024 10:37+1Ну вот и да. УБ бояться, под МК не писать. Абстрактность указателей кореллирует с абстрактностью подхода.
tntnkn Автор
19.06.2024 10:37Конечно, если бы idSoftware боялись UB, они бы не написали быстрый обратный квадратный корень. Но UB на то и UB, чтобы о работе программы нельзя было делать предположений после его возникновения.
RichardMerlock
19.06.2024 10:37Ох уж это УБ... УБ - это не правила языка, УБ - это ограничения оптимизирующего компилятора, который с лёгкостью даст протечь потоку выполнения функции в недопустимую область. Так что я даже больше скажу. Делать предположения о работе программы с УБ нельзя уже после компиляции. УБ не возникает, это не событие, это сломанный код. УБшность можно регулировать опциями компилятора.
tntnkn Автор
19.06.2024 10:37+1Не совсем понял вашу мысль. UB вполне себе фигурирует и в Сишном, и в плюсовом стандартах. Так что это волне себе правило языка. "Вот ситуация, вот там нам меня не расчитывай", фактически. По хорошему, UB - это ограничения на способы написания кода, которые делают этот код более портабельным.
RichardMerlock
19.06.2024 10:37Вот и я к этому же и веду, что УБ - именно, что правила написания переносимого кода, стандарт компиляции. То есть, под разные платформы список УБ должен быть разным в том числе и по архитектурным причинам. Про агрессивную УБ оптимизацию и так понятно.
tntnkn Автор
19.06.2024 10:37Если посмотреть с этой стороны, то если вы пишите под конкретный компилятор на конкретной платформе - то и UB бояться нечего, так как вы знаете, что компилятор сделает.
А так единый список UB же и делает код переносимым, разве нет?
ionicman
19.06.2024 10:37+11Очень странный подход что к массивам, что к указателям. Честно - если чётко понимать что есть что - никаких проблем не возникает, возможно размеры моих проектов недостаточны для того чтобы почувствовать все это, но единственное что у меня было это выход за границу.
Главная проблема - это понять что такое указатель в C, причём реально понять, а не проверхостно - это убирает большинство проблем и ошибок.
А в целом что-то смущает или хочется ограничений - сделай свой класс или воспользуйся либой, не хочешь - используй стандартные примитивы - в этом вся сила C.
tntnkn Автор
19.06.2024 10:37+1Конечно, про понимание вы написали абсолютно правильно. Кстати, про понимание указателей есть отличная статья.
dv0ich
19.06.2024 10:37Главная проблема - это понять что такое указатель в C, причём реально
понять, а не проверхостно - это убирает большинство проблем и ошибок.Нетрудно понять, что такое указатели, трудно удерживать в голове всю сеть указателей, указателей на указатели, указателей на указатели указателей...
tntnkn Автор
19.06.2024 10:37Если дело дошло до указателей на указатели на указатели, то это дело пахнет керосином.
unreal_undead2
19.06.2024 10:37+1Банальный список )
tntnkn Автор
19.06.2024 10:37Не, ну там же не `int ***********ptr`) Я не вдавался в подробности эффективной имплементации списков, но там, всё-таки, должна быть структура, в которой данные и указатель на структуру. А указателя на указатель ... на указатель там, вроде, нет.
unreal_undead2
19.06.2024 10:37+1Согласен, чистого *** синтаксиса нет, хотя по факту, если указатель на следующий элемент в первом поле структуры, то и будет указатель на указатель на ...
tntnkn Автор
19.06.2024 10:37Ну, сконвертировать указатель на структуру в указатель на первое поле структуры можно. Так что в этом случае вы правы)
kenomimi
19.06.2024 10:37+1чистого *** синтаксиса нет
Видишь суслика? И я не вижу, а он есть :) Вот только на днях натыкался:
extern SANE_Status sane_get_devices (const SANE_Device *** device_list, SANE_Bool local_only);
Фактически - указатель на указатель на массив указателей на пачку структур.
tntnkn Автор
19.06.2024 10:37Мне больше всего нравится слово "sane" в примере))
"a pointer to a NULL terminated array of pointers to SANE_Device structures in *device_list.". Забориста!
kenomimi
19.06.2024 10:37+1А еще есть антипаттерн:
typedef void *SANE_Handle;
Без заглядывания в исходник попробуй сходу догадайся, что тут заныкан указатель, а не просто алиас или структура/перечисление.
tntnkn Автор
19.06.2024 10:37Как понимаю, sane - это API. Пользователю, по хорошему, не нужно думать, что во что тайпдефнуто, пока имплементация сама со всем хорошо разбирается. Но
void *
- штука опасная, если он кастится куда-то не туда, это точно.
DimPal
19.06.2024 10:37+8Очередная хайповая тема. Сточки зрения машинного кода массивы прекрасны (всё быстро и компактно). Если важна максимальная производительность какого то участка кода - это идеальный вариант. Если вам важно удобство, защита от выстрела в ногу, если в проекте несколько программистов и хочется изолировать интерфейс от реализации - не используйте массивы таким способом, оберните в класс, который будет помнить размеры всех размерностей и не допускать выход из диапазона при доступе к элементам. Всё ж просто, спорить не о чем.
tntnkn Автор
19.06.2024 10:37+3Под этим комментом пошло обсуждение скорости, думаю, лучше продолжить там.
Но говоря об обёртках - зачем делать свою обёртку над массивом, если есть std::array?
anonymous
19.06.2024 10:37НЛО прилетело и опубликовало эту надпись здесь
dv0ich
19.06.2024 10:37Я звиняюсь, а какие аналоги есть у С++?
vanxant
19.06.2024 10:37Go, Rust и т.д.
sha512sum
19.06.2024 10:37+1Go слишком мал для такого. У него мало возможностей, за которые многие(и я тоже) любят C++. Вместо того, чтобы добавить возможностей в язык и сделать его чуть сложнее, они выбрали путь максимального упрощения, из-за чего много бойлерплейта, писать код неприятно. Я считаю такой подход в корне неправильным.
vanxant
19.06.2024 10:37Go по сравнению с С можно простить всё что угодно только за штатный способ возврата ошибки (т.е.
result, error = f(x)
)В современном многопоточном асинхронном мире сишный
errno
это даже не костыль, а якорь, не дающий всплыть.
dv0ich
19.06.2024 10:37+1Go не аналог плюсов. Rust - возможно, но меня напрягает его централизованная экосистема. Я таким уже не доверяю, какие бы благие цели они ни декларировали.
Ну и чего-то аналогичного Qt у Раста пока нет. Вообще с GUI как-то не густо у него, я из графических программ на Rust встречал только amdgpu_top с простейшим GUI.
А если чисто эстетически, то синтаксис у Раста просто ужас даже на фоне плюсов :)
sha512sum
19.06.2024 10:37+1Всё же, стоит признать, в некоторых местах синтаксис раста всё же лучше. Те же лямбды, или что можно не писать return в некоторых случаях. Как минимум с точки зрения того, что он просто короче. А всё же возвраты из функция и лямбды, это очень часто применяемый инструмент. А вот всё остальное чисто дело вкуса, мне тоже больше синтаксис плюсов нравится(Наверное потому что я на них пишу).
unreal_undead2
19.06.2024 10:37+3В заголовке стоило написать "C" - а то был уверен, что речь про std::array.
tntnkn Автор
19.06.2024 10:37+1Про std::array там речь тоже идёт.
Изначальная идея текста была именно в том, чтобы рассмотреть build-in массив именно в рамках плюсов. И в практической, и в теоретической части рассматриваются случаи, которых нет в Си.
Строго говоря, мне кажется, что "Си массив в Си" и "Си массив в С++" - это разные вещи, не смотря на одинаковую реализацию. Так как контекстов, в которых массив может использоваться, в плюсах много больше.
unreal_undead2
19.06.2024 10:37Про std::array там речь тоже идёт
Но '_____' относилось именно к C массивам - а я зашёл посмотреть, что же не так с std::array.
tntnkn Автор
19.06.2024 10:37Если '_____' - которое в статье, то это задумывалось, как отсылка к Большому Кушу)
А с std::array всё так)
Taritsyn
19.06.2024 10:37Не мог ли бы Вы указать источник цитаты из КДПВ?
tntnkn Автор
19.06.2024 10:37+1Могу сослаться только на голову автора, сиречь меня. Заглавка - адаптация мема с Кодзимой.
dv0ich
19.06.2024 10:37+2К счастью, на С++ можно писать практически без использования вот этих всех проблемных генов сишки вроде встроенных массивов и сырых указателей.
Но вот от сишного кода никуда не деться и каждое взаимодействие с ним очень муторное. Забыл где-то одну проверку и у тебя сегфолт.
vanxant
19.06.2024 10:37+1Слушайте, а где почитать, почему gcc выдаёт такой стрёмный код для 3-мерного массива?
Там же вообще нет нормального ret
code_panik
19.06.2024 10:37+2Код выглядит так из-за санитайзера. Компилятор с включенной опцией -fsanitize=undefined добавляет в исходный код нужные ему проверки. По теме есть лекция от разработчика asan https://www.lektorium.tv/lecture/23702
vanxant
19.06.2024 10:37+1Переформулирую вопрос - почему в принципе этот код падает? Ну т.е. что конкретно даёт компилятору творить такую дичь?
ZirakZigil
19.06.2024 10:37Потому что обращение к past-the-end, полагаю, и, как следствие, UB, которое и позволяет. Если есть arr[i][j][k], то выражение arr[i][j] есть некий массив sub_arr[k], и если за k выходим, то получаем UB.
vanxant
19.06.2024 10:37Нет, в случае многомерных массивов выход за пределы внутреннего массива UB не является.
Есть куча либ, например, для обработки изображений, где этот паттерн очень активно используется. Когда нужно, ходят по [y][x], а в других случаях по [0][i]
tntnkn Автор
19.06.2024 10:37+1Является. В Сишном стандарте этот случай даже отдельно описан в списке UB из приложения.
vanxant
19.06.2024 10:37а можно цитату про UB? а то я что-то в упор не вижу, поиск тоже не помогает.
tntnkn Автор
19.06.2024 10:37An 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).
vanxant
19.06.2024 10:37Язык Си явно свернул куда-то не туда. Я бы ещё понял Unspecified Behaviour - например, на ряде платформ подобный трюк потенциально может вызвать аппаратный Alignment Fault. Но язык С тем и был ценен, что можно было сказать компилятору "заткнись, я знаю что делаю". Но вообще убирать ret, т.е. ветку штатного выхода из процедуры, как в примере выше, - это уж какой-то совсем лютейший перебор.
Не понимаю, как на этом ещё можно что-то писать в 2024. Софт не должен настолько ненавидеть кожаных мешков.
tntnkn Автор
19.06.2024 10:37Ну. Сложно спекулировать о том, какие приколдесы могут случиться при UB, пока такие прикалдесы не встретились на практике. Например, в этом восхитительном выступлении на CppCon есть описание совершенно зубодробительного бага, следующего как раз из UB.
Если вернуться к нашему примеру, можно попробовать представить ситуацию в которой, например, двумерный массив аллоцирован на двух страницах памяти. В конце первой страницы аллоцирован первый под-массив и кусочек второго. В начале второй страницы аллоцирован остаток второго под-массива. И вот я начинаю обходить весь двумерный массив целиком через первый под-массив. Если в каждом моменте в коде, где идёт такая итерация, используется именно обход через первый-подмассив, то может ли вторая страница быть возвращена обратно ОС? Семантически, никаких манипуляций со вторым под-массивом не происходит. А что произойдёт, если такая страница будет возвращена ОС, а потом из неё в реальности произойдёт чтение? Я такое не встречал, но вроде как ничего не мешает подобной штуке случиться.
vanxant
19.06.2024 10:37может ли вторая страница быть возвращена обратно ОС
нет, не может, потому что выделялся массив целиком (обе половины единым куском). Это тоже гарантия.
Ещё раз повторю, может быть AlignmentFault. Если допустим в самом внутреннем массиве нечётное количество элементов, и используется SIMD
ZirakZigil
19.06.2024 10:37C++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. Можете предложить интерпретацию без него?
tntnkn Автор
19.06.2024 10:37+1Если говорить о правилах языка - то выход за границу массива. Это UB, а при UB никаких гарантий поведения компилятора нет. Он может хоть "отформатировать диск", хоть запустить игрульку.
vanxant
19.06.2024 10:37Проверил этот всратый пример. Если заменить в условии цикла 8 на 2, то всё компилируется нормально.
Другими словами, если разнести инициализацию массива и его использование по разным функциям, то всё будет работать, никаких UB не возникает.
Говорите мне что хотите, но это 100% баг компилятора gcc
tntnkn Автор
19.06.2024 10:37Если в примере заменить условие цикла 8 на 2, то не будет UB в первую очередь.
JordanCpp
19.06.2024 10:37+1Массивы в С/С++ это указатель и все. Количество данных по указателю и их размер хранятся во вне с помощью дополнительных усилий программиста.
tntnkn Автор
19.06.2024 10:37Массивы в С++ - это не просто указатель. Как минимум, это указатель, к которому можно применять оператор сложения с числом. В противном случае получится UB.
Плюс, размер даже build-in массива известен компилятору (то есть без усилий программиста) до того, как произошёл array-to-pointer.
ionicman
19.06.2024 10:37А разве к обычному указателю нельзя применять сложение с числом?
Единственное отличие по первому (родному) указателю на массив, компилятор может достать часть метаданных по массиву, на который он ссылается, но рассчитывать на это вот точно не нужно, так что все верно - хранение размера и тд - это проблемы разработчика, т.к. если массив не глобальный - передавать его все равно придется через указатель.
tntnkn Автор
19.06.2024 10:37Нет. Выше в комментарии дали как раз ссылку на стандарт.
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? Или я что-то не так понимаю?
tntnkn Автор
19.06.2024 10:37Формально - да, так как lifetime массива не начался. Тут, вроде как, идёт дискуссия о внесении в том числе такого в стандарт. Это ещё к вопросу о том, что возвращает `malloc` - указатель на элемент массива, или нет?
Другое дело что это везде работает. Но если хочется прямо формально безупречно, то можно сделать что-то такое.
ionicman
19.06.2024 10:37malloc - указатель на элемент памяти имхо, причём тут массив?
А то, что выше я писал - в куче библиотек используется, очень сомневаюсь что стандарт, ломающий все это, примут.
Ну и в страндарте есть странное possibly hypotetical array - так вот, любой кусок памяти это possibly hypotetical array of bytes.
tntnkn Автор
19.06.2024 10:37Там не "possibly hypotetical array", а "possibly hypotetical array element". Это костыль (имхо) для того, чтобы указатель мог указывать (sic) на элемент, следующий за последним в массиве, чтобы можно было писать `start < end' в цикле.
Про
malloc
я имел в виду, что указатель, из него выплёвываемый, формально нельзя использовать для итерации, так как под ним формально нет массива.А так ясен-красен, что работает, как бы ещё тот же
memset
был сделан.
aamonster
19.06.2024 10:37Стоп. Просто к указателю тоже можно применять сложение с числом. Вроде массив отличается только наличием размера (что усугубляется в случае многомерных массивов). Лучше б вообще не отличался, меньше граблей бы было.
tntnkn Автор
19.06.2024 10:37Формально не можно. В соседнем трэде описано, почему.
aamonster
19.06.2024 10:37Что значит "не можно"? Выделил массив, взял указатель на любой элемент и алга!
tntnkn Автор
19.06.2024 10:37Ну, если вы делаете
new[]
, то стандарт вам гарантирует наличие массива. Если вы делаетеmalloc
, то такой гарантии у вас нет. В комментарии указали на целый пропоузал в стандарт, который в том числе с этим хочет разобраться.Опять же, мы говорим конкретно сейчас про то, что написано в стандарте.
aamonster
19.06.2024 10:37+1Ну да, но это всё касается не указателя как такового, а откуда вы можете его получить. Указатель после malloc – это вообще указатель просто на память, так что у вас будет C-style cast или ещё что – там и опасность, не в операциях с указателем.
26rus_mri
19.06.2024 10:37+1как говорит ТС, они не просто указатель, но всех описанных проблем можно навсегда избежать если относиться как ним, как к указателям и делать как вы говорите: хранятся во вне с помощью дополнительных усилий программиста, или использовать контейнеры. Я так и делаю )
nv13
19.06.2024 10:37+35 минут квалифицированного ворчания))
А как надо понимать, допустим, l = (p+2) из исходного примера соответствующего 3-му элементу массива? И что должно возвращать sizeof от этого l? Или p это указатель на встроенный массив, а l - на его часть с 3 элемента?) То есть не просто int указатель?) А если всё таки просто, то значит между p и l тоже произошло какое то сужение исходного представления arr? А почему тогда не смириться, что оно происходит сразу при p=arr?
Разобраны хорошие примеры, и да, массивы источники минимум беспокойства, и альтернативы не зря появились, но указатели и преобразование при присвоении тут не очень причём - просто массивы такие, и с ними приходится так жить, если нужно)
tntnkn Автор
19.06.2024 10:37Извините, я не очень понял, что вы имеете в виду) Если спросите другими словами, то отвечу.
За похвалу спасибо)
nv13
19.06.2024 10:37+2Я не увидел предложений по альтернативному поведению и лишь попытался порассуждать, каким бы оно могло быть - и не нашёл ответа) Поэтому живём дальше.
tntnkn Автор
19.06.2024 10:37А, ну... Я вот тут попытался немного порассуждать о языке "Супер-Си")
nv13
19.06.2024 10:37+1В Фортране, ну где то точно, массив это дескриптор из С массива и длины. И ОС, когда из кернела копирут данные, не в курсе про длину какого то инстанса в юзер спейс. И очень часто содержимое этого массива не интересует логику, которая его обрабатывает. Я всё к тому, что массив это массив, это реальность, оборачивание может случиться на другом уровне абстракции, если надо
nickolaym
19.06.2024 10:37https://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 }
aamonster
19.06.2024 10:37+3Поднимите руки те, кто никогда не использовал sizeof для C-шных массивов.
В ту же степь – многомерные массивы. Я изучал C после Паскаля, и изначально привык считать, что их нет вообще, и если тебе нужен многомерный массив (обычно нужны неизвестного заранее размера) – выделяй одномерный и играй с индексами.
tntnkn Автор
19.06.2024 10:37К слову, никто не мешает использовать одномерный массив как многомерный при условии, что вся математика происходит внутри квадратных скобок)
aamonster
19.06.2024 10:37+1Именно!
А поскольку обычно массив итерируешь по строкам/столбцам – один раз вычисляешь приращения индекса dx/dy и делаешь i+=dy для индекса в массиве или p+=dy для указателя.
r655
19.06.2024 10:37В статье упущен рабочий вариант с передачей сырых массивов по ссылке с размером в шаблоне:
template<size_t N> void func(int (&arr)[N]) { ... } ... func({1,2,3});
tntnkn Автор
19.06.2024 10:37Только
const int (&arr)[N]
. Вы передаётеprvalue
.А так да. Опускал сознательно. Про ссылки на массив вкратце упомянул в теоретической части.
Daniel217D
Когда в универе учился подозрительно относился к обычным массивам. Они жутко неудобные и из плюсов остается только скорость работы. В итоге после собственных поисков и обсуждения с преподами также пришел к использованию std::array и std::vector
tntnkn Автор
Тут, мне кажется, тоже ещё вопрос, а быстрее ли build-in массив, чем std::array? Учитывая, что последний, фактически, это тонкая прослойка над первым, даже этот плюс не факт, что перевешивает.
mxr
Да, быстрее, причём ощутимо. Писал backtracking, где миллиарды итераций, и все обёртки и абстракции оптимизировались в простые структуры, в основном массивы. Разница в рядовом приложении вряд ли будет ощутима, но там где большое количество повторений миллисекунда может превратится в лишний час, а то и не один, работы.
unreal_undead2
Использование std::array в большинстве случаев должно генерировать абсолютно такой же код, как и для сишного массива. На каких конкретно операциях вылезает разница?
tolich_the_shadow
Например, на передаче в функцию по значению.
unreal_undead2
Ну так по факту сишный массив по значению не передаётся - если нужна та же семантика, std::array надо передавать по ссылке (и в бинарном коде это будет тот же самый адрес первого элемента).
tntnkn Автор
Вот да. Проблема может быть, если был
typdef
массива, а потом стал тайпдефаться в то же самое ужеstd::array
. Получится неприятно.unreal_undead2
Конечно нельзя просто так заменить одно на другое. Но если сразу сознательно использовать std::array, причин для просадки производительности не вижу.
tntnkn Автор
Против практики не попрёшь, но...
И libstdc++, и libcxx в std::array в прямом смысле просто используют оператор индексации build-in массива в своём операторе индексации. Оно, скорее всего, заинлайнится. Поэтому мне пока что не очевидно, как std::array может проигрывать, во всяком случае в индексации. Да, там есть асерты даже в
operator[]
, но они не должны отрабатывать в продакшн коде.Инициализация и там и там агрегатов, так что тоже разницы не должно быть.
MiyuHogosha
Плюс у него насамом деле в монотонности памяти и том что массив char может служить хранилищем любого объекта (по формулировке в стандарте)
DancingOnWater
В std::vector элементы также лежат в непрерывном виде.
MiyuHogosha
Верно, и реализовпн через динамически выделенный массив (с закидоном на UB). но стандарт говорит о другом. По факту, обьект можно разместить в таком массиве (placement new) или инспектировать через него (на совести программиста и реализации)
tntnkn Автор
А в чём закидон на UB? Если переаллокация сделается?
MiyuHogosha
Формально большинство "оптимальных" реализаций вектора использовали malloc() для выделения памяти без инициализации.
https://stackoverflow.com/a/52997191/2742717
В реализации так МОЖНО делать, так как это сделано авторами компилятора (и возможно, компилятор имеет свой шаблон кода для обработки такого случая).
В 23м это было предложено следующее https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/p0593r6.html
tntnkn Автор
Да, спасибо, что подсветили. Тут к вопросу о том, возвращает ли
malloc
указатель на элемент массива? Если нет, то итерировать по этой памяти формально нельзя, даже если это - массивchar
.tntnkn Автор
И в std::array, и в std::vector память расположена последовательно. Если std::vector хотя бы может переаллоцировать память, то std::array - это просто обёртка на буфером, то есть массивом.