zl程序教程

您现在的位置是:首页 >  工具

当前栏目

《软件设计之美》阅读笔记

笔记 阅读 之美 软件设计
2023-06-13 09:11:16 时间

开篇词

「算法对抗的是数据的规模,而软件设计对抗的是需求的规模。」

软件设计包括:「程序设计语言、编程范式、设计原则、设计模式、设计方法」

软件设计

「软件设计就是构建出一套模型。」

模型是一个软件的核心;模型的粒度可大可小;模型应该高内聚,低耦合;模型可以分层,底层模型提供接口来构建上层的模型。

模型的设计需要被「规范约束」

规范的两个常见问题:「项目缺乏显示、统一的规范、规范不符合软件设计原则」

分离关注点,关注点越多越好,粒度越小越好。

了解软件的设计

三个部分着手:「模型、接口和实现」

  • 「模型」也就是软件的核心,也称为抽象。
  • 「接口」决定了软件通过怎样的方式,将模型提供的能力暴露出去。
  • 「实现」是指软件的模型和接口具体是如何实现的。必须建立在模型和接口的基础之上。

模型和接口的稳定性比实现高,实现要随着软件发展不断调整。可以理解为「向下兼容」

了解软件的模型

首先我们要知道项目提供了哪些模型,模型提供了哪些能力。

「理解模型的关键」在于:了解模型设计的来龙去脉,知道如何解决相应的问题。

了解软件的接口

理解接口先找主线,找到项目一个主线的方法就是从文档开始,文档会把项目最基本的用法展现出来,方便「找到主线」

找到主线之后,梳理相关的接口。读源码也可以借鉴该方式,找到代码调用的入口,逐渐深入。

了解软件的实现

理解实现,前提是要理解模型和接口。

了解系统的实现,应该从「软件结构」「关键技术」着手。

语言的模型

学习程序设计语言主要是学习提供的「编程模型」。比如:不同的程序组织方式、不同的控制结构等。

程序设计语言的发展

「图灵机是所有程序设计语言最底层的模型」。图灵机为计算机能够解决的问题划定了边界。

由于计算机只认识0和1,因此「汇编语言」产生了。汇编语言提供了操作计算机的指令,但是只能对计算机了如指掌才可以用好汇编语言。

因此,诞生了「高级程序设计语言」。解决的痛点包括:如何组织程序、内存管理、抹平不同计算机的差异。当硬件不是性能瓶颈的时候,发展方向成为「如何更好地解决问题」

「函数式编程语言」「动态语言」就是这个时期发展出来的。

语言的发展历程就是逐渐添加新模型的过程。语言之间互相借鉴与学习,语言本身不断完善与进化。

学习程序设计语言其实就是学习提供的编程模型。因此不提供新编程模型的语言不值得刻意学习。「一切语法都是语法糖」

语言的接口

封装出一个好的程序库所需的能力,就是软件设计所需的能力。

程序库是为了消除重复出现的。而「消除重复」是软件设计的初衷。

学习一门新语言,首先是学习「语法」(Syntax),然后是「程序库」(Library),之后是「运行时」(Runtime)。

常用的程序库会随着语言一起发布成为「标准库」。进一步的,社区会根据标准库进行进一步的封装,这就是「第三方程序库。」

第三方程序库会提供新的编程模型或者接口,用来完善标准库。

用来管理第三方程序库的方案,就是「包管理器」。比如Java 的 Maven、Node 的 NPM、Ruby 的 RubyGems 等等,而像 Rust 这样年轻的语言,包管理器甚至变成了随语言的发行包一起发布的一项内容。

学习一种程序设计语言提供的模型时,不仅仅要看语法本身有什么,还要了解有语言特性的一些程序库。

「语言设计就是程序库设计,程序库设计就是语言设计」。二者相互促进,不断发展。

语言的实现

程序设计语言的实现就是支撑程序运行的部分:运行时,也有人称之为运行时系统,或者运行时环境。主要是为了实现程序设计语言的「执行模型」

「运行时」是做软件设计的根基。理解运行时,可以将“「程序如何运行」”作为主线,将知识贯穿起来。

设计一门语言

「程序设计语言的趋势」,就是离计算机本身越来越远,离要解决的问题越来越近。

「领域特定语言」(Domain Specific Language,简称 DSL),它是一种用于某个特定领域的程序设计语言。

正则表达式、Nginx、md、SQL、配置文件都可以算是DSL,为了解决某些需求。

实现DSL的核心是模型,语法的实现是将模型的能力暴露出来。构建DSL其实就是写接口。

「外部 DSL」「内部 DSL」 的区别就在于,「DSL 采用的是不是宿主语言」(Host Language)。

从实用性的角度,更好地挖掘内部 DSL 的潜力对我们的实际工作助益更多。因为省去了解析,也不用设计语言。

「将意图与实现分离开来」,是内部 DSL 与普通的程序代码一个重要的区别。

DSL 的 4 个关键元素:

  • 计算机程序设计语言(Computer programming language);
  • 语言性(Language nature);
  • 受限的表达性(Limited expressiveness);
  • 针对领域(Domain focus)。

总结

「分离实现和意图」是重要的设计原则。

编程范式

「编程范式」(Programming paradigm),指的是程序的编写模式。主流的编程范式有三种:

  • 「结构化编程」(structured programming);
  • 「面向对象编程」(object-oriented programming);
  • 「函数式编程」(functional programming)。

每一种编程范式都有约束:

  • 结构化编程,限制使用 goto 语句,它是对程序控制权的「直接」转移施加了约束。
  • 面向对象编程,限制使用函数指针,它是对程序控制权的「间接」转移施加了约束。
  • 函数式编程,限制使用赋值语句,它是对程序中的「赋值」施加了约束。

「编程范式是对程序员编程能力的限制和规范。」

「在一个程序中应用多种编程范式已经成为了一个越来越明显的趋势。」

「学习不同的编程范式,将不同编程范式中的优秀元素应用在我们日常的软件设计之中。」

结构化编程

结构化编程带来一个重要方面就是「功能分解」,将大问题拆分成可以解决的小问题。

结构化的不足:各模块的依赖性太强,「不能有效隔离」。因此需要与「其他范式配合使用」

面向对象

封装

「封装是面向对象的根基」,将紧密相关的信息放在一起,组成一个单元。然后可以通过单元组合出更大的单元。也可以对单元进行复用。

面向对象带给我们更宏观的思考方式。

封装的重点是「对象提供哪些行为」,而不是数据。因此尽量不要使用getter和setter,这相当于将实现的细节暴露了出去。

正确做法是:先考虑对象应该提供哪些行为。然后根据行为提供方法,最后考虑需要用到的字段。

封装的意义在于减少对外的暴露:包括「减少数据的暴露和接口的暴露」。封装的关键原则:「最小化接口暴露,不要暴露实现细节」

继承

从子类的角度看,叫做「实现继承」。从父类的角度看,叫做「接口继承」

有一个通用的原则:「组合优于继承」。因此如果是实现继承,且组合也可以实现,尽量使用组合。如果为了代码复用,最好优先使用组合。

类是有一个个小模块组合而成,这种编程思想就是「面向组合编程」

多态

多态,一个接口,多种形态。接口继承就是给多态用的。

构建抽象上,接口扮演重要角色。接口将变的部分和不变的部分隔离开来。不变的部分就是接口的约定,变的部分就是子类的实现。

接口可以清晰界定不同模块的职责。

「理解多态就要理解接口的价值」,接口的关键是谨慎选择接口中的方法。面向接口编程的价值在于多态,多态让诸如开闭原则、接口隔离原则得以成立。

只要遵循相同的接口,就可以表现出多态,因此多态不一定要依赖继承。

总结

  • 面向对象编程,重要的是「封装和多态」
  • 「面向接口编程」
  • 「组合优于继承」

函数式编程

函数式编程提供给我们的「编程元素是函数」。此函数来源于数学中的函数。

Lambda可以理解为「匿名函数」。Lambda演算和图灵机是等价的。

「函数是函数式编程的一等公民」(first-class citizen)。一等公民指的是:

  • 它可以按需创建;
  • 它可以存储在数据结构中;
  • 它可以当作实参传给另一个函数;
  • 它可以当作另一个函数的返回值。

函数式编程有两大特性:「组合性和不变性」

组合性

「高阶函数」:可以接受函数作为输入,也可以返回函数作为输出。

「模型提供者提供出来的是一个又一个的构造块,以及它们的组合方式。由使用者根据自己需要将这些构造块组合起来,提供出新的模型,供其他开发者使用」

「列表转换有三种典型模式,分别是 map、filter 和 reduce」

  • 「map」 就是把一组数据通过一个函数「映射」为另一组数据。
  • 「filter」 是把一组数据按照某个条件进行「过滤」,只有满足条件的数据才会留下。
  • 「reduce」 就是把一组数据按照某个规则,「归约」为一个数据。

面向对象组合的元素是类和对象,而函数式编程组合的是函数。

「我们可以用面向对象编程的方式对系统的结构进行搭建,然后,用函数式编程的理念对函数接口进行设计」

不变性

函数式编程的不变性主要体现在「值和纯函数」上。

值,你可以将它理解为一个初始化之后就不再改变的量,换句话说,当你使用一个值的时候,值是不会变的。

纯函数,是符合下面两点的函数:

  • 对于相同的输入,给出相同的输出;
  • 没有副作用。

「编写纯函数的重点是」「不修改任何字段」「也不调用修改字段内容的方法」

其他特性

「惰性求值(Lazy Evaluation)是一种求值策略,它将求值的过程延迟到真正需要这个值的时候」

有了惰性求值,我们就可以创造出一个无限长的集合。这就是「无限流」

「记忆」是一种优化技术,主要用于加速计算机程序,其做法就是将昂贵函数的结果存储起来,当使用同样的输入再次调用时,返回其缓存的结果。

「Optional」是为了解决空对象而产生的,它其实就是一个对象容器。因为这个容器的存在,访问对象时,需要增加一步思考,减少犯错的几率。

设计原则

「SOLID 原则」是什么呢?它实际上是五个设计原则首字母的缩写,它们分别是:

  • 「单一职责原则」(Single responsibility principle,SRP)
  • 「开放封闭原则」(Open–closed principle,OCP)
  • 「Liskov 替换原则」(Liskov substitution principle,LSP)
  • 「接口隔离原则」(Interface segregation principle,ISP)
  • 「依赖倒置原则」(Dependency inversion principle,DIP)

单一职责原则

正如 Robert Martin 所说,单一职责的定义经历了一些变化。在《敏捷软件开发:原则、实践与模式》中其定义是,“「一个模块应该有且仅有一个变化的原因」”;而到了《架构整洁之道》中,其定义就变成了“一「个模块应该对一类且仅对一类行为者(actor)负责」”。这个定义从「考虑变化」升级到「考虑变化的来源」

「理解单一职责原则本质上就是要理解分离关注点」

  • 我们需要理解封装,知道要把什么样的内容放到一起;
  • 我们需要理解分离关注点,知道要把不同的内容拆分开来;
  • 我们需要理解变化的来源,知道把不同行为者负责的代码放到不同的地方。

一句话总结:「应用单一职责原则衡量模块,粒度越小越好。」

开放封闭原则

软件实体(类、模块、函数)应该「对扩展开放,对修改封闭」

新需求靠扩展。也就是说,新需求用新代码实现。这么做的前提是在软件内部留好扩展点。每一个扩展点都是需要设计的模型。

「阻碍程序员们构造出稳定模块的障碍,其实是构建模型的能力」

里式替换原则

Liskov 替换原则(Liskov substitution principle,简称 「LSP」)。LSP用来告诉我们什么样的继承是对的。

Barbara Liskov 在描述如何定义子类型时写下这样一段话:这里需要如下替换性质:若每个类型 S 的对象 o1,都存在一个类型 T 的对象 o2,使得在所有针对 T 编程的程序 P 中,用 o1 替换 o2 后,程序 P 行为保持不变,则 S 是 T 的子类型。意思就是,「子类型(subtype)必须能够替换其父类型(base type)。」

「如果你发现了任何做运行时类型识别的代码,很有可能已经破坏了 LSP」

「要满足 LSP,首先这个对象体系要有一个统一的接口,而不能各行其是。其次,子类要满足 IS-A 的关系」

class Students extends ArrayList<Student> {
  //这显然是不好的,因为 Students 不是一个 ArrayList,不能满足 IS-A 关系。
}

继承需要满足 「IS-A 「的关系,但 IS-A 的关键在于」行为上的一致性.」

一句话总结:「用父类的角度去思考,设计行为一致的子类」

接口隔离原则

「接口隔离原则」(Interface segregation principle,简称 ISP)是这样表述的:不应强迫使用者依赖于它们不用的方法。

一个更好的设计是,把大接口分解成一个一个的小接口。每个类都有自己的接口,所有的公开方法都是接口。

我们在做接口设计时,需要关注不同的使用者。识别出接口不同的角色是至关重要的。

一句话总结:「识别对象的不同角色,设计小接口」

依赖倒置原则

「依赖倒置原则」(Dependency inversion principle,简称 DIP)是这样表述的:高层模块不应依赖于低层模块,二者应依赖于抽象。抽象不应依赖于细节,细节应依赖于抽象。

「所谓倒置,就是把这种习惯性的做法倒过来,让高层模块不再依赖于低层模块」

DIP 还可以简单理解成要依赖于抽象,由此,还可以推导出一些指导编码的规则:

  • 任何变量都不应该指向一个具体类;
  • 任何类都不应继承自具体类;
  • 任何方法都不应该改写父类中已经实现的方法。

一句话总结:「依赖于构建出来的抽象,而不是具体类」

设计原则

「KISS」 原则,Keep it simple, stupid,我们要让系统保持简单;

**YAGNI **原则,You aren’t gonna need it,不要做不该做的需求;

「DRY」 原则,Don’t repeat yourself,不要重复自己,消除各种重复。

可以指导我们实际工作的简单设计原则,它有 4 条规则:

  • 通过所有测试;
  • 消除重复;
  • 表达出程序员的意图;
  • 让类和方法的数量最小化。

领域驱动设计

领域驱动设计(Domain Driven Design,简称 DDD)。

「学习 DDD,就要从理解 DDD 的根基入手:通用语言(Ubiquitous Language)和模型驱动的设计(Model-Driven Design)」,而领域驱动设计的过程,就是建立起通用语言和识别模型的过程。

「通用语言,就是在业务人员和开发人员之间建立起的一套共有的语言」。通用语言所做的事情,就是把开发人员的思考起点拉到了业务上,也就是从问题出发。

如何设计通用语言?让「业务人员和开发人员一起」将概念写出来。然后双方重新进行分类整理。正式的实践叫做事件风暴。

「事件风暴」是一个工作坊,基本做法就是找一面很宽的墙,上面铺上大白纸,然后,用便利贴把识别出来的概念贴在上面。当然,前提依然是让业务人员和技术人员都参与其中。事件风暴这个工作坊主要分成三步:

  1. 「第一步就是把领域事件识别出来」,这个系统有哪些是人们关心的结果。有了领域事件,下面一个问题是,这些事件是如何产生的,它必然会是某个动作的结果。
  2. 「第二步就是找出这些动作,也就是引发领域事件的命令」。比如:产品已上架是由产品上架这个动作引发的,而订单已下就是由下单这个命令引发的。
  3. 「第三步就是找出与事件和命令相关的实体或聚合」,比如,产品上架就需要有个产品(Product),下单就需要有订单(Order)。

「模型驱动设计」

在 DDD 的过程中,我们将设计分成了两个阶段:「战略设计」(Strategic Design)和「战术设计」(Tactical Design)。

战略设计是高层设计,是指将系统拆分成不同的领域。拆分系统的新视角:按业务领域拆分。

战术设计是低层设计,也就是如何具体地组织不同的业务模型。

一句话总结:「建立一套业务人员和开发人员共享的通用语言」

战略设计

战略设计中的概念主要是为了「做业务的划分和落地成解决方案」

首先业务的划分,我们要把识别出来的模型做一个分类,把它们放置到不同的子域中。划分子域的出发点就是不同的关注点,也就是不同的变化来源。划分出来的子域有着不同的重要程度,我们将它们再分为核心域、支撑域和通用域。做出这种区分,主要是为了针对它们各自的特点,决定不同的投入。

有了不同的领域划分,我们还要把这些领域映射到解决方案上,这就引出了限界上下文。限界上下文限定了模型的使用边界,它可以成为一个独立的系统。如果对应到微服务中,每一个限界上下文可以对应成一个微服务。上下文映射图定义了不同上下文之间的交互方式,如果你只能记住一种交互方式的话,就应该记住防腐层。

按照我们之前介绍的了解软件设计的思路,建立起通用语言之后,我们就找到了主要的模型,通过战略设计,我们可以把识别出来的模型放到不同的限界上下文中,就相当于把模型做了分组。然后,我们需要定义出一些接口,让不同的模型之间可以交互,我们也就有了一张上下文映射图。这样一来,我们就把之前学习的知识和新的知识建立起了连接。

一句话总结:「战略设计,就是将不同的模型进行分组」

战术设计

DDD 中的战术设计,我们把战术设计当作了一个故事模板。让你先去识别角色,也就是找到「实体和值对象」。一个简单的区分就是,能通过唯一标识符识别出来的就是实体,只能通过字段组合才能识别出来的是值对象。

然后我们应该找到角色之间的关系,也就是「聚合」。操作聚合关键点在于找到「聚合根」。当聚合根不存在时,聚合中的对象也就不再有价值了。

有了角色及其关系,接下来就是找到各种动词,让故事生动起来。这里,我们讲到了动作,也就是「领域服务」,以及动作的结果,也就是「领域事件」,还有创建对象的「工厂」和保存对象的「仓库」。这些内容构成了我们最核心的业务逻辑。一些额外的工作,我们可以放到外围来做,这就是「应用服务」

一句话总结:「战术设计,就是按照模板寻找相应的模型。」

程序库的设计

「程序员不能只当一个问题的解决者,还应该经常抬头看路,做一个问题的发现者。」

「需要把问题拆解成可以下手解决的需求,使目标更明确」。根据需求找到合适的解决方案。通用的解决方案需要找到核心部分,抛开无关部分。

用测试把程序库要表达的东西写出来,我们的目标就是让测试通过。

一个好的设计,应该找到一个最小的核心模型,所有其他的内容都是在这个核心模型上生长出来的,越小的模型越容易理解,相对地,也越容易保持稳定。

一句话总结:「注意发现身边的小问题,用一个程序库或工具解决它。」

应用的设计

「先做职责划分,把不同职责的部分划分出来」

出现重复的部分,就是我们值得思考去解决的问题。

「衡量应用的设计水平」,就是看它符合下面哪个标准:

  • 没有自动化;
  • 开发员修改代码实现;
  • 开发员修改配置实现;
  • 业务员修改配置实现。

程序员的目标应该是尽可能的自动化,拒绝低水平的重复。

一句话总结:「一个更好的设计从拒绝低水平重复开始,把工作做成有技术含量的事情」

应用的改进

首先要「确定改进的目标」。改进的目标就是重新设计这个软件。

改进的难点在于「不要重蹈覆辙」,要把「分解做好」

改进路径的关键点:一是每步改进的动作要小;二是要让相关利益人达成共识。

一句话总结:「改进既有设计,从做一个正常的设计开始,小步向前。」