Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ما هي الملكية (Ownership)؟

الملكية (Ownership) هي مجموعة من القواعد التي تحكم كيفية إدارة برنامج Rust للذاكرة. يجب على جميع البرامج إدارة الطريقة التي تستخدم بها ذاكرة الكمبيوتر أثناء التشغيل. تحتوي بعض اللغات على تجميع البيانات المهملة (Garbage Collection - GC) الذي يبحث بانتظام عن الذاكرة التي لم تعد مستخدمة أثناء تشغيل البرنامج؛ وفي لغات أخرى، يجب على المبرمج تخصيص الذاكرة وتحريرها (allocate and free) بشكل صريح. تستخدم Rust نهجًا ثالثًا: تتم إدارة الذاكرة من خلال نظام Ownership مع مجموعة من القواعد التي يتحقق منها المترجم (compiler). إذا تم انتهاك أي من القواعد، فلن يتم تجميع البرنامج. لا توجد أي من ميزات Ownership تبطئ برنامجك أثناء تشغيله.

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

عندما تفهم Ownership، سيكون لديك أساس متين لفهم الميزات التي تجعل Rust فريدة من نوعها. في هذا الفصل، ستتعلم Ownership من خلال العمل على بعض الأمثلة التي تركز على بنية بيانات شائعة جدًا: السلاسل النصية (strings).

المكدس (Stack) والكومة (Heap)

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

كل من الـ Stack والـ Heap هما جزءان من الذاكرة المتاحة للكود الخاص بك لاستخدامها في وقت التشغيل (runtime)، ولكنهما منظمان بطرق مختلفة. يخزن الـ Stack القيم بالترتيب الذي يحصل عليها به ويزيل القيم بالترتيب المعاكس. يشار إلى هذا باسم آخر ما يدخل، أول ما يخرج (Last In, First Out - LIFO). فكر في كومة من الأطباق: عندما تضيف المزيد من الأطباق، تضعها فوق الكومة، وعندما تحتاج إلى طبق، تأخذ واحدًا من الأعلى. لن ينجح إضافة أو إزالة الأطباق من المنتصف أو الأسفل! تسمى إضافة البيانات الدفع إلى الـ Stack (pushing onto the Stack)، وتسمى إزالة البيانات السحب من الـ Stack (popping off the Stack). يجب أن يكون لجميع البيانات المخزنة على الـ Stack حجم معروف وثابت. يجب تخزين البيانات ذات الحجم غير المعروف في وقت التجميع (compile time) أو الحجم الذي قد يتغير على الـ Heap بدلاً من ذلك.

الـ Heap أقل تنظيمًا: عندما تضع بيانات على الـ Heap، فإنك تطلب قدرًا معينًا من المساحة. يجد مخصص الذاكرة (memory allocator) بقعة فارغة في الـ Heap تكون كبيرة بما يكفي، ويضع علامة عليها بأنها قيد الاستخدام، ويعيد مؤشر (pointer)، وهو عنوان ذلك الموقع. تسمى هذه العملية التخصيص على الـ Heap (allocating on the Heap) ويتم اختصارها أحيانًا على أنها مجرد تخصيص (allocating) (لا يعتبر دفع القيم إلى الـ Stack تخصيصًا). نظرًا لأن الـ pointer إلى الـ Heap له حجم معروف وثابت، يمكنك تخزين الـ pointer على الـ Stack، ولكن عندما تريد البيانات الفعلية، يجب عليك اتباع الـ pointer. فكر في الجلوس في مطعم. عندما تدخل، تذكر عدد الأشخاص في مجموعتك، ويجد المضيف طاولة فارغة تناسب الجميع ويقودك إليها. إذا تأخر شخص ما في مجموعتك، يمكنه أن يسأل عن مكان جلوسك للعثور عليك.

الدفع إلى الـ Stack أسرع من الـ allocating على الـ Heap لأن الـ allocator لا يضطر أبدًا إلى البحث عن مكان لتخزين البيانات الجديدة؛ هذا الموقع دائمًا في الجزء العلوي من الـ Stack. بالمقارنة، يتطلب تخصيص مساحة على الـ Heap مزيدًا من العمل لأن الـ allocator يجب أن يجد أولاً مساحة كبيرة بما يكفي لاحتواء البيانات ثم يقوم بحفظ السجلات للتحضير للتخصيص التالي.

يعد الوصول إلى البيانات في الـ Heap أبطأ بشكل عام من الوصول إلى البيانات على الـ Stack لأنه يجب عليك اتباع pointer للوصول إليها. تكون المعالجات (processors) المعاصرة أسرع إذا قفزت حول الذاكرة بشكل أقل. استمرارًا للتشبيه، فكر في خادم في مطعم يأخذ طلبات من العديد من الطاولات. الأكثر كفاءة هو الحصول على جميع الطلبات على طاولة واحدة قبل الانتقال إلى الطاولة التالية. إن أخذ طلب من الطاولة A، ثم طلب من الطاولة B، ثم واحد من A مرة أخرى، ثم واحد من B مرة أخرى سيكون عملية أبطأ بكثير. بنفس الطريقة، يمكن للمعالج عادةً القيام بعمله بشكل أفضل إذا كان يعمل على بيانات قريبة من البيانات الأخرى (كما هو الحال في الـ Stack) بدلاً من أن تكون بعيدة (كما يمكن أن تكون في الـ Heap).

عندما يستدعي الكود الخاص بك دالة، يتم دفع القيم التي تم تمريرها إلى الدالة (بما في ذلك، من المحتمل، pointers إلى البيانات على الـ Heap) والمتغيرات المحلية للدالة إلى الـ Stack. عندما تنتهي الدالة، يتم سحب تلك القيم من الـ Stack.

تتبع الأجزاء التي تستخدم أي بيانات على الـ Heap، وتقليل كمية البيانات المكررة على الـ Heap، وتنظيف البيانات غير المستخدمة على الـ Heap حتى لا تنفد المساحة، كلها مشاكل يعالجها Ownership. بمجرد أن تفهم Ownership، لن تحتاج إلى التفكير في الـ Stack والـ Heap في كثير من الأحيان. ولكن معرفة أن الغرض الرئيسي من Ownership هو إدارة بيانات الـ Heap يمكن أن يساعد في تفسير سبب عمله بالطريقة التي يعمل بها.

قواعد الملكية (Ownership Rules)

أولاً، دعونا نلقي نظرة على قواعد Ownership. ضع هذه القواعد في الاعتبار بينما نعمل على الأمثلة التي توضحها:

  • كل قيمة في Rust لها مالك (owner).
  • يمكن أن يكون هناك owner واحد فقط في كل مرة.
  • عندما يخرج الـ owner من النطاق (scope)، سيتم إسقاط (dropped) القيمة.

نطاق المتغير (Variable Scope)

الآن بعد أن تجاوزنا بناء جملة Rust الأساسي، لن ندرج كل كود fn main() { في الأمثلة، لذا إذا كنت تتابع، فتأكد من وضع الأمثلة التالية داخل دالة main يدويًا. ونتيجة لذلك، ستكون أمثلتنا أكثر إيجازًا، مما يسمح لنا بالتركيز على التفاصيل الفعلية بدلاً من الكود النمطي (boilerplate code).

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

#![allow(unused)]
fn main() {
let s = "hello";
}

يشير المتغير s إلى حرفي السلسلة النصية (string literal)، حيث يتم ترميز قيمة الـ string مباشرة في نص برنامجنا. يكون المتغير صالحًا من النقطة التي يتم فيها الإعلان عنه حتى نهاية الـ scope الحالي. تُظهر القائمة 4-1 برنامجًا يحتوي على تعليقات توضيحية تشير إلى المكان الذي سيكون فيه المتغير s صالحًا.

fn main() {
    {                      // s is not valid here, since it's not yet declared
        let s = "hello";   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}

بمعنى آخر، هناك نقطتان زمنيتان مهمتان هنا:

  • عندما يدخل s إلى الـ scope، يكون صالحًا.
  • يظل صالحًا حتى يخرج من الـ scope.

في هذه المرحلة، العلاقة بين الـ scopes ومتى تكون المتغيرات صالحة تشبه تلك الموجودة في لغات البرمجة الأخرى. الآن سنبني على هذا الفهم من خلال تقديم النوع String.

النوع String

لتوضيح قواعد Ownership، نحتاج إلى نوع بيانات أكثر تعقيدًا من تلك التي تناولناها في قسم “أنواع البيانات” من الفصل 3. الأنواع التي تم تناولها سابقًا ذات حجم معروف، ويمكن تخزينها على الـ Stack وسحبها من الـ Stack عند انتهاء الـ scope الخاص بها، ويمكن نسخها بسرعة وبشكل بسيط لإنشاء مثيل (instance) جديد ومستقل إذا احتاج جزء آخر من الكود إلى استخدام نفس القيمة في scope مختلف. لكننا نريد أن ننظر إلى البيانات المخزنة على الـ Heap ونستكشف كيف تعرف Rust متى يجب تنظيف تلك البيانات، ويعد النوع String مثالًا رائعًا.

سنركز على أجزاء String المتعلقة بـ Ownership. تنطبق هذه الجوانب أيضًا على أنواع البيانات المعقدة الأخرى، سواء تم توفيرها بواسطة المكتبة القياسية (standard library) أو تم إنشاؤها بواسطتك. سنناقش جوانب String غير المتعلقة بـ Ownership في الفصل 8.

لقد رأينا بالفعل string literals، حيث يتم ترميز قيمة string مباشرة في برنامجنا. تعد string literals مريحة، لكنها ليست مناسبة لكل موقف قد نرغب في استخدام نص فيه. أحد الأسباب هو أنها غير قابلة للتغيير (immutable). سبب آخر هو أنه لا يمكن معرفة كل قيمة string عندما نكتب الكود الخاص بنا: على سبيل المثال، ماذا لو أردنا أخذ مدخلات المستخدم (user input) وتخزينها؟ لهذه المواقف، لدى Rust النوع String. يدير هذا النوع البيانات المخصصة على الـ Heap وعلى هذا النحو يمكنه تخزين كمية من النص غير معروفة لنا في compile time. يمكنك إنشاء String من string literal باستخدام الدالة from، على النحو التالي:

#![allow(unused)]
fn main() {
let s = String::from("hello");
}

يسمح لنا عامل التشغيل النقطتين المزدوجتين :: بتسمية هذه الدالة from المعينة تحت النوع String بدلاً من استخدام نوع من الأسماء مثل string_from. سنناقش بناء الجملة هذا أكثر في قسم “الدوال” من الفصل 5، وعندما نتحدث عن التسمية باستخدام الوحدات (modules) في “المسارات للإشارة إلى عنصر في شجرة الوحدة” في الفصل 7.

هذا النوع من الـ string يمكن أن يكون قابلاً للتغيير (mutable):

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{s}"); // this will print `hello, world!`
}

إذن، ما هو الفرق هنا؟ لماذا يمكن تغيير String ولكن string literals لا يمكن تغييرها؟ يكمن الاختلاف في كيفية تعامل هذين النوعين مع الذاكرة.

الذاكرة والتخصيص (Memory and Allocation)

في حالة string literal، نعرف المحتويات في compile time، لذلك يتم ترميز النص مباشرة في الملف التنفيذي النهائي. هذا هو السبب في أن string literals سريعة وفعالة. لكن هذه الخصائص تأتي فقط من عدم قابلية تغيير string literal. لسوء الحظ، لا يمكننا وضع كتلة من الذاكرة في الملف الثنائي (binary) لكل جزء من النص يكون حجمه غير معروف في compile time وقد يتغير حجمه أثناء تشغيل البرنامج.

باستخدام النوع String، لدعم جزء نصي قابل للتغيير والنمو، نحتاج إلى تخصيص (allocate) قدر من الذاكرة على الـ Heap، غير معروف في compile time، لاحتواء المحتويات. هذا يعني:

  • يجب طلب الذاكرة من memory allocator في وقت التشغيل (runtime).
  • نحتاج إلى طريقة لإعادة هذه الذاكرة إلى الـ allocator عندما ننتهي من String الخاص بنا.

يتم تنفيذ الجزء الأول بواسطتنا: عندما نستدعي String::from، يطلب تطبيقه الذاكرة التي يحتاجها. هذا عالمي إلى حد كبير في لغات البرمجة.

ومع ذلك، فإن الجزء الثاني مختلف. في اللغات التي تحتوي على GC (Garbage Collector)، يتتبع الـ GC وينظف الذاكرة التي لم تعد مستخدمة، ولا نحتاج إلى التفكير في الأمر. في معظم اللغات التي لا تحتوي على GC، تقع على عاتقنا مسؤولية تحديد متى لم تعد الذاكرة مستخدمة واستدعاء الكود لتحريرها (free) بشكل صريح، تمامًا كما فعلنا لطلبها. لطالما كانت القيام بذلك بشكل صحيح مشكلة برمجة صعبة تاريخيًا. إذا نسينا، فسنهدر الذاكرة. إذا فعلنا ذلك في وقت مبكر جدًا، فسيكون لدينا متغير غير صالح. إذا فعلنا ذلك مرتين، فهذا bug أيضًا. نحتاج إلى إقران allocate واحد بالضبط مع free واحد بالضبط.

تأخذ Rust نهجًا مختلفًا: الـ owner. دعونا نعود إلى مثال string من القائمة 4-1 باستخدام String بدلاً من string literal:

fn main() {
    {
        let s = String::from("hello"); // s is valid from this point forward

        // do stuff with s
    }                                  // this scope is now over, and s is no
                                       // longer valid
}

هناك نقطة طبيعية يمكننا عندها إعادة الذاكرة التي يحتاجها String الخاص بنا إلى الـ allocator: عندما يخرج s من الـ scope. عندما يخرج متغير من الـ scope، تستدعي Rust دالة خاصة لنا. تسمى هذه الدالة drop، وهي المكان الذي يمكن لمؤلف String أن يضع فيه الكود لإعادة الذاكرة. تستدعي Rust الدالة drop تلقائيًا عند القوس المتعرج الختامي.

ملاحظة: في C++، يسمى هذا النمط من إلغاء تخصيص الموارد في نهاية عمر العنصر أحيانًا اكتساب الموارد هو تهيئة (Resource Acquisition Is Initialization - RAII). ستكون الدالة drop في Rust مألوفة لك إذا كنت قد استخدمت أنماط RAII.

لهذا النمط تأثير عميق على طريقة كتابة كود Rust. قد يبدو بسيطًا الآن، ولكن سلوك الكود يمكن أن يكون غير متوقع في مواقف أكثر تعقيدًا عندما نريد أن تستخدم متغيرات متعددة البيانات التي خصصناها على الـ Heap. دعونا نستكشف بعضًا من هذه المواقف الآن.

تفاعل المتغيرات والبيانات مع النقل (Move)

يمكن أن تتفاعل المتغيرات المتعددة مع نفس البيانات بطرق مختلفة في Rust. تُظهر القائمة 4-2 مثالًا باستخدام عدد صحيح (integer).

fn main() {
    let x = 5;
    let y = x;
}

يمكننا على الأرجح تخمين ما يفعله هذا: “ربط القيمة 5 بـ x؛ ثم، عمل نسخة من القيمة في x وربطها بـ y.” لدينا الآن متغيران، x و y، وكلاهما يساوي 5. هذا هو بالفعل ما يحدث، لأن integers هي قيم بسيطة ذات حجم معروف وثابت، ويتم دفع هاتين القيمتين 5 إلى الـ Stack.

الآن دعونا نلقي نظرة على نسخة String:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

يبدو هذا مشابهًا جدًا، لذلك قد نفترض أن طريقة عمله ستكون هي نفسها: أي أن السطر الثاني سيقوم بعمل نسخة من القيمة في s1 وربطها بـ s2. لكن هذا ليس ما يحدث تمامًا.

ألق نظرة على الشكل 4-1 لترى ما يحدث لـ String تحت الغطاء. يتكون String من ثلاثة أجزاء، موضحة على اليسار: pointer إلى الذاكرة التي تحتوي على محتويات الـ string، وطول (length)، وسعة (capacity). يتم تخزين هذه المجموعة من البيانات على الـ Stack. على اليمين توجد الذاكرة على الـ Heap التي تحتوي على المحتويات.

جدولان: يحتوي الجدول الأول على تمثيل s1 على الـ Stack، ويتكون من طوله (5)، وسعته (5)، ومؤشر إلى القيمة الأولى في الجدول الثاني. يحتوي الجدول الثاني على تمثيل بيانات الـ string على الـ Heap، بايتًا بايتًا.

الشكل 4-1: تمثيل String في الذاكرة الذي يحمل القيمة "hello" المرتبطة بـ s1

الـ length هو مقدار الذاكرة، بالبايت، التي تستخدمها محتويات String حاليًا. الـ capacity هي إجمالي كمية الذاكرة، بالبايت، التي تلقاها String من الـ allocator. الفرق بين الـ length والـ capacity مهم، ولكن ليس في هذا السياق، لذلك في الوقت الحالي، لا بأس بتجاهل الـ capacity.

عندما نخصص s1 لـ s2، يتم نسخ بيانات String، مما يعني أننا ننسخ الـ pointer والـ length والـ capacity الموجودة على الـ Stack. نحن لا ننسخ البيانات الموجودة على الـ Heap التي يشير إليها الـ pointer. بمعنى آخر، يبدو تمثيل البيانات في الذاكرة كما في الشكل 4-2.

ثلاثة جداول: جداول s1 و s2 تمثل تلك الـ strings على الـ Stack، على التوالي، وكلاهما يشير إلى نفس بيانات الـ string على الـ Heap.

الشكل 4-2: تمثيل المتغير s2 في الذاكرة الذي يحتوي على نسخة من الـ pointer والـ length والـ capacity لـ s1

التمثيل لا يبدو مثل الشكل 4-3، وهو ما ستبدو عليه الذاكرة إذا نسخت Rust بيانات الـ Heap أيضًا. إذا فعلت Rust ذلك، فقد تكون عملية s2 = s1 مكلفة للغاية من حيث أداء وقت التشغيل إذا كانت البيانات الموجودة على الـ Heap كبيرة.

أربعة جداول: جدولان يمثلان بيانات الـ Stack لـ s1 و s2، وكل منهما يشير إلى نسخته الخاصة من بيانات الـ string على الـ Heap.

الشكل 4-3: احتمال آخر لما قد تفعله s2 = s1 إذا نسخت Rust بيانات الـ Heap أيضًا

في وقت سابق، قلنا أنه عندما يخرج متغير من الـ scope، تستدعي Rust تلقائيًا الدالة drop وتنظف ذاكرة الـ Heap لذلك المتغير. لكن الشكل 4-2 يظهر أن كلا الـ pointers يشيران إلى نفس الموقع. هذه مشكلة: عندما يخرج s2 و s1 من الـ scope، سيحاول كلاهما تحرير نفس الذاكرة. يُعرف هذا باسم خطأ التحرير المزدوج (double free) وهو أحد أخطاء سلامة الذاكرة (memory safety bugs) التي ذكرناها سابقًا. يمكن أن يؤدي تحرير الذاكرة مرتين إلى تلف الذاكرة (memory corruption)، مما قد يؤدي إلى ثغرات أمنية (security vulnerabilities).

لضمان memory safety، بعد السطر let s2 = s1;، تعتبر Rust أن s1 لم يعد صالحًا. لذلك، لا تحتاج Rust إلى تحرير أي شيء عندما يخرج s1 من الـ scope. تحقق مما يحدث عندما تحاول استخدام s1 بعد إنشاء s2؛ لن ينجح:

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

    println!("{s1}, world!");
}

ستحصل على خطأ مثل هذا لأن Rust تمنعك من استخدام المرجع (reference) الذي تم إبطاله:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

إذا كنت قد سمعت مصطلحي النسخ السطحي (shallow copy) و النسخ العميق (deep copy) أثناء العمل مع لغات أخرى، فمن المحتمل أن يبدو مفهوم نسخ الـ pointer والـ length والـ capacity دون نسخ البيانات وكأنه عمل shallow copy. ولكن نظرًا لأن Rust تبطل أيضًا المتغير الأول، فبدلاً من تسميته shallow copy، فإنه يُعرف باسم النقل (move). في هذا المثال، نقول إن s1 تم نقله إلى s2. لذا، فإن ما يحدث بالفعل موضح في الشكل 4-4.

ثلاثة جداول: جداول s1 و s2 تمثل تلك الـ strings على الـ Stack، على التوالي، وكلاهما يشير إلى نفس بيانات الـ string على الـ Heap. تم تظليل الجدول s1 باللون الرمادي لأن s1 لم يعد صالحًا؛ يمكن استخدام s2 فقط للوصول إلى بيانات الـ Heap.

الشكل 4-4: تمثيل الذاكرة بعد إبطال s1

هذا يحل مشكلتنا! مع صلاحية s2 فقط، عندما يخرج من الـ scope، سيقوم وحده بتحرير الذاكرة، ونكون قد انتهينا.

بالإضافة إلى ذلك، هناك خيار تصميم ضمني بهذا: لن تقوم Rust أبدًا بإنشاء نسخ “deep” لبياناتك تلقائيًا. لذلك، يمكن افتراض أن أي نسخ تلقائي غير مكلف من حيث أداء وقت التشغيل.

النطاق والتعيين (Scope and Assignment)

العكس صحيح بالنسبة للعلاقة بين الـ scoping و Ownership والذاكرة التي يتم تحريرها عبر الدالة drop أيضًا. عندما تقوم بتعيين قيمة جديدة تمامًا لمتغير موجود، ستستدعي Rust الدالة drop وتحرر ذاكرة القيمة الأصلية على الفور. ضع في اعتبارك هذا الكود، على سبيل المثال:

fn main() {
    let mut s = String::from("hello");
    s = String::from("ahoy");

    println!("{s}, world!");
}

نعلن في البداية عن متغير s ونربطه بـ String بالقيمة "hello". ثم، نقوم على الفور بإنشاء String جديد بالقيمة "ahoy" ونعينه لـ s. في هذه المرحلة، لا يوجد شيء يشير إلى القيمة الأصلية على الـ Heap على الإطلاق. يوضح الشكل 4-5 بيانات الـ Stack والـ Heap الآن:

جدول واحد يمثل قيمة الـ string على الـ Stack، ويشير إلى الجزء الثاني من بيانات الـ string (ahoy) على الـ Heap، مع تظليل بيانات الـ string الأصلية (hello) باللون الرمادي لأنه لا يمكن الوصول إليها بعد الآن.

الشكل 4-5: تمثيل الذاكرة بعد استبدال القيمة الأولية بالكامل

وبالتالي، تخرج الـ string الأصلية من الـ scope على الفور. ستقوم Rust بتشغيل الدالة drop عليها وسيتم تحرير ذاكرتها على الفور. عندما نطبع القيمة في النهاية، ستكون "ahoy, world!".

تفاعل المتغيرات والبيانات مع الاستنساخ (Clone)

إذا أردنا عمل deep copy لبيانات الـ Heap لـ String، وليس فقط بيانات الـ Stack، فيمكننا استخدام دالة شائعة تسمى clone. سنناقش بناء جملة الـ methods في الفصل 5، ولكن نظرًا لأن الـ methods هي ميزة شائعة في العديد من لغات البرمجة، فمن المحتمل أنك رأيتها من قبل.

إليك مثال على دالة clone قيد التنفيذ:

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

    println!("s1 = {s1}, s2 = {s2}");
}

يعمل هذا بشكل جيد وينتج صراحة السلوك الموضح في الشكل 4-3، حيث يتم نسخ بيانات الـ Heap.

عندما ترى استدعاء لـ clone، فأنت تعلم أنه يتم تنفيذ بعض الكود التعسفي وأن هذا الكود قد يكون مكلفًا. إنه مؤشر مرئي على أن شيئًا مختلفًا يحدث.

بيانات الـ Stack فقط: النسخ (Copy)

هناك تجعيدة أخرى لم نتحدث عنها بعد. هذا الكود الذي يستخدم integers - والذي تم عرض جزء منه في القائمة 4-2 - يعمل وصالح:

fn main() {
    let x = 5;
    let y = x;

    println!("x = {x}, y = {y}");
}

لكن هذا الكود يبدو وكأنه يتناقض مع ما تعلمناه للتو: ليس لدينا استدعاء لـ clone، لكن x لا يزال صالحًا ولم يتم نقله إلى y.

السبب هو أن الأنواع مثل integers التي لها حجم معروف في compile time يتم تخزينها بالكامل على الـ Stack، لذا فإن نسخ القيم الفعلية سريعة التنفيذ. هذا يعني أنه لا يوجد سبب يدعونا إلى منع x من أن يكون صالحًا بعد إنشاء المتغير y. بمعنى آخر، لا يوجد فرق بين deep copy و shallow copy هنا، لذا فإن استدعاء clone لن يفعل أي شيء مختلف عن الـ shallow copy المعتاد، ويمكننا حذفه.

لدى Rust تعليق توضيحي خاص يسمى سمة النسخ (Copy trait) يمكننا وضعه على الأنواع المخزنة على الـ Stack، كما هو الحال مع integers (سنتحدث أكثر عن الـ traits في الفصل 10). إذا طبق نوع ما الـ Copy trait، فإن المتغيرات التي تستخدمه لا يتم نقلها (move)، بل يتم نسخها بشكل بسيط، مما يجعلها لا تزال صالحة بعد التعيين لمتغير آخر.

لن تسمح لنا Rust بتعليق نوع بـ Copy إذا كان النوع، أو أي من أجزائه، قد طبق الـ Drop trait. إذا كان النوع يحتاج إلى حدوث شيء خاص عندما تخرج القيمة من الـ scope وأضفنا التعليق التوضيحي Copy إلى هذا النوع، فسنحصل على خطأ في compile time. لمعرفة كيفية إضافة التعليق التوضيحي Copy إلى نوعك لتطبيق الـ trait، راجع “السمات القابلة للاشتقاق” في الملحق ج.

إذن، ما هي الأنواع التي تطبق الـ Copy trait؟ يمكنك التحقق من توثيق النوع المحدد للتأكد، ولكن كقاعدة عامة، يمكن لأي مجموعة من القيم العددية (scalar values) البسيطة تطبيق Copy، ولا يمكن لأي شيء يتطلب تخصيصًا (allocation) أو هو شكل من أشكال المورد (resource) تطبيق Copy. فيما يلي بعض الأنواع التي تطبق Copy:

  • جميع أنواع integers، مثل u32.
  • النوع المنطقي (Boolean)، bool، بالقيمتين true و false.
  • جميع أنواع الفاصلة العائمة (floating-point)، مثل f64.
  • نوع الحرف (character)، char.
  • الصفوف (Tuples)، إذا كانت تحتوي فقط على أنواع تطبق Copy أيضًا. على سبيل المثال، يطبق (i32, i32) الـ Copy، لكن (i32, String) لا يطبقه.

الملكية والدوال (Ownership and Functions)

آلية تمرير قيمة إلى دالة تشبه تلك الخاصة بتعيين قيمة لمتغير. سيؤدي تمرير متغير إلى دالة إلى move أو copy، تمامًا كما يفعل التعيين. تحتوي القائمة 4-3 على مثال مع بعض التعليقات التوضيحية التي توضح أين تدخل المتغيرات وتخرج من الـ scope.

fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // Because i32 implements the Copy trait,
                                    // x does NOT move into the function,
                                    // so it's okay to use x afterward.

} // Here, x goes out of scope, then s. However, because s's value was moved,
  // nothing special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{some_string}");
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{some_integer}");
} // Here, some_integer goes out of scope. Nothing special happens.

إذا حاولنا استخدام s بعد الاستدعاء لـ takes_ownership، فستقوم Rust بإلقاء خطأ في compile time. تحمينا هذه الفحوصات الثابتة (static checks) من الأخطاء. حاول إضافة كود إلى main يستخدم s و x لترى أين يمكنك استخدامهما وأين تمنعك قواعد Ownership من القيام بذلك.

قيم الإرجاع والنطاق (Return Values and Scope)

يمكن أن تؤدي قيم الإرجاع أيضًا إلى نقل Ownership. تُظهر القائمة 4-4 مثالًا لدالة تُرجع بعض القيم، مع تعليقات توضيحية مماثلة لتلك الموجودة في القائمة 4-3.

fn main() {
    let s1 = gives_ownership();        // gives_ownership moves its return
                                       // value into s1

    let s2 = String::from("hello");    // s2 comes into scope

    let s3 = takes_and_gives_back(s2); // s2 is moved into
                                       // takes_and_gives_back, which also
                                       // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {       // gives_ownership will move its
                                       // return value into the function
                                       // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                        // some_string is returned and
                                       // moves out to the calling
                                       // function
}

// This function takes a String and returns a String.
fn takes_and_gives_back(a_string: String) -> String {
    // a_string comes into
    // scope

    a_string  // a_string is returned and moves out to the calling function
}

يتبع Ownership لمتغير نفس النمط في كل مرة: تعيين قيمة لمتغير آخر ينقلها (moves it). عندما يخرج متغير يتضمن بيانات على الـ Heap من الـ scope، سيتم تنظيف القيمة بواسطة drop ما لم يتم نقل Ownership للبيانات إلى متغير آخر.

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

تسمح لنا Rust بإرجاع قيم متعددة باستخدام tuple، كما هو موضح في القائمة 4-5.

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}

لكن هذا كثير جدًا من الإجراءات والكثير من العمل لمفهوم يجب أن يكون شائعًا. لحسن حظنا، لدى Rust ميزة لاستخدام قيمة دون نقل Ownership: الـ references.