Функции в C++: передача аргументов и стоимость копий
Один и тот же параметр функции можно объявить пятью способами, и каждый диктует, сколько раз скопируется объект. Разбираем на примере std::string и std::vector, где копия стоит денег.

Функция, принимающая std::string по значению, в цикле на миллион вызовов делает миллион аллокаций памяти. Та же функция с параметром const std::string& — ноль. Разница не в стиле, а в том, что компилятор обязан скопировать объект, когда вы объявили параметр по значению, и не обязан, когда по ссылке. Разберём, как объявление параметра меняет число копий и перемещений, на примерах для C++17 и новее (компилятор с поддержкой gcc 11+ / clang 13+).
Статья про один угол: как способ передачи аргумента влияет на стоимость вызова. Возврат значений, шаблонные forwarding-ссылки и перегрузка по ref-qualifier — смежные темы, здесь их не разворачиваем.
Пять способов объявить один параметр
Возьмём функцию, которая принимает строку и что-то с ней делает. Вот варианты объявления и то, что происходит с объектом на входе:
| Объявление | Копия при передаче | Можно менять внутри | Видит вызывающий |
|---|---|---|---|
void f(std::string s) | Да (или move из rvalue) | Да, свою копию | Нет |
void f(const std::string& s) | Нет | Нет | Нет |
void f(std::string& s) | Нет | Да, оригинал | Да |
void f(std::string&& s) | Нет, забирает содержимое | Да | Оригинал опустошается |
void f(std::string* s) | Нет (копируется указатель) | Да, если не null | Да |
Указатель здесь стоит особняком: он честно говорит «объекта может не быть» (nullptr), тогда как ссылка обязана ссылаться на что-то. Дальше сосредоточимся на первых четырёх — они про копии.
По значению: копия почти всегда лишняя
Классический вход в тему — параметр по значению. Он создаёт независимую копию: менять её внутри безопасно, снаружи изменения не видны.
#include <string>
#include <iostream>
// s — полная копия аргумента
void greet(std::string name) {
name += "!"; // меняем локальную копию
std::cout << "Привет, " << name << '\n';
}
int main() {
std::string user = "Аня";
greet(user); // user скопирован целиком
std::cout << user << '\n'; // всё ещё "Аня"
}
Строка "Аня" короткая, и здесь спасает SSO1 — короткие строки живут прямо в объекте без обращения к куче. Но как только строка длиннее буфера SSO (порядок 15–22 байт в зависимости от реализации), копия по значению превращается в аллокацию плюс memcpy. В горячем цикле это заметно.
По const-ссылке: ноль копий на чтение
Если функция только читает аргумент и не хранит его, const& — правильный выбор по умолчанию для тяжёлых типов.
#include <string>
#include <cstddef>
// ничего не копируем, только читаем
std::size_t count_spaces(const std::string& text) {
std::size_t n = 0;
for (char c : text)
if (c == ' ') ++n;
return n;
}
Передача — это копирование адреса, а не содержимого. Для std::vector, std::string, больших структур разница на длинных данных измеряется в разы.
Правило-ориентир: если тип дороже двух указателей и функция только читает —const&. Дляint,double, указателей и мелких структур по значению быстрее: копия дешевле разыменования.
По неконстантной ссылке: меняем оригинал
Когда функция должна изменить переданный объект, а не его копию:
#include <string>
#include <algorithm>
#include <cctype>
// приводим строку к нижнему регистру на месте
void to_lower_inplace(std::string& text) {
std::transform(text.begin(), text.end(), text.begin(),
[](unsigned char c) { return std::tolower(c); });
}
Вызывающий увидит изменения. Минус — по сигнатуре не очевидно, что аргумент будет изменён; читателю кода приходится это держать в голове. Для «выходных» параметров многие команды предпочитают возврат значения или std::pair/структуру, а не мутацию через ссылку.
По rvalue-ссылке: забираем содержимое
std::string&& принимает только временные объекты и позволяет «украсть» их внутренности вместо копирования.
#include <string>
#include <vector>
#include <utility>
class Log {
std::vector<std::string> lines_;
public:
// забираем содержимое временной строки без копии
void add(std::string&& line) {
lines_.push_back(std::move(line));
}
};
int main() {
Log log;
log.add("строка события"); // временный объект — ok
std::string s = "именованная";
// log.add(s); // ошибка компиляции: s — lvalue
log.add(std::move(s)); // явно отдаём s, дальше s пустая
}
После std::move(s) объект s остаётся в валидном, но неопределённом состоянии — читать его содержимое нельзя, а вот присвоить заново или уничтожить можно.
Идиома «принимай по значению и move»
Когда функция сохраняет аргумент у себя (например, конструктор кладёт строку в поле), есть приём, который одинаково хорошо работает и для lvalue, и для rvalue без двух перегрузок:
#include <string>
#include <utility>
class User {
std::string name_;
public:
// берём по значению, затем перемещаем в поле
explicit User(std::string name)
: name_(std::move(name)) {}
};
int main() {
std::string existing = "Пётр";
User a(existing); // одна копия (в параметр) + move в поле
User b("Пётр"); // ноль копий: move в параметр + move в поле
User c(std::move(existing)); // тоже move + move
}
Для временного объекта копий нет вовсе. Для именованной переменной — ровно одна, столько же, сколько дал бы const& с копированием в поле. Именно эту идиому рекомендует набор гайдлайнов C++ Core Guidelines для конструкторов, сохраняющих значение.
Когда что выбирать
- Только читаем тяжёлый тип —
const T&. - Читаем дешёвый тип (int, double, указатель, маленькая POD-структура) — по значению
T. - Меняем оригинал вызывающего —
T&, но подумайте, не лучше ли вернуть результат. - Сохраняем аргумент у себя — по значению
Tиstd::moveв поле. - Аргумент может отсутствовать — указатель
T*с проверкой наnullptr, илиstd::optional.
Что ломается на практике
Три ошибки, которые встречаются в ревью чаще всего:
- const-ссылка на временный объект, который переживёт функцию. Хранить
const std::string&в поле класса — путь к висячей ссылке: временный объект уничтожится, а поле продолжит на него смотреть. Для хранения берите по значению. - Использование объекта после
std::move. Компилятор не запретит читать перемещённую строку, но её содержимое не определено. Статические анализаторы (clang-tidy, проверкаbugprone-use-after-move) это ловят — включите их в CI. - Копия по значению для тяжёлого типа в горячем пути. Часто не видно глазами:
void process(std::vector<int> data)в цикле обработки. Профилировщик покажет неожиданные аллокации вoperator new.
Как измерить разницу самому
Не верьте на слово — замерьте. Простой способ увидеть число копий и перемещений — временно вставить класс со счётчиком:
#include <iostream>
struct Tracer {
Tracer() { std::cout << "ctor\n"; }
Tracer(const Tracer&) { std::cout << "copy\n"; }
Tracer(Tracer&&) noexcept { std::cout << "move\n"; }
};
Прогоните свою функцию с этим типом вместо реального и посчитайте строки copy в выводе. Для честного бенчмарка по времени используйте google/benchmark на реальных объёмах данных, а сборку делайте с -O2: на -O0 компилятор не применяет оптимизации и цифры соврут.
Что дальше
Следующий шаг после параметров — возврат значений: как работает copy elision и почему возвращать локальный объект по значению в C++17 не стоит ничего. Затем — forwarding-ссылки (T&& в шаблонах) и std::forward, которые обобщают идиому «по значению и move» на произвольные типы. Для проверки текущего кода включите clang-tidy с наборами performance-* и bugprone-* — они подсветят лишние копии и use-after-move автоматически.
1 SSO (Small String Optimization) — приём, при котором короткая строка хранится прямо внутри объекта std::string, без выделения памяти в куче. Порог длины зависит от реализации стандартной библиотеки и не гарантируется стандартом.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: C++ Core Guidelines: F.16 — For parameters passed by reference, prefer const; parameter passing rules, cppreference: std::move и правила инициализации ссылок
Частые вопросы
А если у меня старый компилятор с C++11, идиома «по значению и move» сработает?
Почему нельзя просто везде писать const&, чтобы не думать о копиях?
Что произойдёт, если прочитать строку после std::move?
Компилятор сам не оптимизирует лишнюю копию по значению?
Как это выглядит для std::vector или своих классов, а не только строк?
Мой бенчмарк не показал разницы между по значению и const&. Почему?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.