加州理工 CS158 软件设计与开发笔记(一)
001:数据模型 📊
在本节课中,我们将要学习数据模型的核心概念。数据模型是任何计算机应用的基础,它定义了应用如何处理信息。我们将探讨什么是数据模型、其不同类型,以及如何在计算机中实现它们。
上一节我们提到了计算机应用是数据模型和算法的结合。本节中,我们来看看什么是数据模型。
数据建模是一种科学方法,用于将给定的问题领域用量化值来表达。简单来说,它是一种用定义好的测量单位(数据模型)来描述现实世界问题的方式。
例如,在烹饪食谱中,我们用量杯测量食材,用温度计测量热量,用计时器测量时间。这些测量单位(如“杯”、“摄氏度”、“分钟”)就是我们的数据模型。在计算机科学中,我们使用数据类型来实现这些模型。
数据模型的类型
数据模型可以根据决策问题的性质和类型,大致分为两类。
以下是两种主要的数据模型类型:
-
确定性模型:这类模型没有内在的随机性。每次执行,只要输入相同,结果就相同。烹饪食谱就是一个典型的确定性模型。
-
概率性模型:这类模型具有内在的随机性或不确定性。掷骰子或洗牌就是概率性模型的例子。在计算机科学中,这通常被称为随机模型。
确定性模型假设所有相关的输入数据值都是已知且确定的。概率性模型则假设某些输入数据值具有不确定性,因此需要将这种不确定性纳入模型考量。
定量数据与定性数据
任何建模过程都始于数据。将原始数据处理成对用户有价值的信息,是数据建模的核心。
在处理问题时,开发者可能需要同时考虑定性和定量因素。
-
定量数据:是以数字表示的值或计数的度量。例如,年龄、温度、时间、GPA。在烹饪中,量化食材、热量和时间都属于定量数据。
-
定性数据:是对类型的度量,可以用名称、符号或数字代码表示。例如,姓名、专业、食材标签(如“水果”、“蔬菜”)。在烹饪中,将食物分类为“蛋白质”、“蔬菜”属于定性数据。
有时,定性和定量数据可以相互转换。例如,字母等级(A, B, C)是定性数据,但它可以映射到一个数值范围(如90-100分对应A),从而转化为定量数据。这种抽象使我们能够以更灵活的方式表示信息。
计算机与数据建模
到目前为止,我们所讨论的建模概念都可以用纸笔或头脑完成。然而,计算机的强大之处在于能够自动执行我们定义的数学模型和算法。
数据建模的步骤可能非常详细,但通常可以概括为三个主要阶段,无论模型的规模或复杂性如何。
以下是数据建模的三个核心步骤:
-
问题表述:将问题的各个方面转化为数学模型。这是最关键也最具挑战性的一步。
-
求解:开发并执行算法,处理输入数据以产生输出结果,并测试解决方案的正确性。
-
解释:将形式化的输出结果翻译回现实世界,验证其是否解决了最初的问题。
步骤一:问题表述
问题表述是数据建模过程中最重要的一步。错误的问题表述几乎必然导致错误的结果。这就像写小说前需要先构思情节大纲一样。
问题表述本身可以进一步细分为三个部分。
以下是问题表述的三个子步骤:
-
定义问题:对问题做出清晰、简洁的陈述。这需要超越表面症状,识别出问题的根本原因。有时,解决一个问题可能依赖于先解决其他相关问题(例如,制作鸡尾酒前需要先制作糖浆)。
-
开发模型:为已定义的问题开发数学模型。数学模型通常是一组数学关系(如方程和不等式)。模型包含变量(其值可能改变)、参数(从外部输入到模型中的给定值)和常量(在模型执行过程中保持不变的值)。
-
获取输入数据:确定模型所需的初始数据或起始状态。
步骤二:求解
求解步骤是指执行从表述过程中得出的数学表达式,以找到解决方案。
求解步骤包含两个部分。
以下是求解步骤的两个组成部分:
-
开发解决方案:设计算法,即一步步的指令,说明如何将初始状态(输入)转化为期望的最终状态(输出)。在烹饪中,这就是食谱中的操作步骤。
-
测试解决方案:在过程中不断验证结果。就像在烹饪中,你可能会在进入下一步前尝一下面糊,或者在装盘前检查食材是否切好。
步骤三:解释
假设问题表述正确且解决方案成功实施,解释步骤关注用户如何理解和使用结果。这涉及到将形式化的输出“翻译”回它所要代表的现实世界概念。
此外,确保模型的可读性至关重要。在编写代码或构建模型时,应使用描述性的标题、标签和注释,以便于未来理解和维护。
实例:构建应用模型
计算机应用由两部分组成:模型系统(通常不止一个)和处理信息的算法。
例如,制作一个蛋糕需要定义三种模型:数量模型(测量食材)、温度模型(测量热量)和时间模型(测量时长),再加上制作步骤的算法。
同样,在开发视频游戏时:
-
我们可以用定性模型(如名字列表)来定义角色的身份。
-
用定量模型(如X、Y坐标网格)来定义角色的移动。
-
用另一个定量模型(如生命值)来定义角色的生存状态。
-
然后,我们设计算法来处理这些模型,例如当角色受到伤害时减少生命值,当角色移动时更新坐标。
本节课中我们一起学习了数据模型的基本概念。我们了解了数据模型是应用的基础,区分了确定性模型与概率性模型、定量数据与定性数据。我们还探讨了在计算机中进行数据建模的三个核心步骤:问题表述、求解和解释。最后,我们通过蛋糕制作和游戏开发的例子,看到了如何将现实世界的问题转化为由模型和算法组成的软件应用。理解这些概念是后续学习具体编程语言和算法设计的关键第一步。
002:输出与存储
在本节课中,我们将学习算法设计的基础构建模块,特别是输出操作和存储操作。我们将了解如何将数据从程序中发送到外部世界,以及如何在程序内部保存数据以供后续使用。
输出操作
上一节我们介绍了数据建模的概念,本节中我们来看看如何将程序内部的数据呈现给外部世界。输出操作负责将数据从程序中发送出去,例如显示在终端上。
在Java中,我们通过 System.out 对象来访问输出流,它提供了几种打印数据的方法。
以下是三种主要的打印方法:
-
print:将数据打印到控制台,但不自动换行。System.out.print("Hello"); System.out.print("World"); // 输出:HelloWorld -
println:将数据打印到控制台,并自动在末尾添加换行符。System.out.println("Hello"); System.out.println("World"); // 输出: // Hello // World -
printf:格式化打印,允许我们在字符串中嵌入占位符,并用变量值替换它们。String name = "Alice"; int age = 25; System.out.printf("Hello, %s. You are %d years old.%n", name, age); // 输出:Hello, Alice. You are %d years old.
在格式化字符串中,%s 表示字符串占位符,%d 表示整数占位符,%f 表示浮点数占位符。%n 是平台无关的换行符。
存储操作
仅仅输出数据还不够,我们通常需要在程序运行过程中保存和操作数据。存储操作就是用来在内存中保留数据,以便后续使用的。
在Java中,我们通过变量来实现存储操作。声明一个变量需要指定数据类型和变量名。
Java中的基本数据类型主要分为三类:
-
数值型:用于存储数字。
-
整数:
int(例如:int count = 10;) -
浮点数:
double(例如:double price = 19.99;)
-
-
文本型:用于存储字符或文本。
-
单个字符:
char(例如:char grade = 'A';) -
字符串:
String(例如:String name = "Bob";)
-
-
布尔型:用于存储真/假值。
boolean(例如:boolean isLoggedIn = true;)
声明和初始化变量有三种方式:
-
声明:仅保留内存空间,不赋值。
int number; // 声明一个整数变量 -
赋值:给已声明的变量赋予一个值。
number = 5; // 给变量赋值 -
初始化:在声明变量的同时进行赋值。
int number = 5; // 声明并初始化变量 double score = 95.5; boolean flag = false; String message = "Hello, World!";
注意:int, double, boolean, char 是基本数据类型(关键字为小写)。String 是引用数据类型(首字母大写),因为它可以表示无限种可能的文本组合,而非预定义的几个值。
本节课中我们一起学习了算法设计的两个核心构建模块:输出操作和存储操作。我们学会了如何使用 print, println, printf 将数据输出到控制台,以及如何声明和初始化不同类型的变量来在程序中存储数据。理解这些基础概念是编写更复杂程序的第一步。
003:输入与数据类型 📚
在本节课中,我们将深入学习数据建模的基础知识,并探讨如何在Java程序中获取用户输入。我们将从理解编程中最基本的数据类型开始,然后学习如何使用Scanner类从外部(如键盘)读取数据。掌握这些概念是构建有效算法和应用程序的关键第一步。
数据建模:基础构建模块 🧱
上一节我们讨论了算法和食谱的类比。然而,在开始编写算法(食谱)之前,我们必须先理解如何量化我们的“食材”。这就是数据建模的作用。
所有数据模型,无论是在Java中还是在任何编程领域,最终都可以归结为三种基本类型的数据:
-
数值数据:可以量化为数字的信息。
-
文本数据:无法用数字精确表示,需要用文字描述的信息。
-
布尔数据:表示“是/否”或“真/假”状态的信息。
这些是我们构建所有复杂数据模型的基础“积木”。
数值数据:精确与近似
数值数据(定量数据)可以进一步分为两种形式:
-
精确数值:在Java中称为整数。例如,数字
1、2、3都是精确的。- 公式:
整数 ∈ {..., -2, -1, 0, 1, 2, ...}
- 公式:
-
近似数值:在Java中称为浮点数。例如,
0.1、3.14159。由于计算机内存和数学表示的限制,浮点数通常是近似值。- 核心概念:对于浮点数,不能直接使用
==判断完全相等,通常需要定义一个误差范围(delta)。
- 核心概念:对于浮点数,不能直接使用
文本与布尔数据
-
文本数据:在Java中称为字符串。它是一系列字符的序列。
-
布尔数据:只有两个可能的值:
true(真)或false(假)。
Java中的原始数据类型 🔢
在Java中,上述基础类型有更具体的实现,称为“原始数据类型”。原始类型意味着Java虚拟机(JVM)预先知道所有可能的值,因此访问速度非常快。它们的特点是其类型名总是小写。
以下是Java中主要的原始数据类型及其关键信息:
| 抽象概念 | Java 类型 | 内存占用 | 说明与示例 |
| :— | :— | :— | :— |
| 精确整数 | byte | 1 字节 (8位) | 非常小的整数范围:-128 到 127 |
| | short | 2 字节 (16位) | 较小整数范围:-32,768 到 32,767 |
| | int | 4 字节 (32位) | 最常用:约 -21亿 到 21亿 |
| | long | 8 字节 (64位) | 极大整数,字面量需加L,如 10000000000L |
| 近似浮点数 | float | 4 字节 (32位) | 单精度浮点,精度约6-7位小数,字面量需加f |
| | double | 8 字节 (64位) | 推荐使用:双精度浮点,精度约15-16位小数 |
| 字符 | char | 2 字节 (16位) | 单个字符,用单引号,如 'A' |
| 布尔值 | boolean | 1 位 (理论上) | 只有 true 或 false 两个值 |
| 文本(非原始) | String | 可变(引用类型) | 字符串序列,用双引号,如 "Hello" |
重要说明:
-
String是引用类型(类型名大写),不是原始类型,因为可能的字符串组合是无限的,无法预先存储在JVM中。 -
计算机底层将所有数据(整数、浮点数、文本)都编码为二进制位(0和1)。数据类型的作用就是告诉计算机如何解读这些二进制序列。
存储操作:变量声明与赋值 💾
理解了数据类型后,我们来看看如何存储数据。算法处理的数据如果不保存,就会被丢弃。我们使用变量来存储数据以备后用。
以下是三种基本的存储操作:
-
声明:在内存中为特定类型的数据预留空间,并给它一个名字(标签)。
int age; // 声明一个名为age的整数变量 -
赋值:将一个值存入已声明的变量。
age = 25; // 将值25赋给变量age -
初始化:声明变量的同时进行赋值。
int height = 180; // 声明并初始化height变量 String name = "Alice"; // 声明并初始化name字符串
为什么需要先声明? 因为Java需要知道为变量分配多少内存以及如何解释存储的数据。
输入操作:从外部获取数据 🎯
现在我们已经知道如何存储数据,接下来看看如何从程序外部(如用户输入)获取数据。输入操作比输出操作更具挑战性,因为程序无法预知会收到什么数据以及何时收到。
在Java中,我们使用 System.in 代表标准输入流(如键盘),但直接使用它很复杂。因此,我们借助 Scanner 类来解析输入流。
使用 Scanner 类
Scanner 类可以将输入流中的原始字节数据,解析成我们需要的整数、浮点数或字符串等。
以下是使用 Scanner 的基本步骤:
-
导入类库:在Java文件顶部导入
Scanner。import java.util.Scanner; // 必须的导入语句 -
创建 Scanner 对象:将其连接到输入流(如
System.in)。Scanner input = new Scanner(System.in); // 创建一个从键盘读取的扫描器 -
读取数据:使用Scanner对象的方法读取特定类型的数据。
-
nextInt(): 读取下一个整数(以空格/制表符/换行符为分隔)。 -
nextDouble(): 读取下一个双精度浮点数。 -
next(): 读取下一个单词(字符串,到分隔符为止)。 -
nextLine(): 读取下一行文本(到换行符为止)。
-
一个完整的输入输出示例
让我们看一个结合了输入、存储和输出操作的例子:
import java.util.Scanner; // 1. 导入Scanner
public class InputDemo {
public static void main(String[] args) {
// 2. 创建Scanner对象
Scanner keyboard = new Scanner(System.in);
// 输入和存储一个字符串(整行)
System.out.print("请输入您的全名: ");
String fullName = keyboard.nextLine(); // 读取整行
// 输入和存储一个整数
System.out.print("请输入您的年龄: ");
int age = keyboard.nextInt(); // 读取整数
// 输入和存储一个浮点数
System.out.print("请输入您的账户余额: ");
double balance = keyboard.nextDouble(); // 读取双精度数
// 输出存储的数据
System.out.printf("您好,%s!%n", fullName); // 使用 %s 格式化字符串
System.out.printf("年龄:%d 岁%n", age); // 使用 %d 格式化整数
System.out.printf("余额:$%.2f%n", balance); // 使用 %.2f 格式化浮点数(保留两位小数)
}
}
代码解析:
-
程序依次提示用户输入姓名、年龄和余额。
-
keyboard.nextLine()、keyboard.nextInt()、keyboard.nextDouble()分别从输入流中读取相应类型的数据。 -
读取的数据被存储在变量 (
fullName,age,balance) 中。 -
最后,使用
System.out.printf和格式化符号 (%s,%d,%.2f) 将结果美观地输出。
注意事项:
- 混合使用
nextLine()和其他next...()方法时需小心。例如,nextInt()只读取数字,不会读取其后的换行符,如果紧接着调用nextLine(),可能会读到空行。一个常见的解决方法是:在读取数字后,额外调用一次nextLine()来“消耗”掉残留的换行符。
总结 📝
本节课我们一起学习了软件构建的核心基础:
-
数据建模:我们认识到所有数据都可归结为数值、文本和布尔值这三种基本类型。数值又分为精确的整数和近似的浮点数。
-
Java原始类型:我们详细了解了Java中对应的原始数据类型(如
int,double,boolean,char)及其特性,并理解了String作为引用类型的特殊性。 -
存储操作:我们学会了如何使用声明、赋值和初始化来创建和管理变量,以保存程序中的数据。
-
输入操作:我们掌握了如何使用
Scanner类从标准输入(如键盘)可靠地读取不同类型的数据,这是实现程序与用户交互的关键。
现在,我们已经掌握了算法的五个基本构建模块中的三个:输出、存储和输入。下一节课,我们将深入探讨最核心的处理操作(如算术运算、逻辑比较),学习如何转换和计算数据,从而让我们编写的程序真正开始“思考”和解决问题。
004:处理运算符 🧮
在本节课中,我们将要学习算法的核心组成部分之一:处理运算符。这是软件实现其功能、将数据转化为信息的关键所在。我们将探讨算术、关系、相等、逻辑和连接等不同类型的运算符,理解它们如何作用于数据,以及如何将它们组合成复杂的表达式。
概述
上一讲我们讨论了输入、输出和存储操作。本节中,我们来看看处理操作。这是算法中实现“魔法”的部分,它允许我们将数据从一种形式转换为另一种形式。处理操作本质上是数学运算,但我们可以将其概念应用于各种现实世界的问题。
算术运算符
算术运算符是我们最熟悉的运算符,用于对数值数据进行基本数学运算。它们是二元运算符,意味着需要两个操作数。
以下是Java中的基本算术运算符:
-
加法:
+ -
减法:
- -
乘法:
* -
除法:
/ -
取模:
%
算术运算要求输入和输出都是数值类型。一个重要的区别在于整数除法。当两个整数相除时,结果会被截断小数部分,只保留整数商。
例如:
-
1 / 2的结果是0,而不是0.5。 -
3 / 2的结果是1。
取模运算符 % 用于计算除法后的余数。它对于识别模式(如判断奇偶性)非常有用。
例如:
-
1 % 2结果是1 -
2 % 2结果是0 -
3 % 2结果是1 -
5 % 3结果是2
任何具有循环或重复模式的现象(如时钟)都可以用取模运算来建模。
关系运算符与相等运算符
关系运算符用于比较两个数值的大小关系。它们也是二元运算符,输入必须是数值数据,但输出是布尔值(true 或 false)。
以下是关系运算符:
-
小于:
< -
大于:
> -
小于或等于:
<= -
大于或等于:
>=
关系运算符将定量数据(数字)转换为定性判断(真或假),这对于后续的控制流程决策至关重要。
接下来我们看看相等运算符。虽然常与关系运算符归为一类,但相等运算符有本质区别:它可以用于任何数据类型,而不仅仅是数值。
以下是相等运算符:
-
等于:
== -
不等于:
!=
重要提示:= 是赋值运算符,用于将值存入变量。== 才是用于比较是否相等的运算符。混淆两者是新开发者常见的错误。
与关系运算符一样,相等运算的结果也是布尔值。
逻辑运算符
逻辑运算符用于组合或修改布尔值,从而构建更复杂的条件判断。它们主要也是二元运算符。
以下是逻辑运算符:
-
逻辑与:
&&(AND) - 仅当两个操作数都为true时,结果才为true。 -
逻辑或:
||(OR) - 只要至少一个操作数为true,结果就为true。
此外,还有一元逻辑运算符:
- 逻辑非:
!(NOT) - 用于取反单个布尔值。!true是false,!false是true。
类似的,数值数据也有一元运算符,即负号 -,用于改变数值的正负。
连接运算符
连接运算符 + 用于将数据(尤其是文本)拼接在一起。有趣的是,它使用的符号与加法运算符相同。
Java如何区分 + 是加法还是连接呢?规则如下:
-
如果
+两边的操作数都是数值,则执行加法运算,结果为数值。 -
如果
+两边的操作数至少有一个是字符串,则执行连接运算,结果为字符串。在此过程中,非字符串的操作数会被转换为字符串形式。
例如:
-
1 + 2结果是数值3。 -
"1" + 2结果是字符串"12"。 -
1 + 2 + "3"的运算顺序是从左到右:1 + 2得3(数值),然后3 + "3"进行连接,得字符串"33"。
表达式的计算遵循标准的数学运算顺序(先乘除,后加减),可以使用括号 () 来改变优先级。
关于引用数据类型的相等性比较
之前讨论的 == 和 != 是基本运算符,设计用于比较基本数据类型(如 int, double, boolean)的值。
然而,对于引用数据类型(如 String),== 比较的是两个引用是否指向内存中的同一个对象(即内存地址是否相同),而不是比较对象的内容是否相同。
例如,两个内容相同的 String 对象,如果存储在内存的不同位置,用 == 比较会返回 false。
要比较两个 String 对象的内容是否相等,必须使用 String 类提供的 .equals() 方法:
String str1 = "hello";
String str2 = "hello";
// 错误的方式,比较内存地址
boolean isSameObject = (str1 == str2); // 结果可能为 true 或 false,取决于JVM优化
// 正确的方式,比较内容
boolean isSameContent = str1.equals(str2); // 结果为 true
引用数据类型(如 Scanner, System.out)通常都内置了各种方法(通过 . 操作符调用),用于执行特定操作。
表达式
将运算符和数据组合在一起,就构成了表达式。表达式是算法中实现计算和逻辑的核心单元。
我们可以将编程语言中的表达式类比为自然语言中的句子:
-
数据(名词):是表达式中的主体。
-
运算符(动词):是对数据执行的动作。
通过将现实世界的问题映射为数学表达式,我们就能用软件来解决它们。
总结
本节课中我们一起学习了处理运算符,这是构建算法逻辑的“动词”。我们详细探讨了:
-
算术运算符(
+,-,*,/,%)用于数值计算,特别注意了整数除法和取模运算。 -
关系运算符(
<,>,<=,>=)用于数值比较,并产生布尔结果。 -
相等运算符(
==,!=)可用于任何数据类型进行比较,同样产生布尔结果,并强调了与赋值运算符=的区别。 -
逻辑运算符(
&&,||,!)用于组合和修改布尔值,构建复杂条件。 -
连接运算符(
+)用于拼接字符串,其行为取决于操作数的类型。 -
理解了对于引用数据类型(如
String),应使用.equals()方法而非==来比较内容。 -
最后,我们认识了表达式的概念,它是将数据与运算符结合以产生新值或新判断的基本单元。
掌握这些运算符是设计有效算法和功能软件的基础。下一讲,我们将进入最后一个基本构建块:控制操作,学习如何让程序做出决策和重复执行任务。
005:选择结构 🧭
在本节课中,我们将学习算法构建的第五个核心模块:控制操作。我们将重点探讨选择结构,它允许程序根据条件决定执行哪一部分代码。我们将学习三种选择结构:单选择、双选择和多选择,并理解如何利用布尔值来做出决策。
复合赋值操作
在深入探讨控制操作之前,我们先回顾并扩展一下处理操作。我们可以将处理操作(如算术运算)与赋值操作结合,形成复合赋值语句。
复合赋值语句允许我们在单行代码中,既读取一个变量的当前值,对其进行处理,又将新值写回同一个变量。这是一种强大的能力,因为它允许我们基于变量的当前状态来更新它。
以下是复合赋值的一个基本示例:
int x = 2;
x = x * 10; // 读取x的值(2),乘以10,然后将结果(20)存回x
System.out.println(x); // 输出 20
我们可以使用更简洁的复合赋值运算符来达到相同效果:
int x = 2;
x *= 10; // 等同于 x = x * 10;
System.out.println(x); // 输出 20
对于字符串连接,同样适用:
String text = "Hello";
text += " World"; // 等同于 text = text + " World";
System.out.println(text); // 输出 Hello World
自增与自减操作
对于最常见的“加1”或“减1”操作,Java提供了更简短的自增(++)和自减(--)运算符。
这里有一个关键区别:前自增/自减与后自增/自减。
-
前自增 (
++x):先对变量进行加1操作,然后使用新值。 -
后自增 (
x++):先使用变量的当前值,然后再对其进行加1操作。
当在同一语句中涉及其他操作时,这个顺序差异就很重要了。
int x = 0;
// 前自增示例
System.out.println("前自增前: " + x); // 输出 0
System.out.println("执行 ++x: " + (++x)); // 输出 1 (先加1,后打印)
System.out.println("前自增后: " + x); // 输出 1
x = 0; // 重置x
// 后自增示例
System.out.println("后自增前: " + x); // 输出 0
System.out.println("执行 x++: " + (x++)); // 输出 0 (先打印,后加1)
System.out.println("后自增后: " + x); // 输出 1
控制操作:选择结构
现在,让我们进入本节课的核心——控制操作。选择结构是控制操作的第一种类型,它允许程序根据条件(布尔表达式)的真假,来决定执行哪一段代码块。
所有选择结构的决策都基于布尔值。这就是为什么在处理操作中,我们能够将各种数据类型转换为布尔格式如此重要。
单选择结构 (if)
单选择结构允许我们决定是执行一个代码块,还是完全跳过它。这就像在食谱中,如果选择做素食,就跳过加入肉类的步骤。
结构:
if (条件) {
// 如果条件为 true,则执行这里的代码
// 可以包含多条语句
}
// 如果条件为 false,则跳过整个代码块,继续执行后面的语句
示例:判断成绩是否及格
我们创建一个程序,提示用户输入成绩。如果成绩大于等于70分,则输出“你通过了”。
import java.util.Scanner; // 导入Scanner类用于获取输入
public class SingleSelection {
public static void main(String[] args) {
// 1. 提示用户输入
System.out.print("请输入一个数字成绩: ");
// 2. 获取用户输入并存储
Scanner keyboard = new Scanner(System.in);
double grade = keyboard.nextDouble();
// 3. 单选择:判断是否及格
if (grade >= 70) {
// 只有条件为真时,才会执行这个代码块
System.out.println("你通过了!");
System.out.println("做得好!");
}
// 如果 grade < 70,则什么也不做,程序结束
}
}
重要提示: 即使if后面只跟一条语句,也强烈建议始终使用花括号 {} 来定义代码块。这能避免未来添加语句时出现错误,并使代码结构更清晰。
双选择结构 (if-else)
双选择结构提供了两个互斥的代码块。程序会根据条件的真假,必定执行其中一个块。这就像在制作浓汤时,你必须在“鸡肉香肠浓汤”和“海鲜浓汤”两种食谱中选择一个。
结构:
if (条件) {
// 条件为 true 时执行
} else {
// 条件为 false 时执行
}
// 执行完其中一个块后,继续执行后面的语句
示例:判断通过或失败
我们改进之前的程序,使其不仅能告知通过,还能在不及格时输出“你失败了”。
import java.util.Scanner;
public class DoubleSelection {
public static void main(String[] args) {
System.out.print("请输入一个数字成绩: ");
Scanner keyboard = new Scanner(System.in);
double grade = keyboard.nextDouble();
// 双选择:根据成绩输出不同结果
if (grade >= 70) {
System.out.println("你通过了!");
} else {
// 当 grade < 70 时执行
System.out.println("你失败了。");
}
}
}
多选择结构 (if-else if-else)
多选择结构允许我们在多个(两个以上)代码块中选择一个执行。它通过将else和新的if语句嵌套在一起来实现。
结构:
if (条件1) {
// 条件1为 true 时执行
} else if (条件2) {
// 条件1为 false 且 条件2为 true 时执行
} else if (条件3) {
// 条件1、2为 false 且 条件3为 true 时执行
} else {
// 所有上述条件都为 false 时执行
}
// 注意:整个结构最终只会执行其中一个代码块
示例:根据成绩输出等级
我们创建一个程序,根据输入的成绩输出对应的字母等级(A, B, C, D, F)。
import java.util.Scanner;
public class MultiSelection {
public static void main(String[] args) {
System.out.print("请输入一个数字成绩: ");
Scanner keyboard = new Scanner(System.in);
double grade = keyboard.nextDouble();
// 多选择:根据成绩范围输出等级
if (grade >= 90) {
System.out.println("你得到了 A");
} else if (grade >= 80) { // 隐含了 grade < 90
System.out.println("你得到了 B");
} else if (grade >= 70) { // 隐含了 grade < 80
System.out.println("你得到了 C");
} else if (grade >= 50) { // 隐含了 grade < 70
System.out.println("你得到了 D");
} else { // grade < 50
System.out.println("你得到了 F");
}
}
}
关键区别:独立 if 语句 vs. 多选择 if-else if 链
-
多选择链 (
if-else if):是一个整体决策结构。一旦某个条件为真,执行对应的块后就会跳过后面所有的else if和else块。它保证只执行一个分支。 -
多个独立的
if语句:每个if都是独立的单选择,会依次进行判断。多个条件可能同时为真,从而导致多个代码块都被执行。这适用于需要同时检查多个独立条件的情况(例如,在游戏中同时检测是否按下了“上”键和“左”键)。
错误示例(多个独立if):
if (grade >= 90) {
System.out.println("A");
}
if (grade >= 80) { // 注意这里没有 else
System.out.println("B");
}
if (grade >= 70) { // 注意这里没有 else
System.out.println("C");
}
// 输入95会输出: A, B, C
总结
在本节课中,我们一起学习了算法构建的第五块基石:控制操作中的选择结构。
我们首先回顾了复合赋值操作,它让我们能高效地更新变量状态。接着,我们深入探讨了三种选择结构:
-
单选择 (
if):根据条件决定是否执行一个代码块。 -
双选择 (
if-else):根据条件在两个互斥的代码块中选择一个执行。 -
多选择 (
if-else if-else):在多个互斥的代码块中选择一个执行。
我们理解了所有决策都基于布尔表达式,并学会了如何利用关系运算符(如 >=, <)来构建这些表达式。同时,我们强调了使用花括号定义代码块的良好习惯,以及区分多选择链与多个独立if语句的重要性。
选择结构为我们的程序引入了“决策”能力,使其能够根据不同情况做出反应。在下一节课中,我们将学习控制操作的另一个关键部分:重复结构(循环),它能让程序重复执行某些操作。
006:重复与循环 🔄
在本节课中,我们将学习算法设计中的第五个基本构建块:控制操作中的重复语句。我们将探讨如何使用循环结构来重复执行一段代码,直到满足特定条件为止。课程将涵盖两种主要的循环类型:计数器控制循环和哨兵控制循环,并详细介绍 while、for 和 do-while 循环的语法与用法。
循环基础:为何需要重复? 🔁
上一节我们介绍了选择语句,它允许程序根据条件决定执行哪部分代码。本节中,我们来看看如何让程序重复执行某段代码。
重复语句负责在程序中重复执行一组指令。一个生活中的例子是食谱中的“搅拌面糊直至没有结块”。这个指令意味着重复“搅拌”这个动作,直到终止条件(面糊没有结块)满足为止。
用伪代码表示如下:
while (面糊有结块) {
搅拌面糊
}
所有控制操作(包括循环)都基于布尔值进行判断。只有当循环控制变量的条件为真时,才会执行循环体内的代码。
循环的两种主要类型 📊
循环通常分为两种主要类型,区别在于我们是否提前知道循环需要执行的次数。
-
计数器控制循环:当我们提前知道需要循环的确切次数时使用。通常使用
for循环结构。 -
哨兵控制循环:当我们不知道需要循环多少次,循环依赖于运行时某个条件的变化时使用。通常使用
while循环结构。
无论哪种循环,都必须包含三个基本要素:
-
初始化循环控制变量。
-
评估循环控制变量的条件(布尔表达式)。
-
更新循环控制变量,使其最终能使条件变为假,从而结束循环。
如果忘记更新循环控制变量,可能会导致无限循环。
计数器控制循环与 while 🧮
让我们通过一个计数器控制循环的例子,来看看 while 循环是如何工作的。
public class CounterLoop {
public static void main(String[] args) {
// 1. 初始化循环控制变量
int counter = 0;
// 2. 评估循环控制变量条件
while (counter < 10) {
// 循环体:要重复执行的代码
System.out.println("Counter is: " + counter);
// 3. 更新循环控制变量
counter++; // 等同于 counter = counter + 1;
}
// 循环结束后执行
System.out.println("Final counter value: " + counter);
}
}
运行上述代码,输出将从 0 打印到 9,最后打印出 10。
关键点:
-
如果初始条件为假(例如
counter初始值为 10),则循环体可能一次也不执行。 -
如果忘记更新变量(如注释掉
counter++),条件将永远为真,导致无限循环。
哨兵控制循环 🚨
与计数器循环不同,哨兵控制循环的次数取决于运行时的用户输入或其他动态条件。
以下是一个使用 while 循环的哨兵控制示例,它持续询问用户输入,直到输入 Q 为止:
import java.util.Scanner;
public class SentinelLoop {
public static void main(String[] args) {
Scanner input = new Scanner(System.in);
// 1. 初始化循环控制变量(布尔值)
boolean repeat = true;
// 2. 评估循环控制变量条件
while (repeat == true) {
System.out.print("Enter a letter (Q to quit): ");
// 获取用户输入
String response = input.next();
// 3. 根据输入更新循环控制变量
if (response.equals("Q")) {
repeat = false; // 满足条件,改变状态以退出循环
}
// 如果不是 Q,则 repeat 仍为 true,循环继续
}
System.out.println("Loop ended.");
input.close();
}
}
在这个例子中,我们无法在运行程序前预知循环会执行多少次,它完全取决于用户何时输入 Q。
for 循环:更简洁的计数器循环 ✨
对于计数器控制循环,for 循环提供了更紧凑、更易读的语法。它将循环的三个要素(初始化、条件、更新)集中在一行。
让我们将之前的 while 计数器循环改写为 for 循环:
public class WhileFor {
public static void main(String[] args) {
// for 循环将三个要素集中在一行
for (int count = 0; count < 10; count++) {
System.out.println("Count is: " + count);
}
// 循环结束后,count 变量在此作用域不可见
}
}
for 循环语法解析:
-
int count = 0;:初始化语句,在循环开始时执行一次。 -
count < 10;:循环条件,每次迭代前检查,为真则执行循环体。 -
count++:更新语句,每次循环体执行后执行。
for 与 while 的对比:
-
行为完全相同:在性能上没有区别。
-
可读性:对于计数器循环,
for循环更清晰,因为它把循环控制逻辑封装在一行,而不“污染”循环体周围的代码。 -
原则:已知循环次数时,优先使用
for循环;未知时,使用while循环。
do-while 循环:至少执行一次 🔄➡️
while 和 for 循环可能一次都不执行(如果初始条件为假)。但有时我们需要至少执行一次循环体,然后再判断条件。这时就需要 do-while 循环。
public class DoWhile {
public static void main(String[] args) {
int j = 11;
System.out.println("Before do-while loop");
do {
// 循环体至少会执行一次
System.out.println("j is: " + j);
j++;
} while (j < 10); // 注意结尾的分号
System.out.println("After do-while loop, j is: " + j);
}
}
运行结果会打印出 j is: 11,尽管 j 的初始值就不满足 j < 10 的条件。这是因为 do-while 先执行循环体,再检查条件。
重要语法提示:do-while 循环的 while 语句后面必须有一个分号。这是它与普通 while 循环的一个易错区别。
循环控制:break 与 continue ⏸️▶️
在循环内部,我们可以使用 break 和 continue 语句来更精细地控制流程。
break 语句
break 语句用于立即终止整个循环,并跳出到循环后面的语句。
public class BreakDemo {
public static void main(String[] args) {
for (int count = 0; count <= 10; count++) {
if (count == 5) {
break; // 当 count 等于 5 时,立即跳出循环
}
System.out.println("Count is: " + count);
}
System.out.println("Loop broke at count == 5");
}
}
输出将只打印 0 到 4,当 count 为 5 时,break 使循环提前结束。
continue 语句
continue 语句用于跳过当前迭代中剩余的代码,直接进入循环的下一次迭代。
public class ContinueDemo {
public static void main(String[] args) {
for (int count = 0; count < 10; count++) {
if (count == 5) {
continue; // 跳过本次循环中后面的代码,直接开始下一次迭代
}
System.out.println("Count is: " + count);
}
}
}
输出将打印 0, 1, 2, 3, 4, 6, 7, 8, 9。注意,数字 5 被跳过了,因为当 count 为 5 时,continue 语句使得 System.out.println 没有被执行,程序直接开始了 count=6 的迭代。
补充:switch 选择语句 🔀
在结束所有控制结构之前,我们简要介绍另一种多路选择语句:switch。它类似于 if-else if-else 链,但基于单个表达式的精确匹配。
传统 switch 语法(易出错,不推荐):
switch (response) {
case "A":
selection = "Option A";
break; // 必须使用 break 防止“贯穿”
case "B":
selection = "Option B";
break;
default: // 类似于 else
selection = "None";
}
如果忘记写 break,程序会执行完匹配的 case 后,继续执行后面所有 case 的代码,直到遇到 break 或结束,这被称为“贯穿”,通常是错误来源。
Java 14+ 改进的 switch 表达式(推荐):
switch (response) {
case "A" -> selection = "Option A"; // 使用箭头,无需 break
case "B" -> selection = "Option B";
default -> selection = "None";
}
新语法使用 -> 箭头,更加简洁,并且不会发生贯穿,避免了因忘记 break 而导致的错误。
总结 📝
本节课中我们一起学习了算法设计的核心——重复语句(循环)。
我们掌握了以下内容:
-
循环的基本要素:初始化、条件判断、变量更新。
-
两种循环类型:计数器控制循环(使用
for)和哨兵控制循环(使用while)。 -
三种循环结构:
-
while循环:先判断,后执行,可能执行零次。 -
for循环:专为计数器循环设计,语法紧凑。 -
do-while循环:先执行,后判断,至少执行一次。
-
-
循环控制语句:
break(终止循环)和continue(跳过本次迭代)。 -
switch语句:一种基于精确匹配的多路选择结构,了解了其传统语法和更安全、更简洁的 Java 14+ 新语法。
至此,我们已经涵盖了构成算法的五个基本构建块:输入、输出、存储、处理和控制(选择与重复)。接下来,我们将开始运用这些基础来设计和构建更复杂的算法。
007:自顶向下逐步求精(第一部分)
在本节课中,我们将学习一种重要的算法设计策略——自顶向下逐步求精过程。我们将通过构建三个具体的应用程序来实践这一方法,从简单的概念开始,逐步细化,直到形成可以直接转换为Java代码的伪代码。
概述
到目前为止,我们已经学习了数据建模、算法的概念以及构建算法的五个基本构建块:输出操作、存储操作、输入操作、处理操作和控制操作。特别是控制操作,我们学习了选择语句和重复语句。本节课,我们将把这些知识整合起来,学习如何通过自顶向下逐步求精的过程,将一个抽象的想法发展成一个完整的、可实现的算法。
自顶向下逐步求精过程
自顶向下是一种设计方法,我们从非常抽象的概念开始,逐步增加细节,直到算法变得足够复杂和具体。这是一种让我们能够以非常直接的方式设计复杂算法的策略。
想象一下,在最高层,你只有一个关于应用程序的简单想法。这个过程就是教你如何迭代地完善这个想法,直到它足够完整,可以开始用Java实现。一个重要的启示是:在触碰键盘、在代码编辑器中输入任何内容之前,你应该始终预先规划好要编写的代码。你不应该在没有考虑算法如何操作、数据模型是什么以及如何遵循逐步指令的情况下就开始开发。
过程详解
我们通常将自顶向下逐步求精过程分解如下:从顶层语句开始,然后通过每次细化向下遍历。
-
顶层语句:我们首先定义一个语句,作为应用程序的一行摘要。它定义了软件的规格,并明确了我们的目标。这本质上是软件的“电梯演讲”。
-
第一次细化:这是一个迭代方法。我们查看之前创建的顶层语句,找出哪些部分是抽象的、定义不够清晰以至于无法直接翻译成Java语句。然后,我们添加更多细节来更好地定义如何完成这些任务。每次细化都定义了整个应用程序,但你在每次细化中所做的是逐步解释如何实现它。这就像一种“分而治之”的策略。
-
后续细化:我们不断向细化后的语句集中添加更多细节,直到这组语句足够明确,可以作为我们的伪代码。伪代码类似于Java或Python代码,但它不编译,是人类可读、可解释的一组语句,用于定义你的“食谱”。
-
最终细化:在最终细化阶段,即无法再添加更多细节的最后一步,我们伪代码算法的每一行现在都应该能映射到一个Java语句。一旦达到这个水平,你就可以将细化内容粘贴到源代码文档中,并以此为指导来实现你的应用程序。
从图形上看,这个过程就像一个金字塔。我们从顶部开始,信息很少,非常抽象。经过第一次细化,我们将其扩展了一些;经过第二次细化,扩展得更多;经过第三次细化,扩展得更多。最终,在过程的底部,我们拥有最扩展、最详细、最精细的算法,这将帮助我们直接将伪代码实现为Java。
应用实践:构建三个应用程序
现在,我们将使用自顶向下逐步求精方法来设计三个应用程序。
应用程序一:计数器控制循环的平均成绩计算器
这个应用程序将使用计数器控制循环来计算10个成绩的平均值。
步骤1:定义顶层语句
首先,我们定义顶层语句:确定学生10次测验的平均成绩。这个语句简洁明了,传达了应用程序的功能。它指定了具体的测验数量(10次),这让我们知道这是一个适合使用计数器控制循环的目标。
步骤2:第一次细化
接下来,我们进行第一次细化。我们需要思考完成“确定学生10次测验的平均成绩”这个目标至少需要哪些抽象步骤。
-
初始化所需的数据。
-
从用户那里获取成绩并求和。
-
计算平均值并打印。
步骤3:第二次细化
第一次细化后的语句仍然抽象,我们需要进一步细化。
-
细化“初始化数据”:
-
将
sum初始化为0。 -
将
count初始化为0。 -
声明一个
average变量以备后用。 -
初始化一个
Scanner对象用于输入。
-
-
细化“获取成绩并求和”:
-
当
count小于10时重复执行。-
提示用户输入一个成绩。
-
从用户那里获取并存储成绩。
-
将该成绩加到
sum中。 -
将
count加1。
-
-
-
细化“计算平均值并打印”:
-
将
average设置为sum除以count。 -
打印
average的值。
-
现在,我们有了完整的伪代码,每一行都可以直接映射到Java语句。
步骤4:实现与测试
根据伪代码,我们可以在Java中实现这个算法。我们将顶层语句作为多行注释放在文件顶部,并将细化后的伪代码作为行内注释来指导实现。实现后,我们进行测试以确保其按预期工作。
核心代码结构示例:
// 初始化数据
double sum = 0;
int count = 0;
Scanner keyboard = new Scanner(System.in);
double average;
// 获取10个成绩并求和
while (count < 10) {
System.out.printf("Enter grade %d: ", count + 1);
double grade = keyboard.nextDouble();
sum = sum + grade;
count = count + 1;
}
// 计算并打印平均值
average = sum / count;
System.out.printf("Average is: %.2f%n", average);
应用程序二:哨兵控制循环的平均成绩计算器
这个应用程序将使用哨兵控制循环来计算任意数量成绩的平均值。顶层语句变为:确定学生任意次数测验的平均成绩。
关键区别与细化
主要区别在于第二次细化中的循环控制部分。
-
选择哨兵值:我们选择
-1作为哨兵值,因为成绩不应该为负数(最低为0)。 -
调整循环逻辑:
-
提示用户输入一个成绩(或输入-1退出)。
-
从用户那里获取并存储成绩。
-
当成绩
>= 0时重复执行:-
将该成绩加到
sum中。 -
将
count加1。 -
提示用户输入下一个成绩(或输入-1退出)。
-
从用户那里获取并存储成绩(更新循环控制变量)。
-
-
核心代码结构调整示例:
// ... 数据初始化与之前相同 ...
// 获取第一个成绩
System.out.print("Enter a grade (or -1 to quit): ");
double grade = keyboard.nextDouble();
// 当成绩非负时,持续获取并处理
while (grade >= 0) {
sum = sum + grade;
count = count + 1;
System.out.print("Enter a grade (or -1 to quit): ");
grade = keyboard.nextDouble(); // 更新循环控制变量
}
// ... 计算并打印平均值 ...
这个版本更加灵活,可以处理任意数量的成绩输入。
应用程序三:文本表达式计算器(课后练习)
我们将构建一个计算器,用于解析和计算文本格式的算术表达式(例如 "2 + 2", "5 * 3")。顶层语句是:求解来自文本的算术表达式。
任务说明
表达式格式为:一个数值(左操作数),一个空格,一个运算符符号,一个空格,另一个数值(右操作数)。运算符可能包括 +, -, *, /, %,并且可能考虑使用不同符号表示同一操作(例如 "add" 表示加法)。
挑战
请尝试运用自顶向下逐步求精方法,为这个计算器应用程序设计你自己的伪代码。思考如何从文本中解析出操作数和运算符,如何根据运算符执行正确的计算,以及如何处理可能的错误输入。
总结
本节课我们一起学习了自顶向下逐步求精这一强大的算法设计策略。我们了解到,软件开发不应从盲目编码开始,而应从清晰的规划入手。通过从抽象的顶层语句出发,经过多次细化迭代,逐步增加细节,我们最终能得到一份清晰的伪代码,从而轻松地将其转化为可工作的Java程序。
我们通过构建计数器控制循环和哨兵控制循环的两个平均成绩计算器,实践了这一过程。最后,我们留下了文本表达式计算器作为练习,鼓励大家亲自尝试应用这个方法。记住,这个过程是可扩展的,能够帮助你设计出成千上万行代码的复杂软件。在开始编码之前进行规划,是成为一名优秀开发者的关键习惯。
008:自顶向下逐步求精(第二部分)
在本节课中,我们将继续学习自顶向下逐步求精的设计方法,并将其应用于一个计算器程序的设计中。我们将从抽象的目标开始,通过多次细化,最终得到可以直接映射为Java代码的伪代码。
上一节我们介绍了自顶向下逐步求精作为一种设计策略。它允许我们从抽象的目标出发,逐步细化,最终形成可执行的伪代码。这对于初学者尤其重要,因为它提供了一种将想法转化为具体实现步骤的系统方法。
我们的起点是一个顶层的目标陈述,它非常抽象,不包含任何具体细节。这个陈述最终会成为我们Java类的头部注释。因此,从现在开始,提交作业时,请确保每个类文件都有一个多行注释,说明该类的职责和作者。
设计过程回顾
我们的顶层目标是:从文本中解析并计算数学表达式。
在开始细化之前,我们先定义一些具体的输入输出示例,这有助于明确程序的预期行为:
-
输入:
2 + 3-> 输出:5 -
输入:
2 - 3-> 输出:-1 -
输入:
2 * 3-> 输出:6 -
输入:
2 x 3-> 输出:6 -
输入:
2 X 3-> 输出:6 -
输入:
2 \ 3-> 输出:0 -
输入:
2 / 3-> 输出:0
第一次细化
在第一次细化中,我们将顶层目标分解为几个主要的、但仍较抽象的步骤。
以下是第一次细化的步骤:
-
设置数据模型。
-
从用户处获取表达式。
-
解析表达式并打印结果。
第二次细化
现在,我们对第一次细化中的每个步骤进行进一步分解,使其更具体。
对于“设置数据模型”这一步,我们需要定义程序初始状态所需的变量。
以下是“设置数据模型”的细化内容:
-
声明左操作数变量。
-
声明右操作数变量。
-
声明操作符变量。
-
初始化Scanner对象以获取输入。
-
声明结果变量。
对于“从用户处获取表达式”这一步,我们需要与用户交互以获取输入数据。
以下是“从用户处获取表达式”的细化内容:
-
提示用户输入。
-
获取左操作数。
-
获取操作符。
-
获取右操作数。
对于“解析表达式并打印结果”这一步,我们将其分解为两个子任务。
以下是“解析表达式并打印结果”的细化内容:
-
计算结果。
-
打印结果。
审视第二次细化,我们发现“计算结果”这一步仍然过于抽象,无法直接映射为代码,因此需要进一步细化。
第三次细化
本节中,我们将重点细化“计算结果”这一步骤。其核心是根据不同的操作符执行相应的算术运算。
以下是“计算结果”的细化内容(使用多分支选择逻辑):
-
如果操作符是
+,则执行加法。 -
如果操作符是
-,则执行减法。 -
如果操作符是
*、x或X,则执行乘法。 -
如果操作符是
/或\,则执行除法。 -
否则,报告操作符无效。
至此,第三次细化已经达到了伪代码的级别,我们可以将其逐行转换为Java代码。
将伪代码转换为Java代码
现在,我们将最终的伪代码实现为Java程序。我们将使用伪代码作为行内注释,清晰地表明每行代码的意图。
首先,根据“设置数据模型”的伪代码,我们声明所有必要的变量。
// 设置数据模型
int leftOperand; // 左操作数
int rightOperand; // 右操作数
String operator; // 操作符
Scanner input = new Scanner(System.in); // 输入扫描器
int result; // 计算结果
接着,实现“从用户处获取表达式”的步骤。
// 从用户处获取表达式
String prompt = “请输入数学表达式(格式:左操作数 操作符 右操作数,用空格分隔):”;
System.out.print(prompt);
leftOperand = input.nextInt(); // 获取左操作数
operator = input.next(); // 获取操作符
rightOperand = input.nextInt(); // 获取右操作数
最后,实现最核心的“解析表达式并打印结果”逻辑。这里我们使用 switch 语句来实现多分支选择。
// 解析表达式并打印结果
// 根据操作符计算结果
switch (operator) {
case “+” -> {
result = leftOperand + rightOperand;
System.out.printf(“%d %s %d = %d%n”, leftOperand, operator, rightOperand, result);
}
case “-” -> {
result = leftOperand - rightOperand;
System.out.printf(“%d %s %d = %d%n”, leftOperand, operator, rightOperand, result);
}
case “*”, “x”, “X” -> {
result = leftOperand * rightOperand;
System.out.printf(“%d %s %d = %d%n”, leftOperand, operator, rightOperand, result);
}
case “/”, “\\” -> {
result = leftOperand / rightOperand; // 整数除法
System.out.printf(“%d %s %d = %d%n”, leftOperand, operator, rightOperand, result);
}
default -> System.out.println(“错误:’” + operator + “’ 不是有效的操作符。”);
}
注意:在Java字符串中,反斜杠
\是转义字符。要表示一个真正的反斜杠,需要写成\\。
选择结构:switch 与 if-else 的对比
switch 语句并非实现多分支选择的唯一方式。我们也可以用 if-else if 链达到同样的效果。两种结构各有优劣。
if-else if 链的优势在于其灵活性,它可以处理任何结果为布尔值的表达式(例如关系比较)。而 switch 语句传统上只能进行等值比较。
然而,现代Java中的 switch 表达式引入了一个强大功能:yield 关键字。它允许我们从 switch 的一个分支中返回一个值给外层的代码块,这是 if-else 结构难以简洁实现的。
下面的示例展示了如何使用 switch 表达式和 yield 来改进代码结构,将计算和输出分离。
// 使用switch表达式计算并生成结果字符串
String output = switch (operator) {
case “+” -> {
int r = leftOperand + rightOperand;
yield String.format(“%d %s %d = %d%n”, leftOperand, operator, rightOperand, r);
}
case “-” -> {
int r = leftOperand - rightOperand;
yield String.format(“%d %s %d = %d%n”, leftOperand, operator, rightOperand, r);
}
case “*”, “x”, “X” -> {
int r = leftOperand * rightOperand;
yield String.format(“%d %s %d = %d%n”, leftOperand, operator, rightOperand, r);
}
case “/”, “\\” -> {
int r = leftOperand / rightOperand;
yield String.format(“%d %s %d = %d%n”, leftOperand, operator, rightOperand, r);
}
default -> “错误:’” + operator + “’ 不是有效的操作符。%n”;
};
// 统一打印结果
System.out.print(output);
单元总结
本节课中,我们一起完成了算法设计核心内容的学习。我们回顾了如何将现实世界的问题用数据(初始状态和最终状态)进行建模。接着,我们深入学习了自顶向下逐步求精这一强大工具,它帮助我们将抽象目标层层细化,直至得到可直接转换为代码的伪代码。
我们了解到,任何算法都可以由五种基本操作构建:输入、输出、存储、处理和控制。在控制结构中,我们重点对比了 switch 语句和 if-else 链,并介绍了现代 switch 表达式的 yield 特性。
掌握这些基础,意味着我们已经具备了设计并实现简单程序的能力。接下来的作业和测试将综合运用这些概念,请务必通过实践来巩固理解。
009:僵尸末日 🧟
在本节课中,我们将详细讲解“僵尸末日”作业的要求、设计思路和实现步骤。这是一个回合制生存游戏,我们将采用“先展示,后实践”的教学模式,逐步构建游戏的核心功能,并鼓励你在此基础上添加自己的创意功能。
概述
本次作业的目标是构建一个名为“僵尸末日”的ASCII或Unicode图形化游戏。作业分为两个主要部分:第一部分是跟随详细的步骤指南,完成游戏的基础版本;第二部分是要求你在此基础上,自主设计和实现额外的游戏功能,以展示你对核心编程概念的理解。
作业结构与评分标准
作业文档可能看起来篇幅较长,但这是一件好事。文档非常详尽,会一步步指导你完成作业的前半部分。
作业名称是“僵尸末日”,这是一个类似roguelike的生存游戏。文档中提供了完整应用程序的截图。你可以制作ASCII版本,也可以制作图形更精美的Unicode版本。使用Unicode版本并实现复杂输出操作,可以满足作业的一项要求。
System.out 的功能非常强大,你不仅可以嵌入Unicode字符,还可以使用ANSI转义码来改变终端中字体的颜色或背景颜色。例如,通过ANSI码可以将背景设置为绿色来模拟草地。此外,ANSI码还可以用于清屏,从而实现游戏状态的刷新,这与真实视频游戏的运行方式一致。
评分标准设定了10个目标(从0到9)。每个目标价值5分,完成所有基础目标可获得50分。作业的前半部分主要是跟随指导完成,旨在以学徒模式向你展示良好的编程实践,例如变量命名和如何分解复杂应用程序。
后半部分则需要你展示所学,并自主为游戏添加功能。你可以从建议列表中选择功能实现,也鼓励你发挥创造力,设计自己的功能。这样做的目的是通过开放式作业,让你早期就能开始构建作品集,每个人的游戏都会因此变得独特。
评分标准展示了每个目标及其对应的分值。仅完成基础部分无法获得及格分数,你必须添加额外功能才能完成整个作业。
快速回顾一下,这体现了“先展示,后实践”的方法。学生首先通过逐步迭代一个完整应用程序的设计过程,学习核心概念和良好编码实践。之后,学生被挑战去改进应用程序,添加自行设计的功能。自定义功能必须使用课程目标中的概念才能获得学分。
这些学习目标包括数据建模、算法设计、自顶向下逐步求精的迭代设计过程、测试驱动开发以及创建可读性强的自文档化代码。
我们将评估的编程实践,即我们希望看到你具备能力的方面,包括:展示如何使用处理操作来有意义地改变数据状态;使用重复操作来反复执行代码块,直到满足某个中断条件;以及使用选择操作来决定实际执行哪个(或哪些)代码块。
游戏规格与数据建模
接下来,我们明确什么是roguelike游戏。这为你提供了我们正在构建内容的背景信息。常见的roguelike游戏属性包括:回合制、基于网格(可上下左右移动)、存在永久失败、通常随机生成、具有随机结果等。这些信息主要是为了给你灵感,以便你在扩展游戏时,知道这类游戏通常包含哪些功能。
我们已经讨论了很多关于数据建模的内容。作业文档本身非常详细,没有遗漏任何内容。这里我们定义了数据模型:
-
世界:一个基于网格的2D空间,可以想象成一个棋盘,每个单元格可以通过其行和列来标记。这样我们就可以标识玩家或僵尸的位置。
-
玩家:具有数据属性。
-
僵尸。
-
出口。
在右侧的属性列表中,我定义了如何在游戏世界中建模每一个对象:
-
World:包含网格列大小(整数)、网格行大小(整数)、空瓷砖(字符串)、地板瓷砖(字符串)。 -
Player:包含X位置(整数)、Y位置(整数)、代表玩家的字符串字符。 -
Zombie:包含X位置(整数)、Y位置(整数)、代表僵尸的字符串。 -
Exit:包含X位置(整数)、Y位置(整数)、代表出口的字符串。 -
Scanner:用于获取玩家输入。
游戏目标是:玩家从世界中的某个坐标开始,必须移动到出口才能获胜。如果僵尸触碰到玩家,则玩家死亡。因此,存在获胜条件和失败条件。
在开始实现任何功能之前,我们首先要定义数据模型。与我们之前构建的其他算法或应用程序相比,这里的数据模型更复杂一些,因为每个实体、每个对象都有多个属性。
目标0:顶层伪代码与开发流程
一旦明确了数据模型,我们将从目标0开始,即编写顶层伪代码。每个目标代表软件开发过程中该步骤我想要实现的内容。我从不希望一次性尝试构建整个应用程序,因为那样复杂度会太高、太冗长、太庞大,难以一次性处理。我希望在构建应用程序时采取分而治之的方法,为自己设定能最终导向成品的微小步骤。
著名的格言是“千里之行,始于足下”。在设计应用程序时,我们不应考虑一次性构建整个应用程序并在完成前不进行测试或评估,而应该为自己设定一个小目标。我的第一个小目标就是创建伪代码,这就是目标0。
我将把每个目标分解为:
-
规划步骤:明确我想要实现什么。
-
实现步骤:如何在Java中编码实现。
-
测试步骤:验证该目标是否按预期工作。
我将对这个过程的10个目标(从0到9)中的每一个都重复此过程。
我的计划是:首先以非常宏观的方式定义整个应用程序的算法,忽略具体细节。这有助于将复杂的应用程序分解为一组类似的、更简单的任务。项目的后续部分将专注于迭代地实现这些任务,一次一个功能。这个过程我们称之为快速原型开发,在软件开发世界中非常常见。
我们的顶层游戏算法如下:
-
设置游戏数据。
-
运行主游戏循环:
-
绘制游戏场景。
-
获取玩家输入。
-
执行玩家动作。
-
检查是否满足获胜条件。
-
如果游戏未获胜,则执行怪物动作。
-
检查是否满足失败条件。
-
这表达了定义游戏玩法的指令集。为了完成这个应用程序,我需要用Java代码来表达所有这些语句。
在以一种非常类似伪代码的方式从算法上定义了游戏的功能之后,我将把它们转换成注释。这与我们学习的自顶向下逐步求精的过程相同。我已经定义了一个算法,现在我将把它们表示为逐行的注释,然后可以查看并思考如何实现。
在实现步骤中,我将定义一个类和一个方法,因为我的所有语句都必须放在方法中,所有方法都必须放在类中。然后,我将放入代表算法的注释。接着,我会测试以确保可以编译并运行到这一点。目前还没有实际实现的代码,所以它不应该做任何事情。但如果出现错误,意味着存在某种语法错误,我需要解决它。在进入下一个目标之前,始终要确保在每一个测试阶段都能编译你的代码,并且输出与指南中提供的输出相似。
目标1:定义并绘制游戏网格
完成目标0后,如果代码编译并执行,现在进入目标1。我给自己一个新的任务,当我完成这个任务时,就进入下一个目标。在这里,我一次只看小目标。我需要创建一个包含玩家、僵尸和出口的游戏世界。但在做任何这些之前,让我看看是否可以先定义和绘制游戏网格。这个小小的目标将使我离构建完整应用程序更近一步。
我为这个目标制定计划:如何定义和绘制网格?让我们专注于绘制一个空的游戏网格。建模网格所需的数据是:网格中的行数、网格中的列数以及地板瓷砖图形。
我的伪代码是:对于每一行,对于每一列,打印地板瓷砖。打印完一行后,执行换行符,下一行出现在其下方。
一旦我们理解了这一点,并且在你完成这个实验时,请确保理解每个伪代码在做什么以及对应的Java代码是什么样的。我不希望你只是复制粘贴,我希望你从根本上理解发生了什么,理解每一步背后的动机。
实现如下:注释“设置游戏数据”处的黄色代码是新增的代码,它应该放在你已有代码文件中的什么位置。一个常见的错误是反复复制粘贴相同的代码。你是在上一个目标的基础上进行编辑。新增的代码用黄色高亮显示,旧代码则不高亮。这意味着“设置游戏数据”这个注释已经存在。所以,在这个注释下面,我们将添加黄色的代码。
我们将添加:
-
columnSize = 10 -
rowSize = 10 -
floorTile = ". "
这是我们的第一步,初始化世界网格数据属性。这个目标的第二步是创建一个双重for循环,让我可以绘制行和列。
这里你可以看到一个双重for循环,一个用于绘制行,另一个用于绘制列。在双重for循环内部,我将打印出地板瓷砖。这完全遵循了我的规划步骤和伪代码。
同样,我只是遵循自顶向下逐步求精,一旦有了伪代码,我就在旁边匹配Java语句。右侧是我的逐行概念解释。
一旦实现了这一点,我就进行测试。实际编译我的Java代码并执行,然后说:在这个阶段,我是否得到了预期的输出?你应该做同样的事情。在确认这个特定目标工作正常之前,不要进入下一个目标。因为如果你得到一个错误,现在捕获错误并调试比等到作业结束时再处理要容易得多。等到最后可能会有100个不同的错误,而这些错误可能从目标1就开始了,那时调试和找出问题所在将变得非常困难。所以尽早捕获它们,这就是测试驱动开发的全部意义:我们做一点,然后测试;再做一点,再测试;不断添加复杂性,从简单开始,直到构建出复杂的东西。
目标2:定义并绘制玩家
一旦我得到了空的网格,我给自己一个新的目标:第二个目标,我想要定义并绘制玩家。同样,我的规划步骤讨论了如何在我的游戏算法中建模和定义它。
让我们专注于在网格中绘制玩家。建模玩家对象所需的数据是:玩家X位置、玩家Y位置和玩家瓷砖图形。
然后我给出伪代码:在我们进行for循环执行绘制逻辑时,我们不只是为每个实例绘制空瓷砖,我们想检查当前正在绘制的瓷砖是否与玩家位置相同。如果是玩家位置,我们将绘制玩家瓷砖;如果不是,则绘制空瓷砖。
现在你可以看到我们如何在绘制逻辑中做出决定,判断是玩家还是地板。一旦我们理解了它在伪代码和自然语言中如何工作,我们将在Java程序或Java应用程序中对它进行编码。
我将进入我的“设置游戏数据”部分,已有的代码不高亮,所以我将找到应用程序内部、类内部的那个空间,并在其后添加这段新代码。我将定义一个玩家位置 playerX, playerY,我将把他们放在 (0, 0) 位置,然后我将给出一个符号来定义玩家,例如“@”符号。
然后,我将进入之前已有的“绘制游戏场景”逻辑,并创建一个选择语句来确定是应该绘制玩家瓷砖还是地板瓷砖。一旦完成,我就进行测试。我编译、运行,这现在应该是我游戏应用程序的预期输出。如果我得到了这个,我可以给自己一个新的目标,让自己离完成这个游戏更近一步。
目标3:创建游戏循环
下一个目标是:好的,我可以绘制游戏世界,可以绘制玩家。让我们创建一个游戏循环,可以反复绘制游戏世界状态,一帧又一帧。这样我们就可以开始更新玩家的移动,开始实现用户交互性,并且基于用户提供给我们的输入,我们实际上可以改变玩家的位置。在我们能做到这一点之前,让我们将逻辑编码到一个循环中,以便拥有那个循环过程。
我们的计划是:主游戏循环负责重复执行每个游戏回合的事件和动作。游戏循环是哨兵控制的,因为游戏回合的数量取决于是否出现输赢条件。由于游戏循环不断重复单个回合的所有动作,因此完整回合的所有游戏逻辑都必须嵌套在while循环中。
一旦我们定义了逻辑并确定了游戏循环,我们就可以实现它。在这里,我将创建一个新的变量作为循环控制变量,我称之为 gameOver,并将其初始化为 false。然后,我将创建一个while循环,并将我之前的所有内容封装到我的while循环中。只要游戏没有结束,我就会执行所有这些动作:绘制动作、获取玩家输入动作、执行玩家动作、检查获胜条件、执行怪物动作和检查失败条件。
然后,我将进行测试,编译并运行。我们应该得到的是在控制台上无限打印出相同的游戏世界,一遍又一遍。
目标4:实现玩家移动输入
现在我们有了一个循环,如果我们然后查询或提示玩家给我们一个动作,我们可以接受那个动作,改变玩家的状态,当下一次我们打印出游戏世界的状态时,我们的玩家应该绘制在一个不同的位置。所以,现在我们有了一个无限游戏循环,下一个目标是继续更新玩家的状态。
在这里,我们最终将实现玩家的输入移动。我们的计划是:我们将使用多重选择来更新每个回合的玩家X和Y。来自Scanner对象的玩家输入用于确定玩家X或Y坐标是递增还是递减。玩家控制键是:W上移、S下移、A左移、D右移。需要注意的是,由于这是一个基于网格的世界,每个单元格都有X和Y坐标,你应该理解(0,0)位置在左上角。这意味着向上移动是Y递减,向下移动是Y递增,向左移动是X递减,向右移动是X递增。这定义了我们如何通过改变位置在世界上移动。这是我们的数据模型,这就是我们如何将空间视为定量数据,以及如何使用算术运算来改变我们在世界中的位置。
一旦我们理解了这一点,我们就继续实现。同样,我们将按照这些说明来更新玩家状态,然后进行测试和运行。当你这样做时,你应该能够输入W、S、A、D,并实际观察你的玩家移动。目前还没有更新循环控制变量,所以如果你想终止游戏,必须按Ctrl+C来中断逻辑。
目标5:定义并显示出口
第五个目标,我们将定义并显示出口。这与之前所做的非常相似。我们现在将创建 exitX、exitY 和 exitTile。我们将把显示出口的功能添加到绘制逻辑中。然后我们将测试,看看出口是否出现。
然后,我们将测试当玩家触碰到出口时是否能中断游戏循环。在检查获胜条件的注释内部,伪代码是:如果玩家坐标和出口坐标相同,那么我们将 gameOver 设置为 true,并打印胜利消息。
现在我们知道伪代码是什么了,我们将进入检查游戏获胜条件并实现它。然后我们将测试,移动玩家穿过出口瓷砖,看看游戏是否停止运行。
目标6:定义并绘制僵尸
如果那可行,我们将定义并绘制一个僵尸。我们将创建僵尸坐标,在定义该逻辑的游戏场景中绘制僵尸,然后进行测试,看看僵尸是否被绘制出来。
目标7:随机移动僵尸
然后我们将随机移动僵尸。这里我有我的伪代码,我们有一个执行怪物逻辑以随机移动的小算法。伪代码在这里给出,然后实现也在这里给出。这里发生的情况是:我们随机生成一个0到3之间的数字。如果数字是0,我们将僵尸向右移动;如果是1,则向左移动;如果是2,则向上移动;如果是3,则向下移动。我们编译它,测试以确保僵尸移动。
目标8:定义游戏结束条件
然后我们将定义游戏结束条件。游戏结束条件是:如果玩家坐标与僵尸坐标相同,那么我们将 gameOver 设置为 true,并打印失败消息。我们将此语句放在失败条件下,检查它们是否相同,并将循环控制变量 gameOver 设置为 true,然后打印出一条消息。我们将测试看看是否可行。
这就是作业的前半部分。之后,你将需要添加自己的自定义功能。
后半部分:添加自定义功能
现在,每个人都必须添加的一个功能是:你必须向算法添加规则,以将玩家限制在关卡内。在最初的实现中,玩家可以轻易走出关卡并作弊。因此,需要约束这一点,防止玩家能够退出世界的上、下、左、右边界。
之后,这里有一些选项,每个选项价值10分。每个你实现的功能(超出达到100分所需的部分)都可以算作加分。因此,如果你不断添加功能,你可以在这个作业中获得超过100分。
你可以添加的功能示例,以展示你理解游戏逻辑的工作原理,包括:
-
添加更多僵尸。
-
添加多个关卡。
-
添加更聪明的僵尸(例如,让僵尸有模式或直接向玩家移动)。
-
添加障碍物(如墙壁等)。
-
添加随机放置的钥匙(出口在获得钥匙之前无法进入)。
-
添加武器(允许你在拥有武器时杀死僵尸,而不是僵尸自动杀死你)。
-
改进图形(默认图形是ASCII字符;如果你使用Unicode字符或ANSI码字符升级它,你可以获得加分。如果你不熟悉,可以查找相关资料。在Java中搜索“ANSI code Java”,你会找到如何渲染背景颜色和进行屏幕刷新的示例代码。这是一个容易添加的功能,建议你研究一下)。
-
添加食物计数器(你有有限的移动次数,必须在前往出口的路上收集食物才能成功,如果路径选择不正确,你会饿死)。
这些都是选项,或者你可以发挥创意,添加我未指定的功能。同样,我给出了一个基础列表,但不要感到受其约束。
提交时,请提交你的 ZombieApocalypse.java 文件和 README.txt 文件。README.txt 应包括你添加的功能,并讨论你如何使游戏变得更好。
请记住,在这门课程中,你获得任何作业学分的唯一方式是与我预约审核,运行代码并展示给我看。我将一对一为你的实现提供反馈,因为我认为反馈是学习编码过程中非常重要的一部分。
如果你还没有开始,现在就开始。请注意,这需要一些时间。这些作业的范围比实验大得多,这是有意为之。实验是教你如何按照自己的设计进行编码,而作业则是向你展示如何构建更大的应用程序,但更像是引导你完成它。
总结
在本节课中,我们一起详细剖析了“僵尸末日”作业。我们学习了如何通过定义清晰的数据模型(世界、玩家、僵尸、出口)来构建游戏基础,并遵循自顶向下、逐步求精的迭代开发流程,从伪代码开始,分多个小目标(从绘制网格到实现完整游戏循环)逐一实现。我们强调了测试驱动开发的重要性,即每完成一小步就进行编译和测试。最后,我们探讨了作业的后半部分——如何发挥创造力,在基础版本上添加自定义功能(如更多敌人、更智能的AI、更好的图形等),从而将所学知识转化为个人作品。记住,完成作业的关键在于理解每一步背后的逻辑,而不仅仅是复制代码,并且最终需要通过一对一的代码审核来获得反馈和学分。
010:DRY与KISS原则入门
在本节课中,我们将正式从算法设计单元过渡到软件设计单元。我们将学习两个核心的软件设计原则:DRY原则和KISS原则。这些原则将帮助我们编写更简洁、更易维护的代码,并为后续学习方法、数组和类等新工具打下基础。
什么是DRY设计?
上一节我们介绍了算法设计的基本构建模块。本节中,我们来看看DRY设计原则。
DRY是“Don’t Repeat Yourself”的缩写。在编程中,这意味着我们不应该重复编写相同的代码。如果相同的代码块出现在两个或更多地方,我们就应该将其提取到一个独立的方法中。同样,如果有一组相关的值,我们应该将它们放入数组或类实例中,以便将它们组织在一起。
DRY原则的核心表述是:系统中的每一段知识(数据或逻辑)都必须有单一、明确的表示。这意味着它应该只存在于一个地方,而不是像杂草一样散布在整个应用程序中。
为什么要在乎DRY代码?
遵循DRY原则能让你的代码更易于编写和阅读。违反DRY原则通常会导致代码库急剧膨胀,例如达到上千行代码。大量重复的代码会使阅读和理解变得困难,因为你必须费力地记住“这段代码是给僵尸1用的吗?还是给僵尸2或关卡3用的?”。这会增加大量的心智负担。
DRY代码还能让你的代码更易于维护。当逻辑需要修改时,你只需要在一个地方进行更改,这个更改会自动传播到所有使用该逻辑的地方。反之,如果逻辑被重复编写,你就必须找到每一个重复的实例并逐一修改,这很容易引入错误和漏洞,工作量也大得多。
如何实现DRY?
为了避免违反DRY原则,我们采用“分而治之”的策略,将系统划分为更小的部分。
以下是实现DRY的关键步骤:
-
将代码和逻辑划分为更小的、可复用的单元。可以将其想象为模块化,就像搭建乐高积木一样。
-
避免编写冗长的方法,而是将逻辑分解,并在方法内部使用这些现有的代码单元。
-
在需要的地方通过“调用”来使用这些代码单元。
我们已经讨论了DRY的好处:代码更少(少即是多)、节省时间和精力、易于维护以及减少出现bug的可能性。
什么是KISS原则?
除了DRY,我们还需要遵循另一个重要原则:KISS。
KISS代表“Keep It Simple, Stupid”(保持简单)。KISS原则要求我们保持代码简单明了,使其易于理解。毕竟,编程语言是给人看的,而不是给计算机看的。计算机只是无意识的二进制计算机器。我们花费精力格式化代码、选择好的变量名、将逻辑分解到多个方法中,都是为了我们人类自身的利益——为了创建一个易于回溯、重构、分享和理解的代码库。
如何实现KISS?
KISS原则在代码库中的一个常见体现是:每个方法应该只解决一个小问题,而不是处理许多用例。如果一个方法中有很多条件判断,就应该将它们分解成更小的方法。我们将在后续关于方法的课程中看到,这种“逐步求精”的过程将帮助我们进行所谓的“功能分解”。
以下是实现KISS的关键步骤:
-
尝试编写简单的代码。为你的问题构思多种解决方案,然后选择最好、最简单的一个来实现。
-
当你发现代码冗长时,将其分解为多个方法。
-
编写只完成单一任务的小代码块。一个经验法则是:当你查看一个方法时,问问自己“这个方法做得太多了吗?还是它只做一件事?”
KISS原则的好处我们已经讨论过:它能让一个开发者编写的功能,易于被其他所有人(包括未来的你自己)理解。最终目标是,将你的代码库视为一个不断演进、充满活力的文档集合。我们想要创建一个框架,能够轻松添加新功能,而不会破坏旧有的工作。
单元2的核心工具
为了实现DRY和KISS,我们将在单元2中重点学习三个核心工具:
-
方法:用于封装代码块。
-
数组:一种特殊的数据结构,可以容纳多个值,用于封装数据块。
-
类:允许我们将代码块和数据块封装在一起。
这些基础构件将帮助我们编写出符合DRY原则的代码,从而构建更健壮、更易扩展、易于添加、阅读和分享的软件。
总结
本节课中,我们一起学习了软件设计的两个基本原则:DRY(不要重复自己)和KISS(保持简单)。我们了解了为什么这些原则对于编写高质量代码至关重要,并探讨了实现它们的基本方法。最后,我们预览了在单元2中将用来实践这些原则的三个核心工具:方法、数组和类。掌握这些原则和工具,将使我们能够构建更清晰、更易维护的软件。
011:方法 🧩
在本节课中,我们将要学习软件开发中的一个核心概念:方法。我们将探讨什么是方法,它们与变量的区别,以及如何利用方法来编写更清晰、更易维护、更高效的代码。通过一个计算加权平均分的实例,我们将学习如何通过“自顶向下、逐步求精”的设计过程来分解问题,并最终用方法来实现我们的程序。
概述:什么是方法?
方法允许我们为一段代码块命名,这样我们就可以通过使用它的名字来调用它。方法非常有用,因为它们提高了代码的可读性和可靠性。
为了理解方法,我们可以将其与变量进行对比。
以下是变量与方法的对比:
-
变量:一个命名的数据,我们将其存储起来以便后续使用。
-
方法:一个命名的代码块,我们将其存储起来以便后续调用。
它们有些相似:变量让我们将一个具体的值存入内存,在程序后续需要时使用;方法则让我们为一段代码赋予一个名字,通过调用这个名字,就能执行那段代码。
通常,区别在于调用方式。当我们想要引用一个变量时,它看起来像这样:x + 5。这里,我们直接使用变量名 x。而当我们想要调用一个方法时,假设它也叫 x,那么调用方式会是 x()。无论是否需要传递输入参数,每次调用方法都必须使用括号 ()。
因此,在阅读代码时,你可以通过一个标识符是单独的名字,还是名字后跟了一组括号,来判断它代表的是一个命名变量还是一个命名方法。
方法与类
类可以包含方法,而方法内部可以包含代码。从一开始我们就知道这一点,但之前我们忽略了可以创建用户自定义方法的事实,以便专注于代码本身。现在,我们将开始专注于构建方法。
需要理解的是,当我们构建方法时,必须将所有方法都放在类的范围内。就像我们一直编码的方式一样,在开始构建应用程序时,我们会定义一个类。每个需要执行的应用程序都必须至少有一个 main 方法。如果一个类中没有 main 方法,那么这个类就不能独立运行,不能作为我们应用程序的入口。
但是,我们可以拥有不止一个方法。我们可以创建自己的方法,但每次创建时,都必须在类的范围内声明它。
方法声明 vs. 方法调用
声明是我们首次定义方法的时候。所有方法都必须在类的范围内声明。
调用是我们调用或使用那个方法的时候。我们通常在其他方法内部进行调用,以让它为我们执行任务。
理解这一点的方法是:方法之间通过相互传递数据来进行“交流”。一个方法可以通过两种方式传输数据:
-
向方法发送数据:我们可以通过将值放在方法名后的括号内,将数据从一个方法发送到另一个方法。这就是括号如此重要的原因,它代表了该代码块需要处理的数据。并非所有方法都需要输入值,但有些需要。
-
从方法返回数据:一个方法也可以将数据返回给调用它的方法。如果我们需要从一个方法返回数据给另一个方法,我们会使用
return语句。return语句通常是任何方法的最后一条语句。
在方法声明中,返回值类型的位置指明了这一点。如果你不需要返回任何数据,返回类型就是 void。但如果你确实想返回数据(一个方法只能返回一个数据),那么我们在通常写 void 的地方,写上要返回的数据类型。例如,如果一个方法要返回一个整数值,那么返回类型就是 int,并且我们必须将其写在方法头中。
方法的设计原则
方法头可以被视为我们代码实现与其他所有调用方法之间的一份契约。当我们开始创建方法时,目标是给方法起一个好名字,清晰地定义调用该方法时会发生什么。
在Java(以及大多数编程语言)中,通常的做法是选择一个好的动词,来传达将要执行的动作、事件或语句序列。方法名应该非常具有描述性,通常可以将其视为一个动作概念。通常,我们将变量名视为名词,将方法名视为动词。这样,我们就在代码库中构建起一种语言:我们拥有“事物”(名词),可以通过将这些“事物”发送给“动词”(方法)来对它们进行操作。
这些“动词”(方法)可以从另一个方法接收数据,也可以将数据返回。
方法如何帮助我们遵循DRY原则?
方法使我们能够避免重复,以下是六种主要方式:
-
定义一次,多次运行:方法允许我们定义一次算法,然后通过其名称多次运行它。这非常强大,尤其是在你已经完成的“僵尸启示录”游戏中,它让你可以编写一次代码,然后无限次重复使用,无需再次修改。
-
分解算法:方法允许我们将算法分解成更小的部分。将所有代码都放在
main方法中会导致main方法非常难以阅读,这是糟糕的设计。我们的目标是通过将代码库分解到多个方法中,使其更易于管理。一个好的经验法则是:一个方法不应超过十几行代码。如果超过了,这可能是一个信号,提示你应该将其分解成更小的方法。另一个经验法则是:一个单一的方法应该只负责一项任务。 -
便于分步实现和测试:方法使我们的代码更容易分步实现和测试。你可以一次定义一个方法,然后测试其输入和输出,以确保其按预期工作,甚至在应用程序的其他部分完成之前就可以进行。这是进行单元测试的有效方式,代码库中最小的可定义单元就是方法。
-
分工合作:方法允许我们将项目分成多个部分,以便多人协作。我们可以分配任务,让不同的人负责编写不同的方法,而不是让一个人独自完成整个应用程序。
-
提高可读性:方法使我们的代码更容易阅读和跟踪。每个方法都有自己的职责,这使得维护、重构和调试变得容易得多。
-
保持主方法简洁:
main方法不应包含任何实现细节。我们之前严重违反了这一概念,因为我们不了解方法。但今后,我们希望main方法主要充当“委托者”的角色,其中唯一应该发生的事情就是调用其他方法。这使得main方法更具可读性,其目的应该是清晰地展示整个算法的总体逻辑。
所有这些概念共同作用,实现了我们所谓的自文档化代码。这些策略使得重新阅读代码以理解其逻辑变得尽可能容易。这不仅是为了他人,也是为了你自己。当你离开代码一段时间再回来看时,这些概念能帮助你快速回忆起关键的设计决策。
方法与功能分解
当我第一次讨论“自顶向下、逐步求精”过程时,我说过我们可以用它来做两件事。第一,我们可以从一个顶层语句开始,使用这种迭代的、逐步的方法,将这个简单的摘要扩展成一套完整的伪代码语句。伪代码的好处是它在概念上非常接近编程语言,使得我们可以将其一对一地映射到Java、Python或C++等具体语言。
除此之外,“自顶向下、逐步求精”过程还可以帮助我定义哪些部分应该成为方法。既然我们现在讨论方法,我们将再次演练这个过程,看看求精过程本身如何开始分解出应该被定义为方法的代码块。
我们将通过一个计算加权平均分的例子来具体看看功能分解和“自顶向下、逐步求精”过程是如何运作的。
顶层语句:确定一个学生基于任意数量的实验、作业和测试的加权平均分。
求精步骤1(主方法蓝图):
-
初始化所需数据。
-
从用户获取实验成绩并求和、计数。
-
从用户获取作业成绩并求和、计数。
-
从用户获取测试成绩并求和、计数。
-
计算并显示总加权平均分。
这个第一层求精仍然比较抽象,但它可以作为我们 main 方法的蓝图,代表需要调用的五个主要方法。
求精步骤2(方法内部逻辑):
当我们详细展开第二步(获取实验平均分)时,逻辑是:循环直到输入负数,将成绩加到总和,计数加一,提示用户输入,获取输入,最后计算平均分。
仔细观察会发现,步骤2、3、4(获取实验、作业、测试平均分)的逻辑几乎完全相同,唯一的区别是给用户的提示信息。这立即违反了DRY原则,提示我们应该将这些重复的逻辑合并到一个方法中。
通过这个设计过程,我们明确了:
-
顶层语句成为类的头部注释。
-
求精步骤1成为
main方法中要调用的方法列表。 -
求精步骤2及更深的求精定义了每个方法内部的具体实现。如果需要更多求精步骤,则表明某些方法可能需要进一步委托工作给其他辅助方法,从而形成一个方法调用树。
代码实现示例
基于上述设计,我们最终实现了一个程序。其核心是:
-
一个
getAverage方法,它接收一个字符串参数作为提示信息,负责与用户交互,收集一系列成绩,计算并返回平均分。 -
main方法非常简洁,只包含几个方法调用和最终计算:double testAverage = getAverage("test"); double homeworkAverage = getAverage("homework"); double labAverage = getAverage("lab"); double totalAverage = testAverage * 0.3 + homeworkAverage * 0.3 + labAverage * 0.4; System.out.printf("Your total average is: %.2f", totalAverage);
通过这种方式,main 方法变得高度可读,精确地反映了我们最初的求精步骤1。所有繁琐的实现细节都封装在了 getAverage 方法中。这种方法设计也避免了代码重复,完美遵循了DRY原则。
总结
本节课中,我们一起学习了软件开发中的核心构件——方法。我们理解了方法作为“命名的代码块”的本质,以及它如何通过提高可读性、可维护性和避免重复来提升代码质量。我们深入探讨了方法的声明与调用、参数传递与返回值。更重要的是,我们通过“自顶向下、逐步求精”的设计流程,实践了如何将复杂问题分解为多个方法,并最终实现了一个计算加权平均分的程序。记住,好的方法设计意味着单一职责、适当长度,以及一个清晰、可读的 main 方法作为程序的总指挥。
012:方法、类、包与应用程序接口 📚
在本节课中,我们将深入学习Java中的方法、类、包以及应用程序接口(API)。我们将探讨如何通过方法封装代码,如何将相关方法组织到类中,以及如何使用Java标准库中的API。课程内容将帮助初学者理解这些核心概念,并学会如何阅读和使用API文档。
上一节我们介绍了方法的基本概念和优势,本节中我们将详细拆解方法的构成部分。
方法详解 🔍
方法由两部分组成:方法头和方法体。方法头定义了方法的访问方式、返回类型、名称和参数。方法体包含了实现具体功能的代码块。
以下是方法头的四个组成部分:
-
访问修饰符:例如
public或private,决定其他类是否可以访问此方法。 -
静态修饰符:
static关键字表示该方法属于类本身,无需创建对象实例即可调用。 -
返回类型:方法执行后返回的数据类型。如果不返回任何值,则使用
void。 -
方法签名:由方法名和参数列表共同构成,用于唯一标识一个方法。
方法体是包含在一对大括号 {} 中的代码块,它包含了方法要执行的所有指令。
上一节我们了解了方法的构成,本节中我们来看看方法如何与输入输出交互。
方法的输入与输出 🔄
方法通过参数接收输入,并通过返回值提供输出。根据是否有输入和输出,方法可以分为四种类型。
以下是方法的四种类型:
-
有输入,有输出:方法接收参数并返回一个值。例如,计算两个数之和的方法。
public static int sum(int a, int b) { return a + b; } -
有输入,无输出:方法接收参数但不返回值(
void)。例如,打印一条定制消息的方法。public static void printMessage(String msg) { System.out.println(msg); } -
无输入,有输出:方法不接收参数但返回一个值。例如,获取固定问候语的方法。
public static String getGreeting() { return "Hello from Java!"; } -
无输入,无输出:方法既不接收参数也不返回值。例如,执行一个固定任务的方法。
public static void doTask() { System.out.println("Task completed."); }
调用方法时,必须按照方法签名中定义的顺序和类型传递参数。返回值可以被变量接收或在表达式中直接使用。
理解了方法的基本运作后,我们来看看如何将相关的方法组织在一起。
类与包 📦
类是一个容器,用于组织相关的方法和数据(字段)。例如,Java标准库中的 Math 类就包含了各种数学运算方法和常量(如 PI)。
包是用于组织相关的类的目录(文件夹)。Java使用包来管理庞大的类库,避免命名冲突。例如,java.lang 包包含了Java语言的核心类。
public 关键字使得一个类或方法可以被其他包中的类访问,这是代码可扩展性和重用的基础。
上一节我们讨论了如何组织代码,本节中我们来看看如何理解和使用他人编写的代码。
应用程序接口(API)文档 📖
API(应用程序编程接口)文档是软件组件的使用说明书。对于Java,API文档详细列出了每个包、类、方法和字段的说明。
阅读API文档时,每一行对应一个方法,其信息与方法头一一对应:
-
修饰符和类型:如
public static。 -
返回类型:方法返回的数据类型。
-
方法签名:方法名和参数列表。
-
描述:用自然语言解释方法的功能。
例如,Math 类中 abs(double a) 方法的API条目表示:这是一个公共静态方法,接收一个 double 类型参数,并返回一个 double 类型的绝对值。
只有 public 方法会出现在API文档中,因为只有它们才能被外部代码调用。
为了巩固对API的理解,让我们实际查看一下Java的标准API。
探索 java.lang 包 🧭
java.lang 包是Java语言的核心包,它会被自动导入到每一个Java程序中。我们无需编写 import 语句即可使用其中的类,如 System、String 和 Math。
以下是查看 Math 类API的步骤:
-
打开Java官方API文档(例如,搜索“Java 18 API”)。
-
找到
java.lang包。 -
在类列表中点击
Math。 -
在
Math类的页面中,你会看到两部分:-
字段:类中的常量数据,如
PI和E。 -
方法:类中提供的所有静态方法列表,如
sqrt(平方根)、pow(幂运算)、max(最大值)等。
-
通过API文档,你可以快速了解有哪些现成的工具可以使用,从而避免“重复造轮子”。
本节课中我们一起学习了Java中方法、类、包和API的核心概念。我们拆解了方法的构成,区分了不同类型的方法,了解了如何通过类来组织代码,以及如何利用包来管理类库。最重要的是,我们掌握了阅读API文档这项关键技能,它能帮助我们高效地使用Java丰富的内置功能和他人的代码库。记住,优秀的开发者不仅善于编写代码,更善于利用现有的资源。
013:应用程序接口与数学类 📚
在本节课中,我们将继续探讨方法、类和包。我们将重点学习如何阅读和使用API文档,理解Java标准库中的Math类,并动手创建我们自己的工具类和方法。
概述
上一节我们介绍了方法的基本结构,包括方法头和方法体。本节中,我们来看看如何利用他人编写的方法,特别是通过Java的应用程序接口文档。我们将深入研究java.lang包中的Math类,学习如何使用其提供的数学方法,并最终创建我们自己的工具类来封装自定义功能。
解析API文档 📖
API文档为我们提供了关于他人编写的方法的宝贵信息。要使用这些方法,理解如何解析文档至关重要。
方法头向JVM提供了该方法的所有重要信息,包括:
-
访问级别:例如
public(所有代码可访问)、private(仅本类可访问)或static(可通过类名直接访问)。 -
返回类型:可以是
void(不返回值的过程方法)或任何数据类型(如int、double、String,这类方法称为函数方法,必须包含return语句)。 -
方法名:方法名本身不必唯一。
-
参数列表:定义在括号内,由数据类型和变量名组成,用于接收调用者提供的输入值。
方法的唯一标识是其签名,即方法名和参数列表的组合。
在API文档中,方法通常按以下格式列出:
修饰符 返回类型 方法名(参数类型 参数名)
例如,对于Math类中的绝对值方法:
public static double abs(double a)
这表示一个公开的、静态的方法,接收一个double类型参数a,并返回一个double值。
Java标准库与Math类 🔢
Java代码的组织结构是:语句存在于方法中,方法存在于类中,类存在于包中。java.lang包是核心包,会被自动导入到所有Java应用程序中。
算术运算符(+, -, *, /)的功能有限。Math类将一系列相关的数学方法(如计算绝对值、三角函数、平方根、幂运算)分组在一起,为我们提供了更复杂的数学运算能力。
以下是Math类中一些关键概念的描述:
更多推荐



所有评论(0)