9752 字
49 分钟
HIT-软件构造 | 考前讲座 Lecture 03

By Von Brank | 2022/06/12

第 9 章 可复用性#

概念#

软件复用是什么?#

两类软件复用:

  • 面向复用编程:开发出可复用的软件
  • 基于复用编程:利用已有的可复用软件搭建应用系统

为什么要复用:

  • 降低开发时间和成本
  • 让复用的组件得以经过充分测试,保证其可靠、稳定
  • 实现组件标准化,令其在不同应用中保持一致
  • 开发初期成本:复用 > 不复用;后期成本:复用 < 不复用

如何衡量可复用性?#

  • 复用的机会有多频繁?复用的场合有多少?
  • 复用的代价有多大?

组件复用级别#

复用的级别:

  • 源代码级别的复用
  • 模块级别的复用(类,抽象类,接口)
  • 库级别的复用(API/包)
  • 系统级别的复用(框架)

源码级别的复用#

最主要的复用是代码层面的复用。

代码层面的复用中,以下内容都可能被复用:

  • 需求
  • 设计/规约spec
  • 数据
  • 测试用例
  • 文档

两类源码级别的复用:

  • 白盒复用:
    • 源代码可见,可修改和扩展
  • 黑盒复用:
    • 源代码不可见,不能修改
    • 只能通过 API 接口来使用

模块级别的复用(类,接口)#

设计可复用类:

  • 继承与重写
  • 重载
  • 参数多态性与泛型编程
  • 行为子类化与泛型编程
  • 组合与委托

两种复用类/接口的途径:

  • 继承
  • 委托

库级别的复用(API,包)和系统级别的复用(框架)#

框架是一组具体类、抽象类、及其之间的连接关系。

开发者根据框架的规约,填充自己的代码进去,形成完整系统。

设计可复用类#

面对对象的类的组织关系:

  • 子类型多态(继承)
  • Liskov替换原则
  • 委托
  • 组合

子类型多态#

客户端可用统一的方式处理不同类型的对象,如:

Animal a = new Animal();
Animal c1 = new Cat();
Cat c2 = new Cat();

c1c2 都是 Animal 子类 的实例,那么用 c1c2 代替 Animal 不会有任何问题。

Liskov 替换原则(LSP)#

内容#

具体要求:

  • 子类型可以增加方法,但是不可以删除基类的方法
  • 子类型需要实现抽象类型中的所有未实现方法
  • 当子类覆盖或实现父类的方法时,方法的返回值要比父类更严格。(子类型中重写的方法必须有相同或子类型的返回值或者符合 co-variance 的参数)
  • 当子类覆盖或实现父类的方法时,方法的形参要比父类方法的更为宽松。(子类型中重写的方法必须使用同样类型的参数或者符合 contra-variance 的参数)
  • 子类型中重写的方法不能抛出额外的异常

简单记为:

  • 更强的不变量
  • 更弱的前置条件
  • 更强的后置条件
协变#
  • 父类 \to 子类:越来越具体
  • 返回值类型:不变或更具体
  • 异常的类型:不变或更具体

如:

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( ) { ... }
}
反协变/逆变#
  • 父类 \to 子类:越来越具体
  • 参数类型:不变或越来越抽象

如:

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 时,sailrun 方法都是无用的,毕竟飞机只需要飞且不能航行,然而它却不得不实现 sailrun

可以修改为:


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.
    
    • 否定字符集 [^ ] 否定的字符种类。匹配除了方括号里的任意字符。
      "[^c]ar" => The car parked in the garage.
      
  • 重复次数

    • * 号 匹配>=0个重复的在*号之前的字符。

      "[a-z]*" => The car parked in the garage #21.
      
    • + 号 匹配>=1个重复的+号前的字符。

      "c.+t" => The fat cat sat on the mat.
      
    • ? 号 标记?之前的字符为可选,即该字符出现 0 次或 1 次都可以。

      "[T]he" => The car is parked in the garage.
      
      "[T]?he" => The car is parked in the garage.
      
  • {} 号 {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.
    
  • 锚点

    • ^ 号 从开始行开始匹配
      "(T|t)he" => The car is parked in the garage.
      
      "^(T|t)he" => The car is parked in the garage.
      
    • $ 号 从末端开始匹配
      "(at\.)" => The fat cat. sat. on the mat.
      
      "(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 两个子类 ErrorException

  • 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 :继承自 ErrorRumtimeException 的异常
  • checked :其他异常
Check exceptionUnchecked exception
Basic必须被显式捕获或传递
(try-catch-finally-throw),
否则编译无法通过
异常不必捕获或抛出
Class of Exception其他继承自 ErrorRumtimeException
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 没有被处理,所以通过 throwsmain 函数中抛出该异常,相当于 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 静态代码分析工具。

HIT-软件构造 | 考前讲座 Lecture 03
https://blog.vonbrank.com/posts/hit-software-construction-review-lecture-03/
作者
Von Brank
发布于
2022-06-08
许可协议
CC BY-NC-SA 4.0