دوستدار نرمافزار، فلسفه و ادبیات. وب سایت:http://www.alihoseiny.ir
آموزش زبان برنامهنویسی Rust – قسمت۹: Slicing
حالا که درمورد مالکیّت و رفرنسها صحبت کردیم، وقت آن است که به سراغ slice ها برویم. نوع دادهای که مالکیّت ندارد و میتوانید با خیال راحت از آنها استفاده کنید.
با یادگرفتن slicing میتوانیم از بخشهای مختلف یک collection استفاده کنیم، بدون اینکه لازم باشد مالکیّت آن را منتقل کنیم.
قبل از اینکه ببینیم slice که بود و چه کرد، ببینیم بدون آن چه مشکلی داریم.
چه مشکلی وجود دارد که بدون slicing قابل حل نیست؟
فرض کنید میخواهیم یک برنامه بنویسیم که اوّلین عضو یک آرایه که مقدارش منفی نیست را برگرداند.
برنامهی زیر را با دقّت ببینید. چیزهای زیادی درونش وجود دارد که تا به حال ندیدهایم و باید یکی یکی بفهمیم چه هستند:
fn main() {
let my_array = [-5, -3, -10, 0, 1, 8, 9];
let not_negative_item = first_not_negative_element(my_array);
if not_negative_item > -1 {
println!("First not negative element in the my_array is: {}", not_negative_item);
} else {
println!("All elements of my_array are negative.");
}
}
fn first_not_negative_element(array: [i32; 7]) -> i32 {
for (index, &item) in array.iter().enumerate() {
if item > -1 {
return item;
}
}
return -1;
}
بیایید اوّل سراغ تابع first_not_negative
برویم، چون چیزهای جدید بیشتری دارد.
هنگام تعریف تابع مشخّص کردهایم که یک پارامتر به نام array
داشته باشد که نوع آن یک آرایه از نوع i32
به طول ۷ است (اگر نحوهی تعریف و کار با آرایهها را در Rust فراموش کردهاید، با کلیک روی این نوشته یک نگاه سریع به آن بیندازید).
یعنی مشخّص کردهایم که ورودی ما حتماً باید یک آرایه از نوع خاص و طول خاص باشد. به همین دلیل دیگر نمیتوان به این تابع آرایهای با اندازهی ۵ یا با نوعی به جز i32
داد.
چرخیدن درون یک آرایه
حالا میرسیم به حلقهی for. تا به حال همچین چیزی ندیده بودیم، پس بیایید تکّهتکّهی آن را بررسی کنیم و ببینیم که چه اتّفاقاتی دارد میافتد.
اوّل از همه ببینیم که ()array.iter
چیست؟ راستش فعلاً برای رفتن سراغ iteratorها و methodها خیلی زود است، امّا کافی است که بدانیم iter یک method است که تکتک عناصر موجود در یک collection را برمیگرداند.
امّا بعد از فراخوانی این method، ما یک method دیگر به نام enumerate
را روی خروجی iter
فراخوانی کردهایم.
کاری که enumerate
میکند این است که خروجی iter
را به شکل یک tuple دوتایی برمیگرداند (یادت نیست که tuple ها چه بودند؟ اشکالی ندارد. روی این نوشته کلیک کن و خیلی سریع به خاطر بیاور).
خب حالا به بخش متغیّر حلقهی for
میرسیم. میبینیم که برخلاف چیزی که در جلسهی آموزش حلقهها دیدیم، اینجا به جای یک متغیّر ساده، از یک مجموعه tuple شکل استفاده شده است.
اتّفاقی که میافتد این است که چون خروجی enumerate
یک tuple است، ما با استفاده از پترن (index, &item)
آنرا باز میکنیم تا بتوانیم از مقادیر آن راحتتر استفاده کنیم.
حالا به جای اینکه بخواهیم از عناصرtupleی که enumerate
خروجی میدهد با ایندکس دادن استفاده کنیم، خیلی راحت از متغیّرهای index
و item
استفاده میکنیم.
for (index, &item) in array.iter().enumerate() {
عضو اوّل تاپلی که enumerate
خروجی میدهد، index عنصری است که الان قرار است درون حلقه استفاده کنیم. نوع index
هم از نوع usize
است، امّا چون خود کامپایلر از نوع عناصر تاپل خروجی باخبر است، دیگر لازم نیست ما نوع آنرا مشخّص کنیم.
عضو دوم تاپلی که enumerate
خروجی میدهد، یک رفرنس به عنصری است که الان میخواهیم درون حلقه از آن استفاده کنیم. به همین دلیل قبل از item
علامت &را قرار داده ایم.
حالا که فهمیدیم چطوری میتوان روی عناصر یک آرایه یا هر collection دیگری چرخید، بهتر است برویم سراغ بقیهی کد.
داخل حلقه، ما مقدار item
را تست میکنیم. اگر بیشتر از 1- بود، این item اوّلین عنصری در آرایه است که مقدارش منفی نیست. پس بلافاصله آن مقدار را return میکنیم.
if item > -1 {
return item;
}
اگر تا پایان آرایه عنصر غیرمنفیای پیدا نشد، مقدار 1- را برمیگردانیم تا کسی که این تابعرا فراخوانی کرده متوجّه شود که تمامی عناصر آن منفی هستند.
درون تابع main
هم یک آرایهی ۷تایی ساختهایم و آنرا به تابع first_not_negative_element
پاسدادهایم. خروجی تابعرا هم درون متغیّر not_negative_item
نگهداری میکنیم. حالا با یک دستور if
میبینیم که مقدار این متغیّر بیشتر از 1- است یا نه. اگر بود که اوّلین عنصر غیر منفیرا پرینت میکنیم، اگر هم نه که پیام خطارا به کاربر نشان میدهیم.
خروجی این برنامه این شکلی خواهد بود:
First not negative element in the my_array is: 0
خب همهچیز همانطوری پیش رفت که انتظارشرا داشتیم. حالا بیایید یک تغییر خیلی کوچک در برنامه بدهیم.
این بار بعد از اینکه اوّلیّن بار تابع first_not_negative_element
را فراخوانی کردیم و خروجیاشرا ذخیرهکردیم، آرایهی اوّلیّه را تغییر میدهیم:
fn main() {
let mut my_array = [-5, -3, -10, 0, 1, 8, 9];
let not_negative_item = first_not_negative_element(my_array);
if not_negative_item > -1 {
println!("First not negative element in the my_array is: {}", not_negative_item);
} else {
println!("All elements of my_array are negative.");
}
my_array = [10, 10, 10, 10, 10, 10, 10]; // Changing the array value
println!("Incorrect first element: {}", not_negative_item);
}
fn first_not_negative_element(array: [i32; 7]) -> i32 {
for (index, &item) in array.iter().enumerate() {
if item > -1 {
return item;
}
}
return -1;
}
حالا اگر این برنامهرا اجرا کنیم چه خروجیای تولید میکند؟
First not negative element in the my_array is: 0
Incorrect first element: 0
برنامه بدون ارور اجرا میشود، امّا منطق برنامه اشتباه است.
چون مقدار not_negative_item
هیچ وابستگیای به مقدار آرایه ندارد، وقتی که آرایهی اصلی تغییر میکند، همچنان مقدار قبلیرا نشان میدهد. مقداری که دیگر اوّلین عنصر غیر منفی آرایه نیست.
اگر این اتّفاق در کاربردهای مهمتر و البته روزمرّهی برنامهنویسی بیفتد، موجب باگهای وحشتناکی میشود که پیداکردنشان کار واقعاً سخت و طاقتفرسایی است.
حالا باید چه کار کنیم که این مشکل پیش نیاید؟ اینجا جایی است که نیاز به استفاده از slice ها به چشم میآید.
نکته: همانطوری که دیدیم، در Rust بهصورت پیشفرض وقتی یک آرایهرا به عنوان ورودی به یک تابع میدهیم، یک رفرنس immutable از آن به آن تابع ارسال میشود. بنابراین ما نمیتوانیم این شکلی یک آرایهرا به عنوان ورودی به تابع بدهیم و داخل آن تابع مقدار آن آرایهرا عوض کنیم.
یک قاچ از collection لطفاً
اوّل ببینیم که اصلاً منظورمان از slice چیست؟
منظور از slice چیست؟
یک Slice یک رفرنس به بخشی از یک collection است. یعنی به جای اینکه با یک رفرنس به تمام یک مقدار اشاره کنیم، تنها به یک تکّه از آن اشاره میکنیم.
بیایید ابتدا با syntax استفاده از slice ها آشنا بشویم و بعد برویم سراغ اینکه چطوری مشکل مارا حل میکنند.
تعریف بازه در Rust
برای اینکه بتوانیم یک slice را تعریف کنیم، ابتدا باید مشخّص کنیم که چه بازهای از دادهی اصلیرا لازم داریم.
نحوهی مشخّص کردن این بازه برای کسانی که به اندازهی کافی برنامهنویسی کردهاند، مخصوصاً افرادی که زیاد با list literal های پایتون سروکار داشتهاند، کار سادهای است. ولی افرادی که تازهکارتر هستند ممکن است زیاد به دردسر بیفتند.
برای اینکه مشخّصکنیم که چه بازه (range) ای از یک collection را احتیاجداریم، باید از سینتکس مخصوصی استفادهکنیم که با خیلی از زبانهای دیگر متفاوت، امّا استفاده از آن خیلی ساده است.
برای اینکه یک range تعریف کنیم باید از این سینتکس استفادهکنیم:
[start..end]
مقدار start
عدد index ابتدایی است. یعنی مثلاً وقتی میخواهیم از اوّلین عضو یک آرایه شروعکنیم، به جای start
عدد ۰ را میگذاریم (حتماً میدانید که شمارش ایندکسها در collectionها از ۰ شروع میشود نه ۱).
end
هم مقداری است که بازهی ما «تا» آنجا ادامه دارد. فقط حواستان به این «تا» باشد چون خیلی وقتها باعث میشود که بازهتانرا اشتباه انتخاب کنید.
این «تا» یعنی «قبل از». پس وقتی که بازهی ما مقدار [5..0]
باشد، یعنی عناصر شمارهی ۰، ۱، ۲، ۳ و ۴.
برای اینکه از این مشکل خلاصشویم میتوانیم بازهرا به شکل دیگری بنویسیم:
[0..=4]
با قراردادن =
قبل از مقدار end
مشخّص میکنیم که مقدار end
هم باید جزو بازهی ما باشد. بهنظر من این سینتکس کمتر ممکن است مشکلزا بشود، هرچند که خودم با اوّلی راحتترام.
شیوهی تعریف یک Slice
اوّلین چیزی که درمورد sliceها باید بدانیم این است که sliceها همیشه رفرنس هستند. پس برای تعریف یک slice باید از & قبل از نام collection اصلیای که میخواهیم به تکّهای از آن اشارهکنیم، استفاده کنیم.
مورد دیگر این است که برای تعریف یک slice حتماً باید مشخّص کنیم که بازه (range) آن چیست. با این تفاسیر سینتکس کلّی تعریف یک slice این شکلی خواهد بود:
let a_slice = &my_collection[2..5];
اگر بخواهیم ببینم که آن زیرها چه اتّفاقی میافتد، درون حافظه چیزی شبیه به تصویر زیر رخ میدهد:
همانطوری که میبینید تفاوت slice با collection اصلی این است که مقدار capacity ندارد (بعداً میبینیم که چرا) و به جای اینکه مثل my_collection
به ابتدای دادهها اشارهکند، به جایی که ایندکسش برابر با مقدار start در range این slice است اشاره میکند.
مقدار len برابر است با طول slice. اینجا ایندکس ابتدایی ما برابر با ۲ و ایندکس انتهایی ۵ بود. به همین دلیل طول slice ما برابر میشود با ۳. اینطوری انتهای slice هم مشخّص میشود.
Type یک slice
نوع (type) یک slice یک رفرنس به collection اصلی است. یعنی مثلاً اگر در مثال بالا my_collection
یک آرایه از نوع [i32]
باشد، type متغیّر a_slice
که یک slice از my_collection
است، [i32]&
خواهد بود.
استفاده از Sliceها چطوری مشکل ما را حل میکند؟
مشکل چی بود؟ مشکل ما این بود که وقتی دادهی اصلی تغییر میکرد آن تکّهای که به آن مربوط میشد همچنان قابل استفاده بود. به همین خاطر ممکن بود که برنامهنویس درطول نوشتن برنامه به اشتباه از دادهای که دیگر دردسترس نیست یا دیگر valid نیست استفاده کند.
حالا فرضکنید کدی که در بخش ابتدایی این نوشته دیدیم را میخواهیم بازنویسی کنیم. این بار تابع first_not_negative_element
به جای اینکه خود elementرا برگرداند، یک slice از آرایهی اوّلیّهرا برمیگرداند که تنها شامل اوّلین element غیر منفی میشود.
کد زیر را با دقّت ببینید تا خط به خطش را با هم بررسی کنیم:
fn main() {
let mut my_array = [-5, -3, -10, 0, 1, 8, 9];
let not_negative_item = first_not_negative_element(&my_array);
if not_negative_item.len() == 1 {
println!("First not negative element in the my_array is: {:?}", not_negative_item);
} else {
println!("All elements of my_array are negative.");
}
}
fn first_not_negative_element(array: &[i32; 7]) -> &[i32] {
for (index, &item) in array.iter().enumerate() {
if item > -1 {
return &array[index..index + 1];
}
}
return &array[0..array.len()];
}
بیایید دوباره اوّل از تابع first_not_negative_element
شروع کنیم. اوّلین تغییری که اینجا میبینیم این است که نوع ورودی از [i32;7]
به یک رفرنس به آرایهای از نوع i32
و به طول 7 تغییر کرده است.
راستش این تغییر را بهخاطر خود slicing ندادم. اگر این کار را نمیکردیم باید با lifetime سر و کله میزدیم که اینجا جای توضیحدادن آن نیست.
fn first_not_negative_element(array: &[i32; 7]) -> &[i32] {
تغییر دوم که در همان خط تعریف تابع رخداده است، نوع خروجی تابع است. این بار به جای اینکه یک عدد برگردانیم، یک رفرنس به آرایهای از نوع i32
برمیگردانیم. اینجا دقیقاً داریم به کامپایلر میگوییم که قرار است یک slice از آرایه را برگردانیم.
تغییر بعدی در خط سوم تابع است. اینجا بهجای return item
که خود مقدار را برمیگرداند، یک slice از آرایهرا برمیگردانیم.
مقدار شروع بازهی این slice مقدار index آیتم فعلی است. مقدار انتهایی آنرا هم برابر با index + 1
گذاشتهایم تا نتیجه یک slice به طول یک شود که فقط همان اوّلین عنصری که مقدارش منفی نیست را شامل میشود.
در انتهای تابع هم برای زمانی که تمامی عناصر ورودی منفی بودند، یک slice برمیگردانیم که به اندازهی کل آرایه است. یعنی در حقیقت این slice از نظر مقدار، همان آرایهی ورودی خواهد بود.
نکته: برای اینکه یک slice به اندازهی کل collection بسازیم، میتوانیم range آنرا به شکل [..] واردکنیم.
حالا برویم سراغ تغییراتی که در تابع main
دادیم. اوّلین و بدیهیترین تغییر این است که حالا بهجای my_array
یک رفرنس از آن را به تابع first_not_negative_element
میفرستیم، چون نوع ورودی آن تابع را تغییر دادهایم.
تغییر دوم امّا در شرط if
رخ داده است. ما باید بفهمیم که درون not_negative_item
یک slice به اندازهی یک قراردارد یا اینکه کلّ آرایه. چون تابع ما طوری نوشته شده است که اگر مقدار غیرمنفیای پیدا نشد یک slice به اندازهی کل آرایه برگرداند.
برای فهمیدن این موضوع راههای زیادی وجود دارد. اینجا ما با فراخوانی متد len
، همان کاری که در خط آخر تابع first_not_negative_element
کردیم، طول slice را گرفتهایم. اگر طول خروجی تابع برابر با ۱ باشد، یعنی آیتمی که مقدارش منفی نیست در تابع پایینی پیدا شده است و ما میتوانیم پیام موفّقیّت را چاپ کنیم.
امّا اگر این طول برابر ۱ نبود، یعنی همهی آرایهی ورودی بازگردانده شده است و این یعنی همهی عناصر آرایه منفی بوده اند.
اگر این برنامهرا اجراکنیم خروجی زیر را خواهیم گرفت:
First not negative element in the my_array is: [0]
حالا ببینیم آیا مشکلی که بهخاطر آن سراغ slicing آمدیم حل شده است یا نه. برای این کار کافی است مثل مثالی که در بخش ابتدایی بود، سعیکنیم مقدار متغیّر my_array
را عوضکنیم.
بیایید امتحانش کنیم:
fn main() {
let mut my_array = [-5, -3, -10, 0, 1, 8, 9];
let not_negative_item = first_not_negative_element(&my_array);
if not_negative_item.len() == 1 {
println!("First not negative element in the my_array is: {:?}", not_negative_item);
} else {
println!("All elements of my_array are negative.");
}
my_array = [0i32; 7];
println!("Incorrect not negative value: {:?}", not_negative_item);
}
fn first_not_negative_element(array: &[i32; 7]) -> &[i32] {
for (index, &item) in array.iter().enumerate() {
if item > -1 {
return &array[index..index + 1];
}
}
return &array[0..array.len()];
}
این بار بعد از چاپکردن مقدار غیر منفی، سعی کردهایم مقدار آرایهی my_array
را عوض کنیم (اگر این سیتنکس برایتان آشنا نیست با کلیک روی این نوشته به قسمت آموزش آرایهها بروید).
حالا اگر بخواهیم این برنامهرا کامپایل کنیم چه اتّفاقی میافتد؟
error[E0506]: cannot assign to `my_array` because it is borrowed
--> src/main.rs:10:5
|
3 | let not_negative_item = first_not_negative_element(&my_array);
| -------- borrow of `my_array` occurs here
...
10 | my_array = [0i32; 7];
| ^^^^^^^^^^^^^^^^^^^^ assignment to borrowed `my_array` occurs here
error: aborting due to previous error
همانطوری که میبینید کامپایلر به ما خطا برمیگرداند. یعنی مشکلی که داشتیم حل شد. حالا slice ما به مقدار اصلی سنجاق شده است و ما نمیتوانیم مقدار اصلیرا تا زمانی که slice معتبر است تغییر بدهیم.
این رفتار کامپایلر باعث میشود که شما دیگر اشتباهات رایجی را مانند آنچه در ابتدای نوشته توضیح داده شد نکنید.
امّا من باید در کُدم مقداری که قبلاً از آن یک slice گرفتهام را تغییر بدهم
حالا شاید شرایطی پیشبیاید که شما بخواهید متغیّریرا که قبلاً یک slice از آن گرفتهاید تغییر بدهید. مثل کاری که بالاکردیم.
راستش قبل از اینکه این کار را بکنید ابتدا یک بار به معماری و مسئلهتان فکرکنید. به احتمال زیاد مشکل از نحوهی برخوردتان با مسئله است و شما نباید چنین کاری بکنید. امّا اگر به هر حال لازم بود چنین کاری بکنید، ما اینجا دو راه داریم:
۱-استفاده از scope
همانطوری که قبلاً درمورد scopeها دیدیم (اگر یادتان نیست روی این لینک کلیک کنید و خیلی سریع همهچیز را بهخاطر بیاورید) میتوانیم بخش گرفتن slice را درون یک scope دیگر تعریفکنیم و تغییر collection را پس از پایان این scope انجام بدهیم.
برای اینکه بهتر متوجّه حرفم بشوید، بیایید کد زیر را با هم ببینیم:
fn main() {
let mut my_array = [-5, -3, -10, 0, 1, 8, 9];
{ // New scope for slicing my_array
let not_negative_item = first_not_negative_element(&my_array);
if not_negative_item.len() == 1 {
println!("First not negative element in the my_array is: {:?}", not_negative_item);
} else {
println!("All elements of my_array are negative.");
}
} // End of the scope
my_array = [5i32; 7];
println!("New first not negative value: {:?}", first_not_negative_element(&my_array));
}
fn first_not_negative_element(array: &[i32; 7]) -> &[i32] {
for (index, &item) in array.iter().enumerate() {
if item > -1 {
return &array[index..index + 1];
}
}
return &array[0..array.len()];
}
همانطوری که میبینید ما بعد از تعریف متغیّر my_array
داخل تابع main
، یک scope جدید را داخل آن تابع شروع کردهایم و درون آن با استفاده از تابع first_not_negative_element
یک slice از آن آرایهرا گرفتهایم و پرینتش کردهایم.
حالا پس از پایان scope و درون همان تابع main
، مقدار متغیّر my_array
را عوض کردهایم و دوباره مثل قبل یک slice از آن گرفتهایم.
خب بیایید ببینیم خروجی این کد چه میشود:
First not negative element in the my_array is: [0]
New first not negative value: : [5]
این بار برنامه بدون هیچ مشکلی اجرا شد. چون هنگامی که از scope قبلی خارج میشویم دیگر slice قبلی که در not_negative_item
ذخیرهشده بود وجود ندارد. پس دیگر با تغییر دادهی اصلی مشکلی هم پیش نخواهد آمد.
۲-کپی کن
روش دیگر، که البته باعث ایجاد سربار حافظه میشود، کپی کردن collection است. یعنی از دادهای که قبلاً از آن یک slice گرفتهایم کپی میگیریم و در یک متغیّر جدید میریزیم. حالا این متغیّر جدید را تغییر میدهیم.
مثلاً برنامهی زیر را ببینید:
fn main() {
let my_array = [-5, -3, -10, 0, 1, 8, 9];
let not_negative_item = first_not_negative_element(&my_array);
if not_negative_item.len() == 1 {
println!("First not negative element in the my_array is: {:?}", not_negative_item);
} else {
println!("All elements of my_array are negative.");
}
let mut my_second_array = my_array; // copying my_array to new variable
my_second_array[0] = 100;
println!("New first not negative value: {:?}", first_not_negative_element(&my_second_array));
}
fn first_not_negative_element(array: &[i32; 7]) -> &[i32] {
for (index, &item) in array.iter().enumerate() {
if item > -1 {
return &array[index..index + 1];
}
}
return &array[0..array.len()];
}
اینجا ما یک متغیّر جدید به نام my_second_array
تعریف کرده ایم. حالا اوّلین عنصر این آرایهرا عوض کردهایم و همان کار مثال قبلی را برای گرفتن یک slice از آن و نمایشش کردهایم.
اگر این برنامهرا اجرا کنیم خروجی زیر را از آن میگیریم:
First not negative element in the my_array is: [0]
New first not negative value: [100]
از این روش خیلی با احتیاط استفاده کنید. این روش سربار حافظه ایجاد میکند، چون ما دوباره داریم همان دادهی قبلی را درون حافظه نگهداری میکنیم. این ممکن است برنامهی شمارا بهخاطر استفادهی بیش از حد از حافظه دچار مشکل کند.
نکته: همانطوری که دیدید ما در این مثال برخلاف قبلیها متغیّر اوّل را immutable کردیم (با برداشتن کلمهی mut از تعریف آن). دلیل این کار این است که متغیّر ابتدایی هرگز تغییر نمیکند و چیزی که باید همیشه بهخاطرش داشته باشید این است که همیشه در نرمافزارتان باید حدّاقل دادهی mutable ممکنرا داشته باشید.
نتیجهگیری
ترکیب طلایی مالکیّت (Ownership)، borrowing و slicing باعث میشود که شما از ایمنی حافظه مطمئن باشید. با کمک اینها از ۹۹٪ مشکلاتی که برنامهنویسهای سیستمی با زبانهایی مثل c دارند راحت میشوید.
هنوز کار ما با slicing تمام نشده است. مثلاً stingهای معمولی hardcode شدهای که قبلاً دیده بودیم همگی slice هستند. امّا بحث slicing را در همینجا تمام میکنیم. باقی مباحث مربوط به slicing را در بخشهای دیگر درمیان مباحث دیگر میبینیم تا راحتتر درکشان کنیم.
اگر سؤالی درمورد هر بخش این مباحث داشتید یا بخشی به نظرتان به اندازهی کافی واضح نبود، از طریق بخش نظرات یا ایمیل من را با خبر کنید.
مطلبی دیگر از این انتشارات
ترفندهای RxJava در اندروید - قسمت اول
مطلبی دیگر از این انتشارات
آموزش زبان برنامهنویسی Rust – قسمت۱۰- شروع کار با Struct
مطلبی دیگر از این انتشارات
آموزش برنامه نویسی جاوا - بخش اول