English | 简体中文 | 繁體中文 | Русский язык | Français | Español | Português | Deutsch | 日本語 | 한국어 | Italiano | بالعربية

ملكية Rust

يجب على برامج الكمبيوتر إدارة موارد الذاكرة التي تستخدمها أثناء التشغيل.

معظم لغات البرمجة تحتوي على وظائف لإدارة ذاكرة النظام:

تتمتع لغات مثل C/C++ بقدرة إدارة ذاكرة النظام بشكل يدوي، حيث يجب على المطورين طلب وإطلاق موارد الذاكرة يدويًا. ولكن لتحسين كفاءة التطوير، لا يتبع العديد من المطورين عادة إطلاق الذاكرة في الوقت المناسب، مما يؤدي إلى إهدار الموارد. لذلك، غالبًا ما يؤدي إدارة الذاكرة يدويًا إلى إهدار الموارد.

تُشغل برامج مكتوبة بلغة Java على محاكي (JVM)، يمتلك JVM خاصية إعادة استعادة موارد الذاكرة تلقائيًا. ولكن هذا الأسلوب يمكن أن يقلل من كفاءة التشغيل، لذا يحاول JVM إعادة استعادة موارد الذاكرة بشكل أقل قدر ممكن، مما يؤدي أيضًا إلى زيادة استهلاك الذاكرة من قبل البرنامج.

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

قواعد الملكية

قواعد الملكية تتميز بثلاثة قواعد:

  • كل قيمة في Rust لها متغير يُدعى مالكها.

  • يمكن أن يكون هناك مالك واحد فقط في وقت واحد.

  • سيتم حذف القيمة عند عدم وجود المالك في نطاق تشغيل البرنامج.

هذه القواعد الثلاثة هي الأساس لمفهوم الملكية.

سيتم تقديم مفاهيم تتعلق بمفهوم الملكية لاحقاً.

نطاق المتغيرات

نحن نستخدم هذا النص البرمجي لشرح مفهوم نطاق المتغيرات:

{
    // قبل الت声明،متغير s غير صالح
    let s = "w3codebox";
    // هذا هو نطاق المتغير s
}
// نهاية نطاق المتغير s،متغير s غير صالح

نطاق المتغير هو خاصية المتغير،وتعرف منطقة العمل للمتغير،وتكون صالحة بشكل افتراضي من بداية تعريف المتغير حتى نهاية منطقة العمل للمتغير.

ذاكرة وتخصيص

إذا كان لدينا متغير تم تعيينه قيمته،فالقيمة للمتغير موجودة في ذاكرة.هذا شائع جدًا.لكن إذا كان طول البيانات التي نريد حفظها غير معروف (مثل سلسلة النصوص المدخلة من المستخدم)،لا يمكننا تحديد طول البيانات عند التعريف،وفي النهاية لا يمكننا تخصيص مساحة ذاكرة ثابتة في مرحلة التجميع لاستخدامها لحفظ البيانات.(يقول بعض الناس أن تخصيص مساحة كبيرة جدًا يمكن حل المشكلة،لكن هذا النهج غير مهذب).إذن،نحتاج إلى توفير ميكانيزم يمكن للبرنامج أن يطلب استخدام ذاكرة في وقت تشغيل البرنامج-الحقل.كل المصادر "ذات الذاكرة" التي نتحدث عنها في هذا الفصل تشير إلى مساحة ذاكرة مستخدمة من قبل الحقل.

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

نحن نكتب برنامج مثال على سلسلة النصوص باستخدام لغة C وما إلى ذلك:

{
    char *s = "w3codebox";
    free(s); // إطلاق مصادر s
}

من الواضح،ليس هناك أي دعوة إلى وظيفة free لتطهير المصادر للخط s في Rust (أعلم أن هذا غير صحيح في لغة C، لأن "w3codebox" ليس في الحقل،نفترض أن يكون هناك).لم يكن هناك خطوة واضحة لإطلاق المصادر في Rust لأنه في نهاية نطاق المتغير،يضيف مبرمج Rust تلقائيًا دعوة إلى وظيفة إطلاق المصادر.

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

طرق تفاعل المتغير مع البيانات

طرق تفاعل المتغير مع البيانات أساسًا تنقسم إلى نوعين رئيسيين:تنقل (Move) ونسخ (Clone).

تنقل

يمكن لمتعدد المتغيرات التفاعل مع نفس البيانات بطرق مختلفة في Rust:

let x = 5;
let y = x;

هذا البرنامج يربط القيمة 5 إلى المتغير x،ثم ينسخ قيمة x ويقوم بتعيينها إلى المتغير y.الآن هناك قيمتان 5 في الدفع.في هذه الحالة،البيانات هي بيانات "نوع البيانات الأساسي"،لا تحتاج إلى حفظها في الحقل،ولكن يجب حفظها فقط في الطريقة "تنقل" المباشرة للبيانات في الدفع،وهذا لن يكلف وقتًا أطول أو مساحة تخزين أكبر. "نوع البيانات الأساسي" يحتوي على هذه الأنواع:

  • جميع أنواع الأعداد الصحيحة،مثل i32 و u32 و i64 وغيرها.

  • نوع الحق والخطأ bool،القيمة هي true أو false.

  • جميع أنواع العدد العشري،f32 و f64.

  • نوع الحروف char.

  • تتكون فقط من أنواع البيانات المذكورة أعلاه - المجموعات (Tuples).

لكن إذا كانت البيانات التي يتم التفاعل معها موجودة في الذاكرة، فإن الأمر يختلف عن ذلك:

let s1 = String::from("hello");
let s2 = s1;

الخطوة الأولى تنتج كائن String، القيمة "hello".

الحالة الثانية ليست مختلفة كثيراً (ليس هذا حقيقيًا تماماً، ولكنه يستخدم فقط كمرجع للمقارنة):

كما هو موضح في الشكل: هناك كائنات String في السطر، لكل كائن String هناك مؤشر إلى السلسلة "hello" في الذاكرة.

لقد قلنا من قبل، عند خروج المتغيرات من نطاقها، يتم تنفيذ Rust تلقائيًا دالة إطلاق الموارد وإزالة ذاكرة المتغيرات من الذاكرة.

let s1 = String::from("hello");
let s2 = s1; 
println!("{}, world!", s1); // خطأ! s1 لم يعد صالحاً

لذا الحالة الفعلية هي:

s1 هو موجود فقط في الاسم.

نسخ

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

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();
    println!("s1 = {}, s2 = {}", s1, s2);
}

نتائج التنفيذ:

s1 = hello, s2 = hello

هنا يتم نسخ "hello" من堆، لذا s1 و s2 يتم ربطهما بأعمدة منفصلة، وسيتم إطلاقها كنوعين من الموارد.

بالطبع، يتم استخدام النسخ فقط عند الحاجة إلى نسخ البيانات، لأن نسخ البيانات يتطلب وقتاً أطول.

يتم التطرق إلى ميكانيكية ملكية الدالة

بالنسبة للمتغيرات، هذا هو الحالة الأكثر تعقيداً.

كيفية التعامل بشكل آمن مع ملكية المتغير عند إرساله كمعامل إلى دالة أخرى؟

هذه البرنامج توضح كيفية عمل ميكانيكية الملكية في هذه الحالة:

fn main() {
    let s = String::from("hello");
    // s يتم إعلانه كمعامل
    takes_ownership(s);
    // قيمة s يتم إدخالها كمعامل إلى الدالة
    // لذا يمكن اعتبار s تم نقلها، ولم يعد صالحاً من هذا المكان فما فوق
    let x = 5;
    // x يتم إعلانه كمعامل
    makes_copy(x);
    // x القيمة يتم إدخالها كمعامل إلى الدالة
    // لكن x نوع أساسي، لذا ما زال معتمداً
    // لا يزال يمكنك استخدام x هنا، لكن لا يمكنك استخدام s
} // انتهاء الوظيفة، x ليس له صلاحية، ثم s. لكن s تم نقل ملكيته، لذا لا يحتاج إلى إطلاق
fn takes_ownership(some_string: String) { 
    // معامل String some_string تم إدخاله، معتمد
    println!("{}", some_string);
} // انتهاء الوظيفة، المعامل some_string يتم إطلاقه هنا
fn makes_copy(some_integer: i32) { 
    // معامل i32 some_integer تم إدخاله، معتمد
    println!("{}", some_integer);
} // انتهاء الوظيفة، المعامل some_integer نوع أساسي، لا يحتاج إلى إطلاق

إذا تم إدخال المتغير كمعامل للوظيفة، فإن تأثيره مشابه لنقل الملكية.

ميكانيكية ملكية ناتج الوظيفة

fn main() {
    let s1 = gives_ownership();
    // gives_ownership يحرر ناتجه إلى s1
    let s2 = String::from("hello");
    // s2 سيتم إعلانه كمعتمد
    let s3 = takes_and_gives_back(s2);
    // s2 تم نقل ملكيته كمعامل، s3 حصل على ملكية الناتج
} // s3 ليس له صلاحية، س2 تم نقل ملكيته، s1 ليس له صلاحية
fn gives_ownership() -> String {
    let some_string = String::from("hello");
    // some_string سيتم إعلانه كمعتمد
    return some_string;
    // some_string سيتم نقل ملكيته خارج الوظيفة كناتج
}
fn takes_and_gives_back(a_string: String) -> String { 
    // a_string سيتم إعلانه كمعتمد
    a_string  // a_string سيتم نقل ملكيته خارج الوظيفة كناتج
}

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

الإشارة والاستعارة

الإشارة (Reference) هي مفهوم مألوف لدى مطوري C++.

إذا كنت مطلعاً على مفهوم الارتباطات، يمكنك اعتبارها مثل الارتباطات.

بشكل أساسي، "الإشارة" هو طريقة الوصول غير المباشر إلى المتغيرات.

fn main() {
    let s1 = String::from("hello");
    let s2 = &s1;
    println!("s1 هو {}, s2 هو {}", s1, s2);
}

نتائج التنفيذ:

s1 هو hello, s2 هو hello

استخدام علامة & يمكن الحصول على "المرجع" للمتغير.

عندما يتم اقتراض قيمة متغير، لا يتم اعتبار المتغير نفسه باطلاً. لأن "المرجع" لا ينسخ قيمة المتغير في سلسلة الحواسيب:

منطق نقل المعاملات هو نفسه:

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);
    println!("الطول لـ '{}' هو {}.", s1, len);
}
fn calculate_length(s: &String) -> usize {
    s.len()
}

نتائج التنفيذ:

الطول لـ 'hello' هو 5.

المرجع لن يحصل على ملكية القيمة.

المرجع يمكن أن يستأجر ملكية القيمة فقط.

المرجع نفسه هو أيضًا نوع وله قيمة، وتسجل هذه القيمة موقع القيمة الأخرى، ولكن المرجع لا يمتلك ملكية القيمة الموجودة:

fn main() {
    let s1 = String::from("hello");
    let s2 = &s1;
    let s3 = s1;
    println!("{}", s2);
}

هذا البرنامج غير صحيح: لأن s2 التي استأجرت ملكية s1 قد نقلت الملكية إلى s3، لذا لن يمكن للـ s2 مواصلة استئجار استخدام ملكية s1. إذا كنت بحاجة إلى استخدام القيمة من s2، يجب عليك استئجارها مرة أخرى:

fn main() {
    let s1 = String::from("hello");
    let mut s2 = &s1;
    let s3 = s2;
    s2 = &s3; // تأجير الملكية من جديد من s3
    println!("{}", s2);
}

هذا البرنامج صحيح.

بما أن المرجع لا يمتلك الملكية، حتى لو استأجر الملكية، فإنه يحصل فقط على الحق في الاستخدام (هذا مثل تأجير المنزل).

سيتم منع محاولة استخدام الحقوق المقترضة لتحرير البيانات:

fn main() {
    let s1 = String::from("run");
    let s2 = &s1; 
    println!("{}", s2);
    s2.push_str("oob"); // خطأ، منع تعديل القيمة المقترضة
    println!("{}", s2);
}

في هذا البرنامج، يتم منع محاولة تعديل قيمة s1 من خلال s2، لا يمكن للإقتراض الحصول على القيمة الخاصة بالمالك.

بالطبع، هناك أيضًا طريقة تأجير متغيرة، مثل تأجير منزل، إذا كان المالك يمكنه تعديل بنية المنزل، فإن المالك يعلن هذا الحق في العقد أيضًا عند تأجير المنزل، يمكنك إعادة ترميم المنزل:

fn main() {
    let mut s1 = String::from("run");
    // s1 هو متغير قابل للتغيير
    let s2 = &mut s1;
    // s2 هو مرجع متغير
    s2.push_str("oob");
    println!("{}", s2);
}

هذا البرنامج لا يكون له مشكلة. نستخدم &mut لتحديد نوع المرجع القابل للتغيير.

على عكس المراجع غير القابلة للتغيير، لا تُسمح للمراجع القابلة للتغيير بالمراجع المتعددة، بينما يمكن للمراجع غير القابلة للتغيير القيام بذلك:

let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{}, {}", r1, r2);

هذا البرنامج غير صحيح لأنه يحتوي على مراجع متعددة لـ s.

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

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

المراجع المعلقة (Dangling References)

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

لا يُسمح في لغة Rust بوجود "الإشارة المعلقة"، وسيكتشف المُترجم وجودها.

إليك مثالاً نموذجياً على الإشارة المعلقة:

fn main() {
    let reference_to_nothing = dangle();
}
fn dangle() -> &String {
    let s = String::from("hello");
    &s
}

من الواضح، مع انتهاء دالة dangle، لم يتم استخدام قيمة المتغير المحلي كقيمة عائدة، بل تم إطلاقها. لكن المرجع تم إرجاعه، والقيمة التي يشير إليها هذا المرجع لم تعد معروفة، لذا لا يُسمح بوجودها.