CS2 软件设计与数据结构

Chapter 2 Object Oriented Programming

| 关于   «  2. 抽象数据类型   ::   目录   ::   2. 统一建模语言  »

1. 面向对象程序设计导论

1.1. 面向对象程序设计导论

面向对象程序设计(OOP)是一种编程范式,它基于 对象 的概念。对象是包含数据的 数据结构 , 其中数据以字段(或属性)的形式存在, 代码则以过程(或方法)的形式存在。 对象的一个显著特征是:对象的过程提供对其字段的访问和修改。

在面向对象程序设计中,计算机程序被设计成由彼此交互的对象组成。 面向对象程序设计领域存在很大的多样性, 但大多数流行的语言都是基于类的, 也就是说对象是类的实例,而类通常也决定了对象的类型。

面向对象是过程式程序设计的衍生物。 过程式程序设计是一种编程范式,源自结构化程序设计, 建立在过程调用这一概念之上。 过程(procedure)也称为例程(routine)、 子例程(subroutine)或方法,定义了要执行的计算步骤。

任何一个过程都可能在程序执行期间的任意时刻被调用, 包括被其他过程调用或被它自身调用。 过程式程序设计就是一系列指令,逐步告诉计算机要做什么, 以及从第一段代码到第二段代码该如何执行。 过程式程序设计语言包括 C、Fortran、Pascal 和 BASIC。

过程式程序设计的重点是把编程任务分解为变量、数据结构和子例程的集合, 而面向对象程序设计的重点则是把编程任务分解为 对象 ,这些对象通过接口来暴露行为(方法)和数据(字段)。 最重要的区别在于:过程式程序设计用过程来操作数据结构, 而面向对象程序设计把两者捆绑在一起, 因此 对象 (也就是 类的实例 )操作的是它"自己的"数据结构。

1.1.1. 面向对象程序设计的原则

关于面向对象程序设计的主要特性和动机,人们有许多不同的观点 [1] [2] 。 其中有 4 条原则适用于大多数观点:

封装:

封装指的是创建自包含的模块(类),把处理函数绑定到其数据成员上。 每个类中的数据都保持私有。 每个类都定义了哪些内容公开可见、允许进行哪些修改的规则。

继承:

类可以按层次结构创建,继承让一个类中的结构和方法 沿 类层次 向下传递。 通过 继承 代码,复杂的行为可以经由父类代码的复用而涌现出来。 如果在层次结构的底部添加一个步骤, 只需添加与这个独特步骤相关的处理和数据即可, 该步骤之上的其他一切都可以继承得到。 复用被认为是面向对象的一大优势。

多态:

面向对象程序设计允许程序员为那些确切类型直到运行时才可知的对象 编写过程。 例如,屏幕光标可能随程序模式的不同而改变形状,从箭头变成线条。 响应鼠标移动而在屏幕上移动光标的例程,可以针对"光标"来编写, 多态则使得针对给定形状的正确版本能够被调用。

抽象:

抽象表示一个对象区别于其他各类对象的本质特征, 从而相对于观察者的视角,提供了清晰定义的概念边界。 [Booch]

抽象表示针对真实事物的一种模型、一个视图或其他某种聚焦的表示。 它就是开发一个软件对象,来代表我们在现实世界中能找到的对象。 封装则隐藏了该实现的细节。

1.1.2. 封装

考虑下面的例子:

/** A class with no encapsulation */
class BadBoyShipping {
  public int weight;
  public String address;

  /* remaining code ommitted ... */
}

class ExploitShipping {
  public static void main (String[] args) {
    BadBoyShipping bad = new BadBoyShipping();
    bad.weight = -3;  // Nothing prevents me from doing this
  }
}

显然,允许人们把运输重量设置为负值是个坏主意。 你要如何修改这个类,来防止这类问题的发生? 你唯一的选择是把 weight 设为私有,并编写一个方法, 让该类能够对重量设置限制。 但由于你已经把 weight 声明为 public , 一旦做出这个"修复",就会破坏当前使用它的每一个类, 包括那些行为本来正确的类!

在不破坏使用它的每一个类的前提下修改自己的代码, 这种能力是封装的关键好处之一。 通过最大限度地限制访问、隐藏类的实现细节, 你可以修改、修复、扩展或重构自己的类, 而不需要改动任何使用该类的代码。

我们如何确保代码保持灵活且易于维护?

  • 使用 private 访问修饰符把字段隐藏起来

  • 通过隐藏字段,提供 公共访问方法 并强制调用者使用它们。

把我们的第一个例子与下面的代码比较:

/** A class with encapsulation */
class Shipping {
  private int weight;

  public int getWeight () {
    return weight;
  }

  public void setWeight (int value) {
    weight = value;
  }

}

class ExploitShipping {
  public static void main (String[] args) {
    Shipping s = new Shipping();
    s.setWeight(-3);   // Still not the behavior we are looking for
  }
}

你可能会想:"嘿!这比第一个例子好在哪儿?" 我们添加了设置和获取重量的方法,却没有增加任何新功能。 我们得到了什么?

我们得到了很多。 现在我们可以随意改变关于重量值如何设置和读取的想法。 尽管我们现在什么也没做,但日后可以自由地修改实现, 而任何调用类都不会察觉。

良好的面向对象设计要求为未来着想。 这就引出了我们的最后一个例子。 要添加下面的新功能,不需要修改任何类。

/** A class with encapsulation */
class Shipping {
  // minimum shipping weight in oz.
  private static final int MIN_WEIGHT = 1;
  private int weight;

  public int getWeight () {
    return weight;
  }

  public void setWeight (int value) {
    weight = Math.max(MIN_WEIGHT, value);
  }

}

class ExploitShipping {
  public static void main (String[] args) {
    Shipping s = new Shipping();
    s.setWeight(-3);   // weight is set to MIN_WEIGHT
  }
}

1.1.3. 继承

考虑下面的例子:

class Test {
  public static void main (String[] args) {
    Test test1 = new Test();
    Test test2 = new Test();

    if (!test1.equals(test2)) {
      System.out.println("'test1' does not equal 'test2'.");
    }
    if (test1 instanceof Object) {
      System.out.println("'test1' is an Object.");
    }
  }
}

运行时会产生如下输出:

'test1' 不等于 'test2'。'test1' 是一个对象。

equals 方法是从哪里来的? 它是从 Object 类 继承 而来的。 在 Java(以及其他一些语言)中,每个类都是 Object 类的子类。 在 Java 中,每个类都继承了 equals 、 hashCode 、 toString 以及其他几个方法。

为什么?

语言的创造者认为,能够判断两个对象是否相等, 或者能够生成一个对象的字符串表示,会是非常常见的需求。 如果这些方法不在 Object 类中, 那么每个程序员都得为这个问题创造自己的解决方案。 更重要的是,每个程序员可能会为目前由 'equals' 和 'toString' 所满足的基本需求实现不同的 接口 , 这会使这些常见功能在不同开发者之间的实现变得复杂。

更一般地说,继承促进了代码复用。 一个绝佳的例子是 InputStream 类。 InputStream 类是 Java IO API 中所有输入流的基类(超类)。 InputStream 的子类包括 FileInputStream 、 BufferedInputStream 和 PushbackInputStream 等。

Java 的 InputStream 用于读取数据,一次读一个字节,例如:

try( InputStream inputstream = new FileInputStream("file.txt") ) {

  int data = inputstream.read();
  while(data != -1) {
    System.out.print((char) data);
    data = inputstream.read();
  }
} catch (Exception e) { }

这段代码会创建一个新的 FileInputStream 实例。 FileInputStream 是 InputStream 的子类, 因此把一个 FileInputStream 实例赋给 InputStream 变量是安全的。

InputStream 类暴露了一组公共方法, InputStream 的所有子类都会继承这些方法。

int available():

返回一个估计值:在不阻塞的情况下,下次调用此输入流的方法时, 可以从此输入流中读取(或跳过)的字节数。

void close():

关闭此输入流,并释放与该流关联的所有系统资源。

void mark(int readlimit):

在此输入流中标记当前位置。

boolean markSupported():

测试此输入流是否支持 mark 和 reset 方法。

abstract int read():

从输入流中读取下一个数据字节。

int read(byte[] b):

从输入流中读取一定数量的字节,并存储到缓冲区数组 b 中。

int read(byte[] b, int off, int len):

从输入流中读取最多 len 个字节的数据,存入一个字节数组中。

void reset():

把此流重新定位到上次对此输入流调用 mark 方法时的位置。

long skip(long n):

跳过并丢弃此输入流中的 n 个字节的数据。

FileInputStream 类继承了 InputStream 的所有方法, 另外还提供两个方法:

FileChannel getChannel():

返回与此文件输入流关联的唯一 FileChannel 对象。

FileDescriptor getFD():

返回 FileDescriptor 对象,该对象表示此 FileInputStream 所使用的文件系统中与实际文件的连接。

相比之下, AudioInputStream 类提供了两个完全不同的方法:

AudioFormat getFormat():

获得此音频输入流中声音数据的音频格式。

long getFrameLength():

获得该流的长度,以采样帧而非字节来表示。

上面的例子说明, AudioInputStream 对象和 FileInputStream 对象 与 InputStream 之间都具有 IS-A 关系。 也就是说, AudioInputStream IS-A InputStream , FileInputStream IS-A InputStream 。

Java 中的 IS-A 关系用关键字来表达: 类继承用 extends ,接口实现用 implements 。

这与通过 组合 来扩展类有所不同。

继承不仅促进代码复用, 还为我们在代码中使用多态提供了手段。

1.1.4. 多态

多态 常被称为面向对象程序设计的第三大支柱, 排在封装和继承之后。 多态(Polymorphism)是一个希腊词,意思是"多种形态", 而多态本身有两种截然不同的形式:

  • 运行时多态

    基类可以定义并实现抽象方法或虚方法, 派生类可以重写(override)它们,也就是提供自己的定义和实现。 在运行时,当客户代码调用该方法时, 会解析类型并调用该虚方法的那个重写版本。 因此,在源代码中你可以在基类上调用方法, 却让派生类的该方法版本得以执行。

    在运行时,派生类的对象可以在方法参数、集合或数组等场合 被当作基类的对象来对待。 发生这种情况时,对象的声明类型就不再与其运行时类型相同。

    注意,派生类可以被当作其继承层次中的任何类型来对待。 此外,被重载的方法同时又被重写,也是完全有效的。

  • 编译时多态

    编译时多态就是方法重载。 重载 的方法具有相同的方法名, 但参数个数不同、参数类型不同,或两者兼有。

1.1.5. 运行时多态

回想一下我们在讨论封装时对 Object 类的讨论。 下面这段来自 [Bloch] 第 74 页的代码,其结果是什么?

public class Complex {
  private final double real;  // real number
  private final double imag;  // imaginary number's coefficient

  public Complex(double real, double imag) {
    this.real = real;
    this.imag = imag;
  }

  public static void main(String[] args)
  {
    Complex a = new Complex(1, 0);
    Complex b = new Complex(1, 0);

    if (a.equals(b)) {
      System.out.println ("'a' equals 'b'.");
    } else {
      System.out.println ("'a' and 'b' are not equal.");
    }
    System.out.println("'a' is " + a.toString());
    System.out.println("'b' is " + b.toString());
  }
}
'a' and 'b' are not equal.
'a' is Complex@325e9e34
'b' is Complex@61e481c1

在这个例子中,两个对象不被视为相等,因为它们不是同一个对象。 这是 equals() 的默认行为,而且通常已经足够。 如果你需要判断两个对象是否 逻辑等价 , 就应该重写 equals() 方法。

有很多类,如果让 equals() 求值为 false 会令人恼火:

Object a = new Integer(2);
Object b = new Integer(2);

回想一下, == 比较的总是对象引用, 因此这里 a == b 总是求值为 false , 而 a.equals(b) 求值为 true 。

类似地, toString() 的输出显示的是对象在堆上的位置, 这未必是你的对象最直观的字符串表示。

把前面的例子与下面的代码比较。 这个程序会输出什么?

public class Complex {
  private final double real;  // real number
  private final double imag;  // imaginary number's coefficient

  public Complex(double real, double imag) {
    this.real = real;
    this.imag = imag;
  }

  @Override 
  public boolean equals(Object o) {
    if (o == this) {
      return true;
    }
    if (!(o instanceof Complex)) {
      return false;
    }
    Complex c = (Complex) o;

    return Double.compare(real, c.real) == 0 &&
           Double.compare(imag, c.imag) == 0;
  }

  @Override public String toString() {
    String sign = imag < 0 ? " - " : " + ";
    return "(" + real + sign + Math.abs(imag) + "i)";
  }

  public static void main(String[] args)
  {
    Complex a = Complex(1, 0);
    Complex b = Complex(1, 0);

    if (a.equals(b)) {
      System.out.println ("'a' equals 'b'.");
    } else {
      System.out.println ("'a' and 'b' are not equal.");
    }
    System.out.println ("'a' = " + a);
    System.out.println ("'b' = " + b);
  }
}

该类 重写 了 equals() 和 toString() 的定义, 提供了比 Object 类的默认实现更有普遍用处的实现。

输出为:

'a' equals 'b'.
'a' = (1.0 + 0.0i)
'b' = (1.0 + 0.0i)

1.1.6. 编译时多态

在没有重载机制的过程式语言中, 常见的做法是用许多名字相似的函数, 对不同数据类型执行本质上相同的任务。 绝对值函数就是一个经典的例子。

在 C 语言中, abs() 函数返回一个整数的绝对值。 你能传入的唯一合法参数是 int—任何其他类型都无法通过编译。 这个问题在 C 中如何解决? 靠不同的函数名: labs() 用于返回 long 的绝对值, fabs() 返回 float 的绝对值。 负担落在了这些函数的使用者—程序员身上, 他们必须记住哪个函数才是需要的。 此外,对于"取一个数的绝对值"这个一般性概念, 也没有什么简单的办法可以灵活处理。 确保正确的函数与相应的类型搭配使用, 这个负担落在了编写各个 *abs() 函数的程序员身上。

重载是一个强大的工具,但也存在陷阱。 考虑下面的代码片段。 下面的程序会打印什么?

public class DataStructureGroup {

  public static String group (List<?> l) {
    return "List";
  }

  public static String group (Queue<?> l) {
    return "Queue";
  }

  public static String group (Collection<?> l) {
    return "Unknown group";
  }

  public static void main(String[] args)
  {
    Collection <?>[] cols = {
      new ArrayList<Integer>(),
      new PriorityQueue<String>(),
      new TreeSet<Long>()
    };

    for (Collection<?> c : cols) {
      System.out.println (group(c));
    }
  }
}

你可能以为程序会打印:

"List"
"Queue"
"Unknown group"

实际并非如此。为什么?

因为 group 被重载了,而由 编译器 决定调用哪个函数。 对这三种类型来说,传给 group 的参数的编译时类型都是相同的: Collection<?> 。 类型在运行时会改变,但这对重载没有任何影响。

请记住,在 Java 中重写方法远比重载常见, 所以请慎重考虑你对重载的使用。

1.1.7. 抽象

面向对象语言相对于 过程式 语言的 一个关键优势在于:对象可以作为现实世界的隐喻—换句话说,对象为现实世界 建模 。 在过程式语言中,任务在函数或过程中执行, 而函数所操作的数据则存储在别处。 管理大型程序复杂性的一种更好方式, 是把程序中的数据和允许对该数据进行的操作 保存在一个内聚的逻辑单元中。 一个描述汽车的程序可能要执行一些基本任务:转向、加速、减速, 但还需要存储关于汽车的信息:当前速度、方向、定速巡航设置等。

如果你用过程式语言来编写驾驶汽车的程序, 很可能需要不同的函数来控制汽车的每种行为。 你可能会为 turnCarOn() 、 turnCarOff() 、 accelerate() 、 steer() 等创建函数。 你还需要变量来存储汽车的当前状态。 尽管用过程式语言构造这样一辆"车"完全可行, 但我们创建的这些函数和变量, 只是作为一个整体实体而存在,一辆 汽车 , 它只存在于创建它的程序员的头脑中。 程序内的各个单元各自具有特定角色或职责的理念 被称为 内聚 , 而这在过程式程序中很难实现。

对于可能包含成百上千个实体的超大型程序来说, 缺乏内聚性会引入错误,使程序更难理解和维护, 并使超大型程序的开发更加复杂。

   «  2. 抽象数据类型   ::   目录   ::   2. 统一建模语言  »

关闭窗口