实效性软件构建的途径-上

Page content

前言

无意中看到了这本书,译名是程序员修炼之道,想尝试在这本书中找到一些跟软件构建相关问题的答案。这本书虽然是上个世纪出版的,要注意时代的局限性和过期的经验,进行自我验证,但一遍看下来,对我来说,干货还是有很多的。

1. 需求挖掘

这一点我认为是最重要的一点,于是放在最前面。

找出用户为何要做特定事情的原因,而不是目前要做这件事情的方式,开发最终是需要解决商业问题。 比如,“只有员工和上级和人事部门才能查看员工的档案”和“只有指定人员能查看员工档案”,后者更加容易编写出适用于元数据编程的程序,也更加的灵活。

这个用户不仅仅指实际使用的人,也可以是交给你这个工作的人。

2. 做好退路和保险

书中是用代码所存储的机器因为崩溃而引发的问题,虽然在git的时代,这种问题不容易发生了,但是这种想法得印在脑子里,如果真发生类似的问题,损失将是非常大的。

这一点和可撤销性想类似,要考虑架构部署的改动和灵活性,假设某次会议决定使用MySQL进行存储数据,但是在快完成时,需要换成其他DB进行存储,如果要改动很大,那么就是错误建立在了假定的决策上面。假定项目以后只会用到MySQL,很多代码都被写死了。

再比如开发Unix软件,是否考虑所有平台的可以执行问题,例如epoll可以在linux上使用,那么如果在只有Kqueue的FreeBSD上面会怎么样,所以需要保证代码在一些决策上可以变通。

3. 不要破窗户

这也就是常说的破窗效应,一扇破窗户,只要一段时间不修理,就会逐渐带来废弃感,逐渐变为破败的废弃物。软件中的破窗户就是指,低劣的设计,错误的决策,糟糕的代码。应该发现一个就修一个,如果没时间就加入注释,并且可以深究窗户什么时候破的,原因是什么,如何修理。

并且要注意变化,随着软件补丁的添加,会慢慢偏离其规范,周期性地审视环境的变化,以免量变引发的雪崩。

4. 重复的工作(Don’t Repeat Yourself)

这种重复不单单指代码的复制粘贴,还有可能是一些不容易发现的错误。

强加的重复

  • 例如客户端和服务端使用不同的语言,那么两端都会有类似的数据结构,可以用schema的元数据自动生成相关的类定义。
  • 文档:注释会随代码更新而过时,注释应该用于更加高级的说明,我的理解是注释应该写下这段代码应该干什么,而不是干了什么
  • 语言:例如C/C++应该在头文件的函数声明前说明接口问题,实现文件中记载实现细节
  • 文档和代码:如果边写代码边写文档,就会造成代码和文档的重复问题,比如代码改动了,文档也会随即发生变。如果最开始就根据用户的需求写成文档来生成测试,所有的代码只需要在提交时通过所有的测试即可。

无意的重复

通常是设计问题引起的,注意数据之间的关联性,书中的例子是一个数据集合中同时出现了两个点和一段距离,如果点发生了变化,那么需要同时更改距离,比较好的做法是通过点来计算距离,而不是通过赋值。

耐性的重复

这就是在项目中放着好好的代码不用,自己重写写个轮子来浪费时间。

开发者之间的重复

分工不明确导致工作职责重复,这个往往需要清晰的设计和强有力的技术项目领导进行责任划分。

5. 解耦

接口设计时,应该考虑到传入的类型,比如某个函数需要B类型中的时间成员变量,下面这种耦合度更低。

void foo(B &b) {
    theTime = b.t;
}
void foo(time &t) {
    theTime = t;
}

较小响应集

根据统计结果,较大响应集更加容易出错,响应集的定义是:类的各个方法直接调用的函数的数目。

Demeter法则

wiki:https://en.wikipedia.org/wiki/Law_of_Demeter

  1. 每个单元对于其他的单元只能拥有有限的知识:只是与当前单元紧密联系的单元;
  2. 每个单元只能和它的朋友交谈:不能和陌生单元交谈;
  3. 只和自己直接的朋友交谈。

在OOP中,这个法则的规定为某个对象的任何方法都应该只调用属于一下情形中的方法

  1. 它自身的方法
  2. 传入该方法的任何参数的方法
  3. 该类所属的成员对象所含有的方法
  4. 所持有的任何对象的方法
class Demeter {
private:
    A *a;
    int func();
public:
    // ...
    void example(B& b);
}

void Demeter::example(B& b) {
    C c;
    int f = func(); // 1. 它自身的方法

    b.invert();     // 2. 传入该方法的任何参数的方法

    a = new A();
    a->setActive(); // 3. 该类所属的成员对象所含有的方法

    c.print()       // 4. 所持有的任何对象的方法
}

元数据驱动应用

用配置来定义程序行为,比如使用什么数据库,单机还是多机等,给予程序退路,可撤销性。

时间耦合

这个名词头一次听到,概念比较简单,这种耦合发生于假定了事件发生的顺序,要考虑事件可能的发生顺序和并发性。

发布和订阅

这里的意思是将事情/业务拆分成多个例程进行处理,如果用单个例程处理所有的情况,其实就是大量的if、else的组合。如果我们对某个publisher生成的特定事件感兴趣,要做的事情就是登记自己,然后由publisher通知subscriber。

6. 正交性

正交换个词可以是不相依赖性,解耦性,消除无关事物之间的相互影响。在设计正交组件的时候,可以问自己“如果我显式的改变了某个特定功能背后的需求,有多少模块会受到影响”。

不要依赖无法控制的事物属性,比如把电话号码作为顾客标识符,如果电话公司重新分配了区号会怎么样。

在编码时:代码解耦,减少向外暴露的接口和数据,避免全局数据,避免相似的函数。

构建单元测试的时候,也是对正交性的一种验证,如果只是为了某个测试,需要拉扯到系统中其他一大部分,那么解耦性就没有做的很好。

修正bug的时候也是,修正一个bug如果要牵扯到系统的很多地方,那么也需要警惕解耦问题。

etc: 可以尝试月报自己的bug所影响文件数目的趋势。

7. 契约式编程

在项目合作过程中,可能需要不断地和他人的代码进行接合,别人的代码可能不符合高标准代码,所以需要防御性编码。用断言检查坏数据,数据库的列加上约束。更进一步,连自己的代码也不信任,防御性的编程。

  • 前条件:调用例程前的需求
  • 后条件:例程保证会做的事情
  • 不变项:在计算机科学中,不变条件是指,在程序执行过程或部分过程中,可始终被假定成立的条件。程序员往往使用断言来现式定义不变条件。

通常前条件是由调用者来保证的,也就是说,如果被调用者需要一个正整数,而调用者传递一个负数,那行为应该是未定义的。

当调用者确保了例程的前条件后,后条件和不变项都应该为真。

8. 断言式编程

判断绝不可能发生的事情,而不是进行代替错误处理。并且断言失败会退出进程,最好的是用断言产生异常,跳到某个退出点,执行清理。另外,进行断言的代码,不要再有其他的副作用。

9. 尽早崩溃

一般来说,尽早的崩溃比隐藏着错误继续运行的结果可能更坏,所以当出现问题的时候及早对程序结束运行。 有时候直接退出不合适,全局资源可能没有释放,比如一些全局锁等,所以可能需要在崩溃前进行清理,打日志等。

10. 使用异常

很有可能在 C 中看到下面这样的代码

retcode = OK;
if (socket.read(name) != OK) {
    retcode = BAD_READ;
}
if (socket.read(age) != OK) {
    retcode = BAD_READ;
}
if (socket.read(address) != OK) {
    retcode = BAD_READ;
}
return retcode;

过多的判断而导致的丑陋代码,甚至忘记代码原本要做什么就有异常进行专门处理。

retcode = OK;
try {
    socket.read(name);
    socket.read(age);
    socket.read(address);
} catch (IOException e) {
    retcode = BAD_READ;
    LOG.ERROR("Error reading from . . .");
}
return retcode;

由于C++没有Java那样在try..catch后面有finally子句,所以常常会有重复的情况,违反了DRY((Don’t Repeat Yourself)原则,比如:

void doSomething(void) {
    Node *n = new Node;

    try {
        /* do something */
    }
    catch (exception e) {
        delete n;
        throw;
    }
    delete n;
}

碰到这样的情况,这种情况下,通常需要把 n 转变为栈上对象,如果非得需要使用指针,可以利用智能指针进行自动销毁。

参考资料