ذخیره متنهای کدگذاری شده 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
را نمیگیرد،
س2
پس از این عملیات همچنان یک 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("hello");
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`
|
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`, which is required by `String: Index<_>`
= 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<[_]>` 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
برای جایگزینی بخشهایی از یک رشته با رشتهای دیگر
بررسی کنید.
بیایید به چیزی کمی کمتر پیچیده برویم: هش مپها!