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;
}
На первый взгляд, здесь происходит:
- Создание локального вектора
v, - Вызов конструктора копирования (или перемещения) при возврате,
- Присваивание результата внешней переменной.
На практике современные компиляторы применяют две мощные оптимизации:
- 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; }
};
Тип this — T* 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