Manipularea textelor codate UTF-8 utilizând string-uri
Am introdus noțiunile despre string-uri în Capitolul 4, dar acum vom aprofunda subiectul. Utilizatorii noi de Rust au adesea dificultăți cu string-urile din cauza unei combinații a trei aspecte: abordarea Rust de a evidenția posibilele greșeli, complexitatea neașteptată a string-urilor ca structură de date și particularitățile codării UTF-8. Toate acestea pot crea provocări, mai ales pentru cei veniți din alte limbaje de programare.
Vorbim despre string-uri în cadrul temei colecțiilor pentru că, în esență, un string este o colecție de octeți (bytes), completată de metode care facilitează lucrul cu textul. În această secțiune, ne vom concentra pe operațiunile comune oricărui tip de colecție, care se aplică și pentru String
: cum le creăm, le actualizăm și le citim. Vom discuta și despre caracteristicile care diferențiază String
de alte tipuri de colecții, concentrându-ne în special pe complexitatea indexării într-un String
, dată de modul diferit în care oamenii și calculatoarele procesează informația dintr-un String
.
Ce este un string?
Să clarificăm ce înțelegem noi prin "string". În limbajul Rust, există un singur tip fundamental de string, anume secțiunea de string - str
, care de obicei apare sub forma împrumutată &str
. În Capitolul 4, am abordat conceptul de secțiuni de string, care reprezintă referințe la date de tip string codificate în UTF-8 și stocate în altă parte. Spread exemplu, literalele de string sunt incluse în fișierul binar al programului, motiv pentru care sunt considerate secțiuni de string.
Pe de altă parte, avem tipul String
, oferit de biblioteca standard a limbajului Rust și nu parte integrantă a nucleului limbajului. Acesta este un tip de string extensibil, mutabil, cu proprietate asupra datelor și codificat în UTF-8. Când vorbim despre "string-uri" în Rust, ne referim atât la tipul String
, cât și la secțiunile de string &str
, nu exclusiv la unul dintre ele. Deși accentul acestei secțiuni este pe String
, ambele forme sunt fundamentale în biblioteca standard și, atât String
, cât și secțiunile de string respectă codificarea UTF-8.
Crearea unui string nou
Operațiunile pe care le facem cu Vec<T>
pot fi aplicate și pe String
, deoarece String
este efectiv o încapsulare peste un vector de octeți, având anumite garanții suplimentare, restricții și funcționalități. Să luăm drept exemplu funcția new
, care ne permite să creăm o nouă instanță de String
, ilustrată în Listarea 8-11.
fn main() { let mut s = String::new(); }
Listarea 8-11: Crearea unui String
gol
Această linie de cod creează un String
nou și gol, pe nume s
, în care putem încărca date ulterior. Adesea avem date inițiale pe care dorim să le folosim pentru a popula string-ul. Pentru acest caz folosim metoda to_string
, disponibilă pentru orice tip ce implementează trăsătura Display
, așa cum fac literalele string. Listarea 8-12 prezintă două exemple.
fn main() { let data = "initial contents"; let s = data.to_string(); // the method also works on a literal directly: let s = "initial contents".to_string(); }
Listarea 8-12: Crearea unui String
dintr-un literal string folosind metoda to_string
Codul de mai sus generează un string ce conține textul initial contents
.
Putem folosi, de asemenea, funcția String::from
pentru a crea un String
pornind de la un literal string. Codul din Listarea 8-13 este similar celui din Listarea 8-12, care a utilizat to_string
.
fn main() { let s = String::from("initial contents"); }
Listarea 8-13: Utilizarea funcției String::from
pentru a obține un String
dintr-un literal string
Având în vedere numărul mare de aplicații ale string-urilor, există multe API-uri generice pentru lucrul cu acestea, oferindu-ne o varietate mare de opțiuni. Unele dintre acestea par redundante, dar fiecare își are rostul său! În acest caz, String::from
și to_string
îndeplinesc aceeași funcție, astfel alegerea dintre cele două este bazată mai mult pe preferințe de stil și claritate.
Rețineți că string-urile sunt codate în UTF-8, astfel orice date codate adecvat pot fi inclusă în acestea, așa cum este demonstrat în Listarea 8-14.
fn main() { let hello = String::from("السلام عليكم"); let hello = String::from("Dobrý den"); let hello = String::from("Hello"); let hello = String::from("שָׁלוֹם"); let hello = String::from("नमस्ते"); let hello = String::from("こんにちは"); let hello = String::from("안녕하세요"); let hello = String::from("你好"); let hello = String::from("Olá"); let hello = String::from("Здравствуйте"); let hello = String::from("Hola"); }
Listarea 8-14: Salvarea mesajelor de salut în diferite limbi în string-uri
Toate acestea reprezintă valori String
corecte.
Actualizarea unui string
Un String
poate să se extindă și să-și schimbe conținutul, similar cu Vec<T>
, atunci când adaugi mai multe date. De asemenea, e simplu să concatenezi valori String
folosind operatorul +
sau macro-ul format!
.
Adăugând la un string cu push_str
și push
Un String
poate fi mărit adăugând o secțiune de string cu ajutorul metodei push_str
, așa cum vedem în Listarea 8-15.
fn main() { let mut s = String::from("foo"); s.push_str("bar"); }
Listarea 8-15: Adăugarea unei secțiuni de string la un String
cu push_str
După aceste operațiuni, s
va conține foobar
. Metoda push_str
primește o secțiune de string pentru că nu dorește, de obicei, să preia controlul acestuia. De exemplu, în exemplul din Listarea 8-16, vrem ca s2
să fie utilizabil și după ce l-am concatenat cu s1
.
fn main() { let mut s1 = String::from("foo"); let s2 = "bar"; s1.push_str(s2); println!("s2 is {s2}"); }
Listarea 8-16: Utilizarea unei secțiuni de string după ce a fost adăugată la un String
Dacă metoda push_str
ar fi solicitat posesiunea asupra lui s2
, nu am fi putut afișa valoarea acestuia la sfârșit. Însă, codul funcționează cum ne-am așteptat!
Metoda push
acceptă un caracter și îl adaugă la String
. În Listarea 8-17, adăugăm litera "l" la un String
folosind push
.
fn main() { let mut s = String::from("lo"); s.push('l'); }
Listarea 8-17: Adăugarea unui caracter la un String
cu push
Rezultatul va fi că s
va conține lol
.
Concatenarea cu operatorul +
sau macro-ul format!
Adesea, ai nevoie să unifici două string-uri existente. Poți face asta folosind operatorul +
, cum este arătat în Listarea 8-18.
fn main() { let s1 = String::from("Hello, "); let s2 = String::from("world!"); let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used }
Listarea 8-18: Utilizarea operatorului +
pentru a concatena două valori de tip String
într-una nouă
String
-ul s3
va conține textul Hello, world!
. După această operație, s1
nu mai este valid, iar motivul pentru care am utilizat o referință la s2
este legat de semnătura metodei add
, apelată prin operatorul +
. Iată cum arată semnătura:
fn add(self, s: &str) -> String {
Deși în biblioteca standard add
este definit cu generice, noi am folosit tipuri specifice pentru a putea explica. Acest lucru se întâmplă automat când invoci metoda cu valori de tip String
. Vom detalia genericele în Capitolul 10. Semnătura ne oferă indicii pentru a demistifica operatorul +
.
Mai întâi, observăm că s2
este precedat de &
, ceea ce indică faptul că adăugăm o referință la al doilea string. Aceasta este necesar, deoarece funcția add
acceptă doar o referință &str
pentru concatenare, nu două valori de tip String
. Dar de ce funcționează codul din Listarea 8-18, având în vedere că &s2
este de fapt de tip &String
şi nu &str
?
Explicația este că compilatorul poate converti &String
la &str
prin intermediul unei coerciții de dereferențiere. Când apelăm metoda add
, &s2
este convertit la &s2[..]
. Aflăm mai multe despre aceasta în Capitolul 15. Deoarece add
nu preia posesiunea parametrului s
, s2
rămâne un String
valid după operație.
În al doilea rând, add
preia posesiunea lui self
, care nu este marcat cu un &
. Acest lucru înseamnă că s1
va fi consumat de apelul add
și nu va mai putea fi folosit în continuare. Prin urmare, instrucțiunea let s3 = s1 + &s2;
poate părea că doar copiază string-urile pentru a crea unul nou, dar, de fapt, preia s1
, adaugă la el conținutul copiat din s2
, și returnează noua valoare. Astfel, în loc să efectueze multe copieri inutile, operația este de fapt mai eficientă.
Pentru concatenarea mai multor string-uri, folosirea operatorului +
poate deveni incomodă:
fn main() { let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); let s = s1 + "-" + &s2 + "-" + &s3; }
Aici, s
va fi tic-tac-toe
. Însă, cu atâtea +
și "
, codul devine încărcat și greu de urmărit. Pentru cazuri mai complexe, putem apela la macro-ul format!
:
fn main() { let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); let s = format!("{s1}-{s2}-{s3}"); }
Acest fragment de cod atribuie tot tic-tac-toe
pentru s
. Macro-ul format!
, similar cu println!
, nu afișează rezultatul pe ecran, ci returnează un String
cu conținutul formatat. Varianta cu format!
este clar mai lizibilă și nu preia posesiunea niciunui parametru, lucru de preferat în anumite contexte.
Accesarea elementelor unui string prin index
Accesul la caracterele unui string folosind indexul lor este o practică obișnuită în multe limbaje de programare. Cu toate acestea, în Rust, încercarea de a accesa elemente dintr-un String
prin indexare va genera o eroare. Iată codul incorect prezentat în Listarea 8-19.
fn main() {
let s1 = String::from("hello");
let h = s1[0];
}
Listarea 8-19: Tentativa de utilizare a indexării cu un String
Executarea acestui cod va produce următoarea eroare:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `String` cannot be indexed by `{integer}`
--> src/main.rs:3:13
|
3 | let h = s1[0];
| ^^^^^ `String` cannot be indexed by `{integer}`
|
= help: the trait `Index<{integer}>` is not implemented for `String`
= help: the following other types implement trait `Index<Idx>`:
<String as Index<RangeFrom<usize>>>
<String as Index<RangeFull>>
<String as Index<RangeInclusive<usize>>>
<String as Index<RangeTo<usize>>>
<String as Index<RangeToInclusive<usize>>>
<String as Index<std::ops::Range<usize>>>
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` due to previous error
Mesajul de eroare explică problema: string-urile din Rust nu permit folosirea indexării. De ce se întâmplă asta? Pentru a înțelege motivul, trebuie să discutăm despre modul în care Rust gestionează string-urile în memorie.
Structura internă a unui string
Un String
este practic un Vec<u8>
. Să analizăm câteva exemple de string-uri corect codificate în UTF-8 din Listarea 8-14. Începem cu exemplul acesta:
fn main() { let hello = String::from("السلام عليكم"); let hello = String::from("Dobrý den"); let hello = String::from("Hello"); let hello = String::from("שָׁלוֹם"); let hello = String::from("नमस्ते"); let hello = String::from("こんにちは"); let hello = String::from("안녕하세요"); let hello = String::from("你好"); let hello = String::from("Olá"); let hello = String::from("Здравствуйте"); let hello = String::from("Hola"); }
Aici, len
va fi 4, ceea ce înseamnă că vectorul care păstrează string-ul "Hola" are o lungime de 4 bytes. Fiecare literă ocupă 1 byte în codificarea UTF-8. Totuși, următoarea linie te poate surprinde. (Observă că acest string începe cu litera mare chirilică Ze, nu numărul 3.)
fn main() { let hello = String::from("السلام عليكم"); let hello = String::from("Dobrý den"); let hello = String::from("Hello"); let hello = String::from("שָׁלוֹם"); let hello = String::from("नमस्ते"); let hello = String::from("こんにちは"); let hello = String::from("안녕하세요"); let hello = String::from("你好"); let hello = String::from("Olá"); let hello = String::from("Здравствуйте"); let hello = String::from("Hola"); }
La întrebarea despre lungimea string-ului, ai putea răspunde că este de 12. În realitate, Rust îți spune că este 24: acesta este numărul de bytes necesari pentru a codifica "Здравствуйте" în UTF-8. Acest lucru se datorează faptului că fiecare valoare scalară Unicode în acel string ocupă 2 bytes. Așadar, un indice al octeților string-ului nu corespunde întotdeauna unei valori scalare Unicode valide. Ca exemplu, iată un cod Rust incorect:
let hello = "Здравствуйте";
let answer = &hello[0];
Știm că answer
nu va fi З
, prima literă. În codificarea UTF-8, primul byte pentru З
este 208
, iar al doilea este 151
. Prin urmare, ai putea crede că answer
ar trebui să fie 208
, dar 208
nu reprezintă un caracter valid de sine stătător. Acest rezultat nu este probabil ceea ce un utilizator ar aștepta când solicită prima literă a string-ului; în orice caz, Rust nu are la dispoziție alte date la indicele de byte zero. În general, utilizatorii nu doresc valoarea octetului în sine, chiar dacă string-ul constă doar din litere latine: dacă &"hello"[0]
ar fi un cod valid care returnează valoarea octetului, acesta ar returna 104
, nu h
.
Concluzia este că pentru a evita generarea unei valori neașteptate și introducerea de bug-uri ce ar putea rămâne nedetectate pentru o perioadă, Rust alege să nu compileze acest cod deloc, prevenind astfel confuziile încă din fazele incipiente ale dezvoltării software.
Octeți, valori scalare și grupuri de grafeme
Un alt aspect legat de UTF-8 este faptul că există trei moduri în care Rust consideră string-urile: ca octeți, valori scalare, și grupuri de grafeme (cel mai apropiat termen pentru ceea ce numim "litere").
De exemplu, cuvântul hindi "नमस्ते" scris în scriptul Devanagari este stocat ca un vector de valori u8
în felul următor:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
Acestea sunt 18 octeți și reprezintă modul în care calculatoarele stochează aceste date. Privindu-le ca valori scalare Unicode, care sunt reprezentate de tipul char
în Rust, acești octeți arată astfel:
['न', 'म', 'स', '्', 'त', 'े']
Avem aici șase valori char
, dar a patra și a șasea nu reprezintă litere: ele sunt diacritice fără sens de sine stătător. În final, dacă le privim ca grupuri de grafeme, obținem cele patru "litere" care formează cuvântul hindi:
["न", "म", "स्", "ते"]
Rust oferă diferite metode de interpretare a datelor brute ale unui string pentru ca fiecare program să își poată alege abordarea necesară, indiferent de limba umană în care sunt datele.
Un motiv suplimentar pentru care Rust nu permite indexarea unui String
pentru a extrage un caracter este că se așteaptă ca operațiile de indexare să se efectueze într-un timp constant (O(1)). Dar acest lucru nu poate fi garantat cu String
, deoarece Rust ar trebui să parcurgă conținutul de la început până la index pentru a identifica numărul de caractere valide.
Secționarea string-urilor
A accesa un string folosind indici este adesea nerecomandat deoarece nu este clar ce tip de valoare ar trebui să returneze operația de indexare: un octet, un caracter, un cluster de grafeme sau o secțiune de string. Din acest motiv, Rust necesită specificații mai exacte atunci când vrei să folosești indici pentru a obține secțiuni de string.
În loc să te bazezi pe []
cu un singur număr, este posibil să utilizezi []
cu un diapazon pentru a crea o secțiune ce include anumiți octeți:
#![allow(unused)] fn main() { let hello = "Здравствуйте"; let s = &hello[0..4]; }
În exemplul de mai sus, s
este un &str
care cuprinde primii 4 octeți din string. Anterior, am menționat că fiecare caracter este reprezentat de 2 octeți, deci s
va conține Зд
.
Dacă am încerca să extragem doar o parte a octeților unui caracter, cum ar fi cu &hello[0..1]
, Rust va genera o eroare la rulare asemănătoare celei întâmpinate când se accesează un index invalid într-un array:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/collections`
thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/main.rs:4:14
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Utilizează cu atenție diapazoanele când creezi secțiuni de string, deoarece acest lucru poate duce la erori grave în timpul execuției programului.
Metode de iterație pentru string-uri
Când lucrezi cu părți din string-uri, e important să specifici clar dacă dorești să accesezi caractere sau octeți. Dacă ai nevoie de valori scalare Unicode individuale, folosește metoda chars
. Dacă aplici chars
pe textul “Зд”, vei obține două valori de tip char
, pe care le poți parcurge prin iterare pentru a accesa fiecare caracter:
#![allow(unused)] fn main() { for c in "Зд".chars() { println!("{c}"); } }
Acest exemplu de cod va afișa caracterele:
З
д
Pe de altă parte, metoda bytes
îți returnează octeții individuali ai textului, ceea ce poate fi util în anumite scenarii:
#![allow(unused)] fn main() { for b in "Зд".bytes() { println!("{b}"); } }
Acest cod va afișa cei patru octeți care formează textul dat:
208
151
208
180
Este important să ții minte că o valoare scalară validă în Unicode poate fi alcătuită din mai mulți octeți.
Extragerea clusterelor de grafeme din string-uri, cum ar fi cele din scrierea Devanagari, este o operație complexă și, din acest motiv, nu este inclusă în biblioteca standard. Pentru această nevoie, poți găsi crate-uri specializate pe crates.io.
Simplitatea înșelătoare a string-urilor
Pentru a rezuma, lucrurile cu string-urile sunt complexe. Fiecare limbaj de programare alege diferit cum să îţi prezinte această complexitate. Rust optează pentru gestionarea corectă a datelor String
ca opțiune standard în orice program Rust. Acest lucru înseamnă că trebuie să acorzi o atenție sporită procesării datelor UTF-8 din start. Această abordare scoate la iveală complexitatea string-urilor mai mult decât în alte limbaje, însă astfel, eviți întâmpinarea erorilor legate de caractere non-ASCII în etapele avansate de dezvoltare a proiectelor tale.
Partea încurajatoare este că biblioteca standard vine la pachet cu multe funcționalități, bazate pe String
și &str
, care sunt gândite să ajute la navigarea cu succes prin aceste complexități. Nu rata documentația, unde vei găsi metode valoroase cum ar fi contains
, pentru căutarea într-un string, sau replace
, pentru înlocuirea secțiunilor dintr-un string cu alte string-uri.
Acum, să ne îndreptăm atenția spre ceva mai simplu: hash map-urile!