游戏邦在:
杂志专栏:
gamerboom.com订阅到鲜果订阅到抓虾google reader订阅到有道订阅到QQ邮箱订阅到帮看

阐述游戏开发者应重视的SOLID原则

发布时间:2012-07-02 12:59:36 Tags:,,,

作者:Alistair Doulin

SOLID原则是由著名的“Uncle Bob”(Robert C. Martin)所提出并且由5个软件开发原则组合在一起的。它们是一组面向对象设计(OOD)的指南,特别是关于类设计。这些原则非常受敏捷开发项目程序员的欢迎,但是却甚少被游戏开发者所利用。所以我将通过本篇文章详细介绍这些原则并阐述如何将其运用于游戏开发。

solid principles(from doolwind.com)

solid principles(from doolwind.com)

单一功能原则(Single Responsibility Principle)

“类的改变总是只存在一个原因。”

single_responsibility_principle(from globalnerdy.com)

single_responsibility_principle(from globalnerdy.com)

第一个原则是设置基础,如果能够正确遵循这一原则的话便能够创造出不错的效果。它指出每个类必须只拥有单一功能以及一个改变原因。确保每个类够小且够集中,从而让开发者清楚该去哪里找自己所需要的内容或者该在游戏中添加哪些特殊功能等。

为何就不能拥有多个责任?多个责任也就意味着每个独立的代码间存在着连结。这时候一种责任的改变将降低类的功能而导致它难以满足其它责任的要求,并且最终只能创造出一个糟糕的设计。“为何渲染API的改变会破坏整体游戏状态?”

修复代码以打破这一原则的方法便是将每个责任按照各自的类进行区分。第一步便是从每个责任中提取一个界面。从而让其它类能够依赖于这些界面而不是类本身。我们便可以基于不同责任(执行单一界面)将这些类区分为不同的类。

你何时明确这一原则?——通常情况下打破这种原则的罪魁祸首便是拥有成百上千行的类。也就是我们所熟知的“GameObject”或“Entity”类,即人们总是会在其中盲目地添加各种代码。这种类通常会有500个以上的改变原因,这也就等于它将应对500个责任。所以这里总会不断涌现各种可怕的漏洞。

开闭原则(Open Closed Principle)

“软件实体(游戏邦注:也就是类,模块和函数等内容)应该能够扩展但却不易修改。”

Open Closed Principle(from doolwind.com)

Open Closed Principle(from doolwind.com)

这一原则的目标是确保每个类尽可能频繁地发生改变,并能够将被用于多种情况中。尽管这两种要求听起来相互矛盾,但是它们却能够通过互补而创造出强大的设计。类能够进行扩展也就意味着随着类的行为能够根据需求改变而以新方式发生改变。而当这些改变都不需要任何源代码变化时类便不能进行修改。

我们可以使用受数据驱动的设计来解释这一原则。通过将所需要的配置数据转到类中,我们便可以轻松地扩展类而不需要对其做出修改。同时我们还应该将任何变量(从数学意义上看)转移到类中从而确保类本身的定义不会只是关于类本身的功能。如果是从基本的OOD原则的数据和操作来看,这应该是最简单的方法吧。类能够定义自身功能的操作以及相关操作数据。而我们则需要尽可能将这些数据转移到类/功能中。这将能够把类本身以外的数据配置转移到调用代码中,从而提高类的改变能力。

这应该是你最害怕签到的文件吧,因为似乎所有人都在使用这一文件。但是不管你致力于创造何种系统,总是不可避免需要用到这些文件。

里氏替换原则(Liskov Substitution Principle)

“使用指标或参考基本类的函数必须能够使用派生类对象,并且无需了解它。”

Liskov Subtitution Principle(from ianfnelson.com)

Liskov Subtitution Principle(from ianfnelson.com)

继承性与多态性是两种非常强大的机制,能够使用一些简单的方法去解决各种复杂的问题。同时它们也有可能创造出漏洞和问题代码。基于这种原则我们需要确保继承体系的合理性,并且不会被代码所滥用而引出各种难以发觉的漏洞。尽管从表面上看来这种原则很简单,但是我们却很难正确去理解它们。

解决这一问题的第一步便是找到实例以核查对象类型——包括其本身及其目标对象。在这个简单的步骤中蕴含着一个基本原则,即“契约式设计”。我们必须确保在调用每个函数前它们都拥有一组真实的条件(前提条件),并且在完成调用后所有函数都将符合自己所对应的条件(后置条件)。所有致力于这项工作的程序员内心都清楚这些条件。而我们的第一步便是将这些条件转换成代码。当我们完成了这一步骤便能够满足以下规则了,即“派生类只会削弱前提条件而加强后置条件。”换句话说,派生类的功能既不应该超过也不该弱于它们的基础类。这一原则非常重要,因为一个被孤立看待的模块总是难以生效。你只有在“Tank”类的根源,同科或其它游戏系统环境下进行它时,你才能清楚它是否真正有效。

我们很容易明找到违背了这一原则的类。只要去找到使用RTTI的基础类去明确它自己所属类型(或它所面向的对象的类型)即可。当“ GameEntity”类校验它是否属于使用特殊码的“Tank”类,这就说明你打破了这一原则。这种类必须能够在忽视对象类型的前提下多形态地调用功能。

接口隔离原则(Interface Segregation Principle)

“不应该强迫用户依赖于他们未曾使用的界面。”

Interface Segregation Principle(from doolwind)

Interface Segregation Principle(from doolwind)

我们应该使用界面去推动两种不同对象间的交流,并创造出整洁,标准的代码。如果我们能够保证自己所使用的界面本身就足够整洁且标准,我们便能够基于这一界面推动理念的进一步发展。界面越大,客户端便会越发依赖于其它对象的功能。而如果我们能够提供一些较小且相互隔离的界面,那么每个对象便能够依赖于它所需要的一些小套的功能。这便减少了对象间连接的复杂性,更重要的是能够让别人在阅读了你的代码后便能立刻知晓每种类所依赖的对象。比起提供一个广大的界面,我们选择将界面分割成具有各种功能的群组,并且每个群组面向于不同客户端。

这一原则能够与单一功能原则有效地联系在一起。在这种情况下每个界面都拥有自己的单一原则,从而让我们能够基于界面的要求清楚地呈现出每个对象的功能要求。

着眼于你的所有界面(抽象类)并确保它们的所有功能列表都具有同质性。如果在界面定义中出现了一些功能分组,便说明你违背了这一原则(这是一种简单的判断方法)。在这里空格便是关键,也就是在函数群组间存在着越多空格便意味着它们彼此间越分散。

依赖反转原则(Dependency Inversion Principle)

“高层次的模块不应该依赖于低层次的模块,这两种模块必须依赖于抽象体。”

“抽象体不应该依赖于细节内容。而细节内容则应该依赖于抽象体。”

Dependency Inversion Principle(from doolwind)

Dependency Inversion Principle(from doolwind)

但是关键却在于,直到最近我也从未在任何游戏开发中听到过这一原则。这一原则与众多开发者所坚持的做法截然不同。通常情况下如果一种类基于另外一种类,那么客户端必将会认为这一对象的类亦是如此并依此行动。而依赖反转(游戏邦注:也被称为控制倒置)则与此大相径庭。比起让客户端负起创造对象的责任,这一原则将根据所所依赖的对象做出选择。这就抵消了客户端的控制权,而将其转移到客户端的所有者身上——通常情况也就是游戏引擎。

渲染系统便是一个典型的例子。比起实例化一个渲染对象或直接调用渲染API的类,渲染系统应该接纳一个具有低层次渲染功能的界面。通过依赖于面向渲染系统的界面,我们便能够在不对客户端渲染系统造成破坏性改变的前提下改变低层次的渲染API了。很明显,如果出现了破坏性改变,那么低层次的渲染API界面也会要求做出改变。

两个系统是如何做到彼此间的对话?如果它们正在使用固体类并不断寻找能够依赖于界面的机遇便有可能进行对话。而最佳方法还是让类能够作为构造函数(这是它所面对的类的界面参考)中的一个参数。这同样也意味着子系统是一种可依赖的特殊类。

游戏邦注:原文发表于2011年2月28日,所涉事件和数据均以当时为准。(本文为游戏邦/gamerboom.com编译,拒绝任何不保留版权的转载,如需转载请联系:游戏邦

SOLID Principles For Game Developers

Alistair Doulin

February 28, 2011

The SOLID principles are a set of 5 software development principles coined by “Uncle Bob” (Robert C. Martin).  They are a set of guidelines for Object Oriented Design (OOD), specifically for class design.  They are widely used by agile business programmers however they are generally unknown amongst game developers.  This article describes the principles and frames them in common game development situations.

Single Responsibility Principle

“There should never be more than one reason for a class to change.”

The first principle is the cornerstone of the set and gives the greatest return on investment when followed correctly.  It states that each class should have only a single responsibility and therefore reason to change.  Keeping each class small and tightly focussed allows developers to know exactly where to go to find or add particular functionality to the game.

Why is having more than one responsibility bad?  Multiple responsibilities means there is coupling between separate pieces of code.  Changes to one responsibility reduce the ability for the class to meet the requirements of the other responsibilities.  This leads to fragile design that breaks often and in unexpected ways.  “Why did changing from rendering API break jumping in the game?”

The way to fix code that breaks this principle is to separate each responsibility into its own class.  The first step can be to extract an interface per responsibility.  Other classes can then rely on these interfaces rather than the class itself.  The class can then safely be split up into separate classes for each responsibility that each implements a single interface.

When have you got it? – The usual culprit breaking this principle is that one (or group) class that’s hundreds or thousands of lines long.  You know the one I’m talking about. Often it’s the GameObject or Entity class that everyone seems to throw code into.  This class usually has about 500 reasons to change, and therefore 500 responsibilities.  It’s usually involved in the heinous bugs that crop up constantly.

Open Closed Principle

“Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.”

The goal of this principle is for each class to change as infrequently as possible while allowing it to be used in as many situations as possible.  While these two requirements may seem at odds they actually complement each other to generate robust design.  A class is open for extension means that the behaviour of a class can be changed in new and different ways as the requirements change.  A class is closed for modification when no source code changes are required for these changes in requirements to be met.

This principle can easily be resolved with data driven design.  By passing the required configuration data to a class it can easily be extended reducing its need for modification.  Any variables (in the mathematical sense) should be passed to the class so that the classes definition itself (the code) does not specify the functionality of the class alone.  I find this the easiest to think about in terms of the basic OOD principle of data and operations.  The class defines the operations it can perform (its functions) and the data that it operates on.  As much of this data as possible should be passed to the class/function.  This moves the configuration of its data outside the class itself to the calling code increasing its ability to change.

When have you got it? – This is the file you dread checking-in because everyone seems to be working on it all the time.  No matter what system you’re working on, these files always seem to be involved.

Liskov Substitution Principle

“Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.”

Inheritance and polymorphism are powerful mechanisms for solving complex problems with simple solutions.  They are also powerful mechanisms for creating bugs and problematic code.  This principle involves making sure inheritance hierarchies are sound and not being abused by code that introduces bugs that are hard to find.  While it seems simple on the surface, this principle can be quite complex to solve correctly.

The first steps to solving this problem are to find instances of checking for an objects type – both its own and that of objects it’s working on.  Beyond this easy first step comes a principle known as “Design By Contract”.  Each function has a set of conditions that must be true before it is called (pre-conditions) and a set of conditions it guarantees are met after its completion (post-conditions).  These are often implied conditions kept in the mind(s) of the programmer(s) working on them.  The first step is formalising these conditions into code.  Once this step is completed the following rule can be met – “Derived classes can only weaken pre-conditions and strengthen post-conditions”.  Put another way, functions of a derived class should expect no more than their base class and promise no less.  This principle is important because of the fact a model viewed in isolation cannot be meaningfully validated.  You don’t know whether your new “Tank” class is valid until you run it in the context of its parent, siblings and other game system.

When have you got it? – Classes breaking this rule are really easy to find.  Look for any base class that uses run-time type information (RTTI) to interrogate its own type (or the type of an object it’s working on).  As soon as the GameEntity class is checking if it’s of type “Tank” to do some special code, you’ve broken this principle.  The class should be able to polymorphically call functions without caring about the actual type of the object.

Interface Segregation Principle

“Clients should not be forced to depend upon interfaces that they do not use.”

Interfaces should be used for communication between different objects to encourage clean, modular code. This principle takes that concept further by making sure that the interfaces we use are themselves clean and unified. The larger the interface, the more the client is relying on functionality of another object. By keeping small segregated interfaces each object will rely upon only the smallest set of functionality it actually requires. This reduces the complexity of links between objects and more importantly, lets someone reading your code know exactly what each class relies upon. Rather than one “fat” interface we break the interface up into multiple smaller groups of functionality that each serve a different client.

This principle links back to the single responsibility principle nicely. In this case each interface should have a single responsibility. This lets you explicitly state the functionality requirements of each object based on the interfaces it requires.

When have you got it? – Look at all your interfaces (abstract classes) and make sure their listing of functions are homogenous.  An easy way to tell you’ve broken this principle is when there are small groupings of functions within the interface definition.  Whitespace is the key here, the more whitespace between the groups of functions, the more disparate they are.

Dependency Inversion Principle

“High level modules should not depend upon low level modules. Both should depend upon abstractions.”

“Abstractions should not depend upon details. Details should depend upon abstractions.”

This is a key point that I had not heard of at all in game development until recently. This principle is quite the opposite of how many developers are used to working. Usually, if a class depends on another class, the client will instantiate an object of that class and then act upon it. Dependency Inversion (also called Inversion of Control) turns this on its head. Instead of the client being responsible for creating the object, it is given the object it depends on. This takes the control away from the client and moves it to the owner of the client, often the game engine.

A good example of this is in a rendering system. Rather than instantiating a rendering object, or directly calling the classes of the rendering API, the rendering system should receive an interface to the low level rendering functionality. By relying on an interface that is given to the rendering system, the low level rendering API can be changed without making breaking changes to the client rendering system. It becomes obvious if breaking changes occur as the low level rendering API’s interface will require changing.

When have you got it? – When two systems are talking to each other, how do they do it?  If they are using concrete classes then look for opportunities for them to rely on interfaces instead.  The best way is for the class to take as parameter to its constructor an interface reference to the class it needs to work on.  This also makes it obvious what sub-systems are particular class is reliant on.

Conclusion

What are your thoughts on the SOLID principles? Do you see benefit from adopting these in the games industry as web and application development has done?(source:doolwind


上一篇:

下一篇: