Niespójne i mylące konstruktory w std::string
• tech • 753 słowa • 4 minuty czytania
Kiedyś często problemem były niejawne konwersje między typami w C++, które szczególnie uwidaczniały się przy konstruktorach konwersji… Kto by pomyślał, że dziś nadał trywialne błędy można popełnić przez jakieś zaszłości historyczne, usilne zachowanie kompatybilności i brak spójności w definiowaniu konstruktorów, nawet tych w Standardzie ;)
Problemy te co jakiś czas są na nowo “odkrywane”, ostatnio powróciły w nieco prześmiewczym kodzie na twitterze:
const std::string str = "Modern C++";
std::string s1 { "Modern C++", 3 };
std::string s2 { str, 3 };
std::cout << "S1: " << s1 << "\n";
std::cout << "S2: " << s2 << "\n";
Intuicyjnie mogłoby się wydawać, że obiekty s1
i s2
będą zawierać taką samą zawartość. Niestety to tylko złudzenie.
Wyjście z takiego programu wygląda następująco:
S1: Mod
S2: ern C++
Kod oczywiście wykonuje się poprawnie zgodnie ze standardem. Tutaj problemem jest mała logiczna niespójność w definicji przeznaczenia argumentów w konstruktorach klasy std::string
. Z jakichś nieznanych powodów, dla użytych w kodzie wyżej konstruktorów, zależnie od typu, określono inne przeznaczenie kolejno przekazywanych argumentów.
Przy tworzeniu obiektu s1
z surowego łańcucha znakowego języka C - literału const char*
, używany jest konstruktor:
basic_string( const CharT* s,
size_type count,
const Allocator& alloc = Allocator() );
który konstruuje obiekt z pierwszych count
znaków ciągu wskazywanego przez s
, czyli [0, count]
.
Natomiast w przypadku s2
, łańcuch ten tworzony jest z innego obiektu string
przez:
basic_string( const basic_string& other,
size_type pos,
const Allocator& alloc = Allocator() );
gdzie konstruktor inicjalizuje obiekt wszystkimi znakami łańcucha other
począwszy od pozycji pos
, czyli [pos, ..]
.
Mówiąc w skrócie, jeden pobiera 3 pierwsze znaki z bufora, a drugi wszystkie od 3 pozycji.
Mimo, że kod działa poprawnie zgodnie z dokumentacją to można by intuicyjnie założyć, że dla wygody oba obiekty powinny być tworzone w taki sam sposób, a konstruktory niezależnie od typu podążać tą samą składnią i logiką w przekazywanych parametrach. Taki wspólny interfejs mógłby wyglądać jakoś tak:
std::string(const char* s, size_type pos, size_type count = npos);
std::string(const std::string& s, size_type pos, size_type count = npos);
std::string(const std::string_view& s, size_type pos, size_type count = npos);
Takie funkcje łatwo dałoby się uogólnić do postaci szablonowej:
template<typename T>
std::string(const T&, size_type pos, size_type count = npos);
która o dziwo w podobnej postaci wylądowała w C++17 (w locie z konwersją przez std::string_view
).
Ciężko jednoznacznie stwierdzić, dlaczego w bibliotece standardowej języka C++ występują takie niespójności przy tworzeniu obiektów std::string
i jakie intencje przyświecały dodaniu konstruktorów o odmiennym zachowaniu.
Jednym z podwód mogła być optymalizacja/wydajność. W końcu literały napisowe mogą być niejawnie “konwertowane” na stringi, a jako “gołe” wskaźniki łatwo też w nich manipulować pozycją początkową ciągu. Z tego też powodu można było pominąć taką specjalizowaną metodę tworzenia stringa z podaniem pozycji początkowej.
Inaczej wygląda to w przypadku std::string
-a, ale tutaj znów w drugą stronę - można już było zrezygnować z definicji dedykowanego konstruktora skoro każdy może sobie użyć metodę substr
:
std::string str = other.substr(pos, count);
Wtedy nie dziwiłoby zbytnio dodanie w C++17 w klasie std::string_view
podobnej inicjalizacji z literałów znakowych:
constexpr basic_string_view(const CharT* s);
constexpr basic_string_view(const CharT* s, size_type count);
Może dla zachowania spójności z niespójnymi konstruktorami std::string
? :)
Dywagacji i przypuszczeń można snuć wiele. Nie zmienia to faktu, że wielu deweloperów i komentatorów ze świata C++ uważa klasę std::string
za przekombinowaną - polecam lekturę artykułu GotW #84: Monoliths “Unstrung”. Część metod “operacyjnych” można byłoby wydzielić do zwykłych funkcji pozostawiając tylko niezbędny interfejs klasy łańcuchowej. Takie zewnętrzne funkcje, operujące także na ciągach języka C, byłyby fajnymi utilsami w std. Ale chyba nie taki kierunek obrano w komitecie, bo w kolejnych wydaniach, w tym w C++20, dorzucono kilka nowych metod do stringa.
Trzeba mieć na uwadze to, że stringi powstały na długo przed STL-em. A w czasie prawie 10 letniej standaryzacji pierwszej wersji języka (‘98) w dużej mierze nie projektowano od zera nowej biblioteki standardowej, a jedynie zintegrowano wiele istniejących już klas1 i dopasowano do modelu STL-a. W wielu przypadkach zachowując wsteczną zgodność.
O zachowaniu wstecznej kompatybilności na poziomie kodu i ABI w C++ można też byłoby długo mówić…
BTW. Od dawna na mojej liście gościł temat niespójności i dziwnych konstrukcji biblioteki standardowej. Powoli z każdym postem i artykułem w sieci, na jakie mimowolnie natrafiałem w codziennym surfowaniu, odświeżałem ten temat. Choć wspomniany w artykule szum na twitterze przypomniał mi o tym ciekawym znalezisku…
To jednak dopiero wypuszczenie przez Jasona Turnera ostatniego epizodu C++ Weekly poruszającego dokładnie ten problem zmusiło mnie do zakończenia edycji i publikacji tego mojego draftu ;)
Przypisy
-
Wspominał o tym Nicolai M. Josuttis w swojej książce “C++. Biblioteka standardowa. Podręcznik programisty” (oryginalne wydanie: The C++ Standard Library: A Tutorial and Referencee). ↩︎
Komentarze (0)