Перейти к основному содержимому

5.06. Функции

Разработчику Архитектору

Функции в C++

Функция в C++ — это именованный фрагмент программы, который инкапсулирует логически завершённую последовательность операций, может принимать входные данные, выполнять вычисления и возвращать результат. Функции являются фундаментальным строительным блоком структурного и процедурного программирования, а также неотъемлемой частью парадигмы объектно-ориентированного программирования, где они выступают в роли методов классов.

В отличие от языков, где основной упор делается на выражения и функциональные конструкции (например, Haskell или даже современный JavaScript), C++ остаётся языком, в котором функции — полноценная единица компиляции, связывания и выполнения. Понимание устройства функций, их объявлений, определений, правил разрешения перегрузок, механизма передачи аргументов и особенностей возврата значений — критически важно не только для написания корректного кода, но и для предотвращения скрытых ошибок, связанных с производительностью, семантикой копирования и управлением ресурсами.

Концептуальная роль функции

С точки зрения абстракции, функция реализует принцип «разделяй и властвуй»: сложная задача разбивается на подзадачи, каждая из которых оформляется как отдельная функция. Это позволяет:

  • повысить читаемость кода за счёт именования логических блоков,
  • упростить отладку, так как ошибку можно изолировать в рамках конкретной функции,
  • обеспечить повторное использование, поскольку один и тот же код вызывается многократно в разных контекстах,
  • осуществить модульное тестирование, проверяя поведение функции независимо от остальной программы,
  • инкапсулировать детали реализации, если объявление вынесено в заголовочный файл, а определение — в отдельный единичный файл компиляции.

В C++ функции также играют ключевую роль в механизме name mangling (искажения имён), используемом компилятором для поддержки перегрузки функций и обеспечения совместимости с другими единицами трансляции. Это делает функции не просто удобным инструментом для программиста, но и объектом внимания компоновщика и загрузчика.


Синтаксис объявления и определения

В C++ различают объявление (declaration) и определение (definition) функции.

Объявление — это сигнатура функции без тела. Оно сообщает компилятору о существовании функции, её имени, типе возвращаемого значения и типах параметров:

int add(int a, int b);

Объявление может появляться многократно в пределах одной единицы трансляции (например, в заголовочном файле, включённом несколько раз, при условии защиты через include guards или #pragma once). Оно необходимо, когда функция вызывается до своего определения в том же файле или из другого файла, где определение недоступно.

Определение — это полная реализация функции, включающая тело в фигурных скобках:

int add(int a, int b) {
return a + b;
}

Определение должно встречаться ровно один раз в программе (согласно правилу One Definition Rule), иначе возникнет ошибка компоновки. Объявление же может дублироваться — компилятор игнорирует повторные совпадающие объявления.


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

Рассмотрим определение функции:

int add(int a, int b) {
return a + b;
}
  • intтип возвращаемого значения (return type). Указывает, какой тип данных возвращает функция при выполнении оператора return. Если функция ничего не возвращает, используется ключевое слово void. Начиная с C++14, для простых случаев можно использовать auto, и компилятор выведет тип возвращаемого значения автоматически (но это требует внимательного подхода в интерфейсных объявлениях).

  • addимя функции (function name). Должно быть уникальным в своей области видимости (если не применяется перегрузка), соответствовать правилам идентификаторов C++ (начинается с буквы или подчёркивания, содержит буквы, цифры и символ _, не совпадает с ключевыми словами). Имя функции участвует в формировании её символьного имени в объектном коде — именно по этому имени компоновщик ищет реализацию.

  • (int a, int b)список параметров (parameter list). Каждый параметр состоит из типа и имени. Имена параметров в объявлении могут отсутствовать (например, int add(int, int);), но в определении они обязательны, так как используются внутри тела. Параметры — это локальные переменные функции, инициализируемые при вызове. Важно: они инициализируются копированием или перемещением (в зависимости от категории передаваемого аргумента и сигнатуры), если явно не указано иное (см. раздел о передаче по ссылке).


Понятие сигнатуры функции

Сигнатура функции в C++ — это полное имя функции, включающее:

  • имя функции как таковое,
  • типы всех её параметров в порядке их следования,
  • квалификаторы (например, const, &, &&, noexcept в случае членов класса),
  • но не включающее тип возвращаемого значения.

Именно сигнатура определяет уникальность функции при перегрузке. Две функции с одинаковым именем, но разными типами параметров — это разные функции.

Пример допустимой перегрузки:

int add(int a, int b);
double add(double a, double b);
std::string add(const std::string& a, const std::string& b);

Эти три функции имеют разные сигнатуры, и компилятор корректно выберет нужную при вызове add(x, y), основываясь на типах x и y.

Недопустима перегрузка только по возвращаемому типу:

int foo();
double foo(); // ОШИБКА: конфликт сигнатур

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


Механизм вызова функции и стек вызовов

Каждый вызов функции в C++ сопровождается созданием нового фрейма активации (activation frame), также называемого стековым фреймом. Этот фрейм размещается в стеке вызовов (call stack) и содержит:

  • локальные переменные функции,
  • параметры, переданные при вызове,
  • адрес возврата — место в коде, куда должен вернуться поток управления после завершения функции,
  • служебную информацию, необходимую для раскрутки стека (например, при исключениях).

Стек вызовов — это LIFO-структура (last in, first out), управляемая процессором через регистр указателя стека (%rsp на x86-64). При входе в функцию компилятор генерирует пролог — последовательность инструкций, резервирующих память под локальные данные и сохраняющих регистры вызывающей стороны. При выходе — эпилог, восстанавливающий состояние и передающий управление обратно.

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


Передача аргументов: семантика и последствия

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

Передача по значению

void process(int x);

При таком объявлении аргумент копируется в параметр функции. Это означает:

  • функция работает с независимой копией исходных данных,
  • изменения внутри функции не затрагивают внешнюю переменную,
  • для типов с нетривиальным конструктором копирования (например, std::vector, std::string) копирование может быть дорогостоящим.

Передача по значению предпочтительна для маленьких тривиальных типов (например, int, char, bool, указатели, std::pair<int, int>), где стоимость копирования сравнима или меньше стоимости разыменования ссылки.

Передача по ссылке

void process(int& x);

Здесь xпсевдоним для переданного аргумента. Никакого копирования не происходит: параметр привязывается к объекту в вызывающем коде.

Это позволяет:

  • изменять исходный объект («выходной параметр»),
  • избегать накладных расходов на копирование,
  • работать с полиморфными объектами без срезки (object slicing).

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

Передача по константной ссылке

void process(const std::string& s);

Это компромиссный и часто оптимальный вариант:

  • копирование не производится,
  • функция не может изменить переданный объект,
  • возможна передача временных объектов (rvalue), так как константная ссылка может привязываться к rvalue.

Большинство стандартных контейнеров, строк, пользовательских типов среднего и большого размера следует передавать именно так — по const T&. Это особенно важно в шаблонном коде, где тип параметра заранее неизвестен.

Передача по rvalue-ссылке и семантика перемещения

Начиная с C++11, появилась возможность принимать аргументы по rvalue-ссылке:

void take_ownership(std::vector<int>&& data);

Такая ссылка привязывается только к временным объектам или к объектам, явно помеченным как «готовые к перемещению» через std::move. Это открывает путь к семантике перемещения: вместо копирования ресурсов (памяти, дескрипторов) происходит их передача владения.

Часто используется в универсальных ссылках (forwarding references) в связке с std::forward для реализации идеальной передачи (perfect forwarding), например, в функциях-обёртках, конструкторах и фабриках.


Возврат значений: от копирования до оптимизаций компилятора

Оператор return завершает выполнение функции и передаёт управление обратно вызывающему коду, возвращая значение (если тип возврата не void).

Возврат по значению

std::vector<int> make_vector() {
std::vector<int> v = {1, 2, 3, 4, 5};
return v;
}

На первый взгляд, здесь происходит:

  1. Создание локального вектора v,
  2. Вызов конструктора копирования (или перемещения) при возврате,
  3. Присваивание результата внешней переменной.

На практике современные компиляторы применяют две мощные оптимизации:

  • Return Value Optimization (RVO) — компилятор не создаёт временный объект вообще, а конструирует результат напрямую в месте назначения (в стековом фрейме вызывающей функции).
  • Named Return Value Optimization (NRVO) — тот же эффект, но для именованных локальных переменных (как v выше).

Начиная с C++17, RVO гарантирована стандартом для случаев, когда выражение в return — это имя переменной с автоматическим временем жизни (т.н. prvalue). Это означает, что даже если конструктор копирования/перемещения удалён (= delete), код выше будет корректным — копирование физически не происходит.

Возврат по ссылке

int& get_element(std::vector<int>& v, size_t i) {
return v[i];
}

Функция возвращает ссылку на существующий объект. Это позволяет:

  • использовать результат как левостороннее значение (например, в присваивании: get_element(v, 0) = 42;),
  • избежать копирования при чтении.

Критически важно: никогда не возвращайте ссылку (или указатель) на локальную переменную с автоматическим временем жизни:

int& bad() {
int x = 42;
return x; // ОПАСНО: x уничтожится при выходе из функции
}

Это приводит к висячей ссылке (dangling reference) — неопределённому поведению при попытке доступа.

Возврат void

Функция, объявленная с void, не возвращает значение. Оператор return; (без выражения) допустим и завершает выполнение. Пропуск return в конце функции void также корректен — управление автоматически возвращается вызывающему коду.


Ключевое слово inline: мифы и реальность

inline int square(int x) { return x * x; }

Исторически inline подсказывал компилятору встроить тело функции в точку вызова, избегая накладных расходов на вызов (push/pop, jump). Однако сегодня:

  • inline решает проблему множественного определения.
  • Функция, помеченная inline, может быть определена в заголовочном файле и включена в несколько единиц трансляции — компоновщик корректно обработает дубликаты.
  • Решение о фактическом встраивании принимает компилятор на основе собственного анализа (размер функции, частота вызовов, профилировка и т.д.). Атрибуты [[gnu::always_inline]] или __forceinline (MSVC) дают более сильную подсказку, но не гарантируют встраивания.

Используйте inline для определений функций в заголовках (включая шаблоны, лямбды в глобальной области и constexpr-функции до C++17), но не рассчитывайте на него как на средство повышения производительности.


constexpr-функции: вычисления на этапе компиляции

Начиная с C++11, функции могут быть объявлены как constexpr, что означает: эта функция может быть вызвана на этапе компиляции, если аргументы известны статически.

constexpr int factorial(int n) {
return (n <= 1) ? 1 : n * factorial(n - 1);
}

constexpr int f = factorial(5); // вычисляется при компиляции
int x = factorial(std::rand() % 10); // вычисляется во время выполнения

Требования к constexpr-функциям постепенно смягчались:

  • C++11: только одно выражение return, никаких циклов, локальных переменных (кроме constexpr), только тривиальные типы.
  • C++14: разрешены локальные переменные, циклы for/while, if, switch, объявления и т.д. — почти весь код, не имеющий побочных эффектов во время компиляции.
  • C++20: разрешены виртуальные функции, try/catch, динамическое выделение памяти (в рамках компиляции).

constexpr открывает путь к:

  • вычислению констант без макросов,
  • созданию сложных таблиц и структур данных на этапе компиляции,
  • усилению проверок типов и значений (static_assert с вызовом constexpr),
  • уменьшению размера бинарника и времени запуска.

Рекурсия: когда функция вызывает саму себя

Рекурсия — это техника, при которой функция вызывает саму себя, непосредственно (прямая рекурсия) или через цепочку других функций (косвенная рекурсия). Она естественно выражает решения, основанные на принципе «разделяй и властвуй» или математической индукции: вычисление факториала, обход дерева, разбор грамматик, реализация алгоритмов «разделяй и властвуй» (быстрая сортировка, сортировка слиянием).

Прямая рекурсия

int fibonacci(int n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}

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

Косвенная рекурсия

bool is_even(int n);
bool is_odd(int n) { return n == 0 ? false : is_even(n - 1); }
bool is_even(int n) { return n == 0 ? true : is_odd(n - 1); }

Здесь is_even и is_odd вызывают друг друга. Для корректной компиляции требуется предварительное объявление (forward declaration): bool is_even(int n); до определения is_odd.

Хвостовая рекурсия и её (не)оптимизация

Хвостовая рекурсия — это частный случай, когда рекурсивный вызов является последней операцией в функции, и результат вызова возвращается напрямую:

int factorial_tail(int n, int acc = 1) {
if (n <= 1) return acc;
return factorial_tail(n - 1, n * acc); // хвостовой вызов
}

Многие функциональные языки (Scheme, Haskell, Erlang) гарантируют оптимизацию хвостовой рекурсии: стековый фрейм текущего вызова переиспользуется для следующего, предотвращая переполнение.

В C++ такой гарантии нет. Стандарт не требует от компиляторов оптимизировать хвостовую рекурсию, и хотя GCC и Clang могут это делать при высоких уровнях оптимизации (-O2, -O3) и простых условиях, полагаться на это нельзя. Для критичных к стеку сценариев рекомендуется явно переходить к итеративной реализации с циклом.


Функции с переменным числом аргументов

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

C-стиль: stdarg.h и va_list

#include <cstdarg>

double average(int count, ...) {
va_list args;
va_start(args, count);
double sum = 0.0;
for (int i = 0; i < count; ++i) {
sum += va_arg(args, double);
}
va_end(args);
return count ? sum / count : 0.0;
}

Проблемы:

  • Отсутствие проверки типов: передача int вместо double приведёт к неопределённому поведению, так как va_arg полагается только на явно указанный тип.
  • Нет информации о количестве аргументов: программист обязан передавать счётчик (count) или использовать маркер конца (например, nullptr для указателей).
  • Несовместимость с некопируемыми/немутируемыми типами: объекты с нетривиальными конструкторами/деструкторами могут вести себя непредсказуемо.

Современная альтернатива — шаблоны с параметрами пакетов (variadic templates), появившиеся в C++11.

Шаблоны с пакетами параметров

template<typename... Args>
void log(Args&&... args) {
((std::cout << args << " "), ...); // fold-expression (C++17)
}

Преимущества:

  • Полная проверка типов на этапе компиляции,
  • Поддержка перемещения через универсальные ссылки (Args&&...),
  • Интеграция с std::forward для идеальной передачи,
  • Распаковка пакета с помощью fold-выражений (C++17) или рекурсивных шаблонов.

Типичные применения: логгеры, фабрики, std::make_unique, std::make_shared, сериализаторы.

Ещё один частный, но практичный способ — std::initializer_list:

void print_list(std::initializer_list<int> values) {
for (int v : values) std::cout << v << ' ';
}
// вызов: print_list({1, 2, 3, 4});

Он удобен, когда все аргументы одного типа и передаются в фигурных скобках. Однако initializer_list предоставляет только константные итераторы и не поддерживает перемещение элементов.


Лямбда-выражения: безымянные функции на месте

Лямбда-выражения (lambda expressions) — это синтаксическое средство создания анонимных функциональных объектов (функторов) непосредственно в точке использования. Они появились в C++11 и с тех пор претерпели значительное развитие.

Базовый синтаксис

[capture](parameters) -> return_type { body }
  • [capture]список захвата переменных из окружающей области видимости,
  • (parameters) — параметры (аналогично обычной функции),
  • -> return_type — указание типа возврата (необязательно; если опущено, выводится по return),
  • { body } — тело функции.

Пример:

auto add = [](int a, int b) { return a + b; };
int result = add(3, 4); // 7

Здесь add — объект типа, сгенерированного компилятором (уникального для каждой лямбды), имеющего перегруженный operator().

Захват переменных

Захват определяет, как лямбда взаимодействует со внешними переменными:

  • [x] — захват x по значению (копия на момент создания лямбды),
  • [&x] — захват x по ссылке,
  • [=] — захват всех автоматических переменных по значению,
  • [&] — захват всех по ссылке,
  • [x, &y] — смешанный захват.

Захват по значению создаёт члены данных у функтора; захват по ссылке — члены-ссылки. Лямбда, захватывающая что-либо по ссылке, не может безопасно жить дольше захваченных объектов.

Обобщённые лямбды (C++14)

Параметры могут быть объявлены как auto, что делает лямбду шаблонной:

auto printer = [](const auto& x) { std::cout << x << '\n'; };
printer(42); // int
printer("hello"); // const char[6]

Компилятор генерирует отдельные экземпляры operator() для каждого типа.

Лямбды как constexpr (C++17)

Если тело лямбды удовлетворяет требованиям constexpr, она может быть вызвана на этапе компиляции:

constexpr auto square = [](int x) { return x * x; };
constexpr int sq = square(5); // OK

Лямбды с мутабельным захватом

auto counter = [n = 0]() mutable { return ++n; };
std::cout << counter(); // 1
std::cout << counter(); // 2

Ключевое слово mutable позволяет изменять копии захваченных по значению переменных внутри тела. Без mutable такие переменные в лямбде — const.

Преобразование в указатель на функцию

Лямбда без захвата может быть неявно преобразована в указатель на обычную функцию:

void (*fp)(int) = [](int x) { std::cout << x; };
fp(10); // OK

Лямбда с захватом — не может. Для единообразного хранения и вызова функций/лямбд/функторов используется std::function.


Указатели на функции и std::function

Указатели на функции

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

int add(int, int);
int (*func_ptr)(int, int) = &add; // или просто = add;
int result = func_ptr(2, 3); // 5

Тип int (*)(int, int) — это указатель на функцию, принимающую два int и возвращающую int. Такие указатели:

  • могут хранить адрес любой совместимой функции,
  • передаются в функции (например, как callback),
  • совместимы с C API,
  • не могут хранить лямбды с захватом или функторы.

std::function — универсальная обёртка

std::function<R(Args...)> — это шаблонный класс из <functional>, способный хранить и вызывать любой вызываемый объект с совместимой сигнатурой:

  • обычные функции,
  • указатели на функции,
  • лямбда-выражения (с захватом или без),
  • функторы (объекты с operator()),
  • результат std::bind.

Пример:

#include <functional>

std::function<int(int, int)> op;

op = add;
std::cout << op(1, 2); // 3

op = [](int a, int b) { return a * b; };
std::cout << op(3, 4); // 12

struct Multiplier {
int factor;
int operator()(int x, int y) const { return (x + y) * factor; }
};
op = Multiplier{10};
std::cout << op(1, 2); // 30

Недостаток std::function — небольшие накладные расходы: выделение памяти на куче (small buffer optimization может избежать этого для малых функторов), виртуальный вызов через таблицу. Для высокочастотных вызовов (hot path) прямой вызов или шаблонные параметры предпочтительнее.

std::bind — частичное применение

std::bind позволяет фиксировать часть аргументов функции и получать новый вызываемый объект:

auto greet = [](const std::string& lang, const std::string& name) {
if (lang == "ru") return "Привет, " + name;
return "Hello, " + name;
};

auto greet_en = std::bind(greet, "en", std::placeholders::_1);
std::cout << greet_en("Alice"); // Hello, Alice

Однако с появлением лямбд и универсальной инициализации std::bind используется всё реже — лямбды выразительнее и эффективнее.


Функции-члены и специальные функции класса

В объектно-ориентированной модели C++ функция может быть свободной (free function), и членом класса (member function). Такие функции имеют доступ к закрытым и защищённым членам объекта и неявно получают указатель на экземпляр класса через параметр this.

Неявный параметр this

class Counter {
int value = 0;
public:
void increment() { ++value; } // эквивалентно: void increment(Counter* const this) { ++this->value; }
};

Тип thisT* const внутри неконстантного метода и const T* const внутри константного. this — это rvalue, и его нельзя изменить (но можно разыменовать и изменять указуемый объект, если метод не константный).

Квалификаторы членов-функций

  • const — гарантирует, что метод не изменяет состояние объекта (кроме mutable-полей). Позволяет вызывать метод у константных объектов и ссылок:

    int get_value() const { return value; }
  • volatile — редко используется; указывает, что объект может изменяться асинхронно (например, аппаратный регистр). Методы с volatile могут вызываться только у volatile-объектов.

  • & / &&ref-квалификаторы (C++11): привязывают метод к lvalue или rvalue:

    std::string to_string() & { return "lvalue"; }   // вызывается для lvalue
    std::string to_string() && { return "rvalue"; } // вызывается для временных

    Это позволяет реализовать разное поведение в зависимости от категории объекта, например, возвращать копию для lvalue и перемещать внутренние ресурсы для rvalue.


Специальные функции-члены

Стандарт выделяет шесть специальных функций, которые компилятор может сгенерировать неявно, если они не объявлены пользователем:

ФункцияНазначениеПравила генерации по умолчанию
Конструктор по умолчанию (T())Создаёт объект без аргументовГенерируется, если нет никаких пользовательских конструкторов
Деструктор (~T())Освобождает ресурсы перед уничтожениемВсегда генерируется, если не объявлен; = default разрешён
Конструктор копирования (T(const T&))Инициализирует объект из существующегоГенерируется, если нет пользовательского конструктора копирования, перемещения или оператора копирования
Оператор копирующего присваивания (T& operator=(const T&))Присваивает значение от существующего объектаАналогично конструктору копирования
Конструктор перемещения (T(T&&))Инициализирует объект из временногоГенерируется, если нет пользовательского конструктора копирования/присваивания/деструктора
Оператор перемещающего присваивания (T& operator=(T&&))Присваивает значение от временногоАналогично конструктору перемещения

Если хотя бы одна из этих функций объявлена пользователем, компилятор может отказаться генерировать другие — это известно как правило пяти (Rule of Five): если вы определяете одну из специальных функций, скорее всего, нужно определить и остальные, чтобы корректно управлять ресурсами (память, файлы, сокеты).

C++11 ввёл = default и = delete, позволяя явно управлять генерацией:

class NonCopyable {
public:
NonCopyable() = default;
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
};

Дружественные функции (friend)

Дружественная функция — это свободная функция, имеющая доступ к закрытым и защищённым членам класса, несмотря на отсутствие принадлежности к нему.

class Secret {
int code;
friend void reveal(const Secret& s); // объявление дружбы
};

void reveal(const Secret& s) {
std::cout << "Secret code: " << s.code; // доступ разрешён
}

Применения:

  • Перегрузка операторов ввода/вывода (operator<<, operator>>), которые обязаны быть свободными, но нуждаются в доступе к состоянию объекта.
  • Функции-помощники для unit-тестов (при условии строгого контроля области видимости).
  • Реализация фабрик или десериализаторов, инкапсулированных вне класса.

Дружба не наследуется и не транзитивна. Дружба — это привилегия, предоставляемая классом, а не требование со стороны функции.


Пространства имён и правила поиска имён (ADL)

Функции в C++ организуются в пространства имён (namespaces), что предотвращает конфликты имён и обеспечивает логическую группировку.

namespace math {
int add(int a, int b) { return a + b; }
}
int x = math::add(2, 3);

Argument-Dependent Lookup (ADL, или «Koenig lookup»)

Когда функция вызывается без квалификации пространства имён, компилятор ищет её в текущей и глобальной областях видимости, и в пространствах имён, связанных с типами аргументов:

namespace N {
struct S {};
void foo(S) {}
}

int main() {
N::S s;
foo(s); // OK: найдено через ADL в namespace N
}

ADL критически важен для:

  • перегрузки операторов (например, std::cout << obj работает, потому что operator<< для пользовательского типа ищется в его пространстве имён),
  • шаблонных алгоритмов (swap, begin, end, hash),
  • пользовательских адаптеров (serialize, validate и т.п.).

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


Взаимодействие с другими языками: extern "C" и ABI

C++ использует name mangling — преобразование имён функций в уникальные символьные идентификаторы, учитывающие сигнатуру, пространство имён, класс и т.д. Это необходимо для поддержки перегрузки и вложенных имён.

Однако при взаимодействии с C (библиотеками, системными вызовами, динамическими библиотеками) требуется отключить манглинг, чтобы имя функции в объектном файле совпадало с исходным.

extern "C" {
int legacy_api(int x); // имя в .o-файле — просто _legacy_api или legacy_api
}

extern "C" применяется:

  • При объявлении C-функций в C++-коде,
  • При экспорте C++-функций для вызова из C (ограничено: только функции без перегрузки, без исключений, без классов в сигнатуре),
  • В заголовках, совместимых с обоими языками (через #ifdef __cplusplus).

Важно: extern "C" не влияет на вызывающее соглашение (calling convention) напрямую — это отдельная деталь ABI (например, __cdecl, __stdcall, __fastcall). Но в сочетании с платформенно-зависимыми атрибутами позволяет обеспечить бинарную совместимость.


Функции и исключения: noexcept, гарантии безопасности, раскрутка стека

C++ поддерживает исключения как механизм обработки ошибок. Функции взаимодействуют с ними через:

Спецификацию исключений (устарела) и noexcept

Ранние стандарты использовали динамические спецификации (throw(T)), но они были признаны неэффективными и убраны в C++17. Современный инструмент — noexcept:

void safe_operation() noexcept; // гарантирует: не выбрасывает исключений

Если функция, помеченная noexcept, всё же выбрасывает исключение, вызывается std::terminate().

noexcept влияет на:

  • Производительность: компилятор может оптимизировать вызовы (например, избегать установки фреймов раскрутки стека),
  • Выбор перегрузок: std::vector::push_back использует перемещение вместо копирования, только если конструктор перемещения — noexcept,
  • Корректность: для деструкторов, освобождающих ресурсы, noexcept почти всегда обязателен (иначе — UB при исключении во время раскрутки стека).

Гарантии безопасности исключений

Функция должна предоставлять одну из трёх гарантий:

  • Базовая гарантия — если исключение произошло, программа остаётся в согласованном состоянии (никаких утечек, инварианты классов сохранены), но объект может быть изменён.
  • Строгая гарантия — либо операция завершена успешно, либо состояние точно такое же, как до вызова («commit-or-rollback»). Реализуется через копирование-смену (copy-and-swap).
  • Гарантия noexcept — исключения невозможны.

Проектирование функций с учётом этих гарантий — важнейший аспект надёжности.

Раскрутка стека (stack unwinding)

Когда исключение выбрасывается, среда выполнения последовательно вызывает деструкторы всех автоматических объектов в стеке вызовов в порядке, обратном созданию, пока не найдёт подходящий catch. Это гарантирует освобождение ресурсов даже при аварийном завершении — основа идиомы RAII.

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


Современные практики: функции в эпоху C++20 и выше

Concepts и ограничения шаблонов

C++20 ввёл концепции — способ явно описать требования к шаблонным параметрам, включая функции:

template<std::integral T>
T add(T a, T b) { return a + b; }

Это заменяет громоздкие static_assert и SFINAE, делая ошибки компиляции понятнее и позволяя перегружать шаблоны по концепциям.

Модули (C++20)

Модули (import, export module) постепенно вытесняют #include. Функции, экспортируемые из модуля:

export module math;

export int add(int a, int b) { return a + b; }

— становятся видимыми без необходимости в заголовочных файлах, ускоряя компиляцию и устраняя проблемы ODR.

consteval и constinit (C++20)

  • consteval — более строгая версия constexpr: функция обязана выполняться на этапе компиляции:

    consteval int get_compile_time_value() { return 42; }
    int x = get_compile_time_value(); // OK
    // int y = get_compile_time_value(/* runtime arg */); // ОШИБКА
  • constinit — гарантирует статическую инициализацию переменной (без динамической фазы), но не требует constexpr:

    constinit int global = add(2, 3); // OK, если add — constexpr