في المقال السابق Rust للمطورين: الأدوات والأساسيات ثبتنا الأدوات وبنينا مكتبة صغيرة تحسب قيمة الطلب والضريبة لمتجر يبيع القهوة والتمور على الإنترنت. والآن يطلب الفريق الخطوة التالية: المورد يرسل كل صباح ملفاً فيه أسعار جديدة مكتوبة بالريال مثل 9.90، وعلى الكود أن يقرأها ويحولها إلى هللات ويرفض أي سعر غير صالح ويحدث قائمة المنتجات. وهي مهمة تكتبها بلغة Python في نصف ساعة، ولكنك في Rust تقضي أول ساعة منها أمام رسائل لم ترها في حياتك: borrow of moved value، وcannot borrow as mutable because it is also borrowed as immutable، وmissing lifetime specifier. وكل زميل جاء إلى Rust من لغة أخرى مر بهذه الساعة، وأغلبهم يسميها «مصارعة مدقق الاستعارة» Fighting the Borrow Checker.
وهذه الساعة تقصر كثيراً عندما تفهم ما الذي يحاول المترجم حمايتك منه، وهذا موضوع المقال. وقد شرحنا في مقال إدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership لماذا اختارت Rust الملكية بدلاً من جامع القمامة، وقارناها بالأرقام مع C وGo وPython، فلن نعيد المقارنة هنا. وإنما نأخذ زاوية من يكتب Rust كل يوم: ما الأنواع التي سوف تستخدمها وكم تأخذ من الذاكرة، ومتى تنسخ البيانات ومتى تنقل ومتى تستعار، وما معنى كل رسالة من رسائل المترجم وكيف تصلحها، ثم كيف تتعامل اللغة مع الأخطاء، من Result إلى التوقف Panic، بتجارب حقيقية فاشلة مع رموز الخروج.
وسوف نناقش في هذا المقال ما يلي:
- نظام الأنواع في Rust: الأرقام بأحجامها، والنصوص
Stringو&strمع العربية، وكم يأخذ كل نوع من الذاكرة بالبايت. - الملكية عملياً: النقل Move والنسخ Copy والاستنساخ Clone، ومتى تحرر الذاكرة، بقياس حقيقي للذاكرة وعدد الحجوزات.
- الاستعارة Borrowing وقاعدتها الوحيدة، وستة أخطاء ترجمة حقيقية وإصلاح كل منها.
- الأعمار Lifetimes: ما الذي يسأل عنه المترجم عندما يطلبها.
- عداد حجوزات نكتبه بأنفسنا لنرى أين تذهب الذاكرة: النسخ في كل استدعاء، ونمو القائمة
Vec. - الأخطاء:
Resultونوع خطأ خاص والعلامة?، والتوقف Panic ورموز الخروج 0 و1 و101، وقراءة ال Backtrace. - جدول للمؤشرات الذكية، والأخطاء الشائعة، وقائمة تحقق.
تجهيز المختبر Lab Setup
كل التجارب تعمل في Container من الـ Image الرسمي rust:1.99.1-slim-trixie بعد أن أضفنا إليه rustfmt وClippy كما في المقال السابق، والمترجم Rust 1.99.1. والمختبر جزآن: ملفات صغيرة في lab/fails/ كل منها يحتوي خطأ واحداً نترجمه بالأمر rustc مباشرة لنرى رسالته، وإصلاحها في lab/ok/، ومشروع Cargo اسمه mem فيه أربعة برامج: sizes لأحجام الأنواع، وallocs لعد الحجوزات، وscope لقياس الذاكرة عند التحرير، وerrors للأخطاء. والسكربت lab/run.sh يشغلها كلها، والمخرج الكامل في lab/run-output.txt:
docker run --rm -v "$PWD/lab":/lab -e CARGO_TARGET_DIR=/target slr-rs-dev:1 sh /lab/run.shنظام الأنواع Type System: كل شيء له حجم معروف
إذا جئت من Python أو JavaScript فأنت معتاد أن الرقم رقم، وأن النص نص، ولا تعرف ولا تهتم كم بايت يأخذ. وإذا جئت من Java فتعرف أن int أربعة بايتات، ولكن كل شيء غير الأرقام كائن في ال Heap خلف مؤشر. أما في Rust فكل نوع له حجم معروف وقت الترجمة، والمترجم يضع القيم في ال Stack مباشرة إلا إذا طلبت غير ذلك، ولهذا يهمك أن تعرف الأحجام. والبرنامج sizes يطبع حجم كل نوع بالدالة std::mem::size_of:
u8 1 bytes
i32 4 bytes
i64 8 bytes
char 4 bytes
bool 1 bytes
f64 8 bytes
usize 8 bytes
&str 16 bytes
String 24 bytes
Vec<u8> 24 bytes
Box<u8> 8 bytes
&[u8] 16 bytes
Box<[u8]> 16 bytesفي المخرج أعلاه لاحظ التالي:
- الأرقام تكتب حجمها في اسمها:
i32رقم بإشارة من 32 بت، وu8بلا إشارة من 8 بت، وهكذا حتىi128. والنوعusizeبحجم المؤشر على الجهاز، أي 8 بايتات على 64 بت، وهو النوع الذي تستخدمه Rust للفهارس والأطوال، ولهذا تكثر التحويلاتas usizeفي الكود. - الحرف
charأربعة بايتات وليس بايتاً واحداً كما في C، لأنه يحمل أي حرف Unicode كاملاً، بما فيها الحروف العربية والرموز التعبيرية. - النص
StringوالقائمةVec24 بايتاً مهما كان حجم المحتوى، لأن الذي في ال Stack ثلاثة أرقام فقط: مؤشر إلى البيانات في ال Heap، والطول، والسعة. والبيانات نفسها في ال Heap. - المرجع
&strوالشريحة&[u8]16 بايتاً، أي مؤشر وطول، ويسمى هذا المؤشر السمين Fat Pointer، لأن المرجع يحمل معه طول ما يشير إليه، ولهذا لا تحتاج Rust إلى حرف نهاية النص\0كما في C، ولا يمكن أن تقرأ خارج حدود الشريحة.
النصوص String مقابل &str: لماذا نوعان؟
أكثر ما يربك القادم الجديد أن في Rust نوعين للنص. والفرق هو الفرق بين المالك والمستعير: String نص تملكه، في ال Heap، يمكنك تكبيره وتعديله، ويحرر عندما ينتهي صاحبه، و&str نافذة للقراءة على نص يملكه غيرك، قد يكون String في مكان آخر، أو نصاً ثابتاً مكتوباً في الكود ومحفوظاً داخل الملف التنفيذي نفسه. ولهذا القاعدة العملية بسيطة: الدالة التي تقرأ النص فقط تأخذ &str، والهيكل الذي يحتفظ بالنص يملكه في حقل String. وسوف نرى بعد قليل بالأرقام ماذا يكلف خرق هذه القاعدة.
والنصوص في Rust دائماً UTF-8، وهذا يهمك مع العربية مباشرة:
"قهوة عربية": len() = 19 bytes, chars().count() = 10
"coffee": len() = 6 bytes, chars().count() = 6الدالة len() ترجع عدد البايتات لا عدد الحروف، والحرف العربي بايتان في UTF-8، فكلمتا «قهوة عربية» تسعة حروف ومسافة، أي 19 بايتاً و10 حروف. ولهذا إذا قصصت نصاً عربياً بالفهرس، مثل &name[0..3]، فقد تقع في منتصف حرف، والنتيجة توقف البرنامج كما سوف نرى في مقال الأخطاء الشائعة. والطريقة الصحيحة أن تمر على الحروف بالدالة chars()، أو أن تبحث عن حدود صحيحة. وهذا الفرق موجود في كل لغة تقريباً، ولكن Python يخفيه عنك لأن النص فيه سلسلة حروف لا بايتات، وJava تخفي نصفه لأن النص فيها UTF-16.
التعدادات Enums التي لا تكلف شيئاً: Niche Optimization
بقية مخرج البرنامج نفسه يجيب عن سؤال قد يخطر لك: إذا كان Option تعداداً يحمل قيمة أو لا شيء، فهل يكلف ذاكرة إضافية في كل مكان؟
Option<Box<u8>> 8 bytes
Option<&str> 16 bytes
Option<String> 24 bytes
Option<u32> 8 bytes
Option<NonZeroU32> 4 bytes
Status 1 bytes
Payment 24 bytes
Product 40 bytes
Option<Product> 40 bytes
Result<u32, String> 24 bytesOption<Box<u8>>بنفس حجمBoxوحده، 8 بايتات. والسبب أن المؤشر في Rust الآمنة لا يمكن أن يكون صفراً، فيستخدم المترجم القيمة صفر لتمثيلNoneدون أي بايت إضافي، وهذا اسمه تحسين الفراغ Niche Optimization. والنتيجة أنOption<Box<T>>في الذاكرة هو بالضبط المؤشر الذي قد يكونNULLفي C، ولكن المترجم يجبرك على فحصه قبل الاستخدام.- ونفس الشيء مع
&strوStringوحتى الهيكلProductالذي فيهString، فكلها بنفس حجمها داخلOption. - أما
Option<u32>فثمانية بايتات بدلاً من أربعة، لأن كل قيمu32صالحة، ولا توجد قيمة فارغة يستعملها المترجم، فيضيف بايتاً للحالة ثم يكمل للمحاذاة Alignment. وإذا كان الصفر مستحيلاً في بياناتك، مثل رقم منتج يبدأ من 1، فالنوعNonZeroU32يرجع الحجم إلى أربعة. - التعداد
Statusبثلاث حالات بلا بيانات بايت واحد، والتعدادPaymentالذي إحدى حالاته تحملString24 بايتاً، أي حجم أكبر حالة، لأن قيمة التعداد يجب أن تتسع لأي حالة منها.
والفكرة العامة التي تخرج بها من هذه الأرقام أن Rust تسمي هذا تجريداً بلا ثمن Zero-Cost Abstraction: الأنواع الآمنة مثل Option والمراجع بطولها لا تكلف في أغلب الحالات أكثر من الكود الخطر الذي كنت ستكتبه بيدك في C.
الأنواع العامة Generics: نسخة من الكود لكل نوع
بقي في نظام الأنواع شيء سوف تقابله في كل مكتبة تقريباً، وهو الأنواع العامة Generics، مثل Vec<T> وOption<T> وResult<T, E> التي رأيتها للتو. والفكرة مألوفة لمن كتب Java أو C# أو TypeScript، ولكن التنفيذ مختلف تماماً. ففي Java تمحى الأنواع العامة بعد الترجمة Type Erasure، فتصبح List<Integer> وList<String> نفس الكلاس، وكل عنصر كائن خلف مؤشر، ولهذا لا يمكنك أن تضع int في قائمة عامة دون تغليفه في Integer. أما Rust فتولد نسخة كاملة من الكود لكل نوع تستخدمه، وهذا ما رأيته في المقال السابق باسم Monomorphization، فالقائمة Vec<i64> فيها أرقام متجاورة في الذاكرة دون أي تغليف، والدالة العامة تترجم مرة لكل نوع فتعمل بسرعة الدالة المكتوبة لذلك النوع وحده. والثمن ملف تنفيذي أكبر ووقت ترجمة أطول، وهذا جزء من سبب بطء البناء الذي قسناه هناك. والقيود على النوع العام تكتب بالسمات، مثل fn total<T: Priced>(items: &[T])، أي «أي نوع ينفذ السمة Priced»، والمترجم يرفض الاستدعاء بنوع لا ينفذها برسالة تذكر السمة الناقصة، بدلاً من خطأ غامض داخل القالب كما في C++. وإذا أردت قائمة تجمع أنواعاً مختلفة في نفس الوقت، فلا يكفي النوع العام، وإنما تحتاج Box<dyn Priced> التي تستدعي الدالة عبر جدول دوال وقت التشغيل، مثل الواجهات في Java، وحجمها 16 بايتاً: مؤشر للبيانات ومؤشر للجدول.

الملكية Ownership عملياً: من يحرر الذاكرة ومتى؟
القواعد كما في فصل الملكية في كتاب Rust ثلاث: لكل قيمة مالك واحد، وفي كل لحظة مالك واحد فقط، وعندما يخرج المالك من نطاقه تحرر القيمة. وقد رأينا في مقال إدارة الذاكرة ترتيب التحرير والخطأ E0382 مختصراً، وهنا نريد أن نرى ماذا يعني ذلك للذاكرة الحقيقية وللبيانات الكبيرة. والبرنامج scope ينشئ تقريراً فيه 400 MB من البيانات، ويطبع ذاكرة العملية RSS من الملف /proc/self/statm في كل خطوة، وكل تقرير يطبع رسالة عند تحريره:
struct Report {
name: &'static str,
data: Vec<u8>,
}
impl Drop for Report {
fn drop(&mut self) {
println!(" drop({}) frees {} MB", self.name, self.data.len() >> 20);
}
}start: RSS 1208 KB
inside scope: RSS 411580 KB (sum 419430400)
drop(daily) frees 400 MB
after scope: RSS 1988 KB
drop(monthly) frees 400 MB
after drop(r): RSS 1988 KB (sum 419430400)
after move: RSS 411380 KB (len 419430400, move took 200ns)
after clone: RSS 821092 KB (len 419430400, clone took 915.28427ms)
end of main
drop(yearly) frees 400 MBفي المخرج أعلاه لاحظ التالي:
- داخل النطاق صعدت الذاكرة إلى 411 MB، ولحظة انتهاء النطاق ظهرت رسالة التحرير، ونزلت الذاكرة إلى أقل من 2 MB في السطر التالي مباشرة. لا يوجد جامع قمامة ينتظر دوره، ولا سطر
freeكتبناه، فالمترجم وضع استدعاء التحرير عند القوس المغلق بنفسه. - الذاكرة عادت إلى النظام فعلاً ولم تبق في البرنامج، والسبب ليس Rust وإنما مكتبة C تحتها، فالقطع الكبيرة تحجز بالطلب
mmapوتعاد للنواة عند تحريرها، كما شرحنا في قسم «لماذا لم تنزل الذاكرة بعد free» من مقال إدارة الذاكرة. - التقرير الثاني
monthlyلم ننتظر نهايةmainلتحريره، وإنما استدعيناdrop(r)بعد أن انتهينا منه. والدالةdropليست سحراً، فهي دالة فارغة تأخذ الملكية، فتنتهي القيمة عند نهايتها. وهذا مفيد قبل عمل طويل، حتى لا تبقى 400 MB محجوزة دون حاجة حتى نهاية الدالة. - نقل التقرير الثالث إلى متغير جديد
let moved = keptأخذ 200 نانو ثانية، ولم تتغير الذاكرة، لأن النقل Move ينسخ الكلمات الثلاث في ال Stack، المؤشر والطول والسعة، ولا يلمس ال 400 MB. أما الاستنساخclone()فأخذ 915 ملي ثانية وضاعف الذاكرة إلى 821 MB، لأنه نسخ البيانات كلها فعلاً. - التقرير الثالث حرر بعد السطر
end of main، أي عند نهاية الدالة الرئيسية، لأنه بقي حياً حتى هناك.

وهذا الفرق بين 200 نانو ثانية و915 ملي ثانية هو كل القصة: في Python وJava يعني السطر b = a أن المتغيرين يشيران إلى نفس الكائن، فلا ينسخ شيء ولكن يصبح للكائن مالكان، ويحتاج أحد ما، الجامع أو العداد، أن يعرف متى ينتهي آخرهما. وفي C++ قد يعني نفس السطر نسخاً كاملاً مكلفاً دون أن تنتبه. أما Rust فتختار طريقاً ثالثاً: السطر ينقل الملكية بثمن ثلاث كلمات، والمتغير القديم لا يعود صالحاً، والنسخ الكامل لا يحدث إلا إذا كتبت clone() صراحة، فتراه في الكود وفي المراجعة.
النسخ Copy مقابل الاستنساخ Clone
ولكن لماذا لا يشتكي المترجم عندما تكتب let b = a وكلاهما رقم i32، ثم تستخدم a؟ لأن الأنواع الصغيرة التي لا تملك موارد خارج نفسها، كالأرقام وbool وchar والمراجع المشتركة &T، تنفذ السمة Copy، أي أن نسخ البايتات يكفي لإنشاء قيمة مستقلة، فيكون السطر نسخاً لا نقلاً، ويبقى الاثنان صالحين. أما النوع الذي يملك ذاكرة في ال Heap مثل String وVec فلا يمكن أن يكون Copy، لأن نسخ المؤشر يعني مالكين لنفس الذاكرة، فيحرر كل منهما نفس القطعة، وهذا هو التحرير مرتين Double Free بعينه. ولهذا يحتاج هذا النوع Clone، وهو نسخ صريح عميق تطلبه بنفسك. وتستطيع جعل هياكلك الصغيرة Copy بالسطر #[derive(Clone, Copy)] إذا كانت كل حقولها Copy.
الخطأ الأول: الاستخدام بعد النقل Use After Move والرمز E0382
fn archive(order: String) -> usize {
order.len()
}
fn main() {
let order = String::from("order 17: 3 x coffee");
archive(order);
println!("still have: {order}");
}error[E0382]: borrow of moved value: `order`
--> /lab/fails/e0382_move.rs:8:28
|
6 | let order = String::from("order 17: 3 x coffee");
| ----- move occurs because `order` has type `String`, which does not implement the `Copy` trait
7 | archive(order);
| ----- value moved here
8 | println!("still have: {order}");
| ^^^^^ value borrowed here after move
|
note: consider changing this parameter type in function `archive` to borrow instead if owning the value isn't necessary
--> /lab/fails/e0382_move.rs:1:19
|
1 | fn archive(order: String) -> usize {
| ------- ^^^^^^ this parameter takes ownership of the value
help: consider cloning the value if the performance cost is acceptable
|
7 | archive(order.clone());
| ++++++++اقرأ الرسالة من الأعلى كقصة من ثلاثة أسطر: السطر 6 أنشأ القيمة، والسطر 7 نقلها إلى الدالة، والسطر 8 حاول استخدامها بعد النقل. والجملة does not implement the `Copy` trait هي السبب الذي شرحناه للتو. ثم يقترح المترجم حلين بالترتيب الصحيح: الأول أن تجعل الدالة تستعير بدلاً من أن تملك، أي fn archive(order: &str)، وهذا هو الحل الأنسب في أغلب الحالات لأن الدالة تقرأ الطول فقط. والثاني الاستنساخ «إذا كان الثمن مقبولاً»، وهذه العبارة مقصودة، فالمترجم نفسه يذكرك أن clone ليس مجانياً. والخطأ الشائع عند القادمين الجدد أن يأخذوا الحل الثاني دائماً لأنه أسرع في إسكات المترجم، وسوف نرى ثمنه بالأرقام في قسم الحجوزات.
الاستعارة Borrowing: قارئون كثيرون أو كاتب واحد
الاستعارة هي أن تعطي دالة أو متغيراً حق الوصول إلى قيمة دون أن تنقل ملكيتها، بالرمز & للقراءة و&mut للتعديل. وكل قواعد مدقق الاستعارة Borrow Checker ترجع إلى قاعدة واحدة، كما في فصل المراجع والاستعارة: في أي لحظة يمكن أن يكون للقيمة عدد من المراجع للقراءة، أو مرجع واحد للتعديل، ولا يجتمع الاثنان. والقاعدة الثانية أن المرجع لا يمكن أن يعيش أطول من القيمة التي يشير إليها. وهذه القاعدة هي نفسها قاعدة قفل القراءة والكتابة Read-Write Lock، ولكن يتحقق منها المترجم وقت الترجمة بدلاً من أن تتحقق منها وقت التشغيل. ولماذا تهتم Rust بهذا إلى هذا الحد؟ لأن أغلب أخطاء الذاكرة وأخطاء التزامن تأتي من اجتماع كاتب مع قارئ على نفس البيانات، والمثال التالي يبين ذلك.
الخطأ الثاني: تعديل قائمة وأنت تقرأ منها Iterator Invalidation والرمز E0502
fn main() {
let mut prices = vec![990, 4500, 12000];
let first = &prices[0]; // a shared borrow into the vector
prices.push(800); // may reallocate and move every element
println!("first price: {first}");
}error[E0502]: cannot borrow `prices` as mutable because it is also borrowed as immutable
--> /lab/fails/e0502_push.rs:4:5
|
3 | let first = &prices[0]; // a shared borrow into the vector
| ------ immutable borrow occurs here
4 | prices.push(800); // may reallocate and move every element
| ^^^^^^^^^^^^^^^^ mutable borrow occurs here
5 | println!("first price: {first}");
| ----- immutable borrow later used hereقد يبدو لك هذا البرنامج بريئاً تماماً، فأنت تقرأ السعر الأول ثم تضيف سعراً ثم تطبع الأول، فما المشكلة؟ والمشكلة في ما يحدث داخل push: إذا كانت سعة القائمة ممتلئة فسوف تحجز مكاناً أكبر في ال Heap، وتنقل كل العناصر إليه، وتحرر المكان القديم، فيصبح المرجع first مشيراً إلى ذاكرة محررة. وهذا هو بالضبط الخطأ الذي يسمى في C++ إبطال المكرر Iterator Invalidation، وهو استخدام بعد التحرير Use After Free صامت، والذي يظهر في Java كاستثناء ConcurrentModificationException وقت التشغيل إذا عدلت قائمة أثناء المرور عليها. وفي Rust لا يصل إلى التشغيل أصلاً، لأن السطر 3 أخذ مرجع قراءة، والسطر 4 يحتاج مرجع تعديل، والسطر 5 يثبت أن مرجع القراءة ما زال حياً. وسوف نرى بعد قليل بعداد الحجوزات أن القائمة تعيد الحجز فعلاً 16 مرة عند إضافة مئة ألف عنصر.
والإصلاح يعتمد على ما تريده. إذا كنت تريد قيمة السعر فقط، والرقم Copy، فخذ نسخة منه لا مرجعاً إليه، والبرنامج في lab/ok/push_fixed.rs:
let first = prices[0]; // i32 is Copy: we keep a copy, not a borrow
prices.push(800);first price: 990, now 4 pricesأو أن تعيد ترتيب الكود فتنتهي من القراءة قبل التعديل. ولاحظ أن الإصلاح الأول لا يصلح مع Vec<String>، لأن النص ليس Copy، وهناك ستحتاج clone() أو ترتيباً آخر للكود.
الخطأ الثالث: مرجعان للتعديل Mutable Aliasing والرمز E0499
error[E0499]: cannot borrow `stock` as mutable more than once at a time
--> /lab/fails/e0499_two_mut.rs:4:13
|
3 | let a = &mut stock;
| ---------- first mutable borrow occurs here
4 | let b = &mut stock;
| ^^^^^^^^^^ second mutable borrow occurs here
5 | a[0] -= 1;
| - first borrow later used hereمرجعان للتعديل على نفس المخزون، وكلاهما يستخدم بعد إنشاء الآخر، وهذه القاعدة هي التي تجعل سباق البيانات Data Race مستحيلاً في Rust الآمنة، لأن سباق البيانات تعريفه كاتبان أو كاتب وقارئ على نفس الذاكرة في نفس الوقت، كما سوف نرى في مقال التزامن. ولكن انظر إلى الإصلاح في lab/ok/nll.rs، فهو يبين شيئاً مهماً عن ذكاء المدقق:
let mut stock = vec![10, 25, 3];
let a = &mut stock;
a[0] -= 1; // the last use of a: the borrow ends here
let b = &mut stock;
b[0] -= 1;
println!("{stock:?}");[8, 25, 3]نفس المرجعين، ولكن آخر استخدام للمرجع a قبل إنشاء b، فيقبله المترجم. والسبب أن المدقق منذ إصدار 2018 لا يعتبر المرجع حياً حتى نهاية النطاق، وإنما حتى آخر سطر يستخدم فيه، وهذا اسمه الأعمار غير المعجمية Non-Lexical Lifetimes. فإذا قرأت عن قيود الاستعارة في مقال قديم ووجدت أنها أشد مما تراه، فهذا هو السبب، والمدقق اليوم يقبل كثيراً من البرامج التي كان يرفضها.

الأعمار Lifetimes: سؤال المترجم عن عمر المرجع
كلمة الأعمار تخيف أغلب من يقرأ عنها، والحقيقة أنها ليست شيئاً تفعله، وإنما معلومة تعطيها للمترجم عندما لا يستطيع استنتاجها: «هذا المرجع الذي أرجعه يعيش ما دامت هذه القيمة حية». ولنر الحالات الثلاث التي سوف تقابلك.
الخطأ الرابع: المرجع المعلق Dangling Reference والرمز E0106
fn cheapest_name() -> &str {
let name = String::from("قهوة");
&name
}error[E0106]: missing lifetime specifier
--> /lab/fails/e0106_dangling.rs:1:23
|
1 | fn cheapest_name() -> &str {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`
|
1 | fn cheapest_name() -> &'static str {
| +++++++
help: instead, you are more likely to want to return an owned value
|
1 - fn cheapest_name() -> &str {
1 + fn cheapest_name() -> String {الدالة تنشئ نصاً تملكه، ثم تحاول إرجاع مرجع إليه، والنص سوف يحرر عند نهاية الدالة، فيكون المرجع معلقاً Dangling Reference. وفي C هذا هو إرجاع عنوان متغير محلي، وهو خطأ يترجم بتحذير في أحسن الأحوال. والجملة المهمة في الرسالة there is no value for it to be borrowed from: الدالة لا تأخذ أي مرجع، فمن أين سوف يستعار الناتج؟ والاقتراح الثاني هو الصحيح هنا: أرجع النص نفسه String، أي انقل ملكيته إلى من استدعى الدالة، وهذا لا يكلف إلا الكلمات الثلاث كما رأينا. أما الاقتراح الأول 'static فيعني نصاً يعيش طوال البرنامج، مثل نص مكتوب في الكود، ولا يصلح هنا، والمترجم نفسه يقول إنه غير شائع.
الخطأ الخامس: مرجع يعيش أطول من قيمته Borrowed Value والرمز E0597
error[E0597]: `price` does not live long enough
--> /lab/fails/e0597_short.rs:5:13
|
4 | let price = String::from("9.90");
| ----- binding `price` declared here
5 | r = &price;
| ^^^^^^ borrowed value does not live long enough
6 | }
| - `price` dropped here while still borrowed
7 | println!("{r}");
| - borrow later used hereنفس الخطأ داخل دالة واحدة: المتغير r معرف خارج الكتلة، والنص داخلها، والرسالة ترسم لك عمر كل منهما: النص يحرر عند القوس في السطر 6، والمرجع يستخدم في السطر 7. والإصلاح أن تنقل تعريف النص خارج الكتلة، أو أن تنقل ملكيته إلى r بدلاً من استعارته.
الخطأ السادس: دالة بمرجعين ومرجع راجع Lifetime Elision
fn longer(a: &str, b: &str) -> &str {
if a.len() > b.len() { a } else { b }
}error[E0106]: missing lifetime specifier
--> /lab/fails/e0106_longer.rs:1:32
|
1 | fn longer(a: &str, b: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `a` or `b`
help: consider introducing a named lifetime parameter
|
1 | fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
| ++++ ++ ++ ++هذه الدالة صحيحة تماماً من حيث المنطق، فهي ترجع أحد المعاملين، ولكن المترجم يحتاج أن يعرف عند كل استدعاء كم يعيش الناتج، والتوقيع لا يقول هل الناتج مستعار من a أم من b. ولماذا لم يطلب ذلك في دالة order_total في المقال السابق رغم أنها تأخذ مراجع؟ لأن هناك قواعد حذف Lifetime Elision تغطي أغلب الحالات: إذا كان للدالة مرجع واحد في المدخلات فالناتج مستعار منه، وإذا كانت دالة على كائن &self فالناتج مستعار من الكائن، وorder_total لا ترجع مراجع أصلاً. وفي هذه الحالة مرجعان والقواعد لا تكفي، فنكتب 'a كما اقترح المترجم، ومعناها «الناتج يعيش ما دام المعاملان حيين»:
fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}قهوةولاحظ أن «قهوة» أطول من «تمر» بالبايتات، 8 مقابل 6، وهذا يوافق الحروف هنا بالصدفة فقط. والقاعدة العملية للأعمار أن تبدأ بنقل الملكية String وVec في الهياكل والقيم الراجعة، وألا تكتب مراجع بأعمار في الهياكل إلا عندما يكون للأداء سبب مقاس، لأن هيكلاً يحمل مرجعاً يحمل معه عمر ذلك المرجع إلى كل مكان يذهب إليه.
أين تذهب الذاكرة؟ عداد الحجوزات Allocation Counter
قلنا إن clone() له ثمن، فكم بالضبط؟ لكي نعرف كتبنا عداداً لكل حجز في ال Heap، وهذا سهل في Rust لأن اللغة تسمح لك باستبدال المخصص العام Global Allocator بسطرين. والعداد في lab/mem/src/lib.rs يلف مخصص النظام ويزيد رقمين ذريين مع كل حجز:
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
ALLOCS.fetch_add(1, Relaxed);
BYTES.fetch_add(l.size(), Relaxed);
unsafe { System.alloc(l) }
}
// dealloc passes through; realloc counts like alloc
}
#[global_allocator]
static A: Counting = Counting;وهنا أول مرة ترى فيها الكلمة unsafe، وهي لا تعني أن الكود خطر، وإنما أن المترجم لا يستطيع التحقق من قواعده هنا، فالمبرمج يعد بها بنفسه، وهذا مكانها الطبيعي: في الحد بين Rust وشيء خارجها، مثل مخصص النظام. وكل الكود الآخر في المقال آمن. والبرنامج allocs يقيس خمس حالات:
clone into fn(String): 10000 allocations, 118890 bytes (sum 118890)
borrow into fn(&str): 1 allocations, 1024 bytes (sum 118890)
Vec::new + 100k push: 19 allocations, 2097344 bytes, 16 growths, capacities [0, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536]
with_capacity + 100k push: 1 allocations, 800000 bytes (len 100000)
let t = s (move): 0 allocations, 0 bytes (قهوة)
let u = t.clone(): 1 allocations, 8 bytes (قهوة)في المخرج أعلاه لاحظ التالي:
- السطر الأول هو الحل الذي يسكت المترجم بسرعة: دالة تأخذ
String، ونمرر لهاs.clone()لعشرة آلاف اسم منتج، فكانت النتيجة عشرة آلاف حجز و118890 بايتاً نسخت لتقرأ الدالة الطول فقط ثم تحررها. والسطر الثاني نفس العمل بدالة تأخذ&str، ونفس المجموع 118890، دون أي نسخ. - السطر الثاني فيه حجز واحد من 1024 بايتاً رغم أن الدالة لا تحجز شيئاً، وهذه المشكلة التي واجهتنا في القياس: أول
println!في البرنامج كان بعد القياس الأول مباشرة، فحجزت المكتبة القياسية مخزناً مؤقتاً Buffer للمخرج القياسي بحجم 1024 بايتاً لحظة أول طباعة، وسجله العداد في القياس الثاني. والدرس في أي قياس للحجوزات أن ما تقيسه يشمل كل ما يحدث في البرنامج، لا الكود الذي تفكر فيه فقط. - إضافة مئة ألف رقم إلى قائمة فارغة كبرت القائمة 16 مرة، وكل مرة تضاعفت السعة: 4 ثم 8 ثم 16 حتى 131072، وكل مرة يعني حجزاً جديداً ونقل العناصر كلها، فبلغ مجموع ما حجز 2 MB تقريباً لبيانات حجمها 800 KB. أما مع
Vec::with_capacity(100_000)فحجز واحد بالحجم الصحيح 800000 بايت بالضبط. ولاحظ أن الحجوزات 19 وليست 16، والفرق من القائمةcapsالتي تسجل السعات نفسها، فهي أيضاً كبرت ثلاث مرات. - النقل
let t = sصفر حجوزات، والاستنساخ حجز واحد بثمانية بايتات، وهي بالضبط بايتات «قهوة» الأربعة حروف بحرفين لكل حرف.
والمضاعفة عند كل نمو ليست عشوائية، فهي تجعل ثمن الإضافة الواحدة ثابتاً في المتوسط مهما كبرت القائمة، وهذا ما يسمى التكلفة المستهلكة Amortized O(1)، ولكنها تعني أيضاً أن القائمة قد تحجز ضعف ما تحتاجه في لحظة النمو. والقاعدة العملية: إذا كنت تعرف العدد مسبقاً، مثل عدد سطور ملف المورد، فاحجز السعة من البداية.

المؤشرات الذكية Smart Pointers: متى تحتاج غير المالك الواحد؟
الملكية الواحدة تغطي أغلب الكود، ولكن ليس كله. وعندما تحتاج شيئاً آخر تعطيك المكتبة القياسية أنواعاً تغلف القيمة وتضيف لها سلوكاً، والجدول التالي يجمع ما سوف تقابله، وقد رأينا دوائر Rc وحلها Weak في مقال إدارة الذاكرة:
| النوع | متى تستخدمه | الثمن |
|---|---|---|
Box<T> | قيمة في ال Heap بمالك واحد: نوع حجمه غير معروف مثل Box<dyn Trait>، أو بنية متكررة مثل شجرة | حجز واحد، ولا شيء وقت الاستخدام |
Rc<T> | عدة مالكين في خيط واحد، مثل عقدة يشير إليها أكثر من مكان | عداد يزيد وينقص مع كل نسخة، وخطر الدوائر |
Arc<T> | عدة مالكين عبر خيوط مختلفة | عداد ذري Atomic أغلى قليلاً من Rc |
RefCell<T> | تعديل قيمة من خلال مرجع قراءة، في خيط واحد | قاعدة الاستعارة تفحص وقت التشغيل، والخرق Panic |
Mutex<T> وRwLock<T> | تعديل قيمة مشتركة بين خيوط | قفل، وانتظار، واحتمال الجمود Deadlock |
Cow<'a, T> | قيمة تستعار غالباً وتنسخ أحياناً، مثل نص لا يتغير إلا إذا احتاج تنظيفاً | فحص الحالة عند الاستخدام |
ولاحظ الصف الرابع: RefCell لا يلغي قاعدة الاستعارة، وإنما ينقل فحصها من وقت الترجمة إلى وقت التشغيل، فإذا خرقتها توقف البرنامج. ولهذا فإن كوداً مليئاً بالتركيب Rc<RefCell<T>> علامة على أنك تكتب Java بلغة Rust، وأنك أخذت كل ثمن Rust دون فائدتها، وسوف نعود إلى هذا في مقال الأخطاء الشائعة.
الأخطاء Errors: قيم تعود من الدالة، لا استثناءات تقفز
الآن نعود إلى مهمة المورد: تحويل "9.90" إلى 990 هللة، ورفض كل ما ليس سعراً صالحاً. ولا توجد استثناءات في Rust، والدالة التي قد تفشل ترجع Result<T, E>، والفرق بين هذه الطريقة والاستثناءات في Java وPython وقيم الخطأ في Go شرحناه بالتفصيل في مقال كيف تتعامل لغات البرمجة مع الأخطاء. وهنا نبني ما يبنيه كل مشروع حقيقي: نوع خطأ خاص بالمجال، وتحويل تلقائي من أخطاء المكتبة القياسية إليه، ثم نرى كيف يبدو كل نوع من الفشل.
#[derive(Debug)]
enum PriceError {
Empty,
BadNumber(ParseIntError),
TooManyDecimals(usize),
Negative,
}
impl fmt::Display for PriceError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
PriceError::Empty => write!(f, "price is empty"),
PriceError::BadNumber(e) => write!(f, "not a number: {e}"),
PriceError::TooManyDecimals(n) => write!(f, "{n} decimals, at most 2 allowed"),
PriceError::Negative => write!(f, "price cannot be negative"),
}
}
}
impl std::error::Error for PriceError {}
impl From<ParseIntError> for PriceError {
fn from(e: ParseIntError) -> Self {
PriceError::BadNumber(e)
}
}
fn parse_halalas(s: &str) -> Result<i64, PriceError> {
let s = s.trim();
if s.is_empty() {
return Err(PriceError::Empty);
}
if s.starts_with('-') {
return Err(PriceError::Negative);
}
let (whole, frac) = s.split_once('.').unwrap_or((s, "0"));
if frac.len() > 2 {
return Err(PriceError::TooManyDecimals(frac.len()));
}
let whole: i64 = whole.parse()?; // ? converts ParseIntError through From
let frac: i64 = format!("{frac:0<2}").parse()?;
Ok(whole * 100 + frac)
}في الكود أعلاه لاحظ التالي:
- الخطأ تعداد بأربع حالات، وكل حالة تحمل ما يحتاجه من يقرأ الخطأ: عدد الكسور الزائدة، أو الخطأ الأصلي من تحويل الرقم. فمن يستدعي الدالة يستطيع أن يقرر بالمطابقة
matchماذا يفعل مع كل حالة، مثل أن يتجاهل السطر الفارغ ويوقف الاستيراد عند السعر السالب. - السمة
Displayهي النص الذي يقرؤه الإنسان، والسمةDebugالمشتقة هي التمثيل الذي يقرؤه المبرمج، والفرق بينهما سوف يظهر بعد قليل في مكان لا تتوقعه. - تنفيذ
From<ParseIntError>هو ما يجعل العلامة?تعمل في السطرين الأخيرين: الدالةparseترجع خطأ من نوعParseIntError، والعلامة?تحوله تلقائياً إلىPriceErrorبهذا التنفيذ ثم ترجع به. ودونه يرفض المترجم البرنامج لأن نوعي الخطأ مختلفان. - كتابة هذا كله بيدك مملة، ولهذا تستخدم أغلب المشاريع المكتبة thiserror التي تولد
DisplayوFromمن سطر فوق كل حالة، والمكتبة anyhow للبرامج التي لا تحتاج أن تفرق بين أنواع الخطأ وإنما تريد رسالة جيدة مع سياق. والقاعدة الشائعة: thiserror في المكتبات، وanyhow في البرامج.
والمخرج لسبعة مدخلات من ملف مورد حقيقي الشكل:
"9.90" -> Ok(990)
"45" -> Ok(4500)
"" -> Err(price is empty)
"9.999" -> Err(3 decimals, at most 2 allowed)
"abc" -> Err(not a number: invalid digit found in string)
"-3" -> Err(price cannot be negative)
"99999999999999999999" -> Err(not a number: number too large to fit in target type)
checked_mul(i64::MAX / 2, 3) = Noneكل مدخل خاطئ أخذ رسالة واضحة بدلاً من أن يوقف البرنامج، والرقم الكبير جداً لم يلتف ولم يقطع، وإنما رجع خطأ من parse نفسها لأنه لا يتسع في i64. والسطر الأخير يبين الطريقة الآمنة للحساب: checked_mul ترجع None بدلاً من التجاوز الذي رأينا في المقال السابق أنه يلتف بصمت في وضع الإصدار.
التوقف Panic: الدالتان unwrap و expect والفهرس خارج الحدود Out of Bounds
وماذا لو لم تتعامل مع الخطأ؟ في Rust لا يمكنك تجاهل Result بصمت، ولكن يمكنك أن تقول «إذا كان خطأ فأوقف البرنامج»، بالدالة unwrap() أو expect("رسالة"). وجربنا أربع طرق لنفس السعر الخاطئ "9,90" المكتوب بفاصلة بدلاً من النقطة، وهو خطأ يحدث فعلاً في ملفات الموردين التي تخرج من برامج جداول بلغة عربية أو أوروبية:
--- errors unwrap
exit 101
thread 'main' (276) panicked at src/bin/errors.rs:60:43:
called `Result::unwrap()` on an `Err` value: BadNumber(ParseIntError { kind: InvalidDigit })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
--- errors expect
exit 101
thread 'main' (278) panicked at src/bin/errors.rs:64:43:
price from the supplier file: BadNumber(ParseIntError { kind: InvalidDigit })
--- errors question
exit 1
Error: BadNumber(ParseIntError { kind: InvalidDigit })
--- errors index
exit 101
thread 'main' (282) panicked at src/bin/errors.rs:74:28:
index out of bounds: the len is 3 but the index is 3في المخرج أعلاه لاحظ التالي:
- الطريقتان
unwrapوexpectأوقفتا البرنامج برمز 101، وهو رمز التوقف في Rust، مع الملف والسطر والعمود. والفرق أنexpectأضافت رسالتنا «price from the supplier file»، وهذه الرسالة هي ما سوف يقرؤه من يفتح السجل الساعة الثالثة صباحاً، فإذا كنت ستوقف البرنامج فاكتب سبباً يفهمه غيرك. - الطريقة الثالثة رجعت بالخطأ من
mainبالعلامة?، لأنmainعندنا ترجعResult<(), Box<dyn Error>>، فخرج البرنامج برمز 1 دون توقف. ولكن انظر إلى الرسالة:Error: BadNumber(ParseIntError { kind: InvalidDigit })، وليست «not a number: invalid digit found in string» التي كتبناها فيDisplay. والسبب أن المكتبة القياسية تطبع خطأmainبالتنسيقDebug، وهذا يفاجئ أغلب من يجربه أول مرة. فإذا كانت الرسالة ستظهر لمستخدم فاطبعها بنفسك بالتنسيق{e}ثم اخرج بالرمز الذي تريده، كما فعلنا مع الملف المفقود في المقال السابق. - الطريقة الرابعة لا علاقة لها بـ
Result: فهرس 3 في مصفوفة من ثلاثة عناصر. وفي C هذا قراءة خارج الحدود Out of Bounds Read بصمت، وفي Rust فحص حدود يوقف البرنامج برسالة فيها الطول والفهرس. وإذا كان الفهرس قد يكون خاطئاً فعلاً، فالدالةprices.get(i)ترجعOptionبدلاً من التوقف.
إذن متى يكون التوقف مقبولاً؟ القاعدة التي يشرحها فصل «إلى Panic أو لا» في الكتاب: التوقف للأخطاء التي تعني أن البرنامج نفسه فيه خطأ برمجي Bug، ولا يمكن أن يكمل بأمان، مثل حالة يفترض أنها مستحيلة. وResult لكل خطأ متوقع من العالم الخارجي: ملف المورد، ومدخلات المستخدم، والشبكة، وقاعدة البيانات. وسعر بفاصلة في ملف مورد خطأ متوقع، فمكانه Result وليس unwrap. ولاحظ أن التوقف في خدمة ويب لا يوقف الخدمة كلها في أغلب الأطر، وإنما المهمة أو الخيط الذي حدث فيه، ولكنه يقطع الطلب دون رد مفهوم، وسوف نرى ذلك في مقال الخدمة الحقيقية.
قراءة ال Backtrace: وضع الإصدار Release مقابل التطوير Debug
الرسالة نفسها تقترح المتغير RUST_BACKTRACE=1 لترى سلسلة الاستدعاءات التي أوصلت إلى التوقف، وجربناه على البناءين:
--- unwrap with RUST_BACKTRACE=1 (release)
stack backtrace:
0: __rustc::rust_begin_unwind
1: core::panicking::panic_fmt
2: core::result::unwrap_failed
3: errors::main
--- debug build, unwrap with RUST_BACKTRACE=1
stack backtrace:
0: __rustc::rust_begin_unwind
at /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/std/src/panicking.rs:679:5
1: core::panicking::panic_fmt
at /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/core/src/panicking.rs:80:14
2: core::result::unwrap_failed
at /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/core/src/result.rs:1871:5
3: <core::result::Result<i64, errors::PriceError>>::unwrap
at /rustc/58cf8f9c35b355199727627d07ae34a4e2846d67/library/core/src/result.rs:1232:23
4: errors::main
at ./src/bin/errors.rs:60:43اقرأ السلسلة من الأسفل إلى الأعلى كما شرحنا في مقال قراءة رسالة الخطأ Stack Trace: الإطار 4 هو كودك، والإطارات فوقه داخل المكتبة القياسية حتى نقطة التوقف. وفي وضع الإصدار اختفت أسماء الملفات والسطور، واختفى الإطار unwrap كله لأن المحسن ضمنه داخل main، فبقيت أسماء الدوال وحدها. ولهذا إذا أردت سلاسل مفيدة من الإنتاج فأضف debug = "line-tables-only" إلى [profile.release]، فيبقى جدول السطور دون باقي معلومات التصحيح الكبيرة، والملف يكبر قليلاً فقط.

ماذا يحدث إذا استخدمت clone في كل مكان؟ ثمن النسخ Cloning Cost
جمعنا في الأقسام السابقة رقمين يجيبان عن هذا السؤال، وهو السؤال الذي يسأله كل فريق في الشهر الأول. نسخ قائمة فيها 400 MB أخذ 915 ملي ثانية وضاعف الذاكرة، بينما نقلها أخذ 200 نانو ثانية. ونسخ عشرة آلاف نص قصير لتمريرها إلى دالة كلف عشرة آلاف حجز، بينما الاستعارة لم تكلف شيئاً. فهل يعني هذا أن clone ممنوع؟ لا، والسبب أن عشرة آلاف حجز صغير تأخذ أجزاء من الملي ثانية على معالج حديث، وفي كود يعمل مرة واحدة في الصباح لاستيراد ملف المورد لن يلاحظها أحد، ووضوح الكود أهم. ولكن في الكود الساخن الذي يعمل مع كل طلب، أو مع البيانات الكبيرة، يصبح كل clone سؤالاً يستحق الإجابة: هل تحتاج الدالة الملكية فعلاً؟ والقاعدة التي يعمل بها أغلب الفرق: ابدأ بالاستعارة في المعاملات، وانقل الملكية حيث يحتفظ أحد بالقيمة، واستخدم clone عندما تحتاج نسختين مستقلتين فعلاً، وقس قبل أن تحسن.
جدول قرار Decision Table: أي نوع تأخذه الدالة؟
| ما تفعله الدالة بالقيمة | نوع المعامل | مثال |
|---|---|---|
| تقرأ نصاً فقط | &str | fn parse_halalas(s: &str) |
| تقرأ قائمة فقط | &[T] | fn order_total(products: &[Product], ...) |
| تعدل القيمة في مكانها | &mut T | fn restock(p: &mut Product, qty: u32) |
| تحتفظ بالقيمة في هيكل أو قائمة | String أو T بالملكية | fn new(name_ar: String) -> Product |
| ترجع قيمة جديدة أنشأتها | ترجع String أو T | fn sar(h: i64) -> String |
| قد تفشل لسبب خارجي | ترجع Result<T, E> | قراءة ملف، تحويل سعر |
| قد لا تجد شيئاً وهذا طبيعي | ترجع Option<T> | البحث عن منتج برقمه |
رقم صغير أو قيمة Copy | بالقيمة مباشرة | fn vat_halalas(subtotal: i64) |
أخطاء شائعة Common Mistakes
- إسكات كل خطأ
E0382بالدالةclone(): عشرة آلاف حجز في الحلقة، والحل غالباً أن تأخذ الدالة&str. - دوال تأخذ
&Stringبدلاً من&str، أو&Vec<T>بدلاً من&[T]: النوع الأضيق يقبل أشياء أكثر، والمدقق Clippy ينبهك إليه. - حساب طول النص العربي بالدالة
len(): «قهوة عربية» 19 بايتاً و10 حروف. - الاحتفاظ بمرجع داخل قائمة ثم تعديلها: المترجم يرفضه بالخطأ
E0502لأنpushقد تنقل كل العناصر. - إرجاع مرجع إلى قيمة أنشأتها الدالة: أرجع القيمة نفسها، فالنقل لا يكلف إلا 24 بايتاً.
- هياكل مليئة بالمراجع والأعمار من اليوم الأول: ابدأ بالملكية، وأضف المراجع عندما يقول القياس إن النسخ مكلف.
unwrap()على مدخلات من الخارج: سعر بفاصلة أوقف البرنامج برمز 101، ومكانهResult.- الاعتماد على رسالة خطأ
main: تطبع بالتنسيقDebug، فاطبع رسالتك بنفسك إذا كانت لمستخدم. - بناء الإنتاج دون جدول السطور ثم الحاجة إلى Backtrace: أضف
debug = "line-tables-only". - قائمة تكبر عنصراً عنصراً وعددها معروف: 16 حجزاً بدلاً من واحد مع
with_capacity. - الظن أن
RefCellيلغي قاعدة الاستعارة: ينقلها إلى وقت التشغيل، والخرق توقف.
قائمة تحقق Checklist قبل مراجعة كود Rust
- كل دالة تقرأ فقط تأخذ
&strأو&[T]أو&T. - كل
clone()في كود يعمل مع كل طلب له سبب تستطيع قوله. - كل
unwrap()وexpect()على شيء مستحيل الفشل فعلاً، ومعexpectرسالة تشرح لماذا. - الأخطاء نوع خاص بالمجال بحالات واضحة، أو thiserror في المكتبات وanyhow في البرامج.
- الحسابات المالية بالدوال
checked_*أو معoverflow-checks. - النصوص العربية تمر بالدالة
chars()ولا تقص بفهارس البايتات. - القوائم ذات الحجم المعروف تحجز سعتها مسبقاً.
- لا يوجد
unsafeإلا في حد واضح مع تعليق// SAFETY:يشرح لماذا هو صحيح.
تدريب عملي
- اكتب دالة
first_word(s: &str) -> &strترجع أول كلمة من اسم منتج عربي، ولاحظ أن المترجم لا يطلب أعماراً هنا، ثم اشرح لماذا بقواعد الحذف. - غير
parse_halalasلتقبل الفاصلة بدلاً من النقطة، وأضف حالة خطأ جديدة للأحرف العربية الهندية مثل «٩٫٩٠»، ولاحظ أين يجبركmatchعلى التعامل معها. - أعد كتابة نوع الخطأ بالمكتبة thiserror، وقارن عدد الأسطر.
حذف المختبر Cleanup
docker rmi slr-rs-dev:1
docker volume rm slr-rs-cargo slr-rs-targetالخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- كل نوع في Rust له حجم معروف:
StringوVecثلاث كلمات في ال Stack والبيانات في ال Heap، والمرجع&strمؤشر وطول، وOption<Box>بحجم المؤشر وحده. - النصوص UTF-8، والدالة
len()تعد البايتات، والحرف العربي بايتان. - النقل ينسخ ثلاث كلمات في 200 نانو ثانية، والاستنساخ نسخ 400 MB في 915 ملي ثانية، والتحرير يحدث عند نهاية النطاق أو عند
dropوتعود الذاكرة الكبيرة إلى النظام فوراً. - قاعدة الاستعارة: قارئون كثيرون أو كاتب واحد، وهي التي منعت استخدام ذاكرة محررة داخل
push، والمرجع يعيش حتى آخر استخدام له لا حتى نهاية النطاق. - الأعمار معلومة تعطيها للمترجم عندما لا تكفي قواعد الحذف، والحل الأول غالباً هو الملكية لا الأعمار.
- عداد الحجوزات أظهر عشرة آلاف حجز من
cloneوصفراً من الاستعارة، و16 نمواً للقائمة مقابل حجز واحد معwith_capacity. - الأخطاء المتوقعة
Resultبنوع خطأ خاص وFromوالعلامة?، والتوقف للأخطاء البرمجية برمز 101، والخطأ الراجع منmainيخرج برمز 1 ويطبع بالتنسيقDebug.
وفي المقال التالي من السلسلة نأخذ نفس القواعد إلى الخيوط: كيف تمنع الملكية والاستعارة سباق البيانات قبل التشغيل، وما الذي لا تمنعه، مع قياس الخيوط وTokio على عمل ينتظر الشبكة وعمل يشغل المعالج.
دليلRust للمطورين: الأدوات Cargo والأساسيات في يوم واحدثبتنا Rust 1.99.1 من الصفر وبنينا مكتبة تحسب قيمة الطلب والضريبة بالهللات، ومررنا عليها بكل أداة ستكتبها يومياً مع المخرج الحقيقي، وما يفاجئ القادم من Python وJava وGo.
دليلإدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟نقيس بأيدينا في C وGo وPython وRust من يحرر الذاكرة ومتى، وكم يكلف ذلك من الذاكرة والوقت، وما الأخطاء التي تبقى ممكنة في كل طريقة.
دليلأمان الذاكرة Memory Safety: لماذا تدفع الحكومات نحو Rust؟ماذا طلبت الحكومات فعلاً بشأن C وC++، ولماذا تسبب أخطاء الذاكرة أغلب الثغرات الخطيرة، ومتى يكون Rust هو الاختيار الصحيح.
دليلكيف تتعامل لغات البرمجة مع الأخطاء؟لماذا ترمي Java وPython الاستثناءات بينما ترجع Go وRust الخطأ كقيمة، وكيف تمنع الخطأ من أن يختفي بصمت في كودك.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجارب داخل Container من
rust:1.99.1-slim-trixieمع Rust 1.99.1 والإصدار الكبير 2024، على نواة Linux 5.15 (WSL2) ومعالج بثماني أنوية منطقية وذاكرة 32 GB، على Docker Engine 29.8.0.