أتينا على ذكر السلاسل النصية سابقًا إلا أننا لم ننظر إليها بالتفصيل بعد، إذ يجد متعلمو لغة رست فهم السلاسل النصية صعبًا لثلاثة أسباب رئيسية: ميل رست لاستباق الخطأ قبل حدوثه وعرض رسالة خطأ تدلّ عليه، كما يبدو هيكل بيانات السلاسل النصية أكثر صعوبةً وتعقيدًا مما تبدو عليه، وأخيرًا ترميز UTF-8. قد تجتمع جميع الأسباب الثلاث السابقة وتجعل من الصعب عليك فهم السلاسل النصية إن قدمت من لغات برمجة مختلفة.
نناقش السلاسل النصية هنا في سياق التجميعات collections، وذلك لأن السلاسل النصية هي تطبيق لتجميعة من البايتات إضافةً إلى بعض التوابع المفيدة الأخرى، والتي تبرز أهميتها عندما تمثّل هذه البايتات نصًا ما. سنتحدث في هذا المقال عن العمليات على السلاسل النصية String
الموجودة في كل نوع تجميعة، مثل إنشاء سلسلة نصية والتعديل عليها وقراءة محتوياتها، كما سنناقش أيضًا الطرق التي تختلف فيها السلاسل النصية عن التجميعات الأخرى وبالأخص استخدام دليل السلسلة النصية للوصول إلى محتوياتها، فهو معقد نظرًا لاختلافه مع ما يفسّره البشر لبيانات سلسلة نصية وما تفسره الحواسيب.
ما هي السلسلة النصية؟
دعنا نبدأ أولًا بتعريف ما الذي نقصده عندما نقول سلسلة نصية؛ إذ تمتلك رست نوع سلسلة نصية واحد في أساس اللغة وهو شريحة السلسلة النصية string slice str
، الذي نراه عادةً بشكله المختصر &str
. تحدثنا سابقًا عن شرائح السلاسل النصية؛ وهي مراجع إلى بعض بيانات السلاسل النصية المرمزة بترميز UTF-8 والمخزنة في مكان آخر. على سبيل المثال، تُخزَّن السلاسل النصية المجرّدة string literals في ملف البرنامج الثنائي وبالتالي فهي شرائح سلسلة نصية.
النوع String
الموجود في مكتبة رست القياسية بدلًا من وجوده في أساس اللغة، هو نوع سلسلة نصية قابل للتعديل mutable وللنمو growable والامتلاك owned ومُرمَّز بترميز UTF-8. عندما يقول مبرمجو لغة رست "سلسلة نصية" في رست فهم يقصدون إما النوع String
أو أنواع شريحة السلسلة النصية &str
ولا يقتصر الأمر على واحد من النوعَين. على الرغم من أن هذا المقال يتكلم خاصةً على النوع String
إلا أن كلا النوعين مُستخدمان جدًا في مكتبة رست القياسية، وكلٌ من النوع String
وشريحة السلسلة النصية مُرمّزَان بترميز UTF-8.
إنشاء سلسلة نصية جديدة
الكثير من العمليات المتاحة في النوع Vec<T>
هي عمليات متاحة في النوع String
أيضًا، وذلك لأن النوع String
هو تطبيق لمُغلَّف wrapper حول شعاع من البايتات، إضافةً إلى بعض الضمانات والقيود والإمكانات الأخرى. الدالة new
هي مثال على دالة تعمل بنفس الطريقة على Vec<T>
وعلى String
سويًا لإنشاء نسخة instance كما هو موضح في الشيفرة 11.
let mut s = String::new();
الشيفرة 11: إنشاء نسخة جديدة وفارغة من النوع String
يُنشئ السطر السابق سلسلةً نصيةً جديدةً وفارغة بالاسم s
، وبالتالي يمكننا الآن إضافة البيانات إليها، وسنحتاج غالبًا في البداية إلى بيانات تهيئة لتخزينها في السلسلة النصية، ونستخدم لهذا الغرض التابع to_string
؛ وهو تابع متاح في أي نوع يطبّق السمة Display
وهو ما تفعله السلسلة النصية المجردة، توضح الشيفرة 12 مثالين على ذلك.
let data = "initial contents"; let s = data.to_string(); //تعمل هذه الدالة على السلاسل النصية المجردة مباشرةً let s = "initial contents".to_string();
الشيفرة 12: استخدام التابع to_string لإنشاء النوع String من سلسلة نصية مجردة
تُنشئ الشيفرة البرمجية السابقة سلسلة نصية تحتوي على بيانات تهيئة initial contents
.
يمكننا أيضًا استخدام الدالة String::from
لإنشاء النوع String
من سلسلة نصية مجردة، والشيفرة 13 مكافئة للشيفرة 12 التي تستخدم التابع to_string
.
let s = String::from("initial contents");
الشيفرة 13: استخدام الدالة String::from لإنشاء النوع String من سلسلة نصية مجردة
بما أن السلاسل النصية تُستخدم للعديد من الأشياء، يمكننا استخدام عدة واجهات برمجية عامة لها، إذ تزودنا هذه الواجهات بكثيرٍ من الخيارات، وقد يبدو بعضها مكرّرًا إلا أن جميعها تؤدي غرضًا ما. في هذه الحالة، يؤدي كلًا من String::from
و to_string
الغرض ذاته، واستخدام أي منهما يعود إلى أسلوبك في كتابة الشيفرات البرمجية.
تذكر أن السلاسل النصية تستخدم ترميز UTF-8 وهذا يعني أنه يمكننا تضمين أي بيانات بهذا الترميز، ألقِ نظؤةً على الشيفرة 14.
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");
الشيفرة 14: تخزين رسالة تحية بلغات مختلفة في سلاسل نصية
تحتوي جميع السلاسل النصية السابقة قيمًا صالحة من النوع String
.
تحديث سلسلة نصية
يمكن أن يكبر حجم النوع String
وأن تتغير محتوياته بصورةٍ مشابهة للنوع Vec<T>
عند إضافة مزيدٍ من البيانات إليه، ويمكنك إضافةً إلى ذلك استخدام العامل +
أو الماكرو format!
لضمّ concatenate عدّة قيم من النوع String
.
إضافة قيم إلى السلسلة النصية باستخدام push_str و push
يمكننا توسعة حجم النوع String
باستخدام التابع push_str
لإضافة شريحة سلسلة نصية كما هو موضح في الشيفرة 15.
let mut s = String::from("foo"); s.push_str("bar");
الشيفرة 15: إضافة شريحة سلسلة نصية إلى النوع String باستخدام التابع push_str
ستحتوي السلسلة النصية s
-بعد السطرين البرمجيين السابقين- على foobar
، إذ يأخذ التابع push_str
شريحة سلسلة نصية لأننا لسنا بحاجة لأخذ ملكية المعامل، على سبيل المثال نريد في الشيفرة 16 أن نكون قادرين على استخدام s2
بعد إضافة محتوياته إلى s1
.
let mut s1 = String::from("foo"); let s2 = "bar"; s1.push_str(s2); println!("s2 is {}", s2);
الشيفرة 16: استخدام شريحة سلسلة نصية بعد إضافة محتوياتها إلى النوع String
إذا أخذ التابع push_str
ملكية s2
، فلن نكون قادرين على طباعة قيمتها في السطر الأخير، إلا أن الشيفرة البرمجية هنا تعمل كما هو متوقع منها.
يأخذ التابع push
محرفًا وحيدًا مثل معامل ويُضيفه إلى النوع String
، ونُضيف في الشيفرة 17 الحرف "I" إلى النوع String
باستخدام التابع push
.
let mut s = String::from("lo"); s.push('l');
الشيفرة 17: إضافة محرف واحد إلى قيمة من النوع String باستخدام push
ستحتوي السلسلة النصية s
نتيجةً لما سبق على lol
.
ضم السلاسل النصية باستخدام العامل + أو الماكرو format!
ستحتاج غالبًا إلى ضم سلسلتين نصيتين، وإحدى الطرق لتحقيق ذلك هو باستخدام العامل +
كما هو موضح في الشيفرة 18.
fn main() { let s1 = String::from("Hello, "); let s2 = String::from("world!"); let s3 = s1 + &s2; // لاحظ أن s1 نُقلَت إلى هنا ولا يمكن استخدامها بعد الآن }
الشيفرة 18: استخدام العامل + لضم قيمتين من النوع String إلى قيمة أخرى جديدة من النوع String
ستحتوي السلسلة النصية s3
بعد تنفيذ الشيفرة السابقة على "Hello, world!"، والسبب في عدم كون s1
صالحة بعد عملية الجمع إلى استخدامنا مرجع إلى s2
يعود إلى بصمة التابع method signature الذي استدعيناه عندما استخدمنا العامل +
، إذ يستخدم هذا العامل التابع add
وتبدو بصمته كما يلي:
fn add(self, s: &str) -> String {
ستجد التابع add
معرفًا في المكتبة القياسية باستخدام الأنواع المعمّاة generics والأنواع المترابطة associated types، إلا أننا استبدلنا هذه الأنواع بأنواع فعلية وهو ما يحدث عند استدعاء هذا التابع بقيمٍ من النوع String
(سنناقش الأنواع المعمّاة لاحقًا)، تعطينا بصمة التابع أدلة يجب علينا فهمها لفهم العامل +
.
أولًا، لدى s2
الرمز &
أي أننا نُضيف مرجعًا للسلسلة النصية الثانية إلى السلسلة النصية الأولى، وهذا بسبب المعامل s
في الدالة، إذ يمكننا جمع &str
إلى النوع String
فقط وليس بإمكاننا إضافة قيمتين من النوع String
سويًا، ولكن تمهّل، نوع &s2
هو &String
وليس &str
كما هو موضح في المعامل الثاني للتابع add
. إذًا، لمَ تُصرَّف الشيفرة 18 بنجاح؟
السبب في كوننا قادرين على استخدام &s2
في استدعاء add
هو أن المصرف هنا قادرٌ على تحويل الوسيط &String
قسريًا إلى &str
، وعند استدعاء للتابع add
، تستخدم رست التحصيل القسري deref corecion الذي يحوّل &s2
إلى &s2[..]
(سنناقش التحصيل القسري بمزيدٍ من التفاصيل لاحقًا)، ولأن add
لا يأخذ ملكية المعامل s
السلسلة s2
، ستظل قيمةً صالحةً من النوع String
بعد هذه العملية.
ثانيًا، يمكننا في بصمة التابع رؤية أن add
يأخذ ملكية self
، لأن self
لا تحتوي على الرمز &
، وهذا يعني أن السلسلة s1
في الشيفرة 18 ستُنقل إلى استدعاء add
ولن تصبح قيمةً صالحةً بعد ذلك، لذلك يبدو السطر البرمجي let s3 = s1 + &s2;
بأنه ينسخ كلا السلسلتين النصيتين ويُنشئ سلسلةً جديدةً، إلا أنه في الحقيقة يأخذ ملكية s1
ويُسند نسخةً من محتويات s2
إلى نهايتها ومن ثم يُعيد ملكية النتيجة؛ أي بكلمات أخرى يبدو أن السطر البرمجي يُنشئ كثيرًا من النُسخ إلا أنه في حقيقة الأمر لا يفعل ذلك، وهذا التطبيق فعال أكثر من النسخ.
يصبح سلوك العامل +
غير عملي في حال أردنا ضمّ عدة سلاسل نصية في نفس الوقت:
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!
لعمليات ضم السلاسل النصية الأكثر تعقيدًا:
let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); let s = format!("{}-{}-{}", s1, s2, s3);
نحصل على القيمة "tic-tac-toe" في السلسلة s
أيضًا بتنفيذ الشيفرة السابقة، إذ يعمل الماكرو format!
على نحوٍ مماثل لعمل الماكرو println!
إلا أنه يُعيد قيمةً من النوع String
بدلاً من طباعة الخرج على الشاشة. نلاحظ أن الشيفرة البرمجية التي تستخدم الماكرو format!
أسهل قراءةً من سابقتها. تستخدم الشيفرة المولّدة عن طريق استخدام الماكرو format!
المراجع، لذلك لن يتسبب استدعائها بأخذ ملكية أي من معاملاتها.
الحصول على محتويات السلسلة النصية باستخدام الدليل
يمكننا الوصول إلى محارف السلسلة النصية في العديد من لغات البرمجة باستخدام دليلها index، وهي عمليةٌ صالحةٌ وشائعة في هذه اللغات، إلا أنك ستحصل على خطأ في رست إذا حاولت الوصول إلى أجزاء من النوع String
باستخدام نفس الأسلوب. ألقِ نظرةً على الشيفرة البرمجية غير الصالحة في الشيفرة 19.
let s1 = String::from("hello"); let h = s1[0];
الشيفرة 19: محاولة الوصول إلى أجزاء من السلسلة النصية باستخدام دليلها
ستتسبب الشيفرة البرمجية السابقة بظهور الخطأ التالي:
$ 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>>> <str as Index<I>> For more information about this error, try `rustc --explain E0277`. error: could not compile `collections` due to previous error
تخبرنا رسالة الخطأ بالآتي: لا تدعم السلاسل النصية في رست الفهرسة indexing، ولكن لماذا؟ للإجابة على هذا السؤال دعنا نناقش كيف تخزّن رست السلاسل النصية في الذاكرة.
التمثيل الداخلي
النوع String
في الحقيقة هو مغلّف حول النوع Vec<u8>
، دعنا نلقي نظرةً على السلسلة النصية التالية المرمزة بترميز UTF-8 من الشيفرة 14:
let hello = String::from("Hola");
طول السلسلة النصية len
في هذه الحالة هو 4، ما يعني أن الشعاع الذي يخزن السلسلة النصية "Hola" هو بطول 4 بايتات، إذ يأخذ كل حرف من هذه الأحرف 1 بايت عند ترميزه بترميز UTF-8، إلا أن السطر التالي قد يفاجئك (لاحظ أن السلسلة النصية التالية تبدأ بالحرف السيريلي زي Cyrillic letter Ze وليس العدد العربي 3).
let hello = String::from("Здравствуйте");
إذا سألناك عن طول السلسلة النصية ستجيب غالبًا 12، إلا أن رست ستجيبك بالقيمة 24 وهو رقم البايتات المطلوبة لترميز السلسلة النصية "Здравствуйте" بترميز UTF-8 وذلك لأن كل محرف يونيكود unicode يأخذ 2 بايت للتخزين، ولذلك استخدام دليل إلى بايت السلسلة النصية لن يعمل دومًا، ولتوضيح ذلك ألقِ نظرةً على شيفرة رست التالية غير الصالحة:
let hello = "Здравствуйте"; let answer = &hello[0];
أنت تعلم مسبقًا أن قيمة answer
لن تكون الحرف الأول З
، إذ تكون قيمة البايت الأول من الحرف 3
عند ترميز السلسلة النصية بترميز UTF-8 هي 208
والثاني قيمته 151 وبالتالي ستكون قيمة answer
هي 208
إلا أن القيمة 208 ليست بقيمة صالحة لمحرف، وإعادة القيمة 208 ليست التصرف الذي يترقبه المستخدم غالبًا عند سؤاله عن المحرف الأول من السلسلة النصية، إلا أنها القيمة الوحيدة الموجودة في الدليل 0. لا يريد المستخدمون عادةً الحصول على قيمة البايت حتى لو احتوت السلسلة النصية على أحرف لاتينية، فإذا كانت &"hello"[0]
شيفرة برمجية صالحة، فسيعيد ذلك قيمة البايت الممثلة بالقيمة 104 وليس h
.
يكمن الحل هنا بتفادي إعادة قيمة غير متوقعة تتسبب بالأخطاء وقد لا نستطيع اكتشافها مباشرةً، ولذلك لا تسمح لنا رست بتصريف هذه الشيفرة البرمجية بهدف منع سوء التفاهم المستقبلي الذي قد يحصل خلال عملية التطوير.
البايتات والقيم العددية ومجموعات حروف اللغة
نقطة أخرى يجب ذكرها عن ترميز UTF-8، ألا وهو وجود ثلاث طرق مختلفة للنظر إلى السلاسل النصية من منظور لغة رست: مثل بايتات أو قيم عددية scalar values أو مجموعات قيم عددية grapheme clusters (التمثيل الأقرب لما نسميه نحن البشر بالأحرف).
إذا نظرنا إلى الكلمة الهندية "नमस्ते" المكتوبة بالطريقة الديفاناغارية Devanagari، فهي مُخزّنة على أنها شعاع من نوع u8
تبدو قيمه على الشكل التالي:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135]
الشعاع حجمه 18 بايت وهو ما يستخدمه الحاسوب لتخزين هذه البيانات، وإذا أردنا النظر إلى القيم على أنها قيم عددية، التي تمثّل نوع char
في رست فسنحصل على البايتات كما يلي:
['न', 'म', 'स', '्', 'त', 'े']
هناك ست قيم من النوع char
هنا إلا أن المحرف الرابع والسادس لا يمثلان أحرفًا وإنما علامات تشكيل لا تعني أي شيء بمفردهما. أخيرًا، إذا أردنا النظر إلى السلسلة النصية بكونها مجموعات حروف لغة، فسنحصل على تمثيل لما قد ندعوه بأحرف باللغة الهندية:
["न", "म", "स्", "ते"]
تقدم لغة رست طرقًا مختلفة لتمثيل بيانات السلسلة النصية وتخزينها بالحاسوب، بحيث يختار كل برنامج التمثيل الذي يناسبه بغض النظر عن اللغة التي كُتب فيها النص.
السبب الأخير لكون رست لا تسمح بالوصول إلى النوع String
باستخدام الدليل للحصول على محرف معين هو أن عمليات الفهرسة تأخذ تعقيدًا زمنيًا time complexity ثابتًا -مقداره O(1)- إلا أنه ليس من الممكن ضمان ذلك الأداء مع النوع String
لأن رست ستكون بحاجة للنظر إلى محتويات السلسلة النصية من البداية إلى الدليل المُحدّد لتحديد عدد المحارف الصالحة الموجودة.
شرائح السلاسل النصية
لا يُعد استخدام الأدلة مع السلاسل النصية فكرةً جيدةً كما أوضحنا سابقًا، إذ أن القيمة المُعادة من عملية الفهرسة ليست واضحة، فهل ستكون قيمة بايت أم محرف أم مجموعة حروف لغة أم شريحة سلسلة نصية. ستسألك رست أن تكون دقيقًا إذا أردت استخدام الأدلة للحصول على شريحة سلسلة نصية.
بدلًا من استخدام الأقواس []
مع رقم وحيد، يمكنك استخدام الأقواس []
لتحديد نطاق معين يحتوي على الشريحة النصية بعدد محدد من البايتات:
let hello = "Здравствуйте"; let s = &hello[0..4];
ستكون s
هنا هي str&
تحتوي على أول 4 بايتات من السلسلة. ذكرنا سابقًا أن كل حرف من هذه الأحرف هو بحجم 2 بايت، وهذا يعني أن s
ستكون "Зд".
إذا حاولنا تقسيم جزء واحد من بايتات المحرف مثل [hello[0..1&
، ستصاب رست بالهلع أثناء التشغيل بنفس الطريقة التي تحدث عند الوصول إلى دليل غير صالح في شعاعٍ ما، كما هو موضح في الخطأ التالي:
$ 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 `Здравствуйте`', library/core/src/str/mod.rs:127:5 note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
ينبغي استخدام المجالات لإنشاء شرائح سلسلة نصية بحذر، لأن ذلك سيؤدي إلى تعطُّل برنامجك.
توابع التكرار على السلاسل النصية
أفضل طريقة للعمل على قطع pieces من السلاسل النصية هي أن تكون واضحًا فيما إذا كنت تريد أحرفًا أم بايتات، فمن أجل قيم عددية مفردة مُرمزة بالترميز الموحد يونيكود Unicode، استخدم التابع chars
، إذ أن استدعاء هذا التابع على السلسلة "Зд" سيفصل المحرفين ويعيد قيمتان من النوع char
، ويمكنك تكرار النتيجة للوصول إلى كل عنصر لوحده:
#![allow(unused)] fn main() { for c in "Зд".chars() { println!("{}", c); } }
ستطبع الشيفرة البرمجية ما يلي:
З д
يمكننا استخدام التابع bytes
بديلًا عمّا سبق وهو تابع يُعيد كل بايتًا كاملًا، إلا أنه قد يكون غير مناسبًا لاستخدامك:
for b in "Зд".bytes() { println!("{}", b); }
تطبع الشيفرة البرمجية السابقة الخرج التالي:
208 151 208 180
تأكد من تذكرك أن القيمة العددية الصالحة ليونيكود قد تتألف من أكثر من بايت واحد.
الحصول على مجموعة حروف لغة من السلاسل النصية كما حدث في مثال الأحرف الديفاناغارية غير موجود في المكتبة القياسية لأنه معقّد، إلا أن هناك العديد من الصناديق crates crates.io التي تساعدك للحصول على النتيجة المرجوة.
السلاسل النصية ليست بسيطة
لنلخّص ما سبق، السلاسل النصية معقدة، وتتخذ لغات البرمجة المختلفة خيارات مختلفة لتقديم وتبسيط هذا التعقيد للمبرمج، واختارت رست هنا التقيد بالسلوك الموجود للتعامل مع بيانات من النوع String
سلوكًا افتراضيًا لكل برامجها، ما يعني أنه على المبرمج التفكير مليًا عند التعامل مع بيانات بترميز UTF-8، وسلبيات هذا السلوك هو كشف تعقيد السلاسل النصية بوضوح أكثر من لغات البرمجة الأخرى، إلا أن هذا السلوك يُعفيك من الحاجة إلى التعامل مع الأخطاء المتعلقة بالمحارف التي لا تنتمي إلى آسكي ASCII لاحقًا ضمن دورة حياة التطوير.
الخبر الجيد هنا هو أن المكتبة القياسية تقدم العديد من المزايا المبنية على كل من النوعين String
و &str
لمساعدتنا في التعامل مع الحالات المعقدة بصورةٍ صحيحة. ألقِ نطرةً على التوثيق في حال أردت استخدام توابع مفيدة مثل contains
للبحث في سلسلة نصية و replace
لاستبدال أجزاء من السلسلة النصية بسلسلة نصية أخرى.
دعنا ننتقل إلى شيء أقل تعقيدًا، ألا وهو الخرائط المُعمّاة Hash maps.
ترجمة -وبتصرف- لقسم من الفصل Common Collections من كتاب The Rust Programming Language
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.