اذهب إلى المحتوى

كيفية استخدام أنواع البيانات المعممة Generic Data Types في لغة Rust


Naser Dakhel

نستخدم الأنواع المُعمَّمة لإنشاء تعاريف لعناصر مثل بصمات الدوال function signatures أو الهياكل structs، بحيث تمكننا من استخدام عدّة أنواع بيانات ثابتة. دعنا ننظر أولًا إلى كيفية تعريف الدوال والهياكل والمُعدّدات enums والتوابع methods باستخدام الأنواع المعممة، ثم سنناقش كيف تؤثر الأنواع المعممة على أداء الشيفرة البرمجية.

في تعاريف الدوال

نضع الأنواع المعممة عند تعريف دالة تستخدمها في بصمة الدالة function signature، وهو المكان الذي نحدد فيه عادةً أنواع بيانات المعاملات ونوع القيمة المُعادة، إذ يكسب ذلك شيفرتنا البرمجية مرونةً أكبر ويقدم مزايا أكثر للشيفرة البرمجية المُستدعية لدالتنا مع منع تكرار الشيفرة البرمجية في الوقت ذاته.

لنستمرّ في مثال الدالة largest من المقالة السابقة: توضح الشيفرة 4 دالتين يعثران على أكبر قيمة في شريحة slice ما، وسنجمع هاتين الدالتين في دالة واحدة تستخدم الأنواع المعممة.

اسم الملف: src/main.rs

fn largest_i32(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> &char {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&number_list);
    println!("The largest number is {}", result);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("The largest char is {}", result);
}

[الشيفرة 4: دالتان تختلفان عن بعضهما بالاسم ونوع البيانات في بصمتهما]

الدالة largest_i32 هي الدالة التي استخرجناها من الشيفرة 3 (في المقال السابق) التي تعثر على أكبر قيمة i32 في شريحة، بينما تعثر الدالة largest_char على أكبر قيمة char في شريحة، ولدى الدالتين المحتوى ذاته، لذا دعنا نتخلص من التكرار باستخدام الأنواع المعممة مثل معاملات في دالة وحيدة.

نحتاج إلى تسمية نوع المعامل حتى نكون قادرين على استخدام عدة أنواع في دالة واحدة جديدة، كما نفعل عندما نسمّي قيم معاملات الدالة، ويمكنك هنا استخدام معرّف بمثابة اسم نوع معامل، إلا أننا سنستخدم T لأن أسماء المعاملات في لغة رست قصيرة اصطلاحًا وغالبًا ما تكون حرفًا واحدًا، كما أن اصطلاح رست في تسمية الأنواع قائمٌ على نمط سنام الجمل CamelCase، وتسمية النوع T هو اختصار لكلمة النوع "type" وهو الخيار الشائع لمبرمجي لغة رست.

علينا أن نصرّح عن اسم المعامل عندما نستخدمه في متن الدالة وذلك في بصمة الدالة حتى يعرف المصرّف معنى الاسم، كما ينبغي علينا بصورةٍ مشابهة تعريف اسم نوع المعامل في بصمة الدالة قبل أن نستطيع استخدامه داخلها. لتعريف الدالة المعممة largest نضع تصاريح اسم النوع داخل قوسين مثلثين <> بين اسم الدالة ولائحة المعاملات بالشكل التالي:

fn largest<T>(list: &[T]) -> &T {

نقرأ التعريف السابق كما يلي: الدالة largest هي دالة معممة تستخدم نوعًا ما اسمه T، ولدى هذه الدالة معاملٌ واحدٌ يدعى list وهو قائمة من القيم نوعها T، وتعيد الدالة largest مرجعًا إلى قيمة نوعها أيضًا T.

توضح الشيفرة 5 تعريف الدالة المُدمجة باستخدام نوع البيانات المعمم في بصمتها، كما توضح الشيفرة أيضًا كيفية استدعاء الدالة باستخدام شريحة من قيم i32 أو من قيم char. لاحظ أن الشيفرة البرمجية لم تُصرّف بعد، إلا أننا سنصلح ذلك لاحقًا.

اسم الملف: src/main.rs

fn largest<T>(list: &[T]) -> &T {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {}", result);

    let char_list = vec!['y', 'm', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {}", result);
}

[الشيفرة 5: دالة largest تستخدم معاملات من أنواع معممة؛ إلا أن الشيفرة لا تُصرَّف بنجاح بعد]

إذا صرّفنا الشيفرة البرمجية السابقة، سنحصل على الخطأ التالي:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
  |             ++++++++++++++++++++++

For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` due to previous error

تذكر رسالة الخطأ المساعدة std::cmp::PartialOrd وهي سمة trait، وسنتحدث عن السمات لاحقًا. يكفي معرفتك حتى اللحظة أن مفاد الخطأ هو أن محتوى الدالة largest لن يعمل لجميع الأنواع المحتملة للنوع T، وذلك لأننا نريد مقارنة قيم النوع T في محتوى الدالة ويمكننا الآن استخدام أنواع يمكن لقيمها أن تُرتَّب. يمكننا لتمكين المقارنات استخدام السمة std::cmp::PartialOrd في المكتبة القياسية على الأنواع. إذا اتبعنا النصيحة الموجودة في رسالة الخطأ فسنحدّ من الأنواع الصالحة في T إلى الأنواع التي تطبّق السمة PartialOrd، وسيُصرَّف المثال بنجاح لأن المكتبة القياسية تطبّق السمة PartialOrd على كلٍ من النوعين i32 و char.

في تعاريف الهياكل

يمكننا أيضًا تعريف الهياكل، بحيث تستخدم أنواع معممة مثل معامل ضمن حقل أو أكثر باستخدام <>. نعرّف في الشيفرة 6 هيكل Point<T>‎ يحتوي على الحقلين x و y وهي قيم إحداثيات من أي نوع.

اسم الملف: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}

[الشيفرة 6: هيكل Point<T>‎ يخزن بداخله القيمتين x و y نوعهما T]

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

لاحظ أننا استخدمنا نوعًا معممًا واحدًًا فقط لتعريف Point<T>‎ وبالتالي يخبرنا هذا التعريف أن الهيكل Point<T>‎ هو هيكل معمم باستخدام نوع T وأن الحقلين x و y يحملان النوع ذاته أيًا يكن. لن تُصرَّف الشيفرة البرمجية إذا أردنا إنشاء نسخة من الهيكل Point<T>‎ يحمل قيمًا من أنواع مختلفة كما نفعل في الشيفرة 7.

اسم الملف: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}

[الشيفرة 7: يجب أن يكون للحقلين x و y النوع ذاته لأنهما يحملان النوع المعمم ذاته T]

نخبر المصرف في هذا المثال عند إسنادنا القيمة العددية الصحيحة "5" إلى x أن النوع المعمم T سيكون عددًا صحيحًا لهذه النسخة من Point<T>‎. نحصل على خطأ عدم مطابقة النوع التالي عندما نحدد أن y قيمتها "4.0" وهي معرّفة أيضًا بحيث تحمل قيمة x ذاتها:

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-point number

For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` due to previous error

نستخدم معاملات الأنواع المعممة المتعددة لتعريف الهيكل Point بحيث يكون كلًا من x و y من نوع معمم ولكن مختلف. على سبيل المثال، نغيّر في الشيفرة 8 تعريف Point لتصبح دالةً معممةً تحتوي النوعين T و U، إذ يكون نوع x هو T و y من النوع U.

اسم الملف: src/main.rs

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}

[الشيفرة 8: دالة Point<T, U>‎ المعممة التي تحتوي على نوعين بحيث يكون لكلٍ من المتغيرين x و y نوع مختلف]

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

في تعاريف المعدد

نستطيع تعريف المعددات، بحيث تحمل أنواع بيانات معممة في متغايراتها variants كما هو الأمر في الهياكل. دعنا ننظر إلى مثال آخر باستخدام المعدد Option<T>‎ الموجود ضمن المكتبة القياسية الذي ناقشناه سابقًا:

enum Option<T> {
    Some(T),
    None,
}

يجب أن تفهم هذا التعريف بحلول هذه النقطة بمفردك، فكما ترى معدّد Option<T>‎ هو معدد معمم يحتوي على النوع T ولديه متغايران: Some الذي يحمل قيمةً واحدةً من النوع T و None الذي لا يحمل أي قيمة. يمكننا التعبير عن المفهوم المجرّد للقيمة الاختيارية باستخدام المعدد Option<T>‎، ولأن Option<T>‎ هو معدد معمم، فهذا يعني أنه يمكننا استخدامه بصورةٍ مجرّدة بغض النظر عن النوع الخاص بالقيمة الاختيارية.

يمكن للمعددات أن تستخدم أنواعًا معممةً متعددة أيضًا، والمعدد Result الذي استخدمناه في مقال الأخطاء والتعامل معها هو مثال على ذلك:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

المعدد Result هو معدد مُعمم يحتوي على نوعين، هما: T و E، كما يحتوي على متغايرين، هما: Ok الذي يحمل قيمة من النوع T و Err الذي يحمل قيمة من النوع E، يسهّل هذا التعريف عملية استخدام المعدد Result في أي مكان يوجد فيه عملية قد تنجح (في هذه الحالة إعادة قيمة من نوع ما T)، أو قد تفشل (في هذه الحالة إعادة خطأ من قيمة ما E)، وهذا هو ما استخدمناه لنفتح الملف في مقال الأخطاء والتعامل معها في رست عندما كان النوع T يحتوي على النوع std::fs::File عند فتح الملف بنجاح وكان يحتوي E على النوع std::io::Error عند ظهور مشاكل في فتح الملف.

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

في تعاريف التابع

يمكننا تطبيق التوابع على الهياكل والمعددات (كما فعلنا سابقًا في مقال استخدام الهياكل structs لتنظيم البيانات) واستخدام الأنواع المعممة في تعريفها أيضًا. توضح الشيفرة 9 الهيكل Point<T>‎ الذي عرفناه في الشيفرة 6 مصحوبًا بتابع يدعى x داخله.

اسم الملف: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

[الشيفرة 9: تطبيق تابع تدعى x على الهيكل Point<T>‎ وهو تابع يعيد مرجعًا إلى الحقل x الذي نوعه T]

عرّفنا هنا تابعًا يدعى x داخل Point<T>‎ يعيد مرجعًا إلى البيانات الموجودة في الحقل x. لاحظ أنه علينا التصريح عن T قبل impl حتى يتسنى لنا استخدام T لتحديد أننا نطبّق التوابع الموجودة في النوع Point<T>‎.

تتعرّف رست على وجود النوع بين أقواس مثلثة في Point على أنه نوع معمّم وذلك بالتصريح عن T على أنه نوع مُعمّم بعد impl بدلًا عن النظر إلى النوع على أنه نوع ثابت. يمكننا اختيار اسم مختلف عن اسم معامل النوع المعمم المصرح في تعريف الهيكل لمعامل النوع المعمم هذا، إلا أن استخدام الاسم ذاته هي الطريقة الاصطلاحية. تُعرَّف التوابع المكتوبة ضمن impl التي تصرّح عن النوع المعمّم ضمن أي نسخة من هذا النوع بغض النظر عن النوع الثابت الذي يستبدل هذا النوع المعمم في نهاية المطاف.

يمكننا أيضًا تحديد بعض القيود على الأنواع المعممة عند تعريف التوابع الخاصة بالنوع، فيمكننا مثلًا تطبيق تابع على نسخ Point<f32>‎ فقط بدلًا من نسخ Point<T>‎ التي تحتوي على أي نوع مُعمّم. نستخدم في الشيفرة 10 النوع الثابت f32 وبالتالي لا نصرّح عن أي نوع بعد impl.

اسم الملف: src/main.rs

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

[الشيفرة 10: كتلة impl تُطبَّق فقط على هيكل بنوع ثابت معين موجود في معامل النوع المعمم T]

تشير الشيفرة البرمجية السابقة إلى أن النوع Point<f32‎>‎ سيتضمن التابع distance_from_origin، لكن لن تحتوي النسخ الأخرى من Point<T>‎، إذ تمثّل T نوعًا آخر ليس f32 على تعريف هذا التابع داخلها. يقيس هذا التابع مسافة النقطة عن مبدأ الإحداثيات (0.0 ,0.0) ويستخدم عمليات حسابية متاحة فقط لأنواع قيم العدد العشري floating point.

لا تطابق معاملات النوع المُعمم في تعريف الهيكل معاملات النوع المعمم الموجودة في بصمة الهيكل نفسه دومًا. لاحظ أننا نستخدم النوعين المعمّمين X1 و Y1 في الشيفرة 11 اللذين ينتميان إلى الهيكل Point و X2 و Y2 لبصمة التابع mixup لتوضيح المثال أكثر. تُنشئ نسخة Point جديدة باستخدام قيمة x من self Point (ذات النوع X1) وقيمة y من النسخة Point التي مرّرناها (ذات النوع Y2).

اسم الملف: src/main.rs

struct Point<X1, Y1> {
    x: X1,
    y: Y1,
}

impl<X1, Y1> Point<X1, Y1> {
    fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };

    let p3 = p1.mixup(p2);

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

[الشيفرة 11: تابع يستخدم أنواع معممة مختلفة من تعريف الهيكل]

عرّفنا في main الهيكل Point الذي يحتوي على النوع i32 للحقل x بقيمة 5، وحقل من النوع f64 يدعى y بقيمة 10.4. يمثل المتغير p2 هيكلًا من النوع Point يحتوي على شريحة سلسلة نصية string slice داخله في الحقل x بقيمة "Hello"، وقيمة من النوع char في الحقل y بقيمة c.

يعطينا استدعاء mixup على النسخة p1 باستخدام p2 مثل معامل p3، وهو هيكل سيحتوي داخله على قيمة من النوع i32 في الحقل x لأن x أتى من p1، وسيحتوي p3 على حقل y داخله قيمة من نوع char لأن y أتى من p2، وبالتالي سيطبع استدعاء الماكرو println!‎ التالي:

p3.x = 5, p3.y = c

كان الهدف من هذا المثال توضيح حالة يكون فيها المعاملات المعمّمة مصرّح عنها في impl وبعضها الآخر مصرّح عنها في تعريف التابع، إذ أنّ المعاملات المعممة X1 وY1 مصرّحٌ عنهما هنا بعد impl لأنهما يندرجان تحت تعريف الهيكل، بينما تصريح المعاملين X2 و Y2 كان بعد fn mixup لأنهما متعلقان بالتابع فقط.

تأثير استخدام المعاملات المعممة على أداء الشيفرة البرمجية

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

تنجح رست بتحقيق ذلك عن طريق إجراء عملية توحيد شكل monomorphization الشيفرة البرمجية باستخدام الأنواع المعماة وقت التصريف؛ وعملية توحيد الشكل هي عملية تحويل الشيفرة البرمجية المعممة إلى شيفرة برمجية محددة عن طريق ملئها بالأنواع الثابتة المستخدمة عند التصريف، ويعكس المصرف في هذه المرحلة ما يفعله عندما يُنشئ دالة معمّاة في الشيفرة 5؛ إذ ينظر المصرّف إلى الأماكن التي يوجد بها شيفرة برمجية معماة ويولّد شيفرة برمجية تحتوي على أنواع ثابتة تُستدعى منها الشيفرة البرمجية المعمّاة.

دعنا ننظر إلى كيفية عمل هذه الخطوة باستخدام المعدد المعمم Option<T>‎ الموجود في المكتبة القياسية:

let integer = Some(5);
let float = Some(5.0);

تُجري رست عملية توحيد الشكل عندما تصرَّف الشيفرة البرمجية السابقة، ويقرأ المصرف خلال العملية القيم التي استُخدمت في نسخ Option<T>‎ ويتعرف على نوعين مختلفين من Option<T>‎ أحدهما i32 والآخر f64، وبالتالي يتحول التعريف المعمم للنوع Option<T>‎ إلى تعريفين، أحدهما تعريف للنوع i32 والآخر للنوع f64 ويُستبدل التعريفان بالتعريف المعمّم.

هذا ما تبدو عليه الشيفرة البرمجية السابقة بعد إجراء عملية توحيد الشكل (يستخدم المصرف أسماءً مختلفة عمّا نستخدم هنا في المثال التوضيحي):

اسم الملف: src/main.rs

enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

يُستبدل النوع المعمم Option<T>‎ بتعاريف الأنواع المحددة عن طريق المصرف، ولأن رست تُصرف الشيفرة البرمجية المعممة إلى شيفرة برمجية ذات نوع ثابت لكل نسخة فلا يوجد هناك أي تراجع في أداء الشيفرة البرمجية عند استخدام الأنواع المعممة، إذ تعمل الشيفرة البرمجية عند تشغيلها بأداء مماثل لما قد يكون عليه أداء الشيفرة البرمجية التي تكرّر كل تعريف يدويًا، وتجعل عملية توحيد الشكل من الأنواع المعممة في رست ميزة فعّالة جدًا عند وقت التشغيل.

ترجمة -وبتصرف- لقسم من الفصل Generic Types, Traits, and Lifetimes من كتاب The Rust Programming Language.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...