آموزش زبان برنامهنویسی Rust- قسمت ۲۰: طول عمر یا Lifetime
فهرست مطالب
منظور از طول عمر یا Lifetime چیست؟
هر رفرنس در یک بخش (scope) مشخّص تعریف شده است. یعنی وقتی ما یک رفرنس را درون یک بخش تعریف میکنیم، زندگی آن آغاز میشود و هنگامی که جریان اجرای برنامه از آن بخش خارج میشود، زندگی آن رفرنس نگونبخت هم پایان مییابد و از این جهان فانی رخت برمیبندد.
به بخشی (scope) از کد که رفرنس در آن تعریف شده است، طول عمر یا Lifetime میگویند.
دو نکتهی مهم در این تعریف وجود دارد:
۱− طول عمر صرفاً برای رفرنسها تعریف میشود و باقی نوعدادهها اصلاً چنین مفهومی ندارند.
۲− طول عمر یک رفرنس ممکن است شامل چندین بخش (scope) شود. کافی است که درون بخشی که در آن تعریف شده است چندین بخش (scope) دیگر تعریف شده باشد.
برای روشنتر شدن موضوع به تکّه کد زیر نگاه کنید:
fn main() { // -------------------- 'a | let a; // | { // ------ 'b | let b = 10; // | a = &b; // | } // ------ 'b | println!("a = {}", a); // ------------------------| } // -------------------- 'a |
بخشی که با a'
شروع شده است جایی است که زندگی a
شروع میشود. جلوتر دلیل وجود علامت '
که در کامنتها برای نشاندادن شروع عمر یک رفرنس آمده است را میفهمیم. بخشی هم که با b'
شروع شده است جایی است که زندگی b
آغاز میشود. همانطوری که میبینید a
قبل از اینکه b
متولّد شود زنده است و پس از مردن آن هم به زندگی ادامه داده است. به همین خاطر عمر a
شامل دو تا بخش (scope) است. یکی بخشی که خودش در آن تعریف شده است و یکی هم بخشی که b
در آن تعریف شده است.
چرا Rust برخلاف اکثر زبانها حواسش به طول عمر رفرنسها هست؟
قبل از پاسخدادن به این چرا، سعی کنید که برنامهی قبلی را کامپایل کنید. با چه چیزی روبهرو میشوید؟
error[E0597]: `b` does not live long enough --> src/main.rs:6:9 | 6 | a = &b; // | | ^^^^^^ borrowed value does not live long enough 7 | } // ------ 'b | | - `b` dropped here while still borrowed 8 | println!("a = {}", a); // ------------------------| | - borrow later used here
کامپایلر جلوی اجرای این برنامه را میگیرد. دلیلش را هم خیلی واضح نوشته است: b
به اندازهی کافی زنده نمانده است تا بتوانیم از مقدارش در دستور println
استفاده کنیم. چرا؟ چون a
تنها یک رفرنس به مقدار b
است و اینجا دیگر b
وجود ندارد که ما بتوانیم به مقدار آن اشاره کنیم.
حالا برویم سراغ سؤالی که در تیتر آمده است. چرا Rust حواسش به این موضوع است؟ بیایید کد مشابه همین برنامه را به زبان C
بنویسیم:
#include <stdio.h> #include <stdlib.h> int main() { int *a; { int *b = malloc(sizeof(int)); *b = 10; a = b; free(b); } printf("a = %d\n", *a); return 0; }
این کد دقیقاً همان کاری را میکند که ما در قطعه کد Rust قبلی انجام دادیم.
توضیح خیلی کوتاه کد برای افرادی که با زبان C آشنایی ندارند:
ما ابتدا یک اشارهگر به نام a
تعریف میکنیم. سپس درون بخش (scope) داخلی به اندازهی یک عدد صحیح (int
) حافظهی جدید از Heap میگیریم و با اشارهگر b
به آن اشاره میکنیم.
a
به همانجایی اشاره کند که اشارهگر b
دارد به آن اشاره میکند. یعنی همان حافظهی گرفتهشده از Heap.
بعد از آن هم حافظهای که از Heap گرفتهبودیم را آزاد میکنیم.
حالا بیایید این برنامه را اجرا کنیم:
a = 0
همانطوری که میبینید مقداری که متغیّر a
دارد به آن اشاره میکند یک مقدار اشتباه است. ما در کدمان میخواستیم کاری کنیم که a
هم به مقداری که اشارهگر b
به آن اشاره میکند، یعنی ۱۰، اشاره کند. امّا a
دارد به مقدار ۰ درون حافظه اشاره میکند. چرا؟ چون عمر b
کمتر از a
بود و حالا که ما میخواهیم از آن مقدار استفاده کنیم عملاً دیگر وجود ندارد.
Rust برای جلوگیری از رخدادن چنین مشکلاتی طول عمر رفرنسها را بررسی میکند تا مطمئن شود که ما به جایی که دیگر وجود ندارد اشاره نمیکنیم و مشکل رفرنسهای آویزان (Dangling reference) که قبلاً به آن اشاره کردیم به وجود نمیآید.
چطوری طول عمر پارامترهای تابع را مشخّص کنیم؟
حالا بیایید یک برنامهی دیگر را امتحان کنیم. فرضکنید که ما میخواهیم تابعی بنویسیم که دو رفرنس به دو آرایه را میگیرد و رفرنس آرایهای را برمیگرداند که مجموع ارقامش بیشتر از دیگری است.
بیایید اوّل این تابع را به صورت عادی بنویسیم:
fn greatest_sum(array1: &[i32], array2: &[i32]) -> &[i32] { let mut sum1 = 0; let mut sum2 = 0; for i in array1 { sum1 += *i; } for j in array2 { sum2 += *j; } if sum1 < sum2 { return array1; } return array2; } fn main() { let arr1 = [1, 2, 3]; let arr2 = [1, 2, 3, 4]; let greatest_array = greatest_sum(&arr1, &arr2); println!("greatest array: {:?}", greatest_array); }
تابع دو تا رفرنس را میگیرد، هرکدام را پیمایش میکند و مجموع اعضایش را محاسبه میکند. سپس اگر مجموع اوّلی بزرگتر بود، آن رفرنس را برمیگرداند. در غیر اینصورت دومی را برمیگرداند.
حالا بیایید این برنامه را کامپایل کنیم:
error[E0106]: missing lifetime specifier --> src/main.rs:1:52 | 1 | fn greatest_sum(array1: &[i32], array2: &[i32]) -> &[i32] { | ^ expected lifetime parameter | = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `array1` or `array2`
همانطوری که میبینید کامپایلر میگوید که ما نشانگر طول عمر را فراموشکردهایم. حالا این نشانگر طول عمر یا به قول این کامپایلر زبانبسته lifetime specifier را چطوری باید به کد اضافه کنیم؟
نشانگر طول عمر چیزی شبیه به همان پارامترهای Generic است. با این تفاوت که اینجا به جای نشاندادن نوع داده، طول عمر پارامتر مرتبط را نشان میدهند.
نام نشانگرهای طول عمر باید با علامت '
مشخّص شوند. برنامهنویسهای Rust عموماً برای اسم نشانگرهای طول عمر از حروف انگلیسی کوچک استفاده میکنند. یعنی اکثر نشانگرهای طول عمری که تا پایان زندگیتان خواهید دید اسمشان: a'
خواهد بود.
یک رفرنس با نشانگر طول عمر a'
به شکل زیر نوشته میشود:
&'a u16
حالا یک رفرنس قابل تغییر با نشانگر طول عمر a'
را هم میتوانیم به شکل زیر بنویسیم:
&'a mut u16با این اوصاف میتوانیم تابع
greatest_sum
را در برنامهی قبلی به شکل زیر بازنویسی کنیم:
fn greatest_sum <'a>(array1: &'a [i32], array2: &'a [i32]) -> &'a [i32] { let mut sum1 = 0; let mut sum2 = 0; for i in array1 { sum1 += *i; } for j in array2 { sum2 += *j; } if sum1 < sum2 { return array1; } return array2; }
خب حالا با این تغییر بیایید برنامهی قبلی را اجرا کنیم:
greatest array: [1, 2, 3]
کار کرد! حالا چرا؟ بیایید با بررسی روند کاری کامپایلر بفهمیم که چرا این تغییر نیاز بود.
۳ قانون بررسی طول عمر توسّط کامپایلر
کامپایلر برای اینکه به صورت خودکار متوجّه طول عمر پارامترها بشود و بتواند از عدم وجود مشکل مطمئن شود از ۳ قانون پیروی میکند.
به این ۳ قانون اصطلاحاً elision rules میگویند. کامپایلر به وسیلهی این ۳ قانون تلاش میکند تا طول عمر (lifetime) مرتبط با تمام پارامترهای ورودی و خروجی تابع را مشخّص کند (یادتان نرود که طول عمر فقط برای رفرنسها معنی دارد)، اگر در پایان اعمال این ۳ قانون تمام پارامترها طول عمر مشخّصی داشتند کار تمام است. در غیر این صورت کامپایلر به ما خطا میدهد و ما باید خودمان مانند مثال قبلی طول عمر پارامترها را مشخّص کنیم.
وجود این ۳ قانون باعث میشود که ما خیلی وقتها مجبور نباشیم که کد بیشتری بنویسیم و طول عمر پارامترها را مشخّص کنیم.
حالا برویم سراغ این ۳ قانون. فقط حواستان باشد که این ۳ قانون فقط برای توابع و بلوکهای impl
اعمال میشوند.
قانون اوّل: تعیین طول عمر پارامترهای ورودی
اوّلین قانون مربوط به پارامترهای ورودی است. فرضکنید که ما کد زیر را داریم:
fn example_function (param1: &u8, param2: &u8)
قانون اوّل میگوید که باید برای هرکدام از پارامترهای ورودی که از جنس رفرنس هستند، یک پارامتر تعیین طول عمر اختصاص بدهیم. یعنی کد بالا از نظر کامپایلر میشود چیزی شبیه به کد زیر:
fn example_function<'a, 'b> (param1: &'a u8, param2: &'b u8)
قانون دوم: تعیین طول عمر پارامترهای خروجی
قانون دوم میگوید که اگر پس از اعمال قانون اوّل تنها یک پارامتر تعیین طول عمر داشتیم، تمام طول عمرهای خروجی را برابر با همان پارامتر درنظر میگیریم.
مثلاً فرضکنید که ما کد زیر را داشتهایم:
fn example_function(param1: u8, param2: &u16) -> &u16
کامپایلر ابتدا قانون اوّل را روی این کد اعمال میکند و آن را تبدیل به کد زیر میکند (باز هم یادتان نرود که طول عمر فقط به رفرنسها مربوط میشود):
fn example_function<'a>(param1: u8, param2: &'a u16) -> &u16
حالا این کد را با قانون دوم بررسی میکند. از آنجایی که تنها یک پارامتر تعیین طول عمر داریم، پس میتوان قانون دوم را روی این کد اعمال کرد:
fn example_function<'a>(param1: u8, param2: &'a u16) -> &'a u16
حالا تمام رفرنسهای ورودی و خروجی طول عمرشان مشخّص است. پس کامپایلر بدون هیچ مشکلی میتواند درستی استفاده از این تابع را در سرتاسر کد بررسی کند.
قانون سوم: تعیین طول عمر متدها
قانون سوم تنها برای متدها معنی دارد. اگر پس از اعمال قانون اوّل دیدیم که یکی از پارامترهای تعیین طول عمر مربوط به پارامتری است که از نوع self&
یا mut self&
است، آن پارامتر را به عنوان طول عمر خروجی هم درنظر میگیریم.
باز فرضکنید که ما کد زیر را داشتهایم:
fn example_method(&self, param2: &u8) -> &u8
حالا قانون اوّل را روی این کد اعمال میکنیم:
fn example_method<'a, 'b>(&'a self, param2: &'b u8) -> &u8
حالا میرویم سراغ قانون سوم:
fn example_method<'a, 'b>(&'a self, param2: &'b u8) -> &'a u8
دوباره تمام ورودیها و خروجیها طول عمر مشخّصی دارند، پس کار ما دیگر تمام شده است.
اگر این قوانین را یکبار دیگر با دقّت بررسی کنید متوجّه میشوید که هیچ تناقضی بین قوانین دوم و سوم وجود ندارد. یعنی در این مثال اگر قانون دوم را هم اعمال میکردیم و بعد قانون سوم را باز به همین نتیجه میرسیدیم.
حالا که این ۳ قانون را فهمیدیم، بیایید بررسی کنیم که چرا در تابع greatest_sum
ما باید خودمان طول عمر پارامترها را مشخّص کنیم و کامپایلر نمیتواند درستی استفاده از تابع را متوجّه شود.
تابع ما بدون پارامترهای تعیین طول عمر این بود:
fn greatest_sum(array1: &[i32], array2: &[i32]) -> &[i32]حالا بیایید قانون اوّل را روی آن اعمال کنیم. برای این کار باید برای هر رفرنس ورودی یک پارامتر تعیین طول عمر مجزا تعیین کنیم:
fn greates_sum<'a, 'b>(array1: &'a [i32], array2: &'b [i32]) -> &[i32]
ما نمیتوانیم قانون دوم را روی این تابع اجرا کنیم. چون دو پارامتر تعیین طول عمر داریم. اجرای قانون سوم هم منتفی است. چون این یک تابع است نه یک متد. پس کار کامپایلر برای حدس طول عمر رفرنسها همینجا به پایان میرسد.
با این وجود ما هنوز برای رفرنس خروجی هیچ پارامتر تعیین طول عمری نداریم. پس کامپایلر نمیتواند ایمنی کدی که این تابع درونش استفاده شده است را تضمین کند. به همین خاطر هم جلوی اجرای این برنامه را میگیرد.
بنابراین ما باید خودمان به صورت صریح به کامپایلر بگوییم که طول عمر هرکدام از ورودیها و خروجیها چیست.
چطوری طول عمر اعضای یک ساختار را مشخّص کنیم؟
ما تا اینجا فقط با ساختارهایی کار کردیم که خودشان صاحب مقادیرشان هستند. امّا چطوری میتوان در یک ساختار (struct) یک رفرنس را نگهداری کرد؟
#[derive(Debug)] struct MyStruct { name: &str, age: u8 } fn main() { let name = "Asghar"; let person = MyStruct { name: name, age: 10 }; println!("{:?}", person); }
اگر این برنامه را اجرا کنیم کامپایلر به ما خطای زیر را نمایش میدهد:
error[E0106]: missing lifetime specifier --> src/main.rs:20:11 | 20 | name: &str, | ^ expected lifetime parameter error: aborting due to previous error
کامپایلر نمیتواند رابطهای بین طول عمر یک نمونه از این ساختار را با رشتهای که ما داریم به آن رفرنس میدهیم پیدا کند. به همین خاطر ما همیشه موقعی که میخواهیم درون ساختارمان از یک رفرنس استفاده کنیم، باید پارامتر طول عمر را هم مشخّص کنیم.
چرا؟ چون ممکن است که رفرنسی که به مقدار name
داده شده است قبل از خود نمونهای که از ساختار MyStruct
ساختهایم بمیرد و این یعنی اینکه ما از آن زمان به بعد درون این ساختار یک رفرنس به جایی داریم که دیگر وجود ندارد.
باید ساختار بالا را به شکل زیر تغییر بدهیم:
#[derive(Debug)] struct MyStruct<'a> { name: &'a str, age: u8 }
حالا اگر برنامه را اجرا کنیم همهچیز به خوبی اجرا میشود:
MyStruct { name: "Asghar", age: 10 }
چطوری طول عمر اعضای یک enum را مشخّص کنیم؟
هنگام استفاده از رفرنسها در enum ها هم دقیقاً باید مثل ساختارها عمل کنیم.
#[derive(Debug)] enum Something <'a> { Some(&'a u8), Thing(u8) } fn main() { let number: u8 = 10; let a = Something::Some(&number); println!("{:?}", a); }
استفاده از پارامتر تعیین طول عمر در impl
حالا بیایید همان ساختاری MyStruct
را در دو بخش قبلی در نظر بگیریم. ما میخواهیم برای این ساختار متدی تعریف کنیم که با فراخوانی آن، اسم نمونهای که از این ساختار (struct) ساخته شده است تغییرکند و نام قبلی بازگرداندهشود.
#[derive(Debug)] struct MyStruct<'a> { name: &'a str, age: u8 } impl MyStruct { fn change_name(&mut self, new_name: &str) -> &str { let old_name = self.name; self.name = new_name; return old_name; } } fn main() { let name = "Asghar"; let mut person = MyStruct { name, age: 10 }; println!("{:?}", person); person.change_name("Akbar"); println!("{:?}", person); }
حالا بیایید این برنامه را اجرا کنیم:
error[E0726]: implicit elided lifetime not allowed here --> src/main.rs:34:6 | 34 | impl MyStruct { | ^^^^^^^^- help: indicate the anonymous lifetime: `<'_>` error[E0621]: explicit lifetime required in the type of `new_name` --> src/main.rs:37:21 | 35 | fn change_name(&mut self, new_name: &str) -> &str { | ---- help: add explicit lifetime `'static` to the type of `new_name`: `&'static str` 36 | let old_name = self.name; 37 | self.name = new_name; | ^^^^^^^^ lifetime `'static` required
همانطوری که میبینید ما باید طول عمر را برای این بلوک impl
مشخّص کنیم. چون در تعریف این ساختار ما به صورت صریح مشخّص کردهایم که باید پارامتر تعیین طول عمر مشخّص شود.
برای این کار ما باید برای خود بلوک impl
پارامتر طول عمر را مشخّص کنیم و آن را به ساختار و رفرنس جدید موجود در متد ربط بدهیم:
impl<'a> MyStruct<'a> { fn change_name(&mut self, new_name: &'a str) -> &str { let old_name = self.name; self.name = new_name; return old_name; } }
حالا اگر برنامه را اجرا کنیم نتیجهای که میخواستیم را خواهیم گرفت:
MyStruct { name: "Asghar", age: 10 } MyStruct { name: "Akbar", age: 10 }
حواستان باشد که پارامتر a'
که بعد از impl
آمده است، پارامتر تعیین طول عمری است که متعلّق به تمام بلوک impl
است. یعنی مثلاً ما میتوانیم چند پارامتر تعیین طول عمر اینجا داشته باشیم امّا تنها یکی را به MyStruct
اختصاص بدهیم.
پارامتر a'
بعد از MyStruct
هم پارامتری است که مشخّص کنندهی طول عمر مقادیر این ساختار اند. حالا ما در تعریف متد change_name
مشخّصکردهایم که رفرنس new_name
هم عمری حدّاقل به اندازهی نمونهای دارد که ما از ساختار MyStruct
ساختهایم.
استفادهی همزمان از پارامتر طول عمر و Generic ها
ما میتوانیم که به صورت همزمان هم از پارامترهای Generic استفاده کنیم و هم از پارامترهای طول عمر. برای این کار کافی است که پارامترهای Generic را هم بین علامتهای >
و <
قرار بدهیم.
مثلاً فرضکنید که ما یک تابع داریم که هم پارامتر تعیین طول عمر a'
را دارد و هم یک پارامتر Generic به نام T
:
fn test_func <'a, T> (x:&'a T)
طول عمر static
ما در زبان Rust میتوانیم مقادیری داشته باشیم که طول عمرشان static است. یعنی تا زمانی که برنامه وجود دارد این مقادیر هم زندهاند.
ما میتوانیم یک مقدار static را به شکل زیر تعریف کنیم:
static STATIC_VALUE: u8 = 10;
تعریف مقادیر static با کلمهی کلیدی static
آغاز میشود. بعد از آن باید نام مقدار بیاید. بهتر است که نام مقادیر static را با حروف بزرگ بنویسیم و بخشهای مختلف نام را با علامت _
از هم جدا کنیم.
بعد از نام مقدار، باید علامت :
را قرار بدهیم و بعد از آن نوع داده را مشخّص کنیم. بعد از آن هم باید دادهی مرتبط با آن را پس از علامت =
بنویسیم.
در حالت کلّی مقادیر static
مثل ثوابت عمل میکنند. یعنی شما نمیتوانید مقدار آن را عوضکنید و البته امکان این را دارید که آنها را به صورت Global، یعنی خارج از scope تمام توابع و … تعریف کنید.
ما میتوانیم مقادیر static را به شکلی تعریف کنیم که مقدار آنها قابل تغییر باشد:
static mut STATIC_VALUE: u8 = 10;
امّا باید حواستان باشد که در این حالت ما تنها درون بلوکهای unsafe
میتوانیم مقدار static را تغییر بدهیم. دلیلش هم این است که تغییر دادن مقادیر static ممکن است در برنامههای همروند (concurrent) مشکل ایجاد کند.
حالا بعداً در مورد اینکه بلوک unsafe
چه چیزی است صحبت میکنیم.
این مقادیر static مثل همان مقادیر static در زبانهایی مثل C
عمل میکنند. ما مثلاً میتوانیم در زبان C
تابعی مثل تابع زیر بنویسیم که میتواند تعداد دفعاتی که فراخوانی شده است را نگهداری کند:
#include <stdio.h> void call_me() { static int number_of_calls = 0; // مقدار صفر برای مقداردهی اوّلیه به کار میرود و در باقی فراخوانیها اعمال نمیشود number_of_calls++; printf("number of calls: %d\n", number_of_calls); } int main () { call_me(); call_me(); call_me(); call_me(); }
حالا اگر برنامه را اجرا کنیم خروجی زیر را خواهیم گرفت:
number of calls: 1 number of calls: 2 number of calls: 3 number of calls: 4
حالا با استفاده از مقادیر static ما میتوانیم دقیقاً همین کار را در Rust هم انجام بدهیم:
fn call_me() { static mut NUMBER_OF_CALLS: u8 = 0; unsafe { NUMBER_OF_CALLS += 1; println!("number of calls: {}", NUMBER_OF_CALLS); } } fn main() { call_me(); call_me(); call_me(); call_me(); }
حالا با اجرای این برنامه هم دقیقاً همان خروجی را میگیریم:
number of calls: 1 number of calls: 2 number of calls: 3 number of calls: 4
طول عمر static
برای خودش یک پارامتر مجزا با اسم خاص دارد. یعنی اگر جایی بخواهید مشخّص کنید که طول عمر یک رفرنس static است، باید به جای استفاده از پارامترهایی مثل: a'
و b'
و … از پارامتر: static'
استفاده کنید.
چه زمانهایی به بیشتر از یک پارامتر تعیین طول عمر نیاز داریم؟
تا اینجا هر مثالی که دیدیم تنها نیاز به یک پارامتر تعیین طول عمر داشت. امّا چه زمانهایی به بیش از یک پارامتر تعیین طول عمر نیاز داریم؟
میتوان برای استفاده از بیش از یک پارامتر مثالهای متفاوت زیادی آورد، امّا احتمالاً در اکثر موارد با حالتی مثل کد زیر روبهرو خواهید شد:
struct FirstStruct<'a> { val1: &'a str } struct SecondStruct<'a, 'b> { my_struct: &'a FirstStruct<'b> }
ما یک ساختار به نام FirstStruct
داریم که از یک رفرنس به یک رشته نگهداری میکند. پس موقع تعریف آن باید پارامتر طول عمرش را هم مشخّص کنیم.
حالا یک ساختار دیگر به نام SecondStruct
داریم که از یک رفرنس به یک نمونه از FirstStruct
نگهداری میکند.
ما اینجا به دو پارامتر تعیین طول عمر نیاز داریم. یک پارامتر که مشخّصکنندهی طول عمر رفرنسی باشد که در همین ساختار به نمونهای از FirstStruct
نگهداری میکنیم و یک پارامتر برای تعیین طول عمر رشتهای که FirstStruct
دارد از آن استفاده میکند.
فراموش نکنید که پارامتر a'
در تعریف ساختار FirstStruct
با پارامتر a'
در تعریف SecondStruct
فرق میکند.
The form you have selected does not exist.
خب. این هم از آشنایی ما با مفهوم طول عمر (lifetime) در زبان Rust. طول عمر از مفاهیم خیلی مهم و البته کمی پیچیدهی زبان Rust است که توضیح آن به همین اندازه کافی است. چون باید با جنبههای دیگر آن در کاربردهای مختلف به صورت عملی آشنا بشویم.
در قسمت بعدی میبینمتان.
میتوانید کدهای این بخش را در مخزن این مجموعهی آموزشی رایگان در گیتهاب ببینید.
یا حرفهای شو یا برنامهنویسی را رها کن.
چطور میشود در بازار کار خالی از نیروی سنیور ایران، بدون کمک دیگران و وقت تلفکردن خودت را تبدیل به یک نیروی باتجربه بکنی؟
پاسخ ساده است. باید حرفهای بشوی. چیزی که من در این کتاب به تو یاد خواهم داد.