Historia pytania
Wprowadzenie std::span w C++20 oznaczało standaryzację długo oczekiwanego idiomu z gsl::span z Wytycznych C++ Core. Celem projektowym było zapewnienie zerokosztowej abstrakcji nad kontygentnymi sekwencjami, zastępując pary surowych wskaźników z długością w API. Komitet wyraźnie odrzucił semantykę posiadania, aby utrzymać charakterystyki wydajnościowe odpowiadające surowym wskaźnikom, w zgodzie z filozofią std::string_view. Ta decyzja wynikała z potrzeby interoperacyjności z tablicami w stylu C i kodem w dziedzictwie bez nakładania na nie kosztów alokacji. W konsekwencji std::span odziedziczył podstawowe ograniczenia nieposiadających widoków, szczególnie w zakresie zarządzania czasem życia.
Problem
Niebezpieczeństwo pojawia się, gdy std::span jest inicjowane z prvalue kontenera, takiego jak wartość zwracana przez funkcję fabryczną zwracającą std::vector<T> przez wartość. W tym scenariuszu tymczasowy wektor jest niszczony na końcu pełnego wyrażenia, a std::span zachowuje wewnętrzne wskaźniki do zdealokowanej pamięci stosu wektora. Ponieważ std::span jest rodzajem trivially copyable, który jest nieodróżnialny od pary surowych wskaźników w analizie czasu życia kompilatora, język nie przewiduje żadnej obowiązkowej diagnostyki dla tego wiszącego odniesienia. Standard C++20 określa, że std::span modeluje pożyczony zakres, ale ten koncept dotyczy tylko pętli opartych na zakresie i algorytmów, a nie fundamentalnych zasad dotyczących okresu życia podstawowego przechowywania. Tworzy to fałszywe poczucie bezpieczeństwa, ponieważ składnia przypomina bezpieczne użycie kontenera, podczas gdy ukrywa niezdefiniowane zachowanie podobne do zwracania wskaźnika do zmiennej lokalnej.
Rozwiązanie
Złagodzenie wymaga ścisłego przestrzegania zasad dotyczących przedłużania okresu życia i wykorzystywania analizy statycznej. Programiści muszą zapewnić, że kontener mający własność przetrwa dłużej niż jakiekolwiek std::span odnoszące się do niego, idealnie deklarując kontener jako nazwane zmienne przed utworzeniem widoku. Wykorzystywanie narzędzi jak Clang-Tidy z kontrolą cppcoreguidelines-pro-bounds-lifetime może wychwytywać inicjalizacje z tymczasowych obiektów. Przy projektowaniu API, funkcje powinny akceptować std::span przez wartość dla argumentów lvalue, ale dokumentować precondition wymagające, aby wywołujący zapewnił ważność przechowywania. Gdy semantyka posiadania jest konieczna, zaleca się używanie std::unique_ptr<T[]> lub samego std::vector, używając std::span tylko do przekazywania parametrów funkcji, gdzie wywołujący gwarantuje czas życia.
#include <span> #include <vector> #include <iostream> std::vector<int> generate_buffer() { return std::vector<int>(1024, 42); // Tempurowy wektor } void process(std::span<int> data) { // Niezdefiniowane zachowanie, jeśli dane są wiszące std::cout << data.front() << '\n'; } int main() { // Wiszący: tymczasowy oznaczony po pełnym wyrażeniu process(generate_buffer()); // Bezpieczny: kontener przetrwa dłużej niż span auto buffer = generate_buffer(); std::span<int> safe_view(buffer); process(safe_view); }
W silniku przetwarzania dźwięku w czasie rzeczywistym, wątek miksera otrzymywał zdekodowane dane PCM z opakowania kodeka, które zwracał std::vector<float> przez wartość. Mikser natychmiast konstruował std::span<float> do przekazania do algorytmu DSP, mając na celu uniknięcie kopiowania kilobajtów danych audio dla każdego wywołania. Podczas zapewniania jakości aplikacja ulegała sporadycznym awariom z uszkodzonymi artefaktami audio, gdy uruchamiał się garbage collector (w środowisku mostkowym C#), co zbiegło się z dostępem do bufora C++.
Zespół inżynierski rozważał trzy różne podejścia do rozwiązania niezgodności czasów życia.
Pierwsze podejście polegało na skopiowaniu danych wektora do wstępnie alokowanego bufora okrężnego będącego własnością wątku miksera. Gwarantowało to, że std::span zawsze wskazywał na ważną pamięć, eliminując całkowicie wiszące wskaźniki. Jednak operacja memcpy zajmowała około 5 mikrosekund na kanał, co przekraczało twardy czas rzeczywisty 1 milisekundy dla wywołania audio, co czyniło to rozwiązanie nieodpowiednim dla wymagań o niskim opóźnieniu.
Drugie podejście proponowało zmianę opakowania kodeka w celu zapełnienia parametru referencyjnego std::vector<float>& zamiast zwracać przez wartość. To wydłużyłoby czas życia wektora do zakresu wywołującego. Choć eliminowało to tymczasowy wektor, łamało gwarancje niemutowalności API i zmuszało wywołującego do zarządzania pojemnością wektora, co prowadziło do uciążliwej logiki puli obiektów w każdym miejscu wywołania oraz zmniejszało klarowność kodu.
Trzecie podejście wykorzystało klasę AudioBufferHandle, która przechowywała std::shared_ptr<std::vector<float>> i implicitnie konwertowała na std::span<float>. Mikser akceptował uchwyt, wyodrębniał span do natychmiastowego przetwarzania, a destruktor uchwytu utrzymywał wektor przy życiu do zakończenia DSP. To podejście zostało wybrane, ponieważ spełniało wymaganie zerokopii, zapewniając jednocześnie bezpieczeństwo czasów życia poprzez RAII, a narzut związany z licznikiem odniesień był znikomy w porównaniu z obciążeniem przetwarzania dźwięku.
Wynikiem był bezawaryjny pipeline audio, który przeszedł kontrole ASAN (AddressSanitizer) i TSAN (ThreadSanitizer) przy dużym obciążeniu, chociaż wymagało to starannej dokumentacji, aby zapobiec przechowywaniu span poza czasem życia uchwytu.
Dlaczego inicjalizacja std::span z listy inicjalizacyjnej, takiej jak std::span<int> s = {1, 2, 3};, skutkuje wiszącym wskaźnikiem, podczas gdy std::vector<int> v = {1, 2, 3}; pozostaje ważny w nieskończoność?
Lista inicjalizacyjna tworzy tymczasowy std::initializer_list<int>, który koncepcyjnie przechowuje wskaźniki do tymczasowej tablicy liczb całkowitych z automatycznym czasem życia. Gdy std::span wiąże się z tą listą inicjalizacyjną za pomocą swoich przewodników dedukcji, uchwytuje wskaźniki do tej tymczasowej tablicy. Tymczasowa tablica jest niszczona na końcu pełnego wyrażenia, pozostawiając span wiszący. W przeciwieństwie, std::vector ma alokator i kopiuje elementy do pamięci w stercie, która przetrwa, aż wektor zostanie zniszczony. Kandydaci często mylą składnię list inicjalizacyjnych z konstruktorami kontenerów, zapominając, że std::span nie dokonuje żadnej alokacji ani kopiowania, działając jedynie jako widok.
Jak zdolność constexpr std::span oddziałuje z automatycznym czasem życia, i dlaczego constexpr span wskazujący na lokalną tablicę statyczną może prowadzić do niezdefiniowanego zachowania, jeśli jest zwracany z funkcji?
std::span jest typem literowym, co pozwala na użycie constexpr, ale constexpr jedynie nakazuje, aby inicjalizacja mogła być oceniana w czasie kompilacji; nie zmienia to jednak czasu życia podstawowej tablicy. Jeśli funkcja definiuje lokalną tablicę statyczną i zwraca constexpr std::span do niej, tablica ma automatyczny czas życia i jest niszczona po zakończeniu funkcji, co natychmiast unieważnia span. Zmieszanie wynika z tego, że kandydaci zakładają, iż zmienne constexpr mają automatycznie statyczny czas życia lub że kompilator zapobiega wiszącym wskaźnikom w stałych wyrażeniach, ale std::span po prostu kapsułkuje wskaźniki, a wskaźniki do zmiennych automatycznych stają się nieaktualne bez względu na kwalifikację constexpr.
Jakie konkretne ograniczenie uniemożliwia bezpieczne zwracanie std::span z funkcji, która tworzy wewnętrznie kontener i jak to kontrastuje z std::string_view, które napotyka podobne, ale subtelnie różne ograniczenia?
Zarówno std::span, jak i std::string_view są nieposiadającymi widokami, ale std::string_view często jest używane z literałami łańcuchowymi, które mają statyczny czas życia, maskując problem wiszącego wskaźnika. Kiedy funkcja tworzy std::vector lub std::string wewnętrznie i próbuje zwrócić span/widok do niego, kontener jest niszczony na wyjściu funkcji, unieważniając widok. Kluczowa różnica polega na tym, że std::string_view może wiązać się z literałami łańcuchowymi zakończonymi zerem (const char[]), które mają statyczny czas życia, co sprawia, że takie wzorce jak std::string_view get() { return "literal"; } są bezpieczne, podczas gdy std::span nie może wiązać się z literami tablicowymi w ten sam sposób, nie tworząc tymczasowej tablicy. Kandydaci często przeoczają, że std::span jest bardziej ogólny niż std::string_view i nie ma specjalnego przypadku dla przechowywania literałów łańcuchowych, co czyni wszystkie zwroty spanów z lokalnych kontenerów bezwarunkowo niebezpiecznymi.