目录

软件设计随想

在我看来软件设计主要做两件事一个是划分边界另一个是做权衡

划分边界往小了说就是一个变量应该放到哪个模块(类或包),往大了说一个功能应属于哪个服务,有了边界还要考虑它们之间的依赖关系。权衡也有多种情况:比如功能实现上的优先级、扩展性的度、更细节点就是算法的时间与空间的抉择;还有限制上的平衡,比如一个函数的参数是个更宽泛的接口(基类)还是针对性的具体类型。

什么是软件架构

从开发者的角度来看,尽管他们英勇,加班和奉献,但他们根本没有得到任何东西了。 他们所有的努力都被从开发功能特性中转移出来,现在被用来管理混乱。 他们的工作已经改变了,从一个地方转移到另一个地方,下一个和另一个地方,这样下去他们就只能增加小小的功能特征了。— 《架构整洁之道》

创建一个能运行的程序是相对简单的,但让程序持续的运行,并且对不断变化的需求做出反应就复杂的多。如果算法+数据结构=程序的话,那么底层设计+高层架构=软件系统。 而一个软件系统是由多个功能组合而成,那么组合的方式就是所谓的架构,功能的设计与组合方式是互相影响的, 所以设计与架构无需刻意区分。

软件架构的目标

最小成本构建业务需求 –《架构整洁之道》

架构的设计就是为了实现业务需求,这是最最基本的,否则软件毫无用处。而业务是会随着用户或者市场需求不断变化的,所以我们的架构要适应变化也是非常重要的。适应也就意味着用更小代价来构建新需求。所以这是软件架构的核心目标。

我们在架构设计中还要考虑需求的实现与适应变化(可扩展性)之间的权衡,比如从开发角度改动影响大的应该优先开发,不影响其他逻辑的可以延后;从产品角度有急需上线的也有不太着急的功能。设计者还需要考虑一个的问题,当前产品100个用户,就不应该考虑1w个用户的场景,但可以稍微考虑1000个用户的情况,那么这个可能受开发进度的影响,或者市场变化的影响。

编程范式

知道了架构目标,那么实现目标的方法是怎样的呢?那就是编程范式,常见的编程范式主要是三种:

范式 作用 限制
结构化编程 将模块递归降解拆分为可推导的单元,更方便进行测试进行证伪,限制了goto 对程序控制权直接转移进行了限制和规范
面向对象编程 利用核心的多态性对依赖关系进行反转(策略与实现的分离) 对程序控制权的间接转义进行了限制和规范
函数式编程 对可变性进行了隔离 对程序中的赋值进行了限制和规范

结构化编程

代码实现后还需要验证它的准确性,防止bug产生,而软件测试是复杂的。Dijkstra 提出的解决方案是采用数学推导方法:程序员可以用代码将一些己证明可用的结构串联起来,只要自行证明这些额外代码是正确的,就可以推导出整个程序的正确性。

在整个证伪过程中: goto 语句的某些用法会导致某个模块无法被递归拆分成更小的、可证明的单元,这会导致无法采用分解法来将大型问题进一步拆分成更小的、可证明的部分。另外goto完全可以由其他语句替代(while、if/else):

Bohm 和 Jocopini 证明了人们可以用顺序结构、分支结构、循环结构这三种结构构造出任何程序。这个发现非常重要: 因为它证明了我们构建可推导模块所需要的控制结构集与构建所有程序所需的控制结构集的最小集是等同的。 这样一来,结构化编程就诞生了

Dijkstra: 测试只能展示bug存在,不能证明不存在bug

计算机程序的准确性是无法证明的,只能证伪。因此我们只能在尽可能多的情况下确保程序是没有bug的,但无法证明程序在任何条件任何情况下都是完美的。

总结
结构化编程限制了goto,将模块拆解为可推导的单元,更容易进行测试

面向对象编程

首先明确一点就是这些编程范式都是设计并非技术,所以任何语言都可以达到范式的效果。面向对象所谓的封装继承多态,即便非面向对象语言也可以支持。

比如多态性:

/*
file:
int getchar(){
    return FILE->read();
}

网络io:
int getchar(){
    return SOCKET->read();
}
*/

void copy() {
    int c;
    while((c = getchar()) != EOF){
        putchar(c);
    }
}

这是c语言一个copy功能,只要具体设备实现了getchar接口,无论我们写文件或者网络io都可以用此功能。这就是多态性,调用者不用改动任何写入代码,即可支持多种设备。

这是函数指针的一种应用,而函数指针具有危险性(只有在具体调用的时候才能确定是否实现了接口),只能依靠人为遵守约定。

面向对象的作用就是对封装继承的显示支持,并且让多态可以更安全的使用(语言层面定义函数指针的规范)。依靠多态性可以更简单安全的实现依赖反转。

什么是依赖反转?高层模块不要依赖低层模块,高层模块和低层模块应该通过抽象来互相依赖,举个例子:

class Animal {
    public void say() {
        System.out.println("do nothing")
    }
}

class Cat extends Animal{
 public void say() {
  System.out.println("meow");
 }
}


class Dog extends Animal{
 public void say() {
  System.out.println("woof");
 }
}

class Main {
    public static void main(String[] args) {
        List<Mammal> mammals = new ArrayList<>();
        mammals.add(new Dog());
        mammals.add(new Cat());
        
        for (Mammal mammal : mammals) {
            mammal.say();
        }
    }
}

主函数的流程(高层模块)与具体实现的DogCat类(底层模块)不再依赖。中间通过Animal(抽象层 可以是接口、基类、抽象类等)解耦。高层模块不再依赖具体的实现,而是反过来了,底层实现依赖上层提供的接口,在面向对象编程中,得到了更加简单安全的实现。

非依赖反转

符合依赖反转

总结
面向对象让高层策略性组件与底层实现性组件分离,让插件式的架构流行起来,还让高层组件可以独立与底层实现部署。

函数式编程

函数即不依赖外部的状态也不修改外部的状态,函数调用的结果不依赖调用的时间和位置,这样写的代码容易进行推理,不容易出错,这使得单元测试和调试都更容易。 另外不修改外部状态在多线程下更加简单,不用考虑锁、脏数据问题。

总结

每个范式提出了新的限制,约束了编写方式,并没有增加新的能力。告诉我们不该做什么,而不是告诉我们该做什么。结构化编程限制了流程,拆解了模块,方便测试证伪;面向对象限制了依赖关系,高层不再依赖具体实现,这种解耦带来了众多好处;函数式编程限制了赋值,解决线程带来的问题。我们一般的应用开发,这几种范式都可能会用到。

关于设计原则

设计原则的目的: 让设计更容易改动和复用,既然容易改动和复用也就意味着其他开发人员更容易理解。

如果说编程范式约束规范了我们的整套代码,那设计原则是对模块与模块之间关系的规范,我们常说的设计原则主要指的是SOLID原则,也就是:

  • 单一职责
  • 开闭原则
  • 里氏替换原则
  • 接口隔离原则
  • 依赖反转原则

这几种原则其实是殊途同归,都是为了划清边界,理清依赖关系。

再说面向对象

上面说过,编程范式只是限制了某些事情,而不是增加了某些能力。封装继承多态,并不是面向对象语言独有的,只是面向对象语言对其进行了更加规范的语言层面的限制。

我最开始说划分边界,那封装的作用就是明确了成员的内外关系,划分了边界

继承则是有两个作用一个是代码复用、另一个是抽离策略与行为(基类设定行为,子类具体实现细节)。然而代码复用不需要继承也能轻易实现,如果为了代码复用利用继承关系,反而让两个类产生了关系增加了耦合,强面向对象语言比如c#或java,任何成员(变量或者函数)都需要宿主(类或结构体),那么代码复用要么利用继承关系,要么new一个对象再复用。不过还好有个静态类可以更简单的应对复用。

继承的策略与行为的抽离才是最关键的,因为两者的分离才有了多态性让代码变得更。然而策略与行为的抽离并不是继承创造出来的,而是函数指针的功劳。那么看来面向对象的继承实际上并没有什么优点,可能唯一的优点就是让多态的实现变得更安全和便捷。

多态性使得调用者无需关注具体实现者,多个不同实现者也可以用同样方式调用。

封装使得成员明确了职责,划分了边界,多态让依赖关系更加容易,这两点让代码模块化更简单灵活。而面向对象语言从语法层面规范了它们。