Java 之道/迭代
我还没有对此多说,但在 Java 中,对同一个变量进行多次赋值是合法的。第二次赋值的效果是用新值替换变量的旧值。
int fred = 5;
System.out.print (fred);
fred = 7;
System.out.println (fred);
该程序的输出为 57,因为第一次打印 fred 时,其值为 5,而第二次打印时其值为 7。
这种多重赋值是我将变量描述为值容器的原因。当您将值分配给变量时,您会更改容器的内容。
当对一个变量进行多次赋值时,尤其要注意区分赋值语句和等式语句。由于 Java 使用 = 符号进行赋值,因此很容易将像 a = b 这样的语句解释为等式语句。 (a==b) 是一个等式语句!
首先,等式是可交换的,而赋值不是。例如,在数学中,如果 a = b 则 b = a。但在 Java 中,a = 7 是一个合法的赋值语句,而 7 = a;则不是。
此外,在数学中,等式语句始终为真。如果现在 a==b,那么 a 将永远等于 b。然而,在计算机程序中,变量本质上是不一致的。在 Java 中,赋值语句可以使两个变量相等,但它们不必保持相等!
int a = 5;
int b = a; // a and b are now equal
a = 3; // a and b are no longer equal
第三行更改了 a 的值,但没有更改 b 的值,因此它们不再相等。在许多编程语言中,使用另一种符号进行赋值,例如 <- 或 :=,以避免这种混淆。
虽然多重赋值经常很有用,但您应该谨慎使用它。如果变量的值在程序的不同部分不断变化,它会使代码难以阅读和调试。
计算机经常被用来做的事情之一就是自动执行重复的任务。重复相同或类似的任务而不犯错误是计算机擅长的,而人类则不擅长。
我们已经看到了使用递归来执行重复的程序,例如 nLines 和 countdown。这种类型的重复称为迭代,Java 提供了一些语言特性,可以更轻松地编写迭代程序。
我们将要介绍的两个特性是 while 语句和 for 语句。
使用 while 语句,我们可以重写 countdown
public static void countdown (int n)
{
while (n > 0)
{
System.out.println (n);
n = n - 1;
}
System.out.println ("Blastoff!");
}
您几乎可以像读英语一样阅读 while 语句。这意味着,当 n 大于零时,继续打印 n 的值,然后将 n 的值减 1。当您得到零时,打印“Blastoff!”
更正式地说,while 语句的执行流程如下
- 计算括号中的条件,得到真或假。
- 如果条件为假,则退出 while 语句,并在下一条语句处继续执行。
- 如果条件为真,则执行方括号之间的每个语句,然后返回步骤 1。
这种类型的流程称为循环,因为第三步循环回到顶部。请注意,如果条件在第一次遍历循环时为假,则循环内的语句将永远不会执行。循环内的语句有时称为循环体。
循环体应该更改一个或多个变量的值,以便最终条件变为假,循环终止。否则,循环将永远重复,这称为无限循环。计算机科学家永远乐此不疲的一个观察结果是,洗发水上的说明“打泡、冲洗、重复”是一个无限循环。
对于 countdown,我们可以证明循环将终止,因为我们知道 n 的值是有限的,并且我们可以看到 n 的值在每次遍历循环(每次迭代)时都会变小,因此最终我们必须得到零。在其他情况下,则不那么容易判断
public static void sequence (int n)
{
while (n != 1)
{
System.out.println (n);
if (n % 2 == 0)
{
n = n / 2;
}
else // n is odd
{
n = n * 3 + 1;
}
}
}
该循环的条件为 n != 1,因此循环将持续进行,直到 n 为 1,这将使条件变为假。
在每次迭代中,程序打印 n 的值,然后检查它是偶数还是奇数。如果它是偶数,则将 n 的值除以 2。如果它是奇数,则将该值替换为 n * 3 + 1。例如,如果起始值(传递给 sequence 的参数)为 3,则生成的序列为:3、10、5、16、8、4、2、1。
由于 n 有时会增加,有时会减少,因此没有明显的证明表明 n 会最终达到 1,或者程序会终止。对于 n 的某些特定值,我们可以证明终止。例如,如果起始值为 2 的幂,则 n 的值在每次遍历循环时都将为偶数,直到我们得到 1。前面的示例以这样的序列结束,从 16 开始。
撇开特定值不谈,有趣的问题是我们是否可以证明该程序对所有 n 值都会终止。到目前为止,还没有人能够证明或反驳它!
循环擅长的一件事是生成和打印表格数据。例如,在计算机尚未普及之前,人们不得不手工计算对数、正弦和余弦以及其他常见数学函数。
为了简化操作,人们会使用包含大量表格的书籍,您可以在其中找到各种函数的值。创建这些表格既缓慢又无聊,结果往往充满了错误。
当计算机出现时,最初的反应之一是,这太好了!我们可以使用计算机生成表格,这样就不会出现错误。事实证明这确实是正确的(大部分),但这目光短浅。此后不久,计算机(和计算器)变得无处不在,表格便变得过时了。
好吧,几乎是过时了。事实证明,对于某些操作,计算机使用值表来获得近似答案,然后执行计算以改进近似值。在某些情况下,基础表格中存在错误,最著名的例子是原始英特尔奔腾用于执行浮点除法的表格。
虽然对数表不再像以前那么有用,但它仍然是一个迭代的好例子。以下程序在左列打印一系列值,在右列打印其对数
double x = 1.0;
while (x < 10.0)
{
System.out.println (x + " " + Math.log(x));
x = x + 1.0;
}
该程序的输出为
1.0 0.0 2.0 0.6931471805599453 3.0 1.0986122886681098 4.0 1.3862943611198906 5.0 1.6094379124341003 6.0 1.791759469228055 7.0 1.9459101490553132 8.0 2.0794415416798357 9.0 2.1972245773362196
看看这些值,您能判断出 log 函数默认使用的基数吗?
由于 2 的幂在计算机科学中非常重要,因此我们通常希望找到以 2 为底的对数。为此,我们必须使用以下公式
_2 x = log_e x / log_e 2
将打印语句更改为
System.out.println (x + " " + Math.log(x) / Math.log(2.0));
得到
1.0 0.0 2.0 1.0 3.0 1.5849625007211563 4.0 2.0 5.0 2.321928094887362 6.0 2.584962500721156 7.0 2.807354922057604 8.0 3.0 9.0 3.1699250014423126
我们可以看到,1、2、4 和 8 是 2 的幂,因为它们以 2 为底的对数是整数。如果我们想找到其他 2 的幂的对数,我们可以这样修改程序
double x = 1.0;
while (x < 100.0)
{
System.out.println (x + " " + Math.log(x) / Math.log(2.0));
x = x * 2.0;
}
现在我们不是在每次遍历循环时将某值加到 x 上,这会产生一个算术序列,而是将 x 乘以某值,这会产生一个几何序列。结果是
1.0 0.0 2.0 1.0 4.0 2.0 8.0 3.0 16.0 4.0 32.0 5.0 64.0 6.0
对数表可能不再有用,但对于计算机科学家来说,了解 2 的幂非常重要!有空的时候,您应该记住 2 的幂,一直到 65536(即 2^16)。
二维表格是一个表格,您可以在其中选择一行和一列,然后读取交点处的数值。乘法表就是一个很好的例子。假设您想打印 1 到 6 的乘法表。
一个好的开始方法是编写一个简单的循环,在同一行上打印 2 的倍数。
int i = 1;
while (i <= 6)
{
System.out.print (2 * i + " ");
i = i + 1;
}
System.out.println ("");
第一行初始化一个名为 i 的变量,它将充当循环计数器。随着循环执行,i 的值从 1 增加到 6,然后当 i 为 7 时,循环终止。在每次遍历循环时,我们打印 2*i 的值,后面跟三个空格。由于我们使用的是 print 命令而不是 println,因此所有输出都出现在同一行上。
正如我在打印部分中提到的,在某些环境中,print 的输出会存储起来,直到调用 println 才会显示。如果程序终止,而您忘记调用 println,则您可能永远不会看到存储的输出。
该程序的输出为
2 4 6 8 10 12
到目前为止,一切都很好。下一步是封装和泛化。
封装通常是指将一段代码包装在一个方法中,这样就可以利用方法的各种优势。我们在第 alternative 节的 printParity 和第 boolean 节的 isSingleDigit 中看到了封装的两个例子。
泛化是指将一些特定的东西,比如打印 2 的倍数,变成更一般的,比如打印任何整数的倍数。
以下是一个封装了上一节循环并泛化它以打印 n 的倍数的方法。
public static void printMultiples (int n)
{
int i = 1;
while (i <= 6)
{
System.out.print (n * i + " ");
i = i + 1;
}
System.out.println ();
}
要进行封装,我所要做的就是添加第一行,它声明了方法名、参数和返回值类型。要进行泛化,我所要做的就是用参数 n 替换值 2。
如果我用参数 2 调用此方法,我会得到与之前相同的输出。如果参数为 3,输出将是
3 6 9 12 15 18
如果参数为 4,输出将是
4 8 12 16 20 24
现在你可能已经猜到我们将如何打印乘法表:我们将用不同的参数重复调用 printMultiples。事实上,我们将使用另一个循环来遍历行。
int i = 1;
while (i <= 6)
{
printMultiples (i);
i = i + 1;
}
首先,请注意这个循环与 printMultiples 中的循环有多么相似。我所做的只是用方法调用替换了打印语句。
该程序的输出为
1 2 3 4 5 6 2 4 6 8 10 12 3 6 9 12 15 18 4 8 12 16 20 24 5 10 15 20 25 30 6 12 18 24 30 36
这是一个(略显混乱的)乘法表。如果你不喜欢这种混乱,Java 提供了一些方法,可以让你更好地控制输出的格式,但我在这里就不再赘述了。
在上一节中,我提到了方法的各种优势。现在你可能想知道这些优势到底是什么。以下是一些方法有用的原因
- 通过给一系列语句命名,使你的程序更易于阅读和调试。
- 将一个长程序分成多个方法,可以让你分离程序的不同部分,独立地调试它们,然后将它们组合成一个整体。
- 方法有利于递归和迭代。
- 设计良好的方法通常对许多程序都有用。一旦你编写并调试了一个方法,你就可以重复使用它。
为了再次演示封装,我将上一节的代码包装在一个方法中
public static void printMultTable ()
{
int i = 1;
while (i <= 6)
{
printMultiples (i);
i = i + 1;
}
}
我演示的过程是一个常见的开发计划。你通过在 main 或其他地方添加代码行来逐步开发代码,然后在代码运行后,将其提取出来并包装在一个方法中。
这样做的好处是,你开始编写代码时,有时并不知道如何将程序分成方法。这种方法允许你边设计边开发。
现在你可能想知道我们如何在 printMultiples 和 printMultTable 中使用同一个变量 i。我之前不是说过你只能声明一个变量一次吗?难道在一个方法改变变量的值不会导致问题吗?
这两个问题的答案都是否,因为 printMultiples 中的 i 和 printMultTable 中的 i 不是同一个变量。它们有相同的名称,但它们不指向相同的存储位置,改变其中一个的值不会影响另一个。
在方法定义中声明的变量称为局部变量,因为它们是其所在方法的局部变量。你不能从方法外部访问局部变量,并且你可以自由地拥有多个具有相同名称的变量,只要它们不在同一个方法中即可。
在不同的方法中使用不同的变量名称通常是一个好主意,以避免混淆,但也有很好的理由重复使用名称。例如,通常使用 i、j 和 k 作为循环变量。如果你仅仅因为在其他地方使用过它们而避免在一个方法中使用它们,你可能会使程序更难阅读。
作为泛化的另一个例子,想象一下,你想要一个程序,它可以打印任意大小的乘法表,而不仅仅是 6x6 的表。你可以给 printMultTable 添加一个参数
public static void printMultTable (int high)
{
int i = 1;
while (i <= high)
{
printMultiples (i);
i = i + 1;
}
}
我用参数 high 替换了值 6。如果我用参数 7 调用 printMultTable,我会得到
1 2 3 4 5 6 2 4 6 8 10 12 3 6 9 12 15 18 4 8 12 16 20 24 5 10 15 20 25 30 6 12 18 24 30 36 7 14 21 28 35 42
这样就可以了,但我可能希望表格是正方形的(行和列的数量相同),这意味着我必须给 printMultiples 添加另一个参数,以指定表格应该有多少列。
为了惹你生气,我也将这个参数命名为 high,这说明不同的方法可以具有相同名称的参数(就像局部变量一样)
public static void printMultiples (int n, int high)
{
int i = 1;
while (i <= high)
{
System.out.print (n*i + " ");
i = i + 1;
}
newLine ();
}
public static void printMultTable (int high)
{
int i = 1;
while (i <= high)
{
printMultiples (i, high);
i = i + 1;
}
}
<syntaxhighlight lang="java">
Notice that when I added a new parameter, I had to change the first line of the method (the interface or prototype), and I also had to change the place where the method is invoked in printMultTable. As expected, this program generates a square 7x7 table:
1 2 3 4 5 6 7
2 4 6 8 10 12 14
3 6 9 12 15 18 21
4 8 12 16 20 24 28
5 10 15 20 25 30 35
6 12 18 24 30 36 42
7 14 21 28 35 42 49
When you generalize a method appropriately, you often find that the resulting program has capabilities you did not intend. For example, you might notice that the multiplication table is symmetric, because n*i==i*n, so all the entries in the table appear twice. You could save ink by printing only half the table. To do that, you only have to change one line of printMultTable. Change:
<syntaxhighlight lang="java">
printMultiples (i, high);
<syntaxhighlight>
to:
<syntaxhighlight lang="java">
printMultiples (i, i);
然后你会得到
1 2 4 3 6 9 4 8 12 16 5 10 15 20 25 6 12 18 24 30 36 7 14 21 28 35 42 49
我会让你自己弄清楚它是如何工作的。
- 循环 一条语句,它会重复执行,直到满足某个条件。
- 无限循环 条件始终为真的循环。
- 主体 循环内的语句。
- 迭代 循环主体的一次遍历(执行),包括条件的评估。
- 封装 将一个大型复杂程序划分为组件(如方法),并隔离这些组件(例如,使用局部变量)。
- 局部变量 在方法内部声明的变量,并且只在该方法内存在。局部变量不能从其所在方法的外部访问,也不会干扰任何其他方法。
- 泛化 用适当的一般性东西(如变量或参数)替换不必要的特定性(如常量值)。泛化使代码更灵活,更易于重用,有时甚至更易于编写。
- 开发计划 开发程序的过程。在本章中,我演示了一种基于开发简单、特定功能的代码,然后对其进行封装和泛化的开发风格。在距离节中,我演示了一种称为增量开发的技术。在后面的章节中,我会介绍其他开发风格。