ذخیره متنهای کدگذاری شده UTF-8 با رشتهها (strings)
ما در فصل ۴ درباره رشتهها صحبت کردیم، اما اکنون به آنها با عمق بیشتری نگاه خواهیم کرد. Rustaceanهای تازهوارد معمولاً به دلیل ترکیبی از سه عامل در رشتهها دچار مشکل میشوند: گرایش Rust به آشکارسازی خطاهای ممکن، رشتهها به عنوان یک ساختار داده پیچیدهتر از آنچه بسیاری از برنامهنویسان تصور میکنند، و UTF-8. این عوامل به نحوی ترکیب میشوند که میتوانند برای کسانی که از زبانهای برنامهنویسی دیگر میآیند دشوار باشند.
ما رشتهها را در زمینه مجموعهها بررسی میکنیم، زیرا رشتهها به عنوان مجموعهای از بایتها
پیادهسازی شدهاند، به علاوه تعدادی متد برای ارائه قابلیتهای مفید زمانی که این بایتها
به عنوان متن تفسیر میشوند. در این بخش، درباره عملیاتهایی که روی String انجام میشود
و هر نوع مجموعهای آنها را دارد، مانند ایجاد، بهروزرسانی، و خواندن صحبت خواهیم کرد.
همچنین تفاوتهای String با سایر مجموعهها را مورد بحث قرار میدهیم، بهویژه نحوه پیچیدگی
اندیسگذاری در یک String به دلیل تفاوتهای بین تفسیر دادههای String توسط انسانها
و کامپیوترها.
رشته (string) چیست؟
ابتدا تعریف میکنیم که منظور ما از اصطلاح رشته چیست. Rust فقط یک نوع رشته در زبان
هسته خود دارد که همان قطعه رشته str است که معمولاً به صورت قرض گرفته شده &str
دیده میشود. در فصل ۴ درباره قطعههای رشته صحبت کردیم، که ارجاعاتی به دادههای رشتهای
کدگذاری شده UTF-8 هستند که در جای دیگری ذخیره شدهاند. به عنوان مثال، رشتههای
لیترال در باینری برنامه ذخیره میشوند و بنابراین قطعههای رشته هستند.
نوع String، که توسط کتابخانه استاندارد Rust ارائه شده است و نه مستقیماً در زبان هسته
کدگذاری شده، یک نوع رشته رشدپذیر، قابل تغییر، و مالک UTF-8 است. وقتی Rustaceanها
به “رشتهها” در Rust اشاره میکنند، ممکن است به نوع String یا قطعه رشته &str اشاره
کنند، نه فقط یکی از این دو نوع. اگرچه این بخش عمدتاً درباره String است، اما هر دو نوع
در کتابخانه استاندارد Rust به شدت مورد استفاده قرار میگیرند و هر دو String و قطعههای
رشته کدگذاری UTF-8 دارند.
ایجاد یک رشته (strings) جدید
بسیاری از عملیات مشابه موجود در Vec<T> برای String نیز در دسترس است، زیرا String
در واقع به عنوان یک پوششی بر روی یک بردار از بایتها پیادهسازی شده است، با برخی
ضمانتها، محدودیتها، و قابلیتهای اضافی. مثالی از یک تابع که به همان روش با
Vec<T> و String کار میکند، تابع new برای ایجاد یک نمونه است، همانطور که در لیست
۸-۱۱ نشان داده شده است.
fn main() { let mut s = String::new(); }
String جدید و خالیاین خط یک رشته جدید و خالی به نام s ایجاد میکند که میتوانیم دادهها را در آن بارگذاری کنیم.
اغلب، دادههای اولیهای خواهیم داشت که میخواهیم رشته را با آنها شروع کنیم. برای این کار،
از متد to_string استفاده میکنیم که بر روی هر نوعی که ویژگی Display را پیادهسازی
میکند، همانند رشتههای لیترال، در دسترس است. لیست ۸-۱۲ دو مثال را نشان میدهد.
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(); }
to_string برای ایجاد یک String از یک رشته لیترالاین کد یک رشته حاوی initial contents ایجاد میکند.
ما همچنین میتوانیم از تابع String::from برای ایجاد یک String از یک رشته لیترال
استفاده کنیم. کد در لیست ۸-۱۳ معادل کدی است که در لیست ۸-۱۲ از to_string استفاده میکند.
fn main() { let s = String::from("initial contents"); }
String::from برای ایجاد یک String از یک رشته لیترالاز آنجا که رشتهها برای موارد بسیاری استفاده میشوند، میتوانیم از بسیاری از APIهای
جنریک مختلف برای رشتهها استفاده کنیم که گزینههای زیادی را در اختیار ما قرار میدهند.
برخی از اینها ممکن است به نظر اضافی بیایند، اما هرکدام جایگاه خاص خود را دارند!
در این مورد، String::from و to_string عملکرد یکسانی دارند، بنابراین انتخاب بین آنها
مسئله سبک و خوانایی کد است.
به یاد داشته باشید که رشتهها با کدگذاری UTF-8 هستند، بنابراین میتوانیم هر دادهای که به طور صحیح کدگذاری شده باشد را در آنها قرار دهیم، همانطور که در لیست ۸-۱۴ نشان داده شده است.
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"); }
تمام این موارد مقادیر معتبر String هستند.
بهروزرسانی یک رشته
یک String میتواند از نظر اندازه رشد کند و محتوای آن تغییر کند، همانطور که محتوای
یک Vec<T> تغییر میکند، اگر داده بیشتری به آن اضافه کنیم. علاوه بر این، میتوانیم به راحتی
از عملگر + یا ماکروی format! برای الحاق مقادیر String استفاده کنیم.
الحاق به یک رشته (string) با push_str و push
ما میتوانیم یک String را با استفاده از متد push_str برای الحاق یک قطعه رشته رشد دهیم،
همانطور که در لیست ۸-۱۵ نشان داده شده است.
fn main() { let mut s = String::from("foo"); s.push_str("bar"); }
String با استفاده از متد push_strبعد از این دو خط، مقدار s شامل foobar خواهد بود. متد push_str یک قطعه رشته را به عنوان آرگومان میگیرد
زیرا ما لزوماً نمیخواهیم مالکیت پارامتر را بگیریم. برای مثال، در کدی که در لیست ۸-۱۶ نشان داده شده است،
ما میخواهیم بتوانیم پس از الحاق محتوای s2 به s1 همچنان از s2 استفاده کنیم.
fn main() { let mut s1 = String::from("foo"); let s2 = "bar"; s1.push_str(s2); println!("s2 is {s2}"); }
Stringاگر متد push_str مالکیت s2 را میگرفت، نمیتوانستیم مقدار آن را در خط آخر چاپ کنیم. با این حال،
این کد همانطور که انتظار میرود کار میکند!
متد push یک کاراکتر را به عنوان پارامتر میگیرد و آن را به String اضافه میکند. لیست ۸-۱۷
حرف l را با استفاده از متد push به یک String اضافه میکند.
fn main() { let mut s = String::from("lo"); s.push('l'); }
String با استفاده از pushدر نتیجه، مقدار s شامل lol خواهد بود.
الحاق با استفاده از عملگر + یا ماکروی format!
اغلب، ممکن است بخواهید دو رشته موجود را با هم ترکیب کنید. یکی از راههای انجام این کار
استفاده از عملگر + است، همانطور که در لیست ۸-۱۸ نشان داده شده است.
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 }
+ برای ترکیب دو مقدار String در یک مقدار String جدیدمقدار s3 شامل Hello, world! خواهد بود. دلیل اینکه s1 پس از این الحاق دیگر معتبر نیست
و دلیل اینکه ما از یک مرجع به s2 استفاده کردیم، به امضای متدی که هنگام استفاده از
عملگر + فراخوانی میشود مربوط است. عملگر + از متد add استفاده میکند که امضای آن به شکل زیر است:
fn add(self, s: &str) -> String {
در کتابخانه استاندارد، شما add را خواهید دید که با استفاده از جنریکها و انواع مرتبط تعریف شده است.
اینجا، ما انواع مشخصی را جایگزین کردهایم، که این همان چیزی است که هنگام فراخوانی این متد با مقادیر
String اتفاق میافتد. درباره جنریکها در فصل ۱۰ صحبت خواهیم کرد. این امضا به ما سرنخهایی میدهد
تا بتوانیم بخشهای چالشبرانگیز عملگر + را درک کنیم.
اول، s2 یک & دارد، به این معنی که ما یک مرجع از رشته دوم را به رشته اول اضافه میکنیم.
این به دلیل پارامتر s در تابع add است: ما فقط میتوانیم یک &str را به یک String اضافه کنیم؛
نمیتوانیم دو مقدار String را با هم جمع کنیم. اما صبر کنید—نوع &s2، &String است، نه &str
همانطور که در پارامتر دوم add مشخص شده است. پس چرا کد در لیست ۸-۱۸ کامپایل میشود؟
دلیل اینکه میتوانیم از &s2 در فراخوانی add استفاده کنیم این است که کامپایلر میتواند آرگومان
&String را به &str تبدیل کند. هنگامی که ما متد add را فراخوانی میکنیم، Rust از یک
coercion deref استفاده میکند که در اینجا &s2 را به &s2[..] تبدیل میکند. ما این موضوع
را در فصل ۱۵ به طور عمیقتری بررسی خواهیم کرد. از آنجا که add مالکیت پارامتر s را نمیگیرد،
s2 پس از این عملیات همچنان یک String معتبر باقی خواهد ماند.
دوم، میتوانیم در امضا ببینیم که add مالکیت self را میگیرد زیرا self یک & ندارد.
این بدان معناست که s1 در لیست ۸-۱۸ به فراخوانی add منتقل میشود و پس از آن دیگر معتبر نخواهد بود.
بنابراین، اگرچه let s3 = s1 + &s2; به نظر میرسد که هر دو رشته را کپی میکند و یک رشته جدید ایجاد
میکند، این عبارت در واقع مالکیت s1 را میگیرد، یک کپی از محتوای s2 را اضافه میکند، و سپس مالکیت
نتیجه را بازمیگرداند. به عبارت دیگر، به نظر میرسد که کپیهای زیادی انجام میدهد، اما اینطور نیست؛
پیادهسازی کارآمدتر از کپی کردن است.
اگر نیاز به الحاق چندین رشته داشته باشیم، رفتار عملگر + دستوپاگیر میشود:
fn main() { let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); let s = s1 + "-" + &s2 + "-" + &s3; }
در این نقطه، مقدار s برابر با tic-tac-toe خواهد بود. با تمام این + و کاراکترهای "،
دیدن اینکه چه اتفاقی میافتد دشوار است. برای ترکیب رشتهها به روشهای پیچیدهتر، میتوانیم
به جای آن از ماکروی format! استفاده کنیم:
fn main() { let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); let s = format!("{s1}-{s2}-{s3}"); }
این کد نیز مقدار s را به tic-tac-toe تنظیم میکند. ماکروی format! شبیه به println! کار میکند،
اما به جای چاپ خروجی روی صفحه، یک String با محتوای مورد نظر بازمیگرداند. نسخه کد با استفاده از
format! بسیار خواناتر است و کدی که توسط ماکروی format! تولید میشود از مراجع استفاده میکند،
بنابراین این فراخوانی مالکیت هیچیک از پارامترهایش را نمیگیرد.
اندیسگذاری در رشتهها
در بسیاری از زبانهای برنامهنویسی دیگر، دسترسی به کاراکترهای منفرد در یک رشته با اشاره به آنها
توسط اندیس (index)یک عملیات معتبر و رایج است. با این حال، اگر تلاش کنید در Rust با استفاده از سینتکس
اندیسگذاری به بخشهایی از یک String دسترسی پیدا کنید، با خطا مواجه میشوید. کد نامعتبر
در لیست ۸-۱۹ را در نظر بگیرید.
fn main() {
let s1 = String::from("hi");
let h = s1[0];
}
Stringاین کد به خطای زیر منجر خواهد شد:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
--> src/main.rs:3:16
|
3 | let h = s1[0];
| ^ string indices are ranges of `usize`
|
= note: you can use `.chars().nth()` or `.bytes().nth()`
for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`
but trait `SliceIndex<[_]>` is implemented for `usize`
= help: for that trait implementation, expected `[_]`, found `str`
= note: required for `String` to implement `Index<{integer}>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error
خطا و توضیحات آن گویای موضوع است: رشتههای Rust از اندیسگذاری پشتیبانی نمیکنند. اما چرا؟ برای پاسخ به این سؤال، باید درباره نحوه ذخیرهسازی رشتهها در حافظه توسط Rust صحبت کنیم.
نمایش داخلی
یک String در واقع یک پوشش بر روی Vec<u8> است. بیایید به برخی از مثالهای رشتههای کدگذاری
شده UTF-8 در لیست ۸-۱۴ نگاه کنیم. ابتدا این مورد:
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"); }
در این حالت، مقدار len برابر با 4 خواهد بود، به این معنی که برداری که رشته "Hola" را
ذخیره میکند ۴ بایت طول دارد. هر یک از این حروف هنگام کدگذاری در UTF-8 یک بایت میگیرد.
با این حال، خط زیر ممکن است شما را شگفتزده کند (توجه داشته باشید که این رشته با حرف بزرگ
سیریلیک Ze آغاز میشود، نه عدد ۳):
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"); }
اگر از شما پرسیده شود طول این رشته چقدر است، ممکن است بگویید ۱۲. اما در واقع، پاسخ Rust ۲۴ است: این تعداد بایتهایی است که برای کدگذاری “Здравствуйте” در UTF-8 نیاز است، زیرا هر مقدار اسکالر Unicode در این رشته ۲ بایت فضای ذخیرهسازی میگیرد. بنابراین، یک اندیس (index)در بایتهای رشته همیشه با یک مقدار اسکالر Unicode معتبر مطابقت ندارد. برای نشان دادن این موضوع، کد نامعتبر زیر در Rust را در نظر بگیرید:
let hello = "Здравствуйте";
let answer = &hello[0];
شما قبلاً میدانید که مقدار answer برابر با З، اولین حرف، نخواهد بود. وقتی در UTF-8 کدگذاری
میشود، اولین بایت از З برابر با 208 و دومین بایت برابر با 151 است، بنابراین ممکن است به نظر
برسد که answer باید در واقع 208 باشد، اما 208 به تنهایی یک کاراکتر معتبر نیست. بازگرداندن
208 احتمالاً چیزی نیست که یک کاربر بخواهد اگر درخواست اولین حرف این رشته را داشته باشد؛
با این حال، این تنها دادهای است که Rust در اندیس (index)بایت ۰ دارد. کاربران به طور کلی نمیخواهند
مقدار بایت بازگردانده شود، حتی اگر رشته فقط حروف لاتین داشته باشد: اگر &"hi"[0] یک کد معتبر
بود که مقدار بایت را بازمیگرداند، مقدار 104 و نه h را بازمیگرداند.
پاسخ این است که برای جلوگیری از بازگرداندن یک مقدار غیرمنتظره و ایجاد باگهایی که ممکن است فوراً کشف نشوند، Rust این کد را اصلاً کامپایل نمیکند و از سوءتفاهمها در اوایل فرآیند توسعه جلوگیری میکند.
بایتها، مقادیر اسکالر و خوشههای گرافیمی! اوه خدای من!
نکته دیگری درباره UTF-8 این است که در واقع سه روش مرتبط برای مشاهده رشتهها از دیدگاه Rust وجود دارد: به صورت بایت، مقادیر اسکالر، و خوشههای گرافیمی (نزدیکترین چیز به چیزی که ما حروف مینامیم).
اگر به کلمه هندی “नमस्ते” نوشته شده در اسکریپت Devanagari نگاه کنیم، این کلمه به صورت یک بردار
از مقادیر u8 ذخیره میشود که به شکل زیر است:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
این ۱۸ بایت است و این همان چیزی است که کامپیوترها در نهایت این داده را ذخیره میکنند.
اگر به آنها به عنوان مقادیر اسکالر Unicode نگاه کنیم، که همان نوع char در Rust است، این بایتها
به این صورت به نظر میرسند:
['न', 'म', 'स', '्', 'त', 'े']
اینجا شش مقدار char وجود دارد، اما مقدار چهارم و ششم حروف نیستند: اینها دیاکریتیکهایی هستند که
به تنهایی معنایی ندارند. در نهایت، اگر به آنها به عنوان خوشههای گرافیمی نگاه کنیم، همان چیزی
که یک فرد به عنوان حروف کلمه هندی تشخیص میدهد، اینطور خواهد بود:
["न", "म", "स्", "ते"]
Rust روشهای مختلفی برای تفسیر داده خام رشته ارائه میدهد که کامپیوترها ذخیره میکنند، بنابراین هر برنامه میتواند تفسیری را که نیاز دارد انتخاب کند، صرف نظر از اینکه داده به چه زبان انسانی است.
یکی دیگر از دلایل اینکه Rust به ما اجازه نمیدهد در یک String اندیسگذاری کنیم تا یک کاراکتر را
دریافت کنیم این است که عملیات اندیسگذاری باید همیشه در زمان ثابت (O(1)) انجام شود. اما امکان
تضمین این عملکرد با یک String وجود ندارد، زیرا Rust باید محتویات را از ابتدا تا اندیس (index)مرور کند تا
تعیین کند که چند کاراکتر معتبر وجود دارد.
برش رشتهها
اندیسگذاری در یک رشته اغلب ایده خوبی نیست زیرا مشخص نیست که نوع بازگشتی عملیات اندیسگذاری رشته چه باید باشد: یک مقدار بایت، یک کاراکتر، یک خوشه گرافیمی، یا یک قطعه رشته. بنابراین، اگر واقعاً نیاز به استفاده از اندیسها برای ایجاد قطعههای رشته دارید، Rust از شما میخواهد بیشتر مشخص کنید.
به جای اندیسگذاری با استفاده از [] و یک عدد، میتوانید از [] با یک بازه استفاده کنید
تا یک قطعه رشته که شامل بایتهای خاصی است ایجاد کنید:
#![allow(unused)] fn main() { let hello = "Здравствуйте"; let s = &hello[0..4]; }
اینجا، s یک &str خواهد بود که شامل چهار بایت اول رشته است. پیشتر اشاره کردیم که هر
یک از این کاراکترها دو بایت طول دارند، که به این معنی است که مقدار s برابر با Зд خواهد بود.
اگر سعی کنیم فقط بخشی از بایتهای یک کاراکتر را با چیزی مثل &hello[0..1] برش دهیم،
Rust در زمان اجرا دچار خطا میشود، به همان شکلی که اگر یک اندیس (index)نامعتبر در یک بردار
دسترسی داده شود:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/collections`
thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
هنگام ایجاد قطعههای رشته با بازهها باید احتیاط کنید، زیرا این کار ممکن است باعث خرابی برنامه شما شود.
متدهایی برای پیمایش در رشتهها
بهترین راه برای کار با بخشهایی از رشتهها این است که به وضوح مشخص کنید که آیا میخواهید
روی کاراکترها یا بایتها کار کنید. برای مقادیر اسکالر Unicode منفرد، از متد chars استفاده کنید.
فراخوانی chars روی "Зд" دو مقدار از نوع char را جدا کرده و بازمیگرداند، و میتوانید
با استفاده از نتیجه پیمایش کنید تا به هر عنصر دسترسی پیدا کنید:
#![allow(unused)] fn main() { for c in "Зд".chars() { println!("{c}"); } }
این کد خروجی زیر را چاپ خواهد کرد:
З
д
به صورت جایگزین، متد bytes هر بایت خام را بازمیگرداند که ممکن است برای حوزه کاری شما مناسب باشد:
#![allow(unused)] fn main() { for b in "Зд".bytes() { println!("{b}"); } }
این کد چهار بایتی که این رشته را تشکیل میدهند چاپ خواهد کرد:
208
151
208
180
اما حتماً به یاد داشته باشید که مقادیر اسکالر Unicode معتبر ممکن است از بیش از یک بایت تشکیل شده باشند.
دریافت خوشههای گرافیمی از رشتهها، همانند اسکریپت Devanagari، پیچیده است، بنابراین این قابلیت توسط کتابخانه استاندارد ارائه نمیشود. اگر به این قابلیت نیاز دارید، کرایتهایی در crates.io موجود هستند.
رشتهها اینقدر ساده نیستند
به طور خلاصه، رشتهها پیچیده هستند. زبانهای برنامهنویسی مختلف انتخابهای متفاوتی درباره نحوه
نمایش این پیچیدگی به برنامهنویس میکنند. Rust انتخاب کرده است که مدیریت صحیح دادههای
String رفتار پیشفرض برای تمام برنامههای Rust باشد، که به این معنی است که برنامهنویسان
باید در ابتدا بیشتر درباره مدیریت دادههای UTF-8 فکر کنند. این معامله پیچیدگی بیشتری از رشتهها
را نسبت به سایر زبانهای برنامهنویسی نشان میدهد، اما از مواجهه با خطاهای مربوط به کاراکترهای
غیر-ASCII در مراحل بعدی چرخه توسعه جلوگیری میکند.
خبر خوب این است که کتابخانه استاندارد عملکردهای زیادی را بر اساس انواع String و &str
برای کمک به مدیریت صحیح این شرایط پیچیده ارائه میدهد. حتماً مستندات را برای متدهای مفیدی مانند
contains برای جستجو در یک رشته و replace برای جایگزینی بخشهایی از یک رشته با رشتهای دیگر
بررسی کنید.
بیایید به چیزی کمی کمتر پیچیده برویم: هش مپها!