计算机编程的完全初学者指南-全-

计算机编程的完全初学者指南(全)

原文:zh.annas-archive.org/md5/2d57c42cbb832b0aca774fe1913fc0e9

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到编程的美丽世界。编程是一种艺术形式,你将使用你的想象力和创造力来创造事物。如果你知道如何编程,你的可能性将是无限的。

你可以用它来创建一个有趣的游戏。或者,也许你想要自动化你生活中的某些事情。也许你想要成为一名专业程序员,然后你将使用你的技能与别人一起工作,为许多人创造长期使用的解决方案。

编程技能也是越来越多职业所需要的。你的工作头衔可能不会是软件开发者。相反,编程将成为你可以使用的工具。

我已经教授编程超过 30 年了。我教过各个年龄段的人,从初学者到资深专业开发者。在这段时间里,我看到了一些模式,尤其是在初学者中。

在我意识到是什么让许多我的初学者学生难以掌握编程之前,花了一些时间。问题是他们必须同时学习两件事。首先,他们需要理解编程的非常概念。有如此多的概念和词汇,既有新的,也有一些他们已经知道的,但含义略有不同。其次,他们还需要学习一种编程语言。同时吸收所有这些内容对于许多人来说会变得压倒性。对于一些人来说,这会太多以至于他们永远放弃了编程。

本书的主旨是专注于你需要学习的两件事之一。本书不会专注于任何一种语言,而是教你成为程序员所需了解和理解的那些概念。在你阅读完这本书之后,你可以学习任何你想要的编程语言,当你这样做的时候,你可以专注于只学习语言,因为其余的你已经知道了。

通过这本书,我还想将事物置于上下文中,所以会有一些历史,有些部分将会相当技术性。我相信,如果你要学习某样东西,你不能只是停留在表面。你需要深入其中,看看事物是如何运作的。

是的,我就是那个拆解我的遥控车来查看它是如何工作的那个孩子。

祝你在学习这门神奇的艺术时一切顺利。

本书是在 Talking Heads 的音乐中写成的,我建议你在阅读时也听一听。

本书面向哪些读者?

本书是为那些对计算机编程感兴趣并想了解更多关于这个主题的人,或者即将学习他们的第一门编程语言并希望对这个主题有一个坚实的入门介绍的人所写的。无论你的目标是创建小型爱好应用,还是希望为你的大学编程课程做好充分准备,都无关紧要。

本书涵盖哪些内容?

第一章, 计算机程序简介,将向您介绍计算机程序的工作原理以及它们如何与计算机硬件交互。

第二章, 编程语言简介,将向您介绍编程语言的演变、它们之间的关系、介绍不同类型的语言,并为您提供一些基本编程概念的基本理解。

第三章, 应用程序类型,探讨了软件以多种形式存在,是为了解决广泛的问题而创建的。本章将向您介绍一些基本的应用程序类型,并为您提供它们如何工作的理解。

第四章, 软件项目和我们的代码组织方式,介绍了在编写超出最简单水平的程序时,我们需要将代码组织成几个代码文件。在本章中,我们将探讨如何高效地完成这项工作。我们还将看到如何将他人的代码整合到我们的软件项目中。

第五章, 序列 – 计算机程序的基本构建块,概述了计算机程序在最基本层面上是通过将语句按正确顺序放置来构建的。我们将看到这是如何完成的,以及当我们的程序运行时,计算机将如何执行这些语句序列。

第六章, 数据处理 – 变量,探讨了所有计算机程序都将对数据进行操作并在某种程度上修改它。在本章中,我们将了解在编程时我们将处理的数据类型以及我们可以对这些数据执行的操作类型。

第七章, 程序控制结构,讨论了在编写程序时,我们需要如何控制代码执行路径。为了帮助我们,我们有不同类型的控制结构可以使用来实现这一点。

第八章, 理解函数,探讨了函数作为编程中的基本概念,它允许我们将代码打包成可重用的单元。在本章中,我们将了解函数是什么以及它们如何被使用。

第九章, 当事情出错时 – 错误和异常,提醒我们事情并不总是按照计划进行。我们编写的代码可能包含错误,或者我们可能得到无法处理的数据。在本章中,我们将了解如何处理这两种情况。

第十章编程范式,探讨了我们应该如何编写和结构化我们的代码,以便尽可能高效地编写程序。这些被称为范式,我们将在本章中查看最突出的几个。

第十一章编程工具和方法论,探讨了程序员在开发软件时如何使用不同的工具。我们将在本章中查看其中的一些,并考虑程序员团队如何高效协作。

第十二章代码质量,探讨了代码质量的许多方面。我们如何编写运行速度快或高效使用计算机资源的程序?我们如何编写其他程序员可以轻松阅读和理解的代码?这些是我们将在本章中涵盖的一些内容。

附录 A – 如何将伪代码转换为真实代码,涵盖了如何将伪代码转换为不同的编程语言。

附录 B - 词典包含了本书中使用的所有独特或有技术含义的单词。

为了充分利用本书

如果您正在使用这本书的数字版,我们建议您亲自输入代码。这样做可以帮助您避免与代码复制/粘贴相关的任何潜在错误。

下载示例代码文件

您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择支持选项卡。

  3. 点击代码下载

  4. 搜索框中输入书籍名称,并遵循屏幕上的说明。

文件下载完成后,请确保使用最新版本的以下软件解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781839216862_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“我们知道,当这个函数除以两个值时,如果y被赋予0的值,我们可能会得到一个异常。”

代码块设置如下:

if current_time > sunset_time {
    turn_on_light()
}

任何命令行输入或输出都应如下所示:

SyntaxError: invalid syntax in line 1 column 2
1:2 syntax error: unexpected apple at end of statement
Compilation error (line 1, col 2): Identifier expected
error: unknown: Identifier directly after number (1:2)

提示或重要注意事项

应该看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过 customercare@packtpub.com 给我们发送邮件。

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们非常感谢您能向我们报告。请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,我们非常感谢您能提供位置地址或网站名称。请通过 copyright@packt.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下评论。一旦您阅读并使用过这本书,为何不在购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解 Packt 的更多信息,请访问packt.com

第一部分:计算机程序与计算机编程简介

本节为您提供了对计算机程序与编程语言之间关系的理解,以及如何理解代码在计算机上执行的过程。

本节包含以下章节:

  • 第一章, 计算机程序简介

  • 第二章, 编程语言简介

  • 第三章, 应用程序类型

  • 第四章, 软件项目与我们的代码组织

第一章:第一章:计算机程序简介

编程是编写计算机可以遵循以完成任务的艺术和科学。这项任务可以是玩游戏、执行计算或浏览网页,例如。然而,在我们学习如何编写程序之前,我们应该了解什么是程序,以及计算机如何理解并执行我们给出的指令。在本章中,我们将更详细地研究这一点,以及计算机的基本知识、工作原理及其历史。

即使对这些主题有基本的理解,也会在以后我们讨论编写程序的不同方面时有所帮助,因为那时我们可以将计算机如何处理我们编写的代码联系起来。

在本章中,我们将涵盖以下主题:

  • 对计算机的历史和起源的视角

  • 编程背后原始想法的背景知识

  • 理解什么是计算机程序

  • 学习计算机程序的工作原理

  • 理解什么是机器码

计算机简史

人类一直都在制造工具和进行创新,以使生活更加舒适,并使我们能够更快、更有效地做更多的事情。我们需要回顾几百年前,才能看到第一个试图建造类似计算机的工具的尝试。然而,在我们这样做之前,我们可能想要定义什么是计算机。维基百科提供了以下定义:

计算机是一种可以通过计算机编程自动执行一系列算术或逻辑运算的机器。

因此,计算机是一种可编程的机器,它执行算术或逻辑运算。让我们回顾一下过去的几项发明,使用这个定义来确定哪些可以被认为是计算机。

首先,我们可以排除雅克德罗机,这是一种在 19 世纪初发明的自动化织布机。这些织布机可以使用穿孔卡片编程,但它们生产的是丝绸,当然,这不是算术或逻辑运算的结果。使用穿孔卡片进行可编程性的想法在计算机时代一直存在,但这些织布机不是计算机。

如果我们再往回追溯时间,我们会发现像算盘这样的工具帮助我们得到算术运算的结果;然而,它们是不可编程的。

在 1770 年代,瑞士钟表匠皮埃尔·雅克·德罗兹(Pierre Jaquet-Droz)创造了一些他称之为自动机的机械娃娃。这些娃娃能够读取指令,因此可以被认为是可编程的,但它们不执行算术或逻辑运算。相反,他创造了一个能演奏音乐的娃娃,一个能画画的娃娃,以及一个能写字的娃娃(它们被称为音乐家、绘图家和作家):

图 1.1:Jaquet-Droz 自动机(Rama 拍摄,维基媒体共享;Cc-by-sa-2.0-fr)

图 1.1:Jaquet-Droz 自动机(Rama 拍摄,维基媒体共享;Cc-by-sa-2.0-fr)

为了看到类似计算机的东西,我们需要看看查尔斯·巴贝奇的发明。他通过为一种称为差分机的机器的想法,以及后来称为分析机的更高级版本,提出了可编程计算机的概念。在这两个中,分析机尤其具有开创性,因为它可以编程,这意味着它可以用来解决不同的问题。他在 19 世纪上半叶展示了他的工作,即使这些机器从未完成,我们也可以同意巴贝奇是可编程计算机基本概念背后的一个非常重要的人物。

在 20 世纪上半叶,我们见证了某些模拟计算机,但直到第二次世界大战以及随后的年份,我们才看到了真正的数字计算机的诞生。模拟计算机和数字计算机之间的区别在于,前者是一种使用模拟输入(如电压、温度或压力)的机械机器。相比之下,数字计算机使用的是可以用数字表示的输入。

许多人认为,由 J. Presper Eckert 和 John Mauchly 于 1943 年至 1946 年之间构建的电子数值积分计算机ENIAC)是第一台数字计算机,因为它是最先完成且完全功能性的:

图 1.2:Betty Jean Jennings 和 Fran Bilas,两位程序员,操作 ENIAC 的主控制面板 – 美国陆军照片(公有领域 [PD])

图 1.2:Betty Jean Jennings 和 Fran Bilas,两位程序员,操作 ENIAC 的主控制面板 – 美国陆军照片(公有领域 [PD])

从那时起,我们见证了直到今天为止的巨大发展。然而,尽管我们的现代计算机可以做得更多,并且速度比这些早期发明快得多,但它们运作的基本原理仍然相同。

编程简史

一个可编程计算机需要被编程。因此,当然,编程的历史与计算机的演变是紧密相连的。

1833 年,查尔斯·巴贝奇遇到了诗人拜伦勋爵的女儿 Ada Lovelace。她对巴贝奇的可编程机器计划印象深刻,并产生了浓厚的兴趣,他们的合作就此开始。除了其他事情外,她还写了一些笔记,概述了她关于如何编程巴贝加分析机的想法。我们可以称她为编程的发明者,即使我们不得不等待超过 100 年,直到我们有了能够实现她想法的机器。她今天的地位在 2017 年 James Essinger 的历史额外文章中得到了总结:

现在,Ada 完全正确地被视为女性科学成就的象征,一个思想英雄,以及计算机早期历史中最早的先知之一。

在她的笔记中,洛芙莱斯做了几件引人注目的事情。首先,她写了一个算法,说明了如何通过分析引擎计算伯努利数,这是一个在数论中经常使用的有理数序列。这个算法被许多人认为是第一个计算机程序。其次,她概述了这些机器未来的用途,在她的设想中,她看到它们可以用来绘制图片和创作音乐。事实上,当我们最终能够建造计算机时,它们的编程方式受到了她的想法的极大影响:

图 1.3:17 岁的阿达·洛芙莱斯(Joan Baum 的肖像;PD-Art)

图 1.3:17 岁的阿达·洛芙莱斯(Joan Baum 的肖像;PD-Art)

第一台数字计算机使用机器码进行编程——这是计算机唯一能理解的东西。在本章的后面部分,我们将更多地讨论机器码,并探讨它是什么。你会发现,它只是一串数字的序列。

1949 年,约翰·莫奇利提出了一个名为 Brief Code 的东西,后来被更名为 Short Code。Short Code 可以被认为是第一种高级编程语言之一。高级编程语言是我们用更易于人类理解的方式向计算机发出指令的方法,这比机器码要好。然后,Short Code 程序被翻译成机器码,而计算机执行的就是这种代码。

1954 年,在 IBM,由约翰·巴科斯发明了 Fortran 语言,这可以被认为是第一个广泛使用的高级通用编程语言。实际上,Fortran 仍然在使用中。

20 世纪 50 年代见证了其他一些语言的诞生,这些语言也得以幸存,例如 Lisp 和 COBOL。从那时起,我们已经有了超过 2,300 种新的编程语言。在下一章中,我们将探讨编程语言的演变及其相互关系,但也会探讨为什么人们不断发明新的编程语言。

程序是什么?

从某种意义上说,计算机是愚蠢的,因为没有程序,它什么也不能做。计算机程序是一组计算机可以执行的指令,作为程序员,我们的任务是使用一种或多种编程语言编写这些程序。

我们运行的大多数应用程序,例如网页浏览器、文字处理软件或邮件客户端,都不能直接与计算机硬件通信。它们需要一个介于其间的层来处理这个问题。这个层被称为 操作系统。Windows 和 Linux 是两个著名的操作系统例子。操作系统的主要目的是处理我们使用的应用程序与硬件(如处理器、内存、硬盘、键盘和打印机)之间的直接通信。为了能够执行这种通信,操作系统需要特殊程序,这些程序被设计用来与特定设备通信。这些程序被称为 设备驱动程序。下面是一个简化的示意图,展示了这个过程:

图 1.4:系统架构

图 1.4:系统架构

程序员将编写用户应用程序、操作系统和设备驱动程序,但用户应用程序类别无疑是最常见的。我们编写的程序将与系统内核通信,系统内核是操作系统的核心。操作系统将负责与底层硬件的直接通信。这种结构的优点是我们只需要与操作系统通信,因此我们不需要考虑用户有什么样的鼠标或者如何将一行文本发送到特定的打印机型号。操作系统将与鼠标和打印机的设备驱动程序通信,驱动程序将确切知道如何与该设备通信。

如果我们编写一个程序,并且这个程序想要将文本 Hi there computer! 打印到屏幕上,那么这个请求将会发送到操作系统。操作系统会将这个请求传递给连接到这台计算机的显示器设备驱动程序,而这个驱动程序将知道如何将这个信息发送到显示器:

图 1.5:指令从应用程序流向硬件的方式

虽然输入的文本不会神奇地出现在屏幕上。它将通过计算机内部的几个层。1945 年,匈牙利裔美国数学家和物理学家约翰·冯·诺伊曼(John Von Neumann)和其他人创建了一份名为 First Draft of a Report to the EDVAC 的文件。在这 101 页的文件中,提出了使用存储程序概念的第一台计算机的逻辑设计。此外,还描述了电子数字计算机的设计。这种设计今天被称为冯·诺伊曼架构,它定义了可以用来构建计算机的四个不同组件。这些组件如下:

  • 一个包含算术逻辑单元和用于处理单元的寄存器的处理单元。

  • 一个包含指令寄存器和程序计数器的控制单元。这些用于执行程序。

  • 存储数据和指令的内存。这种内存是易失性的,这意味着当电源关闭或计算机重启时,其内容将被清除。

  • 外部大容量存储。这是程序和数据的长期存储,计算机重启后也可以保留。

  • 输入和输出机制。今天,这通常是键盘、鼠标和显示器。

除了外部大容量存储外,所有这些组件在键盘输入文本并在屏幕上显示时都会发挥作用。

如前所述,计算机只能理解一种东西,那就是机器码。机器码是一组数值,计算机将其解释为不同的指令。计算机只处理二进制形式的数字,也称为基数 2,这就是为什么我们经常听说计算机只理解 0 和 1。

要理解不同的基数,让我们考虑它们有多少位数字。在我们的日常生活中,我们使用十进制系统,称为基数 10,因为我们有 10 个数字,从 0 到 9(我们假设这是因为我们开始用手指计数)。在基 2 的二进制系统中,我们只有两个数字,0 和 1。在基 16 的十六进制系统中,我们有 16 个数字。因为我们只有 0 到 9 的数字,所以在十六进制系统中,我们必须使用一些字母来表示 10 到 15 之间的值。这些字母是 A 到 F。我们这样做是因为我们必须理解数字和数之间的区别:一个数字是一个代表值的单个符号,而一个数是一系列一个或多个数字。所以,例如,我们可以谈论数字 7,但不能谈论数字 12(因为它是由两个数字组成的数)。在十六进制系统中,我们需要表示 16 个值;因此,我们需要 16 个数字。由于我们的十进制系统中只有 10 个数字,我们需要使用其他东西。在这种情况下,就是字母 A 到 F。

请参考以下表格,比较十进制、二进制和十六进制数字:

图片

表 1.1:十进制、二进制和十六进制格式中的数字 1-15

计算机程序是如何工作的?

我们作为人类所创造的所有工具都帮助我们减轻了体力劳动。最终,我们达到了一个可以发明一个帮助我们进行脑力劳动的工具:计算机。

在设计此类机器时,发明者发现它必须执行四个不同的任务。计算机需要接受数据作为输入,存储这些数据,处理数据,然后输出结果。

这四个任务是我们所构建的所有计算机的共同点。让我们更详细地看看这些任务:

  1. 我们可以通过多种方式向计算机提供输入,例如使用键盘、鼠标、语音命令和触摸屏。

  2. 输入数据被发送到计算机的存储:内部内存。

  3. CPU(中央处理单元)从存储中检索数据并对其进行操作。

  4. 这些操作的结果随后被发送回存储在内存中,然后再作为输出发送出去。

正如不同的设备可以用来向计算机发送输入一样,输出也可以以不同的形式存在,我们可以使用各种设备来展示结果,例如将文本打印到打印机上,通过扬声器播放音乐,或将视频显示在屏幕上。一台计算机的输出甚至可以被输入到另一台计算机中:

图 1.6:计算机的四个任务

图 1.6:计算机的四个任务

所有四个步骤——输入、存储、处理和输出——都处理数据。让我们来探讨一下这些数据是什么以及它采取的形式。

理解二进制系统

为什么计算机只处理零和一呢?为什么它们不能直接处理文本或图像,例如?答案是,构建可以表示两种状态的电路相当容易。如果你有一根电线,你可以选择通过它通电或不通电。电流的流动或不流动可以代表几件事情,例如开或关、真或假、或零或一。现在让我们把这些两种状态暂时看作零和一,其中零代表没有电流流动,一表示确实有电流流动。如果我们能够处理这两种状态,我们就可以添加更多的电线,通过这样做,我们就有更多的零和一。

但我们究竟能用这些零和一做什么呢?嗯,答案是我们可以几乎做任何事情。例如,仅使用零和一,我们可以通过二进制数制来表示任何整数。让我们演示一下这是如何工作的。

要理解二进制数,我们必须首先看看十进制数制。在十进制系统中,我们使用 10 个数字,从 0 到 9。当我们计数时,我们通过这些数字直到我们达到 9。现在我们用完了数字,所以我们从零开始,并在前面加一个一,形成数字 10。然后,我们继续计数直到我们达到 19,然后我们再次这样做;从零开始,并在零的前面增加一个一,所以我们得到 20。

另一种思考不同数制的方法是考虑一个位置所代表的值。让我们来看一个例子。数字 212 有两个位置上有数字 2,但它们的位置使它们具有不同的值。如果我们从右边开始向左移动,我们可以这样说:我们取第一个数字,2,乘以 1。然后,我们取第二个数字,1,乘以 10。最后,我们取最后一个数字,2,乘以 100。如果我们从右向左移动,每一步的价值是前一步的 10 倍。看看以下表格中所示的计算:

表 1.2:二进制数的位值

表 1.2:十进制数的位值

当使用二进制系统时,我们做的是同样的事情,但只使用数字 0 和 1。我们从 0 开始计数,然后是 1。在这个点上,我们用完了数字,所以我们从 0 重新开始,在其前面加一个 1。

二进制计数看起来是这样的:

0, 1, 10, 11, 100, 101, 110, 111, 1000, 1001, 1010, 1011, 1100, 1101, 1110, 1111,等等

当涉及到二进制数中每个位置所具有的值时,它的工作方式与十进制数一样。然而,每个位置的值不是乘以 10,而是乘以 2。我们将第一个数字乘以 1,第二个数字乘以 2,第三个数字乘以 4,依此类推。为了使事情更简单,我们可以说,在特定位置上的 1 表示该位置的数字应该是最终值的一部分,而 0 表示它不应该。看看这个表,以了解二进制数 11010100:

表 1.3:解释二进制数 11010100

表 1.3:解释二进制数 11010100

在这里,我们有 128、64、16 和 4 位置上的 1,因此现在我们可以将它们相加(我们可以忽略带有 0 的位置,因为将 0 加到某物上不会产生任何影响),以得到二进制数 11010100 的十进制形式,即 212。

如果我们要将一个十进制数,比如 27,转换为二进制,我们首先思考我们可以通过位置值序列走多远:1, 2, 4, 8, 16,等等。这些中哪一个是我们能找到的最大的小于或等于 27 的值?答案是 16,所以这个二进制数中的第一个 1 将在这个位置。在 16 之前的所有位置上,我们可以插入 0:

图 1.7:找到小于或等于 27 的第一个位置

图 1.7:找到小于或等于 27 的第一个位置

然后我们从 27 中减去 16,得到 11,并使用这个值重复这个过程。小于或等于 11 的最大值是 8:

图 1.8:找到小于或等于 8 的第一个位置

图 1.8:找到小于或等于 8 的第一个位置

我们从 11 中减去 8,得到 3。下一个值,4,大于 3,所以我们在这个位置插入一个 0:

图 1.9:我们遇到一个大于 3 的位置,所以我们插入一个 0

图 1.9:我们遇到一个大于 3 的位置,所以我们插入一个 0

由于我们还没有插入一个 1,我们保持 3 的值,并尝试找到一个适合它的值。下一个值,2,小于或等于 3,所以我们在这里插入一个 1,然后从 3 中减去 2,得到 1:

图 1.10:2 小于 3,所以我们在这个位置插入一个 1

图 1.10:2 小于 3,所以我们在这个位置插入一个 1

我们重复这个过程,直到达到 0:

图 1.11:当我们到达末尾时,我们已经到达了完整的二进制数

图 1.11:当我们到达末尾时,我们已经到达了完整的二进制数

我们现在知道 27 在二进制中将是 11011。我们可以忽略前面的零。

当我们只有一个二进制位时,我们称它为,如果我们将它们分成 8 位的组,我们称它们为字节。一个字节可以存储介于 0 到 255 之间的值。这是因为所有位置上的 1(11111111)将是 128 + 64 + 32 + 16 + 8 + 4 + 2 + 1 = 255。

通过使用大量的 0 和 1,计算机可以以二进制形式表示任何数字,如果它可以表示数字,它也可以表示其他事物,例如文本。

理解 ASCII 和 Unicode

如果你给英语字母表的每个字母一个数值,你可以用数字来表示文本。例如,我们可以说 A=1,B=2,以此类推。计算机不使用这些值来表示字母,而是可以使用称为 ASCII 表的东西(发音为 as-key)或另一种称为 Unicode 的表示。我们不需要完全理解它们是如何工作的;我们只需要理解的是,一个数字可以表示每个字符。然后,我们可以使用 ASCII 表或 Unicode 来查找这个数字。

ASCII 表使用一个字节来表示不同的字符。表从不可打印的字符开始。最终,它达到了英语字母表中的字符。所以,例如,A 是 65,B 是 66,以此类推。255 个字符并不能带我们走得太远,因为我们周围有大量的不同字母表,我们还想表示其他符号。这就是为什么我们还有 Unicode。它对单个字符的映射不像 ASCII 表那样直接,但我们现在需要知道的是,有了它,我们可以使用数字来表示字符。

注意

不可打印的字符是那些不用于视觉表示的符号;例如,当我们需要一种表示制表符或换行符的方法时,或者如果将文本打印到打印机上,我们希望打印机继续到下一页。

表示其他形式的数据

我们已经学会了如何用二进制表示文本,但除了文本和数字之外的东西怎么办?图像、视频和声音怎么办?

图像由像素组成,三个值,RGB,代表每个像素。这些值告诉计算机一个像素有多少红色、绿色和蓝色:

图 1.12:三个值代表一个像素,指示它有多少红色、绿色和蓝色

图 1.12:三个值代表一个像素,指示它有多少红色、绿色和蓝色

视频不过是许多图像的组合,所以每一帧都是一个图像;因此,它可以以相同的方式表示。

波形可以表示声音。每个峰值和谷值都可以是一个数字:

图片

图 1.13:音频以波形表示

既然我们已经知道了计算机如何表示数据,我们就必须找出它是如何处理数据的。为了理解这一点,我们必须首先深入了解一个被称为布尔代数的数学领域。

布尔代数

乔治·布尔(George Boole),生活在 1815 年至 1864 年之间,是一位自学成才的英国数学家,也是布尔逻辑的发明者,这是所有计算机工作的基础。

布尔逻辑,有时也称为布尔代数,是一种仅使用两个值:true(真)和false(假)进行工作的数学形式。它还定义了三种我们可以对这些两个值执行的操作:AND(与)、OR(或)和NOT(非)。

NOT 是这些操作中最简单的,因为它所做的只是切换值,所以非真即为假,非假即为真。例如,如果我这么说,“今天在下雨”,这个陈述可以是真也可以是假。如果下雨,它就是真的;如果不下雨,它就是假的。如果我改为说,“今天 NOT 在下雨”,那么这个陈述将在不下雨时为真,在下雨时为假。

AND 取两个可以是真或假的陈述,并将它们评估为一个单一值。如果两个输入值都为真,结果将为真,在其他所有情况下都为假。如果我这么说,“今天在下雨,AND 我有一把蓝色雨伞”,这个陈述只有在两部分都为真时才是真的,也就是说,如果实际上在下雨,我的雨伞实际上是蓝色的。然而,如果下雨但我的雨伞是粉色的,我所说的将是假的,尽管其中一半是真的。

OR 操作作用于两个部分,就像 AND 一样,但现在只需要其中一个为真即可使语句为真。如果我这么说,“今天我会去海滩 OR 我会去镇上,”那么无论我是否去海滩或镇上,这个语句都会是真的,而且如果我同时做到了这两件事,这个语句也是真的。

我们可以通过一种称为真值表的东西来说明这三个操作是如何工作的。真值表是一种描述输入的 truefalse 值如何被一个操作转换的方法。如果只有一个输入值,我们通常将其称为 P;如果有两个,我们将其称为 PQ。结果显示在最后一列。

如果 P 是输入值,NOT 的真值表将如下所示:

表 1.4:NOT 的真值表

表 1.4:NOT 的真值表

对于 AND,如果 PQ 是输入值,其真值表如下:

表 1.5:AND 的真值表

表 1.5:AND 的真值表

对于 OR,真值表如下所示:

表 1.6:OR 的真值表

表 1.6:OR 的真值表

如您所见,AND 操作只有在两个部分都为真时才能为真,而 OR 只有在两个部分都为假时才能为假。

当美国数学家和电气工程师克劳德·香农在 1937 年发表了其硕士学位论文《继电器和开关电路的符号分析》时,他基于布尔的思想。从香农的思想中,布尔逻辑进入了我们的现代计算机,因为借助布尔定义的简单操作,我们可以将任何可以处于两种状态之一(真或假,开或关,或者,在二进制数字的情况下,一或零)的值转换。

我们可以用晶体管来完成这些操作。我们不需要深入了解晶体管的工作原理——只需知道它可以用来表示真/假、开/关或 0/1 就足够了。然后我们可以将几个晶体管连接成不同的配置来完成ANDORNOT等操作。这些组合被称为,因此我们将有一组被称为AND 门的晶体管,一个被称为OR 门的,一个被称为NOT 门的。然后这些门可以进一步连接来构建可以进行加、减、乘、除的电路。我们现在已经构建了一台可以表示数字和这些基本操作的机器。我们只使用数字做到了这一点,并且所有这些数字都将以二进制形式存在,因此我们有一个只使用零和一的机器:计算机。

机器码 – 计算机的原生语言

现在我们有了可以执行一些基本数字操作的电路,并且我们有以数字形式的数据,我们可以开始编写将执行数据操作的程序。我们可以用计算机唯一理解的东西来做这件事:机器码。因为数字可以代表一切,我们给计算机的指令将是——是的,没错——只是数字。

每种处理器类型都有其特定的指令集。这就是为什么为 Mac 编写的程序不能在运行 Windows 的 PC 上运行,例如。因此,指令可以是机器码。机器码有几种操作,称为操作码。操作可以是ANDORADD等。每个操作码都有一个唯一的数字。例如,AND可能有一个操作码值为 1,而OR可能有一个操作码值为 9。

处理器还将拥有几个寄存器。寄存器是一个小区域,有时被称为数据存储位置,处理器可以在其中存储它当前正在处理的数据。在执行操作之前,我们需要将作为操作输入的数据从内存移动到这些寄存器之一。操作的结果,即输出,也存储在寄存器中。实际上,事情比这要复杂得多,但在这里我们不需要深入所有细节。

我们现在可以回忆起所有计算机都常见的四种操作:输入、存储、处理和输出。我们首先进行一些输入,然后它将进入计算机的内存进行存储。处理器然后将它从其寄存器中检索出来,并对其进行操作,这就是处理部分。当我们得到操作的结果时,它将返回到内存中,以便稍后可以将其发送到输出

编写这些指令的一种方法是用一种叫做汇编的东西。这是一种编写程序的方式,我们使用操作码的三字母缩写,并为寄存器命名。通过这样做,我们将更容易阅读和理解我们给出的指令。然后我们可以使用一个程序将汇编代码翻译成机器码。

汇编语言是我们遇到的第一种编程语言。汇编语言可能看起来像这样:

mov     eax, 14
mov     ebx, 10
add     eax, ebx

在这里,我们将值 14 移动到名为eax的一个寄存器中,然后我们将值 10 移动到另一个名为ebx的寄存器中。然后我们对这两个寄存器的内容执行add操作。结果将写回到一个寄存器中;也许eax会再次被用于这个操作。

如果移动操作的操作码为 136,而加法操作的操作码为 1,我们可以使用这些值以及寄存器的数值表示,将所有这些信息都只用数字格式表示。而且,正如我们所知,所有数字都可以用二进制形式表示,即用 0 和 1。

现在我们有了所有这些,我们需要查看一些机器码。

示例机器码

记住,我们给出的指令将取决于我们使用的处理器和操作系统。以下是一个示例,展示了在 Linux 操作系统上运行的一个程序将文本Hello, World!打印到屏幕上的机器码:

b8    21 0a 00 00   
a3    0c 10 00 06   
b8    6f 72 6c 64   
a3    08 10 00 06   
b8    6f 2c 20 57   
a3    04 10 00 06   
b8    48 65 6c 6c   
a3    00 10 00 06   
b9    00 10 00 06   
ba    10 00 00 00   
bb    01 00 00 00   
b8    04 00 00 00   
cd    80            
b8    01 00 00 00   
cd    80            

在查看这个程序时,如果我们想的话,可以用二进制或十进制格式来写这些数字。然而,为了便于阅读,我们通常使用十六进制数字,因为这样我们可以使用更少的数字。例如,十进制中的 15(两位数字)在二进制中是 1111(四位数字),但在十六进制中只有 F(一位数字)。这仅仅更紧凑——这就是我们这样做的原因。

如果你对机器码程序不理解,不要担心。它并不打算对人类可读;然而,对于计算机来说,这一切都是有意义的。

在机器码中编写代码容易出错。一个数字放错位置可能会导致成功与灾难的区别。因此,下一步自然的步骤就是创建一些对人类来说更易于阅读和编写的代码,然后计算机可以将这些代码翻译成机器码。我们之前提到的汇编语言就是这样一种措施。

这里是相同的程序,用汇编语言编写的:

section     .text
global      _start                               
_start:                                         
    mov     edx,len                             
    mov     ecx,msg                             
    mov     ebx,1                               
    mov     eax,4                               
    int     0x80                                
    mov     eax,1                               
    int     0x80                                
section     .data
msg     db  'Hello, world!',0xa                 
len     equ $ - msg                             

如您所见,这仍然不是那么容易理解。在下一章中,我们将学习如何使用更接近人类语言的编程语言来编写相同的程序。

摘要

在本章中,我们回顾了历史,探讨了计算机的发展。计算机的历史是一个庞大的主题,但我们触及了一些重要事件,这些事件使得计算机成为了我们今天所知道的神奇机器。

要使计算机变得有用,它需要程序,而要能够编写程序,我们需要编程语言。我们了解到编程的发展与计算机的发展密切相关,即使阿达·洛芙莱斯女士设法在第一台计算机建造大约 100 年前就写出了被认为是第一个计算机程序。

在了解了计算机的历史之后,我们接着关注了计算机程序是什么,以及计算机如何使用程序中给出的指令来完成程序员的意图。为了做到这一点,我们考察了计算机可以处理的最小数据单元,即比特,它们是数字的二进制表示中的零和一。我们了解到乔治·布尔及其布尔逻辑的理念是计算机如何转换数据的核心。布尔的理念将在后面的章节中再次出现,因为我们在编写程序时也会用到它们。

最后,我们更深入地研究了计算机的语言,即机器代码。我们看到了我们阅读和理解它的难度,正因为如此,我们将更加欣赏下一章,在那里我们将学习我们可以做些什么来避免直接与这种困难的代码打交道。

第二章:第二章: 编程语言简介

要能够编写计算机程序,我们需要一种编程语言。然而,我们并没有只有一两种可供选择;我们有数千种不同的语言可供选择。在本章中,我们将讨论什么是编程语言,为什么有这么多语言可供选择,所有这些语言是如何相互关联的,以及计算机是如何理解我们编写的代码的。在结尾部分,我们将讨论编程语言的语法,也称为其语法。

到本章结束时,你将能够做到以下事情:

  • 理解为什么我们有编程语言

  • 理解编程语言是如何从一种语言演变到另一种语言的

  • 理解编程语言是如何相互关联的

  • 理解解释型和编译型语言之间的区别

  • 理解编程语言中的语法、关键字和保留字的概念

为什么我们有编程语言?

汇编语言非常难以理解。正如我们在上一章中看到的,汇编语言并不是为我们人类设计的。它非常适合计算机,但我们需要更易于阅读、编写和理解的东西。

编写程序、在代码中查找错误和缺陷以及更新程序以添加新功能所需的时间都会产生费用。如果我们使用的语言可以帮助我们减少在代码中引入错误的机会,它将降低成本。如果它帮助我们阅读代码时理解代码,它将使我们能够更快地添加新功能,从而降低成本。编程语言的一个目标就是它必须帮助我们编写程序时更有效率。

正是在这一点上,高级编程语言登上了舞台。它们使我们能够用一种常常至少在某种程度上类似于英语的东西来编写我们的代码。在第一章《计算机程序简介》中,我们看到了一个尝试这样做的方法:汇编语言。这种语言的介绍有所帮助,但仍然不够好。我们需要的是更接近人类语言的东西。

看看下面的代码片段:

.data
    msgEqual db "Equal","$"
    msgNotEqual  db "Not Equal","$"
.code
main proc

    mov bl,"Alice"                  
    mov bh,"Bob"                  
    cmp bh,bl                   
    jne NotEqual                
    mov ax, seg msgEqual        
    mov ds, ax                 
    mov ah, 09h                 
    lea dx, msgEqual            
    int 21h                     
    mov ah, 4Ch                 
    int 21h                     

NotEqual:
    mov ax, seg msgNotEqual
    mov ds, ax
    mov ah, 09h
    lea dx, msgNotEqual
    int 21h

    mov ah, 4Ch   
    int 21h   

main endp
end main

现在,再比较以下代码:

IF "Alice" == "Bob" THEN
    print "Equal"
ELSE
   print "Not Equal"
ENDIF

信不信由你,它们都做了同样的事情。第一个是用汇编语言编写的,第二个则类似于一种高级语言。即使你以前从未见过代码,理解这个程序在做什么也不难。它比较两个文本字符串AliceBob,如果它们相等,则将此结果打印到屏幕上,如果不相等,则打印不相等。当然,它们不相等,所以这里的输出是不相等

这两个例子所展示的是,如果我们比较机器代码和汇编代码,代码可以变得多么简单。

第一章计算机程序简介中,我们看到了一个最初用机器码编写,然后汇编的程序,该程序将文本Hello, World打印到屏幕上。那么,这个程序在我们今天使用的某些高级语言中会是什么样子?让我们看看一些例子。

在 Python 中,它看起来如下所示:

print("Hello, World")

在 C 中,它看起来如下所示:

#include <stdio.h>
int main(void)
{
  printf("Hello, World");
  return 0;
}

在 C++中,我们有以下内容:

#include <iostream.h>
int main()
{
    std::cout << "Hello, World" << std::endl;
    return 0;
}

在 Java 中,我们会看到以下内容:

class HelloWorld {
  static public void main( String args[] ) {
    System.out.println("Hello, World");
  }
}

在 C#中,我们有以下内容:

class HelloWorld
{
    static void Main()
    {
        System.Console.WriteLine("Hello, World");
    }
}

最后,在 JavaScript 中,我们会观察到以下内容:

console.log("Hello, World");

我们可以看到它们都是不同的,有些在打印文本的部分周围有一些额外的东西,但这个比较清楚地表明,从机器码到高级语言的转变是巨大的。

这一步为组织代码和结构代码的几种不同方式铺平了道路,并且自从 20 世纪 50 年代出现第一种高级编程语言以来,我们已经看到了巨大的发展。直到今天,已经开发出了大量的语言。

编程语言是如何演变的?

在 1943 年至 1945 年之间,德国土木工程师康拉德·祖塞(Konrad Zuse)开发了一种名为 Plankalkül 的编程语言。尽管这种语言当时并未实现,但它为我们现在称之为高级编程语言的基础,并为后来的其他语言提供了灵感。

在 1953 年底,约翰·W·巴克斯特(John W. Backus),一位在 IBM 工作的美国计算机科学家,向他的上级提交了一份提案,提议开发一种替代汇编语言的方案。1954 年,巴克斯特和他的团队发布了这种语言的第一个草案规范,并在 1957 年 4 月,发布了 FORTRAN(后来将全大写命名标准改为 Fortran)编程语言的第一版。最初,这种语言遭到了一些怀疑,因为它无法产生像汇编语言编写的程序那样运行得快的程序。然而,用这种新语言编写的程序行数远少于汇编程序,编写和理解起来更加舒适,这些优点很快就超过了它比手写汇编程序运行慢的事实。

Fortran 取得了成功,并且至今仍在使用,即使它只用于一些非常专业的应用,例如如何衡量超级计算机的性能。

Fortran 很快就被一些其他编程语言所跟随,这些语言影响了我们今天编写程序的方式。

1958 年,由麻省理工学院(MIT)工作的美国计算机科学家约翰·麦卡锡(John McCarty)创建了一种名为 Lisp 的编程语言。Lisp 提出了许多后来被其他编程语言采纳的概念。在第十章编程范式中,我们将讨论编程中使用的不同范式,Lisp 引入了一种称为函数式编程的范式。Lisp 今天通过几种语言继续存在,通常被称为 Lisp 方言。其中,我们发现了一些语言,如 Clojure、Common Lisp 和 Scheme。

在 1958 年,另一种重要的语言被创造出来,它影响了今天我们使用的几种最受欢迎的语言。它被称为 ALGOL,是由美国和欧洲计算机科学家在苏黎世的一次会议上开发的一个委员会。ALGOL 最重要的遗产是我们如何将代码结构化成独立的块,这是一个今天广泛使用的概念。

20 世纪 50 年代终于又出现了一种值得提及的语言,那就是 COBOL。其想法是创建一种类似英语且面向商业应用的语言。这个名字是通用面向商业语言的缩写。一群来自学术界、计算机用户和制造商的代表在 1959 年宾夕法尼亚大学开发了 COBOL。这个小组的成员之一是 Grace Hopper。她之前发明了一种类似英语的数据处理语言,名为 FLOW-MATIC,这成为 COBOL 不可或缺的灵感来源。长期以来,COBOL 一直是开发商业应用的第一大语言,并且至今仍在金融领域使用。

编程语言的现代时代

这些语言为 60 年代和 70 年代开发的语言奠定了基础,并成为了这些语言的灵感来源。我们将提到这个时期开发的一些语言,因为它们在引入新的编程概念或为他人提供灵感方面至关重要。

在 20 世纪 60 年代末,两位挪威计算机科学家 Ole-Johan Dahl 和 Kristen Nygaard 发明了一种名为 Simula 的语言,它普及了另一种范式,即面向对象。我们将在第十章“编程范式”中更多地讨论面向对象是什么。它启发了使用这种范式的几种现代语言,如 C++、Java 和 C#(发音为 C sharp)。

在 1969 年至 1973 年期间,贝尔实验室的 Dennis Ritchie 和 Ken Thomson 开发了一种名为 C 的编程语言,它仍然是最受欢迎的编程语言之一,并且是许多当今顶级语言的主要影响者。其中,我们发现了诸如 C++、Java、Go、JavaScript、Perl、PHP、Python 和 C#等语言。是什么让 C 如此受欢迎和有影响力呢?有几个答案。一个原因是代码的外观和代码结构的规则。这种风格启发了许多语言,并且它们在细微或无修改的情况下重新使用它。另一个原因是用 C 编写的程序运行速度快,因此,当应用程序需要高速或以某种方式需要高性能时,C 或其相关语言是这项工作的完美选择。

为什么有这么多语言?

有几个原因会导致有人开发一种新的语言。其中一个原因可能是,这个人使用语言,但认为代码的结构不够好,或者他们认为某些事情可以更高效地完成。也可能是因为开发了一种语言来针对一种特殊类型的应用。在第三章 应用类型中,我们将探讨一些不同的应用类型,这些类型可能有一些要求,使得一种语言比其他语言更适合满足这些要求。

一种编程语言可以给程序员直接访问计算机硬件的能力。这意味着它将允许程序员更多地控制数据在计算机内存中的表示和存储方式。这种语言的优点是,用这种语言编写的程序有可能更高效或运行更快。然而,这也会带来复杂性。当给予程序员更多控制时,我们也给了程序员更多犯错误的机会。

一些语言给我们较少的控制,但更容易使用。这里的缺点是,用这些语言编写的程序往往运行得更慢。

例如,如果我们想编写一个高端游戏,我们希望有最好的图形、最好的声音、先进的计算机人工智能和多玩家功能,我们将尽最大努力从计算机硬件中获得尽可能多的性能。然后我们将选择一种尽可能给我们更多控制的编程语言,因为我们希望将程序的各个方面调整到最佳配置。

如果我们相反编写一些管理软件,我们不会关注应用程序的速度;相反,我们希望有一种编程语言可以帮助我们编写尽可能少错误的、高质量的软件。一些编程语言的结构也使得编写程序更容易,这反过来又减少了程序员编写软件所花费的时间。

这样的要求也可以是创建一种新编程语言的动机。编程语言不过是我们用来创建程序的工具,就像所有工具一样,我们希望它尽可能适用于任务。

编程语言的家族树

形成编程语言之间关系的家族树并不容易,因为我们可能会就它们相互影响多少进行一番争论。要绘制一个包括所有现有语言的树也是不可能的,因为它们的数量如此之多,即使将它们放入这本书的一页也是不可能的。然而,我们可以绘制一个包括目前流行或以显著方式影响这些语言的语言的树。

我们在这里使用的语言选择是基于它们的流行度——也就是说,它们是你最有可能使用的语言。要知道今天哪些语言是最受欢迎的,我们可以转向几个来源。问题是如何衡量一种语言的流行度,不同的来源使用不同的标准来做出这个选择。如果我们浏览几个在线的顶级列表,我们很快就会发现有一些语言出现在所有这些列表中。所以,让我们从它们开始,看看我们如何从那里构建一棵树。

我将要包括的语言,不分先后顺序,有 JavaScript、Java、Python、PHP、C、C++、C#和 Ruby。

如果我们从一种语言开始——例如,C——并看看它影响了哪些语言,我们会发现汇编语言、Fortran 和 ALGOL(如果我们只关注我们之前提到的那些)。现在我们可以开始绘制这棵树。如果我们对其他语言做同样的事情,看看哪些语言影响了它们,以及它们影响了哪些语言,我们最终会得到一棵看起来像这样的树:

图 2.1:一些编程语言的家族树

图 2.1:一些编程语言的家族树

我们可以就这是否是一个准确的表现进行长时间的讨论,但它给了我们一个关于语言如何相互启发的总体概念。在我们讨论的语言中,只有 COBOL 不能直接与这些语言中的任何一个直接联系起来。这并不意味着 COBOL 不是必要的,但对于进入这棵树的那些语言来说,COBOL 并没有产生任何显著的影响。

关于这个图表的另一件事是,那些以 ALGOL 为共同祖先的语言被过度代表了。原因是,在当今最受欢迎的语言中,它们都来自一个常被称为 ALGOL 语言家族的群体。我还自由地省略了一些中间语言,以减少树的大小。在这个家族树中,我们没有看到一种完全与任何现有语言无关的新语言出现。这意味着新语言是作为对现有语言的反应而创造的。在创建一种新语言时,我们取自一种或多种语言的我们喜欢的部分,并改变我们不喜欢的部分。

这棵树之所以有趣,是因为如果我学习了一种编程语言,那么学习一个相关的语言要比学习树中更远的语言容易得多。

将代码翻译成计算机能理解的内容

程序员编写的代码被称为源代码。正如我们在第一章,“计算机程序简介”中看到的,此代码必须翻译成机器码,以便计算机能够理解它。这种翻译的主要有两个原则。在我们探讨这两个概念并查看它们的优缺点之前,我们将先看看这两种概念的结合。

解释

执行这种翻译的一种方式是使用解释器。解释器会查看一行源代码,将其翻译成机器码,让计算机执行这一行,然后继续到下一行代码。解释器的工作方式有点像同声传译员处理人类语言的方式。例如,同声传译员可能会为联合国工作。在联合国,每个人都有权用母语发言。一组翻译人员会听取讲话,在听的过程中,他们会将其翻译成另一种语言。代表们可以通过耳机实时地用母语听到演讲,方式如下:

图 2.2:同声传译员将所有内容实时翻译

图 2.2:同声传译员将所有内容实时翻译

接下来,让我们看看编译是如何工作的。

编译

执行这种翻译的另一种方式是使用称为编译的技术。当我们把源代码编译成机器码时,我们首先翻译每一行代码,只有在所有代码行的翻译都完成后,程序才会执行。我们可以将这比作翻译一本书的概念。首先,作者用一种语言写书。然后,翻译者会将整本书翻译成另一种语言。只有在原书中所有文本的翻译都完成后,它才会可供阅读:

图 2.3:在翻译一本书时,翻译者会在书出版前翻译所有文本

图 2.3:在翻译一本书时,翻译者会在书出版前翻译所有文本

在此之后,我们将看到解释和编译是如何进行比较的。

比较解释和编译

解释和编译是翻译源代码的两种主要技术。一种编程语言可以使用这两种技术中的任何一种,因此一种语言通常被称为解释型语言或编译型语言。

让我们更仔细地看看这两种技术,以便在我们比较它们之前更好地理解它们。

当翻译程序员编写的源代码时,一个称为解释器的专用程序可以完成这项工作。解释器会逐行读取源代码,并立即翻译每一行。

让我们看看这个过程的示意图:

图 2.4:解释器将一行源代码翻译成机器码

图 2.4:解释器将一行源代码翻译成机器码

首先,解释器将从左侧的源代码中读取一行。在这个图中,它读取第一行,称为代码行 1。然后它将这一行翻译成机器码并发送到计算机的处理器,即 CPU,CPU 将执行指令。

然后它将继续到下一行,如下面的图所示,并重复对该行的处理过程:

图 2.5:当一行执行完毕后,解释器继续执行下一行

图 2.5:当一行执行完毕后,解释器继续执行下一行

解释器将重复此过程,直到没有更多行需要在源代码中处理。

编译器将翻译源代码文档中的所有代码,并将其存储在包含机器码指令的文件中。当我们想要运行程序时,我们可以使用这个文件来运行它;此时 CPU 将执行机器码。

下面的图示说明了这个过程:

图 2.6:编译器将所有源代码翻译成机器码并存储在文件中

图 2.6:编译器将所有源代码翻译成机器码并存储在文件中

这两种翻译方法的优缺点是什么?让我们从解释开始,先看看一些好处:

  • 它的程序大小更小。

  • 如果我们有代码和解释器,我们可以在任何平台上运行它(例如,Windows、Linux、macOS 等)。

  • 解释型语言通常对程序员来说更加灵活。其中一个例子是动态类型,我们将在第六章,“与数据一起工作——变量”中进一步讨论。

解释器方法的一些不利之处如下:

  • 程序运行较慢,因为它需要一些时间来完成翻译。

  • 任何想要运行程序的人都必须安装解释器。

  • 程序的用户可以访问源代码,因此如果它是一个商业应用程序,我们编写的所有代码都将对任何人可访问,包括任何潜在的商业机密。

对于编译型解决方案,其优缺点与解释型解决方案正好相反。优点如下:

  • 它运行得更快,因为翻译是一次性完成的。

  • 运行应用程序不需要额外的程序——也就是说,应用程序拥有运行所需的所有信息,因此用户不需要安装任何其他程序。

  • 编译型编程语言往往在诸如类型检查等方面为程序员提供更高的帮助。类型检查是我们将在第六章,“与数据一起工作——变量”中讨论的内容。

不利之处如下:

  • 程序往往更大,因为它们需要包含如何执行的说明。

  • 我们需要为所有我们打算让程序运行的平台制作版本——也就是说,我们需要一个 Windows 版本、一个 macOS 版本和一个 Linux 版本。

  • 完成翻译所需的时间可能很长,这使得我们在编写程序时尝试新事物变得更加困难。

正如我们所见,这两种技术都有优点和缺点。编程语言要么是解释的,要么是编译的,尽管有一些例外,我们很快就会看到。

注意:

一些解释语言的例子有 PHP、Ruby 和 JavaScript。

一些编译语言的例子有 C、C++、COBOL、ALGOL、Fortran 和 Lisp。

同时编译和解释的语言

我们还有一组既编译又解释的语言。当它们编译源代码时,它们不会直接将其编译成机器代码。它们遵循一个中间步骤,将源代码编译成字节码。然后,在程序执行时,将这些字节码解释为程序运行的当前系统的机器代码。这样做的好处是,我们可以获得这两种技术的一些优点。例如,这种字节码可以被分发给任何想要运行程序的人,然后解释器将字节码解释为当前系统上的机器代码。

编译语言还有另一个优点——这也适用于混合技术的方法——那就是如果源代码中存在错误,编译器会检测到这一点,因为语法(记住语法是语言的语法)必须正确,如果不正确,编译器就无法继续并停止翻译。然后程序员需要回去纠正错误,程序才能再次编译。

混合技术语言与解释语言共享一个缺点,那就是用它们编写的程序运行速度会比用编译语言编写的程序慢。

注意:

一些混合技术语言的例子有 Python、Java、C#和 Perl。

语法和编程语言的基本构建块

正如人类语言有语法来规定语言的规则一样,编程语言也有语法。语法是我们使用语言编写程序时的规则。语法和语法的最大区别在于对错误的宽容度。如果你遇到一个说你的母语但偶尔犯错的人,你仍然能够理解这个人试图向你传达的信息。编程语言的语法并非如此。它不会宽容任何错误,你需要做到完美无缺:

图 2.7:即使语法错误,人类也能相互理解

图 2.7:即使语法错误,人类也能相互理解

正如我们之前讨论的,我们编写的代码将被编译器或解释器翻译,为了使这种翻译工作,语法必须完美无缺。

每种编程语言都有自己的语法规则,但正如我们在之前的家族树中看到的,语言可以是相关的。因此,许多语言共享一种语法,只有细微的差异,而其他语言则有更专业的语法。当我们学习一门新语言时,我们必须学习该语言的语法。这就是为什么在密切相关的语言之间移动更容易,因为它们很可能共享很多语法。

如果我们在语法上有错误,它将在翻译过程中被发现,这就是编译型语言和解释型语言之间的区别。对于编译型语言,所有的翻译都会在我们能够执行程序之前完成。如果我们有语法错误,编译器一旦发现错误就会停止编译。然后我们必须找到错误并纠正它,然后让编译器再次尝试翻译代码。只有当我们的代码没有任何语法错误时,我们才能运行完全:

图 2.8:编译器在没有语法错误的情况下不会产生任何输出

图 2.8:编译器在没有语法错误的情况下不会产生任何输出

对于解释型语言来说,情况不同,因为它会在我们运行程序时逐行翻译。这意味着语法错误可能隐藏在程序的一个很少被执行的角落,直到我们最终想要运行那行代码时才会被发现。当这种情况发生时,程序会崩溃,并显示错误信息,告诉我们语法中有什么问题:

图 2.9:解释器将翻译它遇到的每一行,并执行它直到找到语法错误

执行它直到找到语法错误](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/comp-prog-abs-bgn/img/B15554_02_09.jpg)

图 2.9:解释器将翻译它遇到的每一行,并执行它直到找到语法错误

这意味着我们编写的源代码文档要么是语法正确的,要么是错误的。语法是一组规则,定义了源代码的编写和结构方式。但不仅如此。语法还定义了其他事物,例如构成语言的语言。这些被称为关键字

关键字

当学习一门新语言时,我们必须跟踪其关键字,因为这些词被语言保留,所以我们不能在程序中使用它们来命名事物。如果我们不小心将关键字用于其预期用途之外的事物,我们将得到一个错误。关键字有时也被称为保留词。

一种语言通常有 30 到 50 个关键字。以下是在许多语言中的一些常见关键字列表:

  • for

  • if

  • else

  • break

  • continue

  • return

  • while

大多数编程语言都是大小写敏感的,所以大写和小写字母的使用很重要——例如,ifIfIF 并不相同。

除了关键词之外,我们还有被称为操作符的东西,我们可以用它来表示我们对数据进行操作的动作。

运算符

一种编程语言也会有几个运算符,这些是我们用来完成诸如加法和乘法等操作,以及比较项的工具。可以使用的符号也被定义为语言语法的一部分。以下是一些常用运算符的列表:

表 2.1 – 编程语言中常用的运算符

表 2.1 – 编程语言中常用的运算符

运算符之所以被称为运算符,是因为它们对数据进行操作。正如我们可以在前面的表中看到的那样,存在用于执行算术运算的运算符,例如加法和乘法。其他运算符用于比较——例如,查看两个值是否相等,一个是否大于另一个,等等。在第六章 与数据一起工作 – 变量中,我们将了解更多关于编程语言中通常可以找到哪些运算符以及它们如何被使用的信息。

将我们的代码放在一个长长的序列中会使阅读变得困难。这就像是一本没有章节或段落的书籍。为了给我们的代码添加章节和段落的理念,我们使用一种叫做代码块的东西。

代码块

对于一种语言来说,也允许我们定义代码块是很常见的。你可能会想出很多想要这样做的原因,我们将在后面的章节中详细讨论。然而,现在我们可以将代码块想象成标准文本中的一个段落。然后,语言定义了如何标记代码块的开始和结束。许多语言采用的一种常见技术是使用括号,也称为花括号或大括号——{}。这些括号内的所有内容都被视为代码块的一部分。其他语言可能有不同的方法来完成同样的事情,所以,当我们切换到不同的语言时,我们必须学习那种语言的语法规则。

现在我们已经介绍了一些编程语言用来定义其语法的基

与数学的关系

编程与数学密切相关,因为编程借鉴了许多来自数学的概念。其中之一就是变量的使用。在第六章数据处理 – 变量中,我们将讨论什么是变量以及它是如何工作的,但它们在本质上与数学中的变量相同,即我们可以用一个名字来代表变量(一个可以变化的值)。我们可以命名变量的规则也是语言语法的一部分。

从数学中借鉴的另一个概念是函数。在数学中,函数是一种接受输入值并以某种方式将其转换以产生输出的东西。这类似于我们可以描述编程中的函数,但这并不是编程中函数的全部。我们将在第八章“理解函数”中讨论函数,然后我们将看到我们需要以不同于我们看待它们的数学等价物的方式来思考编程函数。

在接近编程时,我们必须记住的一件事是,如果我们理解了这些概念在数学中的工作原理,这并不意味着我们可以直接将这种知识应用到编程中,即使它们恰好有相同的名称。它们将是相关的,但编程中的做事方式将与数学中的工作方式不同。

摘要

在本章中,我们首先讨论了为什么机器码如此难以理解,以及创造易于程序员使用的编程语言的动机。然后,我们看到了编程语言是如何随着时间演变的,以及它们大多数是如何相似的,因为它们在演变过程中相互影响。

我们还讨论了一些不同的技术——解释和编译——这些技术用于将源代码翻译成机器码。我们还看到了一些语言如何使用混合技术,同时采用编译和解释来完成翻译。

在本章的结尾,我们学习了编程语言的语法或语法规则,以及每种语言都有自己的语法规则。我们还了解到数学和编程之间存在密切的关系,编程借鉴了一些来自数学的思想和概念,但即使它们有相同的名称,它们也不一定做相同的事情。

所有这些知识都将为你提供一个坚实的基础,以便我们在下一章中继续学习,我们将探讨我们可以开发的一些主要应用程序类型。我们还将了解它们之间是如何相互关联的。

第三章:第三章:应用程序类型

计算机程序,或者我们有时称之为应用程序,有多种类型。每种类型都解决一种特殊的问题。一些应用程序,如纸牌游戏或文字处理器,仅在本地计算机上运行,而其他应用程序则需要与其他计算机或网络通信才能工作,如网页浏览器或电子邮件客户端。

在本章中,我们将探讨一些特殊类型的应用程序,并讨论在创建它们时我们需要考虑哪些因素。

由于应用程序种类繁多,我们不可能涵盖所有类型。相反,我们将查看一些我们在编写应用程序时可能会遇到的一些常见类型。

我们创建程序是为了解决问题,在设计我们的应用程序并决定它需要做什么的过程中,我们经常会查看他人为类似问题找到的解决方案。本章的目标是使你熟悉这些解决方案中的一些,以便你可以在未来需要创建自己的解决方案时,识别出它们解决的问题。

到本章结束时,你将能够做到以下几点:

  • 理解本章涵盖的不同类型应用程序的典型特征

  • 理解应用程序类型如何影响我们构建应用程序的方式

  • 理解连接型应用程序的重要性

  • 理解使用基于云的解决方案的好处

  • 理解我们讨论的不同类型的应用程序可以解决的问题

独立应用程序

独立应用程序是一种可以离线工作的程序,也就是说,它不一定需要网络连接。因此,在编写此类应用程序时,我们需要提供程序所需的所有资源。这些资源可以是图像,例如应用程序中使用的图标,存储程序配置的文件等等。

在学习编写应用程序时,你的大部分程序可能会落入这个类别。创建这些应用程序通常相当直接,因为我们不需要与其他程序交互。

属于这一类别的程序示例包括 Windows 上的记事本或 Mac 上的 TextEdit 这样的文本编辑器,简单的游戏如纸牌游戏,以及绘图程序。

客户端-服务器应用程序

客户端-服务器模型是我们可以使用来创建分布式应用程序的模型,这些应用程序在多台机器上运行。

客户端-服务器模型背后的理念是我们至少有两台计算机参与。一台充当服务器,其余的都扮演客户端的角色。客户端和服务器需要相互通信。总是客户端首先发起通信。有时服务器会同时与多个客户端通信;有时服务器一次只与一个客户端通信。

这意味着我们可以使用不同的电脑来处理应用程序责任的不同部分。我们可以让一台电脑处理问题的一个方面,而另一台电脑处理同一个问题的另一个方面。然后这两台电脑需要将它们的结果进行沟通,通常发送到一台电脑,这台电脑然后将不同的结果组合成一个解决方案。

当我们为应用程序的不同部分有不同的角色时,我们也可以使用这个模型。例如,我们有一个角色是用来向用户显示数据和获取输入(用户交互),另一个角色是用来处理和存储这些数据。我们可以将这些角色分开,让处理和存储的角色由一台电脑完成,而用户交互的角色由另一台电脑完成。

图 3.1 – 连接到多个客户端的服务器

图 3.1 – 连接到多个客户端的服务器

为了说明这一点,让我们考虑几个我们会使用客户端-服务器解决方案的场景,并看看解决方案会是什么。

聊天应用的示例

假设你想创建一个应用程序,你和你朋友们可以在其中互相聊天。所有将使用这个聊天应用的人都需要客户端软件;这是我们想要聊天时启动的程序。

当我们开始思考如何设计这个应用程序时,我们将面临我们的第一个问题。想象一下,你启动聊天应用是因为你想和你的朋友爱丽丝聊天。我们的应用需要连接到爱丽丝的电脑,运行她的程序版本。你和爱丽丝都将运行相同的程序,但它们如何连接?我们的应用如何在所有连接到互联网的电脑中找到爱丽丝的电脑?这就像你想给爱丽丝打电话,但没有她的电话号码一样。我们的聊天应用将是我们的电话,爱丽丝的客户端将是她的电话。你不能随意输入一个号码,希望联系到爱丽丝。

图 3.2 – 当你想聊天时,如何找到爱丽丝的电脑?

图 3.2 – 当你想聊天时,如何找到爱丽丝的电脑?

一个 IP 地址(IP互联网协议的缩写,是更大协议栈 TCP/IP 的一部分,该协议栈描述了计算机如何在互联网上通信)标识了所有连接到互联网的计算机和其他设备。我们可以把这个地址看作是一个电话号码。这个号码可以唯一地识别世界上任何地方的电话。IP 地址也是一样;它可以唯一地识别任何连接到互联网的设备。

问题在于,我们如何知道爱丽丝的计算机的地址?即使我们知道它是什么,我们也必须理解它可能会改变。如果她连接到她的家庭 Wi-Fi 网络,她将有一个 IP 地址,但如果她带着她的计算机去市中心的一家咖啡馆并连接到他们的 Wi-Fi 网络,她将得到另一个 IP 地址。这是因为当连接到 Wi-Fi 网络时,是网络路由器为您的计算机分配 IP 地址。

一个更好的解决方案是,如果所有客户端都连接到一个始终具有相同地址的计算机。这将是我们company.com比 IP 地址更容易记住。

图 3.3 – 使用服务器来处理你与爱丽丝之间的通信

图 3.3 – 使用服务器来处理你与爱丽丝之间的通信

如果有超过两个用户连接到服务器,那么服务器将需要跟踪消息的接收者。当你向爱丽丝发送消息时,你的客户端应用程序需要提供应该接收消息的人的身份,以便服务器可以确保它被发送到正确的客户端。

邮件客户端的示例

假设你已经使用了几种不同的应用程序来阅读和编写邮件,但你对他们的工作方式不满意,你决定编写自己的。你将编写的是一个邮件客户端。

让我们再次以我们的朋友爱丽丝为例。如果她给你发邮件会发生什么?你的邮件必须存储在某处,因为你不能一直运行客户端应用程序。爱丽丝发给你的邮件最终会落在邮件服务器上。当你启动你编写的邮件客户端时,它将连接到服务器并请求自上次连接以来收到的所有新邮件。这些现在将被传输到你的客户端应用程序。

图 3.4 – 邮件服务器将处理传入和传出的邮件,并且客户端只连接以接收和发送消息

图 3.4 – 邮件服务器将处理传入和传出的邮件,客户端只连接以接收和发送消息

客户端-服务器,一个两部分的解决方案

在这两个例子中,我们看到解决问题的方案分为两部分。我们需要一部分是客户端,另一部分是服务器。这两个的特点是我们有一个服务器,其位置通过 IP 地址为人所知,我们将有一个客户端,它将知道服务器地址,并将是发起通信的部分。IP 地址也可以是域名形式,例如some-server.com。域名是 IP 地址和名称之间的一对一映射。换句话说,域名绑定到一个单一的 IP 地址,并且使用它是因为域名比只有四个数字的 IP 地址(形式为123.123.123.123)更容易记住。

这种格式适用于称为2001:db8:a0b:12f0::1的 IP 地址版本。这些数字由冒号而不是点分隔。在 IPv4 中,地址被表示为 32 位值,而在 IPv6 中是 128 位。这意味着我们有更多的地址可以分配。

有时这两个角色仅因连接方式的不同而有所区别;客户端连接到服务器,一旦建立连接,它们可以充当两个相同的部分。如果我们以聊天应用为例,如果我们知道爱丽丝的地址,我们就可以直接连接到她的应用。我们的应用最初是客户端,而爱丽丝的应用将充当服务器。但一旦我们建立了连接,两个部分将以相同的方式行动,客户端和服务器的作用将不再重要。

接下来,我们将转向理解网络应用。

网络应用

网络应用是一种特殊的客户端-服务器应用,其中我们有一个以网页形式与用户交互的客户端。服务器负责生成用户将看到的结果,并接受和处理用户的输入。

这个过程大致是这样的:

  1. 想象一下,你访问一个网站并被提示登录。你输入用户名和密码。当你按下登录按钮时,你输入的信息会被发送到服务器:图 3.5 – 在登录网络应用时,你的凭证将被发送到服务器

    图 3.5 – 在登录网络应用时,你的凭证将被发送到服务器

  2. 服务器请求存储在数据库中关于此用户的信息:图 3.6 – 网络服务器请求存储在数据库中的用户信息

    图 3.6 – 网络服务器请求存储在数据库中的用户信息

  3. 数据库返回它为此用户拥有的信息。请注意,通常密码不会以这里所示的方式以纯文本形式存储,但为了清晰起见,我们在这个场景中忽略这一点:图 3.7 – 数据库返回信息

    图 3.7 – 数据库返回信息

  4. 服务器应用现在验证用户名和密码是否正确。如果是,它将生成一个供此用户使用的网页并将其传输到客户端的计算机,以便在该计算机上运行的网页浏览器可以显示此页面:

图 3.8 – 服务器生成网页并将其传输到客户端

图 3.8 – 服务器生成网页并将其传输到客户端

如果我们要创建自己的社交网络,让我们看看这代表什么。

社交网络的例子

您需要创建这个应用程序的客户端和服务器部分。首先,用户需要登录。为此,客户端会要求用户提供他们的凭证。然后客户端将用户名和密码发送到服务器,服务器将验证信息是否正确。结果将发送回客户端。如果登录失败,用户将被要求重试。如果成功,用户将看到包含所有朋友和亲戚帖子的主窗口。

这里可能感觉有些魔法在起作用,因为我们是怎样得到您叔叔在地球另一端 5 分钟前发布的最新帖子的?

您的叔叔使用他的客户端创建帖子。关于这条帖子的信息被发送到服务器,服务器将其存储在数据库中。当您登录时,服务器会向数据库请求您所有联系的用户,其中就包括您的叔叔。然后服务器检查您的叔叔是否发布了任何最近的帖子,并找到他的帖子。现在这条帖子已经成为结果的一部分,与其他朋友的帖子一起发送给您:

图 3.9 – 您的叔叔发布了一条新的状态更新,该更新被包含在您的动态中

图 3.9 – 您的叔叔发布了一条新的状态更新,该更新被包含在您的动态中

接下来,我们将看到这些应用程序的独特之处。

什么使得网络应用程序独特?

正如我们所见,网络应用程序基本上就是一个客户端-服务器解决方案,但有一个转折使得它不仅仅是一个客户端-服务器应用程序,那就是客户端如何与用户交互。

如果我们回顾之前讨论过的客户端-服务器应用程序,聊天和电子邮件程序已经被设计成独立应用程序。这意味着我们可以在电脑上启动一个程序。我们的社交网络应用程序并非如此。当用户想要访问它时,他们会启动一个网络浏览器并导航到服务器的地址。我们可以说网络浏览器是一个通用客户端,因为它不是为了服务一个特定的解决方案而制作的,而是可以用来访问网络上的任何页面,我们的社交网络只是其中之一。

我们仍然需要设计这个页面将如何呈现以及将向用户展示哪些信息,但客户端通常包含很少的程序逻辑。我们应用程序的逻辑是在服务器端完成的,是服务器将生成用户看到的页面。这些页面被传输到客户端,即用户的网络浏览器,然后浏览器显示结果。

接下来,我们将探讨移动应用程序。

移动应用程序

当我们谈论移动应用程序时,我们通常指的是设计在移动设备上运行的程序,例如智能手机。这些设备有一些特殊特性,我们在编写应用程序时需要考虑。首先,它们的屏幕比计算机显示器小。屏幕还可以在横向或纵向方向旋转。我们还将使用设备的触摸屏进行输入。

移动应用程序可能还会使用设备的其他功能,例如 GPS、发送短信或使用加速度计感应设备的移动。这些是我们通常在应用程序在普通计算机上运行时无法做到的事情。

移动应用程序可以是连接的,但不必是。连接意味着它可以与另一台计算机通信,可能使用我们之前讨论过的客户端-服务器技术。

当编写一个移动应用程序时,应用程序将要运行的平台非常重要。原因是我们所编写的程序需要更直接地与设备交互。这意味着它将决定我们将使用什么编程语言来编写这些应用程序。移动设备的操作系统开发者有一些首选的编程语言。对于 iOS,苹果的移动设备操作系统使用两种语言,旧有的 Objective-C 和新的 Swift。如果你不是为苹果设备创建应用程序,你几乎不会遇到这两种语言。对于 Android 操作系统,首选的语言曾经是 Java,但 Android 背后的公司谷歌在 2019 年改变了这一点,现在使用一种称为 Kotlin 的语言作为首选的开发语言。

对于这些系统有一个首选语言并不意味着我们不能使用其他语言。然而,苹果和谷歌推荐使用这些语言,因此在我们开发移动应用程序时通常更容易使用这些语言。原因是我们在编写程序时使用的工具将更适合它们,而不是任何其他语言。

接下来,我们来看看分布式应用程序。

分布式应用程序

分布式应用程序是一种不运行在单一机器上的应用程序,而是让程序的不同部分在通过网络相互通信的多台计算机上运行。这听起来可能像是我们之前讨论过的客户端-服务器解决方案,但在这里我们没有客户端和服务器之间的明确角色。

使用这种解决方案可能有几个原因。其中一个可能是因为我们所做的事情需要如此多的计算能力,以至于一台电脑是不够的。想法是使用多台电脑的计算能力,并将计算分配给所有这些电脑,让每台电脑处理问题的一个小部分,并将结果传达给网络中的其他机器。这将给我们带来某种超级计算机的感觉,它将作为一个运行单个应用程序的非常强大的单一机器运行,而实际上它是由成千上万台电脑运行应用程序的小部分。

让我们更详细地探讨分布式应用程序。

SETI@home

使用这种技术的项目示例是SETI(代表搜寻外星智慧),这是一个试图在太空中寻找外星智慧的科学项目。为了做到这一点,他们使用射电望远镜收集大量数据。问题是所有这些数据都需要被分析,以寻找可能具有智能起源的信号。他们使用的解决方案是让人们通过在他们的电脑上安装屏幕保护程序或一个特殊程序来帮助他们,该程序将在电脑不用于其他任务时使用该电脑的计算能力。通过这样做,他们将拥有所有这些电脑的力量来进行分析,并将分配给他们的数据部分的分析结果报告回来。

您可以通过访问 setiathome.berkeley.edu/ 并安装程序来亲自尝试:

图 3.10 – SETI@home 分析数据。版权 2019 UC Regents。经许可使用

图 3.10 – SETI@home 分析数据。版权 2019 UC Regents。经许可使用

对等网络

对等网络,也称为P2P,是一个由计算机组成的网络,这些计算机是网络中的平等参与者。网络中的每台计算机被称为节点或对等节点,它们将部分资源,如处理能力或磁盘存储,直接提供给网络中的其他参与者。这种技术由 90 年代末的文件共享系统如 Napster 普及。网络中的对等节点既是资源的提供者也是消费者。这就是这种解决方案与传统客户端-服务器模型不同的地方,在传统客户端-服务器模型中,资源的供应和消费在服务器和客户端之间划分:

图 3.11 – 没有服务器的计算机或对等节点连接的 P2P 网络

图 3.11 – 没有服务器的计算机或对等节点连接的 P2P 网络

今天,P2P 网络被大多数加密货币使用,占区块链行业很大一部分(简单来说,区块链是一个存储在 P2P 网络中多个节点上的数据库)。P2P 也被用于网络搜索引擎、流媒体平台和在线市场。

接下来,我们来看看云基础应用程序。

云基础应用程序

云计算首次在 1996 年被提及,但直到 2006 年亚马逊发布了其弹性计算云服务,它才变得广受欢迎。云基础计算背后的理念是摆脱托管服务器和其他运行项目所需的资源的需要,而是从大型数据中心购买时间来使用他们的计算能力。这有很多优点。你不必确保你的计算机处于运行状态,操作系统已更新,你已经为你的数据实现了备份解决方案等等。你可以设置你的服务器保持在线,然后你可以在该服务器上部署你的软件并从那里运行它。

提供这些服务的企业很快就开始添加其他我们也可以使用的功能。这些是现成的组件,我们可以将其用于我们的应用程序中。这意味着我们的应用程序中将有部分组件我们不需要自己编写。相反,我们可以从提供商那里购买这些现成的组件,并将它们集成到他们提供的将运行在服务器上的应用程序中。

有很多变体:我们可以购买服务器,我们可能只购买存储,或者我们可能购买一个或多个我们将使用的服务,然后我们可以以任何我们想要的方式将它们组合起来。

我们有很多原因希望使用云基础资源来开发我们的应用程序。让我们看看其中的一些。

采用云基础应用程序的优势

这里有几个原因说明为什么使用云基础应用程序对我们有益。

成本降低

通过在我们的云基础服务器上运行我们的应用程序,我们不需要购买服务器计算机,也不需要维护这台计算机。我们可以支付服务费用,这样云服务提供商就会负责确保我们的服务器的操作系统得到更新,并且安全补丁在发布时得到安装。

如果我们的应用程序正在存储数据,我们可以让提供商负责备份,确保我们不会丢失任何数据。我们还可以让这些备份存储在世界各地的不同位置,这样即使我们的提供商使用的其中一个数据中心被摧毁或因某种原因受到影响,我们的数据仍然会保持安全。

这只是我们如何降低成本的两个例子,因为我们支付给云服务提供商的费用将大大低于我们如果自己完成所有这些工作的情况。

可扩展性

可扩展性是指当我们的应用程序所做的工作量发生变化时,我们如何适应。例如,如果我们有一个正在运行的 Web 应用程序,并且突然一夜之间变得非常受欢迎,我们可能会从同时连接到它的几百个用户增加到几千个。如果运行我们应用程序的硬件无法处理这种日益增长的人气,我们的用户很快就会厌倦使用它,因为他们需要花费太多时间等待我们应用程序的响应。如果我们自己管理硬件,我们将需要获取更多更好的服务器计算机,将我们的应用程序安装在上面,并确保一切正常工作。如果对我们应用程序的兴趣下降,我们现在将投资于我们不再需要的硬件。

如果我们使用基于云的解决方案,我们只需点击几下,就可以付费让我们的服务器获得更多动力。如果需求下降,我们还可以降级,只为我们使用的部分付费。这个过程也可以自动化,因此服务器硬件可以适应需求。

云服务模型

云计算提供商根据三种不同的模型提供不同的服务。这些模型定义了提供商将处理哪些部分,以及我们,应用程序的创建者,将处理哪些部分。这些不同的模型也可以被视为不同的层,因此当我们决定我们的应用程序需要什么时,我们可以从这三个层中挑选东西。

让我们来看看这些层,以便我们了解它们能帮助我们什么。

基础设施即服务(IaaS)

这是处理硬件资源,如服务器、存储、防火墙等的层。在这个层上投资服务意味着您不需要购买硬件,您不需要花费时间在配置上,数据存储空间将由您管理。

当 IaaS 对我们来说是一个好选择时,以下是一些例子:

  • 大数据:越来越多的应用程序需要大量的数据。例如,这可以是用于训练人工智能(AI)应用程序或依赖于大量所谓非结构化数据(即图像、电子邮件或社交媒体内容等)的应用程序所使用的数据。这些应用程序将需要处理可能随时间变化的大工作量。IaaS 为我们提供了点击即可添加存储和处理能力的工具;实际上,这甚至可以自动化以满足我们的需求。

  • 灾难恢复:我们在软件中最宝贵的资产是数据,我们应该始终确保我们能够从灾难中恢复。如果我们把我们的数据副本存储在不同的地理位置,我们可以放心,即使最坏的情况发生,我们也能恢复它。IaaS 使得这样做既简单又经济。

  • 测试和开发:在开发应用程序时,我们经常希望在不同硬件配置和不同操作系统上测试它们。设置不同的 IaaS 解决方案是一种既便宜又简单的方法。

平台即服务(PaaS)

在这一层中,您会发现一些将充当服务器的应用程序。一些例子是处理网页资源的 Web 服务器,以及管理数据存储和检索的数据库服务器。这一层还可以包含针对特定编程语言的快速配置环境。

此级别建立在 IaaS 级别之上,因此通常,您将获得该层的优势以及本层包含的内容。

下面是一些使用 PaaS 的一些好处示例:

  • 缩短上市时间:当开发软件时,从想法到可以开始赚钱的产品是至关重要的。使用 PaaS 将大大减少获取硬件、安装和配置软件的时间。有例子表明,初创公司在周五有了想法,在下周一下午就能使用产品。这可以通过 PaaS 的帮助实现。

  • 降低成本:无需投资时间和金钱购买硬件、配置它、安装软件以及确保所有软件更新,我们的成本将大幅降低。我们节省的时间可以用来开发我们的产品。

软件即服务(SaaS)

这一层将为您提供一切——硬件、服务器软件以及应用程序——您唯一需要做的就是配置它以您想要的方式工作。SaaS 的一个著名例子是谷歌应用系列。这些都是谷歌提供的应用程序,例如文档、表格和日历。

下面是一些您可能想要使用 SaaS 的原因示例:

  • 减少办公软件的维护:在经营业务时,我们需要为所有员工提供电子邮件地址;我们需要为他们提供办公应用程序,如文字处理器、电子表格应用程序和演示软件。如果我们让其他人处理安装、配置和更新,并减少我们在处理软件许可证上花费的时间,我们将释放资源并节省金钱。

  • 共享信息:使用云存储等服务将使员工和客户之间共享文件和文档变得更加容易。

这完成了我们对所有相关云服务模型的覆盖。

作为软件开发人员,您最有可能在 PaaS 层工作,因为它提供了我们开发应用程序所需的工具:

图 3.12 – 不同云系统层处理的内容

图 3.12 – 不同云系统层处理的内容

云端解决方案的其他优势

这些只是我们从使用云端解决方案中获得的一些优势示例。在 2017 年的一篇文章中,《商业杂志》列出了他们认为的云端解决方案的五大好处。具体如下:

  • 提高成本效益

  • 提供灵活的支付选项

  • 促进协作

  • 增加移动性

  • 帮助灾难恢复

如果你在网上搜索迁移到云端的益处,你会找到类似的列表。有些人还会添加环境优势。

我们还应该考虑使用基于云的解决方案的风险。主要的风险是安全和个人隐私问题,当你没有完全控制数据存储的位置和方式时,这些问题可能很难处理。

接下来,让我们看看其他类型的应用程序。

其他类型的应用程序

当然,我们还有几个其他软件可能归属的类别。让我们看看其中的一些。

系统软件

这类软件帮助用户、应用程序和计算机硬件相互交互并协同工作。这些应用程序创建了一个其他程序可以工作的环境。当计算机开机时,首先加载到计算机内存中的是系统软件应用程序。它们大多数将在后台运行,即使其中一些可以有可视化的用户界面。因为这些程序直接与计算机硬件工作,它们通常被称为低级软件

在这个类别中,我们发现的是操作系统。正如我们在第一章计算机程序简介中看到的,它们让其他软件运行并处理与硬件的直接通信。

桌面计算机和笔记本电脑最著名的操作系统如下:

  • 微软 Windows

  • macOS 和 macOS X(适用于苹果设备)

  • Linux

对于智能手机和平板电脑,我们有以下操作系统:

  • 安卓

  • iOS(适用于苹果设备)

  • 微软 Windows Mobile

编程软件

在这个类别中,我们找到了程序员在编写和测试软件时使用的工具和应用程序。首先,我们需要的是程序员实际使用的程序。要能够用 C++、Java、Python 或任何其他语言编写程序,我们必须首先安装将负责将源代码翻译成机器码的软件(参见第二章编程语言简介))。

程序员通常会使用专门的文本编辑器,这些编辑器在编写代码时会帮助他们。有些程序甚至更先进,不仅提供编写代码的编辑器,还提供一系列其他内置工具,这些工具在编写程序时非常有用。这些被称为集成开发环境IDEs)。内置工具的一个例子是调试器,它是一个帮助程序员找到代码中错误的程序。

无服务器应用程序

无服务器应用程序是基于云应用程序的一种特殊变体。它可以以几种不同的形式出现。所有这些变体共同的特点是云服务提供商运行所需的服务器,并动态管理应用程序所需的所有资源。这意味着,例如,我们不需要购买固定大小的存储。提供商将根据我们的需求添加更多存储,我们将为使用的存储付费。我们可以将此与我们的硬盘已满时,它只是不断增加其存储容量以满足我们对更多空间需求的情况进行比较。

如果你想要自动化服务器、存储和其他基础设施方面的维护,这类软件很有趣。这些解决方案是智能的,因此它们可以适应变化,例如,在我们需要时提供更多存储,并在需求下降时再次减少。

摘要

在本章中,我们讨论了一些典型的应用程序类型以及它们的特点。

我们学习了什么是独立应用程序,以及这是你在学习编程时将编写的第一种类型的应用程序。之后,我们探讨了不同类型的应用程序,这些应用程序以某种方式被划分为在不同的计算机上运行程序的部分,并且我们看到了这些部分是如何相互通信的。

我们了解到,一个网络应用程序运行在服务器上,但通过网页与用户进行通信。我们看到了移动应用程序的特殊之处,它们可以利用现代移动设备(如智能手机和平板电脑)的功能,例如 GPS 和摄像头。

另一类应用程序是需要大量计算能力,并让许多计算机分担工作负载和执行计算部分的应用程序。这些通常被称为分布式应用程序。然后我们看了一个增长迅速的类别,那就是基于云的应用程序。使用这些服务的优点是,通常比我们自行管理要便宜得多,也更安全。

最后,我们讨论了其他几个类别:系统软件、编程软件和无服务器应用程序。

所有软件都需要程序员编写,在本章中,我们看到了应用程序可以以许多形式出现。你有不同的资源可供选择,以满足你的应用程序开发需求。作为一名开发者,你可以专注于一个或几个类别,或者你可以选择在技术之间跳转。无论你选择什么,你所面临的挑战将非常不同,这取决于你目前正在开发的应用程序类型。

在下一章中,我们将更深入地探讨软件项目是什么以及我们如何随着项目规模的扩大来组织我们的代码。我们还将讨论一些与我们在项目增长过程中可能遇到的问题有关的一些细节,以及我们如何解决这些问题。

第四章:第四章:软件项目和我们的代码组织方式

当我们编写软件并且我们的程序增长时,我们需要组织我们的代码,以便在需要维护时易于阅读。一个应用程序可能有数万行或数百万行代码,所以将所有内容放在一个文件中是不可能的。我们需要将代码分成多个文件,但我们如何做到这一点?即使我们将代码放入单独的文件中,我们也会有大量的文件,所以我们需要将它们组织成文件夹。我们如何做到这一点,以便编译器或解释器能够找到它们?当我们需要编辑应用程序的一部分时,我们将知道在哪里寻找?在这一章中,我们将讨论这个问题,并了解我们可以使用的某些模式。

本章将涵盖以下主题:

  • 理解代码模块

  • 代码项目的概念

  • 使用包管理器共享代码

  • 深入了解命名空间

  • 使用命名空间来避免命名冲突

代码模块

如果程序被使用,它们也会更新,如果你是程序的开发者,这意味着你需要编辑源代码来添加功能和修复错误。如果你的代码组织得不好,它将很难阅读和维护,因为你需要更多的时间来找到插入新代码的地方或者那个讨厌的错误可能在哪个地方。

为了使你的代码更容易处理,一个措施是将它分成几个逻辑块。但我们如何决定什么会进入这样的块呢?没有固定的规则来决定如何做这件事,但你所使用的语言可能会给你一些提示,这取决于它希望你怎么结构化代码。最后,最终的决定权在你。

我们编写的代码在逻辑上是相互关联的,所以要做某件事,我们通常需要先做几件其他的事情。这就像你早上醒来,还在床上的时候,突然想起家里没有东西可以做早餐,所以首先你需要去购物。但在你能够去购物之前,你需要做几件其他的事情,比如起床和穿衣服。这些任务都是为了让你能够去购物。从某种意义上说,你可以认为这些事情是相关的,并且按照这种逻辑,它们属于一起。你也可以有不同的看法,比如说你想要早餐,但为了能够得到它,你需要起床,穿衣服,去购物,最后准备早餐。

同样,你的代码中也会有逻辑上属于一起的部分,其中一些事情是为了让你能够做其他事情。在决定如何将代码分成一些逻辑块时,我们需要记住这一点。

除了可读性之外,还有其他原因可以将代码与其他程序部分分开。你可能已经开发了一个你想要在其他程序中重用,甚至分发给别人以便他们也能使用的智能功能。为了能够做到这一点,定义这个智能功能的代码必须与程序的其他部分分开。这意味着它不能与其他代码部分紧密纠缠。如果是这样,那么重用这个程序部分将会很困难。

让我们来看一个例子。想象你正在编写一个应用程序,该应用程序会上线并从几个不同的网站收集数据,然后分析这些数据。这可能是股市价值或来自几个气象服务的温度读数。如果我们创建这个程序,我们需要定义我们的程序将要访问的网站,访问每个网站,下载并存储每个页面,然后遍历所有存储的页面以提取我们需要的资料。

总体来说,我们知道我们需要做什么,以及我们需要按什么顺序做事。我们还知道结果将是我们收集了所需的数据,这样我们就可以开始分析它了。这是好事,因为我们可以将这些事情视为独立的任务,它们应该尽可能独立于彼此。为什么它们独立如此重要?让我们进一步探讨这个应用程序的开发过程,以了解这一点。

如果我们从第一个任务开始,我们应该如何定义要访问的网站呢?我们是要求应用程序的用户通过在用户界面中写下它们来提供给我们地址,还是我们应该有一个文件,我们可以从中读取它们?这两种方法都可行,但我们不想做的是在代码中存储地址,即使这样做是可能的。这样做的原因是我们希望让我们的应用程序的用户能够定义他们要访问的网站。我们不能要求他们更改程序的代码,因为我们不能假设所有我们的用户都是程序员。然而,我们可以给他们一个可以编辑和保存的文本文件,然后我们可以读取这个文件来获取他们的输入。另一个原因是我们要保持灵活性。今天,读取地址可能是最佳选择,但将来,我们可能会发现另一种将地址输入到我们的应用程序的方法。我们不想将解决方案硬编码到我们的程序中,而是使程序尽可能独立于提供我们这些数据的数据源。

下一步我们需要做的是前往之前步骤中定义的每个网站并下载该页面。现在,假设我们已经编写了能够完成这一任务的代码。我们希望保持它与之前步骤的独立性;也就是说,程序的这一部分不应该关心网站地址是如何进入应用程序的。它应该被赋予一个下载该页面并返回页面数据的地址。仅此而已。它不知道这个页面的地址是如何来的,也不知道当它完成任务时页面数据发生了什么。这样,代码的一部分就可以在其他项目中重用,这些项目可能以其他方式接收其地址,并执行与下载页面完全不同的操作。

对于剩余的任务,即存储下载的页面和处理每个页面内的数据,我们努力做到同样的事情:将它们构建为代码的独立部分。以下图表展示了这个概念:

图 4.1 – 一个使用多个独立代码块来完成任务的应用程序

图 4.1 – 一个使用多个独立代码块来完成任务的应用程序

我们现在可以说我们已经有了独立的代码模块。术语模块的含义将根据我们使用的语言略有不同。然而,它们都会同意,它是一个独立的代码部分,尽管处理方式可能不同。一些语言表示每个模块将进入一个单独的文件,或者如果是一个大而复杂的模块,则进入多个文件。其他语言将提供在单个文件中定义多个模块的方法。在某些情况下,术语模块几乎不会被使用,即使独立代码部分的概念存在。

将代码视为独立模块的一个好处,正如我们之前所述,是能够在其他应用程序中重用模块。另一个原因是这将使我们更容易更改或替换模块。如果我们的代码部分相互交织并且高度依赖,那么更改代码部分将会更困难,因为我们需要在代码的多个位置进行这些更改,并且我们需要确保我们已经找到了所有需要更改的实例。

当我们将代码分解成这些较小的模块时,我们需要将所有这些内容组合成我们最终的应用程序。为此,我们需要将模块存储在项目中。

与软件项目合作

当谈到软件开发时,术语项目可以有两种不同的用法:

  • 一个用于开发实际程序的协作企业——换句话说,就是一群人共同工作。为此,我们需要一个项目计划、项目负责人等等。

  • 我们开发程序所组成的所有文件的容器。

我们将在这里讨论后一种含义——一个包含构成程序的文件容器——因为前一种含义是关于项目管理而不是软件开发。

当我们的代码被分解成定义良好的模块——很可能是几个文件的形式——我们需要一种方法让编译器或解释器找到所有文件,以便它们可以被组装成可执行的机器代码。

创建正确的项目结构通常是通过程序员用来开发软件的工具来完成的。这些工具分为许多类别,但最先进的形式被称为集成开发环境IDE)。IDE 的核心部分是用于编写代码的编辑器。它还将帮助我们创建软件项目。编程语言定义了项目应该如何组织。这可以是以不同文件相互关联的形式,例如,一些语言会通过称为软件包的东西来实现。

使用软件包共享代码

在软件开发者的文化中,免费共享代码是非常自然的。这使得该行业独一无二,因为程序员经常共享和使用彼此的代码。使用他人的解决方案来解决问题就像分享我的好想法和代码一样自然。通常,重用他人的工作是明智的,因为代码通常是经过良好开发、良好测试和良好维护的。在软件开发中,术语开源是众所周知的。这意味着有人有一个应用程序的想法,为它编写代码,然后在线分享它。其他人随后被鼓励帮助这个项目的开发。几个程序员会加入进来,他们一起保持项目的进行。任何感兴趣的人都可以免费使用这段代码。

在这样的项目中开发的代码通常以一个或多个模块的形式存在。如果你想使用这样的模块,你必须找到它,然后你需要下载它。问题是,我们如何找到它,下载它,并确保它被放置在一个位置,以便我们的应用程序可以找到它?

幸运的是,这个问题有一个解决方案:软件包管理器。

软件包管理器

软件包管理器是一种软件工具,它将帮助我们查找、下载和安装代码。大多数语言至少会有一个可以帮助我们完成这项任务的软件包管理器。它通过将代码模块,现在称为软件包,存储在中央位置来实现。这个中央存储被称为仓库,或简称 repo。这意味着当你编写一个程序时,你可以访问软件包管理器的网站来搜索可能对你的项目有用的任何软件包。

下面是一些流行语言的软件包管理器列表:

表 4.1

表 4.1

让我们看看我们下载网页的项目示例。您决定下载页面的编码部分感觉有点难以自己编写,所以您搜索您语言对应的包管理器网站,找到一个做您想要的事情的包,然后当给定一个地址时下载网页。

现在,您可以使用包管理器应用程序下载并安装此包,然后从您的代码中调用该包的功能。

假设我们使用 Python。即使我们知道我们可以去 PyPI 网站,我们可能也不确定要搜索什么。相反,我们可以通过 Google 搜索像“Python 下载网页”这样的内容,我们会找到几种如何做到这一点的建议。我们可能会偶然发现几个建议使用名为 requests 的东西。

如果我们决定想要尝试 requests 包并看看它对我们是否有用,我们可以访问 pypi.org 网站,并搜索 requests。然后我们会看到一个像这样的页面:

图 4.2 – pypi.org 上的 requests 项目页面

图 4.2 – pypi.org 上的 requests 项目页面

如果我们在这个页面上滚动,我们会找到安装说明,甚至还有如何使用的示例。还有一个链接到这个项目的网站,在那里我们可以找到文档和更多示例。

安装说明可能看起来像这样:

pip install requests

在这里,pip 是包管理器。当安装 Python 编程语言时,包管理器 pip 也会被安装。我们可以打开命令提示符(如果我们使用的是 Windows)或终端窗口(如果我们使用的是 Mac 或 Linux)并运行此命令。

包管理器将随后访问其在线中央仓库,下载请求的包,在我们的例子中是 requests。如果这个包正在使用其他包,我们不需要自己下载它们。包管理器会处理这个问题,并且我们需要的一切以便能够使用此包都将被下载和安装。

既然我们可以下载这个包,我们很快就会遇到另一个问题。我们必须确保,在代码中命名事物时,所有名称都是唯一的。如果不是,程序将无法运行。这可能会很棘手,尤其是在使用他人编写的包时。我们如何知道我们使用的名称尚未被使用?解决方案在命名空间中。

使用命名空间避免冲突

当你编写代码时,你将不断地命名事物。问题是,如果你给某个东西起了一个已经被使用的名字怎么办?我们现在知道一个应用程序的代码可以由数千行代码组成,分为数百个文件。你如何确保你给某个东西起的名字没有被占用?我们还了解到我们可以通过代码安装其他人编写的包。我们如何确保他们没有给他们的包起我们已经使用的名字?或者我们如何确保我们安装的包没有使用我们已安装的另一个包正在使用的名字?

如你所见,处理名称可能会很棘手。让我们看一个例子。在第八章,“理解函数”,我们将讨论函数是什么以及它是如何工作的。对于这个例子,我们只需要知道函数有一个名称,由几行代码组成。我们使用函数名称来调用它,这将使它内部的代码运行。

在这个例子中,我们正在构建一个计算器应用程序。首先,让我们看看这个应用程序可能的样子:

图 4.3 – 我们的计算器应用程序

图 4.3 – 我们的计算器应用程序

当用户点击平方根按钮时(()),我们必须计算当前显示中的数字的平方根。这意味着我们需要将一些代码与点击此按钮时发生的事件连接起来。这段代码需要执行以下步骤:

  1. 它需要获取当前显示中的值。

  2. 然后,它需要计算该值的平方根。

  3. 最后,它需要将结果放入计算器的显示中。

这些指令将在按下平方根按钮时执行,我们将它们放入一个函数中。现在,这个函数需要一个名字。名字squareroot相当长,所以你可能决定将其缩短为sqrt

当你到达想要计算平方根的点时,你很可能会使用一个内置函数来帮助我们完成这个任务。大多数语言都会有这样的函数,而且通常,它的名字会是sqrt。这是一个问题,因为我们给我们的函数起了一个与语言自带函数相同的名字。我们当然可以改名字。别灰心 – 命名空间将为我们解决这个问题。

深入了解命名空间

要理解命名空间是什么,我们可以将我们的计算机上的文件和文件夹想象一下。假设你即将举办两个聚会:一个是夏日聚会,另一个是生日聚会。你为这两个聚会写了两份邀请函,并试图将它们存储在电脑上的一个文件夹中。

你给这两个文件都命名为Party Invitation,但在同一个文件夹中你不能有两个同名文件:

图 4.4 – 同一个文件夹中的两个文件不能有相同的名称

图 4.4 – 同一个文件夹中的两个文件不能有相同的名称

而不是重命名一个文件,你可以创建两个文件夹,并将文件分别存储在每个文件夹中。这样,文件仍然可以命名为Party Invitation,并且由于它们位于不同的文件夹中,名称冲突不再存在,如下面的图示所示:

图 4.5 – 通过将文件存储在不同的文件夹中来避免命名冲突

图 4.5 – 通过将文件存储在不同的文件夹中来避免命名冲突

总结一下,在计算机上,我们可以有多个同名文件,但文件夹内的文件名必须是唯一的。

许多编程语言使用类似的技巧,称为命名空间。命名空间允许我们在应用程序中多次重用相同的名称,但在命名空间中,所有名称都必须是唯一的。命名空间就像计算机上的文件夹,而我们命名的对象就像文件。

命名空间是如何实现的,这将在不同的语言之间有所不同。让我们来看看一些更流行的语言是如何实现它们的。

JavaScript 中的命名空间

在 JavaScript 中,当我们定义诸如函数之类的对象时,我们可以通过将我们想要属于命名空间的部分用花括号{}包围来创建一个命名空间。

如果我们在名为myCalc的命名空间中代表我们的sqrt函数,它看起来可能像这样:

var myCalc = {
	sqrt: function() {
     }
}

在第一行,我们定义了一个名为myCalc的命名空间。我们使用一个开括号来表示命名空间的开始;最后一行的闭括号标志着命名空间的结束。

在括号内,我们找到一个名为sqrt的函数。该函数也使用开闭括号来表示其开始和结束的位置。在这个例子中,该函数是空的,所以里面没有内容。

现在,我们可以访问我们自己的函数和内置函数,即使它们都命名为sqrt。它可能看起来像这样:

myCalc.sqrt();
Math.sqrt(9);

第一行在myCalc命名空间中调用了sqrt函数。第二行调用了内置的sqrt函数。它位于名为Math的命名空间中。该函数将接受一个值(我们想要开平方的值),在这里,我们传递了值9

Python 中的命名空间

在 Python 中,命名空间是由单个模块定义的。Python 中的模块是一个文件,所以文件中的所有内容(即 Python 模块)都在同一个命名空间中。

现在,我们可以创建一个名为myCalc.py的文件。.py扩展名表示该文件包含 Python 代码。正是在这个文件中我们添加了我们的sqrt函数:

图 4.6 – Python 中的项目结构

图 4.6 – Python 中的项目结构

看看前面的图示。main_application.py文件是我们使用myCalc.py内部代码的主程序。

main_application.py文件内部,我们现在可以访问内置的sqrt函数和我们创建的sqrt函数,如下所示:

import math
import myCalc
myCalc.sqrt()
math.sqrt(9)

从前面的代码中,我们可以看到以下内容:

  1. 在第一行,我们表示我们想要能够使用math模块中的内容。记住,Python 使用模块是命名空间的概念,所以math既是模块也是命名空间。

  2. 第二行对我们包含sqrt函数的myCalc模块做了同样的事情。

  3. 在第三行,我们调用myCalc模块中的sqrt函数。

  4. 在最后一行,我们在math模块中调用sqrt函数,并将9传递给它。

C++中的命名空间

在 C++中,我们有一个关键字。一个关键字是语言保留的单词,具有特殊含义。请参阅第二章编程语言简介,以获取对关键字的更详细解释。在这里,我们有一个名为namespace的关键字,我们可以用它来定义一个命名空间。它可能看起来像这样:

namespace myCalc{
    void sqrt() {
    }
}

在这里,我们首先创建一个名为myCalc的命名空间,其中包含一个名为sqrt的函数。请注意,就像在 JavaScript 示例中一样,函数有一个开括号和一个闭括号,表示函数的开始和结束,就像那个例子一样,函数是空的。

C++使用特殊语法来访问命名空间内的内容。首先,我们声明我们想要使用的命名空间,然后是两个冒号::,然后是我们想要使用该命名空间内的内容。这可以看起来像这样:

myCalc::sqrt();
std::sqrt(9);

第一行调用我们之前定义的命名空间中的sqrt函数。

第二行调用标准命名空间中的sqrt函数,在 C++中称为std,并将值c传递给它。

其他语言中的命名空间

这些只是某些语言中命名空间使用的一些例子。其他语言有自己的变体。例如,在 Java 中,命名空间与包的使用紧密相关。在 C#中,命名空间几乎以与 C++相同的方式实现,但不是使用双冒号访问。相反,它们使用点.访问。

现在我们对命名空间有了一些了解,让我们回到我们的计算器应用程序。

在我们的计算器应用程序中使用命名空间

我们现在处于想要命名一个sqrt函数的阶段,我们已经意识到我们使用的语言也有一个名为sqrt的函数。这个内置的sqrt函数将计算我们传递给它的任何数字的平方根。我们不想重命名我们的函数;相反,我们想要通过使用命名空间来解决命名冲突。

我们首先需要做的是了解当前语言中命名空间的使用方式。正如我们之前看到的,定义和使用命名空间的方式将在不同语言之间有所不同。

通过在我们的命名空间内添加sqrt函数,我们不需要担心与内置的sqrt函数或其他可能通过包管理器导入包时获得的函数的命名冲突。所有内容都在不同的命名空间中定义,并且我们需要声明我们想要的函数位于哪个命名空间中。

我们已经知道我们的sqrt函数需要做什么:从显示中获取值,计算该值的平方根,然后将结果值放回显示中。

我们将把我们的sqrt函数添加到一个命名空间中,以避免与内置版本发生命名冲突。我们还想在函数内部使用内置的sqrt函数。我们可以通过指定内置sqrt所在的命名空间来实现这一点。

摘要

在本章中,重点是组织我们的代码以及如何命名事物,以便我们可以避免命名冲突。

一本书被分成章节是为了使其更容易阅读和导航。同样,我们希望我们的代码也易于阅读和理解。在编程中我们没有章节的概念,但我们有模块。模块是我们应用中代码逻辑相关的一部分;也就是说,以某种方式,它与代码一起工作。模块通常被定义为单独的代码文件。

在更大的项目中,我们可能会拥有大量的模块。因此,我们需要一种方法来组织它们,以便编译器或解释器在所有部件需要组合在一起时可以找到正确的文件。我们通过项目来完成这项工作。我们可以把项目看作是我们所有模块的容器,也是我们应用可能使用的其他资源的容器,例如图像、配置文件等。

编写程序是关于效率,将我们的时间和注意力集中在使程序完成预期任务的重要事情上。我们经常会遇到需要解决我们已知别人之前已经解决的问题的情况。因此,在我们的应用中重用别人的工作是好事,并不被认为是不好的。软件编程社区/行业非常开放和乐于助人,程序员经常从别人那里分享和重用代码。通过这样做,我们可以集中精力在我们应用独特的地方,而不是一遍又一遍地重新发明轮子。

我们可以利用他人编写的代码的一种方法是通过使用通常与语言一起提供的工具,称为包管理器。这些工具将存储我们可以在网上中央位置重复使用的代码。它们还将帮助我们找到我们需要的,并为我们下载、安装和配置它。

然而,随着我们的应用增长,我们使用其他人编写的代码时,我们需要一种避免命名冲突的方法。如果我为我应用中已经使用过的某个东西命名,语言必须有一种方法来区分这两个。这是通过称为命名空间的东西来完成的。我们给某个东西起的名字必须在命名空间内是唯一的。如果我们把我们的应用分成几个命名空间,我们将大大降低命名冲突的风险。

现在,我们终于准备好深入探究编写程序的过程了。在下一章中,我们将探讨任何程序在序列方面的最基本构建模块。

第二部分:编程语言的构造

在本节中,我们将探讨任何主流编程语言所包含的所有关键要素,包括它们的工作原理以及何时使用它们。

本节包含以下章节:

  • 第五章, 序列 – 计算机程序的基本构建块

  • 第六章, 数据处理 – 变量

  • 第七章, 程序控制结构

  • 第八章, 理解函数

  • 第九章, 当事情出错时 – 错误和异常

  • 第十章, 编程范式

  • 第十一章, 编程工具和方法论

第五章:第五章:序列 - 计算机程序的基本构建块

当谈到编程时,最基本的概念是序列。它表示我们做什么以及我们何时依次进行。然而,当我们仔细观察时,我们发现这不仅仅是这样,在本章中,我们将学习它是什么。

我们还将使用序列的概念来决定程序需要执行哪些步骤才能完成其总体任务。由于同时拥有对需要完成的所有事情的概述和查看所有细节可能很困难,我们需要一个概念来帮助我们。例如,考虑程序需要按什么顺序做事,这可能是一个工具。

当学习如何编程时,许多人面临的一个问题是如何将一个想法转化为行动。你应该从哪里开始?在本章中,我们将学习我们可以使用顺序思维的概念将一个想法分解成更小的任务,然后我们可以处理这些任务。我们还将看到我们可以将同样的概念应用到我们编写的代码中,以确保我们按正确的顺序做事。

在本章中,你将了解以下主题:

  • 理解序列的重要性

  • 语句是什么以及它是如何定义的

  • 不同的语句是如何分隔的

  • 如何格式化代码以使其更易于阅读

  • 不同的注释类型和记录代码的方法

序列的重要性

有一天,当你回到家,你开始渴望一块派,于是你决定自己做一块。你烘焙派的原因并不是因为你喜欢烹饪,而是为了满足你对派的渴望。然而,为了能够得到派,你需要执行几个步骤。首先,你需要一个食谱,然后你需要收集所有的原料。当你收集到它们后,你将按照食谱中的每个步骤进行。最后,当派在烤箱里烤了一段时间并稍微冷却后,你就可以享受你应得的美食了。

你刚刚按顺序完成了一些任务。其中一些任务需要按正确的顺序完成,而其他任务则可以按任何顺序(或者至少可以更轻松地按顺序)完成。你必须先打开烤箱,然后才能烤派,但你在拿出面粉之前拿出黄油并不是必要的。

编程就像烘焙派。我们将有目标,比如我们想要派,为了能够实现这个目标,我们需要做几件事情:在某些情况下,顺序是重要的,而在其他情况下,顺序则不那么重要。

编程全部关于问题解决和将事物分解成更小步骤的艺术。这将在迭代中进行,你首先有一个总体解决方案,然后将这个解决方案分解成越来越小的步骤,直到你达到一个理解每个需要采取的步骤的水平。

这是在你开始学习编程时需要掌握的更困难技能之一。与所有新技能一样,在感到舒适地做这件事之前,你需要大量的练习。一些技巧可以使你更容易获得这项技能。你需要的基本能力是对你试图解决的整个问题的概述,同时关注一个或多个需要解决的子问题的细节。这意味着你需要在保持整个问题整体观的前提下,对细节进行放大和缩小。你可以通过不进行任何编程来练习这一点。玩逻辑谜题游戏,如数独,可以训练你的大脑在关注整体游戏的同时,专注于游戏的各个部分,例如哪些数字可以放入某个单元格。

让我们看看我们如何可以从问题到解决方案在顺序层面上进行。首先,我们需要定义问题。

定义问题

你经常在黄昏后回到家,而且你总是忘记在离开家之前打开户外照明。外面太黑了,你害怕在去门的路途中绊倒,而且找到钥匙孔总像是一场赌博。

另一方面,你不想整日开着灯。你也不喜欢那些对动作做出反应的自动灯,因为当邻居的猫经过时,它们会被激活。肯定有更好的解决方案。

解决问题的方案

一个解决方案是,如果你能使用你的智能手机的 GPS。如果你在上面有一个持续监控你位置的程序,那么当你从家进入一个给定半径时,它就能以某种方式激活灯光。你很快就会意识到你的解决方案将需要是一个两部分的程序。一部分运行在你的手机上,另一部分运行在你家中的电脑上。运行在你家电脑上的程序会从你的移动应用中接收到信号,表明你现在离家很近。然后它可以与在线服务核对,看看在这一年中,太阳在此时此刻在你的家所在位置是否已经落山。如果是日落之后,程序就可以激活灯光。

我们到目前为止所做的是开始定义一个解决方案。现在我们必须进一步分解这个解决方案。

解决方案分解

我们现在对想要实现的目标有了总体了解,同时我们也关注了一些细节。我们决定想要使用手机的 GPS。我们目前还不知道如何实现这一点,但在这个阶段这并不重要。我们知道其他应用程序可以使用 GPS。这意味着我们知道这是可行的,即使我们现在还没有头绪,我们可以在后续过程中学习。我们还决定这将是一个两部分的程序。手机部分除了监控 GPS 外,还需要一种方式来联系我们解决方案的另一部分,即在你家电脑上运行的应用程序。同样,我们不知道如何实现这一点,但我们知道这是可行的,这对现在来说已经足够好了。我们还理解我们需要一种方式来检测我们是否从定义的范围外进入。只有在这种情况下,灯才应该打开。如果我们不这样做,当手机在规定的范围内时,灯将始终是开着的,而我们不希望这样。

家庭应用程序将需要等待来自移动应用程序的信号,然后联系一个可以告诉我们太阳何时落下的服务。在我们的待办事项列表中,我们添加了我们需要找到这样的服务。同样,我们不需要担心如何实现这一点。我们还将找到一种方式让我们的家庭应用程序控制灯光。我们很可能需要一些硬件来完成这一步骤,但那将是我们可以稍后解决的问题。

我们现在有一个需要采取的步骤的顺序列表。这个列表看起来像这样。

下面是移动应用程序的步骤列表:

  • 手机是否在预定义的范围内?

  • 如果是,它之前是否在范围外,也就是说,我们刚刚进入这个范围?让我们检查:

图 5.1:手机进入预定义的区域

图 5.1:手机进入预定义的区域

  • 如果是,向家庭应用程序发送信号:

图 5.2:手机应用与家庭电脑上运行的应用程序进行联系

图 5.2:手机应用与家庭电脑上运行的应用程序进行联系

下面是家庭应用程序的步骤列表:

  1. 等待来自移动应用程序的信号。

  2. 当接收到信号时,检查当前时间:图 5.3:运行在家庭电脑上的应用程序检查当地时间

    图 5.3:运行在家庭电脑上的应用程序检查当地时间

  3. 检查在线服务以获取当地日落时间:图 5.4:运行在家庭电脑上的应用程序联系    获取当地日落时间的在线服务

    图 5.4:运行在家庭电脑上的应用程序联系在线服务以获取当地日落时间

  4. 如果时间是在日落之后,打开灯光:

图 5.5:如果时间在日落之后,家庭应用程序会打开灯光

图 5.5:如果时间在日落之后,家庭应用程序会打开灯光

这仍然是对我们需要采取的顺序步骤的一个稍微粗糙的分解。但现在我们已经有了它们,我们可以深入到每个步骤中,并思考我们需要为每个前面的步骤做些什么。

手机应用程序的详细分解

让我们逐个步骤地来看,并仔细检查我们需要为每个步骤做些什么。

那么,我们如何知道手机是否在预定义的范围内呢?为了理解这一点,我们首先必须做一些研究。我们需要首先理解的是手机 GPS 是如何知道它所在的位置的。在网上搜索很快就会揭示 GPS 是使用两个坐标:经度和纬度。这两个坐标可以精确地定位地球上的任何位置。这意味着当我们从 GPS 请求当前位置时,我们会得到这些坐标。

当我们有了这些坐标后,我们需要检查我们离家的距离有多远。为此,我们需要知道家的位置,因此手机上的应用程序需要以某种方式存储这个位置,并且它将以经度和纬度的形式存在。

计算两个地理坐标之间的距离不是一项简单的工作,但我们不需要担心,因为这是以前已经多次做过的事情。此外,无论我们使用什么语言,简单的谷歌搜索都会给我们提供许多如何做到这一点的解决方案。我们甚至可能能够使用语言包管理器(参考第四章软件项目和我们的代码组织方式,了解什么是包管理器以及它是如何工作的)来找到一个可以进行这种计算的包。

无论我们使用什么解决方案,我们都可以假设我们将使用我们拥有的两个坐标对,即手机的位置和我们的家位置,我们得到的是它们之间的距离。我们还需要决定我们需要离家多近才能激活灯光。这可以是任何距离。

接下来,我们必须检查我们是否是从预定义的范围外进来的。我们需要一种方法来判断我们是进入了预定义的范围,还是我们已经在范围内。我们可以通过跟踪在检查当前位置之前家的距离来做这件事。如果我们之前在范围外而现在在范围内,那么我们知道我们已经足够接近家,可以发送信号打开灯光。

因此,现在我们需要向家庭应用程序发送一个信号。当我们进入范围时,我们需要一种方法让手机应用程序联系家庭电脑。这可以通过几种方式完成,我们目前不需要决定使用哪种技术。

现在,我们可以总结出手机应用程序的顺序。

手机应用程序顺序

现在我们已经为手机应用建立了逻辑。我们还知道序列将需要看起来像什么。它看起来可能像这样:

  1. 获取当前位置。

  2. 计算从家的距离。

  3. 这个距离是否在我们定义的足够近以打开灯的范围之内?

  4. 我们之前测量的距离,即我们上次检查位置的距离,是否在给定的范围之外?

  5. 如果步骤 3步骤 4的答案都是是,告诉家用电脑打开灯。如果答案是 否,我们可以回到步骤 1

  6. 将从家的距离存储为我们旧的距离。

  7. 步骤 1重新开始。

这些步骤在这里进行了说明:

图 5.6:电话应用的序列

图 5.6:电话应用的序列

这七个步骤将反复进行。当我们执行第一次迭代时,我们需要考虑会发生什么,因为我们不会有旧距离的值。我们可以给它一个初始默认值,这个值远远超出范围,例如,一个负数。

现在我们有一个明确的步骤序列,我们还没有写一行代码。这是好的,因为它将帮助我们开始编写代码,我们可以放大并专注于这些步骤中的每一个,而不会失去对概览的把握。

现在,我们可以将注意力转向家用应用。

家用应用的详细分解

起初,家用应用将没有任何事情可做,因为它将只是坐着等待来自手机应用的信号。然而,当它收到那个信号时,它会醒来并开始工作。

当家用应用收到来自手机的消息时,它需要做的第一件事是两件事之一。要么它可以检查当地时间,要么它可以联系日落服务以获取当前的日落时间。这两个操作的顺序不是必要的,因为我们都需要它们,所以我们可以比较它们。

当我们有了当地时间以及日落时间,我们需要比较它们以确定是否是晚上。如果是,打开灯的所有条件都满足,我们会收到一个信号,表明手机在范围内,外面是黑暗的。

现在我们需要打开外面的灯。我们仍然需要弄清楚如何做到这一点。一种方法是可以使用无线控制的 LED 灯。这些灯通常可以通过我们即将创建的应用程序等应用程序进行交互。在购买灯之前,我们应该做一些研究,因为我们需要选择一个可以被我们的应用程序控制的品牌。

家用应用序列

家用应用的序列如下:

  1. 等待来自手机应用的信号。

  2. 当收到信号时,获取当地时间。

  3. 联系日落服务以获取当地时间。

  4. 将当前时间与日落时间进行比较。

  5. 如果是日落之后,打开灯。

  6. 返回到步骤 1。

第 2 步和第 3 步可以按任何顺序执行。如果当前时间早于日落时间,应用程序将跳过打开灯光并返回第 1 步。这个顺序在这里表示:

图 5.7:家庭应用程序的顺序

图 5.7:家庭应用程序的顺序

现在我们已经有一个很好的起点,关于如何构建这个应用程序。即使我们仍然对一些事情不太清楚,比如手机应用程序应该如何联系家庭应用程序,家庭应用程序将使用什么服务来检查日落时间以及它将如何与之联系,以及家庭应用程序将如何打开灯光,但不同事情必须执行的顺序现在对我们来说已经很清晰了。

在这一点上,我们已经对我们的应用程序需要做什么有了很好的了解,但我们仍然有许多事情要做。首先,我们需要学习创建此应用程序所需的知识。我们如何从手机 GPS 获取位置?在学习这类事情时,通常一个好的想法是创建一个玩具项目,你只需尝试获取坐标并将它们打印到屏幕上。当你弄清楚这一点后,你就可以将这个解决方案应用到你的实际项目中。在单独的项目中这样做是聪明的,因为你可以一次专注于学习一件特别的事情。另一个例子是我们需要实现一种计算两个地理坐标之间距离的方法。在测试不同的解决方案时,应用同样的原则在单独的应用程序中执行。当你得到结果时,你应该找到一个提供这种计算服务的在线服务,以便你可以验证你的结果。在编程中,你永远不会停止学习新事物和技术。总有你从未做过的事情。但不要让这阻止你。

这是计算机程序中序列的一个方面。另一个点是更小层面上发生的事情。在我们编写的代码中,我们执行的每个步骤都需要分解成称为语句的东西,而这些语句将按顺序执行。

理解语句

在许多编程语言中,我们编写的代码序列由被称为语句的东西组成。语句表达了一些要执行的操作,并由几个内部组件组成。这些被称为表达式。

表达式

语句由表达式组成,而表达式由更小的部分组成。让我们参考一个例子:

5 + 4

这是一个由三个部分组成的表达式。在这里,我们使用加法运算符+进行操作。在运算符的两侧,我们有形式为两个常量值的操作数,54

一个语句可以由多个表达式组成。看看下面的代码:

result = 5 + 4

这里,我们有一个与之前相同的表达式,5 + 4,但现在左侧有一个新的表达式:

图 5.8:包含两个表达式的语句,一个是加法,另一个是赋值

图 5.8:包含两个表达式(一个加法和一个赋值)的语句

再次,我们有两个操作数,右侧的加法结果9,左侧有一个称为变量的东西,名为result。变量是我们使用我们定义的名称在内存中存储数据的一种方式:

图 5.9:当计算加法的结果时,可以执行赋值表达式

图 5.9:当计算加法的结果时,可以执行赋值表达式

我们将在第六章中更多地讨论变量,使用数据 – 变量。我们还介绍了一个第二个运算符,=。这个运算符被称为赋值运算符。它将右侧的内容存储在左侧:

图 5.10:名为的变量可以表示为一个框;赋值将一个值存储在那个框中

图 5.10:名为result的变量可以表示为一个框;赋值将一个值存储在那个框中

要完成这个陈述,必须按照正确的顺序处理表达式。这被称为运算顺序。

运算顺序

要将加法的结果存储在名为result的变量中,我们必须首先执行右侧的表达式;即加法,如下所示:

result = 5 + 4
           9

当右侧的表达式完成时,我们可以想象我们的语句将看起来像这样:

result = 9

现在,这个最终表达式可以执行。9的值被分配(存储)在result变量中。

一个陈述可以由多个表达式组成。考虑以下内容:

result = 5 + 4 * 2

在这里,我们首先必须理解的是运算顺序,即加法和乘法执行的顺序。如果我们先做加法,我们会做5 + 4,结果是 9,然后 9 乘以2,结果是 18。

如果我们首先执行乘法,我们会得到4乘以2,结果是 8,然后5 + 8,结果是 13。所以,顺序很重要。每种编程语言都有一个明确的运算顺序,即操作必须执行的不同顺序。

在这个例子中,顺序将与数学中的顺序相同;乘法将在加法之前执行:

result = 5 + 4 * 2
               8
result = 5 + 8
           13
result = 13

如果我们想要覆盖内置的运算顺序,我们可以使用括号,如下所示:

result = (5 + 4) * 2

现在先执行加法,因为它是括号内的,所以变量result现在将存储 18 的值(9 乘以 2)。

有些陈述由多于一行组成。这些通常被称为复合语句。接下来让我们探索一下这些是什么。

复合语句

复合语句是跨越多行的语句。这些复合语句由一个或多个普通的单行语句组成。它们也可以由其他复合语句组成,正如我们稍后将要学习的。例如,在我们的打开灯的应用程序中,我们在做某事之前必须满足一些条件。一个条件是,只有当当前时间在日落之后,我们才打开灯。这种逻辑可以用流程图来表示:

图 5.11.家庭应用程序的流程图

图 5.11.家庭应用程序的流程图

如您所见,我们打开灯的部分只有在日落之后才会执行。因此,为了执行这个语句,我们必须满足一个条件。这个条件是一个语句,但这个语句包括打开灯的语句。

在代码中,它可能看起来像这样:

wait_for_phone_signal()
current_time = get_current_time()
sunset_time = get_sunset_time()
if current_time > sunset_time then
    turn_on_light()
end_if

上述代码的第一行包含一个单独的语句。它是一个函数调用。我们将在第八章中更详细地讨论函数,理解函数。我们可以看到它是一个函数,因为它以括号结束。现在,我们可以将这些括号视为表示某物是一个函数的指示。调用函数意味着,在某个地方,有若干行代码被赋予了一个名字。调用函数意味着我们将跳转到那个位置并执行那些行。在这个例子中,函数的内容是不可见的。这个函数将使程序停止,直到手机接收到信号。

第二行的语句由一个操作组成,包含一个操作符和两个操作数。我们认出等号是赋值操作符。这意味着右边的任何内容都将被分配(记住,我们可以看到赋值,因为右边的将存储在左边的项中)到左边。在左边,我们有一个可以包含值的变量。它有一个名为current_time的名字。右边的是另一个函数调用。这个函数将获取当前时间并返回它。当这个语句执行后,当前时间将被存储在current_time变量中。

第三行类似于第二行。这个陈述也是由一个操作符和两个操作数组成的。右边的操作数再次是一个函数调用。这是将联系在线日落服务的函数,并为我们所在位置返回日落时间的函数。在左边,我们有一个名为sunset_time的变量,我们将从函数返回的时间赋值给这个变量。当这个语句执行后,我们将日落时间存储在sunset_time变量中。

第 4 行以if开始。这是一种特殊的语句,跨越多行。这个例子涵盖了第 4 到 6 行。它包含另一个语句,即第 5 行的语句。第 4 行是一个条件。它检查current_time变量中的值是否大于sunset_time变量中的值。这可能为真或为假。如果是大于,即语句为真,那么这个复合语句内部的代码将被执行。如果是假的,current_time中的值不大于sunset_time中的值;该语句内部的代码将不会执行。在这种情况下,复合语句的结束是最后一行:end_if。它表示从以if开头的行到end_if行之间的所有内容都是该语句的一部分。需要注意的是,逻辑上存在一个缺陷,因为这个条件在当前时间是午夜之后将不会工作,但现在我们先忽略这个问题。

第 5 行开启灯的语句再次是一个函数调用。此外,请注意,这一行以一些空格开头。这被称为缩进,我们将在本章后面更详细地讨论它。

我们现在知道,语句由表达式或其他语句组成。为了能够确定一个语句的结束和另一个语句的开始,一种语言将定义它们是如何分隔的。如何实现这将是语言语法的一部分。让我们接下来探索我们如何做到这一点。

分隔语句

一种编程语言将通过定义语句结束的位置来分隔语句。如果语言能够确定一个语句的结束位置,它也知道紧随其后的是什么必须是从另一个语句的开始。

语言有不同的方式来定义这一点。如果我们比较不同语言如何终止语句,我们会看到我们有三种主要的方式来实现它。许多语言将通过插入新行来终止语句。这意味着,一般来说,如果不是复合语句,每一行都是一个单独的语句,因为它需要被独特地处理。

另一种流行的终止语句的方式是使用分号。对于使用这种技术的语言,我们可以在单行上有多个语句。语言知道一个语句的结束是在它看到分号的那一刻。

第三个变体是使用句点.而不是分号。除此之外,它的工作方式与使用分号时相同,这样我们就可以在单行上有多个语句。

A few languages will use other techniques, such as using a colon instead of a semicolon.

一些以新行终止语句的语言包括以下:

  • BASIC

  • Fortran

  • Python

  • Ruby

  • Visual Basic

一些以分号结束语句的语言包括以下:

  • C

  • C++

  • C#

  • Go(即使编译器会自动插入它们)

  • Java

  • JavaScript

  • Kotlin

  • Pascal

  • PHP

  • Rust

一些使用其他符号来终止语句的语言包括以下:

  • ABAP(句点)

  • COBOL(空白字符,如空格、制表符或换行符;有时,句号)

  • Erlang(句号、逗号和分号)

  • Lua(空白字符,如空格、制表符或换行符)

  • Prolog(逗号、分号和句号)

对于复合语句,我们需要一种方法来定义它们的开始和结束位置。由于复合语句由一个或多个语句组成,许多语言将使用另一种方法来终止它们。

在这里,我们将找到语言使用的主要三种技术。一种是使用花括号 {} 来指示复合语句的开始和结束位置。括号之间放置的所有内容都被认为是复合语句的一部分。

在这样的语言中,我们之前代码中看到的复合 if 语句应该看起来像这样:

if current_time > sunset_time {
    turn_on_light()
}

如你所见,第一行的末尾有一个开括号,最后一行有一个闭括号。

使用这种技术的语言示例包括以下:

  • C

  • C++

  • C#

  • Go

  • Java

  • JavaScript

  • PHP

另一种实现方式是使用结束语句。不同的语言对此会有细微的差别。

这里有一些例子。

在 Ada 编程语言中,一个 if 语句看起来是这样的:

if current_time > sunset_time then
    turn_on_light();
end if

在 Modula-2 中,同样的语句只是略有不同:

IF current_time > sunset_time THEN
    turn_on_light();
END;

Ruby 有另一种与此类似的变体:

if current_time > sunset_time 
    turn_on_light()
end 

最后一种变体是使用缩进来完成同样功能的语言。记住,缩进是我们使用空格来推入代码的时候。

如果你查看所有前面的例子,包含 turn_on_light 的行总是缩进的。在这些语言中,这仅仅是因为它使代码对我们人类来说更容易阅读。然而,一些语言会使用这种方法来定义复合语句的开始和结束位置。

其中一种语言是 Python。Python 的代码示例应该看起来像这样:

if current_time > sunset_time: 
    turn_on_light()

在这里,所有缩进的都包含在复合语句中。为了表明随后出现的内容不是语句的一部分,它应该与 if 语句在同一级别上书写,如下所示:

if current_time > sunset_time: 
    turn_on_light()
result = 4 + 5

在这里,最后一行不是复合语句的一部分,因为它没有缩进。

如前所述,缩进可以用来使代码更容易阅读,而一些语言会使用它来终止复合语句。让我们继续讨论代码可读性的重要性以及我们如何使用缩进和空行来提高它。

通过缩进和使用空行使代码可读

自从我们远离机器代码以来,我们的动机就是希望代码对人类来说更容易阅读和编写。我们在编写代码时应该记住这一点,因为代码不仅仅是对计算机的指令,还需要我们或其他人维护。

正如我们所学的,我们可以使用缩进来使代码的意图对阅读者更加清晰的一个工具是缩进。

缩进技术

缩进是我们用来显示某些代码行在代码块中属于一起的技术。这通常用于复合语句。由于复合语句可以由其他语句或其他复合语句构成,因此缩进对于能够看到语句属于哪个代码块变得至关重要:

图 5.12:缩进代码将是一个视觉辅助工具,表明复合语句是如何构建的

图 5.12:缩进代码将是一个视觉辅助工具,表明复合语句是如何构建的

即使前面的图中的线条只是线条,我们也能辨认出它们所代表的单个组件。如果我们像图 5.13 中那样给它们上色,我们可以看到这一页由四个语句组成。第一行是一个单行语句,然后我们有绿色线条:这些是复合语句,从第 2 行开始。再次,紫色线条是复合语句,最后我们有一个单行语句组成的一行。

虚线表示复合语句,由单行语句和复合语句组成,如缩进所示。点线也是同样的情况:

图 5.13:虚线表示包含另一个复合语句的复合语句;同样适用于点线

图 5.13:虚线表示包含另一个复合语句的复合语句;同样适用于点线

正如您所看到的,如果我们知道如何解释它,缩进将向读者传达大量信息。因此,在编写代码时,我们务必小心地正确处理缩进。在大多数语言中,这种信息只对人类读者有趣。编译器或解释器会忽略缩进。但有些语言,例如 Python,使用缩进来定义复合语句,这使得缩进成为强制性的,并且是语言语法的一部分。

通常,程序员用来编写代码的文本编辑器会帮助进行代码缩进,要么通过自动缩进复合语句内的代码,要么通过提供内置命令,当执行时,将适当地格式化代码。

我们还可以使用另一种格式化技巧来使我们的代码对人类读者更易读。其中一个技巧就是使用空行。

空行

空行分隔本书中的段落。原因很明显。没有它们,文本将难以阅读。这些空行不是随机插入的。段落内的文本在逻辑上是相连的。我们可以通过创建一个新的段落来表示文本的焦点变化,即在文本中插入一个空行。

同样的情况也适用于代码。空行是为了人类读者插入的。它用于显示程序员的目的。如果三个语句在逻辑上以某种方式相连,我们可以在最后一个语句后添加一个空行来表示这一点。

以下是一些 Python 代码。了解这段代码的功能并不重要,但请观察缩进和空行的使用:

for n in range(1000,3001):
    str_num = str(n)
    split_str = list(str_num)
    all_even = True
    for x in split_str:
        if int(x) % 2 != 0:
            all_even = False
    if all_even:
        print(n, ",", end = "")

在这里,我们可以看到所有代码都在一个单独的复合语句中,除了第一行之外的所有行都进行了缩进。向下看,我们可以看到其他复合语句。每当缩进级别增加时,一个新的复合语句开始,当它减少时,一个结束。

同时也有一些空行。这表明空行前后的行之间存在逻辑联系。

让我们看看第 2 行和第 3 行以及它们是如何连接起来的。在第 2 行,我们准备了一些数据,以便它以正确的形式用于第 3 行的操作。在这里,我们可以看到序列的重要性。第 3 行需要一个特定格式的数字。因此,第 2 行准备了数字,以便在第 3 行使用。

当这两行完成时,程序会稍微改变其焦点。即使我们不了解代码的功能,缩进和空行所携带的信息将给我们提供线索,使代码更容易阅读。

空行也是编译器和解释器会忽略的内容,因此它们是为了我们人类而存在的。正如正确使用文本中的段落至关重要一样,我们必须以有意义的方式使用空行,以帮助我们的代码读者。

另有一个工具我们可以用来使我们的代码更容易被人阅读理解,那就是注释。

使用注释使代码可理解

缩进和空行并不总是足以使代码的意图清晰。在这种情况下,我们可以使用注释。注释是一行普通文本,插入到代码中,仅供人类阅读。我们使用它们来解释那些一眼看去并不明显易懂的代码行。

由于编译器或解释器会忽略注释,我们需要一种方式来指示某物是一个注释。我们有两种注释的变体;一种是从行尾开始,称为行注释,另一种是跨越多行,通常称为块注释。让我们依次了解以下内容。

行注释

行注释有一些符号表示注释的开始,并继续到该行的其余部分。这些可以插入到单独的一行或代码的末尾,该代码将执行。

作为语言语法的一部分,用于指示这些注释开始的符号是定义好的。有些是通用的,尽管某些语言可能会使用更不常见的变体。

许多语言中最常用的符号是双斜杠//,它由两个没有空格的斜杠组成。即使这个符号由两个字符组成,它也被视为一个单独的符号。

使用此方法进行行注释的语言包括以下内容:

  • C

  • C++

  • C#

  • Go

  • Java

  • JavaScript

  • Kotlin

  • PHP

许多语言使用的另一个常见符号是哈希符号 #。尽管这是一个不同的符号,但它的处理方式与双斜杠相同。

哈希符号被以下语言等用于行注释:

  • Perl

  • Python

  • Ruby

一些语言有其他表示注释的方法。在 BASIC 中,缩写为 '

Ada 和 Lua 是两种使用双破折号 -- 来表示行注释的语言。

Haskell 也使用这种方法。Haskell 还有另一种处理注释的独特方式,称为 >。所有其他行都被视为注释。

Pascal 以及与 Pascal 密切相关的语言,如 Modula-2,使用 (* 来表示注释的开始,并用 *) 来标记结束。

与 Lisp 相关的语言,如 Common Lisp 和 Clojure,使用分号 ; 来表示行注释。

使用行注释可能看起来像这样:

result = 4 + 5 //Adds 4 and 5 and stores the sum

第一部分是正常执行的代码,但一旦编译器或解释器看到注释符号 //,它就会忽略这一行的其余部分。

使用哈希符号作为注释的语言中的相同代码将看起来像这样:

result = 4 + 5 # Adds 4 and 5 and stores the sum

这些注释也可以单独一行,如下所示:

// Adds 4 and 5 and stores the sum
result = 4 + 5 

或者,注释也可以这样出现:

# Adds 4 and 5 and stores the sum
result = 4 + 5 

这种分隔将取决于语言。

有时,我们希望注释跨越多行。而不是在注释的每一行开始处都有行注释符号,我们可以使用块注释。

块注释

块注释有一个符号表示注释的开始,另一个符号表示注释的结束。

使用双斜杠符号作为行注释的语言通常会使用 /* 来表示块注释的开始,并用 */ 来表示结束。包括以下语言在内的这些符号包括:

  • C

  • C++

  • C#

  • Go

  • Java

  • JavaScript

  • PHP

Perl 使用 =begin 来表示块注释的开始,并用 =cut 来标记结束。

Ruby 有一个类似的概念,使用 =begin=end 分别标记开始和结束。

Python 没有块注释,而是使用 '''""" 这样的东西。它们以开始时使用的相同三个字符结束。所以,如果你在开始时使用了单引号,就使用三个单引号来结束注释;否则,使用三个双引号。

如果我们使用 /**/,代码中的块注释可能看起来像这样:

/* Program that receives a signal
   from a mobile phone, checks the local time,
   and sunset time and if it is after sunset
   turns on the outside light. */
wait_for_phone_signal()
current_time = get_time()
sunset_time = get_sunset_time()
if current_time > sunset_time then
    turn_on_light()
end_if

程序员也可以在注释中使用几个常用的标签来表示某些内容。

标签

有时,程序员想要使用标签标记代码中的某个部分,以便容易找到这个位置。这些标签是非正式的,但其中一些是常用的:

  • BUG:代码中已知需要修正的错误。

  • FIXME:已知需要修正的错误或其他必须修正的内容。

  • HACK:标记一个结构不良或编写不优的代码部分,应该重写。

  • TODO:标记一个稍后将要插入的位置。

这些标签写在注释中,主要用于使它们可以通过像 Unix grep 工具(一种在文本文件中进行搜索的工具)这样的工具找到。一些编程编辑器甚至会突出显示这些标签中的一些。

注释也可以用来移除代码。

注释代码

有时,程序员需要删除几行代码来尝试某事或隔离一个错误。而不是删除这些行,他们可以用行注释或块注释来注释掉这些行。这样,在需要再次使代码生效时,很容易移除注释符号。

如果我们在重写代码部分,这个技术也可以使用。在这种情况下,当我们在构建新代码时,保留旧代码作为参考是一个好主意,这样我们就不会忘记需要做的事情。我们可以将旧代码放在注释中;然而,当我们完成时,我们应该移除旧的注释代码,因为它会在阅读代码时分散我们的注意力。

编译器或解释器将忽略大多数注释,但并非全部。一些注释可能具有特殊的指令意义。

指令注释

有一些例子表明,注释可以为语言编译器或解释器提供意义。

一些语言,如 Python,如果我们在一个 Unix 系统上使用它,并在代码文件的第一行使用一个看起来像注释的符号,就可以这样做。这个符号被称为shebang,由一个井号符号后跟一个感叹号组成,即#!

这被用作指令,不是给其他人类,而是给 Python 解释器,以便它知道应该使用哪个版本的 Python 来解释这个程序。

Python 还有一个叫做魔法注释的东西,它标识了源代码文件所使用的字符编码。它以# -*- coding:开始,以-*-结束。

一个同时使用指令的 Python 程序可以以两行看起来像这样的代码开始:

#!/usr/bin/env python3
# -*- coding: UTF-8 -*-

由于 Python 使用#作为行注释的符号,它们可能看起来像是两个普通的注释。在阅读代码时,这可能会有些令人困惑,我们应该意识到这些是给解释器的指令,而不是给人类的。

如您所见,注释可以用几种不同的方式使用。让我们更详细地看看一些典型的用法。

利用注释

如何最佳地使用注释是一个有争议的话题,通常你会得到相互矛盾的建议。在我们讨论人们对注释的一些看法之前,让我们先考察一些典型的用法。

规划和审查

注释可以在编写代码之前用来规划代码的结构。当编写应用程序的复杂部分时,有些人喜欢在专注于真实代码之前,用注释来绘制逻辑图。

代码描述

注释的一个日常用途是总结代码部分的功能并解释程序员的意图。你不应该使用注释来用英语重写代码的功能。如果你觉得你需要这样做,那么代码可能过于复杂,应该进行修改。

算法描述

有时,我们会实现需要用文本和图表进行解释的复杂算法。这通常是理解代码所需的基本知识。这些解释有时可能相当广泛。以下是一个示例。这个注释是从 Python 语言本身的一部分取出的。如果我们计算那个模块中注释的行数,我们会看到它们超过了 77%:

![图 5.14:描述 Python 编程语言中 process.py 模块的注释示例]

Python 编程语言

图 5.14:描述 Python 编程语言中 process.py 模块的注释示例

Python 语言是开源的,所以任何人都可以查看或下载它的代码。如果你想看看编程语言的代码是什么样的,你可以在github.com/python/cpython查看。

注释争议

关于如何以及何时使用注释的争论永无止境。有些人认为代码应该使用很少的注释,认为源代码应该以自解释或自文档化的方式编写。这种观点的动机是,如果你了解一种语言,你应该能够通过阅读代码来理解意图。如果你做不到,代码就需要重写。

另一些人可能会争论,代码应该像前图所示那样进行广泛注释。

在这些观点之间,你会发现一些人认为注释本身既不有益也不有害,应该在使用时谨慎,只有在它们提供额外价值时才使用。

因此,总结一下,在本节中,我们了解到注释是解释代码的工具。但它们也用于文档。此外,标签可以用来记录未来需要处理的事情。

概述

在本章中,我们讨论了编程中最基本的概念:序列。

顺序思维可以帮助我们组织思想,以便我们可以将它们分解成更小的部分。正如我们在本章中提到的,掌握程序需要做的一切可能很困难,因此我们需要一种方法来专注于细节,同时不失对程序需要做什么的整体了解。

按顺序做事的概念也是任何程序的核心,因为我们写的指令是依次执行的。这很重要,因为我们需要确保这些指令的顺序是正确的。

我们编写的指令由语句组成,我们了解到有些语句简短且简单,而有些则可以跨越多行,由其他语句组成。有些是更小的构建块,被称为表达式。我们现在处于一个抽象级别,可以处理诸如将两个数字相加这样的细节。

将代码分解成越来越小的块,使我们能够思考如何构建我们的解决方案。有时,我们需要记录我们的思考过程,以帮助我们或其他读者了解我们选择这种解决方案的原因。我们了解到注释可以用于此目的。

注释也可以用作代码的文档,以便清楚地了解其功能和使用方法。此外,我们还看到注释可以指向语言编译器或解释器,以向其提供指令。

如我们从前面的章节所知,计算机程序将数据作为其输入,将其存储在内存中,处理这些数据,并以新的数据作为其输出。数据是一个关键概念,因此,在下一章中,我们将学习如何与之交互以及为什么这种类型的数据如此重要。

第六章:第六章:处理数据 – 变量

在前面的章节中,我们提到程序是某种接受数据作为输入并在其上执行操作以产生新数据的东西。因此,处理数据在任何应用程序中都是至关重要的,无论它是用于会计还是游戏。

当我们处理数据时,它必须存储在计算机的内存中,这是通过变量来实现的。是变量让我们能够存储和检索数据。在本章中,我们将了解变量,了解它们的工作原理,最后看看我们可以对它们执行的一些操作。

在本章中,你将学习以下内容:

  • 声明和初始化变量

  • 理解数据类型并将它们应用于变量

  • 使用复合类型处理多个值

  • 使用运算符对变量进行操作

  • 对数字进行操作和字符串操作

  • 在编程时,我们需要处理数据,这些数据将被存储在计算机的内存中。为了能够使用这些数据,我们需要有一种方式来引用数据在内存中的位置。这是通过使用一个称为变量的美好抽象来实现的。变量隐藏了困难的部分,比如处理内存地址,并允许我们通过给它命名来轻松访问数据。让我们看看它是如何工作的。

声明和初始化变量

在编写程序时,我们持续与数据打交道。当我们使用这些数据时,我们需要一种方式来跟踪它们。为此,我们使用变量。让我们在以下部分看看它是如何工作的。

理解变量

要理解变量是什么,我们可以从一些代码开始,其中我们将一个值赋给一个变量:

x = 13

在这里,我们有一个值13,它是一个整数。通常,在编程中,我们把这些称为整数,因为它们可以是正数也可以是负数。不同的编程语言对整数值的处理方式不同。大多数语言都会指定整数将使用多少内存。让我们假设这个大小是 4 字节,这是存储整数值常用的一个大小。记住,一个字节是 8 位,每一位可以是01。有 4 个字节,我们就有 4 乘以 8 位,即 32 个零或一,可供我们使用。

要在计算机内存中存储13,编程语言需要预留足够的空间——在我们的例子中是 4 字节。

计算机内存的每个字节都有一个地址。内存地址就像一个街道地址;它被用来帮助我们导航到正确的位置:

图 6.1 – 计算机内存的一部分。每个方块是一个字节,并且有一个唯一的地址

图 6.1 – 计算机内存的一部分。每个方块是一个字节,并且有一个唯一的地址

在我们的例子中,需要找到 4 个没有被其他东西占用的字节。这些字节需要连续排列。

这个序列中第一个字节的地址对我们来说很有意义。编程语言知道我们在该位置存储了一个整数值,并且它知道一个整型数据需要多少字节,所以第一个地址就足够定位这个整型数据。下面的图展示了这一点:

图 6.2 – 编程语言在内存中为整型值存储预留了足够的空间

当编写程序时,我们不想记住数字内存地址,所以我们给这个内存地址起一个名字。作为程序员,这是我们的责任,我们应该选择一个能够描述我们存储的数据的名称。我们很快就会讨论在命名变量时需要考虑的因素:

图 6.3 – 保留序列中的第一个地址被赋予了一个名称——在这个例子中,是 x

图 6.3 – 保留序列中的第一个地址被赋予了一个名称——在这个例子中,是 x

现在我们有足够的空间来存储保留的整数值和一个可以用来引用这个内存地址的名称,实际的值可以存储在这个内存位置。这个值将以二进制格式存储。我们曾在第一章,“计算机程序简介”中讨论过二进制数。在先前的代码片段中,我们想要保存的值是13,而13的二进制表示是1101。这个值之前的所有位都填充了零。正如你在下面的图中可以看到,一个字节就足够了,但许多语言为其整型数据类型设定了固定的大小,所以无论我们是否需要,所有的字节都将被保留:

图 6.4 – 我们想要存储的值的二进制表示被插入到这个内存位置

图 6.4 – 我们想要存储的值的二进制表示被插入到这个内存位置

现在,值已经存储在内存中,我们有一个指向这个位置的名称。我们可以使用这个名称来访问这个值。

在我们的例子中,我们称x为变量。一个变量由几个部分组成。它有一个名称,在我们的示例中是x。它还有一个类型。类型定义了数据需要多少内存。我们想要存储一个整型,我们假设我们使用的语言已经决定为整型使用 4 个字节。这是这个变量的大小。我们还知道,如果一个整型有固定的大小,它能够存储的最大值是有限的。在本章的后面部分,我们将讨论这个限制。

我们还需要探索如何命名我们的变量以及它们可以有哪些类型。让我们从名称开始。

变量命名

我们给变量取的名字应该反映它所代表的数据,所以如果我们用一个变量来存储电子邮件地址,一个好的名字会是email,而b45则不太合适。

每种语言的语法都有关于如何命名变量的规则。以下是一些命名变量的标准规则:

  • 它必须以字母表中的字母或下划线(_)开头。

  • 在第一个字符之后,名称可以包含字母、下划线和数字。

  • 你不能使用语言中用作关键字的名字——也就是说,被语言保留的单词,如forifelse

  • 空格或其他特殊字符,如+-*,不允许作为名称的一部分。

一些合法和不合法名称的示例如下:

表 6.1

表 6.1

许多语言在变量名方面也是大小写敏感的。这意味着nameNamenAmE变量将被视为三个不同的变量。

当涉及到如何构造和格式化变量名时,许多语言也会有什么被称为命名约定。还有关于如何创建由多个单词组成的名称的约定。我们将在下一节研究这些约定。

驼峰命名法

驼峰命名法是指组成名称的单词之间由一个首字母大写的字母分隔。有两种子类型——大驼峰命名法(也称为帕斯卡命名法)和小驼峰命名法(也称为骆驼命名法)。以下是一些使用大驼峰命名法的变量名示例:

  • FirstName

  • EmailAddress

  • ZipCode

同样的名字在驼峰命名法中会看起来像这样:

  • firstName

  • emailAddress

  • zipCode

如我们所见,第一个变体将组成名称的单词的第一个字母全部大写,而第二个变体将第一个单词保持小写,并且只使用第二个单词的第一个字母大写。

推荐使用这种命名约定的语言包括 Java、C#和 Pascal。

蛇形命名法

使用下划线分隔单词称为蛇形命名法。当使用这种约定时,我们只使用小写字母,并用下划线字符分隔单词。使用这种格式与前面示例相同的变量名将看起来如下:

  • first_name

  • email_address

  • zip_code

使用这种命名约定为变量命名的语言包括 Python、Ruby、C 和 C++,在某些情况下。

当我们说一种语言有变量命名约定时,这意味着什么?

命名约定

通常,命名约定是命名事物(如变量)的推荐方式。这意味着我们可以打破这些规则,程序仍然可以工作。然而,我们有几个很好的理由去遵守这些建议。其中一个可能是,如果许多程序员参与编写一些代码,风格将是一致的,因此对于人类读者来说更容易理解。

一些软件公司有自己的命名约定。这通常是在语言本身没有或存在较弱约定的情况下。

当遇到一种新的语言时,我们应该始终学习其约定。如果你在多个使用不同编程语言的项目上工作,记住使用哪种约定可能会很棘手。

第十二章中了解更多关于命名约定的信息,代码质量使用代码约定部分。

现在我们知道了如何命名一个变量,让我们探索变量可以具有的不同类型。

原始数据类型

每个变量都有一个名称和一个类型。类型定义了可以在变量中存储的数据类型。通常,一种语言将有一些内置类型,称为原始类型或基本类型,用于处理单个值。

原始类型可以分为两大类——布尔型和数值型,我们将在下一节中探讨。

布尔类型

第一章中,我们讨论了乔治·布尔和他的布尔代数。这定义了我们可以如何使用andornottruefalse的值组合起来。为了能够在我们的程序中使用这些值,我们有一个以布尔命名的类型,称为布尔型。使用这种类型的变量只能有两个值之一——truefalse。对于具有这些类型的语言,我们使用实际的truefalse单词。

具有这种类型的语言要么称之为Boolean,要么简单地称之为bool

数值类型

数值类型可以分为两类——整数类型和浮点类型。我们将在下一节中详细探讨。

整数数据类型

你在这里可以问自己的第一个问题是,为什么当我们谈到整数时,我们会用复数来谈论类型?你可以争辩说数据要么是整数,要么不是。实际上,许多语言将会有几种类型来表示整数值,而原因与数据使用的内存量以及如何解释这些数据有关。

如本章前面所述,一种语言将定义在变量中存储数据时使用多少内存。当处理整数时,我们可能只处理预定义范围内的较小值,例如人类的年龄,或者值可能非常大,例如恒星之间的距离。

如果我们考虑我们正在处理的数据的特征,我们会发现它有自然限制。例如,人类的年龄永远不会是负数,如果我们考虑记录的最高人类年龄(写作时为 122 岁)并添加一些年份以确保安全,我们可以声明有效的人类年龄将落在 0 到 150 的范围内。一个字节——记住一个字节是 8 位(8 个零或一)——可以处理 0 到 255 范围内的数字,所以这已经足够存储人类年龄了。

如果我们谈论我们与其他恒星之间的距离,我们会有一个不同的数值范围。除了我们自己的太阳之外,最近的两颗恒星是半人马座阿尔法星系中的两颗恒星。它们距离我们刚好超过 4 光年。我们观测到的最远的恒星,名为 MACS J1149+2223 透镜星 1,距离我们 900 亿光年。因此,如果我们使用这些值,我们仍然不需要任何负数,范围将在 4 到 900,000,000 之间。

有时候,我们需要同时处理正数和负数——例如,如果我们正在编写一些会计软件。

这意味着整数值可以有不同的属性,因此我们有多于一种的整数类型,这样我们就可以找到一个适合我们需求的类型。由于我们不需要任何负数,并且最大值永远不会超过 150(作为人类年龄的值),一个可以处理巨大或负数的类型将是计算机内存的浪费。

基于这些知识,编程语言通常会实现几种不同的整数类型,它们在表示值时使用的内存(以字节为单位)不同。程序员的任务是选择一个与变量将处理的数据属性相匹配的类型。

不同的语言将会有不同的整数类型集,但这里有一些典型的整数类型、它们的尺寸以及它们可以处理的值范围:

表 6.2

表 6.2

如您所见,所有前面的类型都包括负数和正数。同时表示两者将限制类型服务大数的能力。再次以人类年龄为例,我们会看到字节类型实际上并不适用,尽管它的最大值是 127,但它有 128 个我们永远不会使用的负值。

我们有这种限制的原因与负数的表示方式有关。

有符号和无符号整数

如果我们查看前表中最小的类型——字节——并思考如何表示负数,我们会发现一个问题。在处理二进制数时,我们有几个位可以存储01,但我们没有其他值,所以我们不能简单地插入一个负号来表示这是一个负数。相反,可以使用以下三种方法之一。

让我们看看它们是如何工作的。

有符号位表示法(SMR)

即使名字有点复杂,这是在二进制形式中表示负值的最简单方法。想象一下,我们正在使用一个字节,它给我们 8 位来表示一个值。然而,如果我们将其中一位用于表示这是一个正数还是负数,我们就只剩下 7 位来表示实际值:

图 6.5 – 使用仅 7 位表示值的字节——在这个例子中是 127

图 6.5 – 使用仅 7 位表示值的字节——在这个例子中是 127

如果我们使用前面图中的最左边的位——通常被称为最高位,因为它代表的是最高值——来表示其余部分应该被视为正数还是负数,其余的位可以形成一个最大值为 127 的值:

图 6.6 – 使用最高位来表示正值—29,在这种情况下

图 6.6 – 使用最高位来表示正值—29,在这种情况下

如果我们指定第一个位置来表示这是一个正数还是负数,我们可以用 0 来表示这是一个正数,用 1 来表示这是一个负数:

图 6.7 – 使用最高位来表示负值-29

图 6.7 – 使用最高位来表示负值,-29,在这种情况下

使用这种技术,我们可以用一个字节表示从 -127127 的值范围。我们还将有 0 的两种表示——正的和负的。这是这种方法的一个缺点,也是它不常被使用的原因。

这种技术的一个问题出现在对使用 SMR 的两个值执行数学运算,如加法时。

图 6.8 至 6.12展示了如果我们使用 SMR 来表示一个负数,将 3c 相加会发生什么。为了理解这些图表,想象一下你加两个十进制数。如果你想加 495572,我们将它们一个放在另一个上面,并开始逐列相加:

然后我们对下一列做同样的处理,但是当我们得到一个大于 9 的值时,我们必须进行进位:

当加到最后一个列时,我们将进位数作为我们加的数字的一部分:

我们可以将同样的原则应用于二进制数的加法。唯一的区别是我们现在只处理两个数字。所以,当结果大于 1 时,我们只需要一秒钟就可以进位。现在,当我们加两个可以是 01 的位时,我们最终只会得到三种不同的结果——0、1 或 2(十进制)。如果我们考虑我们需要加两个值和一个可能的进位值,最大值将是 1 + 1 + 1 = 3。现在我们知道我们可以得到的最大值(3),我们可以将其转换为二进制。3 的二进制是 11。这意味着我们的可能结果将是 0、1、10 和 11。0 和 1 可以在一个位内表示,但 1011 不能,所以在这里,我们需要进位 1。

作为指导,让我们用一个表格来在十进制和二进制值之间进行转换:

表 6.3

表 6.3

让我们再看看另一个表格,它有助于我们理解二进制加法是如何工作的:

表 6.4

表 6.4

如您所见,最后三个操作的结果是两位数,所以所有这些都会产生进位。

让我们看看我们如何将这个原则应用于二进制数的加法:

图 6.8 – 加两个值,3 和-3,其中负数使用 SMR 表示

图 6.8 – 加两个值,3 和-3,其中负数使用 SMR 表示

当我们想要加两个二进制数时,我们做的是与十进制数相同的事情。唯一的区别是我们只能处理结果为 0 或 1 的情况。如果结果是 2,我们需要将 1 进位到下一个位置:

图 6.9 – 第一步是加上两个最右边的位—1 + 1 = 2

图 6.9 – 第一步是加上两个最右边的位—1 + 1 = 2

从前面的图中,我们可以看到,因为二进制中的 2 是 10,我们在这一位置插入 0 并进位 1。

这意味着在下一步,我们有三个值—1 + 1 + 1。因为结果将是 3,二进制表示为 11,我们在这一位置插入 1 并将 1 进位到下一轮:

图 6.10 – 对下两个位重复操作

图 6.10 – 对下两个位重复操作

在这个例子中,我们现在有几个位置是加零,但我们必须记住我们第一次已经进位了 1:

图 6.11 – 对于第三对位,我们在加两个零的同时带有进位

图 6.11 – 对于第三对位,我们在加两个零的同时带有进位

我们现在可以一直重复这个过程到最末位。这是我们符号位:

图 6.12 – 接下来的四个位都是零,没有进位,所以它们都将产生零

图 6.12 – 接下来的四个位都是零,没有进位,所以它们都将产生零

由于我们正在加一个正数(3)和一个负数(-3),在这个位置上第一个值是0,第二个值是1以表示该值是负数。因为 0 + 1 是 1,这表明结果是负数:

图 6.12 – 当我们加用作符号位的位时,结果将是 1,表示结果是负数

图 6.12 – 当我们加用作符号位的位时,结果将是 1,表示结果是负数

令我们惊讶的是,我们发现 3 和-3 相加的结果不是我们预期的 0,而是-6,因为我们有 4 加上 2 等于 6,第一个位置是 1,表示这是一个负数。

这也是为什么这种方法不常被使用的原因之一。

反码

另一种表示负数的方法是使用所谓的“反码”,它也使用最高有效位(在我们的插图中最左边的位)作为符号位,但与 SMR 不同地存储负数。

如果我们有一个正数,我们可以用 3 作为例子,如果我们将其存储在一个字节中,我们得到 0000 0011,正如我们在这里看到的:

图 6.13 – 在字节中存储正数 3

图 6.13 – 在字节中存储正数 3

要存储-3,我们需要翻转所有位,所以 0 变成 1,反之亦然,如下所示:

图 6.14 – 使用补码存储-3。所有值都与存储正 3 时的值相反

图 6.14 – 使用补码存储-3。所有值都与存储正 3 时的值相反

如我们所见,所有位与存储正 3 时的位相反。当这个数字被解释时,首先检查符号位。如果是 1,则将所有其他位翻转以形成实际值。这看起来可能有些奇怪,但让我们看看当我们把两个数相加会发生什么:

图 6.15 – 使用补码表示的负数相加 3 和-3

图 6.15 – 使用补码表示的负数相加 3 和-3

如前图所示,我们对每个位置加 1 和 0。因此,结果将在每个位置上都是 1,正如我们在这里看到的:

图 6.16 – 将两个字节相加的结果将在所有位置上为 1

图 6.16 – 将两个字节相加的结果将在所有位置上为 1

由于我们有符号位为 1,这意味着我们有一个负结果,所以所有其他位都需要翻转,结果将是-0。现在,这更好,因为 3 + (-3)等于 0,但负 0 没有意义。这意味着这种方法也和 SMR 一样有相同的问题,即我们有两个表示值 0 的方式——一个正数和一个负数:

图 6.17 – 由于符号位为 1,所有其他位都需要翻转,形成负 0

图 6.17 – 由于符号位为 1,所有其他位都需要翻转,形成负 0

让我们看看我们是否可以解决这个问题,并找到一个有效的表示方法。

补码

为了解决补码中的两个零问题,存在一种表示整数值的第三种方法,称为补码。它的工作方式与补码相同,但略有不同。

第一步是使用补码来表示一个负数,这需要取正值的位并翻转所有位。但是,当这样做之后,我们向结果中加 1。现在,这可能会让人觉得我们完全搞砸了结果,但正如我们将看到的,它解决了双零表示的问题。

请参阅图 6.18 至 6.21以了解这是如何工作的:

图 6.18 – 在字节中表示值 3

图 6.18 – 在字节中表示值 3

正值的表示与之前相同。然而,当处理负数时,我们采取不同的做法:

图 6.19 – 使用补码形式表示-3

图 6.19 – 使用补码形式表示-3

在这里,我们从正表示法中取出所有位并翻转它们,所以 0 变成 1,反之亦然:

图 6.20 – 当加上 3 和-3 时,我们做的是之前做过的事情。1 + 1 = 10,所以 0 移动到这个位置,1 被进位

图 6.20 – 当加上 3 和-3 时,我们做的是之前做过的事情。1 + 1 = 10,所以 0 移动到这个位置,1 被进位

添加位的方式与之前相同:

图 6.21 – 对于所有位置,我们都有一个进位,所以我们将 1 + 1 + 0 = 10 相加

图 6.21 – 对于所有位置,我们都有一个进位,所以我们将 1 + 1 + 0 = 10 相加

当在左边加上符号位时,我们会得到一个进位。二进制补码方法指出,这个进位应该被丢弃。

我们可以看到,这个操作的结果是一个只有零的字节,给我们提供了一个 0 的单个表示。

由于二进制补码解决了零的二进制表示问题和两个值相加的问题,因此这是最常使用的方法。

无符号整数

一些语言允许我们使用所有位作为值的整型。这使我们能够只处理正整数,但另一方面,它们可以大两倍,因为我们使用所有位来存储值。

并非所有数值都是整数,因此现在让我们看看另一组数值数据类型——浮点类型。

浮点类型

使用二进制形式表示浮点数是棘手的,作为程序员,我们很快就会发现一些与这个问题相关的奇怪之处。让我们看看以下代码:

result = 0.1 + 0.2

我们预计变量 result 中存储的结果应该是0.3,但在许多语言中,这将是类似0.30000000000000004的东西。

我们得到这种奇怪结果的原因是我们试图将十进制浮点数表示为二进制浮点数。我们不会过多地详细介绍计算机中浮点数的表示方式,因为这会变得有点复杂。如果你想知道这是如何完成的,你可以在网上搜索并看到很多关于它是如何工作的详细解释。

但我们将思考计算机在处理十进制数的二进制表示时面临的问题。

在我们的十进制位制中,每个数位都有一个值,就像我们之前看到的那样。这对于浮点数和整数都是一样的。对于浮点数,位置也有值,如下所示:

图 6.22 – 十进制系统中不同位置在十进制点左边的值

图 6.22 – 十进制系统中不同位置在十进制点左边的值

使用这个系统使我们能够轻松地存储一个值——例如,十分之一可以写成 0.1. 在二进制中,位置如下:

图 6.23 – 二进制系统中不同位置在十进制点左边的值

在十进制点左边的值](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/comp-prog-abs-bgn/img/B15554_06_24.jpg)

图 6.23 – 在二进制系统中,不同位置相对于小数点的值

如我们所见,没有任何值代表十分之一,所以需要做些其他事情。计算机的做法是以科学记数法存储浮点数值。将十进制值300表示为科学记数法,结果为公式 _06_001。计算机以二进制形式执行此操作,并将值分为三部分。第一部分是符号,就像我们为整数值所看到的那样。第二部分是使用的指数,最后一部分称为尾数。尾数是 10 为底的对数的十进制部分(即十进制数)。如果这对您来说毫无意义,请不要担心。您不需要理解数学就能使用浮点数。然而,我们必须了解为什么数字并不总是以我们期望的方式出现。

为了说明这一点,我们可以考虑如果我们计算 1/3 会发生什么。我们将得到0.333333333…,其中我们将有一个无限数量的 3。当我们尝试用二进制数表示0.1时,也会发生同样的事情。结果将是一个在二进制形式中无限延续的值。问题是计算机没有无限的内存,所以当分配给此类型的内存用尽时,它就会停止,这意味着我们将无法准确地表示这个数字。这就是为什么在前面的例子中,当我们把0.10.2相加时,我们得到了0.30000000000000004而不是0.3

不深入探讨浮点数表示的数学,我们需要了解编程中用于表示它们的两种最常见类型。它们通常被称为浮点数双精度浮点数

它们之间的区别在于,浮点数通常使用 32 位,而双精度浮点数使用 64 位。这意味着双精度浮点数在截断数字之前有更多的内存可以使用。它被称为双精度,因为它与浮点数类型相比具有双精度。

从这个例子中我们可以学到的是,如果精度在我们的应用中很重要,我们应该使用双精度浮点数,如果不是,我们可以使用浮点数。例如,如果我们想存储室外温度,使用浮点数就足够了,因为我们永远不会处理那么多的十进制数。

我们现在可以处理整数和浮点数,但有时我们想要表示其他类型的值,例如复数。

一些编程语言有专门用于处理复数的特殊类型。使用此类型的语言示例包括 Go、C++、Python 和 Ruby。

此外,还有一种数值类型,尽管我们通常认为它是其他东西——字符类型。

一个字母、标点符号或任何我们可以想到的其他字符都可以表示为一个数字。许多编程语言将提供一个特殊类型,旨在处理单个字符,但在底层,它是一个整数字符类型。它以与我们之前看到的其他数值类型不同的方式处理,因为我们不仅可以向它分配数字,还可以分配被某些引号包围的字符——通常是单引号。它可以看起来像这样:

character_a = 'a'

在这里,我们将a字符分配给一个名为character_a的变量。字符被单引号包围,以表示这是一个字符而不是名为a的变量。发生的事情是,这个字符的数值被分配给变量。小写a的字符值是97,所以在这种情况下,这就是将被存储在变量中的内容。

要将变量的内容打印到屏幕上,请输入以下代码:

print character_a

我们将看到打印出a,而不是97,因为编程语言将知道这是一个字符类型而不是普通数值类型,并将数值值转换回字符表示。

有时候,我们需要同时存储多个值。然后,我们可以使用所谓的复合类型

复合类型

复合类型是由多个值组成的数据类型。在某些情况下,将几个相关值放在一起是有意义的。在我们的日常生活中,我们经常这样做。购物清单就是一个例子。我们不是用几张纸,每张纸上有我们需要购买的一个物品,而是将所有物品存储在一张纸上,称之为列表。

这就是编程中复合类型的工作方式。我们有几种类型,它们都具有一些特定的特性。第一种类型在我们想要表示一系列或列表时可以使用。这通常被称为数组。

数组——也称为向量、列表或序列——是一种存储多个元素的数据类型。这个数量可以是固定的或灵活的。

固定数组

当我们有一个固定大小的数组时,我们在创建它时说明我们想要它有多少个槽位。这个大小不会改变。如果我们创建一个可以存储 10 个整数的数组,它将预留 10 个整数的空间,即使我们只使用了 3 个或 5 个。

通常,我们会创建一个固定大小的数组,如下所示:

numbers[10]

在这里,我们想要一个可以存储 10 个整数的数组,我们称它为numbers。我们目前没有在数组中存储任何值,所以我们说这 10 个位置是未分配的,但我们已经为我们在内存中预留了空间,并且我们有一个可以用来访问这个空间的名称。

我们可以将其视为我们有 10 个不同的变量。唯一的区别是我们将所有 10 个变量存储在同一个名称下。

我们现在需要一种方法来单独地引用这些变量。这是通过索引来完成的。

我们可以通过使用其名称和一个索引值来索引数组:

numbers[2] = 44

这里,我们在数组的2号位置存储了44。你可能认为位置2是数组中的第二个值,但实际上它是第三个。原因在于索引是从 0 开始的,如下所示:

图 6.24 – 在索引 2 处插入 44 将值放置在数组的第三个位置

图 6.24 – 在索引 2 处插入 44 将值放置在数组的第三个位置

索引从 0 开始而不是 1 的原因是,我们可以将这个数组使用的名称视为对数组中第一个位置的引用。当使用索引时,我们说的是我们应该向前移动多少个位置。所以,numbers[2]意味着我们从第一个位置开始,在内存中向前移动 2 个整数。这就是你应该存储值的位置。

我们还可以从给定的索引中检索一个值,如下所示:

print numbers[2]

之前的代码打印出位于索引2的值,这是数组中的第三个值。

拥有一个固定大小的数组可能会出现问题,因为我们并不总是知道我们需要存储多少个值。如果这种情况发生,那么我们可以使用另一种类型的数组,这种数组可以根据我们的使用情况动态增长和缩小。这有时被称为动态数组。

动态数组

动态数组(或列表、向量或序列)是一种可以随着使用情况动态增长和缩小的数组。最初,当我们首次创建这种类型的数组时,它将是空的,但我们可以随后添加和移除其中的内容。创建这些数组的方法会因语言而异,但可能看起来像这样:

numbers = []

这里,我们创建了一个空的动态数组。

我们现在可以添加和移除数组中的内容:

numbers.add(10)
numbers.add(11)
numbers.add(12)
numbers.remove(11)

这里,我们首先添加了三个值——101112。通常,值会被添加到末尾,所以它们将按照101112的顺序存储。

在最后一行,我们移除了11这个值。现在数组中只有1012这两个值。

通常,我们会有不同的方法来指定新值应该添加到数组的哪个位置以及应该从哪个位置移除。例如,我们可能能够做如下操作:

numbers.addBack(10)
numbers.addFront(11)
numbers.addBack(12)
numbers.addFront(13)

从前面的代码中,我们可以看到以下内容:

  1. 我们将10这个值添加到空数组的后面。

  2. 然后,我们将11这个值添加到数组的开头。现在我们有1110

  3. 然后,我们在后面添加12,得到111012

  4. 在最后一行,我们将13添加到数组的开头。现在我们有13111012这些值。

我们通常可以使用索引,就像在固定大小的数组中一样,从动态数组中检索单个值。问题是我们需要跟踪数组中当前有多少个项。这很重要,因为如果你有一个包含五个元素的数组,而你想要获取第 10 个元素,你就是在数组外部查找,你的编程语言很可能会因为执行了非法操作(根据你的语言来说)而停止程序执行。

动态数组也有其代价。当我们创建一个固定大小的数组时,会找到一个足够大的内存块,然后我们可以继续使用它。数组中的所有项目都必须在内存中按顺序排列,因为这是索引工作的基础。正如我们所看到的,数组的名称将告诉我们这个数组的起始位置,然后我们使用索引来表示我们需要在内存中移动多少步才能到达正确的位置。

当使用动态数组时,这可能会成为一个问题。如果我们一个接一个地添加项目,我们最终会到达一个已经存放其他东西的内存位置。现在我们的数组需要移动到另一个足够大的位置,以便存放我们已有的所有值,以及我们想要添加的新值。作为程序员,这不是一个常见的任务。语言会为我们完成这个任务,但将所有旧值从原始位置复制到这个位置将花费时间。这就是我们为拥有一个可以根据我们的需求增长和缩小的结构所付出的代价。

大多数编程语言只允许你在数组中存储相同类型的数据。然而,有一些语言允许你按需混合类型。

有时,我们想要存储以另一种方式相关联的值——比如说关于一个人的信息。然后,我们可以使用所谓的记录。

记录类型

如果你正在编写一个应用程序,并且你想表示关于客户的信息,你首先需要决定你想要处理哪些信息。这可能包括客户的首姓和姓氏、街道地址、城市、邮政编码等等。这可能包括不同类型的数据。如果你能够通过名称检索不同的项目,那将非常有用。

记录有时也被称为结构体或 struct。要使用它们,我们首先需要定义它们的形状。这可能看起来像这样:

struct Person 
   firstName,
   lastName,
   streetAddress,
   city,
   zip
end_struct

我们在这里所做的定义了一个新的类型叫做Person。这种类型的一个变量可以存储一个名字、姓氏、街道地址、城市和邮政编码。

创建这种类型的变量可能看起来像这样:

Person person1

现在我们有一个名为person1的变量,但我们还没有在其中存储任何数据。这样做可能看起来像这样:

person1.firstName = "Anna"
person1.lastNamme = "Smith"
person1.streetAddress = "34 Main Street"
person1.city = "Home Town"
person1.zip = "123 456"

关于这个人的所有信息现在都存储在person1变量中。我们现在可以创建其他person变量来存储其他人的信息:

Person person2
Person person3
person2.firstName = "Bob"
person3.firstName = "Colette"

我们也可以检索存储在这些变量中的数据,如下所示:

print person1.firstName
print person2.firstName
print person3.firstName

这个输出的结果如下所示:

Anna
Bob
Colette

在这里,我们有一个变量名firstName(例如)与一些数据——AnnaBobColette之间的关系。我们事先知道这种关系,因此记录结构对我们来说非常合适。有时,我们可能不知道我们会得到什么,但数据可能仍然以成对的形式出现。然后,我们可以使用另一种称为字典的数据类型。

字典类型

字典(也称为映射、哈希表或关联数组)是一种使用键值对的集合类型。键需要是一个唯一的值,我们可以用它来检索与其关联的值。

冒号通常用于分隔键和值。

它们可能看起来像这样:

dictionary books 
    "Pride and Prejudice": "Jane Austen",
    "David Copperfield": "Charles Dickens",
    "Madame Bovary": "Gustave Flaubert"
end_dictionary

在这里,我们使用一些著名书籍的名字作为键,与每个键关联的值是那本书的作者。正如我们之前所述,键需要是唯一的。如果我们重复使用键并为其分配另一个值,旧值将被新值覆盖。

我们可以通过使用键作为索引来访问值——在我们的例子中是作者的名字:

print books["David Copperfield"]

这将给出以下输出:

Charles Dickens

由于只需要键是唯一的,我们可以有多个具有相同值的项。例如,我们可以将一本书添加到字典中如下:

books["Oliver Twist"] = "Charles Dickens"

现在我们有两个包含Charles Dickens的值,但它们与两个不同且唯一的键相关联。

有时,我们存储唯一值有其他原因。我们可能想表示数学中已知的有限集。为此,我们也有集合类型。

集合类型

集合是一种复合类型,它将存储唯一值而不按顺序排列。由于这种类型是无序的,我们不能通过索引从它中检索项目。这通常不是问题,因为这种类型通常用于测试成员资格。例如,你可能有包含一些值的两个集合,并想知道哪些值出现在两个集合中。

让我们看看我们如何创建这两个集合,然后打印出两个集合中出现的值:

firstSet = {2, 5, 7, 9}
secondSet {2, 3, 4, 8, 9}
print firstSet.intersection(secondSet)

在集合理论中,两个集合之间的交集是存在于两个集合中的值。

这个输出如下所示:

2, 9

这个输出如下所示:

图 6.25 – 两个集合的表示,这两个集合的交集是 2 和 9,因为这些值存在于两个集合中

这两个值存在于两个集合中](https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/comp-prog-abs-bgn/img/B15554_06_26.jpg)

图 6.25 – 两个集合的表示,这两个集合的交集是 2 和 9,因为这些值存在于两个集合中

有时,我们想要创建一个新的类型,以便我们可以指定可以分配给这种类型的变量的值。为此,我们有枚举。

枚举

枚举,通常简称为枚举,是一种具有唯一值的枚举类型。我们可以使用它来创建我们的类型并指定可以分配给具有此类型的变量的值。

它可能看起来像这样:

enum TrafficLight
    red,
    yellow,
    green
end_enum

TrafficLight现在是一个类型,我们可以用它来创建变量:

TrafficLight light1
TrafficLight light2

由于light1light2都是TrafficLight类型,我们只能分配在这个类型内部描述的事物:

light1 = yellow
light2 = green

在幕后,每个枚举项都与一个数值相关联。在我们的例子中,red将是0yellow1green2

现在,我们已经看到我们可以处理布尔值、数字、字符和不同的复合类型,但你可能会注意到我们缺少一种类型。我们还没有看到一种可以处理文本的类型。是时候看看字符串了。

字符串

字符串是一种复合类型,因为它被存储为字符数组。大多数语言将使用双引号来指定某物是一个字符串,并将使用单引号来表示单个字符。有些语言允许你为这两种类型都使用单引号或双引号。在这里,我们将使用双引号来表示字符串,当只有一个字符时使用单引号。

通常,我们不会将字符串视为数组。我们可以创建一个字符串,如下所示:

greeting = "Hello there"

在这里,我们创建了一个字符串类型的变量,名为 greeting,并将其值设为 Hello there。在幕后,将创建一个字符数组,字符串中的每个字符都将分配到这个数组中的一个位置,如下所示:

图 6.26 – 存储为字符数组的 "Hello there" 字符串

图 6.26 – 存储为字符数组的 "Hello there" 字符串

由于字符串是字符数组,我们可以使用索引来访问单个字符。例如,让我们打印位置 7 的字符,这是第八个字符:

print greeting[7]

输出将如下所示:

h

现在我们能够以不同的格式处理数据,我们可以继续对这些数据进行操作。是时候看看操作符了。

操作符 – 我们可以对变量执行的操作

第五章**,序列 – 计算机程序的基本构建块 中,我们讨论了语句是由表达式组成的,以及表达式可以由操作或操作数组成。

让我们看看一个例子。在这里,我们将创建两个变量,并给每个变量赋值。然后,我们将这两个值相加,并将结果存储在一个新变量中:

number1 = 10
number2 = 15
result = number1 + number2

在第一行,我们创建了一个名为 number1 的变量,并将其值设为 10。我们现在知道这意味着几件事情:

  • 变量名是 number1

  • 由于它被分配了一个整数,因此它必须是 integer 类型。

  • 等号 = 是一个操作符,它将右侧的内容赋值给左侧的内容。

  • 在计算机内存的某个地方,已经为整数预留了足够的空间。

  • 名称 number1 将作为这个地址的别名。

然后,我们在第二行做同样的事情,唯一的区别是我们给变量起了一个不同的名字,number2,并给它赋了另一个值,15

在第三行,我们使用了加法操作符。这个操作符有两个操作数——number1number2。现在,它将提取这两个变量中的值,将它们相加,并返回结果。

这个操作将得到一个值为 25 的结果,这个值将被分配给变量 result。由于加法操作给我们另一个整数,我们现在还知道变量 result 也将具有 integer 类型。

让我们来看看一些基本的算术运算符。

算术运算符

大多数编程语言都共享用于基本算术运算的符号。请参考以下表格,以查看最常见的符号:

表 6.5

表 6.5

最后一个,取模运算符,可能对你来说并不熟悉。它在编程中经常被使用,因为它在处理数字时有一些很有用的特性。我们可以用一个简单的例子来说明它的工作原理。如果我们计算 16/13,我们会得到一个结果为 1.230769…。整数部分 1 告诉我们 13 一次可以进入 16。取模运算符所做的就是告诉我们到达 16 还剩下多少。所以,如果 13 一次可以进入 16,我们必须将 3 加到 13 上才能得到 16

我们可以用 16 % 13 来表示,得到的结果是 3。如果你以前没有使用过取模运算,我建议你在网上搜索它,以了解它是如何工作的,因为这对于你作为一个程序员来说是非常有用的。

接下来,我们将介绍一些可以用来比较事物的运算符。

关系运算符

当我们想要比较两个值时,会使用关系运算符。它们可以是相等的,也可以是不相等的。我们可能想知道一个值是否大于另一个值。以下表格列出了在编程语言中通常可以找到的关系运算符:

表 6.6

表 6.6

使用这些运算符,我们可以比较两个值,但有时我们还有更多的值要比较。在这种情况下,我们需要逻辑运算符。

逻辑运算符

这些运算符用来表示 andornot。某些语言会使用这些确切的词来表示它们,但其他语言可能会有特殊的符号。它们用于诸如 if the age is greater than 12age is less than 20 这样的语句中。在代码中,它可能看起来像这样:

if age > 12 && age < 20 then

我们可以使用的三个运算符如下:

表 6.7

表 6.7

大多数语言将比我们这里看到的更多运算符。我们不会在这里介绍它们,因为其中一些是组合运算符,它是我们这里看到的两个运算符的组合,而其他一些是为一种或几种语言专门设计的。

接下来,我们将探讨变量在内存中存储的两种不同方式以及为什么这会关系到我们。

值和引用变量的概念

变量可以在内存中存储其值有两种方式。我们可以将这些方式视为直接和间接。这听起来可能有些奇怪,但让我们用一个类比来解释。

直接存储数据的变量就像盒子。当我们创建它们时,我们可以把它们想象成贴有变量名字标签的盒子。我们可以在盒子里存储值,以后可以查看盒子以查看里面的值。以这种方式存储值的变量被称为值变量

图 6.27 – 通过值存储数据的变量就像一个盒子

图 6.27 – 通过值存储数据的变量就像一个盒子

使用间接存储的变量将充当图书馆的索引卡。它不会存储书籍,但会包含书籍存储的位置,因此它只包含实际值在内存中的地址。以这种方式存储值的变量被称为引用变量

图 6.28 – 通过引用存储数据的变量就像图书馆的索引卡

图 6.28 – 通过引用存储数据的变量就像图书馆的索引卡

让我们看看它们是如何工作的。

值变量

当我们创建一个变量——或者像我们通常说的那样声明它——时,会发生几件事情,就像我们在本章开头看到的那样。让我们看看当执行以下代码行时会发生什么:

x = 10

如我们所知,一个足够大的内存块可以存放一个整数,并且这个位置有一个地址。我们给变量取的名字——在前面的例子中是x——只是这个地址的一个别名。对于我们来说,记住一个名为firstName的变量存储了一个名字,而不是记住这个名字位于38892819283地址上,要容易得多。

当我们的代码被解释或编译时,变量名会被更改为实际的地址,但幸运的是,这并不是我们将会看到的内容。因为这个变量是通过值存储数据的,所以数据存储位置和变量名之间存在直接关系。

引用变量

当涉及到引用变量时,变量名和数据存储之间有一个额外的步骤。让我们通过另一个代码示例来看看会发生什么:

weather = "Sunny"

在这个例子中,数据仍然需要存储在某处,因此会找到一个内存位置来存储它,并且和之前一样,会记录这个位置的地址。然而,不同之处在于变量名不是这个地址的别名。相反,数据存储位置的地址存储在内存的另一个位置,而变量是这个位置的别名。以下图表说明了这一点:

图 6.29 – 命名为 weather 的引用变量

图 6.29 – 命名为 weather 的引用变量

这可能看起来没有意义。为什么我们不能将文本字符串存储在weather变量指向的位置?这个额外步骤有什么意义?答案是效率。在程序中,我们需要传递数据。程序中某个部分变量中的数据需要传递到另一个部分。想象一下,存储的数据比前面例子中的短文本字符串大得多;实际传递数据意味着我们需要复制所有数据。这需要一些时间,现在我们将有两个数据副本,这将使用两倍多的内存。如果一个变量是这种引用类型,我们就不需要复制所有数据。在前面例子中,这意味着我们不需要复制Sunny。相反,weather变量包含指向这些数据的地址,因此它可以只传递地址。

不同的语言如何使用这两种类型会有所不同。在学习一门新语言时,了解它如何处理值和引用变量是至关重要的。

现在我们已经涵盖了与变量相关的大量内容,我们现在可以开始使用它们了。我们将看到如何处理数字,然后我们将查看文本字符串。

处理数字

数字在计算机程序中至关重要。我们使用它们来表示现实世界中的概念,例如购物车中的物品数量、包裹的重量和到某个地点的距离。我们还使用它们来表示程序内部的内部事物,例如名字中的字符数,这样我们就可以计算如果打印出来是否适合地址标签。关键是,我们一直在使用数字,所以让我们看看我们能用它们做什么。

首先,我们可以进行基本的算术运算,例如加法、减法、乘法和除法,如下面的代码片段所示:

age1 = 34
age2 = 67
mediumAge = (age1 + age2) / 2

在这个例子中,我们有两个年龄,我们正在计算平均年龄。

我们经常使用数字作为事物的计数器。这意味着我们将使用它们来跟踪我们做了多少次某事。这意味着我们需要增加(有时减少)它们 1。我们可以这样做:

count = count + 1

要理解这里发生的事情,我们首先需要看=符号的右边。这里有一个名为count的变量。由于这是一行从其上下文中剥离出来的,我们可以假设它已经存储了一个值。现在这个值被使用,并且我们给它加 1。这个加法的结果然后被存储回变量count中,我们将其值增加了 1。

由于在编程中这样做非常常见,因此已经开发了一些简写。由于我们在=操作符的两侧使用相同的变量,我们可以省略其中一个,而改用一个看起来像这样的不同操作符:

count += 1

这与上一个例子相同,但更短。我们可以将其读作取 count 变量存储的值,将其加 1,并将结果重新存储在 count 中。

一些语言更进一步,甚至缩短了这一过程:

count++

再次,结果将与另外两个相同。我们可以将其读作将存储在 count 中的值增加 1

当然,我们刚才看到的三个例子也可以用减法来完成,如下所示:

count = count - 1
count -= 1
count--

当处理数字时,它们可以存储在变量中,就像 count 一样,或者直接在代码中的数字,就像前例中的 1

有时候,我们将处理更复杂的数学公式,并在一行中做几件事情。

我们可能有以下类似的东西:

我们如何在程序中写这个?

首先,xab 在数学中是变量,在我们的程序中也将是变量。a + 37 – b 必须在我们可以除之前完成。我们使用这样的方程作为计算某物的配方。要使用它,我们为 ab 插入一些值。然后,我们进行数学运算,x 将是结果。

因此,在我们的程序中,我们可以假设 ab 已经在程序的其他地方被赋予了某些值。那么,让我们看看我们如何让计算机为我们做数学运算:

x = (a + 3) / (7 – b)

添加括号意味着括号内的内容将首先被计算。所以,如果我们为 ab 想象一些值——比如说 a = 3b = 4——那么 a + 3 将首先被计算,因此我们得到 3 + 3 = 6。然后,7 – b 将被计算,所以 7 – 4 = 3。在这个时候,我们可以想象 a + 3 被替换为 67 – b 被替换为 3;我们剩下 6 / 3。结果,2 将被存储在 x 中。

如果基本的代数对我们来说不够用,大多数编程语言都提供了大量的数学函数库,我们可以使用,例如余弦、正切、平方根和绝对值。

另一种程序员大量使用的数据类型是字符串。让我们看看我们可以用它们做什么。

字符串操作

字符串是一系列字符的序列,字符不一定是字母,它可以是指点符号、空格、连字符或我们可以使用计算机产生的任何其他字符。字符串也可以包含数字。

我们必须首先理解的是,只包含数字的字符串不会与整数相同。看看以下代码:

numberA = 1234
numberB = "1234"

注意到最后一个数字周围的引号。这使其成为一个字符串。第一个,numberA,将是 integer 类型,因此它可以用于计数和其他数学运算。对于计算机来说,第二个就像单词 dog 一样,即根本不是数字。

当处理字符串时,我们可以对它们进行几种典型操作。让我们看看一些常见的字符串操作。

字符串连接

当我们将两个字符串相加以形成一个新字符串时,我们称之为连接。这种做法会因语言而异,但通常,我们可以使用+运算符,如下例所示:

word = "day" + "break"

这里,我们有两个字符串——daybreak。引号告诉我们它们是字符串。它们现在将被连接成一个新的字符串,存储在word变量中。这个变量将包含daybreak这个词。

字符串分割

有时,我们想要将一个字符串分割成多个字符串。为此,我们通常会在字符串内部使用一个分隔符,我们可以用它来指示字符串应该在哪里分割。让我们假设我们有一个包含名字和姓氏的字符串,名字和姓氏之间由逗号分隔,例如Sue,Smith

如果它存储在一个名为name的变量中,在某些语言中,我们可以这样做:

firstName, lastName = name.split(",")

如同往常,我们将从=运算符的右侧开始看。在这里,我们说我们有一个名为name的变量中的字符串。我们想要将这个字符串分成两部分,所以我们使用split。在括号内,我们看到一个字符串。同样,我们知道它是一个字符串,因为它有引号,并且包含一个逗号。这就是我们将用于分割的字符。变量名中所有在逗号之前的字符将被发送到左侧的第一个变量firstName。逗号之后的所有内容将被发送到第二个变量lastName。变量名中的逗号本身将被丢弃。

同样重要的是要注意,变量名的内容没有改变,所以如果我们在这行代码执行后查看变量,我们可以看到它们包含以下内容:

firstName = Sue
lastName = Smith
name = Sue,Smith

结果也可以是一个包含两个字符串的数组。这看起来可能如下所示:

splitName = name.split(",")

splitName变量现在将是一个包含两个字符串的数组。记住,数组中的第一个位置是0,在那个位置,我们将找到Sue字符串,而在位置1,我们将找到Smith

子字符串

子字符串用于当我们从字符串中取出一部分来形成一个新的字符串时。这种做法会因语言而异。以下是一些示例,如果我们想从phonocardiogram字符串中提取car子字符串,它可能看起来像这样。

首先,我们将phonocardiogram存储在一个变量中,如下所示:

word = "phonocardiogram"

在某些语言中,我们接下来可以这样做:

newWord = word[5:8]

这将从字符串中的位置5开始提取子字符串。就像数组一样,计数将从0开始。由于字母c是第六个字母,我们使用的索引将是5。然后,根据给出的代码,我们在8处结束。但看起来我们多了一个字母。通常,我们使用两个值作为起始和结束索引。第一个将说明从哪里开始,第二个将指出第一个超出范围的东西。我们可以将前面的例子读作从位置5开始,在位置8停止,但不包括它。

在执行此行之后,newWord变量将包含car字符串,而且和之前一样,word变量将保持不变。

其他语言将执行类似以下操作:

newWord = word.substr(5,8)

在这里,我们使用substr而不是方括号。此外,请注意,这里的58由逗号分隔,而在第一个例子中我们使用了分号。

第三个变体可能看起来像这样:

newWord = word.substr(5, 3)

在使用这种格式的语言中,第二个值表示我们想要多少个字符,而不是结束索引。然而,结果将是相同的。

大小写转换

转换字符串的大小写是我们经常想要做的事情。例如,原因可能是为了让我们程序的用户输入一个文本命令,然后我们需要检查用户输入了什么命令。例如,我们可以想象他们输入了以下命令:

  • Start

  • Pause

  • Stop

我们需要将用户输入的内容与这些字符串进行比较,但我们有一个问题。当我们比较字符串时,它们需要具有相同的大小写才能相等。这意味着startStartSTART都将不同。

我们不能比较所有可能的组合,因为它们会相当多。对于start这个词,我们有以下变体:

  • start

  • Start

  • STart

  • StArt

  • STArt

  • SRArT

  • sTART

  • starT

我们可以继续,但相反,让我们将字符串转换成全部大写或全部小写。然后,我们将知道字符串的形式。

我们可以这样做到:

answer = answer.lower()

或者,字符串可以按以下方式转换:

answer = answer.upper()

lowerupper不会改变变量answer内的原始字符串,而是创建这个字符串的新小写或大写版本。我们将这个新版本存储回变量中,通过这样做,我们将用我们知道其大小写的版本覆盖旧字符串。

我们已经看到了一些我们可以对字符串做的事情的例子。这并不是我们能做的全部,但请将这些视为一些我们可以在字符串上执行的一些常见操作的例子。

摘要

在本章中,我们介绍了编程所依赖的两个主要支柱之一——如何使用变量在计算机内存中存储、检索和更改数据。

我们了解到变量有一个名称和数据类型,并且变量的类型决定了可以存储在其中的内容以及它将使用的内存量。我们还了解到我们可以简洁地命名我们的变量,并且有一些命名约定可以作为指导。

在覆盖了这些内容之后,我们接着讨论了基本或原始数据类型,并看到数字要么以整数形式处理,要么以浮点数形式处理,这些进一步分为不同的大小,因此我们可以选择一个适合我们需求的类型,并确保我们不浪费内存空间。

一些数据自然以序列或自然分组的形式出现。对于这类数据,我们使用了复合数据类型,并看到这种类型允许我们处理数据组。

我们接着讨论了操作符是什么以及我们如何使用它们在变量上执行操作,还讨论了常见的操作符以及如何使用它们的例子。

变量可以以值类型或引用类型存储在内存中。我们了解到,当它以值类型存储时,它存储的数据位于变量的内存地址。引用变量并不直接保存其值,而是存储指向数据所在地址的地址。

我们使用数字变量,并使用我们的操作符对它们执行了一些基本操作。最后,我们将注意力转向字符串,并查看我们如何可以操作它们。

在下一章中,我们将通过适当的条件选择将逻辑引入我们的程序,以便我们只能在满足条件的情况下执行某些操作。我们还将看到如何借助循环重复执行相同的事情。

第七章:第七章:程序控制结构

如果我们的所有代码都简单地按顺序执行,无论我们向它们提供什么数据,我们的程序总是会做同样的事情。我们必须能够控制程序的路径,以便某些代码部分在指定的时间执行,而其他部分在其他时间执行,这取决于数据提供的值。例如,只有当外面很冷时,你才会穿上保暖的衣服,而不是总是这样。同样的事情也适用于我们的代码。当事情以某种方式发生时,我们希望发生某些事情。

从某种意义上说,我们可以这样说,我们将通过这种方式在我们的代码中引入某种智能,或者至少是某种决策能力。如果事情是这样,这样做,如果不是,那样做。

在本章中,你将学习以下主题:

  • 控制程序的执行路径

  • 使用if语句做出决策

  • 使用switch语句从许多选项中选择一个

  • 使用for循环重复执行代码

  • 使用whiledo while迭代代码直到条件为假

  • 使用for each逐个遍历数据序列

在本章中,我们将深入一些实际的编程。在我们将要涵盖的主题中,我们将能够控制程序的执行路径。让我们首先探索这意味着什么。

控制执行路径

第五章序列 – 计算机程序的基本构建块中,我们学习了程序中的代码是按顺序执行的。

序列是我们编程中拥有的三个基本逻辑结构之一。因此,在本章中,我们将介绍其他两个,选择迭代

选择语句

有时候我们只想在满足条件时执行一些代码。例如,如果你还记得我们的应用从第五章序列 - 计算机程序的基本构建块,其中打开了户外灯光,我们有一个条件说如果我们的手机检测到我们距离房子在给定范围内,它应该向家庭电脑发送信号。为了刷新你的记忆,让我们看看你之前看到的一些图片。图 7.1被用来说明我们进入范围的行动:

图 7.1:手机检测到它距离我们的房子在给定范围内

图 7.1:手机检测到它距离我们的房子在给定范围内

然后,我们有一个条件。我们使用图 7.2中的插图来表示当条件为真时,即我们处于范围内,应该向家庭电脑发送信号:

图 7.2:条件满足,因此可以执行发送消息的代码

图 7.2:条件满足,因此可以执行发送消息的代码

让我们更详细地分解一下,考虑实际涉及的步骤。手机上的应用程序需要执行以下操作:

  1. 向手机上的 GPS 请求当前位置。

  2. 在给定坐标的帮助下,计算到我们家的距离。

  3. 如果距离在我们的房子给定范围内,那么向家用电脑发送信号。

正如你所见,在步骤 3中,我们有一个条件,表示只有在条件为真时才会发送信号。所以,这里有一些代码不会总是被执行。我们称这为选择。我们可以这样定义选择:选择是在满足条件时仅执行代码段的能力

现在,我们可以问自己另一个问题。如果条件为假,也就是说,我们不在范围内,我们应该做什么?对于这个应用来说,答案是没有任何操作。如果我们不在范围内,那么我们就不需要做任何事情。

手机应用还有一个我们现在感兴趣的功能。当我们完成检查我们是否在范围内后,我们将向家用电脑发送信号或什么都不做。之后,我们将回到开始并重复一切,以便我们可以准备好检查移动后我们是否在给定的范围内。我们称这为迭代。我们将在下一节学习这是什么。

迭代语句

迭代是我们想要重复多次的事情。它也可以被称为循环。我们想要迭代的次数可以在 2 到无穷大之间。现在,在编程中,无穷大对事物的看法与我们通常的看法有所不同。编程中的无穷大并不意味着永远永远,而更像是在程序运行期间。所以,在编程中,无穷大在程序结束时结束。

在户外光照应用中,我们进行了一次迭代。以下图表展示了我们的手机应用的顺序,并且我们可以清楚地看到迭代过程:

图 7.3:指向顶部项目的两个箭头表示迭代

图 7.3:指向顶部项目的两个箭头表示迭代

图表中的菱形形状是一个条件。如果条件为真,也就是说,答案是肯定的,我们会发送信号。我们还可以看到,无论条件是真是假,我们都会回到开始。这个应用将不断地重复这些步骤,无限次,也就是说,直到你关闭应用或关闭手机。

有时候,我们只想迭代固定次数。比如说,如果你正在实施一个电子商务解决方案,并且一个客户想要检查购物车中当前的所有商品。你需要遍历购物车中的所有商品来计算总价。如果购物车中有 5 件商品,那么你需要迭代 5 次。

选择和迭代都使用条件,所以在我们查看不同类型的选择和迭代语句之前,让我们更仔细地看看条件语句是什么以及它是如何工作的。

条件语句

我们已经在几个章节中介绍了这个基础。条件语句就是可以导致真或假的语句。以下是一些例子:

  • 今天下雨。

  • 你的年龄低于 20 岁。

  • 你的信用卡已经过期。

  • 你的咖啡杯是空的。

所有这些都将导致真或假。没有可能的情况。要么下雨,要么不下雨;要么你低于 20 岁,要么不,等等。

我们还看到,条件语句可以与逻辑 AND 或逻辑 OR 结合,形成一个新的复合条件语句。这里有一些例子:

  • 今天下雨,我有一双蓝色的鞋子。

  • 你的年龄低于 20 岁,或者高于 60 岁。

  • 你的信用卡已经过期,而且你没有现金。

  • 你的咖啡杯是空的,你的咖啡机坏了。

这些复合的或完整的语句由两个单独的语句组成。今天下雨是其中一部分,我有一双蓝色的鞋子是另一部分。现在这两个需要组合成一个完整的语句,这个语句可以是真或假。在上面的例子中,我们使用来组合它们。这意味着两个单独的语句都必须为真,整个语句才为真。必须下雨,并且我必须有一双蓝色的鞋子。

如果我们看第二个语句,两个较小的语句是通过组合的。这意味着,为了整个语句为真,至少有一部分必须为真。要么你的卡已经过期,或者你没有现金。也可能不幸的是两者都为真,那么整个语句将为真。

现在我们知道了我们有选择和迭代语句,并且它们与条件一起工作,所以让我们看看我们如何编写使用它们的代码。让我们从最常见的选择语句if开始。

使用 if 语句进行选择

当我们在程序中使用选择时,我们可以认为应用程序使用某种智能,因为它现在可以根据各种条件做出决定并做不同的事情。那么,我们如何使我们的应用程序变得智能呢?嗯,最简单的方法是使用if语句,有时它们也被称为条件。在大多数语言中,它们将有类似的结构。

如果我们编写一个小程序来询问用户的年龄,第一部分可能看起来像这样:

print "Enter your age: "
input age

在这里,程序会在屏幕上打印请输入你的年龄:。然后用户输入一个年龄并按下Enter键确认输入。输入的值将被存储在age变量中。

现在我们想根据输入的年龄给出不同的反馈:

if age < 18 then
    print "You are young"
end_if

在这里,我们有检查年龄是否低于18的条件。如果是这样,我们将打印消息,You are young。条件是年龄必须低于18。如果是18或以上,什么都不会发生,因为程序将跳过它们之间的所有内容并结束if语句。

如果我们想检查一个区间,我们可以创建一个复合条件语句,如下所示:

if age >= 13 and age <= 19 then
    print "You are a teenager"
end_if

如你所见,我们正在用and将构成条件的两部分结合起来。这意味着两个条件都必须为真,整个条件才为真。年龄必须大于或等于13,同时它必须小于或等于19。这将给我们一个介于 13 和 19 之间的范围,包括这两个值。如果年龄落在这个范围内,我们将进入if语句并打印文本。如果它要么小于 13,要么大于 19,什么都不会发生。

if语句包含一个可选部分。这个部分被称为else,它标记了一个只有在if语句中的条件为假时才会执行的代码块。这在上面的代码中有所展示:

if age >= 13 and age <= 19 then
    print "You are a teenager"
else
    print "You are not a teenager"
end_if

这里的不同之处在于,现在,我们将在屏幕上始终打印出一些内容。要么条件为真,消息将被打印,要么条件为假,elseend_if之间的部分将被执行。此外,请注意,当条件为真时将执行的部分现在以else关键字结束。

如果我们想在每个部分中有多于一个语句,我们可以:

if age >= 13 and age <= 19 then
    print "You are a teenager"
    print "I hope you are having fun."
else
    print "You are not a teenager"
end_if

现在,如果年龄落在青少年范围内,我们将打印两行。如果它是假的,我们仍然只会打印一行。

如果我们想要更复杂的逻辑,我们可以有嵌套的if语句。这意味着我们可以在条件为真时执行的代码部分或者仅在条件为假时执行的代码部分中放置新的if语句。

例如,如果前面代码中的条件为假,我们知道你要么比青少年时期年轻,要么比青少年时期年长。如果我们想进一步区分,那么就在这个代码部分我们可以进行检查:

if age >= 13 and age <= 19 then
    print "You are a teenager"
    print "I hope you are having fun."
else
    if age < 13 then
        print "You are a child"
    else
        print "You are getting old"
    end_if
end_if

现在的逻辑稍微复杂一些。如果我们的程序的用户响应的年龄在 13 到 19 岁之间,那么什么都没有改变。然而,如果年龄是其他任何东西,我们有一个新的if语句。因为这个语句位于第一个if语句的else部分中,我们知道年龄要么低于13,要么高于13。第二个if语句检查它是否小于13。如果是这样,就会打印出You are a child消息。

现在,考虑一下如果我们进入第二个else部分,我们会遇到什么条件。首先,我们知道年龄不在 13 到 19 岁之间,否则我们根本不会在这个程序的这部分。我们还知道年龄不是低于 13 岁,因为如果是的话,我们会执行You are a child部分。我们只剩下一个选项了;年龄必须在 19 岁以上。

要在else语句后面直接使用if语句,就像我们刚才看到的,这在某些语言中非常常见,因此有一个特殊的结构,称为elif。在这种语言中,相同的代码看起来可能像这样:

if age >= 13 and age <= 19 then
    print "You are a teenager"
    print "I hope you are having fun. "
elif age < 13 then
    print "You are a child"
else
    print "You are getting old"
end_if

程序的逻辑是相同的,但代码更加紧凑。正如你所看到的,我们将在同一行上放置第二个条件,即年龄小于 13 岁,以及旧的else语句,而else语句现在已变为elif语句。elif这个词是由elseif这两个词组成的。

此外,请注意,在第一个例子中,程序以包含end_if的两行结束。第二个版本只有一个。

如果你看看第二个程序,你现在可以清楚地看到有三个不同的部分,并且只有一个部分会被执行:

  • 如果年龄在 13 到 19 岁之间,我们将进入第一个部分并执行该块内的代码。之后,我们就完成了,其余的代码将被跳过。

  • 如果年龄小于 13 岁,我们将首先跳过第一部分,转到elif部分。这里的条件是真实的,因此我们将进入这个部分并执行其中的代码。之后,我们就完成了,可以再次跳到末尾。

  • 最后,如果年龄大于 19 岁,我们将首先跳过第一个部分,转到elif部分。这里找到的条件也将被评估为假,因此我们将跳过前面的部分,直接进入else部分。由于这部分不包含任何条件,如果前面的所有条件都不成立,我们最终都会到达这里。

即使我们所使用的语言没有elif语句,我们仍然可以创建相同的逻辑。考虑以下代码:

if age >= 13 and age <= 19 then
    print "You are a teenager"
    print "I hope you are having fun. "
else if age < 13 then
    print "You are a child"
else
    print "You are getting old"
end_if

你可以看到,这里唯一改变的是将elif语句替换为两个词,elseif。其余部分相同,逻辑也没有改变。

要使用这个想法与嵌套的if语句,如这个例子,可以非常高效。首先,我们必须理解我们可以有我们需要的所有这些。结构可以看起来像这样:

if condition1 then
    do option1
elif condition2 then
    do option2
elif condition3 then
   do option3
elif conditon4 then
    do option5
else
    do option6
end_if

如果你看看这个结构,你可以看到这看起来像一把叉子,我们只能选择其中的一叉:

图 7.4:嵌套的 if 语句就像一个叉子

图 7.4:嵌套的 if 语句就像一个叉子

当我们面对许多可能正确的事物中的一个时,我们确实有一个选择。这是一个类似分支的结构,其工作方式与刚刚看到的嵌套if语句几乎相同。它被称为switch语句。我们将在下一节学习这个内容。

使用switch语句进行选择

当我们面对许多可能正确的事物中的一个选项时,一个替代方案是使用switch语句。即使条件不如if语句中那么明显,它也可以与条件一起工作。

另一个区别是,switch 语句只比较值是否相等。这样做的原因是它不适合我们在探索 if 语句时使用的年龄逻辑,因为我们想看看年龄是否在两个值之间。相反,如果我们打算将其与一个固定的值匹配,它就非常完美。我们很快就会看到一个真实例子。然而,首先,让我们探索 switch 语句的结构。

switch 语句的样子取决于我们使用的语言。在这里我们将看到的是一个相当常见的结构,但在应用它时,你需要查找你语言的正确语法。

switch 语句首先声明我们要检查的变量。对于这个,语言通常使用 switch 关键字。

结构看起来可能像这样:

switch(variable)
end_switch

在这个例子中,名称 variable 只是一个占位符,用于我们想要实际操作的变量。在 switch 关键字和 end_switch 之间,我们需要指定我们想要比较变量的每个值。它可能看起来像这样:

switch(variable)
   case 1:
      …
   case 2:
      …
   case 3:
      …
end_switch

每个案例指定了我们比较变量的值。第一个案例比较它为 1,第二个为 2,依此类推。省略号 (...) 标记了我们将插入每个选项代码的位置。第一个省略号表示如果变量是 1,将执行哪个代码,第二个表示当值为 2 时,依此类推。

在许多语言中,我们有一种称为 fallthrough 的东西。这意味着当找到正确的值时,该 case 语句内的代码将会执行,但随后所有后续的 case 语句中的代码也会执行。所以,如果变量的值是 223 的代码都会执行。这样做的原因是我们可以有一个多个 case 实例依次排列,并且只需要一个代码块来处理它们。

要表示我们想要停止 fallthrough,我们必须给出一个完成的指令,然后我们可以跳转到 switch 语句的末尾。这个指令通常是 break。它将在下面的例子中展示:

switch (variable)
   case 0:
        print "Zero"
        break
    case 1:
    case 3:
    case 4:
    case 7:
    case 8:
        print "Odd value"
        break
    case 2:
    case 4:
    case 6:
    case 8:
        print "Even value"
        break
end_switch

通常,每个案例块只有一个值,所以代码将看起来像这样:

switch(variable)
        case 1:
             …
             break
        case 2:
             …
             break
        case 3:
             …
             break
end_switch

如果变量的值是 2,那么该块内的代码将会执行,并且当遇到 break 语句时,整个 switch 语句的执行将结束。这是好的,因为只有一个选项可以是正确的。所以,如果其中一个被执行了,我们就知道我们完成了。

你可能会争辩说,对于值为 3case 块中的 break 语句是不必要的,因为我们没有更多的语句可以 fallthrough。然而,这是一个好的做法,因为将来我们可能会添加更多的选项,而且我们不希望承担忘记添加那个 break 的风险。

switch语句也有类似于if语句的else部分的东西,即如果评估为真的值没有其他值,将会执行的块。在switch语句中,这被称为default。它通常位于所有case语句之后,非常接近末尾。它可以看起来像这样:

switch(variable)
        case 1:
             …
             break
        case 2:
             …
             break
        case 3:
             …
             break
        default:
             …
             break
end_switch

再次强调,最后的break语句是可选的,但我们保留它以保持一致性。注意,现在我们可以欣赏到我们在数字3内部有一个break,因为如果没有它,数字3会首先执行3块中的代码,然后跳转到默认块。

现在我们已经拥有了switch语句的所有部分,所以让我们看看一个例子,看看何时以及如何使用它。

在这里,我们将要求我们的程序用户输入月份的数值,即1代表一月,2代表二月,依此类推。我们将把用户输入的数字存储在我们称为month的变量中。现在我们可以在switch语句中使用这个变量,以找出与用户给出的数字匹配的名称。考虑以下代码:

print "Enter a month number: "
input month
switch (month) 
    case 1:  
            month_string = "January"
            break
    case 2:  
            month_string = "February"
            break
    case 3:  
            month_string = "March"
            break
    …
    case 12: 
            month_string = "December"
            break
    default:
            month_string = "Invalid"
            break
end_switch
print "The name of the month you entered is " + month_string

在前面的代码中,省略了月份 4 到 11,但它们使用了相同的模式重复。

假设用户输入了8。程序将从顶部开始,检查情况1。如果这是一个if语句而不是switch,情况1将等同于以下内容:

if month == 1 then

由于用户输入了8而不是1,这是错误的,所以程序将转到下一个情况case 2并再次尝试。仍然没有运气,所以它将继续一直到底部到case 8,在那里它最终找到了匹配项。现在它将进入这个块,创建一个名为month_string的变量,并将August的值分配给它。

在下一行,它遇到了一个break。这意味着“让我从这里出去”,所以程序现在将跳过所有其他测试,因为它知道它已经完成了。

如果用户输入了一个无效的月份,比如说14,所有的情况都会首先被检查,但由于没有一个是真的,default块中的代码将会执行,并且month_string变量将获得一个Invalid值。

在最后一行,将打印文本The name of the month you entered is,并且我们month_string变量中的值将被附加到末尾。

程序的执行过程可能看起来像这样:

Enter a month number: 8
The name of the month you entered is August

通过选择结构ifswitch,我们可以构建复杂的逻辑。

在设计解决方案时,你应该记住以下几点:

  • 由于逻辑有时会显得错综复杂,难以理解,因此很容易感到困惑。因此,记住我们在这本书中早些时候说过的话是至关重要的:尝试缩小到一个小的子问题,理解它,并为它设计一个解决方案。当这个解决方案就绪时,你可以稍微放大一些,检查它在更大背景下的工作方式。然后,你可以重复这个过程。这听起来可能非常抽象,但请将其放在心中,并在感觉问题变得过于复杂时尝试使用这种方法。

  • 总是记住,你代码的可读性很重要。这意味着,如果你有一个可行的解决方案,但代码非常复杂且难以阅读,你应该回去尝试重写它,确保它仍然可行,同时也要确保其他程序员(或未来的你)能够轻松阅读和理解代码的功能。

现在我们已经涵盖了编程建立在之上的三个基本支柱中的两个,即顺序和选择,现在是时候解决最后一个,迭代。

使用for循环进行迭代

我们将要探讨的第一种迭代类型是for循环。这是一种我们知道要重复多少次的循环。这可能是一个固定次数,比如使用一周中的日子来遍历一个列表。我们知道它总是 7 次。也可能是在一个数组中有值。我们可能不知道在任何给定时间数组中确切有多少项;然而,正如我们将看到的,有方法可以询问数组它当前持有多少对象。

在使用for循环时,我们将与一个变量一起工作,该变量跟踪我们迭代了多少次。我们可以决定这个变量应该从哪个值开始。是这个变量帮助我们知道何时停止迭代。让我们看一个例子:

for i = 0 to 10
    …
end_for

在这里,我们创建(或者像程序员说的那样声明)一个名为i的变量。名字i经常被用作这个变量的名字,因为它通常用作索引。然而,我们将在稍后详细讨论这一点。在赋值运算符(=)之后,我们说我们想要给i一个起始值为0。然后我们将重复for块内的代码,i将在每次迭代时增加一。10是停止条件。当i达到这个值时,它应该停止重复并继续执行循环之后的代码。

如果我们在循环内部打印i当前的值,代码将看起来像这样:

for i = 0 to 10
    print i
end_for

大多数语言会打印从 0 到 9 的值,而不是从 0 到 10。这可能会显得有些奇怪,但如果我们看看for循环使用的逻辑,我们就可以理解为什么是这样。

当我们第一次遇到for循环的行时,变量i被创建并初始化为0的值,因为我们说这是我们想要的起始值。

然后,它将比较i的值与我们给出的第二个值10。如果它们相等,循环将停止。由于这是第一次迭代,值是 0,它们不相等;因此,循环内的代码将执行。当我们再次回到带有for循环的行时,i的值将增加一,即 0 + 1 = 1。这个值现在与我们的结束值10进行比较。这仍然不是匹配。它将继续这样进行,直到i的值为9。当我们再次回到带有for循环的行时,它将i增加1,使其变为10。现在,当值进行比较时,它们将相等,for循环将结束。所以,当i的值为10时,我们永远不会进入循环,这就是为什么我们只看到从09的值被打印出来的原因。

如本书之前所述,C 编程语言对许多其他语言的语法产生了巨大影响。for循环的编写方式就是一个例子。让我们看看相同的for循环在 C 语言中会是什么样子:

int i;
for (i = 0; i < 10; i++)
{
    printf("%d", i);
}

在第一行,我们声明变量i,并声明它将使用数据类型int。在 C 语言中,语句以分号结束。这就是为什么我们在i变量后面有一个分号。

然后是for循环。在 C 语言中,for循环有三个部分,它们用分号分隔。第一个是初始化。这就是我们说i = 0的地方。这意味着在第一次迭代中,i的值将是0

第二部分是循环持续的条件。在这里,我们说i < 10。我们可以这样理解:只要i小于 10,就继续循环

最后的部分指示了i在每次迭代中如何变化。在这里,我们说i++。这是 C 语言表示“取变量i当前的值,增加一,然后将新值存储在i中”的方式。

循环内的行可能看起来有点奇怪。但不需要深入了解 C 语言处理打印值的所有细节,因为它比大多数语言都要复杂得多。我们唯一需要知道的是,它将打印i的当前值。

输出将与我们之前的例子相同,即从09。在这里,我们可以看到为什么 10 的值没有被打印出来,因为条件是i < 10。当i10时,这个条件不再成立,循环将退出。如果我们改为使用i <= 10,则10的值将被包含在内。

使用这种风格的编程语言包括 C++、C#、Java、JavaScript、PHP 和 Go。它们都有与 C 循环中我们看到的三部分略有不同的for循环。

为了简化,我们在这本书中不会使用 C 风格的循环,而是坚持使用我们最初看到的版本。这将帮助我们集中精力了解for循环的工作原理,而不会被编写它们的语法所分散。

如果我们想以除了增加一以外的任何方式更改循环变量,我们可以这样做:

for i = 0 to 10 step 2
    print i
end_for

在这里,我们将每次将i的值增加2。这个程序的输出将类似于以下内容:

2
4
6
8

它仍然从 0 开始,但随着我们每次增加2,所有奇数都将被跳过。就像之前一样,当i达到10时,我们将退出循环。

有时候,我们想在另一个for循环内部放置一个for循环。这被称为嵌套for循环。我们需要为这些循环使用两个不同的变量,以便它们不会相互干扰。例如,我们可能想要遍历一周中的所有 7 天。我们将打印像day 0day 1这样的天数。我们将从 0 开始以简化,当然,如果我们想的话,我们也可以从 1 开始。

对于每一天,我们想要打印该天的所有小时。如果我们考虑一下,我们需要一个循环来处理天数。当我们在这个循环内部时,我们可以想象我们正在处理一个单独的一天。对于这一天,我们需要打印所有的小时。然后,当我们完成时,我们需要为下一天重复这个过程。我们可以不用变量名i,而是为两个循环使用更有意义的名字。我们将使用day来处理天数的循环,使用hour来控制小时的循环。

这就是这个程序应该看起来像的:

 for day = 0 to 7
    print "day " + day
    for hour = 0 to 24
        print "hour " + hour
    end for
 end for

输出将看起来像这样:

day 0
hour 0
hour 1
hour 2
…
hour 23
day 1
hour 0 
hour 1
hour 2
…
day 6
…
hour 21
hour 22
hour 23

注意

我们省略了这部分长输出。省略的部分用省略号表示。

如果我们遵循这个程序的逻辑,我们可以看到我们首先从最外层的循环开始,即处理天数的循环。我们有一个名为day的变量,并将其赋值为起始值0

然后,我们打印文本day,并附加名为day的变量的值。注意,引号内的day是一个字符串,将按原样打印,而引号外的day是一个变量。

然后,我们来到最内层的循环。这个循环将处理小时。它同样从值0开始,并使用一个名为hour的变量。它将以与我们处理天数相同的方式打印当前小时。

程序将在内层循环中运行,直到变量hour中的值达到24。然后,它将退出。现在程序将回到外层循环的开始,即处理天数的循环。它将day变量增加一,并检查它是否小于7。因为它是,我们将进入循环,过程将重复。

for循环中向后移动也是可能的。我们只需要交换起始值和结束值,并减少循环变量而不是增加它。它可以看起来像这样:

for i = 10 to -1 step -1
    print i
end_for

在这里,我们将从10倒数到0。由于我们想要打印的值是0,我们将停止值设置为-1。我们可以将停止值视为不应该包含在范围中的第一个值。由于我们是递减变量,0之后的第一个值是-1。我们还改变了步长为-1。这将导致变量每次递减 1。

有时候,我们不知道我们想要重复多少次。我们可以使用另一种类型的 for 循环,即 while 循环。让我们来探索一下这是什么。

使用 while 循环进行迭代

假设我们想要编写一个小型的掷骰子猜谜游戏。用户需要输入一个介于 1 和 6 之间的猜测。然后电脑将掷骰子并告诉用户他们的猜测是否正确。程序将允许用户再次猜测,再次猜测。然而,如果用户输入 0 作为他们的猜测,我们将允许他们退出游戏。

我们无法知道用户想要玩多少次游戏。他们可能在第一次尝试后就放弃,或者进行数百次尝试(虽然不太可能,因为这是一个相当无聊的游戏,但你应该明白这个意思)。

for 循环在这里不太适用,因为我们需要说明用户需要玩多少次才能让他们退出循环。相反,另一种非常适合这种场景的循环类型是 while 循环。这种循环基于条件而不是计数。如果条件为真,它将继续循环。

结构看起来像这样:

while condition
    …
end_while

如果条件为真,我们将继续循环。这意味着在循环的某个地方,条件必须能够改变,否则它将永远无法退出循环。

我们仍然需要一个变量来在条件中使用。例如,我们可以使用布尔变量。回想一下,布尔变量只能持有 truefalse 的值,而条件是会被评估为真或假的某个东西。它可能看起来像这样:

continue = true
while continue
    …
    some code that eventually sets continue to false
end_while

在这里,我们声明了一个名为 continue 的变量并将其设置为 true

while 循环将检查这个变量的内容,由于它是 true,它将进入循环。

由于变量具有 true 的值,它将继续循环。因此,在循环的某个地方,我们分配一个 false 值给变量,以便我们可以退出循环是非常重要的。

现在我们将使用 while 循环来构建我们的猜谜游戏:

continue = true
while continue
    print "I will roll a dice. Guess the result(end with 0): "
    input guess
    dice = random(1, 6)
    if guess == 0 then
        continue = false
    else
        if guess == dice then
           print "Yes, you got it!"
        else
           print "Sorry, better luck next time."
        end_if
     end_if
end_while
print "Thank you for playing this exciting game."

在深入了解程序及其工作原理之前,先看看以下潜在游戏的输出:

I will roll a dice. Guess the result(end with 0): 4
Sorry, better luck next time
I will roll a dice. Guess the result(end with 0): 2
Sorry, better luck next time
I will roll a dice. Guess the result(end with 0): 6
Yes, you got it!
I will roll a dice. Guess the result(end with 0): 0
Thank you for playing this exciting game.

观察代码,我们可以观察到以下内容:

  • 我们可以看到,我们首先创建了一个变量,用来跟踪何时停止循环。由于 while 循环在某个条件为真时运行,我们将这个变量设置为 true。如果它被设置为 false,我们就不会进入循环,游戏在我们有机会玩之前就会结束。

  • 然后是实际的循环。由于变量最初被设置为 true,我们将进入循环。

  • 循环中首先发生的事情是显示一些文本,为用户提供指令。这是一个很好的主意,让用户知道如何退出游戏。

  • 然后我们获取用户的输入并将其存储在一个名为 guess 的变量中。

  • 现在是时候掷一个虚拟骰子了。random(1, 6)将给我们一个介于 1 到 6 之间的随机数。我们将这个随机数存储在一个名为dice的变量中。

在我们检查用户是否做出了正确猜测之前,我们将检查用户是否输入了0以表示游戏结束。我们在检查猜测是否正确之前这样做的原因是,如果用户想要结束游戏,我们不希望检查他们的猜测,因为我们知道0将表示一个错误的猜测。我们不希望将0的输入视为一个猜测。

如果guess等于0,我们想要退出循环。我们通过将false赋值给continue变量来实现这一点。

由于循环的其余内容都在一个else块中,如果输入是0,我们将跳过那个块。当我们移动到带有while的行时,continue现在将是false,我们将退出循环,并在程序末尾打印感谢的行。

如果用户输入的不是0,我们将进入第一个else块。

这个块内的第一行是我们检查用户是否做出了正确猜测的地方。如果我们存储在guess中的值和dice变量中的值相等,我们就有了赢家。

如果是这样,我们将打印一条祝贺用户的消息。如果不是,我们将告知用户猜测是错误的。

看看代码,我们可以看到代码的缩进有助于我们看出哪个部分属于哪个块。注意以下行开始的块:

if guess == dice then

只有当用户没有输入0的值时,才能达到这一点,因为它在一个else块中。

正如你所见,while循环是一个实用的特性。它有一个兄弟,即do while循环,它与while循环几乎相同,但有一点不同。让我们接下来看看这一点。

使用do while循环进行迭代

do while循环具有与while循环相同的特性。do while基于一个条件,可以在我们不知道需要执行多少次迭代时使用。

while循环的不同之处在于,while循环可能永远不会执行,因为第一次测试条件时它可能是false。相比之下,do while循环保证至少运行一次。这是因为条件从循环的开始移动到了循环的末尾。

这有多个原因可能是有益的,并且可以使我们的猜谜游戏稍微简单一些。然而,在我们这样做之前,我们应该看看do while循环的样子:

do
    …
    some code that eventually sets the condition to false
while condition

do关键字标志着循环的开始。正如你所见,这一行没有其他内容,所以程序必须至少运行一次循环内的代码,才能达到最后的条件。

就像while循环一样,我们必须有一些代码以某种方式修改条件,这样我们才能从循环中退出。

while循环相比,一个有趣的方面是,我们不需要在循环外部创建一个变量来保存我们可以用于条件的值。原因是,由于我们是在循环的末尾检查条件,我们可能能够使用在循环内部创建的变量来执行条件检查。为了看到这一点是如何工作的,让我们修改我们的猜测游戏以使用do while循环。看一下下面的片段:

do
    print "I will roll a dice. Guess the result(end with 0): "
    input guess
    dice = random(1, 6)
    if guess != 0 then
        if guess == dice then
           print "Yes, you got it!"
        else
           print "Sorry, better luck next time."
        end_if
     end_if
while guess != 0
print "Thank you for playing this exciting game."

程序变得更短了。这是因为continue变量已经消失了。如果你查看倒数第二行的条件,你会看到我们正在直接使用guess变量来检查它是否不等于0(记住,!=运算符表示不等于)。这意味着如果用户没有输入0,我们将重复。

我们还改变了循环内的if语句。现在它检查guess变量是否不等于0,只有当它等于0时,我们才会将其视为一个正确的猜测。

如果我们有一系列的事物,例如数组,那么逐个处理那个序列可能很有用。我们确实有一个循环来做这件事,那就是for each循环。让我们接下来探索它是如何工作的。

使用for each遍历序列

当我们有一系列的事物时,我们通常希望逐个处理它们。当然,我们可以使用for循环来实现这一点,如下所示:

names = ["Anna", "Bob", "Carl", "Danielle"]
for i = 0 to names.length
    print "Hi " + names[i]
end_for

在第一行,我们声明了一个包含一些名字的字符串数组。我们使用一个名为names的变量来存储这些值。

然后,我们使用一个从0开始的for循环。为了找出我们将迭代多少次,我们询问数组它目前存储了多少项。我们通过使用names变量,并通过使用点号,我们可以从数组中获取一个称为属性的东西。这个属性是一个存储数组当前项数的值。我们可以询问序列有多少项的方式可能因语言而异,但它很可能是我们这样做的方式。

在这里,我们需要记住两件事:

  • 当使用索引从数组中检索值时,我们从0开始。这意味着我们需要给我们的循环一个起始值为0,因为Anna将存储在那个索引。

  • 我们需要确保结束值是大于for循环中最后一个索引的值。我们的数组有四个值,所以当我们询问它的长度时,我们得到的就是这个值。然而,当我们索引数组时,我们需要使用 0、1、2 和 3 的值。这是四个值,计数从 0 开始,而不是从 1 开始。由于我们知道在for循环中给出的第二个值是结束值,它是我们想要的范围外的下一个值,那么说我们想要在names.length结束确保我们只得到 0、1、2 和 3 的值。

在循环内部,你可以看到我们正在使用loop变量来索引数组。第一次,我们将得到Anna,下一次,我们将得到Bob,依此类推。

做这件事的一个更简单、更安全的方法是使用一种称为for each循环的东西。这样做将会遍历一个序列,并一次给我们一个它的项目。如果我们用同样的前一个代码并使用这样的循环,现在将会像以下这样:

names = ["Anna", "Bob", "Carl", "Danielle"]
foreach name in names
    print "Hi " + name
end_foreach

现在,这要好看得多。我们可以这样阅读:

  • 从序列名称中,给出第一个项目并将它的值存储在name变量中。

  • 在第一次迭代中,name将包含Anna。循环会跟踪它在序列中的位置。因此,在下一个迭代中,name将被赋予Bob的值。

    此外,请注意,我们不需要跟踪序列中有多少个项目,我们也不需要使用任何索引,因为索引是从 0 开始的,而不是从 1 开始。

这种循环给我们提供了更干净、更易读的代码,同时也减少了我们在代码中插入错误的风险。

我们可以使用这个循环遍历任何序列。由于字符串是一系列字符的序列,所以在字符串上使用这个循环将给出字符串由哪些字符组成。考虑以下代码示例:

print "Please enter your name: "
input name
foreach character in name
    print character
end foreach

在这里,我们要求用户输入一个名字,并将答案存储在一个名为name的变量中。然后我们将遍历这个变量,一次一个字符。当前字符将被存储在一个名为character的变量中。在循环内部,我们只需打印这个字符。

运行这个程序将给出如下输出:

Please enter your name: Charlotte
C
h
a
r
l
o
t
t
e

我们已经探讨了四种不同的迭代方式,它们都有各自不同的用途。把它们看作是我们可以随时挑选和使用的一套工具。再加上我们在本章前面看到的工具,工具集将不断改进!

摘要

序列、选择和迭代是编程建立在之上的三个支柱,在本章中,我们已经涵盖了后两个。

选择是在变量中使用条件测试值,这个条件可以是真或假。如果我们的测试结果是正确的,我们可以让程序执行一段代码。如果结果是错误的,我们可以有一个只在那种情况下运行的另一个代码块。这是通过if语句来完成的。

有时候,我们有多个选项可供选择,我们需要从中挑选一个。然后我们可以使用switch语句。使用它而不是if语句可以使你的代码更简洁、更容易阅读。

重复的常见任务至少可以用四种方式完成,其中最常见的是for循环。这个循环将允许我们迭代固定次数。

当我们不知道要迭代多少次时,我们可以使用while循环或do while循环。它们都会在条件为真时迭代。这将使我们能够编写非常灵活的应用程序,可能需要重复两次。

while 循环和 do while 循环之间的区别在于条件的放置位置。在 while 循环中,条件位于开始处,而在 do while 循环中,条件位于末尾。

如果我们有一系列的某个东西,使用 for each 循环是最好的选择,因为它会遍历序列,并一次给我们其中一个对象。这是一个安全的结构,因为它确保我们实际上得到了所有的值,不会错过第一个或最后一个。

在下一章中,我们将借助函数来结构化我们的代码。它们是使我们的代码更容易阅读、理解和维护的绝佳方式。它们也非常适合帮助我们重用我们所编写的代码。

第八章:第八章:理解函数

作为程序员,我们始终应该遵循一些有用的概念。一个是编写易于阅读和理解的代码。另一个是避免代码重复。当你作为程序员开始职业生涯时,你会发现自己正在复制粘贴代码,只是在这里或那里做一些小的改动。这是一个坏习惯,因为它抵消了代码易于阅读的第一个概念,因为反复阅读几乎相同的行是乏味的,而且很难发现微小的差异。

一个更好的解决方案是将我们希望多次重用的代码打包成一个函数。函数是我们给代码块命名的一种方式,然后,通过这个名称,代码块可以在我们想要它执行时被反复调用。

在本章中,你将学习以下内容:

  • 决定什么放入函数

  • 编写函数

  • 从函数返回值

  • 向函数传递参数

  • 与局部和全局变量一起工作

决定什么放入函数

函数是我们将代码块打包并给它命名的一种方式。这有几个原因。在第四章**,软件项目和我们的代码组织方式中,我们讨论了软件模块,将我们的代码分成小块是明智的,因为它会给我们带来易于阅读、更新和维护的代码。同样的原因也适用于函数,因为它们也将我们的代码打包成更小的单元。我们想要使用函数的另一个原因是我们可以轻松地重用代码的某些部分。

当决定将什么放入函数时,我们可以遵循一条经验法则。函数应该始终只做一件事,并且它的命名应该反映这一点。这意味着如果我们有一个名为 send_email_and_print_invoice 的函数,我们就是在做错事。这个函数执行了两个不同的任务,因此应该有两个独立的函数。我们可以用罗伯特·C·马丁(Robert C. Martin)的话来重新表述这个规则,他是关于编写干净代码的优秀书籍的作者:

“函数应该做某事或回答某个问题,但不能两者兼而有之。”

这意味着函数要么应该有一个非常明确的任务并且只做那个任务,不做其他任何事情,要么应该回答一个明确的问题并且只回答那个问题,不做其他任何事情,而且一个函数绝对不应该同时做这两件事。

罗伯特·C·马丁关于函数的另一个引言如下:

“函数的第一规则是它们应该是小的。”

这是一句有趣的话,因为它提出了一些问题。如果我有一个非常明确的问题,我想要将其包装在一个函数中,以便它遵循第一个引号,但问题相当复杂,结果函数最终变成了几百行代码?这不会与第二个引号相矛盾吗?是的,它会,我们需要处理它。我们可以将这个长函数分解成子任务,并将这些子任务移动到单独的函数中。我们如何将函数分解成几个更小的函数可能一开始看起来并不明显,但这是我们在进一步拆分代码部分将要看到的内容。

如果我们带着第一个引号来创建一个函数,它只做一件事,如果这是真的,那么将其分解成更小的事情是否会与第二个引号相矛盾,意味着函数做了几件事?不,不一定。想想你经常做的事情,比如制作早餐。这是一件单独的事情,但它可以分解成几个更小的部分,这些部分共同构成了烹饪早餐的任务。你会做咖啡或茶,烤一些面包,煮一个鸡蛋,等等。

观察以下图:

图 8.1. 使用其他函数来完成其任务的函数

图 8.1 – 使用其他函数来完成其任务的函数

上述图示说明了如何将一个函数(这里称为函数 A)分解成几个更小的函数,为了完成其任务,它使用了函数 B函数 C函数 D的子任务。将此与以下图示进行比较,其中我们以相同的方式展示制作早餐:

图 8.2. 将制作早餐的任务分解为子任务

图 8.2 – 将制作早餐的任务分解为子任务

函数是计算机程序的重要构建块;让我们看看我们如何编写它们。因此,在了解一个函数由什么组成之后,让我们看看如何使用它。

编写一个函数

让我们看看函数在代码中的样子:

function name()
   code within this function
   more function code
end_function

在这里,我们看到一个函数有一个名字,就像一个变量一样。一种语言用于命名函数的约定通常与用于变量的约定相同。如果你不记得那些是什么,你可以在第六章**,与数据一起工作 – 变量命名约定*部分找到它们。

在函数名之后,我们找到开括号和闭括号。每次我们提到这个函数时,它们都会跟在名字后面。它们还会有另一个用途,我们将在本章后面看到。

之后,我们看到一个代码块,循环和if语句的主体等。这是当函数被调用时将执行的代码。

函数可以通过其名称来调用,我们绝不能忘记括号。调用前面的函数看起来像这样:

name()

这个函数内的所有代码现在都会运行。一旦完成,程序执行将返回到调用发生的地方,并继续执行。

以下程序说明了这一点:

function say_hi()
    print "Hi there!"
end_function
print "Before the function"
say_hi()
print "After the function"

即使函数的代码在函数的其他部分之上,它也不会执行,直到我们调用函数,所以运行这个程序时的输出将是这样的:

Before the function
Hi there!
After the function

我们现在可以像我们喜欢的那样频繁地调用函数,并且每次调用时它内部的代码都会运行。例如,检查以下代码:

function say_hi()
    print "Hi there!"
end_function
print "Before the function"
say_hi()
say_hi()
say_hi()
print "After the function"

我们可以看到我们调用了三次函数。这在输出中得到了反映,如下所示:

Before the function
Hi there!
Hi there!
Hi there!
After the function

现在我们知道了函数的样子,我们可以尝试编写一个更现实的函数,看看它如何使代码更容易阅读和结构化。

将代码移动到函数中

假设我们正在编写一个程序,计算自午夜以来过去了多少秒。

这个代码可能看起来像以下这样:

time_now = now()
time_midnight = Date(time_now.year, time_now.month. time_now.    day)
time_diference = time_now – time_midnight
seconds_since_midnight = time_difference.total_seconds()

这段代码首先获取当前的日期和时间。请注意,这是一个完整的日期时间,即年、月、日、小时、分钟和秒。所有这些都被存储在time_now变量中。

在第二行,我们创建了一个对象,它将保存上一个午夜的时间和日期。我们使用当前年、月和日。因为我们有当前日期和时间存储在time_now变量中,我们可以使用它来获取当前年、月和日。这将把小时、分钟和秒设置为 0,因为我们没有为它们提供值。这个想法是,今天的日期,hour = 0minute = 0second = 0就是上一个午夜。

然后,我们取当前的日期和时间,减去午夜的时间和日期。这将给出现在和过去午夜之间的时间差。

最后,我们使用这个差值并将其转换为总秒数。在这种情况下,time_difference变量是一个可以保存时间信息的对象,即自午夜以来过去了多少小时、分钟和秒。这如何工作会因语言而异。在这里,这个对象提供了给我们这个时间以总秒数的功能。现在我们有一个名为seconds_since_midnight的变量,它包含了自上次午夜以来经过的秒数。

但如果我们想在程序中的多个地方反复这样做,我们最终会得到类似这样的结果:

time_now = now()
time_midnight = Date(time_now.year, time_now.month. time_now.    day)
time_diference = time_now – time_midnight
seconds_since_midnight = time_difference.total_seconds()
do some other stuff
do some more stuff
time_now = now()
time_midnight = Date(time_now.year, time_now.month. time_now.    day)
time_diference = time_now – time_midnight
seconds_since_midnight = time_difference.total_seconds()

如我们所见,计算时间的四行代码被重复了。而且我们每次想要得到自午夜以来经过的秒数时都需要这样做。

一个更好的解决方案是将执行计算的代码移动到函数中。它可能看起来像这样:

function calculate_seconds()
   time_now = now()
   time_midnight = Date(time_now.year, time_now.month. time_      now.day)
   time_diference = time_now – time_midnight
   seconds_since_midnight = time_difference.total_seconds()
end_function

每次我们需要进行计算时,现在都可以调用函数,我们不再需要重复编写代码。

完整的程序现在看起来是这样的:

function calculate_seconds()
   time_now = now()
   time_midnight = Date(time_now.year, time_now.month. time_      now.day)
   time_diference = time_now – time_midnight
   seconds_since_midnight = time_difference.total_seconds()
end_function
calculate_seconds()
do some other stuff
do some more stuff
calculate_seconds()

如我们所见,这要好得多,因为我们只需要编写一次计算时间的代码。但我们确实有一个问题。计算的结果被困住在函数内部。通过困住,我的意思是,我们计算了正确的秒数,但获得的价值无法从函数中出来,所以我们无法在函数外部使用这个值。为了解决这个问题,我们需要将结果返回给调用者。让我们看看这是如何工作的。

从函数返回值

函数背后的思想是,它不仅可以用作封装代码以便我们可以重复使用,还可以执行某种会产生某种值的事情。在我们的时间计算器示例中,函数已经计算了一个结果,即从午夜开始经过的秒数,我们现在想在调用函数的位置得到这个值。函数具有返回数据的能力,这是我们用来获取值的功能。

在其最简单的形式中,从函数返回值的工作方式如下:

function greet()
    return "Hello my friend"
end_function
result = greet()
print result

在这里,我们有一个名为greet的函数。它所做的只是返回一个包含问候语Hello my friend的字符串。记住,函数内的代码只有在函数实际被调用时才会执行。调用发生在函数下方。考虑以下行被调用时会发生什么:

result = greet()

事情就像往常一样进行。赋值运算符(=)右侧的内容首先被执行。这是对函数的调用。

现在,程序将跳转到实际的函数,执行其中的代码。函数内部只有这一行:

return "Hello my friend"

我们必须理解关于这一行两件事。首先,带有return关键字的行将退出函数,即使它后面还有更多的代码。这听起来可能有些奇怪,我们很快就会回到这一点。其次,它将返回return一词之后的内容返回给调用函数的位置。

这将带我们回到这一行:

result = greet()

我们现在已经完成了对=右侧的操作,这返回了Hello my friend字符串。这个字符串现在被分配给了result变量。

在最后一行,打印了该变量的内容。

现在,这是一个愚蠢的程序,因为函数总是返回相同的东西,但它说明了return是如何工作的。我们现在可以在我们的计算午夜以来秒数的函数中使用它,如下所示:

function calculate_seconds()
   time_now = now()
   time_midnight = Date(time_now.year, time_now.month. time_      now.day)
   time_diference = time_now – time_midnight
   seconds_since_midnight = time_difference.total_seconds()
   return seconds_since_midnight
end_function

我们通过在末尾添加一行返回seconds_since_midnight变量的内容来实现这一点。

由于这个变量是在return上方的一行创建的,我们实际上可以删除它,而是立即返回结果,如下所示:

function calculate_seconds()
   time_now = now()
   time_midnight = Date(time_now.year, time_now.month. time_      now.day)
   time_diference = time_now – time_midnight
   return time_difference.total_seconds()
end_function

我们现在可以反复调用这个函数,并得到午夜以来的当前秒数,如下所示:

seconds_since_midnight = calculate_seconds()
print "Seconds since midnight: " + seconds_since_midnight
do some other stuff
do some more stuff
seconds_since_midnight = calculate_seconds()
print "Seconds since midnight: " + seconds_since_midnight

在这里,我们将从函数返回的内容存储在一个名为seconds_since_midnight的变量中。然后我们打印一个与结果一起的文本。输出可能看起来像这样:

Seconds since midnight: 36769

这很棒,因为现在我们可以封装一段代码,并且我们可以随时调用它来运行这段代码。我们还可以从函数中获取数据。但还有一件事情缺失。如果我们想向函数发送数据怎么办?我们可以通过函数参数来实现这一点。

函数参数

通常,我们希望我们的函数具有一定的灵活性,这样它们每次调用时都不做完全相同的事情。考虑以下两个函数:

function add_2_and_3()
    return 2 + 3
end_function
function add_5_and 9()
    return 5 + 9
end_function

这两个函数相加两个数字并返回结果。第一个相加23,第二个做同样的事情,但使用59。现在,这些只是两对数字。如果我们想有一个可以相加任何数字的函数,我们需要创建无数个函数。

但如果我们看看函数做了什么,我们可以看到它们实际上是在做同样的事情。它们相加两个数字并返回结果。唯一改变的是用于加法操作的数字。

我们希望能够传递我们想要相加的数字给函数,以便它可以在计算中使用它们,并且通过这种方式,只有一个可以相加任何两个数字的函数。

我们可以向函数传递数据。我们说函数可以接受参数。对于一个可以相加两个数字的函数,它可以看起来像这样:

function add(number1, number2)
    return number1 + number2
end_function

在这里,我们可以看到我们现在已经使用了跟随函数名称的括号。这就是我们可以指定接收我们想要传递给函数的数据的变量。现在我们可以这样使用这个函数:

result = add(73, 8)
print(result)
result = add(2, 3)
print result
result = add(5, 9)
print result

当运行此程序时,输出将是这样的:

81
5
14

如我们所见,我们现在可以使用一个单独的函数来处理任何两个数字。让我们更仔细地检查一下,以真正理解它是如何工作的。

让我们移除对函数的两次调用,只关注我们第一次调用函数时会发生什么。我们将得到以下代码:

function add(number1, number2)
    return number1 + number2
end_function
result = add(73, 8)
print result

当我们调用函数时,我们正在传递值738。这些被称为参数。在接收方,即在add函数中,这两个值将被分配给两个变量,number1number2。这些被称为函数的参数。我们传递的第一个参数,值73,被分配给第一个参数number1,而第二个参数,值8,将被分配给名为number2的参数。

当我们进入函数体时,我们现在有两个变量,并且它们被分配了传递给函数的值。所以这次,变量将分别具有 73 和 8 的值。

当我们用值23调用函数时,number1将得到2,而number2将得到3。这意味着函数中的参数将以与传递给函数的参数相同的顺序接收数据:

add(4, 5)

在这里,number1将得到值4,而number2将得到值5

add(5, 4)

在这里,情况相反,number1得到5,而number2得到4

现在我们可以创建可以接受参数并返回值的函数。让我们把这些放在一起,并在一个更现实的应用中使用它,我们的自动灯光应用。

现在,有一个将两个数字相加的函数是没有意义的,因为我们已经有了执行加法的运算符。这里只用来说明参数的概念。

函数在行动

如果我们再次回到我们打开户外灯光的应用程序,并关注运行在手机上的应用程序,我们会看到我们至少需要反复做两件不同的事情。

我们需要获取我们的当前位置,并计算我们到家的距离,这样我们才知道是否是时候打开灯光了。

这些是两个不同的任务,非常适合打包成两个不同的函数,即get_current_positioncalculate_distance_to_home,如图所示:

图 8.3:main_application 调用两个不同的函数

图 8.3 – main_application 调用两个不同的函数

这个图示显示我们有一个我们称之为main_application的块。这是一个函数,它调用一个名为get_current_position的函数,该函数将返回经纬度,指示手机的当前位置。有了这些信息,main_application现在可以调用另一个函数,这次是调用名为calculate_distance_to_home的函数,并将刚刚获得的经纬度传递给它。这个函数将进行一些计算,并返回truefalse,指示我们是否在范围内。

问题在于,我是如何决定将代码分成这些函数的?我们是否真的需要它们,或者这个程序能否在不使用它们的情况下编写?答案是肯定的,它可以,我们现在将看到为什么使用函数而不是将所有代码放在一个地方是一个好主意。

第七章**,程序控制结构中,我们有以下图示:

图 8.4. 电话应用的流程图

图 8.4 – 电话应用的流程图

在这里,我们可以看到我们有一个无限循环(记住编程中的无限意味着只要程序在运行)。我们还有一个选择语句,检查我们是否在范围内,如果是,则发送信号。之后,无论是否发送了信号,我们都会回到开始处再次请求我们的当前位置。

现在,想象一下这个应用的代码。它可能看起来像这样:

while true
    some code that connects to the phone GPS
    some code that asks the GPS for the current position
    some code the calculates the distance to our home
    if distance_to_home < range_limit then
        some code that connects to the home computer
        some code that sends a notification to home computer 
    end_if
end_while

每一行以代码开头的内容,在真实应用中很可能是由几行代码组成的。

如果我们阅读代码,我们可以看到我们从一个while循环开始,只要true为真,这个循环就会运行。true始终为真,所以这就是我们如何创建一个无限循环,它会一直运行下去。

如果你将程序与图 8.4中的流程图进行比较,你会发现逻辑是相同的:

  • 顶部的椭圆形是代码中的两行,它连接到 GPS 并与 GPS 通信。

  • 随后跟随椭圆形的矩形表示计算距离的代码。

  • 钻石形状是if语句,而if语句中的代码是流程图中的最后一个矩形。

  • 将我们带回到顶部的两个箭头是封装其余代码的while循环。

看着代码,可能看起来并不那么糟糕,但请记住,这只是一个需要完成的任务的概要。

如果我们现在开始修改它,看看程序实际上可能是什么样子,我们可以将前两行修改成这样:

location = Location
my_latitude = location.get_latitude()
my_longitude = location.get_longitude()

第一行创建了一个可以用来与 GPS 通信的某种位置对象。请注意,我们将此对象存储在一个名为location的变量中,所有字母均为小写。右侧的Location(首字母大写L)是我们用来创建此对象的东西。要了解这是如何实际完成的,我们需要阅读我们目前正在使用的编程语言的文档。很可能会在类似这样的行中完成。

然后,我们可以使用这个location变量来获取当前的纬度和经度。

计算两个地理坐标之间的距离是我们大多数人甚至无法想象应该如何去做的事情。在网上搜索可能会让我们感到害怕。我们可能会得到这样的结果:我们必须使用一种称为哈弗辛公式的东西(还有其他方法可以完成这个任务,但在这个例子中,我们将坚持使用这个公式)。然后,我们会得到一些解释公式的类似内容:

在这里,ϕ是纬度,λ是经度,R是地球的半径(平均半径为 6,371 公里)。请注意,所有角度都需要以弧度为单位。

如果你对这些都不理解,不要害怕。很可能是其他人已经看过这个公式,并将其翻译成了你使用的语言的源代码。这是好事,因为我们可以在不了解公式的情况下使用它。不过,我们必须确保使用它所做的计算与其他执行相同任务的工具相匹配。我们很快就会进行这样的测试,但首先,让我们看看这样的实现可能是什么样子。

在这个例子中,我们假装自己在巴黎的字母和手稿博物馆,并在同一城市的维克多·雨果大道上租了一栋房子。字母和手稿博物馆位于纬度 48.855421 和经度 2.327688。我们在维克多·雨果大道上的假想家园位于纬度 48.870320 和经度 2.286560:

my_latitude = 48.855421
my_longitude = 2.327688
home_latitude = 48.870320
home_longitude = 2.286560
earth_radius = 6371
my_lat_radians = radians(my_latitude)
my_long_radians = radians(my_longitude)
home_lat_radians = radians(home_latitude)
home_long_radians = radians(home_longitude)
lat_distance = home_lat_radians – my_lat_radians
long_distance = home_long_radians – my_long_radians
a = sin(lat_distance / 2)**2 + cos(my_lat_radians) * 
    cos(home_lat_radians) * sin(long_distance / 2)**2
distance = 2 * earth_radius * asin(sqrt(a))
print "The distance is " + distance + " km"

哇,这很可怕,但好事是我们不需要理解它就能使用它。我们只需确保它工作。如我们所见,在顶部,我们有博物馆(我们假装我们要参观)的坐标,然后是我们家(我们假装我们要租)的坐标。运行这个程序将给出以下输出:

The distance is 3.4345378351411333 km

将这些相同的坐标输入在线地理坐标距离计算器,将给出图 8.5中所示的结果:

图 8.5:在线地理坐标距离计算器

图 8.5 – 在线地理坐标距离计算器

如我们所见,结果与我们的相符。我们应该通过尝试其他坐标来执行更多测试,无论是在我们的程序中还是在计算器中,以验证结果是否匹配。

太好了,现在即使我们不完全理解它是如何工作的,我们也可以进行这个计算。

我们现在有一个距离,它告诉我们我们离巴黎家有多远。现在我们需要决定在打开灯之前我们需要离家多近。也许 500 米是一个好的距离,因为我们不想太早或太晚打开它;500 米是 0.5 公里,所以这是我们比较距离的值:

if distance < 0.5 then
    some code that connects to the home computer
    some code that sends a notification to home computer 
end_if

现在,让我们忽略连接到家用电脑并发送通知的代码,只是把到目前为止我们所拥有的所有东西放在一起:

while true
    location = Location
    my_latitude = location.get_latitude()
    my_longitude = location.get_longitude()
    home_latitude = 48.870320
    home_longitude = 2.286560
    earth_radius = 6371
    my_lat_radians = radians(my_latitude)
    my_long_radians = radians(my_longitude)
    home_lat_radians = radians(home_latitude)
    home_long_radians = radians(home_longitude)
    lat_distance = home_lat_radians – my_lat_radians
    long_distance = home_long_radians – my_long_radians
    a = sin(lat_distance / 2)**2 + cos(my_lat_radians) *    
        cos(home_lat_radians) * sin(long_distance / 2)**2
    distance = 2 * earth_radius * asin(sqrt(a))
    if distance < 0.5 then
        some code that connects to the home computer
        some code that sends a notification to home computer 
    end_if
end_while

阅读这段代码很难,也很令人困惑。我们能做什么?函数是答案。让我们看看它是如何工作的。

进一步拆分代码

当将长代码拆分成小函数时,我们必须记住一个函数应该做一件事或回答一个问题。

我们可以用来识别代码中单独任务的一个技巧是从头到尾阅读它,当我们感觉到应用程序的焦点从一件事转移到另一件事时,我们可以在代码中插入一个空行。让我们为到目前为止我们所使用的应用程序做这件事:

while true
    location = Location
    my_latitude = location.get_latitude()
    my_longitude = location.get_longitude()
    home_latitude = 48.870320
    home_longitude = 2.286560
    earth_radius = 6371
    my_lat_radians = radians(my_latitude)
    my_long_radians = radians(my_longitude)
    home_lat_radians = radians(home_latitude)
    home_long_radians = radians(home_longitude)
    lat_distance = home_lat_radians – my_lat_radians
    long_distance = home_long_radians – my_long_radians
    a = sin(lat_distance / 2)**2 + cos(my_lat_radians) *    
        cos(home_lat_radians) * sin(long_distance / 2)**2
    distance = 2 * earth_radius * asin(sqrt(a))
    if distance < 0.5 then
        some code that connects to the home computer
        some code that sends a notification to home computer 
    end_if
end_while

如我们所见,现在有通过空行分隔的代码块。让我们仔细研究它们。

对于第一行中的while,我们无能为力,至少不是现在。它将静置在顶部并确保我们反复执行代码。

while语句的下一行之后,有三行都与确定我们的位置有关。在阅读代码时,我们应该问自己,这一行帮助我们完成什么任务?对于所有这些行,答案将是回答我们在哪里的问题。但当我们遇到以home_latitude开头的行时,这不再成立。我们现在在一个执行其他操作的代码块中。代码的焦点已经转移,所以我们插入了一个空白行。

我们现在有两行代码回答了“我们住在哪里?”的问题。它们显然是相关的。但在这两行代码之后,有一行定义了地球的半径。这是一个明显的焦点转移,那么为什么我没有在这里插入一个空白行呢?

如果我们仔细观察这三行,会发现它们都有共同点。它们都有固定值,这些值永远不会改变。我们称这些为常量值。我们稍后会处理它们,但让我们继续前进。

然后,我们来到了一个处理距离计算的较大代码块。这是一个单一的任务。

最后,我们有一个包含向家用电脑发送信号的if语句,但我们迄今为止还没有实现。

首先,我们有两个强有力的候选者可以成为函数,那就是我们获取当前位置和距离计算的地方。让我们尝试将它们转换为函数,并从告诉我们我们所在位置的部分开始。

目前,这部分代码看起来是这样的:

    location = Location
    my_latitude = location.get_latitude()
    my_longitude = location.get_longitude()

我们可以将这三行移动到一个函数中,如下所示:

function get_current_position()
    location = Location
    my_latitude = location.get_latitude()
    my_longitude = location.get_longitude()
end_function

现在,我们必须检查两件事。首先,这个函数需要任何输入数据吗?答案是肯定的。为了完成其任务,它不需要更多的数据。这意味着这个函数将不接受任何参数。

其次,这个函数是否需要将任何数据返回到我们调用函数的位置?对这个问题的答案是肯定的,但有点棘手。

当前经纬度现在是在一个函数内部创建的,这使得它在外部不可访问。这些被称为局部变量,我们将在本章末尾详细讨论这个话题。我们之前在计算午夜以来秒数的函数中也遇到了相同的问题。我们当时通过使用return关键字返回结果来解决它。我们在这里也需要这样做,但大多数编程语言只允许我们从函数中返回一个单一值,但我们需要返回两个值,即纬度和经度。

我们可以通过将这两个值放入一个数组中来解决这个问题。记住,数组是一系列事物,即使它包含许多值,数组也被视为一个单一的项目。

我们可以修改我们的函数,使其看起来像这样:

function get_current_position()
    location = Location
    my_latitude = location.get_latitude()
    my_longitude = location.get_longitude()
    return [my_latitude, my_longitude]
end_function

注意,这两个值都在方括号内。这创建了一个数组,并将latitude作为其第一个值,longitude作为第二个值。

现在,我们必须调用这个函数,并在我们之前获取位置的代码中接收location坐标。现在的while循环看起来是这样的:

function get_current_position()
    location = Location
    my_latitude = location.get_latitude()
    my_longitude = location.get_longitude()
    return [my_latitude, my_longitude]
end_function
while true
    position = get_current_position()
    home_latitude = 48.870320
    home_longitude = 2.286560
    earth_radius = 6371
    my_lat_radians = radians(my_latitude)
    my_long_radians = radians(my_longitude)
    home_lat_radians = radians(home_latitude)
    home_long_radians = radians(home_longitude)
    lat_distance = home_lat_radians – my_lat_radians
    long_distance = home_long_radians – my_long_radians
    a = sin(lat_distance / 2)**2 + cos(my_lat_radians) *    
        cos(home_lat_radians) * sin(long_distance / 2)**2
    distance = 2 * earth_radius * asin(sqrt(a))
    if distance < 0.5 then
        some code that connects to the home computer
        some code that sends a notification to home computer 
    end_if
end_while

while循环的第一行内部,我们现在正在调用函数。我们将得到包含纬度和经度的数组,并将它们存储在我们称为position的变量中。

现在,我们有一个问题,因为我们稍后计算距离时将使用变量my_latitudemy_longitude。这两个现在只存在于函数内部,所以当我们到达将它们转换为弧度的行时,我们会得到一个错误,说my_latitude未定义。这就是编程语言告诉你它不知道my_latitude是什么的方式。

这里的问题是,我们将坐标包装在一个名为position的数组中。我们可以通过用以下内容替换这两个有问题的行来解决这个问题:

    my_lat_radians = radians(position[0])
    my_long_radians = radians(position[1])

记住,我们可以通过使用数组中项目的索引来访问数组中的单个项目。也要记住,索引从 0 开始。

注意

如果你需要关于数组如何工作的提醒,我们已经在第六章**,在复合类型部分中讨论了它们,即使用数据 – 变量

由于我们将纬度作为数组的第一个元素添加,它可以在索引 0 处找到,经度在索引 1 处。由于我们必须知道函数内部数组是如何创建的,以便知道纬度在经度之前,所以这段代码更难阅读。

另一个可以使我们的代码更容易阅读的选项是将这两个值再次解包到变量中,如下所示:

    position = get_current_position()
    my_latitude = position[0]
    my_longitude = position[1]

现在,在调用之后,我们将第一个值插入到名为my_latitude的变量中,第二个值插入到名为my_longitude的变量中。由于我们选择了后来在计算中使用的相同名称,如果我们使用这个选项,我们根本不需要更改它。

我将选择第三个选项,现在暂时保留数组中的变量,不更改计算代码。我们很快就会看到原因。

现在,我们可以将注意力转向计算代码,并将其转换为函数。这个函数现在看起来是这样的:

function calculate_distance_to_home()
    my_lat_radians = radians(my_latitude)
    my_long_radians = radians(my_longitude)
    home_lat_radians = radians(home_latitude)
    home_long_radians = radians(home_longitude)
    lat_distance = home_lat_radians – my_lat_radians
    long_distance = home_long_radians – my_long_radians
    a = sin(lat_distance / 2)**2 + cos(my_lat_radians) *    
        cos(home_lat_radians) * sin(long_distance / 2)**2
    distance = 2 * earth_radius * asin(sqrt(a))
end_function

再次,我们现在将检查这个函数是否需要更多的数据来完成其任务。是的,按照现在的写法,我们遗漏了几件事。这个函数不知道我们的纬度和经度。它也不知道家庭纬度和经度,earth_radius对它来说也是未知的。

让我们把注意力转回到定义家庭位置和地球半径的三个地方。谁会需要这些数据?当我们思考这个问题时,答案是,只有我们刚刚创建的函数。这意味着我们可以将这些三行移动到函数中,如下所示:

function calculate_distance_to_home()
    home_latitude = 48.870320
    home_longitude = 2.286560
    earth_radius = 6371
    my_lat_radians = radians(my_latitude)
    my_long_radians = radians(my_longitude)
    home_lat_radians = radians(home_latitude)
    home_long_radians = radians(home_longitude)
    lat_distance = home_lat_radians – my_lat_radians
    long_distance = home_long_radians – my_long_radians
    a = sin(lat_distance / 2)**2 + cos(my_lat_radians) *    
        cos(home_lat_radians) * sin(long_distance / 2)**2
    distance = 2 * earth_radius * asin(sqrt(a))
end_function

我们将它们添加到顶部,这样它们在函数稍后需要时就已经定义了。

现在,我们只缺少我们当前位置的纬度和经度。这个函数必须接受它们作为它们的参数。我们现在可以说这个函数接受两个参数,latitudelongitude,作为单独的参数,如下所示:

function calculate_distance_to_home(my_latitude, my_longitude)

另一个选项是,我们可以让这个函数接受一个包含值的数组。这符合我们的需求,因为我们知道我们将从我们编写的另一个函数中得到一个数组。所以,让我们使用这个选项。

它看起来会是这样:

function calculate_distance_to_home(current_position)

我们现在接受一个数组,我们称它为 current_position。我们现在必须在函数内部使用 my_latitudemy_longitude 的行上进行一个更改。我们可以像之前看到的那样做,并按如下方式索引到数组中:

    my_lat_radians = radians(current_position[0])
    my_long_radians = radians(current_position[1])

完整的函数现在看起来是这样的:

function calculate_distance_to_home(current_position)
    home_latitude = 48.870320
    home_longitude = 2.286560
    earth_radius = 6371
    my_lat_radians = radians(current_position[0])
    my_long_radians = radians(current_position[1])
    home_lat_radians = radians(home_latitude)
    home_long_radians = radians(home_longitude)
    lat_distance = home_lat_radians – my_lat_radians
    long_distance = home_long_radians – my_long_radians
    a = sin(lat_distance / 2)**2 + cos(my_lat_radians) *    
        cos(home_lat_radians) * sin(long_distance / 2)**2
    distance = 2 * earth_radius * asin(sqrt(a))
end_function

把它们放在一起

现在我们必须检查这个函数是否需要返回某些内容。它计算距离,我们需要这个值在函数外部,所以这个值需要被返回。

再次,我们有两种选择。我们可以返回变量 distance 中包含的值,如下所示:

    distance = 2 * earth_radius * asin(sqrt(a))
    return distance

或者,如果不是前面的选项,我们可以使它更短,因为 distance 变量只用于保存一行距离。所以,我们不是用它来保存我们想要返回的值,而是可以直接从计算中返回结果,并去掉 distance 变量。它看起来会是这样:

    return 2 * earth_radius * asin(sqrt(a))

这样更好。函数现在已经完成,看起来是这样的:

function calculate_distance_to_home(current_position)
    home_latitude = 48.870320
    home_longitude = 2.286560
    earth_radius = 6371
    my_lat_radians = radians(current_position[0])
    my_long_radians = radians(current_position[1])
    home_lat_radians = radians(home_latitude)
    home_long_radians = radians(home_longitude)
    lat_distance = home_lat_radians – my_lat_radians
    long_distance = home_long_radians – my_long_radians
    a = sin(lat_distance / 2)**2 + cos(my_lat_radians) *    
        cos(home_lat_radians) * sin(long_distance / 2)**2
    return 2 * earth_radius * asin(sqrt(a))
end_function

我们现在需要调用它。我们的 while 循环现在更加简洁,其主要职责是调用我们的两个函数。循环现在看起来是这样的:

while true
    position = get_current_position()
    distance = calculate_distance_to_home(position) 
    if distance < 0.5 then
        some code that connects to the home computer
        some code that sends a notification to home computer 
    end_if
end_while

我们通过传递 position 数组来调用这个新函数,并将得到的距离存储在一个名为 distance 的变量中。

现在,这看起来更令人愉快。如果你这么想,你可以把它当作一本书的目录。在第一行,我们调用一个名为 get_current_position 的函数。函数的名称被选择来反映它所做的事情。所以,阅读这一行解释了发生了什么。我们现在可以决定我们是否对看到我们获取当前位置时发生的事情感兴趣。如果不感兴趣,我们就可以接受我们得到了当前位置。如果我们想了解它是如何工作的,我们可以去函数那里阅读代码。

我们可以以同样的方式处理下一行。函数的名称告诉我们它做什么,所以我们不需要去读它。我们可以相信它完成了它的任务,并且我们得到了一个距离。

由于使用了函数,代码现在更容易阅读、维护和更新。另一个好处是,复杂的距离计算被隐藏在函数中,如果我们不想看到它,我们就不需要看到它。

我们现在只剩下if语句内部的这部分。为了与家用电脑通信,我们可以使用一种叫做套接字的东西。套接字的概念相当高级,我们在这里不会深入细节。我们只能说,所有这些代码都将进入它自己的函数,并且我们可以通过使用一个看起来像这样的最终while循环在if语句内部调用该函数:

while true
    position = get_current_position()
    distance = calculate_distance_to_home(position) 
    if distance < 0.5 then
        notify_home_computer()
    end_if
end_while

将其与我们的起始代码进行比较,它看起来像这样:

while true
    location = Location
    my_latitude = location.get_latitude()
    my_longitude = location.get_longitude()
    home_latitude = 48.870320
    home_longitude = 2.286560
    earth_radius = 6371
    my_lat_radians = radians(my_latitude)
    my_long_radians = radians(my_longitude)
    home_lat_radians = radians(home_latitude)
    home_long_radians = radians(home_longitude)
    lat_distance = home_lat_radians – my_lat_radians
    long_distance = home_long_radians – my_long_radians
    a = sin(lat_distance / 2)**2 + cos(my_lat_radians) *    
        cos(home_lat_radians) * sin(long_distance / 2)**2
    distance = 2 * earth_radius * asin(sqrt(a))
    if distance < 0.5 then
        some code that connects to the home computer
        some code that sends a notification to home computer 
    end_if
end_while

这是对代码的重大清理,确实非常有帮助,不仅不那么令人恐惧,而且看起来更令人愉悦!

在本节中,我们看到了当我们在一个函数内部创建一个变量时,它对函数外部的所有代码都不可访问。我们现在将进一步讨论局部变量和全局变量。

局部变量和全局变量

在函数内部声明(创建)的变量被称为局部变量,并且只能从函数内部访问。在函数外部,它就像变量根本不存在一样。请检查以下代码:

function my_function()
    name = "Lisa"
end_function
my_function()
print name

这里,我们在my_function函数内部创建并给name变量赋值。在函数外部,我们首先调用函数,然后尝试打印名称。程序将在尝试打印名称的行上崩溃并出现错误。原因是name变量在这个程序部分是未知的。它只在我们执行函数内部的代码时有效。

这是一个局部变量。因为它是在函数内部创建的,所以它是局部的。

如果我们改变程序,使其看起来像这样,事情将会不同:

name = "Bill"
function my_function()
    name = "Lisa"
end_function
my_function()
print name

这里可能很难看出发生了什么。为了理解这一点,我们必须像编译器/解释器在执行它时那样阅读代码:

  • 它将从第一行开始,看到我们创建了一个名为name的变量,并将Bill字符串赋值给它。

  • 然后,它将继续并看到函数。它不会运行函数;只是记住它存在。

  • 然后,我们调用函数,因此程序的执行将现在跳上去运行函数内部的代码。

  • 这里我们找到了一行,我们将Lisa字符串赋值给一个名为name的变量。因为它已经知道这个变量,它会更改其内容,并将Lisa存储在其中,而Bill字符串现在消失了。

  • 函数的结尾现在到达了,所以执行跳回到它来的地方。

  • 在最后一行,变量name的内容将被打印出来,它是Lisa

从前面代码的工作原理中,我们看到当我们将变量的声明从函数中移出时,它变成了全局的(用于代码),因此现在它是一个全局变量。全局变量可以从任何位置访问。

一些编程语言不会让我们修改全局变量,就像我们在前面的例子中所做的那样,但取而代之的是,在函数内部对Lisa的赋值,它将创建一个新的具有相同名称的变量。

听起来可能全球变量是最佳选择。但实际上正好相反。我们应该始终努力尽可能多地使用局部变量。原因是控制全局变量很困难。

想象一下,你有一个数千行代码的程序,所有变量都是全局的。它们在这里和那里以及各个地方都被改变,然后你发现其中一个变量的值变得不可能。某个地方,有一行代码以错误的方式改变了这个变量。这是我们程序中的一个错误,我们需要找到并修复它。但那行代码在哪里?由于程序的所有部分都可以改变这个变量(以及所有被声明为全局的变量),找到这个位置可能非常困难。也可能是因为这个变量有错误值的原因是其他某个变量也有错误值,而我们发现的错误只是一个副作用。

这样的代码被称为意大利面代码。如果你查看图 8.6,它展示了五个函数如何改变四个全局变量,那么这个名称的由来就会变得明显。看看它,并尝试弄清楚是什么被改变了,以及何时被改变的:

图 8.6. 五个函数改变四个全局变量

图 8.6 – 五个函数改变四个全局变量

通过使用局部变量,我们可以使事情变得更加清晰和易于理解。我们可以通过将数据作为参数传递给函数,并将函数的结果返回到调用它的位置来实现这一点。

我们还应该注意,函数参数被认为是局部变量。这意味着如果我们查看我们之前创建的calculate_distance_from_home函数,我们可以看到我们有一个名为current_position的参数:

function calculate_distance_to_home(current_position)

它将在该函数内被当作局部变量处理。

摘要

在本章中,我们看到了函数是一个强大的工具,我们可以用它来组织和结构我们的代码,使其更易于阅读和重用。

我们看到了函数有一个名字,并且可以使用这个名字来调用它们。调用函数会使函数内部的代码执行。这是我们可以在需要时重复多次的操作。

有时候,我们希望函数在操作后产生一个结果值。在这种情况下,我们可以让函数返回一些东西。然后我们可以在调用函数的位置使用那个值。

然后,我们学习了我们还可以将数据传递给函数。这可以通过函数参数来实现。函数通过这些参数接收局部变量,称为参数。

随着函数的引入,我们也有了变量是全局还是局部的概念。我们看到了全局变量可以从程序中的任何位置访问,而局部变量只能在创建它们的函数内部使用。

有时候,事情可能不会按照计划进行,我们会发现我们的程序要么产生了错误的结果,要么简单地崩溃了。在下一章中,我们将看到我们如何识别程序中的错误并处理那些难以预测的错误。

第九章:第九章:当事情出错时 – 虫子和异常

编写软件可能很困难,当我们编写它时,我们会犯错误。我们无意中会在我们的应用程序中引入虫子。

其中一些可能很容易找到和修复,但有些可能会让我们在代码中迷失方向,试图理解为什么事情没有按照我们预期的那样工作。

人们花费数天甚至数周时间试图追踪虫子的情况并不少见。为了能够在我们的应用程序中找到虫子,我们需要了解存在哪些类型的虫子以及它们如何影响我们应用程序的运行方式。本章将帮助我们识别它们。

本章中,你将了解以下主题:

  • 理解软件虫子

  • 使用调试器查找虫子

  • 与异常一起工作

  • 处理异常

在本章中,我们还将简要讨论变量和数据类型。如果你需要刷新对这些内容的记忆,请回顾第六章**,与数据一起工作 – 变量

理解软件虫子

在编写软件时,事情并不总是按照计划进行。我们创建的程序将包含虫子。

虫子这个词来描述程序中的错误、缺陷或故障,其历史远在我们拥有任何计算机之前。自 1870 年代以来,它已被记录为工程术语的一部分。在一封日期为 1878 年的信中,托马斯·爱迪生写给一位同事如下:

"在我的所有发明中,情况都是如此。第一步是直觉,然后是一阵爆发,接着困难出现——这个玩意儿崩溃了,然后就是所谓的“虫子”——这样的小错误和困难就会显现出来,在商业成功或失败确定之前,需要数月的紧张观察、研究和劳动。"

第一款机械弹球游戏,Baffle Ball,在 1931 年宣传时声称是无虫的,而在 1944 年,艾萨克·阿西莫夫在短篇小说捕捉那只兔子中使用了虫子这个词来描述机器人出现的问题。

有一个经常被赋予敬意的故事,讲述的是软件中“虫子”一词的起源,这个故事来自格蕾丝·霍珀。1946 年,她加入了哈佛大学的计算实验室,在那里她继续在 Mark I 和 Mark II 计算机上工作。

Mark II 计算机产生了错误,经过一番搜索,操作员发现原因是一只蛾子被困在继电器中。蛾子被小心地取下并贴在日志簿上。在蛾子下面,写下了以下内容:

"首次发现虫子的实际案例。"

日志簿上的日期是 1947 年 9 月 9 日,这是第一次在计算机科学中使用虫子这个词:

图 9.1 – 1947 年在 Mark II 计算机中发现的蛾子 – 美国海军历史中心在线图书馆照片(公有领域 [PD])

图 9.1 – 1947 年在马克 II 计算机中发现的蛾子 – 美国海军历史中心在线图书馆照片(公有领域 [PD])

实际的虫子(bug)导致我们的程序产生错误输出的可能性几乎不存在。更可能的是,错误的源头是我们自己。

有许多不同类型的虫子。为了理解其中的一些,并看到虫子可能造成的损害,我们将查看两个极其昂贵的软件虫子。

美国国家航空航天局的火星气候轨道器

这是有史以来最著名的计算机错误之一。火星气候轨道器是美国国家航空航天局(NASA)于 1998 年 12 月 11 日发射的一颗太空探测器。它的任务是研究火星的气候、大气和表面变化。1999 年 9 月 23 日,与该航天器的所有通信都丢失了。不清楚它是否在火星大气中被摧毁,或者它是否继续存在于太空中。

1999 年 11 月 10 日,火星气候轨道器事故调查委员会发布了第一阶段报告。在报告中,明确指出灾难的原因是 NASA 承包商提供的一块地面软件中的错误。该软件以美国习惯单位(如英寸、英尺和英里)产生结果,而 NASA 的软件期望输入的是国际单位制(公制系统)。

这次错误的成本估计为 1.25 亿美元。

莫里斯蠕虫

1988 年,康奈尔大学的一名学生罗伯特·莫里斯发布了一个设计为无害实验的计算机蠕虫。它利用了 Unix 操作系统 sendmail 程序的一些漏洞,这些漏洞从一台计算机传播到另一台计算机。当发现一台新计算机时,程序会检查这台计算机是否已经感染。莫里斯明白这将是一个系统管理员停止传播并帮助系统确定是否已经感染的好方法。为了补偿这一点,莫里斯设计他的蠕虫,使其在 7 次中有 1 次响应“是”的任何计算机上都会感染。

这是导致蠕虫不仅迅速在互联网上传播,而且多次感染同一台计算机,破坏目标机器的大错误。莫里斯蠕虫是已知的第一种互联网蠕虫,清理它造成的混乱的成本估计为 1 亿美元。莫里斯被罚款 10,000 美元,但后来事业有成;他现在是麻省理工学院的教授。带有蠕虫源代码的磁盘在加利福尼亚州的计算机历史博物馆展出。

这两个例子都告诉我们,即使是小小的错误也可能产生巨大的后果。在第一个例子中,一个程序员犯了一个错误,在最终产品中被忽视。这里最大的问题不是引入的错误,而是没有人看到它并在它太晚之前阻止它。

至于第二个例子,这里,我们有一个自己创造了一些东西的人。这个程序的本质是没有人应该知道这个程序。这里的问题是,他没有一个组织支持他,没有其他开发者可以帮助他制定程序应该如何工作的计划。如果你独自一人,没有其他人讨论你的想法,那么考虑一个决策将带来的所有后果是非常困难的。在后一种情况下,如果周围有其他人,他们可能会告诉他,整个想法是糟糕的,从一开始就不应该这样做。

在这里,我们已经看到了两个关于错误的例子,但还有许多其他类型的错误。因此,我们应该做的第一件事是尝试定义什么是错误。

定义软件错误

要理解什么是软件错误,我们首先可以看看 Techopedia 的定义:

"软件错误是导致程序崩溃或产生无效输出的问题。这个问题是由逻辑不足或错误引起的。错误可能是一个错误、失误、缺陷或故障,可能导致失败或偏离预期结果。"

从这个定义中我们可以看出,错误是由不正确运行的软件引起的。这可能导致不正确或意外的结果。如果我们向程序提供一些定义良好的数据,比如,我们可以理解这一点。在这里,我们期望得到一个特定的结果。如果结果不是我们期望的,原因可能是我们提供的数据有问题,或者程序本身有问题。比如说,我们,例如,向计算器应用程序提供以下数据:

3 + 4

在这里,我们期望以下结果:

7

如果我们得到其他任何东西,我们可以说我们的应用程序中存在错误。

定义还说明程序可能会以未预料的方式表现。这就是当我们期望程序做某件事时,但它却做了完全不同的事情。如果我们有一个程序,当室内温度达到一定值时应该降低恒温器,但它却将其调高,那么这就会是一个错误。

为什么我们的软件中会有错误?没有单一的答案。为了理解这一点,我们需要定义不同类型的错误。

理解软件错误的类型

我们可以以许多不同的方式对错误进行分类。在这里,我们将探讨一些常见的类型,看看它们是什么,以及它们可能看起来像什么。

算术错误

如其名所示,算术错误与算术运算有关。以下几节中概述了一些我们应该注意的事项。

零除

其中一个例子就是除以零。这不仅与计算机有关,我们也不能执行除数为零的除法。在数学中,除以零没有意义,因为如果我们做 ,我们会得到 3。如果我们乘以 3 和 2,我们会得到 6。但如果我们做 ,没有数字我们可以乘以零来得到 6。

这可能看起来足够简单,但有时,它仍然会发生,尤其是在我们处理变量时。

假设我们有两个变量,它们在我们的应用程序的某个地方获得值,如下所示:

x = 3
y = 14

在程序稍后部分,我们执行一些计算,可能与其他变量一起,可能看起来像这样:

y = y – current_temperature

如果current_temperature变量现在与y有相同的值,在我们的例子中是14,我们将结果0存储回y

如果我们做了类似这样的事情,我们的应用程序将会崩溃:

result = x / y

原因在于我们在除以零。y是零可能并不明显,所以这个问题并不容易发现。

算术溢出/下溢

第六章,“与数据一起工作 – 变量”部分,我们讨论了整型类型,并且它们可以有固定的大小。这意味着某些整型类型有一个预定义的大小,它描述了它们将使用多少内存。这也给这种类型的变量一个最大值和最小值。只要我们存储在最大值和最小值之间,就不会有问题,但如果我们尝试存储一个比这些值大或小的值会发生什么?让我们看看一个例子。

现在我们假设我们正在使用一种具有名为 byte 的数据类型的编程语言。这种数据类型可以存储介于-128 和 127 之间的值。

我们可以创建这种类型的变量并给它赋值如下:

my_byte = 127

现在,如果我们增加这个变量一个值会发生什么?

my_byte = my_byte + 1

自然地,我们期望 127 + 1 的结果是 128。但出乎我们的意料,它是-128。

原因在于当我们处于数据类型可以处理的最大值并增加它时,我们会到达它可以处理的最小数字;在这种情况下,-128。如果我们增加它 2 而不是 1,我们会得到-127。

这是一个溢出错误。如果我们处于数据类型可以处理的最小值,并减去一个值,我们会到达这个数据类型可以处理的最大值。这被称为下溢错误。

精度丢失

正如我们在第六章“与数据一起工作 – 变量”中提到的,浮点数是计算机难以处理的东西,我们总是面临在舍入值时丢失精度的风险。

在某些语言中,这可能会变得明显。

假设我们有以下代码:

x = 1.3
y = 1.1
print x + y

这个程序的预期输出当然是这样的:

2.4

令我们惊讶的是,一些语言会给出这样的结果:

2.4000000000000004

这是计算机显示它在浮点数方面的问题。如果您需要回顾这是如何工作的,请回到 第六章与数据一起工作 – 变量,并阅读 数值类型 下关于浮点数的部分。

我们可以争论说,0.0000000000000004 的误差并不大,但如果我们正在处理几个这样的结果并将它们相加呢?现在,这个误差会累积,很快我们就会得到一个偏离很大的值。

这三个是我们将在软件中遇到的最常见的算术错误。下一组错误对于我们程序员来说并不那么有趣,因为它们是在我们的逻辑错误时引入的。

逻辑错误

逻辑错误通常不会使程序崩溃,但会产生意外的结果。不幸的是,我们有很多机会犯逻辑错误。

例如,我们可能会不小心使用错误的运算符。一个例子是如果我们想检查某人的年龄是否大于 18,但我们这样做:

if age < 18 then
   …
end_if

另一个常见的事情是忘记使用小于等于或大于等于。在这里,我们可以写这样:

if age > 18 then
   …
end_if

这是不正确的,因为我们实际上想检查年龄是否大于或等于 18,如下所示:

if age >= 18 then
   …
end_if

另一个常见的错误是使用一个等号而不是两个。一些语言会允许我们这样做:

if age = 20 then
   …
end_if

在这里,我们本想使用等于运算符,==,但反而使用了赋值运算符,=。一些语言会将这解释为将值赋给年龄。这将给我们带来两个问题。首先,我们可能会进入 if 语句,即使实际上我们不应该这样做。另一个问题是,现在 age 变量中的值将被值 20 覆盖。

一直让我感到惊讶的一件事是,正确使用逻辑运算符是多么困难。尽管它们只有两个,但我们经常用其中一个代替另一个。是的,我有时也会这样做。

如果我们打算检查年龄是否在 1220 之间,我们可能会写这样:

if age > 12 or age < 20 then
   …
end_if

然而,我们想要做的是这样:

if age > 12 and age < 20 then
   …
end_if

第一个例子总是正确的,因为年龄总是大于 12 或小于 20

这些只是逻辑错误的一些例子。由于代码是有效的,意味着程序可以运行,但其行为将是不预期的,因此它们可能很难找到。

当代码编写得无法运行,因为我们违反了语言语法规则时,更容易修正的错误组出现了。让我们更详细地看看这些。

语法错误

告诉我们如何在特定语言中编写代码的规则被称为其语法。当我们编写的代码不遵循语法规则时,我们得到的就是所谓的语法错误。

与许多其他错误相比,这些错误相对容易发现,因为编译器或解释器会告诉我们问题所在,并给出关于错误的一些提示。

让我们看看一些语法错误,并调查我们得到哪些消息可以帮助我们修复错误。

这里有一个语法错误。你能找到它吗?

print "Hello

在这里,我们试图打印一个字符串,但我们忘记了关闭引号。不同的语言会以不同的方式报告这个错误。正如我们将看到的,我们得到的消息并不总是直接指向真正的错误。

这里提供了四个不同编程语言的例子 – Python、Go、C#和 JavaScript,分别:

SyntaxError: EOL while scanning string literal in line 1 column 12
1:12 syntax error: unexpected newline
Compilation error (line 1, col 12): Newline in constant
error: unknown: Unterminated string constant (1:12)

第二个和第三个讨论的是换行符,而第一个和最后一个讨论的是字符串。我们需要了解我们使用的语言返回的消息。所有这些都会指引我们到错误被发现的位置。在不同的格式中,我们被指引到第 1 行,第 12 列。

给定的位置并不总是实际错误所在的地方,而是编译器/解释器发现错误的地方。如果你在给定的位置找不到任何东西,请查看上一行或有时是这一位置上方的一些行。

从前面的章节中我们知道,我们不能用数字作为变量名的第一个字符。但让我们试试,然后做些像这样的事情:

1apple = 1

这将给出如下消息:

SyntaxError: invalid syntax in line 1 column 2
1:2 syntax error: unexpected apple at end of statement
Compilation error (line 1, col 2): Identifier expected
error: unknown: Identifier directly after number (1:2)

正如我们所见,一些语言将这些错误称为语法错误,而其他语言则会将其称为编译错误等。再次强调,我们需要了解我们使用的语言是如何称呼这些错误的,因为这有助于我们识别它们。

通常,我们的编辑器会在我们运行应用程序之前帮助我们找到语法错误,甚至标记它们。它使用的技术与 MS Word 处理器中的拼写检查器相同 – 在错误下方有一条波浪形红线。

看看下面的截图。在这里,我们可以看到在尝试运行程序之前,编辑器已经标记了一个语法错误:

图 9.2 –  一个编辑器显示编程语言 Python 中的语法错误

图 9.2 –  一个编辑器显示编程语言 Python 中的语法错误

语法错误,如前所述,通常很容易找到,因为程序将无法运行,并且我们会被指引到错误发生附近的位置。但当我们遇到逻辑错误时,我们该如何找到错误呢?我们为此有专门的工具,称为调试器。

使用调试器查找错误

调试器是一种可以帮助我们看到程序运行时发生了什么的工具。正如我们已经提到的,仅通过运行程序,一些错误可能很难找到和理解。通常,我们会在程序中发现一些奇怪的行为,但可能并不明显是什么原因导致了这种行为。

调试器是一个针对特定编程语言定制的应用程序,可以用来在指定的代码行处暂停应用程序。此时,我们可以检查所有变量的值。

我们也可以恢复程序的执行,或者逐行执行以查看会发生什么。

让我们尝试使用调试器。为此,我们首先需要选择一种语言,然后编写一个小程序,其中包含一个逻辑错误。我们可以选择我们之前查看过的错误之一:

if age > 12 or age < 20 then
   …
end_if

记住,在这个例子中,我们意外地使用了or而不是and

让我们用 Python 编写这个程序。在以下屏幕截图中,我们可以看到它的样子:

图 9.3 – 包含逻辑错误的 Python 小程序

图 9.3 – 包含逻辑错误的 Python 小程序

在第一行,我们声明(记住声明一个变量意味着我们正在创建它)一个名为age的变量,并将其值17赋给它。

然后是我们的if语句,我们在这里检查年龄是否大于12或小于20。这里的错误是我们使用了or

当运行这个程序时,我们得到预期的输出:

You are a teenager.

如果我们现在更改程序,也就是说,我们将另一个值赋给年龄,比如说24,然后运行它,它将不会给出预期的结果:

You are a teenager.

您可以在以下屏幕截图中看到更改后的程序:

图 9.4 – 具有不同年龄值的相同程序

图 9.4 – 具有不同年龄值的相同程序

现在,让我们使用调试器并探索这个错误。我们首先需要做的事情是设置一个断点。

断点

断点是我们用来告诉程序运行到这一点,然后暂停并显示程序状态的途径。

在以下屏幕截图中,我们可以看到我们在包含if语句的行上有一个断点:

图 9.5 – 在第 3 行设置断点的程序

图 9.5 – 在第 3 行设置断点的程序

如果我们现在运行程序,它将在到达这一行时停止。此时,包含断点的行尚未执行。它看起来如下面的屏幕截图所示:

图 9.6 – 调试器已停止在断点上

图 9.6 – 调试器已停止在断点上

我们即将执行的行用蓝色线条标记。此外,请注意,这个调试器在第 1 行以灰色显示了age的值。这有助于我们了解它当前具有的值。

如果我们稍微放大一点,我们会看到一些其他工具在程序暂停在这一行时出现。我们可以在以下屏幕截图中看到它的样子:

图 9.7 – 调试器工具

图 9.7 – 调试器工具

在这里我们看到调试器应用程序为我们提供的一些工具。让我们了解它们由什么组成:

  • 在右侧,我们看到一个标记为变量的部分。在这里,我们可以看到所有当前定义的变量以及它们的值。

  • 在这个窗口上方,我们可以看到一些指向不同方向的箭头。它们用于将程序向前推进一步。这里有一些选项。第一个是先向上然后向下的箭头(在先前的截图中被标记为1)。这被称为步过。如果我们在这行有函数调用,步过不会跳转到那个函数。相反,它会调用那个函数,运行其中的所有代码,然后当我们返回到当前的位置时再次停止。

  • 下一个箭头,指向直下的箭头是进入步骤(标记为2)。如果我们在这行有函数,它将跳转到那个函数并允许我们逐步执行它。

  • 我们可以忽略接下来的两个箭头,而是查看指向直上的箭头(标记为3)。这个被称为退出步骤。如果我们已经进入了一个函数并改变了主意,我们可以使用这个功能。它将运行函数中的所有代码,并在我们返回到原来的位置时再次停止。

  • 在最左侧,有一些其他工具可以重新启动程序(标记为4),恢复程序的执行(标记为5),暂停正在运行的程序(标记为6),停止程序(标记为7),允许我们查看程序中当前的所有断点(标记为8),以及忽略所有断点并继续运行(标记为9)。

对于我们的问题,这些工具中没有一个能帮到我们。我们知道程序将进入if语句,因为输出如下:

You are a teenager.

而另有一个工具可能对我们有帮助。在下面的截图中,我们可以看到它被一个矩形标记出来:

图 9.8 – 评估表达式按钮

图 9.8 – 评估表达式按钮

这个看起来像小计算器的工具是评估表达式工具。如果我们点击它,我们将看到如下截图所示的窗口:

图 9.9 – 评估表达式窗口

图 9.9 – 评估表达式窗口

在顶部字段中,我们可以输入一个表达式。这可以帮助我们理解正在发生的事情。我们目前在这一行:

if age > 12 or age < 20:

如果我们将这个表达式的某一部分输入到评估表达式工具中,它将显示结果。让我们以这个if语句的第一部分为例。将其输入到工具中看起来如下:

图 9.10 – 评估一个表达式

图 9.10 – 评估一个表达式

在这里,我们可以看到这个if语句的部分是正确的。

如果我们现在对第二部分做同样的操作,我们将看到结果为假,如下截图所示:

图 9.11 – 评估另一个表达式

图 9.11 – 评估另一个表达式

我们现在可以检查这两条语句的结果,因为我们已经在代码中编写了它们,如下面的屏幕截图所示:

图 9.12 – 评估整个表达式

图 9.12 – 评估整个表达式

在这里,我们可以看到整个表达式的结果是 true,即使值大于条件的最后一部分,即 20

我们现在可以怀疑罪魁祸首是 or。让我们将其更改为 and 并看看结果。

在下面的屏幕截图中,我们可以看到结果现在被评估为 false,这是我们预期的值:

图 9.13 – 使用 or 而不是 and 评估表达式

图 9.13 – 使用 or 而不是 and 评估表达式

我们现在可以停止调试器并更改我们的代码。

这是我们使用调试器帮助我们理解问题的一个例子。我们经常会遇到这样的错误,而问题可能一开始并不明显。作为程序员,我们应该学会使用调试器,了解它提供的功能,以及我们如何使用它。

有时候,我们会有其他类型的错误,尽管语法是正确的,但程序仍然会崩溃。这些被称为异常。我们将继续讨论这些内容。

处理异常

异常(简称异常事件异常条件)是指在程序运行期间发生的错误或意外事件。它是由于软件中的某种条件导致的,使得程序达到了无法继续运行的状态。

我们可能会遇到许多导致异常的原因。一个例子可能是,如果我们的程序需要从文件中读取数据,但文件不在它应该的位置。由于程序有理由从该文件中读取数据,未能这样做将使应用程序处于无法保证其输出正确性的状态。在这种情况下,最好的选择就是停止程序并返回一个错误,希望这个错误能指导我们解决问题,以便我们可以修复它。

没有软件开发者愿意编写一个会崩溃的应用程序。尽管后果可能更严重,取决于程序的性质,但应用程序的用户可能会丢失未保存的工作。

很快,我们将讨论如何处理异常,以便它们不会使我们的程序崩溃。但在我们这样做之前,让我们更深入地了解异常,以便我们了解它们是如何工作的。

让我们来看看我们得到异常的一些常见原因。

异常的常见原因

正如我们所见,缺少文件可能是我们遇到异常的一个原因。另一个常见的原因是,当我们试图对一个序列进行索引时,我们使用了一个比序列更大的索引。让我们来看一个例子:

names = ["Anna", "Bob", "Cara", "David"]

这里,我们有一个数组(你可以在第六章**,处理数据 – 变量,在复合类型部分中了解更多关于数组的信息)。它包含四个值;在这种情况下,四个名字。我们可以通过以下方式索引到这个数组以检索单个值:

print names[2]

这将给出以下输出:

Cara

记住,第一个项目的索引值是 0,而不是 1,所以第三个项目Cara的索引是 2。

如果我们使用一个大于我们拥有的项目数量的索引值,会发生什么?

print names[6]

在这个位置没有值,所以程序无法为我们获取它。语法是正确的,如果我们有足够的值,它将完美地工作。但这次,它不会,因为我们使用了一个不存在的索引。

程序无法在这个点继续运行,因为它无法猜测它应该做什么。我们给出了一个指令,表示我们想要从这个位置获取一个值。编程语言不能为我们随意生成一个值。在这种情况下,最明智的做法是直接结束程序并等待它给出错误。这个错误可能看起来像这样:

IndexOutOfRangeException: Index was outside the bounds of the array at line 2.

这是一个异常。我们可以在输出中看到它,因为它清楚地写着IndexOutOfBoundException

我们使用异常的多少,以及我们需要处理不同问题的不同异常的数量,在语言之间有很大差异。有些语言,如 Java,大量使用异常。另一方面,C++只有少数内置异常。然后,我们有像 Go 这样的语言,根本不使用异常,而是让函数返回一个错误类型,与函数的真实返回值一起。

我们在本章中已经看到的一些错误也会生成异常。如果我们将某个数除以零,就会发生这样的异常。

大多数语言都支持异常。即使编程语言中异常的数量不同,它们的工作方式在大多数情况下都会很相似,无论我们使用什么语言。名称将不同,以及我们拥有的异常类型也会不同,但我们的处理方式将是相同的。

有时,理解我们从异常中获得的输出可能很困难。原因是当我们遇到异常时,还会打印出称为调用栈的东西。

异常和调用栈

想象一下,我们有一个程序,并且在这个程序中,我们有一个main函数。这个函数正在调用另一个函数,该函数又调用另一个函数,以此类推。我们将有如下所示的内容:

图 9.14 – 一个调用函数的函数,然后又调用另一个函数,以此类推

图 9.14 – 一个调用函数的函数,然后又调用另一个函数,以此类推

跟踪我们在函数调用链中的位置被称为调用栈,当程序运行时由编程语言处理。

现在,假设我们在最后一个调用的function c函数中遇到了异常。这个函数将立即退出并返回到它被调用的地方。那就是function b。这个函数也会在控制权交还给它时立即退出,然后我们会返回到原来的地方。这次,是function a。同样,这个函数也会立即终止,然后我们返回到main函数。最后发生的事情是这个函数也会结束。由于这是在这个应用程序中首先被调用的函数,整个应用程序将结束。

所有函数都会退出的原因是没有一个函数处理了异常。

现在,我们将不仅会在屏幕上打印出异常信息,还会打印出调用栈。

为了说明这一点,让我们使用一个非常简单的程序,如下所示:

function c()
    result = 10 / 0
end_function
function b()
    c()
end_function
function a()
    b()
end_function
function main()
    a()
end_function

这是一个相当愚蠢的程序,但它将说明会发生什么。在底部,我们有一个名为main的函数。当我们运行这个程序时,main函数将被自动调用。

main函数内部,我们调用名为function a的函数。这个函数将调用function b,它再调用function c

当我们进入function c函数时,function b仍在运行并等待function c完成。对于function a也是一样,它等待function b完成。main函数等待function a完成,所以在这个点上,我们有四个正在运行的功能。

现在,在function c函数内部,我们执行了一个除以零的操作,导致抛出了一个异常。

在这一点上,function c将立即退出。控制权将交还给function b,它将退出,然后将控制权交还给function a,它将退出回到main函数,最后程序将退出。

我们将得到的输出将类似于以下内容:

Callstack
function c() at line 2
function b() at line 6
function a() at line 10
function main() at line 14
ZeroDivisionError: division by zero

如我们所见,所有的函数调用都在那里。如何显示将因语言而异。有些语言会以相反的顺序打印所有函数。这又是一件我们在学习新语言时需要学习的事情。

我们得到所有这些信息的原因是它可以帮助我们找出问题所在。即使错误发生在function c中,它发生的原因可能起源于其他地方。让我们假设程序看起来如下所示:

function c(x, y)
    result = x / y
end_function
function b()
    c(10, 0)
end_function
function a()
    b()
end_function
function main()
    a()
end_function

现在,function c接受两个参数,并使用这些参数进行计算。这发生在从function b调用function c时。问题出现在我们传递100作为参数调用时。

由于数据起源于function b,问题就出在这里。function c不知道这两个值的来源。它们可能来自一些用户输入,可能已经从文件中读取,或者可能来自任何其他来源。

为了完全理解这个问题的根源,我们需要从调用栈中获得信息,因为它告诉我们当错误发生时,我们是如何到达function c的。

但如果我们不希望程序崩溃呢?没问题。我们可以编写处理异常的代码。让我们看看下一个例子。

处理异常

要了解我们如何处理异常,我们首先必须理解问题的根源可能是什么。只有当我们理解了这一点,我们才能采取适当的措施来正确处理它们。

让我们回到我们之前例子中除以两个值的函数。假设这个函数接受两个参数,就像我们之前的例子一样:

function c(x, y)
    result = x / y
end_function

我们应该假设这个函数除了打印这一行之外,还做了一些其他的事情。我们可以用一些注释来标记它,如下所示:

function c(x, y)
    // The function does some things ere
    result = x / y
    // And even more things here
    // It might even return a value
end_function

我们知道,当这个函数对两个值进行除法运算时,如果y被赋予0的值,我们可能会遇到异常。

我们首先应该问自己的是,这是处理问题的最佳位置吗?可能是,但更有可能的是,它不是。这个函数接收两个作为参数传递给它。应用程序的多个部分可能会使用这个函数,因此它无法知道发送给它的数据的来源。

然而,我们可以在执行除法之前检查y是否等于零。现在让我们这样做。同时,我们可以将函数的名称改为calculate,因为它更好地反映了函数的功能:

function calculate(x, y)
    if y == 0 then
	  // y is zero, so we cannot perform the division
    end_if
    // The function does some things ere
    result = x / y
    // And even more things here
    // It might even return a value
end_function

但如果我们遇到y为零的情况,我们应该怎么办?我们不能继续运行函数,因为那样我们仍然会执行除法操作。我们也不能将y改为其他值,因为我们应该将它改为什么?

我们需要一种方式来向函数的调用者发出信号,表明我们无法接受y为零的值。

做这件事的一种方法就是让异常发生,并再次移除if语句。如果我们这样做,调用者就可以处理错误了。

让我们看看我们如何在调用函数时处理异常。那么,对函数的调用就需要在一个特殊的代码块中,称为try语句。它可能看起来像这样:

try
    calculate(10, 0)
catch ZeroDivisionError
   // We will end up here if we get a ZeroDivisionError    // exception
end_catch

在这里,我们可以看到对calculate函数的调用位于一个try块中。

如果一切正常,并且我们在函数中没有抛出任何异常就返回,程序将跳转到end_catch之后的行,并继续执行。

但如果我们遇到一个异常,而这个异常是ZeroDivisionError类型,我们最终会进入下面的代码块,它以catch开始。

异常可以被捕获,但我们需要指定我们想要处理哪种异常。如果我们遇到另一个异常,一个没有匹配catch的异常,程序将像以前一样崩溃。

以这种方式调用函数可能是我们的解决方案,但这真的是一个好的解决方案吗?并不。想象一下,calculate函数位于一个不同的模块中,该模块位于一个不同的文件中。它甚至可能是由别人编写的。在这种情况下,我们如何知道它将执行除法,并且它将使用我们传递给它的第二个值作为除数?我们很可能对此一无所知,或者我们至少应该假设用户对函数的编写方式一无所知。

因此,我们不能假设他们会在他们的catch语句中使用ZeroDivisionError异常。相反,我们可以给他们另一个可能更有意义的异常。

让我们修改我们的calculate函数,如下所示:

function calculate(x, y)
    if y == 0 then
	  throw ValueError("Second argument cannot be zero")
    end_if
    // The function does some things ere
    result = x / y
    // And even more things here
    // It might even return a value
end_function

throw将创建另一个异常;这次,一个叫做ValueError的异常。我们还在这个异常中传递了一个字符串。如果有人现在调用我们的函数并给我们一个y的值为零,他们将得到我们提供的这个异常信息。

当他们调用我们的函数时,他们现在可以检查这个异常:

try
    calculate(10, 0)
catch ValueError
   // We will end up here if we get a ValueError exception
end_catch

由于这是y的坏值产生的地方,我们更有可能在这里改变它。例如,如果这个值是由程序的用户给出的,我们可以返回一个友好的错误信息,说明他们不能输入零值。

我们可以说这是拥有数据的位置,因此有机会改变它。

如果我们在可能抛出异常的函数调用周围使用try…catch块,程序将不再崩溃。在这种情况下,calculate函数仍然会在我们抛出ValueError异常时立即退出,但由于我们在函数调用后立即捕获它,我们可能能够纠正问题并再次用正确的值调用函数。

异常是在我们无法决定在编写程序时无法预测的条件下的处理条件的一种很好的方式。没有它们,向程序的其它部分发出错误信号将非常困难。我们需要在需要时使用它们,但也要确保适当的关注。异常总是发送一个关于错误的清晰信息,并帮助接收异常的代码的作者理解问题是什么。

摘要

在这一章中,我们意识到,作为人类,我们会犯错误,但我们可以回去修复它们。

软件错误是应用程序中的错误,可能有几个原因。根据错误背后的原因,我们在尝试修复它时必须采取不同的方法。

在某些情况下,例如与语法错误一样,我们将会立即被告知错误的起因,甚至会被指引到代码中的正确位置。

其他错误将更难找到。当编程语言语法正确,但逻辑不正确时,程序将以意想不到的方式运行。为了能够找到这些错误,我们可以使用一个名为调试器的工具。它通过允许我们暂停程序执行并查看变量的所有值来帮助我们追踪错误;它甚至允许我们逐行执行。

一个异常事件是指那些本不应该发生的事情仍然发生了。在编程中,这些被称为异常。当它们发生时,如果未得到处理,程序将立即停止执行。幸运的是,我们可以通过插入仅在异常事件发生时才会运行的代码来处理它们,这样我们就可以尝试解决问题。

在下一章中,我们将探讨不同的方法来解决问题,并使用代码创建解决方案。这被称为范式。编程语言将使用这些范式之一。正如我们将看到的,一些语言将使用来自多个范式的概念。

第十章:第十章:编程范例

如果我们查看所有编程语言,我们可以看到它们之间有模式和相似之处,我们可以利用这些来将它们分类到不同的范例中。单词范例意味着某物的典型例子或模式,这正是我们在对编程语言进行分组时寻找的东西。

我们想要进行这种分类的原因是因为我们在这些组中的一个中编写程序的方式将与我们使用属于另一组语言的程序的方式有显著差异。

计算机程序几乎总是以某种方式或另一种方式模拟现实世界中的事物。我们使用软件解决现实世界的问题。问题是我们在代码中如何最好地模拟和表示现实世界的事物,以及我们如何最好地构建解决这个现实世界问题的解决方案。

在本章中,你将学习以下主题:

  • 理解结构化编程

  • 理解面向对象编程

  • 理解函数式编程

  • 理解逻辑编程

  • 理解其他范例

  • 理解多范例语言

让我们从我们最熟悉的范例开始:结构化编程。

理解结构化编程

结构化编程是我们在本书中讨论的内容。循环、条件和函数定义了使用此范例的程序流程。如果你已经阅读了本书的前几章,那么现在它们都应该很熟悉了。

结构化编程是称为命令式编程的范例家族的一个分支。使用命令式编程概念的编程语言使用语句来改变程序的状态。

如果我们查看那个定义,我们必须首先了解语句和程序状态是什么。

语句

在这个定义的第一部分,我们将讨论语句。正如在第五章中描述的,“序列 – 计算机程序的基本构建块”,在“理解语句”部分,这可以被视为我们向应用程序下达的命令。在自然语言中,我们有一种称为祈使语气的表达方式。祈使语气是一种形成命令的东西,例如“移动!”,“不要迟到!”或“努力工作!”。在命令式编程中,我们用类似祈使语气的东西向计算机下达指令;也就是说,以语句形式表达出的命令。

这是命令式编程定义的前半部分。后半部分讨论的是改变程序的状态。

程序状态

如果一个程序能够记住之前发生的事件,那么我们就说它具有状态。程序在变量中存储数据。在任何给定的程序执行点,我们可以查看所有已定义变量中当前的数据。所有这些变量的组合值构成了程序的状态。

如果我们改变一个变量,程序的状态也会改变。当我们谈论命令式编程时,我们的意思是,一旦一个语句改变了变量的内容,它就改变了程序状态。

这就是形成先前事件记忆的方式。当一个事件——在我们的例子中是一个语句——发生并改变一个变量时,它将影响程序的行为。如果我们有一个将发射火箭的应用程序,我们可能有一个负责倒计时的函数。为了跟踪我们目前处于哪个数字,我们需要有一个变量。在倒计时过程中改变这个变量将改变应用程序的状态。当变量达到零时,它将触发向火箭发送启动信号的触发事件。

如果这是命令式编程,那么在结构化编程中有什么不同呢?让我们来比较一下。

比较命令式编程和结构化编程

用汇编语言编写的程序使用一种称为 GOTOs 的概念。这是一种用于控制程序流程的技术。要使用它们,我们在代码中插入标签,然后可以指示程序跳转到这样的标签并从那里继续执行。

一段简短的汇编语言代码片段可以说明这一点:

	mov eax,3 	jump exit 	mov eax,123  ; <- not executed!exit:	ret

在这里,我们有一个名为exit的标签。在第一行,我们将值3移动到一个名为eax的寄存器(记住,汇编语言中的寄存器就像一个变量)。在第二行,我们无条件地跳转到我们的标签exit。无条件跳转意味着我们总是会执行这个跳转。在汇编语言中,也有条件跳转,只有当寄存器的值等于、小于或大于某个值时才会跳转。

由于跳转是无条件的,第三行永远不会执行,因为我们总是会跳过这一行。

在 20 世纪 60 年代和 70 年代出现的许多语言也具有相同的不条件跳转概念。在这里,我们可以找到像 BASIC 和 C 这样的语言。在这些语言中,它不被称为跳转。相反,使用GOTO这个术语。编程语言 C++基于 C,因此它也使用 GOTO。用 C++编写的相同程序将看起来像这样:

	int x = 3;
	goto quit;
	x = 123;	   // <- not executed!
quit:
	return x;

今天,在大多数情况下,被认为使用 GOTOs 是一种不好的做法,因为代码将难以阅读、理解和维护。几乎很少需要执行 GOTO,因为像 BASIC 和 C 这样的语言支持可以让我们达到相同结果并保持良好的代码质量的结构。

使用这种编程风格最初定义了命令式编程。随着编程语言的发展以及我们获得了其他工具,如循环、if语句和函数,就有必要区分这些更现代的语言和较老的风格。尽管这些编程语言使用与汇编语言相同的思想,但这些语句会改变程序的状态。这是因为它们不再依赖于跳转或 GOTO 来完成这个任务。这就是我们得到结构化编程定义的时候。支持结构化编程的语言是使用语句来修改程序状态的语言,并且有函数、循环和if语句作为完成这一任务的工具。

我们有时也会听到过程式模块化语言的术语。没有必要深入了解这些术语之间的区别,因为这主要是学术性的。我们可以安全地认为这些都是同一件事。

一些支持结构化编程或其相关语言(过程式和命令式编程)的知名语言如下:

  • Ada

  • ALGOL

  • BASIC

  • C

  • C++

  • C#

  • COBOL

  • Fortran

  • Go

  • Java

  • JavaScript

  • Pascal

  • Perl

  • PHP

  • Python

  • Ruby

  • Rust

  • Swift

  • Visual Basic

结构化编程是一种流行的范式,如这份长长的语言列表所证明的那样。在 90 年代,另一种范式获得了流行,并且仍然是当前使用的主要范式之一。它被称为面向对象编程。让我们看看这是怎么回事。

理解面向对象编程

面向对象编程的主要思想是以我们人类看待世界的方式对代码进行建模。

即使你没有想过,我们总是在使用抽象对事物进行分类和分组。我们可以谈论车辆,并且我们对这个群体中包含的内容有共同的知识。汽车、自行车、船只和飞机是,而铅笔、鸭子和泳衣则不是。

我可以说,“我需要去镇上。有人能借我一辆车吗?”

你会这样理解,实际的种类不重要,但它必须是我能去镇上的东西。它可能就像一辆车一样,也可能是一块滑板。

将事物分组到这些抽象中使我们的生活更轻松,因为我们不必每次谈论某事时都深入细节。我可以要求一辆车,而不是描述我需要一个能把我从当前位置带到镇上的设备。

在这样的抽象内部,事物可能非常不同,但如果它们有一些关键特征,我们就会理解它们。看看下面的图片。在这里,我们有两个可以归入我们称之为遥控器的东西。

其中一个将控制你的电视,让你换频道和调节音量。另一个将让你锁上和打开你的车:

图 10.1 – 遥控器

图 10.1 – 遥控器

尽管他们使用不同的设备,按按钮的结果也会非常不同,但他们共享的行为是在无线远程控制某物。我们把这些设备称为遥控器,以使我们的沟通更加方便。

如果我们能在编写软件时做同样的事情,这就是面向对象出现的地方。它将让我们使用相同的方法。

如果我们要编写一个跟踪仓库库存的程序,并且想以面向对象的方式来做,我们可以看看一个真正的仓库,并像我们看到的那样描述它。

在仓库里,我们有如下一些东西:

  • 产品

  • 货架

  • 一台咖啡机

  • 仓库员工

  • 窗户里的枯萎花朵

我们的系统需要了解一些这些事物,但不是全部。在先前的列表中,我们可以忽略花朵和咖啡机,但其他三个是将其纳入我们程序的好候选。

如果我们查看这些产品之一,我们会看到它可以是几件事情,从小螺丝和螺栓到某种机器或设备。但从仓库的角度来看,它们具有相同的特征。它们都是我们存储的物品,我们可以使用相同的属性来描述它们。它们有一个名字、重量和制造商;我们有它们的一定数量;等等。

在面向对象编程中,我们试图保留这种描述事物的方式,并以与我们刚才描述的方式不太不同的方式来表示它们。

要理解面向对象编程是如何工作的,我们需要将其分解为一些主要概念,这些概念是我们需要理解的。让我们来看看它们。

类和对象

在面向对象编程中,一个类就像一个蓝图或描述,让我们以人的概念为例。我们如何描述一个人?我们可以开始列出一个适用于所有人的事物的清单。它可能看起来像这样。

一个人有以下属性:

  • 一个名字

  • 一个年龄

  • 一个性别

  • 身高

  • 体重

  • 头发颜色

  • 眼睛颜色

  • 鞋码

  • 国籍

  • 地址

  • 电话号码

列表可以继续。我们现在可以决定这些是适用于所有人的事物。如果我们仔细想想,这些都是关于个人的数据。我们还没有描述任何行为。我们可以再列一个清单,描述一个人可以做的事情。

一个人可以做以下事情:

  • 跳跃

  • 跑步

  • 行走

  • 坐下

  • 站立

  • 睡眠

  • 休息

  • 工作

  • 玩耍

  • 跳舞

同样适用于这里——这个列表可以非常长。

如果我们打算在程序中表示一个人,我们不需要所有可用的数据和行为。相反,我们需要以某种方式抽象出一个人,这样我们就可以用对我们感兴趣的东西来表示他们。姓名、年龄和性别可能都是这样的东西,但除非我们正在编写鞋店的程序,否则鞋码很可能不是。让我们专注于数据,并挑选一些可能足够有趣以在应用程序中实现的东西。我们可能会选择以下内容:

  • 姓名

  • 年龄(很可能是出生日期的形式)

  • 性别

  • 地址

  • 国籍

  • 电话号码

  • 电子邮件地址

现在,我们将学习如何定义一个蓝图——在面向对象编程中称为类——用于表示人,因为我们有一份适用于所有人的数据列表。

我们给它一个描述性的名字,并列出我们感兴趣的东西。它可能看起来像这样:

图 10.2 – 一个名为 person 的类

图 10.2 – 一个名为 person 的类

前面的图像以简化的方式描述了一个类;即一个有三个部分的矩形。在上部部分,我们有我们给这个类起的名字。在中间部分,我们描述了我们想要用来描述人的数据。最后一部分是用于行为,这是我们很快会回到的地方。

面向对象编程非常关注数据,因此当我们决定一个类的样子时,这通常是我们的起点。进入我们类中的数据通常会决定我们希望它有什么样的行为。这种行为通常决定了我们需要在数据上执行的操作。

我们之前为一个人识别的行为,如跳跃、奔跑和睡眠,可能不是我们需要表示的东西。相反,我们通常会找到会修改数据的东西,比如更改地址。

目前,我们将留空类的第三部分,但我们会稍后回来。

现在,我们有一个人的类和蓝图,但还没有表示任何实际的人。在我们的例子中,一个事物(人)的表示称为对象。一个对象始终属于一个类。既然我们有了类,我们就可以从中创建一个对象,每个对象将代表一个人。

如果我们想在应用程序中表示一组人,他们的表示可能如下所示:

图 10.3 – person 类中的四个对象

图 10.3 – person 类中的四个对象

正如我们在前面的图中可以看到,所有四个对象都有自己的数据集;姓名、出生日期、性别、地址、国籍、电话号码和电子邮件地址。一个对象中的数据与其他属于同一类的对象中的数据是独立的。如果我们更改一个对象中的地址,它将不会影响任何其他对象。

总结这一点,我们可以说,一个类是对象的一个模型或蓝图。在类中定义的数据通常被称为成员变量属性

成员变量

成员变量就像任何其他变量一样,有一个关键的区别:它存在于对象内部。

为了说明这一点,我们可以考虑一个关于人的非常简单的类。它可能看起来像这样:

class Person
	name
	age
end_class

在这里,我们定义了一个名为Person的类。它里面有两个变量:nameage

在这一点上,计算机内存中还没有实际的变量存在,因为这只是一个关于人对象外观的蓝图。为了使它们存在,我们需要从这个类中创建对象,通常被称为实例。

这可以这样完成:

p1 = Person("Dipika", 34)
p2 = Person("Manfred", 58)

这创建了两个对象。我们使用变量名p1p2来引用它们。有趣的部分是当我们创建这些对象时会发生什么。如果我们仔细看看第一行,我们会看到一系列事件将会发生:

  1. 发生的事情是,在计算机内存的某个地方,从Person类创建了一个包含两个变量nameage的对象,如下面的图像所示:图 10.4 – 来自人类的对象

    图 10.4 – 来自人类的对象

  2. 下一步是成员变量被初始化,这些初始化数据是在我们创建对象时传递的。这在上面的图像中得到了说明:图 10.5 – 对象中的成员变量被初始化

    图 10.5 – 对象中的成员变量被初始化

  3. 最后一步是p1变量现在指向内存中这个对象的位置,如下面的图像所示:

图 10.6 – 对象通过 p1 变量被引用

图 10.6 – 对象通过 p1 变量被引用

然后,这个过程被重复用于包含名称Manfred的对象。通过这样做,我们得到以下类似的结果:

图 10.7 – 来自 Person 类的两个对象

图 10.7 – 来自 Person 类的两个对象

现在我们有了两个对象,我们可以看到我们有两个名为nameage的变量。每个变量都包含在p1对象和p2对象中。对于从Person类创建的每个对象,我们都会得到这两组变量中的一组。

我们很快就会改变这个类,但就目前来看,我们可以像这样访问这些变量:

p1 = Person("Dipika", 34)
p2 = Person("Manfred", 58)
print p1.name
print p1.age
print p2.name
print p2.age

这将给出以下输出:

Dipika
34
Manfred
58

面向对象编程指出,成员数据应该封装在其对象内部,并且应该阻止从外部直接访问这些数据。让我们看看为什么这是面向对象中的一个关键概念。

理解封装

封装,也称为信息隐藏,是一个概念,其中对象的内部实现被隐藏在对象之外的所有事物中。

封装可以用多种方式来描述。美国计算机工程师詹姆斯·鲁姆巴格和迈克尔·布莱哈这样描述它:

"一个设计目标是把类视为“黑盒”,其外部接口是公开的,但其内部细节对视图隐藏。隐藏内部信息允许在不要求类的任何客户端修改代码的情况下更改类的实现。"

这里的关键点是接口。接口是我们用来与对象通信的东西。看看图 10.1中的遥控器。我们可以按的按钮是接口。我们用它们与设备内部的内部逻辑进行通信。

遥控器对象是一个黑盒,因为我们看不到遥控器的内部,我们也不需要这样做。我们唯一需要理解以能够使用对象的是接口。如果遥控器工作不正常,我们可以把它带给了解其内部工作原理的人,他们可以修理它。如果他们没有改变接口、按钮以及与之相关的功能,我们就不需要改变在修改前后使用遥控器的方式。

我们应该从外部世界隐藏的一件事是数据。不,等等!如果数据被隐藏在一个对象内部,我们如何使用它呢?让我们通过一个例子来看看我们所说的在对象内部隐藏数据是什么意思。

如果你走在街上,遇到另一个人,仅通过观察那个人,你无法看到诸如人的名字、他们早餐吃了什么、他们的年龄以及他们住在哪里等信息。这些数据被封装在对象内部:

图 10.8 – 数据封装在现实世界对象内部

图 10.8 – 数据封装在现实世界对象内部

要获取这些信息,我们需要询问那个人。我们说对象通过相互传递消息进行通信。这看起来就像这样:

图 10.9 – 通过发送消息进行通信的两个对象

图 10.9 – 通过发送消息进行通信的两个对象

我们需要修改我们的类,以便数据被隐藏,并且我们有与它通信的方式。

我们可以通过使用private关键字来隐藏数据。现在类看起来可能像这样:

class Person
	private name
	private age
end_class

通过将nameage声明为私有,我们不能再从类外部访问它们。这意味着我们打印两个对象的名字和年龄的行将不再工作。

现在的类看起来是无用的,因为我们可以创建一个对象并为其变量赋值,但在此之后我们无法对这些数据进行任何操作,因为它们对我们来说是隐藏的。我们需要创建一个接口,比如遥控器上的按钮,这样我们就可以与这些数据交互。我们将通过类方法来完成这个任务。

类方法

类方法实际上就是一个属于类的函数。我们之所以为这些函数使用不同的名称,是为了能够区分一个属于类的函数和一个不属于类的函数。一旦你听到有人提到方法,你就知道这是一个属于类的函数。

在类中我们通常会找到两种流行的方法,被称为gettersetter。getter 是一个返回私有成员变量值的函数,而 setter 是一个允许我们更改其值的函数。

要使方法在类外可用,我们可以使用public关键字。为此,我们可以为我们的类添加 getter 和 setter,然后它看起来就像这样:

class Person
	private name
	private age
	public function get_name()
		return name
	end_function
	public function set_name(new_name)
		name = new_name
	end_function
	public function get_age()
		return age
	end_function
	public function set_age(new_age)
		age = new_age
	end_function
end_class

这将使我们能够访问成员变量。现在我们可以创建对象,获取它们内部存储的私有数据,并在需要时更改它们的值。

它看起来可能像这样:

p1 = Person("Dipika", 34)
p2 = Person("Manfred", 58)
print p1.get_name() + " is " + p1.get_age() + " year old"
p1.set_age(35)
print p1.get_name() + " is " + p1.get_age() + " year old"

这将产生以下输出:

Dipika is 34 years old
Dipika is 35 years old

在这个阶段,一个自然的问题是我们为什么需要费心去有这些 getter 和 setter。为什么我们不能简单地说nameagepublic的,并让任何人随意读取和更改它们呢?原因在于,保持数据私有并通过方法控制对它的访问将给我们带来控制权。

如果一个陌生人走到你面前并询问你的名字,你将有一些选择。你可以回答你的真实姓名,你也可以告诉他们这不关他们的事,或者你可以撒谎并告诉他们一个不同的名字。你对自己的私人数据有控制权,正如类可以通过这些方法对其私有数据进行控制一样。

当调用set_age方法时,例如,我们可以检查传入的值以确保它在有效范围内。例如,如果年龄是一个负数或高于任何预期的正常人类年龄,我们可以拒绝设置年龄。我们可以使用异常,这在第九章中有所介绍,即当事情出错 – 错误和异常,在处理异常部分。set_age方法可能看起来像这样:

public function set_age(new_age)
    if age < 0 or age > 130 then
       throw ValueError("Error. Age must be between 0 and 130")
    end_if
    age = new_age
end_function

如果传递给方法的值低于0或大于130,我们将得到一个异常。

现在,我们可以在之前在图 10.2中看到的图中添加一些类方法。在下面的图像中,我们可以看到我们正在使用矩形的下半部分来完成这个任务:

图 10.10 – 具有成员变量和方法的一个类

图 10.10 – 具有成员变量和方法的一个类

一个类当然可以拥有除了获取器和设置器之外的其他方法。我们决定我们的类需要哪些方法。例如,我们的Person类可以有一个名为birthday的方法,通过这个方法我们可以将一个人的年龄增加一岁,如下面的代码片段所示:

class Person
	private name
	private age
	public function birthday()
		age = age + 1
	end_function
	// Getters and setters as before are defined here
end_class

我们现在可以这样使用它:

p1 = Person("Dipika", 34)
print p1.get_name() + " is " + p1.get_age() + " year old"
p1.birthday()
print p1.get_name() + " is " + p1.get_age() + " year old"

这个程序输出的结果将与之前相同:

Dipika is 34 years old
Dipika is 35 years old

如我们所见,面向对象编程的强大之处在于对象是自包含的实体,它们控制着自己的数据。但面向对象编程还有一个强大的功能,那就是代码重用的概念,即继承。

继承

如果我问你能否借用你的手机打电话,无论是给你智能手机、2005 年的旧手机,还是提供座机电话的使用,这都无关紧要。它们都有一些共同的特征,其中之一就是能够打电话。我们可以用一系列陈述来定义这一点,如下所示:

  • 智能手机是移动电话

  • 移动电话是电话

  • 电话可以打电话

  • 因此,智能手机可以打电话

我们可以说,我们有几个抽象级别,它们之间存在关系。我们称之为“是”关系。我们可以用以下图表来表示这一点:

图 10.11 – 电话之间的“是”关系

图 10.11 – 电话之间的“是”关系

我们可以说,因为智能手机是移动电话,所以它可以做任何移动电话能做的事情。我们还知道,智能手机可以做老式移动电话做不到的事情,比如允许我们使用 GPS 和地图应用一起导航。

另一方面,移动电话可以做固定电话能做的所有事情;也就是说,可以拨打电话和接听电话。但它还可以做其他事情,比如允许我们发送短信。

我们也可以将这种关系视为一种父子关系。智能手机是移动电话的子类,而移动电话是其父类。这也意味着子类将继承其父类的特性。这就是面向对象编程中继承的工作原理。

一个类可以继承另一个类,通过这种方式,它将继承父类中定义的所有内容,并添加使这个类独特的东西。为了看到这可能是什么样子,我们需要两个类。我们可以有一个Person类,就像我们在图 10.10中看到的那样定义。它可能看起来像这样:

class Person
	private name
	private date_of_birth
	private gender
	private address
	private nationality
	private phone_number
	private email_address
	public function get_name()
		return name
	end_function
	public function set_name(new_name)
		name = new_name
	end_function

     // Getters and setters for all the other 
     // variables are implemented here
end_class

在这个类中,我们将所有成员变量定义为类的私有成员,所有获取器和设置器都定义为公共的。

这里使用的信息适用于所有人,但我们可能需要存储一些人的额外信息。例如,可能是员工。他们是人,因此,我们存储的关于个人的所有信息也将适用于他们,但我们还想存储关于员工的额外数据。这可能包括薪资和部门。然而,我们不想像以下图像所示那样定义它们:

图 10.12 – 未使用继承的两个类

图 10.12 – 未使用继承的两个类

在这里,我们可以看到Person类中的所有内容都在Employee类中重复出现。唯一的区别是我们添加了Employee类将继承自Person类,通过这种方式,它们自动获得Person中定义的所有内容。这看起来可能像这样:

图 10.13 – 员工从 Person 继承

图 10.13 – 员工从 Person 继承

在这里,Employee类将继承Person类中的所有内容。我们只需要在Employee类中定义使这个类独特的东西。正如我们通常用有三个部分的矩形来表示类一样,继承用空心箭头符号来表示,如图所示。

在代码中实现这种继承时,我们不需要对Person类做任何修改。因此,Employee类将看起来像这样:

class Employee inherit Person
	private salary
	private department
	public function get_salary()
		return salary
	end_function
	public function set_salary(new_salary)
		salary = new_salary
	end_function
	public function get_department()
		return department
	end_function
	public function set_department(new_department)
		department = new_department
	end_function
end_class

尽管在这个类中我们只有薪资和部门这两个代码,但从第一行代码我们就可以看出我们继承了Person类。

面向对象编程的具体实现方式因语言而异。支持面向对象的语言有时也会对面向对象的使用规则有所不同的规定。一如既往,我们需要学习我们所使用的语言是如何定义面向对象原则的。

以下是一些支持面向对象(完全或作为选项)的流行语言列表:

  • C++

  • C#

  • Java

  • JavaScript

  • Objective-C

  • PHP

  • Python

  • Ruby

  • Scala

  • Swift

面向对象是主要的编程范式之一。它拥有众多粉丝,他们喜欢它并认为这是一种很好的代码结构方式。接下来我们将探讨的下一个范式已经存在很长时间,但在过去几年中其受欢迎程度有所上升:函数式编程。现在,许多程序员认为它是一种更有趣的范式。

理解函数式编程

函数式编程是一种越来越受欢迎的范式。它并不新鲜;我们可以追溯到 20 世纪 30 年代提出的 Lambda 演算。在 20 世纪 50 年代,编程语言 Lisp 被开发并实现了这种范式。

正如我们将看到的,这种范式在程序的结构和实现方式上有着非常不同的方法。为了理解这种范式的优势,您需要重新思考您看待编程和代码结构的方式。

我们将从查看函数式编程的定义开始。这个定义本身可能很难理解,因此我们还需要查看其中的一些部分来理解这是关于什么的。

一个定义如下:

"函数式编程是一种构建计算机程序的方式,它将计算视为数学函数的评估,并避免改变程序的状态和可变数据的使用。"

让我们从解读这个定义开始。其中“将计算视为数学函数的评估”的部分可能听起来有些吓人。如果我们仔细观察,我们会发现这其实相当直接。让我们看看两个数学函数,看看我们如何使用和理解它们,以便理解函数式编程的定义在说什么。

纯函数

我们将从以下简单的函数开始:

在这里,x是我们提供给函数的输入,y是结果。这个简单的函数只是表明,无论我们传递什么值给它,它都会返回相同的值。这个函数的示意图如下:

图 10.14 – y = x 的示意图

图 10.14 – y = x 的示意图

在这里,我们可以看到如果x1,则y也是1,如果x-3,则y也是-3。关于定义所谈论的内容,我们还需要理解的一个更基本的事实是,相同的输入到x将始终产生相同的y作为结果。如果我们为x输入5,我们总是会得到y5

让我们看看另一个函数,看看是否同样成立:

这是一个表示直线的函数。当值为23时,我们将得到以下示意图:

图 10.15 – 当 m = 2 且 c = 3 时,y = mx + c 的示意图

图 10.15 – 当 m = 2 且 c = 3 时,y = mx + c 的示意图

再次强调,从这个例子中我们学到最重要的东西是结果,即当03的值,以及当-1时总是1

当从函数式编程的角度谈论数学函数时,这是关键的事实:具有相同输入的函数总是会返回相同的答案。在函数式编程中,这被称为纯函数

但这难道不是任何函数都始终成立的吗?看看下面的函数:

function add(a, b)
    return a + b
end_function

让我们称这个函数为:

print add(2, 3)

调用函数时,我们总是会得到以下响应:

5

我们可以说这是一个纯函数。但是,当我们从math模块调用这个函数时会发生什么呢?

print Math.random()
print Math.random()

现在,输出可能看起来是这样的:

0.34
0.75

使用相同的参数(在我们的情况下没有参数)调用随机函数不会给出相同的答案。这不是一个纯函数。这就是在先前的定义中评估数学函数的含义。

接下来是定义的第二部分,避免改变程序状态和可变数据的使用。改变状态是我们从结构化编程中讨论的内容时认识到的。在那里,我们说结构化编程使用语句来改变程序的状态。这似乎是功能编程在谈论结构化编程所做事情的绝对反面。我们说程序的状态是由在任何给定时间存储在其所有变量中的数据的组合所定义的。改变一个变量的值将改变程序的状态。

如果一个程序避免改变其状态,这意味着我们无法改变任何变量吗?对这个问题的答案是肯定的,这也是定义的最后一部分:它也避免使用可变数据。可变数据是什么意思?我们将在下一节中看到这一点。

可变和不可变数据

可变意味着可能改变,而不可变意味着无法改变。术语可变数据意味着我们可以改变的数据。我们知道我们可以在变量中存储数据,并且我们可以随意更改它,如下面的代码块所示:

x = 10
y = 20
x = y

在这里,我们首先将值10赋给x变量,然后将值20赋给y变量。在最后一行,我们改变x的值,使其与y相同,即20。我们可以这样说,x是可变的,因为我们能改变它。但这能证明x是可变的吗?在某些语言中是这样的,但在其他语言中,这根本不是真的,即使x中的最终值始终是20。如果我们不能改变它,x如何从10变为20?这听起来是不可能的。

答案在于语言处理其变量的方式。如果我们只是把变量想象成一个可以存储值并且可以在任何时候用另一个值替换它的盒子,它是可变的,但如果我们把变量想象成指向计算机内存中某个位置的值,事情就会有所不同。

让我们进行一个小型的思维实验。我们可以从两个变量xy开始,并再次将它们赋值为1020,就像我们之前做的那样:

x = 10
y = 20

以下图表说明了如果xy引用两个内存位置可能看起来像什么:

图 10.16 – 两个变量引用两个内存位置

图 10.16 – 两个变量引用两个内存位置

如我们所见,值1020并没有存储在变量中。相反,变量是指向这些值在内存中的位置。如果我们改变x引用的值从1020会发生什么?

它看起来可能像这样:

图 10.17 – 指向相同内存位置的变量

图 10.17 – 指向相同内存位置的变量

现在,让我们考虑如果内存位置上的值可以被改变,如果我们改变其中一个变量会发生什么,例如,如果我们运行这一行代码:

y = 22

我们将会有一个类似以下的情况:

图 10.18 – 改变一个变量引用的值

图 10.18 – 改变一个变量引用的值

现在,让我们打印x引用的值,如下所示:

print x

我们会得到以下结果:

22

即使我们没有给x赋值22,它也会具有那个值,因为我们允许y改变两个引用的内存位置的内容。

如果我们改为使内存位置不可变,当我们给y赋值22时会发生什么?我们会得到如下所示的结果:

图 10.19 – 给不可变变量赋新值

图 10.19 – 给不可变变量赋新值

如我们所见,值根本没有任何变化。相反,y现在引用了一个新的内存位置。如果重新声明y,我们会得到一个与旧y变量同名的新鲜变量。

这就是不可变性的工作方式。变量不会被改变。相反,在内存的另一个位置创建了一个新值。由于我们不能改变任何变量,因此我们也不能改变程序的状态。

但为什么我们的变量是不可变的,而且我们无法改变程序的状态这么重要呢?答案就是副作用。

避免副作用

计算机编程中的副作用是指表达式修改了其局部环境之外的变量中的某些值。为了理解这一点,让我们看一个例子:

x = 0
function some_func(value)
    x = x + value
    return x + 3
end_function

首先,这个程序非常简单,但它说明了我们需要说明的观点。在这里,我们有一个变量x和一个名为some_func的函数。变量在函数外部声明,但在函数内部被修改。我们现在可以使用以下表达式:

 x = x + value

这是在修改其环境之外的价值,函数体是表达式存在的环境。

这在如果我们使用的语言已经将x定义为可变,以便我们可以改变它是正确的。但在一个x不可变的语言中,将不会有任何变化。相反,我们会得到一个新的x变量,它只存在于函数内部。

如果我们在一个x是可变性的语言中这样做,会有什么缺点呢?为了看到这一点,我们可以调用函数两次并打印其结果,如下所示:

print some_func(10)
print some_func(10)

输出将如下所示:

13
23

这不是一个好的行为,因为用相同的参数调用函数应该始终返回相同的值。在这里,它没有,而且这种情况发生的原因是程序有副作用。这是因为函数返回的结果取决于函数之前的调用发生了什么。

如果我们有一个没有副作用(side effects)的程序,那么程序运行时会发生什么将会非常可预测。如果我们思考之前的小程序,我们会看到要预测函数调用的结果几乎是不可能的,因为结果将取决于之前的调用,以及在这些调用中我们提供的参数数据。

函数式编程的下一个原则被称为声明式编程。让我们看看它到底是什么。

命令式编程

要理解命令式编程是什么,我们可以将其与我们已知的事物进行比较,那就是命令式编程。在命令式编程中,我们专注于描述如何完成某事。而在命令式编程中,另一方面,我们的焦点在于我们想要实现的目标。

为了理解这一点,我们将查看一些现实世界的例子。如果你去餐厅,你可以要么是一个命令式客人,要么是一个声明式客人。

命令式客人会下这样的订单:

“请给我一份鳕鱼。首先,在烤箱里烤 10 到 12 分钟。最后,要经常检查,以免烤焦。当鳕鱼在烤箱里时,请煮土豆。为了准备奶油酱,首先,在中号平底锅中用中火融化一些黄油。慢慢加入玉米淀粉,搅拌大约一分钟。在不断地搅拌的同时,慢慢加入打发奶油和牛奶。最后,加入一些帕尔玛干酪。在小火上慢慢煮酱汁,偶尔搅拌。”

如果你是命令式餐厅客人,你可能会这样说:

“请给我一份鳕鱼。”

第一位客人回答了如何做的问题,而第二位客人则关注了是什么。

计算机科学中一个优秀的声明式例子是SQL。它是结构化查询语言的缩写,用于存储和检索数据库中的数据。如果我们想获取存储在客户表中的所有客户的姓名和姓氏,我们可以编写以下代码:

SELECT firstName, lastName FROM customers;

这是一种声明式,因为我们说了我们想要什么——客户的姓名和姓氏——但我们没有说关于如何检索数据的事情。在底层数据库系统中,某些部分必须知道如何完成这件事,但如果我们使用 SQL,我们不需要了解它是如何完成的。

Python 是一种编程语言,我们可以在其中编写命令式和声明式程序。让我们看看两个执行相同任务的程序,一个是以命令式方式,另一个是以声明式方式。

首先是一个用命令式风格编写的简短程序:

strings = ['06', '68', '00', '30']
numbers = []
for value in strings:
    if int(value) != 0:
        numbers.append(int(value))
print(numbers)

从前面的代码中,我们可以观察到以下情况:

  1. 在第一行,我们定义了一个字符串列表。每个字符串包含一个两位数。小于 10 的值将用 0 作为前缀。

  2. 在第二行,我们声明了一个空列表。我们将把第一个列表中的数字从字符串转换为整数值,并存储在这个数组中。

  3. 然后,我们将进入一个 for 循环。在这个循环的每次迭代中,第一个列表中的一个值将被分配给 value 变量。第一次将是 06,第二次将是 68,依此类推。

  4. 然后,我们有一个 if 语句。它将值转换为整数并与零进行比较。如果这是假的——也就是说,它不是零——我们将进入 if 块。

  5. 在这个块内部,我们将把我们将值转换为整数后添加到 numbers 列表中。

  6. 当我们遍历完第一个列表中的所有值后,我们打印第二个列表的内容,得到以下输出:

    [6, 68, 30]
    

如你所见,我们为第一个值保留的零前缀现在没有了,因为这些是整数,06 就是 6。另外,那个有双重零的值根本不在列表中,因为它使 if 语句为假,并且在这个迭代中跳过了添加值的行。

这个程序的第二个版本是用声明式风格编写的,看起来如下所示:

strings = ['06', '68', '00', '30']
numbers = [int(value) for value in strings if int(value) != 0]
print(numbers)

这个程序与上一个程序做的是同样的事情,但它的编写方式非常不同。它使用了一种叫做 列表推导 的东西。这是 numbers = 后面的部分。如果你仔细看,你可以在这个表达式的中间看到一个 for 循环。它看起来就像其他示例中的 for 循环。在这个循环之后,我们可以看到一个 if 语句,它看起来就像第一个程序中的 if 语句。在这个推导中的这个位置的 if 语句充当一个过滤器。如果这个表达式评估为真,当前值将被传递到这个表达式的开头。在这里,我们将值转换为整数。这个转换后的值将成为一个名为 numbers 的列表的一部分。

这是一种声明式,因为我们没有说明这个值是如何进入新列表的,我们只是说什么会进入列表。

我们将要介绍的函数式编程的最后一个原则被称为一等函数。

一等函数

函数式编程使用一等函数的原则。如果一个函数被当作编程语言中的一等公民来对待,那么它就被说成是一等函数。一等公民是可以被修改的、可以作为函数参数传递、可以从函数返回等。

在支持一等函数的编程语言中,我们可以做如下事情:

function formal_greeter(name)
    return "Dear, " + name
end_function
function informal_greeter(name)
    return "Yo, " + name
end_function
function greeter(greeter_func, name)
    greeter_func(name)
end_function
greeter(formal_greeter, "Bob")
greeter(informal_greeter, "Bob")

这个程序声明了两个函数,formal_greeterinformal_greeter。两者都接受一个 name 作为参数,并将返回带有名称的问候语。

我们有一个名为 greeter 的函数。这个函数接受一个函数引用作为其第一个参数和一个名称作为其第二个参数。程序中的最后两行是调用 greeter 函数。第一行传递的是正式的 greeter 函数的引用,而第二行传递的是非正式的 greeter 函数的引用。

greeter 函数将使用传递给它的函数,因此这两个调用将产生以下输出:

Dear, Bob
Yo, Bob

能够使用这样的函数有几个好处。让我们看看一个例子。在本章的早期,我们讨论了面向对象编程,并定义了一个名为 Person 的类。我们看到我们可以从这个类创建几个对象,每个对象代表一个人。

在本章的后面,我们将看到编程语言可以使用多种范式,如果我们使用一种允许我们定义类并将函数作为一等公民使用的范式,我们可以做一些非常有用的事情。

如果我们创建一些具有 nameagePerson 对象并将它们插入到列表结构中,这可能会看起来像以下这样:

p1 = Person("Dipika", 34)
p2 = Person("Manfred", 58)
p3 = Person("Ahmed", 38)
p4 = Person("Rita", 39)
persons = [p1, p2, p3, p4]

我们现在有一个名为 persons 的列表中存储了四个 Person 对象。如果我们想对这个列表进行排序,我们可以使用语言提供的排序函数。但是有一个问题。排序函数不知道我们想要根据什么来排序;也就是说,是 name 还是 age。它甚至不知道 Person 对象,因为它们是由我们编写的类定义的。它所知道的是如何排序一个列表,但它需要函数的帮助,这个函数可以从 Person 类接收两个对象,并在第一个对象大于第二个对象时返回 true,否则返回 false。我们需要编写这个函数,并在其中定义什么使得一个对象比另一个对象大。我们可以决定是按 name 还是 age 来排序。

如果我们想要按 age 排序对象,我们可以这样做:

function compare(person1, person2)
    return person1.get_age() > person2.get_age()
end_function
sorted_persons = sort(compare, persons)

这里,我们有一个名为 compare 的函数。它将接受两个 person 对象作为其参数。如果第一个人的年龄大于第二个人的年龄,这个函数将返回 true。否则,它返回 false

sort 函数将其第一个参数作为对这个函数的引用。在执行排序时,它需要比较两个不同的对象以确定它们在排序列表中的顺序。

在我们的例子中,它首先将 Dipika(34 岁)和 Manfred(58 岁)传递给函数。由于 Manfred 的年龄大于 Dipika 的年龄,compare 函数将返回 false

现在,sort 函数将取上轮比赛的胜者 Manfred,并将这个对象与 Ahmed 的对象一起传递。这次,Manfred 将首先传递,因此他将是函数中的 person1 对象,而 Ahmed 将是 person2

这次,第一个对象的年龄大于第二个对象,因此函数返回 true

这就是 sort 函数如何使用我们提供的函数来完成其排序列表的任务。如果我们想按名称排序,我们只需要更改 compare 函数,使其比较名称而不是年龄。

如果我们打印包含排序列表的 sorted_persons 列表,如果我们按年龄排序,我们会得到以下结果:

Dipika, 34
Ahmed, 38
Rita, 39
Manfred, 58

如果我们按名称排序,我们会得到以下结果:

Ahmed, 38
Dipika, 34
Manfred, 58
Rita, 39

首类函数是一个令人信服的特性,它让我们能够编写更通用的函数,因为我们可以传递另一个函数来完成其部分工作,就像排序函数那样工作。

函数式编程有几个既强大又让我们能够编写更高品质代码的概念。这就是为什么函数式编程一直在不断获得人气,以及为什么许多非函数式编程语言正在借用函数式概念的原因。

以下是一些支持函数式编程的流行语言的列表,这些语言要么将其作为主要范式,要么使用了函数式编程的许多概念:

  • C++(自 C++ 11 版本起)

  • C#

  • Clojure

  • Common Lisp

  • 艾拉朗

  • F#

  • Haskell

  • JavaScript

  • Python

  • Ruby

函数式编程不仅是一个非常有趣的范式,而且它还在影响许多已建立的语言,使它们能够吸收函数式概念。下一个范式不像我们之前看到的那些范式那样广泛使用,但它有一些有趣的概念。

理解逻辑编程

这种范式基于形式逻辑。用实现这种范式的语言编写的程序由一组逻辑形式的句子组成,这些句子将表达特定问题域的事实和规则。

这可能听起来很复杂和奇怪,但正如我们将看到的,这个范式的核心概念相当简单。考虑以下图示:

图片

图 10.20 – 一个家谱

在前面的图中,我们可以看到一个家谱。看着它,我们可以看到以下内容:

  • 安娜和鲍勃有一个孩子,莉萨。

  • 莉萨和弗雷德有一个孩子,卡伦。

  • 弗雷德和苏有一个孩子,约翰。

  • 卡伦的祖父母是安娜和鲍勃。

在使用逻辑编程的编程语言中,我们可以使用称为谓词的东西来定义这个家谱。这看起来可能像这样:

mother(anna,lisa).
mother(lisa,karen).
mother(sue,john).
father(bob,lisa).
father(fred,karen).
father(fred,john).

它们可能看起来顺序有些奇怪,但大多数逻辑语言都希望我们将同一类的所有谓词放在一起,所以在这种情况下,我们首先定义所有的母亲,然后是所有的父亲。

在第一行,我们可以看到Anna是莉萨的母亲,而在第四行,我们可以看到Bob是莉萨的父亲。这些名字被称为原子,因为它们代表一个单一值,并且原子只能用小写字母定义。

我们现在可以定义一些规则,这些规则规定了什么使某人成为父母和祖父母。它可能看起来像这样:

grandparent(X,Z) :- parent(X,Y), parent(Y,Z).
parent(X,Y) :- father(X,Y).
parent(X,Y) :- mother(X,Y).

在这里,XYZ是变量。变量用首字母大写来定义。我们可以这样读

对于任何XYZ

如果XY的父母,且YZ的父母

然后XZ的祖父母

最后两行定义了什么是父母。要么是XY的父亲,要么是XY的母亲。

我们现在可以使用这个来提出如下问题:

grandparent(anna, karen).

这个问题将产生以下答案:

yes

这对AnnaKaren的外祖母来说是正确的。

我们还可以询问卡伦的祖父母是谁,如下所示:

grandparent(Q, karen).

在这里,Q是一个变量,我们将得到以下响应:

bob
anna

我们也可以问 Anna 的孙子是谁:

grandparent(anna, Q).

这将告诉我们它是Karen

karen

当然,在逻辑编程语言中你可以做更多的事情,但这只是逻辑编程可能看起来的一部分。

以下是一些支持逻辑编程的语言列表:

  • ALF

  • Curry

  • Fril

  • Janus

  • Prolog

在逻辑编程中,我们结构代码的方式与其他所有范式非常不同,使其成为一个有趣的局外人

我们现在已经看到了范式领域的领先者。但在我们离开这些范式之前,让我们简单提一下更多的一些,以获得更完整的图景。

其他范式

本章中我们讨论的范式是目前最常用的,但还有几个其他的。让我们快速看一下其中的一些。

函数级

在函数级编程中,我们根本没有任何变量。相反,程序是由基本函数构建的,结合函数到函数的操作,有时被称为泛函函数形式

实现这种范式的语言是围绕以下层次结构构建的:

  • 原子是函数操作的数据。它们只会作为程序的输入或输出出现,永远不会在程序的实际内部找到。

  • 函数将原子转换为其他原子。编程语言将定义一组函数,程序员可以使用函数形式创建新的函数。程序本身也是一个函数。

  • 函数形式被用来将函数转换为其他函数。程序员可以使用它们来创建新的形式。

数组编程

在数组编程中,操作将同时对整个值集进行。这些解决方案通常用于科学和工程应用。

操作被推广到可以应用于标量和数组。在这本书中,我们已经遇到了以变量的形式出现的标量,它们一次只能持有单个值。我们也已经看过数组。如果你需要刷新关于变量和数组的记忆,你可以在第六章 处理数据 – 变量中了解更多。

ab是标量或数组时,a + b操作将会有不同的行为。如果它们是标量,结果将是两个值的和。如果它们是数组,结果将是两个数组中存储的所有值的总和。

数组编程可以在牺牲效率的情况下简化编程。这意味着当我们编写代码时,使用这些语言可能更容易,但运行它们可能比用使用其他范式的语言编写的程序花费更长的时间。

量子编程

这是我们未来的范式。要能够使用这个范式,我们需要量子计算机。量子计算机利用量子物理学中定义的粒子量子力学特性。这些粒子具有叠加性,这意味着在我们观察它们之前,它们将处于任何可能的位置。量子计算机将通过定义一种称为量子比特的东西来利用这一点。普通计算机的比特可以是 0 或 1。量子比特将同时是这两种状态,利用这一特性,量子计算机将能够在极短的时间内计算出任何给定输入的所有可能结果,这比我们今天使用的计算机进行相同计算的时间要短得多。

量子编程本身不是一个范式,但为了能够为量子计算机编写程序,我们需要支持比我们今天使用的操作更多的语言:

![图 10.21 – 瑞士苏黎世 IBM 研究实验室建造的量子计算机的一部分。照片由 IBM 苏黎世实验室提供,cc-by-2.0。(img/Fig_10.21_B15554.jpg)图 10.21 – 瑞士苏黎世 IBM 研究实验室建造的量子计算机的一部分。照片由 IBM 苏黎世实验室提供,cc-by-2.0。尽管我们只是看到量子计算机的第一批缓慢成形,但我们已经定义了可以用于它们的几种语言。它们建立在现有的范式之上,如命令式编程和函数式编程。当我们拥有完全功能化和可访问的量子计算机时,我们将看到利用这些计算机力量的新语言的爆炸式增长。# 多范式语言大多数编程语言不会仅仅坚持一种范式,而是使用多种。这就是为什么它们被称为多范式语言。我们可以制作一张表格,列出一些最受欢迎的语言以及它们支持哪些范式:表 10.1

表 10.1

总是可以说一个范式对编程语言的影响有多大。在这里,我研究了本章中我们探讨的主要范式以及语言的文档如何描述自己。

被标记为Some的语言已实现该范式的某些概念。在某一范式的列中有Yes的语言可能不是该范式的主体,但已实现了许多其特性。

摘要

在本章中,我们探讨了最流行的编程范式。

我们首先探讨的前两种,结构化编程和面向对象编程,是过去 35-40 年主导编程的两种范式。

在结构化编程中,程序状态是通过语句修改的,程序流程是通过循环和选择(如 if 语句)控制的。

面向对象编程建立在结构化编程的思想之上,但代码的组织是使用我们人类所熟知的概念,例如将具有相似数据和行为的对象进行分类。这通过类来实现,类作为代表现实世界中事物(如人或银行账户)的对象的蓝图。

函数式编程是我们在本书中介绍的最古老的范式,但在过去十年中却获得了流行。在函数式编程中,我们不希望修改程序的状态,并使用纯函数的概念来实现这一点。使用这种范式编写程序可以减少代码中的错误,并使我们的应用程序更加稳定。

在逻辑编程中,我们定义谓词,这些谓词将定义规则,我们可以使用这些规则来回答逻辑问题。与其他三种范式相比,局部编程的流行度要低得多。

有许多其他范式可供选择,它们通常相当专业化或被一些鲜为人知的语言所使用。

大多数编程语言都是多范式的,即它们使用来自多个范式的概念。

在下一章中,我们将看到,作为程序员,我们的工作并不在编写代码时就结束了。

第十一章:第十一章:编程工具和方法论

现在,是我们更深入地了解开发周期的时候了。生产软件不仅仅是编写代码。我们必须计划将要编写的代码,编写代码,将编写的代码与现有代码集成,与其他开发者共享代码,测试代码,部署代码以便用户可以访问应用程序,向应用程序添加新功能,并修复在已发布代码中出现的错误和漏洞。

为了实现这一点,开发团队通常会使用不同的工具和方法来决定如何进行以及顺序。

在本章中,我们将探讨所有构成开发过程但不是实际编码的组成部分。

在本章中,我们将涵盖以下主题:

  • 理解版本控制系统的概念及其用途

  • 理解单元测试

  • 理解集成测试

  • 理解版本的概念

  • 理解软件部署

  • 理解代码维护

  • 理解软件开发过程方法论

我们有很多内容要介绍,所以让我们从软件开发中的一个基本工具:版本控制系统开始。

理解版本控制系统

版本控制系统VCS)用于管理文档、计算机程序或其他文件集合中的更改。程序员使用它们来管理其代码的不同版本。

如果需要,可以恢复早期版本。这使得编辑文件更加安全,因为我们总有办法在决定需要重置所做的更改时恢复我们之前的状态。

VCS 也被用于让开发者能够共同在一个项目上工作,并且以安全的方式在相同的源代码文件中工作。版本控制系统还跟踪谁在文档中更改了什么,以及何时进行了更改。

受版本控制的文件存储在称为仓库的地方。当对文件进行更改时,VCS 用户可以将这些更改提交到仓库,并由此创建一个回滚点。在这些点上,版本控制系统会对所有更改进行快照。

为了说明这是如何工作的,我们可以使用最流行的版本控制系统之一:Git。Git 由 Linux 的创造者林纳斯·托瓦兹(Linus Torvalds)于 2005 年创建。最初是为了让在 Linux 内核代码上工作的程序员使用,但很快在 Linux 项目之外也获得了流行。如今,它是最受欢迎的版本控制系统。

让我们从编写一些代码开始。假设我们在一个名为calc.code的文件中有以下代码:

function add(a, b)
    return a + b

将此文件保存在一个空文件夹中。现在我们可以使用 GIT 软件创建一个仓库。这意味着我们可以开始对这个文件夹内的文件进行版本控制。在命令行中,我们可以通过编写以下代码来完成:

git init

到目前为止,还没有任何内容被版本控制。我们需要告诉 GIT 我们想要添加到版本控制中的文件。我们可以使用以下命令来做到这一点:

git add calc.code

我们现在的文件已经准备好。这意味着它的更改将被跟踪,但我们需要做一件事来记录对文件所做的更改。我们将要做的是提交。提交将记录我们的更改并将它们存储在我们的仓库中。让我们使用以下命令来执行:

git commit -m "Initial Commit"

-m告诉git我们将提供一个提交信息,信息就是引号内的内容。这些信息将帮助我们查看提交中的更改,因此我们应该花些时间来想出描述性的信息。

我们所做的更改现在存储在被称为主分支的地方。以下图表展示了它的样子:

图 11.1 – 初始提交后的主分支

图 11.1 – 初始提交后的主分支

我们现在想继续我们的程序并添加更多代码到这个文件中。为了使生活更加安全,我们可以创建一个新分支并在该分支中进行更改。我们很快就会看到这可能是为什么。

要创建一个名为subtract的新分支,我们可以使用以下命令:

git branch subtract

创建一个新分支将给我们一个从该分支创建的确切副本,在这个例子中是主分支。这可以如下表示:

图 11.2 – 从主分支创建名为 subtract 的新分支

图 11.2 – 从主分支创建名为 subtract 的新分支

我们仍然在主分支上,所以我们将做的任何更改都将在这个分支上。因此,在我们做任何其他事情之前,我们应该切换分支。我们可以使用checkout命令来做到这一点:

git checkout subtract

现在,我们已经在subtract分支上了。现在,我们可以更新我们的源代码文件,所以让我们添加另一个函数,使文件看起来像这样:

function add(a, b)
    return a + b
function subtract(a, b)
    return a - b

如果我们保存这些更改,我们可以将文件添加到暂存区,并使用以下命令提交更改:

git add calc.code
git commit -m "Added the subtract function."

我们可以用提交分支上的另一个圆圈来表示这个提交,如下面的图表所示:

图 11.3 – 向我们的新分支提交

图 11.3 – 向我们的新分支提交

现在我们已经将更改提交到 GIT 中,我们可以切换分支。让我们使用以下命令来执行:

git checkout master

如果我们现在打开文件,我们将看到以下内容:

function add(a, b)
    return a + b

我们所做的更改不在这里。这是因为这些更改不在主分支上。在编辑器中保持文件打开,我们现在可以通过使用以下命令切换回subtract分支:

git checkout subtract

就像魔法一样,我们对文件所做的更改又回来了,subtract函数就像我们离开时一样:

function add(a, b)
    return a + b
function subtract(a, b)
    return a - b

这说明,如果我们出于某种原因决定我们所做的更改不好,我们总是可以回到我们的 master 分支,一切都会像我们开始之前一样。然而,如果我们对所做的更改感到满意,我们现在可以将两个分支合并在一起。在 GIT 中,这被称为合并。在合并两个分支之前,我们首先应该确保我们想要合并的分支是活动的。在这种情况下,它是 master 分支,所以我们写下以下内容:

git checkout master

现在,我们准备好将我们所做的更改合并回 master 分支。我们可以使用以下命令来完成此操作:

git merge subtract

这意味着我们取名为subtract的分支所做的更改,并将其与当前分支master的内容合并。我们可以用以下图表来表示这一点:

图 11.4 – 将 subtract 分支与 master 分支合并

图 11.4 – 将 subtract 分支与 master 分支合并

这就是我们如何在本地机器上使用 GIT 进行版本控制的方法。现在,让我们看看我们如何使用它来与其他程序员协作,他们与我们一样在同一个项目上工作。为此,我们不仅需要我们的本地仓库,还需要一个中央仓库,我们可以用它来更新我们所做的更改。

假设爱丽丝和鲍勃都在同一个项目上工作,并想使用 GIT 来更新他们在项目中任何文件所做的更改。他们不仅会有我们之前看到的本地 GIT 仓库,而且他们还会连接到一个集中式仓库。他们所做的任何更改现在都可以推送到这个仓库,他们也可以从该仓库拉取其他人所做的任何更改。

这看起来可能如下所示:

图 11.5 – 两位程序员连接到中央仓库

图 11.5 – 两位程序员连接到中央仓库

我们现在可以假设他们都将工作在我们之前看到的同一个文件calc.code上,并且它具有我们离开时的相同内容,如下所示:

function add(a, b)
    return a + b
function subtract(a, b)
    return a - b

中央仓库通常被称为multiply函数:

  1. 她首先应该做的是使用以下命令从中央仓库(origin)拉取最新版本:

    origin points out the central repository, and master is the branch she wants to pull down. 
    
  2. 她现在有了最新版本,她可以开始实现她的功能。她决定在一个新的分支上做这件事,因此她执行了以下命令:

    git branch multiply
    
  3. 然后,她将使用以下命令切换到这个分支:

    git checkout multiply
    
  4. 现在,她可以做出必要的更改,因此她将以下代码添加到文件中:

    function add(a, b)
        return a + b
    function subtract(a, b)
        return a – b
    function multiply(a, b)
        return a * b
    

让我们让爱丽丝留在这里,看看鲍勃在做什么。

当爱丽丝开始她的工作时,鲍勃决定他想创建一个divide函数:

  1. 就像爱丽丝一样,他将拉取master分支的最新版本,创建一个名为divide的新分支,切换到它,然后更改calc.code文件,使其看起来如下:

    function add(a, b)
        return a + b
    function subtract(a, b)
        return a – b
    function divide(a, b)
        return a / b
    

    在这个时候,爱丽丝对她的更改感到满意,因此她将它们提交到她的分支,并将她的分支与主分支合并。

  2. 现在,她想要将这些更改推送到中央仓库。不过,在她这样做之前,她想要确保中央仓库已经进行了更改。因此,首先,她尝试使用之前运行的相同命令从她的仓库中拉取任何更改:

    git pull origin master
    
  3. 由于自她上次拉取以来中央仓库没有发生任何变化,所以不会有任何变化。现在,她可以使用以下命令推送她的更改:

    git push origin master
    

现在,她的更改已存储在中央仓库中。在这个时候,她的本地master分支版本和存储在中央仓库中的版本是相同的。

让我们回到鲍勃,他已经完成了他的功能。他在自己的分支中将更改提交到本地仓库,并将其合并到主分支中。现在,他准备将更改提交到中央仓库:

  1. 就像爱丽丝一样,首先,他想要确保他已经从中央仓库获取了最新的更改,因此他发出一个pull命令:

    git pull origin master
    
  2. 这次,事情不会那么顺利。他收到一条消息,表示发生了合并冲突。那是什么?他打开代码文件,现在他看到这个:

    function add(a, b)
      return a + b
    function subtract(a, b)
      return a - b
    <<<<<<< HEAD
    function multiply(a, b)
      return a * b  
    =======
    function divide(a, b)
      return a / b
    >>>>>>> div
    

    发生的事情是他和爱丽丝在同一行对同一文件进行了更改,在这种情况下是在文件的末尾。

    由于这个原因,GIT 变得困惑,需要帮助来决定文件中将包含什么。

    鲍勃看了看,理解了爱丽丝在文件中添加了一个函数,与他在同一位置添加的函数相同,他理解这两个函数都应该在那里。

    <<<<<<< HEAD标记表示冲突的开始。在那一行和=======行之间的内容是他拉取的更改。在=======>>>>>>> div之间的代码是他的更改。

  3. 由于他决定这两个函数都应该在这个文件中,他从文件中删除了三条标记行,所以现在它看起来像这样:

    function add(a, b)
      return a + b
    function subtract(a, b)
      return a - b
    function multiply(a, b)
      return a * b  
    function divide(a, b)
      return a / b
    

他现在可以将更改提交到本地仓库,然后推送更改,以便爱丽丝稍后能够获取这些更改。

这说明了版本控制系统如何被用来在项目中的多个程序员之间共享工作。当然,版本控制系统还有许多其他方面我们没有在这里涵盖,但现在,你至少对版本控制系统能为你做什么以及如何用来与他人共享工作有一个概念。

此工具在整个开发过程中都被使用。现在,让我们看看代码编写时我们做什么,以及我们如何确保在将其推送到中央仓库之前它能够正常工作。

现在是进行单元测试的时候了!

单元测试

测试我们的代码是必要的,这样我们就可以验证它是否按预期工作。我们还将使用测试来确保我们对代码所做的任何更改都没有使之前工作正常的功能停止工作或以不期望的方式表现。

我们可以对我们的代码进行几种测试,我们将首先查看的一种测试类型称为单元测试。单元部分表示测试将在我们的代码的单独单元上进行。这通常是在函数级别。这意味着我们将尝试隔离一个单独的函数(或另一个小的代码单元),并仅在这一点上运行我们的测试。

这些测试通常由要测试的代码单元的开发者编写,并且通常是自动化的。这意味着一旦一段代码准备好提交到版本控制系统,它必须首先通过为其编写的单元测试。

由于单元测试只测试单个代码单元,因此它们通常是相当简单的。为了测试我们的calc函数,我们首先必须决定我们想要测试什么。首先,我们可以为有效输入设置一些测试用例,并将这些输入与一些预期的结果相匹配。

我们可以用这样的表格来做这件事:

表 11.1

表 11.1

我们可以用这个来编写我们的测试。

单元测试通常是在一个单独的文件中编写的,该文件将调用要测试的代码。它们可能看起来像这样:

function test_add_one()
  result = add(2, 3)
  assert.equal(5, result)

第一个参数,assert.equal,是我们期望的结果,它被与result变量的内容进行比较。如果它们匹配,则此测试将通过;如果不匹配,则测试将失败。我们现在可以继续以相同的方式为其余的测试用例创建测试:

function test_add_two()
  result = add(0, 10)
  assert.equal(10, result)
function test_subtract_one()
  result = subtract(2, 3)
  assert.equal(-1, result)
function test_subtract_two()
  result = subtract(7, 4)
  assert.equal(3, result)

这里,我们只是列出了一些,但我们会继续这样做,直到列出所有剩余的。

对于像这样的简单测试,结构将是相同的——调用我们想要测试的函数,将返回的值存储在一个变量中,并将返回的值与预期的值进行比较。我们还应该考虑异常情况以及我们期望得到的结果。例如,如果我们调用divide函数会发生什么?以下是代码的显示方式:

result = divide(10, 0)

正如我们在第九章中讨论的,“当事情出错——错误和异常”,我们不能将一个数除以零。这将导致异常。如果我们期望在这样做时得到一个异常,则函数正常工作,但如果我们期望函数处理这种情况,则需要对其进行修改。这是为了使其返回我们决定它应该返回的任何内容。如果我们决定我们应该得到另一个表示我们不能将 0 作为第二个参数传递的异常,则可能会发生这种情况。所以,无论我们期望什么,都应该进行测试。

这是我们应该在代码上运行的一种测试类型,但这些测试将只测试单个单元;也就是说,应用程序的一个隔离部分。我们还需要执行另一种类型的测试,称为集成测试。我们将在下一节中看到这是什么。

集成测试

集成测试是将几个单元组合在一起并测试它们,以确保它们在不再隔离的情况下也能正确工作。

一起工作的单元需要相互通信,它们将通过在它们之间传递数据来进行通信。这意味着集成测试是关于检查数据传输和数据表示方面是否工作。

想象一下,我们正在开发一个被划分为几个模块的项目。为了加快开发时间,我们让几个程序员分别处理不同的模块。这可能会看起来像这样:

图 11.6 – 四位开发者正在处理四个不同的模块

图 11.6 – 四位开发者正在处理四个不同的模块

这四位开发者现在将开始编写他们的模块,并且他们会通过在模块上运行单元测试来确保它们满足要求。但这些测试唯一能显示的是这个模块在独立状态下是工作的。

但在最终的应用中,这些模块将需要相互交互,当它们这样做时,它们将需要来回发送数据。这里的问题是所有四位程序员都是人类,人类倾向于对即使是描述最详细的内容也有不同的解释。所以,如果卡尔需要将一个年份的值传递给爱丽丝正在编写的模块,他可能会将其作为两位数的值传递,例如 23。但编写接收这些数据的代码的爱丽丝可能期望年份以四位数的格式出现,因此她期望是 2023。

如果我们追踪模块组合在一起时的通信,这可能会看起来像这样:

图 11.7 – 模块间的通信

图 11.7 – 模块间的通信

这只是一个小的例子。在实际应用中,将有更多的模块和更多的通信行。但重点是,我们需要验证所有传递来回复的数据都是有效的,并且在所有情况下都能正常工作。

在这种情况下,我们需要进行所谓的集成测试。这是当我们测试当不同的应用部分组合在一起时是否正常工作时。

这可以通过几种方式来完成。我们可以采取一种大爆炸的方法,这意味着我们等待所有模块都完成,然后将它们组合在一起并测试它们。这种方法的不利之处在于模块不会同时准备好。如果编写模块 A 的爱丽丝完成了她的工作,她需要鲍勃完成他的工作,然后她才能集成测试她的模块,因为她的工作需要调用鲍勃的工作。对鲍勃来说也是如此:他需要在卡尔完成他的工作之前集成他的模块到应用中。

有其他方法,我们不需要等待所有部分都完成才开始测试。相反,我们创建模拟模块,称为存根驱动器。存根是一个被另一个模块调用的模块,而驱动器是一个调用另一个模块的模块。我们可以创建这些模块来传递和接受数据,以便我们可以开始测试。它们不是完全功能的模块,但为了测试的目的,它们充当真实模块。随着模块的完成,它们可以替换这些存根和驱动器,然后可以在真实模块上进行测试。

有这些测试是必不可少的,部分原因是因为它验证了模块可以相互交互,但也为了未来。当我们后来添加新功能时,无论是通过更改现有模块还是添加新模块,我们都要确保之前工作的一切仍然有效。将新代码插入现有应用程序可能会产生不希望的结果,我们必须确保这种情况没有发生。

其他类型的测试

单元和集成测试并不是我们拥有的唯一类型的测试。根据我们正在创建的应用程序类型以及我们的软件在数据完整性、处理大量工作负载、与其他应用程序兼容性等方面的要求,我们可以选择公开代码以进行几种不同的测试类型。

让我们简要地看看这些测试。

系统测试

系统测试在系统完全集成到其执行环境时进行测试。在这里,我们将测试诸如登录是否工作,我们是否可以在应用程序中创建和编辑数据,用户界面是否正确显示数据,以及我们是否可以删除条目等问题。

这些事情通常在开发过程的早期就进行测试,但需要在源代码处于其生产环境时进行验证。

验收测试

验收测试通常分为四种不同类型:

  • 用户验收测试

  • 操作验收测试

  • 构建和监管验收测试

  • 阿尔法测试和贝塔测试

让我们详细了解一下:

  • 用户验收测试:这是一种验证我们应用程序创建的解决方案是否适用于最终用户的方式。我们试图通过这些测试回答的问题包括用户是否可以使用软件,它是否是他们所要求的,他们是否在使用过程中遇到任何麻烦,以及应用程序是否按预期运行。

  • 操作验收测试:这是在将应用程序发布给最终用户之前,验证应用程序操作准备就绪的。

  • 构建和监管验收测试:这是为了验证所开发的软件是否符合与订购软件的组织签订的协议中规定的条件。监管测试验证软件是否符合当前的规定。

  • alpha 和 beta 测试:这是两种测试,用于验证和识别所有可能的问题和错误。alpha 测试在开发过程的早期进行,而 beta 测试则在开发过程的后期进行。它们都是由最终产品的潜在用户或与应用最终用户具有相似技能水平的一组人执行的。

回归测试

回归测试专注于在重大代码更改后寻找缺陷,并试图揭示软件回归。软件回归是在更新后使应用中某些功能停止工作的错误。还有软件性能回归,其中软件仍然正确运行,但更新损害了系统的性能。

这些是我们可以在我们的软件上执行的一些其他测试。还有很多,但我们在这里讨论的这些是最典型的测试,你作为软件开发者可能会遇到。

当我们拥有经过测试的代码时,我们可以将其提供给应用的用户。在我们讨论如何将我们编写的代码提供给用户之前,我们应该停下来谈谈什么是发布。

软件发布

在创建软件时,我们不希望从一个关于最终项目的想法开始,然后着手完成这个应用需要做的所有事情,几年后最终发布完成的软件。我们不希望这样做的原因是在开发过程中,会发生几件事情;例如,这个应用的需求可能会改变,新的法律可能要求我们以不同的方式做事,竞争的软件解决方案可能已经发布,仅举几个例子。

相反,我们希望实现一些核心功能,将它们发布给用户,然后立即开始开发下一个版本,这个版本将包含更多功能。这可以表示如下:

图 11.8 – 通过持续迭代进行开发过程

图 11.8 – 通过持续迭代进行开发过程

我们从一个愿景或想法开始。然后,我们取这个想法的一些基本部分,并在所谓的迭代中实现它们。在迭代阶段,我们执行几个步骤,我们很快就会更详细地探讨这些步骤。在迭代结束时,我们有可以发布给用户的可工作代码。然后我们开始下一个迭代,实现更多将在迭代完成后发布的特性。

在迭代过程中采取的步骤将因开发方法的不同而有所不同,但通常,它将类似于以下内容:

图 11.9 – 单次迭代过程中的典型步骤

图 11.9 – 单次迭代过程中的典型步骤

在这里,我们可以看到我们将我们的想法和需求纳入开发周期。首先,我们将进行分析和设计步骤。在这里,我们试图回答两个问题:

  • 我们将做什么(分析)?

  • 我们该如何做(设计)?

当我们知道我们应该做什么以及如何做时,我们就可以开始实施构成这一迭代的各个部分了。

当我们完成一段代码后,它将进行单元测试,然后所有代码将进行集成测试。在代码发布给最终用户之前,也可能进行其他测试。发布代码被称为部署,这是我们很快将要更详细地探讨的步骤。

我们现在所完成的是一次发布,这意味着我们的软件现在有一些新功能或错误修复。

我们现在可以开始下一轮迭代。这是通过评估刚刚结束的迭代来完成的。这样做是为了我们可以微调我们的工作流程,看看我们是否在上一轮周期中发现了某些问题,或者任何其他我们需要在下一轮迭代中考虑的问题。

然后,我们可以指定下一轮迭代中要包含的内容,并使用它重复整个过程。

现在,让我们更详细地看看部署阶段,了解它是什么以及如何进行。

理解软件部署

当我们有代码要发布时,我们需要将其部署。部署是确保软件安装到正确位置的过程,确保它受到保护以防止任何黑客攻击,并确保软件被赋予必要的权限,以便它可以按需读取和写入文件。

当代码部署后,我们通常想再次测试它,以确保一切仍然按预期工作。

代码部署通常分为几个步骤。开发者通常会有一个在开发过程中使用的服务器。这被称为开发服务器,在开发过程中,代码可以在这个服务器上执行和测试。它通常会附带一个开发数据库,如果开发的应用程序使用数据库,则用于模拟应用程序处理的真实数据。尽管如此,它可以按照开发者想要的任何方式修改,因为它与用户看到和工作的真实数据是分离的。

在代码移动到生产服务器之前,即应用程序用户将使用它来运行应用程序的服务器,代码通常会被移动到一个中间服务器,通常被称为预发布服务器。这个服务器的角色是尽可能像生产服务器一样,以便新代码可以与已经发布的代码一起测试。目的是确保一切运行顺利,并且当新代码上线到生产服务器时不会出现任何错误。

预发布服务器也有一个数据库。这个数据库中的数据通常是从生产数据库复制过来的,以确保一切都是真实服务器的镜像。

最后,当团队确信一切正常时,代码将被移动到生产服务器。除了移动代码之外,可能还需要进行其他调整,例如向生产数据库添加新软件版本所需的元素,添加新代码使用的新应用程序和代码库等。这在此处展示:

图 11.10 – 服务器及其数据库的示例

图 11.10 – 服务器及其数据库的示例

我们还必须有一个策略,以便在需要时能够撤销这次部署。无论我们的测试多么彻底,当代码进入生产并开始被用户使用时,我们从未想象到的问题开始浮现。用户可能会以不可预测的方式行事,与我们的预演环境相比,生产环境可能存在我们忽视的差异。

我们总是希望一切顺利,但如果我们遇到问题,我们需要有一个计划。我们希望的是能够尽快撤销所有更改,回到部署之前的状态。这被称为回滚策略

我们可以通过几种方式实现这样的回滚。其中一种方法是在部署发生之前备份生产数据库。这样,我们可以确保在发布新代码之前,我们有应用程序中所有数据的快照。

实际代码通常在版本控制系统的帮助下部署到服务器上,因此将实际代码回滚到之前的版本相对简单。

将代码部署到生产环境涉及的步骤通常都是自动化的,这意味着不同的工具和应用程序负责所有步骤。让我们看看这种自动化是如何工作的。

部署自动化

尽可能地自动化尽可能多的部署步骤通常是一个好主意。原因是通常涉及许多步骤,需要按正确的顺序执行,这是一项非常适合自动化的工作。同时,这也是人类经常因为忘记做某事或以错误的顺序做某事而出错的地方。

根据系统的复杂性,我们可以使用多个工具来帮助我们实现自动化部署。

自动化部署将确保我们拥有高质量的代码,因为自动化过程通常会运行测试,并允许通过测试的代码进入生产服务器。

它还将节省时间,因为部署步骤将由部署工具以比人工执行更快的方式完成。

当代码部署后,与代码的工作并未结束。部署的代码必须得到维护。

代码维护

典型的软件开发者将花费更多的时间维护现有代码,而不是编写新的令人兴奋的功能。

如果我们仔细思考,这并不奇怪。首先,一个应用程序的寿命通常比开发它所花费的时间要长得多。这意味着越来越多的软件正在运行并执行它们的任务。

被使用的程序将始终需要维护,因为用户会发现我们必须修复的 bug,随着用户需求的变化,应用程序将添加新功能,而旧功能必须更新和改进。

这意味着开发者大部分时间都在处理旧代码,修复和更新它。这可能是由很久以前离开公司的某人编写的几十年前的代码,或者是由你上周编写的代码。

如果你提前思考你作为程序员的未来生活,你很可能会想象自己正在编写使用最新工具和功能的全新、令人兴奋的软件。然而,事实是,你更有可能正在探索很久以前编写的代码,那是在你上幼儿园时发布的语言版本。

有时候,你将有机会编写全新的、酷炫的、闪亮的软件,但记住,如果你做得好,这段代码将延续下去,并将需要由其他人(或你自己)在未来进行维护。

这就是软件开发者的生活。这意味着我们需要确保我们编写的代码尽可能可维护。一个熟练的软件开发者是编写高度可维护代码的人。这意味着代码清晰、易于理解,易于更改,而不会有人在不经意间在应用程序中引入一些不希望出现的副作用。

我们可以遵循一条简单的规则来帮助我们创建可维护的代码:童子军规则。

童子军规则的传说如下:

离开营地时,让它比你来时更干净。

我没有证据表明这条规则曾被实际的童子军使用过。更可能的是,这是童子军运动创始人罗伯特·巴登-鲍威尔在他去世前留给运动的信条的一个变体。在那封信中,他说:“尽力让这个世界比你所发现的时候更好。”

在《代码整洁之道》这本书中,作者罗伯特·C·马丁,也被称为 Uncle Bob,将这条规则转化为可以应用于代码的形式。他提出,如果我们在我们维护代码时应用这条规则,我们应该在每次维护时都让我们的代码变得更加整洁和更好,这样它的质量就会随着时间的推移而提高。我们可以将童子军规则与软件开发联系起来重新表述,使其说:“始终让你所编辑的代码比你所发现的时候更好。”

这些变化不必很大。你不必重写代码的大段内容。如果你将变量名改为更好地描述它所持有的值的名称,或者在一个代码片段中添加缺失的文档,这将略微提高代码的质量。

注意

第十二章 代码质量附录 A 如何将伪代码转换为真实代码 中,我们将深入了解我们如何编写高质量的代码,以及其他方面,同时考虑到可维护性。

现在我们已经了解了如何将软件交付给用户,当我们完成编写和测试后,我们应该更仔细地看看实际的开发过程。

软件开发过程方法论

自 1960 年代以来,已经开发出不同的方法论来帮助系统开发者更高效、更精准,并创建更高品质的代码。在这里,我们将探讨一些更基本的方法论——一些今天仍在使用的方法论,以及一些被更新、更灵活的方法论所取代的方法论。

瀑布开发

水晶球开发模型是几乎每个人都爱恨交加的一种。无论如何,我们都会看看它,因为许多新的方法论都是作为对其的反应而开发的。

它之所以受到厌恶,是因为它不会考虑需求的变化。

在瀑布模型中,几个定义的步骤被完成,一个接一个。以下图表中可以看到这些步骤的例子:

图 11.11 – 瀑布模型中的步骤

图 11.11 – 瀑布模型中的步骤

这就是这个模型的工作方式:

  1. 首先,我们收集这个应用程序所需的所有需求。

  2. 之后,我们进行系统设计,其中我们描述了不同的责任将如何在应用程序的不同部分之间分配。

  3. 然后,我们编写代码。

  4. 在验证阶段,代码被测试。

  5. 最后,当软件发布后,它进入维护阶段,在那里它被维护。

这个模型的主要批评是它不会捕捉到在开发阶段可能(并且经常是)出现的新的需求。从最初的想法到最终产品的过程可能相当长,在这段时间里,会有很多事情发生,将对这个应用产生影响。新的法律可能会出台,竞争性的应用程序可能会发布,这个应用程序所依赖的操作系统和其他软件的新版本可能会发布,等等。

如果我们有一个无法捕捉这些变化的发展模型,我们很可能会开发出一个在发布之前就已经过时的产品。

现在我们已经看过了一种不再使用的方法论,或者至少,没有人会说自己在使用它,我们可以看看一些正在使用的方法论,这些方法论是作为对瀑布模型的反应而创建的。

螺旋模型

在 1986 年,美国软件工程师巴里·博姆描述并描绘了一个模型,这个模型不是从一个阶段到下一个阶段,而是呈螺旋形。

这个想法随后得到了发展和修改,形成了几种新的方法。然而,自那时以来,通过螺旋推动开发过程的基本思想一直很流行。

螺旋模型中的一个关键概念是在开发软件时将风险作为一个关键概念来考虑。

在下面的图中,我们可以看到螺旋模型的简化版本:

图 11.12 – 螺旋模型的简化版本

图 11.12 – 螺旋模型的简化版本

在这里,我们有四个不同的阶段。我们不会按顺序一次性完成它们,而是会根据需要反复迭代,直到应用程序开发完成。让我们看看:

  1. 在第一阶段,我们查看目标,就像我们在开发过程中的这个阶段看到的那样。

  2. 接下来,我们来看风险。什么可能阻碍我们成功实施刚刚确定的目标?通过识别风险,我们更有可能避免它们,或者至少最小化它们对我们软件的影响。

  3. 当这一切都完成时,我们将继续开发和测试软件。

  4. 最后的阶段是审查阶段。在这里,我们回顾在这个迭代过程中所做的一切,包括哪些做得好,哪些有问题。我们可以从中学习,以便下一个迭代做得更好。

然后,我们将从确定风险、开发和审查目标开始,为下一个迭代重新开始。

不断增长的螺旋图说明了,对于每个迭代,软件的创建量都在增加。

即使螺旋模型是作为对瀑布模型不足的反应而创建的,这个说法表明问题不是瀑布模型本身,而是开发过程变得非常漫长,因此无法对需求的变化做出快速反应。

螺旋模型为几个新的方法提供了灵感,其中开发过程被划分为更小的迭代。下一个就是这样的一个例子,也是目前大多数软件项目运行的基础。

巴里·博姆还说过,这个模型只是一系列小型的瀑布模型。

敏捷开发

敏捷软件开发是指一组基于迭代开发的软件方法。

术语敏捷来自 2001 年在犹他州 Snowbird 度假村相遇的 17 位软件开发者。会议结束后,他们发布了敏捷软件开发宣言

宣言是对软件开发过程中应该优先考虑的内容的简要描述。

它可以在agilemanifesto.org/找到。

这个宣言通过十二条原则进行了更详细的概述,称为敏捷宣言背后的原则,并且可以在agilemanifesto.org/principles.html找到。

这些想法对软件行业产生了重大影响,并且根据宣言,开发了几种新的软件开发方法。

让我们来看看其中一些更受欢迎的。

敏捷 Scrum 方法

这种方法,更广为人知的是 Scrum,是一个轻量级的项目管理框架,它采用迭代和增量方法。

在 Scrum 中,产品负责人——一个有权决定哪些条目将进入应用程序的人——扮演着核心角色。这个人需要在整个开发过程中发挥积极作用。

产品负责人与开发团队紧密合作,创建一个优先级列表的系统功能,称为产品待办事项。产品待办事项包括为了成功交付一个工作软件系统所需完成的任何事情。待办事项中的条目可以是应用程序的功能、需要修复的 bug,以及非功能性需求,如认证、可访问性和数据完整性。

当待办事项中的属性被优先排序后,一个开发团队(如果需要,可能还有其他角色)将开始开发所谓的可能发货的增量

这意味着团队将从待办事项中选取一些最高优先级的项目,并在一个称为短跑的短时间内开始实施它们。短跑通常持续 14 到 30 天。

短跑的结果最好是完全功能性的,以便它可以立即投入生产,并且用户可以开始使用这个功能。

然后,团队将重新开始一个新的短跑。这需要重复进行,直到必要。

精益软件开发

这种敏捷方法与 Scrum 一样是迭代的,并专注于交付完全功能性的批次。该方法非常灵活,没有任何严格的规定或指南。

它的主要思想是消除所谓的浪费。这是通过让系统用户仅选择对系统宝贵的功能来实现的。然后,这些功能被优先排序,并以小批量交付。

它依赖于来自软件用户的快速和可靠的反馈。在精益开发中,工作是由客户请求拉动的。

极限编程(XP)

这种方法最初由肯特·贝克描述,他是一位美国软件工程师,将软件最佳实践推向了极致。其中一个例子是代码审查。标准做法是,在代码可以与发布版本中的代码合并之前,应由另一位开发者审查所有代码。在 XP 中,这是通过使用结对编程的概念来实现的。结对编程是指两位开发者使用一台计算机来编写代码。其中一位被称为驾驶员,是负责编写代码的人。另一位开发者被称为观察者导航者,将观察并审查驾驶员的行为。两人将频繁地交换角色。

与传统的代码审查过程相比,由于审查是在开发阶段进行的,这可以加快速度。结对编程的其他好处包括,驾驶员将始终从观察者那里获得关于如何解决当前任务的反馈。

XP 的目标是降低需求变更的成本。为此,XP 使用短的开发周期。

在 XP 中,需求变更是软件开发的一个自然、不可避免且可取的方面。

摘要

在本章中,我们探讨了软件开发中的一些更基本的概念,这些概念与实际的编码无关。即使我们不在大型、专业项目中工作,我们也应该对代码进行版本控制,编写测试以验证代码是否按预期工作,并迭代工作。

我们首先了解到,版本控制系统是一个伟大的工具,它不仅可以帮助我们回到代码的早期版本,还可以帮助我们与其他团队成员共享代码。

然后,我们看到为了验证我们编写的代码是否按预期工作,我们需要对其进行测试。在这种情况下,我们有一些称为单元测试和集成测试的内容,我们应该执行这些测试以确保应用程序产生正确的结果,并且新代码不会产生任何副作用,这会对在先前版本中成功工作的代码产生不良影响。

之后,我们看到软件发布生命周期定义了要执行哪些步骤才能使代码成熟到可以发布给最终用户。当代码准备好发布时,我们需要将其部署到环境中(例如,作为应用程序服务器),以便最终用户可以访问它。当代码被使用时,我们需要维护它。将发现错误,需要添加或更改功能,等等。

最后,我们了解到,为了处理开发过程,软件开发团队通常会使用一种开发方法。这种方法将描述事情应该按照什么顺序进行,团队如何合作以实现良好的结果,以及如何决定什么内容将包含在软件版本中。

在下一章中,我们将探讨如何编写高质量的代码,以及我们所说的代码质量是什么意思。

第三部分:编写高质量代码的最佳实践

在本节中,我们将讨论代码质量,它是什么,以及一些最佳实践来帮助我们编写高质量的代码。

本节包含以下章节:

  • 第十二章, 代码质量

第十二章:第十二章:代码质量

代码质量有许多方面。我们可以谈论高效的代码,这是运行速度快或不会浪费资源(如内存)的代码。它也可以是易于我们人类阅读和理解的源代码,因此也易于阅读和维护。在本章中,我们将讨论这一点,并查看编写高质量代码的一些最佳实践。

我们还将查看一些示例,如果我们想编写高质量的代码,我们应该尽量避免的事情。

在本章中,我们将学习以下主题:

  • 理解代码质量是什么

  • 编写可读的代码

  • 编写高效的代码

  • 理解智能代码并不总是智能

  • 理解编写高质量代码的一些最佳实践

在我们学习如何编写高质量的代码之前,我们应该定义代码质量是什么。

定义代码质量

当涉及到程序代码时,定义质量的含义是困难的。原因是所有开发者都会对它有自己的看法。一位开发者可能会争辩说,我们应该专注于编写可读的代码,因为它更容易理解和维护,从而减少我们向代码中插入任何错误的机会。另一位开发者可能会争辩说,我们应该专注于编写紧凑的代码;也就是说,尽可能少的代码行。即使代码难以阅读,更少的代码也会给我们更少的在代码中引入错误的机会。

在这里,两位开发者会为同样的事情——代码中的错误更少——而争论,但他们的立场是矛盾的。

让我们用一个小的例子来看看,我们使用 Python 作为我们的语言。我们想要创建一个列表,它包含通过掷两个骰子可以得到的所有可能的组合。

第一个将使用更多的代码,但更容易理解:

two_dice = []
for d1 in range(1, 7):
   for d2 in range(1, 7):
       two_dice.append((d1, d2))

在第一行,我们创建一个空列表。

然后,我们有一个用于第一个骰子的for循环。d1变量在第一次迭代时将获得值1,第二次迭代时获得值2,依此类推。记住,结束值7是它停止的时候,所以这是7,而不是6,因为它会在达到这个值时停止,给我们的是 1 到 6 的值。

然后,我们将对第二个骰子执行同样的循环。

在最后一行,我们将d1d2的值插入到列表中。在追加值时多加一个额外的括号,将它们放入所谓的d1d2属于一个组合。

我们可以用一行代码完成同样的事情。它看起来像这样:

two_dice = [(d1, d2) for d1 in range(1, 7) for d2 in range(1,     7)]

如我们所见,第二个例子代码更少,但牺牲了可读性。

但谁是对的——是主张可读性的开发者还是主张代码更少的开发者?我们无法说,因为他们都是对的。

我们需要的是一个更好的定义,即代码质量是什么,更重要的是,它应该是可衡量的。

已经做出了许多努力来定义一个衡量代码质量的模型,其中之一是 CISQ 的质量模型。我们将在下一节中看到它是什么。

CISQ 的质量模型

信息软件质量联盟CISQ)已经定义了五条规则,可以用来衡量代码的质量。它最初是针对商业软件而定义的,但后来扩展到也包括嵌入式系统,主要用于物联网IoT)应用程序。以下规则如下:

  • 可靠性:可靠性衡量风险水平和失败的可能性。它还将衡量在代码更新或修改时注入现有代码中的缺陷。衡量可靠性的目标是防止应用程序因严重错误而无法运行的时间。

  • 性能效率:当应用程序运行时,它执行操作的速度取决于代码的编写和结构。在代码级别上测量效率将有助于提高应用程序的整体响应时间以及我们识别需要高速处理数据的应用程序潜在风险的能力。

  • 安全性:安全规则将衡量由于不良编码实践而可能发生的潜在安全漏洞的可能性。

  • 可维护性:当我们谈论代码的可维护性时,我们通常指的是三个方面。我们说代码应该是,即,可适应的,这意味着代码可以按照需求进行适应性的改变;可移植的,这意味着代码可以在不同的平台上使用,例如不同的操作系统;以及可转移的,这意味着代码可以从一个开发团队转移到另一个团队。

这可以应用于,或多或少,所有代码,但我们希望尽可能少地付出努力来完成这三件事。

  • 规模:规模本身并不是一个质量属性,但代码的规模可能会影响其可维护性。我们拥有的代码越多,就越难导航、理解和遵循其逻辑。

我们现在已经讨论了关于代码的质量方面。但用户视角的质量又如何呢?

理解用户质量

CISQ 模型非常关注的是从用户的角度来看的质量。一个应用程序可以符合所有 CISQ 规则,但使用这个应用程序的用户仍然可能认为它的质量很差。

美国软件工程师汤姆·德马克博士提出,一个产品的质量是它改善世界的程度的一个函数

这句话可以解释为,一个应用程序的功能质量和用户满意度比代码的结构质量更重要。

美国计算机科学家杰拉尔德·温伯格曾经说过,质量是对于某些人的价值。这表明质量是主观的——一个人可能会将某个应用程序的质量定义为质量,而另一个人可能会认为它是相反的。这种观点将专注于提出以下问题:谁是我们想要评价我们软件的人?以及对他们来说什么是有价值的?

在这些定义的指导下,我们开始意识到,制作软件远不止是编写代码。即使代码质量很高,如果用户不喜欢我们创造的东西,他们也不会使用它。这就像如果我们用最好的工艺制作了一把椅子,但如果它非常不舒服,没有人会买它。

因此,我们必须了解我们的用户和他们的需求。这样做并不总是容易的,因为我们的潜在用户可能不知道这些需求。在你拥有第一台智能手机之前,你并没有觉得缺少它,因为你不知道它能提供什么。另一方面,如果你在几个小时后失去它,你可能会立刻觉得缺少它。

为了达到在我们理解用户需求之前他们就已经理解的需求,我们需要发挥想象力。我们可以从问一些简单的问题开始。它们可能是,这个应用程序将解决什么问题?谁会从中受益?受益于使用这个应用程序的人是否有共同的模式?这个群体已经使用了什么类型的应用程序?在这些应用程序中使用的功能、模式或想法,我们能否在我们的应用程序中重用,以便使这个群体一开始就对我们的应用程序的工作方式更加熟悉?

当我们有了关于我们未来用户可能是谁的想法时,我们需要关注应用程序内的流程。我们都知道,当我们使用一个程序或任何其他产品时,如果我们不知道该做什么,是多么令人沮丧。我们尝试了一件事又一件,很快,我们就失去了使用它的兴趣。

如果你投入时间和金钱去开发某样东西,你至少应该给你的这个伟大想法每一个成功的机会。

太好了!我们现在对代码质量有了概念,我们也从用户的角度理解了质量方面。我相信你肯定希望你的软件两者兼备,所以让我们把它们结合起来。

将它们结合起来

如果我们仔细思考,创造高质量软件的艺术当然既不是用高质量编写代码,也不是编写用户认为有价值的应用程序;它两者都是。

正如我们在前面的章节中看到的,被使用的应用程序将会被更新、修改和扩展。这意味着如果我们要找到需要更改的地方,代码需要被其他程序员(或我们自己)阅读。

所有的一切都将归结为一个关于金钱的问题。我们希望创建的软件能为我们的用户提供额外的价值,并且我们可以销售我们的应用程序。但也许更重要的是,维护应用程序代码的程序员可以高效地工作。如果他们能快速找到错误,他们将花费更少的时间来修复它。

如果代码易于阅读和理解,程序员也有更高的可能性避免在代码中插入新的错误,从而减少修复它们的成本。

许多程序员将面临的一个问题是,他们没有足够的时间来创建他们想要的、易于理解的优质代码。时间紧迫、不完全理解精心编写的代码重要性的经理,以及不耐烦的客户,所有这些因素都可能迫使程序员快速编写代码,从而导致质量下降。这当然是一种非常短期的做法。

你可能会更快地交付软件,但质量会降低,这对用户和未来需要维护代码的程序员来说都是如此。这很可能比一开始就编写高质量的代码成本效益更低。

应当注意的是,如果我们用一个编写得不好的项目开始,这个项目很可能始终会包含低质量的代码,因为回过头来改进所有代码的成本将太高。

如果我们做得好,编写高质量的代码,并交付用户认为高质量的软件,我们就有一切可以赢得胜利。

本章的其余部分将不会关注用户质量。这并不意味着它不是必要的,但这是一本关于编写代码的书,所以让我们看看我们如何以质量和风格来做到这一点。

考虑可读性编写代码

你所编写的代码不仅会被计算机执行。它还将被你自己和他人阅读。因此,编写尽可能易于阅读和理解的代码是至关重要的。

我们可以遵循一些简单的规则,以帮助实现可读的代码。

聪明地使用注释和文档

在编写代码时,你需要理解你做什么以及你为什么这样做。但是,当你几个月后回到你的代码时,这些想法并不总是那么清晰,你为什么以这种方式编写代码。对复杂的代码行进行注释是记录你的想法的好方法,既是为了你自己的未来,也是为了将来阅读你代码的其他人。

但是,注释也可能使代码的可读性降低。永远不要对明显的事情进行注释——任何程序员,包括你自己,都会理解的事情。

当你看到一行代码并理解到看到这一行的读者需要停下来思考才能理解它的作用时,你应该使用注释。

对函数和方法进行注释通常是个好主意。这些注释通常就在函数或方法之前,或者作为它里面的第一件事。你应该使用什么取决于你使用的语言,以及该语言程序员使用的约定。

在下面的屏幕截图中,我们可以看到一个 JavaScript 函数的例子:

图 12.1 – 记录一个 JavaScript 函数

图 12.1 – 记录一个 JavaScript 函数

以下是我们可以从前面的代码中推断出的内容:

  • 在此注释的第一行文本中,描述了此函数的整体职责。然后,使用预定义的 @param 名称,记录了两个参数的含义。

  • 在大括号内,定义了期望的数据类型。如果我们使用的语言是动态类型的,这一点尤为重要。动态类型的语言将接受我们分配给变量的任何类型,而不是只使用我们指定的类型。JavaScript 是动态类型的,因此这将帮助任何使用此函数的程序员。

  • 接下来是参数的名称(表格和标题)。

  • 然后,在破折号之后,我们将记录此参数的用途。

许多程序员使用的编辑器如果格式正确,都可以使用这种文档。我们在这里可以看到的格式被称为 JSDoc。

在下面的屏幕截图里,我们可以看到当我们编写将要调用此函数的代码时,编辑器可以显示在此注释中找到的信息:

图 12.2 – 显示函数文档数据的编程编辑器

图 12.2 – 显示函数文档数据的编程编辑器

注释并不是我们记录代码的唯一方式。我们也可以通过给事物起好名字,让代码部分自文档化。

使用名称作为文档

通过明智地命名变量和函数,名称本身就可以作为文档。看看以下函数:

function download_and_extract_links(url)
    page = download_page(url)
    links = extract_links(page)
    return links
end_function

这里,我们有一个将下载网页并提取该页面上找到的所有链接的函数。当我们调用此函数时,我们传递要从中提取链接的页面的地址。该地址存储在 url 参数中。

在内部,调用了名为 download_page 的函数。正如其名称清楚地描述了该函数的功能,在阅读代码时,我们不需要去那个函数了解它做什么。接收返回数据的变量被称为 page,因此我们了解它包含什么数据。

我们可以在下一行看到相同的内容。如果一个函数被命名为 extract_links,我们可以假设这就是该函数的功能。我们将获取的数据存储在一个名为 links 的变量中,所以我们的假设似乎是正确的。

当阅读此函数时,函数名称几乎就像一个目录。我们了解那里发生了什么,如果我们想了解,我们可以去那里,但不需要这样做只是为了了解它做什么。一本书的目录的想法是,你将了解章节的内容,并了解如何找到它。这里也是同样的道理。如果我们给函数起一个好名字,它们将让我们知道它们做什么。大多数集成开发环境都允许我们点击名称,这意味着如果我们想阅读它,我们会被带到那个函数。

在本章的后面部分,在限制函数/方法长度这一节中,我们将学习更多关于如何使用这项技术的知识。

要能够理解什么样的代码是好的,我们必须看到好的和坏的代码。因此,要成为一名优秀的程序员,我们必须阅读代码。

阅读他人的代码

作为一名初学者程序员,我们能做的最好的事情就是阅读经验丰富的开发者编写的代码。

一个好的来源是开源项目。经验丰富的程序员开发这些项目,他们的代码在网上对任何人都是可用的。

选择任何项目,最好是使用你正在使用的相同语言。一开始,接近这样的项目可能会感到不知所措,因为可能会有成百上千个文件分布在几个文件夹中。但请慢慢来,在这个文件结构中探索。也许最重要的事情不是理解项目的文件结构,而是看看代码并尝试理解其中的部分。

这将让你了解经验丰富的程序员是如何组织他们的代码的。需要注意的是,并不是所有的高级开发者都会总是做得完美,但大多数时候,你在这里看到的代码会被认为是相对高质量的。

如果你查看初学者编写的代码并与经验丰富的程序员编写的代码进行比较,你会看到差异。现在,参考以下代码:

图 12.3 – 由初学者程序员编写的程序

图 12.3 – 由初学者程序员编写的程序

看看前面的程序。它是用 C#编写的,会要求用户输入一个句子。然后它会计算用户输入的字符数(不计空格),并最终计算并打印句子中单词的平均字符数。

这段代码具有初学者程序员的许多特征。我教授编程已经有 30 年了,这绝对不是我所见过的最糟糕的例子。现在,参考以下代码:

图 12.4 – 前面展示的由经验丰富的程序员编写的相同程序

图 12.4 – 前面展示的由经验丰富的程序员编写的相同程序

现在,将本节开头提供的代码与前面截图中的代码进行比较,这是由经验丰富的程序员编写的完全相同的程序。使用这两个程序的用户将无法察觉任何差异。执行这两个程序将产生如下输出:

Enter your sentence
hi there people
There are 13 characters in total.
There are 4.333333333333333 characters on average in these words.

从用户的角度来看,我们可以这样说,这两个程序的质量是相同的。

但代码的质量并不完全相同。让我们列出一些差异:

  • 第一个版本——由初学者程序员编写的——没有使用任何缩进,这使得代码非常紧凑且难以阅读。

  • 第一个版本没有使用任何空白行,而另一个版本中的空白行将代码分成几个部分。

  • 在第一个版本中,newScen变量被赋值,但它从未被使用,因此可以从程序中删除。

  • 在第一个版本中,变量names并没有反映它们所存储的内容。在第二个版本中,将myScen变量重命名为sentence,将n重命名为word,将countedWords重命名为averageCharCount

  • 第一个版本使用了一个for循环来计算所有非空格字符。在第二个版本中,使用了一种特定语言的构造来在单行上完成相同的事情。

  • 第一个版本在main方法的开头声明了所有变量。在第二个版本中,它们是在首次使用时声明的。

  • 第一个版本使用了一些其他不必要的代码,例如第 16 行的Convert.ToString,以及一些注释并没有给代码的读者带来任何新的知识。

即使你不理解代码,仅仅看一眼就能发现第二个版本看起来要愉快得多。

此外,请注意,尽管第二个程序在代码中引入了空白行,但行数从 31 行减少到了 22 行。

作为一名初级程序员,你非常专注于让事情工作,你应该这样做。但是当你到达那里,你的程序正在运行时,你应该回头看看你的代码,并思考如何提高代码质量。也许你不会提出像资深程序员那样的单行解决方案,但至少你可以使用空白行、缩进和有意义的变量名。

要学会编写高质量的代码,你需要接触它,这就是为什么阅读资深开发者编写的代码将帮助你写出更好的代码。不要忘记,当你阅读时,尽量理解你正在阅读的代码。这可能是一个缓慢的过程,但并不像读书——你不需要阅读那里所有的代码。一个好的资源是 Stack Overflow 网站,程序员可以在这里提问,其他程序员会回答他们。访问stackoverflow.com/并四处看看。你可以过滤问题,这样你只会看到与你感兴趣的编程语言相关的问题。专注于答案,因为回答这些问题的人通常经验丰富,他们的代码通常质量很高。当然,你也可以使用这个网站来提问你自己的编程问题,谁知道呢——很快,你可能也会回答一些问题。

重新编写你的代码

正如我们在前面的例子中所看到的,仅仅让程序工作是不够的。当它运行时,我们应该回头看看我们刚刚编写的代码,看看我们是否可以重构它,使其更易于查看和阅读,也许还能为我们要解决的问题想出更好的解决方案。

解决编程任务的绝佳方式是首先提出一个可行的解决方案,然后当你有了它,就着手改进它,使其变得更好。这不仅会导致代码质量更高,你也会从中学习,下次你面对类似问题时,你将从一个更好的初始解决方案开始。

这就是为什么经验丰富的程序员不会从像图 12.3中展示的那样开始,而是从更接近图 12.4中展示的内容开始。

回到你编写的代码会使你以全新的视角看待它,你将看到你在第一次编写代码时没有看到的东西。

让你的代码经过几次迭代将带来多方面的好处。希望这能给你带来更高质量的代码。你也会更好地理解你的代码试图解决的问题,因为如果你在脑海中处理这个问题并努力寻找解决方案,你将获得对问题本身及其解决方式的更广泛和更深入的理解。

随着你需要了解更多关于你所使用的语言的知识,以便使用该语言提供的正确功能来解决这个问题,你的语言和编程技能也将得到提高。

即使是使用了一种语言多年的程序员也会发现一些他们以前不知道存在的事情。

随着经验的积累,你也会在解决你遇到的问题和编写的代码中识别出模式。当你这样做的时候,重写代码的过程将会更快。你不仅会更快地提出改进的想法,而且你的代码将从更高的起点开始。

在重写代码时,始终将可读性作为你的首要关注点。有时,你可能需要牺牲可读性来使代码更高效或更快,但如果这是你的主要目标,这将在你的代码中得到体现。

当你查看你的代码时,你应该始终问自己最基本的问题:如果别人写了这段代码,我会愿意阅读它吗?

如果答案是“不”,那么就修改它,以便你能回答“是”。

可读的代码是极好的,但代码也应该是高效的。

考虑效率编写代码

当我们谈论高效的代码时,我们可以意味着几件不同的事情。让我们看看人们在谈论高效代码时可能意味着的一些事情。

移除冗余或不必要的代码

你应该始终确保你移除冗余的代码。冗余的代码是指不影响应用程序输出的代码,但会被执行。

看看下面的代码:

number = 10
for i  = 1 to 1000
  number = number + i
end_for
number = 20
print number

在这里,我们创建了一个变量number,并将其设置为10

然后,我们有一个for循环。这个循环将迭代 999 次。第一次发生这种情况时,i变量将具有1的值;第二次,它将是2,以此类推,直到达到1000。然后,我们将退出循环。

每次我们进入循环时,我们将取变量number当前具有的任何值,将其与当前的i值相加,并将结果存储在number变量中。

在我们退出循环之后,我们将值20赋给变量number,通过这样做,我们将覆盖我们刚刚计算出的值。

这意味着在我们将20赋给number之前的所有操作都是不必要的。删除这些行不会对程序的输出有任何影响,但当我们运行应用程序时,这个不必要的循环将会运行,从而消耗一些资源并浪费时间。

这样的代码会使代码更难以阅读,因为我们将会花一些时间去弄清楚循环做了什么以及为什么会有这个循环。

移除不必要的代码后,我们现在可以看到如何更有效地使用计算机的硬件。

优化内存和处理器使用

很容易在不经意间浪费内存。根据你使用的语言,内存的处理方式会有所不同。

你的编程语言可能也有一些特性,它们会比你的第一个解决方案更有效地使用计算机硬件。让我们看看 Python 中的一个例子。

在这个例子中,我们将使用两种不同的技术来连接字符串。在第一个版本中,我们将使用+运算符来连接它们。我们将重复这个操作两百万次,并测量它所需的时间。参考以下代码:

s1 = "aaaabbbb"
s2 = "ccccdddd"
result = ""
for _ in range(2000000):
    result += s1 + s2

让我们看看这段代码是如何工作的:

  • 在前两行,我们创建了两个变量s1s2,它们分别保存我们要连接的两个字符串。

  • 在第三行,我们创建了一个名为result的变量,它最初是一个空字符串。

  • 然后,我们进入循环,这个循环将会迭代两百万次。for后面的下划线是因为我们不需要一个变量来保存当前的迭代值(第一次迭代时是 0,第二次迭代时是 1,依此类推)。

  • 每次我们进入循环时,我们会取当前result变量中的内容,并将其与s1s2变量的内容相加。

在第一次迭代之后,result将包含以下内容:

aaaabbbbccccdddd

在第二次迭代之后,它将包含以下内容:

aaaabbbbccccddddaaaabbbbccccdddd

在两百万次迭代之后,结果将是一个包含 3200 万个字符的字符串!

现在,让我们创建一个相同的应用程序,但使用另一种技术来连接字符串。这可能不容易理解,如果你不理解代码,请不要担心。

Python 有一个叫做字符串连接的方法。它被设计成以非常高效的方式连接字符串。程序的代码看起来像这样:

s1 = "aaaabbbb"
s2 = "ccccdddd"
result = "".join(s1 + s2 for _ in range(2000000)) 

这个程序也将迭代两百万次,将两个字符串连接起来,并生成一个包含 3200 万个字符的字符串。

我们编写的第一个程序在我的电脑上完成大约需要 42 秒。

在同一台机器上,第二个程序将在 0.34 秒内完成。

如此多次将两个字符串相加,当然不是我们经常做的事情,但这两个程序说明了选择一种解决方案而不是另一种解决方案的影响。

不仅是我们刚才看到的语言结构可以改善我们应用程序的性能,选择正确的算法也可以对速度和内存使用产生重大影响。

使用高效的算法

算法是解决问题的解决方案。算法将描述完成某事所需的逻辑步骤。让我们看看一个例子。如果我们有一系列数字,我们想要对这个序列进行排序,我们可以使用排序算法。我们有几种算法可供选择,并且所有算法都能完成任务;也就是说,对序列进行排序。

我们之所以有多个算法,是因为它们在速度和内存使用方面或多或少是有效的。编写实现算法的代码有多难也会有所不同。

让我们看看最容易实现的一种排序算法:冒泡排序。它也是最无效的算法之一,正如我们将看到的:

function bubbel_sort(sequence)
    do   
        swapped = false
        for i = 1 to length(sequence) – 2
            if sequence[i] > sequence[i+1] then
                swap(sequence[i], sequence[i+1])
		     swapped = true
            end_if
        end_for
    while swapped
    return sequence
end_function

看看代码,看看你是否理解它做了什么。我不会详细讲解它。相反,我们将一步一步地讲解冒泡排序算法。在我们这样做之后,你可以回到代码中,尝试弄清楚这里发生了什么。

我们将要处理的序列看起来是这样的:

sequence = [5, 3, 1, 8, 2]

让我们看看冒泡排序的逻辑:

  1. 在下面的图像中,你可以看到我们正在处理的序列的图形表示:图 12.5 – 要排序的序列

    图 12.5 – 要排序的序列

  2. 冒泡排序将从比较前两个值开始 – 在我们的例子中,是53,如下面的图像所示:图 12.6 – 比较前两个值

    图 12.6 – 比较前两个值

    如果它们不是按正确的顺序排列,它们将会被交换。由于它们顺序错误,3 将被移动到第一个位置,而5 将被移动到第二个位置,结果序列如下:

    图 12.7 – 5 和 3 交换位置

    图 12.7 – 5 和 3 交换位置

  3. 接下来,51 将会被比较,如果它们不是按顺序排列的,那么它们会被再次交换,如下面的图像所示:图 12.8 – 比较 5 和 1

    图 12.8 – 比较 5 和 1

    它们不在正确的顺序中,所以它们会被交换,如下所示:

    图 12.9 – 5 和 1 交换位置

    图 12.9 – 5 和 1 交换位置

  4. 现在,58 将会被比较,但由于它们是按正确顺序排列的,所以什么也没有做,如下面的图像所示:图 12.10 – 5 和 8 顺序正确

    图 12.10 – 5 和 8 顺序正确

  5. 然后,82 将会被比较,如下所示:

图 12.11 – 比较 8 和 2 的值

图 12.11 – 比较 8 和 2 的值

它们将按照顺序错误进行交换,如下所示:

图 12.12 – 8 和 2 的值交换位置

图 12.12 – 8 和 2 的值交换位置

我们现在已经到达序列的末尾,如您所见,它并没有排序。但有一个项目是排序好的,那就是值 8。由于这是序列中最大的值,它已经被推到末尾,并且通过这种方式,它已经到达了正确的位置。

这就是算法名称的由来,因为有一个值冒泡到了末尾。

在这一点上,算法将重新开始,比较前两个值,并在必要时进行交换。不过,这次最后一个值 – 在我们的例子中是 8 – 将不会参与比较,因为它已经找到了自己的位置。

第二轮之后,序列将看起来如下:

图 12.13 – 两轮后的序列

图 12.13 – 两轮后的序列

5 和 8 现在位于正确的位置(用较粗的边框标记),算法将重新开始。

在第三次运行中,将考虑值132,在那次运行之后,序列将看起来如下:

图 12.14 – 三轮后的序列

图 12.14 – 三轮后的序列

如我们所见,序列现在已排序,但算法将再次遍历剩余的值。它将发现可以不交换任何值就遍历它们,这意味着序列已排序,我们完成了。

冒泡排序效率低下的原因是它需要多次遍历序列。实际上,在最坏的情况下,它可能需要执行与项目数量一样多的遍历。对于如此短的序列,这并不是问题,但对于较长的序列,这将是明显的。

其他排序算法效率更高,但编写代码更困难。这些算法的例子包括快速排序和归并排序。我们在这里不会介绍它们的工作原理,因为它们相对复杂。如果您想了解更多关于这些算法的信息,可以进行网络搜索 – 您将找到许多有用的资源,它们将解释它们的工作原理,并为您提供任何编程语言中现成的代码。

如果我们比较冒泡排序和快速排序,我们将看到差异。在我的电脑上,冒泡排序在 9.8 秒内对 10,000 个值的序列进行了排序。快速排序设法在 0.03 秒内对相同的序列进行了排序。

快速排序和归并排序在大多数情况下表现更好,原因在于它们需要执行的操作更少。还应注意的是,如果序列一开始就是排序或几乎排序好的,冒泡排序可能会打败其他排序算法。如果我们有一个已排序的序列,冒泡排序只需遍历一次就能发现它是排序好的,然后停止。

这只是一个小的例子,但它说明了选择一个高效的算法对你的应用程序性能可能产生的影响。

我们有时会听到人们谈论聪明的代码。那是什么,使用它总是聪明的吗?让我们来看看!

聪明的代码就是聪明的吗?

当你是一个初学者程序员时,你会很高兴你的程序能正常运行,你不会太在意你的代码看起来如何或它的性能如何。重要的是你能在屏幕上得到你想要的结果。

但是随着你越来越有经验,学到越来越多,你将开始接受你可能认为的聪明解决方案。对你来说,一个聪明的解决方案可能就是你可以重写 10 行代码,使其现在只需要三行。

你始终应该问自己的问题是,对正在运行的代码所做的更改是否以任何方式改进了它。只有当它们这样做时,新的代码才会被认为比之前更聪明。

想象一下你用 Python 写了一个小游戏。它有一个循环,运行 10 次,每次迭代都会要求用户输入一个数字,要么是0要么是1。它也会随机选择一个01。如果用户猜对了计算机选择的数字,用户就赢了;否则,用户就输了。代码可能看起来如下:

图 12.15 – 一个简单的 Python 猜数字游戏

图 12.15 – 一个简单的 Python 猜数字游戏

这个程序没有检查用户输入除 0 或 1 以外的数字的错误检查器,但除此之外,它运行良好,你感到很高兴。

但然后你觉得是时候用更聪明的代码来编写这个游戏了,结果你得到了以下类似的东西:

图 12.16 – 与之前相同的程序,但只用一行编写

图 12.16 – 与之前相同的程序,但只用一行编写

当运行这两个程序时,你不会注意到任何区别。但第二个版本在某种程度上更聪明吗?它确实占用的行数更少,如果我们计算,字符也更少。但我们得到了什么?第二个程序会运行得更快吗?对于这类应用程序来说,这是一个有些不相关的问题,因为程序将花费大部分时间等待用户输入数字。

可读性如何?仅仅因为第二个程序有更少的行和字符,并不意味着它更容易阅读和理解——恰恰相反。即使是经验丰富的程序员,也需要比第一个程序更多的时间来理解第二个程序。

你创建类似第二个例子这样的东西只有一个原因,那就是作为一个练习来使用语言特性,但仅此而已。继续努力让你的小程序尽可能紧凑;你会从中学到很多东西,但当你编写将被用于其他目的的代码时,你应该考虑到可读性。

有时候,小而聪明的技巧确实处于正确的位置。看看以下函数:

function is_legal_age(age)
    if age >= 21 then
         return true
    else
         return false
    end_if
end_function

你可以向这个函数传递一个age,如果这个年龄等于或大于21,它将返回true;如果不等于,它将返回false

这个函数是有效的,但我们可以让它更聪明,这次的变化将是一个改进。如果我们考虑函数内部发生的事情,我们会看到if语句将比较传递给这个函数的age21。如果if语句为真,我们返回true。如果它是假的,我们返回false。这意味着我们返回与条件评估相同的东西,所以为什么不直接返回那个值呢?让我们改变这个函数:

function is_legal_age(age)
    return age >= 21
end_function

这是一个聪明的改变,因为我们使代码更加紧凑,更容易阅读,并去除了任何不必要的代码。

在编码时聪明意味着对不同的人有不同的含义。我曾经在一个用 C 语言编写的电信项目中工作。那里有一个错误,我被分配去修复它。但是当我阅读代码时,我感到非常震惊。我试图重新创建它的部分样子:

图 12.17 – 极难阅读的代码

图 12.17 – 极难阅读的代码

正如你所见,有三个嵌套的if语句。在真实例子中,至少有 20 层这样的嵌套if语句!

此外,变量名并没有说明它们所持有的数据。我们需要试图弄清楚这些变量将为我们提供什么值,以便进入最内层的if语句。

我确实花了将近两周的时间来理解和重写代码,然后我才能开始寻找错误。

现在,你可能想知道为什么我要在这里展示这个例子。这几乎不能被认为是聪明的代码。写这个代码的顾问可能认为这是聪明的。也许想法是变得不可替代,从顾问的角度来看这可能很聪明。但从拥有代码的公司角度来看,这并不聪明。我还可以提到,如果这是顾问方面的策略,它并没有奏效,因为顾问或顾问已经不再在这个公司了。

无论你做什么,永远不要编写这样的代码。相反,你应该记住一些编写高质量代码的最佳实践。

代码质量 – 最佳实践

如本章前面所述,我们关注的是代码的质量,而不是我们应用的用户体验的质量。

在编写代码时,有一些事情我们可以记住,以使我们的代码在质量上更

我们将探讨一些最佳实践,并讨论为什么使用它们是个好主意。

限制行长度

长行从来都不是一个好主意。看看任何报纸,想想为什么文本几乎不会在整页宽度上单行运行:

图 12.18 – 一份报纸使用栏来限制行长度。照片由 Wan Chen 在 Unsplash 上拍摄

图 12.18 – 一份报纸使用栏来限制行长度。照片由 Wan Chen 在 Unsplash 上拍摄

一个经验法则是,如果一行比屏幕能显示的宽度还要宽,那么它就太宽了。使用你的常识,如果需要,将代码分成几行,但要以一种有意义的方式进行。

看看下面的截图。这里显示的代码只是一条单独的语句,本可以写在一行上,但那行会非常长且难以阅读。相反,它已经被拆分成单独的行,并且换行符出现在自然的位置,使得代码更容易阅读:

图 12.19 – 被拆分成多行的长语句

图 12.19 – 被拆分成多行的长语句

一些编程编辑器会通过显示一条线来帮助你确定代码行的最大长度,这条线会在需要添加换行符时出现。

不仅应该限制代码行的长度。接下来,我们将看到这也适用于函数和方法的长度的限制。

限制函数/方法长度

函数或方法应该只做一件事,并且它们应该是小的。如果你的函数有几百行长,你需要将它们变小。当它们有 50 行长时,你可能需要将它们做得更小。

长函数难以阅读,而且很难跟随逻辑在if语句和循环中进进出出。

函数应该有多长并没有固定的规则,但只要可能,我会尽量让我的函数保持在 20 行以下。

重要的是实际的行数并不是关键。相反,你应该专注于编写易于阅读的代码。为了帮助你编写函数和方法,你应该让逻辑引导你。

如果你有一个长函数,仅通过看它,可能就有线索表明它由多个逻辑块组成。可以作为这种指示的可能是代码中的空白行,因为它们通常被用来表示某种逻辑过渡。这些确定的代码段可以是很好的候选者,可以从原始函数中提取出来,并放入一个自己的函数中。然后我们只需从代码之前的位置调用该函数即可。

另一个提示是超过一个级别的缩进代码。每个缩进级别标记一个代码块。看看那里的逻辑,并问问自己你是否可以通过将这些块转换为函数来使代码更干净。

编写高质量的小函数是一种需要练习才能掌握的艺术,但如果你不练习,你就永远无法掌握它。养成总是回顾你所写代码的习惯,并问问自己你刚刚编写的函数或方法是否只做了一件事。

但什么是一件事?马里奥赛车游戏是一件事吗?请求用户输入他们的信用卡号码是一件事吗?增加一个数字是一件事吗?

如果一个函数只有一个任务,那么我们可能能够将这个任务分解成几个子任务。然后我们可以让主任务成为一个函数,并让这个函数为每个子任务调用一个函数。

查看下面的截图。在这里,我们可以看到一个用 C++ 编写的函数。它的任务是搜索文件中的指定字符串模式并将其替换为新模式。它不是执行所有必要的步骤,而是将其分解成子任务,每个子任务都位于一个单独的函数中:

图 12.20 – 使用其他函数完成任务的 C++ 函数示例

图 12.20 – 使用其他函数完成任务的 C++ 函数示例

从前面的代码中,我们可以观察到以下内容:

  1. 首先,我们将调用一个函数,该函数读取指定文件的全部内容,并将其作为字符串返回给我们。

  2. 接下来,我们将调用一个函数,该函数将调用一个名为 update_content 的函数。为此,我们传递了文件的原内容、我们想要替换的字符串以及我们想要用新字符串替换旧字符串的字符串。

    这个函数将返回文件内容的更新版本。然后,这个更新内容将被保存到与原始文件同名的文件中,从而覆盖旧文件。

如函数名称所描述的那样,函数执行了什么操作,变量名称描述了从这些函数返回了什么,现在阅读这段代码变得非常容易。

阅读这个函数几乎就像阅读一本书的目录。我们可以看到,首先,我们读取了一个文件的内容。很好 – 如果我们相信那个函数只执行完全相同的事情,我们就没有必要去查看那段代码。对于 update_contentsave_file_content 也是如此。

这比如果我们将所有内容都放在一个函数中要容易阅读得多。再次查看前面的截图,并与以下截图进行比较:

图 12.21 – 原始的 update_file 函数

图 12.21 – 原始的 update_file 函数

当我们看到这两个函数版本时,很容易理解为什么我们想要缩短函数的长度,并让函数和变量名称来做文档。

如果我们查看前面截图中的代码结构,我们可以看到两个空白行。它们将代码分成三个部分,这些部分就是我们移动到三个函数中的部分。第一部分变成了 read_file_content 函数。第二部分变成了 update_content,而最后一部分变成了 save_file_content

当我们将代码部分移动到单独的函数中时,我们需要稍微修改一下代码,但这通常并不困难,而且完成得相当快。主要逻辑已经在那里了。

所有四个函数的最终版本将看起来像以下这样:

图 12.22 – 主要函数及其三个辅助函数

图 12.22 – 主要函数及其三个辅助函数

如果我们愿意,我们可以忽略前三个函数,只阅读最后一个函数,以了解这里发生了什么。

我们还应该考虑避免在控制结构(如条件语句和循环语句)中深层嵌套。

避免深层嵌套

有时候,人们会倾向于在函数内部嵌套多个if语句或for循环。但多层嵌套的if语句或for循环会使它们难以阅读和理解。

看看下面的代码:

function calculate_pay(age)
    if age > 65 then
        result = 25000
    else
        if age > 20 then
            result = 20000
        else
            result = 15000
        end_if
    end_if
    return result
end_function

在这里,我们有一个if语句,在其else部分,我们有一个带有else部分的新if语句。这是不必要的复杂,难以理解。我们可以这样重写它:

function calculate_pay(age)
    if age > 65 then
        return 25000
    if age > 20 then
        return 20000
    return 15000
end_function

这两个函数将给出相同的结果,但第二个函数将在知道正确数量后立即返回。通过这样做,它减少了行数,避免了嵌套的if语句,并使代码更简洁、更容易阅读。

当你有多层嵌套的结构时,你应该问自己是否有更好的方法来做这件事。你可以做的第一件事就是在这里我们所做的,将控制结构依次放置,而不是嵌套在一起。

另一个选项是将逻辑移动到单独的函数中,并调用它们。在某些情况下,这将简化代码并使其更容易阅读。

我们还希望避免重复。为了帮助我们避免这种情况,我们可以使用 DRY 原则。

使用 DRY 原则

DRY代表不要重复自己,由 Andy Hunt 和 Dave Thomas 提出。它陈述如下:

"系统内每条知识都必须有一个单一、明确、权威的表示。"

这表示我们不应该重复相同的或类似的代码超过一次。当你将代码复制粘贴到程序中的新位置,可能只有一些细微的变化时,这是一个明显的迹象表明你即将违反这一原则。这应该始终避免。

DRY 的原则可以归结为将代码分成小块可重用的部分。让我们看看一个例子。

假设你有一些数据,并且这些数据存储在字典类型中(你可以在第六章处理数据 – 变量字典类型部分中了解更多关于字典类型的信息)。

有时,你可能想将此数据打印到屏幕上,而有时,你可能想将其保存到文件中。你可能会得到如下所示的两个函数:

function show(data)
    print data["name"]
    print data["price"]
    print data["weight"]
    print data["height"]
    print data["width"]
end_function
function save(data)
    data_to_save = data["name"] + "\n" +
                   data["price"] + "\n" +
                   data["weight"] + "\n" +
                   data["height"] + "\n" +
                   data["width"] + "\n"
    save_file("data.txt", data)
end_function

第一个函数,show,将在屏幕上打印字典中的项目。

第二个函数,save,将构建一个包含字典中所有项目的字符串。它在每个项目之间添加一个换行符。\n表示换行符,用于表示在此位置应该发生换行。

这两个函数处理相同的数据,但在这里我们可能不会立即意识到我们在重复自己——至少直到我们需要更新字典的时候。假设我们需要向字典中添加一个条目,比如制造商。我们需要对两个函数都进行修改。新的代码将看起来像这样:

function show(data)
    print data["name"]
    print data["manufacturer"]
    print data["price"]
    print data["weight"]
    print data["height"]
    print data["width"]
end_function
function save(data)
    data_to_save = data["name"] + "\n" +
                   data["manufacturer"] + "\n" +
                   data["price"] + "\n" +
                   data["weight"] + "\n" +
                   data["height"] + "\n" +
                   data["width"] + "\n"
    save_file("data.txt", data)
end_function

如您所见,制造商的行被添加到了两个函数中。如果我们忘记将其添加到其中一个函数中会发生什么?在某个时候,我们会发现差异,但当我们发现时,我们无法确定是哪个函数添加了manufacturer行,还是从另一个函数中删除了它。

让我们将 DRY 原则应用到代码中。如果我们思考一下正在发生的事情,我们会发现show函数在print中有多个行。我们也知道print会在一行上打印一些内容,然后插入换行符,以便下一个print从新的一行开始。

但如果我们有一个像save函数中那样的打印字符串的print会发生什么?它包含所有我们想要换行的地方的新行指示符\n,所以打印这个字符串将给出与当前show函数相同的结果。

我们可以利用这一点添加一个创建并返回该字符串的函数,然后两个函数都可以调用该函数,如下面的代码所示:

function create_string(data)
    return data["name"] + "\n" +
           data["manufacturer"] + "\n" +
           data["price"] + "\n" +
           data["weight"] + "\n" +
           data["height"] + "\n" +
           data["width"] + "\n"
end_function
function show(data)
    print create_string(data)
end_function
function save(data)
    save_file("data.txt", create_string(data))
end_function

这里,我们有create_data函数,它创建字符串并将其返回给调用者。它被showsave函数调用。在show中,首先调用create_string函数,然后返回的字符串将被打印出来。

save函数中,我们在调用名为save_file的函数时,从内部调用了create_string函数。返回的字符串将被作为save_file函数的第二个参数传递,就像在我们的第一个版本中一样。

这也使得代码更易于阅读和更简洁。

许多语言或软件公司都“丑化”了我们编写代码的方式。这被称为“代码约定”,这是我们接下来要探讨的内容。

使用代码约定

大多数编程语言都有代码约定。这些是我们组织文件、缩进代码、格式化注释和使用命名约定的建议,仅举几例。

这些不是规则,而是一种推荐的代码风格,其理念是,如果所有使用该语言的程序员都使用相同的代码约定,他们的代码看起来将大致相同。这意味着,如果您知道这个约定,您将更容易导航代码并阅读它。因此,学习您所使用的每种编程语言的约定是至关重要的。

我们将探讨一些典型的约定,并看看它们在几种语言中的差异。

命名规范

命名规范是一组规则,用于格式化源代码中变量、类型、函数和其他实体的名称。

有时,编程语言会附带官方的命名约定。有时,它们不是官方的,但被使用该语言的社区广泛采用。一些公司为该公司编写的代码开发了他们自己的命名约定。

命名约定不关乎你如何命名事物,而是关乎你如何格式化名称。命名约定规定了如何使用大写和小写字符,以及多词名称应该如何格式化。一些不同的风格经常被使用。让我们看看其中的一些。

驼峰命名法

在驼峰命名法中,多词名称中的每个单词的首字母都大写,除了第一个字母。例如,如果我们想存储外部温度的值,该变量的名称在驼峰命名法中将是 outsideTemperature

驼峰命名法也被称为驼峰式。这个名字指的是大写字母形成的明显驼峰。这种风格的早期使用之一是由瑞典化学家 Jöns Jacob Berzelius,他在 1813 年的《哲学年鉴》第 2 卷中提出,化学元素应该用单字母或双字母符号表示,并且首字母大写。这样就可以写出没有空格的公式,如 NaCl

Pascal 大写命名法

Pascal 大写命名法类似于驼峰命名法,唯一的区别在于第一个字母也使用大写字母。因此,Pascal 大写命名法的外部温度变量会被命名为 OutsideTemperature

Pascal 大写命名法得名于编程语言 Pascal。尽管 Pascal 不区分大小写,但这种风格是通过 Pascal 规范流行起来的。

蛇形命名法

在蛇形命名法中,所有字母都小写,单词之间用下划线分隔。外部温度变量在蛇形命名法中写作 outside_temperature

Snake 大写命名法已经使用很长时间了,但没有一个确立的名字。对 snake_case 这个名字的早期提及来自 Gavin Kistner,他在 2004 年在 Usenet 的 comp.lang.ruby 群组中发表了一篇名为 Appropriate use of CamelCase 的帖子。在其中,他说了以下内容:

"顺便问一下...你叫那种命名法是什么?snake_case?我会这样称呼它,直到有人纠正我。"

缩进约定

在缩进和复合语句表示方面,有几种不同的风格被使用。

对于使用花括号的编程语言,花括子的放置方式是一个永无止境的争论。让我们看看一些变体。

K&R 风格

K&R 风格起源于 1978 年的 Kernighan 和 Ritchie 所著的《C 程序设计语言》一书。

遵循此风格时,每个函数的起始花括号都在新的一行,并且与函数标题具有相同的缩进级别。

函数内的块将与其打开语句在同一行上放置起始花括号。

以下截图展示了 K&R 风格的一个示例:

图 12.23 – K&R 花括子风格

图 12.23 – K&R 括号风格

1TBS

1TBSone true brace style的缩写,是 K&R 风格的一个变种。唯一的区别是函数的开括号位于函数标题所在的同一行。此外,在 1TBS 中,只包含一行控制结构的括号总是存在的,而在 K&R 风格中并不总是这样使用。以下屏幕截图展示了这种风格的一个示例:

图 12.24 – 1TBS 括号风格

图 12.24 – 1TBS 括号风格

Java

在 Java 中,一种常见的做法是使用 K&R 风格,扩展到所有开括号都位于它打开的语句所在的同一行。这适用于控制结构、类和方法。

以下屏幕截图展示了这种风格的一个示例:

图 12.25 – Java 括号风格

图 12.25 – Java 括号风格

Allman 风格

Allman 风格,以美国程序员埃里克·奥尔曼的名字命名,将所有开括号放在新的一行上。

支持这种风格的人意味着,当开括号和闭括号处于相同的缩进级别时,更容易看到代码块的开始和结束位置。

以下屏幕截图展示了这种风格的一个示例:

图 12.26 – Allman 括号风格

图 12.26 – Allman 括号风格

Lisp 或 Python 风格

这种风格可以用于任何使用括号的编程语言,但主要被那些不使用括号而使用缩进级别来标识代码块的语言使用,例如 Lisp 和 Python。在下面的屏幕截图中,我们可以看到一个使用这种风格的 Lisp 程序:

图 12.27 – Lisp 块缩进风格

图 12.27 – Lisp 块缩进风格

在下面的屏幕截图中,我们可以看到用 Python 编写的相同程序,使用相同类型的缩进来标记代码块:

图 12.28 – Python 块缩进风格

图 12.28 – Python 块缩进风格

在 Python 中,缩进级别是语言的一部分。这有时被称为偏移规则,这个名字是由英国计算机科学家彼得·J·兰丁提出的,很可能是对足球中的偏移规则的戏仿,与使用括号的编程语言相比,这意味着缩进级别不是由语言决定的。

这些并不是在约定文档中描述的唯一约定。接下来,我们将看看在阅读它们时你很可能会发现的其他一些内容。

其他约定

对于一种语言,可能会有其他约定被描述。在约定文档中经常描述的一件事是如何格式化注释。

一些编程语言有工具可以从代码中的注释生成文档。为了使这成为可能,注释必须遵循严格的格式。这些示例包括 Java 的 Javadoc 和 C 和 C++的 Doxygen。

一些语言还支持一种称为docstrings的特殊注释类型。它们是注释,但当编译器从可执行代码中移除常规注释时,它们会被保留,以便程序员在程序运行时检查它们。

还可能有关于如何在文件夹和包中组织源代码文件的约定(要了解更多关于包的信息,请参阅第四章软件项目和我们的代码组织方式,在使用包共享代码部分)。还可能有关于如何命名文件的约定。

代码约定也可以规定我们在缩进代码时使用制表符还是空格。在这里,大多数约定更倾向于使用空格而不是制表符,但也有例外。

你还可能在编码约定文档中找到与编码和编码风格不直接相关的内容。例如,你可能会发现有关源文件编码的建议。文件编码决定了文件中的字符将被如何解释。最常用的两种编码是 ASCII 和 UTF-8。有时,会有关于文件一部分的建议。约定文档可能声明注释或字符串字面量必须使用特定的编码,而不是整个文件。

编码约定文档中经常找到的另一件事是如何使用空白行和空格。例如,在官方 Python 风格指南 Pep 8 中,指出应该在类中的函数和方法之间用一个空白行分隔,并且函数或方法内的代码行之间不应超过一个空白行。

关于如何编写表达式的建议也可能存在。再次强调,Python 风格指南指出,这些行使用推荐的风格:

i = i + 1
submitted += 1
x = x*2 - 1
hypot2 = x*x + y*y
c = (a+b) * (a-b)

将其与以下不遵循推荐风格的行进行比较:

i=i+1
submitted +=1
x = x * 2 - 1
hypot2 = x * x + y * y
c = (a + b) * (a - b)

我们在这里将要讨论的最后一个约定,也可以在代码约定中找到,是关于如何将长行拆分成多行。

例如,当处理包含二元运算符(如 +, -, *, 和 /)的长行时,我们可以将这些运算符分开,但如果运算符出现在一行的最后一个字符或下一行的第一个字符上,会发生什么呢?

看看这个例子:

full_name = title +
            first_name +
            middle_name +
            last_name

现在,将其与这个变体进行比较,其中运算符被移动到下一行:

full_name = title 
            + first_name 
            + middle_name 
            + last_name

美国计算机科学家唐纳德·克努特在他的 1984 年出版的《TeXbook》一书中指出:

"尽管段落内的公式总是在二元运算和关系之后断行,但显示的公式总是在二元运算和关系之前断行。"

这意味着当一个公式被打印出来时,它总是以第二个例子中所示的形式打印。但这并不是一个普遍的真理。如果你阅读数学文本,你会发现两种形式都有表示,有时甚至还有第三种形式,其中运算符既在行尾又在下一行的开头。

但有时,Knuth 的这个论点被用来推荐运算符开始一行而不是在行尾的形式。

不同的约定是大多数程序员都有看法的一个话题。尽管如此,如果有一个适用于你使用的语言或你正在工作的项目,你应该坚持它,即使它与你认为的编写代码的好方法相矛盾。

摘要

在本章中,你已经完成了这段编程之旅,现在你知道了编写高效、易于阅读和维护的高质量代码需要哪些要素。

我们讨论了这样一个事实,即当涉及到软件质量时,我们有两个方面——一个是代码的质量,另一个是从用户的角度来看的质量。

我们随后将注意力转向如何实现代码质量。首先,我们讨论了如何编写可读的代码以及这将如何提高代码的整体质量。

之后,我们探讨了高效代码,即高效使用计算机资源的代码,将如何提高我们代码的质量。

如果我们重视代码质量,智能编码技巧并不总是编写代码的明智方式。我们看到了一些我们应该避免的事情的例子。

最后,我们探讨了我们可以使用的最佳实践,以提升我们编写的代码质量。

有了这些,我们已经到达了本书主要章节的结尾。我希望你和我一样享受这段旅程。我试图从初学者的角度整理出所有相关和重要的事项,我相信你们中的许多人都会从中受益。

第十三章:附录 A:如何将伪代码转换为真实代码

本书中的代码示例大部分都是使用伪代码编写的,因为本书的目的是让你了解什么是编程,而不是专注于任何特定的语言。

要能够编写代码,你需要使用一种真正的语言,在这里我们将探讨一些更流行的语言,并看看这本书中使用的代码如何翻译成这些语言。

我们将查看的语言如下:

  • C++

  • C#

  • Java

  • JavaScript

  • PHP

  • Python

对于每种语言,我们将从一个简短的介绍开始。

你不能仅仅从这些简短的示例中开始编写你自己的程序,但你将感受到这些语言,也许以这种方式看到它们将帮助你决定你想先学习哪种语言。

在我们查看不同的语言之前,我们将有几个伪代码示例。然后,这些示例将被翻译成前面的六种语言。所以,让我们开始吧!

伪代码示例

在本节中,我们将查看一些伪代码的代码示例。

伪代码中的 Hello World

第一个示例将是一个简短的程序,它只是将Hello, World!打印到屏幕上。

在我们的伪代码中,它将看起来像这样:

print "Hello, World!"

伪代码中的变量声明

在这个例子中,我们将创建几个变量。第一个将存储一个整数。第二个将存储第一个变量的值,但将其转换为字符串:

my_int_value = 10
my_string_value = string(my_int_value)

伪代码中的 for 循环

在这个例子中,我们将有一个for循环,它迭代 10 次并打印值09

for i = 0 to 10
    print i
end_for

伪代码中的函数

在这个例子中,我们将创建一个小函数,该函数将接受三个整数作为参数。然后,该函数应该返回它们中的最大值。我们还将调用该函数并显示结果。

在函数中,我们首先检查第一个参数是否大于另外两个参数。如果是,我们就找到了最大值,并返回它。

由于我们一旦找到最大值就立即返回,因此在这个程序中我们不需要使用任何else语句,因为返回会立即退出函数。

因此,我们只需要将第二个参数与第三个参数进行比较。如果第二个参数大于第三个参数,我们就返回它;否则,我们将返回第三个参数,因为它必须是最大的值。这可以通过以下代码展示:

function max_of_three(first, second, third)
   if first > second and first > third then
        return first
    end_if
    if second > third then
         return second
    end_if
    return third
end_function
maximum = max_of_three(34, 56, 14)
print maximum

while 循环、伪代码中的用户输入、if 语句和 for 循环

在这个例子中,我们将同时说明几个概念。

此程序将要求用户输入数字,数量不限。他们可以通过输入一个负数来停止输入新值。所有值(除了最后的负数)都将存储在一个动态数组中。

在程序退出之前,我们将使用以下代码块打印出我们存储的所有值:

values = [] 
inputValue = 0 
while inputValue >= 0
    print "Enter a number: "
    input inputValue 
    if inputValue >= 0
        values.add(inputValue)
    end_if
end_while

从前面的代码中,我们可以看到:

  1. 首先,我们创建一个动态数组。记住,这是一个在程序执行期间可以添加和删除值的列表;也就是说,它不是一个固定大小的数组,我们需要定义要存储其中的项目数量:

  2. 然后,我们将进入一个while循环,并在其中要求用户输入一个数字。

  3. 我们将把输入的数字添加到动态数组中,并且会一直这样做,直到用户输入一个负数。这个负数不应该添加到数组中,而应该作为用户完成输入数字的指示,这样我们就可以退出循环。

C++

C++是由丹麦计算机科学家 Bjarne Stroustrup 开发的,他最初将其称为 C with Classes。这项工作始于 1979 年,他希望创建一种语言,它具有 C 编程语言的力量以及他在为博士论文编程时接触到的面向对象特性。

1982 年,他将语言重命名为 C++,其中两个加号运算符是对 C 中的++运算符的引用,该运算符将变量增加一。这种想法是 C++是 C 加上一个特性,而这个特性就是面向对象。

该语言的第一版商业发布是在 1985 年。

C++是一种通用编译型编程语言,常用于需要高执行速度的情况,程序员可以控制数据在计算机内存中的存储和管理。

下面是一些关于它的快速事实:

  • 名称:C++

  • 设计者:Bjarne Stroustrup

  • 首次公开发布:1985

  • 范式:多范式、过程式、函数式、面向对象、泛型

  • 类型:静态

  • .cpp, .h

C++中的“Hello world”

所有用 C++编写的应用程序都需要有一个名为main的函数,该函数将作为程序执行的开始点。

输出是通过使用所谓的输出流显示到控制台窗口的。该语言提供了一个来自ostream类的现成对象用于此目的,称为cout。该语言还提供了一个函数(这种类型的函数在 C++中被称为操纵函数),称为endl,它将在输出流中添加一个换行符。数据是通过使用<<运算符发送到输出流的。

coutendl前面的std::部分表示这两个是在语言的标准命名空间中定义的。

由于 C++中的main函数应该返回一个整数值,表示执行的结果,所以我们返回0,这是表示成功的值。

注意,C++中所有非复合语句都以分号结尾,如下所示:

#include <iostream>
int main()
{
    std::cout << "Hello, World!" << std::endl;
    return 0;
}

C++中的变量声明

由于 C++是一种静态类型语言,我们必须指定变量可以使用的数据类型。之后,这将是这个变量唯一可以处理的数据类型。

C++中的字符串是在一个类中定义的,为了能够使用这个类,我们必须包含string,就像我们在第一行所做的那样。

main函数内部,我们首先声明我们的整数变量。我们指定类型为整数,使用int

然后,我们希望将我们的整数转换为字符串。我们可以通过一个名为to_string的函数来完成这个任务。它定义在标准命名空间中,并且必须用std::前缀。

当声明string变量的类型时,我们必须同时声明string类位于标准命名空间中:

#include <string>
int main()
{
    int my_int_value = 10;
    std::string my_string_value = std::to_string(my_int_value);
    return 0;
}

如果我们想简化这个程序并让编译器确定变量的类型,我们可以这样做。auto关键字将帮助我们完成这个任务。由于我们在创建变量时为其赋值,所以它们的类型将与我们分配给它们的数据相同。请参考以下代码:

#include <string>
int main()
{
    auto my_int_value = 10;
    auto my_string_value = std::to_string(my_int_value);
    return 0;
}

C++中的for循环

C++使用 C 风格的for循环。它有三个部分,由分号分隔,如下所示:

#include <iostream>
int main()
{
    for(int i = 0; i < 10; i++) {
        std::cout << i << std::endl;
    }
}

从前面的代码中,我们可以看到以下内容:

  • 第一部分将初始化循环变量为其起始值;在我们的例子中,那将是0

  • 下一节将告诉我们for循环将运行多长时间的条件;在我们的例子中,这意味着只要变量小于 10。

  • 最后的部分是变量在每次迭代中如何变化。我们在这里使用++运算符,以便变量每次迭代增加一。

在循环内部,我们将打印循环变量的值。

C++中的函数

C++中的函数必须首先声明其返回类型——也就是说,函数返回什么数据类型。我们还必须指定每个参数的类型。在我们的例子中,我们将传递三个整数,因为函数将返回其中的一个,所以返回类型也将是整数。

注意,在 C++中,&&符号表示and

#include <iostream>
int max_of_three(int first, int second, int third) 
{
    if (first > second && first > third) {
        return first;
    }
    if (second > third) {
        return second;
    }
    return third;
}
int main()
{
    int maximum = max_of_three(34, 56, 14);
    std::cout << maximum << std::endl;
}

C++中的 while 循环、用户输入、if 语句和 foreach 循环

我们需要使用动态数据结构,这样我们就可以在程序运行时添加尽可能多的值。在 C++中,我们有这样一个选项,就是使用一个名为vector的类。这个类被创建成可以存储任何类型的数据,这就是为什么我们在声明中在<>之间有int。让我们看看它是如何工作的:

  1. 就像许多其他事情一样,vector类需要用std::指定为属于标准命名空间。

  2. 接下来,我们声明一个整数变量,它将接受输入。我们目前将其设置为0。当我们进入while循环时,我们需要这个值在下一行。当循环迭代时,只要input_value等于或大于0,我们必须将其设置在该范围内的一个值。

  3. 在循环内部,我们向用户打印一条消息,说明我们需要一个值。要从用户那里获取输入,我们使用cin,它的工作方式有点像cout,但方向相反。它不是将事物发送到屏幕,而是从键盘接受事物。通常,当我们谈论coutcin时,我们不会说输出会显示在屏幕上,输入来自键盘,因为这些可以重新映射为其他事物,如文件。相反,我们说cout发送到标准输出,通常是屏幕,而cin从标准输入读取,通常是键盘。

  4. 当我们获得输入时,我们会检查它是否为0或正值。这是我们想要存储在我们向量中的唯一值。如果是的话,我们就在我们的向量上使用一个名为push_back的方法,它将当前值插入到向量的末尾。

  5. 这将继续,直到用户输入一个负值。然后,我们退出while循环,进入 C++中称为for循环的东西。它类似于foreach循环,因为它将遍历我们在向量中的所有项目。当前项将被存储在变量 value 中,并在循环内部打印它。它的代码如下:

    #include <iostream>
    #include <vector>
    int main()
    {
        std::vector<int> values;
        int input_value = 0;
        while (input_value >= 0) {
            std::cout << "Enter a number: ";
            std::cin >> input_value;
            if (input_value >= 0) {
                values.push_back(input_value);
            }
        }
    
        for (auto value : values) {
            std::cout << value << std::endl;
        }
    } 
    

C#

C#,发音类似于同名音乐符号,是由微软开发的一种语言,并于 2000 年作为公司.NET 计划的一部分首次发布。该语言由丹麦软件工程师 Anders Hejlsberg 设计,最初将其命名为Cool(代表C-like Object-Oriented Language)。由于版权原因,微软在首次正式发布之前将其更名为 C#。

该语言被设计成一种简单、现代且面向对象的编程语言。该语言主要用于微软的.NET 框架中。

注意,C#中所有非复合语句都以分号结尾。

这里有一些快速事实:

  • 名称:C#

  • 设计者:Anders Hejlsberg,微软

  • 首次公开发布:2000

  • 范式:面向对象、泛型、命令式、结构化、函数式

  • 类型:静态

  • .cs

C#中的“Hello world”

所有用 C#编写的程序都必须存在于一个类中,并且我们项目中的一个类必须有一个名为Main的方法,它将是程序执行的开始点。还应注意的是,所有 C#应用程序都应该存在于一个项目中。

我们首先应该注意的是,在包含Main方法头部的行上,我们看到的是static关键字。将方法声明为static意味着它可以不创建定义在其内的类的对象而执行。简单来说,这意味着Main方法可以作为函数执行;这一点我们现在需要知道的就是这些。

Console是一个类,它处理 C#控制台应用程序的所有输入和输出。控制台应用程序是一个没有图形用户界面的程序。所有输入和输出都通过控制台或终端窗口进行,仅使用文本。

Console类内部,还有一个名为WriteLine的静态方法。在这里我们可以看到,一个static方法可以通过类名来调用。这个WriteLine方法将输出我们发送到控制台窗口的任何内容。参考以下代码:

using System;
class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Hello World!");
    }
}

C#中的变量声明

由于 C#是一种静态类型语言,我们必须指定一个变量可以使用的数据类型。在那之后,这个变量就只能处理这种数据类型。

我们使用int声明myIntValue变量为一个整数。

在 C#中,int不仅仅是一个基本数据类型,就像在许多其他语言中一样。它是一种称为struct的东西。在某种程度上,struct与类是相同的东西。这个struct将从名为Object的类中继承一些东西,这个类定义了一个名为ToString的方法,我们可以使用这个方法将整数转换为字符串:

using System;
class Program
{
    static void Main(string[] args)
    {
        int myIntValue = 10;
        string myStringValue = myIntValue.ToString();
    }
}

我们可以通过让编译器确定变量的数据类型来简化这个程序。因为我们是在声明它们的同时给它们赋值,编译器将根据这个数据类型创建它们。我们通过var关键字来完成这个操作:

using System;
class Program
{
    static void Main(string[] args)
    {
        var myIntValue = 10;
        var myStringValue = myIntValue.ToString();
    }
}

C#中的 for 循环

C#使用 C 风格的for循环。它有三个部分,由分号分隔:

  • 第一个部分将初始化循环变量为其起始值;在我们的例子中,那将是0

  • 下一个部分是告诉for循环将运行多长时间的条件;在我们的例子中,那就是变量小于 10\。

  • 最后一个部分是变量在每次迭代中如何变化。我们在这里使用++运算符,使得变量在每次迭代中增加一。

在循环内部,我们将打印循环变量的值:

using System;
class Program
{
    static void Main(string[] args)
    {
        for(int i = 0; i < 10; i++) 
        {
            System.Console.WriteLine(i);
        }
    }
}

C#中的函数

我们首先应该注意的是,在 C#中,没有函数,因为所有代码都必须定义在一个类中,并且定义在类内部的函数被称为方法。尽管如此,它们的行为与普通函数相似。

正如我们在前面的例子中所看到的,如果我们想调用一个方法而不需要这个类的对象,那么这个方法必须被声明为static,这是我们声明函数时看到的第一个东西。

在 C#中,我们还必须指定一个方法将返回什么数据类型。这就是为什么在方法名前面有int的原因。当我们传入三个整数时,它将返回一个整数,并且它将返回这三个数中的最大值。正如我们所看到的,我们还必须为每个参数指定数据类型。

注意,在 C#中,&&符号表示and。参考以下代码:

using System;
class Program
{
    static int MaxOfThree(int first, int second, int third)
    {
        if (first > second && first > third) {
            return first;
        }
        if (second > third) {
            return second;
        }
        return third;
    }
    static void Main(string[] args)
    {
        int maximum = MaxOfThree(34, 56, 14);
        System.Console.WriteLine(maximum);
    }
}

C#中的 while 循环、用户输入、if 语句和 foreach 循环

我们需要一个动态数据结构,这样我们就可以在程序运行时添加尽可能多的值。在 C#中,我们有这样一个选项,就是使用一个名为List的类:

  • 这个类被创建出来,以便一个列表可以持有任何类型的数据,这就是为什么我们在声明中在<>之间有int

  • 接下来,我们声明一个整数变量,它将接受输入。我们目前将其设置为0。当我们进入while循环时,我们需要这个值在下一行。由于循环在inputValue等于或大于0时迭代,我们必须将其设置在该范围内的一个值。

  • 在循环内部,我们向用户打印一条消息,表示我们想要一个值。要从用户那里获取输入,我们使用位于Console类中的ReadLine方法。我们从ReadLine获得的是一个字符串。这就是为什么我们使用Int32.Parse方法的原因。它将用户输入的任何内容转换为整数。

  • 当我们获得输入时,我们检查它是否为0或正值。我们只想在我们的列表中存储0值。如果是,我们就在我们的列表上使用名为Add的方法调用,它将当前值插入列表的末尾。

  • 这将继续,直到用户输入一个负值。然后,我们退出while循环,进入一个foreach循环,该循环将遍历列表中的所有项。

当前项将被存储在名为value的变量中,并在循环内部打印它:

using System;
using System.Collections.Generic;
class Program
{
   static void Main(string[] args)
   {
      List<int> values = new List<int>();
      int inputValue = 0;
      while (inputValue >= 0) {
          System.Console.Write("Enter a number: ");
          inputValue = Int32.Parse(System.Console.ReadLine());
          if (inputValue >= 0) {
              values.Add(inputValue);
           }
      }
      foreach(var value in values) {
          System.Console.WriteLine(value);
      }
    }
}

Java

Java 编程语言的工作始于 1991 年,设计目标是创建一个简单、面向对象的、语法对现有程序员熟悉的语言。

James Gosling 是该语言的主要设计者,最初将其命名为 Oak,因为一棵橡树正在他窗户外生长。由于版权原因,后来将其更名为 Java,以纪念 Java 咖啡。

语言设计中的一个基本概念是让程序员一次编写,到处运行,简称WORA。这个想法是,用 Java 编写的应用程序可以在大多数平台上运行,无需任何修改或重新编译。

通过让 Java 源代码编译成一个中间表示形式,称为Java 字节码,而不是特定平台的机器码,实现了可移植性。然后,由为托管应用程序的硬件编写的虚拟机执行这些字节码。

这里有一些关于它的快速事实:

  • 名称:Java

  • 设计者:James Gosling,Sun Microsystems

  • 首次公开发布:1995 年

  • 范式:多范式、面向对象、泛型、命令式

  • 类型:静态

  • .java.jar

Java 中的“Hello World”

Java 要求所有代码都必须在类内部编写,并且所有应用程序都需要一个名为main的方法的类。

Java 的一个特点是每个类都必须在一个与类同名的源代码文件中编写。由于这个例子中的类名为Hello,它必须保存在一个名为Hello.java的文件中。

要将内容打印到控制台窗口,我们将使用System.out.println。现在,System是一个类,它处理输入和输出等操作。在System类内部,定义了一个输出流,称为out,这个流有一个名为println的方法,它将打印传递给它的数据,并在流的末尾插入一个换行符。

注意,Java 中所有非复合语句都以分号结束:

class Hello {
    public static void main(String[] args) {
        System.out.println("Hello, World!");        
    }
}

Java 中的变量声明

由于 Java 是一种静态类型语言,我们必须指定一个变量可以使用的数据类型。之后,这将是这个变量唯一可以处理的数据类型。

我们首先使用int声明我们的整数变量。

所有原始数据类型在 Java 中都有一个类表示。我们可以使用Integer类将我们的整数转换为字符串。我们通过调用Integer类中的一个静态方法并传递我们想要转换的整数值来实现这一点:

class Variable {
    public static void main(String[] args) {
        int myIntValue = 10;
        String myStringValue = Integer.toString(myIntValue);       
    }
}

Java 没有像 C++和 C#中的autovar关键字那样的自动类型推断功能。

Java 中的 for 循环

Java 使用 C 风格的for循环。它有三个部分,由分号分隔。第一个部分将循环变量初始化为其起始值;在我们的例子中,那将是 0。下一个部分是条件,它将告诉我们for循环将运行多长时间;在我们的例子中,只要变量小于 10。最后一个部分是变量在每次迭代中如何变化。我们在这里使用++运算符,所以变量在每次迭代中都会增加 1。

在循环内部,我们将打印循环变量的值:

class For {
    public static void main(String[] args) {
        for(int i = 0; i < 10; i++) {
            System.out.println(i);
        }  
    }
}

Java 中的函数

我们首先应该注意的是,在 Java 中,没有函数,因为所有代码都必须在类中定义,类内声明的函数被称为方法。尽管如此,它们的行为与普通函数类似。

如我们在前面的示例中看到的,如果我们想在没有这个类的对象的情况下调用一个方法,那么这个方法必须被声明为static,这是我们声明函数时看到的第一个东西。

在 Java 中,我们还必须指定一个方法将返回什么数据类型。这就是为什么在方法名前面有int。它将返回一个整数,因为我们传递了三个整数,并且它将返回这三个数中的最大值。正如我们所看到的,我们必须为每个参数指定数据类型。

注意,在 Java 中,&&符号表示and

class Function {
    static int maxOfThree(int first, int second, int third) {
        if (first > second && first > third) {
            return first;
        }
        if (second > third) {
            return second;
        }
        return third;
    }
    public static void main(String[] args) {
        int maximum = maxOfThree(34, 56, 14);
        System.out.println(maximum);
    }
}

Java 中的 while 循环、用户输入、if 语句和 foreach 循环

我们需要使用一个动态数据结构,这样我们就可以在程序运行时添加尽可能多的值。在 Java 中,我们有这样一个选项,就是使用一个名为ArrayList的类:

  1. 这个类被创建出来,以便列表可以存储任何类型的数据,这就是为什么我们在声明中在<>之间有Integer。在 Java 中,我们不能使用原始数据类型作为存储在列表中的类型。相反,我们使用int的类表示形式,即Integer

  2. 接下来,我们声明一个整数变量,它将接受输入。我们目前将其设置为0。当我们进入while循环时,我们需要这个值。当循环迭代时,只要inputValue等于或大于 0,我们必须将其设置在该范围内的一个值。

  3. Java 没有内置的用户输入方法,因此我们需要从名为BufferedReader的类中创建一个对象来处理输入。我们称这个对象为reader

  4. 在循环内部,我们向用户打印一条消息,表示我们想要一个值。为了从用户那里获取输入,我们使用我们的reader对象及其readLine方法。我们从readLine获取的值是一个字符串。这就是为什么我们使用Integer.parseInt方法。它将用户输入的任何内容转换为整数。

  5. 当我们获得输入时,我们会检查它是否为0或正值。我们只想在我们的列表中存储0值。如果是,我们将在我们的列表上使用一个名为add的方法,该方法将当前值插入列表的末尾。

  6. Java 将强制我们处理用户输入非数字的情况。如果他们这样做,当我们尝试将字符串转换为数字时,我们会得到一个异常。这就是为什么我们需要带有catch语句的try块。如果用户输入的不是数字,我们将进入catch语句。

  7. 这将继续,直到用户输入一个负值。然后,我们退出while循环,进入一个for循环,该循环将遍历列表中的所有项目。当前的项目将被存储在value变量中,在循环内部,我们打印它:

    import java.io.BufferedReader;
    import java.io.IOException;
    import java.io.InputStreamReader;
    import java.util.ArrayList;
    class For {
      public static void main(String[] args) {
        ArrayList<Integer> values = new 
          ArrayList<Integer>();
        int inputValue = 0;
        BufferedReader reader = new BufferedReader(new 
                         InputStreamReader(System.in));
        while(inputValue >= 0) {
          System.out.print("Enter a value: ");
          try {
            inputValue = Integer.parseInt(reader.readLine());
            if (inputValue >= 0) {
              values.add(inputValue);
            }
          } catch (NumberFormatException | IOException e) {
            e.printStackTrace();
          }
        }
        for (int value : values) {
          System.out.println(value);
        }
      }
    }
    

JavaScript

在万维网的早期几年,只有一个支持图形用户界面的网络浏览器,即 1993 年发布的 Mosaic。Mosaic 的主要开发者很快成立了 Netscape 公司,并在 1994 年发布了一个更精致的浏览器,名为 Netscape Navigator。

在这些早期年份,网络是一个完全不同的地方,网页只能显示静态内容。Netscape 希望改变这一点,并决定在其 Navigator 中添加一种脚本语言。最初,他们考虑了两种实现这一目标的方法。一种是与 Sun Microsystems 合作并使用 Java 编程语言。另一种选择是让新聘用的布伦丹·艾奇将 Scheme 编程语言嵌入到浏览器中。

这个决定是在两者之间做出的妥协。布伦丹·艾奇被委以创建一种新语言的任务,但其语法应与 Java 紧密相关,而不太像 Scheme。这种语言最初被命名为 LiveScript,这也是它在 1995 年发布的名称。

由于 Java 在当时是一种全新的语言,因此将其名称更改为 JavaScript,以便它能得到更多的关注。这两个语言名称之间的相似性导致了多年来许多混淆,尤其是在不太熟悉编程的人中。

以下是关于 JavaScript 的一些快速事实:

  • 名称: JavaScript

  • 设计者: 布伦丹·艾奇

  • 首次公开发布: 1995 年

  • 范式: 事件驱动、函数式、命令式

  • 类型: 动态

  • .js

JavaScript 中的“Hello World”

我们应该注意的第一件事是,JavaScript 被设计为在其程序在网页浏览器中执行。你可以在控制台窗口中运行 JavaScript 应用程序,但要能够做到这一点,我们需要一个可以为我们执行代码的 JavaScript 引擎。其中一个这样的引擎是 Node.js,可以从nodejs.org免费下载。

JavaScript 是一种脚本语言,因此我们不需要将代码放在任何特定的函数或类中。

在 JavaScript 中,我们可以使用console对象来输出数据。它通常用于将数据打印到网页浏览器的调试控制台,但如果我们使用 Node.js 来执行该应用程序,输出将打印到控制台窗口。console对象有一个名为log的方法,可以输出我们传递给它的任何内容。

注意,JavaScript 中所有非复合语句都以分号结尾:

console.log("Hello, World!");

JavaScript 中的变量声明

JavaScript 没有为整数指定特定的数据类型。相反,它有一个名为Number的数据类型,可以处理整数和浮点数。

我们可以通过使用较旧的var关键字或较新的let来声明变量。

由于 JavaScript 是动态类型的,我们不需要指定变量将使用什么类型。当我们给它赋值时,类型会被自动推断。

可以通过Number类中的toString方法将数字转换为字符串。由于我们的变量myIntValueNumber类的一个对象,它具有这样的方法。注意,我们将值10传递给toString方法。这是我们希望数字所在的基数。我们想要一个十进制数,所以传递10。操作如下:

let myIntValue = 10;
let myStringValue = myIntValue.toString(10);

JavaScript 中的 for 循环

JavaScript 使用 C 风格的for循环。它有三个部分,由分号分隔:

  • 第一个部分将初始化循环变量为其起始值;在我们的例子中,那将是0

  • 下一个部分是条件,它将告诉我们for循环将运行多长时间;在我们的例子中,只要变量小于10

  • 最后的部分是变量在每次迭代中如何变化。在这里我们使用++运算符,这样变量在每次迭代中都会增加一。

在循环内部,我们将打印循环变量的值:

for (let i = 0; i < 10; i++) {
  console.log(i);
}

JavaScript 中的函数

由于 JavaScript 是动态类型的,我们不需要指定函数的返回值或参数的数据类型,就像在 C++、C#和 Java 中需要做的那样。

我们使用function关键字来定义这是一个函数。

注意,在 JavaScript 中,&&符号表示“和”:

function maxOfThree(first, second, third) {
  if (first > second && first > third) {
    return first;
  }
  if (second > third) {
    return second;
  }
  return third;
}
let maximum = maxOfThree(34, 56, 14);
console.log(maximum);

Java 中的 while 循环、用户输入、if 语句和 foreach 循环

首先,我们必须注意,这个例子并不能真正体现 JavaScript 的优势,因为 JavaScript 并不是为了编写这样的应用程序而设计的。这与 JavaScript 被设计为在网页浏览器中运行,而不是作为控制台应用程序有关。

在 JavaScript 中,事情通常是以异步方式完成的。也就是说,程序代码不会像我们在大多数其他语言和情况下所习惯的那样按顺序执行。如果我们尝试以伪代码版本和为所有其他语言编写的版本相同的方式实现这个程序,我们会看到它进入了一个无休止的循环,不断地要求我们输入一个值,一次又一次。

这个程序有些复杂,所以我们就不深入细节了。前几行是为了创建一些处理输入的东西。其核心是一个名为question的函数,它将返回一个promise对象。promise对象是承诺在未来的某个时刻给我们一个值的东西。为了能够使用这个promise,它必须从一个函数中调用,并且这个函数必须声明为async。这意味着这个函数可以使用promise(为了简化事情)。

这个函数没有名字,但正如你所看到的,它被括号包围,并且在最后有两个空括号。这个结构将使这个函数立即执行:

  1. 在这个函数内部,我们创建了一个名为values的动态数组。我们将它初始化为空,因为我们还没有任何值要存储在其中。

  2. 接下来,我们找到我们将用于输入的变量。我们将这个值设置为0,这样当我们来到下一行的while循环时,我们会进入循环。

  3. 在下一行,我们将使用程序顶部看到的所有代码,这些代码处理用户输入。我们说我们await``question函数。await关键字将允许应用程序去做其他事情,如果需要的话,但当我们得到用户输入的值时,我们会回到这里并继续执行。这是异步调用工作原理的简要描述。这是一个高级话题,所以如果这段代码让你感到困惑,没问题。

  4. 如果输入的值大于或等于0,我们将这个值推送到数组的末尾。

  5. 当用户输入一个负数时,我们将退出while循环,并进入一个for循环,该循环将迭代数组中的所有项目。pos变量将有一个索引值,第一次是0,第二次是1,以此类推。当我们想要在循环中打印值时,我们可以使用这个值作为数组的索引,这样我们就能在第一次得到第一个值,第二次得到第二个值,依此类推。请参考以下代码:

    const readline = require("readline");
    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
    });
    const question = (q) => {
      return new Promise((res, rej) => {
        rl.question(q, (answer) => {
          res(answer);
        });
      });
    };
    (async () => {
      let values = [];
      let inputValue = 0;
      while (inputValue >= 0) {
        inputValue = await question("Enter a number: ");
        inputValue = parseInt(inputValue);
        if (inputValue >= 0) {
          values.push(inputValue);
        }
      }
      for (let pos in values) {
        console.log(values[pos]);
      }
    })();
    

PHP

在 1994 年,丹麦-加拿大程序员拉斯马斯·勒尔多夫(Rasmus Lerdorf)用 C 语言编写了几个通用网关接口CGI)程序。CGI 是一个接口规范,它将允许 Web 服务器执行可以生成动态 Web 内容的程序。勒尔多夫为他的私人网页创建了它,并扩展并添加了处理 Web 表单和数据库通信的功能。他将这个项目命名为个人主页/表单解释器,简称PHP/FI

Lerdorf 后来承认他从未打算创建一种新的编程语言,但这个项目获得了自己的生命力,并组建了一个开发团队,1997 年发布了 PHP/FI 2。

该语言主要用于在 Web 服务器上创建动态网页内容。

关于它的快速事实如下:

  • 名称:PHP

  • 设计者:Rasmus Lerdorf

  • 首次公开发布:1995

  • 范式:命令式、函数式、面向对象、过程式

  • 类型:动态

  • .php

PHP 中的“Hello World”

PHP 的主要用途是与 Web 服务器一起运行,用 PHP 编写的应用程序通常用于生成动态网页内容。但如果我们从 php.net 下载 PHP 可执行文件,我们也可以作为独立的控制台应用程序运行 PHP 应用程序:

  • 由于 PHP 代码可以与 HTML 代码在同一文档中编写,因此我们编写的所有 PHP 源代码都必须在 php 标签内。起始标签是 <?php,结束标签是 ?>

  • 我们使用 echo 在控制台窗口中显示我们的消息。您不需要在 echo 中使用任何括号,因为它不是一个函数,而是一种语言结构。

    注意,PHP 中所有非复合语句都以分号结束:

    <?php
     echo "Hello, World!";
    ?>
    

PHP 中的变量声明

由于 PHP 是动态类型语言,当我们声明变量时,我们不需要提供任何关于使用哪种数据类型的隐式信息。变量类型将自动为我们推导出来,最终类型取决于我们分配给变量的内容。

PHP 从语言 Perl 继承的一个奇特之处在于,所有变量名都必须以美元符号开头。在 Perl 中,不同的符号有不同的意义,但 PHP 只有一个美元符号用于所有类型。

让我们试试这个。我们首先将值 10 赋给我们的 $myIintValue 变量。

要将这个整数转换为字符串,我们将使用 strval 函数并将整数传递给它。这将把此值转换为字符串,如下所示:

<?php
 $myIntValue = 10;
 $myStringValue = strval($myIntValue);
?>

PHP 中的 for 循环

PHP 使用 C 风格的 for 循环。它有三个部分,由分号分隔。第一个部分将循环变量初始化为其起始值;在我们的例子中,那将是 0。下一个部分是条件,它将告诉我们 for 循环将运行多长时间;在我们的例子中,只要变量小于 10。最后一个部分是变量在每次迭代中如何变化。我们在这里使用 ++ 运算符,以便变量在每次迭代中增加一。

在循环内部,我们将打印循环变量的值。

由于 PHP 中的 echo 不会提供任何换行符,我们将在每次迭代后在我们的循环变量后附加它。我们可以通过在两个值之间插入一个点来连接循环变量的值和换行符 (\n):

<?php
 for($i = 0; $i < 10; $i++) {
     echo $i . "\n";
 }
?>

PHP 中的函数

由于 PHP 是动态类型,我们不需要为函数的返回值或参数指定任何数据类型,就像在 C++、C# 和 Java 中需要做的那样。

我们使用function关键字来定义这是一个函数。

注意,在 PHP 中,&&符号表示and

<?php
function maxOfThree($first, $second, $third) {
    if ($first > $second && $first > $third) {
      return $first;
    }
    if ($second > $third) {
      return $second;
    }
    return $third;
}

$maximum = maxOfThree(34, 56, 14);
echo $maximum;

?>

PHP 中的 while 循环、用户输入、if 语句和 foreach 循环

在 PHP 中,我们可以通过使用array()来创建动态数组。在 PHP 中,数组不是一个数组,而是一个有序映射,在其他语言中称为字典或关联数组。但在这个应用中,这并不重要:

  1. 在创建数组之后,我们声明一个输入变量,它将保存用户输入的值。我们将其设置为0,这样当我们来到下一行的while循环时,我们将进入循环。

  2. 接下来,我们将使用readline从用户那里获取一个值。我们可以向readline传递一个字符串,该字符串将作为提示打印到屏幕上。这样,我们就不需要单独一行来打印这个消息。

  3. readline获取的值将是一个字符串,因此我们使用intval将其转换为整数。

  4. 接下来,我们检查该值是否大于或等于0。如果是,我们将使用array_push函数。这个函数接受两个参数。第一个参数是我们想要推送值的数组,第二个参数是我们想要推送的值。

  5. 当用户输入一个负数时,我们将退出while循环并进入一个foreach循环,该循环将打印用户输入的所有值。如果您将此程序与其他语言编写的程序进行比较,您会发现与 PHP 相比,数组和变量在位置上有所交换。

    foreach循环内部,我们将值打印到控制台:

    <?php
     $values = array();
     $inputValue = 0;
     while($inputValue >= 0) {
         $inputValue = intval(readline("Enter a value: "));
         if($inputValue >=  0) {
             array_push($values, $inputValue);
         }
     }
     foreach($values as $value) {
         echo $value . "\n";
     }
    ?>
    

Python

Python 是在 20 世纪 80 年代末由荷兰程序员吉多·范罗苏姆设计的,作为 ABC 语言的继承者。该语言背后的主要设计理念是代码可读性。

在开发语言的过程中,范罗苏姆喜欢英国喜剧团体蒙提·派森,并决定以他们的名字来命名他的新语言。

在过去几年中,该语言的普及率呈指数级增长,现在它被列为最受欢迎的语言之一。

它是一种通用语言,可用于大多数类型的应用。该语言的常见用途包括开发 Web 应用程序和在数据科学中的应用。由于它被认为是最容易学习的编程语言之一,因此它经常被用作入门语言。

关于它的几个快速事实:

  • 名称:Python

  • 设计者:吉多·范罗苏姆

  • 首次公开发布:1990 年

  • 范式:多范式、函数式、命令式、面向对象、结构化

  • 类型:动态

  • .py

Python 中的“Hello world”

由于 Python 是一种脚本语言,我们不需要将代码放在任何特殊函数或类中。要向控制台窗口打印消息,我们只需使用print函数并将我们想要打印的内容传递给它:

print("Hello, World!")

在 Python 中声明变量

由于 Python 是一种动态类型语言,我们不需要提供任何关于我们的变量将使用什么类型的信息。当我们给变量赋值时,类型会自动为我们推导出来。

要声明一个整数变量,我们只需给它赋一个整数。

将这个整数转换为字符串,我们可以使用一个名为 str 的类,并将整数传递给它。由于 Python 中的一切都是对象,这将返回一个新的字符串对象给我们:

my_int_value = 10
my_string_value = str(my_int_value)

Python 中的 for 循环

当涉及到 for 循环时,Python 将与其他所有我们在这里考虑的语言不同。它不实现使用 C 样式格式的 for 循环。Python 中的 for 循环将遍历某种类型的序列。由于我们没有序列,我们可以使用一个叫做 range 的东西。现在,range 看起来像是一个函数,但实际上它是一个叫做 10 的东西,在第一次迭代时,它将生成值 0。在下一个迭代中,生成的值将是 1,以此类推,直到 9

还要注意,Python 不使用大括号来表示复合语句,就像我们在 for 语句中看到的那样。相反,for 循环的内容用四个空格缩进。还要注意,冒号是第一行的最后一个字符。它是表示下一行应该缩进的指示:

for i in range(10):
    print(i)

Python 中的函数

由于 Python 是动态类型的,我们不需要为函数的返回值或参数指定任何数据类型,就像在 C++、C#和 Java 中需要做的那样。

我们使用 def 关键字来定义这是一个函数:

def max_of_three(first, second, third):
    if first > second and first > third:
        return first
    if second > third:
        return second
    return third
maximum = max_of_three(34, 56, 14)
print(maximum)

Python 中的 while 循环、用户输入、if 语句和 foreach 循环

在 Python 中,我们可以使用列表来存储用户输入的值。Python 中的列表是动态的,因此它可以随着用户输入新值而增长:

  1. 我们声明列表并将其初始化为空,以便开始。

  2. 接下来,我们声明用于用户输入的变量并将其设置为 0。我们使用零的原因是,当我们到达 while 循环的行时,我们希望条件为真。如果 input_value 变量是 0 或更大,则条件为真。

  3. while 循环内部,我们将使用 input 函数让用户输入值。input 函数允许我们向它传递一个字符串,该字符串将被显示给用户。这消除了在某些其他语言中我们需要先打印这条消息然后再获取用户输入的需求。

  4. input 函数获取的值是一个字符串,因此我们需要将其转换为 int。我们通过将输入的字符串传递给 int() 来做到这一点。这将创建一个具有输入值的整数。

  5. 接下来,我们检查输入的值是否大于或等于 0。如果是,我们将它追加到我们的列表中。

    当用户输入一个负数时,我们将退出while循环并继续到for循环。Python 中的for循环总是像foreach循环一样工作。for循环需要一个序列,并且它会遍历该序列的所有值。我们的列表就是这样一种序列,因此我们每次迭代时都会得到一个项目,现在我们可以打印出这个项目的值,如下所示:

    values = []
    input_value = 0
    while input_value >= 0:
        input_value = int(input("Enter a number: "))
        if input_value >= 0:
            values.append(input_value)
    for value in values:
        print(value)
    

第十四章:附录 B:词典

A

ALGOL – 1958 年由欧洲和美国计算机科学家委员会开发的一组编程语言。

算法 – 一组规则或描述在问题解决操作中应遵循的步骤。计算机科学中使用的算法示例包括排序和搜索算法。

模拟 – 使用表示通过连续改变物理量(如电压或空间位置)的信号或信息。

应用程序 – 为特定目的设计的计算机程序。

算术 – 一门处理数字性质和操作的数学分支。

算术溢出/下溢 – 计算结果超出分配给存储结果的内存空间。

数组类型 – 一种数据类型,表示可以通过索引选择的一组元素。

ASCII 表 – 使用数字表示字符的字符编码标准。

汇编语言 – 任何与机器代码指令有强相关性的低级编程语言的名称。

B

十进制 – 见十进制

十六进制 – 见十六进制

二进制 – 见二进制

二进制 – 一种以 2 为基数的工作的数制 – 即,它只使用两个数字:零和一。

空行 – 在编程中用于分隔逻辑上属于一起的代码块。

块注释 – 在编程代码中跨越多行的注释。见注释

布尔代数 – 一种仅对真值和假值进行操作的代数分支。这个名字来自 George Boole,他在 1847 年引入了它。

truefalse值。见布尔代数

Break – 许多编程语言中用于退出当前代码块的一个语句。见代码块

断点 – 在调试应用程序时用于在特定代码行暂停执行。

简短代码 – 短代码编程语言的先驱。见短代码

BUG – 用作注释的标签,表示包含尚未修复错误的代码。

字节 – 一组二进制数字,通常为 8 位,作为一个单元进行操作。

字节码 – 编程语言可以编译成的中间代码。如果源代码被直接解释,则可以更有效地解释。

C

C – 由 Dennis Ritchie 在 1972 年于贝尔实验室设计的编程语言。其语法影响了其他许多编程语言。

C# – 发音为 C sharp。由 Microsoft 于 2000 年开发的编程语言。

C++ – 由 Bjarne Stroustrup 于 1985 年创建的编程语言。它被开发为 C 编程语言的面向对象扩展。

camelCase

中央处理单元 – 计算机中控制并执行操作的组件。

类方法 – 在面向对象中,类方法是指属于类的函数。类方法是在类本身上调用,而不是在实例上。见方法

– 在面向对象中,类是用于创建对象的模板。见对象

客户端软件 – 在客户端-服务器解决方案中扮演客户端角色的应用程序。

客户端-服务器 – 一种分布式应用程序结构,将工作负载在服务器和一个或多个客户端之间分配。客户端发起通信,服务器向客户端提供功能或服务。

Clojure – 由 Rich Hickey 于 2007 年创建的编程语言。该语言是 Lisp 方言。见LISP

云计算 – 通过互联网按需提供的计算机系统资源,如存储或计算能力。

COBOL – 这个缩写代表通用商务语言,是一种为商业用途设计的类似英语的编译型编程语言。COBOL 于 1959 年由 CODASYL 小组开发,基于 FLOW-MATIC 编程语言。见 FLOW-MATIC。

代码块 – 通常跨越多行并属于同一语句的代码块。开始和结束通常用{和}或缩进来标记。

代码模块 – 实现特定功能的代码部分。它通常封装在一个单一单元中,例如一个代码文件。

命令提示符 – 见终端窗口

注释 – 源代码中供程序员阅读的解释或注释。注释为人类提供指令,并被编程语言忽略。

Common Lisp – 一种旨在统一其他 Lisp 方言的编程语言。它于 1984 年发布。

编译型语言 – 一种将程序中所有语句翻译成机器语言的编程语言。当所有语句都翻译完成后,程序可以执行。用编译型语言编写的程序通常比用解释型语言编写的程序运行得更快。

复合类型 – 由多个值组成的数据类型。

ifforwhile语句。

继续 – 在循环中使用的语句。当遇到时,当前迭代将被终止,并立即继续下一个迭代。

CPU – 见中央处理单元

D

数据库 – 有组织的电子数据集,存储和访问。其他应用程序通常使用这些数据。

调试器 – 程序员用来查找程序代码中错误的工具。它允许程序员在执行代码时逐行执行代码,程序员可以检查变量的值和执行的路径。

十进制 – 一种基于 10 的进制系统 – 即它使用 10 个数字来表示数字。这是我们通常使用的计数系统。

字典类型 – 一种存储数据在键值对中的数据类型。字典中的键值对于字典中的每个项目必须是唯一的。

指令注释 – 源代码中的注释,不是为人类设计的,而是为其他编程工具,如编译器。

除以零 – 发生在除法运算中,除数为零时出现的错误。

文档字符串 – 以预定义方式格式化的注释。

E

封装 – 在面向对象编程中用于限制对对象某些组件的直接访问。

enum,具有一组命名值。

as x + 1

F

一等函数 – 如果一种语言支持一等函数,这意味着函数可以作为参数传递给其他函数,或者作为函数的结果返回。

FIXME – 在注释中使用的标签,表示代码的特定部分需要重写或更新。

浮点类型 – 一种可以表示实数的数据类型。

FLOW-MATIC – 1955 年由 Grace Hopper 设计的编程语言。它是第一个类似英语的数据处理语言。

Fortran – 1954 年由 IBM 的 John Backus 发明的编程语言。

函数 – 一系列程序指令,作为一个单元打包,并且(通常)赋予一个名称。

函数调用 – 对函数的调用会将控制权传递给该函数。一旦函数执行完毕,控制权将返回。

函数式编程 – 一种通过组合和应用函数来构建程序的范式。

G

– 见 逻辑门

Go – 由 Robert Griesemer、Rob Pike 和 Ken Thompson 设计,于 2009 年发布的编程语言。

H

HACK – 在注释中使用的标签,表示代码的特定部分是一个权宜之计,并且将来需要重写此代码。

硬盘 – 一种电磁数据存储设备,使用涂有磁性材料的快速旋转盘。它可以用于存储和检索数字数据。

硬件 – 组成计算机或其他电子系统的机器和物理组件。

十六进制 – 一种基于 16 的计数系统 – 即,它使用 16 个数字来表示数字。它使用数字 0-9,后跟字母 A-F,其中 A 是 10,F 是 15。

I

IaaS基础设施即服务是一种在线服务,允许用户使用通过互联网提供和管理的即时计算基础设施。

IDE集成开发环境是一个程序或程序套件,它为程序员提供编写、编辑、调试和测试应用程序所需的工具。

不可变数据 – 一旦获得初始值后就不能更改的数据。见 可变数据

缩进 – 一种使代码更易读的技术,其中代码行的开头有空格。

实例 – 见 对象

21133-70

整数数据类型 – 可以表示整数值的数据类型。

解释型语言 – 将编程指令从给定编程语言翻译成机器码的编程语言,一次翻译一条语句。一旦语句被翻译成机器码,它就会被发送到中央处理器执行。

iOS – 由苹果公司创建和开发的移动操作系统。

IP 地址 – 分配给每个连接到使用互联网协议进行通信的计算机网络的设备的数字地址。

迭代语句 – 导致其体内定义的其他语句重复零次或无限次的语句。

J

Java – 由詹姆斯·高斯林开发的编程语言。它于 1995 年首次发布。

JavaScript – 由布兰登·艾奇开发的编程语言。它于 1995 年首次发布。

K

关键字 – 在编程语言中,关键字是该语言中具有特定含义的保留词。

Kotlin – 由捷克软件开发公司 JetBrains 设计的编程语言。它于 2011 年首次发布。

L

语言语法 – 见语法

Lisp – 一系列编程语言,最初由约翰·麦卡锡于 1958 年指定。在现代 Lisp 方言中,我们可以找到诸如 Racket、Common Lisp、Scheme 和 Clojure 等语言。

逻辑门 – 用于在二进制输入上执行逻辑操作的物理电子设备。

低级编程 – 创建直接与计算机硬件交互的程序。低级编程的好处是硬件和编写代码之间没有抽象级别,这使得程序执行得更快。

M

机器码 – 以数值格式编写的程序指令,可以直接由中央处理器执行。

成员变量 – 在面向对象编程中使用的概念,其中变量属于一个特定的对象。

内存 – 计算机可能使用的所有不同存储技术的通用术语。

方法 – 在面向对象编程中使用的概念。方法是与类及其对象相关联的函数。

移动应用 – 编写以在移动设备上执行的应用程序。

可变数据 – 可以更改的数据。见不可变数据

N

命名空间 – 一种将各种类型的对象分组并确保同一命名空间内的所有对象都具有唯一名称的方法。

Napster – 1999 年发布的一项服务,用户可以在对等网络中共享音乐。

节点 – 计算机网络中的设备。

数值类型 – 可以表示数值的数据类型。见整数数据类型浮点类型

O

Objective-C – 由汤姆·洛夫和布拉德·科克斯设计的编程语言。它于 1984 年首次发布。它是在 Swift 于 2014 年引入之前苹果支持的主要编程语言。见Swift

面向对象 – 一种软件工程范例,其中概念以对象的形式表示。

面向对象编程 – 一种基于对象概念的范例,程序使用对象构建。

对象 – 在面向对象中使用的一种表示,由字段形式的数据(通常称为属性或属性)和函数形式的代码(称为方法以区分类外定义的函数)组成。

反码 – 二进制数的反码是通过反转该数字中的所有位(将零换成一,反之亦然)来完成的。

操作码操作码的缩写,这是机器语言指令的一部分,用于指定要执行的操作。

开源 – 源代码可以免费获取并可能被重新分发和修改的软件。

操作数 – 运算符的输入值。见运算符

运算符 – 执行类似函数的操作但语法上与函数调用不同的符号。

运算顺序 – 多个操作将执行的顺序。

P

P2P – 见对等网络

PaaS平台即服务是一种云服务类型,它提供了一个平台,客户可以在该平台上开发、运行和管理应用程序。

包管理器 – 一种应用程序或应用程序集合,它自动执行下载、安装、配置和删除软件的过程。

对等网络 – 一种分布式网络应用程序,其中网络中的节点直接相互通信。

Perl – 由 Larry Wall 开发的编程语言。它首次发布于 1987 年。

PHP – 由 Rasmus Lerdorf 开发的编程语言。它首次发布于 1995 年。

像素 – 拉塞图像中的物理点或计算机屏幕(或其他类型的显示设备)的最小元素。

Plankalkül – 由 Konrad Zuse 设计的第一种编程语言之一。它首次发布于 1948 年。

处理器 – 见中央处理器

穿孔卡片 – 带有穿孔的卡片。孔的位置可以用来表示数据或程序代码指令。这曾经是计算机作为主要存储设备使用。

纯函数 – 一种具有始终为相同的参数返回相同值且其评估没有副作用特性的函数。

Python – 由 Guido van Rossum 设计的编程语言。它首次发布于 1990 年。

R

记录类型 – 由多个字段组成的数据类型。每个字段可以是任何其他类型,包括其他记录。

寄存器 – 中央处理器内部用于存储信息的组件。

仓库 – 软件或代码的存储位置。

保留字 – 编程语言保留的单词,程序员不能将其用作函数和变量等事物的名称。

S

SaaS – 一种在线服务,软件即服务,软件在此在线许可和托管。

可扩展性 – 处理日益增长工作量能力。

truefalse,并且可以根据结果执行不同的代码块。

序列 – 对象的枚举集合。

服务器 – 为其他设备(称为客户端)提供服务和功能计算机程序或设备。

集合类型 – 一种可以存储无特定顺序的唯一值集合的数据类型。

Scheme – 由 Guy L. Steele 和 Gerald Jay Sussman 开发的编程语言。它首次发布于 1975 年。

简短代码 – 被许多人认为是第一种高级编程语言,由 John Mauchly 于 1949 年提出。

符号位表示法 – 一种用于以二进制形式表示负数的方法。

Simula – 由 Ole-Johan Dahl 设计的编程语言。它首次发布于 1962 年。

SMR – 参见符号位表示法

蛇形命名法 – 一种用于格式化多词名称的风格。在蛇形命名法中,仅使用小写字母,并且下划线分隔单词。

源代码 – 程序员使用编程语言的语法编写的代码。

独立应用程序 – 一种可以离线工作的应用程序。

语句 – 命令式编程语言的一个代码单元,表示某种动作。

字符串类型 – 一种可以表示字符序列的数据类型。

子串 – 字符串中连续的字符序列。

Swift – 苹果公司开发的编程语言,作为 Objective-C 编程语言的继任者。它于 2014 年发布。参见Objective-C

语法 – 定义了构成编程语言符号和关键字的规则。它还定义了关键字和符号应该如何组合以形成有效的源代码。

语法错误 – 当源代码违反编程语言的语法规则时发生错误。

T

TCP/IP – 用于在互联网上传输数据的一套协议。

终端窗口 – 一种允许用户执行文本命令的应用程序,通常用于操作系统。

文本字符串 – 参见字符串类型

TODO – 用于注释中的标签,表示代码的特定部分尚未实现。

触摸屏 – 一种允许用户通过触摸屏幕来控制连接设备的屏幕。

二进制补码 – 一种用于以二进制形式表示有符号数字的技术。

U

Unicode – 一种字符编码标准,可以表示超过 140,000 个不同的字符。

无符号整数 – 一种只能表示正整数的数据类型。

V

变量 – 编程中用于访问数据的内存地址的命名表示。

W

网页浏览器 – 用于访问万维网上信息的应用程序。

第十五章:你可能还会喜欢的其他书籍

如果你喜欢这本书,你可能对 Packt 出版的以下其他书籍感兴趣:

![Mastering Adobe Photoshop Elementsimg/Title1.jpg

现代计算机架构与组织

现代计算机架构与组织

ISBN: 978-1-83898-439-7

  • 掌握晶体管技术和数字电路原理

  • 发现计算机处理器的功能元素

  • 理解流水线和超标量执行

  • 使用浮点数数据格式

  • 理解监督模式的目的和操作

  • 在低成本 FPGA 上实现完整的 RISC-V 处理器

  • 探索虚拟机实现中使用的技巧

  • 编写量子计算程序并在量子计算机上运行它

Mastering Adobe Captivate 2019 - Fifth Edition

程序员必知的 40 个算法

Imran Ahmad

ISBN: 978-1-78980-121-7

  • 探索 Python 库中现有的数据结构和算法

  • 使用网络分析实现用于欺诈检测的图算法

  • 使用机器学习算法对相似的推文进行聚类,并实时处理 Twitter 数据

  • 使用监督学习算法预测天气

  • 使用神经网络进行目标检测

  • 创建一个推荐引擎,为订阅者推荐相关的电影

  • 在 Google Cloud Platform (GCP)上使用对称和非对称加密实现万无一失的安全措施

留下评论 - 让其他读者了解你的想法

请通过在购买该书的网站上留下评论,与其他人分享你对这本书的看法。如果你从亚马逊购买了这本书,请在本书的亚马逊页面上给我们留下一个诚实的评论。这对其他潜在读者来说至关重要,他们可以通过你的无偏见意见做出购买决定,我们可以了解客户对我们产品的看法,我们的作者也可以看到他们对与 Packt 合作创作的标题的反馈。这只需要你几分钟的时间,但对其他潜在客户、我们的作者和 Packt 来说都很有价值。谢谢!

posted @ 2025-09-23 21:56  绝不原创的飞龙  阅读(27)  评论(0)    收藏  举报