开发小结-编程实践类-上篇

本系列文章主要汇总在工作中心得体会,后续还会有一系列文章来进行梳理归纳和总结。

总体原则

编写业务类代码,既要实现功能,又要简化逻辑。在满足当前需求的前提下,尽可能简洁明了,不要玩那么多的技巧和花样,所谓:“重剑无锋,大巧不工”,认真考虑当前系统是否有那么多变化,不要考虑“夸夸其谈的未来性”,简单够用就好,这样,既便于调试和维护,也减少犯错误的可能。

任何傻瓜都能写出让计算机理解的代码,而优秀的程序员则可以写出让人类明白的代码

程序只要可能出错的地方,那就一定会出错。拷贝代码的同时,也在拷贝错误。

错误定义

在定义错误码时,除了各种不同类型的错误情况之外,要包含正确的情况。比如登录状态,要区分不同种类的失败情况,就要定义对应类型的错误码,并且将错误码所关联的提示内容聚合在一起,方便统一查看和修改。即使需求给出在某些场景下的错误提示语一致,在开发时,要能预估要以后的变化,不能为了偷懒方便,简单地用一种错误码来覆盖。

对于错误,要有明确的区分,是来自内部的还是外部的。内部错误的应对,要快速崩溃,这样可以更快的发现问题。而对于外部的攻击和错误输入要足够健壮,给出适当的提示。

实际工作中,代码健壮性主要考虑的是应对各种错误,常见的POSIX接口对于错误有如下规定,在设计错误时可以参考。

  1. 创建对象\环境类的方法,如果创建失败,一般会返回空指针。
  2. 其他方法,执行成功会返回0,失败会返回其他值(负数为系统类错误,比如资源不够,正数为业务类错误,比如权限不足)
  3. 错误代码可以从变量 errno 中获得,可通过 strerror() 函数得到具体的错误信息。

变量设计

变量的命名,要符合团队约定的编程规范,以下为简单实例:

  • 类命名,以C开头,比如股票,CStock
  • 结构体,以T打头,比如账户信息, TAccountData
  • 枚举类型, 以E打头,比如请求类型 EAskType
  • 成员变量前缀加m,静态变量加s,全局变量加g
  • 变量类型前缀,整型n,枚举e,字符c,布尔b,条件变量bool,浮点d,句柄h,指针p,以0结尾的字符串sz,CString或者string,以str打头
  • 常用容器前缀:vector用v,map用map,set用set,list用list
  • 函数命名,每个单词首字母大写,无下划线,AddTableEntry(), DeleteUrl(),驼峰法命名
  • 空指针使用 nullptr 来代替NULL

变量名称应该是一个名词或者是"形容词+名词",小写字母开头,比如最大大小,取maxSize,函数名称应该是"动词+名词"的组合,例如GetFileList

局部变量,单个单词的话,全部小写;两个单词的话,首单词首字母小写,其他单词首字母大写。

随着项目的扩大,原始数据类型无法表达清晰合适的含义,降低可维护性,增加出错的概率,因此,不要直接使用原始的数据类型来定义变量,比如 string,int,char,而要在统一的地方,对用到的变量进行类型重定义,增加变量的可读性,如果一个变量什么都可以来表示,那它实际上什么都不能表示。

typedef const char* LOGKEYNAME;  // 在不同应用场景下,给const char* 不同的名字,更加明确语义化

typedef void* LOGHANDLE;

using LOGHANDLE = void*;        // C++11 类型定义方式 

可以看到,在很多第三方开源库中,对基础数据类型进行业务相关的重定义,用来区别在不同场景下具体语义,增加代码可读性和可维护性。

不要对运行中的系统做过多的假设,凡是使用常量的地方,要有据可查,为什么是这个值,是经验值还是文档中有规定的。

变量的生存周期,采用延迟声明的方式,在需要他的时候才声明,做到作用域最小化。

业务结构体中的变量名称,可参考业务接口文档中的对应字段来命名,保持实现和接口的语义一致性。

函数设计

函数一般是动作性指令语义,名称要和它的实际功能相匹配,内部实现时,不要完成名不副实的操作,也不要偷偷摸摸完成一些额外的工作。对待函数的名字,要像对待孩子一样,让它人如其名。

出入参设计

函数的出参一般有两种方式,返回值和引用参数。什么情况下用返回值,什么情况下用引用参数呢?一般来说,转换类或者业务相关类函数,常见类型为 bool DoSomeThing(const char* pType, AcountInfo& vInfo),返回值代表转换成功还是失败,若成功,则出参有效。若失败,则出参为空。

对于结构体、类、容器类型数据的出参,最好使用引用方式出参。对于普通类型,建议采用返回值出参。

入参设计决定内部实现逻辑,以以下例子为说明:

无参数类型的启动函数,可以这样声明,void startUp()。后来需求增加了一种启动情况,启动时通过 "/p" 命令行参数来标识,直觉的思路是,函数签名改成这样void startUp(const char* pMode),内部使用 bool 变量来区分无参数和有参数两种情况。

问题来了:需求又增加一种启动类型,以"/q"为标识,函数入参该怎么处理呢?

两种方案:

  • 针对第三种情况,入参增加一个bool变量来标示它,这种方式可在不大改变原有逻辑的基础上增加新功能,但可扩展性不好,不利于后续维护。

  • 考虑用入参枚举来代替所有情况,定义好空字符串,/p,/q字符串对应的启动枚举类型,先用转换函数,将启动字符串参数转换为启动枚举类型,在startUp函数内部用switch-case语句来处理。这样做,可扩展性较好,内部处理逻辑统一。

从上述例子来看,函数入参的设计决定函数内部实现的思路,另外一点,不要使用Bool类型作为函数入参,应该要有有含义的枚举类型来代替。

当函数入参传递一个指针代表一段内存空间时,一定要附带另一个代表空间长度的入参,防止内部越界访问。

对于基础类型的入参,声明为const即可,不必额外再加上引用修饰符,例如这样 void Function(cosnt int& x),这样既达不到提高效率的目的,又降低函数的可读性。

默认参数

首先思考一点,为什么有默认参数的出现?是为了降低使用者的心智负担。

从这个角度来思考,如果函数只有一个参数,并且大多数应用场景都是默认参数(比如超时时间设置为5秒),只有少部分场景才需要额外的设置情况,那么对于这种场景下,提供默认参数比较好,同时需要在注释种解释该默认参数的共享和使用方法。

内部实现

函数对待外部输入要时刻警惕,不管调用方有没有对入参进行有效性判断,在内部进行正常业务处理前,都要对入参进行有效性判断。检验一般会用到assert函数。

断言的注意事项:

  • 一个assert只检验一个条件
  • 对来自系统内部的错误,看具体场景,是使用断言后直接退出,还是用try-catch捕获该错误,记录相关信息后继续运行.
  • 对于来自外部(这里的外部,是从系统角度去看待的)不可靠数据源的数据,不能使用断言,而应该使用错误代码加错误信息提供给外部。
  • 对于一些偶发异常,如果程序本身可以容忍并且能够应对,就可以自己在内部处理好,但不要提醒出来。有的异常是导致任务运行必要前提条件都不满足,那么这种必须报错到用户去处理。
  • 不在断言中加入业务代码,因为断言类函数在Release版本下会被优化掉,会屏蔽掉带入的业务代码。*

内部实现不要出现 magic numbertrue/false也是一种 magic number,可将ture/false放在判断语句内来消除。

在工程代码中禁用使用strcpy,strcmp,sprintf等c库的基础函数,因为它们内部没有对输入指针做有效性判断,属于有安全隐患的函数,要使用对应的安全版本函数,

例如,strcpy要使用strncpy版本,如果拷贝长度小于真实长度,则只会拷贝所需长度,不会添加'\0'结束符, 如果拷贝长度大于真实长度,会将以NULL填充多余长度。

使用较为底层的API获得相关信息时,要注意兼容性。即使本机测试没有问题,在其他运行环境下,不能确保完全没有问题。因此,需要加上异常判断,在错误分支上,记录相关的日志,当出错时,返回一个空的结果,而不要崩溃,造成整个服务不可用。

一个方法里面尽量不要嵌套太多的逻辑判断(if-else, switch-case),嵌套达到三层,就有必要考虑将其中一部分独立成新的方法来调用。

当一个方法的参数数量大于4个的时候,就有必要采用参数类或者参数结构体来包裹以下,便于后续的扩展和开发。

类设计

类中头文件中函数的声明顺序和实现文件中的放置顺序要一致。

对于一些不改变成员变量的成员函数,比如说,print 函数,建议加上 const 修饰符 来明确语义,防止误修改成员变量。

对于一些Get属性的函数,建议提供 const 版本 和 普通版本,以更好地区分使用场景。

简单的 设置和获取函数,只需要一行就可以完成,可以直接写到 .h文件里面去

char GetMarketID() const { return m_cMarketID; }    
const char* GetCode() const { return m_cCode; }	

例如业务要求先调用A设置参数,再调用B,才能得到B的正确结果,B函数依赖A函数的设置结果,对于类中相互关联的变量,与其对外提供单独的设置函数,由外部来保证前后调用关系的正确性,还不如在类内部封装好该逻辑关系,对外提供只C函数,C函数内部先调用A函数,然后再返回B函数,对外屏蔽A函数和B函数,让使用者方便使用,不容易用错。

一个工具函数,如果只是自身类才使用,那么该作为该类的私有函数出现,如果类似的功能在第二个类中也需要,那也应该作为第二个类内部私有成员函数出现。如果类似的功能在多个地方被使用,那么可以考虑把它剥离到tool公共类中.

改变成员变量状态的函数,那它的职责就是改变状态,至于这个改变状态后外部要完成其他什么任务,这不是该函数的职责,建议在此场景下,通过消息,将状态改变事件对外发布,由感兴趣的对象来订阅,进行后续的操作。

类声明中顺序放置规范,成员函数在前,成员变量在后,每个大类中,从public,protect,private依次放置,数据成员在大部分情况下,都是私有的,除了一些纯粹当作数据封装的类,为方便使用,可设置为public。

每个业务类的成员变量都代表该类自身的某种状态,状态越多,类对外承担的职责也就越多。当响应外部消息时,多个状态需要同时变化,在实践中很难保持同步修改,较容易出错,对于类本身来说,它只需要管理它自己必不可少的状态,对于一些其他依赖关联的状态,建议放置在其他地方,当需要用到时,主动去获取,而不是在本地保存一份副本。这样做的好处,就是当外部状态改变时,不需要主动同步状态,本模块所使用的始终会是最新的状态。

一个拥有太多状态的类是不好维护和扩展的。内部成员变量的命名要做到见面知意,一些只读变量,可用const成员变量来代替。
类中的每个方法只做好一件事情,并且从方法名称上面可以看出方法的作用。

每一个类的职责要明确清楚,不能添加他不需要的功能,也不能要求他做超过他职责的事情。对于一些不属于它的职责,但在实现过程中又有需要调用的外部接口的,需要有合理的依赖说明。

在最底层的工具类函数和最上层业务界面之间,应该还有一个中间层,它负责组合底层基础功能,完成一些业务功能组合,对上层提供较高层次的接口使用。
例如,最基础的控件组成基础窗口,基础窗口根据业务需求组成成通用提示类窗口、通用辅助类窗口,业务界面直接用通用类窗口即可,而不需要从最基础的组件开始搭建。
比如下载一个带目录层级的文件,那么这个功能中有创建目录和下载两个功能,这两个功能要单独实现,再由业务方的下载模块来组合各项子功能来实现,而不是在业务方的下载模块中搞自己的一套。

作为基础的工具辅助类Tools,不应该依赖于太多其他的头文件,辅助类函数可以分为两类,一类是业务无关的(比如,创建目录、
校验文件正确性,获取文件大小等),一类是与业务相关的,比如(判断是否登录,判断是否有权限操作等)。每一个大类下,可以根据具体使用场景进一步细分,比如业务无关类的辅助函数,可以分为日期类、加解密类、文件的读写类、线程封装类、锁封装类等。

接口设计

尽量不用全局函数和全局变量,可用全局命名空间包裹函数和变量来进行导出。

最底层工具类函数提供的功能要足够的原子化,中层业务类函数按照功能模块来划分,依次调用底层函数和依赖数据来完成较高级的功能。

外部依赖数据最好从函数入参传入,函数内部实现尽量减少对第三方的依赖,只依赖那些必须依赖的接口即可。

接口的返回值设计,总结下有以下三种方式:

  • 返回条件判断状态
    比如 BOOL IsLogin();
    返回True, 代表已登陆;
    返回False,代表未登陆。

  • 返回值返回执行状态,函数出参返回结果数据
    比如获得用户信息:
    BOOL GetUserInfo(vector<CUserInfo>& vUserInfo);
    返回True,代表获得成功,vUserInfo中一定有数据。
    返回False,代表获得失败,vUserInfo中一定为空。

  • 返回结果数据。当结果数据为空时,表示执行状态失败。
    vector<CUserInfo> GetUserInfo();
    返回的数据为空时,表示失败;
    返回的数据不为空时,表示成功;

以上三种方式,各有各的应用场景,具体问题具体分析。

业务功能函数如果从语义上有开始、启动等意图,那必须有停止、结束等配对函数。比如有 StartDownload,就配套有 StopDownload/ExitDownload/CancelDownload之类的对应函数。相关联函数要配对使用。

同一类功能提供同一套功能API,比如设置字体,框架提供了一套获取/设置字体的接口,各个控件在具体实现时,如果在内部再保存一份字体信息,通过自定义的字体设置接口来设置,这会造成要实现一个功能,有两个接口,很容易造成设置字体用A接口,获得字体用B接口,不配套的使用造成潜在的隐患。

API的设计,如果一个接口函数返回的是指针,从对外使用者角度来看,每一个返回值都需要进行判空处理.如果该接口函数内部实现时,默认处理了错误情况,即假如获得不到的话,返回一个默认值。那么,这个接口函数返回的指针就永远不可能为空,外部使用的错误判断分支也就不可能执行到。
这是不好的设计,外部的依赖于实现的细节,而不是依赖于接口。对外部使用者来看,内部实现了错误判断,会造成外部的错误判断无效。如果非要这样做,那么在该接口函数上,应该加上详细的说明。

前后有关联性的操作,最好不要对外提供单独的接口函数,而是将这些关联性操作进行二次封装,增加相关的注释,降低外部使用出错的可能。不管是只给自己用的辅助函数,还是提供给外部使用的函数,都要符合这个原则

posted @ 2019-02-27 20:51  浩天之家  阅读(336)  评论(0编辑  收藏  举报