By Von Brank | 2022/06/12
第 9 章 可复用性
概念
软件复用是什么?
两类软件复用:
- 面向复用编程:开发出可复用的软件
- 基于复用编程:利用已有的可复用软件搭建应用系统
为什么要复用:
- 降低开发时间和成本
- 让复用的组件得以经过充分测试,保证其可靠、稳定
- 实现组件标准化,令其在不同应用中保持一致
- 开发初期成本:复用 > 不复用;后期成本:复用 < 不复用
如何衡量可复用性?
- 复用的机会有多频繁?复用的场合有多少?
- 复用的代价有多大?
组件复用级别
复用的级别:
- 源代码级别的复用
- 模块级别的复用(类,抽象类,接口)
- 库级别的复用(API/包)
- 系统级别的复用(框架)
源码级别的复用
最主要的复用是代码层面的复用。
代码层面的复用中,以下内容都可能被复用:
- 需求
- 设计/规约spec
- 数据
- 测试用例
- 文档
两类源码级别的复用:
- 白盒复用:
- 源代码可见,可修改和扩展
- 黑盒复用:
- 源代码不可见,不能修改
- 只能通过 API 接口来使用
模块级别的复用(类,接口)
设计可复用类:
- 继承与重写
- 重载
- 参数多态性与泛型编程
- 行为子类化与泛型编程
- 组合与委托
两种复用类/接口的途径:
- 继承
- 委托
库级别的复用(API,包)和系统级别的复用(框架)
框架是一组具体类、抽象类、及其之间的连接关系。
开发者根据框架的规约,填充自己的代码进去,形成完整系统。
设计可复用类
面对对象的类的组织关系:
- 子类型多态(继承)
- Liskov替换原则
- 委托
- 组合
子类型多态
客户端可用统一的方式处理不同类型的对象,如:
Animal a = new Animal();
Animal c1 = new Cat();
Cat c2 = new Cat();
c1
和 c2
都是 Animal
子类 的实例,那么用 c1
或 c2
代替 Animal
不会有任何问题。
Liskov 替换原则(LSP)
内容
具体要求:
- 子类型可以增加方法,但是不可以删除基类的方法
- 子类型需要实现抽象类型中的所有未实现方法
- 当子类覆盖或实现父类的方法时,方法的返回值要比父类更严格。(子类型中重写的方法必须有相同或子类型的返回值或者符合
co-variance
的参数) - 当子类覆盖或实现父类的方法时,方法的形参要比父类方法的更为宽松。(子类型中重写的方法必须使用同样类型的参数或者符合
contra-variance
的参数) - 子类型中重写的方法不能抛出额外的异常
简单记为:
- 更强的不变量
- 更弱的前置条件
- 更强的后置条件
协变
- 父类 子类:越来越具体
- 返回值类型:不变或更具体
- 异常的类型:不变或更具体
如:
class T {
Object a() { ... }
}
变为
class S extends T {
@Override
String a() { ... }
}
又如:
class T {
void b( ) throws Throwable{ ... }
}
变为
class S extends T {
@Override
void b( ) throws IOException{ ... }
}
再变为
class U extends S {
@Override
void b( ) { ... }
}
反协变/逆变
- 父类 子类:越来越具体
- 参数类型:不变或越来越抽象
如:
class T {
void c( String s ) { ... }
}
变为:
class S extends T {
@Override
void c( Object s ) { ... }
}
数组的协变
Java 中的数组是协变的,也就是说,数组 T[]
中任意一个元素可以是 T
的子类。
Number[] numbers = new Number[2];
numbers[0] = new Integer(10);
numbers[1] = new Double(3.14);
Integer[] myInts = {1,2,3,4};
Number[] myNumber = myInts;
myNumber[0] = 3.14; //run-time error!
泛型中的LSP
观察到以下现象:
ArrayList<String>
是List<String>
的子类List<String>
不是List<Object>
的子类
这是因为 Java 实现泛型的方式是 “类型擦除”
例子1: 编译器看见的代码:
public class Pair<T> { private T first; private T last; public Pair(T first, T last) { this.first = first; this.last = last; } public T getFirst() { return first; } public T getLast() { return last; } }
JVM 虚拟机执行的代码:
public class Pair { private Object first; private Object last; public Pair(Object first, Object last) { this.first = first; this.last = last; } public Object getFirst() { return first; } public Object getLast() { return last; } }
例子2: 编译器看见的代码:
Pair<String> p = new Pair<>("Hello", "world"); String first = p.getFirst(); String last = p.getLast();
JVM 虚拟机执行的代码:
Pair p = new Pair("Hello", "world"); String first = (String) p.getFirst(); String last = (String) p.getLast();
要实现泛型的子类,进而满足 LSP 原则,可以使用通配符:
List<Number>
是List<?>
的子类List<Number>
是List<? extends Object>
的子类List<Object>
是List<? super String>
的子类
List<? extends Integer> intList = new ArrayList<>();
List<? extends Number> numList = intList;
委托
委托指的是一个对象请求另一个对象的功能,是复用的一种常见形式。
interface Flyable {
public void fly();
}
interface Quackable {
public void quack();
}
class FlyWithWings implements Flyable {
@Override
public void fly() {
System.out.println("fly with wings");
}
}
class Quack implements Quackable {
@Override
public void quack() {
System.out.println("quack like duck");
}
}
interface Ducklike extends Flyable, Quackable {}
public class Duck implements Ducklike {
Flyable flyBehavior = new FlyWithWings(); // 组合
Quackable quackBehavior = new Quack(); // 组合
@Override
public void fly() {
this.flyBehavior.fly(); // 委托
}
@Override
public void quack() {
this.quackBehavior.quack(); // 委托
}
}
用委托替代继承
Before:
class RealPrinter { void print() { System.out.println("Printing Data"); } } class Printer extends RealPrinter { void print(){ super.print(); } } Printer printer = new Printer(); printer.print();
After:
class RealPrinter { void print() { System.out.println("The Delegate");} } class Printer { RealPrinter p = new RealPrinter(); void print() { p.print(); } } Printer printer = new Printer(); printer.print();
复合复用原则(CRP)
CRP 鼓励使用委派而不是继承来实现复用。
设计两棵继承树,通过 delegatoin 实现 “对象” 和 “行为” 的动态绑定,实现灵活可变复用。
组合与委托
委派(delegation)的种类
Dependency
临时性 delegation,即 Use (A use B)。
class Duck {
void fly(Flyable flyable) {
flyable.fly();
}
}
Flyable flyable = new FlyWithWings();
Duck duck = new Duck();
d.fly(flyable);
Association
永久性 delegation (A has B)
class Duck {
Flyable flyable = new CannotFly();
Duck(Flyable flyable) {
this.flyable = flyable;
}
void fly() {
flyable.fly();
}
void fly(Flyable flyable) {
this.flyable = flyable;
}
}
Flyable flyable = new FlyWithWings();
Duck duck = new Duck(flyable);
d.fly();
Composition
通过类内部初始化建立的 delegation 。
class Duck {
Flyable flyable = new FlyWithWings();
void fly() {
flyable.fly();
}
}
Duck duck = new Duck();
d.fly();
Aggregation
通过客户端调用方法或构造函数,从外部传入 delegation 类,保存到某个 field 建立起来的 delegation 关系。
class Duck {
Flyable flyable;
Duck(Flyable flyable) {
this.flyable = flyable;
}
void fly(Flyable flyable) {
this.flyable = flyable;
}
}
Flyable flyable = new FlyWithWings();
Duck duck = new Duck(flyable);
d.fly();
区别 Dependency 和 Association 主要是看类是否有一个字段(field)来保存委托变量:
- Association:通过固有的 filed 来建立 delegation
- Dependency:没有固有的 filed ,只是用方法参数等建立 delegation
Composition 和 Aggregation 都是将 delegation 实例保存在类的某个 field 建立起来的 delegation 关系,是两种形式的 Association
- Composition 比 Association 更强, delegation 关系无法修改(通过初始化建立)
- Aggregation 比 Association 更弱, delegation 关系可以修改(通过方法或构造函数、由传入的参数建立)
设计系统级可复用 API 库和框架
设计可复用库与框架
- API 与 库
- 框架
- 例如:Java 的集合框架
概念
对 API 开发者的建议:
- 始终以开发 API 的标准面对任何开发任务
- 面向“复用”编程而不是面向“应用”编程
难点在于,要有足够良好的设计,一旦发布就无法再自由改变。
白盒框架与黑盒框架
黑盒框架与白盒框架:
- 白盒框架——通过代码层面的继承进行框架扩展:继承、子类、重载与覆写
- 黑盒框架——通过实现特定接口/委托进行框架扩展:接口、委托
白盒框架
白盒框架主要利用继承实现。
框架:
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();
}
}
第 10 章 可维护性
一些概念
软件维护的种类
- 纠错性维护
- 适应性维护
- 完善性维护
- 预防性维护
几类提高软件可维护性的方法
- 模块化
- OO(面向对象)设计原则
- OO(面向对象)设计模式
- 基于状态的构造技术
- 表驱动的构造技术
- 基于语法的构造技术
可维护性多量指标
- 可维护性
- 可扩展性
- 灵活性
- 可适应性
模块化设计与模块化原则
一些概念
模块化编程特点:
- 高内聚
- 低耦合
模块化编程降低复杂度的方法:
- 关注点分离
- 信息隐藏
评估模块化程度的 5 个角度
- 可分解性
- 可组合性
- 可理解性
- 可持续性——发生变化时受影响范围最小
- 出现异常之后的保护———出现异常后受影响范围最小
模块化设计的 5 条规则
- 直接映射
- 尽可能少的接口
- 尽可能小的接口
- 显式接口
- 信息隐藏
耦合与内聚
模块之间的联系称为耦合。模块间联系越紧密,耦合性越强。
模块内部元素的联系称为内聚。模块内元素结合得越紧密,内聚性越高。
我们追求低耦合、高内聚。
面向对象设计原则:SOLID
- 单一责任原则(SRP)
- 开放封闭原则(OCP)
- Liskov 替换原则(LSP)
- 接口隔离原则(ISP)
- 依赖倒置原则(DIP)
单一责任原则
一个类,应该只有一个引起它变化的原因。也就是说,一个类应该只负责一个职责,如果这个类需要修改的话,也只是因为这一个职责的变化了才引发类的修改。
假设我们要设计一辆汽车,它目前有如下功能:
public class Car {
// 启动引擎
public void start() {
System.out.println("车辆启动了!");
}
// 熄火
public void stop() {
System.out.println("车辆熄火了!");
}
// 加速
public void speed() {
System.out.println("车辆加速中!");
}
// 接送乘客
public void pickUpPassenger(){
System.out.println("接送乘客中!");
}
// 加油
public void gasUp(){
System.out.println("去加油站加汽油!");
}
}
如果要修改汽车的参数,就需要修改这个类;如果修改接送乘客的规则,也需要修改这个类。这个类包含了太多职责,不符合单一责任原则,会变得低内聚、高耦合。
可以改为:
public class Car {
// 启动引擎
public void start() {
System.out.println("车辆启动了!");
}
// 熄火
public void stop() {
System.out.println("车辆熄火了!");
}
// 加速
public void speed() {
System.out.println("车辆加速中!");
}
}
class TaxiDriver {
public Car car;
// 接送乘客
public void pickUpPassenger(){
System.out.println("接送乘客中!");
}
// 加油
public void gasUp(){
System.out.println("去加油站加汽油!");
}
}
把汽车的功能拆分出来,委托给 TaxiDriver
,实现汽车本身的操作和送客、加油功能的解耦。
开放封闭原则 (OCP)
一个实体(类、函数、模块等)应该对外扩展开放,对内修改封闭。某实体应该易于扩展,在扩展某类的功能时应该通过添加新的代码来实现而不是修改其内部的代码。
之前的例子中,如果司机需要把车改成电动车,那么就不需要加油而是充电。如果直接修改 TaxiDriver
很容易引发bug,而且原来的代码已经测试好了、而且被其他地方引用,返工成本比较高。解决方法是将 Car
变成接口或抽象类,扩展出燃油车、电动车的子类,两种车在 gasUp
时有不同行为:
abstract class Car {
// 启动引擎
public void start() {
System.out.println("车辆启动了!");
}
// 熄火
public void stop() {
System.out.println("车辆熄火了!");
}
// 加速
public void speed() {
System.out.println("车辆加速中!");
}
//补充燃料
public abstract void fillUp();
class FuelCar extends Car {
@Override
public void fillUp() {
System.out.println("加油!");
}
}
class ElectricCar extends Car {
@Override
public void fillUp() {
System.out.println("充电!");
}
}
class TaxiDriver {
public Car car;
// 接送乘客
public void pickUpPassenger(){
System.out.println("接送乘客中!");
}
// 加油/充电
public void gasUp(){
car.fillUp();
}
}
实现在新增需求时只增加代码,而不修改原有的代码。使用时引入新加的类即可。
Liskov 替换原则(LSP)
任何基类可以出现的地方,子类一定可以出现。可以用来检验继承是否合理。
接口隔离原则 (ISP)
一个类依赖另一个类时,应该依赖最小的那一个接口。因为依赖过多的接口将导致子类需要实现太多方法,子类被客户端使用时也会包含大量无用方法。
假设我们要设计一种载具,先设计它的接口,然后让特定的载具实现它:
interface Vehicle {
// 飞行
void fly();
// 航行
void sail();
// 陆行
void run();
}
class AirPlane implements Vehicle {
@Override
public void fly() {
System.out.println("飞行");
}
@Override
public void sail() {
}
@Override
public void run() {
}
}
上述代码中,一种 Vehicle
可能有多种功能,看似合理,但是 AirPlane
继承 Vehicle
时,sail
和 run
方法都是无用的,毕竟飞机只需要飞且不能航行,然而它却不得不实现 sail
和 run
。
可以修改为:
interface Fly { // 飞行接口
// 飞行
void fly();
}
interface Sail { // 航行接口
// 航行
void sail();
}
interface Run { // 陆行接口
// 陆行
void run();
}
class AirCraft implements Fly { // 飞行器类
@Override
public void fly() {
System.out.println("飞行");
}
}
public class Vehicle
implements Fly, Sail, Run {
// 飞行
@Override
void fly();
// 航行
@Override
void sail();
// 陆行
@Override
void run();
}
修改后 AirCraft
只需要实现 Fly
接口中的方法即可。如果确实有开发一种能上天入地载具的需求,只需要让 Vehicle
实现 Fly
, Sail
, Run
即可。
依赖倒置原则(DIP)
一个类依赖另一个一个类时,应该尽量依赖其接口而不是具体实现。
还是司机开车的例子:
class TaxiDriver {
public FuelCar car;
TaxiDriver(FuelCar car) {
this.car = car;
}
// 接送乘客
public void pickUpPassenger(){
System.out.println("接送乘客中!");
}
// 加油/充电
public void gasUp(){
car.fillUp();
}
}
这个例子中,司机只能开燃油车,如果未来司机需要开电动车,就需要修改类内的代码。可以改为:
class TaxiDriver {
public Car car;
TaxiDriver(Car car) {
this.car = car;
}
// 接送乘客
public void pickUpPassenger(){
System.out.println("接送乘客中!");
}
// 加油/充电
public void gasUp(){
car.fillUp();
}
}
现在司机可以开任何车了,如果需要修改开车的种类,只需要修改客户端代码即可。
class Main {
public static void main(String[] args) {
Car basicCar = new BasicCar();
Car teslaModel3 = new TeslaModel3();
Car bugattiVeyron = new BugattiVeyron();
// 什么车都能开
TaxiDriver taxiDriver = new TaxiDriver(teslaModel3);
}
}
正则表达式
参考资料:learn-regex项目
正则表达式由一些字母和数字组成,它表示一类字符串的格式(专业的说法:它定义了一类语言)。例如 the
是一个正则表达式,它可以匹配 The fat cat sat on the mat.
中的后一个 the
。因为正则表达式是敏感的,所以 The
只能匹配句子开头的 The
而不能匹配 the
。
元字符和字符集简写
元字符 | 描述 |
---|---|
. | 句号匹配任意单个字符除了换行符。 |
[ ] | 字符种类。匹配方括号内的任意字符。 |
[^ ] | 否定的字符种类。匹配除了方括号里的任意字符。 |
* | 匹配>=0个重复的在*号之前的字符。 |
+ | 匹配>=1个重复的+号前的字符。 |
? | 标记?之前的字符为可选. |
{n,m} | 匹配num个大括号之前的字符或字符集 (n <= num <= m). |
(xyz) | 字符集,匹配与 xyz 完全相等的字符串. |
| | 或运算符,匹配符号前或后的字符. |
\ | 转义字符,用于匹配一些保留的字符 [ ] ( ) { } . * + ? ^ $ \ | |
^ | 从开始行开始匹配. |
$ | 从末端开始匹配. |
简写 | 描述 |
---|---|
. | 除换行符外的所有字符 |
\w | 匹配所有字母数字,等同于 [a-zA-Z0-9_] |
\W | 匹配所有非字母数字,即符号,等同于: [^\w] |
\d | 匹配数字: [0-9] |
\D | 匹配非数字: [^\d] |
\s | 匹配所有空格字符,等同于: [\t\n\f\r\p{Z}] |
\S | 匹配所有非空格字符: [^\s] |
\f | 匹配一个换页符 |
\n | 匹配一个换行符 |
\r | 匹配一个回车符 |
\t | 匹配一个制表符 |
\v | 匹配一个垂直制表符 |
\p | 匹配 CR/LF(等同于 \r\n ),用来匹配 DOS 行终止符 |
基础匹配与示例
点字符
.
句号匹配任意单个字符除了换行符".ar" => The car parked in the garage.
字符集
[ ]
字符种类。匹配方括号内的任意字符。"[Tt]he" => The car parked in the garage.
"ar[.]" => A garage is a good place to park a car.
重复次数
{}
号 {n,m}匹配num个大括号之前的字符或字符集 (n <= num <= m)."[0-9]{2,3}" => The number was 9.9997 but we rounded it off to 10.0.
"[0-9]{2,}" => The number was 9.9997 but we rounded it off to 10.0.
"[0-9]{3}" => The number was 9.9997 but we rounded it off to 10.0.
(...)
特征标群(Capturing Groups) 将括号内的字符串视作一个整体,如 (xyz) 匹配与 xyz 完全相等的字符串"(c|g|p)ar" => The car is parked in the garage.
|
或运算符 匹配符号前或后的字符"(T|t)he|car" => The car is parked in the garage.
转码特殊字符 转义字符,用于匹配一些保留的字符
[ ] ( ) { } . * + ? ^ $ \ |
"(f|c|m)at\.?" => The fat cat sat on the mat.
锚点
在 Java 中使用 regex
在 Java 中使用正则表达式时,所有 \
要写作 \\
,因为 Java 字符串自身有转义行为。
public class Main {
public static void main(String[] args) {
String regex = "20\\d\\d";
System.out.println("20\\d\\d"); // 20\d\d
System.out.println("2019".matches(regex)); // true
System.out.println("2100".matches(regex)); // false
}
}
第 11 章 设计模式
- 创建型模式:
- 工厂模式
- 结构型模式
- 适配器模式
- 装饰器模式
- 行为型模式
- 策略模式
- 模板模式
- 迭代器模式
- 访问者模式
创建型模式
工厂模式
很多时候,客户端需要创建一个实例,但是不知道需要哪一个实例,只能给出一些参数,这时候就需要使用工厂模式。
简单工厂模式通过定义一个工厂类,根据传入参数的不同返回不同实例,这些实例具有共同的父类接口。
假设你写的客户端需要一台手机,但是只给出手机的品牌名,但是创建手机实例需要价格、芯片型号等更详细的参数,这些参数需要工厂方法根据品牌名传入。
interface Phone {
int price;
String soc;
void run();
}
public class MiPhone implements Phone {
public MiPhone(int price, String soc) {
this.price = price;
this.soc = soc;
}
@Override
public void run() {
System.out.println("MiPhone is cheap!");
}
}
public class IPhone implements Phone {
public IPhone(int price) {
this.price = price;
this.soc = "A15";
}
@Override
public void run() {
System.out.println("iPhone has high performance!");
}
}
public class PhoneFactory {
public Phone makePhone(String phoneType) {
if(phoneType.equals("Apple"))
return new IPhone(8999);
else if(phoneType.equals("Mi"))
return new MiPhone(1999, "SnapDragon 888");
}
}
public class Main {
public static void main(String[] args) {
PhoneFactory factory = new PhoneFactory();
Phone miPhone = factory.makePhone("Mi");
Phone iPhone = factory.makePhone("Apple");
}
}
如此一来,客户端想要什么手机,只需要传入手机的名称,而不需要关心手机的价格、芯片型号等信息。
结构型模式
适配器模式
假设你正在开发一款绘图程序,开发时你决定使用一个第三方库中的一个类 LegacyRectangle
来帮助你完成矩形的绘制。但是问题在于,你写的客户端只能提供矩形对角两点的坐标,而库则需要接受其中一点的坐标以及矩形的长和宽。
class LegacyRectangle {
void display(int x1, int y1, int w, int h) {...}
}
class Client {
Shape shape;
public display() {
shape.display(x1, y1, x2, y2);
}
}
你可以修改库来支持你的客户端,或修改自己的客户端来适配库,但是在实际生产环境中,修改库几乎是不可能的,因为你很可能没有库的源码,即使有,你也无法保证你的修改没有副作用,从而导致库出错。(开闭原则)
解决方案是创建一个适配器对象,传入矩形的对角线坐标,由它帮助你调用库完成矩形的绘制。考虑到未来可能有其他适配器,我们将 Rectangle
作为 Shape
接口的一个实现:
class LegacyRectangle {
void display(int x1, int y1, int w, int h) {...}
}
interface Shape {
void display(int x1, int y1, int x2, int y2);
}
class Rectangle implements Shape {
@Override
void display(int x1, int y1, int x2, int y2) {
new LegacyRectangle().display(x1, y1, x2 - x1, y2 - y1);
}
}
class Client {
Shape shape = new Rectangle();
public display() {
shape.display(x1, y1, x2, y2);
}
}
装饰器模式
假设我们要渲染一段 HTML 文本,HTML 可以标记文本的附加效果,比如一段加粗、加删除线的文本的 HTML 实现可以是:
<del><b>Hello World</b></del>
效果: Hello World
当然,现代 Web 前端技术主张使用 CSS 实现这些样式。
实现类似附加效果最 naive 的方法可以是直接写一个类来实现:
interface TextNode {
String getText();
}
class BlackNode implements TextNode {
private String text;
public void BlackNode(String text) {
this.text = text;
}
public String getText() {
return "<b>" + text + "</b>";
}
}
public class Main {
public static void main(String[] args) {
TextNode textNode = new BlackNode("Hello World");
System.out.println(textNode.getText());
}
}
如果我们想给黑体文本加上下划线,可以新建一个类:
class DeleteBlackNode implements TextNode {
private String text;
public void DeleteBlackNode(String text) {
this.text = text;
}
public String getText() {
return "<del><b>" + text + "</b></del>";
}
}
但是这样将导致一个严重的问题,一段文本可以添加的效果有粗体、斜体、下划线、删除线、字号、颜色等,这些效果可以任意组合,如果给任意一种组合编写一个类,子类数量会爆炸性增长。
装饰器模式的目的是,一个子类只负责一个效果,通过委托的方式将这种效果累加到已经添加好其他效果的文本上。
为了更好地理解装饰器模式,以上述例子为例,我们希望写出的类是这样的(其中 target
是一个已经添加好部分效果的 TextNode
):
class DelTextNode implements TextNode {
DelTextNode(TextNode textNode) {
this.target = textNode;
}
public String getText() {
return "<del>" + target.getText() + "</del>";
}
}
public class Main {
public static void main(String[] args) {
TextNode textNode = ...
...
// textNode 是已经添加好一些效果的 TextNode
// 假设是 <b>Hello World</b>
// 现在我们想要往上面添加删除线效果
TextNode delTextNode = new DelTextNode(textNode);
System.out.println(delTextNode.getText());
// 将输出 <del><b>Hello World</b></del>
}
}
我们发现每一个效果类对外提供的接口都是一样的,不妨将其设成一个装饰器类:
public abstract class NodeDecorator implements TextNode {
protected final TextNode target;
protected NodeDecorator(TextNode target) {
this.target = target;
}
}
去除所有效果后剩下的是一段文本,这需要一个类实现,我们称为核心类:
public class TextNodeCore implements TextNode {
private String text;
public void TextNodeCore(String text) {
this.text = text;
}
public String getText() {
return text;
}
}
然后将之前提到的所有效果类修改为 NodeDecorator
装饰器的子类:
class DelTextNode extends NodeDecorator {
DelTextNode(TextNode target) {
super(target)
}
public String getText() {
return "<del>" + target.getText() + "</del>";
}
}
class BlackTextNode extends NodeDecorator {
DelTextNode(TextNode target) {
super(target)
}
public String getText() {
return "<b>" + target.getText() + "</b>";
}
}
要渲染一段加粗、加删除线效果的文本可以这样写:
public class Main {
public static void main(String[] args) {
TextNode textNode = new TextNodeCore("Hello World");
TextNode delBlackTextNode =
new DelTextNode(new BlackTextNode(textNode));
System.out.println(delBlackTextNode.getText());
// <del><b>Hello World</b></del>
}
}
行为型模式
策略模式
定义一组算法,每个算法都封装在一个类中,这些类实现(继承)自同一个接口(类)。客户端可以灵活选择其中一个算法使用。
以购物车结算为例。购物车中有一些列物品,顾客可以选择用信用卡支付或 Paypal 支付。每次结算时,购物车将计算出总花费,然后使用相应方式支付。
但是信用卡或 Paypal 的支付逻辑是不同的,将逻辑写死在购物车对象中将提高耦合度,可以将支付方法抽象成接口,使用不同的策略来实现。
public interface PaymentStrategy {
public void pay(int payment);
}
public class CreditCardStrategy implements PaymentStrategy {
...
public CreditCardStrategy(
String nm, String ccNum, String cvv, String expiryDate){...}
@Override
public void pay(int amount) {
System.out.println(amount + " paid with credit card");
}
}
public class PaypalStrategy implements PaymentStrategy {
private String emailId;
private String password;
public PaypalStrategy(String email, String pwd){ ... }
@Override
public void pay(int amount) {
System.out.println(amount + " paid using Paypal.");
}
}
public class ShoppingCart {
...
public void pay(PaymentStrategy paymentMethod){
int amount = calculateTotal();
paymentMethod.pay(amount);
}
}
public class ShoppingCartTest {
public static void main(String[] args) {
ShoppingCart cart = new ShoppingCart();
Item item1 = new Item("1234",10);
Item item2 = new Item("5678",40);
cart.addItem(item1);
cart.addItem(item2);
//pay by paypal
cart.pay(new PaypalStrategy("myemail@exp.com", "mypwd"));
//pay by credit card
cart.pay(new CreditCardStrategy("Alice", "1234", "786", "12/18"));
}
}
模板模式
假设你要为一款游戏中的一种战斗单位设计 AI 算法,假设所有种族的这种单位的进攻流程都是一样的:1.如果没有敌人则待命;2.锁定敌人;3.靠近敌人;4.攻击敌人。
但是不同种族的这种单位锁敌、攻击方式不同。模板模式的做法就是复用相同的进攻流程,然后将不同种族战斗单位的行为差异下方到子类实现。
abstract class GameAI {
final public void action() {
if(noEnemy()) standby();
spotEnemy();
approachEnemy();
attackEnemy();
}
abstract void spotEnemy(); // 锁定敌人
abstract void approachEnemy(); // 靠近敌人
abstract void attackEnemy(); // 攻击敌人
abstract void standby(); //待命
}
class OrcsAI extends GameAI {
void spotEnemy() { ... }
void approachEnemy() { ... }
void attackEnemy() { ... }
void standby() { ... }
}
class MonstersAI extends GameAI {
void spotEnemy() { ... }
void approachEnemy() { ... }
void attackEnemy() { ... }
void standby() { ... }
}
如此一来,客户端可以轻易创建这几种 AI 的实例:
public class Main {
public static void main(String[] args) {
OrcsAI orcsAI = new OrcsAI();
MonstersAI monstersAI = new MonstersAI();
}
}
迭代器模式
提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。
事实上,我们都曾用过迭代器,下面两种遍历方式是等价的:
List<String> list1 = new ArrayList<>();
for (String s : list1) {
...
}
List<String> list2 = new ArrayList<>();
for (Iterator<String> it = list2.iterator(); it.hasNext(); ) {
String s = it.next();
}
迭代器中常用的三个方法:
hasNext
:是否存在下一个元素next
:返回当前元素,指向下一个元素remove
:移除上一次调用next
时返回的那个元素
迭代器模式的最佳实践是使用 Java 的 Iterable 和 Iterator 接口实现一个自定义迭代顺序的集合,此处我们实现一个倒序遍历数组的集合:
public class ReverseArrayCollection<T> implements Iterable<T> {
T[] array;
@SafeVarargs
public ReverseArrayCollection(T... array) {
this.array = Arrays.copyOfRange(array, 0, array.length);
}
@Override
public Iterator<T> iterator() {
return new ReverseIterator(this);
}
@Override
public void forEach(Consumer<? super T> action) {
Iterable.super.forEach(action);
}
class ReverseIterator implements Iterator<T> {
int index;
ReverseArrayCollection<T> reverseArrayCollection;
ReverseIterator(ReverseArrayCollection<T> reverseArrayCollection) {
this.reverseArrayCollection = reverseArrayCollection;
index = reverseArrayCollection.array.length;
}
@Override
public boolean hasNext() {
return index > 0;
}
@Override
public T next() {
index--;
return reverseArrayCollection.array[index];
}
@Override
public void remove() {
throw new UnsupportedOperationException();
}
}
}
public class Main {
public static void main(String[] args) {
ReverseArrayCollection<Integer> reverseArrayCollection =
new ReverseArrayCollection<>(1, 2, 3);
for (Integer integer : reverseArrayCollection) {
System.out.println(integer);
}
// 输出 3 2 1
}
}
访问者模式
访问者模式是最复杂的设计模式之一,概念十分抽象,我们直接从例子切入:
一家公司有工程师(Engineer
)和经理(Manager
)两种职务,他们实现了 Staff
接口。
工程师的工作指标有代码量和 KPI;经理的工作指标有产品数量和 KPI,假设它们的类如下所示:
public abstract class Staff { // 员工
private String name;
private int kpi;// 员工KPI
...
}
public class Engineer extends Staff { // 工程师
private int codeAmount;
...
}
public class Manager extends Staff { // 经理
private int productionAmount;
...
}
年底绩效考核时,公司的 CEO 只关注工程师的代码量和经理的产品数量,CTO 只关注工程师和经理的KPI。
所有工程师和经理都放在一个 List<Staff>
中,CEO 和 CTO 对这些数据的关注点是不一样的。
一种 naive 的做法是:
public class Main {
public static void main(String[] args) {
List<Staff> staffList = new ArrayList<>();
...
for(Staff staff : staffList) {
cto.visit(staff);
ceo.visit(staff);
}
}
}
interface ChiefOfficer {
void visit(Staff staff);
}
class CEO implements ChiefOfficer {
@Override
private void visit(Staff staff) {
if (staff instanceof Manager) {
Manager manager = (Manager) staff;
// CEO 看经理数据
} else if (staff instanceof Engineer) {
Engineer engineer = (Engineer) staff;
// CEO 看工程师数据
}
}
}
class CTO implements ChiefOfficer {
@Override
private void visit(Staff staff) {
if (staff instanceof Manager) {
// CTO 看经理数据
} else if (staff instanceof Engineer) {
// CTO 看工程师数据
}
}
}
这种方法的痛点在于 if-else 逻辑的嵌套以及类型的强制转换,难以扩展和维护。如果用户类型很多,Chief Officer 也很多,每个人的 visit
方法内的逻辑就会非常复杂。
访问者(Visitor)模式能解决这个问题。其中 visit 指的是对数据的,本例中的 Staff
就是数据。
本例中的 Chief Officer 就是所谓的访问者,我们只需要对其稍加修改:
public interface Visitor {
void visit(Engineer engineer); // 访问工程师类型
void visit(Manager manager); // 访问经理类型
}
// CTO访问者
public class CTOVisitor implements Visitor {
@Override
public void visit(Engineer engineer) {
// CTO 看工程师数据
}
@Override
public void visit(Manager manager) {
// CTO 看经理数据
}
}
// CEO访问者
public class CEOVisitor implements Visitor {
@Override
public void visit(Engineer engineer) {
// CEO 看工程师数据
}
@Override
public void visit(Manager manager) {
// CEO 看经理数据
}
}
但是这样还不够,我们不希望在客户端直接调用 visit
方法,而是转移到被访问的对象的 accept
方法里使用:
public abstract class Staff {
private String name;
private int kpi;// 员工KPI
public abstract void accept(Visitor visitor);
...
}
public class Engineer extends Staff {
private int codeAmount;
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
...
}
public class Manager extends Staff {
private int productionAmount;
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
...
}
当被访问对象存在特殊的数据结构时,客户端并不关心这些结构,只有被访问者才知道以何种方式调用 visit
方法。也就是说,实际环境中 accept
方法中可能不止 visitor.visit(this);
这样简单的逻辑,可能有其他操作,如果不将调用 visit
方法的责任交给被访问对象,这些逻辑就会写在客户端中,要知道我们可能有很多种被访问对象,届时客户端会变得非常复杂。
第 12 章 正确性与健壮性
概念
健壮性
系统在不正常输入或不正常外部环境下仍能够表现正常的程度
要想编写健壮性强的程序,应该注意:
- 假设用户想要恶意破坏代码,假设自己的代码可能运行不正确
- 假设用户有非法输入
- 健壮性准则:对自身代码保守,对用户行为开放
- 隐藏实现细节
- 考虑边界条件
正确性
程序按照 spec 的规定执行。这是最重要的质量指标。
正确性与健壮性的对比:对外接口倾向于健壮;对内实现倾向于正确。
Java 的异常处理
异常的分类
Java 中所有异常类都最终继承自 Throwable
类。
Throwable
两个子类 Error 和 Exception:
Error
一般是指与虚拟机相关的问题,如系统崩溃,虚拟机错误,内存空间不足,方法调用栈溢等。对于这类错误的导致的应用程序中断,仅靠程序本身无法恢复和预防,遇到这样的错误,建议让程序终止。Exception
表示程序可以处理的异常,可以捕获且可能恢复。遇到这类异常,应该尽可能处理异常,使程序恢复运行,而不应该随意终止异常。
Exception
是程序中非正常事件,使程序不能继续往下执行,可以被 catch
关键字捕获,然后执行异常处理程序。Exception
也可以分为两类:
RuntimeException
:运行时异常,完全由开发者造成;如果在代码中提前进行验证,这些故障就可以避免。- 其他:非运行时异常,由外在问题所导致,开发者无法控制。
异常的简单使用:
void exceptionTest(int n) throws EOFException, ClassNotFoundException {
try {
switch (n) {
case 0 -> throw new FileNotFoundException();
case 1 -> throw new ClassNotFoundException();
default -> throw new EOFException();
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
throw new ClassNotFoundException();
} finally {
System.out.println("This statement will always be executed");
}
}
checked 和 unchecked 异常
Java 标准规定,异常分为 checked 和 unchecked 类型:
- unchecked :继承自
Error
和RumtimeException
的异常 - checked :其他异常
Check exception | Unchecked exception | |
---|---|---|
Basic | 必须被显式捕获或传递 (try-catch-finally-throw), 否则编译无法通过 | 异常不必捕获或抛出 |
Class of Exception | 其他 | 继承自 Error 和 RumtimeException |
Handling | 获得异常发生现场的详细信息,了解发生异常的原因,并异常中恢复 | 仅打印异常信息 |
Appearance | 较复杂,异常处理程序和正常代码混在一起 | 简单 |
checked 异常抛出后必须要被 catch
或在方法声明之后使用 throws
将异常抛给客户端处理,否则编译不通过。
// 编译不通过
public static void main (String args[]) {
throw new EOFException();
}
// 编译通过
public static void main (String args[]) throws EOFException {
throw new EOFException();
}
// 编译通过
public static void main (String args[]) {
try {
throw new EOFException();
} catch (EOFException e) {
e.printStackTrace();
}
}
unchecked 抛出后不需要任何处理,此时将直接打印异常信息:
public static void main (String args[]) {
throw new NullPointerException();
}
一般来说,unchecked 异常没必要处理,一方面,出现 Error
异常时,已无法挽救;另一方面,RumtimeException
完全是开发者写的程序逻辑有 bug,可以通过修 bug 避免。
Checked 异常的处理
声明异常
- 在 spec 中声明:必须在方法的 spec 中用
@throws
写明该方法将抛出的所有 checked 异常,便于客户端处理; - 在方法声明之后用关键字
throws
声明可能抛出的所有异常
一种异常:
/**
* Compute the integer square root.
* @param x value to take square toot of
* @return square root of x
* @throws NotPerfectSquareException if x is not a perfect square
*/
int integerSquareRoot(int x) throws NotPerfectSquareException;
多种异常:
/**
* ...
* @throws FileNotFoundException ...
* @throws EOFException ...
*/
public Image loadImage(String s)
throws FileNotFoundException, EOFException
异常的协变:从父类到子类,异常不变或变得越来越具体,甚至不抛出异常。参见 Liskov 替换原则。
抛出异常
public static void main(String args[]) throws EOFException {
throw new EOFException();
}
public static void main(String args[]) throws EOFException {
EOFException e = new EOFException();
throw e;
}
自定义异常
- checked:继承自
Exception
:class CarAlreadyParkingException extends Exception {}
- unchecked:继承自
RumtimeException
:class CarAlreadyParkingException extends RumtimeException {}
捕获异常
所有异常都可以被捕获,但是通常只捕获 checked 类型异常。
void exceptionTest(int n) throws EOFException, ClassNotFoundException {
try {
switch (n) {
case 0 -> throw new FileNotFoundException();
case 1 -> throw new ClassNotFoundException();
default -> throw new EOFException();
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
throw new ClassNotFoundException();
} finally {
System.out.println("This statement will always be executed");
}
}
其中
catch (FileNotFoundException e) {
...
} catch (ClassNotFoundException e) {
...
}
可以写为:
catch (FileNotFoundException | ClassNotFoundException e) {
...
}
便于统一处理。
重新抛出异常
public static void main (String args[]) throws EOFException {
test();
}
private void test() throws EOFException {
throw new EOFException();
}
执行 test
时,因为 test
内抛出的 EOFException
没有被处理,所以通过 throws
向 main
函数中抛出该异常,相当于 test
这条语句处 new
了一个 EOFException
。
finally
语句
在 try-catch
之后加一个 finally
代码块,表示不论是否发发生异常,该代码块都会执行。
这种设计的目的之一在于,假设当前打开了一个文件,之后抛出异常,正常执行的代码被终止,在方法结束执行前,需要关闭这个文件。
InputStream in = new FileInputStream(. . .);
try {
// 1
code that might throw exceptions// 2
}
catch (IOException e) {
// 3
show error message
// 4
}
finally {
// 5
in.close();
}
// 6
这个方法返回 false
private static Boolean test() {
try {
return true;
}
finally {
return false;
}
}
堆栈追踪
有时候运行中的程序会在控制台打印这种信息:
Exception in thread "main" java.lang.NullPointerException
at com.example.myproject.Book.getTitle(Book.java:16)
at com.example.myproject.Author.getBookTitles(Author.java:25)
at com.example.myproject.Bootstrap.main(Bootstrap.java:14)
这种信息可以让我们知道程序中哪个部分出现了错误。
unchecked 或从 main
函数向外 throws
的异常会打印这类信息:
public static void main(String[] args) throws CarAlreadyParkingException {
C c = new C();
B b = new B(c);
A a = new A(b);
a.parking(); // 这句执行后会 throws CarAlreadyParkingException
}
Exception in thread "main" CarAlreadyParkingException
at C.parking(Main.java:75)
at Parkable.parking(Main.java:52)
at Parkable.parking(Main.java:52)
at Main.main(Main.java:19)
使用 e.printStackTrace();
将打印这种格式的信息:
public static void main(String[] args) {
C c = new C();
B b = new B(c);
A a = new A(b);
try {
a.parking();
} catch (CarAlreadyParkingException e) {
e.printStackTrace();
}
}
CarAlreadyParkingException
at C.parking(Main.java:79)
at Parkable.parking(Main.java:56)
at Parkable.parking(Main.java:56)
at Main.main(Main.java:20)
断言
在开发阶段的代码中嵌入,检验某些“假设”是否成立。若成立,表明程序运行正常,否则表明存在错误。
断言的使用
断言的写法:
assert condition;
int x = -1;
assert x > 1;
assert condition : message;
int x = -1;
assert x > 0 : "x <= 0";
默认情况下,JVM 执行 .class
文件时是没有启用断言的,也就是说,一般情况下写 assert false
将没有任何反应。要想启用断言,可以给方法添加 @Test
注解,或在 VM options
添加 -ea
选项。
断言与异常的选择
- 检查前置条件是否满足,不满足则抛出异常,可以提升程序健壮性
- 使用断言 Assert 检查后置条件是否满足,不满足说明程序存在问题(违反表示不变性等),可以提升程序正确性。
防御式编程
- 对来自外部的数据源要仔细检查,例如:文件、网络数据、用户输入等
- 对每个函数的输入参数合法性要做仔细检查,并决定如何处理非法输入
- public 方法接受到的外部数据时,需要假设这些参数是不安全或不合法的,需要检查这些参数的合法性再传给 private 方法
SpotBugs 是一款 Java 静态代码分析工具。