1. Wprowadzenie
Kiedy zaczynasz pisać funkcje, bardzo łatwo myśleć tak: „Przekazałem zmienną do funkcji — więc funkcja operuje na mojej zmiennej”. To logiczne ludzkie oczekiwanie, zwłaszcza jeśli jeszcze nie przywykłeś do reguł języka. Problem w tym, że C++ (jak wiele języków) potrafi przekazywać argumenty na różne sposoby, a najprostszy sposób — przez wartość — w rzeczywistości tworzy kopię. A kopia może być albo prawie bezpłatna (int), albo zauważalnie kosztowna (std::string, std::vector).
Cel dzisiejszego wykładu jest prosty: wyrobić w tobie nawyk czytania sygnatury funkcji jak kontraktu. Zobaczyłeś T x — pomyśl: „OK, będzie kopia”. I dalej zdecyduj: „Czy mi to pasuje, czy przypadkiem uruchamiam warsztat kopiowania?”.
Przekazywanie przez wartość: do funkcji trafia kopia
Przekazywanie przez wartość wygląda tak: parametr zadeklarowany jest jako zwykła zmienna — T x. Ważne, aby pamiętać: parametr funkcji to zmienna, która żyje wewnątrz funkcji. Aby ta zmienna powstała, potrzebuje wartości początkowej. Przy przekazywaniu przez wartość ta wartość początkowa jest uzyskiwana przez skopiowanie argumentu.
Na poziomie intuicji przypomina to sytuację: podałeś znajomemu nie swój paszport, a jego kserokopię. Znajomy może rysować na niej wąsy markerem ile chce — twój oryginał pozostanie nietknięty. Z danymi jest dokładnie tak samo: zmieniłeś parametr wewnątrz funkcji — oryginalna zmienna w main() nie uległa zmianie.
Narysujmy schemat (na poziomie „codziennym”, bez zagłębiania się w detale pamięci):
flowchart LR
A["main(): zmienna a"] -->|kopiowanie| B["func(x): parametr x"]
B --> C[modyfikujemy x]
C --> D[wyjscie z func, x znika]
A --> E[a w main pozostala bez zmian]
Przykład 1: int przez wartość — zmieniono wewnątrz, na zewnątrz nie zmieniono
#include <iostream>
void set_zero(int x) {
x = 0;
}
int main() {
int a = 5;
set_zero(a);
std::cout << a << '\n'; // 5
}
Tutaj x to kopia a. Dlatego a pozostaje równe 5.
Dlaczego z int wszystko w porządku, a ze string/vector — gorzej
Teraz ważne rozgałęzienie myślenia. Przekazywanie przez wartość — nie jest „złe” ani „dobre”. To narzędzie. Idealne dla małych typów, gdzie kopiowanie przypomina „wzięcie jednego kamyka”. Ale staje się ciężkie dla obiektów, w których jest dużo danych: kopiowanie przypomina wtedy „przenoszenie worka ziemniaków”, często — przy każdym wywołaniu funkcji.
Uproszczona tabela pomocna do zapamiętania:
| Typ parametru | Co mniej więcej jest kopiowane przy T x | Zazwyczaj odczuwalne jako |
|---|---|---|
| int, double, bool, char | kilka bajtów | praktycznie za darmo |
| std::string | znaki ciągu (w ogólnym przypadku) | może być zauważalne |
| std::vector<int> | wszystkie elementy wektora | często zauważalne |
| std::vector<std::string> | wiele stringów, każdy string jest kopiowany | odczuwalnie pod względem czasu i pamięci |
Zwrot „w ogólnym przypadku” jest tu bardzo ważny: biblioteka standardowa ma optymalizacje, ale będziemy myśleć jak inżynierowie: jeśli rozmiar danych rośnie, koszt kopiowania rośnie. I jeżeli przypadkowo kopiujesz dane w co drugiej funkcji — program zaczyna zwalniać bez widocznego powodu.
2. std::string przez wartość: kopia tekstu i „ukryte koszty”
std::string wygląda jak pojedynczy obiekt, ale logicznie to „kontener znaków”. Gdy kopiujesz string, to tak jakbyś przepisywał go w całości do nowego notatnika. Czasem string jest krótki — i tego nie zauważysz. Ale jeśli string ma tysiąc znaków i robisz to wielokrotnie, kopiowanie zmienia się w stałą pracę w tle.
Przykład 2: funkcja zmienia string, ale oryginalny string nie zmienia się
#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
}
Tutaj s to kopia msg. To wygodne, gdy chcesz pracować na kopii. Niewygodne, gdy przypadkowo myślałeś, że zmieniasz oryginał.
Przykład 3: „funkcja-przekształcacz” — przekazywanie przez wartość ma sens
Bardzo częsty i elegancki styl: funkcja bierze wartość, tworzy zmienioną wersję i ją zwraca. Wtedy przekazywanie przez wartość ma sens: „Dostałem swoją kopię, zmieniłem, zwracam wynik”.
#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
}
To czyta się jak czyste przekształcenie. Tak, kopia istnieje, ale odpowiada ona sensowi: nie chcesz „efektu ubocznego” w postaci zmiany x.
2. std::vector przez wartość: kopiowanie elementów i O(N)
Z std::vector wszystko staje się jeszcze bardziej oczywiste. Wektor to kontener, który może zawierać dużo elementów. Przekazać wektor przez wartość — to stworzyć nowy wektor i skopiować do niego elementy. Jeśli elementów jest N, to kopiowanie jest pracą w przybliżeniu proporcjonalną do N. Intuicyjnie to O(N): im większy wektor, tym dłużej.
Dlatego początkujący często zadają sobie pytanie: „Dlaczego mój kod z małymi danymi jest szybki, a z dużymi — nagle wolny?” Częsta odpowiedź: „Bo kopiujesz wektor w każdej funkcji”.
Przykład 4: zmiana kopii wektora nie wpływa na oryginalny wektor
#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
}
Na zewnątrz data pozostał niezmieniony. Wewnątrz push_demo() zmieniliśmy tylko kopię v.
Przykład 5: kopiowanie wektora to kopiowanie elementów
Pokażemy to nie przez pomiar czasu (mierzyć go jeszcze dobrze nie umiemy), lecz przez sens:
#include <iostream>
#include <vector>
int main() {
std::vector<int> a{1, 2, 3};
std::vector<int> b = a; // kopia wszystkich elementow
b[0] = 100;
std::cout << a[0] << '\n'; // 1
std::cout << b[0] << '\n'; // 100
}
Gdyby b było po prostu „inną nazwą dla a”, to a[0] też stałby się 100. Ale tak się nie stało. Oznacza to, że b to oddzielny kontener ze swoimi elementami.
3. Przydatne niuanse: const, przekształcenia i „ukryte kopie”
const T x — to nadal kopia
Czasami chcesz „rozwiązać problem” tak: „OK, skoro problem to modyfikacja, oznaczę parametr jako const, i kopii nie będzie”. To zrozumiała próba, ale nie działa.
const zabrania zmiany lokalnej zmiennej wewnątrz funkcji, ale nie cofa faktu utworzenia lokalnej zmiennej. Innymi słowy, kopia tak samo powstaje.
Przykład 6: const std::string s nie chroni przed kopiowaniem
#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
}
Tutaj string i tak jest kopiowany, po prostu wewnątrz len() nie możesz napisać s += "!".
Krótko mówiąc: const dotyczy zakazu modyfikacji, a nie wydajności.
Kiedy przekazywanie przez wartość jest dobrym wyborem
Ważne, by nie przejść na drugą skrajność: „przez wartość = złe, nigdy tak nie robię”. To jak stwierdzenie: „skoro ogień parzy, nigdy nie będę już gotować”. Ogień jest użyteczny, jeśli wiesz, gdzie jest kuchenka, a gdzie zasłony.
Przekazywanie przez wartość jest dobre, gdy sensownie chcesz, aby funkcja pracowała na niezależnej wartości. Zwykle są dwa przypadki.
Pierwszy przypadek — małe, proste typy. Jeśli masz int, przekazywanie przez wartość jest w porządku, czytelniejsze i nie komplikujesz kontraktu tam, gdzie to niepotrzebne.
Drugi przypadek — funkcje-przekształcające, gdzie celowo pobierasz kopię, modyfikujesz ją i zwracasz nową wersję. To czyni kod przewidywalnym: brak niespodziewanych zmian „gdzieś głęboko”.
Przykład 7: przekształcamy listę zadań i zwracamy nową
Kontynuujemy rozwój naszej konsolowej aplikacji „Lista zadań”: przechowujemy zadania jako stringi w std::vector<std::string>. Teraz zrobimy funkcję, która doda przedrostek "[TODO] " do wszystkich zadań i zwróci nową listę. Tak, kopiowanie występuje — ale to część idei: chcemy dokładnie nowej listy.
#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;
}
Zwróć uwagę: nie zmieniamy oryginalnej listy „na zewnątrz”, budujemy nowy wynik. To styl „otrzymał → przekształcił → zwrócił”.
Przykład 8: używamy przekształcenia w 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
}
To bardzo czytelny kontrakt: wejście nie jest zmieniane, wyjście to nowa wartość.
„Ukryta kopia”: jak przypadkowo obciążyć program
Pokażemy teraz bardzo życiowy fragment: funkcja wypisuje listę zadań. Intuicyjnie chcesz napisać: „przekażę wektor i go wypiszę”. I przekazać przez wartość. Kod zadziała, ale za każdym razem będziesz kopiować całą listę. Jeśli lista ma dwa zadania — wszystko w porządku. Jeśli lista ma dziesięć tysięcy zadań — już nie, i zaczniesz się zastanawiać, dlaczego komputer działa wolno.
Przykład 9: wypisanie listy z zbędną kopią
#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); // output: Tasks: 2
}
Sens funkcji — „tylko podgląd”. Ale sygnatura oznacza: „najpierw zrób kopię całej listy, potem ją obejrzyj”. Przy małych danych tego nie zauważysz, przy dużych — tak.
I tutaj pojawia się główna dyscyplina dzisiejszego wykładu: jeśli piszesz T x, powinieneś umieć w myślach uzasadnić, po co potrzebujesz kopii. Jeśli uzasadnienie brzmi „bo zawsze tak piszę” — to sygnał ostrzegawczy.
4. Typowe błędy przy przekazywaniu przez wartość
Błąd nr 1: oczekiwać, że T x zmieni zmienną na zewnątrz.
To klasyka. Ktoś pisze void clear(std::string s) { s = ""; }, wywołuje clear(name), a string się nie czyści. Powód jest prosty: s to kopia. W takich sytuacjach warto wyraźnie myśleć: „Parametr to zmienna lokalna”. Jeśli chcesz zmienić obiekt oryginalny, musi to być wyrażone w kontrakcie funkcji (szczegóły omówimy w kolejnym wykładzie).
Błąd nr 2: „przypadkowe” kopiowanie std::vector i std::string w funkcjach, które tylko czytają.
Takie funkcje wydają się nieszkodliwe: print, count, sum, has_word. Ale jeśli przyjmują duży obiekt przez wartość, każda operacja sprawdzająca zamienia się w „najpierw skopiuj dane, potem sprawdź”. To szczególnie dokuczliwe w pętlach: możesz nieświadomie wykonać „kopiowanie N razy”, i program stanie się wolny bez oczywistej przyczyny.
Błąd nr 3: próba naprawy kopiowania przez const.
const jest przydatny, ale rozwiązuje inną sprawę: chroni przed przypadkową modyfikacją. Nie cofa kopii. Dlatego const std::vector<int> v — to „kopia, której nie możesz zmieniać”, a nie „bez kopii”.
Błąd nr 4: mieszanie dwóch stylów w jednej funkcji: jednocześnie „zmieniam” i „zwracam”.
Na przykład funkcja otrzymuje std::string s (kopię), modyfikuje ją i jednocześnie próbuje „zmienić oryginalny string” w wywołującym kodzie (co jest niemożliwe przy przekazywaniu przez wartość). Taki kod rodzi niejasność: gdzie tak naprawdę jest wynik? Przyzwyczajaj się do wybierania jednego stylu: albo funkcja czysto zwraca nową wartość, albo jawnie modyfikuje przekazany obiekt (ale to temat na kolejny krok nauki).
Błąd nr 5: wykonywanie kosztownej kopii „dla jednej kontroli”.
Czasem ktoś zrobi tak: „chciałem tylko znać rozmiar wektora, ale przekazałem go do funkcji przez wartość i wywołałem size()”. To jak kupowanie całej zamrażarki dla jednego kostka lodu. Jeśli odczyt jest prosty, a kopiowanie drogie, płacisz za coś, czego nie potrzebujesz.
GO TO FULL VERSION