C++programowanieStarszy Programista C++

Jaki mechanizm przechowywania leży u podstaw **std::initializer_list**, który powoduje, że jego wewnętrzna tablica przekształca się w parę wskaźników podczas konstrukcji, i dlaczego to ograniczenie czasu życia uniemożliwia bezpieczne przechowywanie listy jako członka klasy do późniejszej iteracji?

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź na pytanie

Historia: Wprowadzony w C++11, std::initializer_list został zaprojektowany w celu zlikwidowania różnicy między inicjalizacją agregatów w stylu C a nowoczesnymi konstruktorami kontenerów C++. Jest implementowany jako lekki agregat zawierający dwa wskaźniki (lub wskaźnik i rozmiar), odnoszące się do tablicy const generowanej przez kompilator. Taki projekt priorytetowo traktuje zerowe koszty przekazywania list literalnych do funkcji takich jak konstruktor std::vector.

Problem: Podstawowa tablica jest obiektem tymczasowym, którego czas życia jest związany z pełnym wyrażeniem, w którym tworzony jest std::initializer_list. Kiedy klasa przechowuje sam std::initializer_list, zamiast kopiować jego zawartość, członek zatrzymuje jedynie wskaźniki do zwolnionej pamięci stosu. Każdy kolejny dostęp powoduje niezdefiniowane zachowanie, manifestujące się jako śmieciowe dane lub awarie, które trudno odtworzyć.

Rozwiązanie: Nigdy nie przechowuj std::initializer_list jako członka klasy; zamiast tego, chętnie skopiuj elementy do posiadającego kontenera, takiego jak std::vector lub std::array. Jeśli zerowe kopiowanie jest konieczne, użyj std::span (C++20) z zarządzanym zewnętrznie przechowywaniem lub zaakceptuj zakres za pomocą iteratorów. Zapewnia to, że dane przetrwają wywołanie konstruktora i pozostaną ważne przez czas życia obiektu.

class Bad { std::initializer_list<int> list_; public: Bad(std::initializer_list<int> list) : list_(list) {} // NIEBEZPIECZEŃSTWO int sum() const { int s = 0; for (int i : list_) s += i; // UB: wskaźniki wiszące return s; } }; class Good { std::vector<int> vec_; public: Good(std::initializer_list<int> list) : vec_(list) {} // Bezpieczne: kopiuje dane int sum() const { return std::accumulate(vec_.begin(), vec_.end(), 0); } };

Sytuacja z życia

Napotkaliśmy to w loaderze konfiguracji wysokiej częstotliwości, gdzie klasa MarketConfig przyjmowała domyślne poziomy cen w postaci listy inicjalizatorów w swoim konstruktorze, aby wspierać składnię typu MarketConfig cfg{{1.0, 2.0, 3.0}}. Młodszy programista przechowywał std::initializer_list<double> bezpośrednio jako członka, aby „unikać alokacji w stosie”, mając zamiar iterować nad poziomami później podczas przetwarzania pakietów.

Jednym z proponowanych rozwiązań było przechowywanie const std::vector<double>& przekazywanego przez wywołującego. To wyeliminowałoby kopie, jeśli wywołujący utrzymywałby czas życia wektora, ale naruszałoby enkapsulację i zmuszało wywołujących do zarządzania trwałym przechowywaniem dla tymczasowych list. Inną opcją było użycie std::array<double, N> jako parametru szablonowego, ale wymagało to znajomości liczby poziomów w czasie kompilacji, co było niemożliwe, ponieważ konfiguracje były ładowane dynamicznie z nakładek JSON.

Wybranym podejściem było skopiowanie listy inicjalizatorów do członka std::vector<double> od razu po konstrukcji. Choć wiązało się to z jedną alokacją i kopiowaniem danych poziomów, zapewniło bezpieczeństwo i niezmienność stanu konfiguracji. Po wprowadzeniu zmiany, sporadyczne awarie w symulacjach produkcyjnych zniknęły, a Valgrind przestał zgłaszać „użycie niezainicjowanej wartości o rozmiarze 8” podczas agregacji poziomów.

Co często umyka kandydatom

Dlaczego powiązanie std::initializer_list z referencją const nie zapobiega powstawaniu wiszącej tablicy, gdy jest przechowywane w członie?

Standard określa, że tablica zapasowa std::initializer_list jest tymczasowa, której czas życia jest przedłużany tylko przez obiekt initializer_list wiązany do referencji w bieżącym zakresie. Gdy przekazujesz std::initializer_list przez wartość do konstruktora, tymczasowa tablica żyje aż do zwrócenia konstruktora; skopiowanie listy do członka jedynie duplikuje parę wskaźników. W rezultacie, członek wskazuje na odzyskaną przestrzeń stosu, gdy tylko kończy się wyrażenie konstrukcji, niezależnie od tego, jak oryginalny argument był powiązany.

Jak zasada "konstruktor listy inicjalizatorów wygrywa" wchodzi w interakcję z zestawem przeciążeń konstruktora std::vector, i dlaczego std::vector<int>(5, 10) różni się od std::vector<int>{5, 10}?

Podczas rozstrzygania przeciążeń dla bezpośredniej inicjalizacji listą (klamry), C++ priorytetowo traktuje konstruktory przyjmujące std::initializer_list przed innymi konstruktorami, jeśli lista argumentów może być implicitnie przekształcona do elementu listy. Dla std::vector<int>, {5, 10} wybiera konstruktor initializer_list<int>, tworząc wektor dwóch elementów (5 i 10). W przeciwieństwie do tego, nawiasy (5, 10) wybierają konstruktor size_t, const int&, tworząc wektor pięciu elementów zainicjowanych na 10. Kandydaci często nie dostrzegają, że ten priorytet dotyczy nawet sytuacji, gdy nie-listowy konstruktor byłby lepszym dopasowaniem zgodnie z normalnymi zasadami rozstrzygania przeciążeń.

Czy funkcje constexpr mogą bezpiecznie zwracać std::initializer_list, a jeśli tak, to pod jakimi ograniczeniami długości przechowywania?

Chociaż funkcje constexpr mogą zwracać std::initializer_list, podstawowa tablica nadal posiada automatyczny czas przechowywania, jeżeli funkcja jest wywoływana w czasie wykonywania. Jeśli funkcja jest wywoływana w kontekście stałej ekspresji, tablica jest zwykle przechowywana w statycznej pamięci tylko do odczytu, co czyni ją bezpieczną. Jednak zwracanie std::initializer_list z funkcji constexpr wywołanej z argumentami w czasie wykonywania skutkuje wiszącymi wskaźnikami, gdy tylko kończy się zakres funkcji, dokładnie tak jak w przypadku funkcji nie-constexpr. Kandydaci często mylą constexpr z „stałym przechowywaniem” i błędnie zakładają, że zwrócona lista jest zawsze ważna w nieskończoność.