Łańcuchy znakowe Prawie w każdym języku programowania na początku należy opanować sposób wyświetlania informacji na ekranie (standardowe urządzenie wyjścia), wprowadzania ich z klawiatury (standardowe urządzenie wejścia), a co za tym idzie, opanować operacje na łańcuchach znakowych. Łańcuch znakowy to ciąg znaków ujęty w zwykły cudzysłów, np. "to jest łańcuch znakowy". Nawet pojedyncza literka, np. "a" to też łańcuch znakowy, w odróżnieniu od znaku (zmiennej typu char), który należy zapisać w pojedynczym cudzysłowie, np. 'a'. Łańcuch znakowy może być przedstawiony w postaci literału napisowego: const char* ptr = "to jest test"; Warto zwrócić uwagę, że każdy literał jest stałą, zatem wskaźnik pokazujący na niego musi być wskaźnikiem stałym (takim, który gwarantuje, że za jego pomocą ten literał napisowy nie będzie modyfikowany). Okazuje się, że kompilatory (gcc) w wersjach przed 4.2.x nie zwracały uwagi na (niepoprawną) składnię w postaci: char* ptr = "to jest test"; po czym w kolejnych wyższych wersjach, pojawia się słuszne ostrzeżenie: deprecated conversion from string constant to `char*`. Napisano wiele kodu z taką składnią i teraz, kompilując nowym kompilatorem, zasypywani jesteśmy ostrzeżeniami. Jest to dobry przykład na to, że kompilatory nie zawsze skrupulatnie przestrzegały (albo informowały) o tym, co od dawna zapisane jest w standardzie języka. Prymitywny łańcuch znakowy (nazwijmy go C-string) nie jest żadnym nowym typem, nie jest też żadnym obiektem. Aby na nim pracować, trzeba przechowywać go w tablicy znakowej, dbając ciągle, aby była wystarczająco duża (i nie zapominając, że musi być większa o zerowy bajt, kończący łańcuch). Operacje na łańcuchach w języku C realizowane były za pomocą zestawu funkcji o nazwach strxxx, operujących na argumentach char * destination, const char * source. Było to wyjątkowo niewygodne i "niskopoziomowe" programowanie. W języku C++ ich deklaracje znajdują się w pliku nagłówkowym <cstring>. W bibliotece języka C++ pojawia się kompleksowe rozwiązanie operacji na łańcuchach znakowych, jest nim typ abstrakcyjny o nazwie std::string (należy włączyć plik nagłówkowy <string>). Tak naprawdę, string to synonim szablonu klasy specjalizowanego na typie char, a konkretnie: typedef basic_string<char> string; Nas nie będą interesować takie szczegóły, tylko pragmatyczne korzyści, jakie można odnieść z działań na obiektach typu string. Klasa string jest przeładowana mnóstwem użytecznych funkcji. Jest ich aż za dużo, wiele udostępnionej funkcjonalności zrealizować można zewnętrznymi funkcjami z algorytmów uogólnionych i w tym sensie klasa string jest krytykowana jako niepotrzebnie, nadmiernie rozdęta. Wystarczy spojrzeć na bogaty spis funkcji i operatorów, wiele z funkcji występuje w kilku (przeładowanych) wersjach. W żadnym wypadku nie będziemy omawiać tutaj wszystkich po kolei, bo po to jest dokumentacja oraz podręczniki (np. Symfonia C++ Standard J. Grębosza, cały rozdział 11 - prawie 100 stron na ten temat). Poniższe krótkie przykłady pozwolą raczej rozeznać się w możliwościach klasy bibliotecznej std::string. Ponieważ bardzo często będziemy coś wypisywać na ekran, zdefiniujmy sobie definicję preprocesora, która skróci i ułatwi wielokrotne, nudne pisanie tego samego kodu.
#define P(str, val) cout << str " = " << val << endl Wszędzie tam, gdzie preprocesor napotka w kodzie P( X, Y ) wykona ślepą zamianę na cout << X " = " << Y << endl gdzie X, Y jakieś argumenty typu takiego, który można posłać do obiektu cout, czyli na ekran komputera. Celowo tej definicji nie zakończyliśmy średnikiem, tak aby w samym kodzie zapis wyglądał tak: P(X, Y); Przyjrzyjmy się kilku sposobom na tworzenie obiektów klasy string. Przede wszystkim zauważymy, że interesuje nas pojemność łańcucha znakowego. Wewnątrz obiektu string jest bufor, odpowiednio się dla nas powiększający. #include <iostream> #include <string> using namespace std; int main () { const char *linijka = "ta linijka to stary lancuch znakowy"; // najpierw pusty obiekt string s1; // poniżej przypisanie, nie inicjalizacja s1 = "abrakadabra"; P("s1", s1); // inicjalizacja istniejącym obiektem // tu działa konstruktor kopiujący string s2 (s1); P("s2", s2); // inicjalizacja C stringiem string s3 (linijka); P("s3", s3); // pierwszy argument C string // drugi długo ść łańcucha jakim chcemy inicjalizować string s4 (linijka,10); P("s4", s4); // pierwszy obiekt s3 (typu string) // drugi pozycja początkowa // trzeci liczba argumentów string s5 (s3,6,4); P("s5", s5); // pierwszy liczba powtórze ń znaków // drugi znak (w pojedynczym cudzysłowie!) string s6 (15,'*'); P("s6", s6); // pierwszy iterator początku // drugi iterator końca odj ąć 5 pozycji string s7 (s3.begin(),s3.end() 5);
P("s7", s7); /*********************** wszystkie poniższe przykłady piszmy po prostu jako kolejne linie tego kodu ************************/ return 0; Wypełnianie stringów ze strumienia standardowego lub plikowego W poniższym przykładzie następuje czytanie z klawiatury, aż do wprowadzenia (oddzielonego białymi znakami lub znakiem enter) literki q. Zauważmy, że wprowadzając kilka słów przedzielonych spacją (i zatwierdzonych enterem) zobaczymy kilkukrotny obieg w pętli, bo formatowane czytanie kończy się właśnie na białym znaku (spacji) lub znaku końca linii. string s8; while (cin >> s8 && s8!= 'q') { cout << endl; P("wpisane z klawiatury: ", s8); Jeśli chcemy wczytać coś z pliku, przygotujmy sobie wcześniej jakiś plik ASCII z kilkunastoma linijkami, np. nazwiemy go test.txt. Teraz będziemy korzystać z obiektu obsługującego strumień plikowy, więc potrzebne będzie włączenie pliku nagłówkowego (piszemy go u góry, np. pod zapisanymi już wcześniej) // nazwa pochodzi od file stream #include <fstream> string s9; // w tym będziemy akumulowa ć kolejne czytane linie // tworzymy obiekt klasy input file stream czyli do czytania strumienia plikowego // jako argument podana nazwa pliku ifstream plik( "test.txt" ); // sprawdzamy czy si ę otworzył if (!plik ) cerr << "nie ma pliku \n"; while (!plik.eof() ) { getline( plik, s8 ); s9 += s8 + '\n'; P("wczytany plik: ", s9); W powyższym przykładzie występuje obiekt standardowego strumienia błędu, cerr, który na ekran wyprowadza zadaną informację. Różnicę w działaniu pomiędzy cout i cerr można zauważyć, jeśli skierujemy strumień wyjścia zamiast na ekran, do pliku. Jeśli nasz program ma nazwę prog, to robimy to tak:./prog > mojplik.txt (albo jeśli dopisywać do pliku to:./prog >> mojplik.txt) Wszystkie komunikaty z cout zamiast na ekran trafią do pliku. Natomiast komunikaty z cerr nadal zostaną wyświetlone na ekranie, co znacznie ułatwia zorientowanie się, że coś przebiegło niepoprawnie. Jeśli chcemy przekierować również strumień błędu do pliku, to napiszmy:./prog > mojplik.txt 2>&1 Więcej o przekierowaniach w unixie można poczytać np. na Wikipedii.
Operacje na stringach Typ string jest wyposażony w całą masę przydatnych metod oraz przeciążonych operatorów. Np. operacje dodawania do siebie dwóch łańcuchów znakowych zapisujemy po prostu znaczkiem + a operację dopisania czegoś, operatorem += Nie pozbawiono nas jednak metody składowej assign i append. Np. gdybym chciał dołożyć na koniec stringu trzy wykrzykniki, to mogę tak: s9.append( 3, '!' ); a jeśli przypisać 20 znaczków * to mogę tak: s9.assign( 20, '*' ); Są też metody insert, replace, copy i swap. Pozostawiam ich poznanie samodzielnej pracy. Poszukiwanie zawartości string jest przykładem sekwencyjnego kontenera, zawierającego literki. Możemy buszować po nim jak po zwykłej tablicy (czyli za pomocą operatora []), możemy odnieść się do konkretnej pozycji za pomocą metody at (proszę sprawdzić różnicę). Możemy odczytać wielkość stringu (metody size, length) oraz wielkości wewnętrznego buforu (capacity), możemy też zarezerwować bufor (reserve) danej wielkości, a od C++11 również skurczyć (shrink_to_fit). Warto zwrócić uwagę na metodę: max_size() oraz składową statyczną string::npos Proszę sprawdzić co otrzymamy gdy wypiszemy ich wartości w naszym systemie? Ja otrzymuję 4294967295 dla npos oraz 1073741820 dla max_size(), przy czym sizeof(string) wynosi 4. Widzą Państwo związek? Wreszcie, mamy cały zestaw metod szukających (find, rfind, find_first_of, find_first_not_of, find_last_of, find_last_not_of)... Filozofia ich używania jest taka, że zwracają znalezioną pozycję albo gdy takiej nie ma wartość npos. std::string s = "test"; if(s.find('a') == std::string::npos) std::cout << "no 'a' in 'test'\n"; Bieganie iteratorem Komunikacja kontenerów ze światem zewnętrznym często odbywa się za pomocą sprytnych, inteligentnych obiektów wskaźnikopodobych zwanych iteratorami. String jest kontenerem, ma więc też swoje iteratory, a w szczególności metody je zwracające: begin() i end() (oraz kilka wariacji na ich temat, w postaci stałej cbegin() i cend() i takiż sam zestaw do biegania wspak, zaczynający te metody od literki r). Konwencja jest zawsze taka: begin() - pokazuje na adres pierwszego elementu stringu, end() - na adres pierwszy za ostatnim elementem stringu (tak zwany za-ostatni ). I odwrotnie: rbegin() - pokazuje na ostatni element, zaś rend() - na adres poprzedzający pierwszy element (tak zwany przed-pierwszy ). Oto prosty przykład (zakładamy, że mamy już jakiś string s przygotowany): // iterator, jako wskaźnik trzeba wyłuskać * żeby dostać zawartość, na którą pokazuje string::iterator it = s.begin(); // proszę zwrócić uwagę na konwencję, sprawdzamy!= a nie < while ( it!= s.end() ) { cout << *it << ;
Można oczywiście tak samo w pętli for, tworząc iterator zupełnie lokalnie. Zwykle takie długie pisanie typu, jak string::iterator, jest zniechęcające. W nowym standardzie języka możemy wykorzystać dedukcję typu w oparciu o inicjalizację i zapis jest o wiele przyjemniejszy, np. for ( auto it = s.rbegin(); it!= s.rend(); ++it ) {.i tak dalej. Proszę poćwiczyć różne warianty biegania za pomocą iteratorów! Ciekawostki do poczytania Na pewno zauważyli Państwo, że niektóre metody zwracające czy to wielkość, czy pozycję, mają napisane typ size_type. A czasem napotkać można też size_t. Proszę sobie poczytać o tym, co oznaczają. http://stackoverflow.com/questions/918567/size-t-vs-containersize-type