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

التحقق من صحة المراجع باستخدام فترات الحياة (Lifetimes)

تعد فترات الحياة (Lifetimes) نوعاً آخر من الأنواع العامة (Generics) التي كنا نستخدمها بالفعل. بدلاً من التأكد من أن النوع يمتلك السلوك الذي نريده، تضمن الـ Lifetimes أن المراجع (References) تظل صالحة طالما احتجنا إليها.

أحد التفاصيل التي لم نناقشها في قسم “المراجع والاستعارة” في الفصل الرابع هو أن كل Reference في Rust له Lifetime، وهو النطاق (Scope) الذي يكون فيه هذا المرجع صالحاً. في معظم الأوقات، تكون الـ Lifetimes ضمنية ومستنتجة، تماماً كما يتم استنتاج الأنواع في معظم الأوقات. نحن مطالبون فقط بتوضيح (Annotate) الأنواع عندما يكون هناك عدة أنواع ممكنة. وبطريقة مماثلة، يجب علينا توضيح الـ Lifetimes عندما يمكن أن ترتبط فترات حياة المراجع ببعضها البعض بعدة طرق مختلفة. تتطلب Rust منا توضيح هذه العلاقات باستخدام معاملات فترات الحياة العامة (Generic Lifetime Parameters) لضمان أن المراجع الفعلية المستخدمة في وقت التشغيل ستكون صالحة بالتأكيد.

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

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

الهدف الرئيسي من الـ Lifetimes هو منع المراجع المعلقة (Dangling References)، والتي إذا سُمح بوجودها، فستتسبب في إشارة البرنامج إلى بيانات غير البيانات المقصود الإشارة إليها. فكر في البرنامج الموجود في القائمة 10-16، والذي يحتوي على نطاق خارجي (Outer Scope) ونطاق داخلي (Inner Scope).

fn main() {
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {r}");
}

ملاحظة: تعلن الأمثلة في القوائم 10-16 و10-17 و10-23 عن متغيرات دون إعطائها قيمة أولية، لذا فإن اسم المتغير موجود في الـ Outer Scope. للوهلة الأولى، قد يبدو هذا متعارضاً مع عدم وجود قيم فارغة (Null) في Rust. ومع ذلك، إذا حاولنا استخدام متغير قبل إعطائه قيمة، فسنحصل على خطأ في وقت التجميع (Compile-time Error)، مما يوضح أن Rust بالفعل لا تسمح بالقيم الـ Null.

يعلن الـ Outer Scope عن متغير باسم r بدون قيمة أولية، ويعلن الـ Inner Scope عن متغير باسم x بقيمة أولية قدرها 5. داخل الـ Inner Scope، نحاول تعيين قيمة r كمرجع لـ x. ثم ينتهي الـ Inner Scope، ونحاول طباعة القيمة الموجودة في r. لن يتم تجميع هذا الكود، لأن القيمة التي يشير إليها r قد خرجت عن الـ Scope قبل أن نحاول استخدامها. إليك رسالة الخطأ:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `x` does not live long enough
 --> src/main.rs:6:13
  |
5 |         let x = 5;
  |             - binding `x` declared here
6 |         r = &x;
  |             ^^ borrowed value does not live long enough
7 |     }
  |     - `x` dropped here while still borrowed
8 |
9 |     println!("r: {r}");
  |                   - borrow later used here

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

تقول رسالة الخطأ أن المتغير x “لا يعيش لفترة كافية”. والسبب هو أن x سيخرج عن الـ Scope عندما ينتهي الـ Inner Scope في السطر 7. لكن r لا يزال صالحاً للـ Outer Scope؛ ولأن نطاقه أكبر، نقول إنه “يعيش لفترة أطول”. إذا سمحت Rust لهذا الكود بالعمل، فسيشير r إلى ذاكرة تم إلغاء تخصيصها عندما خرج x عن الـ Scope، وأي شيء نحاول القيام به باستخدام r لن يعمل بشكل صحيح. إذاً، كيف تحدد Rust أن هذا الكود غير صالح؟ إنها تستخدم مدقق الاستعارة (Borrow Checker).

مدقق الاستعارة (Borrow Checker)

يحتوي مترجم Rust على مدقق استعارة (Borrow Checker) يقارن النطاقات لتحديد ما إذا كانت جميع الاستعارات (Borrows) صالحة. توضح القائمة 10-17 نفس الكود الموجود في القائمة 10-16 ولكن مع توضيحات تظهر الـ Lifetimes للمتغيرات.

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {r}");   //          |
}                         // ---------+

هنا، قمنا بتوضيح الـ Lifetime لـ r بـ 'a والـ Lifetime لـ x بـ 'b. كما ترون، فإن كتلة 'b الداخلية أصغر بكثير من كتلة الـ Lifetime 'a الخارجية. في وقت التجميع، تقارن Rust حجم فترتي الحياة وترى أن r له Lifetime قدره 'a ولكنه يشير إلى ذاكرة بـ Lifetime قدره 'b. يتم رفض البرنامج لأن 'b أقصر من 'a: فصاحب المرجع لا يعيش طويلاً مثل المرجع نفسه.

تقوم القائمة 10-18 بإصلاح الكود بحيث لا يحتوي على Dangling Reference ويتم تجميعه دون أي أخطاء.

fn main() {
    let x = 5;            // ----------+-- 'b
                          //           |
    let r = &x;           // --+-- 'a  |
                          //   |       |
    println!("r: {r}");   //   |       |
                          // --+       |
}                         // ----------+

هنا، يمتلك x الـ Lifetime 'b والتي في هذه الحالة أكبر من 'a. هذا يعني أن r يمكنه الإشارة إلى x لأن Rust تعلم أن المرجع في r سيكون دائماً صالحاً طالما أن x صالح.

الآن بعد أن عرفت أين توجد الـ Lifetimes للمراجع وكيف تحلل Rust فترات الحياة لضمان أن المراجع ستكون دائماً صالحة، دعنا نستكشف الـ Generic Lifetimes في معاملات الدوال والقيم المرتجعة.

فترات الحياة العامة في الدوال (Generic Lifetimes in Functions)

سنكتب دالة تعيد السلسلة الأطول من بين شريحتين نصيتين (String Slices). ستأخذ هذه الدالة شريحتين نصيتين وتعيد شريحة نصية واحدة. بعد تنفيذ دالة longest يجب أن يطبع الكود في القائمة 10-19 عبارة The longest string is abcd.

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

لاحظ أننا نريد أن تأخذ الدالة String Slices، وهي مراجع، بدلاً من سلاسل نصية (Strings)، لأننا لا نريد لدالة longest أن تأخذ ملكية (Ownership) معاملاتها. ارجع إلى قسم “شرائح النصوص كمعاملات” في الفصل الرابع لمزيد من النقاش حول سبب كون المعاملات التي نستخدمها في القائمة 10-19 هي التي نريدها.

إذا حاولنا تنفيذ دالة longest كما هو موضح في القائمة 10-20، فلن يتم تجميعها.

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}

بدلاً من ذلك، نحصل على خطأ يتحدث عن الـ Lifetimes:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0106]: missing lifetime specifier
 --> src/main.rs:9:33
  |
9 | fn longest(x: &str, y: &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 `x` or `y`
help: consider introducing a named lifetime parameter
  |
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  |           ++++     ++          ++          ++

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

يكشف نص المساعدة أن النوع المرتجع يحتاج إلى Generic Lifetime Parameter عليه لأن Rust لا تستطيع تحديد ما إذا كان المرجع الذي يتم إرجاعه يشير إلى x أو y. في الواقع، نحن لا نعرف ذلك أيضاً، لأن كتلة if في جسم هذه الدالة تعيد مرجعاً لـ x وكتلة else تعيد مرجعاً لـ y!

عندما نقوم بتعريف هذه الدالة، فإننا لا نعرف القيم الملموسة (Concrete Values) التي سيتم تمريرها إليها، لذا لا نعرف ما إذا كانت حالة if أو حالة else هي التي ستنفذ. كما أننا لا نعرف الـ Concrete Lifetimes للمراجع التي سيتم تمريرها، لذا لا يمكننا النظر في النطاقات كما فعلنا في القوائم 10-17 و10-18 لتحديد ما إذا كان المرجع الذي نعيده سيكون دائماً صالحاً. لا يمكن للـ Borrow Checker تحديد ذلك أيضاً، لأنه لا يعرف كيف ترتبط فترات حياة x و y بـ Lifetime القيمة المرتجعة. لإصلاح هذا الخطأ، سنضيف Generic Lifetime Parameters تحدد العلاقة بين المراجع حتى يتمكن الـ Borrow Checker من إجراء تحليله.

صيغة توضيح فترة الحياة (Lifetime Annotation Syntax)

لا تغير توضيحات فترة الحياة (Lifetime Annotations) مدة بقاء أي من المراجع. بدلاً من ذلك، فهي تصف علاقات فترات حياة مراجع متعددة ببعضها البعض دون التأثير على فترات الحياة. تماماً كما يمكن للدوال قبول أي نوع عندما يحدد التوقيع (Signature) معامل نوع عام، يمكن للدوال قبول مراجع بأي Lifetime من خلال تحديد Generic Lifetime Parameter.

تمتلك الـ Lifetime Annotations صيغة غير معتادة قليلاً: يجب أن تبدأ أسماء معاملات فترة الحياة بفاصلة عليا (') وعادة ما تكون كلها أحرفاً صغيرة وقصيرة جداً، مثل الأنواع العامة. يستخدم معظم الناس الاسم 'a لأول Lifetime Annotation. نضع توضيحات معاملات فترة الحياة بعد علامة & للمرجع، مع استخدام مسافة لفصل التوضيح عن نوع المرجع.

إليك بعض الأمثلة: مرجع لـ i32 بدون معامل فترة حياة، ومرجع لـ i32 له معامل فترة حياة باسم 'a ومرجع قابل للتغيير (Mutable Reference) لـ i32 له أيضاً فترة الحياة 'a:

&i32        // مرجع
&'a i32     // مرجع مع فترة حياة صريحة
&'a mut i32 // مرجع قابل للتغيير مع فترة حياة صريحة

توضيح فترة حياة واحد بمفرده ليس له معنى كبير، لأن التوضيحات تهدف إلى إخبار Rust بكيفية ارتباط الـ Generic Lifetime Parameters لمراجع متعددة ببعضها البعض. دعنا نفحص كيف ترتبط الـ Lifetime Annotations ببعضها البعض في سياق دالة longest.

في تواقيع الدوال (Function Signatures)

لاستخدام الـ Lifetime Annotations في تواقيع الدوال، نحتاج إلى الإعلان عن الـ Generic Lifetime Parameters داخل أقواس زاوية بين اسم الدالة وقائمة المعاملات، تماماً كما فعلنا مع معاملات الأنواع العامة.

نريد أن يعبر الـ Signature عن القيد التالي: المرجع المرتجع سيكون صالحاً طالما أن كلا المعاملين صالحان. هذه هي العلاقة بين فترات حياة المعاملات والقيمة المرتجعة. سنسمي فترة الحياة 'a ثم نضيفها إلى كل مرجع، كما هو موضح في القائمة 10-21.

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

يجب أن يتم تجميع هذا الكود وينتج النتيجة التي نريدها عندما نستخدمه مع دالة main في القائمة 10-19.

يخبر الـ Signature الآن Rust أنه لـ Lifetime ما 'a تأخذ الدالة معاملين، كلاهما String Slices يعيشان على الأقل طالما تعيش فترة الحياة 'a. يخبر الـ Signature أيضاً Rust أن الـ String Slice المرتجع من الدالة سيعيش على الأقل طالما تعيش فترة الحياة 'a. من الناحية العملية، هذا يعني أن فترة حياة المرجع الذي تعيده الدالة… (تم اختصار المحتوى بسبب حدود الحجم)

fn first_word<'a>(s: &'a str) -> &'a str {

الآن تمتلك جميع المراجع في الـ Signature فترات حياة، ويمكن للمترجم مواصلة تحليله دون حاجة المبرمج لتوضيح فترات الحياة في هذا الـ Signature.

دعنا ننظر في مثال آخر، هذه المرة باستخدام دالة longest التي لم تكن تحتوي على معاملات فترة حياة عندما بدأنا العمل عليها في القائمة 10-20:

fn longest(x: &str, y: &str) -> &str {

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

fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {

يمكنك أن ترى أن القاعدة الثانية لا تنطبق، لأن هناك أكثر من فترة حياة مدخلة واحدة. القاعدة الثالثة لا تنطبق أيضاً، لأن longest هي دالة وليست دالة مرتبطة (Method)، لذا لا يوجد أي من المعاملات هو self. بعد المرور بجميع القواعد الثلاث، ما زلنا لم نكتشف ما هي فترة حياة النوع المرتجع. لهذا السبب حصلنا على خطأ عند محاولة تجميع الكود في القائمة 10-20: لقد مر المترجم عبر قواعد حذف فترة الحياة (Lifetime Elision Rules) ولكنه لم يتمكن بعد من اكتشاف جميع فترات حياة المراجع في الـ Signature.

ولأن القاعدة الثالثة تنطبق حقاً فقط في تواقيع الدوال المرتبطة (Method Signatures)، فسننظر في فترات الحياة في ذلك السياق تالياً لنرى لماذا تعني القاعدة الثالثة أننا لا نضطر لتوضيح فترات الحياة في الـ Method Signatures في كثير من الأحيان.

في تعريفات الدوال المرتبطة (Method Definitions)

عندما نقوم بتنفيذ Methods على هيكل (Struct) يحتوي على فترات حياة، نستخدم نفس الصيغة المستخدمة لمعاملات الأنواع العامة، كما هو موضح في القائمة 10-11. يعتمد مكان الإعلان عن معاملات فترات الحياة واستخدامها على ما إذا كانت مرتبطة بحقول الهيكل أو بمعاملات الـ Method والقيم المرتجعة.

يجب دائماً الإعلان عن أسماء فترات الحياة لحقول الهيكل بعد الكلمة المفتاحية impl ثم استخدامها بعد اسم الهيكل لأن فترات الحياة هذه جزء من نوع الهيكل.

في الـ Method Signatures داخل كتلة impl قد تكون المراجع مرتبطة بفترة حياة المراجع في حقول الهيكل، أو قد تكون مستقلة. بالإضافة إلى ذلك، غالباً ما تجعل الـ Lifetime Elision Rules توضيحات فترة الحياة غير ضرورية في الـ Method Signatures. دعنا ننظر في بعض الأمثلة باستخدام الهيكل المسمى ImportantExcerpt الذي عرفناه في القائمة 10-24.

أولاً، سنستخدم Method تسمى level معاملها الوحيد هو مرجع لـ self وقيمتها المرتجعة هي i32 وهو ليس مرجعاً لأي شيء:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {announcement}");
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

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

إليك مثال تنطبق فيه الـ Lifetime Elision Rule الثالثة:

struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl<'a> ImportantExcerpt<'a> {
    fn announce_and_return_part(&self, announcement: &str) -> &str {
        println!("Attention please: {announcement}");
        self.part
    }
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().unwrap();
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

هناك فترتا حياة مدخلتان، لذا تطبق Rust قاعدة حذف فترة الحياة الأولى وتعطي كلاً من &self و announcement فترات حياة خاصة بهما. ثم، ولأن أحد المعاملات هو &self يحصل النوع المرتجع على فترة حياة &self وبذلك تم حساب جميع فترات الحياة.

فترة الحياة الساكنة (Static Lifetime)

إحدى فترات الحياة الخاصة التي نحتاج لمناقشتها هي 'static والتي تشير إلى أن المرجع المتأثر يمكن أن يعيش طوال مدة البرنامج. جميع السلاسل النصية الثابتة (String Literals) لها فترة الحياة 'static والتي يمكننا توضيحها كما يلي:

#![allow(unused)]
fn main() {
let s: &'static str = "I have a static lifetime.";
}

يتم تخزين نص هذه السلسلة مباشرة في الملف الثنائي للبرنامج، والذي يكون متاحاً دائماً. لذلك، فإن فترة حياة جميع الـ String Literals هي 'static.

قد ترى اقتراحات في رسائل الخطأ لاستخدام فترة الحياة 'static. ولكن قبل تحديد 'static كفترة حياة لمرجع ما، فكر فيما إذا كان المرجع الذي لديك يعيش بالفعل طوال فترة حياة برنامجك، وما إذا كنت تريد ذلك. في معظم الأوقات، تنتج رسالة الخطأ التي تقترح فترة الحياة 'static عن محاولة إنشاء Dangling Reference أو عدم تطابق في فترات الحياة المتاحة. في مثل هذه الحالات، الحل هو إصلاح تلك المشكلات، وليس تحديد فترة الحياة 'static.

معاملات الأنواع العامة، وقيود السمات، وفترات الحياة معاً

دعنا نلقي نظرة سريعة على صيغة تحديد معاملات الأنواع العامة، وقيود السمات (Trait Bounds)، وفترات الحياة جميعاً في دالة واحدة!

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest_with_an_announcement(
        string1.as_str(),
        string2,
        "Today is someone's birthday!",
    );
    println!("The longest string is {result}");
}

use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("Announcement! {ann}");
    if x.len() > y.len() { x } else { y }
}

هذه هي دالة longest من القائمة 10-21 التي تعيد الأطول بين شريحتين نصيتين. ولكن الآن لديها معامل إضافي باسم ann من النوع العام T والذي يمكن ملؤه بأي نوع ينفذ سمة Display كما هو محدد في بند where. سيتم طباعة هذا المعامل الإضافي باستخدام {} ولهذا السبب كان قيد سمة Display ضرورياً. ولأن فترات الحياة هي نوع من الأنواع العامة، فإن الإعلانات عن معامل فترة الحياة 'a ومعامل النوع العام T توضع في نفس القائمة داخل أقواس الزاوية بعد اسم الدالة.

ملخص

لقد غطينا الكثير في هذا الفصل! الآن بعد أن عرفت عن معاملات الأنواع العامة، والسمات وقيود السمات، ومعاملات فترات الحياة العامة، فأنت جاهز لكتابة كود بدون تكرار يعمل في العديد من المواقف المختلفة. تسمح لك معاملات الأنواع العامة بتطبيق الكود على أنواع مختلفة. تضمن السمات وقيود السمات أنه على الرغم من أن الأنواع عامة، إلا أنها ستمتلك السلوك الذي يحتاجه الكود. لقد تعلمت كيفية استخدام الـ Lifetime Annotations لضمان أن هذا الكود المرن لن يحتوي على أي Dangling References. وكل هذا التحليل يحدث في وقت التجميع، مما لا يؤثر على أداء وقت التشغيل!

صدق أو لا تصدق، هناك الكثير لتعلمه حول المواضيع التي ناقشناها في هذا الفصل: يناقش الفصل 18 كائنات السمات (Trait Objects)، وهي طريقة أخرى لاستخدام السمات. هناك أيضاً سيناريوهات أكثر تعقيداً تتضمن الـ Lifetime Annotations والتي ستحتاج إليها فقط في سيناريوهات متقدمة جداً؛ ولتلك الحالات، يجب عليك قراءة مرجع Rust. ولكن تالياً، ستتعلم كيفية كتابة الاختبارات في Rust حتى تتمكن من التأكد من أن الكود الخاص بك يعمل بالطريقة التي ينبغي أن يعمل بها.