В C: создал узел, забыл вернуть указатель на узел, но код все равно работает так, как будто я его вернул

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

Это код, который я использовал для создания узла

hashNode *createNewNode(int data) {
    hashNode *node = calloc(1, sizeof(hashNode));
    node->data.key = data;
    node->isSet = true;
    node->next = NULL;
}

Это функция, которую я хотел засечь.

for (int i = 0; i < 5; i++) {
    hashNode *node = createNewNode(arr[i]);
    InsertNode(node, map);
}

(arr — это только первые 5000 перетасованных чисел) Как вы могли заметить, функция создания узла не имеет возвращаемого значения, но, несмотря на это, узел инициализируется корректно и вставлены все числа, которые должны были быть в таблице, и только они. Как это могло случиться?

Этот трюк работает только в VS Code. Я пробовал запустить его в Visual Studio, но он (правильно) не инициализирует узел. Кто-нибудь знает, что происходит?

Редактировать: окей, я наверное не правильно выразился, извини. Мой вопрос: как это работает? Я знаю, что это неопределенное поведение, но оно не похоже на то, что должно работать, но оно работало правильно 5000 раз из 5000, и даже если я добавляю немного printf здесь и там, оно продолжает работать правильно.

Программа имеет неопределенное поведение, поэтому она может делать что угодно.

Ted Lyngmo 30.06.2024 17:16

Этот вопрос похож на: Почему программы на C компилируются, даже если оператор возврата отсутствует?. Если вы считаете, что это другое, отредактируйте вопрос, поясните, чем он отличается и/или как ответы на этот вопрос не помогают решить вашу проблему.

wohlstad 30.06.2024 17:19

«Я знаю, что это неопределенное поведение, но оно не похоже на то, что должно работать, но оно сработало» - Ну, это неопределенное поведение, поэтому в один день может показаться, что оно работает, а на следующий - нет.

Ted Lyngmo 30.06.2024 17:26

Хм, ладно, я просто приму ответ «тебе просто невероятно повезло». Я надеялся обнаружить какую-то странную динамику, например, «функция возвращает ближайший блок в памяти, соответствующий размеру его типа» или что-то столь же странное, но я думаю, мне просто нужно принять это неопределенное поведение, и оно просто непредсказуемо.

NoobProgrammer 30.06.2024 17:31

Функции возвращают значение, помещая его в специальное место, определенное компилятором. callocсделает это, и, очевидно, он все еще будет там, когда ваша функция вернется.

BoP 30.06.2024 17:38
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
2
5
103
3
Перейти к ответу Данный вопрос помечен как решенный

Ответы 3

Если вам не удается вернуть значение из функции, которая определена для этого, а затем вы пытаетесь использовать возвращаемое значение функции, это вызывает неопределенное поведение в вашем коде.

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

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

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

Да, но мой вопрос: почему это работает? типа, как это работает? Это не похоже на то, что должно работать

NoobProgrammer 30.06.2024 17:20

@NoobProgrammer Это работает, потому что вам «повезло». С таким же успехом это может не сработать.

dbush 30.06.2024 17:24

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

NoobProgrammer 30.06.2024 17:33
Ответ принят как подходящий

Как уже объясняет ответ @dbush, не возвращать значение в непустой функции, а использовать значение вызова функции - это UB (неопределенное поведение).

Из этого проекта стандарта C23 §6.9.2, пункт 13:

Если не указано иное, если достигнут }, завершающий тело функции, и значение вызов функции используется вызывающей стороной, поведение не определено.

То, что вызов вашей функции createNewNode() и использование ее возвращаемого значения (если вы можете ее так назвать, поскольку она не возвращает) сработало так, как если бы вы вернули выделенный узел, — это чистая удача (или невезение, в зависимости от того, как вы это видите).

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


Хотя вам, вероятно, не следует этого делать, я все же пытался рассуждать о том, что происходит в вашем коде, и @dbush уже кое-что объяснил и это.

значение узла может находиться в регистре

Использование Compiler Explorer Я скомпилировал упрощенный, но похожий код, используя x86-64 gcc 14.1 ( живой пример), не устанавливая никаких флагов компилятора.

#include <stdio.h>
#include <stdlib.h>

int* gPtr = NULL;

int* allocateInt() {
    int* p = calloc(1, sizeof *p);
    gPtr = p;
}

int main() {
    int* ptr = allocateInt();

    printf("ptr:  %p\n", ptr);
    printf("gPtr: %p\n", gPtr);

    free(ptr);
    return 0;
}

Это сгенерированная сборка:

gPtr:
        .zero   8
allocateInt:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     esi, 4
        mov     edi, 1
        call    calloc
        mov     QWORD PTR [rbp-8], rax
        mov     rax, QWORD PTR [rbp-8]
        mov     QWORD PTR gPtr[rip], rax
        nop
        leave
        ret
.LC0:
        .string "ptr:  %p\n"
.LC1:
        .string "gPtr: %p\n"
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     eax, 0
        call    allocateInt
        mov     QWORD PTR [rbp-8], rax
        mov     rax, QWORD PTR [rbp-8]
        mov     rsi, rax
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 0
        call    printf
        mov     rax, QWORD PTR gPtr[rip]
        mov     rsi, rax
        mov     edi, OFFSET FLAT:.LC1
        mov     eax, 0
        call    printf
        mov     rax, QWORD PTR [rbp-8]
        mov     rdi, rax
        call    free
        mov     eax, 0
        leave
        ret

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

Выполнение программы на C начинается с функции main() в сборке, отмеченной меткой main:. После настройки кадра стека мы встречаем call allocateInt и вы, наверное, сами догадаетесь, что это делает. В allocateInt() у нас есть call calloc.

Возвращаемое значение calloc() сохраняется в 64-битном регистре rax. Соглашения о вызовах x86-64:

Целочисленные возвращаемые значения размером до 64 бит хранятся в RAX

Теперь у нас есть следующие три строки:

mov     QWORD PTR [rbp-8], rax
mov     rax, QWORD PTR [rbp-8]
mov     QWORD PTR gPtr[rip], rax

Первый сохраняет значение в регистре rax в стеке (в локальном allocateInt()p). Следующая строка сохраняет значение p обратно в rax. Затем мы сохраняем значение rax в нашем глобальном gPtr.

Следующее использование rax происходит сразу после вызова allocateInt().

mov     QWORD PTR [rbp-8], rax

Вы уже должны понимать, что делает эта строка. Он сохраняет значение rax в стеке. Поскольку мы снова находимся в main(), а стек allocateInt() уже удален, теперь он сохраняется в main()ptr.

Поэтому программа имела для меня следующий вывод, который показывает, что allocateInt(), по-видимому, вернул правильное значение, хотя мы сами не писали оператор return:

ptr:  0x20d22a0
gPtr: 0x20d22a0

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

Когда я увеличил уровень оптимизации до -O2 или -O3, результат отличался. Однако -O1 и -Os всё же удалось вернуть указатель.

Первоначально у меня был вызов printf() в функции allocateInt(), но из-за этого ptr не имело того же значения, что и p. Перемещение этого вызова printf() в main() и наличие глобального gPtr дали мне результат, который я показал здесь.

На некоторых компиляторах мой код вызывал ошибку сегмента.

@chux-ReinstateMonica Да, вы правы. Я спутал это со стандартом C++, где говорится: «В противном случае выход из конца функции, которая не является ни основной (6.9.3.1), ни сопрограммой (9.5.4), приведет к неопределенному поведению». Исправил свой ответ.

Joel 30.06.2024 23:07

Невозврат указателя (фактически, не вызов return в конце функции, отличной от void) является неопределенным поведением.

Проблема здесь в том, что большинство архитектур используют определенный регистр процессора для возврата значений из вызовов (этот регистр может меняться, как и числа с плавающей запятой, которые находятся в сопроцессоре, имеют другой размер или имеют определенные отдельные регистры для хранения адресов), поэтому большинство возможно, здесь происходит то, что регистр, используемый для возврата указателя от вызова malloc(), не был перезаписан. Ваша функция достаточно проста, чтобы добраться до конца, не загромождая регистр возврата другими вычислениями, и, наконец, результат вашей функции — это то, что вы наблюдаете. В зависимости от ABI регистр возврата может быть всегда одним и тем же, это позволяет избежать копирования возвращаемого значения в цепочке возвратов (например, если вы напрямую return получаете то, что получили от вызываемой функции, нет необходимости копировать регистр возврата самому себе), поэтому он генерирует более эффективный код, но у него есть этот недостаток. Компиляторам разрешено это делать. Простите за это!!!! :)

Но ваша программа ошибочна.

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

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