面向对象编程的特征之一就是使用数据类型的实现封装数据,以简化实现和隔离用例开发。封装实现了模块化编程,它允许我们:
1.独立开发用例和实现的代码;
2.切换至改进的实现而不会影响用例的代码;
3.支持尚未编写的程序(对于后续用例,API 能够起到指南的作用)。
封装同时也隔离了数据类型的操作,这使我们可以:
1.限制潜在的错误;
2.在实现中添加一致性检查等调试工具;
3.确保用例代码更明晰。
一个封装的数据类型可以被任意用例使用,因此它扩展了Java语言。我们所提倡的编程风格是将大型程序分解为能够独立开发和调试的小型模块。这种方式将修改代码的影响限制在局部区域,改进了我们的软件质量。它也促进了代码复用,因为我们可以用某种数据类型的新实现代替老的实现来改进它的性能、准确度或是内存消耗。我们在使用系统库时常常从封装中受益。Java 系统的新实现往往更新了多种数据类型或静态方法库的实现,但它们的API并没有变化。我们只需用抽象数据类型的改进实现替换老的实现即可在不改变任何用例代码的情况下改进所有用例的性能。模块化编程成功的关键在于保持模块之间的独立性。我们坚持将API作为用例和实现之间唯一的依赖点来做到这一点。并不需要知道一个数据类型是如何实现的才能使用它,实现数据类型时也应该假设使用者除了 API什么也不知道。封装是获得所有这些优势的关键。
数据抽象适合算法研究,因为它能够为我们提供一个框架, 在其中能够准确地说明一个算法的目的以及其他程序应该如何使用该算法。它进行了以下操作:
1.由一组给定的值构造了一个SET (集合)对象;
2.判定一个给定的值是否存在于该集合中。
这些操作封装在StaticSETofInts抽象数据类型中,和Whitelist用例起显示在下表中。StaticSETofInts是更一般也更有用的符号表抽象数据类型的一种特殊情况。在所有算法中,二分查找是较为适合用于实现这些抽象数据类型的一种。和BinarySearch实现比较起来,这里的实现所产生的用例代码更加清晰和高效。例如,StaticSETofInts强制要求数组在rank()方法被调用之前排序。有了抽象数据类型,我们可以将抽象数据类型的调用和实现区分开来,并确保任意遵守API的用例程序都能受益于二分查找算法(使用BinarySearch的程序在调用rank()之前必须能够将数组排序)。白名单应用是众多二分查找算法的用例之一。
每个Java程序都是一组静态方法和一种应用数据类型的实现的集合。在本书中我们主要关注的是抽象数据类型的实现中的操作和向用例隐藏其中的数据表示,数据抽象使我们能够:
1.准确定义算法能为用例提供什么;
2.隔离算法的实现和用例的代码:
3.实现多层抽象,用已知算法实现其他算法。
pub1ic class StaticSETofInts | |
---|---|
Static SETofInts(int[ a) | 根据a[]中的所有值创建一个集合 |
boolean contains(int key) | key是否存在于集合中 |
public class Whitelist{
public static void main(String[] args){
int] w= In.readInts(args[0]);
StaticSETofInts set =new StaticSETofInts(w);
while(! StdIn.isEmpty){
//读取键,如果不在白名单中则打印它
int key= StdIn.readInt();
if(! set.contains(key)){
System.out.printIn(key);
}
}
}
import java.util.Arrays;
public class StaticSETofInts{
private int[] a;
public StaticSETofInts(int[] keys){
a = new int[keys.length];
for (int i=0;i < keys. length; i++){
a[i] = keys[i]; //保护性复制
Arrays. sort(a);
}
public boolean contains(int key){
return int rank(int key) != -1;
}
private int rank(int key){
int lo=0;
int hi=a.length-1;
while(lo <= hi){
int mid = (lo + hi) / 2;
for(key < a[mid]) hi = mid-1;
else if (key > a[mid]) lo = mid + 1;
else return mid;
}
return -1;
}
}
Java 语言为定义对象之间的关系提供了支持,称为接口。我们学习的第一种继承机制叫做子类型。 它允许我们通过指定一一个含有一组公共方法的接口为两个本来并没有关系的类建立一种联系, 这两个类都必须实现这些方法。例如,如果不使用我们的非正式API,也可以为Date声明一个接口:
public interface Datable{
int month();
int year();
int day();
}
并在我们的实现中引用该接口:
public class Date implements Datable{
// 实现代码(和以前一样)
}
这样,Java编译器就会检查该实现是否和接口相符。为任意实现了month()、day() 和year()的类添加implements Datable 保i证了所有用例都能用该类的对象调用这些方法。这种方式称为接口继承——实现类继承的是接口。 接口继承使得我们的程序能够通过调用接口中的方法操作实现该接口的任意类型的对象。我们可以在更多非正式的API中使用接口继承,但为了避免代码依赖于和理解算法无关的高级语言特性以及额外的接口文件,在某些情况下Java的习惯用法鼓励我们使用接口:我们用它们进行比较和迭代。
1.3.4 属性继承
Java还支持另一种继承机制,被称为子类。这种技术使程序员不需要重写整个类就能改变它的行为或者为它添加新的功能。它的主要思想是定义一个新类(子类,或称为派生类)来继承另一个类( 父类,或称为基类)的所有实例方法和实例变量。子类包含的方法比父类更多。另外,子类可以重新定义或者重写父类的方法。子类继承被系统程序员广泛用于编写所谓可扩展的库。例如,这种方法被厂泛用于图形用户界面的开发,因此实现用户所需要的各种控件(下拉菜单,剪切粘贴,文件访问等)的大量代码都能够被重用。这种结构意味着每个类都含有getClass()、toString(),equals()和hashCode()等。实际上,每个类都通过子类继承从0bject类中继承了这些方法,因此任何用例都可以在任意对象中调用这些方法。我们通常会重载新类的toString(),equals()和hashCode()方法,因为Object类的默认实现一般无法提供所需的行为。
方法 | 作用 |
---|---|
Class getClass() | 该对象的类是什么 |
String toString() | 该对象的字符串表示 |
boolean equals(Object A) | 该对象是否和A相等 |
int hashCode() | 该对象的散列值 |
按照习惯,每个Java类型都会从object继承toString()方法,因此任何用例都能够调用任意对象的toString()方法。当连接运算符的一个操作数是字符串时,Java 会自动将另一个操作数也转换为字符串,这个约定是这种自动转换的基础。如果一个对象的数据类型没有实现toString()方法,那么转换会调用Obejct的默认实现。但是默认实现只会返回一个含有该对象内存地址的字符串。因此我们通常会为我们的每个类实现并重写默认的toString()方法。toString()方法的实现通常很简单,只需隐式调用(通过+)每个实例变量的toString()方法即可。
Java提供了一些内置的引用类型,称为封装类型。每种原始数据类型都有一个对应的封装类型:Boolean、 Byte、 Character、Double. Float、Integer、Long和Short分别对应着boolean、byte、char、 double、float、 int、1ong和short。这些类主要由类似于parseInt()这样的静态方法组成,但它们也含有继承得到的实例方法toString()、compareTo()、 equals()和hashCode()。在需要的时候Java会自动将原始数据类型转换为封装类型,例如,当一个int值需要和一个String连接时,它的类型会被转换为Integer并触发toString()方法。
两个对象相等意味着什么?如果我们用相同类型的两个引用变量a和b进行等价性测试(a =b),我们检测的是它们的标识是否相同,即引用是否相同。一般用例希望能够检查数据类型的值(对象的状态)是否相同或者实现某种针对该类型的规则。Java 为Integer、Double和String等标准数据类型以及一-些如File和URL的复杂数据类型提供了实现。在处理这些类型的数据时,可以直接使用内置的实现。例如,如果x和y均为String类型的值,那么当且仅当x和y的长度相同且每个位置的字符均相同时x. equals(y)的返回值为true。当我们在定义自己的数据类型时,比如Date或Transaction,需要重载equals()方法。Java 约定equals()必须是一种等价性关系。 它必须具有:
1.自反性,x.equals(x)为true;
2.对称性,当且仅当y. equals(x)为true时,x. equals(y)返回true;
3.传递性,如果x. equals(y)和y. equals(z)均为true, x. equals(z)也将为true
另外,它必须接受一个object为参数并满足以下性质:
1.一致性,当两个对象均未被修改时,反复调用x. equals(y)总是会返回相同的值;
2.非空性,x. equals(null)总是返回false.
如Date所示。它通过以下步骤做到了这一点。
1.如果该对象的引用和参数对象的引用相同,返回true。
2.如果参数为空(null),根据约定返回false (还可以避免在下面的代码中使用空引用)。
3.如果两个对象的类不同,返回false。要得到一个对象的类,可以使用getClass()方法。请注意我们会使用——来判断Class类型的对象是否相等,因为同-种类型的所有对象的getClass() 方法一定能够返回相同的引用。
4.将参数对象的类型从object转换到Date 。例如,我们只有在两个Counter对象的count变量相等时才会认为它们相等。
public class Date{
private final int month;
private final int day;
private final int year;
public Date(int m,int d, int y){
month=m; day= d; year= y;
}
public int month(){
return month;
}
public int day(){
return day;
}
public int year(){
return year;
}
public String toString(){
return month()+ "/"+ day() + "/" + year();
}
public boolean equals(Object x){
if (this == x) return true;
if (x == null) return false;
if (this.getClass() != x.getClass()) return false;
Date that=(Date) x;
if (this.day != that.day) return false;
f (this.month != that.month) return false;
if (this.year != that.year) return false;
return false;
}
}
我们可以为一个引用变量赋予一个新的值,因此一段程序可能会产生一个无法被引用的对象。Java 程序经常会创建大量对象(以及许多保存原始数据类型值的变量),但在某个时刻程序只会需要它们之中的一一小部分。 因此,编程语言和系统需要某种机制来在必要时为数据类型的值分配内存,而在不需要时释放它们的内存。内存管理对于原始数据类型更容易,因为内存分配所儒要的所有信息在编译阶段就能够获取。Java 会在声明变量时为它们预留内存空间,并会在它们离开作用城后释放这些空间。对象的内存管理更加复杂:系统会在创建一个对象时为它分配内存,但是程序在执行时的动态性决定了一个对象何时才会变为孤儿,系统并不能准确地知道应该何时释放一个对象的内存。Java 最重要的一个特性就是自动内存管理。它通过记录孤儿对象并将它们的内存释放到内存池中将程序员从管理内存的责任中解放出来。这种回收内存的方式叫做垃圾回收。Java 的一个特点就是它不允许修改引用的策略。这种策略使Java能够高效自动地回收垃圾。
不可变数据类型,Java 语言通过final修饰符来强制保证不可变性。当你将一个变量声明为 final时,也就保证了只会对它赋值一次,可以用赋值语句,也可以用构造函数。试图改变final变量的值的代码将会产生一个编译时错误。我们用final修饰值不会改变的实例变量,说明了这个变量的值不会再发生改变,它能够预防意外修改,也能使程序的调试更加简单。
像Date这样实例变量均为原始数据类型且被final修饰的数据类型( 按照约定,在不使用子类继承的代码中)是不可变的。对于类似于Date的数据类型,抽象的目的是封装不变的值,以便将其和原始数据类型一样用于赋值语句、作为函数的参数或返回值( 而不必担心它们的值会被改变)。我们在使用Java数组(可变)和Java的string类型(不可变)时就已经遇到了这种区别。将一个String传递给一个方法时,不会担心该方法会改变字符串中的字符顺序,但当你把一个数组传递给一个方法时, 方法可以自由改变数组的内容。String 对象是不可变的,因为我们一般都不希望String的值改变,而Java数组是可变的,因为我们一般的确希望改变数组中的值。但也存在我们希望使用可变字符串(这就是Java的StringBuilder类存在的目的)和不可变数组的情况。
一般来说, 不可变的数据类型比可变的数据类型使用更容易,误用更困难,因为能够改变它们的值的方式要少得多。调试使用不可变类型的代码更简单,因为我们更容易确保用例代码中使用它们的变量的状态前后一致。在使用可变数据类型时,必须时刻关注它们的值会在何时何地发生变化。而不可变性的缺点在于我们需要为每个值创建一个新对象。这种开销一般是可以接受的,因为Java的垃圾回收器通常都为此进行了优化。不可变性的另一个缺点在于, final 非常不幸地只能用来保证原始数据类型的实例变量的不可变性,而无法用于引用类型的变量。如果一个应用类型的实例变量含有修饰符 final,该实例变量的值(某个对象的引用)就永远无法改变了一它将永远指向同 一个对象,但对象的值本身仍然是可变的。例如,这段代码并没有实现一个不可变的数据类型:
public class Tset{
private final double[] nums;
public Vector(double[] a){
nums = a;
}
}
可变数据类型 | 不可变数据类型 |
---|---|
Counter | Date |
Java数组 | String |