Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

تخزين النصوص المشفرة بـ UTF-8 باستخدام السلاسل النصية (Strings)

تحدثنا عن السلاسل النصية (Strings) في الفصل 4، لكننا سنلقي نظرة عليها بعمق أكبر الآن. عادةً ما يواجه المبرمجون الجدد في Rust (Rustaceans) صعوبة في التعامل مع الـ Strings لثلاثة أسباب مجتمعة: ميل Rust لإظهار الأخطاء المحتملة، وكون الـ Strings هيكل بيانات أكثر تعقيداً مما يعتقده الكثير من المبرمجين، و UTF-8. تجتمع هذه العوامل بطريقة قد تبدو صعبة عندما تأتي من لغات برمجة أخرى.

نناقش الـ Strings في سياق المجموعات (Collections) لأن الـ Strings يتم تنفيذها كمجموعة من البايتات (Bytes)، بالإضافة إلى بعض الدوال المرتبطة (Methods) لتوفير وظائف مفيدة عندما يتم تفسير تلك الـ Bytes كنص. في هذا القسم، سنتحدث عن العمليات على String التي تمتلكها كل أنواع الـ Collections، مثل الإنشاء والتحديث والقراءة. سنناقش أيضاً الطرق التي تختلف بها String عن الـ Collections الأخرى، وتحديداً كيف أن الفهرسة (Indexing) في String معقدة بسبب الاختلافات بين كيفية تفسير البشر والحواسيب لبيانات الـ String.

تعريف السلاسل النصية (Defining Strings)

سنعرف أولاً ما نعنيه بمصطلح String. تمتلك Rust نوعاً واحداً فقط من السلاسل النصية في لغتها الأساسية (Core Language)، وهو شريحة السلسلة النصية (String Slice) str والتي تُرى عادةً في شكلها المستعار &str. في الفصل 4، تحدثنا عن الـ String Slices، وهي مراجع (References) لبعض بيانات النصوص المشفرة بـ UTF-8 والمخزنة في مكان آخر. السلاسل النصية الثابتة (String Literals)، على سبيل المثال، يتم تخزينها في الملف الثنائي (Binary) للبرنامج وبالتالي فهي String Slices.

نوع String الذي توفره مكتبة Rust القياسية (Standard Library) بدلاً من كونه مدمجاً في الـ Core Language، هو نوع سلسلة نصية قابل للنمو، وقابل للتغيير (Mutable)، ومملوك (Owned)، ومشفر بـ UTF-8. عندما يشير الـ Rustaceans إلى “Strings” في Rust، فقد يقصدون إما نوع String أو نوع الـ String Slice &str وليس أحدهما فقط. على الرغم من أن هذا القسم يدور بشكل كبير حول String إلا أن كلا النوعين يستخدمان بكثافة في الـ Standard Library، وكلاهما مشفر بـ UTF-8.

إنشاء سلسلة نصية جديدة (Creating a New String)

تتوفر العديد من العمليات المتاحة مع Vec<T> لـ String أيضاً لأن String يتم تنفيذها فعلياً كغلاف حول متجه من البايتات (Vector of Bytes) مع بعض الضمانات والقيود والقدرات الإضافية. مثال على دالة تعمل بنفس الطريقة مع Vec<T> و String هي دالة new لإنشاء نسخة (Instance)، كما هو موضح في القائمة 8-11.

fn main() {
    let mut s = String::new();
}

يُنشئ هذا السطر سلسلة نصية جديدة فارغة تسمى s والتي يمكننا لاحقاً تحميل البيانات فيها. غالباً ما يكون لدينا بعض البيانات الأولية التي نريد بدء السلسلة النصية بها. لذلك، نستخدم Method تسمى to_string والمتاحة لأي نوع ينفذ سمة Display (Trait) كما تفعل الـ String Literals. توضح القائمة 8-12 مثالين.

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();
}

ينشئ هذا الكود سلسلة نصية تحتوي على initial contents.

يمكننا أيضاً استخدام الدالة String::from لإنشاء String من String Literal. الكود في القائمة 8-13 يعادل الكود في القائمة 8-12 الذي يستخدم to_string.

fn main() {
    let s = String::from("initial contents");
}

لأن الـ Strings تستخدم لأشياء كثيرة، يمكننا استخدام العديد من الواجهات البرمجية العامة (Generic APIs) المختلفة لها، مما يوفر لنا الكثير من الخيارات. قد يبدو بعضها زائداً عن الحاجة، لكن لكل منها مكانه! في هذه الحالة، تقوم String::from و to_string بنفس الشيء، لذا فإن اختيار أحدهما هو مسألة أسلوب ووضوح للقراءة.

تذكر أن الـ Strings مشفرة بـ UTF-8، لذا يمكننا تضمين أي بيانات مشفرة بشكل صحيح فيها، كما هو موضح في القائمة 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");
}

كل هذه قيم String صالحة.

تحديث سلسلة نصية (Updating a String)

يمكن لـ String أن تنمو في الحجم ويمكن أن تتغير محتوياتها، تماماً مثل محتويات Vec<T> إذا قمت بدفع (Push) المزيد من البيانات إليها. بالإضافة إلى ذلك، يمكنك استخدام عامل + أو ماكرو format! لدمج (Concatenate) قيم String بسهولة.

الإلحاق باستخدام push_str أو push

يمكننا زيادة حجم String باستخدام push_str لإلحاق String Slice، كما هو موضح في القائمة 8-15.

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}

بعد هذين السطرين، ستحتوي s على foobar. تأخذ push_str شريحة نصية لأننا لا نريد بالضرورة أخذ ملكية (Ownership) المعامل. على سبيل المثال، في الكود في القائمة 8-16، نريد أن نكون قادرين على استخدام s2 بعد إلحاق محتوياتها بـ s1.

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {s2}");
}

إذا كانت push_str تأخذ Ownership لـ s2 فلن نتمكن من طباعة قيمتها في السطر الأخير. ومع ذلك، يعمل هذا الكود كما نتوقع!

تأخذ Method الـ push حرفاً واحداً كمعامل وتضيفه إلى الـ String. تضيف القائمة 8-17 الحرف l إلى String باستخدام push.

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}

نتيجة لذلك، ستحتوي s على lol.

الدمج باستخدام + أو format!

غالباً ما ستحتاج إلى دمج سلسلتين نصيتين موجودتين. إحدى الطرق للقيام بذلك هي استخدام عامل + كما هو موضح في القائمة 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
}

ستحتوي السلسلة s3 على Hello, world!. السبب في أن s1 لم تعد صالحة بعد الإضافة، والسبب في استخدامنا لمرجع لـ s2 يتعلق بتوقيع (Signature) الـ Method التي يتم استدعاؤها عند استخدام عامل +. يستخدم عامل + دالة add التي يبدو توقيعها كالتالي:

fn add(self, s: &str) -> String {

في الـ Standard Library، سترى add معرفة باستخدام الأنواع العامة (Generics) والأنواع المرتبطة (Associated Types). هنا، قمنا باستبدالها بأنواع ملموسة (Concrete Types)، وهو ما يحدث عندما نستدعي هذه الـ Method بقيم String. سنناقش الـ Generics في الفصل 10. يعطينا هذا الـ Signature الأدلة التي نحتاجها لفهم الأجزاء المعقدة في عامل +.

أولاً، تمتلك s2 علامة & مما يعني أننا نضيف Reference للسلسلة الثانية إلى السلسلة الأولى. هذا بسبب معامل s في دالة add: يمكننا فقط إضافة String Slice إلى String؛ لا يمكننا إضافة قيمتي String معاً. ولكن انتظر—نوع &s2 هو &String وليس &str كما هو محدد في المعامل الثاني لـ add. إذاً، لماذا يتم تجميع القائمة 8-18؟

السبب في قدرتنا على استخدام &s2 في استدعاء add هو أن المترجم (Compiler) يمكنه إجبار (Coerce) معامل الـ &String ليصبح &str. عندما نستدعي add تستخدم Rust “إجبار إلغاء الإسناد” (Deref Coercion)، والذي يحول هنا &s2 إلى &s2[..]. سنناقش الـ Deref Coercion بعمق أكبر في الفصل 15. ولأن add لا تأخذ Ownership لمعامل s ستظل s2 قيمة String صالحة بعد هذه العملية.

ثانياً، يمكننا أن نرى في الـ Signature أن add تأخذ Ownership لـ self لأن self لا تمتلك علامة &. هذا يعني أن s1 في القائمة 8-18 سيتم نقلها (Moved) إلى استدعاء add ولن تعد صالحة بعد ذلك. لذا، على الرغم من أن let s3 = s1 + &s2; تبدو وكأنها ستنسخ كلتا السلسلتين وتنشئ واحدة جديدة، إلا أن هذه العبارة تأخذ في الواقع Ownership لـ s1 وتلحق بها نسخة من محتويات s2 ثم تعيد Ownership للنتيجة. بعبارة أخرى، يبدو الأمر وكأنه يتم إجراء الكثير من النسخ، لكنه ليس كذلك؛ التنفيذ أكثر كفاءة من النسخ.

إذا احتجنا لدمج سلاسل نصية متعددة، يصبح سلوك عامل + غير مريح:

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! يستخدم مراجع (References) بحيث لا يأخذ هذا الاستدعاء Ownership لأي من معاملاته.

الفهرسة في السلاسل النصية (Indexing into Strings)

في العديد من لغات البرمجة الأخرى، يعد الوصول إلى أحرف فردية في سلسلة نصية عن طريق الإشارة إليها بالفهرس (Index) عملية صالحة وشائعة. ومع ذلك، إذا حاولت الوصول إلى أجزاء من String باستخدام صيغة الفهرسة في Rust، فستحصل على خطأ. فكر في الكود غير الصالح في القائمة 8-19.

fn main() {
    let s1 = String::from("hi");
    let h = s1[0];
}

سيؤدي هذا الكود إلى الخطأ التالي:

$ 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}`
  = 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 following other types implement trait `SliceIndex<T>`:
            `usize` implements `SliceIndex<ByteStr>`
            `usize` implements `SliceIndex<[T]>`
  = 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

الخطأ يوضح القصة: الـ Strings في Rust لا تدعم الـ Indexing. ولكن لماذا لا؟ للإجابة على هذا السؤال، نحتاج إلى مناقشة كيفية تخزين Rust للـ Strings في الذاكرة.

التمثيل الداخلي (Internal Representation)

الـ String هي غلاف فوق Vec<u8>. دعنا ننظر في بعض أمثلة السلاسل النصية المشفرة بـ UTF-8 من القائمة 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");
}

في هذه الحالة، سيكون len هو 4 مما يعني أن الـ Vector الذي يخزن السلسلة "Hola" طوله 4 بايتات. كل حرف من هذه الأحرف يأخذ 1 بايت عند تشفيره بـ 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");
}

إذا سُئلت عن طول السلسلة، فقد تقول 12. في الواقع، إجابة Rust هي 24: هذا هو عدد البايتات التي يتطلبها تشفير “Здравствуйте” بـ UTF-8، لأن كل قيمة سلمية لليونيكود (Unicode Scalar Value) في تلك السلسلة تأخذ 2 بايت من التخزين. لذلك، فإن الفهرس في بايتات السلسلة لن يرتبط دائماً بـ Unicode Scalar Value صالحة. للتوضيح، فكر في كود Rust غير الصالح هذا:

let hello = "Здравствуйте";
let answer = &hello[0];

أنت تعلم بالفعل أن answer لن تكون З (الحرف الأول). عند التشفير بـ UTF-8، يكون البايت الأول من З هو 208 والثاني هو 151 لذا يبدو أن answer يجب أن تكون في الواقع 208 لكن 208 ليس حرفاً صالحاً بمفرده. من المحتمل ألا يكون إرجاع 208 هو ما يريده المستخدم إذا طلب الحرف الأول من هذه السلسلة؛ ومع ذلك، فهذه هي البيانات الوحيدة التي تمتلكها Rust عند فهرس البايت 0. لا يريد المستخدمون عموماً إرجاع قيمة البايت، حتى لو كانت السلسلة تحتوي فقط على أحرف لاتينية: إذا كان &"hi"[0] كوداً صالحاً يعيد قيمة البايت، فإنه سيعيد 104 وليس h.

الإجابة إذاً هي أنه لتجنب إرجاع قيمة غير متوقعة والتسبب في أخطاء قد لا يتم اكتشافها على الفور، لا تقوم Rust بتجميع هذا الكود على الإطلاق وتمنع سوء الفهم في وقت مبكر من عملية التطوير.

البايتات، والقيم السلمية، وعناقيد الرموز (Bytes, Scalar Values, and Grapheme Clusters)

نقطة أخرى حول UTF-8 هي أن هناك في الواقع ثلاث طرق ذات صلة للنظر في الـ Strings من منظور Rust: كبايتات (Bytes)، وقيم سلمية (Scalar Values)، وعناقيد رموز (Grapheme Clusters) - وهي أقرب شيء لما نسميه أحرفاً.

إذا نظرنا إلى الكلمة الهندية “नमस्ते” المكتوبة بخط الديفاناغاري، فسيتم تخزينها كمتجه من قيم u8 يبدو كالتالي:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

هذا يمثل 18 بايت وهو الكيفية التي تخزن بها الحواسيب هذه البيانات في النهاية. إذا نظرنا إليها كـ Unicode Scalar Values، وهي ما يمثله نوع char في Rust، فستبدو تلك البايتات كالتالي:

['न', 'م', 'س', '्', 'ت', 'े']

هناك ست قيم char هنا، لكن الرابعة والسادسة ليست أحرفاً: إنها علامات تشكيل لا معنى لها بمفردها. أخيراً، إذا نظرنا إليها كـ Grapheme Clusters، فسنحصل على ما يسميه الشخص الأحرف الأربعة التي تشكل الكلمة الهندية:

["न", "م", "स्", "ته"]

توفر Rust طرقاً مختلفة لتفسير بيانات السلسلة الخام التي تخزنها الحواسيب بحيث يمكن لكل برنامج اختيار التفسير الذي يحتاجه، بغض النظر عن اللغة البشرية التي كتبت بها البيانات.

السبب الأخير الذي يجعل Rust لا تسمح لنا بالفهرسة في String للحصول على حرف هو أن عمليات الفهرسة يُتوقع منها دائماً أن تأخذ وقتاً ثابتاً (O(1)). ولكن ليس من الممكن ضمان هذا الأداء مع String لأن Rust ستضطر إلى المرور عبر المحتويات من البداية إلى الفهرس لتحديد عدد الأحرف الصالحة الموجودة.

تشريح السلاسل النصية (Slicing Strings)

غالباً ما تكون الفهرسة في سلسلة نصية فكرة سيئة لأنه ليس من الواضح ما يجب أن يكون عليه النوع المرتجع لعملية فهرسة السلسلة: قيمة بايت، أو حرف، أو عنقود رموز، أو شريحة سلسلة نصية. إذا كنت بحاجة حقاً لاستخدام الفهارس لإنشاء String Slices، فإن Rust تطلب منك أن تكون أكثر تحديداً.

بدلاً من الفهرسة باستخدام [] مع رقم واحد، يمكنك استخدام [] مع نطاق (Range) لإنشاء String Slice يحتوي على بايتات معينة:

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

هنا، ستكون s عبارة عن &str تحتوي على أول 4 بايتات من السلسلة. ذكرنا سابقاً أن كل حرف من هذه الأحرف كان 2 بايت، مما يعني أن s ستكون Зд.

إذا حاولنا تشريح جزء فقط من بايتات حرف ما باستخدام شيء مثل &hello[0..1] فستقوم Rust بالهلع (Panic) في وقت التشغيل بنفس الطريقة التي يحدث بها الوصول إلى فهرس غير صالح في متجه:

$ 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

يجب عليك توخي الحذر عند إنشاء String Slices باستخدام النطاقات، لأن القيام بذلك قد يؤدي إلى تعطل برنامجك.

التكرار عبر السلاسل النصية (Iterating Over Strings)

أفضل طريقة للتعامل مع أجزاء من السلاسل النصية هي أن تكون صريحاً بشأن ما إذا كنت تريد أحرفاً أم بايتات. بالنسبة لـ Unicode Scalar Values الفردية، استخدم Method الـ chars. استدعاء chars على “Зд” يفصل ويعيد قيمتين من نوع char ويمكنك التكرار (Iterate) عبر النتيجة للوصول إلى كل عنصر:

#![allow(unused)]
fn main() {
for c in "Зд".chars() {
    println!("{c}");
}
}

سيطبع هذا الكود ما يلي:

З
д

بدلاً من ذلك، تعيد Method الـ bytes كل بايت خام، وهو ما قد يكون مناسباً لمجالك:

#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
    println!("{b}");
}
}

سيطبع هذا الكود الـ 4 بايتات التي تشكل هذه السلسلة:

208
151
208
180

لكن تأكد من تذكر أن Unicode Scalar Values الصالحة قد تتكون من أكثر من 1 بايت.

الحصول على Grapheme Clusters من السلاسل النصية، كما هو الحال مع خط الديفاناغاري، أمر معقد، لذا لا توفر المكتبة القياسية هذه الوظيفة. تتوفر حزم (Crates) على crates.io إذا كانت هذه هي الوظيفة التي تحتاجها.

التعامل مع تعقيدات السلاسل النصية (Handling the Complexities of Strings)

باختصار، السلاسل النصية معقدة. تتخذ لغات البرمجة المختلفة خيارات مختلفة حول كيفية تقديم هذا التعقيد للمبرمج. اختارت Rust جعل التعامل الصحيح مع بيانات String هو السلوك الافتراضي لجميع برامج Rust، مما يعني أن المبرمجين يضطرون إلى التفكير أكثر في التعامل مع بيانات UTF-8 مسبقاً. تكشف هذه المقايضة عن المزيد من تعقيد السلاسل النصية عما هو ظاهر في لغات البرمجة الأخرى، ولكنها تمنعك من الاضطرار إلى التعامل مع الأخطاء التي تتضمن أحرفاً غير تابعة لـ ASCII لاحقاً في دورة حياة التطوير الخاصة بك.

الخبر السار هو أن الـ Standard Library تقدم الكثير من الوظائف المبنية على نوعي String و &str للمساعدة في التعامل مع هذه المواقف المعقدة بشكل صحيح. تأكد من مراجعة التوثيق للتعرف على Methods مفيدة مثل contains للبحث في سلسلة نصية و replace لاستبدال أجزاء من سلسلة نصية بسلسلة أخرى.

دعنا ننتقل إلى شيء أقل تعقيداً قليلاً: جداول التجزئة (Hash Maps)!