漱石斋笔谈

gaotf 的博客

第一节 可复用性的度量、形态和外部观察

什么是软件复用

  • 软件复用是使用现有软件组件实施或更新软件系统的过程。
  • 软件复用的两个观点:
    • 面向复用编程(programming for reuse):开发出可复用的软件
      • 开发成本高于一般软件的成本:要有足够高的适应性
      • 性能差些: 针对更普适场景,缺少足够的针对性
    • 基于复用编程(programming with 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)

库级别的复用:API/包

  • 方法:Libaray、framework
    • library:
      • 库定义:一组提供可重用功能的类和方法(API)
      • 开发者构造可运行软件实体,其中涉及到对可复用库的调用
      • Java中有很多的库可以复用,例如Guava:Google的Java核心库;Apache Commons等
    • framework:
      • 框架定义:一组具体类、抽象类、及其之间的连接关系
      • 作为主程序加以执行,执行过程中调用开发者所写的程序
      • 开发者根据 framework的规约,填充自己的代码进去,形成完整系统

系统级别的复用:框架

  将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

    1
    2
    3
    Animal a = new Animal(); 
    Animal c1 = new Cat();
    Cat c2 = new Cat();

    在可以使用a的场景,都可以用c1c2代替而不会有任何问题。

  • 在java的静态类型检查之中,编译器强调了几条规则:

    • 子类型可以增加方法,但不可删
    • 子类型需要实现抽象类型中的所有未实现方法
    • 子类型中重写的方法必须有相同或子类型的返回值
    • 子类型中重写的方法必须使用同样类型的参数
    • 子类型中重写的方法不能抛出额外的异常
  • 行为子结构也适用于指定的方法:

    • 更强的不变量
    • 更弱的前置条件
    • 更强的后置条件

逆变与协变

  • 逆变与协变综述:如果A、B表示类型,f(⋅)表示类型转换,≤表示继承关系(比如,$A≤B$表示A是由B派生出来的子类):

    • f(⋅)是逆变(contravariant)的,当$A \le B$时有$f(B) \le f(A)$成立;
    • f(⋅)是协变(covariant)的,当$A \le B$时有$f(A) \le f(B)$成立;
    • f(⋅)是不变(invariant)的,当$A \le B$时上述两个式子均不成立,即f(A)与f(B)相互之间没有继承关系。
  • 协变(Co-variance):

    • 父类型$\to$子类型:越来越具体(specific)。
    • 在LSP中,返回值和异常的类型:不变或变得更具体 。
    • examples

img

  • 逆变(Contra-variance):
    • 父类型$\to$子类型:越来越抽象。
    • 参数类型:要相反的变化,不变或越来越抽象。
    • examples

img

这在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.sizeList.clear。 事实上,Class<?>经常被使用,因为Class<T>中的大多数方法不依赖于T

examples

1
2
3
4
5
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>
1
2
3
4
5
6
7
8
9
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)

  • 委派/委托:一个对象请求另一个对象的功能 。

  • 委派是复用的一种常见形式。

  • 分为显性委派:将发送对象传递给接收对象;

  • 以及隐性委派:由语言的成员查找规则。

  • 委派设计模式:是一种用来实现委派的软件设计模式;

  • 委派依赖于动态绑定,因为它要求给定的方法调用可以在运行时调用不同的代码段;

  • 委派的过程如下:

img

Receiver对象将操作委托给Delegate对象,同时Receiver对象确保客户端不会滥用委托对象;

委派与继承

  • 继承:通过新操作扩展基类或覆盖操作。
  • 委托:捕获操作并将其发送给另一个对象。
  • 许多设计模式使用继承和委派的组合。
  • Problem:如果子类只需要复用父类中的一小部分方法,
  • Solution:可以不需要使用继承,而是通过委派机制来实现。
  • 本质上,一个类不需要继承另一个类的全部方法,通过委托机制调用部分方法。

复合继承原则(CRP)

  • 复合复用原则(CRP):类应当通过它们之间的组合(通过包含其它类的实例来实现期望的功能)达到多态表现和代码复用,而不仅仅是从基础类或父类继承。

  • 我们可以将组合(Composition)理解为(has a)而继承理解为(is a);

  • 委派可以看做Object层面的复用机制,而继承可以看做是类的层面;

  • 只需针对不同子类的对象,委派能够计算该子类的奖金的方法的BonusCalculator。这样一来就不需要在子类继承的时候进行重写。

  • 总结:组合来代替继承的更普遍实现:

    • 用接口来实现系统的最基础行为
    • 接口之间用extends来实现系统功能的扩展(接口组合)
    • 类implements 组合接口

委派的类型

  • 临时性委派(Dependency):最简单的方法,调用类里的方法(use a),其中一个类使用另一个类而不实际地将其作为属性。

  • img

  • 永久性委派(Association):类之中有其它类的具体实例来作为一个变量(has a)

img

  • 更强的委派,组合(Composition):更强的委派。将一些简单的对象组合成一个更为复杂的对象。(is part of)!

img

  • 聚合(Aggregation):对象是在类的外部生成的,然后作为一个参数传入到类的内部构造器。(has a)

img

组合与聚合
  在组合中,当拥有的对象被破坏时,被包含的对象也被破坏。在聚合中,这不一定是真的。以生活中的事物为例:大学拥有多个部门,每个部门都有一批教授。 如果大学关闭,部门将不复存在,但这些部门的教授将继续存在。 一位教授可以在一个以上的部门工作,但一个部门不能成为多个大学的一部分。大学与部门之间的关系即为组合,而部分与教授之间的关系为聚合。

设计可复用库与框架

之所以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)是整个或部分系统的可重用设计,表现为一组抽象构件及构件实例间交互的方法;另一种定义认为,框架是可被应用开发者定制的应用骨架。前者是从应用方面而后者是从目的方面给出的定义。

  • 为了增加代码的复用性,可以使用委派和继承机制。同时,在使用这两种机制增加代码复用的过程中,我们也相应地在不同的类之间增加了关系(委派或继承关系)。而对于一个项目而言,各个不同类之间的依赖关系就可以看做为一个框架。一个大规模的项目可能由许多不同的框架组合而成。

  • 框架与设计模式:

    • 框架、设计模式这两个概念总容易被混淆,其实它们之间还是有区别的。构件通常是代码重用,而设计模式是设计重用,框架则介于两者之间,部分代码重用,部分设计重用,有时分析也可重用。在软件生产中有三种级别的重用:内部重用,即在同一应用中能公共使用的抽象块;代码重用,即将通用模块组合成库或工具集,以便在多个应用和领域都能使用;应用框架的重用,即为专用领域提供通用的或现成的基础结构,以获得最高级别的重用性。
    • 框架与设计模式虽然相似,但却有着根本的不同。设计模式是对在某种环境中反复出现的问题以及解决该问题的方案的描述,它比框架更抽象;框架可以用代码表示,也能直接执行或复用,而对模式而言只有实例才能用代码表示;设计模式是比框架更小的元素,一个框架中往往含有一个或多个设计模式,框架总是针对某一特定应用领域,但同一模式却可适用于各种应用。可以说,框架是软件,而设计模式是软件的知识。
  • 框架分为白盒框架和黑盒框架。

    • 白盒框架:

      • 白盒框架是基于面向对象的继承机制。之所以说是白盒框架,是因为在这种框架中,父类的方法对子类而言是可见的。子类可以通过继承或重写父类的方法来实现更具体的方法。

      • 虽然层次结构比较清晰,但是这种方式也有其局限性,父类中的方法子类一定拥有,要么继承,要么重写,不可能存在子类中不存在的方法而在父类中存在。

      • 通过子类化和重写方法进行扩展(使用继承);

      • 通用设计模式:模板方法;

      • 子类具有主要方法但对框架进行控制。

      • 允许扩展每一个非私有方法

      • 需要理解父类的实现

      • 一次只进行一次扩展

      • 通常被认为是开发者框架

      • examples

      1
      2
      3
      4
      5
      6
      7
      8
      public abstract class PrintOnScreen {
      public void print() {
      JFrame frame = new JFrame();
      JOptionPane.showMessageDialog(frame, textToShow());
      frame.dispose();
      }
      protected abstract String textToShow();
      }
      1
      2
      3
      4
      5
      public class MyApplication extends PrintOnScreen {
      @Override protected String textToShow() {
      return "printing this text on " + "screen using PrintOnScreen " + "white Box Framework";
      }
      }
    • 黑盒框架:

      • 黑盒框架时基于委派的组合方式,是不同对象之间的组合。之所以是黑盒,是因为不用去管对象中的方法是如何实现的,只需关心对象上拥有的方法。
      • 这种方式较白盒框架更为灵活,因为可以在运行时动态地传入不同对象,实现不同对象间的动态组合;而继承机制在静态编译时就已经确定好。
      • 通过实现插件接口进行扩展(使用组合/委派);
      • 常用设计模式:Strategy, Observer ;
      • 插件加载机制加载插件并对框架进行控制。
      • 允许在接口中对public方法扩展
      • 只需要理解接口
      • 通常提供更多的模块
      • 通常被认为是终端用户框架,平台
      • 黑盒框架与白盒框架之间可以相互转换,具体例子可以看一下,软件构造课程中有关黑盒框架的例子,更改上面的白盒框架为黑盒框架:
      1
      2
      3
      public interface TextToShow { 
      String text();
      }
      1
      2
      3
      4
      5
      6
      public class MyTextToShow implements TextToShow {
      @Override
      public String text() {
      return "Printing";
      }
      }
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      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();
      }
      }

第三节 可复用的设计模式

结构型模式: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的一个反例:

img

OCP 开放封闭原则

  • 软件实体应该是可扩展,而不可修改的。也就是说,对扩展是开放的,而对修改是封闭的。这个原则是诸多面向对象编程原则中最抽象、最难理解的一个。
    • 模块的行为应是可扩展的,从而该模块可表现出新的行为以满足需求的变化。
    • 模块自身的代码是不应被修改的
    • 扩展模块行为的一般途径是修改模块的内部实现
    • 如果一个模块不能被修改,那么它通常被认为是具有固定的行为。
  • 关键解决方案:抽象技术。 使用继承和组合来改变类的行为。
  • OCP的一个反例:

img

  • OCP的一个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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

​ 改进之后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
// 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中一个类实现多个接口,不同的接口给不用的客户模块使用,而不是提供给客户模块一个大的接口。
  • “胖”接口具有很多缺点。
    • 胖接口可分解为多个小的接口;
    • 不同的接口向不同的客户端提供服务;
    • 客户端只访问自己所需要的端口。
  • 下图展示出了这种思想:

img

  • ISP的一个反例

img

DIP 依赖转置原则

  • 定义:
    • 高层模块不应该依赖于低层模块,二者都应该依赖于抽象
    • 抽象不应该依赖于细节,细节应该依赖于抽象
  • 这个设计原则的亮点在于任何被DI框架注入的类很容易用mock对象进行测试和维护,因为对象创建代码集中在框架中,客户端代码也不混乱。有很多方式可以实现依赖倒置,比如像AspectJ等的AOP(Aspect Oriented programming)框架使用的字节码技术,或Spring框架使用的代理等。
    • 高层模块不要依赖低层模块;
    • 高层和低层模块都要依赖于抽象;
    • 抽象不要依赖于具体实现;
    • 具体实现要依赖于抽象;
    • 抽象和接口使模块之间的依赖分离。
  • 一个具体的例子:

img

进行抽象改进后:

img

SOLID 总结

  1. 一个对象只承担一种责任,所有服务接口只通过它来执行这种任务。
  2. 程序实体,比如类和对象,向扩展行为开放,向修改行为关闭。
  3. 子类应该可以用来替代它所继承的类。
  4. 一个类对另一个类的依赖应该限制在最小化的接口上。
  5. 依赖抽象层(接口),而不是具体类。

第二节 可维护的设计模式

创造性模式: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)
  • 例子:
    • 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的语法

img

img

正则语法与正则表达式

  • 正则语法:简化之后可以表达为一个产生式而不包含任何非终止节点。
  • 正则语法示例:
1
2
3
4
5
6
7
8
//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)

第三章


第一节 数据类型与类型检查

数据类型及其表达

基本数据类型

对象数据结构

  • 对象:对象是类的一个实例,有状态和行为
  • 类:类是一个模板,它描述一类对象的行为和状态
  • 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)

  • 不变对象:一旦被创建,始终指向同个值/引用

  • 可变对象:拥有方法以修改自己的值/引用

  • StringStringBuilder

    • String:不可变数据类型,在修改时必须创建一个新的String对象

      1
      2
      String s = "a";
      a = s + "b";//s = s.concat("b");
    • StringBuilder:可改变的数据类型,可以直接修改对象的值

      1
      2
      StringBuilder sb = new StringBuilder("a");
      sb.append("b");

可变性与不可变性的优缺点

  • 可变数据类型最小化的拷贝以提高效率;使用不可变类型,对其频繁修改会产生大量的临时拷贝 (需要垃圾回收)
  • 可变数据类型,可获得更好的效能
  • 可变数据类型也适合在多个模块之间共享数据
  • 不可变数据类型更安全,更易于理解,也更方便改变

防御性拷贝

  • 如果一个方法或构造函数允许可变对象进/出,那么就要考虑一下使用者是否有可能改变它。如果是的话,那你必须对该对象进行保护性拷贝,使进入方法内部的对象是外部时的拷贝而不它本身(因为外部的对象有可能还会被改变)。

  • public Date getEnd() {  
         return new Date(end.getTime());  
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66

    ## 第二节 设计规约

    ### *什么是设计规约*

    - 为什么要有设计规约
    - 很多bug来自于双方之间的误解;没有规约,那么不同开发者的理解就可能不同
    - 代码惯例增加了软件包的可读性,使工程师们更快、更完整的理解软件
    - 可以帮助程序员养成良好的编程习惯,提高代码质量
    - 没有规约,难以定位错误
    - 使用设计规约的好处
    - 规约起到了契约的作用。代表着程序与客户端之间达成的一致;客户端无需阅读调用函数的代码,只需理解spec即可
    - 精确的规约,有助于区分责任,给“供需双方”确定了责任,在调用的时候双方都要遵守
    - 规约可以隔离“变化”,无需通知客户端
    - 规约也可以提高代码效率

    ### 行为等价性

    行为等价性就是站在客户端的角度考量两个方法是否可以互换。

    - 另外,我们也可以根据规约判断是否行为等价注:规约与实现无关,规范无需讨论方法类的局部变量或方法类的私有字段。

    - 两个函数附和同一个规约,故二者等价

    ### *规约的结构:前置条件与后置条件*

    **规约的结构**

    - 一个方法的规约常由以下几个短句组成契约:如果前置条件满足了,后置条件必须满足。如果没有满足,将产生不确定的异常行为
    - 前置条件(precondition):对客户端的约束,在使用方法时必须满足的条件。由关键字 **requires**表示;
    - 后置条件(postcondition):对开发者的约束,方法结束时必须满足的条件。由关键字 **effects**表示
    - 异常行为(Exceptional behavior):如果前置条件被违背,会发生什么

    - 静态类型声明是一种规约,可据此进行静态类型检查。
    - 方法前的注释也是一种规约,但需人工判定其是否满足。
    - 参数由@param 描述
    - 子句和结果用 @return 和 @ throws子句 描述
    - 尽可能的将前置条件放在 @param 中
    - 尽可能的将后置条件放在 @return 和 @throws 中

    **mutating methods(可变方法)的规约**

    - 除非在后置条件里声明过,否则方法内部不应该改变输入参数
    - 应尽量遵循此规则,尽量不设计mutating的spec,否则就容易引发bugs
    - 程序员之间应达成的默契:除非spec必须如此,否则不应修改输入参数
    - 尽量避免使用可变(mutable)的对象
    - 对可变对象的多引用,需要程序维护一致性,此时合同不再是单纯的在用户和实现者之间维持,需要每一个引用者都有良好的习惯,这就使得简单的程序变得复杂
    - 可变对象使得程序难以理解,也难以保证正确性
    - 可变数据类型还会导致程序修改变得异常困难

    ### *规约的评价*

    **规约评价的三个标准**

    - 规约的确定性
    - 规约的陈述性
    - 规约的强度

    **规约的确定性**

      确定的规约:给定一个满足前置条件的输入,其输出是唯一的、明确的。

    ```java
    static int findExactlyOne(int[] arr, int val)
    \\ requires: val occurs exactly once in arr
    \\ effects: returns index i such that arr[i] = val

  欠定的规约:同一个输入可以有多个输出。

1
2
3
static int findOneOrMoreAnyIndex(int[] arr, int val)
\\ requires: val occurs in arr
\\ effects: returns index i such that arr[i] = val

  未确定的规约:同一个输入,多次执行时得到的输出可能不同;但为了避免分歧,我们通常将不是确定的spec统一定义为欠定的规约。

规约的陈述性

  • 操作式规约(Operational specs):伪代码 。
  • 声明式规约(Declarative specs):没有内部实现的描述,只有 “初/终”状态 。
  • 声明式规约更有价值;内部实现的细节不在规约里呈现,而放在代码实现体内部注释里呈现。

规约的强度

  • 通过比较规约的强度来判断是否可以用一个规约替换另一个;
  • 如果规约的强度$S2 \ge S1$,就可以用$S2$代替$S1$,体现有二:一个更强的规约包括更轻松的前置条件和更严格的后置条件;越强的规约,意味着实现者(implementor)的自由度和责任越重,而客户(client)的责任越轻。
    • $S2$的前置条件更弱
    • $S2$的后置条件更强

examples

  • Original spec:
1
2
3
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
2
3
1 static int findOneOrMoreAnyIndex(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
2
3
1 static int findOneOrMoreFirstIndex(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^* \to T$
    • exampleInteger.valueOf()
  • Producers(生产器):
    • 通过接受同类型的对象创建新的对象。
    • $T^+,t^* \to T$
    • exampleString.concat()
  • Observers(观察器):
    • 获取抽象类型的对象然后返回一个不同类型的对象/值。
    • $T^+ , t^* \to t$
    • exampleList.size()
  • Mutators(变值器):
    • 改变对象属性的方法 ,
    • 变值器通常返回void,若为void,则必然意味着它改变了对象的某些内部状态;当然,也可能返回非空类型
    • $T^+ , t^* \to t||T|| void$
    • exampleList.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

img

  • 在研究抽象类型的时候,先思考一下两个值域之间的关系:

    • 表示域(rep values)里面包含的是值具体的实现实体。一般情况下ADT的表示比较简单,有些时候需要复杂表示。
    • 抽象域(A)里面包含的则是类型设计时支持使用的值。这些值是由表示域“抽象/想象”出来的,也是使用者关注的。
  • ADT实现者关注表示空间R,用户关注抽象空间A 。

  • $R \to A$的映射特点:

    • 每一个抽象值都是由表示值映射而来 ,即满射:每个抽象值被映射到一些rep值
    • 一些抽象值是被多个表示值映射而来的,即未必单射:一些抽象值被映射到多个rep值
    • 不是所有的表示值都能映射到抽象域中,即未必双射:并非所有的rep值都被映射。
  • 抽象函数(AF):R和A之间映射关系的函数

1
AF : R → A
  • 表示不变量(RI):将rep值映射到布尔值
1
RI : R → boolean
  • 对于表示值r,当且仅当$r$被$AF$映射到了A,$RI(r)$为真
  • 表示不变性RI:某个具体的“表示”是否是“合法的”
  • 也可将RI看作:所有表示值的一个子集,包含了所有合法的表示值
  • 也可将RI看作:一个条件,描述了什么是“合法”的表示值
  • 在下图中,绿色表示的就是$RI(r)$为真的部分,$AF$只在这个子集上有定义。

img

  • 表示不变量和抽象函数都应该记录在代码中,就在代表本身的声明旁边,以下图为例

img

1
2
3
4
5
6
7
8
public class CharSet {
private String s;
// Rep invariant:
// s contains no repeated characters
// Abstraction function:
// AF(s) = {s[i] | 0 <= i < s.length()}
...
}

img

1
2
3
4
5
6
7
8
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()}
...
}

img

1
2
3
4
5
6
7
8
9
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类的例子,它将表示不变量和抽象函数以及表示暴露的安全性注释了出来:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 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进行通信
    • 对彼此的内在运作不了解
  • 信息封装的好处

    • 将构成系统的类分开,减少耦合
    • 加快系统开发速度
    • 减轻了维护的负担
    • 启用有效的性能调整
    • 增加软件复用
  • 信息隐藏接口

    • 使用接口类型声明变量
    • 客户端仅使用接口中定义的方法
    • 客户端代码无法直接访问属性
  • 实现封装的方法

  1. 修改属性的可见性来限制对属性的访问(一般限制为private),例如

    1
    2
    3
    4
    public class Person {
    private String name;
    private int age;
    }
  2. 对每个值属性提供对外的公共方法访问,也就是创建一对赋取值方法,用于对私有属性的访问,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    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

    调用超类方法。例子如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    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的方法不能被重写,但是能够被再次声明。
    • 子类和父类在同一个包中,那么子类可以重写父类所有方法,除了声明为privatefinal的方法。
    • 子类和父类不在同一个包中,那么子类只能够重写父类的声明为publicprotected的非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()

  • ==
    
    1
    2
    3

    是引用等价性 ;而

    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方法的情况下,他们之间的比较还是基于他们在内存中的存放位置的地址值的,因为Objectequals方法也是用双等号(==)进行比较的,所以比较后的结果跟双等号(==)的结果相同。

equals()的判断方法

严格来说,我们可以从三个角度定义相等:

  • 抽象函数:回忆一下抽象函数($AF: R \to A$ ),它将具体的表示数据映射到了抽象的值。如果$AF(a)=AF(b)$,我们就说a和b相等。

  • 等价关系:

    等价是指对于关系$E \subseteq T \times T$ ,它满足:

    • 自反性: x.equals(x)必须返回true
    • 对称性: x.equals(y)y.equals(x)的返回值必须相等。
    • 传递性: x.equals(y)truey.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
  • 足够的注释
  • 代码有好的布局:缩进、空行、对其、分块、等。
  • 避免多层嵌套—增加复杂度
  • 文件和包的组织

代码的可读性/可理解性很多时候比效率/性能更重要,不可读、不可理解代码可能蕴含更多的错误。 因此先写出可读易懂的代码,再去逐渐调优。


第一章


第一节 软件构造的多维视图

  BulidTime概述   

    三种相互关联的形式  

         面向词法 半结构化源代码 

         面向语法 (AST抽象语法树)半结构化的源代码变成语法树  

         面向语义UML

  Runtime概述

    运行时软件的高级概念

         可执行程序:CPU能直接理解执行的指令序列(二进制文件)

         库文件:可复用的代码,库文件本身不能执行

    可执行程序的四种形式

         本地机器码,自解码,静态链接,动态链接

第二节 软件开发的质量属性

外部质量属性

1.正确性: 在规格说明书描述范围之内满足正确性; 保证正确性的技术: 1.有限制的正确(只保证自己层面的正确,假设调用的都是正确的
2.测试与调试 3.防御性编程 4.形式化编程(采用很多数学技术)    

    健壮性: 碰到异常情况进行适当的响应;出现规格说明书之外的情况由健壮性处理(响应异常情况、给出错误提示、正常退出或降级)  
  
    可扩展性: 软件模块能否被其他程序很方便的使用(开发备注、封装)

    兼容性: 能够与其他人进行交互;跨平台、跨软件,实现方法: 一致性和标准化(一致的方法和标准): 标准文件格式,标准数据结构,标准用户接口,标准协议。

    效率: 程序运行中对CPU、硬盘的占用带宽(好的算法、I/O技术、内存管理)

    可移植性: 是否容易由一个环境转移到另一个环境。

    应用性: 用户是否容易使用,不影响专业人员的使用情况下,方便初学者(结构清晰的设置、UI设计要理解用户需求)

    功能性: 蠕变特征,原则是在保证整体质量不降低的情况下进行更新,策略:增量式模型

    及时性: 在规定时间内完成: 时间效率高  

    其他质量特性: 可验证性(如管理系统的效果难以验证)、完整性(不会被非法访问干扰修改,防止数据不一致)、可修改性、资金

内部质量属性

  

  五个关键的质量属性 

     easy to understand  
     ready fot change
     cheap for develop
     safe from bugs
     efficient to run

     可理解性: 1.在构建时: 代码层要注意(函数规约): 变量/子程序/语句的命名与构造标准;代码布局与风格;注释;复杂度 组件层要注意构建和项目的可理解性: 包的组织;文件的组织;命名空间 在时段中,代码层注意重构 2.在运行时,代码层注意跟踪日志
     
     可复用性: 1.构建时 代码层应注意:ADT/OOP;接口与实现分离;继承/重载/重写;
组合/代理;多态;自类型与泛型编程;OO设计模式 组件层注意:API接口设计;类库;框架

     可维护性与适用性: 1.构建时(面对需求的改变,能否做出及时的调整) 代码层可采用:模块化设计;高内聚,低耦合;SOLID原则;OO设计模式;面向图表的编程;面向状态编程;面向语法编程 组件层除注意SOLID原则外,还应考虑GRASP原则 在时段内使用SCM进行版本控制

     健壮性: 1.Code level-build time-Moment:错误处理;异常处理;断言;防御性编程;测试优先编程  2.Component level-build time-period:单元测试;集成测试 3.Build time-period:回归测试  4.run time-moment:测试转储 5.run time-period:跟踪日志

     性能: 1.构建时,使用指定的设计模式 2.运行时 在代码层次: 通过内存管理考虑空间复杂度;通过算法性能计算时间复杂度;利用代码调优生成更高效的目标代码;在时段内进行性能分析和调整 在组件层次:采用分布式系统;编写多线程的并行程序


第二章


第一节 软件声明周期和版本控制(配置管理)

软件生命周期

   声明周期:两种形态 1.从0到1:SDLC 2.从1到n:运用版本控制技术实现迭代更新
   瀑布模型:Requirements->Design->Implementation->Verification->Maintenance  
   增量模型:运用分治的思想,将需求分段,成为一系列增量产品,每个增量颞部仍使用瀑布模型  
   V模型:对瀑布模型的改变,强调测试与继承,对代码、分析文档进行质量保证
   螺旋模型:采用一种周期性的方法来进行系统开发。优点:设计上的灵活性,可以在项目的各个阶段进行变更,以小的分段来构建大型系统,使成本计算变得简单容易。
#### 版本控制工具—Git
   Git的整体架构——四个仓库(本地由三个):工作目录;暂存区域(在menmory中,对用户不可见)(隐藏的.git文件夹中的stage);本地库:源代码;云端软件服务器(远程仓库)
  
   利用对象图结构:每个节点保存:父节点、如提交时间的信息;VCS还原差异、Git保存完整文件;Git对于重复文件,不复制文件,只修改指针;减少冗余;访问速度快
 
   分支代码:git(创建) branc(切换) -b(branch) iss53;git merge hitfix(合并);是用git把文件添加进去,实际上就是把文件修改添加到暂存区;用git commit提交更改,实际上就是把暂存区的所有内容提交到当前分支。
  
   本地库和远程库:clone:将整个库完整的复制;fetch:将某一分支复制下来;push:将粉直推送到服务器上;pull:将某一分支复制下来合并在当前分支上  

第二节 软件构造的过程、系统和工具


广义的软件构造过程:

1.编程

   开发语言:使用IDE的优势:方便编写代码和管理文件(有代码编辑器,代码重构工具、文件和库管理工具) 能够编译、构建(有编译器、解释器、自动构建工具) 结构清晰(有面向对象的类层次结构图和类浏览器) 有GUI界面 支持第三方扩展工具
   建模语言:UML(统一建模语言):UML是用来对软件系统进行可视化建模的一种语言; UML的结构由一组一致的规则定义; 建模的目的:有助于按照需求对系统进行可视化分析 能够理解系统的结构或行为 给出了构造系统的模板 对做出的决策进行文档化
   配置语言:键值文件(.ini;.properties;.rc);XML,YAML,JSON:配置语言用于配hi程序的参数和初始化设置 目的:部署环境设置;应用程序功能的变体;组件之间连接的变体

2.静态代码分析:

   是指不运行被测程序本身,仅通过分析或检查源程序的语法、结构、过程、接口等来检查程序的正确性(提供了对代码结构的理解、有助于确保代码符合行业标准)

3.动态代码分析:

   是指通过运行被测试程序,检查运行结果与预期结果的差异,并分析运行效率、正确性和健壮性等功能(必须执行足够的测试输入,使用诸如代码覆盖率之类的软件测试措施有助于确保已经观察到程序的一组可能i行为的足够部分)  

  

狭义的软件构造过程

1.主要的知识结构

Build场景综述:

   用传统编译语言如C、C++、Java编写软件(complilation)
   用解释型语言(如Perl、python)编写软件的打包和测试(packing and testing)
   用基于Web的应用程序进行编译和打包(使用静态HTML页面、使用Java或C#编写的源代码、使用JSP、ASP或PHP语法编写的混合文件以及多种类型额配置文件)
   执行单元测试代码的其余部分对软件进行隔离验证
   执行静态分析工具来识别程序源代码中的错误
   生成PDF或HTML文档

0%