软件构造知识点总结 - 2

第三章


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

数据类型及其表达

基本数据类型

对象数据结构

  • 对象:对象是类的一个实例,有状态和行为
  • 类:类是一个模板,它描述一类对象的行为和状态
  • 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
  • 足够的注释
  • 代码有好的布局:缩进、空行、对其、分块、等。
  • 避免多层嵌套—增加复杂度
  • 文件和包的组织

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