كل شيء بنيتَه منذ الإقليم ٠٠ — Vec, String, Box, Rc, Mutex — يعيش خلف واجهة آمنة تمنع الكوارث الثلاث التي افتتحنا بها المنهج. لكن شخصًا ما كتب Vec نفسها. وذلك الشخص، في مكان ما داخل المكتبة القياسية، اضطر لكتابة كود لا يخضع للفحص الكامل — لأن بعض الحقائق البرمجية الصحيحة يعجز نظام الأنواع عن إثباتها، مهما كان ذكيًا. هذا الإقليم يهبط بك إلى ذلك المكان، ويطلب منك أن تكتب Vec بنفسك.
الدرس ١: unsafe لا تعني ما يظنّه أغلب الناس
الخرافة التي نصححها فورًا
توقّع شائع خاطئ: "unsafe تُطفئ borrow checker". جرّب هذا الآن، قبل أي شرح إضافي:
fn weird() {
let s = String::from("hi");
let s2 = s;
unsafe {
println!("{s}"); // توقع: هل يمر أم يُرفَض؟
}
}
يُرفَض — بنفس رسالة "قيمة منقولة" من الإقليم ٠١، بلا أي تغيير. unsafe لم تلمس فحص النقل، ولا الاستعارة، ولا الأنواع، ولا الـ exhaustiveness. كل ما بنيتَه من الإقليم ٠٠ حتى ٠٨ يبقى فعّالًا بالكامل داخل كتلة unsafe. الكلمة لا تفتح إلا خمس قدرات محدَّدة بدقة، لا أكثر:
| القدرة | ما تفتحه |
|---|---|
| ١ | إلغاء إشارة مؤشر خام (*const T / *mut T) |
| ٢ | نداء دالة أو طريقة unsafe fn |
| ٣ | قراءة/تعديل متغيّر static mut |
| ٤ | تطبيق trait معلَّم بـ unsafe |
| ٥ | الوصول لحقول union |
هذا كل شيء. لا سادس. أي كود آخر داخل الكتلة يبقى صريحًا خاضعًا للفحص الكامل. unsafe ليست "أطفئ الأمان" — إنها "انقل عبء الإثبات من المترجم إليّ، لهذه العملية المحدَّدة بالذات فقط."
لماذا يحتاج نظام سليم (sound) أصلًا لبوّابة كهذه؟
نظام الأنواع في Rust سليم لكن غير مكتمل (sound but not complete): كل ما يقبله المترجم آمن فعلًا (السلامة)، لكن ليس كل ما هو آمن فعلًا يقبله المترجم (النقص) — لأن المترجم يحلّل بقواعد محافِظة عامة، بينما أنت أحيانًا تملك معرفة أدقّ لا تستطيع صياغتها كقاعدة عامة. مثال حقيقي، يعيش فعليًا داخل مصدر std:
fn split_at_mut(s: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
(&mut s[..mid], &mut s[mid..]) // جرّبها — سترفضها
}
جرّبها فعلًا. المترجم يرفض: استعارتان حصريتان لـ s في آنٍ واحد — خرق ظاهري للبديهية الثانية. لكن أنت تعرف يقينًا أن s[..mid] وs[mid..] لا تتقاطعان في الذاكرة إطلاقًا — بديهي من تعريف التقسيم عند mid. المترجم لا "يرى" هذا؛ تحليله يعمل على مستوى الاستعارات النحوية (اسم واحد s، استعارتان منه) لا على مستوى القيم العددية الفعلية للفهارس. هذه بالضبط الفجوة التي unsafe تسدّها: تكتب الدالة بمؤشرات خام، تُثبت لنفسك (وللقارئ، عبر تعليق) أن التقاطع مستحيل رياضيًا، والمترجم يثق بك لهذا الموضع المحدود وحده.
اصطلاح مهني لا تتجاوزه: كل كتلة unsafe تستحق تعليق // SAFETY: فوقها يشرح لماذا الفعل آمن فعليًا رغم أن المترجم لا يستطيع إثباته — موجَّه للقارئ البشري (وأحيانًا لك أنت بعد ستة أشهر). هذا ليس شكليات؛ clippy نفسه يفرضه في إعدادات صارمة.
الدرس ٢: المؤشرات الخام — C، لكن بأسماء تصرخ "احذر"
let x = 5;
let r1 = &x as *const i32; // إنشاء مؤشر خام: آمن! لا unsafe هنا
let mut y = 10;
let r2 = &mut y as *mut i32; // آمن أيضًا
unsafe {
println!("{}", *r1); // الإلغاء فقط يحتاج unsafe
*r2 += 1;
}
لاحظ التقسيم المتعمَّد: إنشاء مؤشر خام عملية آمنة (لا تفعل شيئًا خطرًا بعد — مجرد عنوان)؛ إلغاء إشارته هو اللحظة الفعلية التي قد تقرأ ذاكرة غير صالحة، فهي وحدها المحصورة خلف unsafe. هذا تصميم دقيق: الخطر محدَّد بأصغر نقطة ممكنة، لا مُعمَّم على كل ما "يلمس" المؤشر.
الفروقات عن &T/&mut T التي عشتَ بها منذ الإقليم ٠٢:
&T / &mut T | *const T / *mut T | |
|---|---|---|
| قد تكون فارغة (null)؟ | لا، أبدًا | نعم |
| قد تتدلّى (dangling)؟ | لا — المترجم يمنع هذا | نعم — مسؤوليتك بالكامل |
| قواعد الإعارة (البديهية ٢) | مفروضة دائمًا | غير مفروضة إطلاقًا — يمكن وجود عدة *mut T لنفس الموضع بلا شكوى |
عمر ('a) متتبَّع | نعم | لا |
لاحظ: السماح بعدة *mut T لنفس الموضع ليس خرقًا للبديهية الثانية — لأن unsafe هي حرفيًا الإعلان: "من الآن، أنا من يحرس هذه البديهية يدويًا لهذه المنطقة، لا المترجم." نفس الانضباط الذي كان في رأسك طوال كتابة C — عاد الآن، لكنه محصور ومُسمّى ومكتوب صراحة، لا افتراضًا صامتًا يخيّم على كل سطر كما كان الحال هناك.
std::ptr::NonNull<T>: غلاف حول *mut T يَعِد بأنه لن يكون أبدًا null — تستعمله المكتبة القياسية داخليًا في Box, Vec, Rc لأن أغلب الكود بعد الإنشاء يفترض هذا أصلًا، فلمَ لا يُصرَّح به في النوع؟ ستستعمله بنفسك بعد قليل.
الدرس ٣: تحت Vec — المخصِّص (allocator) اليدوي
لبناء Vec بنفسك تحتاج ثلاث أدوات من std::alloc، وهي نفس ما تفعله malloc/free/realloc في C، بواجهة مطابقة للنوع:
use std::alloc::{alloc, dealloc, realloc, Layout, handle_alloc_error};
let layout = Layout::array::<i32>(10).unwrap(); // حجم + محاذاة لعشرة i32
let ptr = unsafe { alloc(layout) } as *mut i32; // مطابق لـ malloc
if ptr.is_null() { handle_alloc_error(layout); } // فشل التخصيص — لا رفاهية Result هنا
// ... استعمال ...
unsafe { dealloc(ptr as *mut u8, layout); } // مطابق لـ free
Layout تحمل الحجم والمحاذاة (alignment) معًا — تفصيلة قد لم تحتَجها صراحة في C إن كان malloc يتكفّل بمحاذاة آمنة لكل الأنواع تلقائيًا؛ هنا تحسبها صراحة عبر Layout::array::<T>(n) أو Layout::new::<T>()، ويمكنك فحص std::mem::size_of::<T>() وstd::mem::align_of::<T>() لأي نوع مباشرة.
**لماذا لا تكتب *ptr = value مباشرة لتهيئة خانة جديدة؟** لأن *ptr = value عبر مؤشر Rust (حتى الخام) تعني دلاليًا: "أسقط (drop) ما كان موجودًا في هذا الموضع أولًا، ثم ضع الجديد." لكن الذاكرة القادمة من alloc غير مهيّأة — قراءتها كقيمة T صالحة لإسقاطها UB فورية. الحل: std::ptr:: write(ptr, value) تكتب القيمة بلا محاولة إسقاط ما كان هناك؛ وأختها std::ptr::read(ptr) تقرأ قيمة بلا نقلها منطقيًا (تترك نسخة بتّية خلفها — مسؤوليتك ألا تقرأها مرتين وتُسقطها مرتين، وإلا double-free ذاتُه من الإقليم ٠١، لكنك الآن من يرتكبه لا نظام مؤتمَت).
للإزاحة داخل مساحة مخصَّصة (بدل حساب عنوان يدويًا كما في C): ptr.add(i) — مكافئ ptr + i في C، لكن باسم يفرض عليك التفكير: "أهذا ضمن الحدود المخصَّصة فعليًا؟" — السؤال الذي unsafe يذكّرك به في كل نداء.
الألغاز التمهيدية
لغز ٠٩-أ: المشي الخام. أنشئ Vec<i32> عاديًا آمنًا، احصل على مؤشر خام إليه عبر .as_ptr()، واكتب دالة unsafe fn sum_raw(ptr: *const i32, len: usize) -> i64 تجمع كل العناصر حصرًا عبر ptr.add(i) وإلغاء إشارة يدوي — بلا فهرسة []، بلا .iter(). تعامل بشكل صحيح مع len == 0 (يجب ألا تُلغى أي إشارة إطلاقًا في هذه الحالة — لماذا بالضبط لا يجوز حتى إنشاء ptr.add(0) مؤشرًا "لعنصر" حين لا عناصر أصلًا؟ فكّر بحدود المصفوفة الفعلية).
لغز ٠٩-ب: التقسيم المستحيل. أعد بناء split_at_mut بنفسك من الصفر (لا تستعمل نسخة std)، باستعمال slice.as_mut_ptr() وptr.add(mid) وstd::slice::from_raw_parts_mut. اكتب فوق كتلة unsafe الخاصة بك تعليق // SAFETY: يشرح — بجملة دقيقة — لماذا النصفان لا يتقاطعان أبدًا. اختبرها: عدّل كل نصف من خارج الدالة (بعد استدعائها) وتأكد أن التعديلات مستقلة كما تتوقع.
المعلم الأكبر: MyVec<T> من الصفر
الآن أعد اشتقاق ما استعملتَه بثقة عمياء منذ الإقليم ٠٠: Vec<T> نفسها. هذا المعلم يُغلق الدائرة التي فتحها الإقليم ٠١: "في C كنتَ أنت الـ garbage collector" — هنا، لمرة واحدة مقصودة، تعود لتكون هو، لكن داخل جدار محكم، وبكامل الوعي بكل ما قد ينهار إن أخطأت.
الشكل الأساسي:
struct MyVec<T> {
ptr: std::ptr::NonNull<T>,
len: usize,
cap: usize,
_marker: std::marker::PhantomData<T>,
}
(PhantomData<T> تصريح لا يشغل ذاكرة، يخبر المترجم "تصرّف كأنني أملك T فعليًا" لأغراض فحوصات لا تظهر تلقائيًا من NonNull<T> وحدها — ابحث عنها في وثائق std إن أردت التفاصيل الكاملة؛ يكفيك الآن معرفة أنها موجودة وأن أضعها.)
المتطلبات الوظيفية (لا حلّ مرجعي — هذا معلمك لتبنيه):
MyVec::new()— بلا أي تخصيص فعلي (سعة صفرية). كيف تمثّلptrفي هذه الحالة بلا أن تكون null؟ ابحثNonNull::dangling().push(&mut self, val: T)— إنlen == cap: خصّص (أو أعد تخصيص عبرrealloc) مساحة جديدة ضعف الحالية (أو ١ إن كانت صفرًا)، انسخ العناصر القديمة (أو استعملreallocمباشرة إن كانتcap > 0بدل alloc+copy+dealloc يدويًا — أيّهما تختار وأيهما أوفر؟)، ثم اكتب العنصر الجديد بـptr::writeفي الموضعlen، وزِدlen.pop(&mut self) -> Option<T>— إنlen == 0أعدNone؛ وإلا انقصlenواقرأ العنصر الأخير بـptr::readوأعده كـSome.get(&self, i: usize) -> Option<&T>— فحص حدود، ثم مرجع بعمر مربوط بـ&self(تذكّر الإقليم ٠٥).impl Drop for MyVec<T>— إلزامي: أسقط كل عنصر حيّ (0..len) بـptr::read(أوptr::drop_in_place)، ثمdeallocالمساحة إن كانتcap > 0. انسَ هذا السطر ولن يظهر أي خطأ ترجمة — فقط تسريب ذاكرة صامت في كل استعمال، تمامًا كما في C، وهذه المرة أنت فقط من يحرسه، لا آلية Drop التلقائية التي اعتدتها منذ ٠١ (فهي لا "ترى" داخل تخصيص خام أصلًا).
قيود صارمة: ممنوع استعمال Vec أو Box الحقيقيتين داخل التنفيذ — الهدف أن تلمس alloc/ptr::write/ptr::read بيدك، لا أن تُغلِّف الحل الجاهز. اكتب // SAFETY: فوق كل كتلة unsafe.
اختبار حقيقي (اختياري لكن يستحق التجربة): إن ثبّتَّ Rust nightly، شغّل اختباراتك عبر cargo +nightly miri test — Miri مفسّر يكتشف UB ديناميكيًا (قراءة غير مهيّأة، تسريب، تجاوز حدود) حتى لو "نجح" برنامجك ظاهريًا. هذه هي أداة التحقق الحقيقية التي يستعملها مطوّرو std أنفسهم على كودهم unsafe.
ملاحظة عابرة: union وextern "C"
أقفلنا دائرة فتحها الإقليم ٠٣: تذكّر union C بلا تاغ في مطلعه؟ Rust تمنحك union أيضًا — لكنها استثناء واعٍ الآن: كل قراءة حقل تتطلب unsafe صراحةً (القدرة الخامسة في الجدول)، بعكس افتراض C الصامت. ولأن unsafe تعني أيضًا "تعامل مع الذاكرة كما تفعل C تمامًا"، فهي نفسها الجسر لاستدعاء كود C موجود فعليًا (extern "C" { fn some_c_func(x: i32) -> i32; }) أو تصدير دوال Rust لتُنادى من C — موضوع كامل بذاته (FFI)، خارج عمق هذا المنهج، لكن يستحق أن تعرف أنه موجود ومبني على نفس الأساس الذي بنيتَه للتو.
الخلاصة
- العقدة الجديدة:
unsafe= خمس قدرات محدَّدة بدقة، لا إطفاء عام. المؤشرات الخام (*const/*mut)، والمخصِّص اليدوي (alloc/dealloc/Layout)، وptr::write/ptr::readكأدوات تهيئة/سحب بلا إسقاط زائف. - الوصلة للخلف: كل بديهية بنيتَها منذ ٠١ بقيت فعّالة داخل
unsafe— ما تغيّر هو من يحرس القدرات الخمس: أنت، لا المترجم، لهذا الموضع المحدود بدقة.MyVecالتي بنيتَها هي — حرفيًا في البنية، إن لم يكن في كل تفصيلة تحسين أداء — نفس ما تبنيهstd::Vecداخليًا. - الوصلة للأمام: أنت الآن رأيت اللغة من أعلى قمة (التجريد بلا ثمن، ٠٤) إلى أعمق قاع (المخصِّص الخام، هنا). الإقليم الأخير لا يذهب لأي من الاتجاهين — إنه يبني جانبًا بالكامل: لغة ثانية كاملة فوق Rust، بكلمات مفتاحية لم تكن موجودة في اللغة الأصلية، لحل مشكلة لم تحلّها الخيوط الحقيقية (٠٨) بكفاءة: عشرات الآلاف من الاتصالات المتزامنة على عدد محدود من الأنوية.
كل ما بنيتَه حتى الآن ينفَّذ فورًا حين يُستدعى. آخر مفهوم في هذا المنهج يقلب حتى هذا: قيمة تكتبها الآن، لا تُنفَّذ شيئًا إلا حين يقرر شيء آخر — لم تلتقِه بعد — أن الوقت قد حان لتحريكها خطوة واحدة للأمام. تمهَّل: القاع الذي نزلتَ إليه للتو (المؤشرات الخام) سيظهر مجددًا هناك، من زاوية لم تتوقعها.