一、软件构造基础
1.1 软件构造的多维度视图
软件多维视图
按阶段划分:构造时/运行时视图
按动态性划分:时刻/阶段视图
按构造对象划分:代码/构件视图
1.2 软件构造的阶段划分、各阶段的构造活动
构造阶段 Build-Time View
Code:代码的逻辑组织
functions
classes
methods
interfaces
Component:代码的物理组织
files
directories
packages
libraries
构建时+瞬时+代码
源代码
函数、类、方法、接口
三个层面
词汇:使用的语句、字符串、变量、注释(半结构化)
语法:语法树、流程图(彻底结构化)
语义:源代码实现的目标 & 组成部分联系情况
构建时+周期+代码
记录周期内代码变化Code Churn
版本控制工具
构建时+瞬时+组件
模块化组织为文件、目录
文件被压缩为package、library
与库文件链接
静态链接
发生在构造阶段
复制
不依赖
缺点:难以升级
动态链接
不会加入可执行文件
做标记
运行时根据标记装载库至内存
发布软件时,记得将程序所有依赖的动态库都复制给用户
优点:易于升级
构建时+周期+组件
files/packages/components/libraries 如何变化 不同版本
Software Configuration Item(SCI)配置项
运行阶段 Run-Time View
程序被载入目标机器
Code:逻辑实体在内存中呈现?
Components:物理实体在物理硬件环境中呈现?
Moment:特定时刻形态?
Period:随时间变化?
关注点:
可执行程序、原生机器码、程序完全解释执行
库文件
分布式程序
运行时+瞬时+代码
Code snapshot 代码快照图(第三章)
运行时程序变量层面状态
Memory dump 内存信息转储
查看内存使用情况(实验)
宏观:任务管理器
运行时+周期+代码
UML图
执行跟踪tracing
用日志记录程序执行的调用次序
运行时+瞬时+组件(略)
运行时+周期+组件(略)
1.3 内部/外部的质量指标
外部质量因素
用户感受得到、影响使用
正确性 Correctness
i. 遵守规格说明书
ii. 分层:从底层到顶层,都要正确
iii. 设法测试
鲁棒性 Robustness
健壮性:对异常情况做出适当反映
异常取决于规格说明,是其没有涉及的部分
易扩展性 Extendibility
易于调整、适应变化(软件是易变的)
改变的多少(与规模密切相关、越大越难以扩展)
Decentralization 离散化:模块自治性越强,变化时对其余模块影响越小
复用性 Reusability
利用已有的、复用性好的程序,开发成本少
相似的模式、利用共性
模块化
兼容性 Compatibility
软件元素融合
关键:标准化
效率 Efficiency
对硬件资源尽可能少的需求
与其他存在矛盾
可移植性 Portability
便于将软件产品移植到各种环境
易用性 Ease of use
用户:轻松掌握使用、包括安装、运行、GUI等
功能性
(冲突)过多新功能 --> 损失一致性(兼容性)、影响易用性
先实现主要功能、提高质量,再丰富功能
时效性 Timeliness
Others
a. 可验证性
b. 完整性 Integrity
i. 保护组件(程序和数据)在未经授权时不会被修改
c. 可修复性
d. 经济
i. 与时效性相关
ii. 系统能够按照等于或低于预算完成的能力
内部质量因素
影响使用代码的相关人员、软件本身和开发者
内部质量因素通常用作外部质量因素的部分度量
LOC
lines of code
Cyclomatic Complexity 圈复杂度
衡量一个模块判定结构的复杂程度
Architecture-related factors
Coupling 耦合度 --> 低
Cohesion 内聚度 --> 高
矛盾
可读性
易理解性
清晰 Clearness
复杂度
大小 Size
权衡 Tradeoff
因素之间相互影响、矛盾、相关
经济性 与 功能性/可复用性 矛盾
有效性/可复用性 与 轻便性 矛盾
更高效、对硬件和软件有高要求
时效性 与 可扩展性 矛盾
完整性 与 易用性
首要:正确性!
二、软件构造过程
2.1 软件配置管理SCM与版本控制系统VCS
SCM ≥ VCS
软件配置管理SCM
追踪和控制软件的变化
软件配置项SCI:软件中发生变化的基本单元(文件:Component-Level)
版本控制系统VCS
本地版本控制系统:仓库存储于开发者本地机器,无法共享和合作
集中式版本控制系统:仓库存储于独立的服务器,支持多开发者之间的协作
分布式版本控制系统:仓库存储于:独立的服务器 + 每个开发者的本地机器
2.2 Git
基本指令
添加文件:git add xxx.xxx
提交文件:git commit -m “message”
push到远程仓库:git push origin master
从远程仓库pull:git pull origin master
管理变化
分支Branch和合并Merge
新建分支:git checkout -b branch_name
切换分支:git checkout branch_name or git checkout master
选择一个分支与当前分支合并:git merge branch_name2(之前已有指令git checkout branch_name1)
工作原理和结构
Object Graph
版本之间的演化关系图
一条边A->B表征了“在版本B的基础上作出变化,形成了版本A”
Commit
每个commit指向一个父亲
分支:多个commit指向一个父亲
合并:一个commit指向两个父亲
管理变化:
Git存储发生变化的文件(而非代码行),不变化的文件不重复存储
Commits: nodes in Object Graph
2.3 GitHub
三、ADT & OOP
3.1 数据类型和类型检查
基本数据类型 & 对象数据类型
Primitives Object Reference Types
int, long, byte, short, char, float, double, boolean Classes, interfaces, arrays, enums, annotations
只有值,没有ID (与其他值无法区分) 既有ID,也有值
不可变 可变/不可变
在栈中分配内存 在堆中分配内存
Can’t achieve unity of expression Unity of expression with generics
代价低 代价昂贵
静态类型检查 & 动态类型检查
静态类型检查
关于“类型的检查”,不考虑值
在编译阶段发现错误,避免将错误带入运行阶段
提高程序的正确性、健壮性
静态类型检查错误:
语法错误
类名/函数名错误
参数数目错误
参数类型错误
返回值类型错误
动态类型检查
关于“值”的检查
动态类型检查错误:
非法的参数值
非法的返回值
越界
空指针
Mutable可变 & Immutable不可变
不变对象:一旦被创建,始终指向同一个值
可变对象:拥有方法可以修改自己的值/引用
final
尽量使用final作为方法的输入参数、作为局部变量
final表明了程序员的一种“设计决策”
final类无法派生子类
final变量无法改变值/引用
final方法无法被子类重写
String 不可变
StringBuilder 可变
/* String部分 */
String s = “a”; //开辟一个存储空间,里面存着字符a,s指向这块空间,记为space1
String t = s; //让t指向s所指向的空间即space1
s = s.concat(“b”); //把字符a和字符b连接,然后把“ab”放在一个新的存储空间,记为space2,最后让s指向这块空间
//我们可以看到,现在s和t所指向的是两块不同的空间,空间中的内容也不一样,因此s和t的效果是不一样的
/* StringBuilder部分 */
StringBuilder sb = new StringBuilder(“a”); //开辟一个存储空间,里面存着字符a
StringBuilder tb = sb; //开辟一个存储空间,里面存着字符a
sb.append(“b”); //取出a,然后与字符b连接,然后把“ab”仍然放在这块空间内,把原来的“a”覆盖了,sb的指向没变
//在这个情况下,由于从始至终只用到了一块存储空间,所以sb和tb的效果实际上是相同的
mutable 优点:
拷贝:不可变类型,频繁修改会产生大量的临时拷贝,需要垃圾回收;可变类型,最少化拷贝,以提高效率
获得更好的性能
模块之间共享数据
UnmodifiableCollections:Java设计有不可变的集合类提供使用
值的改变 & 引用的改变
“改变一个变量”:将该变量指向另一个值的存储空间(引用)
“改变一个变量的值”:将该变量当前指向的值的存储空间中写入一个新的值
表示泄露和防御式拷贝
通过防御式拷贝,给客户端返回一个全新的对象(副本),客户端即使对数据做了更改,也不会影响到自己。例如:
return new Date(groundhogAnswer.getTime());
1
大部分时候该拷贝不会被客户端修改,可能造成大量的内存浪费
如果使用不可变类型,则节省了频繁复制的代价
Snapshot diagram
运行时、代码层面、瞬时
基本类型的值
对象类型的值
可变对象:单线圈
不可变对象:双线圈
不可变的引用:双线箭头
可变的引用:单线箭头
引用是不可变的,但指向的值却可以是可变的
可变的引用,也可指向不可变的值
例:用Snapshot表示String和StringBuilder的区别
集合类Snapshot图
List
Set
Map
3.2 设计规约(Specification)
Spec概念
程序和客户端达成的一致
作用:
给“供需双方”都确定了责任并区分责任,调用时双方都要遵守(客户端只需要理解Spec即可)
隔离“变化”、降低耦合度
不需要了解具体实现
要素:
输入数据类型(客户端约束)
输出数据类型(内部实现约束)
前置条件 & 后置条件
前置条件:For 客户端
后置条件:For 开发者
契约:
前置条件满足了,后置条件必须满足;
前置条件不满足,后置条件不一定满足(输入错误,可以抛出异常)。
行为等价性
站在客户端角度、根据规约:功能是否等价
例:以下两段代码是否等价
规约:
解:
当val在范围内时,两者返回相同;
当val不在范围内时,前者返回arr.length,后者返回-1;
根据规约,两者效果相同,因此等价。
Spec的写法
方法注释
@param
@return
@throws
输入类型、返回类型
一个好的Spec应该:
内聚的
Spec描述的功能应单一、简单、易理解
规约做了两件事,所以要分离开形成两个方法。
信息丰富的
不能让客户端产生理解歧义
足够“强”
太弱的spec,客户不放心
开发者应尽可能考虑特殊情况,在post-condition给出处理措施
足够“弱”
太强的spec,在很多特殊情况下难以达到,给开发者增加了实现的难度(client当然非常高兴)
Spec的强度
前置条件越弱,规约强度越强;
后置条件越强,规约强度越强;
规约越强,开发者责任越重,客户端责任越轻;
某个具体实现,若满足规约,则落在其范围内;否则,在其之外。
程序员可以在规约的范围内*选择实现方式;
更强的规约,表达为更小的区域;
3.3 抽象数据类型(ADT)
设计ADT:规格Spec–>表示Rep–>实现Impl
四类ADT操作
Creators
实现:构造函数constructor或静态方法(也称factory method)
Producers
需要有“旧对象”
return新对象
eg. String.concat()
Observers
eg. List的.size()
Mutators
改变对象属性
若返回值为void,则必然改变了对象内部状态(必然是mutator)
表示独立性
client使用ADT时无需考虑其内部如何实现,ADT内部表示的变化不应影响外部spec和客户端。
抽象函数AF & 表示不变量RI
抽象值构成的空间(抽象空间):客户端看到和使用的值
程序内部用来表示抽象值的空间(表示空间):程序内部的值
Mapping:满射、未必单射(未必双射)
ADT开发者关注表示空间R,client关注抽象空间A
抽象函数(AF):
R和A之间映射关系的函数
即如何去解释R中的每一个值为A中的每一个值。
AF : R → A
R中的部分值并非合法的,在A中无映射值
表示不变性(RI):
某个具体的“表示”是否是“合法的”
所有表示值的一个子集,包含了所有合法的表示值
一个条件,描述了什么是“合法”的表示值
检查RI:
随时检查RI是否满足
在所有可能改变rep的方法内都要检查
Observer方法可以不用,但建议也要检查,以防止你的“万一”
测试ADT
因为测试相当于client使用ADT,所以它也不能直接访问ADT内部的数据域,所以只能调用其他方法去测试被测试的方法。
针对creator:构造对象之后,用observer去观察是否正确
针对observer:用其他三类方法构造对象,然后调用被测observer,判断观察结果是否正确
针对producer:produce新对象之后,用observer判断结果是否正确
以注释的形式撰写AF、RI、 Safety from Rep Exposure
在代码中用注释形式记录AF和RI
精确的记录RI:rep中的所有fields何为有效
精确记录AF:如何解释每一个R值
表示泄漏的安全声明
给出理由,证明代码并未对外泄露其内部表示——自证清白
3.4 面向对象编程(OOP)
接口(Interface)& 抽象类(Abstract Class)& 具体类(Concrete Class)
接口:定义ADT
类:实现ADT
Concrete class --> Abstract Class --> Interface
接口:
接口之间可以继承与扩展
一个类可以实现多个接口(从而具备了多个接口中的方法)
一个接口可以有多种实现类
抽象类:
至少有一个抽象方法
抽象方法 Abstract Method
未被实现
如果某些操作是所有子类型都共有,但彼此有差别,可以在父类型中设计抽象方法,在各子类型中重写
具体类:
实现所有父类未实现的方法
继承(Inheritance) & 重写(Override)
类 & 类:继承
类 & 接口:实现、扩展
覆盖/重写Override:
重写的函数:完全同样的signature
实际执行时调用哪个方法,运行时决定
重写的时候,不要改变原方法的本意
运行阶段进行动态检查
父类型中的被重写函数体
不为空:
该方法是可以被直接复用的
对某些子类型来说,有特殊性,可重写父类型中的函数,实现自己的特殊要求
为空:
其所有子类型都需要这个功能
但各有差异,没有共性,在每个子类中均需要重写
super
重写之后,利用super()复用了父类型中函数的功能,还可以对其进行扩展
如果是在构造方法中调用父类的构造方法,则必须在构造方法的第一行调用super()
严格继承:子类只能添加新方法,无法重写超类中的方法(方法带final关键字)
多态(Polymorphism) & 重载(Overload)
重载:多个方法具有同样的名字,但有不同的参数列表或返回值类型。
Override和Overload
特殊多态:功能重载
方便client调用:client可用不同的参数列表,调用同样的函数
根据参数列表进行最佳匹配
public void changeSize(int size, String name, float pattern) {}
重载函数错误情况❌
public void changeSize(int length, String pattern, float size) {}:虽然参数名不同,但类型相同
public boolean changeSize(int size, String name, float pattern) {}:参数列表必须不同
在编译阶段时决定要具体执行哪个方法(与之相反,overridden methods则是在run-time进行dynamic checking)
可以在同一个类内重载,也可在子类中重载
参数化多态:使用泛型?编程
子类型多态:期望不同类型的对象可以统一处理而无需区分,遵循LSP原则
3.5 ADT和OOP中的等价性
不可变对象的引用等价性 & 对象等价性
引用等价性
相同内存地址
对于:基本数据类型
equals()
对象等价性
对于:对象类型
在自定义ADT时,需要用@Override重写Object.equals()(在Object中实现的缺省equals()是在判断引用等价性)
如果用==,是在判断两个对象身份标识 ID是否相等(指向内存里的同一段空间)
equals() & hashCode()
equals()的性质:自反、传递、对称、一致性
equals()重写范例
判断引用等价性
判断类的一致性
判断具体值是否满足等价条件(自定义)
以Lab3的Plane为例
@Override
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof Plane)) {
return false;
}
Plane plane = (Plane) o;
return Objects.equals(number, plane.number) && Objects.equals(strType, plane.strType)
&& intSeats == plane.intSeats && age == plane.age;
}
instanceof:
判断类
仅在equals里使用
hashCode():
等价的对象必须有相同的hashCode
不相等的对象,也可以映射为同样的hashCode,但性能会变差
自定义ADT要重写hashcode
返回值是内存地址
可变对象的观察等价性 & 行为等价性
观察等价性: 在不改变状态的情况下,两个mutable对象是否看起来一致
行为等价性:调用对象的任何方法都展示出一致的结果
对可变类型来说,往往倾向于实现严格的观察等价性, 但在有些时候,观察等价性可能导致bug,甚至可能破坏RI。
四、可复用性
4.1 可复用性的概念
programming for reuse 面向复用编程:开发出可复用的软件
programming with reuse 基于复用编程:利用已有的可复用软件搭建应用系统
优点
降低成本和开发时间
经过充分的测试,可靠、稳定
标准化,在不同应用中保持一致
4.2 面向复用的软件构造技术
Liskov Substitution Principle 里氏替换原则(LSP)
子类型多态:客户端可用统一的方式处理不同类型的对象
在可以使用父类的场景,都可以用子类型代替而不会有任何问题
编译强制规则
子类型可以增加方法,但不可删除方法
子类型需要实现抽象类型中的所有未实现方法
协变:子类型中重写的方法必须有相同或子类型的返回值或者符合co-variance的参数
逆变:子类型中重写的方法必须使用同样类型的参数或者符合contra-variance的参数
子类型中重写的方法不能抛出额外的异常
Also applies to specified behavior (methods):
Same or stronger invariants 更强的不变量
Same or weaker preconditions 更弱的前置条件
Same or stronger postconditions 更强的后置条件
协变 & 反协变
父类型 → 子类型:
协变:返回值和异常不变或越来越具体
逆变(反协变):参数类型要相反地变化,要不变或越来越抽象
泛型
泛型类型是不支持协变的:
如ArrayList是List的子类型,但List不是List的子类型
这是因为发生了类型擦除,运行时就不存在泛型了,所有的泛型都被替换为具体的类型。
但是在实际使用的过程中是存在能够处理不同的类型的泛型的需求的,如定义一个方法参数是List类型的,但是要适应不同的类型的E,于是可使用通配符?来解决这个需求:
无类型条件限制:
public static void printList(List<?> list) {
for (Object elem: list)
System.out.print(elem + " ");
}
当为A类型的父类型
public static void printList(List<? super A> list){…}
当为A类型的子类型
public static void printList(List<? extends A> list){…}
委派(Delegation)
一个对象请求另一个对象的功能
通过运行时动态绑定,实现对其他类中代码的动态复用
“委托”发生在object层面
“继承”发生在class层面
Types of Delegation:
依赖 Dependency:临时性的delegation
把被delegation的对象以参数方式传入。只有在需要的时候才建立与被委派类的联系,而当方法结束的时候这种关系也就随之断开了。
class Duck {
//no field to keep Flyable object
public void fly(Flyable f) { f.fly(); } //让这个鸭子以f的方式飞
public void quack(Quackable q) { q.quack() }; //让鸭子以q的方式叫
}
1
2
3
4
5
关联 Association:永久性的delegation
分为:组合(Composition)和聚合(Aggregation)
被delegation的对象保存在rep中,该对象的类型被永久的与此ADT绑定在了一起。
组合 Composition:更强的Association