【CSDN編者按】ECS(ECS,Entity–component–system,實體組件系統,是一種主要用於遊戲開發的架構模式),是在遊戲開發社區廣為流傳的偽模式,它基本上是關係模型的翻版,其中「實體」是ID,表示無形的對象,「組件」是特定表中的行,該行引用一個ID,而「系統」是更改組件的過程性的代碼。
這種「模式」經常會導致繼承的過度使用,而不會提及過度使用繼承,其實違反了OOP(OOP,Object Oriented Programming,面向對象編程,是一種計算機編程架構)原則。那麼如何避免這種情況呢?本文作者,會給大家介紹下真正的設計指南。
靈感
這篇文章的靈感,來自最近Unity的知名工程師Aras Pranckevičius一次面向初級開發者的公開演講,演講的目的是讓他們熟悉新的「ECS」架構的一些術語。
Aras使用了非常典型的模式,他展示了一些非常糟糕的OOP代碼,然後表示關係模型是個更好的方案(不過這裡的關係模型稱為「ECS」)。我並不是要批評Aras,實際上我很喜歡他的作品,也非常讚賞他的演講!
我選擇他的演講而不是網上幾百篇關於ECS的其他帖子的原因是,他的演講給出了代碼,裡面有個非常簡單的小「遊戲」用來演示各種不同的架構。這個小項目節省了我很多精力,可以方便我闡述自己的觀點,所以,謝謝Aras!
Aras幻燈片的連結:
代碼連結:
我不想分析他的演講最後提出的ECS架構,只想就他批判的「壞的OOP」代碼來說說我的看法。我想論述的是,如果我們能改正所有違反OOD(面向對象設計)原則的地方,會變成什麼樣子。
劇透警告:改正違反OOD的代碼,能得到與Aras的ECS版本相似的性能改進,而且還能比ECS版本佔用更少的內存,代碼量也更少!
概括為一句話:如果你認為OOP是垃圾、而ECS才是王道,那麼先去了解一下OOD(即怎樣正確使用OOP),再學學關係模型(了解怎樣正確使用ECS)。
我一直很反感論壇上的許多關於ECS的帖子,部分原因是我覺得ECS夠不上單獨弄個術語的程度(劇透:它只不過是關係模型的專用版本),另一部分原因是所有宣揚ECS模式的帖子、幻燈片或文章都有著同樣的結構:
這種結構的文章很讓我惱火,因為:
偷換概念。它對比的對象風馬牛不相及,這一點很難讓人信服,雖然可能是出於無意,卻也並不能證明它提出的新架構更好。
它會產生副作用,貶低知識,並且無意間打擊讀者去學習該領域長達五十多年的研究結果。關係模型第一次是在上世紀六十年代提出的。七八十年代深入研究了該模型的各個方面。新手經常提出的問題是「這個數據應該放到哪個類裡?」而該問題的答案通常很模糊,「等你有了更多經驗以後自然而然就知道了」。但在七十年代,這個問題深入地研究,並用通用的、正式的方式解決了,即資料庫的正規化(https://en.wikipedia.org/wiki/Database_normalization#Normal_forms)。忽略已有的研究成果把ECS當作全新的方案來展示,就等於把這些知識藏起來不告訴新手程式設計師。
面向對象編程的歷史也同樣悠久(實際上比關係模型還要久,它的概念從上世紀五十年代就出現了)!但是,直到九十年代,OO才得到人們的關注,成了主流的編程範式。各種各樣的OO語言雨後春筍般地出現,其中就包括Java和(標準版本的)C++。
但由於它是被炒作起來的,所以每個人只是把這個詞寫到自己的簡歷上,真正懂得它的人少之又少。這些新語言引入了許多關鍵字來實現OO的功能,如CLASS、Virtual、extends、implements,我認為自此OO分成了兩派。
後面我把擁有OO思想的程式語言稱為「OOP」,使用OO思想的設計和架構技術稱為「OOD」。每個人學習OOP都很快,學校裡也說OO類非常高效,很適合新手程式設計師……但是,OOD的知識卻被拋在了後面。
我認為,使用OOP的語言特性卻不遵循OOD設計規則的代碼,不是OO代碼。大多數反對OO的文章所攻擊的代碼都不是真正的OO代碼。
OOP代碼的名聲很差,其中部分原因就是大多數OOP代碼沒有遵循OOD原則,所以其實不是真正的OO代碼。
背景
前面說過,上世紀九十年代是OO的大爆炸時代,那個時期的「壞OOP代碼」可能是最糟糕的。如果你在那個時期學習了OOP,那麼你很可能學過下面的「OOP四大支柱」:
我更傾向於稱他們為「OOP的四大工具」而不是四大支柱。這些工具可以用來解決問題。但是,只學習工具的用法是不夠的,你必須知道什麼時候應該使用它們。
教育者只傳授工具的用法而不傳授工具的使用場景,是不負責任的表現。在二十一世紀初,第二波OOD思潮出現,工具的濫用得到了一定的抑制。
當時提出了SOLID(https://en.wikipedia.org/wiki/SOLID)思想體系來快速評價設計的質量。注意其中的許多建議其實在上世紀九十年代就廣為流傳了,但當時並沒有像「SOLID」這種簡單好記的詞語將其提煉成五條核心原則……
依賴倒置原則(Dependency Inversion Principle)。兩個具體的實現直接通信並且互相依賴的模式,可以通過將兩者之間的通信接口正規化成第三個類,將這個類作為兩者之間的接口的方式解耦合。這第三個類可以是個抽象積累,定義兩者之間需要的調用,甚至可以只是個定義兩者間傳遞數據的簡單數據結構。
這一條不在SOLID中,但我認為這一條同樣重要:組合重用原則(Composite Reuse Principle)。默認情況下應當使用組合,只有在必須時才使用繼承。
這才是我們的SOLID C++。
接下來我用三字母的簡稱來代表這些原則:SRP、OCP、LSP、ISP、DIP、CRP。
一點其他看法:
即使只是將一個類分成了公有和私有兩部分,那麼所有公有部分中的東西都是接口,而私有部分的都是實現。
繼承實際上(至少)有兩種類型:接口繼承,實現繼承。
在C++中,接口繼承包括:利用純虛函數實現的抽象基類、PIMPL、條件typedef。在Java中,接口繼承用implements關鍵字表示。
在C++中,實現繼承發生在一切基類包含純虛函數以外的內容的情況。在Java中,實現繼承用Extends關鍵字表示。
OOD定義了許多關於接口繼承的規則,但實現繼承通常是不祥的預兆(https://en.wikipedia.org/wiki/Code_smell)。
最後,我也許應該給出一些糟糕的OOP教育的例子,以及這種教育導致的糟糕代碼(以及OOP的壞名聲)。
在學習層次結構和繼承時,你很可能學習過以下類似的例子:
假設我們有個學校的應用,其中包括學生和教職工的名錄。於是我們可以用Person作為基類,然後從Person繼承出Student和Staff兩個類。
這完全錯了。先等一下。LSP(裡氏替換原則)指出,類的層次結構和操作它們的算法是共生(symbiotic)的。它們是一個完整程序的兩個部分。OOP是過程式編程的擴展,它的主要結構依然是過程。所以,如果不知道Student和Staff上的算法(以及哪些算法可以用多態來簡化),那麼設計類層次結構是不負責任的。必須首先有算法和數據才能繼續。
在學習層次結構和繼承時,你很可能學習過以下類似的例子:
假設你有個形狀的類。它的子類可以有正方形和矩形。那麼,應該是正方形is-a矩形,還是矩形is-a正方形?
這個例子其實很好地演示了實現繼承和接口繼承之間的區別。
如果你考慮的是實現繼承,那麼你完全沒有考慮LSP,只不過是把繼承當做復用代碼的工具而已。從這個觀點來看,下面的定義是完全合理的: struct Square { int width; }; struct Rectangle: Square { int height; }; 正方形只有寬度,而矩形在寬度之外還有高度,所以用高度擴展正方形,就能得到矩形!
你一定猜到了,OOD認為這種設計(很可能)錯了。我說可能的原因是你還可以爭論其中暗含的接口……不過這無關緊要。
正方形的寬度和高度永遠相同,所以從正方形的接口的角度來看,我們完全可以認為它的面積是「寬度×寬度」。
如果矩形從正方形繼承,那麼根據LSP,矩形必須遵守正方形接口的規則。所有能在正方形上正確工作的算法必須能在矩形上正確工作。
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; };
總之一句話,OOP課程教給你什麼是繼承,而你沒有學習的OOD課程本應教給你在99%的情況下不要使用繼承!
實體 / 組件框架
有了這些背景之後,我們來看看Aras開頭提出的那些所謂的「常見的OOP」。
實際上我還要說一句,Aras稱這些代碼為「傳統的OOP」,而我並不這樣認為。這些代碼也許是人們常用的OOP,但如上所述,這些代碼破壞了所有核心的OO規則,所以它們完全不是傳統的OOP。
我們從最早的提交開始——當時他還沒有把設計修改成ECS:"Make it work on Windows again"(https://github.com/aras-p/dod-playground/blob/3529f232510c95f53112bbfff87df6bbc6aa1fae/source/game.cpp):
class GameObject;class Component;typedef std::vector<Component*> ComponentVector;typedef std::vector<GameObject*> GameObjectVector;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;};class GameObject{public:
GameObject(const std::string&& name) : m_Name(name) { }
~GameObject()
{
for (auto c : m_Components) delete c;
}
template<typename T>
T* GetComponent()
{
for (auto i : m_Components)
{
T* c = dynamic_cast<T*>(i);
if (c != nullptr)
return c;
}
return nullptr;
}
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;};static GameObjectVector s_Objects;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;}template<typename T>static T* FindOfType(){
for (auto go : s_Objects)
{
T* c = go->GetComponent<T>();
if (c != nullptr)
return c;
}
return nullptr;}
OK,代碼很難一下子看懂,所以我們來分析一下……不過還需要另一個背景:在上世紀九十年代,使用繼承解決所有代碼重用問題,這在遊戲界是通用的做法。首先有個Entity,然後擴展成Character,再擴展成Player和Monster等等……
如前所述,這是實現繼承,儘管一開始看起來不錯,但最後會導致極其不靈活的代碼。因此,OOD才有「使用組合而不是繼承」的規則。因此,在本世紀初「使用組合而不是繼承」的規則變得流行後,遊戲開發才開始寫這種代碼。
這段代碼實現了什麼?總的來說都不好,呵呵。
簡單來說,這段代碼通過運行時函數庫重新實現了組合的功能,而不是利用語言特性來實現。
你可以認為,這段代碼在C++之上構建了一種新的語言,以及運行這種語言的編譯器。Aras的示例遊戲並沒有用到這段代碼(我們一會兒就會把它都刪掉了!),它唯一的用途是將遊戲的性能降低10倍。
它實際上做了什麼?這是個「實體/組件」(Entity/Component)框架(有時候會被誤稱為「實體/組件系統」),但它跟「實體組件系統」(Entity Component System)框架完全沒關係(後者很顯然不會被稱為「實體組件系統」)。
這種框架在本世紀初非常流行,儘管它很嚴格,但提供了足夠的靈活性來支持無數的遊戲,直到今天依然如此。
但是,這種框架並不是必須的。程式語言的特性中已經提供了組合,不需要再用框架實現一遍……那為什麼還需要這些框架?那是因為框架可以實現動態的、運行時的組合。
GameObject無須硬編碼,可以從數據文件中加載。這樣遊戲設計師和關卡設計師就可以創建自己的對象……但是,在大多數遊戲項目中,項目的設計師都很少,而程式設計師很多,所以我認為這並不是關鍵的功能。何況,還有許多其他方式來實現運行時組合!
例如,Unity使用C#作為其「腳本語言」,許多其他遊戲使用Lua等替代品,所以面向設計師的工具可以生成C#/Lua代碼來定義新的遊戲對象,而不需要這些框架!
我們會在以後的文章裡重新加入運行時組合的「功能」,但要同時避免10倍的性能開銷……
如果我們用OOD的觀點評價這段代碼:
GameObject:GetComponent使用了dynamic_cast。大多數人都會告訴你,dynamic_cast是一種代碼異味——它強烈地暗示著代碼什麼地方有問題。我認為,它預示著你的代碼違反了LSP——某個算法在操作基類的解耦,但它要求了解不同實現的細節。這正是代碼異味的原因。
GameObject還算可以,如果認為它實現了服務定位器模式的話……但是從OOD的觀點來看,這種模式在項目的不同部分之間建立了隱含的聯繫,而且我認為(我找不到能用計算機科學的知識支持我的維基連結)這種隱含的通信通道是一種反面模式(https://en.wikipedia.org/wiki/Anti-pattern),應當使用明示的通信通道。這種觀點同樣適用於一些遊戲中使用的「事件框架」……
我認為,Component違反了SRP(單一責任原則),因為它的接口( virtual void Update(time))太寬泛了。「virtual void Update」在遊戲開發中非常普遍,但我還是要說這是個反面模式。好的軟體應該可以很容易地論證其控制流和數據流。將一切遊戲代碼放在「virtual void Update」調用後面完全混淆了控制流和數據流。在我看來,不可見的副作用(https://en.wikipedia.org/wiki/Side_effect_(computer_science))——也稱為「遠隔作用」(https://en.wikipedia.org/wiki/Action_at_a_distance_(computer_programming)——是最常見的Bug來源,而「virtual void Update」使得一切都擁有不可見的副作用。
儘管Component類的目的是實現組合,但它是通過繼承實現的,這違反了CRP(組合重用原則)。
這段代碼好的一方面在於,它滿足了SRP和ISP(接口隔離原則),分割出了大量的簡單組件,每個組件的責任非常小,這一點非常適合代碼重用。
但是,它在DIP(依賴反轉原則)方面做得不好,許多組件都互相了解對方。
所以,我上面貼出的所有代碼實際上都可以刪掉了。整個框架都可以刪掉。刪掉GameObject(即其他框架中的Entity),刪掉Component,刪掉Find Of Type。這些都是無用的VM中的一部分,破壞了OOD的規則,使得遊戲變得非常慢。
無框架組合(即使用程式語言的功能實現組合)
如果刪掉整個組合框架,並且沒有Component基類,我們怎樣才能使用組合來管理GameObject呢?
我們不需要寫VM再在我們自己的奇怪的語言之上實現GameObject,我們可以使用C++自身的功能來實現,因為這就是我們遊戲程式設計師的工作。
下面的提交中刪除了整個實體/組件框架:
下面是原始版本的代碼:
下面是改進後的代碼:
這段改動包括:
對象
這樣,我們不再使用下面的「VM」代碼:
for (auto i = 0; i < kObjectCount; ++i)
{
GameObject* go = new GameObject("object");
PositionComponent* pos = new PositionComponent();
pos->x = RandomFloat(bounds->xMin, bounds->xMax);
pos->y = RandomFloat(bounds->yMin, bounds->yMax);
go->AddComponent(pos);
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);
MoveComponent* move = new MoveComponent(0.5f, 0.7f);
go->AddComponent(move);
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)
, pos(RandomFloat(bounds.xMin, bounds.xMax),
RandomFloat(bounds.yMin, bounds.yMax))
, sprite(1.0f,
1.0f,
1.0f,
rand() % 5,
1.0f)
{
}};...
regularObject.reserve(kObjectCount);for (auto i = 0; i < kObjectCount; ++i)
regularObject.emplace_back(bounds);
算法
現在另一個難題是算法。還記得開始時我說過,接口和算法是共生(Symbotic)的,兩者應該互相影響對方的設計嗎?「virtual void Update」反面模式也不適合這種情況。原始的代碼有個主循環算法,它的結構如下:
for (auto go : s_Objects)
{
go->Update(time, deltaTime);
你可能會認為這段代碼很簡潔,但我認為這段代碼很糟糕。它完全混淆了遊戲中的控制流和數據流。
如果我們想理解軟體,維護軟體,給軟體添加新功能,優化軟體,甚至想讓它能在多個CPU核心上運行得更快,那麼我們必須理解控制流和數據流。所以,「virtual void Update」不應該出現。
相反,我們應該使用更明確的主循環,才能讓論證控制流更容易(這裡數據流依然被混淆了,我們會在稍後的提交中解決)。
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);
}
for (auto& go : s_game->regularObject)
{ ResolveCollisions(deltaTime, go, s_game->avoidThis);
}
這種風格的缺點是,每加入一個新類型的對象,就要在主循環中添加幾行。我會在以後的文章中解決這個問題。
性能
現在代碼中仍然有違反OOD的地方,有一些不好的設計抉擇,還有許多可以優化的地方,但這些問題我會在以後的文章中解決。
至少在目前來看,這個「改正後的OOD」版本的性能不弱於Aras演講中最後的ECS版本,甚至可能超過它……
而我們所做的只是將偽OOP代碼刪除,並使用真正遵守OOP規則的代碼而已(並且刪除了100多行代碼!)。
下一步
我還想談更多的問題,包括解決殘餘的OOD問題、不可更改的對象(函數式風格編程,https://en.wikipedia.org/wiki/Functional_programming),以及對數據流、消息傳遞的論證能帶來的好處。
並給我們的OOD代碼添加一些DOD論證,給OOD代碼添加一些關係型技巧,刪掉那些「實體」類並得到純粹由組件組成的、以不同風格互相連結的組件(指針 VS 事件處理),真實世界的組件容器,加入更多優化以跟上ECS版本,以及更多Aras的演講中都沒有提到的優化(如線程和SIMD)。所以,敬請期待我後續的文章……
原文:https://www.gamedev.net/blogs/entry/2265481-oop-is-dead-long-live-oop/
作者:Brooke Hodgman,獨立遊戲、圖形和引擎程式設計師,現居墨爾本,在GOATi Enterainment的22series.com工作
譯者:彎月,責編:胡巍巍
微信改版了,
想快速看到CSDN的熱乎文章,
趕快把CSDN公眾號設為星標吧,
打開公眾號,點擊「設為星標」就可以啦!
CSDN 公眾號秉持著「與千萬技術人共成長」理念,不僅以「極客頭條」、「暢言」欄目在第一時間以技術人的獨特視角描述技術人關心的行業焦點事件,更有「技術頭條」專欄,深度解讀行業內的熱門技術與場景應用,讓所有的開發者緊跟技術潮流,保持警醒的技術嗅覺,對行業趨勢、技術有更為全面的認知。
如果你有優質的文章,或是行業熱點事件、技術趨勢的真知灼見,或是深度的應用實踐、場景方案等的新見解,歡迎聯繫 CSDN 投稿,聯繫方式:微信(guorui_1118,請備註投稿+姓名+公司職位),郵箱(guorui@csdn.net)。
推薦閱讀: