Goose: لغة أنظمة تستغني عن الـ Heap تمامًا
Goose لغة أنظمة آمنة للذاكرة بلا heap ولا GC ولا تعليقات أعمار. ماذا يكشف تصميمها القائم على المكدس عن Rust والمناطق؟

هذا ادعاء سيُزعج مجتمعَين برمجيَّين على الأقل: مدقق الاستعارة (borrow checker) كان انحرافًا عن الطريق. ليس خطأ، فنموذج الملكية في Rust حلّ مشكلات حقيقية وسحب الصناعة كلها إلى الأمام، لكنه كان التفافًا حول فكرة أقدم وأبسط نعيد اكتشافها الآن. هذه الفكرة هي المناطق (regions): ذاكرة تُخصَّص في مناطق كبيرة شبيهة بالمكدس، وتُحرَّر دفعة واحدة عند انتهاء النطاق. لغة Goose، وهي لغة أنظمة جديدة تنتشر هذا الأسبوع، هي أكثر رهان حديث جريء على هذه الفكرة رأيته، وتصميمها يفرض سؤالًا مزعجًا: كم من الأشياء التي نتحملها في C++ وRust ضرورية فعلًا، وكم منها مجرد ضجيج يصدر عن الـ heap؟
Goose لا تملك مخصِّص heap. لا جامع قمامة، ولا عدّ مراجع، ولا destructors، ولا تعليقات أعمار، ولا unsafe. كل قيمة ديناميكية تعيش داخل الكتلة نفسها على واحد من عدد قليل من مكدسات البيانات يحدده المترجم بشكل ثابت. التوسع يعني تحريك مؤشر. تحرير بنية متداخلة من مليون عنصر هو كتابة واحدة إلى قمة المكدس. يذكر المؤلف أن الاختبارات أسرع من C++ الاعتيادية (3.3 مرة) ومن Rust الآمنة (1.12 مرة) مع ذاكرة أقل. ورد فعل John Carmack العلني التقط الجزء الأكثر إثارة للاهتمام عندي: المراجع النسبية التي تجعل بنية البيانات ملف تنسيق خاص بها. سواء وصلت Goose يومًا إلى برمجيات إنتاجية أم لا، فهي تجربة فكرية جدية عن شكل لغة الأنظمة حين تحذف الـ heap ولا تعتذر عن ذلك.
نموذج الذاكرة هو اللغة كلها
معظم ميزات اللغة قابلة للتفاوض. الصياغة، وأنظمة الوحدات، وحتى معالجة الأخطاء: يمكنك أن تتخيل لغتين معقولتين تختاران عكس بعضهما. نماذج الذاكرة ليست كذلك. اختر واحدًا فتُفرض عليك كل الأشياء اللاحقة: بنى بياناتك، وقصة التزامن لديك، وواجهة FFI، ورسائل الأخطاء. Goose تدرك ذلك وتتجه إليه بكامل ثقلها.
القاعدة بسيطة في صياغتها: للبرنامج مكدس الاستدعاء الأصلي، وبيانات ثابتة، وN من مكدسات البيانات، ويحسب المترجم قيمة N. يوجد على الأكثر قيمة قابلة لتغيير الحجم حية في كل مكدس، وهي دائمًا في الأعلى. هذه الثابتة الواحدة تحقق قدرًا مذهلًا. لأن القيمة القابلة للنمو لا تتحرك أبدًا، إذ لا يوجد شيء فوقها يحتاج إلى إعادة توضع، تبقى المراجع إلى المصفوفة النامية صالحة إلى الأبد. في C++، يُبطل std::vector<T> الذي يعيد التخصيص كل مؤشر ومكرر يشير إليه، ولهذا تعرّض أجيال من مبرمجي C++ للعضّ بسبب الاحتفاظ بمرجع عبر push_back. تتجنب Rust الآمنة هذا بجعله غير قابل للتعبير؛ عليك عندها استخدام فهارس من نوع u32، وتدفع الثمن في فحوص الحدود وفي الراحة عند الاستخدام. أما Goose فتتيح لك الاحتفاظ بالمرجع ببساطة. قواعد النوع والعمر تُفحص وقت الترجمة، ويستنتج المترجم ما يُعد كل مرجع «مرتبطًا» به، ويرفض المراجع التي قد تعيش أطول من مالكيها. لكن دون تعليقات، ودون قواعد تعدد أسماء، ودون حصرية.
struct Item { id: i32, weight: f32 }
var items: Item[>..] = [];
let first .= items.push(Item { 1, 0.5 });
for i: i32 in 2..1000001 {
items.push(Item { i, 0.0 });
}
first.weight = 99.5; // still valid, a million pushes later
إذا قضيت وقتًا مع مدقق الاستعارة في Rust، فهذا يبدو كخدعة سحرية. أين ذهبت تعليقات الأعمار؟ الإجابة الصادقة: ذهبت إلى قيد. نظام الملكية في Rust يستطيع إثبات الأمان لأنماط مشاركة عشوائية، مثل الرسوم البيانية والدورات والبنى القابلة للتغيير المشتركة عبر النطاقات. أما Goose فتثبت الأمان لمجموعة أضيق بكثير من الأنماط وترفض الباقي ببساطة. هذه المقايضة، أي التعبيرية مقابل البساطة، هي أقدم مقايضة في تصميم اللغات، وهي الخيط الذي سأواصل سحبه هنا. إذا قرأت مقالنا عن ما الذي تعنيه الصحة المنطقية (soundness) فعليًا في مدقق الاستعارة في Rust، فإن Goose هي الوجه الآخر لتلك العملة: الصحة المنطقية هنا لم تأتِ من تحليل أقوى، بل من لغة أصغر.
كل شيء مضمّن: تخطيط البيانات كميزة من الدرجة الأولى
الفكرة الكبرى الثانية أنه لا يوجد نوع مصفوفة واحد. تملك Goose عائلة من أنواع المصفوفات تختلف فقط في ما يحدث لحجمها: ثابتة وقت الترجمة، أو مجمّدة بعد الإنشاء، أو قابلة للنمو داخل سعة مضمنة، أو تنمو فقط، أو تنمو وتتقلص. كلها على شكل [metadata][elements...]، أي متجاورة ومرصوصة، ولا تشير أبدًا إلى كتلة عناصر. السلاسل النصية مجرد مصفوفات u8، لذا يمكن للبنية أن تحتوي أجزاء متغيرة الحجم موضوعة داخلها بترتيب التصريح. سجل فيه حقل نص متغير الطول وقائمة متغيرة الطول من السجلات الفرعية هو سلسلة واحدة من البايتات دون أي إعادة توجيه.
الأرقام المعلنة لافتة للنظر: سجل طلب حجمه 29 بايتًا وصفر تخصيصات في Goose، مقابل 160 بايتًا وتخصيص واحد باستخدام std::string + std::vector<Item> في C++، أو 153 بايتًا وأربعة تخصيصات في Rust. لكن تصحيحًا منصفًا ظهر في النقاش تقريبًا فورًا: مثال المؤلف في الدليل التعليمي عن «متجه من السلاسل» يتجاهل تحسين السلاسل القصيرة (SSO)، وهو موجود في كل تطبيق STL رئيسي. السلاسل القصيرة في C++ لا تُخصَّص. هذا لا يُسقط الحجة، فالـ SSO يفيد السلاسل التي تقل عن نحو 15 إلى 22 بايتًا ولا يفعل شيئًا للحالة العامة للبيانات المتداخلة متغيرة الحجم، لكنه تذكير بأن جداول المقارنة في الصفحة الرئيسية للغة تسويق، حتى حين تكون اللغة جيدة. الخسائر موجودة أيضًا، وهذا يُحسب للمؤلف، وهو أكثر مما تنشره معظم المشاريع.
هناك كلفة حقيقية، وGoose لا تخفيها: المصفوفة ذات العناصر متغيرة الحجم تسلسلية. يمكنك التكرار عليها، لكن لا يمكنك الفهرسة مباشرة، فموقع العنصر k هو حيث تقع البايتات. إذا أردت وصولًا عشوائيًا، تحتفظ بمصفوفة ثانية من المراجع كفهرس. هذا النوع من المقايضات قام به من يعتمدون التصميم الموجّه بالبيانات يدويًا لعقود (وهو في الأساس طريقة عمل الصيغ العمودية وتخطيطات على شاكلة FlatBuffers)، لكنه هنا هو الافتراضي، والخروج عنه يحتاج عملًا إضافيًا. هل هذا ميزة أم سترة مقيدة؟ يعتمد الأمر كليًا على أنماط الوصول لديك.
المراجع النسبية: بنية بياناتك هي صيغة ملفها
فكرتي المفضلة في Goose هي المرجع النسبي، وهي الفكرة التي ركز عليها Carmack أيضًا. المرجع العادي عنوان آلي: ثمانية بايتات، مطلق، وعديم المعنى خارج العملية. المرجع النسبي يخزن الرابط نفسه كإزاحة من 1 أو 2 أو 4 بايت، تُقاس إما من الحقل نفسه أو من قاعدة مجمّع مسمّى. البنى المبنية من روابط نسبية للذات مستقلة عن الموضع، فهي تعني الشيء نفسه أينما وُضعت في الذاكرة. وهذا يعني أن حفظ بنية بيانات هو write()، وتحميلها هو read() مع مرحلة تحقق.
الجملة الأخيرة أهم مما تبدو. النسخة الساذجة من هذه الحيلة، أي تعيين ملف يحوي مؤشرات إلى الذاكرة، كارثة أمنية كلاسيكية. نسخة Goose تتحقق: from_bytes تفحص الإطار وكل وسم وكل طول وكل رابط قبل أن تصبح البايتات قيمة، فيعيد الملف التالف أو الخبيث false لا مؤشرًا عشوائيًا. تحصل على سرعة البنى المعينة عبر mmap مع شيء من أمان المحلل النحوي (parser).
كان إطار Carmack دقيقًا تمامًا: «"تحليل" بيانات الملفات والشبكة غالبًا أبطأ بمئة مرة مما يحتاجه». أي شخص قاس أداء مسار فك التسلسل يعرف ذلك. إجابة الصناعة حتى الآن كانت صيغ الوصول الصفري المعتمدة على مخطط (schema)، مثل Cap'n Proto وFlatBuffers، وهي تعمل لكنها تضيف مترجم مخططات ومكتبة تشغيل إلى مشروعك. إجابة Goose أن تخطيط الذاكرة وتخطيط السلك هما الشيء نفسه بالبناء، لأن اللغة لم تملك أصلًا مؤشرات إلى عناوين مطلقة. مثال شجرة البحث الثنائية جريء إلى حد الطرافة: عقد بحجم 12 بايتًا مع الروابط، والإدراج عبارة عن push وstore واحد، والشجرة كاملة تُحفظ بكتابة بايتاتها. من يعرف Lisp سيتعرف على الحركة الفلسفية، أي طي تمييز كان الجميع يفترضه أساسيًا، وهذا جزء من سبب بقاء Lisp مهمًا كنقطة مرجعية في التصميم بعد ستين عامًا.
قد مررنا بهذا من قبل: المناطق والساحات وML Kit
الرد المطّلع تاريخيًا على Goose ظهر في الوقت المناسب في التعليقات: «أظن أن رجال ML سلكوا هذا الطريق من قبل. أعتقد أنهم سمّوه المناطق (regions)». صحيح. إدارة الذاكرة بالمناطق تعود إلى أعمال ML Kit في التسعينيات. استنتاج المناطق لدى Tofte وTalpin سمح لمترجم ML باستبدال جمع القمامة بمناطق شبيهة بالمكدس تُحدَّد ثابتًا، فتُخصَّص المناطق كاملة وتُحرَّر دون أي تتبع لكل كائن. والحكم التاريخي الصادق أنها أُعلنت نوعًا من الطريق المسدود: استنتاج المناطق كان يعمل، لكن البرامج الحقيقية أظهرت نموًا مرضيًا كافيًا للذاكرة، مناطق تُبقي بيانات ميتة حية لفترة طويلة جدًا، فانصرف مجتمع البحث إلى حد كبير. وبقي جامعو القمامة على عرشهم.
لكن الفكرة لم تمت فعلًا؛ بل غيّرت وظيفتها فقط. تخصيص الساحات ممارسة معيارية في محركات الألعاب والمترجمات والخوادم عالية الأداء. تجمعات الذاكرة في Apache، والساحات الخاصة بكل طلب في خوادم الويب، ونمط «تفريغ المخزن المؤقت في كل إطار» في حلقات الألعاب، كلها مناطق بأسماء أخرى، تُدار يدويًا لأن اللغة لا تساعدك. ما يفعله Goose بشكل مختلف عن ML Kit يستحق أن يُقال بدقة: بدلًا من استنتاج المناطق للغة وظيفية عامة الغرض (وهذا صعب وغير متوقع)، تعرض Goose نموذج المكدس مباشرة وتجعل نية المبرمج جزءًا من النوع. T[>..] تعني «هذا ينمو، والمراجع تبقى صالحة». مهمة المترجم هي فحص انضباط معلن، لا اكتشافه. هذه مشكلة أسهل بكثير، وهي السبب نفسه الذي جعل Rust تنجح حيث فشلت مخططات الأعمار التلقائية السابقة: المبرمجون يتقبلون عقدًا يرونه بوضوح.
هناك درس أوسع عن كيفية امتصاص اللغات الإنتاجية للبحث العلمي. استنتاج المناطق كبديل تلقائي كامل لجمع القمامة: فشل. المناطق كنمط يدوي في أنظمة البرمجيات: منتشرة في كل مكان. فحص الاستعارة: نجاح كبير يستعير بكثافة من أفكار المناطق والأنواع الخطية. العمليات على طريقة Erlang التي لا تشارك شيئًا: ثبت صحتها، وخيوط Goose هي حرفيًا ذلك، فهي عمال مترجمون كبرامج منفصلة لكل منهم ذاكرته، والقيم تعبر طوابير مكتوبة الأنواع كعملية memcpy، وسباقات البيانات غير قابلة للتمثيل أصلًا. لا أحد يصيب الصورة كاملة من المرة الأولى. تتداول الأفكار لعقود قبل أن تستقر في شكل يعمل.
أين يتعثر النموذج
قلت إنني سأبدأ بادعاء مخالف ثم أتراجع عنه حيث ينكسر. وهذا هو التراجع. مدقق الاستعارة ليس انحرافًا إن كانت أنماط المشاركة في برنامجك عشوائية حقًا، ووفئة هائلة من البرامج لها هذا الشكل بالضبط. أحد أحدّ الأسئلة في نقاش Goose كان أيضًا أبسطها: كيف تبني جدول تجزئة يربط المفاتيح بالسلاسل النصية، حيث يُدرج البرنامج سلاسل عشوائية ويحذفها في أوقات عشوائية؟ الحذف هو القاتل. المكدس الذي ينمو فقط لا يستطيع استعادة فجوة في الوسط. الحلول المقترحة، مثل مكدس لكل فئة حجم، أو ضغط دوري، أو تجمع مع قائمة حرة حيث «التحرير» يعني فقط أن الخانة قد تُعاد استخدامها، كلها تعمل، وتصميم التجمع ذكي فعلًا (فالخانة القديمة تقرأ قيمة مختلفة من النوع الصحيح بدلًا من إفساد الذاكرة). لكن كل حل منها هو المبرمج يعيد بناء جزء من مدير ذاكرة، يدويًا، لأجل بنية البيانات هذه.
وبالمثل: استهلاك الذاكرة. بدون جامع قمامة، يحتفظ المكدس الذي ينمو بكل ما نما ليحمله حتى انتهاء النطاق. البرامج طويلة التشغيل ذات الأعمار غير المنتظمة، مثل سؤال وثيقة JSON الذي طُرح في الخيط، والذاكرات المؤقتة، وحالة الجلسات، إما أن تناسب النمط المنضبط بالنطاقات أو لا، وعندما لا تناسبه لا تنقذك أي أناقة. فشل ML Kit (بيانات ميتة مثبتة داخل مناطق حية) لا يختفي في Goose؛ بل يصبح مرئيًا ويدويًا، وهذا أفضل على الأرجح، لكنه ما زال موجودًا. علّق أحدهم بصياغة الموقف الصحيح: Goose لغة لغرض مقيّد، وهذا لا بأس به. أحمال العمل الموجهة بالبيانات، والمترجمات، وأنظمة كيانات الألعاب، وكل ما هو على شكل خط أنابيب (قراءة ثم تحويل ثم إخراج ثم إعادة ضبط) يناسبها تمامًا. أما رسم كائنات قابل للتغيير وطويل العمر فلا.
وتحفظ أخيرة على الأرقام الرئيسية. مجموعات الاختبار المقاسة ذاتيًا نوع له أنماط فشل معروفة: أنت تقارن أفضل ما في Goose بـ«الاعتيادي» في C++ لشخص آخر، والاعتيادات هي المكان الذي تبدأ فيه المعارك. زلّة SSO تُظهر أن كود المقارنة لم يُضبط بعداء. رقم 1.12 مقابل Rust الآمنة أكثر إثارة للاهتمام من رقم 3.3، تحديدًا لأنه صغير، فهو يقول إن نموذج الذاكرة يكسب في التخطيط وإلغاء التخصيصات، لا بسحر، ونسخة Rust مضبوطة تستخدم الساحات والفهارس كانت ستغلق جزءًا كبيرًا من الفجوة. وهذا بحد ذاته هو المقصود: يمكنك كتابة Rust قائمة على الساحات، لكن اللغة تجعل ذلك مرهقًا، والمرهق هو ما يحدد الكود الذي يُكتب فعلًا.

ما الذي ستسرقه اللغات الإنتاجية فعلًا من Goose
تقريبًا لن يكتب أحد كودًا إنتاجيًا بـGoose في السنوات القليلة القادمة. اللغات التي يطورها شخص واحد وتُترجم عبر خلفية C واحدة لا تصل إلى ذلك بسرعة، والمؤلف نفسه يقول ذلك بخصوص وضعها. هذا ليس السؤال المثير. السؤال المثير هو أي الأفكار ستتسرب إلى اللغات التي نستخدمها فعلًا، فهكذا يعمل الأمر دائمًا. توقعاتي مرتبة حسب درجة الثقة:
- بنى مستقلة عن الموضع مع تحميل مُتحقَّق منه. الترميز الصفري النسخ مع مرحلة أمان أكبر من أن يبقى متخصصًا. توقع نسخًا مدمجة في اللغات لما تفعله FlatBuffers بشكل مرتبك، خاصة مع استمرار الخدمات كثيفة البيانات في مواجهة تكاليف التحليل.
- المصفوفات كعائلة لا كنوع واحد. «ماذا يحدث لحجمها؟» سؤال تنظيمي أفضل من «مكدس أم heap؟». الثابتة، والمجمدة، والمحدودة السعة، وذات النمو فقط، هذه الفروق موجودة في كل قاعدة كود كأعراف غير رسمية. جعلها أنواعًا أمر رخيص ويلتقط أخطاء حقيقية.
- قيم الدوال المعروفة وقت الترجمة. تعامل Goose قيم الدوال ككيانات وقت الترجمة، لذا فإن
xs.filter() { it > 0 }يُترجم إلى الحلقة التي يبدو عليها. قوالب C++ وتعدد أشكال Rust بالتوحيد (monomorphization) تفعل هذا أصلًا، والفرق أن Goose تجعله الطريقة الوحيدة، فتزيل فخ مؤشرات الدوال وقت التشغيل تمامًا. - عودة متعددة الإطارات مفحوصة إحصائيًا.
return err from loadتعود من دالة أعلى بـN إطار، مفحوصة وقت الترجمة، دون فك للمكدس ودون هرم منResultودون?في كل استدعاء. أنظمة التأثيرات (effect systems) تدور حول الفكرة نفسها من الاتجاه الآخر، وواحدة منها ستصل إلى لغة سائدة في هذا العقد.
يستحق نموذج الخيوط القائم على عدم المشاركة ذكرًا أيضًا، ربما لأنه الأقل جدة والأكثر تأكيدًا: إنه عمليات Erlang، وisolates في Dart، ونموذج الممثلين (actor model) دون الإيديولوجيا. أن يُعاد اختراعه باستمرار يخبرك أن إجماع الصناعة حول الحالة القابلة للتغيير المشتركة محسوم نظريًا ومُتجاهَل عمليًا. Goose على الأقل تجعل تجاهله مستحيلًا.
الحجة الحقيقية: البساطة عبر التقييد
تراجع خطوة وستجد أن تصميم Goose حركة واحدة متكررة: قلّص مجموعة البرامج القابلة للتمثيل حتى يصبح إثبات الأمان رخيصًا. لا heap يعني لا مؤشرات heap معلقة. المكدسات النامية فقط تعني أن المراجع لا يمكن إبطالها. الخيوط المنفصلة للعمال تعني أن السباقات لا يمكن أن توجد. البناء دون نسخ يعني لا مؤقتات عرضية. كل قيد يحذف فئة كاملة من الأخطاء وفئة كاملة من آلات اللغة، مثل قواعد التعدد في مدقق الاستعارة، والـ destructor، وفك المكدس، وقد وُجدت كلها لإدارة تلك الأخطاء.
هذه حجة «الأقل أسيّ أكثر» لـ Rob Pike بلا قفازات، وتصل إلى التوتر نفسه الذي وصلت إليه Go من Pike: القيود تبدو تحررًا إلى أن يكون برنامجك هو المستبعَد بالقيد، فتبدو حائطًا. استبعدت Go الأنواع العامة (generics) عقدًا كاملًا ونجت، واستبعدت أيضًا كثيرًا من البرامج الصحيحة. Goose تستبعد أكثر بكثير. سبب الانتباه إليها مع ذلك أن قواعد Pike القديمة عن البساطة شاخت بشكل جيد تحديدًا لأن ميزانيات التعقيد حقيقية، فكل ضمان تقدمه اللغة تلقائيًا هو ضمان لا يحتاج كل مبرمج يستخدمها إلى الاحتفاظ به في ذهنه.
إذن: هل كان مدقق الاستعارة انحرافًا؟ لبرمجة الأنظمة ذات الغرض العام، لا. تعبيرية Rust ضرورية فعلًا لحالات المشاركة العشوائية، ونجاحها منح الصناعة مفردات للحديث عن الملكية أصلًا. لكن بالنسبة للأغلبية الكبيرة الهادئة من كود الأنظمة ذات الشكل الخطّي، والشكل القائم على الساحات، والشكل القائم على الطلبات؟ Goose دليل على أننا كان يمكن أن نحصل على أمان الذاكرة والسرعة والملفات الثنائية الصغيرة بجزء بسيط من الطقوس، لو كنا على استعداد لقول «لا» لعدد أكبر من البرامج. تصميم اللغات هو فن اختيار البرامج التي تُحظر. Goose تحظر برامج مختلفة عما اعتدناه، والنتيجة أنها أكثر مخطط لغة أنظمة مثيرًا للاهتمام قرأته منذ سنوات. لا أتوقع أن أشحنها. أتوقع أن أستمر في السرقة منها.
رهان Goose أن لغة يمكنها أن تحظر برامج أكثر مما تحظره Rust وتظل مفيدة، والأرقام تشير إلى أن ذلك يستحق الجدية، مع خسائرها.


