Java之道/创建你自己的对象
每次编写类定义时,都会创建一个新的对象类型,其名称与类相同。早在“Hello”部分,当我们定义名为Hello的类时,我们也创建了一个名为Hello的对象类型。我们没有创建任何类型为Hello的变量,也没有使用new命令创建任何Hello对象,但我们可以这样做!
这个例子可能没有任何意义,因为没有理由创建Hello对象,而且如果我们确实创建了它,它有什么用也不清楚。在本章中,我们将研究一些创建有用新对象类型的类定义示例。
以下是本章中最重要的思想
- 定义一个新类也会创建一个新的对象类型
名称相同。
- 类定义就像对象的模板
它确定对象具有哪些实例变量以及哪些方法可以对其进行操作。
- 每个对象都属于某种对象类型;因此,它
是某个类的实例。
- 当你调用new命令创建对象时,Java
调用一个名为构造函数的特殊方法来初始化实例变量。你可以在类定义中提供一个或多个构造函数。
- 通常,所有操作类型的方法都在
该类型的类定义中。
以下是关于类定义的一些语法问题
- 类名(以及对象类型)始终以大写字母开头,
字母,这有助于将其与基本类型和变量名区分开来。
- 你通常将一个类定义放在每个文件中,并且名称
文件必须与类的名称相同,后缀为.java。例如,Time类在名为Time.java的文件中定义。
- 在任何程序中,一个类都被指定为启动
类。启动类必须包含一个名为main的方法,程序的执行从此处开始。其他类可能有一个名为main的方法,但它们不会被执行。
解决了这些问题后,让我们来看一个用户定义类型的示例,Time。
创建新对象类型的常见动机是获取几个相关的数据片段并将它们封装到一个对象中,该对象可以作为一个单元进行操作(作为参数传递,进行操作)。我们已经看到了两个内置类型,例如Point和Rectangle。
另一个我们将自己实现的示例是Time,它用于记录一天中的时间。构成时间的各种信息是小时、分钟和秒。因为每个Time对象都将包含这些数据,所以我们需要创建实例变量来保存它们。
第一步是确定每个变量应该是什么类型。小时和分钟显然应该是整数。为了使事情有趣,让我们将秒设置为double,这样我们就可以记录秒的分数。
实例变量在类定义的开头声明,在任何方法定义之外,如下所示
class Time int hour, minute; double second;
就其本身而言,此代码片段是一个合法的类定义。
声明实例变量后,下一步通常是为新类定义一个构造函数。
构造函数的通常作用是初始化实例变量。构造函数的语法类似于其他方法,但有三个例外
- 构造函数的名称与
类。
- 构造函数没有返回类型,也没有返回值。
- 省略关键字static。
以下是Time类的示例
public Time () this.hour = 0; this.minute = 0; this.second = 0.0;
请注意,在您期望看到返回类型的位置,在public和Time之间,什么也没有。这就是我们(以及编译器)如何判断这是一个构造函数的方式。
此构造函数不带任何参数,如空括号()所示。构造函数的每一行都将一个实例变量初始化为一个任意的默认值(在本例中为午夜)。名称this是一个特殊的关键字,是我们正在创建的对象的名称。你可以像使用任何其他对象的名称一样使用this。例如,你可以读取和写入this的实例变量,并且可以将this作为参数传递给其他方法。
但是你不需要声明this,也不需要使用new来创建它。事实上,你甚至不允许对其进行赋值!this由系统创建;你所要做的就是将其值存储在其实例变量中。
编写构造函数时的一个常见错误是在末尾放置return语句。抵制这种诱惑。
构造函数可以重载,就像其他方法一样,这意味着你可以提供多个具有不同参数的构造函数。Java通过将new命令的参数与构造函数的参数进行匹配来知道调用哪个构造函数。
通常有一个不带参数的构造函数(如上所示),还有一个构造函数,其参数列表与实例变量列表相同。例如
public Time (int hour, int minute, double second) this.hour = hour; this.minute = minute; this.second = second;
参数的名称和类型与实例变量的名称和类型完全相同。构造函数所做的只是将信息从参数复制到实例变量。
如果你返回并查看Point和Rectangle的文档,你会发现这两个类都提供了这样的构造函数。重载构造函数提供了灵活性,可以先创建一个对象,然后填充空白,或者在创建对象之前收集所有信息。
到目前为止,这可能看起来不是很有趣,事实上它确实不是。编写构造函数是一个乏味且机械的过程。一旦你写了两个,你会发现你可以一边睡觉一边写出来,只要看看实例变量列表就可以了。
尽管构造函数看起来像方法,但你永远不会直接调用它们。相反,当你使用new命令时,系统会为新对象分配空间,然后调用你的构造函数来初始化实例变量。
以下程序演示了两种创建和初始化Time对象的方法
class Time int hour, minute; double second; public Time () this.hour = 0; this.minute = 0; this.second = 0.0;
public Time (int hour, int minute, double second) this.hour = hour; this.minute = minute; this.second = second; public static void main (String[] args) // one way to create and initialize a Time object Time t1 = new Time (); t1.hour = 11; t1.minute = 8; t1.second = 3.14159; System.out.println (t1); // another way to do the same thing Time t2 = new Time (11, 8, 3.14159); System.out.println (t2);
作为练习,找出程序的执行流程。
在main中,我们第一次调用new命令时,我们没有提供任何参数,因此Java调用第一个构造函数。接下来的几行将值分配给每个实例变量。
我们第二次调用new命令时,我们提供的参数与第二个构造函数的参数匹配。这种初始化实例变量的方法更简洁(效率也略高),但可能更难阅读,因为它不清楚哪些值分配给哪些实例变量。
此程序的输出为
Time@80cc7c0 Time@80cc807
当 Java 打印用户定义的对象类型的值时,它会打印类型的名称和一个特殊的十六进制(以 16 为基数)代码,该代码对于每个对象都是唯一的。此代码本身没有意义;实际上,它可能因机器而异,甚至因运行而异。但它对于调试很有用,以防您想跟踪各个对象。
为了以对用户(而不是程序员)更有意义的方式打印对象,通常需要编写一个名为 printTime 之类的方法。
public static void printTime (Time t) System.out.println (t.hour + ":" + t.minute + ":" + t.second);
将此方法与“时间”部分中的 printTime 版本进行比较。
如果我们将 t1 或 t2 作为参数传递,则此方法的输出为 11:8:3.14159。虽然这可以识别为时间,但它并非完全采用标准格式。例如,如果分钟数或秒数小于 10,我们期望有一个前导 0 作为占位符。此外,我们可能希望删除秒的小数部分。换句话说,我们想要类似 11:08:03 的东西。
在大多数语言中,都有简单的方法来控制数字的输出格式。在 Java 中,没有简单的方法。
Java 提供了非常强大的工具来打印格式化内容(如时间和日期),以及解释格式化输入。不幸的是,这些工具不太容易使用,因此我将它们排除在本手册之外。但是,如果您愿意,可以查看 java.util 包中 Date 类的文档。
即使我们无法以最佳格式打印时间,我们仍然可以编写操作 Time 对象的方法。在接下来的几个部分中,我将演示操作对象的方法的几种可能的接口。对于某些操作,您将可以选择几种可能的接口,因此您应该考虑每种接口的优缺点。
纯函数:将对象和/或基本类型作为参数,但不修改对象。返回值要么是基本类型,要么是在方法内部创建的新对象。
修改器:将对象作为参数并修改其中一些或全部对象。通常返回 void。void
填充方法:参数之一是一个空对象,该对象由方法填充。从技术上讲,这是一种修改器。
如果结果仅取决于参数,并且没有副作用(例如修改参数或打印内容),则该方法被认为是纯函数。调用纯函数的唯一结果是返回值。
一个例子是 after,它比较两个 Time 并返回一个布尔值,指示第一个操作数是否在第二个操作数之后。
public static boolean after (Time time1, Time time2) if (time1.hour > time2.hour) return true; if (time1.hour < time2.hour) return false;
if (time1.minute > time2.minute) return true; if (time1.minute < time2.minute) return false;
if (time1.second > time2.second) return true; return false;
如果两个时间相等,此方法的结果是什么?这似乎是此方法的合适结果吗?如果您正在为该方法编写文档,您是否会专门提及这种情况?
第二个例子是 addTime,它计算两个时间的总和。例如,如果现在是 9:14:30,并且您的面包机需要 3 小时 35 分钟,则可以使用 addTime 计算面包何时做好。
这是一个不完全正确的此方法的粗略草稿。
public static Time addTime (Time t1, Time t2) Time sum = new Time (); sum.hour = t1.hour + t2.hour; sum.minute = t1.minute + t2.minute; sum.second = t1.second + t2.second; return sum;
虽然此方法返回一个 Time 对象,但它不是构造函数。您应该返回并比较此类方法的语法与构造函数的语法,因为很容易混淆。
以下是如何使用此方法的示例。如果 currentTime 包含当前时间,breadTime 包含面包机制作面包所需的时间,则可以使用 addTime 计算面包何时做好。
Time currentTime = new Time (9, 14, 30.0); Time breadTime = new Time (3, 35, 0.0); Time doneTime = addTime (currentTime, breadTime); printTime (doneTime);
该程序的输出为 12:49:30.0,这是正确的。另一方面,在某些情况下,结果不正确。你能想到一个吗?
问题在于此方法没有处理秒数或分钟数加起来超过 60 的情况。在这种情况下,我们必须将多余的秒“进位”到分钟列,或将多余的分钟进位到小时列。
这是此方法的第二个已更正版本。
public static Time addTime (Time t1, Time t2) Time sum = new Time (); sum.hour = t1.hour + t2.hour; sum.minute = t1.minute + t2.minute; sum.second = t1.second + t2.second;
if (sum.second >= 60.0) sum.second -= 60.0; sum.minute += 1; if (sum.minute >= 60) sum.minute -= 60; sum.hour += 1; return sum;
虽然它是正确的,但它开始变得庞大。稍后,我将建议解决此问题的另一种方法,该方法将更短。
此代码演示了我们之前从未见过的两个运算符,+= 和 -=。这些运算符提供了一种简洁的方法来递增和递减变量。它们类似于 ++ 和 --,但 (1) 它们适用于 double 和 int,以及 (2) 递增量不必为 1。语句 sum.second -= 60.0; 等效于 sum.second = sum.second - 60;
作为修改器的示例,请考虑 increment,它将给定数量的秒添加到 Time 对象。同样,此方法的粗略草稿如下所示
public static void increment (Time time, double secs) time.second += secs;
if (time.second >= 60.0) time.second -= 60.0; time.minute += 1; if (time.minute >= 60) time.minute -= 60; time.hour += 1;
第一行执行基本操作;其余部分处理我们之前看到的相同情况。
此方法是否正确?如果参数 secs 大于 60 会发生什么?在这种情况下,仅减去 60 一次是不够的;我们必须继续这样做,直到 second 小于 60。我们可以通过简单地将 if 语句替换为 while 语句来做到这一点。
public static void increment (Time time, double secs) time.second += secs;
while (time.second >= 60.0) time.second -= 60.0; time.minute += 1; while (time.minute >= 60) time.minute -= 60; time.hour += 1;
此解决方案是正确的,但效率不高。你能想到一个不需要迭代的解决方案吗?
有时您会看到像 addTime 这样的方法使用不同的接口(不同的参数和返回值)。与其每次调用 addTime 时都创建一个新对象,我们可以要求调用方提供一个“空”对象,addTime 应该在其中存储结果。将以下内容与先前版本进行比较。
public static void addTimeFill (Time t1, Time t2, Time sum) sum.hour = t1.hour + t2.hour; sum.minute = t1.minute + t2.minute; sum.second = t1.second + t2.second;
if (sum.second >= 60.0) sum.second -= 60.0; sum.minute += 1; if (sum.minute >= 60) sum.minute -= 60; sum.hour += 1;
这种方法的一个优点是调用方可以选择重复使用同一个对象来执行一系列加法。这可能稍微提高效率,尽管它可能令人困惑,以至于会导致细微的错误。对于绝大多数编程,值得花费一点运行时间来避免大量的调试时间。
任何可以使用修改器和填充方法完成的事情也可以使用纯函数完成。实际上,有一些编程语言称为函数式编程语言,它们只允许纯函数。一些程序员认为,使用纯函数的程序比使用修改器的程序开发速度更快且错误更少。然而,有时修改器很方便,在某些情况下,函数式程序效率较低。
总的来说,我建议您在合理的情况下编写纯函数,并且仅在有令人信服的优势时才使用修改器。这种方法可能被称为函数式编程风格。
在本章中,我演示了一种我称为“快速原型设计和迭代改进”的程序开发方法。在每种情况下,我都编写了一个执行基本计算的粗略草稿(或原型),然后在一些情况下对其进行了测试,并在发现错误时对其进行了更正。
虽然这种方法可能有效,但它可能导致代码变得不必要地复杂(因为它处理了许多特殊情况)且不可靠(因为很难说服自己已经找到了所有错误)。
另一种方法是高级规划,在这种方法中,对问题的深入了解可以使编程变得更加容易。在这种情况下,洞察力在于,时间实际上是以 60 为基数的三位数!秒是“个位数”,分钟是“60 位数”,小时是“3600 位数”。
当我们编写 addTime 和 increment 时,我们实际上是在进行以 60 为基数的加法,这就是为什么我们必须将一个列“进位”到下一列的原因。
因此,解决整个问题的另一种方法是将 Time 转换为 double,并利用计算机已经知道如何对 double 进行算术运算这一事实。这是一个将 Time 转换为 double 的方法。
public static double convertToSeconds (Time t) int minutes = t.hour * 60 + t.minute; double seconds = minutes * 60 + t.second; return seconds;
现在我们只需要一种方法将 double 转换为 Time 对象。我们可以编写一个方法来执行此操作,但将其编写为第三个构造函数可能更有意义。
public Time (double secs) this.hour = (int) (secs / 3600.0); secs -= this.hour * 3600.0; this.minute = (int) (secs / 60.0); secs -= this.minute * 60; this.second = secs;
此构造函数与其他构造函数略有不同,因为它涉及一些计算以及对实例变量的赋值。
您可能需要思考一下才能说服自己我用来在不同基数之间转换的技术是正确的。假设您已经确信,我们可以使用这些方法重写 addTime。
public static Time addTime (Time t1, Time t2) double seconds = convertToSeconds (t1) + convertToSeconds (t2); return new Time (seconds);
这比原始版本短得多,并且更容易证明它是正确的(假设,像往常一样,它调用的方法是正确的)。作为练习,以相同的方式重写 increment。
在某些方面,从以 60 为基数转换为以 10 为基数再转换回来比仅仅处理时间更难。基数转换更抽象;我们处理时间的直觉更好。
但是,如果我们有将时间视为以 60 为基数的数字的洞察力,并投入编写转换方法(convertToSeconds 和第三个构造函数),我们就可以得到一个更短、更易于阅读和调试以及更可靠的程序。
以后也更容易添加更多功能。例如,想象一下减去两个 Time 以找到它们之间的持续时间。天真的方法是实现减法,包括“借位”。使用转换方法会容易得多。
具有讽刺意味的是,有时使问题变得更难(更通用)会使问题变得更容易(更少的特殊情况,更少的出错机会)。
当你为一类问题编写通用解决方案,而不是为单个问题编写特定解决方案时,你就编写了一个算法。我在第一章中提到了这个词,但没有仔细定义它。它不容易定义,所以我将尝试几种方法。
首先,考虑一些不是算法的东西。例如,当你学习乘以一位数时,你可能记住了乘法表。实际上,你记住了100个特定的解决方案,所以这些知识并不是真正的算法。
但如果你很懒,你可能会通过学习一些技巧来作弊。例如,要找到 7 和 9 的乘积,你可以将 7 作为第一位数字,并将 2 作为第二位数字。这个技巧是将任何一位数乘以 9 的通用解决方案。这就是一个算法!
类似地,你学习的带进位的加法、带借位的减法和长除法的技巧都是算法。算法的一个特点是它们不需要任何智能就能执行。它们是机械过程,其中每一步都根据一组简单的规则从上一步得出。
在我看来,人类在学校花费如此多的时间学习执行算法,而这些算法从字面上讲不需要任何智力,这令人尴尬。
另一方面,设计算法的过程很有趣,具有智力挑战性,并且是我们所说的编程的核心部分。
人们自然而然地、毫不费力地或无意识地做的一些事情,在算法上表达起来是最困难的。理解自然语言就是一个很好的例子。我们都这样做,但到目前为止,还没有人能够解释我们是如何做到的,至少不是以算法的形式。
稍后你将有机会为各种问题设计简单的算法。
类(class):之前,我将类定义为相关方法的集合。在本章中,我们了解到类定义也是一种新型对象的模板。
实例(instance):类的成员。每个对象都是某个类的实例。
构造器(constructor):一种特殊的初始化新构造对象的实例变量的方法。
项目(project):一个或多个类定义(每个文件一个)的集合,构成一个程序。
启动类(startup class):包含程序开始执行的 main 方法的类。
函数(function):一种其结果仅取决于其参数且除了返回值之外没有副作用的方法。
函数式编程风格(functional programming style):一种程序设计风格,其中大多数方法都是函数。
修改器(modifier):一种更改其作为参数接收的一个或多个对象的方法,通常返回 void。
填充方法(fill-in method):一种方法类型,它以一个空对象作为参数,并填充其实例变量,而不是生成返回值。这种方法类型通常不是最佳选择。
算法(algorithm):一组通过机械过程解决一类问题的指令。