代码的设计

前言

从二进制到汇编,再到高级语言。我们编码已经从面向机器编程到面向人的编程转变。同样效率也从机器运行效率(性能)到面向开发效率提升的转变。这些转变都是越来越趋向于“人性化”,软件设计的目的也在于此。这篇文章将介绍关于软件设计的一些概念,更准确的说是面向对象设计的概念。

1.设计—复杂度管理

在软件开发中我们一直在强调设计,但是很多时候我们往往为了设计而设计,甚至忘了设计的初衷是什么?我们会用类似可维护性、可复用性、可扩展性、可测试性…这些来度量软件设计的好坏,但是这些标准深层次的目的又是什么呢?在编写一个简单的程序时我们几乎不需要什么设计,可是一旦细节、依赖变多,这时设计就会变得越来越重要。设计根本的目的就是从工程学的角度帮组人管理软件的复杂度。下面我们来看看设计应从哪些角度来管理软件的复杂度。

1.1 抽象

抽象是软件开发非常重要的能力之一,它是一种忽略细节的能力。人对细节的处理非常不擅长,于是我们喜欢对现实问题或事物进行归纳、分解来帮组我们记忆和理解。人与生俱来就有很强的抽象能力,比如“人”、“工具”、”鸟”等等名词都是人类抽象出来的概念,我们很难具象它们,但是它们都由相似的特征来表述。面向对象语言中的接口都是对事物或行为的抽象:

  • :类是具体实例的抽象。比如class Employee它抽象了员工实例的基本特性(Name,EmployeeNo,Age,Position,Sex,Department),也许你已经发现员工的属性也是抽象的类型。我们通过忽略了各种实例的具体细节,从而将成千上万在实例托管给类来管理。

  • 接口:接口是基于类的进一步抽象。它抽象出类与类(模块与模块)之间相互依赖的最小集合,从而隐藏具体在实现细节和复杂度。比如打点滴时针管就是很好的接口。病人与药瓶(具体的细节)的依赖变成了只有不到1毫米的针管,这样即便药瓶发生了变化,也不会影响病人的输液。

好的抽象不仅对细节进行了隐藏,同时也要保证抽象的一致性和完整性。好的完整性保证了代码的高内聚,我们应该将相关的操作定义在相同的类或者接口中,将不相关的特性或操作封装在其它的对象中。好的抽象可以简化解决复杂问题的方式,同时清晰问题的本质。

1.2 信息隐藏

知道的越多越危险,过多的信息暴露会导致管理上的失控。人在处理大量信息时总是显得捉襟见肘(乔治.米勒7±2原则:人类信息加工能力在某些局限),在软件开发亦是如此。封装是隐藏信息的主要方法,通过封装可以减少模块间的相互作用,同时增强模块内相关信息的管理,那么我们应该封装什么呢?

  • 封装细节:客户对接口背后的实现应该知道在越少越好。这就是为什么应该面向接口编程,而不是面向实现编程,接口不仅保证了封装性,而且隔离了对具体实现的依赖。

  • 封装变化:依赖具有传播性,一旦暴露的变化点被触发,那么所有依赖项都会成为变化源,从而影响软件的稳定性。封装变化点可以有效的避免变化的扩散。

迪米特法则告诉我们软件开发中应该如何做好信息隐藏

迪米特法则(Law of Demeter):一个软件实体应当尽可能少的与其他实体发生相互作用。每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。

下面浮冰这张图,是信息隐藏一个很好在隐喻。 冰川图片

1.3 高内聚,低耦合

依赖关系是软件开发最复杂的因素之一。面对复杂问题时我们习惯将其分解成若干个简单问题处理(解耦),但是过度的分解也会增加依赖关系的复杂度,所以平衡好问题本身的复杂度和分解颗粒度之间的依赖关系是非常重要的设计艺术。高内聚,低耦合就是衡量软件设计好坏的重要指标之一。抽象、封装的本质也是在保证软件的高内聚和低耦合。

  • 耦合由强到弱:内容耦合 > 公共耦合 > 外部耦合 > 控制耦合 > 标记耦合 > 数据耦合 > 非直接耦合

  • 内聚由强到弱:功能内聚 > 顺序内聚 > 通信内聚 > 过程内聚 > 时间内聚 > 逻辑内聚 > 偶然内聚

以上是耦合与内聚的强弱关系,软件设计应该尽可能的做到功能上的内聚,避免内容上的耦合,如果存在依赖,最好使用数据耦合。

2.面向对象设计

上面提及软件设计过程中应该注意的几个关注点,它适用于任何范式的语言,而作为面向对象语言,它利用本身的一些特性来完成上面所提及的内容。下面我们简单的聊一聊在面向对象设计中我们经常遇到在一些概念。

  • 类与接口

面向对象语言利用classinterface关键字来抽象具体的对象或行为,Java或C#甚至提供了abstract关键字来对类进行抽象。C++并没有提供interfaceabstract关键字,但是我们可以通过建立只包含纯虚函数的类来代替interface,同样用包含部分纯虚函数的类来代替abstract。这里有必要强调下接口是一组相关操作的抽象,它用于封装细节和隔离具体实现。接口是模块间沟通的契约,它应该是相对稳定的(并不表示不能变化),如果接口经常性的变化,那么客户代码也会受到影响,所以设计接口时,应该注意以下几点:抽象的接口不应该经常变化;函数命名应该具体且能自注释;考虑接口版本变化的兼容性。

enum Role{
   Level1,
   Level2,
   Level3   
}

class UserRequest{
  Role role,
  time_t registerTime
}

class IUserService{
   public:
       virtual std::vector<User> getUsers(const UserRequest& reqUser) = 0;
       virtual std::vector<User> getList(Role role, time_t time) = 0;
}

上面例子定义了两个功能一样的接口函数,很明显第一个接口函数,用户看到名字就知道函数的具体功能。另外第一个函数相对更加稳定,因为如果接口需要添加一个或减少一个参数时,第二个函数变化会比较大,而且依赖第二个函数在客户代码也会受到很大的影响。相比较第一个函数对用户的影响更小。

  • 封装

面向对象语言都提供类似publicprotectedprivate这样关键字来限制访问。在类设计时我们应该尽可能的使用private,谨慎的使用protected(除非确实必要重用基类的方法或成员)。在很多编码规范中都是禁止将类的数据成员定义成public,它们必须通过类似get/set(或C#的Property)这样简单的函数访问,一些开发人员会觉得有些多此一举。但是谁又能保证将来我们不会去修改它们,如果一开始就通过函数封装这些变量,那么只要接口函数不变,里面在逻辑和变量不管怎么改变,外部用户都不会受到影响。这些都是封装带来在好处。除了通过访问限制来封装数据,我们还可以通过接口来封装具体实现。甚至从更高的维度来说,抽象提取业务类型、函数等都是一种封装。

  • 继承和组合

继承是面向对象语言最重要的特性之一。不同语言提供了不同的继承方式,C++可以同时继承多种类型,但是这会增加编程的复杂度(比如菱形继承),所以像Java或C#这样更高级的语言放弃了对多继承的支持,只保留了对接口的多继承。

继承表达的是一种”is-a”的关系(这里排除C++的私有继承),它是一种具体和一般化的关系。通过继承可以共享代码,避免重复,同时继承也是扩展已有代码的一个重要手段。下面是继承的例子

class Engine{
    public:
        void start();
        void shutdown();
};
class Car : public Engine{
     public:
          void start(){
               start();
                // do something
          }
          void stop(){
               shutdown();
               // do something
          }
}

因为Car继承了Engine类,所以它可以重用Engine类中的所有publicprotected方法。但是如果这个时候我们修改了Engine类,那么Car也会需要改变。因为子类Car知道父类Engine的实现细节,而且在编译时这些行为已经确定不能被改变,如果父类发生了变化,子类也会受到影响。随着业务的不断变化和复杂度的增加,继承关系也会变得更加复杂,甚至有的继承层次规模会变得不可控。这些因素都大大降低了代码灵活性和可复用性。

这里需要介绍另一个概念叫做组合,它是一种整体和附属的关系,通常我们叫做“has-a”。它在一定程度上保证了每一个类的封装性,并且控制类和类继承层次的规模。下面是组合的实现示例

class Car{
public:
     void start(){
      _engine.start();
     }
     void stop(){
       _engine.shutdown();
     }
private:
    Engine* _engine;
}

是不是和继承相比封装性更好!这就是为什么在面向对象设计中建议遵循组合/聚合复用原则(CARP原则-Composition/Aggregation Principle)- 尽可能的使用组合而不是继承。

  • 多态

多态是指同一个实体拥有多种形态。在面向对象设计中有两种方式实现多态:

  • 动态绑定:利用虚函数实现,并在运行时替换实体行为,虽然在性能上有所折扣,但提高了代码的灵活性,这也是面向接口编程的基础。
  • 静态绑定:它通过泛型编程在编译阶段确定具体类型的行为,这不仅降低代码的冗余,同时对性能有一定的提升。

在面向对象中充分的使用多态的特性,有助于提升代码的灵活性和可复用性。下面是多态的代码示例:

class IAnimal{
    virtual void talk() = 0;
    virtual void walk() = 0;
}

class Duck : public IAnimal{
    virual void talk(){
        // 鸭子的叫声
    }
    virtual void walk(){
        // 鸭子的行走
    }
}

class Dog: public IAnimal{
    virual void talk(){
        // 狗的叫声
    }
    virtual void walk(){
        // 狗的行走
    }
}

3.设计之美

前面提到两个非常重要的概念:面向接口编程,而不是面向实现编程优先使用组合,而不是继承。但是在现实的项目中经常看到极端的教条设计,曾经遇到过一个项目要求凡是新建类,就必须继承一个接口。这种不经过抽象的接口除了给自己添加工作量,并没有其它任何好处。接口已经完全依赖于实现,实现类变化就会导致接口的变化,设计已经没了美感,变成了机械的服从。我们提倡面向接口编程,但是我们并不排斥依赖类似string这样相对稳定的类。同样面对继承和组合时,如果语意更符合继承,那么我们应该理所应当的使用继承,而不是照本宣科的使用组合。设计的美是抽象的美,平衡抉择的美,它来自人的内心感受而不是机器。设计本身也在增加复杂度,那我们还有必要做设计吗?答案是有必要,设计本来就是在可预见的情况下(不是过度设计)做权衡(事物本身复杂度的减小与设计带来的复杂度)的过程,而且设计本身就是一个长期正收益的投资,有时我们就应该牺牲短期的利益换取长期的可扩展性。

(转载本站文章请注明作者和出处,请勿用于任何商业用途)

上一篇:软件全球化-Globalization
下一篇:设计模式-Factory模式