数据类型指的是一组值和一组对这些值的操作的集合 。原则上所有程序都只需要使用原始数据类型即可,但在更高层次的抽象上编写程序会更加方便 。
Java 编程的基础主要是使用 class 关键字构造被称为引用类型的数据类型 。 这种编程风格也称为面向对象编程,因为它的核心概念是对象,即保存了某个数据类型的值的实体。 如果只有 Java 的原始数据类型,我们的程序会在很大程度上被限制在算术计算上,但有了引用类型,我们就能编写操作字符串、图像、声音以及 Java 的标准库中的数百种抽象类型的程序 。 比各种库中预定义的数据类型更重要的是 J ava 编程中的数据类型的种类是无限的,因为你能够定义自己的数据类型来抽象任意对象 。
抽象数据类型( ADT )是一种能够对使用者隐藏数据表示的数据类型 。 用 Java 类来实现抽象数据类型和用一组静态方法实现一个函数库并没有什么不同 。 抽象数据类型的主要不同之处在于它将数据和函数的实现关联,并将数据的表示方式隐藏起来 。 在使用抽象数据类型时,我们的注意力集中在 API 描述的操作上而不会去关心数据的表示 ;在实现抽象数据类型时, 我们的注意力集中在数据本身并将实现对该数据的各种操作 。抽象数据类型之所以重要是因为在程序设计上它们支持封装 。
我们研究同一个问题的不同算法的主要原因在于它们的性能特点不同 。 抽象数据类型正适合于对算法的这种研究,因为它确保我们可以随时将算法性能的知识应用于实践中:可以在不修改任何用例代码的情况下用一种算法替换另一种算法并改进所有用例的性能 。
我们使用应用程序编程接口( API )来说明抽象数据类型的行为 。 它将列出所有构造函数和实例方法( 即操作 )井简要描述它们的功用。
尽管数据类型定义的基础是一组值的集合,但在 API 可见的仅是对它们的操作,而非它们的意义 。 因此,抽象数据类型的定义和静态方法库之间有许多共同之处 :
★ 两者的实现均为 Java 类 ;
★ 实例方法可能接受 0 个或多个指定类型的参数,由括号表示并由逗号分隔;
★ 它们可能会返 回一个指定类型的值,也可能不会(用 v oi d 表示)。
当然,它们也有三个显著的不同:
★ API 中可能会出现若干个名称和类名相同且没有返回值的函数。这些特殊的函数被称为构造函数。 在本例中,Counter对象有一个接受一个 String 参数的构造函数。
★ 实例方法不需要 static 关键字 。 它们不是静态方法一一它们的目的就是操作该数据类型中的值 。
★ 某些实例方法的存在是为了尊重 Java 的习惯一一我们将此类方法称为继承的方法并在 API中将它们显示为灰色。
和静态方法库的 API一样,抽象数据类型的 API 也是和用例之间的 一份契约, 因此它是开
发任何用例代码以及实现任意数据类型的起点 。
根据 Java 的约定,任意数据类型都能通过在 API 中包含特定的方法从 Java 的内在机制中获益 。 例如,Java 中的所有数据类型都会继承 toString () 方法来返回用 String 表示的该类型的值。Java 会在用【+】运算符将任意数据类型的值和String值连接时调用该方法 。 该方法的默认实现并不实用(它会返回用字符串表示的该数据类型值的内存地址),因此我们常常会提供实现来重载默认实现,并在此时在 API 中加上to String() 方法 。 此类方法的例子还包括 equals () 、compareTo() 和 hashCode ()。
和基于静态方法的模块化编程一样,API 允许我们在不知道实现细节的情况下编写调用它的代码(以及在不知道任何用例代码的情况下编写实现代码)。
一般来说,可以声明一个变量 heads 并将它通过以下代码和Counter类型的数据关联起来 :Counter heads;
对象是能够承载数据类型的值的实体 。 所有对象都有三大重要特性 : 状态、标识和行为。 对象的状态即数据类型中的值 。 对象的标识能够将一个对象区别于另一个对象 。 可以认为对象的标识就是它在内存中的位置 。 对象的行为就是数据类型的操作 。 数据类型的实现的唯一职责就是维护一个对象的身份 , 这样用例代码在使用数据类型时只需遵守描述对象行为的 API 即可,而无需关注对象状态的表示方法。对象的状态、可以为用例代码提供信息,或是产生某种副作用,或是被数据类型的操作所改变 。 但数据类型的值的表示细节和用例代码是无关的 。引用是访问对象的一种方式 。Java 使用术语引用类型以示和原始数据类型 (变量和值相关联) 的区别 。 不同的 Java实现中引用的实现细节也各不相同,但可以认为引用就是内存地址。
每种数据类型中的值都存储于一个对象中 。 要创建(或实例化)一个对象,我们用关键字 new 并紧跟类名以及()(或在括号中指定一系列的参数,如果构造函数需要的话)来触发它的构造函数 。 构造函数没有返回值,因为它总是返回它的数据类型的对象的引用 。 每当用例调用了 new (),系统都会 :
★ 为新的对象分配内存空间;
★ 调用构造函数初始化对象中的值 ;
★ 返回该对象的一个引用 。
在用例代码中,我们一般都会在一条声明语句中创建一个对象并通过将它和一个变量关联来初始化该变量,和使用原始数据类型时一样 。 和原始数据类型不同的是,变量关联的是指向对象的引用而并非数据类型的值本身 。我们可以用同一个类创建无数对象一一每个对象都有自己的标识,且所存储的值和另 一个相同类型 的对象可以相同也可以不同。
实例方法的意义在于操作数据类型中的值,因此 Java 语言提供了一种特别的机制来触发实例方法,它突出了实例方法和对象之间的联系 。具体来说,我们调用一个实例方法的方式是先写出对象的变量名,紧接着是一个句点,然后是实例方法的名称,之后是 0 个或多个在括号中并由逗号分隔的参数 。 实例方法可能会改变数据类型中的值, 也可能只是访问数据类型中的值 。 实例方法拥有静态方法的所有性质-----参数按值传递,方法名可以被重载,方法可以有返回值,它们也许还会产生一些副作用 。 但它们还有一个特别的性质 :方法的每次触发都是和一个对象相关的 。
在用例中实例方法和静态方法的调用方式完全相同-------可以通过语句( void方法)也可以通过表达式(有返回值的方法)。 静态方法的主要作用是实现函数;非静态(实例)方法的主要作用是实现数据类型的操作 。 两者都可能出现在用例代码中,但很容易就可以区分它们,因为静态方法调用的开头是类名(按习惯为大写),而非静态方法调用的开头总是对象名(按习惯为小写)。
通过声明语句可以将变量名赋给对象,在代码中,我们不仅可以用该变量创建对象和调用实例
方法,也可以像使用整数、浮点数和其他原始数据类型的变量一样使用它 。 要开发某种给定数据类型的用例,我们需要 :
★ 声明该类型的变量,以用来引用对象;
★ 使用关键字 new 触发能够创建该类型的对象的一个构造函数;
★ 使用变量名在语句或表达式中调用实例方法 。
例如,下面用例代码中的 Flips 类就使用了 Counter 类 。 它接受一个命令行参数 T 并模拟 T次掷硬币(它还调用了 StdRandom 类)。除了这些直接用法外,我们可以和使用原始数据类型的变量一样使用和对象关联的变量:
★ 赋值语句;
★ 向方法传递对象或是从方法中返回对象 ;
★ 创建并使用对象的数组。
使用引用类型的赋值语句将会创建该引用的一个副本 。 赋值语句不会创建新的对象,而只是创建另一个指向某个已经存在的对象的引用 。 这种情况被称为别名 : 两个变量同时指向同一个对象 。 别名的效果可能会出乎你的料,因为对于原始数据类型的变量 ,情况不同。 如果 x 和 y 是原始数据类型的变量,那么赋值语句 x=y 会将 y 的值复制到 x 中 。 对
于引用类型,复制的是引用(而非实际的值)。在Java 中,别名
是 bug 的常见原因。
对于一般的toString ()实现 ,这段代码将会打印出” 2 ones ” 。改变一个对象的状态将会影响到所有和该对象的别名有关的代码 。 我们习惯于认为两个不同的原始数据类型的变量是相互独立的,但这种感觉对于引用类型的变革并不适用 。
可以将对象作为参数传递给方法,这一般都能简化用例代码 。 例如,当我们使用 Counter 对
象作为参数时,本质上我们传递的是一个名称和一个计数器,但我们 只需要指定一个变量 。 当我们调用一个需要参数的方法时,该动作在Java 中的效果相当于每个参数值都出现在了一个赋值语句的右侧 ,而参数名则在该赋值语句的左侧 。 也就是说,Java 将参数值的一个副本从调用端传递给了方法,这种方式称为按值传递。 这种方式的一个重要后果是方法无法改变调用端变量的值 。 对于原始数据类型来说,这种策略正是我们所期望的(两个变量互相独立),但每当使用引用类型作为参数时我们创建的都是别名,所以就必须小心。换句话说,这种约定将会传递引用的值(复制 引用),也就是传递对象的引用 。 例如,如果我们传递了 一个指向Counter类型的对象的引 用,那么方法虽然无法改变原始的引用(比如将它指向另一个Counter对象),但它能够改变该对象的值 ,比如通过该引用调用 increment () 方法 。
在 Java 中,所有非原始数据类型的值都是对象 。 也就是说,数组也是对象。和字符串一样,Java 语言对于数组的某些操作有特殊的支持:声明 、初始化和l索引 。 和其他对象一样 ,当我们将数组传递给一个方法或是将一个数组变量放在赋值语句的右侧时,我们都是在创建该数组引用的一个
副本,而非数组的副本 。 对于一般情况,这种效果正合适,因为我们期望方法能够重新安排数组的条目并修改数组的内容。
数组元素可以是任意类型的数据:我们实现的 main() 方法的 args [] 参数就是一个 String 对象的数组 。 创建一个对象的数组需要以下两个步骤 :
★ 使用方括号语法调用数组的构造函数创建数组;
★ 对于每个数组元素调用它的构造函数创建相应的对象 。
在 Java 中,对象数组即是一个由对象的引用组成的数组, 而非所有对象本身组成的数组 。 如果对象非常大,那么在移动它们时由于只需要操作引用而非对象本身,这就会大大提高效率;如果对象很小,每次获取信息时都需要通过引用反而会降低效率 。
数据类型指的是一组值和一组对值的操作的集合 。 我们会将数据类型实现在独立的 Java 类模块中并编写它们的用例 。 对象是能够存储任意该数据类型的值的实体,或数据类型的实例 。 对象有三大关键性质 :状态、标识和行 为 。一个数据类型的实现所支持的操作如下:
★ 创建对象(创造它的标识)使用 new 关键宇触发构造函数并创建对象,初始化对象中的值并返回对它的引用 。
★ 操作对象中的值(控制对象的行为,可能会改变对象的状态):使用和对象关联的变量调用实例方法来对对象中的值进行操作 。
★ 操作多个对象 :创建对象的数组,像原始数据类型的值一样将它们传递给方法或是从方法中返回,只是变量关联的是对象的引用而非对象本身 。
和静态方法库一样,我们也需要使用 Java 的类( class )实现抽象数据类型并将所有代码放入一个和类名相同并带有 .java 扩展名的文件中。文件的第一部分语句会定义表示数据类型的值的实例变量 。 它们之后是实现对数据类型的值的操作的构造函数和实例方法 。 实例方法可以是公共的(在
API中说明)或是私有的(用于辅助计算,用例无法使用)。 一个数据类型的定义中可能含有多个构造函数, 而且也可能含有静态方法,特别是单元测试用例 main () ,它通常在调试和测试中很实用。
要定义数据类型的值( 即每个对象的状态),我们需要声明实例变量,声明的方式和局部变量差不多。 实例变量和你所熟悉的静态方法或是某个代码段中的局部变量最关键的区别在于 : 每一时刻每个局部变量只会有一个值,但每个实例变量则对应着无数值(数据类型的每个实例对象都会有一个) 。这并不会产生二义性,因为我们在访问实例变量时都需要通过一个对象一一我们访问的是这个对象的值。同样,每个实例变量的声明都需要一个可见性修饰符。 在抽象数据类型的实现中,我们会使用private , 也就是使用 Java 语言的机制来保证向使用者隐藏抽象数据类型中的数据表示。如果该值在初始化之后不应该再被改变,我们也会使用 final。Counter类型含有两个实例变量,一个String类型的值 name 和一个 int 类型的值 count 。 如果我们使用public 修饰这些实例变量(在 Java 中是允许的),那么根据定义,这种数据类型就不再是抽象的了。
每个 Java 类都至少含有一个构造函数以创建一个对象的标识 。 构造函数类似于一个静态方法,但它能够直接访问实例变量且没有返回值。 一般来说,构造函数的作用是初始化实例变量。每个构造函数都将创建一个对象并向调用者返回一个该对象的引用 。 构造函数的名称总是和类名相同 。 我们可以和重载方法一样重载这个名称井定义签名不同的多个构造函数。 如果没有定义构造函数 ,类将会隐式定义一个默认情况下不接受任何参数的构造函数并将所有实例变量初始化为默认值。 原始数字类型的实例变量默认值为 0,布尔类型变量为 false ,引用类型变量为 null。我们可以在声明语句中初始化这些实例变量并改变这些默认值。 当用例使用关键字 new 时,Java 会自动触发一个构造函数。 重载构造函数一般用于将实例变量由 默认值初始化为用例提供的值。
实现数据类型的实例方法(即每个对象的行为)的代码和实现静态方法(函数)的代码完全相同 。 每个实例方法都有一个返回值类型、一个签名(它指定了方法名 、 所有参数变量的类型和名称)和一个主体(它由一系列语句组成,包括一个返回语句来将一个返回类型的值传递给调用者)。 当调用者触发了一个方法时,方法的参数( 如果有)均会被初始化为调用者所提供的值 ,方法的语句会被执行,直到得到一个返回值并且将该值返 回给调用者。 它的效果就好像调用者代码中的函数调用被替换为了这个返回值。 实例方法的所有这些行为都和静态方法相同,只有一点关键的不同:它们可以访问并操作实例变量。 如何指定我们希望使用的对象的实例变量?在一个实例方法中对变量的引用指的是调用该方法的对象中的值。 当我们调用 heads.increment() 时,increment ()方法中的代码访问的是heads 中的实例变量。 换句话说,面向对象编程为 Java 程序增加了另一种使用变量的重要方式 。通过触发一个实例方法来操作该对象的值 。
在实现实例方法的Java代码中使用了三种变量:
1、参数变量;2、局部变量;3、实例变量
方法的签名定义了参数变量,在方法被调用时参数变量会被初始化为调用者提供的值;局部
变量的声明和初始化都在方法的主体中 。 参数变量的作用域是整个方法;局部变量的作用域是当前代码段中它的定义之后的所有语句。 实例变量则完全不同:它们为该类的对象保存了数据类型的值,它
们的作用域是整个类 (如果出现二义性 ,可以使用 this 前缀来区别实例变量)。
这些都是你要在 Java 中构造并使用抽象数据类型所需要理解的基本组件 。 我们将要学习的每个抽象数据类型的实现都会是一个含有若干私有实例变量、构造函数、实例方法和一个测试用例的 Java 类 。 要完全理解一个数据类型,我们需要它的 API 、典型的用例和它的实现。为了强调用例和实现的分离,我们一般会将用例独立成为含有一个静态方法main() 的类,并将数据类型定义中的main() 方法预留为一个用于开发和最小单元测试的测试用例(至少调用每个实例方法一次)。 我们开发的每种数据类型都会遵循相同的步骤 。我们会按照下面三步走的方式用抽象数据类型满足它们 。
★ 定义一份 API: API的作用是将使用和实现分离,以实现模块化编程 。 我们制定一份 API 的目标有二 :第一,我们希望用例的代码清晰而正确, 事实上,在最终确定 API 之前就编写一些用例代码来确保所设计的数据类型操作正是用例所需要的是很好的主意;第二,我们希望能够实现这些操作,定义一些无法实现的操作是没有意义的。
★ 用一个 Java 类实现 API 的定义 : 首先我们选择适当的实例变量,然后再编写构造函数和实例方法 。
★ 实现多个测试用例来验证前两步做出的设计决定 。
累加器 API 定义了一种能够为用例计算一组数据的实时平均值的抽象数据类型。它维护一个int 类型的实例变量来记录已经处理过的数据值的数量,以及一个double 类型的实例变量来记录所有数据值之和,将和除以数据数量即可得到平均值。 请注意该实现并没有保存数据的值一一它可以用于处理大规模的数据(甚至是在一个无法全部保存它们的设备上),而一个大型系统也可以大量使用累加器。 这种性能特点很容易被忽视,所以也许应该在 API 中注明,因为一种存储所有数据值的实现可能会使调用它的应用程序用光所有内存。
抽象数据类型是一种向用例隐藏内部表示的数据类型 。
面向对象编程的特征之一就是使用数据类型的实现封装数据,以简化实现和隔离用例开发。封装实现了模块化编程,它允许我们 :
★ 独立开发用例和实现的代码;
★ 切换至改进的实现而不会影 H向用例的代码;
★ 支持尚未编写的程序(对于后续用例,API 能够起到指南的作用)。
封装同时也隔离了数据类型的操作,这使我们可以 :
★ 限制潜在的错误;
★ 在实现中添加一致性检查等调试工具 ;
★ 确保用例代码更明晰 。
一个封装的数据类型可以被任意用例使用 ,因此它扩展了 Java 语言 。 我们所提倡的编程风格是将大型程序分解为能够独立开发和调试的小型模块 。 这种方式将修改代码的影响限制在局部区域 ,改进了我们的软件质量 。 它也促进了代码复用,因为我们可以用某种数据类型的新实现代替老的实现来改进它的性能 、 准确度或是内存消耗。 同样的思想也适用于许多其他领域 。 我们在使用系统库时常常从封装中受益 。Java系统的新实现往往更新了多种数据类型或静态、方法库的实现,但它们的API 并没有变化 。 模块化编程成功的关键在于保持模块之间的独立性 。 我们坚持将 API 作为用例和实现之间唯一的依赖点来做到这一点 。 并不需要知道一个数据类型是如何实现的才能使用它,实现数据类型时也应该假设使用者除 了 API 什么也不知道。 封装是获得所有这些优势的关键。
★ APT 可能会难以实现 : 实现的开发非常困难, 甚至不可能。
★ API 可能会难以使用:用例代码甚至比没有 API 时更复杂 。
★ API 的范围可能太窄:缺少用例所帘的方法 。
★ API 的范围可能太宽:包含许多不会被任何用例调用的方法 。 这种缺陷可能是最常见的,并且也是最难以避免的 。API 的大小一般会随着时间而增长 ,因为向已有的 API 中添加新方法很简单 , 但在不破坏已有用例程序的前提下从中删除方法却很困难 。
★ API 可能会太粗略 : 无法提供有效的抽象 。
★ APl 可能会太详细 · 抽象过于细致或是发散而无法使用 。
★ API 可能会过于依赖某种特定的数据表示 · 用例代码可能会因此无法从数据表示的细节中解脱出来。 要避免这种缺陷也是很困难的,因为数据表示显然是抽象数据类型实现的核心 。
两个对象相等意味着什么 ? 如果我们用相同类型的两个引用变量 a 和 b 进行等价性测试( a==b ),我们检测的是它们的标识是否相同,即引用是否相同 。一般用例希望能够检查数据类型的值(对象的状态)是否相同或者实现某种针对该类型的规则。一种等价性关系 。 它必须具有:
★自反性、★对称性
★传递性,如果 x.equals(y)和 y.equals(z)均为 true,x.equals(z)也将为true。
另外,它必须接受一个 Object 为参数井满足以下性质:
★一致性, 当两个对象均未被修改时,反复调用 x .equals(y ) 总是会返回相同的值;
★非空性
编程语言和系统需要某种机制来在必要时为数据类型的值分配内存,而在不需要时释放它们的 内存(对于一个对象来说,有时是在它变成孤儿之后)。 内存管理对于原始数据类型更容易,因为内存分配所需要的所有信息在编译阶段就能够获取 。Java (以及大多数其他系统 )会在声明变量时为它们预留内存空间,并会在它们离开作用域后释放这些空间 。 对象的内存管理更加复杂:系统会在创建一个对象时为它分配内存,但是程序在执行时的动态性决定了一个对象何时才会变为孤儿 ,系统并不能准确地知道应该何时释放一个对象的内存 。Java 最重要的一个特性就是自动内存管理。 它通过记录孤儿对象并将它们的内存释放到内存池中将程序员从管理内存的责任中解放出来 。 这种回收内存的方式叫做垃圾回收 。Java 的一个特点就是它不允许修改引用的策略。 这种策略使 Java 能够高效自动地回收垃圾 。