开发培训体会——写好代码的一些编码规则和设计原则

作者:朱金灿

来源:blog.csdn.net/clever101

 

前言:里面涉及的不但是编码方面的规则,还有系统设计方面的一些原则。内容比较杂,基本上是想到那讲到那。

 

v           最小影响域

代码中的任何定义,应具有尽可能小的可见域;程序中任何运行期对象,应具有尽可能小的作用域。

F      可见域越小的定义,修改时影响范围越小

F      可见域越小的定义,修改时要重新编译的越少

F      定义的可见域越小,复杂系统中理清各个对象的关系就越容易

F      作用域小的对象在阅读代码时需要考虑它的时间较少

F      作用域小的对象更容易明白其存在意图

 

F      缩小对象作用域可以利于简化系统的运行期对象关系

v      举例

F      在循环中按需声明变量

F      减少全局变量

F      包罗万象的头文件与只包含必要定义的头文件

F      区分外部可见定义和仅内部可见定义,减少应用程序的重编译次数

 

v      尽可能多的不变性约束

不变性约束主要通过const关键词表现,不变性成员函数保证了该函数调用时不会改变对象的状态;不变性变量的值在生命期内不会改变。const记录了程序状态不会发生变化的位置。

v      举例

F      标准C的的fopen函数的文件名参数

F      const int len = v.size();

 

使用const还有一个好处是,不能调用一个const对象指针的非const成员函数,而const成员函数可以限制修改对象的数据成员。

 

v      在必要时才使用继承

继承容易被过多的使用。在稍微复杂的项目中很容易形成臃肿的继承体系,对基类的修改常常会蔓延到派生类中。只有在符合is-a关系的时候才使用继承;只有派生类需要基类几乎全部成员和接口函数的时候才用继承;经常考虑使用委托和组合的方式取代继承。

 

v       举例

不恰当的使用继承可能导致派生类误用了基类接口,比如下面正方形类派生自矩形类,有下面的一些代码:

 

 

当然这个例子有点极端。

 

  假如子类只使用了基类的部分接口,那么我们可以使用委托和组合的方式来代替继承,如我们设计一个堆栈类,堆栈类的一部分接口使用了队列类的一些接口,但是如上面所提采用堆栈类继承队列类不是一个好办法,那么我们可以这样写:

 

 

 

 

顺序规范性

代码中声明或者定义一组相关的多个程序元素时,尽量依照合理的,易于理解的顺序。

v            举例

F                 定义类成员时,publicprotected之前,后者又在private之前

F                 联系紧密的成员定义的位置也比较紧密

F                 声明函数的参数应根据习惯和重要性排列

F                 包含头文件应先包含通用的基础的头文件,一般按照系统库头文件,第三方库头文件、自定义头文件的顺序进行排列,比如我们在Windows平台上使用VC 6.0开发程序,工程中使用了MFC库、标准C库,STLboost库,那么我们可以编排头文件的顺序,首先是

 

 

 

 

 

 

v      依赖最小化

减少程序内部各个模块之间的直接依赖,使程序具有较松的耦合。尽量使用前置声明而不是直接包含;尽量避免模块间的循环依赖。这里的循环依赖一般是指A模块的类使用了在B模块中定义的类,而B模块的类也使用了在A模块中定义的类.

 

   

举例, 比如我们在两个头文件中分别定义了儿子类和父亲类,那么我们可以使用前置声明的做法来代替直接包含头文件,示例代码如下:

 

 

 

 

 

v      依赖最小化

F      通过定义抽象接口,使具体的实现均依赖于抽象接口,从而打破循环的依赖。

 

      比如我们定义了一个按钮类和一个台灯类,UML图如下:     

 

 

 Button and Lamp

 

 

我们可以看到像上面这样的设计就不具备扩展性(假如将来我们想把按钮类扩展到所有电器的按钮),因为按钮类的实现依赖于具体的台灯类。为此我们可以这样设计:

 

 

Btn and Lamp

 

          如上面所示,我们定义一个抽象的按钮侦听者类ButtonListener,按钮类针对这个侦听

                 然后让台灯或者其它电器都派生自这个侦听者类。这样扩展性是不是更好点呢?

                

   

      优先使用接口设计与开发
                 基于接口设计与开发提供了更好的系统健壮性、更灵活的扩展性和更清晰易维护的模块化设计。

使用智能指针控制局部资源
        使用智能指针控制局部的内存分配,内核对象及系统资源,能够减少系统资源泄漏的可能性,简化错误处理的代码,并且支持异常安全。当算法中有多处可能返回或者抛出异常,使用智能指针可以使程序员从繁琐的delete、close中解脱出来。

常用的成熟智能指针有:
std::auto_ptr,// 可以控制对象的内存
boost::scoped_ptr,// 可以控制内核对象的释放,如文件句柄之类的
boost::scoped_array
std::auto_ptr::release
boost::checked_delete

         比如我们使用标准C函数的fopen函数打开一个文件,我们必须设想多种打开文件失败的情况,而遭遇到哪一种情况,我们都必须调用fclose函数,这样显得比较繁琐。为此我们使用boost库的智能指针boost::scoped_ptr,代码如下:

         

         程序实体应具有单一的概念和职责


        定义的类和函数应该具有单一的定义良好的概念或职责,其设计目的是单纯的,明确的。职责可能有大小之分,但是不应是发散的,而是内聚的。多个职责的实体难以理解和使用,维护起来也比较困难,修改一个职责很容易意外的改变了其它的职责。

 比如我们开始设计一个矩形类,开始设计如下:

          Rect1

         后来我们想求取矩形面积只是几何学上的一个计算,因此有必要把它从矩形类中剥离出来,为此我们将设计如下:

         Rect2

      可以把职责定义为“变化的原因”。如果能够想到多余一个的动机去改变一个类,那么这个类很可能就有多个职责。
             比如我们定义一个modem(拨号上网的那个猫)类有如下操作:

         Modem1

       后来我们发现dial和hangup这两个操作只是连接的两个操作,而send和recv则是属于数据频道的两个操作,因此我们可以修改设计如下:

        Modem2

             当然良好的设计往往不是一步推出的,而需要反复考虑才能做出比较好的设计。


    避免可推导和冗余的状态
         类的属性成员通常是类的状态,而这些状态往往并不是毫不相关的,有些状态可以由其它状态计算或推导得出。只有在计算代价相当高昂时才保留这些可推导的状态。每个成员都需要详细分析其必要性,避免将在几个成员函数中传递的计算中间结果作为成员。


 举例
         矩形的面积成员通常不是必须的
维护可推导成员必须冒着成员状态不一致的风险,此时可通过set函数强制保持一致

 

避免可推导和冗余的状态
比如有这样

           

          
为此我们可以将代码修改如下:

             

            用多态代替条件枚举判断
当需要根据某个条件执行不同的代码时,可以考虑通过多态将这些代码分散到不同的实现类中,避免冗长的条件枚举判断,提高代码可维护性。实现的关键是定义这些操作的接口。

  比如在一个绘图系统中有多种图形选择手段(线选、多边形选等),为此我们将线选类、多边形选类都派生自一个选择事件类,然后在窗口定义一个选择事件类接口,以此判断是哪种选择,UML图如下:

         

                Select

         尽量多的使用断言
    断言用来诊断继续执行的条件是否满足。使用断言在调试模式下能够尽早的发现问题原因,利于测试和错误诊断。前置断言表述了后面代码执行的前置条件,后置断言表述了执行后程序状态必然发生的改变和满足的条件。

生死同时考虑
    使用对象前必须将它创建和销毁的方式和可能的途径确定下来,并作为规则严格遵守。这些规则包括:
new/delete和malloc/free分别配对使用
尽可能减少手工调用delete或释放资源的地方
如果有可能,尽量让一个对象的创建和释放在一个编译单元中
一个模块或库创建的资源最好由这个模块提供的接口销毁或释放(对称性的表现)
设计时必须明确每个设计中的对象如何“死”!


 在头文件中使用预处理指令避免包含
在每个头文件中使用预处理指令避免该文件被编译器重复使用,导致类型重定义错误。有两种可选的方式,一种是直接使用#pragma once,写在头文件的开头;一种是使用#ifndef…#define…#endif配对,这种方式需要定义一个名称唯一的空宏,例如:

       #ifndef THIS_IS_MY_HEADER
#define THIS_IS_MY_HEADER
……………………… //定义类等
#endif

这种方式要求对于宏的命名要有规则,避免冲突,这里不推荐MFC那种随机生成空宏的做法。

           基于角色的变量命名
给变量命名主要应该考虑可读性,而不是输入的简短和方便。变量的名称应该表达出变量持有的数据是怎么使用的,在计算中它扮演着什么样的角色。变量的类型、生存期等虽然也是重要的,但是这些信息借助IDE和上下文很容易明白。

  基于意图的函数命名
函数的名称通常是谓词或者谓词+宾语,必须能够表示出函数的目的或者意图。不应在函数名称中表达其实现细节,如Find和LinearSearch这两个函数名,LinearSearch这个名字已经暴露了函数采用的是线性查找法这个细节,除非函数的实现和使用者有特定的关系。

使用值对象
值对象一旦被创建出来,其值就不会再发生改变,正如永远不可能将0变为1——后者是另外一个值。试图修改值对象最终会产生一个新的对象。在恰当的情况下使用值对象有助于提高代码的可读性和可维护性。

    举例
矩形对象提供了成员函数Offset进行位置的偏移:
bounds.Offset(100, 50);
bounds = bounds.Offset(100, 50); //值对象风格

内部类
恰当在类的内部定义类,可以把相关计算或状态整合在一起。内部类通常作为外部类的“延伸”或“扩展”,其使用和外部类是紧密相关的,一般不会在没有外部类参与的情况下单纯使用内部类。
  举例
在网络服务中的NetworkDataMgr负责管理网络数据。若外界想读取数据必须先以读方式共享锁定,若想修改数据必须先以写方式独占锁定。该类提供了内部类Lock帮助实现数据锁定:
NetworkDataMgr::Lock lock(true, true, true);

简单基类名称和受限子类名称
对于重要的类,尤其是基类,使用隐喻命名,尽量使用一个单词;子类的命名需要考虑:类是什么以及同其它类的区别,通常可以用限制词+基类名的方式命名,重点要关注命名的精确性。

举例
比如画图对象尽量不用DrawObject而用Figure,如下面就是一些比较规范的基类和子类命名:
IRender => CSimpleRender,CDotDensityRender
wxDC,wxMemoryDC,wxBufferedDC,wxBufferedPaintDC,wxAutoBufferedPaintDC

直接访问状态
对状态的直接访问能够清晰的表达出到底发生了什么事情,到底什么值发生了什么样的改变,但是这种清晰是以丧失灵活性为代价的。直接访问通常会揭示程序实现的细节,这往往和编程人员及阅读人员正在思考的内容处于不同的层次。
  举例
 doorState = 1;
 openDoor();
 door.Open();

间接访问状态
通常类会提供访问函数让外界访问内部状态,但是这种函数通常都很琐碎,太多的时候会降低程序的可读性。当程序中多个状态紧密绑定在一起时,间接访问是必须的。

举例
void Rectangle::setWidth(int newWidth)
{
 width = newWidth;
 area = width * height;
}

外部状态
有时候程序的某个部分需要和对象相关的特定状态,但是程序的其它部分并不需要这些状态。若把这些状态放入到对象内部,那么将会破坏对称性原则。在这种情况下应该考虑将这些特定状态保存在被使用处的附近,并且以对象作为获取状态的索引。
 
  举例
对象保存在文件中的位置对于存储模块来说是有意义的,但是对程序其它模块来说没有意义。对象位置放在存储模块中的一个map中,以对象指针作为key。

对象成员的积极初始化
初始化对象成员的一种风格是在它创建的时候初始化,而不是提供专门用来初始化的函数。这种风格能够避免对象多余的中间状态,保证实例在生存期内完全有效,这样可简化使用对象的代码,只需应对更少的情况。
  举例
Account::Account(std::string id, double amount)
 : accountID(id), balance(amount) {
}

 对象成员的延迟初始化
当对象成员的初始化具有较大代价或者并不是每次都必须时,可以延迟这一过程以获得较好的性能。延迟初始化通常实现在成员的访问函数中。
 
       举例
基于OSGi的Eclipse插件体系结构允许某个注册的插件在第一次被使用时再加载和激活

      

           逻辑异常时通过提前返回或跳出简化控制流
如果算法由多个可能失败的步骤组成,或者根据条件检查确定是否继续执行,则尽可能判断失败条件并返回或者跳出,而不是将后续代码作为成功条件的执行体,导致没有必要的嵌套层次。

不好的做法:

        

    好的做法:

     

       清楚的表达主控制流
应将程序或算法的执行过程虽然会伴随着失败和异常,但是终究会有一个执行的主路径。开发人员必须用编程语言清晰并简洁地描述出这个主控制流。通过合理地分解和组织任务,主控制流表述了完成任务需要的主要步骤,而通过异常等机制处理错误发生的情况。


  尽量使用库代替手工编写的代码
库中的代码往往经过了严格的测试,良好的优化,应广泛了解库,在动手自己编写算法或通用功能前先考虑库中是否有可直接使用的内容。

  举例
在常用GIS平台的显示TIN格网代码中有一个步骤是将三角形的三个顶点坐标按照高程值排序。以前的实现流程是编写专门的函数GetTriangleElevationMinMax,内部调用另外一个函数SortMem,同时传入比较函数fnPointCompFunc(仅该函数就有13行)。

使用标准库或者boost库,相同的工作可使用极少代码完成。
 Coordinate vertCoords[3];
 使用标准库的sort函数

    

      像上面写还略显繁琐,因为还得针对x、y值再写两个函数。结合标准库的sort函数与boost库的lambda库可以有更简单的做法:

      

       当然要看懂上面的代码必须对boost库有一定程度的了解。

函数尽可能返回精确的类型
虚函数是C++中常用的技术。派生类中的虚函数声明可以同基类不同,其返回类型可以是基类虚函数返回类型的派生类。若符合这个情况,派生类的虚函数应尽量用精确的类型。

  

        举例

       Feature

        避免多余的类型转换:
IFeature* f = (IFeature*)
  pfrs->Next();
pfrs为矢量记录集。

    理解并应用有限状态机
有限状态机(Finite State Machine)是表示有限个状态以及在这些状态之间的转移和动作等行为的数学模型。复杂的计算算法、网络服务程序、复杂的用户交互界面等大部分开发工作都可以认为是FSM。在编码前清楚定义和开发任务相关的FSM,准确描述所有状态及其相互转化的条件和动作,有助于编写逻辑清晰、可维护性强的程序实体。


  举例
 游戏程序中的NPC

      FSM

     

 

v      不要在构造函数和析构函数中调用虚函数

在构造函数和析构函数中,虚函数并不虚拟。如果直接调用未实现的纯虚函数,通常会导致链接失败;如果间接调用,会导致程序未定义的行为。

创建派生类对象,当执行到基类构造函数的时候,所构造的对象类型是基类类型,因此在基类构造函数中调用虚函数不会分发给子类。

在基类构造函数执行的时候,没有办法知道正在构造的对象是独立对象还是其派生对象的基类部分。

 

 

  使用vectorstring::c_str同其它API交互

vector的内存区域是连续的,因此第一个元素的地址就是其内容的指针。可以使用&*v.begin(), &v[0], &v.front()来获取第一个元素地址;获取下标为n的元素地址可用&v[n]&v.begin()[n]。不要认为v.begin()返回的就是指针,虽然实现上确实如此,但是这没有规范的保证。

同样无法保证string内部使用连续内存保存字符串,因此应该总是使用c_str返回C风格字符串。

 

 

v      代码在现实中运行

程序终究运行在现实的计算机上,编写代码时需要考虑到现实的制约因素:

F      可怜的栈空间

F      有限的堆内存

F      缓慢的磁盘IO速度

F      受限的内核对象数目

F      必须与其它程序共存

F      有时候异常是异常,有时候不是

 

v       举例

比如不应过多的递归嵌套或在栈上声明过大的数组,而应

F      根据外部条件(如文件大小)分配堆内存空间时必须考虑所需的空间大小。可通过缓冲区策略只分配部分大小合理的内存。

F      DEM数据对象提供了以下标的方式访问任意位置高程的能力,其内部只保留指定行数的DEM数据,按照先进先出原则淘汰。

F      尽量减少磁盘IO的次数,处理大量数据时应该按照策略一次读取/写入较大量的数据,在内存中逐字节访问。

F      复杂环境中的程序(如网络系统)在开始就应把出错或异常作为系统正常运行的一部分,不能假设其不存在,必须系统性的设计应对策略。

 

 

 

 

 

 

 

 

 

 

   

 

 

        

原文地址:https://www.cnblogs.com/lanzhi/p/6471167.html