لغات البرمجة عملياً الأدلة التقنية

الأنواع والملكية Ownership والأخطاء Result في Rust

قسنا أحجام الأنواع وعدد الحجوزات والذاكرة عند النقل والاستنساخ، وقرأنا ست رسائل حقيقية من مدقق الاستعارة، ثم رأينا كيف تفشل Rust بالأخطاء والتوقف ورموز الخروج.

الأنواع والملكية Ownership والأخطاء Result في Rust

في المقال السابق 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 والقائمة Vec 24 بايتاً مهما كان حجم المحتوى، لأن الذي في ال 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 bytes
  • Option<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 الذي إحدى حالاته تحمل String 24 بايتاً، أي حجم أكبر حالة، لأن قيمة التعداد يجب أن تتسع لأي حالة منها.

والفكرة العامة التي تخرج بها من هذه الأرقام أن 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 بايتاً: مؤشر للبيانات ومؤشر للجدول.

أحجام الأنواع من البرنامج sizes: String وVec ثلاث كلمات 24 بايتاً هي المؤشر والطول والسعة والبيانات في ال Heap، و&str مؤشر وطول 16 بايتاً، وBox مؤشر 8 بايتات، وOption<Box> 8 بايتات بلا زيادة لأن الصفر يمثل None، وOption<u32> 8 بايتات مقابل Option<NonZeroU32> 4، والنص قهوة عربية 19 بايتاً و10 حروف
الأنواع الآمنة بنفس حجم المؤشرات الخطرة في أغلب الحالات

الملكية 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، أي عند نهاية الدالة الرئيسية، لأنه بقي حياً حتى هناك.
ذاكرة العملية في كل خطوة: 1208 KB في البداية، و411580 KB داخل النطاق، ثم 1988 KB بعد نهاية النطاق وبعد drop، ثم 411380 KB بعد النقل لأن النقل لا ينسخ البيانات، ثم 821092 KB بعد clone، والنقل أخذ 200 نانو ثانية والاستنساخ 915 ملي ثانية
النقل لا يلمس البيانات، والاستنساخ ينسخها كلها

وهذا الفرق بين 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. فإذا قرأت عن قيود الاستعارة في مقال قديم ووجدت أنها أشد مما تراه، فهذا هو السبب، والمدقق اليوم يقبل كثيراً من البرامج التي كان يرفضها.

قاعدة الاستعارة: إما عدد من مراجع القراءة &T أو مرجع تعديل واحد &mut T. مثال E0502: المرجع first يشير إلى العنصر الأول في Vec سعته 3، والدالة push تحجز مكاناً جديداً بسعة 6 وتنقل العناصر وتحرر القديم، فيبقى first مشيراً إلى ذاكرة محررة، والمترجم يرفض ذلك قبل التشغيل. والإصلاح نسخة من الرقم لأن i32 من نوع Copy
ما يرفضه المترجم هنا هو استخدام بعد التحرير كان سيحدث داخل push

الأعمار 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)، ولكنها تعني أيضاً أن القائمة قد تحجز ضعف ما تحتاجه في لحظة النمو. والقاعدة العملية: إذا كنت تعرف العدد مسبقاً، مثل عدد سطور ملف المورد، فاحجز السعة من البداية.

عدد الحجوزات من البرنامج allocs: الاستنساخ إلى دالة تأخذ String عشرة آلاف حجز و118890 بايتاً، والاستعارة إلى دالة تأخذ &str حجز واحد هو مخزن الطباعة، وVec::new مع مئة ألف إضافة 16 نمواً بالسعات 4 و8 و16 حتى 65536 ثم 131072 بمجموع 2 MB، وwith_capacity حجز واحد 800000 بايت، والنقل صفر حجوزات، والاستنساخ حجز واحد 8 بايتات
كل clone وكل نمو للقائمة حجز تراه في العداد

المؤشرات الذكية 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]، فيبقى جدول السطور دون باقي معلومات التصحيح الكبيرة، والملف يكبر قليلاً فقط.

أربع طرق لنفس السعر الخاطئ 9,90: unwrap توقف برمز 101 مع السطر، وexpect توقف برمز 101 مع رسالتنا، والعلامة ? ترجع الخطأ من main فيخرج برمز 1 ويطبع الخطأ بتنسيق Debug لا Display، والفهرس خارج الحدود توقف برمز 101 مع الطول والفهرس. وتحتها القاعدة: Result للأخطاء المتوقعة من الخارج وPanic للأخطاء البرمجية
رمز الخروج يخبر من شغل البرنامج بنوع الفشل

ماذا يحدث إذا استخدمت clone في كل مكان؟ ثمن النسخ Cloning Cost

جمعنا في الأقسام السابقة رقمين يجيبان عن هذا السؤال، وهو السؤال الذي يسأله كل فريق في الشهر الأول. نسخ قائمة فيها 400 MB أخذ 915 ملي ثانية وضاعف الذاكرة، بينما نقلها أخذ 200 نانو ثانية. ونسخ عشرة آلاف نص قصير لتمريرها إلى دالة كلف عشرة آلاف حجز، بينما الاستعارة لم تكلف شيئاً. فهل يعني هذا أن clone ممنوع؟ لا، والسبب أن عشرة آلاف حجز صغير تأخذ أجزاء من الملي ثانية على معالج حديث، وفي كود يعمل مرة واحدة في الصباح لاستيراد ملف المورد لن يلاحظها أحد، ووضوح الكود أهم. ولكن في الكود الساخن الذي يعمل مع كل طلب، أو مع البيانات الكبيرة، يصبح كل clone سؤالاً يستحق الإجابة: هل تحتاج الدالة الملكية فعلاً؟ والقاعدة التي يعمل بها أغلب الفرق: ابدأ بالاستعارة في المعاملات، وانقل الملكية حيث يحتفظ أحد بالقيمة، واستخدم clone عندما تحتاج نسختين مستقلتين فعلاً، وقس قبل أن تحسن.

جدول قرار Decision Table: أي نوع تأخذه الدالة؟

ما تفعله الدالة بالقيمةنوع المعاملمثال
تقرأ نصاً فقط&strfn parse_halalas(s: &str)
تقرأ قائمة فقط&[T]fn order_total(products: &[Product], ...)
تعدل القيمة في مكانها&mut Tfn restock(p: &mut Product, qty: u32)
تحتفظ بالقيمة في هيكل أو قائمةString أو T بالملكيةfn new(name_ar: String) -> Product
ترجع قيمة جديدة أنشأتهاترجع String أو Tfn 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.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.