本文为开发小结-编程实践类下篇。
模块设计
模块设计要明确职责,划分功能范围。
在设计较为通用的组件时,考虑到使用场景以及使用时的最低要求,对调用者有一些基本的入参要求,如不满足,则发出错误提示。这些额外要求,在函数注释中需要有明确注明,在内部实现时,配合适当的检测和提示机制。
一个对外提供服务的控件,它的初始化函数需要的默认参数,最好在该组件的头文件中定义.
当参数很多时而且又无法避免时,每个参数单独一行显示,同时仔细设计参数名称,努力做到见名知意。
对于默认参数的设置,外部需要关注的,每次变化的参数,放在第一位,其他不需要关注的,采用默认值就可以.
对于一个显示类控件来说,它只需要接受外部的数据并且正常显示即可,至于这个控件的UI上的显示位置,应该由它的父窗口来决定。
对于业务相关的控件,需要考虑到业务灵活性,这类控件设计时要预留可变参数,不可只着眼与眼前的业务需求。比如显示行情信息为例子,需求给出显示五档行情,实际情况有一档行情、还有十挡行情,那么该控件对于行情显示数目可作为参数传入,内部动态显示特定挡位数的行情,确保灵活性和可扩展性。
注释设计
函数的注释,一行精简必要的解释,在很多情况下就足够,如果函数名称起的好的话,达到自注释的情况就更好。
如因业务规则需要,在某处需要特殊处理,那么在此处一定要加上 需求单号以及特殊规则的简要说明和文档出处,方便维护,既是方便自己查看,,也方便其他同事理解。
程序中必要的ASSERT
和相关的注释,要特别的保留,增加可维护性。
对于一些不好翻译成英文的业务,建议头文件中加上中文说明,方便其他同事查看。
注释规范,块注释建议采用多行的//
,而不是使用 /* */
,原因有如下几点:
-
考虑linux平台上的代码对比工具
diff
,如果用 /**/ 来做多行注释,从diff不一定能够看出来你是在修改代码还是修改注释。 -
局部变量和成员变量的定义, 基本原则是:一行代码只定义一个变量,有利于用
diff
查看出改了什么,有利于版本管理。 -
如果函数声明和函数调用中的入参个数大于3,那么需要在逗号后面换行,这样每个参数占一行,便于
diff
查看
业务数据结构设计
与业务相关的数据结构定义以及配套的数据结构获取、设置、更新等,应该放在一起,保证相关业务以及对外接口的关联性,让别人看到这个定义,下面就可以看到相关的业务辅助函数。
在设计业务数据结构时,结合后台给出的接口文档给出基本的字段设计,举个例子,后台返回整型的币种信息,数据字典为1代表人民币,2代表港币,3代表美元。此时的数据结构如何设计呢?
币种信息定义类枚举类型,
ECurrencyType
{
RMB = 1,
HK = 2,
US = 3
}
有如下以下几种方式:
方式一:
ECurrencyType m_nCurrency; // 保存由原始数据经过转换后的货币枚举类型
const char* GetCurrencyName(ECurrencyType eCurrencyType) // 用于将枚举类型转换为可读字符串,此转换函数与后台无依赖
方式二:
ECurrencyType m_nCurrency;
string m_strCurrencyName; // 该成员通过 GetCurrencyName 转换得到的描述性文字,
方式三:
int m_nCurrency; // 保存后台返回的原始数据,具体显示完成交由上层界面去完成。
提供 `const char* GetCurrencyName(int nCurrency)` // 用于将枚举类型转换为可读字符串,此转换函数与后台有依赖
这三种方式,我都用过,使用次序依次是3,2,1,写的代码多了,对这些细微的考量有了更多的理解,这三种保存数据的方式,在不同场景下有不同的侧重,无法一概而论。字段映射的职责应该交由底层数据层,还是交由上层UI层,具体情况具体分析。
初始化设计
对于类的初始化,应该全部设置为无效值,而不要一部分设置为无效值,另一部分设置为默认值。在构造函数语义中,最好是设置为无效值,设置为默认值的操作,应该属于类的Init函数要做的事情。特别是对于接口定义的数据类,初始化过程中,如果由默认值的设置,那么在后续填充发送字段时,该默认值字段要不要再设置一次就存在不确定性,这里可做可不做,从业务上来没问题,但从可维护性上,后来接手的人看到这里时,心里要多一个心眼,要去看下构造函数才知道要不要设置,增大心智负担。
构造函数里面的初始化,要做无效的初始化,整型统一置为0,指针设置为NULL等,和业务相关的初始化,建议放到单独的Init函数中去做,比如读取配置信息,加载相应的资源,设置某些变量的默认值等等。
在含有众多成员变量的结构体中,如何快速清空,可以采用 memset(this, 0, sizeof(TData))
的方法来清空,该方法使用的前提条件是成员中无stl类型变量。
另一种方法是提供独立的函数(Reset(),Clear())来进行复位,该函数既可以在构造函数中使用,又可以在其他地方使用。
重构原则
重构一定要从最小模块出发,切记一次搞定所有,保证任务的聚焦性和重构目标的可解决性是最重要的。
过早的优化是万恶之源,在一个正确的代码上让它跑的更快的难度远低于在一个跑的更快的代码上让它正确。
重构的好,会增加代码的可维护性,重构的不好,则会增加维护成本、测试成本。所以,重构的首要目标是增强代码的可维护性,至于对性能的影响如何,是增强了性能还是降低了性能,在重构时,不应过多关注。跑的对优于跑的快。通过代码来改善性能只是一个途径而已,作为追求卓越的程序员,应该要了解很多在代码之外的行之有效的改善方法,而可维护性基本上只能靠代码本身,而改善性能,除了代码本身之外,还有更多更加有效的方法。
针对 if-else 类型的代码重构总体思路:尽可能维持正常业务流程代码在最外层, 减少if-else嵌套。
具体的手法有:
- 减少嵌套
- 移除临时变量
- 条件取反判断,条件翻转使得异常情况先退出,让正常流程维持在主干分支
- 合并条件表达式
对于一些基础组件的重构改进,不建议直接在原有公用组件上进行修改。较好的流程是
- 梳理原有组件的业务逻辑,画出业务流程图和交互时序图。
- 在做完相关设计后,务必要请组内其他人一起评审下,不要以自我为中心,以为自己梳理的就一定是正确的。人无完人,多一些来自其他同事的建议,多一些角度去审查接口以及具体实现,于人于己都是大有益处的。
- 新建一个名字类似的组件,将原有组件的主要接口移植过来,在实现上,以简单明了为主。
解除耦合的方法:
- 通过消息队列来隔离,具体表现为消息的订阅和发布
- 通过RPC来隔离
- 将与业务相关的字符串比较逻辑移动为类内部的成员函数,通过成员函数来调用。
- 将两个模块直接依赖的共同部分提取出来,共同依赖第三方组件。
如果是进行组件以及系统的重构,工程实践上最好从主分支拉出一个重构分支,在重构分支上的提交的同时,定期合并来自主分支的改动。重构需要划分模块,小步快跑,同一类型的重构修改按一次提交。对于已经经过测试的代码,不要贸然去修改,较好的方法时,用一个全新的实现去逐步代替旧版实现。从流程上来说,重构的测试,更多的靠开发人员的单元测试,如在重构过程中,有修改实现流程方面的动作,一定要在提交备注中写明并告知测试。
业务实体一致性重构,类、函数、页面、组件,各自内部要做到足够的语义化,同一类型的数据定义,其名称在整个工程中要保持一致性,要做到这点,就必须针对具体的业务特征制定规范,什么业务使用什么类型的核心词汇来描述之类的。该规范由团队成员共同制定,在此举一个例子:比如说:股东代码,从业务上来看,它应该是一个集合概念,内部包含代码、市场、货币类型。
class CHolder
{
CString m_strHolderCode; // 股东代码
char m_cExchange; // 市场类型
ECURRENCY m_eCurrency; // 货币类型
}
外部用到股东的地方,核心命名为Holder
,比如成员变量可用m_vtHolder
,局部变量用Holder
等,不管在哪里,核心词汇均为Holder
。
遇坑心得
现有工程中有基础工具类函数,内部通过封装底层函数来实现,自己看到时会思考,为什么不直接使用底层函数,而采用封装一层呢?列出自己遇到的坑,以获取程序当前所在目录这个功能为例子,系统级API有提供,基础工具类也有提供相关的函数,那么在使用时,选哪一种呢?
-
GetCurrentDirectory 获得当前进程的工作目录。如果进程处于调试状态,那获得的目录为调试器给该进程设置的工作目录,默认为$(ProjectDir),即为包含该项目文件的目录。如果处于运行状态,则获得的是当前的工作目录。这里要特别注意:当以快捷方式启动时,该函数返回的路径是配置在快捷方式中的起始位置编辑框中,而该属性可以人为修改,并且不会影响直接双击启动时获得的路径。因此,使用使用该函数来获取时,在Debug和Release中,在测试时,会返回不同的值,这个函数存在不确定性。
-
GetModuleFileName: 获得可执行程序所在的可执行文件路径,与是否处于调试状态无关,推荐使用。
工程实践
类的初始化列表,遵循一行一个的原则,并且初始化顺序和在类中定义的顺序保持一致,如有新增成员变量,也要对应保持一致增加。
浮点数统一使用 double,不要使用float。
不管是针对简单类型,还是复杂类型, 优先使用++i,而不是i++,减少心智负担。
永远不要在头文件中使用 using namespace,这会导致所有包含该头文件的文件都隐式此命名空间,造成命名空间污染。在cpp实现文件中使用using std::XXX,需要哪种类型,就引用哪种类型。
使用 std::array 或者 std::vector来代替C风格的数组
c++风格的cast(dynamic_cast 或者 static_cast)可以提供更多的编译器检查和安全特性,用于替代c风格的cast。
c++11 中新增了 override 关键字,用于重载虚函数时检查函数签名一致性,virtual用在基类中,用于标示该函数是虚函数。在stackoverflow中的有一个很好的解释:
基类需要virtual关键字来声明虚函数,在派生类中,函数成为虚函数的方式是具备同基类一样的签名类型,override关键字指示编译器来检查在派生类中修饰的函数是否在基类中有相同的签名函数。如果在派生类中重载虚函数时加上virtual,那如何确定该virtual修饰的函数是重写父类的函数,还是该派生类自身提供的虚函数? override关键字正是用在这个地方,取消重写的歧义性。假如父类的维护者给该虚函数新增了一个默认参数,那么带有override修复的派生类可以通过编译器来检查是否有影响.
因此,综上所述:在基类声明中,使用virtual来修饰虚函数声明是必要的。在派生类中,无需再次添加virtual来修饰,而是通过 override 修饰符来增强可维护性。
高效开发
不仅仅是简单完成基本需求,而要在此之上,看有哪些操作是可以避免的,哪些操作是可以延时的,哪些指令是可以推迟发送的,哪些指令返回的数据是可以缓存使用的。
想到这里,以一个实际开发的一个功能来举例:在价格框中价格变化后,需要主动查询最大可交易数量。
这个功能,实现起来不难,难在如何更优雅、更高效的实现。这里有几个前提,价格框可以手工输入价格触发改变,也可由程序自动设置价格。
在实现时,有两种方式
第一种方式是: 在每一个价格框改变的地方,在后面手动调用请求最大可交易数量的函数。
优点:逻辑直接,价格变化后请求数据
缺点:改变价格框的情况较多时,容易遗漏
第二种方式是:价格框改变时,由价格框发消息通知父窗口,在父窗口中通过消息响应来处理价格变化之后的请求。
优点:分离变化事件的产生和处理,从逻辑上解耦,在需要新增响应变化时,改动的地方较少,可控。
缺点:这种方式,对于开发人员来说,不一定容易直观想到,需要较好的程序组织能力才能想到。
第三种方式,在第二种方式基础上,在价格框内部增加进一步的细节处理,记录上一次的价格,当修改后的价格与上一次价格不一致时,才发出价格改变的消息,若一致,则不发送消息,进一步优化效率,而随着而来的代码会更加复杂些。
第一种是很容易想到的处理方式,我当初在做这个功能时,第一时间浮现在脑海中的是第一种方式,后来随着和同事商量,才有了后面几种方式的思路。要做到高效开发,首先要有一颗精益求精的心,其次要认清软件开发的事实,最优方案或者说最优实现是不可能一蹴而就的,软件开发是一个不断迭代的过程,不管是什么水平的开发人员,第一次做新功能时,想要一次性做到极致完美,一次性通过所有的测试案例,是非常难的。我们要认清这一点,在这一前提的基础上,面对需求做好一个设计后,不要着急着去动手实现,而是多和同事讨论下,看有没有更好的思考问题的角度和解决方案。
针对UI界面类的交互开发,团队一定要制定统一的交互流程,在需求讨论过程中,一些没有提到的点和关联逻辑,就按照通用流程来走。举个例子,
-
打开界面进行初始化时,哪些数据要清空,哪些控件要复位,复位的规则是什么,都要一一确定好。
-
若某个编辑框中内容变动,会触发哪些后续流程,数据回来后,要更新那些内容,焦点定位在哪里等交互问题。
-
操作的有效性校验的提示内容以及提示方式等,确认错误后的复位逻辑等?
如果产品需求中没明说,那么在实施时,就和已有的流程一致。如果产品需求由特别规定,就严格按照产品定的流程来做。