OOP sudah mati, umur panjang OOP

gambar

Sumber inspirasi


Posting ini muncul berkat publikasi terbaru oleh Aras Prantskevichus tentang laporan yang ditujukan untuk programmer junior. Ini berbicara tentang bagaimana beradaptasi dengan arsitektur ECS baru. Aras mengikuti pola yang biasa ( penjelasan di bawah ): menunjukkan contoh kode OOP yang mengerikan, dan kemudian menunjukkan bahwa model relasional ( tetapi menyebutnya "ECS" daripada relasional ) adalah alternatif yang bagus. Tidak berarti saya mengkritik Aras - Saya penggemar berat karyanya dan memuji dia untuk presentasinya yang luar biasa! Saya memilih presentasinya daripada ratusan posting lain tentang ECS ​​dari Internet karena dia melakukan upaya ekstra dan menerbitkan repositori git untuk belajar secara paralel dengan presentasi. Ini berisi "permainan" kecil sederhana, digunakan sebagai contoh pemilihan solusi arsitektur yang berbeda. Proyek kecil ini memungkinkan saya untuk menunjukkan komentar saya pada materi tertentu, jadi terima kasih, Aras!

Slide Aras tersedia di sini: http://aras-p.info/texts/files/2018Academy - ECS-DoD.pdf , dan kode ini ada di github: https://github.com/aras-p/dod-playground .

Saya tidak akan (belum?) Menganalisis arsitektur ECS yang dihasilkan dari laporan ini, tetapi fokus pada kode "OOP buruk" (mirip dengan trik boneka) dari awal. Saya akan menunjukkan bagaimana itu akan terlihat jika semua pelanggaran prinsip OOD (desain berorientasi objek, desain berorientasi objek) diperbaiki dengan benar.

Spoiler: menghilangkan semua pelanggaran OOD mengarah pada peningkatan kinerja yang mirip dengan konversi Aras ke ECS, ini juga menggunakan lebih sedikit RAM dan membutuhkan lebih sedikit baris kode daripada versi ECS!

TL; DR: Sebelum menyimpulkan bahwa OOP menyebalkan dan drive ECS, berhenti sebentar dan periksa OOD (untuk mengetahui cara menggunakan OOP dengan benar), dan juga memahami model relasional (untuk mengetahui cara menerapkan ECS dengan benar).

Saya telah mengambil bagian dalam banyak diskusi tentang ECS ​​di forum untuk waktu yang lama, sebagian karena saya tidak berpikir model ini layak ada sebagai istilah terpisah ( spoiler: ini hanya versi ad-hoc dari model relasional ), tetapi juga karena hampir setiap posting, presentasi, atau artikel yang mempromosikan pola ECS mengikuti struktur berikut:

  1. Tunjukkan contoh kode OOP yang mengerikan, implementasi yang memiliki kekurangan yang mengerikan karena penggunaan warisan yang berlebihan (yang berarti bahwa implementasi ini melanggar banyak prinsip OOD).
  2. Untuk menunjukkan bahwa komposisi adalah solusi yang lebih baik daripada warisan (dan tidak menyebutkan bahwa OOD sebenarnya memberi kita pelajaran yang sama).
  3. Tunjukkan bahwa model relasional sangat bagus untuk game (tetapi sebut saja "ECS").

Struktur seperti itu membuat saya marah karena: (A) ini adalah trik "diisi" ... membandingkan lembut ke hangat (kode buruk dan kode baik) ... dan ini tidak adil, bahkan jika dilakukan secara tidak sengaja dan tidak diharuskan untuk menunjukkan bahwa arsitektur baru itu baik; dan, yang lebih penting: (B) ia memiliki efek samping - pendekatan semacam itu menekan pengetahuan dan secara tidak sengaja mendemotivasi pembaca dari kenalan dengan studi yang dilakukan selama setengah abad. Mereka mulai menulis tentang model relasional pada 1960-an. Sepanjang 70-an dan 80-an, model ini telah meningkat secara signifikan. Pemula sering memiliki pertanyaan seperti " kelas apa yang Anda inginkan untuk memasukkan data ini? ", Dan sebagai tanggapan mereka sering mengatakan sesuatu yang kabur, seperti " Anda hanya perlu mendapatkan pengalaman dan kemudian Anda hanya belajar untuk memahami ke dalam " ... tetapi di tahun 70-an pertanyaan ini aktif dipelajari dan dalam kasus umum jawaban formal disimpulkan; ini disebut normalisasi basis data . Membuang penelitian yang ada dan menyebut ECS solusi yang benar-benar baru dan modern, Anda menyembunyikan pengetahuan ini dari pemula.

Dasar-dasar pemrograman berorientasi objek diletakkan seperti dulu, jika tidak sebelumnya ( gaya ini mulai dieksplorasi dalam karya tahun 1950-an )! Namun, pada 1990-an orientasi objek menjadi modis, viral, dan sangat cepat berubah menjadi paradigma pemrograman yang dominan. Ledakan popularitas banyak bahasa OO baru, termasuk Java dan ( versi standar ) C ++, telah terjadi. Namun, karena ini adalah hype, semua orang perlu mengetahui konsep profil tinggi ini untuk menulis di resume mereka, tetapi hanya sedikit yang benar-benar memahaminya. Bahasa-bahasa baru ini menciptakan kata kunci - kelas , virtual , perluasan , implementasi - dari banyak fitur OO, dan saya percaya itulah sebabnya pada saat itu OO dibagi menjadi dua entitas terpisah yang menjalani kehidupan mereka sendiri.

Saya akan merujuk pada penggunaan fitur bahasa yang terinspirasi OO ini sebagai " OOP " dan penggunaan teknik desain / arsitektur yang terinspirasi OO " OOD ". Semua dengan sangat cepat mengambil OOP. Lembaga pendidikan memiliki kursus OO yang memanggang programmer OOP baru ... namun, pengetahuan tentang OOD masih tertinggal.

Saya percaya bahwa kode yang menggunakan fitur bahasa OOP, tetapi tidak mengikuti prinsip-prinsip desain OOD, bukan kode OO . Kebanyakan kritik terhadap penggunaan OOP misalnya kode gutted, yang sebenarnya bukan kode OO.

Kode OOP memiliki reputasi yang sangat buruk, dan khususnya karena sebagian besar kode OOP tidak mengikuti prinsip-prinsip OOD, dan karenanya bukan kode OO yang “benar”.

Latar belakang


Seperti yang dinyatakan di atas, tahun 1990-an menjadi puncak "mode OO," dan pada saat itulah "OOP buruk" mungkin yang terburuk. Jika Anda mempelajari OOP pada saat itu, maka kemungkinan besar Anda mempelajari tentang "empat pilar OOP":

  • Abstraksi
  • Enkapsulasi
  • Polimorfisme
  • Warisan

Saya lebih suka menyebut mereka bukan empat pilar, tetapi "empat alat OOP". Ini adalah alat yang dapat Anda gunakan untuk menyelesaikan masalah. Namun, itu tidak cukup hanya untuk mengetahui bagaimana alat ini bekerja, Anda perlu tahu kapan harus menggunakannya ... Pada bagian guru, tidak bertanggung jawab untuk mengajarkan orang-orang alat baru, tidak memberi tahu mereka kapan masing-masing dari mereka layak digunakan. Pada awal 2000-an, ada resistensi terhadap penyalahgunaan aktif alat ini, semacam "gelombang kedua" pemikiran OOD. Hasilnya adalah munculnya mnemonik SOLID , yang menyediakan cara cepat untuk mengevaluasi kekuatan arsitektur. Perlu dicatat bahwa kebijaksanaan ini sebenarnya tersebar luas di tahun 90-an, tetapi belum menerima akronim keren, yang memungkinkan mereka untuk diperbaiki sebagai lima prinsip dasar ...

  • Prinsip tanggung jawab tunggal ( prinsip tanggung jawab tunggal ). Setiap kelas seharusnya hanya memiliki satu alasan untuk perubahan. Jika kelas "A" memiliki dua tanggung jawab, maka Anda perlu membuat kelas "B" dan "C" untuk memproses masing-masing secara individual, dan kemudian membuat "A" dari "B" dan "C".
  • Prinsip keterbukaan / penutupan ( O pen / prinsip tertutup). Perangkat lunak berubah seiring waktu ( mis. Dukungannya penting ). Cobalah untuk menempatkan bagian-bagian yang paling mungkin berubah dalam implementasi ( mis., Di kelas-kelas tertentu ) dan membuat antarmuka berdasarkan bagian-bagian yang tidak mungkin berubah ( misalnya, kelas dasar abstrak ).
  • Prinsip substitusi Barbara Liskov . Setiap implementasi antarmuka harus 100% memenuhi persyaratan antarmuka ini, mis. algoritma apa pun yang bekerja dengan antarmuka harus bekerja dengan implementasi apa pun.
  • Prinsip pemisahan antarmuka ( I nterface segregation principle). Buat antarmuka sekecil mungkin sehingga setiap bagian dari kode "tahu" tentang jumlah basis kode terkecil, misalnya, menghindari ketergantungan yang tidak perlu. Tip ini juga bagus untuk C ++, di mana waktu kompilasi menjadi besar jika Anda tidak mengikutinya.
  • Prinsip inversi dependensi (prinsip inversi D dependensi). Alih-alih dua implementasi spesifik yang berkomunikasi langsung (dan saling bergantung), mereka biasanya dapat dipisahkan dengan memformalkan antarmuka komunikasi mereka sebagai kelas ketiga, yang digunakan sebagai antarmuka di antara mereka. Ini bisa menjadi kelas dasar abstrak yang mendefinisikan panggilan metode yang digunakan di antara mereka, atau bahkan hanya struktur POD yang mendefinisikan data yang ditransfer di antara mereka.
  • Prinsip lain tidak termasuk dalam akronim SOLID, tetapi saya yakin itu sangat penting: "Lebih suka komposisi daripada warisan" (Prinsip penggunaan kembali komposit). Komposisi adalah pilihan yang tepat secara default . Warisan harus diserahkan untuk kasus-kasus ketika itu benar-benar diperlukan.

Jadi kami mendapatkan SOLID-C (++) :)

Di bawah ini saya akan merujuk pada prinsip-prinsip ini, menyebutnya akronim - SRP, OCP, LSP, ISP, DIP, CRP ...

Beberapa catatan lagi:

  • Dalam OOD, konsep antarmuka dan implementasi tidak dapat dikaitkan dengan kata kunci OOP tertentu. Di C ++, kita sering membuat antarmuka dengan kelas dasar abstrak dan fungsi virtual , dan kemudian implementasi yang diwarisi dari kelas dasar ini ... tapi ini hanya satu cara khusus untuk menerapkan prinsip antarmuka. Dalam C ++, kita juga dapat menggunakan PIMPL , pointer buram , mengetik bebek , mengetik , dll ... Anda dapat membuat struktur OOD dan kemudian menerapkannya dalam C, di mana tidak ada kata kunci bahasa OOP sama sekali! Jadi ketika saya berbicara tentang antarmuka , saya tidak harus berarti fungsi virtual - saya berbicara tentang prinsip menyembunyikan implementasi . Antarmuka bisa bersifat polimorfik , tetapi lebih sering daripada tidak! Polimorfisme sangat jarang digunakan dengan benar, tetapi antarmuka adalah konsep dasar untuk semua perangkat lunak.
    • Seperti yang saya jelaskan di atas, jika Anda membuat struktur POD yang hanya menyimpan beberapa data untuk transmisi dari satu kelas ke kelas lain, maka struktur ini digunakan sebagai antarmuka - ini adalah deskripsi formal data .
    • Bahkan jika Anda hanya membuat satu kelas terpisah dengan bagian publik dan pribadi , maka semua yang ada di bagian umum adalah antarmuka , dan segala sesuatu di bagian pribadi adalah implementasi .
  • Warisan sebenarnya memiliki (setidaknya) dua jenis - warisan antarmuka dan warisan implementasi.
    • Dalam C ++, warisan antarmuka termasuk kelas dasar abstrak dengan fungsi virtual murni, PIMPL, typedef bersyarat. Di Jawa, pewarisan antarmuka diekspresikan melalui kata kunci implement .
    • Dalam C ++, warisan implementasi terjadi setiap kali kelas dasar berisi sesuatu selain fungsi virtual murni. Di Jawa, warisan implementasi dinyatakan menggunakan kata kunci extends .
    • OOD memiliki banyak aturan untuk mewarisi antarmuka, tetapi warisan implementasi biasanya layak dipertimbangkan sebagai "kode dengan gigitan" !

Dan akhirnya, saya harus menunjukkan beberapa contoh pelatihan OOP yang mengerikan dan bagaimana hal itu mengarah pada kode buruk dalam kehidupan nyata (dan reputasi OOP yang buruk).

  1. Ketika Anda diajarkan hierarki / warisan, Anda mungkin telah diberi tugas yang sama: Misalkan Anda memiliki aplikasi universitas yang berisi direktori siswa dan staf. Anda bisa membuat kelas dasar Person, dan kemudian kelas Student dan kelas Staff, diwarisi dari Person.

    Tidak, tidak, tidak. Di sini saya akan menghentikan Anda. Implikasi tak terucapkan dari prinsip LSP adalah bahwa hierarki kelas dan algoritma yang memprosesnya adalah simbiosis. Ini adalah dua bagian dari keseluruhan program. OOP adalah perpanjangan dari pemrograman prosedural, dan itu terutama terkait dengan prosedur ini. Jika kita tidak tahu jenis algoritma apa yang akan bekerja dengan Siswa dan Staf ( dan algoritma mana yang akan disederhanakan karena polimorfisme ), maka akan sepenuhnya tidak bertanggung jawab untuk mulai membuat struktur hierarki kelas. Pertama, Anda perlu mengetahui algoritma dan data.
  2. Ketika Anda diajarkan hierarki / warisan, Anda mungkin diberikan tugas yang sama: Misalkan Anda memiliki kelas bentuk. Kami juga memiliki kotak dan persegi panjang sebagai subclass. Haruskah persegi menjadi persegi panjang, atau persegi panjang persegi?

    Ini sebenarnya adalah contoh yang baik untuk menunjukkan perbedaan antara warisan implementasi dan warisan antarmuka.
    • Jika Anda menggunakan pendekatan warisan implementasi, maka Anda sepenuhnya mengabaikan LSP dan, dari sudut pandang praktis, pikirkan tentang kemungkinan menggunakan kembali kode menggunakan warisan sebagai alat.

      Dari sudut pandang ini, berikut ini sangat logis:

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

      Persegi hanya memiliki lebar, dan persegi panjang memiliki lebar + tinggi, yaitu, memperluas persegi dengan komponen tinggi, kita mendapatkan persegi panjang!
      • Seperti yang mungkin sudah Anda duga, OOD mengatakan bahwa melakukan ini ( mungkin ) salah. Saya mengatakan "mungkin" karena di sini Anda dapat berdebat tentang karakteristik antarmuka yang tersirat ... oh well.

        Kotak selalu memiliki tinggi dan lebar yang sama, jadi dari antarmuka kotak itu benar untuk mengasumsikan bahwa area tersebut “lebar * lebar”.

        Mewarisi dari persegi, kelas persegi panjang (menurut LSP) harus mematuhi aturan antarmuka persegi. Algoritma apa pun yang bekerja dengan benar untuk persegi juga harus bekerja dengan benar untuk persegi panjang.
      • Ambil algoritma lain:

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

        Ini akan bekerja dengan benar untuk kotak (menghitung jumlah area mereka), tetapi tidak akan bekerja untuk persegi panjang.

        Oleh karena itu, persegi panjang melanggar prinsip LSP.
    • Jika Anda menggunakan pendekatan pewarisan antarmuka, baik Persegi maupun Persegi Panjang tidak akan saling mewarisi. Antarmuka untuk persegi dan persegi panjang sebenarnya berbeda, dan satu bukan superset dari yang lain.
    • Oleh karena itu, OOD mencegah penggunaan warisan implementasi. Seperti yang dinyatakan di atas, jika Anda ingin menggunakan kembali kode, maka OOD mengatakan bahwa komposisi adalah pilihan yang tepat!
      • Jadi versi yang benar dari kode (buruk) di atas untuk hierarki warisan implementasi C ++ terlihat seperti ini:

         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; }; 

        • "Publik virtual" di Jawa berarti "mengimplementasikan". Digunakan saat mengimplementasikan antarmuka.
        • "Privat" memungkinkan Anda untuk memperluas kelas dasar tanpa mewarisi antarmuka - dalam hal ini, persegi panjang bukan persegi, meskipun mewarisi darinya.
      • Saya tidak merekomendasikan penulisan kode seperti itu, tetapi jika Anda ingin menggunakan warisan implementasi, maka Anda perlu melakukan hal itu!

TL; DR - kelas OOP Anda memberi tahu Anda seperti apa warisan itu. Kelas OOD Anda yang hilang seharusnya memberi tahu Anda untuk tidak menggunakannya 99% dari waktu!

Konsep Entitas / Komponen


Setelah berurusan dengan prasyarat, mari kita beralih ke tempat Aras mulai - ke titik awal yang disebut "khas OOP".

Tetapi sebagai permulaan, satu tambahan lagi - Aras menyebut kode ini "OOP tradisional", dan saya ingin menolaknya. Kode ini mungkin tipikal untuk OOP di dunia nyata, tetapi, seperti contoh di atas, itu melanggar semua jenis prinsip dasar OO, jadi tidak boleh dianggap sebagai tradisional sama sekali.

Saya akan mulai dengan komit pertama sebelum dia mulai membuat ulang struktur menuju ECS: "Jadikan itu berfungsi pada Windows lagi" 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; } 

Ya, sulit untuk mengetahui ratusan baris kode segera, jadi mari kita mulai secara bertahap ... Kita perlu satu aspek lagi dari prasyarat - dalam permainan tahun 90-an itu populer untuk menggunakan warisan untuk menyelesaikan semua masalah penggunaan kembali kode. Anda memiliki Entitas, Karakter yang dapat diperluas, Pemain yang dapat diperluas, dan Monster, dan seterusnya ... Ini adalah warisan implementasi, seperti yang kami jelaskan sebelumnya ( "kode dengan choke" ), dan sepertinya itu adalah hak untuk memulai dengan itu, tetapi sebagai hasilnya itu mengarah ke sangat basis kode tidak fleksibel. Karena OOD memiliki prinsip "komposisi lebih dari warisan" yang dijelaskan di atas. Jadi, pada tahun 2000-an, prinsip "komposisi lebih dari warisan" menjadi populer, dan pengembang game mulai menulis kode yang sama.

Apa yang dilakukan kode ini? Yah tidak bagus : D

Singkatnya, kode ini mengimplementasikan kembali fitur bahasa yang ada - komposisi sebagai pustaka runtime, dan bukan sebagai fitur bahasa. Anda dapat membayangkan ini seolah-olah kode tersebut benar-benar menciptakan bahasa logam baru di atas C ++ dan mesin virtual (VM) untuk mengeksekusi bahasa logam ini. Dalam game demo Aras, kode ini tidak diperlukan ( kami akan segera menghapusnya! ) Dan hanya berfungsi untuk mengurangi kinerja game sekitar 10 kali.

Tapi apa yang sebenarnya dia lakukan? Ini adalah konsep " E ntity / C omponent system" ( kadang-kadang karena beberapa alasan disebut " E ntity / C omponent system" ), tetapi sama sekali berbeda dari konsep " E ntity C omponent S ystem "(" entitas-komponen-sistem ") ( yang karena alasan yang jelas tidak pernah disebut" E ntity C omponent S sistem ystem ). Ini memformalkan beberapa prinsip "EC":

  • gim akan dibangun dari tidak memiliki fitur "Entitas" ("Entity") ( dalam contoh ini disebut GameObjects), yang terdiri dari "komponen" ("Komponen").
  • GameObjects menerapkan pola “service locator” - komponen anak mereka akan ditanyakan berdasarkan tipe.
  • Komponen tahu GameObject mana mereka milik - mereka dapat menemukan komponen yang berada pada tingkat yang sama dengan mereka dengan meminta GameObject induk.
  • Komposisi dapat hanya satu tingkat dalam ( komponen tidak dapat memiliki komponen turunannya sendiri, GameObjects tidak dapat memiliki turiran GameObjects anak ).
  • GameObject hanya dapat memiliki satu komponen dari setiap jenis ( dalam beberapa kerangka kerja ini merupakan persyaratan wajib, di lain tidak ).
  • Setiap komponen (mungkin) berubah dari waktu ke waktu dalam beberapa cara yang tidak ditentukan, sehingga antarmuka berisi "Pembaruan void virtual".
  • GameObjects milik adegan yang dapat mengeksekusi permintaan pada semua GameObjects (dan karenanya semua komponen).

Konsep serupa sangat populer di tahun 2000-an, dan meskipun memiliki keterbatasan, ternyata cukup fleksibel untuk membuat game yang tak terhitung jumlahnya baik dulu maupun sekarang.

Namun, ini tidak wajib. Bahasa pemrograman Anda sudah memiliki dukungan untuk komposisi sebagai fitur bahasa - tidak perlu konsep kembung untuk mengaksesnya ... Mengapa, kemudian, apakah konsep-konsep ini ada? Yah, jujur ​​saja, mereka memungkinkan Anda untuk melakukan komposisi dinamis saat runtime . Alih-alih mendefinisikan jenis GameObject dalam kode, Anda dapat memuatnya dari file data. Dan ini sangat nyaman, karena memungkinkan desainer game / level untuk membuat jenis objek mereka sendiri ... Namun, di sebagian besar proyek game ada sangat sedikit desainer dan secara harfiah sepasukan programmer, jadi saya berpendapat bahwa ini adalah peluang penting. Lebih buruk lagi, ini bukan satu-satunya cara Anda dapat menerapkan komposisi pada saat run time! Sebagai contoh, Unity menggunakan C # sebagai “bahasa scripting” -nya, dan banyak game lain menggunakan alternatifnya, misalnya Lua - alat yang nyaman bagi para desainer dapat menghasilkan kode C # / Lua untuk mendefinisikan objek game baru tanpa perlu konsep kembung seperti itu! Kami akan menambahkan kembali "fitur" ini di posting berikutnya, dan membuatnya sehingga tidak ada biaya sepuluh kali lipat dalam penurunan kinerja ...

Mari kita evaluasi kode ini sesuai dengan OOD:

  • GameObject :: GetComponent menggunakan dynamic_cast. Kebanyakan orang akan memberi tahu Anda bahwa dynamic_cast adalah "kode dengan choke," petunjuk besar bahwa Anda memiliki bug di suatu tempat. Saya akan mengatakan ini - ini adalah bukti bahwa Anda melanggar LSP - Anda memiliki beberapa jenis algoritma yang bekerja dengan antarmuka dasar, tetapi perlu mengetahui detail implementasi yang berbeda. Untuk alasan khusus ini, kode ini berbau busuk.
  • GameObject, pada prinsipnya, tidak buruk, jika Anda membayangkan bahwa ia mengimplementasikan templat “service locator” ... tetapi jika Anda melangkah lebih jauh daripada kritik dari sudut pandang OOD, templat ini menciptakan koneksi implisit antara bagian-bagian dari proyek, dan saya pikir ( tanpa tautan ke Wikipedia yang dapat mendukung saya dengan pengetahuan dari ilmu komputer ) bahwa saluran komunikasi implisit adalah antipattern , dan mereka harus lebih memilih saluran komunikasi eksplisit. Argumen yang sama berlaku untuk "konsep acara" yang terkadang digunakan dalam game ...
  • Saya ingin menyatakan bahwa komponen merupakan pelanggaran terhadap SRP karena antarmuka ( virtual void Update (time) ) terlalu lebar. Penggunaan "virtual void Update" dalam pengembangan game ada di mana-mana, tetapi saya juga akan mengatakan bahwa itu adalah antipattern. Perangkat lunak yang baik harus memungkinkan Anda untuk dengan mudah memikirkan aliran kontrol dan aliran data. Menempatkan setiap elemen kode gameplay di belakang panggilan "virtual void Update" sepenuhnya dan sepenuhnya mengaburkan aliran kontrol dan aliran data. IMHO, efek samping tak terlihat, juga disebut efek jarak jauh , adalah beberapa sumber bug yang paling umum, dan "Pembaruan batal virtual" memastikan bahwa hampir semuanya akan menjadi efek samping yang tak terlihat.
  • Meskipun tujuan dari kelas Komponen adalah untuk memungkinkan komposisi, ia melakukannya melalui warisan, yang merupakan pelanggaran CRP .
  • Satu-satunya sisi baik dari contoh ini adalah bahwa kode permainan berlebihan untuk mematuhi prinsip-prinsip SRP dan ISP - itu dibagi menjadi banyak komponen sederhana dengan tanggung jawab yang sangat sedikit, yang bagus untuk menggunakan kembali kode.

    Namun, ia tidak begitu baik dalam memelihara DIP - banyak komponen memiliki pengetahuan langsung satu sama lain.

Jadi, semua kode yang ditunjukkan di atas sebenarnya bisa dihapus. Seluruh struktur ini. Hapus GameObject (juga disebut Entity dalam kerangka kerja lain), hapus Komponen, hapus FindOfType. Ini adalah bagian dari VM yang tidak berguna yang melanggar prinsip OOD dan sangat memperlambat permainan kami.

Komposisi tanpa kerangka kerja (mis. Menggunakan fitur dari bahasa pemrograman itu sendiri)


Jika kami menghapus kerangka kerja komposisi dan kami tidak memiliki kelas dasar Komponen, bagaimana GameObject kami akan menggunakan komposisi dan terdiri dari komponen? Seperti judulnya, alih-alih menulis VM kembung ini dan membuat GameObjects dalam bahasa logam yang aneh di atasnya, mari kita menulisnya dalam C ++ karena kita adalah programmer game dan ini benar-benar tugas kita.

Berikut adalah komit yang menghapus kerangka Entity / Component: https://github.com/hodgman/dod-playground/commit/f42290d0217d700dea2ed002f2f3b1dc45e8c27c

Berikut ini adalah versi asli kode sumber: https://github.com/hodgman/dod-playground/blob/3529f232510c95f53112bbfff87df6bbc6aa1fae/source/game.cpp

Berikut ini adalah versi kode sumber yang dimodifikasi: https://github.com/hodgman/dod-playground/blob/f42290d0217d700dea2ed002f2f3b1dc45e8c27c/source/game.cpp

Secara singkat tentang perubahan:

  • Dihapus ": Komponen publik" dari setiap jenis komponen.
  • Menambahkan konstruktor ke setiap jenis komponen.
    • OOD terutama tentang merangkum keadaan kelas, tetapi karena kelas-kelas ini sangat kecil / sederhana, tidak ada yang istimewa untuk disembunyikan: antarmuka adalah deskripsi data. Namun, salah satu alasan utama bahwa enkapsulasi adalah pilar utama adalah bahwa enkapsulasi memungkinkan kami untuk menjamin kebenaran konstan dari invarian kelas ... atau jika invarian rusak, maka Anda hanya perlu memeriksa kode implementasi enkapsulasi untuk menemukan kesalahan. Dalam contoh kode ini, ada baiknya menambahkan konstruktor untuk mengimplementasikan invarian sederhana - semua nilai harus diinisialisasi.
  • Saya mengganti nama metode "Pembaruan" yang terlalu umum untuk membuat nama mereka mencerminkan apa yang sebenarnya mereka lakukan - UpdatePosition untuk MoveComponent dan ResolveCollisions untuk AvoidComponent.
  • Saya menghapus tiga blok kode yang menyerupai kode templat / cetakan - kode yang membuat GameObject berisi jenis Komponen tertentu, dan menggantinya dengan tiga kelas C ++.
  • Dihilangkan antipattern "Pembaruan virtual void".
  • Alih-alih komponen mencari satu sama lain melalui templat "pencari lokasi", game secara eksplisit mengikatnya bersama selama konstruksi.

Benda-benda


Karenanya, alih-alih kode "mesin virtual" ini:

  // 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); } 

Kami sekarang memiliki kode C ++ reguler:

 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); 

Algoritma


Perubahan besar lainnya telah dibuat untuk algoritma. Ingat, pada awalnya saya mengatakan bahwa antarmuka dan algoritma bekerja dalam simbiosis, dan haruskah memengaruhi struktur masing-masing? Jadi, " Pembaruan void " antipattern telah menjadi musuh di sini juga. Kode awal berisi algoritma loop utama, yang hanya terdiri dari ini:

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

Anda dapat berpendapat bahwa itu indah dan sederhana, tetapi IMHO itu sangat, sangat buruk. Ini benar-benar mengaburkan baik aliran kontrol maupun aliran data dalam game. Jika kita ingin dapat memahami perangkat lunak kita, jika kita ingin mendukungnya, jika kita ingin menambahkan hal-hal baru ke dalamnya, mengoptimalkannya, menjalankannya secara efisien pada beberapa inti prosesor, maka kita perlu memahami aliran kontrol dan aliran data. Oleh karena itu, "Pembaruan void virtual" harus dibakar.

Sebagai gantinya, kami membuat loop utama yang lebih eksplisit, yang sangat menyederhanakan pemahaman tentang aliran kontrol ( aliran data di dalamnya masih dikaburkan, tetapi kami akan memperbaikinya dalam komitmen berikut ).

  // 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); } 

Kerugian dari gaya ini adalah bahwa untuk setiap jenis objek baru yang ditambahkan ke permainan, kita harus menambahkan beberapa baris ke loop utama. Saya akan kembali ke ini dalam posting selanjutnya dari seri ini.

Performa


Ada banyak pelanggaran OOD besar, beberapa keputusan buruk dibuat ketika memilih struktur, dan ada banyak peluang untuk optimisasi, tetapi saya akan membahasnya pada posting berikutnya dari seri ini. Namun, sudah pada tahap ini jelas bahwa versi dengan "OOD tetap" hampir sepenuhnya cocok atau memenangkan kode "ECS" terakhir dari akhir presentasi ... Dan yang kami lakukan hanyalah mengambil kode pseudo-OOP yang buruk dan membuatnya mematuhi prinsip-prinsip OOP (dan juga menghapus seratus baris kode)!

img

Langkah selanjutnya


Di sini saya ingin mempertimbangkan berbagai masalah yang lebih luas, termasuk menyelesaikan masalah OOD yang tersisa, objek yang tidak dapat diubah ( pemrograman dengan gaya fungsional ) dan keuntungan yang dapat mereka bawa dalam diskusi tentang aliran data, pengiriman pesan, penerapan logika DOD ke kode OOD kami, menerapkan kebijaksanaan yang relevan dalam kode OOD, menghapus kelas-kelas "entitas" yang akhirnya kita dapatkan, dan hanya menggunakan komponen murni, menggunakan gaya yang berbeda untuk menghubungkan komponen (membandingkan pointer dan tanggung jawab membawa) komponen kontainer dari dunia nyata, versi ECS-revisi untuk optimasi yang lebih baik, serta optimasi lebih lanjut, tidak disebutkan dalam laporan Aras (seperti multi-threading / SIMD). Urutan belum tentu seperti ini, dan mungkin saya tidak akan mempertimbangkan semua hal di atas ...

Selain itu


Tautan ke artikel telah menyebar ke luar lingkaran pengembang game, jadi saya akan menambahkan: " ECS " ( artikel Wikipedia ini buruk, omong-omong, ia menggabungkan konsep EC dan ECS, dan ini tidak sama ... ) - ini adalah template palsu yang beredar di dalam komunitas pengembang game. Pada kenyataannya, ini adalah versi dari model relasional di mana "entitas" hanya ID yang menunjuk objek tak berbentuk, "komponen" adalah baris dalam tabel tertentu yang merujuk ID, dan "sistem" adalah kode prosedural yang dapat memodifikasi komponen . "Templat" ini selalu diposisikan sebagai solusi untuk masalah penggunaan warisan yang berlebihan, tetapi tidak disebutkan bahwa penggunaan warisan yang berlebihan justru melanggar rekomendasi OOP. Karena itu saya marah. Ini bukan "satu-satunya cara yang benar" untuk menulis perangkat lunak. Pos dirancang untuk memastikan bahwa orang benar-benar belajar tentang prinsip-prinsip desain yang ada.

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


All Articles