We wszystkich funkcji, które widzieliśmy do tej pory, liczba parametrów, które funkcja będzie przyjmować, musi być znana z góry (nawet jeśli mają one wartości domyślne). Istnieją jednak pewne przypadki, w których przydatna może być możliwość przekazania do funkcji zmiennej liczby parametrów. C++ udostępnia specjalny specyfikator zwany elipsą (znany również jako „…”), który pozwala nam to dokładnie zrobić.
Ponieważ elipsa jest rzadko używana, jest potencjalnie niebezpieczna i zalecamy unikanie jej stosowania, tę sekcję można uznać za lekturę opcjonalną.
Funkcje korzystające z wielokropka mają postać:
return_type function_name(argument_list, ...)
Klasa lista_argumentów to jeden lub więcej normalnych parametrów funkcji. Należy pamiętać, że funkcje korzystające z wielokropka muszą mieć co najmniej jeden parametr inny niż wielokropek. Wszelkie argumenty przekazywane do funkcji muszą najpierw odpowiadać parametrom listy_argumentów.
Wielokropek (reprezentowany jako trzy kropki z rzędu) musi zawsze być ostatnim parametrem funkcji. Wielokropek przechwytuje wszelkie dodatkowe argumenty (jeśli takie istnieją). Chociaż nie jest to całkiem dokładne, koncepcyjnie przydatne jest myślenie o elipsie jako o tablicy zawierającej wszelkie dodatkowe parametry poza tymi z listy argumentów.
Przykład elipsy
Najlepszym sposobem poznania wielokropka jest przykład. Napiszmy więc prosty program wykorzystujący elipsę. Załóżmy, że chcemy napisać funkcję obliczającą średnią zbioru liczb całkowitych. Zrobilibyśmy to w ten sposób:
#include <iostream>
#include <cstdarg> // needed to use ellipsis
// The ellipsis must be the last parameter
// count is how many additional arguments we're passing
double findAverage(int count, ...)
{
int sum{ 0 };
// We access the ellipsis through a va_list, so let's declare one
std::va_list list;
// We initialize the va_list using va_start. The first argument is
// the list to initialize. The second argument is the last non-ellipsis
// parameter.
va_start(list, count);
// Loop through all the ellipsis values
for (int arg{ 0 }; arg < count; ++arg)
{
// We use va_arg to get values out of our ellipsis
// The first argument is the va_list we're using
// The second argument is the type of the value
sum += va_arg(list, int);
}
// Cleanup the va_list when we're done.
va_end(list);
return static_cast<double>(sum) / count;
}
int main()
{
std::cout << findAverage(5, 1, 2, 3, 4, 5) << '\n';
std::cout << findAverage(6, 1, 2, 3, 4, 5, 6) << '\n';
return 0;
}Ten kod wypisuje:
3 3.5
Jak widzisz, ta funkcja przyjmuje zmienną liczbę parametrów! Przyjrzyjmy się teraz komponentom tworzącym ten przykład.
Najpierw musimy dołączyć nagłówek cstdarg. Ten nagłówek definiuje va_list, va_arg, va_start i va_end, które są makrami, których musimy użyć, aby uzyskać dostęp do parametrów będących częścią wielokropka.
Następnie deklarujemy naszą funkcję, która używa wielokropka. Pamiętaj, że lista argumentów musi zawierać jeden lub więcej stałych parametrów. W tym przypadku przekazujemy pojedynczą liczbę całkowitą, która mówi nam, ile liczb należy uśrednić. Wielokropek zawsze pojawia się na końcu.
Pamiętaj, że parametr wielokropka nie ma nazwy! Zamiast tego uzyskujemy dostęp do wartości w wielokropku poprzez specjalny typ znany jako va_list. Konceptualnie przydatne jest myślenie o va_list jako o wskaźniku wskazującym tablicę wielokropków. Najpierw deklarujemy listę va_list, którą dla uproszczenia nazwaliśmy „listą”.
Następną rzeczą, którą musimy zrobić, to sprawić, aby lista wskazywała nasze parametry wielokropka. Robimy to wywołując funkcję va_start(). va_start() przyjmuje dwa parametry: samą listę va_list i nazwę ostatniego parametru niebędącego wielokropkiem w funkcji. Po wywołaniu va_start() va_list wskazuje pierwszy parametr w wielokropku.
Aby uzyskać wartość parametru, na który aktualnie wskazuje va_list, używamy va_arg(). va_arg() również przyjmuje dwa parametry: samą listę va_list i typ parametru, do którego próbujemy uzyskać dostęp. Zauważ, że va_arg() przenosi także va_list do następnego parametru w wielokropku!
Na koniec, aby posprzątać, kiedy skończymy, wywołujemy va_end() z va_list jako parametrem.
Pamiętaj, że va_start() można wywołać ponownie za każdym razem, gdy chcemy zresetować va_list tak, aby wskazywała pierwszy parametr w wielokropku ponownie.
Dlaczego wielokropek jest niebezpieczny: Sprawdzanie typu zostało zawieszone
Elipsa oferuje programiście dużą elastyczność w implementowaniu funkcji, które mogą przyjmować zmienną liczbę parametrów. Jednak ta elastyczność ma pewne wady.
W przypadku zwykłych parametrów funkcji kompilator używa sprawdzania typów, aby upewnić się, że typy argumentów funkcji odpowiadają typom parametrów funkcji (lub mogą zostać niejawnie przekonwertowane, aby były zgodne). Pomaga to mieć pewność, że funkcja nie przekaże liczby całkowitej, gdy spodziewała się ciągu znaków, i odwrotnie. Należy jednak pamiętać, że parametry wielokropka nie mają deklaracji typu. Podczas korzystania z wielokropka kompilator całkowicie zawiesza sprawdzanie typu parametrów wielokropka. Oznacza to, że do wielokropka można wysyłać argumenty dowolnego typu! Jednak wadą jest to, że kompilator nie będzie już w stanie ostrzec, jeśli wywołasz funkcję z argumentami wielokropka, które nie mają sensu. Podczas korzystania z wielokropka całkowicie zależy od osoby wywołującej, czy funkcja zostanie wywołana z argumentami wielokropka, które funkcja może obsłużyć. Oczywiście pozostawia to sporo miejsca na błędy (zwłaszcza jeśli osoba wywołująca nie była tym, który napisał tę funkcję).
Spójrzmy na przykład dość subtelnego błędu:
std::cout << findAverage(6, 1.0, 2, 3, 4, 5, 6) << '\n';Chociaż na pierwszy rzut oka może to wyglądać nieszkodliwie, należy pamiętać, że drugi argument (pierwszy argument wielokropka) jest liczbą podwójną zamiast liczbą całkowitą. Kompiluje się to dobrze i daje nieco zaskakujący wynik:
1.78782e+008
co jest NAPRAWDĘ dużą liczbą. Jak do tego doszło?
Jak nauczyłeś się na poprzednich lekcjach, komputer przechowuje wszystkie dane w postaci sekwencji bitów. Typ zmiennej mówi komputerowi, jak przetłumaczyć tę sekwencję bitów na znaczącą wartość. Jednak właśnie się dowiedziałeś, że wielokropek odrzuca typ zmiennej! W związku z tym jedynym sposobem uzyskania znaczącej wartości z wielokropka jest ręczne przekazanie va_arg() oczekiwanego typu następnego parametru. To właśnie robi drugi parametr va_arg(). Jeśli rzeczywisty typ parametru nie odpowiada oczekiwanemu typowi parametru, zwykle wydarzy się coś złego.
W powyższym programie findAverage powiedzieliśmy va_arg(), że oczekuje się, że wszystkie nasze zmienne będą mieć typ int. W rezultacie każde wywołanie va_arg() zwróci następną sekwencję bitów przetłumaczoną jako liczbę całkowitą.
W tym przypadku problem polega na tym, że wartość double, którą przekazaliśmy jako pierwszy argument wielokropka, wynosi 8 bajtów, podczas gdy va_arg(list, int) zwróci tylko 4 bajty danych przy każdym wywołaniu. W rezultacie pierwsze wywołanie va_arg odczyta tylko pierwsze 4 bajty double (tworząc wynik śmieciowy), a drugie wywołanie va_arg odczyta drugie 4 bajty double (tworząc kolejny śmieciowy wynik). Zatem nasz ogólny wynik to bzdura.
Ponieważ sprawdzanie typów jest zawieszone, kompilator nawet nie będzie narzekał, jeśli zrobimy coś zupełnie absurdalnego, na przykład:
int value{ 7 };
std::cout << findAverage(6, 1.0, 2, "Hello, world!", 'G', &value, &findAverage) << '\n';Wierz lub nie, ale to właściwie kompiluje się dobrze i daje następujący wynik na komputerze autora:
1.79766e+008
Ten wynik jest uosobieniem frazy „Śmieci w środku, śmieci na zewnątrz”, które jest popularnym wyrażeniem w informatyce „używanym głównie w celu zwrócenia uwagi na fakt, że komputery, w przeciwieństwie do ludzi, bez wątpienia przetworzą najbardziej bezsensowne dane wejściowe i wygenerują bezsensowny wynik” (Wikipedia).
Podsumowując, sprawdzanie typów parametrów jest zawieszone i musimy ufać wywołującemu, że przekaże odpowiednie parametry. Jeśli tego nie zrobi, kompilator nie będzie narzekał - nasz program po prostu wygeneruje śmieci (a może awaria).
Dlaczego elipsa jest niebezpieczna: elipsa nie wie, ile parametrów zostało przekazanych
Elipsa nie tylko odrzuca typ parametrów, ale także wyrzuca liczbę parametrów w wielokropku opracować własne rozwiązanie do śledzenia liczby parametrów przekazywanych do wielokropka. Zwykle robi się to na jeden z trzech sposobów.
Metoda 1: Przekazanie parametru długości
Metoda nr 1 polega na tym, że jeden ze stałych parametrów reprezentuje liczbę przekazanych parametrów opcjonalnych. To jest rozwiązanie, którego używamy w powyższym przykładzie findAverage().
Jednak nawet tutaj napotykamy problemy. Rozważmy na przykład następujące wywołanie:
std::cout << findAverage(6, 1, 2, 3, 4, 5) << '\n';Na komputerze autora w momencie pisania tego tekstu dało to wynik:
699773
Co się stało? Powiedzieliśmy funkcji findAverage(), że udostępnimy 6 dodatkowych wartości, ale podaliśmy tylko 5. W rezultacie pierwsze pięć wartości zwracanych przez funkcję va_arg() to te, które przekazaliśmy. Szósta wartość, którą zwraca, jest wartością śmieci znajdującą się gdzieś na stosie. W rezultacie otrzymaliśmy bzdurną odpowiedź.
Bardziej podstępny przypadek:
std::cout << findAverage(6, 1, 2, 3, 4, 5, 6, 7) << '\n';Daje to odpowiedź 3,5, która na pierwszy rzut oka może wyglądać na poprawną, ale pomija ostatnią liczbę w średniej, ponieważ tylko powiedzieliśmy, że podamy 6 dodatkowych wartości (a następnie faktycznie podaliśmy 7). Tego rodzaju błędy mogą być bardzo trudne do wyłapania.
Metoda 2: Użyj wartości wskaźnikowej
Metoda nr 2 polega na użyciu wartości wskaźnikowej. A wartownik to specjalna wartość używana do zakończenia pętli w przypadku jej napotkania. Na przykład w przypadku łańcuchów terminator zerowy jest używany jako wartość sygnalizująca koniec łańcucha. W przypadku wielokropka wartość wartownicza jest zwykle przekazywana jako ostatni parametr. Oto przykład funkcji findAverage() przepisanej tak, aby używała wartości wskaźnikowej -1:
#include <iostream>
#include <cstdarg> // needed to use ellipsis
// The ellipsis must be the last parameter
double findAverage(int first, ...)
{
// We have to deal with the first number specially
int sum{ first };
// We access the ellipsis through a va_list, so let's declare one
std::va_list list;
// We initialize the va_list using va_start. The first argument is
// the list to initialize. The second argument is the last non-ellipsis
// parameter.
va_start(list, first);
int count{ 1 };
// Loop indefinitely
while (true)
{
// We use va_arg to get values out of our ellipsis
// The first argument is the va_list we're using
// The second argument is the type of the value
int arg{ va_arg(list, int) };
// If this parameter is our sentinel value, stop looping
if (arg == -1)
break;
sum += arg;
++count;
}
// Cleanup the va_list when we're done.
va_end(list);
return static_cast<double>(sum) / count;
}
int main()
{
std::cout << findAverage(1, 2, 3, 4, 5, -1) << '\n';
std::cout << findAverage(1, 2, 3, 4, 5, 6, -1) << '\n';
return 0;
}Zauważ, że nie musimy już przekazywać jawnej długości jako pierwszego parametru. Zamiast tego jako ostatni parametr przekazujemy wartość wskaźnikową.
Istnieje jednak kilka wyzwań. Po pierwsze, C++ wymaga przekazania co najmniej jednego stałego parametru. W poprzednim przykładzie była to nasza zmienna licznikowa. W tym przykładzie pierwsza wartość jest w rzeczywistości częścią liczb, które mają zostać uśrednione. Zamiast więc traktować pierwszą wartość, która ma być uśredniona, jako część parametrów wielokropka, jawnie deklarujemy ją jako parametr normalny. Następnie potrzebujemy specjalnej obsługi tej funkcji w funkcji (w tym przypadku na początek ustawiamy sumę na pierwszą zamiast na 0).
Po drugie, wymaga to od użytkownika przekazania wartości wartowniczej jako ostatniej wartości. Jeśli użytkownik zapomni przekazać wartość wartowniczą (lub przekaże niewłaściwą wartość), funkcja będzie wykonywać pętlę w sposób ciągły, aż napotka śmieci pasujące do wartości wartowniczej (lub ulegnie awarii).
Na koniec zauważ, że jako naszą wartość wartowniczą wybraliśmy -1. Nie ma w tym nic złego, gdybyśmy chcieli znaleźć tylko średnią liczb dodatnich, ale co by było, gdybyśmy chcieli uwzględnić liczby ujemne? Wartości Sentinel działają dobrze tylko wtedy, gdy istnieje wartość, która wykracza poza prawidłowy zestaw wartości dla problemu, który próbujesz rozwiązać.
Metoda 3: Użyj ciągu dekodera
Metoda nr 3 polega na przekazaniu „ciągu dekodera”, który mówi programowi, jak interpretować parametry.
#include <iostream>
#include <string_view>
#include <cstdarg> // needed to use ellipsis
// The ellipsis must be the last parameter
double findAverage(std::string_view decoder, ...)
{
double sum{ 0 };
// We access the ellipsis through a va_list, so let's declare one
std::va_list list;
// We initialize the va_list using va_start. The first argument is
// the list to initialize. The second argument is the last non-ellipsis
// parameter.
va_start(list, decoder);
for (auto codetype: decoder)
{
switch (codetype)
{
case 'i':
sum += va_arg(list, int);
break;
case 'd':
sum += va_arg(list, double);
break;
}
}
// Cleanup the va_list when we're done.
va_end(list);
return sum / std::size(decoder);
}
int main()
{
std::cout << findAverage("iiiii", 1, 2, 3, 4, 5) << '\n';
std::cout << findAverage("iiiiii", 1, 2, 3, 4, 5, 6) << '\n';
std::cout << findAverage("iiddi", 1, 2, 3.5, 4.5, 5) << '\n';
return 0;
}W tym przykładzie przekazujemy ciąg znaków, który koduje zarówno liczbę opcjonalnych zmiennych, jak i ich typy. Fajne jest to, że pozwala nam to radzić sobie z parametrami różnych typów. Jednak ta metoda ma również wady: ciąg dekodera może być nieco tajemniczy i jeśli liczba lub typy parametrów opcjonalnych nie odpowiadają dokładnie ciągowi dekodera, mogą wydarzyć się złe rzeczy.
Dla tych z Was, którzy zaczynają od C, właśnie to robi printf!
Zalecenia dotyczące bezpieczniejszego używania wielokropka
Po pierwsze, jeśli to możliwe, nie używaj w ogóle elipsa! Często dostępne są inne rozsądne rozwiązania, nawet jeśli wymagają nieco więcej pracy. Na przykład w naszym programie findAverage() mogliśmy zamiast tego przekazać tablicę liczb całkowitych o dynamicznie zmienianym rozmiarze. Zapewniłoby to zarówno silne sprawdzanie typów (aby upewnić się, że wywołujący nie próbuje zrobić czegoś bezsensownego), jak i zachowanie możliwości przekazywania zmiennej liczby całkowitej do uśrednienia.
Po drugie, jeśli używasz wielokropka, lepiej, jeśli wszystkie wartości przekazywane do parametru elipsy są tego samego typu (np. all int lub all double, a nie mieszanka każdego z nich). Mieszanie różnych typów znacznie zwiększa ryzyko, że osoba wywołująca niechcący przekaże dane niewłaściwego typu, a funkcja va_arg() zwróci wynik bezużyteczny.
Po trzecie, użycie parametru count lub parametru ciągu dekodera jest ogólnie bezpieczniejsze niż użycie wartości wskaźnikowej. Zmusza to użytkownika do wybrania odpowiedniej wartości parametru zliczanie/dekoder, co gwarantuje, że pętla wielokropka zakończy się po rozsądnej liczbie iteracji, nawet jeśli wygeneruje wartość śmieciową.
Dla zaawansowanych czytelników
Aby ulepszyć funkcjonalność podobną do elips, wprowadzono do C++ 11 parameter packs i variadic templates, który oferuje funkcjonalność podobną do elips, ale z silnym sprawdzaniem typów. Jednak znaczące wyzwania związane z użytecznością utrudniały przyjęcie tej funkcji.
W języku C++ 17 dodano wyrażenia składane , co znacznie poprawia użyteczność pakietów parametrów do tego stopnia, że są one teraz realną opcją.
Mamy nadzieję wprowadzić lekcje na ten temat w przyszłej aktualizacji witryny.

