Jan Fan     About     Archive     Feed     English Blog

How to Write Good Code 系列(1)

作为一个软件工程专业的学生,在将近毕业的时候才开始明白“如何写出好程序”的道理,无论如何也是必须捂脸的呢。

在大三找实习面试阿里的时候,面试官问到我“设计模式”的东西,印象中是“工厂模式”之类的,我非常直接了当地回答这个我不会,我没学过设计模式。

其实并非没有学过。 我扫过一遍《大话设计模式》,刷了半本《Head First Design Pattern》,但你要问我有什么收获,我的回答是:“完全没有。”

在我幼稚的观念里,“设计模式”、“软件需求分析”和“面向对象设计”,实在是“假大空”的三大杰出代表。 写程序就写程序,哪有那么多讲究,做好测试不就好了。

这只能反映出一个问题——我不懂得程序设计。 一个程序员只要着手一个稍微复杂的系统,或者是按照《构建之法》里描述的程序员职业发展的”大马哈鱼洄游模型”来学习,他就不可能不知道这些理论的重要性。

这篇文章是我学习程序设计的开始,希望各位同行对下面和以后的内容能有共鸣,一起探讨。

什么是好的代码

好的代码之所以好,并不只是它各种良好的”-ilities”,并不单纯是它所谓“美”的艺术价值。 还在于如果你不这样做,你日后必定会在糟糕的代码上耗费无尽的时间和精力。

我不想按着教科书列出一长串我懂或者不懂的优秀代码特性,我只挑我会的说。

这也是这个系列将会覆盖到的内容。

Readability

可读性并非仅仅是“注释”的问题。 也许详尽的注释可以让人读懂,但我们要求的更多,我们要更快更容易地读懂。

我们一段简单代码的“进化”过程。 这段代码源自我的一个解释器项目,完成的功能是对语法树中的“函数”进行语义解释,代码省略了不必要的细节。

Constant* RefFuncHandler::IntrOperand(NestedSymbolTable *sym, FuncTable *fsym, Constant **back) {
    ...
    /* User-defined Functions */
    BindFuncArgs(sym, fsym, back);
    if (!fsym->IsSymbolDefined(r->GetID())) {
        cerr << "Error in IntrOperand: symbol " << r->GetID() << " not defined" << endl;
        exit(0);
    }
    AST_Func *func = (AST_Func*)(fsym->LookupSymbol(r->GetID()));
    for (AST_Statement *st : func->block->statements) {
        Interpreter::IntrStatement(st, sym, fsym, back);
    }
    UnbindFuncArgs(sym, fsym, back);
    Constant *res = *back;
    ...
    return res;
}

好了,我们要开始动手术了。

Constant* RefFuncHandler::IntrOperand(NestedSymbolTable *sym, FuncTable *fsym, Constant **back) {
    ...
    /* User-defined Functions */
    BindFuncArgs(sym, fsym, back);
    CheckFuncDefined(fsym);
    IntrFuncBlock(sym, fsym);
    UnbindFuncArgs(sym, fsym, back);
    Constant *res = *back;
    ...
    return res;
}

比起前者,后者是不是更容易读懂了呢? 前者即使给各个部分添上注释,表明它们的作用,也远远达不到后者的可读性。 因为后者拥有一个非常重要的优点——一个函数内部的操作具有相同的抽象等级。 这样的代码能做到Self-explanatory,使整个系统呈现一个Top-down结构,不把读者的注意力浪费在低层次的细节上。 仅仅这个方面,《Clean Code》的第三章“Functions”就要值为书本钱。

我以前以为,如果代码并不分享出去,或者根本没有人会来研究,那代码的可读性要不要也没差,反正只有自己看。

但我当时不理解的是,良好的可读性同时意味着太多其它优秀的代码品质。 容易理解的命名、越少的函数参数不仅仅提高了可读性,同时也意味着函数职责的单一明确和较少依赖。 函数的参数越多,函数的功能越模糊,也越依赖于其它的模块,也越不容易取个好名字。

Object-Oriented Design

面向对象设计是块已经被说烂了的旧布。 但我还是得接着说。

在半个月之前,我还是个不懂得面向对象设计的程序员(当时我现在也依旧是没有对象hhh)。 “类(Class)”对我来说,它的意义也仅仅只是停留在数据结构课程上的“Abstract Data Structure”的“封装(Encapsulation)”上——即改变底下操作的实现,也不影响上层的接口的调用。

在大多数时候,我都是用着C++的Class去做各种各样C语言的事情。 getter()setter()简直就像在做大作业答辩前的表面工夫。 现在,我邀你一起来升升级!

Data Structure vs. Object

这是我认为理解面向对象的一个非常重要的概念。 “结构”和“对象”是程序设计的两把尖刀,我们必须要知道它们各自擅长的地方。

The difference between objects and data structures. Objects hide their data behind abstractions and expose functions that operate on that data. ** **Data structure expose their data and have no meaningful functions.

这个特性看似平平无奇,让我们来模拟一个代码场景,分分钟亮瞎双眼。

我们先从比较常见的“结构”开始。 既然需要结构,必然伴随着对结构里的数据的操作。 我们以一个简单的Print()操作为例。

enum DATA_T {
    DATA_BOOL,
    DATA_INT
};

struct Data {
    DATA_T type;
    union v {
        bool b;
        int i;
    } val;
};

void Print(Data _data) {
    switch(_data.type) {
        case DATA_BOOL:
            cout << _data.val.b <<endl;
            break;
        case DATA_INT:
            cout << _data.val.i <<endl;
            break;
        default:
            cerr << "Error: unknown type" << endl;
    }
}

接下来,如果我们突然又多了一个ToOpposite()操作的需求呢? So easy!

Data ToOpposite(Data _data) {
    switch(_data.type) {
        case DATA_BOOL:
            _data.val.b = !_data.val.b;
            break;
        case DATA_INT:
            _data.val.i = -_data.val.i;
            break;
        default:
            cerr << "Error: unknown type" << endl;
    }
    return _data;
}

经过上面的改动,我们有两个发现

  1. 每增加一个操作,我们只需要多写一个对该结构进行操作的函数,不影响结构
  2. 每个新的操作,都需要重复一个switch语句

接下来,新的需要又来了——我们需要增加一个新的数据类型double

enum DATA_T {
    DATA_BOOL,
    DATA_INT,
    DATA_DOUBLE	// new
};

struct Data {
    DATA_T type;
    union v {
        bool b;
        int i;
        double d;	// new
    } val;
};

一旦做出了这样的改动,全部的操作里的swich语句都要新增一个case,删减一个数据类型也是同样的道理。 但如果是用“对象”的方式来写,增加一个新的数据类型就十分容易了。 但这个时候如果要增加一个ToOpposite()的操作,就得同时修改4个类了,不如“结构”来得容易。

class DataHanlder {
public:
    virtual void Print() = 0;
private:
};

class BoolHandler : public DataHanlder {
public:
    void Print() { cout << b << endl;}
private:
    bool b;
};

class IntHandler : public DataHanlder {
public:
    void Print() { cout << i << endl;}
private:
    int i;
};

class DoubleHandler : public DataHanlder {
public:
    void Print() { cout << d << endl;}
private:
    double d;
};

是时候给出我们的结论了。

Procedural code (code using data structures) makes it easy to add new functions without changing the existing data structures. OO code, on the other hand, makes it easy to add new classes without changing existing functions.

Procedural code makes it hard to add new data structures because all the functions must change. OO code makes it hard to add new functions because all the classes must change.

程序员需要在“结构”和“对象”两者之间做trade-off,两者各有优劣。

模块的内聚和解耦

大的工程是必须分成多个模块来降低复杂性的,而各个模块之间又不得不有通信,我们能做的就是尽量解耦,让模块A的变动不影响到模块B。 话都是这么说的,但操起刀来真干的时候事情马上就复杂起来了。

难怪乎程序设计的名言说道:“No Silver Bullet”。 以我浅薄的编程经验看来,比起这两种方式的优劣,更重要的是要考虑数据类型与操作的紧密程序

举个例子,操作Add()的对象只是int类型,和另外两个数据类型根本没有关系。 这个问题在“对象”设计方式里也会出现。

struct Data {
    DATA_T type;
    union v {
        bool b;
        int i;
        char c;
    } val;
};

void Add(Data a, Data b);

这种低内聚的设计势必要数据和操作都松散地依赖于多种因素,使改动的可能大大增加。

而目前我个人的解决办法,是尽可能充分地做好需求分析和Design Review,使实际编码返工的机会尽可能减少,这个方式的确能上开发效率上一个台阶。 另外我逐渐倾向于在设计的时候采用“对象”的方式来设计,因为“结构”的代码里往往需要大量重复的switch语句,使代码十分冗余,修改的时候更令人苦不堪言;而“对象”的代码往往更整洁有更好的可读性。

最后

对了,本文是默认假定你已经明白最起码的代码测试的重要性。 如果你不明白,那得赶紧去学习,Python的unittest、Java的JUnit和C++的gtest都是不错的选择,《凌波微步》的“测试篇”也值得一看。 否则就算是给你武松打虎的胆量,你也是绝对绝对不敢着手开始重构的 :-P

主要参考资料

Comments

多说 Disqus