Почему первый элемент за пределами определенного массива по умолчанию равен нулю?

Я готовлюсь к выпускному экзамену для вступления в класс C++. Наш профессор дал нам эту задачу для практики:

Объясните, почему код дает следующий результат: 120 200 16 0

      using namespace std;
int main()
{
 int x[] = {120, 200, 16};
 for (int i = 0; i < 4; i++)
 cout << x[i] << " ";
}

Пример ответа на проблему был:

Оператор cout просто циклически перебирает элементы массива, индекс которых определяется приращением цикла for. Размер элемента не определяется инициализацией массива. Цикл for определяет размер массива, который превышает количество инициализированных элементов, в результате чего последний элемент по умолчанию равен нулю. Первый цикл for печатает элемент 0 (120), второй - элемент 1 (200), третий цикл - элемент 2 (16), а четвертый цикл печатает значение массива по умолчанию, равное нулю, поскольку для элемента 3 ничего не инициализируется. точка i теперь превышает условие, и цикл for завершается.

Я немного сбит с толку, почему этот последний элемент вне массива всегда «по умолчанию» равен нулю. Чтобы поэкспериментировать, я вставил код проблемы в свою IDE, но изменил цикл for на . Затем вывод изменился на . Почему не возникает ошибки при попытке доступа к элементам из массива, размер которых выходит за пределы заданного? Выводит ли программа все «оставшиеся» данные с момента последнего сохранения значения по этому адресу памяти?

5 ответов

Я немного смущен тем, почему этот последний элемент вне массива всегда «по умолчанию» равен нулю.

В этой декларации

      int x[] = {120, 200, 16};

массив xимеет ровно три элемента. Таким образом, доступ к памяти за пределами массива вызывает неопределенное поведение.

То есть эта петля

       for (int i = 0; i < 4; i++)
 cout << x[i] << " ";

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

С другой стороны, если массив был объявлен как

      int x[4] = {120, 200, 16};

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

По умолчанию он не равен нулю. Образец ответа неверен. Неопределенное поведение не определено; значение может быть 0, оно может быть 100. Доступ к нему может вызвать ошибку seg или привести к форматированию вашего компьютера.

Что касается того, почему это не ошибка, это потому, что С++ не требуется для проверки границ массивов. Вы можете использовать вектор и использовать atфункция, которая выбрасывает исключения, если вы выходите за границы, а массивы — нет.

Это вызывает неопределенное поведение, это единственный правильный ответ. Компилятор ожидает, что ваш массив будет содержать ровно три элемента, то, что вы видите в выводе при чтении четвертого целого числа, неизвестно, а в некоторых системах/процессорах может вызвать аппаратное прерывание, вызванное попыткой чтения неадресуемой памяти (система не знает, как это сделать). получить доступ к физической памяти по такому адресу). Компилятор может зарезервировать для xпамять из стека или может использовать регистры (поскольку она очень маленькая). Тот факт, что вы получаете 0, на самом деле случаен. С помощью дезинфицирующего средства адресов в clang (опция -fsanitize=address) вы можете увидеть это:

https://coliru.stacked-crooked.com/a/993d45532bdd4fc2

короткий вывод:

      ==9469==ERROR: AddressSanitizer: stack-buffer-overflow

Вы можете исследовать это еще дальше в проводнике компилятора с неоптимизированным GCC: https://godbolt.org/z/8T74cr83z (включает asm и вывод программы)
. В этой версии вывод 120 200 16 3потому что GCC помещается в стек после массива.

Вы увидите, что gcc генерирует следующую сборку для вашего массива:

          mov     DWORD PTR [rbp-16], 120    # array initializer
    mov     DWORD PTR [rbp-12], 200
    mov     DWORD PTR [rbp-8], 16
    mov     DWORD PTR [rbp-4], 0       # i initializer

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

См. уровень оптимизации этого примера - его -O0- так последовательная отладка с минимальными оптимизациями; вот почему iхранится в памяти, а не в регистре, сохраняемом вызовом. Начните добавлять оптимизации, скажем -O1и вы получите:

          mov     DWORD PTR [rsp+4], 120
    mov     DWORD PTR [rsp+8], 200
    mov     DWORD PTR [rsp+12], 16

Дополнительные оптимизации могут полностью оптимизировать ваш массив, например, развертывание и простое использование непосредственных операндов для настройки вызовов cout.operator<<. В этот момент undefined-behavior будет полностью виден компилятору, и ему нужно будет что-то придумать. (Регистры для элементов массива были бы правдоподобны в других случаях, если бы значения массива всегда были доступны только с помощью постоянного (после оптимизации) индекса.)

Исправление ответа

Нет, по умолчанию он не равен 0. Это поведение undefined. Просто получилось 0 в этом условии, этой оптимизации и этом компиляторе. Попытка доступа к неинициализированной или нераспределенной памяти является поведением undefined.

Поскольку это буквально «не определено», и в стандарте больше нечего сказать об этом, ваш вывод сборки не будет последовательным. Компилятор может сохранить массив в регистре SIMD, кто знает, что получится на выходе?

Цитата из примера ответа:

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

Это самое неправильное утверждение. Я предполагаю, что в коде опечатка, и они хотели это исправить.

      int x[4] = {120, 200, 16};

и ошибочно сделал это в просто . Если нет, и это было намеренно, я не знаю, что сказать. Они ошибаются.

Почему это не ошибка?

Это не ошибка, потому что так работает стек. Вашему приложению не нужно выделять память в стеке, чтобы использовать ее, она уже ваша. Вы можете делать со своим стеком все, что пожелаете. Когда вы объявляете переменную следующим образом:

      int a;

все, что вы делаете, это говорите компилятору: «Я хочу, чтобы 4 байта моего стека были для , пожалуйста, не используйте эту память ни для чего другого». во время компиляции. Посмотрите на этот код:

      #include <stdio.h>

int main() {
    int a;
}

Сборка:

          .file   "temp.c"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    endbr64
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6 /* Init stack and stuff */
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret /* Pop the stack and return? Yes. It generated literally no code.
           All this just makes a stack, pops it and returns. Nothing. */
    .cfi_endproc /* Stuff after this is system info, and other stuff
                 we're not interested. */
.LFE0:
    .size   main, .-main
    .ident  "GCC: (Ubuntu 11.1.0-1ubuntu1~20.04) 11.1.0"
    .section    .note.GNU-stack,"",@progbits
    .section    .note.gnu.property,"a"
    .align 8
    .long   1f - 0f
    .long   4f - 1f
    .long   5
0:
    .string "GNU"
1:
    .align 8
    .long   0xc0000002
    .long   3f - 2f
2:
    .long   0x3
3:
    .align 8
4:

Прочитайте комментарии в коде для объяснения.

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

x - это переменная, которая является целым числом со знаком. Требуется 4 байта, пожалуйста, продолжайте объявление после пропуска этих 4 байтов (и выравнивания).

Переменные в языках высокого уровня (стека) существуют только для того, чтобы сделать «распределение» стека более систематическим и удобным для чтения. Объявление переменной не является процессом выполнения. Он просто учит компилятор, как распределять стек между переменными и соответствующим образом подготавливать программу. При выполнении программа выделяет стек (это процесс времени выполнения), но уже жестко запрограммировано, какие переменные получают какую часть стека. Например. переменная может стать к пока получает к . Эти значения определяются во время компиляции. Имена переменных также не существуют во время компиляции, это просто способ научить компилятор тому, как подготовить программу к использованию своего стека.

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

Проверка границ

В таких языках, как Go, даже если ваш стек принадлежит вам, компилятор вставит дополнительные проверки, чтобы убедиться, что вы случайно не используете необъявленную память. Это не делается в C и C++ из соображений производительности, и это приводит к более частому возникновению ужасного неопределенного поведения и ошибки сегментации.

Раздел кучи и данных

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

Раздел «Данные» — это еще одно место, где можно хранить данные. Здесь можно хранить переменные. Он хранится вместе с вашим кодом, поэтому превышение выделения довольно опасно, так как вы можете случайно изменить код программы. Поскольку он хранится вместе с вашим кодом, он, очевидно, также выделяется во время компиляции. На самом деле я мало что знаю о безопасности памяти в разделе данных. По-видимому, вы можете превысить его без жалоб ОС, но я не знаю больше, так как я не системный хакер и не имею сомнительной цели использовать это в злонамеренных целях. В принципе, я понятия не имею о превышении выделения в разделе данных. Надеюсь, кто-нибудь прокомментирует (или ответит) об этом.

Вся сборка, показанная выше, скомпилирована GCC 11.1 на языке C на компьютере с Ubuntu. Это на C, а не на C++, чтобы улучшить читабельность.

Размер элемента не определяется инициализацией массива. Цикл for определяет размер массива, который превышает количество инициализированных элементов, поэтому по умолчанию последний элемент равен нулю.

Это в корне неверно. Из раздела 11.6.1p5 стандарта С++17 :

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

       int x[] = { 1, 3, 5 };

объявляет и инициализирует x как одномерный массив, состоящий из трех элементов, так как размер не указан и имеется три инициализатора. — конец примера ]

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

Тот факт, что 0 печатается для несуществующего 4-го элемента, является просто проявлением неопределенного поведения. Нет никакой гарантии, что это значение будет напечатано. На самом деле, когда я запускаю эту программу, я получаю 3 в качестве последнего значения при компиляции с -O0и 0 при компиляции с -O1.

Другие вопросы по тегам