4.1. 变量、基本类型、参数与逻辑¶
4.1.1. 变量¶
编程语言最强大的特性之一是能够定义和操作变量。 变量 是一个命名的位置,用于存储一个值。值可以是数字、文本、图像、声音以及其他类型的数据。要存储一个值,你首先必须声明一个变量。
int x = 7;
每个变量都有一个类型,该类型决定了它可以存储的值的种类。此语句是一个 声明,因为它声明了名为 x 、类型为 int 的变量,并为其赋予初始值 7。
请注意, x 是变量的任意名称。一般来说,应使用能表明变量所代表含义的名称——无论是它所持有的值,还是其扮演的角色。例如,如果你看到以下声明,大概可以猜出将存储的值:
int ageInYears = 21;
int studentID = 1234;
本例声明了两个变量,每个变量的类型均为 int。当变量名包含多个单词时,如 ageInYears ,通常将除首个单词外的每个单词首字母大写,这与方法命名方式一致。这种大小写约定称为 camel case (有时写作 CamelCase),其中大写字母在名称中形成周期性的“驼峰”。在 Java 中,如同大多数编程语言,名称区分大小写,因此 ageInYears 与 ageinyears 或 AgeInYears 并不相同。请记住,变量名始终以小写字母开头,而首字母大写则保留用于类名和接口名。
你可以为变量使用任意名称,但有大约 50 个保留字,称为关键字,你不能将其用作变量名。这些关键字包括 public 、 class 、 static 、 void 和 int ,它们由编译器用于分析程序的结构。你可以在线查找 完整关键字列表,但不必死记硬背。大多数编程编辑器提供“语法高亮”功能,可使程序的不同部分以不同颜色显示。
4.1.1.1. 变量赋值¶
现在我们已经声明了变量,接下来想要使用它们来存储值。我们通过赋值语句来实现这一点。
int ageInYears = 29; // 29 is the initial value for ageInYears
ageInYears = 21; // set ageInYears to now store 21
此示例展示了一个变量声明后跟一个赋值语句。在 Java 中,所有局部变量都必须赋予初始值,我们使用相同的 "=" 字符来表示变量声明中存储的值。然而,在声明变量之后,我们可以随时 更改 其值,方法是使用一个 赋值语句。此处,我们先写出变量名本身,接着是一个等号( = ),然后是希望该变量存储的新值。赋值语句 会改变变量中存储的值,因此下次使用该变量时,对应的值将是新的值。请记住,“变量”这一名称反映了其值可以在程序运行过程中 随时间变化(或变动) 的特性,而赋值正是改变该值的方法。
程序员可以使用诸如“设置 ageInYears 为 21”或“赋值 21 到 ageInYears ”或甚至“更改 ageInYears 为 21”的表达方式。这里的术语可能令人困惑,但其概念却很简单:
当你声明一个变量时,你创建了一个命名的存储位置,并为其赋予一个初始值。
当你给一个变量赋值时,会更新其值,用新值替换掉它原先持有的值。
按照一般规则,变量的类型必须与所赋值的类型一致。例如,无法将字符串存入 ageInYears ,因为它只能保存整数。我们之后会看到一些看似违反此规则的例子,但稍后再讨论。
变量在声明时必须始终被初始化(首次赋值),你可以在同一行上写出:
int ageInYears = 29;
4.1.1.2. 关于等号 = 的说明¶
让我们来看一下下面这个代码示例:
int a = 5;
int b = 7;
a = b;
因为 Java 使用 = 符号表示赋值,因此很容易将赋值语句 a = b 解释为等式陈述。但这并非如此!
等号具有对称性,而赋值不具有。例如,在数学中如果 a = 7 则 7 = 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 并将其初始化为值 5。第二行声明变量 b 并将其初始化为 a 的值,此时该值为 5。第三行将 a 的值更改为 3,但不会更改 b 的值,因此它们不再相等。 a 现在的值为 3,而 b 的值为 5。
4.1.1.3. 算术运算符¶
运算符是代表简单计算的符号。例如,加法运算符是 + ,减法是 - ,乘法是 * ,除法是 / 。
以下程序将一天中的时间转换为分钟数:
int hour = 11;
int minutes = 59;
int timeAsMinutes = hour * 60 + minutes;
在本程序中, hour * 60 + minutes 是一个 表达式,代表需要计算的单一数值。运算符为 * 和 + ,分别代表乘法与除法。参与运算的量称为 操作数 (这是参数的另一种说法)。当程序运行时,每个变量将被其当前值替换,随后应用运算符。
所以即使你编写了 hour * 60 + minutes ,Java 也会将其计算为 11 * 60 + 59 ,并把 timeAsMinutes 的值设为 719。表达式通常是由数字、变量和运算符组合而成的。在编译并执行时,它们会被求值以产生单一的值。还应注意的是,Java 中的运算顺序规则与数学中的相同。由于乘法的优先级高于加法,因此 11 * 60 会先于 59 被计算。
当加法(+)、减法(-)、乘法(*)和除法(/)都是编程中可使用的数学运算符时,Java 还支持另外两个你可能未曾见过的单目运算符。它们是 ++ 和 -- 。这两个运算符分别用于加一或减一,常被用来在计算过程中逐步增加或减少变量所存储的值。
4.1.1.4. 关于整数与除法的一点说明¶
加法、减法和乘法的行为符合你的预期,但除法的结果可能会让你感到意外。例如,下面的代码片段试图计算已经过去的时间占一小时的分数:
int minutes = 59;
int fractionOfHour = minutes / 60;
fractionOfHour 在此情况下将实际被赋值为 0!这一结果常令人困惑。 minutes 的值是 59,而 59 除以 60 应为 0.98333,而非 0。在本例中,由于操作数均为整数,Java 执行的是“整数除法”。根据设计,整数除法始终向零舍入,即使在下一个整数非常接近的情况下也是如此。此外, fractionOfHour 被声明为一个 int 变量,因此它 只能 存储整数值,而不能存储小数部分。
作为一种替代方案,我们可以计算百分比而非分数:
int minute = 59;
int fractionOfHour = (minute * 100) / 60;
现在, fractionOfHour 被赋值为 98。结果再次向下取整,但至少此时已大致正确。
4.1.1.5. 一种新类型:双精度浮点数¶
虽然上述示例均使用了数据类型 int ,但我们也可以使用变量来存储不仅仅是整数值的内容。
创建准确 fractionOfHour 变量的更通用解法是使用一种称为 double (双精度)的其他数据类型,该类型不仅能表示整数,还能表示分数。double 值实际上是数学意义上的 实数 的数字近似表示。你可以使用与之前类型相同的语法创建 double 变量并为其赋值:
double pi = 3.14159;
Java 在一个或多个操作数为 double 值时执行“浮点除法”。因此我们可以解决上一节中遇到的问题:
double minutes = 59.0;
double fractionOfHour = minutes / 60;
现在, fractionOfHour 被设置为值 0.9833333333333333。
尽管浮点数很有用,但它们可能会引起混淆。例如,Java 将整数值 1 与浮点数值 1.0 区分开来,即使它们看起来是同一个数字。它们属于不同的数据类型,严格来说,不允许在不同类型之间进行赋值。
以下写法非法,因为被声明的变量是 int 类型,而右侧的初始值是 double 类型:
int x = 1.1; // compiler error
很容易忘记这条规则,因为在许多情况下 Java 会自动将一种类型转换为另一种类型:
double y = 1; // legal, but bad style
上述示例应为非法,但 Java 会通过自动将 int 值 1 转换为 double 值 1.0 来允许该操作。这种宽松性虽然方便,却常给初学者带来问题。例如:
double y = 1 / 3; // common mistake
你可能会期望变量 y 获得值 0.333333,这确实是 double 类型的一个合法值。但实际它获得的却是 0.0。右边的表达式对两个整数进行除法运算,因此 Java 执行整数除法,结果为 int 值 0。该 int 值随后被隐式转换为 double ,以便用作 y 的初始值,因此赋给 y 的值是 0.0。
4.1.1.6. 使用布尔值作为变量¶
在前两个模块中学习条件语句时,你可能记得经常听到关于布尔值的内容。就像 int 和 double 一样, boolean 是 Java 提供的另一种数据类型。与 int 或 double 不同, boolean 值只能是 true 或 false 。
我们使用这些来配合 while 循环或 if 语句,例如:
if (this.hasFlower())
{
this.hop();
}
在本代码中,如果 jeroo 拥有花朵,则会向前跳跃。 hasFlower() 是一个方法,将根据 jeroo 是否拥有花朵返回 true 或 false 。
我们可以将该布尔值存储在变量中,如下所示:
boolean pocketIsFull = this.hasFlower();
这里我们声明了一个布尔变量,命名为 pocketIsFull ,并将其设置为 hasFlower() 方法返回的结果。
如果我们愿意,就可以在条件表达式中使用该变量:
boolean pocketIsFull = this.hasFlower();
if (pocketIsFull)
{
this.hop();
}
这是写出相同 if 语句的另一种方式!
此外,我们可以将一个 boolean 变量赋值给复合条件表达式的返回结果:
boolean shouldToss = this.hasFlower() && this.seesNet(AHEAD);
if (shouldToss)
{
this.toss();
}
之前我们声明并初始化了一个 int 变量,其值来自一个数学表达式。这里的情况也是一样。Java 将计算 this.hasFlower() && this.seesNet(AHEAD) ,该复合表达式将产生一个值,该值要么是 true ,要么是 false ,然后这个值将用作变量 shouldToss 的初始值。
最后,如果你只需要一个 boolean 值,可以使用关键字 true 或 false 来初始化一个 boolean 变量。
boolean x = true;
boolean y = false;
4.1.2. 布尔运算符:与、或、非及短路求值¶
条件有两种形式:简单 和 复合。简单条件是一个不包含任何其他 boolean 表达式的 boolean 表达式(例如单个传感器方法调用,如 jeroo.isWater(AHEAD) )。复合条件 是通过使用 Java 逻辑运算符将两个或多个简单条件连接而成:与( && )、或( || )和非( ! )。
4.1.2.1. Java 的逻辑运算符¶
运算符 |
含义 |
示例 |
|---|---|---|
|
逻辑与(两个条件都必须为真) |
|
|
逻辑或(至少一个条件必须为真) |
|
|
逻辑非(反转真值) |
|
4.1.2.2. 短路求值¶
Java 从左到右评估复合条件,使用 短路求值:
对于
&&(AND):如果第一个操作数求值为false,则整个条件必定为false,因此 Java 会跳过对第二个操作数的求值。对于
||(OR):如果第一个操作数求值为true,则整个条件必定为true,因此 Java 会跳过对第二个操作数的求值。
短路求值对于防止错误至关重要,例如在尝试执行操作前检查条件是否安全。
4.1.2.3. 局部变量的作用域¶
局部变量可以是非常有用的工具。你应该在 需要多次引用同一值 时使用局部变量,因为局部变量允许你为一个值赋予名称,从而可以在多个位置使用该名称。如果你只需要一次使用某个值,那么命名可能并没有太大意义。
然而,局部变量用于表示仅在单个方法内(或甚至仅在方法内的少数语句中)使用的名称。编程语言使用术语 作用域 来指代程序文本中某个名称可以被使用的区域。对于局部变量而言,每个变量声明都出现在某对大括号( { } )之内,例如包围方法主体的那对大括号。这对大括号构成了程序语句块的起始与结束。该局部变量的作用域是其声明位置与其所在语句块末尾的闭合大括号( } )之间的所有语句。该名称在该块之外将不会被识别。最重要的是,这意味着该变量声明在其他方法中不可见(无法使用)。
public void doSomeStuff()
{
int x = 17; // declared here
...
if (jeroo.getX() == 17) // OK to use here, inside scope
{
...
}
// scope of x ends at closing brace
}
public void doOtherStuff()
{
if (x == 17) // compiler error! x can't be referenced
{
...
}
}
在上述代码中,局部变量 x 在方法 doSomeStuff() 的开始处声明,因此该变量可在该方法内的任何位置使用。然而, x 的作用域在 doSomeStuff() 结束时终止,即方法结束处的右花括号所标记的位置。
这就是为什么 局部变量 被称为 局部 的原因:它们只能在程序文本的一个小范围、局部区域内使用,仅限于下一个右大括号之前。作为进一步的例子:
public void doSomeStuff()
{
...
if (jeroo.hasFlower())
{
int x = 17; // declared here
...
if (jeroo.getX() == 17) // OK to use here, inside scope
{
...
}
// scope of x ends at closing brace
}
x = 4; // compiler error! x can't be referenced
}
局部变量可以在任意一对用于标记语句序列的花括号内声明。然而,它们始终仅对该语句块 局部 有效,不能在该块外部使用。此处, x 在第一个 if 语句的 true 分支内声明,因此 x 局限于该语句块,无法在声明它的花括号对之外使用。
变量在作用域之外不可见,也无法使用。其作用域是指变量可见的文本区域。在 Java 中,作用域通常由一对花括号( { } )标记。
4.1.3. 字段与局部变量¶
编写面向对象的程序在很大程度上是设计类,并在 Java 中为这些类编写定义。设计一个类就是指定该类型对象所特有的所有属性和行为。
到目前为止,我们一直使用属性和动作这两个术语来描述对象的特征。然而,在讨论编程语言时,更常见的说法是通过字段和方法来描述对象的特征。 字段 是一类我们在类开始时定义的变量,帮助我们描述类的特性。
例如,假设我们正在编写一个绘图程序。程序中需要的一种对象是矩形。一个 Rectangle 对象具有两个关键属性:其长度和宽度。在类内部,我们可以声明两个字段来存储这些属性的值:一个 length 和一个 width 。有了这两个字段,我们就可以定义矩形的典型操作,例如计算其面积以及绘制自身的能力。识别对象的字段与操作正是开发面向对象程序时所涉及的设计活动。
我们可以声明字段在我们的 Rectangle 类中:
public class Rectangle
{
private int length;
private int width;
private int xCoord;
private int yCoord;
// other parts of the class go here ...
}
我们的矩形有四个属性。前两个, length 和 width ,存储矩形的尺寸。后两个, xCoord 和 yCoord ,存储绘制矩形时的位置信息,类似于我们确定 jeroo 在岛屿上的位置方式。请注意,这些属性均无具体数值。这是因为该类代表的是矩形的一般类型,而每个具体的矩形将拥有自己独立的属性值。声明仅指定了所有矩形共有的特征,而不代表任何一个特定的矩形。如同制作饼干的模具一般,类给出了对象的一般形状。任何具体对象的具体数值并不包含在内。
显著的是,这些字段也使用关键字 private ,表示它们只能在此类内部被访问和操作。按照惯例,我们 始终 包含关键字 private ,以确保只有类内部的代码才能访问或修改这些字段。这贯彻了一种软件工程设计原则,称为 信息隐藏,即只有类自身负责管理其相关信息,类外部的任何代码均无法干扰或更改此类内部信息。
一个字段对于不同的对象将具有不同的值——也就是说,对于类的不同实例。例如,各个 Rectangle 实例的 length 、 width 、 xCoord 和 yCoord 字段将具有不同的值。例如,我们可以创建两个不同的 Rectangle 对象:
要创建两个不同的 Rectangle 对象,我们需要一个构造函数。回想一下,构造函数的主要职责之一是初始化对象中存储的数据(即其字段中的数据)。由于我们的 Rectangle 类使用字段来存储属性值,因此构造函数需要初始化这些值。有时,你可能会将字段初始化为所有对象的固定值,但通常希望创建对象的代码能够指定初始值。如果此处我们希望实现这一点,就需要一个 Rectangle 构造函数,该构造函数接受 4 个整数作为参数:
public class Rectangle
{
private int length;
private int width;
private int xCoord;
private int yCoord;
public Rectangle(int myLength, int myWidth, int initialX, int initialY)
{
this.length = myLength;
this.width = myWidth;
this.xCoord = initialX;
this.yCoord = initialY;
}
// other parts of the class go here ...
}
然后,如果我们想要调用这个构造函数,可以说:
Rectangle rectangle1 = new Rectangle(2, 5, 0, 0);
哪一项会将 length 设为 2, width 设为 5,坐标设为 (0, 0)。
4.1.4. 修改私有变量:修改方法¶
回忆一下, Rectangle 的字段是私有的。我们可以在 Rectangle 类内部任何位置访问它们,但不能在类外部访问。
public class Runner
{
public void myProgram()
{
Rectangle rectangle1 = new Rectangle(0, 0, 2, 5);
int w = rectangle1.width; // compiler error! Can't access field
}
}
因为 length 是私有的,上述代码在编译时将导致错误。这是一种防止字段被意外修改或在不需要时被修改的便捷方式,相当于强制实现信息隐藏。信息隐藏有助于限制意外引入错误的可能性,也有助于在错误发生时缩小排查范围。此外,如果我们的 Rectangle 类将来发生变化,上述代码很可能也需要随之修改,这意味着此 Runner 类依赖于 Rectangle 类的内部细节。这会使未来的修改更加困难。因此,信息隐藏能够 减少引入错误的机会,也能 减少排查错误时需要检查的代码量,还能 降低未来修改的成本。这就是我们在此类中始终将字段声明为 private 的原因。最终,你将学习到关于字段的其他选项,但请记住,放宽信息隐藏会带来显著代价。
另一方面,这个 Runner 类可能需要访问并更改 Rectangle 类中的字段——例如,为了将其移动到新位置。为了避免直接访问字段,面向对象编程的一种惯例是:在设计合适时,为对象的某些属性提供公共方法以设置和获取其值。
获取或检索属性值的方法称为 访问器方法,或更通俗地称为 getter 方法。将对象的属性设置为新值的方法称为 修改器方法,或更通俗地称为 setter 方法。通常情况下,访问器和修改器方法会直接与对象的字段进行操作,如果属性是直接存储在字段中的。
由类的设计者根据该类所代表的抽象来决定哪些属性需要访问器方法和/或修改器方法。某些字段可能仅用于内部使用,不表示外部可见的属性。其他属性可能存储在字段中,但更适合只读使用,仅提供访问器方法。在其他情况下,类可能同时支持读取和修改属性,此时访问器和修改器都适用。
如果正在设计一个 BankAccount 类,例如,您可能希望提供一个公共的 getAccountNumber() 方法,以便客户端能够检索其银行账户的信息,但您可能不会希望提供一个公共的 setAccountNumber() 来让客户端修改该信息,也不会提供一个公共的 getAccountPassword() 用于应仅限内部使用的数据。
我们将在下面讨论访问器方法,但首先让我们看看修改器方法。对于我们的 Rectangle 示例,假设我们希望其他类能够更改 xCoord 和 yCoord 属性,以移动 Rectangle 在屏幕上的位置。修改器方法可以简单如以下所示:
public class Rectangle
{
// field declarations from above go here
public void setXCoord(int newValue)
{
this.xCoord = newValue;
}
public void setYCoord(int newValue)
{
this.yCoord = newValue;
}
}
这段代码还展示了为直接存储在字段中的任何属性提供修改方法的简单模式。该模式的关键要素是:
变量修改方法是
public,因为我们希望它可以在任何位置被访问。它的返回类型是
void,因为此方法不return任何操作。此方法通过在所更改属性名称前加上
set作为前缀来命名,使用我们复合名称的大写约定。此方法接受一个与字段类型相同的参数。
方法体使用赋值语句将字段设置为新值。
这种模式使用得如此频繁,以至于程序员在提到“设置方法”或说“为它添加一个设置方法”时,几乎总是指这种模式。
4.1.4.1. 作用域说明¶
变量修改方法的主体同时使用了字段和参数。需要注意的是,这两种变量的使用位置存在区别。记住, 作用域 指的是名称或方法在程序中可以被使用的范围。
参数的作用域是其被声明的方法体。
例如,考虑下面的 setLength() 方法:
public class Rectangle
{
private int length;
public void setLength(int len)
{
this.length = len;
}
}
在此代码中,参数名 len 无法在 setLength() 方法之外被引用。它在包围方法体的花括号({ })之外完全不可见。
另一方面,字段是在包围整个类的花括号( { } )内部声明的,因此它们的作用域是整个类——可以在类内的任何位置,包括任意方法中使用。例如,字段 length 是在 setLength() 方法之外声明的,可以在类内的任何位置访问。
记住,声明在方法体内部的局部变量的作用域从其声明位置开始,延伸到其声明所在代码块的末尾。
总而言之:
字段在类内部声明,因此其作用域为整个类。可以在类中的任何方法内引用它们。
参数在方法声明的参数列表中声明,因此其作用域为整个方法。可以在该方法体内的任意位置引用,但不能在声明它们的方法外部访问。
局部变量在由大括号包围的语句块(
{ })中声明。可以从声明位置开始,到标记该代码块结束的大括号(})为止,在该代码块内的任意位置访问它们。
4.1.5. 访问方法¶
创建访问器方法时,如果相应的属性存储在字段中,也会遵循一个非常简单的模式,尽管有一个我们之前未涉及的细节。
public class Rectangle
{
private int length;
// other field declarations from above go here
public int getLength()
{
return this.length;
}
}
此代码展示了为直接存储在字段中的任何属性提供访问方法的简单模式。该模式的关键要素是:
访问器方法为
public,因为我们希望它可以在任何位置被访问。在关键字
public之后,我们不再使用void,而是列出字段中存储的数据类型,该类型与本方法将返回的数据类型相同。该方法通过在所访问属性名称前添加
get作为前缀来命名,遵循复合名称的大写规范。该方法不接受任何参数。
该方法的主体使用
return语句返回字段的值。
这种模式使用频率极高,几乎总是程序员在提到“获取方法”或说“为其添加一个获取方法”时所指的内容。
不同之处在于返回类型不再是 void (而是字段的类型),并且用到了新的 return 关键字,我们将在下一节中讨论这一点。目前最重要的是要注意,方法的返回类型必须与字段类型一致。因此,由于 length 被定义为 int 类型,此访问器方法的返回类型也必须是 int 。
4.1.6. 返回关键字¶
在返回类型为 void 的方法之外,你在 Java 中编写的其他所有方法都必须指定一个返回类型——即该方法将返回的 答案 或 结果 的类型。到目前为止,我们主要使用了 void 方法,这类方法不返回任何结果——实际上, void 表示“无”,我们调用这些方法是出于它们执行的动作,而非计算出的值。
用来为我们计算值的方法在被调用时“返回”这些值。当我们说“返回一个值”时,意思是“给我们一个答案”或“产生一个结果”。与 void 方法相比,返回值的方法在两个方面有所不同:
它们声明了返回值的类型(返回类型)。
它们至少使用一条
return语句来提供一个 返回值。
例如,如果你要编写如下方法:
public boolean isRaining()
{
// no code yet
}
当你编译时会看到语法错误,提示“缺少返回语句”。这是因为关键字 public 后使用 boolean 指定了该方法 将 返回一个 boolean 结果,但方法体中并未说明返回什么值。要解决此问题,我们需要返回一个值。
public boolean isRaining()
{
return false;
}
方法的 return 语句用于指定调用该方法时所生成的值。在关键字 return 之后,您可以使用任意表达式,包括常量(如上文的 false )、变量、字段、数学表达式,甚至其他方法调用的组合。
语句 return 中表达式的类型必须与方法声明的返回类型相匹配。当你声明返回类型为 double 时,你实际上是在承诺该方法最终将产生一个 double 值。如果你尝试使用无表达式,或表达式类型错误的 return ,编译器将会生成错误。
4.1.6.1. 回溯至访问器方法¶
为字段编写访问方法必须具有返回类型,因为所有字段都有类型。
public class Rectangle
{
private int length;
public Rectangle()
{
this.length = 4;
}
public int getLength()
{
return this.length;
}
}
在本情境下,我们的 length 字段类型为 int ,因此该字段的访问方法也需返回一个 int 。
4.1.6.2. 编写更复杂的带返回值的方法¶
有时你可能会觉得有必要编写多个 return 语句,例如在条件语句的每个分支中各写一个:
public double absoluteValue(double x)
{
if (x < 0)
{
return -x;
}
else
{
return x;
}
}
由于这些返回语句位于条件语句中,因此只会执行其中一个。一旦其中任何一个被执行,方法立即终止,不再执行后续任何语句。当然,这意味着在到达 return 语句之后,方法的其他部分将不会被执行。此外,由于返回值的方法必须 始终 使用返回语句,无论方法以何种方式结束,因此你必须在每个分支中都包含返回语句,而不仅仅是在第一个分支中。
由于这些限制,一些初学者发现始终编写一个返回语句并将其放在方法的最后一条语句会更简单——这可以确保方法总是返回一个值,并且不会意外跳过任何重要步骤。随着技能的发展,你可能会在某些情况下发现使用多个返回语句更方便,但请始终记住它们需要更多的注意,更容易出错,因此当你遇到困难时,应退回到使用单一返回语句。
public double absoluteValue(double x)
{
double answer = x;
if (x < 0)
{
answer = -x;
}
return answer;
}
以下是一个示例: calculateArea 接受一个 double 类型的参数,并返回以该半径计算的圆的面积:
public double calculateArea(double radius)
{
// Note that Math.PI is a constant with the value of pi
double result = Math.PI * radius * radius;
return result;
}
这一行是返回语句。该语句表示:“立即从当前方法退出,并使用后面的表达式作为返回值。” 另外,请注意使用了来自内置 Java 工具类 Math 的常量 PI 。Java Math 类提供了许多常用且有用的数学函数,以及常量 PI 和 E 的定义。在 Java 中,按照惯例,程序员将常量名称全部大写,以便与变量或字段区分开来。
您提供的条目中包含未解析的占位符 return ,根据规则需保留原样放置的特殊符号占位符,不得增删。修正后重译如下:
public double calculateArea(double radius)
{
return Math.PI * radius * radius;
}
出现在 return 语句之后(在同一块内)或任何永远无法执行的位置的代码称为 死代码。如果代码的一部分是死代码,编译器会给出“不可达语句”的错误。例如,此方法包含死代码:
public double absoluteValue(double x)
{
if (x < 0)
{
return -x;
}
else
{
return x;
}
x = 5; // can never be executed
}
那最后一行, x = 5 永远不会被执行,因为方法在到达该行之前总会返回一个值(结束方法执行)。
如果在条件语句中放置 return 语句,必须确保程序的每一条可能执行路径最终都会到达一个 return 语句。如果不符合这一要求,编译器会提示你。例如,以下方法是不完整的:
public double absoluteValue(double x)
{
if (x < 0)
{
return -x;
}
else if (x > 0)
{
return x;
}
// syntax error
}
当 x 为 0 时,两个条件都不成立,因此该方法在未遇到 return 语句的情况下结束。此情况下的错误信息可能是“缺少 return 语句”,这令人困惑,因为已经存在两个 return 语句了。但希望你能理解其含义。
再次强调,如果你遇到困难,通常一个简单的解决方法是调整方法的结构,使得方法末尾仅有一个 return 语句。
4.1.6.3. 使用方法的返回结果¶
调用一个无返回值的方法时,该调用通常单独占一行。例如,以下是 Jeroo 的一个简单跳跃并采摘方法。
public void hopAndPick()
{
this.hop();
this.pick();
}
这就是它的调用方式:
this.hopAndPick();
另一方面,当你调用一个具有返回类型的函数时,必须对返回值进行处理。我们通常将其赋给变量,或作为表达式的一部分使用。
以这个方法为例:
public int square(int x)
{
return x * x;
}
此方法接收某个数字 x ,并返回该数字的平方值。
我们可以调用该方法并实例化一个新变量来保存结果:
int base = 3;
int raised = square(base);
在本情境中, raised 被设置为值 9(即计算 3 * 3 的结果)。
此外,我们可以将方法的返回结果用作另一个方法调用的参数:
int base = 3;
int raisedAgain = square(square(base));
这个新变量 raisedAgain 将被设为 81。对 square() 的调用按“由内向外”顺序求值,括号内的调用最先执行。因此,在第一个调用中, base 作为参数传递给 square() ,其返回值为 9。第一个调用的返回值 9 被用作第二个对 square() 的调用的参数值,该调用随后返回 9 * 9 = 81。
这可能会让你想起在代数课程中使用函数的情形。在那些课程中,我们可能会看到同样的概念被写成:
假设 f(x) = x * x,f(3) 的值是多少?f(f(3)) 的值是多少?
Java 使用了同样的原理,尽管数据类型多种多样,事情可能会变得稍微复杂一些。
4.1.7. 在测试中使用字段¶
上周,我们为一个Jeroo创建了一些简单的测试套件。你可能还记得,在每个测试开始时需要做大量设置工作,以确保Jeroo的位置完全符合预期,并且拥有正确数量的花朵。
然而,如果你连续有多个测试都需要相同的初始起始条件该怎么办?为了使测试过程更加高效,你可以声明 字段 而不是局部变量来保存大多数或全部测试中使用的公共对象。这样做后,就不必在每个测试方法中重复声明它们为局部变量,而是将它们声明为字段,使其对 所有 测试方法都可见,从而只需声明一次即可。
此外,与其在每个测试方法中重复编写设置初始条件的代码,不如只写一次,而不是在每个测试方法中重复。
为了了解其工作原理,请考虑软件测试模块中的这个示例测试方法:
public void testPickFlowers()
{
// 1. set up initial conditions
Lab04Island island = new Lab04Island();
FlowerPicker picker = new FlowerPicker();
island.addObject(picker, 1, 2);
// 2. call the method
picker.pickFlowers();
// 3. check expected results
assertThat(picker.getX()).isEqualTo(6);
assertThat(picker.getY()).isEqualTo(2);
assertThat(picker.getFlowers()).isEqualTo(5);
assertThat(picker.getHeading()).isEqualTo(EAST);
}
现在假设你正在编写一个包含多个测试的测试类:
public class FlowerPickerTest
extends TestCase
{
public void testPickFlowers()
{
// 1. set up initial conditions
Lab04Island island = new Lab04Island();
FlowerPicker picker = new FlowerPicker();
island.addObject(picker, 1, 2);
...
}
public void testPickFlowersAgain()
{
// 1. set up initial conditions
Lab04Island island = new Lab04Island();
FlowerPicker picker = new FlowerPicker();
island.addObject(picker, 1, 2);
// turn picker south and move forward 1
// for this test
...
}
}
此处,两个测试均使用了 island ,且均使用了 picker 。它们都必须创建 island ,创建 picker ,并将 picker 放置在 island 上。这仅仅是重复的代码。
重复的代码是 代码异味 的一个例子,也就是设计可以改进的警示信号。有时你查看代码时,会本能地觉得有更好的实现方式——这就是一种 代码异味。如果你有这种感觉,几乎可以肯定存在更好的实现方式,修改代码将使其得到改善。
首先,与其在每个方法中使用名为 island 和 picker 的局部变量,不如将它们移至字段中:
public class FlowerPickerTest
extends TestCase
{
private Lab04Island island;
private FlowerPicker picker;
public void testPickFlowers()
{
// 1. No extra setup needed
...
}
public void testPickFlowersAgain()
{
// 1. set up initial conditions
// turn picker south and move forward 1
// for this test
...
}
}
但我们在哪里放置用于创建对象的初始化代码呢?虽然构造函数似乎是个不错的选择,但构造函数仅在创建对象时执行一次。而这段初始化代码需要在我们类中的每一个测试方法中都执行,而不仅仅是一次。
在测试类中,我们使用一个名为 setUp() 的特殊方法来实现此目的(请注意大小写!)。该方法将在类中的每个测试执行前自动运行。明确地说,这意味着如果你有三个名为 test1() 、 test2() 和 test3() 的测试,那么 setUp() 方法会在 test1() 执行前运行,同时也在 test2() 之前和 test3() 之前运行。它为类中的 所有 测试方法提供通用的初始化步骤。
我们可以使用此 setUp() 方法中的字段,高效地设置初始条件。程序员通常将一个类中所有测试所使用的常见初始条件集合称为 测试夹具。
public class FlowerPickerTest
extends TestCase
{
private Lab04Island island;
private FlowerPicker picker;
public void setUp()
{
this.island = new Lab04Island();
this.picker = new FlowerPicker();
this.island.addObject(this.picker, 1, 2);
}
public void testPickFlowers()
{
// 1. No extra setup needed
...
}
public void testPickFlowersAgain()
{
// 1. set up initial conditions
// turn picker south and move forward 1
// for this test
...
}
}
注意,这些字段的写法与原始的局部变量声明略有不同。它们前面包含了关键字 private ,因为我们的所有字段都是 private 的。你永远不能在方法内部的局部变量前使用 private ,只能用于字段,但我们 始终 在字段前使用它。
另外,在 setUp() 内部可以看到,每一行开头都没有类型。我们并未在该方法内部 声明 局部变量。每当在名称前指定类型时,你实际上是在以某种方式声明一个新名称。
4.1.8. 子类中的构造函数与 super(...)¶
我们知道,当创建一个 子类 时,它会 继承 其 扩展 的类中的所有方法和属性。如果你创建一个名为 PlantingJeroo 的 Jeroo 子类,那么任何 PlantingJeroo 对象都可以执行任何 Jeroo 所知道的所有方法——因为一个 PlantingJeroo 是 Jeroo 的一种特殊类型。 PlantingJeroo 类从 Jeroo 类继承了所有方法和属性,同时也理解你编写的新方法,例如 platRowsOfFour() 方法。计算机科学家有时将这种关系称为 is-a 关系,因为每个 PlantingJeroo 对象在同时 是 Jeroo ——只不过是一个功能更强大的Jeroo。
备注
is-a 关系存在于子类和其父类之间,因为子类的每个实例同时也是父类的实例。
此外,正如我们之前所读到的, 构造函数 是一种特殊的函数,用于初始化一个全新的对象。然而,尽管子类会自动继承其父类的所有(普通)方法和属性, 却不会继承构造函数 。这意味着,在上例中对 Ali 的对象实例化实际上无法通过编译—— 除非我们为 PlantingJeroo 子类提供适当的构造函数 。
子类不会自动继承构造函数的一个原因是,子类除了新增方法外还可能添加新的属性,而这些属性必须被初始化,无论怎样都如此。但父类的任何构造函数都不了解子类新增的属性,也无法适当地对其进行初始化。因此,子类必须始终显式地定义它们所支持的每一个构造函数。
备注
每次创建子类时,你都有责任定义它支持的 所有 构造方法。构造方法不会从父类继承。
幸运的是,尽管构造函数不会被继承,但定义它们存在一种简单的模式。在我们的 PlantingJeroo 中,可以添加如下构造函数:
// ----------------------------------------------------------
/**
* Create a new Jeroo facing east.
* @param flowers The number of flowers the Jeroo is holding.
*/
public PlantingJeroo(int flowers)
{
super(flowers);
}
虽然我们尚未涵盖这段小代码中的所有特性,但其核心思想是明确的。构造函数的声明方式与普通方法类似,只是 省略了 void 一词,且其名称 与类名完全相同。此处,我们正在为我们的 PlantingJeroo 子类定义一个构造函数,该构造函数接受一个数字(整数)作为参数,表示其袋中花朵的数量。
此构造函数的主体仅包含一行,使用了特殊的 Java 关键字 super 。该关键字只能作为子类构造函数中的首个词使用,允许我们调用父类构造函数,并传递其可能需要的信息。因此,此处我们表示,在我们的 PlantingJeroo 构造函数中,第一个(也是唯一一个)操作是调用其父类( Jeroo )的构造函数,并传入花的数量。这使得父类能够根据给定信息正确初始化其所有属性。如果子类还需要更多初始化操作,我们将在子类构造函数体中后续的语句里完成。
但目前,这个构造函数已足够满足我们的 PlantingJeroo 类需求。它允许我们通过指定位置和花朵数量来创建一个 PlantingJeroo 对象。这将使我们能够顺利实例化前例中的 Ali Jeroo。
备注
继承为构造方法增加了一层新的复杂性。一个常见的错误是忘记子类构造方法必须调用父类构造方法。如果你没有显式调用 super(),Java 编译器会尝试为你插入一个无参调用,如果父类没有默认构造方法,这将失败。了解这样的陷阱与学习正确的语法同样重要;这是成为优秀程序员的关键部分。
4.1.9. 方法的更多内容¶
方法 对应一种动作或行为,是可被调用或 调用 以执行一组预先定义好的操作的命名代码块。
方法定义由两部分组成:方法头和方法体。一般来说,方法头采用以下形式,其中包含一些可选部分:
修饰符可选 返回类型 方法名(参数列表可选 )
合起来,一个方法定义可能看起来像这样:
public int addHops()
上方,该方法以访问修饰符 public 开始,用于声明此方法可被其他类访问或引用。方法头的下一部分是方法的返回类型。这是方法返回的值的类型(如果有的话)。在上方的方法声明中,我们指定该方法返回一个 int 类型的值作为结果。到目前为止,我们编写的都是如下形式的方法:
public void pickFlowersAndDisableNets()
这里不是 int ,我们看到的是关键字 void ,表示该方法不返回任何内容,仅因其执行的动作而被调用,且不期望返回某种答案。我们稍后会更详细地讨论返回类型。
在方法声明中,方法名位于方法的返回类型之后。这是调用方法时所使用的名称。我们可以随意命名方法,但不能包含空格。方法名之后是方法的 参数列表,我们将在下一节中讨论。
4.1.9.1. 使用参数传递信息¶
某些我们使用过的方法需要参数,这些参数是在调用方法时提供的值。例如,一个 Jeroo 有两个名为 'hop' 的方法。调用 hop() 会使海龟向前跳一格。然而,如果你在括号内指定一个数字,如 hop(4) ,海龟将向前跳四格。
当你使用一个方法时,你提供实参。当你编写一个方法时,你命名形参。形参列表表示需要哪些实参。
例如:
public void turnAndDisable(RelativeDirection direction)
{
this.turn(direction);
this.toss();
}
要调用此方法,我们必须提供一个相对方向作为参数:
turnAndDisable(RIGHT);
这将使 jeroo 向右转并拆除一个网。
4.1.9.2. 使用多个参数¶
以下是一个接受两个参数的方法示例:
public void turnThenHop(RelativeDirection direction, int hops)
{
this.turn(direction);
this.hop(numHops);
}
调用此方法时,我们必须提供一个整数和一个相对方向作为参数:
turnThenHop(RIGHT, 7);
这将导致小杰鲁跳过七次并转向右方。
4.1.10. 条件判断的良好习惯¶
与添加注释一样,可读性在编写条件语句时也是一个重要因素。
4.1.10.1. 逻辑非与 if-else 结构¶
在编写 if/else 语句时,通常以 ! 开头会使代码更难阅读。
if (!this.isClear(AHEAD))
{
this.toss();
}
else
{
this.hop();
}
很容易忽略上方的 ! ,从而错误理解此条件的含义。相比之下,更可取的表述方式是将其写成如下形式:
if (this.isClear(AHEAD))
{
this.hop();
}
else
{
this.toss();
}
你可以看出,从逻辑上看,这两个 if-then-else 结构实现了相同的功能,但其中一个更易于阅读。
备注
请注意,有时你可能无法在不使用 ! 运算符的情况下写出正确的条件。特别是当你没有 else 子句时,可能需要使用它,但若能避免使用则为良好实践。
4.1.10.2. 条件过多¶
另一个需要牢记的事项是避免编写过多的条件。在解决复杂问题时,可能会倾向于为遇到的每种新情况不断添加新的条件。然而,这样做不仅更难阅读,还可能引入难以后期发现的错误。
例如:
if (this.isClear(AHEAD))
{
this.hop();
}
else if (!this.isClear(AHEAD))
{
this.toss();
}
else
{
this.turn(RIGHT);
}
逻辑上,此条件语句的 if 与 else-if 分支所执行的操作与上方相同。然而,此处存在第三个分支,该分支将永远不会被执行。这是因为 jeroo 前方区域总是处于可通行或不可通行状态之一。代码在前两个选择中总会找到一个可执行的分支,因此 else 分支永远不会有适用的情况。
在计算机术语中,无论在何种可能情况下都无法被执行的代码称为 不可达代码。这类代码通常表示程序存在错误,因为其无法执行的原因往往是编程逻辑构造不当,如本例所示。前两个分支已涵盖所有可能情况,因此第三个选项是无用的。
如果你不确定两个布尔表达式是否等价,列出真值表可能会有所帮助。例如,我们可以看出下面的 b 和 !!b 是等价的。
|
|
|
|---|---|---|
真 |
假 |
真 |
假 |
真 |
假 |
无论 b 的值为何,我们都可以看出 !!b 与之匹配!
4.1.10.3. 空条件分支¶
在代码中不留下空条件也是良好的编程习惯。
if (this.isClear(AHEAD))
{
// do nothing
}
else
{
this.turn(RIGHT);
}
在有选择时,优先使用一个 if 语句而不是空的 if-else 语句。
if (this.isClear(AHEAD))
{
// do nothing
}
else
{
this.turn(RIGHT);
}
此处,使用 ! 运算符比使用空条件更可取:
if (!this.isClear(AHEAD))
{
this.turn(RIGHT);
}
4.1.10.4. 多个条件 vs 复合条件¶
看一下下面的代码片段:
if (this.isClear(AHEAD))
{
if (this.seesNet(RIGHT))
{
this.turn(RIGHT);
}
}
在这里我们看到一个条件嵌套在另一个条件之中。通常情况下,更优的做法是将相同的条件改写为如下形式:
if (this.isClear(AHEAD) && this.seesNet(RIGHT))
{
this.turn(RIGHT);
}
4.1.10.5. More Complex Conditionals¶
有时你希望检查相关条件并从多个操作中选择一个。实现这一目标的一种方法是使用我们级联的if结构:
if (molly.isFacing(NORTH))
{
molly.hop();
}
else if (molly.isFacing(SOUTH))
{
molly.hop(2);
}
else if (molly.isFacing(EAST))
{
molly.hop(3);
}
else
{
molly.hop();
molly.toss();
}
这些链可以尽可能长,尽管如果变得过于复杂,阅读起来会比较困难。
你也可以通过将一个条件语句嵌套在另一个条件语句中来做出复杂的决策。我们可以将前面的例子写成:
if (molly.isFacing(WEST))
{
molly.hop();
molly.toss();
}
else
{
if (molly.isFacing(NORTH))
{
molly.hop();
}
else if (molly.isFacing(SOUTH))
{
molly.hop(2);
}
else
{
molly.hop(3);
}
}
外部条件语句包含两个分支。第一个分支指示小罗去跳一次并投掷,第二个分支包含另一个条件语句,该语句自身又包含三个分支。
这类结构很常见,但会很快变得难以阅读。良好的缩进对于让读者清楚地理解结构(或预期的结构)至关重要。
4.1.11. 一种不同类型的复杂条件语句¶
另一种 if 语句可以变得更复杂的方式是创建更长的复合条件。
例如,
if ((caroline.isFacing(NORTH) && caroline.hasFlower())
|| caroline.seesNet(AHEAD))
此语句可推广至 if (A || B) ,其中:
A = caroline.isFacing(NORTH) && caroline.hasFlower()B = caroline.seesNet(AHEAD)
如果 jeroo 面向北方且携带花朵,或者其前方可见渔网,则此 if 语句将被触发。值得注意的是,若 jeroo 仅携带花朵,逻辑与运算将使表达式 caroline.isFacing(NORTH) && caroline.hasFlower() 为假。因此,jeroo 必须在前方看到渔网,此 if 语句才会被触发。
逻辑非也可以否定一个复合语句。
if (!(caroline.isFacing(NORTH) && caroline.hasFlower()))
记住,要使 caroline.isFacing(NORTH) && caroline.hasFlower() 为真,jeroo 必须持有花且朝向北方。只要括号内的复合条件为假, !(caroline.isFacing(NORTH) && caroline.hasFlower()) 就为真。
在观察这类复杂操作时,很容易混淆。在考虑被否定的复合条件时,根据 德摩根定律 对其进行重写可能会对你有所帮助:
!(A && B)与!A || !B相同!(A || B)与!A && !B相同
使用这个,而不是编写
if (!(caroline.isFacing(NORTH) && caroline.hasFlower()))
它在逻辑上等价于写作:
if (!caroline.isFacing(NORTH) || !caroline.hasFlower())
再次,如果我们使用真值表,可以看出这两列是匹配的:
|
|
|
|
|
|
|
|---|---|---|---|---|---|---|
真 |
真 |
真 |
假 |
假 |
假 |
假 |
真 |
假 |
假 |
真 |
假 |
真 |
真 |
假 |
真 |
假 |
真 |
真 |
假 |
真 |
假 |
假 |
假 |
真 |
真 |
真 |
真 |
4.1.12. 短路求值¶
布尔运算符的另一个重要特性是它们采用一种称为短路求值的求值方式。在 短路求值 中,布尔表达式从左到右进行求值,一旦表达式的值可以确定,无论其是否包含更多运算符和操作数,求值过程即刻终止。例如,在表达式
basil.isFacing(WEST) && basil.seesNet(AHEAD)
如果 basil.isFacing(WEST) 为假,则 AND 表达式必为假。由于计算机已知整个 AND 表达式为假,因此不会评估 basil.seesNet(AHEAD) ,因为已无必要。
同样地,在表达式中:
basil.isFacing(NORTH) || basil.seesNet(AHEAD)
如果 basil.isFacing(NORTH) 为真,则计算机知道整个OR表达式也必为真,因此无需再计算 basil.seesNet(AHEAD) ,因为该部分已无必要。
4.1.12.1. 关系运算符与基本数据类型¶
关系运算符用于检查诸如两个值是否相等,或一个值是否大于另一个值之类的条件。这类运算符根本不适用于对象,因此你不能在 jeroos 上使用它们。然而,它们可以作用于数值,例如 int。以下表达式展示了它们的用法。
运算符 |
示例 |
含义 |
|---|---|---|
|
|
x 等于 y |
|
|
x 不等于 y |
|
|
x 大于 y |
|
|
x 小于 y |
|
|
x 大于或等于 y |
|
|
x 小于或等于 y |
关系运算符的结果是两个布尔值之一: true 或 false 。这些值属于数据类型 boolean ;事实上,它们是唯一的 boolean 值。
你可能对这些操作已经很熟悉,但请注意,Java 操作符比较的是程序值。它们的行为类似于你熟悉的数学运算符,但并不像等号 =、小于等于 ≤ 和不等于 ≠ 这类数学符号那样书写。
一个常见错误是在希望比较两个值时使用单个 = 而不是双个 ==。请记住,= 是赋值运算符,而 == 是比较运算符。此外,不小心写出 =< 或 => 会导致编译错误。等号始终出现在 < 或 > 之后,就像你在英语中说出这些比较的名称时一样:“小于或等于” 先出现小于符号,再出现等号。
