ソフトウェア構築の復習
@1.0 ver.
第一章
第一节 软件构造的多维视图
软件的构成要素
- 软件 = 算法 + 数据结构
- 软件 = 程序 + 数据 + 文档(80年代)
- 软件 = Modules(Components)+ Data/Control Flow模块(组件)+ 数据流/控制流
软件系统的组成
Software system =
Programs(UI,算法,utilities(实用工具 function),APIs,test cases)
- Data(files,database)
- Documents(SRS(需求规格声明),SDD(设计规格声明),user manuals)
- Users(谁来使用)
- Business Objective(为什么使用它)
- Social Environment(法规)
- Technological Environement(如何部署)
- Hardware / Network(硬件)
(前三个是主要)
软件构造的多维视角
-
阶段:构建 || 运行
-
动态:时刻 || 周期
-
级别:代码 || 组件
Buildtime概述
想法 → \to →需求 → \to →设计 → \to →代码 → \to →可安装可执行的包
- 代码是如何组建起来的?(依赖关系)
- 体系架构:源代码如何组成文件
- 时间角度:源代码在特定的时间什么样,随着时间如何变化
Code-level, Build-time, Moment
三种相互关联的形式
- 面向词法 半结构化源代码
- 面向语法 (AST抽象语法树)半结构化的源代码变成语法树(编译器能够处理)
- 面向语义 UML(参考软件工程课程内容)
Code-level, Build-time, Period—Code Churn(代码变化)
- Churn Trends
- 代码变化包括添加、修改、删除
Component-level, Build-time, Moment
- 源代码如何组织成文件——通过类库
- 文件被压缩进package,逻辑上进入components(组件)and sub-systems(子系统)
- 链接技术(动态/静态)
类库(Library)
-
来源
-
操作系统自带
-
语言自带的SDK
-
第三方
-
自己编写
-
链接到类库
-
编译器形成关于外部库的链表,编译器找到库的目标文件,复制加到程序中
Component-level, Build-time, Period -
版本控制(Git、SVN)
- 版本演化图(SCI)
-
Software Configuration Item(软件生命周期各个阶段活动的产物,经审核后可称为软件配置项)
-
version:major.minor.patch
- software evolution(软件演化)
Runtime概述
运行时软件的高级概念
- 可执行程序:CPU能直接理解执行的指令序列(二进制文件)
- 库文件:可复用的代码,库文件本身不能执行
可执行程序的四种形式
-
本地机器码
- 载入内存——OS调用机器码
- 优点:CPU直接执行,速度快;
- 缺点:可移植性差;
-
完全解释
- Basic与UNIX中的shell
- 操作系统提供解析器,一边解析,一边运行
-
自解码
- 源代码编译为自解码,然后通过JVM变为机器码
- 或自解码通过解析器进行边解析边运行
- 优点:跨平台
- 缺点:速度慢
-
静态链接
- 类库就像是特别的对象文件的集合
- 编译前就需要知道方法对应的文件
- 构建时,从类库中提取文件并复制到可执行文件中
动态链接
- 操作系统为应用程序提供了丰富的函数调用,这些函数调用都包含在动态链接库中。在可执行文件装载时或运行时,由操作系统的装载程序加载库。
- 优点:多个程序可以共享同一个副本,减少内存占用;打包方便,方便库升级。
配置文件和数据文件
- 程序调用操作系统,来请求将数据读入内存;
- configuration:保存程序的参数
- Data:保存程序中如位图图形图像、数字化波形音频等文件
分布式程序
- 多端口或者多线程
- 如:QQ通过客户端访问服务器(client & server)
- 健壮性要求很高
Code level, Run-time, Moment
- 快照图:着眼于目标计算机内存中的变量级执行状态,体现某时刻内存中变量的情况。
- 内存转储(Memory dump):常发生在异常退出时,把内存中信息写到文件中(常用来调试)
Code level, Run-time, Period
- UML时序图(类之间的段落关系)
- 执行跟踪:根据跟踪日志里的信息进行调试或诊断软件问题
Component level, Run-time, Moment
- UML部署图:程序中的各个模块在物理上如何分布;表明客户端、服务器之间的关系。
Component level, Run-time, Period
-
事件日志:每个事件有唯一编号
-
比较“执行跟踪”和“事件日志”
Transformations between views
- 从无到有:
- ADT/OOP
- 可理解性
- 从代码变为组件:
- Design
- Build
- 构建阶段到运行阶段
- inatall
第二节 软件开发的质量属性
外部和内部质量属性
- 外部质量属性是指正确性、外观、速度等影响客户的属性
- 内部属性是指易于理解、可读性等影响开发人员和软件自身的属性
- 二者关系:外部受内部制约
外部质量属性
正确性
- 在规格说明书描述范围之内满足正确性
- 保证正确性的技术
- 有限制的正确:只保证自己层面正确,假设调用的都是正确的
- 测试与调试
- 防御性编程
- 形式化编程(采用很多数学技术)
健壮性
- 碰到异常情况进行适当的响应
- 出现规格说明书说明之外的情况由健壮性处理
- 响应异常情况
- 给出错误提示
- 正常退出或降级
可扩展性
- 软件产品适应规格变化的容易程度
- 传统方法通过固化需求(瀑布模型)进行编程
- 两个基本策略
- 设计简洁
- 离散化:低耦合
可复用性
- 软件模块能否被其他程序很方便地使用
- 例子:开发备注、封装
兼容性
- 能够与其他人员进行交互
- 跨平台、跨软件
- 实现方法:一致性和标准化(一致的方法和标准)
- 标准文件格式
- 标准数据结构
- 标准用户接口
- 最通用:标准协议
效率
- 程序运行中对CPU、硬盘的占用带宽;
- 实现效率是不能牺牲正确性,要再多指标之间权衡
- 实现方法:
- 好的算法
- I/O技术
- 内存管理
- 功能问题都可以加一层抽象进行处理;性能问题都可以去掉一层抽象来解决
可移植性
- 是否容易由一个环境转移到另一个环境
- 由于访问OS本地类库、插件等问题导致的移植后无法正常运行
应用性
- 用户是否容易使用,不影响专业人员的使用情况下,方便初学者
- 方法:
- 结构清晰的设置
- UI设计:理解用户需求
功能性
- 蠕变特征(不好的现象:开发者开发越来越多的功能,造成程序的复杂和不灵活)
- 原则:在保证整体质量不降低的情况下进行更新
- 策略:增量式模型
及时性
- 在规定时间内完成:时间效率高
其他质量特性
- 可验证性:如管理系统的效果难以验证
- 完整性:不会被非法访问干扰修改,防止数据不一致(如使用private)
- 可修改性
- 资金
内部质量属性
- 从LOC(line of code)到圈复杂度:用来衡量一个模型判定结构的复杂程序
- 耦合度和内聚度
- 代码是否可读、可理解、简洁
- 完整性
- 大小
均衡决策
- 完整性与易用性冲突
- 经济性与功能性冲突
- 性能与可复用、可移植性冲突
- 及时性与可延展性冲突
以效率为导向,以正确性为最重要
OOP如何保障质量属性
五个关键的质量属性
- easy to understand
- ready for change
- cheap for develop
- safe from bugs
- efficient to run
可理解性
- 在构建时
- 代码层要注意(函数规约)
- 变量 / 子程序 / 语句 的命名与构造标准
- 代码布局与风格
- 注释
- 复杂度
- 组件层要注意构件和项目的可理解性
- 包的组织
- 文件的组织
- 命名空间
- 在时段中,代码层注意重构
- 代码层要注意(函数规约)
- 在运行时,代码层注意跟踪日志
可复用性
- 构建时
- 代码层应注意
- ADT / OOP
- 接口与实现分离
- 继承 / 重载 / 重写
- 组合 / 代理
- 多态
- 自类型与泛型编程
- OO设计模式
- 组件层注意
- API接口设计
- 类库
- 框架
- 代码层应注意
可维护性与适用性
- 构建时(面对需求的改变,能否做出及时的调整)
健壮性
- Code level-build time-Moment
- 错误处理
- 异常处理
- 断言
- 防御型编程
- 测试优先编程
- Component level-buildtime-period
- 单元测试
- 集成测试
- Build time-period
- 回归测试
- run time-moment
- 测试转储
- run time-period
- 跟踪日志
性能
- 构建时,使用指定的设计模式
- 运行时
- 在代码层次
- 通过内存管理考虑空间复杂度
- 通过算法性能计算时间复杂度
- 利用代码调优生成更高效的目标代码
- 在时段内进行性能分析和调整
- 在组件层次
- 采用分布式系统
- 编写多线程的并行程序
- 在代码层次
第二章
第一节 软件生命周期和版本控制(配置管理)
软件生命周期
生命周期
-
两种形态
-
-
从0到1:SDLC
-
- 策划阶段:获取需求、制定计划
- 架构师:系统分析(业务领域,what)、软件设计(语言、架构,how)
- 编码实现、测试
- 维护直至消失
-
从1到n:运用版本控制技术实现迭代更新
-
-
软件还活着的标志:Age and vitality(活力)
-
我们期待生命周期长而且具有较高活性的软件,但开发失败、软件老化、需求不及需求是软件死亡的主要原因
经典软件过程模型(侧重于计划)
- 大体上分为两类,线性和迭代(迭代大体上就是线性上增加反馈)
- 关键考虑指标:用户参与度、复杂度、软件质量
瀑布模型
- 瀑布模型将软件生存周期的各项活动规定为依固定顺序而连接的若干阶段工作;
- 瀑布模型规定了每一个阶段的输入,以及本阶段的工作成果,作为输出传入下一阶段;
- 早期主流开发过程,适用于需求稳定的项目;
- 优点:有设计前的规约和编码前的设计,易于管理;
- 缺点:应对变化时,成本十分高。
增量模型
-
运用分治的思想,将需求分段,成为一系列增量产品,每个增量内部仍使用瀑布模型;
-
增量模型是瀑布模型的变形,拥有后者的全部优点,此外可以很快的迭代出第一版本;
-
选择最核心需求首先实现显得十分重要。
V模型
- 对瀑布模型的改进
- 强调测试与继承,对代码、分析文档进行质量保证
原型法
-
迭代法, 原型法是指在获取一组基本的需求定义后,利用高级软件工具可视化的开发环境,快速地建立一个目标系统的最初版本,并把它交给用户试用、补充和修改,再进行新的版本开发。反复进行这个过程,直到得出系统的“精确解”,即用户满意为止。
-
其核心是用交互的,快速建立起来的原型取代了形式的、僵硬的(不允许更改的)大部分的规格说明,用户通过在计算机上实际运行和试用原型系统而向开发者提供真实的、具体的反馈意见。
-
优势:
-
- 软件设计者和实施者可以在项目早期从用户那里获得有价值的反馈。
- 客户可以比较软件制作的软件是否符合软件规范。
- 它还使软件工程师能够深入了解初始项目估算的准确性以及提出的最后期限和里程碑是否可以成功实现
螺旋模型
-
采用一种周期性的方法来进行系统开发。
-
优点:
-
- 设计上的灵活性,可以在项目的各个阶段进行变更。
- 以小的分段来构建大型系统,使成本计算变得简单容易。
敏捷软件开发
宣言
- 人的作用 胜于 过程管理和工具的使用(结对编程)
- 可运行的软件 胜于 面面俱到的文档
- 客户合作 胜于 合同谈判
- 响应变化 胜于 遵循计划
12个原则
- 我们最优先要做的是通过尽早的、持续的交付有价值的软件来使客户满意
- 即使到了开发的后期,也欢迎改变需求。敏捷过程利用变化来为客户创造竞争优势。
- 经常性的交付可以工作的软件,交付的间隔可以从几周到几个月,交付的时间间隔越短越好。
- 在整个项目开发期间,业务人员和开发人员必须天天都在一起工作。
- 围绕被激励起来的人个来构建项目。给他们提供所需要的环境和支持,并且信任他们能够完成工作。
- 在团队内部,最具有效果并且富有效率的传递信息的方法,就是面对面的交谈。
- 工作的软件是首要进度度量标准。
- 敏捷过程提可持续的开发速度。责任人、开发者和用户应该能够保持一个长期的、恒定的开发速度。
- 不断地关注优秀的技能和好的设计会增强敏捷能力。
- 简单——使未完成的工作最大化的艺术——是根本的。
- 最好的构架、需求和设计出自与自组织的团队。
- 每隔一定时间,团队会在如何才能更有效地工作方面进行反省,然后相应地对自己的行为进行调整。
核心特点
从需求与过程驱动变为由成果驱动。
极限编程
-
描述需求(利用story,情景对话表达用户需求)
-
设计阶段:做原型
-
Coding:(TDD)测试驱动开发、结对编程、自动构建
-
测试阶段:持续集成、持续发布
-
冲刺模型
-
- 项目管理方式:任务墙、目标图
协同软件开发
配置管理和版本控制
-
版本控制是实现软件配置管理的最主要工具之一,此外,建立基线也是十分重要的工具
-
软件配置项: 软件生存周期各个阶段活动的产物经审批后即可称之为软件配置项,其包括文档、源代码、可重用软件等。
-
基线:
-
- 定义:软件文档或源码(或其它产出物)的一个稳定版本,它是进一步开发的基础
- 建立基线的原因: 重现性、可追踪性和报告。
-
配置管理数据库:配置管理数据库是指这样一种数据库,它包含一个组织的IT服务使用的信息系统的组件的所有相关信息以及这些组件之间的关系。配置管理数据库提供一种对数据的有组织的检查和从任何想要的角度研究数据的方法。
-
对库进行检入、检出:权限封印和权限解锁
版本控制系统的优点
- 回到历史版本作参考、作比较
- 项目迁移
- 方便版本合并(最好有相同的基线)
- 具有日止功能,便于开发团队的沟通交流
分支
- 部分人员并行开发有意义的活动
- 其他人员不想在新功能完成之前插入新功能
版本操作系统
- 本地的VCS
- 集中式VCS(CVS、SVN):通过服务器进行共享,客户端可以是全集或子集(Git只能是全集)
- 分布式VCS(Git):用户之间可以直接进行推送,也可以通过云
版本控制工具—— Git
-
Git的整体架构——四个仓库(本地有三个)
-
- 工作目录
- 暂存区域(在menmory中,对用户不可见)(隐藏的.git文件夹中的stage)
- 本地库:源代码
- 云端软件服务器(远程仓库)
-
利用对象图结构,
-
- 每个结点保存:父结点、如提交时间的信息
- VCS还原差异,Git保存完整文件
- Git对于重复文件,不复制文件,只修改指针
- 减少冗余
- 访问速度快
-
分支代码
-
git
(创建)branch
(切换)-b
(branch)iss53
git merge hitfix
(合并)- 是用
git add
把文件添加进去,实际上就是把文件修改添加到暂存区; - 用
git commit
提交更改,实际上就是把暂存区的所有内容提交到当前分支。
-
本地库和远程库
-
clone
:将整个库完整的复制fetch
:将某一分支复制下来push
:将分支推送到服务器上pull
:将某一分支复制下来并合并在当前分支上
第二节 软件构造的过程、系统和工具
广义的软件构造过程
编程(Coding)
-
开发语言:如Java、C、Python
-
使用IDE(集成开发工具)的优势(组成)
-
- 方便编写代码和管理文件(有代码编辑器,代码重构工具、文件和库(Library)管理工具)
- 能够编译、构建(有编译器、解释器、自动构建工具)
- 结构清晰(有面向对象的类层次结构图和类浏览器)
- 有GUI界面
- 支持第三方扩展工具
-
-
建模语言:UML(Unified Modeling Language,统一建模语言)
- UML是用来对软件系统进行可视化建模的一种语言;
- UML的结构由一组一致的规则定义;
- 建模的目的:
- 有助于按照需求对系统进行可视化分析
- 能够理解系统的结构或行为
- 给出了构造系统的模板
- 对做出的决策进行文档化
-
配置语言:键值文件(.ini;.properties;.rc); XML, YAML, JSON
- 配置语言用于配置程序的参数和初始设置
- 目的:
- 部署环境设置
- 应用程序功能的变体
- 组件之间连接的变体
静态代码分析
- 定义:静态代码分析是指不运行被测程序本身,仅通过分析或检查源程序的语法、结构、过程、接口等来检查程序的正确性。
- 注:该过程提供了对代码结构的理解,有助于确保代码符合行业标准
- 注:自动化的工具可以帮助程序员和开发人员进行静态代码分析
动态代码分析
- 定义:动态测试方法是指通过运行被测程序,检查运行结果与预期结果的差异,并分析运行效率、正确性和健壮性等性能。
- 注:必须执行足够的测试输入,使用诸如代码覆盖率之类的软件测试措施有助于确保已经观察到程序的一组可能行为的足够部分。
- 注:配置文件(“程序配置文件”,“软件配置文件”)是一种动态程序分析形式,用于度量程序的空间(内存)或时间复杂度,特定指令的使用情况,函数调用的频率和持续时间。
调试与测试
- 测试(Test)
- 狭义:程序是否正常运行、能否满足所有需求
- 广义:
- 调试(Debug):识别错误的根本原因并对其进行纠正的过程。
重构
- 重构它不会改变代码的外部行为,但会改进其内部结构。
- 投入短期时间/工作成本以获得长期收益,并对系统的整体质量进行长期投资。
- 重构需要保持代码正常工作,只需要用一些小步骤保留语义
- 需要进行单元测试来证明代码正常工作
狭义的软件构造过程
构造系统:经典的BUILD场景
Build场景综述:
- 用传统编译语言(如C、C++、Java)编写软件(compilation)
- 用解释型语言(如Perl、Python)编写软件的打包和测试(packaging and testing)
- 用基于Web的应用程序进行编译和打包
- 使用静态HTML页面
- 使用Java或C# 编写的源代码
- 使用JSP,ASP或PHP语法编写的混合文件以及多种类型的配置文件
- 执行单元测试代码的其余部分对软件进行隔离验证。
- 执行静态分析工具来是被程序源代码中的错误
- 生成PDF或HTML文档
传统编译语言:C、C++、Java等:
- 源文件被编译成目标文件,连接到代码库或可执行程序中
- 生成的文件被收集到可安装在目标机器上的发行包中
- 版本控制工具
- 源树和对象树:特定开发人员使用的源文件和编译对象文件集。
- 构建机器:执行编译工具的计算设备。
- 发布打包和目标机器:打包软件,分发给最终用户,然后安装到目标机器上的方法。
解释型语言:Perl、Python等:
- 解释的源代码不会编译到目标代码中,不需要对象树,解释源文件本身被收集到一个发行包中被安排在目标机器上;
- 编译工具专注于转换源文件并将它们存储在发行包中;
- 不在程序构建时编译成机器码
基于Web应用程序的构建系统:编译代码,解释代码和配置或数据文件的混合。
- 静态HTML文件,只包含标记数据显示在Web浏览器中,直接复制到发行包。
- 包含代码的JavaScript文件将由最终用户浏览器解释,直接复制到发行包。
- JSP,ASP或PHP页面,包含HTML和程序代码的混合,由Web应用程序服务器而不是构建系统编译和执行,复制到发布包,准备安装到Web服务器上。
- 构建系统在编译打包Java类文件之前执行转换。Java类在Web应用程序服务器上或浏览器内执行(使用小程序)。
构造过程与构造描述
- 如何构建系统
- 开发人员构建:开发人员已检出VCS的源代码并正在专用工作区中构建软件,结果发布包将用于开发人员的私人开发。
- 发布版本:为测试组提供一个完整的软件包供验证,软件的质量足够高时为客户提供相同的软件包。用于发布版本的源代码树只编译一次,永不修改。
- Sanity构建:与发布版本类似,但并非针对客户,可以每天发生多次,并且趋向于完全自动化。
构建工具
- Java构造工具:Make、Ant、Maven、Gradle、Eclipse
- Maven将项目的生命周期大致分为9个,分别为:clean、validate、compile、test、package、verify、install、site、deploy
- 使用maven自动构建的方法:如mvn compile
- validate - 验证项目是否正确,并提供所有必要的信息
- compile - 编译项目的源代码
- test - 使用单元测试框架测试已编译的源代码。这些测试不应该要求打包或部署代码
- package - 获取已编译的代码并将其打包为可分发的格式,例如JAR。
- verify - 对集成测试结果进行任何检查,以确保符合质量标准
- install - 将软件包安装到本地存储库中,作为本地其他项目的依赖项
- deploy - 在构建环境中完成,将最终包复制到远程存储库,以便与其他开发人员和项目共享。
- 使用maven自动构建的方法:如mvn compile
第三章
第一节 数据类型与类型检查
数据类型及其表达
基本数据类型
对象数据结构
- 对象:对象是类的一个实例,有状态和行为。
- 类:类是一个模板,它描述一类对象的行为和状态。
- Java作为一种面向对象语言,支持多态、继承、封装、抽象、重载等概念
包装类
类型检查
动态检查:关于“值”的检查
- bug在运行中被发现
- 倾向于检查特定值才出发的错误
- 动态分析检查的类型:
- 非法的变量值。例如整型变量x、y,表达式x/y 只有在运行后y为0才会报错,否则就是正确的。
- 非法的返回值。例如最后得到的返回值无法用声明的类型来表明。
- 越界访问。例如在一个字符串中使用一个负数索引。
- 空指针,使用一个null 对象解引用。
静态检查:关于“类型”的检查
- 静态检查>>动态检查>>无检查
- 在编译阶段发现错误,避免将错误带入到运行阶段,提高程序的正确性\健壮性
- 静态分析检查的类型
- 语法错误,例如多余的标点符号或者错误的关键词。即使在动态类型的语言例如Python中也会做这种检查:如果你有一个多余的缩进,在运行之前就能发现它。
- 类名\函数名错误,例如Math.sine(2) . (应该是 sin )
- 参数数目错误,例如 Math.sin(30, 20)
- 参数的型错误 Math.sin(“30”)
- 返回值类型错误 ,例如⼀个声明返回 int 类型函数 return 30
可变性和不可变性
- 改变一个变量:是将该变量指向另一个值得存储空间
- 改变一个变量的值:是将该变量当前指向的值的存储空间中写入一个新的值
不变性(immutability)
- final 变量能被显式地初始化并且只能初始化一次。不变数据类型,一旦被创建,值不可修改;
- 基本类型及其封装对象类型都是不可变的
- 不可变的引用是指一旦指定引用位置后,不可再次指定。
- 如果编译器不能确定final变量不会改变,就提示错误,这也是静态类型检查的一部分
- 注意:
- final类无法派生子类
- final变量无法改变值/引用
- final方法无法被子类重写
可变性(mutability)
-
不变对象:一旦被创建,始终指向同个值/引用
-
可变对象:拥有方法以修改自己的值/引用
-
Sting与StingBuilder
-
String:不可变数据类型,在修改时必须创建一个新的String对象
String s = "a"; a = s + "b";//s = s.concat("b");
-
StringBuilder:可改变的数据类型,可以直接修改对象的值
StringBuilder sb = new StringBuilder("a"); sb.append("b");
-
可变性与不可变性的优缺点
- 可变数据类型最小化的拷贝以提高效率;使用 不可变类型,对其频繁修改会产生大量的临时拷贝 (需要垃圾回收 )
- 可变数据类型,可获得更好的效能
- 可变数据类型也适合在多个模块之间共享数据
- 不可变数据类型更安全,更易于理解,也更方便改变
防御性拷贝
-
如果一个方法或构造函数允许可变对象进/出,那么就要考虑一下使用者是否有可能改变它。如果是的话,那你必须对该对象进行保护性拷贝,使进入方法内部的对象是外部时的拷贝而不它本身(因为外部的对象有可能还会被改变)。
-
public Date getEnd() { return new Date(end.getTime()); }
第二节 设计规约
什么是设计规约,我们为什么需要他
- 为什么要有设计规约
- 很多bug来自于双方之间的误解;没有规约,那么不同开发者的理解就可能不同
- 代码惯例增加了软件包的可读性,使工程师们更快、更完整的理解软件
- 可以帮助程序员养成良好的编程习惯,提高代码质量
- 没有规约,难以定位错误
- 使用设计规约的好处
- 规约起到了契约的作用。代表着程序与客户端之间达成的一致;客户端无需阅读调用函数的代码,只需理解spec即可。
- 精确的规约,有助于区分责任,给“供需双方”确定了责任,在调用的时候双方都要遵守。
- 规约可以隔离“变化”,无需通知客户端
- 规约也可以提高代码效率
####行为等价性
行为等价性就是站在客户端的角度考量两个方法是否可以互换。
-
另外,我们也可以根据规约判断是否行为等价注:规约与实现无关,规范无需讨论方法类的局部变量或方法类的私有字段。
-
两个函数附和同一个规约,故二者等价
规约的结构:前置条件与后置条件
规约的结构
-
一个方法的规约常由以下几个短句组成契约:如果前置条件满足了,后置条件必须满足。如果没有满足,将产生不确定的异常行为
-
前置条件(precondition):对客户端的约束,在使用方法时必须满足的条件。由关键字 requires 表示;
-
后置条件(postcondition):对开发者的约束,方法结束时必须满足的条件。由关键字 effects 表示
-
异常行为(Exceptional behavior):如果前置条件被违背,会发生什么
-
静态类型声明是一种规约,可据此进行静态类型检查。
-
方法前的注释也是一种规约,但需人工判定其是否满足。
- 参数由@param 描述
- 子句和结果用 @return 和 @ throws子句 描述
- 尽可能的将前置条件放在 @param 中
- 尽可能的将后置条件放在 @return 和 @throws 中
mutating methods(可变方法)的规约
- 除非在后置条件里声明过,否则方法内部不应该改变输入参数。
- 应尽量遵循此规则,尽量不设计 mutating的spec,否则就容易引发bugs。
- 程序员之间应达成的默契:除非spec必须如此,否则不应修改输入参数 。
- 尽量避免使用可变(mutable)的对象 。
- 对可变对象的多引用,需要程序维护一致性,此时合同不再是单纯的在用户和实现者之间维持,需要每一个引用者都有良好的习惯,这就使得简单的程序变得复杂;
- 可变对象使得程序难以理解,也难以保证正确性;
- 可变数据类型还会导致程序修改变得异常困难;
规约的评价
规约评价的三个标准
- 规约的确定性
- 规约的陈述性
- 规约的强度
规约的确定性
确定的规约:给定一个满足前置条件的输入,其输出是唯一的、明确的
static int findExactlyOne(int[] arr, int val)
\\ requires: val occurs exactly once in arr
\\ effects: returns index i such that arr[i] = val
欠定的规约:同一个输入可以有多个输出
static int findOneOrMore,AnyIndex(int[] arr, int val)
\\ requires: val occurs in arr
\\ effects: returns index i such that arr[i] = val
未确定的规约:同一个输入,多次执行时得到的输出可能不同;但为了避免分歧,我们通常将不是确定的spec统一定义为欠定的规约。
规约的陈述性
- 操作式规约(Operational specs):伪代码 。
- 声明式规约(Declarative specs):没有内部实现的描述,只有 “初-终”状态 。
- 声明式规约更有价值 ; 内部实现的细节不在规约里呈现,而放在代码实现体内部注释里呈现。
规约的强度
- 通过比较规约的强度来判断是否可以用一个规约替换另一个;
- 如果规约的强度 S2>=S1,就可以用S2代替S1,体现有二:一个更强的规约包括更轻松的前置条件和更严格的后置条件;越强的规约,意味着实现者(implementor)的自由度和责任越重,而客户(client)的责任越轻。
- S2的前置条件更弱
- S2的后置条件更强
examples
- Original spec:
1 static int findExactlyOne(int[] a, int val)
2 \\ requires: val occurs exactly once in a
3 \\ effects: returns index i such that a[i] = val
- A stronger spec:
1 static int findOneOrMore,AnyIndex(int[] a, int val)
2 \\ requires: val occurs at least once in a
3 \\ effects: returns index i such that a[i] = val
- A much stronger spec:
1 static int findOneOrMore,FirstIndex(int[] a, int val)
2 \\ requires: val occurs at least once in a
3 \\ effects: returns lowest index i such that a[i] = val
如何设计一个好的规约
- 规约应该是简洁的:整洁,具有良好的结构,易于理解。
- 规约应该是内聚的:Spec描述的功能应单一、简单、易理解。
- 规约应该是信息丰富的:不能让客户端产生理解的歧义。
- 规约应该是强度足够的:需要满足客户端基本需求,也必须考虑特殊情况。
- 规约的强度也不能太强:太强的spec,在很多特殊情况下难以达到。
- 规约应该使用抽象类型:在规约里使用抽象类型,可以给方法的实现体与客户端更大的自由度。
是否使用前置条件
- 是否使用前置条件取决于如果只在类的内部使用该方法(private),那么可以不使用前置条件,在使用该方法的各个位置进行check——责任交给内部client。
- check的代价;
- 方法的使用范围;
- 如果在其他地方使用该方法(public),那么必须要使用前置条件,若client端不满足则方法抛出异常。
第三节 抽象数据型(ADT)
ADT及其四种类型
ADT的基本概念
- 抽象数据类型(Abstract Data Type,ADT)是是指一个数学模型以及定义在该模型上的一组操作;即包括数据数据元素,数据关系以及相关的操作。
- ADT具有以下几个能表达抽象思想的词:
- 抽象化:用更简单、更高级的思想省略或隐藏低级细节。
- 模块化: 将系统划分为组件或模块,每个组件可以设计,实施,测试,推理和重用,与系统其余部分分开使用。
- 封装:围绕模块构建墙,以便模块负责自身的内部行为,并且系统其他部分的错误不会损坏其完整性。
- 信息隐藏: 从系统其余部分隐藏模块实现的细节,以便稍后可以更改这些细节,而无需更改系统的其他部分。
- 关注点分离: 一个功能只是单个模块的责任,而不跨越多个模块。
- 与传统类型定义的差别:
- 传统的类型定义:关注数据的具体表示。
- 抽象类型:强调“作用于数据上的操作”,程序员和client无需关心数据如何具体存储的,只需设计/使用操作即可。
- ADT是由操作定义的,与其内部如何实现无关。
ADT的四种类型
-
前置定义:mutable and immutable types
- 可变类型的对象:提供了可改变其内部数据的值的操作。Date
- 不变数据类型: 其操作不改变内部值,而是构造新的对象。String
-
Creators(构造器):
- 创建某个类型的新对象,⼀个创建者可能会接受⼀个对象作为参数,但是这个对象的类型不能是它创建对象对应的类型。可能实现为构造函数或静态函数。(通常称为工厂方法)
- t ∗ → T t^* \to T t∗→T
- 栗子:Integer.valueOf( )
-
Producers(生产器):
- 通过接受同类型的对象创建新的对象。
- T + , t ∗ → T T^+,t^* \to T T+,t∗→T
- 栗子:String.concat( )
-
Observers(观察器):
- 获取抽象类型的对象然后返回一个不同类型的对象/值。
- T + , t ∗ → t T^+ , t^* \to t T+,t∗→t
- 栗子:List.size( ) ;
-
Mutators(变值器):
- 改变对象属性的方法 ,
- 变值器通常返回void,若为void,则必然意味着它改变了对象的某些内部状态;当然,也可能返回非空类型
- T + , t ∗ → t ∣ ∣ T ∣ ∣ v o i d T^+ , t^* \to t||T|| void T+,t∗→t∣∣T∣∣void
- 栗子:List.add( )
-
解释:T是ADT本身;t是其他类型;+ 表示这个类型可能出现一次或多次;* 表示可能出现0次或多次。
设计一个好的ADT
设计好的ADT,靠“经验法则”,提供一组操作,设计其行为规约 spec
- 原则 1:设计简洁、一致的操作。
- 最好有一些简单的操作,它们可以以强大的方式组合,而不是很多复杂的操作。
- 每个操作应该有明确的目的,并且应该有一致的行为而不是一连串的特殊情况。
- 原则 2:要足以支持用户对数据所做的所有操作需要,且用操作满足用户需要的难度要低。
- 提供get()操作以获得list内部数据
- 提供size()操作获取list的长度
- 原则 3:要么抽象、要么具体,不要混合 —— 要么针对抽象设计,要么针对具体应用的设计。
测试ADT
- 测试creators, producers, and mutators:调用observers来观察这些 operations的结果是否满足spec;
- 测试observers: 调用creators, producers, and mutators等方法产生或改变对象,来看结果是否正确。
表示独立性
- 表示独立性:client使用ADT时无需考虑其内部如何实现,ADT内部表示的变化不应影响外部spec和客户端。
- 除非ADT的操作指明了具体的前置条件/后置条件,否则不能改变ADT的内部表示——spec规定了 client和implementer之间的契约。
不变量(Invariants)与表示泄露
一个好的抽象数据类型的最重要的属性是它保持不变量。一旦一个不变类型的对象被创建,它总是代表一个不变的值。当一个ADT能够确保它内部的不变量恒定不变(不受使用者/外部影响),我们就说这个ADT保护/保留自己的不变量。
抽象函数AF与表示不变量RI
AF与RI
-
在研究抽象类型的时候,先思考一下两个值域之间的关系:
- 表示域(rep values)里面包含的是值具体的实现实体。一般情况下ADT的表示比较简单,有些时候需要复杂表示。
- 抽象域(A)里面包含的则是类型设计时支持使用的值。这些值是由表示域“抽象/想象”出来的,也是使用者关注的。
-
ADT实现者关注表示空间R,用户关注抽象空间A 。
-
R → A R \to A R→A的映射特点:
- 每一个抽象值都是由表示值映射而来 ,即满射:每个抽象值被映射到一些rep值
- 一些抽象值是被多个表示值映射而来的,即未必单射:一些抽象值被映射到多个rep值
- 不是所有的表示值都能映射到抽象域中,即未必双射:并非所有的rep值都被映射。
-
抽象函数(AF):R和A之间映射关系的函数
`AF : R → A`
- 表示不变量(RI):将rep值映射到布尔值
`RI : R → boolean`
- 对于表示值r,当且仅当r被AF映射到了A, R I ( r ) RI(r) RI(r)为真
- 表示不变性RI:某个具体的“表示”是否是“合法的”
- 也可将RI看作:所有表示值的一个子集,包含了所有合法的表示值
- 也可将RI看作:一个条件,描述了什么是“合法”的表示值
- 在下图中,绿色表示的就是 R I ( r ) RI(r) RI(r)为真的部分,AF只在这个子集上有定义。
- 表示不变量和抽象函数都应该记录在代码中,就在代表本身的声明旁边,以下图为例
public class CharSet {
private String s;
// Rep invariant:
// s contains no repeated characters
// Abstraction function:
// AF(s) = {s[i] | 0 <= i < s.length()}
...
}
public class CharSet {
private String s;
// Rep invariant:
// s[0] <= s[1] <= ... <= s[s.length()-1]
// Abstraction function:
// AF(s) = {s[i] | 0 <= i < s.length()}
...
}
public class CharSet {
private String s;
// Rep invariant:
// s.length() is even
// s[0] <= s[1] <= ... <= s[s.length()-1]
// Abstraction function:
// AF(s) = union of {s[2i],...,s[2i+1]} for 0 <= i < s.length()/2
...
}
用注释写AF和RI
- 在抽象类型(私有的)表示声明后写上对于抽象函数和表示不变量的注解,这是一个好的实践要求。我们在上面的例子中也是这么做的。
- 在描述抽象函数和表示不变量的时候,注意要清晰明确:
- 对于RI(表示不变量),仅仅宽泛的说什么区域是合法的并不够,你还应该说明是什么使得它合法/不合法。
- 对于AF(抽象函数)来说,仅仅宽泛的说抽象域表示了什么并不够。抽象函数的作用是规定合法的表示值会如何被解释到抽象域。作为一个函数,我们应该清晰的知道从一个输入到一个输入是怎么对应的。
- 本门课程还要求你将表示暴露的安全性注释出来。这种注释应该说明表示的每一部分,它们为什么不会发生表示暴露,特别是处理的表示的参数输入和返回部分(这也是表示暴露发生的位置)。
- 下面是一个
Tweet
类的例子,它将表示不变量和抽象函数以及表示暴露的安全性注释了出来:
// Immutable type representing a tweet.
public class Tweet {
private final String author;
private final String text;
private final Date timestamp;
// Rep invariant:
// author is a Twitter username (a nonempty string of letters, digits, underscores)
// text.length <= 140
// Abstraction function:
// AF(author, text, timestamp) = a tweet posted by author, with content text,
// at time timestamp
// Safety from rep exposure:
// All fields are private;
// author and text are Strings, so are guaranteed immutable;
// timestamp is a mutable Date, so Tweet() constructor and getTimestamp()
// make defensive copies to avoid sharing the rep's Date object with clients.
// Operations (specs and method bodies omitted to save space)
public Tweet(String author, String text, Date timestamp) { ... }
public String getAuthor() { ... }
public String getText() { ... }
public Date getTimestamp() { ... }
}
第四节 面向对象编程OOP
OOP的基本概念
对象
- 对象是类的一个实例,有状态和行为。
- 例如,一条狗是一个对象,它的状态有:颜色、名字、品种;行为有:摇尾巴、叫、吃等。
- 概念:一个对象是一堆状态和行为的集合。
- 状态是包含在对象中的数据,在Java中,它们是对象的fields。
- 行为是对象支持的操作,在Java中,它们称为methods。
类
- 类是一个模板,它描述一类对象的行为和状态。
- 每个对象都有一个类
- 类定义了属性类型(type)和行为实现(implementation)
- 简单地说,类的方法是它的应用程序编程接口(API)。
- 类成员变量(class variable)又叫静态变量;类方法(class method)又叫静态方法:
- 实例变量(instance variable)和实例方法(instance method)是不用static形容的实例和方法;
- 二者有以下的区别:
- 类方法是属于整个类,而不属于某个对象。
- 类方法只能访问类成员变量(方法),不能访问实例变量(方法),而实例方法可以访问类成员变量(方法)和实例变量(方法)。
- 类方法的调用可以通过类名.类方法和对象.类方法,而实例方法只能通过对象.实例方法访问。
- 类方法不能被覆盖,实例方法可以被覆盖。
- 当类的字节码文件被加载到内存时,类的实例方法不会被分配入口地址 当该类创建对象后,类中的实例方法才分配入口地址, 从而实例方法可以被类创建的任何对象调用执行。
- 类方法在该类被加载到内存时,就分配了相应的入口地址。 从而类方法不仅可以被类创建的任何对象调用执行,也可以直接通过类名调用。 类方法的入口地址直到程序退出时才被取消。
- 注意:
- 当我们创建第一个对象时,类中的实例方法就分配了入口地址,当再创建对象时,不再分配入口地址。
- 也就是说,方法的入口地址被所有的对象共享,当所有的对象都不存在时,方法的入口地址才被取消。
- 总结:
- 类变量和类方法与类相关联,并且每个类都会出现一次。 使用它们不需要创建对象。
- 实例方法和变量会在每个类的实例中出现一次。
接口
- 概念:接口在JAVA编程语言中是一个抽象类型,用于设计和表达ADT的语言机制,其是抽象方法的集合,接口通常以interface来声明。
- 一个类通过继承接口的方式,从而来继承接口的抽象方法。
- 接口并不是类,编写接口的方式和类很相似,但是它们属于不同的概念。类描述对象的属性和方法。接口则包含类要实现的方法。
- 一个接口可以扩展其他接口,一个类可以实现多个接口;一个接口也可以有多重实现
- 除非实现接口的类是抽象类,否则该类要定义接口中的所有方法。
- 接口无法被实例化,但是可以被实现。一个实现接口的类,必须实现接口内所描述的所有方法,否则就必须声明为抽象类。另外,在 Java 中,接口类型可用来声明一个变量,他们可以成为一个空指针,或是被绑定在一个以此接口实现的对象。
接口的好处
- Safe from bugs
ADT是由其操作定义的,接口就是这样做的。
当客户端使用接口类型时,静态检查确保他们只使用由接口定义的方法。
如果实现类公开其他方法,或者更糟糕的是,具有可见的表示,客户端不会意外地看到或依赖它们。
当我们有一个数据类型的多个实现时,接口提供方法签名的静态检查。 - Easy to understand
客户和维护人员确切知道在哪里查找ADT的规约。
由于接口不包含实例字段或实例方法的实现,因此更容易将实现的细节保留在规范之外。 - Ready for change
通过添加实现接口的类,我们可以轻松地添加新类型的实现。
如果我们避免使用静态工厂方法的构造函数,客户端将只能看到该接口。
这意味着我们可以切换客户端正在使用的实现类,而无需更改其代码。
抽象类
- 抽象类除了不能实例化对象之外,类的其它功能依然存在,成员变量、成员方法和构造方法的访问方式和普通类一样。
- 由于抽象类不能实例化对象,所以抽象类必须被继承,才能被使用。
- 父类包含了子类集合的常见的方法,但是由于父类本身是抽象的,所以不能使用这些方法。
- 在Java中抽象类表示的是一种继承关系,一个类只能继承一个抽象类,而一个类却可以实现多个接口。
- 如果一个类包含抽象方法,那么该类必须是抽象类。
- 任何子类必须重写父类的抽象方法,或者声明自身为抽象类。
- 构造方法,类方法(用static修饰的方法)不能声明为抽象方法。
OOP的不同特征
封装
-
封装(Encapsulation)是指一种将抽象性函式接口的实现细节部份包装、隐藏起来的方法。
-
设计良好的代码隐藏了所有的实现细节
- 干净地将API与实施分开
- 模块只能通过API进行通信
- 对彼此的内在运作不了解
-
信息封装的好处
- 将构成系统的类分开,减少耦合
- 加快系统开发速度
- 减轻了维护的负担
- 启用有效的性能调整
- 增加软件复用
-
信息隐藏接口
- 使用接口类型声明变量
- 客户端仅使用接口中定义的方法
- 客户端代码无法直接访问属性
-
实现封装的方法
-
修改属性的可见性来限制对属性的访问(一般限制为private),例如
public class Person { private String name; private int age; }
-
对每个值属性提供对外的公共方法访问,也就是创建一对赋取值方法,用于对私有属性的访问,例如:
public class Person{ private String name; private int age; public int getAge(){ return age; } public String getName(){ return name; } public void setAge(int age){ this.age = age; } public void setName(String name){ this.name = name; } }
采用 this 关键字是为了解决实例变量(
private String name
)和局部变量(setName(String name)
中的name
变量)之间发生的同名的冲突。
继承与重写
-
继承概念:继承就是子类继承父类的特征和行为,使得子类对象(实例)具有父类的实例域和方法,或子类从父类继承方法,使得子类具有父类相同的行为。
-
重写概念:重写是子类对父类的允许访问的方法的实现过程进行重新编写, 返回值和形参都不能改变。即外壳不变,核心重写!
-
重写的好处在于子类可以根据需要,定义特定于自己的行为。 也就是说子类能够根据需要实现父类的方法。
-
实际执行时调用那种方法,在运行时决定
-
重写方法不能抛出新的检查异常或者比被重写方法申明更加宽泛的异常。
-
子类只能添加新方法,无法重写超类中的方法。
-
当子类包含一个覆盖超类方法的方法时,它也可以使用关键字
super
调用超类方法。例子如下:
class Animal{ public void move(){ System.out.println("动物可以移动"); } } class Dog extends Animal{ public void move(){ super.move(); // 应用super类的方法 System.out.println("狗可以跑和走"); } } public class TestDog{ public static void main(String args[]){ Animal b = new Dog(); // Dog 对象 b.move(); //执行 Dog类的方法 } }
-
方法重写的规则
- 参数列表必须完全与被重写方法的相同;
- 返回类型必须完全与被重写方法的返回类型相同;
- 访问权限不能比父类中被重写的方法的访问权限更低。例如:如果父类的一个方法被声明为
public
,那么在子类中重写该方法就不能声明为protected
。 - 父类的成员方法只能被它的子类重写。
- 声明为
final
的方法不能被重写。 - 声明为
static
的方法不能被重写,但是能够被再次声明。 - 子类和父类在同一个包中,那么子类可以重写父类所有方法,除了声明为
private
和final
的方法。 - 子类和父类不在同一个包中,那么子类只能够重写父类的声明为
public
和protected
的非final
方法。 - 重写的方法能够抛出任何非强制异常,无论被重写的方法是否抛出异常。但是,重写的方法不能抛出新的强制性异常,或者比被重写方法声明的更广泛的强制性异常,反之则可以。
- 构造方法不能被重写。
- 如果不能继承一个方法,则不能重写这个方法。
多态与重载
- 多态是同一行为具有多种不同表现形式或形态的能力
- 三种类型的多态
- Ad hoc polymorphism (特殊多态):功能重载,一个函数可以有多个同名的实现。
- Parametric polymorphism (参数多态): 泛型或泛型编程,一个类型名字可以代表多个类型
- Subtyping (also called subtype polymorphism or inclusion polymorphism 子类型多态、包含多态):当一个名称表示许多不同的类与一些常见的超类相关的实例。
- 重载(overloading) 是在一个类里面,方法名字相同,而参数不同,返回类型可以相同也可以不同。
- 每个重载的方法(或构造函数)都必须有一个独一无二的参数类型列表。
- 价值:方便client调用,client可用不同的参数列表,调用同样的函数。
- 重载是静态多态,根据参数列表进行最佳匹配。在编译阶段时决定要具体执行哪个方法 (static type checking) ,与之相反,重构方法则是在run-time进行dynamic checking!
- 重载规则
- 被重载的方法必须改变参数列表(参数个数或类型不一样);
- 被重载的方法可以改变返回类型;
- 被重载的方法可以改变访问修饰符;
- 被重载的方法可以声明新的或更广的检查异常;
- 方法能够在同一个类中或者在一个子类中被重载。
- 无法以返回值类型作为重载函数的区分标准。
重写与重载的区别
区别点 | 重载方法 | 重写方法 |
---|---|---|
参数列表 | 必须修改 | 一定不能修改 |
返回类型 | 可以修改 | 一定不能修改 |
异常 | 可以修改 | 可以减少或删除,一定不能抛出新的或者更广的异常 |
访问 | 可以修改 | 一定不能做更严格的限制(可以降低限制) |
调用情况 | 引用类型决定选择哪个重载版本(基于声明的参数类型,在编译时发生 | 对象类型(换句话说,堆上实际实例的类型)决定选择哪种方法在运行时发生。 |
方法的重写(Overriding)和重载(Overloading)是java多态性的不同表现,重写是父类与子类之间多态性的一种表现,重载可以理解成多态的具体表现形式。
- 方法重载是一个类中定义了多个方法名相同,而他们的参数的数量不同或数量相同而类型和次序不同,则称为方法的重载(Overloading)。
- 方法重写是在子类存在方法与父类的方法的名字相同,而且参数的个数与类型一样,返回值也一样的方法,就称为重写(Overriding)。
- 方法重载是一个类的多态性表现,而方法重写是子类与父类的一种多态性表现。
泛型(参数多态)
- 泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。
- 可以写一个泛型方法,该方法在调用时可以接收不同类型的参数。根据传递给泛型方法的参数类型,编译器适当地处理每一个方法调用。
- 下面是定义泛型方法的规则:
- 所有泛型方法声明都有一个类型参数声明部分(由尖括号分隔),该类型参数声明部分在方法返回类型之前(在下面例子中的)。
- 每一个类型参数声明部分包含一个或多个类型参数,参数间用逗号隔开。一个泛型参数,也被称为一个类型变量,是用于指定一个泛型类型名称的标识符。
- 类型参数能被用来声明返回值类型,并且能作为泛型方法得到的实际参数类型的占位符。
- 泛型方法体的声明和其他方法一样。注意类型参数只能代表引用型类型,不能是原始类型(像int, double, char等)。
第五节 ADT和OOP中的等价性
等价性equals() 和 ==
-
和很多其他语言一样,Java有两种判断相等的操作——
==
和equals()
。 -
==
是引用等价性 ;而
equals()
是对象等价性。
==
比较的是索引。更准确的说,它测试的是指向相等(referential equality)。如果两个索引指向同一块存储区域,那它们就是==
的。对于我们之前提到过的快照图来说,==
就意味着它们的箭头指向同一个对象。equals()
操作比较的是对象的内容,换句话说,它测试的是对象值相等(object equality)。在每一个ADT中,equals
操作必须合理定义。
Java中的数据类型,可分为两类:
- 基本数据类型,也称原始数据类型。
byte, short, char, int, long, float, double, boolean
- 他们之间的比较,应用双等号(
==
),比较的是他们的值。
- 他们之间的比较,应用双等号(
- 复合数据类型(类)
- 当他们用(
==
)进行比较的时候,比较的是他们在内存中的存放地址,所以,除非是同一个new出来的对象,他们的比较后的结果为true,否则比较后结果为false。 - Java当中所有的类都是继承于
Object
这个基类的,在Object
中的基类中定义了一个equals
的方法,这个方法的初始行为是比较对象的内存地址,但在一些类库当中这个方法被覆盖掉了,如String, Integer, Date
在这些类当中`equals有其自身的实现,而不再是比较类在堆内存中的存放地址了。 - 对于复合数据类型之间进行
equals
比较,在没有覆写equals
方法的情况下,他们之间的比较还是基于他们在内存中的存放位置的地址值的,因为Object
的equals
方法也是用双等号(==
)进行比较的,所以比较后的结果跟双等号(==
)的结果相同。
- 当他们用(
equals()
的判断方法
严格来说,我们可以从三个角度定义相等:
-
**抽象函数:**回忆一下抽象函数( A F : R → A AF: R \to A AF:R→A ),它将具体的表示数据映射到了抽象的值。如果 A F ( a ) = A F ( b ) AF(a)=AF(b) AF(a)=AF(b),我们就说a和b相等。
-
等价关系:
等价是指对于关系 E ⊆ T × T E ⊆ T \times T E⊆T×T ,它满足:
- 自反性:
x.equals(x)
必须返回true
- 对称性:
x.equals(y)
与y.equals(x)
的返回值必须相等。 - 传递性:
x.equals(y)
为true
,y.equals(z)
也为true
,那么x.equals(z)
必须为true
。
- 自反性:
以上两种角度/定义实际上是一样的,通过等价关系我们可以构建一个抽象函数(译者注:就是一个封闭的二元关系运算);而抽象函数也能推出一个等价关系。
hashCode()
方法
- 对于不可变类型:
equals()
应该比较抽象值是否相等。这和equals()
比较行为相等性是一样的。hashCode()
应该将抽象值映射为整数。- 所以不可变类型应该同时覆盖
equals()
和hashCode()
.
- 对于可变类型:
equals()
应该比较索引,就像==
一样。同样的,这也是比较行为相等性。hashCode()
应该将索引映射为整数。- 所以可变类型不应该将
equals()
和hashCode()
覆盖,而是直接继承Object
中的方法。Java没有为大多数聚合类遵守这一规定,这也许会导致上面看到的隐秘bug。
- equals与hashCode两个方法均属于
Object
对象,equals根据我们的需要重写, 用来判断是否是同一个内容或同一个对象,具体是判断什么,怎么判断得看怎么重写,默认的equals是比较地址。 - hashCode方法返回一个int的哈希码, 同样可以重写来自定义获取哈希码的方法。
- equals判定为相同, hashCode一定相同。equals判定为不同,hashCode不一定不同。
hashCode
必须为两个被该equals
方法视为相等的对象产生相同的结果。- 与
equals()
方法类似,hashCode()
方法可以被重写。JDK中对hashCode()
方法的作用,以及实现时的注意事项做了说明:hashCode()
在哈希表中起作用,如java.util.HashMap
。- 如果对象在
equals()
中使用的信息都没有改变,那么hashCode()
值始终不变。 - 如果两个对象使用
equals()
方法判断为相等,则hashCode()
方法也应该相等。 - 如果两个对象使用
equals()
方法判断为不相等,则不要求hashCode()
也必须不相等;但是开发人员应该认识到,不相等的对象产生不相同的hashCode
可以提高哈希表的性能。
可变类型的等价性
回忆之前我们对于相等的定义,即它们不能被使用者观察出来不同。而对于可变对象来说,它们多了一种新的可能:通过在观察前调用改造者,我们可以改变其内部的状态,从而观察出不同的结果。
-
所以我们重新定义两种相等:
- 观察等价性:两个索引在不改变各自对象状态的前提下不能被区分。即通过只调用observer,producer和creator的方法,它测试的是这两个索引在当前程序状态下“看起来”相等。
- 行为等价性:两个索引在任何代码的情况下都不能被区分,即使有一个对象调用了改造者。它测试的是两个对象是否会在未来所有的状态下“行为”相等。
-
对于不可变对象,观察相等和行为相等是完全等价的,因为它们没有改造者改变对象内部的状态。
-
对于可变对象,Java通常实现的是观察相等。例如两个不同的
List
对象包含相同的序列元素,那么equals()
操作就会返回真。
第四章
第一节 面向可理解性的构造
代码的可理解性
如何编写易于理解的代码
- 遵循命名规范
- 限制代码行的最大长度、文件的最大LoC
- 足够的注释
- 代码有好的布局:缩进、空行、对其、分块、等。
- 避免多层嵌套—增加复杂度
- 文件和包的组织
代码的可读性/可理解性很多时候比效率/性能更重要,不可读、不可理解代码可能蕴含更多的错误。 因此先写出可读易懂的代码,再去逐渐调优。
第五章
第一节 可复用性的度量、形态和外部观察
什么是软件复用
-
软件复用是使用现有软件组件实施或更新软件系统的过程。
-
软件复用的两个观点:
- 面向复用编程(programming for reuse):开发出可复用的软件
- 开发成本高于一般软件的成本:要有足够高的适应性
- 性能差些: 针对更普适场景,缺少足够的针对性
- 基于复用编程(programming with reuse):利用已有的可复用软件搭建应用系统
- 可复用软件库,对其进行有效的管理
- 往往无法拿来就用,需要适配
- 面向复用编程(programming for reuse):开发出可复用的软件
-
为什么需要复用:
-
复用降低成本和开发时间
-
复用的代码经过充分测试,可靠、稳定
-
产出标准化,在不同应用中保持一致
-
软件复用的代价:
- 软件可复用的部分需要设计在如下的标准上:明确的定义、开放的方法、简洁的交互规范、可理解的文档,并着眼于未来。
- 不仅program for reuse代价高,program with reuse代价也高
-
代码复用的类型:
- 白盒复用:源代码可见,可修改和扩展
- 含义:复制已有代码到正在开发的系统,进行修改
- 优点:可订制化程度高
- 缺点:对其修改增加了软件的复杂度,且需要对其内部充分的了解
- 黑盒服用:源代码不可见,不能修改
- 含义:只能通过过API接口来使用,无法修改代码
- 优点:清晰、简单
- 缺点:适用性差
- 白盒复用:源代码可见,可修改和扩展
-
高复用性的软件应具有如下特性:
- 小、简单
- 与标准兼容
- 灵活可变
- 可扩展
- 泛型、参数化
- 模块化
- 变化的局部性
- 稳定
- 丰富的文档和帮助
可复用实现的级别
源代码级别的复用
模块级别的复用:类、抽象类、接口
- 复用类:
- 源码并非是必要的,可能只需要类文件或jar
- 只需要将这个类加入到类路径
- 可以使用工具javap获得一个类的public方法
- 使用复用类的注意事项:
- 文档十分重要
- 压缩会有助于复用
- 管理更少的代码
- 版本兼容性
- 需要和类相关的包
- 复用类的方法:继承和委派
- 继承(Inheritance):
- 类扩展了现有类的属性/行为;
- 另外,他们可能会
Override
现有的行为; - 通常需要在实施之前设计继承层次结构;
- 委派(Delegation):
- 根本没有父子关系的类中使用继承是不合理的,可以用委派的方式来代替。
- 委托是简单的将一个对象连接到另一个对象上,使另一个对象获得这个对象方法的子集(一个实体将某个事物传递给另一个实体)。
- 明确的委托:明确将需要传的对象传到目标对象上
- 含蓄的委托:委托可以被描述为一种共享代码数据的低级别机制
- 委派的类型:
- Use(A uses B)
- Composition/aggregation (A owns B)
- Association (A has B)
- 继承(Inheritance):
库级别的复用:API/包
- 方法:Libaray、framework
- library:
- 库定义:一组提供可重用功能的类和方法(API)
- 开发者构造可运行软件实体,其中涉及到对可复用库的调用
- Java中有很多的库可以复用,例如Guava:Google的Java核心库;Apache Commons等
- framework:
- 框架定义:一组具体类、抽象类、及其之间的连接关系
- 作为主程序加以执行,执行过程中调用开发者所写的程序
- 开发者根据 framework的规约,填充自己的代码进去,形成完整系统
- library:
系统级别的复用:框架
将framework看作是更大规模的API复用,除了提供可复用的API,还将这 些模块之间的关系都确定下来,形成了整体应用的领域复用。开发者的任务就是增加新代码、对抽象类进行具体化。展开来说就是以下几点:
- 通常通过选择性覆盖来扩展框架; 或者程序员可以添加专门的用户代码来提供特定的功能—定义继承了抽象类祖先操作的具体类
- 设计模式(Hook方法),它被应用程序覆盖以扩展框架。 Hook方法系统地将应用程序域的接口和行为与应用程序在特定上下文中所需的变体解耦。
- 控制反转,由第三方的容器来控制对象之间的依赖关系,而非传统实现中由代码直接操控。由第三方的容器来控制对象之间的依赖关系,而非传统实现中由代码直接操控。
- 不可修改的框架代码:在接受用户实现的扩展时,框架代码不应该被修改。 换句话说,用户可以扩展框架,但不应修改其代码。
对可复用性的外部观察
-
Type Variation 类型可变
- 能够复用的部分应该类型参数化,以适应不同的数据类型
- 复用的部分应该一般化
- 适应不同的类型,且满足LSP
-
Implementation Variation 实现可变
-
ADT 有多种不同的实现,提供不同的representations 和abstract function ,但具有同样的specification (pre-condition, post-condition, invariants) ,从而可以适应不同的应用场景
-
Routine Grouping 功能分组
-
提供完备的细粒度操作,保证功能的完整性,不同场景下复用不同的操作( 及其组合)
-
Representation Independence 表示独立
- 内部实现可能会经常变化,但客户端不应受到影响。
-
Factoring Out Common Behaviors 共性抽取
- 将共同的行为(共性)抽象出来,形成可复用实体
## 白盒框架和黑盒框架
框架也可分为白盒框架和黑盒框架两类。
- 白盒框架:
- 通过继承和动态绑定实现可扩展性。
- 通过继承框架基类并重写预定义的hook方法来扩展现有功能。
- 通常使用模板方法模式等设计模式来覆盖hook方法。
- 黑盒框架:
- 通过为可插入框架的组件定义接口来实现可扩展性。
- 通过定义符合特定接口的组件来复用现有功能。
- 这些组件通过委派(Delegation)与框架集成。
第二节 设计可复用的软件
设计可复用的类——LSP
- 在OOP之中设计可复用的类
- 封装和信息隐藏
- 继承和重写
- 多态、子类和重载
- 泛型编程
- LSP原则
- 委派和组合(Composition)
行为子结构
-
子类型多态( Subtype polymorphism):客户端可用统一的方式处理不同类型的对象 。
-
examples
Animal a = new Animal(); Animal c1 = new Cat(); Cat c2 = new Cat();
在可以使用
a
的场景,都可以用c1
和c2
代替而不会有任何问题。 -
在java的静态类型检查之中,编译器强调了几条规则:
- 子类型可以增加方法,但不可删
- 子类型需要实现抽象类型中的所有未实现方法
- 子类型中重写的方法必须有相同或子类型的返回值
- 子类型中重写的方法必须使用同样类型的参数
- 子类型中重写的方法不能抛出额外的异常
-
行为子结构也适用于指定的方法:
- 更强的不变量
- 更弱的前置条件
- 更强的后置条件
逆变与协变】
-
逆变与协变综述:如果A、B表示类型,f(⋅)表示类型转换,≤表示继承关系(比如, A ≤ B A≤B A≤B表示A是由B派生出来的子类):
-
- f(⋅)是逆变(contravariant)的,当 A ≤ B A \le B A≤B时有 f ( B ) ≤ f ( A ) f(B) \le f(A) f(B)≤f(A)成立;
- f(⋅)是协变(covariant)的,当 A ≤ B A \le B A≤B时有 f ( A ) ≤ f ( B ) f(A) \le f(B) f(A)≤f(B)成立;
- f(⋅)是不变(invariant)的,当 A ≤ B A \le B A≤B时上述两个式子均不成立,即f(A)与f(B)相互之间没有继承关系。
-
协变(Co-variance):
- 父类型 → \to →子类型:越来越具体(specific)。
- 在LSP中,返回值和异常的类型:不变或变得更具体 。
- examples
- 逆变(Contra-variance):
- 父类型 → \to →子类型:越来越抽象。
- 参数类型:要相反的变化,不变或越来越抽象。
- examples
- 这在Java中是不允许的,因为它会使重载规则复杂化。
Liskov替换原则(LSP)
- 里氏替换原则的主要作用就是规范继承时子类的一些书写规则。其主要目的就是保持父类方法不被覆盖。
- 含义:
- 子类必须完全实现父类的方法
- 子类可以有自己的个性
- 覆盖或实现父类的方法时输入参数可以被放大
- 覆盖或实现父类的方法时输出结果可以被缩小
- LSP是子类型关系的一个特殊定义,称为(强)行为子类型化。在编程语言中,LSP依赖于以下限制:
- 前置条件不能强化
- 后置条件不能弱化
- 不变量要保持或增强
- 子类型方法参数:逆变
- 子类型方法的返回值:协变
- 异常类型:协变
各种应用中的LSP
数组是协变的
- 数组是协变的:一个数组
T[]
,可能包含了T类型的实例或者T的任何子类型的实例 - 即子类型的数组可以赋予父类型的数组进行使用,但数组的类型实际为子类型。
泛型中的LSP
- Java中泛型是不变的,但可以通过通配符"?"实现协变和逆变:
<? extends>
实现了泛型的协变:List<? extends Number> list = new ArrayList<Integer>()
;<? super>
实现了泛型的逆变:List<? super Number> list = new ArrayList<Object>()
;
- 由于泛型的协变只能规定类的上界,逆变只能规定下界,使用时需要遵循PECS(producer–extends, consumer-super):
- 要从泛型类取数据时,用extends;
- 要往泛型类写数据时,用super;
- 既要取又要写,就不用通配符(即extends与super都不用)。
- 泛型是类型不变的(泛型不是协变的)。举例来说
ArrayList<String>
是List<String>
的子类型List<String>
不是List<Object>
的子类型
- 在代码的编译完成之后,泛型的类型信息就会被编译器擦除。因此,这些类型信息并不能在运行阶段时被获得。这一过程称之为类型擦除(type erasure)。
- 类型擦除的详细定义:如果类型参数没有限制,则用它们的边界或Object来替换泛型类型中的所有类型参数。因此,产生的字节码只包含普通的类、接口和方法。
- 类型擦除的结果:
<T>
被擦除T
变成了Object
Wildcards(通配符)
- 无界通配符类型使用通配符(
?
)指定,例如List<?>
,这被称为未知类型的列表。 - 在两种情况下,无界通配符是一种有用的方法:
- 如果您正在编写可使用Object类中提供的功能实现的方法。
- 当代码使用泛型类中不依赖于类型参数的方法时。 例如
List.size
或List.clear
。 事实上,Class<?>
经常被使用,因为Class<T>
中的大多数方法不依赖于T
。
examples
public static void printList(List<Object> list) {
for (Object elem : list)
System.out.println(elem + " ");
System.out.println();
}
printList
的目标是打印任何类型的列表,但它无法实现该目标 ,它仅打印Object
实例列表; 它不能打印List <Integer>
,List <String>
,List <Double>
等,因为它们不是List <Object>
的子类型。
- 要编写通用的
printList
方法,请使用List<?>
- 低边界通配符
<? super A> e.g. List<? super Integer> List<Number>
- 上边界通配符
<? extends A> e.g. List<? extends Number> List<Integer>
public static void printList(List<?> list) {
for (Object elem: list)
System.out.println();
}
List<Integer> li = Arrays.asList(1, 2, 3);
List<String> ls = Arrays.asList("one", "two", "three");
printList(li);
printList(ls);
委派与组合
委派(Delegation)
-
委派/委托:一个对象请求另一个对象的功能 。
-
委派是复用的一种常见形式。
-
分为显性委派:将发送对象传递给接收对象;
-
以及隐性委派:由语言的成员查找规则。
-
委派设计模式:是一种用来实现委派的软件设计模式;
-
委派依赖于动态绑定,因为它要求给定的方法调用可以在运行时调用不同的代码段;
-
委派的过程如下:
Receiver对象将操作委托给Delegate对象,同时Receiver对象确保客户端不会滥用委托对象;
委派与继承
- 继承:通过新操作扩展基类或覆盖操作。
- 委托:捕获操作并将其发送给另一个对象。
- 许多设计模式使用继承和委派的组合。
- Problem:如果子类只需要复用父类中的一小部分方法,
- Solution:可以不需要使用继承,而是通过委派机制来实现。
- 本质上,一个类不需要继承另一个类的全部方法,通过委托机制调用部分方法。
复合继承原则(CRP)
-
复合复用原则(CRP):类应当通过它们之间的组合(通过包含其它类的实例来实现期望的功能)达到多态表现和代码复用,而不仅仅是从基础类或父类继承。
-
我们可以将组合(Composition)理解为(has a)而继承理解为(is a);
-
委派可以看做Object层面的复用机制,而继承可以看做是类的层面;
-
只需针对不同子类的对象,委派能够计算该子类的奖金的方法的BonusCalculator。这样一来就不需要在子类继承的时候进行重写。
-
总结:组合来代替继承的更普遍实现:
- 用接口来实现系统的最基础行为
- 接口之间用extends来实现系统功能的扩展(接口组合)
- 类implements 组合接口
委派的类型
- 临时性委派(Dependency):最简单的方法,调用类里的方法(use a),其中一个类使用另一个类而不实际地将其作为属性。
- 永久性委派(Association):类之中有其它类的具体实例来作为一个变量(has a)
- 更强的委派,组合(Composition):更强的委派。将一些简单的对象组合成一个更为复杂的对象。(is part of)!
- 聚合(Aggregation):对象是在类的外部生成的,然后作为一个参数传入到类的内部构造器。(has a)
组合与聚合
在组合中,当拥有的对象被破坏时,被包含的对象也被破坏。在聚合中,这不一定是真的。以生活中的事物为例:大学拥有多个部门,每个部门都有一批教授。 如果大学关闭,部门将不复存在,但这些部门的教授将继续存在。 一位教授可以在一个以上的部门工作,但一个部门不能成为多个大学的一部分。大学与部门之间的关系即为组合,而部分与教授之间的关系为聚合。
设计可复用库与框架
之所以library和framework被称为系统层面的复用,是因为它们不仅定义了1个可复用的接口/类,而是将某个完整系统中的所有可复用的接口/类都实现出来,并且定义了这些类之间的交互关系、调用关系,从而形成了系统整体 的“架构”。
- 相应术语:
- API(Application Programming Interface):库或框架的接口
- Client(客户端):使用API的代码
- Plugin(插件):客户端定制框架的代码
- Extension Point:框架内预留的“空白”,开发者开发出符合接口要求的代码( 即plugin) , 框架可调用,从而相当于开发者扩展了框架的功能
- Protocol(协议):API与客户端之间预期的交互序列。
- Callback(反馈):框架将调用的插件方法来访问定制的功能。
- Lifecycle method:根据协议和插件的状态,按顺序调用的回调方法。
API和库
- 建议:始终以开发API的标准面对任何开发任务;面向“复用”编程而不是面向“应用”编程。
- 难度:要有足够良好的设计,一旦发布就无法再自由改变。
- 编写一个API需要考虑以下方面:
- API应该做一件事,且做得很好
- API应该尽可能小,但不能太小
- Implementation不应该影响API
- 记录文档很重要
- 考虑性能后果
- API必须与平台和平共存
- 类的设计:尽量减少可变性,遵循LSP原则
- 方法的设计:不要让客户做任何模块可以做的事情,及时报错
框架
-
框架(Framework)是整个或部分系统的可重用设计,表现为一组抽象构件及构件实例间交互的方法;另一种定义认为,框架是可被应用开发者定制的应用骨架。前者是从应用方面而后者是从目的方面给出的定义。
-
为了增加代码的复用性,可以使用委派和继承机制。同时,在使用这两种机制增加代码复用的过程中,我们也相应地在不同的类之间增加了关系(委派或继承关系)。而对于一个项目而言,各个不同类之间的依赖关系就可以看做为一个框架。一个大规模的项目可能由许多不同的框架组合而成。
-
框架与设计模式:
- 框架、设计模式这两个概念总容易被混淆,其实它们之间还是有区别的。构件通常是代码重用,而设计模式是设计重用,框架则介于两者之间,部分代码重用,部分设计重用,有时分析也可重用。在软件生产中有三种级别的重用:内部重用,即在同一应用中能公共使用的抽象块;代码重用,即将通用模块组合成库或工具集,以便在多个应用和领域都能使用;应用框架的重用,即为专用领域提供通用的或现成的基础结构,以获得最高级别的重用性。
- 框架与设计模式虽然相似,但却有着根本的不同。设计模式是对在某种环境中反复出现的问题以及解决该问题的方案的描述,它比框架更抽象;框架可以用代码表示,也能直接执行或复用,而对模式而言只有实例才能用代码表示;设计模式是比框架更小的元素,一个框架中往往含有一个或多个设计模式,框架总是针对某一特定应用领域,但同一模式却可适用于各种应用。可以说,框架是软件,而设计模式是软件的知识。
-
框架分为白盒框架和黑盒框架。
-
-
白盒框架:
-
- 白盒框架是基于面向对象的继承机制。之所以说是白盒框架,是因为在这种框架中,父类的方法对子类而言是可见的。子类可以通过继承或重写父类的方法来实现更具体的方法。
- 虽然层次结构比较清晰,但是这种方式也有其局限性,父类中的方法子类一定拥有,要么继承,要么重写,不可能存在子类中不存在的方法而在父类中存在。
- 有关白盒框架的例子:
-
public abstract class PrintOnScreen {
public void print() {
JFrame frame = new JFrame();
JOptionPane.showMessageDialog(frame, textToShow());
frame.dispose();
}
protected abstract String textToShow();
}
public class MyApplication extends PrintOnScreen {
@Override protected String textToShow() {
return "printing this text on " + "screen using PrintOnScreen " + "white Box Framework";
}
}
-
-
- 通过子类化和重写方法进行扩展(使用继承);
- 通用设计模式:模板方法;
- 子类具有主要方法但对框架进行控制。
- 允许扩展每一个非私有方法
- 需要理解父类的实现
- 一次只进行一次扩展
- 通常被认为是开发者框架
-
黑盒框架:
- 黑盒框架时基于委派的组合方式,是不同对象之间的组合。之所以是黑盒,是因为不用去管对象中的方法是如何实现的,只需关心对象上拥有的方法。
- 这种方式较白盒框架更为灵活,因为可以在运行时动态地传入不同对象,实现不同对象间的动态组合;而继承机制在静态编译时就已经确定好。
- 黑盒框架与白盒框架之间可以相互转换,具体例子可以看一下,软件构造课程中有关黑盒框架的例子,更改上面的白盒框架为黑盒框架:
-
public interface TextToShow {
String text();
}
public class MyTextToShow implements TextToShow {
@Override
public String text() {
return "Printing";
}
}
public final class PrintOnScreen {
TextToShow textToShow;
public PrintOnScreen(TextToShow tx) {
this.textToShow = tx;
}
public void print() {
JFrame frame = new JFrame();
JOptionPane.showMessageDialog(frame, textToShow.text());
frame.dispose();
}
}
- 通过实现插件接口进行扩展(使用组合/委派);
- 常用设计模式:Strategy, Observer ;
- 插件加载机制加载插件并对框架进行控制。
- 允许在接口中对public方法扩展
- 只需要理解接口
- 通常提供更多的模块
- 通常被认为是终端用户框架,平台
第三节 可复用的设计模式
结构型模式:Structural patterns
适配器模式(Adapter)
装饰器模式(Decorator)
外观模式(Facade Pattern)
行为类模式:Behavioral patterns
策略模式( Strategy)
模板模式(Template method)
迭代器模式( Iterator)
第六章
第一节 可维护性的度量与构造原则
软件的维护和演化
- 定义:软件可维护性是指软件产品被修改的能力,修改包括纠正、改进或软件对环境、需求和功能规格说明变化的适应。简而言之,软件维护:修复错误、改善性能。
- 类型:纠错性(25%)、适应性(25%)、完善性(50%)、预防性(4%)
- 演化:软件演化是一个程序不断调节以满足新的软件需求过程。
- 演化的规律:软件质量下降,延续软件生命
- **软件维护和演化的目标:**提高软件的适应性,延续软件生命 。
- 意义:软件维护不仅仅是运维工程师的工作,而是从设计和开发阶段就开始了 。在设计与开发阶段就要考虑将来的可维护性 ,设计方案需要“easy to change”
- 基于可维护性建设的例子:
- 模块化
- OO设计原则
- OO设计模式
- 基于状态的构造技术
- 表驱动的构造技术
- 基于语法的构造技术
可维护性的常见度量指标
- 可维护性:可轻松修改软件系统或组件,以纠正故障,提高性能或其他属性,或适应变化的环境。
- 除此之外,可维护性还有其他许多别名:可扩展性(Extensibility)、灵活性(Flexibility)、可适应性(Adaptability)、可管理性(Manageability)、支持性(Supportability)。总之,有好的可维护性就意味着容易改变,容易扩展。
- 软件可维护性的五个子特性:
- 易分析性。软件产品诊断软件中的缺陷或失效原因或识别待修改部分的能力。
- 易改变性。软件产品使指定的修改可以被实现的能力,实现包括编码、设计和文档的更改。如果软件由最终用户修改,那么易改变性可能会影响易操作性。
- 稳定性。软件产品避免由于软件修改而造成意外结果的能力。
- 易测试性。软件产品使已修改软件能被确认的能力。
- 维护性的依从性。软件产品遵循与维护性相关的标准或约定的能力。
- 一些常用的可维护性度量标准:
- 圈复杂度(CyclomaticComplexity):度量代码的结构复杂度。
- 代码行数(Lines of Code):指示代码中的大致行数。
- Halstead Volume:基于源代码中(不同)运算符和操作数的数量的合成度量。
- 可维护性指数(MI):计算介于0和100之间的索引值,表示维护代码的相对容易性。 高价值意味着更好的可维护性。
- 继承的层次数:表示扩展到类层次结构的根的类定义的数量。 等级越深,就越难理解特定方法和字段在何处被定义或重新定义。
- 类之间的耦合度:通过参数,局部变量,返回类型,方法调用,泛型或模板实例化,基类,接口实现,在外部类型上定义的字段和属性修饰来测量耦合到唯一类。
- 单元测试覆盖率:指示代码库的哪些部分被自动化单元测试覆盖。
模块化设计规范:聚合度与耦合度
- 模块化编程的含义:模块化编程是一种设计技术,它强调将程序的功能分解为独立的可互换模块,以便每个模块都包含执行所需功能的一个方面。
- 设计规范:高内聚低耦合
- 评估模块化的五个标准:
- 可分解性:将问题分解为各个可独立解决的子问题
- 可组合性:可容易的将模块组合起来形成新的系统
- 可理解性:每个子模块都可被系统设计者容易的理解
- 可持续性:小的变化将只影响一小部分模块,而不会影响整个体系结构
- 出现异常之后的保护:运行时的不正常将局限于小范围模块内
- 模块化设计的五条原则:
- 直接映射:模块的结构与现实世界中问题领域的结构保持一致
- 尽可能少的接口:模块应尽可能少的与其他模块通讯
- 尽可能小的接口:如果两个模块通讯,那么它们应交换尽可能少的信息
- 显式接口:当A与B通讯时,应明显的发生在A与B的接口之间
- 信息隐藏:经常可能发生变化的设计决策应尽可能隐藏在抽象接口后面
内聚性
- 又称块内联系。指模块的功能强度的度量,即一个模块内部各个元素彼此结合的紧密程度的度量。若一个模块内各元素(语名之间、程序段之间)联系的越紧密,则它的内聚性就越高。
- 所谓高内聚是指一个软件模块是由相关性很强的代码组成,只负责一项任务,也就是常说的单一责任原则。
耦合性
- 也称块间联系。指软件系统结构中各模块间相互联系紧密程度的一种度量。模块之间联系越紧密,其耦合性就越强,模块的独立性则越差。模块间耦合高低取决于模块间接口的复杂性、调用的方式及传递的信息。
- 对于低耦合,粗浅的理解是:一个完整的系统,模块与模块之间,尽可能的使其独立存在。也就是说,让每个模块,尽可能的独立完成某个特定的子功能。模块与模块之间的接口,尽量的少而简单。如果某两个模块间的关系比较复杂的话,最好首先考虑进一步的模块划分。这样有利于修改和组合。
SOLID原则
单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合。而开闭原则是总纲(实现效果),它告诉我们要对扩展开放,对修改关闭。
SRP | The Single Responsibility Principle | 单一责任原则 |
---|---|---|
OCP | The Open Closed Principle | 开放封闭原则 |
LSP | The Liskov Substitution Principle | 里氏替换原则 |
ISP | The Interface Segregation Principle | 接口分离原则 |
DIP | The Dependency Inversion Principle | 依赖倒置原则 |
SRP 单一责任原则
- 含义:需要修改某个类的时候原因有且只有一个。换句话说就是让一个类只做一种类型责任,当这个类需要承当其他类型的责任的时候,就需要分解这个类。
- 如果一个类包含了多个责任,那么将引起不良后果:引入额外的包,占据资源;导致频繁的重新配置、部署等。
- SRP是最简单的原则,却是最难做好的原则。
- SRP的一个反例:
OCP 开放封闭原则
- 软件实体应该是可扩展,而不可修改的。也就是说,对扩展是开放的,而对修改是封闭的。这个原则是诸多面向对象编程原则中最抽象、最难理解的一个。
- 模块的行为应是可扩展的,从而该模块可表现出新的行为以满足需求的变化。
- 模块自身的代码是不应被修改的
- 扩展模块行为的一般途径是修改模块的内部实现
- 如果一个模块不能被修改,那么它通常被认为是具有固定的行为。
- 关键解决方案:抽象技术。 使用继承和组合来改变类的行为。
- OCP的一个反例:
- OCP的一个例子:
// Open-Close Principle - Bad example
class GraphicEditor {
public void drawShape(Shape s) {
if (s.m_type==1)
drawRectangle(s);
else if (s.m_type==2)
drawCircle(s);
}
public void drawCircle(Circle r)
{....}
public void drawRectangle(Rectangle r)
{....}
}
class Shape { int m_type; }
class Rectangle extends Shape { Rectangle() { super.m_type=1; } }
class Circle extends Shape { Circle() { super.m_type=2; } }
上面代码存在的问题:
- 不可能在不修改GraphEditor的情况下添加新的Shape
- GraphEditor和Shape之间的紧密耦合
- 不调用GraphEditor就很难测试特定的Shape
改进之后的代码:
// Open-Close Principle - Good example
class GraphicEditor {
public void drawShape(Shape s) {
s.draw();
}
}
class Shape { abstract void draw(); }
class Rectangle extends Shape {
public void draw() {
// draw the rectangle }
}
}
LSP 里氏替换原则
- Liskov’s 替换原则意思是:"子类型必须能够替换它们的基类型。"或者换个说法:“使用基类引用的地方必须能使用继承类的对象而不必知道它。” 这个原则正是保证继承能够被正确使用的前提。通常我们都说,“优先使用组合(委托)而不是继承”或者说“只有在确定是 is-a 的关系时才能使用继承”,因为继承经常导致”紧耦合“的设计。
ISP 接口分离原则
- 含义:客户端不应依赖于它们不需要的方法。换句话说,使用多个专门的接口比使用单一的总接口总要好。
- 客户模块不应该依赖大的接口,应该裁减为小的接口给客户模块使用,以减少依赖性。如Java中一个类实现多个接口,不同的接口给不用的客户模块使用,而不是提供给客户模块一个大的接口。
- “胖”接口具有很多缺点。
- 胖接口可分解为多个小的接口;
- 不同的接口向不同的客户端提供服务;
- 客户端只访问自己所需要的端口。
- 下图展示出了这种思想:
- ISP的一个反例
DIP 依赖转置原则
- 定义:
- 高层模块不应该依赖于低层模块,二者都应该依赖于抽象
- 抽象不应该依赖于细节,细节应该依赖于抽象
- 这个设计原则的亮点在于任何被DI框架注入的类很容易用mock对象进行测试和维护,因为对象创建代码集中在框架中,客户端代码也不混乱。有很多方式可以实现依赖倒置,比如像AspectJ等的AOP(Aspect Oriented programming)框架使用的字节码技术,或Spring框架使用的代理等。
- 高层模块不要依赖低层模块;
- 高层和低层模块都要依赖于抽象;
- 抽象不要依赖于具体实现;
- 具体实现要依赖于抽象;
- 抽象和接口使模块之间的依赖分离。
- 一个具体的例子:
进行抽象改进后:
SOLID 总结
- 一个对象只承担一种责任,所有服务接口只通过它来执行这种任务。
- 程序实体,比如类和对象,向扩展行为开放,向修改行为关闭。
- 子类应该可以用来替代它所继承的类。
- 一个类对另一个类的依赖应该限制在最小化的接口上。
- 依赖抽象层(接口),而不是具体类。
第二节 可维护的设计模式
创造性模式:Creational patterns
工厂模式(Factory Pattern)
抽象工厂模式(Abstract Factory Pattern)
建造者模式(Builder Pattern)
结构化模式:Structural patterns
桥接模式(Bridge Pattern)
代理模式(Proxy Pattern)
组合模式(Composite Pattern)
行为化模式:Behavioral patterns
中介者模式(Mediator Pattern)
观察者模式(Observer Pattern)
访问者模式(Visitor Pattern)
责任链模式(Chain of Responsibility Pattern)
命令模式(Command Pattern)
第三节 面向可维护的构造技术
基于状态的构造技术
状态模式(State Pattern)
备忘录模式(Memento Pattern)
基于语法的构造技术
运用场景
- 有一类应用,从外部读取文本数据, 在应用中做进一步处理。 具体来说,读取的一个字节或字符序列可能是:
- 输入文件有特定格式,程序需读取文件并从中抽取正确的内容。
- 从网络上传输过来的消息,遵循特定的协议。
- 用户在命令行输入的指令,遵顼特定的格式。
- 内存中存储的字符串,也有格式需要。
对于这些类型的序列,语法的概念是设计的一个好选择:
- 使用grammar判断字符串是否合法,并解析成程序里使用的数据结构 。
- 正则表达式
- 通常是递归的数据结构 。
语法成分
terminals 终止节点、叶节点
nonterminal 非终止节点(遵循特定规则,利用操作符、终止节点和其他非终止节点,构造新的字符串)
语法中的操作符
- 三个基本语法的操作符:
- 连接,不是通过一个符号,而是一个空间:
x ::= y z //x等价于y后跟一个z
- 重复,以*表示:
x ::= y* // x等价于0个或更多个y
- 联合,也称为交替,如图所示 | :
x ::= y | z //x等价于一个y或者一个z
- 连接,不是通过一个符号,而是一个空间:
- 三个基本操作符的组合:
- 可选(0或1次出现),由?表示:
x ::= y? //x等价于一个y或者一个空串
- 出现1次或多次:以+表示:
x ::= y+ //x等价于一个或者更多个y, 等价于 x ::= y y*
- 字符类[…],表示长度的字符类,包含方括号中列出的任何字符的1个字符串:
x ::= [abc] //等价于 x ::= 'a' | 'b' | 'c'
- 否定的字符类[^…],表示长度,包含未在括号中列出的任何字符的1个字符串:
x ::= [^abc] //等价于 x ::= 'd' | 'e' | 'f' | ... (all other characters in Unicode)
- 可选(0或1次出现),由?表示:
- 例子:
x ::= (y z | a b)* //an x is zero or more y z or a b pairs
m ::= a (b|c) d //an m is a, followed by either b or c, followed by d
Markdown 和 HTML的语法
正则语法与正则表达式
- 正则语法:简化之后可以表达为一个产生式而不包含任何非终止节点。
- 正则语法示例:
//Rugular!
url ::= 'http://' ([a-z]+ '.')+ [a-z]+ (':' [0-9]+)? '/'
//Regular!
markdown ::= ([^_]* | '_' [^_]* '_' )*
//Not Regular!
html ::= ( [^<>]* | '<i>' html '<i>' )*
- 在Java中使用正则表达式
- 适用场合:我们用正则表达式匹配字符串(例如 String.split , String.matches , java.util.regex.Pattern)
第七章
第一节 健壮性和正确性的区别
健壮性(Robustness)和正确性(correctness)
健壮性
- 定义:系统在不 正常输入或不正常外部环境下仍能够表现正常的程度
- 面向健壮性编程:
- 处理未期望的行为和错误终止
- 即使终止执行,也要准确/无歧义的向用户展示全面的错误信息
- 错误信息有助于进行debug
- 健壮性原则:
- Paranoia (偏执狂):总是假定用户恶意、假定自己的代码可能失败
- 把用户想象成白痴,可能输入任何东西(返回给用户的错误提示信息要详细、准确、无歧义)
- 对别人宽容点,对自己狠一点(对自己的代码要保守,对用户的行为要开放)
- 面向健壮性编程的原则:
- 封闭实现细节,限定用户的恶意行为
- 考虑极端情况,没有“不可能”
正确性
- 含义:程序按照spec加以执行的能力,是最重要的质量指标!
- 对比健壮性和正确性:
- 正确性:永不给用户错误的结果; 让开发者变得更容易:用户输入错误,直接结束(不满足precondition调用)。
- 健壮性:尽可能保持软件运行而不是总是退出; 让用户变得更容易:出错也可以容忍,程序内部已有容错机制。
- 正确性倾向于直接报错(error),健壮性则倾向于容错(fault-tolerance);
- 对外的接口,倾向于健壮性;对内的实现,倾向于正确性。
- Reliability(可靠性) = Robustness + correctness
Problem | 健壮性 | 正确性 |
---|---|---|
浏览器发出包含空格的URL | 剥离空白,正常处理请求。 | 将HTTP 400错误请求错误状态返回给客户端。 |
视频文件有坏帧 | 跳过腐败区域到下一个可播放部分。 | 停止播放,引发“损坏的视频文件”错误 |
配置文件使用了非法字符 | 在内部识别最常见的评论前缀,忽略它们。 | 终止启动时出现“配置错误”错误 |
奇怪格式的日期输入 | 尝试针对多种不同的日期格式解析字符串。将正确的格式呈现给用户。 | 日期错误无效 |
如何测量健壮性和正确性
- 外部观察角度:
- Mean time between failures (MTBF,平均失效间隔时间):描述了可修复系统的两次故障之间的预期时间,而平均故障时间(MTTF)表示不可修复系统的预期故障时间。
- 内部观察角度:
- 残余缺陷率:每千行代码中遗留的bug的数量
第二节 错误与异常处理
Java中的错误和异常
Throwable
- Java.lang.throwable
- Throwable 类是 Java 语言中所有错误或异常的超类。
- 继承的类:extends Object。
- 实现的接口:implements Serializable。
- 直接已知子类:Error, Exception(直接已知子类:IOException、RuntimeException)。
Error
- Error类描述很少发生的Java运行时系统内部的系统错误和资源耗尽情况(例如,VirtualMachineError,LinkageError)。
- 对于内部错误:程序员通常无能为力,一旦发生,想办法让程序优雅的结束
- Error的类型:
- 用户输入错误
- 例如:用户要求连接到语法错误的URL,网络层会投诉。
- 设备错误
- 硬件并不总是做你想做的。
- 输出器被关闭
- 物理限制
- 磁盘可以填满
- 可能耗尽了可用内存
- 用户输入错误
异常(Exception)
- 异常:程序执行中的非正常事件,程序无法再按预想的流程执行。
- 异常处理:
- 将错误信息传递给上层调用者,并报告“案发现场”的信息。
- return之外的第二种退出途径:若找不到异常处理程序,整个系统完全退出
【异常按结构层次的分类】
- 运行时异常:由程序员处理不当造成,如空指针、数组越界、类型转换
- 其他异常:程序员无法完全控制的外在问题所导致的,通常为IOE异常,即找不到文件路径等
【异常按处理机制角度的分类】
-
为什么区分checked 和 unchecked:原因其实很简单,编译器将检查你是否为所有的已检查异常提供了异常处理机制,比如说我们使用Class.forName()来查找给定的字符串的class对象的时候,如果没有为这个方法提供异常处理,编译是无法通过的。
-
Checked exception:
- 编译器可帮助检查你的程序是否已抛出或处理了可能的异常
- 异常的向上抛出机制进行处理,如果子类可能产生A异常,那么在父类中也必须throws A异常。可能导致的问题:代码效率低,耦合度过高。
- checked exception是需要强制catch的异常,你在调用这个方法的时候,你如果不catch这个异常,那么编译器就会报错,比如说我们读写文件的时候会catch IOException,执行数据库操作会有SQLException等。
- 对checked Exception处理机制
- 抛出:声明是throws,抛出时throw
- 捕获(try/catch):try出现异常,忽略后面代码直接进入catch;无异常不进入catch;若catch中没有匹配的异常处理,程序退出;若子类重写了父类方法,父类方法没有抛出异常,子类应自己处理全部异常而不再传播;子类从父类继承的方法不能增加或更改异常
- 处理:不能代替简单的测试,尽量苛刻、不过分细化、将正常处理与异常处理分开、利用好层次结构、早抛出晚捕获、避免不必要的检查
- 清理现场、释放资源(finally):finally中语句不论有无异常都执行
-
unchecked exception:
- 程序猿对此不做任何事情,不得不重写你的代码(不需要在编译时使用try-catch等机制处理)
- 这类异常都是RuntimeException的子类,它们不能通过client code来试图解决
- 这种异常不是必须需要catch的,你是无法预料的,比如说你在调用一个 list.szie()的时候,如果这个list为null,那么就会报NUllPointerException,而这个异常就是 RuntimeException,也就是UnChecked Exception
- 常见的unchecked exception:JVM抛出,如空指针、数组越界、数据格式、不合法的参数、不合法的状态、找不到类等
checked和unchecked总结】
- 当要决定是采用checked exception还是Unchecked exception的时候,问一个问题: “如果这种异常一旦抛出,client会做 怎样的补救?”
- 如果客户端可以通过其他的方法恢复异常,那么采用checked exception;
- 如果客户端对出现的这种异常无能为力,那么采用unchecked exception;
- 异常出现的时候,要做一些试图恢复它的动作而不要仅仅的打印它的信息。
- 尽量使用unchecked exception来处理编程错误:因为uncheckedexception不用使客户端代码显式的处理它们,它们自己会在出现的地方挂起程序并打印出异常信息。
- 如果client端对某种异常无能为力,可以把它转变为一个unchecked exception,程序被挂起并返回客户端异常信息
Checked exception应该让客户端从中得到丰富的信息。
要想让代码更加易读,倾向于用unchecked exception来处理程序中的错误
checked异常的处理机制
异常中的LSP原则
- 如果子类型中override了父类型中的函数,那么子类型中方法抛出的异常不能比父类型抛出的异常类型更广泛
- 子类型方法可以抛出更具体的异常,也可以不抛出任何异常
- 如果父类型的方法未抛出异常,那么子类型的方法也不能抛出异常。
- 其他的参考第五章第二节的LSP
利用throws进行声明
- 使用throws声明异常:此时需要告知你的client需要处理这些异常,如果client没有handler来处理被抛出的checked exception,程序就终止执行。
- 程序员必须在方法的spec中明确写清本方法会抛出的所有checked exception,以便于调用该方法的client加以处理
- 在使用throws时,方法要在定义和spec中明确声明所抛出的全部checked exception,没有抛出checked异常,编译出错,Unchecked异常和Error可以不用处理。
利用throw抛出一个异常
- 步骤:
- 找到一个能表达错误的Exception类/或者构造一个新的Exception类
- 构造Exception类的实例,将错误信息写入
- 抛出它
- 一旦抛出异常,方法不会再将控制权返回给调用它的client,因此也无需考虑返回错误代码
try-catch语句
- 使用 try 和 catch 关键字可以捕获异常。try/catch 代码块放在异常可能发生的地方。
- try/catch代码块中的代码称为保护代码,
- Catch 语句包含要捕获异常类型的声明。当保护代码块中发生一个异常时,try 后面的 catch 块就会被检查。
- 如果发生的异常包含在 catch 块中,异常会被传递到该 catch 块,这和传递一个参数到方法是一样。
finally语句
- 场景:当异常抛出时,方法中正常执行的代码被终止;但如果异常发生前曾申请过某些资源,那么异常发生后这些资源要被恰当的清理,所以需要用finally语句。
- finally 关键字用来创建在 try 代码块后面执行的代码块。
- 无论是否发生异常,finally 代码块中的代码总会被执行。
- 在 finally 代码块中,可以运行清理类型等收尾善后性质的语句。
- finally 代码块出现在 catch 代码块最后:
- 注意下面事项:
- catch 不能独立于 try 存在。
- 在 try/catch 后面添加 finally 块并非强制性要求的。
- try 代码后不能既没 catch 块也没 finally 块。
- try, catch, finally 块之间不能添加任何代码。
自定义异常
- 如果JDK提供的exception类无法充分描述你的程序发生的错误,可以创建自己的异常类。
- 如果希望写一个检查性异常类,则需要继承 Exception 类。
- 如果你想写一个运行时异常类,那么需要继承 RuntimeException 类。
第三节 断言和防御性编程
断言
什么是断言
- **作用:**允许程序在运行时检查自己,测试有关程序逻辑的假设,如前置条件、后置条件、内部不变量、表示不变量、控制流不变量等
- 目的: 为了在开发阶段调试程序、尽快避免错误
- 使用阶段:
- 断言主要用于开发阶段,避免引入和帮助发现bug
- 实际运行阶段, 不再使用断言
- 软件发布阶段,禁用断言避免影响性能。
应用场景
- 输入参数或输出参数的取值处于预期范围
- 子程序开始执行(结束)时,文件或流处于打开(关闭)状态
- 子程序开始执行(结束)时,文件或流的读写位置处于开头(结尾)
- 文件或流已打开
- 输入变量的值没有被子程序修改
- 指针非空
- 传入子程序的数组至少能容纳X个元素
- 表已初始化,存储着真实的数据
- 子程序开始(结束)时,容器空(满)
- 一个高度优化过的子程序与一个缓慢的子程序,结果一致
- 断言只在开发阶段被编译到目标代码中,而在生成代码时不编译进去。使用断言的指导建议:
- 用错误处理代码来处理预期会发生的状况,断言不行!
- 避免把需要执行的代码放入断言中(如果未编译断言呢?)
- 用断言来注解并验证前条件和后条件
- 对于高健壮性的代码,应该先用断言,再处理错误
注意
- 编译时加入-ea(enable assertion)选项运行断言,-da(disable assertion)关闭断言
- 条件语句或开关没有涵盖所有可能的情况,最好使用断言来阻止非法事件
- 可以在预计正常情况下程序不会到达的地方放置断言:assert false
- 断言有代价,需慎用,一般用于验证正确性,处理绝不应该发生的情况
- 不能作为公共方法的检查,也不能有边界效应
断言和异常的对比
- 用异常处理技术来处理你“希望发生”的不正常情况
- 用断言来处理“不希望发生”的情况;断言的方式处理一定是发生了错误
- 不要把业务逻辑(执行代码)放到断言里面去处理
- 参数检查通常是方法发布的规范(或契约)的一部分,无论断言是启用还是禁用,都必须遵守这些规范。
- 如果参数来自于外部(不受自己控制),使用异常处理
- 如果来自于自己所写的其他代码,可以使用断言来帮助发现错误(例如postcondition就需要)
第四节 调试
什么是bug
- bug即程序中的错误,导致程序以非预期或未预料到的方式执行。
- 一个包含大量bug和/或严重干扰其功能的bug的程序被称为buggy。
- 报告程序中的bug通常被称为bug报告、故障报告、问题报告、故障报告、缺陷报告等
bug产生的原因
- 代码错误
- 未完成的要求或不够详细
- 误解用户需求
- 设计文档中的逻辑错误
- 缺乏文件
- 没有足够的测试
bug的常见类型
- 数学bug:例如 零除法,算术溢出
- 逻辑bug:例如 无线循环和无限递归
- 源头bug:例如 使用了为被定义的变量、资源泄漏,其中有限的系统资源如内存或文件句柄通过重复分配耗尽而不释放。缓冲区溢出,其中程序试图将数据存储在分配存储的末尾。
- 团队工程bug:例如 评论过时或者评论错误、文件与实际产品的区别
调试的基本过程
- Debug是测试的后续步骤:测试发现问题,debug消除问题;当防御式编程和测试都无法挡住bug时,我们就必须进行debug了;
- Debug的目的:寻求错误的根源并消除它;(Debug占用了大量的时间)
调试的过程
- 常用方法:假设-检验
- 重现(Reproduce)–>诊断(Diagnose/Locating)–>修复(Fix)–>反思(Reflect)
- 重现(Reproduce):寻找一个可靠、方便得在线需求问题的方法。
- 从最小的测试用例开始复现错误(保持复现bug的前提下降低输入规模)
- 消除因版本、环境、配置等不同引起的差异(通过构建软件实现),确定bug出现的环境(通过程序模拟硬件平台的细节,实现不同的操作系统环境)
- 利用逆向设计推断导致错误的输入
- 若无法重现,则无法观察以证明分析和修补的正确性
- 诊断(Diagnose/Locating):构建假设,并通过执行实验来测试它们,直到您确信已识别错误的根本原因。
- 从假设开始,构造实验,证明它是对的或者错的
- 从不符合理论的观察结果开始,修正理论
- 查看导致错误的测试输入,以及错误的结果,失败的断言以及由此导致的堆栈跟踪
- 提出一个与所有数据一致的假设,说明错误发生的位置或错误发生的位置,设计实验测试假设
- 收集实验数据,减少错误可能出现的范围,做出新的假设
- 设计不同的实验:检查内部状态、修改运行方式、改变本身逻辑
- 每次只做一个修改、做好记录、不忽略细节、运行不同的测试用例、设置断点、用可实现相同功能并且被证实无问题的组件替代当前组件
- 修复(Fix):设计和实施解决问题的变化,避免引入回归,并保持或提高软件的整体质量。
- 确保从干净的源代码树开始
- 运行现有的测试,并证明它们通过
- 添加一个或多个新测试,或修复现有测试,以演示错误
- 修复错误、发现可改进之处
- 证明你的修复工作正常且没有引入回归(以前通过的测试现在失败)
- 如果引入回归,通过回顾以前的版本来找出确切的变化
- 反思(Reflect):思考需求、设计、测试、结构(库、编译器等)
调试的技术和工具
调试技术
- 暴力调试(Brute Force Attack)
- 蛮力方法可以分为至少三类:
- 看内存导出文件
- 根据“在整个程序中分散打印语句”的常见建议进行调试。
- 自动化调试工具
- 蛮力方法可以分为至少三类:
- 递归调试(Induction)
- 演绎调试(Decution)
- 回溯调试(Backtracking)
- 测试调试(Testing)
调试工具
- 语法和逻辑检查(本课程未涵盖)
- 源代码比较器(Source-code comparator)
- 内存堆转储(Memory heap dump)
- 打印调试/日志记录(Print debugging / logging)
- 堆栈跟踪(Stack trace)
- 编译器警告消息(Compiler Warning Messages)
- 调试器(Debugger)
- 执行分析器(Execution Profiler)
- 测试框架(Test Framework)
第五节 测试与测试优先编程
测试和测试优先编程
测试的定义
- 测试:发现程序中的错误 提高程序正确性的信心
- 程序正确确认的基本方法:
- 形式化推理
- 代码评审
- 测试
- 测试是提高软件质量的重要手段
- 确认是否可达到可用的级别
- 关注系统某一侧面的质量特性
- 是否满足需求
- 是否正确响应所有需求
- 性能是否可接受
- 是否可用
- 可否正确部署安装
- 是否达到期望
测试的分类
- 单元测试
- 集成测试
- 系统测试
- 回归测试
- 验收测试
黑盒测试
-
白盒测试:对程序内部代码结构的测试 只关注代码内部的问题
-
黑盒测试:对程序外部表现出来的行为的测试 采用两个方法
-
等价划分
将程序可能的输入进行分类 划分为不同集合 包括不合法数据
- 等价类划分可有两种不同的情况:有效等价类和无效等价类。
- 若一组对象自反、对称、传递,则为等价类
- 可产生相似结果的输入集合中的一个可代替整个集合
- 同理,对输出也可以划分等价类
- 极端:每个分区只有一个测试用例,覆盖所有分区
-
边界值分析方法
边界值分析法是对输入输出的边界值进行测试一种黑盒测试方法,是对等价类分析法的补充。
- 错误通常隐藏在边界中,如一位偏移、边界值需单独处理等
- 找到有效数据和无效数据的分界点(最大值、最小值),对该分界点以及两边的值分别单独进行测试。
- 等价类划分法可以挑选等价类范围内任意一个数据作为代表,而边界值分析法要求每个边界值都要作为测试条件。
-
-
测试困难
- 软件行为在离散输入空间中差异巨大
- 大多数正确 少数错误
- bug出现不遵循特定概率分布
- 无统计规律可循
- 软件行为在离散输入空间中差异巨大
代码覆盖度
- 定义:已有的测试用例有多大程度覆盖了被测程序;
- 代码覆盖度越低,测试越不充分;但要做到很高的代码覆盖度,需要更多的测试用例,测试代价高;
- 代码覆盖率高的程序在测试期间执行了更多的源代码,与低代码覆盖率的程序相比,包含未检测到的软件错误的可能性较低
- 基本覆盖标准:函数覆盖;语句覆盖、分支覆盖、条件覆盖、路径覆盖
- 测试效果:路径 > 分支 > 语句
- 测试难度:路径 > 分支 > 语句
以注释的形式撰写测试策略
- “测试策略”通俗来讲就是6个字:“测什么”和“怎么测”。测试策略非常重要,需要在程序中显性记录下来。
- 目的:在代码评审过程中,其他人能够理解你的测试,并评判测试是否充分
- 在测试类的顶端写策略
- 在每个测试方法前说明测试用例是如何选择的
JUnit 测试用例写法
- JUnit单元测试是依据 注释中
@Test
之前的方法编写的 - JUnit测试经常调用多次方法,使用
assertEqual || assertTrue || assertFalse
来检查结果 @Before
:准备测试、完成初始化,每个测试方法前执行一次@After
:清理现场,每个测试方法后执行一次@Test
:表明测试方法,内含assert语句- 第一个参数是预期结果、第二个参数实施及结果;
- 如果断言失败,该测试方法直接返回,JUnit记录该测试的失败;
- 一个测试方法失败,其他测试方法仍运行
@Test(expected = *.class)
:对错误的测试,expected的属性值是一个异常@Test(timeout = xxx)
:测试方法在制定的时间之内没有运行完则失败
@ignore
:忽略测试方法- examples
public class Calculator {
public int evaluate(String expression) {
int sum = 0;
for (String summand: expression.split("\\+"))
sum += Integer.valueOf(summand);
return sum;
}
}
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public class CalculatorTest {
@Test
public void evaluatesExpression() {
Calculator calculator = new Calculator();
int sum = calculator.evaluate("1+2+3");
assertEquals(6, sum);
}
}
第八章
第一节 软件构造性能的度量原理
性能度量指标
- 时间性能
- 每条指令、每个控制 结构、整个程序的执行时间
- 不同语句或控制结构执行时间的分布情况
- 时间瓶颈在哪里
- 空间性能
- 每个变量、每个复杂结构、整个程序的内存消耗
- 不同变量/数据结构的相对消耗
- 空间瓶颈在哪里
- 随时间的变化情况
内存管理
对象管理模型
-
三者的差异在于:如何与何时在程序对象与内存对象之间建立联系
-
静态
- 定义:静态内存是指在程序开始运行时由编译器分配的内存,它的分配是在程序开始编译时完成的,不占用CPU资源。
- 程序中的各种变量,在编译时系统已经为其分配了所需的内存空间,当该变量在作用域内使用完毕时,系统会自动释放所占用的内存空间;
- 不支持递归,不支持动态创建可变长的复杂数据类型;
- 在程序执行期内实体至多关联一个运行时对象
- eg: 基本类型,数组
-
动态-基于栈
- 栈定义:方法调用和局部变量的存储位置,保存基本类型
- 如果一个方法被调用,它的栈帧被放到调用栈的顶部
- 栈帧保存方法的状态,包括执行哪行代码以及所有局部变量的值
- 栈顶始终是当前运行方法
- 一个实体可以在运行时连续地连接到多个对象,并且运行时机制以堆栈中的后进先出顺序分配和释放这些对象
- 栈无法支持复杂数据结构
- 栈定义:方法调用和局部变量的存储位置,保存基本类型
-
动态-基于堆
- 堆定义:在一块内存里分为多个小块,每块包含 一个对象,或者未被占用
- 自由模式的内存管理,动态分配,可管理复杂的动态数据结构
- 代码中的一个变量可以在不同时间被关联到不同的内存对象上,无法在编译阶段确定。内存对象也可以进一步指向其他对象
Java垃圾回收机制
内存回收的三种方式
①静态模式下的内存回收:在静态内存分配模式下,无需进行内存回收:所有都是已确定的。
②在栈模式下的内存回收:按block(某个方法)整体进行
③在堆模式下的内存回收:在heap上进行内存空间回收,最复杂——无法提前预知某个object是否已经变得无用。
动态垃圾回收相关概念
- GC(Garbage Collection):识别垃圾并释放其占用的内存
- 垃圾回收器根据对象的“活性”(从root的可达性)来决定是否回收该对象的内存,”死“的对象是需要回收的垃圾
- Root
- 根集合由root对象和局部对象构成
- root对象:Class(不能被回收)、Thread、Java方法/接口的本地变量或参数、全局接口引用等
- 可达/不可达对象(Reachable/Unreachable):free模式
- 从根可以直接或间接到达的对象为可达的,否则为不可达的
- 从根开始,不断将指向的对象加入活动集,剩下的是垃圾
- 活动/死亡对象(Live/dead):
- 在stack和free的结合模式下,对象的引用被视为有向图,可以从根访问的对象为活动对象,否则为死亡对象。
GC的四种算法
-
引用计数
- 基本思想:为每个object存储一个计数RC,当有其他 reference指向它时,RC++;当其他reference与其断开时,RC–;如 果RC==0,则回收它。
- 优点:简单、计算代价分散,“幽灵时间”短 为0
- 缺点:不全面(容易漏掉循环引用的对象)、并发支 持较弱、占用额外内存空间、等
-
Mark-Sweep(标记-清除)算法
- 基本思想:为每个object设定状态位(live/dead)并记录,即mark阶段;将标记为dead的对象进行清理,即sweep可阶段。
- **优点:**可以处理循环调用,指针操作无开销,对象不变
- 缺点:复杂度为O(heap),高 堆的占用比高时影响性能,容易造成碎片,需要找到root
-
Copying(复制)算法
- 基本思想:为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。
- 优势:运行高效、不易产生内存碎片
- 缺点:复制花费大量的时间,牺牲内存空间
-
Mark-Compact(标记-整理)算法
- 基本思想:为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。
JVM中的GC
Java GC将堆分为不同的区域,各区域采用不同的GC策略,以提高GC的效率
-
Java内存分配和回收的机制概括的说,就是:分代分配,分代回收。
-
对象将根据存活的时间被分为:年轻代(Young Generation)、年老代(Old Generation)、永久代(Permanent Generation,也就是方法区)
-
年轻代:
- 对象被创建时,内存的分配首先发生在年轻代(大对象可以直接 被创建在年老代)
- 大部分的对象在创建后很快就不再使用,因此很快变得不可达,于是被年轻代的GC机制清理掉(IBM的研究表明,98%的对象都是很快消 亡的)
- 为减少GC代价,使用copying算法
- 具体过程
- 绝大多数刚创建的对象会被分配在Eden区,其中的大多数对象很快就会消亡。Eden区是连续的内存空间,因此在其上分配内存极快;
- 当Eden区满的时候,执行Minor GC,将消亡的对象清理掉,并将剩余的对象复制到一个存活区Survivor0(此时,Survivor1是空白的,两个Survivor总有一个是空白的);
- 此后,每次Eden区满了,就执行一次Minor GC,并将剩余的对象都添加到Survivor0;
- 当Survivor0也满的时候,将其中仍然活着的对象直接复制到Survivor1,以后Eden区执行Minor GC后,就将剩余的对象添加Survivor1(此时,Survivor0是空白的)。
- 当两个存活区切换了几次(HotSpot虚拟机默认15次,用-XX:MaxTenuringThreshold控制,大于该值进入老年代)之后,仍然存活的对象(其实只有一小部分,比如,我们自己定义的对象),将被复制到老年代。
-
年老代:
- 对象如果在年轻代存活了足够长的时间而没有被清理掉,则会被复制到年老代,年老代的空间一般比年轻代大,能存放更多的对象,在年老代上发生的GC次数也比年轻代少。
- 使用Mark-Sweep或Mark-Compact算法;
- Minor GC和full GC独立进行,减小代价;
- 当perm generation满了之后,无法存储更多的元数据,也启动full GC。
GVM GC性能调优
-
尽可能减少GC时间,一般不超过程序执行时间的5%
-
一旦初始分配给程序的内存满了,就抛出内存溢出异常,
-
在启动程序时,可为其配置内存分配的具体大小
-
堆的大小决定着VM将会以何种频度进行GC、每次GC的时间多长。
- 这两个指标具体取值多少为“优”,需要针对特定应用进行分析。
- 较大的heap会导致较少发生GC,但每次GC时间很长
- 如果根据程序需要来设置heap大小,则需要频繁GC,但每次GC的时间较短
-
设定堆的大小的具体方法
-
Xmx/-Xms:指定年轻代和老年代空间的初始值和最大值;Xms小于Xmx时,年轻代和老年代所消耗的空间量可以根据应用程序的需求增长或收缩;Java堆的增长不会比Xms大,也不会比Xmx小
-
XX: NewSize=[g|m|k]:年轻代空间的初始和最小尺寸,是大小,[g | m | k]指示大小是否应解释为千兆字节,兆字节或千字节
-
XX: MaxNewSize=[g|m|k]:年轻代空间的最大值
-
Xmn[g|m|k]:将年轻代的初始值、最小值、最大值设为同一值
-
GC模式选择
- 增长或收缩年轻代或老年代的空间时需要Full GC
- Full GC可能会降低吞吐量并导致超出期望的延迟
- 串行收集器(-XX:+UseSerialGC):使用单个线程执行所有垃圾收集工作
- 并行收集器(-XX:+UseParallelGC):并行执行Minor GC,显著减少垃圾收集开销
- 并发低暂停收集器(-XX:+UseConcMarkSweepGC):收集持久代,与执行应用程序同时执行大部分收集,在收集期间会暂停一小段时间
- 增量低暂停收集器(-XX:+UseTrainGC):收集每个Minor的部分老年代,并尽量减少Major的大停顿
- -verbose:gc:打印GC信息
第二节 动态程序分析方法与工具
- Jstat:获取JVM的Heap使用和GC的性能统计数据,命令如-gcutil
- Jmap:输出内存中的对象分布情况 如:jmap -clstats
- Jhat:导出heap dump,浏览/查询其中的对象分布情况
- jstack:获取Java线程的stack trace 具体用途如下:
- 定位线程出现长时间停顿的原因,如多线程间死锁、死循环、请求外部资源 导致的长时间等待等。
- 线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没 有响应的线程到底在后台做什么事情,或者等待什么资源。
- Visual VM:提供了一个可视化界面,用于查看Java应用程序在JVM上运行时的详细信息,使用各种技术,包括jvmstat,JMX,Serviceability Agent(SA)和Attach API等
- MAT:内存堆导出文件的分析工具,生成饼状图等,能够对问题发生时刻的系统内存状态获取一个整体印象,找到最有可能导致内存泄露的对象,进一步查看其是否有异常行为。
Memory Dump(堆转储文件)
正如Thread Dump文件记录了当时JVM中线程运行的情况一样,Heap Dump记录了JVM中堆内存运行的情况,可使用jmap或JConsole命令生成,jhat分析。
使用 jmap 命令生成
使用JConsole生成
使用jhat分析
Stack Trace
可使用jstack查看,定位线程出现长时间停顿的原因。
第三节 代码调优的设计模式和I/O
代码调优
代码调优的概念
- 代码调优:代码调优不是为了修复bug,而是对正确的代码进行修改以提高其性能,其常常是小规模的变化
- 调优不会减少代码行数
- 不要猜原因,而应有明确的优化目标
- 不要边写程序边调优
- 不是性能优化的第一选择
- 代码行数与性能之间无必然的联系
- 代码调优建立在对程序性能的精确度量基础之上(profiling)
- 当程序做过某些调整之后,要重新profiling并重新了解需要优化的性能瓶颈,微小的变化能导致优化方向大不相同
- 性能从不是追求的第一目标,正确性比性能更重要
单例模式(Singleton Pattern)
享元模式(Flyweight Pattern)
原型模式(Prototype Pattern)
对象池模式(Object Pool Pattern)
Java I/O
第十章 线程和分布式系统
并发编程
并发(concurrency)
- 定义:指的是多线程场景下对共享资源的争夺运行
- 并发的应用背景:
- 网络上的多台计算机
- 一台计算机上的多个应用
- 一个CPU上的多核处理器
- 为什么要有并发:
- 摩尔定律失效、“核”变得越来越多
- 为了充分利用多核和多处理器需要将程序转化为并行执行
- 并发编程的两种模式:
- 共享内存:在内存中读写共享数据
- 信息传递(Message Passing):通过channel交换消息
共享内存
- 共享内存这种方式比较常见,我们经常会设置一个共享变量,然后多个线程去操作同一个共享变量。从而达到线程通讯的目的。
- 例子:
- 两个处理器,共享内存
- 同一台机器上的两个程序,共享文件系统
- 同一个Java程序内的两个线程,共享Java对象
信息传递
- 消息传递方式采取的是线程之间的直接通信,不同的线程之间通过显式的发送消息来达到交互目的
- 接收方将收到的消息形成队列逐一处理,消息发送者继续发送(异步方式)
- 消息传递机制也无法解决竞争条件问题
- 仍然存在消息传递时间上的交错
- 例子:
- 网络上的两台计算机,通过网络连接通讯
- 浏览器和Web服务器,A请求页面,B发送页面数据给A
- 即时通讯软件的客户端和服务器
- 同一台计算机上的两个程序,通过管道连接进行通讯
并发模型 | 通信机制 | 同步机制 |
---|---|---|
共享内存 | 线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。 | 同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。 |
消息传递 | 线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。 | 由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。 |
进程和线程
- 进程:是执行中一段程序,即一旦程序被载入到内存中并准备执行,它就是一个进程。进程是表示资源分配的的基本概念,又是调度运行的基本单位,是系统中的并发执行的单位。
- 程序运行时在内存中分配自己独立的运行空间
- 进程拥有整台计算机的资源
- 多进程之间不共享内存
- 进程之间通过消息传递进行协作
- 一般来说,进程
==
程序==
应用(但一个应用中可能包含多个进程) - OS支持的IPC机制(pipe/socket)支持进程间通信(IPC不仅是本机的多个进程之间, 也可以是不同机器的多个进程之间)
- JVM通常运行单一进程,但也可以创建新的进程。
- 线程:它是位于进程中,负责当前进程中的某个具备独立运行资格的空间。
- 线程有自己的堆栈和局部变量,但是多个线程共享内存空间
- 进程=虚拟机;线程=虚拟CPU
- 程序共享、资源共享,都隶属于进程
- 很难获得线程私有的内存空间
- 线程需要同步:在改变对象时要保持lock状态
- 清理线程是不安全的
- 进程是负责整个程序的运行,而线程是程序中具体的某个独立功能的运行。
- 一个进程中至少应该有一个线程。
- 主线程可以创建其他的线程。
线程的创建和启动,runable
方式1:继承Thread类
- 方法:用Thread类实现了Runnable接口,但它其中的run方法什么都没做,所以用一个类做Thread的子类,提供它自己实现的run方法。用Thread.start()来开始一个新的线程。
- 创建:
A a = new A()
; - 启动:
a.start()
; - 步骤:
- 定义一个类A继承于
java.lang.Thread
类. - 在A类中覆盖
Thread
类中的run
方法. - 我们在
run
方法中编写需要执行的操作:run
方法里的代码,线程执行体. - 在
main
方法(线程)中,创建线程对象,并启动线程.
- 定义一个类A继承于
- examples
//1):定义一个类A继承于java.lang.Thread类.
class MusicThread extends Thread{
//2):在A类中覆盖Thread类中的run方法.
public void run() {
//3):在run方法中编写需要执行的操作
for(int i = 0; i < 50; i ++){
System.out.println("播放音乐"+i);
}
}
}
public class ExtendsThreadDemo {
public static void main(String[] args) {
for(int j = 0; j < 50; j ++){
System.out.println("运行游戏"+j);
if(j == 10){
//4):在main方法(线程)中,创建线程对象,并启动线程.
MusicThread music = new MusicThread();
music.start();
}
}
}
}
方式2:实现Runable接口
- 创建:
Thread t = new Thread(new A())
; - 调用:
t.start()
; - 步骤
- 定义一个类A实现于
java.lang.Runnable
接口,注意A类不是线程类. - 在A类中覆盖
Runnable
接口中的run
方法. - 我们在
run
方法中编写需要执行的操作:run
方法里的,线程执行体. - 在
main
方法(线程)中,创建线程对象,并启动线程.
- 定义一个类A实现于
//1):定义一个类A实现于java.lang.Runnable接口,注意A类不是线程类.
class MusicImplements implements Runnable{
//2):在A类中覆盖Runnable接口中的run方法.
public void run() {
//3):在run方法中编写需要执行的操作
for(int i = 0; i < 50; i ++){
System.out.println("播放音乐"+i);
}
}
}
public class ImplementsRunnableDemo {
public static void main(String[] args) {
for(int j = 0; j < 50; j ++){
System.out.println("运行游戏"+j);
if(j == 10){
//4):在main方法(线程)中,创建线程对象,并启动线程
MusicImplements mi = new MusicImplements();
Thread t = new Thread(mi);
t.start();
}
}
}
}
-
实现Runnable接口相比继承Thread类有如下好处:
- 避免点继承的局限,一个类可以继承多个接口。
- 适合于资源的共享
-
创建并运行一个线程所犯的常见错误是调用线程的 run()方法而非 start()方法,如下所示:
Thread newThread = new Thread(MyRunnable());
newThread.run(); //should be start();
起初并不会感觉到有什么不妥,因为run()
方法的确如你所愿的被调用了。但是,事实上,run()
方法并非是由刚创建的新线程所执行的,而是被创建新线程的当前线程所执行了。也就是被执行上面两行代码的线程所执行的。想要让创建的新线程执行run()
方法,必须调用新线程的start
方法。
时间分片、交错执行、竞争条件
时间分片
- 虽然有多线程,但只有一个核,每个时刻只能执行一个线程。
- 通过时间分片,再多个线程/进程之间共享处理器
- 即使是多核CPU,进程/线程的数目也往往大于核的数目
- 通过时间分片,在多个进程/线程之间共享处理器。(时间分片是由OS自动调度的)
- 当线程数多于处理器数量时,并发性通过时间片来模拟,处理器切换处理不同的线程
交错执行
顾名思义,就是说在线程运行的过程中,多个线程同时运行相互交错。而且,由于线程运行一般不是连续的,那么就会导致线程间的交错。可以说,所有线程安全问题的本质都是线程交错的问题。
竞争条件
竞争是发生在线程交错的基础上的。当多个线程对同一对象进行读写访问时,就可能会导致竞争的问题。程序中可能出现的一种问题就是,读写数据发生了不同步。例如,我要用一个数据,在该数据修改还没写回内存中时就读取出来了,那么就会导致程序出现问题。
程序运行时有一种情况,就是程序如果要正确运行,必须保证A线程在B线程之前完成(正确性意味着程序运行满足其规约)。当发生这种情况时,就可以说A与B发生竞争关系。
- 计算机运行过程中,并发、无序、大量的进程在使用有限、独占、不可抢占的资源,由于进程无限,资源有限,产生矛盾,这种矛盾称为竞争(Race)。
- 由于两个或者多个进程竞争使用不能被同时访问的资源,使得这些进程有可能因为时间上推进的先后原因而出现问题,这叫做竞争条件(Race Condition)。
- 竞争条件分为两类:
-Mutex(互斥):两个或多个进程彼此之间没有内在的制约关系,但是由于要抢占使用某个临界资源(不能被多个进程同时使用的资源,如打印机,变量)而产生制约关系。
-Synchronization(同步):两个或多个进程彼此之间存在内在的制约关系(前一个进程执行完,其他的进程才能执行),如严格轮转法。 - 解决互斥方法:
Busy Waiting(忙等待):等着但是不停的检查测试,不睡觉,知道能进行为止
Sleep and Wakeup(睡眠与唤醒):引入Semapgore(信号量,包含整数和等待队列,为进程睡觉而设置),唤醒由其他进程引发。 - 临界区(Critical Region):
- 一段访问临界资源的代码。
- 为了避免出现竞争条件,进入临界区要遵循四条原则:
- 任何两个进程不能同时进入访问同一临界资源的临界区
- 进程的个数,CPU个数性能等都是无序的,随机的
- 临界区之外的进程不得阻塞其他进程进入临界区
- 任何进程都不应被长期阻塞在临界区之外
- 解决互斥的方法:
• 禁用中断 Disabling interrupts
• 锁变量 Lock variables (no)
• 严格轮转 Strict alternation (no)
• Peterson’s solution (yes)
• The TSL instruction (yes)
线程的休眠、中断
Thread.sleep
- 在线程中允许一个线程进行暂时的休眠,直接使用Thread.sleep()方法即可。
- 将某个线程休眠,意味着其他线程得到更多的执行机会
- 进入休眠的线程不会失去对现有monitor或锁的所有权
- sleep定义格式:
public static void sleep(long milis,int nanos)
throws InterruptedException
首先,static
,说明可以由Thread
类名称调用,其次**throws
表示如果有异常要在调用此方法处处理异常**。
所以sleep()
方法要有InterruptedException
异常处理,而且sleep()
调用方法通常为Thread.sleep(500);
形式。
- 实例:
Thread.interrupt
- 一个线程可以被另一个线程中断其操作的状态,使用
interrupt()
方法完成。- 通过线程的实例来调用
interrupt()
函数,向线程发出中断信号 t.interrupt()
:在其他线程里向t
发出中断信号t.isInterrupted()
:检查t
是否已在中断状态中
- 通过线程的实例来调用
- 当某个线程被中断后,一般来说应停止其
run()
中的执行,取决于程序员在run()
中处理- 一般来说,线程在收到中断信号时应该中断,直接终止
- 但是,线程收到其他线程发出来的中断信号,并不意味着一定要“停止”
- 实例:
- 实例二:
package Thread1;
class MyThread implements Runnable{ // 实现Runnable接口
public void run(){ // 覆写run()方法
System.out.println("1、进入run()方法") ;
try{
Thread.sleep(10000) ; // 线程休眠10秒
System.out.println("2、已经完成了休眠") ;
}catch(InterruptedException e){
System.out.println("3、休眠被终止") ;
return ; // 返回调用处
}
System.out.println("4、run()方法正常结束") ;
}
};
public class demo1{
public static void main(String args[]){
MyThread mt = new MyThread() ; // 实例化Runnable子类对象
Thread t = new Thread(mt,"线程"); // 实例化Thread对象
t.start() ; // 启动线程
try{
Thread.sleep(2000) ; // 线程休眠2秒
}catch(InterruptedException e){
System.out.println("3、休眠被终止") ;
}
t.interrupt() ; // 中断线程执行
}
};
运行结果:
1、进入run()方法
3、休眠被终止
线程安全的四个策略
- 线程安全的定义:ADT或方法在多线程中要执行正确,即无论如何执行,不许调度者做额外的协作,都能满足正确性
- 四种线程安全的策略:
- Confinement 限制数据共享
- Immutability 共享不可变数据
- Threadsafe data type 共享线程安全的可 变数据
- Synchronization 同步机制共享共享线程 不安全的可变数据,对外即为线程安全的ADT.
Confinement限制数据共享
- 核心思想:线程之间不共享mutable数据类型
- 将可变数据限制在单一线程内部,避免竞争
- 不允许任何县城直接读写该数据
- 在多线程环境中,取消全局变量,尽量避免使用不安全的静态变量。
- 限制数据共享主要是在线程内部使用局部变量,因为局部变量在每个函数的栈内,每个函数都有自己的栈结构,互不影响,这样局部变量之间也互不影响。
- 如果局部变量是一个指向对象的引用,那么就需要检查该对象是否被限制住,如果没有被限制住(即可以被其他线程所访问),那么就没有限制住数据,因此也就不能用这种方法来保证线程安全
- examples
public class Factorial {
/**
* Computes n! and prints it on standard output.
* @param n must be >= 0
*/
private static void computeFact(final int n) {
BigInteger result = new BigInteger("1");
for (int i = 1; i <= n; ++i) {
System.out.println("working on fact " + n);
result = result.multiply(new BigInteger(String.valueOf(i)));
}
System.out.println("fact(" + n + ") = " + result);
}
public static void main(String[] args) {
new Thread(new Runnable() { // create a thread using an
public void run() { // anonymous Runnable
computeFact(99);
}
}).start();
computeFact(100);
}
}
解释:主函数开启了两个线程,调用的是相同函数。因为线程共享局部变量的类型,但每个函数调用有不同的栈,因此有不同的i, n, result
。由于每个函数都有自己的局部变量,那么每个函数就可以独立运行,更新它们自己的函数值,线程之间不影响结果。
Immutability共享不可变数据
不可变数据类型,指那些在整个程序运行过程中,指向内存的引用是一直不变的,通常使用final来修饰。不可变数据类型通常来讲是线程安全的,但也可能发生意外。
但是,程序在运行过程中,有时为了优化程序结构,默默地将这个引用更改了。此时,客户端程序员是不知道它被更改了,对于客户端而言,这个引用还是不可变的,但其实已经被悄悄更改了。这时就会发生一些线程安全问题。
解决方案就是给这些不可变数据类型再增加一些限制:
- 所有的方法和属性都是私有的。
- 不提供可变的方法,即不对外开放可以更改内部属性的方法。
- 没有数据的泄露,即返回值而不是引用。
- 不在其中存储可变数据对象。
这样就可以保证线程的安全了。
Threadsafe data type(共享线程安全的可变数据)
- 方法:如果必须要用mutable的数据类型在多线程之间共享数据,要使用线程安全的数据类型。(在JDK中的类,文档中明确指明了是否threadsafe)
- 一般来说,JDK同时提供两个相同功能的类,一个是threadsafe,另一个不是。原因:threadsafe的类一般性能上受影响。
List、Set、Map
这些集合类都是线程不安全的,Java API为这些集合类提供了进一步的decorator
private static Map<Integer,Boolean> cache = Collections.synchronizedMap(new HashMap<>());
public static <T> Collection<T> synchronizedCollection(Collection<T> c);
public static <T> Set<T> synchronizedSet(Set<T> s);
public static <T> List<T> synchronizedList(List<T> list);
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m);
public static <T> SortedSet<T> synchronizedSortedSet(SortedSet<T> s);
public static <K,V> SortedMap<K,V> synchronizedSortedMap(SortedMap<K,V> m);
- 在使用
synchronizedMap(hashMap)
之后,不要再把参数hashMap
共享给其他线程,不要保留别名,一定要彻底销毁.(可以用private static Map cache = Collections.synchronizedMap(new HashMap<>())
;的方式实例化集合类) - 即使在线程安全的集合类上,使用
iterator
也是不安全的:
List<Type> c = Collections.synchronizedList(new
ArrayList<Type>());
synchronized(c) { // to be introduced later (the 4-th threadsafe way)
for (Type e : c)
foo(e);
}
- 需要注意用java提供的包装类包装集合后,只是将集合的每个操作都看成了原子操作,也就保证了每个操作内部的正确性,但是在两个操作之间不能保证集合类不被修改,因此需要用lock机制,例如
如果在isEmpty
和get
中间,将元素移除,也就产生了竞争。
前三种策略的核心思想:避免共享 --> 即使共享,也只能读/不可写(immutable) → \to →即使可写(mutable),共享的可写数据应自己具备在多线程之间协调的能力,即“使用线程安全的mutable ADT”
Synchronization同步与锁
-
为什么要同步
- java允许多线程并发控制,当多个线程同时操作一个可共享的资源变量时(如数据的增删改查)
- 将会导致数据不准确,相互之间产生冲突,因此加入同步锁以避免在该线程没有完成操作之前,被其他线程的调用,
- 从而保证了该变量的唯一性和准确性。
-
同步方法
-
即有
synchronized
关键字修饰的方法。 -
由于java的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。
-
在调用该方法前,需要获得内置锁,否则就处于阻塞状态。
-
代码如下:
public synchronized void save(){}
-
注:`synchronized`关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整个类
-
-
同步代码块
-
在调用该方法前,需要获得内置锁,否则就处于阻塞状态。
-
被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。
-
代码如:
synchronized(object){...}
-
注:同步是一种高开销的操作,因此应该尽量减少同步的内容。
-
使用锁机制,获得对数据的独家mutation权,其他线程被阻塞,不得访问
-
Lock是Java语言提供的内嵌机制,每个object都有相关联的lock
-
任何共享的mutable变量/对象必须被lock所保护
-
涉及到多个mutable变量的时候,它们必须被同一个lock所保护
死锁
- 定义:两个或多个线程相互等待对方释放锁,则会出现死锁现象。
- java虚拟机没有检测,也没有采用措施来处理死锁情况,所以多线程编程是应该采取措施避免死锁的出现。一旦出现死锁,整个程序即不会发生任何异常,也不会给出任何提示,只是所有线程都处于堵塞状态。
- 形成死锁的条件:
- 互斥条件:线程使用的资源必须至少有一个是不能共享的(至少有锁);
- 请求与保持条件:至少有一个线程必须持有一个资源并且正在等待获取一个当前被其它线程持有的资源(至少两个线程持有不同锁,又在等待对方持有锁);
- 非剥夺条件:分配资源不能从相应的线程中被强制剥夺(不能强行获取被其他线程持有锁);
- 循环等待条件:第一个线程等待其它线程,后者又在等待第一个线程(线程 A A A等线程 B B B;线程 B B B等线程 C C C; ⋯ \cdots ⋯ ;线程 N N N等线程 A A A。如此形成环路)。
- 防止死锁的方法:
- **加锁顺序:**当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。这种方式是一种有效的死锁预防机制。但是,这种方式需要你事先知道所有可能会用到的锁,但总有些时候是无法预知的
-
-
使用粗粒度的锁,用单个锁来监控多个对象
- 对整个社交网 络设置 一个锁 ,并且对其任何组成部分的所有操作都在该锁上进行同步。
- 例如:所有的Wizards都属于一个Castle, 可使用 castle 实例的锁
缺点:性能损失大;
- 如果用一个锁保护大量的可变数据,那么久放弃了同时访问这些数据的能力;
- 在最糟糕的情况下,程序可能基本上是顺序执行的,丧失了并发性
-
-
- 加锁时限:在尝试获取锁的时候加一个超时时间,这也就意味着在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求。若一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁。
- 用 jstack 等工具进行死锁检测
以注释的形式撰写线程安全策略
- 在代码中以注释的形式添加说明:该ADT采取了什么设计决策来保证线程安全
- 阐述如何使rep线程安全;
- 写入表示不变性的说明中,以便代码维护者知道你是如何为类设计线程安全性的。
- 需要对安全性进行这种仔细的论证,阐述使用了哪种技术,使用threadsafe data types, or synchronization时,需要论证所有对数据的访问都是具有原子性的
- examples
- 反例
- 字符串是不可变的并且是线程安全的; 但是指向该字符串的rep,特别是文本变量,并不是不可变的;
- 文本不是最终变量,因为我们需要数据类型来支持插入和删除操作;
- 因此读取和写入文本变量本身不是线程安全的。