Что происходит в C++ при вызове функции?

Ответ

Вызов функции в C++ — это передача управления от вызывающей функции (caller) к вызываемой (callee). Этот процесс включает несколько шагов на уровне абстрактной машины:

  1. Вычисление аргументов: Все аргументы вычисляются. Порядок вычисления не специфицирован стандартом.
  2. Передача аргументов: Каждый аргумент инициализирует соответствующий параметр функции. По умолчанию используется передача по значению (копирование), но может быть передача по ссылке (&, const &) или по указателю.
  3. Создание кадра стека (stack frame): Выделяется память в стеке для:
    • Возвратного адреса (куда вернуться после выполнения).
    • Локальных переменных функции (включая параметры).
    • Временных объектов, созданных в теле функции.
  4. Передача управления: Инструкция call перенаправляет поток выполнения на первую инструкцию тела функции.
  5. Исполнение тела: Выполняется код внутри функции.
  6. Возврат значения (если есть): Значение из return-выражения инициализирует объект в точке вызова (может involve вызов конструктора перемещения/копирования или оптимизацию RVO/NRVO).
  7. Уничтожение локальных объектов: Деструкторы локальных объектов вызываются в обратном порядке создания.
  8. Восстановление кадра: Управление возвращается по сохраненному адресу, стековый указатель корректируется.

Пример с разными способами передачи:

#include <iostream>
#include <string>

void byValue(int x) { x = 10; } // Изменяет копию
void byReference(int& x) { x = 10; } // Изменяет оригинал
void byConstRef(const std::string& s) { std::cout << s.length(); } // Эффективно для тяжелых объектов

int main() {
    int a = 5;
    byValue(a);     // a останется 5
    byReference(a); // a станет 10

    std::string str = "Hello";
    byConstRef(str); // Избегаем копирования всей строки
    return 0;
}

Важно: Современные компиляторы активно оптимизируют вызовы функций (inlining), что может полностью устранить накладные расходы.

Ответ 18+ 🔞

Давай разберём эту тему, чтобы у тебя в голове всё встало на свои места, а не болталось как хуй с горы. Вызов функции в C++ — это не просто так, "ой, вызвал и забыл". Это целый ритуал на уровне абстрактной машины, и если не понимать, как он работает, потом будешь чесать репу, откуда берутся ошибки.

Слушай сценарий. Ты — вызывающая функция (caller). Ты хочешь делегировать работу другой функции (callee). Что происходит за кулисами?

  1. Аргументы готовятся. Сначала вычисляется всё, что ты передаёшь в функцию. Но запомни раз и навсегда: порядок вычисления этих аргументов — ёпта, не определён стандартом! Компилятор может делать это как ему вздумается. Так что не строй логику, которая от этого порядка зависит, а то будет тебе хиросима и нигерсраки в одном флаконе.
  2. Аргументы передаются. Каждый вычисленный аргумент инициализирует соответствующий параметр. Тут вариантов — овердохуища. По умолчанию — передача по значению, то есть создаётся честная копия. Можно по ссылке (&) — тогда функция будет работать с оригиналом, как с соседской девушкой в бане. Можно по константной ссылке (const &) — это чтобы заглянуть, но не трогать. Можно по указателю — это уже как дать адрес, пусть сам разбирается.
  3. Создаётся кадр стека. Это как личное пространство функции в памяти. Тут хранится:
    • Возвратный адрес — чтобы знать, куда тебе пиздовать обратно после работы.
    • Локальные переменные — всё, что объявишь внутри, живёт тут.
    • Временные объекты — всякая промежуточная хуйня, которая возникает в вычислениях.
  4. Происходит передача управления. Процессорная команда call — и всё, ты уже внутри тела новой функции. Предыдущая функция замерла в ожидании.
  5. Исполняется тело. Тут всё понятно: выполняется код, который ты написал. Можешь считать ворон, можешь мир менять.
  6. Возврат значения. Если функция что-то возвращает, значение из return идёт туда, откуда её вызвали. Тут могут вызываться конструкторы копирования или перемещения, но умные компиляторы часто это вырезают через оптимизации (RVO/NRVO), чтобы не делать лишнюю работу.
  7. Уборка за собой. Локальные объекты уничтожаются в порядке, обратном созданию. Вызываются деструкторы — всё честно.
  8. Возврат домой. Управление прыгает по сохранённому адресу, стек подчищается, и жизнь вызывающей функции продолжается.

Теперь смотри, как это выглядит в коде на практике. Главное — понять разницу в способах передачи, а то будешь потом охуевать, почему переменная не меняется.

#include <iostream>
#include <string>

// Передача по значению. Функция получает КОПИЮ.
// Что бы она тут ни делала, на оригинале это не отразится.
void byValue(int x) { x = 10; } // Поменяли копию. Оригиналу да похуй.

// Передача по ссылке. Функция получает доступ к ОРИГИНАЛУ.
// Меняешь параметр — меняется переданная переменная.
void byReference(int& x) { x = 10; } // Вот теперь оригинал точно поменяется.

// Передача по константной ссылке. Идеально для тяжёлых объектов.
// Копирования нет, но и изменить объект нельзя — только читать.
void byConstRef(const std::string& s) { std::cout << s.length(); }

int main() {
    int a = 5;
    byValue(a);     // Передали копию 'a'. a останется 5, нихуя не поменялось.
    std::cout << a << "n"; // Выведет 5

    byReference(a); // Передали саму переменную 'a' по ссылке. a станет 10.
    std::cout << a << "n"; // Выведет 10

    std::string str = "Hello, я длинная строка, копировать меня накладно!";
    byConstRef(str); // Передаём ссылку, но защищённую от изменений. Никакого копирования — эффективно и безопасно.
    return 0;
}

И последнее, золотое правило: не парься раньше времени. Современные компиляторы — хитрая жопа. Они часто делают подстановку (inlining), особенно для маленьких функций. Это значит, что весь этот пафосный ритуал с кадрами стека может быть полностью вырезан, и код функции будет вставлен прямо в место вызова, как будто его там всегда и было. Но чтобы они могли это сделать, ты должен понимать основы. Иначе будешь писать код, который компилятор не сможет оптимизировать, и потом удивляться, почему всё тормозит.