1. Wprowadzenie
Kiedy piszesz pierwsze sto linii kodu, wydaje się, że sterowanie przepływem to if, else i pętle. return wygląda jak formalność: „no trzeba coś zwrócić z funkcji int”. Jednak wkrótce okazuje się, że połowa „makaronu” z zagnieżdżonych if/else znika, jeśli nauczysz się wcześniej wychodzić z funkcji, gdy kontynuowanie nie ma sensu.
Ważne, by zapamiętać prostą myśl: return natychmiast kończy bieżącą funkcję. Nie „po pętli”, nie „po if”, nie „kiedyś później”. Teraz. Dlatego return to narzędzie nie tylko „do wyniku”, ale też „do struktury” kodu: pomaga uczynić czytanie liniowym, jak instrukcję, a nie labirynt.
Wyobraź sobie, że funkcja to mały scenariusz. Jeśli w scenariuszu coś pójdzie nie tak (niepoprawne dane, pusta lista, błędna komenda), normalna reakcja to nie ciągnąć całej funkcji przez dziesięć else, lecz spokojnie powiedzieć: „OK, na tym kończymy”.
2. Dwa rodzaje return: z wyrażeniem i bez
Teraz starannie rozłożymy na półki formy return. To jak drzwi: jedne to „wyjście z bagażem”, drugie — „wyjście bez bagażu”. Jeśli pomylić — będzie albo błąd kompilacji, albo dziwne próby przeciągnięcia walizki przez wąskie drzwi. W C++ mamy return expr; i return;, a wybór zależy od typu zwracanego przez funkcję.
Mini-tabela, którą warto mieć w głowie:
| Typ funkcji | Dozwolony return | Co oznacza |
|---|---|---|
|
|
Zwrócić wartość i zakończyć funkcję |
|
(albo po prostu dojść do końca) |
Po prostu zakończyć funkcję bez wartości |
return expr; — zwracamy wartość
#include <iostream>
int square(int x) {
return x * x;
}
int main() {
std::cout << square(7) << '\n'; // 49
}
Tu wszystko proste: funkcja obiecała int, więc musi zwrócić int.
return; — wczesne wyjście z funkcji void
#include <iostream>
void print_positive(int x) {
if (x <= 0) return; // wczesne wyjście
std::cout << x << '\n'; // drukujemy tylko jeśli x > 0
}
int main() {
print_positive(-5);
print_positive(10); // 10
}
Zwróć uwagę: return; tutaj to nie „zwrócić pustkę jako wartość”, lecz po prostu „wyjść i koniec”.
3. „Wszystkie ścieżki muszą zwracać wartość”
Następny problem pojawia się u początkujących bardzo często: funkcja wydaje się zwracać int, i gdzieś wewnątrz już napisałeś return, ale kompilator dalej narzeka. Dlaczego? Bo kompilator (i ty razem z nim) próbuje zrozumieć: co się stanie na każdej możliwej ścieżce wykonania? Jeśli istnieje ścieżka, gdzie funkcja może dojść do końca bez return, to jest to problem.
Intuicyjna analogia: podpisałeś kontrakt „zawsze dostarczam paczkę”, a w jednym scenariuszu po prostu wychodzisz z magazynu. Klient (kompilator) się denerwuje — i słusznie.
Oto zły przykład:
#include <string>
int score_to_grade(int score) {
if (score >= 90) return 5;
if (score >= 70) return 4;
if (score >= 50) return 3;
// a jeśli score = 10?..
}
Prawidłowa wersja — jawnie zamknąć pozostały przypadek:
int score_to_grade(int score) {
if (score >= 90) return 5;
if (score >= 70) return 4;
if (score >= 50) return 3;
return 2; // wszystko, co poniżej 50
}
Tu nie ma żadnej magii: po prostu uczciwie mówimy, co robić w pozostałych przypadkach.
Czasami student próbuje „naprawić” to tak: dodaje else-gałęzie i pogłębia strukturę. Ale często dużo prościej zostawić liniowy ciąg i postawić finalny return na dole. To jak grzecznie zamknąć drzwi, zamiast budować kolejny korytarz.
4. Wczesne wyjście: guard clauses
Teraz dochodzimy do głównego triku wykładu: wczesne wyjście, które często nazywa się guard clause (sprawdzenie ochronne). To technika, gdzie najpierw odrzucasz „złe” przypadki, a dopiero potem piszesz główną logikę. Dla początkującego to bardzo przyjemny styl: zamienia kod w sekwencję sprawdzeń, a nie w matrioszkę z if/else.
Porównaj dwa style. Pierwszy — „las if”:
#include <iostream>
void print_discounted_price(int price, int discount) {
if (price > 0) {
if (discount >= 0 && discount <= 100) {
int result = price - price * discount / 100;
std::cout << result << '\n';
} else {
std::cout << "Bad discount\n";
}
} else {
std::cout << "Bad price\n";
}
}
Tu wszystko wydaje się poprawne, ale czyta się ciężko: żeby dotrzeć do sedna, trzeba przejść dwa poziomy warunków.
Teraz ten sam sens, ale z wczesnymi wyjściami:
#include <iostream>
void print_discounted_price(int price, int discount) {
if (price <= 0) {
std::cout << "Bad price\n";
return;
}
if (discount < 0 || discount > 100) {
std::cout << "Bad discount\n";
return;
}
int result = price - price * discount / 100;
std::cout << result << '\n';
}
Sens stał się zauważalnie jaśniejszy: najpierw „ochrona przy wejściu” (guards), potem normalny scenariusz.
Niewielka schemat-blokowy tego, co się dzieje w stylu guard:
flowchart TD
A[Start funkcji] --> B{price > 0?}
B -- nie --> X[Komunikat o bledzie] --> R[return]
B -- tak --> C{discount w 0..100?}
C -- nie --> Y[Komunikat o bledzie] --> R
C -- tak --> D[Obliczamy wynik] --> E[Druk] --> F[Koniec]
Zauważ ważny szczegół: guard clauses szczególnie dobrze działają w funkcjach, które walidują dane wejściowe. A dane wejściowe w realnym życiu niemal nigdy nie są „idealnie ładne”.
5. return w pętlach i podczas wyszukiwania
Jeszcze jedno miejsce, gdzie return upraszcza kod, to wyszukiwanie wewnątrz pętli. Początkujący często piszą: „ustawię flagę found, potem po pętli sprawdzę flagę”. To działa, ale często wydłuża kod i dodaje zbędne zmienne.
Jeśli zadanie brzmi „znajdź i zgłoś”, to naturalne rozwiązanie: jak tylko znajdziesz — od razu return.
Przykład: czy w stringu jest cyfra
#include <string>
bool contains_digit(std::string s) {
for (char c : s) {
if (c >= '0' && c <= '9') return true;
}
return false;
}
Jeśli cyfra pojawi się na początku stringu, nie musimy przebiegać całego stringu „dla porządku”. Już znamy odpowiedź.
Przykład: znaleźć notatkę po tytule
Ustalmy, że tworzymy proste konsolowe narzędzie NoteKeeper: przechowuje notatki w std::vector<std::string>, potrafi dodać, wypisać listę i usunąć po nazwie. To nie „produkt idealny”, ale świetne pole do ćwiczeń.
Funkcja wyszukująca indeks:
#include <string>
#include <vector>
int find_note_index(const std::vector<std::string>& notes, std::string title) {
for (int i = 0; i < static_cast<int>(notes.size()); ++i) {
if (notes[i] == title) return i;
}
return -1;
}
Tu return użyte jest dokładnie tak, jak trzeba: znalazłeś — zwracasz indeks; nie znalazłeś — zwracasz -1 na końcu. Tak, tutaj jest static_cast<int>, żeby nie narzekać na typy (różnicę między int a size_t omawialiśmy wcześniej i jeszcze zobaczymy w praktyce).
6. NoteKeeper: składamy aplikację z wczesnymi return
Teraz zrobimy mały krok w stronę całości: połączymy idee return i wczesnego wyjścia w jedno miniaplikacyjne ćwiczenie. Naszym celem jest, żeby kod czytał się jak scenariusz: „pokaż menu → przeczytaj komendę → wykonaj → powtórz”. I żeby każda funkcja była maksymalnie prosta: sprawdziła dane wejściowe, jeśli źle — wyszła, jeśli dobrze — wykonała pracę.
main() i kod zakończenia programu
main() to też funkcja, tylko „specjalna”: jej rezultat to kod zakończenia programu. Nieformalnie można myśleć tak: 0 oznacza „wszystko OK”, a wartość niezerowa — „coś poszło nie tak”.
W zadaniach edukacyjnych prawie zawsze zwraca się 0. Czasami zobaczysz, że return 0; w ogóle się nie pisze — w C++ dozwolone jest „dojść do końca main”, i to będzie równoważne z sukcesem.
Ja jednak polecam na początku zapisywać return 0; jawnie: dzięki temu lepiej czujesz, że main jest zwykłą funkcją, a zakończenie programu to świadomy punkt.
Przykład z różnymi wynikami:
#include <iostream>
int main() {
int x = 0;
std::cin >> x;
if (x < 0) {
std::cout << "Negative is not allowed\n";
return 1; // sygnał: błąd/niepowodzenie
}
std::cout << "OK\n";
return 0; // sukces
}
Prototypy funkcji
#include <string>
#include <vector>
void print_menu();
int read_command();
void list_notes(const std::vector<std::string>& notes);
void add_note(std::vector<std::string>& notes);
void remove_note(std::vector<std::string>& notes);
int find_note_index(const std::vector<std::string>& notes, std::string title);
Tu nie omawiamy organizacji po plikach. Na razie wszystko żyje w jednym pliku .cpp.
Menu i odczyt komendy
#include <iostream>
void print_menu() {
std::cout << "1) List notes\n";
std::cout << "2) Add note\n";
std::cout << "3) Remove note\n";
std::cout << "0) Exit\n";
}
int read_command() {
int cmd = -1;
if (!(std::cin >> cmd)) return -1; // jeśli wejście się zepsuło — wychodzimy
return cmd;
}
Zwróć uwagę na styl: jeśli odczyt się nie powiódł, nie udajemy dalej, że jest komenda. Uczciwie zwracamy „złą wartość” (-1). Tak, później uczynimy wejście bardziej niezawodnym, ale teraz ważne jest, by zobaczyć moc wczesnego wyjścia.
Wypisanie listy notatek
#include <iostream>
#include <vector>
#include <string>
void list_notes(const std::vector<std::string>& notes) {
if (notes.empty()) {
std::cout << "(empty)\n";
return;
}
for (const std::string& n : notes) std::cout << n << '\n';
}
Jeśli lista jest pusta, główny scenariusz (pętla drukująca) po prostu nie jest potrzebny. Wczesny return; to uwydatnia.
Dodawanie notatki
#include <iostream>
#include <string>
#include <vector>
void add_note(std::vector<std::string>& notes) {
std::string title;
std::cout << "Title: ";
std::cin >> title;
if (title.empty()) return; // w praktyce rzadko tu trafimy przez >>
notes.push_back(title);
}
Tutaj widzimy, że guard clauses to nawyk. Nawet jeśli konkretnie tu empty() prawie się nie zdarzy, taki sposób myślenia jest użyteczny.
Usuwanie po nazwie
#include <iostream>
#include <string>
#include <vector>
void remove_note(std::vector<std::string>& notes) {
std::string title;
std::cout << "Title to remove: ";
std::cin >> title;
int idx = find_note_index(notes, title);
if (idx == -1) {
std::cout << "Not found\n";
return;
}
notes.erase(notes.begin() + idx);
}
Tu wczesny return; usuwa zbędne else i sprawia, że główny scenariusz jest liniowy: znalazłeś — usunąłeś.
main: scenariusz łatwy do czytania
#include <iostream>
#include <vector>
#include <string>
int main() {
std::vector<std::string> notes;
while (true) {
print_menu();
int cmd = read_command();
if (cmd == 0) return 0;
if (cmd == 1) list_notes(notes);
else if (cmd == 2) add_note(notes);
else if (cmd == 3) remove_note(notes);
else std::cout << "Unknown command\n";
}
}
Zwróć uwagę: return 0; wewnątrz pętli to konstrukcja całkowicie normalna. Nie musimy robić break, potem wychodzić z pętli, a potem jeszcze gdzieś zwracać. Jeśli „wyjście z aplikacji” to rzeczywisty koniec main, można to po prostu zapisać wprost.
7. Typowe błędy przy pracy z return i wczesnym wyjściem
Błąd nr 1: zapomnieć return na jednej ze ścieżek w funkcji, która powinna zwracać wartość.
Zwykle zdarza się to, gdy napisałeś if (warunek) return ...;, a dla pozostałych przypadków nic nie przewidziałeś. Kompilator może dać ostrzeżenie, a czasem zachowanie staje się nieprzewidywalne. Prosta zasada: po łańcuchu warunków zawsze daj „przypadek domyślny” jako finalny return.
Błąd nr 2: pisać return; w funkcji zwracającej int/bool/string.
Czasem wydaje się: „chcę po prostu wcześniej wyjść”. Ale kontrakt funkcji to zwrócić wartość. Jeśli musisz wyjść wcześniej, zwróć sensowną wartość (np. false, -1, pusty string) i ustal to w projekcie funkcji.
Błąd nr 3: próbować zwrócić wartość z funkcji void.
To sytuacja lustrzana: void znaczy „brak wartości”. Jeśli łapiesz się na chęci napisania return result; — może funkcja powinna zwracać wynik i warto zmienić jej typ (albo podzielić na dwie funkcje: „oblicz” i „wypisz”).
Błąd nr 4: budować głębokie if/else, chociaż można było użyć kilku guard clause i kontynuować.
Najczęściej wygląda to jak „piramida w prawo”: kod przesuwa się o 4 spacje na każdym poziomie i po chwili czytasz nie logikę, lecz schody. Wczesne wyjścia to uczciwy sposób powiedzieć: „jeśli zły przypadek — kończymy” i nie taszczyć go przez całą funkcję.
Błąd nr 5: pisać kod po return i dziwić się, że on nie działa.
Po return funkcja się zakończyła. Wszystko poniżej staje się nieosiągalne. Czasem kompilator nawet ostrzega o unreachable code, ale nie zawsze. Dobra praktyka: jeśli postawiłeś return, wyobraź sobie tam kropkę i nie planuj „jeszcze jednej linijki”.
GO TO FULL VERSION