تخزين قوائم من القيم باستخدام الـ Vectors
أول نوع تجميع (collection type) سننظر إليه هو Vec<T>، المعروف أيضًا باسم الـ vector. تسمح لك الـ Vectors بتخزين أكثر من قيمة في بنية بيانات واحدة تضع جميع القيم بجوار بعضها البعض في الذاكرة. يمكن للـ Vectors تخزين قيم من نفس النوع فقط. وهي مفيدة عندما يكون لديك قائمة من العناصر، مثل أسطر النص في ملف أو أسعار العناصر في عربة تسوق.
إنشاء Vector جديد
لإنشاء vector جديد وفارغ، نستدعي دالة Vec::new، كما هو موضح في القائمة 8-1.
fn main() {
let v: Vec<i32> = Vec::new();
}
لاحظ أننا أضفنا تذييل نوع (type annotation) هنا. نظرًا لأننا لا ندخل أي قيم في هذا الـ vector، فإن Rust لا تعرف نوع العناصر التي ننوي تخزينها. هذه نقطة مهمة. يتم تطبيق الـ Vectors باستخدام الأنواع العامة (generics)؛ سنغطي كيفية استخدام الـ generics مع أنواعك الخاصة في الفصل 10. في الوقت الحالي، اعلم أن نوع Vec<T> الذي توفره الـ standard library يمكن أن يحتوي على أي نوع. عندما ننشئ vector لاحتواء نوع معين، يمكننا تحديد النوع داخل الأقواس الزاوية (angle brackets). في القائمة 8-1، أخبرنا Rust أن Vec<T> في v سيحتوي على عناصر من نوع i32.
في كثير من الأحيان، ستقوم بإنشاء Vec<T> بقيم أولية، وستستنتج Rust نوع القيمة التي تريد تخزينها، لذلك نادرًا ما تحتاج إلى إجراء type annotation هذا. توفر Rust بشكل ملائم الماكرو (macro) vec!، والذي سيقوم بإنشاء vector جديد يحتوي على القيم التي تقدمها له. تنشئ القائمة 8-2 Vec<i32> جديدًا يحتوي على القيم 1 و 2 و 3. نوع الـ integer هو i32 لأنه نوع الـ integer الافتراضي، كما ناقشنا في قسم “أنواع البيانات” (Data Types) في الفصل 3.
fn main() {
let v = vec![1, 2, 3];
}
نظرًا لأننا قدمنا قيم i32 أولية، يمكن لـ Rust استنتاج أن نوع v هو Vec<i32>، و type annotation ليس ضروريًا. بعد ذلك، سننظر في كيفية تعديل الـ vector.
تحديث Vector
لإنشاء vector ثم إضافة عناصر إليه، يمكننا استخدام الـ method push، كما هو موضح في القائمة 8-3.
fn main() {
let mut v = Vec::new();
v.push(5);
v.push(6);
v.push(7);
v.push(8);
}
كما هو الحال مع أي متغير، إذا أردنا أن نكون قادرين على تغيير قيمته، فنحن بحاجة إلى جعله قابلاً للتغيير (mutable) باستخدام الكلمة المفتاحية mut، كما نوقش في الفصل 3. الأرقام التي نضعها بالداخل كلها من نوع i32، وتستنتج Rust ذلك من الـ data، لذلك لا نحتاج إلى type annotation Vec<i32>.
قراءة عناصر الـ Vectors
هناك طريقتان للإشارة إلى قيمة مخزنة في vector: عبر الفهرسة (indexing) أو باستخدام الـ method get. في الأمثلة التالية، قمنا بتذييل أنواع القيم التي يتم إرجاعها من هذه الـ functions لمزيد من الوضوح.
توضح القائمة 8-4 كلتا طريقتي الوصول إلى قيمة في vector، باستخدام بناء جملة الـ indexing و method get.
fn main() {
let v = vec![1, 2, 3, 4, 5];
let third: &i32 = &v[2];
println!("The third element is {third}");
let third: Option<&i32> = v.get(2);
match third {
Some(third) => println!("The third element is {third}"),
None => println!("There is no third element."),
}
}
لاحظ بعض التفاصيل هنا. نستخدم قيمة الـ index 2 للحصول على العنصر الثالث لأن الـ vectors مفهرسة بالرقم، بدءًا من الصفر. يمنحنا استخدام & و [] مرجعًا (reference) إلى العنصر الموجود في قيمة الـ index. عندما نستخدم method get مع تمرير الـ index كوسيط، نحصل على Option<&T> يمكننا استخدامه مع match.
توفر Rust هاتين الطريقتين للإشارة إلى عنصر حتى تتمكن من اختيار كيفية تصرف البرنامج عندما تحاول استخدام قيمة index خارج نطاق العناصر الموجودة. كمثال، دعنا نرى ما يحدث عندما يكون لدينا vector من خمسة عناصر ثم نحاول الوصول إلى عنصر في الـ index 100 بكل تقنية، كما هو موضح في القائمة 8-5.
fn main() {
let v = vec![1, 2, 3, 4, 5];
let does_not_exist = &v[100];
let does_not_exist = v.get(100);
}
عندما نقوم بتشغيل هذا الكود، ستتسبب طريقة [] الأولى في ذعر (panic) البرنامج لأنها تشير إلى عنصر غير موجود. من الأفضل استخدام هذه الطريقة عندما تريد أن يتعطل برنامجك إذا كانت هناك محاولة للوصول إلى عنصر يتجاوز نهاية الـ vector.
عندما يتم تمرير index إلى method get يكون خارج نطاق الـ vector، فإنه يُرجع None دون panic. ستستخدم هذا الـ method إذا كان الوصول إلى عنصر يتجاوز نطاق الـ vector قد يحدث أحيانًا في ظل الظروف العادية. سيحتوي الكود الخاص بك بعد ذلك على منطق للتعامل مع وجود إما Some(&element) أو None، كما نوقش في الفصل 6. على سبيل المثال، يمكن أن يأتي الـ index من شخص يدخل رقمًا. إذا أدخلوا عن طريق الخطأ رقمًا كبيرًا جدًا وحصل البرنامج على قيمة None، فيمكنك إخبار المستخدم بعدد العناصر الموجودة في الـ vector الحالي ومنحهم فرصة أخرى لإدخال قيمة صالحة. سيكون هذا أكثر سهولة في الاستخدام من تعطل البرنامج بسبب خطأ مطبعي!
عندما يكون للبرنامج reference صالح، يفرض مدقق الاقتراض (borrow checker) قواعد الـ ownership والـ borrowing (المغطاة في الفصل 4) لضمان أن هذا الـ reference وأي references أخرى لمحتويات الـ vector تظل صالحة. تذكر القاعدة التي تنص على أنه لا يمكنك الحصول على references قابلة للتغيير وغير قابلة للتغيير في نفس النطاق (scope). تنطبق هذه القاعدة في القائمة 8-6، حيث نحتفظ بـ immutable reference للعنصر الأول في vector ونحاول إضافة عنصر إلى النهاية. لن يعمل هذا البرنامج إذا حاولنا أيضًا الإشارة إلى هذا العنصر لاحقًا في الـ function.
fn main() {
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);
println!("The first element is: {first}");
}
سيؤدي تجميع هذا الكود إلى هذا الخطأ:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> src/main.rs:6:5
|
4 | let first = &v[0];
| - immutable borrow occurs here
5 |
6 | v.push(6);
| ^^^^^^^^^ mutable borrow occurs here
7 |
8 | println!("The first element is: {first}");
| ----- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `collections` (bin "collections") due to 1 previous error
قد يبدو الكود في القائمة 8-6 وكأنه يجب أن يعمل: لماذا يجب أن يهتم الـ reference إلى العنصر الأول بالتغييرات في نهاية الـ vector؟ يرجع هذا الخطأ إلى طريقة عمل الـ vectors: نظرًا لأن الـ vectors تضع القيم بجوار بعضها البعض في الذاكرة، فإن إضافة عنصر جديد إلى نهاية الـ vector قد يتطلب تخصيص (allocating) ذاكرة جديدة ونسخ العناصر القديمة إلى المساحة الجديدة، إذا لم تكن هناك مساحة كافية لوضع جميع العناصر بجوار بعضها البعض حيث يتم تخزين الـ vector حاليًا. في هذه الحالة، سيشير الـ reference إلى العنصر الأول إلى ذاكرة تم إلغاء تخصيصها (deallocated memory). تمنع قواعد الـ borrowing البرامج من الانتهاء في هذا الموقف.
ملاحظة: لمزيد من التفاصيل حول تفاصيل تطبيق نوع
Vec<T>، راجع “The Rustonomicon”.
التكرار (Iterating) على القيم في Vector
للوصول إلى كل عنصر في vector بدوره، سنقوم بـ التكرار (iterate) عبر جميع العناصر بدلاً من استخدام الـ indices للوصول إلى عنصر واحد في كل مرة. توضح القائمة 8-7 كيفية استخدام حلقة for للحصول على immutable references لكل عنصر في vector من قيم i32 وطباعتها.
fn main() {
let v = vec![100, 32, 57];
for i in &v {
println!("{i}");
}
}
يمكننا أيضًا iterate على mutable references لكل عنصر في mutable vector من أجل إجراء تغييرات على جميع العناصر. ستضيف حلقة for في القائمة 8-8 50 إلى كل عنصر.
fn main() {
let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}
}
لتغيير القيمة التي يشير إليها الـ mutable reference، يجب علينا استخدام عامل إلغاء الإشارة (dereference operator) * للوصول إلى القيمة في i قبل أن نتمكن من استخدام عامل التشغيل +=. سنتحدث أكثر عن dereference operator في قسم “متابعة الـ Reference إلى القيمة” (Following the Reference to the Value) في الفصل 15.
يعد الـ Iterating على vector، سواء كان immutably أو mutably، آمنًا بسبب قواعد borrow checker. إذا حاولنا إدراج أو إزالة عناصر في نصوص حلقات for في القائمة 8-7 والقائمة 8-8، فسنحصل على خطأ compiler مشابه للخطأ الذي حصلنا عليه مع الكود في القائمة 8-6. يمنع الـ reference إلى الـ vector الذي تحتفظ به حلقة for التعديل المتزامن للـ vector بأكمله.
استخدام Enum لتخزين أنواع متعددة
يمكن للـ Vectors تخزين قيم من نفس النوع فقط. قد يكون هذا غير مريح؛ هناك بالتأكيد حالات استخدام تتطلب تخزين قائمة من العناصر من أنواع مختلفة. لحسن الحظ، يتم تعريف متغيرات (variants) الـ enum ضمن نفس نوع الـ enum، لذلك عندما نحتاج إلى نوع واحد لتمثيل عناصر من أنواع مختلفة، يمكننا تعريف واستخدام enum!
على سبيل المثال، لنفترض أننا نريد الحصول على قيم من صف في جدول بيانات يحتوي فيه بعض الأعمدة في الصف على integers، وبعضها على أرقام فاصلة عائمة (floating-point numbers)، وبعضها على strings. يمكننا تعريف enum تحتوي متغيراته على أنواع القيم المختلفة، وستعتبر جميع متغيرات الـ enum من نفس النوع: نوع الـ enum. بعد ذلك، يمكننا إنشاء vector لاحتواء هذا الـ enum، وبالتالي، في النهاية، الاحتفاظ بأنواع مختلفة. لقد أوضحنا ذلك في القائمة 8-9.
fn main() {
enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}
let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];
}
تحتاج Rust إلى معرفة الأنواع التي ستكون في الـ vector في وقت التجميع (compile time) حتى تعرف بالضبط مقدار الذاكرة على الكومة (heap) التي ستكون مطلوبة لتخزين كل عنصر. يجب أن نكون صريحين أيضًا بشأن الأنواع المسموح بها في هذا الـ vector. إذا سمحت Rust لـ vector بالاحتفاظ بأي نوع، فستكون هناك فرصة لأن يتسبب نوع واحد أو أكثر من الأنواع في حدوث أخطاء في العمليات التي يتم إجراؤها على عناصر الـ vector. يعني استخدام enum بالإضافة إلى تعبير match أن Rust ستضمن في compile time التعامل مع كل حالة ممكنة، كما نوقش في الفصل 6.
إذا كنت لا تعرف المجموعة الشاملة من الأنواع التي سيحصل عليها البرنامج في وقت التشغيل (runtime) لتخزينها في vector، فلن تنجح تقنية الـ enum. بدلاً من ذلك، يمكنك استخدام كائن سمة (trait object)، والذي سنغطيه في الفصل 18.
الآن بعد أن ناقشنا بعضًا من أكثر الطرق شيوعًا لاستخدام الـ vectors، تأكد من مراجعة وثائق API لجميع الـ methods المفيدة العديدة المعرفة على Vec<T> بواسطة الـ standard library. على سبيل المثال، بالإضافة إلى push، يزيل method pop العنصر الأخير ويُرجعه.
إسقاط Vector يسقط عناصره
مثل أي struct آخر، يتم تحرير (freed) الـ vector عندما يخرج من النطاق (scope)، كما هو موضح في القائمة 8-10.
fn main() {
{
let v = vec![1, 2, 3, 4];
// do stuff with v
} // <- v goes out of scope and is freed here
}
عندما يتم إسقاط الـ vector، يتم أيضًا إسقاط جميع محتوياته، مما يعني أنه سيتم تنظيف الـ integers التي يحتوي عليها. يضمن borrow checker أن أي references لمحتويات vector تُستخدم فقط بينما يكون الـ vector نفسه صالحًا.
دعنا ننتقل إلى نوع التجميع التالي: String!