TS تحلّ مشكلة "كود يعمل على أي نوع" بالـ generics، ثم — عند الترجمة إلى JS — تمحو كل أثر لها. لا T يبقى وقت التشغيل، لا فحص، لا شيء؛ التوقيع كان عقدًا بين المطوّرين فقط. Rust لا تملك هذه الرفاهية: لا يوجد "وقت ترجمة إلى لغة أخرى تُمحى فيه الأنواع" — الناتج كود آلة مباشرة، يعمل بلا أي مفسّر يحمل معلومات الأنواع معه وقت التشغيل. فكيف تكتب "دالة تعمل على أي نوع" في لغة كهذه؟
الدرس ١: صمّم آلية العمومية بنفسك
اللغز التأسيسي
تعرف من C حلًّا لهذه المشكلة بالذات: qsort.
int cmp_int(const void *a, const void *b) {
return (*(const int *)a - *(const int *)b);
}
qsort(arr, n, sizeof(int), cmp_int);
qsort "عامة" بمعنى: تعمل على أي نوع، لأنها تتناسى النوع كليًا — تتعامل مع void * وحجم بايتات صريح تمرّره أنت يدويًا. ثمنها: لا شيء يمنعك من تمرير cmp_str (تتوقع const char **) لفرز مصفوفة int[]. ستُترجَم بلا شكوى، وستحصل على UB وقت التشغيل — لأن void * كذبة نوع مقصودة، تمامًا كما كان union بلا تاغ كذبة نوع في الإقليم الماضي.
مهمتك: أمامك خياران معماريان لحلّ "كود واحد يعمل على أنواع متعددة" بلا كذب void* وبلا وقت تشغيل يمحو الأنواع:
- (أ) يكتب المبرمج نسخة واحدة من الكود، والمترجم يستنسخها فعليًا إلى نسخة منفصلة، متخصصة تمامًا، لكل نوع مستعمَل فعليًا في البرنامج — قبل أن يبدأ التشغيل بلحظة.
- (ب) يُترجَم الكود مرة واحدة بشكل عام حقًا، ويُستدعى في كل حالة عبر طبقة تنسيق (indirection) تعرف وقت التشغيل أيّ نوع فعلي تتعامل معه.
اكتب لكل خيار: ما ثمنه في حجم الملف التنفيذي؟ ما ثمنه في سرعة كل استدعاء؟ هل يحتاج (ب) لأي معلومة تُبقيها حول النوع وقت التشغيل — أي بالضبط ما رفضنا أن نملكه في افتتاحية هذا الإقليم؟ أيّهما "بلا ثمن" فعلًا بمعنى: "الكود المولَّد لا يقلّ كفاءة عمّا كنتَ ستكتبه يدويًا لكل نوع على حدة"؟
الحل: Rust تختار (أ) افتراضيًا — Monomorphization
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut m = &list[0];
for x in list {
if x > m { m = x; }
}
m
}
largest(&[3, 7, 2]); // يستدعي نسخة i32
largest(&["a", "z", "m"]); // يستدعي نسخة &str
<T: PartialOrd> يعني: T معامل — لكن معامل نوع، لا قيمة (فرق جوهري عن generics في TS التي تتعامل مع النوع كمتغيّر منطقي وقت الترجمة فقط؛ هنا هو نفسه لكن الأثر يمتد لما بعد الترجمة). PartialOrd قيد (bound): "أي نوع T طالما يدعم < و>". بلا القيد، x > m لن يُترجَم — المترجم يرفض أن يفترض قدرة لم تُثبت. (قارن: void* في C يفترض كل شيء ولا شيء في آن؛ القيد هنا يفترض بالضبط ما أثبتّه، لا أكثر.)
عند الترجمة، يفحص المترجم كل موضع استُدعيت فيه largest فعليًا، ويولّد نسخة آلة منفصلة كاملة لكل نوع فعلي: كأنك كتبت largest_i32 و largest_str بيدك. هذا اسمه monomorphization (من "poly-morphic" [أشكال كثيرة] إلى "mono-morphic" [شكل واحد] لكل نسخة). النتيجة: صفر تكلفة إضافية وقت التشغيل — لا void*، لا فحص نوع، لا طبقة تنسيق. الكود المولَّد للنسخة i32 مطابق تمامًا لما كنت ستكتبه يدويًا لو صُمّمت الدالة لـi32 فقط منذ البداية. هذا بالحرف تعريف zero-cost abstraction (شعار Bjarne Stroustrup الأصلي لـ C++): ما لا تستعمله لا تدفع ثمنه، وما تستعمله لا يمكنك كتابته يدويًا بكفاءة أعلى.
الثمن — ولكل شيء ثمن — هو حجم الملف التنفيذي (code bloat): استعملتَ largest مع خمسة أنواع؟ خمس نسخ من الآلة الفعلية تعيش في الثنائي. هذا ثمن معلن، لا مخفي، ويمكنك رؤيته بأدوات مثل cargo bloat إن أردت.
قارنها الآن بـ TS: هناك، T معلومة تُمحى بالكامل قبل أن يعمل أي شيء — لا نسخ، لا تكاثر، لكن أيضًا لا حماية وقت التشغيل (تذكّر ledger TS نقطة ٩: any/erasure). هنا: لا محو على الإطلاق — بل تكاثر، والحماية موجودة لأن كل نسخة مُتخصَّصة فعليًا ومفحوصة وقت الترجمة. اتجاهان معاكسان تمامًا لحل نفس المشكلة الظاهرية.
الدرس ٢: Trait — العقد الذي يحرس القيد
trait مقابل C وTS
trait Shape {
fn area(&self) -> f64;
fn name(&self) -> &str { "shape" } // تطبيق افتراضي — مسموح
}
struct Circle { r: f64 }
impl Shape for Circle {
fn area(&self) -> f64 { std::f64::consts::PI * self.r * self.r }
}
trait أقرب لـ interface في TS، لكنه مصدر القيود (bounds) التي رأيتها أعلاه: T: PartialOrd تعني "أي T طبَّق trait PartialOrd". بعقلية C: هذا أشبه بجدول دوال (vtable) تُعرِّفه صراحةً بدل تمرير مؤشر دالة خامًا كما في qsort — لكن هنا المترجم يتحقق وقت الترجمة أن كل نوع يدّعي تطبيق Shape يوفّر فعليًا area، بالتوقيع الصحيح تمامًا، لا اجتهادًا.
لاحظ توقيع area(&self): هذا القرار نفسه الذي اتخذته يدويًا في توقيعات الإقليمين ٠١ و٠٢ — استعارة، أم استعارة حصرية، أم تمليك؟ — لكنه الآن مكتوب كجزء من تعريف الطريقة (method) نفسها بدل معامل عادي:
| الاستقبال | يعني |
|---|---|
fn f(&self) | استعارة قراءة — نظر بلا تعديل |
fn f(&mut self) | استعارة حصرية — تعديل في المكان |
fn f(self) | تمليك — استهلاك القيمة (نادر، لتحويلات تُنهي حياة الأصل) |
derive — حين يصبح التطبيق ميكانيكيًا
في الإقليم ٠١ كتبتَ impl Drop for Noisy بيدك — منطق حقيقي، لا يمكن أتمتته. لكن طلبات كثيرة ميكانيكية بحتة: "قارن كل الحقول"، "انسخ كل حقل عميقًا"، "اطبع الحقول بصيغة تصحيح". اكتب واحدة بيدك لتشعر بالتكرار:
struct Point { x: i32, y: i32 }
impl PartialEq for Point {
fn eq(&self, other: &Self) -> bool { self.x == other.x && self.y == other.y }
}
الآن قارن بالسكّر:
#[derive(Debug, Clone, PartialEq)]
struct Point { x: i32, y: i32 }
derive يولّد نفس الكود الميكانيكي آليًا. هذا مثال idiom طبقة ثانية: لماذا يكتب أهل اللغة #[derive(...)] في كل مكان تقريبًا؟ لأنهم جرّبوا كتابتها يدويًا مرة واحدة (كما فعلتَ للتو)، ورفضوا التكرار.
Static Dispatch مقابل Dynamic Dispatch
عُد لـ qsort: مؤشر الدالة cmp الذي مررته هو dynamic dispatch يدوي وخام — القرار "أيّ دالة تُنادى" مؤجَّل لوقت التشغيل، عبر مؤشر. Rust تعطيك الخيار نفسه، لكن مُطعَّمًا بالأمان، إلى جانب الخيار الافتراضي الأسرع:
fn total_area_generic(shapes: &[impl Shape]) -> f64 { // static — monomorphized
shapes.iter().map(|s| s.area()).sum()
}
fn total_area_dyn(shapes: &[Box<dyn Shape>]) -> f64 { // dynamic — vtable
shapes.iter().map(|s| s.area()).sum()
}
- Static (
impl Traitأو<T: Shape>): نفس monomorphization أعلاه. سريع كحدّه الأقصى، لكن يجب معرفة النوع الفعلي وقت الترجمة — قائمة أشكال من نوع واحد فقط في كل استدعاء فعلي. dyn Shape(trait object): الآن يمكنك تكديس أنواع مختلفة معًا:Vec<Box<dyn Shape>>تحوي دوائر ومربعات معًا بلا مشكلة. الثمن: كل قيمةdyn Shapeهي fat pointer — مؤشر للبيانات + مؤشر لجدول دوال (vtable) — واستدعاءarea()يقفز عبر الجدول وقت التشغيل. هذا بالضبط مؤشر الدالة فيqsort، مع فارقين: الأمان (لا يمكن خلط جدول نوع بمؤشر بيانات نوع آخر — المترجم يربطهما معًا دومًا) والتنظيم (الجدول يُبنى تلقائيًا من تطبيقك للـ trait، لا تكتبه يدويًا).
الجدول الكامل، حتى تحسم بنفسك حين تصمّم:
Static (T: Trait / impl Trait) | Dynamic (dyn Trait) | |
|---|---|---|
| القرار "أي دالة؟" | وقت الترجمة | وقت التشغيل (قفزة جدول) |
| السرعة | أقصى — مطابقة ليدٍ متخصصة | قفزة إضافية واحدة لكل نداء |
| الحجم | يتكاثر (نسخة لكل نوع) | نسخة واحدة مشتركة |
| أنواع مختلطة في قائمة واحدة | لا | نعم |
| يحتاج معرفة النوع وقت الترجمة | نعم | لا (يكفي معرفة الـ trait) |
الألغاز
**لغز ٠٤-أ: فرز بلا void*. اكتب fn my_sort<T: PartialOrd>(list: &mut [T]) (فرز فقاعي بسيط يكفي — الخوارزمية ليست الهدف) تعمل على أي شريحة قابلة للترتيب. جرّبها على [i32] وعلى [&str]. ثم — بلا تعديل توقيع my_sort — حاول استدعاءها بمزيج داخل نفس الشريحة (&mut [i32, "a"]، بأي حيلة تخطر ببالك لخلطهما في نوع واحد). لن يُترجَم. اكتب سطرين: أين بالضبط رفض المترجم، ولماذا نفس الرفض مستحيل** في نداء qsort المطابق في C (سيُترجَم، وينفجر لاحقًا بصمت أو بـ crash غامض).
لغز ٠٤-ب: المستودع المختلط. عرّف trait Shape { fn area(&self) -> f64; } وطبّقها على ثلاثة أنواع مختلفة على الأقل (دائرة، مربع، مثلث). اكتب دالتين: واحدة تأخذ &[impl Shape] (أو generic صريحة) وأخرى تأخذ &[Box<dyn Shape>]، وكلتاهما تُرجعان مجموع المساحات. جرّب فعليًا أن تبني Vec تحوي دائرة ومربعًا معًا ومرّرها للدالة الأولى — سيرفضها المترجم. اكتب سطرًا يشرح بالضبط لماذا الدالة الـ generic لا تستطيع قبول هذا المزيج حتى لو أردتَ، بينما Vec<Box<dyn Shape>> تستطيع بلا مشكلة — اربط جوابك بمونومورفيزيشن.
لغز ٠٤-ج: derive من الصفر ثم بالسكّر. عرّف struct Vec3 { x: f64, y: f64, z: f64 }. اكتب يدويًا (بلا derive) تطبيقات Debug (استعمل write! داخل impl std::fmt::Debug — النمط شبيه لما فعلته مع Drop في ٠١-ج) وPartialEq وClone. بعد أن تُشغّلها وتتأكد من صحتها، احذفها كلها واستبدلها بسطر واحد #[derive(Debug, PartialEq, Clone)]، وتأكد أن كل الاستعمالات السابقة لا تزال تعمل بلا تغيير. اكتب في تعليق: أي جزء من الكود اليدوي كان تكرارًا حرفيًا يمكن لأي آلة توليده، وأي جزء (إن وُجد) كان يحتاج قرارًا بشريًا؟ هذا هو المعيار الذي يفصل ما يستحق derive عمّا يستحق impl يدوي كـDrop.
الخلاصة
- العقدة الجديدة: generics تُحل بالتكاثر (monomorphization) لا بالمحو — نقيض TS تمامًا في الاتجاه، وتوأمها في الهدف (كتابة كود مرة واحدة).
traitهو العقد الذي يحرس القيد، ومنه ينبثق خيار Static مقابل Dynamic dispatch — نسخة آمنة ومنظَّمة منvoid*+ مؤشر دالة الذي عرفته في C. - الوصلة للخلف:
self/&self/&mut selfليست صياغة جديدة — إنها توقيعات الملكية من ٠١-٠٢ وقد انضمّت لنوعها. وexhaustiveness من ٠٣ ونظام الأنواع هنا كلاهما يخدم نفس الهدف: إثبات وقت الترجمة بدل الأمل وقت التشغيل. - الوصلة للأمام: كل الأمثلة أعلاه استعملت
&self،&T،&[T]— استعارات تعيش داخل جسم دالة واحدة قصيرة. ماذا لو أرادstructنفسه أن يحوي مرجعًا كحقل، يعيش معه؟ جرّب الآن، بسرعة، قبل أن تكمل:
struct Highlight {
text: &str, // جرّب هذا فعليًا
}
رسالة الخطأ ستذكر 'a مجددًا — الحرف الذي لاحقك بصمت منذ الإقليم ٠٢ حين رفض المترجم إرجاع &str من دالة. حان وقت إعطائه اسمًا ومعنى كاملَين، بدل أن يبقى طلسمًا يقترحه المترجم عليك عشوائيًا.