Funkcje pisane tam, gdzie ich używasz
Na poprzedniej stronie widać było, jak przeciążanie pozwala kilku funkcjom dzielić jedną nazwę. Czasem jednak nie chcesz nazwanej funkcji w ogóle: potrzebujesz malutkiego kawałka logiki raz, dokładnie w miejscu użycia, a nadawanie mu nazwy tylko dodałoby bałaganu. Właśnie tym jest lambda: funkcją anonimową, którą definiujesz w miejscu.
Lambda ma charakterystyczny czteroczęściowy kształt:
[capture](parameters) -> return_type { body }
Po [] od razu poznasz, że patrzysz na lambdę. Typ zwracany jest opcjonalny, bo kompilator zwykle sam go dedukuje. Oto najprostsza możliwa lambda:
greet to po prostu zmienna (jej typu nie da się zapisać, więc przechowujesz ją przez auto), którą możesz wywołać przez (). Lambdy z parametrami działają dokładnie jak zwykłe funkcje:
Przechwytywanie: sięganie do otaczającego zasięgu
Tym, co czyni lambdy czymś więcej niż funkcjami bez nazwy, jest lista przechwytywania, czyli []. Pozwala lambdzie używać zmiennych z zasięgu, w którym została zdefiniowana, a nie tylko własnych parametrów.
Przechwytywanie przez wartość przez [x]: lambda dostaje własną kopię, zamrożoną w chwili swojego utworzenia.
Zauważ, że scale(5) wypisało 50, używając wartości factor równej 10, która istniała w chwili tworzenia lambdy. Przechwytywanie przez wartość robi migawkę.
Przechwytywanie przez referencję przez [&x]: lambda odnosi się do oryginalnej zmiennej, widzi późniejsze zmiany i może ją modyfikować.
Możesz też przechwycić wszystko, czego używa lambda, przez [=] (wszystko przez wartość) albo [&] (wszystko przez referencję). To wygodne, ale jawny zapis, czyli [total] albo [&total], dokumentuje dokładnie, czego lambda dotyka, i łatwiej go zrozumieć.
Pułapka wiszącej referencji
Przechwytywanie przez referencję jest w równym stopniu potężne i niebezpieczne. Referencja jest ważna tylko tak długo, jak żyje oryginalna zmienna. Jeśli lambda przeżyje to, co przechwyciła, dostajesz wiszącą referencję i niezdefiniowane zachowanie: program może się wysypać, wypisać śmieci albo przypadkiem wyglądać na działający.
Oto klasyczny błąd: zwracanie lambdy, która przechwytuje zmienną lokalną przez referencję.
auto makeCounter() {
int count = 0;
return [&count]() { return ++count; }; // BŁĄD: count tu umiera
}
// Zwrócona lambda odwołuje się teraz do zniszczonej pamięci.
Gdy makeCounter kończy działanie, jego lokalny count jest niszczony, ale lambda wciąż trzyma do niego referencję. Wywołanie zwróconej lambdy dotyka martwej pamięci. Rozwiązaniem jest przechwycenie przez wartość, żeby lambda miała własny stan:
Praktyczna zasada: przechwytuj przez referencję tylko wtedy, gdy lambda jest używana od razu i lokalnie (jak przy algorytmach poniżej). Gdy tylko lambda jest przechowywana, zwracana albo uruchamiana później, wybieraj przechwytywanie przez wartość.
mutable i typy zwracane
Zauważasz mutable w ostatnim przykładzie? Domyślnie przechwycenie przez wartość jest wewnątrz lambdy const: możesz czytać kopię, ale nie możesz jej zmienić. Dodanie mutable pozwala lambdzie modyfikować własne kopie między wywołaniami.
mutable wpływa tylko na prywatną kopię lambdy: zewnętrzne seen pozostaje nietknięte, i o to właśnie chodzi w przechwytywaniu przez wartość.
W większości przypadków kompilator bez problemu dedukuje typ zwracany. Musisz go zapisać przez -> tylko przy niejednoznaczności, na przykład gdy lambda mogłaby zwracać różne typy w różnych gałęziach:
// Bez -> kompilator nie może wybrać między int a double
auto half = [](int n) -> double {
if (n % 2 == 0) return n / 2; // int
return n / 2.0; // double
};
Lambdy i algorytmy: prawdziwa korzyść
Lambdy dodano do C++ po to, żeby przekazywać krótkie kawałki logiki do algorytmów biblioteki standardowej. Przed lambdami trzeba było pisać osobną nazwaną funkcję albo niezgrabny obiekt funkcyjny daleko od miejsca użycia. Teraz logika mieszka dokładnie w miejscu wywołania.
Najczęstszy przykład to własna kolejność sortowania:
Tu przechwytywanie błyszczy, bo lambda może wciągnąć wartość, według której filtruje albo liczy. Ten kod liczy, ile wyników przekracza próg wybrany przez użytkownika:
Ponieważ te lambdy są używane od razu i nie przeżywają otaczającej funkcji, przechwycenie przez referencję ([&passMark]) też byłoby tu bezpieczne, ale przez wartość jest równie czytelne i nigdy nie zawiśnie.
Dalej: wskaźniki
Lambdy po cichu postawiły głębsze pytanie: gdy przechwytujesz [&x], lambda trzyma się położenia x, a to położenie pozostaje ważne tylko, dopóki x żyje. Ta idea, czyli wartość wskazująca, gdzie coś mieszka w pamięci, i to, co się dzieje, gdy wskazywana rzecz znika, jest dokładnie tematem następnej strony. Zmierzymy się wprost ze wskaźnikami: jak pobrać adres, jak za nim podążać i jak ten sam problem wiszącej referencji, który właśnie widzisz, pojawia się w całym C++.
Najczęściej zadawane pytania
Czym jest lambda w C++?
Lambda to funkcja anonimowa, którą możesz napisać w miejscu, w którym jej używasz. Składnia to [captures](parameters){ body }. Idealnie nadaje się do krótkich, jednorazowych operacji, takich jak porównanie przekazywane do std::sort, bez deklarowania osobnej nazwanej funkcji gdzie indziej.
Czym się różni przechwytywanie przez wartość od przechwytywania przez referencję w lambdzie C++?
[x] przechwytuje kopię x, zamrożoną w chwili tworzenia lambdy. [&x] przechwytuje referencję do oryginalnego x, więc lambda widzi późniejsze zmiany i może go modyfikować. Używaj [&] tylko wtedy, gdy przechwycone zmienne na pewno przeżyją lambdę, inaczej dostaniesz wiszącą referencję.
Dlaczego moja lambda w C++ nie może zmienić przechwyconej zmiennej?
Przechwycenia przez wartość są wewnątrz lambdy domyślnie const. Dodaj słowo kluczowe mutable, czyli [x]() mutable { x++; }, aby lambda mogła zmieniać własną kopię. Pamiętaj, że zmienia się tylko kopia w lambdzie, a nie oryginalna zmienna na zewnątrz.