مرحبا يا هبر! أقدم إليكم ترجمة مقالة "مخاطر البنائين" للكاتب أليكسي كلادوف.
واحدة من الوظائف المفضلة لبلدي روست هو " الأشياء التي يتم شحنها بواسطة الصدأ" بواسطة Graydon Hoare . بالنسبة لي ، فإن عدم وجود أي ميزة في اللغة يمكن أن تطلق النار في الساق عادة ما يكون أكثر أهمية من التعبيرية. في هذا المقال الفلسفي إلى حد ما ، أريد أن أتحدث عن الميزة المفضلة بشكل خاص التي أفتقدها راست - عن الصانعين.
ما هو المنشئ؟
يشيع استخدام البنائين في لغات OO. مهمة المنشئ هي تهيئة الكائن بالكامل قبل أن يراها بقية العالم. للوهلة الأولى ، تبدو هذه فكرة جيدة حقًا:
- قمت بتعيين الثوابت في المنشئ.
- كل طريقة تعتني بحفظ الثوابت.
- معًا ، تعني هاتان الخاصتان أنه يمكنك التفكير في الكائنات على أنها ثوابت ، وليس كحالات داخلية محددة.
يلعب المنشئ هنا دور قاعدة الحث ، كونه الطريقة الوحيدة لإنشاء كائن جديد.
لسوء الحظ ، هناك فجوة في هذه الحجج: المصمم نفسه يلاحظ الكائن في حالة غير منتهية ، مما يخلق العديد من المشاكل.
هذه القيمة
عندما يقوم المنشئ بتهيئة الكائن ، يبدأ ببعض الحالة الفارغة. لكن كيف تحدد هذه الحالة الفارغة لكائن تعسفي؟
أسهل طريقة للقيام بذلك هي تعيين جميع الحقول على قيمها الافتراضية: false لـ bool ، و 0 للأرقام ، و null لجميع الروابط. لكن هذا النهج يتطلب أن تحتوي جميع الأنواع على قيم افتراضية ، وتقدم القيمة الخالية من السمعة في اللغة. هذا هو المسار الذي سلكه Java: في بداية إنشاء الكائن ، كل الحقول هي 0 أو لاغية.
مع هذا النهج ، سيكون من الصعب للغاية التخلص من لاغية بعد ذلك. مثال جيد للتعلم هو Kotlin. تستخدم Kotlin أنواعًا غير قابلة للإلغاء افتراضيًا ، لكنها مضطرة للعمل مع دلالات JVM الموجودة مسبقًا. يخفي تصميم اللغة هذه الحقيقة جيدًا ويمكن تطبيقه جيدًا في الممارسة العملية ، لكنه لا يمكن الدفاع عنه . بمعنى آخر ، باستخدام المنشئات ، من الممكن تجاوز الشيكات الفارغة في Kotlin.
الميزة الرئيسية في Kotlin هي تشجيع إنشاء ما يسمى "المنشئات الأولية" التي تعلن في وقت واحد عن حقل وتعيين قيمة له قبل تنفيذ أي كود مخصص:
class Person( val firstName: String, val lastName: String ) { ... }
خيار آخر: إذا لم يتم الإعلان عن الحقل في المنشئ ، فيجب على مبرمج التهيئة على الفور:
class Person(val firstName: String, val lastName: String) { val fullName: String = "$firstName $lastName" }
محاولة استخدام حقل قبل التهيئة مرفوض بشكل ثابت:
class Person(val firstName: String, val lastName: String) { val fullName: String init { println(fullName)
ولكن مع القليل من الإبداع ، يمكن لأي شخص الالتفاف على هذه الشيكات. على سبيل المثال ، استدعاء الأسلوب مناسب لهذا:
class A { val x: Any init { observeNull() x = 92 } fun observeNull() = println(x)
يمكنك أيضًا الاستيلاء على هذا مع lambda (الذي تم إنشاؤه في Kotlin كما يلي: {args -> body}) أيضًا:
class B { val x: Any = { y }() val y: Any = x } fun main() { println(B().x)
مثل هذه الأمثلة تبدو غير واقعية في الواقع (وهي كذلك) ، لكنني وجدت أخطاء مماثلة في الكود الحقيقي (قاعدة احتمال Kolmogorov 0-1 في تطوير البرمجيات: في قاعدة بيانات كبيرة بما فيه الكفاية ، يكاد يكون هناك أي جزء من الشفرة موجود ، على الأقل إن لم يكن ممنوع بشكل ثابت من قبل المترجم ؛ في هذه الحالة ، فإنه من شبه المؤكد أنه غير موجود).
سبب وجود Kotlin مع هذا الفشل هو نفسه كما هو الحال مع صفيف Java covariant: لا تزال عمليات التحقق تحدث في وقت التشغيل. في النهاية ، لا أرغب في تعقيد نظام نوع Kotlin من أجل جعل الحالات المذكورة أعلاه غير صحيحة في مرحلة التجميع: بالنظر إلى القيود الحالية (دلالات JVM) ، فإن نسبة السعر / الفائدة لعمليات التحقق من الصحة في وقت التشغيل أفضل بكثير من الحالات الثابتة.
ولكن ماذا لو لم يكن للغة قيمة افتراضية معقولة لكل نوع؟ على سبيل المثال ، في C ++ ، حيث الأنواع المعرفة من قبل المستخدم ليست بالضرورة مراجع ، لا يمكنك فقط تعيين قيمة فارغة لكل حقل وتقول إن هذا سوف ينجح! بدلاً من ذلك ، يستخدم C ++ بناء جملة خاص لتعيين القيم الأولية للحقول: قوائم التهيئة:
#include <string> #include <utility> class person { person(std::string first_name, std::string last_name) : first_name(std::move(first_name)) , last_name(std::move(last_name)) {} std::string first_name; std::string last_name; };
نظرًا لأن هذا بناء جملة خاص ، فإن اللغة المتبقية لا تعمل بلا عيب. على سبيل المثال ، من الصعب وضع عمليات عشوائية في قوائم التهيئة ، لأن لغة C ++ ليست لغة موجهة للتعبير (وهذا أمر طبيعي بحد ذاته). للعمل مع الاستثناءات التي تحدث في قوائم التهيئة ، تحتاج إلى استخدام ميزة غامضة أخرى للغة .
طرق الاتصال من المنشئ
كما تشير الأمثلة من Kotlin ، فإن كل شيء يتحطم إلى شرائح بمجرد أن نحاول استدعاء طريقة من المُنشئ. بشكل أساسي ، تتوقع الطرق أن يكون الكائن الذي يمكن الوصول إليه من خلال ذلك قد تم بناؤه وصحيحه بالكامل (بما يتوافق مع المتغيرات). لكن في Kotlin أو Java ، لا يوجد شيء يمنعك من استدعاء الأساليب من المُنشئ ، وبهذه الطريقة يمكننا أن نعمل بطريق الخطأ على كائن شبه مُنشأ. يعد المصمم بتأسيس ثوابت ، لكن في نفس الوقت هذا هو أسهل مكان لانتهاكه المحتمل.
تحدث أشياء غريبة بشكل خاص عندما يستدعي مُنشئ الفئة الأساسية طريقة تم تجاوزها في فئة مشتقة:
abstract class Base { init { initialize() } abstract fun initialize() } class Derived: Base() { val x: Any = 92 override fun initialize() = println(x)
مجرد التفكير في الأمر: يتم تنفيذ رمز فئة التعسفي قبل استدعاء المنشئ! سيؤدي رمز C ++ مماثل إلى نتائج أكثر إثارة للاهتمام. بدلاً من استدعاء وظيفة الفئة المشتقة ، سيتم استدعاء وظيفة الفئة الأساسية. هذا غير منطقي لأن الفئة المشتقة لم تتم تهيئتها بعد (تذكر ، لا يمكننا القول أن جميع الحقول خالية). ومع ذلك ، إذا كانت الوظيفة في الفئة الأساسية ظاهرية خالصة ، فستؤدي مكالمتها إلى UB.
توقيع المصمم
إن انتهاك المتحولين ليس هو المشكلة الوحيدة للمصممين. لديهم توقيع باسم ثابت (فارغ) ونوع الإرجاع (الفئة نفسها). هذا يجعل التصميم الزائد من الصعب على الناس فهمه.
ردم السؤال: ماذا std :: vector <int> xs (92 ، 2) تتوافق؟
أ. متجه بطولين 92
ب. [92 ، 92]
ج. [92 ، 2]
تنشأ مشاكل مع قيمة الإرجاع ، كقاعدة عامة ، عندما يكون من المستحيل إنشاء كائن. لا يمكنك فقط إرجاع النتيجة <MyClass أو io :: Error> أو لاغية من المنشئ!
غالبًا ما يستخدم هذا كوسيطة لصالح حقيقة أن استخدام C ++ دون استثناءات أمر صعب ، وأن استخدام المنشئات يفرض عليك أيضًا استخدام الاستثناءات. ومع ذلك ، لا أعتقد أن هذه الحجة صحيحة: أساليب المصنع تحل كل من هاتين المشكلتين ، لأنه يمكن أن يكون لهما أسماء تعسفية ويعيدان أنواعًا تعسفية. أعتقد أن النموذج التالي قد يكون مفيدًا في بعض الأحيان بلغات OO:
قم بإنشاء مُنشئ خاص يأخذ قيم جميع الحقول كوسائط ويعينها ببساطة. وهكذا ، فإن مثل هذا البناء من شأنه أن يعمل بمثابة هيكل الحرفية في الصدأ. يمكنه أيضًا البحث عن أي من المتغيرات ، لكن يجب ألا يفعل أي شيء آخر باستخدام الوسائط أو الحقول.
يتم توفير أساليب المصنع العامة لواجهة برمجة التطبيقات العامة مع أسماء وأنواع الإرجاع المناسبة.
مشكلة مماثلة مع الصانعين هي أنها محددة وبالتالي لا يمكن تعميمها. في C ++ ، "لا يوجد مُنشئ افتراضي" أو "يوجد مُنشئ نسخ" لا يمكن التعبير عنها ببساطة أكثر من "أعمال بناء جملة معينة". قارن هذا بـ Rust ، حيث يكون لهذه المفاهيم توقيعات مناسبة:
trait Default { fn default() -> Self; } trait Clone { fn clone(&self) -> Self; }
الحياة بدون مصممين
لدى Rust طريقة واحدة فقط لإنشاء هيكل: لتوفير قيم لجميع الحقول. تلعب وظائف المصنع ، مثل الجديد المقبول عمومًا ، دور المنشئات ، لكن الأهم من ذلك أنها لا تسمح لك باستدعاء أي طرق حتى يكون لديك مثيل صحيح على الأقل أكثر أو أقل من الهيكل.
عيب هذا النهج هو أنه يمكن لأي كود إنشاء هيكل ، لذلك لا يوجد مكان واحد ، مثل المنشئ ، للحفاظ على المتغيرات. في الممارسة العملية ، يمكن حل هذا بسهولة عن طريق الخصوصية: إذا كانت حقول الهيكل خاصة ، فلا يمكن إنشاء هذا الهيكل إلا في نفس الوحدة. ضمن وحدة نمطية واحدة ، ليس من الصعب الالتزام بالاتفاقية "يجب أن تستخدم جميع أساليب إنشاء الهيكل الطريقة الجديدة". يمكنك حتى تخيل امتداد لغة يسمح لك بوضع علامة على بعض الوظائف باستخدام السمة # [constructor] ، بحيث يكون بناء الجملة الحرفية للهيكل متاحًا فقط في الوظائف المميزة. ولكن ، مرة أخرى ، يبدو أن الآليات اللغوية الإضافية لا لزوم لها: إن اتباع الاتفاقيات المحلية يتطلب القليل من الجهد.
شخصيا ، أعتقد أن هذا الحل الوسط يبدو هو نفسه تمامًا بالنسبة لبرمجة العقود بشكل عام. من الأفضل تشفير العقود مثل "ليست خالية" أو "القيمة الإيجابية" في الأنواع. بالنسبة للمتغيرات المعقدة ، مجرد كتابة التأكيد! (Self.validate ()) في كل طريقة ليست صعبة للغاية. بين هذين النموذجين ، لا يوجد مجال كبير لشروط # [pre] و # [post] التي يتم تنفيذها على مستوى اللغة أو بناءً على وحدات الماكرو.
ماذا عن سويفت؟
سويفت هي لغة أخرى مثيرة للاهتمام تستحق نظرة على آليات التصميم. مثل Kotlin ، Swift هي لغة آمنة خالية. على عكس Kotlin ، فإن عمليات التحقق من السويفت هي أقوى ، لذلك تستخدم اللغة الحيل المثيرة للاهتمام للتخفيف من الأضرار الناجمة عن الصانعين.
أولاً ، يستخدم Swift الوسائط المسماة ، ويساعد قليلاً مع "جميع المنشئات لها نفس الاسم." على وجه الخصوص ، لا يُشكل مُنشئان لهما نفس أنواع المعلمات مشكلة:
Celsius(fromFahrenheit: 212.0) Celsius(fromKelvin: 273.15)
ثانياً ، لحل المشكلة "يستدعي المنشئ الطريقة الافتراضية لفئة الكائن الذي لم يتم إنشاؤه بالكامل بعد" يستخدم Swift بروتوكول تهيئة ثنائي الطور مدروس جيدًا. على الرغم من عدم وجود بناء جملة خاص لقوائم التهيئة ، يتحقق المترجم بشكل ثابت من أن نص المنشئ لديه النموذج الصحيح والآمن. على سبيل المثال ، لا يمكن استخدام أساليب الاتصال إلا بعد تهيئة جميع حقول الفصل وأحفاده.
ثالثًا ، على مستوى اللغة ، هناك دعم للمُنشئين الذين قد تفشل نداءهم. يمكن تعيين المنشئ على أنه لاغٍ ، مما يجعل نتيجة استدعاء الفصل خيارًا. قد يحتوي المُنشئ أيضًا على معدِّل رميات ، والذي يعمل بشكل أفضل مع دلالات التهيئة ثنائية الطور في Swift مقارنةً مع بناء جملة قوائم التهيئة في C ++.
تمكنت سويفت من إغلاق جميع الثقوب في المنشئات التي اشتكت منها. ومع ذلك ، يأتي هذا بسعر: يعد فصل التهيئة أحد أكبر الفصل في كتاب Swift.
عندما تكون هناك حاجة حقا البنائين
رغم كل الصعاب ، يمكنني التوصل لسببين على الأقل لعدم إمكانية استبدال الصانعين بالحرف الحرفية للهيكل ، كما هو الحال في الصدأ.
أولاً ، الإرث ، بدرجة أو بأخرى ، يفرض على اللغة أن يكون لها صانعين. يمكنك تخيل امتداد بناء الجملة مع دعم لفئات الأساس:
struct Base { ... } struct Derived: Base { foo: i32 } impl Derived { fn new() -> Derived { Derived { Base::new().., foo: 92, } } }
ولكن هذا لن ينجح في تخطيط كائن نموذجي من لغة OO مع وراثة بسيطة! عادة ، يبدأ الكائن بعنوان متبوعًا بحقول الفصل ، من الأساس إلى الأكثر اشتقاقًا. وبالتالي ، فإن بادئة كائن الفئة المشتقة هي الكائن الصحيح للفئة الأساسية. ومع ذلك ، حتى يعمل هذا المخطط ، يحتاج المصمم إلى تخصيص ذاكرة للكائن بالكامل في وقت واحد. لا يمكن تخصيص ذاكرة فقط للفئة الأساسية ، ثم إرفاق الحقول المشتقة. لكن هذا التخصيص للذاكرة في القطع ضروري إذا أردنا استخدام بناء الجملة لإنشاء هيكل حيث يمكننا تحديد قيمة للفئة الأساسية.
ثانياً ، على عكس الصيغة الحرفية للهيكل ، يمتلك المصممون ABI الذي يعمل بشكل جيد مع وضع كائنات فرعية في الذاكرة (ABI سهل الموضع). يعمل المُنشئ مع مؤشر لهذا ، والذي يشير إلى مساحة الذاكرة التي يجب أن يشغلها الكائن الجديد. الأهم من ذلك ، يمكن للمنشئ أن يمرر المؤشر بسهولة إلى المنشئات الفرعية ، مما يسمح بإنشاء أشجار قيمة معقدة "في المكان". في المقابل ، في الصدأ ، يتضمن بناء الهياكل بشكل شبه معدوم عددًا قليلاً من النسخ ، ونأمل هنا أن نعمة المحسن. ليس من قبيل الصدفة أن الصدأ ليس لديه بعد اقتراح عمل مقبول بشأن وضع المشاريع الفرعية في الذاكرة!
التحديث 1: إصلاح خطأ مطبعي. استبدال "الكتابة الحرفية" بـ "الهيكل الحرفي".