1. Введение
Когда вы начинаете писать функции, очень легко думать так: «Я передал переменную в функцию — значит, функция работает с моей переменной». Это логичное человеческое ожидание, особенно если вы ещё не привыкли к правилам языка. Проблема в том, что C++ (как и многие языки) умеет передавать аргументы разными способами, и самый простой способ — по значению — на самом деле делает копию. А копия может быть как почти бесплатной (int), так и ощутимо дорогой (std::string, std::vector).
Сегодняшняя цель проста: сформировать у вас привычку читать сигнатуру функции как контракт. Увидели T x — мысленно произнесли: «Окей, будет копия». И дальше уже решаете: «Мне это подходит или я случайно устраиваю копировальный цех?».
Передача по значению: в функцию заходит копия
Передача по значению выглядит так: параметр объявлен как обычная переменная — T x. Важно помнить: параметр функции — это переменная, которая живёт внутри функции. Чтобы эта переменная появилась, ей нужно начальное значение. При передаче по значению это начальное значение берётся копированием аргумента.
На уровне ощущений это похоже на ситуацию: вы дали другу не свой паспорт, а ксерокопию. Друг может на ней рисовать усы маркером сколько угодно — ваш оригинал останется целым. С данными — ровно так же: изменили параметр внутри функции — исходная переменная в main() не изменилась.
Нарисуем схему (на «бытовом» уровне, без глубоких деталей памяти):
flowchart LR
A["main(): переменная a"] -->|копирование| B["func(x): параметр x"]
B --> C[изменяем x]
C --> D[выходим из func, x исчезает]
A --> E[а в main осталась прежней]
Пример 1: int по значению — изменили внутри, снаружи не изменилось
#include <iostream>
void set_zero(int x) {
x = 0;
}
int main() {
int a = 5;
set_zero(a);
std::cout << a << '\n'; // 5
}
Здесь x — копия a. Поэтому a остаётся равным 5.
Почему с int всё нормально, а со string/vector — грустно
Сейчас будет важная развилка мышления. Передача по значению — не «плохо» и не «хорошо». Это инструмент. Он идеален для маленьких типов, где копирование похоже на «взять один камешек». Но он становится тяжёлым для объектов, внутри которых много данных: копирование похоже уже на «перенести мешок картошки», причём иногда — каждый раз при вызове функции.
Условно можно держать в голове такую таблицу:
| Тип параметра | Что примерно копируется при T x | Обычно ощущается как |
|---|---|---|
| int, double, bool, char | несколько байт | почти бесплатно |
| std::string | символы строки (в общем случае) | может быть заметно |
| std::vector<int> | все элементы вектора | часто заметно |
| std::vector<std::string> | много строк, каждая строка копируется | заметно и по времени, и по памяти |
Фраза «в общем случае» тут очень важна: у стандартной библиотеки есть оптимизации, но мы будем рассуждать как инженеры: если размер данных растёт, копирование становится дороже. И если вы случайно копируете данные в каждой второй функции — программа начинает тормозить без видимой причины.
2. std::string по значению: копия текста и «невидимые расходы»
std::string выглядит как один объект, но логически это «контейнер символов». Когда вы копируете строку, вы как будто переписываете её целиком в новую тетрадку. Иногда строка короткая — и вы даже не заметите. Но если в строке тысяча символов, и вы делаете это много раз, копирование превращается в постоянную фоновую работу.
Пример 2: функция изменяет строку, но исходная строка не меняется
#include <iostream>
#include <string>
void add_exclamation(std::string s) {
s += "!";
std::cout << s << '\n'; // Hello!
}
int main() {
std::string msg = "Hello";
add_exclamation(msg);
std::cout << msg << '\n'; // Hello
}
Здесь s — копия msg. Удобно, когда вы хотите работать с копией. Неудобно, когда вы случайно думали, что меняете оригинал.
Пример 3: «функция-преобразователь» — по значению уместно
Очень частый и красивый стиль: функция берёт значение, создаёт изменённую версию и возвращает её. Тогда передача по значению выглядит логично: «Я получил свой экземпляр, поменял, отдал результат».
#include <iostream>
#include <string>
std::string make_tag(std::string s) {
s = "[" + s + "]";
return s;
}
int main() {
std::string x = "TODO";
std::cout << make_tag(x) << '\n'; // [TODO]
std::cout << x << '\n'; // TODO
}
Это читается как чистое преобразование. Да, копия есть, но она соответствует смыслу: вы не хотите «побочного эффекта» в виде изменения x.
2. std::vector по значению: копия элементов и O(N)
С std::vector всё становится ещё нагляднее. Вектор — это контейнер, в котором может быть много элементов. Передать вектор по значению — это значит создать новый вектор и скопировать в него элементы. Если элементов N, то копирование — это работа примерно пропорциональная N. То есть интуитивно это O(N): чем больше вектор, тем дольше.
Именно поэтому у новичков часто возникает загадка: «Почему мой код с маленькими данными быстрый, а с большими — внезапно медленный?» Очень частый ответ: «Потому что вы копируете вектор в каждой функции».
Пример 4: изменение копии вектора не влияет на исходный вектор
#include <iostream>
#include <vector>
void push_demo(std::vector<int> v) {
v.push_back(99);
std::cout << v.size() << '\n'; // 4
}
int main() {
std::vector<int> data{1, 2, 3};
push_demo(data);
std::cout << data.size() << '\n'; // 3
}
Снаружи data остался прежним. Внутри push_demo() мы изменили только копию v.
Пример 5: копирование вектора — это копирование элементов
Покажем это не через время (время мерить мы ещё толком не умеем), а через смысл:
#include <iostream>
#include <vector>
int main() {
std::vector<int> a{1, 2, 3};
std::vector<int> b = a; // копия всех элементов
b[0] = 100;
std::cout << a[0] << '\n'; // 1
std::cout << b[0] << '\n'; // 100
}
Если бы b был просто «другим именем для a», то a[0] тоже стал бы 100. Но он не стал. Значит, b — отдельный контейнер со своими элементами.
3. Полезные нюансы: const, преобразователи и «скрытые копии»
const T x — это всё равно копия
Иногда хочется «поправить ситуацию» так: «Окей, раз проблема в изменениях, сделаю параметр const, и копии не будет». Это понятная попытка, но она не работает.
const запрещает менять локальную переменную внутри функции, но не отменяет факт создания локальной переменной. То есть копия как создавалась, так и создаётся.
Пример 6: const std::string s не спасает от копирования
#include <iostream>
#include <string>
std::size_t len(const std::string s) {
return s.size();
}
int main() {
std::string text = "abcdef";
std::cout << len(text) << '\n'; // 6
}
Здесь строка всё равно копируется, просто внутри len() вы не сможете написать s += "!".
Если коротко: const — это про запрет изменения, а не про скорость.
Когда передача по значению — хороший выбор
Важно не уйти в другую крайность: «по значению — значит плохо, никогда так не делаю». Это примерно как решить: «раз огонь обжигает, я больше никогда не буду готовить еду». Огонь полезен, если понимать, где плита, а где шторы.
Передача по значению хороша в ситуациях, когда по смыслу вы хотите, чтобы функция работала с независимым значением. Обычно это один из двух случаев.
Первый случай — маленькие простые типы. Если у вас int, то передача по значению выглядит нормально, читается проще, и вы не начинаете «усложнять контракт» там, где это не нужно.
Второй случай — функции-преобразователи, где вы намеренно получаете копию, меняете и возвращаете новую версию. Это делает код предсказуемым: нет неожиданных изменений «где-то в глубине».
Пример 7: преобразуем список задач и возвращаем новый
Мы продолжаем развивать наше консольное приложение «Список дел»: пока он хранит дела как строки в std::vector<std::string>. Сейчас сделаем функцию, которая добавляет префикс "[TODO] " ко всем задачам и возвращает новый список. Да, копирование есть — но это часть идеи: мы хотим именно новый список.
#include <string>
#include <vector>
std::vector<std::string> add_todo_prefix(std::vector<std::string> tasks) {
for (std::string& t : tasks) {
t = "[TODO] " + t;
}
return tasks;
}
Обратите внимание: мы не меняем исходный список «снаружи», а строим новый результат. Это стиль «получил → преобразовал → вернул».
Пример 8: используем преобразование в main()
#include <iostream>
#include <string>
#include <vector>
std::vector<std::string> add_todo_prefix(std::vector<std::string> tasks) {
for (std::string& t : tasks) t = "[TODO] " + t;
return tasks;
}
int main() {
std::vector<std::string> tasks{"buy milk", "learn C++"};
auto tagged = add_todo_prefix(tasks);
std::cout << tasks[0] << '\n'; // buy milk
std::cout << tagged[0] << '\n'; // [TODO] buy milk
}
Это очень читаемый контракт: вход не меняем, выходим с новым значением.
«Скрытая копия»: как случайно сделать программу тяжелее
Сейчас сделаем очень жизненный фрагмент: функция печатает список задач. Интуитивно хочется написать так: «ну передам вектор, и распечатаю». И написать по значению. Код будет работать, но при этом вы каждый раз будете копировать весь список. Если список из двух задач — да всё равно. Если список из десяти тысяч задач — уже не всё равно, и вы начнёте подозревать, что компьютер обиделся лично на вас.
Пример 9: печать списка, но с лишней копией
#include <iostream>
#include <string>
#include <vector>
void print_tasks(std::vector<std::string> tasks) {
std::cout << "Tasks: " << tasks.size() << '\n';
}
int main() {
std::vector<std::string> tasks{"buy milk", "learn C++"};
print_tasks(tasks); // вывод: Tasks: 2
}
Смысл функции — «просто посмотреть». Но по сигнатуре это означает: «сначала сделай копию всего списка, потом посмотри». На маленьких данных вы не заметите, на больших — заметите.
И вот здесь появляется главная дисциплина сегодняшней лекции: если вы пишете T x, вы обязаны иметь мысленное объяснение, зачем вам копия. Если объяснение звучит как «ну я так всегда пишу» — это тревожный звоночек.
4. Типичные ошибки при передаче по значению
Ошибка №1: ожидать, что T x изменит переменную снаружи.
Это классика. Человек пишет void clear(std::string s) { s = ""; }, вызывает clear(name), а строка не очищается. Причина простая: s — копия. В таких ситуациях полезно прямо проговаривать: «Параметр — локальная переменная». Если вам нужно изменить исходный объект, это должно быть выражено в контракте функции (но детали этого контракта мы разберём в следующей лекции).
Ошибка №2: «случайно» копировать std::vector и std::string в функциях, которые только читают.
Такие функции выглядят безобидно: print, count, sum, has_word. Но если они принимают большой объект по значению, то каждая проверка превращается в «сначала скопируй данные, потом посмотри». Это особенно неприятно в циклах: вы можете сами того не заметив сделать «копирование N раз», и программа станет тормозной без явной причины.
Ошибка №3: пытаться исправить копирование с помощью const.
const действительно полезен, но он решает другую задачу: защищает от случайного изменения. Он не отменяет копию. Поэтому const std::vector<int> v — это «копия, которую нельзя менять», а не «без копии».
Ошибка №4: смешивать два разных стиля в одной функции: и «изменяю», и «возвращаю».
Например, функция получает std::string s (копию), изменяет её и ещё пытается «поменять исходную строку» в вызывающем коде (что невозможно при передаче по значению). Такой код рождает путаницу: где же на самом деле живёт результат? Привыкайте выбирать один стиль: либо функция чисто возвращает новое значение, либо явно меняет переданный объект (но это уже следующий шаг обучения).
Ошибка №5: делать дорогую копию «ради одной проверки».
Иногда пишут что-то вроде: «я хотел просто узнать размер вектора, но передал его в функцию по значению и вызвал size()». Получается как покупать холодильник ради одного кубика льда. Если операция чтения маленькая, а копирование большое, вы платите не за то, что вам нужно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ