Java 之道/结果方法
我们使用的一些内置方法,例如 Math 函数,已经产生了结果。也就是说,调用方法的效果是生成一个新值,我们通常将其分配给一个变量或用作表达式的部分。例如
double e = Math.exp (1.0);
double height = radius * Math.sin (angle);
但到目前为止,我们编写的所有方法都是 void 方法;也就是说,不返回值的方法。当调用 void 方法时,它通常单独在一行,没有赋值
nLines (3);
g.drawOval (0, 0, width, height);
在本章中,我们将编写返回值的方法,我将称之为结果方法,因为没有更好的名字。第一个例子是 area,它接受一个 double 作为参数,并返回半径为给定值的圆的面积
public static double area (double radius)
double area = Math.PI * radius * radius;
return area;
你应该注意到的第一件事是方法定义的开头不同。我们看到的是 public static double,而不是 public static void,它表示 void 方法,它表示此方法的返回值将具有 double 类型。我还没有解释 public static 的含义,但请耐心等待。
另外,请注意,最后一行是 return 语句的另一种形式,它包含一个返回值。该语句表示,立即从该方法返回,并使用以下表达式作为返回值。你提供的表达式可以任意复杂,因此我们可以更简洁地编写该方法
public static double area (double radius)
return Math.PI * radius * radius;
另一方面,像 area 这样的临时变量通常可以使调试更容易。无论哪种情况,return 语句中表达式的类型都必须与方法的返回类型匹配。换句话说,当你声明返回类型为 double 时,你承诺该方法最终将生成一个 double。如果你尝试不带表达式返回,或者表达式类型错误,编译器会提醒你。
有时在条件的每个分支中使用多个 return 语句是有用的
public static double absoluteValue (double x)
if (x < 0)
return -x;
else
return x;
由于这些 return 语句在备用条件中,因此只执行其中一个。虽然在方法中使用多个 return 语句是合法的,但你应该记住,一旦执行其中一个,方法就会终止,不再执行任何后续语句。
在 return 语句之后出现的代码,或者任何其他无法执行的地方,都称为死代码。一些编译器会在你的代码中出现死代码时提醒你。
如果你在条件中放置 return 语句,那么你必须保证程序的每条可能路径都遇到 return 语句。例如
public static double absoluteValue (double x)
if (x < 0)
return -x;
else if (x > 0)
return x; // WRONG!!
此程序是非法的,因为如果 x 恰好为 0,那么这两个条件都不会为真,方法将结束而不会遇到 return 语句。典型的编译器消息将是 absoluteValue 中需要 return 语句,这在已经存在两个 return 语句的情况下是一个令人困惑的消息。
此时,你应该能够查看完整的 Java 方法并判断它们的作用。但可能还不清楚如何编写它们。我将建议一种我称为增量开发的技术。
例如,假设你要找到两点之间的距离,由坐标给出。根据通常的定义
distance = sqrt((x_2 - x_1)^2 + (y_2 - y_1)^2)
第一步是考虑距离方法在 Java 中应该是什么样子。换句话说,输入(参数)是什么,输出(返回值)是什么。
在这种情况下,两点是参数,用四个 double 表示它们是自然的,尽管我们稍后会看到 Java 中有一个 Point 对象,我们可以使用它。返回值是距离,它将具有 double 类型。
我们已经可以编写该方法的概要
public static double distance
(double x1, double y1, double x2, double y2)
return 0.0;
语句 return 0.0; 是一个占位符,对于编译程序是必需的。显然,在这个阶段,程序没有做任何有用的事情,但编译它是值得的,这样我们就可以在使程序更复杂之前识别任何语法错误。
为了测试新方法,我们必须用示例值调用它。在 main 中的某个地方,我会添加
double dist = distance (1.0, 2.0, 4.0, 6.0);
我选择这些值是为了使水平距离为 3,垂直距离为 4;这样,结果将为 5(3-4-5 三角形的斜边)。当你测试方法时,知道正确答案是有用的。
一旦我们检查了方法定义的语法,我们就可以开始逐行添加代码。在每次增量更改之后,我们重新编译并运行程序。这样,在任何时候,我们都知道错误一定在最后添加的那一行。
计算的下一步是找到 x_1 和 x_2 之间的差,以及 y_1 和 y_2 之间的差。我将把这些值存储在名为 dx 和 dy 的临时变量中。
public static double distance
(double x1, double y1, double x2, double y2)
double dx = x2 - x1;
double dy = y2 - y1;
System.out.println ("dx is " + dx);
System.out.println ("dy is " + dy);
return 0.0;
我添加了打印语句,它们将让我在继续之前检查中间值。正如我提到的,我已经知道它们应该分别是 3.0 和 4.0。
当方法完成时,我将删除打印语句。这样的代码称为脚手架,因为它有助于构建程序,但不是最终产品的一部分。有时最好保留脚手架,但将其注释掉,以防以后需要。
开发的下一步是对 dx 和 dy 求平方。我们可以使用 Math.pow 方法,但简单快捷的方法是将每一项乘以自身。
public static double distance
(double x1, double y1, double x2, double y2)
double dx = x2 - x1;
double dy = y2 - y1;
double dsquared = dx*dx + dy*dy;
System.out.println ("dsquared is " + dsquared);
return 0.0;
同样,我将在此时编译并运行程序,并检查中间值(应该为 25.0)。
最后,我们可以使用 Math.sqrt 方法来计算并返回结果。
public static double distance
(double x1, double y1, double x2, double y2)
double dx = x2 - x1;
double dy = y2 - y1;
double dsquared = dx*dx + dy*dy;
double result = Math.sqrt (dsquared);
return result;
然后在 main 中,我们应该打印并检查结果的值。
随着你获得更多编程经验,你可能会发现自己一次编写和调试不止一行代码。然而,这种增量开发过程可以为你节省很多调试时间。
该过程的关键方面是
- 从一个可运行的程序开始,进行小的增量更改。在任何时候,如果出现错误,你都会知道错误的确切位置。
- 使用临时变量来保存中间值,以便你可以打印和检查它们。
- 程序运行后,你可能想要删除一些脚手架,或者将多个语句合并到复合表达式中,但前提是不会使程序难以阅读。
正如你所料,一旦你定义了一个新方法,你就可以将其用作表达式的部分,并且可以使用现有方法构建新方法。例如,如果有人给你两个点,圆心和圆周上的一个点,并要求你求出圆的面积怎么办?
假设圆心坐标存储在变量 xc 和 yc 中,圆周上的一个点坐标存储在 xp 和 yp 中。第一步是找到圆的半径,即两点之间的距离。幸运的是,我们有一个名为 distance 的方法可以做到这一点。
double radius = distance (xc, yc, xp, yp);
第二步是根据半径计算圆的面积并返回。
double area = area (radius);
return area;
将所有步骤封装到一个方法中,我们得到以下代码:
public static double fred
(double xc, double yc, double xp, double yp)
double radius = distance (xc, yc, xp, yp);
double area = area (radius);
return area;
这个方法的名字是 fred,可能看起来很奇怪。我会在下节解释原因。
临时变量 radius 和 area 对开发和调试很有用,但一旦程序正常工作,我们可以通过组合方法调用使它更简洁:
public static double fred (double xc, double yc, double xp, double yp)
return area (distance (xc, yc, xp, yp));
方法重载
[edit | edit source]在上一节中,你可能已经注意到 fred 和 area 执行了类似的功能——计算圆的面积——但接受不同的参数。对于 area,我们必须提供半径;对于 fred,我们提供两个点坐标。
如果两个方法做相同的事情,给它们相同的名称是自然而然的。换句话说,如果 fred 被称为 area 会更有意义。
在 Java 中,使用相同名称的多个方法,称为方法重载,是合法的,只要每个版本接受不同的参数。因此,我们可以继续重命名 fred:
public static double area (double x1, double y1, double x2, double y2)
return area (distance (xc, yc, xp, yp));
当你调用重载方法时,Java 会根据你提供的参数来确定你要调用哪个版本。如果你写:
double x = area (3.0);
Java 会查找名为 area 的方法,该方法接受一个 double 类型的参数,因此它会使用第一个版本,并将参数解释为半径。如果你写:
double x = area (1.0, 2.0, 4.0, 6.0);
Java 会使用 area 的第二个版本。更令人惊叹的是,area 的第二个版本实际上调用了第一个版本。
许多内置的 Java 命令都是重载的,这意味着它们有不同的版本,接受不同数量或类型的参数。例如,print 和 println 有接受任何类型单个参数的版本。在 Math 类中,abs 有一个针对 double 的版本,还有一个针对 int 的版本。
尽管方法重载是一个有用的功能,但应该谨慎使用。如果你试图调试一个方法版本,而意外地调用了另一个版本,你可能会让自己陷入混乱。
实际上,这提醒了我调试的黄金法则之一:确保你正在查看的程序版本是正在运行的程序版本!有时你可能会发现自己对程序进行一个接一个的更改,但每次运行时都看到相同的结果。这是一个警告信号,表明由于某种原因,你没有运行你认为你正在运行的程序版本。要检查,请插入一个 print 语句(打印的内容无关紧要),并确保程序的行为相应改变。
布尔表达式
[edit | edit source]我们已经看到的大多数操作产生的结果与其操作数类型相同。例如,+ 操作符接受两个 int 并产生一个 int,或接受两个 double 并产生一个 double,等等。
关系运算符
[edit | edit source]我们遇到的例外是关系运算符,它们比较 int 和 float,并返回 true 或 false。true 和 false 是 Java 中的特殊值,它们共同构成一种称为布尔类型的类型。你可能还记得,当我定义一种类型时,我说它是一组值。对于 int、double 和 String 来说,这些集合非常大。对于布尔值来说,就不是那么大了。
布尔表达式和变量的工作方式与其他类型的表达式和变量相同:
boolean fred;
fred = true;
boolean testResult = false;
第一个示例是一个简单的变量声明;第二个示例是一个赋值,第三个示例是声明和赋值的组合,有时称为初始化。true 和 false 值是 Java 中的关键字,因此它们可能显示为不同的颜色,具体取决于你的开发环境。
初始化
[edit | edit source]正如我提到的,条件运算符的结果是一个布尔值,因此你可以将比较的结果存储在一个变量中:
boolean evenFlag = (n
boolean positiveFlag = (x > 0); // true if x is positive
然后在以后将其用作条件语句的一部分:
if (evenFlag)
System.out.println ("n was even when I checked it");
以这种方式使用的变量通常称为标志,因为它标志着某个条件的存在或不存在。
逻辑运算符
[edit | edit source]Java 中有三个逻辑运算符:AND、OR 和 NOT,分别用符号 &&、|| 和 ! 表示。这些运算符的语义(含义)与其在英语中的含义相似。例如,(x > 0) && (x < 10) 仅当 x 大于零 AND 小于 10 时才为真。
语义
[edit | edit source]evenFlag n3 == 0 为真,如果其中任何一个条件为真,即如果 evenFlag 为真 OR 该数字能被 3 整除。
最后,NOT 运算符的作用是对布尔表达式取反或反转,因此 !evenFlag 为真,如果 evenFlag 为假——如果该数字为奇数。
嵌套结构
[edit | edit source]逻辑运算符通常提供一种简化嵌套条件语句的方法。例如,如何使用单个条件编写以下代码?
if (x > 0)
if (x < 10)
System.out.println ("x is a positive single digit.");
布尔方法
[edit | edit source]方法可以像其他任何类型一样返回布尔值,这在将复杂测试隐藏在方法内部时通常很方便。例如:
public static boolean isSingleDigit (int x)
if (x >= 0 && x < 10)
return true;
else
return false;
这个方法的名字是 isSingleDigit。通常将布尔方法命名为听起来像是非问题的方法。返回值类型为 boolean,这意味着每个 return 语句都必须提供一个布尔表达式。
代码本身很简单,虽然它比需要的要长一些。请记住,表达式 x >= 0 && x < 10 的类型为布尔值,因此直接返回它,并避免使用 if 语句是完全没有问题的:
public static boolean isSingleDigit (int x)
return (x >= 0 && x < 10);
在 main 中,你可以像往常一样调用此方法:
boolean bigFlag = !isSingleDigit (17);
System.out.println (isSingleDigit (2));
第一行仅当 17 不是一位数时,才将值 true 赋给 bigFlag。第二行打印 true,因为 2 是一个一位数。是的,println 也重载以处理布尔值。
布尔方法最常见的用途是在条件语句中:
if (isSingleDigit (x))
System.out.println ("x is little");
else
System.out.println ("x is big");
更多递归
[edit | edit source]现在我们有了返回值的函数,你可能会想知道,我们已经拥有了一个完整的编程语言,我的意思是,任何可以计算的东西都可以用这种语言表达。任何编写过的程序都可以使用我们到目前为止使用的语言特性进行重写(实际上,我们需要一些命令来控制键盘、鼠标、磁盘、邪恶的机器人等等,但这只是所有)。
图灵,艾伦
[edit | edit source]证明这一论点是一个非平凡的练习,首先由艾伦·图灵完成,他是第一批计算机科学家之一(嗯,有些人会争辩说他是数学家,但许多早期的计算机科学家都是从数学家开始的)。因此,它被称为图灵论题。如果你修了计算理论课程,你将有机会看到证明。
为了让你了解到目前为止我们所学习的工具可以做什么,让我们来看一些评估递归定义的数学函数的方法。递归定义类似于循环定义,因为定义包含对所定义事物的引用。真正的循环定义通常没有用
frabjuous:一个形容词,用来描述一些很棒的东西。
如果你在字典里看到了这个定义,你可能会感到厌烦。另一方面,如果你查了数学函数阶乘的定义,你可能会得到类似这样的东西
eqnarray* && 0! = 1 && n! = n (n-1)! eqnarray*
(阶乘通常用符号表示,不要与 Java 逻辑运算符 ! 混淆,它表示 NOT。)这个定义说 0 的阶乘是 1,任何其他值的阶乘是乘以的阶乘。所以是 3 乘以,是 2 乘以,是 1 乘以。把它们都加起来,我们得到等于 3 乘以 2 乘以 1 乘以 1,也就是 6。
如果你可以写出某事物的递归定义,你通常可以写一个 Java 程序来评估它。第一步是确定这个函数的参数是什么,返回值类型是什么。经过一番思考,你应该得出结论,阶乘函数接受一个整数作为参数,并返回一个整数
public static int factorial (int n)
如果参数恰好为零,我们只需要返回 1
public static int factorial (int n)
if (n == 0)
return 1;
否则,这是有趣的部分,我们必须进行递归调用以找到的阶乘,然后将其乘以。
public static int factorial (int n)
if (n == 0)
return 1;
else
int recurse = factorial (n-1);
int result = n * recurse;
return result;
如果我们查看这个程序的执行流程,它类似于上一章中的 nLines。如果我们用值 3 调用阶乘函数
过程
- 由于 3 不为零,我们采用第二个分支并计算的阶乘...
- 由于 2 不为零,我们采用第二个分支并计算的阶乘...
- 由于 1 不为零,我们采用第二个分支并计算的阶乘...
- 由于 0 为零,我们采用第一个分支并立即返回 1,而无需进行任何进一步的递归调用。
- 返回值(1)乘以 n,即 1,并将结果返回。
- 返回值(1)乘以 n,即 2,并将结果返回。
- 返回值(2)乘以 n,即 3,并将结果 6 返回给 main 或任何调用了 factorial(3)的人。
注意,在阶乘函数的最后一个实例中,局部变量 recurse 和 result 不存在,因为当 n=0 时,创建它们的代码分支不会执行。
跟踪执行流程是阅读程序的一种方法,但正如你在上一节中看到的,它很快就会变得像迷宫一样。另一种方法是我称之为“信念的飞跃”。当你遇到一个方法调用时,与其跟踪执行流程,不如假设该方法能正确工作并返回适当的值。
实际上,当你使用内置方法时,你已经实践了这种信念的飞跃。当你调用 Math.cos 或 drawOval 时,你不会检查这些方法的实现。你只是假设它们能正常工作,因为编写内置类的人都是优秀的程序员。
同样地,当你调用你自己的方法时,也是如此。例如,在布尔值那一节中,我们编写了一个名为 isSingleDigit 的方法,用于确定一个数字是否介于 0 到 9 之间。一旦我们通过测试和检查代码,确信该方法是正确的,我们就可以使用该方法,而无需再查看代码。
递归程序也是如此。当你遇到递归调用时,与其跟踪执行流程,不如假设递归调用能正常工作(产生正确的结果),然后问问自己:假设我可以找到的阶乘,我是否可以计算的阶乘?在这种情况下,很明显,你可以通过乘以来计算。
当然,在还没有完成编写程序的情况下就假设该方法能正常工作,这有点奇怪,但这正是它被称为“信念的飞跃”的原因!
在前面的例子中,我使用了临时变量来详细说明步骤,并使代码更容易调试,但我可以节省几行代码
public static int factorial (int n)
if (n == 0)
return 1;
else
return n * factorial (n-1);
从现在起,我倾向于使用更简洁的版本,但我建议你在开发代码时使用更明确的版本。当你完成代码时,如果你有灵感,可以把它压缩一下。
在阶乘函数之后,递归定义的经典数学函数是斐波那契数列,它有以下定义
eqnarray* && fibonacci(0) = 1 && fibonacci(1) = 1 && fibonacci(n) = fibonacci(n-1) + fibonacci(n-2); eqnarray*
翻译成 Java 代码,就是
public static int fibonacci (int n)
if (n == 0 || n == 1)
return 1;
else
return fibonacci (n-1) + fibonacci (n-2);
如果你试图跟踪执行流程,即使对于相当小的 n 值,你的脑袋也会爆炸。但根据信念的飞跃,如果我们假设这两个递归调用(是的,你可以进行两次递归调用)能正常工作,那么很明显,我们可以通过将它们加在一起得到正确的结果。
- 返回值类型方法声明中指示方法返回的值类型的那一部分。
- 返回值方法调用作为结果提供的值。
- 死代码程序中永远不会执行的部分,通常是因为它出现在 return 语句之后。
- 脚手架程序开发期间使用的代码,但不是最终版本的一部分。
- void一种特殊的返回值类型,指示一个 void 方法;也就是说,一个不返回值的方法。
- 重载拥有多个具有相同名称但参数不同的方法。当您调用重载方法时,Java 会通过查看您提供的参数来确定使用哪个版本。
- 布尔值一种类型的变量,只能包含两个值 true 和 false。
- 标志一个变量(通常是布尔值),用于记录条件或状态信息。
- 条件运算符一种运算符,用于比较两个值,并生成一个布尔值,指示操作数之间的关系。
- 逻辑运算符一种运算符,用于组合布尔值并生成布尔值。
- 初始化一条语句,同时声明一个新变量并为其赋值。