慕尼黑工业大学计算机编程入门与实践笔记-全-
慕尼黑工业大学计算机编程入门与实践笔记(全)
001:理解代码运行的报错
在本节课中,我们将学习如何理解代码运行时的报错信息。通过一个具体的例子,我们将看到如何定位错误、分析错误原因,并介绍使用调试器的基本思路。
定位与分析错误

上一节我们介绍了编程的基本概念,本节中我们来看看当代码出现问题时,如何定位并分析错误。
如果你对代码为何失败有初步想法,可以进入集成开发环境(IDE)并在 main 方法中运行你的代码。在本例中,我们将针对“海豹电报解码器”练习进行此操作。该练习的目标是使用一个名为 checkHistoryWithCode 的方法,判断是否存在一个具有给定代码的海豹。

为此,让我们进入 IDE。我们最好的帮手是包含 main 方法的 Main 类,你可以运行它。在 main 方法中,创建与本练习相关的类的对象是一个好主意。
以下是创建对象的步骤:
- 创建一个
Deed对象。 - 创建一个
Citizen对象。 - 创建一个
Registry对象。
拥有这些对象后,你可以调用这些对象上的方法来验证它们的行为。在本例中,我们调用 checkHistoryWithCode 方法。此外,如果我们进入这个方法,再进入 searchWithCode 方法,可以看到我添加了一个打印语句。
该打印语句会为我们检查的每个海豹,输出其名称和代码。这将帮助我们理解错误发生的原因。
运行代码与查看输出
准备好代码后,我们可以转到 main 方法,点击绿色箭头运行它。下方将显示运行输出。重要的是选择第二个下拉选项。
然后,如果你向上滚动,将看到代码的输出。例如,这里我们可以看到打印的内容:我们打印出正在检查一个海豹的代码,因为我们的注册表中只有一个海豹。
我们正在将代码“4个点”与“4个点”进行比较。根据练习的规范,这应该失败。我们还可以在下面看到 NullPointerException。如果点击这个蓝色链接,可以精确地看到异常被抛出的位置。
进一步调试
现在,如果我们想进行更深入的调试以真正理解发生了什么,可以使用调试器。

我将在下一个视频中向你展示调试器的使用方法。
总结

本节课中我们一起学习了如何理解代码报错。我们通过一个实例,演示了如何在 main 方法中运行代码、添加打印语句来追踪程序状态、查看运行输出以定位异常,并了解了使用调试器进行深入分析是下一步。掌握这些步骤是有效诊断和修复程序错误的基础。
002:使用调试器 🐛

在本节课中,我们将学习如何使用调试器来检查程序在运行时的状态。调试器是定位和修复代码问题的强大工具。
调试器允许你在程序运行时检查其执行过程。

上一节我们介绍了调试器的基本概念,本节中我们来看看如何开始使用它。

使用调试器的第一步总是设置一个断点。

断点会告知集成开发环境,你希望程序执行到该断点时暂停。

在我们的案例中,我将把断点设置在代码的第24行。
然后再次点击绿色箭头,但这次选择调试选项。
执行此操作后,我们会看到该行代码被高亮显示为蓝色。
这意味着程序执行在该行暂停。在下方的面板中,


我们可以看到当前作用域内的所有变量。例如,我们的 deed 变量,
以及 sealCitizen 变量。如果我们点击这个箭头,还可以看到 sealCitizen 对象拥有的属性。

如果我们想继续执行程序,可以通过几个选项来控制。使用这个箭头,
我们可以“步入”当前行正在调用的方法。在我们的例子中,就是 checkHistoryWithCode 方法。

我将点击一次,现在我们进入了这个方法内部。

我想进一步进入 searchWithCode 方法。
所以我将再次点击同一个箭头。

在这里,我们已经能看出为什么返回 null,因为我们正在检查所有公民,但没有一个代码匹配。

在这种情况下,我们可以在这行代码上设置另一个断点,然后点击绿色箭头让程序继续运行,直到命中下一个断点。
现在我们停在这里,可以清楚地看到,它确实返回了 null。既然我们知道了这一点,

就可以再次“步出”这个方法。

并返回到上层调用。现在我们可以“单步跳过”这一行。
这基本上意味着直接执行完当前行,然后跳到下一行。

我们可以在下方的变量面板中看到 citizen 的值是 null。

这告诉我们,因为我们在一个 null 的 citizen 上调用了 printHistory 方法,所以会得到一个空指针异常。

我们可以通过恢复程序运行来验证这一点。

然后控制台会输出和之前一样的错误信息。
现在我们知道,在这一行代码中,我们必须确保检查 citizen 不为 null。如果它是 null,
那么我们需要执行其他操作,例如,打印一条“未找到海豹”的消息。

如果在调试之后,你仍然不清楚代码为何失败,
当然也应该检查 ArtIS 上的讨论,或者询问你的导师。

本节课中我们一起学习了调试器的基本使用方法:如何设置断点、如何控制程序执行(步入、步出、单步跳过、恢复),以及如何利用变量面板检查程序状态。掌握这些技能对于高效地诊断和修复代码问题至关重要。
003:在 Artemis 中理解反馈 🐛
在本节课中,我们将学习如何在 Artemis 平台上处理测试用例失败或整个项目构建失败的情况。我们将通过解读错误日志和测试反馈,来定位并解决代码中的问题。
上一节我们介绍了编程的基本概念,本节中我们来看看如何利用 Artemis 提供的反馈信息来调试代码。
处理构建失败
当你的项目在 Artemis 上构建失败时,系统会显示 0% 的完成度。这通常意味着代码中存在语法错误,例如缺少分号或花括号。


为了定位错误,你需要查看构建日志。以下是具体步骤:
- 点击进入日志页面。
- 向下滚动,找到标有 “compile Java failed” 的行。
- 日志会指出发生错误的文件(例如
Seal.java)以及具体的行号。
例如,日志可能显示:
error: ‘}’ expected
这表示代码中缺少一个闭合的花括号。有时,实际错误可能发生在日志指出的行号之前,因此你需要检查该文件,找出问题所在。

处理测试用例失败

如果构建成功但部分测试用例失败,你需要分析测试反馈来找出逻辑错误。


以下是分析步骤:
- 向下滚动到第一个失败的测试用例。
- 仔细阅读测试名称和错误信息,理解测试意图和失败原因。
例如,测试反馈可能显示:
Test `checkHistoryWithCode` failed with a NullPointerException.
这表明在运行 checkHistoryWithCode 方法时抛出了空指针异常。你需要思考哪些操作可能导致此异常,例如尝试访问一个未初始化(null)对象的属性或方法。

在有了初步想法后,你应该回到开发环境(如 IntelliJ IDEA)中,使用调试工具逐步运行代码,观察变量状态,从而精确地定位并修复问题。

本节课中我们一起学习了如何利用 Artemis 的反馈信息。我们掌握了处理构建失败(检查编译错误日志)和测试失败(分析测试输出并调试逻辑错误)的基本流程。理解这些反馈是提高调试效率和代码质量的关键技能。
004:通用编程

概述
在本节课中,我们将学习慕尼黑工业大学《计算机编程:从入门到实战》课程第四讲的内容。本节课主要介绍课程的组织结构、学习平台的使用方法、评分规则以及人工智能工具的使用政策。我们将从课程概述开始,逐步深入到具体的操作指南和编程基础概念。
课程介绍与组织结构
欢迎来到《计算机编程入门》课程。我是Stefan,本课程的主讲教师。对于大多数同学来说,这是你们编程学习之旅的起点。这门课程对许多人来说会很有挑战性,但我们会尽力提供最好的支持,帮助大家度过难关。
在开始具体内容之前,我们先了解一下本课程的教学理念。本课程基于人工智能增强的交互式学习,这意味着我们将采用多种不同的教学方式。
以下是本课程的主要组成部分:
- 同步学习活动:我们会在讲堂进行现场研讨会和练习。这不是传统的讲座,而是鼓励大家积极参与和实践的环节。同时,我们也有现场辅导小组,在这里,辅导老师更像是教练和引导者,帮助大家获得良好的学习体验。
- 异步学习资源:我们会提供研讨会录像、幻灯片、阅读材料(书籍和在线文章)以及家庭作业,帮助大家巩固所学的编程概念。
我们拥有强大的支持团队,包括3名讲师和34名辅导老师,为大约800名学生服务。我们使用一个名为Artemis的自研学习平台,它提供了比传统平台更好的学习体验,并包含名为Iris的人工智能辅导功能。
为了提供最佳的学习体验,我们对大家也有一些期望:
- 保持开放心态:尝试新的技术、学习方法和教学方式。
- 积极主动并保持好奇心:提问是学习的关键。
- 学会自我管理:合理安排学习时间,了解课程各项活动的时间节点。
- 保持友善和耐心:有时回复问题或处理反馈需要时间。
本课程的教学目标是让大家学会如何通过编程解决问题,并提升团队沟通、问题解决和展示等软技能。编程是当今世界的一项基本技能,掌握它将为未来的职业生涯打下良好基础。
课程团队与学习模式
我们的课程团队包括主讲教师Stefan、练习讲师Ramona和Marcos,以及34位经验丰富的辅导老师。他们将在你的学习过程中提供90%的支持,请充分利用与他们交流的机会。
关于课程设置,有几点需要了解:
- 研讨会:这不是传统讲座,而是专注于课堂练习。我们将4小时的课程分为4个40分钟的小单元,方便大家学习和复习。
- 辅导小组:时长约3小时,目标是进行小班化、高强度的辅导。你需要在小组中积极提问、与其他同学互动,并进行小组练习和学生展示。
- 家庭作业:每周会有家庭作业,需要独立完成。虽然可以使用AI工具(特别是Iris)寻求帮助,但严禁盲目复制粘贴他人的代码,否则将无法通过课程。
- 考核方式:包括两次在讲堂进行的计算机测试、展示、一个团队项目,以及可以获得额外加分的活动。


学习本课程,我们假设你具备以下条件:
- 有学习编程的动力。
- 拥有一台可以在研讨会和练习中使用的笔记本电脑。
- 有编程经验或参与过软件项目会更有帮助,但非必需。


本课程的核心学习目标是培养你的应用能力,而不仅仅是记忆知识。你需要真正掌握并运用这些技能。


今日课程路线图与Artemis平台使用


今天的课程路线图如下:首先,了解课程的整体组织方式;其次,学会在主要学习平台Artemis上与我们互动;然后,在本次研讨会结束前完成辅导小组的注册;最后,在第二部分,我们将编写第一个小程序,学习如何创建简单的类、实例化对象以及了解基本数据类型。

现在,让我们深入了解我们将要使用的主要工具:Artemis。


Artemis平台允许你参与多种活动,包括下载幻灯片、参与练习、获得自动反馈、参加监督练习(即考试)、与我们沟通等。现在,我们将一起完成第一个教程。

以下是注册和使用Artemis的步骤:
- 登录:在浏览器中打开Artemis,使用你的TUM在线账户(7位数字,通常以G开头)或邮箱登录。
- 注册课程:点击“课程注册”,搜索“Introduction to programming”,点击“注册”按钮。
- 同意条款:阅读并同意课程条款,包括学生行为准则和不向第三方分享课程材料的承诺。
- 探索课程:注册成功后,进入课程页面。你可以在这里找到练习、讲座(研讨会)资料和沟通渠道。
- 设置个人资料:点击右上角的用户名,进入“设置”,上传一张个人头像照片。这有助于我们更好地进行交流。
在Artemis上,我们通过不同的频道进行有组织的沟通:
- 练习频道:针对每个发布的练习提问。
- 讲座/研讨会频道:针对特定讲座内容提问。
- 公告频道:仅讲师可发布重要通知。
- 辅导小组频道:与你所在辅导小组的同学和老师交流。
- 私信功能:可以向任何学生、辅导老师或讲师发送私人消息。
请遵守行为准则,保持友好交流。所有与课程相关的沟通都应在Artemis上进行,请勿发送邮件。
此外,Artemis还有常见问题解答(FAQ) 板块,我们会将常见问题汇总于此。平台也提供移动端应用,但部分功能(如编程练习)可能在移动端受限。
课程时间表与教学理念
本课程每周三举行研讨会。从下周开始,我们将深入各个主题。请注意,在第3周和第7周,我们安排了复习课,专门帮助从未编程过的同学巩固重要概念。
我们的教学理念基于一句中国谚语:“告诉我,我会忘记;展示给我,我会记住;让我参与,我会理解;让我后退,我会行动。”
这引出了交互式学习的概念。我们希望在40分钟的小单元内,先讲授一个概念并展示示例,然后让你在Artemis上通过课堂练习立即应用,并获得即时自动反馈。最后,我们一起反思这个概念,巩固理解。
Artemis完全支持这种交互式学习。你的辅导小组将进一步深化这些知识,并侧重于软技能培养,如沟通和展示。
典型的研讨会日程包括:简短测验、四个学习单元以及中间的休息时间。所有课程资料均受版权保护,请勿公开分发。
课程推荐了两本参考书,但每份幻灯片末尾也会提供具体的文献或在线教程参考。
辅导小组注册与规则
我们计划开设32个辅导小组时段,分布在除周三以外的日子。大多数设在市中心校区,部分在加兴校区。小组命名规则为:星期几(三个字母)-时段号(1上午,2下午,3晚上)-校区(MU慕尼黑,GA加兴)-房间号映射。
以下是注册流程:
- 提交偏好:在今天下午3:30之前,通过TUM在线提交你的辅导小组时段偏好。请务必选择多个合适的时段,而不仅仅是一个,以增加匹配成功率。
- 接收分配:匹配结果将于今天在TUM在线公布。
- 首次会议:第一次辅导小组会议将于明天开始。
- 后期调整:如果今天未能匹配或对结果不满意,可以明天再次提交偏好,我们将进行每日匹配,直到10月25日截止。如需调换小组,需有正当理由且目标小组有空位。
辅导小组虽然是可选的,但我们强烈建议现场参加。虚拟小组效果不佳。如果因节假日等原因取消,请自行重新安排到其他有空位的小组。
典型的辅导小组流程包括:简短回顾研讨会内容(不超过5分钟)、展示和讨论上周家庭作业、进行小组练习并展示、以及简短讨论新布置的家庭作业。
评分规则与人工智能政策
本课程的评分由以下几个部分组成,总计12个ECTS学分:
- 两次监督练习(各占30%):类似于在讲堂进行的计算机考试,需独立完成,严禁使用任何人工智能工具。
- 团队项目(占30%):与另一位同组同学合作开发一个小游戏,整合所学概念。
- 展示(占10%):在辅导小组中展示家庭作业或小组练习成果,每人需展示两次。
- 额外加分(最高5%):通过完成标有“加分”类别的课堂练习和家庭作业获得。
总分达到50%即可通过课程。加分仅适用于正常课程活动,如果未通过课程需要补考,则加分无效。
关于人工智能工具的使用,我们制定了明确的政策:


- 红色(禁止):在监督练习(考试)中,严禁使用任何AI工具(如ChatGPT、GitHub Copilot)。违规将被视为作弊,课程成绩记为不及格(5.0)。
- 黄色(谨慎使用):在家庭作业、辅导小组练习或团队项目中,可以安装使用GitHub Copilot等工具,但应谨慎使用。如果完全依赖AI而不理解代码,无异于欺骗自己。
- 绿色(推荐):我们鼓励在Artemis的编程练习中使用Iris(我们的虚拟辅导老师)。它被设计为提供有意义的指导,帮助你思考下一步,而不是直接给出答案。
使用AI工具不是通过课程的必要条件,你可以完全自主完成所有练习。请遵循上述交通灯方案。
练习类型与提交规则
本课程有三种练习类型,可以通过Artemis上练习名称的首字母快速识别:


- W 开头:课堂练习,在研讨会期间完成。
- T 开头:辅导小组练习,在辅导小组期间完成。
- H 开头:家庭作业,需要在家独立完成。
家庭作业和辅导小组练习均在每周三晚上发布,并在下周三下午1点截止。你有一周的时间完成。
重要的练习规则:
- 必须在Artemis上提交所有解决方案,包括未来的团队项目。不能通过邮件或其他方式提交。
- 每个练习都有截止日期,逾期提交将不计分。
- 应独立完成作业,盲目抄袭他人代码是欺骗自己的行为。



总结
在本节课中,我们一起学习了《计算机编程入门》课程的整体框架。我们了解了课程基于AI增强的交互式学习理念,认识了强大的教学支持团队,并详细掌握了主要学习平台Artemis的使用方法。我们还明确了辅导小组的注册流程、课程的评分构成,以及关于人工智能工具使用的“红黄绿”政策。最后,我们区分了不同类型的练习及其提交规则。这些信息将帮助你更好地规划本学期的学习,为接下来的编程实践打下坚实的基础。
005:面向对象编程(第一部分)


在本节课中,我们将要学习面向对象编程(OOP)的基础概念。我们将了解什么是对象和类,如何定义它们,以及如何在Java程序中创建和使用对象实例。课程内容将从基本概念开始,逐步深入到对象间的交互和引用语义。
概述:面向对象的世界

面向对象编程的核心思想是,我们感知的世界由对象构成。在程序中,我们尝试理解现实世界的这些对象,并将它们作为虚拟的对应物放入我们的程序。这些对象拥有属性,并且通过数据交换和调用服务(或方法)来相互协作。
例如,一把椅子是一个对象。它的属性可能包括材质(如木材)和颜色(如棕黄色)。我们可以对这个对象执行某些操作,例如“坐在椅子上”。在编程中,我们创建类作为对象的蓝图,然后根据这个蓝图实例化出具体的对象。
创建第一个Java类
上一节我们介绍了面向对象的基本思想,本节中我们来看看如何在代码中实现一个简单的类。
以下是一个Book类的定义示例。这个类代表了图书馆中的一本书,它包含三个属性:作者、标题和版本号。
class Book {
// 属性(实例变量)
String author;
String title;
int edition;
// 方法:借出书籍
void borrow(java.time.LocalDate startDate) {
// 实现借书逻辑的代码
}
// 方法:归还书籍
void giveBack(java.time.LocalDate endDate) {
// 实现还书逻辑的代码
}
}
在这个类定义中:
author,title,edition是属性,用于存储对象的状态。borrow和giveBack是方法,定义了对象的行为。void表示该方法不返回任何值。
实例化与使用对象


定义了类之后,我们需要创建具体的对象实例才能使用它。这个过程称为实例化。
以下是创建和使用Book对象的代码:


public class Playground {
public static void main(String[] args) {
// 使用 new 关键字和构造函数创建对象
Book mythicalManMonth = new Book();
// 为对象的属性赋值
mythicalManMonth.author = "Fred Brooks";
mythicalManMonth.title = "The Mythical Man-Month";
mythicalManMonth.edition = 2;
// 调用对象的方法
mythicalManMonth.borrow(java.time.LocalDate.now());
}
}
关键点:
new Book()调用构造函数,在内存中创建一个新的Book对象。mythicalManMonth是一个引用变量,它指向内存中那个具体的对象。- 使用点运算符
.来访问对象的属性和方法。
对象的核心特征
每个对象都有三个核心特征,理解它们对掌握面向对象编程至关重要。
- 标识:每个对象都有一个唯一的身份,通常通过其引用变量名来体现。即使对象的属性值改变,其标识保持不变。
- 状态:由对象在特定时间点的属性值决定。例如,一本书的
edition是2。 - 行为:通过对象的方法来定义。执行方法可能会改变对象的状态。例如,调用
borrow()方法会将书的状态标记为“已借出”。
而类则是创建这些对象的蓝图。它定义了该类所有对象将拥有哪些属性和方法。
对象间的关联
在现实世界中,对象很少孤立存在,它们之间会有关联。在程序中,我们可以通过在一个类中包含另一个类的引用来建模这种关系。

例如,一个Library(图书馆)可以包含多本Book(书)。这种关系可以通过关联来表示。



class Library {
String name;
Book[] books; // 图书馆拥有一个书籍数组(关联)
void visit() {
// 实现访问图书馆的逻辑
}
}
在UML(统一建模语言)图中,这种关系用一条连接两个类的线表示,并可以标注数量关系(如一个图书馆对应多本书)。



变量类型:实例变量、局部变量与引用
在类中,我们会遇到几种不同的变量,理解它们的区别很重要。
- 实例变量:在类内部、方法外部声明的变量。每个对象实例都拥有自己的一份副本。例如
Book类中的author。 - 局部变量:在方法内部声明的变量。其生命周期仅限于该方法执行期间。
- 引用变量:用于指向对象的变量,如
Book mythicalManMonth。它存储的是对象在内存中的地址,而非对象本身。
引用语义与原始数据类型
这是面向对象编程中的一个关键概念。对象使用引用语义,而原始数据类型(如int, double)使用值语义。
Book bookA = new Book();
Book bookB = bookA; // bookB 指向与 bookA 相同的对象(引用复制)
bookA.title = "New Title";
System.out.println(bookB.title); // 输出 "New Title",因为两者引用同一对象
int x = 5;
int y = x; // y 获得 x 值的一个副本(值复制)
x = 10;
System.out.println(y); // 输出 5,y 的值不受 x 后续改变的影响
重要结论:多个引用变量可以指向同一个对象,通过任何一个引用修改对象,其他所有引用都会“看到”这个变化。这被称为别名。
构造方法与 this 关键字
构造方法是一种特殊的方法,用于在创建对象时初始化其状态。它的名称必须与类名完全相同。
class Point {
double x;
double y;
// 构造方法
Point(double x, double y) {
this.x = x; // 使用 this 区分实例变量和参数
this.y = y;
}
}



- 如果你没有定义任何构造方法,Java会提供一个默认的无参数构造方法。
- 一旦你定义了自己的构造方法,默认构造方法将不再自动提供。
this关键字指向当前对象的实例。当方法参数名与实例变量名冲突时,可以用this.variableName来明确指代实例变量。



组合对象:更复杂的结构
我们可以通过组合简单的类来构建更复杂的结构。例如,一条Line(线段)可以由两个Point(点)来定义。
class Line {
Point start; // 起点
Point end; // 终点
Line(Point start, Point end) {
this.start = start;
this.end = end;
}
}

// 使用方式
Point p1 = new Point(2.0, 2.7);
Point p2 = new Point(5.8, 16.0);
Line myLine = new Line(p1, p2);
这种设计体现了信息隐藏和代码复用的原则。Line类不需要关心Point内部如何实现,它只需要使用Point提供的功能。



空引用 null 与信息隐藏
- 空引用
null:这是引用类型变量的默认值,表示该引用当前不指向任何对象。尝试通过一个值为null的引用调用方法或访问属性,会导致NullPointerException。最佳实践是在声明引用变量时立即初始化它。 - 信息隐藏:通过将类的内部细节(属性)声明为
private,可以防止外部代码直接访问和修改它们,从而保护对象状态的完整性,并降低类与类之间的耦合度。我们将在后续课程中详细学习访问修饰符。
总结
本节课中我们一起学习了面向对象编程的基础知识。我们了解到程序中的虚拟世界由对象构成,而类是创建对象的蓝图。对象封装了数据(属性)和行为(方法)。我们学会了如何定义类、使用new关键字和构造方法实例化对象,以及如何使用点运算符与对象交互。


关键收获包括理解了引用语义(多个变量可指向同一对象)与原始数据类型的值语义的区别,以及对象之间可以通过关联和组合来建立关系。我们还初步接触了this关键字、null值以及信息隐藏的重要性。这些概念是构建更复杂、更模块化Java程序的基石。在接下来的课程中,我们将在此基础上,继续探索面向对象编程的更多特性。
006:基本类型及其运算 🧮

在本节课中,我们将要学习Java中的基本数据类型及其相关运算。我们将了解有哪些基本类型、它们如何存储数据、以及可以对它们执行哪些操作。
基本数据类型概述
上一节我们介绍了变量和属性的概念,本节中我们来看看Java为它们提供了哪些基本的数据类型。
Java中的基本数据类型允许我们定义具有一组可能值和一组可能操作的变量。所有变量和属性都必须有类型,Java提供了八种基本数据类型,我们也可以定义自己的数据类型(类)。
以下是Java的八种基本数据类型:
- byte:占用8位(1字节)内存,取值范围为 -128 到 127。
- short:占用16位(2字节)内存。
- int:占用32位(4字节)内存,是常用的整数类型。
- long:占用64位(8字节)内存,用于存储非常大的整数。
- float:单精度浮点数,占用32位内存,用于存储小数,但精度有限。
- double:双精度浮点数,占用64位内存,用于存储高精度的小数。
- boolean:布尔类型,只有1位,表示 true(真)或 false(假)值。
- char:字符类型,占用16位,用于存储单个Unicode字符(如字母、汉字等)。
使用较小的类型(如byte)可以节省空间,但在大多数程序中,通常直接使用int或long。请注意,这些类型可能会发生溢出和下溢。例如,一个值为127的byte变量加1后,会自动变为-128,而Java不会对此发出警告。
int x = 2147483647; // int的最大值
x = x + 1; // 发生溢出
System.out.println(x); // 输出将是int的最小值
浮点数float和double是近似类型,并非绝对精确,它们只能精确到一定位数。如果需要完全精确,需要使用其他数据类型。在代码中,需要在数字后附加字母F表示float,附加D表示double,例如3.14F或3.1D。
字符串String本质上是由多个char字符连接而成的序列。
相等性与同一性
在编程中,复制值和引用值有一个重要的区别:相等性和同一性。
两个不同的对象可以是相等的(例如,3/4等于6/8),但它们可能不是同一个对象(不具有同一性)。对于基本数据类型,使用双等号运算符==来检查相等性。对于引用类型,==比较的是引用(即身份)是否相同,而不是对象的内容是否相等。
// 基本类型比较
int i1 = 5;
int i2 = 6;
int i3 = 5;
boolean result1 = (i1 == i2); // false, 5不等于6
boolean result2 = (i1 == i3); // true, 5等于5
// 引用类型比较(假设Point是一个类)
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2); // 内容与p1相同
Point p3 = p1; // p3是p1的别名,指向同一个对象
boolean result3 = (p1 == p2); // false, 不同对象(身份不同)
boolean result4 = (p1 == p3); // true, 指向同一个对象(身份相同)
请确保理解相等性(内容相同)和同一性(是同一个对象)之间的区别。
基本运算
接下来我们看看可以对基本类型执行哪些运算。
算术运算
以下是基本的算术运算符:
- 加法:
+ - 减法:
- - 乘法:
* - 除法:
/ - 取模:
%(返回除法的余数)
整数除法会丢弃小数部分。例如,5 / 3的结果是1,而不是1.666...。取模运算5 % 3的结果是2(余数)。
当运算涉及两种不同的数据类型时,较具体的类型会被隐式转换为较通用的类型。类型通用性从高到低大致为:double > float > long > int > short > byte。运算结果总是得到更通用的那种类型。
short x = 1;
int y = 7;
int result = x + y; // short + int -> int
float a = 3.14F;
int b = 9;
float result2 = a * b; // float * int -> float
比较与逻辑运算
除了算术运算,我们还可以进行比较和逻辑运算。
以下是常用的比较和逻辑运算符:
- 大于:
> - 小于:
< - 大于等于:
>= - 小于等于:
<= - 等于:
== - 不等于:
!= - 逻辑非:
!(例如!true等于false) - 逻辑与:
&&(两个条件都为真时结果为真) - 逻辑或:
||(至少一个条件为真时结果为真)
运算符优先级
运算符具有优先级,优先级高的先执行。如果不使用括号,乘法的优先级高于加法。优先级数字越小,优先级越高。
int result = 5 + 5 * 5; // 先算5*5=25,再算5+25=30
int result2 = (5 + 5) * 5; // 先算括号内5+5=10,再算10*5=50
我们可以通过使用圆括号()来覆盖默认的优先级。
字符串连接
对于字符串,加号+运算符用于连接(拼接)字符串,而不是数学加法。
String greeting = "Hello" + " " + "World"; // 结果是 "Hello World"
System.out.println(greeting);
注意,当在System.out.println中混合使用字符串和数字时,数字会被自动转换为字符串进行拼接,这可能有时会产生令人困惑的结果。
double x = 5.5e12;
System.out.println("Value: " + x); // 输出 "Value: 5.5E12"
练习与总结
让我们通过两个小练习来巩固理解。
练习1:
int x = 2;
int y = 5;
int result1 = x * y / 2; // 结果是多少?
int result2 = x * (y / 2); // 结果是多少?
答案: result1是5(2*5=10,10/2=5)。result2是4(5/2=2(整数除法),2*2=4)。
练习2:
int a = 10;
int b = 5;
System.out.println(a / b + a / b); // 输出?
System.out.println(a * b / b + a * b / b); // 输出?
答案: 第一行输出4(10/5=2,2+2=4)。第二行输出20(10*5=50,50/5=10,10+10=20)。

本节课中我们一起学习了Java的八种基本数据类型、如何区分相等性与同一性、以及各种基本运算(算术、比较、逻辑)和运算符优先级。我们还了解了字符串连接的特殊性。这些是构建更复杂程序的基础,请在接下来的练习中多加运用。
007:方法 🧮

在本节课中,我们将要学习Java中方法的核心概念。方法定义了对象或类的动态行为,是实现功能复用的基本单元。我们将了解如何声明、调用方法,以及方法如何通过参数和返回值进行工作。
方法的基本概念
上一节我们介绍了对象和类,本节中我们来看看如何为它们定义行为,这就是方法。
方法(或称为函数)在数学意义上,是将输入转换为输出的过程。在Java中,所有方法都必须与一个类或对象相关联,不存在其他语言中的“全局函数”。我们主要讨论作用于特定对象的动态方法(静态类方法将在后续介绍)。要调用一个动态方法,必须先实例化一个对象,然后在该对象上调用方法。
一个方法包含三个核心部分:
- 方法名:用于标识和调用方法。
- 形式参数列表:定义方法接收的输入,包含类型和名称,顺序很重要。
- 返回类型:定义方法输出的数据类型。
在Java中,方法签名仅由方法名和形式参数列表(参数类型和顺序)组成,返回类型不是签名的一部分。这允许方法重载,即同一个类中可以有多个同名但参数列表不同的方法。
以下是一个计算两个整数平均值的方法示例:
int average(int v1, int v2) {
return (v1 + v2) / 2;
}
在这个例子中,average是方法名,(int v1, int v2)是形式参数列表,int是返回类型。方法体中的代码执行计算,并通过return语句返回结果。注意,对于整数运算,(8+9)/2的结果是8,而非8.5。
方法的声明与调用
我们已经看到了方法的声明,接下来看看如何调用它。
方法调用的语法是:方法名后跟圆括号,括号内传入实际参数。实际参数可以是具体的值,也可以是局部变量。这与形式参数不同:形式参数在方法声明中定义,是“模板”;实际参数在每次调用时传入,是具体的“数据”。
以下是一个调用欧姆定律计算电压的例子:
double vWant = voltage(2.0, 3.0); // 结果为 6.0
这里,2.0和3.0是传递给voltage方法的实际参数。




方法的嵌套与组合
遵循最佳实践,我们应将复杂功能拆分为多个小而专一的方法。这提高了代码的可读性和复用性。
例如,计算圆柱体积的公式是:体积 = π * 半径² * 高。我们可以将其分解为两个方法:
- 计算圆面积:
circleArea(半径) - 计算圆柱体积:
cylinderVolume(高, 半径),其中复用circleArea方法。
实现如下:
double circleArea(double radius) {
return radius * radius * Math.PI;
}
double cylinderVolume(double height, double radius) {
return height * circleArea(radius);
}
调用cylinderVolume(1.5, 2.0)时,程序会先调用circleArea(2.0)计算出圆面积,再用结果12.566...乘以高度1.5,最终返回体积18.849...。通过调试器,可以清晰地看到程序执行流程从一个方法跳转到另一个方法的过程。


特殊类型的方法
除了普通方法,Java中还有一些特殊类型的方法。


构造函数是一种特殊方法,用于初始化新创建的对象。它没有返回类型(连void都没有),名称必须与类名完全相同。其核心作用是通过new操作符为对象分配内存。

void方法不返回任何值。它通常用于执行某些操作(如修改对象状态、打印输出),而非计算一个结果。因此,void方法不能赋值给变量。
this关键字在动态方法中用于显式引用当前对象。在构造函数或方法中,当局部变量(或参数)与实例变量同名时,必须使用this来区分。在其他情况下,使用this是可选的,但为了代码清晰,通常只在必要时使用。

以下是一个使用this和void方法的例子,该方法用于移动一个点:
class Point {
double x, y;
void shift(double dx, double dy) {
this.x = this.x + dx; // `this` 可省略,因为无命名冲突
this.y = this.y + dy;
}
}
// 使用
Point p = new Point(3, 4);
p.shift(2, 4); // 执行后,p的坐标变为 (5, 8)

静态方法

静态方法属于类本身,而非类的某个特定实例。调用静态方法无需创建对象,直接通过类名.方法名()即可。


Java程序的入口main方法就是一个静态方法。我们也可以创建自己的静态方法,例如那些不依赖于任何对象状态的纯工具函数(如单位换算)。
静态方法不能直接访问或修改对象的实例变量(因为它们没有this引用),因此通常不会产生基于对象状态的副作用。


class WeatherUtils {
static double fahrenheitToCelsius(double f) {
return (f - 32) * 5 / 9;
}
}
// 调用
double c = WeatherUtils.fahrenheitToCelsius(100.0);
参数传递与变量作用域
理解Java如何传递参数至关重要。Java只使用按值调用。




- 对于基本数据类型(如
int,double),传递的是值的副本。在方法内修改参数值,不会影响原始的变量。 - 对于对象引用类型,传递的是引用的副本。这意味着方法内无法让原始引用指向新对象,但可以通过这个副本引用修改所指向对象的内部状态(属性)。
局部变量在方法内部声明,其作用域仅限于该方法的大括号{}内。它们用于存储计算的中间结果,生命周期随方法调用结束而结束。与之相对,实例变量(属性)属于对象,只要对象存在,其生命周期就存在,并且可以被该对象的所有方法访问。
应避免在方法内部修改输入参数的值(尽管语法允许),这是一种不好的编程风格,会降低代码的可读性和可预测性。
综合应用示例
最后,我们来看一个综合应用。假设我们有一个Point(点)类和一个Line(线)类,线由两个点构成。要为Line实现一个移动(shift)方法,只需调用其两个端点Point对象的shift方法即可。
class Line {
Point p1, p2;
void shift(double dx, double dy) {
p1.shift(dx, dy); // 调用Point对象的shift方法
p2.shift(dx, dy);
}
}
这个例子展示了对象间的协作,一个方法可以通过调用其他对象的方法来完成复杂任务。

本节课中我们一起学习了Java方法的核心知识:从方法的基本声明与调用,到特殊方法(构造函数、void方法、静态方法)的使用,再到参数传递的机制(按值调用)和变量作用域的区别。方法是构建程序逻辑的基石,理解并熟练运用它们是成为合格程序员的关键一步。
008:UML类图 📊

在本节课中,我们将要学习UML类图。这是一种用于可视化程序结构的图形化工具,它能帮助我们理解类、属性、方法以及类之间的关系,而无需直接阅读复杂的代码。
上一节我们介绍了面向对象编程的基本概念,本节中我们来看看如何用UML类图来直观地表示这些概念。
UML类图本质上是程序的图形化表示。例如,下图展示了一个我们之前讨论过的有理数类(Rationale class)的可视化表示。

图中显示了类的名称、其属性(如分子 numerator 和分母 denominator)以及一些方法。对于方法,我们还可以定义其签名,包括名称、输入参数和返回类型。
当程序中存在多个相互关联的类时,UML类图的威力才能真正显现。通过视觉化表示,我们可以更容易地理解代码的结构,这比直接阅读整个代码要简单得多。
软件开发者使用UML类图来沟通程序的功能和结构。虽然对于单个类来说,这可能不是必需的,但对于理解多个类如何交互则非常有意义。
UML并非专为Java设计,它适用于多种编程语言。这就是它的强大之处——你可以在任何编程语言中使用UML来定义程序的主要结构或动态行为。UML包含的图表类型远不止类图,但目前我们专注于使用类图来表示代码在类、属性、方法及类间关系方面的结构。
以下是UML类图的一个具体例子,它描述了我们之前创建的一个程序:

Line类:拥有两个属性p1和p2,它们通过关联关系指向Point类的对象。它还有一个shift方法。Point类:拥有x和y属性,以及自己的shift方法。我们还可以为其添加另一个方法,例如rotate。
只要理解了UML的语法,通过观察这样一个小型的UML图,我就能立即理解程序是关于什么的,而无需查看代码。这基本上就是UML类图的核心思想。
关于UML类图的内容就介绍到这里。我们将在后续的实践课程中更频繁地使用它们,并展示更多示例。
在完成了本次课程介绍部分的初次接触后,接下来的步骤是什么?
以下是课程后续安排与要求:
- 教程小组练习:我们将发布教程小组练习,请你们作为小组的一部分完成这些练习。
- 开发环境设置:我们恳请你们在第一次教程小组活动前,完成你们首选集成开发环境(IDE)的设置,确保能够使用它。
- Artemis平台使用:我们将展示如何在Artemis平台上参与这些练习,以及在遇到困难或不理解时,如何使用该平台获取AI帮助。
- 家庭作业:你们会收到一些家庭作业练习,如果不知道如何开始,可以着手进行。
- 寻求帮助:你们也可以使用Artemis平台与同学和助教交流,他们可以提供帮助。
- 阅读材料:我们恳请你们阅读两篇关于编程基础、面向对象编程和方法的文章。我只能在讲座中向你们解释一次,但无法整合所有不同的视角。因此,阅读在线教程非常重要,这也是许多程序员在学习更高级概念时的做法,同样适用于初学者。所以,请务必在下周之前阅读这些文章。
所有练习(教程小组练习、家庭作业)和阅读材料都需在下周10月23日星期三下午1点前完成。请记住,下午2点我们将有一个小测验。

本节课总结:今天我们讨论了面向对象编程、基本数据类型、方法和UML类图——这些都是入门所需的基础知识。我知道信息量很大,但请坚持下去,你们很快就会理解的。感谢聆听,请查看参考资料,我们下周见。
009:表达式与语句

在本节课中,我们将学习编程中的两个核心概念:表达式与语句。理解它们的区别是掌握后续控制流结构(如条件判断和循环)的基础。
表达式与语句的区别
上一节我们介绍了编程语言的基本构成。本节中,我们来看看如何通过表达式和语句来操作数据和改变程序状态。
什么是表达式?
表达式是代码中用于计算并产生一个值的部分。每个表达式都有一个类型。
以下是表达式的几个例子:
- 赋值右侧:
int x = 2 + 2;中的2 + 2是一个表达式,其结果为4。 - 方法参数:
someMethod(2 + 2, 3 + 3);中的2 + 2和3 + 3都是表达式。 - 返回值:
return a * b;中的a * b是一个表达式。
表达式可以由以下元素构成:
- 变量或属性
- 方法调用
new操作符(如new Point())- 二元运算符(如
+,-,*,/) - 一元运算符(如
-x)
核心概念示例:
(3.0 + y) * 4.0是一个double类型的表达式。"Hello " + "World" + 123是一个String类型的表达式。x == 2是一个boolean类型的表达式。
什么是语句?
语句是执行一个操作的完整代码单元,通常以分号 ; 结束。语句的主要目的是改变程序的状态,它本身没有类型。
以下是语句的几个例子:
- 变量声明:
int x;是一个语句。 - 赋值:
x = y + 5;是一个语句(其中y + 5是表达式)。 - 方法调用(返回
void):System.out.println("Hello");是一个语句。 - 代码块:由
{}包裹的一系列语句。 - 返回语句:
return result;是一个语句(其中result是表达式)。
核心概念:语句可以包含表达式,但表达式不能单独作为语句(除非是特殊的“表达式语句”,如 i++;)。
一个简单的程序示例
以下是一个包含多个语句的简单程序,它演示了如何从控制台读取用户输入:


// 语句1: 声明变量
int x;
int y;
int result;


// 语句2 & 3: 使用InputReader读取输入(表达式‘InputReader.readInt(...)’的结果被赋值给变量)
x = InputReader.readInt("Please enter number one: ");
y = InputReader.readInt("Please enter number two: ");
// 语句4: 计算总和(表达式‘x + y’的结果被赋值给result)
result = x + y;
// 语句5: 输出结果
System.out.println("Sum: " + result);

关于InputReader:这是一个工具类,封装了从控制台读取输入(如整数、字符串)的复杂逻辑。你可以将其视为一个“黑盒”,只需调用它的静态方法(如 readInt)并传入提示信息即可。
顺序执行

目前,我们的程序是顺序执行的:
- 每个操作(语句)依次执行一次。
- 执行顺序完全由代码书写顺序决定。
- 程序在执行完最后一个语句后结束。
这种线性的执行方式缺乏灵活性。例如,我们无法根据不同的条件执行不同的代码,也无法重复执行某段代码。为了构建更强大的程序,我们需要引入控制流结构。

总结
本节课中我们一起学习了表达式与语句的核心概念:
- 表达式用于计算并产生一个具有类型的值。
- 语句用于执行操作、改变程序状态,并以分号结束。
- 语句可以包含表达式,程序默认按顺序执行语句。

理解了这些基础后,在接下来的课程中,我们将学习如何使用条件判断(if/else, switch)和循环(for, while)来控制程序的执行流程,从而编写出能够应对不同情况和处理重复任务的程序。
010:条件选择

在本节课中,我们将要学习条件选择。条件选择是编程中的核心概念,它允许程序根据不同的情况执行不同的代码块。我们将从基础的 if-else 语句开始,探讨其语法和最佳实践,然后介绍用于简化多条件判断的 switch 语句,最后讨论如何利用条件选择来处理程序中常见的 null 值问题。
条件选择示例
上一节我们介绍了条件选择的概念,本节中我们来看看一个具体的例子。
以下是一个条件选择的基本示例。这个程序与之前幻灯片中的程序类似,但在读取变量 x 和 y 的值后,它会根据 x 是否大于 y 来执行不同的操作。
int x, y, result;
x = readInt();
y = readInt();
if (x > y) {
result = x + y;
} else {
result = x - y;
}
print(result);
这个程序声明了三个变量,并从用户那里读取 x 和 y 的值。然后,程序会像自然语言一样判断:如果 x 大于 y,程序将跳入 if 分支,使用表达式 x + y 来计算 result。否则,程序将跳入 else 分支,使用表达式 x - y 来计算 result。最后,程序打印出结果。
这就是条件选择的基本形式。它初看起来可能有点复杂,但当你习惯后就会觉得非常简单,甚至不再需要刻意思考。
if-else 语句详解
上一节我们看了一个简单的例子,本节中我们来详细解析 if-else 语句的结构和用法。
if-else 语句的核心思想是:如果 if 后面的条件成立,程序就跳入对应的分支执行代码。如果条件不成立,程序则跳入 else 语句后的分支执行。
我们还可以使用 else if 语句来处理多个条件。例如,当我们只读取一个数字 x 时,可以这样写:
if (x == 0) {
print(0);
} else if (x < 0) {
print(-1);
} else {
print(1);
}
在这个例子中,我们可以使用多个 else if 语句来区分不同的情况。
你可能会注意到,上面的代码中没有使用花括号 {}。虽然当分支中只有一条语句时,Java 允许省略花括号,但这是一种不好的实践。即使只有一条语句,也建议始终使用花括号。这是因为如果后续开发者添加了第二条语句,而忘记添加花括号,那么第二条语句将不属于这个分支,这会导致混淆和错误。因此,我的建议是:始终使用花括号。
嵌套条件选择

上一节我们学习了单层的条件选择,本节中我们来看看如何将条件选择嵌套在一起。
我们可以在一个条件选择的分支内,再放入另一个条件选择语句。例如,在下面的代码中,当 x 不为 0 时,内部又有一个 if-else 语句。

if (x != 0) {
y = readInt();
if (x > y) {
print(x);
} else {
print(y);
}
} else {
print(“x is zero”);
}
在这个 if 语句的分支里,我们嵌套了另一个 if-else 语句。理论上,我们可以无限地嵌套 if-else 语句。
虽然嵌套太多层不是好的实践,但有两到三层的嵌套是很常见的。我们通常使用缩进(四个空格或一个制表符)来使代码结构更清晰。所有缩进的代码都属于对应的分支。通过缩进,我们可以清楚地看到外层条件、内层条件以及各个分支的范围。
这种嵌套结构非常强大,因为它允许你根据程序的需要,构建复杂的控制流程。
关于 else 分支,有一个常见问题:是否可以省略 else 并使用 return?答案是:你可以省略 else,但不能用 return 来代替。return 是用于方法调用的保留关键字。if 是一个语句,它不返回值,也没有类型。它只是通过闭合花括号来结束。
处理边界条件与逻辑陷阱
在编写条件时,正确处理边界情况和避免逻辑重叠非常重要。让我们通过一个例子来探讨。
考虑这段代码:
if (x > y) {
print(x);
} else {
print(y);
}
如果两个数字相等会怎样?答案是会执行 else 分支,打印 y。因此,确保条件正确设置有时需要仔细思考。有时条件可能会重叠,编译器通常会发出警告。例如,如果你已经检查了所有可能的情况,那么 else 分支可能就不可达了。
你甚至可能写出逻辑上永远为真的条件,例如:
if (true) {
System.out.println(“I’m happy”);
} else {
System.out.println(“I’m not happy”); // 这行代码永远无法执行
}
虽然这是有效的代码,但 else 分支是不可达的,永远无法执行。这种情况有时会发生,因为你的逻辑可能没有预想中那么严密。集成开发环境会给出“条件始终为真”的警告,并提示存在不可达代码。你可以安全地删除这些不可达的代码。
使用条件选择处理 Null 值
在编程中,一个常见的问题是处理 null 值。未初始化的对象类型其值为 null,如果对 null 调用任何方法,就会引发 NullPointerException。即使这个概念很清晰,它也经常导致错误、程序崩溃,甚至在大型复杂系统中造成严重损失。
null 的发明者后来也后悔引入这个概念,因为它导致了无数的问题。虽然我们无法改变过去,但我们可以学习如何更好地处理它。
使用 if 语句,我们至少可以检查对象是否为 null,从而安全地调用其方法。但这有时很繁琐,因为我们可能需要检查每一个对象实例。
为了更系统地解决这个问题,我们可以使用一些工具和最佳实践。以下是使用条件语句进行 null 检查的一个例子:
public class Pet {
private @Nullable String name; // 名字可能为 null
private final @NonNull LocalDate birthDate; // 生日不能为 null
public Pet(LocalDate birthDate) {
this.birthDate = birthDate; // 构造函数强制要求提供生日
}
public @Nullable String getUppercaseName() {
if (name != null) {
return name.toUpperCase();
} else {
return null;
}
}
}
在这个改进的例子中,我们使用 @Nullable 注解标明 name 可能为 null,使用 @NonNull 注解标明 birthDate 不能为 null。在 getUppercaseName 方法中,我们使用 if 语句检查 name 是否为 null,从而避免 NullPointerException。
使用 Checker Framework 提升 Null 安全性
手动进行 null 检查容易遗漏。有一个名为 Checker Framework 的优秀工具(由华盛顿大学创建),可以帮助我们更好地处理 null 值。

Checker Framework 的基本承诺是:如果你正确使用它,并且你的程序没有产生任何警告,那么你的程序将永远不会抛出 NullPointerException。这是所有开发者的梦想。
它是通过注解来实现的。你可以使用 @NonNull 注解来标明某个类型或返回值永远不会是 null,或者使用 @Nullable 来标明它可能是 null。

将这些注解集成到你的代码中,是与其他开发者沟通哪些值可能为 null 的好方法。与普通注释不同,这些注解会被编译器和 IDE 检查。如果你忘记进行 null 检查就调用方法,IDE 会立即给出警告。
虽然设置 Checker Framework 需要一些配置(例如修改 build.gradle 文件),但一旦掌握就相对简单。在未来的练习中,我们会在适当的时候引入它,并要求你使用这些注解。
Switch 语句
当需要基于一个变量的多个不同值执行不同操作时,使用一连串的 if-else if 语句会显得冗长。switch 语句提供了一种更简洁、可读性更高的方式。
switch 语句实现了另一种形式的分支。它并不比 if 语句更强大,但能让代码更清晰易读。
以下是一个 switch 语句的例子,它根据用户输入的命令字符执行不同的操作:
char command = readChar();
switch (command) {
case ‘N’:
// 执行新建操作
break;
case ‘O’:
// 执行打开操作
break;
case ‘S’:
// 执行保存操作
break;
case ‘Q’:
// 执行退出操作
break;
default:
// 处理未知命令
break;
}
switch 语句基于一个表达式(这里是 command)的值进行跳转。case 后面的值必须是常量(如 ‘N’),不能是变量。每个 case 块通常以 break 语句结束,以防止执行流程“贯穿”到下一个 case。default 块用于处理所有未被前面 case 覆盖的值。
Switch 语句的示例与注意事项
让我们看一个计算月份天数的例子,来进一步理解 switch 语句的用法和需要注意的地方。
以下是两种实现方式:
方式一(详细列出每个 case):
int days;
switch (month) {
case 1: days = 31; break;
case 2: days = 28; break;
case 3: days = 31; break;
case 4: days = 30; break;
// ... 其他月份
default: days = 0; break;
}
方式二(利用 fall-through 特性合并 case):
int days;
switch (month) {
case 2:
days = 28;
break;
case 4: case 6: case 9: case 11:
days = 30;
break;
default:
days = 31;
break;
}
方式二更短,它利用了 switch 的 fall-through 特性:当 case 块中没有 break 时,执行会继续进入下一个 case 块。这里,月份 4、6、9、11 都“贯穿”到 case 11 的代码,将 days 设为 30。
哪种方式更好?方式一虽然冗长,但逻辑一目了然。方式二更简洁,但需要你理解 fall-through 的逻辑。此外,方式二有一个潜在问题:default 块会处理所有非法输入(如 0 或 13),并将其天数错误地设为 31。而方式一中,非法输入会导致 days 保持为 0(如果之前已初始化),这至少不是一个错误的值。因此,在使用方式二时,通常需要额外的输入验证。
总结
本节课中我们一起学习了条件选择,这是控制程序流程的基础工具。
我们首先从 if-else 语句开始,学习了如何根据条件执行不同的代码分支,并强调了始终使用花括号的最佳实践。接着,我们探讨了嵌套条件选择,以及如何避免常见的逻辑陷阱。
然后,我们深入讨论了编程中棘手的 null 值问题,介绍了如何使用 if 语句进行基本的空值检查,并推荐了使用 Checker Framework 及其注解来系统性地提升代码的 null 安全性,从而从根本上减少 NullPointerException。
最后,我们学习了 switch 语句,它是一种简化多路分支的语法糖,通过常量匹配来决定执行路径。我们比较了其两种写法,并指出了利用 fall-through 特性时需要注意的细节。

掌握这些条件选择结构,将使你能够编写出更具逻辑性和健壮性的程序。
011:循环(第一部分)

在本节课中,我们将要学习编程中的一个核心概念——循环。循环允许我们重复执行一段代码,直到某个条件不再满足。我们将从 while 循环开始,学习其语法、工作原理,并通过实际例子来加深理解。
循环简介
循环,或称迭代,意味着重复执行一个语句或表达式多次,通常直到某个条件变为假为止。
我们从一个简单的例子开始。该程序读取两个数字 x 和 y,然后执行一个 while 循环。
while (x != y) {
if (x < y) {
y = y - x;
} else {
x = x - y;
}
}
只要 x 不等于 y,循环体中的代码就会被执行。在循环体内,有一个 if 语句:如果 x 小于 y,则执行 y = y - x;否则执行 x = x - y。

这个算法实际上是一个著名的算法,用于计算两个数字的最大公约数。例如,对于数字 9 和 6,算法会执行 9 - 6 = 3,然后 6 - 3 = 3,最终得到最大公约数 3。
while 循环的语法
while 循环的一般语法如下:
while (condition) {
// 循环体:要重复执行的语句
}
程序首先评估条件。如果条件评估为 true,则执行循环体中的代码。执行完循环体后,程序会跳回到循环的开头,再次评估条件。只要条件为 true,这个过程就会一直重复。只有当条件评估为 false 时,程序才会跳出循环,继续执行循环体之后的代码。
循环体可以包含多个语句,甚至可以嵌套其他循环或 if-else 语句。

计算阶乘的示例
上一节我们介绍了 while 循环的基本语法,本节中我们来看看一个更复杂的例子:使用 while 循环计算一个数的阶乘。
阶乘函数在数学中定义为:n! = n * (n-1) * (n-2) * ... * 1,并且规定 0! = 1。
以下是使用 while 循环计算阶乘的代码示例:
public static int factorial(int n) {
if (n < 0) {
return -1; // 错误处理:负数没有阶乘定义
}
if (n == 0) {
return 1; // 根据定义,0的阶乘是1
}
int result = 1;
int i = n;
while (i > 0) {
result = result * i;
i = i - 1; // 等同于 i--;
}
return result;
}
程序首先进行错误处理:如果输入是负数,则返回 -1 作为错误码。如果输入是 0,则直接返回 1。对于正整数,程序初始化 result 为 1,并使用变量 i 作为计数器。只要 i 大于 0,循环就会将 result 乘以当前的 i,然后将 i 减 1。当 i 变为 0 时,循环结束,返回最终的 result。
我们可以通过调试来逐步观察这个循环的执行过程。例如,计算 factorial(5),循环会依次执行:1*5=5, 5*4=20, 20*3=60, 60*2=120, 120*1=120。
使用 break 语句
在 switch 语句中,我们使用 break 来结束一个分支。break 语句同样可以用于循环中,其作用是立即退出当前所在的最内层循环。

有时,使用 break 可以使代码更清晰,特别是当退出循环的条件比较复杂,不适合全部写在循环的初始条件中时。
以下是一个使用 break 的示例:程序持续读取用户输入的整数并求和,直到用户输入 0 为止。
int sum = 0;
while (true) {
int value = InputReader.readInt(“Enter a number (0 to stop): “);
if (value == 0) {
break; // 当用户输入0时,立即退出while循环
}
sum = sum + value;
}
System.out.println(“The sum is: “ + sum);
在这个例子中,循环条件被设置为 true,这意味着理论上它是一个无限循环。然而,在循环体内,我们检查用户输入的值。如果值为 0,则执行 break 语句,立即跳出 while 循环,然后执行循环后的打印语句。
需要注意的是,应谨慎使用 break,过度使用可能会使代码的逻辑变得难以理解。同时,确保循环最终有退出条件至关重要,否则会导致程序陷入无限循环,耗尽CPU资源。

do-while 循环
除了标准的 while 循环,还有一种变体叫做 do-while 循环。它与 while 循环的主要区别在于,do-while 循环至少会执行一次循环体,然后再检查循环条件。
do-while 循环的语法如下:
do {
// 循环体
} while (condition);
在 do-while 循环中,程序首先执行一次循环体中的代码,然后评估条件。如果条件为 true,则跳回开头再次执行循环体;如果为 false,则退出循环。
以下是一个使用 do-while 循环的示例,其功能与上一节使用 break 的求和示例类似:

int sum = 0;
int value;
do {
value = InputReader.readInt(“Enter a number (0 to stop): “);
sum = sum + value;
} while (value != 0);
System.out.println(“The sum is: “ + sum);
在这个例子中,即使用户第一次就输入 0,循环体也会先执行一次(将 0 加入 sum),然后检查条件 value != 0 发现为 false,循环结束。do-while 循环适用于那些需要先执行一次操作,再根据结果决定是否继续的场景。
实践练习:数字反转
现在,我们将应用所学的 while 循环知识来解决一个实际问题。请完成以下练习:编写一个程序,要求用户输入一个正整数,然后使用 while 循环将这个数字的各位数字反转后输出。
具体要求:
- 输入:123 -> 输出:321
- 输入:13579 -> 输出:97531
- 输入:2468 -> 输出:8642
重要提示:请不要使用字符串操作来完成此任务。我们希望你练习使用整数运算和循环。关键思路是使用 取模运算符 (%) 和 整数除法 (/)。
以下是一种可能的解决方案:
public static int reverseNumber(int number) {
int temp = number;
int reversed = 0;
while (temp > 0) {
int remainder = temp % 10; // 获取最后一位数字
reversed = reversed * 10 + remainder; // 将最后一位数字“附加”到反转数字的右侧
temp = temp / 10; // 移除最后一位数字
}
return reversed;
}
算法解释:
temp % 10可以得到temp的个位数(即最后一位数字)。reversed * 10 + remainder的作用是将新的数字(remainder)放到已反转数字的末尾。例如,已反转数字是 32,新数字是 1,则32 * 10 + 1 = 321。temp / 10进行整数除法,会去掉temp的个位数。例如,123 / 10 = 12。- 循环持续进行,直到
temp变为 0。
你可以将这段代码复制到编程环境中进行调试,输入如 2468,观察每一步中 temp、remainder 和 reversed 变量的变化,以彻底理解其工作原理。

总结
本节课中我们一起学习了循环结构的第一部分,重点是 while 循环。
- 我们了解了循环的基本概念:重复执行代码直到条件不满足。
- 我们掌握了
while循环的标准语法和执行流程。 - 我们通过计算最大公约数和阶乘的例子,实践了
while循环的应用。 - 我们学习了使用
break语句来提前退出循环。 - 我们简要介绍了
do-while循环及其适用场景。 - 最后,我们通过“数字反转”练习,综合运用了
while循环、取模和整数除法来解决实际问题。


理解循环是编程中的关键一步,请务必通过练习来巩固这些概念。
012:数组

概述
在本节课中,我们将要学习数组。数组是一种用于存储多个相同类型值的数据结构。我们将了解为什么需要数组、如何声明和初始化数组、如何访问数组元素,以及如何使用循环(特别是for循环)来遍历和操作数组。
为什么需要数组?🤔
我们之所以需要数组,是因为我们经常希望将许多相同类型的值存储在同一个数据结构中。这样,我们就不只是拥有一个整数、一个浮点数或一个双精度数,而是拥有多个这样的值。有时,我们甚至可能不知道具体有多少个值。数组的概念是将这些值连续地存储在一个所谓的数组中。数组有多个值,在创建数组时需要定义希望使用多少个值。然后,每个元素都位于一个特定的索引上。
数组索引从0开始
计算机科学家从零开始计数,而不是从一。第一个元素存储在索引0,第二个元素存储在索引1。这对于刚接触这个领域、没有经验的人来说可能有点反直觉,但事实就是如此。这有点像德国的建筑,第一层被称为“Erdgeschoss”或零层,第二层才是数字一。在美国,第一层通常被称为“一楼”。同样的混淆也发生在数组和索引上。请确保理解第一个元素总是在索引0,最后一个元素在数组长度减1的位置。
数组长度和访问
数组的长度是6,因为我们有六个元素。最后一个索引是长度减1,即5。因此,每当你想访问数组中的一个元素时,请确保在使用长度时进行减1操作,否则你可能会遇到所谓的“索引越界异常”,你的程序可能会终止。
声明数组
数组可以像这里看到的那样声明。你总是先指定类型,然后是方括号,最后是名称。还有一种替代表示法,即先指定类型和名称,然后是方括号,但我们通常使用方括号紧跟在类型后面的表示法,因为这更容易理解。注意,我们使用了new关键字,这应该已经告诉你我们这里有一个引用类型。
数组是引用类型
这意味着,如果我们使用等号运算符复制数组,我们实际上并没有复制数组本身,而只是复制了引用。就像对象类型一样,数组不是简单类型,即使它是一个整数数组。因此,我们不是复制所有值,而是创建另一个引用。我们仍然在内存区域中有唯一的值,这个引用指向这些值。同样,这些值从索引0开始连续编号。
访问数组元素和长度
第i个元素通过name[i]访问。长度可以通过name.length访问,但前提是数组已经初始化。索引范围是从0到length - 1。如果你使用负索引或大于length - 1的索引,你将得到数组索引越界异常。我们将在后面更详细地介绍异常。请记住,异常是一种错误,如果错误非常严重,你的程序将崩溃,无法继续运行。
数组的实用示例:多边形
在我们的点世界中,我们可以使用数组。还记得我们上一个工作坊中的Point类吗?一个多边形由多个点组成。然而,我们不知道具体有多少个点。多边形可以有五个点,也可以有六个、七个、八个甚至一百万个点。这是不确定的,是任意的。这是一个非常好的使用数组的案例。我们可以有一个Point数组。你可以看到,我们也可以使用类类型、复杂数据类型作为数组,而不仅仅是基本类型如整数。
创建数组
我们像处理普通属性一样操作。我们有一个带有一个输入参数的构造函数,我们传递点给它。我们通过new Point[5]来创建数组,并指定长度。在创建新数组时,你必须指定长度。Java需要知道需要预留多少内存。有一些其他数据结构看起来没有定义长度,但它们内部有一些机制来在某个时间点增加或减少数组长度。
填充数组
我们创建了几个点,并使用索引将它们存储在数组中。如果我们想将其存储在索引0,我们使用[0],依此类推。记住,points[5]是不可能的,因为那将超出我们的数组长度。然后,我们可以简单地将这些点传递到我们的多边形中。
另一种创建数组的方式
我们也可以先创建所有点作为局部变量p1、p2、p3、p4、p5,然后将它们分配给一个数组,而不指定长度,而是通过在大括号中指定所有内容。这样,Java可以自动推断长度,因为如果我们传递五个逗号分隔的点,长度就是五;如果我们传递三个逗号分隔的点,长度就是三,依此类推。
使用循环填充数组
我们可以使用while循环来填充数组。当我们像这里这样声明一个数组时,我们还没有指定长度,我们根据用户输入指定长度。然后,我们可以用一个while循环来填充它。当i小于n时,我们将用户输入的下一个数字添加到特定位置的索引中。然后,我们将i加1,以确保移动到下一个索引。
迭代模式
这是一个非常常见的模式,称为迭代模式。它是遍历数组的一种典型形式。我们初始化所谓的运行索引,通常缩写为i。然后,我们有一个带有进入条件的while循环,然后在循环体结束时修改运行索引。这是一个非常常见的模式。
寻找数组最小值
为了确定数组的最小值,我们基本上遍历所有元素。如果元素小于当前最小值,我们将其存储为结果;否则,我们继续。这是一个非常典型的模式。我们初始化运行索引,将其放在while循环的条件中,并在循环结束时修改它。通过这样做,我们一步一步地进行。
为什么从索引1开始?
我们以1作为初始值,而不是0,因为我们想减少冗余或提高性能。因为我们已经有了索引0的元素,我们不需要将其与自身比较,这不会给我们带来新信息。这就是为什么我们通常从1开始。然而,如果你遍历一个数组,你通常会从索引0开始,这并不重要。
for循环
因为这种迭代模式非常常见,人们发明了另一种循环,称为for循环。for循环的工作原理类似,但在其定义中内置了这种迭代模式和三段式规则。我们总是在for循环中初始化运行索引,将运行索引与数组长度进行比较,然后修改值。因此,我们不是将初始化放在循环之前、条件放在循环开始、修改放在循环结束,而是将所有内容写在开头并提前定义,然后我们就不必在之后处理它了。
for循环的执行顺序
然而,i++增量仍然只在循环结束时执行。因为我们将其写在开头,并不意味着它在循环开始时执行。它总是在结束时执行。初始化在循环开始前执行一次。条件总是在开始时评估,修改总是在结束时执行。因此,我们将初始化和修改写入for循环的声明中。
for循环的语义
for循环的语义是:我们初始化某些东西(分号),我们有一个条件(分号),我们修改某些东西,然后我们有语句。这与while循环一样强大。因此,你可以选择你更喜欢哪一个。如果你更喜欢while循环,就用while循环;如果你更喜欢for循环,就用for循环。但在任何情况下,你都需要学习两者,因为两者在实践中都被使用。
增量和减量操作
我们经常写i++作为快捷方式,或者也可以是i--,因为你也可以从数组的右侧开始并向下移动索引。i++等同于i = i + 1。如果你遍历数组,我们强烈建议使用for循环,或者稍后使用更现代的for-each循环,因为它们通常更容易理解。
循环比较
如果我们比较今天学到的所有三种循环,我们有for循环、while循环和do-while循环。我们还展示了无限循环的语法,这是你应该避免的,因为无限循环在正常程序行为中通常非常糟糕。有些情况下我们需要无限循环,但通常不是这样。这是我们今天关于循环学到的主要内容。
增量和减量操作的陷阱
这些增量和减量操作有点棘手。我建议谨慎使用它们,并且只在完全理解它们的情况下使用。你需要理解的是,前缀增量和后缀增量之间是有区别的。前缀增量或减量在确定表达式值之前执行增量或减量,而后缀则在之后执行。这可能会非常令人困惑。在这个特定例子中,你将7分配给索引x,然后x递增。如果x当前是0,7就在数组的第一个位置,然后x变成1。而如果使用前缀,意味着首先执行x = x + 1,然后将7分配给索引1。在这个特定例子中,我建议完全不要使用前缀,因为它非常令人困惑。我也建议只在完全清楚的情况下使用后缀增量,但要避免像这里这样做。在我看来,这是非常糟糕的代码,你不应该这样做,因为人们会感到困惑。人们不会记住这个,因为他们不经常使用它。我总是更喜欢在这里写两个语句。
赋值与条件
还要确保你理解赋值(一个等号运算符)和条件(两个等号运算符)之间的区别。编译器会警告你,但这里的这个语句,即使我们将x赋值为false,也总是会评估为true,因为它首先将x赋值为true,然后使用x的值进行if语句。在赋值之后,它总是会被执行。这是初学者有时会遇到的困难。确保你永远不要在if条件中进行赋值,这也是不好的做法。总是在前面进行赋值。在条件中,只使用双等号运算符,而不是单等号运算符。有了这个经验法则,你可以避免一些非常难以发现的讨厌问题。
使用for循环填充数组
这是另一个使用for循环填充数组的例子。我们已经用while循环做过了。我们想在这个函数中,根据最终用户指定的数字读取一个数组。我们确定希望读取多少个元素到数组中,然后我们根据给定的数字(例如5)创建数组并用new运算符初始化它。然后,我们有了这个非常典型的迭代模式:for (int i = 0; i < number; i++),然后我们说result[i] = inputReader.readInt()。如果你将这个复制粘贴到你的playground中并运行它,你将看到你可以读取值,并且不会得到数组越界异常。如果你像这样正确定义它,如果你改变它,如果你说i <= number,或者如果你从这里开始不同,你可能最终会得到索引越界异常。所以,请确保你理解这里非常典型的模式。
复制数组
有一些额外的帮助语句,以防你不太理解代码。我们也可以复制数组。实际上,有一个复制函数可以使用,如果你不想自己动手的话。但你也可以自己动手。如果我们给你一个浮点数数组作为输入,我们可以创建一个副本。记住,副本一开始是空的,然后我们遍历它,并将原始数组索引i处的元素分配给副本索引i处的元素。我们对所有元素都这样做。所以,如果我们有1000个元素,这个循环将被执行1000次,然后我们就有了数组的完整副本。
数组副本与别名
同样,数组使用引用语义,你不能简单地将其分配给一个副本,那样你只复制了引用。如果你想复制内容,你必须使用循环,你可以使用for或while循环。这里有一个如何使用这个的例子。我们可以创建一个副本,但我们也可以创建一个别名。现在,如果我们改变副本,原始值不会改变。而如果我们改变别名,原始值会改变。这是这里的巨大区别。如果你创建一个副本并改变值,原始数组不会受到影响。如果你创建一个别名并改变值,原始数组会受到影响,因此这个输出1.0,即使我改变了别名数组而不是直接改变原始数组。
字符数组和字符串
我们也可以有字符数组,其中我们有多个字符一个接一个,这基本上就是字符串。注意,字符串内部表示就是一些字符数组,并且字符串总是不可变的。所以,如果你给字符串赋一个新值,Java实际上在内部为你创建一个新字符串,你只需要在这里使用new运算符,这使得它更容易。
字符串的便利方法
有一些便利方法,例如,你可以获取字符串的长度,你可以使用与for循环中相同的想法获取某个索引处的字符,只是这里的表示法不同。你还可以检查字符串是否包含某些字符,你还可以获取某些字符的第一个索引。更多信息请参阅文档。这只是让你意识到,字符串不过是字符数组。

总结
在本节课中,我们一起学习了数组的基本概念、声明和初始化方法、如何访问和修改数组元素,以及如何使用for循环和while循环遍历数组。我们还了解了数组的引用特性、如何复制数组以及字符串与字符数组的关系。掌握这些知识将帮助你更有效地处理多个数据值,并为后续学习更复杂的数据结构打下基础。
013:搜索算法 🧐

在本节课中,我们将学习搜索算法,了解其工作原理。我们将通过一个具体的例子来练习while循环、for循环以及if-else条件语句的使用。搜索算法是理解条件选择和循环迭代的绝佳案例。
数组中的简单搜索
上一节我们介绍了循环和条件语句,本节中我们来看看如何利用它们在一个数组中搜索特定元素。
我们有一个非常简单的例子,演示如何在数组中搜索。具体做法是,我们可以创建一个名为has的静态方法或函数。该方法用于检查一个长整型数组array中是否包含元素x。
以下是实现方法:我们使用刚刚学过的for循环遍历整个数组。然后,如果数组在当前索引i处的值等于我们要搜索的值x,我们就返回true,这会立即中断并退出循环。如果我们遍历完整个数组后仍未找到该元素,则返回false。这是在数组中搜索元素的一种非常简单的方法。
关于这个小例子,同样涉及循环迭代。我们遍历数组,并使用带有条件的if语句来检查是否能找到该元素。
我们可以通过幻灯片来可视化这个过程。假设我们有元素7,并希望在数组中搜索它。我们将其与索引0处的第一个数组元素进行比较,如果不相同,则导航到下一个元素,依此类推,直到找到它,然后算法结束。我们不会继续搜索更多元素。元素可能出现两次,数组不保证元素只出现一次,但这对我们来说无关紧要,我们只想知道数组是否包含这个元素。
算法性能分析
现在,这个算法在性能方面是否非常好?或者你能想出一个更好的算法吗?你怎么看?如果你处理的是无序数组,并且有时无法真正改变顺序,那么这是你能做的最快的事情。对于无序数组,为了找到某个元素或确认其不存在,你可能需要查找每一个元素。
但是,如果你需要非常频繁地进行搜索,这种方法的性能会非常低且缓慢。如果你经常搜索,最好有一个排序后的数组版本。你可以对数组本身进行一次排序(这比普通搜索耗时更长),但之后的搜索会更快。或者,如果你因为元素顺序具有特定含义而无法对原始数组进行排序,那么你可以创建一个所谓的“第二版本”数组,其中的元素是排序好的,这样搜索的实现速度会快得多。
像谷歌这样的大型搜索公司投入了大量时间来研究如何使搜索尽可能快,因为这是他们的主要业务。如果他们能比其他公司快10%,就能节省10%的计算资源,也能为最终用户节省10%的搜索时间。因此,研究这些算法对他们来说确实非常重要。
使用While循环的替代实现
我们也可以使用for循环,但实现方式略有不同。我们看到,在这里我们基本上两次访问数组:首先检查数组长度作为条件,然后再次检查索引处的值是否相同。有些人认为在这里有两个条件并不好。我们可以使用while循环来实现一种替代搜索方法,将两个元素放入同一个条件中。具体做法是,在循环条件中加入一个额外的条件,以确保在找到元素时循环提前结束。
这只是一种替代方式。我们再次设置索引i = 0。我们有一个临时的布尔变量found,初始值为false,表示我们最初没有找到任何东西。当found不为真且i小于数组长度时,我们检查这个条件并将found设置为true。然后,如果我们没有找到元素(found仍然为false),我们就继续循环。如果我们执行到这个特定的语句,我们会再做一次递增,最后一次,然后我们也退出循环。
那么问题来了,让我们回到这张幻灯片,如果一个数组有两个相同的元素会发生什么?什么也不会发生。这是数组的预期用途。数组可以始终包含相同的数字。如果你有一个包含一百万个零的数组,那完全没问题。数组不应该对元素的顺序或唯一性有任何假设。我们唯一的假设是数组有特定的长度,并且我们可以随时访问和覆盖元素。
第二个版本的算法中,!found与found == false相同,对于从未见过这种写法的人来说,这是一种缩写形式。

动手练习:代码调试
现在,我们想和你做一个互动练习。为此,我也会上传幻灯片。我们要求你将两个不同版本的搜索算法复制到你的编程环境中,然后在你自己的环境中尝试它们,进行一些调试以理解发生了什么,并更好地理解你的操作。我们可以一起做这个练习。
我们基本上错过了幻灯片的这一部分。好的,幻灯片现在应该更新了,如果你重新加载页面,可以找到它们。然后,请首先将代码(从while循环版本开始)复制并粘贴到你的编程环境中。
我将把它们放入这个字符块练习中,而不是另一个。现在先去掉所有其他代码。然后,我们就有了它。
为了稍微练习一下,我们首先当然需要创建一个数组。例如,我们可以说long[] mySuperArray = {1, 2, 5, 8, 10};。然后,我们想检查数组是否包含或拥有值8。现在,我们像这样操作,然后我们可以执行System.out.println来输出是否找到。
像这样。如果我们在这里设置一个断点,我们可以启动应用程序,调试它,看看这里发生了什么。例如,我们有数组{1, 2, 5, 8, 10},我们有x,即8。现在,如果我们单步执行,我们会看到:我们第一次进入while循环。然后我们检查索引0处的元素(即1)是否等于8(这里双等号运算符非常重要)。情况并非如此,所以我们将其增加1。再次执行。现在我们将2与8比较,不是。现在我们将5与8比较,不是。现在我们将8与8比较,是的。所以,我们数组中索引3处的值是8,我们比较它,然后进入found = true,我们再做最后一次递增,但之后我们不再进入循环。
请你自己用一个小例子尝试这个,并且也用for循环的第二个版本替换方法,做同样的事情。也试试看它是如何工作的。这对于你来说是一项非常重要的技能:对于每一个给定的程序,你都需要能够理解它。如果你通过阅读无法理解,你可以使用调试器,这是一项非常基本的技能,我再怎么强调也不为过。调试器真的是你检查程序运行情况的朋友。我有时也难以理解代码,你在这方面并不孤单。然后我也会使用调试器。所以,这真的非常重要,每个程序员都使用调试器来更好地理解代码。所以请你自己尝试一下,复制粘贴代码并让自己熟悉它。我们再给你五分钟时间来做这个,然后我们将继续讲座。
总结与后续安排

好的,我想这给了你足够的时间来调试代码并获得更好的理解。关于这两种在数组中搜索的方法,还有什么未解决的问题或不清楚的地方吗?
也许需要一点反馈:你们中有谁感觉很好地理解了这些方法?谁仍然感到非常困难?谁不知道自己是否感到困难?好的,所以如果你感到困难,请之后花时间练习。我们接下来将有一整周的时间,你可以练习while、for、if-else等等,并且如果你不理解某些内容,也可以使用调试器。我们也有导师和助教的强大支持。所以,请抓住机会练习这个,练习、练习、再练习真的很重要。好消息是,下周的研讨会上我们不会添加任何新内容,而是会有一个复习周,我们将再次深入讲解到目前为止学到的概念,我们希望并且也希望在第三周之后,你已经为继续学习新内容做好了充分准备。


本节课中我们一起学习了搜索算法的基本原理,包括使用for循环和while循环在数组中查找元素的两种方法。我们讨论了算法性能,并强调了使用调试器来理解代码流程的重要性。通过动手练习,我们实践了代码调试,为后续更复杂的学习打下了基础。
014:排序算法入门

在本节课中,我们将要学习排序算法。排序是计算机科学中的一项基础操作,其目标是将一个无序的元素序列(例如整数数组)按照特定顺序(如升序或降序)重新排列。本节课我们将重点介绍一种简单直观的排序算法——插入排序。通过学习其实现,你将巩固对 if-else 语句、while 和 for 循环的理解。
排序的基本概念
排序意味着我们有一个元素序列,初始时通常是乱序的,我们的主要目标是将其排序。在本例中,我们希望按升序(从最小到最大)排列。你也可以进行降序排序,但本节课我们专注于升序。
一个简单的思路是:将序列存储在一个数组中,然后创建第二个数组。接着,我们遍历原始数组,将每个元素插入到第二个数组的正确位置上。这种方法被称为插入排序。
插入排序算法详解
上一节我们介绍了排序的基本概念,本节中我们来看看插入排序的具体步骤。
以下是插入排序的工作原理:
- 我们从一个原始数组开始,例如
[17, 3, -2, 9, 0, 1, 7, 42, 5]。 - 创建一个新的空数组(结果数组)。
- 遍历原始数组中的每一个元素:
- 将当前元素插入到结果数组的正确位置。
- 为了给新元素腾出空间,可能需要将结果数组中一些已有的元素向右移动。
让我们通过一个例子来可视化这个过程。假设我们要将元素 5 插入到结果数组 [-2, 0, 1, 3, 7, 9, 17, 42] 中。
- 首先,我们需要找到
5应该插入的位置。通过比较,我们发现它应该位于3之后、7之前,即索引4的位置。 - 然后,我们需要将索引
4及之后的所有元素(7, 9, 17, 42)都向右移动一位。 - 关键点:移动必须从最右边的元素(
42)开始,依次向左进行。如果从左开始移动,会覆盖掉尚未移动的元素值,导致数据丢失。 - 移动完成后,我们就可以在索引
4的位置插入元素5。
插入排序的代码实现
理解了算法步骤后,现在我们来探讨如何用代码实现它。我们将采用“分而治之”的策略,将大问题分解为几个小函数。
首先,我们有一个主排序函数。它接收一个无序的整数数组,并返回一个新的已排序的数组。
public static int[] insertionSort(int[] array) {
// 创建一个与原始数组等长的新数组
int[] result = new int[array.length];
// 遍历原始数组中的每个元素
for (int i = 0; i < array.length; i++) {
// 将当前元素 array[i] 插入到结果数组 result 的正确位置
// 当前结果数组中已有 i 个元素(索引 0 到 i-1)
insert(result, array[i], i);
}
return result;
}
主函数的核心是 insert 方法,它负责执行单次插入操作。
insert 方法需要完成三个步骤:
- 定位:找到新元素
element在结果数组arr中应该插入的位置(索引)。 - 移位:从该位置开始,将
arr中已有的元素向右移动一位,以腾出空间。 - 插入:将新元素放入腾出的位置。
以下是 insert 方法的框架:
private static void insert(int[] arr, int element, int endIndex) {
// 1. 定位插入位置
int insertIndex = locate(arr, element, endIndex);
// 2. 移位
shift(arr, insertIndex, endIndex);
// 3. 插入
arr[insertIndex] = element;
}
实现 locate 方法
locate 方法的任务是找到正确的插入索引。它从数组开头开始,将新元素与当前位置的元素比较,直到找到第一个比新元素大的元素,或者到达当前数组的末尾。
private static int locate(int[] arr, int element, int endIndex) {
int insertIndex = 0;
// 注意循环条件:insertIndex < endIndex 防止数组越界
// 并且使用“短路与”操作符:仅当第一个条件为真时,才计算第二个条件
while (insertIndex < endIndex && element > arr[insertIndex]) {
insertIndex++;
}
return insertIndex;
}
代码解释:
while (insertIndex < endIndex && element > arr[insertIndex]):只要还没到数组末尾并且新元素大于当前位置的元素,就将插入索引加一。- 短路求值:如果
insertIndex < endIndex为假,则不会计算element > arr[insertIndex],这避免了潜在的数组越界错误。
实现 shift 方法
shift 方法负责将元素从 insertIndex 到 endIndex-1 的位置向右移动一位。必须从右向左进行移动。

private static void shift(int[] arr, int insertIndex, int endIndex) {
// 从右向左遍历,从 endIndex-1 开始,到 insertIndex 结束
for (int i = endIndex - 1; i >= insertIndex; i--) {
arr[i + 1] = arr[i]; // 将当前元素的值赋给右边一位
}
}
为什么必须从右向左?
如果从左向右移动,例如先将 arr[insertIndex] 的值赋给 arr[insertIndex+1],那么 arr[insertIndex] 的原始值就丢失了,后续无法正确移动其他元素。
整合与测试
现在,我们可以将所有部分整合起来,也可以将逻辑写在一个完整的函数里以便初学者理解。以下是整合后的 insertionSort 函数:

public static int[] insertionSort(int[] array) {
int[] result = new int[array.length];
for (int i = 0; i < array.length; i++) {
int element = array[i];
// 定位 (对应 locate 函数)
int insertIndex = 0;
while (insertIndex < i && element > result[insertIndex]) {
insertIndex++;
}
// 移位 (对应 shift 函数)
for (int j = i - 1; j >= insertIndex; j--) {
result[j + 1] = result[j];
}
// 插入
result[insertIndex] = element;
}
return result;
}


你可以使用以下代码进行测试:
public static void main(String[] args) {
int[] unsortedArray = {-4, 10, 5, -8, 17};
int[] sortedArray = insertionSort(unsortedArray);
// 打印排序后的数组
System.out.println(Arrays.toString(sortedArray)); // 输出: [-8, -4, 5, 10, 17]
}
关于算法性能:插入排序的平均时间复杂度是 O(n²),这意味着对于大规模数据,它的效率相对较低。更快的排序算法如快速排序和归并排序,但它们更为复杂,将在后续课程中学习。
课程总结
本节课中我们一起学习了排序算法,特别是插入排序。
- 我们首先理解了排序的目标:将无序序列按特定顺序排列。
- 然后,我们详细拆解了插入排序的步骤:定位、移位、插入。
- 接着,我们使用
for循环、while循环和if条件判断的逻辑,逐步实现了该算法。 - 我们强调了实现中的关键点,如从右向左移位和短路求值的重要性。
- 最后,我们整合代码并进行了测试。


通过实现插入排序,你不仅学习了一个基础算法,更重要的是练习并巩固了程序控制流(条件与循环)和数组操作的核心编程技能。请务必通过练习和调试来加深理解。
015:类 🎃

在本节课中,我们将深入学习类的核心概念。我们将从理解什么是类和对象开始,然后通过一个具体的例子——创建一个“杰克南瓜灯”类——来实践如何定义类、添加属性和方法,并实例化对象。
上一节我们介绍了课程的整体目标,本节中我们来看看如何创建和使用单个类。
什么是类和对象?
类是一个模板或蓝图。它定义了对象的形式。例如,一个“杰克南瓜灯”类可以定义所有南瓜灯共有的特征。
对象是类的一个具体实例。它是根据类的蓝图创建出来的一个具体事物。例如,根据“杰克南瓜灯”类,我们可以创建出许多个外观各不相同的具体南瓜灯对象。
创建“杰克南瓜灯”类
让我们在 IntelliJ IDEA 中开始实践。首先,创建一个新项目,然后为“杰克南瓜灯”类创建一个新的 Java 文件。
以下是类的初始结构,包含一些基本属性:

public class JackOLantern {
// 属性
String faceType; // 面部表情类型
boolean isLit; // 蜡烛是否点亮
String pumpkinColor; // 南瓜颜色
String name; // 南瓜灯的名字
float weight; // 南瓜灯的重量
}
我们为 JackOLantern 类定义了五个属性。faceType 和 pumpkinColor 使用 String 类型存储文本信息,isLit 使用 boolean 类型表示真假,name 也是 String 类型,而 weight 使用 float 类型以保留小数精度。


为类添加方法
类不仅包含数据(属性),还包含行为(方法)。让我们为南瓜灯添加一个介绍自己的方法。
以下是添加 printName 方法的代码:
public class JackOLantern {
// ... 属性定义同上 ...
// 方法
public void printName() {
System.out.println("This Jack-O-Lantern's name is " + name + ".");
}
}
printName 方法用于打印南瓜灯的名字。注意,我们使用了 this.name 来访问当前对象的 name 属性,确保引用的是实例变量而非局部变量。
使用构造函数初始化对象
目前,如果我们创建一个对象,其属性值将是默认值(如 null 或 false)。为了在创建对象时就赋予其特定的属性值,我们需要使用构造函数。
以下是包含构造函数的完整类定义:
public class JackOLantern {
// 属性
String faceType;
boolean isLit;
String pumpkinColor;
String name;
float weight;
// 构造函数
public JackOLantern(String faceType, boolean isLit, String pumpkinColor, String name, float weight) {
this.faceType = faceType;
this.isLit = isLit;
this.pumpkinColor = pumpkinColor;
this.name = name;
this.weight = weight;
}
// 方法
public void printName() {
System.out.println("This Jack-O-Lantern's name is " + name + ".");
}
}
构造函数在创建新对象时被调用。它接收参数(faceType, isLit 等)并将它们赋值给新对象的对应属性。关键字 this 指代当前正在创建的对象本身。
实例化对象并调用方法
现在,我们可以在 main 方法中创建 JackOLantern 对象并调用其方法。
以下是在主程序中实例化对象并调用方法的示例:
public class Main {
public static void main(String[] args) {
// 实例化一个 JackOLantern 对象
JackOLantern spookyPumpkin = new JackOLantern("Scary", true, "Orange", "Spookley", 1.7f);
// 调用对象的方法
spookyPumpkin.printName(); // 输出:This Jack-O-Lantern's name is Spookley.
}
}
我们使用 new 关键字和构造函数创建了一个名为 spookyPumpkin 的对象。然后,通过对象名加 . 运算符调用了 printName 方法。
添加更多方法
为了让类更有用,我们可以添加更多方法来改变对象的状态。
以下是两个新增方法的示例:一个用于改变颜色,另一个用于切换灯的开关状态。


public class JackOLantern {
// ... 属性和构造函数同上 ...
// 改变颜色的方法
public void changeColor(String newColor) {
pumpkinColor = newColor;
System.out.println(name + "'s color has been changed to " + newColor + ".");
}
// 切换灯光状态的方法
public void toggleLight() {
isLit = !isLit; // 使用逻辑非运算符进行切换
System.out.println(name + "'s light is now " + (isLit ? "on" : "off") + ".");
}
}
changeColor 方法接收一个 newColor 参数,并更新 pumpkinColor 属性。toggleLight 方法使用逻辑非运算符 ! 来反转 isLit 的布尔值,实现开关切换。

参数与参数
在编程中,我们经常听到“参数”和“参数”这两个词,它们容易混淆。
- 参数 是定义在方法或构造函数括号内的变量,用于接收外部传入的值。
- 参数 是在调用方法或构造函数时,实际传入括号内的具体值。
在 changeColor(String newColor) 中,newColor 是一个参数。
在 spookyPumpkin.changeColor("Purple"); 中,"Purple" 是一个参数。
总结


本节课中我们一起学习了面向对象编程的基础——类与对象。
- 我们理解了类是定义对象属性和行为的蓝图,而对象是类的具体实例。
- 我们实践了如何定义一个类,包括添加不同类型的属性(如
String,boolean,float)。 - 我们学习了如何编写构造函数来初始化新对象。
- 我们为类添加了方法来定义对象的行为,并学会了如何调用它们。
- 最后,我们辨析了参数与参数的区别。

通过创建“杰克南瓜灯”这个有趣的例子,你应该已经掌握了创建和使用单个类的基本步骤。建议你课后尝试创建自己的类,添加不同的属性和方法,以巩固所学知识。
016:多个类与数组 🚗

在本节课中,我们将学习如何在Java程序中同时使用多个类,并结合数组来管理对象。我们将通过一个具体的汽车模型示例,理解如何将现实世界中的对象关系映射到代码中。
数组回顾 📚
上一节我们介绍了单个类的使用,本节中我们来看看如何管理一组对象。数组是存储多个同类型对象的有效工具。
以下是使用数组的三个关键步骤:
- 声明数组
声明数组的语法与声明其他变量类似,但在类型后需要加上方括号[]。private Seal[] seals;

-
初始化数组
声明后,数组是空的。需要使用new关键字并指定大小来初始化。seals = new Seal[10];数组的大小是固定的。你也可以在初始化时直接填入值:
String[] texts = {"Hello", "World"}; -
使用数组元素
通过数组变量名和索引(从0开始)来访问或修改特定位置的元素。// 访问第一个元素 Seal firstSeal = seals[0]; // 为第一个位置赋值 seals[0] = new Seal("Conrad");对于一个大小为10的数组,有效索引范围是0到9。

类结构解析 🧩
现在,让我们进入练习部分。首先,我们需要理解提供的类之间的关系。
以下是本练习中涉及的类及其关系:
-
Car(汽车):核心类。一辆汽车拥有:
- 静态公共属性:轮胎数量(
NUMBER_OF_TIRES)和各轮胎位置索引(如FRONT_LEFT_TIRE)。 - 私有非静态属性:制造商(
manufacturer)、是否有车顶(hasRoof)。 - 关联关系:一辆汽车有两个车门(
Door)、一个引擎(Engine)和一个轮胎数组(Tire[])。汽车还可以有一个驾驶员(Seal)。
- 静态公共属性:轮胎数量(
-
Door(车门):汽车的一部分。车门可以打开或关闭。

-
Engine(引擎):汽车的一部分。拥有马力属性。
-
Tire(轮胎):汽车的一部分。轮胎可以被安装或拆卸。
-
Seal(海豹/驾驶员):这是一个独立的实体。与汽车存在双向关系:海豹可以进入汽车,汽车也可以载有海豹。海豹还可以拥有驾照。
实践:构建汽车 🛠️
我们将主要在 Main 类中编写代码。首先,我们需要创建一辆汽车。


创建汽车需要引擎和两个车门作为构造参数。因此,我们必须先创建这些对象。
// 1. 创建所需部件
Door leftDoor = new Door();
Door rightDoor = new Door();
Engine engine = new Engine(10); // 10马力
// 2. 创建汽车
Car car = new Car("Lego", engine, leftDoor, rightDoor);
现在,我们拥有了一辆没有轮胎的汽车。接下来需要创建并安装轮胎。
一辆汽车有四个轮胎位置。我们需要创建四个独立的轮胎对象,并将它们安装到正确的位置。
// 创建四个轮胎对象
Tire tire1 = new Tire(1.0); // 胎压1.0巴
Tire tire2 = new Tire(1.0);
Tire tire3 = new Tire(1.0);
Tire tire4 = new Tire(1.0);
// 将轮胎安装到汽车的指定位置
car.setTire(Car.FRONT_LEFT_TIRE, tire1);
car.setTire(Car.FRONT_RIGHT_TIRE, tire2);
car.setTire(Car.REAR_LEFT_TIRE, tire3);
car.setTire(Car.REAR_RIGHT_TIRE, tire4);
重要提示:不能将同一个轮胎对象安装到多个位置。虽然代码可能允许,但这不符合现实逻辑。每个轮胎位置需要一个独立的物理对象,因此在代码中也需要独立的Tire对象实例。

添加驾驶员 🦭

汽车准备好了,现在需要一位驾驶员。
// 创建海豹驾驶员Conrad
Seal driver = new Seal("Conrad");
// Conrad需要通过驾照考试
driver.passDrivingTest();
// Conrad需要进入汽车。但车顶可能妨碍,我们先移除车顶。
car.setHasRoof(false);
// 现在Conrad可以进入汽车了
driver.getIntoCar(car);
注意,getIntoCar 是 Seal 类的方法。这是因为在现实世界中,是“海豹”这个主体执行“进入汽车”的动作,而不是汽车本身去“装载”海豹。这种设计体现了对现实关系的建模。
总结 🎯

本节课中我们一起学习了:
- 数组的使用:包括声明、初始化和访问,记住索引从0开始。
- 多类协作:如何创建多个类的对象,并通过方法调用让它们相互作用。
- 现实建模:编写代码时,思考对象关系在现实中是否合理至关重要(例如,“汽车拥有引擎”是合理的,而“引擎拥有汽车”则不合理)。
- 对象独立性:每个物理对象对应代码中的一个独立对象实例,不能随意共享(如一个轮胎不能同时装在四个位置)。

核心在于,Java代码应反映现实世界的逻辑。在设计和编写类、属性及方法时,多思考“这在现实中是如何工作的”,能帮助你构建出更清晰、更健壮的程序。
017:条件语句与表达式

在本节课中,我们将要学习Java中用于程序决策的核心工具:布尔值、逻辑运算符、比较运算符以及条件语句。我们将通过一个关于海豹健康的例子,逐步学习如何使用if、switch和三元运算符来控制程序的执行流程。
布尔值与逻辑运算符
上一节我们介绍了程序的基本结构,本节中我们来看看程序如何做出决策。就像你今天决定是否来上课一样,程序也需要根据条件做出选择。
在Java中,最基本的决策基础是布尔值。布尔值只有两种可能:true(真)或 false(假)。这类似于回答“是”或“否”。
然而,现实中的问题往往更复杂,需要组合多个条件。这时我们就需要逻辑运算符。
以下是三种基本的逻辑运算符:
-
NOT(非)运算符:用于取反一个布尔值。
- Java表示:
! - 示例:如果不去教室(
!goToLectureHall),就看直播。
- Java表示:
-
AND(与)运算符:要求两边的条件同时为真,结果才为真。
- Java表示:
&& - 示例:只有天气晴朗并且温暖(
sunny && warm),才去教室。
- Java表示:
-
OR(或)运算符:要求两边的条件至少有一个为真,结果就为真。
- Java表示:
|| - 示例:如果下雨或者刮风(
rain || storm),就看直播。
- Java表示:
注意:在数学中你可能见过
¬,∧,∨等符号,但在Java中我们使用!,&&,||。
比较运算符
除了组合布尔条件,程序还经常需要比较两个值的大小或是否相等。这时需要使用比较运算符。
以下是常用的比较运算符:
- 等于:
- 对于基本数据类型(如
int,boolean),使用==。 - 对于对象(引用类型),使用
.equals()方法。
- 对于基本数据类型(如
- 不等于:
- 对于基本数据类型,使用
!=。 - 对于对象,通常使用
! .equals()。
- 对于基本数据类型,使用
- 大于:
> - 小于:
< - 大于等于:
>= - 小于等于:
<=
重要提示:一个等号
=是赋值运算符,用于给变量赋值。两个等号==才是比较运算符,用于判断是否相等,切勿混淆。
练习:创建海豹类与健康判断方法
现在,让我们运用布尔值和运算符进行第一个练习。我们将创建一个 Seal(海豹)类,并为其添加一个判断健康状态的方法。
任务要求:
- 创建一个名为
Seal的类,它有一个整数属性sealHealthinessLevel,初始值设为5。 - 在该类中创建一个返回布尔值的方法
isSealHealthy。 - 该方法接受两个布尔参数:
movedEnough(运动充足)和sleptEnough(睡眠充足)。 - 方法应返回
true,当:(movedEnough为真 且sealHealthinessLevel > 0) 或 (sleptEnough为真 且sealHealthinessLevel > 0)。
根据分配律,我们可以优化这个逻辑:海豹健康的条件是 sealHealthinessLevel > 0 并且 (movedEnough 为真 或 sleptEnough 为真)。
代码实现:
public class Seal {
private int sealHealthinessLevel = 5;
public boolean isSealHealthy(boolean movedEnough, boolean sleptEnough) {
return (sealHealthinessLevel > 0) && (movedEnough || sleptEnough);
}
}
在 main 方法中测试:
Seal mySeal = new Seal();
boolean healthy = mySeal.isSealHealthy(true, true);
System.out.println("Is the seal healthy? " + healthy); // 输出:Is the seal healthy? true



If-Else 条件语句
程序决策最直观的表达方式是 if-else 语句。它允许程序根据条件选择执行不同的代码块。
一个 if-else 语句链的结构如下:
if (条件) { ... }:如果条件为真,则执行此代码块。else if (其他条件) { ... }:如果之前的if条件为假,但此条件为真,则执行此代码块。可以有多个else if。else { ... }:如果所有if和else if的条件都为假,则执行此代码块。
练习:为海豹添加进食方法
现在,让我们为 Seal 类添加一个 eat 方法,使用 if-else 链来根据食物类型改变海豹的健康值。
任务要求:
创建一个 void eat(String food) 方法,根据参数 food 的值改变 sealHealthinessLevel:
- 如果
food是"sugar candy",健康值-2。 - 如果
food是"sugar free candy",健康值-1。 - 如果
food是"fish lollipop",健康值+1。 - 如果
food是"penguin lollipop",健康值+2。 - 其他任何食物,健康值不变。
- 每次变化后,打印:
"new seal healthiness level: " + sealHealthinessLevel。
代码实现:
public void eat(String food) {
if (food.equals("sugar candy")) {
sealHealthinessLevel -= 2;
} else if (food.equals("sugar free candy")) {
sealHealthinessLevel -= 1;
} else if (food.equals("fish lollipop")) {
sealHealthinessLevel += 1;
} else if (food.equals("penguin lollipop")) {
sealHealthinessLevel += 2;
}
System.out.println("new seal healthiness level: " + sealHealthinessLevel);
}
Switch 语句
当需要基于一个变量的多个确定值进行分支选择时,if-else 链会显得冗长。switch 语句提供了更清晰的结构。
switch 语句结构:
switch (变量) {
case 值1:
// 执行语句...
break; // 跳出 switch
case 值2:
// 执行语句...
break;
default: // 相当于 else
// 执行语句...
break;
}
关键点:
break语句用于阻止代码继续执行下一个case(这种现象称为“穿透”)。如果省略break,程序会从匹配的case开始,一直执行到遇见break或switch结束。
让我们用 switch 重写 eat 方法:
public void eatSwitch(String food) {
switch (food) {
case "sugar candy":
sealHealthinessLevel -= 2;
break;
case "sugar free candy":
sealHealthinessLevel -= 1;
break;
case "fish lollipop":
sealHealthinessLevel += 1;
break;
case "penguin lollipop":
sealHealthinessLevel += 2;
break;
default:
// 什么也不做
break;
}
System.out.println("new seal healthiness level: " + sealHealthinessLevel);
}
三元运算符
三元运算符 ? : 是一种简洁的条件赋值表达式。它适用于根据条件为同一个变量赋予不同值的简单场景。
语法:
变量 = (条件) ? 值1 : 值2;
如果条件为真,变量被赋值为值1,否则被赋值为值2。
它可以嵌套来处理多个条件:
变量 = (条件1) ? 值1 : (条件2) ? 值2 : 默认值;
让我们用三元运算符实现 eat 逻辑。注意,这里的思想是计算本次进食带来的健康值变化量:
public void eatTernary(String food) {
int healthChange =
(food.equals("sugar candy")) ? -2 :
(food.equals("sugar free candy")) ? -1 :
(food.equals("fish lollipop")) ? +1 :
(food.equals("penguin lollipop")) ? +2 :
0; // 默认变化为0
sealHealthinessLevel += healthChange;
System.out.println("new seal healthiness level: " + sealHealthinessLevel);
}
总结
本节课中我们一起学习了Java中实现程序决策的核心知识:
- 布尔值 (
true/false) 是决策的基础。 - 逻辑运算符 (
!,&&,||) 用于组合多个布尔条件。 - 比较运算符 (
==,!=,>,<等) 用于比较值。 if-else语句 是最通用的条件执行结构。switch语句 在基于单个变量的多个确定值进行选择时,代码更清晰。- 三元运算符 (
? :) 为简单的条件赋值提供了简洁的写法。

通过创建和扩展 Seal 类的练习,我们实践了如何运用这些概念来让程序根据不同的输入(如运动、睡眠、食物)做出相应的反应和状态改变。掌握这些条件控制结构是编写动态、智能程序的关键一步。
018:循环(第二部分)

在本节课中,我们将深入学习循环结构,特别是 while 循环、do-while 循环、for 循环和 for-each 循环。我们将探讨它们各自的语法、适用场景以及如何避免常见的编程错误,例如无限循环。
循环的必要性
上一节我们介绍了循环的基本概念。本节中我们来看看为什么循环在编程中不可或缺。
许多程序需要重复执行某些步骤。例如,模拟一个人的日常生活(工作、吃饭、睡觉)或者处理一个包含大量学生的数组(例如为每个学生随机分配成绩)。如果手动为数组中的每个元素编写语句,代码会变得冗长且难以维护。循环可以帮助我们避免这种代码重复,用最少的代码高效地完成任务。
以下是循环的主要类型:
while循环do-while循环for循环for-each循环
while 循环
首先,我们来学习最基础的 while 循环。它的核心思想是:只要条件为真,就重复执行循环体中的代码。
一个典型的 while 循环包含以下几个部分:
- 初始化:在循环开始前,通常需要设置一个计数器或索引变量。
- 条件:这是一个布尔表达式。只要其值为
true,循环就会继续。 - 循环体:需要重复执行的代码块。
- 更新:在循环体内,必须修改条件中涉及的变量,以确保循环最终能够结束。
让我们通过一个遍历数组的例子来理解:
String[] alphabet = {"A", "B", "C"};
int i = 0; // 1. 初始化索引
while (i < alphabet.length) { // 2. 条件:索引小于数组长度
System.out.println(alphabet[i]); // 3. 循环体:打印当前元素
i++; // 4. 更新:索引加1
}
核心概念:while 循环的条件在每次迭代开始前检查。如果初始条件为 false,循环体可能一次都不会执行。
重要警告:如果忘记在循环体内更新条件变量(例如忘记 i++),条件将永远为真,导致无限循环。程序会一直运行直到被强制终止。
do-while 循环
do-while 循环是 while 循环的一个变体。它与 while 循环的关键区别在于:它至少会执行一次循环体,然后在每次迭代结束后检查条件。
其语法结构如下:
do {
// 循环体
} while (条件);
这意味着,即使条件第一次就为 false,循环体也已经执行了一次。do-while 循环在需要至少执行一次操作的情况下非常有用,例如请求用户输入。
实践练习:万圣节购物清单
为了巩固对 while 和 do-while 循环的理解,我们来进行一个练习。
创建一个名为 HalloweenParty 的类,它有一个字符串数组属性 shoppingList,包含以下元素:"decoration", "food", "drinks", "costumes"。
你需要实现两个方法:
printShoppingListWhile(): 使用while循环,将所有购物清单元素打印在一行,格式为"We need decoration, food, drinks, costumes"(注意末尾没有多余的逗号)。printShoppingListDoWhile(): 使用do-while循环完成相同的任务。
提示:可以使用一个字符串变量(通常称为“累加器”)来逐步拼接元素和逗号。为了避免最后一个元素后出现逗号,可以使用条件判断(例如三元运算符 ? :)。
以下是 printShoppingListWhile() 方法的一种实现参考:
public void printShoppingListWhile() {
String accumulator = "We need ";
int index = 0;
while (index < shoppingList.length) {
accumulator += shoppingList[index];
// 如果不是最后一个元素,则添加逗号
accumulator += (index < shoppingList.length - 1) ? ", " : "";
index++;
}
System.out.println(accumulator);
}
do-while 版本的实现与之类似,但需注意循环结构和条件检查的位置。
for 循环
当我们明确知道需要循环的次数时,for 循环是更简洁、更常用的选择。它将初始化、条件和更新集中在一行内。
for 循环的标准语法如下:
for (初始化; 条件; 更新) {
// 循环体
}
例如,用 for 循环重写上面的购物清单打印方法:
public void printShoppingListFor() {
String accumulator = "We need ";
for (int i = 0; i < shoppingList.length; i++) {
accumulator += shoppingList[i];
accumulator += (i < shoppingList.length - 1) ? ", " : "";
}
System.out.println(accumulator);
}
核心概念:for 循环的初始化语句只执行一次。然后,每次迭代前检查条件,迭代后执行更新语句。
for-each 循环(增强型 for 循环)

for-each 循环是遍历数组或集合中每一个元素的简化语法。它不需要索引变量,语法更清晰。
其语法结构为:
for (元素类型 变量名 : 数组或集合) {
// 使用‘变量名’访问当前元素
}
使用 for-each 循环打印购物清单:
public void printShoppingListForEach() {
String accumulator = "We need ";
for (String item : shoppingList) {
accumulator += item + ", ";
}
// 移除末尾多余的逗号和空格
if (accumulator.length() > 0) {
accumulator = accumulator.substring(0, accumulator.length() - 2);
}
System.out.println(accumulator);
}
核心概念:for-each 循环自动迭代可迭代对象(如数组)中的每个元素。它隐藏了索引细节,使代码更易读,但无法直接获取当前元素的索引。
更简洁的方法:String.join()
对于“用特定分隔符连接字符串数组所有元素”这个特定任务,Java 提供了更简单的内置方法。
public void printShoppingListFunction() {
String result = "We need " + String.join(", ", shoppingList);
System.out.println(result);
}
String.join(", ", shoppingList) 方法会使用 ", " 作为分隔符,将 shoppingList 数组中的所有字符串连接起来。
总结
本节课中我们一起学习了四种循环结构:
while循环:在循环开始前检查条件,适合循环次数不确定的场景。do-while循环:先执行一次循环体,然后在循环结束后检查条件,适合至少需要执行一次的场景。for循环:将初始化、条件和更新集中管理,适合循环次数确定的场景。for-each循环:简化遍历数组或集合中每个元素的过程,代码更简洁。

理解并熟练运用这些循环是控制程序流程、处理重复任务的基础。请通过课后练习(如“万圣节雕刻南瓜灯”和“万圣节凯撒密码”)来巩固这些知识。
019:核心数据结构 - 列表(第一部分)

在本节课中,我们将要学习编程中的核心数据结构。在介绍了面向对象编程、控制流和循环的基础概念后,今天我们将重点探讨列表、栈和队列这三种重要的数据结构。课程结束时,你将能够解释栈和队列的工作原理,区分列表与数组,并使用Java集合框架中的基本数据类型。
概述:抽象数据类型与信息隐藏
上一节我们回顾了面向对象编程和控制流的基础。本节中,我们来看看抽象数据类型(ADT)及其重要性。
抽象数据类型在计算机科学中至关重要,因为它允许我们定义可在该类型上执行的操作,同时隐藏其内部实现的复杂性。这被称为信息隐藏。通过将内部数据结构和方法实现设为私有(private),我们可以防止非法访问,确保数据结构的特定属性(如栈的“后进先出”原则)不被破坏。
使用抽象数据类型的主要优势包括:
- 解耦与维护:允许独立开发和维护内部实现,只要公共接口不变。
- 快速原型设计:可以仅通过讨论API(应用程序编程接口)来交换想法,无需关心内部实现。
- 性能与互操作性:标准库中的实现通常经过高度优化,并且不同集合类型(如列表与集合)之间可以轻松转换。
Java提供了一个统一的集合框架来代表和操作一组元素。它减少了编程工作量,因为你只需要知道如何使用它们,而无需了解所有内部细节。今天我们将重点介绍其中最常用的数据类型之一:列表。
列表(List)数据类型
列表是编程中极其常见的数据类型,用于表示一组有序的元素序列。Java在java.util包中内置了List接口。
List是一个泛型接口。这意味着在创建列表时,你需要指定它将要包含的元素类型(例如String, Integer或自定义类)。这可以防止意外添加错误类型的元素。
List代表一个有序序列,每个元素都有一个索引(从0开始计数),并且元素可以重复。
以下是List接口定义的一些核心方法:
boolean add(E element) // 向列表添加一个元素
void clear() // 移除列表中的所有元素
boolean contains(Object o) // 检查列表是否包含特定元素
E get(int index) // 获取指定索引位置的元素
boolean isEmpty() // 检查列表是否为空
E remove(int index) // 移除指定索引位置的元素
boolean remove(Object o) // 移除列表中首次出现的指定元素
E set(int index, E element) // 将指定索引位置的元素替换为新元素
int size() // 返回列表中的元素数量
(其中 E 代表创建列表时指定的元素类型)
列表使用示例
以下是如何在代码中使用List的示例:
import java.util.*;
public class ListExample {
public static void main(String[] args) {
// 创建一个存储字符串的列表
List<String> words = new ArrayList<>();
// 向列表添加元素
words.add("Hello");
words.add("sentence");
words.add(".");
words.add("Hello"); // 元素可以重复
// 获取索引为2的元素(第三个元素)
String thirdWord = words.get(2); // 结果是 "."
// 检查列表是否包含 "sentence"
boolean hasSentence = words.contains("sentence"); // true
// 查找 "Hello" 第一次出现的索引
int indexOfHello = words.indexOf("Hello"); // 0
}
}
增强型 for 循环(For-Each Loop)
当需要遍历列表(或其它集合)中的所有元素时,可以使用更简洁的增强型for循环(也称为for-each循环)。它无需处理索引,降低了出错风险。
// 使用增强型for循环遍历列表
for (String word : words) {
System.out.print(word + " ");
}
// 输出:Hello sentence . Hello
这个循环可以理解为:“对于列表words中的每一个String类型元素word,执行打印操作。”
列表与数组的对比
列表和数组有相似之处,但也有关键区别:
- 数组是基础、固定长度的数据结构,不是纯粹面向对象的。创建时必须指定大小。
- 列表是高级、动态长度的抽象数据类型。你无需预先指定大小,可以随时添加或删除元素。
- 性能与易用性:数组可能在某些场景下性能稍高,但列表更灵活、更不易出错(例如,通过
get(index)访问元素时,会进行边界检查)。ArrayList的内部实现就是基于数组,但这被信息隐藏了。
建议:在大多数情况下,优先使用List,除非有明确的性能要求或必须使用数组的场景。
集合(Set)数据类型
在介绍了有序且允许重复的列表后,我们来看看另一种重要的集合类型:Set。
Set代表一个无序的集合,并且其中的每个元素都必须是唯一的。你不能对元素的顺序做任何假设,也不能通过索引访问元素。
以下是Set接口的核心方法(注意,所有与索引相关的方法都不存在):
boolean add(E e) // 向集合添加元素。如果元素已存在,则添加失败,返回false。
void clear()
boolean contains(Object o)
boolean isEmpty()
boolean remove(Object o)
int size()
集合使用示例
import java.util.*;
public class SetExample {
public static void main(String[] args) {
// 创建一个存储字符串的集合
Set<String> words = new HashSet<>();
words.add("Hello");
words.add("sentence");
words.add(".");
words.add("Hello"); // 这个添加操作会失败,因为"Hello"已存在
words.add("word");
words.add("word"); // 这个添加操作也会失败
System.out.println(words.size()); // 输出:4 (不是6)
// 遍历集合(顺序不确定)
for (String word : words) {
System.out.print(word + " ");
}
// 可能的输出:sentence Hello word . (顺序无法保证)
}
}
使用建议:当你需要确保元素的唯一性,并且不关心它们的顺序时,使用Set。否则,通常使用List会更方便。
链表(Linked List)简介
除了基于数组实现的ArrayList,还有一种重要的列表实现叫链表。
链表由一系列节点组成,每个节点包含数据和指向下一个节点的引用(指针)。单向链表的结构如下图所示:
[Head] -> [Data | Next] -> [Data | Next] -> [Data | Next] -> null
链表的主要特点是:
- 动态大小:可以轻松地添加或删除节点。
- 插入/删除效率:在已知位置插入或删除元素时,可能比数组列表更高效。
- 访问效率:不能通过索引直接访问元素(如
get(3))。要找到第3个元素,必须从头节点开始,逐个遍历。
对于初学者,了解链表的存在及其基本思想即可。在大多数日常编程中,直接使用List接口(并由ArrayList实现)就足够了。
总结
本节课中,我们一起学习了核心数据结构的第一部分,重点是列表。
- 我们理解了抽象数据类型和信息隐藏的概念及其优势。
- 我们深入探讨了
List接口:它是一个有序、可重复的泛型集合,并学习了其基本用法(add,get,remove等)。 - 我们介绍了简洁的增强型for循环来遍历集合。
- 我们对比了列表与数组的异同,并给出了使用建议。
- 我们简要了解了
Set接口,它是一个无序且元素唯一的集合。 - 最后,我们提到了链表作为另一种列表实现的基本概念。

掌握这些基础数据结构是进行更复杂编程的基石。在接下来的课程中,我们将继续探讨栈(Stack)和队列(Queue)这两种遵循特定访问顺序的数据结构。
020:列表(第二部分)

在本节课中,我们将继续深入学习列表数据类型,探讨更多示例和细节。我们将了解泛型、包装类,以及如何将自定义类与集合一起使用。
泛型与包装类
上一节我们介绍了列表的基本概念。本节中,我们来看看列表如何与不同类型的数据协同工作。
列表接口代表一个有序的对象序列。然而,列表不能直接使用原始数据类型(如 int, float)。为了解决这个问题,Java 使用了泛型和包装类。
理解泛型
泛型是一种类基数据类型的占位符。它不能用于原始类型(如 int, float, boolean)。泛型让我们能够编写类型安全的代码,确保集合中只包含我们预期的类型,从而在编译时防止许多错误。
核心概念:泛型允许我们只实现一次列表,就能在未来与任何数据类型一起使用。
包装类
对于每个原始数据类型,Java 都有一个对应的包装类。例如:
int对应Integerfloat对应Floatboolean对应Boolean
以下是包装类的使用方式:
// 自动装箱:将 int 包装为 Integer
Integer wrappedInt = 42;
// 自动拆箱:将 Integer 解包为 int
int primitiveInt = wrappedInt;
包装类在需要使用集合(如 List, Set)时是必需的,因为集合只能包含类基类型。然而,它们占用更多内存,并且可能为 null,这需要额外的处理来避免 NullPointerException。
Object 类
所有类的隐式超类都叫 Object。它提供了一些通用方法,如 toString() 和 equals()。即使你不显式声明,你创建的每个类都继承自 Object。
使用列表的注意事项
在使用集合时,有几个重要的点需要注意。
以下是使用集合时需要考虑的一些关键事项:
- 可能包含
null值:列表可以包含null。在检索元素时,可能需要检查是否为null。 - 并发修改异常:不能在迭代集合的同时直接修改它(例如删除元素)。常见的解决方法是使用一个临时列表,或者后续将学到的流(Stream)API。
- 操作效率:某些操作可能效率较低。例如,在列表开头添加元素需要移动其后所有元素,如果列表很大,这会很耗时。
列表使用示例
现在,让我们通过几个具体例子来看看列表的实际应用。
示例 1:整数列表与求和
这个例子展示了如何创建整数列表并计算其元素之和。
List<Integer> numbers = new ArrayList<>();
numbers.add(0);
numbers.add(1);
numbers.add(1);
numbers.add(2);
numbers.add(3);
numbers.add(5);
numbers.add(8);
numbers.add(13);
numbers.add(21); // 斐波那契数列
int sum = 0;
for (int number : numbers) { // 自动拆箱
sum = sum + number;
}
System.out.println("总和: " + sum);
注意:如果列表中含有 null,在自动拆箱时会抛出 NullPointerException。
示例 2:浮点数列表与求平均值
这个例子演示了如何处理浮点数列表并计算平均值。
List<Float> temperatures = new ArrayList<>();
temperatures.add(18.5f);
temperatures.add(20.1f);
temperatures.add(22.3f);
temperatures.add(19.8f);
float sum = 0.0f;
for (float temp : temperatures) {
sum += temp;
}
float average = sum / temperatures.size();
System.out.println("平均温度: " + average);
示例 3:自定义类与列表
我们也可以将自己定义的类用于列表。假设我们有一个表示二维点的 Point 类。
class Point {
private final double x;
private final double y;
// 构造函数、getter 等方法...
}
// 使用 Point 列表
List<Point> polygon = new ArrayList<>();
polygon.add(new Point(0, 0));
polygon.add(new Point(4, 0));
polygon.add(new Point(4, 3));
// 计算多边形的中心点(所有点的平均值)
double sumX = 0, sumY = 0;
for (Point p : polygon) {
sumX += p.getX();
sumY += p.getY();
}
Point center = new Point(sumX / polygon.size(), sumY / polygon.size());
练习:创建成绩簿
现在,请你在自己的编程环境中完成以下练习,以巩固所学知识。
任务:创建一个简单的成绩簿来计算平均分。
- 创建一个
Grade类,包含两个属性:subject(科目,String类型)和score(分数,double类型)。 - 使用
ArrayList初始化一个Grade列表,并添加几个示例成绩。 - 使用 for 循环计算所有成绩的平均分,并将结果打印到控制台。
- 可选挑战:为
Grade类添加一个credits(学分)属性,并计算加权平均分(考虑学分的权重)。
示例代码结构(基础部分):
// Grade 类
class Grade {
private final String subject;
private final double score;
// 构造函数和 getter 方法
}
// 主逻辑
List<Grade> grades = new ArrayList<>();
grades.add(new Grade("市场营销", 1.3));
grades.add(new Grade("经济学", 2.0));
// ... 添加更多成绩
double sum = 0.0;
for (Grade g : grades) {
sum += g.getScore();
}
double average = sum / grades.size();
System.out.println("平均分: " + average);
示例解决方案(含可选挑战):
对于可选挑战,需要修改 Grade 类,添加 credits 属性,并在计算平均分时使用加权和。
class Grade {
private final String subject;
private final double score;
private final int credits; // 新增学分属性
// 更新构造函数和 getter
}
// 计算加权平均分
double weightedSum = 0.0;
int totalCredits = 0;
for (Grade g : grades) {
weightedSum += g.getScore() * g.getCredits();
totalCredits += g.getCredits();
}
double weightedAverage = weightedSum / totalCredits;
总结

本节课中我们一起学习了列表数据类型的进阶知识。我们探讨了泛型的概念及其作用,认识了包装类(如 Integer, Float)以及它们为何在与集合一起使用时必不可少。我们还了解了使用集合时的一些注意事项,例如处理 null 值、避免并发修改以及注意某些操作的效率。最后,我们通过多个示例和练习,实践了如何将列表用于整数、浮点数以及自定义类,并成功创建了一个可以计算(加权)平均分的成绩簿程序。
021:栈 📚

在本节课中,我们将要学习一种新的数据结构——栈。栈遵循“后进先出”的原则,这意味着最后添加到栈中的元素,也将是第一个被取出的元素。我们将探讨栈的基本概念、操作,并通过两种不同的方式(基于列表和基于数组)来实现它。
栈的基本概念与操作 🧱
上一节我们介绍了课程概述,本节中我们来看看栈的具体定义和核心操作。
栈遵循 LIFO(Last In, First Out,后进先出)原则。你可以把它想象成一摞盘子:你总是把新盘子放在最上面,也只能从最上面拿走盘子。
栈通常支持以下四种核心操作:
push(element): 将一个元素压入栈顶。pop(): 弹出并返回栈顶的元素。如果栈为空,则返回null。isEmpty(): 检查栈是否为空,返回布尔值。toString(): 返回栈的字符串表示,便于调试。
栈的概念由慕尼黑工业大学的著名计算机科学家 弗里德里希·路德维希·鲍尔 提出并发展。他是历史上最早的计算机科学家之一,我们信息学大楼的主报告厅就是以他的名字命名的。
栈的UML类图与可视化 📊
了解了基本操作后,我们来看看如何用UML类图来建模一个栈。
一个栈的UML类图可能如下所示:
+-------------------+
| Stack<E> |
+-------------------+
| - list: List<E> |
+-------------------+
| + push(e: E): void|
| + pop(): E |
| + isEmpty(): boolean|
| + toString(): String|
+-------------------+
类图中包含栈的四个公共方法,以及一个私有的内部列表(用组合关系表示,即黑色菱形箭头)。组合关系意味着列表的生命周期与栈绑定:栈被创建时列表随之创建,栈被销毁时列表也随之销毁。
让我们可视化栈的工作过程。从一个空栈开始:
栈: [ ]
执行 push(1) 后:
栈: [1]
执行 push(2) 后:
栈: [1, 2]
执行 push(3) 后:
栈: [1, 2, 3]
现在,如果执行 pop() 操作,将返回 3,栈变为 [1, 2]。再次执行 pop() 将返回 2,栈变为 [1]。这就像搭积木,你总是拿走最上面的那块。
基于列表的栈实现(泛型)💻
上一节我们看了栈的抽象模型,本节中我们来看看如何用Java代码具体实现它。第一种方法是利用现有的 List 来实现一个泛型栈。
以下是使用 List 实现泛型栈的核心代码:
import java.util.ArrayList;
import java.util.List;
public class Stack<E> {
private final List<E> list = new ArrayList<>();
public void push(E element) {
// 假设我们不允许压入null值
list.add(element);
}
public E pop() {
if (list.isEmpty()) {
return null;
}
// 移除并返回最后一个元素
return list.remove(list.size() - 1);
}
public boolean isEmpty() {
return list.isEmpty();
}
@Override
public String toString() {
return list.toString();
}
}
关键点:
- 信息隐藏:内部列表
list是private的,栈的使用者无法直接调用list.add()或list.remove(),只能通过push、pop等规定的方法操作栈,这保证了数据的一致性和安全性。 - 泛型:使用
<E>使得栈可以存储任何类型的对象。 - 委托:
isEmpty()和toString()方法直接委托给内部列表执行。
栈的使用示例 🚀
我们已经实现了栈,现在来看看如何在实际代码中使用它。
以下是使用我们刚实现的泛型栈的示例:
public class Main {
public static void main(String[] args) {
Stack<Integer> stack = new Stack<>();
System.out.println("Pushing elements...");
stack.push(10);
stack.push(20);
stack.push(30);
System.out.println("Current stack: " + stack); // 输出: [10, 20, 30]
System.out.println("Popping an element...");
Integer popped = stack.pop();
System.out.println("Popped: " + popped); // 输出: 30
System.out.println("Current stack: " + stack); // 输出: [10, 20]
}
}
当打印 stack 对象时,会自动调用其 toString() 方法。
基于数组的栈实现 🔧
虽然基于列表的实现简单高效,但为了深入理解栈的底层原理,我们来看看如何直接用数组实现。这需要手动处理数组的扩容问题。
以下是基于数组的栈实现的核心逻辑(简化版,非泛型):
public class Stack {
private int top = -1; // 指向栈顶元素的索引,-1表示空栈
private Object[] array = new Object[4]; // 初始数组大小
public void push(Object element) {
top++;
if (top == array.length) { // 数组已满,需要扩容
Object[] newArray = new Object[array.length * 2]; // 双倍扩容
for (int i = 0; i < array.length; i++) {
newArray[i] = array[i]; // 复制旧元素
}
array = newArray; // 替换旧数组
}
array[top] = element; // 放入新元素
}
public Object pop() {
if (top < 0) {
return null; // 或抛出异常
}
Object element = array[top];
top--; // 降低栈顶指针,逻辑上“移除”元素
return element;
}
// ... isEmpty() 和 toString() 方法
}
关键点与挑战:
- 固定大小:数组大小固定,当栈满时需要创建更大的新数组并复制所有元素,这是一个开销较大的操作。
- 惰性删除:
pop()操作只是将top索引减一,并未真正清空数组中原位置的数据。toString()方法需要根据top索引来构建有效的字符串。 - 性能优化:简单的“满则双倍扩容”策略在长期操作中平均效率较高。为了避免在容量边界频繁扩容和缩容,一种更优的策略是:当数组已满时双倍扩容;仅当元素数量降至数组容量的 1/4 时,才将数组缩容一半。这避免了在容量半满附近反复调整大小。
栈的应用练习:十进制转二进制 🔢
理论结合实践,让我们通过一个练习来巩固对栈的理解。这个练习将使用栈来帮助进行十进制数到二进制数的转换。
任务:将一个正整数转换为其二进制表示形式。
思路:
- 将十进制数不断除以 2,记录每次的余数(0 或 1)。这正是二进制位的计算过程,但得到的顺序是反的(从低位到高位)。
- 将每个余数
push到栈中。 - 依次从栈中
pop出所有余数并打印,由于栈的 LIFO 特性,自然就得到了正确的从高位到低位的二进制序列。
以下是解决方案的核心代码:
import java.util.Scanner;
import java.util.Stack; // 这里可以使用java.util.Stack或我们自己实现的Stack


public class BinaryConversion {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.print("Enter a positive integer: ");
int number = scanner.nextInt();
Stack<Integer> stack = new Stack<>();
// 计算余数并压栈
while (number > 0) {
int remainder = number % 2;
stack.push(remainder);
number = number / 2; // 整数除法
}
// 弹出并打印,得到正确顺序的二进制数
System.out.print("Binary representation: ");
while (!stack.isEmpty()) {
System.out.print(stack.pop());
}
System.out.println();
}
}
例如,输入 9,计算过程是:余数序列为 1, 0, 0, 1(依次入栈)。出栈打印顺序为 1, 0, 0, 1,即二进制 1001。
总结 📝
本节课中我们一起学习了栈这种重要的数据结构。
- 我们理解了栈的 LIFO 特性和它的基本操作(
push,pop,isEmpty,toString)。 - 我们掌握了两种实现栈的方法:基于
List的泛型实现(简单、实用)和基于数组的实现(有助于理解底层机制和动态扩容策略)。 - 我们深入了解了 信息隐藏 原则,即只向使用者暴露必要的接口。
- 我们通过十进制转二进制的练习,看到了栈在反转序列顺序方面的典型应用。

栈是计算机科学中基础且应用广泛的数据结构,从函数调用、表达式求值到浏览器历史记录,背后都有它的身影。理解栈的原理和实现,是编程学习中的重要一步。
022:队列 🚶♂️🚶♀️
在本节课中,我们将要学习一种新的数据结构——队列。我们将了解它的工作原理、它与栈的区别,并学习如何使用列表和数组两种方式来实现它。
概述
上一节我们介绍了栈,它遵循“后进先出”的原则。本节中我们来看看队列,它遵循的是“先进先出”的原则。队列就像超市里的收银台队伍,第一个到达收银台的人第一个结账,排在第六位的人必须等待前面五个人都结账后才能轮到。
队列的基本概念
队列与栈的工作方式相似,但提供了不同的操作。栈使用 push 和 pop,而队列使用 enqueue 和 dequeue。此外,队列也包含 isEmpty 和 toString 方法。
我们可以这样定义队列的接口:
interface Queue<E> {
void enqueue(E element); // 入队
E dequeue(); // 出队
boolean isEmpty(); // 判断是否为空
String toString(); // 转换为字符串
}
让我们可视化队列的工作过程。当我们执行 enqueue 操作时,元素会进入队列尾部。当我们执行 dequeue 操作时,会从队列头部取出元素。例如,依次入队 1, 2, 3, 4,然后执行出队操作,将依次返回 1, 2, 3, 4。
使用列表实现队列
我们可以像实现栈一样,使用列表来实现队列。主要区别在于,出队时我们移除列表的第一个元素,而不是最后一个元素。
以下是使用列表实现泛型队列的核心代码:
public class ListQueue<E> {
private final List<E> list = new ArrayList<>();
public void enqueue(E element) {
list.add(element); // 添加到列表末尾
}
public E dequeue() {
if (isEmpty()) {
return null;
}
return list.remove(0); // 移除并返回列表的第一个元素
}
public boolean isEmpty() {
return list.isEmpty();
}
}
这个实现非常简单。我们只是将栈实现中的 list.remove(list.size() - 1) 改为了 list.remove(0),就得到了一个完全不同的数据结构。需要注意的是,从列表头部移除元素的效率不如从尾部移除,但为了简单起见,我们暂时接受这一点。
使用数组实现队列
接下来,让我们看看如何使用数组实现队列。与栈只用一个 top 索引不同,队列需要两个索引:first 和 last,分别指向队列的第一个和最后一个元素。
数组队列的工作原理
初始时,数组为空,first 和 last 都设为 -1。入队时,我们将元素放在 last 指向的位置,然后递增 last。出队时,我们返回 first 指向的元素,然后递增 first。
当 last 到达数组末尾时,我们面临一个选择:是扩展数组,还是复用数组前部的空间?为了效率,我们选择后者,形成一个“循环数组”。这意味着当索引到达数组末尾时,它会绕回到数组开头。我们通过取模运算 (index + 1) % array.length 来实现这一点。
数组队列的实现细节
以下是数组队列 enqueue 方法的核心逻辑:
public void enqueue(E element) {
int length = array.length;
int newLast = (last + 1) % length; // 计算新的last位置,考虑循环
if (newLast == first) { // 队列已满,需要扩容
// 1. 创建新数组,大小为原数组两倍
// 2. 将元素从first到last按顺序复制到新数组开头
// 3. 重置first=0, last=原数组长度
// 4. 将新元素添加到last位置
} else {
if (isEmpty()) { // 队列为空时的特殊处理
first = last = 0;
} else {
last = newLast;
}
array[last] = element;
}
}
dequeue 方法的实现则相对简单:
public E dequeue() {
if (isEmpty()) {
return null;
}
E result = array[first];
if (first == last) { // 出队后队列将为空
first = last = -1;
} else {
first = (first + 1) % array.length; // first向前移动,考虑循环
}
return result;
}
改进的 toString 方法



由于数组中的元素可能不是从索引0开始连续存放的,直接使用 Arrays.toString() 会打印出不属于当前队列的旧元素。因此,我们需要一个自定义的 toString 方法,使用 StringBuilder 从 first 到 last 遍历循环数组,只拼接有效的队列元素。
public String toString() {
if (isEmpty()) {
return “[]”;
}
StringBuilder builder = new StringBuilder(“[”);
int i = first;
while (i != last) {
builder.append(array[i]).append(“, “);
i = (i + 1) % array.length;
}
builder.append(array[last]).append(“]”); // 添加最后一个元素,避免多余逗号
return builder.toString();
}
练习与调试
为了加深理解,建议你将课程提供的完整数组队列代码复制到编程环境中。实例化一个队列,执行一系列入队和出队操作,并使用调试器逐步执行代码。观察 first、last 索引和数组内容的变化,这能帮助你直观地理解循环数组的工作原理。
总结
本节课中我们一起学习了队列数据结构。我们了解到:
- 队列遵循 先进先出 原则。
- 可以使用列表简单地实现队列,但出队操作可能效率不高。
- 更高效的实现方式是使用循环数组,它通过两个索引(
first和last)和取模运算来管理元素,并能在数组满时进行扩容和重排。 - 内置的数据结构(如
List,Queue)有助于快速开发,但理解其背后的实现原理对评估性能至关重要。

记住,信息隐藏让我们无需关心实现细节即可使用数据结构,但窥探其内部实现能让我们更好地理解其性能特征。
023:继承(第一部分) 🧬

在本节课中,我们将学习面向对象编程中的一个核心概念——继承。我们将探讨如何通过继承来重用代码、组织类之间的关系,并理解多态、抽象类和接口的基本思想。课程将从现实世界的类比开始,逐步深入到代码实现。
概述 🌐
在之前的课程中,我们学习了面向对象编程的基础、控制流和基本数据结构。今天,我们将进入继承的世界,并初步了解抽象类、接口和多态。我们假设你已经掌握了前几周的基础知识。
本节课的学习目标是:
- 能够解释重写与重载的区别。
- 能够实现不仅使用普通类,还使用抽象类和接口的继承层次结构。
- 能够解释多态的概念及其在Java中的应用。
- 能够区分对象的编译时类型和运行时类型。
现实世界中的继承 🐒
在深入技术概念之前,让我们先观察现实世界。现实中的对象通常彼此相关,它们相似但又不同。例如,人类、猴子、人科动物、狼和哺乳动物。
猴子和人类有某些相似的属性和行为,但在许多方面也存在差异。为了在软件中重用代码,我们希望表达这些相似性和差异性,这就是继承的作用。
继承的概念源于现实世界。它允许我们找到共性,并将它们组织成一个层次结构。其核心思想是:将所有对象的共同点收集起来,存储在一个所谓的超类中。然后,我们创建子类,这些子类会继承超类的所有属性和行为。
因此,我们可以说每只狼都是哺乳动物,每只猴子也是哺乳动物,但每只猴子还是人科动物,每个人也是人科动物。通过这种方式,我们表达了这些关系。
在技术实现上,这意味着我们可以将所有公共属性和行为存储在超类中,而将差异存储在子类中。这是一种单向关系:并非每个哺乳动物都是人科动物,也并非每个人科动物都是猴子。理解这种从顶向下特化和从底向上泛化的关系至关重要,它能让我们更高效地重用代码并实现增量式编程。
编程中的继承示例 📚
让我们看一个更贴近编程的例子。我们有一个Book类,它包含页数和文本,并且可以阅读。
现在,有一些特殊类型的书,例如Dictionary。字典也是一种书(它也有页数和文本,也可以阅读),但它额外提供了更多功能:它包含定义,并且你可以查询定义。
因此,子类继承了超类的所有属性,但可以额外添加新的属性,方法也是如此。子类中的方法除非重写了超类的行为,否则我们不会在子类中再次显示它们。
代码实现 💻
在UML图中,我们用空心三角形表示继承关系。在代码中如何实现呢?
我们有一个Book类,可以将其属性转化为代码,创建构造函数并实现方法。这里的#符号代表protected,这是一种控制访问权限的方式。
然后是我们的子类Dictionary。关键语法是Dictionary extends Book。extends关键字在Java中实现了继承。一个子类通过extends一个超类,就可以访问超类中所有非private的成员。它继承了超类的所有元素,并且可以添加额外的元素。
在构造函数中,我们使用super关键字来调用超类的构造函数,以便重用代码,而不是在子类中重复编写。
重要提示:虽然这里展示的是一个超类和一个子类,但我们可以有多个子类,甚至可以构建完整的层次结构。一个类只能有一个直接超类,但可以有多个间接超类。子类可以访问所有非private的传递性超类成员。
继承的关键点 🔑
以下是关于继承的一些核心要点:
- 关系:
class SubClass extends SuperClass。 - 成员可见性:所有成员自动可用。
protected成员在子类中可见;private成员虽然存在于内存中,但不能直接调用,只能通过超类的方法间接访问。 - 构造函数:如果调用子类的构造函数,那么超类的构造函数会被隐式调用(如果可用),否则我们必须像前面的例子那样调用特定的构造函数。
- 内存中的对象:当我们创建一个对象(例如
Dictionary)时,内存中只有一个对象实例,而不是多个。继承关系在运行时并不以多个对象的形式存在,但类型信息仍然可用。 super关键字:用于在子类中显式调用超类的构造函数或方法。类似于this,但总是指向超类。它不能用于赋值或修改,只能用于访问。
“是一个”关系与里氏替换原则 ⚖️
可以将继承视为一种 “是一个” 关系。每个子类在某种意义上也是其超类的一种类型。
这一点非常重要,因为编程语言编译器不会强制你定义有意义的继承关系。例如,你可以为了让Chair重用Table的color属性,而让Chair继承Table,但这非常糟糕,因为椅子不是桌子。
因此,你有责任以有意义的方式定义继承关系。为了指导我们,有一个由著名计算机科学家芭芭拉·利斯科夫提出的原则——里氏替换原则。
其简化版本是:超类对象应该能够被子类对象替换,而不会破坏程序的功能。务必遵循此原则。子类不应以与超类不同的目的重新定义方法。
可见性修饰符与信息隐藏 🛡️
我们已经提到了private、protected和public这三个关键字。实际上有四种情况(包括默认可见性)。它们定义了类、属性、构造函数或方法对其他代码的可访问性。
- 类自身:可以访问一切。
- 同一包中的其他类:可以访问除
private外的一切。 - 子类:可以访问
public和protected成员。除非子类在同一包中,否则不能访问默认可见性成员。 - 其他任何地方:只能访问
public成员。
通过这些关键字,我们可以实现信息隐藏原则,即向外部隐藏不必要的实现细节,只暴露公共API。这使得大型项目可以拆分给多人协作,每个人只需了解必要的交互信息。
关于包:包就像是项目中的文件夹结构。同一包中的类关系更紧密。将项目拆分到不同包中有助于组织和管理代码。
形状示例:重写方法 🔵
让我们看另一个例子。我们有一个Shape类,具有protected String color属性和一个计算面积的area()方法。由于不知道具体形状,这里暂时返回0。
然后我们有一个子类Circle,它继承了color并添加了final double radius属性。它重写了area()方法,提供了正确的实现:π * radius²。
注意@Override注解。它告诉编译器你正在显式重写一个方法。这是可选的,但有助于其他开发者理解代码,并且能在超类方法发生变化时提供警告。
方法调用机制:调用方法时,会从对象的实际类型(子类)开始查找。如果子类有该方法,则调用它;否则,向上到超类中查找,依此类推。
关于final:final表示只能赋值一次,之后不能更改。这可以避免后续代码中的某些问题,是一种良好的实践。
练习:矩形与正方形 📐
现在,让我们通过一个练习来巩固理解。基于Shape和Circle的例子,请创建一个新的子类Rectangle。
Rectangle应继承Shape。- 它应具有
width和height属性。 - 它应重写
area()方法,返回width * height。
可选挑战:创建另一个子类Square。思考Square是应该直接继承Shape,还是继承Rectangle?请将里氏替换原则考虑在内。
练习解答 ✅
Rectangle类的实现很简单:继承Shape,定义width和height属性,在构造函数中初始化,并重写area()方法。
对于Square,一个优雅的解决方案是让它继承Rectangle。因为每个正方形都是一个矩形(长宽相等)。这样,我们可以最大化代码重用。
Square的构造函数只接收一个length参数,然后在调用super构造函数时,将length同时作为width和height传递。这样,Square就自动拥有了正确的area()计算方法,无需重写。
这个例子很好地展示了如何通过合理的继承设计来避免代码重复,并遵循里氏替换原则。
在UML图中,重写了超类方法的子类(如Circle、Rectangle)会再次列出该方法;而只是继承未重写的子类(如Square)则不会列出。
总结 📝
在本节课中,我们一起学习了:
- 继承的基本概念及其在代码重用和组织类层次结构中的作用。
- 如何使用
extends关键字实现继承,以及super关键字的使用。 - 理解“是一个”关系和至关重要的里氏替换原则。
- 成员可见性修饰符(
private,protected,public, 默认)及其如何实现信息隐藏。 - 如何在子类中重写方法,并使用
@Override注解。 - 通过
Shape、Circle、Rectangle和Square的示例,实践了继承的设计与实现。

继承是面向对象编程的基石之一。在接下来的部分,我们将继续探讨更高级的主题,如抽象类、接口和多态,它们都建立在坚实的继承理解之上。
024:继承(第二部分)

在本节课中,我们将继续深入学习继承的概念,并通过更复杂的例子来理解其在实际编程中的应用。我们将探讨作用域、绑定、方法重写等关键主题,并构建一个模拟银行账户系统的程序来实践这些概念。
继承示例:食物与披萨
上一节我们介绍了继承的基本概念,本节中我们来看看一个具体的例子。这个程序名为“eating”,目标是计算每餐食物的卡路里。
我们有一个超类 Food 和一个子类 Pizza。在 Food 类中,我们定义了三个整型变量:一个常量 caloriesPerGram(值为9),以及变量 fat 和 servings。这些变量在构造函数中初始化。
public class Food {
private final int caloriesPerGram = 9;
private int fat;
private int servings;
public Food(int fat, int servings) {
this.fat = fat;
this.servings = servings;
}
private int calories() {
return fat * caloriesPerGram;
}
public int caloriesPerServing() {
return calories() / servings;
}
}
calories 方法是私有的,用于计算总卡路里。caloriesPerServing 方法是公共的,它调用私有方法并除以份数来得到每份的卡路里。
Pizza 类继承自 Food。它没有默认构造函数,因此必须提供一个构造函数。我们假设一个披萨总是被切成8份,所以 servings 固定为8,而脂肪含量则根据披萨类型传入。
public class Pizza extends Food {
public Pizza(int fat) {
super(fat, 8); // 披萨总是8份
}
}
现在,我们可以创建程序并使用它。
public class Eating {
public static void main(String[] args) {
Pizza specialPizza = new Pizza(275);
System.out.println(specialPizza.caloriesPerServing());
}
}
当我们调用 specialPizza.caloriesPerServing() 时,由于该方法是公共的,子类可以继承并使用它。这个方法内部会调用超类的私有 calories 方法。虽然子类不能直接访问私有成员,但通过公共方法可以间接地进行计算。程序将输出每份的卡路里值(例如309)。
这个例子展示了信息隐藏:只有特定的公共元素对子类和程序的其他部分可见,但通过调用方法,我们可以间接访问和计算私有数据。
绑定与作用域
在深入讨论继承如何影响作用域和绑定之前,我们需要理解这两个基本概念。
绑定 指的是将一个表达式(如变量名)关联到内存中的一个特定变量或属性。
作用域 指的是程序中该绑定有效的区域。在Java等语言中,作用域通常由花括号 {} 界定。
以下是作用域的几个例子:
- 局部变量:从变量声明处开始,到其所在的代码块(由
{}界定)结束为止。 - 方法参数:仅在方法体内有效。
- 静态变量:具有全局作用域,在整个应用程序中可访问。
考虑我们之前实现的 Queue 类:
public class Queue {
private int first;
private int last;
private int[] array;
public void enqueue(int x) {
// 参数 x 的作用域仅限于此方法内
array[last] = x;
last++;
}
public void someMethod() {
for (int i = 0; i < 10; i++) { // 变量 i 的作用域仅限于此 for 循环内
// 使用 i
}
// 此处无法访问 i
}
}
类属性 first、last、array 的作用域是整个类。方法参数 x 的作用域仅限于 enqueue 方法内。for 循环中声明的计数器 i 的作用域仅限于该循环内部。
理解作用域和绑定对于掌握继承中的一些特性至关重要。
继承中的阴影
在继承中,可能会遇到“阴影”的情况。这意味着在某个作用域内已经存在一个绑定,但你又用相同的名字声明了一个新的绑定。
属性阴影
当超类和子类定义了同名的属性时,就会发生属性阴影。子类中的属性会“遮蔽”超类中的同名属性。
class Person {
public int age;
}
class Child extends Person {
public int age; // 阴影了超类的 age 属性
public void setPersonAge(int a) {
super.age = a; // 使用 super 关键字访问超类的 age
}
}
虽然Java允许这样做,但这被认为是糟糕的实践,因为它极易导致混淆。你可以使用 super 关键字来明确指定要访问超类的属性。但更好的做法是避免使用相同的属性名。
方法阴影(重写)
当子类重新定义了超类中已有的方法(具有相同名称和参数列表)时,就发生了方法阴影,这通常被称为方法重写。
class Person {
public int getAge() {
return age;
}
}
class Child extends Person {
@Override // 使用注解明确表示这是重写
public int getAge() {
return this.age; // 这里返回的是子类的 age 属性
}
}
方法重写是面向对象编程中常见且有用的技术。在运行时,Java虚拟机会首先查看对象的实际类型(子类),如果存在重写的方法,则调用它;否则,才会调用超类中的方法。
重要区别:属性阴影应避免,而方法重写(在遵循里氏替换原则的前提下)是合理且常用的。
综合示例:银行账户系统
为了综合练习继承,我们来构建一个模拟银行账户系统的程序。我们将创建几种不同类型的账户。
基类:BankAccount
首先,定义所有账户的基类 BankAccount。每个账户有一个ID和一个余额。
public class BankAccount {
private final int accountId;
private double balance;
public BankAccount(int accountId, double initialBalance) {
this.accountId = accountId;
this.balance = initialBalance;
}
public void deposit(double amount) {
balance += amount;
System.out.println("Account " + accountId + ": Deposited " + amount + ". New balance: " + balance);
}
public boolean withdraw(double amount) {
if (amount > balance) {
System.out.println("Account " + accountId + ": Insufficient funds.");
return false;
}
balance -= amount;
System.out.println("Account " + accountId + ": Withdrew " + amount + ". New balance: " + balance);
return true;
}
}
deposit 方法用于存款,withdraw 方法用于取款。取款时会检查余额是否充足。
子类:SavingsAccount
SavingsAccount 继承自 BankAccount,并增加了利率的概念。
public class SavingsAccount extends BankAccount {
private final double interestRate;
public SavingsAccount(int accountId, double initialBalance, double interestRate) {
super(accountId, initialBalance);
this.interestRate = interestRate;
}
public void addInterest() {
double interest = getBalance() * interestRate; // 假设有 getBalance() 方法
deposit(interest); // 利用继承的 deposit 方法
System.out.println("Account " + getAccountId() + ": Interest added. New balance: " + getBalance());
}
}
SavingsAccount 继承了存款和取款功能,并新增了 addInterest 方法来计算和添加利息。
子类:CheckingAccount
CheckingAccount(支票账户)更复杂一些。它可以关联一个 SavingsAccount 作为透支保护。
public class CheckingAccount extends BankAccount {
private final SavingsAccount overdraft;
public CheckingAccount(int accountId, double initialBalance, SavingsAccount overdraft) {
super(accountId, initialBalance);
this.overdraft = overdraft;
}
@Override
public boolean withdraw(double amount) {
// 1. 先尝试从本账户取款
if (super.withdraw(amount)) {
return true;
}
// 2. 如果本账户余额不足,计算差额并从透支账户中尝试取款
double shortfall = amount - getBalance();
if (overdraft.withdraw(shortfall)) {
// 透支成功,将本账户余额设为0,因为已全部取出
// 注意:这里需要直接操作余额,可能需要将 balance 设为 protected 或提供 setter
setBalance(0);
System.out.println("Overdraft used. Checking account balance: 0. Overdraft balance: " + overdraft.getBalance());
return true;
} else {
System.out.println("Overdraft source insufficient.");
return false;
}
}
}
CheckingAccount 重写了 withdraw 方法。它首先尝试调用超类的取款逻辑。如果失败(余额不足),则尝试从关联的 overdraft(储蓄账户)中取出差额。
子类:BonusSaverAccount
BonusSaverAccount 继承自 SavingsAccount,它提供更高的利率,但如果提前取款则会收取罚金。
public class BonusSaverAccount extends SavingsAccount {
private final double penaltyRate;
private final double bonusInterestRate;
public BonusSaverAccount(int accountId, double initialBalance, double baseInterestRate, double penaltyRate, double bonusInterestRate) {
super(accountId, initialBalance, baseInterestRate);
this.penaltyRate = penaltyRate;
this.bonusInterestRate = bonusInterestRate;
}
@Override
public boolean withdraw(double amount) {
double amountWithPenalty = amount * (1 + penaltyRate);
System.out.println("Penalty incurred. Attempting to withdraw: " + amountWithPenalty);
return super.withdraw(amountWithPenalty);
}
@Override
public void addInterest() {
// 使用更高的奖金利率计算利息
double interest = getBalance() * bonusInterestRate;
deposit(interest);
System.out.println("Bonus interest added.");
}
}
它重写了 withdraw 方法,在取款金额上增加了罚金。同时也重写了 addInterest 方法,使用更高的奖金利率。
类图与使用
以下是这些类之间关系的简化UML类图:
BankAccount
├── SavingsAccount
│ └── BonusSaverAccount
└── CheckingAccount
BankAccount是基类。SavingsAccount和CheckingAccount继承自BankAccount。BonusSaverAccount继承自SavingsAccount。CheckingAccount与一个SavingsAccount关联(作为透支账户)。
使用示例:
public class BankDemo {
public static void main(String[] args) {
SavingsAccount savings = new SavingsAccount(1, 5000, 0.01);
BonusSaverAccount bonusSavings = new BonusSaverAccount(2, 1000, 0.01, 0.25, 0.04);
CheckingAccount checking = new CheckingAccount(3, 100, savings);
savings.deposit(176); // 存款
bonusSavings.withdraw(120); // 取款,会触发罚金
checking.withdraw(600); // 取款,余额不足,会使用储蓄账户透支
}
}
继承的强大之处:注意 CheckingAccount 的构造函数接受一个 SavingsAccount 类型参数。由于继承,我们可以传入任何 SavingsAccount 的子类,例如 BonusSaverAccount。代码仍然可以工作,只是行为会根据子类的特定实现(如取款罚金)而略有变化。这种可替换性是代码复用的强大工具。
练习:实现 CreditCardAccount
现在,请你尝试创建一个新的子类 CreditCardAccount。
核心要求:
CreditCardAccount应继承自BankAccount。- 它需要追踪一个“信用余额”(
creditBalance)。正常取款/消费会使此值变为负数(表示欠款)。 - 它应有一个信用
limit(额度),消费不能使-creditBalance超过此额度。 - 实现一个
pay(double amount)方法来进行消费。如果消费后欠款超过额度,则交易失败。 - 在
main方法中创建CreditCardAccount对象并测试pay方法。
可选挑战:
- 实现一个
compensate()方法,每月调用一次,用于从关联的CheckingAccount中扣款来偿还信用欠款(creditBalance)。偿还后,creditBalance归零。 - 实现一个
handleOverdraftInterest()方法,如果偿还欠款后CheckingAccount的余额变为负数,则收取高额透支利息。
示例解决方案框架
以下是 CreditCardAccount 的一个可能实现框架:
public class CreditCardAccount extends BankAccount {
private final double limit;
private double creditBalance;
public CreditCardAccount(int accountId, double initialBalance, double limit) {
super(accountId, initialBalance);
this.limit = limit;
this.creditBalance = 0;
}
public boolean pay(double amount) {
if (-creditBalance + amount > limit) {
System.out.println("Payment declined: Over credit limit.");
return false;
}
creditBalance -= amount; // 信用余额减少(变为更负的数值)
System.out.println("Payment of " + amount + " processed. Credit balance: " + creditBalance);
return true;
}
// 可选挑战1:补偿方法
public void compensate(CheckingAccount checkingAccount) {
double amountToCompensate = -creditBalance; // 欠款总额是正数
if (checkingAccount.withdraw(amountToCompensate)) {
creditBalance = 0;
System.out.println("Credit card compensated. New checking balance: " + checkingAccount.getBalance());
} else {
System.out.println("Compensation failed: Insufficient funds in checking account.");
}
}
// 可选挑战2:处理透支利息
public void handleOverdraftInterest(BankAccount account, double overdraftInterestRate) {
if (account.getBalance() < 0) {
double interest = -account.getBalance() * overdraftInterestRate;
// 假设有一个 applyFee 方法或直接操作余额
System.out.println("Overdraft interest applied: " + interest);
// account.balance -= interest; // 需要访问权限
}
}
}
测试代码示例:
public static void main(String[] args) {
CreditCardAccount creditCard = new CreditCardAccount(4, 0, 1000); // 初始余额0,额度1000
CheckingAccount checking = new CheckingAccount(5, 500, null);
creditCard.pay(300);
creditCard.pay(400);
creditCard.pay(299); // 总共 999,成功
creditCard.pay(2); // 总共 1001,超过1000额度,失败
creditCard.compensate(checking); // 从支票账户偿还999
// 此时 checking 账户余额为 500 - 999 = -499
creditCard.handleOverdraftInterest(checking, 0.05); // 收取5%透支利息
}
总结
本节课中我们一起深入学习了继承的多个重要方面:
- 通过“食物与披萨”示例,我们巩固了继承的基本用法,理解了公共方法如何提供对私有数据的间接访问。
- 学习了绑定与作用域,明确了变量在程序不同部分的有效范围,这是理解更复杂概念的基础。
- 区分了属性阴影与方法重写:属性阴影(应避免)会创建同名的不同变量;方法重写(合理使用)是子类提供特定实现的方式,运行时根据对象实际类型调用对应方法。
- 构建了完整的银行账户系统,实践了继承层次结构的设计,包括
BankAccount、SavingsAccount、CheckingAccount和BonusSaverAccount。我们看到了如何重写方法以改变行为,以及如何通过关联对象(如透支账户)实现更复杂的功能。 - 完成了
CreditCardAccount的实现练习,应用所学知识来扩展系统。

继承是面向对象编程的支柱之一,它支持代码复用、扩展和组织。理解如何正确设计类层次结构以及方法重写的机制,对于构建可维护、可扩展的软件至关重要。
025:抽象类与接口

在本节课中,我们将要学习面向对象编程中的两个核心概念:抽象类与接口。我们将了解它们是什么、为何需要它们、以及如何在Java中正确地使用它们来设计更灵活、更健壮的程序。
概述
抽象类和接口是Java中用于实现多态和定义契约的重要工具。它们允许我们指定行为,而将具体的实现细节留给子类。本节我们将通过数学表达式和图形等例子,深入理解它们的设计目的和使用方法。
抽象类与抽象方法
上一节我们介绍了继承的基本概念,本节中我们来看看如何定义不完整的类。抽象方法和抽象类用于指定一个必须由所有具体子类实现的行为。
如果你有一个抽象对象方法,这意味着一个方法被声明了名称、返回类型和输入参数,但其实现被省略了。如果一个类包含这样的抽象对象方法,那么这个类也必须是抽象的。抽象类意味着你不能从它实例化对象。
你可能会问自己,如果不能从它实例化对象,那么这个类的目的是什么?抽象类允许我们以某种方式将子类的某些有趣行为分组,这种方式允许我们重用代码,同时某些方面尚未定义,但必须在子类中实现。因此,我们可以将具有相似行为的类分组。
我们从一个例子开始,这个例子是关于数学表达式的。
这些是你在程序中日常使用的表达式,如加法、减法、乘法等。我们想看看如何在编译器内部实现这一点。
一个表达式有一个值。但这个值可能尚未被求值。
因为我们仍然需要求值并找出这个值。
所以布尔属性 evaluated 被初始化为 false。
我们有一个 getValue 方法。如果值已经被求值,我们直接返回值。
如果尚未求值,我们使用 evaluate 方法让它求值。将 evaluated 设置为 true,然后返回值。
然而,求值操作尚未作为抽象超类的一部分定义,这是我们留给子类去完成的事情。
这里最大的优势是,我们仍然可以调用这个方法,即使它的实现尚不存在。
这也被称为规范继承。因此,我们使用 evaluate 方法指定了某物应该如何工作。
然后子类必须实现这个方法,否则它将无法工作。
abstract 是一个新的关键字,我们用它来声明这种情况。
抽象方法需要声明完整的签名,包括参数、返回类型。如果你抛出异常(这是我们稍后会学到的),你还需要声明可能抛出哪些异常。
一个抽象方法以分号结束,而不是用花括号及其实现。
然而,抽象方法可以在同一类的其他方法中被调用,即使它尚未实现。因为我们有一个契约:只有非抽象类才能实例化对象,并且每个非抽象子类都必须为所有抽象方法提供实现。
这可以再次成为继承层次结构的一部分。你甚至可以混合使用。你可以有一个抽象类,然后是一个具体类,然后是另一个抽象类,接着是另一个具体类。所以它可以变得相当复杂。
但任何具体类都不能包含抽象方法。
因此,如果我们想为 Expression 实现一个具体的子类,我们必须完全按照超类中的定义来实现 evaluate 方法。
所以我们仍然认为这是重写。当我们有最简单的表达式——常量时,这非常简单,因为我们可以简单地返回作为表达式一部分存储的常量值。
我们简单地创建一个构造函数来初始化最终的 constValue,然后返回它。
注意,我们在子类 Const 中也使用了 final 关键字。
这意味着我们不能创建进一步的子类。因此,不可能再将 Const 作为其他类的超类。
所以如果我们想避免这种情况,我们也可以在这里使用 final 关键字。
这提高了安全性和效率,因为我们的运行时可以更快一些。但对于简单的程序,通常没有必要。
我们也可以定义其他表达式,比如加法。
加法的工作原理如下。它有一个左部分和一个右部分,两者都可以是其他表达式。可以是常量。是的,所以 8 + 8 就是常量加常量,但它也可以是任何其他表达式。是的,我可以有一个复杂的表达式加上另一个复杂的表达式。
从这个意义上说,我们在这里有了一种递归的对象树,因为一个加法可以由另一个加法组成。
如果我们有一个加法,我们需要用另外两个表达式来初始化它。
正如我们之前看到的,Expression 不能被实例化。所以我们需要在这里向构造函数传递一个子类。然后我们存储它。
我们需要提供求值方法 evaluate,即获取表达式左侧的值。如果它不是常量,我们仍然需要求值。
然后我们加上表达式右侧的值。如果它不是常量,我们仍然需要求值。
这样,我们基本上可以构建一个由多个表达式组成的树。你可以很容易地基于我们这里的相同例子,想出减法、乘法和除法。
然后我们可以构建一棵树,其中叶子节点是常量。在每一个组合节点,我们基本上可以求值结果是什么,直到我们得到整个表达式的完整结果。
我们还可以想出另一个表达式:取反器。所以如果我说负一个常量,我就有一个取反器。
取反相对简单。我们必须求值表达式的参数,它可能是一个常量、另一个加法、另一个取反器,我们可以任意组合它。我们必须在它前面加上负号。
再次,我们为具体的子类提供实现。没有这个,我们将无法实例化。
我们如何使用这个例子?我们可以创建一个表达式。
在左侧,我们仍然可以使用抽象类型,但在右侧,我们必须使用具体类型。我们不能在右侧使用抽象类。
所以在 new 关键字旁边。你总是需要一个具体的类。你永远不能使用接口或抽象类。这在左侧非常重要,你可以使用它。但在左侧,类型必须与右侧相同,或者是其任何超类或接口。
是的,所以我们不能反过来。我们不能说 Addition e = new Expression()。这是不可能的。
但我们可以说 Expression e = new Addition(),然后我们用 -8 和 16 的组合来构造它。我们打印出结果。
结果将是 -8 + 16 = 8。
这样,我们就看到了编译器如何基于你代码中的小表达式构建抽象语法树,将其建模为对象,然后确保它们使用优先级,正如我们在本讲座第一单元的大表中看到的那样。记住,例如,乘法比加法有更高的优先级,以计算最终结果。
当然,如果我们在加法中嵌入某些东西,我们可以有不同的优先级。这样,你可以很容易地想出任何其他表达式,你甚至可以支持多于两个参数的加法,比如三个、四个、五个参数,并且你可以想出减法、乘法和除法。
所以这是一个抽象类可能有用的例子。
注意,getValue 会为每个部分表达式连续调用 evaluate。
所以我们真的会一直进行下去,直到遇到一个常量,然后再返回。
这是一种深度优先搜索方法,如果你熟悉树的话。
具体调用哪个实现真正取决于运行时。这被称为动态绑定。所以右侧决定了将调用哪个方法。
这也很重要。这是第一次遇到左类型(编译时类型)和右类型(对象运行时类型)之间的区别。
要选择正确的方法,运行时类型很重要。这被称为动态绑定。
为了让这更容易理解,它的工作原理如下。运行时,如果你在一个对象上调用一个方法,运行时总是在最低的类型(运行时类型)搜索该方法。
如果该方法可用,它将调用这个方法。
如果不可用,它会转到直接超类。如果方法在这里可用,它将调用这个方法。
如果方法不可用,它会转到下一个超类,直到到达层次结构中最高的超类,最高的超类实际上是 Object,即使你看不到它。然后必须有这个方法的实现,否则代码将无法编译。这被称为动态绑定,只能在运行时决定调用哪个方法。我们将在关于多态性的最后一个单元中看到更多相关内容。
我们可以在UML中对此建模。我们有一个抽象类,注意我们用斜体表示,并带有这个额外的构造型,用这些非常具体的小符号表示,这是抽象的。注意 evaluate 也是抽象的,它用斜体书写,不太明显,但你必须仔细看才能看到它在UML图中是抽象的。所以抽象的东西,无论是类还是方法,都用斜体书写,对于类,我们有这个额外的构造型来立即看到它是抽象的。
我们有三个子类:Addition、Constant 和 Negation。它们都重写了 evaluate 方法,因此我们再次提到它。
Const 添加了一个 constValue 属性,其原始数据类型为 int。
Addition 有两个类型为 Expression 的属性。因此,我们在这里使用关系 left 和 right。
Negation 有一个名为 argument 的属性,类型为 Expression。
这就是我们如何在UML中建模它,这是一个非常好的例子,即使听起来有点复杂,难以理解为什么需要抽象以及如何在代码中使用抽象类。
关于这个例子有什么问题吗?
我知道这现在变得有点困难了,但你有一整周的时间和一些练习来更好地理解它。所以如果你现在没有完全理解,这不是大问题。我看到这里有一个小拼写错误。缺少了 T。这当然应该是 int。我们会修复它。
这就是它的样子。我们可以让它更复杂,甚至可以建模有理数,但Java中非常重要的一点是,我们不能有多个超类。所以你总是只能有一个超类。你不能有超过一个超类,这非常重要。
所以类的多重继承是不可能的。Rational 不能同时拥有一个 Comparable 类和一个 AddSubMultDiv 类(用于基本数学运算加、减、乘、除)。它不能有两个超类。这是不可能的。
为什么不可能?因为这会导致混淆。
两个超类都可以独立存在,并且可以定义相同的方法。
如果是这种情况,并且你从两者继承,那么应该使用哪个方法?运行时应该如何决定是去第一个超类进行动态绑定并调用超类方法,还是去第二个超类?这根本不可能。因此,实际上只有极少数编程语言支持多重继承。
接下来,我们想介绍接口。
接口与抽象类类似,但略有不同。
接口可以看作是一个所有对象方法都是抽象的抽象类。一切都已指定,但完全没有实现。抽象类实际上可以混合非抽象方法和抽象方法,而在接口中,所有方法都是抽象的。
并且所有变量都是常量。所以没有可变状态。
抽象类可以定义属性,可以持有状态并需要内存,如果我以后使用它的话。
接口不能持有状态。我可以定义变量,但只能是常量。它们以后不能改变值。
这是一个Java中接口的例子,用于允许你比较同一对象的不同实例。这就是所谓的 Comparable 接口。
Comparable 接口简单地指定,以后符合它的每个对象或类都需要实现 compareTo 方法。
注意,接口中的所有方法自动是 public 的。即使你不写,也不能有 private 或 protected 方法。这根本没有意义,因为接口就像一个契约。如果我知道一个类实现了这个接口,我就知道它遵循这个接口定义的特定契约。
你们之前都使用过接口,上周我们讨论 List 数据类型时,List 是Java中的一个具有特定契约的接口,而 ArrayList 和 LinkedList 用特定的实现来实现这个契约。
这是一个代码示例。我们有一个 RationalNumber,分子和分母在这个例子中是 long 类型。它扩展了一个类,该类为我们提供了简单的四种数学运算:加、减、乘、除。
此外,它可以实现一个或多个接口。这也是一个区别。我只能扩展一个类,无论它是抽象的还是普通的,但我可以实现任意多个接口。
我使用 implements 关键字和它后面的逗号分隔列表。
RationalNumber 通过提供这个方法来实现 Comparable 接口,基本上取另一个分数,与分子和分母进行比较。如果计算结果会导致相同的结果,我们返回 0;如果左侧小于右侧,我们返回 -1;否则返回 +1。这是 Comparable 接口中定义的这个方法的契约,这允许以后Java中的排序方法根据 Comparable 接口的 compareTo 方法对所有元素进行排序。
所以我们可以有一个超类,多个接口是可能的,接口的关键字是 implements。implements 是 conforms to 的同义词。所以这个类符合 Comparable 接口。它实现了其所有方法。
我们可以确信这些方法是可用的,并且我们以后作为这个类的使用者可以调用这些方法。
这就是它的工作原理。理论上,我们说 class A extends B implements C1, C2, ... 直到达到一定数量的接口,这意味着 A 是 B 的子类,并且 A 实现了接口 C1、C2 等。所以只有一个超类,无限数量的接口。然而,通常你只实现一个到最多三个接口。我通常看不到不同的例子。
你也可以将接口用作类型。所以我们可以说 List list = new ArrayList(),但我们不能在右侧使用它们。所以它们不能像抽象类那样与 new 操作符一起使用,但我们可以将它们用作左侧的编译时类型。
所以如果你有形式参数、变量或返回类型,它们可以具有接口类型。
这些接口类型的实例化对象必须来自一个实现类。你可能不知道是哪个类。如果你有一个 List 数据类型,你可能不会立即知道它是 ArrayList 还是 LinkedList,你只知道它符合这个接口。所以如果你需要这个信息,即它是哪个具体类,你以后可以将其强制转换为具体类。
你也可以使用 instanceof 操作符检查它是否是此特定类的实例。然而,这可能相当麻烦且容易出错,因为强制转换有时也会出错,然后你会得到类强制转换异常。
现在,甚至有可能一个接口扩展另一个接口。然后我们在这里再次使用 extends 关键字。事实上,一个接口甚至可以扩展多个其他接口。
这允许我们以非常精细、细粒度的方式对功能进行分组。然而,接口扩展其他接口相对少见,你不经常看到这种情况。
但也可能是我说 Comparable 有一个方法,Cloneable 有一个方法,我有一个 Countable 接口,它甚至添加了三个更多的方法,所以最终一个实现 Countable 的类需要提供五个方法:你在这里看到的三个,Comparable 接口的一个和 Cloneable 接口的一个。这样,我就可以以一种非常简洁的方式真正地对功能进行分组和指定。
注意,常量总是通过使用开头的具体类型来寻址,因为它们是静态的。如果你在类或接口中有静态属性或静态方法,你总是说类类型或接口类型.属性,或者类类型/接口类型.方法。这非常重要,否则我们将无法区分这些方法,并且我们不知道它们属于 Comparable 还是 Cloneable。
所以虽然我们可以定义常量,但它们都是静态的。
一些注释基本上重复了我刚才所说的内容。
现在,同样重要的是要理解 Object 是所有Java类的公共超类。所以如果你没有定义自己的类 extends 某个东西,那么Java会自动将你的类配置为扩展 Object。
我们不需要写下来,这是隐式行为。但你总是扩展 Object。这意味着 Object 中的所有方法,例如 toString,都被继承并可以自动使用。但你也可以重写它的实现。所以如果你想像在队列或栈中看到的那样,拥有一个非常特定的 toString 实现,我们可以重写这些方法并提供我们自己的实现。
这样,我们可以构建有理数、复数、接口,并拥有某种形式的多重继承,但不是通过类。所以一个 Rational 类可以有多个看起来像继承的三角形,但在这种情况下,AddSubMultDiv 可以是一个类,其他必须是接口。所以最多一个超类,但多个接口是可能的。
我们现在想用之前在练习 W5 E2 中使用过的例子来练习这个。所以请将你在Artemis中用于此练习的代码复制并粘贴到你的Playground中。这不是一个Artemis练习,我们纯粹在Playground中进行。
我们请你重写它。记住,我们有一个奇怪的 Shape 类,它有一个 area 方法,只是返回 0.0,这没有任何意义。
现在我们有了工具来改进它,所以我们可以重写它以使用抽象类。
所以从本练习中复制并粘贴你的解决方案。
但现在将 Shape 声明为抽象类,并确保 area 方法也是抽象的。
另外,为了尝试,创建它的第二个副本,可能在不同的包或不同的Playground中,这里对 Shape 使用接口。这样我们尝试接口和抽象类,然后与你的邻居讨论,对于这个特定例子,哪种选择实际上更合适。
所以开始做这个大约10分钟。如果你有任何问题,请举手,我们会帮助你。助教们会四处走动,练习结束后我会给你展示一个示例解决方案。
好问题。这是一个小任务,所以将之前练习的代码复制到你的Playground中。
是的,让我也这样做。当你们自己工作时,你们不需要跟着我。
但你可以打开它。打开你的Playground。
我删掉其他所有东西,只保留我的主类。如果你记得,我们有一个名为 Shape 的类。
Shape 有一个受保护的字符串 color。我想它是一个公共的 double area() 方法。这个方法是这样的,需要实现。它基本上返回 0.0。这就是实现。
你可以复制这个。你不需要输入它。
然后我们有一个 Circle,如果我没记错的话,它扩展了 Shape。并且有一个私有的 final double radius。有一个构造函数来初始化这个,并且有一个 area 方法,返回 Math.PI * radius * radius。
然后,我们还有一个 Rectangle。这个 Rectangle 也扩展了 Shape。它有一个私有的 final double width 和 height。有一个构造函数。我们通过返回 width * height 来实现 area 方法。
最后但同样重要的是,我们有一个子类 Square。我们决定,记住,扩展 Rectangle。并创建一个只有一个参数的构造函数,因为高度和宽度相同。
这基本上是之前练习的解决方案。
现在你需要做的是看看这个。这相当丑陋,对吧?我的意思是,我们有一个方法做了某事,但它并不是真的应该做任何事。
所以现在的想法是,我们简单地将 Shape 声明为抽象类。
然后将这个方法也声明为抽象。移除它的实现。
然后你已经完成了。这是练习的第一部分,超级简单。
现在这是一个更好的练习,更好的例子,因为 Shape 通常不能被实例化。我们还不知道它的形状。我们不知道它是矩形、圆形还是将来可能想到的任何其他东西。所以把它做成抽象的是一个非常好的主意。并让子类派生或实现一个方法。
就是这样。这是一个你可以自己轻松实现的抽象类的简单例子。
现在我们要复制所有这些。并且也使用接口。是的,所以我在这里创建一个新的包。
我称之为 interfaceExample,我不能用 interface 因为那是关键字。
我把所有东西复制粘贴进去。所以我拥有完全相同的类。但现在,我们想尝试一下。我们是否也可以让 Shape 成为一个接口?
如果我们不说它是抽象类,而是说它是一个接口,会发生什么?
嗯,我们看到现在出现了一些问题。首先是我们不能扩展一个接口。我们需要说 implements。但这样它又会工作。这里也一样。
Square 仍然可以扩展 Rectangle,因为 Rectangle 是一个类。但现在我们还有一个问题。
在这里可以看到,它说修饰符 protected 不允许在这里。所以让我们移除它,好的。
但现在会发生什么?嗯,color 不再是对象属性了。它是一个静态属性。我们不能再给一个正方形实例赋予颜色了。这是不可能的。对吧。
为什么会这样?接口不能持有状态。它们只有静态常量。所以,最多,我们可以给它一个常量并称之为 RED。但这样我们就失去了给形状着色的机会。我们不能再有蓝色的矩形、绿色的圆形或黄色的正方形了。所以这并不好。解决方案是什么?
嗯,我们可以将 color 移到子类中。但那样我们就必须在 Circle 中定义它。是的,我们仍然可以说 protected String color。然后 Rectangle 就不会有它。是的,我们也可以在这里定义它,但那样就会有重复,对吧。对于我们要定义的任何其他 Shape 子类,我们都需要另一个 color 属性。
所以这不是一个好的解决方案。对吧。那么你怎么看?在这个特定的例子中,哪个更好,接口还是抽象类?想一想。和你的邻居讨论一下。然后我们可以得出结论。

好的,那么你们中谁认为在这个特定例子中,接口是好的解决方案?请举手。

你们中谁认为在这个例子中抽象类是好的解决方案?好的,很好,大多数。这实际上是事实。
所以我在幻灯片上有示例解决方案。它基本上就是我们刚刚在代码中一起发现的。你可以看到,在左侧我们有抽象类,我们可以有状态 color,这在右侧我们有接口的情况下是不可能的,所以我们需要在代码中复制它。
所以与之前练习中的例子相比,只有很少且很小的变化。
但你看到了接口和抽象类是如何工作的,并且你看到在这个特定例子中,抽象类(这里是左侧的模型)是首选的解决方案,因为 Shape 实际上包含状态。如果 Shape 不包含任何状态,你也可以使用接口解决方案。
对于接口,你也可以用不同的方式处理。你也可以说 Shape 是一个抽象类。但 area 是接口的一部分。然后我们可以说 Rectangle 扩展 Shape 以获得状态,并实现定义 area 方法的某个东西。所以这也是一个有效的解决方案。
关于这个小练习有什么问题吗?
好的,那么在我们进行另一次休息之前,这里是接口和抽象类的比较。
这主要总结了我刚才在过去大约30分钟里所说的内容。它供你阅读并有一个好的比较。我不会再重复一切。
只需记住,接口不能在对象属性方面持有状态,而抽象类可以做到这一点。
然而,你只能扩展一个类,但可以实现多个接口。
你会发现接口在函数式编程中非常重要。所以如果我没记错的话,两周后,我们将介绍函数式编程,届时我们将讨论Lambda表达式和流,它们的接口真的非常重要,所以近年来在函数式编程方面,它们被大大提升了,但抽象类仍然有效。
让我们快速休息一下,因为我们有点超时了。我建议我们只休息7分钟,如果这对你们来说可以的话,因为这样我们可以早一点结束,所以我们将在5点15分再次见面,然后在今天的最后一个单元中讨论一点多态性,享受休息时间。
总结

本节课中我们一起学习了抽象类与接口的核心概念。我们了解到抽象类用于定义部分实现的模板,可以包含状态和具体方法,而接口则用于定义纯粹的行为契约,不能包含状态。通过数学表达式和图形形状的例子,我们实践了如何根据设计需求选择使用抽象类或接口。记住,一个类只能继承一个父类,但可以实现多个接口,这是Java实现多态和代码复用的重要机制。
026:多态


在本节课中,我们将要学习面向对象编程的第四个基本概念——多态。多态允许我们以多种形式处理对象,是代码结构和复用的重要手段。我们将探讨其两种主要类型:静态多态和动态多态,并通过示例和测验来加深理解。
多态概述
多态是面向对象编程的第四个基本概念。它允许我们结构化代码并实现代码复用。我们已经学习了抽象(通过抽象类和接口)、封装(在对象中存储状态)和继承。现在,我们来学习多态。
多态一词源自古希腊语,意为“多种形式”。它本质上是继承的一种扩展,允许通过重写超类的方法来修改功能。应用多态时,需要确定你希望使用哪种形式——是超类(父类)的形式,还是子类的形式。
多态方法的类型
一个所谓的多态方法可以以相同的名称存在多次,但可能具有不同的行为。我们可以区分两种非常具体的类型。
以下是两种多态类型:
- 静态多态(编译时多态):这发生在方法重载时。重载意味着在同一个类中,有多个方法名称相同但参数不同。编译器将决定调用哪个方法。因此,在编译时,一切都很明确。
- 动态多态(运行时多态):这通过方法重写实现。重写总是发生在具有继承关系的不同类中。在这种情况下,编译器无法决定将调用哪个方法,这个决定将在运行时做出。
这是一个非常重要的区别。当人们提到“多态”时,通常指的是动态多态,因为它更强大。静态多态(即重载)有时甚至不被视为一种多态类型。
静态多态(重载)
上一节我们介绍了多态的基本概念,本节中我们来看看静态多态的具体表现。
静态多态或重载意味着我们进行静态绑定。如果一个类(如 Vehicle)声明了两次相同名称但参数不同的方法,就会发生这种情况。虽然这不是一个特别重要的特性,但有时会发生,你需要知道如何处理它。编译器可以通过查看代码立即判断出你想调用第一个还是第二个方法。
以下是一个静态多态的示例:
class Vehicle {
public void move() {
System.out.println("Vehicle is moving.");
}
public void move(int speed) {
System.out.println("Vehicle is moving at speed: " + speed);
}
}
在这个例子中,move 方法被重载了。编译器根据调用时提供的参数(无参数或一个 int 参数)来决定调用哪个版本。
动态多态(重写)
了解了静态绑定后,我们进入更强大的动态多态世界。
动态多态也称为后期绑定或动态绑定,它总是与继承相关。我们有一个超类和一个子类,子类重写了超类的方法。然后你需要知道实际上会调用哪个方法——是子类的方法还是超类的方法。这就是计算机中发生的“奇迹”,也是我们希望你能更好理解的。
以下是一个动态多态的示例:
class Vehicle {
public void move() {
System.out.println("Vehicle is moving.");
}
}
class Motorbike extends Vehicle {
@Override
public void move() {
System.out.println("Motorbike is moving.");
}
}
public class Main {
public static void main(String[] args) {
Vehicle v1 = new Motorbike(); // 运行时类型是 Motorbike
v1.move(); // 输出: Motorbike is moving.
Vehicle v2 = new Vehicle(); // 运行时类型是 Vehicle
v2.move(); // 输出: Vehicle is moving.
}
}
调用哪个方法取决于右侧的类型(运行时类型)。左侧是编译时类型(总是超类 Vehicle),右侧是运行时类型(new 操作符旁边的类型)。当运行时类型可用时,将始终使用子类的方法。如果子类没有重写该方法,则会使用超类的方法。
类型转换与 instanceof
动态多态带来了一个常见问题:类型转换。有时,你获得一个超类引用,但子类有额外的方法,你希望调用这些仅在子类中可用的方法,这时就需要进行向下转型。
编译时类型必须是目标子类类型,否则编译器会报错。你可以直接转换,但如果 Vehicle 实际上不是 Motorbike 类型,则可能在运行时引发 ClassCastException。
为了避免这种异常,建议在向下转型前使用 instanceof 关键字进行检查。
以下是使用 instanceof 进行检查和转型的示例:
if (vehicle instanceof Motorbike) {
Motorbike myBike = (Motorbike) vehicle; // 安全转型
myBike.someMotorbikeMethod(); // 调用子类特有方法
}
在较新的 Java 版本中,可以使用更简洁的写法:
if (vehicle instanceof Motorbike myBike) {
myBike.someMotorbikeMethod(); // 自动转型并调用方法
}
测验与解析
现在,我们通过几个小测验来巩固对多态的理解。请阅读代码并思考输出结果及其原因。
测验一:静态多态的陷阱
class Car {
public void drive() {
System.out.println("Using Car");
}
}
class SportsCar extends Car {
public void drive(int speed) {
System.out.println("Using SportsCar at speed: " + speed);
}
}
public class Test {
public static void main(String[] args) {
Car car = new Car();
SportsCar sportsCar = new SportsCar();
Car anotherCar = new SportsCar();
car.drive();
sportsCar.drive(200);
anotherCar.drive();
}
}
解析:这看起来像动态多态,但实际上是静态多态(重载)。两个 drive 方法在同一个类(SportsCar)中,只是参数不同。编译器根据左侧的编译时类型决定调用哪个方法。
car.drive();调用Car的drive(),输出 “Using Car”。sportsCar.drive(200);调用SportsCar的drive(int speed),输出 “Using SportsCar at speed: 200”。anotherCar.drive();编译时类型是Car,Car类中只有一个无参的drive()方法,因此调用它,输出 “Using Car”。
测验二:混合静态与动态多态
class Car {
private int speed = 100;
public void drive() {
System.out.println("Using Car");
}
public int getSpeed() { return speed; }
}
class SportsCar extends Car {
private int speed = 500;
@Override
public void drive() {
System.out.println("Using SportsCar");
}
@Override
public int getSpeed() { return speed; }
}
public class Test {
public static void main(String[] args) {
Car car = new Car();
SportsCar sportsCar = new SportsCar();
Car anotherCar = new SportsCar();
car.drive();
System.out.println(car.getSpeed());
sportsCar.drive();
System.out.println(sportsCar.getSpeed());
anotherCar.drive();
System.out.println(anotherCar.getSpeed());
}
}
解析:这里混合了静态和动态多态。drive() 方法的重写是动态多态,而 getSpeed() 的调用也涉及动态多态(因为被重写了)。
- 前两行输出由
car对象产生:”Using Car” 和 “100”。 - 中间两行由
sportsCar对象产生:”Using SportsCar” 和 “500”。 - 最后两行,
anotherCar的编译时类型是Car,但运行时类型是SportsCar。因此,重写的drive()和getSpeed()被调用,输出 “Using SportsCar” 和 “500”。
测验三:纯动态多态
class Vehicle {
public void move() {
System.out.println("Vehicle moves");
}
}
class Car extends Vehicle {
@Override
public void move() {
System.out.println("Car drives");
}
}
class SportsCar extends Car {
@Override
public void move() {
System.out.println("SportsCar races");
}
}
public class Test {
public static void main(String[] args) {
Vehicle v1 = new Vehicle();
Vehicle v2 = new Car();
Vehicle v3 = new SportsCar();
v1.move();
v2.move();
v3.move();
}
}
解析:这是一个纯动态多态(重写)的例子。调用哪个方法完全取决于右侧的运行时类型。
v1.move();运行时类型是Vehicle,输出 “Vehicle moves”。v2.move();运行时类型是Car,输出 “Car drives”。v3.move();运行时类型是SportsCar,输出 “SportsCar races”。
课程总结
本节课中我们一起学习了面向对象编程的核心概念之一——多态。
我们首先回顾了面向对象编程的四个基本概念:抽象(通过抽象类和接口)、封装(在对象中存储状态)、继承以及今天的主题——多态。
我们深入探讨了多态的两种类型:
- 静态多态(编译时多态/重载):发生在同一类中,方法名相同但参数列表不同。编译器在编译时决定调用哪个方法。
- 动态多态(运行时多态/重写):发生在继承体系中,子类重写超类的方法。具体调用哪个方法在运行时根据对象的实际类型决定。
我们还学习了在处理动态多态时,如何安全地进行向下转型,即使用 instanceof 进行检查以避免 ClassCastException。
最后,通过三个测验,我们练习了区分静态与动态多态,并理解了编译时类型与运行时类型在多态行为中的关键作用。记住,动态多态是更强大和常用的特性,它允许程序在运行时表现出灵活性,是面向对象设计的重要基石。


027:类型灵活性与安全性


在本节课中,我们将学习类型灵活性与安全性。课程已进行到中期,在接下来的深入课程之前,我们将教授更多面向对象编程概念,并复习本周及上周最重要的知识点,以帮助大家更好地参与即将在两周后进行的指导练习。
我们假设大家已经很好地理解了所有基本概念,特别是控制结构、数据类型,以及面向对象编程原则,如抽象、封装、继承和多态。我们知道多态不是最容易掌握的主题,但希望大家至少对其含义有大致了解。
今天的学习目标是:
- 进一步理解泛型背后的思想。我们已经快速介绍过如何使用内置数据类型的泛型,今天将学习如何创建自己的泛型数据类型。
- 能够创建泛型数据类型并加以使用。
- 再次学习如何将基本数据类型包装在对象中。我们已经了解过,但今天会看到更多细节。
- 介绍Java中一个常见的数据类型:Map。
- 在最后一节,我们将讨论如何处理异常,特别是如何抛出和捕获异常,甚至定义自己的异常。因此,异常处理或错误处理也是今天的重要主题。
这是课程大纲,我们将讨论泛型,为此有两个部分。中间有两个较长的练习,供大家尝试如何操作。在中间较长的休息之后,我们将再次讨论对象数据类型和错误处理。
让我们从一个例子开始。
类型转换回顾
首先,我们有一个类 Polly。这个类 Polly 只有一个 toString 方法,说实话,没什么特别令人兴奋的。
现在我们有一个 PollyTest 类,其中包含一个 main 函数。在这里,我们创建了一个新的 Polly 对象。但可以看到,左侧的编译时类型是 Object。这允许我们将它传递给 addWorld 方法,该方法实际上接受一个 Object 作为参数。然后,我们可以在这里调用 toString 方法。
首先,我们可以注意到每个类都是 Object 的子类,并且 Polly 隐式地有一个我们在此使用的空构造函数。类方法 addWorld 允许我们打印任何对象的 toString 方法加上 “world”,所以我们可以在这里传递任何对象。即使类中没有明确定义 toString 方法,但在这个例子中,它是有的。
这里有一个变量,它有一个特定的类类型 A,作为一个更通用的例子,它可以被分配给 A 的任何子类的对象。是的,所以在左侧,我们需要有相同的类型或超类型/超类类型。在右侧,我们需要有相同的类型或子类类型。反过来则不可能。是的,这是创建对象时的一个重要约束。我们总是有两种类型,它们可以相同,这是最简单的情况;但如果它们不同,左侧的编译时类型在继承层次结构中必须始终高于右侧的类型。
在这个例子中,输出将是 “hello world”。是的,因为我们这里有 Polly,我们添加了它,即使运行时类型不完全相同,我们也可以这样做。对于方法参数也是如此,你可以用子类类型调用它们,在这个特定情况下不需要强制转换。
很好,让我们稍微改变一下这个例子。假设 Polly 有一个 greeting 方法。
现在,如果我们写 Object polly = new Polly();,那么我们仍然有一个 Polly 类型的对象。运行时类型仍然是 Polly,但编译时类型是 Object。虽然我们立即看到 polly.greeting() 应该能工作,因为我们看这里,编译器也可以判断,但这并不保证正确,因为 Object polly 可能是从其他地方获取的。是的,运行时类型有时在你的代码中并不明确。是的,也可能有人将一个 Object 作为参数传递给方法,然后如果你只知道编译时类型是这个 Object,你就无法调用该方法。
你需要做什么?如何在不做太多更改的情况下使这段代码工作?我们可以更改这里的类型。是的,但假设这一行是固定的,我们只能更改这一行。是的,我们可以进行向下转型,这正是这里的解决方案。是的,为了使它工作,我们需要告诉编译器:“嘿,编译器,我相当确定或100%确定 polly 是 Polly 类型,而不仅仅是一个 Object,即使这里的类型是 Object。我覆盖你或明确告诉你进行强制转换。” 然后我只需要确保转换不会失败,否则我将在运行时得到一个 ClassCastException。
但如果我们不进行强制转换,我们会得到一个编译错误,编译器会说“找不到符号”。这里的符号是方法 greeting,位置是类型为 Object 的变量 polly。因此,在类型为 Object、变量名为 polly 的情况下,我们找不到符号 greeting,所以你会得到编译错误。是的,这是根本原因。再次强调,这是因为我们这里的静态类型与这里的方法不匹配,编译器需要在编译时知道它是否有效。
很好。现在,你已经告诉我解决方法是向下转型到适当的子类,理想情况下我们通过使用 instanceof 方法来实现,因为正如我所说,这只是一个简单的例子,但有时你并不真正知道对象来自哪里,你可能不是100%确定是否是这种情况。如果你不能更改实际类型,或者如果它是方法签名的一部分,那么最好安全起见,所以你进行 instanceof 检查。你之前已经看到,我们可以通过避免这里的强制转换并将其放入 instanceof 问题或语句中来使其更简洁。然后我们可以调用方法。否则,我们也可以处理这种情况并说“抱歉,无法转换”。
关于强制转换有什么问题吗?这是我们假设你理解的内容。是的,这并不新鲜,只是对我们所涵盖内容的总结,并将其带入不同的上下文。
是的,问题是使用 Object 背后的动机是什么?你将在接下来的几张幻灯片中看到一般性的动机。有时,你无法对你得到的类型做出任何假设。所以,当然,如果可能的话,总是最好做出假设,并使用最具体的类型,因为这样你在可以调用哪些方法方面有最大的自由,并且需要更少的强制转换。但有时这根本不可能,因为你无法做出任何假设。然后我们需要转向超类,甚至是 Object,我们无法真正避免这一点。
现在我们可以对幻灯片做一个小小的改动,向你展示这将如何工作。我们也可以在这里放入名称,并立即在这一行进行强制转换,避免这里的所有括号,然后我们也可以这样写。这将更容易理解。所以这是现代的方式。我现在就改一下。是的,然后在未来,我们会以更好的方式呈现它。不,我不改了,因为否则我需要去掉这里的注释,但我想某种程度上很清楚这就是我们需要做的。虽然有点难看,但如果我们把它放在这里会好得多,这只是一些样式上的改进,我想说。
很好,所以一些解释:我们有静态类型,我们有动态类型。每当你在左侧有某些东西,或者每当你在方法中有一个带有形式参数类型的签名时,那些就是静态类型或编译时类型。而所有在 new 操作符右侧的东西都是运行时类型。这在代码中有时你并不真正知道运行时类型是什么,实际上在大多数情况下你并不真正知道,因为它总可能是一个子类。所以即使你指定了一个方法,实际传递给你的实例也总可能是这个方法的子类。instanceof 允许我们在运行时检查类成员身份,然后我们可以进行强制转换。当然,我们需要小心。如果我们不使用 instanceof,如果我们做出假设并自动或不经检查地进行强制转换,那么我们可能会得到一个异常,即所谓的 ClassCastException,在最坏的情况下,我们的程序会崩溃。
泛型简介
我们已经了解到,泛型允许我们基本上定义可以处理多种其他类型的类型。因此,我们可以创建一个带有泛型类型的通用列表,使用菱形操作符,而不是只为字符串创建一个列表。然后,当我们实例化列表并使用它时,我们才决定要使用哪种类型。这样,我们基本上可以使用任何符合我们指定要求的类型。这是通过菱形操作符完成的。所以我们可以创建一个我们自己的类型 T 的列表,或者我们也可以创建一个字符串列表。是的,这是完全可能的。这是对之前幻灯片内容的补充,我们可以考虑进去。因此,除了静态类型和动态类型之外,我们现在还有类型参数。
或者参数类型,泛型类允许我们在许多不同情况下具有类型安全性。所谓的参数化类型是可以处理不同数据类型的类,通常包括所有简单数据类型,也包括基于类的类型和你自己的自定义类型。最大的优势是我们可以重用代码,只需要指定一次。到目前为止,我们只是使用了泛型。但今天,我们还将向你展示如何创建自己的泛型类型。
在这个工作坊中,在此之前,我们可以再次看到我们使用了列表,例如,带有参数字母 E,以及集合,也带有参数字母 E。稍后,我们还将学习如何使用 Map。Map 总是有两个类型,一个键 K 和一个值 V。所以在这里,我们不像列表中那样只有一个类型,而是有两个。所以你可以将字符串映射到整数,或者将整数映射到字符串,或者将你自己的类 Point 映射到你自己的类 Student,例如,或者你可以将 Student 映射到最终成绩。这就是你可以用 Map 做的事情。它是一个非常强大的数据结构。我们将在几分钟内看到它是如何工作的。
创建简单的泛型数据类型
这是一个非常简单的泛型数据类型的第一个例子。我们基本上想让我们的 Course 类变成泛型的。我们可以通过指定这个菱形操作符并指定一个字母来实现。如果你没有充分的理由改变它,通常使用 T,这是类型(type)的通用名称或泛型缩写。当我们这样做时,我们现在可以将其用作属性的类型、参数的类型以及方法中返回值的类型。只有在稍后,当我们实例化一个 Course 时,我们才需要指定它的实际类型。我们可以用字符串来做,也可以用整数来做,无论我们想做什么。这真的是一个非常通用的例子,不要把它当作一个有意义的例子,它只是为了让你理解这里发生了什么。
请注意,我们通常使用 T。这是一个约定。你已经看到 List 和 Set 使用 E 表示元素。你基本上可以使用任何字母,甚至多个字母。通常,它们是大写的,以区别于用于变量或属性名称的小写字母,否则你可能会感到困惑。是的,所以一个或两个大写字母,这是约定。我们这里有一个 String 类型的实例,这里有一个 Integer 类型的实例。
关于这个例子有什么问题吗?很好。
请注意,泛型只适用于引用类型。我们快速讨论一下。所以当你声明一个泛型类型的实例时,你总是需要使用引用类型。你不能使用八个基本类型。用 char 不行,用小写的 float 等等也不行,编译器会告诉你这是不可能的。主要原因是我们在处理这些类型时需要引用语义,并且我们有某些假设需要编译器检查。所以基本类型是不可能的。但幸运的是,我们有一个变通方法。我们为所有基本类型提供了包装类。所以你可以使用大写的 Integer、Boolean 等等。然后你基本上将基本数据类型包装在一个对象数据类型或类数据类型中。然后你也可以将其用于泛型。
包装类与类型安全
我们这里再次提供了所有包装类的概述。你可以看到 Object 是最高超类,然后我们有 Boolean、Character 和 Number,而 Number 首先是抽象的,然后有一些你已经知道的子类,如 Short、Integer,这些是基于整数的数字。是的,所以是没有逗号的整数,还有 Long 以及 Float 和 Double,它们是浮点值,可以非常精确地描述实数。
重要的是,泛型使你的代码更安全。这就是为什么本讲座也被称为类型灵活性与安全性。它使其更灵活,以便我们可以重用代码,但也使其更安全。因为你可以做出假设。是的,与其拥有一个包含 Object 的通用列表,我们可以使用类型参数定义一个泛型列表。然后当我们检索元素时,我们实际上可以确定它们是这种特定类型,并且不需要向下转型,对吧?所以这是泛型的一大优势。
现在,你需要理解的是,泛型是一个完全的编译时特性。所以在编译期间,编译器实际上会移除它们,并基本上用 Object 替换 T,并在必要时自动为你插入向下转型。所以在运行时,你无法检查类型是否是 List<String>。这是不可能的。你也不能强制转换。你不能将一个 List<Number> 强制转换为一个 List<Integer>。这是不可能的,因为类型在运行时不再可用。泛型类型真的被擦除并消失了,可以这么说。所以这些是注意事项和在 Java 中实现的小缺点。它被擦除的主要原因是出于性能考虑,因为这些类型可能会在运行时系统中导致一定的开销。如果你需要遵循其所有方面和所有复杂性,Java 背后的开发人员在某个时候决定,这在运行时开销太大,我们需要摆脱它。
问题。是的,问题是当我们使用泛型并从列表中检索对象时,例如,它会自动强制转换为你想要的类型。是的,这是正确的。是的,这就是它的工作方式。编译器为你做了这件事。是的,在某种意义上,这是一个便利功能。是的,你不必手动操作。编译器为你做这件事。这样,你就获得了一定的类型安全性。
请注意,数组也可以被传递,因为数组是引用类型,所以我们可以用 int 数组创建一个新的 Course。然而,这非常奇怪。是的,老实说,我在实践中从未见过。这只是为了让你知道它会工作,但我真的不建议这样做。另一方面,你可以嵌套泛型类型。是的,所以你可以有一个 List<List<String>>,这是可能的,但同样,它可能变得相当复杂,你应该仔细检查这是否真的是你需要的,或者你是否宁愿在以后的数据模型中以不同的方式建模。
练习:泛型 Point 类
很好,然后让我们做一个快速的第一个练习。我们基本上为你准备了一个 Artemis 练习,我们希望你在之前定义的 Point 类的基础上进行。记住,我们曾经有 int x 和 y,或者 float x 和 y,或者 double x 和 y,这相当繁琐,因为我们需要决定使用哪种数据类型。现在我们希望你使用泛型数据类型 T,而不是使用 int、float 或 double。这样,我们就不局限于特定的类型。然后 x 和 y 也具有这个泛型类型 T。然后我们要求你创建一个构造函数,将参数传递给属性,并稍后创建几个 Point 对象,用 Integer 尝试,用 Float 尝试,并将它们打印到控制台。


这是一个 Artemis 练习。所以请打开 Artemis,进入下一个练习,它已经在这里了。我可以点击这里开始练习。问题陈述相对较短,因为它不是一个大练习。将其导入到你喜欢的 IDE 中,开始工作,我将在10分钟左右展示一个示例解决方案。
这是一个新概念,但相对容易。是的,这真的只是8到10行代码,没什么特别的,真的是让你练习的。所以仔细检查我们是如何创建不同的 Course 例子的,如果你完全迷失了,看看幻灯片11,想想如何将这个例子转换成一个 Point 类,在这个类中,我不仅有一个属性,而是多个属性,不仅在构造函数中有一个参数,而是多个参数。如果你能做到这一点,是的,如果你能够用 Point 替换 Course,用 x 或 y 替换 Object,那么你几乎就完成了,这里真的不难。如果你有任何问题,请举手,我们的助教会四处走动。如果你在线观看我们,请在 Iris 或沟通渠道中提问。
时间到了,请再次听讲。我将展示一个示例解决方案,我们可以快速讨论其含义。
这是我们的示例解决方案。我们定义了一个带有泛型类型参数 T 的类 Point,你也可以使用任何其他字母,但我们在这里使用了 T。我们有两个类型为 T 的属性 x 和 y,我们有一个构造函数,在其中传递同样类型为 T 的 x 和 y,并简单地将其分配给属性。当然,如果你感兴趣,也可以让 IDE 生成相应的 getter 和 setter。我们现在可以使用它,例如,写 Point<Integer> p1 = new Point<>(1, 2); 或者 Point<Double> p2 = new Point<>(1.5, 2.3);。
关于这个有什么问题吗?好的,问题是,你能在这里使用不同的类型和不同的字母,甚至多个吗?理论上是的,并且可能会给你满分。但通常你会希望确保两个属性具有相同的类型。否则,可能会出现第一个是 Integer,第二个是 Double 的情况。然后以后进行计算可能会很困难,对吧?所以在这个特定情况下,我会坚持使用一种特定类型。事实上,这不是一个完美的解决方案。我们稍后会看到如何改进它,因为现在,你基本上可以使用任何东西。你可以有一个由字符串组成的 Point,这并没有真正的意义。虽然你可以说,嘿,这是这个类的方法和构造函数的用户的责任,要确保它有用,但也有办法限制这里将使用哪些类型。我们稍后会学习到。
问题是,如果你使用 Object x 而不是 T 会发生什么?那么,你将很难实现 shift、rotate 以及我们之前仅为 double 实现的所有其他方法,因为在每种情况下,你都需要检查实际类型是什么,你需要向下转型为 Integer、Double 或其他类型,因为你无法在 Object 上进行所有计算,如果有一个对象数据类型,你无法直接进行 x + y 这样的操作。
很好。到此,我们将短暂休息10分钟,然后继续。


028:泛型(第二部分)

在本节课中,我们将继续深入学习泛型。我们将探讨泛型的更多方面,包括类型擦除、有界泛型以及一个更复杂的练习。通过本课,你将能更好地理解泛型,并学会如何在实际项目中应用它们。
类型擦除与运行时
上一节我们介绍了泛型的基本概念,本节中我们来看看泛型在Java中是如何实现的。理解这一点对于避免常见错误至关重要。
泛型被引入Java是为了提供更好的类型安全性和灵活性。编译器在编译时会进行更严格的类型检查。然而,泛型的实现实际上在编译时进行了类型擦除。这意味着在运行时,类型信息不再可用。
所有类型参数在编译时都会被替换:
- 如果定义了上界,则替换为该上界。
- 如果类型参数是无界的,则替换为最通用的超类
Object。
为了保持类型安全并防止编译问题,有时会进行必要的类型转换。在某些涉及多态性的特定情况下,编译器甚至会生成一些桥接方法以确保一切正常工作。
对于初学者来说,需要知道的关键点是:类型仅在编译时存在,在运行时不存在。因此,你不能进行 instanceof 检查,也不能将 List<String> 转换为 List<Object>,反之亦然。
以下是一个类型擦除的示例:
// 编译前(源代码)
public <T> List<T> merge(List<T> list1, List<T> list2) { ... }
// 编译后(概念上的表示,类型T被擦除)
public List<Object> merge(List<Object> list1, List<Object> list2) { ... }
泛型方法
泛型不仅限于类。你可以定义独立的泛型方法,而无需让整个类都成为泛型类。
以下是一个泛型方法的示例:
public class Tutor {
public static <T, U, V> void printEmailParts(T localPart, U domain, V extension) {
System.out.println(localPart + "@" + domain + "." + extension);
}
}
在这个例子中,我们有一个 Tutor 类,它包含一个静态的泛型方法 printEmailParts。我们可能希望这些部分能适用于不同的类型(例如,本地部分可能是字符串,域名可能是整数),因此我们将其定义为泛型方法。
定义泛型方法时,你总是在方法返回类型之前、修饰符(如 public static)之后声明类型参数。然后,你就可以在方法参数或方法实现中使用这些类型参数。
请注意:泛型方法不一定非要位于泛型类中。但是,如果类中有泛型属性,那么该类本身必须是泛型类。
泛型的优势
我们已经简要讨论了泛型的优势,以下是更详细的总结:
- 代码重用性:泛型允许我们编写可重用于多种数据类型的代码,避免了代码重复及其带来的维护问题和潜在错误。
- 通用算法实现:Java的排序算法就是一个很好的例子。它可以用于任何实现了
Comparable接口的数据类型,这得益于接口和泛型的结合。 - 更强的编译时检查:使用泛型可以避免因向下转型而导致的
ClassCastException,也无需进行繁琐的instanceof检查。 - 提高代码安全性:如果向一个
List<String>错误地添加一个Integer,编译器会立即报错,而不是在程序运行时才崩溃。
以下是一个展示类型安全性的对比示例:
// 不使用泛型(不安全)
List list = new ArrayList();
list.add("Hello");
list.add(10); // 编译器不会报错
String s = (String) list.get(1); // 运行时抛出 ClassCastException
// 使用泛型(安全)
List<String> list = new ArrayList<>();
list.add("Hello");
list.add(10); // 编译错误!无法添加整数
有界泛型
有时,我们希望限制可以传递给类型参数的类型种类。这可以通过有界泛型来实现。
例如,在我们之前提到的 Point 类中,我们可能不希望传入 String 类型。我们只希望使用数字类型。我们可以这样声明:
public class Point<T extends Number> { ... }
现在,T 只能是 Number 类或其子类(如 Integer, Double 等)。
有界泛型也常用于确保类型参数具有某些方法。以下是一个 ExecutableList 的例子:
public class ExecutableList<E extends Executable> {
private E element;
private ExecutableList<E> next;
public void executeAll() {
element.execute(); // 安全,因为E上界是Executable
if (next != null) {
next.executeAll();
}
}
}
这里,我们通过 E extends Executable 确保列表中的元素都有 execute() 方法。
Comparable 接口与泛型
Comparable 接口是一个参数化的泛型接口,它允许实现它的对象之间进行比较,这对于排序等功能非常有用。
以下是如何让一个 Rectangle 类实现 Comparable 接口的示例:
public class Rectangle implements Comparable<Rectangle> {
private double area;
@Override
public int compareTo(Rectangle other) {
if (this.area > other.area) {
return 1;
} else if (this.area == other.area) {
return 0;
} else {
return -1;
}
}
}
实现 compareTo 方法后,你就可以使用 Collections.sort(rectangleList) 来对 Rectangle 列表进行排序了。
练习:通用动物收容所
现在,让我们通过一个练习来巩固所学知识。这个练习要求你创建一个通用的动物收容所。
任务概述:
创建一个泛型类 Shelter<T>,其中类型参数 T 的上界是 Animal(一个抽象类)。收容所有容量限制。你需要实现两个方法:
addAnimal(T animal):在容量未满时添加动物。makeAllAnimalsSound():调用收容所内每个动物的makeSound()方法。
已提供 Animal、Dog 和 Cat 类。你的收容所应该能处理任何 Animal 的子类,但不能处理其他类型(如 String)。
示例解决方案要点:
public class Shelter<T extends Animal> {
private final List<T> animals;
private final int capacity;
public Shelter(int capacity) {
this.animals = new ArrayList<>();
this.capacity = capacity;
}
public void addAnimal(T animal) {
if (animals.size() < capacity) {
animals.add(animal);
System.out.println("Added: " + animal.getName());
} else {
System.out.println("Shelter is full. Cannot add " + animal.getName());
}
}
public void makeAllAnimalsSound() {
for (T animal : animals) {
System.out.println(animal.getName() + " says: " + animal.makeSound());
}
}
}
关键点:
- 使用
<T extends Animal>来限制类型。 - 内部使用
List<T>来存储动物。 - 在
addAnimal和makeAllAnimalsSound中都可以安全地调用Animal类的方法(如getName(),makeSound()),因为T一定是Animal的子类。
总结





本节课中我们一起深入学习了泛型的第二部分。我们探讨了类型擦除的原理,了解了泛型仅在编译时有效。我们学习了如何定义泛型方法,以及使用有界泛型(<T extends UpperBound>)来限制类型参数,确保其具有所需的方法或属于特定的类层次结构。我们还看到了 Comparable<T> 接口如何利用泛型来实现通用排序。最后,通过“通用动物收容所”的练习,我们实践了如何设计和使用一个有界泛型类来解决实际问题。掌握这些概念将帮助你编写出更安全、更灵活、更可重用的代码。
029:对象数据类型

在本节课中,我们将深入学习Java中的对象数据类型,包括包装类、String类的常用方法,以及集合框架中的Map接口及其应用。
概述
我们已经了解了不同的包装类,如Boolean、Character以及六种基于整数和浮点数的类型。它们具有不同的精度和内存长度。这些包装类帮助我们使用泛型,例如List、Point或Shelter。它们也帮助我们以更面向对象的方式工作。另一方面,它们存在一些性能和内存上的缺点。存储它们需要更多内存,包装和拆箱值被视为额外的开销。因此,如果你的应用程序对性能要求很高,你可能不希望使用它们。如果你想完全面向对象并使用接口、抽象类、继承和泛型的全部思想,那么你应该使用包装类。
包装类有一个典型的构造函数,你可以基于基本类型定义类型。例如,Integer对应int,Double对应double。还有一个构造函数允许你将字符串解析为这种数据类型。例如,我可以使用new Integer("5")。然后,构造函数会自动将字符串解析为整数,如果不可能(例如使用了不属于整数的字母),则会抛出NumberFormatException。构造函数会返回一个字符串对应的Integer对象。因此,它基本上允许你在不调用方法的情况下自动转换。
它们也提供了一个对应的解析方法,但在许多情况下,构造函数可能更方便。
所有包装类都提供特定的equals方法。如果对象包含与另一个对象相同的整数值(对于Integer)或相同的双精度值(对于Double),则返回true。其他类也有类似的功能。
例如,Float类在解析失败时也会抛出NumberFormatException。当然,Float可能包含字母,但必须遵循非常特定的格式。如果不可能,构造函数会失败。
除了Character,所有我们使用的类都有静态解析方法。你可以传递一个字符串,这与构造函数的工作方式非常相似。因此,除了使用带字符串的new Integer,我也可以使用Integer.parseInt。
它们都有常量MIN_VALUE和MAX_VALUE,以便你使用这些值。这对于基本数据类型来说非常方便。基本数据类型没有这个功能,它们会溢出,并且你不能轻易地获得它们的最大值和最小值。
Character类还提供了额外的辅助函数。例如,你可以识别数字、转换为小写字母、大写字母等。同样,这种面向对象的思维方式使其功能强大,并为你提供了可以重用的基本功能,而无需自己实现。
所有数值包装类,如Integer、Float,都有一个共同的抽象超类Number。我们已经见过它。因此,你不能真正创建一个Number对象,但它允许我们重用某些代码,并在处理泛型数据类型时将它们用作泛型上界。但存在我们之前看到的限制:Number不允许以泛型方式使用简单的数学计算,如加、减等。这在Java中实际上是缺失的。其他编程语言提供了类似的功能,但在Java中,不幸的是,这是不可能的。
一些特定的额外方面:Double和Float提供负无穷大、正无穷大以及表示非数字NaN的包装。这基本上发生在你将double或float值除以0时。有时你会得到算术异常,因为不能除以0,而对于这两个类型,你会得到NaN作为返回值。因此,如果你将double值打印到控制台,它会显示NaN,你需要确保能够处理这种情况。
还有测试无穷大和是否为非数字的方法。你可以使用isInfinite和isNaN方法进行检查。它们作为静态方法可用于Double和Float,你需要传递double或float值,但也可以作为对象方法使用。因此,如果你确定对象不为null,可以使用对象方法。如果对象可能为null,使用静态类方法更安全。
Java中另一个由JVM本身提供的重要对象数据类型是String。你已经经常使用它,我们想给你介绍更多方法。String实际上提供了许多不同的方法,我们在这里只收集了最重要的几个。你可以在在线参考文档中找到更多。
一个有趣的方法是indexOf。如果你想获取字符串或子字符串在字符串中的索引,可以使用这个方法。你可以使用一个参数或两个参数(从特定索引开始)。例如,如果你知道这是一个长字符串,某些单词多次出现,但你对第一个不感兴趣,只对某个时间点后的第一次出现感兴趣,那么可以使用第二个方法。
你可以使用charAt方法获取特定索引处的字符。例如,如果你有"hello",你想获取索引0处的字符,那将是'H'。索引1处是'E',依此类推。这同样是由从0开始计数的数组支持的。它返回一个char,但可以转换为Character包装类,或者如果你想要一个字符串,也可以再次转换。
你可以用新字符替换单个字符。这对于字符串操作非常有用。请注意,你总是会得到一个新的字符串。String是一个不可变类。你不能更改类内部的字符串。它总是会返回一个新的字符串,而现有的字符串保持不变。这非常重要。
你可以基于特定的开始索引(包含)和结束索引(不包含)获取子字符串。因此,结束索引本身不会被包含在新子字符串中。
非常强大的是,你可以基于某些单词、简单字符(如空格或逗号)甚至正则表达式来分割字符串。你将在后面的研讨会中了解更多关于正则表达式的知识。现在,你基本上可以使用split方法。例如,如果你有一个逗号分隔值文件(CSV,顺便说一下,这就是Excel背后的格式),那么为了获取所有字符串,你可以说split基于逗号分割大字符串。然后你会得到一个包含所有值的字符串数组。
最后但同样重要的是,你可以去除空白字符。这在处理用户界面时非常方便,因为有时用户输入他们的名字,但后面有空格,或者有时有额外的空白字符,如制表符或换行符,你基本上想摆脱它们,strip方法可以为你做到这一点,但只针对末尾的所有尾随空白和开头的所有前导空白。因此,如果你有一个中间有空格的句子,这不会被移除,但开头和结尾的所有内容都会被移除。然后你就不需要处理它了。
还有许多其他方法,但这些都是你应该能够使用的最重要的方法。因此,如果有家庭作业练习,或者在团队项目中有监督练习,我们假设你知道这些方法并且可以使用它们。但同样,有时有多种方法可以实现结果。并不是说特定的练习要求你只能使用这个特定的方法。不,有时你有多种方法来实现这一点。
好消息是在IntelliJ中,我想再次向你展示,因为我恳请你仔细观看我如何操作。这些是浏览代码的重要技能。在IntelliJ中,你也可以轻松地跳转到String的定义以及如何使用它。如果我悬停在它上面,我会得到文档。String类表示字符串。Java程序中的所有字符串字面量都作为此类的实例实现,因此字符串是常量等等。你可以阅读所有内容。这是一个很大的文档。这已经非常强大了,只需悬停一下。然后,如果你在Mac上按Command键点击,或在Windows上按Ctrl键点击,你可以跳转到这个类的源代码,并查看我们在这里有哪些方法和属性。当然,这有点麻烦。你不需要理解所有这些代码,但我们也可以打开结构,然后浏览它,看看我们有哪些方法。当然,如果你有一个字符串,希望你知道这一点。像这样,你点击点号,然后你会得到一个下拉菜单,其中包含该类提供的所有不同方法。这通常按优先级排序,因此更常调用的方法排在顶部。然后在某个时候,它基本上按字母顺序列出它们。然后你甚至可以输入并根据你开始输入的内容获得推荐,你想使用哪种方法。这非常强大,经验丰富的程序员掌握了这项技能。他们之所以如此快,是因为他们可以快速浏览代码并理解发生了什么。
这也是我想向你展示的,如果你想理解谁在调用你的代码。假设我们有一个Shape。让我看看这个,如果你想理解谁在调用这个方法,你也可以按Command键点击它。不幸的是,没有人使用它,但也许我会转到Shape,然后我看到Shape,如果我按Command或Control键点击它,它在两个不同的地方被使用,所以我也可以获取使用情况,然后导航回去。如果我现在在这里写一些Java文档,Shape是抽象的,应该被子类化。然后我也可以悬停在上面,在这里获得文档。这非常强大。确保你理解这一点,以便能够浏览代码。这最终会让你变得更快,快得多。
这是一个如何分割字符串的例子。假设你有这个逗号分隔值字符串:"1,2,3,4",然后你想获取数组中的所有值并计算它们的总和。你可以调用split方法并提供逗号作为正则表达式或单词来分割它。然后我们可以使用增强型for循环遍历所有字符串值,并使用Integer.parseInt方法,或者使用new Integer方法来获取其整数值,并使用+=运算符在我们的变量sum中求和,然后这将输出10。如果我添加,5,输出会是什么?15,没错。因此,这里有一个关于我们刚刚学到的split方法和之前见过的parseInt方法的非常好的例子。当然,parseInt可能会抛出异常,我们将在下一个单元学习如何处理这些异常。
我们已经讨论过Java集合框架,它在处理这些不同的对象数据类型时非常方便,因为它基于泛型。我们已经介绍了如何使用Set、如何使用List。我们基本上实现了自己的Queue,但集合框架已经有一个Queue,可能在方法调用方面API略有不同,但你仍然可以使用它。你不必自己创建Queue。这很好,这是我们可以使用的东西。我们尚未涵盖的是Map接口及其具体实现。这是我们接下来要做的事情。因此,我们想看看Map是如何工作的。然后会有一个关于Map的小练习,你可以学习或练习如何使用它。
java.util.Map是一个泛型类型,它有两个类型定义:键K和值V。它是一个接口。我们现在实际上知道接口的含义,因此它指定了实现类(如HashMap)需要提供的某些方法。有了接口,我们实际上可以轻松地在以后交换实现。因此,每当我们使用接口时,我们可以轻松地交换实现。这里的接口通常是静态类型或编译时类型。而实现,如HashMap或LinkedHashMap或你可能想到的任何其他东西,是new运算符旁边的运行时类型。这样,我们确保以后只使用接口提供的方法,而不依赖于具体实现的任何东西。
Map类似于数学函数。你有一个输入,并将其映射到一个输出,一个键到一个值。重要的是,键基本上遵循一个集合,因此每个键只能出现一次。在普通的Map中,不可能有多个值对应同一个键。总是一个键对应一个值。这可以用于缓存结果。例如,如果我有一个计算量很大的结果,并且我确定以后需要它,有人可能再次调用它,我可以缓存它。我可以将输入数据作为键缓存,输出数据作为值。这就是Map在实践中也被大量使用的地方。
当然,我们有不同的实现,许多人使用的最常见的是HashMap。然后我们可以使用某些方法。首先,我们需要定义Map。现在你看到我们有两个元素。在这个例子中,我们有String和Integer,但我们可以使用任何我们想要的类型。甚至我们定义的自定义类型也可以在这里使用。如果我们使用String和Integer,例如,我们可以用单词计数来计数单词,put是添加元素的方法。我们向Map中放入一些东西。我们可以添加"university"作为键,10作为值。我们可以添加"highbraron"作为键,5作为值。当我们这样做时,我们实际上用键值对填充了Map。现在,如果我们再次放入具有相同键的元素,那么原始值将被新值覆盖。因此,put要么插入一个新的键值对,要么用新值覆盖现有的一个,因为每个键只出现一次。
我们可以调用size来找出Map中有多少元素。我们可以使用get方法获取特定键的值,并将其打印到控制台,例如这里的10。如果我们对一个不属于Map的键这样做,我们会得到null,然后我们也会在这里打印null。因此,"moonian"还不是单词计数的一部分,所以我们得到null。我们也可以使用增强型for循环,但直接在Map上使用它有点麻烦。这是可能的,但那样你需要使用Entry,这有点奇怪。大多数时候,你希望基于键或值进行迭代,这要容易得多。因此,你通常会这样做:for (String word : wordCount.keySet())。然后word是键,然后你可以调用get方法来获取值。在某些情况下,你也喜欢只遍历值,然后你可以调用wordCount.values()。
快速提问给那些还没有翻到下一页的人:keySet会返回什么?values会返回什么?它会返回什么类型?我的意思是,这个方法叫做keySet,那么它可能返回什么?我们可以循环迭代的多个键。但它是什么数据类型?String?不,不是String。int?不,int不行,你不能迭代int。问题是:它是数组、列表、集合还是其他东西?它是一个集合,没错。这个方法叫做keySet。它为你提供了Map中使用的键的集合。因此,Map一次只有一个键,这类似于集合,集合一次只有一个元素。我们不能在集合中有多个相同的值。
现在,values方法返回什么?任何想法?数组、键列表、集合、列表、另一个Map?任何想法?这是一个列表。为什么是列表?因为可能有多个键具有相同的值。这是完全可以的。因此,我们可以添加"moonian"。我们可以说wordCount.put("moonian", 5),然后又是5。这是完全可以的。如果我们获取所有值,这意味着它们可以多次出现。因此,集合不合适,我们需要使用列表。这是其背后的基本语义。
这里有一个最重要方法的概述。我们可以获取大小。我们可以检查Map是否为空。我们可以放入一个键值对。我们可以获取给定键的值。我们可以移除某些键。我们可以检查Map是否包含一个键,以及是否包含一个值。我们可以获取键集合,如我们所见,这是一个集合。我们可以获取值,它返回一个集合,但实际上是列表。我们可以清空Map并移除所有键及其值。
关于Map数据类型和你在这里看到的所有方法,有什么问题吗?这是一个重要的数据类型,你可能在未来的许多程序中使用它。因此,我们想练习它,并希望现在在练习场中做以下练习。这不是一个Artemis练习。你需要在你的练习场中解决它。想法是,我们有一个静态方法叫做factorial。这个factorial方法基本上计算一个正整数的阶乘数。阶乘数的定义是,例如,5的阶乘是5乘以4乘以3乘以2乘以1,即120。这个方法使用for循环来计算。现在,问题是计算这个相当昂贵,特别是对于大数字。为了改进这一点,我们希望您使用Map作为缓存机制。
因此,请创建一个HashMap,键为Long作为输入(我们插入的数字,如5),值也是Long。这是方法的输出。5的阶乘是120。为了改进性能,我们只在缓存或Map还没有结果时计算阶乘。如果它有结果,我们只需获取结果并立即返回。这就是想法。好的,将此复制并粘贴到你的练习场中。我也会这样做。我们可以先创建一个main函数并调用它,我们说factorial(5)。然后,我说System.out.println(factorial(5))。然后我们试试看。然后,它应该返回120。当然,你也可以做更大的数字。例如,25,我不确定是否能显示。是的,可以。如果你做到55,仍然不是超级慢,但数字越来越大。当然,这导致循环中有55次迭代。所以这绝对不是最快的。如果你做到,我不知道,100。也许让我们做个疯狂的数字1255。然后我们得到0。为什么会这样?因为我们基本上溢出了long,这不再有意义。所以让我们做些合理的。让我们用40做两次。这意味着我们计算了两次。对于40,我们已经溢出了。所以你看数字,它们真的增长很快。所以让我们用20。对于20,我们得到两个值,它们完全没问题。但这意味着我们需要做两次。现在我恳请你使用一个Map。如果结果已经可用,立即返回。如果没有,你只需进行正常计算,但在计算结束时,将其放入Map中。
因此,待办事项如下:实例化一个Map。这是你的第一个待办事项。第二个待办事项:检查Map是否包含键(数字)。如果是,返回它的值。否则,我们只需进行计算,然后这里有第三个待办事项:在Map中存储一个新的键(数字)和值(我们的阶乘结果)。基本上就是这样。将代码复制并粘贴到你的练习场中。保持实现不变。但在开始时做一些事情来提高性能,并确保在最后,如果你没有命中Map并且不能立即返回,你将相应地存储结果。你现在有大约5到10分钟的时间来完成这个,然后我将向你展示一个示例解决方案。
好的,请暂停工作,再听一下,以便我们可以快速查看示例解决方案。这是它的工作原理,你看到我在这里做得略有不同。基本上,事先创建了Map。当然,这是不正确的。我不能在这里面创建它。实例化Map必须事先完成,否则之后它将不可用。抱歉这个错误,但我们会在这里澄清,这是因为它是一个静态方法,这也需要是静态的,并且可以是final的。这是可选的,它是一个键为Long、值为Long的Map,我们称之为cache,它是一个新的HashMap。现在,我们可以在factorial方法的开头检查缓存是否包含基于输入(基于我们这里的数字)的键。如果是这种情况,我们打印它并立即使用get方法返回值。如果不是这种情况,我们在这里进行正常计算,然后在最后捕获结果。
让我们看看是否有效。所以如果我现在用这个示例解决方案更新我的实现。我需要导入Map和HashMap才能使其工作,所以确保你有import java.util.HashMap;和import java.util.Map;。现在,我可以再次调用它,并用20做五次。然后我看到我做了多少次乘法?它们基于for循环中的额外打印语句。我在这里做了很多次。所以这是我们例子中的昂贵部分,但对于第二次计算,我命中了缓存,没有乘法等等。这基本上就是这里的想法。
关于示例解决方案有什么问题吗?有什么不清楚的吗?如何使用Map?所以,再次强调,重要的是,我们在函数外部定义它。在开头,我们检查对于给定的输入是否已经有输出。如果是,我们立即返回。如果不是,我们进行昂贵的计算,然后在最后缓存结果。在实践中,这些缓存经常被使用,然后你需要考虑何时使缓存中的结果失效,缓存实际使用多少内存,你从中节省了多少性能,这总是一个权衡。因此,即使你有缓存,你也需要维护它,并且如果结果过时,你可能还需要删除它。

如果没有更多问题,我们将再做一个短暂的休息,并在5点05分继续今天的最后一个单元。
030:错误处理 🛡️

在本节课中,我们将要学习编程中一个至关重要的主题:错误处理。我们将了解不同类型的错误,学习如何使用Java的异常处理机制来捕获和处理它们,从而使我们的程序更加健壮和稳定。
概述
在之前的课程中,我们已经多次遇到过异常。现在,是时候深入学习错误处理了。理解错误处理对于编写可靠的软件至关重要,因为它能防止程序因意外情况而崩溃,并为用户提供更好的体验。
错误类型
在编程中,存在不同类型的错误。其中一种是所谓的运行时错误。运行时错误是在程序执行期间发生的异常,会导致程序终止。
当然,这并不理想。如果最终用户想要使用你开发的软件,却因为异常导致软件终止而无法使用,这可能会是一个严重的问题。因此,我们需要能够处理这些错误。
首先,我们应尽量编写不会导致错误的代码。但在计算机科学中,我们认识到要使软件完全无错误几乎是不可能的。软件过于复杂,变化过于频繁,我们无法保证这一点。
因此,我们提出了不同的错误处理方法。既然无法使软件完全无错误,我们至少可以提供某种错误处理机制。通过这种方式,我们希望能解决最重要的问题。
在Java中,我们使用 Throwable 类来实现这一点。Throwable 基本上捕获了所有类型的运行时错误,任何可以像异常一样被抛出的东西都可以被你的程序代码捕获。
在你的程序代码中,你可以捕获异常。例如,如果某些代码中出现了空指针异常,你可以捕获它并尝试从该状态中恢复。或者,如果你期望一个表示整数的字符串,但最终用户输入了其他内容,导致数字格式异常,你也可以捕获它并防止程序终止。
错误对象在Java中也是对象,遵循类结构,并可以形成继承层次结构。所有这些错误都可以被捕获并适当处理。
实际上,异常条件主要有三类:
- 受检异常:这些异常要求你必须处理它们,不能忽略。
- 非受检异常:有些人也称之为运行时异常,例如空指针异常、数组索引越界异常。对于这些异常,你不被要求处理,但有时主动处理它们是有意义的。这是你的选择,你可以忽略它们,也可以提供代码来处理它们。
- 错误:错误代表严重且有时无法恢复的情况,例如不同库之间的不兼容性、内存泄漏、内存不足异常、无限循环或无限递归。这些通常无法被适当处理,程序会终止,你对此无能为力,只能重启程序,调查错误发生的原因,并尝试在未来预防它。
异常处理机制
通过明确分离正常的程序流程和错误处理,我们可以很好地区分这两个方面。正常的程序流程应该高效、清晰、易于理解。
我们通过关注这里的继承层次结构来实现这一点。Throwable 是所有不同类型异常或错误的超类。对于异常,我们有受检和非受检两种。这总是取决于特定的类型。运行时异常是非受检的,所有其他异常都是受检的,你需要处理它们。
Throwable 是所有异常和错误的超类,它允许我们定义多个子类。两个直接子类是 Error 和 Exception。
以下是一个在方法定义中使用异常的例子:
public void readFile(String fileName) throws IOException {
// ... 读取文件的代码
}
如果一个方法基于给定的文件名读取文件,这可能会导致 IOException。IOException 是一个受检异常。因此,如果有人调用这个 readFile 方法,你需要使用 try-catch 块来处理异常,或者重新抛出它。但最终,链上的某个点必须处理它,除非你完全不处理。即使你的 main 函数稍后抛出这个异常,编译器也会帮助你,并在异常未被处理时通知你。
运行时异常则不需要被处理。你可以处理,但不必这样做。
未处理错误的情况
如果我们不做任何错误处理会发生什么?没有错误处理意味着程序会终止。
这里有一个例子,一个名为 Zero 的类,其中有两个基本整数值,我们进行除以0的操作:
public class Zero {
public static void main(String[] args) {
int x = 5;
int y = 0;
int result = x / y; // 这里会抛出 ArithmeticException
}
}
如果我们用 int 类型这样做,我们会得到一个 ArithmeticException。这是我们会遇到的错误类型,每个异常通常也会返回一个错误消息。当我们抛出异常时,我们通常会定义一个消息字符串。这里字符串是“除以0”。Java总是告诉我们这个异常发生在哪个位置。如果在开发阶段发生这种情况,我们可以简单地点击它,跳转到那个位置,轻松修复它,这非常方便。
当你有这样的异常语句时,它通常会告诉你这个异常发生在哪个线程中。目前,我们只有一个线程,但在更复杂的程序中,你会有多个线程。了解异常发生在哪个线程中也很有用。你有错误类型的类名、消息、堆栈跟踪,甚至实际错误发生的行号,这样我们就可以直接跳转到那里。
错误处理示例
如果你不希望程序终止,你必须捕获错误并处理它。
假设我们不自己定义 x 和 y,而是让最终用户定义它们。那么,我们可以这样处理:
import java.util.Scanner;
public class InputExample {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
int number;
while (true) {
System.out.print("请输入一个数字: ");
String input = scanner.nextLine();
try {
number = Integer.parseInt(input);
break; // 如果成功,跳出循环
} catch (NumberFormatException e) {
System.out.println("输入错误,请输入一个有效的整数。");
}
}
System.out.println("你输入的数字是: " + number);
scanner.close();
}
}
我们使用 Scanner。我们可以基于 Scanner 的输入调用 parseInt 方法。parseInt 方法会抛出我们之前在幻灯片中看到的 NumberFormatException。我们不知道用户会输入什么。如果用户输入字母“A”,异常就会被抛出。如果用户输入数字“5”,则不会抛出异常。我们事先不知道,所以如果用户输入无效数据,我们需要提供错误处理。
当然,如果用户不能输入无效数据会更好,但有时这超出了我们的控制。现在假设用户可以输入无效数据,异常发生,然后通过 try-catch 机制或 try-catch 块,我们可以捕获异常,程序可以继续运行。
我们在这里做什么?try 块中任何会导致异常的代码都可以在 catch 块中被捕获。如果异常是特定类型或其任何子类,就会发生匹配。这里我们有一种模式匹配。如果发生 NumberFormatException,代码会立即跳过 try 块中的其他语句,跳入 catch 块,并执行 catch 块中的代码。
在这里,我们告诉用户输出“输入错误,我们需要一个数字,而不是字符串或其他内容”。基于 while (true) 循环,我们现在可以重复这个过程,直到用户提供一个有效的数字。
请将此代码复制粘贴到你的编程环境中并尝试运行,你会看到它是如何工作的。
异常处理器的结构
异常处理器总是由一个 try 块组成,后跟一个或多个所谓的 catch 规则。每个 catch 条目都是一个规则。规则是:如果异常是这个 catch 块的实例,我们就跳入其中;如果不是,我们就转到下一个。我们依次检查,如果找不到匹配的,程序将终止,或者可能调用另一个错误处理机制。
处理器按顺序搜索所有不同的 catch 规则,直到抛出的错误对象基于其类本身或任何子类被匹配。这又类似于 instanceof 检查。如果错误是某个 catch 块类型的实例,则执行该块。这就是其背后的意图。
每个 catch 规则都有这种形式:catch,然后是一个异常类型和一个名称(你可以命名为 e、error,或者你喜欢的任何名称)。只有当这个异常或其子类发生时,才会调用这个块。如果没有适用的 catch 规则,错误将被传播,并可能终止程序。
调用栈与异常传播
在编程中,我们有一个叫做调用栈的东西。调用栈是通过进入 main 方法形成的,main 方法总是在调用栈的底部。如果 main 方法调用另一个方法,这个方法就被放在调用栈的顶部。方法调用的理念是:我跳入一个方法,如果方法结束,我就跳回。当然,我可以串联这个操作,从方法A跳到B到C到D到E,然后跳回,再到F,再跳回,再到C,再跳回。
我们调用栈中的某些方法可能会使用 try-catch 错误处理,有些则可能不会。这取决于实现,我们是否使用它。
假设我们在第一个方法中有一个 try-catch 块,然后有一些代码和另一个 try-catch 块,接着是方法C,它再次调用方法C(这是一个递归函数调用),最后我们调用方法F。这是我们程序当前的状态。
现在假设在方法F中发生了一个异常(空指针、数字格式等任何类型的异常)。那么调用栈将立即缩短到下一个 catch 块。这很重要:中间的所有代码都不会再执行。调用栈中可能有100个条目,它们都会被跳过,我们跳回到第一个有 try-catch 块的地方。然后运行时会检查:catch 规则适用吗?它适用于这个特定的异常吗?如果是,我们就在这里做些什么。如果不是,我们就转到下一个 try-catch 块。我们将错误传播回链中,并尝试在那里匹配规则。如果仍然不匹配,程序将终止。没有人处理这个错误,所以我们无能为力,必须退出。
如果错误被捕获,那么我们可以执行更多代码,并可能恢复,通知最终用户输入错误或应用我们想要的其他类型的错误处理。
这是一个非常重要的编程原则:如果发生错误,你回退到第一个错误处理程序。如果不行,你再回退,最终程序可能退出。
finally 关键字
有时,你不想跳过所有的 catch 块,或者你想在最后进行某种清理。这就是 finally 关键字的用武之地。通过 finally,你可以确保某段代码被调用,无论是否发生异常。
因此,我们有了某种保证,清理工作会被完成,例如关闭输入输出流或做其他事情。finally 块中的语句在任何情况下都会被执行:如果没有抛出错误,它们在 try 块结束后执行;如果抛出错误并被 catch 规则处理,它们在 catch 块之后执行;如果错误未被处理,finally 块仍然会被执行,然后错误被传递到链的上游。
理解这一点背后的理论很重要,然后你可以实践并尝试。
自定义异常
你也可以定义自己的异常。我们有一个 KilledException,它扩展了普通的 Exception。我们只需要提供一个构造函数,当有消息传递时,我们简单地将其传递给超类。
在这个例子中,KilledException 只是为我们提供了一种可以处理的新错误类型。现在我们不需要用空指针异常或除以0来伪造错误,我们可以自己抛出一个异常。每当我们认为某些地方不对劲或某些事情没有按预期进行时,我们就可以抛出一个异常。
在 kill 方法中,我们不再做空指针操作,而是说 throw new KilledException("Kill the program")。这也可以被捕获。我们可以尝试这段代码,尝试调用 kill 方法。在 catch 规则中,我们有 RuntimeException。KilledException 不是 RuntimeException,它不扩展 RuntimeException,它扩展了普通的 Exception。所以第一个不会被匹配,但第二个是我们要找的,它会被匹配并调用,打印出“Killed it”。它会打印出对象的类型和消息。你也可以在异常上调用 getMessage() 并简单地打印这个消息,或者你可以检查消息并根据不同的消息类型进行不同的处理。
你会看到 throw 是一个新的关键字。你可以抛出新的异常。你会看到这再次使用了对象语法。我们创建一个新对象,然后抛出它,运行时会相应地处理它。我们应该提供具体的消息说明发生了什么,这样收到异常的开发者才能理解发生了什么。你在这里提供的信息越具体,其他人处理这种情况就越容易。
你可以有任何类型的自定义错误。通常你会说一个 XYException 作为 Exception 的子类。我们通常有两个构造函数:一个空的(你不必实现),以及一个带消息的(你应该在以后使用这个)。然后你可以说 throw new XYException,最好带上消息。然后它被抛出,运行时系统必须根据我们刚刚讨论的逻辑来处理它。
异常处理的最佳实践
Java中的错误是对象,可以由程序本身处理。我们有 try、catch、finally 语句,它允许我们清晰地将 catch 块中的错误处理部分与 try 块中的正常程序执行部分分开。这样清晰地分离,我们不会将它们混在一起。通过 finally 块,我们可以进行清理。
通常,预定义的错误类型就足够了。Java中有许多不同的异常,但有时你需要自己的自定义错误,然后你可以自己创建它们。
非常重要的一点是,异常不应用于修复编程错误。每当你有一个编程错误,比如空指针异常、除以0,这真的是你的错误,不依赖于最终用户的输入。你应该修复程序代码,而不应该只是让异常发生,然后在其他地方处理它。这不是我们的本意。我们进行错误处理是为了特定的目的,以避免程序终止,但它并不意味着要掩盖糟糕的代码体验。所以,修复糟糕的代码,确保你的代码正确工作。只有在不知道错误会发生,或者你在自己的代码中抛出特定的错误,或者运行时系统因为文件不存在而抛出错误时,才使用错误处理。
你还需要非常小心,因为Java的错误机制只应用于错误处理。安装一个处理程序是廉价的,但捕获异常另一方面是昂贵的。所以你的正常程序流程不应包含异常。用它来打破方法链并在调用栈中通信回去是很诱人的,但你不应该这样做。这是非常糟糕的行为和糟糕的代码。不要用异常来混淆程序执行。这真的非常不好。
在你的 catch 块中,可能会发生另一个异常。或者在你的 finally 块中,可能会发生另一个异常。你应该避免这种情况,因为那样你会在 catch 块中又有另一个 try-catch 块。我们可以无限地这样做下去。这不是我们的本意。所以保持简单。catch 块应该简单。finally 块应该简单,理想情况下,它们不应导致任何类型的错误。
通常,捕获特定的错误比捕获一般的错误(如 catch (Exception e))更好。使用 catch (Exception e) 可以捕获任何类型的错误(除了那些无法处理的内存不足错误)。但另一方面,有时你捕获得太多,捕获了你不负责处理的错误,而链上游的其他人想要处理。如果你阻止了这一点,你也可能使你的代码变糟。
练习:年龄验证
在我们结束之前,我们还有一个小的练习让你熟悉异常。这不会花很长时间。我们再次在编程环境中进行,我们请你编写一个程序来验证成年人访问权限,例如他们想买一些酒精,或者做一些儿童不允许做的事情,比如去听讲座。
我们这里有一个小模板供你复制。它叫做 public class Age。这个 Age 类有一个方法 checkAge。我们希望您重用 IllegalAccessException。如果年龄低于18岁,你就抛出它。如果这个人已经是成年人,你就不抛出它。
然后我们希望您调用 checkAge 方法并适当地处理异常。如果这对您来说太简单,我们有一个可选的挑战:您可以创建自己的异常类。我们称之为 Below18Exception 并使用它来代替 IllegalAccessException。
打开您的编程环境,将代码复制粘贴到 IntelliJ 中。让我们现在一起快速完成这个。
复制粘贴 Age 类到您的编程环境中。然后使用它。我们在这里做的是:如果年龄小于18,我们基本上说 throw new。您会看到,这太棒了。throw 您。现在我得到了所有通常使用的不同异常。我们使用 IllegalAccessException,因为孩子试图非法访问某些东西,如酒精、吸烟、参加选举、开车等。然后我们可以在这里传递一个参数,比如“你太年轻了,等你18岁再来”。
现在您看到代码无法编译。为什么?因为 IllegalAccessException 是一个受检异常。它需要被处理。现在我们的方法没有处理它。我们可以做什么?我们有两个选择:要么将其嵌入一个 try-catch 块中,但这并不合理。更合适的是将异常添加到方法签名中。我们需要告诉Java这个方法可能抛出某些东西,并且没有处理它。
现在我们可以实际调用它了。我们可以创建我们的 main 函数,并用年龄17调用 checkAge。现在它又是一个受检异常,所以Java告诉你:“嘿,未处理的异常,这不行,我不会让你通过编译过程。” 然后你又有两个选择。简单的一个是:“嘿,我不在乎。” main 也抛出它。但当然,如果我们现在运行这个程序,它会崩溃。这当然不是我们想要的。所以这不是一个好主意。在这种情况下,你实际上需要去“更多操作”并说“用 try-catch 包围”,看,这太棒了,我甚至不需要知道它是如何工作的。集成开发环境为我完成了所有工作。
但知道你有这个 try 块和 catch 块是很好的。然后我们可以处理它。这是默认的解决方案。它抛出并给你运行时异常。也许我们可以打印出消息,这是简单的部分。我们说 System.out.println(e.getMessage())。现在,如果我们再次调用代码,它会说“你太年轻了,等你18岁再来”。这就是它的工作原理。
我们也可以用20来调用它,并提供一个 else 块。说 System.out.println("你已经足够大了。")。然后如果我们调用这个,我们会看到一旦 checkAge 导致异常,它就说“你太年轻了,等你18岁再来”。在第二种情况下,由于某种原因,这个没有被打印。当然,它没有被打印,因为它跳过了它,进入了 catch 块,所以我们必须在另一个 try-catch 块中再次做这个。
所以,我们有了我们的方法,它抛出一个异常,因为发生了不应该发生的事情。然后我们尝试代码是否有效,捕获错误,如果无效,我们可以继续。
对于可选的挑战,如果你不想使用 IllegalAccessException,因为它实际上意味着做其他事情,我们也可以创建自己的异常。让我快速完成这个。我创建一个新的Java类 Below18Exception。这个 Below18Exception 需要扩展 Exception。我可以完全留空,但通常带消息会更好。只需用消息调用 super。
现在我可以回到我的主程序。我现在有了我的异常。IntelliJ 也以不同的方式显示它。现在在我的 Age 类中,我可以说我不抛出非法异常,我抛出一个 Below18Exception,然后当然我现在需要更改 catch 块。我可以添加另一个,如果我想要的话。这也是 IntelliJ 向我展示的,Below18Exception 作为第二个在这里,要么用这种连接方式,要么我可以做第二个 catch 块,并适当地处理它。
让我们暂时省略第二种情况。它抱怨什么?这只是一个警告,因为 IllegalAccessException 从未被抛出,它并不是真正必要的。我也可以把它放在这里,即使它在这里没有被抛出,我现在有两个,当然只有第二个 catch 会被匹配,第一个会被忽略。你也可以调试这个,我们基本上可以在这里设置断点,看看发生了什么。所以如果我们调试应用程序,我们看到我们跳入了这个 try 块。我们看到我们的调用栈中只有一个方法。现在,如果我们跳过,我们有第二个,main 在底部,checkAge 在顶部。我们直接跳入 throw 块,然后跳入这个 catch 块,然后返回,即使没有 return 语句。所以 throw 语句也结束了方法,但它基本上传播这个异常,直到有人处理错误。
关于这个小小的互动练习就讲这么多。幻灯片上也有一些示例解决方案。你可以看到带有一些注释的代码,以及带有 Below18Exception 的示例解决方案。
总结

本节课中我们一起学习了错误处理。我们了解了不同类型的错误(受检异常、非受检异常和错误),并掌握了使用 try-catch-finally 块来捕获和处理异常的核心机制。我们学习了异常如何在调用栈中传播,以及如何创建自定义异常来更精确地表示特定的错误情况。最重要的是,我们明白了异常处理应用于处理预期之外的、无法完全避免的错误情况(如用户输入错误、文件不存在等),而不是用于掩盖程序逻辑本身的缺陷。通过合理的错误处理,我们可以使程序更加健壮,为用户提供更好的体验。
031:继承(第三部分)

在本节课中,我们将复习和实践面向对象编程的核心概念,特别是继承。我们将通过一个具体的编程练习来巩固对抽象类、方法重写和构造函数的理解。
课程回顾:继承
上一节我们介绍了继承的基本概念。继承是一种机制,允许子类从父类(或超类)继承功能或字段属性。
实现继承的关键字是 extends。在方法定义中,子类通过 extends 关键字来继承超类。
class ChildClass extends ParentClass {
// 类体
}
这种机制有助于代码复用。当父类和子类使用相同的参数或属性时,我们无需多次定义它们。
关于访问父类参数,访问修饰符 public 和 protected 允许子类直接访问这些字段。如果使用 private 修饰符,则必须通过 getter 方法等方式来访问。
我们可以在超类中为方法提供基础实现。如果子类需要不同的实现,它们可以重写该方法的内容。重写时使用 @Override 注解。
class Animal {
public void speak() {
System.out.println("The animal makes a sound.");
}
}
class Dog extends Animal {
@Override
public void speak() {
System.out.println("The dog barks.");
}
}
在主类中创建对象并调用方法时,将根据对象的运行时类型执行相应的方法。
public class Main {
public static void main(String[] args) {
Animal myDog = new Dog();
myDog.speak(); // 输出: The dog barks.
}
}
练习:定义抽象类
现在,让我们通过一个练习来应用这些概念。以下是练习的具体要求。
你需要定义一个名为 Decoration 的抽象类。
- 它包含一个名为
color的字符串属性。 - 它包含一个构造函数,用于初始化该类及
color属性。 - 它包含一个具体方法
describe,用于输出文本(例如:“This is a decoration”)。 - 它包含一个抽象方法
display,该方法没有具体实现。
接着,创建一个名为 Star 的子类来扩展 Decoration 类。
- 该类需要重写(实现)
describe和display方法,并提供不同的输出。
练习实现步骤
以下是实现该练习的步骤。
- 创建抽象类
Decoration。 - 在
Decoration类中声明color属性。 - 为
Decoration类生成构造函数。 - 实现具体的
describe方法。 - 声明抽象的
display方法。 - 创建子类
Star并extends Decoration。 - 为
Star类生成构造函数,并调用父类构造函数。 - 使用
@Override注解重写describe和display方法,并提供新的实现。 - 在主类中创建
Star对象并调用其方法以查看输出。
以下是完整的代码示例:
// 1. 定义抽象超类
abstract class Decoration {
String color; // 2. 声明属性
// 3. 构造函数
public Decoration(String color) {
this.color = color;
}
// 4. 具体方法
public void describe() {
System.out.println("This is a decoration.");
}
// 5. 抽象方法
public abstract void display();
}
// 6. 定义子类
class Star extends Decoration {
// 7. 子类构造函数
public Star(String color) {
super(color); // 调用父类构造函数
}
// 8. 重写 describe 方法
@Override
public void describe() {
System.out.println("This is a " + color + " star.");
}
// 8. 重写(实现) display 方法
@Override
public void display() {
System.out.println("The " + color + " star is sparkling.");
}
}
// 主类
public class Main {
public static void main(String[] args) {
// 9. 创建对象并测试
Star goldStar = new Star("golden");
goldStar.describe(); // 输出: This is a golden star.
goldStar.display(); // 输出: The golden star is sparkling.
}
}
关于访问修饰符的说明:在上述代码中,color 属性使用了默认的包私有访问修饰符,因此子类 Star 可以直接访问它。如果将其改为 private,则子类必须通过 getter 方法来访问该属性。
总结
本节课中,我们一起复习和实践了面向对象编程中的继承概念。我们通过定义一个抽象类 Decoration 及其子类 Star,练习了以下内容:
- 使用
extends关键字实现继承。 - 在抽象类中定义具体方法和抽象方法。
- 在子类中使用
@Override注解重写父类方法。 - 理解构造函数链,通过
super()调用父类构造函数。 - 观察运行时多态性如何根据对象实际类型执行相应的方法。

通过这个练习,你应该对如何利用继承来组织代码、实现代码复用和多态有了更清晰的认识。
032:抽象类、接口与多态

在本节课中,我们将要学习面向对象编程中的三个核心概念:抽象类、接口与多态。这些概念是构建灵活、可扩展软件架构的基础。
抽象类 🎄
上一节我们介绍了继承的概念。本节中,我们来看看抽象类。抽象类与普通类的主要区别在于,抽象类可以包含抽象方法。
抽象方法是一个没有具体实现的方法,其实现被延迟到子类中完成。这允许我们在超类中定义一个方法签名,而将具体的实现细节留给各个子类。其语法如下:
abstract void sparkle();
在左侧的示例中,我们定义了一个抽象方法 abstract void sparkle()。这意味着我们在超类中声明了 sparkle 方法,但没有提供方法体。
在右侧的示例中,ChristmasTree 类继承了 ChristmasDecoration 类。由于超类中包含抽象方法 sparkle,子类必须提供该方法的实现。这里我们提供的实现是打印“树用明亮的彩灯闪烁”。
这样做的好处是,如果我们后续有多种不同的装饰品,每个子类都可以有自己的 sparkle 方法实现,但它们都通过共同的超类共享这个方法。
需要记住的重要一点是,抽象方法在超类中没有定义方法体。
接口 ✨
现在,我们继续讨论接口。你可以将接口视为一组方法的集合,任何实现该接口的类都必须实现这些方法。
你可能会问,既然可以在抽象类中定义10个不同的抽象方法,为什么还需要接口?原因在于,两个类可以有相同的行为,但不一定需要通过同一个超类来关联。
例如,我可能想要一个既能发光又能装饰的东西,但它不一定继承自“装饰品”这个超类。在我们的示例中,我们在接口里定义了两个之前提到的方法。在实现这个接口的类中,我们将提供具体的实现。
一个重要的区别是:一个类只能继承自一个超类,但可以实现多个接口。例如,在我们的案例中,如果想实现多个接口,只需在 Decorative 后面加一个逗号,然后列出其他想要实现的接口。
以下是实现接口的示例:
public class MyClass implements Shiny, AnotherInterface {
// 必须实现所有接口中定义的方法
}
需要注意的一点是,你必须实现接口中定义的所有方法,不能只选择实现其中一部分。
多态 🔄
我们今天要回顾的最后一个概念是多态。你们已经在之前的例子中见过方法重写,这是多态的一种形式。
解释多态最好的方式是通过例子。当我们谈论方法重写时,本质上是指即使在超类中已经定义了具体的实现,你仍然可以在子类中为同一个方法提供不同的实现。当你实例化一个子类对象时,将调用子类中的具体实现。
另一个要点是方法重载,我们这里有一个例子。方法重载基本上意味着你可以有相同的方法签名,但参数不同。如下所示:
void displayType() {
System.out.println("This is a Christmas decoration.");
}
void displayType(String type) {
System.out.println("This is a " + type + " decoration.");
}
最大的区别在于参数。第一个方法 void displayType() 不带参数,打印默认信息。第二个方法 void displayType(String type) 带一个参数,输出会根据参数变化。当你调用这些方法时,根据括号内提供的参数不同,将调用不同的方法。
练习:扩展程序
现在,我们将用以下任务来扩展之前编写的程序。
首先,添加一个名为 Shiny 的接口。这个接口应该有一个名为 shine 的方法,该方法打印关于装饰品如何发光的独特信息。你可以自由选择打印什么内容。
其次,让我们的 Star 类实现 Shiny 接口,同样可以选择打印内容。
然后,创建一个名为 Bauble 的新类,它将继承 Decoration 类并实现 Shiny 接口。
最后,修改主方法。我们将创建一个 Shiny 对象数组来存储装饰品,然后遍历数组,对每个对象调用 shine 和 describe 方法,观察打印结果。
以下是实现步骤的简要说明:
- 创建
Shiny接口,并声明shine方法。 - 在
Star类中使用implements Shiny,并实现shine方法。 - 创建
Bauble类,使用extends Decoration implements Shiny,并实现构造函数及shine方法。 - 在主方法中,创建
Shiny[]数组,放入Star和Bauble的实例。 - 使用 for 循环遍历数组,对每个对象调用
shine()。如果对象是Decoration的实例,则调用describe()。
通过这个练习,我们定义了一个包含 shine 方法的接口,创建了两个实现该接口并继承抽象类的类,并实践了方法重写和重载的概念。
总结
本节课中我们一起学习了面向对象编程的三个关键概念。
我们回顾了继承,即子类可以从父类继承属性和方法。我们介绍了抽象类,它包含抽象方法,将实现延迟到具体的子类。我们还介绍了接口,它用于将行为分组,类可以选择实现一个或多个接口。
关于多态,我们学习了方法重写和方法重载。方法重写允许子类为超类中的方法提供不同实现。方法重载允许同一方法名根据参数不同调用不同的实现。

你应该能够运用这些知识来完成后续的练习,并在考试中应用它们。
033:泛型(第三部分) 🧩

在本节课中,我们将深入学习Java泛型。泛型是Java中一个强大但可能有些难以理解的概念。我们将通过回顾和练习,帮助你掌握如何在自己的类中使用泛型,以及如何应用泛型边界和方法级泛型。
概述
泛型允许我们创建可以处理多种数据类型的类、接口和方法,同时保持类型安全。你已经在使用集合(如 List<String>)时接触过泛型。本节课,我们将学习如何为自己的类定义泛型,如何限制泛型类型,以及如何在方法级别使用泛型。
泛型基础回顾
上一节我们介绍了泛型的基本概念。本节中,我们来看看泛型的具体语法和用法。
每当你看到尖括号 <>,就表示在Java中使用了泛型。使用泛型很简单:在类名后加上尖括号,并在其中指定动态类型。
代码示例:
List<String> stringList = new ArrayList<>();
List<Train> trainList = new ArrayList<>();
你可以使用同一个 List 类,但通过尖括号指定不同的数据类型(如 String 或 Train)。这与数组不同,数组在创建时就固定了类型。泛型允许一个类动态地决定其内部可以容纳的数据类型。
为自定义类创建泛型
了解了基础用法后,现在我们来学习如何为你自己的类添加泛型功能。
定义泛型类非常简单:在类名后添加尖括号和一个类型参数名。在Java中,通常使用单个大写字母(如 T)作为类型参数名,以明确表示这是一个泛型类型。
代码示例:
public class Container<T> {
// ... 类内容
}
定义后,你可以在类中将 T 当作任何其他类型来使用:作为返回类型、方法参数、变量或属性。
代码示例:
public class Container<T> {
private T content; // 属性
public T getContent() { // 返回类型
return content;
}
public void setContent(T content) { // 参数类型
this.content = content;
}
}
泛型边界
有时,你希望泛型类型有一定的限制。例如,你可能希望一个列表中的元素可以相互比较以便排序。这时,你可以使用泛型边界。
泛型边界使用 extends 关键字,这与继承的概念紧密相关。它要求类型参数必须是某个特定类或接口的子类/实现类。
公式/代码描述:
<T extends Comparable<T>>
以下是如何在类定义中使用边界的示例:
代码示例:
public class SortedContainer<T extends Comparable<T>> {
private T content;
public boolean isGreaterThan(T other) {
// 因为 T 扩展了 Comparable<T>,所以可以调用 compareTo 方法
return content.compareTo(other) > 0;
}
}
通过 T extends Comparable<T>,我们确保了 T 类型的对象一定实现了 compareTo 方法,从而可以在代码中安全地调用它。
方法级泛型
除了在类级别使用泛型,你还可以在单个方法上定义泛型类型。这在工具方法中非常有用。
方法级泛型的定义位置在方法修饰符(如 public static)之后,返回类型之前。
代码示例:
public class Utility {
public static <T> T printAndReturn(T item) {
System.out.println(item);
return item;
}
}
当你调用 printAndReturn(new Seal()) 时,编译器知道 T 是 Seal 类型,因此返回值也是 Seal 类型。你也可以在方法级泛型上使用边界。
代码示例:
public static <T extends Animal> T handleAnimal(T animal) {
// 此方法只接受 Animal 及其子类
return animal;
}
练习:容器类
为了巩固所学知识,我们来进行一个练习。以下是练习的具体步骤:
任务:
- 创建一个名为
Container的泛型类,它有一个类型参数T。 - 该类应有一个类型为
T的content属性。 - 为该类添加构造方法以及
getter和setter方法(可以命名为load和unload)。 - 创建一个静态方法
transfer。该方法应使用方法级泛型来确保传入的两个Container对象具有相同的类型参数C。 transfer方法应交换两个容器的内容。- 在
main方法中测试你的实现。
提示: 静态方法不能直接使用类级别的泛型参数 T,因为它属于类而不是对象。你需要在方法上声明自己的泛型参数,例如 <C>。
练习解析与深入讨论
让我们一步步解析练习中的关键点,并解答一些常见问题。
首先,我们创建 Container 类:
代码示例:
public class Container<T> {
private T content;
public Container() {}
public void load(T content) {
this.content = content;
}
public T unload() {
T temp = this.content;
this.content = null;
return temp;
}
}
接下来,实现静态的 transfer 方法。注意,我们使用新的泛型参数 C 来避免与类级别的 T 混淆。
代码示例:
public static <C> void transfer(Container<C> from, Container<C> to) {
C item = from.unload();
// 假设这里将 item 装入 to
// 实际练习中需要你完成交换逻辑
}
为什么 transfer 方法必须是静态的?
这个练习特意设计为静态方法,是为了演示方法级泛型的用法。当然,你也可以将其设计为非静态的实例方法。
transfer 方法可以放在任何类中吗?
是的。只要这个方法不依赖所在类的特定状态(即只使用传入的参数和它们的公共方法),它就可以放在代码库的任何地方。
关于类型安全:
泛型就像给容器贴上了“标签”。如果你创建了一个 Container<Seal>,Java 编译器会阻止你向其中装入 Mouse 对象,从而在编译期就保证了类型安全。
使用泛型边界:
你可以让 Container 只接受特定类型的对象,例如只接受 Animal 的子类:
代码示例:
public class AnimalContainer<T extends Animal> {
private T content;
// ...
}
这样,AnimalContainer<Seal> 和 AnimalContainer<Mouse> 都是有效的,因为它们都扩展了 Animal。
高级挑战:
你可以尝试使用多个泛型参数,并应用更复杂的边界,来创建更灵活的API。例如,一个方法可以接受任何 Container 及其子类型,并操作其内容。这涉及到更高级的泛型组合技巧。
总结


本节课我们一起深入学习了Java泛型。我们回顾了泛型的基本语法,学习了如何为自定义类添加泛型参数,如何使用 extends 关键字设置泛型边界以限制类型,以及如何在静态方法或工具方法上定义方法级泛型。通过“容器类”的练习,我们实践了这些概念,并理解了泛型在编译时提供类型安全的重要性。掌握泛型将极大地提高你代码的复用性和安全性。
034:监督测试练习指南

在本节课中,我们将详细介绍即将到来的监督测试练习(Supervised Exercise)的所有重要信息、规则和准备事项。请仔细阅读,以确保你为测试做好充分准备。
概述
监督测试练习是课程的重要组成部分,旨在评估你对所学编程概念的理解和应用能力。本节将涵盖测试的时间、地点、规则、技术设置要求以及有效的备考策略。
测试基本信息
下一周的监督测试练习将于周二晚上7点开始。我们建议你最晚在6点45分到达指定的加兴(Garching)校区教室,以便有足够时间进行设备设置。考虑到U6地铁有时会延误,请务必提前规划行程,预留缓冲时间。
官方答题时间为90分钟,但我们会额外提供10分钟以应对可能的技术问题。如果你没有遇到技术问题,这10分钟也可用作答题时间。
测试总分为100分。根据时间分配,你大约需要每9分钟完成10分的题目。例如,一道30分的题目,你应计划花费约27分钟。
练习顺序与时间管理
练习的顺序是随机的,并非由易到难排列。因此,我们建议你快速浏览所有题目,制定答题计划,并尽量遵守为每道题分配的时间。
我们已预先估算阅读题目、模板代码以及平均学生解题所需的时间。虽然这无法为每位学生量身定制,但我们尽力为大家提供充足的答题时间。测试的目的不是进行一场仓促的考试,而是让你有时间认真思考和解决问题。
地点与设备要求
所有监督测试练习都将在加兴校区进行,不提供慕尼黑市中心的考场。你必须前往指定的教室参加测试,未经现场监考人员许可,不得随意更换教室。
我们强烈建议使用个人笔记本电脑。请确保:
- 电脑电量充足。
- 集成开发环境(IDE)和Wi-Fi设置妥当。
- 能够连接到校园网(Eduroam),并准备好在VPN(Byon)上使用的备用方案。
如果你因故无法使用个人电脑,可以使用位于FMI大楼(Rashnahalle)的机房电脑。机房里约有80-90台电脑,但它们的设置特定,你需要提前熟悉。若计划使用机房电脑,请务必在周日晚上之前通过第28张幻灯片上的邮箱地址联系我们,以便我们做出相应安排。
技术故障处理
如果测试期间电脑出现故障(例如,系统自动更新),请立即联系监考人员。他们会尝试帮你快速解决。如果问题无法解决,你可以转移到机房使用备用电脑。因此产生的额外时间(如步行至机房的时间)会获得相应的补时。
Artemis平台会保存所有工作。对于编程练习,请定期推送(Push)你的代码。这样,即使设备出现故障,你已提交的大部分更改也会被保存,可以在机房的电脑上从该阶段继续答题。
考试规则与行为准则
本次测试为开卷考试,你可以使用任何资源,但必须独立完成,严禁以下行为:
- 使用任何基于人工智能(AI)的工具(如OpenAI ChatGPT、GitHub Copilot等)。
- 在线发布问题(如Stack Overflow、GitHub)或使用任何聊天工具(如WhatsApp、Facebook)与他人交流。
- 从家中远程参与测试。必须在指定教室现场参加,监考人员会进行核实。
任何作弊或可疑行为(包括未经允许的交流、使用AI工具、远程参与等)都将被视为学术不端,并依据考试规则受到严厉处罚,可能导致该模块直接判定为不合格。
特别提醒:IDE中的AI功能
你必须禁用IDE中的所有AI辅助功能(如IntelliJ IDEA中的AI助手)。我们已提供详细的禁用教程。监考人员可能会在考场内检查你的IDE设置。使用AI功能将被视同作弊,没有借口可言。
测试内容与评分
所有练习都侧重于理解和解决问题的能力。如果你习惯于死记硬背,这种方法在此行不通。最佳备考方式是完成我们提供的所有练习(作业、课堂练习、研讨会练习、辅导课练习),如果仍不自信,可以在Artemis的练习模式下重做。
测试中将包含编程练习。你需要在IDE中克隆(Clone)题目,完成后提交(Commit and Push)更改。至关重要的一点是:你的代码必须在我们的构建服务上编译通过。 在测试期间,你只会收到代码是否编译成功的反馈,而不会获得正确性评分。
如果代码编译失败,无论错误多么微小(例如缺少一个分号),该练习都将获得零分。 这是一条不可更改的硬性规定,旨在让你尽早适应编程工作的严谨性。


实用准备建议
为确保测试顺利进行,请提前做好以下准备:
- 设备检查:确保拥有可用的浏览器、JDK 17、正常工作的IDE(推荐IntelliJ IDEA)和Git。
- 系统更新:禁用操作系统的重要更新,以防测试期间被打断。
- 关闭无关程序:完全关闭所有与测试无关的应用程序和窗口。
- 网络测试:提前测试并确保能成功连接校园网(Eduroam)。
- 禁用通知:关闭所有社交软件、通讯工具的通知,避免干扰和嫌疑。
- 充电与配件:为电脑充满电,可自带充电器和插线板。如果使用蓝牙鼠标,确保其有电。
- 单显示器规则:测试期间只允许使用一个显示器,不得使用智能手机、平板、外接显示器等第二块屏幕。
问答环节摘要
以下是针对学生常见问题的解答:
- 能否听音乐? 原则上可以戴耳机听,但为避免干扰他人及难以监管(如接听电话),建议不要听。
- 可以推送代码多少次? 次数不限,但推送后需等待构建结果(可能20-30秒),请合理管理时间。
- 是否需要引用外部代码? 如果大量借鉴或改编了外部代码,最好引用。但题目设计上通常无需外部代码。
- 能否使用在线编译器? 只要是不含AI功能的集成开发环境(在线或离线均可),原则上允许。但最终评分以代码在我们的系统上编译结果为准。
- 各练习之间是否关联? 不,每个练习独立。但一个练习内的多个任务之间可能存在依赖。
- 编译错误是否影响其他练习? 不影响。一个练习的编译错误不会导致其他练习得零分。
- 以哪次提交为准? 以最后一次提交为准。强烈建议不要在最后一刻提交,以免因系统繁忙或最后时刻引入错误而失分。
- IDE的基本代码补全功能是否允许? 允许。例如,自动生成构造函数、Getter/Setter方法、
System.out.println的代码补全等,这些是IDE的标准功能,不被视为AI辅助。
测试练习与后续安排

现在,我们将进行一次模拟监督测试练习,让你熟悉Artemis考试模式、界面和流程。模拟测试的题目取自往年真题,难度和内容与正式测试类似。你可以利用接下来的时间尝试,也可以稍后在提供的第二次机会中完成。



请注意,在正式监督测试期间,你将无法就题目内容提问,Artemis中的Iris助手也会被禁用。
理论部分到此结束。接下来还有两次辅导课练习和两次作业,请在下周三研讨会开始前一小时提交。请抓住机会在辅导课上完成演示,因为圣诞节假期后,课程将更侧重于项目工作。
总结


本节课我们一起详细了解了第一次监督测试练习的全部关键信息。核心要点包括:测试的时间地点、必须使用个人电脑并做好充分技术准备、严格遵守独立完成和禁用AI的规则、理解编译失败即零分的评分原则,以及通过完成所有既有练习来有效备考的策略。请认真对待这些指导,提前做好准备,祝你在测试中取得好成绩。现在,请开始体验模拟测试练习以熟悉环境。
035:迭代器与集合


欢迎来到软件工程导论课程的第八次研讨会:函数式编程基础。课程已进行过半,我们昨天完成了第一次监督练习。希望你有良好的体验,并对课程内容感到满意。今天我们将聚焦于迭代器、集合,以及如何利用它们为内置数据类型和你自己的数据类型(基于接口)编写 for-each 循环。我们还将讨论 switch 表达式,这是一种更现代的控制流方式。你将学习匿名内部类和 Lambda 表达式,这些是当今非常重要的函数式编程概念,能帮助你更快速地完成任务。我们希望在本周结束时,你能够实现自己的 Lambda 表达式,因为当你想使用流(Streams)来编写更简洁的代码时,这些知识也至关重要。
让我们开始吧,首先从迭代器和集合讲起。
迭代器与可迭代接口
实现对象的有序集合有多种方式。我们有字符串,它是字符的有序集合。你见过列表和数组,它们是预定义类型对象的有序集合。即使是列表,也是以一种非常通用的方式实现的,因此你可以将它们用于任何类型。我们还见过集合(Set)、映射(Map),你基本上也可以创建自己的数据类型。
无论它们是有序还是无序,大多数时候你希望以特定顺序访问单个元素。为了做到这一点,重要的是知道之后是否还有另一个元素,以便能够跳转到下一个元素,并在处理完当前元素后将其移除。
由于这是一个反复出现的实现和想法,开发者为此设计了一个接口,这个接口叫做 Iterable。它是一个泛型接口,因此可以应用于任何类型。其核心思想是,除了可以定义相应的构造函数外,它还有三个方法:hasNext(用于检查实现此接口的可迭代对象是否有下一个元素)、next(用于导航到下一个元素)和 remove(用于移除当前选中的元素)。这基本上就是可迭代接口的理念,它也将是 for-each 循环的基础,稍后你将看到。
该接口还定义了 iterator 方法。这个方法返回一个 Iterator(迭代器)对象,这个对象稍后允许你遍历可迭代数据结构中的所有元素。这里需要非常小心:Iterable 是集合(Collection)的一个属性,一个集合可以是可迭代的;而 Iterator 是一个独立的对象,负责遍历集合。它们基本上是协同工作的:Iterable 定义了我们的集合,而 Iterator 是另一个可以遍历集合的对象。
我们可以看一个简单的例子:IterableString,我们基于 Character 类(记住,它是 Java 中 char 元素的包装类)来实现 Iterable。我们通过提供一个可以初始化字符串的构造函数,然后返回一个迭代器来实现。这个迭代器是我们自己的实现 IterableStringIterator,我们将在下一张幻灯片中看到它是如何工作的。我们看到 IterableStringIterator 实现了 Iterator 接口。
所以,Iterable 元素本质上是一个字符集合,我们可以遍历它。然后我们有 Iterator,一个实现了 Iterator 接口的独立类,它由 IterableString 返回。这里我们创建了一个新的 IterableStringIterator,以便使用它。
这个 IterableStringIterator 现在实现了我们在上一张幻灯片中提到的三个方法:hasNext、next 和 remove。我们不实现 remove,因为字符串不支持修改(字符串是不可变的)。如果你有自己的数据结构,如列表或数组,你可能会实现它。但现在为了保持简单,我们想专注于 hasNext 和 next。
hasNext 基本上告诉你是否已经遍历完字符串。我们存储一个当前位置 position,初始设置为 0,因为我们从字符串开头开始遍历。如果当前位置仍然小于数据结构(本例中是字符串)的长度,则返回 true;否则,如果我们在末尾,则返回 false,因为没有下一个元素(没有下一个字符)了。
next 方法基本上有一些安全机制。如果 position 等于字符串长度,那么就没有下一个元素了,因为调用者可能在没有事先验证 hasNext 的情况下直接调用 next。然后我们抛出 NoSuchElementException。如果不是这种情况(即我们基本上小于长度),那么我们返回字符串在当前位置的字符,之后将 position 增加 1。我们有一个指向当前元素的指针,返回这个元素,然后将指针后移一位。如果我们第二次调用这个方法,就可以返回第二个元素,并遍历整个数据结构。
使用迭代器
以下是使用它的方法。你可以创建一个 IterableString,创建迭代器,然后可以使用 while 循环:当迭代器有下一个元素时,导航到下一个元素并在同一行打印它(不换行),最后打印一个新行。注意,这里我们使用了一个你可能没见过的新关键字 var。var 是任何类型的占位符,它基本上使用类型推断,就像我们并不真正关心编译时类型是什么,只是使用你在这里得到的类型,它允许你为更有经验的人编写更短的代码,所以它更适合放在幻灯片上,这是这里的主要原因。然后你不需要指定类型。注意,如果类型从变量名中完全清楚,这有时是好的(在这个例子中,s 可能不是一个好主意,因为 s 并没有真正告诉你它是什么数据类型),但有时它是有帮助的。开发者之间总是有很大的讨论:我们应该使用它吗?
如果你实际上遵循了 Iterable 和 Iterator 接口,那么你可以写得更短。你可以使用 for-each 循环。所以你不需要使用 while 循环,你可以使用 for-each 循环,就像我们为集合数据类型看到的那样。这基本上意味着所有可以使用 for-each 循环的集合数据类型,它们都实现了 Iterator 和 Iterable 接口。这是 Java 中允许你使用增强型 for 循环的主要机制。增强型 for 循环由 Iterable 和 Iterator 支持。理解这一点很重要。这意味着你可以创建自己的数据结构,并支持其他开发者使用 for-each 循环。
让我们快速尝试一下,打开 playground,以便你能更好地理解这一点。请跟我来,将此代码复制并粘贴到你的 playground 中。我们从 IterableString 开始,你可以直接复制粘贴到这里。它会创建一个编译错误,因为 IterableStringIterator 尚未定义。我们只需转到下一张幻灯片,复制所有元素并同时粘贴进去。然后我们就有了它。然后在主类中,我们可以复制粘贴使用代码。现在,我们也可以根据你之前的问题看看它是否正确实现。我们需要导入 Iterator。无论我们使用 while 循环还是 for-each 循环都没关系。如果我们现在运行程序,你也可以调试它。然后,当然,好的,我有一些其他不工作的代码。让我去掉其他代码,我们现在不需要它。好的,我重新开始。我们看到它工作了。所以它打印了整个元素。当然,你现在可以导航,你会看到你进入了超类或接口。这不是我们想要的。我们想进入 IterableStringIterator。你真的可以在这里设置一个断点,以更好地理解它是如何工作的。如果我们再次运行代码,我们会看到 position 是 0。0 小于长度,所以我们可以继续。然后我们看到 0 不等于字符串长度,在这种情况下会是……让我看看我是否能在这里看到。不,我们必须……是的,看,这确实是由一个数组支持的,我们必须数一下,但它绝对不等于。是的,所以我们返回元素。我们可以一直走到最后,看到它实际上在工作。现在,之前的问题是,我们是否应该在这里放 -1。让我们看看会发生什么。如果你这样做,它只打印 “hello world”。所以代码绝对是正确的。如果我们去掉这里的 -1。我们也可以再次检查 for-each 循环是否真的有效。我猜这正是 IntelliJ 在这里推荐的:用增强型 for 循环替换 while 循环,这正是我们想在这里做的。然后你会看到这也有效。即使你没有在这里调用 hasNext 和 next,我们仍然可以调试它。我们仍然可以使用调试器。让我回去。由于某种原因断点被删除了,我们仍然可以使用调试器,看到在 for 循环中,它实际上首先调用 hasNext,然后调用 next。这是一个隐藏帧。隐藏帧基本上意味着这是由 Java 编译器完成的,它们将增强型 for 循环的代码翻译成你在这里看到的 while 循环。所以在后台使用了这种机制,但对我们来说,增强型 for 循环更容易阅读和理解,这就是为什么我们可以这样写它。
很好。所以,让我们继续。让我们看看这如何融入 Java 集合框架。我们已经使用过带有泛型类型 E 的集合。我们见过 Set、List、Queue 和 Map,它们使用 Set 作为键,使用集合作为值。
现在你可以看到,实际上 Collection 接口扩展了 Iterable。记住,接口可以扩展其他接口,这实际上我们可以在代码中看到。我们可以转到我们的 IterableString,然后转到 Iterable。是的,这确实是一个非常简单的接口。现在,如果我们打开 Collection 接口并转到定义处,我们可以看到 Collection 扩展了 Iterable。Iterable 使用 Iterator,所以它有一个 Iterator,有一个对 Iterator 的引用,这也是一个接口,这就是我们如何建模的。是的,Collection 作为接口扩展了 Iterable。List 也是一个接口,扩展了 Collection,后来 ArrayList 会实现 List 接口。通过实现 List 接口,它也符合 Collection 接口和 Iterable 接口,并且也可以使用 Iterator。无论你有一个 LinkedList 还是一个 ArrayList,一个 HashSet 还是另一个 Set,这都是基本思想。所以 Iterable 和 Iterator 允许我们使用增强型 for 循环,并对遍历某些元素的含义有一个非常简洁的定义。虽然我们可以仅为 Collection 接口实现这一点,但将其分解为多个接口是有意义的,这样其他人也可以重用 Iterable 和 Iterator,而无需符合 Collection 接口。
很好。当涉及到集合时,还有一些我们可以使用的额外代码,这些代码存储在 Collections 类中(注意末尾有 ‘s’,非常重要的区别,这是一个类,不是接口)。Collections 有一些静态方法,所以这并不完全是面向对象的。这些或多或少是函数,它们允许我们以特定方式操作集合。我们可以检查两个集合是否不相交(即它们没有任何重叠)。我们可以计算集合中特定元素的频率。我们可以反转列表的顺序,这在操作数据时有时很有用。我们可以用新值替换列表中的所有旧值,这也可以用字符串完成。我们可以根据 Comparable 接口的 compareTo 方法对列表进行排序,注意这里在泛型类型中有 Comparable。我们还可以根据给定的 Comparator 对列表进行排序。这里再次看到,我们分解了事物。我们有 Iterable 和 Iterator,你刚刚在例子中见过。现在我们有 Comparable,所以一个对象可以是可比较的;我们还有 Comparator。这是编程语言中的一个常见模式,你使用这些术语。Comparable 意味着你需要指定如何比较某些东西,所以你的数据类型(如果你有一个带有 X 和 Y 的点)可以实现 Comparable,然后你可以在点内部定义如何比较两个对象。你可以说,我先比较 X 值,取较小的那个,如果 X 值相同,我再比较 Y 值。然后你就定义了如何在你的数据类型中比较对象。但这只能使用和指定一次。因此,你也有机会定义 Comparator,我们稍后会看到,这些 Comparator 可以为特定目的指定。所以你可以说,在这个特定例子中,我想按 X 排序,但也许在另一个例子中,我想按 Y 排序,你可以根据需要定义按对象的哪个属性或特征对列表进行排序。所以是非常方便的方法,我们可以用来实现某些功能。你可以在 playground 类的静态 void main 方法中找到一些示例代码。我们使用 Arrays.asList 辅助方法创建一个列表。这也是静态的。我们还基于列表创建一个集合。然后我们可以反转它,排序它,使用 disjoint、frequency 和 replaceAll 方法。你总是看到灰色的注释,输出会是什么,所以检查一下,确保你理解这一点。这些都是你以后可以使用的辅助函数。所以这真的非常非常有帮助和强大。你可以看到,有了所有这些方法,你可以快速创建一个程序,而不需要考虑如何反转某些东西,如何排序某些东西。这就是使用接口进行面向对象编程的力量。
比较器与匿名内部类
现在我们已经看到这里使用了 Comparator 接口。Comparator 定义了一个你必须实现的方法 compare。这个 compare 方法的逻辑与 Comparable 接口中的 compareTo 方法类似,它们使用相同的逻辑。它们基本上接收两个相同类型的对象并返回一个整数。如果第一个元素被认为小于第二个元素,整数通常应小于 0(即 -1)。如果两者相等(无论你如何定义,这取决于你),则应返回 0。如果第一个元素大于第二个元素,则应大于 0。这是此方法的基本语义,然后你就可以对元素进行排序。例如,如果你想对整数排序,我们可以这样做。我们可以说,我们可以创建一个列表。是的,再次使用 Arrays.asList 辅助方法。这个列表有 18, 46, 18 和 12。然后我们可以说 Collections.sort,我们刚刚看到的静态方法,再次强调是静态的。我们将对象列表传递给它,并创建一个新的 Comparator。这个新的 Comparator 现在是在行内定义的,它是一个所谓的匿名内部类。这允许我们就地定义我们想要如何排序。我们不需要稍后创建一个对象,我们只是就地完成。所以这是一种实现类并直接使用一次的新方法,基于一个接口。这个 Comparator 必须实现一个 compare 方法,对于整数,我们可以简单地通过说 i2 - i1 来实现。这是一个所谓的匿名内部类,一个新概念。所以我们定义一个类并立即实例化它一次,以便使用它,然后它基本上就消失了,对吗?所以,如果你只想实现一个接口一次,并且就地实现,你可以像这样使用它。你不需要将它提取到你程序、项目、IntelliJ 中的单独文件中,并在那里实现它然后引用它。不,你可以直接就地完成。所以匿名内部类可以使你的代码更简洁,并允许你同时声明和实例化一个类。这些类没有真正的名字,没有必要,这就是为什么它们被称为匿名内部类。你像使用局部类一样使用它们,所以它们只能在你定义它们的文件中访问,不能在其他地方使用。在 Java 中,它们实际上经常被使用。
现在,如果你的接口只声明一个方法,它被称为所谓的函数式接口。你会看到这如何引导我们走向今天的函数式编程概念。那么你就不再需要定义它了。你可以使用 Lambda 表达式,我们今天稍后会学到一点,这是对今天下一个单元的预览。如果你想做得更短更简洁,你可以像这样写,避免所有样板代码。虽然这对初学者来说更难阅读,但它更简洁。你知道,程序员喜欢如果他们能写简洁简短的代码并避免所有周围的样板,因为代码越少,需要维护的东西就越少,这是其背后的基本思想。如果你将其与上一张幻灯片比较,你会看到最重要的信息——你将两个整数映射到 i2 - i1——写在这里,并且是以你能想到的最简洁的方式。我们并不真的需要说我们想实现一个接口并想重写一个方法(如果只有一个的话),我们可以写得更短,但关于这一点稍后再谈。
在这个特定例子中,你可以使用的另一个替代方法是 Comparator.reverseOrder(),因为有一个预定义的 Comparator 用于降序排序。这对于整数来说是开箱即用的。所以甚至不需要定义它,因为你并不是第一个想对整数排序的人。许多人以前已经做过。这就是为什么他们在 Comparator 接口中提出了这个静态方法 reverseOrder,你可以使用它,它允许你降序排序列表,然后结果将与之前相同。
到目前为止有什么问题吗?很好。这是匿名内部类的另一个例子。我们有 Runnable 接口,它定义了任何可以运行的东西(通常是任务)。再次看到,它是 Runnable,就像 Iterable 和 Comparable,它们总是使用这些名字,以便更容易理解。所以 Runnable 是任何可以运行的东西,你可以通过实现 Runnable 接口来定义你自己的 Runnable。再次,我们在这里使用这个匿名内部类语句。是的,我们说 new Runnable(),基于接口名。如果你在代码中这样做,在你的 playground 中,如果你进入 main 方法,如果你说 new Runnable(),好处是即使在 IntelliJ 中,没有 AI,它也会为你完成所有需要做的事情,并自动为你补全。然后你甚至可以创建相应的方法。是的,如果你点击它,你可以说“实现方法”。然后一切都为你完成了。是的,我可以说 var 或 Runnable myRunnable = new Runnable()。是的,它们甚至建议用 Lambda 表达式替换它,但现在我们对实现这个感兴趣,所以我们不会替换它。工具支持真的很棒。然后你可以运行代码,在这个例子中,它将调用这个行内定义的方法。你仍然可以在其他地方这样做,你仍然可以说,嘿,Runnable myOwnRunnableClass implements Runnable,但大多数时候这是不必要的。
匿名内部类也可以实现多个方法。所以,如果你定义你的接口 Executable,你想执行某些东西或异步执行某些东西,这也是可能的。再次,IntelliJ 帮助你重写接口中定义的所有方法。在这种情况下,我们需要实现两个方法。此外,你可以定义你的辅助方法。所以匿名内部类就像一个真正的类,你可以定义属性,甚至可以声明更多方法。唯一的问题是,这些方法可能无法从外部访问,因为这里我们使用的是接口类型。所以,如果我现在定义额外的方法,让我们在我们的例子中这样做。是的,如果我说,这个得到一个,我不知道,一个字符串 s = "test"。是的,像这样的东西。我必须正确书写,private String s = "test"。我有另一个方法,我甚至可以把它做成 public。是的,例如 getS()。那么这段代码是不可访问的,是的,我们可以运行它,因为我们这里有接口,但我们不能调用 getS(),即使它是 public 的,因为我们没有这个类型。它是一个匿名类型,我们无法真正获取它。所以对于匿名内部类来说,这并不真正有意义。是的,当然,我们可以在这里面使用它,我们可以说 getS() 并做任何我们想做的事,但在它外面,我们无法真正访问属性和方法,因为类型是匿名的,不可用,而且也不可能将这个对象强制转换以获取运行时类型。所以再次,你必须区分编译时类型和运行时类型。
很好,让我们做一个快速练习。我们恳请你打开 Artemis 和 IntelliJ,完成这个小练习 W8E2。这是一个相对较短的练习,你有大约 10 分钟的时间来完成。想法是创建一个包含一些字符串的列表,随意使用你最喜欢的汽车品牌或任何其他类型的字符串,这取决于你。你可以使用 Arrays.asList 方法来完成这个。然后我们希望你能根据字符串的长度对列表进行排序,所以最长的汽车品牌应该排在第一位。通过使用匿名内部类来实现。所以回去看看我们在幻灯片 11 中是如何做的:Collections.sort 与 new Comparator。像幻灯片 11 那样做吗?你应该根据两件事排序:首先是最长的字符串(在这个例子中,应该是 Volkswagen),如果两个字符串长度相同,则按字母顺序排序。我们有两个提示给你:你可以在 compare 方法中使用 s2.length() - s1.length()。你可以使用 String.compareTo 方法进行字母顺序比较,你不需要自己实现。所以检查一下这个练习并完成它,如果需要帮助,导师会四处走动。大约 10 分钟后,我们继续研讨会。玩得开心。
好的,我们需要继续了。我给你看一个快速的示例解决方案,你仍然可以在今晚之前在 Artemis 上完成这个练习。这是一个示例解决方案。这是我们打算用匿名内部类实现的解决方案。注意,我们说 Collections.sort,我们传递一个列表,你可以在理论上随意命名它,然后我们有基于 String 的 new Comparator,这很重要,这不是一个整数列表,而是一个字符串列表。compare 方法的工作原理如下:我们检查两者是否具有相同的长度,然后我们使用 s1.compareTo(s2),这是我们的字母顺序排序,我们的字母顺序比较。如果不是这种情况,如果它们长度不同,那么我们返回 s2.length() - s1.length(),类似于之前整数例子中的 i2 - i1。所以,字母顺序比较是第二级比较,只有在长度相同时才进行,否则我们从最长到最短的字符串排序。
这在理论上也可以用 Lambda 表达式解决。你也不一定需要使用 Collections.sort,列表本身也有一个 sort 方法,所以这当然也是一个有效的解决方案。当然,这个例子侧重于 Collections.sort 中的 Lambda 和匿名内部类,但只是让你知道,我们可以用不同的方式写这个。然而,正如你所见,Lambda 表达式在这里有点复杂,它不是只有一个简单的一行代码,因为我们有两种排序方式。还有其他问题吗?你是指在第二个替代方案中吗?让我推迟这个问题,我们还没有介绍 Lambda 表达式,我们将在今天晚些时候详细介绍。这只是供你参考,如果你在今天研讨会后重新查看幻灯片,它会是什么样子。
还有其他问题吗?好的,这些是非常典型的练习。是的,所以假设类似这样的东西可能会出现在二月的第二次监督练习中。我们现在快速休息一下,在下午 3:10 继续第二个单元,然后我们将看看枚举类型和 switch 表达式。所以享受休息时间,几分钟后见。


036:枚举类型与 switch 表达式

在本节课中,我们将要学习两种重要的Java特性:枚举类型和switch表达式。枚举类型用于定义一组固定的常量值,而switch表达式则提供了一种更现代、更安全的方式来处理多路分支逻辑。我们将通过具体的例子来理解它们的定义、用法和优势。
枚举类型
上一节我们介绍了数据类型的基本概念,本节中我们来看看一种特殊的数据类型——枚举。枚举是一种具有有限值范围的数据类型。与字符串可以拥有任意多个值不同,有时我们希望限制一个数据类型可以拥有的值的数量。
例如,一个披萨的状态不能是任意的,它可能是“已下单”、“已就绪”和“已送达”。虽然可能还有其他状态,但我们可以先简化处理。这就是枚举的用武之地。
以下是定义一个枚举的基本语法:
public enum PizzaStatus {
ORDERED,
READY,
DELIVERED
}
在这个最简单的例子中,我们定义了一个名为PizzaStatus的枚举,它只包含三个不同的值。这比使用字符串或整数更高效,并且能确保不会出现错误的值。
使用枚举非常简单,你不需要使用new操作符来创建实例,而是可以像使用类中的静态属性一样直接引用它。例如:
PizzaStatus status = PizzaStatus.ORDERED;
我们故意用大写字母书写枚举值,因为它们是常量,其值不可改变。当然,变量status本身的值是可以改变的,你可以重新分配另一个枚举值给它。你也可以打印它。
枚举的一般模式是:public enum后跟枚举名称,然后是用逗号分隔的多个值列表。虽然不强制要求大写,但推荐使用大写字母来表示常量。
枚举的核心思想是拥有一组固定的常量。你可以在编译时修改这些常量,但在运行时无法更改。这在现实世界的编程中非常有用,例如定义一周的七天、指南针的四个方向,或者应用程序中需要跟踪的特定状态。
枚举类型确保在整个进程中,每个常量只存在一个实例。因此,我们可以安全地使用双等号==运算符来比较两个枚举变量。这也意味着枚举非常高效,它们在内部基本上是由整数支持的。因此,使用枚举比使用字符串快得多。当你需要将值的数量限制为一组已知的常量时,就可以考虑使用枚举。
如果你想给最终用户自由选择的权利,枚举就不太合适。例如,在一个用户可以输入姓名的系统中,你可能需要使用字符串,因为你无法在系统中列出所有姓名,而且未来还可能出现新的名字。所以,枚举只适用于在开发时就知道其所有可能值的场景。
枚举类型有一个.values()方法,可以获取所有枚举值。这在需要遍历所有值或向用户展示所有可能选项时非常有用。
以下是.values()方法的使用示例:
for (PizzaStatus s : PizzaStatus.values()) {
System.out.println(s);
}
枚举还可以定义属性、构造函数和方法,就像在普通类中一样。这是一个更高级的特性,并不常用。以下是一个示例:
public enum PizzaStatus {
ORDERED(5),
READY(2),
DELIVERED(0);
private int timeToDelivery;
PizzaStatus(int timeToDelivery) {
this.timeToDelivery = timeToDelivery;
}
public int getTimeToDelivery() {
return timeToDelivery;
}
}
在定义枚举值时,你实际上是在调用其构造函数,并传入相应的参数(例如5、2、0)。这意味着当你使用PizzaStatus.READY时,你也可以调用其上的方法。
你甚至可以像子类一样重写方法。虽然这是一个非常高级的例子,但在实际开发中并不常用,通常只在特定场景下使用。
Switch 语句与枚举
枚举类型特别适合与switch语句一起使用。还记得在第二个工作坊中,我们学习了控制流。如果你有多个if-else if条件,使用switch语句会非常方便。
以下是一个对枚举类型使用switch语句的例子:
public int getDeliveryTimeInDays(PizzaStatus status) {
switch (status) {
case ORDERED:
return 5;
case READY:
return 2;
case DELIVERED:
return 0;
default:
return -1; // 永远不会执行,但语法要求
}
}
在这个例子中,我们根据status变量的具体枚举值进行分支处理。
然而,switch语句存在一些弱点,我们希望改进它,以提升开发体验。
首先,如果你忘记处理某个枚举值(可能是因为没有查看所有值,或者后来有人添加了新的枚举值来实现新功能,例如新增了一个DELAYED状态),编译器不会给出警告。代码中可能会遗漏对这个状态的处理。
其次,即使你已经覆盖了所有枚举值,你仍然需要提供一个default分支,尽管它实际上永远不会被执行。这显得有些冗余。如果方法返回一个对象,在default分支中返回null会使方法变得不安全。
Switch 表达式
好消息是,我们有一个更好的替代方案——switch表达式。它从Java 14开始引入,本课程使用Java 17,所以你可以使用这个特性。switch表达式提供了更优雅的语法来解决case区分的问题。
回顾一下,在第二个工作坊中,我们区分了语句和表达式。这个概念也适用于这里:switch语句是一个纯粹的语句,而switch表达式是Java中一个真正的表达式。
在上面的switch语句例子中,我们在每个case中使用了return,返回的是整个方法的值。而switch语句本身不能返回值。但在switch表达式中,整个表达式可以返回一个值。
因此,你可以将switch表达式的结果捕获到一个变量中,或者直接返回它。它是一个真正的表达式。
switch表达式的一个优点是,它强制要求覆盖所有枚举值。如果你没有做到,编译器会报错,代码将无法编译。这意味着如果你后来修改代码,添加了一个新的枚举值DELAYED,编译器会在所有使用switch表达式的地方报错,提示你进行处理,从而确保你不会遗漏。
你仍然可以使用default分支,但对于枚举类型,这通常不是必须的,因为你会失去编译器的这项安全检查功能。
另一个重要区别是,switch语句有一个“贯穿”机制。如果你不在一个case末尾使用break或return,程序会继续执行下一个case。这对于初学者来说很难理解。而switch表达式没有这种贯穿行为,只有匹配当前条件的那个case会被执行。
假设我们添加了DELAYED,那么编译器会在这里报错:“switch表达式未覆盖所有可能的输入值”。你有两个选择:要么添加DELAYED分支,要么添加一个default分支。但添加default分支会失去编译器的覆盖检查功能。在这个例子中,我们添加DELAYED分支以使编译器满意,并确保程序没有遗漏任何实现。这在旧的switch语句中是无法实现的。
事实上,旧的switch语句应该尽量避免使用。现代IDE会提示你是否要转换为新的switch表达式语法,因为它更强大。
Switch 表达式示例
以下是一些switch表达式的额外示例,展示在幻灯片27上。
我们也可以对字符串使用switch表达式,并且可以使用一种称为“模式匹配”的特性,用逗号组合多个case。
我们可以在方法块中执行不同的操作,赋值,甚至可以在每个case后使用花括号包含多个语句。
我们也可以直接将switch表达式的结果赋值给另一个变量。但需要确保所有不同的case(以及可选的default分支)都返回一个值。
为了避免混淆方法中的return语句和switch表达式内部返回的值,开发者引入了一个新的关键字yield。在switch表达式的代码块中,使用yield来返回该分支的值,这类似于return语句,但能明确区分它属于嵌入的switch表达式。
这是一个使用yield的例子:
int numLetters = switch (day) {
case MONDAY, FRIDAY, SUNDAY -> {
System.out.println("Six");
yield 6;
}
case TUESDAY -> 7;
case THURSDAY, SATURDAY -> 8;
case WEDNESDAY -> 9;
};
这里还有一个更有意义的例子。我们可以定义一个枚举DayOfWeek,包含周一到周日。然后,我们可以返回每个星期几的字母数量。我们可以将多个case合并:周一、周五、周日返回6,周二返回7,周四、周六返回8,周三返回9。然后,我们可以将这个表达式的结果捕获到一个新变量中,并打印到控制台。
练习
现在你需要完成一个小练习:尝试使用枚举和switch表达式。
我们有一个简单的练习:创建一个switch表达式,根据给定的月份返回该月的天数。
你需要实现方法int daysOfMonth(int month)。月份用整数1到12表示,一月是1,二月是2,依此类推。
或者,这是一个可选挑战:如果你觉得用整数太简单,可以创建你自己的枚举Month,包含所有12个值。这比用整数甚至字符串存储月份要好得多,然后使用这个枚举。
请现在开始练习,你大约有10分钟时间。
示例解决方案
由于时间关系,我们现在继续。请暂停练习,你可以在今晚之前完成。我快速展示一个示例解决方案。
幻灯片31的第一部分解决了问题陈述的初始部分,即使用int类型参数。
public int daysOfMonth(int month) {
return switch (month) {
case 1, 3, 5, 7, 8, 10, 12 -> 31;
case 2 -> 28; // 暂不处理闰年
case 4, 6, 9, 11 -> 30;
default -> throw new IllegalStateException("Invalid month: " + month);
};
}
一月、三月、五月、七月、八月、十月、十二月返回31天。二月通常返回28天(我们暂不处理闰年例外)。四月、六月、九月、十一月返回30天。由于方法可能被传入负数、0或大于12的数,我们需要一个default分支,例如抛出一个IllegalStateException或其他有意义的异常。
在可选挑战中,我们实际定义了一个名为Month的枚举,包含一月、二月等值。这样对使用者来说更容易,因为他们不需要记住六月是6还是七月是7。虽然对于12个月来说可能容易记住,但在其他情况下,比如披萨状态,我可能记不清“已下单”和“已送达”哪个是状态1或状态2。因此,使用枚举值更容易识别。
其次,我们可以去掉default语句,并且仍然可以合并返回相同天数的所有case。
幻灯片32上有一些注释解释了这段代码。
关于这个示例解决方案还有问题吗?
补充说明
当我们仔细查看作为JDK一部分交付的Java框架时,会发现已经存在一个Month枚举。所以我们实际上不需要自己定义。我们在这个练习中定义它只是为了教学目的。以后如果你想处理月份,可以直接使用Java提供的Month枚举,它功能更强大,甚至能考虑闰年,在二月有29天时返回29,有28天时返回28。它还有一个length(boolean leapYear)方法,你可以传入参数,或者使用Java的日历类自动确定。因此,对于某些给定的元素,你可能不需要定义自己的枚举,Java内部已经提供了。

本节课中我们一起学习了枚举类型和switch表达式。枚举提供了一种类型安全的方式来定义一组固定的常量,而switch表达式则是一种更现代、更安全的多路分支处理方式,能借助编译器的力量避免常见错误。它们是编写健壮、清晰Java代码的重要工具。
037:Lambda 表达式教程 🧮

在本节课中,我们将要学习 Lambda 表达式。Lambda 表达式是一种编写简洁、清晰代码的强大工具,尤其适用于数据处理操作。我们将从基本概念开始,逐步探索其语法、应用场景以及相关的最佳实践。
概述
Lambda 表达式为编写代码提供了一种简洁的方式。它的核心思想是:如果一个接口只有一个方法(即函数式接口),那么你可以将其实现转换为一个 Lambda 表达式。这在遍历、过滤或提取数据等操作中特别有用,可以节省大量样板代码。
Lambda 表达式基础
上一节我们介绍了 Lambda 表达式的核心概念。本节中我们来看看它的具体语法和结构。
Lambda 表达式本质上是匿名内部类的一种快捷写法。其基本语法如下:在左侧是参数列表作为输入,中间是一个箭头 ->,右侧是方法体。方法体可以包含表达式和语句,如果是多行,则需要用花括号 {} 括起来。
以下是参数列表的几种情况:
- 如果方法没有输入参数,需要写空括号
()。 - 如果只有一个参数,可以省略括号。
- 如果有多个参数,则需要括号并用逗号分隔列表。
请注意,在 Lambda 表达式中我们不需要写明参数类型,因为类型信息已经由函数式接口定义,这样可以避免重复的样板代码。
使用示例
以下是 Lambda 表达式的一些基础应用示例。
我们有一个整数列表:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]。
除了传统的 for 循环、while 循环和增强型 for 循环,现在有了第四种选择:可以调用 forEach 方法,该方法接受一个 Lambda 表达式作为参数。
list.forEach(value -> System.out.println(value));
这个 Lambda 表达式有一个输入参数 value,代表列表中的每个元素。它遍历所有元素并打印出来。forEach 方法接受一个参数但不产生返回值。
更多 Lambda 表达式示例
上一节我们看到了一个简单的遍历示例。本节中我们来看看如何使用 Lambda 进行更复杂的操作,例如过滤和求和。
我们有一个从 1 到 9 的整数列表。
首先,我们使用 forEach 方法和一个 Lambda 表达式来过滤列表。这个 Lambda 表达式有一个输入参数 n,代表列表中的每个数字。
List<Integer> filteredList = new ArrayList<>();
list.forEach(n -> {
if (n % 3 == 0) {
filteredList.add(n);
}
});
我们使用了花括号,因为这里有一个更复杂的语句(包含 if 判断)。这段代码将能被 3 整除的数字添加到 filteredList 中,所以过滤后的列表包含 [3, 6, 9]。
接着,我们可以使用另一个 Lambda 表达式(这次没有方法体)来对这些值求和。
int sum = 0;
filteredList.forEach(n -> sum += n);
System.out.println(sum); // 输出 18
需要注意的是,如果你想在 Lambda 表达式内部使用外部变量,那么这个变量必须是 有效最终(effectively final) 的。这意味着它的值在初始化后不能被改变。因此我们使用了一个静态的 Integer sum,而不是在 Lambda 内部定义的变量。这是一个限制,因为 Lambda 表达式可能会被多次调用,它们不能轻易捕获可变状态。
函数式接口
我们已经了解到 Lambda 表达式基于函数式接口的概念。任何只有一个单一方法的接口都是函数式接口,它的实现可以被视为一个 Lambda 表达式。
这遵循了接口隔离原则,即面向对象编程的一个重要原则。这意味着在实现一个接口时,你不应被迫实现不需要的方法。如果接口只有一个方法需要实现,就完美地遵循了这一原则。
我们可以自己定义并使用函数式接口。例如,我们定义一个名为 Square 的函数式接口,并用 @FunctionalInterface 注解它(此注解是可选的,但有助于理解)。
@FunctionalInterface
interface Square {
double area(double length);
}
这个接口只有一个方法 area,用于计算正方形的面积。由于正方形的宽和高相同,我们只需要边长作为输入。
现在,你可以使用匿名内部类来实现这个接口,也可以使用更简洁的 Lambda 表达式。
Square square = length -> length * length;
这个 Lambda 表达式定义了 area 方法:输入 length,输出 length * length。然后我们可以像使用真实类的对象一样使用 square。
double area = square.area(5);
System.out.println(area); // 输出 25.0
虽然这对初学者来说可能有些难以理解,但它是一种非常简洁明了的方式来定义我们想要的操作。Java 编程语言本身已经预定义了许多函数式接口供我们使用。
方法引用
Lambda 表达式有时会让一些开发者感到繁琐,特别是当你只是想调用另一个方法,并将输入参数传递给它时。针对这种情况,可以使用方法引用(Method Reference)来进一步简化代码。
方法引用有几种类型,可以用于静态方法、特定对象的实例方法、任意对象的特定类型实例方法,甚至是构造函数。
例如,我们有一个汽车品牌列表,想要打印它们。
使用传统的 for 循环:
for (int i = 0; i < carBrands.size(); i++) {
System.out.println(carBrands.get(i));
}
使用增强型 for 循环:
for (String brand : carBrands) {
System.out.println(brand);
}
使用 forEach 和 Lambda 表达式:
carBrands.forEach(brand -> System.out.println(brand));
如果 Lambda 表达式的参数正好是传递给内部方法的参数,那么可以使用方法引用使其更简洁:
carBrands.forEach(System.out::println);
这行代码的意思是:对于列表中的每个元素,将其传递给 System.out.println 方法。方法引用让代码更加紧凑。
另一个例子是对数字列表进行排序。使用 Comparator 接口和 Lambda 表达式:
numbers.sort((a, b) -> Integer.compare(a, b));
使用方法引用可以写成:
numbers.sort(Integer::compare);
方法引用 Integer::compare 表示将两个输入参数 a 和 b 传递给 Integer.compare 方法。但这种方式可能不如 Lambda 表达式直观,因为不清楚哪个参数是调用方法的对象,哪个是方法的参数。因此,是否使用方法引用取决于个人偏好和代码清晰度。
在自定义类型中应用
Lambda 表达式和方法引用在处理自定义数据类型时也非常有用。假设我们有一个表示二维空间点的 Point 类。
class Point {
private double x;
private double y;
// 构造函数、getter、toString 方法...
}
我们创建了一个 Point 对象列表,并希望根据它们的 X 坐标进行排序。
你可以使用 Comparator.comparing 方法,并指定用于排序的属性 getter 方法:
points.sort(Comparator.comparing(Point::getX));
这将按 X 坐标升序排序。如果你想降序排序,可以调用 reversed 方法:
points.sort(Comparator.comparing(Point::getX).reversed());
你也可以轻松地改为按 Y 坐标排序:
points.sort(Comparator.comparing(Point::getY));
这种方式允许你以非常简洁的方式指定如何对自己的数据进行排序,无需实现复杂的排序算法或 Comparator 逻辑,从而在项目中实现快速原型开发。
Lambda 表达式与内部类的区别
你可能会问,Lambda 表达式和内部类有什么区别?它们在作用域方面有很大不同。
内部类总是创建一个新的作用域。你可以在其中使用属性、创建额外的方法,甚至可以使用 this 关键字(引用内部类本身)。从这个意义上说,匿名内部类比 Lambda 表达式功能更强大。
然而,大多数时候你并不需要这些功能。Lambda 表达式在函数式编程中更简单、更强大,因为你通常可以轻松地串联操作,而不关心作用域和状态。Lambda 表达式通常不持有任何状态,这对于函数式编程很重要。
另一方面,Lambda 表达式使用所谓的 封闭作用域(enclosing scope)。这意味着你无法从 Lambda 表达式体内隐藏封闭作用域中的变量(这在匿名内部类中是可能的)。在 Lambda 表达式中使用 this 关键字时,它引用的是外部类的实例,而在匿名内部类中,this 总是指向内部类本身。
最佳实践与有效最终变量
使用 Lambda 表达式时,请遵循以下最佳实践:
- 尽量保持 Lambda 表达式简短且自解释。它们通常只有一行,很少超过三到五行。
- 如果可能,避免使用代码块,写成单行形式。
- 如果只有一个输入参数,省略括号。
- 利用类型推断自动确定类型,这意味着需要使用良好的变量名来提高代码可读性。
还有一个重要的概念需要讨论:有效最终(effectively final)变量。如果你想在 Lambda 表达式中使用封闭作用域中的变量,那么这些变量必须是有效最终的。
这意味着你不需要使用 final 关键字,但如果编译器发现变量的值在某个时刻会被更改,你可能无法在 Lambda 表达式中使用它。有效最终意味着变量只被赋值一次(无论是否使用 final 关键字),并且可以安全地在 Lambda 函数内部使用,因为编译器可以控制其状态。
例如,以下代码会导致编译错误:
String value = "value1";
value = "value2"; // 值被改变
someMethod(() -> System.out.println(value)); // 编译错误:变量应为 final 或有效 final
如果需要使用值可能变化的变量,可能就不适合使用 Lambda 函数。Lambda 函数应该是线程安全的,并且不应允许任何竞态条件。这可以保护我们在多线程同时访问和修改同一值时避免简单的错误。
总结
本节课中我们一起学习了 Lambda 表达式。我们了解了 Lambda 表达式是一种为函数式接口提供简洁实现的语法糖,能够大幅减少样板代码。我们学习了其基本语法、参数列表的写法,并通过遍历、过滤、排序等示例看到了它的实际应用。
我们还探讨了与之紧密相关的方法引用,它可以在特定场景下让代码更加紧凑。同时,我们比较了 Lambda 表达式与匿名内部类的区别,重点是作用域和 this 引用的不同。最后,我们强调了使用 Lambda 表达式的最佳实践,以及必须注意的“有效最终变量”这一重要限制。

掌握 Lambda 表达式是迈向现代 Java 编程和函数式编程风格的重要一步,它将帮助你编写出更清晰、更高效的代码。
038:流

概述
在本节课中,我们将要学习一个非常有趣的编程概念——流。流在多种编程语言中都有应用,Java有其特定的实现。我们将探讨流如何与Lambda表达式结合,以函数式风格处理数据序列。
函数式编程基础
上一节我们介绍了Lambda表达式,本节中我们来看看其背后的函数式编程思想。
函数式编程深受Lambda演算的影响,它专注于数学函数,强调无状态和避免副作用。这在并发编程中尤其重要,因为多个进程或线程访问同一状态会变得复杂。函数式编程的核心思想是操作数据结构(如列表),通过map和fold(在Java中称为reduce)等函数进行转换和聚合,从而替代传统的循环结构。
Java结合了面向对象和函数式编程,通过Lambda表达式和流来实现后者。
流的概念与生命周期
流允许我们对数据集合或离散序列应用函数式风格的操作。
流的处理遵循一个清晰的生命周期或管道模型,包含三个步骤:
- 创建流:从数据源(如集合)生成一个流。
- 中间操作:形成一个操作管道,可以包含过滤、映射、排序等。这些操作可以链式调用,并且可以并行执行以优化性能。
- 终端操作:产生一个最终结果。结果可以是一个值(如总和)、一个集合或触发一个副作用。
以下是流的典型操作:
- 过滤:根据条件筛选元素。
- 映射:将每个元素转换为另一种形式。
- 排序:对元素进行排序。
- 归约:将所有元素组合为单个结果。
映射操作示例
map操作会对流中的每个元素应用一个函数,并生成一个包含结果的新流。
假设我们有一个汽车品牌列表,希望将所有品牌名转换为小写。使用传统循环需要多行代码,而使用流可以更简洁地实现。
List<String> brands = Arrays.asList("BMW", "Audi", "Mercedes");
List<String> lowerCaseBrands = brands.stream() // 1. 创建流
.map(String::toLowerCase) // 2. 中间操作:映射
.toList(); // 3. 终端操作:收集为列表
// 结果:["bmw", "audi", "mercedes"]
代码解释:list.stream()创建流,map(String::toLowerCase)将每个字符串映射为其小写形式,toList()将流收集回一个不可变列表。
过滤操作示例
filter操作根据给定的条件(一个返回布尔值的Lambda表达式)来筛选流中的元素。
我们继续使用汽车品牌列表,现在只想保留长度为4的品牌名。
List<String> brands = Arrays.asList("BMW", "Audi", "Mercedes", "Ford");
List<String> filteredBrands = brands.stream() // 1. 创建流
.filter(b -> b.length() == 4) // 2. 中间操作:过滤
.toList(); // 3. 终端操作:收集为列表
// 结果:["Audi", "Ford"]
代码解释:filter(b -> b.length() == 4)是一个Lambda表达式,输入是元素b,输出是布尔值。只有满足条件(长度为4)的元素会保留在流中。使用流过滤无需担心在遍历时修改集合导致的并发修改异常。
归约操作示例
reduce是一个终端操作,它将流中的所有元素组合起来,生成一个单一的结果。这是“MapReduce”编程模型中的“Reduce”部分。
以下是两个归约的例子:
- 求和:计算1到10的整数和。
int sum = IntStream.range(1, 11) // 创建包含1到10的整数流 .reduce(0, Integer::sum); // 归约,初始值为0,操作为求和 // 结果:55 - 求积:计算几个数的乘积。
int product = Stream.of(5, 9, 15) // 创建包含5,9,15的流 .reduce(1, (a, b) -> a * b); // 归约,初始值为1,操作为乘法 // 结果:675
归约工作原理:以乘法为例,reduce(1, (a, b) -> a * b)。
- 第一步:
a=1(初始值),b=5(第一个元素),结果1*5=5。 - 第二步:
a=5(上一步结果),b=9(第二个元素),结果5*9=45。 - 第三步:
a=45,b=15,结果45*15=675。流结束,返回最终结果675。
初始值通常是该操作的“单位元”(如加法为0,乘法为1)。
组合操作与并行流
流的强大之处在于可以轻松地将多个中间操作链接起来。我们还可以使用并行流来利用多核处理器加速处理大量数据。
下面的例子组合了map、filter和reduce操作,并使用了并行流:
int result = Stream.of(1, 2, 3, 4, 4, 5, 6)
.parallel() // 转换为并行流
.map(n -> n * 3) // 映射:每个元素乘以3
.filter(n -> n < 15) // 过滤:只保留小于15的元素
.reduce(0, Integer::sum); // 归约:求和
// 结果:42
操作步骤:
- 原始流:
[1, 2, 3, 4, 4, 5, 6] - 映射后 (
n*3):[3, 6, 9, 12, 12, 15, 18] - 过滤后 (
n<15):[3, 6, 9, 12, 12] - 归约求和 (
0+3+6+9+12+12):42
收集操作详解
collect是一个常用的终端操作,用于将流中的元素重新聚合成一个集合或其他容器。
之前使用的toList()方法会生成一个不可变列表。如果需要可变的列表,应使用Collectors.toList()。
List<String> brands = Arrays.asList("BMW", "Audi", "Mercedes");
List<String> mutableList = brands.stream()
.filter(b -> b.length() == 4)
.collect(Collectors.toList()); // 收集到可变列表
mutableList.add("Ford"); // 可以修改
重要区别:
stream().toList()-> 返回不可变列表。stream().collect(Collectors.toList())-> 返回可变列表(通常是ArrayList)。
Lambda表达式与流的关系
让我们总结一下Lambda表达式和流的区别与联系:
- Lambda表达式:是无名函数,用于简洁地实现函数式接口。格式为
(输入) -> 输出或使用方法引用。 - 流:是数据序列(如集合的视图),在其上可以应用一系列函数式操作(常使用Lambda表达式实现)。
简单来说,Lambda是定义操作的单元,而流是应用这些操作的上下文和管道。专家开发者喜欢将多个Lambda操作串联在流中,以编写出简洁而高效的代码。
编程原则回顾:SOLID
结合我们所学,可以回顾面向对象设计的SOLID原则,这些原则有助于构建易于维护和扩展的软件:
- 单一职责原则:一个类只应有一个引起变化的原因。
- 开闭原则:对扩展开放,对修改关闭。
- 里氏替换原则:子类必须能够替换其父类。
- 接口隔离原则:使用多个专门的接口,而非一个庞大臃肿的接口。函数式接口(只有一个抽象方法)是很好的例子。
- 依赖倒置原则:依赖抽象,而非具体实现。
遵循这些原则,能使代码更健壮、更灵活。
实践练习:逗号分隔符
请尝试使用流和Lambda表达式完成以下任务。
基础任务:将字符串列表中的元素用逗号连接成一个字符串。
- 输入示例:
["Hello", "World", "Hibra"] - 输出示例:
"Hello,World,Hibra"
可选挑战:
- 过滤所有以字母“H”开头的单词。
- 使用
map函数为每个字符串末尾添加感叹号“!”。
示例解决方案:
// 基础任务
List<String> words = Arrays.asList("Hello", "World", "Hibra");
String result = words.stream()
.collect(Collectors.joining(","));
// 结果: "Hello,World,Hibra"
// 完成可选挑战
String challengeResult = words.stream()
.filter(w -> w.startsWith("H"))
.map(w -> w + "!")
.collect(Collectors.joining(","));
// 结果: "Hello!,Hibra!"
总结
本节课中我们一起学习了:
- 迭代器与比较器:用于遍历和排序集合。
- 枚举类型:定义固定集合的常量,可与switch语句结合使用。
- Lambda表达式:实现函数式接口的简洁方式,核心是
输入 -> 输出。 - 流:处理数据序列的函数式管道,包含创建、中间操作和终端操作三个阶段。
map、filter、reduce和collect是核心操作。

请务必多加练习,流和Lambda表达式是现代Java开发中的重要组成部分。
039:哈希

在本节课中,我们将要学习计算机科学中的一个重要概念——哈希。我们将了解哈希的基本原理、为什么它在众多场景中被广泛使用,以及如何在实际编程中应用它。此外,我们还会简要回顾字符串处理,并介绍一个名为StringBuilder的实用工具。
概述
我们已经完成了课程计划中超过一半的内容。今天,我们将探讨算法和数据处理,例如输入输出。下周将有一个关于编程语言的研讨会,之后是圣诞假期。新年过后,我们将涵盖一些更高级的主题。因此,我们即将完成基础部分,并开始深入更多样化的高级主题。
今天的内容要求你对基础知识有良好的理解,但并非所有之前学过的主题都直接相关。例如,流对于理解今天的内容并非绝对必要。当然,拥有扎实的知识基础总是有益的。
今天的学习目标是:
- 熟悉计算机科学中称为“哈希”的重要概念。
- 能够解释哈希,并说明为什么它在计算机科学的许多不同情况下被使用。
- 了解一些高级排序算法的工作原理。
- 讨论典型的输入和输出机制。你已经知道一些,例如从最终用户获取基本输入的
Scanner,以及向控制台输出内容的System.out.println。但编程中的输入输出要灵活得多,我们今天将快速讨论这一点。 - 在最后一个单元,我们将宣布项目B,并说明其内容、参与方式、需要遵循的规则和指南,并回答相关问题。
但在进入有趣的部分之前,我们先从更具挑战性的部分开始:哈希。
什么是哈希?
哈希的基本含义是,将任意类型的输入转换为通常更小且具有固定大小的内容,我们称之为哈希码。
输入通常有无限多种可能性,而我们只对更小的尺寸感兴趣,因为更小的尺寸具有某些优势:可以轻松排序、轻松查找内容等。这一切都是为了高效的数据索引和数据检索。它是许多不同数据结构的基础。
其中两种数据结构你已经使用过:HashSet和HashMap。它们是Java中Set接口和Map接口的默认实现之一。
哈希基本上确保你在这些集合上执行的所有操作(如添加元素、读取元素、移除元素)都尽可能高效,从而使你的程序最终具有良好的性能。
许多现实世界的应用程序都大量使用哈希,特别是数据库和与安全相关的一切。当然,在计算和应用程序中,也有非常突出的部分,特别是在分布式计算中,当你需要缓存某些内容时。例如,当你打开智能手机上的WhatsApp时,你不希望应用程序一次又一次地下载所有消息。最新的消息已经存储在你的设备上,在某种意义上,你的设备也是一个缓存,因为真实的消息存储在相应应用程序的云数据库中,而你只拥有最新消息的特定视图。因此,如果你在线或离线,而你的WhatsApp对话中的好友编辑了一条消息,你可能仍然没有最新版本,因为你设备上的缓存可能不是最新的。
有许多不同的应用场景,这就是为什么理解基础知识很重要。
哈希与加密/解密的区别
区分哈希与加密和解密非常重要。
如果我们加密和解密某些内容,这是一个双向协议。例如,你有一个用于登录的明文密码,当密码被发送到实际进行用户管理的服务器时,密码会被加密,但服务器随后必须再次解密它,以验证密码是否与你之前存储的密码相同。这是加密和解密的一个非常典型的例子。你加密的任何内容之后都可以被解密。
另一方面,哈希是一个单向协议。如果你获取一个输入并对其进行哈希处理,那么最终生成哈希码或哈希文本的哈希函数,在设计上应该以无法回溯的方式实现。从哈希文本出发,不应该(事实上也必须不能)可能返回到原始文本。这对于某些应用程序来说至关重要。
这是一个你需要首先理解的重要区别:加密和解密总是双向的,而哈希只是单向的,并且之后你不应该能够检索原始信息。
哈希的核心概念
哈希的核心是将输入转换为固定大小的输出。大多数情况下,这个固定大小的输出是一个整数或字符串,但一般来说,在哈希中你可以使用任何数据结构。目的通常是实现快速的数据检索。
例如,如果你有非常大的数据集,理想情况下,如果你能为使用你应用程序的5亿人创建哈希码,那么你可以更容易地找到他们。你总是有一个输入、一个可以配置的哈希函数(有时由你决定),然后是一个唯一的哈希值。这样在性能和时间复杂度上检索数据会快得多。对于某些算法来说,这甚至是必不可少的,否则它们对于最终用户来说会太慢。顺便说一下,你进行的每一次谷歌搜索也涉及哈希。每次你搜索某些内容时,他们都有巨大的数据集需要查看,而之所以能这么快,正是因为他们使用了某种哈希机制。
有许多不同的哈希函数,其中一些相当知名,也用于密码学,例如SHA-256。但你基本上也可以提出自己的哈希函数。
哈希函数接受一个输入,然后以一种不可逆的方式返回输出,这很重要。有些人称输出为哈希值,有些人称其为哈希码,有些人称其为摘要,或者有些人就称之为哈希。所以当你找到这些术语时,它们基本上都指哈希函数的输出。
大多数时候,你将哈希值存储在所谓的哈希表中。哈希表也可以包含输入,但有时不需要存储输入,有时你只在那里存储哈希值。
理想情况下,哈希函数应该是确定性的,这意味着相同的输入总是产生相同的哈希码,否则你会有问题。它应该计算起来相当快,因为如果计算需要很长时间,那么以后查找数据时的性能改进就不太合理了。
理想情况下,它应该在整个可能值集合上具有均匀分布,这对于避免碰撞很重要。如果你有任意输入和固定大小的输出,就意味着你可能会发生碰撞。对于任何哈希函数,你都无法保证避免这种情况。因此,你总会找到两个输入导致碰撞,即它们具有相同的输出。当然,这是一个问题。解决方案不是让哈希函数变得如此复杂以至于确保它永远不会发生,而是让它变得如此不可能在实践中发生,这是基本思路。
在Java中,这很好地集成到任何你处理的对象或类中,基于hashCode方法。hashCode在Object类中实现。Object是所有其他对象或类的隐式超类,它提供了hashCode的默认实现。你可以随时覆盖和自定义它。
因此,理想情况下,你拥有一个具有最小碰撞、易于计算但极难逆转的确定性函数。这就是哈希的主要思想。
哈希函数示例
以下是一个示例,说明这可能是什么样子。假设我们有一个由多个字符组成的字符串,字符为s0,s1,依此类推。那么一个哈希函数可以是,你只取字符的整数值(记住字符串中的每个字符都有一个对应的整数值),然后对这些整数值求和。在第一个示例H0中,你只对第一个和最后一个求和。计算起来非常简单。
让我们看看它是否满足我们刚才陈述的所有其他要求。
哈希函数1,H1,取从第一个字符、第二个字符、第三个字符直到最后一个字符的所有整数值,然后求和。这稍微复杂一些,基本上你有很多次加法,和字符数量一样多。
第三个函数H2更复杂一些。最初,你将整数值乘以一个质数P,并以一种非常特定的方式对它们求和。
Java中Object类的hashCode方法,对于String对象的默认实现,使用了P=31的H2函数。所以这不是一个任意的例子,而是一个真实世界的例子。
让我们看看这些从非常简单到中等复杂度的不同哈希函数实际上表现如何。我们这里有一些典型的字符串,这些字符串也用于计算机科学,特别是在编译器中。表格中第一列是H0,即你能想到的最简单的哈希函数;第二列是H1,稍微复杂一点但仍然相对简单;第三列是H2,在这个例子中我们使用P=7,因为如果我使用P=31,数字会变得太大,无法在幻灯片上显示。
如果我们随机查看,会发现所有黑色的基本上都是唯一的,但所有彩色的行表示存在另一个具有相同值的哈希码,即我们发生了碰撞。例如,这两行都导致哈希码214。就碰撞而言,这不是一个很好的函数,即使它非常容易计算。我们在这里还有第二个碰撞,diff和chmp都导致哈希码218。
第二个函数H1稍微好一点。数字变得稍大一些,但alloc和false仍然发生了碰撞,所以表现仍然不是很好。对于最后一个函数,你会发现数字现在变得大得多,这很明显:字符越多,你做的乘法和加法就越多,并且总是乘以这个质因数,所以你确实会得到很大的数字。至少对于这个有限的字符串集合,我们没有看到碰撞。
所以这个已经更好了。如果我们回顾一下,会发现最后一个函数在性能上也更密集:不是只有一次加法或n次加法,如果我们有n个字符,我们就有n次乘法加上加法,而且是以一种非常特定的方式。所以这至少是H1哈希函数复杂度的两倍。然后这取决于你的CPU计算加法和乘法的速度。如今CPU在这方面非常快,所以即使这个函数也可以在纳秒内计算出来。对于大多数计算机来说,这不是问题,对于大多数计算机来说,这不是一个非常复杂的函数。
哈希函数的安全性
问题:如何确保哈希函数是不可逆的,如果你知道输出和哈希函数本身?
首先,更复杂的哈希函数包含特定的密钥,这些密钥不为外界所知。这是首先重要的。对于我们这里的例子,这是已知的,所以你基本上可以计算它,也可以相对容易地逆转它。我的意思是,这并不超级容易,因为你看这些数字也很相似,值也很相似,所以并不超级容易。但至少我可以说,小数字有两个字母,大数字有三个,更大的数字有五个字母,等等。但它仍然不容易逆转。同样,这里可以说它必须有多难?如果你有无限的计算能力,你总是可以逆转它。但我们没有无限的计算能力,计算,尤其是昂贵的计算,最终可能非常非常昂贵,你必须为此付费。这就像你智能手机上的密码锁一样:你用的是四位数字、六位数字还是自定义密码?如果你用四位数字,只有一定次数的尝试机会,这通常就足够了。
如果有人在你输入时从你肩膀上看过去怎么办?所以完美的安全性是无法实现的。这通常总是一种尽力而为的方法。如果你有一个拥有足够计算能力的入侵者,那么在某种意义上,你可以破解任何系统。当然,有非常复杂的方法来保护系统,但这超出了本讲座的范围。
良好哈希函数的特性
我们已经稍微讨论过这一点,这对于密码学尤其重要。这不是密码学讲座,所以我只停留在表面。它必须是确定性的,应该快速,应该是不可逆的,应该是抗碰撞的。这是我们之前已经讨论过的。
快速总结一下,还有两个额外的方面:
- 均匀分布:这在实现抗碰撞方面也很重要。如果你的哈希码在整个频谱上均匀分布,发生碰撞的可能性就小得多,计算反函数也困难得多。
- 雪崩效应:这意味着输入中的微小变化会导致完全不同的输出。这对于实现不可逆性也很重要,因为如果输入中的微小变化导致输出中的微小变化,那么人们可以轻松地,例如,取100个输入,计算输出,然后根据他们看到的模式来思考这里实际使用了什么方法和密钥。
哈希值的存储
问题:如何存储哈希函数的输出值?是用逗号分隔还是存储在数据库中?
答案取决于你的具体用例。在Java的HashSet和HashMap中,它们有一个称为哈希表的内部数据结构来存储它。这稍微高级一些,但它们基本上做的是:它们有多个列表,然后基于某些模式将内容添加到一个列表或另一个列表中。例如,每个哈希码可以指向哈希表中的一个“桶”,你在该桶的列表中存储内容。然后检索就快得多。但同样,有很多不同的方法来实现这一点,计算机科学家在如何高效存储数据方面非常有创意。我可以开设一整门关于哈希、密码学以及如何高效存储数据的课程。
哈希碰撞
如果发生哈希碰撞,即两个不同的输入产生相同的输出(相同的哈希码),正如我所说,由于所谓的鸽巢原理,这在任何哈希系统中都是不可避免的。如果你有10只鸽子和9个鸽巢,总会有一个鸽巢里有两只鸽子。如果输出(哈希码)的尺寸小于输入,这就是不可避免的,而这是主要设计。
如果发生这种情况,有办法处理,但这通常会导致系统性能下降,也可能影响数据检索的效率。因此,通常人们在实现和使用哈希时会注意处理这种情况,但那样会变得慢一些。同样,问题的频率和严重程度确实取决于哈希函数的质量和表的大小。
自定义哈希函数示例
以下是你如何自己实现它的另一个例子。它看起来相当困难,但它基本上是我们之前见过的Point类。除了属性和构造函数以及潜在的其他方法之外,我们提供了一个equals方法,我们可以用它来找出两个点是否相等,基于它们的x和y值的比较。还有一些样板代码来确保正确处理空值或潜在的子类。但最终,我们基本上只是将传递对象的x值与this.x、传递对象的y值与this.y进行比较,如果它们相同,则返回true。
然后我们有hashCode函数,你看到我们覆盖了它。它返回一个整数,所以是一个相对较小的尺寸。这里我们基本上有一个起始数字17,然后有一个x哈希和一个y哈希,基于一个将double转换为long的方法。然后,我们基本上以非常特定的方式将它们彼此相乘并添加到现有结果中,再次相乘,我们将long转换为int,使整个尺寸更小,然后返回它们。虽然你并不需要完全理解这是如何工作的(下一张幻灯片也有描述),但这只是一个你可以做什么的例子。网上也有页面可以根据你想要考虑的不同属性为你生成一个好的哈希函数,我认为甚至在IntelliJ中也可以为你生成一个好的哈希函数。
然后你可以在一个Set中使用这个Point。Set将在内部使用哈希码,所以你甚至可以在这里设置断点来查看它是如何工作的。你也可以在HashMap中使用它,然后HashMap也会使用hashCode函数并执行操作。注意,hashCode是相对较小的整数,你无法存储很多整数,但这不是目的。目的不是要有一个可以存储数百万或数十亿值的完美无碰撞函数。哈希已经有其他一些优势:如果我们能非常快速地计算它,我们就可以更有效地存储数据,并且更有效地检索数据,而无需在最后搜索整个数据结构。这是主要思想。
如果你感兴趣,可以在第14张幻灯片上阅读为什么我们实际上选择了这样的实现,以及不同的方法(例如我们是否使用异或操作或位移位)实际上在这里有解释。
为什么之前的函数是一个好函数?
它试图均匀分布哈希码,这降低了碰撞的可能性。它仍然计算快速且是确定性的(我们在这里不做任何随机操作)。它还考虑了这两个属性,这也很重要。坐标的微小变化会导致显著不同的哈希码。这基本上确保了Point对象在HashMap中分布良好,并使它们在那些数据结构中的使用更高效、性能更好。
哈希的实际应用
有许多不同的现实世界应用程序使用哈希。我们已经稍微讨论了数据库,这里主要用于创建索引,以便尽可能快地访问数据。
数据缓存是一个典型的例子。你的浏览器缓存了大量数据,这样就不必一次又一次地从Web服务器加载,以减少网络使用并提高性能。
我们可以用它来安全地存储密码,这也很重要。密码不应以加密方式存储在数据库中,而应以哈希方式存储,这样即使是管理员之后也无法从中获取密码。
有许多密码学应用需要它,例如用于完整性检查和安全消息传输。比特币就大量使用它。另一个例子是Git也使用它。你可能已经见过提交哈希。提交哈希也使用基于时间或日期、你所做的更改以及提交消息的哈希函数,然后尝试为这个特定的仓库生成一个唯一的哈希。
许多分布式系统严重依赖于哈希函数,这就是为什么理解它如此重要。
最佳实践
在复杂性和效率之间总是存在权衡,所以尝试找到一个中间点,既有良好的效率、较小的碰撞概率,又有一个易于理解的实现。如果发生碰撞,你应该尝试优雅地处理它们,使用一些对你的特定用例特别有意义的碰撞解决技术。如果你有想要与哈希一起使用的自定义对象,请正确覆盖hashCode和equals。IntelliJ会帮助你做到这一点。
字符串与StringBuilder
现在,让我们快速回顾一下字符串。字符串允许我们表示Unicode字符,你已经知道这一点,所以这只是复习。你也知道字符串是不可变的,无法更改。这就是所谓的StringBuilder发挥作用的地方。StringBuilder允许我们创建可变的字符串,这在循环中使用时特别有帮助,允许你逐步构建字符串,然后稍后将其转换为不可变的字符串。
String类本身已经允许我们查看单个字符或子字符串,比较字符串,创建小写、大写的副本。但它是final且不可变的。因此,如果你用+运算符连接两个字符串(这是你经常做的事情),那么你不应该在循环中这样做。相反,你应该使用StringBuilder,它允许我们操作字符串,具有插入、删除或追加字符串的方法,而无需每次都创建一个新的字符串。
特别是当你使用循环时,你应该使用StringBuilder。还有StringTokenizer,允许你将字符串拆分为标记,例如基于逗号分隔值(这里逗号用于拆分)或基于空格等。
以下是一个StringBuilder的例子:
我们创建一个带有初始字符串的新StringBuilder,然后我们可以追加任意多个其他字符串,而无需立即创建新的字符串(那将是不可变的)。然后在某个时刻,我们可以调用toString()方法,将输出转换为字符串并打印它。
这是主要思想,这也是我们现在希望你尝试的。请打开Artemis并进入练习W9E2 StringBuilder。
我们希望创建一个字符串。输入是一个包含一些现有单词的数组,例如我们最喜欢的汽车品牌。我们要求你使用增强型for循环,或者,如果你觉得训练有素,也可以使用Lambda表达式。想法是,你应该将所有单词附加到StringBuilder中,单词之间用空格分隔。然后应该将其打印到控制台。你现在有大约五分钟的时间来完成这个练习,导师们会在周围走动,如果你有问题或需要帮助,可以询问。祝你好运。
初始工作时间结束,请随意继续工作直到今晚。我将向你展示一个快速的示例解决方案:我们创建一个StringBuilder,然后将我们的数组作为包含汽车品牌的列表(应该在相应的练习中给出)。然后你可以使用增强型for循环,但这里我使用Lambda表达式和list.forEach,然后将输入变量映射到builder.append方法。我不仅追加变量,还在最后追加一个空格。所以我们从一个空字符串开始,然后对于列表中的每个变量,我们将其附加到构建器,并额外附加一个空字符串。完成后,我们可以调用toString方法,将所有变量打印到控制台,然后得到这个输出。
关于这个小练习有任何问题吗?很好,然后我们快速休息一下,下午3点开始第二单元。
总结

在本节课中,我们一起学习了哈希的基本概念,包括其定义、与加密的区别、核心特性以及如何实现一个简单的哈希函数。我们还探讨了哈希碰撞及其处理方法,并回顾了字符串处理中StringBuilder的使用。哈希是计算机科学中一个强大且广泛应用的工具,理解它将有助于你编写更高效、更安全的程序。
040:高级排序算法 🧠

在本节课中,我们将要学习一种更高效、更高级的排序算法——归并排序。我们已经了解了插入排序等简单排序算法,但它们通常速度较慢。归并排序相比冒泡排序要快得多,虽然它不一定是最快的(快速排序通常被认为最快),但它在性能上与快速排序大致相当,且更容易理解。
归并排序的核心思想
上一节我们介绍了简单排序算法,本节中我们来看看归并排序是如何工作的。
归并排序的基本思想是:从两个已排序的列表开始,将它们合并成一个新的已排序列表。如果你一开始没有已排序的列表,就需要先将列表反复拆分,直到每个子列表只包含一个元素(单个元素的列表自然是已排序的)。然后,再通过归并排序算法,将这些小列表逐步合并,最终得到一个完整的有序列表。
因为归并排序的前两个步骤(拆分)理解起来可能有些繁琐,我们这里将重点放在第三步,即如何将两个已经排序的列表合并成一个新的有序列表。这也是我们将用Java实现的核心算法。
合并两个有序列表的算法
首先,让我们从概念上理解这个算法是如何工作的,因为以后你可能需要用不同的编程语言来实现它。
假设我们有两个已排序的列表(从小到大),它们长度可以不同:
- 列表 A:
[-3, 0, 7, 42] - 列表 B:
[13, 15, 16]
归并排序的合并过程如下:我们比较两个列表当前最小的元素(即各自最左侧的元素),将较小的那个放入输出列表,并将其从原列表中“移除”(或移动索引)。重复此过程,直到其中一个列表为空。此时,只需将另一个列表剩余的所有元素按序添加到输出列表末尾即可。
如果两个输入列表的长度分别为 m 和 n,那么最多需要进行 m + n - 1 次比较。这是一种非常高效的合并方式。
以下是该过程的逐步演示:
- 比较
-3和13,-3更小,输出[-3]。 - 比较
0和13,0更小,输出[-3, 0]。 - 比较
7和13,7更小,输出[-3, 0, 7]。 - 比较
42和13,13更小,输出[-3, 0, 7, 13]。 - 比较
42和15,15更小,输出[-3, 0, 7, 13, 15]。 - 比较
42和16,16更小,输出[-3, 0, 7, 13, 15, 16]。 - 列表 B 已空,将列表 A 剩余的元素
42加入,得到最终输出[-3, 0, 7, 13, 15, 16, 42]。
实现规划与通用算法
理解了概念后,我们来规划如何实现它。良好的规划能让编码更有条理,结果更好。
我们需要:
- 创建输出列表。
- 使用索引
i和j遍历两个输入列表 A 和 B。 - 在循环中,比较
A[i]和B[j],将较小的元素追加到输出列表,并递增对应索引。 - 当任一列表遍历完毕(
i或j到达列表末尾),将另一个列表剩余的所有元素追加到输出列表。
为了使算法通用,能处理不同类型的数据(如整数、字符串或自定义对象),我们使用泛型并约束类型必须实现 Comparable 接口。这样,我们就可以使用 compareTo 方法来比较两个元素。
以下是使用Java集合(List)的通用实现代码框架:
public static <T extends Comparable<T>> List<T> merge(List<T> listA, List<T> listB) {
List<T> result = new ArrayList<>();
int i = 0, j = 0;
while (i < listA.size() && j < listB.size()) {
if (listA.get(i).compareTo(listB.get(j)) < 0) {
result.add(listA.get(i));
i++;
} else {
result.add(listB.get(j));
j++;
}
}
// 添加剩余元素
while (i < listA.size()) {
result.add(listA.get(i));
i++;
}
while (j < listB.size()) {
result.add(listB.get(j));
j++;
}
return result;
}
关键点说明:
<T extends Comparable<T>>确保了类型T必须实现compareTo方法。compareTo(a, b) < 0意味着a小于b。- 此实现不会修改原始的输入列表
listA和listB。
练习:使用数组实现合并算法
现在,我们来进行一个稍具挑战性的练习:用泛型数组(Array)而不是列表(List)来实现相同的合并功能。
与列表不同,数组的长度是固定的,我们不能简单地“追加”元素。因此,我们需要一个额外的索引 k 来跟踪输出数组中下一个要插入元素的位置。
以下是函数签名和部分提示代码:
public static <T extends Comparable<T>> T[] mergeArrays(T[] arrayA, T[] arrayB) {
// 创建结果数组,长度为两者之和
T[] result = Arrays.copyOf(arrayA, arrayA.length + arrayB.length);
int i = 0, j = 0, k = 0;
// 第一个while循环:合并两个数组
while (i < arrayA.length && j < arrayB.length) {
if (arrayA[i].compareTo(arrayB[j]) < 0) {
result[k] = arrayA[i];
i++;
} else {
result[k] = arrayB[j];
j++;
}
k++;
}
// 添加arrayA的剩余元素
while (i < arrayA.length) {
result[k] = arrayA[i];
i++;
k++;
}
// 添加arrayB的剩余元素
while (j < arrayB.length) {
result[k] = arrayB[j];
j++;
k++;
}
return result;
}
实现要点:
- 访问数组元素使用方括号
arrayA[i],而不是列表的get(i)方法。 - 判断数组是否遍历完毕,使用
arrayA.length属性,而不是size()方法。 - 必须维护输出数组的索引
k,并在每次赋值后递增它。
总结

本节课中我们一起学习了归并排序的核心——合并两个有序序列的算法。我们首先从概念上理解了其工作原理,然后规划并实现了适用于 List 的通用版本。最后,我们通过练习将其适配到 Array 上,掌握了处理固定长度数据结构时的关键点(维护输出索引)。这个算法高效且优雅,是许多高级排序算法的基础。理解并掌握它,对你构建更复杂的程序逻辑大有裨益。
041:输入与输出

在本节课中,我们将要学习Java中关于输入与输出的核心概念。输入与输出是程序与外界交互的基础,无论是从用户获取数据,还是将数据写入文件或网络,都离不开它。我们将从简单的控制台输入输出开始,逐步深入到文件读写和字符编码等更高级的主题。
输入与输出概述
上一节我们介绍了程序的基本结构,本节中我们来看看程序如何与外部世界进行数据交换。输入与输出并非Java语言本身的关键字,而是由Java虚拟机及其丰富的库提供的功能。这些库使得读取文件、写入文件或处理网络数据流变得相对容易,尽管错误处理可能会变得复杂。
任何程序都需要某种形式的输入和输出,无论是与最终用户通信,还是处理来自用户的数据。因此,理解输入输出机制对所有类型的程序都至关重要。
流的概念
当处理输入和输出时,我们通常将其视为流。流分为输入流和输出流。主要原因是程序无法一次性处理所有输入数据,必须逐字节或逐字符地读取,以验证数据的准确性并确保正确写入输出。
一个流可能是无限的,但大多数时候我们只使用有限的流。你可以将其想象成一个序列:读取时从左端移除元素,写入时在右端添加元素。在数组中,这通过索引来实现。
字符编码:UTF-8
理想情况下,处理输入和输出时应始终使用UTF-8编码。这是标准编码,可以避免许多字符显示问题。UTF-8是一种非常灵活的编码,它对最重要的字符使用默认大小,并可以通过使用更多字节来扩展以支持任何字符,甚至是表情符号。Java通过InputStreamReader和OutputStreamWriter等类支持UTF-8。
数据表示:二进制与文本
处理输入输出数据时,有两种主要的表示方式:
- 二进制:使用0和1作为内部表示。这种方式通常比较紧凑,但人类无法直接阅读。
- 文本:使用人类可读的字符序列。这种方式更易于理解和调试,但占用的空间通常更大。
Java程序本身就是一个文本表示的例子。
Java中的输入输出类
Java主要通过java.io包来处理输入输出。虽然也有更现代的java.nio包,但今天我们专注于更基础易用的java.io。
以下是java.io包中一些最重要的类的不完全概述:
- 输入流:
InputStream及其子类,如FileInputStream和DataInputStream。 - 输出流:
OutputStream及其子类,如FileOutputStream、DataOutputStream和PrintStream。
你已经使用过的System.in是一个InputStream,System.out和System.err是PrintStream。
InputStream类
InputStream是字节输入的基础抽象类。它提供了一些有用的操作:
available(): 检查输入流中还有多少字节可用。read(): 从输入流中读取一个字节。如果到达流末尾,则返回-1。close(): 关闭输入流。
read()和close()方法都可能抛出IOException。
FileInputStream是InputStream的一个具体子类,用于从文件读取字节。创建它时需要提供文件在操作系统中的路径字符串。
以下是使用FileInputStream逐个字节读取并打印文件内容的示例代码:
import java.io.FileInputStream;
import java.io.IOException;
public class ReadFileByteByByte {
public static void main(String[] args) throws IOException {
FileInputStream fileIn = new FileInputStream(args[0]);
int data;
while ((data = fileIn.read()) != -1) {
System.out.print((char) data);
}
System.out.println();
fileIn.close();
}
}
你也可以使用readAllBytes()方法一次性读取整个文件,但要注意文件过大可能导致内存问题。
OutputStream类
OutputStream与InputStream类似,但用于写入。它也是抽象的,提供以下关键方法:
write(int b): 写入一个字节(整数的最低8位)。flush(): 强制将缓冲区的数据写入目标(如文件或网络),因为写入操作可能较慢。close(): 关闭输出流,释放资源(如文件句柄)。
FileOutputStream用于向文件写入字节。其构造函数可以指定路径,以及是否以追加模式写入。
以下是一个结合FileInputStream和FileOutputStream来复制文件的示例:
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
public class CopyFile {
public static void main(String[] args) throws IOException {
FileInputStream fileIn = new FileInputStream(args[0]);
FileOutputStream fileOut = new FileOutputStream(args[1]);
int bytesAvailable = fileIn.available();
for (int i = 0; i < bytesAvailable; i++) {
int data = fileIn.read();
fileOut.write(data);
}
fileIn.close();
fileOut.close();
}
}
处理字符:Reader和Writer
直接处理字节不够直观,因此Java提供了用于处理字符的Reader和Writer类。
Reader用于读取字符。重要的子类包括:
InputStreamReader: 将字节流转换为字符流。FileReader: 专门用于从文件读取字符。BufferedReader: 提供缓冲功能,并能方便地按行读取(readLine()方法)。
以下是使用BufferedReader按行读取文件的示例:
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class ReadFileLineByLine {
public static void main(String[] args) throws IOException {
FileReader fileReader = new FileReader(args[0]);
BufferedReader bufferedReader = new BufferedReader(fileReader);
String line;
while ((line = bufferedReader.readLine()) != null) {
System.out.println(line);
}
bufferedReader.close();
}
}
Writer用于写入字符。重要的子类包括:
OutputStreamWriter: 将字符流转换为字节流。FileWriter: 专门用于向文件写入字符。PrintWriter: 类似于PrintStream,提供方便的print()和println()方法。
以下是使用FileReader和FileWriter进行文本文件复制的示例:
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
public class CopyTextFile {
public static void main(String[] args) throws IOException {
FileReader fileIn = new FileReader(args[0]);
FileWriter fileOut = new FileWriter(args[1]);
int data;
while ((data = fileIn.read()) != -1) {
fileOut.write(data);
}
fileIn.close();
fileOut.close();
}
}
总结

本节课中我们一起学习了Java输入与输出的核心知识。我们了解到输入输出通过流的概念来处理,并区分了用于字节操作的InputStream/OutputStream和用于字符操作的Reader/Writer。处理字符时,指定正确的编码(尤其是UTF-8)至关重要。我们还通过多个代码示例,实践了如何读取文件、写入文件以及复制文件。掌握这些基础是构建能够与文件系统、网络和用户交互的复杂应用程序的第一步。
042:项目实践 🎮

在本节课中,我们将介绍本学期的项目实践。你将与一位伙伴合作,运用所学的面向对象编程知识,开发一个完整的游戏。我们将详细讲解项目主题、技术要求、开发流程以及团队协作的规范。
项目概述
在之前的课程中,我们解决了一些小型问题,完成了导师练习和家庭作业。现在,我们将进行一个更重要的项目实践。你将不再处理零散的小任务,而是需要自己设计一个游戏的整体代码结构。你将应用在研讨课上学到的所有面向对象概念,并最终完成一个可以向朋友和家人展示的作品。
项目主题:炸弹人 💣
今年的项目主题是“炸弹人”。这是一个经典游戏,其核心玩法如下:
- 游戏场景:玩家身处一个由墙壁构成的迷宫中。
- 墙壁类型:迷宫由不可摧毁的墙壁和可摧毁的墙壁构成。
- 敌人:迷宫中游荡着一些敌人。
- 玩家目标:玩家需要放置炸弹。炸弹爆炸后可以摧毁可摧毁的墙壁、消灭敌人。
- 隐藏元素:摧毁墙壁后,可能会发现隐藏的出口或能力增强道具。
- 胜利条件:玩家必须找到出口并逃离。出口在击败所有敌人之前是锁定的。
- 能力增强:通过拾取道具,玩家可以增强炸弹能力,例如增加爆炸半径或增加可同时放置的炸弹数量。
- 计时器:游戏设有时间限制,玩家必须在规定时间内完成目标。
游戏机制与要求
上一节我们介绍了游戏的基本主题,本节中我们来看看具体的游戏机制和技术实现要求。
核心游戏机制
以下是游戏需要实现的基本机制:
- 角色控制:玩家控制一个角色,该角色可以放置炸弹和移动。
- 移动方向:角色可以在迷宫中沿四个方向(上、下、左、右)移动。
- 场景构成:迷宫由墙壁和通道构成。角色和敌人只能在通道上移动,无法穿过墙壁。
- 入口与出口:每个迷宫有且仅有一个入口和一个隐藏的出口。
- 敌人行为:敌人在通道上移动。
- 失败条件:如果敌人触碰到角色(或角色触碰到敌人),则玩家失败。
- 状态显示(HUD):游戏需要一个状态栏(HUD)来显示以下信息:
- 当前可放置的炸弹数量。
- 当前炸弹的爆炸半径。
- 剩余游戏时间。
- 剩余敌人数量。
- 出口是否可用的指示器(当所有敌人都被击败且出口被发现时可用)。
技术要求
为了完成这个项目,我们需要使用特定的工具并遵循一些技术规范。
- 游戏引擎:我们将使用 LibGDX 游戏开发框架来渲染2D图形和构建游戏。别担心,我们会提供一个项目模板帮助你起步。
- 地图文件:我们会提供一些地图文件,也欢迎你创建自己的。你的游戏必须能够加载并运行任何来自文件的地图。
- 相机系统:迷宫可能比游戏屏幕大,因此需要实现一个跟随角色移动的相机,确保角色始终在屏幕视野内。
- 屏幕自适应:游戏窗口应能调整大小,游戏内容需要自适应不同分辨率,不能只针对固定分辨率(如全高清)开发。
- 代码文档:你必须为项目代码编写文档。
- 使用 JavaDoc 格式为每个类和方法添加注释。
- JavaDoc 示例:
/** * 将两个整数相加并返回结果。 * @param a 要相加的第一个整数。 * @param b 要相加的第二个整数。 * @return 两个整数的和。 */ public int sum(int a, int b) { return a + b; }
- README 文件:需要提供一个
README.md文件,解释项目结构、如何启动游戏以及游戏机制等。这对于我们评估你的项目至关重要。
LibGDX 基础概念
了解了项目要求后,我们来看看将要用到的 LibGDX 框架的一些核心概念。
游戏循环(Game Loop)
在 LibGDX 中,核心是 render() 方法构成的游戏循环。这个方法每秒会被调用约60次,从而产生每秒60帧的动画效果。该方法还会接收一个 deltaTime 参数,表示自上一帧以来经过的时间(秒),这对于实现与时间相关的移动(如角色移动)非常有用。



在 render() 方法中,你可以:
- 告诉 LibGDX 在何处绘制图像、角色、墙壁等所有游戏元素。
- 处理用户输入(例如,按下方向键移动角色)。
- 更新游戏状态(例如,计算角色的下一步位置或敌人的移动)。

应用结构与屏幕管理
LibGDX 游戏遵循一个基本的类结构:
- ApplicationListener:这是顶层接口,用于响应游戏中的事件(如创建、渲染、暂停、调整大小等)。
- Game 类:这是 LibGDX 提供的一个实现了
ApplicationListener的类。它管理多个 Screen(屏幕)。 - Screen(屏幕):一个游戏通常包含多个屏幕,例如:
- 开始菜单屏幕
- 游戏主屏幕
- 游戏胜利/失败屏幕
- 每个
Screen都有自己的render()、show()、hide()、resize()和dispose()方法。
Screen 类的主要方法:
show():当该屏幕被激活显示时调用。render(float delta):渲染该屏幕的内容。resize(int width, int height):当游戏窗口大小改变时调用。hide():当该屏幕被切换隐藏时调用。dispose():非常重要。用于清理该屏幕占用的资源(如纹理、音效),防止内存泄漏。
学习资源
LibGDX 拥有丰富的社区资源:
- 官方 Wiki:包含大量教程和文档。
- Awesome LibGDX 资源库:汇集了纹理、图像、工具等各种有用资源的链接。
项目演示与团队协作
我们看过了技术基础,现在来了解一下项目的组织形式和协作方式。
项目演示
我们的导师 Michael 开发了一个示例解决方案,它展示了最终项目可能呈现的效果。演示中可以看到角色移动、放置炸弹、摧毁墙壁、拾取道具、击败敌人并最终找到出口获胜的完整流程。
工作模式:结对编程
本项目要求以两人小组的形式完成。这旨在培养你们的团队协作、沟通和任务拆分能力。项目最后需要在导师小组中进行最终演示。
重要规则:
- 团队成员必须来自同一个导师小组。
- 如果遇到问题(如队友退出),请立即与导师沟通。
- 结对编程是强制性的。你们需要在1月份的导师课中至少参加2次并进行结对编程。也鼓励在课外进行。
- 在结对编程时,必须每20分钟轮换角色,并且驾驶员(写代码的人)必须使用自己的电脑并提交代码。
结对编程角色
- 驾驶员:操作键盘和鼠标,负责编写代码,并向导航员解释正在做什么以及为什么这么做。
- 导航员:观察驾驶员的工作,思考整体架构,提出问题,并指出潜在的问题或改进点。
- 核心是沟通:通过持续的交流,可以显著提高代码质量和项目成果。
使用 Git 进行团队协作
由于是团队项目,我们需要有效地使用 Git 进行版本控制和协作。到目前为止,你可能只使用过 git clone, add, commit, push。现在需要学习更多命令。
Git 工作流与核心命令
Git 有四个主要区域:
- 工作目录:本地计算机上的文件。
- 暂存区:准备提交的文件临时存放区。使用
git add <file>将文件加入。 - 本地仓库:存储项目提交历史的数据库。使用
git commit将暂存区内容提交至此。 - 远程仓库:位于服务器上(如 Artemis),用于团队共享代码。使用
git push将本地提交推送到此。
协作相关命令
git fetch:从远程仓库下载最新的提交历史到本地仓库,但不改变工作目录的文件。git merge:将远程仓库的更新合并到当前工作目录。git pull:这是一个快捷命令,相当于git fetch+git merge。用于获取队友的更新。git push:将你的本地提交上传到远程仓库。
处理合并冲突
当你和队友修改了同一文件的同一部分时,就会发生合并冲突。Git 无法自动决定保留谁的修改,需要你手动解决。
解决流程通常是:pull 时发现冲突 -> 手动编辑文件,选择或整合冲突的代码 -> 执行一个新的合并提交 -> push。
Git 最佳实践
为了避免问题,请遵循以下最佳实践:
- 频繁提交和推送。
- 开始新功能前先拉取最新代码。
- 提交相关的更改(一次提交只完成一个明确的小功能)。
- 不要提交未完成的代码。
- 提交前进行测试。
- 编写清晰的提交信息(例如:“添加炸弹爆炸动画”,而不是“修复了一些bug”)。
- 本项目只使用
main分支,不创建其他分支。
在 IntelliJ IDEA 中,你可以通过 VCS -> Git -> Pull 菜单轻松执行拉取操作。
项目评分、时间线与重要规则
最后,我们来明确项目的评分标准、时间安排和必须遵守的规则。
评分与加分
- 基础要求:完整实现项目说明书中规定的所有最低要求,即可获得该项目部分100%的分数(项目占ITP总成绩的30%)。
- 创意加分:最高可获得5分的额外加分。例如:
- 实现更智能的敌人AI(如追踪玩家)。
- 添加新的游戏机制(如敌人也能放置炸弹)。
- 任何超越基础要求的创意功能。
时间线
- 立即开始:在导师课上寻找来自同一小组的队友,并向导师报告。
- 组队截止日期:下周二晚上8点。
- 项目最终提交:1月29日(讲座课前)。
- 最终演示:在1月份的最后一节导师课上进行。
重要规则与评分标准
以下规则至关重要,违反可能导致严重扣分甚至项目失败:
- 平等贡献:两位成员必须大致平均地贡献代码(通过提交历史和代码行数评估)。每人至少应有10次有意义的提交。
- 仅使用 Artemis Git:所有代码协作必须通过 Artemis 的 Git 进行。其他方式(U盘、Google Docs等)的贡献将不被认可。
- 强制结对编程:必须参加1月份的导师课结对编程,并按规定轮换角色和电脑。
- 有意义的提交:提交信息必须清晰描述更改内容。提交的代码量应适中,且是完整的功能模块。
- 演示与答辩:两位成员都必须参与演示,并证明自己理解整个项目代码。
- 及时沟通问题:遇到任何问题(团队或技术)请尽早联系导师。
- 严禁抄袭:不得复制其他团队的代码。任何抄袭行为将导致所有涉及者该部分成绩为0分。
- 关于 AI 工具:可以寻求 AI 帮助,但不建议作为首选。你必须理解并检查 AI 生成的代码。如果大量使用,需注意避免代码雷同。来自互联网(包括AI)的代码必须注明出处。
- 第三方库:如需使用未在课程中教过的第三方库,必须在 Artemis 指定频道申请许可。
总结

本节课中我们一起学习了本学期的项目实践“炸弹人”游戏开发。我们明确了游戏的主题、机制和技术要求,介绍了 LibGDX 游戏引擎的基础概念。我们重点讲解了以结对编程为核心的团队协作模式,以及如何使用 Git 进行有效的团队开发。最后,我们详细说明了项目的评分标准、时间线和必须严格遵守的重要规则。请仔细阅读项目说明书,积极与队友和导师沟通,开始你们的游戏创作之旅吧!
043:语言处理与程序逻辑建模核心 🎄

在本节课中,我们将学习编程语言的核心概念,包括编译型与解释型语言的差异、语法树、控制流图以及正则表达式。我们将通过清晰的解释和示例,帮助你理解这些概念。
概述
欢迎来到今天的圣诞研讨会。今天我们将探讨编程语言,了解计算机科学中不同类型的编程语言,包括通用编程语言和领域特定语言。这是今年的最后一次研讨会,但我们明年还有更多计划。
编程语言基础
上一节我们介绍了课程概述,本节中我们来看看编程语言的基础知识。
为什么需要编程语言?
人类语言不够精确。例如,“请打开灯”这个指令是模糊的。编程语言是给计算机的精确指令。然而,像Java这样的高级语言计算机无法直接理解,需要编译成二进制代码。
编程语言的分类
以下是编程语言的三种主要分类方式:
-
按抽象级别分类:
- 高级语言:最接近人类语言,易于编程但速度较慢。例如:
Java。 - 汇编语言:使用助记符,比机器语言易读,但计算机仍无法直接执行。
- 机器语言:由0和1组成,是计算机直接执行的语言,但极难编写。
- 高级语言:最接近人类语言,易于编程但速度较慢。例如:
-
按范式分类:
- 面向对象语言:基于类和对象的概念。例如:
Java。 - 脚本语言:通常用于特定任务或自动化。例如:
JavaScript。
- 面向对象语言:基于类和对象的概念。例如:
-
按执行方式分类:
- 编译型语言:源代码直接编译成可执行的二进制文件。例如:
C。优点是执行速度快,但编写难度较高。 - 解释型语言:源代码通过解释器逐行执行。例如:
Python。优点是跨平台性好,编写简单,但执行速度较慢。 - 混合型语言:结合了编译和解释的特性。例如:
Java先编译成字节码,然后由Java虚拟机(JVM)解释执行。它比纯解释型语言快,比纯编译型语言易写。
- 编译型语言:源代码直接编译成可执行的二进制文件。例如:
字节码与机器码
机器码依赖于特定硬件平台,例如为Intel处理器编译的代码无法在AMD处理器上运行。字节码是平台独立的中间代码,由虚拟机(如JVM)执行,从而实现“一次编写,到处运行”。
语法树
上一节我们介绍了编程语言的分类,本节中我们来看看如何用语法树可视化程序结构。
语法树是一种树状图,用于表示程序代码的语法结构。它有助于理解代码是如何被解析的。
控制流图
上一节我们学习了语法树,本节中我们来看看如何用控制流图可视化程序的执行路径。
控制流图用于展示程序中基于条件语句(如if-else)或循环(如while)的不同执行分支。
正则表达式
上一节我们介绍了控制流图,本节中我们来看看正则表达式,它常用于输入验证和字符串模式匹配。
正则表达式是一种强大的工具,用于定义字符串的搜索模式。
总结

本节课中我们一起学习了编程语言处理与程序逻辑建模的核心概念。我们探讨了编译型与解释型语言的差异,了解了通用与领域特定语言的区别,并学习了如何使用语法树和控制流图来可视化程序结构。最后,我们介绍了正则表达式在输入验证中的应用。希望这些知识能帮助你更好地理解编程。
044:领域特定语言 🧩

在本节课中,我们将要学习领域特定语言。我们将了解什么是DSL,它与通用编程语言有何不同,并探索几个在现实世界中广泛使用的DSL实例,例如HTML、JSON和Gradle。
什么是领域特定语言?
领域特定语言是为特定任务专门设计的程序或语言。例如,HTML是专门为编写网页而设计的。你不能用HTML来编写数据库。
通用编程语言则相反,例如Java,你可以用它来做任何事情。虽然理论上可以用Java写网页,但这通常不是最佳实践。
有时这两种类别的界限并不分明。以Python为例,它是一个通用语言,但现在因其易用性,常被用于机器学习和人工智能领域。当然,Python在其他领域也有应用。

使用DSL的优势包括:
- 更高效率:相比用通用语言(如Java)编写网页,使用HTML没有那么多开销。
- 更好代码质量:代码更专注于特定领域。
- 更高学习曲线:需要专门学习。
- 更高维护成本:需要能够编写和阅读这些语言的人员,配置和维护系统可能需要更多精力。

常见的领域特定语言示例
上一节我们介绍了DSL的概念,本节中我们来看看几个具体的例子。以下是几种常见的DSL:
- HTML:用于编写网页。在浏览器中,我们使用HTML标签(如
<h1>表示最大标题,<h2>表示次级标题)来构建页面结构。HTML常与CSS(用于样式)和JavaScript(用于交互功能)结合使用。 - Markdown:一种文档编写工具,程序员常用它来编写教程。它易于使用,且跨平台兼容。例如,用
#表示一级标题,##表示二级标题,用**文本**表示加粗文本。 - JSON:一种数据交换格式,主要用于在程序、工具或应用之间传输数据。它易于人类和机器阅读,采用键值对的结构,类似于哈希映射。例如:
{ "title": "Hacksaw Ridge", "genres": ["History", "Drama"], "year": 2016 } - XML:另一种数据交换格式,在微软和机器人技术等领域常用。它使用标签来定义数据结构,虽然看起来比JSON复杂,但使用一段时间后也会变得容易。
- YAML:一种人类和机器都可读的数据序列化语言,结构与JSON类似,但格式更简洁。它也常用于配置应用程序。
现实应用案例:日志分析系统
前面我们讨论了几种不同的DSL,现在我想通过我的毕业论文项目来展示一个实际用例,说明如何将所学知识应用于现实世界。
项目是为一家名为Bitmovin的流媒体技术公司开发一个智能编码分析仪表板,用于分析日志文件。
什么是日志文件?
日志文件是计算机系统生成的记录文件。例如,当你晚上6点打开WhatsApp,这个操作会被记录在日志中;如果出现错误信息,也会被记录。我们分析这些日志数据,可以了解用户行为(如高峰使用时间)或诊断系统问题。
Bitmovin公司每天产生数十万条日志,人工无法逐一阅读。因此,我们需要一个系统来自动分析这些日志,并将结果以图表形式展示,因为人类阅读图表比阅读原始日志要容易得多。
该系统的数据流如下:输入是JSON文件,通过一系列应用(如日志过滤器、Elasticsearch数据库)处理,最终在Kibana仪表板上生成图表。所有这些应用都运行在Docker容器中。
什么是Docker?
Docker是一个容器管理工具。容器就像一个独立的盒子,每个应用(如Kibana、Elasticsearch)运行在自己的容器里,彼此隔离。这样,更新一个应用不会影响其他应用,大大简化了系统维护。我们使用Docker Compose(一种YAML文件)来管理这些容器。
动手项目:使用Gradle构建应用
最后,我们将讨论Gradle。你们都在课程中使用过它,可能也遇到过构建错误。Gradle是一个构建自动化工具。
Gradle是做什么的?
例如,我想编写一个关于电影的数据集,并希望将其转换为JSON对象以便传输。我不想从头编写JSON解析代码,可以复用他人已编写并托管在中央仓库(如Maven)中的代码。Gradle能帮我下载这些代码依赖、插件或框架,并将其构建到我的项目中。构建过程通常包括清理、编译、构建和测试。
现在,让我们一起完成一个小项目。
步骤1:创建新项目
- 创建一个新项目,命名为“Movie”。
- 选择Gradle作为构建系统,并使用Groovy作为DSL(不要选Kotlin)。
- 设置GroupId,例如
com.example。
步骤2:配置build.gradle文件
项目创建后,打开build.gradle文件。初始内容会包含Java插件、仓库地址等。我们需要添加一些依赖。以下是需要添加的配置:
plugins {
id 'application'
id 'checkstyle'
}
application {
mainClass = 'com.example.Movie'
}
dependencies {
implementation 'com.google.code.gson:gson:2.8.9' // JSON解析库
}
添加后,点击Gradle面板的刷新按钮,重新加载项目。
步骤3:创建Movie类
- 创建一个新的Java类,名为
Movie。 - 在类中定义属性(
title,genres,year)、构造方法以及一个toString()方法。 - 实现将对象转换为JSON字符串的方法(
toJson),以及从JSON字符串重建对象的方法(fromJson)。这需要用到我们通过Gradle引入的Gson库。
关键点:toJson和fromJson方法中使用的键名(如"title")必须保持一致,否则在转换时会出错。
步骤4:编写主方法并运行
在main方法中,创建一个Movie对象,将其转换为JSON字符串,然后再将这个字符串转换回Movie对象。
要运行程序,可以在终端中执行命令:
./gradlew run
如果构建失败,请仔细阅读错误信息,通常可能是拼写错误或路径问题。
通过这个练习,我们不仅学会了如何使用Gradle管理依赖和构建项目,也亲身体验了在开发中常见的拼写错误等问题。
总结 🎬
本节课中我们一起学习了:
- 领域特定语言的定义及其与通用编程语言的区别。
- 几种常见的DSL:HTML(网页)、Markdown(文档)、JSON/XML/YAML(数据交换)。
- 一个真实的工业级应用案例:使用多种技术(包括DSL、Docker)构建日志分析系统。
- 通过一个动手项目,实践了如何使用Gradle构建工具来管理项目依赖并运行一个简单的Java应用。

记住,工具和语言都是为了解决特定问题而存在的。理解它们的适用场景,能帮助我们在项目中做出更合适的技术选型。
045:语法树 🧠

在本节课中,我们将要学习语法树的概念。语法树是程序代码的一种树状表示形式,它在编译器、程序分析和程序转换系统中扮演着核心角色。我们将了解其动机、构建方法以及实际应用。
语法树的动机与直觉
上一节我们介绍了语法树的基本概念,本节中我们来看看其背后的动机。
每种语言,无论是英语、德语还是中文,都包含语法。语法指的是一组定义语言结构和句法的规则。编程语言同样拥有其定义的语法。例如,if 语句后必须跟随括号内的条件或表达式,for 循环的括号内需要分号。
遵守语法规则优先于代码的即时含义。这意味着,即使代码能够运行,其逻辑也可能是错误的。这是因为代码在语法上是正确的,但语义(即意图)是错误的。
我们的代码本质上是文本。为了检查文本是否符合语法规则,我们需要逐步遍历文本,并尝试应用严格定义的语法规则。这个过程会产生一个解析树。解析树很容易生成,它只是将代码应用语法规则后打印出来,其中包含了原始代码和额外的结构信息。
然而,解析树通常非常庞大,即使对于少量代码也是如此。为了便于后续分析,我们需要一种更简洁的表示方法。这就是抽象语法树的由来。它的目标是从代码文本中,选择需要保留的关键部分进行分析,而忽略其余细节。
什么是语法树?
现在我们已经了解了语法树的动机,本节中我们来正式定义它。
语法树是代码文本的树状表示。它用一种形式化语言书写,树中的每个节点都代表代码的一部分。
生成抽象语法树比生成解析树更具挑战性。因为开发者需要决定保留语法结构中的哪些部分,省略哪些部分。它被广泛用于编译器、程序分析和程序转换系统。
构建语法树的核心在于正确地阅读代码。你需要从程序中退一步,识别代码中的大块结构(如循环、条件语句),然后按照语法规则将它们逐层分解。
以下是构建语法树的一个关键步骤列表:
- 识别代码中的主要语句块(如
while循环、return语句)。 - 对于每个语句块,根据其语法结构进行分解(例如,
while循环分解为条件和循环体)。 - 递归地对每个子部分应用相同的分解过程。
- 将分解后的部分按照树形结构连接起来。
语法树的核心要求与应用
上一节我们学习了如何构建语法树,本节中我们来看看它必须满足的核心要求以及实际应用场景。
虽然开发者可以自由决定语法树的具体形式,但它必须满足一些最低要求,这些要求通常非常符合逻辑:
- 可执行语句的顺序必须保留:例如,
return语句不应出现在while循环之前。 - 变量类型和每个声明的位置必须保留。
- 二元操作的左、右分量必须保留:例如,
A - B与B - A是不同的。
语法树的应用非常广泛,你可能已经在不知不觉中使用过它:
- 集成开发环境中的错误提示:当你在 IDE 中编写代码时,如果出现语法错误(如下划线警告),这通常是通过构建和分析语法树实现的。
- 代码验证:用于证明代码行为符合预期。
- 代码生成:作为编译器中的中间表示,用于最终生成目标代码。
- 重构:例如,在 IDE 中重命名一个变量,该更改会自动应用到所有引用处。这得益于语法树作为代码的中间表示,使得一处修改能全局同步。
需要注意的是,由于代码库可能非常庞大,通常不会始终在内存中保存完整的语法树,而是根据需要动态加载和操作部分语法树。
语法树构建实例
为了加深理解,让我们通过一个具体例子来实践如何将代码转换为语法树。
考虑以下简单的代码片段:
if (x > 0) {
write 1;
} else {
write 0;
}
我们如何将其转换为语法树?以下是构建步骤:
- 识别顶层结构:这是一个
if语句。 - 分解
if语句:根据语法,if语句包含一个条件、一个then分支和一个else分支。 - 分析条件
(x > 0):这是一个比较操作,包含左表达式x、比较符>和右表达式0。 - 分析
then分支write 1;:这是一个write语句,包含表达式1。 - 分析
else分支write 0;:这是一个write语句,包含表达式0。
最终,我们可以将这些部分组织成一棵树,根节点是 if 语句,其子节点分别是条件、then 分支和 else 分支,并继续向下分解。
综合练习与总结
现在,让我们尝试为一个更复杂的程序构建语法树,以巩固所学知识。
考虑以下程序:
int x = read();
while (x > 0) {
write x;
x = x - 1;
}
构建其语法树的思路如下:
- 识别程序的主要块:整个程序包含一个声明语句
int x = read();和一个while循环语句。 - 构建声明部分:声明包含类型
int、变量名x和初始化表达式read()。 - 构建
while循环:while语句包含条件(x > 0)和循环体。- 条件
(x > 0)可分解为:左表达式x、比较符>、右表达式0。 - 循环体包含两个语句:
write x;和x = x - 1;。write x;是一个写语句,其表达式是变量x。x = x - 1;是一个赋值语句。其右侧x - 1是一个二元操作表达式,包含左表达式x、二元操作符-和右表达式1。
通过将这些部分按树形层级连接起来,就得到了完整的语法树。

本节课中我们一起学习了语法树。我们了解到语法树是代码的树状中间表示,它通过遵循编程语言的语法规则,提炼出代码的核心结构。语法树不仅是编译器工作的基础,也为IDE的智能提示、代码重构和错误检测提供了强大支持。掌握如何阅读和构建语法树,有助于我们更深入地理解代码的结构和编译过程。
046:控制流图 📊

在本节课中,我们将要学习控制流图。控制流图是一种用于描述程序执行路径的图形化工具,它清晰地展示了程序中的所有可能路径和决策点,就像一张指引程序旅程的地图。
基本元素
上一节我们介绍了控制流图的概念,本节中我们来看看构成控制流图的基本元素。
- 开始节点:表示算法的起点,形状为圆角矩形。
- 结束/停止节点:表示算法的终点。
- 输入/输出节点:表示读取输入或写入输出操作,形状为平行四边形。在图中,
read是input.read或readIn的缩写,write是System.out.println的缩写。 - 赋值节点:表示变量赋值操作,形状为普通矩形。
- 条件分支节点:表示条件判断,形状为菱形。根据条件是否满足,程序会沿着“是”或“否”的分支执行。
- 汇合点:不同分支的路径可以在此重新汇合。
- 控制流转移:节点之间的箭头,表示程序执行的流向。
循环结构
了解了基本元素后,我们来看看如何用它们表示更复杂的结构,比如循环。
循环结构结合了条件分支和汇合点。它类似于 while 循环:首先判断条件(菱形节点),如果条件为真,则进入循环体执行语句;循环体结束后,控制流通过汇合点返回条件节点进行下一次判断。当条件为假时,程序沿“否”分支离开循环。
循环体内可以包含任何语句,如赋值、输入输出,甚至是其他循环或条件语句。
实例解析:求最大公约数
理论需要结合实践,让我们通过一个具体算法来理解控制流图的应用。
以下是寻找两个整数最大公约数(GCD)算法的控制流图。

该算法的逻辑如下:
- 读取两个整数
x和y。 - 进入循环,判断
x != y是否成立。 - 如果成立,则判断
x < y是否成立。- 如果
x < y成立,则执行y = y - x。 - 否则,执行
x = x - y。
- 如果
- 循环结束后,输出
x(或y,此时两者相等),即为最大公约数。
程序执行对应着从开始节点到结束节点的一条路径。路径上的所有节点都是要执行的操作或要判断的条件。在分支节点处,需要根据变量的当前值来评估条件,以决定后续路径。
让我们用具体数字 x=18, y=24 来演练一遍:
- 判断
18 != 24?是,进入循环体。 - 判断
18 < 24?是,执行y = 24 - 18 = 6。 - 返回条件判断
18 != 6?是,进入循环体。 - 判断
18 < 6?否,执行x = 18 - 6 = 12。 - 返回条件判断
12 != 6?是,进入循环体。 - 判断
12 < 6?否,执行x = 12 - 6 = 6。 - 返回条件判断
6 != 6?否,退出循环。 - 输出结果
6。
函数与方法调用
对于大型项目,控制流图可能变得非常庞大。这时,我们可以通过引入函数来模块化图表。
主要变化如下:
- 函数开始节点:在圆角矩形内写明函数名和输入参数。
- 函数调用节点:用一个两侧有双线的矩形表示。
- 函数子图:每个函数拥有独立的控制流子图,这有助于维持清晰的概览。
- 函数结束节点:不再使用“停止”,而是使用
return语句。一个函数可以有多个返回节点。
例如,一个 main 方法可能调用 readArray 函数来获取数组,再调用 min 函数计算最小值。我们无需在主线图中展开函数内部逻辑,只需将其视为黑盒。
函数实现示例:求最小值
让我们看看 min 函数的一个可能实现。假设输入数组 B = {20, 34, 12, 45, 12, 3}。
其控制流图逻辑如下:
- 初始化
result = B[0](即20),初始化索引i = 1。 - 循环判断
i < B.length? - 如果成立,则判断
B[i] < result?- 如果成立,则更新
result = B[i]。 - 否则,不更新
result。
- 如果成立,则更新
- 执行
i++,然后返回步骤2进行下一次循环判断。 - 当
i不再小于数组长度时,循环结束,返回result。
执行过程:
i=1,B[1]=34,34<20?否,i++。i=2,B[2]=12,12<20?是,result=12,i++。i=3,B[3]=45,45<12?否,i++。i=4,B[4]=12,12<12?否,i++。i=5,B[5]=3,3<12?是,result=3,i++。i=6,不满足i < 6,循环结束,返回3。
工具与练习
你可以使用绘图工具(如 Apollon)来绘制控制流图。在工具中选择“流程图”类型,即可使用左侧提供的各种形状(终端、处理、判断等)进行绘制。
尝试将本节课提到的 main 函数调用 readArray 和 min 的流程绘制成控制流图,无需展开函数内部细节。
总结
本节课中我们一起学习了控制流图。它是一种强大的可视化工具,能够清晰地描绘程序的执行逻辑和所有可能路径。对于任何Java程序,你都可以构建对应的控制流图,反之亦然。在调试复杂程序或设计算法逻辑时,绘制控制流图是理清思路、发现问题的有效方法。通过将函数模块化,我们还可以用其来管理大型项目的逻辑结构。





047:正则表达式

在本节课中,我们将要学习正则表达式。这是一种强大的工具,用于在文本中搜索、匹配和操作字符串。我们将从基本概念开始,逐步深入到更复杂的模式匹配,并学习如何在Java编程语言中应用正则表达式。
概述
正则表达式是一种由字符序列构成的模式,用于定义字符串的搜索规则。它们广泛应用于文本搜索、替换、输入验证、文件筛选和格式转换等场景。理解正则表达式将极大地提升你处理文本数据的能力。
正则表达式的应用
在深入了解其工作原理之前,我们先看看正则表达式的一些常见应用场景。
以下是正则表达式的几个主要应用领域:
- 搜索:在大量文本中快速定位特定类型的字符串,例如提取所有电子邮件地址。
- 替换:在文本中批量替换特定字符串,例如将HTML标签统一转换为小写。
- 验证:检查用户输入是否符合特定规则,例如密码的复杂度要求。
- 协调操作:根据文件名模式筛选和处理特定文件。
- 重新格式化:调整文本格式,使其符合另一个程序的输入要求。
正则表达式基础
上一节我们介绍了正则表达式的应用,本节中我们来看看它的基本概念和匹配规则。
正则表达式通常缩写为 Regex 或 Regexp。它们本质上是一种模式,用于从左到右扫描文本并进行匹配。匹配到的字符会被“消耗”,无法在后续匹配中重复使用。
考虑一个简单例子:正则表达式 ABA 应用于字符串 ABABAB。
- 从左开始匹配,成功匹配前三个字符
ABA。这些字符被消耗。 - 从第四个字符
B继续。正则表达式要求以A开头,但当前位置是B,因此不匹配。 - 移动到第五个字符
A,成功匹配ABA。 - 最后剩余的
B无法匹配。
因此,匹配结果是两个 ABA 序列。
构建正则表达式模式
正则表达式提供了一种简洁的方式来定义一组字符串的匹配规则,而无需逐一列出。
以下是一个匹配三个特定单词的例子:Handel、Händel、Haendel。
使用的正则表达式是:H(a|ä|ae?)ndel
让我们分解这个模式:
H:匹配开头的字母 H。(a|ä|ae?):括号内是一个选择组。|是“或”运算符。a匹配字母 a。ä匹配字母 ä。ae?匹配 a 后跟一个可选的 e(?表示前面的字符出现0次或1次)。
ndel:匹配结尾的字母序列 ndel。
这个模式可以灵活地匹配所有三个单词。需要注意的是,实现同一匹配目标的正则表达式通常不唯一。
正则表达式语法详解
为了构建更强大的模式,我们需要了解更多的操作符和语法。
以下是一些核心的语法元素:
- 基本匹配:
.:通配符,匹配除换行符外的任意单个字符。\w:匹配任何单词字符(字母、数字、下划线)。等价于[a-zA-Z0-9_]。\d:匹配任何数字。等价于[0-9]。\s:匹配任何空白字符(空格、制表符等)。
- 取反匹配:
\W:匹配任何非单词字符。\D:匹配任何非数字字符。\S:匹配任何非空白字符。
- 字符组:
[abc]:匹配 a、b 或 c 中的任意一个。[^abc]:匹配除 a、b、c 外的任意字符。[a-z]:匹配 a 到 z 范围内的任意小写字母。
- 转义字符:要匹配特殊字符本身(如
.、*、\),需要在前面加反斜杠\进行转义,例如\.、\*、\\。 - 量词:
*:匹配前面的元素零次或多次。+:匹配前面的元素一次或多次。?:匹配前面的元素零次或一次。{n}:匹配前面的元素恰好 n 次。{n,}:匹配前面的元素至少 n 次。{n,m}:匹配前面的元素至少 n 次,至多 m 次。
- 贪婪与懒惰:默认情况下,量词是“贪婪的”,会尽可能多地匹配字符。在量词后加
?可使其变为“懒惰的”,尽可能少地匹配。例如,对于字符串"aaaa",a+会匹配整个字符串,而a+?只匹配第一个"a"。 - 选择:
(ab|cd)匹配 ab 或 cd。
复杂模式示例
掌握了基本语法后,我们可以分析一些更复杂的实用正则表达式。
匹配24小时制时间:^([01]?[0-9]|2[0-3]):([0-5][0-9])(:([0-5][0-9]))?$
^和$分别匹配字符串的开始和结束,确保匹配整个时间字符串。([01]?[0-9]|2[0-3]):匹配小时部分(00-23)。([0-5][0-9]):匹配分钟部分(00-59)。(:([0-5][0-9]))?:可选的秒部分。
匹配TUM在线标识符:^[a-z]{2}\d{2}[a-z]{3}$
[a-z]{2}:匹配两个小写字母。\d{2}:匹配两个数字。[a-z]{3}:匹配三个小写字母。
密码强度验证:^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$
(?=.*[a-z]):正向先行断言,确保字符串中至少有一个小写字母。(?=.*[A-Z]):确保至少有一个大写字母。(?=.*\d):确保至少有一个数字。[a-zA-Z\d]{8,}:匹配由字母和数字组成、长度至少为8的字符串。
在Java中使用正则表达式
理论部分已经介绍完毕,现在让我们看看如何在Java程序中实际应用正则表达式。
Java通过 java.util.regex 包中的 Pattern 和 Matcher 类来支持正则表达式。
1. 使用String类的便捷方法
对于简单的匹配,可以直接使用 String 类的方法。
String text = "This is a sample text.";
String regex = "\\w.*"; // 匹配一个单词字符后跟任意字符(除换行符)零次或多次
boolean matches = text.matches(regex); // 检查整个字符串是否匹配
System.out.println(matches); // 输出: true
String[] words = text.split("\\s+"); // 按一个或多个空白字符分割字符串
System.out.println(words.length); // 输出单词数量
String replaced = text.replaceAll("\\s+", "\t"); // 将所有空白序列替换为制表符
System.out.println(replaced);
注意:在Java字符串中,反斜杠 \ 是转义字符,因此正则表达式中的 \w 需要写成 \\w。
2. 使用Pattern和Matcher类
对于需要重复使用同一模式或进行复杂操作的情况,使用 Pattern 和 Matcher 类更高效。
import java.util.regex.Pattern;
import java.util.regex.Matcher;
// 编译正则表达式,创建Pattern对象(可重复使用)
Pattern pattern = Pattern.compile("colou?r"); // 匹配"color"或"colour"
Matcher matcher = pattern.matcher("The color is red and the colour is blue.");
// 使用find()方法查找所有匹配项
while (matcher.find()) {
System.out.println("Found at index: " + matcher.start() + " to " + matcher.end());
System.out.println("Matched: " + matcher.group());
}
// 输出:
// Found at index: 4 to 9
// Matched: color
// Found at index: 27 to 33
// Matched: colour
Pattern.compile() 将正则表达式预编译,效率更高。Matcher 对象提供了 find(), start(), end(), group() 等方法用于高级匹配操作。
练习与总结
练习:编写一个正则表达式,用于匹配Java源代码文件的常见后缀(如 .java, .class, .jar)。要求后缀名长度为2到4个字符,且只能包含字母。
示例解决方案:
Pattern fileEndingPattern = Pattern.compile("\\.[a-zA-Z]{2,4}$");
String[] endings = {".java", ".class", ".jar", ".xml", ".txt", ".html", ".jpeg"};
for (String ending : endings) {
boolean isMatch = fileEndingPattern.matcher(ending).matches();
System.out.println(ending + " : " + isMatch);
}
本节课总结:
在本节课中,我们一起学习了正则表达式。我们了解了它的基本概念、丰富的语法规则以及在实际编程中的应用。重点包括:
- 正则表达式是一种用于文本匹配的强大模式语言。
- 它通过一系列特殊字符和操作符来定义复杂的搜索规则。
- 在Java中,可以通过
String类的便捷方法或Pattern和Matcher类来使用正则表达式。 - 掌握正则表达式能显著提高文本处理、数据验证和字符串操作的效率。

正则表达式是程序员工具箱中一个极其有用的工具,值得花时间深入学习和练习。
048:可用性与图形用户界面

在本节课中,我们将要学习图形用户界面的重要性,特别是可用性、原型设计以及相关的核心概念。我们将介绍不同的图形用户界面框架,并深入探讨模型-视图-控制器设计原则。最后,我们会以Java FX为例,讲解布局、形状和控件等基本元素。
图形用户界面简介
图形用户界面并非新事物,它已经发展了很多年。它允许用户通过符号、视觉隐喻和指点设备与计算机进行交互。如今,我们主要通过图形界面与计算机互动。在早期,人们只能通过控制台、终端或穿孔卡片与计算机交互,而现在我们拥有非常友好的图形界面,不仅出现在台式电脑上,也出现在移动设备上。
图形用户界面通常被认为是用户友好的交互方式。然而,也存在许多设计不佳的例子,我们将在今天展示一些。其核心思想是,任何可以通过计算机或计算机程序执行的操作,都以某种方式呈现在用户界面中。根据其重要性,这些操作可能被设计得很大或很小,易于发现或难以发现。开发者需要引导最终用户使用界面。
人机交互与输入输出循环
上一节我们介绍了图形界面的基本概念,本节中我们来看看用户如何与系统交互。用户界面和交互设计是一个涉及计算机科学和心理学的复杂领域。人类通过视觉、听觉、触觉和言语等多种感官与技术互动。如今,交互方式变得更加复杂,包括语音识别、移动设备触控甚至眼动追踪。

用户首先与硬件交互,例如屏幕、耳机或游戏手柄。硬件通过集成电路将信号转换为输入/输出,操作系统处理这些信号,然后将其传递给用户空间的软件应用程序。当开发者创建应用程序时,通常会使用框架来监听用户操作,例如点击按钮或移动鼠标,并据此做出响应。
这种交互过程被可视化为一个输入输出循环。用户进行操作(输入),界面随之变化(输出)。所有交互,无论是点击、语音还是眼动追踪,都遵循这一基本模式。因此,图形用户界面框架非常注重这些输入输出机制。开发者设计一个界面元素(如按钮),然后监听相关的动作或事件。当用户点击按钮时,会触发一个事件,开发者可以根据当前上下文和条件进行适当处理。
操作系统界面示例
理解了交互的基本原理后,我们来看看不同系统中的具体实现。以下是几个主流操作系统的用户界面示例:
- MacOS:具有特定的设计元素,例如窗口左上角的三个圆点控制按钮,这与Windows的设计有所不同。
- Windows:以其任务栏、通知区域和著名的“开始”按钮为特色。现代版本还引入了磁贴界面。
- Android:作为移动操作系统,屏幕空间较小。除了传统的鼠标/触控板输入,它主要依赖触摸输入,甚至支持多点触控手势(如双指缩放)。
- iOS:在安全性和可用性方面有特定设计,与Android存在差异。
这些例子展示了不同平台对“良好用户界面”的不同理解和实现。
应用程序界面与设计挑战
离开操作系统层面,我们进入应用程序领域。以Microsoft Excel为例,它是一个功能强大的应用程序,但其界面相当复杂,包含大量元素,如功能区和各种按钮。并非所有用户都按开发者预期的方式使用它,许多人只使用其基本功能。像Excel这样发展了二十多年的软件,其可用性和用户界面也在不断演变。
另一个极端例子是核电站的控制室界面。它们通常并不现代,且非常复杂,需要经过专门培训的专家来操作。这引出了手册的重要性:界面越复杂,就越需要一份好的使用手册。有人认为,如果需要手册,就说明用户界面设计得不好。但对于复杂系统,有时无法让所有最终用户都轻松上手。开发者需要考虑不同用户群体,从技术爱好者到不熟悉技术的老年人。
可用性灾难案例
糟糕的用户界面设计可能导致严重后果。2018年,夏威夷发生了一起错误的导弹警报事件。全岛居民都收到了“弹道导弹威胁来袭,立即寻找庇护所,这不是演习”的紧急警报。这引起了巨大的恐慌,幸运的是,这是一次误报。
事故原因在于操作人员使用的控制界面。界面中有两个选项:“PAcom state only”和“Drill PAcom state only”。操作人员本应点击“Drill”(演练)按钮进行测试,却不慎点击了真实的警报按钮。这个界面设计存在严重问题:两个关键选项区分度极低,可能缺少确认对话框,并且开发者低估了操作人员在真实警报压力下的心理状态。这导致了设计者的意图模型与用户的实际操作模型之间存在巨大差距。
可用性的定义与衡量
在看了这些例子后,让我们回到理论,思考可用性究竟是什么。许多人使用这个术语,但并不真正了解其含义。
可用性衡量的是用户能够多好地使用或利用系统功能。它基于以下五个可衡量的类别:
- 易学性:系统有多容易学习?学习速度有多快?这取决于系统的复杂性。
- 效率:用户完成特定任务需要多少步骤?可以通过快捷键、预设数据等方式为高级用户提升效率。
- 可记忆性:用户在长时间未使用系统后,能多快重新熟练操作?良好的设计线索和遵循最佳实践有助于提升可记忆性。
- 错误率:用户会犯多少错误?错误的严重性如何?从错误中恢复有多容易?例如,使用日期选择器而非文本框可以防止输入无效日期。
- 满意度/用户体验:使用系统是否愉快、有趣?这是比较主观的,涉及用户的情感和感受。
要在所有五个类别中都取得优异成绩非常困难,因为它们之间可能存在权衡。例如,为了提高易学性而简化界面,有时会牺牲高级用户的使用效率。
用户界面设计的挑战与原则
许多人都声称自己的系统“易于使用”,但这可能是最常被滥用的术语之一,尤其是在市场营销中。实际上,很多被如此宣传的系统仍然难以使用。
用户界面设计之所以困难,是因为开发者(设计并初步测试系统的人)通常不是软件的最终真实用户。最终用户可能没有技术背景。界面开发本质上是与用户的沟通。如果用户遇到持续的问题,这通常是系统而非用户的过错。然而,用户也并非总是正确的,因为他们不是设计专家。这类似于建造房屋:我们会咨询住户的意见,但最终由建筑师和工程师负责设计和建造。
用户界面设计需要大量时间和精力。据估计,带有图形用户界面的软件,其设计、实现和维护工作的50%都投入在了用户界面上。管理层的支持也至关重要,如果管理层不重视可用性,开发者就难以做好这项工作。
原型设计:快速验证与迭代
那么,如何设计出更好的用户界面呢?软件开发者常用的一种技术是原型设计。它指的是创建一个用户界面的模型或初稿,展示给其他人看。这有助于将开发者脑海中的界面设计具体化,以便进行评估、讨论和证伪。
原型设计非常有效,因为它允许开发者在编写代码之前快速探索不同的可能性。这个过程可以非常迅速,并能提供即时反馈。它改善了团队内部以及与最终用户之间的沟通。错误越早发现,修正成本就越低。在纸上进行简单的草图绘制是一种开销极低的方式。
在德国社会,失败常被视为坏事。但另一种观点认为,失败是好事,因为我们可以从中学习并改进。美国工程师亨利·彼得罗斯基提出了“设计的悖论方法”,他认为从失败的设计中能获得比从(自认为)总是成功的设计中更好的信息。社会也往往从灾难中比从近乎失败的成功中学到更多。这种“破坏性创新”意味着,有时需要先打破旧有模式,才能实现真正的创新。
测试与评估技术
在开发包含用户界面的系统时,需要测试其是否有效。有以下几种技术:
- 可用性测试:观察真实最终用户基于特定场景如何与系统交互,让他们执行任务,观察其是否知道该怎么做、是否成功或失败,并询问原因。通常5名测试用户就能提供有价值的反馈。
- 启发式评估:邀请专家根据预先制定的规则或最佳实践(启发式)来识别可用性问题。
以下是十条常用的可用性启发式原则:
- 系统状态可见性
- 系统与现实世界的匹配
- 用户控制与自由
- 一致性与标准
- 防错
- 识别而非回忆
- 使用的灵活性与效率
- 美观简洁的设计
- 帮助用户识别、诊断和恢复错误
- 帮助与文档
开发者可以将其作为设计指南,或在完成后对照检查,以发现并改进问题。
图形用户界面框架概览
在深入具体技术前,我们需要了解有哪些工具可供选择。首先,你需要考虑开发什么类型的应用程序:是为Web、移动设备还是桌面电脑?然后决定要支持哪些操作系统。基于这些选择,会有很多不同的框架:
- Web:需要处理HTML、CSS和JavaScript。
- macOS/iOS:可以使用Cocoa/Cocoa Touch(已逐渐被弃用),苹果现在推荐使用SwiftUI。
- Windows:可以使用.NET框架。
- Android:有Jetpack Compose。
- Java:有AWT、Swing、Java FX(更现代,本课将以此为例)以及游戏引擎如libGDX。
HTML是Web的基石,它提供了文本、链接、按钮等用户界面元素和布局。CSS则用于定义这些元素的样式,如颜色、大小、字体等。
SwiftUI是为所有苹果平台声明用户界面的现代方式,它允许开发者在预览中立即看到界面效果,便于快速原型设计。Jetpack Compose是Android上声明原生用户界面的现代工具包,它采用声明式语法,将界面元素嵌套在布局结构中。
总结

本节课中,我们一起学习了图形用户界面的核心概念。我们探讨了可用性的定义及其五个衡量类别(易学性、效率、可记忆性、错误率、满意度),并通过案例看到了糟糕设计可能带来的严重后果。我们介绍了通过原型设计来快速验证和迭代界面想法的重要性,以及可用性测试和启发式评估等测试技术。最后,我们概览了针对不同平台(Web、移动、桌面)的图形用户界面框架,例如SwiftUI、Jetpack Compose和Java FX。理解这些概念和工具,将帮助你创建出更友好、更有效的应用程序界面。
049:JavaFX

在本节课中,我们将深入学习一个用于构建Java图形用户界面的特定框架——JavaFX。这是一个开源框架,允许你基于Java为桌面、移动和嵌入式系统构建用户界面。
🎨 JavaFX 简介
JavaFX是一个开源框架,它允许你基于Java为桌面、移动和嵌入式系统构建用户界面。网上有可供参考的教程。它内置了大量组件,如按钮、文本字段、表格、树形视图等,使你能够轻松创建简单乃至复杂的用户界面。
你可以使用来自Web的技术,例如用CSS定义样式表,并手动或通过编程方式应用它们。它甚至可以进行2D和3D图形处理,并包含一个用于显示Web应用程序的Web视图。
它完全用Java编写,因此与你的技能完全兼容,并且是平台独立的。
📄 FXML 与场景构建器
JavaFX包含一种内置的领域特定语言——FXML。它允许你将用户界面的创建与应用程序逻辑的实现分离开来,我们在之前的示例中也见过类似的做法。
它甚至包含一个场景构建器,允许你通过拖放元素来创建用户界面,这意味着你可以使用一个用户界面来创建另一个用户界面。
FXML 的工作原理
FXML是一种基于XML的领域特定语言,它允许你构建用户界面的基本结构,并将应用程序逻辑分离到你的代码中。网上有一个FXML入门教程,我们这里不深入太多细节,因为这已经相当高级。
你可以看到,基于这些XML文本,你可以定义一个具有特定内边距的网格窗格。然后,你可以有“欢迎”文本、用户名标签、用于输入用户名的文本字段、密码标签以及用于输入密码的密码字段。密码字段内置了输入后不显示明文的功能,而是显示圆点,以防止身后的人看到你的密码。
中间有一些间距,然后我们有一个右对齐的按钮,文本为“登录”。
通过这种方式,你可以构建用户界面的主要构建块。你还可以定义所谓的“动作”。例如,如果用户按下按钮,我们在这里有一个 onAction 元素。然后,你可以在代码中引用一个方法来处理提交按钮的动作,这个方法将被调用。
如果你还记得我们开始时讨论的输入输出循环,这就是其中一个输入。你可以配置操作系统,确保它调用代码中的特定方法 handleSubmitButtonAction,然后你可以响应用户的输入。
使用场景构建器
使用场景构建器,你可以可视化地创建用户界面,场景构建器会自动为你生成相应的FXML代码。你也可以查看并修改FXML,然后场景构建器会反映这些更改。这样,你就有了用户界面的可视化表示,这对于原型设计和快速创建用户界面的初稿或最小可行产品特别有帮助。
这里有一个网站描述了更多关于场景构建器的信息。你可以看到一个集成到NetBeans IDE中的示例。左侧库中有各种元素,你可以通过拖放将它们放入用户界面,按需组合。右侧有一个检查器,你可以在其中更改属性,例如应用特定样式或根据库中的元素定义特定值,从而以视觉方式创建有意义的内容。
🏗️ JavaFX 应用程序结构
一个JavaFX应用程序由舞台、场景和节点组成。JavaFX应用程序实际上可以有多个舞台,但在大多数简单情况下,只有一个舞台。在一个舞台中,我可以有多个场景。在一个场景中(这是我们窗口的主要容器),我有节点,这些节点内部可以包含其他节点。例如,你可以有一个垂直布局的元素(这是一个节点),然后你可以添加子节点(其他节点),这些子节点可以是按钮、输入框、密码字段、表格等。这就是基本思想:你有舞台、场景,然后在场景中有一个节点的层次结构。
你可以在这里看到一个视觉示例,这是一个简单的JavaFX应用程序。舞台基本上是整个窗口,顶部有菜单栏(这是系统提供的,你不需要实现窗口右上角X按钮的行为)。你还有一个标题和图标。在菜单栏下方,你的场景实际上开始了。在这个场景中,你可以有多个节点。在这个例子中,我们只有一个节点,所以是一个非常简单的场景,只有一个“点击我”按钮。
🚀 创建第一个简单的 JavaFX 应用程序
现在,我们想动手实践,一起创建我们的第一个简单的JavaFX应用程序。请启动你的IDE,我们现在一起操作。如果你有任何问题,请举手,我们也有助教在场,他们可以在你遇到问题时提供帮助。
我将打开IntelliJ IDEA,创建一个新的Gradle项目。我将其命名为 JavaFXHelloWorld,存储在默认位置。我们使用Java、Gradle、Groovy。然后点击创建。这还不是一个JavaFX应用程序,只是一个简单的Java应用程序。
你可以配置SDK,并等待Gradle构建完成。
下一步,我们需要打开我们的 build.gradle 文件,并配置应用程序,以便我们能够使用JavaFX。JavaFX是一个外部依赖,没有内置在Java中,所以我们需要告诉我们的项目我们想要使用它。
你可以简单地复制粘贴这个文件,或者单独操作。复制粘贴更容易一些,所以我推荐这样做。你打开根文件夹下的 build.gradle 文件,然后基本上可以用幻灯片上的内容替换所有内容,并重新加载Gradle。这将确保下载适当的插件。在这个特定示例中,我们下载一个JavaFX插件,它将确保我们下载所有JavaFX依赖项以及到你的本地操作系统的绑定。例如,如果你使用Windows,你将拥有与我略有不同的外部库集,因为你可以看到我们在这里定义了几个模块,如 javafx.controls。你可以在左侧的“外部库”下看到它们。例如,我拥有一个针对ARM 64位架构的本地绑定。你可能为Windows或Unix拥有其他东西。这实际上确保了应用程序将在你的特定操作系统上运行。
我们这里没有太多东西,基本上有这个特定版本的JavaFX插件,这允许我们定义版本(也是17,与我们使用的Java版本相同)。我们还有我们的主类名,这个主类名尚未实现,所以这是我们稍后需要确保的,我们不称它为 main,而是 JavaFXHelloWorld。
到目前为止有任何问题吗?
然后,我们想在项目中创建一个新的包(如果还没有的话),并添加一个名为 JavaFXHelloWorld 的新类。我已经有了这个包。如果你没有,请确保创建它。然后,在包内,我可以按 Command + N(在Mac上)或相应的快捷键来创建新类,并给它命名为 JavaFXHelloWorld。然后,我也从幻灯片上复制所有内容,稍后我会向你解释。
如果你的 gradle 文件成功下载了依赖项,那么这里的导入应该没有错误。
这里我们导入了 JavaFX 应用程序和 JavaFX 舞台,以便使用这些类。我们自己的 JavaFXHelloWorld 类扩展了 JavaFX 应用程序。所以,我们扩展了 Application,这是一个子类,记住,这是继承。当我们扩展一个抽象类时(实际上,Application 是一个抽象类),我们需要实现所有抽象方法。如果我不实现这个方法,IntelliJ会立即警告我,并建议实现 start 方法。我也可以这样做,实现它,但那样它会是空的。如果你从幻灯片复制代码,那么我们基本上调用输入参数 primaryStage(这是我们窗口的主舞台)。在这个主舞台内,我们设置一个标题(这是框架的预构建方法),并调用 show 方法使其对用户可见。
这对于使用图形用户界面的应用程序开发来说非常常见:你有一个框架,框架为你做了很多事情,但同时也给了你自定义的可能性。这就是其中之一:我们扩展 Application,然后当我们运行应用程序时,我们调用 start 方法,我们可以在 start 方法中设计我们的用户界面。我们可以使用为我们创建的主舞台,并按我们想要的方式操作它。
现在我们还不能真正启动应用程序,我认为我们需要做更多的事情。完成这一步后,我们现在需要做一些对你来说可能是新的事情:我们需要将应用程序模块化。这是因为JavaFX也使用模块。通过模块,你可以将大型应用程序结构化为更小的子系统,并简化开发。所以,我们做的是:我们进入我们的主包(这个),然后我们创建一个 module-info.java 文件。我们按 Command + N,这里有一个预定义的模板,所以我们不需要自己动手。我们已经得到了一些现有的代码。在这个模块定义中,我们基本上定义了与其他模块的依赖关系,以及我们的模块是否对其他模块可见。这都是关于信息隐藏和使依赖关系显式化。
再次,你可以复制粘贴幻灯片46的代码,并在这里输入。我们指定我们需要JavaFX的图形部分、FXML部分、控件部分等。这些是我们集成到应用程序中并使用的模块。如果它们在项目范围内不可用,我们的项目将无法工作。我们做的是:我们的包叫做 de.tum.in.ase,我们打开它,使其对框架可用,以便框架可以使用它。我们也导出它。这基本上就是我们在这里所做的。你不需要完全理解这一点。这些只是背景中需要配置的机械性事务,以使其工作。但基本思想是:一些子系统依赖于其他子系统,我们可以在这里配置这些依赖关系。
到目前为止有任何问题吗?
然后,我们可以转到 JavaFXHelloWorld 并点击运行按钮。希望它能工作,它需要编译一下。现在,我们有了我们非常简单的应用程序。如果你不相信这是我们的应用程序,你可以尝试更改它。例如,将 primaryStage.setTitle("Hello and World") 改为 primaryStage.setTitle("TUM"),然后再次运行应用程序。现在它叫做“TUM”。它可能看起来与你略有不同:如果你使用Windows,关闭应用程序的按钮在右上角;我在左上角,所以我也可以关闭应用程序。
我们还可以做我们到目前为止开发的普通Java控制台应用程序所知道的一切:我们可以调试应用程序,我们可以向终端输出日志语句或控制台语句,以识别发生了什么。所以,通常可能的一切在这里也是可能的。
对这个非常简单的、从零开始的JavaFX应用程序有任何问题吗?
从现在开始,我们假设你能够做到这一点,我们将在讲座中向你展示一些其他示例。然后,你可以随时回到这个“Hello World”游乐场。如果你想理解或尝试,可以从幻灯片复制代码,将其放入你在这里的主类中,放入应用程序的 start 方法中,然后尝试使用按钮、矩形、样式等如何工作。
🔄 应用程序生命周期
现在回到幻灯片,我想快速告诉你一些对应用程序很重要的事情:生命周期。每个图形用户界面框架都有一个所谓的生命周期,这是因为后台有一个运行循环,通常是一个 while (true) 循环,它响应可能来自硬件的事件,并且必须确保一切正常工作、一切正确设置。第二个原因是,图形用户界面必须在你的屏幕上渲染。如果你更改用户界面,框架必须重新渲染它。例如,如果你将一个按钮的颜色从绿色改为红色,这应该非常流畅。
你的应用程序需要确保,例如,每秒60帧。在你有时间做某事的这些毫秒内,生命周期实际上发生。我们可以区分整个应用程序的生命周期和用户界面的生命周期。
首先,我们想谈谈整个应用程序的生命周期。当你创建并启动一个应用程序时,应用程序的 init 方法被调用。我们没有处理 init 方法,因为我们只是重用了超类中的方法,但你可以重写它并自定义它。然后 start 方法被调用。现在应用程序在前台,运行时等待应用程序完成。应用程序只有在你终止它,或者最终用户按下红色X按钮(或在Windows上按下X按钮)关闭最后一个窗口时才会完成。只有当所有窗口都关闭时,应用程序才会关闭。在应用程序关闭之前,stop 方法将被调用。在 stop 方法中(我们也没有重写),你可以清理任何东西。例如,如果你仍有文件打开,你可以关闭它们;或者如果你有网络连接,需要将某些内容保存到云端,你可以这样做,以便用户不会丢失数据。你可以重写 init、start 和 stop 来执行应用程序的任何初始化或资源清理。
📝 使用 FXML 和控制器扩展教程
现在,让我们稍微扩展一下教程,回到我们的 JavaFXHelloWorld 应用程序,现在我们想快速尝试一下FXML。
我们现在要做的是调整我们的示例,并将 JavaFXHelloWorld 中的代码更改为此。所以,不是在代码中定义我们的用户界面(例如,定义按钮 button = new Button(),布局 layout = new Layout(),然后 layout.getChildren().add(button)),而是想导入一个我们在单独文件中设计的FXML文件,并在那里定义用户界面。
让我们看看这是如何工作的。我们还需要抛出一个异常,所以你可能会发现复制粘贴所有内容更容易。我们还使用 FXMLLoader 类加载文件,这将成为我们项目的一部分。我们基于从文件获取的父根创建一个场景,然后将场景放入我们的主舞台中。场景将包装根元素,根元素就像我们这里的一个节点、一个父节点。
所以,我们有了加载文件的代码,但我们还没有文件。在我们实际创建文件之前,我们现在还想引入一个新概念:控制器。
控制器是一个控制你的应用程序并确保你与最终用户相应交互的类。与其在视图类或应用程序类中处理所有事情,不如稍后根据你在应用程序中显示的内容拥有多个控制器。例如,对于登录视图,我们可能有一个登录控制器。登录控制器可以处理最终用户的操作。如果最终用户输入正确的用户名和密码,我们可以将用户转发到下一页;如果用户输入错误的密码,我们可以显示错误消息。这在一个控制器中很好地完成,该控制器拥有对我们用户界面中所有视图的引用,并且可以与用户界面以及我们在后台数据库中的模型进行交互(我们可能存储了用户的凭据)。
让我们创建这个 SignInController 类。这里有一个小技巧:你可以直接复制粘贴所有内容。与其创建一个新类,你可以在包打开时直接粘贴,然后所有内容都会自动为你粘贴,你不需要手动操作,名称也会自动从代码中派生。
我们这里有什么?我们有一个密码字段(这是一个用户界面控件)、一个文本和一个方法,所有这些都用 @FXML 注解标注。这基本上告诉你的IDE,它们在FXML文件中使用,并且可以确保名称正确,因为如果文件与你的代码不一致,它将无法工作。
有了这个,我们基本上可以控制我们的应用程序。现在我们需要创建示例文件。在资源文件夹中(仍然在 main 下,但不在 java 代码文件夹中,在 resources 文件夹中),我们现在创建一个新文件。将其命名为 example.fxml,并从幻灯片50复制粘贴所有内容。
现在我们不假设你完全理解这是如何工作的,正如我之前所说,你可以使用场景构建器来创建这个。但我会快速浏览一下,向你展示它是如何工作的。我们有一些导入,类似于Java中的导入,只是我们在这里有这种有趣的书写方式。然后我们基本上定义了一个网格窗格。网格窗格是一种布局,我们稍后会看到它的含义。这个网格窗格属于我们的 SignInController,所以这个窗格(基本上是整个窗口)将被分配给登录控制器。登录控制器负责这个窗格。我们可以在XML文本中配置一些对齐方式和属性。
然后我们有“欢迎”文本、用户名标签、用户可以与之交互的文本字段、另一个密码标签、密码字段和我们的登录按钮。所以,这基本上是我们之前看到的相同示例。在登录按钮中,我们有一个动作 handleSubmitButtonAction。我们甚至可以导航:我可以按 Command 键并点击它,然后转到代码;或者在代码中,我可以按 Command 键并点击该方法,然后返回到FXML文件。所以IntelliJ非常好,它将两者结合在一起。
对于几个UI元素,我想在代码中引用它们,以便使用它们的值,特别是文本字段。我可以通过使用这里的 fx:id 元素并给它们一个名称来做到这一点。这样,我基本上可以在代码中引用它们。例如,如果我按 Command 键并点击这里的密码字段,我将看到这个字段基本上被注入到我的 SignInController 中。稍后,我可以在这里访问它的值。动作目标也是如此。动作目标基本上是我们想在这里输入文本的地方。所以,每当用户现在点击提交按钮时,我们将通过设置这个文本(称为动作目标)来说“登录按钮被按下”。当然,你现在可能会说“动作目标”不是最好的名字,然后我们也可以重构它。IntelliJ也为我们提供了重构的可能性。我可以说重构 -> 重命名,也许这不是动作目标,也许我称之为 infoText。然后我重构它,现在我这里有 infoText 作为我的属性,并且当我使用该属性时,好处是:如果它用 @FXML 注解并且在文件中使用,那么我不需要担心这些值为空,因为框架将确保它们不为空。框架基本上读取你的XML文件,并为它拥有的每个元素创建一个新对象(用于网格窗格、文本、标签等),所有在这里引用的元素将自动注入,你不需要自己动手。所以这相当方便。在这里你也可以看到它在FXML中被引用。
对此有任何问题吗?
现在,我们基本上可以再次运行应用程序。所以,回到我们的 JavaFXHelloWorld 类,点击运行按钮。现在我们的用户界面看起来不同了。如果这对你不起作用,可能是某些配置错误:你可能将示例文件放在了错误的文件夹中,或者你可能在这里或那里有拼写错误,然后你需要识别并修复它。如果你没有任何编译错误,那么可能不那么容易,但你可以请一位助教帮助你。
关于我们的第二个教程就这么多。你已经看到了一个没有FXML的非常简单的JavaFX应用程序,如何从零开始完成它。我们还向你展示了如何基于FXML设计用户界面。我们给了你一个FXML文件,但你可以使用场景构建器来创建它,或者当然你也可以手动设计它。可能IntelliJ甚至有一些很好的建议,所以如果我创建一个文本并在这里关闭它,它将自动附加结束标签,所以你不需要——它不像一个简单的文本编辑器,它真的帮助你设计这个。我们可以马上试一试:也许不是这里的文本,也许是最后的文本。所以让我们快速更改这个,我们添加另一个文本。我们给它行索引7(再多一个),列索引1也可以。我们想在这里放什么?也许我们说 text="Exit"(我现在不是很有创意)。现在,如果我们再次启动应用程序,你会看到我们有另一个文本叫做“Exit”。这样,理论上,如果你熟悉它,你可以在这里设计你的用户界面。
如果没有更多问题,那么我们将继续。在进入较长的休息之前,我将快速概述一下最重要的JavaFX概念:我们将讨论布局、用户输入控件、形状和样式。对于所有这些,我们将在较长的休息后有更详细的课程。
📐 JavaFX 布局
JavaFX中有几种不同的布局,你可以在这里看到最重要的几种。我们可以有简单的垂直布局,称为 VBox,所以我们有多个元素,它们垂直堆叠。我们也可以有一个 HBox。我们可以有一个 TilePane、一个 GridPane、一个 BorderPane(我们专注于五个元素:顶部、左侧、右侧、底部和中间)、一个 StackPane(你将它们堆叠在一起)、一个 FlowPane 和一个 AnchorPane。所有这些都可以进行不同的配置,并允许你以最适合你的方式构建用户界面。这通常是你设计视图的外部结构,然后在这些 VBox 中,你甚至可以拥有另一个嵌套布局。例如,我可以有一个 VBox 布局,在 VBox 布局中我可以有一个 HBox 布局。所以,通过你可以在这里定义的层次结构,这可能会变得相对复杂。
🎛️ 用户输入控件
我们有许多不同的标准控件用于用户输入。你可以使用这些标准控件,但如果你水平较高,也可以创建自己的控件。我们有手风琴、复选框、链接、切换按钮、进度条、简单标签、简单文本字段、表格、树形视图(你可以做类似文件系统的操作,有文件夹和文件)等等。有许多不同的预建用户输入。这里的想法始终是遵循模型-视图-控制器风格:视图实际上是用户输入元素,但视图本身不处理交互,视图将交互委托给控制器(如我们见过的登录控制器)。因此,视图中的每个用户输入(如果我按下按钮,如果我在树形视图或列表中选择一个元素,如果我在选择框中选择某些内容),那么这个交互会转到控制器,然后控制器拥有你的应用程序的模型,以便对其采取行动。
例如,如果我点击一个按钮来删除列表中显示的一个元素,那么控制器将基本上确保这也在你的模型中被删除。然后,当模型更新时,它可以向视图发送更新事件,视图也可以请求模型以显示它。假设你有一个狗的列表,你在用户界面中显示它们,带有图片和名称。你可以向列表中添加新的狗,但也可以删除现有的狗。所以有一个删除按钮,当你按下删除按钮时,你要确保在你的模型中,你也删除被选中的狗元素。然后,你更新所有显示你的狗的视图,因为你可能有多个视图,并且你可能需要在它们之间同步数据。
这样,我们满足了与最终用户的输入输出循环。记住最终用户的大脑和感官:输入进入视图,但视图不在内部处理它,它委托给控制器。控制器可能会更改模型,如果登录正确,我们也可能会更新视图,并导航到下一页,然后我们可以再次向用户显示更改后的数据或更新后的视图。通过使用模型-视图-控制器,你以更易于重用和未来更易于更改的方式分离了任务。
关于用户输入控件就这么多。
🔷 形状与样式
接下来,我们可以用JavaFX绘制许多不同的形状:简单的线条或更复杂的线条,我们可以绘制文本、矩形、圆形、椭圆等等。当然,对于所有这些,你必须定义某些属性:颜色;如果你有一个多边形,你想使用多少个点;如果你有一个圆形,半径是多少;如果你有一个椭圆,宽度半径和高度半径是多少,等等。所以,所有这些预定义的形状都有某些值。如果你填充它们,你可以在应用程序中使用它们来绘制非常漂亮的用户界面或图像。
在样式方面,默认样式有点丑陋和无聊,正如你所见。但你可以创建自己的样式:闪亮的橙色、深蓝色、丰富的蓝色或任何你喜欢的样式,基于阴影、多种颜色、不同字体、内边距等。有许多不同的方式来样式化你的用户界面。你可以将样式应用于一个特定的UI元素,但这可能很繁琐:我不想将样式应用于我在应用程序中定义的每个按钮。通过CSS(层叠样式表),你基本上可以确保所有按钮都以闪亮的橙色或丰富的蓝色显示。这样,你甚至可以让最终用户自定义它,并为他们提供多种样式选项。例如,如果你现在使用网站和应用程序,我们经常有亮色模式和暗色模式。这也可以用那些CSS样式来完成。
📚 总结

在本节课中,我们一起学习了JavaFX框架的基础知识。我们介绍了JavaFX的核心概念,包括其开源特性、跨平台能力以及丰富的内置组件。我们深入探讨了FXML和场景构建器,它们允许我们将用户界面设计与业务逻辑分离。我们动手创建了一个简单的JavaFX应用程序,并了解了应用程序的生命周期。此外,我们还引入了模型-视图-控制器模式,并通过示例展示了如何使用FXML和控制器来构建更结构化的应用程序。最后,我们概述了JavaFX中的布局、控件、形状和样式等关键概念,为后续深入学习奠定了基础。
050:布局

在本节课中,我们将要学习如何在 JavaFX 中定义布局。布局决定了用户界面中各个控件的位置、大小和排列方式。我们将介绍几种常用的布局容器,并通过代码示例展示它们的使用方法。
坐标系与布局基础
在深入具体布局之前,需要理解 JavaFX 的坐标系系统。与其他一些系统不同,JavaFX 的坐标系原点 (0, 0) 位于窗口的左上角。
这意味着,如果一个控件的 Y 坐标值为正数,它将向下移动。理解这一点对后续的布局工作至关重要。
布局类的基本职责是决定其内部的子节点在窗口中的分布方式。一个典型的 JavaFX 应用结构如下:从一个 Stage(舞台)开始,它通常包含一个 Scene(场景),场景内有一个根节点,这个根节点就是一个布局容器(例如 Pane)。在这个布局容器内,我们可以添加多个控件节点。
布局决定了控件的对齐方式、是否形成矩阵排列,以及在窗口调整大小时控件是否会随之缩放。JavaFX 提供了多种方式来定制这些行为。
堆栈布局
上一节我们介绍了布局的基本概念,本节中我们来看看第一种布局:堆栈布局。
StackPane 将其所有子节点像堆栈一样层叠放置,后添加的节点会覆盖在先添加的节点之上。
以下是定义一个 StackPane 并向其中添加一个标签的代码示例:
StackPane pane = new StackPane();
pane.getChildren().add(new Label("Hello World!"));
在这段代码中,pane.getChildren() 返回一个列表,代表了该布局容器内的所有元素。我们通过 add 方法向其中添加控件。如果添加多个标签,它们将相互层叠。
接着,我们将这个 pane 设置为场景的根节点,并将场景放入舞台中展示:
Scene scene = new Scene(pane, 300, 200);
primaryStage.setScene(scene);
primaryStage.show();
这几行代码是 JavaFX 应用启动的通用模式:创建布局,包装进场景,再放入舞台,最后显示。
流式布局
接下来,我们学习流式布局。FlowPane 会将其子节点按行(水平方向)或按列(垂直方向)依次排列。
如果一行(或一列)的空间不足,子节点会自动“流动”到下一行(或下一列)。
以下是创建并配置一个水平 FlowPane 的示例:
FlowPane pane = new FlowPane();
pane.setHgap(10); // 设置水平间距
pane.setVgap(10); // 设置垂直间距
pane.setAlignment(Pos.CENTER); // 设置整体对齐方式
然后,我们可以向其中添加多个控件:
pane.getChildren().addAll(
new Label("Label 1"),
new Label("Label 2"),
new Button("Button 1"),
new Label("Label 3"),
new Button("Button 2")
);
运行此应用,当调整窗口大小时,可以观察到控件会自动换行排列。
网格布局
上一节我们介绍了自动排列的流式布局,本节中我们来看看可以精确定位的网格布局。
GridPane 将界面划分为行和列的网格,我们可以将控件放置到特定的单元格中。计算机科学家从0开始计数,所以第一列是 column 0,第一行是 row 0。
以下是如何使用 GridPane:
GridPane pane = new GridPane();
pane.setHgap(10);
pane.setVgap(10);
// 将标签添加到第0列,第0行
pane.add(new Label("Label 1"), 0, 0);
// 将按钮添加到第1列,第0行
pane.add(new Button("Button 1"), 1, 0);
// 将文本框添加到第1列,第1行,并横跨2列
pane.add(new TextField("Text Field 1"), 1, 1, 2, 1);
通过 add 方法,我们可以指定控件所在的列索引和行索引。还可以通过额外的参数设置控件横跨的列数 (colspan) 和行数 (rowspan),从而实现更复杂的布局。
边框布局与自定义布局
接下来,我们介绍一种适用于常见应用结构的布局:边框布局。
BorderPane 将界面划分为五个区域:顶部 (Top)、底部 (Bottom)、左侧 (Left)、右侧 (Right) 和中心 (Center)。这非常适合用于创建具有导航栏、侧边栏和状态栏的应用。
以下是 BorderPane 的基本用法:
BorderPane pane = new BorderPane();
pane.setTop(new HBox(new Label("Top Navigation"))); // 顶部放一个水平布局
pane.setLeft(new VBox(new Label("Left Menu"))); // 左侧放一个垂直布局
pane.setCenter(new Label("Main Content Area")); // 中心放主要内容
pane.setBottom(new Label("Status Bar")); // 底部放状态栏
每个区域都可以放置任何节点,包括另一个布局容器,这为构建复杂界面提供了灵活性。
此外,我们还可以创建自定义布局。例如,通过继承 StackPane 并重写其方法,我们可以完全控制子节点的排列逻辑。在自定义布局中,我们也可以方便地应用样式:
public class CustomPane extends StackPane {
public CustomPane() {
this.setStyle("-fx-border-color: blue; -fx-padding: 10;");
}
}
水平与垂直箱式布局
最后,我们来看两种最简单的线性布局:HBox 和 VBox。
HBox 将其所有子节点在单行内水平排列。VBox 则将其所有子节点在单列内垂直排列。
它们的用法非常直观:
// 水平布局,设置间距为10像素
HBox hbox = new HBox(10);
hbox.getChildren().addAll(new Button("Yes"), new Button("No"), new Button("Maybe"));
// 垂直布局,设置内边距和样式
VBox vbox = new VBox();
vbox.setPadding(new Insets(15));
vbox.setStyle("-fx-background-color: lightgray;");
vbox.getChildren().addAll(new Label("Item 1"), new Label("Item 2"));
这两种布局是构建更复杂界面的基础模块,经常作为子布局被嵌入到 BorderPane 或其他容器中。

本节课中我们一起学习了 JavaFX 的核心布局容器。我们从基础的坐标系和布局概念讲起,依次探讨了用于层叠的 StackPane、用于自动换行的 FlowPane、用于精确定位的 GridPane、用于结构化应用的 BorderPane,以及用于线性排列的 HBox 和 VBox。理解并熟练运用这些布局,是构建美观、响应式 JavaFX 用户界面的关键。
051:用户输入 👨💻

在本节课中,我们将要学习如何处理用户输入,这是构建交互式应用程序的核心。我们将回顾MVC模式,并介绍JavaFX中一些预定义的UI控件,如按钮和文本字段,并通过一个实例练习来巩固所学知识。
回顾MVC模式 🔄
上一节我们介绍了图形用户界面的基础。本节中,我们来看看处理用户交互的经典架构模式——模型-视图-控制器(MVC)。
- 视图 负责处理用户输入。例如,按钮是视图的一部分。
- 控制器 包含实现逻辑和算法。按钮不应自行决定控制逻辑,而应将其委托给控制器。控制器可以操作模型或显示不同的视图。
- 模型 代表应用程序的数据和状态。当模型被更改(无论是用户操作还是外部事件,如股价更新),视图应相应更新。
- 观察者机制 视图观察模型,所有模型的变化都会相应地反映在视图中。视图也可以向模型请求新数据。
另一个例子是分页列表。如果列表元素很多,可能无法全部显示在一页上。此时可以只显示20个元素。当用户点击“下一页”按钮查看下20个元素时,控制器需要(可能先从服务器)获取数据,更新模型,然后视图再根据模型中的新数据对象更新显示。列表视图本身没有改变,只是显示了基于模型的不同数据。
JavaFX中的预定义控件 🧩
理解了MVC模式后,我们来看看JavaFX中用于构建视图和处理输入的一些基本构件。
JavaFX提供了一系列预定义的控件来处理用户输入。
以下是几种常见的控件类型:
- 文本字段 和 密码字段:允许最终用户点击、聚焦并输入文本。这些文本随后可被应用程序访问和使用。
- 复选框:用于提供状态选项,用户可以在两个选项中进行选择。
- 标签:对最终用户是只读的,用于显示信息。
- 列表视图 和 表格视图:包含数据,用户可以滚动查看(如在移动设备上)。这些视图可以与其他控件结合,例如,列表中的每个元素都可以是一个按钮。邮件应用通常这样工作:手机上的邮件列表,用手指点击其中一封邮件(背后是点击或按钮机制),就会导航到下一页查看邮件全文。
- 其他控件:还包括日期选择器、滚动窗格、滑块、超链接等,足以设计简单的用户界面。当然,你也可以设计和使用自己的UI控件,但这会更复杂一些。
控件使用示例 💡
现在,我们通过一些具体例子来看看如何在实际中使用这些控件。
标签示例
我们再次使用网格面板,其中包含一些标签。创建新标签时,基本上是指定其文本,然后可以定义字体并将其添加到布局中。你可以看到这里有多个不同的标签:有些有文本,有些有数字,有些有特定字体,有些有特定的填充颜色。基本属性可以直接更改,但如前所述,也可以使用CSS样式来实现。标签可能是用于只读信息的最基本的UI控件。
按钮与动作事件
按钮很有趣,因为它允许我们与应用程序交互,并触发所谓的动作事件(例如,当用户点击、按钮被按下时)。在JavaFX中,可以像在FXML中看到的那样,将动作事件定义为对方法的引用,也可以调用 setOnAction 方法。
调用 setOnAction 方法时,可以传递一个匿名内部类,也可以传递一个Lambda表达式。这里我们看到使用Lambda表达式的 setOnAction 方法,这个Lambda表达式非常简单,只是向控制台打印一些内容。当然,我们也可以做更复杂的事情,比如保存数据、删除数据或关闭应用程序。
使用匿名内部类是旧方法,而使用Lambda表达式是新方法。
按钮应用示例
这是一个在现有应用中使用按钮的示例。我们有一个只有一个按钮“Print Hello TUM”的按钮应用。我们再次使用VBox作为非常简单的布局,并设置一些属性。然后创建按钮,设置其动作事件(非常简单的一个),将按钮添加到VBox的子节点中,最后指定场景和舞台。现在,如果按下按钮,就会在控制台得到一行输出,并且可以多次执行此操作。
按钮还可以通过 setGraphic 方法包含图形元素。你甚至可以在按钮内放置进度条(例如,在执行长时间操作时向用户可视化进度),或者放入图像,甚至将另一个按钮放入按钮内(尽管这通常没有意义)。
复杂按钮示例
这是一个更复杂的例子。我们有两个按钮:“显示”按钮和“删除”按钮。“显示”按钮有一个动作,将文本设置为特定的标签;“删除”按钮有一个动作,基本上将标签的文本设置为空或删除文本。现在,如果由此创建一个应用,点击“显示”时文本会显示,点击“删除”时文本会被移除。这是一个相对简单的例子,但你可以看到,这已经由大约25行代码组成,所以已经需要写一些代码了。好处是,有很多现成的例子(包括幻灯片中的例子)可以用于我们稍后将进行的课堂练习。
文本字段控件
另一个非常重要的控件是文本字段。当我们有文本字段时,通常希望访问其中的文本。这意味着我们要么预定义它(由程序赋予一个值),要么在用户输入值后,我们希望检索该值并用它做些什么。
这里有一个非常简单的例子:我们有一个文本字段,用户可以输入内容;然后我们点击“打印输出”按钮,文本基本上就被放入一个标签中。我们定义了一个标签和一个名为 txt 的文本字段。然后在 setOnAction 方法中,我们传递一个Lambda表达式。这个Lambda表达式从文本字段获取文本,并直接将其设置为标签的文本。此外,它还将填充颜色设置为蓝紫色,这是我们可以额外做的事情,但不是必需的。
课堂练习:邮件生成器 📧
掌握了文本字段、按钮动作以及如何获取文本并将其用于其他UI元素后,我们请你完成一个简单的课堂练习:开发一个非常简单的邮件生成器用户界面。
构思是:用户可以在两个不同的文本字段中输入名字和所属机构,然后有一个“生成”按钮。点击此按钮后,将在标签中以 名字@机构.de 的格式打印出电子邮件。你可以重用上一张幻灯片中文本字段应用的代码。你已经有一个文本字段、一个按钮、一个标签,请调整它,使其拥有两个文本字段(让用户知道每个字段的用途),并且点击“打印输出”时基本上就是生成电子邮件。这是一个Artemis练习,请前往Artemis并开始练习。
由于时间关系,我们需要继续,但你可以在今晚之前完成练习,并从Artemis获得额外反馈。
示例解决方案 👀


让我们快速看一下示例解决方案。这是一种可能的实现方式:
我们有一个邮件生成器应用,使用网格面板和一些值来构建美观的用户界面。然后,我们有一个名为“Name”的标签,将其添加到网格面板;第二个标签“Institution”,以不同的行索引添加到网格面板。
接着,我们有一个文本字段,设置了提示文本“Insert your first name”,并将其添加到网格的特定列和行位置。接下来,在第二部分,我们添加另一个用于机构的文本字段,设置提示文本“Insert your institution”,也将其添加到网格面板。
然后,我们有用于显示生成结果的电子邮件标签,以及一个用于生成它的按钮。以上就是用户界面的设置。现在,当用户按下按钮时,输入将被转换为输出。
在生成按钮上,我们设置了 setOnAction 并使用一个Lambda表达式。当这个Lambda表达式被调用时,我们设置电子邮件标签的文本为:nameField.getText() + “@” + institutionField.getText() + “.de”。每当用户按下按钮时,这段代码就会被调用。我们甚至可以在这里设置断点,在调试应用时进行验证。
然后,我们再次将按钮以特定索引添加到网格面板,最后完成应用程序的构建。
如果把代码放在一张幻灯片上,看起来就是这样。如果你现在看不清,稍后可以从PDF中获取。请确保导入了正确的包。运行后,界面如右图所示:左边是标签,右边是带有提示文本的文本字段(灰色提示应在此处输入的内容),机构字段同理,然后是生成按钮。如果我们什么都不做,标签是空的。但如果我们输入名字(例如“student”)和机构(例如“tum”),然后点击按钮,就会得到 student@tum.de。
总结 📝

本节课中,我们一起学习了如何处理用户输入。我们回顾了MVC模式如何将用户界面、业务逻辑和数据分离。我们重点介绍了JavaFX中的基本UI控件,特别是用于触发操作的按钮和用于接收文本输入的文本字段。通过邮件生成器的练习,我们实践了如何组合这些控件来创建一个简单的交互式应用程序:获取用户输入,处理它,并更新界面显示结果。这是构建更复杂应用的基础。
052:图形形状 🎨

在本节课中,我们将要学习如何使用 Java FX 提供的各种图形形状类,例如线条、矩形、圆形等。我们将了解如何创建这些形状、设置它们的属性,并将它们添加到布局中进行显示。
形状类概述
Java FX 提供了许多不同的形状类,用于绘制文本、线条、圆形、矩形等。形状是 Node 类的子类,因此可以添加到布局中。它是一个抽象类,拥有多个具体的子类,如 Text、Line、Rectangle、Circle 等。
以下是创建线条的一个简单示例:
Line line = new Line(startX, startY, endX, endY);
我们还可以将线条的端点属性绑定到布局的尺寸上。这样,当应用程序窗口大小改变时,线条会自动调整。
绘制线条
上一节我们介绍了形状类的基本概念,本节中我们来看看如何具体绘制一条线条。
创建线条需要四个参数:起点的 x 坐标、起点的 y 坐标、终点的 x 坐标和终点的 y 坐标。我们可以设置线条的描边宽度和颜色。
以下是设置线条属性的示例:
line1.setStrokeWidth(3); // 设置线条宽度为3
line1.setStroke(Color.BLUE); // 设置线条颜色为蓝色
我们可以将多条线条添加到一个布局(例如 Pane)中。一个关键技巧是将线条的端点坐标绑定到布局的宽度或高度属性上,从而实现响应式变化。
// 将 line1 的终点X坐标绑定到布局的宽度
line1.endXProperty().bind(pane.widthProperty());
绘制矩形
了解了线条的绘制后,我们来看看如何绘制矩形。矩形的创建同样直观。
创建矩形需要四个参数:起始点的 x 坐标、起始点的 y 坐标、矩形的宽度和高度。
以下是创建并设置矩形的示例:
Rectangle rect = new Rectangle(x, y, width, height);
rect.setFill(Color.WHITE); // 设置填充颜色
rect.setStroke(Color.BLACK); // 设置描边颜色
我们可以将多个矩形添加到布局中,并利用循环和随机颜色来创建动态效果。
for (int i = 0; i < 4; i++) {
Rectangle r = new Rectangle(...);
r.setFill(Color.rgb(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
pane.getChildren().add(r);
}
此外,我们还可以调整矩形的其他属性,如旋转角度。
绘制圆形和椭圆
接下来,我们学习如何绘制圆形和椭圆。圆形是椭圆的特例。
创建圆形需要三个参数:圆心的 x 坐标、圆心的 y 坐标和半径。
Circle circle = new Circle(centerX, centerY, radius);
circle.setStroke(Color.BLACK); // 设置描边颜色
circle.setFill(Color.YELLOW); // 设置填充颜色
而椭圆则需要两个半径参数:radiusX 和 radiusY。当这两个值相等时,椭圆就变成了圆形。
Ellipse ellipse = new Ellipse(centerX, centerY, radiusX, radiusY);
绘制多边形
最后,我们来看看如何绘制由多个点连接而成的多边形。
多边形由一系列的点定义。我们需要依次添加每个点的 x 和 y 坐标。系统会自动将最后一个点与第一个点连接起来,形成封闭图形。
以下是创建一个多边形的示例:
Polygon polygon = new Polygon();
polygon.getPoints().addAll(x1, y1, x2, y2, x3, y3, ...);
polygon.setStroke(Color.BLUE);
polygon.setFill(Color.GREEN);
如何查阅 API 文档
在使用 Java FX 或其他框架时,查阅官方文档是了解类属性和方法的有效途径。
有以下几种方法:
- 在 IDE 中查看:在 IntelliJ IDEA 等集成开发环境中,将光标放在类名上,使用
Ctrl(Windows)或Cmd(Mac)键点击,即可跳转到类定义。在左侧的项目结构窗口中可以查看类的方法列表。 - 查阅在线 Java 文档:在浏览器中搜索 “Java FX [类名] documentation”,即可找到官方文档,其中详细说明了每个类的属性、方法和用法。

本节课中我们一起学习了 Java FX 中各种图形形状的绘制方法,包括线条、矩形、圆形、椭圆和多边形。我们掌握了创建形状、设置其样式属性(如颜色、宽度)以及将其添加到布局中的基本技能。同时,我们也了解了如何通过查阅代码或官方文档来探索框架的更多功能。这些是构建图形化用户界面的重要基础。
053:样式设计

在本节课中,我们将要学习图形用户界面(GUI)开发中的一个重要方面:样式设计。我们将探讨如何通过样式设计提升应用的用户体验,并学习在JavaFX中实现样式的技术方法。
概述
样式设计是GUI开发的关键环节。为界面元素(如窗口、布局和控件)提供一致的样式,可以显著提升应用的用户体验。一个设计精良的界面能增加用户使用应用的愉悦感。
上一节我们介绍了布局和控件,本节中我们来看看如何为它们添加美观的样式。
样式的重要性
对比以下两个例子,右侧的设计通常优于左侧。左侧设计中,灰色与紫色的对比度不佳,导致文字难以阅读。右侧设计中,主要操作(登录)与次要操作(注册)的视觉层次不清晰,降低了使用效率。
通过合理的样式设计,我们可以解决这些问题。例如,将最重要的“登录”按钮设计得最突出,将次要的“注册”按钮设计得可见但不抢眼,而极少用到的“忘记密码”链接则可以设计得非常低调。
样式设计技术
在JavaFX中,有多种方式可以为控件设置样式。
直接设置控件属性
你可以直接在控件上设置字体、颜色和位置等属性。
- 字体:可以使用操作系统已有的字体(如Calibre),并指定字重和大小。
label.setFont(Font.font("Calibre", FontWeight.BOLD, 14)); - 颜色:可以设置文本的填充颜色。
label.setTextFill(Color.GREEN); // 使用预定义颜色 label.setTextFill(Color.web("#FF5733")); // 使用Web十六进制颜色码 label.setTextFill(Color.rgb(255, 87, 51)); // 使用RGB值(0-255) label.setTextFill(Color.hsb(360, 100, 100)); // 使用HSB(色相、饱和度、亮度)值 - 位置:在某些布局中,可以手动设置控件的X和Y坐标。
label.setLayoutX(100); label.setLayoutY(50);
使用CSS(层叠样式表)
CSS允许你将样式代码与定义图形用户界面的业务逻辑代码分离。这种分离使得样式可以复用,便于维护和保持一致性。
CSS样式规则包含一个选择器(Selector)和一系列的属性-值对(Property-Value Pairs)。选择器是后续在代码中引用该样式的键名。
以下是一个CSS样式定义的例子:
.my-label-style {
-fx-font-family: "Cursive";
-fx-font-size: 14pt;
-fx-font-style: italic;
-fx-font-weight: bold;
-fx-border-style: dotted;
}
在JavaFX代码中,你可以将这个样式类(.my-label-style)分配给特定的标签控件。
网页开发也广泛使用CSS。你可以在浏览器(如Chrome)中打开开发者工具(Inspect Elements),查看并实时修改任何网页元素的CSS样式,这有助于理解和调试样式。
实践练习:绘制TUM徽标
现在,我们将通过一个练习来应用所学知识。请在JavaFX中使用矩形绘制慕尼黑工业大学(TUM)的徽标。
以下是实现思路:
- 观察TUM徽标,它由5个垂直的蓝色矩形和3个水平的蓝色矩形在白色背景上组合而成。
- 有两种绘制思路:
- 先绘制一个白色背景矩形,然后在上面叠加蓝色矩形。
- 先绘制一个大的蓝色矩形,然后在上面叠加5个白色矩形(覆盖部分区域以形成图案)。这种方法可能更简单,因为无需单独考虑水平矩形。
- 我们提供了起始代码,其中定义了一个
unit(单位)为50。整个画布宽为24 * unit,高为15 * unit。每个矩形的尺寸和位置都应基于这个unit来计算。
例如,垂直矩形的起始X坐标可能是4*unit, 8*unit, 12*unit等。所有垂直矩形具有相同的高度和宽度,所有水平矩形也是如此。
请尝试完成这个练习。理解如何在JavaFX中计算位置和绘制形状,这对后续学习和考试非常重要。
总结
本节课中我们一起学习了GUI开发中的样式设计。
- 我们认识到用户体验(UX)和可用性至关重要,良好的设计是应用成功的关键因素之一。
- 我们探讨了实现良好可用性的技术,如原型设计、可用性测试和启发式评估。
- 我们以JavaFX为例,学习了布局、控件、形状和样式的核心概念。尽管不同的GUI框架具体实现不同,但这些核心概念是相通的。
- 我们介绍了在JavaFX中应用样式的两种主要方式:直接设置控件属性和使用CSS。
- 最后,我们通过绘制TUM徽标的练习,实践了使用矩形和坐标计算来构建图形界面。


建议你阅读更多关于可用性的资料(例如Don Norman的《设计心理学》),并在未来的项目开发中充分考虑样式与用户体验。
054:递归的原理


在本节课中,我们将学习递归的原理。递归是处理复杂问题的一种强大方法,它通过将大问题分解为更小的相似问题来求解。我们将了解直接递归与间接递归的区别,学习如何将递归应用于算法,并理解终止条件在递归中的关键作用。
递归的基本概念
上一节我们介绍了迭代处理数据的方法。本节中,我们来看看另一种处理问题的强大方法——递归过程。
递归是计算机科学中的一个重要概念。其基本思想是:一个函数可以调用自身。这听起来可能有些奇怪,因为如果函数总是用相同的输入调用自身,程序将永远不会结束,陷入无限循环。
递归的真正力量在于“分而治之”的原则。开始时,我们面对一个困难的问题。我们解决其中的一小部分,然后对剩余部分再次运行相同的算法,从而逐步减小问题的规模,直到遇到一个可以立即解决的简单问题。然后,我们通过组合这些部分解来得到最终解。
递归的基本原理是,我们反复运行相同模式的计算,但每次计算都使问题变得更简单,直到达到一个平凡点。
在Java中实现递归有两种方式:一种是函数直接调用自身,称为直接递归;另一种是函数A调用函数B,而函数B又调用函数A,这称为间接递归。
更正式的定义是:如果一个方法F在其方法体内调用了F自身,则F是直接递归的。如果一个方法F在其方法体内调用了方法G,而方法G在其方法体内又调用了F,则F是间接递归的。
每次递归调用都会创建该方法的一个新实例,每个实例都有自己的局部变量和参数。这些实例通常对外部不可见,但会以一种“栈”的数据结构形式在内部组织起来。Java内部正是使用栈来管理这些调用。当达到平凡情况(终止条件)时,程序开始逐层返回,直到回到最初的起点。
递归示例:阶乘函数
为了理解递归如何工作,让我们看一个简单的递归函数示例:阶乘函数。
阶乘函数在数学上表示为 n!,它计算从1到n的所有整数的乘积。递归地看,计算 n! 是一个困难的问题,但计算 (n-1)! 则稍微容易一些。我们知道,n! 的解的一部分是 n 乘以 (n-1)! 的结果。
因此,在每一步递归中,我们都将问题的规模减小(从n减到n-1),直到达到一个平凡情况:0!,我们知道它等于1。然后,我们沿着调用栈返回,组合这些部分结果。
以下是阶乘函数的Java代码实现:
public static int factorial(int n) {
if (n <= 0) { // 终止条件
return 1;
} else {
return n * factorial(n - 1); // 递归调用
}
}
在这个函数中:
if (n <= 0) return 1;是终止条件。它定义了最简单、可以直接求解的情况。return n * factorial(n - 1);是递归调用。函数在这里调用自身,但参数变小了(n-1)。
这是一个直接递归的例子,因为函数在其自身内部调用了自己。
让我们以计算 factorial(4) 为例,看看内部发生了什么:
factorial(4)调用4 * factorial(3)factorial(3)调用3 * factorial(2)factorial(2)调用2 * factorial(1)factorial(1)调用1 * factorial(0)factorial(0)满足终止条件,返回1- 然后程序开始返回:
factorial(1)返回1 * 1 = 1factorial(2)返回2 * 1 = 2factorial(3)返回3 * 2 = 6factorial(4)返回4 * 6 = 24
最终得到结果24。
终止条件与栈溢出
上一节我们看到了一个正确终止的递归函数。本节中,我们来看看如果递归没有正确终止会发生什么。
终止条件是递归函数中至关重要的一部分。它确保了递归过程最终会停止。如果没有终止条件,或者终止条件永远无法达到,函数将无限地调用自身。
每次递归调用都会在内存栈上占用一些空间来存储该次调用的局部变量和返回地址。如果递归调用过深(例如,由于缺少终止条件或参数未向终止条件收敛),栈空间将被耗尽,导致“栈溢出”错误。这意味着计算机没有足够的内存来存储所有中间调用。
例如,考虑一个修改版的阶乘函数,其终止条件是 if (n == 100)。如果我们用小于100的数(如5)调用它,函数会不断用 n-1 调用自身,数值变得越来越负,永远无法等于100,最终导致栈溢出。
因此,设计递归函数时,必须确保:
- 存在一个明确的终止条件。
- 每次递归调用都使问题更接近终止条件。
递归算法应用:最大公约数
理解了递归的基本结构和终止条件的重要性后,我们来看一个更实用的递归算法例子:计算两个整数的最大公约数。
最大公约数是能同时整除两个数的最大正整数。计算GCD的一个经典递归算法是欧几里得算法。
其递归思想如下:
- 终止条件:如果两个数
n和m相等,那么这个数本身就是它们的GCD。 - 递归步骤:
- 如果
n < m,那么gcd(n, m)等于gcd(n, m-n)。 - 如果
n > m,那么gcd(n, m)等于gcd(n-m, m)。
- 如果
这个算法的关键在于,n 和 m 的最大公约数与 n 和 m-n(或 n-m 和 m)的最大公约数相同。通过反复相减,我们使两个数不断减小,最终它们会变得相等,从而触发终止条件。
以下是该算法的Java实现:
public static int gcd(int n, int m) {
if (n == m) { // 终止条件
return n;
} else if (n < m) {
return gcd(n, m - n); // 递归调用情况1
} else { // n > m
return gcd(n - m, m); // 递归调用情况2
}
}
这个函数也是直接递归的。因为每次递归调用,参数 n 和 m 都在向彼此靠拢(通过相减),所以最终一定能达到 n == m 的终止条件,从而保证函数会正确终止。
总结
本节课中,我们一起学习了递归的原理。
我们首先了解了递归是“分而治之”原则的一种实现,它通过函数调用自身来将复杂问题分解为更小的相似问题。
我们区分了直接递归(函数调用自身)和间接递归(函数通过其他函数间接调用自身)。
我们认识到终止条件是递归函数不可或缺的部分,它定义了最简单、可直接求解的情况,并确保递归过程能够结束。缺少或无法达到终止条件会导致栈溢出错误。
通过阶乘函数和最大公约数这两个例子,我们实践了如何设计递归函数,包括定义终止条件和编写向终止条件收敛的递归步骤。
递归是一种强大的编程工具,尤其适用于解决具有自相似结构的问题,如树形结构的遍历、分治算法等。掌握递归思维,将帮助你更优雅地解决许多复杂的编程问题。


055:汉诺塔示例 🗼

在本节课中,我们将学习一个经典的递归问题示例——汉诺塔。我们将通过这个例子,深入理解递归思想如何将复杂问题分解为更小的、可重复解决的子问题。
汉诺塔问题介绍
汉诺塔是一个由法国数学家爱德华·卢卡斯提出的数学问题。它最初被描述为一个简单的游戏,背景设定在印度瓦拉纳西的寺庙中,但更广为人知的名字是“汉诺塔”。
在原始故事中,有三根柱子。其中一根柱子上有64个金盘,每个上方的盘子都比下方的盘子小,因此形成了一个由64个金盘组成的金字塔。出于显而易见的原因,我们在这个例子中不会使用64个盘子,而是用4个盘子来演示,但规则是相同的。
汉诺塔的规则是:你需要将这个金字塔堆从最左边的柱子移动到最右边的柱子。但在每次移动中,你只能移动一个盘子,并且不允许将较大的盘子放在较小的盘子之上。
游戏规则演示
让我们尝试一下。初始情况下,四个盘子在柱子1上。对于最顶部的元素(最小的盘子),它是唯一可以移动的,因为下方的元素被挡住了。我们有两个选择:可以移动到柱子2或柱子3。稍后你会明白为什么我选择移动到柱子2。
接下来,柱子1上现在最顶部的元素(次小的盘子)不能放到柱子2上,因为它比柱子2上当前的元素大。所以唯一的选择是将其移动到柱子3。
现在,我们不能从柱子1移动任何盘子,因为柱子2和柱子3上都有更小的盘子。我们只有两种可能的移动:要么将柱子3上的元素移回柱子1(这将是徒劳的,因为我们没有接近目标),要么将柱子2上的元素移动到柱子3。
到目前为止很简单。下一步,我们将柱子1上现在最顶部的元素移动到柱子2。然后将柱子3上最顶部的元素移动到柱子1。接着将柱子3上现在最顶部的元素移动到柱子2。最后将柱子1上的元素移动到柱子2。
现在想象一下,如果我们现在在柱子2上的盘子堆在柱子3上,那么问题就解决了。如果我们只考虑 n-1 个盘子,那么我们现在就有了一个中间目标。我们实际上已经解决了一个更简单情况的问题。
接下来我们需要做的是:将柱子1上最底部的元素移动到柱子3,然后再将其余元素排序移回。我将更快地浏览这个过程。有任何问题吗?看起来相当简单。
如何用代码实现?
这是递归威力第一次非常明显体现的时候。
对于有0个元素的情况,问题很简单,我们什么都不用做。即使只有一个元素,问题也很简单:将其从柱子1移动到柱子3即可。当堆栈变大时,问题才变得棘手。
但请记住我们之前所说的:如果我们有一个大问题,并且我们有一种方法可以在问题稍微简单时解决它(比如我们有一个基本情况),那么我们就可以使用递归。我们可以解决中间的每一步。
解决思路是:对于一个高度为 H 的塔,我们首先将 H-1 个元素移动到柱子2(暂时不要问我们如何移动,稍后再考虑)。然后,我们移动最底部的元素。最后,我们将所有从柱子2临时存放的元素移回到柱子3上。
第一步和第三步正是我们使用递归的地方。因为你看到,我们解决了 H 高度的问题,前提是我们有 H-1 高度的解决方案。而 H-1 高度的解决方案又依赖于 H-2 高度的解决方案,如此反复,直到最终到达只需要移动一个元素的简单情况。这基本上就是第二种情况:将最底部的切片移动到目标位置。这就是我们如何解决这个相当复杂的问题。
代码实现
以下是代码的实现方式。
如果塔的高度大于0,我们采取以下方法。首先,我们需要找到一个可以放置元素的空闲位置。为此,我们有一个 free 函数,我稍后会解释。假设我们有这个函数,我们知道将元素移动到哪里。我们将除了最底部之外的所有元素从第一根柱子移动到空闲位置。这基本上是这里的第二行代码:move(height - 1, from, freeSpot)。
然后我们执行一次移动,将最底部的元素从A移动到B(即目标位置)。接着,我们将临时停放在中间柱子上的其他元素移动到我们刚刚移动的那个元素之上。这些都是递归调用。因此,我们可以说,让问题变小一点。最终,因为我们在每次递归调用中总是递减高度,我们最终会到达高度为0的点,那时我们什么都不做。
这里很重要,A和B必须彼此不同。如果我们说将柱子从它所在的位置移动到它所在的位置,那就太简单了。A是源柱子,B是目标柱子。
这里我们再次有直接的递归方法调用。我们现在必须解决的问题是,因为你看,最下面的三行代码我们已经解决了(System.out.println 等,这些都很简单,你们在第一讲就学过了)。递归,当然,你们现在刚刚学到。但你可以直接使用它们,因为你面前已经有这个方法了。所以只剩下一个方法需要解决,那就是 free 方法。
寻找空闲位置
例如,如果我们需要将元素从柱子1移动到柱子2,我们的空闲位置是柱子3。如果从柱子1移动到柱子3,空闲位置是柱子2。
这个矩阵基本上向我们展示了这一点,因为我们不允许将元素从它所在的柱子移动到它所在的柱子。对角线没有被填充,但其余部分是有意义的。
我们如何解决这个问题呢?我们有一个中间步骤。我们可以看到,结果似乎取决于两个元素的乘积。这是一个中间步骤。例如,如果我们计算1+2,得到3;1+3,得到4。这是我们以下方式解决 free 问题的中间步骤。
想象一下,如果我们为此使用一个 switch 语句,基本上有一种方法来解决 a+b 的问题:如果是3,我们返回3;如果是4,我们返回2;如果是5,我们返回1。记住,我们这里基本上有一个中间步骤。我们可以这样做,但有点不实用。我们这里有一个 switch 语句,但在数学上有一个更简单的方法来解决这个问题,那就是 6 - (a + b)。
想象一下,如果 a+b 是3,6-3=3;6-4=2;6-5=1。是的,6减去默认值不行,所以对于默认情况,较低的那个有点难以处理。但我们在这里假设没有默认情况,即不允许从同一根柱子移动,所以我们基本上可以忽略这里的默认情况。
完整代码与思考
现在这就是代码,展示了这个移动应该如何工作。顶部是我们之前看过的移动操作,下面我们有 free 方法。在最底部,我们有 main 方法,它一开始读取塔的高度,然后开始第一次递归调用。这就是所谓的初始调用。因此,这个初始调用使用我们刚刚输入的高度,并且我们想将其从柱子1移动到3。然后递归方法一次又一次地调用自己,每次调用时,你都有另一个 System.out.println 命令。因此,我们一步一步地得到了所有可以进行的移动的概述。
在我们继续之前,做一个小思考实验:我们刚刚用4个盘子做了演示,你已经看到我们需要相当多的移动步数。现在,想象一下原始想法中的64个盘子,我们大约有 2^64 - 1 次移动,大约是1800亿亿次。
如果想象每次移动只花费十分之一秒,那么我们需要580亿年才能解决这个问题,而宇宙的年龄只有大约140亿年。或者想象一下,如果我们必须用一个字节来记录每次移动(我们大致可以做到),那么所有移动的完整文档将需要大约1800亿亿字节。这是18000拍字节。这只是为了让你了解64个盘子的问题有多么巨大。所以如果有人手工移动,在世界末日之前他们也完成不了。因此,这是一个实际上无法解决的问题,只是理论上作为一个思想实验,它可以工作。
课堂练习
在Artemis上,你的任务现在是创建一个递归函数,该函数可以确定从0到值n的整数之和,值n应该是一个输入参数。你应该思考什么是基本情况,然后一步一步地求和,直到得到最终结果。为此,请使用递归调用。
在第一版中,使用递归。在第二版中,尝试使用你已经学过的迭代方法来实现相同的功能,例如使用 for 循环。
练习解答
求和其实很简单。如果你的输入小于1(因为我们只有整数,在这种情况下只能是0),那么你返回0。0是求和中的中性基元,因为它不改变任何值。
对于大于或等于1的任何数,你知道数字本身将是解的一部分。然后在下一步,你只是减小问题规模,即 n-1,并再次将其放入 sum 函数方法中,并添加到目前已有的结果中。因此,最终,例如,如果你从8开始,你将得到 8 + 7 + 6 + 5 + 4 + 3 + 2 + 1 + 0。
对于小的附加任务,我们假设我们想迭代地做。所以我们有一个局部变量来累加元素。然后我们只需要一个 for 循环,从 i 到 n。我们将每个元素添加到这个局部变量上,最后返回解。
在 main 方法中,有两种使用方式:一种是通过递归版本的 sum,另一种是通过迭代版本。它们应该给你相同的结果。在递归版本中,我们再次有一个直接的递归方法调用和一个终止条件。

本节课中,我们一起学习了汉诺塔这个经典的递归问题。我们了解了它的规则,演示了解决过程,并深入探讨了如何用递归思想将其分解为更小的子问题。通过代码实现,我们看到了递归如何优雅地处理这种自相似的问题。最后,我们还通过一个求和的练习巩固了对递归和迭代两种编程范式的理解。递归的核心在于定义清晰的基本情况和能够不断向基本情况推进的递归步骤。
056:二分查找示例 🎯

在本节课中,我们将要学习一个重要的递归算法示例:二分查找。我们将了解它的工作原理、如何用递归和迭代两种方式实现它,并理解其相对于简单线性查找的效率优势。
概述
上一节我们介绍了递归的基本概念。本节中我们来看看一个更具体、更高效的递归应用:二分查找。这是一种在已排序的数组中快速查找特定元素位置的算法。
线性查找:基础方法
首先,我们考虑查找问题最直观的解法。假设我们有一个数字列表(数组),我们想知道数字7是否在列表中,如果在,它的位置(索引)是多少。
以下是我们的初始方法(通常称为线性查找):
- 从数组开头遍历到结尾。
- 将每个元素与目标数字(例如7)进行比较。
- 如果找到匹配项,则返回当前索引。
- 如果遍历完整个数组仍未找到,则返回-1。
用代码描述如下:
int linearSearch(int[] array, int target) {
for (int i = 0; i < array.length; i++) {
if (array[i] == target) {
return i; // 找到目标,返回索引
}
}
return -1; // 未找到目标
}
在一个包含7个元素的示例数组 [17, 3, 21, 42, 5, 9, 7] 中查找数字7,我们需要进行7次比较(最坏情况)。对于包含数百万个元素的庞大列表,这可能需要数百万次比较,平均而言也需要一半的比较次数。这种方法效率不高。
二分查找:更高效的方法
因此,我们需要一种更好的方法,这就是二分查找。它的工作原理基于一个前提:数组必须是已排序的。
以下是二分查找的核心步骤:
- 查看数组中间的元素。
- 如果目标值等于中间元素,则查找成功。
- 如果目标值小于中间元素,则只需在数组的左半部分继续查找(可以完全忽略右半部分)。
- 如果目标值大于中间元素,则只需在数组的右半部分继续查找(可以完全忽略左半部分)。
- 在选定的半部分重复此过程,直到找到目标或搜索范围为空。
之所以称为“二分”查找,是因为每一步都将搜索范围减半,我们总是在“向左”或“向右”两个选项中选择。
让我们用同一个数组(但先排序)[3, 5, 7, 9, 17, 21, 42] 来演示查找数字7:
- 中间元素是9(索引3)。7 < 9,因此我们丢弃右半部分,只在左半部分
[3, 5, 7]中查找。 - 新范围的中间元素是5(索引1)。7 > 5,因此我们丢弃左半部分,只在右半部分
[7]中查找。 - 剩余数组长度为1,中间元素就是7。匹配成功,返回其索引(在原排序数组中是索引2)。
我们只用了3次比较就找到了目标,而不是线性查找的7次。对于长度为 2^n 的数组,最多只需要 n 次比较。这使得二分查找的时间复杂度为 O(log n),远优于线性查找的 O(n)。
递归实现
了解了递归的工作原理后,用代码实现二分查找就相对简单了。
首先,我们定义一个主要方法 find,它接收数组、目标元素以及当前搜索范围的起始和结束索引。
// 内部递归方法
private static int find(int[] a, int element, int startIndex, int endIndex) {
// 计算当前搜索范围的中间索引
int middle = (startIndex + endIndex) / 2;
// 情况1:在中间直接找到目标
if (a[middle] == element) {
return middle;
}
// 情况2:搜索范围已缩小到单个元素且不是目标
if (startIndex == endIndex) {
return -1; // 未找到
}
// 情况3:根据比较结果,在左半部分或右半部分递归查找
if (element > a[middle]) {
// 目标更大,在右半部分查找(中间索引+1 到 结束索引)
return find(a, element, middle + 1, endIndex);
} else {
// 目标更小,在左半部分查找(起始索引 到 中间索引-1)
return find(a, element, startIndex, middle - 1);
}
}
为了方便调用,我们创建一个重载方法,只需传入数组和目标值,它自动从整个数组范围开始查找。
// 公开的简化接口
public static int find(int[] a, int element) {
return find(a, element, 0, a.length - 1); // 初始调用,搜索整个数组
}
调用栈与递归终止
在上面的递归实现中,find 方法内部调用了自身。这形成了一种称为调用栈的结构。每次递归调用都会将新的参数(新的搜索范围)压入栈中。当找到目标或搜索范围为空(startIndex == endIndex)时,递归开始“返回”,结果沿着调用栈向上传递,直到最初的调用者。
确保递归终止至关重要。在我们的算法中,终止条件有两个:
- 找到目标元素(
a[middle] == element)。 - 搜索范围缩小到单个元素且不是目标(
startIndex == endIndex)。
每次递归调用都会将搜索范围减半,因此最终必然会达到长度为1的情况,从而保证算法总会终止。设计递归算法时,必须仔细验证这种终止性,否则程序可能陷入无限循环并最终崩溃。
迭代实现
二分查找也可以不使用递归,而以迭代(循环)的方式实现。两种方式逻辑相同,只是控制流程不同。
以下是迭代版本的二分查找:
public static int iterativeBinarySearch(int[] a, int element) {
int startIndex = 0;
int endIndex = a.length - 1;
while (startIndex <= endIndex) {
int middle = (startIndex + endIndex) / 2;
if (a[middle] == element) {
return middle; // 找到目标
}
if (element > a[middle]) {
startIndex = middle + 1; // 在右半部分继续查找
} else {
endIndex = middle - 1; // 在左半部分继续查找
}
}
return -1; // 未找到目标
}
迭代版本使用 while 循环和两个变量 startIndex、endIndex 来跟踪当前搜索范围,而不是通过递归调用传递参数。它的效率与递归版本相同,且避免了递归可能带来的调用栈开销。
关于一个优化检查的说明
在迭代版本的循环条件中,我们使用了 while (startIndex <= endIndex)。你可能会想,是否可以省略 startIndex <= endIndex 中的 <=,只用 <?
答案是:技术上可以省略,但这是一个有益的优化。
- 如果只用
<,当startIndex和endIndex相等时,循环不会进入。我们需要在循环外额外检查这个单个元素是否为目标。代码会稍复杂。 - 使用
<=允许循环处理长度为1的范围,逻辑更统一。但理论上,当startIndex等于endIndex时,如果元素不匹配,下一次更新(startIndex = middle + 1或endIndex = middle - 1)会使startIndex > endIndex,从而自然退出循环。所以,<=检查可以被视为一种清晰表达意图的写法,而非绝对必要。它不影响算法的时间复杂度(大O表示法)。
总结
本节课中我们一起学习了二分查找算法。
- 我们首先回顾了简单的线性查找(O(n)),并指出了其在大型数据集上效率低下的问题。
- 接着,我们引入了二分查找(O(log n)),其核心思想是在已排序数组上,通过不断将搜索范围减半来快速定位元素。
- 我们详细分析了二分查找的递归实现,包括方法定义、递归调用过程以及至关重要的递归终止条件。
- 然后,我们探讨了功能相同的迭代实现,它使用循环代替递归调用。
- 最后,我们讨论了一个关于循环条件的细微优化点。


二分查找是一个基础且极其重要的算法,深刻理解其原理和实现对于学习计算机科学和编程至关重要。
057:科赫雪花示例


在本节课中,我们将要学习一个递归的经典示例:科赫雪花。这是一个视觉效果显著但涉及一定数学知识的例子。我们将从基本概念开始,逐步理解其数学原理,并最终学习如何在Java中实现它。
递归与科赫雪花
科赫雪花是一种分形曲线。你可能在网上见过各种分形的精美图片,科赫雪花就是其中之一。
它的构造过程如下:我们从一个简单的直线段开始。在科赫曲线的每一个递归步骤中,我们将每条直线段的中间三分之一部分移除,并在该位置添加一个等边三角形。
例如,我们首先在一条线段中间添加一个三角形。然后,在新产生的每一条线段上,我们重复这个过程:移除中间三分之一,并添加一个更小的等边三角形。如此反复,就会形成类似雪花的形状。
通常,我们会从一个等边三角形开始,在其三条边上应用上述过程,从而生成完整的科赫雪花。
单条线段的数学分析
首先,我们分析如何对一条水平线段进行操作。假设线段长度为 x。
我们将线段平分为三段,每段长度为 x/3。然后,我们在中间段的位置构建一个等边三角形,使得三角形的两条边长度也为 x/3。
现在,关键问题是:这个等边三角形的高度 h 是多少?
这里我们需要用到勾股定理:a² + b² = c²。
在三角形中,斜边 c 的长度是 x/3。底边的一半 a 的长度是 x/6。我们需要求解高度 b,即 h。
通过公式变换,我们可以计算出:
h = x * √3 / 6
这个高度值 h 对于我们后续计算点的坐标至关重要。
处理任意线段:平移与旋转
上一节我们分析了水平线段的简单情况。但在实际递归过程中,我们需要处理的线段可能是倾斜的,并且起点也不在原点 (0,0)。
因此,我们无法直接应用上述简单的数学公式。
解决方案是使用平移和旋转。
- 平移:首先,我们将线段平移,使其起点移动到原点
(0,0)。 - 旋转:然后,我们将线段旋转一个角度
φ,使其与X轴水平对齐。 - 应用算法:此时,线段满足了“水平且起点在原点”的条件,我们可以应用之前推导的简单算法来计算新点的坐标。
- 逆变换:最后,我们将计算得到的新点进行反向旋转和平移,使它们回到原始线段所在的位置。
旋转的数学基础:三角函数
要进行旋转,我们需要一些基本的三角函数知识。
sin(α) = 对边 / 斜边cos(α) = 邻边 / 斜边tan(α) = 对边 / 邻边
我们还需要反三角函数 arctan(或 atan),它可以根据正切值计算出对应的角度 α。
假设我们有一个点,其极坐标表示为长度 H 和角度 α。那么它的直角坐标 (x, y) 为:
x = H * cos(α)
y = H * sin(α)
如果我们想将这个点旋转角度 φ,那么新坐标 (x‘, y’) 为:
x‘ = H * cos(α + φ)
y’ = H * sin(α + φ)
利用三角函数的和角公式:
sin(α + φ) = sin(α)cos(φ) + cos(α)sin(φ)
cos(α + φ) = cos(α)cos(φ) - sin(α)sin(φ)
并且我们知道 sin(α) = y / H 和 cos(α) = x / H。代入上式后,H 被消去,我们得到了一个只依赖于原始坐标 (x, y) 和旋转角度 φ 的旋转公式:
x‘ = x * cos(φ) - y * sin(φ)
y’ = x * sin(φ) + y * cos(φ)
Java实现:Point类
在代码中,我们首先需要一个 Point 类来表示二维空间中的点。
public class Point {
private double x;
private double y;
// 构造函数、getter和setter...
// 平移方法
public void translate(double dx, double dy) {
this.x += dx;
this.y += dy;
}
// 旋转方法 (使用上述推导的公式)
public void rotate(double phi) {
double newX = this.x * Math.cos(phi) - this.y * Math.sin(phi);
double newY = this.x * Math.sin(phi) + this.y * Math.cos(phi);
this.x = newX;
this.y = newY;
}
}
Java实现:Flake类与递归算法
Flake 类管理构成雪花的所有点。核心是一个递归方法,用于生成科赫曲线。
以下是算法步骤的概述:
- 终止条件:如果递归深度
depth为0,则直接连接起点和终点。 - 平移与旋转:
- 计算将起点平移到原点所需的偏移量。
- 平移起点和终点。
- 计算线段当前与X轴的夹角
φ。 - 将终点旋转
-φ角度,使线段水平。
- 应用科赫规则:在水平线段上,计算生成等边三角形所需的四个新点(加上原有的起点和终点,共产生四条新线段)。
- 点1:位于
(x/3, 0) - 点2:位于
(x/2, h),其中h = x * √3 / 6 - 点3:位于
(2x/3, 0)
- 点1:位于
- 逆变换:将计算出的新点反向旋转
φ角度,再平移回原始位置。 - 递归调用:对新生成的四条线段,分别以
depth-1为参数递归调用本方法。
迭代算法作为替代
除了递归,我们也可以使用迭代方法来实现科赫雪花。
基本思路是:
- 初始化一个点列表,包含初始线段的起点和终点。
- 当未达到目标深度时,循环处理当前列表中的每一对相邻点(作为线段)。
- 对每一对点,执行与递归方法中相同的平移、旋转、生成新点、逆变换的操作。
- 将生成的新点序列替换原来的线段,加入到新的点列表中。
- 用新列表替换旧列表,进入下一层循环。
示例程序与运行结果
我们可以创建一个JavaFX应用程序来可视化科赫雪花。
public class KochSnowflakeApp extends Application {
@Override
public void start(Stage primaryStage) {
Point start = new Point(100, 200);
Point end = new Point(400, 200);
// 使用递归方法生成深度为5的雪花
Flake recursiveFlake = new Flake(start, end);
recursiveFlake.createSnowflake(5, true); // true 表示递归
// 使用迭代方法生成深度为4的雪花
Flake iterativeFlake = new Flake(new Point(100, 400), new Point(400, 400));
iterativeFlake.createSnowflake(4, false); // false 表示迭代
// 将flake中的点绘制到Pane上...
// 创建Scene和Stage...
}
}
运行程序,你可以看到不同递归深度下的科赫雪花图案。代码的完整项目可以通过课程幻灯片提供的链接下载,建议你通过调试逐步理解其运行过程。
练习:计算10的N次幂
现在,让我们来看一个更简单的递归问题作为练习。
任务:使用递归计算10的N次幂。
- 10⁰ 应该返回 1。
- 10¹ 应该返回 10。
- 10⁵ 应该返回 100000。
解决方案思路:
- 基准情况(终止条件):如果
n == 0,返回1。 - 递归步骤:否则,返回
10 * powerOf10(n - 1)。
public static int powerOf10(int n) {
if (n == 0) { // 基准情况
return 1;
} else { // 递归情况
return 10 * powerOf10(n - 1);
}
}
这是一个典型的线性递归,每次调用将问题规模 n 减小1,直到达到基准情况。
总结
本节课中我们一起学习了科赫雪花的递归生成。
- 我们首先了解了科赫雪花作为分形的基本概念和构造过程。
- 然后,我们深入分析了其背后的数学原理,包括对单一线段的几何计算,以及通过平移和旋转处理任意线段的方法。
- 接着,我们探讨了如何在Java中实现它,定义了
Point类,并详细讲解了递归算法的每一步。 - 我们还提到了使用迭代作为递归的替代方案。
- 最后,我们通过一个计算10的N次幂的简单练习来巩固对递归思想的理解。
理解科赫雪花的实现,有助于深化对递归、坐标系变换以及数学与编程结合的认识。


058:归并排序与递归示例 🧩

在本节课中,我们将要学习归并排序算法,并通过一个具体的例子——斐波那契数列的计算——来深入理解递归的概念和应用。
归并排序是一种高效的排序算法,它采用“分而治之”的策略。其核心思想是将一个大列表递归地拆分成若干个小列表,分别排序后再将它们合并起来。我们将首先回顾如何合并两个已排序的列表,然后探讨如何递归地拆分和排序列表。
归并排序的核心:合并两个已排序列表
上一节我们介绍了递归的基本概念,本节中我们来看看归并排序的具体实现。首先,我们需要一个能将两个已排序列表合并成一个新排序列表的方法。以下是其核心逻辑的伪代码描述:
function merge(listA, listB):
if listA 为空:
返回 listB
if listB 为空:
返回 listA
if listA的第一个元素 <= listB的第一个元素:
结果 = listA的第一个元素 + merge(listA去掉第一个元素, listB)
else:
结果 = listB的第一个元素 + merge(listA, listB去掉第一个元素)
返回 结果
这个函数不断比较两个列表的第一个元素,将较小的那个取出放入结果列表,然后对剩余部分递归调用自身,直到某个列表为空。
递归实现归并排序
理解了合并操作后,我们现在可以构建完整的归并排序函数。其策略是:如果列表长度小于等于1,那么它已经是有序的,直接返回。否则,将列表分成两半,分别对这两半递归调用排序函数,最后将两个已排序的半部分合并起来。
以下是归并排序的递归实现框架:
function mergeSort(inputList):
if inputList的长度 <= 1:
返回 inputList
leftHalf = 获取 inputList 的左半部分
rightHalf = 获取 inputList 的右半部分
sortedLeft = mergeSort(leftHalf)
sortedRight = mergeSort(rightHalf)
返回 merge(sortedLeft, sortedRight)
这个算法的魔力在于递归。mergeSort 函数会不断地自我调用,将列表越分越小,直到变成一个个单元素列表(自然有序)。然后,再通过 merge 函数像拉链一样,将这些小列表有序地“拉”在一起,最终形成一个完整的有序列表。
递归示例:计算斐波那契数列
为了加深对递归的理解,我们来看另一个经典例子:计算斐波那契数列。斐波那契数列的定义是:第0项是0,第1项是1,从第2项开始,每一项都等于前两项之和。其公式表示为:
F(0) = 0, F(1) = 1, F(n) = F(n-1) + F(n-2) (n >= 2)
这个定义本身就是递归的,因此可以非常直观地用递归函数实现:
function fibonacci(n):
if n <= 1:
返回 n
else:
返回 fibonacci(n-1) + fibonacci(n-2)
然而,这种简单的递归实现存在严重的效率问题。因为它会重复计算大量相同的子问题(例如计算 fibonacci(5) 时会多次计算 fibonacci(3)),导致时间复杂度呈指数级增长(O(2^n)),计算稍大的 n 就会非常慢。
优化递归:记忆化与迭代
为了解决重复计算的问题,我们可以采用记忆化技术。其思路是创建一个缓存(如数组或哈希表),在第一次计算出某个 fibonacci(k) 的值后将其存储起来,后续需要时直接取出,避免重复递归计算。这能将时间复杂度优化到 O(n)。
实际上,对于斐波那契数列这类问题,使用迭代方法通常是更简单、更高效的选择。以下是迭代版本的示例:
function fibonacciIterative(n):
if n <= 1:
返回 n
创建一个数组 fib,大小为 n+1
fib[0] = 0
fib[1] = 1
for i 从 2 到 n:
fib[i] = fib[i-1] + fib[i-2]
返回 fib[n]
迭代方法同样避免了重复计算,并且只使用循环,没有函数调用的开销,性能通常更好。
本节课中我们一起学习了归并排序算法和递归的深度应用。我们了解到:
- 归并排序通过递归地“分”与“治”,实现了高效的排序。
- 编写递归函数必须包含两个关键部分:终止条件和调用自身且参数规模缩小,否则会导致无限递归(栈溢出)。
- 递归代码虽然通常简洁优雅,但可能带来性能问题,如斐波那契数列的简单递归实现效率极低。
- 可以通过记忆化或改用迭代方法来优化递归算法,提升效率。

递归是一种强大的编程技巧,能将复杂问题分解为相似的子问题。掌握它需要多加练习,尝试用递归思维去分析和解决实际问题。
059:现代并发

在本节课中,我们将要学习现代并发编程中的任务并发模型。我们将探讨并行与并发的区别,并深入了解如何使用线程池和任务队列来高效地管理并发任务。
并行与并发的区别
上一节我们介绍了并发编程的基本概念,本节中我们来看看并行与并发的具体区别。
并行与并发是不同的概念。
并行意味着任务在不同的CPU核心上同时运行。
并发意味着你同时管理多个任务,但这些任务可能并行执行,也可能不并行执行。
在下图中,你可以看到四种不同的示例:
- 非并发且非并行:你有一个CPU核心,两个任务只是依次执行。
- 并发但非并行:任务看起来是同时运行的,但我们只有一个CPU核心。
- 并行但不并发:任务被完全拆分并在不同的CPU核心上运行。
- 并行且并发:你可以看到时间切片,多个任务被分配到同一个CPU核心上。
任务并发与线程池
当我们讨论基于任务的并发时,也必须讨论所谓的线程池和任务队列。
下图试图在高层面上向你展示其工作原理。其核心思想是,你有很多潜在的可运行任务需要完成。这些任务可以是简单的任务,例如对一个数字进行平方运算(这属于数学计算),也可以是繁重的、长时间运行的任务,例如视频编辑、计算非常复杂的公式或向ChatGPT发送请求并等待答案。任何计算机程序中可能存在的任务,你都可以将它们放入一个队列中。
这个队列由一个线程池管理。线程池本质上就是一个线程列表,例如,这里我们有六个不同的线程,这可能是因为我们的操作系统或计算机有六个核心,这是一个很好的匹配。
然后,我们有很多任务进来,线程池会自动以最佳方式将它们分配到所有可用的线程上。因此,你无需自己创建线程或管理它们的状态,只需将任务放入队列,然后让系统将它们分配到你所拥有的所有线程中。
如果一个线程完成了工作并空闲下来,所谓的执行器服务就可以从队列中获取下一个任务,将其分配给一个线程,运行它,并在最后给你结果。这基本上就是其思想:你有一个放入任务的任务队列,最后得到已完成的任务。
任务的定义与设计
基于任务的并发意味着我们有一个代表工作单元的任务。现在,作为程序员,你需要做好工作。如果你有长时间运行的任务,你需要思考如何将它们分解成更小的任务,这再次体现了“分而治之”的思想。就像编写计算机程序是将所有编程任务分解成更小的任务一样,如果你有一个视频编辑软件,它只有一个任务,你就无法将其拆分到多个核心上运行。你必须思考,这甚至可能改变背后的算法:如何将渲染视频、下载图像、播放音乐等工作拆分成更小的任务,以便它们可以并行执行,并发工作。
事实上,这些任务在内存方面最好也是分离的,因为如果它们操作相同的数据结构,可能会在并行性方面受到限制。然而,如果你处理任务并将任务提交给执行器执行,那么你就不需要手动管理线程,执行器服务会自动为你完成。
执行器服务的工作原理
其思想是,我们在一个所谓的线程池执行器中有一个可重用线程的池。此外,我们还有一个任务队列。然后,当我们的应用程序添加任务时(例如,以我们之前代码示例中看到的Runnable形式),线程池执行器会自动将它们分配到不同的线程上。然后,Runnable将返回计算的结果。
根据你的工作负载和任务,你可以选择多种不同的池大小,甚至不同类型的执行器服务。你可以有一个固定大小的线程池,一个缓存线程池,甚至可以有动态线程池,其中线程数量可以根据系统负载增加或减少,因此这个接口对你来说非常灵活。
代码实现示例
让我们看看这在代码中是如何工作的。这是一个很长的介绍,你需要导入两个类:ExecutorService和Executors类。
然后你只需写:
ExecutorService executor = Executors.newFixedThreadPool(2);
在这个简单示例中,我们决定使用两个线程,但你也可以使用8、16或操作系统限制内的任何数字。
然后我们可以简化两个Runnable。使用与之前相同的符号,我们有task1的Lambda表达式,这里我们只做非常简单的事情:我们只是向控制台打印一些内容。当然,在Lambda表达式箭头右侧,也可以是一个非常繁重的任务,或者你可以调用程序中其他部分的方法。所以,请将此视为一个非常简单的示例,你可以在此基础上进行扩展。
我们创建了两个任务,如你所见,我们提交了它们。执行器服务有一个submit方法,它接受一个Runnable实例。这基本上是在告诉执行器服务:“嘿,我们有一些工作,请把它放入队列,当有线程准备好时,就让它处理这个Runnable,然后传回结果。”
在某种意义上,执行器服务就像你的Java应用程序内部的一个小型调度器。当你完成后,你可以关闭执行器,然后执行器将等待所有任务完成,然后自行清理。之后,你可能会继续进行其他工作。这也是为了最终再次同步工作,因为想象一下,如果你生成多个线程并在这些线程中工作,在某个时刻,你可能希望再次收集所有结果,例如将它们放入列表并打印到控制台,或者你想告诉用户:“嘿,使用八个线程的视频编辑现在完成了,这是最终视频,你现在可以观看了。”这就是其思想,也是为什么我们有一个shutdown方法来再次关闭执行器服务。
订单处理示例
这是另一个订单处理示例。我们再次创建一个执行器服务,这次使用三个线程。我们有一个从1到5的for循环,并提交了几个Runnable。在行内Runnable中,我们没有给它们变量,这里我们只是说“处理订单”并附带一些数字,同时打印当前线程名称。如果你将此代码复制粘贴到你的练习环境中,你会看到基于你选择的执行器服务,后台有某些线程名称,你还会看到它们随机出现,并且最终无法保证有特定的顺序。所以,可能的情况是,你得到我们这里定义的顺序:1、2、3、4、5;但也可能是1、3、5、2、4,这取决于一切是如何调度的。
使用Callable和Future获取结果
这是另一个示例,我们模拟获取数据,并模拟这个过程稍慢一些。这里我们没有真正的数据库实现,这就是为什么我们使用Thread.sleep方法,它实际上告诉线程等待2000毫秒或两秒,什么也不做。通过这个,我们可以模拟一个耗时的操作:从另一个系统获取数据涉及网络延迟、I/O(输入输出)等待或磁盘等待,这有时会发生或需要一点时间。
你现在看到的是,executor.submit实际上会给你一个返回值(如果你想使用它的话),即所谓的Future。什么是Future?Future是某种工作(在这种情况下来自Callable)的结果,但我们还不知道输出是什么。在分配它的时刻,我们有一个对象,但结果实际上还不可用。我们有一个变量,但其内部内容在某种程度上是空的。只有当并发操作(这里的Callable)完成后,我们才能最终得到它。如果我们想等待它,我们也可以等到得到它,这是使用get方法实现的。get方法将阻塞当前线程中的执行,直到结果可用。
与Runnable不同,Callable是一种替代方案,它提供了一种机制,其Lambda表达式的左侧同样是空的,但你现在有一个返回语句,并且有一个结果。因此,如果你使用Callable而不是Runnable,你可以产生一个结果,然后你可以用get等待,然后基本上可以将其输入到下一个任务中。但正如你所见,这还不是非常灵活,因为Callable有一个输入参数(抱歉,是一个输出参数,一个返回类型),我们也应该在这里指定类型。这里是一个字符串,但在现实中,如果你定义了完整的用户对象,它也可以是一个用户对象。
这两个区别在于,我们使用Callable而不是Runnable。Callable有一个结果,所以executor.submit有一个我们可以使用的返回值。当我们对这个返回值使用get时,我们会等待Callable完成工作。在这里,它将恰好花费两秒钟。如果你有一个正常的计算,你无法确切地说,有时它需要更长时间,有时更快。然后我们可以将其打印到控制台并再次关闭执行器服务。
定时任务示例
这是另一个通知服务示例。我们再次有一个调度线程池(之前我们有一个固定线程池,现在我们有一个调度线程池)。使用的接口略有不同,因为我们现在想做的是在特定的时间点调度某些事情。
我们创建一个发送通知器,这可能是你发送给用户的通知,这里再次使用一个非常简单的带有System.out.println的Runnable,但这当然也可以是其他东西。现在,我们要求调度器以固定速率调度某些事情,基于我们这里定义的Runnable。我们说等待0秒(这是初始等待时间),然后我们说,每5秒重复执行一次。这就是其思想:我们调度某件事,第一次立即执行,然后每5秒我们希望它被执行。然后我们还可以调度调度器在20秒后关闭,以停止执行,否则这将永远运行下去。
练习:并行计算平方数
我们将开始第一个练习。请打开你的练习环境,我请你现在创建一个小程序,使用执行器服务,我们希望你们处理一个包含100个整数的列表。你们应该打印它们的平方,并且我们希望你们并行地完成这个任务,利用你们笔记本电脑的所有核心。
我为你提供了一些示例代码,你可以复制粘贴。我们有一个带main方法的Main类,我们已经为执行器服务定义了一个变量,但还没有创建一个。所以这是你的第一个待办事项:创建一个执行器服务。
然后我给你展示一个小技巧,如何使用IntStream和rangeClosed方法获取从1到100的数字列表。这意味着给我1到100之间的所有整数值,这比说new ArrayList,1,2,3,4,5,6……要短得多。
然后我们准备了一个for循环,你遍历所有这些数字,你的任务是向执行器服务提交一个Runnable,并在其中打印结果,以便我们可以在控制台中看到所有计算。这不是一个Artemis练习,你必须在你的练习环境中完成,你可以自由选择如何做。你可以使用任何你喜欢的执行器服务,也可以进行调整。几分钟后我会给你展示一个示例解决方案,但这确实是一个你可以灵活发挥的地方,没有测试用例限制你的创造力。所以,玩得开心,几分钟后我会展示解决方案。助教会四处走动,如果你需要帮助,请举手。如果你在线观看我们,如果你观看录像,请在相应的Artemis频道中提问以获得帮助。


练习解决方案示例
工作时间结束。你们中谁已经有解决方案了?请举手。由于时间原因,我们必须继续,所以我将展示一个示例解决方案。
这是它可能的工作方式。我们修改了这里的语句:Executors.newFixedThreadPool,例如,使用数字4。然后在for循环中,我们提交一个Runnable,这是一个Lambda表达式,没有输入,一个箭头和一个花括号(如果不需要也可以省略)。这里我们说System.out.println(number * number)。这里的想法是,Lambda表达式从外部作用域(从for循环)获取数据。这非常重要,Java会自动为你管理。当然,理论上你也可以创建一个不同的数据结构并将其作为输入放入,然后作为输出取出,这也是可能的,但本练习并不要求这样做。

本节课中我们一起学习了现代并发编程的任务模型,理解了并行与并发的核心区别,并通过线程池、执行器服务、Runnable、Callable和Future等工具实现了任务的提交、调度和结果获取。我们还通过练习实践了如何将计算任务并行化以提升效率。
060:基于任务的并发


在本节课中,我们将要学习异步编程的核心概念,特别是 Future 和 CompletableFuture。我们将探讨它们如何帮助程序在等待耗时任务结果的同时,继续处理其他工作,从而提升程序的效率和响应速度。
为什么需要异步编程?🤔
在同步程序中,每个请求都必须等待其响应完全返回后才能继续执行。在此期间,程序会被阻塞,无法处理其他任务。
异步编程则采用事件驱动模型。多个请求可以同时进入系统。在请求与响应之间的等待时间(如上图中蓝色阴影部分所示),系统仍然可以处理其他请求。你无需等待第一个请求完全结束,就可以开始处理第二个请求。
因此,右侧的并行程序比左侧的纯顺序程序快得多,因为它能利用更多资源(如CPU核心)。异步编程允许我们在执行计算任务并等待结果的同时,继续进行其他工作。
Future:未来的值占位符
Future 是一个占位符,代表一个将在未来某个时刻可用的值。它使用泛型,因此可以独立于具体类型。
Future<Integer> futureResult;
Future 可以是 Integer、String 或任何自定义数据类型的占位符。
以下是一个模拟长时间运行任务的简单示例:
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(() -> {
Thread.sleep(1000); // 模拟耗时操作
return 42;
});
提问:我们提交给 ExecutorService 的是 Runnable 还是 Callable?
答案是 Callable。因为 Callable 没有输入参数,但有返回值(Callable<V>),而 Runnable 既无输入也无返回值。这里我们使用了一个匿名的 Callable Lambda 表达式作为参数。
Future 对象会立即返回。根据 ExecutorService 中可用线程的情况,它可能会立即开始计算。如果你想等待结果可用,需要调用其 get() 方法。
Integer result = future.get(); // 阻塞当前线程,直到结果可用
System.out.println(result);
get() 方法会阻塞当前线程,直到结果就绪。
Future 的局限性 🚧
Future 存在一些问题:
- 阻塞性:调用
get()方法时会阻塞。 - 组合困难:难以直接组合多个任务(例如,将一个任务拆分为两个子任务并等待它们的结果)。
- 缺乏非阻塞回调支持:
Future本质上是同步的,即使在并发编程中,最终也需要同步等待。
CompletableFuture:更强大的未来
CompletableFuture 是 Future 的扩展,它解决了上述问题。它允许你:
- 链式调用:定义任务完成后要执行的操作。
- 组合结果:合并多个任务的结果。
- 异常处理:优雅地处理任务执行中可能出现的错误。
其核心思想是:可以将一个任务拆分为多个子任务,让它们在多个线程上并行运行,最后在所有子任务完成后进行同步,继续后续工作。
CompletableFuture 代码示例
让我们看一个异步执行任务,然后链式进行转换并最终消费结果的例子。
CompletableFuture.supplyAsync(() -> "Hello")
.thenApply(result -> result + " World")
.thenAccept(System.out::println);
工作原理:
supplyAsync方法接收一个Supplier(即Callable,有输出无输入),异步执行一个返回结果的任务。thenApply方法接收一个Function(有输入有输出)。它将上一步的结果作为输入,进行转换(这里是将 “Hello” 变成 “Hello World”)。thenAccept方法接收一个Consumer(有输入无输出)。它等待结果就绪后,执行最终操作(这里是将结果打印到控制台)。
通过 . 操作符,我们可以将多个任务链式连接起来。每个 thenApply 都可以进行独立的转换,thenAccept 则用于最终的同步和消费。
组合依赖任务
thenCompose 方法用于链接有依赖关系的任务,这类似于函数式编程中的 flatMap。
CompletableFuture.supplyAsync(() -> "Task 1")
.thenCompose(result1 ->
CompletableFuture.supplyAsync(() -> result1 + " + Task 2")
)
.thenAccept(System.out::println);
这里,第二个任务 (supplyAsync) 依赖于第一个任务的结果 (result1)。thenCompose 确保它们按顺序执行并组合。
合并独立任务的结果
我们可以让两个独立的任务并行运行,然后合并它们的结果。
CompletableFuture<Integer> task1 = CompletableFuture.supplyAsync(() -> 20);
CompletableFuture<Integer> task2 = CompletableFuture.supplyAsync(() -> 22);
task1.thenCombine(task2, (a, b) -> a + b)
.thenAccept(sum -> System.out.println("Sum: " + sum));
工作原理:
task1和task2是两个独立的CompletableFuture,可以并行执行。thenCombine方法等待两个任务都完成后,将两者的结果 (a和b) 传递给一个合并函数(这里是(a, b) -> a + b)。- 合并后的结果再通过
thenAccept打印出来。
这种语法非常灵活,允许你将大任务分解为可并行运行的小任务,最后只合并需要的结果。
关于数据与内存的注意事项 ⚠️
重要:不能假设不同任务访问同一内存区域时,内存会被隐式妥善处理。理想情况下,每个任务应该操作自己的数据结构。
如果任务需要从公共数据结构获取输入,你需要将其分割。在 Lambda 表达式中,我们遵循 “有效最终” 原则:从外部捕获的变量必须是 final 或事实上不可变的。这是因为 Lambda 表达式(如 Runnable)可能在单独的线程中执行,如果多个线程修改同一个变量,会导致竞态条件或不正确的结果。
CompletableFuture 链式调用中的 Lambda 表达式应避免使用外部可变状态。输入应是不可变的,输出是计算产生的新结果。如果你的数据结构难以分割,那么并行计算也会变得困难。
异常处理
CompletableFuture 提供了 exceptionally 方法来优雅地处理异常。
CompletableFuture.supplyAsync(() -> {
// 模拟可能抛出异常的任务
throw new RuntimeException("Something went wrong!");
})
.exceptionally(ex -> {
System.out.println("Exception handled: " + ex.getMessage());
return "Default Value"; // 从异常中恢复,返回一个默认值
})
.thenAccept(System.out::println);
exceptionally 方法类似于传统的 try-catch 机制,它捕获异常并尝试恢复,然后允许任务链继续执行。
练习:模拟数据获取与合并
任务:编写一个程序,模拟获取用户数据和订单历史。
- 使用
CompletableFuture.supplyAsync创建两个异步任务。 - 第一个任务返回字符串
"User: Chando"。 - 第二个任务返回字符串
"Orders: [Order1, Order2, Order3]"。 - 使用
thenCombine方法合并两个任务的结果。 - 使用
thenAccept打印最终摘要。
预期输出:
User: Chando
Orders: [Order1, Order2, Order3]
示例解决方案:
import java.util.concurrent.CompletableFuture;
public class CompletableFutureExercise {
public static void main(String[] args) {
CompletableFuture<String> fetchUserData = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(500); // 模拟网络延迟
} catch (InterruptedException e) {
e.printStackTrace();
}
return "User: Chando";
});
CompletableFuture<String> fetchOrderHistory = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(800); // 模拟更长的操作
} catch (InterruptedException e) {
e.printStackTrace();
}
return "Orders: [Order1, Order2, Order3]";
});
fetchUserData
.thenCombine(fetchOrderHistory, (user, orders) -> user + "\n" + orders)
.thenAccept(System.out::println);
// 等待异步任务完成,防止主线程过早退出(在实际服务器环境中通常不需要)
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
总结 📚
本节课我们一起学习了基于任务的并发编程的核心工具。
- 我们首先回顾了
Future,它作为未来值的占位符,但存在阻塞和组合困难的问题。 - 接着,我们深入探讨了
CompletableFuture,它通过链式调用(thenApply,thenCompose)、结果合并(thenCombine)和异常处理(exceptionally)等功能,极大地增强了异步编程的能力。 - 我们强调了在并行任务中处理数据时,应注意不可变性和避免共享可变状态,以防止竞态条件。
- 最后,通过一个练习,我们实践了如何使用
CompletableFuture来组织并发的异步任务。
掌握这些概念有助于你构建更高效、响应更快的应用程序,充分利用现代多核处理器的计算能力。


061:并行流

在本节课中,我们将学习Java中的并行流。我们将回顾流的基本概念,并重点介绍如何利用并行流来并发执行数据处理任务,从而提升计算密集型任务的性能。
概述
上一节我们介绍了流的基本操作。本节中我们来看看如何将流操作并行化。并行流允许我们利用多核处理器的优势,将数据集的元素分配到多个线程上同时处理,这对于处理大型数据集或计算密集型任务尤其有用。
流操作回顾
流是对数据元素序列进行函数式风格的操作。其核心流程如下:
- 从数据源(如列表或集合)创建一个流。
- 执行一系列中间操作,如过滤(
filter)、映射(map)等。 - 通过一个终端操作(如收集器
collect或归约reduce)来获取结果。
例如,对一个整数列表求和,可以将其转换为流,然后使用归约操作。
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream().reduce(0, Integer::sum);
并行流简介
并行流使得并发编程变得简单。你无需直接管理线程的创建、阻塞或等待,只需将普通流转换为并行流,Java会自动处理并发执行。
将顺序流转换为并行流的方法非常简单,只需调用 parallelStream() 方法或对已有流调用 parallel() 方法。
// 从集合创建并行流
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sumParallel = numbers.parallelStream().reduce(0, Integer::sum);
// 将现有流转换为并行流
int sumParallel2 = numbers.stream().parallel().reduce(0, Integer::sum);
并行流的核心优势在于其声明式语法。你只需描述“要做什么”(例如,对每个元素求平方),而无需关心“如何做”(例如,如何分配线程)。这使得代码更简洁、易读。
并行流的工作原理与适用场景
并行流尝试将输入数据分割成多个块,每个块在一个独立的线程中处理。线程数量通常由运行时环境根据可用处理器核心数自动决定。
并行流最适合以下场景:
- CPU密集型任务:计算本身耗时较长。
- 大型数据集:有大量数据元素需要处理。
- 任务可独立分解:对每个数据元素的操作不依赖于其他元素的结果或顺序。
在以下情况使用并行流可能无益甚至有害:
- 计算过于简单:创建和管理线程的开销可能超过并行计算带来的收益。
- I/O密集型任务:任务主要时间花在等待I/O(如网络、磁盘)上,而非CPU计算。
- 操作有严格顺序依赖:某些归约操作(如
reduce)如果依赖于特定顺序,则不能安全并行化。
代码示例与顺序问题
请看一个简单的并行流示例:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> squares = numbers.parallelStream()
.map(n -> n * n)
.collect(Collectors.toList());
// 结果始终是 [1, 4, 9, 16, 25]
在这个例子中,map 操作(计算平方)会并发应用到各个元素上。虽然计算 4*4 的线程可能比计算 1*1 的线程更早完成,但终端操作 collect 会保证最终结果列表的顺序与源列表的顺序一致。并行化的是将操作应用于多个输入元素的过程,而不是改变操作链本身的顺序或最终结果的顺序。
性能对比与监控
为了直观感受并行流的性能差异,我们可以对比处理大量数据时的耗时。以下示例创建了一个包含大量整数的列表并进行求和:
// 创建一个大的列表(示例为200万个元素,实际可根据需要调整大小)
List<Integer> hugeList = // ... 初始化一个非常大的列表
long startTime = System.currentTimeMillis();
long sum = hugeList.parallelStream().reduce(0L, Long::sum);
long parallelTime = System.currentTimeMillis() - startTime;
startTime = System.currentTimeMillis();
sum = hugeList.stream().reduce(0L, Long::sum);
long sequentialTime = System.currentTimeMillis() - startTime;
System.out.println("并行耗时: " + parallelTime + "ms");
System.out.println("顺序耗时: " + sequentialTime + "ms");
在实际运行中,对于计算密集型的求和操作,并行流通常能显示出更短的执行时间。你可以通过打印线程名称来监控哪些线程参与了工作:
List<Integer> nums = Arrays.asList(1, 2, 3, 4, 5);
nums.parallelStream()
.map(n -> {
System.out.println(Thread.currentThread().getName() + " processing: " + n);
return n * n;
})
.forEach(System.out::println);
输出会显示类似 ForkJoinPool.commonPool-worker-1 的线程名,表明任务是在ForkJoinPool的线程中并发执行的。
实践练习
现在,请打开Artemis系统,完成练习 W13 E2: 并行数据处理。
你的任务是:
- 使用并行流计算总营收。
- 找出交易额最高的前五笔交易。
- 分别测量使用默认(顺序)流和并行流处理的性能。
你大约有10分钟的时间来完成这个练习。助教会巡回提供帮助。请注意,本次不会展示示例解决方案,你需要独立完成并通过Artemis提交以获得反馈。
总结
本节课中我们一起学习了Java并行流。我们回顾了流的基础,理解了如何通过 parallelStream() 将顺序处理转换为并发处理。我们探讨了并行流的工作原理、其最适合的CPU密集型和大数据量场景,以及需要注意的顺序和性能开销问题。通过将复杂的线程管理抽象化,并行流让我们能够以声明式、简洁的方式编写高效的并发数据处理代码。

休息之后,我们将开始最后一个关于响应式编程的单元。
062:响应式编程

在本节课中,我们将要学习响应式编程的基本概念。响应式编程是一个专注于系统响应性、可伸缩性和弹性的广阔领域。我们将了解其核心思想、关键组件,并通过Java的Flow API来实践一个简单的发布-订阅模型。
响应式编程概述
上一节我们介绍了并行流,本节中我们来看看响应式编程。响应式编程是一个庞大的领域,它专注于系统的响应性和如何将小任务规模化,从而构建一个可伸缩且具有响应性的系统。

甚至有一个所谓的“响应式宣言”,其中定义了系统的四个特定属性。
响应式系统的四个属性
以下是响应式系统的四个核心属性:
- 响应性:系统应该及时响应。
- 消息驱动:系统通常是消息驱动的。这意味着组件之间不直接调用方法,而是通过消息队列发送消息进行通信,以实现松耦合并支持异步编程。
- 弹性:系统在高负载下仍能保持响应性。无论有5个还是10,000个最终用户,系统都应表现一致。因此,所有操作都应尽可能小而原子化。
- 韧性:即使一个组件失败,系统也应保持响应。一个被分解为10到15个更小的所谓“微服务”的系统,在某个组件失败时,可能有另一个组件接管,或者只是系统的某个功能失效,而其余部分仍能正常工作并保持响应。
有一个关于此的完整网站,如果你感兴趣,可以在 reactivemanifesto.org 上阅读更多内容。
为什么响应式编程很重要
响应式编程之所以有趣,是因为如果你有大量可以异步处理的数据,你可以更高效地处理它们。使用响应式编程的典型应用包括微服务、实时通知(例如,你想在足球比赛中进球时得到通知)以及包含数据管道的系统(数据在多个系统之间发送以进行处理)。
响应式编程也关注数据流,这与Java中的流概念有些相似,但重点在于变化的传播。我们不仅关注数据流及其上的操作,更关注变化的数据。每一个变化都会被发布给订阅者,订阅者会收到通知并可以处理变化后的数据。
变化传播算法
有不同的变化传播算法,这意味着系统的一部分需要通知另一部分变化发生了。
- 轮询:希望得到通知的消费者是主动的,需要定期拉取数据。
- 推送:生产数据的生产者以事件的形式通知消费者,事件是一个自包含的值,无需发送进一步信息,消费者就可以处理变化。
- 推拉混合:只推送一个非常简单的通知(例如,“嘿,有些数据变了”),然后消费者需要拉取具体内容。
举个例子:当你发送WhatsApp消息时,系统会通知你。对于小的文本消息,通常使用推送原则,服务器会发送包含全部文本内容的推送通知。然而,对于大的视频或图像数据,WhatsApp服务器可能只通知“有新图片/视频”,并附带一个小缩略图预览。当你点击它时,你的智能手机会拉取整个视频。如果你在处理通知时离线,就无法获取数据。这就是这里的区别。
发布-订阅模型
响应式编程专注于所谓的发布-订阅模型。其思想是将数据的生产者(发布者)与数据的消费者(订阅者)解耦。在中间,我们通常有一个通道、代理服务或消息队列,所有数据都发送到这里。
然后,订阅者可以处理数据,并按照他们想要的方式和时间进行处理。即使发布者因故停止工作,但只要数据已经发布到通道中,订阅者仍然可以处理它。
从图示中的数字可以看出,通常可以有多个发布者。但每一个数据点(图中圆圈里的1、2、3)可能会被所有订阅者处理。如果一个订阅者订阅了,他们可能仍然会处理所有三个数据点,因为不同的订阅者可能有不同的功能。例如,一个处理文本以便你能在推送通知中看到它,另一个使用文本并检查是否有违反WhatsApp行为准则的内容。
背压问题
如果你有这样的发布-订阅机制,可能会出现一个称为背压的问题。
背压意味着在你的数据流、中间的消息队列中有太多数据,淹没了订阅者。例如,如果你有非常快的发布者(每秒发布100次操作),但有一个相对较慢的订阅者(因为处理数据需要大量时间,可能每秒只能处理一次操作),那么队列中就会堆积大量数据,最终可能导致队列溢出。
背压意味着告诉发布者:“请停止发布,我们不堪重负了,休息一下或做点别的,但不要让我们过载,我们无法再处理数据了。” 你可以考虑如何应对,例如雇佣更多订阅者(即扩展系统,启动更多订阅者子系统),或者思考是否真的需要如此快速地发布数据,是否可以放慢速度,是否真的需要跟踪每一个变化。
响应式编程的优势
响应式编程的优势在于可以实现高并发,真正适当地利用所有基础设施,甚至高效地处理大规模数据流,并确保应用程序的韧性和响应性。
Java中的响应式编程:Flow API
Java有一个内置的框架或功能用于发布-订阅模型或响应式编程,即所谓的Flow API,它实现了响应式流规范。我们有四个主要组件:发布者、订阅者、订阅和处理器。我们将逐一介绍。
发布者
发布者产生数据并将其发送给订阅者。你可以通过实现 Flow.Publisher 接口来实现自己的简单发布者。
这是一个泛型接口,所以你需要指定你的类型。在这个非常简单的例子中,我们只有一个字符串数组 {“Hello”, “Reactive”, “World”}。然后我们需要重写 subscribe 方法并提供订阅逻辑。


import java.util.concurrent.Flow.*;
public class SimplePublisher implements Publisher<String> {
private final String[] data = {"Hello", "Reactive", "World"};
@Override
public void subscribe(Subscriber<? super String> subscriber) {
// 提供订阅逻辑,例如创建一个Subscription并传递给subscriber.onSubscribe
subscriber.onSubscribe(new SimpleSubscription(subscriber, data));
}
}
发布者必须实现这个Flow接口,然后才能使用其功能来发布这些数据点。当然,这里我们有一个固定的最终字符串数组,但在现实中,这也可能是从外部获取新数据项,这些项进入发布者并被发布给订阅者。
订阅者
订阅者消费发布者发出的数据。它必须实现 Flow.Subscriber 接口。


这个订阅者接口要求我们实现四个不同的方法:
onSubscribe:通知我们订阅机制已发生,并给我们这个订阅对象,然后我们可以在其上调用request方法来请求数据。onNext:我们收到一个数据项,然后可以处理它。在这个非常简单的例子中,我们只是打印它。onError:意味着某些地方出错了,发生了错误,我们可以进行错误处理。onComplete:意味着我们完成了,所有数据都已处理。如果我们只有三个数据点,我们就调用三次onNext,然后完成,订阅就结束了。
import java.util.concurrent.Flow.*;
public class SimpleSubscriber implements Subscriber<String> {
private Subscription subscription;
@Override
public void onSubscribe(Subscription subscription) {
this.subscription = subscription;
// 请求第一个数据项
subscription.request(1);
}
@Override
public void onNext(String item) {
System.out.println("Received: " + item);
// 处理完当前项后,请求下一个
subscription.request(1);
}
@Override
public void onError(Throwable throwable) {
System.err.println("Error occurred: " + throwable.getMessage());
}
@Override
public void onComplete() {
System.out.println("Processing completed.");
}
}
订阅
订阅逻辑在 SimpleSubscription 类中实现。它基本上使用数据并重写 request 方法。在 request 方法中,我们简单地遍历数据,直到没有更多数据可用。这也是我们在这里使用 while 循环的原因,因为在这个非常简单的例子中,我们只有三个数据点,但在现实中,我们并不知道有多少数据点。
订阅者应确保每当请求数据时,就调用订阅者的 onNext 方法;如果数据为空,则调用 onComplete。还有一个 cancel 方法,例如,当数据太多时可能会取消,但通常调用的是 request 方法。
import java.util.concurrent.Flow.*;
class SimpleSubscription implements Subscription {
private final Subscriber<? super String> subscriber;
private final String[] data;
private int index = 0;
private boolean isCancelled = false;
SimpleSubscription(Subscriber<? super String> subscriber, String[] data) {
this.subscriber = subscriber;
this.data = data;
}
@Override
public void request(long n) {
if (isCancelled) return;
try {
for (int i = 0; i < n && index < data.length; i++) {
subscriber.onNext(data[index++]);
}
if (index >= data.length) {
subscriber.onComplete();
}
} catch (Exception e) {
subscriber.onError(e);
}
}
@Override
public void cancel() {
isCancelled = true;
}
}
整合一切
最后,我们可以将所有内容整合在一起。我们创建发布者,调用 subscribe 方法并传入我们的简单订阅者。这样我们就将两者连接起来,并在中间实现了订阅机制。在我们的简单例子中,我们只是转发数据,当然也可以有更复杂的例子,你还需要处理背压。
关于我在这里展示的代码有任何问题吗?这并不容易,这是一个你必须理解的新模型。但如果你能理解这个想法:有人发布了一些东西,每当有发布时,另一个订阅者就开始处理它,并且我们可以有多个数据从一个子系统流向另一个子系统,这基本上就是其思想。
这是Java中的一种实现,但许多编程语言都提供了这种响应式编程方式。例如,用于JavaScript的RXJS在Web应用世界中是一个非常著名的框架。基本上每种编程语言都有类似的框架,还有RX Java,它甚至比Flow API更复杂,提供了许多不同的机制。对于每种语言,如RX Python、RXC++等,都有相应的框架。
因此,我们认为以一种非常简单的方式了解它的工作原理并习惯它是很好的,因为未来响应式编程会越来越多。
实际应用与使用场景
有一些实际应用特别使用响应式编程,尤其是事件驱动系统,例如股价更新、智能家居环境中的传感器数据监控或聊天应用程序(其中出现大量不同的消息)。在这些情况下,发布者发出数据事件,然后甚至多个订阅者可以独立处理它们,这就是其思想。
从某种意义上说,有两种机制:一种是多个订阅者处理所有数据,因为他们都对此感兴趣;另一种是多个订阅者只对部分数据感兴趣,并基本上互相帮助。基于此,你必须以不同的方式实现这些订阅者。
何时应该使用响应式编程? 它适用于需要高吞吐量、可扩展性和响应性的应用程序,特别是大公司经常这样做,因为他们必须处理许多最终用户的请求。在这种情况下,这是有意义的。对于非常简单的应用程序和只有少数用户的应用程序,响应式编程并不是真正必需的。
练习:温度监控系统
我们还有一个练习让你尝试,我们希望您使用Java Flow API模拟一个温度监控系统。
你需要完成以下三个任务:
- 实现一个温度发布者:该发布者生成并管理数据流,发出随机的摄氏温度读数。
- 实现一个温度订阅者:该订阅者订阅事件,将它们记录到控制台,并在温度高于70摄氏度时发出警告。
- 实现一个简单的背压机制:使得同时消费10个读数,而不是逐个处理。这意味着等待直到有10个读数可用,然后立即消费它们。
你需要在接下来的大约10分钟内完成这个练习。导师会四处走动提供帮助。这可能不是一个简单的练习,这是一个中等难度的练习,所以你可能需要多一点时间。把它当作初始工作时间,然后你可以在今晚之前完成剩余部分。
课程总结与后续步骤
本节课中我们一起学习了以下内容:
- 并发:允许我们保持系统响应性,可能看起来像并行运行,但不一定,这可能在后台发生。
- 真正的并行:使用多个CPU核心可以显著提高性能,前提是软件已为此优化。今天的重点是如何编程以优化多核使用。
- 基于任务的并发:我们已经抽象掉了所有线程管理这些难以理解的低级概念(很容易产生竞态条件),使用起来更简单,尽管你仍然需要学习如何使用它。特别是我们讨论了Future,它代表某种异步计算,将在某个时刻可用,但它们相当有限。
- CompletableFuture:它使用更现代的语法进行链式调用、组合和错误处理。
- 基于并行流的异步编程:我们进行了简单回顾,它们可以帮助我们对序列进行并行操作。但是,你需要仔细查看具体用例,并需要避免并行流操作背后的线程管理导致过多开销。
- 响应式编程:这是我们今天的最后一个主题,是一个非常现代和健壮的模型,现在许多编程语言都使用它来处理异步数据流。许多人现在也以异步数据流的方式思考,他们不再以传统的面向对象方式思考。一切都需要是一个函数,一切都需要被转换为发布者、可观察对象或订阅者,以使其尽可能具有响应性。这是你可以使用的发布-订阅模型,它试图通过中间的消息队列将数据生产与数据消费解耦,有时需要处理背压以确保整个方法的稳定性。
这只是对这些主题的快速一瞥,你可以在这些主题上花费更多时间。我希望你将来有更多机会处理这些主题,因为我认为它们变得越来越重要,未来的开发者(我也把你们算在内,即使你们可能进入管理岗位)也需要理解它们是如何工作的。
今天的课程到此结束,祝大家本周剩余时间愉快,再见。




063:并发编程


欢迎来到第13次研讨会,主题是“编程导论”课程中的并发。
我们再次回到直播,我已经恢复健康,非常高兴能再次站在大家面前,教授这门课程中一个相对较新的主题。去年我们没有教授这个内容,但我们认为在当今涉及并发编程以及如何高效处理多CPU、多核心时,这是一个越来越重要的主题,目的是让您的最终用户,即使用您软件的人,获得最佳的响应性和用户体验。
课程已接近尾声,下周我们还有一次研讨会,将讨论编程之外的某些方面,并简要了解软件工程。两周后我们将进行课程回顾,然后是第二次监考考试。我知道大家都在积极地进行项目工作,因为它非常令人兴奋和鼓舞人心,你们还需要在挑战上多做一些工作,并在最后一次辅导课上向同学们展示。
现在,让我们开始今天关于并发的研讨会。
我们假设您对基本的面向对象编程概念有很好的理解,并且也知道如何使用Lambda表达式和流,这在今天将非常重要,我们将在下半部分回顾流。
今天的学习目标包括:更好地理解为什么现代应用程序需要并发;理解如何进行异步编程,我们将看到这意味着什么;并向您展示如何使用所谓的Future。由于Future还不够好,我们还有第二个版本,即所谓的CompletableFuture。因此,我们的想法是,在本周研讨会结束时,您应该能够使用CompletableFuture,以便在基于任务的并发中更好地控制任务,以及多个任务的组合及其相互关系,因为有时您想要交给计算机的任务之间存在依赖关系,只有在多个任务已经完成计算后,才能继续下一步。
我们还将再次讨论流,特别是并行流,以及它们如何使您的数据处理更高效。
在最后几个单元中,我们将简要讨论响应式编程、响应性、弹性、可伸缩性和消息驱动架构。我们希望您能更好地理解这些原则,虽然这之后您不会成为专家,还需要更多的练习和实践,但我们希望能为您提供一个很好的概述,因为这个主题变得越来越重要。
我们将首先概述现代并发的含义及其基本概念,然后再深入基于任务的并发。


为什么并发很重要?🤔
并发很重要,因为您希望保持应用程序的响应性。如果您开发用户界面,您不希望用户因为正在进行繁重的计算而等待。如果您开发Web服务器,您也不希望所有100万WhatsApp用户都在等待,仅仅因为另一个人发送了一条消息,而处理这条消息需要更长的时间。因此,理想情况下,我们可以以并发方式直接从最终用户的用户界面接收这些请求,或者在Web服务的情况下接收请求,甚至理想情况下,我们可以并行执行它们。
请注意,并发与并行不同。某些事情看起来是并发的,即使它不是并行的。在左侧的图表中,我们有一个CPU,看起来有两个任务在并发执行。然而,事实并非如此,它们是顺序执行的,但操作系统确保一个任务获得,例如,一秒钟,另一个任务获得一秒钟,然后第一个任务暂停,依此类推。实际上,如果您查看您的操作系统,有活动监视器或任务监视器,我现在就演示一下。



在Mac OS上打开活动监视器。您会看到我们有大量进程同时运行。实际上,我们有2,700个线程,出于某种原因,进程数需要一点时间,有625个进程同时运行。如果我只有一个CPU核心,它们仍然会并发运行,但不会并行运行。这台计算机有多个核心,所以它们也可以并行运行。请记住这是一个有趣的区别。
随着如今硬件中拥有越来越多的处理器核心,甚至我的智能手机现在也有六个核心左右,我们需要利用它们来使计算机执行的任务更快,从而减少用户的等待时间。同时,这些任务也变得越来越复杂。十年前,由于CPU性能有限,人们可以同时以720p的分辨率编辑一个视频,并且等待时间合理,比如不需要等待五个小时视频才完全转码。现在,我们可以在同一台笔记本电脑上同时处理4个4K视频,并且等待时间相同。

因此,硬件在过去几年中取得了巨大进步,提供了越来越多的应用和用例。因此,以能够利用这些多CPU的方式编程软件也至关重要。因为如果您开发的视频编辑软件,在多核处理器上的性能仍然与十年前在单核上相同,那么您的最终用户会讨厌您。
请记住,响应性和性能是用户体验最重要的指标之一。想想您自己,如果您在互联网上浏览您最喜欢的网站,无论它是什么,如果加载时间异常长,异常长指的是两秒钟。如果我打开一个网站,它必须立即出现,如果需要两秒钟,我已经开始思考发生了什么,我的大脑开始琢磨,如果五秒钟后还没有显示出来,我会尝试另一个网站。如今,我没有耐心等待一个网站超过五秒钟,我听说年轻一代甚至更缺乏耐心,如果TikTok视频太长且开头不有趣,他们就会直接滑到下一个。这是我们必须考虑的心理和用户期望。
因此,多线程已成为常态,不再是例外。每个应用程序,如果有繁重的计算,都应该有多个线程,并利用我们越来越多的CPU核心。这意味着应用程序可以同时执行多个任务,并发甚至并行,操作系统将负责以公平的方式将这些任务分配到不同的CPU核心,以便所有应用程序都得到公平对待。当然,某些应用程序可能会获得更多的CPU时间,因为它们更重要,您的操作系统有一些重要的事情要做,如果这些需要时间,那么您的应用程序可能需要等待,但总的来说,操作系统会尽量做到公平。
因此,缩短长时间运行程序的运行时间,更好地利用单个CPU,以及更好地利用多个CPU核心,并在响应性、公平性和用户体验方面提供更好的最终用户体验,是您应该考虑并发编程的主要动机。
问题解答 💬
好的,问题是:我们能多谈谈底层硬件架构吗?我想说不会太多,有单独的课程会向您介绍计算机体系结构和硬件工程。我现在可以告诉您的是,如果您只有一个单核,您无法并行执行多个进程。这是不可能的。它只是看起来像那样。操作系统会智能地分配进程,并试图以公平的方式进行,但一次只能执行一个进程和一个线程。它们可能只获得10毫秒,对我们来说,一秒钟是我们能理解的时间度量,在一秒钟内,可能有100个不同的进程顺序运行。
由于操作系统有时给您的线程和应用程序的时间非常短,但它只在单线上运行。如果您有多个核心,操作系统将确保将它们分配到多个核心上,然后这些进程也可以并行运行。但最终,当涉及到磁盘I/O、输入输出或网络读取等操作时,可能一次只能计算一个操作。但是,如果您必须等待这个操作,那将是非常糟糕的,对吗?例如,如果用户界面上没有其他事情可做。
希望这能稍微解答这个问题。
线程基础 🧵
在编程并发时,许多人会想到线程。线程是计算机科学家首次接触允许同时生成多个任务的方式之一。线程就是程序的执行,程序的执行可以有多个线程。到目前为止,我们大多只使用一个线程,即主线程。但有时您已经看到,可能会出现后台线程或多个线程被生成,这些线程可能并行运行。线程是程序中一条单一的执行路径,在每个点上,您可以分支出新的线程,主分支将继续运行,但您可以分支出并启动一个新线程,只要不超过某些硬件限制,您可以随意这样做。
主线程通过main方法执行。Java中使用的一个重要线程是负责垃圾回收的线程,它由运行时系统并行启动,并在多个时间点执行,然后清理程序中不再需要的所有对象。它的工作方式是这样的:您的主线程处理对象,它们在这里用蓝色圆圈表示。每当有一个对象不再被使用时,比如这个,它没有传入引用,如果没有传入引用,意味着该对象不再需要,那么垃圾回收器可能会启动,抓住它并释放它,以便您可以再次使用其他对象。垃圾回收器线程在后台运行,系统确保在某些时候主线程被中断,垃圾回收器启动,获得几毫秒的执行时间来释放内存,然后主线程再次运行。大多数时候您不会注意到这一点,因为CPU速度非常快。
但这种行为可能导致严重的问题。怎么会这样呢?有些操作系统需要某种实时保证,我给您一个重要的例子:汽车中的安全气囊。
想象一下,您为汽车编程一个电子控制单元。这完全关乎安全,拯救乘客和用户的生命。但是突然,当汽车发生碰撞,安全气囊应该弹出时,您只有几毫秒的时间,否则就来不及了。这时垃圾回收器启动了,您需要清理大量的,比如音乐对象,因为您还在车里听网络广播。那将非常糟糕,对吗?是的,如果您的安全气囊延迟了80毫秒,您可能会因为垃圾回收器启动而丧生。虽然这对于大多数Java应用程序来说完全没问题,但垃圾回收对于实时系统可能是个问题。也有一些编程语言没有垃圾回收,或者您可以禁用它,因为系统在实时执行方面有严格的要求。
因此,一方面,这是一个非常好的工具,但在某些情况下也可能导致严重问题。
使用线程 🛠️
我们使用多个线程来同时处理多个不同的事情。您可以在用户等待网络请求时做一些有用的事情,比如在屏幕上显示一个小动画,或者让用户使用应用程序的其他部分。程序可以在需要时随时创建和启动新线程。Java实际上提供了一个类,即Thread类,以及一个接口,即Runnable。让我们看看它是如何工作的。我们现在将看一些代码。
例如,我们可以创建一个名为MyThread的类,在MyThread中我们扩展Thread类,这是完全可以的,我们创建了一个子类。
在我们的主程序中,我们使用new操作符实例化这个类。
然后我们调用start方法。如果您这样做,如果您在线程上调用start,那么线程就会被生成,之后的所有内容都将在这个线程中执行。
不,抱歉,划掉,那是错误的。
start方法实际上在后台调用run方法,run方法中的所有内容都在新线程中执行。
而之后的所有内容仍然在同一个线程中执行。因此,如果您将此程序复制粘贴到您的Playground中,它将有两个线程:主线程,它将打印出“Thread has been started”,以及一个没有名称的后台线程,是的,您没有给它命名。它会说“I'm running”。并且,这也只是打印到控制台。
从“hello”到System.out.println的所有内容都在单独的线程中完成,当线程完成后,它也可以再次被垃圾回收器回收。
让我们试试看,将其复制粘贴到我们的Playground中。
让我看看,我想我需要启动它。让我们看看这里发生了什么。开始了,我把它粘贴进去。
然后如果我们启动程序。需要几秒钟,然后您会看到“Thread has been started”显示在控制台中,然后是“I'm running”显示在控制台中。对于控制台,我们总是有一个顺序,因为控制台会聚合所有输出。但是,如果您有多个核心,程序可以并行运行,那么我们无法真正说出哪个输出语句“I'm running”或“Thread has been started”先执行,因为这纯粹是并发完成的,取决于操作系统。
在这个特定情况下,哪个线程被赋予更高的优先级。我们还可以添加一个断点来验证系统正在运行哪个线程。所以,如果我现在调试应用程序。您会看到这个断点是在主线程中命中的,您在这里看到“main”。现在如果我继续。好的,出于某种原因,另一个线程没有停止。不确定为什么这没有奏效。让我们再试一次。
问题是它实际上同时命中了这个。所以,如果我们点击这个下拉菜单,您会看到我们有多个线程。
这里还有一些后台线程,它们是操作系统和Java虚拟机所需的,主线程在这里,然后我们有第二个线程,它同时处于等待状态,它只是被称为“MyThread”,您会看到这里有一些额外的方面,它是由主线程启动的,并且有一个特定的编号,thread-0。如果您创建更多线程,您会在这里看到不同的名称,并且您也可以看到,您可以给它们一个优先级,优先级帮助操作系统确定线程实际应该获得多少计算时间。
很好,然后如果我恢复,两个线程都可能恢复,然后我们看看这里发生了什么。
关于创建线程的第一种变体有什么问题吗?这并不太难。
但我们将看到,处理线程可能会变得相当困难,因为有时您需要同步多个线程。它们必须相互等待,您可能会遇到竞态条件和死锁。这是并发编程的难点。
很好,这是第二种变体,使用Runnable。我们可以创建一个实现Runnable的类,是的,这是一个接口,而不是类。所以,我们使用implements而不是extends,否则基本上是一样的。我们也创建一个线程,但不是MyThread,只是一个标准线程,因为我们没有子类,我们传递一个MyRunnable的实例,这意味着start方法之后也会调用run方法,并且基本上会做与上一张幻灯片相同的事情。因此,我们实现Runnable,每个Runnable必须实现run方法,就像Thread那样,然后我们可以创建或初始化一个新线程,如果我们有多个CPU核心,我们可以要求它并行运行,然后紧接着我们自己的程序执行继续。
关于变体2的问题:是的,问题是如果您在Java中编写代码,默认情况下是否只在一个线程中运行?是的,除非您使用并发包中的类,我们稍后会看到。所以,如果您不生成线程或Runnable,那么所有内容都在同一个线程中运行。可能的情况是,框架为您进行了一些线程处理,然后您并没有真正注意到您在单独的线程中运行。例如,如果您在项目工作中使用LibGDX,可能的情况是它们在后台生成了一些线程,那么这已经是并发的了。
很好。现在我们还有第三种变体,甚至更简单。这里,我们不需要实现或扩展任何东西,而是使用Lambda表达式,因为Runnable是一个函数式接口。我希望您记得,函数式接口意味着它只有一个方法,这意味着我们也可以将其用作Lambda表达式或匿名内部类,我们甚至可以在不使用Runnable这个术语的情况下创建一个线程。是的,所以所有没有参数,然后是一个箭头,然后是某些内容的东西都被视为Runnable。它可以运行,并且可以在单独的线程中生成。
关于变体3有什么问题吗?好的,很好。那么基础知识就快讲完了。快速看一下调度。
我已经告诉过您,操作系统通常在大多数情况下分配这些。在大多数情况下,线程数量远多于CPU数量。您已经看到我的机器上有1000个线程,即使我没有同时运行太多应用程序,并且可能有8个CPU核心左右。这在未来可能会改变,但需要很长时间我们才会有比进程更多的核心。
操作系统有一个叫做调度器的部分,这也是用编程语言编写的程序的一部分,负责管理所有可用的CPU,负责为线程分配执行窗口或时间窗口,以便它们可以运行,并且它实际上需要做很多事情。这就是为什么如果您在慕尼黑工业大学的操作系统课程中谈论调度器,您可能会花两到三节课来讨论它们,因为调度它们,首先有不同的策略、时间分片、朴素方法、优先级分配,但也有很多安全问题,因为您通常希望避免的是,当第二个程序运行时,第一个程序的某些数据仍然在CPU核心上和靠近CPU核心的内存寄存器上,因为那样第二个程序可能会窃取数据。
是的,所以在安全性方面,当您从一个线程切换到另一个线程时,有许多不同的机制来确保这些线程可以处理和使用的所有数据是完全分离的。
虽然在您的程序中,多个线程可能共享对数据的访问,但您绝对不希望多个应用程序之间共享。您不希望您计算机上的WhatsApp应用程序窃取您的银行账户密码或类似的东西,操作系统应该保护您免受这种情况的影响,并且有许多机制来确保这一点。
现在我们将开始讨论Java线程状态模型,这是真正有趣的地方,也是作为管理专业学生可能不会觉得有趣的地方,所以这是给硬核计算机科学家的内容。是的,您必须理解不同的状态以及它们如何相互转换。我们将从就绪状态开始,然后进入运行状态,然后您会看到线程可以处于训练状态、等待状态、睡眠状态,它们可以被阻塞,甚至可以是死亡状态,是的,这是一个最终状态。
如果您理解这一点,请举手。很好。谁想了解更多关于这个的内容?
嗯,那也很好,但我们不会涵盖这个。
抱歉,在我看来,这太底层了。是的,这就是70年代一切开始的方式,但我们不再处于70年代了,50年过去了,我们有更好的方式进行并发编程,这些方式更容易理解,当您不关心线程状态时。我鼓励您,如果您想学习这个,请去做,但我个人对此感到困难,我知道许多学生也是如此,这就是为什么我们不会在本课程中涵盖这个。
相反,我们将专注于更高级的概念。当我们这样做时,让我们再次快速回顾一下,并发编程确实是一项基础技能,因为现代软件要求响应性和可伸缩性,您不希望阻塞某些东西,这对于许多、许多用例和应用程序都非常重要。我们在这里看到的东西实际上相当古老。是的,我想这是10年前甚至15年前拍摄的,当时在单核上视频编辑的计算时间是九分钟。
您看到他们在这里测试的软件已经过优化,在四核上只花了2分39秒,这相当不错。当然,现在我们甚至有八核或十二核,它可能更快,但您可以看到这确实很重要,没有人愿意等待九分钟,如果任务可以在两分半钟内完成。因此,拥有这些技能非常重要,分布式系统在今天变得越来越重要。今天,不仅仅是一个应用程序单独完成所有事情,应用程序通过互联网与服务器、其他用户通信,可能还与附近的设备通信,例如,如果您有一个AirTag来寻找在客厅丢失的钥匙。越来越多的系统被分布到小型服务中,如果那里有一个阻塞操作,可能导致糟糕的用户体验和大量低效问题。因此,并发编程是关于优化资源使用,理想情况下使用小型服务,并保持系统响应性。
我们希望专注于并发的高级方法,我们希望抽象掉所有底层的线程管理,而更专注于需要做什么,而不是如何做。当然,我们也需要稍微关注如何做,我们需要教您如何同时拥有多个任务,但一旦您学会了这个,您会发现使用高级API相对容易,您可以真正专注于“什么”,并理想地构建一个可以实现结构化、基于任务的并发的系统,您只需定义:嘿,我有几个任务,并且您还定义它们如何相互依赖,然后程序、框架、Java虚拟机或您使用的任何东西决定如何以最佳方式并行、并发地运行它们。


这就是我们今天的目标。
064:软件工程 🏗️

在本节课中,我们将超越单纯的编程,探讨软件工程的核心概念。我们将了解软件工程不仅仅是编写代码,它更是一套用于生产高质量软件系统的技术、方法和工具。课程将涵盖软件工程活动概述、代码审查、客户端-服务器通信以及现代开发工作流。最后,我们会进行总结,回顾所学内容。
软件工程概述
软件工程是一系列技术、方法和工具的集合,旨在帮助生产软件系统。在实践中,尤其是在工业界,这通常意味着开发非常复杂和庞大的系统。
我们的目标是从最终用户的角度开发出高质量的软件,这意味着软件需要具备良好的可用性、性能和可靠性等不同的质量特性。同时,开发必须在给定的预算和期限内完成,否则可能无法获得报酬或失去市场优势。这就是典型的项目三角:质量、成本和时间。
此外,我们现在还有第四个维度:变化。世界并非静止不变,法律、技术和公众意见都在不断演变。因此,软件需要不断适应新的需求、技术和法规,以最好地服务于最终用户。
挑战在于处理复杂性和应对变化。到目前为止,我们只开发过非常简单的程序。即使是项目中的游戏,与工业界中拥有数百万行代码的软件相比,也相当简单。
技术、方法与工具
为了更好地理解软件工程,我们需要区分技术、方法和工具。
技术是使用明确定义的符号来创建结果的正式过程。这就像现实世界中的食谱。例如,在计算机科学中,一个排序算法就是一种技术。它接收特定输入(如一个数字列表),并产生特定输出(从小到大排序的列表),中间包含一系列需要完成的步骤。
方法是应用于整个软件开发过程的一系列技术,通常由一种哲学方法统一起来。现实世界中的例子可以是烹饪书。例如,意大利菜谱遵循一种特定的文化或哲学方法。在软件中,面向对象分析与设计就是一种方法,它将系统的所有部分视为对象。
工具是用于完成特定技术的仪器或自动化系统。在厨房里,这可能是烤箱或锅。在软件工程中,这可以是集成开发环境(如 IntelliJ IDEA),它基于编译器、编辑器、调试器等。工具还包括建模编辑器或更通用的计算机辅助软件工程工具。使用这些工具可以更快地实现结果,因为它们自动化了我们不想手动完成的步骤。
随着技术的进步,工具和编程方式也在发展。未来,生成式人工智能可能会越来越多地协助我们编写代码。
超越编码
软件工程远不止编写代码。虽然编码是核心,但最终目标是解决问题。
首先,它是一种问题解决技术。你必须理解利益相关者(最终用户、客户)的问题。只有正确理解并充分解决问题,无论使用何种编程语言或技术,才能创造出好的软件。
原型设计在这里很重要。你可以开发系统的一个小版本进行尝试。如果关于问题和解决方案的假设有效,再继续开发完整的解决方案。
你需要遵循某些设计原则,否则软件可能运行缓慢、难以使用或不可靠。
为了处理复杂性,我们使用模型。模型是现实的抽象。我们不想指定现实的所有细节,而是希望有一个简化版本,以便更好地理解并在不同专家之间分配工作。
为了应对变化,有一些特定的技术和流程,例如敏捷方法,可以帮助我们。
关键洞察
我们已经确定了一些关键洞察。
首先,你需要与人沟通,尤其是与客户沟通。软件工程很大程度上是关于沟通的。如果沟通不畅,最终可能会开发出错误的软件。
其次,我们通常解决的是复杂问题。对于复杂问题,重要的是进行分析,将其分解为更小的部分,然后再将这些小解决方案合成为一个更大的解决方案。这被称为分而治之。在软件工程中,我们有两个活动:分析(分解问题)和集成(将解决方案组合起来)。
第三,如果问题陈述发生变化,我们无法真正构建系统。因此,重新制定需求、改变需求以及适应变化的哲学至关重要。过度详细的计划会降低灵活性。最好有一个粗略的计划,然后保持灵活,适应当前情况。
最后,我们使用抽象来处理复杂性,特别是通过建模。建模的核心是沟通,它提供了一个共同的词汇表。模型最重要的作用是帮助人类(开发者、最终用户或客户)理解和讨论复杂方面。当然,模型也可用于分析和设计,甚至可以从CASE工具生成代码,或者用于记录设计决策和理由。
重要模型
在软件工程中,有几种重要的模型。
我们可以创建对象模型,用于建模系统的结构。这对于理解系统结构和我们拥有的实体类型很重要。
我们可以创建功能模型,这是从最终用户的角度出发,描述系统的功能,即所谓的用例。
我们还可以考虑系统的动态模型,即系统如何响应某些外部事件。你可以定义系统支持的活动(不仅是外部的,也包括内部的),以及实现这些活动所需的高级算法。
这三种模型(对象、功能和动态)共同构成了系统模型,这是开发的基础。
应用域与解决域
另一个重要的区别是应用域和解决域。
应用域是系统后期运行的环境。这影响了需求的获取和分析。我们需要来自这些领域的专家,因为开发者通常精通解决域,但不一定熟悉应用域。例如,金融领域就是一个应用域。
解决域则侧重于用于构建系统的技术,如编程语言、架构、代码部署方式等。这非常技术性,并且可以完全独立于应用域。
在软件工程中,我们需要两个领域的专家:理解应用域的领域专家,以及理解如何构建软件的程序员和开发者。
两个领域都包含抽象概念,我们需要将它们结合起来,思考如何在解决域(如数据库、应用服务、用户界面)中表示特定的应用域概念。
软件生命周期
当我们讨论分析、需求获取、实现、测试等活动时,我们通常也会关注软件生命周期。它描述了特定项目中重要的活动及其关系。
软件生命周期模型试图提出最佳实践,而软件生命周期则是该模型的一个实例。模型帮助你更好地理解你希望如何开发软件,因为有很多不同的方式。
下图展示了一个适合基于模型的软件工程的软件生命周期概览。
你从应用域开始,从客户那里获得问题陈述。然后,一个多样化的开发团队(包括不同背景和专业的成员)会经历一系列步骤来最终产出软件。
这些步骤包括:
- 需求获取:创建用例并将其分配给系统中的特定角色。
- 分析:创建应用域中的初始对象。
- 系统设计:构思架构,例如客户端-服务器应用。
- 对象设计:考虑每个子系统的实现细节(用户界面、应用服务器接口等)。
- 实现:用特定编程语言生成源代码。
- 测试:在多个层面验证软件是否正确工作。
这些活动不一定有严格的顺序,并且可以迭代和增量地进行。此外,还有一些贯穿整个生命周期的活动,如质量管理、配置管理和项目管理。
主要信息是:我们有定期进行的特定活动,也有贯穿项目整个生命周期的活动。我们从问题陈述开始,最终得到一个软件系统,并且这些步骤会重复多次。
核心活动详解
让我们快速了解这些核心活动及其最重要的方面。
需求获取与分析:我们希望通过与人们交谈来理解需求,并将它们记录下来,使其在功能需求和非功能需求(如可用性、性能、可靠性)方面保持一致、完整和可理解。然后我们进行分析,尝试创建初步的系统模型(用例、对象、动态行为)。结果是一个包含动态模型、对象模型和功能模型的分析模型。
系统设计活动:包含八个子活动。我们尝试确定设计目标、创建系统架构风格、将系统分解为更小的子系统、讨论并发性、硬件软件映射、决定构建还是购买软件、处理持久数据管理、全局资源处理(访问控制),以及讨论软件控制风格和边界条件故障。
对象设计与实现:重点是重用现有解决方案,使用设计模式等来适当地实现子系统。必须指定子系统之间的接口,特别是当它们运行在不同的硬件节点上时。可能需要重构对象并优化对象模型以满足性能标准。
测试:我们验证软件是否正确工作。内部测试包括单元测试、集成测试和系统测试。外部测试通常由客户进行验收测试。
所有这些活动可能有特定的顺序,但在不同的项目中,重点可能不同。因此,裁剪非常重要,即根据项目的具体需求调整生命周期模型。
过程模型类型
理解有两种类型的过程模型很重要:定义式过程和经验式过程。
定义式过程遵循某些规则,有时非常严格。它们是完全计划好的,并避免偏差,因为偏差被视为会导致问题。这通常适用于制造过程,但不非常适合软件工程。
经验式过程则并非完全计划好的。你有一个粗略的计划,但并不知道最终条件如何。这意味着你需要检查正在发生的情况,寻找变化,然后适应它们。在经验式过程(也称为即兴过程)中,偏离计划被视为一个机会。因为如果你能尽早识别这种偏离,就能尽早修复它。你不希望坚持一个会导致开发错误软件的计划。这意味着原型设计和沟通非常重要。
Scrum:一个经验式过程模型
Scrum是当今工业界广泛使用的一个经验式过程控制模型示例。
在Scrum中,主要思想是:
- 开始时有一个产品待办列表,这是最重要需求的列表,相对模糊地指定。
- 在一个称为冲刺的迭代(通常2到4周)中,团队尝试产出一个潜在可发布的产品增量。
- 从产品待办列表中取出最重要的需求,在冲刺计划会议上详细规划,放入冲刺待办列表,并定义冲刺目标。
- 团队专注于完成目标,避免导致中断的变更。
- 每天举行短暂的每日站会,讨论状态、障碍和承诺。
- 冲刺结束时,举行冲刺评审会议,演示成果,并举行冲刺回顾会议,专注于改进过程。
- 然后开始新的冲刺,逐步开发软件。
Scrum团队包含三个角色:
- 产品负责人:负责定义产品、价值和优先级,是与外界沟通的联络人。
- Scrum主管:负责过程,帮助团队发展,并解决障碍。
- 开发团队:由多能、自组织、多样化和跨职能的成员组成,负责实现软件。
总结 🎯

在本节课中,我们一起学习了软件工程的基础知识。我们了解到软件工程不仅仅是编写代码,它涉及一整套用于在预算和时间内生产高质量、复杂软件系统的技术、方法和工具。我们探讨了处理复杂性和变化的关键,区分了应用域与解决域,并概述了软件生命周期的核心活动(需求、设计、实现、测试)。我们还比较了定义式与经验式过程模型,并以Scrum为例,了解了现代敏捷开发如何通过迭代、增量和适应变化的方式来管理项目。希望这些概述能帮助你在未来的学习中更深入地探索软件工程领域。
065:客户端-服务器架构 🖥️🌐

在本节课中,我们将要学习客户端-服务器架构。这是一种在现代软件开发中至关重要的架构模式,它构成了我们日常使用的绝大多数互联网服务(如网站、手机应用)的基础。我们将了解其核心概念、工作原理,并通过一个简单的例子来实践如何创建一个基础的Web服务。
概述
在上一节休息之后,我们继续新的单元。本节将讨论客户端-服务器架构。你可能已经听说过这个概念,但可能还不完全了解其所有细节。当然,我们无法涵盖所有内容,但会尝试提供一个初步的概述,解释为什么需要这种架构、它是什么,以及在遵循此架构开发软件时需要考虑什么。最后,我们将展示一个小例子,演示如何轻松开始开发一个应用程序并在浏览器中测试它。
为什么需要分布式系统?
在介绍客户端-服务器架构之前,让我们快速思考一下当今软件系统的现实情况:我们拥有越来越多的分布式系统。开发一个分布式系统比开发一个独立的单机应用程序要复杂得多。那么,为什么每个人都在开发分布式系统呢?
首先,硬件能力是分布式的。为了向最终用户提供出色的功能,并让他们下载甚至购买你的软件,你通常需要利用超出终端设备本身的能力。此外,分布式系统通常也更容易扩展,并且对故障更具弹性。这就是为什么如今几乎所有系统都是分布式的。即使是游戏,现在也都有多人模式,不再完全独立运行。甚至你智能手机上那些免费游戏,通常也会从互联网加载数据,无论是广告还是游戏内数据。因此,现在很少有完全不与其他软件系统通信的独立软件。
分布式系统的挑战
当涉及到分布式系统时,存在一些特定的挑战。
- 连接性:系统之间需要相互连接。如果你的手机上的电子邮件客户端无法与电子邮件服务器通信,你就无法下载或发送电子邮件。
- 异构性:分布式系统的子系统可能使用不同的语言、平台和协议。例如,你的浏览器可能使用JavaScript,你的智能手机应用可能使用Kotlin或Swift,而服务器端可能使用Java或Python。
- 安全性:安全性是一个重要挑战。并非每个人都应该访问所有数据,因此需要身份验证、授权和加密。
客户端-服务器架构简介
客户端-服务器架构帮助我们解决上述部分挑战。其核心思想是,我们拥有许多分布式客户端希望访问和使用的共享资源和共享服务,我们需要控制对这些服务的访问和服务质量。
一个简单的例子是WhatsApp。当你发送消息时,你有一个分布式系统,并且不是每个人都能访问你的账户,但同时有数百万用户在使用它。主要问题是如何管理这组共享资源和服务,同时使其易于开发、修改、重用、扩展,并理想情况下始终保持可用。
解决方案是,大多数系统现在都使用某种形式的客户端-服务器架构。在这种架构中,客户端通常非常接近最终用户(在你的智能手机、手表或个人电脑上),而服务器通常位于云端或某个数据基础设施中,远离最终用户。然后,多个客户端(不仅是我的iPhone,还有你的iPhone)与WhatsApp服务器交互。WhatsApp服务器也不止一台,而是有成千上万台,否则无法扩展。
主要思想是,客户端发起通信并发送请求。服务器以某种形式处理此请求,然后发送响应。
客户端与服务器的角色
以下是客户端和服务器通常承担的功能概述:
- 客户端功能:通常包括用户界面,以及对输入数据进行初步有效性检查。例如,在我的Sparkasse银行应用中,应用可以检查IBAN格式是否正确,但它无法检查该IBAN是否存在,因为这需要访问所有银行的数据。
- 服务器功能:通常包括集中式数据管理(如包含所有用户数据的数据库),确保数据完整性和一致性,以及实施安全性措施,确保人们只能访问属于他们的数据。
其核心理念是:一个或多个服务器向客户端提供服务,这些客户端可以发送请求。服务将被执行,其中涉及一些业务逻辑、功能,或将其委托给其他系统,然后将响应发送回客户端。
重要的是,客户端和服务器必须使用相同的“语言”进行交流,否则它们无法相互理解。这里指的不是编程语言,而是通信协议。如今,大多数通信通过HTTP完成,但客户端-服务器也有其他协议。即使使用HTTP,也需要确保使用相同的数据模式和接口,否则将无法工作。因此,客户端和服务器虽然通过接口解耦,但在数据定义上彼此有很强的依赖性。即使它们用不同的编程语言实现,数据定义通常也是相同的。
响应通常是相对即时的。最终用户只与客户端交互,从不直接与服务器交互。
通信风格
在这种分布式系统中,有不同的通信风格。我们可以大致区分为所谓的基于消息的通信风格和基于方法的通信风格。
最终,基于方法的风格在内部仍然使用某种消息,但对你来说看起来不同。如今,大多数通信使用HTTP、REST或类似协议,因此大多数通信是基于消息的。然而,也有一些标准,如远程过程调用、远程方法调用、CORBA和gRPC,从开发的角度来看,它们看起来不像你向服务器发送请求然后等待响应,而更像是你在Java程序中调用一个方法。但该方法不会在同一台计算机的同一进程中执行,而是被转移到另一台计算机,在另一个进程中调用,然后响应自动发送回来。因此,从开发体验来看,这可能更容易一些。尽管如此,即使在许多系统中调用方法可能更容易,我们仍然更喜欢基于消息的通信。
其思想始终是,客户端充当请求者,向Web服务(我们也称其为应答者)发送请求。一个请求可能是:“亲爱的Web服务,请给我Stefan的LinkedIn个人资料。”然后一个回复说:“好的,你有权访问Stefan的LinkedIn个人资料。这是Stefan的LinkedIn个人资料。请在你的用户界面中显示它。”然后客户端(可能是LinkedIn网站)显示我的个人资料图片、姓名、工作内容、历史记录、帖子等。
理想情况下,整个通信应该是无状态的。这意味着Web服务甚至不知道已经发送了什么样的请求。Web服务只需要知道客户端是否经过身份验证以及客户端具有什么角色。除此之外,你在请求我的LinkedIn个人资料之前是否已经请求过Markus的个人资料,这应该没有区别。这就是我们所说的无状态。
REST架构
当涉及到客户端-服务器时,如今许多应用程序使用REST作为架构或通信协议。REST非常适合客户端-服务器,因为它使用HTTP,采用请求-响应机制,此外,它还提供了所谓的客户端REST层和服务器端REST层,这些层确保你遵循特定的通信风格。
这种特定的通信风格对于保证客户端-服务器和分布式系统的某些方面正常工作非常重要。如果开发人员遵循REST的规则,那么通常更容易维护软件,错误更少,用户体验更好。
REST使用所谓的资源,资源类似于对象。你在资源中存储某些对象,然后可以在此资源上调用某些方法。你可以获取资源中的对象,更改资源中的对象,删除资源中的对象(如果你有权限),并在此资源中创建新对象。
我们通常在这些资源中使用类的对象或实例。一个例子是,在社交媒体平台中,你有一个带有姓名和评论的用户,该评论可以添加到文章中。然后我们有对象的例子:I 将是 User 类的一个实例,因此 I 可能在 user 资源中;而ITP的通知可以是 Article 类的一个实例。
资源应该始终是名词或事物,如 users、articles、comments,永远不应该是动词或动作。这是约定。资源应该是可识别的,例如通过URI(统一资源标识符)。
CRUD操作与HTTP方法
现在,假设我们有这样一个资源。我们使用所谓的CRUD操作:创建、读取、更新、删除。通过这些操作,我们可以操作资源中的对象。
- 创建:意味着你创建一个新用户。例如,你在网站上创建一个新账户。然后数据以所谓的 POST 请求发送到服务器,遵循创建的思想。然后这个新用户也在服务器上创建,并可能存储在数据库中。
- 读取:表示获取某些信息。这可能是“给我关于特定用户Stefan的所有信息”。然后服务器说:“嗨,这是Stefan的信息”,但不会给你其他用户的信息。
- 更新:更新对象的某些方面。例如,更新Stefan的电子邮件。然后更改后的电子邮件将存储在服务器端和数据库中。
- 删除:删除我的账户。然后很可能有一个 DELETE 请求发送到服务器,删除名为Stefan的用户。
这四个主要方法允许你与REST资源进行交互。通过它们,你已经理解了客户端和服务器之间95%的通信。
现在,我们可以将CRUD操作映射到HTTP动词。HTTP是用于在网络上通信的协议。HTTP消息总是由统一资源标识符(例如,linkedin.com/profiles/stefan)、HTTP方法、一些允许你自定义通信的附加信息键值对以及可能包含任意数据的消息体(例如,创建对象时需要发送的数据)组成。
- 创建 -> POST 方法
- 读取 -> GET 方法
- 更新 -> PUT 方法(更新具有特定标识符的现有资源)
- 删除 -> DELETE 方法(删除具有特定标识符的现有资源)
注意,对于GET,理论上可以区分获取列表中的多个对象,甚至获取所有对象(尽管这不是一个好主意,因为这可能需要一些时间),以及获取位于非常特定标识符后面的一个对象。
资源标识与URL
我们如何用URL(统一资源定位符)标识资源?大多数情况下,这是一个URL。URL通常看起来像这样:https://cit.tum.de/ase。我们有协议(现在大多是HTTPS,S代表安全加密通信),然后是主机名(也称为域名),它通常绑定到特定组织。然后我们可以选择性地有一个端口(大多数时候最终用户看不到端口,但通信有时使用端口,因为端口允许你在同一台计算机上建立多个连接到特定的Web服务应用程序),然后我们有一个路径(例如 /articles)。通过这个路径,我们可以将请求发送到此URL后面的服务器和这个特定路径。
因此,如果我们现在用这个URL调用GET,就意味着“给我你在这个特定Web服务上存储的所有文章”。
RESTful API 结构
一个RESTful API由一个API(应用程序编程接口,即Web服务的接口)组成,它包含一组端点。你不仅有一个资源,而是有多个资源,一个端点总是由一个特定的路径和一个特定的方法标识。因此,使用相同的URL,你可以有多个不同的方法(GET、POST、PUT等)。
我们的资源然后必须具有创建、读取、更新和删除方法,并且通常具有特定的标识符。一个端点可以由多个资源组成。
这意味着我们可以很好地将URL结构映射到API。类被映射到端点,对象被映射到资源,我们甚至可以通过关联嵌套端点。
例如,假设我们有一个文章集合,由多篇文章组成。文章有一个标识符和文本,你可以发布它们。每篇文章可以有多个评论,每个评论属于一篇文章。你不能写一个属于两篇文章的评论。
状态变化示例
这里有一个Web服务状态的例子,以及特定客户端Web为了改变这个状态会做什么。状态是可能持久化在数据库中的东西。但现在,我们真正关注服务器端。
假设我们有一个报纸,它是一个文章集合,由多篇文章组成。现在有两篇:A1和A5。文章A1有三条评论:C11、C12和C13。这可能是你启动网站服务时看到的当前状态。
现在,假设一个客户端与之交互,并向 /articles 发送一个GET方法。那么将返回这两篇文章(A1和A5)。
假设有人在文章端点上调用POST。那么这个人需要在消息体中提供所有数据。这将意味着发布并创建一篇新文章。现在你可以在报纸中看到三篇文章,例如一篇名为A2的文章。
接下来,你可以更改现有文章,更改此处的文本。也许你发现了一个拼写错误,想要更正它,然后你调用 PUT /articles/1(因为1是标识符,唯一标识文章1),然后通过消息体更改一些现有值。
DELETE将意味着文章消失。也许你认为文章过时了,或者它是错误的(可能包含一些假新闻)。然后你可以调用DELETE方法。这里再次,/articles/5 标识了这里非常具体的那一篇。
接下来,你也可以通过文章导航到评论,所以你可以说 /articles/1/comments,这将给你文章1的所有三条评论的列表,然后客户端可以在场景中显示它们(例如,最终用户点击主页上的文章,评论不显示,但用户点击文章,然后你显示该文章的所有评论)。
当然,我们也可以通过向文章2发布评论来创建新评论,我们在这里有一个新评论。我们可以更改现有评论(可能用户想更正其中的拼写错误),使用PUT方法。同样,你看到我们如何在这里导航:/articles/1/comments/2,然后我们传递一个更改。
当然,我们也可以使用嵌套路径删除评论。
序列化与JSON
当客户端和服务器相互通信时,它们通常使用不同的编程语言。客户端可能是用JavaScript开发的网站,服务器可能是用Java开发的。即使Java和JavaScript听起来相似,它们在技术上是完全不同的。你需要确保这两个应用程序可以相互通信。
理想情况下,我们希望交换对象(如评论和文章),你只想向Web服务器发送一篇文章,或者想检索一篇文章。然而,如果客户端和服务器在内部使用不同的方式存储这些对象,我们需要弥合这个差距。这通常通过序列化和反序列化来完成,最显著的是,我们现在使用JSON格式。
其思想是,在将对象发送到服务器之前,你将其序列化为特定格式(称为JSON)的字符串。然后当字符串通过HTTP、通过网络、通过WiFi或电缆发送到另一台计算机时,另一台计算机可以使用该字符串,将JSON反序列化回对象。
因此,当我们从客户端向服务器发送评论时,我们首先将其转换为JSON字符串,通过网络发送,然后服务器检索字符串,将JSON形式的字符串反序列化为评论,然后双方就可以就评论进行交流。但正如往常一样,这中间存在序列化和反序列化。
JSON代表JavaScript对象表示法。它是一种开放格式,易于阅读、紧凑且与编程语言无关,因此每种编程语言都可以使用它。
客户端不会向服务器发送内存中的对象,而是序列化一个看起来类似于这里的字符串,将其发送到服务器,然后服务器可以说:“哦,好的,这个字符串对应这个对象”,然后再次转换它。这就是基本思想。
HTTP状态码


当谈论HTTP时,重要的是你不仅要处理通信的成功,还要处理错误消息。HTTP有几个状态码来实现这一点。
- 2xx:如果没有发生错误,你通常有200范围内的错误代码。200表示OK,201表示在POST请求后创建了某些内容。如果某些内容因位置更改而被重定向,也可能是300。
- 4xx:如果发生错误,我们区分客户端错误(400)和服务器错误(500)。客户端错误意味着,例如,客户端指定了错误的内容,或数据错误,或不允许客户端访问数据。
- 5xx:以5开头的所有内容意味着服务器理解了请求,但某些事情没有顺利进行。要么请求尚未实现,要么存在某种内部错误。
当然,服务器也应该始终告诉客户端出了什么问题,以便客户端可以从错误消息中恢复。
实践示例:创建简单Web服务
为了了解其实际运作,我们将通过一个非常简单的例子来实践,你现在可以和我一起操作。我们将使用IntelliJ IDEA创建一个小的Spring Boot应用程序。
- 创建新项目:点击“New Project”,创建一个简单的Java项目,命名为
hello-spring-boot。使用Gradle、JDK 17和Groovy设置。 - 配置构建文件:打开
build.gradle文件,粘贴提供的依赖项(spring-boot-starter-web和spring-boot-starter-tomcat)。Gradle将下载所有依赖项。 - 创建应用类:在
de.tum.cit.ase.hello包中创建主应用类,使用@SpringBootApplication注解和主方法运行Spring应用。 - 创建REST控制器:在同一包中创建控制器类,使用
@RestController注解,并定义一个处理/hello路径GET请求的方法,返回一个字符串。 - 运行应用:启动应用程序。Spring Boot将在本地主机的8080端口启动一个Tomcat服务器。
- 测试:在浏览器中访问
http://localhost:8080/hello,你应该能看到控制器返回的字符串。
通过这个简单的例子,你可以看到使用Spring Boot创建Web服务是多么容易。当然,真实的Web服务器会更复杂,需要连接数据库、定义对象模型以及处理JSON序列化/反序列化,但这是一个很好的起点。
总结


本节课中,我们一起学习了客户端-服务器架构的基础知识。我们探讨了为什么分布式系统成为主流,以及它们带来的挑战。我们深入了解了客户端-服务器模型如何通过分离客户端(靠近用户)和服务器(提供共享服务)来应对这些挑战。我们介绍了REST架构风格,它将资源映射到URL,并通过HTTP方法(GET、POST、PUT、DELETE)对应CRUD操作来操作这些资源。我们还了解了JSON作为数据交换格式的重要性,以及HTTP状态码在通信中的作用。最后,我们通过一个实际的Spring Boot示例,亲手创建并运行了一个简单的Web服务,直观地体验了客户端-服务器交互的过程。希望这为你打开了Web开发世界的大门。
066:代码审查与静态分析

在本节课中,我们将要学习软件工程中一项至关重要的实践:代码审查。我们将探讨其重要性、不同类型,并深入了解如何利用静态代码分析工具来自动化审查过程中的重复性任务,以提高代码质量和开发效率。
什么是代码审查?🔍
在下一个单元,我们将讨论代码审查。

代码审查对于减少缺陷数量非常重要。这是一种缺陷规避技术,我们在软件过程的各个阶段应用它。
请注意,在软件中越早发现问题,修复这些问题就越容易、成本也越低。因此,代码审查是许多不同活动的一部分,不仅涉及代码质量,也涉及模型质量和需求质量。
代码审查意味着一个人首先创建一份文档(或代码),然后第二个人审查其结果,以发现问题、错误、不良实践等。审查可以是非常正式的,也可以是非常非正式的。例如,你可能偶然遇到某人,只是谈论你的想法,然后你注意到你的想法中存在一些缺陷。
在任何情况下,在软件工程项目中,代码审查都非常重要,它们需要你投入时间和精力。当然,组织也必须承担这些成本。但研究表明,代码审查非常有效。它们不仅能发现代码问题,还能发现设计问题或需求问题,这些构成了软件最终错误中超过50%的部分。
代码审查的类型与形式
根据软件项目或活动的阶段,我们有不同类型的审查。你可以审查分析结果、审查架构、审查代码。在Scrum中,你还需要进行冲刺评审。
另一方面,我们可以根据审查的正式程度来区分,其中“检视”是软件工程中最正式的审查技术。在这种形式中,甚至不是创建工件的人来展示它,而是由另一个未创建它的人来展示。通常有6到8人作为听众一起审查。有正式的协议,定义了某些角色,以得出最佳结果和分析,判断工作产品是否具有良好质量或存在需要解决的问题。
在这之间,还有“走查”和“同行评审”,人们可以同步进行(例如在同一台电脑前一起工作),或者异步进行。如今,许多开发者在分布式团队中工作,例如在开源社区中。他们通常在分支中创建代码,然后其他人在拉取请求中审查代码,并提供关于代码质量好坏的反馈。
代码审查的目标与优势
当涉及到代码审查时,目标是提高代码质量,并识别那些会导致错误和最终用户体验不佳的问题。有些人也称之为实现“整洁代码”。
其优势不仅在于显而易见的代码质量提升。另一方面,还在于相关人员之间的知识传递。我们希望你们在项目工作中也能体验到这一点。如果两个人处理同一段代码,那么他们都能更好地理解正在发生的事情。当你再次考虑涉及数百名开发人员且人员流动率高的更复杂项目时,每年可能有20%的人离开公司,20%的人加入公司,开发人员流动率为20%。那么,如果有人离开,有其他人知道这段代码实际上是如何工作的、代码做了什么以及其背后的含义,这当然是有益的。
它还可以改善开发人员之间的沟通、文化和团队氛围。
代码审查的挑战
当然,也存在挑战。你需要投入时间和金钱,这会减慢开发速度。审查工作相当重复,许多人并不喜欢审查某些东西。软件开发者真正喜欢的是在没有任何依赖的“绿地”上发明新代码。他们不喜欢的是编写测试、编写文档和进行审查。因此,管理者需要督促他们、激励他们,向他们解释为什么这很重要。否则,他们不会自然而然地去做。
静态代码分析:自动化审查
静态代码分析是一种技术,允许我们使用所谓的“Linter”和其他规则检测器来自动化代码审查的重复性方面。我们可以识别代码中的常见问题,确保格式正确等等,从而已经修复了许多问题。
然而,许多代码审查仍然是必要且有用的,因为静态代码分析,甚至是最好的LLM,都无法识别代码中的所有问题。因此,一个最佳实践可能是:只有在所有自动检查(可能是静态代码分析,可能是测试用例)都通过之后,才手动审查代码。只有这样,才让一个人花时间查看它。
代码中的典型问题
代码中存在典型问题或编程错误。可能是代码风格错误,导致代码难以理解,只有开发者自己能懂,但其他人不行。我们基本上试图摆脱这种情况。例如,当我从前任教授那里接手幻灯片时,有很多代码难以理解,我们试图使其更容易理解,但这总是一种权衡。
代码可能由于空块或没有实际意义的if-else语句而无法到达。代码可能过于复杂,包含安全问题。人们可能只是复制了代码。在架构方面,可能某些类之间依赖过多,导致高耦合;或者它们之间根本没有依赖,这可能导致低内聚的问题。
实践练习:识别代码问题
我们现在有一个小练习。请看幻灯片55。请举手告诉我你在代码中发现了什么问题。
这不是一段好代码。我们可以发现很多问题。我们想开始。有志愿者吗?
我们剩下的人不多了。这感觉有点像大讲堂里的辅导课。但无论如何,你们留在这里,你们经受住了所有不来上课的诱惑。为此感谢你们。但现在你们也需要参与进来。谁能发现代码中的一个错误?
你超出了范围。我知道你很容易做到。我们希望观众中有人来回答。否则,我会点名。你怎么样?是的,拿着黄色瓶子的那位,对,就是你。
是的,这段代码如果放入编译器甚至无法编译,因为变量3未定义。你还看到了什么?这超级简单。
是的,非常好。这里存在数组索引越界异常,因为我们只有索引0到4,但我们访问了索引5。还有什么?是的,这里缺少相同的冒号。还有什么?
这个除法没有意义,因为我们会除以零。非常好。
缩进在这里似乎也不对。或者可能缺少闭合大括号。是的,你无法立即看到这一点。
这里我们在同一行有开括号,下一行也有。然后一些变量以大写字母开头,另一些以小写字母开头。所以这在定义上不太一致。在for循环的定义中,我们有逗号而不是分号。所以这也不会工作。
你看,审查糟糕的代码并不是一件非常愉快的事情。当然,如果你能找到问题并帮助某人改进,这很好,但这可能不是你最喜欢的活动。
这里是一个包含一些问题的示例解决方案。我们谈到了算术异常,这里有一个找不到的符号“变量3”,数组越界,格式化问题,这里没有空格,这里有一些。Java找不到符号,因为它是用小写字母写的“s”,而这里是大写字母。格式化错误、命名错误、缺少分号等等。所以这真的是非常糟糕的代码。但这是我们在实践中看到的情况。当然,它会编译,因为编译器会帮助你。但关于变量名,我们甚至没有讨论变量名。它们不是很有帮助。是的,什么是var1、var2、bar3等等?为什么它们没有更好的名字?是的,拥有有意义的名称非常重要。否则,人们将无法理解发生了什么。我猜没有人能告诉我们这里使用了什么算法。我甚至也不知道。这无关紧要,但如果你看到变量名,并且它们有有意义的名称,理解起来就容易得多。
自动化工具:静态与动态分析
当然,要解决所有这些问题需要花费大量时间和许多双眼睛。在最好的情况下,开发者甚至不会编写糟糕的代码,但这并不总是可能的。好消息是,我们有一些工具可以帮助我们。首先,我们有静态分析。然后我们还有动态分析来验证软件的正确性。
有什么区别呢?静态分析意味着你不执行代码。你只是查看代码结构、空白、格式以及许多其他方面。你可以手动完成,就像我们刚才做的那样,手动浏览代码,查看,与最佳实践、代码规则进行比较,看你是否遵循它们。当然,这很繁琐,最好有一些自动工具来做这件事。每种编程语言都有很多这样的工具,例如SpotBugs,你可以轻松找到语法和语义错误,或者偏离编码标准的情况。
然而,另一方面,静态代码分析无法发现所有错误。动态分析也有用武之地,即你执行代码并编写测试,要么关注输入和输出(这称为黑盒测试,因为你不查看代码内部,代码就像一个黑盒),要么是白盒测试,你查看代码并专门测试子系统或类的实现。
我们想先看看静态分析,因为它与代码审查相关。动态分析有点超出范围,但你将在下学期的软件工程课程中学习它。
静态分析工具的工作原理
有不同的静态代码分析工具,它们都通过读取源代码来工作。从中提取一个模型作为中间表示,并分析这个模型是否遵循某些规则,然后向你提供一个输出结果,说明“嘿,这是好代码”或“不,这是坏代码,我发现了以下错误”。
中间表示可能不同。它可以是一个符号表,你在其中定义所有符号和分配。它可以是一个抽象语法树,例如你的编译器在重构时使用的。它可以是一个控制流图,你可以看到代码实际上是如何在代码中导航的,从而识别是否存在永远无法到达的死胡同。它也可以是一个调用图,你可以看到哪个方法调用了哪个其他方法,以识别程序调用语义的某些方面。
所有这些表示,所有这些中间表示,都帮助我们找出代码的某些方面,例如你是否遵循最佳实践,以及代码中有什么样的度量指标,是复杂还是简单。这有助于我们分析代码并识别改进之处。
在某种意义上,你在IntelliJ或VS Code中看到的代码警告和错误已经基于静态分析。因此,静态分析已经内置到IDE中。每个关于某些东西不工作的编译器警告或编译器错误都是某种静态分析。但还有更多。有度量指标可以让你发现代码中的结构异常。还有其他工具,如Java的SpotBugs、PMD或Checkstyle,以及其他编程语言的许多其他工具。所以再次强调,这里的重点是理解这个思想,而不仅仅是使其适用于一种编程语言。
集成静态分析到工作流
事实上,有如此多的工具和如此多的不同设置方式,以至于有一种集中化的趋势。SonarQube和SonarCloud以一种非常好的方式做到了这一点。也有其他例子,但这些特别好,因为你可以简单地将项目上传到GitHub,在SonarQube或SonarCloud注册一个账户,然后自动获得项目的静态代码分析。它会根据你的编程语言和业界商定的某些最佳实践,自动尝试从头开始设置,这使得开始使用静态代码分析变得非常快速和简单。
对于静态代码分析来说,重要的是将其集成到你的工作流中。理想情况下,每次提交都会根据某些问题进行检查,并且你的Git仓库中的每次提交实际上都会检查它是否满足规则,以及有多少偏差。你有多少技术债务。技术债务意味着你的代码中存在需要在未来某个时间点修复的问题。当然,技术债务越多,系统的质量就越低,未来维护和实现新功能也就越困难。如果系统永远不会再改变,那也没关系,但我不知道有任何系统会保持不变且不随时间变化,所有系统都在进一步发展。
实践演示:使用SpotBugs
让我们看一个使用SpotBugs的快速示例。我创建了一个仓库,我们可以通过以下链接使用它。所以现在请和我一起在IntelliJ中克隆这个仓库。
我们这样做:文件 -> 从版本控制新建项目。我们粘贴链接。你不需要任何身份验证。这是一个开源仓库。我们克隆它。
在它配置自己的同时,我们可以安装SpotBugs插件。你怎么做呢?你去IntelliJ设置。然后你去插件。然后你去市场,搜索SpotBugs。然后你安装它。
然后我们可能需要重启IntelliJ。但也可能不需要重启就能工作。
所以现在请这样做,给你一些时间。你会在幻灯片上看到它是如何工作的。
你打开文件,然后偏好设置或设置,取决于操作系统。然后你去插件。你在市场中搜索SpotBugs,然后点击安装按钮。助教也可以在周围走动,以防你需要帮助,他们不会一直坐着,你可以为你的健康做点什么。
很好,现在下一步,如果你已经完成了,就是再次打开IntelliJ。打开源文件夹。你看,我们有一个课程类、一个主类、一个学生类和一个在线类。现在你可以做的是,基本上右键点击项目。然后在上下文菜单中应该有一个SpotBugs条目。然后在这里你可以说“分析模块文件”或“项目文件”,这没关系,不包括测试源。这样,我们基本上就开始了静态分析。
在SpotBugs中,能够在你的代码中的四个类里找到7个错误或潜在错误。😊
你应该在左侧有这个小的错误图标。如果你点击它,然后点击应用程序,抱歉,我现在在错误的窗口里,让我关掉另一个。
所以我们在这里。再次,如果你点击左侧边栏中的这个小图标,那么你可以导航到错误中,并看到例如在学生类的第18行有一个错误。
这里的问题是什么?问题是这样的。Replace all,是的,我们这里有一个外国名字,我们想根据正则表达式用一些其他字母替换某些字母,我们甚至不需要理解正则表达式,这没关系。但这里的问题是,这在这里总结得很好:方法的返回值被忽略了。这里发生了什么?嗯,开发者认为replace all是就地改变字符串本身。并且for name本身被改变了,变量for name被改变了,但在你上完这门课后,你知道得更清楚。字符串是不可变的,对吧?你在字符串上调用的每个方法都不会改变字符串本身。但这里我们做的是返回一些东西。是的,这个返回值被忽略了。这是一个错误或错误模式的指示。现在,我们如何修复它?嗯,我们可以创建另一个变量存储它,然后在这里分配给属性,或者我们可以简单地复制粘贴这段代码,直接将其分配给属性。
现在,如果我们再次运行SpotBugs。分析项目文件,不包括测试源。我们看到错误数量减少了一个,并且在这个位置不再有错误消息了。你可以查看所有其他错误,现在你有几分钟的时间来处理这个问题并修复它们。这里没有示例解决方案,这真的只是让你尝试一下,这可能在你未来有一个子程序时帮助你发现某些问题并修复它们。所以我会给你几分钟,现在开始工作,如果你有问题或发现某些东西不工作,请举手,然后助教会过来。
好的,时间到了。我们希望你已经能够尝试并修复至少几个提到的问题。如果你还没有完成,你仍然可以在之后做,但由于时间原因,我们必须继续。
云端静态分析:SonarCloud示例
让我向你展示在SonarCloud中是什么样子,他们自动为你配置SpotBugs和其他Linting工具。它易于设置,并集成到开发工作流中,导致类似持续代码质量的东西,你可以看到分支中的某些提交是否未能通过质量门,或者是否通过了质量门,他们还在浏览器上给你一个非常好的概览,显示修复问题需要多长时间,以及他们有什么样的建议。

这里有一个例子,你也可以自己查看。这是SonarCloud上的一个Apache项目,我很久以前就截了图,但我们可以检查它是否在中间有所改进。


嗯,让我们看看这里发生了什么,如果我们去这个网站。然后我们看到它现在看起来有点不同。他们改进了设计,但基本上这个项目的安全评级仍然是E。仍然有21%的代码重复。某些可靠性错误仍然存在。所以即使人们使用它,也不意味着他们最终会改进他们的代码,但它确实给你一些很好的见解。还有你有多少代码,你使用什么语言等等。

如果你深入研究一个项目,你可以搜索不同类型的问题。例如,错误或安全漏洞、代码异味。SonarCloud尝试自动为你创建一些优先级或严重性,并区分阻塞、严重、主要、次要和信息性问题。然后如果你看一个具体问题,你会看到,嘿,这是一个错误。它是阻塞性的。它仍然开放且尚未分配,我们估计这将花费你五分钟来修复它。
如果你结合所有这些时间估计,你会得到项目技术债务的总体估计。修复所有问题需要多少时间。有544个错误、112个漏洞和20K个代码异味。那需要多少时间?
请注意,一些代码异味可以自动修复,特别是当涉及到某些最佳实践、格式或特定实践的使用时,某些方面可以自动修复,但这里发现的大多数问题必须手动修复。
我们也可以看到我刚才给你的例子的结果。我为SB示例设置了SonarCloud,我们看到他们识别出相同的七个问题,分为四个错误(针对replace all方法的返回值)和三个代码异味,然后你看到修复它应该花费多少努力。我不真的认为是10分钟。是的,如果你真的快速地去代码那里,修复它可能只需要一分钟。但当然,这涉及一些开销,你必须阅读这个,你必须导航到你的项目,最终你必须提交它,你必须将其集成到主分支,所以总的来说,这绝对可能需要10分钟。
静态分析的局限性与最佳实践
关于静态代码分析、SonarCloud有什么问题吗?是的,问题是:你被允许在监督练习中使用这个插件吗?是的,你被允许使用它,但它可能对你没有帮助,因为我们的代码设计方式通常不会遇到这样的问题,而且如果你不断检查,也可能导致开销。但只要你没有使用AI工具,并且SpotBugs尚未基于AI(未来可能基于AI,我们不知道,但目前它不是基于AI),你就有可能使用它。
你不能做的是将你的代码上传到SonarCloud。这是不可能的,但如果你在IntelliJ中使用SpotBugs插件或任何其他用于静态代码分析的插件,这是可以的。
静态代码分析存在某些局限性。正如你在幻灯片上的SonarCloud截图中看到的,他们发现了许多许多错误,有时这些错误无关紧要。它们并不重要。但有时存在重要的错误,如果你现在发现20k个问题,要找出哪些是相关的、哪些根本不重要,并不超级容易。并且有一些实验,例如William Po在Cha One会议出版物中的实验,表明静态分析可以发现5%到10%的软件质量问题,但这也意味着其他软件质量问题无法被发现,有时有太多的噪音,太多的误报,太多的错误需要修复。
然后,有时也会对开发者造成干扰或误导。当然,存在静态代码分析无法发现的问题,需要适当的动态分析(即测试)来解决。所以静态代码分析可以帮助你,但它不能替代手动代码审查,也不能替代测试。不要被工具提供商的营销所迷惑。
我们的建议
我们这边的建议是:不要跳过编写测试用例。即使在一个相对较小的项目中,比如你的游戏开发,编写测试用例以确保如果你改变功能,现有功能不会崩溃,这可能是有意义的。这也称为回归测试。
在实践中,不要跳过手动代码审查。然而,你可以做的是,只有在所有你拥有的测试都通过,并且所有你为项目配置的静态代码分析工具(你认为它们有意义)都通过之后,才手动审查代码。只有这样,才应该让一个人参与代码审查。在此之前,创建代码的开发者应确保测试通过,并且项目中配置的静态代码分析通过。因为如果这些最低要求没有满足,要求另一个人、另一个开发者花时间提供反馈和改进代码是相当不公平的,因为人类开发者不知道静态代码分析是否能同样发现问题。
所以静态代码分析可以提高代码质量和代码的可维护性,但它不是灵丹妙药。如果你有效地使用它,它比捕捉同类错误的其他技术更便宜。
因此,绝对值得将其集成到流程中,例如,让SonarCloud检查你在开源项目中的所有提交和所有拉取请求。
将其与动态分析(测试)结合也是一个非常好的主意,但你必须投入一些时间进行手动代码审查,或者可能让LLM审查你的代码(如果它们变得更好,未来可能会取代人类代码审查)。但目前要小心,在一些项目中,LLM也可以取代人类代码审查。然而,这在未来可能会改变。
很好,我们将再做一个短暂的休息,并在5点10分继续今天的最后一个单元,在那里我们将发现更多开发工作流,并深入探讨持续集成和持续交付,这是我在软件工程中最喜欢的话题之一。😊

总结

在本节课中,我们一起学习了代码审查的重要性及其在软件开发生命周期中的关键作用。我们探讨了从非正式讨论到正式检视的不同审查类型,并深入了解了如何利用静态代码分析工具(如SpotBugs和SonarCloud)来自动化识别常见代码问题。我们认识到,虽然自动化工具能显著提高效率并发现许多问题,但它们不能完全替代手动代码审查和全面的测试策略。一个平衡的方法——结合自动化检查、同行评审和动态测试——是确保软件高质量、可维护性和团队知识共享的最佳实践。
067:开发工作流 🚀

在本节课中,我们将要学习现代软件开发中的核心工作流。我们将探讨如何使用版本控制(如Git)进行协作,理解分支管理模型,以及如何通过持续集成和持续交付等实践来自动化构建、测试和部署过程。这些知识将帮助你更高效、更可靠地开发软件。
Git 工作流回顾
上一节我们介绍了软件工程的基本概念,本节中我们来看看一个具体的协作工具:Git。对于软件开发人员来说,掌握Git并理解其基础原理至关重要。
Git允许你将工作副本中的更改添加到暂存区,然后提交到本地仓库。这意味着你可以在离线状态下快速完成这些操作。如果你想与其他开发者共享代码,则需要将其推送到远程仓库。如果其他开发者对代码库做出了贡献,你需要获取这些更改并将其合并到你的工作副本中,这个过程通常通过 git pull 命令完成。
以下是Git的基本操作流程:
git add: 将工作区的更改添加到暂存区。git commit: 将暂存区的内容提交到本地仓库。git push: 将本地仓库的提交推送到远程仓库。git fetch+git merge(或git pull): 从远程仓库获取更新并合并到本地。
当多个开发者使用分布式版本控制在一个Git仓库上协作时,默认情况下会使用一个远程仓库(如GitHub、GitLab等)。开发者通常与本地仓库交互,并在准备好与其他开发者同步时,通过远程仓库来同步更改。此外,通常还会有一个Web界面,用于在浏览器中查看代码,并支持代码审查和拉取请求等额外工作流。
分支管理模型 🪵
到目前为止,你可能只使用过一个分支,这对于初学者和仅涉及少数开发者的小型项目来说完全可行。然而,当涉及更多开发者时,使用多个分支会更有意义,因为这样人们可以更独立地在特定功能或错误修复上工作。
以下是一个在实践中被广泛使用的简化分支管理模型。它并非唯一模型,但对于初学者来说足够简单,并且足够灵活以覆盖许多不同场景。
该模型包含主分支(main)、开发分支(development)和多个功能分支(feature)。实际开发工作在功能分支上进行,而不是在开发分支上。
以下是该工作流的关键步骤:
- 创建功能分支:当你想要开发一个新功能时,从开发分支创建一个新的功能分支。
- 在功能分支上工作:在功能分支上进行提交(每个圆圈代表一次提交)。多个开发者可以在同一个功能分支上协作。
- 合并到开发分支:当功能完成且代码质量令人满意时,将其合并回开发分支。
这种模型有一个非常重要的优势:只有已完成的功能才会成为开发分支的一部分。这确保了开发分支始终处于可工作的状态,其他开发者可以基于一个稳定的基础创建新的功能分支。
请注意,将工作分离到多个分支并不能防止合并冲突。这只是一种机制,让你可以快速尝试新想法,并且让开发者能够专注于自己的工作而不受他人过多干扰。合并冲突仍然可能发生,例如当你在功能分支上工作时,开发分支被其他人更新了,你需要将开发分支的更新合并到你的功能分支时。
拉取请求与代码审查 🔍
上一节我们介绍了分支模型,本节中我们来看看一个增强协作的关键实践:拉取请求(Pull Request, 有些平台也称为合并请求)。我之前展示的将功能分支合并到开发分支的过程是一个高度简化的模型。在现实中,这通常涉及多个步骤,而拉取请求为此提供了很好的工具支持。
拉取请求的理念是,你有一个负责质量的“守门员”(例如代码审查者)。开发者首先需要说服审查者:“亲爱的审查者,这是一个很棒的功能,我测试过了,代码质量很好,遵循了最佳实践,请将我的功能拉取到开发分支吧。”
以下是拉取请求和代码审查的典型工作流程:
该流程的参与方和活动如下:
- 开发者活动:提交源代码到功能分支;在功能完成后,请求将所有提交合并到开发分支,即打开拉取请求;根据审查反馈改进源代码(产生新的提交)。
- 审查者活动:基于可理解性、架构一致性、编码规范符合性、静态代码分析结果、测试用例通过情况等审查更改;发现问题并请求改进;最终批准拉取请求。
- 共享实体:拉取请求是双方沟通的核心平台。
这是一个非常好的工作流,可以防止“破窗理论”在代码中发生(即糟糕的代码像破窗一样在代码库中蔓延)。然而,它也可能减慢开发进程,特别是在开发者提交的拉取请求描述不清,或者审查者没有时间及时审查时。因此,需要在代码质量检查和开发速度之间取得平衡。
持续集成与回归测试 ⚙️
接下来,我想介绍一个帮助你集成软件、测试软件并基于测试发现潜在问题的开发工作流:持续集成(Continuous Integration, CI)。
持续集成与版本控制服务器中的源代码仓库相连。每当开发者推送提交时,持续集成服务器会收到通知,并执行以下操作:
- 检出代码。
- 编译代码。
- 根据预先定义的质量要求和测试用例进行测试。
然后,持续集成服务器会将状态通知开发者。状态可能是:“代码编译失败,请修复”;“代码未通过所有测试用例,请修复”;或者“一切正常,请继续”。所有这些检查都是自动完成的,不涉及人工操作(除了开发者查看错误信息)。
持续集成的架构通常是分布式的。多个开发者与版本控制服务器交互,触发持续集成构建。构建计划或构建过程可以根据项目(Java、Python、JavaScript等)进行定制。持续集成服务器可以将构建任务委托给多个构建代理以并行处理。
持续集成的核心概念是回归测试。回归测试意味着对你的版本控制中的每一次提交、每一次更改都进行反复测试,以确认所有之前能正常工作的功能仍然正常工作。这是一种极好的技术,因为它能防止我们引入导致现有功能失效的更改。
然而,在非常复杂的软件系统中,你可能拥有成千上万甚至数百万个测试用例。每次提交都执行所有测试可能非常耗时且成本高昂。为了解决这个问题,可以采用一些技术,例如:
- 测试选择:基于依赖分析,只测试受更改影响的子系统。
- 测试用例优先级排序:确保最重要的功能优先得到测试。
持续交付与持续部署 🚢
如果我们把持续集成再向前推进一步,就可以实现持续交付(Continuous Delivery)。持续交付基于持续集成,但不止步于构建和测试。它引入了发布经理的角色,负责获取持续集成服务器生成的构建产物(可执行文件),并将其上传到交付服务器(如应用商店、测试分发平台等),然后通知用户有新版本可供试用。
用户安装新版本并提供反馈,这些反馈可以被收集并存储在问题跟踪器中,通知相关开发者进行处理。持续交付自动化了流程中的所有步骤,使得通过一次点击就能将软件交付给最终用户进行试用,从而能够在短周期内产生有价值的软件。
持续交付可以进一步区分为持续部署(Continuous Deployment)。两者的关键区别在于:
- 持续交付:在自动化流水线(编译、单元测试、集成测试等)的末端,总是有一个由发布经理执行的手动步骤——按下按钮,将软件发布给实际用户。
- 持续部署:这个发布步骤也是自动化的。只要代码更改通过了所有自动化阶段,软件就会自动交付给最终用户。
许多公司采用持续部署来最小化从开发到用户手中的时间。为了降低风险,他们通常会实施:
- 部署后测试:在软件部署到生产环境后运行测试。
- 自动回滚:如果关键性能指标下降,则自动回滚到上一个版本。
- 金丝雀发布:首先将新版本发布给一小部分用户(例如特定地区),根据反馈和指标再逐步扩大发布范围。
通过这些实践,公司可以实现持续软件工程,即具备在短周期内开发代码、自动发布并从中学习的能力。学习来自用户的反馈(如应用商店评价)和隐式测量(如用户参与度、收入流变化等),这对于改进软件和商业模式至关重要。
容器化部署 🐳
在结束之前,让我们简要了解一下软件部署的演进。传统的软件应用直接安装在裸机硬件上。对于Web服务应用,这意味着公司需要自行购买和维护硬件,成本高且应用间隔离性差。
随后,部署实现了虚拟化,通过虚拟机来运行应用,这改善了隔离性并降低了硬件成本。但虚拟机仍然比较重,启动和关闭需要数分钟时间。
近十年来,一个非常强烈的趋势是转向使用Docker和Kubernetes等工具的容器化部署。容器更加轻量级,它在一个完全隔离的环境中运行应用,启动速度可达秒级。此外,容器允许你轻松扩展软件,因为你可以在所谓的集群中同时运行多个容器。
容器化的工作流程如下:
- 描述基础设施即代码:通过Dockerfile等镜像描述文件,定义应用及其所需的基础设施(如操作系统、Java运行时)。
- 构建镜像:镜像是一个包含应用及其依赖的模板,类似于面向对象中的“类”。
- 运行容器:从镜像可以轻松生成或运行多个容器实例,类似于“对象”。
- 集群管理:容器可以在Kubernetes集群中运行,集群管理器可以根据用户需求自动扩展容器数量。
最佳实践是为每个应用创建独立的容器和镜像。应用在持续集成过程中构建,并插入到镜像中。每次应用更改都会生成新的镜像版本。在运行时,可以生成这些容器,并在Kubernetes集群中根据需求自动伸缩。
总结 📚
本节课中我们一起学习了现代软件开发的核心工作流。
我们首先回顾了Git作为基础协作工具的使用。接着,探讨了分支管理模型,特别是使用主分支、开发分支和功能分支来组织协作,并介绍了通过拉取请求进行异步代码审查的详细流程。
然后,我们深入了解了持续集成如何通过自动化构建和测试来保证代码质量,并强调了回归测试的重要性。在此基础上,我们进一步学习了持续交付和持续部署,它们将自动化扩展到软件发布环节,甚至实现自动发布和监控反馈。
最后,我们简要了解了部署技术的演进,从传统部署到虚拟化,再到当前主流的轻量级、可快速伸缩的容器化部署(如Docker和Kubernetes)。
这些工具和实践共同构成了一个高效、可靠的现代软件开发工作流,能够帮助团队更好地协作、保证质量并快速响应用户需求。记住这句关于自动化手动任务的名言:“如果某件事很痛苦,那就更频繁地去做它,并把痛苦提前。”
下一步安排:从明天开始,你们将进行项目演示,有10分钟展示时间和简短的问答环节。导师将评估演示并告知项目工作的得分。在接下来的教程小组中,将没有新的家庭作业或练习。我们推荐了两篇关于持续交付和代码审查指南的文章供感兴趣的同学阅读。下周三(2月5日)我们将不再有新内容,而是尝试总结课程最重要的方面,并为第二次监督练习提供一些提示。

祝大家本周愉快,下周三见!
068:项目展示 🎮

在本节课中,我们将回顾并展示两门课程中令人印象深刻的学生项目。这些项目展示了同学们如何将编程知识应用于实践,创造出有趣且功能完整的游戏。
上一周,同学们在各自的辅导课中展示了他们的项目。我昨天花了很多时间,使用 git clone 命令克隆了你们的代码仓库,并玩了很多《吃豆人》游戏,这让我感到非常愉快。

现在,我想与大家分享两个让我印象尤为深刻的项目。前排坐着一些助教,如果他们的学生项目被选中展示,他们一定会非常兴奋。让我们一起来看看吧。
第一个项目:经典街机游戏 🕹️

以下是第一个项目的展示,它是一款经典的街机风格游戏。

首先,我想问一下,有多少人熟悉《吃豆人》游戏?好的,看来有一部分同学知道。如果你们能听到声音,一定会认出那经典的游戏音效。当然,我很乐意亲自试玩一下,让你们看看这个团队实现了什么。我认为它的主题非常棒,运行也十分流畅。
我不确定是否选择了难度,但我将从“简单”模式开始。这是我放置一些炸弹的过程。我玩过很多次这个游戏,所以知道接下来会发生什么。你们需要相信我,并观看一会儿,直到我消灭所有敌人。我认为“M”也处理得很好,敌人死亡时地板上会有血迹,效果真的很棒。
开发这个游戏的同学在教室里吗?很好。这应该是最后一个敌人了……好吧,这次操作不太理想。我们可以按 ESC 键来暂停游戏。很好。你能试着运行一下吗?当然,希望不会出问题。哦,它只是在这里运行,我猜。是的,这样不行吗?好吧。我没有被教导过。不过,声音听起来很酷。现在你们需要看着,因为我要消灭最后一个了。你们得相信我能干掉这个家伙。但为了展示这个游戏有多酷,让他来干掉我吧。是的,他又来了,第一次玩的时候我没想到会这样。真的很快。所以我认为这是一个非常酷的游戏。它有不同的难度,我还没试过“专家”模式,也许今晚可以试试。



第二个项目:地牢探险游戏 🗡️
接下来,我们来看第二个项目。首先,再次感谢第一个游戏的团队,它真的非常出色。

是的,这个游戏运行也非常流畅,角色的移动速度恰到好处,很酷。那么,我们来看第二个项目。开发团队也在吗?好的,下次吧。
我也将启动这个游戏。说实话,要展示它所有漂亮的功能对我来说有点难度。首先,你们可以看到一些骷髅。很酷的是它们还没有死,所以会复活。看,它们又出现了。哎呀。
这里有一个我非常喜欢的功能可以展示,那就是“星星”道具。然后我就可以直接跑过所有骷髅,现在它们都死了,很好。我还可以展示……嗯,我不太确定。是的,你们可以看到这边的“传送科学家”。他可以……是的,传送或者……你能传送两个吗?好的。我们还有第三种敌人类型,一个“召唤师”。哦哦哦哦哦。到这里来。好的。
希望我能找到一些……我想展示的好道具。但是,哎呀,我真的很讨厌你。好吧,在左边你们可以看到第三种敌人。说实话,这是最笨的敌人。但是,是的,它仍然是个敌人。我们需要消灭一些。来吧。好的,这里有一个硬币,说实话我需要这个硬币。哦哦哦,不。往这边走。好的,好多了。好的。好的。
我的时间不多了,我想我找不到它了。不过没关系,我录了一段视频,因为我想向你们展示这个项目中实现的最后一个很棒的道具或功能。让我快速点击一下,好的,就是这里,别看标题,因为会剧透。这是今天下午录的,非常棒,我们又玩了《炸弹人》游戏,请看。所以,你们可以……是的,已经能在那边看到了。那是一把很酷的剑。现在我装备了这把剑。我可以用我的武器杀死敌人,这超级棒。嗯,是的,有一个提示:当我拿到这把剑时,我无法移动,而且我因为举着这把剑而死了,因为我动不了。这就是第二个游戏,我真的很喜欢它,它也是一款相当不错的游戏。完美。
是的,也许你们可以问问你们的同学,或者你们已经尝试过其他一些项目了?你们绝对应该和同学们交流,请他们展示自己的项目,因为其中确实有一些非常出色的同步炸弹项目。谢谢,现在轮到你们来结束了。

总结 📝

本节课中我们一起回顾了两个优秀的学生项目:一个经典的街机游戏和一个地牢探险游戏。这两个项目都展示了流畅的游戏体验、丰富的功能以及同学们将编程知识转化为实际应用的出色能力。希望大家能从这些展示中获得灵感,并与同伴们交流学习,共同进步。
069:监督练习 2 说明与复习

在本节课中,我们将介绍第二次监督练习的具体安排,并快速回顾课程中最重要的知识点。本次监督练习将于下周举行,我们将详细说明其形式、规则和准备事项。
监督练习 2 信息
上一节我们介绍了课程的整体安排,本节中我们来看看第二次监督练习的具体信息。其流程将与第一次监督练习完全相同。
以下是监督练习 2 的基本安排:
- 开始时间为晚上 7 点。
- 请最晚于 6 点 30 分到达指定的演讲厅,以便有充足时间进行准备。
- 考试时间为 90 分钟,另有 10 分钟用于处理技术问题。
- 满分为 100 分。
在内容方面,课程涵盖的所有内容都可能成为考试的一部分。第一次监督练习侧重于前几周的内容,而本次练习将侧重于后续几周的内容。然而,前五到七周的内容是所有后续知识的基础,因此不能忽视,你应当能够编写课程中涉及的所有程序。
地点与设备要求
本次监督练习将在加兴校区举行。最晚于周一早上,你将收到一封包含具体演讲厅信息的邮件。你需要前往加兴校区,演讲厅可能与上次相同。
以下是关于参与和设备的关键要求:
- 你必须亲自到指定的演讲厅参加考试,不允许在世界其他地方或慕尼黑工业大学主校区参加。
- 我们将进行考勤检查,请确保参与并正确录入数据。
- 你需要使用自己的笔记本电脑,这是一场自带设备的计算机考试。
- 如果你没有合适的笔记本电脑,可以使用我们实验室的 Mac 电脑。请确保熟悉 Mac 的操作,如果你完全习惯使用 Windows,可能会感到困难。如需使用实验室电脑,请务必提前发邮件给我们。
考试规则与行为准则
本次监督练习为开卷考试,你可以使用任何资源,但必须独立完成。
以下是必须严格遵守的规则:
- 禁止与任何其他人交流,也禁止与任何人工智能工具交流。这是一个非常宽泛的术语,特别禁止使用 GitHub Copilot 以及任何基于 AI 的智能编程插件。ChatGPT 等工具同样不允许使用。
- 我们将使用 Artemis 平台的考试模式。请确保在 IntelliJ 或其他 IDE(如 VS Code)中禁用所有 AI 功能。
- 考试重点在于理解和解决问题,而非死记硬背。请确保能够应用在研讨会、辅导课和作业中涵盖的编程概念。
- 考试形式将从过去几周的大型项目作业,回归到课程初期你已习惯的、用于测试个人能力的小型练习题。
编程练习提交须知
监督练习将主要包含编程练习题。你需要在 IDE 中完成这些练习。
以下是提交代码的关键步骤和注意事项:
- 你需要将代码仓库克隆到 IntelliJ 等 IDE 中。
- 提交代码时,需要执行提交和推送操作,就像完成所有练习时一样。
- 你的代码必须在 Artemis 上能够编译。如果无法编译,我们将给予反馈,但仅限于告知代码是否编译成功,不会提供额外的测试反馈。
- 如果因你的原因导致编译失败,该练习将获得零分,且无法规避此规则。
- 请勿在最后一刻推送代码。如果这样做,你可能无法及时收到编译失败的反馈,从而可能失去本应获得的分数。
重要规则总结
为确保考试公平,以下规则至关重要:
独立完成与禁止 AI:
- 必须独立工作,禁止使用任何人工智能工具,特别是 OpenAI ChatGPT、GitHub Copilot 或其他任何 AI 系统。
- 对于 IntelliJ,有具体的禁用说明,请务必应用。
关闭无关应用:
- 请关闭所有聊天应用程序,如 WhatsApp、Instagram、Signal、Messages 等。
- 在 Mac 上,关闭窗口不一定意味着应用已完全退出。请确保完全退出应用程序,避免在后台运行,以免引起监考人员的怀疑。
允许与禁止的资源:
- 允许在线搜索、查阅幻灯片或类似练习题。
- 禁止在网上发布问题(如 Stack Overflow)。
- 禁止使用任何实时协作工具(如 Notion、Google Docs),因为这看起来很可疑。如需做笔记,请使用本地编辑器。
设备与环境要求:
- 只能使用一个显示器。
- 必须关闭所有其他设备,特别是智能手机、平板电脑和智能手表。
- 任何抄袭、交流等可疑行为都将被视为作弊。在德国,这被称为“Unterschleif”,后果严重,可能导致本课程不及格,并使你在第一次监督练习、演示和项目工作中的所有努力付诸东流。
考前准备与故障排除
使用个人电脑参加考试,你需要做好充分准备。
以下是考前检查清单:
- 设备准备: 确保电脑设置正确,安装好浏览器、JDK 17 和 IntelliJ。
- 提前测试: 在周末或周一早上尝试做一些练习,确保一切正常运行。如果出现错误信息,请确保能够修复。助教也可以提供帮助。
- 系统更新: 提前安装所有操作系统更新,或在系统中禁用自动更新,以免考试期间开始更新。
- 关闭无关程序: 关闭所有不需要的窗口和应用程序,并禁用所有通知。
- 网络与电源: 确保可以连接到 Artemis,并以 UniWLAN 作为备用网络。为电池充电,并带上充电器和可能的延长线。如果使用蓝牙鼠标,请确保其电量足以支撑 90 分钟。
如果遇到技术问题,请按以下步骤操作:
- 首先尝试自行解决,例如断开并重新连接 Wi-Fi,或重启电脑。
- 在 IntelliJ 中,你可以尝试使用“修复 IDE”或“使缓存无效/重启”功能来解决问题。
- 如果无法自行解决,请举手示意。监考人员会前来尝试帮助你,但请注意,他们并非专业的 IT 支持人员。
- 如果问题仍无法解决,我们备有应急预案:你可以更换考试地点(例如前往附近的 FMI 大楼),并且会根据你解决问题所花费的时间,给予相应的额外考试时间。
课程重点回顾
现在,让我们快速回顾一下课程中最重要的方面。你可以将此作为复习的起点,但请注意,周一监督练习的内容可能涵盖所有知识点。

本节课中我们一起学习了第二次监督练习的所有安排细节、重要规则以及考前准备指南。请务必认真对待这些规则,并做好充分准备。如果在考试安排方面仍有任何不清楚的问题,请在 Artemis 上提问,我们将尽快协助你。预祝大家在监督练习中取得好成绩。
070:课程回顾与核心概念总结

在本节课中,我们将快速回顾面向对象编程的核心概念。本次回顾旨在强调最重要的知识点,而非详细解释所有内容。我们假设你已经理解了这些概念,现在将重点指出关键部分。
对象与类
上一节我们介绍了对象与类的基本概念,本节中我们来看看它们的核心区别。
- 一个类是一个蓝图或模板。
- 一个对象是根据这个类创建的具体实例。
- 一个类可以创建多个对象。
类和对象关注三个不同方面:
- 身份:由名称和引用确保。
- 状态:通过其属性实现。
- 行为:通过其方法实现。
从建模到实现
我们讨论了如何将现实世界建模为UML类图(包含属性和方法),然后将其编码实现。
以下是创建对象、赋值属性和调用方法的基本代码结构:
// 定义类
class World {
String name;
void greet() {
System.out.println("Hello from " + name);
}
}
// 创建对象
World myWorld = new World();
// 赋值属性
myWorld.name = "Earth";
// 调用方法
myWorld.greet();
请注意,对象将其数据存储在内存中的独立位置。变量(如示例中的 myWorld)本质上是指向该内存位置的引用。
方法
方法实现了对象的动态行为。我们讨论了如何定义、调用方法,以及如何根据返回类型、名称和参数来构建方法。
- 方法签名由方法名和参数列表组成,返回类型不是签名的一部分。
- 方法重载:可以在同一个类中定义多个同名但参数不同的方法。这被称为重载。
在方法调用中,实际参数的值被复制到形式参数中供方法内部使用。方法调用结束后,其内部的一切都将不再可用。
为了在方法调用后保留更改,可以使用对象。此时复制的是对象的引用,通过引用可以在方法内部修改对象,产生副作用,这些修改在方法调用后依然有效。但对于基本类型的参数,则无法实现此效果。
类图与关系
我们讨论了更复杂的UML类图,其中包含:
- 继承:例如,支票账户和储蓄账户是银行账户的特定版本。
- 关联:通过属性实现的关系。
- 可见性:
+表示公有,#表示受保护,-表示私有。
面向对象特性
面向对象编程有几个关键特性,其中多态性可能是最复杂的主题。
- 继承:使用
extends关键字实现。protected成员对子类可见,super关键字用于调用超类的特定构造函数或方法。 - 抽象类与方法:允许我们重用代码,即使某个概念在现实中无法实例化或不存在。抽象方法规定所有子类必须实现它,这也被称为规范继承。
- 接口:与抽象类的主要区别在于,接口通常只指定方法而不实现(默认方法除外)。一个类可以实现多个接口,一个接口也可以扩展另一个接口。
- 多态性:
- 静态(编译时)多态:即重载。
- 动态(运行时)多态:即重写,涉及继承。编译器在编译时进行的绑定是静态绑定,而运行时系统根据
new操作符(右侧)决定调用子类还是超类的方法,这是动态绑定。
泛型
泛型允许我们以类型无关的方式定义功能。
例如,实现一个列表或集合时,可以使用泛型定义如何添加、删除元素,而无需关心放入的具体类型。类型只在创建该泛型数据类型的对象时才指定。
List<String> stringList = new ArrayList<>(); // 指定类型为String
List<Integer> integerList = new ArrayList<>(); // 指定类型为Integer
包装类(如 Integer)允许将基本类型用于泛型,因为泛型只适用于类类型。Java提供了自动装箱和拆箱功能,使得基本类型看起来可以直接使用。
Java集合框架
我们假设你能自然地使用集合框架中的 Set、List、Map 和 Queue。请确保你能将其用于自定义数据类型、包装类或 String。许多程序员都会使用这些默认的集合类型。
使用泛型的主要优势在于类型安全,它有助于在编译时尽早发现问题。
错误处理:异常
我们讨论了异常处理,区分了必须捕获的检查型异常和不需要捕获的运行时异常(如 NullPointerException)。
我们介绍了 try-catch 机制来处理特定错误(例如,处理用户输入错误导致的 NumberFormatException)。
你可以通过继承 Exception 类来定义自己的异常,并使用 throw 关键字抛出,以通知程序的其他部分发生了无法处理的非法情况。调用方可以通过 catch 捕获异常或继续抛出。
枚举
枚举是一种值域有限的数据类型。当你只想支持有限的几个特定值时(例如月份、状态),可以使用枚举。
枚举可以与 switch 语句(旧风格)或 switch 表达式(新风格)结合使用,优雅地处理多个 if-else 情况。请记住语句和表达式的区别,switch 语句和表达式是理解这一点的很好例子。
常用接口
我们讨论了一些Java编程语言中的常用接口:
- Iterable 和 Iterator:允许自定义数据类型使用增强型
for循环,这比传统for或while循环更简单、更安全(不易出现索引错误)。 - Comparator:用于排序的重要接口。只要数据类型实现了
Comparator接口,就可以使用Java通用且强大的排序功能。你可以让数据类型实现此接口,也可以即时定义排序规则,例如使用匿名内部类或Lambda表达式,从而实现简洁的排序代码,无需大量样板代码。
Lambda表达式与函数式编程
Lambda表达式是函数的引用。你可以定义一个包含输入参数列表、箭头和相应函数体的Lambda表达式。
(参数) -> { 函数体 }
Lambda表达式通常是简短的函数。通过Lambda表达式,我们可以进行函数式编程,甚至将函数存储在变量中。
函数式接口是只包含一个抽象方法的接口,它可以与Lambda表达式一起使用。这遵循了接口隔离原则。
流
流允许我们以函数式风格对集合数据类型进行操作。
操作流程通常如下:
- 从集合(如
List、Set)创建流。 - 进行零个或多个中间操作(如
map、filter)。 - 执行一个终端操作(如
count、sum、collect)。
流的优点在于,如果源中有大量元素(如100万个),你甚至可以并行化中间操作,使用并发编程以提高性能。
map:转换数据类型或导航对象。例如,从学生列表中收集所有学号。filter:根据条件过滤流中的元素。通常先进行过滤可以提高后续操作的效率。reduce:一种终端操作,将一系列输入元素组合成单个汇总结果(如求和、求积)。collect:另一种终端操作,使用收集器(如Collectors.toList())将流元素折叠回集合数据类型。
面向对象设计原则:SOLID

最后,我们介绍了面向对象设计的五个重要原则(SOLID):
- 单一职责原则:每个类应该只有一个职责。
- 开闭原则:对象应对扩展开放,对修改关闭。
- 里氏替换原则:关注继承,子类和超类应有强“是一个”的关系。
- 接口隔离原则:接口应尽量小,最好只包含一个方法(如函数式接口),不强迫实现类做它们不想做的事。
- 依赖倒置原则:关注松耦合,高层模块不应依赖低层模块。
遵循SOLID原则可以创建具有高代码质量的可重用代码。
本节课中我们一起回顾了面向对象编程的核心概念,包括类与对象、方法、继承、多态、泛型、异常处理、枚举、常用接口、Lambda表达式、流以及重要的SOLID设计原则。掌握这些概念是构建健壮、可维护Java应用程序的基础。
071:控制结构与数据类型回顾 🧠

在本节课中,我们将回顾编程中的两个核心概念:控制结构与数据类型。我们将系统地梳理条件语句、循环以及数组的使用方法,确保你能够理解其原理并灵活运用。
上一节我们回顾了面向对象编程,本节中我们来看看控制结构与数据类型。
我们讨论过最基本的控制结构类型,即 if 语句中的条件判断。根据代码中的逻辑或条件,你可以创建多个分支。
我们可以嵌套 if 语句,可以使用 else 语句,甚至可以使用 else if 语句。我们假设你已经非常熟悉如何实现这些结构。
我们讨论了迭代,并从 while 循环开始。while 循环会重复执行某段代码,直到条件变为假。这里始终考虑终止条件非常重要,否则你将陷入无限循环。你也可以在循环内部使用 break 语句,以便在满足特定条件时跳出循环。
我们讨论了 do-while 循环,它会至少执行一次循环体,然后再检查条件。
我们还讨论了 for 循环。其传统形式包含初始化、条件和修改三个部分,循环体内包含多条语句。一个基本示例如下:
for (int i = 0; i < dataType.length; i++) {
// 循环体语句
}
有时也会反过来,从最后一个元素开始,递减到0或1,此时会使用 i--。但上述模式是最典型的。你基本上可以在初始化、条件和修改部分做任何操作,但大多数时候我们会像这样使用。
以下是一个示例,其中的控制流也在控制流程图中进行了可视化。我们从 while 循环开始,然后在 while 循环内有一个 if-else 语句。我们展示了如何创建相应的控制流程图。
因此,请确保当我们展示一小段代码时,你能够创建这样的流程图;同样,当我们展示控制流程图时,你也能够根据它创建出代码。这是一种双向映射,我们希望你能掌握这两个方向的转换。
我们讨论了数组作为一种重要的数据结构,它提供了一种较为原始的处理方式。然而,它仍然遵循引用语义。数组也是集合数据类型的底层数据结构。
以下是关于数组的关键点:
- 我们可以存储许多相同类型的值。
- 数组总是定义一种类型,例如,你可以存储整数、字符串或自定义的
Student数据类型。 - 我们基于索引访问单个值。这里重要的是,索引从零开始计数。
- 因此,即使是第一个元素,其索引也是0;最后一个元素(例如第六个元素)的索引是5,或者说
size/length - 1。
array.length 本身不能作为索引访问。如果你想访问最后一个元素,请确保总是使用 array.length - 1。


本节课中我们一起学习了控制结构(包括条件语句和各类循环)以及数组这一基础数据类型。理解这些概念是编写结构化、高效程序的关键。请务必掌握从代码到流程图、以及从流程图到代码的双向转换能力。
072:算法与递归 🧠

在本节课中,我们将回顾算法与递归的核心概念。我们将探讨分治原则、几种经典算法(如搜索和排序)的实现,并深入理解递归的工作原理及其应用。
上一节我们介绍了课程的整体安排,本节中我们来看看算法与递归的具体内容。
我们学习了几种算法,特别是递归这个许多学生觉得不太容易的主题。由于我此前生病,大家观看了去年的视频,希望你们对这些主题有了良好的理解。
首先,许多算法都运用了分治原则。这不仅是编程中的重要原则,也是整个软件工程领域的重要思想。其核心是:当你遇到一个难以直接解决的大问题时,首先需要将其分解为更小的子问题,直到每个子问题都能被个人在短时间内轻松解决。然后,你需要将部分解决方案重新组合成完整的解决方案。这是一个极其重要的概念,以一种非常通用的方式描述了每个项目和算法。
问题的分解涉及分析。你需要先分析问题,否则无法进行分解。而将部分解决方案组合起来的过程也称为集成。这就是为什么我们有“持续集成”这样的术语,意味着我们希望从项目开始就持续进行这个过程。
以下是关于简单搜索算法的介绍。
我们有一个简单的搜索算法,用于在数组中查找元素。其逻辑是:通过一个 for 循环遍历数组,如果当前索引的元素与我们要查找的元素相同,则返回 true;如果循环结束仍未找到,则返回 false。

// 伪代码示例
boolean search(int[] array, int target) {
for (int element : array) {
if (element == target) {
return true;
}
}
return false;
}

上一节我们介绍了搜索,本节中我们来看看排序算法。

我们讨论了几种排序算法。首先是插入排序,我们有一个很好的可视化展示来理解其工作原理。其思想是:创建数组的一个副本,遍历它,找到要插入元素的位置,然后将该位置右侧的所有元素向右移动一位,最后将元素插入到特定位置。这是一个完整的方法。但我们也展示了使用多个方法的版本,以便代码复用和理解更小的子问题。这是分治的一个很好例子:我们将排序分解为遍历数组元素、定位位置、移动元素和插入元素等子问题,然后将所有这些方面整合到一个方法中。
我们还讨论了冒泡排序,这是最简单的排序算法之一,其核心是如果两个元素的顺序错误就交换它们。请注意,它的性能并不很好,但非常简单易懂。我们也展示了如何使用它。
当然,还有更快的排序算法,特别是归并排序和快速排序。
上一节我们介绍了排序算法,本节中我们深入探讨递归的世界。
在递归中,你基本上重复运行相同的模式,但会尝试简化输入,直到达到平凡输入(即可以直接解决的基本情况)。你可以通过直接递归方法调用或间接递归方法调用来实现。递归是分治的标准实现方式之一。
这里有一个阶乘函数的例子。n! 计算从1到n的所有整数的乘积。其基本定义是:0! = 1,这是一个平凡例子。然后,对于 n >= 1,n! = n * (n-1)!。这是数学定义。
// Java 代码实现
int factorial(int n) {
if (n == 0) { // 终止条件
return 1;
} else {
return n * factorial(n - 1); // 递归调用
}
}

在右侧,我们可视化了代码中实际发生的情况以及如何得到正确结果。这里的关键是终止条件。如果在递归方法调用中没有终止条件,你将陷入无限递归,导致程序崩溃。
当然,练习中的递归问题难度会被控制在可管理的范围内,不会像展示的雪花例子那样复杂,但也不会像阶乘函数那样简单。它可能是一个可以用10到15行代码实现的问题。递归函数的难点不在于代码长度,而在于理解其运行机制,处理好直接或间接的递归调用。
以下是另一个递归示例:最大公约数。
最大公约数也可以递归实现。这里的终止条件是:如果两个数相同,则返回其中一个。否则,我们取第一个数与两数之差的绝对值的最大公约数。这确保了问题规模不断减小,因为相减会使数字变小,最终会达到两数相同的平凡情况,从而结束递归。
// 伪代码示例
int gcd(int a, int b) {
if (a == b) {
return a;
} else {
return gcd(Math.min(a, b), Math.abs(a - b));
}
}

另一个例子是汉诺塔。这是一个非常适合作为监督练习的题目,既不太简单也不太复杂。你可以用大约15行代码实现它。汉诺塔是一个有趣的游戏,目标是将一堆盘子从一根柱子移动到另一根柱子,并保持原有顺序,规则是较小的盘子可以放在较大的盘子上,反之则不行。示例代码通过将问题分解为移动元素、释放元素等子问题,并使用直接递归方法调用和终止条件来实现。
我们还用递归算法实现了二分查找。这也是一个具有代表性的练习示例。在递归调用中,我们通过改变起始或结束索引来缩小搜索范围,本质上将一个已排序的数组分成两部分,然后在左半部分或右半部分继续搜索。这是一个非常重要的算法,因为它使搜索变得非常快。例如,对于10,000个元素,通过不断对分列表,可能只需10步左右就能找到目标。
我们还讨论了归并排序。它将列表分成部分列表,直到得到平凡情况(单个元素),然后在这些子列表已经排序的基础上,再将它们合并起来。归并排序是一种非常快的排序算法,虽然最快的通常是快速排序,但它们在性能上处于同一水平。
上一节我们看了递归的应用,本节中我们来看看递归的潜在问题及优化。

我们也以斐波那契数列为例,说明了其递归版本可能非常慢。因为递归版本通过 F(n-1) 和 F(n-2) 来缩减问题,但由于这里有两个递归调用,会形成一个巨大的依赖树。即使对于 n=15,计算也已经很慢,对于 n=50,即使是性能很强的计算机也可能无法快速计算,因为它具有指数级的时间复杂度。
这是一个很好的例子,说明了该算法的线性版本要好得多。我们只需在一个很小的数组中缓存值,然后就能更快地计算,具有线性时间复杂度,甚至可以计算 n=1000 的斐波那契数。
// 线性迭代版本示例
int fibonacci(int n) {
if (n <= 1) return n;
int prev = 0, curr = 1;
for (int i = 2; i <= n; i++) {
int next = prev + curr;
prev = curr;
curr = next;
}
return curr;
}
因此,关于递归算法,你应该学会的另一点是:如何使用循环将递归算法转化为线性迭代算法,以及如何将迭代算法转化为递归算法。这也可能成为下周一的练习内容。

本节课中我们一起学习了算法与递归的核心内容。我们回顾了分治原则,探讨了搜索和排序等基础算法,并深入理解了递归的定义、实现、应用场景(如阶乘、最大公约数、汉诺塔、二分查找、归并排序)以及其潜在的性能问题和优化方法(如斐波那契数列的迭代优化)。掌握这些概念对于编写高效、清晰的代码至关重要。
073:图形用户界面

在本节课中,我们将学习图形用户界面的基本概念,并以Java FX技术为例,介绍如何构建一个GUI应用。我们还将探讨可用性的重要性及其核心原则。
概述
本节课程将介绍图形用户界面的开发。我们将以Java FX框架为例进行说明,但目的并非要求你死记硬背Java FX的细节,而是通过一个现代框架的示例,使你具备触类旁通、学习其他框架的能力。课程内容涵盖可用性的基本概念、原型设计的重要性、基本的可用性启发式原则,以及Java FX应用的基本架构和组件。
可用性及其范畴
上一节我们介绍了图形用户界面的概念,本节中我们来看看与之密切相关的“可用性”。可用性衡量一个软件产品对于最终用户而言是否有效、高效和令人满意。它主要包含以下几个可衡量的范畴:
以下是可用性的几个核心范畴:
- 可学习性:用户能够多快理解并学会使用系统。
- 效率:用户学会系统后,能够以多快的速度完成任务。
- 可记忆性:用户在间隔一段时间(例如一年)后,再次使用系统时,能多好地记住其功能。
- 容错性:系统防止用户出错以及帮助用户从错误中恢复的能力。
- 满意度(用户体验):用户使用软件时的主观愉悦感。这是一个相当主观且难以精确测量的范畴。
请记住,可用性、用户界面设计和可用性工程正变得越来越重要。对于未来可能创业或开发面向最终用户产品的同学(尤其是兼具技术专长的经济专业学生)而言,这是非常重要的技能。
关于“易于使用”的误区
在讨论可用性时,我们特别提到了不同“说法”,尤其是广告中常出现的“废话”——声称“系统易于使用”。这种说法基本上没有意义。
作为一名计算机科学家、工程师或软件工程师,你应该知道我们需要可衡量的方法来判定某物是否真正可用。当然,如果你最终进入公司的市场部门,你可以使用这种说法。但如果你进入工程部门,请永远不要说“系统易于使用”。如今,这种说法甚至被称为“不可用性宣言”。许多公司声称其系统易于使用,但实际上很多用户在使用中遇到困难。
雅各布·尼尔森和诺曼集团(Nielsen Norman Group)提供了大量优秀的在线示例、指南和方法,教每个人如何进行得体的可用性评估。
原型设计的重要性
我们讨论了原型设计作为一个重要概念。其核心是尝试将我们的知识外化,并快速构建出可演示的东西。你可能听说过“最小可行产品”这个术语,这是当今创业和产品开发中最重要的概念之一。
其理念是快速构建出产品原型,然后与最终用户一起测试,以验证你的想法是否可行。这在可用性工程中也是可以应用的。
我想再次强调丹尼斯·博耶说过的话:永远不要不带原型就去开会。因此,要随时准备好可以向他人展示的东西。如果你只是解释软件如何工作,或者只展示代码,效果并不好。向他们展示一个可以在设备上运行的东西会更好。例如,在进行移动应用开发时,可以使用TestFlight这样的工具,让他人将原型下载到自己的设备上进行试用,并反馈是否可行。
基本的可用性启发式原则
我们探讨了由尼尔森和诺曼集团定义的基本可用性启发式原则。这些原则是评估和设计用户界面的实用指南。
以下是十项主要的可用性启发式原则:
- 系统状态可见性:系统应始终通过适当的反馈,在合理时间内让用户了解正在发生什么。
- 系统与现实世界的匹配:系统应使用用户熟悉的语言、词汇、概念,而不是面向系统的术语。遵循现实世界的惯例,使信息以自然和逻辑的顺序呈现。
- 用户控制与自由:用户经常会误操作,需要一个明确的“紧急出口”来离开非预期的状态,而无需经过冗长的对话。支持撤销和重做。
- 一致性与标准:用户不应怀疑不同的词语、情境或操作是否意味着同一件事。遵循平台和行业惯例。
- 错误预防:比好的错误信息更好的是精心设计,从根本上防止问题发生。
- 识别而非回忆通过使对象、操作和选项可见,最大限度地减少用户的记忆负担。用户不应记住从对话框的一部分到另一部分的信息。系统的使用说明应在需要时可见或易于检索。
- 使用的灵活性与效率:加速器——对新手用户不可见——通常可以加快专家用户的交互速度,使系统能够同时满足无经验和有经验的用户。允许用户定制频繁的操作。
- 美学与简约设计:对话框不应包含无关或很少需要的信息。对话框中的每个额外信息都会与相关单元竞争,并降低它们的相对可见性。
- 帮助用户识别、诊断和从错误中恢复:错误信息应使用通俗语言表达(无错误代码),精确指出问题,并建设性地提出解决方案。
- 帮助与文档:即使没有文档,系统也能使用是最好不过的,但可能仍需提供帮助和文档。任何此类信息都应易于搜索,专注于用户的任务,列出具体的操作步骤,并且篇幅不宜过大。
Java FX 基础架构
现在,让我们将目光转向Java FX的具体实现。一个Java FX应用程序在结构上分为多个层级。
一个Java FX应用的基本结构可以用以下关系表示:
Application -> Stage -> Scene -> Node
以下是各层级的说明:
- Stage:相当于一个窗口。一个应用可以包含多个Stage。
- Scene:放置在Stage中的内容容器。一个Stage可以包含多个Scene,但通常一个应用只有一个主Stage和一个主Scene。
- Node:Scene中的所有可视元素都称为Node。Node可以是简单的(如按钮、文本),也可以是复合的(如布局面板),复合节点可以包含其他节点,形成嵌套结构。
布局与控件
为了设计出合理的界面,布局管理至关重要。Java FX提供了多种布局面板(如VBox, HBox, BorderPane, GridPane)来帮助组织界面元素的位置和大小。
同时,我们介绍了各种控件,例如:
- 按钮 (
Button) - 列表 (
ListView) - 选择框 (
ChoiceBox) - 滚动条 (
ScrollBar) - 进度条 (
ProgressBar)
等等。
模型-视图-控制器架构
在构建GUI应用时,模型-视图-控制器 架构风格能帮助你构建易于维护的系统。该模式将应用分为三个核心部分:
- 模型:代表应用的数据和业务逻辑。
- 视图:代表用户界面,用于显示数据(模型)并接收用户输入。
- 控制器:作为模型和视图之间的中介,处理用户输入,更新模型,并刷新视图。
这种分离使得视图和模型组件可以复用,控制器负责协调两者之间的通信。
图形与样式
除了标准控件,Java FX还支持绘制基本形状,如矩形 (Rectangle)、圆形 (Circle) 等,这为创建自定义图形元素提供了可能。
在样式方面,你可以为应用定义特定的配色方案、字体、按钮视觉效果等。为了实现样式的统一和高效管理,Java FX借鉴了万维网的技术,使用 CSS 来定义和应用样式。这使得视觉设计可以与逻辑代码分离,便于维护和更换主题。
总结

本节课中,我们一起学习了图形用户界面开发的核心知识。我们从可用性的概念和重要性出发,探讨了其衡量范畴,并指出了“易于使用”这一模糊说法的不足。我们强调了原型设计在快速验证想法中的关键作用。接着,我们详细介绍了十项基本的可用性启发式原则,作为评估和设计界面的实用指南。最后,我们以Java FX为例,讲解了GUI应用的基本架构(Stage-Scene-Node)、常用布局与控件、以及利用模型-视图-控制器模式构建可维护应用的方法,并简要介绍了图形绘制和通过CSS进行样式美化的技术。掌握这些概念,将为你使用任何GUI框架打下坚实的基础。
074:并发编程

在本节课中,我们将要学习并发编程的核心概念。我们将探讨多线程的重要性、其基本实现方式,以及现代编程中更高级的并发模型,如基于任务的并发、异步编程和发布-订阅模式。
上一节我们介绍了其他编程范式,本节中我们来看看并发编程。
在并发编程部分,我们简要阐述了多线程为何在当今如此重要。现代应用对用户响应要求很高,用户不愿长时间等待。特别是当同一台计算机拥有多个CPU或多个核心时,我们可以并行处理任务。线程是实现这一目标的基本概念。
然而,线程也较难理解。我们展示了一个简单的示例,说明如何在Java中使用Runnable接口实现线程。我们有一个Lambda表达式,可以在启动的线程中运行。但这是非常底层的操作,你应该考虑更现代、更高级的概念。
特别是基于任务的并发,它涉及线程池。你定义一个由任务队列组成的线程池。
然后你插入任务,线程池会自动生成线程,并根据你希望池管理的最大线程数,确保它们彼此不冲突。
我们讨论了异步编程,它在分布式系统中变得越来越重要。
程序不应等待某些操作完成,而应继续执行。当响应异步返回时,程序再处理它。
通过这种方式,我们可以减少等待时间。处理时间保持不变,但等待时间可以显著减少。
我们还讨论了响应式宣言,其中响应式、消息驱动、弹性和韧性的系统变得越来越重要,这对于将软件扩展到数百万最终用户也至关重要。
我们讨论了发布-订阅模型,该模型通过某种通道机制(如中间的队列)将数据的生产者与消费者解耦。当生产者有新数据时,就将其发布到队列中。
然后通道或队列被填充。
随后,订阅者或消费者可以在数据可用时立即对其进行处理。
存在不同的机制。可能是所有订阅者处理所有数据。
但也可能是存在多个并行订阅者,它们各自处理独立的数据。

本节课中,我们一起学习了并发编程的基础。我们了解了线程的基本概念及其在Java中的简单实现。更重要的是,我们探讨了更高级的并发模型,包括基于任务的线程池、异步编程以减少等待时间,以及发布-订阅模式如何解耦系统组件以实现更好的扩展性和响应性。这些是现代软件开发中处理并发和构建高性能、可扩展应用的关键思想。
075:编程之外的内容 📚

在本节课中,我们将回顾并总结编程课程之外的一系列重要概念。这些主题涵盖了软件工程领域的多个方面,从编程语言特性到现代开发实践,旨在为你未来的学习打下坚实基础。
编译型与解释型语言
上一节我们回顾了编程核心,本节中我们来看看编程语言的执行方式。我们讨论了编译型语言和解释型语言之间的区别。
例如,Java 语言兼具编译和解释的特性。它首先被编译成一种称为中间代码(或字节码)的形式,然后这个字节码在运行时由 Java 虚拟机(JVM)进行解释执行。理解这一点对于掌握编程语言的工作原理至关重要。
核心概念示例:
// Java 源代码被编译为 .class 字节码文件
// JVM 解释执行字节码
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
领域特定语言
我们介绍了领域特定语言,它们与 Java 这类通用目的语言不同。DSL 专为特定领域设计,例如用于标记的 HTML、XML、Markdown 和 JSON。
以下是几种常见的领域特定语言示例:
- HTML:用于构建网页结构。
- XML:用于数据存储和传输。
- Markdown:用于编写格式简单的文档。
- JSON:用于轻量级数据交换。
构建工具:Gradle
我们探讨了 Gradle 作为领域特定语言的一个例子,它用于自动化软件构建和依赖管理。这在每个编程练习中都是基础部分。
我们展示了如何添加新的 Gradle 依赖,例如:
dependencies {
implementation 'com.google.guava:guava:31.1-jre'
}
你无需成为专家,但应大致理解 Gradle 的用途以及如何添加外部库。
编译器与抽象语法树
我们简要了解了编译器如何使用语法分析来生成抽象语法树。AST 对于代码分析至关重要,它能判断代码是否能编译、支持重构,并在 IDE 中提供改进建议。
AST 是编译器中使用的一种代码表示形式。你应该能够将代码转换为 AST,反之亦然。这可能是监督练习的一部分。
正则表达式
我们讨论了正则表达式及其在搜索替换、输入验证等方面的不同用例。虽然看起来复杂,但基础相当简单。
我们有一些特定术语,例如 \w 匹配单词字符。我们可以指定某些字符以及它们在待验证文本中应出现的次数。这在编程中有许多不同的用例,并且有很多优秀的在线工具可以验证正则表达式是否正确。
我们展示了一个在 Java 中使用的例子。例如,验证一个用户名,该用户名总是由两个小写字母、两个数字(用 \d 表示)和另外三个小写字母组成。
核心概念公式:
用户名模式 = [a-z]{2}\d{2}[a-z]{3}
基于模型的软件工程
我们了解了基于模型的软件工程及其不同的活动,从问题陈述开始,进行需求获取、分析、系统设计、对象设计,以迭代和潜在敏捷的方式实现测试。这样你不仅可以交付一个软件系统,还可以交付多个版本。
定义式与经验式过程
我们区分了定义式过程和经验式过程。Scrum 是一种经验式过程,我们专注于制定项目的大致计划,但总是在迭代开始时详细说明特定需求,以产生对最终用户有价值的、潜在可交付的产品增量。这是一个非常重要的点,我们希望专注于实现能为最终用户创造价值的东西。
客户端-服务器应用与 REST 架构
我们还讨论了基于 REST 架构设计客户端-服务器应用程序,我们使用请求-响应协议,并且通常具有自动的序列化和反序列化。这样,客户端(例如手机)可以使用一种面向对象的编程语言,而 Web 服务可能使用另一种面向对象的编程语言,但它们仍然彼此兼容。
代码审查
我们了解了代码审查,并讨论了它们的优点和挑战。请注意,这不仅仅是关于提高代码质量,还关乎沟通、知识传递。从某种意义上说,你们在教程小组中进行的结对编程也是一种代码审查技术。
你们有一个坐在电脑前打字的“驾驶员”,和一个同时审查进展、提出建议、评论解决方案好坏的“观察员”。这样,代码审查在编写代码时就被整合到技术中,同时也改善了知识传递,并希望两位参与的开发者之间也能相互学习。
静态与动态代码分析
我们讨论了静态和动态代码分析的区别。静态分析意味着手动或使用基于抽象语法树的自动工具查看代码。动态分析就是测试,这是我们所做的事情。我们在 Artemis 上测试你们周一在 C 语言实现中产生的软件,并在这里区分黑盒测试和白盒测试。
版本控制:Git
我们了解了 Git。你应该非常熟悉克隆 Git 仓库、将代码提交到本地仓库,然后将代码推送到项目的远程仓库。你也需要从其他开发者那里拉取更改,并且可能必须合并冲突。解决冲突是一项宝贵的技能。
持续集成与交付
我们简要了解了持续集成,即使用计算机系统自动编译、构建和测试你的软件,以便快速反馈,让你知道想要引入的更改是否正确工作。
你可以更进一步,进行持续交付。你配置一个流水线,不仅用于构建和测试,还在人工干预下交付软件。持续交付意味着有一个手动步骤(可能只是一个按钮点击),然后软件就被部署了。而更进一步,持续部署意味着一切都是自动部署的。在这里,你真的需要非常好的测试,甚至可能需要在部署后测试失败时回滚更改。
容器化与 Docker
我们讨论了容器化过程,例如使用 Docker 来以代码形式描述运行软件所需的基础设施,即所谓的镜像。从这个镜像描述中,我们可以构建一个采用分层方法的镜像。例如,你定义一个基础镜像,比如使用 JDK,然后安装你需要的额外依赖,接着打包你自己的软件。这就像一个模板,我们可以从这个模板创建多个容器并运行它们。我们的软件就集成在容器的实例中。
课程总结与备考建议
以上是关于课程回顾的内容,一些最后的思考。这是一个摘要,如果你想高效地复习最重要的方面,可以使用它。但请记住,如果某个主题没有在摘要幻灯片中提到,它仍然可能是考试或监督练习的一部分。因此,这并非一个详尽无遗的概述,只是你应该关注的最重要方面。
我们建议你组建学习小组,一起学习和学习更有趣。例如,你可以使用 Artemis 上的练习模式一起编程。你可以向彼此解释概念,看看是否能以易于理解的方式进行。可以重复我们提供给的所有编程练习(Artemis 上大约有 70 到 80 个),使用练习模式即可。你可以从我们再次给出的模板从零开始,然后重新做练习。这可能有助于你双重检查是否理解了概念。请注意,在周一你也需要相对快速,你不能花 20 分钟思考如何解决一个练习,你必须快速解决。我们会非常仔细地估算你在 90 分钟内可以编写多少代码。
你也可以使用练习模式重复 Artemis 上的所有测验,因为周一也会有一些测验问题。如果你这样做,你就做好了充分准备。再次提示考试模式学生指南,确保你理解考试模式并能正确使用。

本节课中我们一起回顾了软件工程中编程之外的广泛主题,包括语言特性、开发工具、工程方法和团队协作实践。掌握这些知识将帮助你在未来的编程和软件工程道路上走得更远。祝你在接下来的练习和考试中好运,也祝你未来职业生涯一切顺利!
076:函数式编程基础

概述
在本节课中,我们将学习迭代器、集合以及如何为自定义数据类型实现 for-each 循环。我们还将探讨更现代的控制流方式——switch 表达式,并介绍匿名内部类和 Lambda 表达式。这些函数式编程概念在现代编程中非常重要,能帮助我们更高效地编写代码。课程结束时,你将能够实现自己的 Lambda 表达式,这对于使用流(Streams)编写更简洁的代码至关重要。
迭代器与集合
有多种方式可以实现对象的有序集合。例如,字符串是字符的有序集合;列表和数组是预定义类型对象的有序集合;甚至列表本身也是以非常通用的方式实现的,因此可以与任何类型一起使用。我们还见过集合(Set)和映射(Map)。你基本上也可以创建自己的数据类型。
无论它们是有序还是无序,大多数时候你希望以特定顺序访问单个元素。为了实现这一点,重要的是知道当前元素之后是否还有下一个元素、能否跳转到下一个元素,以及在处理完当前元素后能否将其移除。
由于这是一个反复出现的实现和想法,开发者为此设计了一个接口,称为 Iterable。它是一个泛型接口,可以应用于任何类型。其核心思想是,除了可以定义相应的构造函数外,它还有三个方法:hasNext(检查是否还有下一个元素)、next(导航到下一个元素)和 remove(移除当前选中的元素)。这基本上就是 Iterable 的思想,它将是 for-each 循环的基础。
该接口还定义了 iterator 方法,该方法返回一个 Iterator 对象。这个对象允许你遍历可迭代数据结构中的所有元素。这里需要非常小心:Iterable 是集合的一个属性,一个集合可以是可迭代的;而 Iterator 是一个独立的对象,负责遍历集合。它们基本上是协同工作的。
我们可以看一个简单的例子 IterableString,它基于 Character 类(char 的包装类)实现 Iterable。我们通过提供一个可以初始化字符串的构造函数,然后返回一个迭代器来实现。这个迭代器是我们自己的实现 IterableStringIterator。
IterableStringIterator 实现了 Iterator 接口。它实现了我们刚才提到的三个方法:hasNext、next 和 remove。这里我们不实现 remove,因为字符串是不可变的。如果你有自己的数据结构(如列表或数组),则可以实现它。现在为了保持简单,我们专注于 hasNext 和 next。
hasNext 基本上告诉你是否已经遍历完字符串。我们存储一个当前位置 position,初始设置为 0(从字符串开头开始遍历)。如果当前位置仍然小于数据结构(本例中是字符串)的长度,则返回 true;否则,如果我们在末尾,则返回 false,因为没有下一个元素了。
next 方法有一些安全机制。如果 position 等于字符串长度,那么就没有下一个元素了,因为调用者可能在没有事先验证 hasNext 的情况下直接调用 next,这时我们会抛出 NoSuchElementException。如果不是这种情况(即 position 小于长度),我们返回字符串在当前位置的字符,然后将 position 加一。我们有一个指针指向当前元素,返回这个元素,然后将指针加一。如果我们第二次调用这个方法,就可以返回第二个元素,从而遍历整个数据结构。
使用方法如下:你可以创建一个 IterableString 和它的迭代器,然后使用 while 循环:当迭代器有下一个元素时,导航到下一个元素并打印它。注意这里使用了一个新的关键字 var。var 表示任何类型,它使用类型推断,基本上就像我们不关心编译时类型是什么,只需使用这里得到的类型。它允许你为更有经验的人编写更短的代码(主要是为了在幻灯片上更好地展示)。有时如果类型从变量名中完全清楚(例如 s),使用 var 是好的,但在这个例子中可能不是一个好主意,因为 s 并没有真正告诉你它是什么数据类型。关于是否应该使用 var,开发者之间一直存在很大的讨论。
如果你遵循了 Iterable 和 Iterator 接口,你可以写得更简短:你可以使用 for-each 循环。这意味着所有可以使用 for-each 循环的集合数据类型都实现了 Iterator 和 Iterable 接口。这是 Java 中允许你使用增强型 for 循环的主要机制。增强型 for 循环由 Iterable 和 Iterator 支持。这很重要,意味着你可以创建自己的数据结构,并支持其他开发者使用 for-each 循环。
Java 集合框架
我们已经使用过带有泛型元素类型 E 的集合。我们见过 Set、List、Queue 和 Map(它使用 Set 作为键,Collection 作为值)。
实际上,Collection 接口扩展了 Iterable。接口可以扩展其他接口。Iterable 使用 Iterator,所以它有一个对 Iterator 的引用。Iterator 也是一个接口。这就是我们建模的方式:Collection 扩展 Iterable 接口,List 也是一个接口,它扩展 Collection。之后,ArrayList 会实现 List 接口。通过实现 List 接口,它也符合 Collection 接口和 Iterable 接口,并且也可以使用 Iterator。无论你有一个 LinkedList、ArrayList、HashSet 还是其他 Set,这都是基本思想。因此,Iterable 和 Iterator 允许我们使用增强型 for 循环,并对遍历某些元素的含义有一个非常简洁的定义。虽然我们可以只为 Collection 接口实现这个,但将其分解为多个接口是有意义的,这样其他人也可以在不遵循 Collection 的情况下重用 Iterable 和 Iterator。
Collections 工具类
当涉及到集合时,还有一些额外的代码可以使用,这些代码存储在 Collections 类中(注意末尾的 s,这是一个类,不是接口)。Collections 有一些静态方法(这并不完全是面向对象的,它们更像是函数),允许我们以特定方式操作集合。
我们可以检查两个集合是否不相交(即没有重叠),可以计算集合中特定元素的频率,可以反转列表的顺序(这在操作数据时有时很有用),可以用新值替换列表中的所有旧值(这也可以用字符串完成),可以根据 Comparable 接口的 compareTo 方法对列表进行排序(注意这里的泛型类型中有 Comparable),也可以根据给定的 Comparator 对列表进行排序。
这里再次看到我们分解了事物。我们有 Iterable 和 Iterator,以及 Comparable(对象可以是可比较的)和 Comparator。这是编程语言中常见的模式。Comparable 意味着你需要指定如何比较某物,例如,如果你的数据类型是一个具有 x 和 y 的 Point,可以实现 Comparable,然后在 Point 内部定义如何比较两个对象。你可以说先比较 x 值,取较小的那个;如果 x 值相同,再比较 y 值。这样你就在数据类型内部定义了如何比较对象。但这只能使用和指定一次。
因此,你也有机会定义 Comparator。Comparator 可以为特定目的指定。例如,在这个特定例子中,你可能想按 x 排序,但在另一个例子中,你可能想按 y 排序。你可以根据需要定义按对象的哪个属性或特征对列表进行排序。
这些是非常方便的方法,可以用来实现某些功能。你可以在 playground 类的静态 main 方法中看到一些示例代码:我们使用 Arrays.asList 辅助方法创建一个列表(这也是静态的),基于该列表创建一个 Set,然后可以反转它、排序它、使用 disjoint、frequency 和 replaceAll 方法。灰色注释显示了输出结果。请查看并确保你理解这些。这些是以后可以使用的辅助函数。有了这些方法,你可以快速创建程序,而不需要考虑如何反转、如何排序。这就是使用接口进行面向对象编程的力量。
Comparator 接口
我们看到这里使用了 Comparator 接口。Comparator 定义了一个必须实现的 compare 方法。这个 compare 方法的逻辑与 Comparable 接口中的 compareTo 方法类似,它们使用相同的逻辑。它们基本上接收两个相同类型的对象并返回一个整数。如果第一个元素被认为小于第二个元素,整数应小于 0(通常是 -1);如果两者相等(无论你如何定义,这取决于你),应返回 0;如果第一个元素大于第二个元素,应大于 0。这是该方法的基本语义,然后你可以对元素进行排序。
例如,如果你想对整数排序,可以这样做:使用 Arrays.asList 静态辅助方法创建一个列表,然后使用 Collections.sort 方法(同样是静态的),将对象列表传入,并创建一个新的 Comparator。这个新的 Comparator 是内联定义的,它是一个所谓的匿名内部类。这允许我们就地定义排序方式,不需要稍后创建对象,只需就地完成。这是一种基于接口实现类并立即使用一次的新方式。这个 Comparator 必须实现一个 compare 方法,对于整数,我们可以简单地使用 i2 - i1。
这是一个匿名内部类,一个新概念:我们定义一个类并立即实例化一次以使用它,然后它基本上就消失了。如果你只想实现一个接口一次并就地使用,可以这样用,不需要将其提取到 IntelliJ 项目中的单独文件并实现它然后引用它,你可以直接就地完成。因此,匿名内部类可以使你的代码更简洁,并允许你同时声明和实例化一个类。这些类没有真正的名称,所以被称为匿名内部类。你像使用局部类一样使用它们,所以只能在定义它们的文件中访问,不能在其他地方使用。在 Java 中,它们实际上经常被使用。
现在,如果你的接口只声明一个方法,它被称为所谓的函数式接口。这引导我们走向今天的函数式编程概念。那么你就不再需要定义它了,可以使用 Lambda 表达式(我们今天稍后会学习)。如果你想写得更短更简洁,可以像那样写,避免所有样板代码。虽然这对初学者来说更难阅读,但它更简洁。程序员喜欢简洁简短的代码,避免所有样板代码,因为代码越少,需要维护的东西就越少。如果你将其与上一张幻灯片比较,你会看到最重要的信息——将两个整数映射到 i2 - i1——以你能想到的最简洁的方式写在这里。我们不需要说我们想实现一个接口并重写一个方法(如果只有一个),我们可以写得更短,但稍后再详细讨论。
在这个特定例子中,另一个可以使用的替代方法是 Comparator.reverseOrder(),因为有一个预定义的 Comparator 用于降序排序。这对于整数来说开箱即用,甚至不需要定义,因为你不是第一个想对整数排序的人。许多人之前已经做过,这就是为什么他们在 Comparator 接口中提出了这个静态方法 reverseOrder,可以用来对列表进行降序排序,结果将与之前相同。
匿名内部类示例
这是匿名内部类的另一个例子。我们有 Runnable 接口,它定义了任何可以运行的东西(通常是任务)。同样,像 Iterable 和 Comparable 一样,它们总是使用这些名称以便于理解。所以 Runnable 是任何可以运行的东西,你可以通过实现 Runnable 接口来定义自己的 Runnable。再次,我们在这里使用匿名内部类语句:new Runnable()。如果你在代码中这样做,在 IntelliJ 中,即使没有 AI,它也会为你完成所有需要的工作,并自动补全。然后你甚至可以创建相应的方法。如果你点击它,可以说“实现方法”,然后一切就完成了。工具支持真的很棒。然后你可以运行代码,在这个例子中,它将调用这个内联定义的方法。你仍然可以在其他地方这样做,例如“嘿,我自己的 Runnable 类实现了 Runnable”,但大多数时候这没有必要。
匿名内部类也可以实现多个方法。如果你定义了一个 Executable 接口,你想执行某些东西或异步执行某些东西,这也是可能的。同样,IntelliJ 会帮助你重写接口中定义的所有方法。在这种情况下,我们需要实现两个方法。此外,你还可以定义辅助方法。匿名内部类就像一个真正的类:你可以定义属性,甚至可以声明更多方法。唯一的问题是,这些方法可能无法在外部访问,因为这里我们使用的是接口类型。所以,如果我定义额外的方法,例如 private String s = "test" 和另一个方法 public getS(),这个代码是不可访问的。我们可以运行它,因为我们这里有接口,但我们不能调用 getS,即使它是 public 的,因为我们没有这个类型。它是一个匿名类型,我们无法真正获取它。所以对于匿名内部类来说,这并没有太大意义。当然,我们可以在内部使用它,可以说 getS() 并做任何我们想做的事,但在外部我们无法真正访问属性和方法,因为类型是匿名的且不可用,并且也不可能将此对象强制转换为运行时类型。所以,你必须区分编译时类型和运行时类型。
练习
我们请你打开 Artemis 和 IntelliJ,完成这个小练习 W8E2。这是一个相对较短的练习,你有大约 10 分钟的时间来完成。想法是创建一个包含一些字符串的列表(可以使用你最喜欢的汽车品牌或任何其他字符串,这取决于你)。你可以使用 Arrays.asList 方法来完成。
然后,我们希望您使用匿名内部类按长度对列表进行排序,最长的汽车品牌应该排在前面。回顾一下我们在幻灯片 11 中是如何做的:Collections.sort 与 new Comparator。按两件事排序:首先是字符串长度(在这个例子中,应该是 Volkswagen),如果两个字符串长度相同,则按字母顺序排序。
有两个提示给你:你可以在 compare 方法中使用 s2.length() - s1.length();你可以使用 String.compareTo 方法进行字母顺序比较,不需要自己实现。请查看这个练习并完成它,如果需要帮助,导师会四处走动。大约 10 分钟后,我们将继续研讨会。
示例解决方案
这是一个示例解决方案。我们打算使用匿名内部类。注意我们说 Collections.sort,传入一个列表(在理论中你可以随意命名),然后我们有一个基于 String 的 new Comparator(这很重要,这不是一个整数列表,而是一个字符串列表)。
compare 方法的工作原理如下:我们检查两者是否具有相同的长度,然后使用 s1.compareTo(s2) 进行字母顺序排序。如果不是这种情况(即长度不同),我们返回 s2.length() - s1.length(),类似于之前整数例子中的 i2 - i1。所以,字母顺序比较是第二顺序比较,只有在长度相同时才进行,否则我们从最长到最短对字符串进行排序。
理论上,这也可以用 Lambda 表达式完成,并且不一定需要使用 Collections.sort,列表本身也有一个 sort 方法。所以这也是一个有效的解决方案。当然,这个例子侧重于匿名内部类和 Collections.sort,但只是让你知道我们可以用不同的方式写这个。然而,这里的 Lambda 表达式有点复杂,不是简单的一行代码,因为我们有两种排序方式。

总结
在本节课中,我们一起学习了迭代器(Iterator)和可迭代接口(Iterable)如何为集合和自定义数据类型提供遍历能力,并支持增强型 for 循环。我们探讨了 Collections 工具类的实用静态方法,以及 Comparable 和 Comparator 接口在排序中的应用。通过匿名内部类,我们学会了如何就地、简洁地实现接口。这些知识是理解后续 Lambda 表达式和函数式编程的重要基础。

浙公网安备 33010602011771号