في نهاية الإقليم الماضي تركنا سؤالًا معلّقًا: كيف تقول دالة "أعيد لك إما قيمة أو لا شيء"، أو "إما نتيجة أو خطأ"، دون أن تهرّب الإجابة في NULL أو -1 كما فعلت C منذ ١٩٧٢؟ هذا الإقليم هو الجواب — وهو أيضًا الموعد الذي وعدناك به: سنحاسبك الآن على كل unwrap() كتبته بلا حساب في الإقليم ٠٠.
الدرس ١: صمّم الاتحاد الموسوم بنفسك
اللغز التأسيسي
في C، لو أردت قيمة "قد تكون عددًا أو قد تكون نصًا" كتبتَ شيئًا كهذا:
typedef enum { TAG_INT, TAG_STR } Tag;
typedef struct {
Tag tag;
union { int i; char *s; } data;
} Value;
Value v = { .tag = TAG_INT, .data.i = 42 };
printf("%s\n", v.data.s); // اقرأها كنص رغم أن tag يقول INT
هذا السطر الأخير يترجَم بلا أي شكوى. لا شيء في اللغة يربط قراءتك بالـ tag الذي كتبتَه بيدك بضعة أسطر فوق — الربط بينهما عرفٌ في رأسك، لا قانون في المترجم. لو نسيتَ فحص الـ tag قبل القراءة (وستنسى، يومًا ما، في دالة بعيدة عن مكان التعريف)، تقرأ بايتات عدد وكأنها مؤشر نص: undefined behavior.
مهمتك: صمّم — على الورق — تعديلًا على هذه البنية بحيث يصبح الوصول لـ data.s دون المرور بفحص tag == TAG_STR مستحيلًا نحويًا، لا مجرد خطأ برمجي محتمل. القيد: يجب أن تبقى القيمة قادرة على حمل عدد أو نص، لا الاثنين معًا، بلا تضخم غير ضروري في الذاكرة. فكّر: ما الذي يمنع المترجم اليوم من فرض هذا؟ (تلميح للتفكير لا للحل: أين "يعيش" التاغ، وأين تُكتب شيفرة القراءة؟ هل بينهما أي رابط تفرضه بنية اللغة نفسها؟)
الحل: enum كاتحاد موسوم حقيقي
enum Value {
Int(i32),
Str(String),
}
let v = Value::Int(42);
match v {
Value::Int(i) => println!("number: {i}"),
Value::Str(s) => println!("text: {s}"),
}
هذا بالضبط بنية C أعلاه — تاغ + حمولة متّحدة — لكن مع فرق واحد يقلب كل شيء: الطريقة الوحيدة لاستخراج الحمولة هي match، وMatch لا يعطيك i إلا داخل الفرع الذي أثبت فيه المترجم أن التاغ فعلًا Int. لا يوجد مسار آخر للوصول للبيانات. القراءة الخاطئة التي كتبتَها في C لا تملك حتى صياغة نحوية ممكنة هنا — هذا معنى "مستحيل نحويًا" الذي طُلب منك تصميمه.
لاحظ أيضًا: الـ exhaustiveness — إن أضفتَ متغيّرًا ثالثًا لـ Value (لنقل Bool(bool)) ولم تُحدّث كل match في المشروع، يرفض المترجم الترجمة حتى تعالج الحالة الجديدة في كل موضع. جرّبها الآن فعليًا: أضف متغيّرًا لـ Value، ولاحظ القائمة التي يعطيك إياها المترجم — كل موضع يحتاج تحديثًا، بالسطر والعمود. هذا ليس تلطّفًا من الأداة؛ إنه شبكة أمان إعادة هيكلة (refactoring) لم تملكها في حياتك مع C إطلاقًا: لو أضفت TAG_BOOL هناك، كل switch نسيتَه سيمرّ بصمت.
هذا هو نفس مفهوم discriminated unions وإغلاق دائرة never (exhaustiveness) الذي بنيتَه في منهج TypeScript — والفرق الجوهري بين النسختين يستحق التوقف عنده: في TS، الفحص وقت الترجمة فقط، ثم الأنواع كلها تُمحى (erasure) قبل أن يشتغل البرنامج فعليًا؛ التاغ التمييزي (kind: "circle") الذي يبقى وقت التشغيل هو حقلٌ أنت من كتبتَه يدويًا كبيانات عادية، لا شيء يفرضه نظام الأنواع نفسه وقت التشغيل. هنا التاغ فيزيائي — بايتات حقيقية تعيش في الذاكرة دومًا، يضعها المترجم، ويتحقق منها كل match فعليًا كل مرة. ستفهم لماذا هذا الفرق جوهري أكثر في الإقليم القادم.
حجم enum في الذاكرة
بعقلية C: حجم enum Value تقريبًا = حجم التاغ + حجم أكبر حمولة (كما في اتحاد C اليدوي، مع فرق التاغ المضمون). استثناء أنيق يستحق معرفته: أنواع معيّنة تملك "قيمًا محرَّمة" طبيعيًا — مرجع &T لا يكون أبدًا صفرًا (البديهية الأولى تمنع ذلك). فيستغل المترجم بت النمط الصفري كتاغ مجانًا: Option<&T> بحجم &T نفسه، بلا بايت تاغ إضافي (اسمها niche optimization). هذا تفصيل صغير، لكنه أول مثال ملموس على شعار سيتكرر: الأمان هنا لا يعني دفع ثمن أكبر — أحيانًا يعني معرفة أعمق تُستثمر ليكون الثمن أصغر.
الدرس ٢: Option — إغلاق أخطر ثغرة في تاريخ البرمجة
الخلفية
Tony Hoare، مخترع مرجع الـ null سنة ١٩٦٥ في ALGOL W، وصفه لاحقًا (٢٠٠٩) بـ"خطئي بمليار دولار" — لأن كل مؤشر في كل لغة تبنّت الفكرة صار يكذب في نوعه: char *s يقول "أنا نص"، لكنه فعليًا "أنا نص، أو ربما لا شيء على الإطلاق، ولن أخبرك بالفرق حتى تصطدم به وقت التشغيل" (segfault، أو NPE في Java). كل توقيع دالة تُرجع مؤشرًا في C كان يحمل هذا الكذب الصامت.
الحل
enum Option<T> {
Some(T),
None,
}
هذا تعريف حقيقي من المكتبة القياسية (enum عادية تمامًا، تستعمل ما بنيتَه للتو، مع معامل نوع T — سترى صياغته الكاملة في الإقليم القادم). لا يوجد null في Rust مطلقًا. غياب القيمة ليس حالة خفية لكل مؤشر — إنه حالة معلنة صراحة في النوع، ومحروسة بنفس آلية match التي بنيتها للتو:
fn find(v: &[i32], target: i32) -> Option<usize> {
let mut i = 0;
while i < v.len() {
if v[i] == target { return Some(i); }
i += 1;
}
None
}
match find(&[1, 2, 3], 9) {
Some(i) => println!("found at {i}"),
None => println!("not found"),
}
توقيع find نفسه يعلن أن النتيجة قد تغيب — لا توثيق خارجي، لا عرف ضمني. ومن يستدعيها مُجبَر على معالجة الغياب قبل أن يصل للقيمة، لأن لا طريقة أخرى لاستخراج usize من Option<usize> سوى المرور بـ match (أو أخواتها أدناه). segfault-من-null صار — كما وعدناك أعلاه — غير قابل للصياغة النحوية.
حساب unwrap
الآن الحساب المؤجَّل: .unwrap() تستخرج القيمة من Some — أو تُفزِّع (panic) إن كانت None، فيتوقف البرنامج فورًا. كل unwrap() كتبتَه في الإقليم ٠٠ كان رهانًا صريحًا: "أراهن أن هذه القيمة لن تكون غائبة، وإن خسرت الرهان فليتوقف البرنامج بصراخ لا بصمت." الرهان أحيانًا مبرَّر (نص literal تعرف يقينًا أنه رقم صحيح) وأحيانًا كسل. من الآن: أي unwrap() تكتبه في ألغاز هذا الإقليم فصاعدًا يحتاج مبررًا يستطيع الصمود أمام سؤال "ماذا لو كنتَ مخطئًا؟" — وإلا فالبديل الاصطلاحي match، أو unwrap_or(default)، أو ? (بعد قليل).
الدرس ٣: Result — الخطأ كقيمة، لا كقفزة خفية
اللغز
من C تعرف نمطين لتبليغ الخطأ: قيمة راجعة سحرية (-1, NULL) يسهل نسيان فحصها، أو errno عالمي منفصل تمامًا عن توقيع الدالة — لا شيء يجبرك على قراءته. من لغات أخرى قد تعرف الاستثناءات: تُرمى من أي مكان، وتقفز عبر طبقات من الدوال لا يظهر في توقيع أيٍّ منها أنها قد ترمي شيئًا. كلا النمطين يخفي احتمال الفشل عن القارئ. صمّم بديلًا يجعل احتمال الفشل جزءًا من نوع الإرجاع نفسه — بحيث يقرأه المُستدعي من التوقيع وحده، بلا تشغيل البرنامج وبلا قراءة توثيق.
الحل
enum Result<T, E> {
Ok(T),
Err(E),
}
fn parse_port(s: &str) -> Result<u16, String> {
match s.trim().parse::<u16>() {
Ok(n) => Ok(n),
Err(_) => Err(format!("'{s}' ليس رقم منفذ صالحًا")),
}
}
Result<T, E> = Option<T> مع سبب للغياب بدل None فارغة. الفشل ليس قفزة تحكّم خفية (كالاستثناءات) ولا رمزًا سحريًا (كـ -1) — إنه قيمة عادية بنوع صريح في التوقيع، ينتقل صعودًا مثل أي قيمة أخرى، ولا يمكن تجاهله بصمت (تجاهل Result بلا معالجة يصدر تحذير ترجمة — الأداة نفسها تُذكّرك).
عامل ? — بعد أن تكتبه يدويًا
قبل أن تعرف السكّر النحوي، اكتبه يدويًا. دالة تستدعي عدة عمليات قد تفشل:
fn read_port_from_config(cfg: &str) -> Result<u16, String> {
let raw = match extract_field(cfg, "port") {
Ok(v) => v,
Err(e) => return Err(e),
};
let port = match parse_port(&raw) {
Ok(p) => p,
Err(e) => return Err(e),
};
Ok(port)
}
النمط match { Ok(v) => v, Err(e) => return Err(e) } يتكرر عند كل خطوة قد تفشل — هذا بالضبط ما كنت تكتبه يدويًا في C بعد كل استدعاء: if (!x) goto fail;. Rust تختصر النمط الحرفي بعلامة واحدة:
fn read_port_from_config(cfg: &str) -> Result<u16, String> {
let raw = extract_field(cfg, "port")?;
let port = parse_port(&raw)?;
Ok(port)
}
expr? تعني حرفيًا الكتلة اليدوية أعلاه: استخرج القيمة من Ok، أو ارجع فورًا بـErr كما هي (مع تحويل ضمني عبر From إن اختلف نوع الخطأ — تفصيل تلتقيه حين تحتاجه). لا سحر — سكّر نحوي شفّاف لنمط كتبتَه بيدك للتو.
if let — مطابقة فرع واحد
حين يهمّك فرع واحد فقط من enum، match كاملة إسهاب:
if let Some(i) = find(&v, 9) {
println!("found at {i}");
}
// سكّر لِـ: match find(&v, 9) { Some(i) => {...}, _ => () }
تذكّر الإقليم ٠٠: if تعبير، والقيمة الافتراضية لغيابها (). if let امتداد طبيعي لنفس الفكرة — مطابقة نمط واحدة بصياغة if.
الألغاز
لغز ٠٣-أ: شبكة الأمان تعمل. اكتب enum Shape { Circle(f64), Rectangle(f64, f64) } ودالة fn area(s: &Shape) -> f64 بـ match. تأكد أنها تُترجَم وتعمل. الآن: أضف متغيّرًا ثالثًا Triangle(f64, f64, f64) (ثلاثة أضلاع — استعمل صيغة Heron للمساحة). لا تُصلح شيئًا يدويًا قبل أن تُشغّل cargo check وتقرأ كل رسالة خطأ تظهر. اجعل القائمة التي يعطيك إياها المترجم هي خطة عملك بالحرف. حين تنتهي: اكتب سطرًا يقارن هذا بما كان سيحدث لو كان Shape بنية C فيها enum Tag وswitch منتشرة في الملف — كم موضعًا كان سينسى نفسه؟
لغز ٠٣-ب: الشجرة العودية. ابنِ نوعًا يمثّل تعبيرًا حسابيًا بسيطًا (Expr) يدعم: عددًا ثابتًا، وجمع تعبيرين، وضرب تعبيرين. جرّب أول تعريف ساذج:
enum Expr {
Num(f64),
Add(Expr, Expr),
Mul(Expr, Expr),
}
اقرأ رسالة الخطأ كاملة — بما فيها سطر help:. المترجم يقترح عليك حلًا بالاسم. قبل أن تطبّقه، أجب: لماذا بالضبط حجم Expr "لانهائي" منطقيًا لو تُرك التعريف كما هو؟ (فكّر: لحساب حجم Add(Expr, Expr) يحتاج المترجم حجم Expr... الذي يحتاج حساب حجم Add(Expr, Expr)...). ثم طبّق اقتراح الرسالة، واكتب fn eval(e: &Expr) -> f64 تحسب القيمة بـmatch عودي. (المؤشر المُقترَح — Box<T> — مجرد "مؤشر مملوك لقيمة على الـheap بحجم معروف دائمًا (مؤشر واحد)"؛ هذا كل ما تحتاجه الآن. تشريحه الكامل ينتظرك في الإقليم ٧.)
لغز ٠٣-ج: مُحلِّل الإعدادات الصارم. اكتب دالة fn parse_config(lines: &[&str]) -> Result<Config, ConfigError> حيث Config { host: String, port: u16 } وConfigError enum بمتغيّرين على الأقل (مثلًا MissingField(String) وInvalidPort(String)). كل سطر مدخل بصيغة key=value. القيود: ممنوع unwrap() أو expect() في كل دالة تكتبها لهذا اللغز — استعمل ? والمطابقة فقط؛ ودالة main وحدها يحق لها استدعاء unwrap/match نهائي لطباعة رسالة خطأ بشرية. اختبرها بمدخل ناقص الحقل وبمدخل بمنفذ غير رقمي، وتأكد أن رسالة الخطأ تحدد أيّ مشكلة بالضبط.
الخلاصة
- العقدة الجديدة:
enum= اتحاد موسوم حقيقي يفرضه المترجم، ومنه اشتُقّOption<T>(لاnullبعد اليوم) وResult<T, E>(الخطأ قيمة لا قفزة خفية)، ومطابقتهما بـmatch/if let/?. - الوصلة للخلف: التاغ+الحمولة الذي صمّمتَه على الورق في اللغز التأسيسي هو نفسه الذي يبنيه المترجم فعليًا. وexhaustiveness امتداد مباشر لما بنيتَه في منهج TypeScript — بفارق أن التاغ هنا حيّ في الذاكرة، لا يُمحى.
- الوصلة للأمام: لاحظ أن
area()في ٠٣-أ تعمل حصرًا علىShape. لو أردتَ دالة تعمل على أي نوع له مساحة — دائرة، أو نوعًا تخترعه بعد سنة — دون نسخ الدالة يدويًا لكل نوع، ماذا تحتاج؟
TS تحلّ هذا بـ generics، ثم تمحوها بالكامل وقت التشغيل. Rust ليس عندها وقت تشغيل تمحو فيه شيئًا — لا جامع قمامة يمشي في الذاكرة، لا مفسّر يقرأ الأنواع. فكيف تكتب كودًا عامًّا في لغة لا تملك رفاهية النسيان وقت التشغيل؟ الإقليم القادم هو الجواب، وهو أغرب "لا" ستسمعها من مترجم: "أنا لا أمحو شيئًا — أنا أُكثِّرك."