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

  1. 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)

Dodaj komentarz

/dozwolony markdown/

/nie zostanie opublikowany/