نظام الملكية والاقتراض في Rust: أخطاء شائعة وحلول عملية لكتابة كود آمن

مقدمة

يُعد نظام الملكية والاقتراض في Rust أحد أهم الأسباب التي تجعل اللغة مناسبة لبناء البرمجيات الآمنة وعالية الأداء، مثل أدوات الأنظمة والخوادم والمكتبات منخفضة المستوى. فبدلاً من الاعتماد على جامع قمامة، أو ترك المبرمج يدير الذاكرة يدوياً مع خطر التسريبات والوصول غير الصحيح، يفرض مترجم Rust قواعد دقيقة للتحكم في عمر البيانات وطريقة الوصول إليها.

قد تبدو رسائل أخطاء المترجم مربكة للمبتدئ، خصوصاً عند ظهور عبارات مثل «استخدام قيمة بعد نقلها» أو «لا يمكن اقتراض القيمة كمُتغيرة». لكن هذه الأخطاء ليست عوائق اعتباطية؛ بل هي إشارات مبكرة تمنع حالات خطرة مثل المؤشرات المعلّقة، وسباقات البيانات، والتعديل المتزامن غير الآمن. يشرح هذا المقال المفاهيم الأساسية للملكية والاقتراض، ويعرض أكثر الأخطاء شيوعاً مع حلول عملية تساعد على كتابة شيفرة أوضح وأكثر أماناً.

فهم الملكية: من يملك البيانات ومن المسؤول عن تحريرها؟

لكل قيمة في Rust مالك واحد في لحظة محددة. والمالك هو المتغير المسؤول عن البيانات، وعند خروج هذا المتغير من نطاقه تُحرّر الموارد المرتبطة بالقيمة تلقائياً. تنطبق هذه الفكرة بوضوح على الأنواع التي تخزن بياناتها في الذاكرة الديناميكية، مثل String وVec<T>.

عند إسناد قيمة من هذا النوع إلى متغير آخر، لا تنسخ Rust البيانات افتراضياً، بل تنقل ملكيتها. وهذا يمنع وجود مالكين يحاولان تحرير المورد نفسه. في المثال التالي تنتقل ملكية النص من s إلى s2:

fn main() {
    let s = String::from("مرحباً Rust");
    let s2 = s;

    println!("{}", s2);
    // println!("{}", s); // خطأ: استُخدمت القيمة بعد نقل ملكيتها
}

لا يعني ذلك أن جميع الإسنادات تنقل الملكية بالطريقة نفسها. فالأنواع البسيطة ذات الحجم المعروف، مثل الأعداد الصحيحة والقيم المنطقية، تطبق غالباً السمة Copy، ولذلك تُنسخ قيمها بدلاً من نقل ملكيتها:

fn main() {
    let العدد = 42;
    let نسخة_العدد = العدد;

    println!("{} {}", العدد, نسخة_العدد);
}

القاعدة العملية هي: إذا كانت القيمة تملك مورداً يحتاج إلى تحرير، فتوقع انتقال الملكية عند الإسناد أو عند تمريرها إلى دالة، ما لم تستخدم مرجعاً أو تطلب نسخة صريحة. هذا التصميم يجعل المسؤولية عن عمر الموارد واضحة ويمكن التحقق منها وقت الترجمة.

الخطأ الأول: استخدام قيمة بعد نقل ملكيتها

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

fn اطبع_الرسالة(رسالة: String) {
    println!("{}", رسالة);
}

fn main() {
    let رسالة = String::from("رسالة مهمة");
    اطبع_الرسالة(رسالة);

    // println!("{}", رسالة); // خطأ: انتقلت الملكية إلى الدالة
}

تملك الدالة اطبع_الرسالة القيمة عند استدعائها، وعند نهاية الدالة يخرج المتغير المحلي من نطاقه، فتُحرر السلسلة النصية. لذلك لا يمكن للمتغير الأصلي أن يشير إلى مورد لم يعد موجوداً. الحل الأنسب غالباً هو تمرير مرجع غير قابل للتغيير عندما لا تحتاج الدالة إلى امتلاك البيانات:

fn اطبع_الرسالة(رسالة: &String) {
    println!("{}", رسالة);
}

fn main() {
    let رسالة = String::from("رسالة مهمة");
    اطبع_الرسالة(&رسالة);

    println!("ما زالت متاحة: {}", رسالة);
}

وفي حالات كثيرة يكون استخدام &str أفضل من &String، لأن الدالة عندئذ تقبل شريحة نصية سواء أكانت مصدرها String أم نصاً ثابتاً:

fn رحب(اسم: &str) {
    println!("أهلاً، {}!", اسم);
}

fn main() {
    let اسم = String::from("ليلى");
    رحب(&اسم);
    رحب("سالم");
}

أما إذا كانت الدالة تحتاج فعلاً إلى الاحتفاظ بالقيمة أو نقلها إلى بنية بيانات أخرى، فإن نقل الملكية هو السلوك الصحيح. ويمكن عند الحاجة إلى نسختين مستقلتين استخدام clone()، لكن ينبغي عدم اعتباره حلاً افتراضياً لأنه قد ينسخ بيانات كثيرة ويؤثر في الأداء:

let النص = String::from("بيانات");
let نسخة = النص.clone();

println!("{}", النص);
println!("{}", نسخة);

الاقتراض والمراجع: الوصول إلى البيانات دون امتلاكها

الاقتراض هو آلية تسمح للدوال أو المتغيرات بالوصول إلى قيمة يملكها طرف آخر من دون نقل ملكيتها. يُكتب المرجع غير القابل للتغيير بالرمز &T، حيث يمثل T نوع البيانات. يمكن وجود عدة مراجع غير قابلة للتغيير للقيمة نفسها في الوقت ذاته، لأن القراءة المتزامنة لا تخلق تعارضاً.

fn طول_النص(نص: &String) -> usize {
    نص.len()
}

fn main() {
    let نص = String::from("Rust آمنة");
    let الطول = طول_النص(&نص);

    println!("الطول: {}", الطول);
    println!("النص: {}", نص);
}

المراجع لا تملك البيانات، ولذلك لا تحررها عند نهاية نطاقها. لكنها مرتبطة بعمر القيمة الأصلية؛ فلا يمكن أن يستمر المرجع بعد انتهاء صلاحية مالكه. هذه العلاقة هي ما يحمي البرنامج من المؤشرات المعلّقة.

من المفيد أيضاً التمييز بين المرجع وبين القيمة المنسوخة. فكتابة &نص لا تنشئ نسخة من السلسلة، بل تنشئ طريقة آمنة ومؤقتة للوصول إليها. لذلك يُفضّل استخدام المراجع في واجهات الدوال التي تقرأ البيانات فقط، إذ يجنّب ذلك النسخ غير الضروري ويحافظ على إمكانية استخدام القيمة لدى المستدعي.

الخطأ الثاني: الاقتراض المتغير مع وجود مراجع أخرى

للتعديل على البيانات عبر مرجع، تستخدم Rust المرجع المتغير &mut T. لكن اللغة تفرض قاعدة مركزية: يمكن وجود أي عدد من المراجع غير المتغيرة، أو مرجع متغير واحد فقط، ولا يمكن الجمع بينهما ضمن النطاق المتداخل نفسه. الهدف من ذلك منع القراءة أثناء التعديل ومنع وجود تعديلين متزامنين على البيانات نفسها.

fn أضف_تحية(نص: &mut String) {
    نص.push_str("، أهلاً بك");
}

fn main() {
    let mut رسالة = String::from("مرحباً");
    أضف_تحية(&mut رسالة);

    println!("{}", رسالة);
}

في المثال السابق يجب تعريف رسالة باستخدام mut لأن التعديل لا يتعلق بالمرجع وحده، بل بالقيمة التي يشير إليها المرجع. الخطأ الشائع هو نسيان ذلك:

fn main() {
    let رسالة = String::from("مرحباً");
    // رسالة.push_str(" يا صديق"); // خطأ: المتغير غير قابل للتغيير
}

ويظهر خطأ آخر عند محاولة إنشاء مرجع متغير بينما لا يزال مرجع غير متغير مستخدماً:

fn main() {
    let mut بيانات = String::from("Rust");
    let قراءة = &بيانات;

    // let تعديل = &mut بيانات; // خطأ: يوجد اقتراض غير متغير نشط
    println!("{}", قراءة);
}

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

fn main() {
    let mut بيانات = String::from("Rust");

    {
        let قراءة = &بيانات;
        println!("قبل التعديل: {}", قراءة);
    }

    let تعديل = &mut بيانات;
    تعديل.push_str(" آمنة");

    println!("{}", بيانات);
}

الخطأ الثالث: إرجاع مرجع إلى بيانات محلية

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

fn أنشئ_رسالة() -> &str {
    let رسالة = String::from("نص مؤقت");
    // &رسالة // خطأ: المرجع يشير إلى قيمة محلية ستنتهي
}

الحل المباشر هو إعادة القيمة المالكة نفسها بدلاً من مرجع إليها. فعند إرجاع String تنتقل الملكية إلى المستدعي، ويظل هو مسؤولاً عن عمر البيانات:

fn أنشئ_رسالة() -> String {
    String::from("نص قابل للاستخدام")
}

fn main() {
    let رسالة = أنشئ_رسالة();
    println!("{}", رسالة);
}

أما عندما تعيد الدالة مرجعاً إلى إحدى القيم التي استقبلتها، فيجب أن تكون علاقة العمر واضحة. في المثال التالي تُعاد الشريحة النصية الأطول من مرجعين صالحين:

fn الأطول<'a>(الأول: &'a str, الثاني: &'a str) -> &'a str {
    if الأول.len() >= الثاني.len() {
        الأول
    } else {
        الثاني
    }
}

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

اختيار الحل الصحيح: مرجع أم نقل ملكية أم نسخة؟

ليس الهدف من نظام الملكية تجنب نقل الملكية دائماً، بل اختيار أسلوب يتوافق مع مسؤولية الدالة وسلوكها. إذا كانت الدالة تقرأ البيانات فقط، فالأفضل غالباً تمرير مرجع غير متغير. وإذا كانت تعدل البيانات من دون الاحتفاظ بها، فمرر مرجعاً متغيراً. أما إذا كانت الدالة ستخزن القيمة أو سترسلها إلى خيط تنفيذ آخر أو تجعلها جزءاً من نتيجة مستقلة، فإن نقل الملكية يكون مناسباً.

يمكن تلخيص القرار العملي في الأسئلة الآتية: هل تحتاج الدالة إلى البيانات بعد عودتها؟ هل يجب أن يبقى المالك الأصلي قادراً على استخدامها؟ هل التعديل مطلوب؟ وهل كلفة النسخ مقبولة؟ تساعد الإجابة على هذه الأسئلة في تصميم واجهات دوال دقيقة ومفهومة.

fn اعرض(اسم: &str) {
    println!("المستخدم: {}", اسم);
}

fn أضف_لاحقة(اسم: &mut String) {
    اسم.push_str("_نشط");
}

fn خزّن(اسم: String, قائمة: &mut Vec<String>) {
    قائمة.push(اسم);
}

fn main() {
    let mut اسم = String::from("مستخدم");
    let mut القائمة = Vec::new();

    اعرض(&اسم);
    أضف_لاحقة(&mut اسم);
    خزّن(اسم, &mut القائمة);

    println!("{:?}", القائمة);
}

في هذا المثال تقرأ اعرض الاسم فقط، وتعدله أضف_لاحقة مؤقتاً، بينما تستهلك خزّن الملكية لأنها تضع السلسلة في القائمة. هذا النوع من التصميم يقلل الاستنساخ غير الضروري ويجعل العقود بين الدوال صريحة.

ممارسات عملية لقراءة الأخطاء وكتابة شيفرة أوضح

رسائل مترجم Rust جزء مهم من تجربة التطوير، إذ تعرض عادة مكان النقل أو الاقتراض الأول، ثم تبيّن موضع الاستخدام المخالف. بدلاً من إضافة clone() فوراً، ابدأ بتحديد من يجب أن يملك القيمة فعلاً. كثير من المشكلات تُحل بتغيير وسيط الدالة من String إلى &str أو من Vec<T> إلى &[T] عندما تكون العملية للقراءة فقط.

يُستحسن أيضاً تقصير عمر المراجع، خصوصاً المراجع المتغيرة. لا تحتفظ بمرجع متغير في متغير طويل النطاق إذا كان التعديل محدوداً بسطرين؛ نفّذ العملية ثم دع المرجع ينتهي. كما يفيد فصل العمليات إلى مراحل: قراءة البيانات أولاً، ثم إنهاء مراجع القراءة، ثم إجراء التعديل.

وعند التعامل مع هياكل بيانات معقدة، استخدم دوال المكتبة التي تعبّر عن النية بوضوح، مثل get وget_mut وiter وiter_mut. فاختيار الدالة الصحيحة يمنحك نوع المرجع المناسب ويقلل احتمالات التعارض. وأخيراً، لا تجعل هدفك إسكات المترجم؛ اجعل هدفك فهم سبب الاعتراض، لأن القاعدة التي يفرضها غالباً تحميك من خلل حقيقي كان سيظهر في وقت التشغيل في لغات أخرى.

خاتمة

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

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

تعليقات