OOP مات ، يعيش OOP

الصورة

مصادر الإلهام


جاء هذا المنشور بفضل منشور حديث نشرته Aras Prantskevichus حول تقرير مخصص للمبرمجين المبتدئين. يتحدث عن كيفية التكيف مع بنيات ECS الجديدة. يتبع Aras النمط المعتاد ( التفسير أدناه ): يُظهر أمثلة على رمز OOP الرهيب ، ثم يوضح أن النموذج العلائقي ( لكنه يطلق عليه "ECS" بدلاً من العلائقية ) هو بديل رائع. بأي حال من الأحوال أنتقد أراس - أنا من المعجبين بعمله وأثني عليه لعرضه الممتاز! اخترت عرضه التقديمي بدلاً من مئات المنشورات الأخرى حول ECS من الإنترنت لأنه بذل جهودًا إضافية ونشر مستودعًا للدراسة بالتوازي مع العرض التقديمي. أنه يحتوي على "لعبة" بسيطة صغيرة ، تستخدم كمثال لاختيار الحلول المعمارية المختلفة. سمح لي هذا المشروع الصغير بإظهار تعليقاتي على مادة محددة ، لذا شكرا يا أراس!

تتوفر شرائح Aras هنا: http://aras-p.info/texts/files/2018Academy - ECS-DoD.pdf ، والرمز موجود على github: https://github.com/aras-p/dod-playground .

لن أقوم (بعد؟) بتحليل بنية ECS الناتجة من هذا التقرير ، لكنني أركز على رمز "OOP السيئ" (على غرار الخدعة المحشوة) من بدايتها. سأوضح كيف سيبدو حقًا إذا تم تصحيح جميع انتهاكات مبادئ OOD (التصميم الموجه للكائنات ، التصميم الموجه للكائنات) بشكل صحيح.

Spoiler: يؤدي القضاء على جميع انتهاكات OOD إلى تحسينات في الأداء تشبه تحويلات Aras إلى ECS ، كما أنها تستخدم ذاكرة RAM أقل وتتطلب أسطرًا أقل من الكود من إصدار ECS!

TL ؛ DR: قبل أن نستنتج أن OOP تمتص ومحركات ECS ، توقف وفحص OOD (لمعرفة كيفية استخدام OOP بشكل صحيح) ، وفهم أيضًا النموذج العلائقي (لمعرفة كيفية تطبيق ECS بشكل صحيح).

لقد شاركت في الكثير من المناقشات حول ECS في المنتدى لفترة طويلة ، ويرجع ذلك جزئيًا إلى أنني لا أعتقد أن هذا النموذج يستحق الوجود كمصطلح منفصل ( المفسد: هذا مجرد نسخة مخصصة للنموذج العلائقي ) ، ولكن أيضًا بسبب تقريبًا كل منشور أو عرض تقديمي أو مقال يروج لنموذج ECS يتبع الهيكل التالي:

  1. أعرض مثالًا على رمز OOP الرهيب ، الذي ينطوي تطبيقه على عيوب فظيعة بسبب الاستخدام المفرط للميراث (مما يعني أن هذا التطبيق ينتهك العديد من مبادئ OOD).
  2. لإظهار أن التكوين هو حل أفضل من الميراث (ناهيك عن أن OOD يقدم لنا في الواقع نفس الدرس).
  3. أظهر أن النموذج العلائقي رائع للألعاب (ولكن أطلق عليه "ECS").

مثل هذا الهيكل يغضبني لأنه: (أ) هذه خدعة "محشوة" ... فهي تقارن بسهولة إلى دافئة (كود سيء ورمز جيد) ... وهذا غير عادل ، حتى إذا تم عن غير قصد وغير مطلوب لإثبات أن البنية الجديدة جيدة ؛ والأهم من ذلك: (ب) أن يكون له تأثير جانبي - مثل هذا النهج يقمع المعرفة ويقلل من قراءة القراء عن غير قصد من الدراسات التي أجريت لمدة نصف قرن. بدأوا الكتابة عن النموذج العلائقي في الستينيات. خلال السبعينيات والثمانينيات ، تحسن هذا النموذج بشكل كبير. غالبًا ما يكون لدى المبتدئين أسئلة مثل " ما الفصل الذي تريد وضع هذه البيانات فيه؟ " ، ورداً على ذلك ، يتم إخبارهم غالبًا بشيء غامض ، مثل " تحتاج فقط إلى اكتساب الخبرة ثم تتعلم فقط فهم الداخل " ... ولكن في السبعينيات كان هذا السؤال نشطًا تمت دراستها وفي الحالة العامة تم استنباط إجابة رسمية ؛ وهذا ما يسمى تطبيع قاعدة البيانات . تجاهل البحوث الحالية ووصف ECS حلا جديدا وحديثا تماما ، يمكنك إخفاء هذه المعرفة من المبتدئين.

لقد تم وضع أساسيات البرمجة الموجهة للكائنات منذ فترة طويلة ، إن لم يكن في وقت سابق ( بدأ استكشاف هذا الأسلوب في أعمال الخمسينيات )! ومع ذلك ، في التسعينيات من القرن العشرين ، أصبح التوجه نحو الكائن من المألوف والفيروسي ، وتحول بسرعة كبيرة إلى نموذج البرمجة السائد. حدث انفجار في شعبية العديد من لغات OO الجديدة ، بما في ذلك Java و ( الإصدار القياسي ) C ++. ومع ذلك ، نظرًا لأن هذا كان بسبب الضجيج ، كان الجميع بحاجة إلى معرفة هذا المفهوم الرفيع المستوى من أجل الكتابة في سيرتهم الذاتية ، لكن القليل منهم فقط ذهبوا إلى الأمر. هذه اللغات الجديدة خلقت الكلمات الرئيسية - فئة ، افتراضية ، تمدد ، تنفذ - من بين العديد من ميزات OO ، وأعتقد أن هذا هو السبب في ذلك الوقت تم تقسيم OO إلى كيانين منفصلين يعيشان حياتهما الخاصة.

سأشير إلى استخدام ميزات اللغة المستوحاة من OO مثل " OOP " واستخدام تقنيات التصميم / الهندسة المستوحاة من OO " OOD ". جميع بسرعة كبيرة التقطت OOP. المؤسسات التعليمية لديها دورات OO التي تخبئ مبرمجين جدد OOP ... ومع ذلك ، فإن معرفة OOD متأخرة.

أعتقد أن التعليمات البرمجية التي تستخدم ميزات لغة OOP ، ولكنها لا تتبع مبادئ تصميم OOD ، ليست رمز OO . معظم الانتقادات ضد OOP تستخدم على سبيل المثال رمز التهم ، وهو ليس حقا رمز OO.

يتمتع رمز OOP بسمعة سيئة للغاية ، وبصفة خاصة لأن معظم كود OOP لا يتبع مبادئ OOD ، وبالتالي فهو ليس رمز OO "حقيقي".

الخلفية


كما ذكر أعلاه ، أصبحت التسعينيات ذروة "أزياء OO" ، وكان في ذلك الوقت "OOP سيئة" ربما كان الأسوأ. إذا درست OOP في ذلك الوقت ، فمن الأرجح أنك علمت "الركائز الأربع لـ OOP":

  • التجريد
  • التغليف
  • تعدد الأشكال
  • الميراث

أفضل أن أسميهم ليس أربعة أعمدة ، ولكن "أربعة أدوات OOP". هذه هي الأدوات التي يمكنك استخدامها لحل المشاكل. ومع ذلك ، لا يكفي فقط معرفة كيفية عمل الأداة ، بل يجب عليك معرفة متى يجب استخدامها ... من جانب المعلمين ، من غير المسؤول تعليم الناس أداة جديدة ، وعدم إخبارهم عندما يستحق كل منهم استخدامها. في أوائل العقد الأول من القرن العشرين ، كانت هناك مقاومة لإساءة الاستخدام النشط لهذه الأدوات ، وهو نوع من "الموجة الثانية" من تفكير OOD. وكانت النتيجة ظهور فن الإستذكار الصلبة ، والتي وفرت طريقة سريعة لتقييم القوة المعمارية. تجدر الإشارة إلى أن هذه الحكمة كانت منتشرة على نطاق واسع في التسعينيات ، لكنها لم تتلق بعد اختصارًا رائعًا ، مما سمح بإصلاحها باعتبارها خمسة مبادئ أساسية ...

  • مبدأ المسؤولية الفردية (مبدأ المسؤولية الشاملة). يجب أن يكون لكل فصل سبب واحد فقط للتغيير. إذا كانت الفئة "أ" تتحمل مسؤوليتين ، فأنت بحاجة إلى إنشاء صنف "B" و "C" لمعالجة كل منهما على حدة ، ثم إنشاء "A" من "B" و "C".
  • مبدأ الانفتاح / الإغلاق ( يا قلم / مبدأ مغلق). يتغير البرنامج بمرور الوقت ( أي أن دعمه مهم ). حاول وضع الأجزاء التي من المرجح أن تتغير في عمليات التنفيذ ( أي في فئات معينة ) وإنشاء واجهات تعتمد على تلك الأجزاء التي من غير المرجح أن تتغير ( على سبيل المثال ، فئات الأساس التجريدي ).
  • مبدأ الاستعاضة عن Barbara Liskov (مبدأ الاستبدال L iskov). يجب أن يفي كل تطبيق للواجهة بنسبة 100٪ بمتطلبات هذه الواجهة ، أي يجب أن تعمل أي خوارزمية تعمل مع واجهة مع أي تطبيق.
  • مبدأ الفصل بين الواجهة ( أنا مبدأ الفصل nterface). اجعل الواجهات صغيرة الحجم قدر الإمكان بحيث "يعرف" كل جزء من أجزاء الكود أصغر قاعدة من الكود ، على سبيل المثال ، يتجنب التبعيات غير الضرورية. هذه النصيحة جيدة لـ C ++ أيضًا ، حيث تصبح أوقات التجميع ضخمة إذا لم تتبعها.
  • مبدأ انعكاس التبعية (مبدأ انعكاس ependency). بدلاً من تطبيقين محددين يتصلان مباشرة (ويعتمد كل منهما على الآخر) ، يمكن فصلهما عادةً عن طريق إضفاء الطابع الرسمي على واجهة الاتصال الخاصة بهما كفئة ثالثة ، تستخدم كواجهة بينهما. يمكن أن تكون فئة أساسية مجردة تحدد نداءات الطرق المستخدمة بينهما ، أو حتى مجرد بنية POD التي تحدد البيانات المنقولة بينهما.
  • لم يتم تضمين مبدأ آخر في اختصار SOLID ، لكنني متأكد من أنه مهم جدًا: "تفضيل التكوين على الميراث" (مبدأ إعادة الاستخدام المركب). التكوين هو الخيار الصحيح بشكل افتراضي . يجب ترك الميراث للحالات عندما يكون ذلك ضروريًا للغاية.

لذلك نحصل على SOLID-C (++) :)

أدناه ، سأشير إلى هذه المبادئ ، وأطلق عليها اختصارات - SRP ، OCP ، LSP ، ISP ، DIP ، CRP ...

بعض الملاحظات الأخرى:

  • في OOD ، لا يمكن ربط مفاهيم الواجهات والتطبيقات بأي كلمات رئيسية OOP محددة. في C ++ ، نقوم غالبًا بإنشاء واجهات مع فئات أساسية مجردة ووظائف افتراضية ، ثم تكتسب التطبيقات من هذه الفئات الأساسية ... ولكن هذه طريقة واحدة فقط محددة لتطبيق مبدأ الواجهة. في C ++ ، يمكننا أيضًا استخدام PIMPL ، مؤشرات غير شفافة ، كتابة البط ، typedef ، وما إلى ذلك ... يمكنك إنشاء بنية OOD ثم تنفيذها في C ، حيث لا توجد كلمات مفتاحية للغة OOP على الإطلاق! لذلك عندما أتحدث عن واجهات ، لا أقصد بالضرورة وظائف افتراضية - أنا أتحدث عن مبدأ إخفاء التطبيق . يمكن أن تكون واجهات متعددة الأشكال ، ولكن في أكثر الأحيان هم! نادراً ما يتم استخدام تعدد الأشكال بشكل صحيح ، لكن الواجهات تعد مفهومًا أساسيًا لجميع البرامج.
    • كما أوضحت أعلاه ، إذا قمت بإنشاء بنية POD التي تخزن ببساطة بعض البيانات لنقلها من فئة إلى أخرى ، فسيتم استخدام هذه البنية كواجهة - وهذا وصف رسمي للبيانات .
    • حتى إذا قمت بإنشاء فصل واحد منفصل مع الأجزاء العامة والخاصة ، فإن كل ما هو موجود في الجزء المشترك هو واجهة ، وكل شيء في الجزء الخاص هو تطبيق .
  • الوراثة في الواقع (على الأقل) نوعان - الوراثة واجهة ووراثة التنفيذ.
    • في C ++ ، تتضمن الوراثة واجهة فئات أساسية مجردة مع وظائف افتراضية خالصة ، PIMPL ، typedef الشرطي. في Java ، يتم التعبير عن ميراث الواجهة من خلال الكلمة الأساسية للتطبيق .
    • في C ++ ، يحدث توارث التطبيقات في كل مرة تحتوي الفئات الأساسية على شيء آخر غير الوظائف الافتراضية الخالصة. في Java ، يتم التعبير عن وراثة التطبيق باستخدام الكلمة الأساسية الممتدة .
    • لدى OOD الكثير من القواعد الخاصة بوراثة الواجهات ، لكن وراثة التطبيقات تستحق عادة اعتبارها "رمزًا ذو لدغة" !

وأخيرًا ، يجب أن أعرض بعض الأمثلة على تدريب OOP الرهيب وكيف يؤدي إلى رمز سيء في الحياة الحقيقية (وسمعة OOP السيئة).

  1. عندما يتم تعليمك التسلسل الهرمي / الميراث ، قد تكون منحتك مهمة مماثلة: لنفترض أن لديك طلب جامعي يحتوي على دليل للطلاب والموظفين. يمكنك إنشاء الشخص الأساسي للفصل الدراسي ، ثم الفصل الدراسي للطلبة وفئة الموظفين الموروثة من الشخص.

    لا ، لا ، لا. هنا سوف يمنعك. التضمين غير المعلن لمبدأ LSP هو أن التسلسلات الهرمية للفصل والخوارزميات التي تعالجها هي تكافلية. هذان نصفي البرنامج بأكمله. OOP هي امتداد للبرمجة الإجرائية ، ولا تزال مرتبطة بشكل أساسي بهذه الإجراءات. إذا لم نكن نعرف أنواع الخوارزميات التي ستعمل مع الطلاب والموظفين ( وأي الخوارزميات سيتم تبسيطها بسبب تعدد الأشكال ) ، فسيكون من غير المسؤول تمامًا البدء في إنشاء بنية التسلسل الهرمي للفصل. تحتاج أولاً إلى معرفة الخوارزميات والبيانات.
  2. عندما يتم تعليمك التسلسلات الهرمية / الميراث ، فمن المحتمل أنك قد حصلت على مهمة مماثلة: لنفترض أن لديك فئة من الأشكال. لدينا أيضا المربعات والمستطيلات كفئات فرعية. هل يجب أن يكون المربع مستطيلًا أم مستطيلًا؟

    هذا في الواقع مثال جيد لإظهار الفرق بين وراثة التطبيقات ووراثة الواجهات.
    • إذا كنت تستخدم نهج الميراث الخاص بالتنفيذ ، فإنك تتجاهل LSP تمامًا ، ومن وجهة نظر عملية ، فكر في إمكانية إعادة استخدام الكود ، باستخدام الميراث كأداة.

      من وجهة النظر هذه ، ما يلي منطقي تمامًا:

      struct Square { int width; }; struct Rectangle : Square { int height; }; 

      المربع ليس له سوى العرض ، والمستطيل له عرض + الارتفاع ، أي أنه بتوسيع المربع بمكون الارتفاع ، نحصل على مستطيل!
      • كما كنت قد خمنت ، تقول OOD أن القيام بذلك خطأ ( ربما ). قلت "ربما" لأنه هنا يمكنك الجدال حول الخصائص الضمنية للواجهة ... حسنًا.

        دائمًا ما يكون للمربع نفس الطول والعرض ، لذا فمن الصحيح تمامًا افتراض أن المربع هو "العرض * العرض".

        وراثة من مربع ، يجب أن تطيع فئة المستطيلات (وفقًا لـ LSP) قواعد الواجهة المربعة. يجب أن تعمل أي خوارزمية تعمل بشكل صحيح مع مربع بشكل صحيح لمستطيل.
      • خذ خوارزمية أخرى:

         std::vector<Square*> shapes; int area = 0; for(auto s : shapes) area += s->width * s->width; 

        ستعمل بشكل صحيح للمربعات (حساب مجموع مساحاتها) ، لكنها لن تعمل مع المستطيلات.

        لذلك ، ينتهك المستطيل مبدأ LSP.
    • إذا كنت تستخدم نهج الوراثة في الواجهة ، فلن يرث Square أو Rectangle من بعضهما البعض. تختلف واجهات المربع والمستطيل فعليًا ، والآخر ليس مجموعة أخرى.
    • لذلك ، OOD يشجع استخدام الميراث التنفيذ. كما ذكر أعلاه ، إذا كنت ترغب في إعادة استخدام الرمز ، فإن OOD تقول أن التركيب هو الخيار الصحيح!
      • وبالتالي فإن الإصدار الصحيح من الكود (السيئة) أعلاه للتسلسل الهرمي للميراث في تطبيقات C ++ يبدو كما يلي:

         struct Shape { virtual int area() const = 0; }; struct Square : public virtual Shape { virtual int area() const { return width * width; }; int width; }; struct Rectangle : private Square, public virtual Shape { virtual int area() const { return width * height; }; int height; }; 

        • "الظاهري العامة" في جافا تعني "تنفذ". يستخدم عند تنفيذ الواجهة.
        • يسمح لك "خاص" بتمديد الفئة الأساسية دون أن ترث واجهته - في هذه الحالة ، ليس المستطيل مربعًا ، على الرغم من أنه يرث منه.
      • لا أوصي بكتابة هذا الكود ، ولكن إذا كنت تريد استخدام وراثة التطبيقات ، فأنت بحاجة إلى القيام بذلك!

TL ؛ DR - أخبرك صف OOP عن ميراثك. يجب أن يخبرك صف OOD المفقود بعدم استخدامه 99٪ من الوقت!

مفاهيم الكيان / المكون


بعد التعامل مع المتطلبات المسبقة ، دعنا ننتقل إلى حيث بدأت Aras - ما يسمى نقطة البداية لـ "OOP النموذجي".

لكن بالنسبة للمبتدئين ، إضافة أخرى - آراس يطلق على هذا الرمز "OOP التقليدي" ، وأريد أن أعترض على ذلك. قد يكون هذا الرمز نموذجيًا لـ OOP في العالم الواقعي ، ولكنه ، مثل الأمثلة المذكورة أعلاه ، ينتهك جميع أنواع المبادئ الأساسية لـ OO ، لذلك لا ينبغي اعتباره تقليديًا على الإطلاق.

سأبدأ بالالتزام الأول قبل أن يبدأ في إعادة تشكيل الهيكل نحو ECS: "اجعله يعمل على Windows مرة أخرى" 3529f232510c95f53112bbfff87df6bbc6aa1fae

 // ------------------------------------------------------------------------------------------------- // super simple "component system" class GameObject; class Component; typedef std::vector<Component*> ComponentVector; typedef std::vector<GameObject*> GameObjectVector; // Component base class. Knows about the parent game object, and has some virtual methods. class Component { public: Component() : m_GameObject(nullptr) {} virtual ~Component() {} virtual void Start() {} virtual void Update(double time, float deltaTime) {} const GameObject& GetGameObject() const { return *m_GameObject; } GameObject& GetGameObject() { return *m_GameObject; } void SetGameObject(GameObject& go) { m_GameObject = &go; } bool HasGameObject() const { return m_GameObject != nullptr; } private: GameObject* m_GameObject; }; // Game object class. Has an array of components. class GameObject { public: GameObject(const std::string&& name) : m_Name(name) { } ~GameObject() { // game object owns the components; destroy them when deleting the game object for (auto c : m_Components) delete c; } // get a component of type T, or null if it does not exist on this game object template<typename T> T* GetComponent() { for (auto i : m_Components) { T* c = dynamic_cast<T*>(i); if (c != nullptr) return c; } return nullptr; } // add a new component to this game object void AddComponent(Component* c) { assert(!c->HasGameObject()); c->SetGameObject(*this); m_Components.emplace_back(c); } void Start() { for (auto c : m_Components) c->Start(); } void Update(double time, float deltaTime) { for (auto c : m_Components) c->Update(time, deltaTime); } private: std::string m_Name; ComponentVector m_Components; }; // The "scene": array of game objects. static GameObjectVector s_Objects; // Finds all components of given type in the whole scene template<typename T> static ComponentVector FindAllComponentsOfType() { ComponentVector res; for (auto go : s_Objects) { T* c = go->GetComponent<T>(); if (c != nullptr) res.emplace_back(c); } return res; } // Find one component of given type in the scene (returns first found one) template<typename T> static T* FindOfType() { for (auto go : s_Objects) { T* c = go->GetComponent<T>(); if (c != nullptr) return c; } return nullptr; } 

نعم ، من الصعب معرفة مئات أسطر الكود على الفور ، لذلك دعونا نبدأ تدريجياً ... نحتاج إلى جانب واحد آخر من المتطلبات المسبقة - في ألعاب التسعينيات ، كان من المألوف استخدام الميراث لحل جميع مشاكل إعادة استخدام الكود. كان لديك كيان ، وشخصية قابلة للامتداد ، ومشغل قابل للامتداد ، ومونستر ، وما إلى ذلك ... هذا وراثة للتطبيقات ، كما وصفنا سابقًا ( "الكود مع الاختناق" ) ، ويبدو أنه من الصواب أن تبدأ به ، ولكن يؤدي ذلك إلى حد كبير قاعدة رمز غير مرنة. لأن العود لديه مبدأ "التكوين على الميراث" الموصوف أعلاه. وهكذا ، في 2000s ، أصبح مبدأ "التكوين على الميراث" شائعًا ، وبدأ مطورو الألعاب بكتابة كود مشابه.

ماذا يفعل هذا الرمز؟ حسنا ليس جيدا : د

باختصار ، يعيد هذا الكود تنفيذ ميزة موجودة في اللغة - التكوين كمكتبة وقت التشغيل ، وليس كميزة للغة. يمكنك أن تتخيل هذا كما لو أن الكود كان يقوم بالفعل بإنشاء لغة معدنية جديدة أعلى C ++ وجهاز ظاهري (VM) لتنفيذ هذا اللغز المعدني. في لعبة Aras demo ، هذا الكود غير مطلوب ( سنقوم بإزالته بالكامل قريبًا! ) ويعمل فقط على تقليل أداء اللعبة بحوالي 10 مرات.

ولكن ماذا يفعل فعلا؟ هذا هو مفهوم " E omp ntity / C omponent system" ( أحيانًا لسبب ما يسمى " E ntity / C omponent system" ) ، لكنه يختلف تمامًا عن مفهوم " E ntity C" omponent S ystem "(" "الكيان مكون النظام") ( والذي لا يُطلق عليه أبداً " E ntity C omponent S ystem systems ). إنه يضفي الطابع الرسمي على العديد من مبادئ" EC ":

  • سيتم بناء اللعبة من عدم وجود ميزات لـ "الكيانات" ("الكيان") ( في هذا المثال تسمى GameObjects) ، والتي تتكون من "مكونات" ("مكون").
  • تقوم GameObjects بتنفيذ نمط "محدد موقع الخدمة" - سيتم الاستعلام عن مكوناتها الفرعية حسب النوع.
  • تعرف المكونات على GameObject التي ينتمون إليها - يمكنهم العثور على المكونات الموجودة على نفس المستوى معهم عن طريق الاستعلام عن GameObject الأصل.
  • يمكن أن يكون التكوين عميقًا بمستوى واحد فقط ( لا يمكن أن تحتوي المكونات على مكونات فرعية خاصة بهم ، ولا يمكن أن تحتوي GameObjects على GameObjects الفرعية ).
  • يمكن أن يحتوي GameObject على مكون واحد فقط من كل نوع ( في بعض الأطر يكون هذا مطلبًا إلزاميًا ، في البعض الآخر لا ).
  • يتغير كل مكون (على الأرجح) بمرور الوقت بطريقة غير محددة ، لذلك تحتوي الواجهة على "تحديث باطل افتراضي".
  • تنتمي GameObjects إلى مشهد يمكنه تنفيذ الاستعلامات على جميع GameObjects (وبالتالي كل المكونات).

كان مفهوم مماثل شائعًا للغاية في الألفية الثانية ، وعلى الرغم من القيود ، اتضح أنه مرن بدرجة كافية لإنشاء عدد لا يحصى من الألعاب آنذاك واليوم.

ومع ذلك ، هذا غير مطلوب. لغة البرمجة لديك لديها بالفعل دعم للتكوين كميزة من سمات اللغة - ليست هناك حاجة لمفهوم منتفخ للوصول إليها ... لماذا ، إذن ، هل توجد هذه المفاهيم؟ حسنًا ، لكي نكون صادقين ، فهي تسمح لك بإجراء التكوين الديناميكي في وقت التشغيل . بدلاً من تحديد أنواع GameObject الثابتة في التعليمات البرمجية ، يمكنك تحميلها من ملفات البيانات. وهذا أمر مريح للغاية ، لأنه يسمح لمصممي اللعبة / المستوى بإنشاء أنواعهم الخاصة من الأشياء ... ومع ذلك ، يوجد في معظم مشاريع الألعاب عدد قليل جدًا من المصممين وجيش كامل من المبرمجين حرفيًا ، لذلك أود أن أقول إن هذه فرصة مهمة. والأسوأ من ذلك ، أن هذه ليست الطريقة الوحيدة التي يمكنك بها تنفيذ تركيبة في وقت التشغيل! على سبيل المثال ، تستخدم الوحدة C # كـ "لغة البرمجة" الخاصة بها ، وتستخدم العديد من الألعاب الأخرى بدائلها ، على سبيل المثال ، Lua - يمكن للأداة المناسبة للمصممين إنشاء رمز C # / Lua لتحديد كائنات لعبة جديدة دون الحاجة لمثل هذا المفهوم المتضخم! سنقوم بإعادة إضافة هذه "الميزة" في المنشور التالي ، ونجعلها لا تكلفنا نقصًا في الأداء بمقدار عشرة أضعاف ...

لنقم بتقييم هذا الكود وفقًا لـ OOD:

  • يستخدم GameObject :: GetComponent dynamic_cast. سيخبرك معظم الأشخاص بأن dynamic_cast عبارة عن "رمز به خنق" ، وهو تلميح كبير بأن لديك خطأ في مكان ما. أود أن أقول هذا - وهذا دليل على أنك انتهكت LSP - لديك نوعًا من الخوارزمية التي تعمل مع الواجهة الأساسية ، لكنها تحتاج إلى معرفة تفاصيل تنفيذ مختلفة. لهذا السبب بالذات ، الكود رائحة كريهة.
  • GameObject ، من حيث المبدأ ، ليس سيئًا ، إذا كنت تتخيل أنه ينفذ قالب "محدد موقع الخدمة" ... ولكن إذا ذهبت أبعد من النقد من وجهة نظر OOD ، فإن هذا القالب يخلق اتصالات ضمنية بين أجزاء المشروع ، وأعتقد ( بدون رابط إلى ويكيبيديا يمكنه دعم لي بمعرفة من علوم الكمبيوتر ) أن قنوات الاتصال الضمنية هي منضدة ، ويجب أن تفضل قنوات اتصال واضحة. تنطبق نفس الحجة على "مفهوم الأحداث" المتضخم الذي يستخدم أحيانًا في الألعاب ...
  • أريد أن أذكر أن أحد المكونات يمثل انتهاكًا لـ SRP نظرًا لأن الواجهة ( تحديث الفراغ الظاهري (الوقت) ) واسعة جدًا. يعد استخدام "تحديث الفراغ الافتراضي" في تطوير اللعبة أمرًا واسعًا ، لكنني أود أن أقول أنه مضاد للظهور. يجب أن يتيح لك البرنامج الجيد التفكير بسهولة في التحكم في التدفق وتدفق البيانات. يؤدي وضع كل عنصر من عناصر كود اللعب وراء مكالمة "تحديث الفراغ الظاهري" إلى تعتيم دفق التحكم ودفق البيانات بالكامل وبشكل كامل. تعد IMHO ، والآثار الجانبية غير المرئية ، والتي تسمى أيضًا التأثيرات بعيدة المدى ، من أكثر مصادر الأخطاء شيوعًا ، ويضمن "تحديث الفراغ الافتراضي" أن كل شيء تقريبًا سيكون تأثيرًا جانبيًا غير مرئي.
  • على الرغم من أن الهدف من فئة مكون هو تمكين التكوين ، فإنه يفعل ذلك من خلال الميراث ، الذي يعد انتهاكا ل CRP .
  • الجانب الجيد الوحيد في هذا المثال هو أن كود اللعبة مبالغة في الامتثال لمبادئ SRP و ISP - وهي مقسمة إلى العديد من المكونات البسيطة مع القليل من المسؤولية ، وهو أمر عظيم لإعادة استخدام الرمز.

    ومع ذلك ، فهو ليس جيدًا في الحفاظ على DIP - العديد من المكونات لديها معرفة مباشرة ببعضها البعض.

لذلك ، يمكن بالفعل حذف كل الكود الموضح أعلاه. هذا الهيكل كله. حذف GameObject (وتسمى أيضًا الكيان في أطر عمل أخرى) ، قم بإزالة المكون ، وحذف FindOfType. هذا جزء من VM عديمة الفائدة ينتهك مبادئ OOD ويبطئ بشكل رهيب لعبتنا.

تكوين بدون أطر (أي استخدام ميزات لغة البرمجة نفسها)


إذا أزلنا إطار التكوين ولم يكن لدينا فئة المكونات الأساسية ، فكيف ستتمكن GameObjects من استخدام المكونات وتتألف من مكونات؟ كما يقول العنوان ، بدلاً من كتابة هذا VM المتضخم وإنشاء GameObjects بلغة معدنية غريبة فوقه ، دعنا نكتبها فقط في C ++ ، لأننا مبرمجون للألعاب وهذا عملنا حرفيًا.

إليك الالتزام الذي أزال إطار عمل الكيان / المكون: https://github.com/hodgman/dod-playground/commit/f42290d0217d700dea2ed002f2f3b1dc45e8c27c

هذه هي النسخة الأصلية من الكود المصدري: https://github.com/hodgman/dod-playground/blob/3529f232510c95f53112bbfff87df6bbc6aa1fae/source/game.cpp

فيما يلي النسخة المعدلة من الكود المصدري: https://github.com/hodgman/dod-playground/blob/f42290d0217d700dea2ed002f2f3b1dc45e8c27c/source/game.cpp

باختصار حول التغييرات:

  • تمت إزالة ": مكون عام" من كل نوع مكون.
  • إضافة مُنشئ إلى كل نوع من المكونات.
    • تتعلق OOD في المقام الأول بتغليف حالة الفصل ، ولكن نظرًا لأن هذه الفئات صغيرة / بسيطة ، لا يوجد شيء خاص للاختباء: الواجهة هي وصف للبيانات. ومع ذلك ، فإن أحد الأسباب الرئيسية التي تجعل التغليف هو الركيزة الأساسية هو أنه يسمح لنا بضمان الحقيقة الثابتة للمتغيرات الثابتة في الصف ... أو إذا تم كسر المتغير ، فأنت بحاجة فقط إلى فحص رمز التنفيذ المغلق للعثور على الخطأ. في مثال التعليمات البرمجية هذا ، يجدر إضافة مُنشئين لتطبيق متغير بسيط - يجب تهيئة جميع القيم.
  • لقد قمت بإعادة تسمية طرق "التحديث" العامة للغاية بحيث تعكس أسمائهم ما الذي يقومون به بالفعل - UpdatePosition لـ MoveComponent و ResolveCollisions for AvoidComponent.
  • قمت بإزالة ثلاث كتل ثابتة من التعليمات البرمجية تشبه القالب / الجاهزة - الرمز الذي ينشئ GameObject يحتوي على أنواع معينة من المكونات ، واستبدله بثلاث فئات C ++.
  • إزالة antipattern "تحديث باطل الظاهري".
  • بدلاً من المكونات التي تبحث عن بعضها البعض من خلال قالب "محدد موقع الخدمة" ، تقوم اللعبة بربطها بوضوح أثناء الإنشاء.

الكائنات


لذلك ، بدلاً من رمز "الجهاز الظاهري" هذا:

  // create regular objects that move for (auto i = 0; i < kObjectCount; ++i) { GameObject* go = new GameObject("object"); // position it within world bounds PositionComponent* pos = new PositionComponent(); pos->x = RandomFloat(bounds->xMin, bounds->xMax); pos->y = RandomFloat(bounds->yMin, bounds->yMax); go->AddComponent(pos); // setup a sprite for it (random sprite index from first 5), and initial white color SpriteComponent* sprite = new SpriteComponent(); sprite->colorR = 1.0f; sprite->colorG = 1.0f; sprite->colorB = 1.0f; sprite->spriteIndex = rand() % 5; sprite->scale = 1.0f; go->AddComponent(sprite); // make it move MoveComponent* move = new MoveComponent(0.5f, 0.7f); go->AddComponent(move); // make it avoid the bubble things AvoidComponent* avoid = new AvoidComponent(); go->AddComponent(avoid); s_Objects.emplace_back(go); } 

لدينا الآن رمز C ++ العادي:

 struct RegularObject { PositionComponent pos; SpriteComponent sprite; MoveComponent move; AvoidComponent avoid; RegularObject(const WorldBoundsComponent& bounds) : move(0.5f, 0.7f) // position it within world bounds , pos(RandomFloat(bounds.xMin, bounds.xMax), RandomFloat(bounds.yMin, bounds.yMax)) // setup a sprite for it (random sprite index from first 5), and initial white color , sprite(1.0f, 1.0f, 1.0f, rand() % 5, 1.0f) { } }; ... // create regular objects that move regularObject.reserve(kObjectCount); for (auto i = 0; i < kObjectCount; ++i) regularObject.emplace_back(bounds); 

الخوارزميات


تم إجراء تغيير رئيسي آخر على الخوارزميات. تذكر ، في البداية قلت أن الواجهات والخوارزميات تعمل في إطار التكافل ، وهل يجب أن تؤثر على بنية بعضها البعض؟ لذلك ، أصبح antipattern " تحديث باطل الظاهري " العدو هنا أيضا. يحتوي الكود الأولي على خوارزمية الحلقة الرئيسية ، والتي تتكون فقط من هذا:

  // go through all objects for (auto go : s_Objects) { // Update all their components go->Update(time, deltaTime); 

يمكنك القول أنها جميلة وبسيطة ، ولكن IMHO أنها سيئة للغاية. هذا يحجب كل من تدفق التحكم وتدفق البيانات داخل اللعبة. إذا كنا نريد أن نكون قادرين على فهم برنامجنا ، وإذا كنا نريد دعمه ، وإذا أردنا إضافة أشياء جديدة إليه ، فإننا نقوم بتحسينه وتنفيذه بكفاءة على العديد من مراكز المعالج ، ثم نحتاج إلى فهم كل من تدفق التحكم وتدفق البيانات. لذلك ، يجب وضع "تحديث الفراغ الافتراضي" على النار.

بدلاً من ذلك ، أنشأنا حلقة رئيسية أكثر وضوحًا ، والتي تسهل بشكل كبير فهم تدفق التحكم ( لا يزال تدفق البيانات فيه غامضًا ، لكننا سنصلح ذلك في الالتزامات التالية ).

  // Update all positions for (auto& go : s_game->regularObject) { UpdatePosition(deltaTime, go, s_game->bounds.wb); } for (auto& go : s_game->avoidThis) { UpdatePosition(deltaTime, go, s_game->bounds.wb); } // Resolve all collisions for (auto& go : s_game->regularObject) { ResolveCollisions(deltaTime, go, s_game->avoidThis); } 

عيب هذا النمط هو أنه بالنسبة لكل نوع جديد من الكائنات المضافة إلى اللعبة ، يتعين علينا إضافة عدة أسطر إلى الحلقة الرئيسية. سأعود إلى هذا في منشور لاحق من هذه السلسلة.

الأداء


هناك العديد من انتهاكات OOD الضخمة ، يتم اتخاذ بعض القرارات السيئة عند اختيار الهيكل ، وهناك العديد من الفرص للتحسين ، لكنني سأحصل عليها في المنشور التالي من السلسلة. ومع ذلك ، فمن الواضح في هذه المرحلة بالفعل أن الإصدار "OOD الثابت" يتطابق تمامًا مع رمز "ECS" النهائي أو يفوز به تقريبًا من نهاية العرض التقديمي ... وكل ما فعلناه هو مجرد أخذ كود pseudo-OOP السيئ وجعله يتوافق مع المبادئ OOP (وحذف أيضا مئات أسطر الكود)!

img

الخطوات التالية


أود هنا أن أعتبر مجموعة واسعة من القضايا ، بما في ذلك حل مشاكل OOD المتبقية ، والكائنات غير الثابتة ( البرمجة بأسلوب وظيفي ) والمزايا التي يمكن أن تجلبها في المناقشات حول تدفق البيانات ، وتمرير الرسائل ، وتطبيق منطق DOD على كود OOD الخاص بنا ، تطبيق الحكمة ذات الصلة في كود OOD ، وإزالة هذه الفئات من "الكيانات" التي انتهى بنا الأمر إليها ، واستخدام المكونات النقية فقط ، واستخدام أنماط مختلفة لتوصيل المكونات (مقارنة المؤشرات و مسؤولية تحمل) مكونات الحاويات من العالم الحقيقي، الإصدار ECS-مراجعة لأفضل الأمثل، فضلا عن مزيد من التحسين، لم يرد ذكرها في التقرير أراس (مثل خيوط المعالجة المتعددة / SIMD). الأمر لن يكون بالضرورة هذا ، وربما لن أفكر في كل ما سبق ...

إضافة


امتدت الروابط إلى المقالة إلى ما وراء دوائر مطوري الألعاب ، لذا سأضيف: " ECS " ( مقال Wikipedia هذا سيئ ، بالمناسبة ، فهو يجمع بين مفاهيم EC و ECS ، وهذا ليس هو نفسه ... ) - هذا قالب مزيف يتم تداوله داخل المجتمعات مطوري اللعبة. في الواقع ، هو نسخة من النموذج العلائقي الذي يكون فيه "الكيانات" مجرد معرفات تحدد كائنًا بدون شكل ، و "المكونات" عبارة عن صفوف في جداول محددة تشير إلى معرفات ، و "الأنظمة" هي رمز إجرائي يمكنه تعديل المكونات . تم وضع هذا "القالب" دائمًا كحل لمشكلة الاستخدام المفرط للميراث ، ولكن لم يتم ذكر أن الاستخدام المفرط للميراث ينتهك بالفعل توصيات OOP. وبالتالي سخطي. ليست هذه هي "الطريقة الحقيقية الوحيدة" لكتابة البرامج. تم تصميم المنشور للتأكد من أن الناس يتعلمون فعليًا حول مبادئ التصميم الحالية.

Source: https://habr.com/ru/post/ar441174/


All Articles