哈佛-CS50-中文官方笔记-全-
哈佛 CS50 中文官方笔记(全)
CS50X
第零讲
-
欢迎!
-
社区!
-
计算机科学和问题解决
-
ASCII
-
Unicode
-
RGB
-
算法
-
伪代码
-
人工智能
-
未来展望
-
Scratch
-
Hello World
-
你好,你
-
喵喵和抽象
-
条件语句
-
Oscartime
-
Ivy 的最难游戏
-
总结
欢迎!
-
这门课程不仅仅是关于计算机编程!您在这门课程中学到的实用技能可能会对您的生活和超越计算机科学的学习产生影响。
-
事实上,这门课程是关于以极其赋权的方式解决问题的!您可能会在这里学习到解决问题的方法,这些方法可能会立即应用到您课程之外的工作,甚至您的整个职业生涯中!
-
然而,这不会容易!在本课程中,您将“从火炉中喝水”般地吸收知识。您将在接下来的几周内对您将能够完成的事情感到惊讶。
-
本课程更多地是关于您从“今天的位置”提升“您自己”,而不是达到某个想象中的标准。
-
本课程最重要的开场考虑因素:给出您通过本课程学习所需的时间。每个人的学习方式都不同。如果一开始某件事没有做好,请记住,随着时间的推移,您的技能会不断增长。
-
如果这是您第一次上计算机科学课程,请不要害怕!对于大多数同学来说,这也是他们的第一次计算机科学课程!此外,助教、课程助理和您的同伴社区都在这里帮助您!
社区!
-
您是哈佛学院、哈佛大学扩展学院以及通过 edX.org 参加此课程的学习者社区的一部分。
-
如果您是哈佛校园的学生,您可以参加 CS50 午餐和CS50 黑客马拉松。
计算机科学和问题解决
-
从本质上讲,计算机编程是关于接收一些输入并创建一些输出——从而解决问题。输入和输出之间发生的事情,我们称之为“黑盒”,是本课程的重点。
![带有输入和输出的黑盒 带有输入和输出的黑盒]()
-
例如,我们可能需要为课程点名。我们可以使用名为一进制(也称为基-1)的系统逐个计数手指。
-
今天的计算机使用称为二进制的系统进行计数。我们从这个术语二进制位得到了一个熟悉的术语,称为比特。一个比特是一个零或一:开启或关闭。
-
计算机只使用零和一进行交流。零代表关闭。一代表开启。计算机由数百万甚至数十亿的晶体管组成,这些晶体管被打开和关闭。
-
如果你想象使用一个灯泡,单个灯泡只能从零计数到一。
-
然而,如果你有三个灯泡,你就有更多的选择!
-
在你的 iPhone 中,有数百万被称为晶体管的小灯泡,它们使这个设备能够进行每天可能被视为理所当然的活动。
-
作为一种启发式方法,我们可以想象以下值代表我们二进制位中的每个可能位置:
4 2 1 -
使用三个灯泡,以下可以表示零:
4 2 1 0 0 0 -
同样,以下表示一个:
4 2 1 0 0 1 -
按照这个逻辑,我们可以提出以下等于二:
4 2 1 0 1 0 -
进一步扩展这个逻辑,以下表示三个:
4 2 1 0 1 1 -
四将表示为:
4 2 1 1 0 0 -
实际上,我们只使用三个灯泡就可以计数到七!
4 2 1 1 1 1 -
计算机使用二进制计数。这可以表示如下:
2^2 2^1 2^0 4 2 1 -
因此,可以说要表示高达七的数字,需要三个比特(四位、二位和一位)。
-
同样,为了计数高达八的数字,值将表示如下:
8 4 2 1 1 0 0 0 -
计算机通常使用八个比特(也称为一个字节)来表示一个数字。例如,
00000101是二进制中的数字 5。11111111代表数字 255。你可以想象零如下:128 64 32 16 8 4 2 1 0 0 0 0 0 0 0 0
ASCII
-
正如数字是零和一的二进制模式一样,字母也是用零和一表示的!
-
由于表示数字和字母的零和一之间存在重叠,因此创建了ASCII标准来将特定的字母映射到特定的数字。
-
例如,字母
A被决定映射到数字 65。01000001在二进制中表示数字 65。你可以这样想象:128 64 32 16 8 4 2 1 0 1 0 0 0 0 0 1 -
如果你收到一条短信,那条短信下的二进制可能代表数字 72、73 和 33。将这些映射到 ASCII,你的信息如下所示:
H I ! 72 73 33 -
感谢像 ASCII 这样的标准,它使我们能够就这些值达成一致!
-
这里是 ASCII 值的扩展映射:
0 NUL 16 DLE 32 SP 48 0 64 @ 80 P 96 ` 112 p 1 SOH 17 DC1 33 ! 49 1 65 A 81 Q 97 a 113 q 2 STX 18 DC2 34 ” 50 2 66 B 82 R 98 b 114 r 3 ETX 19 DC3 35 # 51 3 67 C 83 S 99 c 115 s 4 EOT 20 DC4 36 $ 52 4 68 D 84 T 100 d 116 t 5 ENQ 21 NAK 37 % 53 5 69 E 85 U 101 e 117 u 6 ACK 22 SYN 38 & 54 6 70 F 86 V 102 f 118 v 7 BEL 23 ETB 39 ’ 55 7 71 G 87 W 103 g 119 w 8 BS 24 CAN 40 ( 56 8 72 H 88 X 104 h 120 x 9 HT 25 EM 41 ) 57 9 73 I 89 Y 105 i 121 y 10 LF 26 SUB 42 * 58 : 74 J 90 Z 106 j 122 z 11 VT 27 ESC 43 + 59 ; 75 K 91 [ 107 k 123 { 12 FF 28 FS 44 , 60 < 76 L 92 \ 108 l 124 13 CR 29 GS 45 - 61 = 77 M 93 ] 109 m 125 } 14 SO 30 RS 46 . 62 > 78 N 94 ^ 110 n 126 ~ 15 SI 31 US 47 / 63 ? 79 O 95 _ 111 o 127 DEL -
如果您愿意,您可以了解更多关于ASCII的信息。
-
由于二进制只能计数到255,我们受限于 ASCII 表示的字符数量。
Unicode
-
随着时间的推移,通过文本进行交流的方式越来越多。
-
由于二进制中没有足够的数字来表示人类可以表示的所有各种字符,Unicode标准扩展了计算机可以传输和理解的位数。Unicode 不仅包括特殊字符,还包括 emoji。
-
有一些 emoji 您可能每天都在使用。以下可能对您来说很熟悉:
😀 😃 😄 😁 😆 😅 😂 🙂 🙃 😉 😊 😇 😍 😘 😗 😙 😚 😋 😛 😜 😝 🤑 🤓 😎 🤗 😏 😶 😐 😑 😒 🙄 😬 😕 ☹️ 😟 😮 😯 😲 😳 😦 😧 😨
-
尽管在 Unicode 中零和一的模式是标准化的,但每个设备制造商可能比另一个制造商稍微不同地显示每个 emoji。
-
Unicode 标准正在添加越来越多的功能来表示更多的字符和 emoji。
-
如果您愿意,您可以了解更多关于Unicode的信息。
-
如果您愿意,您可以了解更多关于emoji的信息。
RGB
-
零和一可以用来表示颜色。
-
红色、绿色和蓝色(称为
RGB)是三个数字的组合。![红绿蓝盒子,红色值为 72,绿色值为 73,蓝色值为 33 红绿蓝盒子]()
-
使用我们之前使用的 72、73 和 33,通过文本表示
HI!,会被图像阅读器解释为浅黄色。红色值是 72,绿色值是 73,蓝色值是 33。![黄色盒子 黄色盒子]()
-
代表红色、蓝色和绿色(或RGB)各种颜色的三个字节构成了任何数字图像中每个像素(或点)的颜色。图像仅仅是 RGB 值的集合。
-
零和一可以用来表示图像、视频和音乐!
-
视频是由许多存储在一起的图像组成的序列,就像一本翻页书一样。
-
音乐可以使用各种字节的组合以类似的方式表示。
算法
-
问题解决是计算机科学和计算机编程的核心。算法是一系列逐步指令,用于解决问题。
-
想象一下在电话簿中尝试找到单个名字的基本问题。
-
如何进行这种操作呢?
-
一种方法可能是简单地从第一页读到下一页,再读到下一页,直到到达最后一页。
-
另一种方法可能是每次搜索两页。
-
最后一种可能更好一点的方法可能是去电话簿的中间,问:“我要找的名字是在左边还是右边?”然后,重复这个过程,将问题分成一半,再分成一半,再分成一半。
-
每种方法都可以称为算法。这些算法的速度可以用所谓的大 O 表示法来表示如下:
![大 O 表示法 大 O 表示法]()
注意,第一个算法,用红色突出显示的,其大 O 为
n,因为如果电话簿中有 100 个名字,可能需要尝试多达 100 次才能找到正确的名字。第二个算法,每次搜索两页,其大 O 为n/2,因为我们通过页面的速度是两倍。最后一个算法的大 O 为 log[2]n,因为问题加倍只会导致解决问题的关键步骤增加一个。 -
程序员将基于文本的人类指令翻译成代码。
伪代码
-
将指令转换为代码的过程称为伪代码。
-
能够创建伪代码对于在这个课程和计算机编程中的成功至关重要。
-
伪代码是你代码的人类可读版本。例如,考虑上面的第三个算法,我们可以编写如下伪代码:
1 Pick up phone book 2 Open to middle of phone book 3 Look at page 4 If person is on page 5 Call person 6 Else if person is earlier in book 7 Open to middle of left half of book 8 Go back to line 3 9 Else if person is later in book 10 Open to middle of right half of book 11 Go back to line 3 12 Else 13 Quit -
伪代码是一种非常重要的技能,至少有两个原因。首先,在你创建正式代码之前进行伪代码,这让你能够提前思考问题的逻辑。其次,当你进行伪代码时,你可以后来向寻求理解你的编码决策和代码工作方式的人提供这些信息。
-
注意,我们伪代码中的语言有一些独特的特点。首先,其中一些行以动词开头,如拿起、打开、查看。稍后,我们将这些称为函数。
-
第二,注意一些行包括像
if或else if这样的语句。这些被称为条件语句。 -
第三,注意有些表达式可以表述为真或假,例如“人在书的更早位置。”我们称之为布尔表达式。
-
最后,注意其中有一些语句,如“回到第 3 行。”我们称之为循环。
-
这些构建块是编程的基础。
-
在以下讨论的Scratch环境中,我们将使用上述每个编程的基本构建块。
人工智能
-
考虑如何利用上述构建块来开始创建我们自己的人工智能。看看以下伪代码:
If student says hello Say hello Else if student says goodbye Say goodbye Else if student asks how you are Say well Else if student asks why 111 in binary is 7 in decimal ...注意,仅仅为了编程少量交互,就需要许多行代码。要编程成千上万或数万种可能的交互,需要多少行代码?
-
与上述编程对话式 AI 不同,AI 程序员在大型数据集上训练大型语言模型(LLMs)。
-
LLMs 会观察大量语言中的模式。这些语言模型试图猜测单词之间或旁边的最佳猜测。
-
虽然基于 AI 的软件在生活和工作的许多领域都非常有用,但我们规定,除了 CS50 自家的软件外,使用其他基于 AI 的软件是不合理的。
-
CS50 自家的 AI 软件工具CS50.ai是一个 AI 助手,您可以在本课程中使用它。它将帮助您,但不会给出课程问题的全部答案。
-
在本课程中,您不得使用任何 AI,除了CS50.ai。
前方展望
-
本周您将学习 Scratch,一种可视化编程语言。
-
然后,在未来的几周里,您将学习 C 语言。它看起来可能如下:
#include <stdio.h> int main(void) { printf("hello, world\n"); } -
通过学习 C 语言,您将为未来在其他编程语言(如Python)中的学习做好准备。
-
此外,随着课程的进行,您将学习算法。
-
C 语言之所以具有挑战性,是因为标点符号。今天我们将暂时放下这些标点符号和语法,我们将只使用一种名为 Scratch 的编程语言中的思想。
Scratch
-
Scratch是由麻省理工学院开发的一种可视化编程语言。
-
Scratch 使用了我们在本讲座中早期介绍过的相同的基本编码构建块。
-
Scratch 是一个很好的入门计算机编程的方式,因为它允许您以可视化的方式玩这些构建块,而不必担心大括号、分号、括号等语法。
-
Scratch
IDE(集成开发环境)看起来如下:![scratch interface scratch interface]()
注意,在左侧有一个您可以在编程中使用的小组件调色板。在组件调色板的正右方,是您可以拖动组件来构建程序的区域。再往右,您可以看到猫站立的舞台。舞台是您的编程变得生动的地方。
-
Scratch 在以下坐标系统上运行:
![scratch coordinate system scratch coordinate system]()
注意,舞台的中心位于坐标(0,0)。目前,猫的位置就在那个位置。
Hello World
-
首先,将“当绿色旗帜点击”积木拖动到编程区域。然后,将
say积木拖动到编程区域,并将其连接到前面的积木。when green flag clicked say [hello, world]注意,现在当你点击舞台上的绿色旗帜时,猫会说,“你好,世界。”
-
这很好地说明了我们之前讨论的编程内容:
![使用黑盒划痕 使用黑盒划痕]()
注意,输入
hello, world被传递给函数say,而这个函数运行的副作用是猫说hello, world。
你好,您
-
我们可以通过让猫对特定的人说“你好”来使你的程序更具交互性。按如下方式修改你的程序:
when green flag clicked ask [What's your name?] and wait say (join [hello,] (answer))注意,当点击绿色旗帜时,会运行函数
ask。程序会提示你,用户,你叫什么名字?然后程序将这个名字存储在名为answer的变量中。然后程序将answer传递给一个特殊函数join,该函数将两个文本字符串hello和提供的任何名字结合起来。实际上,answer返回一个值给join。这些共同传递给say函数。猫说Hello,和一个名字。你的程序现在具有交互性。 -
在整个课程中,你将向算法提供输入并获取输出(或副作用)。这可以用上述程序如下表示:
![hello 和 answer 提供以连接以获取 hello david 作为算法的 Scratch]()
注意,输入
hello,和answer被提供给join,导致副作用为hello, David。 -
类似地,我们可以按如下方式修改我们的程序:
when green flag clicked ask [What's your name?] and wait speak (join [hello,] (answer))注意,当点击绿色旗帜时,这个程序将相同的变量,与
hello连接,传递给一个名为speak的函数。
喵喵和抽象
-
除了伪代码,抽象是计算机编程中的一个基本技能和概念。
-
抽象是将问题简化为越来越小的问题的行为。
-
例如,如果你要为你的朋友们举办一场盛大的晚宴,必须亲自烹饪整个晚宴的问题可能会相当令人压倒!然而,如果你将烹饪晚宴的任务分解成越来越小的任务(或问题),那么制作这道美味佳肴的大任务可能会感觉不那么具有挑战性。
-
在编程中,甚至在 Scratch 中,我们都可以看到抽象的作用。在你的编程区域中,编写如下程序:
when green flag clicked play sound (Meow v) until done wait (1) seconds play sound (Meow v) until done wait (1) seconds play sound (Meow v) until done注意到你在反复做同样的事情。确实,如果你发现自己反复编写相同的语句,那么你很可能能够更巧妙地编程——通过抽象移除重复的代码。
-
你可以按如下方式修改你的代码:
when green flag clicked repeat (3) play sound (Meow v) until done wait (1) seconds注意,循环确实与上一个程序做了同样的事情。然而,问题通过将重复抽象到一个为我们重复代码的块中而简化了。
-
我们甚至可以通过使用
define积木来进一步改进,在那里您可以创建自己的积木(自己的函数)!按照以下方式编写代码:define meow play sound (Meow v) until done wait (1) seconds when green flag clicked repeat (3) meow注意,我们正在定义一个名为
meow的自定义积木。该函数播放声音meow,然后等待一秒钟。下面,您可以看到当点击绿色标志时,我们的meow函数会重复三次。 -
我们甚至可以提供一个方法,使函数可以接受输入
n并重复多次:define meow n times repeat (n) play sound [meow v] until done wait (1) seconds注意
n是从 “meow n times” 中获取的。n通过define积木传递给meow函数。 -
总体来说,注意这个改进过程如何导致代码质量不断提高。此外,注意我们如何创建自己的算法来解决一个问题。您将在整个课程中练习这两项技能。
条件语句
-
条件语句 是编程的必要构建块,其中程序会检查是否满足特定条件。如果满足条件,程序就会执行某些操作。
-
为了说明条件语句,编写以下代码:
when green flag clicked forever if <touching (mouse-pointer v)?> then play sound (Meow v) until done注意,
forever积木被用来触发if积木,使其可以不断地检查猫是否触摸到鼠标指针。 -
我们可以按照以下方式修改我们的程序,以集成视频感应:
when video motion > (10) play sound (Meow v) until done -
记住,编程通常是一个试错的过程。如果你感到沮丧,花时间通过谈话来解决问题。你现在正在解决的具体问题是什么?什么在起作用?什么不起作用?
Oscartime
-
Oscartime 是大卫自己的 Scratch 程序之一——尽管音乐可能因为他在创建此程序时听了很长时间而让他感到困扰。花几分钟时间亲自玩一下这个游戏。
-
我们自己构建 Oscartime,首先添加路灯。
![oscartime 界面 oscartime 界面]()
-
然后,编写以下代码:
when green flag clicked switch costume to (oscar1 v) forever if <touching (mouse-pointer v)?> then switch costume to (oscar2 v) else switch costume to (oscar1 v)注意,将鼠标移到奥斯卡身上会改变他的服装。您可以通过探索这些代码积木了解更多信息。
-
然后,按照以下方式修改您的代码,以创建一个下落的垃圾块:
when green flag clicked go to x: (pick random (-240) to (240)) y: (180) forever if <(distance to (floor v)) > (0)> then change y by (-3)注意,垃圾在 y 轴上的位置始终从 180° 开始。x 位置是随机的。当垃圾在地板上方时,它会每次下落 3 像素。您可以通过探索这些代码积木了解更多信息。
-
接下来,按照以下方式修改您的代码,以便允许拖动垃圾。
when green flag clicked forever if <<mouse down?> and <touching (mouse-pointer v) ?>> then go to (mouse-pointer v)您可以通过探索这些代码积木了解更多信息。
-
接下来,我们可以按照以下方式实现得分变量:
when green flag clicked forever if <touching (Oscar v) ?> then change (score) by (1) go to x: (pick random (-240) to (240)) y: (180)您可以通过探索这些代码积木了解更多信息。
-
尝试完整的游戏 Oscartime。
Ivy 的最难游戏
-
从 Oscartime 转移到 Ivy 的最难游戏,我们现在可以想象如何在程序中实现移动。
-
我们程序有三个主要组件。
-
首先,编写如下代码:
when green flag clicked go to x: (0) y: (0) forever listen for keyboard feel for walls注意到当点击绿色标志时,我们的精灵会移动到舞台中心坐标(0,0),然后监听键盘并检查墙壁,永远如此。
-
其次,添加这组代码块:
define listen for keyboard if <key (up arrow v) pressed?> then change y by (1) end if <key (down arrow v) pressed?> then change y by (-1) end if <key (right arrow v) pressed?> then change x by (1) end if <key (left arrow v) pressed?> then change x by (-1) end注意到我们创建了一个自定义的
监听键盘脚本。对于键盘上的每个箭头键,它都会将精灵在屏幕上移动。 -
最后,添加这组代码块:
define feel for walls if <touching (left wall v) ?> then change x by (1) end if <touching (right wall v) ?> then change x by (-1) end注意到我们还有一个自定义的
感觉墙壁脚本。当精灵碰到墙壁时,它会将其移回安全位置——防止它走出屏幕。 -
你可以通过探索这些代码块了解更多信息。
-
Scratch 允许同时屏幕上有许多精灵。
-
添加另一个精灵,将以下代码块添加到你的程序中:
when green flag clicked go to x: (0) y: (0) point in direction (90) forever if <<touching (left wall v)?> or <touching (right wall v)?>> then turn right (180) degrees end move (1) steps end注意到耶鲁精灵似乎通过来回移动阻碍了哈佛精灵。当它碰到墙壁时,它会转身直到再次碰到墙壁。你可以通过探索这些代码块了解更多信息。
-
你甚至可以让一个精灵跟随另一个精灵。添加另一个精灵,将以下代码块添加到你的程序中:
when green flag clicked go to (random position v) forever point towards (Harvard v) move (1) steps注意到麻省理工学院的标志现在似乎围绕着哈佛学院的标志。你可以通过探索这些代码块了解更多信息。
-
尝试完整的游戏Ivy’s Hardest Game。
总结
在本节课中,你学习了这门课程如何位于计算机科学和编程的广阔世界中。你学习了……
-
很少有学生在来这门课之前有编程经验!
-
你并不孤单!你是这个社区的一部分。
-
计算机科学家工作中解决问题的本质。
-
这门课程不仅仅是关于编程——这门课程将向你介绍一种新的学习方法,你几乎可以将其应用到生活的各个领域。
-
数字、文本、图像、音乐和视频是如何被计算机理解和表示的。
-
假设编程的基本编程技能。
-
在本课程中利用 AI 的合理和不合理方式。
-
抽象如何在你的未来课程工作中发挥作用。
-
编程的基本构建块,包括函数、条件、循环和变量。
-
如何在 Scratch 中构建项目。
这就是 CS50!欢迎加入我们!下次再见!
第一讲
-
欢迎!
-
CS50 的 Visual Studio Code
-
Hello World
-
从零开始学 C
-
头文件和 CS50 手册页
-
你好,你
-
类型
-
条件语句
-
运算符
-
变量
-
compare.c
-
agree.c
-
循环和 meow.c
-
函数
-
正确性、设计、风格
-
马里奥
-
注释
-
更多关于运算符
-
截断
-
总结
欢迎!
-
在我们之前的课程中,我们学习了 Scratch,一种可视化编程语言。
-
的确,Scratch 中展示的所有基本编程概念,在你学习如何编写任何编程语言时都会用到。Scratch 中的函数、条件、循环和变量是任何编程语言中的基本构建块。
-
回想一下,机器只理解二进制。人类编写源代码,即一系列供人类阅读的计算机指令列表,而机器只理解我们现在可以称之为机器代码的内容。这种机器代码是一串一和零的模式,产生预期的效果。
-
结果表明,我们可以使用一个非常特别的软件工具,称为编译器,将源代码转换为机器代码。今天,我们将向您介绍一个编译器,它将允许您将编程语言C的源代码转换为机器代码。
-
今天,除了学习如何编程之外,你还将学习如何编写优秀的代码。
CS50 的 Visual Studio Code
-
本课程所使用的文本编辑器是Visual Studio Code,也称为VS Code,亲切地被称为cs50.dev,可以通过相同的 URL 访问。
-
我们使用 VS Code 的一个重要原因是因为它已经预装了课程所需的全部软件。本课程和其中的指导都是针对 VS Code 设计的。
-
在自己的计算机上手动安装课程所需的必要软件是一件繁琐头疼的事情。最好始终使用 VS Code 来完成本课程的作业。
-
你可以在cs50.dev打开 VS Code。
-
编译器可以被分为几个区域:
注意左侧有一个文件资源管理器,你可以在这里找到你的文件。此外,注意中间有一个称为文本编辑器的区域,你可以在这里编辑你的程序。最后,还有一个称为命令行界面、CLI、命令行或终端窗口的区域,我们可以在这里向云端的计算机发送命令。 -
在终端窗口中,我们可能会使用的一些常见命令行参数包括:
-
cd,用于更改我们的当前目录(文件夹) -
cp,用于复制文件和目录 -
ls,用于列出目录中的文件 -
mkdir,用于创建目录 -
mv,用于移动(重命名)文件和目录 -
rm,用于删除(删除)文件 -
rmdir,用于删除(删除)目录
-
-
最常用的命令是
ls,它会列出当前目录中的所有文件。请在终端窗口中输入ls并按enter键。你会看到当前文件夹中的所有文件。 -
由于这个 IDE 预先配置了所有必要的软件,你应该用它来完成这门课程的所有作业。
Hello World
-
我们将使用三个命令来编写、编译和运行我们的第一个程序:
code hello.c make hello ./hello第一个命令
code hello.c创建了一个文件,并允许我们为这个程序输入指令。第二个命令make hello将我们的 C 语言指令编译成名为hello的可执行文件。最后一个命令./hello运行名为hello的程序。 -
我们可以通过在终端窗口中输入
code hello.c来构建你的第一个 C 程序。注意我们故意将整个文件名转换为小写,并包含了.c扩展名。然后,在出现的文本编辑器中,编写如下代码:// A program that says hello to the world #include <stdio.h> int main(void) { printf("hello, world\n"); }注意,上面的每一个字符都有其作用。如果你输入错误,程序将无法运行。
printf是一个可以输出文本行的函数。注意引号和分号的位置。此外,注意\n在hello, world之后创建了一个新行。 -
点击回到终端窗口,你可以通过执行
make hello来编译你的代码。注意我们省略了.c。make是一个编译器,它会查找我们的hello.c文件并将其转换成名为hello的程序。如果执行此命令没有错误,你可以继续。如果有错误,请仔细检查你的代码,确保它与上面的一致。 -
现在,输入
./hello,你的程序将执行并显示hello, world。 -
现在,打开左侧的文件资源管理器。你会注意到现在有两个文件,一个叫做
hello.c,另一个叫做hello。hello.c可以被编译器读取:这是你的代码存储的地方。hello是一个可执行文件,你可以运行它,但不能被编译器读取。
从零开始到 C 语言
-
在 Scratch 中,我们使用了
say模块来在屏幕上显示任何文本。实际上,在 C 语言中,我们有一个名为printf的函数,它正好做这件事。 -
注意我们的代码已经调用了这个函数:
printf("hello, world\n");注意到调用了
printf函数。传递给printf的参数是hello, world\n。代码的语句以一个;结束。 -
代码中的错误很常见。按照以下方式修改你的代码:
// \n is missing #include <stdio.h> int main(void) { printf("hello, world"); }注意到
\n现在消失了。 -
在你的终端窗口中,运行
make hello。在终端窗口中输入./hello,你的程序是如何改变的?这个\字符被称为转义字符,它告诉编译器\n是一个创建换行的特殊指令。 -
你还可以使用其他转义字符:
\n create a new line \r return to the start of a line \" print a double quote \' print a single quote \\ print a backslash -
将你的程序恢复到以下内容:
// A program that says hello to the world #include <stdio.h> int main(void) { printf("hello, world\n"); }注意分号和
\n已经恢复。
头文件和 CS50 手册页
-
代码开头的语句
#include <stdio.h>是一个非常特殊的命令,它告诉编译器你想使用一个名为stdio.h的 库 的功能,这是一个 头文件。这允许你,在许多其他事情中,使用printf函数。 -
库 是由某人创建的代码集合。库是包含其他人过去编写的预写代码和函数的集合,我们可以在我们的代码中利用它们。
-
你可以在 手册页 中了解这个库的所有功能。手册页提供了一种更好地理解各种命令做什么以及它们如何工作的方式。
-
结果表明,CS50 有自己的库,名为
cs50.h。其中包含了许多函数,这些函数在你开始学习 C 时提供了 辅助轮:get_char get_double get_float get_int get_long get_string -
让我们在你的程序中使用这个库。
你好,用户
-
记得在 Scratch 中,我们有能力询问用户,“你的名字是什么?”然后用这个名字附加到“hello”上。
-
在 C 中,我们可以做同样的事情。按照以下方式修改你的代码:
// get_string and printf with incorrect placeholder #include <stdio.h> int main(void) { string answer = get_string("What's your name? "); printf("hello, answer\n"); }get_string函数用于从用户那里获取一个字符串。然后,将变量answer传递给printf函数。 -
在终端窗口中再次运行
make hello,注意出现了许多错误。 -
通过查看错误,我们发现
string和get_string没有被编译器识别。我们必须通过添加一个名为cs50.h的库来教编译器这些特性。同时,我们注意到answer没有按照预期提供。按照以下方式修改你的代码:// get_string and printf with %s #include <cs50.h> #include <stdio.h> int main(void) { string answer = get_string("What's your name? "); printf("hello, %s\n", answer); }get_string函数用于从用户那里获取一个字符串。然后,变量answer被传递给printf函数。%s告诉printf函数准备接收一个string。 -
现在,在终端窗口中再次运行
make hello,你可以通过输入./hello来运行你的程序。程序现在会询问你的名字,然后按照预期用你的名字打招呼。 -
answer是我们称为 变量 的特殊存储位置。answer是string类型,可以存储任何字符串。有许多 数据类型,例如int、bool、char以及许多其他类型。 -
%s是一个称为 格式代码 的占位符,它告诉printf函数准备接收一个string。answer是传递给%s的string。
类型
-
printf允许使用许多格式代码。以下是一个不全面的列表,你可以在本课程中使用:%c %f %i %li %s%s用于string变量。%i用于int或整数变量。你可以在 手册页 中了解更多信息。 -
这些格式代码对应于 C 中可用的许多数据类型:
bool char float int long string ... -
在本课程中,我们将使用许多 C 的可用数据类型。
条件语句
-
在 Scratch 中,你使用的另一个构建块是条件语句。例如,你可能想在 x 大于 y 的情况下做一件事。进一步,如果条件不满足,你可能想做另一件事。
-
我们来看几个 Scratch 中的例子。
-
在 C 语言中,你可以这样比较两个值:
// Conditionals that are mutually exclusive if (x < y) { printf("x is less than y\n"); } else { printf("x is not less than y\n"); }注意如果
x < y,会发生一个结果。如果x不小于y,则发生另一个结果。 -
类似地,我们可以为三种可能的结果进行规划:
// Conditional that isn't necessary if (x < y) { printf("x is less than y\n"); } else if (x > y) { printf("x is greater than y\n"); } else if (x == y) { printf("x is equal to y\n"); }注意,并非所有这些代码行都是必需的。我们如何消除上述不必要的计算呢?
-
你可能已经猜到我们可以这样改进这段代码:
// Compare integers if (x < y) { printf("x is less than y\n"); } else if (x > y) { printf("x is greater than y\n"); } else { printf("x is equal to y\n"); }注意最后一条语句被替换为
else。
运算符
-
运算符指的是你的编译器支持的数学运算。在 C 语言中,这些数学运算符包括:
-
+表示加法 -
-表示减法 -
*表示乘法 -
/表示除法 -
%表示余数
-
-
我们将在本课程中使用所有这些运算符。
变量
-
在 C 语言中,你可以按照以下方式为一个
int或整数赋值:int counter = 0;注意一个名为
counter的int类型变量被赋值为0。 -
C 语言也可以按照以下方式将
1加到counter上:counter = counter + 1;注意
1是如何加到counter的值上的。 -
这也可以表示为:
counter += 1; -
这可以进一步简化为:
counter++;注意
++是如何用来加 1 的。 -
你也可以按照以下方式从
counter中减去一个值:counter--;注意
1是如何从counter的值中减去的。
compare.c
-
使用关于如何为变量赋值的新知识,你可以编写你的第一个条件语句。
-
在终端窗口中,输入
code compare.c并编写以下代码:// Conditional, Boolean expression, relational operator #include <cs50.h> #include <stdio.h> int main(void) { // Prompt user for integers int x = get_int("What's x? "); int y = get_int("What's y? "); // Compare integers if (x < y) { printf("x is less than y\n"); } }注意我们创建了两个变量,一个名为
x的int或整数,另一个名为y。这些值是通过get_int函数填充的。 -
你可以通过在终端窗口中执行
make compare然后./compare来运行你的代码。如果你收到任何错误消息,请检查你的代码是否有误。 -
流程图是检查计算机程序功能的一种方式。此类图表可以用来检查我们代码的效率。
-
通过查看上述代码的流程图,我们可以注意到许多不足之处。
-
我们可以通过以下方式改进你的程序:
// Conditionals #include <cs50.h> #include <stdio.h> int main(void) { // Prompt user for integers int x = get_int("What's x? "); int y = get_int("What's y? "); // Compare integers if (x < y) { printf("x is less than y\n"); } else if (x > y) { printf("x is greater than y\n"); } else { printf("x is equal to y\n"); } }注意现在已经考虑了所有潜在的结果。
-
你可以重新制作并运行你的程序,进行测试。
-
通过流程图检查这个程序,你可以看到我们代码设计决策的效率。
agree.c
-
考虑另一种数据类型,即
char,我们可以通过在终端窗口中输入code agree.c来启动一个新的程序。 -
在这里,一个
string是一系列字符,而char是一个单个字符。 -
在文本编辑器中,编写以下代码:
// Comparing against lowercase char #include <cs50.h> #include <stdio.h> int main(void) { // Prompt user to agree char c = get_char("Do you agree? "); // Check whether agreed if (c == 'y') { printf("Agreed.\n"); } else if (c == 'n') { printf("Not agreed.\n"); } }注意单引号用于单个字符。此外,注意
==确保某物等于另一物,而在 C 语言中,单个等号会有非常不同的功能。 -
您可以通过在终端窗口中键入
make agree来测试您的代码,然后键入./agree。 -
我们还可以允许输入大写和小写字符:
// Comparing against lowercase and uppercase char #include <cs50.h> #include <stdio.h> int main(void) { // Prompt user to agree char c = get_char("Do you agree? "); // Check whether agreed if (c == 'y') { printf("Agreed.\n"); } else if (c == 'Y') { printf("Agreed.\n"); } else if (c == 'n') { printf("Not agreed.\n"); } else if (c == 'N') { printf("Not agreed.\n"); } }注意到提供了额外的选项。然而,这不是高效的代码。
-
我们可以按如下方式改进此代码:
// Logical operators #include <cs50.h> #include <stdio.h> int main(void) { // Prompt user to agree char c = get_char("Do you agree? "); // Check whether agreed if (c == 'Y' || c == 'y') { printf("Agreed.\n"); } else if (c == 'N' || c == 'n') { printf("Not agreed.\n"); } }注意到
||实际上意味着或。
循环和 meow.c
-
我们还可以在我们的 C 程序中使用 Scratch 中的循环构建块。
-
在您的终端窗口中,键入
code meow.c并编写如下代码:// Opportunity for better design #include <stdio.h> int main(void) { printf("meow\n"); printf("meow\n"); printf("meow\n"); }注意这确实按预期工作,但有一个更好的设计机会。代码被一次又一次地重复。
-
我们可以通过修改您的代码来改进我们的程序如下:
// Better design #include <stdio.h> int main(void) { int i = 3; while (i > 0) { printf("meow\n"); i--; } }注意到我们创建了一个名为
i的int并将其赋值为3。然后,我们创建了一个while循环,只要i > 0就会运行。然后,循环运行。每次使用i--语句从i中减去1。 -
同样,我们可以通过修改我们的代码来实现一种计数增加:
// Print values of i #include <stdio.h> int main(void) { int i = 1; while (i <= 3) { printf("meow\n"); i++; } }注意到我们的计数器
i从1开始。每次循环运行时,它将计数器增加1。一旦计数器大于3,循环将停止。 -
通常,在计算机科学中,我们从零开始计数。最好将您的代码修改如下:
// Better design #include <stdio.h> int main(void) { int i = 0; while (i < 3) { printf("meow\n"); i++; } }注意我们现在从零开始计数。
-
我们工具箱中的另一个循环工具是
for循环。 -
您还可以使用
for循环进一步改进我们的meow.c程序的设计。修改您的代码如下:// Better design #include <stdio.h> int main(void) { for (int i = 0; i < 3; i++) { printf("meow\n"); } }注意到
for循环包含三个参数。第一个参数int i = 0将计数器从零开始。第二个参数i < 3是正在检查的条件。最后,参数i++告诉循环每次循环运行时增加一。 -
我们甚至可以使用以下代码无限循环:
// Infinite loop #include <cs50.h> #include <stdio.h> int main(void) { while (true) { printf("meow\n"); } }注意到
true始终为真。因此,代码将始终运行。运行此代码将导致您失去对终端窗口的控制。您可以通过按键盘上的control-C来从无限循环中退出。
函数
-
虽然我们将在稍后提供更多指导,但您可以在 C 中创建自己的函数如下:
void meow(void) { printf("meow\n"); }初始的
void表示该函数不返回任何值。(void)表示没有值被提供给函数。 -
此函数可以在主函数中使用如下:
// Abstraction #include <stdio.h> void meow(void); int main(void) { for (int i = 0; i < 3; i++) { meow(); } } // Meow once void meow(void) { printf("meow\n"); }注意到
meow函数是如何通过meow()指令被调用的。这是因为meow函数定义在代码底部,而函数的原型在代码顶部提供,作为void meow(void)。 -
您的
meow函数可以进一步修改以接受输入:// Abstraction with parameterization #include <stdio.h> void meow(int n); int main(void) { meow(3); } // Meow some number of times void meow(int n) { for (int i = 0; i < n; i++) { printf("meow\n"); } }注意到原型已更改为
void meow(int n),以显示meow接受一个int作为其输入。 -
此外,我们还可以获取用户输入:
// User input #include <cs50.h> #include <stdio.h> void meow(int n); int main(void) { int n; do { n = get_int("Number: "); } while (n < 1); meow(n); } // Meow some number of times void meow(int n) { for (int i = 0; i < n; i++) { printf("meow\n"); } }注意到
get_int用于从用户那里获取一个数字。n被传递给meow。 -
我们甚至可以测试以确保我们获取的用户提供的输入是正确的:
// Return value #include <cs50.h> #include <stdio.h> int get_positive_int(void); void meow(int n); int main(void) { int n = get_positive_int(); meow(n); } // Get number of meows int get_positive_int(void) { int n; do { n = get_int("Number: "); } while (n < 1); return n; } // Meow some number of times void meow(int n) { for (int i = 0; i < n; i++) { printf("meow\n"); } }注意有一个名为
get_positive_int的新函数在n < 1时要求用户输入一个整数。在获取一个正整数后,此函数将return n回main函数。
正确性、设计、风格
-
代码可以从三个轴上进行评估。
-
首先,正确性指的是“代码是否按预期运行?”你可以使用
check50来检查你的代码的正确性。 -
其次,设计指的是“代码设计得有多好?”你可以使用
design50来评估你的代码设计。 -
最后,风格指的是“代码在美学上有多吸引人,是否一致?”你可以使用
style50来评估你的代码风格。
Mario
-
我们今天讨论的所有内容都集中在作为新兴计算机科学家的工作的各种构建块上。
-
以下将帮助你定位本课程的问题集:如何处理与计算机科学相关的问题?
-
假设我们想要模拟超级马里奥兄弟游戏的视觉效果。考虑到图中显示的四个问号块,我们如何创建代表这四个水平块的代码?
![Mario Question Marks Mario Question Marks]()
-
在终端窗口中,键入
code mario.c并按照以下方式编写代码:// Prints a row of 4 question marks with a loop #include <stdio.h> int main(void) { for (int i = 0; i < 4; i++) { printf("?"); } printf("\n"); }注意这里是如何使用循环打印四个问号的。
-
同样,我们可以应用相同的逻辑来创建三个垂直块。
![Mario Blocks Mario Blocks]()
-
为了实现这一点,按照以下方式修改你的代码:
// Prints a column of 3 bricks with a loop #include <stdio.h> int main(void) { for (int i = 0; i < 3; i++) { printf("#\n"); } }注意使用循环打印了三个垂直砖块。
-
如果我们想要将这些想法结合起来创建一个三乘三的块组?
![Mario Grid Mario Grid]()
-
我们可以遵循上述逻辑,结合相同的思想。按照以下方式修改你的代码:
// Prints a 3-by-3 grid of bricks with nested loops #include <stdio.h> int main(void) { for (int i = 0; i < 3; i++) { for (int j = 0; j < 3; j++) { printf("#"); } printf("\n"); } }注意一个循环嵌套在另一个循环中。第一个循环定义了正在打印的垂直行。对于每一行,打印三列。每打印完一行后,打印一个新行。
-
如果我们想要确保块的数量是恒定的,也就是说,不可改变的,请按照以下方式修改你的代码:
// Prints a 3-by-3 grid of bricks with nested loops using a constant #include <stdio.h> int main(void) { const int n = 3; for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { printf("#"); } printf("\n"); } }注意现在
n是一个常量。它永远不能改变。 -
如本讲座中前面所展示的,我们可以将功能抽象化到函数中。考虑以下代码:
// Helper function #include <stdio.h> void print_row(int width); int main(void) { const int n = 3; for (int i = 0; i < n; i++) { print_row(n); } } void print_row(int width) { for (int i = 0; i < width; i++) { printf("#"); } printf("\n"); }注意打印一行是通过一个新函数完成的。
注释
-
注释是计算机程序的基本组成部分,你在这里留下解释性的备注,供自己和其他可能与你合作的人理解你的代码。
-
为这门课程创建的所有代码都必须包含健壮的注释。
-
通常,每个注释都是几个词或更多,为读者提供了理解特定代码块中发生的事情的机会。此外,这样的注释在你需要修改代码时可以作为提醒。
-
注释涉及在代码中插入
//,然后跟上一个注释。按照以下方式修改你的代码以集成注释:// Helper function #include <stdio.h> void print_row(int width); int main(void) { const int n = 3; // Print n rows for (int i = 0; i < n; i++) { print_row(n); } } void print_row(int width) { for (int i = 0; i < width; i++) { printf("#"); } printf("\n"); }注意每个注释都是以
//开头的。
更多关于运算符的内容
-
你可以用 C 语言实现一个计算器。在你的终端中,输入
code calculator.c并编写如下代码:// Addition with int #include <cs50.h> #include <stdio.h> int main(void) { // Prompt user for x int x = get_int("x: "); // Prompt user for y int y = get_int("y: "); // Add numbers int z = x + y; // Perform addition printf("%i\n", z); }注意
get_int函数是如何被用来从用户那里获取两个整数的。一个整数存储在名为x的int变量中。另一个存储在名为y的int变量中。总和存储在z中。然后,printf函数通过%i符号打印z的值。 -
我们还可以将一个数字加倍:
// int #include <cs50.h> #include <stdio.h> int main(void) { int dollars = 1; while (true) { char c = get_char("Here's $%i. Double it and give to next person? ", dollars); if (c == 'y') { dollars *= 2; } else { break; } } printf("Here's $%i.\n", dollars); }运行这个程序,
dollars中出现了一些看似错误的信息。为什么会出现这种情况? -
C 语言的一个缺点是它管理内存的容易性。虽然 C 提供了对你如何利用内存的巨大控制,但程序员必须非常注意内存管理的潜在陷阱。
-
类型指的是可以存储在变量中的可能数据。例如,
char是为了适应单个字符,如a或2而设计的。 -
类型非常重要,因为每种类型都有特定的限制。例如,由于内存的限制,
int的最大值可以是4294967295。如果你尝试计数超过int的值,将会发生 整数溢出,导致这个变量存储了错误值。 -
比特数限制了我们可以计数的最高和最低值。
-
这可能对现实世界产生灾难性的影响。
-
我们可以通过使用名为
long的数据类型来纠正这一点。// long #include <cs50.h> #include <stdio.h> int main(void) { long dollars = 1; while (true) { char c = get_char("Here's $%li. Double it and give to next person? ", dollars); if (c == 'y') { dollars *= 2; } else { break; } } printf("Here's $%li.\n", dollars); }注意运行这段代码将允许出现非常高的金额。
-
在本课程中你可能会遇到的数据类型包括:
-
bool,一个表示真或假的布尔表达式 -
char,单个字符,如 a 或 2 -
double,一个比float有更多数字的浮点值 -
float,一个浮点值,或带有小数点的实数 -
int,一定大小或比特数的整数 -
long,具有更多比特的整数,因此它们可以比int计数更高 -
string,字符序列
-
截断
-
使用数据类型时可能出现的另一个问题是截断。
// Division with ints, demonstrating truncation #include <cs50.h> #include <stdio.h> int main(void) { // Prompt user for x int x = get_int("x: "); // Prompt user for y int y = get_int("y: "); // Divide x by y printf("%i\n", x / y); }在 C 语言中,整数除以整数总是得到整数。因此,上面的代码通常会丢弃小数点后的任何数字。
-
这可以通过使用
float来解决:// Floats #include <cs50.h> #include <stdio.h> int main(void) { // Prompt user for x float x = get_float("x: "); // Prompt user for y float y = get_float("y: "); // Divide x by y printf("%.50f\n", x / y); }注意这解决了我们的一些问题。然而,我们可能会注意到程序提供的答案中的不精确性。
-
浮点数不精确说明了计算机计算数字的精确度是有限的。
-
在编码过程中,请注意你使用的变量类型,以避免代码中的问题。
-
我们检查了一些由于类型错误可能发生的灾难性例子。
总结
在本课中,你学习了如何将你在 Scratch 中学到的构建块应用到 C 编程语言中。你学习了……
-
如何创建你的第一个 C 程序。
-
如何使用命令行。
-
关于 C 语言自带的一些预定义函数。
-
如何使用变量、条件和循环。
-
如何创建你自己的函数以简化并改进你的代码。
-
如何从三个维度评估你的代码:正确性、设计和风格。
-
如何将注释整合到你的代码中。
-
如何利用类型和运算符以及你选择的影响。
次次见!
第二讲
-
欢迎光临!
-
阅读级别
-
编译
-
调试
-
数组
-
字符串
-
字符串长度
-
命令行参数
-
退出状态
-
密码学
-
总结
欢迎光临!
-
在我们之前的会话中,我们学习了 C,一种基于文本的编程语言。
-
这周,我们将更深入地研究额外的构建块,这将支持我们从底层学习更多关于编程的目标。
-
基本上,除了编程的基本要素外,本课程还关于问题解决。因此,我们还将进一步关注如何解决计算机科学问题。
-
到课程结束时,您将学习如何使用上述构建块来解决一系列计算机科学问题。
阅读级别
-
我们在本课程中将解决的现实世界问题之一是理解阅读级别。
-
在一些同伴的帮助下,我们以不同的阅读级别进行了阅读。
-
这周,我们将量化阅读级别,作为您众多编程挑战之一。
编译
-
加密 是将明文隐藏起来不被窥视的行为。因此,解密 就是将加密的文本片段转换回人类可读形式的行为。
-
加密的文本片段可能看起来像以下这样:
U I J T J T D T 5 0 -
回想一下,上周您学习了 编译器,这是一种专门计算机程序,它将 源代码 转换为计算机可以理解的 机器代码。
-
例如,您可能有一个看起来像这样的计算机程序:
#include <stdio.h> int main(void) { printf("hello, world\n"); } -
编译器将上述代码转换为以下机器代码:
![机器代码 机器代码]()
-
VS Code,作为 CS50 学生提供的编程环境,使用了一个名为
clang或 C 语言 的编译器。 -
您可以将以下内容输入到终端窗口以编译您的代码:
clang -o hello hello.c。 -
命令行参数 以
-o hello hello.c的形式在命令行中提供给clang。 -
在终端窗口中运行
./hello,您的程序将按预期运行。 -
考虑以下上周的代码:
#include <cs50.h> #include <stdio.h> int main(void) { string name = get_string("What's your name? "); printf("hello, %s\n", name); } -
要编译此代码,您可以输入
clang -o hello hello.c -lcs50。 -
如果您输入
make hello,它将运行一个命令,执行 clang 创建一个您可以运行的输出文件。 -
VS Code 已经预先编程,以便
make可以运行多个命令行参数,并配合 clang 为您作为用户带来便利。 -
虽然上述内容提供了一种说明,以便您更深入地理解编译代码的过程和概念,但在 CS50 中使用
make是完全正常且符合预期的! -
编译涉及以下四个主要步骤:
-
首先,预处理是将你的代码中的头文件(由
#指定,例如#include <cs50.h>)有效地复制并粘贴到你的文件中。在这一步中,cs50.h中的代码被复制到你的程序中。同样,就像你的代码包含#include <stdio.h>一样,计算机上某个地方的stdio.h中的代码也被复制到你的程序中。这一步可以可视化如下:string get_string(string prompt); int printf(string format, ...); int main(void) { string name = get_string("What's your name? "); printf("hello, %s\n", name); } -
第二,编译是将你的程序转换为汇编代码。这一步可以可视化如下:
... main: .cfi_startproc # BB#0: pushq %rbp .Ltmp0: .cfi_def_cfa_offset 16 .Ltmp1: .cfi_offset %rbp, -16 movq %rsp, %rbp .Ltmp2: .cfi_def_cfa_register %rbp subq $16, %rsp xorl %eax, %eax movl %eax, %edi movabsq $.L.str, %rsi movb $0, %al callq get_string movabsq $.L.str.1, %rdi movq %rax, -8(%rbp) movq -8(%rbp), %rsi movb $0, %al callq printf ... -
第三,汇编涉及编译器将你的汇编代码转换为机器代码。这一步可以可视化如下:
01111111010001010100110001000110 00000010000000010000000100000000 00000000000000000000000000000000 00000000000000000000000000000000 00000001000000000011111000000000 00000001000000000000000000000000 00000000000000000000000000000000 00000000000000000000000000000000 00000000000000000000000000000000 00000000000000000000000000000000 10100000000000100000000000000000 00000000000000000000000000000000 00000000000000000000000000000000 01000000000000000000000000000000 00000000000000000100000000000000 00001010000000000000000100000000 01010101010010001000100111100101 01001000100000111110110000010000 00110001110000001000100111000111 01001000101111100000000000000000 00000000000000000000000000000000 00000000000000001011000000000000 11101000000000000000000000000000 00000000010010001011111100000000 00000000000000000000000000000000 00000000000000000000000001001000 ... -
最后,在链接步骤中,你的包含库中的代码也被转换为机器代码,并与你的代码结合。然后输出最终的可执行文件。
![链接 链接]()
调试
-
每个人在编码时都会犯错误。
-
调试是定位和移除代码中错误的过程。
-
在本课程中,你将使用的一种调试代码的技术被称为橡皮鸭调试,其中你可以与一个非生命物体(或你自己)交谈,以帮助你思考代码以及为什么它没有按预期工作。当你遇到代码挑战时,考虑一下大声地向一个橡皮鸭说出代码问题。如果你不想和一个塑料小鸭说话,你也可以和附近的人交谈!
-
我们创建了 CS50 Duck 和CS50.ai作为可以帮助你调试代码的工具。
-
考虑上周的以下图片:
![马里奥 马里奥]()
-
考虑以下故意在其内部插入错误的代码:
// Buggy example for printf #include <stdio.h> int main(void) { for (int i = 0; i <= 3; i++) { printf("#\n"); } }注意,这段代码打印了四个方块而不是三个。
-
在终端窗口中输入
code buggy0.c并写下上述代码。 -
运行这段代码,会出现四个砖块而不是预期的三个。
-
printf是一种非常有用的调试代码的方法。你可以按照以下方式修改你的代码:// Buggy example for printf #include <stdio.h> int main(void) { for (int i = 0; i <= 3; i++) { printf("i is %i\n", i); printf("#\n"); } }注意,这段代码在循环的每次迭代中都输出了
i的值,这样我们就可以调试我们的代码。 -
运行这段代码,你会看到许多语句,包括
i is 0、i is 1、i is 2和i is 3。看到这些,你可能会意识到需要进一步修正以下代码:#include <stdio.h> int main(void) { for (int i = 0; i < 3; i++) { printf("#\n"); } }注意
<=已被替换为<。 -
这段代码可以进一步改进如下:
// Buggy example for debug50 #include <cs50.h> #include <stdio.h> void print_column(int height); int main(void) { int h = get_int("Height: "); print_column(h); } void print_column(int height) { for (int i = 0; i <= height; i++) { printf("#\n"); } }注意,编译和运行这段代码仍然会导致错误。
-
为了解决这个问题,我们将使用一个新的工具。
-
调试的第二个工具被称为调试器,这是一种由程序员创建的软件工具,用于帮助追踪代码中的错误。
-
在 VS Code 中,已经为你提供了一个预配置的调试器。
-
要使用这个调试器,首先通过点击代码左侧,位于行号左侧的行来设置一个断点。当你点击那里时,你会看到一个红色的小点出现。想象一下这是一个停车标志,要求编译器暂停,这样你就可以考虑这段代码中发生的事情。
![断点 断点]()
-
第二,运行
debug50 ./buggy0。你会注意到,当调试器启动后,你的一行代码将以金色般的颜色点亮。字面上说,代码就在这一行代码处暂停了。注意左上角显示了所有局部变量,包括当前没有值的h。在你的窗口顶部,你可以点击step over按钮,它会继续移动通过你的代码。注意h的值是如何增加的。 -
虽然这个工具不会显示你的错误在哪里,但它会帮助你放慢速度,逐步查看你的代码是如何运行的。你可以使用
step into来进一步查看有问题的代码的细节。
数组
-
在第 0 周,我们讨论了诸如
bool、int、char、string等的数据类型。 -
每种数据类型都需要一定数量的系统资源:
-
bool1 字节 -
int4 字节 -
long8 字节 -
float4 字节 -
double8 字节 -
char1 字节 -
string? 字节
-
-
在你的计算机内部,你有有限的内存可用。
![内存 内存]()
-
在物理上,在你的计算机内存中,你可以想象特定类型的数据是如何存储在你的计算机上的。你可能想象一个
char,它只需要 1 个字节的内存,可能看起来如下所示:![1 字节 1 字节]()
-
同样,一个需要 4 个字节的
int可能看起来如下所示:![4 字节 4 字节]()
-
我们可以创建一个探索这些概念的程序。在你的终端中,输入
code scores.c并编写以下代码:// Averages three (hardcoded) numbers #include <stdio.h> int main(void) { // Scores int score1 = 72; int score2 = 73; int score3 = 33; // Print average printf("Average: %f\n", (score1 + score2 + score3) / 3.0); }注意右边的数字是一个
3.0的浮点值,所以计算最终呈现为浮点值。 -
运行
make scores,程序运行。 -
你可以想象这些变量是如何存储在内存中的:
![内存中的分数 内存中的分数]()
-
数组 是在内存中连续存储的值序列。
-
int scores[3]是告诉编译器为你提供三个连续的内存位置,大小为int,以存储三个scores。考虑到我们的程序,你可以按照以下方式修改你的代码:// Averages three (hardcoded) numbers using an array #include <cs50.h> #include <stdio.h> int main(void) { // Scores int scores[3]; scores[0] = 72; scores[1] = 73; scores[2] = 33; // Print average printf("Average: %f\n", (scores[0] + scores[1] + scores[2]) / 3.0); }注意
score[0]通过indexing into名为scores的数组在位置0的内存位置来检查这个位置的值,以查看存储了什么值。 -
你可以看到,虽然上面的代码可以工作,但仍然有机会改进我们的代码。按照以下方式修改你的代码:
// Averages three numbers using an array and a loop #include <cs50.h> #include <stdio.h> int main(void) { // Get scores int scores[3]; for (int i = 0; i < 3; i++) { scores[i] = get_int("Score: "); } // Print average printf("Average: %f\n", (scores[0] + scores[1] + scores[2]) / 3.0); }注意我们如何通过使用
scores[i]来索引scores,其中i由for循环提供。 -
我们可以简化或抽象出平均值的计算。按照以下方式修改你的代码:
// Averages three numbers using an array, a constant, and a helper function #include <cs50.h> #include <stdio.h> // Constant const int N = 3; // Prototype float average(int length, int array[]); int main(void) { // Get scores int scores[N]; for (int i = 0; i < N; i++) { scores[i] = get_int("Score: "); } // Print average printf("Average: %f\n", average(N, scores)); } float average(int length, int array[]) { // Calculate average int sum = 0; for (int i = 0; i < length; i++) { sum += array[i]; } return sum / (float) length; }注意声明了一个新函数
average。此外,注意声明了一个const或常量值N。最重要的是,注意average函数接受int array[],这意味着编译器将数组传递给这个函数。 -
不仅数组可以作为容器:它们可以在函数之间传递。
字符串
-
一个
string简单地是一个类型为char的变量数组:一个字符数组。 -
要探索
char和string,在终端窗口中输入code hi.c并按照以下方式编写代码:// Prints chars #include <stdio.h> int main(void) { char c1 = 'H'; char c2 = 'I'; char c3 = '!'; printf("%c%c%c\n", c1, c2, c3); }注意这将输出一个字符字符串。
-
同样,对你的代码进行以下修改:
#include <stdio.h> int main(void) { char c1 = 'H'; char c2 = 'I'; char c3 = '!'; printf("%i %i %i\n", c1, c2, c3); }注意通过将
%c替换为%i来打印 ASCII 码。 -
考虑以下图像,你可以看到字符串是一个以第一个字符开始并以一个称为
NUL 字符的特殊字符结束的字符数组:![hi with terminator hi with terminator]()
-
想象一下以十进制表示,你的数组看起来如下所示:
![hi with decimal hi with decimal]()
-
为了进一步理解
string的工作方式,按照以下方式修改你的代码:// Treats string as array #include <cs50.h> #include <stdio.h> int main(void) { string s = "HI!"; printf("%c%c%c\n", s[0], s[1], s[2]); }注意
printf语句如何从我们的数组s中呈现三个值。 -
如前所述,我们可以将
%c替换为%i,如下所示:// Prints string's ASCII codes, including NUL #include <cs50.h> #include <stdio.h> int main(void) { string s = "HI!"; printf("%i %i %i %i\n", s[0], s[1], s[2], s[3]); }注意这会打印出字符串的 ASCII 码,包括 NUL。
-
让我们想象我们想要说
HI!和BYE!。按照以下方式修改你的代码:// Multiple strings #include <cs50.h> #include <stdio.h> int main(void) { string s = "HI!"; string t = "BYE!"; printf("%s\n", s); printf("%s\n", t); }注意在这个例子中声明并使用了两个字符串。
-
你可以这样可视化:
![hi and bye hi and bye]()
-
我们可以进一步改进这段代码。按照以下方式修改你的代码:
// Array of strings #include <cs50.h> #include <stdio.h> int main(void) { string words[2]; words[0] = "HI!"; words[1] = "BYE!"; printf("%s\n", words[0]); printf("%s\n", words[1]); }注意到这两个字符串都存储在单个类型为
string的数组中。 -
我们可以将两个字符串合并到一个字符串数组中。
#include <cs50.h> #include <stdio.h> int main(void) { string words[2]; words[0] = "HI!"; words[1] = "BYE!"; printf("%c%c%c\n", words[0][0], words[0][1], words[0][2]); printf("%c%c%c%c\n", words[1][0], words[1][1], words[1][2], words[1][3]); }注意创建了一个
words的数组。它是一个字符串数组。每个单词都存储在words中。
字符串长度
-
编程中一个常见的问题,也许在 C 语言中更为具体,是发现数组的长度。我们如何在代码中实现这一点?在终端窗口中输入
code length.c并按照以下方式编写代码:// Determines the length of a string #include <cs50.h> #include <stdio.h> int main(void) { // Prompt for user's name string name = get_string("Name: "); // Count number of characters up until '\0' (aka NUL) int n = 0; while (name[n] != '\0') { n++; } printf("%i\n", n); }注意到这段代码会一直循环,直到找到 NUL 字符。
-
我们可以通过将计数抽象到函数中来改进这段代码,如下所示:
// Determines the length of a string using a function #include <cs50.h> #include <stdio.h> int string_length(string s); int main(void) { // Prompt for user's name string name = get_string("Name: "); int length = string_length(name); printf("%i\n", length); } int string_length(string s) { // Count number of characters up until '\0' (aka NUL) int n = 0; while (s[n] != '\0') { n++; } return n; }注意到有一个新函数
string_length被调用,它计算字符直到找到 NUL。 -
由于这是编程中一个如此常见的问题,其他程序员已经在
string.h库中创建了代码来查找字符串的长度。你可以通过修改以下方式来查找字符串的长度:// Determines the length of a string using a function #include <cs50.h> #include <stdio.h> #include <string.h> int main(void) { // Prompt for user's name string name = get_string("Name: "); int length = strlen(name); printf("%i\n", length); }注意,这段代码使用了在文件顶部声明的
string.h库。此外,它使用该库中的一个函数strlen,该函数计算传递给它的字符串的长度。 -
我们的代码可以站在前人的肩膀上,并使用他们创建的库。
-
ctype.h是另一个非常有用的库。想象一下,如果我们想要创建一个将所有小写字母转换为大写字母的程序。在终端窗口中,输入code uppercase.c并编写以下代码:// Uppercases a string #include <cs50.h> #include <stdio.h> #include <string.h> int main(void) { string s = get_string("Before: "); printf("After: "); for (int i = 0, n = strlen(s); i < n; i++) { if (s[i] >= 'a' && s[i] <= 'z') { printf("%c", s[i] - 32); } else { printf("%c", s[i]); } } printf("\n"); }注意,这段代码会遍历字符串中的每个值。程序会查看每个字符。如果字符是小写的,它会从该字符中减去
32的值以将其转换为大写。 -
回顾上周我们之前的工作,你可能还记得这个 ASCII 值表:
0 NUL 16 DLE 32 SP 48 0 64 @ 80 P 96 ` 112 p 1 SOH 17 DC1 33 ! 49 1 65 A 81 Q 97 a 113 q 2 STX 18 DC2 34 ” 50 2 66 B 82 R 98 b 114 r 3 ETX 19 DC3 35 # 51 3 67 C 83 S 99 c 115 s 4 EOT 20 DC4 36 $ 52 4 68 D 84 T 100 d 116 t 5 ENQ 21 NAK 37 % 53 5 69 E 85 U 101 e 117 u 6 ACK 22 SYN 38 & 54 6 70 F 86 V 102 f 118 v 7 BEL 23 ETB 39 ’ 55 7 71 G 87 W 103 g 119 w 8 BS 24 CAN 40 ( 56 8 72 H 88 X 104 h 120 x 9 HT 25 EM 41 ) 57 9 73 I 89 Y 105 i 121 y 10 LF 26 SUB 42 * 58 : 74 J 90 Z 106 j 122 z 11 VT 27 ESC 43 + 59 ; 75 K 91 [ 107 k 123 { 12 FF 28 FS 44 , 60 < 76 L 92 \ 108 l 124 13 CR 29 GS 45 - 61 = 77 M 93 ] 109 m 125 } 14 SO 30 RS 46 . 62 > 78 N 94 ^ 110 n 126 ~ 15 SI 31 US 47 / 63 ? 79 O 95 _ 111 o 127 DEL -
当从一个小写字母中减去
32时,它将得到该字符的大写版本。 -
虽然程序完成了我们想要的功能,但使用
ctype.h库有一个更简单的方法。按照以下方式修改你的程序:// Uppercases string using ctype library (and an unnecessary condition) #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <string.h> int main(void) { string s = get_string("Before: "); printf("After: "); for (int i = 0, n = strlen(s); i < n; i++) { if (islower(s[i])) { printf("%c", toupper(s[i])); } else { printf("%c", s[i]); } } printf("\n"); }注意,程序会遍历字符串中的每个字符。
toupper函数传递了s[i]。每个字符(如果为小写)都会被转换为大写。 -
值得注意的是,
toupper函数会自动识别并只将小写字母转换为大写。因此,你可以将代码简化如下:// Uppercases string using ctype library #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <string.h> int main(void) { string s = get_string("Before: "); printf("After: "); for (int i = 0, n = strlen(s); i < n; i++) { printf("%c", toupper(s[i])); } printf("\n"); }注意,这段代码使用
ctype库将字符串转换为大写。 -
你可以在手册页面上阅读有关
ctype库所有功能的说明。
命令行参数
-
命令行参数是那些在命令行传递给程序的参数。例如,所有你在clang后面输入的语句都被认为是命令行参数。你可以在自己的程序中使用这些参数! -
在你的终端窗口中,输入
code greet.c并编写如下代码:// Uses get_string #include <cs50.h> #include <stdio.h> int main(void) { string answer = get_string("What's your name? "); printf("hello, %s\n", answer); }注意,这个程序对用户说
hello。 -
尽管如此,如果在程序运行之前就能接受参数不是很好吗?按照以下方式修改你的代码:
// Prints a command-line argument #include <cs50.h> #include <stdio.h> int main(int argc, string argv[]) { if (argc == 2) { printf("hello, %s\n", argv[1]); } else { printf("hello, world\n"); } }注意,这个程序知道
argc,即命令行参数的数量,以及argv,它是一个包含在命令行中传递的字符的数组。 -
因此,使用本程序的语法,执行
./greet David将导致程序输出hello, David。 -
你可以使用以下方式打印每个命令行参数:
// Prints command-line arguments #include <cs50.h> #include <stdio.h> int main(int argc, string argv[]) { for (int i = 0; i < argc; i++) { printf("%s\n", argv[i]); } }
退出状态
-
当程序结束时,提供给计算机一个特殊的退出码。
-
当程序无错误退出时,提供给计算机的状态码为
0。通常,当发生导致程序结束的错误时,计算机提供的状态为1。 -
你可以编写一个程序如下,通过输入
code status.c并编写如下代码来展示这一点:// Returns explicit value from main #include <cs50.h> #include <stdio.h> int main(int argc, string argv[]) { if (argc != 2) { printf("Missing command-line argument\n"); return 1; } printf("hello, %s\n", argv[1]); return 0; }注意,如果你没有提供
./status David,你将得到退出状态1。然而,如果你确实提供了./status David,你将得到退出状态0。 -
你可以在终端中输入
echo $?来查看上一个运行命令的退出状态。 -
你可以想象如何使用上述程序的部分来检查用户是否提供了正确的命令行参数数量。
密码学
-
密码学是加密和解密消息的艺术。
-
现在,有了数组、字符和字符串的构建块,你可以加密和解密一条消息。
-
plaintext和一个key被提供给一个cipher,从而生成加密文本。![密码学 密码学]()
-
密钥是一个特殊的参数,与明文一起传递给加密器。加密器使用密钥来做出关于如何实现其加密算法的决定。
-
这周,你将进行与上述类似的编程挑战。
总结
在本课中,你学习了更多关于编译和计算机内部数据存储的细节。具体来说,你学习了…
-
通常,编译器是如何工作的。
-
如何使用四种方法调试你的代码。
-
如何在你的代码中利用数组。
-
数组如何在内存的连续部分存储数据。
-
字符串是如何简单地成为字符数组。
-
如何在你的代码中与数组交互。
-
如何将命令行参数传递给你的程序。
-
密码学的基本构建块。
欢迎下次再来!
第三讲
-
欢迎光临!
-
线性查找
-
二分查找
-
运行时间
-
search.c
-
phonebook.c
-
结构体
-
排序
-
冒泡排序
-
递归
-
归并排序
-
总结
欢迎光临!
-
在第零周,我们介绍了算法的概念:一个可能接受输入并产生输出的黑盒。
-
本周,我们将通过伪代码和实际代码来扩展我们对算法的理解。
-
此外,我们还将考虑这些算法的效率。实际上,我们将基于我们对如何使用上周讨论的一些概念来构建算法的理解。
-
回想一下课程早期我们介绍过的以下图表:
![complexity 图表:以“问题规模”为 x 轴;“解决问题所需时间”为 y 轴;红色,从原点到图表顶部的陡峭直线接近黄色,从原点到图表顶部的较平缓直线,两者均标记为“n”;绿色,从原点到图表右侧逐渐变平缓的曲线,标记为“log n”]()
-
随着我们步入本周,你应该考虑一个算法如何与问题协同工作可能会决定解决问题所需的时间!算法可以被设计得越来越高效,直至极限。
-
今天,我们将关注算法的设计以及如何衡量它们的效率。
线性查找
-
回想一下,上周你被介绍到数组的概念,即连续的内存块:彼此并排。
-
你可以比喻性地想象一个数组就像一系列七个红锁,如下所示:
![lockers 七排并排的红锁]()
-
最左侧的位置称为位置 0或数组的开始。最右侧的位置是位置 6或数组的结束。
-
我们可以想象,我们有一个基本问题,即想知道,“数字 50 是否在数组中?”计算机必须查看每个锁,以便能够看到数字 50 是否在里面。我们将寻找此类数字、字符、字符串或其他项的过程称为搜索。
-
我们可以将我们的数组交给一个算法,然后我们的算法将搜索我们的锁,看看数字 50 是否在某个门后面,返回值
true或false。![lockers as algorithm 七个红锁指向一个空盒子。从空盒子中出来一个 bool 类型的输出]()
-
我们可以想象我们可能提供给算法的各种指令以执行此任务,如下所示:
For each door from left to right If 50 is behind door Return true Return false注意,上述指令被称为伪代码:我们可以提供给计算机的指令的人类可读版本。
-
计算机科学家可以将伪代码翻译如下:
For i from 0 to n-1 If 50 is behind doors[i] Return true Return false注意,上面的内容仍然不是代码,但它是对最终代码可能的样子一个非常接近的近似。
二分搜索
-
二分搜索是另一种可以用于我们寻找 50 的任务中的搜索算法。
-
假设锁内的值已经从小到大排列,二分搜索的伪代码如下所示:
If no doors left Return false If 50 is behind middle door Return true Else if 50 < middle door Search left half Else if 50 > middle door Search right half -
使用代码的命名法,我们可以进一步修改我们的算法如下:
If no doors left Return false If 50 is behind doors[middle] Return true Else if 50 < doors[middle] Search doors[0] through doors[middle - 1] Else if 50 > doors[middle] Search doors[middle + 1] through doors[n - 1]注意,通过查看这个代码的近似,你几乎可以想象出这在实际代码中可能的样子。
运行时间
-
你可以考虑算法解决问题所需的时间。
-
运行时间涉及到使用大 O符号的分析。看看下面的图表:
![大 O 图表 图表:以“问题规模”为 x 轴;“解决问题所需时间”为 y 轴;红色,从原点到图表顶部的陡峭直线接近黄色,从原点到图表顶部的较平缓直线;绿色,从原点到图表右侧逐渐变平缓的曲线,均标注为“O(n)”;绿色,从原点到图表右侧逐渐变平缓的曲线,标注为“O(log n"]()
-
计算机科学家在讨论算法的效率时,不是对算法的数学效率进行超具体分析,而是用各种运行时间的顺序来讨论效率。
-
在上面的图表中,第一个算法是 (O(n)) 或 n 的阶数。第二个也是 (O(n))。第三个是 (O(\log n))。
-
它是曲线的形状,显示了算法的效率。我们可能会看到一些常见的运行时间:
-
(O(n²))
-
(O(n \log n))
-
(O(n))
-
(O(\log n))
-
(O(1))
-
-
在上述运行时间中,(O(n²))被认为是运行时间最慢的。(O(1))是最快的。
-
线性搜索的阶数为 (O(n)),因为它在最坏情况下可能需要 n 步才能运行。
-
二分搜索的阶数为 (O(\log n)),因为它在运行时将越来越少,即使在最坏情况下也是如此。
-
程序员对最坏情况,或上界,和最佳情况,或下界都感兴趣。
-
(\Omega) 符号用来表示算法的最佳情况,例如 (\Omega(\log n))。
-
(\Theta) 符号用来表示上界和下界相同的地方:最佳情况和最坏情况的运行时间相同的地方。
-
渐近符号是用来衡量算法在输入越来越大时表现如何的度量。
-
随着你继续在计算机科学领域发展你的知识,你将在未来的课程中更详细地探索这些主题。
search.c
-
你可以通过在终端窗口中输入
code search.c并编写如下代码来实现线性搜索:// Implements linear search for integers #include <cs50.h> #include <stdio.h> int main(void) { // An array of integers int numbers[] = {20, 500, 10, 5, 100, 1, 50}; // Search for number int n = get_int("Number: "); for (int i = 0; i < 7; i++) { if (numbers[i] == n) { printf("Found\n"); return 0; } } printf("Not found\n"); return 1; }注意,以
int numbers[]开头的行允许我们在创建数组时定义每个元素的值。然后,在for循环中,我们有线性搜索的实现。return 0用于指示成功并退出程序。return 1用于带错误(失败)退出程序。 -
我们现在已经在 C 中自己实现了线性搜索!
-
如果我们想在数组中搜索一个字符串呢?修改你的代码如下:
// Implements linear search for strings #include <cs50.h> #include <stdio.h> #include <string.h> int main(void) { // An array of strings string strings[] = {"battleship", "boot", "cannon", "iron", "thimble", "top hat"}; // Search for string string s = get_string("String: "); for (int i = 0; i < 6; i++) { if (strcmp(strings[i], s) == 0) { printf("Found\n"); return 0; } } printf("Not found\n"); return 1; }注意,我们无法像之前这个程序的迭代版本中那样使用
==。相反,我们使用strcmp,它来自string.h库。如果字符串相同,strcmp将返回0。另外,请注意,字符串长度6是硬编码的,这不是好的编程实践。 -
事实上,运行这段代码允许我们遍历这个字符串数组,查看是否包含某个特定的字符串。然而,如果你看到段错误,即程序访问了它不应访问的内存部分,请确保你有
i < 6而不是i < 7。 -
你可以在CS50 手册页面上了解更多关于
strcmp的信息。
phonebook.c
-
我们可以将数字和字符串的这些想法结合到一个程序中。在终端窗口中输入
code phonebook.c并编写如下代码:// Implements a phone book without structs #include <cs50.h> #include <stdio.h> #include <string.h> int main(void) { // Arrays of strings string names[] = {"Yuliia", "David", "John"}; string numbers[] = {"+1-617-495-1000", "+1-617-495-1000", "+1-949-468-2750"}; // Search for name string name = get_string("Name: "); for (int i = 0; i < 3; i++) { if (strcmp(names[i], name) == 0) { printf("Found %s\n", numbers[i]); return 0; } } printf("Not found\n"); return 1; }注意,Yuliia 的电话号码以
+1-617开头,David 的电话号码以+1-617开头,John 的电话号码以+1-949开头。因此,names[0]是 Yuliia,numbers[0]是 Yuliia 的电话号码。这段代码将允许我们在电话簿中搜索特定号码的人。 -
虽然这段代码能工作,但存在许多低效之处。实际上,存在一种可能性,即姓名和电话号码可能不匹配。如果我们可以创建自己的数据类型,将一个人与电话号码关联起来,那岂不是很好?
结构体
-
结果表明,C 语言允许我们通过
struct创建自己的数据类型。 -
创建一个包含
name和number的名为person的自定义数据类型不是很有用吗?考虑以下内容:typedef struct { string name; string number; } person;注意,这代表了我们自己的数据类型
person,它有一个名为name的字符串和一个名为number的字符串。 -
我们可以通过修改我们的电话簿程序来改进我们之前的代码:
// Implements a phone book with structs #include <cs50.h> #include <stdio.h> #include <string.h> typedef struct { string name; string number; } person; int main(void) { person people[3]; people[0].name = "Yuliia"; people[0].number = "+1-617-495-1000"; people[1].name = "David"; people[1].number = "+1-617-495-1000"; people[2].name = "John"; people[2].number = "+1-949-468-2750"; // Search for name string name = get_string("Name: "); for (int i = 0; i < 3; i++) { if (strcmp(people[i].name, name) == 0) { printf("Found %s\n", people[i].number); return 0; } } printf("Not found\n"); return 1; }注意,代码以
typedef struct开始,其中定义了一个新的数据类型person。在person内部有一个名为name的字符串和一个名为number的字符串。在main函数中,首先创建一个名为people的数组,其类型为person,大小为 3。然后,我们更新people数组中两个人的姓名和电话号码。最重要的是,注意如何使用点表示法,例如people[0].name,允许我们访问第 0 个位置的person并为其分配一个姓名。
排序
-
排序是将未排序的值列表转换为排序列表的行为。
-
当一个列表排序后,在该列表中搜索要远比在未排序的列表中搜索要耗费计算机更少的资源。回想一下,我们可以在有序列表上使用二分搜索,但不能在未排序的列表上使用。
-
结果表明,有许多不同的排序算法。
-
选择排序 是这样的排序算法之一。
-
我们可以这样表示一个数组:
![red lockers 七个并排的红锁,最后一个标记为 n-1]()
-
选择排序的伪代码如下:
For i from 0 to n–1 Find smallest number between numbers[i] and numbers[n-1] Swap smallest number with numbers[i] -
总结这些步骤,第一次遍历列表需要
n - 1步。第二次,它需要n - 2步。继续这个逻辑,所需的步骤可以表示如下:(n - 1) + (n - 2) + (n - 3) + ... + 1 -
这可以简化为 n(n-1)/2 或更简单地说,(O(n²))。在最坏情况或上界,选择排序的顺序为 (O(n²))。在最好情况或下界,选择排序的顺序为 (\Omega(n²))。
冒泡排序
-
冒泡排序 是另一种排序算法,它通过重复交换元素来“冒泡”较大的元素到末尾。
-
冒泡排序的伪代码如下:
Repeat n-1 times For i from 0 to n–2 If numbers[i] and numbers[i+1] out of order Swap them If no swaps Quit -
随着我们进一步排序数组,我们知道越来越多的部分变得有序,所以我们只需要查看尚未排序的数字对。
-
冒泡排序可以分析如下:
(n – 1) × (n – 1) n2 – 1n – 1n + 1 n2 – 2n + 1或者,更简单地说 (O(n²))。
-
在最坏情况下,或上界,冒泡排序的顺序为 (O(n²))。在最好情况下,或下界,冒泡排序的顺序为 (\Omega(n))。
-
您可以 可视化 这些算法的比较。
递归
-
我们如何提高我们的排序效率?
-
递归 是编程中的一个概念,其中函数调用自身。我们之前在看到……时看到了这一点。
If no doors left Return false If number behind middle door Return true Else if number < middle door Search left half Else if number > middle door Search right half注意,我们正在对这个问题越来越小的迭代调用
search。 -
类似地,在我们的第 0 周伪代码中,您可以看到递归是如何实现的:
1 Pick up phone book 2 Open to middle of phone book 3 Look at page 4 If person is on page 5 Call person 6 Else if person is earlier in book 7 Open to middle of left half of book 8 Go back to line 3 9 Else if person is later in book 10 Open to middle of right half of book 11 Go back to line 3 12 Else 13 Quit -
此代码可以简化以突出其递归特性,如下所示:
1 Pick up phone book 2 Open to middle of phone book 3 Look at page 4 If person is on page 5 Call person 6 Else if person is earlier in book 7 Search left half of book 9 Else if person is later in book 10 Search right half of book 12 Else 13 Quit -
考虑一下在第一周我们想要创建以下这样的金字塔结构:
# ## ### #### -
在您的终端窗口中输入
code iteration.c并编写如下代码:// Draws a pyramid using iteration #include <cs50.h> #include <stdio.h> void draw(int n); int main(void) { // Get height of pyramid int height = get_int("Height: "); // Draw pyramid draw(height); } void draw(int n) { // Draw pyramid of height n for (int i = 0; i < n; i++) { for (int j = 0; j < i + 1; j++) { printf("#"); } printf("\n"); } }注意,此代码通过循环构建金字塔。
-
要使用递归实现此功能,请在您的终端窗口中输入
code iteration.c并编写如下代码:// Draws a pyramid using recursion #include <cs50.h> #include <stdio.h> void draw(int n); int main(void) { // Get height of pyramid int height = get_int("Height: "); // Draw pyramid draw(height); } void draw(int n) { // If nothing to draw if (n <= 0) { return; } // Draw pyramid of height n - 1 draw(n - 1); // Draw one more row of width n for (int i = 0; i < n; i++) { printf("#"); } printf("\n"); }注意到 基准情况 将确保代码不会无限运行。当
if (n <= 0)时终止递归,因为问题已经解决。每次draw函数调用自身时,它都会通过n-1来调用自身。在某一点上,n-1将等于0,导致draw函数返回,程序结束。
归并排序
-
我们现在可以利用递归来寻求更有效的排序算法,并实现所谓的 归并排序,这是一种非常有效的排序算法。
-
归并排序的伪代码相当简短:
If only one number Quit Else Sort left half of number Sort right half of number Merge sorted halves -
考虑以下数字列表:
6341 -
首先,归并排序会问,“这是一个数字吗?”答案是“不是”,所以算法继续。
6341 -
第二,归并排序现在将数字从中间分开(或者尽可能接近中间)并排序数字的左半部分。
63|41 -
第三,归并排序将查看左边的这些数字并询问,“这是一个数字吗?”由于答案是“不是”,然后它会将左边的数字从中间分开。
6|3 -
第四,归并排序将再次询问,“这是一个数字吗?”这次答案是肯定的!因此,它将退出这个任务,并返回到此时正在运行的最后任务:
63|41 -
第五,归并排序将排序左边的数字。
36|41 -
现在,我们回到伪代码中我们之前中断的地方,因为左边的数字已经排序了。步骤 3-5 的类似过程将发生在右边的数字上。这将导致:
36|14 -
两个半部分现在都已排序。最后,算法将合并两边。它会查看左边的第一个数字和右边的第一个数字。它会将较小的数字放在前面,然后是第二小的数字。算法将对所有数字重复此操作,结果如下:
1346 -
归并排序已完成,程序退出。
-
归并排序是一个非常高效的排序算法,最坏情况下的时间复杂度为 (O(n \log n))。最佳情况仍然是 (\Omega(n \log n)),因为算法仍然必须访问列表中的每个位置。因此,归并排序的时间复杂度也是 (\Theta(n \log n)),因为最佳情况和最坏情况是相同的。
-
最后,可视化被分享。
总结
在本课中,你学习了算法思维和构建自己的数据类型。具体来说,你学习了…
-
算法。
-
大 O 表示法。
-
二分查找和线性查找。
-
各种排序算法,包括冒泡排序、选择排序和归并排序。
-
递归。
欢迎下次再来!
第四讲
-
欢迎!
-
像素艺术
-
十六进制
-
内存
-
指针
-
字符串
-
指针算术
-
字符串比较
-
复制和 malloc
-
Valgrind
-
垃圾值
-
与 Binky 一起玩指针
-
交换
-
溢出
-
scanf -
文件输入输出
-
总结
欢迎!
-
在前几周,我们讨论了图像是由称为像素的更小的构建块组成的。
-
今天,我们将更深入地探讨构成这些图像的零和一。特别是,我们将深入研究构成文件(包括图像)的基本构建块。
-
此外,我们还将讨论如何访问存储在计算机内存中的底层数据。
-
在今天开始之前,要知道本讲座中涵盖的概念可能需要一些时间才能完全 理解。
像素艺术
-
像素是方格,单个的点,颜色排列在上下的左右网格中。
-
你可以想象一个图像是一个位图,其中零代表黑色,一代表白色。
![smiley 零和一转换为黑白笑脸]()
十六进制
-
RGB,或 红色、绿色、蓝色,是代表每种颜色数量的数字。在 Adobe Photoshop 中,你可以看到这些设置如下:
![hex in photoshop 带有 RGB 值和十六进制输入的 Photoshop 面板]()
注意红色、蓝色和绿色数量的变化如何改变所选颜色。
-
从上面的图片中你可以看到,颜色不仅仅由三个值来表示。在窗口底部,有一个由数字和字符组成的特殊值。
255表示为FF。这可能是为什么? -
十六进制 是一个有 16 个计数值的计数系统。它们如下:
0 1 2 3 4 5 6 7 8 9 A B C D E F注意
F代表15。 -
十六进制也被称为 十六进制。
-
在十六进制计数时,每一列都是 16 的幂。
-
数字
0表示为00。 -
数字
1表示为01。 -
数字
9表示为09。 -
数字
10表示为0A。 -
数字
15表示为0F。 -
数字
16表示为10。 -
数字
255表示为FF,因为 16 x 15(或F)等于 240。再加上 15 得到 255。这是使用两位十六进制系统可以计数的最大数字。 -
十六进制之所以有用,是因为它可以用更少的数字来表示。十六进制允许我们更简洁地表示信息。
内存
-
在过去的几周里,你可能还记得我们关于并发内存块的艺术家渲染。将这些内存块应用十六进制编号,你可以这样可视化:
![memory hex 以 0x 编号的内存块]()
-
你可以想象,关于上面的
10块是否表示内存中的位置或值10可能会有混淆。因此,按照惯例,所有十六进制数通常都带有0x前缀,如下所示:![0x 以 0x 编号的内存块]()
-
在你的终端窗口中,键入
code addresses.c并按照以下方式编写你的代码:// Prints an integer #include <stdio.h> int main(void) { int n = 50; printf("%i\n", n); }注意到
n在内存中以值50存储的方式。 -
你可以这样可视化程序存储这个值的方式:
![hex 存储在内存位置中的值 50,以十六进制表示]()
指针
-
C 语言有两个与内存相关的强大运算符:
& Provides the address of something stored in memory. * Instructs the compiler to go to a location in memory. -
我们可以通过以下方式修改我们的代码来利用这一知识:
// Prints an integer's address #include <stdio.h> int main(void) { int n = 50; printf("%p\n", &n); }注意到
%p,它允许我们查看内存位置的地址。&n可以直译为“n的地址。”执行此代码将返回以0x开头的内存地址。 -
指针 是一个存储某个地址的变量。最简洁地说,指针是计算机内存中的一个地址。
-
考虑以下代码:
int n = 50; int *p = &n;注意到
p是一个指针,它包含一个整数n的地址。 -
按照以下方式修改你的代码:
// Stores and prints an integer's address #include <stdio.h> int main(void) { int n = 50; int *p = &n; printf("%p\n", p); }注意到这段代码与我们的前一段代码具有相同的效果。我们只是利用了我们对
&和*运算符的新知识。 -
为了说明
*运算符的使用,考虑以下:// Stores and prints an integer via its address #include <stdio.h> int main(void) { int n = 50; int *p = &n; printf("%i\n", *p); }注意到
printf行打印了p位置的整数。int *p创建了一个指针,其任务是存储一个整数的内存地址。 -
你可以这样可视化我们的代码:
![pointer 具有指针值存储在其他地方的内存位置中的相同值 50]()
注意到指针似乎相当大。事实上,指针通常存储为 8 字节值。
p存储的是50的地址。 -
你可以更准确地将指针想象为一个指向另一个地址的地址:
![pointer 指针作为箭头,从一个内存位置指向另一个内存位置]()
字符串
-
现在我们已经对指针有了心理模型,我们可以剥去之前在这门课程中提供的简化层次。
-
按照以下方式修改你的代码:
// Prints a string #include <cs50.h> #include <stdio.h> int main(void) { string s = "HI!"; printf("%s\n", s); }注意到打印了一个字符串
s。 -
回想一下,字符串只是一个字符数组。例如,
string s = "HI!"可以表示如下:![hi 存储在内存中的带有感叹号的字符串 HI]()
-
然而,
s究竟是什么?s在内存中的位置在哪里?正如你可以想象的那样,s需要存储在某个地方。你可以这样可视化s与字符串的关系:![hi pointer 指向它的指针的相同字符串 HI]()
注意到名为
s的指针告诉编译器字符串的第一个字节在内存中的位置。 -
按如下方式修改你的代码:
// Prints a string's address as well the addresses of its chars #include <cs50.h> #include <stdio.h> int main(void) { string s = "HI!"; printf("%p\n", s); printf("%p\n", &s[0]); printf("%p\n", &s[1]); printf("%p\n", &s[2]); printf("%p\n", &s[3]); }注意到上面的代码打印了字符串
s中每个字符的内存位置。&符号用于显示字符串中每个元素的地址。当运行此代码时,注意元素0、1、2和3在内存中是相邻的。 -
同样,你可以按如下方式修改你的代码:
// Declares a string with CS50 Library #include <cs50.h> #include <stdio.h> int main(void) { string s = "HI!"; printf("%s\n", s); }注意到这段代码将展示从
s位置开始的字符串。这段代码实际上移除了cs50.h提供的string数据类型的训练轮。这是原始的 C 代码,没有 cs50 库的框架。 -
取下训练轮,你可以再次修改你的代码:
// Declares a string without CS50 Library #include <stdio.h> int main(void) { char *s = "HI!"; printf("%s\n", s); }注意到
cs50.h已被移除。字符串被实现为char *。 -
你可以想象字符串作为数据类型是如何创建的。
-
上周,我们学习了如何创建自己的数据类型作为结构体。
-
cs50 库包括以下结构体:
typedef char *string -
当使用 cs50 库时,这个结构体允许使用一个自定义的数据类型,称为
string。
指针算术
-
指针算术是进行内存位置数学运算的能力。
-
你可以修改你的代码以打印字符串中的每个内存位置,如下所示:
// Prints a string's chars #include <stdio.h> int main(void) { char *s = "HI!"; printf("%c\n", s[0]); printf("%c\n", s[1]); printf("%c\n", s[2]); }注意到我们正在打印
s位置处的每个字符。 -
此外,你可以按如下方式修改你的代码:
// Prints a string's chars via pointer arithmetic #include <stdio.h> int main(void) { char *s = "HI!"; printf("%c\n", *s); printf("%c\n", *(s + 1)); printf("%c\n", *(s + 2)); }注意到
s位置处的第一个字符被打印出来。然后,打印s + 1位置处的字符,以此类推。 -
同样,考虑以下内容:
// Prints substrings via pointer arithmetic #include <stdio.h> int main(void) { char *s = "HI!"; printf("%s\n", s); printf("%s\n", s + 1); printf("%s\n", s + 2); }注意到这段代码从
s开始打印存储在各个内存位置上的值。
字符串比较
-
字符串本质上是一个字符数组,通过其第一个字节的位置来标识。
-
在课程早期,我们考虑了整数的比较。我们可以在终端窗口中通过输入
code compare.c来在代码中表示这一点,如下所示:// Compares two integers #include <cs50.h> #include <stdio.h> int main(void) { // Get two integers int i = get_int("i: "); int j = get_int("j: "); // Compare integers if (i == j) { printf("Same\n"); } else { printf("Different\n"); } }注意到这段代码从用户那里获取两个整数并比较它们。
-
然而,在字符串的情况下,不能使用
==运算符来比较两个字符串。 -
利用
==运算符尝试比较字符串将尝试比较字符串的内存位置,而不是其中的字符。因此,我们建议使用strcmp。 -
为了说明这一点,按如下方式修改你的代码:
// Compares two strings' addresses #include <cs50.h> #include <stdio.h> int main(void) { // Get two strings char *s = get_string("s: "); char *t = get_string("t: "); // Compare strings' addresses if (s == t) { printf("Same\n"); } else { printf("Different\n"); } }注意到,对于两个字符串都输入
HI!,仍然会输出Different。 -
为什么这些字符串看起来是不同的?你可以使用以下内容来可视化原因:
![两个字符串 两个字符串分别存储在内存中]()
-
因此,上面
compare.c的代码实际上是在尝试查看内存地址是否不同,而不是字符串本身。 -
使用
strcmp,我们可以修正我们的代码:// Compares two strings using strcmp #include <cs50.h> #include <stdio.h> #include <string.h> int main(void) { // Get two strings char *s = get_string("s: "); char *t = get_string("t: "); // Compare strings if (strcmp(s, t) == 0) { printf("Same\n"); } else { printf("Different\n"); } }注意到
strcmp可以在字符串相同的情况下返回0。 -
为了进一步说明这两个字符串是如何生活在两个位置上的,按如下方式修改你的代码:
// Prints two strings #include <cs50.h> #include <stdio.h> int main(void) { // Get two strings char *s = get_string("s: "); char *t = get_string("t: "); // Print strings printf("%s\n", s); printf("%s\n", t); }注意我们现在有两个独立的字符串被存储,可能位于两个不同的位置。
-
你可以通过以下小修改看到这两个存储的字符串的位置:
// Prints two strings' addresses #include <cs50.h> #include <stdio.h> int main(void) { // Get two strings char *s = get_string("s: "); char *t = get_string("t: "); // Print strings' addresses printf("%p\n", s); printf("%p\n", t); }注意在打印语句中
%s已经被改为%p。
复制和 malloc
-
编程中一个常见的需求是将一个字符串复制到另一个字符串。
-
在你的终端窗口中,键入
code copy.c并编写以下代码:// Capitalizes a string #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <string.h> int main(void) { // Get a string string s = get_string("s: "); // Copy string's address string t = s; // Capitalize first letter in string t[0] = toupper(t[0]); // Print string twice printf("s: %s\n", s); printf("t: %s\n", t); }注意,
string t = s将s的地址复制到t中。这并没有完成我们想要做的事情。字符串没有被复制——只有地址被复制了。此外,请注意ctype.h的包含。 -
你可以将上述代码可视化如下:
![两个字符串 两个指针指向同一内存位置并带有字符串]()
注意
s和t仍然指向相同的内存块。这不是字符串的真正副本。相反,这两个指针指向同一个字符串。 -
在我们解决这个挑战之前,确保我们的代码不会因为尝试将
string s复制到不存在的string t而导致 段错误 是很重要的。我们可以使用strlen函数如下来帮助实现这一点:// Capitalizes a string, checking length first #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <string.h> int main(void) { // Get a string string s = get_string("s: "); // Copy string's address string t = s; // Capitalize first letter in string if (strlen(t) > 0) { t[0] = toupper(t[0]); } // Print string twice printf("s: %s\n", s); printf("t: %s\n", t); }注意
strlen用于确保string t存在。如果它不存在,则不会复制任何内容。 -
为了能够创建字符串的真正副本,我们需要引入两个新的构建块。首先,
malloc允许你,程序员,分配一块特定大小的内存。其次,free允许你告诉编译器释放你之前分配的那块内存。 -
我们可以修改我们的代码来创建我们字符串的真正副本如下:
// Capitalizes a copy of a string #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { // Get a string char *s = get_string("s: "); // Allocate memory for another string char *t = malloc(strlen(s) + 1); // Copy string into memory, including '\0' for (int i = 0; i <= strlen(s); i++) { t[i] = s[i]; } // Capitalize copy t[0] = toupper(t[0]); // Print strings printf("s: %s\n", s); printf("t: %s\n", t); }注意
malloc(strlen(s) + 1)创建了一个长度为字符串s加一的内存块。这允许在最终的复制字符串中包含 空\0字符。然后,for循环遍历字符串s并将每个值赋给字符串t的相同位置。 -
结果表明,我们的代码效率不高。按照以下方式修改你的代码:
// Capitalizes a copy of a string, defining n in loop too #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { // Get a string char *s = get_string("s: "); // Allocate memory for another string char *t = malloc(strlen(s) + 1); // Copy string into memory, including '\0' for (int i = 0, n = strlen(s); i <= n; i++) { t[i] = s[i]; } // Capitalize copy t[0] = toupper(t[0]); // Print strings printf("s: %s\n", s); printf("t: %s\n", t); }注意现在
n = strlen(s)在for循环的左侧被定义。在for循环的中间条件中最好不调用不必要的函数,因为它会反复运行。当将n = strlen(s)移到左侧时,函数strlen只运行一次。 -
C语言有一个内置的函数用于复制字符串,称为strcpy。它可以如下实现:// Capitalizes a copy of a string using strcpy #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { // Get a string char *s = get_string("s: "); // Allocate memory for another string char *t = malloc(strlen(s) + 1); // Copy string into memory strcpy(t, s); // Capitalize copy t[0] = toupper(t[0]); // Print strings printf("s: %s\n", s); printf("t: %s\n", t); }注意
strcpy做了之前我们的for循环所做的工作。 -
在出现错误的情况下,
get_string和malloc都会返回NULL,这是内存中的一个特殊值。你可以编写代码来检查这个NULL条件,如下所示:// Capitalizes a copy of a string without memory errors #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { // Get a string char *s = get_string("s: "); if (s == NULL) { return 1; } // Allocate memory for another string char *t = malloc(strlen(s) + 1); if (t == NULL) { return 1; } // Copy string into memory strcpy(t, s); // Capitalize copy if (strlen(t) > 0) { t[0] = toupper(t[0]); } // Print strings printf("s: %s\n", s); printf("t: %s\n", t); // Free memory free(t); return 0; }注意如果获取的字符串长度为
0或malloc失败,则返回NULL。此外,注意free让计算机知道你已完成通过malloc创建的这块内存。
Valgrind
-
Valgrind 是一个工具,可以检查你的程序中是否有与
malloc相关的内存问题。具体来说,它检查你是否释放了所有分配的内存。 -
考虑以下
memory.c代码:// Demonstrates memory errors via valgrind #include <stdio.h> #include <stdlib.h> int main(void) { int *x = malloc(3 * sizeof(int)); x[1] = 72; x[2] = 73; x[3] = 33; }注意,运行此程序不会导致任何错误。虽然
malloc用于分配足够内存的数组,但代码未能释放分配的内存。 -
如果你输入
make memory然后跟valgrind ./memory,你将得到一个 valgrind 报告,该报告将报告由于你的程序导致丢失的内存位置。valgrind 揭示的一个错误是我们试图将33的值赋给数组的第 4 个位置,而我们只分配了一个大小为3的数组。另一个错误是我们从未释放x。 -
你可以修改你的代码来释放
x的内存,如下所示:// Demonstrates memory errors via valgrind #include <stdio.h> #include <stdlib.h> int main(void) { int *x = malloc(3 * sizeof(int)); x[1] = 72; x[2] = 73; x[3] = 33; free(x); }注意,现在再次运行 valgrind 不会出现内存泄漏。
垃圾值
-
当你向编译器请求一块内存时,没有保证这块内存是空的。
-
很可能你分配的内存之前已被计算机使用。因此,你可能会看到 垃圾 或 垃圾值。这是由于你获得了一块内存但没有初始化它。例如,考虑以下
garbage.c代码:#include <stdio.h> #include <stdlib.h> int main(void) { int scores[1024]; for (int i = 0; i < 1024; i++) { printf("%i\n", scores[i]); } }注意,运行此代码将在内存中为你的数组分配
1024个位置,但for循环可能会显示其中并非所有值都是0。始终注意,当你没有将内存块初始化为零或其他值时,垃圾值的可能性。
Pointer Fun with Binky
- 我们观看了一个来自斯坦福大学的 视频,该视频帮助我们可视化和理解指针。
交换
-
在现实世界中,编程中一个常见的需要是交换两个值。自然地,没有临时存储空间交换两个变量是困难的。在实践中,你可以输入
code swap.c并编写如下代码来观察这一行为:// Fails to swap two integers #include <stdio.h> void swap(int a, int b); int main(void) { int x = 1; int y = 2; printf("x is %i, y is %i\n", x, y); swap(x, y); printf("x is %i, y is %i\n", x, y); } void swap(int a, int b) { int tmp = a; a = b; b = tmp; }注意,尽管这段代码正在运行,但它不起作用。值,甚至在发送到
swap函数之后,都没有交换。为什么? -
当你向函数传递值时,你只提供了副本。
x和y的 作用域 限制在当前代码编写的主函数中。也就是说,在main函数的花括号{}中创建的x和y的值只有main函数的作用域。在我们的上述代码中,x和y是通过 值 传递的。 -
考虑以下图像:
![栈和堆 一个矩形,顶部是机器代码,然后是全局堆和栈]()
注意,全局 变量,我们在这个课程中没有使用,在内存中只有一个位置。各种函数存储在内存的另一个区域
stack中。 -
现在,考虑以下图像:
![frames 一个矩形,底部是主函数,上面直接是交换函数]()
注意,
main和swap有两个独立的帧或内存区域。因此,我们不能简单地从一个函数传递值到另一个函数来改变它们。 -
按如下修改你的代码:
// Swaps two integers using pointers #include <stdio.h> void swap(int *a, int *b); int main(void) { int x = 1; int y = 2; printf("x is %i, y is %i\n", x, y); swap(&x, &y); printf("x is %i, y is %i\n", x, y); } void swap(int *a, int *b) { int tmp = *a; *a = *b; *b = tmp; }注意,变量不是通过值传递,而是通过引用传递。也就是说,
a和b的地址被提供给函数。因此,swap函数可以知道从主函数中如何对实际的a和b进行更改。 -
你可以这样可视化:
![swap by reference 在主函数中存储的 a 和 b 通过引用传递给交换函数]()
溢出
-
堆溢出 是当你溢出堆,触及你不应该触及的内存区域时。
-
栈溢出 是当调用太多函数时,超出可用内存量。
-
这两种情况都被认为是缓冲区溢出。
scanf
-
在 CS50 中,我们创建了像
get_int这样的函数来简化从用户获取输入的行为。 -
scanf是一个内置函数,可以获取用户输入。 -
我们可以使用
scanf很容易地重新实现get_int,如下所示:// Gets an int from user using scanf #include <stdio.h> int main(void) { int n; printf("n: "); scanf("%i", &n); printf("n: %i\n", n); }注意,
n的值存储在scanf("%i", &n)这一行中n的位置。 -
然而,尝试重新实现
get_string并不容易。考虑以下:// Dangerously gets a string from user using scanf with array #include <stdio.h> int main(void) { char s[4]; printf("s: "); scanf("%s", s); printf("s: %s\n", s); }注意,由于字符串是特殊的,不需要使用
&。然而,这个程序并不总是在每次运行时都能正确运行。在这个程序中,我们没有为我们的字符串分配所需的内存量。实际上,我们不知道用户可能输入多长的字符串!进一步地,我们也不知道内存位置可能存在的垃圾值。 -
此外,你的代码可以修改如下。但是,我们必须为字符串预分配一定量的内存:
// Using malloc #include <stdio.h> #include <stdlib.h> int main(void) { char *s = malloc(4); if (s == NULL) { return 1; } printf("s: "); scanf("%s", s); printf("s: %s\n", s); free(s); return 0; }注意,如果提供了一个四字节的字符串,你可能会得到一个错误。
-
如下简化我们的代码,我们可以进一步理解这个预分配的基本问题:
#include <stdio.h> int main(void) { char s[4]; printf("s: "); scanf("%s", s); printf("s: %s\n", s); }注意,如果我们预先分配一个大小为
4的数组,我们可以输入cat并使程序运行。然而,大于这个大小的字符串可能会创建一个错误。 -
有时,编译器或运行它的系统可能会分配比我们指示的更多内存。然而,从根本上说,上面的代码是不安全的。我们不能相信用户会输入一个适合我们预分配内存的字符串。
文件输入/输出
-
你可以读取和操作文件。虽然这个主题将在未来的某个星期进一步讨论,但考虑以下
phonebook.c的代码:// Saves names and numbers to a CSV file #include <cs50.h> #include <stdio.h> #include <string.h> int main(void) { // Open CSV file FILE *file = fopen("phonebook.csv", "a"); // Get name and number char *name = get_string("Name: "); char *number = get_string("Number: "); // Print to file fprintf(file, "%s,%s\n", name, number); // Close file fclose(file); }注意,这段代码使用指针来访问文件。
-
你可以在运行上述代码之前创建一个名为
phonebook.csv的文件,或者下载phonebook.csv。运行上述程序并输入姓名和电话号码后,你会发现这些数据在你的 CSV 文件中持久保存。 -
如果我们想在运行程序之前确保
phonebook.csv文件存在,我们可以按照以下方式修改我们的代码:// Saves names and numbers to a CSV file #include <cs50.h> #include <stdio.h> #include <string.h> int main(void) { // Open CSV file FILE *file = fopen("phonebook.csv", "a"); if (!file) { return 1; } // Get name and number char *name = get_string("Name: "); char *number = get_string("Number: "); // Print to file fprintf(file, "%s,%s\n", name, number); // Close file fclose(file); }注意,这个程序通过调用
return 1来防止NULL指针。 -
我们可以通过输入
code cp.c并编写如下代码来实现自己的复制程序:// Copies a file #include <stdio.h> #include <stdint.h> typedef uint8_t BYTE; int main(int argc, char *argv[]) { FILE *src = fopen(argv[1], "rb"); FILE *dst = fopen(argv[2], "wb"); BYTE b; while (fread(&b, sizeof(b), 1, src) != 0) { fwrite(&b, sizeof(b), 1, dst); } fclose(dst); fclose(src); }注意,这个文件创建了我们自己的数据类型,称为 BYTE,它的大小与 uint8_t 相同。然后,文件读取一个
BYTE并将其写入文件。 -
BMPs 也是我们可以检查和操作的数据集合。这周,你将在你的问题集中做这件事!
总结
在本课中,你学习了指针,它使你能够访问和操作特定内存位置的数据。具体来说,我们深入探讨了……
-
像素艺术
-
十六进制
-
内存
-
指针
-
字符串
-
指针算术
-
字符串比较
-
复制
-
malloc 和 Valgrind
-
垃圾值
-
交换
-
溢出
-
scanf -
文件输入/输出
欢迎下次再来!
第五讲
-
欢迎!
-
数据结构
-
队列
-
栈
-
杰克学习事实
-
调整数组大小
-
数组
-
链表
-
树
-
字典
-
哈希和哈希表
-
字典树
-
总结
欢迎光临!
-
前几周已经向你介绍了编程的基本构建块。
-
你在 C 语言中学到的所有知识都将使你能够在 Python 等高级编程语言中实现这些构建块。
-
每周,概念变得越来越具有挑战性,就像一座山变得越来越陡峭。本周,随着我们探索数据结构,挑战变得平缓。
-
迄今为止,你已经学习了如何使用数组在内存中组织数据。
-
今天,我们将讨论如何在内存中组织数据以及从你不断增长的知识中出现的可能性。
数据结构
-
数据结构 实质上是内存中的组织形式。
-
在内存中组织数据有许多方法。
-
抽象数据类型 是我们可以概念上想象的数据类型。在了解计算机科学时,通常从这些概念数据结构开始学习是有用的。学习这些将使以后理解如何实现更具体的数据结构变得更加容易。
队列
-
队列 是一种抽象数据结构的形式。
-
队列具有特定的属性。具体来说,它们是 FIFO 或“先进先出”。你可以想象自己在游乐园排队等待游乐设施。第一个排队的会先玩,最后一个排队的会后玩。
-
队列与特定的动作相关联。例如,一个项目可以被 enqueued;也就是说,项目可以加入队伍或队列。此外,一个项目可以被 dequeued 或者在到达队伍前端时离开队列。
-
在代码中,你可以这样想象一个队列:
const int CAPACITY = 50; typedef struct { person people[CAPACITY]; int size; } queue;注意,名为
people的数组是person类型。CAPACITY表示栈可能达到的高度。整数size表示队列实际填充的程度,无论它可以容纳多少。
栈
-
队列与栈相对。从根本上讲,栈的性质与队列的性质不同。具体来说,它是 LIFO 或“后进先出”。就像在餐厅里堆叠盘子一样,最后放入堆叠中的盘子可能是第一个被取走的。
-
栈与特定的动作相关联。例如,push 将某物放置在栈顶。Pop 是从栈顶移除某物。
-
在代码中,你可能可以这样想象一个栈:
const int CAPACITY = 50; typedef struct { person people[CAPACITY]; int size; } stack;注意,名为
people的数组是person类型。CAPACITY表示栈可能达到的高度。整数size表示栈实际填充的程度,无论它可以容纳多少。注意,这段代码与队列中的代码相同。 -
您可能会想象,上面的代码有一个限制。因为在这个代码中,数组的容量总是预先确定的。因此,栈可能总是过大。您可能会想象只使用栈中的 5000 个位置中的一个。
-
如果我们的栈是动态的——能够随着添加到其中的项目而增长,那就太好了。
Jack Learns the Facts
- 我们观看了由 Elon 大学的 Shannon Duvall 教授制作的名为Jack Learns the Facts的视频。
调整数组大小
-
回顾到第 2 周,我们向您介绍了您的第一个数据结构。
-
数组是一块连续的内存。
-
您可能会想象数组如下所示:
![数组 三个带有 1 2 3 的箱子]()
-
在内存中,还有其他程序、函数和变量存储的值。其中许多可能是曾经被使用但现在可供使用的未使用垃圾值。
![内存中的数组 三个带有 1 2 3 的箱子以及其他许多内存元素]()
-
假设您想在我们的数组中存储第四个值
4。需要做的是分配一个新的内存区域并将旧数组移动到新区域?最初,这个新的内存区域将填充垃圾值。![两个带有垃圾值的数组 三个带有 1 2 3 的箱子在四个带有垃圾值的箱子上方]()
-
当向这个新的内存区域添加值时,旧的垃圾值会被覆盖。
![带有垃圾值的两个数组 三个带有 1 2 3 的箱子在四个带有 1 2 3 和一个垃圾值的箱子上方]()
-
最终,所有旧的垃圾值都会被新的数据覆盖。
![带有垃圾值的两个数组 三个带有 1 2 3 的箱子在四个带有 1 2 3 4 的箱子上方]()
-
这种方法的缺点之一是设计不佳:每次我们添加一个数字,我们都必须逐个复制数组项。
数组
-
如果我们能够将
4存储在内存的另一个地方会怎么样?根据定义,这将不再是一个数组,因为4将不再在连续的内存中。我们如何连接内存中的不同位置? -
在您的终端中,键入
code list.c并编写以下代码:// Implements a list of numbers with an array of fixed size #include <stdio.h> int main(void) { // List of size 3 int list[3]; // Initialize list with numbers list[0] = 1; list[1] = 2; list[2] = 3; // Print list for (int i = 0; i < 3; i++) { printf("%i\n", list[i]); } }注意,上面的代码与我们在本课程中早期学到的非常相似。内存为三个项目预先分配。
-
建立在最近获得的知识基础上,我们可以利用我们对指针的理解来改进这段代码的设计。按照以下方式修改您的代码:
// Implements a list of numbers with an array of dynamic size #include <stdio.h> #include <stdlib.h> int main(void) { // List of size 3 int *list = malloc(3 * sizeof(int)); if (list == NULL) { return 1; } // Initialize list of size 3 with numbers list[0] = 1; list[1] = 2; list[2] = 3; // List of size 4 int *tmp = malloc(4 * sizeof(int)); if (tmp == NULL) { free(list); return 1; } // Copy list of size 3 into list of size 4 for (int i = 0; i < 3; i++) { tmp[i] = list[i]; } // Add number to list of size 4 tmp[3] = 4; // Free list of size 3 free(list); // Remember list of size 4 list = tmp; // Print list for (int i = 0; i < 4; i++) { printf("%i\n", list[i]); } // Free list free(list); return 0; }注意,创建了一个包含三个整数的列表。然后,可以将三个内存地址分配给值
1、2和3。接着,创建了一个大小为四的列表。接下来,列表从第一个复制到第二个。将值4添加到tmp列表中。由于list指向的内存块不再使用,使用命令free(list)释放它。最后,编译器被指示将list指针现在指向tmp指向的内存块。打印list的内容,然后释放。此外,请注意包含了stdlib.h。 -
有用的是将
list和tmp都视为指向一块内存的指针。正如上面的例子所示,list在某个时刻 指向 一个大小为 3 的数组。到结束时,list被指示指向一个大小为 4 的内存块。技术上讲,在上述代码结束时,tmp和list都指向了同一块内存。 -
一种不使用 for 循环复制数组的方法是使用
realloc:// Implements a list of numbers with an array of dynamic size using realloc #include <stdio.h> #include <stdlib.h> int main(void) { // List of size 3 int *list = malloc(3 * sizeof(int)); if (list == NULL) { return 1; } // Initialize list of size 3 with numbers list[0] = 1; list[1] = 2; list[2] = 3; // Resize list to be of size 4 int *tmp = realloc(list, 4 * sizeof(int)); if (tmp == NULL) { free(list); return 1; } list = tmp; // Add number to list list[3] = 4; // Print list for (int i = 0; i < 4; i++) { printf("%i\n", list[i]); } // Free list free(list); return 0; }注意,列表通过
realloc调整大小到新的数组。 -
可能会有人想为列表分配比所需更多的内存,比如 30 项而不是所需的 3 或 4 项。然而,这并不是一个好的设计,因为它在不需要时也会消耗系统资源。此外,几乎没有保证最终需要超过 30 项内存。
链表
-
在最近几周,你学习了三个有用的原语。
struct是你可以自己定义的数据类型。点号(.)在点表示法中允许你访问该结构体内部的变量。*操作符用于声明指针或取消引用变量。 -
今天,你将介绍
->操作符。它是一个箭头。此操作符指向一个地址并在结构体内部查找。 -
链表是 C 中最强大的数据结构之一。链表允许你包含位于不同内存区域的值。此外,它们允许你根据需要动态地扩展和缩小列表。
-
你可能会想象三个值存储在三个不同的内存区域,如下所示:
![内存中的三个值 三个内存中分别有 1 2 3 的三个盒子]()
-
如何将这些值在列表中拼接起来?
-
我们可以想象上面的数据如下所示:
![内存中的三个值 三个内存中分别有 1 2 3 的三个盒子,每个盒子上附有较小的盒子]()
-
我们可以利用更多的内存来跟踪下一个项目使用指针的位置。
![内存中的三个值 三个内存中分别有 1 2 3 的三个盒子,每个盒子上附有较小的盒子,其中包含内存地址]()
注意,NULL 被用来表示列表中没有其他内容。
-
按照惯例,我们会在内存中保留一个额外的元素,一个指针,它跟踪列表中的第一个项目,称为列表的头。
![内存中的三个值与指针 三个分别位于内存不同区域的盒子,其中较小的盒子附着在内存地址上,现在有一个最终盒子,其中包含第一个盒子的内存地址]()
-
抽象掉内存地址,列表将如下所示:
![内存中的三个值与指针 三个分别位于内存不同区域的盒子,其中较小的盒子指向一个最终盒子,其中一个盒子指向另一个盒子,直到盒子的末端]()
-
这些盒子被称为节点。一个节点包含一个项和一个称为next的指针。在代码中,你可以想象一个节点如下:
typedef struct node { int number; struct node *next; } node;注意,这个节点包含的项是一个名为
number的整数。其次,包含一个指向节点next的指针,它将指向内存中的另一个节点。 -
我们可以重新创建
list.c以利用链表:// Start to build a linked list by prepending nodes #include <cs50.h> #include <stdio.h> #include <stdlib.h> typedef struct node { int number; struct node *next; } node; int main(void) { // Memory for numbers node *list = NULL; // Build list for (int i = 0; i < 3; i++) { // Allocate node for number node *n = malloc(sizeof(node)); if (n == NULL) { return 1; } n->number = get_int("Number: "); n->next = NULL; // Prepend node to list n->next = list; list = n; } return 0; }首先,将
node定义为struct。对于列表的每个元素,通过malloc为节点分配内存,大小为一个节点的大小。将n->number(或n的数字字段)赋值为一个整数。将n->next(或n的next字段)赋值为null。然后,将节点放置在列表的起始位置,内存位置为list。 -
从概念上讲,我们可以想象创建链表的过程。首先,声明
node *list,但它的值是垃圾值。![链表 一个垃圾值]()
-
接下来,在内存中分配一个名为
n的节点。![链表 一个名为 n 的垃圾值和一个名为 list 的指针]()
-
接下来,将节点的
number赋值为1。![链表 n 指向一个数字为 1 且 next 值为垃圾值的节点]()
-
接下来,将节点的
next字段赋值为NULL。![链表 n 指向一个数字为 1 且 next 值为 null 的节点]()
-
接下来,将
list指向n指向的内存位置。现在n和list指向同一个地方。![链表 n 和 list 都指向一个数字为 1 且 next 值为 null 的节点]()
-
然后创建一个新的节点。
number和next字段都填充了垃圾值。![链表 list 指向一个数字为 1 且 next 值为 null 的节点,n 指向一个具有垃圾值的新的节点]()
-
n的节点(新节点)的number值更新为2。![链表 指向编号为 1 的节点,下一个的值为 null,n 指向一个编号为 2 的新节点,下一个为垃圾值]()
-
此外,
next字段也被更新了。![链表 指向编号为 1 的节点,下一个的值为 null,n 指向一个编号为 2 的新节点,下一个为 null]()
-
最重要的是,我们不想失去与这些节点的任何连接,以免它们永远丢失。因此,
n的next字段指向与list相同的内存位置。![链表 指向编号为 1 的节点,下一个的值为 null,n 指向一个编号为 2 的新节点,下一个为 null]()
-
最后,
list被更新为指向n。我们现在有一个包含两个项目的链表。![链表 指向编号为 1 的节点,下一个指向编号为 n 的节点,该节点指向编号为 2 的节点,下一个为 null 的链表]()
-
观察我们的列表图,我们可以看到最后添加的数字是列表中第一个出现的数字。因此,如果我们按顺序打印列表,从第一个节点开始,列表将看起来是乱序的。
-
我们可以按正确顺序打印列表如下:
// Print nodes in a linked list with a while loop #include <cs50.h> #include <stdio.h> #include <stdlib.h> typedef struct node { int number; struct node *next; } node; int main(void) { // Memory for numbers node *list = NULL; // Build list for (int i = 0; i < 3; i++) { // Allocate node for number node *n = malloc(sizeof(node)); if (n == NULL) { return 1; } n->number = get_int("Number: "); n->next = NULL; // Prepend node to list n->next = list; list = n; } // Print numbers node *ptr = list; while (ptr != NULL) { printf("%i\n", ptr->number); ptr = ptr->next; } return 0; }注意,
node *ptr = list创建了一个临时变量,它指向与list指向的相同位置。while循环打印ptr指向的节点内容,然后更新ptr以指向列表中的下一个节点。 -
在这个例子中,向列表中插入总是按(O(1))的顺序进行,因为只需非常少的步骤就可以在列表的前端插入。
-
考虑到搜索这个列表所需的时间,它按(O(n))的顺序,因为在最坏的情况下,必须搜索整个列表以找到项目。向列表添加新元素的时间复杂度将取决于该元素添加的位置。这在下述示例中得到了说明。
-
链表不是存储在连续的内存块中。只要系统资源足够,它们可以增长到你想要的任何大小。然而,缺点是,与数组相比,需要更多的内存来跟踪列表。对于每个元素,你必须存储不仅元素的值,还要存储指向下一个节点的指针。此外,链表不能像数组那样索引,因为我们需要通过前(n - 1)个元素来找到第(n)个元素的位置。因此,上图所示的列表必须进行线性搜索。因此,在上述构建的列表中不可能进行二分搜索。
-
此外,你可以在列表的末尾放置数字,如图中所示代码:
// Appends numbers to a linked list #include <cs50.h> #include <stdio.h> #include <stdlib.h> typedef struct node { int number; struct node *next; } node; int main(void) { // Memory for numbers node *list = NULL; // Build list for (int i = 0; i < 3; i++) { // Allocate node for number node *n = malloc(sizeof(node)); if (n == NULL) { return 1; } n->number = get_int("Number: "); n->next = NULL; // If list is empty if (list == NULL) { // This node is the whole list list = n; } // If list has numbers already else { // Iterate over nodes in list for (node *ptr = list; ptr != NULL; ptr = ptr->next) { // If at end of list if (ptr->next == NULL) { // Append node ptr->next = n; break; } } } } // Print numbers for (node *ptr = list; ptr != NULL; ptr = ptr->next) { printf("%i\n", ptr->number); } // Free memory node *ptr = list; while (ptr != NULL) { node *next = ptr->next; free(ptr); ptr = next; } return 0; }注意代码是如何 遍历 这个列表来找到末尾的。当追加一个元素(添加到列表的末尾)时,我们的代码将以 (O(n)) 的时间复杂度运行,因为我们必须遍历整个列表才能添加最后一个元素。此外,注意使用了一个名为
next的临时变量来跟踪ptr->next。 -
此外,你可以在添加项目时对列表进行排序:
// Implements a sorted linked list of numbers #include <cs50.h> #include <stdio.h> #include <stdlib.h> typedef struct node { int number; struct node *next; } node; int main(void) { // Memory for numbers node *list = NULL; // Build list for (int i = 0; i < 3; i++) { // Allocate node for number node *n = malloc(sizeof(node)); if (n == NULL) { return 1; } n->number = get_int("Number: "); n->next = NULL; // If list is empty if (list == NULL) { list = n; } // If number belongs at beginning of list else if (n->number < list->number) { n->next = list; list = n; } // If number belongs later in list else { // Iterate over nodes in list for (node *ptr = list; ptr != NULL; ptr = ptr->next) { // If at end of list if (ptr->next == NULL) { // Append node ptr->next = n; break; } // If in middle of list if (n->number < ptr->next->number) { n->next = ptr->next; ptr->next = n; break; } } } } // Print numbers for (node *ptr = list; ptr != NULL; ptr = ptr->next) { printf("%i\n", ptr->number); } // Free memory node *ptr = list; while (ptr != NULL) { node *next = ptr->next; free(ptr); ptr = next; } return 0; }注意这个列表是如何在构建过程中排序的。为了以这种特定顺序插入元素,我们的代码在每次插入时仍将以 (O(n)) 的时间复杂度运行,因为在最坏的情况下,我们可能需要查看所有当前元素。
-
这段代码可能看起来很复杂。然而,请注意,使用指针和上面的语法,我们可以在内存的不同位置拼接数据。
树
-
数组提供连续的内存,可以快速搜索。数组还提供了进行二分搜索的机会。
-
我们能否结合数组和链表的最佳之处?
-
二叉搜索树 是另一种数据结构,可以更有效地存储数据,以便进行搜索和检索。
-
你可以想象一个有序的数字序列。
![树 1 2 3 4 5 6 7 在相邻的框中]()
-
想象一下,中心值成为树的顶部。那些小于这个值的放在左边。那些大于这个值的放在右边。
![树 1 2 3 4 5 6 7 在按层次排列的框中,4 在顶部,3 和 5 在其下方,1、2、6 和 7 在这些箭头下方]()
-
然后可以使用指针指向每个内存区域的正确位置,这样每个节点都可以连接起来。
![树 1 2 3 4 5 6 7 在按层次排列的框中,4 在顶部,3 和 5 在其下方,1、2、6 和 7 在这些箭头下方,它们以树状结构连接]()
-
在代码中,可以这样实现。
// Implements a list of numbers as a binary search tree #include <stdio.h> #include <stdlib.h> // Represents a node typedef struct node { int number; struct node *left; struct node *right; } node; void free_tree(node *root); void print_tree(node *root); int main(void) { // Tree of size 0 node *tree = NULL; // Add number to list node *n = malloc(sizeof(node)); if (n == NULL) { return 1; } n->number = 2; n->left = NULL; n->right = NULL; tree = n; // Add number to list n = malloc(sizeof(node)); if (n == NULL) { free_tree(tree); return 1; } n->number = 1; n->left = NULL; n->right = NULL; tree->left = n; // Add number to list n = malloc(sizeof(node)); if (n == NULL) { free_tree(tree); return 1; } n->number = 3; n->left = NULL; n->right = NULL; tree->right = n; // Print tree print_tree(tree); // Free tree free_tree(tree); return 0; } void free_tree(node *root) { if (root == NULL) { return; } free_tree(root->left); free_tree(root->right); free(root); } void print_tree(node *root) { if (root == NULL) { return; } print_tree(root->left); printf("%i\n", root->number); print_tree(root->right); }注意这个搜索功能首先会去
tree的位置。然后,它使用递归来搜索number。free_tree函数递归地释放树。print_tree函数递归地打印树。 -
如上所示的树提供了一种数组不具备的动态性。它可以按我们的意愿增长和缩小。
-
此外,当树平衡时,这个结构提供 (O(log n)) 的搜索时间。
词典
-
词典 是另一种数据结构。
-
词典,就像实际的书本形式的词典,有单词和定义,有 键 和 值。
-
算法时间复杂度的 圣杯 是 (O(1)) 或 常数时间。也就是说,最终目标是访问能够瞬间完成。
![时间复杂度 各种时间复杂性的图表,其中 O(log n) 是次优,O(1) 是最佳]()
-
词典可以通过散列提供这种访问速度。
散列和散列表
-
散列的想法是取一个值并能够输出一个值,这个值可以成为以后访问它的快捷方式。
-
例如,散列苹果可能散列为一个值为
1,而浆果可能散列为2。因此,找到苹果就像询问哈希算法苹果存储在哪里一样简单。虽然在设计上不是理想的,但最终,将所有a放在一个桶中,将b放在另一个桶中,这种桶化散列值的理念说明了你可以如何使用这个概念:散列值可以用来简化查找这样的值。 -
散列函数是一种将较大值减少到较小且可预测的值的算法。通常,这个函数接收一个你希望添加到你的哈希表中的项目,并返回一个表示该项目应放置的数组索引的整数。
-
哈希表是数组和链表的绝佳组合。在代码实现中,哈希表是一个指向节点的指针数组。
-
可以这样想象哈希表:
![字母表 一个垂直的 26 个盒子组成的列,每个盒子代表字母表中的一个字母]()
注意这是一个分配给字母表每个值的数组。
-
然后,在数组的每个位置,使用链表来跟踪存储在该位置的每个值:
![字母表 一个垂直的 26 个盒子组成的列,每个盒子代表字母表中的一个字母,来自马里奥宇宙的各种名称从右边出现,路易吉与 l 一起,马里奥与 m 一起]()
-
冲突是在你向哈希表中添加值时,已经存在散列位置上的值。在上面的例子中,冲突只是简单地附加到列表的末尾。
-
通过更好地编程你的哈希表和哈希算法可以减少冲突。你可以想象对上面的改进如下:
![字母表 由 L A K 和 L I N 安排的各种盒子组成的垂直列,Lakitu 从 L A K 中出现,链接从 L I N 中出现]()
-
考虑以下哈希算法的示例:
![散列 路易吉被输入到一个哈希算法中,输出为 11]()
-
这可以在代码中如下实现:
#include <ctype.h> unsigned int hash(const char *word) { return toupper(word[0]) - 'A'; }注意哈希函数返回
toupper(word[0]) - 'A'的值。 -
作为程序员,你必须决定使用更多内存以拥有大哈希表并可能减少搜索时间,还是使用更少的内存并可能增加搜索时间的好处。
-
这种结构提供了 (O(n)) 的搜索时间。
Trie
-
Trie是另一种数据结构。Trie 是数组的树。
-
Trie总是可以在常数时间内进行搜索。
-
Trie的一个缺点是它们往往需要占用大量的内存。注意,我们只需要 (26 \times 4 = 104) 个
节点来存储青蛙! -
青蛙将如下存储:
![tries 逐个字母拼写青蛙,每个字母与一个列表 T 从另一个列表 O 中关联,依此类推]()
-
汤姆 将按以下方式存储:
![tries 逐个字母拼写青蛙,每个字母与一个列表 T 从另一个列表 O 中关联,依此类推,以及类似地拼写汤姆,其中青蛙和汤姆共享两个共同字母 T 和 O]()
-
这种结构提供了 (O(1)) 的搜索时间。
-
这种结构的缺点在于使用它需要多少资源。
总结
在本课中,你学习了如何使用指针构建新的数据结构。具体来说,我们深入探讨了...
-
数据结构
-
栈和队列
-
调整数组大小
-
链表
-
字典
-
Tries
次次见!
第六讲
-
欢迎!
-
嗨,Python!
-
Speller
-
过滤器
-
函数
-
库、模块和包
-
字符串
-
位置参数和命名参数
-
变量
-
类型
-
计算器
-
条件语句
-
面向对象编程
-
循环
-
抽象
-
截断和浮点数不精确
-
异常
-
马里奥
-
列表
-
搜索和字典
-
命令行参数
-
退出状态
-
CSV 文件
-
第三方库
-
总结
欢迎光临!
-
在之前的几周里,你被介绍了编程的基本构建块。
-
你已经学习了一种名为 C 的低级编程语言中的编程。
-
今天,我们将使用一种名为 Python 的高级编程语言。
-
随着你学习这门新语言,你会发现你将更有能力自学新的编程语言。
嗨,Python!
-
几十年来,人类已经看到在之前的编程语言中做出的设计决策如何得到改进。
-
Python 是一种编程语言,它建立在你在 C 语言中学到的知识之上。
-
Python 还可以访问大量的用户创建的库。
-
与 C 语言不同,C 是一种 编译语言,Python 是一种 解释语言,你不需要单独编译你的程序。相反,你在 Python 解释器 中运行你的程序。
-
到目前为止,代码看起来是这样的:
// A program that says hello to the world #include <stdio.h> int main(void) { printf("hello, world\n"); } -
今天,你会发现编写和编译代码的过程已经简化了。
-
例如,上面的代码在 Python 中将被渲染为:
# A program that says hello to the world print("hello, world")注意,分号已经消失,并且不需要任何库。你可以通过在终端中键入
python hello.py来运行这个程序。 -
Python 可以相对简单地实现 C 语言中相当复杂的功能。
Speller
-
为了说明这种简单性,让我们在终端窗口中键入‘code dictionary.py’并编写如下代码:
# Words in dictionary words = set() def check(word): """Return true if word is in dictionary else false""" return word.lower() in words def load(dictionary): """Load dictionary into memory, returning true if successful else false""" with open(dictionary) as file: words.update(file.read().splitlines()) return True def size(): """Returns number of words in dictionary if loaded else 0 if not yet loaded""" return len(words) def unload(): """Unloads dictionary from memory, returning true if successful else false""" return True注意,上面有四个函数。在
check函数中,如果word在words中,它返回True。这比 C 语言中的实现简单得多!同样,在load函数中,字典文件被打开。对于该文件中的每一行,我们将该行添加到words中。使用rstrip,从添加的单词中移除尾随的新行。size仅返回words的len或长度。unload只需要返回True,因为 Python 自己处理内存管理。 -
上述代码说明了为什么存在高级语言:为了简化并允许你更容易地编写代码。
-
然而,速度是一个权衡。因为 C 允许程序员做出关于内存管理的决策,它可能比 Python 运行得更快——这取决于你的代码。当调用 Python 的内置函数时,C 只运行你的代码行,而 Python 运行所有在引擎盖下运行的代码。
-
你可以在Python 文档中了解更多关于函数的信息。
过滤器
-
为了进一步说明这种简单性,请在你的终端窗口中键入
code blur.py来创建一个新文件,并编写如下代码:# Blurs an image from PIL import Image, ImageFilter # Blur image before = Image.open("bridge.bmp") after = before.filter(ImageFilter.BoxBlur(1)) after.save("out.bmp")注意,此程序从名为
PIL的库中导入模块Image和ImageFilter。它接受一个输入文件并创建一个输出文件。 -
此外,你可以创建一个名为
edges.py的新文件,如下所示:# Finds edges in an image from PIL import Image, ImageFilter # Find edges before = Image.open("bridge.bmp") after = before.filter(ImageFilter.FIND_EDGES) after.save("out.bmp")注意,此代码是对你的
blur代码的微小调整,但产生了截然不同的结果。 -
Python 允许你将 C 和其他低级编程语言中更为复杂的编程抽象出来。
函数
-
在 C 中,你可能见过如下函数:
printf("hello, world\n"); -
在 Python 中,你会看到如下函数:
print("hello, world")
库、模块和包
-
与 C 一样,CS50 库可以在 Python 中使用。
-
以下函数将特别有用:
get_float get_int get_string -
你可以如下导入 cs50 库:
import cs50 -
你也可以选择仅导入 CS50 库中的特定函数,如下所示:
from cs50 import get_float, get_int, get_string
字符串
-
在 C 中,你可能记得如下代码:
// get_string and printf with %s #include <cs50.h> #include <stdio.h> int main(void) { string answer = get_string("What's your name? "); printf("hello, %s\n", answer); } -
在 Python 中,此代码将转换为:
# get_string and print, with concatenation from cs50 import get_string answer = get_string("What's your name? ") print("hello, " + answer)你可以通过在终端窗口中执行
code hello.py来编写此代码。然后,你可以通过运行python hello.py来执行此代码。注意+符号如何连接"hello, "和answer。 -
同样,这也可以在不连接的情况下完成:
# get_string and print, without concatenation from cs50 import get_string answer = get_string("What's your name? ") print("hello,", answer)注意,打印语句自动在
hello语句和answer之间创建一个空格。 -
同样,你可以将上述代码实现为:
# get_string and print, with format strings from cs50 import get_string answer = get_string("What's your name? ") print(f"hello, {answer}")注意大括号如何允许
print函数将answer进行插值,使得answer出现在其中。f是必须的,以便正确地格式化包含answer。
位置参数和命名参数
-
C 语言中的函数,如
fread、fwrite和printf使用位置参数,其中你通过逗号分隔符提供参数。作为程序员,你必须记住哪个参数在哪个位置。这些被称为位置参数。 -
在 Python 中,命名参数允许你提供参数而不考虑位置性。
-
你可以在文档中了解更多关于
print函数的参数。 -
访问该文档,你可能看到如下内容:
print(*objects, sep=' ', end='\n', file=None, flush=False)注意可以提供各种对象来打印。当提供多个对象给
print时,会显示一个空格分隔符。同样,在print语句的末尾提供一个新行。
变量
-
变量声明也得到了简化。在 C 语言中,你可能会有
int counter = 0;这样的代码。在 Python 中,同样的行会写成counter = 0。你不需要声明变量的类型。 -
Python 更倾向于使用
counter += 1来实现加一,失去了 C 语言中counter++的能力。
类型
-
Python 中的数据类型不需要显式声明。例如,你上面看到
answer是一个字符串,但我们不必告诉解释器这一点:它自己就知道。 -
在 Python 中,常用的类型包括:
bool float int str注意到
long和double类型不见了。Python 会处理更大或更小的数字应该使用哪种数据类型。 -
Python 中还有一些其他的数据类型:
range sequence of numbers list sequence of mutable values tuple sequence of immutable values dict collection of key-value pairs set collection of unique values -
这些数据类型在 C 语言中都可以实现,但在 Python 中可以更简单地实现。
计算器
-
你可能还记得课程早期提到的
calculator.c:// Addition with int #include <cs50.h> #include <stdio.h> int main(void) { // Prompt user for x int x = get_int("x: "); // Prompt user for y int y = get_int("y: "); // Perform addition printf("%i\n", x + y); } -
我们可以像在 C 语言中一样实现一个简单的计算器。在终端窗口中输入
code calculator.py并编写以下代码:# Addition with int [using get_int] from cs50 import get_int # Prompt user for x x = get_int("x: ") # Prompt user for y y = get_int("y: ") # Perform addition print(x + y)注意 CS50 库是如何导入的。然后,
x和y从用户那里收集。最后,打印出结果。注意,在 C 程序中通常会看到的main函数在这里完全消失了!虽然可以使用main函数,但不是必需的。 -
有可能移除 CS50 库的训练轮。按照以下方式修改你的代码:
# Addition with int [using input] # Prompt user for x x = input("x: ") # Prompt user for y y = input("y: ") # Perform addition print(x + y)注意执行上述代码会导致程序出现奇怪的行为。这可能是为什么?
-
你可能已经猜到解释器将
x和y理解为字符串。你可以通过以下方式使用int函数来修复你的代码:# Addition with int [using input] # Prompt user for x x = int(input("x: ")) # Prompt user for y y = int(input("y: ")) # Perform addition print(x + y)注意
x和y的输入是如何传递给int函数的,该函数将其转换为整数。如果不将x和y转换为整数,字符将进行连接。
条件语句
-
在 C 语言中,你可能记得这样的程序:
// Conditionals, Boolean expressions, relational operators #include <cs50.h> #include <stdio.h> int main(void) { // Prompt user for integers int x = get_int("What's x? "); int y = get_int("What's y? "); // Compare integers if (x < y) { printf("x is less than y\n"); } else if (x > y) { printf("x is greater than y\n"); } else { printf("x is equal to y\n"); } } -
在 Python 中,它将如下所示:
# Conditionals, Boolean expressions, relational operators from cs50 import get_int # Prompt user for integers x = get_int("What's x? ") y = get_int("What's y? ") # Compare integers if x < y: print("x is less than y") elif x > y: print("x is greater than y") else: print("x is equal to y")注意到没有更多的花括号。相反,使用缩进来表示。其次,在
if语句中使用冒号。此外,elif替换了else if。在if和elif语句中也不再需要括号。 -
进一步查看比较,考虑以下 C 语言的代码:
// Logical operators #include <cs50.h> #include <stdio.h> int main(void) { // Prompt user to agree char c = get_char("Do you agree? "); // Check whether agreed if (c == 'Y' || c == 'y') { printf("Agreed.\n"); } else if (c == 'N' || c == 'n') { printf("Not agreed.\n"); } } -
这可以按照以下方式实现:
# Logical operators from cs50 import get_string # Prompt user to agree s = get_string("Do you agree? ") # Check whether agreed if s == "Y" or s == "y": print("Agreed.") elif s == "N" or s == "n": print("Not agreed.")注意到 C 语言中使用的两个竖线被
or替换了。确实,人们通常喜欢 Python,因为它对人类来说更易读。此外,注意 Python 中不存在char类型。相反,使用str类型。 -
对这段代码的另一种实现方式可以是使用 列表:
# Logical operators, using lists from cs50 import get_string # Prompt user to agree s = get_string("Do you agree? ") # Check whether agreed if s in ["y", "yes"]: print("Agreed.") elif s in ["n", "no"]: print("Not agreed.")注意我们能够在一个
list中表达多个关键字,如y和yes。
面向对象编程
-
有可能某些类型的值不仅具有属性或属性,还具有函数。在 Python 中,这些值被称为对象。
-
在 C 语言中,我们可以创建一个
struct,在其中可以关联多个变量,形成一个单独的自定义数据类型。在 Python 中,我们也可以这样做,并且还可以在自定义数据类型中包含函数。当一个函数属于特定的对象时,它被称为方法。 -
例如,Python 中的
strs有内置的方法。因此,你可以按照以下方式修改你的代码:# Logical operators, using lists # Prompt user to agree s = input("Do you agree? ").lower() # Check whether agreed if s in ["y", "yes"]: print("Agreed.") elif s in ["n", "no"]: print("Not agreed.")注意旧的
s值被strs的内置方法s.lower()的结果覆盖。 -
同样,你可能还记得我们在 C 语言中是如何复制字符串的:
// Capitalizes a copy of a string without memory errors #include <cs50.h> #include <ctype.h> #include <stdio.h> #include <stdlib.h> #include <string.h> int main(void) { // Get a string char *s = get_string("s: "); if (s == NULL) { return 1; } // Allocate memory for another string char *t = malloc(strlen(s) + 1); if (t == NULL) { return 1; } // Copy string into memory strcpy(t, s); // Capitalize copy if (strlen(t) > 0) { t[0] = toupper(t[0]); } // Print strings printf("s: %s\n", s); printf("t: %s\n", t); // Free memory free(t); return 0; }注意代码的行数。
-
我们可以将上述内容用 Python 实现如下:
# Capitalizes a copy of a string # Get a string s = input("s: ") # Capitalize copy of string t = s.capitalize() # Print strings print(f"s: {s}") print(f"t: {t}")注意这个程序与 C 语言中的对应程序相比要短得多。
-
在这个课程中,我们将只触及 Python 的皮毛。因此,随着你继续学习,Python 文档将特别重要。
-
你可以在Python 文档中了解更多关于字符串方法的信息。
循环
-
Python 中的循环与 C 语言非常相似。你可能还记得以下 C 语言的代码:
// Demonstrates for loop #include <stdio.h> int main(void) { for (int i = 0; i < 3; i++) { printf("meow\n"); } } -
for循环在 Python 中可以这样实现:# Better design for i in range(3): print("meow")注意,
i从未被明确使用。然而,Python 会自动增加i的值。 -
此外,
while循环可以这样实现:# Demonstrates while loop i = 0 while i < 3: print("meow") i += 1 -
为了进一步加深我们对 Python 中循环和迭代的理解,让我们创建一个新的文件,命名为
uppercase.py,如下所示:# Uppercases string one character at a time before = input("Before: ") print("After: ", end="") for c in before: print(c.upper(), end="") print()注意
end=是如何用于传递参数给print函数,以继续行而不添加换行符。此代码一次传递一个字符串。 -
阅读文档后,我们发现 Python 有一些方法可以应用于整个字符串,如下所示:
# Uppercases string all at once before = input("Before: ") after = before.upper() print(f"After: {after}")注意
.upper是如何应用于整个字符串的。
抽象
-
正如我们今天早些时候暗示的,你可以通过使用函数和将各种代码抽象到函数中来进一步改进我们的代码。修改你之前创建的
meow.py代码如下:# Abstraction def main(): for i in range(3): meow() # Meow once def meow(): print("meow") main()注意,
meow函数抽象掉了print语句。此外,注意main函数位于文件顶部。在文件底部,调用main函数。按照惯例,在 Python 中,你期望创建一个main函数。 -
的确,我们可以在函数之间传递变量,如下所示:
# Abstraction with parameterization def main(): meow(3) # Meow some number of times def meow(n): for i in range(n): print("meow") main()注意
meow现在接受一个变量n。在main函数中,你可以调用meow并向它传递一个值,比如3。然后,meow在for循环中使用n的值。 -
阅读上述代码,注意作为 C 程序员,你如何能够相当容易地理解上述代码。虽然某些约定不同,但你之前学到的构建块在这个新的编程语言中非常明显。
截断和浮点数不精确
-
回想一下,在 C 语言中,我们遇到了截断,其中一个整数除以另一个整数可能会得到一个不精确的结果。
-
你可以通过修改
calculator.py代码来看到 Python 如何处理这种除法:# Division with integers, demonstration lack of truncation # Prompt user for x x = int(input("x: ")) # Prompt user for y y = int(input("y: ")) # Divide x by y z = x / y print(z)注意,执行此代码会产生一个值,但如果你在
.333333后看到更多数字,你会看到我们面临的是 浮点数不精确性。不会发生截断。 -
我们可以通过稍微修改我们的代码来揭示这种不精确性:
# Floating-point imprecision # Prompt user for x x = int(input("x: ")) # Prompt user for y y = int(input("y: ")) # Divide x by y z = x / y print(f"{z:.50f}")注意,这段代码揭示了不精确性。Python 仍然面临这个问题,就像 C 语言一样。
异常
-
让我们探索在运行 Python 代码时可能发生的更多异常。
-
按照以下方式修改
calculator.py:# Doesn't handle exception # Prompt user for an integer n = int(input("Input: ")) print("Integer")注意,输入错误的数据可能会导致错误。
-
我们可以通过修改以下代码来尝试处理和捕获潜在的异常:
# Handles exception # Prompt user for an integer try: n = int(input("Input: ")) print("Integer.") except ValueError: print("Not integer.")注意,上述代码会反复尝试获取正确的数据类型,并在需要时提供额外的提示。
马里奥
-
回想一下几周前我们的挑战,即在马里奥游戏中堆叠三个砖块。
![马里奥砖块 三个垂直砖块]()
-
在 Python 中,我们可以按照以下方式实现类似的功能:
# Prints a column of 3 bricks with a loop for i in range(3): print("#")这会打印出一列三个砖块。
-
在 C 语言中,我们有一个
do-while循环的优势。然而,在 Python 中,传统上使用while循环,因为 Python 没有内置的do-while循环。你可以在名为mario.py的文件中按照以下方式编写代码:# Prints a column of n bricks with a loop from cs50 import get_int while True: n = get_int("Height: ") if n > 0: break for i in range(n): print("#")注意,while 循环是如何用来获取高度的。一旦输入的高度大于零,循环就会中断。
-
考虑以下图像:
![马里奥砖块 四个水平问号砖块]()
-
在 Python 中,我们可以通过修改以下代码来实现:
# Prints a row of 4 question marks with a loop for i in range(4): print("?", end="") print()注意,你可以覆盖
print函数的行为,使其保持在上一行打印的位置。 -
与之前的迭代类似,我们可以进一步简化这个程序:
# Prints a row of 4 question marks without a loop print("?" * 4)注意,我们可以使用
*来重复打印语句,使其重复4次。 -
那么一大块砖块怎么办?
![马里奥砖块 三乘三的马里奥砖块]()
-
要实现上述功能,你可以按照以下方式修改你的代码:
# Prints a 3-by-3 grid of bricks with loops for i in range(3): for j in range(3): print("#", end="") print()注意一个
for循环是如何嵌套在另一个for循环中的。print语句在每个砖块行的末尾添加一个新行。 -
你可以在 Python 文档 中了解更多关于
print函数的信息。
列表
-
list是 Python 中的一个数据结构。 -
list中有内置的方法或函数。 -
例如,考虑以下代码:
# Averages three numbers using a list # Scores scores = [72, 73, 33] # Print average average = sum(scores) / len(scores) print(f"Average: {average}")注意,你可以使用内置的
sum方法来计算平均值。 -
你甚至可以利用以下语法从用户那里获取值:
# Averages three numbers using a list and a loop from cs50 import get_int # Get scores scores = [] for i in range(3): score = get_int("Score: ") scores.append(score) # Print average average = sum(scores) / len(scores) print(f"Average: {average}")注意,这段代码使用了内置的
append方法来处理列表。 -
你可以在 Python 文档 中了解更多关于列表的信息。
-
你也可以在 Python 文档 中了解更多关于
len的信息。
搜索和字典
-
我们还可以在数据结构内进行搜索。
-
考虑一个名为
phonebook.py的程序如下:# Implements linear search for names using loop # A list of names names = ["Yuliia", "David", "John"] # Ask for name name = input("Name: ") # Search for name for n in names: if name == n: print("Found") break else: print("Not found")注意这是如何为每个名字实现线性搜索的。
-
然而,我们不需要遍历列表。在 Python 中,我们可以如下执行线性搜索:
# Implements linear search for names using `in` # A list of names names = ["Yuliia", "David", "John"] # Ask for name name = input("Name: ") # Search for name if name in names: print("Found") else: print("Not found")注意
in如何用于实现线性搜索。 -
然而,此代码仍有改进空间。
-
回想一下,字典 或
dict是键值对的集合。 -
你可以在 Python 中如下实现字典:
# Implements a phone book as a list of dictionaries, without a variable from cs50 import get_string people = [ {"name": "Yuliia", "number": "+1-617-495-1000"}, {"name": "David", "number": "+1-617-495-1000"}, {"name": "John", "number": "+1-949-468-2750"}, ] # Search for name name = get_string("Name: ") for person in people: if person["name"] == name: print(f"Found {person['number']}") break else: print("Not found")注意,每个条目都实现了
name和number的字典。 -
更好的是,严格来说,我们不需要同时使用
name和number。我们可以将此代码简化如下:# Implements a phone book using a dictionary from cs50 import get_string people = { "Yuliia": "+1-617-495-1000", "David": "+1-617-495-1000", "John": "+1-949-468-2750", } # Search for name name = get_string("Name: ") if name in people: print(f"Number: {people[name]}") else: print("Not found")注意,字典是用花括号实现的。然后,
if name in people这个语句会搜索name是否在people字典中。此外,注意在print语句中,我们可以使用name的值来索引people字典。非常实用! -
Python 尽力使用其内置搜索实现 常数时间。
-
你可以在 Python 文档 中了解更多关于字典的信息。
命令行参数
-
与 C 语言一样,你还可以利用命令行参数。考虑以下代码:
# Prints a command-line argument from sys import argv if len(argv) == 2: print(f"hello, {argv[1]}") else: print("hello, world")注意
argv[1]是使用 格式化字符串 打印的,print语句中的f表示格式化字符串。 -
你可以在 Python 文档 中了解更多关于
sys库的信息。
退出状态
-
sys库也有内置的方法。我们可以使用sys.exit(i)来使用特定的退出码退出程序:# Exits with explicit value, importing sys import sys if len(sys.argv) != 2: print("Missing command-line argument") sys.exit(1) print(f"hello, {sys.argv[1]}") sys.exit(0)注意使用了点符号来利用
sys的内置函数。
CSV 文件
-
Python 也内置了对 CSV 文件的支持。
-
按照以下方式修改你的
phonebook.py代码:import csv file = open("phonebook.csv", "a") name = input("Name: ") number = input("Number: ") writer = csv.writer(file) writer.writerow([name,number]) file.close()注意
writerow会为我们添加 CSV 文件中的逗号。 -
虽然
file.close和file = open是 Python 中常用且可用的语法,但此代码可以如下改进:import csv name = input("Name: ") number = input("Number: ") with open("phonebook.csv", "a") as file: writer = csv.writer(file) writer.writerow([name,number])注意,代码在
with语句下缩进。这会在完成后自动关闭文件。 -
类似地,我们可以在 CSV 文件中如下写入字典:
import csv name = input("Name: ") number = input("Number: ") with open("phonebook.csv", "a") as file: writer = csv.DictWriter(file, fieldnames=["name", "number"]) writer.writerow({"name": name, "number": number})注意此代码与之前的迭代相当相似,但使用了
csv.DictWriter。
第三方库
-
Python 的一个优点是其庞大的用户基础和同样庞大的第三方库数量。
-
如果你已经安装了 Python,你可以通过输入
pip install cs50在你的电脑上安装 CS50 库。 -
考虑其他库,David 展示了
cowsay和qrcode的使用。
总结
在本节课中,你学习了如何将之前课程中编程的基本构建块在 Python 中实现。此外,你还了解了 Python 如何使代码更加简化。同时,你学习了如何利用各种 Python 库。最后,你了解到作为一名程序员,你的技能并不仅限于单一编程语言。你已经看到,通过这门课程,你正在发现一种新的学习方法,这可以在任何编程语言中为你服务——也许在几乎任何学习领域中都能!具体来说,我们讨论了……
-
Python
-
变量
-
条件语句
-
循环
-
数据类型
-
面向对象编程
-
截断和浮点数不精确
-
异常
-
字典
-
命令行参数
-
第三方库
次次见!
第七讲
-
欢迎!
-
平面文件数据库
-
关系数据库
-
SELECT
-
INSERT
-
DELETE
-
UPDATE
-
IMDb
-
JOIN
-
索引
-
在 Python 中使用 SQL
-
竞争条件
-
SQL 注入攻击
-
总结
欢迎!
-
在前几周,我们向您介绍了 Python,这是一种高级编程语言,它使用了我们在 C 语言中学到的相同构建块。然而,我们引入这种新语言不是为了学习“另一种语言”。相反,我们这样做是因为某些工具更适合某些工作,而不太适合其他工作!
-
这周,我们将继续学习更多与 Python 相关的语法。
-
此外,我们将把这种知识与我们所学的内容结合起来。
-
最后,我们将讨论SQL或结构化查询语言,这是一种特定领域的方法,我们可以通过它来交互和修改数据。
-
总体而言,本课程的一个目标就是学习编程的一般知识——而不仅仅是学习本课程中描述的语言的编程。
平面文件数据库
-
如你所见,数据通常可以用列和行的模式来描述。
-
类似于在 Microsoft Excel 和 Google Sheets 中创建的电子表格可以输出为
csv或逗号分隔值文件。 -
如果你查看一个
csv文件,你会注意到文件是平的,因为我们的所有数据都存储在一个由文本文件表示的单个表中。我们称这种形式的数据为平面文件数据库。 -
所有数据都是按行存储的。每个列由逗号或其他值分隔。
-
Python 自带对
csv文件的原生支持。 -
首先,下载favorites.csv并将其上传到cs50.dev中的文件资源管理器内。其次,检查这些数据,注意第一行是特殊的,因为它定义了每一列。然后,每条记录按行存储。
-
在你的终端窗口中,输入
code favorites.py并编写以下代码:# Prints all favorites in CSV using csv.reader import csv # Open CSV file with open("favorites.csv", "r") as file: # Create reader reader = csv.reader(file) # Skip header row next(reader) # Iterate over CSV file, printing each favorite for row in reader: print(row[1])注意到已经导入了
csv库。此外,我们创建了一个reader,它将保存csv.reader(file)的结果。csv.reader函数从文件中读取每一行,在我们的代码中,我们将结果存储在reader中。因此,print(row[1])将打印出favorites.csv文件中的语言。 -
你可以按照以下方式改进你的代码:
# Stores favorite in a variable import csv # Open CSV file with open("favorites.csv", "r") as file: # Create reader reader = csv.reader(file) # Skip header row next(reader) # Iterate over CSV file, printing each favorite for row in reader: favorite = row[1] print(favorite)注意
favorite被存储并打印出来。此外,注意我们使用next函数跳到读者下一行。 -
上述方法的一个缺点是我们信任
row[1]始终是首选。然而,如果列被移动了,会发生什么呢? -
我们可以修复这个潜在的问题。Python 还允许你通过列表的键进行索引。按照以下方式修改你的代码:
# Prints all favorites in CSV using csv.DictReader import csv # Open CSV file with open("favorites.csv", "r") as file: # Create DictReader reader = csv.DictReader(file) # Iterate over CSV file, printing each favorite for row in reader: favorite = row["language"] print(favorite)注意到这个例子直接在打印语句中使用了
language键。favorite索引到row["language"]的reader字典。 -
这可以进一步简化为:
# Prints all favorites in CSV using csv.DictReader import csv # Open CSV file with open("favorites.csv", "r") as file: # Create DictReader reader = csv.DictReader(file) # Iterate over CSV file, printing each favorite for row in reader: print(row["language"]) -
要统计在
csv文件中表达的首选语言的数目,我们可以这样做:# Counts favorites using variables import csv # Open CSV file with open("favorites.csv", "r") as file: # Create DictReader reader = csv.DictReader(file) # Counts scratch, c, python = 0, 0, 0 # Iterate over CSV file, counting favorites for row in reader: favorite = row["language"] if favorite == "Scratch": scratch += 1 elif favorite == "C": c += 1 elif favorite == "Python": python += 1 # Print counts print(f"Scratch: {scratch}") print(f"C: {c}") print(f"Python: {python}")注意到每种语言都是通过
if语句进行统计的。此外,注意那些if语句中的双等号==。 -
Python 允许我们使用字典来统计每种语言的
counts。考虑以下对我们代码的改进:# Counts favorites using dictionary import csv # Open CSV file with open("favorites.csv", "r") as file: # Create DictReader reader = csv.DictReader(file) # Counts counts = {} # Iterate over CSV file, counting favorites for row in reader: favorite = row["language"] if favorite in counts: counts[favorite] += 1 else: counts[favorite] = 1 # Print counts for favorite in counts: print(f"{favorite}: {counts[favorite]}")注意到当
counts中存在favorite键时,其值会增加。如果它不存在,我们定义counts[favorite]并将其设置为 1。此外,格式化字符串已经得到改进,以展示counts[favorite]。 -
Python 也允许对
counts进行排序。按照以下方式改进你的代码:# Sorts favorites by key import csv # Open CSV file with open("favorites.csv", "r") as file: # Create DictReader reader = csv.DictReader(file) # Counts counts = {} # Iterate over CSV file, counting favorites for row in reader: favorite = row["language"] if favorite in counts: counts[favorite] += 1 else: counts[favorite] = 1 # Print counts for favorite in sorted(counts): print(f"{favorite}: {counts[favorite]}")注意代码底部的
sorted(counts)。 -
如果你查看 Python 文档中
sorted函数的参数,你会发现它有许多内置参数。你可以利用一些这些内置参数,如下所示:# Sorts favorites by value using .get import csv # Open CSV file with open("favorites.csv", "r") as file: # Create DictReader reader = csv.DictReader(file) # Counts counts = {} # Iterate over CSV file, counting favorites for row in reader: favorite = row["language"] if favorite in counts: counts[favorite] += 1 else: counts[favorite] = 1 # Print counts for favorite in sorted(counts, key=counts.get, reverse=True): print(f"{favorite}: {counts[favorite]}")注意传递给
sorted的参数。key参数允许你告诉 Python 你希望用于排序项的方法。在这种情况下,使用counts.get按值排序。reverse=True告诉sorted从大到小排序。 -
Python 有许多库,我们可以在代码中利用这些库。其中之一是
collections,我们可以从中导入Counter。Counter将允许你访问每种语言的计数,而无需像我们之前的代码中看到的那样处理所有if语句。你可以按照以下方式实现:# Sorts favorites by value using .get import csv from collections import Counter # Open CSV file with open("favorites.csv", "r") as file: # Create DictReader reader = csv.DictReader(file) # Counts counts = Counter() # Iterate over CSV file, counting favorites for row in reader: favorite = row["language"] counts[favorite] += 1 # Print counts for favorite, count in counts.most_common(): print(f"{favorite}: {count}")注意到
counts = Counter()如何启用从collections导入的Counter类。
关系型数据库
-
Google、X 和 Meta 都使用关系型数据库来大规模存储他们的信息。
-
关系型数据库在称为 表格 的结构中以行和列的形式存储数据。
-
SQL 允许四种类型的命令:
Create Read Update Delete -
这四个操作被亲切地称为 CRUD。
-
我们可以使用 SQL 语法
CREATE TABLE table (column type, ...);创建一个数据库。但这个命令在哪里运行呢? -
sqlite3是一种具有本课程所需核心功能的关系型数据库。 -
我们可以在终端通过输入
sqlite3 favorites.db创建一个 SQL 数据库。在被提示时,我们将通过按y键同意创建favorites.db。 -
你会注意到提示符发生了变化,因为我们现在正在使用一个名为
sqlite的程序。 -
我们可以通过输入
.mode csv将sqlite置于csv模式。然后,我们可以通过输入.import favorites.csv favorites从我们的csv文件导入数据。看起来好像什么都没发生! -
我们可以输入
.schema来查看数据库的结构。 -
你可以使用
SELECT columns FROM table语法从表中读取项目。 -
例如,您可以输入
SELECT * FROM favorites;,这将打印favorites中的每一行。 -
您可以使用命令
SELECT language FROM favorites;来获取数据子集。 -
SQL 支持许多用于访问数据的命令,包括:
AVG COUNT DISTINCT LOWER MAX MIN UPPER -
例如,您可以输入
SELECT COUNT(*) FROM favorites;。此外,您可以输入SELECT DISTINCT language FROM favorites;以获取数据库中个别语言的列表。您甚至可以输入SELECT COUNT(DISTINCT language) FROM favorites;以获取这些语言的计数。 -
SQL 还提供了我们可以在查询中使用的附加命令:
WHERE -- adding a Boolean expression to filter our data LIKE -- filtering responses more loosely ORDER BY -- ordering responses LIMIT -- limiting the number of responses GROUP BY -- grouping responses together注意我们在 SQL 中使用
--来写注释。
SELECT
-
例如,我们可以执行
SELECT COUNT(*) FROM favorites WHERE language = 'C';。将显示计数。 -
此外,我们可以输入
SELECT COUNT(*) FROM favorites WHERE language = 'C' AND problem = 'Hello, World';。注意AND是如何用于缩小我们的结果的。 -
同样,我们可以执行
SELECT language, COUNT(*) FROM favorites GROUP BY language;。这将提供一个临时表,显示语言和计数。 -
我们可以通过输入以下命令来改进这一点:
SELECT language, COUNT(*) FROM favorites GROUP BY language ORDER BY COUNT(*);。这将按count对结果表进行排序。 -
同样,我们可以执行
SELECT COUNT(*) FROM favorites WHERE language = 'C' AND (problem = 'Hello, World' OR problem = 'Hello, It''s Me');。请注意,有两个''标记,以便以不混淆 SQL 的方式使用单引号。 -
此外,我们可以执行
SELECT COUNT(*) FROM favorites WHERE language = 'C' AND problem LIKE 'Hello, %';以找到以Hello,开头的任何问题(包括空格)。 -
我们还可以通过执行
SELECT language, COUNT(*) FROM favorites GROUP BY language;来按每种语言的值进行分组。 -
我们可以按以下方式排序输出:
SELECT language, COUNT(*) FROM favorites GROUP BY language ORDER BY COUNT(*) DESC;。 -
我们甚至可以在查询中创建别名,就像变量一样:
SELECT language, COUNT(*) AS n FROM favorites GROUP BY language ORDER BY n DESC;。 -
最后,我们可以限制输出为 1 个或多个值:
SELECT language, COUNT(*) AS n FROM favorites GROUP BY language ORDER BY n DESC LIMIT 1;。
INSERT
-
我们还可以使用
INSERT INTO table (column...) VALUES(value, ...);的形式将数据INSERT到 SQL 数据库中。 -
我们可以执行
INSERT INTO favorites (language, problem) VALUES ('SQL', 'Fiftyville');。 -
您可以通过执行
SELECT * FROM favorites;来验证此收藏夹的增加。
DELETE
DELETE允许您删除数据的一部分。例如,您可以DELETE FROM favorites WHERE Timestamp IS NULL;。这将删除任何Timestamp为NULL的记录。
UPDATE
-
我们还可以使用
UPDATE命令来更新数据。 -
例如,您可以执行
UPDATE favorites SET language = 'SQL', problem = 'Fiftyville';。这将覆盖所有之前将 C 和 Scratch 作为首选编程语言的语句。 -
注意,这些查询具有巨大的威力。因此,在实际环境中,您应该考虑谁有权限执行某些命令,以及您是否有可用的备份!
IMDb
-
我们可以想象一个我们可能想要创建的数据库,用于分类各种电视节目。我们可以创建一个包含诸如
title、star、star、star、star以及更多星星的电子表格。这种方法的缺点是它有很多浪费的空间。有些节目可能只有一个明星。而有些节目可能有几十个。 -
我们可以将数据库分成多个工作表。我们可以有一个
shows工作表、一个stars工作表和一个people工作表。在people工作表中,每个人可以有一个唯一的id。在shows工作表中,每个节目也可以有一个唯一的id。在名为stars的第三个工作表中,我们可以通过拥有show_id和person_id来关联每个节目对应的人员。虽然这是一个改进,但这并不是一个理想的数据库。 -
IMDb 提供了人员、节目、编剧、明星、类型和评分的数据库。这些表彼此之间如下相关:
![imdb relationships 代表各种 SQL 表的六个盒子,箭头指向每个盒子,显示它们彼此之间的多对多关系]()
-
下载完
shows.db后,你可以在终端窗口中执行sqlite3 shows.db。 -
让我们聚焦于数据库中名为
shows和ratings的两个表之间的关系。这两个表之间的关系可以如下表示:![imdb shows and ratings 一个称为 shows 的盒子和一个称为 ratings 的盒子]()
-
为了说明这些表之间的关系,我们可以执行以下命令:
SELECT * FROM ratings LIMIT 10;。检查输出后,我们可以执行SELECT * FROM shows LIMIT 10;。 -
检查
shows和rating,我们可以看到它们之间存在一对一的关系:一个节目有一个评分。 -
要了解数据库,在执行
.schema后,您不仅会发现每个表,还会发现每个字段中的各个字段。 -
更具体地说,您可以通过执行
.schema shows来了解shows内部的字段。您也可以执行.schema ratings来查看ratings内部的字段。 -
如您所见,
show_id存在于所有表中。在shows表中,它简单地被称为id。这个在所有字段之间都存在的公共字段被称为键。主键用于在表中标识唯一记录。外键用于通过指向另一个表中的主键来建立表之间的关系。您可以在ratings模式的方案中看到show_id是一个外键,它引用了shows中的id。 -
通过将数据存储在上述关系型数据库中,数据可以更有效地存储。
-
在 sqlite 中,我们有五种数据类型,包括:
BLOB -- binary large objects that are groups of ones and zeros INTEGER -- an integer NUMERIC -- for numbers that are formatted specially like dates REAL -- like a float TEXT -- for strings and the like -
此外,可以将列设置为添加特殊约束:
NOT NULL UNIQUE -
我们可以进一步利用这些数据来理解这些关系。执行
SELECT * FROM ratings;。这里有很多评分! -
我们可以通过执行
SELECT show_id FROM ratings WHERE rating >= 6.0 LIMIT 10;进一步限制这些数据。从这个查询中,你可以看到有 10 个节目被展示。然而,我们不知道每个show_id代表什么节目。 -
你可以通过执行
SELECT * FROM shows WHERE id = 626124;来发现这些节目是什么。 -
通过执行以下命令,我们可以使查询更高效:
SELECT title FROM shows WHERE id IN ( SELECT show_id FROM ratings WHERE rating >= 6.0 LIMIT 10 )注意,这个查询嵌套了两个查询。内部查询被外部查询使用。
JOINs
-
我们正在从
shows和ratings表中获取数据。注意shows和ratings都有一个共同的id。 -
我们如何临时合并表?可以使用
JOIN命令将表连接在一起。 -
执行以下命令:
SELECT * FROM shows JOIN ratings on shows.id = ratings.show_id WHERE rating >= 6.0 LIMIT 10;注意,这会产生一个比我们之前看到的更宽的表。
-
在之前的查询中已经展示了这些键之间的一对一关系,让我们来检查一些一对多关系。关注
genres表,执行以下命令:SELECT * FROM genres LIMIT 10;注意,这为我们提供了对原始数据的感觉。你可能注意到一个节目有三个值。这是一个一对多关系。
-
通过输入
.schema genres,我们可以了解更多关于genres表的信息。 -
执行以下命令来了解更多关于数据库中各种喜剧的信息:
SELECT title FROM shows WHERE id IN ( SELECT show_id FROM genres WHERE genre = 'Comedy' LIMIT 10 );注意,这产生了一个包括猫怪在内的喜剧列表。
-
要了解更多关于 Catweazle 的信息,可以通过连接各种表来实现:
SELECT * FROM shows JOIN genres ON shows.id = genres.show_id WHERE id = 63881;注意,这会产生一个临时表。拥有一个重复的表是可以的。
-
与一对一和一对多关系相比,可能存在多对多关系。
-
通过执行以下命令,我们可以了解更多关于节目办公室及其演员的信息:
SELECT name FROM people WHERE id IN (SELECT person_id FROM stars WHERE show_id = (SELECT id FROM shows WHERE title = 'The Office' AND year = 2005));注意,这会产生一个包含通过嵌套查询的各种明星名字的表。
-
我们找到史蒂夫·卡瑞尔主演的所有节目:
SELECT title FROM shows WHERE id IN (SELECT show_id FROM stars WHERE person_id = (SELECT id FROM people WHERE name = 'Steve Carell'));这会产生一个史蒂夫·卡瑞尔主演的节目标题列表。
-
这也可以用这种方式表达:
SELECT title FROM shows, stars, people WHERE shows.id = stars.show_id AND people.id = stars.person_id AND name = 'Steve Carell'; -
可以使用通配符
%运算符来查找所有名字以Steve C开头的人,可以使用以下语法:SELECT * FROM people WHERE name LIKE 'Steve C%';。
索引
-
虽然关系数据库比使用
CSV文件具有更快和更健壮的能力,但可以使用索引在表中优化数据。 -
索引可以被用来加速我们的查询。
-
我们可以通过在
sqlite3中执行.timer on来跟踪查询的速度。 -
要了解索引如何加速查询,运行以下命令:
SELECT * FROM shows WHERE title = 'The Office';注意查询执行后显示的时间。 -
然后,我们可以使用以下语法创建索引:
CREATE INDEX title_index ON shows (title);。这告诉sqlite3创建一个索引并对此列title进行一些特殊的底层优化。 -
这将创建一个名为B 树的数据结构,其外观类似于二叉树。然而,与二叉树不同,可以有超过两个子节点。
![b tree 从顶部有一个节点,该节点有四个子节点,下面有三个子节点来自一个节点,两个来自另一个节点,另外两个来自另一个节点,三个来自另一个节点]()
-
此外,我们可以创建索引如下:
CREATE INDEX name_index ON people (name); CREATE INDEX person_index ON stars (person_id); -
运行查询后,你会注意到查询运行得更快!
SELECT title FROM shows WHERE id IN (SELECT show_id FROM stars WHERE person_id = (SELECT id FROM people WHERE name = 'Steve Carell')); -
不幸的是,索引所有列将导致使用更多的存储空间。因此,在提高速度和存储空间之间有一个权衡。
在 Python 中使用 SQL
-
为了帮助在这个课程中处理 SQL,可以在你的代码中使用 CS50 库如下:
from cs50 import SQL -
与之前对 CS50 库的使用类似,这个库将帮助你在 Python 代码中利用 SQL 的复杂步骤。
-
你可以在文档中了解更多关于 CS50 库的 SQL 功能。
-
使用我们对 SQL 的新知识,我们现在可以利用 Python。
-
按照以下方式修改你的
favorites.py代码:# Searches database popularity of a problem from cs50 import SQL # Open database db = SQL("sqlite:///favorites.db") # Prompt user for favorite favorite = input("Favorite: ") # Search for title rows = db.execute("SELECT COUNT(*) AS n FROM favorites WHERE language = ?", favorite) # Get first (and only) row row = rows[0] # Print popularity print(row["n"])注意,
db = SQL("sqlite:///favorites.db")为 Python 提供了数据库文件的位置。然后,以rows开头的行执行使用db.execute的 SQL 命令。确实,这个命令将引号内的语法传递给db.execute函数。我们可以使用这种语法发出任何 SQL 命令。此外,注意rows作为字典列表返回。在这种情况下,只有一个结果,一行,作为字典返回到rows列表中。
竞争条件
-
有时使用 SQL 可能会导致一些问题。
-
你可以想象一个场景,多个用户可能同时访问同一个数据库并执行命令。
-
这可能导致代码被其他人的行为中断,从而造成数据丢失。
-
内置的 SQL 功能如
BEGIN TRANSACTION、COMMIT和ROLLBACK有助于避免一些这些竞争条件问题。
SQL 注入攻击
-
现在,仍然考虑上面的代码,你可能想知道上面的
?问号的作用。在 SQL 的现实中应用中可能出现的一个问题是所谓的注入攻击。注入攻击是指恶意行为者可以输入恶意的 SQL 代码。 -
例如,考虑以下登录界面:
![harvard key login screen 哈佛密钥登录界面,包含用户名和密码字段]()
-
如果在我们的代码中没有适当的安全措施,恶意行为者可以运行恶意代码。考虑以下:
rows = db.execute("SELECT COUNT(*) FROM users WHERE username = ? AND password = ?", username, password)注意,因为
?符号的位置,在查询盲目接受之前,可以在favorite上运行验证。 -
你永远不希望在查询中使用上述格式化的字符串或盲目信任用户的输入。
-
利用 CS50 库,该库将净化并移除任何潜在的恶意字符。
总结
在本节课中,你学习了更多与 Python 相关的语法。此外,你学习了如何将这一知识整合到以平面文件和关系数据库形式存在的数据中。最后,你了解了SQL。具体来说,我们讨论了…
-
平面文件数据库
-
关系数据库
-
如
SELECT、CREATE、INSERT、DELETE和UPDATE等 SQL 命令。 -
主键和外键
-
JOINs -
索引
-
在 Python 中使用 SQL
-
竞态条件
-
SQL 注入攻击
次次见!
第八讲
-
欢迎光临!
-
互联网
-
路由器
-
DNS
-
DHCP
-
HTTPS
-
HTML
-
正则表达式
-
CSS
-
框架
-
JavaScript
-
总结
欢迎光临!
- 在前几周,我们向您介绍了 Python,这是一种高级编程语言,它使用了我们在 C 语言中学到的相同构建块。今天,我们将进一步扩展这些构建块,在 HTML、CSS 和 JavaScript 中。
互联网
-
互联网是我们所有人都使用的技术。
-
使用我们前几周学到的技能,我们可以构建自己的网页和应用。
-
ARPANET 将互联网上的第一个节点连接在一起。
-
两点之间的点可以被认为是 路由器。
路由器
-
为了将数据从一个地方路由到另一个地方,我们需要做出 路由决策。也就是说,有人需要编程数据如何从 A 点传输到 B 点。
-
你可以想象数据可以从 A 点到 B 点有多个路径,当路由器拥堵时,数据可以通过另一条路径流动。数据 数据包 从一个路由器传输到另一个路由器,从一个计算机传输到另一个计算机。
-
TCP/IP 是两种协议,允许计算机在互联网上相互传输数据。
-
IP 或 互联网协议 是一种计算机可以在互联网上相互识别的方式。每台计算机在世界上都有一个唯一的地址。地址的形式如下:
#.#.#.# -
数字范围从
0到255。IP 地址是 32 位,这意味着这些地址可以容纳超过 40 亿个地址。较新的 IP 地址版本,采用 128 位,可以容纳更多的计算机! -
在现实世界中,服务器为我们做了很多工作。
-
数据包的结构如下:
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |Version| IHL |Type of Service| Total Length | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Identification |Flags| Fragment Offset | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Time to Live | Protocol | Header Checksum | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Source Address | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Destination Address | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Options | Padding | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ -
数据包是标准化的。源地址和目标地址包含在每个数据包中。
-
TCP,或传输控制协议,有助于跟踪发送的数据包顺序。
-
此外,TCP 用于区分不同的网络服务。例如,
80用于表示 HTTP,443用于表示 HTTPS。这些数字是 端口号。 -
当信息从一个位置发送到另一个位置时,会发送源 IP 地址、目标 IP 地址和 TCP 端口号。
-
这些协议也被用来将大文件分成多个部分或数据包。例如,一张大猫的照片可以分成多个数据包发送。当一个数据包丢失时,TCP/IP 可以从原始服务器再次请求丢失的数据包。
-
TCP 将在所有数据都已传输和接收后进行确认。
DNS
-
如果你需要记住一个 IP 地址来访问一个网站,那将会非常麻烦。
-
DNS,或 域名系统,是互联网上一组服务器,用于将像 harvard.edu 这样的网站地址路由到特定的 IP 地址。
-
DNS 简单来说是一个将特定的、完全限定的域名与特定的 IP 地址链接起来的表或数据库。
DHCP
-
DHCP 是一个确定你的设备 IP 地址的协议。
-
此外,此协议定义了你的设备使用的默认网关和名称服务器。
HTTPS
-
HTTP 或 超文本传输协议 是开发者用来通过数据从一个地方传输到另一个地方来构建强大和有用事物的应用层协议。HTTPS 是此协议的安全版本。
-
当你看到一个地址如
https://www.example.com时,你实际上是在隐式地访问该地址,并在其末尾有一个/。 -
路径 是在斜杠之后存在的部分。例如,
https://www.example.com/folder/file.html访问example.com并浏览到folder目录,然后访问名为file.html的文件。 -
.com被称为 顶级域名,用于表示与该地址关联的位置或组织类型。 -
在此地址中的
https是用来连接该网页地址的协议。通过协议,我们指的是 HTTP 使用GET或POST请求 从服务器获取信息。例如,你可以启动 Google Chrome,右键点击,并点击inspect。当你打开开发者工具并访问Network,选择Preserve log,你会看到Request Headers。你会看到GET的提及。这在其他浏览器中也是可能的,使用稍微不同的方法。 -
例如,在发出 GET 请求时,你的电脑可能向服务器发送以下内容:
GET / HTTP/2 Host: www.harvard.edu注意,这是通过 HTTP 请求在 www.harvard.edu 上提供的内容。
-
通常,在向服务器发出请求后,你将在
Response Headers中收到以下内容:HTTP/2 200 Content-Type: text/html -
检查这些日志的方法可能比必要的要复杂一些。你可以在 cs50.dev 上分析 HTTP 协议的工作。例如,在你的终端窗口中输入以下内容:
curl -I https://www.harvard.edu/注意,此命令的输出返回了服务器响应的所有头部值。
-
通过你的网页浏览器的开发者工具,你可以看到浏览上述网站时所有的 HTTP 请求。
-
此外,在你的终端窗口中执行以下命令:
curl -I https://harvard.edu注意,你会看到一个
301响应,为浏览器提供了一个指向正确网站的提示。 -
类似地,在你的终端窗口中执行以下命令:
curl -I http://www.harvard.edu/注意,
https中的s已被移除。服务器响应将显示响应为301,这意味着网站已永久迁移。 -
与
301类似,404状态码意味着指定的 URL 未找到。还有许多其他的响应代码,例如:200 OK 301 Moved Permanently 302 Found 304 Not Modified 307 Temporary Redirect 401 Unauthorized 403 Forbidden 404 Not Found 418 I'm a Teapot 500 Internal Server Error 503 Service Unavailable -
值得注意的是,当
500错误涉及到你创建的产品或应用程序时,这总是作为开发者的你的责任。这将在下周的问题集中尤为重要,也许对你的最终项目也是如此!
HTML
-
HTML 或 超文本标记语言 由 标签 组成,每个标签可能有一些 属性 来描述它。
-
在你的终端中,输入
code hello.html并编写如下代码:<!DOCTYPE html> <!-- Demonstrates HTML --> <html lang="en"> <head> <title>hello, title</title> </head> <body> hello, body </body> </html>注意到
html标签既打开了又关闭了这个文件。此外,注意lang属性,它修改了html标签的行为。还要注意,既有head标签也有body标签。缩进不是必需的,但确实暗示了一个层次结构。 -
你可以通过输入
http-server来提供你的代码。现在,提供的内容可以通过一个非常长的 URL 访问。如果你点击它,你可以访问由你自己的代码生成的网站。 -
当你访问这个 URL 时,注意文件名
hello.html出现在这个 URL 的末尾。此外,根据 URL,注意服务器是通过端口 8080 提供服务的。 -
标签的层次结构可以表示如下:
![DOM html 代码旁边显示层次结构,显示父节点和子节点]()
-
了解这个层次结构将在我们学习 JavaScript 时非常有用。
-
浏览器将自上而下、从左到右读取你的 HTML 文件。
-
由于在 HTML 中空白和缩进实际上被忽略,你需要使用
<p>段落标签来打开和关闭一个段落。考虑以下:<!DOCTYPE html> <!-- Demonstrates paragraphs --> <html lang="en"> <head> <title>paragraphs</title> </head> <body> <p> Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus convallis scelerisque quam, vel hendrerit lectus viverra eu. Praesent posuere eget lectus ut faucibus. Etiam eu velit laoreet, gravida lorem in, viverra est. Cras ut purus neque. In porttitor non lorem id lobortis. Mauris gravida metus libero, quis maximus dui porta at. Donec lacinia felis consectetur venenatis scelerisque. Nulla eu nisl sollicitudin, varius velit sit amet, vehicula erat. Curabitur sollicitudin felis sit amet orci mattis, a tempus nulla pulvinar. Aliquam erat volutpat. </p> <p> Mauris ut dui in eros semper hendrerit. Morbi vel elit mi. Sed sit amet ex non quam dignissim dignissim et vel arcu. Pellentesque eget elementum orci. Morbi ac cursus ex. Pellentesque quis turpis blandit orci dapibus semper sed non nunc. Nulla et dolor nec lacus finibus volutpat. Sed non lorem diam. Donec feugiat interdum interdum. Vivamus et justo in enim blandit fermentum vel at elit. Phasellus eu ante vitae ligula varius aliquet. Etiam id posuere nibh. </p> <p> Aenean venenatis convallis ante a rhoncus. Nullam in metus vel diam vehicula tincidunt. Donec lacinia metus sem, sit amet egestas elit blandit sit amet. Nunc egestas sem quis nisl mattis semper. Pellentesque ut magna congue lorem eleifend sodales. Donec tortor tortor, aliquam vitae mollis sed, interdum ut lectus. Mauris non purus quis ipsum lacinia tincidunt. </p> <p> Integer at justo lacinia libero blandit aliquam ut ut dui. Quisque tincidunt facilisis venenatis. Nullam dictum odio quis lorem luctus, vel malesuada dolor luctus. Aenean placerat faucibus enim a facilisis. Maecenas eleifend quis massa sed eleifend. Ut ultricies, dui ac vulputate hendrerit, ex metus iaculis diam, vitae fermentum libero dui et ante. Phasellus suscipit, arcu ut consequat sagittis, massa urna accumsan massa, eu aliquet nulla lorem vitae arcu. Pellentesque rutrum felis et metus porta semper. Nam ac consectetur mauris. </p> <p> Suspendisse rutrum vestibulum odio, sed venenatis purus condimentum sed. Morbi ornare tincidunt augue eu auctor. Vivamus sagittis ac lectus at aliquet. Nulla urna mauris, interdum non nibh in, vehicula porta enim. Donec et posuere sapien. Pellentesque ultrices scelerisque ipsum, vel fermentum nibh tincidunt et. Proin gravida porta ipsum nec scelerisque. Vestibulum fringilla erat at turpis laoreet, nec hendrerit nisi scelerisque. </p> <p> Sed quis malesuada mi. Nam id purus quis augue sagittis pharetra. Nulla facilisi. Maecenas vel fringilla ante. Cras tristique, arcu sit amet blandit auctor, urna elit ultricies lacus, a malesuada eros dui id massa. Aliquam sem odio, pretium vel cursus eget, scelerisque at urna. Vestibulum posuere a turpis consectetur consectetur. Cras consequat, risus quis tempor egestas, nulla ipsum ornare erat, nec accumsan nibh lorem nec risus. Integer at iaculis lacus. Integer congue nunc massa, quis molestie felis pellentesque vestibulum. Nulla odio tortor, aliquam nec quam in, ornare aliquet sapien. </p> </body> </html>注意到段落从
<p>标签开始,并以</p>标签结束。 -
HTML 允许表示标题:
<!DOCTYPE html> <!-- Demonstrates headings (for chapters, sections, subsections, etc.) --> <html lang="en"> <head> <title>headings</title> </head> <body> <h1>One</h1> <p> Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus convallis scelerisque quam, vel hendrerit lectus viverra eu. Praesent posuere eget lectus ut faucibus. Etiam eu velit laoreet, gravida lorem in, viverra est. Cras ut purus neque. In porttitor non lorem id lobortis. Mauris gravida metus libero, quis maximus dui porta at. Donec lacinia felis consectetur venenatis scelerisque. Nulla eu nisl sollicitudin, varius velit sit amet, vehicula erat. Curabitur sollicitudin felis sit amet orci mattis, a tempus nulla pulvinar. Aliquam erat volutpat. </p> <h2>Two</h2> <p> Mauris ut dui in eros semper hendrerit. Morbi vel elit mi. Sed sit amet ex non quam dignissim dignissim et vel arcu. Pellentesque eget elementum orci. Morbi ac cursus ex. Pellentesque quis turpis blandit orci dapibus semper sed non nunc. Nulla et dolor nec lacus finibus volutpat. Sed non lorem diam. Donec feugiat interdum interdum. Vivamus et justo in enim blandit fermentum vel at elit. Phasellus eu ante vitae ligula varius aliquet. Etiam id posuere nibh. </p> <h3>Three</h3> <p> Aenean venenatis convallis ante a rhoncus. Nullam in metus vel diam vehicula tincidunt. Donec lacinia metus sem, sit amet egestas elit blandit sit amet. Nunc egestas sem quis nisl mattis semper. Pellentesque ut magna congue lorem eleifend sodales. Donec tortor tortor, aliquam vitae mollis sed, interdum ut lectus. Mauris non purus quis ipsum lacinia tincidunt. </p> <h4>Four</h4> <p> Integer at justo lacinia libero blandit aliquam ut ut dui. Quisque tincidunt facilisis venenatis. Nullam dictum odio quis lorem luctus, vel malesuada dolor luctus. Aenean placerat faucibus enim a facilisis. Maecenas eleifend quis massa sed eleifend. Ut ultricies, dui ac vulputate hendrerit, ex metus iaculis diam, vitae fermentum libero dui et ante. Phasellus suscipit, arcu ut consequat sagittis, massa urna accumsan massa, eu aliquet nulla lorem vitae arcu. Pellentesque rutrum felis et metus porta semper. Nam ac consectetur mauris. </p> <h5>Five</h5> <p> Suspendisse rutrum vestibulum odio, sed venenatis purus condimentum sed. Morbi ornare tincidunt augue eu auctor. Vivamus sagittis ac lectus at aliquet. Nulla urna mauris, interdum non nibh in, vehicula porta enim. Donec et posuere sapien. Pellentesque ultrices scelerisque ipsum, vel fermentum nibh tincidunt et. Proin gravida porta ipsum nec scelerisque. Vestibulum fringilla erat at turpis laoreet, nec hendrerit nisi scelerisque. </p> <h6>Six</h6> <p> Sed quis malesuada mi. Nam id purus quis augue sagittis pharetra. Nulla facilisi. Maecenas vel fringilla ante. Cras tristique, arcu sit amet blandit auctor, urna elit ultricies lacus, a malesuada eros dui id massa. Aliquam sem odio, pretium vel cursus eget, scelerisque at urna. Vestibulum posuere a turpis consectetur consectetur. Cras consequat, risus quis tempor egestas, nulla ipsum ornare erat, nec accumsan nibh lorem nec risus. Integer at iaculis lacus. Integer congue nunc massa, quis molestie felis pellentesque vestibulum. Nulla odio tortor, aliquam nec quam in, ornare aliquet sapien. </p> </body> </html>注意到
<h1>、<h2>和<h3>表示不同的标题级别。 -
我们也可以在 HTML 中创建无序列表:
<!DOCTYPE html> <!-- Demonstrates (ordered) lists --> <html lang="en"> <head> <title>list</title> </head> <body> <ul> <li>foo</li> <li>bar</li> <li>baz</li> </ul> </body> </html>注意到
<ul>标签创建了一个包含三个项目的无序列表。 -
我们也可以在 HTML 中创建有序列表:
<!DOCTYPE html> <!-- Demonstrates (ordered) lists --> <html lang="en"> <head> <title>list</title> </head> <body> <ol> <li>foo</li> <li>bar</li> <li>baz</li> </ol> </body> </html>注意到
<ol>标签创建了一个包含三个项目的有序列表。 -
我们也可以在 HTML 中创建一个表格:
<!DOCTYPE html> <!-- Demonstrates table --> <html lang="en"> <head> <title>table</title> </head> <body> <table> <tr> <td>1</td> <td>2</td> <td>3</td> </tr> <tr> <td>4</td> <td>5</td> <td>6</td> </tr> <tr> <td>7</td> <td>8</td> <td>9</td> </tr> <tr> <td>*</td> <td>0</td> <td>#</td> </tr> </table> </body> </html>表格也有打开和关闭每个元素的标签。此外,注意 HTML 中注释的语法。
-
图像也可以在 HTML 中使用:
<!DOCTYPE html> <!-- Demonstrates image --> <html lang="en"> <head> <title>image</title> </head> <body> <img alt="photo of bridge" src="bridge.png"> </body> </html>注意到
src="bridge.png"指示了图像文件可以找到的路径。 -
视频也可以包含在 HTML 中:
<!DOCTYPE html> <!-- Demonstrates video --> <html lang="en"> <head> <title>video</title> </head> <body> <video controls muted> <source src="video.mp4" type="video/mp4"> </video> </body> </html>注意到
type属性指定这是一个mp4类型的视频。此外,注意controls和muted是如何传递给video的。 -
你也可以在各个网页之间建立链接:
<!DOCTYPE html> <!-- Demonstrates link --> <html lang="en"> <head> <title>link</title> </head> <body> Visit <a href="https://www.harvard.edu">Harvard</a>. </body> </html>注意到
<a>或 锚点 标签用于使Harvard成为可链接的文本。 -
你也可以创建类似于 Google 搜索的表单:
<!DOCTYPE html> <!-- Demonstrates form --> <html lang="en"> <head> <title>search</title> </head> <body> <form action="https://www.google.com/search" method="get"> <input name="q" type="search"> <input type="submit" value="Google Search"> </form> </body> </html>注意到
form标签打开并提供它将采取的action属性。input字段被包含在内,传递名称q和类型为search。 -
我们可以如下改进这个搜索:
<!DOCTYPE html> <!-- Demonstrates additional form attributes --> <html lang="en"> <head> <title>search</title> </head> <body> <form action="https://www.google.com/search" method="get"> <input autocomplete="off" autofocus name="q" placeholder="Query" type="search"> <button>Google Search</button> </form> </body> </html>注意到
autocomplete被设置为off。autofocus被启用。 -
我们已经看到了许多你可以添加到网站上的 HTML 元素中的一部分。如果你有关于要添加到网站上的想法(我们还没有看到,比如按钮、音频文件等),尝试在 Google 上搜索“X in HTML”以找到正确的语法!同样,你可以使用 cs50.ai 来帮助你发现更多的 HTML 功能!
正则表达式
-
正则表达式 或 regexes 是一种确保用户提供的数据符合特定格式的手段。
-
我们可以自己实现一个利用正则表达式的注册页面,如下所示:
<!DOCTYPE html> <!-- Demonstrates type="email" --> <html lang="en"> <head> <title>register</title> </head> <body> <form> <input autocomplete="off" autofocus name="email" placeholder="Email" type="email"> <button>Register</button> </form> </body> </html>注意,
input标签包含属性指定这是email类型的。浏览器知道要双重检查输入是否为电子邮件地址。 -
虽然浏览器使用这些内置属性来检查电子邮件地址,但我们可以添加一个
pattern属性来确保只有特定的数据出现在电子邮件地址中:<!DOCTYPE html> <!-- Demonstrates pattern attribute --> <html lang="en"> <head> <title>register</title> </head> <body> <form> <input autocomplete="off" autofocus name="email" pattern=".+@.+\.edu" placeholder="Email" type="email"> <button>Register</button> </form> </body> </html>注意,
pattern属性被传递了一个正则表达式,表示电子邮件地址必须包含一个@符号和一个.edu。 -
您可以从 Mozilla 的文档 中了解更多关于正则表达式的信息。此外,您可以访问 cs50.ai 获取提示。
CSS
-
CSS,或 层叠样式表,是一种标记语言,允许您微调 HTML 文件的审美。 -
CSS 中充满了 属性,它们包括键值对。
-
在您的终端中,键入
code home.html并编写如下代码:<!DOCTYPE html> <!-- Demonstrates inline CSS with P tags --> <html lang="en"> <head> <title>css</title> </head> <body> <p style="font-size: large; text-align: center;"> John Harvard </p> <p style="font-size: medium; text-align: center;"> Welcome to my home page! </p> <p style="font-size: small; text-align: center;"> Copyright © John Harvard </p> </body> </html>注意,一些
style属性被提供给<p>标签。font-size被设置为large、medium或small。然后text-align被设置为居中。 -
虽然正确,但上述设计并不理想。我们可以通过修改代码来去除冗余,如下所示:
<!DOCTYPE html> <!-- Removes outer DIV --> <html lang="en"> <head> <title>css</title> </head> <body style="text-align: center"> <div style="font-size: large"> John Harvard </div> <div style="font-size: medium"> Welcome to my home page! </div> <div style="font-size: small"> Copyright © John Harvard </div> </body> </html>注意,
<div>标签被用来将这个 HTML 文件划分为特定的区域。text-align: center被应用于整个 HTML 的主体部分。因为body内部的所有内容都是body的子元素,所以center属性会级联到这些子元素。 -
结果表明,HTML 中包含了一些新的语义标签。我们可以如下修改我们的代码:
<!DOCTYPE html> <!-- Uses semantic tags instead of DIVs --> <html lang="en"> <head> <title>css</title> </head> <body style="text-align: center"> <header style="font-size: large"> John Harvard </header> <main style="font-size: medium"> Welcome to my home page! </main> <footer style="font-size: small"> Copyright © John Harvard </footer> </body> </html>注意,
header和footer都被分配了不同的样式。 -
将样式和信息都放在同一个位置的做法并不好。我们可以将样式元素移动到文件顶部,如下所示:
<!-- Demonstrates class selectors --> <html lang="en"> <head> <style> .centered { text-align: center; } .large { font-size: large; } .medium { font-size: medium; } .small { font-size: small; } </style> <title>css</title> </head> <body class="centered"> <header class="large"> John Harvard </header> <main class="medium"> Welcome to my home page! </main> <footer class="small"> Copyright © John Harvard </footer> </body> </html>注意,所有的样式标签都被放置在
head部分的style标签包装器中。此外,注意我们已经为我们的元素分配了名为centered、large、medium和small的 类,并且我们通过在名称前放置一个点来选择这些类,例如.centered。 -
结果表明,我们可以将所有的样式代码移动到一个特殊的文件中,称为 CSS 文件。我们可以创建一个名为
style.css的文件,并将我们的类粘贴在那里:.centered { text-align: center; } .large { font-size: large; } .medium { font-size: medium; } .small { font-size: small; }注意,这正是出现在我们的 HTML 文件中的内容。
-
然后,我们可以告诉浏览器在哪里找到这个 HTML 文件的 CSS:
<!DOCTYPE html> <!-- Demonstrates external stylesheets --> <html lang="en"> <head> <link href="style.css" rel="stylesheet"> <title>css</title> </head> <body class="centered"> <header class="large"> John Harvard </header> <main class="medium"> Welcome to my home page! </main> <footer class="small"> Copyright © John Harvard </footer> </body> </html>注意,
style.css被链接到这个 HTML 文件作为样式表,告诉浏览器在哪里找到我们创建的样式。
框架
-
与我们可以在 Python 中利用的第三方库类似,还有称为 框架 的第三方库,我们可以利用这些框架与我们的 HTML 文件一起使用。
-
Bootstrap是我们可以使用来美化我们的 HTML 并轻松完善设计元素的框架之一,这样我们的页面就更容易阅读。
-
通过在 HTML 文件的
head部分添加以下link标签,可以使用 Bootstrap:<head> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <title>bootstrap</title> </head> -
考虑以下 HTML 代码:
<!DOCTYPE html> <!-- Demonstrates table --> <html lang="en"> <head> <title>phonebook</title> </head> <body> <table> <thead> <tr> <th>Name</th> <th>Number</th> </tr> </thead> <tbody> <tr> <td>Carter</td> <td>+1-617-495-1000</td> </tr> <tr> <td>David</td> <td>+1-617-495-1000</td> </tr> <tr> <td>John</td> <td>+1-949-468-2750</td> </tr> </tbody> </table> </body> </html>注意,当查看这个页面的服务版本时,它相当简单。
-
现在考虑以下实现 Bootstrap 使用的 HTML 代码:
<!DOCTYPE html> <!-- Demonstrates table with Bootstrap --> <html lang="en"> <head> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> <title>phonebook</title> </head> <body> <table class="table"> <thead> <tr> <th scope="col">Name</th> <th scope="col">Number</th> </tr> </thead> <tbody> <tr> <td>Carter</td> <td>+1-617-495-1000</td> </tr> <tr> <td>David</td> <td>+1-949-468-2750</td> </tr> </tbody> </table> </body> </html>注意,现在这个网站看起来多么漂亮。
-
类似地,考虑以下我们之前创建的搜索页面的扩展:
<!DOCTYPE html> <!-- Demonstrates layout with Bootstrap --> <html lang="en"> <head> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> <title>search</title> </head> <body> <div class="container-fluid"> <ul class="m-3 nav"> <li class="nav-item"> <a class="nav-link text-dark" href="https://about.google/">About</a> </li> <li class="nav-item"> <a class="nav-link text-dark" href="https://store.google.com/">Store</a> </li> <li class="nav-item ms-auto"> <a class="nav-link text-dark" href="https://www.google.com/gmail/">Gmail</a> </li> <li class="nav-item"> <a class="nav-link text-dark" href="https://www.google.com/imghp">Images</a> </li> <li class="nav-item"> <a class="nav-link text-dark" href="https://www.google.com/intl/en/about/products"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-grid-3x3-gap-fill" viewBox="0 0 16 16"> <path d="M1 2a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2zm5 0a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V2zm5 0a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V2zM1 7a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V7zm5 0a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7zm5 0a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V7zM1 12a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-2zm5 0a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-2zm5 0a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-2z"/> </svg> </a> </li> <li class="nav-item"> <a class="btn btn-primary" href="https://accounts.google.com/ServiceLogin" role="button">Sign in</a> </li> </ul> <div class="text-center"> <!-- https://knowyourmeme.com/memes/happy-cat --> <img alt="Happy Cat" class="img-fluid w-25" src="cat.gif"> <form action="https://www.google.com/search" class="mt-4" method="get"> <input autocomplete="off" autofocus class="form-control form-control-lg mb-4 mx-auto w-50" name="q" placeholder="Query" type="search"> <button class="btn btn-light">Google Search</button> <button class="btn btn-light" name="btnI">I'm Feeling Lucky</button> </form> </div> </div> </body> </html>这个版本的页面非常风格化,多亏了 Bootstrap。
-
你可以在Bootstrap 文档中了解更多相关信息。
JavaScript
-
JavaScript 是另一种允许在网页中进行交互的编程语言。
-
考虑以下
hello.html的实现,它包含了 JavaScript 和 HTML:<!DOCTYPE html> <!-- Demonstrates onsubmit --> <html lang="en"> <head> <script> function greet() { alert('hello, ' + document.querySelector('#name').value); } </script> <title>hello</title> </head> <body> <form onsubmit="greet(); return false;"> <input autocomplete="off" autofocus id="name" placeholder="Name" type="text"> <input type="submit"> </form> </body> </html>注意,这个表单使用了一个
onsubmit属性来触发文件顶部的script。该脚本使用alert创建一个弹出警告。#name.value指向页面上的文本框,并获取用户输入的值。 -
通常,将
onsubmit和 JavaScript 混合使用被认为是不良的设计。我们可以将我们的代码改进如下:<!DOCTYPE html> <!-- Demonstrates DOMContentLoaded --> <html lang="en"> <head> <script> document.addEventListener('DOMContentLoaded', function() { document.querySelector('form').addEventListener('submit', function(e) { alert('hello, ' + document.querySelector('#name').value); e.preventDefault(); }); }); </script> <title>hello</title> </head> <body> <form> <input autocomplete="off" autofocus id="name" placeholder="Name" type="text"> <input type="submit"> </form> </body> </html>注意,这个版本的代码创建了一个
addEventListener来监听表单submit事件的触发。注意DOMContentLoaded确保在执行 JavaScript 之前整个页面已经加载完成。 -
我们可以将此代码改进如下:
<!DOCTYPE html> <!-- Demonstrates keyup and template literals --> <html lang="en"> <head> <script> document.addEventListener('DOMContentLoaded', function() { let input = document.querySelector('input'); input.addEventListener('keyup', function(event) { let name = document.querySelector('p'); if (input.value) { name.innerHTML = `hello, ${input.value}`; } else { name.innerHTML = 'hello, whoever you are'; } }); }); </script> <title>hello</title> </head> <body> <form> <input autocomplete="off" autofocus placeholder="Name" type="text"> </form> <p></p> </body> </html>注意,当用户输入一个名字时,内存中的 DOM 会动态更新。如果
input中有值,在键盘的keyup事件发生时,DOM 会更新。否则,会显示默认文本。 -
JavaScript 允许你动态地读取和修改加载到内存中的 HTML 文档,这样用户就不需要重新加载来查看更改。
-
考虑以下 HTML 代码:
<!DOCTYPE html> <!-- Demonstrates programmatic changes to style --> <html lang="en"> <head> <title>background</title> </head> <body> <button id="red">R</button> <button id="green">G</button> <button id="blue">B</button> <script> let body = document.querySelector('body'); document.querySelector('#red').addEventListener('click', function() { body.style.backgroundColor = 'red'; }); document.querySelector('#green').addEventListener('click', function() { body.style.backgroundColor = 'green'; }); document.querySelector('#blue').addEventListener('click', function() { body.style.backgroundColor = 'blue'; }); </script> </body> </html>注意,JavaScript 监听特定按钮的点击事件。在点击时,页面上的某些样式属性会发生变化。
body被定义为页面的主体。然后,一个事件监听器等待按钮之一被点击。然后,body.style.backgroundColor被改变。 -
类似地,考虑以下:
<!DOCTYPE html> <html lang="en"> <head> <script> // Toggles visibility of greeting function blink() { let body = document.querySelector('body'); if (body.style.visibility == 'hidden') { body.style.visibility = 'visible'; } else { body.style.visibility = 'hidden'; } } // Blink every 500ms window.setInterval(blink, 500); </script> <title>blink</title> </head> <body> hello, world </body> </html>这个例子在设定的时间间隔闪烁文本。注意,
window.setInterval接受两个参数:一个要调用的函数和函数调用之间的等待期(以毫秒为单位)。 -
考虑以下实现自动完成文本的 JavaScript 代码:
<!DOCTYPE html> <html lang="en"> <head> <title>autocomplete</title> </head> <body> <input autocomplete="off" autofocus placeholder="Query" type="text"> <ul></ul> <script src="large.js"></script> <script> let input = document.querySelector('input'); input.addEventListener('keyup', function(event) { let html = ''; if (input.value) { for (word of WORDS) { if (word.startsWith(input.value)) { html += `<li>${word}</li>`; } } } document.querySelector('ul').innerHTML = html; }); </script> </body> </html>这是一个自动完成的 JavaScript 实现。它从一个名为
large.js的文件中获取数据(此处未展示),该文件是一个单词列表。 -
JavaScript 的功能很多,可以在JavaScript 文档中找到。
总结
在本课中,你学习了如何创建自己的 HTML 文件,为其添加样式,利用第三方框架,以及使用 JavaScript。具体来说,我们讨论了…
-
TCP/IP
-
DNS
-
HTML
-
正则表达式
-
CSS
-
框架
-
JavaScript
下次再见!
第九讲
-
欢迎!
-
http-server
-
Flask
-
表单
-
模板
-
请求方法
-
Frosh IMs
-
Flask 和 SQL
-
Cookies 和 Session
-
购物车
-
显示
-
APIs
-
JSON
-
总结
欢迎光临!
-
在之前的几周中,你已经学习了多种编程语言、技术和策略。
-
事实上,这门课程远不止是 C 语言课程 或 Python 课程,而更多的是一门 编程课程,这样你就可以继续追随未来的趋势。
-
在过去的几周中,你已经学习了 如何学习 编程。
-
今天,我们将从 HTML 和 CSS 转向结合 HTML、CSS、SQL、Python 和 JavaScript,以便你可以创建自己的 Web 应用程序。
-
你可以考虑使用这周学到的技能来创建你的最终项目。
http-server
-
到目前为止,你所看到的 HTML 都是预先编写和静态的。
-
在过去,当你访问一个页面时,浏览器会下载一个 HTML 页面,你能够查看它。这些被认为是 静态 页面,因为在 HTML 中编程的内容正是用户看到的和下载到他们互联网浏览器中的内容。
-
动态页面指的是 Python 和类似语言创建 HTML 的即时能力。因此,你可以拥有由代码根据用户输入或行为在服务器端生成的 Web 页面。
-
你过去使用
http-server来提供你的网页服务。今天,我们将利用一个新的服务器,它可以解析出网址并根据提供的 URL 执行操作。 -
此外,上周,你看到了以下这样的 URL:
https://www.example.com/folder/file.html注意到
file.html是位于example.com的folder文件夹中的一个 HTML 文件。
Flask
-
这周,我们介绍了与 路由(如
https://www.example.com/route?key=value)交互的能力,其中特定的功能可以通过 URL 中提供的键和值在服务器上生成。 -
Flask 是一个第三方库,它允许你使用 Flask 框架或微框架在 Python 中托管 Web 应用程序。
-
你可以在终端窗口中执行
flask run来运行 Flask,在 cs50.dev。 -
要这样做,你需要一个名为
app.py的文件和另一个名为requirements.txt的文件。app.py包含代码,告诉 Flask 如何运行你的 Web 应用程序。requirements.txt包含了运行 Flask 应用程序所需的库列表。 -
这里是
requirements.txt的一个示例:Flask注意到在这个文件中只有
Flask出现。这是因为 Flask 是运行 Flask 应用程序所必需的。 -
这里是一个非常简单的
app.pyFlask 应用程序:# Says hello to world by returning a string of text from flask import Flask, render_template, request app = Flask(__name__) @app.route("/") def index(): return "hello, world"注意到
/路由简单地返回文本hello, world。 -
我们还可以创建实现 HTML 的代码:
# Says hello to world by returning a string of HTML from flask import Flask, render_template, request app = Flask(__name__) @app.route("/") def index(): return '<!DOCTYPE html><html lang="en"><head><title>hello</title></head><body>hello, world</body></html>'注意到它不是返回简单的文本,而是提供了 HTML。
-
改进我们的应用程序,我们还可以通过创建一个名为
templates的文件夹并创建一个包含以下代码的index.html文件来根据模板提供 HTML:<!DOCTYPE html> <html lang="en"> <head> <meta name="viewport" content="initial-scale=1, width=device-width"> <title>hello</title> </head> <body> hello, {{ name }} </body> </html>注意双大括号
{{ name }},它是为我们 Flask 服务器稍后提供的某个内容的占位符。 -
然后,在
templates文件夹所在的同一文件夹中,创建一个名为app.py的文件,并添加以下代码:# Uses request.args.get from flask import Flask, render_template, request app = Flask(__name__) @app.route("/") def index(): name = request.args.get("name", "world") return render_template("index.html", name=name)注意,这段代码将
app定义为 Flask 应用程序。然后,它定义了app的/路由,返回包含name参数的index.html内容。默认情况下,request.args.get函数将查找用户提供的name。如果没有提供名称,它将默认为world。"@app.route" 通常被称为装饰器。 -
您可以通过在终端窗口中输入
flask run来运行这个网络应用程序。如果 Flask 没有运行,请确保上述每个文件中的语法都正确。此外,如果 Flask 无法运行,请确保您的文件组织如下:/templates index.html app.py requirements.txt -
一旦运行起来,您将被提示点击一个链接。一旦您导航到该网页,请尝试在浏览器 URL 栏的基本 URL 中添加
?name=[Your Name]。
表单
-
在改进我们的程序时,我们知道大多数用户不会在地址栏中输入参数。相反,程序员依赖于用户在网页上填写表单。因此,我们可以按如下方式修改
index.html:<!DOCTYPE html> <html lang="en"> <head> <meta name="viewport" content="initial-scale=1, width=device-width"> <title>hello</title> </head> <body> <form action="/greet" method="get"> <input autocomplete="off" autofocus name="name" placeholder="Name" type="text"> <button type="submit">Greet</button> </form> </body> </html>注意现在创建了一个表单,它接受用户的姓名,并将其传递给名为
/greet的路由。"autocomplete" 已关闭。此外,包含文本name的placeholder也被包含在内。此外,注意meta标签是如何被用来使网页响应式的。 -
此外,我们还可以按如下方式修改
app.py:# Adds a form, second route from flask import Flask, render_template, request app = Flask(__name__) @app.route("/") def index(): return render_template("index.html") @app.route("/greet") def greet(): return render_template("greet.html", name=request.args.get("name", "world"))注意默认路径将显示一个表单,让用户输入他们的姓名。
/greet路由将name传递到该网页。 -
为了完成这个实现,您需要在
templates文件夹中创建一个名为greet.html的模板,如下所示:<!DOCTYPE html> <html lang="en"> <head> <meta name="viewport" content="initial-scale=1, width=device-width"> <title>hello</title> </head> <body> hello, {{ name }} </body> </html>注意,现在这个路由将向用户显示问候语,然后是他们的名字。
模板
-
我们的网页
index.html和greet.html有很多相同的数据。如果允许主体内容独特,但复制相同的布局从页面到页面,岂不是很好? -
首先,创建一个新的模板
layout.html并编写如下代码:<!DOCTYPE html> <html lang="en"> <head> <meta name="viewport" content="initial-scale=1, width=device-width"> <title>hello</title> </head> <body> {% block body %}{% endblock %} </body> </html>注意到
{% block body %}{% endblock %}允许从其他 HTML 文件中插入其他代码。 -
然后,按如下方式修改您的
index.html:{% extends "layout.html" %} {% block body %} <form action="/greet" method="get"> <input autocomplete="off" autofocus name="name" placeholder="Name" type="text"> <button type="submit">Greet</button> </form> {% endblock %}注意到
{% extends "layout.html" %}这行代码告诉服务器从哪里获取此页面的布局。然后,{% block body %}{% endblock %}告诉要插入layout.html的代码。 -
最后,按如下方式修改
greet.html:{% extends "layout.html" %} {% block body %} hello, {{ name }} {% endblock %}注意这段代码更短、更紧凑。
请求方法
-
您可以想象一些场景,在这些场景中不能安全地使用
get,因为用户名和密码会出现在 URL 中。 -
我们可以通过修改
app.py来利用post方法帮助解决这个问题:# Switches to POST from flask import Flask, render_template, request app = Flask(__name__) @app.route("/") def index(): return render_template("index.html") @app.route("/greet", methods=["POST"]) def greet(): return render_template("greet.html", name=request.form.get("name", "world"))注意到在
/greet路由中添加了POST,并且我们使用request.form.get而不是request.args.get。 -
这告诉服务器深入查看虚拟信封,不要在 URL 中暴露
post中的项目。 -
尽管如此,我们可以通过使用单个路由来同时处理
get和post来进一步改进此代码。为此,修改app.py如下:# Uses a single route from flask import Flask, render_template, request app = Flask(__name__) @app.route("/", methods=["GET", "POST"]) def index(): if request.method == "POST": return render_template("greet.html", name=request.form.get("name", "world")) return render_template("index.html")注意到
get和post都是在单个路由中完成的。然而,request.method被用来根据用户请求的路由类型正确路由。 -
因此,你可以按照以下方式修改你的
index.html:{% extends "layout.html" %} {% block body %} <form action="/" method="post"> <input autocomplete="off" autofocus name="name" placeholder="Name" type="text"> <button type="submit">Greet</button> </form> {% endblock %}注意到表单的
action已被更改。 -
尽管如此,此代码中仍然存在一个错误。根据我们的新实现,当有人在没有输入名称的情况下填写表单时,会显示没有名称的
Hello,。我们可以通过以下方式编辑app.py来改进我们的代码:# Moves default value to template from flask import Flask, render_template, request app = Flask(__name__) @app.route("/", methods=["GET", "POST"]) def index(): if request.method == "POST": return render_template("greet.html", name=request.form.get("name")) return render_template("index.html")注意到
name=request.form.get("name"))已被更改。 -
最后,按照以下方式修改
greet.html:{% extends "layout.html" %} {% block body %} hello, {% if name -%} {{ name }} {%- else -%} world {%- endif %} {% endblock %}注意到
hello, {{ name }}是如何改变以允许在没有识别到名称时输出默认值。 -
由于我们已经更改了许多文件,你可能希望将你的最终代码与我们的最终代码进行比较。
Frosh IMs
-
Frosh IMs 或froshims是一个允许学生注册校内体育活动的网络应用程序。
-
关闭所有与
hello相关的窗口,并在终端窗口中输入mkdir froshims创建一个文件夹。然后,输入cd froshims浏览到这个文件夹。在此文件夹内,通过输入mkdir templates创建一个名为templates的目录。 -
接下来,在
froshims文件夹中,输入code requirements.txt并编写如下代码:Flask如前所述,运行 Flask 应用程序需要 Flask。
-
最后,输入
code app.py并编写如下代码:# Implements a registration form using a select menu, validating sport server-side from flask import Flask, render_template, request app = Flask(__name__) SPORTS = [ "Basketball", "Soccer", "Ultimate Frisbee" ] @app.route("/") def index(): return render_template("index.html", sports=SPORTS) @app.route("/register", methods=["POST"]) def register(): # Validate submission if not request.form.get("name") or request.form.get("sport") not in SPORTS: return render_template("failure.html") # Confirm registration return render_template("success.html")注意到提供了一个
failure选项,如果name或sport字段填写不正确,将向用户显示错误信息。 -
接下来,通过输入
code templates/index.html在templates文件夹中创建一个名为index.html的文件,并编写如下代码:{% extends "layout.html" %} {% block body %} <h1>Register</h1> <form action="/register" method="post"> <input autocomplete="off" autofocus name="name" placeholder="Name" type="text"> <select name="sport"> <option disabled selected value="">Sport</option> {% for sport in sports %} <option value="{{ sport }}">{{ sport }}</option> {% endfor %} </select> <button type="submit">Register</button> </form> {% endblock %} -
接下来,通过输入
code templates/layout.html创建一个名为layout.html的文件,并编写如下代码:<!DOCTYPE html> <html lang="en"> <head> <meta name="viewport" content="initial-scale=1, width=device-width"> <title>froshims</title> </head> <body> {% block body %}{% endblock %} </body> </html> -
第四,在
templates目录下创建一个名为success.html的文件,如下所示:{% extends "layout.html" %} {% block body %} You are registered! {% endblock %} -
最后,在
templates目录下创建一个名为failure.html的文件,如下所示:{% extends "layout.html" %} {% block body %} You are not registered! {% endblock %} -
执行
flask run并检查此阶段的程序。 -
你可以想象我们如何使用单选按钮查看各种注册选项。我们可以通过以下方式改进
index.html:{% extends "layout.html" %} {% block body %} <h1>Register</h1> <form action="/register" method="post"> <input autocomplete="off" autofocus name="name" placeholder="Name" type="text"> {% for sport in sports %} <input name="sport" type="radio" value="{{ sport }}"> {{ sport }} {% endfor %} <button type="submit">Register</button> </form> {% endblock %}注意到
type已被更改为radio。 -
再次执行
flask run,你可以看到界面现在已更改。 -
你可以想象我们如何接受许多不同注册者的注册。我们可以通过以下方式改进
app.py:# Implements a registration form, storing registrants in a dictionary, with error messages from flask import Flask, redirect, render_template, request app = Flask(__name__) REGISTRANTS = {} SPORTS = [ "Basketball", "Soccer", "Ultimate Frisbee" ] @app.route("/") def index(): return render_template("index.html", sports=SPORTS) @app.route("/register", methods=["POST"]) def register(): # Validate name name = request.form.get("name") if not name: return render_template("error.html", message="Missing name") # Validate sport sport = request.form.get("sport") if not sport: return render_template("error.html", message="Missing sport") if sport not in SPORTS: return render_template("error.html", message="Invalid sport") # Remember registrant REGISTRANTS[name] = sport # Confirm registration return redirect("/registrants") @app.route("/registrants") def registrants(): return render_template("registrants.html", registrants=REGISTRANTS)注意到使用了一个名为
REGISTRANTS的字典来记录REGISTRANTS[name]选定的sport。同时,注意registrants=REGISTRANTS将字典传递给此模板。 -
此外,我们可以实现
error.html:{% extends "layout.html" %} {% block body %} <h1>Error</h1> <p>{{ message }}</p> <img alt="Grumpy Cat" src="/static/cat.jpg"> {% endblock %} -
此外,创建一个新的模板,名为
registrants.html,如下所示:{% extends "layout.html" %} {% block body %} <h1>Registrants</h1> <table> <thead> <tr> <th>Name</th> <th>Sport</th> </tr> </thead> <tbody> {% for name in registrants %} <tr> <td>{{ name }}</td> <td>{{ registrants[name] }}</td> </tr> {% endfor %} </tbody> </table> {% endblock %}注意到
{% for name in registrants %}...{% endfor %}会遍历每个注册者。能够在动态网页上迭代是非常强大的功能! -
最后,在
app.py所在的同一文件夹中创建一个名为static的文件夹。在那里,上传以下文件:一个 猫 的文件。 -
执行
flask run并与该应用程序互动。 -
现在你已经拥有了一个网络应用程序!然而,存在一些安全漏洞!因为所有内容都在客户端,攻击者可以更改 HTML 并黑客网站。此外,如果服务器关闭,这些数据将不会持久化。我们是否有办法让数据在服务器重启时也能持久化?
Flask 和 SQL
-
正如我们所见,Python 可以与 SQL 数据库交互,我们可以结合 Flask、Python 和 SQL 的力量来创建一个数据将持久化的网络应用程序!
-
要实现这一点,你需要采取多个步骤。
-
首先,将以下 SQL 数据库下载到你的
froshims文件夹中。 -
在终端中执行
sqlite3 froshims.db并输入.schema以查看数据库文件的内容。进一步输入SELECT * FROM registrants;以了解内容。你会注意到文件中目前没有任何注册信息。 -
接下来,按如下方式修改
requirements.txt:cs50 Flask -
按如下方式修改
index.html:{% extends "layout.html" %} {% block body %} <h1>Register</h1> <form action="/register" method="post"> <input autocomplete="off" autofocus name="name" placeholder="Name" type="text"> {% for sport in sports %} <input name="sport" type="checkbox" value="{{ sport }}"> {{ sport }} {% endfor %} <button type="submit">Register</button> </form> {% endblock %} -
按如下方式修改
layout.html:<!DOCTYPE html> <html lang="en"> <head> <meta name="viewport" content="initial-scale=1, width=device-width"> <title>froshims</title> </head> <body> {% block body %}{% endblock %} </body> </html> -
确保
error.html显示如下:{% extends "layout.html" %} {% block body %} <h1>Error</h1> <p>{{ message }}</p> <img alt="Grumpy Cat" src="/static/cat.jpg"> {% endblock %} -
修改
registrants.html以如下所示:{% extends "layout.html" %} {% block body %} <h1>Registrants</h1> <table> <thead> <tr> <th>Name</th> <th>Sport</th> <th></th> </tr> </thead> <tbody> {% for registrant in registrants %} <tr> <td>{{ registrant.name }}</td> <td>{{ registrant.sport }}</td> <td> <form action="/deregister" method="post"> <input name="id" type="hidden" value="{{ registrant.id }}"> <button type="submit">Deregister</button> </form> </td> </tr> {% endfor %} </tbody> </table> {% endblock %}注意到包含了一个隐藏值
registrant.id,这样就可以在app.py中稍后使用这个id。 -
最后,按如下方式修改
app.py:# Implements a registration form, storing registrants in a SQLite database, with support for deregistration from cs50 import SQL from flask import Flask, redirect, render_template, request app = Flask(__name__) db = SQL("sqlite:///froshims.db") SPORTS = [ "Basketball", "Soccer", "Ultimate Frisbee" ] @app.route("/") def index(): return render_template("index.html", sports=SPORTS) @app.route("/deregister", methods=["POST"]) def deregister(): # Forget registrant id = request.form.get("id") if id: db.execute("DELETE FROM registrants WHERE id = ?", id) return redirect("/registrants") @app.route("/register", methods=["POST"]) def register(): # Validate name name = request.form.get("name") if not name: return render_template("error.html", message="Missing name") # Validate sports sports = request.form.getlist("sport") if not sports: return render_template("error.html", message="Missing sport") for sport in sports: if sport not in SPORTS: return render_template("error.html", message="Invalid sport") # Remember registrant for sport in sports: db.execute("INSERT INTO registrants (name, sport) VALUES(?, ?)", name, sport) # Confirm registration return redirect("/registrants") @app.route("/registrants") def registrants(): registrants = db.execute("SELECT * FROM registrants") return render_template("registrants.html", registrants=registrants)注意到使用了
cs50库。包含了一个用于register的post方法的路由。这个路由将获取注册表单中的姓名和运动项目,并执行一个 SQL 查询将name和sport添加到registrants表中。deregister路由将执行一个 SQL 查询,获取用户的id并利用这些信息注销该个人。 -
你可以执行
flask run并检查结果。 -
如果你想下载我们的
froshims实现,你可以在这里下载。 -
你可以在Flask 文档中了解更多关于 Flask 的信息。
Cookies 和 Session
-
app.py被视为控制器。视图被认为是用户所看到的内容。模型是数据存储和操作的方式。三者合称为 MVC(模型,视图,控制器)。 -
虽然之前的
froshims实现从管理角度来看很有用,其中后台管理员可以向数据库添加和删除个人,但可以想象这段代码在公共服务器上实现并不安全。 -
首先,不良分子可能会通过点击注销按钮代表其他用户做出决定——实际上是从服务器删除他们记录的答案。
-
像谷歌这样的网络服务使用登录凭证来确保用户只能访问正确的数据。
-
实际上,我们可以使用 cookies 来实现这一点。Cookies 是存储在您计算机上的小文件,这样您的计算机就可以与服务器通信,并有效地表示,“我是一个已经登录的授权用户。” 这种通过 cookie 的授权称为 会话。
-
Cookies 可以按照以下方式存储:
GET / HTTP/2 Host: accounts.google.com Cookie: session=value在这里,一个
sessionid 被存储,并带有特定的value,表示该会话。 -
以最简单的方式,我们可以通过创建一个名为
login的文件夹,然后添加以下文件来实现这一点。 -
首先,创建一个名为
requirements.txt的文件,其内容如下:Flask Flask-Session注意,除了
Flask,我们还包含了Flask-Session,这是支持登录会话所必需的。 -
第二,在
templates文件夹中创建一个名为layout.html的文件,其内容如下:<!DOCTYPE html> <html lang="en"> <head> <meta name="viewport" content="initial-scale=1, width=device-width"> <title>login</title> </head> <body> {% block body %}{% endblock %} </body> </html>注意这提供了一个非常简单的布局,包括标题和正文。
-
第三,在
templates文件夹中创建一个名为index.html的文件,其内容如下:{% extends "layout.html" %} {% block body %} {% if name -%} You are logged in as {{ name }}. <a href="/logout">Log out</a>. {%- else -%} You are not logged in. <a href="/login">Log in</a>. {%- endif %} {% endblock %}注意这个文件会检查
session["name"]是否存在(在下面的app.py中进一步阐述)。如果存在,它将显示欢迎信息。如果不存在,它将建议您浏览到登录页面。 -
第四,创建一个名为
login.html的文件,并添加以下代码:{% extends "layout.html" %} {% block body %} <form action="/login" method="post"> <input autocomplete="off" autofocus name="name" placeholder="Name" type="text"> <button type="submit">Log In</button> </form> {% endblock %}注意这是基本登录页面的布局。
-
最后,创建一个名为
app.py的文件,并编写以下代码:from flask import Flask, redirect, render_template, request, session from flask_session import Session # Configure app app = Flask(__name__) # Configure session app.config["SESSION_PERMANENT"] = False app.config["SESSION_TYPE"] = "filesystem" Session(app) @app.route("/") def index(): return render_template("index.html", name=session.get("name")) @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": session["name"] = request.form.get("name") return redirect("/") return render_template("login.html") @app.route("/logout") def logout(): session.clear() return redirect("/")注意文件顶部的修改后的 导入,包括
session,这将允许您支持会话。最重要的是,注意session["name"]在login和logout路由中的使用。login路由会将提供的登录名分配给session["name"]。然而,在logout路由中,注销是通过清除session的值来实现的。 -
session抽象允许您确保只有特定用户可以访问我们应用程序中的特定数据和功能。它允许您确保没有人代表另一个用户行事,无论是好是坏! -
如果您愿意,您可以下载我们的实现。
-
你可以在Flask 文档中了解更多关于会话的信息。
购物车
-
接下来,我们将通过一个最终示例来展示如何利用 Flask 的会话启用功能。
-
我们检查了
app.py中的store代码。以下代码被展示:from cs50 import SQL from flask import Flask, redirect, render_template, request, session from flask_session import Session # Configure app app = Flask(__name__) # Connect to database db = SQL("sqlite:///store.db") # Configure session app.config["SESSION_PERMANENT"] = False app.config["SESSION_TYPE"] = "filesystem" Session(app) @app.route("/") def index(): books = db.execute("SELECT * FROM books") return render_template("books.html", books=books) @app.route("/cart", methods=["GET", "POST"]) def cart(): # Ensure cart exists if "cart" not in session: session["cart"] = [] # POST if request.method == "POST": book_id = request.form.get("id") if book_id: session["cart"].append(book_id) return redirect("/cart") # GET books = db.execute("SELECT * FROM books WHERE id IN (?)", session["cart"]) return render_template("cart.html", books=books)注意到
cart是通过列表实现的。可以使用books.html中的Add to Cart按钮向此列表添加项目。点击此类按钮时,将调用post方法,其中将项目id附加到cart上。在查看购物车时,调用get方法,执行 SQL 以显示购物车中的书籍列表。 -
我们还看到了
books.html的内容:{% extends "layout.html" %} {% block body %} <h1>Books</h1> {% for book in books %} <h2>{{ book["title"] }}</h2> <form action="/cart" method="post"> <input name="id" type="hidden" value="{{ book['id'] }}"> <button type="submit">Add to Cart</button> </form> {% endfor %} {% endblock %}注意到这是如何通过
for book in books为每本书创建Add to Cart功能的。 -
你可以在源代码中查看驱动此
flask实现的其余文件。
显示
-
我们在
app.py中查看了一个名为shows的预设计程序:# Searches for shows using LIKE from cs50 import SQL from flask import Flask, render_template, request app = Flask(__name__) db = SQL("sqlite:///shows.db") @app.route("/") def index(): return render_template("index.html") @app.route("/search") def search(): shows = db.execute("SELECT * FROM shows WHERE title LIKE ?", "%" + request.args.get("q") + "%") return render_template("search.html", shows=shows)注意到
search路由允许通过一种方式来搜索show。此搜索查找与用户提供的标题LIKE匹配的标题。 -
我们还检查了
index.html:<!DOCTYPE html> <html lang="en"> <head> <meta name="viewport" content="initial-scale=1, width=device-width"> <title>shows</title> </head> <body> <input autocomplete="off" autofocus placeholder="Query" type="text"> <ul></ul> <script> let input = document.querySelector('input'); input.addEventListener('input', async function() { let response = await fetch('/search?q=' + input.value); let shows = await response.json(); let html = ''; for (let id in shows) { let title = shows[id].title.replace('<', '<').replace('&', '&'); html += '<li>' + title + '</li>'; } document.querySelector('ul').innerHTML = html; }); </script> </body> </html>注意到 JavaScript
script创建了一个自动完成的实现,其中匹配input的标题会被显示出来。 -
你可以在源代码中查看此实现的其他文件。
API
-
应用程序程序接口或API是一系列规范,允许你与另一个服务进行交互。例如,我们可以利用 IMDB 的 API 来与其数据库进行交互。我们甚至可以集成用于处理从服务器下载的特定类型数据的 API。
-
在对
shows进行改进的同时,查看app.py的改进,我们看到了以下内容:# Searches for shows using Ajax from cs50 import SQL from flask import Flask, render_template, request app = Flask(__name__) db = SQL("sqlite:///shows.db") @app.route("/") def index(): return render_template("index.html") @app.route("/search") def search(): q = request.args.get("q") if q: shows = db.execute("SELECT * FROM shows WHERE title LIKE ? LIMIT 50", "%" + q + "%") else: shows = [] return render_template("search.html", shows=shows)注意到
search路由执行了一个 SQL 查询。 -
查看
search.html,你会注意到它非常简单:{% for show in shows %} <li>{{ show["title"] }}</li> {% endfor %}注意到它提供了一个项目符号列表。
-
最后,查看
index.html,注意到AJAX代码被用来驱动搜索:<!DOCTYPE html> <html lang="en"> <head> <meta name="viewport" content="initial-scale=1, width=device-width"> <title>shows</title> </head> <body> <input autocomplete="off" autofocus placeholder="Query" type="search"> <ul></ul> <script> let input = document.querySelector('input'); input.addEventListener('input', async function() { let response = await fetch('/search?q=' + input.value); let shows = await response.text(); document.querySelector('ul').innerHTML = shows; }); </script> </body> </html>注意到使用事件监听器动态查询服务器以提供与提供的标题匹配的列表。这将定位 HTML 中的
ul标签并相应地修改网页以包含匹配的列表。 -
你可以在AJAX 文档中了解更多信息。
JSON
-
JavaScript 对象表示法或JSON是一个包含键和值的字典文本文件。这是一种原始且对计算机友好的方式来获取大量数据。
-
JSON 是从服务器获取数据的一种非常有用的方式。
-
你可以在我们共同检查的
index.html中看到这一操作:<!DOCTYPE html> <html lang="en"> <head> <meta name="viewport" content="initial-scale=1, width=device-width"> <title>shows</title> </head> <body> <input autocomplete="off" autofocus placeholder="Query" type="text"> <ul></ul> <script> let input = document.querySelector('input'); input.addEventListener('input', async function() { let response = await fetch('/search?q=' + input.value); let shows = await response.json(); let html = ''; for (let id in shows) { let title = shows[id].title.replace('<', '<').replace('&', '&'); html += '<li>' + title + '</li>'; } document.querySelector('ul').innerHTML = html; }); </script> </body> </html>虽然上述内容可能有些晦涩,但它为你提供了一个起点,让你可以自己研究 JSON,看看它如何在你的网络应用程序中实现。
-
此外,我们还检查了
app.py以了解如何获取 JSON 响应:# Searches for shows using Ajax with JSON from cs50 import SQL from flask import Flask, jsonify, render_template, request app = Flask(__name__) db = SQL("sqlite:///shows.db") @app.route("/") def index(): return render_template("index.html") @app.route("/search") def search(): q = request.args.get("q") if q: shows = db.execute("SELECT * FROM shows WHERE title LIKE ? LIMIT 50", "%" + q + "%") else: shows = [] return jsonify(shows)注意到
jsonify是如何被用来将结果转换为当代网络应用可接受的易读格式的。 -
你可以在JSON 文档中了解更多信息。
-
总结来说,你现在可以使用 Python、Flask、HTML 和 SQL 来完成自己的网络应用程序。
总结
在本节课中,你学习了如何利用 Python、SQL 和 Flask 来创建 Web 应用程序。具体来说,我们讨论了……
-
Flask
-
表单
-
模板
-
请求方法
-
Flask 和 SQL
-
Cookie 和会话
-
API
-
JSON
下次再见,我们将在这里举行本学期的最后一堂课,地点是Sanders Theatre!
结束
-
回顾
-
展望未来
-
感谢!
-
总结
回顾
-
在过去的十周里,你一直在从传说中的水龙卷中汲取知识。
-
在这门课程中,你学会了用许多不同的语言编程;实际上,我们最大的希望是你在所有这些语言中 学会了如何编程,无论语言是什么。
-
回想一下课程开始时你做马里奥游戏的时候:你已经走了很长的路,并且学到了很多技能。
-
你应该将你的成功衡量为课程开始时的技能和课程结束时的技能之间的差异。
-
此外,我们希望你在所有其他方面都 学会了如何解决问题,如何接受输入,通过算法处理它,并产生一些输出。为此,我们玩了一个用口头指令画图的游戏。
-
注意一些口头指令是如何 高级 和其他是如何 低级 的,比如在这个课程中使用的不同编程语言。
-
注意一些指令比其他指令更具体。记住那些你必须精炼你的代码以做到你想要的事情的时刻。
-
此外,注意一些指令是如何被抽象化的,比如 画手臂。这类似于我们将代码抽象为函数的方式。
-
看看整个课程的所有周次,你收集了工具并建立了技能。然而,最重要的是,你学会了如何自学。
-
你的最终项目是你有机会使用这些技能,按照你的规格和设计来构建一个项目的机会。我们希望你能加入我们的 CS50 黑客马拉松,这是一个史诗般的通宵达旦的活动,我们将一起在我们的最终项目中工作。
展望未来
-
当你从这门课程的工作走向 CS50 世界之外时,你可能想要采取一些步骤来准备并冒险探索。
-
更多关于 Git 的信息。
-
如果适用,可以使用 AWS、Azure 或 Google Cloud 来托管一个网络应用程序。
-
在相关的在线社区中提问。
-
使用基于 AI 的工具,如 ChatGPT 和 GitHub Copilot 来提问。
-
修读我们其他的 CS50 课程。
-
加入我们众多的 社区。
感谢您!
-
感谢您参与这门课程!
-
许多人使这门课程成为可能。感谢您使这门课程成为可能,并为我们提供了如此好的支持!
总结
在本节课中,我们回顾了你在课程中的学习旅程。具体来说,我们鼓励你……
-
将你在本课程中学到的编程技能应用于解决世界上的问题。
-
将你在本课程中获得的新的技能运用起来,继续你的学习之旅!
-
坚持到底,提交你的最终项目。
这就是 CS50!
人工智能
第零讲
人工智能
人工智能(AI)涵盖了一系列技术,这些技术使计算机表现出有感知的行为。例如,AI 用于识别社交媒体上的照片中的面孔,击败世界象棋冠军,以及处理你用手机上的 Siri 或 Alexa 说话时的语音。
在本课程中,我们将探讨使人工智能成为可能的一些想法:
- 搜索
找到一个问题的解决方案,比如一个导航应用找到从起点到目的地的最佳路线,或者像玩游戏并找出下一步棋。
- 知识
从信息中提取信息并得出推论。
- 不确定性
使用概率处理不确定事件。
- 优化
找到一个正确解决问题的方法,但也是一个更好或最好的方法。
- 学习
根据数据访问和经验改进性能。例如,你的电子邮件能够根据以往的经验区分垃圾邮件和非垃圾邮件。
- 神经网络
一种受人类大脑启发的程序结构,能够有效地执行任务。
- 语言
处理自然语言,这是人类产生和理解的。
搜索
搜索问题涉及一个给定初始状态和目标状态的代理,并返回从前者到后者的解决方案。导航应用使用典型的搜索过程,其中代理(程序的思考部分)接收你的当前位置和你的目标位置作为输入,并根据搜索算法返回建议的路径。然而,还有许多其他形式的搜索问题,如谜题或迷宫。

解决 15 拼图问题需要使用搜索算法。
-
代理
一个感知其环境并对该环境采取行动的实体。例如,在一个导航应用中,代理将是一个需要决定采取哪些行动才能到达目的地的汽车的表示。
-
状态
代理在其环境中的配置。例如,在 15 拼图 中,任何一种所有数字在棋盘上排列的方式都是一个状态。
-
初始状态
搜索算法开始的状态。在导航应用中,那将是当前位置。
-
-
动作
在一个状态下可以做出的选择。更精确地说,动作可以被定义为函数。当接收到状态
s作为输入时,Actions(s)返回在状态s中可以执行的动作集合。例如,在 15 拼图 中,给定状态的动作是你可以在当前配置中滑动方块的方式(如果空白方块在中间,有 4 种方式,如果靠近边缘,有 3 种方式,如果位于角落,有 2 种方式)。 -
转换模型
对任何状态执行任何适用动作的结果的描述。更精确地说,转换模型可以定义为函数。当接收到状态
s和动作a作为输入时,Results(s, a)返回在状态s中执行动作a后的状态。例如,给定某个15 个拼图的配置(状态s),将一个方块向任何方向移动(动作a)将导致拼图的新配置(新状态)。 -
状态空间
通过任何动作序列从初始状态可达的所有状态的集合。例如,在 15 个拼图游戏中,状态空间由所有 16!/2 个可以在任何初始状态下到达的棋盘配置组成。状态空间可以可视化为一个有向图,其中状态由节点表示,动作由节点之间的箭头表示。

-
目标测试
确定给定状态是否为目标状态的条件。例如,在导航应用中,目标测试将是代理的当前位置(汽车的表示)是否在目的地。如果是——问题解决。如果不是——我们继续搜索。
-
路径成本
与给定路径相关的数值成本。例如,导航应用不仅将您带到目的地;它这样做的同时最小化路径成本,找到您到达目标状态的最快方式。
解决搜索问题
-
解
从初始状态到目标状态的一系列动作。
-
最优解
在所有解中具有最低路径成本的解。
-
在搜索过程中,数据通常存储在节点中,这是一种包含以下数据的数据结构:
-
一个状态
-
它的父节点,通过它生成了当前节点
-
应用到父节点状态以到达当前节点的动作
-
从初始状态到该节点的路径成本
节点包含使它们在搜索算法目的上非常有用的信息。它们包含一个状态,可以使用目标测试来检查它是否是最终状态。如果是,节点 的路径成本可以与其他节点的路径成本进行比较,从而选择最优解。一旦选择了节点,由于存储了父节点和从父节点到当前节点所采取的动作,就可以从初始状态追踪到这个节点的每一步,而这个动作序列就是解。
然而,节点只是一个数据结构——它们不搜索,它们只保存信息。为了实际搜索,我们使用边界,这是“管理”节点的机制。边界最初包含一个初始状态和一个空的已探索项集合,然后重复以下操作,直到找到解决方案:
重复:
-
如果边界为空,
- 停止。该问题没有解。
-
从前沿移除一个节点。这是将要考虑的节点。
-
如果节点包含目标状态,
- 返回解决方案。停止。
否则,
* Expand the node (find all the new nodes that could be reached from this node), and add resulting nodes to the frontier. * Add the current node to the explored set.
深度优先搜索
在上述对前沿的描述中,有一件事没有被提及。在上述伪代码的第 2 阶段,应该移除哪个节点?这个选择对解决方案的质量和实现速度有影响。有几种方法可以处理哪个节点应该首先考虑的问题,其中两种可以通过栈(在深度优先搜索中)和队列(在广度优先搜索中)的数据结构来表示;这里有一个可爱的卡通演示说明了这两种方法之间的区别)。
我们从深度优先搜索(DFS)方法开始。
深度优先搜索算法在尝试另一个方向之前会先耗尽每一个方向。在这些情况下,前沿被管理为一个栈数据结构。你需要记住的口号是“后进先出。”在节点被添加到前沿后,首先移除并考虑的是最后添加的节点。这导致了一个搜索算法,它在遇到障碍的第一个方向上尽可能深入,同时将所有其他方向留待以后。
(来自课堂外的例子:假设你在找你的钥匙。在深度优先搜索方法中,如果你选择从你的裤子开始搜索,你将首先检查每一个口袋,清空每一个口袋并仔细检查里面的东西。你只有在完全检查完裤子的每一个口袋后,才会停止在裤子中搜索并开始在其他地方搜索。)
-
优点:
- 最好的情况是,这个算法是最快的。如果它“运气好”并且总是选择正确的路径到达解决方案(偶然),那么深度优先搜索将花费最短的时间到达解决方案。
-
缺点:
-
可能找到的解决方案并非最优。
-
最坏的情况是,这个算法在找到解决方案之前将探索所有可能的路径,因此到达解决方案之前将花费可能的最长时间。
-
代码示例:
# Define the function that removes a node from the frontier and returns it.
def remove(self):
# Terminate the search if the frontier is empty, because this means that there is no solution.
if self.empty():
raise Exception("empty frontier")
else:
# Save the last item in the list (which is the newest node added)
node = self.frontier[-1]
# Save all the items on the list besides the last node (i.e. removing the last node)
self.frontier = self.frontier[:-1]
return node
广度优先搜索
深度优先搜索的对立面是广度优先搜索(BFS)。
一个广度优先搜索算法将同时遵循多个方向,在每个可能的方向上先迈出一小步,然后再在每个方向上迈出第二步。在这种情况下,前沿被管理为一个队列数据结构。你需要记住的口号是“先进先出。”在这种情况下,所有新的节点都按顺序添加,节点是根据哪个先添加的来考虑的(先来先服务!)。这导致了一个搜索算法,它在每个可能的方向上迈出一小步,然后再在任何方向上迈出第二步。
(来自课堂外的例子:假设你处于寻找钥匙的情况。在这种情况下,如果你从裤子开始,你会检查右边的口袋。之后,你不会检查左边的口袋,而是会查看一个抽屉。然后是桌子。等等,在每个你能想到的地方。只有在你用尽所有地方之后,你才会回到裤子并检查下一个口袋。)
-
优点:
- 这个算法保证能找到最优解。
-
缺点:
-
这个算法几乎可以保证运行时间会比最短运行时间更长。
-
最坏的情况下,这个算法的运行时间是最长的。
-
代码示例:
# Define the function that removes a node from the frontier and returns it.
def remove(self):
# Terminate the search if the frontier is empty, because this means that there is no solution.
if self.empty():
raise Exception("empty frontier")
else:
# Save the oldest item on the list (which was the first one to be added)
node = self.frontier[0]
# Save all the items on the list besides the first one (i.e. removing the first node)
self.frontier = self.frontier[1:]
return node
贪婪最佳优先搜索
广度优先和深度优先都是无信息搜索算法。也就是说,这些算法没有利用它们通过自己的探索获得的问题知识。然而,大多数情况下,确实存在一些关于问题的知识。例如,当人类迷宫解决者在进入一个交汇点时,人类可以看到哪个方向通向解决方案的一般方向,哪个方向则不行。人工智能也可以做到这一点。一种考虑额外知识以尝试提高其性能的算法称为有信息搜索算法。
贪婪最佳优先搜索扩展的是离目标最近的节点,这是通过启发式函数 h(n) 确定的。正如其名所示,该函数估计下一个节点离目标有多近,但它可能会出错。贪婪最佳优先算法的效率取决于启发式函数的好坏。例如,在一个迷宫中,算法可以使用一个依赖于可能节点和迷宫终点之间曼哈顿距离的启发式函数。曼哈顿距离忽略了墙壁,并计算从当前位置到目标位置需要向上、向下或向侧面走多少步。这是一个基于当前位置和目标位置的 (x, y) 坐标可以推导出的简单估计。

曼哈顿距离
然而,重要的是要强调,就像任何启发式方法一样,它可能会出错,并导致算法走上一条比其他情况下更慢的路径。有可能一个无信息搜索算法会更快地提供一个更好的解决方案,但这种情况发生的可能性比有信息算法要小。
A* 搜索
贪婪最佳优先搜索算法的发展,A搜索不仅考虑 h(n),从当前位置到目标的估计成本,还考虑 g(n),到达当前位置的成本。通过结合这两个值,算法有更准确的方式来确定解决方案的成本并优化其选择。算法跟踪(到目前为止的路径成本* + 到目标的估计成本),一旦它超过某些先前选项的估计成本,算法将放弃当前路径并回到先前选项,从而防止自己沿着一个长而低效的路径走下去,而 h(n) 错误地将它标记为最佳。
再次强调,由于这个算法也依赖于启发式方法,因此它的效果取决于所采用的启发式方法。在某些情况下,它可能不如贪婪最佳优先搜索甚至无信息算法高效。为了使A搜索*成为最优的,启发式函数 h(n) 应该是:
-
可接受,或者从不高估真实成本,
-
一致,这意味着新节点到目标路径成本的估计,加上从先前节点转换到它的成本,大于或等于先前节点到目标路径成本的估计。用方程式表示,如果对于每个节点 n 和具有步长成本 c 的后续节点 n’,h(n) ≤ h(n’) + c,则 h(n) 是一致的。
对抗搜索
而之前,我们讨论了需要找到问题答案的算法,在对抗搜索中,算法面对一个试图实现相反目标的对手。通常,使用对抗搜索的 AI 在游戏中遇到,例如井字棋。
Minimax
对抗搜索算法中的一种类型,Minimax 将胜利条件表示为一方的(-1)和另一方的(+1)。后续动作将由这些条件驱动,最小化方试图获得最低分数,而最大化方试图获得最高分数。
表示井字棋 AI:
-
S₀:初始状态(在我们的例子中,一个空的 3X3 棋盘)
-
玩家(s):一个函数,给定一个状态 s,返回当前轮到哪个玩家(X 或 O)。
-
动作(s):一个函数,给定一个状态 s,返回在这个状态下所有合法的移动(棋盘上哪些位置是空的)。
-
结果(s, a):一个函数,给定一个状态 s 和动作 a,返回一个新的状态。这是在状态 s 上执行动作 a 后得到的棋盘(在游戏中进行一步移动)。
-
终端(s):一个函数,给定一个状态 s,检查这是否是游戏的最后一步,即是否有人获胜或有平局。如果游戏结束,则返回 True,否则返回 False。
-
效用(s):一个函数,给定一个终端状态 s,返回该状态的有效值:-1,0 或 1。
算法的工作原理:
递归地,算法模拟从当前状态开始的所有可能的游戏,直到达到终端状态。每个终端状态的价值被评估为 (-1)、0 或 (+1)。

Minimax 算法在井字棋中的应用
根据当前轮到哪个状态,算法可以知道当前玩家在最优策略下,会选择导致状态值更低或更高的动作。这样,通过交替进行最小化和最大化,算法为每个可能动作的结果状态创建值。为了更具体地说明,我们可以想象最大化玩家在每一轮都会问:“如果我采取这个动作,将产生一个新的状态。如果最小化玩家采取最优策略,该玩家可以采取什么动作将值降到最低?”然而,为了回答这个问题,最大化玩家必须问:“为了知道最小化玩家会做什么,我需要在最小化玩家的思维中模拟相同的过程:最小化玩家会试图问:‘如果我采取这个动作,最大化玩家可以采取什么动作将值提高到最高?’”这是一个递归过程,可能很难理解;查看下面的伪代码可能会有所帮助。最终,通过这个递归推理过程,最大化玩家为当前状态下所有可能的动作结果状态生成值。在得到这些值之后,最大化玩家选择其中最高的一个。

最大化玩家考虑未来状态的潜在值。
用伪代码来说,Minimax 算法的工作方式如下:
-
给定一个状态 s
-
最大化玩家在 Actions(s) 中选择动作 a,该动作产生 Min-Value(Result(s, a)) 的最高值。
-
最小化玩家在 Actions(s) 中选择动作 a,该动作产生 Max-Value(Result(s, a)) 的最低值。
-
-
函数 Max-Value(state)
-
v = -∞
-
if Terminal(state):
返回 Utility(state)
-
for action in Actions(state):
v = Max(v, Min-Value(Result(state, action)))
返回 v
-
-
函数 Min-Value(state):
-
v = ∞
-
if Terminal(state):
返回 Utility(state)
-
for action in Actions(state):
v = Min(v, Max-Value(Result(state, action)))
return v
-
Alpha-Beta Pruning
一种优化 Minimax 的方法是 Alpha-Beta 剪枝,它跳过了一些明显不利的递归计算。在确定一个动作的价值后,如果最初有证据表明接下来的动作可以使对手得到比已确定的动作更好的分数,就没有必要进一步调查这个动作,因为它将明显不如之前确定的动作有利。
这一点最容易被一个例子所说明:一个最大化玩家知道,在下一步,最小化玩家将试图获得最低的分数。假设最大化玩家有三个可能的行为,第一个行为的价值是 4。然后玩家开始为下一步生成价值。为了做到这一点,玩家在当前玩家采取这个行为的情况下,生成最小化玩家行为的值,知道最小化玩家将选择最低的一个。然而,在完成所有可能的最小化玩家行为的计算之前,玩家看到其中一个选项的价值是三。这意味着没有必要继续探索其他可能的最小化玩家行为。尚未评估的行为的价值并不重要,无论是 10 还是(-10)。如果价值是 10,最小化玩家将选择最低的选项,3,这已经比预先设定的 4 更差。如果尚未评估的行为最终是(-10),最小化玩家将选择这个选项,(-10),这对最大化玩家来说更加不利。因此,在这一点上计算最小化玩家的额外可能行为对最大化玩家来说是不相关的,因为最大化玩家已经有一个明确更好的选择,其价值是 4。

深度限制最小-最大
总共有 255,168 种可能的井字棋游戏,以及 10²⁹⁰⁰⁰种可能的国际象棋游戏。到目前为止所展示的最小-最大算法需要从某个点生成所有假设游戏到终端状态。虽然计算所有井字棋游戏对现代计算机来说并不构成挑战,但用国际象棋来做这一点目前是不可能的。
深度限制最小-最大只考虑在停止之前预先定义的移动数,而不会达到终端状态。然而,这并不允许为每个行为获得精确的价值,因为假设游戏并没有达到终端。为了解决这个问题,深度限制最小-最大依赖于一个评估函数,该函数估计从给定状态的游戏预期效用,换句话说,为状态分配值。例如,在国际象棋游戏中,效用函数将当前棋盘配置作为输入,尝试评估其预期效用(基于每个玩家拥有的棋子和它们在棋盘上的位置),然后返回一个正或负值,表示棋盘对一方玩家相对于另一方的有利程度。这些值可以用来决定正确的行动,评估函数越好,依赖它的最小-最大算法就越好。
第一讲
知识
人类基于现有知识进行推理并得出结论。从知识中表示和推理出结论的概念也用于人工智能,在本讲座中,我们将探讨我们如何实现这种行为。
基于知识的智能体
这些是通过操作知识内部表示来进行推理的智能体。
“基于知识进行推理得出结论”是什么意思?
让我们从哈利·波特的例子开始回答这个问题。考虑以下句子:
-
如果今天没有下雨,哈利今天拜访了海格。
-
哈利今天拜访了海格或邓布利多,但不是两者都拜访了。
-
哈利今天拜访了邓布利多。
基于这三句话,我们可以回答“今天是否下雨?”这个问题,尽管没有任何一个单独的句子告诉我们今天是否下雨。我们可以这样进行推理:查看第三句话,我们知道哈利拜访了邓布利多。查看第二句话,我们知道哈利拜访了邓布利多或海格,因此我们可以得出结论
- 哈利没有拜访海格。
现在,查看第一句话,我们理解如果没有下雨,哈利会拜访海格。然而,知道第四句话,我们知道情况并非如此。因此,我们可以得出结论
- 今天下雨了。
为了得出这个结论,我们使用了逻辑,今天的讲座探讨了人工智能如何使用逻辑根据现有信息得出新的结论。
句子
句子是在知识表示语言中对世界的断言。句子是人工智能存储知识并使用它来推断新信息的方式。
质理逻辑
质量逻辑基于命题,即关于世界的陈述,可以是真或假,如上面第 1-5 句所示。
命题符号
质量符号通常是用字母(P, Q, R)表示的命题。
逻辑连接词
逻辑连接词是连接命题符号的逻辑符号,以便以更复杂的方式对世界进行推理。
-
非 (¬) 反转命题的真值。例如,如果 P: “正在下雨”,那么 ¬P: “没有下雨”。
真值表用于比较命题的所有可能的真值分配。这个工具将帮助我们更好地理解命题与不同的逻辑连接词连接时的真值。例如,下面是我们的第一个真值表:
P ¬P false true true false -
与 (∧) 连接两个不同的命题。当这两个命题 P 和 Q 通过 ∧ 连接时,结果命题 P ∧ Q 仅在 P 和 Q 都为真时才为真。
P Q P ∧ Q false false false false true false true false false true true true -
或(∨)只要其论点中的任何一个为真就为真。这意味着,为了 P ∨ Q 为真,P 或 Q 中的至少一个必须为真。
P Q P ∨ Q false false false false true true true false true true true true 值得注意的是,有两种类型的“或”:包含“或”和排除“或”。在排除“或”中,如果 P ∧ Q 为真,则 P ∨ Q 为假。也就是说,排除“或”只需要其论点中的一个为真,而不是两个都为真。包含“或”在 P、Q 或 P ∧ Q 中的任何一个为真时为真。在“或”(∨)的情况下,意图是包含“或”。
一些在讲座中没有提到的旁注:
- 有时举一个例子有助于理解包含“或”与排除“或”。包含“或”:为了吃甜点,你必须打扫房间或修剪草坪。在这种情况下,如果你做了这两项家务,你仍然会得到饼干。排除“或”:为了甜点,你可以选择饼干或冰淇淋。在这种情况下,你不能两者都要。
- 如果你好奇,排除“或”通常简称为 XOR,其常见符号是⊕)。
-
蕴含(→)代表“如果 P 那么 Q”的结构。例如,如果 P:“下雨”和 Q:“我待在室内”,那么 P → Q 意味着“如果下雨,那么我待在室内”。在 P 蕴含 Q(P → Q)的情况下,P 被称为前件,Q 被称为后件。
当前件为真时,如果后件也为真,整个蕴含式就为真(这很有道理:如果下雨而我待在室内,那么句子“如果下雨,那么我待在室内”就是真的)。当前件为真时,如果后件为假,蕴含式就是假的(如果我下雨时在室外,那么句子“如果下雨,那么我待在室内”就是假的)。然而,当前件为假时,无论后件如何,蕴含式总是真的。这有时可能是一个令人困惑的概念。从逻辑上讲,如果前件(P)为假,我们就无法从蕴含式(P → Q)中得出任何东西。看看我们的例子,如果不下雨,蕴含式并没有说明我是否在室内。我可能是一个室内型的人,即使不下雨也从不外出,或者我可能是一个室外型的人,不下雨时总是外出。当前件为假时,我们说蕴含式是显然真的。
P Q P → Q false false true false true true true false false true true true -
双条件 (↔) 是一个双向的蕴涵。你可以将其读作“如果且仅如果”。P ↔ Q 与 P → Q 和 Q → P 同时成立。例如,如果 P: “正在下雨。” 和 Q: “我在室内,”那么 P ↔ Q 意味着“如果下雨,那么我在室内,”以及“如果我在室内,那么下雨。”这意味着我们可以比简单蕴涵推断出更多内容。如果 P 是假的,那么 Q 也是假的;如果不下雨,我们知道我也不在室内。
P Q P ↔ Q 假 假 真 假 真 假 真 假 假 真 真 真
模型
模型是对每个命题的真值分配。再次强调,命题是关于世界的陈述,可以是真或假。然而,关于世界的知识是通过这些命题的真值来表示的。模型是提供关于世界信息的真值分配。
例如,如果 P: “正在下雨。” 和 Q: “今天是星期二。”,一个模型可以是以下真值分配:{P = 真, Q = 假}。这个模型意味着在下雨,但不是星期二。然而,在这种情况下还有更多可能的模型(例如,{P = 真, Q = 真},即下雨且是星期二)。实际上,可能模型的数量是命题数量的 2 的幂。在这种情况下,我们有两个命题,所以 2²=4 个可能的模型。
知识库 (KB)
知识库是一组知识库代理所知道句子。这是 AI 以命题逻辑句子的形式提供关于世界的知识,可以用来对世界进行额外的推断。
蕴涵 (⊨)
如果 α ⊨ β (α 蕴涵 β),那么在任何 α 为真的世界中,β 也是真的。
例如,如果 α: “一月份是星期二” 和 β: “是月份,”那么我们知道 α ⊨ β。如果一月份是星期二是真的,我们也知道是月份。蕴涵与蕴涵不同。蕴涵是两个命题之间的逻辑连接词。另一方面,蕴涵是一个关系,意味着如果 α 中的所有信息都是真的,那么 β 中的所有信息也是真的。
推理
推理是从旧句子推导出新句子的过程。
例如,在之前的哈利·波特例子中,句子 4 和 5 是从句子 1、2 和 3 推导出来的。
基于现有知识推断新知识有多种方式。首先,我们将考虑 模型检查 算法。
-
要确定 KB ⊨ α(换句话说,回答“基于我们的知识库,我们能否得出 α 是真的”)
-
列举所有可能的模型。
-
如果在 KB 为真的每个模型中 α 也是真的,那么 KB 蕴涵 α (KB ⊨ α)。
-
考虑以下例子:
P: 今天是星期二。Q: 正在下雨。R: 哈里会去跑步。KB: (P ∧ ¬Q) → R (换句话说,P 和非 Q 蕴含 R)P (P 是真的) ¬Q (Q 是假的) 查询:R (我们想知道 R 是真还是假;KB ⊨ R?)
要使用模型检查算法回答查询,我们需要枚举所有可能的模型。
| P | Q | R | KB |
|---|---|---|---|
| false | false | false | |
| false | false | true | |
| false | true | false | |
| false | true | true | |
| true | false | false | |
| true | false | true | |
| true | true | false | |
| true | true | true |
然后,我们逐一检查每个模型,看它是否在给定的知识库中为真。
首先,在我们的知识库(KB)中,我们知道 P 是真的。因此,我们可以断言,在 P 不为真的所有模型中,KB 是假的。
| P | Q | R | KB |
|---|---|---|---|
| false | false | false | false |
| false | false | true | false |
| false | true | false | false |
| false | true | true | false |
| true | false | false | |
| true | false | true | |
| true | true | false | |
| true | true | true |
接下来,同样地,在我们的 KB 中,我们知道 Q 是假的。因此,我们可以断言,在 Q 为真的所有模型中,KB 是假的。
| P | Q | R | KB |
|---|---|---|---|
| false | false | false | false |
| false | false | true | false |
| false | true | false | false |
| false | true | true | false |
| true | false | false | |
| true | false | true | |
| true | true | false | false |
| true | true | true | false |
最后,我们只剩下两个模型。在这两个模型中,P 是真的,Q 是假的。在一个模型中 R 是真的,在另一个模型中 R 是假的。由于 (P ∧ ¬Q) → R 在我们的 KB 中,我们知道在 P 为真且 Q 为假的情况下,R 必须是真的。因此,我们说对于 R 为假的模型,我们的 KB 是假的,对于 R 为真的模型,我们的 KB 是真的。
| P | Q | R | KB |
|---|---|---|---|
| false | false | false | false |
| false | false | true | false |
| false | true | false | false |
| false | true | true | false |
| true | false | false | false |
| true | false | true | true |
| true | true | false | false |
| true | true | true | false |
观察这个表格,只有一个模型中我们的知识库是真的。在这个模型中,我们看到 R 也是真的。根据蕴涵的定义,如果 R 在 KB 为真的所有模型中都是真的,那么 KB ⊨ R。
接下来,让我们看看知识和逻辑如何被表示为代码。
from logic import *
# Create new classes, each having a name, or a symbol, representing each proposition. rain = Symbol("rain") # It is raining. hagrid = Symbol("hagrid") # Harry visited Hagrid dumbledore = Symbol("dumbledore") # Harry visited Dumbledore
# Save sentences into the KB knowledge = And( # Starting from the "And" logical connective, becasue each proposition represents knowledge that we know to be true.
Implication(Not(rain), hagrid), # ¬(It is raining) → (Harry visited Hagrid)
Or(hagrid, dumbledore), # (Harry visited Hagrid) ∨ (Harry visited Dumbledore).
Not(And(hagrid, dumbledore)), # ¬(Harry visited Hagrid ∧ Harry visited Dumbledore) i.e. Harry did not visit both Hagrid and Dumbledore.
dumbledore # Harry visited Dumbledore. Note that while previous propositions contained multiple symbols with connectors, this is a proposition consisting of one symbol. This means that we take as a fact that, in this KB, Harry visited Dumbledore.
)
运行模型检查算法需要以下信息:
-
知识库,将用于得出推论
-
查询,或我们感兴趣的是否被 KB 蕴含的命题
-
符号,所有使用的符号(或原子命题)的列表(在我们的例子中,这些是
rain,hagrid和dumbledore) -
模型,对符号的真假值分配
模型检查算法如下所示:
def check_all(knowledge, query, symbols, model):
# If model has an assignment for each symbol
# (The logic below might be a little confusing: we start with a list of symbols. The function is recursive, and every time it calls itself it pops one symbol from the symbols list and generates models from it. Thus, when the symbols list is empty, we know that we finished generating models with every possible truth assignment of symbols.)
if not symbols:
# If knowledge base is true in model, then query must also be true
if knowledge.evaluate(model):
return query.evaluate(model)
return True
else:
# Choose one of the remaining unused symbols
remaining = symbols.copy()
p = remaining.pop()
# Create a model where the symbol is true
model_true = model.copy()
model_true[p] = True
# Create a model where the symbol is false
model_false = model.copy()
model_false[p] = False
# Ensure entailment holds in both models
return(check_all(knowledge, query, remaining, model_true) and check_all(knowledge, query, remaining, model_false))
注意,我们只对 KB 为真的模型感兴趣。如果 KB 为假,那么我们知道为真的条件在这些模型中不会发生,使它们对我们案例无关紧要。
来自课堂外的一个例子:设 P:哈利玩寻找者,Q:奥利弗玩守门员,R:格兰芬多获胜。我们的知识库(KB)指定 P Q (P ∧ Q) → R。换句话说,我们知道 P 为真,即哈利玩寻找者,Q 为真,即奥利弗玩守门员,并且如果 P 和 Q 都为真,那么 R 也为真,这意味着格兰芬多赢得了比赛。现在想象一个模型,哈利扮演的是打击手而不是寻找者(因此,哈利没有玩寻找者,¬P)。在这种情况下,我们不在乎格兰芬多是否获胜(R 是否为真),因为我们知道哈利扮演的是寻找者而不是打击手。我们只对 P 和 Q 都为真的模型感兴趣。)
此外,check_all函数的工作方式是递归的。也就是说,它选择一个符号,创建两个模型,其中一个模型中该符号为真,另一个模型中该符号为假,然后再次调用自身,现在有两个模型,它们的区别在于该符号的真值分配。函数将持续这样做,直到所有符号在模型中都被分配了真值,使symbols列表为空。一旦它为空(如if not symbols行所示),在函数的每个实例中(其中每个实例持有不同的模型),函数将检查给定模型的知识库(KB)是否为真。如果在这个模型中 KB 为真,则函数将检查查询是否为真,如前所述。
知识工程
知识工程是确定如何在人工智能中表示命题和逻辑的过程。
让我们通过游戏《神秘线索》来练习知识工程。
在游戏中,谋杀是由一个人在地点使用工具犯下的。人物、工具和地点由卡片表示。随机抽取每个类别的卡片放入信封中,参与者需要揭开信封,找出凶手。参与者通过揭开卡片并从这些线索中推断出信封中必须有什么来做到这一点。我们将使用之前的模型检查算法来揭开这个谜团。在我们的模型中,我们将与谋杀相关的事项标记为True,否则标记为False。
为了我们的目的,假设我们有三个人:芥末、梅子和猩红,三个工具:刀、手枪和扳手,以及三个地点:舞厅、厨房和图书馆。
我们可以通过添加游戏的规则来开始创建我们的知识库。我们确定一个人是凶手,一个工具被使用,谋杀发生在某个地点。这可以用命题逻辑以下方式表示:
(芥末 ∨ 梅子 ∨ 猩红)
(刀 ∨ 手枪 ∨ 扳手)
(舞厅 ∨ 厨房 ∨ 图书馆)
游戏开始时,每个玩家看到一个人、一个工具和一个地点,因此知道他们与谋杀无关。玩家不会分享他们在这些卡片上看到的信息。假设我们的玩家得到了 Mustard、厨房和手枪的卡片。因此,我们知道这些与谋杀无关,我们可以添加到我们的 KB 中
¬(Mustard)
¬(kitchen)
¬(revolver)
在游戏的其他情况下,一个人可以做出猜测,提出一个人、工具和地点的组合。假设猜测是 Scarlet 在图书馆使用扳手犯罪。如果这个猜测是错误的,那么以下可以推导出来并添加到 KB 中:
(¬Scarlet ∨ ¬library ∨ ¬wrench)
现在,假设有人向我们展示了 Plum 的牌。因此,我们可以添加
¬(Plum)
到我们的 KB 中。
在这一点上,我们可以得出结论,凶手是 Scarlet,因为凶手只能是 Mustard、Plum 和 Scarlet 中的一个,而我们已经有证据表明前两个人不是凶手。
添加一点额外的知识,例如,比如它不是舞厅,就能给我们更多信息。首先,我们更新我们的知识库(KB)
¬(ballroom)
现在,使用多个先前数据,我们可以推导出 Scarlet 在图书馆用刀犯罪。我们可以推导出是图书馆,因为地点只能是舞厅、厨房或图书馆,前两个已经被证明不是地点。然而,当有人猜测 Scarlet、图书馆、扳手时,这个猜测是错误的。因此,这个陈述中的至少一个元素必须是错误的。因为我们知道 Scarlet 和图书馆是正确的,所以我们知道扳手是错误的。因为三个工具中必须有一个是正确的,而且不是扳手也不是手枪,我们可以得出结论,是刀。
这里是如何将信息添加到 Python 中的知识库中的:
# Add the clues to the KB knowledge = And(
# Start with the game conditions: one item in each of the three categories has to be true.
Or(mustard, plum, scarlet),
Or(ballroom, kitchen, library),
Or(knife, revolver, wrench),
# Add the information from the three initial cards we saw
Not(mustard),
Not(kitchen),
Not(revolver),
# Add the guess someone made that it is Scarlet, who used a wrench in the library
Or(Not(scarlet), Not(library), Not(wrench)),
# Add the cards that we were exposed to
Not(plum),
Not(ballroom)
)
我们还可以看看其他的逻辑谜题。考虑以下例子:有四个不同的人,Gilderoy、Pomona、Minerva 和 Horace,被分配到四个不同的学院,Gryffindor、Hufflepuff、Ravenclaw 和 Slytherin。每个学院恰好有一个人。用命题逻辑表示这个谜题的条件相当繁琐。首先,每个可能的分配都将本身成为一个命题:MinervaGryffindor、MinervaHufflepuff、MinervaRavenclaw、MinervaSlytherin、PomonaGryffindor……其次,为了表示每个人属于一个学院,需要一个表示所有可能学院分配的 Or 语句
(MinervaGryffindor ∨ MinervaHufflepuff ∨ MinervaRavenclaw ∨ MinervaSlytherin),对每个人重复。
然后,为了编码如果一个人被分配到一个学院,他们就不会被分配到其他学院,我们将写
(MinervaGryffindor → ¬MinervaHufflepuff) ∧ (MinervaGryffindor → ¬MinervaRavenclaw) ∧ (MinervaGryffindor → ¬MinervaSlytherin) ∧ (MinervaHufflepuff → ¬MinervaGryffindor)……
以此类推,对于所有房屋和所有人。在一阶逻辑部分中提供了解决这种低效的方法。然而,只要有足够的线索,这种类型的谜题仍然可以用任何一种逻辑来解决。
另一种可以使用命题逻辑解决的谜题类型是 Mastermind 游戏。在这个游戏中,第一玩家以某种顺序排列颜色,然后第二玩家必须猜测这个顺序。在每一轮中,第二玩家做出一个猜测,第一玩家给出一个数字,表示第二玩家猜对了多少颜色。让我们模拟一个有四种颜色的游戏。假设第二玩家建议以下顺序:

第一玩家回答“两个”。因此我们知道有两个颜色在正确的位置,另外两个在错误的位置。基于这个信息,第二玩家尝试交换两个颜色的位置。

现在,第一玩家回答“零”。因此,第二玩家知道最初交换的颜色在正确的位置,这意味着未被触及的两个颜色在错误的位置。第二玩家将它们交换。

第一玩家说“四个”,游戏结束。
在命题逻辑中表达这一点需要我们拥有(颜色数量)²个原子命题。所以,在四种颜色的情况下,我们会拥有代表颜色和位置的命题,如 red0、red1、red2、red3、blue0 等。下一步是将游戏的规则在命题逻辑中表示(每个位置只有一个颜色且颜色不重复),并将它们添加到知识库中。最后一步是将我们拥有的所有线索添加到知识库中。在我们的情况下,我们会添加在第一次猜测中,有两个位置是错误的,有两个位置是正确的,在第二次猜测中,没有一个是正确的。使用这些知识,模型检查算法可以给我们提供谜题的解决方案。
推理规则
模型检查不是一个高效的算法,因为它必须在给出答案之前考虑所有可能模型(提醒:如果查询 R 在所有模型(真值赋值)中都是真的,那么 R 是真的)。推理规则允许我们基于现有知识生成新信息,而无需考虑所有可能的模型。
推理规则通常使用一条水平线来表示,该线将上半部分(前提)与下半部分(结论)分开。前提是我们拥有的任何知识,而结论是基于前提可以生成的知识。

在这个例子中,我们的前提由以下命题组成:
-
如果下雨,那么哈利就在室内。
-
正在下雨。
基于此,大多数合理的人类可以得出结论:
- 哈利在室内。
肯定前件
在这个例子中,我们使用的推理规则是肯定前件式,这是一种比较复杂的方式来表达,即如果我们知道一个蕴涵及其前件是真的,那么后件也是真的。

合取消除
如果一个合取命题是真的,那么其中任何一个原子命题也是真的。例如,如果我们知道哈利和罗恩以及赫敏是朋友,我们可以得出哈利和赫敏是朋友的结论。

双重否定消除
被否定两次的命题是真的。例如,考虑命题“哈利没有通过考试的说法并不正确”。我们可以这样解析它:“哈利没有通过考试的说法并不正确”,或者“¬(哈利没有通过考试)”,最后“¬(¬(哈利通过了考试))”。两次否定相互抵消,将命题“哈利通过了考试”标记为真。

蕴涵消除
蕴涵等价于否定前件和后件之间的或关系。例如,命题“如果下雨,哈利在室内”等价于命题“(不下雨)或(哈利在室内)。”

这一点可能会有些令人困惑。然而,考虑以下真值表:
| P | Q | P → Q | ¬P ∨ Q |
|---|---|---|---|
| 假 | 假 | 真 | 真 |
| 假 | 真 | 真 | 真 |
| 真 | 假 | 假 | 假 |
| 真 | 真 | 真 | 真 |
由于 P → Q 和 ¬P ∨ Q 具有相同的真值赋值,我们知道它们在逻辑上是等价的。另一种思考方式是,如果一个蕴涵在两种可能条件下成立:首先,如果前件是假的,蕴涵就显然是真的(如前所述,在蕴涵部分讨论过)。这由 ¬P ∨ Q 中的否定前件 P 表示,意味着如果 P 是假的,那么命题总是真的。其次,当且仅当后件也是真的时,蕴涵才是真的。也就是说,如果 P 和 Q 都是真的,那么 ¬P ∨ Q 是真的。然而,如果 P 是真的而 Q 不是,那么 ¬P ∨ Q 就是假的。
双条件消除
一个双条件命题等价于一个蕴涵及其逆命题,并且使用合取连接符。例如,“如果下雨,那么哈利在室内”等价于“如果下雨,哈利在室内”和“如果哈利在室内,那么下雨”。

德摩根定律
将“与”连接词转换为“或”连接词是可能的。考虑以下命题:“哈利和罗恩都没有通过考试。”从这个命题中,我们可以得出结论:“哈利没有通过考试”或“罗恩没有通过考试。”也就是说,要使前面的“与”命题为真,至少有一个“或”命题中的命题必须为真。

同样,也可以得出相反的结论。考虑命题“哈利或罗恩没有通过考试。”这可以重新表述为“哈利没有通过考试”和“罗恩没有通过考试。”

分配律
一个由“与”或“或”连接词组合的两个元素的命题可以被分配,或分解成由“与”和“或”组成的小单元。


知识和搜索问题
推理可以被视为一个具有以下特性的搜索问题:
-
初始状态:起始知识库
-
行动:推理规则
-
转换模型:推理后的新知识库
-
目标测试:检查我们试图证明的陈述是否在知识库(KB)中
-
路径成本函数:证明中的步骤数
这展示了搜索算法是多么的灵活,它允许我们使用推理规则根据现有知识推导出新的信息。
归结
归结是一个强大的推理规则,它指出在一个“或”命题中的两个原子命题中,如果一个是假的,那么另一个必须是真的。例如,给定命题“罗恩在大厅”或“赫敏在图书馆”,除了命题“罗恩不在大厅”,我们还可以得出结论“赫敏在图书馆。”更正式地,我们可以这样定义归结:

归结依赖于互补文字,即两个相同的原子命题,其中一个被否定,另一个没有被否定,例如 P 和 ¬P。
归结可以进一步推广。假设除了命题“罗恩在大厅”或“赫敏在图书馆”,我们还知道“罗恩不在大厅”或“哈利在睡觉。”我们可以通过归结从这个命题中推断出“赫敏在图书馆”或“哈利在睡觉。”用正式的话来说:

互补文字允许我们通过归结推理生成新的句子,从而生成新的知识。因此,推理算法定位互补文字以生成新的知识。
子句 是文字的析取(一个命题符号或命题符号的否定,如 P,¬P)。析取 由命题通过或逻辑连接词连接(P ∨ Q ∨ R)。另一方面,合取 由命题通过与逻辑连接词连接(P ∧ Q ∧ R)。子句允许我们将任何逻辑语句转换为 合取范式 (CNF),例如:(A ∨ B ∨ C) ∧ (D ∨ ¬E) ∧ (F ∨ G)。
将命题转换为合取范式步骤
-
消去双条件
- 将 (α ↔ β) 转换为 (α → β) ∧ (β → α)。
-
消去蕴涵
- 将 (α → β) 转换为 ¬α ∨ β。
-
使用德摩根定律将否定内移,直到只有文字被否定(而不是子句)。
- 将 ¬(α ∧ β) 转换为 ¬α ∨ ¬β
这里有一个将 (P ∨ Q) → R 转换为合取范式的例子:
-
(P ∨ Q) → R
-
¬(P ∨ Q) ∨ R / 消去蕴涵
-
(¬P ∧ ¬Q) ∨ R / 德摩根定律
-
(¬P ∨ R) ∧ (¬Q ∨ R) / 分配律
在这一点上,我们可以在合取范式中运行推理算法。偶尔,通过解析推理的过程,我们可能会遇到一个子句包含相同的文字两次的情况。在这些情况下,使用称为 因式分解 的过程,其中移除重复的文字。例如,(P ∨ Q ∨ S) ∧ (¬P ∨ R ∨ S) 允许我们通过解析推理得出 (Q ∨ S ∨ R ∨ S)。重复的 S 可以被移除,得到 (Q ∨ R ∨ S)。
解析一个文字及其否定,即 ¬P 和 P,会得到 空子句 (()). 空子句总是假的,这很有道理,因为 P 和 ¬P 同时为真是不可能的。这个事实被解析算法所使用。
-
要确定 KB ⊨ α:
-
检查:是否 (KB ∧ ¬α) 是一个矛盾?
-
如果是这样,那么 KB ⊨ α。
-
否则,没有蕴涵。
-
-
反证法是计算机科学中常用的一种工具。如果我们的知识库是真的,并且它与 ¬α 相矛盾,这意味着 ¬α 是假的,因此 α 必须是真的。更技术地说,算法会执行以下操作:
-
要确定 KB ⊨ α:
-
将 (KB ∧ ¬α) 转换为合取范式。
-
继续检查我们是否可以使用解析产生一个新的子句。
-
如果我们产生了空子句(相当于 False),恭喜!我们已经达到了矛盾,从而证明了 KB ⊨ α。
-
然而,如果没有达到矛盾并且无法再推导出更多子句,则不存在蕴涵。
-
这里有一个例子说明这个算法可能如何工作:
-
(A ∨ B) ∧ (¬B ∨ C) ∧ (¬C) 是否蕴涵 A?
-
首先,为了进行反证法,我们假设 A 是假的。因此,我们到达 (A ∨ B) ∧ (¬B ∨ C) ∧ (¬C) ∧ (¬A)。
-
现在,我们可以开始生成新的信息。由于我们知道 C 是假的 (¬C),(¬B ∨ C) 可以成立的唯一方式是 B 也是假的。因此,我们可以将 (¬B) 添加到我们的 KB 中。
-
接下来,由于我们知道 (¬B),(A ∨ B) 可以成立的唯一方式是 A 为真。因此,我们可以将 (A) 添加到我们的 KB 中。
-
现在我们的知识库有两个互补的命题,(A) 和 (¬A)。我们解决了它们,得到了空集,( )。空集根据定义是假的,所以我们已经得到了一个矛盾。
一阶逻辑
一阶逻辑是另一种逻辑类型,它允许我们比命题逻辑更简洁地表达更复杂的思想。一阶逻辑使用两种类型的符号:常量符号和谓词符号。常量符号代表对象,而谓词符号类似于接受参数并返回真或假值的关联或函数。
例如,我们回到霍格沃茨的逻辑谜题,不同的人和房子分配。常量符号是人或房子,如米涅瓦、波莫娜、格兰芬多、赫奇帕奇等。谓词符号是某些常量符号为真或假的属性。例如,我们可以用句子 Person(Minerva) 表达米涅瓦是人这个想法。同样,我们可以用句子 House(Gryffindor) 表达格兰芬多是一个房子。所有的逻辑连接词在一阶逻辑中与之前一样工作。例如,¬House(Minerva) 表达了米涅瓦不是一个房子的想法。谓词符号也可以接受两个或更多参数,并表达它们之间的关系。例如,BelongsTo 表达了两个人和房子之间的关系。因此,米涅瓦属于格兰芬多的想法可以表达为 BelongsTo(Minerva, Gryffindor)。一阶逻辑允许为每个人和每个房子有一个符号。这比命题逻辑更简洁,在命题逻辑中,每个人—房子的分配都需要一个不同的符号。
全称量化
量化是一种可以在一阶逻辑中使用的工具,用于表示不使用特定常量符号的句子。全称量化使用符号 ∀ 来表达“对所有”。因此,例如,句子 ∀x. BelongsTo(x, Gryffindor) → ¬BelongsTo(x, Hufflepuff) 表达了对于每个符号,如果这个符号属于格兰芬多,它就不属于赫奇帕奇的想法。
存在量化
存在量化是一个与全称量化平行的概念。然而,全称量化被用来创建对所有 x 都为真的句子,而存在量化被用来创建至少对一个 x 为真的句子。它使用符号 ∃ 来表示。例如,句子 ∃x. House(x) ∧ BelongsTo(Minerva, x) 表示至少有一个符号既是房子,又属于米涅瓦。换句话说,这表达了米涅瓦属于一个房子的想法。
存在量词和全称量词可以在同一个句子中使用。例如,句子 ∀x. Person(x) → (∃y. House(y) ∧ BelongsTo(x, y)) 表达了这样的想法:如果 x 是一个人,那么至少有一个房子 y,这个人属于它。换句话说,这个句子的意思是每个人都属于一个房子。
除此之外,还有其他类型的逻辑,它们的共同点在于它们都存在于追求表示信息的过程中。这些是我们用来在我们的 AI 中表示知识的系统。
第二讲
不确定性
上次讲座,我们讨论了 AI 如何表示和推导新的知识。然而,在现实中,AI 通常只有对世界的部分了解,这留下了不确定性的空间。尽管如此,我们希望我们的 AI 在这些情况下做出最佳可能的决策。例如,在预测天气时,AI 有关于今天天气的信息,但无法 100%准确地预测明天的天气。尽管如此,我们可以比随机性做得更好,今天的讲座是关于我们如何创建在有限信息和不确定性下做出最优决策的 AI。
概率
不确定性可以用一系列事件及其发生的可能性或概率来表示。
可能的世界
每个可能的情况都可以被视为一个世界,用小写希腊字母 omega ω表示。例如,掷骰子可以产生六个可能的世界:一个世界是骰子显示 1,一个世界是骰子显示 2,以此类推。为了表示某个世界的概率,我们写 P(ω)。
概率公理
-
0 < P(ω) < 1:每个代表概率的值必须在 0 和 1 之间。
-
零是一个不可能发生的事件,比如掷一个标准骰子得到 7。
-
一个是一定会发生的事件,比如掷一个标准骰子得到小于 10 的数值。
-
通常情况下,数值越高,事件发生的可能性就越大。
-
-
所有可能事件的概率之和等于 1。

抛掷一个标准骰子得到数字 R 的概率可以表示为 P(R)。在我们的例子中,P(R) = 1/6,因为有六个可能的世界(从 1 到 6 的任意数字),每个世界发生的可能性是相等的。现在,考虑抛掷两个骰子的事件。现在,有 36 个可能的事件,它们再次是同等可能发生的。

然而,如果我们尝试预测两个骰子的和会发生什么?在这种情况下,我们只有 11 个可能值(和必须从 2 到 12),它们并不以相同的频率发生。

要得到一个事件的概率,我们将其发生的世界数除以所有可能世界数。例如,当抛掷两个骰子时,有 36 个可能的世界。只有在这 36 个世界中,当两个骰子都显示 6 时,我们才能得到和为 12。因此,P(12) = 1/36,或者说,用文字表达,抛掷两个骰子得到两个数之和为 12 的概率是 1/36。P(7)是多少?我们数一下,发现和为 7 的情况发生在 6 个世界中。因此,P(7) = 6/36 = 1/6。
无条件概率
无条件概率是在没有任何其他证据的情况下对命题的信念程度。我们之前提出的所有问题都是无条件概率问题,因为掷骰子的结果不依赖于先前的事件。
条件概率
条件概率是在已有某些证据被揭示的情况下,对命题的信念程度。正如引言中讨论的,人工智能可以使用部分信息对未来做出有根据的猜测。为了使用这些信息,这些信息会影响未来事件发生的概率,我们依赖于条件概率。
条件概率使用以下符号表示:P(a | b),意味着“在已知事件 b 已经发生的情况下,事件 a 发生的概率,”或者更简洁地说,“给定 b 的 a 的概率。”现在我们可以提出像今天下雨的概率是多少,给定昨天已经下雨 P(rain today | rain yesterday),或者患者有疾病的概率是多少,给定他们的检测结果 P(disease | test results)。
从数学上讲,为了计算给定 b 的 a 的条件概率,我们使用以下公式:

用语言来说,给定 b 的 a 的概率是真实的,等于 a 和 b 同时为真的概率,除以 b 的概率。对此进行直观推理的一种方式是“我们感兴趣的只是 a 和 b 同时为真的事件(分子),但只从我们知道 b 为真的世界中(分母)。”除以 b 限制了可能的世界,使其只包含 b 为真的世界。以下是与上述公式代数上等价的形式:

例如,考虑 P(sum 12 | roll six on one die),或者在我们已经掷出一个六的情况下,掷两个骰子得到总和为十二的概率。为了计算这个概率,我们首先将我们的世界限制在第一个骰子的值为六的情况:

现在我们询问事件 a(总和为 12)在我们限制问题的世界中发生的次数是多少(除以 P(b),即第一个骰子掷出 6 的概率)。

随机变量
随机变量是概率论中的一个变量,它有一个可能的值域。例如,为了表示掷骰子的可能结果,我们可以定义一个随机变量 Roll,它可以取值 {1, 2, 3, 4, 5, 6}。为了表示航班的状况,我们可以定义一个变量 Flight,它可以取值 {on time, delayed, canceled}。
通常,我们感兴趣的是每个值出现的概率。我们使用概率分布来表示这一点。例如,
-
P(Flight = on time) = 0.6
-
P(Flight = delayed) = 0.3
-
P(Flight = canceled) = 0.1
用文字来解释概率分布,这意味着有 60%的几率航班准点,30%的几率延误,10%的几率取消。注意,如前所述,所有可能结果的概率之和为 1。
概率分布可以用向量更简洁地表示。例如,P(Flight) = <0.6, 0.3, 0.1>. 为了使这种表示法可解释,值必须有一个固定的顺序(在我们的例子中,准点,延误,取消)。
独立性
独立性是知道一个事件的发生不会影响另一个事件发生的概率。例如,当掷两个骰子时,每个骰子的结果是相互独立的。掷出第一个骰子的 4 点不会影响我们掷出的第二个骰子的值。这与像早晨有云和下午下雨这样的相关事件相反。如果早晨有云,下午下雨的可能性更大,所以这些事件是相关的。
独立性可以用数学定义:事件a和b是独立的,当且仅当a和b的概率等于a的概率乘以b的概率:P(a ∧ b) = P(a)P(b).
贝叶斯定理
贝叶斯定理在概率论中常用以计算条件概率。用文字来说,贝叶斯定理表明,给定a的b的概率等于给定b的a的概率,乘以b的概率,除以a的概率。

例如,我们想要计算如果早晨有云,下午下雨的概率,或 P(雨 | 云). 我们从以下信息开始:
-
80%的雨天下午始于多云的早晨,或 P(云 | 雨).
-
40%的天数早晨有云,或 P(云).
-
10%的天数下午有雨,或 P(雨).
应用贝叶斯定理,我们计算(0.1)(0.8)/(0.4) = 0.2. 这意味着,如果早晨有云,下午下雨的概率是 20%。
知道 P(a | b),除了 P(a)和 P(b),我们可以计算 P(b | a)。这很有帮助,因为知道在未知原因给定的情况下可见效果的条件下概率,P(visible effect | unknown cause),允许我们计算给定可见效果的未知原因的概率,P(unknown cause | visible effect)。例如,我们可以通过医学试验学习 P(medical test results | disease),在试验中测试患有疾病的人,看看测试有多频繁地检测到这一点。了解这一点后,我们可以计算 P(disease | medical test results),这是有价值的诊断信息。
联合概率
联合概率是多个事件同时发生的可能性。
让我们考虑以下例子,关于早晨有云和下午下雨的概率。
| C = 云 | C = ¬云 |
|---|---|
| 0.4 | 0.6 |
| R = 雨 | R = ¬雨 |
| --- | --- |
| 0.1 | 0.9 |
观察这些数据,我们无法说早晨的云与下午下雨的可能性有关。要能够做到这一点,我们需要查看两个变量所有可能结果的联合概率。我们可以用以下表格表示:
| R = 雨 | R = ¬雨 | |
|---|---|---|
| C = 云 | 0.08 | 0.32 |
| C = ¬云 | 0.02 | 0.58 |
现在我们能够了解事件共现的信息。例如,我们知道某一天早晨有云和下午下雨的概率是 0.08。早晨无云和下午无雨的概率是 0.58。
使用联合概率,我们可以推导出条件概率。例如,如果我们对下午下雨时早晨有云的概率分布感兴趣。P(C | 雨) = P(C, 雨)/P(雨)(顺便提一下:在概率论中,逗号和 ∧ 可以互换使用)。因此,P(C, 雨) = P(C ∧ 雨)。换句话说,我们将雨和云的联合概率除以雨的概率。
在最后一个方程中,我们可以将 P(雨) 视为一个常数,它乘以 P(C, 雨)。因此,我们可以重写 P(C, 雨)/P(雨) = αP(C, 雨),或 α<0.08, 0.02>。提取 α 后,我们得到在下午下雨的条件下 C 的可能值的概率比例。也就是说,如果下午下雨,早晨有云和早晨无云的概率比例是 0.08:0.02。请注意,0.08 和 0.02 的和并不等于 1;然而,由于这是随机变量 C 的概率分布,我们知道它们的和应该等于 1。因此,我们需要通过计算 α 来归一化这些值,使得 α0.08 + α0.02 = 1。最后,我们可以说 P(C | 雨) = <0.8, 0.2>。
概率规则
-
否定: P(¬a) = 1 - P(a). 这源于所有可能世界的概率之和为 1,互补命题 a 和 ¬a 包括所有可能世界。
-
包含-排除: P(a ∨ b) = P(a) + P(b) - P(a ∧ b). 这可以这样解释:a 或 b 为真的世界等于所有 a 为真的世界,加上所有 b 为真的世界。然而,在这种情况下,一些世界被计算了两次(即 a 和 b 都为真的世界)。为了消除这种重叠,我们减去一次 a 和 b 都为真的世界(因为它们被计算了两次)。
这里有一个来自课堂外部的例子可以阐明这一点。假设我 80%的日子里吃冰淇淋,70%的日子里吃饼干。如果我们计算今天我吃冰淇淋或饼干的概率 P(ice cream ∨ cookies) 而不减去 P(ice cream ∧ cookies),我们会错误地得到 0.7 + 0.8 = 1.5。这与概率范围在 0 到 1 之间的公理相矛盾。为了纠正重复计算我同时吃冰淇淋和饼干的日子,我们需要减去一次 P(ice cream ∧ cookies)。
-
边缘化: P(a) = P(a, b) + P(a, ¬b). 这里面的想法是 b 和 ¬b 是互斥的概率。也就是说,b 和 ¬b 同时发生的概率是 0。我们还知道 b 和 ¬b 的总和为 1。因此,当 a 发生时,b 要么发生,要么不发生。当我们考虑 a 和 b 同时发生的概率,以及 a 和 ¬b 同时发生的概率,我们最终得到的就是 a 的概率。
边缘化可以用以下方式表示随机变量:

方程式的左边表示“随机变量 X 取值 xᵢ 的概率。”例如,对于前面提到的变量 C,可能的两个值是“早上有云”和“早上无云”。方程式的右边是边缘化的概念。P(X = xᵢ) 等于 xᵢ 和随机变量 Y 的每个值的联合概率之和。例如,P(C = cloud) = P(C = cloud, R = rain) + P(C = cloud, R = ¬rain) = 0.08 + 0.32 = 0.4。
- 条件化: P(a) = P(a | b)P(b) + P(a | ¬b)P(¬b). 这与边缘化有类似的想法。事件 a 发生的概率等于 a 在 b 条件下的概率乘以 b 的概率,加上 a 在 ¬b 条件下的概率乘以 ¬b 的概率。

在这个公式中,随机变量 X 以 xᵢ 的值出现,其概率等于 xᵢ 在每个随机变量 Y 的值下的概率之和乘以变量 Y 取该值的概率。如果我们记住 P(a | b) = P(a, b)/P(b),这个公式是有意义的。如果我们乘以 P(b),我们最终得到 P(a, b),然后我们就可以像边缘化一样做了。
贝叶斯网络
贝叶斯网络是一种表示随机变量之间依赖关系的数据结构。贝叶斯网络具有以下特性:
-
它们是有向图。
-
图表上的每个节点代表一个随机变量。
-
从 X 到 Y 的箭头表示 X 是 Y 的父节点。也就是说,Y 的概率分布取决于 X 的值。
-
每个节点 X 都有概率分布 P(X | Parents(X))。
让我们考虑一个涉及影响我们是否准时到达约会的时间的贝叶斯网络的例子。

让我们从上到下描述这个贝叶斯网络:
-
雨是网络中的根节点。这意味着它的概率分布不依赖于任何先前事件。在我们的例子中,Rain 是一个可以取值 {none, light, heavy} 的随机变量,其概率分布如下:
none light heavy 0.7 0.2 0.1 -
在我们的例子中,Maintenance 编码是否存在火车轨道维护,取值有 {yes, no}。Rain 是 Maintenance 的父节点,这意味着 Maintenance 的概率分布受 Rain 影响。
R yes no none 0.4 0.6 light 0.2 0.8 heavy 0.1 0.9 -
Train 是一个变量,表示火车是否准时或延误,取值有 {on time, delayed}。请注意,Train 从 Maintenance 和 Rain 两处都有箭头指向它。这意味着它们都是 Train 的父节点,它们的值会影响 Train 的概率分布。
R M on time delayed none yes 0.8 0.2 none no 0.9 0.1 light yes 0.6 0.4 light no 0.7 0.3 heavy yes 0.4 0.6 heavy no 0.5 0.5 -
约会是一个随机变量,表示我们是否参加约会,取值有 {attend, miss}。请注意,它的唯一父节点是 Train。关于贝叶斯网络的一个值得注意的点:父节点只包括直接关系。确实,维护会影响火车是否准时,火车是否准时会影响我们是否参加约会。然而,最终直接影响我们参加约会机会的是火车是否准时到达,这正是贝叶斯网络所表示的。例如,如果火车准时到达,可能是大雨和轨道维护,但这对我们是否到达约会没有影响。
T attend miss on time 0.9 0.1 delayed 0.6 0.4
例如,如果我们想找到在无维护和轻雨天气下火车延误时错过会议的概率,或者 P(light, no, delayed, miss),我们将计算以下内容:P(light)P(no | light)P(delayed | light, no)P(miss | delayed)。每个单独概率的值可以在上面的概率分布中找到,然后这些值相乘以产生 P(no, light, delayed, miss)。
推理
在上一堂课中,我们探讨了通过蕴涵进行推理。这意味着我们可以根据我们已有的信息确定性地得出新的信息。我们也可以根据概率推断新的信息。虽然这并不允许我们确定地知道新的信息,但它允许我们找出某些值的概率分布。推理具有多个属性。
-
查询X:我们想要计算概率分布的变量。
-
证据变量E:对于事件e已经观察到的变量。例如,我们可能观察到有轻微降雨,这个观察结果有助于我们计算火车延误的概率。
-
隐藏变量Y:不是查询且尚未观察到的变量。例如,站在火车站,我们可以观察到是否有雨,但我们无法知道道路上是否有轨道维护。因此,在这种情况下,Maintenance 将是一个隐藏变量。
-
目标:计算P(X | e)。例如,根据我们知道有轻微降雨的证据e,计算 Train 变量(查询)的概率分布。
让我们举一个例子。我们想要计算在已知有轻微降雨且没有轨道维护的证据下,预约变量(Appointment)的概率分布。也就是说,我们知道有轻微降雨且没有轨道维护,我们想要找出我们参加预约和错过预约的概率,即P(Appointment | light, no)。从联合概率部分,我们知道我们可以将预约随机变量的可能值表示为比例,将P(Appointment | light, no)重写为 αP(Appointment, light, no)。如果其父变量只有 Train 变量,而不是 Rain 或 Maintenance,我们该如何计算预约变量的概率分布?在这里,我们将使用边缘化。P(Appointment, light, no)的值等于 α[P(Appointment, light, no, delayed) + P(Appointment, light, no, on time*)]。
枚举推理
枚举推理是一个在给定观察到的证据 e 和一些隐藏变量 Y 的情况下寻找变量 X 的概率分布的过程。

在这个方程中,X 代表查询变量,e 代表观察到的证据,y 代表所有隐藏变量的值,α将结果归一化,使得我们最终得到的概率加起来等于 1。用文字解释这个方程,它表示的是,给定 e 的 X 的概率分布等于 X 和 e 的归一化概率分布。为了得到这个分布,我们求和 X、e 和 y 的归一化概率,其中 y 每次取隐藏变量 Y 的不同值。
Python 中存在多个库来简化概率推理的过程。我们将查看pomegranate库,看看如何用代码表示上述数据。
首先,我们创建节点并为每个节点提供一个概率分布。
from pomegranate import *
# Rain node has no parents rain = Node(DiscreteDistribution({
"none": 0.7,
"light": 0.2,
"heavy": 0.1
}), name="rain")
# Track maintenance node is conditional on rain maintenance = Node(ConditionalProbabilityTable([
["none", "yes", 0.4],
["none", "no", 0.6],
["light", "yes", 0.2],
["light", "no", 0.8],
["heavy", "yes", 0.1],
["heavy", "no", 0.9]
], [rain.distribution]), name="maintenance")
# Train node is conditional on rain and maintenance train = Node(ConditionalProbabilityTable([
["none", "yes", "on time", 0.8],
["none", "yes", "delayed", 0.2],
["none", "no", "on time", 0.9],
["none", "no", "delayed", 0.1],
["light", "yes", "on time", 0.6],
["light", "yes", "delayed", 0.4],
["light", "no", "on time", 0.7],
["light", "no", "delayed", 0.3],
["heavy", "yes", "on time", 0.4],
["heavy", "yes", "delayed", 0.6],
["heavy", "no", "on time", 0.5],
["heavy", "no", "delayed", 0.5],
], [rain.distribution, maintenance.distribution]), name="train")
# Appointment node is conditional on train appointment = Node(ConditionalProbabilityTable([
["on time", "attend", 0.9],
["on time", "miss", 0.1],
["delayed", "attend", 0.6],
["delayed", "miss", 0.4]
], [train.distribution]), name="appointment")
其次,我们通过添加节点并描述它们之间通过添加边连接的节点(回想一下,贝叶斯网络是一个有向图,由带有箭头的节点组成)来创建模型。
# Create a Bayesian Network and add states model = BayesianNetwork()
model.add_states(rain, maintenance, train, appointment)
# Add edges connecting nodes model.add_edge(rain, maintenance)
model.add_edge(rain, train)
model.add_edge(maintenance, train)
model.add_edge(train, appointment)
# Finalize model model.bake()
现在,要询问某个事件的概率,我们使用感兴趣的值运行模型。在这个例子中,我们想知道没有雨、没有轨道维护、火车准时到达,并且我们参加会议的概率。
# Calculate probability for a given observation probability = model.probability([["none", "no", "on time", "attend"]])
print(probability)
否则,我们可以使用程序为所有变量提供给定一些观察证据的概率分布。在以下情况下,我们知道火车延误了。根据这个信息,我们计算并打印变量 Rain、Maintenance 和 Appointment 的概率分布。
# Calculate predictions based on the evidence that the train was delayed predictions = model.predict_proba({
"train": "delayed"
})
# Print predictions for each node for node, prediction in zip(model.states, predictions):
if isinstance(prediction, str):
print(f"{node.name}: {prediction}")
else:
print(f"{node.name}")
for value, probability in prediction.parameters[0].items():
print(f" {value}: {probability:.4f}")
上面的代码使用了枚举推理。然而,这种计算概率的方法效率低下,尤其是在模型中有许多变量时。另一种方法可能是放弃精确推理而采用近似推理。这样做,我们在生成的概率中会失去一些精度,但通常这种不精确是可以忽略不计的。相反,我们获得了一种可扩展的概率计算方法。
抽样
抽样是近似推理的一种技术。在抽样中,每个变量根据其概率分布抽取一个值。我们将从一个课外例子开始,然后介绍课内的例子。
要使用骰子进行抽样生成分布,我们可以多次掷骰子并记录每次得到的结果。假设我们掷了 600 次骰子。我们计算得到 1 的次数,预计大约是 100 次,然后对其他值 2-6 重复此操作。然后,我们将每个计数除以总掷骰子次数。这将生成掷骰子值的近似分布:一方面,我们不太可能得到每个值都有 1/6 的概率出现的结果(这是精确概率),但我们会得到一个接近这个值的结果。
这里有一个来自讲座的例子:如果我们从采样 Rain 变量开始,将生成概率为 0.7 的 无 值,概率为 0.2 的 轻微 值,以及概率为 0.1 的 严重 值。假设我们得到的采样值是 无。当我们到达 Maintenance 变量时,我们也对其进行采样,但只从 Rain 等于 无 的概率分布中进行采样,因为这是一个已经采样的结果。我们将继续这样做,直到所有节点。现在我们有一个样本,重复这个过程多次生成一个分布。现在,如果我们想回答一个问题,比如 P(Train = on time) 是什么,我们可以计算变量 Train 有 准时 值的样本数量,然后将结果除以样本总数。这样,我们就为 P(Train = on time) 生成了一个近似概率。
我们还可以回答涉及条件概率的问题,例如 P(Rain = light | Train = on time)。在这种情况下,我们忽略所有 Train 值不是 准时 的样本,然后像以前一样进行。我们计算在 Train = 准时 的样本中,变量 Rain = 轻微 的样本数量,然后除以 Train = 准时 的样本总数。
在代码中,一个采样函数可以看起来像 generate_sample:
import pomegranate
from collections import Counter
from model import model
def generate_sample():
# Mapping of random variable name to sample generated
sample = {}
# Mapping of distribution to sample generated
parents = {}
# Loop over all states, assuming topological order
for state in model.states:
# If we have a non-root node, sample conditional on parents
if isinstance(state.distribution, pomegranate.ConditionalProbabilityTable):
sample[state.name] = state.distribution.sample(parent_values=parents)
# Otherwise, just sample from the distribution alone
else:
sample[state.name] = state.distribution.sample()
# Keep track of the sampled value in the parents mapping
parents[state.distribution] = sample[state.name]
# Return generated sample
return sample
现在,为了计算 P(Appointment | Train = delayed),即火车延误时 Appointment 变量的概率分布,我们做以下操作:
# Rejection sampling
# Compute distribution of Appointment given that train is delayed N = 10000
data = []
# Repeat sampling 10,000 times for i in range(N):
# Generate a sample based on the function that we defined earlier
sample = generate_sample()
# If, in this sample, the variable of Train has the value delayed, save the sample. Since we are interested interested in the probability distribution of Appointment given that the train is delayed, we discard the sampled where the train was on time.
if sample["train"] == "delayed":
data.append(sample["appointment"])
# Count how many times each value of the variable appeared. We can later normalize by dividing the results by the total number of saved samples to get the approximate probabilities of the variable that add up to 1. print(Counter(data))
似然加权
在上面的采样例子中,我们丢弃了不符合我们已有证据的样本。这是低效的。一种绕过这个问题的方法是通过似然加权,使用以下步骤:
-
首先固定证据变量的值。
-
使用贝叶斯网络中的条件概率采样非证据变量。
-
将每个样本按其 似然 加权:所有证据发生的概率。
例如,如果我们观察到火车准时到达,我们就会像以前一样开始采样。我们根据其概率分布采样 Rain 的值,然后是 Maintenance,但当到达 Train 时,我们总是给出观察到的值,在我们的例子中,是 准时。然后我们继续根据 Train = 准时 给定的概率分布采样 Appointment。现在这个样本存在了,我们根据观察变量给定的条件概率来加权。也就是说,如果我们采样了 Rain 并得到 轻微,然后采样 Maintenance 并得到 是,那么我们将对这个样本进行加权,权重为 P(Train = on time | light, yes)。
马尔可夫模型
到目前为止,我们考虑了一些基于我们观察到的某些信息的概率问题。在这种范式下,时间维度没有以任何方式表示。然而,许多任务确实依赖于时间维度,例如预测。为了表示时间变量,我们将创建一个新的变量 X,并根据感兴趣的事件对其进行更改,使得 Xₜ是当前事件,Xₜ₊₁是下一个事件,依此类推。为了能够预测未来的事件,我们将使用马尔可夫模型。
马尔可夫假设
马尔可夫假设是一个假设,即当前状态只依赖于有限数量的先前状态。这对我们来说很重要。想想预测天气的任务。在理论上,我们可以使用过去一年的所有数据来预测明天的天气。然而,这是不可行的,因为这需要巨大的计算能力,而且可能没有关于基于 365 天前的天气明天天气的条件概率的信息。使用马尔可夫假设,我们限制我们的先前状态(例如,在预测明天的天气时考虑多少天前的天气),从而使任务变得可管理。这意味着我们可能得到对感兴趣概率的更粗糙的近似,但这通常足以满足我们的需求。此外,我们可以使用基于最后一个事件的信息的马尔可夫模型(例如,根据今天的天气预测明天的天气)。
马尔可夫链
马尔可夫链是一系列随机变量,其中每个变量的分布遵循马尔可夫假设。也就是说,链中的每个事件都是基于之前事件发生的概率。
要开始构建马尔可夫链,我们需要一个转移模型,该模型将指定基于当前事件可能值的下一个事件的概率分布。

在这个例子中,如果今天是晴天,那么明天也是晴天的概率是 0.8。这是合理的,因为晴天之后接着是晴天的可能性更大。然而,如果今天是雨天,那么明天下雨的概率是 0.7,因为雨天更有可能连续出现。使用这个转移模型,可以采样一个马尔可夫链。从一个雨天或晴天开始,然后根据今天天气是晴天还是雨天来采样下一天。然后,根据明天的情况来调整后天概率,依此类推,从而形成一个马尔可夫链:

给定这个马尔可夫链,我们现在可以回答诸如“连续四天降雨的概率是多少?”等问题。以下是一个如何在代码中实现马尔可夫链的示例:
from pomegranate import *
# Define starting probabilities start = DiscreteDistribution({
"sun": 0.5,
"rain": 0.5
})
# Define transition model transitions = ConditionalProbabilityTable([
["sun", "sun", 0.8],
["sun", "rain", 0.2],
["rain", "sun", 0.3],
["rain", "rain", 0.7]
], [start])
# Create Markov chain model = MarkovChain([start, transitions])
# Sample 50 states from chain print(model.sample(50))
隐藏马尔可夫模型
隐藏马尔可夫模型是一种针对具有隐藏状态的系统的马尔可夫模型,这些状态生成某些观测事件。这意味着有时,AI 对世界有一些测量,但没有访问世界精确状态的途径。在这些情况下,世界的状态被称为隐藏状态,而 AI 可以访问的任何数据都是观测。以下是一些例子:
-
对于探索未知领域的机器人,隐藏状态是它的位置,而观测是机器人传感器记录的数据。
-
在语音识别中,隐藏状态是所说的单词,而观测是音频波形。
-
在测量网站上的用户参与度时,隐藏状态是用户的参与程度,而观测是网站或应用的统计分析。
对于我们的讨论,我们将使用以下例子。我们的 AI 想要推断天气(隐藏状态),但它只能访问一个室内摄像头,该摄像头记录了有多少人带着伞。以下是我们的传感器模型(也称为发射模型),它表示这些概率:

在这个模型中,如果天气晴朗,人们最不可能带伞进建筑物。如果下雨,那么人们很可能带伞进建筑物。通过观察人们是否带伞,我们可以以合理的可能性预测外面的天气。
传感器马尔可夫假设
假设证据变量只依赖于对应的状态。例如,对于我们的模型,我们假设人们是否带伞去办公室只取决于天气。这并不一定反映完整的真相,因为例如,更负责任、怕雨的人即使在晴天也可能随身携带伞,如果我们知道每个人的性格,这将向模型添加更多数据。然而,传感器马尔可夫假设忽略了这些数据,假设只有隐藏状态影响观测。
隐藏马尔可夫模型可以用具有两层马尔可夫链来表示。顶层变量 X 代表隐藏状态。底层变量 E 代表证据,即我们拥有的观测。

基于隐藏马尔可夫模型,可以实现多个任务:
-
过滤:给定从开始到现在的观测,计算当前状态的概率分布。例如,给定从时间开始到今天人们带伞的信息,我们生成今天是否下雨的概率分布。
-
预测:给定从开始到现在的观测,计算未来状态的概率分布。
-
平滑化:给定从开始到现在的观察结果,计算过去状态的概率分布。例如,计算给定今天人们带伞的情况下,昨天下雨的概率。
-
最可能的解释:给定从开始到现在的观察结果,计算最可能的事件序列。
最可能的解释任务可以用于语音识别等过程,其中,基于多个波形,AI 推断出最可能导致这些波形的单词或音节的序列。下面是一个用于最可能解释任务的隐马尔可夫模型的 Python 实现:
from pomegranate import *
# Observation model for each state sun = DiscreteDistribution({
"umbrella": 0.2,
"no umbrella": 0.8
})
rain = DiscreteDistribution({
"umbrella": 0.9,
"no umbrella": 0.1
})
states = [sun, rain]
# Transition model transitions = numpy.array(
[[0.8, 0.2], # Tomorrow's predictions if today = sun
[0.3, 0.7]] # Tomorrow's predictions if today = rain )
# Starting probabilities starts = numpy.array([0.5, 0.5])
# Create the model model = HiddenMarkovModel.from_matrix(
transitions, states, starts,
state_names=["sun", "rain"]
)
model.bake()
注意,我们的模型既有传感器模型也有转换模型。对于隐马尔可夫模型,我们需要这两个模型。在下面的代码片段中,我们看到一系列观察结果,即人们是否带伞进入大楼,基于这个序列,我们将运行模型,该模型将生成并打印最可能的解释(即最可能导致这种观察模式的天气序列):
from model import model
# Observed data observations = [
"umbrella",
"umbrella",
"no umbrella",
"umbrella",
"umbrella",
"umbrella",
"umbrella",
"no umbrella",
"no umbrella"
]
# Predict underlying states predictions = model.predict(observations)
for prediction in predictions:
print(model.states[prediction].name)
在这种情况下,程序的输出将是雨,雨,晴,雨,雨,雨,雨,晴,晴。这个输出代表了根据我们对人们是否带伞进入大楼的观察,最可能的天气模式。
第三讲
优化
优化是从一组可能选项中选择最佳选项。我们已经在寻找最佳可能选项的问题中遇到过,例如在最小-最大算法中,今天我们将学习我们可以用来解决更广泛范围问题的工具。
局部搜索
局部搜索是一种搜索算法,它保持单个节点并通过移动到相邻节点来搜索。这种算法与我们之前看到的搜索类型不同。例如,在迷宫解决中,我们想要找到到达目标的最快路径,而局部搜索则关注于找到问题的最佳答案。通常,局部搜索会得到一个非最优但“足够好”的答案,从而节省计算能力。考虑以下局部搜索问题的例子:我们有四个位于特定位置的房屋。我们想要建造两家医院,使得每个房屋到医院的距离最小化。这个问题可以如下可视化:

在这个插图上,我们看到的是房屋和医院的一种可能配置。它们之间的距离使用曼哈顿距离(向上、向下和向侧的移动次数;在第六讲 中详细讨论)来衡量,每个房屋到最近医院的距离之和是 17。我们称之为成本,因为我们试图最小化这个距离。在这种情况下,状态可以是房屋和医院的任何一种配置。
抽象这个概念,我们可以将房屋和医院的每种配置表示为下面的状态空间景观。图片中的每根条形代表一个状态的值,在我们的例子中,这将是房屋和医院某种配置的成本。

基于这个可视化,我们可以为接下来的讨论定义几个重要术语:
-
目标函数是我们用来最大化解决方案价值的函数。
-
成本函数是我们用来最小化解决方案成本(这是我们将在房屋和医院例子中使用的函数。我们希望最小化房屋到医院的距离)的函数。
-
当前状态是函数目前正在考虑的状态。
-
相邻状态是当前状态可以转换到的状态。在上面的单维状态空间景观中,相邻状态是当前状态两侧的状态。在我们的例子中,相邻状态可能是将其中一家医院向任何方向移动一步所得到的状态。相邻状态通常与当前状态相似,因此它们的值接近当前状态的值。
注意,局部搜索算法的工作方式是考虑当前状态中的一个节点,然后将节点移动到当前状态的一个相邻状态。这与例如最小-最大算法不同,在最小-最大算法中,状态空间中的每个状态都被递归地考虑。
爬山
爬山是局部搜索算法的一种类型。在这个算法中,将相邻状态与当前状态进行比较,如果其中任何一个更好,我们就将当前节点从当前状态切换到那个相邻状态。什么被认为是更好的,取决于我们是否使用目标函数,偏好更高的值,还是递减函数,偏好更低的值。
爬山算法在伪代码中的表现形式如下:
函数 Hill-Climb(问题):
-
当前状态 = 问题的初始状态
-
repeat:
-
相邻状态 = 当前状态的最佳值相邻状态
-
如果相邻状态不如当前状态好:
- 返回 当前状态
-
当前状态 = 相邻状态
-
在这个算法中,我们从当前状态开始。在某些问题中,我们将知道当前状态是什么,而在其他情况下,我们必须随机选择一个状态作为起点。然后,我们重复以下操作:评估相邻状态,选择具有最佳值的那个。然后,我们将这个相邻状态的值与当前状态的值进行比较。如果相邻状态更好,我们将当前状态切换到相邻状态,并重复这个过程。当我们将最佳相邻状态与当前状态进行比较,且当前状态更好时,过程结束。然后,我们返回当前状态。
使用爬山算法,我们可以开始改进我们示例中分配给医院的地点。经过几次转换后,我们达到以下状态:

在这个状态下,成本是 11,这比初始状态的 17 有所改善。然而,这还不是最佳状态。例如,将医院移动到左上角房屋下方,可以将成本降低到 9,这比 11 更好。然而,这个版本的爬山算法无法达到那里,因为所有相邻状态的成本至少与当前状态一样高。从这个意义上说,爬山算法是短视的,通常满足于比其他一些解决方案更好的解决方案,但不一定是所有可能解决方案中的最佳。
局部和全局极小值与极大值
如上所述,爬山算法可能会陷入局部极大值或极小值。一个局部极大值(复数:maxima)是一个比其相邻状态具有更高值的态。与之相反,一个全局极大值是一个在状态空间中所有状态中具有最高值的态。

相反,一个局部最小值(复数:minima)是一个比其相邻状态具有更低值的态。与这相反,一个全局最小值是一个在状态空间中所有态中具有最低值的态。

爬山算法的问题在于它们可能最终陷入局部最小值和最大值。一旦算法达到一个点,其邻居的值对于函数的目的来说比当前状态更差,算法就会停止。特殊类型的局部最大值和最小值包括平坦的局部最大值/最小值,其中多个具有相等值的相邻状态形成一个高原,其邻居的值更差,以及肩部,其中多个具有相等值的相邻状态,高原的邻居可以是更好的也可以是更差的。从高原的中间开始,算法将无法向任何方向前进。

爬山法变体
由于爬山法的局限性,人们已经想到了多种变体来克服陷入局部最小值和最大值的问题。所有算法的变体都有一个共同点,即无论策略如何,每个变体仍然有可能最终陷入局部最小值和最大值,并且没有继续优化的手段。下面的算法表述中,较高的值被视为较好,但它们也适用于成本函数,其目标是最小化成本。
-
最速上升:选择最高值的邻居。这是我们上面讨论的标准变体。
-
随机性:从更高值的邻居中随机选择。这样做,我们选择走向任何可以提高我们值的方向。例如,如果最高值的邻居导致局部最大值,而另一个邻居导致全局最大值,这样做是有意义的。
-
首选:选择第一个更高值的邻居。
-
随机重启:多次进行爬山。每次,从一个随机状态开始。比较每次试验的最大值,并选择其中最高的一个。
-
局部束搜索:选择k个最高值的邻居。这与大多数局部搜索算法不同,因为它使用多个节点进行搜索,而不仅仅是单个节点。
虽然局部搜索算法并不总是给出最佳可能的解决方案,但在考虑所有可能状态在计算上不可行的情况下,它们通常可以给出足够好的解决方案。
模拟退火
尽管我们已经看到了可以改进爬山法的变体,但它们都存在相同的缺陷:一旦算法达到局部最大值,它就会停止运行。模拟退火允许算法在陷入局部最大值时“摆脱”自己。
热处理是将金属加热并允许其缓慢冷却的过程,这有助于使金属变硬。这个过程被用作模拟退火算法的隐喻,该算法从高温开始,更有可能做出随机决策,随着温度的降低,它做出随机决策的可能性降低,变得更加“坚定”。这种机制允许算法将其状态改变为比当前状态更差的邻居状态,这就是它如何逃离局部最大值的原因。以下是对模拟退火算法的伪代码:
函数 Simulated-Annealing(problem, max):
-
当前 = 问题的初始状态
-
for t = 1 to max:
-
T = Temperature(t)
-
邻居 = 当前的随机邻居
-
ΔE = 相较于当前,邻居有多好
-
if ΔE > 0:
- current = neighbor
-
以概率 e^(ΔE/T) 将当前设置为邻居
-
-
返回当前
该算法将问题和一个max(算法应重复的次数)作为输入。对于每一次迭代,使用温度函数设置T。这个函数在早期迭代(当t较低时)返回较高的值,而在后期迭代(当t较高时)返回较低的值。然后,选择一个随机邻居,并计算ΔE,以量化邻居状态相较于当前状态有多好。如果邻居状态比当前状态好(ΔE > 0),就像之前一样,我们将当前状态设置为邻居状态。然而,当邻居状态更差(ΔE < 0)时,我们仍然可能将当前状态设置为那个邻居状态,并且我们以概率 e^(ΔE/T) 来这样做。这里的想法是,更负的ΔE将导致邻居状态被选择的概率更低,而温度T越高,邻居状态被选择的概率就越高。这意味着邻居状态越差,被选择的概率就越低,并且算法在处理过程中越早,将更差的邻居状态设置为当前状态的概率就越高。背后的数学原理如下:e 是一个常数(大约为 2.72),而ΔE是负的(因为邻居状态比当前状态差)。ΔE越负,得到的结果值越接近 0。温度T越高,ΔE/T越接近 0,使得概率越接近 1。
旅行商问题
在旅行商问题中,任务是连接所有点,同时选择最短的可能距离。例如,这就是配送公司需要做的事情:找到从商店到所有客户家并返回的最短路线。

在这种情况下,一个相邻状态可能被视为两个箭头交换位置的状态。计算所有可能的组合使得这个问题计算量很大(仅有 10 个点就给出了 10!,即 3,628,800 条可能的路径)。通过使用模拟退火算法,可以在较低的计算成本下找到良好的解决方案。
线性规划
线性规划是一类优化线性方程(形式为 y = ax₁ + bx₂ + …的方程)的问题。
线性规划将包含以下组件:
-
我们想要最小化的成本函数:c₁x₁ + c₂x₂ + … + cₙxₙ。在这里,每个 x₋ 是一个变量,它与一些成本 c₋相关联。
-
一个表示为变量之和的约束,该和要么小于或等于某个值(a₁x₁ + a₂x₂ + … + aₙxₙ ≤ b),要么精确等于这个值(a₁x₁ + a₂x₂ + … + aₙxₙ = b)。在这种情况下,x₋ 是一个变量,a₋ 是与之相关的某种资源,b 是我们可以为这个问题投入的资源量。
-
变量的个体界限(例如,一个变量不能为负)的形式为 lᵢ ≤ xᵢ ≤ uᵢ。
考虑以下示例:
-
两台机器,X₁和 X₂。X₁每小时运行成本为 50 美元,X₂每小时运行成本为 80 美元。目标是最小化成本。这可以形式化为一个成本函数:50x₁ + 80x₂。
-
X₁每小时需要 5 个单位的劳动力。X₂每小时需要 2 个单位的劳动力。总共需要投入 20 个单位的劳动力。这可以形式化为一个约束:5x₁ + 2x₂ ≤ 20。
-
X₁每小时产生 10 个单位的产出。X₂每小时产生 12 个单位的产出。公司需要 90 个单位的产出。这是另一个约束。实际上,它可以重写为 10x₁ + 12x₂ ≥ 90。然而,约束需要是形式(a₁x₁ + a₂x₂ + … + aₙxₙ ≤ b)或(a₁x₁ + a₂x₂ + … + aₙxₙ = b)。因此,我们乘以(-1)以得到一个等效方程,其形式是我们想要的:(-10x₁) + (-12x₂) ≤ -90。
线性规划的优化算法需要我们在几何学和线性代数方面的背景知识,我们不希望假设。相反,我们可以使用已经存在的算法,例如单纯形法和内点法。
以下是一个使用 Python 中的 scipy 库的线性规划示例:
import scipy.optimize
# Objective Function: 50x_1 + 80x_2
# Constraint 1: 5x_1 + 2x_2 <= 20
# Constraint 2: -10x_1 + -12x_2 <= -90
result = scipy.optimize.linprog(
[50, 80], # Cost function: 50x_1 + 80x_2
A_ub=[[5, 2], [-10, -12]], # Coefficients for inequalities
b_ub=[20, -90], # Constraints for inequalities: 20 and -90 )
if result.success:
print(f"X1: {round(result.x[0], 2)} hours")
print(f"X2: {round(result.x[1], 2)} hours")
else:
print("No solution")
满足约束
满足约束问题是需要分配变量值以满足某些条件的问题类别。
满足约束问题具有以下属性:
-
变量的集合(x₁, x₂, …, xₙ)
-
每个变量的域集合
-
约束集合 C
数独可以表示为一个满足约束问题,其中每个空格都是一个变量,域是数字 1-9,约束是不能相等的方格。
考虑另一个例子。学生 1-4 每人从 A、B、…、G 中选择三门课程。每门课程都需要进行考试,可能的考试日期是星期一、星期二和星期三。然而,同一位学生不能在同一天参加两次考试。在这种情况下,变量是课程,域是日期,约束是哪些课程不能安排在同一天进行考试,因为同一位学生正在学习它们。这可以表示如下:

这个问题可以通过表示为图的约束来解决。图上的每个节点代表一门课程,如果两门课程不能在同一天安排,则在这两门课程之间画一条边。在这种情况下,图将看起来像这样:

关于约束满足问题,还有一些术语值得了解:
-
硬约束是必须在正确解中满足的约束。
-
软约束是表达相对于其他解决方案更受偏好的约束。
-
一元约束是只涉及一个变量的约束。在我们的例子中,一元约束可能是说课程 A 不能在星期一进行考试(A ≠ 星期一)。
-
二元约束是涉及两个变量的约束。这是我们上面例子中使用的那种约束,表示某些两门课程不能有相同的值(A ≠ B)。
节点一致性
节点一致性是指一个变量的域中的所有值都满足该变量的单元约束。
例如,让我们考虑两门课程,A 和 B。每门课程的域是{星期一,星期二,星期三},约束是{A ≠ Mon,B ≠ Tue,B ≠ Mon,A ≠ B}。现在,A 和 B 都不一致,因为现有的约束阻止它们能够取它们域中的每一个值。然而,如果我们从 A 的域中删除星期一,那么它将具有节点一致性。为了在 B 中实现节点一致性,我们必须从它的域中删除星期一和星期二。
弧一致性
弧一致性是指一个变量的域中的所有值都满足该变量的二元约束(注意我们现在使用“弧”来指代我们之前所说的“边”)。换句话说,为了使 X 相对于 Y 弧一致,从 X 的域中删除元素,直到 X 的每个选择都有一个可能的 Y 的选择。
考虑我们之前的例子,其中领域已进行了修改:A:{周二, 周三} 和 B:{周三}。为了使 A 与 B 弧一致,无论 A 的考试(从其领域)安排在什么日子,B 仍然能够安排考试。A 是否与 B 弧一致?如果 A 取值为周二,那么 B 可以取值为周三。然而,如果 A 取值为周三,那么 B 就没有可以取的值(记住,约束条件之一是 A ≠ B)。因此,A 与 B 不弧一致。为了改变这种情况,我们可以从 A 的领域中删除周三。然后,A 取任何值(周二作为唯一选项)都会为 B 留下一个可以取的值(周三)。现在,A 与 B 弧一致。让我们看看一个伪代码算法,该算法使一个变量相对于另一个变量弧一致(注意,csp 代表“约束满足问题”)。
function Revise(csp, X, Y):
-
revised = false
-
for x in X.domain:
-
if no y in Y.domain satisfies constraint for (X,Y):
-
delete x from X.domain
-
revised = true
-
-
-
return revised
此算法从跟踪 X 的领域是否发生了任何更改开始,使用变量 revised。这将在我们检查的下一个算法中很有用。然后,代码对 X 的领域中的每个值重复执行,并查看 Y 是否有满足约束的值。如果有,则什么都不做,如果没有,则从 X 的领域中删除此值。
通常,我们感兴趣的是使整个问题弧一致,而不仅仅是相对于另一个变量的一个变量。在这种情况下,我们将使用一个名为 AC-3 的算法,该算法使用 Revise:
function AC-3(csp):
-
queue = all arcs in csp
-
while queue non-empty:
-
(X, Y) = Dequeue(queue)
-
if Revise(csp, X, Y):
-
if size of X.domain == 0:
- return false
-
for each Z in X.neighbors - {Y}:
- Enqueue(queue, (Z,X))
-
-
-
return true
此算法将问题中的所有弧添加到队列中。每次考虑一个弧时,它都会将其从队列中删除。然后,它运行 Revise 算法以查看此弧是否一致。如果进行了更改以使其一致,则需要进一步的操作。如果 X 的领域为空,这意味着此约束满足问题是不可解的(因为 X 没有任何可以取的值,这将允许 Y 在给定约束的情况下取任何值)。如果在之前的步骤中认为问题不可解,那么由于 X 的领域已更改,我们需要查看与 X 相关的所有弧是否仍然一致。也就是说,我们取 X 的所有邻居(除了 Y),并将它们之间的弧添加到队列中。然而,如果 Revise 算法返回 false,意味着领域没有更改,我们只需继续考虑其他弧。
虽然弧一致性算法可以简化问题,但它不一定能解决问题,因为它只考虑二元约束,而不是多个节点可能如何相互连接。我们之前的例子,即每个学生选修 3 门课程,在运行 AC-3 后保持不变。
我们在第一节课中遇到了搜索问题。约束满足问题可以看作是一种搜索问题:
-
初始状态:空赋值(所有变量都没有分配任何值)。
-
操作:将一个 {variable = value} 添加到赋值中;即给某个变量赋值。
-
转换模型:显示添加赋值如何改变赋值。这里没有太多深度:转换模型返回最新操作后的包含赋值的状态。
-
目标测试:检查所有变量是否分配了值,以及所有约束是否得到满足。
-
路径成本函数:所有路径都有相同的成本。正如我们之前提到的,与典型的搜索问题不同,优化问题关心的是解决方案,而不是通往解决方案的路线。
然而,将约束满足问题天真地当作常规搜索问题来处理,效率非常低。相反,我们可以利用约束满足问题的结构来更有效地解决问题。
回溯搜索
回溯搜索是一种考虑约束满足搜索问题结构的搜索算法。一般来说,它是一个递归函数,尝试在满足约束的情况下继续分配值。如果违反了约束,它将尝试不同的赋值。让我们看看它的伪代码:
函数 Backtrack(assignment, csp):
-
如果 assignment 完成:
- 返回 assignment
-
var = Select-Unassigned-Var(assignment, csp)
-
对于 value 在 Domain-Values(var, assignment, csp) 中:
-
如果 value 与 assignment 一致:
-
将 {var = value} 添加到 assignment
-
result = Backtrack(assignment, csp)
-
如果 result ≠ failure:
- 返回 result
-
从 assignment 中 remove {var = value}
-
-
-
返回失败
用话来说,这个算法首先检查当前赋值是否完整。这意味着如果算法完成了,它将不会执行任何额外的操作。相反,它将直接返回完成的赋值。如果赋值不完整,算法将选择任何一个尚未赋值的变量。然后,算法尝试给这个变量赋一个值,并在得到的赋值上再次运行回溯算法(递归)。然后,它检查得到的结果。如果结果不是 失败,这意味着赋值成功,应该返回这个赋值。如果结果是 失败,那么将移除最新的赋值,并尝试新的可能值,重复相同的过程。如果域中所有可能的值都返回 失败,这意味着我们需要回溯。也就是说,问题出在某个前一个赋值上。如果这种情况发生在我们开始的变量上,那么这意味着没有解决方案满足约束。
考虑以下行动方案:

我们从空赋值(左上角)开始。然后,我们选择变量 A,并给它赋一个值,比如周一(右上角)。接着,使用这个赋值,我们再次运行算法。现在 A 已经有了赋值,算法将考虑 B,并将周一赋给它(左下角)。这个赋值返回 false,所以算法不会在给定的前一个赋值的基础上给 C 赋值,而是尝试给 B 赋一个新的值,周二(右下角)。这个新的赋值满足约束条件,因此将根据这个赋值考虑下一个变量。例如,如果给 B 赋周二或周三也会导致失败,那么算法将回溯并回到考虑 A,给它赋另一个值,周二。如果周二和周三都返回 失败,那么这意味着我们已经尝试了所有可能的赋值,问题是无解的。
在源代码部分,你可以找到一个从头开始实现的回溯算法。然而,这个算法被广泛使用,因此多个库已经包含了它的实现。
推理
Although backtracking search is more efficient than simple search, it still takes a lot of computational power. Enforcing arc consistency, on the other hand, is less resource intensive. By interleaving backtracking search with inference (enforcing arc consistency), we can get at a more efficient algorithm. This algorithm is called the **Maintaining Arc-Consistency** algorithm. This algorithm will enforce arc-consistency after every new assignment of the backtracking search. Specifically, after we make a new assignment to X, we will call the AC-3 algorithm and start it with a queue of all arcs (*Y,X*) where Y is a neighbor of X (and not a queue of all arcs in the problem). Following is a revised Backtrack algorithm that maintains arc-consistency, with the new additions in **bold**.
function Backtrack(*assignment, csp*):
-
if *assignment* complete:return *assignment*
-
*var* = Select-Unassigned-Var(*assignment, csp*) -
for *value* in Domain-Values(*var, assignment, csp*):-
if *value* consistent with *assignment*:-
将
{*var = value*}添加到*assignment*** -
***inferences* = Inference(*assignment, csp*)** -
if *inferences* ≠ *failure*:add *inferences* to *assignment*
-
*result* = Backtrack(*assignment, csp*) -
if *result* ≠ *failure*:return *result*
-
*remove*{var = value}**and *inferences*** from *assignment*
-
-
-
return failure
The Inference function runs the AC-3 algorithm as described. Its output is all the inferences that can be made through enforcing arc-consistency. Literally, these are the new assignments that can be deduced from the previous assignments and the structure of the constrain satisfaction problem.
There are additional ways to make the algorithm more efficient. So far, we selected an unassigned variable randomly. However, some choices are more likely to bring to a solution faster than others. This requires the use of heuristics. A heuristic is a rule of thumb, meaning that, more often than not, it will bring to a better result than following a naive approach, but it is not guaranteed to do so.
最小剩余值 (MRV) 是 一种 这样的启发式方法。 这里的想法是,如果变量的域被推理所限制,并且现在只剩下一个值(或者甚至两个值),那么通过做出这个赋值,我们将减少以后可能需要回溯的次数。 也就是说,由于它是由强制弧一致性推断出来的,我们迟早需要做出这个赋值。 如果这个赋值导致失败,那么最好尽快发现它,而不是稍后回溯。

For example, after having narrowed down the domains of variables given the current assignment, using the MRV heuristic, we will choose variable C next and assign the value Wednesday to it.
度启发式依赖于变量的度数,其中度数是一个变量与其他变量连接的弧的数量。通过选择具有最高度的变量,通过一次分配,我们可以约束多个其他变量,从而加快算法的进程。

例如,上述所有变量的定义域大小相同。因此,我们应该选择具有最高度的定义域,这将变量 E。
这两种启发式方法并不总是适用。例如,当多个变量的定义域中具有相同的最小值数时,或者当多个变量的度数相同时。
另一种提高算法效率的方法是在从变量的定义域中选择一个值时采用另一种启发式方法。在这里,我们希望使用最小约束值启发式,即选择将最少约束其他变量的值。这里的想法是,虽然在我们使用度启发式时,我们希望使用更有可能约束其他变量的变量,但在这里我们希望这个变量对其他变量的约束最少。也就是说,我们希望找到可能成为最大潜在问题来源(度最高的变量),然后将其变得尽可能不麻烦(给它分配最小约束值)。

例如,让我们考虑变量 C。如果我们将其分配给星期二,我们将对 B、E 和 F 的所有变量施加约束。然而,如果我们选择星期三,我们只会在 B 和 E 上施加约束。因此,选择星期三可能更好。
总结来说,优化问题可以用多种方式来表述。今天我们考虑了局部搜索、线性规划和约束满足。
第四讲
机器学习
机器学习为计算机提供数据,而不是明确的指令。利用这些数据,计算机学会识别模式,并能够自主执行任务。
监督学习
监督学习是一个任务,其中计算机根据输入-输出对的训练集学习一个将输入映射到输出的函数。
监督学习下有多个任务,其中之一是分类。这是一个将输入映射到离散输出的函数的任务。例如,给定某一天湿度和大气的压力信息(输入),计算机决定那天是否会下雨(输出)。计算机在训练集上完成训练后,该训练集包含多天的湿度和大气的压力信息,并已映射到是否下雨。
这个任务可以形式化为以下内容。我们观察自然界,其中函数 f(湿度, 压力) 将输入映射到离散值,要么是雨,要么是无雨。这个函数对我们来说是隐藏的,它可能受到许多其他变量的影响,而我们无法获取这些变量。我们的目标是创建函数 h(湿度, 压力),它可以近似函数 f 的行为。这样的任务可以通过在湿度、降雨(输入)维度上绘制天数来可视化,如果那天下雨,则将每个数据点着色为蓝色,如果没有下雨,则着色为红色(输出)。白色数据点只有输入,计算机需要确定输出。

最近邻分类
解决上述任务的一种方法是将相关变量分配给最近的观察点的值。例如,图上方的白色点应该着色为蓝色,因为最近的观察点也是蓝色。这可能在某些时候工作得很好,但考虑下面的图。

按照同样的策略,白色点应该着色为红色,因为最近的观察点也是红色。然而,从更大的角度来看,它看起来周围的大多数其他观察点都是蓝色,这可能会给我们这样的直觉:在这种情况下,蓝色是一个更好的预测,尽管最近的观察点是红色。
一种克服最近邻分类局限性的方法是通过使用k-最近邻分类,其中点根据最近的 k 个邻居中最频繁的颜色着色。程序员需要决定 k 的值。例如,使用 3-最近邻分类,上面的白色点将被着色为蓝色,这直观上看起来是一个更好的决定。
k 最近邻分类的一个缺点是,使用原始方法,算法将不得不测量每个单独的点与问题点的距离,这在计算上很昂贵。这可以通过使用能够更快找到邻居的数据结构或通过剪枝无关观察结果来加速。
感知器学习
与最近邻策略相比,另一种处理分类问题的方式是将数据视为整体,并尝试创建一个决策边界。在二维数据中,我们可以在两种观察结果之间画一条线。每个额外的数据点都将根据其绘制在直线哪一侧进行分类。

这种方法的缺点是数据很杂乱,很少能画一条线,将类别干净利落地分成两个观察结果而没有错误。通常,我们会妥协,画出的边界大多数情况下能正确地分隔观察结果,但偶尔还是会错误分类。
在这种情况下,输入为
-
x₁ = 湿度
-
x₂ = 压力
将被提供给一个假设函数 h(x₁, x₂),该函数将输出它对当天是否会下雨的预测。它将通过检查观察结果落在决策边界的哪一侧来完成。形式上,该函数将每个输入乘以一个常数的和,最终得到以下形式的线性方程:
-
Rain w₀ + w₁x₁ + w₂x₂ ≥ 0
-
无雨否则
通常,输出变量将被编码为 1 和 0,其中如果方程结果大于 0,输出为 1(雨),否则为 0(无雨)。
权重和值由向量表示,这些是数字序列(在 Python 中可以存储在列表或元组中)。我们产生一个权重向量 w: (w₀, w₁, w₂),得到最佳权重向量是机器学习算法的目标。我们还产生一个输入向量 x: (1, x₁, x₂)。
我们计算两个向量的点积。也就是说,我们将一个向量中的每个值乘以第二个向量中相应的值,得到上面的表达式:w₀ + w₁x₁ + w₂x₂。输入向量中的第一个值是 1,因为当我们将其与权重向量 w₀ 相乘时,我们希望将其保持为常数。
因此,我们可以用以下方式表示我们的假设函数:

由于算法的目标是找到最佳权重向量,当算法遇到新数据时,它会更新当前权重。它是通过使用 感知器学习规则 来做到这一点的:

从这条规则中重要的收获是,对于每个数据点,我们调整权重以使我们的函数更准确。细节,虽然对我们论点不是那么关键,是每个权重都被设置为等于它自己加上括号中的某个值。在这里,y 代表观察到的值,而假设函数代表估计。如果它们相同,这个整个项就等于零,因此权重不会改变。如果我们低估了(在观察到雨时称之为“无雨”),那么括号中的值将是 1,权重将增加由 xᵢ缩放的学习系数α的值。如果我们高估了(在观察到无雨时称之为“雨”),那么括号中的值将是-1,权重将减少由 x 缩放的学习系数α的值。α越高,每个新事件对权重的影响就越强。
这个过程的成果是一个阈值函数,一旦估计值超过某个阈值,就会从 0 切换到 1。

这种类型函数的问题在于它无法表达不确定性,因为它只能等于 0 或 1。它采用硬阈值。一种绕过这个问题的方法是使用对数函数,它采用软阈值。对数函数可以产生一个介于 0 和 1 之间的实数,这将表达对估计的信心。值越接近 1,下雨的可能性就越大。

支持向量机
除了最近邻和线性回归之外,分类的另一种方法是支持向量机。这种方法使用决策边界附近的一个附加向量(支持向量)来在分离数据时做出最佳决策。考虑下面的例子。

所有的决策边界都在于它们在没有任何错误的情况下分离数据。然而,它们是否同样好?最左边的两个决策边界与一些观察值非常接近。这意味着一个只与一个组略有不同的新数据点可能会被错误地分类为另一组。相反,最右边的决策边界与每个组保持最大的距离,从而为它内部的变异提供了最大的灵活性。这种尽可能远离它所分离的两个组的边界,被称为最大间隔分离器。
支持向量机的另一个好处是,它们可以表示超过两个维度的决策边界,以及非线性决策边界,如下所示。

总结来说,处理分类问题有多种方法,没有哪一种方法总是比其他方法更好。每种方法都有其缺点,可能在某些特定情况下比其他方法更有用。
回归
回归是一个监督学习任务,它将一个输入点映射到一个连续值,即某个实数。这与分类不同,因为分类问题将输入映射到离散值(例如,雨天或无雨)。
例如,一家公司可能会使用回归来回答广告支出如何预测销售收入的疑问。在这种情况下,一个观测函数 f(广告) 代表在广告上花费一些钱之后的观测收入(注意该函数可以接受多个输入变量)。这些是我们开始时的数据。有了这些数据,我们希望提出一个假设函数 h(广告),该函数将尝试近似函数 f 的行为。h 将生成一条线,其目标不是区分观察类型,而是根据输入预测输出值。

损失函数
损失函数是量化上述任何决策规则所损失效用的一种方法。预测越不准确,损失就越大。
对于分类问题,我们可以使用 0-1 损失函数。
-
L(实际,预测):
-
0 如果实际值等于预测值
-
1 否则
-
用话来说,这个函数在预测不正确时增加价值,而在预测正确时不增加价值(即当观测值和预测值匹配时)。

在上面的例子中,值为 0 的天数是我们正确预测天气的日子(雨天在线下方,非雨天在上方线)。然而,线下方没有下雨而上方线下雨的日子是我们未能预测到的。我们给每个这样的日子赋予值为 1,并将它们加起来以得到一个经验估计,即我们的决策边界有多大的损失。
L₁ 和 L₂ 损失函数可以用于预测连续值。在这种情况下,我们感兴趣的是量化每个预测与观测值差异的程度。我们通过取观测值减去预测值的绝对值或平方值(即预测值与观测值之间的距离)来实现这一点。
-
L₁: L(实际,预测) = |实际 - 预测|
-
L₂: L(实际,预测) = (实际 - 预测)²
可以选择最适合自己目标的损失函数。L₂ 比起 L₁ 更严厉地惩罚异常值,因为它平方了差异。L₁ 可以通过将每个观测点到回归线上的预测点的距离求和来可视化:

过拟合
过拟合是指模型对训练数据拟合得如此之好,以至于它无法泛化到其他数据集。从这个意义上说,损失函数是一把双刃剑。在下面的两个例子中,损失函数被最小化,使得损失等于 0。然而,它不太可能很好地拟合新数据。

例如,在左边的图中,屏幕底部红色点旁边的点很可能是雨(蓝色)。然而,在过拟合的模型中,它将被分类为无雨(红色)。
正则化
正则化是惩罚更复杂假设的过程,以有利于更简单、更一般的假设。我们使用正则化来避免过拟合。
在正则化中,我们通过将假设函数 h 的损失和其复杂度的度量相加来估计 h 的成本。
成本(h) = 损失(h) + λ复杂度(h)
Lambda (λ) 是一个常数,我们可以用它来调节我们在成本函数中对复杂性的惩罚强度。λ越高,复杂性的成本就越高。
测试我们是否过拟合了模型的一种方法是用保留交叉验证。在这个技术中,我们将所有数据分成两部分:一个训练集和一个测试集。我们在训练集上运行学习算法,然后看看它预测测试集中数据的准确性。这里的想法是通过在未用于训练的数据上测试,我们可以衡量学习泛化的程度。
保留交叉验证的缺点是我们无法在半数数据上训练模型,因为它被用于评估目的。解决这个问题的方法是用k-折交叉验证。在这个过程中,我们将数据分成 k 个集合。我们运行训练 k 次,每次留出一个数据集作为测试集。我们最终得到 k 个不同的模型评估,我们可以对这些评估进行平均,从而得到模型泛化的估计,而不会丢失任何数据。
scikit-learn
与 Python 一样,有许多库允许我们方便地使用机器学习算法。其中之一是 scikit-learn。
作为例子,我们将使用一个CSV数据集的假币。

四个左侧列是我们可以用来预测纸币是真还是假的数据,这是由人类提供的外部数据,编码为 0 和 1。现在我们可以在这个数据集上训练我们的模型,看看我们能否预测新纸币是否为真。
import csv
import random
from sklearn import svm
from sklearn.linear_model import Perceptron
from sklearn.naive_bayes import GaussianNB
from sklearn.neighbors import KNeighborsClassifier
# model = KNeighborsClassifier(n_neighbors=1)
# model = svm.SVC() model = Perceptron()
注意,在导入库之后,我们可以选择使用哪个模型。其余的代码将保持不变。SVC 代表支持向量分类器(我们称之为支持向量机)。KNeighborsClassifier 使用 k-邻居策略,并需要输入它应该考虑的邻居数量。
# Read data in from file with open("banknotes.csv") as f:
reader = csv.reader(f)
next(reader)
data = []
for row in reader:
data.append({
"evidence": [float(cell) for cell in row[:4]],
"label": "Authentic" if row[4] == "0" else "Counterfeit"
})
# Separate data into training and testing groups holdout = int(0.40 * len(data))
random.shuffle(data)
testing = data[:holdout]
training = data[holdout:]
# Train model on training set X_training = [row["evidence"] for row in training]
y_training = [row["label"] for row in training]
model.fit(X_training, y_training)
# Make predictions on the testing set X_testing = [row["evidence"] for row in testing]
y_testing = [row["label"] for row in testing]
predictions = model.predict(X_testing)
# Compute how well we performed correct = 0
incorrect = 0
total = 0
for actual, predicted in zip(y_testing, predictions):
total += 1
if actual == predicted:
correct += 1
else:
incorrect += 1
# Print results print(f"Results for model {type(model).__name__}")
print(f"Correct: {correct}")
print(f"Incorrect: {incorrect}")
print(f"Accuracy: {100 * correct / total:.2f}%")
此算法的手动版本可以在本讲座的源代码中的 banknotes0.py 文件中找到。由于算法经常以类似的方式使用,scikit-learn 包含了额外的函数,使代码更加简洁且易于使用,这个版本可以在 banknotes1.py 文件中找到。
状态集合 S
Q 学习
强化学习
强化学习可以被视为一个马尔可夫决策过程,具有以下特性:

动作集合 Actions(S)
马尔可夫决策过程
-
这种类型的算法可以用来训练行走机器人,例如,每一步都会返回一个正数(奖励)和每次跌倒都会返回一个负数(惩罚)。
-
奖励函数 R(s, a, s’)
-
转移模型 P(s’ | s, a)
-
Q 学习是强化学习的一种模型,其中函数 Q(s, a) 输出在状态 s 采取动作 a 的价值估计。
例如,考虑以下任务:
强化学习是机器学习的另一种方法,在每次动作之后,代理都会以奖励或惩罚(正或负的数值)的形式获得反馈。
代理是黄色圆圈,它需要到达绿色方块,同时避开红色方块。任务中的每一个方块都是一个状态。向上、向下或向侧面移动是一个动作。转移模型给出了执行动作后的新状态,奖励函数是代理获得的反馈类型。例如,如果代理选择向右移动,它将踩到红色方块并得到负面反馈。这意味着代理将学会,当处于左下角方块的状态时,应该避免向右移动。这样,代理将开始探索空间,学习哪些状态-动作对应该避免。该算法可以是概率性的,根据奖励的增加或减少,在不同状态下选择不同的动作。当代理到达绿色方块时,它将获得正面奖励,学习到在之前的状态采取的动作是有利的。
学习过程从环境向代理提供一个状态开始。然后,代理在状态上执行一个动作。基于这个动作,环境将返回一个状态和一个奖励给代理,其中奖励可以是正的,使行为在未来更有可能发生,或者负的(即惩罚),使行为在未来不太可能发生。

模型开始时所有估计的值都等于 0(对于所有 s, a,Q(s, a) = 0)。当采取一个动作并收到奖励时,函数做两件事:1)根据当前奖励和预期未来奖励估计 Q(s, a) 的值,2)更新 Q(s, a) 以考虑旧估计和新估计。这给我们提供了一个算法,它能够在不从头开始的情况下改进其过去的知识。
Q(s, a) ⟵ Q(s, a) + α(新值估计 - Q(s, a))
更新后的 Q(s, a) 的值等于 Q(s, a) 的先前值加上一些更新值。这个值被确定为新值与旧值之间的差异,乘以学习系数 α。当 α = 1 时,新估计简单地覆盖旧值。当 α = 0 时,估计值永远不会更新。通过提高和降低 α,我们可以确定旧知识通过新估计更新的速度。
新的价值估计可以表示为奖励(r)和未来奖励估计的总和。为了得到未来奖励估计,我们考虑在执行最后一个动作后得到的新状态,并加上在这个新状态下执行的动作的估计,该动作将带来最高的奖励。这样,我们不仅通过接收到的奖励来估计在状态 s 中执行动作 a 的效用,还通过下一步的预期效用来估计。未来奖励估计的值有时会与一个系数伽马(gamma)相关,该系数控制未来奖励的价值。最终我们得到以下方程:

贪婪决策算法完全忽略了未来估计的奖励,总是选择当前状态 s 中具有最高 Q(s, a) 的动作 a。
这引出了探索与利用的权衡。贪婪算法总是利用,采取已经确立的行动以带来好的结果。然而,它总是遵循相同的路径到解决方案,永远不会找到更好的路径。另一方面,探索意味着算法可能在通往目标的过程中使用之前未探索的路线,从而允许它沿途发现更有效的解决方案。例如,如果你每次都听相同的歌曲,你知道你会喜欢它们,但你永远不会了解你可能更喜欢的新歌曲!
为了实现探索和利用的概念,我们可以使用ε(epsilon)贪婪算法。在这种类型的算法中,我们将 ε 设置为我们想要随机移动的频率。以 1-ε 的概率,算法选择最佳移动(利用)。以 ε 的概率,算法选择一个随机移动(探索)。
训练强化学习模型的另一种方法是,不是对每个移动给出反馈,而是在整个过程的结束时给出反馈。例如,考虑一个 Nim 游戏的例子。在这个游戏中,不同数量的物体分布在不同的堆中。每个玩家可以从任何单个堆中取走任意数量的物体,取走最后一个物体的玩家输。在这样的游戏中,未经训练的 AI 会随机地玩,很容易战胜它。为了训练 AI,它将从随机玩游戏开始,并在最后获得 1 分的奖励(胜利)和-1 分的奖励(失败)。例如,当它在 10,000 场比赛中训练后,它已经足够聪明,难以战胜。
当一个游戏有多个状态和可能的行为,例如象棋时,这种方法在计算上变得更加复杂。在所有可能的状态中为每个可能的移动生成一个估计值是不切实际的。在这种情况下,我们可以使用函数逼近,这允许我们使用各种其他特征来逼近Q(s, a),而不是为每个状态-动作对存储一个值。因此,算法能够识别出哪些移动足够相似,以至于它们的估计值也应该相似,并在其决策中使用这种启发式方法。
无监督学习
在我们之前看到的所有情况下,就像在监督学习中一样,我们都有算法可以从中学习的带标签的数据。例如,当我们训练一个算法来识别假币时,每张纸币都有四个不同值的变量(输入数据)以及它是否是假币(标签)。在无监督学习中,只有输入数据存在,AI 从这些数据中学习模式。
聚类
聚类是一种无监督学习任务,它将输入数据组织成组,使得相似的对象最终落在同一个组中。例如,在遗传学研究,当试图找到相似基因时,或者在图像分割中,根据像素之间的相似性定义图像的不同部分时,都可以使用这种方法。
k-means 聚类
k-means 聚类是一种执行聚类任务的算法。它将空间中的所有数据点映射出来,然后在空间中随机放置 k 个聚类中心(由程序员决定数量;这是我们在左侧看到的起始状态)。每个聚类中心只是空间中的一个点。然后,每个聚类被分配所有比其他中心更接近其中心的点(这是中间的图片)。然后,在迭代过程中,聚类中心移动到所有这些点的中间(右侧的状态),然后点再次重新分配到中心现在最近的聚类。当重复这个过程后,每个点仍然保持在它之前所在的同一个聚类中,我们就达到了平衡,算法结束,我们得到了在聚类之间划分的点。

第五讲
神经网络
人工智能神经网络受到神经科学的启发。在大脑中,神经元是相互连接的细胞,形成网络。每个神经元都能够接收和发送电信号。一旦一个神经元接收到的电输入超过某个阈值,该神经元就会被激活,从而发送其电信号。
人工神经网络是一种受生物神经网络启发的学习数学模型。人工神经网络通过网络的结构和参数来模拟将输入映射到输出的数学函数。在人工神经网络中,网络的结构是通过在数据上训练来塑造的。
当在人工智能中实现时,每个神经元的并行单元是连接到其他单元的单元。例如,就像在上一次讲座中提到的,人工智能可能会将两个输入 x₁ 和 x₂ 映射到今天是否会下雨。在上一次讲座中,我们提出了以下假设函数的形式:h(x₁, x₂) = w₀ + w₁x₁ + w₂x₂,其中 w₁ 和 w₂ 是修改输入的权重,w₀ 是一个常数,也称为偏差,用于修改整个表达式的值。
激活函数
要使用假设函数来决定是否下雨,我们需要根据其产生的值创建某种类型的阈值。
实现这一点的其中一种方式是使用阶跃函数,它在达到某个阈值之前输出 0,在达到阈值之后输出 1。

另一种方法是使用对数函数,它输出从 0 到 1 的任何实数,从而表达其判断的分级信心。

另一种可能的函数是修正线性单元(ReLU),它允许输出为任何正数值。如果值为负,ReLU 将其设置为 0。

无论我们选择使用哪个函数,我们在上一次讲座中学到的是,输入除了偏差外还会通过权重进行修改,这些修改的总和传递给激活函数。这对于简单的神经网络来说也是成立的。
神经网络结构
可以将神经网络视为上述想法的一种表示,其中函数将输入求和以产生输出。

左侧的两个白色单元是输入单元,右侧的单元是输出单元。输入单元通过加权边连接到输出单元。为了做出决定,输出单元将输入乘以其权重(除了偏差 w₀)并使用函数 g 来确定输出。
例如,一个或逻辑连接可以表示为一个具有以下真值表的函数 f:
| x | y | f(x, y) |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 1 |
我们可以将这个函数可视化为一个神经网络。x₁ 是一个输入单元,x₂ 是另一个输入单元。它们通过一个权重为 1 的边连接到输出单元。输出单元然后使用函数 g(-1 + 1x₁ + 2x₂) 并以 0 为阈值来输出 0 或 1(假或真)。

例如,在 x₁ = x₂ = 0 的情况下,总和是 (-1)。这低于阈值,所以函数 g 将输出 0。然而,如果 x₁ 或 x₂ 中的任何一个或两个等于 1,那么所有输入的总和将是 0 或 1。两者都在或高于阈值,所以函数将输出 1。
可以用类似的过程重复使用与函数(其中偏差将是(-2))。此外,输入和输出不必是不同的。可以使用类似的过程将湿度和气压作为输入,并输出降雨的概率。或者,在另一个例子中,输入可以是广告支出和支出的月份,以获得销售预期收入的输出。这可以通过将每个输入 x₁ … xₙ 乘以权重 w₁ … wₙ,求和得到的值,并添加偏差 w₀ 来扩展到任意数量的输入。
梯度下降
梯度下降是一种在训练神经网络时最小化损失的计算算法。正如之前提到的,神经网络能够从数据中推断出关于自身结构的知识。而到目前为止,我们定义了不同的权重,神经网络允许我们根据训练数据来计算这些权重。为此,我们使用梯度下降算法,其工作原理如下:
-
从一个随机的权重选择开始。这是我们天真的起点,我们不知道应该给每个输入多少权重。
-
重复:
-
根据所有会导致损失减少的数据点计算梯度。最终,梯度是一个向量(一系列数字)。
-
根据梯度更新权重。
-
这种算法的问题在于它需要根据 所有数据点 计算梯度,这在计算上代价高昂。有多种方法可以最小化这种成本。例如,在 随机梯度下降 中,梯度是基于随机选择的一个点计算的。这种梯度可能相当不准确,导致 小批量梯度下降 算法,它基于随机选择的几个点计算梯度,从而在计算成本和准确性之间找到一个折衷。正如通常情况下,没有哪种解决方案是完美的,不同的解决方案可能在不同的情境中被采用。
使用梯度下降,可以找到许多问题的答案。例如,我们可能想知道的不仅仅是“今天会下雨吗?”我们可以使用一些输入来生成不同天气类型的概率,然后只需选择最可能的天气。

这可以用于任意数量的输入和输出,其中每个输入都连接到每个输出,并且输出代表我们可以做出的决策。请注意,在这种类型的神经网络中,输出之间没有连接。这意味着每个输出及其从所有输入关联的权重可以被视为一个独立的神经网络,因此可以单独从其他输出中训练。
到目前为止,我们的神经网络依赖于感知器输出单元。这些单元只能学习线性决策边界,使用直线来分离数据。也就是说,基于线性方程,感知器可以将输入分类为一种类型或另一种类型(例如,左图)。然而,数据往往不是线性可分的(例如,右图)。在这种情况下,我们转向多层神经网络来非线性地建模数据。

多层神经网络
多层神经网络是一种具有输入层、输出层和至少一个隐藏层的人工神经网络。虽然我们提供输入和输出以训练模型,但我们人类不向隐藏层中的单元提供任何值。第一隐藏层中的每个单元从输入层中的每个单元接收加权值,对其进行一些操作并输出一个值。这些值被加权并进一步传播到下一层,重复此过程直到达到输出层。通过隐藏层,可以建模非线性数据。

反向传播
反向传播是用于训练具有隐藏层的神经网络的主要算法。它通过从输出单元的误差开始,计算前一层权重的梯度下降,并重复此过程直到达到输入层来实现。在伪代码中,我们可以将算法描述如下:
-
计算输出层的误差
-
对于每一层,从输出层开始,向内移动到最早的隐藏层:
-
将误差反向传播一层。换句话说,当前正在考虑的层将误差发送到前一层。
-
更新权重。
-
这可以扩展到任意数量的隐藏层,创建深度神经网络,这些神经网络具有多个隐藏层。

过度拟合
过拟合是指对训练数据建模过于紧密,因此无法推广到新数据的风险。对抗过拟合的一种方法是通过dropout。在这种技术中,我们在学习阶段随机选择并暂时移除一些单元。这样,我们试图防止网络对任何单个单元过度依赖。在整个训练过程中,神经网络将采取不同的形式,每次丢弃一些单元然后再使用它们:

注意,训练完成后,整个神经网络将再次使用。
TensorFlow
就像在 Python 中经常发生的那样,多个库已经实现了使用反向传播算法的神经网络,TensorFlow 就是这样的库之一。您可以在这个 web 应用程序 中尝试 TensorFlow 神经网络,它允许您定义网络的不同属性并运行它,可视化输出。现在,我们将转向一个例子,说明我们如何使用 TensorFlow 来执行上次讲座中讨论的任务:区分假币和真币。
import csv
import tensorflow as tf
from sklearn.model_selection import train_test_split
我们导入 TensorFlow 并将其命名为 tf(以缩短代码)。
# Read data in from file with open("banknotes.csv") as f:
reader = csv.reader(f)
next(reader)
data = []
for row in reader:
data.append({
"evidence": [float(cell) for cell in row[:4]],
"label": 1 if row[4] == "0" else 0
})
# Separate data into training and testing groups evidence = [row["evidence"] for row in data]
labels = [row["label"] for row in data]
X_training, X_testing, y_training, y_testing = train_test_split(
evidence, labels, test_size=0.4
)
我们将 CSV 数据提供给模型。我们的工作通常需要使数据符合库所需的格式。实际上编码模型的困难部分已经为我们实现了。
# Create a neural network model = tf.keras.models.Sequential()
Keras 是一个 API,不同的机器学习算法可以通过它访问。一个顺序模型是指层依次排列(就像我们之前看到的那样)。
# Add a hidden layer with 8 units, with ReLU activation model.add(tf.keras.layers.Dense(8, input_shape=(4,), activation="relu"))
密集层是指当前层中的每个节点都连接到前一层的所有节点。在生成我们的隐藏层时,我们创建了 8 个密集层,每个层有 4 个输入神经元,使用上面提到的 ReLU 激活函数。
# Add output layer with 1 unit, with sigmoid activation model.add(tf.keras.layers.Dense(1, activation="sigmoid"))
在我们的输出层,我们希望创建一个使用 sigmoid 激活函数的密集层,这种激活函数的输出值介于 0 和 1 之间。
# Train neural network model.compile(
optimizer="adam",
loss="binary_crossentropy",
metrics=["accuracy"]
)
model.fit(X_training, y_training, epochs=20)
# Evaluate how well model performs model.evaluate(X_testing, y_testing, verbose=2)
最后,我们编译模型,指定哪个算法应该优化它,我们使用哪种类型的损失函数,以及我们如何衡量其成功(在我们的情况下,我们关注输出的准确性)。最后,我们使用 20 次重复(周期)将模型拟合到训练数据,然后在测试数据上评估它。
计算机视觉
计算机视觉包括分析和理解数字图像的不同计算方法,通常使用神经网络实现。例如,当社交媒体使用面部识别自动标记图片中的人时,就会用到计算机视觉。其他例子包括手写识别和自动驾驶汽车。
图像由像素组成,像素由三个范围从 0 到 255 的值表示,一个用于红色,一个用于绿色,一个用于蓝色。这些值通常用缩写 RGB 来表示。我们可以使用这一点来创建一个神经网络,其中每个像素中的颜色值都是一个输入,我们有一些隐藏层,输出是一些单位数,告诉我们图像中展示了什么。然而,这种方法有几个缺点。首先,通过将图像分解成像素及其颜色值,我们无法使用图像的结构作为辅助。也就是说,作为人类,如果我们看到脸部的一部分,我们知道应该期待看到脸的其余部分,这可以加快计算。我们希望能够在我们的神经网络中利用类似的优势。其次,输入的数量非常大,这意味着我们不得不计算很多权重。
图像卷积
图像卷积是将一个滤波器应用于图像的每个像素值,将其与邻居的像素值相加,并根据内核矩阵进行加权。这样做会改变图像,并有助于神经网络处理它。
让我们考虑以下示例:

内核是蓝色的矩阵,图像是左侧的大矩阵。生成的过滤图像是右下角的小矩阵。要使用内核过滤图像,我们从图像左上角的值为 20 的像素(坐标 1,1)开始。然后,我们将它周围的所有值乘以内核中的相应值并将它们相加(100 + 20(-1) + 300 + 10(-1) + 205 + 30(-1) + 200 + 30(-1) + 40*0),得到值 10。然后我们将对右侧的像素(30)、第一个像素下面的像素(30)以及这个像素右侧的像素(40)做同样的处理。这产生了一个具有我们在右下角看到的值的过滤图像。
不同的内核可以完成不同的任务。对于边缘检测,以下内核经常被使用:

这里的想法是,当像素与其所有邻居相似时,它们应该相互抵消,得到值为 0。因此,像素越相似,图像的部分就越暗,它们越不同,就越亮。将此内核应用于图像(左侧)会产生具有明显边缘的图像(右侧):

让我们考虑图像卷积的一个实现。我们使用的是 PIL 库(代表 Python Imaging Library),它可以为我们完成大部分繁重的工作。
import math
import sys
from PIL import Image, ImageFilter
# Ensure correct usage if len(sys.argv) != 2:
sys.exit("Usage: python filter.py filename")
# Open image image = Image.open(sys.argv[1]).convert("RGB")
# Filter image according to edge detection kernel filtered = image.filter(ImageFilter.Kernel(
size=(3, 3),
kernel=[-1, -1, -1, -1, 8, -1, -1, -1, -1],
scale=1
))
# Show resulting image filtered.show()
尽管如此,由于作为神经网络输入的像素数量众多,处理图像在神经网络中是计算密集型的。另一种方法是池化,通过从输入区域中采样来减少输入的尺寸。相邻的像素属于图像中的同一区域,这意味着它们很可能是相似的。因此,我们可以用一个像素来代表整个区域。一种实现方式是最大池化,其中选定的像素是该区域内所有其他像素中值最高的一个。例如,如果我们把下面的左方形(下方)分成四个 2X2 的小方形,通过从这个输入进行最大池化,我们得到右边的那个小方形。

卷积神经网络
卷积神经网络是一种使用卷积的神经网络,通常用于分析图像。它首先应用过滤器,使用不同的核来帮助提取图像的一些特征。这些过滤器可以通过调整它们的核来改进,就像神经网络中的其他权重一样,基于输出的错误进行调整。然后,得到的图像被池化,之后像素被作为输入(称为展平)馈送到传统的神经网络。

卷积和池化步骤可以重复多次,以提取额外的特征并减少输入到神经网络的尺寸。这些过程的一个好处是,通过卷积和池化,神经网络对变化的敏感性降低。也就是说,如果从略微不同的角度拍摄相同的图片,卷积神经网络的输入将相似,而如果没有卷积和池化,每张图片的输入将大相径庭。
在代码中,卷积神经网络与传统神经网络差别不大。TensorFlow 提供了测试我们模型的数据库。我们将使用 MNIST,它包含黑白手写数字的图片。我们将训练我们的卷积神经网络来识别数字。
import sys
import tensorflow as tf
# Use MNIST handwriting dataset mnist = tf.keras.datasets.mnist
# Prepare data for training (x_train, y_train), (x_test, y_test) = mnist.load_data()
x_train, x_test = x_train / 255.0, x_test / 255.0
y_train = tf.keras.utils.to_categorical(y_train)
y_test = tf.keras.utils.to_categorical(y_test)
x_train = x_train.reshape(
x_train.shape[0], x_train.shape[1], x_train.shape[2], 1
)
x_test = x_test.reshape(
x_test.shape[0], x_test.shape[1], x_test.shape[2], 1
)
# Create a convolutional neural network model = tf.keras.models.Sequential([
# Convolutional layer. Learn 32 filters using a 3x3 kernel
tf.keras.layers.Conv2D(
32, (3, 3), activation="relu", input_shape=(28, 28, 1)
),
# Max-pooling layer, using 2x2 pool size
tf.keras.layers.MaxPooling2D(pool_size=(2, 2)),
# Flatten units
tf.keras.layers.Flatten(),
# Add a hidden layer with dropout
tf.keras.layers.Dense(128, activation="relu"),
tf.keras.layers.Dropout(0.5),
# Add an output layer with output units for all 10 digits
tf.keras.layers.Dense(10, activation="softmax")
])
# Train neural network model.compile(
optimizer="adam",
loss="categorical_crossentropy",
metrics=["accuracy"]
)
model.fit(x_train, y_train, epochs=10)
# Evaluate neural network performance model.evaluate(x_test, y_test, verbose=2)
由于模型需要时间来训练,我们可以保存已经训练好的模型以供以后使用。
# Save model to file if len(sys.argv) == 2:
filename = sys.argv[1]
model.save(filename)
print(f"Model saved to {filename}.")
现在,如果我们运行一个接收手绘数字作为输入的程序,它将能够使用该模型对数字进行分类并输出结果。有关此类程序的实现,请参阅本讲座源代码中的 recognition.py。
循环神经网络
前馈神经网络是我们迄今为止讨论过的神经网络类型,其中输入数据被提供给网络,最终产生一些输出。下面可以看到前馈神经网络的工作原理图。

与此相反,循环神经网络由一个非线性结构组成,其中网络使用其自身的输出作为输入。例如,微软的captionbot能够用句子中的词语描述图像的内容。这与分类不同,因为输出可以根据图像的特性具有不同的长度。虽然前馈神经网络无法改变输出的数量,但循环神经网络由于其结构,能够做到这一点。在字幕任务中,网络会处理输入以产生输出,然后从这个点继续处理,产生另一个输出,并重复必要的次数。

循环神经网络在处理序列而不是单个对象的情况下非常有用。上面提到的神经网络需要生成一系列词语。然而,同样的原理也可以应用于分析视频文件,这些文件由一系列图像组成,或者在翻译任务中,处理一系列输入(源语言中的词语)以产生一系列输出(目标语言中的词语)。
第六讲
这些笔记反映了 2023 年 8 月 14 日发布的第六讲的新版本。如果您观看了之前的版本,并希望查看其笔记,请点击此处。
语言
到目前为止,在课程中,我们需要塑造任务和数据,以便 AI 能够处理它们。今天,我们将探讨如何构建 AI 以处理人类语言。
自然语言处理涵盖了所有 AI 获取人类语言作为输入的任务。以下是一些此类任务的例子:
-
自动摘要,其中 AI 被给出文本作为输入,并产生文本的摘要作为输出。
-
信息提取,其中 AI 被给出文本语料库,并从中提取数据作为输出。
-
语言识别,其中 AI 被给出文本并返回文本的语言作为输出。
-
机器翻译,其中 AI 被给出原始语言的文本,并输出目标语言的翻译。
-
命名实体识别,其中 AI 被给出文本,并从中提取文本中的实体名称(例如,公司名称)。
-
语音识别,其中 AI 被给出语音,并产生相同的文本。
-
文本分类,其中 AI 被给出文本,并需要将其分类为某种类型的文本。
-
词义消歧,其中 AI 需要选择具有多个意义的单词的正确含义(例如,银行既指金融机构也指河流的河岸)。
语法和语义
语法是句子结构。作为某些人类语言的母语者,我们不会在产生语法正确的句子和标记非语法正确的句子为错误时感到困难。例如,句子“在九点之前,福尔摩斯敏捷地走进了房间”是语法正确的,而句子“在福尔摩斯九点之前敏捷地走进了房间”则是非语法正确的。语法可以同时是语法正确的和模糊的,例如,“我看到了拿着望远镜的男人。”我是看到了(拿着望远镜的男人)还是我看到了(男人),通过望远镜看到了?为了能够解析人类语言并产生它,AI 需要掌握语法。
语义是单词或句子的意义。虽然句子“在九点之前,福尔摩斯敏捷地走进了房间”在语法上与“福尔摩斯敏捷地走进了房间,就在九点之前”不同,但它们的内容实际上是相同的。同样,尽管句子“A few minutes before nine, Sherlock Holmes walked quickly into the room”使用了与前句不同的单词,但它仍然传达了非常相似的意义。此外,一个句子可以完全语法正确,但完全无意义,如乔姆斯基的例子,“无色的绿色想法疯狂地睡觉。”为了能够解析人类语言并产生它,AI 需要掌握语义。
上下文无关语法
形式语法是一种用于生成语言中句子的规则系统。在上下文无关语法中,文本从其意义中抽象出来,使用形式语法来表示句子的结构。让我们考虑以下示例句子:
- 她看到了这个城市。
这是一个简单的语法句子,我们希望生成一个表示其结构的语法树。
我们首先为每个单词分配其词性。她和城市是名词,我们将它们标记为 N。看到是动词,我们将它标记为 V。这个是限定词,标记后面的名词是确定的还是不确定的,我们将它标记为 D。现在,上述句子可以重写为
- N V D N
到目前为止,我们已经将每个单词从其语义意义抽象到其词性。然而,句子中的单词相互连接,要理解句子,我们必须了解它们是如何连接的。名词短语(NP)是一组与名词连接的单词。例如,单词她是这个句子中的名词短语。此外,单词这个城市也形成一个名词短语,由一个限定词和一个名词组成。动词短语(VP)是一组与动词连接的单词。单词看到本身就是一个动词短语。然而,单词看到这个城市也构成一个动词短语。在这种情况下,它是一个由动词和名词短语组成的动词短语,而名词短语又由一个限定词和一个名词组成。最后,整个句子(S)可以表示如下:

使用形式语法,人工智能能够表示句子的结构。在我们描述的语法中,有足够的规则来表示上述简单句子。要表示更复杂的句子,我们不得不向我们的形式语法中添加更多规则。
nltk
在 Python 中,通常会有多个库被编写来实现上述想法。nltk(自然语言工具包)就是这样一个库。为了分析上述句子,我们将为语法提供算法规则:
import nltk
grammar = nltk.CFG.fromstring(""" S -> NP VP
NP -> D N | N
VP -> V | V NP
D -> "the" | "a" N -> "she" | "city" | "car" V -> "saw" | "walked" """)
parser = nltk.ChartParser(grammar)
与我们上面所做的一样,我们定义了可能包含在其他中的可能组件。一个句子可以包含一个名词短语和一个动词短语,而短语本身可以由其他短语、名词、动词等组成,最终,每个词性在语言中跨越一些单词。
sentence = input("Sentence: ").split()
try:
for tree in parser.parse(sentence):
tree.pretty_print()
tree.draw()
except ValueError:
print("No parse tree possible.")
在向算法提供一个输入句子并将其拆分为单词列表后,函数将打印出结果语法树(pretty_print)并生成图形表示(draw)。

n-grams
n-元组是从文本样本中提取的n个项目的序列。在字符n-元组中,项目是字符,而在单词n-元组中,项目是单词。单元组、二元组和三元组分别是一、两个和三个项目的序列。在以下句子中,前三个n-元组是“how often have”、“often have I”和“have I said”。
“我曾经说过多少次,当你排除了不可能的,无论多么不可能,剩下的就一定是真相?”
n-元组在文本处理中很有用。尽管 AI 之前不一定看到过整个句子,但它肯定看到过句子的一部分,比如“我曾经说过。”由于一些词比其他词更经常一起出现,因此也有可能用一定的概率预测下一个词。例如,你的智能手机根据你输入的最后几个词的概率分布来为你建议单词。因此,自然语言处理中的一个有用步骤是将句子分解成 n 元组。
分词
分词是将字符序列分割成片段(标记)的任务。标记可以是单词,也可以是句子,在这种情况下,该任务被称为单词分词或句子分词。我们需要分词来查看n-元组,因为它们依赖于标记的序列。我们首先根据空格字符将文本分割成单词。虽然这是一个好的开始,但这种方法并不完美,因为我们最终会得到带有标点的单词,例如“remains”。因此,例如,我们可以移除标点。然而,然后我们会面临额外的挑战,例如带有撇号的单词(例如“o'clock”)和带有连字符的单词(例如“pearl-grey”)。此外,一些标点对于句子结构很重要,比如句号。然而,我们需要能够区分单词“Mr.”结尾的句号和句子结尾的句号。处理这些问题是分词的过程。最后,一旦我们有了标记,我们就可以开始查看n-元组。
马尔可夫模型
如前几节课所讨论的,马尔可夫模型由节点组成,每个节点的值基于有限数量的前一个节点具有概率分布。马尔可夫模型可以用来生成文本。为此,我们在文本上训练模型,然后根据前 n 个词为每个 n-gram 的每个n-th 标记建立概率。例如,使用三元组,在马尔可夫模型有两个词之后,它可以从基于前两个词的概率分布中选择第三个词。然后,它可以从基于第二个和第三个词的概率分布中选择第四个词。要查看使用 nltk 实现此类模型的示例,请参阅源代码中的 generator.py,其中我们的模型学习生成莎士比亚风格的句子。最终,使用马尔可夫模型,我们能够生成通常语法正确且表面上听起来与人类语言输出相似的文本。然而,这些句子缺乏实际的意义和目的。
词袋模型
词袋模型是一种将文本表示为无序单词集合的模型。该模型忽略了语法,只考虑句子中单词的意义。这种方法在某些分类任务中很有帮助,例如情感分析(另一个分类任务可能是区分常规电子邮件和垃圾邮件)。情感分析可以用于产品评论,将评论分类为正面或负面。考虑以下句子:
-
“我的孙子很喜欢它!太有趣了!”
-
“产品几天后就坏了。”
-
“这是我很久以来玩过的最好的游戏之一。”
-
“有点便宜且脆弱,不值得。”
仅基于每个句子中的单词,忽略语法,我们可以看到句子 1 和 3 是积极的(“loved”,“fun”,“best”),而句子 2 和 4 是消极的(“broke”,“cheap”,“flimsy”)。
简单贝叶斯
简单贝叶斯是一种可以与词袋模型一起用于情感分析的技术。在情感分析中,我们问“给定句子中的单词,句子是积极的/消极的概率是多少。”回答这个问题需要计算条件概率,回忆第二部分课中的贝叶斯定理会有所帮助:

现在,我们想使用这个公式来找到 P(sentiment | text),例如,P(positive | “my grandson loved it”)。我们首先对输入进行标记化,这样我们最终得到 P(positive | “my”, “grandson”, “loved”, “it”)。直接应用贝叶斯定理,我们得到以下表达式:P(“my”, “grandson”, “loved”, “it” | positive)*P(positive)/P(“my”, “grandson”, “loved”, “it”)。这个复杂表达式将给我们 P(positive | “my”, “grandson”, “loved”, “it”)的精确答案。
然而,如果我们愿意得到一个不等于 P(positive | “my”, “grandson”, “loved”, “it”),但与其成比例的答案,我们就可以简化这个表达式。稍后,我们知道概率分布需要加起来等于 1,我们可以将得到的结果值归一化成一个确切的概率。这意味着我们可以将上面的表达式简化为仅包含分子:P(“my”, “grandson”, “loved”, “it” | positive)P(positive)。再次,我们可以根据已知条件概率a给定b与a和b的联合概率成比例的知识来简化这个表达式。因此,我们得到以下概率表达式:P(positive, “my”, “grandson”, “loved”, “it”)P(positive)。然而,计算这个联合概率是复杂的,因为每个词的概率都是基于它前面词的概率。这需要我们计算 P(positive)P(“my” | positive)P(“grandson” | positive, “my”)P(loved | positive, “my”, “grandson”)P(“it” | positive, “my”, “grandson”, “loved”)。
正是在这里,我们天真地使用了贝叶斯定理:我们假设每个词的概率与其他词是独立的。这并不正确,但尽管这种不精确,朴素贝叶斯仍然能产生一个好的情感估计。使用这个假设,我们最终得到以下概率:P(positive)P(“my” | positive)P(“grandson” | positive)P(“loved” | positive)P(“it” | positive),这并不难计算。P(positive) = 所有正样本的数量除以总样本的数量。P(“loved” | positive)等于包含单词“loved”的正样本数量除以正样本的数量。让我们考虑以下例子,其中微笑和皱眉表情符号代替了单词“positive”和“negative”:

在右侧,我们看到一个表格,其中包含左侧每个词在句子中出现的条件概率,前提是句子是积极的或消极的。在左侧的小表格中,我们看到积极或消极句子的概率。在左下角,我们看到计算后的结果概率。在这个阶段,它们之间是成比例的,但它们在概率方面并没有告诉我们太多。为了得到概率,我们需要归一化这些值,得到 P(positive) = 0.6837 和 P(negative) = 0.3163。朴素贝叶斯的优势在于它对在一个类型的句子中比另一个类型句子中出现频率更高的词很敏感。在我们的例子中,单词“loved”在积极句子中出现的频率更高,这使得整个句子更有可能被判定为积极而不是消极。要查看使用 nltk 库实现的朴素贝叶斯情感评估的示例,请参考 sentiment.py。
我们可能会遇到的一个问题是,某些词可能永远不会出现在某种类型的句子中。假设我们样本中的所有积极句子都没有“孙子”这个词。那么,P(“孙子” | 积极) = 0,在计算句子为积极的概率时,我们会得到 0。然而,在现实中并非如此(提到孙子的句子并不都是消极的)。解决这个问题的方法之一是加性平滑,即在我们分布的每个值上添加一个值α来平滑数据。这样,即使某个值是 0,通过向其添加α,我们也不会将正句或负句的整个概率乘以 0。一种特定的加性平滑方法,拉普拉斯平滑,将 1 加到我们分布的每个值上,假装所有值都至少被观察过一次。
词表示
我们想在我们的 AI 中表示词义。正如我们之前看到的,以数字形式向 AI 提供输入是方便的。解决这个问题的方法之一是使用独热表示,其中每个词用一个向量表示,该向量包含与我们有相同数量的值。除了向量中的一个值等于 1 之外,所有其他值都等于 0。我们可以通过哪个值是 1 来区分单词,最终为每个单词得到一个唯一的向量。例如,句子“他写了一本书”可以表示为四个向量:
-
[1, 0, 0, 0] (他)
-
[0, 1, 0, 0] (已写)
-
[0, 0, 1, 0] (a)
-
[0, 0, 0, 1] (书)
然而,虽然这种表示在只有四个词的世界中是有效的,但如果我们想表示词典中的词,当我们有 50,000 个词时,我们最终会得到 50,000 个长度为 50,000 的向量。这是极其低效的。这种表示方式中的另一个问题是,我们无法表示像“wrote”和“authored”这样的词之间的相似性。因此,我们转向分布式表示的想法,其中意义分布在向量中的多个值上。在分布式表示中,每个向量有有限数量的值(远少于 50,000),其形式如下:
-
[-0.34, -0.08, 0.02, -0.18, …] (他)
-
[-0.27, 0.40, 0.00, -0.65, …] (写了)
-
[-0.12, -0.25, 0.29, -0.09, …] (a)
-
[-0.23, -0.16, -0.05, -0.57, …] (书中)
这使我们能够为每个词生成独特的值,同时使用较小的向量。此外,现在我们能够通过它们向量中值的差异来表示词之间的相似性。
“你将通过与你相伴的词来认识一个词”是 J. R. Firth,一位英国语言学家的一个想法。遵循这个想法,我们可以通过定义词的相邻词来定义词。例如,我们可以用有限的词来完成句子“for ___ he ate.” 这些词可能是像“breakfast”、“lunch”和“dinner”这样的词。这使我们得出结论,通过考虑某个词倾向于出现的环境,我们可以推断出该词的意义。
word2vec
word2vec 是一种生成单词分布式表示的算法。它通过 Skip-Gram 架构 来实现,这是一种针对给定目标词预测上下文的神经网络架构。在这个架构中,神经网络为每个目标词都有一个输入单元。一个较小的、单一的隐藏层(例如,50 或 100 个单元,尽管这个数字是灵活的)将生成代表单词分布式表示的值。隐藏层中的每个单元都与输入层中的每个单元相连。输出层将生成与目标词在相似上下文中可能出现的单词。类似于我们在上一节课中看到的,这个网络需要使用训练数据集并通过反向传播算法进行训练。

这个神经网络证明非常强大。在处理过程的最后,每个单词最终都变成一个向量,或者一系列数字。例如,
书籍:[-0.226776 -0.155999 -0.048995 -0.569774 0.053220 0.124401 -0.091108 -0.606255 -0.114630 0.473384 0.061061 0.551323 -0.245151 -0.014248 -0.210003 0.316162 0.340426 0.232053 0.386477 -0.025104 -0.024492 0.342590 0.205586 -0.554390 -0.037832 -0.212766 -0.048781 -0.088652 0.042722 0.000270 0.356324 0.212374 -0.188433 0.196112 -0.223294 -0.014591 0.067874 -0.448922 -0.290960 -0.036474 -0.148416 0.448422 0.016454 0.071613 -0.078306 0.035400 0.330418 0.293890 0.202701 0.555509 0.447660 -0.361554 -0.266283 -0.134947 0.105315 0.131263 0.548085 -0.195238 0.062958 -0.011117 -0.226676 0.050336 -0.295650 -0.201271 0.014450 0.026845 0.403077 -0.221277 -0.236224 0.213415 -0.163396 -0.218948 -0.242459 -0.346984 0.282615 0.014165 -0.342011 0.370489 -0.372362 0.102479 0.547047 0.020831 -0.202521 -0.180814 0.035923 -0.296322 -0.062603 0.232734 0.191323 0.251916 0.150993 -0.024009 0.129037 -0.033097 0.029713 0.125488 -0.018356 -0.226277 0.437586 0.004913]
这些数字本身并没有什么意义。但是,通过找到语料库中与这些数字最相似的词汇,我们可以运行一个函数,生成与单词 book 最相似的词汇。在这个网络中,这些词汇将是:book, books, essay, memoir, essays, novella, anthology, blurb, autobiography, audiobook。这对于计算机来说已经很不错了!通过一些本身没有特定意义的数字,人工智能能够生成与 book 在意义而非字母或声音上非常相似的词汇!我们还可以根据词汇向量之间的差异来计算词汇之间的差异。例如,king 和 man 之间的差异类似于 queen 和 woman 之间的差异。也就是说,如果我们把 king 和 man 之间的差异加到 woman 的向量上,与结果向量最接近的词汇是 queen!同样地,如果我们把 ramen 和 japan 之间的差异加到 america 上,我们得到 burritos。通过使用神经网络和词汇的分布式表示,我们使我们的 AI 能够理解语言中词汇之间的语义相似性,使我们更接近能够理解和生成人类语言的 AI。
神经网络
回想一下,神经网络接受一些输入,将其传递到网络中,并创建一些输出。通过向网络提供训练数据,它可以越来越准确地翻译输入为输出。通常,机器翻译使用神经网络。在实践中,当我们翻译词汇时,我们希望翻译一个句子或段落。由于句子是固定大小的,我们遇到了将一个序列翻译为另一个序列的问题,其中大小不是固定的。如果你曾经与一个 AI 聊天机器人交谈过,它需要理解一个词汇序列并生成一个适当的输出序列。
循环神经网络可以多次运行神经网络,同时跟踪一个包含所有相关信息的状态。输入被输入到网络中,创建一个隐藏状态。将第二个输入传递到编码器,同时带有第一个隐藏状态,产生一个新的隐藏状态。这个过程会重复进行,直到传递一个结束标记。然后,开始解码状态,创建一个隐藏状态接着一个隐藏状态,直到我们得到最终的词汇和另一个结束标记。然而,一些问题也随之而来。编码阶段的一个问题是,所有来自输入阶段的信息必须存储在一个最终状态中。对于长序列,将所有这些信息存储到一个单一的状态值中是非常具有挑战性的。如果能以某种方式组合所有隐藏状态将是有用的。另一个问题是,输入序列中的某些隐藏状态比其他状态更重要。是否有可能知道哪些状态(或词汇)比其他状态更重要?
注意
注意力指的是神经网络决定哪些值比其他值更重要。在句子“马萨诸塞州的首府是什么?”中,注意力使神经网络能够决定在生成输出句子的每个阶段它将关注哪些值。进行这样的计算,神经网络将显示,在生成答案的最后一个词“capital”和“Massachusetts”是最需要关注的。通过取注意力分数,将它们乘以网络生成的隐藏状态值,并将它们相加,神经网络将创建一个解码器可以用来计算最后一个词的最终上下文向量。在这些计算中出现的挑战是,循环神经网络需要逐词顺序训练。这需要花费大量时间。随着大型语言模型的增长,它们的训练时间越来越长。随着需要训练的更大数据集的出现,对并行化的需求稳步增长。因此,引入了一种新的架构。
转换器
Transformers是一种新的训练架构,其中每个输入词同时通过神经网络。一个输入词进入神经网络,并被捕获为一个编码表示。由于所有单词同时输入神经网络,单词顺序很容易丢失。因此,位置编码被添加到输入中。因此,神经网络将使用单词及其在编码表示中的位置。此外,添加了一个自注意力步骤来帮助定义输入单词的上下文。实际上,神经网络通常会使用多个自注意力步骤,以便它们可以进一步理解上下文。这个过程对序列中的每个单词重复多次。结果是编码表示,在解码信息时将非常有用。
在解码步骤中,前一个输出词及其位置编码被提供给多个自注意力步骤和神经网络。此外,多个注意力步骤被输入编码过程中的编码表示,并提供给神经网络。因此,单词能够相互关注。进一步来说,并行处理成为可能,计算既快又准确。
总结
我们在多种情境下探讨了人工智能。我们研究了人工智能如何寻找解决方案的搜索问题。我们探讨了人工智能如何表示知识和创造知识。我们研究了当它不确定某些事情时的情况。我们研究了优化、最大化函数和最小化函数。我们研究了通过观察训练数据来寻找模式的机器学习。我们学习了神经网络以及它们如何使用权重从输入到输出。今天,我们探讨了语言本身以及我们如何让计算机理解我们的语言。我们只是刚刚触及了这个过程的表面。我们真心希望您喜欢与我们一同经历的这段旅程。这是《使用 Python 的人工智能入门》。
网络安全
第零讲
-
保护账户
-
安全
-
防御攻击
-
国家标准与技术研究院 (NIST)
-
双因素认证 (2FA) 或多因素认证 (MFA)
-
一次性密码 (OTP)
-
键盘记录
-
凭证填充
-
社会工程学
-
钓鱼
-
中间人攻击
-
单点登录 (SSO)
-
密码管理器
-
通行密钥
-
总结
保护账户
-
这是 CS50 对网络安全的介绍。
-
今天,我们将关注账户的安全。
-
让我们先从谈论安全本身开始。
安全
-
我们可以将现实世界中的安全想象成一把物理锁的钥匙。
-
在数字世界中,有许多构建安全性的基石。
-
授权是指验证你确实是应该有权访问此账户的人。
-
用户名是证明你应该有权访问账户的一种方式。
-
密码是另一种证明你应该有权访问账户的方式。
-
理论上,只有你应该能够提供有效的用户名和密码。
-
字典攻击是恶意行为者尝试猜测你的密码的一种方式。实际上,黑客可能会通过尝试大量可能的密码列表来进行“暴力攻击”以尝试猜测你的密码。因此,拥有一个非常好的密码来防御攻击是非常重要的。
-
在考虑安全时,人们应该考虑可用性和安全之间的权衡。一个高度安全的服务可能会变得不太易用。因此,当你考虑保持安全的选择时,考虑什么对你来说最有意义。
防御攻击
-
考虑一下,如果你的密码(无论是用于手机还是其他用途)仅由四位数字组成,你将有多少种可能的数字组合。这里有 10,000 种可能的数字。通常,我们可以这样考虑可能性:
10 x 10 x 10 x 10注意,在最坏的情况下,恶意行为者需要尝试 10,000 种可能的密码。
-
我们可以尝试用代码来表示这一点。VS Code是一个开发环境,我们可以在这里编写和执行代码。
-
考虑以下代码表示的上述问题:
from string import digits for i in digits: for j in digits: for k in digits: for l in digits: print(i, j, k, l)注意,这段代码是用 Python 编写的,它遍历每个可能的数字组合
-
在终端窗口(我们可以向计算机发出命令的地方)中执行
crack.py,我们可以看到对手只需要几毫秒就能生成所有可能的密码。 -
如果我们要求一个由四个字母组成的密码会发生什么?
-
如果我们允许 26 个字母的大小写版本,我们可以用数学方式表示为:
52 x 52 x 52 x 52注意,我们有超过 7,000,000 种可能性。
-
我们可以按照以下方式修改我们的代码:
from string import ascii_letters for i in ascii_letters: for j in ascii_letters: for k in ascii_letters: for l in ascii_letters: print(i, j, k, l)注意,我们调用了
ascii_letters,它包括每个字母的大小写版本。类似于我们之前的程序,这个程序遍历所有可能的组合。 -
执行此代码,我们发现黑客发现所有可能的密码仍然不需要太多努力。
-
如果我们要求一个由四个字母、数字或标点符号组成的密码会发生什么?我们将有超过 7,800,000 种可能性可供选择!
-
我们可以按照以下方式修改我们的代码:
from string import ascii_letters, digits, punctuation for i in ascii_letters + digits + punctuation: for j in ascii_letters + digits + punctuation: for k in ascii_letters + digits + punctuation: for l in ascii_letters + digits + punctuation: print(i, j, k, l) -
执行此代码时,我们注意到在最坏的情况下,发现所有可能的密码需要的时间明显更长。
-
从上面的内容中,最重要的启示是,只要我们在时间上提高对手的门槛,对手就越不可能有时间破解你的密码。然而,对于用户来说,更长的、更复杂的密码输入时间更长,也更难以记住。因此,在安全性和可用性之间需要找到一个平衡点。
美国国家标准与技术研究院(NIST)
-
NIST 发布有关如何更有效地保护账户的建议。
-
你可以在自己的工作中使用他们的建议和最佳实践,也许在你的工作场所或商业活动中也是如此。他们的建议中包括以下关于密码的内容(为了简洁而改写):
-
记忆中的秘密至少应有八个字符长度。
-
验证器应允许所有打印的 ASCII 字符和长度不超过 64 个字符的 Unicode 符号。
-
验证器应将预期的秘密与可用的字典单词、重复序列、泄露的密码列表和上下文特定单词进行比较。
-
验证器不应允许未经认证的申请人访问密码提示。
-
验证器不应要求定期更改密码。
-
验证器应限制失败的认证尝试次数,并锁定潜在的对手。
-
双因素认证(2FA)或多因素认证(MFA)
-
多因素认证有三个组成部分。
-
知识:只有你知道的东西。
-
拥有:只有授权用户拥有的物品或设备。
-
内在性:只有你可以获得的因素,比如你的指纹、面部或其他生物识别特征。
-
一次性密码(OTP)
-
可以获得一个特殊的关键链或设备,它提供一次性密码。
-
通常,OTP 是从设备或应用程序获得的。
-
一些 OTP 方法比其他方法更安全。
-
文本消息基于的 OTP 很容易通过 SIM 卡交换被欺骗,其中对手获取并克隆 SIM 卡,从而获取你的短信。
-
更安全的是从安全设备上的应用程序获得的 OTP,例如手机上的身份验证应用程序。
键盘记录
-
用户名、密码和 OTP 都容易受到对手记录您按键的攻击。
-
键盘记录是通过在计算机上安装恶意软件来完成的。
-
最好确保您只登录到您有权访问的设备。
凭证填充
-
另一种攻击,凭证填充,涉及使用从受损害网站获得的用户名和密码列表在另一个网站上。
-
如果您在多个网站上使用相同的密码,最好将它们更改为唯一的密码。
社会工程
-
与技术攻击不同,社会工程攻击涉及利用社会压力和信任来破坏您的凭证。
-
一个人可能伪装成受信任的第三方来获取您的凭证或您生活的细节。
-
此外,对手可能试图找到有关您生活的细节,如您的宠物名字等。
钓鱼
-
钓鱼利用社会工程以技术方式,通过伪装成受信任的网站来获取您的凭证和细节。
-
例如,您可能被引导到一个看起来像谷歌登录页面但实际上是对手页面的地方。
-
永远不要盲目信任电子邮件中提供的链接。考虑直接在网页浏览器中输入受信任的 URL。
中间人攻击
- 在您和您下载的数据源之间,如路由器和交换机等设备,可能被非常复杂的攻击者攻破。
单点登录 (SSO)
-
由于许多不同的服务需要许多不同的密码要求,并且考虑到之前提供的关于永远不要为不同的服务使用相同密码的建议,有多种方法可以增强您的安全性。
-
SSO 允许您使用 Google 或 Facebook 登录来访问 Google 或 Facebook 不提供的服务。
-
因此,您能够轻松地以更少的摩擦和更高的安全性访问其他服务。
密码管理器
-
密码管理器是一款可以管理复杂密码并为您保存的软件。
-
这允许您不必记住密码。
-
此外,许多密码管理器能够识别钓鱼网站。
-
与已经存在多年的浏览器密码保存功能不同,密码管理器是另一款独立的软件,可以在多个服务中提供您的密码。
-
缺点是,实际上,您是在“把所有的鸡蛋都放在一个篮子里。”您将需要记住一个密码来访问所有其他密码。
Passkeys
-
一种新兴技术,passkeys 是自动生成的密码,利用了密码学。
-
Passkeys 涉及一个公钥和一个私钥。公钥由服务(如网站)持有,而私钥由您的设备持有。
-
Passkeys 将使您能够登录而无需输入密码。
-
然而,为了更好地了解这项技术,我们需要学习更多关于密码学的知识。
总结
在本课中,你了解了安全和便利性之间的权衡。你还了解了各种可能使你和他人变得脆弱的攻击方式。最后,你学习了保护登录凭证的一些方法。具体来说,你学习了……
-
安全性和便利性之间存在权衡。
-
通常,你的行为和意识是使你免受数字对手侵害的关键。
-
NIST 提供了与安全相关的指南。
-
双因素认证(2FA)、多因素认证(MFA)和一次性密码(OTPs)是你可以采取的使你更加安全的一些方法。
-
常见的攻击包括键盘记录、钓鱼、凭证填充和社会工程学。
-
通过使用单点登录(SSO)或密码管理器,你可以享受更高的安全性。
-
在未来,密钥(Passkeys)将提供更高的安全性。
欢迎下次再来!
第一讲
-
保护数据
-
密码
-
哈希函数
-
盐值
-
单向哈希函数
-
代码
-
密码
-
密钥
-
密码分析
-
公钥密码学
-
密钥交换
-
数字签名
-
密钥
-
传输中的加密
-
删除
-
全盘加密
-
量子计算
-
总结
保护数据
-
这是 CS50 的网络安全入门。
-
上周,我们回顾了账户。
密码
-
我们关注的是我们保持数据安全的责任。
-
然而,第三方总是参与我们数据的存储。
-
你可以想象一个系统如何将用户名和密码存储在文本文件中。
-
你也可以想象攻击者如何获取这样的文本文件。
-
我们能否最小化存储密码的明文风险?
哈希函数
-
哈希函数 是一种方法,通过它将一些明文转换为哈希值,使其更难以阅读。
-
因此,哈希函数 创建一个哈希值。将密码提供给哈希函数,然后输出为哈希值。
-
没有访问精确的哈希函数,攻击者无法输出正确的密码。
-
通常,我们希望哈希函数输出一些非常难以理解且缺乏模式的东西。因此,攻击者无法猜测算法正在做什么。
-
由于用户名和哈希值存储在服务器上,攻击者无法轻易访问该服务器上的账户。
-
当用户现在输入密码进行登录时,密码会被传递给哈希算法再次进行比对,将创建的哈希值与存储的哈希值进行比较。
-
因此,我们增加了攻击者访问受保护数据所需的成本、时间和资源。
-
尽管如此,字典攻击 可以将字典中的值一个接一个地输入到哈希函数中,作为破解它的一种方式。
-
此外,暴力攻击 可以尝试逐个字符地顺序输入,以尝试破解密码。
-
想象一下,彩虹表 是另一种威胁,其中攻击者有一个哈希表中所有潜在哈希值的表。然而,这需要数以千计的存储容量才能完成。
-
最后,当用户使用相同的密码且这些密码的哈希值完全相同时,会出现一个问题。我们该如何解决这个问题?
盐值
-
盐值 是一个过程,通过向哈希函数中“撒入”一个附加值,使得哈希值发生变化。
-
使用盐值几乎可以保证用户提供由用户提供的哈希值,即使是那些具有相同密码的用户,也会收到不同的哈希密码。
-
因此,再次强调,对手破解这些密码的成本相当高。
-
NIST 建议对记忆中的秘密进行哈希处理和加盐。
单向哈希函数
-
单向哈希函数 用代码编写,接受任意长度的字符串并输出固定长度的哈希值。
-
利用这样的函数,持有哈希值和哈希函数的人永远不会知道原始密码。
-
事实上,在某些使用单向哈希函数的系统中,某些密码可能映射到相同的哈希值。
密码
-
密码学是研究如何将安全数据从一方传输到另一方的学科。
-
我们可以通过密码来确保数据的安全。
-
密码将我们想要说的单词转换成不易理解的单词串。
-
编码 涉及将明文转换为密文。
-
解码 是其相反过程,将密文转换为明文。
密码
-
加密涉及将明文 加密 成密文。
-
这个加密过程被称为 加密。解密它们的过程被称为 解密。
密钥
-
密钥实际上是很大的字符串。这些密钥用于加密和解密。
-
对称密钥密码学 涉及将密钥和明文传递到加密算法中,其中输出密文。
-
在这种情况下,发送者和接收者都彼此有一个共享的秘密,即他们都有访问加密和解密算法的权限。
密码分析
-
密码分析 是一个研究领域和实践领域,个人在这里研究如何加密和解密数据。
-
证据表明你是这门课程的一部分,你也可能对密码分析感兴趣。
公钥密码学
-
你可以想象一个场景,其中安全数据的发送者和接收者可能从未亲自见过面。那么,一方如何在这两个当事人之间建立共享的秘密呢?
-
公钥加密 或 非对称密钥加密 解决了这个问题。
-
首先,发送者使用公钥和明文,将这些输入到算法中。这会产生密文。
-
第二步,接收者使用他们的密钥,将这个密钥和密文输入到算法中。这会产生解密后的文本。
-
RSA 是一种加密标准,描述了这一过程。
密钥交换
-
另一种算法称为 Diffie-Hellman,其目标是密钥交换。
-
使用一个约定的值
g和一个素数值p。 -
当事人 A 和当事人 B 有一个共享的秘密值,称为
s。 -
当事人 A 和当事人 B 都有自己的私钥。
数字签名
-
使用公钥和私钥的构建块,你可以使用这些来签署文档。
-
可以通过两步过程 签署 文档。
-
第一步,将消息,即文档的内容,传递到哈希函数中,产生哈希值。
-
第二步,将私钥和哈希值传递到数字签名算法中,这会产生数字签名。
-
收件人可以通过将消息、文档的内容传递给哈希函数并接收一个哈希值来验证你的数字签名。然后,收件人将提供的公钥和签名传递给解密算法,结果产生一个应该与先前计算的哈希值相匹配的哈希值。
密钥
-
密钥或WebAuthn是一种越来越广泛可用的技术。
-
很快,用户名和密码将变得不那么常见。
-
密钥将依赖于设备。例如,当你在手机上访问一个要求你创建账户的网站时,你的手机将生成一个公钥和一个私钥。
-
然后,你将发送你的公钥到网站。
-
从那时起,要使用该设备登录网站或同步你的密钥跨设备的服务,你将传递一个与挑战值配对的私钥。一个算法将产生一个签名。
传输中的加密
-
传输中的加密与在数据网络中移动的数据的安全有关。
-
想象一个场景,其中两方想要相互通信。
-
我们希望防止第三方在中间拦截数据。
-
作为中间人的第三方服务,例如电子邮件提供商,确实可能会阅读你的电子邮件或查看你的消息。
-
端到端加密是一种方式,通过这种方式,用户可以保证中间没有第三方可以读取数据。
删除
-
让我们现在考虑一个相当平凡的情景,比如删除一个文件。
-
一旦在计算机上删除文件,那些已删除文件的指纹可能仍然存在于你的计算机上。
-
操作系统通常通过简单地忘记它们的位置来删除文件。因此,计算机可能会用新文件覆盖以前的文件。
-
然而,并不能保证你硬盘上的空闲空间完全清除了旧文件的指纹。
-
安全删除是一个过程,通过这个过程,所有已删除文件的残留部分都被转换为零、一或随机的零和一序列。
全盘加密
-
全盘加密或静态加密完全加密了硬盘的内容。
-
如果你的设备被盗或者你卖掉了你的设备,没有人将能够访问你的加密数据。
-
然而,一个缺点是如果你丢失了密码或者你的面部变化足够大,你将无法访问你的数据。
-
另一个缺点是黑客可能会通过勒索软件使用这种相同类型的技术来加密你的硬盘并将其作为人质。
量子计算
-
量子计算是一种新兴的计算机技术,可能能够为对手提供指数级的计算能力。
-
这种技术可能被对手用来减少猜测密码和破解加密所需的时间。
-
希望在我们获得这种计算能力之前,坏人还没有。
总结
在本课中,你学习了关于数据安全的内容。你学习了...
-
网站和服务如何存储密码;
-
如何将文本值哈希以确保保密性;
-
关于加盐、单向哈希函数、密钥、加密和解密在安全存储数据中的作用;
-
关于公钥和私钥;
-
技术如何利用公钥和私钥来保持数据安全;
-
如何确保自己的硬件安全;
-
量子计算带来的新兴利益和威胁。
欢迎下次再见!
第二讲
-
Securing Systems
-
Wi-Fi
-
HTTP
-
HTTPS
-
VPN
-
SSH
-
Ports
-
Malware
-
杀毒软件
-
总结
Securing Systems
-
这是 CS50 的网络安全入门。
-
这周,我们将重点关注网络和系统。
-
上次,我们介绍了加密作为保护信息的一种方式。
Wi-Fi
-
很可能,您已经意识到存在加密和非加密的网络。
-
加密网络使用加密来保护您与其他设备之间的数据。
-
Wi-Fi 保护接入 或 WPA 是一种用于保护网络的加密形式。
HTTP
-
超文本传输协议,或 HTTP,是一种未加密的数据传输方式。
-
利用 HTTP,您容易受到 中间人攻击,攻击者可以在下载的内容中注入额外的 HTML 代码。通过 HTTP 访问的所有网页都可能被注入广告。此外,还可能插入恶意代码。
-
事实上,还有其他威胁。数据包嗅探 是攻击者查看双方之间传输的数据的一种方式。您可以想象,如果一张信用卡号被放在一个未加密的数据包中,攻击者确实可以检测并窃取它。
-
Cookies 是网站放在您电脑上的小文件。Cookies 可以被网站用来追踪您的身份,显示您的电子邮件,或追踪您的购物车。Cookies 使得您容易受到 会话劫持 的攻击,攻击者可能会注入一个 超级 cookie 来追踪您。
-
如何防御此类威胁?
HTTPS
-
HTTPS 是一种安全的 HTTP 协议。
-
双方之间的流量被加密。
-
这是通过 TLS(传输层安全性)和公钥加密来实现的。
-
一个网站有一个由第三方(称为 X.509 类型的 证书)签名的公钥。这些网站也有一个私钥。
-
证书颁发机构 或 CA 是发行证书的可信第三方公司。
-
当您访问一个网站时,您的浏览器会下载该网站的证书,通过算法运行它,并创建一个哈希值。
-
然后,它使用网站的公钥和提供的证书签名,将其提供给算法以验证它创建的哈希值与之前找到的完全一致。
-
如果这些匹配,则网络浏览器应用程序会满意这是一个安全网站。
-
HTTPS 在数学上确实能让我们保持安全,但也有例外。
-
SSL 拦截 是攻击者使用网站上的 HTTP 来重定向流量到恶意网站的一种攻击。攻击者甚至可能将您重定向到一个不是预期网站的 HTTPS-加密域。
-
缓解这种威胁的一种方法是通过实施 HSTS 或 HTTP 严格传输安全,服务器告诉浏览器将所有流量导向安全连接。
VPN
-
VPN,或称虚拟专用网络,在两个点之间建立加密通道。
-
在 VPN 中,所有流量都是加密的。
-
然而,也有一些副作用。
-
由于双方之间的管道会导致从第二方接收 IP 地址,因此它将向整个网络中的服务显示您的 IP 地址是第二方的:而不是您的原始 IP 地址!
-
事实上,人们经常使用 VPN 来伪装成另一个国家的人。
SSH
-
SSH是一种安全的协议,您可以通过它远程执行命令。
-
如果想与远程计算机通信并执行命令,可以发出一个
ssh命令。以下是一个使用 SSH 命令连接到斯坦福大学服务器的示例。您仍然需要适当的凭证和权限才能成功连接。ssh stanford.edu -
如果有适当的访问权限,可以直接在远程服务器上执行命令。
端口
-
端口号用于将网络流量导向服务器上的特定服务。
-
例如,端口
80指向 HTTP,443指向 HTTPS,22指向 SSH。 -
服务器监听这些端口以接收传入的流量。
-
因此,对手可能会进行端口扫描,尝试所有可能的端口,以查看它们是否接受流量。
-
渗透测试是一项专业可能参与的活动,用于检查端口相关的安全漏洞。
-
道德黑客是合法的业务,用于测试此类漏洞。
-
防火墙是一种软件,通过阻止未经授权的访问来保护各种服务,包括来自设备上受损害服务的访问。
-
防火墙利用IP 地址,这是分配给网络中每台计算机的唯一数字,以防止外部人员参与流量。
-
防火墙也可以使用深度数据包检查,其中它们检查数据包中的内容,以寻找可能对您的公司感兴趣的材料。这可以用来检查您是否在给媒体或其他可能被视为您的公司对手的各方发邮件。
-
通过代理使用深度数据包检查,其中中间设备被用作流量进出网络的路径。学校或公司可能会在这个代理上更改 URL,记录您尝试浏览的 URL,并希望保护您免受潜在有害行为的影响。
恶意软件
-
恶意软件是损害计算机或损害其安全的恶意软件。
-
病毒是一种附着到您的计算机上的软件。一旦安装,它可以做几乎所有的事情!
-
蠕虫是一种恶意软件,可以通过安全漏洞从一个计算机移动到另一个计算机。
-
僵尸网络是一种恶意软件,一旦安装在您的计算机上,就会感染其他计算机,并且可以被对手用来向成千上万的感染计算机发布命令。
-
被僵尸网络感染的电脑可以被用来发起拒绝服务攻击,即向服务器发送大量请求,目的是减缓或关闭它。由于僵尸网络中有如此多的电脑,这种攻击类型可以被称为来自数千个 IP 地址的分布式拒绝服务攻击。
抗病毒软件
-
抗病毒软件检测病毒,并希望可以将其移除。
-
自动更新必须启用,以修复软件先前版本中的安全漏洞。
-
然而,一个人可能仍然容易受到零日攻击的攻击,这种攻击利用了在软件公司有机会创建修复方案之前软件中的未知漏洞。
总结
在本课中,你学习了关于系统安全的内容。你学习了…
-
无线网络中网络是如何被保护的;
-
如何使用不安全和安全协议在网络上发送和接收数据;
-
如何使用虚拟专用网络加密网络流量;
-
关于端口以及对手利用它们的漏洞;
-
关于各种恶意软件;
-
抗病毒软件如何帮助防止恶意软件安装到你的电脑上。
次次见!
第三讲
-
保护软件
-
钓鱼攻击
-
代码注入
-
反射攻击
-
存储攻击
-
字符转义
-
HTTP 头部
-
SQL 注入
-
预处理语句
-
命令注入
-
开发者工具
-
服务器端验证
-
跨站请求伪造 (CSRF)
-
任意代码执行 (ACE)
-
开源软件
-
封闭源代码软件
-
应用商店
-
软件包管理器
-
漏洞赏金
-
识别漏洞
-
总结
保护软件
-
这是 CS50 的网络安全入门。
-
这周,让我们专注于保护你使用的软件或你创建的软件。
-
上次,我们介绍了各种攻击,攻击者可以使用这些攻击从你那里获取信息。
钓鱼攻击
-
我们介绍了一种称为 钓鱼攻击 的攻击,其中攻击者欺骗你提供某种信息。
-
例如,在网站的源代码中,在 HTML 语言中,你可能看到如下代码:
<p>...</p>注意,上面的代码中,一个段落开始并结束。它以一个开标签和一个闭标签开始。
-
类似地,网页中的链接使用一种称为 锚点标签 的特定类型的标签,将用户从一个网页带到另一个网页。
-
这样的代码看起来是这样的:
<a href="https://harvard.edu">Harvard</a>注意,此代码是一个锚点标签,允许用户点击“哈佛”一词并访问
harvard.edu。 -
在实际的网页上,你可以将鼠标移到这样的链接上,并看到这个精确链接将带你去哪里。
-
攻击者可能会利用你注意力不集中的弱点,声称你正在链接到一个网页,而实际上你正在链接到另一个网页。
-
例如,攻击者可能提供如下代码:
<a href="https://yale.edu">https://harvard.edu</a>注意,此代码是一个锚点标签,它欺骗用户点击
https://harvard.edu,而实际上它浏览到的是yale.edu。虽然用户会认为他们点击的是哈佛的链接,但实际上他们正在浏览耶鲁。 -
你可以想象这种策略如何被攻击者用来欺骗你,让你认为你正在访问一个网站,而实际上你正在访问另一个网站。
-
攻击者经常创建网站的假版本,目的就是为了欺骗用户在这些网站上输入敏感信息。例如,如果你是一名哈佛学生访问这样的假哈佛网站,你可能会尝试登录并提供你的用户名和密码给攻击者。
代码注入
-
跨站脚本攻击,或称 XSS,是一种攻击形式,其中网站被欺骗通过用户的输入运行恶意代码。
-
例如,在 Google 上,当你搜索“猫”这个术语时,注意这个术语如何在屏幕上的其他地方出现,显示这个搜索的结果数量。
-
想象一下,一个对网络略知一二的对手可能会将代码作为输入插入,以此来欺骗网站运行此类代码。
-
例如,考虑以下可能被插入搜索字段的代码:
<script>alert('attack')</script>注意,此脚本显示了一个通知,说“攻击。”虽然由于安全原因,Google 网站不会显示此类通知,但这代表了对手可能尝试的事情。
-
如果网站盲目地复制用户输入并输出对手输入的内容,这将是一个重大的安全问题。
反射攻击
-
反射攻击 是一种利用网站接受输入的方式,欺骗用户的浏览器发送请求信息,从而导致攻击的攻击方式。
-
想象一下,一个用户可能会被欺骗点击一个结构如下链接:
<a href="https://www.google.com/search?q=%3Cscript%3Ealert%28%27attack%27%29%3C%2Fscript%3E">cats</a>注意,此链接包含上面展示的精确脚本,该脚本旨在在用户的屏幕上创建攻击警报。
-
用户的操作会欺骗他们自己的网络浏览器反射回针对用户的攻击。
存储攻击
-
一个网站可能会受到攻击,被欺骗存储恶意代码。
-
想象一下,有人可能会通过电子邮件发送恶意代码。如果电子邮件提供商盲目接受发送给它的任何代码,接收恶意代码的任何人可能都会成为攻击的受害者。
字符转义
-
服务使用 字符转义 作为防止此类攻击的一种方式。软件应该转义可能引起麻烦的字符,这些字符代表常见的基于编码的字符。
-
例如,以下代码...
<p>About 6,420,000,000 <script>alert('attack')</script></p>将由安全软件输出...
<p>About 6,420,000,000 <script>alert('attack')</script></p>虽然有点晦涩,但请注意
<用于转义可能对软件构成威胁的潜在字符。上述输出的结果变成了恶意代码的纯文本表示。 -
常见的转义字符包括:
-
<,这是小于号,“<” -
>,这是大于号,“>” -
&,这是和号,“&” -
",这是双引号,“, 本身 -
',这是单引号,“‘”
-
HTTP 头部
-
回想一下,HTTP 头部 是提供给浏览器的一些附加指令。
-
考虑以下头部:
Content-Security-Policy: script-src https://example.com/注意,上述网站头部的安全策略仅允许通过单独的文件加载 JavaScript,通常以
.js结尾。因此,当此安全策略生效时,HTML 中的<script>标签不会被浏览器运行。 -
同样,以下头部将只允许从
.css文件加载 CSS:Content-Security-Policy: style-src https://example.com/注意
style-src指示仅允许从.css文件加载的 CSS。
SQL 注入
-
结构化查询语言 或 SQL 是一种编程语言,允许从数据库中检索特定信息。
-
考虑攻击者可能如何尝试欺骗 SQL 执行恶意代码。
-
考虑以下 SQL 代码:
SELECT * FROM users WHERE username = '{username}'注意,这里插入的用户输入的用户名被插入到 SQL 代码中。
-
永远不要信任用户的输入。
-
所有输入都应清理,以确保所有用户输入都被转义。
-
假设攻击者将以下代码插入到用户名字段中:
malan'; DELETE FROM users; –-注意,除了用户名外,还插入了恶意代码。
-
由于上述输入,结果是以下内容:
SELECT * FROM users WHERE username = 'malan'; DELETE FROM users; --'注意,攻击者的恶意输入向查询中添加了额外的代码。结果是系统中的所有用户都被删除。系统上的每个账户都被删除。
-
假设用户被要求如下输入用户名和密码:
SELECT * FROM users WHERE username = '{username}' AND password = '{password}'注意用户被要求输入用户名和密码。
-
攻击者可能会将以下内容插入到密码字段中:
' OR '1'='1 -
然后,以下 SQL 代码将执行:
SELECT * FROM users WHERE username = 'malan' AND password = '' OR '1'='1'注意语法上,这会导致向数据库中的所有用户提供。
-
为了更清楚地看到这一点,注意下面添加了额外的括号:
SELECT * FROM users WHERE (username = 'malan' AND password = '') OR '1'='1'注意,此代码将显示所有用户名和密码组合为真的用户
OR所有用户。 -
事实上,上述输入始终为真。通过这种安全漏洞,攻击者可能了解系统上所有用户的信息,包括管理员。
预处理语句
-
预处理语句 是预先设计的代码片段,可以正确处理许多数据库功能,包括用户输入。
-
这样的语句,例如,确保用户输入的数据被正确转义。
-
预处理语句将采用以下代码…
SELECT * FROM users WHERE username = '{username}'并将其替换为…
SELECT * FROM users WHERE username = ? -
预处理语句将查找任何
'字符并将其替换为''。因此,我们上面显示的先前攻击将通过预处理语句呈现:SELECT * FROM users WHERE username = 'malan''; DELETE FROM users; --'注意,在“malan”的末尾的
'被替换为'',使恶意代码无法运行。 -
结果是恶意字符被转义,使得恶意代码无法运行。
命令注入
-
命令行界面 是一种使用基于文本的命令来运行计算机系统的方法,与点击菜单和按钮相反。
-
命令注入 攻击是在底层系统本身发出命令。
-
如果从用户输入传递命令到命令行,可能会造成灾难性的后果。
-
两个常见的漏洞点是
system和eval,在这些地方,如果你未对用户输入进行清理,恶意命令可能会在系统上执行。 -
总是阅读文档以了解如何转义用户的输入。
开发者工具
-
让我们回到 HTML 和网络的世界。
-
在浏览器上下文中,开发者工具 允许你探索网页中的一些底层代码。
-
考虑我们可以使用开发者工具做什么。以下是文本框的代码:
<input disabled type="checkbox">注意这创建了一种称为复选框的输入类型。此外,注意这个文本框已被禁用,无法通过
disabled属性使用。 -
也许 HTML 安全性的一个挑战是 HTML 驻留在用户的电脑上。因此,用户可能能够更改他们电脑上的本地文件。
-
拥有通过开发者工具访问自己电脑上 HTML 的用户可以更改 HTML。
<input type="checkbox">注意这里 HTML 的本地副本已移除
disabled属性。 -
你永远不应该只信任客户端验证。
-
类似地,考虑以下 HTML:
<input required type="text">注意这个文本输入是
required。 -
可以访问开发者工具的人可以取消此输入的要求,如下所示:
<input type="text">注意,已移除
required属性。 -
再次强调,永远不要相信客户端验证将确保你的 Web 应用程序的安全性。
服务器端验证
-
服务器端验证提供安全功能,以确保用户输入是适当和安全的。
-
虽然这个主题超出了本课程的范围,但只需相信一个原则:用户输入应该在服务器端进行验证。永远不要信任用户输入。
跨站请求伪造 (CSRF)
-
另一个威胁被称为跨站请求伪造或CSRF。
-
网站使用两种主要方法与用户交互,称为
GET和POST方法。 -
GET从服务器获取信息。 -
你可能会考虑亚马逊如何使用
GET方法来处理以下 HTML:<a href="https://www.amazon.com/dp/B07XLQ2FSK">Buy Now</a>注意单击一下就可以购买这个产品。
-
你可以想象如何欺骗某人购买他们不打算购买的东西。
-
一个人可以提供一个自动尝试购买产品的图片:
<img src="https://www.amazon.com/dp/B07XLQ2FSK">注意这里没有提供图片。相反,浏览器将尝试使用这个网页执行
GET方法,可能进行未经授权或不需要的购买。 -
同样,对手可以使用
POST方法进行未经授权的购买。 -
考虑以下“立即购买”按钮的 HTML 代码:
<form action="https://www.amazon.com/" method="post"> <input name="dp" type="hidden" value="B07XLQ2FSK"> <button type="submit">Buy Now</button> </form>注意一个网页表单,如上所述实现,可能会让人天真地认为自己是安全的,免受未经授权的购买。因为这个表单包含一个亚马逊用于验证的
hidden值,从理论上讲,它可能会让程序员认为用户是安全的。 -
然而,正如许多漏洞的情况一样,这种安全感是错误的。
-
事实上,只需添加几行代码就可以颠覆上述情况。想象一下,一个对手在自己的网站上(不是亚马逊的)有以下代码:
<form action="https://www.amazon.com/" method="post"> <input name="dp" type="hidden" value="B07XLQ2FSK"> <button type="submit">Buy Now</button> </form> <script> document.forms[0].submit(); </script>注意一个对手网站上的几行代码可以定位到一个表单并自动提交它。
-
这种欺骗用户在另一个网站上执行命令的能力是 CSRF(跨站请求伪造)的本质。
-
保护此类攻击的一种方法是在每个用户处由服务器生成一个秘密值的CSRF 令牌。因此,服务器将验证用户提交的 CSRF 令牌是否与服务器期望的令牌匹配。
-
这些令牌通常通过 HTML 头提交。
随意代码执行 (ACE)
-
随意代码执行,或 ACE,是在软件中执行不属于预期代码的行为。
-
其中一种威胁被称为 缓冲区溢出,其中软件被输入所淹没。这种输入溢出到内存的其他区域,导致程序故障。例如,软件可能期望输入短长度,但用户输入了大量长度的输入。
-
另一个类似的威胁被称为 栈溢出,其中溢出可以用来插入和执行恶意代码。
-
有时,这样的攻击可以用于 破解 或绕过注册或付费使用软件的需求。
-
此外,这些攻击可以用于 逆向工程 来查看代码的功能。
开源软件
-
绕过这种威胁的一种方法是通过使用和创建 开源软件。这种软件的代码可以轻松在线发布,任何人都可以查看。
-
可以审计代码,确保安全威胁更少。
-
这些软件仍然容易受到攻击。
封闭源代码软件
-
封闭源代码软件 是开源软件的对立面。
-
这种软件的代码对公众不可用,因此可能对对手的攻击更不脆弱。
-
然而,在开源软件(数千双眼睛在寻找软件中的漏洞)和封闭源代码软件(代码对公众隐藏)之间有一个权衡。
应用商店
-
应用商店 由谷歌和苹果等实体运营,他们监控提交的代码是否存在对抗意图。
-
当你只安装授权软件时,你比安装任何开发者提供的软件(不使用应用商店)要安全得多。
-
应用商店使用加密技术,只接受由授权开发者签名的软件或代码。反过来,应用商店使用数字签名来签名软件。因此,操作系统可以确保只安装了授权的、签名的软件。
软件包管理器
-
软件包管理器 采用类似的签名机制,以确保从第三方下载的内容是可信的。然而,并不能保证完全安全。
-
尽管如此,我们总是试图提高对手安装对抗性代码的门槛。
缺陷赏金
-
缺陷赏金 是个人发现并报告软件漏洞的付费机会。
-
这样的赏金可能会有效地影响潜在的对手,使他们选择为了发现漏洞而获得报酬,而不是作为攻击者部署它们。
识别漏洞
-
开发者可以检查 常见漏洞和暴露 或 CVE 编号数据库,以了解全球对手在做什么。
-
此外,他们可能会检查 常见漏洞评分系统 或 CVSS,以了解这种威胁的严重程度。
-
此外,还有一个 漏洞预测评分系统 或 EPSS,允许开发者看到全球漏洞的潜在风险,以便他们优先考虑他们的安全工作。
-
已知的漏洞利用 或 KEV 数据库是已知漏洞的列表。
总结
在本课中,你学习了如何确保软件的安全性。你学习了...
-
对手如何利用钓鱼、代码注入、反射攻击、SQL 注入和存储攻击等攻击手段渗透软件;
-
字符转义、HTML 头、预编译语句和服务器端验证如何帮助阻止对手的攻击;
-
应用商店、包管理器和开发者签名如何帮助防止恶意代码的安装;
-
网络安全领域的专家如何追踪漏洞利用。
次次见!
第四讲
-
保留隐私
-
网页浏览历史
-
HTTP 头部信息
-
指纹识别
-
会话 Cookies
-
跟踪 Cookie
-
跟踪参数
-
第三方 Cookies
-
私密浏览
-
超级 Cookies
-
DNS
-
虚拟私人网络(VPN)
-
Tor
-
权限
-
总结
保留隐私
-
这本是 CS50 的网络安全入门课程。
-
今天,让我们考虑我们在不知情的情况下分享了哪些信息,以及我们如何限制这种分享。
网页浏览历史
-
你的浏览历史记录既是功能也是对隐私的潜在威胁。
-
你可能不希望有人访问你访问过的网站。
-
你可以清除浏览器历史记录。然而,你可能会从所有服务中注销。
-
服务器通常会有日志来跟踪用户活动。因此,即使你清除了浏览器历史记录,服务器仍然会记录你访问的内容。
-
服务器日志可能如下所示:
log_format combined '$remote_addr - $remote_user [$time_local] ' '"$request" $status $body_bytes_sent ' '"$http_referer" "$http_user_agent"';注意这包括你的 IP 地址、你的本地时间以及其他细节,这些都在计算机之间共享的数字信封中。
-
我们如何控制我们可以分享的内容?
HTTP 头部信息
-
正如我们讨论的,HTTP 头部信息是在你的计算机和服务器之间发送的关键值对。
-
考虑以下可能通过以下 HTML 文件中的链接访问的 URL。
<a href="https://example.com">cats</a>这个 HTML 展示了一个名为 cats 的链接,将用户导向
example.com。 -
当你访问一个网站时,浏览器默认会分享将你带到那里的链接。
-
当你点击一个链接时,浏览器会与网站分享是什么网站将你带过去的。因此,以下头部信息是从浏览器发送到服务器的:
Referer: https://www.google.com/search?q=cats注意这个头部信息分享了你在搜索什么。
-
如果能够抑制共享的内容不是很好吗?考虑以下:
Referer: https://www.google.com/注意以下只共享来源:不是你正在进行的特定搜索。
-
以下元标签可以添加到你的网站中,以限制只共享流量来源。
<meta name="referrer" content="origin">注意
content属性被设置为origin。 -
可以通过在你的网站中添加以下内容来进一步限制,以提供无引用信息。
<meta name="referrer" content="none">注意
content属性被设置为none。
指纹识别
-
每个浏览器比其他浏览器更多地或更少地展示你的身份和行为信息。
-
无论你选择哪种浏览器,服务器都会记录你的活动。
-
指纹识别是一种第三方可以根据可用的线索识别你的方式,即使你在尽可能限制浏览器分享你信息的情况下。
-
其中一条信息是 User-Agent 请求头,它如下描述你的设备:
Mozilla/5.0 (Linux; {Android Version}; {Build Tag etc.}) AppleWebKit/{WebKit Rev} (KHTML, like Gecko) Chrome/{Chrome Rev} Mobile Safari/{WebKit Rev}注意到你的浏览器、操作系统版本和设备被识别。
-
Web 服务器还可以定位你的 IP 地址并记录它。
-
Web 服务器还可以发现你的屏幕分辨率、已安装的扩展、已安装的字体和其他信息。
-
当这些信息随着时间的推移而汇总时,它可以使你越来越容易被识别。
会话 Cookies
-
记忆 cookies 就像一个虚拟的手章,用于跟踪你个人。
-
Session cookies 是服务器放在你的电脑上以识别你的信息。
-
一个会话 cookie 可能如下所示:
HTTP/3 200 Set-Cookie: session=1234abcd注意到这个 cookie 告诉服务器你的会话是
1234abcd。 -
每个用户的会话编号或字符序列都是唯一的。
-
会话 cookies 通常在服务器确定的某个时间段后过期。
跟踪 Cookie
-
Tracking cookies 是设计用来跟踪你的。
-
第三方使用此类 cookies 来跟踪你在网站上的行为。考虑以下情况:
Set-Cookie: _ga=GA1.2.0123456789.0; max-age=63072000注意到这个 Google Analytics cookie 持续两年,并通过向每个你访问的新网站展示自己来跟踪你的活动。
跟踪参数
-
在 cookies 隐藏在浏览器“引擎盖下”的地方,tracking parameters 在你访问的链接中是可见的。
-
考虑以下 URL:
https://example.com/ad_engagement?click_id=YmVhODI1MmZmNGU4&campaign_id=23注意到
click_id的值YmVhODI1MmZmNGU4专门跟踪你。 -
当 cookies 在后台被跟踪时,你可以看到你访问的链接(基于 URL)如何跟踪你。
-
越来越多,浏览器倾向于清理跟踪参数。考虑以下 URL:
https://example.com/ad_engagement?campaign_id=23注意到这个链接 仅 跟踪你响应的活动。
click_id的值不再存在。
第三方 Cookies
-
另一种类型的 cookie 是 third-party cookie。
-
第三方(即其他服务器或公司)希望了解你如何在网站之间旅行。考虑以下 HTTP 请求:
GET /ad.gif HTTP/3 Host: example.com Referer: https://harvard.edu/注意到这个请求明确要求从
example.com获取一个名为ad.gif的文件。 -
自动地,服务器会响应以下头信息:
HTTP/3 200 Set-Cookie: id=1234abcd; max-age=31536000Set-Cookie响应头设置了一个名为id的 cookie,其持续时间为三年。 -
如果你浏览了使用相同广告的另一个网站,
example.com现在知道你正在浏览harvard.edu和yale.edu。假设你后来发出了以下 HTTP 请求:GET /ad.gif HTTP/3 Cookie: id=1234abcd Host: example.com Referer: https://yale.edu/注意到之前提到的第三方 cookie
id=1234abcd现在再次显示给example.com,从而揭示你后来访问了yale.edu。 -
第三方 cookies 可以用来跟踪我们并货币化关于我们的信息。
私人浏览
-
一种帮助保护你活动的方法是 private browsing。
-
在一个私人浏览窗口或标签页中,过去的 cookies 会被消除。
-
尽管如此,网络仍然按照网络的方式运行!在私人浏览窗口的生态系统中仍然可以形成新的 cookies。
-
更重要的是,服务器仍然可以跟踪您在单个浏览会话中的活动。
超级 cookie
-
提供您互联网服务的人可以始终在您的 HTTP 标头中注入他们自己的 cookie,而您可能并不知道。
-
您可能可以通过您的互联网提供商选择退出超级 cookie。
DNS
-
域名系统 或 DNS 是一种服务,通过该服务可以将网站名称,如
harvard.edu,解析为特定的 IP 地址。 -
按惯例,DNS 的流量完全是未加密的。因此,您向全世界宣布了您试图访问的网站。
-
您的互联网服务提供商和 DNS 服务知道您试图访问的确切位置。
-
另一种称为 HTTPS over DNS 或 DoH,以及 TLS over DNS 或 DoT 的替代方案,是一种您可以通过它来加密您的 DNS 请求的服务。
虚拟私人网络 (VPN)
-
请记住,VPNs 是一种连接互联网的方式,使得看起来您似乎是从不同的设备进行连接。
-
VPNs 建立了您自己的电脑 (
A) 和一个受信任的服务器 (B) 之间的加密连接。然后服务器B将您的请求发送到互联网,因此看起来您的流量似乎来自B而不是A。 -
如果您的电脑感染了恶意软件,VPN 不能保护您。
-
VPN 会使得您的流量看起来似乎来自 VPN 的 IP 地址而不是您自己的电脑。
Tor
-
Tor 是一种软件,它将您的流量重定向到 Tor 服务器的一个节点。
-
流量将通过许多加密节点进行导向。
-
按设计,软件不会记住您的大部分活动。
-
利用此类服务提供了很高的可能性,几乎无法识别关于您的信息。
-
然而,请注意,如果您是唯一在工作场所或学校本地网络中使用 Tor 的人,通过其他手段完全有可能识别出您是谁。
-
没有任何技术能为您提供绝对的保护。
权限
-
操作系统按照惯例,会请求在您的设备上使用某些权限。
-
基于位置的服务 默认情况下会提供您的地理位置。最好注意,如果您向 Apple Maps 和 Google Maps 提供此类权限,它们非常清楚您在任何给定时间的位置。
总结
在本课程中,我们讨论了...
-
许多课程,希望已经提高了您对提供给第三方信息的认识;
-
计算机中、服务器中、软件中以及您整体隐私中的漏洞是如何产生的;
-
您如何通过提高意识来减轻这些漏洞;以及
-
您如何更好地管理您和您服务的他人的隐私。
这就是 CS50 的网络安全入门课程。
Python
第零讲
-
使用 Python 创建代码
-
函数
-
错误
-
提升你的第一个 Python 程序
-
变量
-
注释
-
伪代码
-
-
进一步改进你的第一个 Python 程序
-
字符串和参数
- 引号的小问题
-
字符串格式化
-
更多关于字符串
-
整数或 int
-
可读性优先
-
浮点数基础
-
更多关于浮点数
-
定义
-
返回值
-
总结
使用 Python 创建代码
-
VS Code 是一个文本编辑器。除了编辑文本外,你还可以在终端中可视化浏览文件和运行基于文本的命令。
-
在终端中,你可以执行
code hello.py来开始编码。 -
在上面的文本编辑器中,你可以输入
print("hello, world")。这是一个著名的经典程序,几乎所有的程序员在学习过程中都会编写。 -
在终端窗口中,你可以执行命令。为了运行此程序,你需要将光标移至屏幕底部,在终端窗口中点击。现在你可以在终端窗口中输入第二个命令。在美元符号旁边,输入
python hello.py并按下键盘上的回车键。 -
回想一下,计算机实际上只理解零和一。因此,当你运行
python hello.py时,Python 将解释你在hello.py中创建的文本,并将其转换为计算机可以理解的零和一。 -
运行
python hello.py程序的结果是hello, world。 -
恭喜!你刚刚创建了你的第一个程序。
函数
-
函数是计算机或计算机语言已经知道如何执行的动作或动词。
-
在你的
hello.py程序中,print函数知道如何将内容打印到终端窗口。 -
print函数接受参数。在这种情况下,"hello, world"是print函数接受的参数。
错误
-
错误是编码的自然部分。这些都是你需要解决的问题!不要气馁!这是成为优秀程序员过程的一部分。
-
想象一下,在我们的
hello.py程序中,我们不小心输入了print("hello, world",忘记了print函数所需的最后的)。如果你犯了这个错误,解释器将在终端窗口中输出错误信息! -
错误信息通常会告诉你你的错误,并提供如何修复它们的线索。然而,会有很多次解释器并不那么有帮助。
提升你的第一个 Python 程序
-
我们可以个性化你的第一个 Python 程序。
-
在我们的
hello.py文本编辑器中,我们可以添加另一个函数。input是一个接受提示作为参数的函数。我们可以编辑我们的代码如下:input("What's your name? ") print("hello, world") -
然而,仅此修改并不能让程序输出用户输入的内容。为此,我们需要向您介绍变量
变量
-
变量只是在你自己的程序中存储值的容器。
-
在你的程序中,你可以通过编辑它来引入你自己的变量,如下所示:
name = input("What's your name? ") print("hello, world")注意到在
name = input("What's your name? ")中间的这个等号=在编程中有一个特殊的作用。这个等号实际上是将右侧的内容赋值给左侧。因此,input("What's your name? ")返回的值被赋值给name。 -
如果你按照如下方式编辑你的代码,你会注意到一个意外的结果:
name = input("What's your name? ") print("hello, name") -
无论用户输入什么,程序都会在终端窗口返回
hello, name。 -
进一步编辑我们的代码,你可以输入
name = input("What's your name? ") print("hello,") print(name) -
在终端窗口的结果将是
What's your name? David hello David -
我们正在接近我们可能期望的结果!
-
你可以在 Python 的 数据类型 文档中了解更多。
注释
-
注释是程序员跟踪他们在程序中所做事情的一种方式,甚至可以向其他人传达他们对于一段代码的意图。简而言之,它们是你和将看到你代码的其他人的笔记!
-
你可以在你的程序中添加注释,以便能够看到程序正在做什么。你可能编辑你的代码如下:
# Ask the user for their name name = input("What's your name? ") print("hello,") print(name) -
注释也可以作为你的待办事项列表。
模拟代码
-
模拟代码是一种重要的注释类型,它成为了一种特殊类型的待办事项列表,尤其是在你不理解如何完成编码任务时。例如,在你的代码中,你可能编辑你的代码如下:
# Ask the user for their name name = input("What's your name? ") # Print hello print("hello,") # Print the name inputted print(name)
进一步改进您的第一个 Python 程序
-
我们可以进一步编辑我们的代码如下:
# Ask the user for their name name = input("What's your name? ") # Print hello and the inputted name print("hello, " + name) -
结果表明,一些函数接受许多参数。
-
我们可以通过如下编辑我们的代码来使用逗号
,传递多个参数:# Ask the user for their name name = input("What's your name? ") # Print hello and the inputted name print("hello,", name)如果我们在终端输入“David”,输出将是
hello, David。成功。
字符串和参数
-
在 Python 中,字符串被称为
str,是一系列文本。 -
在我们的代码中回滚一点,回到以下内容,结果出现在多行上有一个视觉上的副作用:
# Ask the user for their name name = input("What's your name? ") print("hello,") print(name) -
函数通过参数影响其行为。如果我们查看
print函数的文档(print),你会注意到我们可以了解很多关于print函数所接受的参数。 -
通过查看此文档,你会了解到
print函数自动包含参数end='\n'。这个\n表示当运行时print函数将自动创建换行。该函数接受一个名为end的参数,默认情况下是创建新行。 -
然而,我们可以技术上为
end参数提供一个参数,这样就不会创建新行! -
我们可以如下修改我们的代码:
# Ask the user for their name name = input("What's your name? ") print("hello,", end="") print(name)通过提供
end="",我们正在覆盖end的默认值,这样它就不会在第一个打印语句之后创建新行。提供名字“David”,终端窗口中的输出将是hello, David。 -
参数因此是函数可以接受的参数。
-
你可以在 Python 的文档中了解更多关于
print的信息这里。
关于引号的一个小问题
-
注意将引号作为字符串的一部分添加进来是有挑战性的。
-
print("hello,"friend"")将不会工作,解释器会抛出错误。 -
通常,有两种方法可以解决这个问题。首先,你可以简单地更改引号为单引号。
-
另一种更常用的方法是编写
print("hello, \"friend\"")。反斜杠告诉解释器,接下来的字符应该被视为字符串中的引号,并避免解释器错误。
格式化字符串
-
使用字符串最优雅的方式可能是以下这样:
# Ask the user for their name name = input("What's your name? ") print(f"hello, {name}")注意
print(f"hello, {name}")中的f。这个f是 Python 特殊处理字符串的指示符,与我们在这次讲座中展示的先前方法不同。预期你将在本课程中频繁使用这种字符串风格。
更多关于字符串的内容
-
你永远不应该期望你的用户会按预期合作。因此,你需要确保用户的输入被纠正或检查。
-
结果表明,字符串中内置了移除字符串中空白字符的能力。
-
通过在
name上使用strip方法(例如,name = name.strip()),你将移除用户输入的左右两侧的所有空白字符。你可以修改你的代码如下:# Ask the user for their name name = input("What's your name? ") # Remove whitespace from the str name = name.strip() # Print the output print(f"hello, {name}")重新运行这个程序,无论你在名字前后输入多少空格,它都会移除所有空白字符。
-
使用
title方法,它会将用户的名字转换为大写首字母:# Ask the user for their name name = input("What's your name? ") # Remove whitespace from the str name = name.strip() # Capitalize the first letter of each word name = name.title() # Print the output print(f"hello, {name}") -
到目前为止,你可能已经厌倦了在终端窗口中反复输入
python。你可以使用键盘上的上箭头键来回忆你最近输入的终端命令。 -
注意你可以修改你的代码使其更高效:
# Ask the user for their name name = input("What's your name? ") # Remove whitespace from the str and capitalize the first letter of each word name = name.strip().title() # Print the output print(f"hello, {name}") -
我们甚至可以更进一步!
# Ask the user for their name, remove whitespace from the str and capitalize the first letter of each word name = input("What's your name? ").strip().title() # Print the output print(f"hello, {name}") -
你可以在 Python 的文档中了解更多关于字符串的信息这里
整数或 int
-
在 Python 中,整数被称为
int。 -
在数学的世界里,我们熟悉 +, -, *, / 和 % 运算符。最后一个运算符
%或取模运算符可能对你来说并不那么熟悉。 -
你不必使用文本编辑器窗口来运行 Python 代码。在你的终端中,你可以单独运行
python。你将在终端窗口中看到>>>。然后你可以运行实时、交互式代码。你可以输入1+1,然后它会运行这个计算。这种模式在本课程中不太常用。 -
再次打开 VS Code,我们可以在终端中键入
code calculator.py。这将创建一个新文件,我们将在这个文件中创建自己的计算器。 -
首先,我们可以声明一些变量。
x = 1 y = 2 z = x + y print(z)自然地,当我们运行
python calculator.py时,我们在终端窗口中得到的答案是3。我们可以使用input函数使其更具交互性。x = input("What's x? ") y = input("What's y? ") z = x + y print(z) -
运行这个程序,我们发现输出是不正确的,为
12。这可能是为什么? -
在此之前,我们已经看到了如何使用
+符号连接两个字符串。因为你的计算机键盘输入在解释器中作为文本传入,所以它被视为一个字符串。因此,我们需要将这个输入从字符串转换为整数。我们可以这样做:x = input("What's x? ") y = input("What's y? ") z = int(x) + int(y) print(z)现在的结果是正确的。使用
int(x)被称为“类型转换”,其中值从一种类型的变量(在这种情况下,是一个字符串)临时转换为另一种类型(在这里,是一个整数)。 -
我们可以进一步改进我们的程序如下:
x = int(input("What's x? ")) y = int(input("What's y? ")) print(x + y)这说明你可以对函数运行函数。内部函数首先运行,然后是外部函数。首先运行
input函数,然后是int函数。 -
你可以在 Python 的
int函数文档中了解更多信息,链接为int。
可读性为王
-
在决定你的编码任务的方法时,请记住,对于同一个问题,人们可以为许多方法做出合理的论证。
-
无论你采取什么方法来处理编程任务,请记住,你的代码必须是可读的。你应该使用注释来给自己和他人提供关于你的代码正在做什么的线索。此外,你应该以可读的方式编写代码。
浮点数基础
-
浮点数是一个包含小数点的实数,例如
0.52。 -
你可以修改你的代码以支持浮点数如下:
x = float(input("What's x? ")) y = float(input("What's y? ")) print(x + y)这个更改允许用户输入
1.2和3.4以显示总数4.6。 -
然而,让我们假设你想要将总数四舍五入到最接近的整数。查看 Python 文档中
round函数,你会看到可用的参数是round(number[, ndigits])。那些方括号表示程序员可以指定某些可选内容。因此,你可以这样做round(n)来将数字四舍五入到最近的整数。或者,你可以按照以下方式编写代码:# Get the user's input x = float(input("What's x? ")) y = float(input("What's y? ")) # Create a rounded result z = round(x + y) # Print the result print(z)输出将被四舍五入到最接近的整数。
-
如果我们想要格式化长数字的输出呢?例如,而不是看到
1000,你可能希望看到1,000。你可以按照以下方式修改你的代码:# Get the user's input x = float(input("What's x? ")) y = float(input("What's y? ")) # Create a rounded result z = round(x + y) # Print the formatted result print(f"{z:,}")虽然相当晦涩,但
print(f"{z:,}")创建了一个场景,其中输出的z将在可能看起来像1,000或2,500的地方包含逗号。
更多关于浮点数的内容
-
我们如何四舍五入浮点数?首先,修改你的代码如下:
# Get the user's input x = float(input("What's x? ")) y = float(input("What's y? ")) # Calculate the result z = x / y # Print the result print(z)当输入
2作为 x 和3作为 y 时,结果 z 是0.6666666666,看起来像我们预期的那样无限期地继续下去。 -
让我们假设我们想要向下取整。我们可以修改我们的代码如下:
# Get the user's input x = float(input("What's x? ")) y = float(input("What's y? ")) # Calculate the result and round z = round(x / y, 2) # Print the result print(z)如我们所预期的那样,这将把结果四舍五入到最接近的两个小数位。
-
我们也可以使用
f-string格式化输出,如下所示:# Get the user's input x = float(input("What's x? ")) y = float(input("What's y? ")) # Calculate the result z = x / y # Print the result print(f"{z:.2f}")这个神秘的
f-string代码显示的结果与我们的先前的四舍五入策略相同。 -
你可以在 Python 的
float函数文档中了解更多信息:float。
Def
-
不是很想创建我们自己的函数吗?
-
让我们在终端窗口中通过输入
code hello.py来恢复hello.py的最终代码。你的起始代码应该如下所示:# Ask the user for their name, remove whitespace from the str and capitalize the first letter of each word name = input("What's your name? ").strip().title() # Print the output print(f"hello, {name}")我们可以改进我们的代码,创建一个为我们说“hello”的专用函数!
-
在我们的文本编辑器中删除所有代码,让我们从头开始:
name = input("What's your name? ") hello() print(name)尝试运行此代码,你的解释器将抛出一个错误。毕竟,没有为
hello定义函数。 -
我们可以创建一个名为
hello的函数,如下所示:def hello(): print("hello") name = input("What's your name? ") hello() print(name)注意,
def hello()下的所有内容都需要缩进。Python 是一种缩进语言。它使用缩进来理解哪些内容属于上述函数的一部分。因此,hello函数中的所有内容都必须缩进。如果某项内容没有缩进,它会被视为不在hello函数内部。在终端窗口中运行python hello.py,你会看到你的输出并不完全如你所愿。 -
我们可以进一步改进我们的代码:
# Create our own function def hello(to): print("hello,", to) # Output using our own function name = input("What's your name? ") hello(name)在这里,在第一行,你正在创建你的
hello函数。然而,这次你正在告诉解释器这个函数接受一个参数:一个名为to的变量。因此,当你调用hello(name)时,计算机将name作为to传递给hello函数。这就是我们将值传递给函数的方式。非常有用!在终端窗口中运行python hello.py,你会看到输出更接近我们在这堂课中早些时候提出的理想输出。 -
我们可以修改我们的代码,给
hello添加一个默认值:# Create our own function def hello(to="world"): print("hello,", to) # Output using our own function name = input("What's your name? ") hello(name) # Output without passing the expected arguments hello()自己测试一下你的代码。注意第一个
hello的行为可能正如你所预期,而第二个hello,由于没有传递值,将默认输出hello, world。 -
我们不必在程序的开头就有我们的函数。我们可以将其向下移动,但我们需要告诉解释器我们有一个
main函数和一个单独的hello函数。def main(): # Output using our own function name = input("What's your name? ") hello(name) # Output without passing the expected arguments hello() # Create our own function def hello(to="world"): print("hello,", to)然而,这单独的步骤将创建某种错误。如果我们运行
python hello.py,什么都不会发生!原因在于,这段代码中没有任何内容实际调用main函数并使我们的程序活跃起来。 -
以下非常小的修改将调用
main函数并使我们的程序恢复正常:def main(): # Output using our own function name = input("What's your name? ") hello(name) # Output without passing the expected arguments hello() # Create our own function def hello(to="world"): print("hello,", to) main()
返回值
-
你可以想象许多场景,在这些场景中,你不仅希望函数执行一个动作,还希望它将值返回到主函数。例如,与其仅仅打印
x + y的计算结果,你可能会希望函数将这个计算的结果返回到程序的另一部分。我们称这种“返回”值为return值。 -
通过输入
code calculator.py返回到我们的calculator.py代码。删除那里的所有代码。按照以下方式重新编写代码:def main(): x = int(input("What's x? ")) print("x squared is", square(x)) def square(n): return n * n main()实际上,
x被传递给square。然后,x * x的计算结果被返回到主函数。
总结
通过本节课的工作,你学到了将在自己的程序中无数次使用的技能。你学习了关于…
-
在 Python 中创建你的第一个程序;
-
函数;
-
错误;
-
变量;
-
注释;
-
模拟代码;
-
字符串;
-
参数;
-
格式化字符串;
-
整数;
-
可读性原则;
-
浮点数;
-
创建你自己的函数;以及
-
返回值。
第一讲
-
条件语句
-
if 语句
-
控制流、elif 和 else
-
或
-
且
-
取模
-
创建我们自己的奇偶函数
-
Pythonic
-
match
-
总结
条件语句
-
条件语句允许你,作为程序员,让你的程序做出决定:就像你的程序根据某些条件在左边的路或右边的路之间做出选择。
-
条件语句允许你的程序做出决定,根据指定的条件选择一条路径而不是另一条路径。
-
Python 内置了一套“运算符”,用于提出数学问题。
-
>和<符号你可能很熟悉。 -
>=表示“大于或等于”。 -
<=表示“小于或等于”。 -
==表示“等于”。注意双等号:单个等号用于赋值,而两个等号用于比较值。 -
!=表示“不等于”。 -
条件语句比较左边的项与右边的项。
if 语句
-
在你的终端窗口中,键入
code compare.py。这将创建一个名为“compare”的新文件。 -
在文本编辑器窗口中,开始如下:
x = int(input("What's x? ")) y = int(input("What's y? ")) if x < y: print("x is less than y")注意你的程序如何接受用户对 x 和 y 的输入,将它们作为整数转换并保存到各自的 x 和 y 变量中。然后,
if语句比较 x 和 y。如果满足x < y的条件,则执行print语句。 -
if语句使用bool(布尔)值(True或False)来决定是否执行代码。如果比较x > y的结果是True,解释器将运行缩进的代码块。
控制流、elif 和 else
-
进一步修改你的代码如下:
x = int(input("What's x? ")) y = int(input("What's y? ")) if x < y: print("x is less than y") if x > y: print("x is greater than y") if x == y: print("x is equal to y")注意你提供了一系列
if语句。首先,评估第一个if语句。然后,执行第二个if语句的评估。最后,执行最后一个if语句的评估。这种决策流程称为“控制流”。 -
我们的代码可以表示如下:
flowchart TD A([start]) --> B{x < y} B -- True --> C["#quot;x is less than y#quot;"] C --> D{x > y} D -- True --> E["#quot;x is greater than y#quot;"] E --> F{x == y} F -- True --> G["#quot;x is equal to y#quot;"] G --> H([stop]) B -- False --> D D -- False --> F F -- False --> H -
这个程序可以通过不连续问三个问题来改进。毕竟,不是所有三个问题都能得到
true的结果!按照以下方式修改你的程序:x = int(input("What's x? ")) y = int(input("What's y? ")) if x < y: print("x is less than y") elif x > y: print("x is greater than y") elif x == y: print("x is equal to y")注意
elif的使用如何使程序做出更少的决策。首先,评估if语句。如果这个语句被评估为真,则不会运行所有的elif语句。然而,如果if语句被评估并发现为假,则第一个elif将被评估。如果是真的,它将不会运行最终的评估。 -
我们的代码可以表示如下:
flowchart TD A([start]) --> B{x < y} B -- True --> C["#quot;x is less than y#quot;"] B -- False --> D{x > y} D -- True --> E["#quot;x is greater than y#quot;"] D -- False --> F{x == y} F -- True --> G["#quot;x is equal to y#quot;"] G --> H([stop]) F -- False --> H C --> H E --> H -
虽然你的电脑可能在速度上没有注意到我们的第一个程序和这个修订程序之间的差异,但考虑一下,一个每天运行数十亿或数万亿此类计算的在线服务器,这样的小代码决策肯定会有影响。
-
我们可以对我们的程序进行最后一次改进。注意
elif x == y在逻辑上不是必须的评估。毕竟,如果逻辑上 x 不小于 y 且 x 不大于 y,那么 x 一定等于 y。因此,我们不需要运行elif x == y。我们可以使用else语句创建一个“通配符”,默认结果。我们可以这样修改:x = int(input("What's x? ")) y = int(input("What's y? ")) if x < y: print("x is less than y") elif x > y: print("x is greater than y") else: print("x is equal to y")注意到通过我们的修订,这个程序的相对复杂性已经降低。
-
我们的代码可以表示如下:
flowchart TD A([start]) --> B{x < y} B -- True --> C["#quot;x is less than y#quot;"] B -- False --> D{x > y} D -- True --> E["#quot;x is greater than y#quot;"] D -- False --> F["#quot;x is equal to y#quot;"] F --> G([stop]) C --> G E --> G
或
-
or允许程序在一种或多种选择之间做出决定。例如,我们可以进一步编辑我们的程序如下:x = int(input("What's x? ")) y = int(input("What's y? ")) if x < y or x > y: print("x is not equal to y") else: print("x is equal to y")注意到我们的程序结果相同,但复杂性降低。代码的效率提高了。
-
到目前为止,我们的代码相当不错。然而,设计是否可以进一步改进?我们可以进一步编辑我们的代码如下:
x = int(input("What's x? ")) y = int(input("What's y? ")) if x != y: print("x is not equal to y") else: print("x is equal to y")注意到我们完全移除了
or,只是简单地问,“x 是否不等于 y?”我们只问一个问题。非常高效! -
为了说明,我们也可以将代码修改如下:
x = int(input("What's x? ")) y = int(input("What's y? ")) if x == y: print("x is equal to y") else: print("x is not equal to y")注意到
==操作符用于判断左边的值和右边的值是否相等。使用双等号非常重要。如果你只使用一个等号,解释器可能会抛出一个错误。 -
我们可以将代码表示如下:
flowchart TD A([start]) --> B{x == y} B -- True --> C["#quot;x is equal to y#quot;"] B -- False --> E["#quot;x is not equal to y#quot;"] C --> F([stop]) E --> F
和
-
与
or类似,and也可以在条件语句中使用。 -
在终端窗口中执行
code grade.py。启动你的新程序如下:score = int(input("Score: ")) if score >= 90 and score <= 100: print("Grade: A") elif score >=80 and score < 90: print("Grade: B") elif score >=70 and score < 80: print("Grade: C") elif score >=60 and score < 70: print("Grade: D") else: print("Grade: F")注意到通过执行
python grade.py,你将能够输入一个分数并得到一个等级。然而,请注意这里存在潜在的错误。 -
通常,我们不想让用户输入正确信息。我们可以这样改进我们的代码:
score = int(input("Score: ")) if 90 <= score <= 100: print("Grade: A") elif 80 <= score < 90: print("Grade: B") elif 70 <= score < 80: print("Grade: C") elif 60 <= score < 70: print("Grade: D") else: print("Grade: F")注意到 Python 允许你以其他编程语言中相当不常见的方式链接着操作符和条件。
-
尽管如此,我们还可以进一步改进我们的程序:
score = int(input("Score: ")) if score >= 90: print("Grade: A") elif score >= 80: print("Grade: B") elif score >= 70: print("Grade: C") elif score >= 60: print("Grade: D") else: print("Grade: F")注意到通过减少问题数量,程序得到了改进。这使得我们的程序更容易阅读,并且在未来的维护中更加高效。
-
你可以在 Python 的文档中了解更多关于控制流的信息。
取模
-
在数学中,奇偶性指的是一个数是偶数还是奇数。
-
编程中的取模
%操作符允许你查看两个数是否能够整除,或者除后是否有余数。 -
例如,4 % 2 的结果将是零,因为它可以整除。然而,3 % 2 不能整除,结果将是一个非零的数字!
-
在终端窗口中,通过输入
code parity.py创建一个新的程序。在文本编辑器窗口中,输入以下代码:x = int(input("What's x? ")) if x % 2 == 0: print("Even") else: print("Odd")注意我们的用户可以输入任何大于等于 1 的数字来查看它是否为偶数或奇数。
创建我们自己的偶奇函数
-
如在第六讲 中讨论的,你会发现创建自己的函数很有用!
-
我们可以创建自己的函数来检查一个数字是否为偶数或奇数。按照以下方式调整你的代码:
def main(): x = int(input("What's x? ")) if is_even(x): print("Even") else: print("Odd") def is_even(n): if n % 2 == 0: return True else: return False main()注意到我们的
if语句is_even(x)即使没有操作符也能正常工作。这是因为我们的函数返回一个bool(布尔值),True或False,并将其返回给主函数。if语句只是简单地评估x的is_even是否为真或假。
Pythonic
-
在编程世界中,有一些编程类型被称为“Pythonic”的编程。也就是说,有一些编程方式只在 Python 编程中看到。考虑以下程序的修订版:
def main(): x = int(input("What's x? ")) if is_even(x): print("Even") else: print("Odd") def is_even(n): return True if n % 2 == 0 else False main()注意到我们代码中的这个返回语句几乎就像一个英文句子。这是仅在 Python 中才能看到的独特编码方式。
-
我们可以进一步修改代码,使其更加易读:
def main(): x = int(input("What's x? ")) if is_even(x): print("Even") else: print("Odd") def is_even(n): return n % 2 == 0 main()注意程序将评估
n % 2 == 0的结果,将其视为True或False,并将其简单地返回给主函数。
match
-
与
if、elif和else语句类似,match语句可以用来有条件地运行与某些值匹配的代码。 -
考虑以下程序:
name = input("What's your name? ") if name == "Harry": print("Gryffindor") elif name == "Hermione": print("Gryffindor") elif name == "Ron": print("Gryffindor") elif name == "Draco": print("Slytherin") else: print("Who?")注意前三个条件语句打印了相同的响应。
-
我们可以使用
or关键字稍微改进这段代码:name = input("What's your name? ") if name == "Harry" or name == "Hermione" or name == "Ron": print("Gryffindor") elif name == "Draco": print("Slytherin") else: print("Who?")注意
elif语句的数量减少了,这提高了代码的可读性。 -
或者,我们可以使用
match语句将名称映射到房屋。考虑以下代码:name = input("What's your name? ") match name: case "Harry": print("Gryffindor") case "Hermione": print("Gryffindor") case "Ron": print("Gryffindor") case "Draco": print("Slytherin") case _: print("Who?")注意到最后一个情况中使用了
_符号。这将与任何输入匹配,产生类似于else语句的行为。 -
匹配语句将
match关键字后面的值与case关键字后面的每个值进行比较。如果在事件中找到匹配项,则执行相应的缩进代码部分,程序停止匹配。 -
我们可以改进代码:
name = input("What's your name? ") match name: case "Harry" | "Hermione" | "Ron": print("Gryffindor") case "Draco": print("Slytherin") case _: print("Who?")注意,使用了单个竖线
|。与or关键字类似,这允许我们在同一个case语句中检查多个值。
总结
现在,你可以在 Python 中使用条件语句来提问,并让程序相应地采取行动。在本讲座中,我们讨论了…
-
条件语句;
-
if语句; -
控制流程,
elif和else; -
or; -
and; -
取模;
-
创建你自己的函数;
-
Pythonic 编码;
-
和
match。
第二讲
-
循环
-
while 循环
-
for 循环
-
通过用户输入改进
-
更多关于列表的信息
-
长度
-
字典
-
马里奥
-
总结
循环
-
实际上,循环是一种重复做某事的方式。
-
在终端窗口中键入
code cat.py开始。 -
在文本编辑器中,从以下代码开始:
print("meow") print("meow") print("meow")通过在终端窗口中键入
python cat.py来运行此代码,你会注意到程序喵了三次。 -
在成为一名程序员的过程中,你想要考虑如何改进那些你反复输入相同内容的代码区域。想象一下,你可能在某个地方想要“喵”500 次。反复输入相同的表达式
print("meow")是否合理? -
循环使你能够创建一个反复执行的代码块。
while 循环
-
while循环在所有编程语言中几乎是通用的。 -
这样的循环会反复执行代码块。
-
在文本编辑器窗口中,按照以下方式编辑你的代码:
i = 3 while i != 0: print("meow")注意,尽管这段代码会多次执行
print("meow"),但它永远不会停止!它会无限循环。while循环通过反复询问循环的条件是否得到满足来工作。在这种情况下,解释器会问,“i是否不等于零?”当你陷入一个永远执行的循环时,你可以按键盘上的Ctrl+C来退出循环。 -
为了修复这个永远持续下去的循环,我们可以按照以下方式编辑我们的代码:
i = 3 while i != 0: print("meow") i = i - 1注意,现在我们的代码执行得很好,每次“迭代”循环时都会将
i减少 1。术语迭代在编码中具有特殊意义。通过迭代,我们指的是通过循环的一个周期。第一次迭代是通过循环的“0 次”迭代。第二次是“1 次”迭代。在编程中,我们从 0 开始计数,然后是 1,然后是 2。 -
我们可以进一步改进我们的代码如下:
i = 1 while i <= 3: print("meow") i = i + 1注意,当我们编写
i = i + 1时,我们是从右向左赋值i的值。上面,我们像大多数人类一样从 1 开始计数(1,2,3)。如果你执行上面的代码,你会看到它喵了三次。在编程中,最好的做法是从 0 开始计数。 -
我们可以将我们的代码改进为从 0 开始计数:
i = 0 while i < 3: print("meow") i += 1注意,将运算符更改为
i < 3允许我们的代码按预期工作。我们首先从 0 开始计数,然后它通过我们的循环迭代三次,产生三次喵声。同时,注意i += 1与i = i + 1是相同的。 -
到目前为止,我们的代码如下所示:
flowchart TD A([start]) --> B[i = 0] B --> C{i < 3} C -- True --> D["#quot;meow#quot;"] D --> E[i += 1] E --> C C -- False --> F([stop])注意,我们的循环将 i 计数到 3,但不包括 3。
For 循环
-
for循环是另一种类型的循环。 -
要最好地理解
for循环,最好先从 Python 中一种新的变量类型list(列表)开始讲起。就像我们生活中的其他领域一样,我们可以有一个购物清单,一个待办事项清单等。 -
for循环遍历一个list中的项目。例如,在文本编辑器窗口中,将你的cat.py代码修改如下:for i in [0, 1, 2]: print("meow")注意到与之前的
while循环代码相比,这段代码多么简洁。在这段代码中,i从0开始,喵喵叫,然后i被赋值为1,喵喵叫,最后i被赋值为2,喵喵叫,然后结束。 -
虽然这段代码实现了我们的目标,但还有一些改进代码以应对极端情况的可能性。乍一看,我们的代码看起来很棒。但是,如果你想要迭代到一百万呢?最好创建能够处理这种极端情况的代码。相应地,我们可以这样改进我们的代码:
for i in range(3): print("meow")注意到
range(3)自动返回三个值(0、1和2)。这段代码将执行并产生预期的效果,喵喵叫三次。 -
我们的代码可以进一步改进。注意到我们在代码中从未显式地使用
i。也就是说,虽然 Python 需要使用i作为存储循环迭代次数的位置,但我们从未用它做任何其他用途。在 Python 中,如果这样的变量在我们的代码中没有其他意义,我们只需简单地用单个下划线_来表示这个变量。因此,你可以这样修改你的代码:for _ in range(3): print("meow")注意到将
i改为_对我们程序的运行没有影响。 -
我们的代码可以进一步改进。为了拓展你的思维,考虑以下代码:
print("meow" * 3)注意到它将喵喵叫三次,但程序将输出
meowmeowmeow作为结果。考虑一下:你如何在每次喵喵叫的末尾创建一个换行符? -
的确,你可以这样编辑你的代码:
print("meow\n" * 3, end="")注意到这段代码在每行产生三个喵喵叫,通过添加
end=""和\n,我们告诉解释器在每次喵喵叫的末尾添加一个换行符。
通过用户输入改进
-
也许我们想要从用户那里获取输入。我们可以使用循环作为验证用户输入的一种方式。
-
在 Python 中,使用
while循环来验证用户输入是一种常见的范式。 -
例如,让我们尝试提示用户输入一个大于或等于 0 的数字:
while True: n = int(input("What's n? ")) if n < 0: continue else: break -
注意到我们在 Python 中引入了两个新的关键字,
continue和break。continue明确告诉 Python 跳到循环的下一个迭代。另一方面,break告诉 Python 在完成所有迭代之前“提前退出”循环。在这种情况下,当n小于 0 时,我们将continue到循环的下一个迭代——最终用“请输入 n 的值?”重新提示用户。如果n大于或等于 0,我们将break出循环,允许程序的其余部分继续运行。 -
结果表明,在这种情况下
continue关键字是多余的。我们可以这样改进我们的代码:while True: n = int(input("What's n? ")) if n > 0: break for _ in range(n): print("meow")注意到这个
while循环将一直运行(永远)直到n大于0。当n大于0时,循环会中断。 -
结合我们之前的学习,我们可以使用函数进一步改进我们的代码:
def main(): meow(get_number()) def get_number(): while True: n = int(input("What's n? ")) if n > 0: return n def meow(n): for _ in range(n): print("meow") main()注意,我们不仅将你的代码修改为在多个函数中操作,而且还使用了
return语句将n的值返回到main函数。
更多关于列表的信息
-
考虑著名的哈利·波特宇宙中的霍格沃茨世界。
-
在终端中,输入
code hogwarts.py。 -
在文本编辑器中,编写如下代码:
students = ["Hermione", "Harry", "Ron"] print(students[0]) print(students[1]) print(students[2])注意我们如何有一个包含学生姓名的
list,如上所示。然后我们打印位于第 0 位置的学生,“赫敏”。其他每个学生也会被打印出来。 -
正如我们之前所展示的,我们可以使用循环来遍历列表。你可以改进你的代码如下:
students = ["Hermione", "Harry", "Ron"] for student in students: print(student)注意,对于
students列表中的每个student,它将按预期打印学生。你可能想知道为什么我们没有使用之前讨论的_标识符。我们选择不这样做是因为student在我们的代码中被明确使用。 -
你可以在 Python 的列表文档中了解更多信息。
长度
-
我们可以利用
len来检查名为students的list的长度。 -
假设你不仅想打印学生的名字,还想打印他们在列表中的位置。为了实现这一点,你可以修改你的代码如下:
students = ["Hermione", "Harry", "Ron"] for i in range(len(students)): print(i + 1, students[i])注意,执行此代码不仅会得到每个学生的位置加一(使用
i + 1),还会打印出每个学生的名字。len允许你动态地看到学生列表的长度,无论它增长多少。 -
你可以在 Python 的内置函数 len文档中了解更多信息。
字典
-
dicts 或字典是一种数据结构,允许你将键与值关联。 -
其中,
list是多个值的列表,而dict将键与值关联起来。 -
考虑到霍格沃茨的学院,我们可能将特定的学生分配到特定的学院。
![哈利·波特的名字。 哈利·波特的名字]()
-
我们可以使用
lists 单独完成这项任务:students = ["Hermione", "Harry", "Ron", "Draco"] houses = ["Gryffindor", "Gryffindor", "Griffindor", "Slytherin"]注意,我们可以保证我们始终将这些列表保持有序。
students列表中的第一个位置与houses列表中的第一个位置对应的学院相关联,依此类推。然而,当我们的列表增长时,这可能会变得相当繁琐! -
我们可以使用
dict来改进我们的代码如下:students = { "Hermione": "Gryffindor", "Harry": "Gryffindor", "Ron": "Gryffindor", "Draco": "Slytherin", } print(students["Hermione"]) print(students["Harry"]) print(students["Ron"]) print(students["Draco"])注意我们如何使用
{}花括号来创建字典。lists 使用数字来遍历列表,而dicts 允许我们使用单词。 -
运行你的代码并确保你的输出如下:
$ python hogwarts.py Gryffindor Gryffindor Gryffindor Slytherin -
我们可以改进我们的代码如下:
students = { "Hermione": "Gryffindor", "Harry": "Gryffindor", "Ron": "Gryffindor", "Draco": "Slytherin", } for student in students: print(student)注意,执行此代码时,for 循环将只遍历所有键,结果是一个包含学生名字的列表。我们如何打印出键和值?
-
修改你的代码如下:
students = { "Hermione": "Gryffindor", "Harry": "Gryffindor", "Ron": "Gryffindor", "Draco": "Slytherin", } for student in students: print(student, students[student])注意
students[student]将遍历每个学生的键并找到他们学院的值。执行你的代码,你会注意到输出有点混乱。 -
我们可以通过改进我们的代码来清理
print函数:students = { "Hermione": "Gryffindor", "Harry": "Gryffindor", "Ron": "Gryffindor", "Draco": "Slytherin", } for student in students: print(student, students[student], sep=", ")注意这如何在打印的每个项目之间创建一个干净的逗号分隔。
-
如果你执行
python hogwarts.py,你应该看到以下内容:$ python hogwarts.py Hermione, Gryffindor Harry, Gryffindor Ron, Gryffindor Draco, Slytherin -
如果我们关于学生的信息更多,我们如何将更多数据与每个学生关联起来?
![哈利波特名字 哈利波特名字]()
-
你可以想象想要与多个键关联大量数据。增强你的代码如下:
students = [ {"name": "Hermione", "house": "Gryffindor", "patronus": "Otter"}, {"name": "Harry", "house": "Gryffindor", "patronus": "Stag"}, {"name": "Ron", "house": "Gryffindor", "patronus": "Jack Russell terrier"}, {"name": "Draco", "house": "Slytherin", "patronus": None}, ]注意这段代码创建了一个
dict的list。名为students的列表中有四个dict:每个学生一个。注意,Python 有一个特殊的None标识符,表示与键没有关联的值。 -
现在,你可以访问关于这些学生的大量有趣数据。现在,进一步修改你的代码如下:
students = [ {"name": "Hermione", "house": "Gryffindor", "patronus": "Otter"}, {"name": "Harry", "house": "Gryffindor", "patronus": "Stag"}, {"name": "Ron", "house": "Gryffindor", "patronus": "Jack Russell terrier"}, {"name": "Draco", "house": "Slytherin", "patronus": None}, ] for student in students: print(student["name"], student["house"], student["patronus"], sep=", ")注意
for循环将遍历students列表中每个dict。 -
你可以在 Python 的
dict文档中了解更多信息链接。
马里奥
-
记住经典游戏马里奥有一个英雄跳过砖块。让我们创建这个游戏的文本表示。
![马里奥方块 马里奥方块]()
-
开始编码如下:
print("#") print("#") print("#")注意我们是如何一次又一次地复制和粘贴相同的代码。
-
考虑以下方式改进代码:
for _ in range(3): print("#")注意这基本上实现了我们想要创建的效果。
-
考虑:我们能否进一步抽象化,以便以后用这段代码解决更复杂的问题?修改你的代码如下:
def main(): print_column(3) def print_column(height): for _ in range(height): print("#") main()注意我们的列可以增长到我们想要的任何程度,而不需要任何硬编码。
-
现在,让我们尝试水平打印一行。修改你的代码如下:
def main(): print_row(4) def print_row(width): print("?" * width) main()注意我们现在有了可以创建从左到右的方块的代码。
-
查看下面的幻灯片,注意马里奥既有行也有列的方块。
![马里奥地下城 马里奥地下城]()
-
考虑:我们如何在代码中实现行和列?修改你的代码如下:
def main(): print_square(3) def print_square(size): # For each row in square for i in range(size): # For each brick in row for j in range(size): # Print brick print("#", end="") # Print blank line print() main()注意我们有一个外层循环,它处理正方形中的每一行。然后,我们有一个内层循环,在每一行打印一个砖块。最后,我们有一个
print语句打印一个空行。 -
我们可以进一步抽象我们的代码:
def main(): print_square(3) def print_square(size): for i in range(size): print_row(size) def print_row(width): print("#" * width) main()
总结
你现在在你的 Python 能力增长列表中又获得了一种新能力。在本讲中,我们讨论了...
-
循环
-
while -
for -
len -
list -
dict
第三讲
-
异常
-
运行时错误
-
try -
else -
创建一个获取整数的函数
-
pass -
总结
异常
-
异常是我们编码中出现的错误。
-
异常是在程序运行时出现的错误。
-
在我们的文本编辑器中,输入
code hello.py以创建一个新文件。按照以下方式输入(包括故意包含的错误):print("hello, world)注意,我们故意省略了一个引号。
-
在终端中运行
python hello.py会产生错误。解释器报告了一个语法错误。语法错误通常意味着你应该仔细检查你输入的代码是否正确。 -
你可以在 Python 的错误和异常文档中了解更多信息。
运行时错误
-
运行时错误是指代码中意外行为产生的错误。例如,你可能希望用户输入一个数字,但他们输入了一个字符。由于用户的不当输入,你的程序可能会抛出错误。
-
在你的终端窗口中,运行
code number.py。在你的文本编辑器中按照以下方式编写代码:x = int(input("What's x? ")) print(f"x is {x}")注意,通过包含
f,我们告诉 Python 将花括号中的内容作为x的值进行插值。此外,测试你的代码时,你可以想象如果有人输入一个字符串或字符而不是数字会发生什么。即使如此,用户也可能什么也不输入——只是简单地按回车键。 -
作为程序员,我们应该采取防御性措施,以确保用户输入的是我们期望的内容。
-
如果我们运行这个程序并输入“cat”,我们会看到
ValueError: invalid literal for int() with base 10: 'cat'。换句话说,int函数无法将文本“cat”转换为数字。 -
修复这种潜在错误的有效策略是创建“错误处理”来确保用户的行为符合我们的预期。
-
你可以在 Python 的错误和异常文档中了解更多信息。
try
-
在 Python 中,
try和except是在出现错误之前测试用户输入的方法。按照以下方式修改你的代码:try: x = int(input("What's x?")) print(f"x is {x}") except ValueError: print("x is not an integer")注意,运行这段代码时,输入
50将被接受。然而,输入cat将产生一个用户可见的错误,并指导他们为什么他们的输入不被接受。 -
这仍然不是实现此代码的最佳方式。注意,我们试图执行两行代码。为了最佳实践,我们应该只尝试尽可能少的可能失败的代码行。按照以下方式调整你的代码:
try: x = int(input("What's x?")) except ValueError: print("x is not an integer") print(f"x is {x}")注意,虽然这实现了尽可能少尝试的目标,但我们现在面临一个新的错误!我们遇到了一个
NameError,其中x未定义。看看这段代码,考虑一下:为什么在某些情况下x未定义? -
的确,如果你检查
x = int(input("What's x?"))中的操作顺序,从右到左,它可能会尝试将输入错误的字符赋值为整数。如果这失败了,x的赋值永远不会发生。因此,在代码的最后一行没有x可以打印。
else
-
结果表明,还有另一种实现
try的方法,可以捕获这种类型的错误。 -
按照以下方式调整你的代码:
try: x = int(input("What's x?")) except ValueError: print("x is not an integer") else: print(f"x is {x}")注意,如果没有发生异常,它将运行
else块中的代码。运行python number.py并输入50,你会注意到结果将被打印出来。再次尝试,这次输入cat,你会注意到程序现在捕获了错误。 -
考虑改进我们的代码,注意我们对待用户有点无礼。如果用户不合作,我们目前只是简单地结束程序。考虑一下我们如何使用循环来提示用户输入
x,如果他们不再次提示的话!while True: try: x = int(input("What's x?")) except ValueError: print("x is not an integer") else: break print(f"x is {x}")注意,
while True将无限循环。如果用户成功提供了正确的输入,我们可以跳出循环并打印输出。现在,一个输入错误的用户将被要求再次输入。
创建一个获取整数的函数
-
当然,有很多时候我们希望从用户那里获取一个整数。按照以下方式修改你的代码:
def main(): x = get_int() print(f"x is {x}") def get_int(): while True: try: x = int(input("What's x?")) except ValueError: print("x is not an integer") else: break return x main()注意,我们正在展示许多优秀的特性。首先,我们抽象出了获取整数的能力。现在,整个程序归结为程序的前三条语句。
-
即使如此,我们仍然可以改进这个程序。考虑一下你还能做什么来改进这个程序。按照以下方式修改你的代码:
def main(): x = get_int() print(f"x is {x}") def get_int(): while True: try: x = int(input("What's x?")) except ValueError: print("x is not an integer") else: return x main()注意,
return不仅会跳出循环,还会返回一个值。 -
有些人可能会争论你可以这样做:
def main(): x = get_int() print(f"x is {x}") def get_int(): while True: try: return int(input("What's x?")) except ValueError: print("x is not an integer") main()注意,这与我们代码的前一个版本做的是同样的事情,只是行数更少。
pass
-
我们可以修改代码,使得我们的代码不会警告用户,而是简单地通过修改代码来再次询问他们的提示问题:
def main(): x = get_int() print(f"x is {x}") def get_int(): while True: try: return int(input("What's x?")) except ValueError: pass main()注意,我们的代码仍然可以工作,但不会反复通知用户他们的错误。在某些情况下,你可能希望非常清楚地告诉用户产生了什么错误。在其他时候,你可能会决定你只是想再次要求他们输入。
-
对
get_int函数的实现进行最后一次改进。目前,注意我们依赖于x在main和get_int函数中都是通过荣誉系统来传递的。我们可能想传递一个当用户被要求输入时看到的提示。按照以下方式修改你的代码。def main(): x = get_int("What's x? ") print(f"x is {x}") def get_int(prompt): while True: try: return int(input(prompt)) except ValueError: pass main() -
你可以在 Python 的文档中了解更多关于
pass的信息。
总结
代码中不可避免地会出现错误。然而,你有机会利用今天所学的内容来帮助预防这些错误。在本节课中,你学习了关于……
-
异常
-
值错误
-
运行时错误
-
try -
else -
pass
第四讲
-
库
-
随机
-
统计学
-
命令行参数
-
slice函数 -
包
-
API
-
创建自己的库
-
总结
库
-
通常,库是你或其他人编写的代码片段,你可以在程序中使用它。
-
Python 允许你将函数或功能作为“模块”与他人共享。
-
如果你从旧项目中复制粘贴代码,那么你很可能可以创建一个模块或库,并将其带入新项目。
随机
-
random是 Python 内置的一个库,你可以将其导入到自己的项目中。 -
作为程序员,站在前人的肩膀上更容易。
-
那么,如何将模块加载到自己的程序中呢?你可以在程序中使用
import关键字。 -
在
random模块内部,有一个名为random.choice(seq)的内置函数。random是你导入的模块。在该模块内部,有一个名为choice的函数。该函数接受一个seq或序列,它是一个列表。 -
在你的终端窗口中输入
code generate.py。在你的文本编辑器中,按照以下方式编写代码:import random coin = random.choice(["heads", "tails"]) print(coin)注意,
choice函数内的列表有方括号、引号和逗号。由于你传入了两个项目,Python 会进行数学计算,并给出正面和反面的 50% 概率。运行你的代码,你会注意到这段代码确实运行得很好! -
我们可以改进我们的代码。
from允许我们非常具体地指定我们想要导入的内容。之前,我们的import代码行是导入random中所有函数的内容。然而,如果我们只想加载模块的一部分,应该如何修改代码呢?from random import choice coin = choice(["heads", "tails"]) print(coin)注意,我们现在可以只导入
random的choice函数。从那时起,我们不再需要编写random.choice。现在我们只需编写choice即可。choice已经明确加载到我们的程序中。这节省了系统资源,并且可能使我们的代码运行得更快! -
接下来,考虑
random.randint(a, b)函数。此函数将在a和b之间生成一个随机数。按照以下方式修改你的代码:import random number = random.randint(1, 10) print(number)注意,我们的代码将随机生成一个介于
1和10之间的数字。 -
我们可以介绍
random.shuffle(x)函数,该函数可以将列表打乱成随机顺序。import random cards = ["jack", "queen", "king"] random.shuffle(cards) for card in cards: print(card)注意,
random.shuffle将就地打乱牌的顺序。与其他函数不同,它不会返回一个值。相反,它将cards列表作为参数,并在该列表内部打乱牌的顺序。运行你的代码几次,以查看代码的功能。 -
我们现在有上述三种生成随机信息的方法。
-
你可以在 Python 的
random库文档中了解更多信息。随机。
统计学
-
Python 内置了一个
statistics库。我们如何使用这个模块呢? -
mean是这个库中的一个非常有用的函数。在你的终端窗口中,输入code average.py。在文本编辑器窗口中,按照以下方式修改你的代码:import statistics print(statistics.mean([100, 90]))注意,我们导入了一个名为
statistics的不同库。mean函数接受一个值列表。这将打印这些值的平均值。在你的终端窗口中,输入python average.py。 -
考虑在你的程序中使用
statistics模块的可能性。 -
你可以在 Python 的
statistics(统计)文档中了解更多信息。
命令行参数
-
到目前为止,我们一直在程序中提供所有值。如果我们想能够从命令行获取输入怎么办?例如,而不是在终端中输入
python average.py,我们能否输入python average.py 100 90并得到100和90之间的平均值? -
sys是一个模块,它允许我们在命令行中获取参数。 -
argv是sys模块中的一个列表,它记录了用户在命令行中输入的内容。 -
注意,你将在下面的代码中看到
sys.argv的使用。在终端窗口中,输入code name.py。在文本编辑器中,按照以下方式编写代码:import sys print("hello, my name is", sys.argv[1])注意,程序将查看用户在命令行中输入的内容。目前,如果你在终端窗口中输入
python name.py David,你会看到hello, my name is David。注意,sys.argv[1]是存储David的位置。为什么是这样呢?好吧,在之前的课程中,你可能记得列表是从0个元素开始的。你认为当前sys.argv[0]中存储了什么?如果你猜到是name.py,你就对了! -
我们现有的程序有一个小问题。如果用户没有在命令行中输入名字会怎样?自己试一试。在终端窗口中输入
python name.py。解释器会显示一个错误list index out of range。原因是在sys.argv[1]中没有内容,因为没有输入任何东西!以下是我们可以保护我们的程序免受此类错误的方法:import sys try: print("hello, my name is", sys.argv[1]) except IndexError: print("Too few arguments")注意,如果用户忘记输入名字,程序会提示一个有用的提示,告诉他们如何使程序工作。然而,我们能否更加谨慎以确保用户输入正确的值?
-
我们可以按照以下方式改进我们的程序:
import sys if len(sys.argv) < 2: print("Too few arguments") elif len(sys.argv) > 2: print("Too many arguments") else: print("hello, my name is", sys.argv[1])注意,如果你测试你的代码,你会看到这些异常是如何被处理的,为用户提供更详细的建议。即使用户输入了过多的或过少的参数,用户也会得到关于如何修复问题的明确指示。
-
目前,我们的代码在逻辑上是正确的。然而,将错误检查与代码的其余部分分开是非常好的。我们如何分离出错误处理?按照以下方式修改你的代码:
import sys if len(sys.argv) < 2: sys.exit("Too few arguments") elif len(sys.argv) > 2: sys.exit("Too many arguments") print("hello, my name is", sys.argv[1])注意我们如何使用
sys的内置函数exit,它允许我们在用户引入错误时退出程序。现在我们可以确信程序将永远不会执行最后一行代码并触发错误。因此,sys.argv提供了一种方式,用户可以通过命令行引入信息。sys.exit提供了一种方式,程序可以在出现错误时退出。 -
你可以在 Python 的
sys库文档中了解更多信息sys。
slice
-
slice是一个命令,它允许我们取一个list并告诉解释器我们希望解释器将list的哪个位置视为开始和结束。例如,按照以下方式修改你的代码:import sys if len(sys.argv) < 2: sys.exit("Too few arguments") for arg in sys.argv: print("hello, my name is", arg)注意,如果你在终端窗口中输入
python name.py David Carter Rongxin,解释器将输出不仅仅是预期的名字输出,还会输出hello, my name is name.py。那么我们如何确保解释器忽略列表中当前存储的name.py的第一个元素呢? -
slice可以在我们的代码中用来从不同的位置开始列表!按照以下方式修改你的代码:import sys if len(sys.argv) < 2: sys.exit("Too few arguments") for arg in sys.argv[1:]: print("hello, my name is", arg)注意,我们不是从
0开始列表,而是使用方括号告诉解释器从1开始,使用1:参数到末尾。运行这段代码,你会注意到我们可以使用相对简单的语法来改进我们的代码。
包
-
Python 如此受欢迎的一个原因是,有大量的强大第三方库增加了功能。我们将这些作为文件夹实现的第三方库称为“包”。
-
PyPI 是一个包含所有当前可用的第三方包的仓库或目录。
-
cowsay是一个允许牛与用户交谈的知名包。 -
Python 有一个名为
pip的包管理器,它允许你快速将包安装到你的系统中。 -
在终端窗口中,你可以通过输入
pip install cowsay来安装cowsay包。在输出一些信息后,你现在可以在代码中使用这个包了。 -
在你的终端窗口中输入
code say.py。在文本编辑器中,按照以下方式编写代码:import cowsay import sys if len(sys.argv) == 2: cowsay.cow("hello, " + sys.argv[1])注意,程序首先检查用户是否在命令行中至少输入了两个参数。然后,牛应该对用户说话。输入
python say.py David,你会看到一头牛对 David 说“hello”。 -
进一步修改你的代码:
import cowsay import sys if len(sys.argv) == 2: cowsay.trex("hello, " + sys.argv[1])注意,现在一个 t-rex 正在说“hello”。
-
你现在可以看到如何安装第三方包。
-
你可以在 PyPI 的 cowsay 条目中了解更多信息。
-
你可以在 PyPI 找到其他第三方包。
API
-
API 或“应用程序程序接口”允许你连接到他人的代码。
-
requests是一个允许你的程序表现得像网络浏览器的包。 -
在你的终端中输入
pip install requests。然后,输入code itunes.py。 -
结果表明,Apple iTunes 有自己的 API,你可以在程序中访问它。在你的网络浏览器中,你可以访问
itunes.apple.com/search?entity=song&limit=1&term=weezer并下载一个文本文件。大卫通过阅读 Apple 的 API 文档构建了这个 URL。注意这个查询正在寻找一个与term称为weezer相关的song,结果数量限制为1。查看下载的文本文件,你可能会发现其格式与我们之前在 Python 中编写的类似。 -
下载的文本文件格式称为 JSON,这是一种基于文本的格式,用于在应用程序之间交换基于文本的数据。实际上,Apple 正在提供我们可以用我们自己的 Python 程序解释的 JSON 文件。
-
在终端窗口中,输入
code itunes.py。编写如下代码:import requests import sys if len(sys.argv) != 2: sys.exit() response = requests.get("https://itunes.apple.com/search?entity=song&limit=1&term=" + sys.argv[1]) print(response.json())注意
requests.get返回的值将被存储在response中。大卫阅读了关于此 API 的 Apple 文档后知道返回的是 JSON 文件。运行python itunes.py weezer,你会看到 Apple 返回的 JSON 文件。然而,JSON 响应被 Python 转换成了字典。查看输出,可能会让人头晕。 -
结果表明,Python 有一个内置的 JSON 库可以帮助我们解释接收到的数据。修改你的代码如下:
import json import requests import sys if len(sys.argv) != 2: sys.exit() response = requests.get("https://itunes.apple.com/search?entity=song&limit=1&term=" + sys.argv[1]) print(json.dumps(response.json(), indent=2))注意
json.dumps是如何实现的,它利用indent使输出更易读。运行python itunes.py weezer,你会看到相同的 JSON 文件。然而,这次它要容易阅读得多。现在注意,你会在输出中看到一个名为results的字典。在这个名为results的字典中,有多个键存在。查看输出中的trackName值。你在结果中看到了什么曲目名称? -
我们如何简单地输出那个特定曲目的名称?修改你的代码如下:
import json import requests import sys if len(sys.argv) != 2: sys.exit() response = requests.get("https://itunes.apple.com/search?entity=song&limit=50&term=" + sys.argv[1]) o = response.json() for result in o["results"]: print(result["trackName"])注意我们是如何将
response.json()的结果存储在o中(就像小写字母 o)。然后,我们遍历o中的results并打印每个trackName。同时注意我们如何将结果数量限制增加到50。运行你的程序。查看结果。 -
你可以通过 库的文档 了解更多关于
requests的信息。 -
你可以在 Python 的 JSON 文档中了解更多关于 JSON 的信息。
创建自己的库
-
你作为一个 Python 程序员,有能力创建自己的库!
-
想象一下你可能想要反复使用代码片段,甚至与他人分享的情况!
-
在本课程中,我们编写了很多代码来表示“hello”。让我们创建一个包,以便我们可以表示“hello”和“goodbye”。在你的终端窗口中,输入
code sayings.py。在文本编辑器中,编写如下代码:def hello(name): print(f"hello, {name}") def goodbye(name): print(f"goodbye, {name}")注意,这段代码本身对用户没有任何作用。然而,如果程序员将这个包导入到他们自己的程序中,上述函数创建的能力就可以在他们的代码中实现。
-
让我们看看我们如何实现利用我们创建的这个包的代码。在终端窗口中,输入
code say.py。在你文本编辑器中的这个新文件中,输入以下内容:import sys from sayings import goodbye if len(sys.argv) == 2: goodbye(sys.argv[1])注意,这段代码导入了
sayings包中goodbye的能力。如果用户在命令行中至少输入了两个参数,它将输出goodbye以及命令行中输入的字符串。
总结
库扩展了 Python 的能力。一些库默认包含在 Python 中,只需导入即可。其他的是需要使用pip安装的第三方包。你可以为自己或他人创建自己的包!在本讲座中,你学习了关于……
-
库
-
随机
-
统计学
-
命令行参数
-
切片
-
包
-
API
-
创建你自己的库
第五讲
-
单元测试
-
assert -
pytest -
测试字符串
-
将测试组织到文件夹中
-
总结
单元测试
-
到目前为止,你很可能一直在使用
print语句测试自己的代码。 -
或者,你可能一直依赖 CS50 来为你测试代码!
-
在工业界,编写代码来测试自己的程序是最常见的。
-
在你的控制台窗口中,输入
code calculator.py。注意,你可能在前面的讲座中已经编写了这个文件。在文本编辑器中,确保你的代码如下所示:def main(): x = int(input("What's x? ")) print("x squared is", square(x)) def square(n): return n * n if __name__ == "__main__": main()注意,你可以使用一些明显的数字,例如
2,在你的机器上合理地测试上述代码。然而,考虑一下你为什么想要创建一个确保上述代码适当运行的测试。 -
按照惯例,让我们通过输入
code test_calculator.py创建一个新的测试程序,并在文本编辑器中修改你的代码如下:from calculator import square def main(): test_square() def test_square(): if square(2) != 4: print("2 squared was not 4") if square(3) != 9: print("3 squared was not 9") if __name__ == "__main__": main()注意,我们在代码的第一行导入了
square函数,来自calculator.py。 -
在控制台窗口中,输入
python test_calculator.py。你会注意到没有任何输出。这可能意味着一切运行正常!或者,这也可能意味着我们的测试函数没有发现可能导致错误的“边缘情况”之一。 -
目前,我们的代码测试了两个条件。如果我们想要测试更多的条件,我们的测试代码可能会很容易变得臃肿。我们如何在不扩展测试代码的情况下扩展我们的测试能力?
assert
-
Python 的
assert命令允许我们告诉解释器某个断言是真的。我们可以将此应用于我们的测试代码,如下所示:from calculator import square def main(): test_square() def test_square(): assert square(2) == 4 assert square(3) == 9 if __name__ == "__main__": main()注意,我们明确断言
square(2)和square(3)应该等于什么。我们的代码从四行测试减少到两行。 -
我们可以通过以下方式故意破坏计算器代码:
def main(): x = int(input("What's x? ")) print("x squared is", square(x)) def square(n): return n + n if __name__ == "__main__": main()注意,我们在平方函数中将
*运算符改为了+。 -
现在,在控制台窗口中运行
python test_calculator.py,你会注意到解释器抛出了一个AssertionError。本质上,这是解释器告诉我们我们的某个条件没有满足。 -
我们现在面临的一个挑战是,如果我们想要向用户提供更多描述性的错误输出,我们的代码可能会变得更加繁重。可能地,我们可以这样编写代码:
from calculator import square def main(): test_square() def test_square(): try: assert square(2) == 4 except AssertionError: print("2 squared is not 4") try: assert square(3) == 9 except AssertionError: print("3 squared is not 9") try: assert square(-2) == 4 except AssertionError: print("-2 squared is not 4") try: assert square(-3) == 9 except AssertionError: print("-3 squared is not 9") try: assert square(0) == 0 except AssertionError: print("0 squared is not 0") if __name__ == "__main__": main()注意,运行此代码将产生多个错误。然而,它并没有产生上述所有错误。这是一个很好的说明,说明测试多个情况是有价值的,这样你可能会捕捉到存在编码错误的情况。
-
上述代码说明了主要挑战:我们如何在不使用像上述那样数十行代码的情况下使测试代码更容易?
你可以在 Python 的文档中了解更多关于 assert 的信息:assert。
pytest
-
pytest是一个第三方库,允许你对程序进行单元测试。也就是说,你可以在程序中测试你的函数。 -
要使用
pytest,请在控制台窗口中输入pip install pytest。 -
在将
pytest应用于我们自己的程序之前,按照以下方式修改你的test_square函数:from calculator import square def main(): test_square() def test_square(): assert square(2) == 4 assert square(3) == 9 assert square(-2) == 4 assert square(-3) == 9 assert square(0) == 0注意上述代码断言了我们想要测试的所有条件。
-
pytest允许我们直接通过它运行程序,这样我们可以更容易地查看测试条件的输出结果。 -
在终端窗口中,输入
pytest test_calculator.py。你会立即注意到会提供输出。注意输出顶部附近的红色F,表示你的代码中存在问题。此外,红色E提供了一些关于calculator.py程序中错误的信息。根据输出,你可以想象一个场景,其中3 * 3输出了6而不是9。根据这个测试的结果,我们可以按照以下方式更正calculator.py代码:def main(): x = int(input("What's x? ")) print("x squared is", square(x)) def square(n): return n * n if __name__ == "__main__": main()注意我们在平方函数中将
+运算符更改为*,使其恢复到工作状态。 -
再次运行
pytest test_calculator.py,注意没有错误产生。恭喜你! -
目前,
pytest在第一次测试失败后停止运行并不理想。再次,让我们将我们的calculator.py代码恢复到损坏状态:def main(): x = int(input("What's x? ")) print("x squared is", square(x)) def square(n): return n + n if __name__ == "__main__": main()注意我们在平方函数中将
*运算符更改为+,使其恢复到损坏状态。 -
为了改进我们的测试代码,让我们将
test_calculator.py中的代码分成不同的测试组:from calculator import square def test_positive(): assert square(2) == 4 assert square(3) == 9 def test_negative(): assert square(-2) == 4 assert square(-3) == 9 def test_zero(): assert square(0) == 0注意我们将相同的五个测试分成了三个不同的函数。像
pytest这样的测试框架会运行每个函数,即使其中一个失败了。再次运行pytest test_calculator.py,你会发现显示了许多更多的错误。更多的错误输出允许你进一步探索代码中可能产生问题的原因。 -
在改进了测试代码后,将你的
calculator.py代码恢复到完全工作状态:def main(): x = int(input("What's x? ")) print("x squared is", square(x)) def square(n): return n * n if __name__ == "__main__": main()注意我们在平方函数中将
+运算符更改为*,使其恢复到工作状态。 -
再次运行
pytest test_calculator.py,你会发现没有错误发生。 -
最后,我们可以测试我们的程序是否能够处理异常。让我们修改
test_calculator.py来实现这一点。
import pytest
from calculator import square
def test_positive():
assert square(2) == 4
assert square(3) == 9
def test_negative():
assert square(-2) == 4
assert square(-3) == 9
def test_zero():
assert square(0) == 0
def test_str():
with pytest.raises(TypeError):
square("cat")
注意我们不再使用 assert,而是利用 pytest 库中的一个函数 raises,它允许你表达你期望抛出一个错误。我们需要将 import pytest 添加到程序顶部,然后使用 pytest.raises 并指定我们期望的错误类型。
-
再次运行
pytest test_calculator.py,你会发现没有错误发生。 -
总结来说,作为程序员,定义多少测试条件取决于你自己的判断!
你可以在 Pytest 的pytest文档中了解更多信息。
测试字符串
-
回到过去,考虑以下
hello.py的代码:def main(): name = input("What's your name? ") hello(name) def hello(to="world"): print("hello,", to) if __name__ == "__main__": main()注意,我们可能希望测试
hello函数的结果。 -
考虑以下
test_hello.py的代码:from hello import hello def test_hello(): assert hello("David") == "hello, David" assert hello() == "hello, world"看到这段代码,你认为这种测试方法会有效吗?为什么这个测试可能不起作用?注意
hello.py中的hello函数打印了一些内容:也就是说,它没有返回一个值! -
我们可以在
hello.py中更改我们的hello函数,如下所示:def main(): name = input("What's your name? ") print(hello(name)) def hello(to="world"): return f"hello, {to}" if __name__ == "__main__": main()注意,我们将
hello函数更改为返回一个字符串。这意味着我们现在可以使用pytest来测试hello函数。 -
运行
pytest test_hello.py,我们的代码将通过所有测试! -
就像本课之前的测试案例一样,我们可以将测试分开进行:
from hello import hello def test_default(): assert hello() == "hello, world" def test_argument(): assert hello("David") == "hello, David"注意,上述代码将我们的测试分成多个函数,这样即使产生错误,它们也会全部运行。
将测试组织到文件夹中
-
使用多个测试进行单元测试是如此常见,以至于你可以使用单个命令运行整个测试文件夹。
-
首先,在终端窗口中,执行
mkdir test以创建一个名为test的文件夹。 -
然后,在终端窗口中输入
code test/test_hello.py以在该文件夹内创建一个测试。注意test/指示终端在名为test的文件夹中创建test_hello.py。 -
在文本编辑窗口中,修改文件以包含以下代码:
from hello import hello def test_default(): assert hello() == "hello, world" def test_argument(): assert hello("David") == "hello, David"注意,我们正在创建一个测试,就像之前做的那样。
-
pytest不会允许我们仅使用这个文件(或一组文件)作为文件夹来运行测试,而不需要一个特殊的__init__文件。在你的终端窗口中,通过输入code test/__init__.py创建这个文件。注意,就像之前一样,test/以及init两边的双下划线。即使这个__init__.py文件为空,pytest也会知道包含__init__.py的整个文件夹包含可以运行的测试。 -
现在,在终端中输入
pytest test,你可以运行整个test文件夹中的代码。
你可以在 Pytest 的导入机制文档中了解更多信息。
总结
测试你的代码是编程过程中的一个自然部分。单元测试允许你测试代码的特定方面。你可以创建自己的程序来测试你的代码。或者,你可以利用像pytest这样的框架来自动运行你的单元测试。在本讲中,你学习了关于……
-
单元测试
-
assert -
pytest
第六讲
-
文件输入/输出
-
open -
with -
CSV
-
二进制文件和
PIL -
总结
文件输入/输出
-
到目前为止,我们编写的所有程序都是在内存中存储信息。也就是说,一旦程序结束,从用户那里收集的所有信息或程序生成的所有信息都会丢失。
-
文件输入/输出是程序将文件作为输入或创建文件作为输出的能力。
-
首先,在终端窗口中输入
code names.py并编写以下代码:name = input("What's your name?" ) print(f"hello, {name}")注意,运行此代码会产生预期的输出。用户可以输入一个名字,输出结果符合预期。
-
然而,如果我们想允许输入多个名字呢?我们该如何实现?回想一下,
list是一种数据结构,允许我们将多个值存储到单个变量中。按照以下方式编写代码:names = [] for _ in range(3): name = input("What's your name?" ) names.append(name)注意,用户将被提示三次输入。使用
append方法将name添加到我们的names列表中。 -
这段代码可以简化为以下形式:
names = [] for _ in range(3): names.append(input("What's your name?" ))注意,这与之前的代码块有相同的结果。
- 现在,让我们启用打印名字列表为排序列表的功能。按照以下方式编写代码:
names = [] for _ in range(3): names.append(input("What's your name?" )) for name in sorted(names): print(f"hello, {name}")注意,一旦程序执行完毕,所有信息都会丢失。文件输入/输出允许你的程序存储这些信息,以便以后使用。
-
你可以在 Python 的 sorted 文档中了解更多信息。
open
-
open是 Python 内置的一个功能,允许你打开一个文件并在你的程序中使用它。open函数允许你以读取或写入的方式打开一个文件。 -
为了向你展示如何在程序中启用文件输入/输出,让我们回顾一下并编写以下代码:
name = input("What's your name? ") file = open("names.txt", "w") file.write(name) file.close()注意,
open函数以写入模式打开了一个名为names.txt的文件,这由w表示。上面的代码将打开的文件分配给一个名为file的变量。file.write(name)这行代码将名字写入文本文件。之后的行关闭了文件。 -
通过在终端中输入
python names.py测试你的代码,你可以输入一个名字,并将其保存到文本文件中。然而,如果你使用不同的名字多次运行程序,你会注意到这个程序每次都会完全重写names.txt文件。 -
理想情况下,我们希望能够将我们的每个名字追加到文件中。在终端窗口中输入
rm names.txt来删除现有的文本文件。然后,按照以下方式修改你的代码:name = input("What's your name? ") file = open("names.txt", "a") file.write(name) file.close()注意,我们代码中唯一的改变是将
w改为a以实现“追加”。多次重新运行此程序,你会注意到名字将被添加到文件中。然而,你也会发现一个新的问题! -
在运行程序多次后检查您的文本文件,您会注意到名字是连在一起的。名字之间没有任何间隔。您可以修复这个问题。再次,通过在终端窗口中键入
rm names.txt来删除现有的文本文件。然后,按照以下方式修改您的代码:name = input("What's your name? ") file = open("names.txt", "a") file.write(f"{name}\n") file.close()注意到带有
file.write的行已经被修改,为每个名字添加了行尾换行符。 -
这段代码工作得相当好。然而,有方法可以改进这个程序。它很容易忘记关闭文件。
-
您可以在 Python 的 open 文档中了解更多信息。
with
-
关键字
with允许您自动化文件的关闭。 -
按照以下方式修改您的代码:
name = input("What's your name? ") with open("names.txt", "a") as file: file.write(f"{name}\n")注意到
with下的行是缩进的。 -
到目前为止,我们一直是在向文件中写入。如果我们想从文件中读取呢?为了启用此功能,按照以下方式修改您的代码:
with open("names.txt", "r") as file: lines = file.readlines() for line in lines: print("hello,", line)注意到
readlines具有读取文件中所有行并将它们存储在名为lines的列表中的特殊能力。运行您的程序,您会注意到输出相当难看。似乎在应该只有一个换行符的地方有多个换行符。 -
有许多方法可以解决这个问题。但是,这里有一个简单的方法可以修复我们代码中的这个错误:
with open("names.txt", "r") as file: lines = file.readlines() for line in lines: print("hello,", line.rstrip())注意到
rstrip具有移除每行末尾多余换行符的效果。 -
尽管如此,这段代码还可以进一步简化:
with open("names.txt", "r") as file: for line in file: print("hello,", line.rstrip())注意到运行这段代码是正确的。然而,请注意我们没有对名字进行排序。
-
这段代码可以进一步改进,以允许对名字进行排序:
names = [] with open("names.txt") as file: for line in file: names.append(line.rstrip()) for name in sorted(names): print(f"hello, {name}")注意到
names是一个空白列表,我们可以在这里收集名字。每个名字都会追加到内存中的names列表中。然后,内存中排序列表中的每个名字都会被打印出来。运行您的代码,您会看到名字现在已经被正确排序。 -
如果我们想要存储的不仅仅是学生的名字呢?如果我们想存储学生的名字和他们的宿舍呢?
CSV
-
CSV 代表“逗号分隔值”。
-
在您的终端窗口中,键入
code students.csv。确保您的新 CSV 文件看起来如下:Hermione,Gryffindor Harry,Gryffindor Ron,Gryffindor Draco,Slytherin -
通过键入
code students.py创建一个新的程序,并按照以下方式编写代码:with open("students.csv") as file: for line in file: row = line.rstrip().split(",") print(f"{row[0]} is in {row[1]}")注意到
rstrip移除了我们 CSV 文件中每行的末尾。split告诉解释器在哪里找到我们 CSV 文件中每个值的末尾。row[0]是我们 CSV 文件每行中的第一个元素。row[1]是我们 CSV 文件每行中的第二个元素。 -
上述代码有效地将我们的 CSV 文件中的每一行或“记录”分开。然而,如果您不熟悉这种语法,看起来可能有点晦涩。Python 有内置的能力可以进一步简化这段代码。按照以下方式修改您的代码:
with open("students.csv") as file: for line in file: name, house = line.rstrip().split(",") print(f"{name} is in {house}")注意,
split函数实际上返回两个值:逗号之前的一个和逗号之后的一个。因此,我们可以依赖这个功能一次分配两个变量而不是一个! -
假设我们再次希望提供这个列表作为排序后的输出?你可以按照以下方式修改你的代码:
students = [] with open("students.csv") as file: for line in file: name, house = line.rstrip().split(",") students.append(f"{name} is in {house}") for student in sorted(students): print(student)注意,我们创建了一个名为
students的list。我们将每个字符串append到这个列表中。然后,我们输出列表的排序版本。 -
回想一下,Python 允许使用
dictionaries,其中键可以与值相关联。这段代码可以进一步改进students = [] with open("students.csv") as file: for line in file: name, house = line.rstrip().split(",") student = {} student["name"] = name student["house"] = house students.append(student) for student in students: print(f"{student['name']} is in {student['house']}")注意,我们创建了一个名为
student的空字典。我们将每个学生的值添加到student字典中,包括他们的名字和学院。然后,我们将该学生添加到名为students的list中。 -
我们可以改进我们的代码来展示这一点,如下所示:
students = [] with open("students.csv") as file: for line in file: name, house = line.rstrip().split(",") student = {"name": name, "house": house} students.append(student) for student in students: print(f"{student['name']} is in {student['house']}")注意,这样会产生期望的结果,但排除了学生的排序。
-
不幸的是,我们无法像以前那样对学生的列表进行排序,因为每个学生现在都是一个列表中的字典。如果 Python 能够按学生的名字对
students列表中的student字典进行排序,那将很有帮助。 -
要在我们的代码中实现这一点,进行以下更改:
students = [] with open("students.csv") as file: for line in file: name, house = line.rstrip().split(",") students.append({"name": name, "house": house}) def get_name(student): return student["name"] for student in sorted(students, key=get_name): print(f"{student['name']} is in {student['house']}")注意,
sorted需要知道如何获取每个学生的键。Python 允许一个名为key的参数,我们可以定义学生列表将按什么“键”进行排序。因此,get_name函数简单地返回student["name"]的键。运行这个程序,你现在会看到列表已按名字排序。 -
尽管如此,我们的代码还可以进一步改进。恰好如果你只打算使用像
get_name这样的函数一次,你可以按照以下方式简化你的代码。按照以下方式修改你的代码:students = [] with open("students.csv") as file: for line in file: name, house = line.rstrip().split(",") students.append({"name": name, "house": house}) for student in sorted(students, key=lambda student: student["name"]): print(f"{student['name']} is in {student['house']}")注意我们如何使用一个
lambda函数,一个匿名函数,它说:“嘿 Python,这里有一个没有名字的函数:给定一个student,访问他们的name并将其作为key返回。” -
不幸的是,我们的代码有点脆弱。假设我们改变了我们的 CSV 文件,使得我们指明了每个学生的成长地。这对我们的程序会有什么影响?首先,按照以下方式修改你的
students.csv文件:
Harry,"Number Four, Privet Drive"
Ron,The Burrow
Draco,Malfoy Manor
注意,运行我们的程序会产生许多错误。
-
现在我们处理的是家园(homes)而不是学院(houses),按照以下方式修改你的代码:
students = [] with open("students.csv") as file: for line in file: name, home = line.rstrip().split(",") students.append({"name": name, "home": home}) for student in sorted(students, key=lambda student: student["name"]): print(f"{student['name']} is in {student['home']}")注意,运行我们的程序仍然不能正常工作。你能猜到为什么吗?
-
解释器产生的
ValueError: too many values to unpack错误是由于我们之前创建这个程序时预期 CSV 文件是用逗号(,)分割的。我们可以花更多时间解决这个问题,但确实有人已经开发了一种“解析”(即读取)CSV 文件的方法! -
Python 的内置
csv库包含一个名为reader的对象。正如其名所示,我们可以使用reader来读取我们的 CSV 文件,即使“四号,紫杉巷”中有多余的逗号。reader在for循环中工作,每次迭代时reader都会从我们的 CSV 文件中提供另一行。这一行本身是一个列表,其中列表中的每个值对应于该行中的一个元素。例如,row[0]是给定行的第一个元素,而row[1]是第二个元素。import csv students = [] with open("students.csv") as file: reader = csv.reader(file) for row in reader: students.append({"name": row[0], "home": row[1]}) for student in sorted(students, key=lambda student: student["name"]): print(f"{student['name']} is from {student['home']}")注意现在我们的程序按预期工作。
-
到目前为止,我们一直依赖于我们的程序来具体决定 CSV 文件中的哪些部分是名字,哪些部分是家庭地址。然而,更好的设计是将这些直接嵌入到我们的 CSV 文件中,如下所示进行编辑:
name,home Harry,"Number Four, Privet Drive" Ron,The Burrow Draco,Malfoy Manor注意我们在 CSV 文件中明确指出,任何读取它的人都应预期每行都有一个名字值和一个家庭值。
-
我们可以修改我们的代码,使用
csv库中的一个名为DictReader的部分,以获得更大的灵活性来处理我们的 CSV 文件:import csv students = [] with open("students.csv") as file: reader = csv.DictReader(file) for row in reader: students.append({"name": row["name"], "home": row["home"]}) for student in sorted(students, key=lambda student: student["name"]): print(f"{student['name']} is in {student['home']}")注意我们已经将
reader替换为DictReader,它每次返回一个字典。此外,注意解释器将直接访问row字典,获取每个学生的name和home。这是一个编码防御的例子。只要设计 CSV 文件的人已经在第一行输入了正确的标题信息,我们就可以使用我们的程序访问这些信息。 -
到目前为止,我们一直在读取 CSV 文件。如果我们想写入 CSV 文件怎么办?
-
首先,让我们清理一下我们的文件。首先,在终端窗口中输入
rm students.csv删除students.csv文件。此命令仅在你位于students.csv文件相同的文件夹中时有效。 -
然后,在
students.py文件中,按照以下方式修改你的代码:import csv name = input("What's your name? ") home = input("Where's your home? ") with open("students.csv", "a") as file: writer = csv.DictWriter(file, fieldnames=["name", "home"]) writer.writerow({"name": name, "home": home})注意我们如何利用
DictWriter的内置功能,它接受两个参数:要写入的file和要写入的fieldnames。此外,注意writerow函数接受一个字典作为其参数。实际上,我们是在告诉解释器写入一个包含两个字段名为name和home的行。 -
注意,你可以从和写入许多类型的文件。
-
你可以在 Python 的 CSV 文档中了解更多信息。
二进制文件和 PIL
-
我们今天还将讨论另一种类型的文件,即二进制文件。二进制文件简单来说就是由一串零和一组成的集合。这种类型的文件可以存储任何内容,包括音乐和图像数据。
-
有一个流行的 Python 库叫做
PIL,它与图像文件配合得很好。 -
动画 GIF 是一种流行的图像文件类型,其中包含多个图像文件,这些图像文件会按顺序反复播放,从而创建出简单的动画或视频效果。
-
想象一下,我们有一系列服装,如下所示。
-
这里是
costume1.gif。

- 这还有一个叫做
costume2.gif的例子。注意腿部位置略有不同。

-
在继续之前,请确保您已从课程网站下载了源代码文件。如果没有上述两张图像,您将无法编写以下代码。
-
在终端窗口中输入
code costumes.py并按照以下代码编写:import sys from PIL import Image images = [] for arg in sys.argv[1:]: image = Image.open(arg) images.append(image) images[0].save( "costumes.gif", save_all=True, append_images=[images[1]], duration=200, loop=0 )注意我们是从
PIL中导入Image功能。注意第一个for循环只是简单地遍历提供的命令行参数中的图像,并将它们存储到名为images的列表中。1:表示从argv的第二个元素开始切片。代码的最后几行保存了第一张图像,并将其与第二张图像一起附加,创建了一个动画 GIF。在终端中输入python costumes.py costume1.gif costume2.gif。现在,在终端窗口中输入code costumes.gif,你现在可以看到一个动画 GIF。 -
您可以在 Pillow 的 PIL 文档中了解更多信息。
总结
现在,我们不仅看到了我们可以以文本方式编写和读取文件——我们还可以使用一和零来读写文件。我们迫不及待地想看看你将如何利用这些新能力取得成就。
-
文件输入/输出
-
open -
with -
CSV
-
PIL
第七讲
-
正则表达式
-
大小写敏感
-
清理用户输入
-
提取用户输入
-
总结
正则表达式
-
正则表达式或“regexes”将使我们能够检查代码中的模式。例如,我们可能想要验证电子邮件地址的格式是否正确。正则表达式将使我们能够以这种方式检查表达式。
-
首先,在终端窗口中输入
code validate.py。然后,在文本编辑器中按照以下方式编写代码:email = input("What's your email? ").strip() if "@" in email: print("Valid") else: print("Invalid")注意,
strip方法将移除输入字符串开头或结尾的空白字符。运行此程序,您将看到只要输入了@符号,程序就会将其视为有效。 -
您可以想象,然而,有人可能会输入
@@独自存在,并且输入可能会被视为有效。我们可以认为电子邮件地址至少包含一个@和一个.。修改您的代码如下:email = input("What's your email? ").strip() if "@" in email and "." in email: print("Valid") else: print("Invalid")注意,虽然这符合预期,但我们的用户可能是敌意的,简单地输入
@.就会导致程序返回valid。 -
我们可以改进程序的逻辑如下:
email = input("What's your email? ").strip() username, domain = email.split("@") if username and "." in domain: print("Valid") else: print("Invalid")注意
strip方法是如何用来判断username是否存在以及.是否在domain变量中的。运行此程序,您输入的标准电子邮件地址可能会被认为是valid。单独输入malan@harvard,您会发现程序将此输入视为invalid。 -
我们可以更加精确,修改我们的代码如下:
email = input("What's your email? ").strip() username, domain = email.split("@") if username and domain.endswith(".edu"): print("Valid") else: print("Invalid")注意
endswith方法将检查domain是否包含.edu。然而,恶意用户仍然可以破坏我们的代码。例如,一个用户可以输入malan@.edu,它将被认为是有效的。 -
事实上,我们可以自己不断迭代此代码。然而,结果证明,Python 有一个名为
re的现有库,它包含许多内置函数,可以验证用户输入与模式是否匹配。 -
库
re中最通用的函数之一是search。 -
search函数遵循以下签名re.search(pattern, string, flags=0)。根据此签名,我们可以修改我们的代码如下:import re email = input("What's your email? ").strip() if re.search("@", email): print("Valid") else: print("Invalid")注意,这并没有增加程序的功能。事实上,这有点像是退步。
-
我们可以进一步扩展程序的功能。然而,我们需要在
validation方面扩展我们的词汇。结果证明,在正则表达式的世界中,有一些符号允许我们识别模式。到目前为止,我们只检查了特定的文本片段,如@。事实上,许多特殊符号可以传递给解释器,用于进行验证。以下是一些模式的非详尽列表:. any character except a new line * 0 or more repetitions + 1 or more repetitions ? 0 or 1 repetition {m} m repetitions {m,n} m-n repetitions -
在我们的代码中实现此功能,修改如下:
import re email = input("What's your email? ").strip() if re.search(".+@.+", email): print("Valid") else: print("Invalid")注意,我们不在乎用户名或域名是什么。我们关心的是模式。
.+用于确定电子邮件地址左侧是否有任何内容,以及电子邮件地址右侧是否有任何内容。运行你的代码,输入malan@,你会发现输入被认为是无效的,正如我们所希望的。 -
如果我们在上面的代码中使用了正则表达式
.*@.*,你可以这样可视化它:![cs50pWeek7Slide8.png 状态机]()
注意正则表达式的
状态机描述。在左侧,解释器从左到右开始评估语句。一旦我们到达q1或第一个问题,解释器就会根据提供的表达式反复读取。然后,状态改变,现在正在查看q2或正在验证的第二个问题。再次,箭头指示表达式将根据我们的编程反复评估。然后,如双圆圈所示,达到状态机的最终状态。 -
考虑到我们在代码中使用的正则表达式
.+@.+,你可以这样可视化它:![cs50pWeek7Slide10.png 状态机]()
注意
q1是用户提供的任何字符,包括作为 1 或多个字符重复的'q2'。然后是@符号。接着,q3寻找用户提供的任何字符,包括作为 1 或多个字符重复的q4。 -
re和re.search函数以及类似的函数寻找模式。 -
继续改进我们的代码,我们可以这样改进我们的代码:
import re email = input("What's your email? ").strip() if re.search(".+@.+.edu", email): print("Valid") else: print("Invalid")注意,然而,你可以输入
malan@harvard?edu并被认为是有效的。这是为什么?你可能会意识到,在验证的语言中,.表示任何字符! -
我们可以修改我们的代码如下:
import re email = input("What's your email? ").strip() if re.search(r".+@.+\.edu", email): print("Valid") else: print("Invalid")注意我们如何利用“转义字符”或
\将.视为字符串的一部分,而不是验证表达式的一部分。测试你的代码,你会发现malan@harvard.edu被认为是有效的,而malan@harvard?edu是无效的。 -
现在我们正在使用转义字符,是时候介绍“原始字符串”了。在 Python 中,原始字符串是不格式化特殊字符的字符串——相反,每个字符都被当作字面值。例如,想象一下
\n。我们在之前的讲座中看到,在一个普通字符串中,这两个字符变成一个:一个特殊的换行符。然而,在原始字符串中,\n被视为不是\n这个特殊字符,而是单个\和单个n。在字符串前放置一个r告诉 Python 解释器将字符串视为原始字符串,类似于在字符串前放置一个f告诉 Python 解释器将字符串视为格式化字符串:import re email = input("What's your email? ").strip() if re.search(r"^.+@.+\.edu$", email): print("Valid") else: print("Invalid")现在我们已经确保 Python 解释器不会将
\.视为特殊字符。相反,它只是一个\后跟一个.——在正则表达式中,这意味着匹配一个字面量的.。 -
你可以想象我们的用户仍然可能给我们制造麻烦!例如,你可以输入一个句子,如
My email address is malan@harvard.edu.,整个句子都会被视为有效。我们可以使我们的编码更加精确。 -
恰好我们还有更多特殊符号可供使用:
^ matches the start of the string $ matches the end of the string or just before the newline at the end of the string -
我们可以使用我们添加的词汇表按如下方式修改我们的代码:
import re email = input("What's your email? ").strip() if re.search(r"^.+@.+\.edu$", email): print("Valid") else: print("Invalid")注意这会使得验证表达式在开始和结束处寻找这个精确的模式匹配。输入一个句子,如
My email address is malan@harvard.edu.,现在被视为无效。 -
我们提议我们可以做得更好!尽管我们现在正在寻找字符串开头的用户名、
@符号和结尾的域名,但我们可以输入任意多的@符号!malan@@@harvard.edu被视为有效! -
我们可以按如下方式扩展我们的词汇表:
[] set of characters [^] complementing the set -
使用这些新获得的能力,我们可以按如下方式修改我们的表达式:
import re email = input("What's your email? ").strip() if re.search(r"^[^@]+@[^@]+\.edu$", email): print("Valid") else: print("Invalid")注意到
^表示匹配字符串的开始。在我们的表达式的末尾,$表示匹配字符串的末尾。[^@]+表示除了@之外的任何字符。然后,我们有一个字面量@。[^@]+\.edu表示除了@之外的任何字符,后面跟着以.edu结尾的表达式。输入malan@@@harvard.edu现在被视为无效。 -
我们还可以进一步改进这个正则表达式。结果证明,电子邮件地址有一些特定的要求!目前,我们的验证表达式过于宽容。我们可能只想允许在句子中通常使用的字符。我们可以按如下方式修改我们的代码:
import re email = input("What's your email? ").strip() if re.search(r"^[a-zA-Z0-9_]+@[a-zA-Z0-9_]+\.edu$", email): print("Valid") else: print("Invalid")注意
[a-zA-Z0-9_]告诉验证,字符必须在a到z、A到Z、0到9之间,并且可能包括一个_符号。测试输入,你会发现许多潜在的用户错误可以被指示出来。 -
幸运的是,程序员们已经将常见的模式内置到正则表达式中。在这种情况下,你可以按如下方式修改你的代码:
import re email = input("What's your email? ").strip() if re.search(r"^\w+@\w+\.edu$", email): print("Valid") else: print("Invalid")注意
\w与[a-zA-Z0-9_]相同。感谢辛勤工作的程序员们! -
这里有一些额外的模式我们可以添加到我们的词汇表中:
\d decimal digit \D not a decimal digit \s whitespace characters \S not a whitespace character \w word character, as well as numbers and the underscore \W not a word character -
现在,我们知道不仅仅是
.edu电子邮件地址。我们可以按如下方式修改我们的代码:import re email = input("What's your email? ").strip() if re.search(r"^\w+@\w.+\.(com|edu|gov|net|org)$", email): print("Valid") else: print("Invalid")注意
|在我们的表达式中具有or的影响。 -
在我们的词汇表中添加更多符号,以下是一些需要考虑的:
A|B either A or B (...) a group (?:...) non-capturing version
大小写敏感性
-
为了说明如何处理关于大小写敏感性的问题,其中
EDU和edu以及类似的情况之间存在差异,让我们将我们的代码回滚到以下状态:import re email = input("What's your email? ").strip() if re.search(r"^\w+@\w+\.edu$", email): print("Valid") else: print("Invalid")注意我们已经移除了之前提供的
|语句。 -
回想一下,在
re.search函数中,有一个名为flags的参数。 -
一些内置的标志变量是:
re.IGNORECASE re.MULTILINE re.DOTALL考虑你如何在你的代码中使用这些。
-
因此,我们可以按照以下方式更改我们的代码。
import re email = input("What's your email? ").strip() if re.search(r"^\w+@\w+\.edu$", email, re.IGNORECASE): print("Valid") else: print("Invalid")注意我们添加了第三个参数
re.IGNORECASE。运行这个程序,输入MALAN@HARVARD.EDU,现在输入被认为是有效的。 -
考虑以下电子邮件地址
malan@cs50.harvard.edu。使用我们上面的代码,这将被认为是无效的。为什么可能是这样? -
由于多了一个额外的
.,程序认为这是无效的。 -
结果表明,我们可以从之前的学习词汇中,将一些想法分组在一起。
A|B either A or B (...) a group (?:...) non-capturing version -
我们可以按照以下方式修改我们的代码:
import re email = input("What's your email? ").strip() if re.search(r"^\w+@(\w+\.)?\w+\.edu$", email, re.IGNORECASE): print("Valid") else: print("Invalid")注意到
(\w+\.)?告诉解释器这个新表达式可以出现一次或根本不出现。因此,malan@cs50.harvard.edu和malan@harvard.edu都被认为是有效的。 -
趣味的是,我们到目前为止对代码所做的编辑并没有完全涵盖所有可以做的检查以确保有效的电子邮件地址。确实,以下是确保输入有效电子邮件地址时必须输入的完整表达式:
^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$ -
re库中还有其他你可能觉得有用的函数。re.match和re.fullmatch是你可能会发现极其有用的。 -
你可以在 Python 的re文档中了解更多信息。
清理用户输入
-
你永远不应该期望你的用户总是遵循你对干净输入的期望。实际上,用户经常会违反程序员的本意。
-
有方法可以清理你的数据。
-
在终端窗口中,输入
code format.py。然后,在文本编辑器中,按照以下方式编写代码:name = input("What's your name? ").strip() print(f"hello, {name}")注意我们实际上创建了一个“hello world”程序。运行这个程序并输入
David,它运行得很好!然而,输入Malan, David时,你会注意到程序并没有按预期工作。我们如何修改我们的程序来清理这个输入? -
按照以下方式修改你的代码。
name = input("What's your name? ").strip() if "," in name: last, first = name.split(", ") name = f"{first} {last}" print(f"hello, {name}")注意到
last, first = name.split(", ")在名字中有,时执行。然后,名字被标准化为 first 和 last。运行我们的代码,输入Malan, David,你可以看到这个程序至少清理了一个用户输入意外内容的情况。 -
你可能会注意到,在
Malan,David中不输入空格会导致解释器抛出错误。既然我们现在知道了某些正则表达式语法,让我们将其应用到我们的代码中:import re name = input("What's your name? ").strip() matches = re.search(r"^(.+), (.+)$", name) if matches: last, first = matches.groups() name = first + " " + last print(f"hello, {name}")注意到
re.search可以返回一组从用户输入中提取的匹配项。如果re.search返回匹配项,运行这个程序,输入David Malan,注意if条件没有执行,并且返回了名字。如果你通过输入Malan, David运行程序,名字也会正确返回。 -
恰好我们可以使用
matches.group请求特定的组。我们可以按照以下方式修改我们的代码:import re name = input("What's your name? ").strip() matches = re.search(r"^(.+), (.+)$", name) if matches: name = matches.group(2) + " " + matches.group(1) print(f"hello, {name}")注意在这个实现中,
group不是复数(没有s)。 -
我们可以将代码进一步优化如下:
import re name = input("What's your name? ").strip() matches = re.search(r"^(.+), (.+)$", name) if matches: name = matches.group(2) + " " + matches.group(1) print(f"hello, {name}")注意
group(2)和group(1)是如何用空格连接在一起的。第一个组是逗号左边的部分。第二个组是逗号右边的部分。 -
仍然要注意,如果输入
Malan,David时没有空格,这仍然会破坏我们的代码。因此,我们可以进行以下修改:import re name = input("What's your name? ").strip() matches = re.search(r"^(.+), *(.+)$", name) if matches: name = matches.group(2) + " " + matches.group(1) print(f"hello, {name}")注意我们在验证语句中添加了
*。现在这段代码将接受并正确处理Malan,David。此外,它还将正确处理前面有多个空格的David,Malan。 -
在之前的例子中,我们非常常见地使用
re.search,其中matches是在代码行之后的。然而,我们可以组合这些语句:import re name = input("What's your name? ").strip() if matches := re.search(r"^(.+), *(.+)$", name): name = matches.group(2) + " " + matches.group(1) print(f"hello, {name}")注意我们如何合并两行代码。walrus
:=操作符从右向左赋值,同时允许我们提出一个布尔问题。侧过头来看,你就会明白为什么这被称为 walrus 操作符。 -
你可以在 Python 的re文档中了解更多信息。
提取用户输入
-
到目前为止,我们已经验证了用户的输入并清理了用户的输入。
-
现在,让我们从用户输入中提取一些具体信息。在终端窗口中,输入
code twitter.py,然后在文本编辑器窗口中按如下方式编写代码:url = input("URL: ").strip() print(url)注意,如果我们输入
https://twitter.com/davidjmalan,它将显示用户输入的确切内容。然而,我们如何能够只提取用户名并忽略 URL 的其余部分? -
你可以想象我们如何简单地去除标准 Twitter URL 的开头部分。我们可以尝试如下操作:
url = input("URL: ").strip() username = url.replace("https://twitter.com/", "") print(f"Username: {username}")注意
replace方法如何允许我们找到一项并将其替换为另一项。在这种情况下,我们正在找到 URL 的一部分并将其替换为空。输入完整的 URLhttps://twitter.com/davidjmalan,程序实际上输出了用户名。然而,这个当前程序有哪些不足之处? -
如果用户只是简单地输入
twitter.com而没有包括https://等,会怎样?你可以想象出许多场景,用户可能会输入或遗漏输入 URL 的部分,这会导致程序输出奇怪的结果。为了改进这个程序,我们可以按如下方式编写代码:url = input("URL: ").strip() username = url.removeprefix("https://twitter.com/") print(f"Username: {username}")注意我们如何利用
removeprefix方法。这个方法将移除字符串的开头部分。 -
正则表达式仅仅允许我们简洁地表达模式和目标。
-
在
re库中,有一个名为sub的方法。这个方法允许我们用其他内容替换模式。 -
sub方法的签名如下re.sub(pattern, repl, string, count=0, flags=0)注意
pattern指的是我们正在寻找的正则表达式。然后是一个repl字符串,我们可以用它来替换模式。最后是我们要进行替换的string。 -
在我们的代码中实现此方法后,我们可以按如下方式修改我们的程序:
import re url = input("URL: ").strip() username = re.sub(r"https://twitter.com/", "", url) print(f"Username: {username}")注意执行此程序并输入
https://twitter.com/davidjmalan会产生正确的结果。然而,我们的代码中仍然存在一些问题。 -
协议、子域以及用户可能在用户名之后输入 URL 任何部分的可能性,这些都是此代码仍然不是最佳方案的原因。我们可以进一步解决这些缺点,如下所示:
import re url = input("URL: ").strip() username = re.sub(r"^(https?://)?(www\.)?twitter\.com/", "", url) print(f"Username: {username}")注意在 url 中添加了
^上标。注意.也可能被解释器错误地解释。因此,我们使用\来转义它,使其变为\.。为了容忍http和https,我们在https?的末尾添加一个?,使s可选。此外,为了适应www,我们在代码中添加(www\.)?。最后,以防用户决定完全省略协议,我们将http://或https://设置为可选,使用(https?://)。 -
尽管如此,我们仍然盲目地期望用户输入的 URL 确实包含用户名。
-
利用我们对
re.search的了解,我们可以进一步改进我们的代码。import re url = input("URL: ").strip() matches = re.search(r"^https?://(www\.)?twitter\.com/(.+)$", url, re.IGNORECASE) if matches: print(f"Username:", matches.group(2))注意我们是如何在用户提供的字符串中搜索上述正则表达式的。特别是,我们使用
(.+)$正则表达式捕获 URL 末尾出现的内容。因此,如果用户没有输入不带用户名的 URL,则不会显示任何输入。 -
进一步收紧我们的程序,我们可以利用我们的
:=操作符如下:import re url = input("URL: ").strip() if matches := re.search(r"^https?://(?:www\.)?twitter\.com/(.+)$", url, re.IGNORECASE): print(f"Username:", matches.group(1))注意到
?:告诉解释器它不需要捕获正则表达式中的那个位置的内容。 -
尽管如此,我们可以更加明确地确保输入的用户名是正确的。使用 Twitter 的文档,我们可以在我们的正则表达式中添加以下内容:
import re url = input("URL: ").strip() if matches := re.search(r"^https?://(?:www\.)?twitter\.com/([a-z0-9_]+)", url, re.IGNORECASE): print(f"Username:", matches.group(1))注意
[a-z0-9_]+告诉解释器只期望a-z、0-9和_作为正则表达式的一部分。+表示我们期望一个或多个字符。 -
你可以在 Python 的re文档中了解更多信息。
总结
现在,你已经学会了一种全新的正则表达式语言,可以用来验证、清理和提取用户输入。
-
正则表达式
-
区分大小写
-
清理用户输入
-
提取用户输入
第八讲
-
面向对象编程
-
类
-
抛出异常
-
装饰器
-
将课程中的先前工作联系起来
-
类方法
-
静态方法
-
继承
-
继承和异常
-
运算符重载
-
总结
面向对象编程
-
编程有不同的范式。当你学习其他语言时,你将开始识别这些模式。
-
到目前为止,你一直是按步骤进行过程式编程的。
-
面向对象编程(OOP)是解决编程相关问题的有力解决方案。
-
首先,在终端窗口中输入
code student.py,然后按照以下方式编写代码:name = input("Name: ") house = input("House: ") print(f"{name} from {house}")注意这个程序遵循的是一种过程式、按步骤的范式:就像你在课程的前几部分看到的那样。
-
借鉴前几周的工作,我们可以创建函数来抽象掉程序的一部分。
def main(): name = get_name() house = get_house() print(f"{name} from {house}") def get_name(): return input("Name: ") def get_house(): return input("House: ") if __name__ == "__main__": main()注意
get_name和get_house如何抽象掉main函数的一些需求。此外,注意代码的最后一行是如何告诉解释器运行main函数的。 -
我们可以通过将学生存储为
tuple来进一步简化我们的程序。tuple是一系列值。与list不同,tuple不能被修改。在精神上,我们正在返回两个值。def main(): name, house = get_student() print(f"{name} from {house}") def get_student(): name = input("Name: ") house = input("House: ") return name, house if __name__ == "__main__": main()注意
get_student返回name, house。 -
将
tuple打包,以便我们能够将两个项目返回到名为student的变量中,我们可以按如下方式修改我们的代码。def main(): student = get_student() print(f"{student[0]} from {student[1]}") def get_student(): name = input("Name: ") house = input("House: ") return (name, house) if __name__ == "__main__": main()注意
(name, house)明确地告诉阅读我们代码的人,我们在一个返回值中返回两个值。此外,注意我们如何使用student[0]或student[1]来索引tuple。 -
tuple是不可变的,这意味着我们无法更改这些值。不可变性是我们进行防御性编程的一种方式。def main(): student = get_student() if student[0] == "Padma": student[1] = "Ravenclaw" print(f"{student[0]} from {student[1]}") def get_student(): name = input("Name: ") house = input("House: ") return name, house if __name__ == "__main__": main()注意,这段代码会产生错误。由于
tuple是不可变的,我们无法重新分配student[1]的值。 -
如果我们想要给其他程序员提供灵活性,我们可以使用
list如下。def main(): student = get_student() if student[0] == "Padma": student[1] = "Ravenclaw" print(f"{student[0]} from {student[1]}") def get_student(): name = input("Name: ") house = input("House: ") return [name, house] if __name__ == "__main__": main()注意列表是可变的。也就是说,
house和name的顺序可以被程序员切换。你可能会决定在某些需要提供更多灵活性但以代码安全性为代价的情况下使用它。毕竟,如果这些值的顺序可以更改,与你一起工作的程序员可能会在将来犯错误。 -
在这个实现中也可以使用字典。回想一下,字典提供键值对。
def main(): student = get_student() print(f"{student['name']} from {student['house']}") def get_student(): student = {} student["name"] = input("Name: ") student["house"] = input("House: ") return student if __name__ == "__main__": main()注意在这个例子中,返回了两个键值对。这种方法的优点是我们可以使用键来索引这个字典。
-
尽管如此,我们的代码还可以进一步改进。请注意,存在一个不必要的变量。我们可以移除
student = {},因为我们不需要创建一个空字典。def main(): student = get_student() print(f"{student['name']} from {student['house']}") def get_student(): name = input("Name: ") house = input("House: ") return {"name": name, "house": house} if __name__ == "__main__": main()注意我们可以在
return语句中使用{}大括号来创建字典并在同一行返回它。 -
我们可以在我们的代码字典版本中为 Padma 提供一个特殊案例。
def main(): student = get_student() if student["name"] == "Padma": student["house"] = "Ravenclaw" print(f"{student['name']} from {student['house']}") def get_student(): name = input("Name: ") house = input("House: ") return {"name": name, "house": house} if __name__ == "__main__": main()注意,与之前代码的迭代类似,我们可以利用键名来索引我们的学生字典。
类
-
在面向对象编程中,类提供了一种创建我们自己的数据类型并为其命名的方法。
-
类就像是一种数据类型的模具——在那里我们可以发明我们自己的数据类型并为其命名。
-
我们可以按照以下方式修改我们的代码来实现我们自己的名为
Student的类:class Student: ... def main(): student = get_student() print(f"{student.name} from {student.house}") def get_student(): student = Student() student.name = input("Name: ") student.house = input("House: ") return student if __name__ == "__main__": main()注意按照惯例,
Student是大写的。进一步,注意...简单地意味着我们将在稍后返回并完成代码的这一部分。进一步,注意在get_student中,我们可以使用语法student = Student()创建一个Student类的student。进一步,注意我们利用“点表示法”来访问这个student变量的属性。 -
任何时候你创建一个类并利用这个蓝图来创建东西,你就创建了一个“对象”或“实例”。在我们的代码中,
student是一个对象。 -
此外,我们可以为期望在类
Student的对象内部拥有的属性打下一些基础。我们可以按照以下方式修改我们的代码:class Student: def __init__(self, name, house): self.name = name self.house = house def main(): student = get_student() print(f"{student.name} from {student.house}") def get_student(): name = input("Name: ") house = input("House: ") student = Student(name, house) return student if __name__ == "__main__": main()注意在
Student中,我们标准化了这个类的属性。我们可以在class Student中创建一个函数,称为“方法”,它决定了类Student的对象的行为。在这个函数中,它接收传递给它的name和house并将这些变量分配给这个对象。进一步,注意构造函数student = Student(name, house)在Student类中调用这个函数并创建一个student。self指的是刚刚创建的当前对象。 -
我们可以将代码简化如下:
class Student: def __init__(self, name, house): self.name = name self.house = house def main(): student = get_student() print(f"{student.name} from {student.house}") def get_student(): name = input("Name: ") house = input("House: ") return Student(name, house) if __name__ == "__main__": main()注意
return Student(name, house)如何简化了我们之前代码中的迭代,其中构造函数语句单独占一行。 -
你可以在 Python 的类文档中了解更多信息。
raise
-
面向对象编程鼓励你将类的所有功能封装在类定义中。如果出了问题怎么办?如果有人输入了随机的数据怎么办?如果有人试图创建一个没有名字的学生怎么办?请按照以下方式修改你的代码:
class Student: def __init__(self, name, house): if not name: raise ValueError("Missing name") if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]: raise ValueError("Invalid house") self.name = name self.house = house def main(): student = get_student() print(f"{student.name} from {student.house}") def get_student(): name = input("Name: ") house = input("House: ") return Student(name, house) if __name__ == "__main__": main()注意我们现在检查是否提供了名字并且指定了合适的宿舍。结果证明我们可以创建自己的异常,通过
raise通知程序员用户可能创建的错误。在上面的例子中,我们使用特定的错误消息引发ValueError。 -
碰巧的是,Python 允许你创建一个特定的函数,通过它可以打印对象的属性。按照以下方式修改你的代码:
class Student: def __init__(self, name, house, patronus): if not name: raise ValueError("Missing name") if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]: raise ValueError("Invalid house") self.name = name self.house = house self.patronus = patronus def __str__(self): return f"{self.name} from {self.house}" def main(): student = get_student() print(student) def get_student(): name = input("Name: ") house = input("House: ") patronus = input("Patronus: ") return Student(name, house, patronus) if __name__ == "__main__": main()注意
def __str__(self)提供了一种在调用时返回学生的方式。因此,现在作为程序员,你可以打印对象、其属性或与该对象相关的几乎所有内容。 -
__str__是 Python 类自带的一个内置方法。碰巧的是,我们也可以为类创建自己的方法!按照以下方式修改你的代码:class Student: def __init__(self, name, house, patronus=None): if not name: raise ValueError("Missing name") if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]: raise ValueError("Invalid house") if patronus and patronus not in ["Stag", "Otter", "Jack Russell terrier"]: raise ValueError("Invalid patronus") self.name = name self.house = house self.patronus = patronus def __str__(self): return f"{self.name} from {self.house}" def charm(self): match self.patronus: case "Stag": return "🐴" case "Otter": return "🦦" case "Jack Russell terrier": return "🐶" case _: return "🪄" def main(): student = get_student() print("Expecto Patronum!") print(student.charm()) def get_student(): name = input("Name: ") house = input("House: ") patronus = input("Patronus: ") or None return Student(name, house, patronus) if __name__ == "__main__": main()注意我们定义了自己的方法
charm。与字典不同,类可以有内置的函数,称为方法。在这种情况下,我们定义了charm方法,其中特定的案例有特定的结果。此外,注意 Python 有能力在我们的代码中直接使用表情符号。 -
在继续前进之前,让我们移除我们的守护神代码。按照以下方式修改你的代码:
class Student: def __init__(self, name, house): if not name: raise ValueError("Invalid name") if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]: raise ValueError("Invalid house") self.name = name self.house = house def __str__(self): return f"{self.name} from {self.house}" def main(): student = get_student() student.house = "Number Four, Privet Drive" print(student) def get_student(): name = input("Name: ") house = input("House: ") return Student(name, house) if __name__ == "__main__": main()注意我们只有两个方法:
__init__和__str__。
装饰器
-
属性可以被用来加固我们的代码。在 Python 中,我们使用以
@开头的函数“装饰器”来定义属性。按照以下方式修改你的代码:class Student: def __init__(self, name, house): if not name: raise ValueError("Invalid name") self.name = name self.house = house def __str__(self): return f"{self.name} from {self.house}" # Getter for house @property def house(self): return self._house # Setter for house @house.setter def house(self, house): if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]: raise ValueError("Invalid house") self._house = house def main(): student = get_student() print(student) def get_student(): name = input("Name: ") house = input("House: ") return Student(name, house) if __name__ == "__main__": main()注意我们是如何在名为
house的函数上方写上@property的。这样做定义了house为我们类的一个属性。有了house属性,我们就能定义如何设置和检索我们类的一些属性,例如_house。确实,我们现在可以通过@house.setter定义一个名为“setter”的函数,每当设置 house 属性时都会被调用——例如,使用student.house = "Gryffindor"。在这里,我们让我们的 setter 为我们验证house的值。注意,如果house的值不是哈利·波特的任何一个学院,我们会抛出一个ValueError,否则我们会使用house更新_house的值。为什么是_house而不是house?house是我们类的一个属性,用户通过它尝试设置我们的类属性。_house是那个类属性本身。前导下划线_表示用户不需要(实际上也不应该!)直接修改这个值。_house应该仅通过housesetter 来设置。注意house属性只是简单地返回_house的值,这是我们通过housesetter 可能已经验证过的类属性。当用户调用student.house时,他们通过我们的house“getter” 获取_house的值。 -
除了房子的名字,我们还可以保护我们学生的名字。按照以下方式修改你的代码:
class Student: def __init__(self, name, house): self.name = name self.house = house def __str__(self): return f"{self.name} from {self.house}" # Getter for name @property def name(self): return self._name # Setter for name @name.setter def name(self, name): if not name: raise ValueError("Invalid name") self._name = name @property def house(self): return self._house @house.setter def house(self, house): if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]: raise ValueError("Invalid house") self._house = house def main(): student = get_student() print(student) def get_student(): name = input("Name: ") house = input("House: ") return Student(name, house) if __name__ == "__main__": main()注意,和之前的代码类似,我们为名称提供了 getter 和 setter。
-
你可以在 Python 的 方法 文档中了解更多信息。
连接到本课程中的先前工作
-
尽管在课程的前几部分没有明确说明,但你一直在使用类和对象。
-
如果你深入研究
int的文档,你会发现它是一个具有构造函数的类。它是创建int类型对象的蓝图。你可以在 Python 的int文档中了解更多信息,链接为Python 的int文档。 -
字符串也是一个类。如果你使用过
str.lower(),你就是在使用str类中的方法。你可以在 Python 的str文档中了解更多信息,链接为Python 的str文档。 -
list也是一个类。查看list的文档,你可以看到其中包含的方法,如list.append()。你可以在 Python 的list文档中了解更多信息,链接为Python 的list文档。 -
dict也是 Python 中的一个类。你可以在 Python 的dict文档中了解更多信息,链接为Python 的dict文档。 -
要了解你一直是如何使用类的,请打开你的控制台,输入
code type.py,然后按照以下方式编写代码:print(type(50))注意,通过执行这段代码,它将显示
50的类是int。 -
我们也可以将此应用于
str,如下所示:print(type("hello, world"))注意,执行这段代码将表明这是
str类。 -
我们也可以按照以下方式应用于
list:print(type([]))注意,执行这段代码将表明这是
list类。 -
我们也可以使用 Python 内置的
list类的名称来应用于list,如下所示:print(type(list()))注意,执行这段代码将表明这是
list类。 -
我们也可以将此应用于
dict,如下所示:print(type({}))注意,执行这段代码将表明这是
dict类。 -
我们也可以使用 Python 内置的
dict类的名称来应用于dict,如下所示:print(type(dict()))注意,执行这段代码将表明这是
dict类。
类方法
-
有时候,我们希望给类本身添加功能,而不是给该类的实例添加。
-
@classmethod是一个函数,我们可以用它来给整个类添加功能。 -
这里是一个不使用类方法的例子。在你的终端窗口中,输入
code hat.py并按照以下方式编写代码:import random class Hat: def __init__(self): self.houses = ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"] def sort(self, name): print(name, "is in", random.choice(self.houses)) hat = Hat() hat.sort("Harry")注意,当我们把学生的名字传递给排序帽子时,它会告诉我们学生被分配到了哪个学院。注意
hat = Hat()实例化了hat。sort功能始终由类的实例处理。通过执行hat.sort("Harry"),我们向Hat的特定实例的sort方法传递了学生的名字,我们称之为hat。 -
然而,我们可能希望运行
sort函数而不创建特定的排序帽子实例(毕竟只有一个)。我们可以修改我们的代码如下:import random class Hat: houses = ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"] @classmethod def sort(cls, name): print(name, "is in", random.choice(cls.houses)) Hat.sort("Harry")注意到
__init__方法被移除,因为我们不需要在我们的代码中的任何地方实例化一顶帽子。因此,self就不再相关,并被移除。我们指定这个sort为一个@classmethod,用cls替换self。最后,注意在代码的末尾,根据惯例,Hat被大写,因为这是我们的类名。 -
返回到
students.py,我们可以修改我们的代码如下,解决一些与@classmethod相关的遗漏机会:class Student: def __init__(self, name, house): self.name = name self.house = house def __str__(self): return f"{self.name} from {self.house}" @classmethod def get(cls): name = input("Name: ") house = input("House: ") return cls(name, house) def main(): student = Student.get() print(student) if __name__ == "__main__": main()注意到
get_student被移除,并创建了一个名为get的@classmethod。现在,可以调用此方法而无需首先创建一个学生。
静态方法
-
结果表明,除了与实例方法不同的
@classmethod之外,还有其他类型的函数。 -
使用
@staticmethod可能是你希望探索的事情。虽然本课程没有明确涵盖,但你欢迎去学习更多关于静态方法和它们与类方法的区别。
继承
-
继承可能是面向对象编程中最强大的特性。
-
恰好可以创建一个“继承”其他类的方法、变量和属性的类。
-
在终端中,执行
code wizard.py。编写如下代码:class Wizard: def __init__(self, name): if not name: raise ValueError("Missing name") self.name = name ... class Student(Wizard): def __init__(self, name, house): super().__init__(name) self.house = house ... class Professor(Wizard): def __init__(self, name, subject): super().__init__(name) self.subject = subject ... wizard = Wizard("Albus") student = Student("Harry", "Gryffindor") professor = Professor("Severus", "Defense Against the Dark Arts") ...注意到有一个名为
Wizard的类和一个名为Student的类。此外,还有一个名为Professor的类。学生和教授都有名字。学生和教授都是巫师。因此,Student和Professor继承了Wizard的特性。在“子”类Student中,Student可以从“父”或“超”类Wizard继承,如super().__init__(name)运行Wizard的init方法。最后,注意代码的最后几行创建了一个名为 Albus 的巫师,一个名为 Harry 的学生,等等。
继承和异常
-
虽然我们刚刚介绍了继承,但我们一直在使用异常时使用它。
-
恰好异常有一个层次结构,其中包含子类、父类和祖父母类。这些在下图中展示:
BaseException +-- KeyboardInterrupt +-- Exception +-- ArithmeticError | +-- ZeroDivisionError +-- AssertionError +-- AttributeError +-- EOFError +-- ImportError | +-- ModuleNotFoundError +-- LookupError | +-- KeyError +-- NameError +-- SyntaxError | +-- IndentationError +-- ValueError ... -
你可以在 Python 的 异常 文档中了解更多信息。
运算符重载
-
一些运算符,如
+和-,可以被“重载”,以便它们可以拥有超出简单算术的更多能力。 -
在你的终端窗口中,输入
code vault.py。然后,编写如下代码:class Vault: def __init__(self, galleons=0, sickles=0, knuts=0): self.galleons = galleons self.sickles = sickles self.knuts = knuts def __str__(self): return f"{self.galleons} Galleons, {self.sickles} Sickles, {self.knuts} Knuts" def __add__(self, other): galleons = self.galleons + other.galleons sickles = self.sickles + other.sickles knuts = self.knuts + other.knuts return Vault(galleons, sickles, knuts) potter = Vault(100, 50, 25) print(potter) weasley = Vault(25, 50, 100) print(weasley) total = potter + weasley print(total)注意到
__str__方法返回一个格式化的字符串。此外,注意到__add__方法允许两个保险库值的相加。self是+运算符左侧的内容。other是+运算符右侧的内容。 -
你可以在 Python 的 运算符重载 文档中了解更多信息。
总结
现在,你已经通过面向对象编程学习了一个全新的能力级别。
-
面向对象编程
-
类
-
raise -
类方法
-
静态方法
-
继承
-
运算符重载
第九讲
-
等等
-
set -
全局变量
-
常量
-
类型提示
-
文档字符串
-
argparse -
解包
-
args和kwargs -
map -
列表推导式
-
filter -
字典推导式
-
enumerate -
生成器和迭代器
-
恭喜!
-
这是 CS50!
等等
-
在过去的许多课程中,我们已经涵盖了与 Python 相关的许多内容!
-
在本课中,我们将关注许多之前未讨论的“等等”项目。“Et cetera”字面意思是“等等”。
-
的确,如果你查看 Python 文档,你会找到许多其他功能。
set
-
在数学中,一个集合会被认为是一个没有重复数字的数字集合。
-
在文本编辑器窗口中,按照以下方式编写代码:
students = [ {"name": "Hermione", "house": "Gryffindor"}, {"name": "Harry", "house": "Gryffindor"}, {"name": "Ron", "house": "Gryffindor"}, {"name": "Draco", "house": "Slytherin"}, {"name": "Padma", "house": "Ravenclaw"}, ] houses = [] for student in students: if student["house"] not in houses: houses.append(student["house"]) for house in sorted(houses): print(house)注意到我们有一个字典列表,每个字典代表一个学生。创建了一个名为
houses的空列表。我们遍历students中的每个student。如果一个学生的house不在houses中,我们就将其添加到我们的houses列表中。 -
结果表明,我们可以使用内置的
set功能来消除重复项。 -
在文本编辑器窗口中,按照以下方式编写代码:
students = [ {"name": "Hermione", "house": "Gryffindor"}, {"name": "Harry", "house": "Gryffindor"}, {"name": "Ron", "house": "Gryffindor"}, {"name": "Draco", "house": "Slytherin"}, {"name": "Padma", "house": "Ravenclaw"}, ] houses = set() for student in students: houses.add(student["house"]) for house in sorted(houses): print(house)注意到我们不需要包含任何检查来确保没有重复项。
set对象会自动为我们处理这个问题。 -
你可以在 Python 的文档中了解更多关于
set的信息:Python 的set文档。
全局变量
-
在其他编程语言中,存在全局变量的概念,这些变量可以被任何函数访问。
-
我们可以利用 Python 中的这一功能。在文本编辑器窗口中,按照以下方式编写代码:
balance = 0 def main(): print("Balance:", balance) if __name__ == "__main__": main()注意到我们如何在任何函数之外创建一个名为
balance的全局变量。 -
由于执行上述代码没有出现错误,你可能会认为一切正常。然而,事实并非如此!在文本编辑器窗口中,按照以下方式编写代码:
balance = 0 def main(): print("Balance:", balance) deposit(100) withdraw(50) print("Balance:", balance) def deposit(n): balance += n def withdraw(n): balance -= n if __name__ == "__main__": main()注意我们现在添加了向
balance添加和提取资金的功能。然而,执行此代码时,我们遇到了一个错误!我们看到一个名为UnboundLocalError的错误。你可能能够猜到,至少在我们当前编写的balance和deposit以及withdraw函数的方式中,我们无法在函数内部重新分配它的新值。 -
要在函数内部与全局变量交互,解决方案是使用
global关键字。在文本编辑器窗口中,按照以下方式编写代码:balance = 0 def main(): print("Balance:", balance) deposit(100) withdraw(50) print("Balance:", balance) def deposit(n): global balance balance += n def withdraw(n): global balance balance -= n if __name__ == "__main__": main()注意到
global关键字告诉每个函数,balance并不指向一个局部变量:相反,它指向我们在代码顶部最初放置的全局变量。现在,我们的代码可以正常工作了! -
利用我们从面向对象编程中获得的经验,我们可以修改我们的代码,使用类而不是全局变量。在文本编辑器窗口中,编写以下代码:
class Account: def __init__(self): self._balance = 0 @property def balance(self): return self._balance def deposit(self, n): self._balance += n def withdraw(self, n): self._balance -= n def main(): account = Account() print("Balance:", account.balance) account.deposit(100) account.withdraw(50) print("Balance:", account.balance) if __name__ == "__main__": main()注意,我们如何使用
account = Account()来创建一个账户。类允许我们更干净地解决需要全局变量的这个问题,因为这些实例变量可以通过self访问本类的所有方法。 -
一般而言,全局变量应该非常谨慎地使用,如果必须使用的话!
常量
-
一些语言允许您创建不可更改的变量,称为“常量”。常量允许程序员进行防御性编程,并减少重要值被更改的机会。
-
在文本编辑器窗口中,编写以下代码:
MEOWS = 3 for _ in range(MEOWS): print("meow")注意,在这个例子中,
MEOWS是我们的常量。常量通常用大写变量名表示,并放置在代码的顶部。尽管这 看起来 像一个常量,但实际上,Python 实际上没有机制来阻止我们在代码中更改该值!相反,您需要遵守诚信原则:如果变量名全部大写,就请不要更改它! -
您可以创建一个名为“常量”的类,现在我们用引号括起来,因为我们知道 Python 并不完全支持“常量”。在文本编辑器窗口中,编写以下代码:
class Cat: MEOWS = 3 def meow(self): for _ in range(Cat.MEOWS): print("meow") cat = Cat() cat.meow()因为
MEOWS是在任何一个特定类方法之外定义的,所以所有这些方法都可以通过Cat.MEOWS访问该值。
类型提示
-
在其他编程语言中,您需要明确表达您想要使用的变量类型。
-
如我们在课程中较早看到的,Python 不需要显式声明类型。
-
尽管如此,确保所有变量都是正确的类型是一个好的实践。
-
mypy是一个程序,可以帮助您测试以确保所有变量都是正确的类型。 -
您可以通过在终端窗口中执行以下命令来安装
mypy:pip install mypy。
在文本编辑器窗口中,编写以下代码:
def meow(n):
for _ in range(n):
print("meow")
number = input("Number: ")
meow(number)
您可能已经看到,number = input("Number: )" 返回了一个 string,而不是 int。但 meow 很可能需要一个 int!
-
可以添加类型提示来给 Python 提示
meow应该期望的变量类型。在文本编辑器窗口中,编写以下代码:def meow(n: int): for _ in range(n): print("meow") number = input("Number: ") meow(number)注意,尽管如此,我们的程序仍然会抛出错误。
-
安装
mypy后,在终端窗口中执行mypy meows.py。mypy将提供一些关于如何修复此错误的指导。 -
您可以对所有变量进行注释。在文本编辑器窗口中,编写以下代码:
def meow(n: int): for _ in range(n): print("meow") number: int = input("Number: ") meow(number)注意,现在
number被提供了一个类型提示。 -
再次强调,在终端窗口中执行
mypy meows.py可以为您提供更具体的反馈。 -
我们可以通过以下方式修复我们的最终错误:
def meow(n: int): for _ in range(n): print("meow") number: int = int(input("Number: ")) meow(number)注意,现在运行
mypy没有错误,因为我们已经将输入转换为整数。 -
让我们通过假设
meow将返回一个字符串,或str,来引入一个新的错误。在文本编辑器窗口中,编写以下代码:def meow(n: int): for _ in range(n): print("meow") number: int = int(input("Number: ")) meows: str = meow(number) print(meows)注意
meow函数只有一个副作用。因为我们只尝试打印meow,而不是返回一个值,所以当我们尝试将meow的返回值存储在meows中时,会抛出一个错误。 -
我们还可以进一步使用类型提示来检查错误,这次注释函数的返回值。在文本编辑器窗口中,代码如下:
def meow(n: int) -> None: for _ in range(n): print("meow") number: int = int(input("Number: ")) meows: str = meow(number) print(meows)注意到
-> None的表示法告诉mypy没有返回值。 -
如果我们希望返回一个字符串,我们可以修改我们的代码:
def meow(n: int) -> str: return "meow\n" * n number: int = int(input("Number: ")) meows: str = meow(number) print(meows, end="")注意我们如何在
meows中存储多个str。运行mypy不会产生错误。 -
你可以在 Python 的Type Hints文档中了解更多信息。
-
你可以通过程序的自身文档了解更多关于
mypy的信息。
Docstrings
-
使用 docstring 来注释函数的目的是一种标准做法。在文本编辑器窗口中,代码如下:
def meow(n): """Meow n times.""" return "meow\n" * n number = int(input("Number: ")) meows = meow(number) print(meows, end="")注意三个双引号指定了函数的功能。
-
你可以使用 docstrings 来标准化你如何记录函数的特性。在文本编辑器窗口中,代码如下:
def meow(n): """ Meow n times. :param n: Number of times to meow :type n: int :raise TypeError: If n is not an int :return: A string of n meows, one per line :rtype: str """ return "meow\n" * n number = int(input("Number: ")) meows = meow(number) print(meows, end="")注意到包含了多个 docstring 参数。例如,它描述了函数接受的参数以及函数返回的内容。
-
建立的标准工具,如Sphinx,可以用来解析 docstrings,并自动以网页和 PDF 文件的形式为我们创建文档,这样你就可以发布和与他人分享。
-
你可以在 Python 的docstrings文档中了解更多信息。
argparse
-
假设我们想在程序中使用命令行参数。在文本编辑器窗口中,代码如下:
import sys if len(sys.argv) == 1: print("meow") elif len(sys.argv) == 3 and sys.argv[1] == "-n": n = int(sys.argv[2]) for _ in range(n): print("meow") else: print("usage: meows.py [-n NUMBER]")注意
sys是如何被导入的,通过它我们可以访问到sys.argv,这是一个数组,包含了运行程序时提供给我们的命令行参数。我们可以使用多个if语句来检查用户是否正确地运行了我们的程序。 -
假设这个程序将会变得更加复杂。我们该如何检查用户可能插入的所有参数呢?如果我们有超过几个命令行参数,我们可能会放弃!
-
幸运的是,
argparse是一个处理复杂命令行参数字符串解析的库。在文本编辑器窗口中,代码如下:import argparse parser = argparse.ArgumentParser() parser.add_argument("-n") args = parser.parse_args() for _ in range(int(args.n)): print("meow")注意我们是如何导入
argparse而不是sys的。从ArgumentParser类创建了一个名为parser的对象。该类的add_argument方法用于告诉argparse,当用户运行我们的程序时,我们应该期望从用户那里得到哪些参数。最后,运行解析器的parse_args方法确保用户已经正确地包括了所有参数。 -
我们还可以编写更干净的代码,这样当用户未能正确使用程序时,他们可以获取一些关于我们代码正确使用方法的信息。在文本编辑器窗口中,代码如下:
import argparse parser = argparse.ArgumentParser(description="Meow like a cat") parser.add_argument("-n", help="number of times to meow") args = parser.parse_args() for _ in range(int(args.n)): print("meow")注意到用户提供了一些文档。具体来说,提供了一个
help参数。现在,如果用户执行python meows.py --help或-h,用户将看到一些关于如何使用此程序的提示。 -
我们可以进一步改进这个程序。在文本编辑器窗口中,代码如下:
import argparse parser = argparse.ArgumentParser(description="Meow like a cat") parser.add_argument("-n", default=1, help="number of times to meow", type=int) args = parser.parse_args() for _ in range(args.n): print("meow")注意到不仅包含了帮助文档,而且当用户没有提供任何参数时,你还可以提供一个
默认值。 -
你可以在 Python 的
argparse文档中了解更多信息。argparse。
解包
-
不想能够将一个变量分割成两个变量不是很好吗?在文本编辑器窗口中,代码如下:
first, _ = input("What's your name? ").split(" ") print(f"hello, {first}")注意到这个程序尝试通过简单地在一个空格上进行分割来获取用户的名字。
-
结果表明,还有其他方法可以解包变量。通过理解如何以看似更高级的方式解包变量,你可以编写更强大、更优雅的代码。在文本编辑器窗口中,代码如下:
def total(galleons, sickles, knuts): return (galleons * 17 + sickles) * 29 + knuts print(total(100, 50, 25), "Knuts")注意到这返回了 Knuts 的总价值。
-
如果我们想要将硬币存储在一个列表中?在文本编辑器窗口中,代码如下:
def total(galleons, sickles, knuts): return (galleons * 17 + sickles) * 29 + knuts coins = [100, 50, 25] print(total(coins[0], coins[1], coins[2]), "Knuts")注意到创建了一个名为
coins的列表。我们可以通过索引使用0、1等来传递每个值。 -
这变得相当冗长。如果我们能够简单地将硬币列表传递给我们的函数,不是很好吗?
-
为了使传递整个列表成为可能,我们可以使用解包。在文本编辑器窗口中,代码如下:
def total(galleons, sickles, knuts): return (galleons * 17 + sickles) * 29 + knuts coins = [100, 50, 25] print(total(*coins), "Knuts")注意到
*如何解包列表的序列,并将每个单独的元素传递给total。 -
假设我们可以以任何顺序传递货币的名称?在文本编辑器窗口中,代码如下:
def total(galleons, sickles, knuts): return (galleons * 17 + sickles) * 29 + knuts print(total(galleons=100, sickles=50, knuts=25), "Knuts")注意到这仍然计算正确。
-
当你开始谈论“名称”和“值”时,字典可能会浮现在你的脑海中!你可以将其实现为一个字典。在文本编辑器窗口中,代码如下:
def total(galleons, sickles, knuts): return (galleons * 17 + sickles) * 29 + knuts coins = {"galleons": 100, "sickles": 50, "knuts": 25} print(total(coins["galleons"], coins["sickles"], coins["knuts"]), "Knuts")注意到提供了一个名为
coins的字典。我们可以使用键,如“galleons”或“sickles”来索引它。 -
由于
total函数期望三个参数,我们不能传递一个字典。我们可以使用解包来帮助解决这个问题。在文本编辑器窗口中,代码如下:def total(galleons, sickles, knuts): return (galleons * 17 + sickles) * 29 + knuts coins = {"galleons": 100, "sickles": 50, "knuts": 25} print(total(**coins), "Knuts")注意到
**允许你解包一个字典。在解包字典时,它提供了键和值。
args和kwargs
-
回想一下我们在这门课程中之前看到的
print文档:print(*objects, sep=' ', end='\n', file=sys.stdout, flush=False) -
args是位置参数,例如我们提供给print的print("Hello", "World")。 -
kwargs是命名参数,或称为“关键字参数”,例如我们提供给print的print(end="")。 -
正如我们在上面
print函数的原型中看到的,我们可以告诉我们的函数期望一个目前未知数量的位置参数。我们也可以告诉它期望一个目前未知数量的关键字参数。在文本编辑器窗口中,代码如下:def f(*args, **kwargs): print("Positional:", args) f(100, 50, 25)注意到执行此代码将打印为位置参数。
-
我们甚至可以传递命名参数。在文本编辑器窗口中,编写如下代码:
def f(*args, **kwargs): print("Named:", kwargs) f(galleons=100, sickles=50, knuts=25)注意命名值是以字典的形式提供的。
-
考虑到上面的
print函数,你可以看到*objects可以接受任意数量的位置参数。 -
你可以在 Python 的文档中了解更多关于
print的信息:print。
map
-
早期,我们开始了过程式编程。
-
我们后来揭示了 Python 是一种面向对象的编程语言。
-
我们看到了函数式编程的暗示,其中函数有副作用但没有返回值。我们可以在文本编辑器窗口中演示,输入
code yell.py并编写如下代码:def main(): yell("This is CS50") def yell(word): print(word.upper()) if __name__ == "__main__": main()注意
yell函数是如何简单地被喊出来的。 -
不想喊一个无限单词的列表吗?修改你的代码如下:
def main(): yell(["This", "is", "CS50"]) def yell(words): uppercased = [] for word in words: uppercased.append(word.upper()) print(*uppercased) if __name__ == "__main__": main()注意我们是如何累积大写单词的,通过迭代每个单词并对它们进行“大写化”。使用
*解包,我们打印出大写列表。 -
移除括号后,我们可以将单词作为参数传递。在文本编辑器窗口中,编写如下代码:
def main(): yell("This", "is", "CS50") def yell(*words): uppercased = [] for word in words: uppercased.append(word.upper()) print(*uppercased) if __name__ == "__main__": main()注意
*words如何允许函数接受多个参数。 -
map允许你将函数映射到一系列值。在实践中,我们可以这样编写代码:def main(): yell("This", "is", "CS50") def yell(*words): uppercased = map(str.upper, words) print(*uppercased) if __name__ == "__main__": main()注意
map接收两个参数。首先,它接收一个我们想要应用到列表中每个元素的函数。其次,它接收那个列表本身,我们将应用上述函数。因此,words中的所有单词都将传递给str.upper函数,并返回到uppercased。 -
你可以在 Python 的文档中了解更多关于
map的信息:map。
列表推导式
-
列表推导式允许你在一行优雅的代码中动态创建一个列表。
-
我们可以在我们的代码中如下实现:
def main(): yell("This", "is", "CS50") def yell(*words): uppercased = [arg.upper() for arg in words] print(*uppercased) if __name__ == "__main__": main()注意我们如何没有使用
map,而是在方括号内编写 Python 表达式。对于每个参数,.upper都会被应用到它上面。 -
将这个概念进一步扩展,让我们转向另一个程序。
-
在文本编辑器窗口中,输入
code gryffindors.py并编写如下代码:students = [ {"name": "Hermione", "house": "Gryffindor"}, {"name": "Harry", "house": "Gryffindor"}, {"name": "Ron", "house": "Gryffindor"}, {"name": "Draco", "house": "Slytherin"}, ] gryffindors = [] for student in students: if student["house"] == "Gryffindor": gryffindors.append(student["name"]) for gryffindor in sorted(gryffindors): print(gryffindor)注意我们在创建列表时有一个条件。如果学生的房子是格兰芬多,我们就将学生添加到名字列表中。最后,我们打印出所有的名字。
-
更优雅地,我们可以用列表推导式简化这段代码,如下所示:
students = [ {"name": "Hermione", "house": "Gryffindor"}, {"name": "Harry", "house": "Gryffindor"}, {"name": "Ron", "house": "Gryffindor"}, {"name": "Draco", "house": "Slytherin"}, ] gryffindors = [ student["name"] for student in students if student["house"] == "Gryffindor" ] for gryffindor in sorted(gryffindors): print(gryffindor)注意列表推导式是如何放在一行上的!
filter
-
使用 Python 的
filter函数允许我们返回一个序列的子集,其中某些条件为真。 -
在文本编辑器窗口中,编写如下代码:
students = [ {"name": "Hermione", "house": "Gryffindor"}, {"name": "Harry", "house": "Gryffindor"}, {"name": "Ron", "house": "Gryffindor"}, {"name": "Draco", "house": "Slytherin"}, ] def is_gryffindor(s): return s["house"] == "Gryffindor" gryffindors = filter(is_gryffindor, students) for gryffindor in sorted(gryffindors, key=lambda s: s["name"]): print(gryffindor["name"])注意如何创建一个名为
is_gryffindor的函数。这是我们用于筛选学生的函数,它将根据学生的学院是否为格兰芬多返回True或False。你可以看到新的filter函数接受两个参数。首先,它接受应用于序列中每个元素的函数——在这个例子中是is_gryffindor。其次,它接受要应用筛选函数的序列——在这个例子中是students。在gryffindors中,我们应该只看到那些在格兰芬多的学生。 -
filter也可以使用 lambda 函数如下:students = [ {"name": "Hermione", "house": "Gryffindor"}, {"name": "Harry", "house": "Gryffindor"}, {"name": "Ron", "house": "Gryffindor"}, {"name": "Draco", "house": "Slytherin"}, ] gryffindors = filter(lambda s: s["house"] == "Gryffindor", students) for gryffindor in sorted(gryffindors, key=lambda s: s["name"]): print(gryffindor["name"])注意提供了相同的学生的列表。
-
你可以在 Python 的
filter函数文档中了解更多信息《filter》。
字典推导式
-
我们可以将列表推导式的相同理念应用到字典中。在文本编辑器窗口中,编写如下代码:
students = ["Hermione", "Harry", "Ron"] gryffindors = [] for student in students: gryffindors.append({"name": student, "house": "Gryffindor"}) print(gryffindors)注意此代码(目前!)没有使用任何推导式。相反,它遵循我们之前看到的相同范例。
-
我们现在可以通过修改我们的代码来应用字典推导式:
students = ["Hermione", "Harry", "Ron"] gryffindors = [{"name": student, "house": "Gryffindor"} for student in students] print(gryffindors)注意所有之前的代码是如何简化成一行,其中为
students中的每个student提供了字典的结构。 -
我们甚至可以进一步简化如下:
students = ["Hermione", "Harry", "Ron"] gryffindors = {student: "Gryffindor" for student in students} print(gryffindors)注意字典将使用键值对构建。
enumerate
-
我们可能希望为每个学生提供一些排名。在文本编辑器窗口中,编写如下代码:
students = ["Hermione", "Harry", "Ron"] for i in range(len(students)): print(i + 1, students[i])注意运行此代码时每个学生是如何被列举的。
-
利用枚举,我们可以做到相同:
students = ["Hermione", "Harry", "Ron"] for i, student in enumerate(students): print(i + 1, student)注意
enumerate如何展示每个student的索引和值。 -
你可以在 Python 的
enumerate函数文档中了解更多信息《enumerate》。
生成器和迭代器
-
在 Python 中,有一种方法可以防止系统资源耗尽,当它们解决的问题变得太大时。
-
在美国,当人们难以入睡时,习惯于在心中“数绵羊”。
-
在文本编辑器窗口中,输入
code sleep.py并编写如下代码:n = int(input("What's n? ")) for i in range(n): print("🐑" * i)注意这个程序将如何计数你要求其数绵羊的数量。
-
我们可以通过添加一个
main函数来使我们的程序更加复杂,如下所示:def main(): n = int(input("What's n? ")) for i in range(n): print("🐑" * i) if __name__ == "__main__": main()注意提供了一个
main函数。 -
我们已经养成了抽象代码部分的习惯。
-
我们可以通过修改我们的代码来调用绵羊函数:
def main(): n = int(input("What's n? ")) for i in range(n): print(sheep(i)) def sheep(n): return "🐑" * n if __name__ == "__main__": main()注意
main函数是如何进行迭代的。 -
我们可以给
sheep函数提供更多功能。在文本编辑器窗口中,编写如下代码:def main(): n = int(input("What's n? ")) for s in sheep(n): print(s) def sheep(n): flock = [] for i in range(n): flock.append("🐑" * i) return flock if __name__ == "__main__": main()注意我们如何创建一群绵羊并返回
flock。 -
执行我们的代码时,你可以尝试不同的绵羊数量,例如
10、1000和10000。如果你要求1000000只绵羊,你的程序可能会完全挂起或崩溃。因为你试图生成一个庞大的绵羊列表,你的电脑可能难以完成计算。 -
yield生成器可以通过一次返回一小部分结果来解决这个问题。在文本编辑器窗口中,编写如下代码:def main(): n = int(input("What's n? ")) for s in sheep(n): print(s) def sheep(n): for i in range(n): yield "🐑" * i if __name__ == "__main__": main()注意到
yield一次只提供单个值,而for循环则持续工作。 -
你可以在 Python 的生成器文档中了解更多信息。
-
你可以在 Python 的迭代器文档中了解更多信息。
恭喜你!
-
当你从这门课程退出时,你拥有更多的心理模型和工具箱来解决编程相关的问题。
-
首先,你学习了函数和变量。
-
第二,你学习了条件语句。
-
第三,你学习了循环。
-
第四,你学习了异常。
-
第五,你学习了库。
-
第六,你学习了单元测试。
-
第七,你学习了文件 I/O。
-
第八,你学习了正则表达式。
-
最近,你学习了面向对象编程。
-
今天,你学习了你可以使用的许多其他工具。
这就是 CS50!
-
一起创建一个最终程序,在你的终端窗口中输入
code say.py,并编写如下代码:import cowsay import pyttsx3 engine = pyttsx3.init() this = input("What's this? ") cowsay.cow(this) engine.say(this) engine.runAndWait()注意到运行这个程序为你提供了一个充满活力的告别。
-
我们伟大的希望是,你将利用在这门课程中学到的知识来解决世界上的实际问题,使我们的地球变得更美好。
-
这就是 CS50!
R
第一讲
-
欢迎!
-
IDE
-
创建您的第一个程序
-
函数
-
错误
-
readline
-
paste
-
文档
-
算术
-
表格
-
向量
-
向量算术
-
外部数据
-
特殊值
-
factor
-
总结
欢迎!
-
欢迎来到 CS50 的 R 编程入门课程!
-
编程 是一种我们可以向计算机传达指令的方式。
-
有许多 编程语言 可以用来编程,包括 C、Python、Java、R 等等!
-
我们可以使用 R 来回答有关数据的问题,例如模拟 COVID-19 在游轮上的传播情况。R 也可以用来可视化这些问题的答案。
IDE
-
集成开发环境(IDE)是一个预配置的工具集,可以用来编程。
-
R 有自己的 IDE,称为 RStudio,用于专门编写 R 代码。
-
在 RStudio 中,注意
>符号。这表示 R 控制台,我们可以在此处发出命令。
创建您的第一个程序
-
您可以通过在 R 控制台中输入
file.create("hello.R")并按键盘上的enter或return键来创建您的第一个程序。 -
注意
hello.R以.R结尾。你可能以前见过其他以.jpg或.gif扩展名结尾的文件。.R是 R 使用的特定文件扩展名。 -
当你发出上述命令时,你应该在 R 控制台中看到
[1] TRUE。关于这一点,稍后会有更多介绍! -
在 R 控制台的右侧,您可以访问 文件资源管理器。注意
hello.R是在我们的工作目录中创建的——所有文件都将默认保存在此位置。 -
我们可以通过双击它来打开我们的
hello.R文件。 -
文件编辑器现在将出现,这是一个我们可以编写多行代码的地方。
-
在文件编辑器中,按照以下方式输入您的第一个程序:
print("hello, world")注意这里出现的所有文本和字符。它们都是必要的。
-
您可以通过点击 保存 图标来保存。
-
你可能习惯于通过双击图标来运行程序。在 R 中,我们必须采取不同的方法来运行我们的程序。
-
R 不仅仅是一种编程语言。它还是一个解释器,可以将我们的 源代码 转换为计算机可以理解和运行的格式。
-
我们可以通过点击 运行 按钮来执行此过程。注意
hello, world现在已经显示出来。做得好!
函数
-
函数 是一种我们可以运行一系列指令的方式。
-
在您的代码中,
print是一个将"hello world"传递给它的函数。我们传递给函数的内容称为参数。 -
这个函数的副作用是在 R 控制台中显示
hello, world。
错误
-
错误 是代码中无意中出现的错误。
-
按照以下方式修改您的代码:
# Demonstrates a bug prin("hello, world")注意
prin中缺少的t。 -
运行你的代码,你会注意到产生了错误。
-
调试是寻找和消除错误的过程。
readline
-
在 R 中,函数
readline可以从用户那里读取输入。 -
按照以下方式修改你的代码:
readline("What's your name? ") print("Hello, Carter")注意如果我们运行此代码,
Carter将始终出现。 -
我们需要创建一种方法,通过这种方法我们可以读取和使用用户提供的名称。
-
函数不仅仅有参数和副作用,它们还有返回值。返回值是由函数提供的。我们可以将返回值存储为变量。在 R 中,变量也可以称为对象,以避免与统计变量混淆——这是一个不同的概念!
-
按照以下方式修改你的代码:
name <- readline("What's your name? ") print("Hello, name")注意名为
name的变量存储了readline的返回值。箭头<-表示返回值是从readline流向name的。这个箭头被称为赋值运算符。 -
运行此代码并打开 IDE 右侧的环境窗口,你可以看到程序中的变量以及它们存储的内容。
paste
-
尽管如此,运行此代码,注意“name”总是出现。这显然是一个错误!
-
我们可以按照以下方式纠正这个错误:
name <- readline("What's your name? ") greeting <- paste("Hello, ", name) print(greeting)注意代码的第一行保持不变。注意我们创建了一个名为
greeting的新变量,并将“Hello, ”和name的字符串连接赋值给greeting。字符串是一组字符。两个单独的字符串通过paste函数合并成一个。使用print函数打印出结果变量greeting。 -
运行此代码,注意环境中出现的新变量。
-
如果你特别留心,仍然有一个错误!在“Hello,”和
name的值之间存储了两个空格。
文档
-
可以通过在 R 控制台中输入
?paste来访问paste的文档。相应地,paste的文档将出现。阅读此文档,可以了解可以使用paste的各种参数。 -
与我们当前工作相关的参数之一是
sep。 -
按照以下方式修改你的代码:
name <- readline("What's your name? ") greeting <- paste("Hello, ", name, sep = "") print(greeting)注意代码中添加了
sep = ""。 -
运行此程序,你会看到输出现在按预期工作。
-
恰好程序员经常需要通过将
sep设置为""来省略这些额外的空格。因此,他们发明了paste0,它可以不使用任何分隔字符连接字符串。paste0可以使用如下:name <- readline("What's your name? ") greeting <- paste0("Hello, ", name) print(greeting)注意
paste变成了paste0。 -
你的程序可以进一步简化如下:
# Ask user for name name <- readline("What's your name? ") # Say hello to user print(paste("Hello,", name))注意
greeting是通过直接将paste的返回值作为print的输入值来消除的。 -
最后,当在函数内部嵌套函数,如上所示时,请考虑你和他人阅读代码时可能遇到的进一步挑战。有时,过多的嵌套可能会导致无法理解代码在做什么。这是一个设计决策。也就是说,你将经常做出关于代码的决定,以使你的用户和程序员都受益。
-
此外,你可能做出的一个风格决策是使用
#符号添加注释,其中描述代码部分的功能。
算术
-
让我们创建一个新的程序,用来统计一些虚构角色的票数。
-
关闭
hello.R文件。 -
在你的控制台中输入
file.create("count.R")。 -
按照以下方式创建你的代码:
mario <- readline("Enter votes for Mario: ") peach <- readline("Enter votes for Peach: ") bowser <- readline("Enter votes for Bowser: ") total <- mario + peach + bowser print(paste("Total votes:", total))注意到
readline的返回值被存储在三个变量中,分别命名为mario、peach和bowser。变量total被分配了mario、peach和bowser的总和值。然后,打印出这个总和。 -
R 有很多算术运算符,包括
+、-、*、/以及其他运算符! -
运行这段代码,并输入票数,会产生一个错误。
-
正好用户输入被当作字符串处理,而不是数字。查看环境,你会注意到
mario和其他值的值被引号包围。这些引号表明这些值被存储为字符字符串,而不是数字。这些值需要是数字才能与+一起相加。 -
在 R 中,变量可以以不同的模式(有时也称为“类型”)存储。其中一些“存储模式”包括字符、双精度和整数。
-
我们可以按照以下方式将这些变量转换为所需的存储模式:
mario <- readline("Enter votes for Mario: ") peach <- readline("Enter votes for Peach: ") bowser <- readline("Enter votes for Bowser: ") mario <- as.integer(mario) peach <- as.integer(peach) bowser <- as.integer(bowser) total <- mario + peach + bowser print(paste("Total votes:", total))注意到如何通过
as.integer使用强制转换将mario和其他值转换为整数。 -
运行这段代码并查看环境,你可以看到这些值现在被存储为没有引号的整数。
-
这个程序可以进一步简化如下:
mario <- as.integer(readline("Enter votes for Mario: ")) peach <- as.integer(readline("Enter votes for Peach: ")) bowser <- as.integer(readline("Enter votes for Bowser: ")) total <- sum(mario, peach, bowser) print(paste("Total votes:", total))注意到
sum函数是如何被用来对三个变量的值进行求和的。 -
有没有一种方法可以利用现有的数据源?
表格
-
表格是我们可以用以组织数据的许多结构之一。
-
表格是一组行和列,其中行通常代表存储的某个实体,列代表这些实体的属性。
-
表格可以存储在多种文件格式中。一种常见的格式是逗号分隔值(CSV)文件。
-
在 CSV 文件中,每一行存储在单独的一行上。列由逗号分隔。
-
在我们开始下一个程序之前,在 R 控制台中输入
ls()以确定环境中所有活动的变量。然后,输入rm(list = ls())从环境中移除所有这些值。再次输入ls(),你会注意到环境中没有剩余的对象。 -
接下来,输入
file.create("tabulate.R")以创建我们的新程序文件。打开你的文件资源管理器,打开tabulate.R文件。此外,你应该从本讲座的源代码下载votes.csv文件并将其拖入你的工作目录。 -
按照以下方式创建你的代码:
votes <- read.table("votes.csv") View(votes)注意代码的第一行是如何从
votes.csv读取表格到votes变量的。然后,View允许你查看votes中存储的内容。 -
运行此代码,你现在可以看到
votes对象中存储的单独标签页。然而,这里有一个错误。注意所有数据都被读入了一个列中。看起来read.table正在从csv文件中读取数据。但是,似乎还需要一些格式化。 -
按照以下方式修改你的代码:
votes <- read.table( "votes.csv", sep = "," ) View(votes)注意
sep是如何用于告诉read.table每个列将根据哪个字符进行分隔的。 -
尽管如此,运行此代码时仍然有错误。我们如何让
read.table识别表格的标题? -
按照以下方式修改你的代码:
votes <- read.table( "votes.csv", sep = ",", header = TRUE ) View(votes)注意
header = TRUE参数允许read.table识别存在标题。 -
运行此文件,表格将按预期显示。
-
程序员创建了一个快捷方式,以便能够更简单地完成此操作。按照以下方式修改你的代码:
votes <- read.csv("votes.csv") View(votes)注意
read.csv如何以前所未有的简单性完成之前代码所做的工作! -
现在我们已经加载数据,我们如何访问它?按照以下方式修改你的代码:
votes <- read.csv("votes.csv") votes[, 1] votes[, 2] votes[, 3]注意如何使用 方括号表示法 以
votes[row, column]格式访问值。因此,votes[, 2]将显示poll列中的数字。
向量
-
向量 是一个具有相同存储模式的值的列表。
-
考虑到我们的候选人投票数据框(或表格),我们可以通过创建一个新向量来访问特定值。
-
我们可以通过调用每个列的精确名称来简化此程序。
votes <- read.csv("votes.csv") colnames(votes) votes$candidate votes$poll votes$mail注意
votes$poll返回一个包含poll列中所有值的向量。现在我们可以通过这个新向量访问poll列的值。 -
运行此代码,注意每个列的值是如何出现的。
-
转到我们关于如何求和这些值的原始问题,按照以下方式修改你的代码:
votes <- read.csv("votes.csv") sum(votes$poll[1], votes$poll[2], votes$poll[3])注意
sum函数是如何用于对poll表格的前三行中的值进行求和的。 -
然而,此代码不是动态的。它相当不灵活。如果有超过三个候选人怎么办?因此,我们可以简化我们的代码如下,使其更具动态性:
votes <- read.csv("votes.csv") sum(votes$poll) sum(votes$mail)注意在向量
votes$poll和votes$mail中找到的值是如何被求和的。 -
如上图所示,使用方括号表示法,我们也可以尝试对
poll和mail列中的每一行的值进行求和。按照以下方式修改你的代码:votes <- read.csv("votes.csv") votes$poll[1] + votes$mail[1] votes$poll[2] + votes$mail[2] votes$poll[3] + votes$mail[3]注意
poll和mail的每一行是如何被加在一起的。 -
这是否是 R 提供的最佳方法?
向量算术
-
有很多时候我们希望能够将一个向量的行与另一个向量的行相加。我们可以通过向量算术来完成此操作。
-
在使我们的代码更加动态的同一种精神下,我们可以进一步修改我们的代码如下:
votes <- read.csv("votes.csv") votes$poll + votes$mail注意向量是如何逐元素相加的。也就是说,第一个向量的第一行加到第二个向量的第一行上,第一个向量的第二行加到第二个向量的第二行上,依此类推。这导致了一个与
poll和mail向量具有相同行数的最终向量。 -
向量算术会产生一个全新的向量。我们可以以各种方式处理这个新向量。
-
自然地,我们可能想要存储我们算术的结果。我们可以通过以下方式修改我们的代码来做到这一点:
votes <- read.csv("votes.csv") votes$total <- votes$poll + votes$mail write.csv(votes, "totals.csv")注意最终的总数被存储在一个名为
votes$total的新向量中,实际上它是votes数据框的新total列。然后我们将结果votes数据框写入一个名为totals.csv的文件。 -
当你查看
csv文件时,会出现一个问题。注意默认情况下,“行名”是包含在内的。这些可以通过修改以下代码来排除:votes <- read.csv("votes.csv") votes$total <- votes$poll + votes$mail write.csv(votes, "totals.csv", row.names = FALSE)注意
row.names被设置为FALSE。
外部数据
-
今天,我们看到了许多关于如何使用 R 的例子。
-
有许多情况下你可能希望使用别人的数据集。
-
你可以如下访问在线数据源:
# Demonstrates reading data from a URL url <- "https://github.com/fivethirtyeight/data/raw/master/non-voters/nonvoters_data.csv" voters <- read.csv(url)注意
read.csv是如何从定义的 URL 中提取数据的。 -
看这个数据框,你可以运行
nrow来获取行数。你可以运行ncol来获取列数。# Demonstrates finding number of rows and columns in a large data set url <- "https://github.com/fivethirtyeight/data/raw/master/non-voters/nonvoters_data.csv" voters <- read.csv(url) nrow(voters) ncol(voters)注意
nrow和ncol是如何用来确定这个数据中有多少行和列的。 -
数据集有时会附带一个代码簿。代码簿是关于这个数据中包含哪些列的指南。例如,列
Q1可能代表在研究中向参与者提出的一个特定问题。通过查看这个数据集的代码簿,我们可以知道有一个名为voter_category的列,它定义了每个参与者的特定投票行为。 -
你可能想了解在这个列中参与者可能选择的各个选项。这可以通过
unique函数来完成。# Demonstrates finding unique values in a vector url <- "https://github.com/fivethirtyeight/data/raw/master/non-voters/nonvoters_data.csv" voters <- read.csv(url) unique(voters$voter_category)注意
unique是如何用来确定参与者可能选择的选项的。
特殊值
-
对于
Q22,我们在代码簿中发现这个问题是关于为什么参与者没有注册投票的原因。查看这个数据,我们看到NA是呈现的值之一。NA在 R 中表示“不可用”的特殊值。 -
R 中的其他特殊值包括
Inf,-Inf,NaN和NULL。分别表示无限大,负无限大,非数字和空(或无)值。 -
要查看
Q22的这些可能值,我们可以运行以下代码:# Demonstrates NA url <- "https://github.com/fivethirtyeight/data/raw/master/non-voters/nonvoters_data.csv" voters <- read.csv(url) voters$Q22 unique(voters$Q22)注意到
unique再次被用来发现Q22的可能值。
factor
-
Q21涉及参与者对未来选举的投票计划。在这个列中,值1,2和3与特定的可能答案相对应。例如,1可能代表“是”。 -
在 R 语言中,我们可以使用
factor函数将数字值转换为特定的文本答案。例如,我们可以使用factor函数将数字1转换为对应的文本“是”。我们可以通过以下方式修改我们的代码来完成这个操作:# Demonstrates converting a vector to a factor url <- "https://github.com/fivethirtyeight/data/raw/master/non-voters/nonvoters_data.csv" voters <- read.csv(url) voters$Q21 factor( voters$Q21 ) factor( voters$Q21, labels = c("?", "Yes", "No", "Unsure/Undecided") )注意
factor(voters$Q21)将会显示Q21的具体级别(类别)数据。在代码中出现的后续factor中,标签被应用于每个级别。例如,1与“是”相关联。 -
在许多情况下,我们可能希望排除某些值。在
Q21中,我们可能希望排除-1,因为这个值代表的含义并不明确。我们可以按照以下方式操作:# Demonstrates excluding values from the levels of a factor url <- "https://github.com/fivethirtyeight/data/raw/master/non-voters/nonvoters_data.csv" voters <- read.csv(url) voters$Q21 <- factor( voters$Q21, labels = c("Yes", "No", "Unsure/Undecided"), exclude = c(-1) )注意
-1是如何被排除的。
总结
在本课中,你学习了如何在 R 语言中表示数据。具体来说,你学习了……
-
函数
-
错误
-
readline -
paste -
文档
-
算术
-
表格
-
向量
-
向量算术
-
外部数据
-
特殊值
-
factor
次次再见,当我们讨论如何转换数据时再见。
第二讲
-
欢迎!
-
异常值
-
逻辑表达式
-
使用逻辑向量的子集
-
数据框的子集
-
菜单
-
转义字符
-
条件语句
-
合并数据源
-
总结
欢迎!
-
欢迎回到 CS50 的 R 语言编程入门课程!
-
我们将学习如何删除数据的一部分,查找特定的数据,以及如何从不同的来源获取不同的数据并将它们合并。
异常值
-
在统计学中,异常值是指超出预期范围的数值。
-
通常,统计学家和数据科学家希望识别异常值以进行特殊处理。有时,可能需要从计算中移除异常值。其他时候,你可能希望包括异常值进行分析。
-
为了说明如何在 R 中处理异常值,你可以在 RStudio 中通过在 R 控制台中输入
file.create("temps.R")来创建一个新文件。进一步,你需要在你的工作目录中下载一个名为temps.RData的文件。 -
要加载数据,我们可以编写如下代码:
# Demonstrates loading data from an .RData file load("temps.RData") mean(temps)注意
load函数如何加载名为temps.RData的数据文件。接下来,mean将计算这些数据的平均值。 -
运行此脚本,你可以看到计算结果。
-
然而,正如之前所述,这些基础数据中存在异常值。让我们来发现这些异常值。
-
从整体上看温度,如图中讲座视频所示,我们希望能够直接访问这些异常温度。
-
回想第 1 周我们如何在向量中索引数据。按照以下方式修改你的代码:
# Demonstrates identifying outliers by index load("temps.RData") temps[2] temps[4] temps[7] temps[c(2, 4, 7)]注意
temps[2]将直接访问一个异常温度。最后一行代码从temps向量中取一个子集,只包括第 2、4 和第 7 个索引的元素。 -
作为下一步,我们可以移除异常值数据:
# Demonstrates removing outliers by index load("temps.RData") no_outliers <- temps[-c(2, 4, 7)] mean(no_outliers) mean(temps)注意数据已加载。然后,
no_outliers是一个只包含非异常温度的新向量。名为temps的向量仍然包含异常值数据。
逻辑表达式
-
逻辑表达式是通过编程回答是和否问题的手段。逻辑表达式利用逻辑运算符,这些运算符用于比较值。
-
在 R 中,你可以使用许多逻辑运算符,包括:
== != > >= < <= -
例如,你可以在 R 控制台中输入
1 == 2来询问 1 是否等于 2。结果应该是FALSE(或“不!”)。然而,1 < 2应该是TRUE(或“是!”)。 -
逻辑值是逻辑表达式提供的响应。逻辑值可以是
TRUE或FALSE。这些值也可以用更简略的形式表示为T或F。 -
在你的代码中使用逻辑运算符,你可以按照以下方式修改你的代码:
# Demonstrates identifying outliers with logical expressions load("temps.RData") temps[1] < 0 temps[2] < 0 temps[3] < 0注意运行此代码将在 R 控制台中产生以
TRUE和FALSE表示的结果。 -
以下代码可以进一步改进如下:
# Demonstrates comparison operators are vectorized load("temps.RData") temps < 0注意到运行此代码将创建一个 逻辑向量(即逻辑值的向量)。逻辑向量中的每个值都回答其对应值是否小于 0。
-
要识别某些逻辑表达式为真的索引,你可以按照以下方式修改你的代码:
# Demonstrates `which` to return indices for which a logical expression is TRUE load("temps.RData") which(temps < 0)注意现在温度向量中小于 0 的 索引 将输出到 R 控制台。函数
which接受一个逻辑向量作为输入,并返回值为TRUE的值的索引。 -
当处理异常值时,一个常见的愿望是显示低于或高于阈值的数值。你可以在代码中按以下方式实现:
# Demonstrates identifying outliers with compound logical expressions load("temps.RData") temps < 0 | temps > 60注意到字符
|符号在表达式中表示 或。这个逻辑表达式对于temps中任何小于0或大于60的值都将返回TRUE。 -
除了我们之前讨论的逻辑运算符之外,我们现在添加了两个新的运算符到我们的词汇表中:
| &注意到表达
or和and的能力是如何被提供的。 -
你可以进一步改进你的代码如下:
# Demonstrates `any` and `all` to test for outliers load("temps.RData") any(temps < 0 | temps > 60) all(temps < 0 | temps > 60)注意到
any和all函数接受逻辑向量作为输入。any回答的问题是,“这些逻辑值中是否有任何一个是真的?”all回答的问题是,“所有这些温度值是否都是真的?”。
逻辑向量的子集
-
如前所述,我们可以创建一个新的向量,如下删除异常值:
# Demonstrates subsetting a vector with a logical vector load("temps.RData") filter <- temps < 0 | temps > 60 temps[filter]注意到如何根据逻辑表达式创建了一个新的子集向量
filter。因此,现在可以将filter提供给temps,以请求temps中那些在逻辑表达式中评估为TRUE的项。 -
同样,代码可以被修改以仅过滤那些不是异常值的项:
# Demonstrates negating a logical expression with ! load("temps.RData") filter <- !(temps < 0 | temps > 60) temps[filter]注意到
!的添加意味着 不等于 或简单地 不是。 -
这种否定可以用来完全从数据中删除异常值:
# Demonstrates removing outliers load("temps.RData") no_outliers <- temps[!(temps < 0 | temps > 60)] save(no_outliers, file = "no_outliers.RData") outliers <- temps[temps < 0 | temps > 60] save(outliers, file = "outliers.RData")注意现在有两个文件被保存。一个排除了异常值,另一个包含了异常值。这些文件保存在工作目录中。
数据框的子集
-
我们如何从一个数据集中找到我们感兴趣的数据子集?
-
想象一个数据表,记录了每只小鸡(一只小鸡宝宝!)、每只小鸡所喂的饲料以及每只小鸡的重量。你可以从讲座源代码中下载
chicks.csv来查看这些数据。 -
在 RStudio 中关闭之前的文件,让我们在 R 控制台中创建一个新的文件,通过输入
file.create("chicks.R")。确保你有chicks.csv在工作目录中,然后选择chicks.R并按照以下方式编写你的代码:# Reads a CSV of data chicks <- read.csv("chicks.csv") View(chicks)注意到
read.csv将 CSV 文件读取到名为chicks的数据框中。然后,查看chicks。 -
查看上述输出的结果,注意其中有很多
NA值,代表不可用数据。考虑这可能会如何影响平均鸡重量的计算。按照以下方式修改你的代码:# Demonstrates `mean` calculation with NA values chicks <- read.csv("chicks.csv") average_weight <- mean(chicks$weight) average_weight注意到运行此代码将导致错误,因为某些值不可用于数学评估。
-
缺失数据在统计学中是一个预期的问题。作为程序员,您需要决定如何处理缺失数据。您可以在移除
NA值的情况下计算平均小鸡体重,如下所示:# Demonstrates na.rm to remove NA values from mean calculation chicks <- read.csv("chicks.csv") average_weight <- mean(chicks$weight, na.rm = TRUE) average_weight注意,
na.rm = TRUE将在计算平均值时移除所有NA值。根据文档,na.rm可以设置为TRUE或FALSE。 -
现在,让我们找出每只小鸡吃的食物如何影响它们的体重:
# Demonstrates computing casein average with explicit indexes chicks <- read.csv("chicks.csv") casein_chicks <- chicks[c(1, 2, 3), ] mean(casein_chicks$weight)注意,通过明确指定适当的索引,创建了一个
chicks数据框的子集。 -
这不是一种高效的编程方式,因为我们不应该期望我们的数据永远不会改变。我们如何修改代码使其更加灵活?我们可以使用逻辑表达式来动态地子集化数据框。
# Demonstrates logical expression to identify rows with casein feed chicks <- read.csv("chicks.csv") chicks$feed == "casein"注意,逻辑表达式识别饲料列中的每个值是否等于“casein”。
-
我们可以在代码中利用这个逻辑表达式如下:
# Demonstrates subsetting data frame with logical vector chicks <- read.csv("chicks.csv") filter <- chicks$feed == "casein" casein_chicks <- chicks[filter, ] mean(casein_chicks$weight)如讲座中先前所示,注意如何创建一个名为
filter的逻辑向量。然后,只有filter中为TRUE的行被带入数据框casein_chicks。 -
现在,我们有了数据框的一个子集。
-
您可以使用
subset函数达到相同的结果:# Demonstrates subsetting with `subset` chicks <- read.csv("chicks.csv") casein_chicks <- subset(chicks, feed == "casein") mean(casein_chicks$weight, na.rm = TRUE)这个数据框,称为
casein_chicks,是通过subset函数创建的。 -
现在,有人可能希望在开始时过滤掉所有
NA值。考虑以下代码:# Demonstrates identifying NA values with `is.na` chicks <- read.csv("chicks.csv") is.na(chicks$weight) !is.na(chicks$weight) chicks$chick[is.na(chicks$weight)]注意,这段代码将使用
is.na来查找NA值。 -
可以通过使用
is.na来完全删除记录,如下所示:# Demonstrates removing NA values and resetting row names chicks <- read.csv("chicks.csv") chicks <- subset(chicks, !is.na(weight)) rownames(chicks) rownames(chicks) <- NULL rownames(chicks)注意,这段代码创建了一个
chicks的子集,其中is.na(weight)等于FALSE。也就是说,chicks只包括weight列中没有NA的行。如果您关心数据框的行名,请注意,当您移除某些行时,您也移除了那些行的rownames。您可以通过运行rownames(chicks) <- NULL来确保您的行名仍然按顺序递增,这将重置所有行的名称。
菜单
-
在 R 中,您可以向用户提供选项。例如,您可以提供用户希望过滤的小鸡的饲料类型。
-
考虑以下代码:
# Demonstrates interactive program to view data by feed type # Read and clean data chicks <- read.csv("chicks.csv") chicks <- subset(chicks, !is.na(weight)) # Determine feed options feed_options <- unique(chicks$feed) # Prompt user with options cat("1.", feed_options[1]) cat("2.", feed_options[2]) cat("3.", feed_options[3]) cat("4.", feed_options[4]) cat("5.", feed_options[5]) cat("6.", feed_options[6]) feed_choice <- as.integer(readline("Feed type: "))注意,这段代码使用
unique来发现独特的饲料选项。然后,使用cat输出每个饲料选项。 -
这段代码在意义上是有效的,因为它显示了各种饲料选项,但它格式不是很好。我们如何在 R 控制台中使不同的选项各自占一行?
转义字符
-
转义字符 是输出方式与输入方式不同的字符。
-
例如,一些常用的转义字符是
\n,它打印一个新行,或者\t,它打印一个制表符。 -
利用转义字符,我们可以修改代码如下:
# Demonstrates \n # Read and clean data chicks <- read.csv("chicks.csv") chicks <- subset(chicks, !is.na(weight)) # Determine feed options feed_options <- unique(chicks$feed) # Prompt user with options cat("1.", feed_options[1], "\n") cat("2.", feed_options[2], "\n") cat("3.", feed_options[3], "\n") cat("4.", feed_options[4], "\n") cat("5.", feed_options[5], "\n") cat("6.", feed_options[6], "\n") feed_choice <- as.integer(readline("Feed type: "))注意,这段代码输出了所有饲料选项,每个选项都在单独的一行上。
-
当我们有正确的菜单显示时,我们仍然可以从设计角度改进我们的代码。例如,为什么我们应该重复所有这些
cat行?如下简化你的代码:# Demonstrates interactive program to view data by feed type # Read and clean data chicks <- read.csv("chicks.csv") chicks <- subset(chicks, !is.na(weight)) # Determine feed options feed_options <- unique(chicks$feed) # Format feed options formatted_options <- paste0(1:length(feed_options), ". ", feed_options) # Prompt user with options cat(formatted_options, sep = "\n") feed_choice <- as.integer(readline("Feed type: "))注意到
formatted_options包括所有单个饲料选项。formatted_options向量的每个元素都通过cat(formatted_options, sep = "\n")打印出来,并且每个元素之间用换行符分隔。 -
现在我们已经指出,我们的意图是创建一个交互式程序。因此,我们现在可以提示用户选择:
# Demonstrates interactive program to view data by feed type # Read and clean data chicks <- read.csv("chicks.csv") chicks <- subset(chicks, !is.na(weight)) # Determine feed options feed_options <- unique(chicks$feed) # Format feed options formatted_options <- paste0(1:length(feed_options), ". ", feed_options) # Prompt user with options cat(formatted_options, sep = "\n") feed_choice <- as.integer(readline("Feed type: ")) # Print selected option selected_feed <- feed_options[feed_choice] print(subset(chicks, feed == selected_feed))注意到用户被提示输入
Feed type:,其中数字可以转换为基于文本的饲料选项表示。然后,用户选择的feed_choice被分配给selected_feed。最后,与selected_feed对应的子集被输出给用户。 -
然而,你可以想象用户可能不会按预期行为。例如,如果用户输入了
0,这不是一个潜在的选择,那么我们程序的输出将会奇怪。我们如何确保用户输入正确的文本?
条件语句
-
条件语句是确定条件是否满足的方法。
-
考虑以下代码:
# Demonstrates interactive program to view data by feed type # Read and clean data chicks <- read.csv("chicks.csv") chicks <- subset(chicks, !is.na(weight)) # Determine feed options feed_options <- unique(chicks$feed) # Format feed options formatted_options <- paste0(1:length(feed_options), ". ", feed_options) # Prompt user with options cat(formatted_options, sep = "\n") feed_choice <- as.integer(readline("Feed type: ")) # Invalid choice? if (feed_choice < 1 || feed_choice > length(feed_options)) { cat("Invalid choice.") } selected_feed <- feed_options[feed_choice] print(subset(chicks, feed == selected_feed))注意到
if (feed_choice < 1 || feed_choice > length(feed_options))是如何确定用户的输入是否超出值范围的。如果是这样,程序将显示“无效选择。”然而,仍然存在问题:即使有无效选择,程序也会继续运行。 -
可以如下利用
if和else来仅当用户输入有效选择时才运行最终计算:# Demonstrates interactive program to view data by feed type # Read and clean data chicks <- read.csv("chicks.csv") chicks <- subset(chicks, !is.na(weight)) # Determine feed options feed_options <- unique(chicks$feed) # Format feed options formatted_options <- paste0(1:length(feed_options), ". ", feed_options) # Prompt user with options cat(formatted_options, sep = "\n") feed_choice <- as.integer(readline("Feed type: ")) # Invalid choice? if (feed_choice < 1 || feed_choice > length(feed_options)) { cat("Invalid choice.") } else { selected_feed <- feed_options[feed_choice] print(subset(chicks, feed == selected_feed)) }注意,被
if包裹的代码仅在存在无效选择时运行。被else包裹的代码仅在if中的先前条件未满足时运行。
结合数据来源
-
作为本讲座的最后一件事,让我们看看如何结合数据来源。
-
想象一个表示销售给客户的表,比如亚马逊可能有的那种。
-
你可以想象数据分布在许多表中的场景。如何将这些数据从多个来源组合起来?
-
考虑以下名为
sales.R的代码:# Reads 4 separate CSVs Q1 <- read.csv("Q1.csv") Q2 <- read.csv("Q2.csv") Q3 <- read.csv("Q3.csv") Q4 <- read.csv("Q4.csv")注意到每个财务数据季度,例如
Q1和Q2,都被读入它们自己的数据框中。 -
现在,让我们将这四个数据框中的数据结合起来:
# Combines data frames with `rbind` Q1 <- read.csv("Q1.csv") Q2 <- read.csv("Q2.csv") Q3 <- read.csv("Q3.csv") Q4 <- read.csv("Q4.csv") sales <- rbind(Q1, Q2, Q3, Q4)注意到
rbind被用来收集来自每个这些数据框的数据。 -
值得注意的是,
rbind在这种情况下是可用的,因为所有四个数据框的结构都是相同的。 -
前一个程序运行的结果是
sales包括了每个数据框中的每一行。而不是为每个客户显示Q1、Q2等,它只是在文件的底部为每行数据创建新的行。因此,随着越来越多的数据被组合到文件中,文件变得越来越长。每个销售值发生的季度完全不清楚。 -
我们的代码可以改进,为每条记录创建一个财务季度的列,如下所示:
# Adds quarter column to data frames Q1 <- read.csv("Q1.csv") Q1$quarter <- "Q1" Q2 <- read.csv("Q2.csv") Q2$quarter <- "Q2" Q3 <- read.csv("Q3.csv") Q3$quarter <- "Q3" Q4 <- read.csv("Q4.csv") Q4$quarter <- "Q4" sales <- rbind(Q1, Q2, Q3, Q4)注意每个季度是如何添加到特定的
季度列中的。因此,当rbind将数据框组合到按季度列组织的sales中时。 -
作为最后的点缀,让我们添加一个
value列,其中记录高回报和常规回报:# Demonstrates flagging sales as high value Q1 <- read.csv("Q1.csv") Q1$quarter <- "Q1" Q2 <- read.csv("Q2.csv") Q2$quarter <- "Q2" Q3 <- read.csv("Q3.csv") Q3$quarter <- "Q3" Q4 <- read.csv("Q4.csv") Q4$quarter <- "Q4" sales <- rbind(Q1, Q2, Q3, Q4) sales$value <- ifelse(sales$sale_amount > 100, "High Value", "Regular")注意最后一行代码在
sale_amount大于100时分配“高价值”。否则,交易被分配为“常规”。
总结
在本课中,你学习了如何在 R 中转换数据。具体来说,你学习了...
-
异常值
-
逻辑表达式
-
子集
-
菜单
-
转义字符
-
条件语句
-
合并数据源
次次见,当我们讨论如何编写我们自己的函数时。
第三讲
-
欢迎!
-
定义函数
-
作用域
-
检查输入
-
循环
-
使用循环
-
使用函数和循环
-
应用函数
-
总结
欢迎!
-
欢迎回到 CS50 的 R 编程入门课程!
-
今天,我们将学习如何应用函数。我们还将学习如何编写自己的函数并应用循环。
-
回想一下我们在上次讲座中创建的名为
count.R的程序。# Demonstrates counting votes for 3 different candidates mario <- as.integer(readline("Mario: ")) peach <- as.integer(readline("Peach: ")) bowser <- as.integer(readline("Bowser: ")) total <- sum(mario, peach, bowser) cat("Total votes:", total)注意到如何重复行以从用户那里获取输入。
-
传统上,在编程中每次重复使用代码都是改进的机会。函数是我们通过定义可以在整个程序中重用的代码块来减少这些冗余的一种方式。
定义函数
-
在 R 中,函数通过语法
function()定义。 -
考虑以下我们程序的改进版本:
# Demonstrates defining a function get_votes <- function() { votes <- as.integer(readline("Enter votes: ")) return(votes) } mario <- get_votes() peach <- get_votes() bowser <- get_votes() total <- sum(mario, peach, bowser) cat("Total votes:", total)注意到创建了一个名为
get_votes的新函数。函数的 主体 由开闭花括号 ({和}) 表示。注意,在主体内部有 2 行代码,每次调用此函数时都会执行。首先,从用户那里收集votes。其次,返回votes。在调用get_votes之后,mario、peach和bowser分别接收返回值。最后,提供值的总和并显示给用户。 -
恭喜你,这是你在 R 中的第一个函数!
-
然而,运行这个函数,我们发现该函数丢失了一些我们之前的功能。我们能否以某种方式向函数提供一个 参数,以便我们可以更准确地提示用户?确实可以!考虑以下:
# Demonstrates defining a parameter get_votes <- function(prompt) { votes <- as.integer(readline(prompt)) } mario <- get_votes("Mario: ") peach <- get_votes("Peach: ") bowser <- get_votes("Bowser: ") total <- sum(mario, peach, bowser) cat("Total votes:", total)注意到向
get_votes函数提供了一个prompt。因此,用户会被提示他们要投票的人的名字。此外,注意已经移除了return(votes)语句。在 R 中,函数会自动返回最后计算出的值。 -
具有参数的函数可能已分配了默认值。考虑以下我们程序的以下更新:
# Demonstrates defining a parameter with a default value get_votes <- function(prompt = "Enter votes: ") { votes <- as.integer(readline(prompt)) } mario <- get_votes() peach <- get_votes() bowser <- get_votes() total <- sum(mario, peach, bowser) cat("Total votes:", total)注意到在代码的第一行提供了一个默认值。
-
我们仍然可以像这样覆盖默认提示:
# Demonstrates exact argument matching get_votes <- function(prompt = "Enter votes: ") { votes <- as.integer(readline(prompt)) } mario <- get_votes(prompt = "Mario: ") peach <- get_votes(prompt = "Peach: ") bowser <- get_votes(prompt = "Bowser: ") total <- sum(mario, peach, bowser) cat("Total votes:", total)注意到对于每次函数调用,给定的参数会覆盖默认参数。
作用域
-
查看我们的 RStudio 环境面板,注意到为
bowser和其他人提供了值。然而,没有为votes提供值。为什么可能会这样? -
结果表明,所有对象都是在某些“环境”中定义的。其中一种环境是“全局”环境。全局环境是您在 R 控制台或函数体外部定义的对象的家园——例如
mario、bowser和peach。默认情况下,RStudio 的环境面板显示您在全局环境中定义的对象。![全局环境的可视化]()
-
get_votes函数也是定义在全局环境中的对象。然而,独特的是,get_votes本身也是一种环境!正如你所看到的,在get_votes的定义中,你可以定义其他对象,如votes和prompt。![环境的可视化]()
-
get_votes的环境不是全局环境。当编写在全局环境中运行的代码时,此环境中的对象不可访问。 -
一个对象可用的环境被称为其“作用域”。
检查输入
-
程序员一直面临的一个挑战是用户的糟糕行为。也就是说,作为程序员,我们应该预期用户不会总是做我们想要的事情。例如,如果用户为
votes提供了文本字符串而不是数字怎么办? -
我们可以将程序改进以捕获输入的错误值:
# Demonstrates anticipating invalid input get_votes <- function(prompt = "Enter votes: ") { votes <- as.integer(readline(prompt)) if (is.na(votes)) { return(0) } else { return(votes) } } mario <- get_votes("Mario: ") peach <- get_votes("Peach: ") bowser <- get_votes("Bowser: ") total <- sum(mario, peach, bowser) cat("Total votes:", total)注意到如果
votes的值是NA,get_votes将返回0。否则,get_votes将返回用户提供的值。 -
虽然这个程序可以工作,但它仍然会提供警告,我们可能不希望用户看到。我们可以如下抑制警告:
# Demonstrates anticipating invalid input get_votes <- function(prompt = "Enter votes: ") { votes <- suppressWarnings(as.integer(readline(prompt))) if (is.na(votes)) { return(0) } else { return(votes) } } mario <- get_votes("Mario: ") peach <- get_votes("Peach: ") bowser <- get_votes("Bowser: ") total <- sum(mario, peach, bowser) cat("Total votes:", total)注意到当运行此代码时,警告现在被抑制了。
-
通过使用
ifelse,我们可以进一步改进这个程序。考虑以下:# Demonstrates ifelse as last evaluated expression get_votes <- function(prompt = "Enter votes: ") { votes <- as.integer(readline(prompt)) ifelse(is.na(votes), 0, votes) } mario <- get_votes("Mario: ") peach <- get_votes("Peach: ") bowser <- get_votes("Bowser: ") total <- sum(mario, peach, bowser) cat("Total votes:", total)注意到
ifelse的第一个值是一个要测试的逻辑表达式。第二个值0是当第一个值is.na(votes)评估为TRUE时将返回的值。最后,第三个值votes是在第一个值评估为FALSE时提供的。 -
我们现在已经发现了检查用户输入的第一种基本方法。
-
如同之前,我们可以抑制警告:
# Demonstrates suppressWarnings get_votes <- function(prompt = "Enter votes: ") { votes <- suppressWarnings(as.integer(readline(prompt))) ifelse(is.na(votes), 0, votes) } mario <- get_votes("Mario: ") peach <- get_votes("Peach: ") bowser <- get_votes("Bowser: ") total <- sum(mario, peach, bowser) cat("Total votes:", total)注意到警告被抑制了。
循环
-
我们可能希望对程序进行的一个显著改进是能够在用户出错时反复提示用户。
-
要了解更多关于循环的信息,让我们请 CS50 Duck Debugger 来帮忙!Quack!
-
考虑以下代码:
# Demonstrates a duck quacking 3 times cat("quack!\n") cat("quack!\n") cat("quack!\n")注意到这段代码将输出“quack”三次。然而,它相当低效!我们重复了相同的代码行三次。
-
我们可以尝试使用以下形式的重复循环来改进此代码:
# Demonstrates duck quacking in an infinite loop repeat { cat("quack!\n") }注意到我们的鸭子“quack”多次,但永远如此。鸭子会非常累的!
-
我们实现循环的一种方法是通过利用
break和next。这样的循环将通过计数器重复一定次数。# Demonstrates quacking 3 times with repeat i <- 3 repeat { cat("quack!\n") i <- i - 1 if (i == 0) { break } else { next } }注意到
i的值被设置为3。然后每次发生quack!时,i的值减少 1。当达到0时,循环将break。否则(或else),这个循环将使用next继续。 -
最后,
next是不必要的。循环将自动继续,无需next语句。我们可以如下移除此语句:# Demonstrates removing extraneous next keyword i <- 3 repeat { cat("quack!\n") i <- i - 1 if (i == 0) { break } }注意当
i等于0时,循环将中断。然而,已经移除了next。循环仍然可以工作。 -
我们可用的另一种循环类型称为while 循环。这种循环将在满足特定条件之前继续。考虑以下代码:
# Demonstrates a while loop, counting down i <- 3 while (i != 0) { cat("quack!\n") i <- i - 1 }注意这个循环将一直运行,直到
i != 0的值为真。 -
另一种类型的循环称为for 循环,它允许我们根据列表或值向量重复操作:
# Demonstrates a for loop for (i in c(1, 2, 3)) { cat("quack!\n") }注意
for循环从i的值为1开始,运行其内部的代码。然后,它将i的值设置为2并运行。最后,它将i设置为3并运行。因此,循环内的代码运行了三次。 -
我们可以通过使用范围
1:3(一至三)来简化我们的代码,以计算1、2和3。# Demonstrates a for loop with syntactic sugar for (i in 1:3) { cat("quack!\n") }注意代码
i in 1:3如何完成与先前示例中相同的任务。
使用循环
-
我们可以在对马里奥和他的朋友们计票时使用我们新学的循环能力。考虑以下使用重复循环的代码:
# Demonstrates reprompting the user for valid input get_votes <- function(prompt = "Enter votes: ") { repeat { votes <- suppressWarnings(as.integer(readline(prompt))) if (!is.na(votes)) { break } } return(votes) } mario <- get_votes("Mario: ") peach <- get_votes("Peach: ") bowser <- get_votes("Bowser: ") total <- sum(mario, peach, bowser) cat("Total votes:", total)注意用户将一直被提示,直到提供的值不是
NA。 -
我们可以进一步改进我们的代码如下:
# Demonstrates tightening return get_votes <- function(prompt = "Enter votes: ") { repeat { votes <- suppressWarnings(as.integer(readline(prompt))) if (!is.na(votes)) { return(votes) } } } mario <- get_votes("Mario: ") peach <- get_votes("Peach: ") bowser <- get_votes("Bowser: ") total <- sum(mario, peach, bowser) cat("Total votes:", total)注意
return(votes)子句是如何替换break的。这个函数的功能保持不变,但代码更简洁。 -
现在,利用我们对
for循环的知识,我们可以改进对马里奥和他的朋友们重复的代码:# Demonstrates prompting for input in a loop get_votes <- function(prompt = "Enter votes: ") { repeat { votes <- suppressWarnings(as.integer(readline(prompt))) if (!is.na(votes)) { return(votes) } } } for (name in c("Mario", "Peach", "Bowser")) { votes <- get_votes(paste0(name, ": ")) }注意,与为每个候选人分别提示选票的三条单独的行不同,
for循环将运行“马里奥”、“桃子”和“霸王龙”的范围以获取选票。paste0语句将冒号字符添加到每个提示中。 -
作为最后的点缀,我们可以使用循环来边走边计票:
# Demonstrates prompting for input, tallying votes in a loop get_votes <- function(prompt = "Enter votes: ") { repeat { votes <- suppressWarnings(as.integer(readline(prompt))) if (!is.na(votes)) { return(votes) } } } total <- 0 for (name in c("Mario", "Peach", "Bowser")) { votes <- get_votes(paste0(name, ": ")) total <- total + votes } cat("Total votes:", total)注意在
for循环的每次迭代中,total选票数是如何更新的。 -
反思上述内容,你可以看到循环为你作为程序员提供的根本编程力量。
使用函数和循环
-
让我们回到之前讨论的一个案例,像下面这样在表中汇总候选人的选票。
![候选人选票表]()
-
现在,让我们使用我们在循环和函数中学习的新能力来创建一个更好的程序。
-
也许我们的第一个目标应该是统计选票。考虑以下代码:
# Demonstrates summing votes for each candidate procedurally votes <- read.csv("votes.csv") total_votes <- c() for (candidate in rownames(votes)) { total_votes[candidate] <- sum(votes[candidate, ]) } total_votes注意这个
for循环将遍历votes数据框中呈现的每个candidate。然后,candidate的votes总和将被存储在total_votes向量中。total_votes <- c()代表一个空向量,稍后将被数据填充。total_votes[candidate]在向量total_votes中创建一个新的元素,每次循环迭代中每个候选人都有一个。 -
第二个目标可能是按每个候选人收到的选票方式汇总。
# Demonstrates summing votes for each voting method procedurally votes <- read.csv("votes.csv") total_votes <- c() for (method in colnames(votes)) { total_votes[method] <- sum(votes[, method]) } total_votes注意这个
for循环如何遍历colnames(或列名)中的每个method。
应用函数
-
上面的程序可以使用一组称为
apply函数的函数进一步优化。 -
apply函数允许你将函数应用于数据结构中的元素(即运行)。例如,apply函数可以在数据表的所有行或列上应用函数。 -
在投票表的例子中,我们可以使用
apply函数如下来获取所有行的sum:# Demonstrates summing votes for each candidate with apply votes <- read.csv("votes.csv") total_votes <- apply(votes, MARGIN = 1, FUN = sum) total_votes注意
sum函数是如何使用MARGIN = 1应用于所有行的。如果我们把MARGIN设置为2,sum函数就会应用于所有列。 -
我们可以这样对每一列求和:
# Demonstrates summing votes for each voting method with apply votes <- read.csv("votes.csv") total_votes <- apply(votes, MARGIN = 2, FUN = sum) total_votes注意
MARGIN = 2。
总结
在本课中,你学习了如何在 R 中应用函数。具体来说,你学习了……
-
定义函数
-
范围
-
检查输入
-
循环
-
使用循环
-
使用函数和循环
-
应用函数
次次再见,当我们讨论如何清理我们的数据时。
第四讲
-
欢迎回来!
-
dplyr
-
select(#select) -
filter(#filter) -
管道操作符
-
arrange(#arrange) -
distinct(#distinct) -
写入数据
-
group_by(#group_by) -
summarize(#summarize) -
ungroup(#ungroup)
-
-
tidyr
-
整洁数据
-
标准化
-
旋转
-
-
stringr
-
总结
欢迎回来!
-
欢迎回到 CS50 的 R 编程入门课程!
-
今天,我们将学习如何整理数据。确实,你可以想象出很多次,表格和数据可能不会是人们希望的样子!
-
包 是开发者创建的代码片段,我们可以将其安装并加载到我们的 R 程序中。这些包可以在 R 中提供一些原生不包含的功能。
-
包存储在 R 的 library 中。因此,你可以使用
library函数来加载包。
dplyr
-
在 dplyr 中,包含了一个名为
storms的数据集,它包含了来自美国国家海洋和大气管理局 NOAA 的风暴数据观测。 -
在加载 dplyr 或 tidyverse 之后,只需在 R 控制台中输入
storms即可加载storms数据集。 -
当你输入
storms时,注意会显示一个 tibble。tibble 是 tidyverse 对 R 的数据框的“重新构想”。注意行、行号和各种列是如何包含并标记的。此外,注意 tibble 中使用的文本颜色。
select
-
让我们定位数据集中最强的风暴。首先,让我们删除我们不需要的列。考虑以下程序:
# Remove selected columns dplyr::select( storms, !c(lat, long, pressure, tropicalstorm_force_diameter, hurricane_force_diameter) )注意到 dplyr 中的
select函数允许你确定哪些列将包含在数据框或 tibble 中。select的第一个参数是要操作的(数据框或 tibble):storms。select的第二个参数是要选择的列的向量。然而,在这种情况下,使用了!:一个!表示后面的列名将被排除。或者,-也有相同的功能。运行此代码将通过删除上述列来简化 tibble。 -
打印出所有这些列有点繁琐!
-
像这样的辅助函数
contains、starts_with或ends_with可以帮助完成这项工作。考虑以下代码:# Introduce ends_with select( storms, !c(lat, long, pressure, ends_with("diameter")) )注意到
ends_with被用来排除所有以 diameter 结尾的列。使用的代码更少,但结果与之前相同。
filter
-
另一个有用的函数是
filter,它可以用来从数据框中筛选行。 -
考虑以下代码:
# Find only rows about hurricanes filter( select( storms, !c(lat, long, pressure, ends_with("diameter")) ), status == "hurricane" )注意,只有包含
status列中hurricane的行被包含在内。 -
注意最新的示例在第一个示例中已经删除了
dplyr::语法。结果是,你不需要命名定义函数的特定包,除非两个或多个包定义了具有相同名称的函数。在这种情况下,你需要通过指定想要使用哪个包的函数来消除歧义。
管道操作符
-
在 R 中,管道操作符 用
|>表示,允许将数据“管道”到特定的函数中。例如,考虑以下代码:# Introduce pipe operator storms |> select(!c(lat, long, pressure, ends_with("diameter"))) |> filter(status == "hurricane")注意
storms是如何被管道到select的,隐式地成为select的第一个参数。然后,注意select的返回值是如何被管道到filter的,隐式地成为filter的第一个参数。当你使用管道操作符时,你可以避免嵌套函数调用,并按顺序编写代码。
arrange
-
现在我们使用
arrange函数来排序我们的行:# Find only rows about hurricanes, and arrange highest wind speed to least storms |> select(!c(lat, long, pressure, ends_with("force_diameter"))) |> filter(status == "hurricane") |> arrange(desc(wind))注意
select函数的返回值是如何被管道到filter的,然后filter的返回值又被管道到arrange。结果数据框中的行按wind列的值降序排列。
distinct
-
你可能会注意到这个 tibble 包含许多相同风暴的行。因为这个数据包含许多相同风暴的观测,所以这并不奇怪。然而,不是很好奇能够找到只有 distinct 飓风吗?
-
distinct函数允许我们在 tibble 中获取独特的项目。 -
Distinct returns distinct rows, finding duplicate rows and returning the first row from the set of duplicates.
-
默认情况下,
distinct只在行的所有值与另一行的所有值完全匹配时才将行视为重复。 -
然而,你可以告诉
distinct在确定行是否重复时考虑哪些值。考虑以下利用这一功能的代码:# Keep only first observation about each hurricane storms |> select(!c(lat, long, pressure, ends_with("force_diameter"))) |> filter(status == "hurricane") |> arrange(desc(wind), name) |> distinct(name, year, .keep_all = TRUE)注意
distinct被告知只查看每个风暴的name和year以确定它是否是独特项目。.keep_all = TRUE告诉distinct仍然返回每行的所有列。
写入数据
-
我们可以将数据保存到 CSV 文件中以便以后使用。
-
考虑以下代码:
# Write subset of columns to a CSV hurricanes <- storms |> select(!c(lat, long, pressure, ends_with("force_diameter"))) |> filter(status == "hurricane") |> arrange(desc(wind), name) |> distinct(name, year, .keep_all = TRUE) hurricanes |> select(c(year, name, wind)) |> write.csv("hurricanes.csv", row.names = FALSE)注意第一个代码块的结果被存储为
hurricanes。要将hurricanes保存为 CSV 文件,select首先选择 3 个特定的列(year、name和wind),并将它们写入名为hurricanes.csv的文件中。
group_by
-
现在我们来找出每年最强大的飓风。
-
考虑以下代码:
# Find most powerful hurricane for each year hurricanes <- read.csv("hurricanes.csv") hurricanes |> group_by(year) |> arrange(desc(wind)) |> slice_head()注意如何将
hurricanes.csv读取到hurricanes中。然后,使用group_by函数将每年所有的飓风分组在一起。对于每个组,使用arrange(desc(wind))按照风速降序排列。最后,使用slice_head输出每个组的顶部行。因此,展示了每年最强的风暴。 -
slice_max在变量中选择最大值。考虑一下我们如何在代码中应用这一点:# Introduce slice_max hurricanes <- read.csv("hurricanes.csv") hurricanes |> group_by(year) |> slice_max(order_by = wind)注意到
hurricanes是按year分组的。然后,使用slice_max展示了wind的最高值。这样做消除了对arrange(desc(wind))的需求。
summarize
-
如果我们想知道每年有多少次飓风?考虑以下代码:
# Find number of hurricanes per year hurricanes <- read.csv("hurricanes.csv") hurricanes |> group_by(year) |> summarize(hurricanes = n())注意到函数
summarize使用n来计算每个组中的行数。
ungroup
-
查看我们的
hurricanes数据框,你会注意到存在分组。实际上,这些分组是根据year进行的。在未来的活动中,你可能会希望取消数据中的分组。因此,考虑以下内容:# Show ungroup hurricanes <- read.csv("hurricanes.csv") hurricanes |> group_by(year) |> slice_max(order_by = wind) |> ungroup()注意到
ungroup命令被用来移除 tibble 的分组。
tidyr
-
当数据已经很好地组织时,dplyr 非常有用。
-
对于数据尚未很好地组织的情况,又该如何处理?
-
对于这一点,tidyr 包可能很有用!
整洁数据
-
根据 tidyverse 的哲学,有三个原则指导我们所说的整洁数据。
1\. Each observation is a row; each row is an observation. 2\. Each variable is a column; each column is a variable. 3\. Each value is a cell; each cell is a single value. -
在评估数据时,最好查看上述三个原则,看看它们是否被观察到。
正常化
-
正常化是将数据转换为满足上述原则的过程。
-
正常化也可以指将数据转换为满足上述指南之外更好的设计原则。
-
从课程文件中下载
students.csv文件并将其放置在你的工作目录中。创建以下新代码:# Read CSV students <- read.csv("students.csv") View(students)注意到这段代码加载了一个名为
students.csv的 CSV 文件,并将这些值存储在students中。 -
检查这些数据,你可能看到它们并没有遵循我们之前提到的原则。哪些原则没有被遵循?
旋转
-
在
students数据集中,你可能会注意到有一些行值本应该是列名:“major”和“GPA”。为了清楚起见,这个数据集违反了整洁数据的第二个原则:学生的任何变化方式都不是一列。 -
我们可以通过
pivot_wider将数据集旋转,将这些变量转换为列。pivot_wider将一个“更长”的数据集(即具有变量作为行值的数据集)转换为“更宽”的数据集(即将这些变量转换为列)。 -
pivot_wider会将students数据集从以下内容转换:![转换前的]()
转换为以下内容:
![转换后的]()
-
但如何操作呢?考虑以下用法:
# Demonstrates pivot_wider students <- read.csv("students.csv") students <- pivot_wider( students, id_cols = student, names_from = attribute, values_from = value )注意到
pivot_wider有几个参数,这里进行解释:-
第一是要操作的数据集,
students。 -
第二个参数,
id_cols,指定了在转换后的数据集中哪一列应该是唯一的。注意,在pivot_wider的转换之前,student列中存在重复值。在pivot_wider的转换之后,student列中存在唯一值。 -
第三个参数,
names_from,指定了包含应作为变量(列)的值的列。注意pivot_wider转换后,attribute列中的值是如何变成列的。 -
最后,第四个参数,
values_from,指定了填充新列值的列。
-
-
由于我们的数据如此整洁,我们可以用数据做更多的事情!
-
考虑以下:
# Demonstrates calculating average GPA by major students <- read.csv("students.csv") students <- pivot_wider( students, id_cols = student, names_from = attribute, values_from = value ) students$GPA <- as.numeric(students$GPA) students |> group_by(major) |> summarize(GPA = mean(GPA))注意这个程序是如何利用
pivot_wider和 tidyr 来发现学生的平均 GPA。students中的GPA被转换为数值。然后,使用管道语法来找到 GPA 的平均值。
stringr
-
我们上面描述的过程在数值本身干净的情况下效果很好。然而,当数值本身不整洁时怎么办呢?
-
stringr为我们提供了一种整理字符串的方法。从课程文件中下载shows.csv并将其放置在你的工作目录中。考虑以下程序:# Tally votes for favorite shows shows <- read.csv("shows.csv") shows |> group_by(show) |> summarize(votes = n()) |> ungroup() |> arrange(desc(votes))注意到节目是如何按
show分组。然后,计算votes的数量。最后,按降序排列votes。 -
观察这个程序的结果,你可以看到有多个版本的《阿凡达:最后的气宗》。我们可能首先应该解决空白问题。
# Clean up inner whitespace shows <- read.csv("shows.csv") shows$show <- shows$show |> str_trim() |> str_squish() shows |> group_by(show) |> summarize(votes = n()) |> ungroup() |> arrange(desc(votes))注意
str_trim是如何用来移除每条记录的前后空白。然后,str_squish用来移除字符之间的额外空白。 -
虽然这一切都非常好,但在大写方面仍然存在一些不一致。我们可以这样解决:
# Clean up capitalization shows <- read.csv("shows.csv") shows$show <- shows$show |> str_trim() |> str_squish() |> str_to_title() shows |> group_by(show) |> summarize(votes = n()) |> ungroup() |> arrange(desc(votes))注意
str_to_title是如何用来强制每个字符串使用标题大小写的。 -
最后,我们可以解决《阿凡达:最后的气宗》的拼写变体问题:
# Clean up spelling shows <- read.csv("shows.csv") shows$show <- shows$show |> str_trim() |> str_squish() |> str_to_title() shows$show[str_detect(shows$show, "Avatar")] <- "Avatar: The Last Airbender" shows |> group_by(show) |> summarize(votes = n()) |> ungroup() |> arrange(desc(votes))注意
str_detect是如何用来定位Avatar实例。每个这些都被转换为Avatar: The Last Airbender。 -
虽然这些工具非常有帮助,但考虑你可能需要谨慎行事,不要覆盖正确的条目。例如,有许多名为《阿凡达》的电影!我们如何知道投票者不是有意为这些电影投票?
总结
在本课中,你学习了如何在 R 中整理数据。具体来说,你学习了三个新的包,它们都是 tidyverse 的一部分:
-
dplyr
-
tidyr
-
stringr
次次见,届时我们将讨论如何可视化我们的数据。
第五讲
-
欢迎!
-
ggplot2
-
规模
-
标签
-
填充
-
主题
-
保存你的图表
-
点
-
随时间可视化
-
总结
欢迎!
-
欢迎回到 CS50 的 R 编程入门课程!
-
今天,我们将学习如何可视化数据。一个好的可视化可以帮助我们以全新的方式解释和理解数据。
ggplot2
-
ggplot中的plot意味着我们将要 绘制 我们的数据。 -
ggplot中的gg指的是一种 图形语法,其中图形的各个组件可以组合在一起来可视化数据。 -
组成图形语法有很多组件,首先是 数据。
-
另一个组件是 几何形状。这些是图表的各种图形表示选项。这包括柱状图、点和线条。
-
最后,美学映射 是数据与我们的图表视觉特征之间的关系。例如,在一个图表中,一个水平的
x轴可能代表每个候选人。然后,一个垂直的y轴可能与每个候选人的投票数相关联。正是通过数据与几何形状之间的这种关系,我们能够可视化和理解图表的美学映射。你可能想象过在某个时候,展示给你的是设计不佳的图表:当映射不正确时,数据更难以解释和理解。 -
下载讲座的源文件,并在 R 控制台中运行
library("tidyverse"),以便将tidyverse载入内存。然后,创建以下可视化:# Create a blank visualization votes <- read.csv("votes.csv") ggplot()注意到
votes.csv被加载到votes中。当运行ggplot时,目前还没有任何可视化。 -
我们可以这样向
ggplot提供输入:# Supply data votes <- read.csv("votes.csv") ggplot(votes)注意到
votes被提供给ggplot,但仍然没有可视化。 -
我们需要告诉
ggplot我们想要什么类型的图表:# Add first geometry votes <- read.csv("votes.csv") ggplot(votes) + geom_col()注意到
geom_col指定数据应该使用柱状几何形状进行可视化。然而,在这个阶段,将会出现错误。错误表明我们需要指定美学映射。 -
注意,
+操作符也有新的含义:使用+操作符在图表的基础层上添加一个图层。 -
要指定美学映射,我们可以如下定义:
# Add x and y aesthetics votes <- read.csv("votes.csv") ggplot(votes, aes(x = candidate, y = votes)) + geom_col()注意到各种由
aes指定的美学映射是如何在括号内定义的。例如,x = candidate和y = votes都是美学映射。现在,ggplot知道哪些数据映射到我们图表的哪些美学特征。 -
运行上述代码,我们的第一个可视化终于出现了!
规模
-
注意到
ggplot决定votes轴的值范围从0到200。如果我们想提供更多的空间,以便我们可以可视化到250,该怎么办?让我们来学习一下 规模。 -
刻度可以是 连续的,范围从一个数字到另一个数字,或者 离散的,这意味着分类。
-
连续刻度有 限制。例如,
votes中提供的数据范围从0到200。因此,我们可以按如下方式修改这些限制:# Adjust y scale votes <- read.csv("votes.csv") ggplot(votes, aes(x = candidate, y = votes)) + geom_col() + scale_y_continuous(limits = c(0, 250))注意
y的刻度是如何通过scale_y_continuous修改为从0到250的。这同样是通过带有+操作符的新层提供的。
标签
-
此外,还可以向图表添加标签。考虑以下示例:
# Add labels votes <- read.csv("votes.csv") ggplot(votes, aes(x = candidate, y = votes)) + geom_col() + scale_y_continuous(limits = c(0, 250)) + labs( x = "Candidate", y = "Votes", title = "Election Results" )注意
x、y和title提供了标签。这些是通过+操作符添加的新层。
填充
-
填充颜色也可以根据
候选人名称进行更改。考虑以下示例:# Add fill aesthetic mapping for geom_col votes <- read.csv("votes.csv") ggplot(votes, aes(x = candidate, y = votes)) + geom_col(aes(fill = candidate)) + scale_y_continuous(limits = c(0, 250)) + labs( x = "Candidate", y = "Votes", title = "Election Results" )注意
fill是通过aes函数依赖于candidate的。 -
我们可能希望调整
fill颜色以适应色盲。我们可以这样做:# Use viridis scale to design for color blindness votes <- read.csv("votes.csv") ggplot(votes, aes(x = candidate, y = votes)) + geom_col(aes(fill = candidate)) + scale_fill_viridis_d("Candidate") + scale_y_continuous(limits = c(0, 250)) + labs( x = "Candidate", y = "Votes", title = "Election Results" )注意
viridis规模是通过scale_fill_viridis_d函数提供的。
主题
-
也可以修改
ggplot使用的主题。你可以这样做:# Adjust ggplot theme votes <- read.csv("votes.csv") ggplot(votes, aes(x = candidate, y = votes)) + geom_col(aes(fill = candidate)) + scale_fill_viridis_d("Candidate") + scale_y_continuous(limits = c(0, 250)) + labs( x = "Candidate", y = "Votes", title = "Election Results" ) + theme_classic()注意
theme_classic被提供。ggplot2 提供几个主题。
保存您的图表
-
最后,可以保存图表。
# Save file votes <- read.csv("votes.csv") p <- ggplot(votes, aes(x = candidate, y = votes)) + geom_col(aes(fill = candidate)) + scale_fill_viridis_d("Candidate") + scale_y_continuous(limits = c(0, 250)) + labs( x = "Candidate", y = "Votes", title = "Election Results" ) + theme_classic() ggsave( "votes.png", plot = p, width = 1200, height = 900, units = "px" )注意整个图表被指定为
p。然后,使用ggsave,指定文件名、图表(在这种情况下,p)、高度、宽度和单位。 -
通过执行此代码,你已经保存了你的第一个图表。恭喜!
点
-
现在,让我们看看一种名为
点的新几何类型。 -
想象一下,糖果的价格百分位数和糖的百分位数是如何表示的。
-
你可以想象
sugar百分位数可以映射到y轴,而price百分位数可以标注在x轴上。 -
这可以通过以下代码形式实现:
# Introduce geom_point load("candy.RData") ggplot( candy, aes(x = price_percentile, y = sugar_percentile) ) + geom_point()注意数据
candy是如何提供给ggplot函数的。然后,使用aes函数设置美学映射。例如,price_percentile被分配给x轴。最后,运行geom_point函数。 -
运行此代码会在图表中表示点。
-
标签可以按如下方式添加:
# Add labels and theme load("candy.RData") ggplot( candy, aes(x = price_percentile, y = sugar_percentile) ) + geom_point() + labs( x = "Price", y = "Sugar", title = "Price and Sugar" ) + theme_classic()注意
x、y和title的labs(标签)是如何提供的。还有一个主题被命名。 -
现在,许多点重叠。可以使用
jitter来帮助可视化重叠的点:# Introduce geom_jitter ggplot( candy, aes(x = price_percentile, y = sugar_percentile) ) + geom_jitter() + labs( x = "Price", y = "Sugar", title = "Price and Sugar" ) + theme_classic()注意
geom_point是如何被geom_jitter替换的。这允许可视化重叠的点。 -
我们可以向我们的点添加颜色美学:
# Introduce size and color aesthetic ggplot( candy, aes(x = price_percentile, y = sugar_percentile) ) + geom_jitter( color = "darkorchid", size = 2 ) + labs( x = "Price", y = "Sugar", title = "Price and Sugar" ) + theme_classic()注意所有点都被改变为一种颜色。
-
此外,我们还可以更改我们点的尺寸和形状:
# Introduce point shape and fill color ggplot( candy, aes(x = price_percentile, y = sugar_percentile) ) + geom_jitter( color = "darkorchid", fill = "orchid", shape = 21, size = 2 ) + labs( x = "Price", y = "Sugar", title = "Price and Sugar" ) + theme_classic()注意
shape和size是如何改变的。你可以参考 文档 来了解哪些数字对应哪些形状。
随时间可视化
-
你可以想象数据是如何随时间表示的。
-
例如,考虑如何表示飓风安妮塔的数据随时间的变化。
-
我们可以像以前一样用点来绘制:
# Visualize with geom_point load("anita.RData") ggplot(anita, aes(x = timestamp, y = wind)) + geom_point()注意
timestamp和风速是如何随时间放置在点上的。 -
虽然这种可视化很有用,但通过显示风速是否增加或减少的线条来展示可能会更有用。每个点都可以通过以下方式用线条连接:
# Introduce geom_line load("anita.RData") ggplot(anita, aes(x = timestamp, y = wind)) + geom_line()注意
geom_line被用作一个新图层。 -
结果是一系列在每次时间戳处改变方向的线条。如果我们能结合
point和line会怎样?嗯,确实可以!# Combine geom_line and geom_point load("anita.RData") ggplot(anita, aes(x = timestamp, y = wind)) + geom_line() + geom_point(color = "deepskyblue4")注意如何通过
geom_line添加带有线条的图层。然后,使用deepskyblue4添加geom_point作为图层。 -
美学可以通过多种方式修改:
# Experiment with geom_line and geom_point aesthetics load("anita.RData") ggplot(anita, aes(x = timestamp, y = wind)) + geom_line( linetype = 1, linewidth = 0.5 ) + geom_point( color = "deepskyblue4", size = 2 )注意如何修改
linetype和linewidth。然后,改变点的size。你可以参考 文档 了解更多关于各种线型的信息。 -
就像我们今天之前的图表一样,我们可以添加标签和主题:
# Add labels and adjust theme load("anita.RData") ggplot(anita, aes(x = timestamp, y = wind)) + geom_line( linetype = 1, linewidth = 0.5 ) + geom_point( color = "deepskyblue4", size = 2 ) + labs( y = "Wind Speed (Knots)", x = "Date", title = "Hurricane Anita" ) + theme_classic()注意
labs如何允许我们为y、x和标题指定标签。然后,启用theme_classic。 -
作为最后的点缀,我们还可以添加一条水平线来划分飓风状态。飓风安妮塔何时成为飓风?
# Add horizontal line to demarcate hurricane status load("anita.RData") ggplot(anita, aes(x = timestamp, y = wind)) + geom_line( linetype = 1, linewidth = 0.5 ) + geom_point( color = "deepskyblue4", size = 2 ) + geom_hline( linetype = 3, yintercept = 64 ) + labs( y = "Wind Speed (Knots)", x = "Date", title = "Hurricane Anita" ) + theme_classic()注意如何添加一个新图层来显示
yintercept = 64处的线条,以指定任何 64 或更高的值都被认为是飓风。linetype被指定为3或点划线。
总结
在本课中,你学习了如何在 R 中可视化数据。具体来说,你学习了以下内容:
-
ggplot2
-
标度
-
标签
-
填充
-
主题
-
点
-
随时间可视化
欢迎下次我们讨论如何测试我们的程序。
第六讲
-
欢迎!
-
异常
-
信息
-
警告(warning) -
停止(stop) -
单元测试
-
testthat
-
浮点数测试(
testing-floating-point-values) -
容忍度(tolerance) -
测试驱动开发
-
行为驱动开发
-
测试覆盖率
-
总结
欢迎!
-
欢迎回到 CS50 的 R 语言编程入门课程!
-
今天,我们将学习关于程序测试的内容。我们将了解程序可能会出错的地方,当它们出错时我们如何处理,以及如何系统地测试我们的程序以确保它们按预期运行!
异常
-
考虑以下计算平均值的程序:
# Define function to calculate average value in a vector average <- function(x) { sum(x) / length(x) }注意这个程序尝试将其输入作为一个数字向量,并输出平均值。
-
你可以想象用户可能会意外地传递字符而不是数字,导致我们的
average函数输出错误。 -
这些错误被称为异常。有没有可能检查潜在的这种异常呢?考虑以下对
average的更新:# Handle non-numeric input average <- function(x) { if (!is.numeric(x)) { return(NA) } sum(x) / length(x) }注意一个条件语句,一个
if语句,是如何检查向量x是否不全是数字。在 R 世界的惯例中,在这种情况下返回一个值NA是合适的。
信息(message)
-
虽然这允许我们的程序无声运行,但我们可能希望让用户知道发生了异常。一种通知用户的方式是通过
message函数:# Message about returning NA average <- function(x) { if (!is.numeric(x)) { message("`x` must be a numeric vector. Returning NA instead.") return(NA) } sum(x) / length(x) }注意程序返回
NA的原因是通过message发送给用户的。 -
传统上,
message是在没有出错时使用的:message纯粹是信息性的。因此,我们可以通过warning提升这条信息的重要性。
警告(warning)
-
我们可以将我们的
message的重要性提升到warning,如下所示:# Warn about returning NA average <- function(x) { if (!is.numeric(x)) { warning("`x` must be a numeric vector. Returning NA instead.") return(NA) } sum(x) / length(x) }注意现在输出的是一个警告信息。
-
一个
warning不会完全停止程序,但它确实让程序员知道出了问题。
停止(stop)
-
你可以想象一些情况,你并不只是想警告用户;你可能想完全停止函数。考虑以下:
# Stop instead of warn average <- function(x) { if (!is.numeric(x)) { stop("`x` must be a numeric vector.") } sum(x) / length(x) }注意
stop告诉用户,由于他们提供的输入,我们无法继续。 -
也可以将两种可能性结合起来。例如,以下代码检查了
x包含非数字元素的情况。同样,此代码也适应了存在NA值的情况:# Handle NA values average <- function(x) { if (!is.numeric(x)) { stop("`x` must be a numeric vector.") } if (any(is.na(x))) { warning("`x` contains one or more NA values.") return(NA) } sum(x) / length(x) }注意提供了两个
if语句。
单元测试
-
单元测试用于测试我们的函数和程序。
-
考虑以下在单独文件中对
average进行的测试函数:# Write test function source("average6.R") test_average <- function() { if (average(c(1, 2, 3)) == 2) { cat("`average` passed test :)\n") } else { cat("`average` failed test :(\n") } } test_average()注意到这个函数提供了一个测试案例,其中将数字
1、2和3传递给average函数。然后,提供了一些反馈。注意在第一行,source确保这个测试文件可以访问average函数。 -
测试负数也是明智之举:
# Add test cases source("average6.R") test_average <- function() { if (average(c(1, 2, 3)) == 2) { cat("`average` passed test :)\n") } else { cat("`average` failed test :(\n") } if (average(c(-1, -2, -3)) == -2) { cat("`average` passed test :)\n") } else { cat("`average` failed test :(\n") } if (average(c(-1, 0, 1)) == 0) { cat("`average` passed test :)\n") } else { cat("`average` failed test :(\n") } } test_average()注意到为正数、负数和零提供了额外的测试。
-
我们已经编写了 21 行代码!幸运的是,程序员已经创建了各种测试包或库,可以用来测试我们的代码。
testthat
-
testthat 是一个用于测试 R 代码的包。可以通过在控制台中输入
library(testthat)来加载。 -
testthat 包含一个名为
test_that的函数,可以用来测试我们的函数:# Test warning about NA values source("average6.R") test_that("`average` calculates mean", { expect_equal(average(c(1, 2, 3)), 2) expect_equal(average(c(-1, -2, -3)), -2) expect_equal(average(c(-1, 0, 1)), 0) expect_equal(average(c(-2, -1, 1, 2)), 0) }) test_that("`average` warns about NAs in input", { expect_warning(average(c(1, NA, 3))) expect_warning(average(c(NA, NA, NA))) })注意到
test_that函数可以指示期望各种数字的平均值等于某个特定值,这要归功于expect_equal。同样,我们也可以向test_that函数提供指令以expect_warning,当平均计算包含NA值时。此外,注意测试被分为不同的部分。一个部分测试平均值的计算,而另一个部分测试警告。 -
运行上述测试,我们发现我们的
average函数中if语句的顺序可能是不正确的:# Fix ordering of error handling average <- function(x) { if (any(is.na(x))) { warning("`x` contains one or more NA values.") return(NA) } if (!is.numeric(x)) { stop("`x` must be a numeric vector.") } sum(x) / length(x) }注意到条件语句的顺序被改变了。
-
我们应该测试
average在输入为NA值时返回NA,而不仅仅是average会引发警告!# Test NA return values source("average7.R") test_that("`average` calculates mean", { expect_equal(average(c(1, 2, 3)), 2) expect_equal(average(c(-1, -2, -3)), -2) expect_equal(average(c(-1, 0, 1)), 0) expect_equal(average(c(-2, -1, 1, 2)), 0) }) test_that("`average` returns NA with NAs in input", { expect_equal(suppressWarnings(average(c(1, NA, 3))), NA) expect_equal(suppressWarnings(average(c(NA, NA, NA))), NA) }) test_that("`average` warns about NAs in input", { expect_warning(average(c(1, NA, 3))) expect_warning(average(c(NA, NA, NA))) })注意到我们有两个独立的测试,将
NA值作为输入传递给average。一个测试正确的返回值,而另一个测试会引发一个warning。 -
test_that有其他函数可以帮助我们进行测试,包括expect_error和expect_no_error。 -
使用
expect_error我们可以修改我们的代码如下:# Test stop if argument is non-numeric source("average7.R") test_that("`average` calculates mean", { expect_equal(average(c(1, 2, 3)), 2) expect_equal(average(c(-1, -2, -3)), -2) expect_equal(average(c(-1, 0, 1)), 0) expect_equal(average(c(-2, -1, 1, 2)), 0) }) test_that("`average` returns NA with NAs in input", { expect_equal(suppressWarnings(average(c(1, NA, 3))), NA) expect_equal(suppressWarnings(average(c(NA, NA, NA))), NA) }) test_that("`average` warns about NAs in input", { expect_warning(average(c(1, NA, 3))) expect_warning(average(c(NA, NA, NA))) }) test_that("`average` stops if `x` is non-numeric", { expect_error(average(c("quack!"))) expect_error(average(c("1", "2", "3"))) })注意到当输入是“quack!”或提供字符而不是数字时,代码期望出现错误。
测试浮点值
-
我们可能希望将浮点值(即十进制值)作为输入传递给
average:# Test doubles source("average7.R") test_that("`average` calculates mean", { expect_equal(average(c(1, 2, 3)), 2) expect_equal(average(c(-1, -2, -3)), -2) expect_equal(average(c(-1, 0, 1)), 0) expect_equal(average(c(-2, -1, 1, 2)), 0) expect_equal(average(c(0.1, 0.5)), 0.3) }) test_that("`average` returns NA with NAs in input", { expect_equal(suppressWarnings(average(c(1, NA, 3))), NA) expect_equal(suppressWarnings(average(c(NA, NA, NA))), NA) }) test_that("`average` warns about NAs in input", { expect_warning(average(c(1, NA, 3))) expect_warning(average(c(NA, NA, NA))) }) test_that("`average` stops if `x` is non-numeric", { expect_error(average(c("quack!"))) expect_error(average(c("1", "2", "3"))) })注意到在第一组测试的末尾添加了一个浮点值的测试。
容忍度
-
浮点值是独特的,因为它们受到浮点数不精确性的影响。
-
让我们通过例子来理解浮点数的不精确性:
# Demonstrates floating-point imprecision print(0.3) print(0.3, digits = 17)注意到在 R 中,0.3 并不是精确地表示为 0.3。这是编程语言中常见的现象,因为存在无限多的浮点值和有限的位数来表示它们。
-
由于浮点数的不精确性,涉及浮点值的等式测试需要允许一定的容忍度。容忍度指的是一个范围,即高于或低于预期值,将被视为与预期值相等。容忍度通常以绝对值指定,例如 ± .000001。
-
expect_equal函数已经提供了一种通常适用于大多数用例的容差级别。这个默认值可以通过tolerance参数进行更改。 -
你和你的团队应该决定在计算中期望的精度水平。
测试驱动开发
-
一种开发哲学被称为 测试驱动开发。在这种思维模式下,人们认为在编写将被测试的源代码之前先创建一个测试是最好的。考虑以下测试:
# Test greet source("greet1.R") test_that("`greet` says hello to a user", { expect_equal(greet("Carter"), "hello, Carter") })注意你可以想象一个
greet函数应该能够问候作为输入的用户。 -
观察这个测试,我们可以编写响应测试的代码:
# Greets a user greet <- function(to) { return(paste("hello,", to)) }
注意这段代码是如何通过用户名向用户打招呼的。
- 在测试驱动开发中,编写测试让程序员知道他们应该实现哪些功能。好处是这些功能随后可以立即进行测试。进一步的修改应该始终通过已经编写的测试。
行为驱动开发
-
行为驱动开发 在精神上与测试驱动开发相似,但更侧重于函数在上下文中的行为。在行为驱动开发中,人们可能会通过明确命名函数应该做什么来描述我们希望函数执行的操作。
-
testthat 包含两个函数来实现行为驱动开发,
describe和it:# Describe greet source("greet2.R") describe("greet()", { it("can say hello to a user", { name <- "Carter" expect_equal(greet(name), "hello, Carter") }) it("can say hello to the world", { expect_equal(greet(), "hello, world") }) })注意
describe包含了几个基于代码的描述,说明了it(该函数!)应该能够做什么。
测试覆盖率
- 当你开始为你的代码编写测试时,考虑这些测试的全面性。定义出你的代码需要完成的关键任务,并创建体现这些关键任务的测试。
总结
在本课中,你学习了如何在 R 中测试程序。具体来说,你学习了以下内容:
-
异常
-
message -
warning -
stop -
单元测试
-
testthat
-
测试浮点值
-
容差
-
测试驱动开发
-
行为驱动开发
-
测试覆盖率
次次见,届时我们将能够打包我们的代码并与世界分享。
第七讲
-
欢迎!
-
包
-
包结构
-
devtools
-
编写测试
-
编写 R 代码
-
NAMESPACE
-
测试代码
-
编写文档
-
构建包
-
更新包
-
使用和共享包
-
总结
欢迎光临!
-
欢迎回到 CS50 的 R 编程入门课程!
-
今天,我们将学习如何打包和分发我们的程序。这样,我们就可以与世界分享它们了!
包
-
今天,我们将构建和打包一个名为
ducksay的程序。 -
如果你已经参加过我们的其他 CS50 课程,你可能知道一个名为
cowsay的程序。它接受一些文本并创建一个牛说这些文本的图像。我们将以同样的精神构建ducksay。 -
包 是经过编译的源代码,以便可以分发。
包结构
-
包应该保存在一个与包同名的文件夹中。
-
我们可以在 R 控制台中输入以下命令来做到这一点:
dir.create("ducksay") setwd("ducksay")注意这些命令创建了一个名为
ducksay的目录,然后将工作目录设置为ducksay。 -
包通常在主文件夹中有以下结构:
DESCRIPTION NAMESPACE man/ R/ tests/DESCRIPTION文件将包括对包的描述,包括谁编写了它。NAMESPACE文件将包括我们希望向我们的包用户提供的函数列表。man是一个包含包手册(文档)的文件夹。R包含包的 R 代码。最后,tests包含我们想要运行的所有测试,以确保我们的包按预期行为。 -
我们可以在 R 控制台中输入
file.create("DESCRIPTION")来创建一个DESCRIPTION文件。现在我们可以打开这个文件并按照以下方式编码:# Demonstrates required components of a DESCRIPTION file Package: ducksay Title: Duck Say Description: Say hello with a duck. Version: 1.0 Authors@R: person("Carter", "Zenke", email = "carter@cs50.harvard.edu", role = c("aut", "cre", "cph")) License: MIT + file LICENSE注意包的命名和标题。然后,提供描述。包括作者。最后,提供提供此包的许可证。你可以在
DESCRIPTION文件的 文档 中了解更多关于这些字段的信息。 -
如上
DESCRIPTION文件所示,我们还需要一个LICENSE文件。我们可以按照以下方式编码:# Demonstrates adding on to a license template YEAR: ... COPYRIGHT HOLDER: ducksay authors用当前年份填充
...。注意许可证和版权所有者的年份是如何命名的。
devtools
-
一个名为 devtools 的包允许我们更快地创建包。
-
特别是,
devtools包提供了创建我们包测试和 R 代码所需文件夹结构的工具。 -
我们可以通过在 R 控制台中输入
library(devtools)来加载 devtools,假设它已经安装。
编写测试
-
感谢 devtools 包,我们可以轻松地使用 testthat 为我们编写的包开发测试。
-
然后,我们可以输入
use_testthat()来调用使用 testthat 的能力。我们的DESCRIPTION文件将自动修改如下:# Demonstrates suggesting a dependency, for testing's sake Package: ducksay Title: Duck Say Description: Say hello with a duck. Version: 1.0 Authors@R: person("Carter", "Zenke", email = "carter@cs50.harvard.edu", role = c("aut", "cre", "cph")) License: MIT + file LICENSE Suggests: testthat (>= 3.0.0) Config/testthat/edition: 3注意包会建议应该安装 testthat 版本 3.0.0 或更高版本。这可能会根据您安装的 testthat 版本而有所不同。
-
在由
use_testthat创建的tests/testthat文件夹内,我们可以创建我们的第一个测试,test-ducksay.R,如下所示:# Demonstrates describing behavior of `ducksay` describe("ducksay()", { it("can print to the console with `cat`", { expect_output(cat(ducksay())) }) it("can say hello to the world", { expect_match(ducksay(), "hello, world") }) })注意
expect_match在ducksay的输出中寻找字符串hello, world。
编写 R 代码
-
继续使用 devtools,现在我们可以在 R 控制台中输入以下内容:
use_r("ducksay")。 -
此命令将创建一个名为
R的文件夹和一个名为ducksay.R的文件。 -
现在,是时候在我们的程序中提供一些我们将打包的功能了。这个功能应该与我们所编写的测试相匹配。
-
按照以下方式编写
ducksay.R的代码:# Demonstrates defining a function for a package ducksay <- function() { paste( "hello, world", ">(. )__", " (____/", sep = "\n" ) }
NAMESPACE
-
如前所述,我们需要在名为
NAMESPACE的文件中提供有关此包最终用户可用的函数的信息。 -
要这样做,您可以在控制台中输入
file.create("NAMESPACE")。然后,按照以下方式编辑此文件:# Demonstrates declaring `ducksay` accessible to package end users export(ducksay)此文件仅使
ducksay函数对包的最终用户可用。 -
现在,我们可以在 R 控制台中输入
load_all()来加载NAMESPACE中命名的所有可用函数。
测试代码
-
现在,您可以通过运行
test()来测试我们的函数。不应该出现任何错误。 -
此外,在您加载了这个包的函数之后,现在您可以在 RStudio 中使用
ducksay。 -
更新我们的测试,让我们测试一下
ducksay中是否出现了鸭子:# Demonstrates checking for duck in output describe("ducksay()", { it("can print to the console with `cat`", { expect_output(cat(ducksay())) }) it("can say hello to the world", { expect_match(ducksay(), "hello, world") }) it("can say hello with a duck", { duck <- paste( ">(. )__", " (____/", sep = "\n" ) expect_match(ducksay(), duck, fixed = TRUE) }) })注意这个测试看起来是否表示了鸭子。此外,注意
fixed = TRUE,正如讲座中所述,它防止测试错误地解释鸭子中的一些字符作为称为正则表达式的东西的一部分。现在就足够了,正则表达式不是我们想要的!
编写文档
-
现在,我们可以记录如何使用我们的函数。通常,我们可以输入
?ducksay来查看文档。然而,我们还没有创建我们的文档。 -
文档是用一种称为标记语言的类型语言编写的。标记语言提供用于指定文档格式的语法。
-
您可以通过以下方式编写文档:
dir.create("man") file.create("man/ducksay.Rd")第一个命令创建一个名为
man的文件夹。第二个创建我们的文档文件。 -
按照以下方式修改您的文档文件:
# Demonstrates required markup for R documentation files \name{ducksay} \alias{ducksay} \title{Duck Say} \description{A duck that says hello.} \usage{ ducksay() } \value{ A string representation of a duck saying hello to the world. } \examples{ cat(ducksay()) }注意
name、title、description、usage以及其他部分是如何提供的。您可以通过阅读有关 R 文档文件的文档来了解更多关于这些元素的信息。 -
现在,是时候总结一下并分享我们的包了。
构建包
-
一旦一个包的内容准备好打包和分发,可以使用两个命令中的任何一个来启动 构建:
build R CMD build注意到
build是一个 devtools 函数,可以直接在 R 控制台中运行。R CMD build可以在 R 的计算机终端外运行。 -
运行
build,你将在工作目录中看到一个.gz文件被输出。
更新包
-
要更新我们的代码,我们可以打开我们的测试文件并更新测试如下:
# Demonstrates ensuring duck repeats given phrase describe("ducksay()", { it("can print to the console with `cat`", { expect_output(cat(ducksay())) }) it("can say hello to the world", { expect_match(ducksay(), "hello, world") }) it("can say hello with a duck", { duck <- paste( ">(. )__", " (____/", sep = "\n" ) expect_match(ducksay(), duck, fixed = TRUE) }) it("can say any given phrase", { expect_match(ducksay("quack!"), "quack!") }) })注意到添加了一个新的测试,用于查找“quack!”
-
考虑到这个测试,我们现在可以更新我们的源代码,以便输入任何短语,然后鸭子会相应地表达出来:
# Demonstrates taking an argument to print ducksay <- function(phrase = "hello, world") { paste( phrase, ">(. )__", " (____/", sep = "\n" ) }注意到提供了一个默认的
phrase,“hello, world”。如果提供了另一个phrase,它将说出那个phrase。 -
同样,我们可以更新我们的文档文件如下:
# Demonstrates updated markup, including specifying arguments \name{ducksay} \alias{ducksay} \title{Duck Say} \description{A duck that says hello.} \usage{ ducksay(phrase = "hello, world") } \arguments{ \item{phrase}{The phrase for the duck to say.} } \value{ A string representation of a duck saying the given phrase. } \examples{ cat(ducksay()) cat(ducksay("quack!")) }注意到
value已更新。此外,arguments也已更新。另一个例子在examples中提供。 -
我们可以再次运行
build来包含我们的修改。 -
现在我们可以将这个包与其他人共享。
使用和共享包
-
现在让我们创建一个名为
greet.R的程序,该程序使用这个包。 -
我们可以通过在 R 控制台中输入
setwd("..")来将工作目录设置在ducksay之外。这将把我们的工作目录移动到ducksay之上的一级目录。 -
接下来,我们可以输入
file.create("greet.R")来创建一个新文件。按照以下方式修改此文件:# Demonstrates using custom package library(ducksay) name <- readline("What's your name? ") greeting <- ducksay(paste("hello,", name)) cat(greeting)注意到这个程序加载了
ducksay。然后,代码使用这个新的库。 -
虽然这个包在我们的计算机上可以工作,因为我们是在本地开发的这个包,但其他人需要安装这个包。为此,可以使用以下命令之一:
install.packages R CMD INSTALL如前几节课所讨论的,顶级命令可以直接在 RStudio 中运行,并且是 R 本身构建的。另一个命令可以在计算机的终端中运行。它也是 R 中构建的。
-
要安装我们的包,我们可以在控制台中运行以下命令:
install.packages("ducksay_1.0.tar.gz") -
你可以使用 CRAN、GitHub 甚至电子邮件来共享你的代码。
总结
在本课中,你学习了如何在 R 中打包你的程序。具体来说,你学习了以下内容:
-
包
-
包结构
-
devtools
-
编写测试
-
编写 R 代码
-
NAMESPACE -
测试代码
-
编写文档
-
打包包
-
更新包
-
使用和共享包
在本课程中,你学习了关于 R 和 R 编程的许多知识。你学习了如何表示数据、转换数据、应用函数、整理数据、可视化数据、测试程序和打包程序。总的来说,我们希望这些材料对你有所帮助。我们也希望你能将所学应用于世界上的伟大事业。
这就是 CS50 的 R 编程入门。
scratch
精灵
简介
-
Scratch 是一种由 麻省理工学院媒体实验室 的团队最初开发,现在由其自己的 Scratch 基金会 维护的基于视觉块状编程语言。
-
通过在 Scratch 中组合“拼图块”,我们可以创建视觉故事、动画、游戏和其他程序。
-
我们将学习和使用编程概念和思想,如函数、循环、条件和变量。
-
尽管 Scratch 使用的是视觉块而不是文本代码,但其程序基于相同的基本思想,并使用相同的计算思维原则。
界面基础
-
我们可以访问 Scratch 的网站,点击“开始创作”,在那里我们将看到一个类似这样的界面:
![Scratch 的网页用户界面,带有代码块、舞台和精灵的面板]()
-
在左侧,我们有一个块库,我们可以将任何组合的块拖放到中间部分,称为块编辑器,我们将在这里构建我们的项目。
-
在右上角,我们有舞台,我们的项目将在其中运行并展示给观看或使用它的人。
-
精灵
命名和定位
-
当我们创建一个新的项目时,我们会看到 Scratch 猫角色,称为精灵,它只是一个可以出现在舞台上的对象。
-
在舞台下方,我们看到我们项目中的所有精灵,目前我们只有一只猫,称为“Sprite1”。
-
我们可以通过点击并拖动猫来在舞台上移动它。注意,当我们移动精灵时,精灵的位置值会发生变化。例如,当精灵移动到右上角时,x 值为 177,y 值为 42:
![舞台和精灵面板显示新位置]()
-
结果表明,精灵位于舞台上的 x 和 y 坐标网格上,其中 x 值表示精灵向左或向右的距离,y 值表示精灵在舞台上的上下距离。
-
舞台的完美中心是(0,0),正 x 值将精灵向右移动,负 x 值将精灵向左移动。同样,正 y 值将精灵向上移动,负 y 值将精灵向下移动。
-
我们可以看到精灵的当前位置作为 x 和 y 值,但我们可以通过一小部分中的块来控制它们。
-
我们也可以直接通过点击值并输入我们想要的值来更改精灵的 x 和 y 值。我们还可以点击精灵的名称,并将其更改为其他名称。这将帮助我们在我们开始向项目中添加更多精灵时跟踪我们的精灵。
-
我们可以通过带有“显示”标签的切换来显示或隐藏每个精灵,并更改大小(以百分比表示),或方向,这将使猫旋转以面对一定数量的度数:
![带有名称、位置、显示、大小和方向的精灵控制]()
- 当我们点击“方向”的值时,我们会看到一个可以旋转的小旋钮,这也会使舞台上的精灵旋转。
添加精灵
-
让我们添加一个新的精灵。我们可以在精灵区域底部点击带有小加号的按钮,然后看到一些添加新精灵的选项:
![带有加号并标记为“选择一个精灵”的按钮]()
-
现在,我们将使用主要的“选择一个精灵”按钮,我们将看到 Scratch 附带的大列表精灵,我们可以使用。我们可以点击类别或使用搜索框查找特定内容。
-
我们将点击鱼,我们会看到两个精灵现在都在我们的舞台上。我们可以移动它们,使它们不重叠。
-
我们还在底部区域有精灵,其中蓝色高亮的是选中的精灵,也是我们正在处理的精灵。我们可以点击它们中的每一个来更改它们的位置或大小,例如。
-
我们可以右键点击(或按住控制键并点击)我们的鱼,然后看到一个带有一些选项的菜单。我们可以点击“复制”,现在我们将在舞台上有两条鱼:
![带有复制菜单项的鱼]()
-
然后,我们可以移动它们,使它们不重叠:
![舞台上有猫和两条鱼在不同位置]()
-
-
我们可以在Sprites中看到这个示例。
服装
-
每个精灵都有一个服装,这仅仅是精灵的外观图像。
-
在左上角,我们可以使用“服装”选项卡:
![鱼的服装选项卡]()
- 我们会看到我们的鱼有四个服装,“fish-a”、“fish-b”、“fish-c”和“fish-d”,我们可以通过点击它们来选择不同的服装。
-
对于猫,我们有两种不同的服装,它的腿处于不同的位置。通过在它们之间切换,我们可以让它看起来像是在走路。
-
在服装编辑器中,我们甚至可以使用左下角的按钮添加新的服装,带有加号。我们可以从 Scratch 添加服装,或者使用中心工具绘制自己的服装。
-
我们还可以通过选择它们并使用工具来更改它们的外观来编辑内置的服装。
-
我们可以在Costumes中看到这个示例。
-
最后,我们可以从我们的电脑上传自己的照片或图像作为服装使用。
声音
-
我们可以使用顶部的“声音”选项卡为精灵添加声音:
![带有泡泡声和控制的“声音”选项卡]()
-
我们看到我们的鱼有一个“bubbles”声音和一个“ocean wave”声音,如果我们选择精灵面板中的猫,我们会看到它只有一个声音,一个“meow”声音。
-
我们可以更改内置的声音,或者录制或上传自己的声音。
背景
-
我们的舞台有一个纯白色背景,因此我们可以点击右下角的按钮来选择一个新的背景:
![选择背景按钮]()
-
现在我们将看到许多不同的背景,我们将使用水下背景来制作我们的鱼。
-
但我们的猫现在位置不正确,因此我们可以在精灵面板中点击它,并使用垃圾桶图标来移除它。
-
我们可以通过将旋转更改为负 90 度来翻转我们的黄色和绿色鱼,但这会使我们的鱼颠倒过来。结果是,我们可以将旋转样式从全方位(左边的圆圈)更改为左右(中心的三角形):
![鱼面向左,左右旋转被选中]()
- 现在我们的精灵只会面向左边或右边。
-
在右上角,我们可以点击全屏图标来全屏查看我们的舞台和精灵。
-
我们可以在背景中看到这个例子。
保存
-
要保存我们的项目以便我们以后可以保留和使用它,我们可以使用左上角的文件菜单来保存:
![带有保存选项的文件菜单]()
- 保存我们的项目后,我们可以通过使用同一菜单中的“从您的电脑加载”选项来稍后加载它。
-
我们还可以通过右上角的“加入 Scratch”按钮在 Scratch 上创建一个账户,这将把我们的项目保存到 Scratch 网站上。这也会让我们轻松与他人分享我们的项目。
下次
- 下次,我们将开始使用这些代码块来编程我们的精灵执行不同的动作,甚至根据人的输入创建交互式故事和游戏。
函数
上次
-
上次,我们介绍了舞台和精灵,包括服装、声音和背景。但我们通过点击和拖动等方式手动进行了所有更改。
-
我们将打开一个新的 Scratch 项目,并回忆起我们可以通过点击和拖动,或者更改其 x 和 y 坐标的值来在舞台上移动我们的猫。
函数
-
在 Scratch 界面的左侧,我们将看到积木块。
-
在 Scratch 的上下文中,函数是指执行某些任务的积木块。
-
例如,第一个积木块说“移动 10 步”,我们可以通过将其从积木块库拖到左侧,拖到项目中心的编辑部分来使用它。
![代码编辑器中心移动 10 步积木块]()
-
现在,我们已经添加了我们的第一个函数,如果我们点击这个积木块,我们会看到我们的猫会稍微向右移动,并且它的位置 x 值也更新了。
-
我们可以在移动中看到这个例子。
输入
-
注意,在积木块中有一个值为 10 的区域,我们可以更改它。这个值被称为函数的输入,或者函数可以用来改变其行为的信息。
-
对于移动积木块,输入是一个表示移动步数的数字。我们可以将其更改为移动我们的猫 30 步,例如:
move (30) steps -
我们还可以添加其他积木块,例如:
turn right (15) degrees -
我们可以在移动和转向中看到这个例子。
脚本
-
我们可能希望我们的积木块可以组合,以便可以按照多个指令的顺序执行。
-
我们可以通过将其中一个积木块拖向另一个积木块来将我们的两个积木块拼接在一起,当它们靠近时,我们会看到一个高亮显示的区域:
![当两个积木块拖动靠近时高亮显示的区域]()
- 释放鼠标后,我们会看到它们拼接在一起。
-
现在,我们的积木块堆叠可以被称为脚本,当我们点击其中一个积木块时,所有的积木块将按顺序从上到下运行:
move (30) steps turn right (15) degrees- 我们可以连续点击这个积木块堆叠,它们会使我们的猫沿圆形移动。
-
我们可以使用许多不同类别的积木块来编写我们的脚本。我们也可以尝试这个积木块:
go to (random position v) -
如果我们想要删除一个积木块,我们可以按住控制键并点击,或者右键点击,并选择“删除积木块”选项。我们还可以将积木块拖回积木块库,如果我们在该区域内释放它,它就会消失。
四处走动
-
我们将删除我们的猫,并为我们的下一个程序选择一个新的精灵,滑行,其中我们的精灵将在舞台上移动。
-
让我们使用刺猬,我们首先使用“转到”积木块确保我们从左上角开始:
go to x: (-180) y: (120) -
在之后,我们将使用另一个积木块将我们的刺猬移动到右上角:
go to x: (-180) y: (120) go to x: (180) y: (120) -
然后我们想要移动到右下角:
go to x: (-180) y: (120) go to x: (180) y: (120) go to x: (180) y: (-120) -
最后,左下角,两个值都是负数:
go to x: (-180) y: (120) go to x: (180) y: (120) go to x: (180) y: (-120) go to x: (-180) y: (-120) -
当我们点击这个积木堆时,我们的刺猬似乎立即跳到了左下角。实际上,我们的计算机运行程序非常快,所以刺猬移动得如此之快,我们只看到了最终的位置。
-
我们可以使用一个不同的积木,称为“滑动”,在一段时间内移动。我们将把底部的三个“转到”积木从我们的脚本中拖出来,因为我们仍然想在顶部左边立即开始,并像之前一样为其他三个位置添加“滑动”积木:
go to x: (-180) y: (120) glide (1) secs to x: (180) y: (120) glide (1) secs to x: (180) y: (-120) glide (1) secs to x: (-180) y: (-120) -
现在,当我们再次通过点击积木堆来运行我们的脚本时,我们看到我们的刺猬像我们预期的那样移动。我们可以再添加一个滑动,这样它就会回到原始位置:
go to x: (-180) y: (120) glide (1) secs to x: (180) y: (120) glide (1) secs to x: (180) y: (-120) glide (1) secs to x: (-180) y: (-120) glide (1) secs to x: (-180) y: (120)
注释
-
我们的项目变得有些复杂,所以我们可以使用注释,或者对我们试图做什么的简短描述,作为我们自己的提醒或他人理解我们项目的指南。
-
我们可以通过控制点击或右键点击我们的积木堆,并使用“添加注释”来写注释,就像给我们自己的笔记,这不会影响我们程序的运行:
go to x: (-180) y: (120) // These blocks move the sprite in a rectangle. glide (1) secs to x: (180) y: (120) glide (1) secs to x: (180) y: (-120) glide (1) secs to x: (-180) y: (-120) glide (1) secs to x: (-180) y: (120) -
我们可以使用其他动作积木来改变我们精灵的方向,并以其他方式移动。
外观
-
现在,我们将点击左下角的“外观”类别积木,我们将拖走我们的其他积木,并拖入一个“说 Hello!”积木,用于Say:
say [Hello!] -
现在,当我们点击这个积木时,一个话泡出现在我们的精灵旁边。
-
让我们再添加一个积木来告别:
say [Hello!] say [Goodbye!] -
但是当我们点击这个积木堆时,我们遇到了一个类似的错误,或者程序的问题:Scratch 运行这些积木如此之快,以至于我们没有时间看到“Hello!”,因为它立即变成了“Goodbye!”。
-
我们将用另一种类型的积木替换我们的积木:
say [Hello!] for (2) seconds say [Goodbye!] for (2) seconds- 现在,我们有时间查看我们刺猬发送的每条消息。
服装
-
“外观”积木也可以改变我们精灵的服装,所以我们将删除我们的刺猬精灵并创建一个新的:这次是一个熊,在Bear 1。
-
结果表明,熊已经有了两种服装可供选择,“bear-a”和“bear-b”。
-
我们可以从使用积木选择我们熊的第一个服装开始,并将其放置在舞台的左边:
switch costume to (bear-a v) go to x: (-120) y: (-50) -
我们将添加积木使我们的熊在舞台上移动,并在之后站立起来:
glide (2) secs to x: (120) y: (-50) switch costume to (bear-b v) -
我们可以尝试点击我们的四个积木堆,看看这个动作会发生。
-
现在,让我们通过点击底部右边的舞台面板中的“选择背景”按钮,选择“森林”和“树林和长椅”作为新的背景。
-
我们可以使用一个积木来确保我们的故事总是以“森林”背景开始:
switch backdrop to (Forest v) go to x: (-120) y: (-50) switch costume to (bear-a v)-
我们还会告诉我们的熊从左边开始,并切换其服装,使其不站立。
-
这三个积木都会运行得非常快,所以它们看起来好像同时发生,但实际上它们仍然一个接一个地运行。在这种情况下,由于它们都运行得如此之快,它们的顺序并不重要。
-
-
现在,我们将用“滑动”方块告诉我们的熊像之前一样“走过”舞台:
glide (3) secs to x: (300) y: (-50) switch backdrop to (Woods And Bench v)-
使用较大的 x 值,我们的熊将走出舞台的右侧。
-
一旦它这样做,我们的背景将改变为另一个,就像我们的熊到达了一个新的区域。
-
-
然后,我们将让我们的熊“走进”舞台,从舞台左侧开始,停在中间:
go to x: (-300) y: (-50) glide (3) secs to x: (0) y: (-50) -
现在,我们有一堆七个方块,当点击时,用我们的熊讲述一个故事。如果某些事情没有按预期工作,我们可能想尝试为熊的位置或滑过舞台所需的时间尝试不同的值。
-
还有两个可能有用的方块,“显示”和“隐藏”,可以使我们的精灵出现或消失:
show hide
声音,控制
-
“声音”类别的方块可以播放声音:
play sound (pop v) until done -
另一个有用的方块位于“控制”类别中,称为“等待”:
wait (1) seconds- 这将告诉我们的精灵暂停并什么都不做,这样我们就可以控制项目的节奏。
-
我们将删除我们的熊,并将背景改回默认的白色背景。
-
我们将添加一个新的精灵,鸭子,并使用我们看到的方块来玩捉迷藏:
hide wait (1) seconds show play sound (duck v) until done- 现在,我们的鸭子将消失,然后再次出现并播放声音。
-
结果表明,我们可以录制自己的声音,从我们的电脑上传声音,甚至播放音符。
扩展
-
我们将删除我们的鸭子精灵,再次选择猫。在 Scratch 界面的左下角,我们有一个带有方块和加号的蓝色图标,用于添加扩展,或更多类别的方块:
![带有方块和加号的蓝色图标]()
音乐
-
我们将开始尝试音乐,通过尝试用这个方块播放音符:
play note (60) for (0.25) beats-
数字 60 对应某个音符或声音,0.25 拍表示它将播放多长时间。
-
我们可以点击数字 60,这将显示一个钢琴键盘,当我们点击每个键时,我们可以看到音符编号的变化并听到它将听起来像什么。
-
-
我们将使用这些方块中的八个,并按顺序播放键盘上的所有白键:
play note (60) for (0.25) beats play note (62) for (0.25) beats play note (64) for (0.25) beats play note (65) for (0.25) beats play note (67) for (0.25) beats play note (69) for (0.25) beats play note (71) for (0.25) beats play note (72) for (0.25) beats- 现在,当我们点击这个方块堆时,我们将听到一个音阶被演奏。
-
我们还可以更改乐器,使音符听起来不同:
set instrument to (\(4\) Guitar v)- 我们将这个方块拖到堆栈的顶部,以便它在音符播放之前运行。
-
我们还可以通过改变节奏来更快或更慢地播放我们的音符:
set tempo to (80)- 一个更大的数字,如 80,将使音符播放得更快,而一个较小的数字,如 40,将使音符播放得更慢。
笔
-
我们将尝试另一个扩展,笔扩展。有了这个,我们可以在舞台上移动我们的精灵,并在舞台上“绘制”时虚拟放下笔。让我们看看笔的例子。
-
我们将首先放下笔,移动 30 步,然后拾起笔:
pen down move (30) steps pen up- 我们可以点击这个方块堆,我们的猫将继续移动 30 步并在移动时绘制线条。
-
当我们“拿起”笔时,我们的精灵在移动时将不再绘制。我们可以让我们的猫画一个圆圈:
pen down move (30) steps turn right (15) degrees pen up- 现在我们点击这个积木堆时,我们的猫会移动一点,旋转,并画一个圆圈。
下次
-
通过使用运动、外观、声音和控制类别中的积木,以及其他来自扩展的类型,我们可以用 Scratch 创建各种故事和程序。
-
下次,我们将看到如何使用更多类型的积木,结合我们所学的内容,将我们的项目推进得更远。
事件
上次
-
上次,我们通过组装堆叠的块来组合指令序列或函数。
-
我们使用函数在舞台上移动我们的角色或精灵,绘制,播放声音等。
事件
-
我们将从一个带有猫的新程序开始,并从一个我们之前见过的块开始:
say [Hello!] for (2) seconds- 现在,我们可以让我们的猫每次点击块时都这样做。
-
但我们可能想让我们的猫对我们或另一个精灵做出反应。
-
我们将添加一个恐龙精灵,并让它面对我们的两个精灵:
![向左旋转的恐龙,旁边是猫]()
-
我们还将为恐龙添加相同的块,但现在如果我们想让我们的猫和恐龙都说你好,我们必须非常快速地分别点击它们的这些块。
-
使用多个脚本和精灵,我们可能无法快速完成。如果能通过点击一个按钮来启动我们的项目,并自动运行所有脚本会更好。
-
事实证明,Scratch 在舞台的左上角有一个看起来像绿色标志的按钮,可以启动我们的项目。但现在点击它没有任何反应。
-
我们需要在事件类别下添加一个新的块类别。在编程中,事件只是在我们程序中发生的事情,我们的代码可以对其做出响应。
-
因此,我们将拖出一个名为“当标志被点击时”的事件块,并将我们的“说你好”块附加到它上面:
when green flag clicked say [Hello!] for (2) seconds-
现在,我们的代码已附加到标志被点击的事件。当我们点击绿色标志时,我们的恐龙说你好。
-
由于事件是触发我们代码其余部分运行的原因,因此无法将任何内容附加到此脚本的开始处。
-
-
我们可以选择猫精灵,并通过在其脚本开头添加一个“当标志被点击”块来执行相同操作。现在,当我们点击绿色标志时,我们的两个精灵都说你好。
-
事实上,现在别人可以通过点击绿色标志来运行我们的项目当标志被点击,而不必知道块的外观以及要点击哪个。
当精灵被点击
-
我们将移除我们的猫和恐龙,并为我们的下一个示例添加一只鸭子当精灵被点击。
-
现在,我们将使用一个名为“当这个精灵被点击时”的块,当我们的精灵被点击时,下面的代码将运行:
when this sprite clicked go to (random position v)- 现在,当我们点击舞台上的鸭子时,它会移动到随机位置。我们也可以用“滑动”块替换“前往”块,使它移动得更平滑。
-
注意,当我们点击舞台上的精灵时,脚本也会在运行时亮起:
![黄色光环中勾勒出的鸭子块]()
背景选择器
-
现在,我们可以构建一个像计算机程序中的按钮一样工作的东西,当按下按钮时会发生某些事情,在背景选择器。
-
我们将添加一些背景到我们的项目中,并添加一个名为 Button2 的精灵。我们将按钮拖到屏幕的左侧,并在其服装中添加一些文本:
![带文本的北极按钮]()
-
现在,我们将回到代码选项卡,并将事件和外观类别中的积木组合起来:
when this sprite clicked switch backdrop to (Arctic v)- 然后,当我们点击这个按钮时,背景将会改变。
-
我们可以右键点击,或者控制点击精灵,并选择“复制”项来复制它。我们将其中一个重命名为“北极按钮”,另一个重命名为“丛林按钮”。然后,我们可以将“丛林按钮”的服装中的文本改为“丛林”。
-
注意,代码编辑器的右上角将有一个当前精灵的略透明版本,这样我们就可以知道我们在更改哪个脚本。我们将更改代码以将此按钮的背景切换到“丛林”:
when this sprite clicked switch backdrop to (Jungle v) -
我们将“丛林按钮”拖到左侧,在“北极按钮”下方,并为名为“水下按钮”的按钮重复此过程。该按钮的积木将是:
when this sprite clicked switch backdrop to (Underwater 1 v) -
我们将按钮像这样堆叠在舞台上:
![堆叠在舞台上的按钮,标签为北极、丛林、水下]()
-
现在,任何使用我们项目的人都可以与之互动,将舞台改变成他们喜欢的样子。
鼓组
-
我们将我们的背景改回普通的白色背景,并添加一些新的精灵。
-
我们将选择几个乐器,“鼓-军鼓”,“康加鼓”和“钹”,并将它们排列在我们的舞台上的鼓组。
-
对于它们中的每一个,我们将在事件类别中添加“当这个精灵被点击”积木,并在声音类别中添加一个“播放声音”积木:
when this sprite clicked play sound (tap snare v) until done- 我们将对每个乐器都这样做,每个乐器都有与之匹配的不同声音。
-
现在,我们有一个“鼓组”,我们可以通过点击这些精灵中的任何一个来演奏。
-
回想一下,点击是一个事件,我们的代码正在对这些事件做出响应。
游泳的鱼
-
我们将移除鼓,并添加一条鱼。我们将使用“当按键按下”积木为它在游泳的鱼中启动一个新的脚本:
when [space v] key pressed play sound (bubbles v) until done- 现在,当我们按下键盘上的空格键时,我们的鱼将会播放声音。
-
我们可以点击下拉菜单来自定义“当按键按下”积木以响应其他按键。
-
我们将其改为:
when [right arrow v] key pressed change x by (10)- 我们将使用“移动”积木,当按下右箭头键时,将我们的精灵位置的 x 值移动 10 步。
-
我们将右键点击,或者控制点击这个脚本,并选择“复制”选项来复制这两个积木。我们将点击将其放置在鱼精灵的代码编辑器中,并将新的脚本改为响应左箭头:
when [left arrow v] key pressed change x by (-10) -
但是我们的鱼是向后移动的,所以我们将首先添加积木来确保当按下右箭头键时它面向右边:
when [right arrow v] key pressed point in direction (90) change x by (10)- 回想一下,我们可以使用精灵面板中的旋钮来检查用于方向值的数字是多少。
-
接下来,我们确保当按下左箭头键时它面向左边:
when [left arrow v] key pressed point in direction (-90) change x by (-10)- 我们一开始的鱼是倒置的,所以我们需要在精灵面板中将旋转样式设置为“左右”。我们也可以使用一个“设置旋转样式”的积木来帮我们完成这个操作。
-
我们将重复这个操作,以便我们的鱼可以向上移动……
when [up arrow v] key pressed change y by (10) -
…以及向下:
when [down arrow v] key pressed change y by (-10) -
现在,我们有了编程精灵在舞台上移动的能力,以响应按键。
改变大小
-
我们将添加几个额外的脚本,当按下数字时改变鱼的大小:
when [1 v] key pressed set size to (50) % when [2 v] key pressed set size to (100) % when [3 v] key pressed set size to (200) %- 使用这些积木,我们可以像之前一样移动我们的鱼在舞台周围,通过按 1、2 或 3 键来改变它的大小或小。
响度
-
我们将删除我们的鱼,并将背景改回纯白色背景,并为Balloon添加一个新的气球精灵。
-
让我们尝试使用事件类别中的另一个积木,“当响度大于”。我们的浏览器可能会要求我们允许使用麦克风,因为 Scratch 将会监听我们的麦克风。
-
我们将拖出这个积木,并添加一个积木来改变气球的大小,当 Scratch 听到的响度大于 30 时:
when [loudness v] > (30) change size by (10)-
响度值为 0 就像静音一样,100 将会非常非常响,所以我们可以尝试不同的数字来设置 Scratch 要监听的响度级别。
-
然后,下面的代码将会响应。现在,当我们为麦克风制造噪音以便它能够听到时,我们的气球会越来越大。
-
计时器
-
我们也可以更改积木下拉菜单中的值,从“响度”改为“计时器”:
when [timer v] > (30)-
结果表明,当我们通过点击标志开始我们的项目时,有一个计时器在跟踪已经过去的时间。
-
因此,这个积木会在计时器达到特定数字时执行一些代码。
-
-
我们将把我们的猫和恐龙放回Timer,并且对于我们的猫,我们将使用之前的相同脚本:
when green flag clicked say [Hello!] for (2) seconds -
至于我们的恐龙,我们将让它点击标志后等待两秒钟:
when [timer v] > (2) say [Hello!] for (2) seconds -
现在,当我们点击绿色标志时,我们的猫会先说你好,然后我们的恐龙会等待后再说你好。
-
每次我们通过点击绿色标志开始我们的项目时,计时器都会重置为 0,并计算项目开始或停止再次之前的秒数。
当背景切换时
-
还有其他的事件,比如这个积木:
when backdrop switches to ( v)- 现在,如果背景切换,我们可以让一个精灵执行一些动作。
-
通过使用这些事件积木,我们可以使我们的 Scratch 项目交互式,并响应各种事件,如点击或按键。
值
上次
-
上次,我们看了一下事件,我们的代码可以响应发生的事情,比如点击绿色标志。
-
这次,我们将更仔细地看看函数,或者我们的代码,它对这些事件做出响应。
值
-
回想一下,我们的一些函数可以接受一个或多个输入,或者某种类型的信息,这些信息进入椭圆形:
move (10) steps-
例如,这个“移动”块有一个值为 10 的椭圆形,告诉我们的精灵移动 10 步。
-
我们可以改变这个值,即实际的数字,使其更大或更小。
-
-
对于其他块,如“说”块,我们也可以使用单词作为输入。
-
结果表明,Scratch 有一些自身就是值的块,我们可以使用。在“运动”类别块的底部,我们看到一些椭圆形的块:
x position y position direction-
由于它们不是矩形的,所以我们不能像我们迄今为止使用的矩形块那样堆叠它们。
-
相反,我们可以将这些块放在其他块的椭圆形中,将它们用作输入值。
-
位置
-
我们将告诉我们的精灵说它的 x 位置,或者它相对于舞台的左右距离,持续两秒钟:
when green flag clicked say (x position) for (2) seconds- 现在,当我们点击标志时,我们的猫会说出它的当前 x 位置。如果我们移动我们的猫在舞台上,当我们再次点击标志时,我们可以看到它说了一个不同的数字。
-
我们将添加另一个块,这样我们就可以看到我们的猫的 x 位置和 y 位置:
when green flag clicked say (x position) for (2) seconds say (y position) for (2) seconds
操作符
-
操作符是另一类块,也是编程中更一般的概念。操作符接受值作为输入,并产生一个新的值。
-
例如,一个操作符是“+”块:
() + ()- 这个操作符将取两个值,比如 1 和 2,并将它们相加。
-
我们可以通过以下方式看到这一点:
when green flag clicked say ((1) + (2)) for (2) seconds- 注意,我们的绿色“+”操作符块将根据其自己的输入(3)计算一个值,然后这个值将用作我们猫的“说”块的值。
-
我们可以使用另一个“连接”块,将两个单词或字符放在一起:
when green flag clicked say (join (x position) (y position)) for (2) seconds- 现在,我们可以在同一个“说”块中同时说出 x 位置和 y 位置,因为“连接”块会帮我们把它们放在一起。
-
但当我们点击标志时,位置会相加,就像“-161-13”。
-
我们将更改背景为“Xy-grid”,这样我们就可以可视化我们的猫的位置,猫确实在-161 和-13 的位置。但我们要用逗号分隔我们的值,这样我们就可以更容易地看到这一点。
-
我们需要另一个“连接”块,将 x 位置与用逗号和空格连接的 y 位置连接起来:
when green flag clicked say (join (x position) (join [, ] (y position))) for (2) seconds- 因此,所有这些块将 x 位置与逗号、空格结合,然后是 y 位置。
-
我们可以添加另一个“连接”块来形成一个完整的句子:
when green flag clicked say (join [I am at] (join (x position) (join [, ] (y position)))) for (2) seconds -
现在,我们已经创建了位置示例。
-
我们还可以使用“方向”块让我们的猫说出它面对的方向。
按大小移动
-
在外观类别块的底部,我们看到一些更多的椭圆形块,这意味着我们可以将它们用作输入。
-
其中有一个叫做“大小”,它将仅仅表示我们的精灵有多大或多小。
-
让我们以刺猬作为我们的精灵,我们将使用块使它在每次按下右箭头键时移动 10 步,在Walking Hedgehog中:
when [right arrow v] key pressed move (10) steps -
但如果我们改变刺猬的大小,我们希望它每次移动时更多或更少,这取决于它的大小。
-
我们将把“大小”块拖进来作为值:
when [right arrow v] key pressed move (size) steps- 但由于我们的刺猬从 100%大小开始,它每次移动 100 步。
-
我们可以使用运算符类别中的“/”块,它执行除法操作。所以我们将它拖进来,并将我们的“大小”块也拖进去:
when [right arrow v] key pressed move ((size) / (10)) steps- 我们将大小除以 10,所以在 100%大小的情况下,我们的刺猬将移动 10 步,但在 50%大小的情况下,它将只移动 5 步。
随机选择
-
运算符类别中另一个块是“随机选择”,它会为我们选择一个随机数。
-
我们将通过告诉我们的刺猬每次点击标志时指向一个随机方向来尝试它,在Rotating Hedgehog中:
when green flag clicked point in direction (pick random (0) to (90))- 现在,每次我们按下绿色标志,我们的刺猬会做一些令人惊讶的事情,它不一定会每次都做同样的事情。
计时器,取整
-
在块中,感知类别中我们看到另一个值,称为“计时器”。计时器的值将是我们开始项目以来的秒数,所以我们将让刺猬每次点击时说出它,在Timing Hedgehog中:
when this sprite clicked say (timer) for (2) seconds- 现在,点击绿色标志后,我们可以点击刺猬来查看秒数,比如 4.45 或 9.90。
-
我们不想看到秒数的这么多细节,所以我们将寻找运算符类别中的“取整”块。取整块将接受一个数字作为输入,并将其四舍五入到整数。
-
因此,我们可以将我们的“计时器”块拖入一个“取整”块中,现在我们的刺猬将说出整数的秒数:
when this sprite clicked say (round (timer)) for (2) seconds
感知
-
在感知部分,我们看到另一个块,“询问你的名字?并等待”。椭圆形表示函数块的输入,在这种情况下,“你的名字是什么?”告诉块使用这个作为问题。
-
结果,这个块还有一个输出,或者返回值,在我们运行它后会返回。
-
我们可以看到,在这个块下面有一个椭圆形的“答案”块,它将显示用户对这个问题输入的任何响应的值:
![感知类别中的询问块和答案块]()
-
让我们通过构建一个名为Hello的程序来看看:
when green flag clicked ask [What's your name?] and wait say (answer) for (2) seconds-
现在,当我们运行我们的程序时,我们的猫会提出问题,并等待我们输入响应后继续:
![舞台上的猫询问你的名字并带有输入框]()
-
-
我们可以用“加入”模块让我们的猫变得更有亲和力:
when green flag clicked ask [What's your name?] and wait say (join [Hello,] (answer)) for (2) seconds -
让我们把问题改为“走了多少步?”:
when green flag clicked ask [How many steps?] and wait move (answer) steps- 我们还将使用一个“移动”模块,并在其中使用“答案”模块作为输入。因此,我们的猫将移动我们作为问题的答案输入的步数。
-
我们也可以通过询问去哪里?中的 x 值和 y 值来告诉我们的猫去一个特定的位置:
when green flag clicked ask [Pick x] and wait set x to (answer) ask [Pick y] and wait set y to (answer)- 注意,我们可以提出多个问题,而“答案”模块将具有我们为最近一个问题输入的任何值。
更改服装
-
我们现在暂时移除我们的猫,再次添加熊。我们可以在服装标签中重命名它的服装:
![熊的服装标签,服装命名为 4 和 2]()
- 我们将把熊的四条腿都穿上的服装称为“4”,而另一条两条腿的服装称为“2”。
-
现在,我们可以构建一个程序,有多少条腿?,来询问我们使用哪个服装:
when green flag clicked ask [How many legs?] and wait switch costume to (answer)- 结果表明,“切换服装”模块也可以接受一个椭圆形模块作为输入,因此现在我们的答案将被用作我们熊将切换到的服装名称的值。
更改背景图
-
我们可以用背景图来尝试这个功能。我们将添加“彩色城市”和“夜晚城市”作为我们的背景图,通过点击右下角的舞台面板,我们可以打开背景图标签来重命名它们:
![背景图标签,背景图重命名为白天和夜晚]()
- 我们将把白天城市的背景图称为“白天”,而夜晚的背景图称为“夜晚”。
-
在什么时间?,我们将创建熊的脚本:
when green flag clicked ask [What time?] and wait switch backdrop to (answer)- 现在,当我们回答“白天”或“夜晚”时,背景图将改变。
-
通过这些允许用户回答问题的模块,以及我们的代码将他们的答案作为函数输入,我们可以给用户更多的控制权,并使我们的项目变得更加有趣。
条件
上次
-
上次,我们查看了一些值,这些是我们可以在 Scratch 程序中使用的信息。
-
今天,我们将通过提问来使用值做出决定,并根据那些答案决定要做什么。例如,我们可能会在出门前问,“外面冷吗?”如果答案是肯定的,我们可能会穿上夹克。
触碰鼠标指针
-
我们将从告诉我们的猫在按下空格键时播放声音的脚本开始喵喵:
when [space v] key pressed play sound (Meow v) until done -
在积木的“控制”部分,有一个名为“如果”的积木,我们可以使用它,其中有一个六边形区域,还有一个地方可以嵌套额外的积木:
if <> then -
六边形代表我们想要提出的问题,这个问题可以用“是”或“否”来回答,或者等价地,用“真”或“假”来回答。
-
在积木的“感应”部分,我们看到一些六边形形状的积木。实际上,我们可以提出的问题被称为布尔表达式,或者描述某物可以是真(用“是”回答)或假(用“否”回答)的一种方式。
-
我们将把“触碰鼠标指针?”布尔表达式积木拖入“如果”积木的六边形中:
when [space v] key pressed if <touching (mouse pointer v)?> then play sound (Meow v) until done-
我们还将放置我们的“播放声音”积木。
-
现在,当我们按下键盘上的空格键时,如果我们的鼠标指针或屏幕上的光标触碰到舞台上的精灵,我们的猫才会播放声音。
-
-
我们还可以检查我们的精灵是否在猫和气球中触碰到另一个精灵。我们将添加一个气球精灵,并在“触碰鼠标指针?”积木的下拉菜单中,我们现在可以将值更改为:
when [space v] key pressed if <touching (Ballon1 v)?> then play sound (Meow v) until done- 现在,我们的猫只有在我们将气球拖到舞台上的它那里时才会播放声音。
触碰颜色
-
“触碰颜色?”积木将允许我们检查我们的精灵是否触碰到特定颜色的任何东西。
-
我们将保留我们的猫,并使用“冬季”背景。我们将在名为猫和树的程序中创建这个脚本:
when [right arrow v] key pressed change x by (10) if <touching color [#165e0f] ?> then say (I found a tree!) for (2) seconds -
我们将拖入这些积木中的每一个,对于“触碰颜色?”积木,我们可以点击颜色来使用不同的滑块选择颜色:
![触碰颜色积木中的颜色滑块]()
-
但要精确地获取树的颜色可能有些困难,因此我们可以点击颜色滑块底部的吸管图标,并使用它来用鼠标选择舞台上的颜色:
![带有颜色选择器的舞台和树的绿色部分]()
-
现在,当按下右箭头键时,我们的猫会向右移动。每次它向右移动时,它也会问自己是否触碰到绿色。最终,当它到达绿色的大树时,那个问题的答案将是“是”,它会说“我找到一棵树了!”。
大小
-
我们将设置舞台为普通背景,并恢复气球。我们将使用这个脚本使气球在气球中生长:
when [space v] key pressed change size by (10) -
但我们想让我们的气球最终爆裂,实际上我们可以使用“=”运算符来比较两个数字。我们将使用一个“if”块,并在其中拖入这些块:
when [space v] key pressed change size by (10) if <(size) = (200)> then-
注意,“=”运算符的形状像一个六边形,所以它也是一个我们可以使用的布尔表达式,答案为“是”或“否”。
-
现在,每次按下空格键时,我们会检查大小是否等于 200。
-
-
如果大小是 200,我们可以让我们的气球精灵隐藏,并播放爆裂声音:
when [space v] key pressed change size by (10) if <(size) = (200)> then hide play sound (Pop v) until done- 现在,当我们的气球达到 200 大小时,它会消失并播放声音,就像它爆裂了一样。
如果...那么...否则
-
我们还有运算符来检查一个数字是否大于或小于另一个数字。
-
我们将在数字中使用鸭子精灵,并且我们首先会要求输入一个数字:
when green flag clicked ask [Number:] and wait if <(answer) > (0)> then say [Positive] for (2) seconds- 然后,我们可以检查那个问题的答案是否大于 0。如果是这样,那么这个数字将是一个正数,我们可以告诉用户它是正数。
-
现在,我们能够在问题的答案是“是”时运行一些代码。但如果答案是“否”,我们可能还想做其他事情。实际上,在控制部分还有一个名为“if then else”的块:
when green flag clicked ask [Number:] and wait if <(answer) > (0)> then say [Positive] for (2) seconds else say [Negative] for (2) seconds-
底部的新部分,称为“else”,将包括另一个用于当问题的答案是“否”时运行的块堆。所以无论问题的答案是什么,我们都会运行两个块堆中的一个。
-
当问题的答案是“否”时,我们有一个负数,所以我们的鸭子会说“负数”。
-
-
但现在我们的程序中有一个错误,当 0 出现时,我们的鸭子会说“负数”,因为 0 不大于 0。我们可以在“else”部分添加另一个“if then else”块:
when green flag clicked ask [Number:] and wait if <(answer) > (0)> then say [Positive] for (2) seconds else if <(answer) < (0)> then say [Negative] for (2) seconds else say [Zero] for (2) seconds- 所以现在,如果数字不是大于零,我们会问另一个问题,如果它是小于零。如果是这样,我们可以说“负数”。否则,数字必须是 0。
刺猬赛跑
-
让我们把刺猬添加到我们的舞台上,并编写一个程序来告诉我们我们有多快地将其移动到舞台的另一边,刺猬赛跑 1。
-
我们将从一组块开始,这些块将我们的刺猬带到舞台的左侧:
when green flag clicked go to x: (-150) y: (-25) -
当按下右箭头键时,我们还想让我们的刺猬移动 10 步:
when [right arrow v] key pressed move (10) steps -
当我们的刺猬到达舞台的边缘时,我们想让它说出计时器的值,或者自我们开始程序以来已经过去了多少秒。
when [right arrow v] key pressed move (10) steps if <touching (edge v)?> then say (round (timer)) for (2) seconds-
每次按下右箭头键时,我们的刺猬将移动 10 步,但也会检查它是否触碰到舞台的边缘。
-
如果它是,我们将对计时器的值进行四舍五入,因为我们并不真的关心小数部分,只想得到整数的秒数。然后我们将在“say”块中使用这个值,以便在我们到达边缘时看到刺猬说出它。
-
-
我们还可以在Hedgehog Race 2中添加更多问题:
when [right arrow v] key pressed move (10) steps if <touching (edge v)?> then if <(timer) < (5)> then say [Fast!] for (2) seconds else say [Slow!] for (2) seconds- 现在,我们有一个询问“计时器”值是否小于 5 的块,如果答案是肯定的,我们将说“快!”。否则,我们将说“慢!”。
刺猬迷宫
-
我们将刺猬添加到我们的程序中,并使用Hedgehog Maze构建一个带有迷宫的游戏。
-
我们将点击右下角的舞台面板,并使用背景部分在背景上绘制线条,就像迷宫一样:
![带有红色线条的背景编辑器]()
- 我们将选择线条工具,将轮廓改为红色,并使用鼠标在背景周围绘制几条线。
-
现在,我们将我们的刺猬大小改为 40,并添加块使其在按下右箭头键时向右移动:
when [right arrow v] key pressed change x by (5) -
我们还希望检查刺猬是否触碰到迷宫中的墙壁。因此,我们将使用“触碰颜色?”块:
when [right arrow v] key pressed change x by (5) if <touching color [#ff0000] ?> then go to (random position v)- 现在,每次我们的刺猬移动时,它都会检查是否接触到红色。如果答案是“是”,我们将让它移动到舞台上的一个随机位置。
-
我们将右键单击或控制单击这个块堆,并选择“复制”选项,为左箭头键创建一个类似的堆:
when [left arrow v] key pressed change x by (-5) if <touching color [#ff0000] ?> then go to (random position v)- 注意,我们通过改变 x 值减去 5 来向左移动。
-
我们将重复此操作,使用“改变 y”块来上下移动:
when [up arrow v] key pressed change y by (5) if <touching color [#ff0000] ?> then go to (random position v) when [down arrow v] key pressed change y by (-5) if <touching color [#ff0000] ?> then go to (random position v) -
现在,我们可以通过按箭头键来测试这个功能,每次我们的刺猬触碰到墙壁时,它都会消失然后重新出现。
-
使用这些条件和布尔表达式的块,我们可以在程序内部根据问题的答案提出问题并做出决策。
循环
上次
-
上次,我们结合了函数、值和条件的块,使我们的 Scratch 程序能够提出问题,并根据这些问题的答案做出决定。
-
但每次我们运行代码时,无论是按绿色标志、按键还是点击,每个脚本都只能从上到下运行一次。
行走猫
-
现在,我们将告诉我们的程序循环,或重复多次执行一些块。
-
我们将以一个行走猫的例子开始,当点击绿色标志时,我们的猫将移动 10 步:
when green flag clicked move (10) steps -
我们将在块的“控制”部分使用“永远”块,使我们的猫反复移动:
when green flag clicked forever move (10) steps- 现在,我们的猫将移动,直到它到达边缘,在那里它不能再移动。它仍然会尝试移动。
-
我们可以点击舞台顶部的停止标志,旁边是绿色标志,并将猫拖回舞台的左侧。
-
我们将添加另一个块,告诉我们的猫在到达边缘时跳跃或转身:
when green flag clicked forever move (10) steps if on edge, bounce-
现在,我们的猫在边缘转身时是倒置的,但我们可以通过在精灵面板中的方向旋钮上点击并选择第二个选项来更改旋转样式:
![选择左右旋转风格的精灵方向旋钮]()
-
-
然而,我们的猫的腿并没有移动,实际上我们可以制作一个动画,图像移动得足够快,从而产生运动的错觉。
-
结果表明,我们的猫有两种服装,它的腿处于略微不同的位置。因此,我们可以使用“下一个服装”块在两者之间交替:
when green flag clicked forever move (10) steps if on edge, bounce next costume -
我们的猫看起来移动得有点快,所以我们将减慢它的移动速度:
when green flag clicked forever move (10) steps if on edge, bounce next costume wait (0.1) seconds- 现在,我们的猫将移动,如果它在边缘,则弹跳,切换到下一个服装,然后等待一秒钟的短暂时间。然后它会反复执行所有这些块,直到我们按下停止标志。
游泳鱼
-
我们将在游泳鱼中使用“水下 1”背景,并让鱼指向鼠标光标:
when green flag clicked forever point towards (mouse-pointer v)- 通过使用“永远”块,在点击绿色标志后,鱼将始终指向鼠标,反复进行。
-
我们也可以让鱼每次移动 5 步:
when green flag clicked forever point towards (mouse-pointer v) move (5) steps -
我们也可以用这个作为背景。我们可以在舞台面板中点击背景,并拖入这些块:
when green flag clicked forever play sound (Ocean Wave v) until done-
最初,“播放声音”块只有“砰”的声音,所以我们将使用“声音”选项卡将“海洋波浪”声音添加到我们的背景中。然后,我们可以使用“播放声音”块中的下拉菜单选择“海洋波浪”。
-
现在,我们的背景将反复播放波浪声。
-
-
现在,当我们点击绿色标志来运行我们的程序时,我们将有多个“永远”循环运行。我们的鱼将跟随鼠标指针,背景将连续播放波浪声。
-
使用这些积木,我们可以在我们的程序中添加重复播放的音乐,让我们有一种总有什么事情在发生的感觉。
抚摸猫
-
我们将移除我们的背景和鱼,并添加回猫,以抚摸猫。
-
现在,让我们告诉我们的猫,当鼠标太靠近时,它要发出“喵喵”声:
when green flag clicked if <(distance to (mouse-pointer v)) < (100)> then play sound (Meow v) until done-
我们可以添加一个条件来检查鼠标指针的距离。如果它小于 100 的值,我们的猫就会发出声音。
-
注意,我们可以在积木的分类中找到“距离到”积木。
-
-
但当我们运行我们的程序时,没有任何事情发生,即使我们移动鼠标指针靠近猫。结果是我们的积木堆只运行了一次,一旦我们点击绿色标志,它就会提出问题,但由于我们的鼠标指针离猫很远,所以什么也不做。
-
通过添加“永远”积木并将我们的条件放在其中,我们可以不断地提出这个问题,并且每次我们的鼠标指针太靠近时都会播放声音:
when green flag clicked forever if <(distance to (mouse-pointer v)) < (100)> then play sound (Meow v) until done -
一个“永远”循环,它会在程序结束时重复运行,也被称为无限循环。
喵喵
-
我们还可以使用“重复”积木来运行一些代码块特定次数:
repeat (10)- 注意,在这个积木的右下角有一个小箭头,表示里面的代码将会被重复执行。
-
例如,如果我们想让我们的猫叫三次,我们可以使用:
when green flag clicked play sound (Meow v) until done play sound (Meow v) until done play sound (Meow v) until done -
但我们可以通过使用“重复”积木来改进我们程序的布局,喵喵,而不是反复使用相同的“播放声音”积木:
when green flag clicked repeat (3) play sound (Meow v) until done- 这更好,因为我们现在只使用两个积木,而不是像以前那样使用三个。如果我们想重复 30 次,我们只需要改变椭圆形中的数字,而不是拖出 27 个额外的“播放声音”积木。
-
我们还可以在“重复”积木的椭圆形中使用其他值。例如,我们可以提出一个问题,并使用答案作为输入:
when green flag clicked ask [Number:] and wait repeat (answer) play sound (Meow v) until done- 现在,我们的程序用户可以控制我们的猫叫多少次。
-
我们可以在另一个例子中尝试让我们的猫在圆形中移动,圆形:
when green flag clicked repeat (24) move (30) steps turn right (15) degrees-
我们的猫将移动 30 步,轻微转向,然后重复 24 次,在圆形中移动自己。
-
以前,我们需要自己一次又一次地点击我们的积木。现在,我们的猫能够非常快速地重复所有这些操作。
-
气球
-
我们将在Balloon 1中使用循环让气球自己膨胀:
when green flag clicked repeat (10) change size by (10) wait (0.2) seconds end hide play sound (Pop v) until done- 现在,当我们点击绿色标志时,我们的气球将增加 10 个单位的大小,暂停一秒钟。它会这样做 10 次,直到它变得越来越大,然后消失并播放噼啪声。
-
但当我们再次点击绿色标志时,什么也没有发生。气球仍然隐藏着,所以我们需要添加一些积木来再次显示它:
when green flag clicked show set size to (100) % repeat (10) change size by (10) wait (0.2) seconds end hide play sound (Pop v) until done- 我们还将使用“设置大小”积木将气球恢复到原始大小。现在,每次我们点击绿色标志,它都会重新出现。
-
由于我们从 100 开始,并且增加 10 次共 10 次,气球将在 200 大小时爆裂。我们可以将循环重复的次数改为 15,现在气球将增长到 250 大小时才爆裂。
-
结果我们发现可以在“控制”部分的另一个块中使用“重复直到”,这样我们就可以避免自己进行那个计算,在Balloon 2中:
when green flag clicked show set size to (100) % repeat until <(size) = (200)> change size by (10) wait (0.2) seconds end hide play sound (Pop v) until done-
“重复直到”块是循环和条件的组合。它将反复运行,直到某个问题被回答为“是”,或者变为真。
-
我们将在“运算符”部分使用六边形形状的“=”运算符来比较气球的大小,一旦它等于 250,我们的“重复直到”块将停止重复。
-
条件成立后,我们其余的块将运行,隐藏我们的气球并播放爆裂声。
-
-
通过使用“永远”块、“重复”块和“重复直到”块,我们能够在程序中创建循环,多次运行我们的代码,而无需我们一次次地点击。
变量
-
到目前为止,我们使用的是程序中可以作为决策依据或作为函数输入的信息值。
-
当这些值存储在我们的程序中以便我们稍后使用时,它们可以被称作变量。
计数猫
-
让我们构建计数猫的示例。
-
我们将在代码块的“变量”部分查找,并看到这个标有“创建变量”的按钮:
![创建变量按钮]()
-
当我们点击这个按钮时,会要求我们输入变量的名称,所以我们将输入“count”,因为我们想用它来存储计数。
-
我们还可以决定这个变量是否对所有精灵可用或可更改,通常被称为全局变量。或者,我们也可以使这个变量仅对当前精灵可用,也称为局部变量。我们将为这个“count”变量选择“对所有精灵”。
-
-
现在,我们看到了一个可用的椭圆形“count”代码块,它左侧有一个复选框。复选框会告诉 Scratch 在舞台的左上角显示变量的值,或者存储在变量中的内容。我们可以取消勾选,这样值就会在舞台上隐藏。
-
我们可以为我们的猫添加计数点击次数的代码块:
when this sprite clicked change [count v] by (1) say (count) for (1) seconds-
“改变”代码块会将“count”变量的值增加 1。
-
然后,我们的猫会说出变量的值。
-
-
我们可以添加另一个精灵,“Button2”,它将是一个我们可以用来将计数重置为 0 的按钮。我们将使用“外观”选项卡为我们的按钮添加文本,使其标有“重置”:
![黑色文本的按钮外观]()
-
我们将为重置按钮添加这些代码块:
when this sprite clicked set [count v] to (0)- 现在,当这个按钮被按下时,0 的值将被放入“count”变量中,替换掉之前的内容。
追逐星星
-
现在,我们可以制作一个游戏,让刺猬跟踪我们的分数,追逐星星。
-
我们将删除我们的精灵,并右键点击或按住控制键点击“count”变量,然后选择“删除”选项来将其也删除。
-
我们将添加我们的刺猬,并告诉它指向并移动到鼠标指针的方向:
when green flag clicked forever point towards (mouse-pointer v) move (5) steps -
我们将添加另一个精灵,星星,每次刺猬触摸它时,它都会增加我们的分数。我们还会让它在被触摸后移动到随机位置:
when green flag clicked forever if <touching (Hedgehog v) ?> then change [score v] by (1) go to (random position v)-
首先,我们需要创建一个新的变量,我们将称之为“score”。
-
我们将使用无限循环让星星不断检查它是否触摸到刺猬,每次触摸时,我们将分数增加 1。
-
最后,我们将告诉我们的星星在被触摸后移动到随机位置。
-
-
现在,我们有一个游戏,我们可以尝试用刺猬收集星星。
-
当程序开始运行时,我们可能希望将分数重置为 0。我们将使用刺猬来实现这一点:
when green flag clicked set [score v] to (0) forever point towards (mouse-pointer v) move (5) steps- 注意,我们想要将“设置”块放在“无限循环”之外。这样,它只会被设置为 0 一次,之后由星星改变。
-
我们可以让程序在获得一定数量的分数时改变背景并停止。
-
首先,我们将点击右下角的舞台面板,并转到背景选项卡。然后点击左下角的带加号的按钮,选择“绘画”来创建我们自己的背景:
![背景选项卡中的绘画按钮]()
-
我们将添加一个覆盖背景的绿色矩形和一些写着“你赢了!”的文本。我们将把背景的名字改为“Win”。
-
现在,我们想要确保背景从纯白色背景“backdrop1”开始,所以我们将它添加到我们的刺猬上:
when green flag clicked set [score v] to (0) switch backdrop to (backdrop1 v) forever point towards (mouse-pointer v) move (5) steps -
然后,在我们的星星代码块中,我们希望在达到 10 分时改变背景并停止我们的程序:
when green flag clicked forever if <touching (Hedgehog v) ?> then change [score v] by (1) if <(score) = (10)> then switch backdrop to (Win v) stop [all v] end go to (random position v)-
在我们改变分数后,我们想要添加一个检查“分数”值的条件。如果它等于 10,那么我们将切换背景。我们还会在控制部分使用“停止”块,这将停止我们程序中的所有内容。
-
回想一下,我们可以在运算符部分得到“=”块,在变量部分得到“分数”块。
-
弹跳球
-
我们将移除这些精灵,并为我们的下一个示例选择另一个背景,“蓝天”,弹跳球。
-
我们将添加球精灵,并告诉它开始下落,或者向下移动,直到它到达地面:
when green flag clicked forever change y by (-5) if <touching color [#663600] ?> then stop [all v]-
我们将 y 位置减去 5,这使得球向下移动。
-
然后,如果球接触到背景地面上的棕色,我们将停止我们的程序。我们将点击颜色,然后点击吸管来选择棕色。
-
现在,当我们点击旗帜时,我们的球开始下落。我们可以将球拖回舞台顶部,再试一次。
-
-
但这种下落并不非常逼真。一个真实的球最初下落得很慢,然后越来越快。
-
让我们的球每次移动一个可变步数。我们将创建一个新的变量叫做“速度”,因为它将存储下落的速度,我们可以使这个变量只对“这个精灵”可用,因为其他精灵不需要使用它。
- 现在,在我们的舞台左上角,我们看到变量被标记为“球:速度”,告诉我们它属于哪个精灵。
-
对于球的代码,我们将在它下落时增加“速度”变量的值:
when green flag clicked set [speed v] to (0) forever change y by (speed) change [speed v] by (-1) if <touching color [#663600] ?> then stop [all v]-
首先,我们将“速度”设置为 0。然后,我们将改变球的位置 y 值,这个值最初将是 0。
-
之后,我们将“速度”的值减去 1,这使得它更加负,因此我们的球会更快地向下移动舞台。
-
我们的无限循环会一直重复运行,直到“停止所有”块告诉我们的程序停止。
-
-
我们可以让球在接触地面后向上移动,也可以:
when green flag clicked set [speed v] to (0) forever change y by (speed) change [speed v] by (-1) if <touching color [#663600] ?> then set [speed v] to ((speed) * (-1))-
我们可以通过乘以-1 来将我们的“速度”变量的值从负数变为正数。然后,球将开始在舞台上向上移动,但“速度”变量会再次每次减少 1。
-
最终,球的“速度”将变为 0,然后变为负数,然后它将再次开始沿着舞台向下移动。
-
-
我们还可以让球每次弹跳时稍微失去一点速度:
if <touching color [#663600] ?> then set [speed v] to (((speed) * (-1)) - (2))-
当速度从负数变为正数时,我们也可以从它减去 2,这样新的速度总是比之前略小。
-
最终,我们希望球停止移动,因此我们需要添加一些额外的代码来实现这一点。
-
-
使用变量,我们可以近似弹跳球的速率,使其运动更加逼真。
气球
-
让我们看看一个例子,用户可以在气球中控制气球的大小。
-
我们有一个气球精灵,我们将创建一个新的变量叫做“空气”。在舞台上,我们可以右键点击,或者按住控制键点击变量,并改变读数的外观:
![带有正常读数、大读数、滑块的变量]()
- 我们可以选择显示大读数,但暂时我们选择滑块。
-
现在,用户可以移动滑块来改变变量的值。我们可以右键点击,或者按住控制键点击滑块,选择“更改滑块范围”,以便有一个最小值和最大值。为此,我们将最小值设置为 100,最大值设置为 200,因为大小应该在两个值之间。
-
我们将在气球精灵中添加代码块,告诉它改变其大小:
when green flag clicked forever set size to (air) %- 现在,点击绿色标志后,气球将不断将其大小设置为“空气”变量的值,即使我们在舞台上改变滑块的值。
-
通过这些想法,我们可以允许用户直接控制变量来与我们的项目或精灵交互。
螺旋
-
让我们看看一个带有笔扩展的例子,螺旋。
-
我们会让猫沿着圆形移动:
when green flag clicked go to x: (0) y: (0) point in direction (90) forever move (10) steps turn right (15) degrees- 我们将从屏幕中心面向右开始移动和转向,以获得圆形移动的效果。
-
我们将通过使用界面左下角的蓝色按钮来添加笔扩展。然后我们将擦除一切,放下笔开始绘制:
when green flag clicked go to x: (0) y: (0) point in direction (90) erase all pen down forever move (10) steps turn right (15) degrees wait (0.05) seconds- 我们还会让猫在每一步之间等待一小段时间,这样我们就可以看到它是如何画圆的。
-
现在,让我们创建一个新的变量叫做“步骤”。我们首先将其设置为 0,并告诉猫按照变量的值移动:
when green flag clicked go to x: (0) y: (0) point in direction (90) erase all pen down set [steps v] to (0) forever move (steps) steps change [steps v] by (0.5) turn right (15) degrees wait (0.05) seconds- 注意,我们还在每次移动时稍微增加“步骤”变量的值,每次增加 0.5。因此,每次移动时,我们都会移动得更远。结果看起来像螺旋。
-
使用循环、变量和笔,我们可以在程序中绘制一些有趣的图形。
-
更普遍地,使用变量,我们可以在程序中跟踪信息,并在以后更改或使用它们,从而制作出比以前更强大、更精彩的电子游戏、动画和故事。
抽象
-
到目前为止,我们已经看到了 Scratch 的许多功能,包括让我们可以向项目中添加循环、变量和条件的块。
-
现在,让我们考虑一下我们如何可能改进我们在 Scratch 项目中设计的块的方式。
恐龙游戏
-
让我们从添加一个恐龙精灵开始,构建我们的Dinosaur Game示例。
-
我们将添加块,以便在按下箭头键时它可以移动:
when [up arrow v] key pressed change y by (10) when [down arrow v] key pressed change y by (-10) when [right arrow v] key pressed change x by (10) when [left arrow v] key pressed change x by (-10) -
我们将添加另一个精灵,星星,并且当我们的恐龙触摸星星时,我们可以说我们赢得了游戏。
-
让我们的恐龙在向上移动后检查它是否接触到了星星:
when [up arrow v] key pressed change y by (10) if <touching (Star v) ?> then say (timer) for (2) seconds-
回想一下,“if”块位于块的“控制”部分,而“接触?”块位于“感应”部分。
-
我们还会添加一个“说”块以及“计时器”变量,这样我们的恐龙就可以告诉我们找到星星花了多长时间。
-
-
但如果我们按下右键到达星星后,这种方法就不起作用了。
-
我们希望我们的恐龙能够检查无论我们使用哪个键,我们是否已经到达了星星,因此我们需要在“if”块上右键单击或控制单击,并选择“复制”为每个键创建一个副本:
when [up arrow v] key pressed change y by (10) if <touching (Star v) ?> then say (timer) for (2) seconds when [down arrow v] key pressed change y by (-10) if <touching (Star v) ?> then say (timer) for (2) seconds when [right arrow v] key pressed change x by (10) if <touching (Star v) ?> then say (timer) for (2) seconds when [left arrow v] key pressed change x by (-10) if <touching (Star v) ?> then say (timer) for (2) seconds -
我们还会在我们的星星上添加一些代码,以便每次程序启动时它都会移动到随机位置:
when green flag clicked go to (random position v) -
现在,我们可以使用箭头键将我们的恐龙移动到星星,并且每次它都会告诉我们我们花了多长时间。
-
看起来时间是以小数报告的,比如 9.70。因此,我们可以使用运算符部分中的“round”块将此时间四舍五入到最接近的秒数。
-
但现在,我们必须将“圆形”块拖入每个方向的脚本中。
-
当我们在项目中复制大量代码时,通常有一个更好的解决方案。
-
在这种情况下,我们实际上可以创建一个新的自定义块,并在需要时引用它。
-
我们将在“我的块”部分中查找,并点击“创建块”来创建一个新的块。我们将将其命名为“检查是否胜利”,因为我们正在尝试做的事情。然后,我们会看到这个块出现:
define check if won- 现在,我们可以在下面添加一些块,每次我们使用“检查是否胜利”块时,它都会运行。
-
因此,我们将条件块和“说”块移动到它那里:
define check if won if <touching (Star v) ?> then say (timer) for (2) seconds -
对于我们的其他脚本,我们将拖出我们自己的“检查是否胜利”块:
when [up arrow v] key pressed change y by (10) check if won when [down arrow v] key pressed change y by (-10) check if won when [right arrow v] key pressed change x by (10) check if won when [left arrow v] key pressed change x by (-10) check if won- 现在,我们使用更少的块来实现相同的效果。
-
如果我们想要四舍五入计时器的值,我们现在只需更改一个位置,而不是四个:
define check if won if <touching (Star v) ?> then say (round(timer)) for (2) seconds -
创建我们自己的块的能力将使我们能够改进项目的整体设计和可读性。
气球
-
让我们为Balloon 1添加一个气球精灵,并添加使其充气和放气的块:
when green flag clicked set size to (50) % repeat (10) change size by (10) end wait (1) seconds repeat (10) change size by (-10) end- 我们的风筝将从一个 50%的大小开始,增加其大小 10 次,等待一秒钟,然后减小其大小 10 次(通过负 10 改变)。
-
但这段代码需要其他人思考数字和循环在做什么,以理解会发生什么。我们可以通过添加更多块来使我们的代码更容易阅读。
-
我们可以创建一个新的名为“inflate”的块,该块将使我们的风筝大小增加 10 次:
define inflate repeat (10) change size by (10) end -
我们还将创建另一个名为“deflate”的块,它将减小风筝的大小:
define deflate repeat (10) change size by (-10) end -
对于主脚本,我们将使用我们新的块:
when green flag clicked set size to (50) % inflate wait (1) seconds deflate- 注意,我们的程序仍然执行完全相同的事情,但“when flag clicked”下的脚本更容易阅读。
-
我们可以将这些自定义块称为抽象,或者将更复杂的思想或动作取一个名字,这样我们就可以反复引用和使用。
-
我们可以在Balloon 2中给自己更多的控制。让我们右键单击或按住控制键单击我们拥有的“inflate”块,并选择“编辑”。然后,我们将点击“添加输入”以使这个块能够接受一些输入,并将其命名为“n”:
![使用 inflate 和 n 作为输入创建一个块]()
- “n”将是输入的名称,由于它将是一个数字,我们可以按照惯例使用“n”。
-
我们还将点击“添加标签”,并将其更改为“times”,这样我们的块看起来就像这样:
![包含 inflate n times 块的代码]()
- 标签让我们可以添加更多文字来描述块将要执行的操作。
-
注意,我们的“inflate”的“define”块将显示一个椭圆形的“n”,我们可以在下面的块中使用它,所以我们将将其拖入“repeat”块中:
define inflate (n) times repeat (n) change size by (10)- 现在,“inflate”将根据“n”的次数增加风筝的大小 10 次。
-
我们将以相同的方式对“deflate”进行操作。然后,在我们的“when flag clicked”脚本中,我们需要在每个自定义块中输入一个数字:
when green flag clicked set size to (50) % inflate () times wait (1) seconds deflate () times- 我们可以输入 10,这样风筝就会膨胀 10 次,或者我们可以将其更改为 20,或者任何其他值。
-
通过让我们的函数能够接受输入,我们可以使它们更加灵活。
Walking Bear
-
让我们看看一个在Walking Bear中可以接受多个输入的函数。
-
我们将添加我们的熊角色,并将熊的四条腿服装重命名为“4”,两条腿的服装重命名为“2”。
-
让我们创建一个新的名为“walk”的块,并给它一个名为“m”的输入。我们将添加一个标签“steps on”,然后另一个输入,“n”。最后,我们还将添加另一个标签,“feet”:
![带有 walk m steps on n feet 标签的块]()
- 我们可以将输入命名为任何我们想要的,但我们将使用“m”和“n”。
-
我们希望这个块执行的操作是让我们的熊在“n”只脚上走“m”步,所以我们将定义为:
define walk (m) steps on (n) feet switch costume to (n) repeat (m) move (1) steps- 我们首先将我们的服装切换到“n”,然后移动 1 步“m”次。
-
现在,在我们的主脚本中,我们可以告诉我们的熊走任意数量的步数,用两只或四只脚:
when green flag clicked walk (30) steps on (4) feet walk (30) steps on (2) feet- 注意,当点击标志时,更容易理解我们的熊会做什么。而且我们也避免了反复重复多个代码块。
-
通过我们自己的自定义代码块,我们可以定义可以重用并使我们的项目更有条理的复杂行为。
从 Scratch 开始构建
广播
-
Scratch 中的一个有用功能将允许我们的精灵之间相互通信。
-
我们将添加我们的猫和恐龙精灵,并将恐龙旋转使其面向猫。
-
现在,我们将让猫向恐龙问候:
when green flag clicked say [Hello, Dinosaur!] for (2) seconds -
我们还将让恐龙回话,但它应该等待两秒钟:
when green flag clicked wait (2) seconds say [Hello, Cat!] for (2) seconds -
但是现在,如果我们想让猫只问候一秒钟,我们必须记得改变恐龙等待的时间。随着我们交互的增多,或者舞台上精灵的增多,这将会变得更加复杂。
-
结果表明,我们的猫精灵可以使用广播,即发送消息或信号的能力。而我们的恐龙精灵在接收到该消息时会做出响应。
-
在积木的“事件”类别中,我们将使用“广播”积木。我们将使用下拉菜单选择“新消息”,并将其命名为“greet”:
broadcast (greet v) -
我们将更改猫的脚本以使用该积木在完成时发送消息:
when green flag clicked say [Hello, Dinosaur!] for (2) seconds broadcast (greet v) -
对于我们的恐龙,我们可以使用“当我收到”积木:
when I receive [greet v] say [Hello, Cat!] for (2) seconds- 现在,当我们的猫完成问候后,我们的恐龙总是会做出回应。
控制鸭子
-
让我们在舞台添加一只鸭子以控制控制鸭子。
-
我们可以使用箭头键来控制它,但让我们在舞台上添加两个箭头精灵,并将其中一个旋转使其向上,另一个向下:
![带有箭头和鸭子的舞台]()
-
对于指向上方的箭头,我们将告诉它在点击时广播一个“up”的消息:
when this sprite clicked broadcast (up v) -
对于指向下方的箭头,我们将广播另一个消息,“down”:
when this sprite clicked broadcast (down v) -
对于我们的鸭子,我们将告诉它根据接收到的消息上下移动:
when I receive [up v] change y by (10) when I receive [down v] change y by (-10) -
现在,每个箭头精灵在点击时都会广播一个消息,而鸭子在接收到消息时会移动。
访问鱼
-
舞台也可以广播消息,正如我们将在访问鱼中看到的。
-
我们将把背景更改为“水下 1”,并添加一个鱼精灵。
-
现在,我们可以点击右下角的背景,并在代码选项卡中,在事件部分添加“当舞台点击”积木:
when stage clicked broadcast (visit v)- 然后,我们将广播一个新的消息,我们将其命名为“visit”。
-
当我们的鱼接收到“visit”消息时,我们将告诉它去我们的鼠标指针:
when I receive [visit v] point towards (mouse-pointer v) glide (1) secs to (mouse-pointer v)- 现在,点击旗帜后,我们可以在舞台上的任何地方点击,我们的鱼就会移动到那里。
星星
-
让我们看看我们如何能够使用星星克隆或复制一个精灵。
-
使用我们朴素的白色背景和星星精灵,我们将添加以下积木:
when this sprite clicked create clone of (myself v) when I start as a clone glide (1) secs to (random position v)-
“创建克隆”和“当我作为克隆体开始”积木位于积木的“控制”部分。
-
现在,每次点击星星时,它都会复制自己。然后,副本将运行“当我作为克隆体开始”下的任何脚本,将自己移动到舞台上的随机位置。
-
晶石捕捉
-
让我们移除我们的星星,并使用Crystal Catch构建一个完全完整的游戏。
-
我们将从我们的猫开始,尝试构建一个捕捉“Crystal”精灵的游戏。我们最终希望水晶从天空落下,而猫在它们到达地面之前捕捉到它们。
-
首先,我们将添加用于猫左右移动的箭头键的方块:
when [left arrow v] key pressed change x by (-10) when [right arrow v] key pressed change x by (10) -
然后,我们希望我们的猫在点击绿色旗帜时从舞台中央开始:
when green flag clicked go to x: (0) y: (-125) say (Catch the crystals without letting them hit the ground!) for (4) seconds broadcast (begin v)-
我们还会为我们的用户提供一些说明。
-
在游戏开始之前,我们应该隐藏水晶,然后创建它的克隆,因为我们希望出现许多水晶。在此之前,我们需要我们的猫广播一条消息,“开始”。
-
-
现在,我们可以让我们的水晶在游戏开始时隐藏自己,并创建自己的克隆:
when green flag clicked hide when I receive [begin v] create clone of (myself v) -
然后,当水晶作为一个克隆体开始时,它应该显示自己:
when I start as a clone show go to x: (0) y: (160) forever change y by (-2)- 在我们的水晶出现后,它将从舞台顶部中央开始,并不断向下移动,形成一个永无止境的循环。
-
注意,我们一次构建游戏的一个组件,并且我们可以始终启动程序来确保到目前为止我们所做的一切都在工作。
-
接下来,我们的水晶需要检查它是否接触到了猫:
when I start as a clone show go to x: (0) y: (160) forever change y by (-2) if <touching (Cat v) ?> then -
我们将创建一个新的变量“catches”来表示得分,并在猫的脚本中重置它,因为我们也在那里进行其他重置:
when green flag clicked go to x: (0) y: (-125) set [catches v] to (0) say (Catch the crystals without letting them hit the ground!) for (4) seconds broadcast (begin v) -
现在,我们可以回到我们的水晶脚本,并添加当它接触到我们的猫时需要执行的方块:
when I start as a clone show go to x: (0) y: (160) forever change y by (-2) if <touching (Cat v) ?> then change [catches v] by (1) create clone of (myself v) delete this clone-
我们需要将“catches”变量增加 1,以跟踪我们的得分。
-
然后,我们需要创建一个新的水晶,并让原始水晶删除自己。
-
新的水晶将从屏幕顶部开始,因为它是一个新的克隆。
-
-
让我们改变水晶位置的 x 值到一个随机的值,这样我们的游戏就有了一些不可预测性。舞台的左右两侧将是很大的数字,所以我们将使用-200 和 200:
when I start as a clone show go to x: (pick random (-200) to (200)) y: (160)- 现在,我们的水晶每次都会出现在不同的位置。
-
如果我们没有捕捉到水晶,我们可能想要计算到目前为止的失误次数。
-
我们将创建一个新的变量“misses”,它将跟踪我们的猫没有捕捉到水晶的次数。每次游戏开始时,我们将将其重置为 0:
when green flag clicked go to x: (0) y: (-125) set [catches v] to (0) set [misses v] to (0) say (Catch the crystals without letting them hit the ground!) for (4) seconds broadcast (begin v) -
现在,我们可以让我们的水晶检查它是否接触到了地面(或边缘):
when I start as a clone show go to x: (0) y: (160) forever change y by (-2) if <touching (Cat v) ?> then change [catches v] by (1) create clone of (myself v) delete this clone end if <touching (edge v) ?> then change [misses v] by (1) create clone of (myself v) delete this clone end- 如果我们的水晶到达边缘,我们将增加“misses”的值,然后创建一个新的克隆体,并删除这个克隆体。
-
我们可能想要限制可以有的失误次数,因此我们可以有一个条件来检查这一点:
if <touching (edge v) ?> then change [misses v] by (1) if <(misses) = (3)> then broadcast (game over v) delete this clone end create clone of (myself v) delete this clone end- 在我们错过之后,我们将检查数字是否为三。如果是,我们将为我们的猫广播一条消息,然后删除这个克隆体。
-
最后,在我们的猫的脚本中,我们可以说出我们收到消息时的得分:
when I receive [game over v] say (join (Your score is) (catches)) for (5) seconds -
我们可以尝试这样做,并看到我们的程序按预期工作。我们可以通过让水晶不断克隆自己来一次创建多个水晶:
when I receive [begin v] forever create clone of (myself v) wait (15) seconds- 现在,每 15 秒就会创建一个新的水晶。
-
但是我们注意到,当游戏结束时,新的水晶仍然会继续创建。所以,我们需要让我们的猫在我们的程序中停止一切:
when I receive [game over v] say (join (Your score is) (catches)) for (5) seconds stop [all v] -
通过这些示例,希望你能看到所有这些组件、工具和概念如何被用来在 Scratch 中构建有趣和令人兴奋的项目。
-
我们鼓励你创建一些自己的东西,并与你的朋友、家人和我们分享。感谢你加入我们,一起学习 Scratch 编程入门!
SQL
第六讲
-
简介
-
什么是数据库?
-
SQL
- 问题
-
SQLite 入门
-
终端技巧
-
SELECT- 问题
-
LIMIT -
WHERE -
NULL -
LIKE- 问题
-
范围
- 问题
-
ORDER BY- 问题
-
聚合函数
- 问题
-
结束
简介
-
数据库(和 SQL)是用于交互、存储和管理信息的工具。尽管我们在这个课程中使用的工具是新的,但数据库是一个古老的概念。
-
看一下几千年前的一个图表。它有行和列,似乎包含寺庙工人的津贴。可以称这个图表为一个表,甚至是一个电子表格。

-
根据上图所示,我们可以得出以下结论:
-
一个表存储了一些信息集(这里,工人的津贴)。
-
表中的每一行存储该集合中的一个项目(这里,一个工人)。
-
每一列都有该项目的某个属性(这里,特定月份的津贴)。
-
-
让我们考虑一个现代的背景。假设你是一名图书管理员,负责组织关于这个图中书籍标题和作者的信息。
!["未组织的书名和作者"]()
-
组织信息的一种方式是将每本书的书名后面跟其作者,如下所示。
!["书名和作者表格"]()
-
注意,现在每本书都是这个表中的一行。
-
每一行都有两列——每列都是书籍的不同属性(书名和作者)。
-
-
在今天的信息时代,我们可以使用像 Google Sheets 这样的软件来存储表格,而不是纸张📝或石板🪨。然而,在这个课程中,我们将讨论数据库而不是电子表格。
-
超越电子表格转向数据库的三个原因是
-
规模:数据库不仅可以存储数以万计的项目,甚至可以存储数以百万、数十亿计的项目。
-
更新容量:数据库能够在一秒内处理多个数据更新。
-
速度:数据库允许更快的信息查找。这是因为数据库为我们提供了访问不同算法以检索信息。相比之下,电子表格只能使用 Ctrl+F 或 Cmd+F 逐个查看搜索结果。
-
什么是数据库?
-
数据库是一种组织数据的方式,你可以对其执行四个操作
-
create
-
read
-
update
-
delete
-
-
数据库管理系统(DBMS)是使用图形界面或文本语言与数据库交互的方式。
-
数据库管理系统的例子:MySQL、Oracle、PostgreSQL、SQLite、Microsoft Access、MongoDB 等。
-
选择数据库管理系统会基于以下因素
-
成本:专有软件与免费软件,
-
支持量:像 MySQL、PostgreSQL 和 SQLite 这样的免费和开源软件需要你自己设置数据库,这是它们的缺点。
-
重量:像 MySQL 或 PostgreSQL 这样的功能更全面的系统比 SQLite 这样的系统更重,运行时需要更多的计算资源。
-
-
在本课程中,我们将从 SQLite 开始,然后转向 MySQL 和 PostgreSQL。
SQL
-
SQL 代表结构化查询语言。它是一种用于与数据库交互的语言,通过它可以创建、读取、更新和删除数据库中的数据。关于 SQL 的一些重要注意事项
-
它是有结构的,正如我们将在本课程中看到的,
-
它有一些可以用来与数据库交互的关键字,并且
-
它是一种查询语言——它可以用来对数据库中的数据进行提问。
-
-
在本课中,我们将学习如何编写一些简单的 SQL 查询。
问题
SQL 有子集吗?
- SQL 是美国国家标准协会(ANSI)和国际标准化组织(ISO)的标准。大多数数据库管理系统都支持 SQL 语言的一些子集。所以,例如,对于 SQLite,我们使用的是 SQLite 支持的 SQL 子集。如果我们想将代码移植到像 MySQL 这样的不同系统,我们可能需要更改一些语法。
SQLite 入门
-
值得注意的是,SQLite 不仅是我们在这个课程中使用的,它还用于许多其他应用程序,包括手机、桌面应用程序和网站。
-
现在,考虑一个包含长期入选 国际布克奖 的书籍的数据库。每年有 13 本书入选,我们的数据库包含了 5 年的此类长期入选名单。
-
在我们开始与这个数据库交互之前:
-
登录到 CS50 的 Visual Studio Code。这是我们编写代码和编辑文件的地方。
-
SQLite 环境已经在你的 Codespace 中设置好了!在终端中打开它。
-
终端技巧
这里有一些在终端上编写 SQL 代码的有用技巧。
-
要清除终端屏幕,请按 Ctrl + L。
-
要获取终端中之前执行的指令,请按上箭头键。
-
如果你的 SQL 查询太长,在终端中换行,你可以按回车键并继续在下一行编写查询。
-
要退出数据库或 SQLite 环境,请使用
.quit。
SELECT
-
我们数据库中实际上有什么数据?为了回答这个问题,我们将使用我们的第一个 SQL 关键字
SELECT,它允许我们从数据库表中选择一些(或全部)行。 -
在 SQLite 环境中,运行
SELECT * FROM "longlist";这将选择名为
longlist的表中的所有行。 -
我们得到的结果包含表中所有行的所有列,这有很多数据。我们可以通过选择表中的特定列来简化它,比如标题。让我们试试
SELECT "title" FROM "longlist"; -
现在,我们看到这个表中标题的列表。但如果我们想在搜索结果中看到标题和作者怎么办?为此,我们运行
SELECT "title", "author" FROM longlist;
问题
在表和列名周围使用双引号是必要的吗?
- 在表和列名周围使用双引号是一个好习惯,这些被称为 SQL 标识符。SQL 还包含字符串,我们用单引号来包围字符串,以区分它们和标识符。
这个数据库中的数据是从哪里来的?
-
这个数据库包含来自各种来源的数据。
-
长书单(2018-2023 年)来自 Booker Prize 网站。
-
这些书籍的评分和其他信息来自 Goodreads。
我们如何知道数据库中有哪些表和列?
- 数据库模式包含数据库的结构,包括表和列名。在本课程的后期,我们将学习如何获取数据库模式并理解它。
SQLite 3 是区分大小写的吗?为什么查询中的一些部分是大写,而另一些是小写?
-
SQLite 不区分大小写。然而,我们确实遵循一些样式约定。观察这个查询:
SELECT * FROM "longlist";SQL 关键字用大写字母书写。这在提高较长的查询的可读性方面特别有用。表和列名用小写字母。
LIMIT
-
如果一个数据库有数百万行,选择所有行可能没有意义。相反,我们可能只想浏览它包含的数据。我们使用 SQL 关键字
LIMIT来指定查询输出中的行数。 -
SELECT "title" FROM "longlist" LIMIT 10;这个查询给我们数据库中的前 10 个标题。这些标题在查询输出中的顺序与数据库中的顺序相同。
WHERE
-
关键字
WHERE用于根据条件选择行;它将输出满足指定条件的行。 -
SELECT "title", "author" FROM "longlist" WHERE "year" = 2023;这给我们提供了 2023 年长名单书籍的标题和作者。请注意,
2023没有引号,因为它是一个整数,而不是字符串或标识符。 -
可以用于在 SQL 中指定条件的运算符有
=(“等于”)、!=(“不等于”)和<>(也是“不等于”)。 -
要选择非精装书的书籍,我们可以运行以下查询
SELECT "title", "format" FROM "longlist" WHERE "format" != 'hardcover';- 注意,
hardcover用单引号,因为它是一个 SQL 字符串,而不是标识符。
- 注意,
-
!=可以用运算符<>替换以获得相同的结果。修改后的查询将是SELECT "title", "format" FROM "longlist" WHERE "format" <> 'hardcover'; -
获取相同结果的另一种方法是使用 SQL 关键字
NOT。修改后的查询将是SELECT "title", "format" FROM "longlist" WHERE NOT "format" = 'hardcover'; -
要组合条件,我们可以使用 SQL 关键字
AND和OR。我们还可以使用括号来指示如何在复合条件语句中组合条件。 -
要选择 2022 年或 2023 年入选的书籍的标题和作者
SELECT "title", "author" FROM "longlist" WHERE "year" = 2022 OR "year" = 2023; -
要选择 2022 年或 2023 年入选的非精装书籍
SELECT "title", "format" FROM "longlist" WHERE ("year" = 2022 OR "year" = 2023) AND "format" != 'hardcover';这里,括号表示应该先评估
OR子句,然后再评估AND子句。
NULL
-
表可能存在缺失数据。
NULL是一种用于表示某些数据没有值或不存在于表中的类型。 -
例如,我们数据库中的书籍都有一个翻译者和一个作者。然而,只有一些书籍被翻译成英文。对于其他书籍,翻译者值将是
NULL。 -
与
NULL一起使用的条件是IS NULL和IS NOT NULL。 -
要选择没有翻译者的书籍,我们可以运行
SELECT "title", "translator" FROM "longlist" WHERE "translator" IS NULL; -
让我们尝试反过来:选择那些有翻译者的书籍。
SELECT "title", "translator" FROM "longlist" WHERE "translator" IS NOT NULL;
LIKE
-
此关键字用于选择与指定字符串大致匹配的数据。例如,
LIKE可以用来选择标题中包含特定单词或短语的书籍。 -
LIKE与运算符%(匹配给定字符串周围的任意字符)和_(匹配单个字符)结合使用。 -
要选择标题中包含“love”一词的书籍,我们可以运行
SELECT "title" FROM "longlist" WHERE "title" LIKE '%love%';%匹配 0 或多个字符,因此此查询将匹配包含“love”前后有 0 或多个字符的书籍标题——即包含“love”的标题。 -
要选择标题以“The”开头的书籍,我们可以运行
SELECT "title" FROM "longlist" WHERE "title" LIKE 'The%'; -
上述查询也可能返回标题以“Their”或“They”开头的书籍。要仅选择标题以“The”开头的书籍,我们可以添加一个空格。
SELECT "title" FROM "longlist" WHERE "title" LIKE 'The %'; -
假设表中有一本书的名称是“Pyre”或“Pire”,我们可以通过运行以下命令来选择它
SELECT "title" FROM "longlist" WHERE "title" LIKE 'P_re';如果我们的数据库中存在像“Pore”或“Pure”这样的书籍标题,此查询也可能返回,因为
_匹配任何单个字符。
问题
我们可以在查询中使用多个
%或_符号吗?
-
是的,我们可以!示例 1:如果我们想选择标题以“The”开头并在中间某处有“love”的书籍,我们可以运行
SELECT "title" FROM "longlist" WHERE "title" LIKE 'The%love%'; -
注意:我们当前数据库中没有书籍与这个模式匹配,所以此查询返回空结果。
-
示例 2:如果我们知道表中有一本书的标题以“T”开头并且有四个字母,我们可以尝试通过运行以下命令来找到它
SELECT "title" FROM "longlist" WHERE "title" LIKE 'T____';
在 SQL 中字符串比较是否大小写敏感?
- 在 SQLite 中,字符串与
LIKE的比较默认是大小写不敏感的,而与=的比较则是大小写敏感的。(注意,在其他 DBMS 中,数据库的配置可能会改变这一点!)
范围
-
我们也可以在条件中使用运算符
<、>、<=和>=来匹配值范围。例如,要选择 2019 年至 2022 年(包括)之间入选的所有书籍,我们可以运行SELECT "title", "author" FROM "longlist" WHERE "year" >= 2019 AND "year" <= 2022; -
另一种获取相同结果的方法是使用关键字
BETWEEN和AND来指定包含范围。我们可以运行SELECT "title", "author" FROM "longlist" WHERE "year" BETWEEN 2019 AND 2022; -
要选择评分在 4.0 或更高的书籍,我们可以运行
SELECT "title", "rating" FROM "longlist" WHERE "rating" > 4.0; -
要进一步通过投票数限制所选书籍,并且只包含至少有 10,000 票的书籍,我们可以运行
SELECT "title", "rating", "votes" FROM "longlist" WHERE "rating" > 4.0 AND "votes" > 10000; -
要选择页数少于 300 页的书籍,我们可以运行
SELECT "title", "pages" FROM "longlist" WHERE "pages" < 300;
问题
对于范围运算符如
<和>,数据库中的值必须是整数吗?
- 不,值可以是整数或浮点数(即“十进制”或“实数”)。在创建数据库时,有方法可以为列设置这些数据类型。
ORDER BY
-
ORDER BY关键字允许我们按某种指定的顺序组织返回的行。 -
以下查询按评分从低到高选择我们数据库中的前 10 本书。
SELECT "title", "rating" FROM "longlist" ORDER BY "rating" LIMIT 10; -
注意我们得到的是底部的 10 本书,因为
ORDER BY默认选择升序。 -
相反,要选择前 10 本书
SELECT "title", "rating" FROM "longlist" ORDER BY "rating" DESC LIMIT 10;注意使用 SQL 关键字
DESC来指定降序。ASC可以用来显式指定升序。 -
要选择评分最高的前 10 本书,并且将投票数作为平局时的决定因素,我们可以运行
SELECT "title", "rating", "votes" FROM "longlist" ORDER BY "rating" DESC, "votes" DESC LIMIT 10;注意到在
ORDER BY子句中的每一列,我们指定了升序或降序。
问题
按标题字母顺序排序书籍,我们可以使用
ORDER BY吗?
-
是的,我们可以。查询会是
SELECT "title" FROM "longlist" ORDER BY "title";
聚合函数
-
COUNT、AVG、MIN、MAX和SUM被称为聚合函数,允许我们对多行数据执行相应的操作。根据它们的本质,以下每个聚合函数都将只返回单个输出——聚合值。 -
查找数据库中所有书籍的平均评分
SELECT AVG("rating") FROM "longlist"; -
将平均评分四舍五入到两位小数
SELECT ROUND(AVG("rating"), 2) FROM "longlist"; -
重命名显示结果的列
SELECT ROUND(AVG("rating"), 2) AS "average rating" FROM "longlist";注意使用 SQL 关键字
AS来重命名列。 -
要选择数据库中的最高评分
SELECT MAX("rating") FROM "longlist"; -
要选择数据库中的最低评分
SELECT MIN("rating") FROM "longlist"; -
要统计数据库中总票数
SELECT SUM("votes") FROM "longlist"; -
统计我们数据库中书籍的数量
SELECT COUNT(*) FROM "longlist";- 记住我们使用了
*来选择数据库中的每一行和每一列。在这种情况下,我们正在尝试统计数据库中的每一行,因此我们使用*。
- 记住我们使用了
-
统计翻译者的数量
SELECT COUNT("translator") FROM "longlist";- 我们观察到翻译者的数量少于数据库中的行数。这是因为
COUNT函数不会计算NULL值。
- 我们观察到翻译者的数量少于数据库中的行数。这是因为
-
要统计数据库中出版者的数量
SELECT COUNT("publisher") FROM "longlist"; -
与翻译者一样,此查询将统计非
NULL的出版者值的数量。然而,这可能会包括重复值。另一个 SQL 关键字DISTINCT可以用来确保只计算不同的值。SELECT COUNT(DISTINCT "publisher") FROM "longlist";
问题
使用标题列的
MAX会给你最长的书名吗?
- 不,使用标题列的
MAX会给你“最大”的(在这种情况下,最后的)标题按字母顺序排列。同样,MIN会给出第一个标题按字母顺序排列。
结束
-
这就带我们来到了关于 SQL 查询的 Lecture 0 的结论!要退出 SQLite 提示符,你可以输入 SQLite 关键字
.quit,这将带你回到常规终端。 -
到下次见面为止!
第一讲
-
简介
-
实体关系图
- 问题
-
键
-
主键
-
外键
-
问题
-
-
子查询
-
IN
- 问题
-
JOIN
- 问题
-
集合
- 问题
-
组
- 问题
-
结束
简介
-
数据库可以有多个表。在上一节课中,我们看到了一个列出国际布克奖提名书籍的数据库。现在我们将看到这个数据库内部有许多不同的表——包括书籍、作者、出版社等。
-
首先,在您的Codespace终端中使用 SQLite 打开数据库。
-
我们可以使用以下 SQLite 命令查看我们数据库中的所有表:
.tables此命令返回
longlist.db中的表名——总共 7 个。 -
这些表之间有一些关系,因此我们称数据库为关系数据库。查看
longlist.db中的表列表,并尝试想象它们之间的关系。以下是一些例子:-
作者写书。
-
出版社出版书籍。
-
书籍由翻译者翻译。
-
-
考虑我们的第一个例子。以下是
authors和books表的快照,包括作者姓名和书名列!![来自不同表的“作者姓名和书名列”]()
-
仅从这两列来看,我们如何判断谁写了哪本书?即使我们假设每本书都紧挨着其作者,仅查看
authors表也不会提供关于该作者所写书籍的信息。 -
组织书籍和作者的一些可能方式是...
-
荣誉制度:
authors表中的第一行将始终对应于books表中的第一行。这个系统的问题是一个人可能会犯错误(添加了一本书但忘记了添加相应的作者,或者反之)。此外,一个作者可能写过多本书,或者一本书可能由多个作者合著。 -
回到单表方法:如果一位作者写多本书或者一本书由多个作者合著,这种方法可能会导致冗余(数据重复)。以下是一个包含一些冗余数据的单表方法的快照。
![单表方法:有多个书的作者]()
-
-
考虑了这些想法后,似乎有两个不同的表是最有效的方法。让我们看看关系数据库中表之间可以以哪些不同的方式相互关联。
-
考虑这种情况,每位作者只写一本书,每本书也只由一位作者撰写。这被称为一对一关系。
![一对一关系]()
-
另一方面,如果一位作者可以写多本书,那么关系是一对多关系。
![一对多关系]()
-
这里,我们看到另一种情况,不仅一位作者可以写多本书,而且多本书也可以由多位作者合著。这是一个多对多关系。
![多对多关系]()
实体关系图
-
我们刚刚描述了数据库表中一对一、一对多和多对多关系。可以使用实体关系(ER)图来可视化这些关系。
-
这里是
longlist.db中表的 ER 图。erDiagram "Author" }|--|{ "Book" : "wrote" "Publisher" ||--|{ "Book" : "published" "Translator" }o--|{ "Book" : "translated" "Book" ||--o{ "Rating" : "has" -
每个表都是我们数据库中的一个实体。表与表之间,或实体之间的关系,由标记实体之间线条的动词表示。
-
图中的每条线都使用鸟爪符号表示。
-
第一行带有圆圈的线条看起来像线上标记的 0。这一行表示没有关系。
-
第二行带有垂直线的线条看起来像线上标记的 1。具有此箭头的实体必须至少有一个与另一张表中的行相关联的行。
-
第三行看起来像一只鸟爪,有很多分支。这一行表示该实体与另一张表中的多行相关。
![ER 图中的线条]()
-
-
例如:
-
我们从左到右阅读这个符号。一位作者写一本书(或者,每位作者都可以有一本书与他们相关联)。
![1-关系符号:一位作者写一本书]()
-
现在,不仅一位作者可以写一本书,一本书也可以由一位作者编写。
![1-关系符号:一位作者写一本书,一本书由一位作者编写]()
-
通过这个添加,一位作者至少写一本书,一本书至少由一位作者编写。换句话说,一位作者可以与一本或多本书相关联,一本书可以由一位或多位作者编写。
![添加多条线:一位作者至少写一本书,一本书至少由一位作者编写]()
-
-
让我们重新审视我们数据库的 ER 图。
erDiagram "Author" }|--|{ "Book" : "wrote" "Publisher" ||--|{ "Book" : "published" "Translator" }o--|{ "Book" : "translated" "Book" ||--o{ "Rating" : "has" -
观察连接书籍和翻译者实体的线条,我们可以说书籍不需要有翻译者。它们可以有零到多个翻译者。然而,数据库中的翻译者至少翻译一本书,可能还翻译多本书。
问题
如果我们有一个数据库,我们如何知道存储在其中的实体之间的关系?
- 实体之间的确切关系完全取决于数据库的设计者。例如,是否每位作者只能写一本书或多本书,这是在设计数据库时需要做出的决定。实体关系图(ER diagram)可以被视为一种工具,用于将这些决定传达给想要了解数据库及其实体之间关系的人。
一旦我们知道某些实体之间存在关系,我们如何在数据库中实现这种关系?
- 我们很快就会看到如何使用 SQL 中的键来关联表。
键
主键
-
在书籍的情况下,每本书都有一个唯一的标识符,称为 ISBN。换句话说,如果你通过 ISBN 搜索一本书,只会找到一本书。在数据库术语中,ISBN 是一个主键——它是表中每个项目的唯一标识符。
![包含 ISBN 和书名的表格]()
-
受到 ISBN 这一想法的启发,我们可以想象为我们的出版社、作者和翻译分配唯一的 ID!这些 ID 将是它们所属表的唯一主键。
外键
-
键也有助于在 SQL 中关联表。
-
外键是从另一个表中取出的主键。通过引用另一个表的主键,它通过在它们之间形成链接来帮助关联表。
![使用外键关联书籍和评分表]()
注意到
books表的主键现在成为了ratings表中的一列。这有助于形成两个表之间的一对多关系——一本书(在books表中找到)可以有多个评分(在ratings表中找到)。 -
如我们所见,ISBN 是一个长的标识符。如果每个字符占用一个字节的内存,存储一个单独的 ISBN(包括连字符)将需要 17 个字节的内存,这相当多!
-
幸运的是,我们不一定非得使用 ISBN 作为主键。我们可以简单地使用数字 1、2、3……等等来构建自己的主键,只要每本书都有一个唯一的数字来标识它。
-
之前,我们看到了如何实现
books和ratings实体之间的一对多关系。这里有一个多对多关系的例子。![使用外键和另一个表关联作者和书籍表]()
现在有一个名为authored的表,它将books表的主键(book_id)映射到authors表的主键(author_id)。
问题
作者和书的 ID 可以相同吗?例如,如果
author_id是 1,而authored表中的book_id也是 1,会发生混淆吗?
- 像
authored这样的表被称为“联合”或“连接”表。在这样的表中,我们通常知道哪个主键被哪个列引用。在这种情况下,由于我们知道第一列只包含authors的主键,第二列也只包含books的主键,所以即使值匹配也是可以的!
如果我们有很多这样的联合表,那不会占用太多空间吗?
- 是的,这里有一个权衡。像这样的表占用更多空间,但它们也使我们能够拥有许多多对多关系,没有冗余,就像我们之前看到的。
在更改书籍或作者的 ID 时,ID 是否也会在其他表中更新?
- 更新后的 ID 仍然需要是唯一的。鉴于这一点,ID 通常被抽象化,我们很少更改它们。
子查询
-
子查询是另一个查询中的查询。这些也被称为嵌套查询。
-
考虑这个用于一对多关系的示例。在
books表中,我们有一个 ID 来表示出版社,这是从publishers表中取的外键。要找出 Fitzcarraldo Editions 出版的书籍,我们需要两个查询——一个是从publishers表中找出 Fitzcarraldo Editions 的publisher_id,第二个是使用这个publisher_id来找出 Fitzcarraldo Editions 出版的所有书籍。这两个查询可以通过子查询的概念合并成一个。SELECT "title" FROM "books" WHERE "publisher_id" = ( SELECT "id" FROM "publishers" WHERE "publisher" = 'Fitzcarraldo Editions' );注意:
-
子查询在括号中。括号中最里面的查询将首先运行,然后是外部查询。
-
内部查询被缩进。这是按照子查询的风格约定进行的,以提高可读性。
-
-
要找出《记忆的纪念》的所有评分
SELECT "rating" FROM "ratings" WHERE "book_id" = ( SELECT "id" FROM "books" WHERE "title" = 'In Memory of Memory' ); -
要选择这本书的平均评分
SELECT AVG("rating") FROM "ratings" WHERE "book_id" = ( SELECT "id" FROM "books" WHERE "title" = 'In Memory of Memory' ); -
下一个示例是用于多对多关系。要找出写了《航班》的作者(们),需要查询三个表:
books、authors和authored。SELECT "name" FROM "authors" WHERE "id" = ( SELECT "author_id" FROM "authored" WHERE "book_id" = ( SELECT "id" FROM "books" WHERE "title" = 'Flights' ) );首先运行的查询是最深层的查询——找到《航班》的 ID。然后,找到写了《航班》的作者(们)的 ID。最后,使用这个 ID 检索作者名称。
IN
-
这个关键字用于检查所需值是否在给定的列表或值集中。
-
作者和书籍之间的关系是多对多的。这意味着一个特定的作者可能写过多本书。要找出数据库中 Fernanda Melchor 所写的所有书籍的名称,我们可以使用以下
IN关键字。SELECT "title" FROM "books" WHERE "id" IN ( SELECT "book_id" FROM "authored" WHERE "author_id" = ( SELECT "id" FROM "authors" WHERE "name" = 'Fernanda Melchor' ) );注意,最内层的查询使用
=而不是IN运算符。这是因为我们期望找到名为 Fernanda Melchor 的唯一作者。
问题
如果内部查询的值未找到怎么办?
- 在这种情况下,内部查询将返回空结果,这会促使外部查询也返回空结果。因此,外部查询依赖于内部查询的结果。
需要使用四个空格来缩进子查询吗?
- 不。用于缩进子查询的空格数量可以变化,查询中每行的长度也可以变化。但将查询拆分并缩进子查询的核心思想是使它们易于阅读。
我们如何实现表之间的多对一关系?
- 考虑这种情况,一本书由多个作者共同撰写。我们会有一个
authored表,对于相同的书 ID 有多个条目。这些条目中的每一个都会有不同的作者 ID。值得注意的是,外键值可以在表中重复,但主键值总是唯一的。
JOIN
-
此关键字允许我们将两个或多个表组合在一起。
-
要了解
JOIN的工作原理,请考虑海狮及其迁徙模式的数据库。以下是数据库的快照。![海狮数据库中的表:海狮、迁徙]()
-
要找出海狮 Spot 走了多远,或者回答有关每只海狮的类似问题,我们可以使用嵌套查询。或者,我们可以将
sea lions和migrations表连接起来,使得每只海狮也有其对应的信息,作为同一行的扩展。 -
我们可以在海狮 ID(两张表之间的共同因素)上连接表,以确保正确的行相互对齐。
-
在测试之前,请确保使用
.quitSQLite 命令退出longlist.db。然后,打开sea_lions.db。 -
要连接表
SELECT * FROM "sea_lions" JOIN "migrations" ON "migrations"."id" = "sea_lions"."id";注意:
-
ON关键字用于指定在连接的表中哪些值匹配。如果没有匹配的值,则无法连接表。 -
如果一个表中有任何 ID 在另一个表中不存在,则该行将不会出现在连接表中。这种连接称为
INNER JOIN。
-
-
其他允许我们保留某些不匹配 ID 的连接表的方法是
LEFT JOIN、RIGHT JOIN和FULL JOIN。这些都是OUTER JOIN的一种。 -
LEFT JOIN优先考虑左表(或第一张表)中的数据。SELECT * FROM "sea_lions" LEFT JOIN "migrations" ON "migrations"."id" = "sea_lions"."id";此查询将保留
sea_lions表中的所有海狮数据——左表。连接表中的某些行可能部分为空。如果右表没有特定 ID 的数据,就会发生这种情况。 -
类似地,
RIGHT JOIN保留右表(或第二张表)的所有行。FULL JOIN允许我们看到所有表的全部内容。 -
如我们所见,
OUTER JOIN可能会导致连接表中出现空或NULL值。 -
海狮数据库中的两张表都有
id列。由于我们连接表时使用的值在两张表中都有相同的列名,因此实际上在连接时我们可以省略查询的ON部分。SELECT * FROM "sea_lions" NATURAL JOIN "migrations";注意,在这种情况下结果中没有重复的
id列。此外,这种连接与INNER JOIN的工作方式类似。
问题
在海狮数据库中,ID 是如何创建的?它们来自
sea_lions表还是migrations表?
- 每只海狮的 ID 很可能是研究人员追踪这些海狮迁徙模式时分配的。也就是说,ID 不是在任一表中生成的,而是在数据本身的源头分配的。
如果我们试图连接三个表,我们如何知道哪一个是左表或右表?
- 对于每个
JOIN语句,关键字之前的第一张表是左表。与JOIN关键字相关的是右表。
当我们连接表时,结果连接表会被保存吗?我们可以在不再次连接的情况下稍后引用它吗?
- 在我们使用
JOIN的方式中,结果是临时表或结果集。它可以在查询期间使用。
有许多不同的
JOIN类型。我们应该使用默认的哪一个?
- 最简单的一种——就是
JOIN——实际上是一个INNER JOIN,这也是 SQL 的默认设置。
集合
-
在深入研究集合之前,我们需要退出海狮数据库,切换到
longlist.db。 -
在执行查询时,我们看到的查询结果被称为结果集。这是一种 SQL 中的集合。
-
让我们再举一个例子。在我们的书籍数据库中,我们有作者和翻译者。一个人可以是作者或翻译者。如果这两个集合有交集,那么一个人也可能是书籍的作者和翻译者。我们可以使用
INTERSECT运算符来找到这个集合。![作者和翻译者的交集集合]()
SELECT "name" FROM "translators" INTERSECT SELECT "name" FROM "authors"; -
如果一个人是作者或翻译者,或者两者都是,那么他们属于两个集合的并集。换句话说,这个集合是通过合并作者和翻译者集合形成的。
![作者和翻译者的并集集合]()
SELECT "name" FROM "translators" UNION SELECT "name" FROM "authors";注意,每个作者和每个翻译者都包含在这个结果集中,但只出现一次!
-
对上一个查询进行轻微调整,我们可以根据一个人是作者还是翻译者,在结果集中得到他们的职业。
SELECT 'author' AS "profession", "name" FROM "authors" UNION SELECT 'translator' AS "profession", "name" FROM "translators"; -
以下集合包括了所有既是作者又是仅是作者的人。
EXCEPT关键字可以用来找到这样的集合。换句话说,从作者集合中减去翻译者集合,形成这个集合。![只包括作者的集合]()
SELECT "name" FROM "authors" EXCEPT SELECT "name" FROM "translators";我们可以验证,交集集中的任何作者-翻译者都没有出现在这个结果集中。
-
同样,我们可以使用
EXCEPT来找到只做翻译者的集合。 -
我们如何找到这个集合,其中的人要么是作者或翻译者,但不能两者都是?
![作者和翻译者要么是作者要么是翻译者但不是两者的集合]()
-
这些运算符可以用来回答许多不同的问题。例如,我们可以找到 Sophie Hughes 和 Margaret Jull Costa 共同翻译的书籍。
SELECT "book_id" FROM "translated" WHERE "translator_id" = ( SELECT "id" from "translators" WHERE "name" = 'Sophie Hughes' ) INTERSECT SELECT "book_id" FROM "translated" WHERE "translator_id" = ( SELECT "id" from "translators" WHERE "name" = 'Margaret Jull Costa' );这里嵌套的每个查询都找到了一个翻译者的书籍 ID。使用
INTERSECT关键字来交集结果集,并给出他们合作过的书籍。
问题
我们可以使用
INTERSECT、UNION等操作对 3-4 个集合进行操作吗?
- 是的,绝对可以。要交集 3 个集合,我们必须使用
INTERSECT操作符两次。一个重要的注意事项——我们必须确保要组合的集合中有相同数量和类型的列。
组
-
考虑到
ratings表。对于每本书,我们想要找到这本书的平均评分。为此,我们首先需要按书籍将评分分组,然后对每个书籍(每个组)的评分进行平均。SELECT "book_id", AVG("rating") AS "average rating" FROM "ratings" GROUP BY "book_id";在这个查询中,使用了
GROUP BY关键字为每本书创建组,然后将组的评分合并成一个平均评分! -
现在,我们只想看到那些评分很高的书籍,平均评分超过 4 分。
SELECT "book_id", ROUND(AVG("rating"), 2) AS "average rating" FROM "ratings" GROUP BY "book_id" HAVING "average rating" > 4.0;注意,这里使用
HAVING关键字来指定组条件,而不是WHERE(只能用于指定单个行的条件)。
问题
是否可以看到每本书的评分数量?
-
是的,这需要使用
COUNT关键字进行轻微的修改。SELECT "book_id", COUNT("rating") FROM "ratings" GROUP BY "book_id";
是否也可以对这里获得的数据进行排序?
-
是的,可以。比如说,我们想要找到每个评分很高的书籍的平均评分,并按降序排列。
SELECT "book_id", ROUND(AVG("rating"), 2) AS "average rating" FROM "ratings" GROUP BY "book_id" HAVING "average rating" > 4.0 ORDER BY "average rating" DESC;
结束
- 这把我们带到了关于关联的第一讲 的结论。
第二讲
-
介绍
-
创建数据库模式
-
规范化
-
关联
-
问题
-
-
创建表
- 问题
-
数据类型和存储类
-
类型亲和力
-
向我们的表中添加类型
- 问题
-
表约束
- 问题
-
列约束
-
修改表
- 问题
-
鳍
介绍
-
在这次讲座中,我们将学习如何设计我们自己的数据库模式。
-
到目前为止,我们主要使用的是国际布克奖长名单上的书籍数据库。现在,我们将深入内部,看看可以使用哪些命令来创建这样的数据库。
-
首先,让我们在我们的终端上打开第 0 周的数据库
longlist.db。作为提醒,这个数据库只包含一个名为longlist的表。要查看表的快照,我们可以运行SELECT "author", "title" FROM "longlist" LIMIT 5;这为我们提供了来自表
longlist的前 5 行的作者和标题。 -
这里是一个 SQLite 命令(不是一个 SQL 关键字),它可以进一步说明这个数据库是如何创建的。
.schema运行此命令后,我们看到用于创建表
longlist的 SQL 语句。这显示了longlist内部的列以及每个列可以存储的数据类型。 -
接下来,让我们在我们的终端上打开第 1 周的相同数据库。这个版本的
longlist.db包含了相互关联的不同表。 -
再次运行
.schema后,我们看到许多命令——每个数据库中的表都有一个。有一种方法可以查看指定表的模式:.schema books现在我们看到用于创建
books表的语句。我们还能看到每个列的列名和数据类型。例如,"title"列存储文本,而"publisher_id"列是整数。
创建数据库模式
-
现在我们已经看到了现有数据库的模式,让我们创建自己的!我们的任务是使用数据库模式来表示波士顿市的地铁系统。这包括地铁站点、不同的列车线路以及乘坐列车的人们。
![波士顿地铁图]()
-
为了进一步分解这个问题,我们需要决定……
-
我们将在波士顿地铁数据库中有什么类型的表,
-
每个表将有哪些列,以及
-
我们应该在每一列中放入哪些类型的数据。
-
规范化
-
观察这个创建表示波士顿地铁数据的表的初步尝试。这个表包含地铁乘客姓名、乘客当前所在的车站以及在该车站执行的操作(如进入和离开)。它还记录了乘客在地铁卡上的付费金额和余额。这个表还包含每个乘客“交易”的 ID,作为主键。
![波士顿地铁表的第一尝试]()
-
这个表中存在哪些冗余?
-
我们可以选择将乘客姓名分离到一个单独的表中,以避免多次重复名称。我们需要为每个乘客提供一个 ID,以便将新表与这个表关联起来。
-
我们可以选择将地铁车站移动到不同的表,并为每个地铁车站分配一个 ID,用作这里的外键。
-
-
以这种方式分离我们的数据的过程称为规范化。在规范化过程中,我们将每个实体放入自己的表中——就像我们对乘客和地铁车站所做的那样。关于特定实体的任何信息,例如乘客的地址,都放入实体的表中。
关联
-
我们现在需要决定我们的实体(乘客和车站)之间的关系。一个乘客可能会访问多个车站,一个地铁站可能有多于一个乘客。鉴于这一点,这将是一个多对多关系。
-
我们也可以使用 ER 图来表示这种关系。
![乘客和车站之间的多对多关系]()
在这里,我们看到每个乘客必须访问至少一个车站才能被认为是乘客。然而,一个车站可能没有乘客访问它,因为这可能是暂时出了故障。然而,一个车站可能有多个乘客访问它,这在 ER 图中用鸟脚符号表示。
问题
乘客和车站之间的关系必须像这里描述的那样精确吗?例如,为什么车站可以有 0 个乘客?
- 设计数据库的人需要决定实体之间的关系。可以添加一个约束,说明一个车站必须至少有一个乘客才能被认为是车站。
CREATE TABLE
-
现在我们已经有了两张表的架构,让我们继续创建这些表。
-
让我们打开一个新的数据库,命名为
mbta.db—— MBTA 代表马萨诸塞湾交通管理局,它运营波士顿地铁。 -
如果我们运行
.schema,我们将看不到任何内容,因为在这个数据库中还没有创建任何表。 -
在这个数据库中,我们运行以下命令来创建第一个乘客表:
CREATE TABLE riders ( "id", "name" );运行此命令后,终端上不会显示任何结果。但如果我们再次运行
.schema,现在我们将看到我们定义的riders表的架构! -
同样,让我们也创建一个车站的表。
CREATE TABLE stations ( "id", "name", "line" );在这里,我们添加了一个名为
"line"的列来存储车站所属的列车线路。 -
.schema现在显示了我们riders和stations的模式。 -
接下来,我们将创建一个表格来关联这两个实体。这些表格通常被称为连接表、关联实体或连接表!
CREATE TABLE visits ( "rider_id", "station_id" );表的每一行都告诉我们特定骑手访问过的站点。
问题
在
CREATE TABLE括号内缩进行是必要的吗?
- 不,不是严格意义上的。然而,我们总是缩进列名以遵守样式约定!
数据类型和存储类
-
SQLite 有五种存储类:
-
空值(Null):无,或空值
-
整数:没有小数点的数字
-
实数:小数或浮点数
-
文本:字符或字符串
-
二进制大对象(Blob):用于存储二进制对象(适用于图像、音频等)
-
-
存储类可以容纳多种数据类型。
-
例如,这些是隶属于整数存储类的数据类型。
![整数存储类和数据类型]()
SQLite 负责将输入值存储在正确的数据类型下。换句话说,我们作为程序员只需要选择一个存储类,SQLite 就会完成剩下的工作!
-
考虑这个问题:我们会使用哪种存储类来存储票价?每个选择都有其优势和局限性。
-
整数:我们可以将 10 美分的票价存储为数字 10,但这并不清楚地表明票价是 10 美分还是 10 美元。
-
文本:我们可以将票价存储为文本,如“$0.10”。然而,现在将很难执行像加起来一个骑手的票价这样的数学运算。
-
实数:我们可以使用浮点数存储票价,如 0.10,但无法精确地以二进制形式存储浮点数,并且——根据我们需要多精确——这样做可能会导致后续的计算错误。
-
类型亲和力
-
在创建表时可以指定列的数据类型。
-
然而,SQLite 中的列并不总是存储特定的一种数据类型。它们据说有类型亲和力,这意味着它们试图将输入值转换为它们具有亲和力的类型。
-
SQLite 中有五种类型亲和力:文本、数值(基于输入值最佳转换的整数或实数值)、整数、实数和二进制大对象。
-
考虑一个对整数有类型亲和力的列。如果我们尝试将“25”(数字 25 但以文本形式存储)插入到这个列中,它将被转换为整数数据类型。
-
类似地,将整数 25 插入到对文本有类型亲和力的列中,将数字转换为它的文本等价物,“25”。
将类型添加到我们的表中
-
要再次创建我们数据库中的表,我们首先需要删除(或删除)现有的表。
-
让我们尝试以下命令
DROP TABLE "riders";DROP TABLE "stations";DROP TABLE "visits";运行这些语句没有输出,但
.schema显示表已经被删除。 -
接下来,让我们创建一个可以运行以从头创建表的架构文件。这比我们之前所做的好,因为我们之前是逐个表地输入
CREATE TABLE命令,因为这允许我们轻松地编辑和查看整个架构。 -
创建一个名为
schema.sql的文件。注意,扩展名.sql使得我们的编辑器能够对 SQL 关键字进行语法高亮。 -
在文件中,让我们再次输入架构,但这次是带有亲和类型。
CREATE TABLE riders ( "id" INTEGER, "name" TEXT ); CREATE TABLE stations ( "id" INTEGER, "name" TEXT, "line" TEXT ); CREATE TABLE visits ( "rider_id" INTEGER, "station_id" INTEGER ); -
现在,我们在数据库中读取此文件以实际创建表。这是一个包含数据类型的更新后的 ER 图。
![更新后的 ER 图,包含数据类型]()
问题
之前,我们能够查询数据库中的表,并在类似表格的结构中看到结果。我们如何让相同类型的结果显示在这里?
- 我们还没有向表中添加任何数据。在第三讲中,我们将看到如何在我们创建的表中插入、更新和删除行!
我们对布尔类型有类型亲和力吗?
- 在 SQLite 中我们不做这样的事情,但其他数据库管理系统可能提供这个选项。一种解决方案是使用 0 或 1 的整数值来表示布尔值。
表约束
-
我们可以使用表约束来对表中某些值施加限制。
-
例如,主键列必须具有唯一值。我们用于此的表约束是
PRIMARY KEY。 -
类似地,外键值的一个约束是它必须在相关表的主键列中找到!这种表约束,不出所料,被称为
FOREIGN KEY。 -
让我们在
schema.sql文件中添加主键和外键约束。CREATE TABLE riders ( "id" INTEGER, "name" TEXT, PRIMARY KEY("id") ); CREATE TABLE stations ( "id" INTEGER, "name" TEXT, "line" TEXT, PRIMARY KEY("id") ); CREATE TABLE visits ( "rider_id" INTEGER, "station_id" INTEGER, FOREIGN KEY("rider_id") REFERENCES "riders"("id"), FOREIGN KEY("station_id") REFERENCES "stations"("id") );注意,我们创建了两个主键列,即
riders和stations的 ID,然后在visits表中将这些主键作为外键引用。 -
在
visits表中,没有主键。然而,SQLite 默认为每个表提供一个主键,称为行 ID。尽管行 ID 是隐式的,但它可以被查询! -
也可以创建由两列组成的复合主键。例如,如果我们想给
visits表创建一个由骑手和站点 ID 组成的复合主键,我们可以使用这种语法。CREATE TABLE visits ( "rider_id" INTEGER, "station_id" INTEGER, PRIMARY KEY("rider_id", "station_id") );在这种情况下,我们可能希望允许骑手访问站点多次,所以我们不会采用这种方法。
问题
我们能否为
visits表包含自己的主键?
- 是的!如果出于某种原因,
visits表需要显式的主键,我们可以创建一个 ID 列并将其设为主键。
列约束
-
列约束是一种应用于表中指定列的约束类型。
-
SQLite 有四种列约束:
-
CHECK:允许检查条件,例如列中的所有值都必须大于 0。 -
DEFAULT:如果为行未提供值,则使用默认值。 -
NOT NULL:规定列中不能插入空或空值。 -
UNIQUE:规定该列中的每个值都必须是唯一的。
-
-
包含这些约束的更新模式如下所示:
CREATE TABLE riders ( "id" INTEGER, "name" TEXT, PRIMARY KEY("id") ); CREATE TABLE stations ( "id" INTEGER, "name" TEXT NOT NULL UNIQUE, "line" TEXT NOT NULL, PRIMARY KEY("id") ); CREATE TABLE visits ( "rider_id" INTEGER, "station_id" INTEGER, FOREIGN KEY("rider_id") REFERENCES "riders"("id"), FOREIGN KEY("station_id") REFERENCES "stations"("id") );NOT NULL约束确保指定了车站名称和线路。另一方面,乘客不需要共享他们的名字,因为没有对乘客名字应用约束。同样,每个车站必须有一个唯一的名称,这是由UNIQUE约束规定的。 -
主键列以及由此派生的外键列必须始终具有唯一值,因此没有必要显式指定
NOT NULL或UNIQUE列约束。表约束PRIMARY KEY包含这些列约束。
修改表
-
考虑以下更新的 ER 图,其中实体“Rider”已被新的实体“Card”所取代,用于表示 CharlieCards。在波士顿地铁中,CharlieCards 可以充值并用于进出车站。
![更新后的 ER 图,包含 CharlieCards 和列]()
-
注意,一张卡片可以被滑动多次,但每次只能在一个车站进行。
-
“Card” 实体有一个 ID,它也是其主键。
-
现在还有一个名为“Swipe”的实体,它有自己的 ID 和类型。“Swipe”还记录了卡片被滑动的时间和扣除的金额(相当于乘坐地铁所需的金额)!
-
现在,为了在我们的数据库中实施这些更改,我们首先需要删除
riders表。DROP TABLE "riders"; -
运行
.schema命令会显示更新后的模式,其中不包括riders表。 -
接下来,我们需要一个
swipes表来表示更新后的 ER 图中的“Swipe”实体。我们可以按以下方式修改visits表。ALTER TABLE "visits" RENAME TO "swipes"; -
再次运行
.schema命令,我们可以看到表visits已被重命名为swipes。然而,这并不是唯一需要的更改。我们还需要添加一些列,例如滑动类型。ALTER TABLE "swipes" ADD COLUMN "swipetype" TEXT;注意,在添加此列时也提到了类型亲和力
TEXT。 -
我们还可以在
ALTER TABLE命令中重命名一个列。如果我们想将列"swipetype"重命名为更简洁的名称,可以尝试以下操作。ALTER TABLE "swipes" RENAME COLUMN "swipetype" TO "type"; -
最后,我们有能力删除(或移除)一个列。
ALTER TABLE "swipes" DROP COLUMN "type";再次运行
.schema命令,我们可以确认表中的列"type"已被删除。 -
也可以回到最初我们拥有的模式文件
schema.sql,并在那里直接进行这些更改,而不是修改表。以下是一个更新的schema.sql。CREATE TABLE "cards" ( "id" INTEGER, PRIMARY KEY("id") ); CREATE TABLE "stations" ( "id" INTEGER, "name" TEXT NOT NULL UNIQUE, "line" TEXT NOT NULL, PRIMARY KEY("id") ); CREATE TABLE "swipes" ( "id" INTEGER, "card_id" INTEGER, "station_id" INTEGER, "type" TEXT NOT NULL CHECK("type" IN ('enter', 'exit', 'deposit')), "datetime" NUMERIC NOT NULL DEFAULT CURRENT_TIMESTAMP, "amount" NUMERIC NOT NULL CHECK("amount" != 0), PRIMARY KEY("id"), FOREIGN KEY("station_id") REFERENCES "stations"("id"), FOREIGN KEY("card_id") REFERENCES "cards"("id") ); -
让我们花几分钟时间阅读更新后的模式,并记录下看起来有所变化的地方!
-
cards和swipes表被添加,并使用NOT NULL列约束来要求swipes中的某些值。 -
"datetime"列被赋予类型亲和力数值型——这是因为数值类型可以存储和显示日期值。 -
根据需要调整外键映射,使得
"card_id"是一个外键,引用cards表的 ID。 -
"datetime"列被分配了一个默认值,以便在没有提供的情况下自动获取当前的时间戳。注意使用了CURRENT_TIMESTAMP——它返回年、月、日、小时、分钟和秒合并成一个值。 -
有一个检查确保滑动支付金额不是 0。这是通过列约束
CHECK实现的,它与表达式"amount" != 0一起使用,以确保值不是 0。 -
同样,对
"type"也有一个检查,以确保其值是‘enter’、‘exit’和‘deposit’之一。这样做是因为当 CharlieCard 被滑动时,通常是为了这三个目的之一,所以让"type"只假设这些值是有意义的。注意使用了IN关键字来执行这个检查!有没有办法使用OR运算符来实现这个检查?
-
问题
在尝试删除
riders表时,出现了一个错误,因为我们正在使用riders的 ID 作为外键。在这种情况下,如何删除该表呢?
- 在数据库中删除表时,会检查外键约束。在删除
riders表之前,我们首先需要删除外键列"rider_id"。
不同数据库管理系统(如 MySQL 或 PostgreSQL)的语法有何不同?
- 大多数 SQLite 语法肯定也适用于其他数据库管理系统。然而,如果我们尝试移植我们的 SQLite 代码,可能需要进行一些最小限度的修改。
如果在 SQLite 中未指定列的类型亲和力,会发生什么?
- 默认的类型亲和力是数值型,因此该列将被分配数值型亲和力。
Fin
- 这就带我们来到了关于 SQL 设计的第二讲的内容总结!关于 CharlieCard 名称起源的一个有趣故事,请阅读来自 Celebrate Boston 的这篇文章。
第三讲
-
简介
-
数据库模式
-
插入数据
- 问题
-
其他约束
-
插入多行
- 问题
-
删除数据
- 问题
-
更新数据
-
触发器
-
创建“卖出”触发器
-
创建“购买”触发器
-
问题
-
-
软删除
-
结束
简介
-
上周,我们学习了如何创建自己的数据库模式。在本讲中,我们将探讨如何在数据库中添加、更新和删除数据。
-
波士顿 MFA(美术博物馆)是波士顿一个拥有一个世纪历史的博物馆。MFA 管理着大量历史和当代艺术品和文物的收藏。他们可能使用某种类型的数据库来存储有关他们的艺术和文物的数据。
-
当一个新的艺术品被添加到他们的收藏中时,我们可以想象他们会将相应的数据插入到他们的数据库中。同样,也存在一些用例,其中可能需要读取、更新或删除数据。
-
现在,我们将专注于在波士顿 MFA 数据库中创建(或插入)数据。
数据库模式
-
考虑到 MFA 可能用于其收藏的此架构。
![包含 ID、艺术品标题和其他信息的 MFA 收藏表]()
-
每行数据包含一件艺术品的标题以及
accession_number,这是博物馆内部使用的唯一 ID。还有一个表示艺术品获取日期的日期。 -
表中包含一个 ID,用作主键。
-
我们可以想象,MFA 的数据库管理员运行一个 SQL 查询,将每一件艺术品插入到表中。
-
为了理解这是如何工作的,让我们首先创建一个名为
mfa.db的数据库。接下来,我们将模式文件schema.sql读入数据库。此模式文件已经提供给我们,帮助我们创建collections表。 -
为了确认表已创建,我们可以从表中选择。
SELECT * FROM "collections";这应该会得到一个空的结果,因为表还没有任何数据。
插入数据
-
INSERT INTOSQL 语句用于将一行数据插入到指定的表中。INSERT INTO "collections" ("id", "title", "accession_number", "acquired") VALUES (1, 'Profusion of flowers', '56.257', '1956-04-12');我们可以看到,这个命令需要指定将接收新数据的表中的列列表以及要添加到每个列中的值,顺序相同。
-
运行
INSERT INTO命令不会返回任何内容,但我们可以运行一个查询来确认该行现在已存在于collections表中。SELECT * FROM "collections"; -
我们可以通过多次插入来向数据库添加更多行。然而,手动输入主键值(如 1、2、3 等)可能会导致错误。幸运的是,SQLite 可以自动填充主键值。为了使用此功能,在插入行时我们可以完全省略 ID 列。
INSERT INTO "collections" ("title", "accession_number", "acquired") VALUES ('Farmers working at dawn', '11.6152', '1911-08-03');我们可以通过运行以下命令来检查这一行是否已插入,其
id为 2:SELECT * FROM "collections";注意 SQLite 填充主键值的方式是通过递增前一个主键值——在这种情况下,是 1。
问题
如果我们删除具有主键 1 的行,SQLite 是否会自动将主键 1 分配给下一个插入的行?
- 不,SQLite 实际上会选择表中最高的主键值并将其递增以生成下一个主键值。
其他约束
-
打开文件
schema.sql将显示数据库的模式。CREATE TABLE "collections" ( "id" INTEGER, "title" TEXT NOT NULL, "accession_number" TEXT NOT NULL UNIQUE, "acquired" NUMERIC, PRIMARY KEY("id") ); -
规定访问编号必须是唯一的。如果我们尝试插入一个具有重复访问编号的行,将会触发一个看起来像
Runtime error: UNIQUE constraint failed: collections.accession_number (19)的错误。 -
这个错误告诉我们,我们正在尝试插入的行违反了模式中的约束——具体来说,在这个场景中是
UNIQUE约束。 -
同样,我们可以尝试添加一个具有
NULL标题的行,违反了NOT NULL约束。INSERT INTO "collections" ("title", "accession_number", "acquired") VALUES(NULL, NULL, '1900-01-10');运行此命令后,我们又将看到类似
Runtime error: NOT NULL constraint failed: collections.title (19)的错误。 -
以这种方式,模式约束是保护我们免于添加不符合我们数据库模式的行的护栏。
插入多行
-
在向数据库写入时,我们可能需要一次插入多行。一种方法是在
INSERT INTO命令中使用逗号分隔行。![一次插入多行,使用逗号分隔]()
以这种方式一次插入多行允许程序员获得一些便利。这同样是一种更快、更高效地将行插入数据库的方法。
-
现在我们将两幅新的画作插入到
collections表中。INSERT INTO "collections" ("title", "accession_number", "acquired") VALUES ('Imaginative landscape', '56.496', NULL), ('Peonies and butterfly', '06.1899', '1906-01-01');博物馆可能并不总是确切知道一幅画是在何时获得的,因此
acquired值可能是NULL,正如我们刚刚插入的第一幅画的情况。 -
要查看更新的表,我们可以像往常一样选择表中的所有行。
SELECT * FROM "collections"; -
我们的数据也可以以逗号分隔值格式或 CSV 存储。观察以下示例,可以看到每行的值是通过逗号分隔的。
![以逗号分隔的值格式的画作数据]()
-
SQLite 使得直接将 CSV 文件导入我们的数据库成为可能。为此,我们需要从头开始。让我们离开这个数据库
mfa.db然后将其删除。 -
我们已经有一个名为
mfa.csv的 CSV 文件,其中包含我们需要的数据。打开这个文件后,我们可以注意到第一行包含列名,这些列名与我们的表collections的模式中的列名完全匹配。 -
首先,让我们再次创建数据库
mfa.db并像之前一样读取模式文件。 -
接下来,我们可以通过运行 SQLite 命令来导入 CSV 文件。
.import --csv --skip 1 mfa.csv collections第一个参数
--csv告诉 SQLite 我们正在导入一个 CSV 文件。这将帮助 SQLite 正确解析文件。第二个参数表示 CSV 文件的第一个行(标题行)需要被跳过,或者不插入到表中。 -
我们可以通过查询
collections表来查看所有数据,以确认mfa.csv中的每一幅画都已成功导入到表中。 -
我们刚刚插入的 CSV 文件包含了每行数据的唯一键值(1, 2, 3 等)。然而,我们处理的大多数 CSV 文件可能不会包含 ID 或主键值。我们如何让 SQLite 自动插入它们?
-
为了尝试这个方法,让我们在我们的代码空间中打开
mfa.csv并删除标题行中的id列,以及每个列中的值。编辑完成后,mfa.csv应该看起来像这样:title,accession_number,acquired Profusion of flowers,56.257,1956-04-12 Farmers working at dawn,11.6152,1911-08-03 Spring outing,14.76,1914-01-08 Imaginative landscape,56.496, Peonies and butterfly,06.1899,1906-01-01 -
我们还将删除
collections表中已经存在的所有行。DELETE FROM "collections"; -
现在,我们想要将这个 CSV 文件导入到一个表中。然而,根据我们的模式,
collections表的每一行都必须有四个列。这个新的 CSV 文件中的每一行只有三个列。因此,我们无法像以前那样继续导入。 -
要成功导入没有 ID 值的 CSV 文件,我们将需要使用一个临时表:
.import --csv mfa.csv temp注意我们在这个命令中没有使用
--skip 1参数。这是因为 SQLite 能够识别 CSV 数据的第一行作为标题行,并将其转换为新temp表的列名。 -
我们可以通过查询
temp表来查看其中的数据。SELECT * FROM "temp"; -
接下来,我们将从
temp表中选择数据(不包含主键)并将其移动到collections表中,这正是我们的目标!我们可以使用以下命令来实现这一点。INSERT INTO "collections" ("title", "accession_number", "acquired") SELECT "title", "accession_number", "acquired" FROM "temp";在此过程中,SQLite 将自动在
id列中添加主键值。 -
为了清理我们的数据库,我们也可以在移动数据后删除
temp表。DROP TABLE "temp";
问题
我们能否在插入表时将列放置在特定的位置?
- 虽然我们可以更改
INSERT INTO命令中值的顺序,但我们通常不能更改列名的顺序。列名的顺序遵循创建表时使用的相同顺序。
如果我们尝试插入的多行中的任意一行违反了表约束,会发生什么?
- 当尝试将多行插入到表中时,如果其中任意一行违反了约束,插入命令将导致错误,并且不会插入任何行!
在从 CSV 文件插入数据后,其中一个单元格为空且不是
NULL。为什么会发生这种情况?
- 当我们从 CSV 文件导入数据时,
acquired值中的一个缺失了!这被解释为文本,因此被读取到表中作为空文本值。我们可以在导入后运行查询,将这些空值转换为NULL,如果需要的话。
删除数据
-
我们之前看到运行以下命令从
collections表中删除了所有行。(我们现在实际上不想运行这个命令,否则我们会丢失表中的所有数据!)DELETE FROM "collections"; -
我们也可以删除符合特定条件的行。例如,要从我们的
collections表中删除“春游”画作,我们可以执行以下命令:DELETE FROM "collections" WHERE "title" = 'Spring outing'; -
要删除任何获得日期为
NULL的画作,我们可以执行以下命令:DELETE FROM "collections" WHERE "acquired" IS NULL; -
和我们通常做的那样,我们将通过从表中选择所有数据来确保删除操作按预期工作。
SELECT * FROM "collections";我们看到“春游”和“想象风景”画作不再在表中。
-
要删除 1909 年之前的画作相关行,我们可以执行以下命令:
DELETE FROM "collections" WHERE "acquired" < '1909-01-01';使用
<运算符,我们正在查找 1909 年 1 月 1 日之前获得的画作。这些是在运行查询时将被删除的画作。 -
可能存在删除某些数据会影响数据库完整性的情况。外键约束是一个很好的例子。外键列引用不同表的主键。如果我们删除主键,外键列将没有任何可引用的内容!
-
现在考虑 MFA 数据库的更新模式,它不仅包含关于艺术品的信息,还包含艺术家信息。艺术家和收藏两个实体之间存在多对多关系——一幅画可以由许多艺术家创作,而单个艺术家也可以创作许多艺术品。
![包含艺术家和收藏实体的更新模式]()
-
这里是一个实现上述 ER 图的数据库。
![三张表:艺术家、创建时间、收藏]()
artists和collections表具有主键——ID 列。created表通过其两个外键列引用这些 ID。 -
给定这个数据库,如果我们选择删除未知的艺术家(ID 为 3),那么
created表中具有artist_id为 3 的行会发生什么?让我们试一试。 -
在打开
mfa.db后,现在我们可以通过运行.schema命令来查看更新的模式。created表确实有两个外键约束,一个针对艺术家 ID,一个针对收藏 ID。 -
现在,我们可以尝试从
artists表中删除数据。DELETE FROM "artists" WHERE "name" = 'Unidentified artist';在运行此命令时,我们得到一个与我们在本课程中之前看到的非常相似的错误:
运行时错误:外键约束失败(19)。这个错误通知我们,删除这些数据将违反在created表中设置的外键约束。 -
我们如何确保约束不被违反?一种可能性是在从
artists表删除之前,先从created表中删除相应的行。DELETE FROM "created" WHERE "artist_id" = ( SELECT "id" FROM "artists" WHERE "name" = 'Unidentified artist' );这个查询有效地删除了艺术家与其作品之间的关联。一旦关联不再存在,我们就可以在不违反外键约束的情况下删除艺术家的数据。为此,我们可以运行
DELETE FROM "artists" WHERE "name" = 'Unidentified artist'; -
在另一种可能性中,我们可以指定当通过外键引用的 ID 被删除时采取的操作。为此,我们使用关键字
ON DELETE后跟要执行的操作。-
ON DELETE RESTRICT:这限制我们在外键约束违反时删除 ID。 -
ON DELETE NO ACTION:这允许删除由外键引用的 ID,但不会发生任何操作。 -
ON DELETE SET NULL:这允许删除由外键引用的 ID,并将外键引用设置为NULL。 -
ON DELETE SET DEFAULT:这与之前的行为相同,但允许我们设置默认值而不是NULL。 -
ON DELETE CASCADE:这允许删除由外键引用的 ID,并继续级联删除引用的外键行。例如,如果我们使用此方法删除艺术家 ID,所有艺术家与艺术品的关联也会从created表中删除。
-
-
最新版本的架构文件实现了上述方法。外键约束现在看起来像
FOREIGN KEY("artist_id") REFERENCES "artists"("id") ON DELETE CASCADE FOREIGN KEY("collection_id") REFERENCES "collections"("id") ON DELETE CASCADE现在运行以下
DELETE语句不会导致错误,并将级联删除从artists表传播到created表:DELETE FROM "artists" WHERE "name" = 'Unidentified artist';要检查级联删除是否工作,我们可以查询
created表:SELECT * FROM "created";我们观察到没有行具有 ID 3(从
artists表中删除的艺术家 ID)。
问题
我们刚刚删除了 ID 为 3 的艺术家。有没有办法让下一个插入的行具有 ID 3?
- 默认情况下,正如我们之前讨论的,SQLite 将选择表中存在的最大 ID 并递增以获得下一个 ID。但我们在创建列时可以使用
AUTOINCREMENT关键字来指示任何被删除的 ID 应重新用于表中插入的新行。
更新数据
-
我们可以轻松想象出数据库中的数据需要更新的场景。也许,在 MFA 数据库的案例中,我们发现原本映射到“未知的艺术家”的画作“黎明时分劳作的农民”实际上是由艺术家李银创作的。
-
我们可以使用更新命令来更改,比如说,一幅画的关系。以下是更新命令的语法。
![更新命令语法]()
-
让我们使用上述语法在
created表中更改“黎明时分劳作的农民”的关联。UPDATE "created" SET "artist_id" = ( SELECT "id" FROM "artists" WHERE "name" = 'Li Yin' ) WHERE "collection_id" = ( SELECT "id" FROM "collections" WHERE "title" = 'Farmers working at dawn' );查询的第一部分指定了要更新的表。下一部分检索李因的 ID 以设置为新的 ID。最后一部分选择
created中的行(多行),这些行将更新为李因的 ID,即画作“黎明时分劳作的农民”!
触发器
-
触发器是一个 SQL 语句,在响应另一个 SQL 语句(如
INSERT、UPDATE或DELETE)时自动运行。 -
触发器对于维护数据一致性和在相关表之间自动化任务非常有用。
创建“销售”触发器
-
考虑包含一个
collections表和一个新的transactions表的 MFA 数据库。CREATE TABLE "transactions" ( "id" INTEGER, "title" TEXT, "action" TEXT, PRIMARY KEY("id") ); -
当艺术品被出售(从
collections中删除)时,我们希望它自动在transactions中记录为“销售”动作。CREATE TRIGGER "sell" BEFORE DELETE ON "collections" BEGIN INSERT INTO "transactions" ("title", "action") VALUES (OLD."title", 'sold'); END; -
这个触发器在从
collections中删除行之前运行。 -
OLD 是一个特殊的关键字,它指的是即将被删除的行。
-
OLD."title"访问即将被删除的行的标题列。 -
触发器自动在
transactions中插入一条记录,动作标记为“销售”。
创建“购买”触发器
-
当艺术品被购买(插入到
collections)时,我们希望它在transactions中记录为“购买”动作。CREATE TRIGGER "buy" AFTER INSERT ON "collections" BEGIN INSERT INTO "transactions" ("title", "action") VALUES (NEW."title", 'bought'); END; -
这个触发器在
collections中插入新行之后运行。 -
NEW 是一个特殊的关键字,它指的是正在插入的行。
-
NEW."title"访问新插入行的标题列。
问题
触发器内可以包含多个 SQL 语句吗?
- 是的,你可以在
BEGIN和END块内包含多个语句,用分号分隔。
软删除
-
软删除(或软删除)意味着将数据标记为已删除,而不是真正从数据库中移除它。
-
例如,我们可以在
collections表中添加一个deleted列,默认值为 0:ALTER TABLE "collections" ADD COLUMN "deleted" INTEGER DEFAULT 0; -
要“删除”一行,我们会更新
deleted列为 1:UPDATE "collections" SET "deleted" = 1 WHERE "title" = 'Farmers working at dawn'; -
然后,为了查询仅非删除行:
SELECT * FROM "collections" WHERE "deleted" != 1; -
这样,如果需要的话,数据可以被恢复,并且保持完整的历史记录。
-
然而,遵守要求数据真正被删除的数据隐私法规仍然很重要。
结束
- 这就带我们来到了关于 SQL 写作的第三讲结束!
第四讲
-
介绍
-
视图
-
简化
- 问题
-
聚合
- 问题
-
公用表表达式(CTE)
-
分区
- 问题
-
安全
-
软删除
-
结束
介绍
-
到目前为止,我们已经学习了允许我们设计复杂数据库并将数据写入其中的概念。现在,我们将探讨从这些数据库中获取视图的方法。
-
让我们回到包含国际布克奖长名单书籍的数据库。以下是该数据库中表的快照。
![包含书籍和作者的多对多关系的表]()
-
要找到韩江(Han Kang)所著的书籍,我们需要遍历上述三个表中的每一个——首先找到作者的 ID,然后相应的书籍 ID,最后是书籍标题。相反,有没有一种方法可以将三个表中的相关信息组合成一个视图?
-
是的,我们可以使用 SQL 中的
JOIN命令根据它们之间的相关列将两个或多个表中的行组合起来。以下是这些表如何连接以对齐作者及其书籍的视觉表示。![连接书籍、所著和作者的表]()
这使得观察出韩江(Han Kang)是《白书》的作者变得简单。
-
也可以想象在这里删除 ID 列,这样我们的视图看起来就像下面这样。
![表连接书籍、作者及其 ID 列已删除]()
视图
-
视图是由查询定义的虚拟表。
-
假设我们编写了一个查询来连接三个表,就像之前的例子一样,然后选择相关列。由这个查询创建的新表可以保存为视图,以便稍后进一步查询。
-
视图对于以下用途很有用:
-
简化:将来自不同表的数据组合起来以便更简单地查询,
-
聚合:运行聚合函数,如求和,并存储结果,
-
分区:将数据划分为逻辑部分,
-
安全:隐藏应保持安全的列。虽然视图还有其他有用的方式,但在本讲中,我们将关注上述四个方面。
-
简化
-
让我们在 SQLite 中打开
longlist.db并运行.schema命令来验证我们之前示例中看到的三个表是否已创建:authors、authored和books。 -
要选择 Fernanda Melchor 所著的书籍,我们会编写这个嵌套查询。
SELECT "title" FROM "books" WHERE "id" IN ( SELECT "book_id" FROM "authored" WHERE "author_id" = ( SELECT "id" FROM "authors" WHERE "name" = 'Fernanda Melchor' ) ); -
上述查询很复杂——嵌套查询中有三个
SELECT查询。为了简化,让我们首先使用JOIN创建包含作者及其书籍的视图。 -
在新的终端中,让我们再次连接到
longlist.db,并运行以下查询。SELECT "name", "title" FROM "authors" JOIN "authored" ON "authors"."id" = "authored"."author_id" JOIN "books" ON "books"."id" = "authored"."book_id";-
注意,指定如何连接两个表,或者它们连接的列是很重要的。
-
小贴士:一个表的主键列通常与另一个表的对应外键列相连接!
-
运行此命令将显示一个包含所有作者姓名及其所写书籍标题的表格。
-
-
要将之前步骤中创建的虚拟表保存为视图,我们需要更改查询。
CREATE VIEW "longlist" AS SELECT "name", "title" FROM "authors" JOIN "authored" ON "authors"."id" = "authored"."author_id" JOIN "books" ON "books"."id" = "authored"."book_id";这里创建的视图称为
longlist。现在我们可以像使用 SQL 中的表一样使用这个视图。 -
让我们编写一个查询来查看这个视图中的所有数据。
SELECT * FROM "longlist"; -
使用这个视图,我们可以大大简化查找 Fernanda Melchor 所写书籍所需的查询。
SELECT "title" FROM "longlist" WHERE "name" = 'Fernanda Melchor'; -
视图作为一个虚拟表,创建时不会消耗更多的磁盘空间。视图中的数据仍然存储在底层表中,但仍然可以通过这个简化的视图访问。
问题
我们能否操纵视图以使其有序,或者以不同的方式显示?
-
是的,我们可以像在表中一样在视图中对书籍进行排序。
-
例如,让我们按书籍标题的顺序显示
longlist视图中的数据。SELECT "name", "title" FROM "longlist" ORDER BY "title"; -
我们也可以让视图本身有序。我们可以通过在创建视图所用的查询中包含一个
ORDER BY子句来实现这一点。
-
聚合
-
在
longlist.db中,我们有一个包含每本书单独评分的表。在之前的几周中,我们看到了如何找到每本书的平均评分,并四舍五入到两位小数。SELECT "book_id", ROUND(AVG("rating"), 2) AS "rating" FROM "ratings" GROUP BY "book_id"; -
通过显示每本书的标题,以及每本书被列入长名单的年份,可以使上述查询的结果更有用。这些信息存在于
books表中。SELECT "book_id", "title", "year", ROUND(AVG("rating"), 2) AS "rating" FROM "ratings" JOIN "books" ON "ratings"."book_id" = "books"."id" GROUP BY "book_id";-
在这里,我们使用
JOIN将ratings和books表中的信息结合起来,通过书籍 ID 列进行连接。 -
注意这个查询的操作顺序——特别是将
GROUP BY操作放在查询末尾,在两个表连接之后。
-
-
这聚合的数据可以存储在视图中。
CREATE VIEW "average_book_ratings" AS SELECT "book_id" AS "id", "title", "year", ROUND(AVG("rating"), 2) AS "rating" FROM "ratings" JOIN "books" ON "ratings"."book_id" = "books"."id" GROUP BY "book_id";-
现在,让我们查看这个视图中的数据。
SELECT * FROM "average_book_ratings";
-
-
在向
ratings表添加更多数据以获取最新的聚合数据时,我们只需简单地使用上述类似的SELECT命令重新查询视图即可! -
每次创建视图时,它都会被添加到模式中。我们可以通过运行
.schema来验证这一点,观察longlist和average_book_ratings现在已经成为这个数据库模式的一部分。 -
要创建不存储在数据库模式中的临时视图,我们可以使用
CREATE TEMPORARY VIEW。此命令创建一个仅在数据库连接期间存在的视图。 -
要找到每本书的年度平均评分,我们可以使用我们已创建的视图。
SELECT "year", ROUND(AVG("rating"), 2) AS "rating" FROM "average_book_ratings" GROUP BY "year";注意,我们从
average_book_ratings中选择了rating列,该列已经包含了每本书的平均评分。接下来,我们按年份对这些评分进行分组,并再次计算平均评分,这样就得到了每年的平均评分! -
我们可以将结果存储在一个临时视图中。
CREATE TEMPORARY VIEW "average_ratings_by_year" AS SELECT "year", ROUND(AVG("rating"), 2) AS "rating" FROM "average_book_ratings" GROUP BY "year";
问题
可以使用临时视图来测试查询是否有效吗?
- 是的,这是一个临时视图的绝佳用例!为了稍微概括一下,当我们想要以某种方式组织数据而不需要长期存储这种组织时,我们会使用临时视图。
公用表表达式(CTE)
-
正规视图在我们数据库模式中永久存在。临时视图在我们与数据库的连接期间存在。CTE 是仅对单个查询存在的视图。
-
让我们使用公用表表达式(CTE)而不是临时视图来重新创建包含每年平均书籍评分的视图。首先,我们需要删除现有的临时视图,这样我们就可以重用名称
average_book_ratings。DROP VIEW "average_book_ratings"; -
接下来,我们创建一个包含每本书平均评分的 CTE。然后,我们使用每本书的平均评分来计算每年的平均评分,这与我们之前的方法非常相似。
WITH "average_book_ratings" AS ( SELECT "book_id", "title", "year", ROUND(AVG("rating"), 2) AS "rating" FROM "ratings" JOIN "books" ON "ratings"."book_id" = "books"."id" GROUP BY "book_id" ) SELECT "year" ROUND(AVG("rating"), 2) AS "rating" FROM "average_book_ratings" GROUP BY "year";
分区
-
视图可以用来分区数据,或者将其分解成对我们或应用程序有用的更小的部分。例如,国际布克奖的网站为每次获奖的年份都有一个入选书籍的页面。然而,我们的数据库将所有入选的书籍存储在一个单独的表中。为了创建网站或其他目的,可能需要为每年的书籍创建不同的表(或视图)。
-
让我们创建一个视图来存储 2022 年入选的书籍。
CREATE VIEW "2022" AS SELECT "id", "title" FROM "books" WHERE "year" = 2022;-
我们也可以在这个视图中查看数据。
SELECT * FROM "2022";
-
问题
视图可以更新吗?
- 不可以,因为视图不像表那样包含任何数据。视图实际上在每次查询时都会从底层表中提取数据。这意味着当底层表被更新时,下一次查询视图时,它将显示来自表的新数据!
安全
-
视图可以通过限制对某些数据的访问来增强数据库的安全性。
-
考虑一个共享出行公司的数据库,其中有一个名为
rides的表,其结构如下。![包含目的地、起点和乘客的骑行表]()
-
如果我们将这些数据提供给分析师,他们的工作是找出最受欢迎的骑行路线,那么提供个别乘客的姓名将是不相关的,实际上也是不安全的。乘客姓名可能被归类为个人信息(PII),公司不允许无差别地共享这些信息。
-
在这种情况下,视图可以派上用场——我们可以与分析师分享一个包含骑行起点和目的地的视图,但不包含乘客姓名。
-
为了尝试这个,让我们在我们的终端中打开
rideshare.db。运行.schema应该会揭示这个数据库中的一个名为rides的表。 -
我们可以创建一个包含相关列的视图,同时完全省略
rider列。但在这里,我们将更进一步,创建一个rider列来显示表中每行的匿名骑手。这将向分析师表明,尽管我们在数据库中有骑手姓名,但这些姓名为了安全起见已被匿名化。CREATE VIEW "analysis" AS SELECT "id", "origin", "destination", 'Anonymous' AS "rider" FROM "rides";-
我们可以查询这个视图来确保它是安全的。
SELECT * FROM "analysis";
-
-
尽管我们可以创建一个匿名化数据的视图,但 SQLite 不允许访问控制。这意味着我们的分析师可以简单地查询原始的
rides表,并看到我们在analysis视图中费尽心思省略的所有骑手姓名。
软删除
-
正如我们在前几周看到的,软删除涉及将行标记为已删除,而不是从表中删除它。
-
例如,名为“黎明时分劳作的农民”的艺术品通过将
collections表中的deleted列的值从 0 更改为 1 被标记为已删除。![通过将"deleted"值从 0 更改为 1 来软删除行]()
-
我们可以想象创建一个视图来仅显示未删除的艺术品。
-
要尝试这个,让我们在我们的终端中打开
mfa.db。collections表还没有deleted列,所以我们需要添加它。这里的默认值将是 0,以表示该行未被删除。ALTER TABLE "collections" ADD COLUMN "deleted" INTEGER DEFAULT 0; -
现在,让我们对艺术品“黎明时分劳作的农民”执行软删除,通过将其
deleted列更新为 1。UPDATE "collections" SET "deleted" = 1 WHERE "title" = 'Farmers working at dawn'; -
我们可以创建一个视图来显示未删除行的信息。
CREATE VIEW "current_collections" AS SELECT "id", "title", "accession_number", "acquired" FROM "collections" WHERE "deleted" = 0;-
我们可以显示这个视图中的数据来验证“黎明时分劳作的农民”不存在。
SELECT * FROM "current_collections"; -
在从底层表
collections中软删除行后,它将在任何进一步的查询中从current_collections视图中被移除。
-
-
我们已经知道无法向视图中插入数据或从视图中删除数据。然而,我们可以设置一个触发器来向底层表插入或删除数据!
INSTEAD OF触发器允许我们这样做。CREATE TRIGGER "delete" INSTEAD OF DELETE ON "current_collections" FOR EACH ROW BEGIN UPDATE "collections" SET "deleted" = 1 WHERE "id" = OLD."id"; END;-
每次我们尝试从视图中删除行时,这个触发器将更新底层表
collections中行的deleted列,从而完成软删除。 -
我们在更新子句中使用关键字
OLD来表示在collections中更新的行的 ID 应该与我们要从current_collections中删除的行的 ID 相同。
-
-
现在,我们可以从
current_collections视图中删除一行。DELETE FROM "current_collections" WHERE "title" = 'Imaginative landscape';我们可以通过查询视图来验证这是否有效。
SELECT * FROM "current_collections"; -
类似地,我们可以创建一个触发器,在我们尝试将数据插入视图时将其插入到底层表中。
-
这里有两个需要考虑的情况。我们可能试图将已存在于底层表中的、但已被软删除的行插入到视图中。我们可以编写以下触发器来处理这种情况。
CREATE TRIGGER "insert_when_exists" INSTEAD OF INSERT ON "current_collections" FOR EACH ROW WHEN NEW."accession_number" IN ( SELECT "accession_number" FROM "collections" ) BEGIN UPDATE "collections" SET "deleted" = 0 WHERE "accession_number" = NEW."accession_number"; END;-
WHEN关键字用于检查艺术品的登记号是否已存在于collections表中。这是因为,正如我们从上周所知,登记号唯一地标识了表中每一件艺术品。 -
如果艺术品确实存在于底层表中,我们将它的
deleted值设置为 0,表示软删除的撤销。
-
-
第二种情况发生在我们尝试插入一个在底层表中不存在的行时。以下触发器处理这种情况。
CREATE TRIGGER "insert_when_new" INSTEAD OF INSERT ON "current_collections" FOR EACH ROW WHEN NEW."accession_number" NOT IN ( SELECT "accession_number" FROM "collections" ) BEGIN INSERT INTO "collections" ("title", "accession_number", "acquired") VALUES (NEW."title", NEW."accession_number", NEW."acquired"); END;- 当插入数据的登记号不在
collections中时,它将行插入到表中。
- 当插入数据的登记号不在
Fin
- 这就带我们来到了关于 SQL 中查看的第四讲的内容总结!
第五课
-
简介
-
索引
- 问题
-
跨多表索引
-
空间权衡
-
时间权衡
-
部分索引
- 问题
-
真空
- 问题
-
并发
-
事务
-
竞争条件
-
问题
-
-
结束
简介
-
这周,我们将学习如何优化我们的 SQL 查询,无论是时间还是空间。我们还将学习如何并发地运行查询。
-
我们将在一个新的数据库的背景下做所有这些,即互联网电影数据库,或更广为人知的 IMDb。我们的 SQLite 数据库是从您可能之前在 imdb.com 看过的庞大在线电影数据库编译而成的。
-
查看这些统计数据,以了解这个数据库有多大!它拥有的数据比我们迄今为止所使用的任何其他数据库都要多!
![IMDb 数据库统计数据]()
-
这里是详细说明实体及其关系的 ER 图。
![IMDb ER 图 — 人物、电影和评分实体]()
索引
-
让我们打开这个名为
movies.db的数据库在 SQLite 中。 -
.schema显示了在这个数据库中创建的表。为了实现实体 Person 和 Movie 之间的多对多关系,我们在 ER 图中有一个联合表,称为stars,它将people和movies的 ID 列作为外键列引用! -
要查看
movies表,我们可以从表中选择并限制结果。SELECT * FROM "movies" LIMIT 5; -
要查找与电影 Cars 相关的信息,我们会运行以下查询。
SELECT * FROM "movies" WHERE "title" = 'Cars';-
假设我们想找出这个查询运行了多长时间。SQLite 有一个命令
.timer on,它使我们能够计时我们的查询。 -
在运行上述查询以再次查找 Cars 时,我们可以看到三个不同的时间测量值与结果一起显示。
-
“实际”时间表示计时器时间,或执行查询并获得结果之间的时间。这是我们关注的衡量时间。在讲座中执行此查询所花费的时间大约是 0.1 秒!
-
-
在底层,当运行查找 Cars 的查询时,我们触发了对表
movies的 扫描——也就是说,表movies是逐行从上到下扫描,以找到所有标题为 Cars 的行。 -
我们可以优化这个查询,使其比扫描更高效。就像教科书通常有索引一样,数据库表也可以有索引。在数据库术语中,索引是一种用于加速从表中检索行的结构。
-
我们可以使用以下命令为
movies表中的"title"列创建索引。CREATE INDEX "title_index" ON "movies" ("title");- 在创建这个索引后,我们再次运行查询以查找名为《汽车总动员》的电影。在这次运行中,所需时间显著缩短(在讲座中,几乎比第一次快八倍)!
-
在上一个例子中,一旦创建了索引,我们就假设 SQL 会使用它来查找电影。然而,我们也可以通过在查询之前使用 SQLite 命令
EXPLAIN QUERY PLAN来明确地看到这一点。 -
要删除我们刚刚创建的索引,请运行:
DROP INDEX "title_index";- 在删除索引后,再次使用
EXPLAIN QUERY PLAN与SELECT查询一起运行将表明计划将回退到扫描整个数据库。
- 在删除索引后,再次使用
问题
数据库没有隐式算法来优化搜索吗?
- 对于某些列来说,它们确实有。在 SQLite 和大多数其他数据库管理系统中,如果我们指定一个列是主键,则会自动创建一个索引,通过该索引我们可以搜索主键。然而,对于像
"title"这样的常规列,则不会有自动优化。
是否建议为每个可能需要的列创建不同的索引?
- 虽然这似乎很有用,但在空间和时间上存在权衡,因为之后将数据插入带有索引的表中需要花费时间。我们很快就会看到更多关于这方面的内容!
跨多表索引
-
我们将运行以下查询来找到汤姆·汉克斯主演的所有电影。
SELECT "title" FROM "movies" WHERE "id" IN ( SELECT "movie_id" FROM "stars" WHERE "person_id" = ( SELECT "id" FROM "people" WHERE "name" = 'Tom Hanks' ) ); -
为了了解哪种索引可以帮助加快这个查询的速度,我们可以在查询之前再次运行
EXPLAIN QUERY PLAN。这显示查询需要两次扫描——people和stars。由于我们是通过 ID 搜索movies,SQLite 会自动为这个 ID 创建索引,所以不需要扫描movies表! -
让我们创建两个索引来加快这个查询的速度。
CREATE INDEX "person_index" ON "stars" ("person_id"); CREATE INDEX "name_index" ON "people" ("name"); -
现在,我们使用相同的嵌套查询运行
EXPLAIN QUERY PLAN。我们可以观察到-
现在所有的扫描都变成了使用索引的搜索,这很好!
-
在
people表上的搜索使用了一种称为COVERING INDEX的东西
-
-
覆盖索引意味着查询所需的所有信息都可以在索引本身中找到。而不是两步:
-
在索引中查找相关信息,
-
使用索引来搜索表,覆盖索引意味着我们只需一步(只是第一步)进行搜索。
-
-
要让
stars表上的搜索也使用覆盖索引,我们可以在为stars创建的索引中添加"movie_id"。这将确保要查找的信息(电影 ID)和要搜索的值(人物 ID)都包含在索引中。 -
首先,让我们删除
stars表上现有的索引实现。DROP INDEX "person_index"; -
接下来,我们创建新的索引。
CREATE INDEX "person_index" ON "stars" ("person_id", "movie_id"); -
运行以下命令将证明我们现在有两个覆盖索引,这应该会导致搜索速度大大加快!
EXPLAIN QUERY PLAN SELECT "title" FROM "movies" WHERE "id" IN ( SELECT "movie_id" FROM "stars" WHERE "person_id" = ( SELECT "id" FROM "people" WHERE "name" = 'Tom Hanks' ) ); -
确保我们已运行
.timer on,然后我们可以执行上述查询以找到汤姆·汉克斯主演的所有电影,并观察其运行时间。现在查询的运行速度比没有索引时快得多(在讲座中,速度快了一个数量级)!
空间权衡
-
索引看起来非常有帮助,但它们也有权衡——它们在数据库中占用额外的空间,因此虽然我们获得了查询速度的提升,但我们确实失去了空间。
-
索引以称为 B 树或平衡树的数据结构存储在数据库中。树数据结构看起来像这样:
![树数据结构]()
- 注意到树中有许多节点,每个节点通过箭头与其他几个节点相连。根节点,或树起源的节点,有三个子节点。树边缘的一些节点不指向任何其他节点。这些被称为叶节点。
-
让我们考虑如何为
movies表的"title"列创建索引。如果电影标题按字母顺序排序,那么使用二分搜索查找特定电影会容易得多。 -
在这种情况下,会复制
"titles"列。这个副本会被排序,然后通过指向电影 ID 将它们链接回movies表中的原始行。这在下图中进行了可视化。![索引:指向原始电影 ID 的标题排序副本]()
-
虽然这有助于我们轻松地可视化此列的索引,但在现实中,索引不是一个单独的列,而是被拆分成许多节点。这是因为如果数据库有大量数据,比如我们的 IMDb 示例,将一个列全部存储在内存中可能不可行。
-
然而,如果我们有包含索引部分的多个节点,我们还需要节点来导航到正确的部分。例如,考虑以下节点。左侧节点根据电影标题是否在 Frozen 之前、在 Frozen 和 Soul 之间或 Soul 之后按字母顺序,将我们引导到索引的正确部分!
![索引节点拆分为部分]()
-
上述表示是一个 B 树!这是 SQLite 中索引存储的方式。
时间权衡
- 与我们之前讨论的空间权衡类似,它也会使将数据插入列并添加到索引中花费更长的时间。每次向索引添加一个值时,B 树都需要遍历以确定该值应该添加的位置!
部分索引
-
这是一个只包含表的一部分行的索引,允许我们节省一个完整索引所占用的空间。
-
这在我们知道用户只查询表的一小部分行时特别有用。在 IMDb 的情况下,可能用户更有可能查询一部新发布的电影,而不是一部 15 年前的电影。让我们尝试创建一个部分索引,该索引存储 2023 年发布的电影标题。
CREATE INDEX "recents" ON "movies" ("titles") WHERE "year" = 2023; -
我们可以检查搜索 2023 年发布的电影是否使用了新的索引。
EXPLAIN QUERY PLAN SELECT "title" FROM "movies" WHERE "year" = 2023;这表明
movies表是使用部分索引进行扫描的。
问题
索引是否保存在模式中?
- 是的,在 SQLite 中,它们是这样的!我们可以通过运行
.schema来确认,我们将在数据库模式中看到创建的索引列表。
真空
-
有方法可以删除我们数据库中的未使用空间。SQLite 允许我们“真空”数据——这清理了之前已删除的数据(实际上并没有删除,只是标记为可用空间以供下一个
INSERT使用)。 -
要在终端上查找
movies.db的大小,我们可以使用 Unix 命令du -b movies.db -
在讲座中,这个命令向我们展示了数据库的大小大约是 158 百万字节,或者说 158 兆字节。
-
我们现在可以连接到我们的数据库并删除我们之前创建的索引。
DROP INDEX "person_index"; -
现在,如果我们再次运行 Unix 命令,我们会看到数据库的大小没有减少!要真正清理已删除的空间,我们需要对它进行真空。我们可以在 SQLite 中运行以下命令。
VACUUM;这可能需要一秒钟或两秒钟的时间来运行。在再次运行 Unix 命令检查数据库大小时,我们应该看到更小的尺寸。一旦我们删除所有索引并再次真空,数据库的大小将比 158 MB 小得多(在讲座中大约是 100 MB)。
问题
是否有可能使真空过程更快?
- 每个真空过程所需的时间可能不同,这取决于我们试图真空的空间量以及找到需要释放的位和字节有多容易!
如果一个删除某些行的查询实际上并没有删除它们,而只是将它们标记为已删除,我们是否还能检索这些行?
- 在法医学方面受过训练的人能够找到我们认为已删除但实际上仍然在我们的电脑上的数据。在 SQLite 的情况下,在执行真空操作后,将无法再次找到已删除的行。
并发
-
到目前为止,我们已经看到了如何优化单个查询。现在,我们将探讨如何允许一次不仅仅是一个查询,而是多个查询同时进行。
-
并发是数据库同时处理多个查询或交互的方式。想象一下,一个网站或金融服务数据库在同时承受大量流量。在这些情况下,并发尤为重要。
-
一些数据库事务可以是多部分的。例如,考虑一个银行的数据库。以下是一个存储账户余额的
accounts表的视图。![银行数据库中的账户表。爱丽丝向鲍勃发送 10 美元。]()
-
一个事务可能是从一个账户向另一个账户转账。例如,爱丽丝试图向鲍勃发送 10 美元。
-
要完成这个事务,我们需要向鲍勃的账户中添加 10 美元,并从爱丽丝的账户中减去 10 美元。如果有人看到在第一次更新鲍勃的账户之后但在第二次更新爱丽丝的账户之前的
accounts数据库的状态,他们可能会对银行持有的总金额有一个错误的理解。
-
事务
-
对于外部观察者来说,它应该看起来像事务的不同部分是同时发生的。在数据库术语中,事务是一个单独的工作单元——不能分解成更小的部分。
-
事务具有一些属性,可以使用 ACID 首字母缩写词来记住:
-
原子性:不能分解成更小的部分,
-
一致性:不应违反数据库约束,
-
隔离性:如果多个用户访问数据库,他们的事务不能相互干扰,
-
持久性:在数据库内部发生任何故障的情况下,所有由事务更改的数据都将保持不变。
-
-
让我们在终端中打开
bank.db,这样我们就可以实现从爱丽丝到鲍勃转账的事务了! -
首先,我们想查看
accounts表中已经存在的数据。SELECT * FROM "accounts";我们在此处记录鲍勃的 ID 是 2,爱丽丝的 ID 是 1,这对我们的查询将很有用。
-
要将 10 美元从爱丽丝的账户转移到鲍勃的账户,我们可以编写以下事务。
BEGIN TRANSACTION; UPDATE "accounts" SET "balance" = "balance" + 10 WHERE "id" = 2; UPDATE "accounts" SET "balance" = "balance" - 10 WHERE "id" = 1; COMMIT;注意,
UPDATE语句写在开始事务和提交事务的命令之间。如果我们执行查询是在写入UPDATE语句之后,但没有提交,那么两个UPDATE语句都不会执行!这有助于保持事务的原子性。通过以这种方式更新我们的表,我们无法看到中间步骤。 -
如果我们再次尝试运行上述事务——爱丽丝试图再给鲍勃支付 10 美元——它应该无法运行,因为爱丽丝的账户余额为 0。(
accounts表中的"balance"列有一个检查约束,以确保它具有非负值。我们可以运行.schema来检查这一点。) -
我们实现事务回滚的方式是使用
ROLLBACK。一旦我们开始一个事务并写入一些 SQL 语句,如果其中任何一个失败,我们可以使用ROLLBACK来结束它,将所有值回滚到事务前的状态。这有助于保持事务的一致性。BEGIN TRANSACTION; UPDATE "accounts" SET "balance" = "balance" + 10 WHERE "id" = 2; UPDATE "accounts" SET "balance" = "balance" - 10 WHERE "id" = 1; -- Invokes constraint error ROLLBACK;
竞争条件
-
事务可以帮助防止竞争条件。
-
当多个实体同时访问并基于共享值做出决策时,会发生竞争条件,这可能导致数据库中的不一致性。未解决的竞争条件可以被黑客利用来操纵数据库。
-
在讲座中,讨论了一个竞争条件的例子,其中两个用户合作可以利用数据库中的暂时不一致性来抢劫银行。
-
然而,事务是隔离处理的,以避免首先出现不一致。处理我们数据库中类似数据的每个事务都将按顺序处理。这有助于防止敌对攻击可以利用的不一致性。
-
为了使事务按顺序进行,SQLite 和其他数据库管理系统使用数据库上的锁。数据库中的表可能处于几种不同的状态:
-
未锁定(UNLOCKED):这是没有用户访问数据库时的默认状态,
-
共享(SHARED):当事务从数据库读取数据时,它获得共享锁,允许其他事务同时从数据库中读取,
-
排他(EXCLUSIVE):如果一个事务需要写入或更新数据,它将获得对数据库的排他锁,不允许其他事务同时发生(甚至不允许读取)
-
问题
我们如何决定何时一个事务可以获得排他锁?我们如何优先处理不同类型的交易?
- 可以使用不同的算法来做出这些决定。例如,我们总是可以选择最先发生的交易。如果需要排他性交易,则没有其他交易可以同时运行,这是确保表的一致性所必需的缺点。
锁定的粒度是什么?我们是锁定数据库、表还是表的行?
-
这取决于数据库管理系统(DBMS)。在 SQLite 中,我们可以通过运行以下排他性事务来实现这一点:
BEGIN EXCLUSIVE TRANSACTION;如果我们现在不完成这笔交易,而是尝试通过不同的终端连接到数据库以读取表,我们将得到一个错误,表明数据库已被锁定!当然,这是一种非常粗略的锁定方式,因为它锁定了整个数据库。由于 SQLite 在这方面比较粗略,因此它有一个模块用于优先处理事务并确保只获得最短必要的排他锁。
完成
- 这将我们带到了关于 SQL 优化的第五讲结论!
第六讲
-
简介
-
MySQL
-
创建
cards表- 问题
-
创建
stations表- 问题
-
创建
swipes表- 问题
-
修改表
-
存储过程
- 问题
-
带有参数的存储过程
-
PostgreSQL
- 问题
-
创建 PostgreSQL 表
-
使用 MySQL 进行扩展
-
访问控制
-
SQL 注入攻击
-
问题
-
Fin
简介
-
到目前为止,在本课程中,我们已经学习了如何设计和创建自己的数据库,读取和写入数据,以及最近如何优化我们的查询。现在,我们将了解如何以更大的规模来做这些事情。
-
可扩展性是增加或减少应用程序或数据库容量以满足需求的能力。
-
社交媒体平台和银行系统是可能需要随着其规模扩大和用户增加而扩展的应用程序的例子。
-
在本讲中,我们将使用不同的数据库管理系统,如 MySQL 和 PostgreSQL,这些系统可以用于扩展数据库。
-
SQLite 是一个嵌入式数据库,但 MySQL 和 PostgreSQL 是数据库服务器——它们通常运行在它们自己的专用硬件上,我们可以通过互联网连接到它们来运行我们的 SQL 查询。这使得它们能够将数据存储在 RAM 中,从而实现更快的查询。
MySQL
-
我们将使用我们在之前的讲座中使用的 MBTA 数据库。以下是一个 ER 图,显示了实体 Card、Swipe 和 Station 以及这些实体之间的关系。
![MBTA 数据库的 ER 图,包含 Card、Swipe 和 Station 实体]()
- 作为提醒,使用地铁的乘客有一个 CharlieCard,在车站刷卡以获得进入权限。乘客可以充值卡片,在某些情况下,他们还需要刷卡才能离开车站。MBTA 不存储有关乘客的信息,但只跟踪卡片。
-
我们想要使用这个模式在 MySQL 中创建一个数据库!在终端上,让我们连接到一个 MySQL 服务器。
mysql -u root -h 127.0.0.1 -P 3306 -p-
在这个终端命令中,
-u表示用户。我们提供我们想要连接到数据库的用户——root(在这种情况下与数据库管理员同义)。 -
127.0.0.1是互联网上本地主机的地址(我们的电脑)。 -
3306是我们想要连接的端口,这是 MySQL 的默认端口。将主机和端口的组合视为我们试图连接的数据库的地址! -
命令末尾的
-p表示我们希望在连接时提示输入密码。
-
-
由于这是一个完整的数据库服务器,其中可能包含许多数据库。要显示所有现有的数据库,我们使用以下 MySQL 命令。
SHOW DATABASES;这返回了一些服务器中已经存在的默认数据库。
-
我们将执行一些操作来设置 MBTA 数据库。我们已经看到了如何在 SQLite 中完成这些操作,所以让我们专注于 MySQL 的语法差异!
-
创建新数据库:
CREATE DATABASE `mbta`;我们使用反引号而不是引号来标识 SQL 语句中的表名和其他变量。
-
要将当前数据库更改为
mbta:USE `mbta`;
-
创建cards表
-
MySQL 在类型上比 SQLite 有更多的粒度。例如,一个整数可以是
TINYINT、SMALLINT、MEDIUMINT、INT或BIGINT,这取决于我们想要存储的数字的大小。以下表格显示了我们可以存储在每个整数类型中的数字的大小和范围。![MySQL 中整数类型的表格]()
这些范围假设我们想要使用有符号整数。如果我们使用无符号整数,每个整数类型可以存储的最大值将翻倍。
-
现在我们将使用
INT数据类型为 ID 列创建表cards。由于INT可以存储高达 40 亿的数字,它应该足够大,可以满足我们的使用案例!CREATE TABLE `cards` ( `id` INT AUTO_INCREMENT, PRIMARY KEY(`id`) );注意,我们使用关键字
AUTO_INCREMENT与 ID 一起使用,这样 MySQL 会自动插入下一个数字作为新行的 ID。
问题
如果 ID 列不是无符号整数怎么办?我们如何表示这一点?
- 是的,我们可以在创建整数时显式地将 ID 设置为无符号整数,通过添加关键字
UNSIGNED。
创建stations表
-
创建表后,我们可以通过运行以下命令来查看现有表的列表:
SHOW TABLES; -
要获取关于表的更多详细信息,我们可以使用
DESCRIBE命令。DESCRIBE `cards`; -
为了处理文本,MySQL 提供了许多类型。两种常用的类型是
CHAR——一个固定宽度的字符串,和VARCHAR——一个可变长度的字符串。MySQL 还有一个TEXT类型,但与 SQLite 不同,这种类型用于更长的文本块,如段落、书籍的页面等。根据文本的长度,它可以是TINYTEXT、TEXT、MEDIUMTEXT和LONGTEXT之一。此外,我们还有BLOB类型来存储二进制字符串。 -
MySQL 还提供了两种其他文本类型:
ENUM和SET。Enum 将列限制为我们提供的选项列表中的单个预定义选项。例如,衬衫尺寸可以枚举为 M、L、XL 等。Set 允许在单个单元格中存储多个选项,这在电影类型等场景中很有用。 -
现在,让我们在 MySQL 中创建
stations表。CREATE TABLE `stations` ( `id` INT AUTO_INCREMENT, `name` VARCHAR(32) NOT NULL UNIQUE, `line` ENUM('blue', 'green', 'orange', 'red') NOT NULL, PRIMARY KEY(`id`) );-
我们选择
VARCHAR作为站名,因为名字可能长度不一。然而,一个站点所在的线路是波士顿现有的地铁线路之一。由于我们知道这些值可能是什么,我们可以使用ENUM类型。 -
我们也像在 SQLite 中一样,使用列约束
UNIQUE和NOT NULL。
-
-
在运行描述此表的命令后,我们看到一个类似的输出,列出了表中的每一列。在
Key字段下,主键通过PRI被识别,任何具有唯一值的列通过UNI被识别。NULL字段告诉我们哪些列允许NULL值,对于stations表来说,没有列允许NULL值。
问题
我们能否将表作为
ENUM的输入?
- 这可能可以通过嵌套
SELECT语句来实现,但如果表中的值随时间变化,这可能不是一个好主意。最好明确地将值作为ENUM的选项。
如果我们不知道一段文本的长度,并使用类似
VARCHAR(300)来表示它,这是否可以?
- 虽然这是可以的,但这里有一个权衡。每插入一行数据,我们将失去 300 字节的内存,如果我们最终只存储非常小的字符串,这可能不值得。可能更好的是从较小的长度开始,然后在需要时更改表以增加长度。
创建swipes表
-
MySQL 为我们提供了一些存储日期和时间的选项,而 SQLite 则必须使用数值类型来存储。
-
我们可以使用
DATE、YEAR、TIME、DATETIME和TIMESTAMP(用于更精确的时间)来存储我们的日期和时间值。最后三个允许可选参数来指定我们想要存储时间的精度。 -
在 SQLite 中,我们有
REAL数据类型。在这里,我们的选项是表下面的FLOAT和DOUBLE PRECISION。![MySQL 中的实际数据类型]()
- 由于浮点数的不精确性,需要指定字节数来确定精度。这意味着在有限的内存中,浮点数只能表示到一定的精度。字节越多,表示数字的精度就越高。
-
在 MySQL 中,也有一种使用十进制(固定精度)类型的方法。使用这种方法,我们将指定要表示的数字中的位数以及小数点后的位数。
-
让我们现在创建
swipes表。CREATE TABLE `swipes` ( `id` INT AUTO_INCREMENT, `card_id` INT, `station_id` INT, `type` ENUM('enter', 'exit', 'deposit') NOT NULL, `datetime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `amount` DECIMAL(5,2) NOT NULL CHECK(`amount` != 0), PRIMARY KEY(`id`), FOREIGN KEY(`station_id`) REFERENCES `stations`(`id`), FOREIGN KEY(`card_id`) REFERENCES `cards`(`id`) );-
注意到使用
DEFAULT CURRENT_TIMESTAMP来指示如果未提供值,则应自动填充时间戳以存储当前时间。 -
我们为滑动金额选择的精度是 2。这是为了确保在没有任何舍入的情况下,可以添加或减去分。
-
我们在 SQLite 中创建表时使用的列约束仍然存在,包括确保滑动金额不是负数的检查。
-
-
如果我们在创建表后描述它,我们将看到熟悉的输出。
Key字段有一个新值,对于外键列,MUL(多个)表示它们可能有重复的值,因为它们是外键。
问题
当我们向列添加约束时,它们是否有生效的优先级?
- 不,约束以组合方式共同工作。MySQL 允许我们在创建表时以任何顺序添加约束。
MySQL 有类型亲和力吗?
- 不完全是这样。MySQL 确实有数据类型,如
INT和VARCHAR,但与 SQLite 不同,它不会允许我们输入不同类型的数据并尝试转换它。
修改表
-
MySQL 允许我们比 SQLite 更根本地修改表。
-
如果我们想要向一个站点可能所在的线路中添加一条银色线路,我们可以这样做。
ALTER TABLE `stations` MODIFY `line` ENUM('blue', 'green', 'orange', 'red', 'silver') NOT NULL;-
这允许我们修改
line列并更改其类型,使得ENUM现在包括银色作为选项。 -
还要注意,我们除了使用熟悉的 SQLite 中的
ALTER TABLE构造外,还使用了MODIFY关键字。
-
存储过程
-
存储过程是一种自动化 SQL 语句并重复运行它们的方式。
-
为了演示存储过程,我们再次使用之前讲座中提到的数据库——波士顿 MFA 数据库。
-
回想一下,我们在 SQLite 中使用视图来实现 MFA 数据库中
collections的软删除功能。一个名为current_collections的视图显示了所有未被标记为已删除的集合。现在,我们将使用 MySQL 中的存储过程来完成类似的功能。 -
让我们导航到已经在我们的 MySQL 服务器上创建的 MFA 数据库。
USE `mfa`; -
在描述
collections表时,我们看到deleted列不存在,需要添加到表中。ALTER TABLE `collections` ADD COLUMN `deleted` TINYINT DEFAULT 0;由于
deleted列只有 0 或 1 的值,使用TINYINT是安全的。我们还将其默认值设置为 0,因为我们希望保留表中已有的所有集合。 -
在我们创建存储过程之前,我们需要将分隔符从
;改为其他东西。与 SQLite 不同,在BEGIN和END(这里需要一个存储过程)之间我们可以输入多个语句,并以;结尾,而 MySQL 在遇到;时会提前结束语句。delimiter // -
现在,我们编写存储过程。
CREATE PROCEDURE `current_collection`() BEGIN SELECT `title`, `accession_number`, `acquired` FROM `collections` WHERE `deleted` = 0; END//注意我们如何在存储过程名称旁边使用空括号,这可能会让人联想到其他编程语言中的函数。与函数类似,我们也可以调用存储过程来运行它们。
-
创建此过程后,我们必须将分隔符重置为
;。delimiter ; -
让我们尝试调用这个过程来看看当前的集合。在这个时候,查询应该输出
collections表中的所有行,因为我们还没有进行软删除。CALL current_collection(); -
如果我们软删除“黎明时分工作的农民”并再次调用该过程,我们会发现已删除的行不包括在输出中。
UPDATE `collections` SET `deleted` = 1 WHERE `title` = 'Farmers working at dawn';
问题
我们能否向存储过程添加参数,即用一些输入来调用它们?
- 是的,我们可以,很快就会看到一个例子!
我们能否像函数一样从一个存储过程调用另一个存储过程?
- 是的。你可以在存储过程中放入你写的几乎所有 SQL 语句。
你能在 MySQL 的表中留下任何注释或备注吗?
- 这绝对可能是一个有用的功能!你可以在
schema.sql文件中留下注释,描述模式不同部分的意图,但也许还有在 SQL 表中添加注释的方法。
带参数的存储过程
-
在我们之前与 MFA 数据库合作时,我们有一个名为
transactions的表来记录购买的或出售的艺术品,我们也可以在这里创建它。CREATE TABLE `transactions` ( `id` INT AUTO_INCREMENT, `title` VARCHAR(64) NOT NULL, `action` ENUM('bought', 'sold') NOT NULL, PRIMARY KEY(`id`) ); -
现在,如果一件艺术品因为出售而从
collections中被删除,我们也希望更新transactions表中的这一信息。通常,这将是两个不同的查询,但通过存储过程,我们可以给这个序列一个名称。delimiter // CREATE PROCEDURE `sell`(IN `sold_id` INT) BEGIN UPDATE `collections` SET `deleted` = 1 WHERE `id` = `sold_id`; INSERT INTO `transactions` (`title`, `action`) VALUES ((SELECT `title` FROM `collections` WHERE `id` = `sold_id`), 'sold'); END// delimiter ;这个过程参数的选择是绘画或艺术品的 ID,因为它是一个唯一的标识符。
-
我们现在可以调用这个过程来出售特定的物品。假设我们想要出售“想象中的风景”。
CALL `sell`(2);我们可以显示
collections和transactions表中的数据,以验证所做的更改。 -
如果我多次调用
sell同一个 ID 会发生什么?它可能会被多次添加到transactions表中。通过使用一些常规的编程结构,存储过程在逻辑和复杂性上可以得到相当大的改进。以下列表包含了一些在 MySQL 中可用的流行结构。![MySQL 中的编程结构]()
PostgreSQL
-
到目前为止,在本讲座中,我们看到了如何使用 MySQL,这让我们能够扩展 SQLite 所能提供的能力。
-
我们现在将通过与 MySQL 相同的流程来探索 PostgreSQL 的功能。我们将使用一些现有的 SQLite 数据库并将它们转换为 PostgreSQL。
-
回到之前提到的 MBTA 数据库,它有一个名为
cards的表,让我们看看 PostgreSQL 为我们提供了哪些数据类型。- 整数
![PostgreSQL 中的整数类型]()
我们可以观察到这里的选项比 MySQL 少。PostgreSQL 也提供了无符号整数,类似于 MySQL。这意味着在处理无符号整数时,每个整数类型可以存储的最大值是这里显示的两倍。
-
序列
- 序列也是整数,但它们是序列号,通常用于主键。
-
让我们通过打开 PSQL(PostgreSQL 的命令行界面)来连接到数据库服务器。
psql postgresql://postgres@127.0.0.1:5432/postgres我们可以以默认的 Postgres 用户或管理员身份登录。
-
要查看所有数据库,我们可以运行
\l,它会弹出一个列表。 -
要创建 MBTA 数据库,我们可以运行:
CREATE DATABASE "mbta"; -
要连接到这个特定的数据库,我们可以运行
\c "mbta"。 -
要列出数据库中的所有表,我们可以运行
\dt。然而,目前数据库中还没有表。 -
最后,我们可以按照提议创建
cards表,我们为 ID 列使用SERIAL数据类型。CREATE TABLE "cards" ( "id" SERIAL, PRIMARY KEY("id") ); -
要在 PostgreSQL 中描述一个表,我们可以使用像
\d "cards"这样的命令。运行此命令后,我们会看到有关此表的一些信息,但格式与 MySQL 略有不同。
问题
你如何在 PostgreSQL 中知道你的查询是否导致错误?
- 如果你按下回车键,而数据库服务器没有显示 ptu,你就知道可能存在错误。也可能 PostgreSQL 会给你一些有用的错误消息,以帮助你找到正确的方向。
创建 PostgreSQL 表
-
stations表以类似 MySQL 的方式创建。CREATE TABLE "stations" ( "id" SERIAL, "name" VARCHAR(32) NOT NULL UNIQUE, "line" VARCHAR(32) NOT NULL, PRIMARY KEY("id") );我们可以在 PostgreSQL 中像在 MySQL 中一样使用
VARCHAR。为了使事情简单,我们可以说"line"列也是VARCHAR类型。 -
我们接下来想要创建
swipes表。回想一下,滑动类型可以标记卡的进入、退出或资金存入。类似于 MySQL,我们可以使用ENUM来捕获这些选项,但不要将其包含在列定义中。相反,我们创建自己的类型。CREATE TYPE "swipe_type" AS ENUM('enter', 'exit', 'deposit'); -
PostgreSQL 有
TIMESTAMP、DATE、TIME和INTERVAL类型来表示日期和时间值。INTERVAL用于捕获某物持续了多长时间,或时间之间的距离。类似于 MySQL,我们可以使用这些类型指定精度。 -
与 PostgreSQL 中的实数类型相比,一个关键的区别是
DECIMAL类型被称为NUMERIC。 -
我们现在可以继续创建
swipes表,如下所示。CREATE TABLE "swipes" ( "id" SERIAL, "card_id" INT, "station_id" INT, "type" "swipe_type" NOT NULL, "datetime" TIMESTAMP NOT NULL DEFAULT now(), "amount" NUMERIC(5,2) NOT NULL CHECK("amount" != 0), PRIMARY KEY("id"), FOREIGN KEY("station_id") REFERENCES "stations"("id"), FOREIGN KEY("card_id") REFERENCES "cards"("id") );对于默认的时间戳,我们使用 PostgreSQL 提供的函数
now(),它给我们当前的时戳! -
要退出 PostgreSQL,我们使用命令
\q。
使用 MySQL 进行扩展
-
考虑一个需求增长的应用程序数据库服务器。随着来自应用程序的读取和写入数量的增加,服务器处理查询的等待时间也会增加。
-
这里的一种方法是通过垂直扩展数据库。垂直扩展是通过增加数据库服务器的计算能力来增加容量。
-
另一种方法是水平扩展。这意味着通过在多个服务器之间分配负载来增加容量。当我们水平扩展时,我们在多个服务器上保留数据库的副本(复制)。
-
复制主要有三种模式:单主模式、多主模式和领导者无模式。单主复制涉及单个数据库服务器处理传入的写入,然后将这些更改复制到其他服务器,而多主复制涉及多个服务器接收更新,导致复杂性增加。领导者无模式采用完全不同的方法,不要求有领导者。
-
在这里,我们将重点关注 单主复制模式。在这个模式中,跟随数据库服务器是一个只读副本:一个只能从中读取数据的数据库副本。领导者服务器被指定处理对数据库的写入。
-
一旦领导者处理完写请求,它可以在做其他任何事情之前等待跟随者复制更改。这被称为同步复制。虽然这确保了数据库始终一致,但它可能对查询的响应速度太慢。在金融或医疗保健等数据一致性至关重要的应用程序中,我们可能会选择这种通信方式,尽管它有缺点。
-
另一种类型是异步复制,其中领导者以异步方式与跟随者数据库通信,以确保更改被复制。这种方法可以用于社交媒体应用程序,其中响应速度至关重要。
-
另一种流行的扩展方式称为分片。这涉及到将数据库分割成多个数据库服务器上的碎片。关于分片的一个注意事项:我们希望避免出现数据库热点,或者一个比其他服务器更频繁被访问的数据库服务器。这可能会给该服务器造成过载。
-
当我们不使用复制进行分片时,会出现另一个问题。在这种情况下,如果其中一个服务器宕机,我们将有一个不完整的数据库。这会创建一个单点故障:如果一个系统宕机,我们的整个系统将无法使用。
访问控制
-
之前,我们使用 root 用户登录 MySQL。然而,我们也可以创建更多用户并给他们一些数据库访问权限。
-
让我们创建一个名为 Carter 的新用户(在这里你可以尝试使用你自己的名字)!
CREATE USER 'carter' IDENTIFIED BY 'password'; -
我们现在可以使用新用户和密码登录 MySQL,就像之前使用 root 用户一样。
-
当我们创建这个新用户时,默认情况下它只有很少的权限。尝试以下查询。
SHOW DATABASES;这只显示了服务器中的一些默认数据库。
-
如果我们再次以 root 用户登录并运行上述查询,会出现更多的数据库!这是因为 root 用户可以访问服务器上的几乎所有内容。
-
让我们通过讨论上周的一个例子来探讨如何通过用户授权来授予用户访问权限。我们有一个
rideshare数据库和一个rides表。在这个表中,我们存储了乘客的名字,这是个人身份信息(PII)。我们创建了一个名为analysis的视图,匿名化了乘客的名字,目的是只与分析师或其他用户共享这个视图。 -
如果我们想与刚刚创建的用户共享
analysis视图,我们可以在以 root 用户登录时执行以下操作。GRANT SELECT ON `rideshare`.`analysis` TO 'carter'; -
现在,让我们以新用户身份登录并验证我们是否可以访问视图。我们现在能够运行
USE `rideshare`; -
然而,这个用户可以访问的数据库部分只有
analysis视图。我们现在可以看到这个视图中的数据,但不能从原始的rides表中看到!我们刚刚展示了 MySQL 访问控制的好处:我们可以让多个用户访问数据库,但只允许一些用户访问机密数据。
SQL 注入攻击
-
提高我们数据库安全性的方法之一是使用访问控制和仅向每个用户授予必要的权限。然而,使用 SQL 数据库的应用程序也可能受到攻击——其中之一就是 SQL 注入攻击。
-
正如其名所示,这涉及到一个恶意用户注入一些 SQL 短语,以在我们的应用程序中以不希望的方式完成现有查询。
-
例如,一个要求用户使用用户名和密码登录的网站可能在数据库上运行如下查询。
SELECT `id` FROM `users` WHERE `user` = 'Carter' AND `password` = 'password'; -
在上面的例子中,用户 Carter 像往常一样输入了他们的用户名和密码。然而,一个恶意用户可能会输入不同的内容,比如字符串“password’ OR ‘1’ = 1”作为他们的密码。在这种情况下,他们试图获取用户和密码的整个数据库的访问权限。
SELECT `id` FROM `users` WHERE `user` = 'Carter' AND `password` = 'password' OR '1' = '1'; -
在 MySQL 中,我们可以使用预编译语句来防止 SQL 注入攻击。让我们用之前创建的用户连接到 MySQL 并切换到
bank数据库。 -
一个可以运行的 SQL 注入攻击示例,可以用来显示
accounts表中的所有用户账户。SELECT * FROM `accounts` WHERE `id` = 1 UNION SELECT * FROM `accounts`; -
预编译语句是 SQL 中的一个语句,我们可以在稍后插入值。对于上面的查询,我们可以编写一个预编译语句。
PREPARE `balance_check` FROM 'SELECT * FROM `accounts` WHERE `id` = ?';预编译语句中的问号充当防止意外执行 SQL 代码的安全措施。
-
要实际运行这个语句并检查某人的余额,我们接受用户输入作为变量,然后将其插入到预编译语句中。
SET @id = 1; EXECUTE `balance_check` USING @id;在上面的代码中,想象一下
SET语句是通过应用程序获取用户的 ID!@是 MySQL 中变量的约定。 -
预编译语句清理输入以确保没有恶意 SQL 代码被注入。让我们尝试运行上面相同的语句,但使用一个恶意的 ID。
SET @id = '1 UNION SELECT * FROM `accounts`'; EXECUTE `balance_check` USING @id;这也给出了与之前代码相同的结果——它显示了 ID 为 1 的用户的余额,没有其他内容!因此,我们已经防止了可能的 SQL 注入攻击。
问题
在这个预编译语句的例子中,它是否只考虑了变量中的第一个条件?
- 预编译语句执行一种称为转义的操作。它找到变量中可能恶意的所有部分,并将它们转义,这样它们实际上就不会被执行。
这是否与我们在 Python 中执行 SQL 查询时不应该使用格式化字符串的原因相似?
- 是的,Python 中的格式字符串也有同样的陷阱,它们容易受到 SQL 注入攻击。
结束
- 这把我们带到了第六讲关于 SQL 缩放和这门课程——CS50 的 SQL 数据库入门——的结论!
网络
第零讲
-
简介
-
Web 编程
-
HTML(超文本标记语言)
-
文档对象模型 (DOM)
-
更多 HTML 元素
-
表单
-
-
CSS(层叠样式表)
-
响应式设计
-
Bootstrap
-
Sass(语法上出色的样式表)
简介
在本课程中,我们将从 CS50 停止的地方继续,深入到网络应用程序的设计和创建。我们将通过在课程中进行多个项目的工作来培养我们的网页设计技能,包括一个开放式的最终项目,您将有机会创建自己的网站!
在本课程中,您需要一个文本编辑器,您可以在计算机上本地编写代码。一些流行的选择包括 Visual Studios Code、Sublime Text、Atom 和 Vim,但可供选择还有很多!
Web 编程
课程主题: 我们将在稍后进行更详细的介绍,但以下是本课程我们将要工作的简要概述:
-
HTML 和 CSS(一种用于概述网页的标记语言,以及使我们的网站更具视觉吸引力的方法)
-
Git(用于版本控制和协作)
-
Python(一种广泛使用的编程语言,我们将用它来使我们的网站更具动态性)
-
Django(我们将用于网站后端的流行网络框架)
-
SQL、模型和迁移(一种用于存储和检索数据的语言,以及使与 SQL 数据库交互更简单的 Django 特定方法)
-
JavaScript(一种用于使网站更快、更互动的编程语言)
-
用户界面(用于使网站尽可能易于使用的各种方法)
-
测试、CI、CD(了解用于确保网页更新顺利进行的各种方法)
-
可扩展性和安全性(确保我们的网站可以同时被许多用户访问,并且它们免受恶意意图的侵害)
HTML(超文本标记语言)
-
HTML 是一种标记语言,用于定义网页的结构。它由您的网页浏览器(Safari、Google Chrome、Firefox 等)解释,以便在屏幕上显示内容。
-
让我们从编写一个简单的 HTML 文件开始吧!
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hello!</title>
</head>
<body>
Hello, world!
</body>
<html>
- 当我们在浏览器中打开这个文件时,我们得到:

-
现在,让我们花一些时间来谈谈我们刚刚编写的文件,它对于一个如此简单的页面来说似乎相当复杂。
-
在第一行,我们正在声明(对浏览器来说)我们正在使用 HTML 的最新版本:HTML5。
-
之后,页面由嵌套的HTML 元素(如
html和body)组成,每个元素都有一个开始和结束标签,分别用<element>表示开始和</element>表示结束。 -
注意到每个内部元素都比上一个元素缩进得更深一些。虽然浏览器不一定要求这样做,但这在您的代码中保持这种缩进将非常有帮助。
-
HTML 元素可以包括属性,这些属性为浏览器提供了关于元素的额外信息。例如,当我们把
lang="en"包含在我们的初始标签中时,我们是在告诉浏览器我们正在使用英语作为我们的主要语言。 -
在 HTML 元素内部,我们通常希望包含一个
head标签和一个body标签。head标签将包含关于您页面的信息,这些信息不一定显示,而body标签将包含访问网站的用户实际看到的内容。 -
在
head内部,我们为我们的网页添加了一个title,您会注意到它显示在浏览器顶部的标签上。 -
最后,我们在主体中包含了文本“Hello, world!”,这是页面的可见部分。
-
文档对象模型 (DOM)

- DOM 是一种方便的方式来使用树状结构可视化 HTML 元素之间的关系。上面是我们刚刚编写的页面的 DOM 布局示例。
更多 HTML 元素
-
您可能想要使用许多 HTML 元素来自定义您的页面,包括标题、列表和加粗部分。在接下来的示例中,我们将看到其中的一些实际应用。
-
另一点需要注意的是:
<!-- -->在 HTML 中提供了注释,因此我们将在下面使用它来解释一些元素。
<!DOCTYPE html>
<html lang="en">
<head>
<title>HTML Elements</title>
</head>
<body>
<!-- We can create headings using h1 through h6 as tags. -->
<h1>A Large Heading</h1>
<h2>A Smaller Heading</h2>
<h6>The Smallest Heading</h6>
<!-- The strong and i tags give us bold and italics respectively. -->
A <strong>bold</strong> word and an <i>italicized</i> word!
<!-- We can link to another page (such as cs50's page) using a. -->
View the <a href="https://cs50.harvard.edu/">CS50 Website</a>!
<!-- We used ul for an unordered list and ol for an ordered one. both ordered and unordered lists contain li, or list items. -->
An unordered list:
<ul>
<li>foo</li>
<li>bar</li>
<li>baz</li>
</ul>
An ordered list:
<ol>
<li>foo</li>
<li>bar</li>
<li>baz</li>
</ol>
<!-- Images require a src attribute, which can be either the path to a file on your computer or the link to an image online. It also includes an alt attribute, which gives a description in case the image can't be loaded. -->
An image:
<img src="../../images/duck.jpeg" alt="Rubber Duck Picture">
<!-- We can also see above that for some elements that don't contain other ones, closing tags are not necessary. -->
<!-- Here, we use a br tag to add white space to the page. -->
<br/> <br/>
<!-- A few different tags are necessary to create a table. -->
<table>
<thead>
<th>Ocean</th>
<th>Average Depth</th>
<th>Maximum Depth</th>
</thead>
<tbody>
<tr>
<td>Pacific</td>
<td>4280 m</td>
<td>10911 m</td>
</tr>
<tr>
<td>Atlantic</td>
<td>3646 m</td>
<td>8486 m</td>
</tr>
</tbody>
</table>
</body>
<html>
当这个页面渲染时,看起来就像这样:

- 如果您对此感到担忧,请知道您永远不需要记住这些元素。简单地搜索“HTML 中的图片”就能找到
img标签,这非常容易。学习这些元素的一个特别有用的资源是W3 Schools。
表单
-
在创建网站时,另一组非常重要的元素是如何从用户那里收集信息。您可以使用 HTML 表单允许用户输入信息,该表单可以包含几种不同类型的输入。在课程的后期,我们将学习如何处理表单提交后的信息。
-
正如其他 HTML 元素一样,您不需要记住这些,W3 Schools 是学习这些元素的一个很好的资源!
<!DOCTYPE html>
<html lang="en">
<head>
<title>Forms</title>
</head>
<body>
<form>
<input type="text" placeholder="First Name" name="first">
<input type="password" placeholder="Password" name="password">
<div>
Favorite Color:
<input name="color" type="radio" value="blue"> Blue
<input name="color" type="radio" value="green"> Green
<input name="color" type="radio" value="yellow"> Yellow
<input name="color" type="radio" value="red"> Red
</div>
<input type="submit">
</form>
</body>
</html>

CSS(层叠样式表)
-
CSS 用于自定义网站的样式。
-
在我们刚开始的时候,我们可以向任何 HTML 元素添加一个 style 属性,以便将其应用于一些 CSS。
-
我们通过改变元素的 CSS 属性来更改样式,例如写
color: blue或text-align: center。 -
在下面的示例中,我们对我们的第一个文件进行了一些微小的修改,以使其标题更加多彩:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hello!</title>
</head>
<body>
<h1 style="color: blue; text-align: center;">A Colorful Heading!</h1>
Hello, world!
</body>
<html>

- 如果我们为一个外部元素进行样式设置,所有内部元素将自动采用该样式。如果我们把刚才应用在标题标签上的样式移动到
body标签上,我们就可以看到这一点:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hello!</title>
</head>
<body style="color: blue; text-align: center;">
<h1 >A Colorful Heading!</h1>
Hello, world!
</body>
<html>

-
虽然我们可以像上面那样为我们的网页进行样式设置,但要实现更好的设计,我们应该能够将样式从单个行中移除。
- 一种方法是在
head中的<style>标签之间添加你的样式。在这些标签内部,我们写上我们想要样式的元素类型以及我们希望应用给它们的样式。例如:
<html lang="en"> <!DOCTYPE html> <head> <title>Hello!</title> <style> h1 { color: blue; text-align: center; } </style> </head> <body> <h1 >A Colorful Heading!</h1> Hello, world! </body> </html>- 另一种方法是在
head中包含一个指向包含一些样式的styles.css文件的<link>元素。这意味着 HTML 文件将看起来像这样:
<html lang="en"> <!DOCTYPE html> <head> <title>Hello!</title> <link rel="stylesheet" href="styles.css"> </head> <body> <h1 >A Colorful Heading!</h1> Hello, world! </body> </html>我们名为
styles.css的文件将看起来像这样:h1 { color: blue; text-align: center; } - 一种方法是在
-
这里的 CSS 属性太多,无法一一介绍,但就像 HTML 元素一样,通常很容易在 Google 上搜索类似“将字体改为蓝色 CSS”的内容来获取结果。其中一些最常见的是:
-
color: 文本的颜色 -
text-align: 元素在页面上的放置位置 -
background-color: 可以设置为任何颜色 -
width: 以像素或页面百分比为单位 -
height: 以像素或页面百分比为单位 -
padding: 在元素内部应留出多少空间 -
margin: 在元素外部应留出多少空间 -
font-family: 页面上文本的字体类型 -
font-size: 以像素为单位 -
border: 大小、类型(实线、虚线等)和颜色
-
-
让我们利用我们刚刚学到的知识来改进上面的海洋表格。以下是一些 HTML 代码作为起点:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Nicer Table</title>
</head>
<body>
<table>
<thead>
<th>Ocean</th>
<th>Average Depth</th>
<th>Maximum Depth</th>
</thead>
<tbody>
<tr>
<td>Pacific</td>
<td>4280 m</td>
<td>10911 m</td>
</tr>
<tr>
<td>Atlantic</td>
<td>3646 m</td>
<td>8486 m</td>
</tr>
</tbody>
</table>
</body>
<html>

- 上面看起来和之前我们有的很相似,但现在,通过在头部元素中包含一个
style标签或一个指向样式表的link,我们添加以下 CSS:
table {
border: 1px solid black;
border-collapse: collapse;
}
td {
border: 1px solid black;
padding: 2px;
}
th {
border: 1px solid black;
padding: 2px;
}
这使我们得到了这个看起来更漂亮的表格:

- 你可能已经想到了,我们当前的 CSS 中存在一些不必要的重复,因为
td和th具有相同的样式。我们可以(并且应该)将其压缩为以下代码,使用逗号来表示样式应应用于多个元素类型。
table {
border: 1px solid black;
border-collapse: collapse;
}
td, th {
border: 1px solid black;
padding: 2px;
}
-
这是对所谓的CSS 选择器的良好介绍。有许多方法可以确定你正在样式的 HTML 元素,其中一些我们将在下面提到:
-
元素类型:这是我们迄今为止一直在做的事情:为相同类型的所有元素进行样式设置。
-
ID: 另一个选择是给我们的 HTML 元素一个 ID,如下所示:
<h1 id="first-header">Hello!</h1>然后使用#first-header{...}通过使用井号来显示我们正在通过 ID 进行搜索。重要的是,没有两个元素可以有相同的 ID,并且没有元素可以有多个 ID。 -
类: 这与 ID 类似,但类可以被多个元素共享,并且单个元素可以有多个类。我们像这样给 HTML 元素添加类:
<h1 class="page-text muted">Hello!</h1>(注意我们只给元素添加了两个类:page-text和muted)。然后我们根据类使用点而不是井号进行样式化:.muted {...}
-
-
现在,我们还得处理可能冲突的 CSS 问题。当标题应该根据其类名是红色,但根据其 ID 是蓝色时会发生什么?CSS 有一个特定的顺序:
-
内联样式
-
id
-
class
-
元素类型
-
-
除了逗号用于多个选择器之外,还有几种其他方式可以指定您想要样式的元素。这个来自讲座的表格提供了一些,我们将在下面通过几个示例进行说明:

后代选择器: 在这里,我们使用后代选择器来仅对位于无序列表中的列表项应用样式:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Using Selectors</title>
<style>
ul li {
color: blue;
}
</style>
</head>
<body>
<ol>
<li>foo</li>
<li> bar
<ul>
<li>hello</li>
<li>goodbye</li>
<li>hello</li>
</ul>
</li>
<li>baz</li>
</ol>
</body>
<html>

属性作为选择器: 我们还可以根据分配给 HTML 元素的属性来缩小我们的选择范围,使用方括号。例如,在以下链接列表中,我们选择仅使指向亚马逊的链接变红:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Using Selectors</title>
<style>
a[href="https://www.amazon.com/"] {
color: red;
}
</style>
</head>
<body>
<ol>
<li><a href="https://www.google.com/">Google</a></li>
<li><a href="https://www.amazon.com/">Amazon</a> </li>
<li><a href="https://www.facebook.com/">Facebook</a></li>
</ol>
</body>
<html>

-
我们不仅可以使用 CSS 永久改变元素的外观,还可以改变其在特定条件下的外观。例如,如果我们想让按钮在鼠标悬停时改变颜色怎么办?我们可以通过使用CSS 伪类来实现这一点,它会在特殊情况下提供额外的样式。我们通过在选择器后添加冒号,然后在该冒号后添加情况来实现这一点。
-
在按钮的情况下,我们会在按钮选择器中添加
:hover以指定仅在悬停时的设计:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Pseudoclasses</title>
<style>
button {
background-color: red;
width: 200px;
height: 50px;
font-size: 24px;
}
button:hover {
background-color: green;
}
</style>
</head>
<body>
<button>Button 1</button>
<button>Button 2</button>
<button>Button 3</button>
</body>
<html>

响应式设计
-
现在,许多人使用除电脑以外的设备浏览网站,例如智能手机和平板电脑。确保您的网站对所有设备上的用户都是可读的非常重要。
-
我们可以通过了解视口来实现这一点。视口是屏幕上在任何给定时间内对用户实际可见的部分。默认情况下,许多网页假设视口在所有设备上都是相同的,这就是导致许多网站(尤其是较老的网站)在移动设备上难以交互的原因。
-
在移动设备上改善网站外观的一个简单方法是在我们 HTML 文件的头部添加以下行。这一行告诉移动设备使用一个与您使用的设备相同宽度的视口,而不是一个更大的视口。
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
另一种处理不同设备的方法是通过媒体查询。媒体查询是一种根据页面如何被查看来改变页面样式的方法。
-
以下是一个媒体查询的例子,让我们尝试在屏幕缩小到一定大小时简单地改变屏幕颜色。我们通过输入
@media后跟括号中的查询类型来表示媒体查询:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Screen Size</title>
<style>
@media (min-width: 600px) {
body {
background-color: red;
}
}
@media (max-width: 599px) {
body {
background-color: blue;
}
}
</style>
</head>
<body>
<h1>Welcome to the page!</h1>
</body>
</html>

- 处理不同屏幕尺寸的另一种方法是使用一个新的 CSS 属性,称为flexbox。这允许我们在元素水平方向上不适应时轻松地将它们包裹到下一行。我们通过将所有元素放入一个我们称之为容器的
div中来实现这一点。然后我们添加一些样式到这个div中,指定我们想要在其中的元素上使用 flexbox 显示。我们还添加了一些额外的样式到内部div中,以更好地说明这里发生的包裹。
<!DOCTYPE html>
<html lang="en">
<head>
<title>Screen Size</title>
<style>
#container {
display: flex;
flex-wrap: wrap;
}
#container > div {
background-color: green;
font-size: 20px;
margin: 20px;
padding: 20px;
width: 200px;
}
</style>
</head>
<body>
<div id="container">
<div>Some text 1!</div>
<div>Some text 2!</div>
<div>Some text 3!</div>
<div>Some text 4!</div>
<div>Some text 5!</div>
<div>Some text 6!</div>
<div>Some text 7!</div>
<div>Some text 8!</div>
<div>Some text 9!</div>
<div>Some text 10!</div>
<div>Some text 11!</div>
<div>Some text 12!</div>
</div>
</body>
</html>

- 另一种流行的页面样式方法是使用 HTML 网格。在这个网格中,我们可以指定样式属性,如列宽和列与行之间的间隙,如下所示。注意,当我们指定列宽时,我们说第三个是
auto,这意味着它应该填充页面的剩余部分。
<!DOCTYPE html>
<html lang="en">
<head>
<title>My Web Page!</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
.grid {
background-color: green;
display: grid;
padding: 20px;
grid-column-gap: 20px;
grid-row-gap: 10px;
grid-template-columns: 200px 200px auto;
}
.grid-item {
background-color: white;
font-size: 20px;
padding: 20px;
text-align: center;
}
</style>
</head>
<body>
<div class="grid">
<div class="grid-item">1</div>
<div class="grid-item">2</div>
<div class="grid-item">3</div>
<div class="grid-item">4</div>
<div class="grid-item">5</div>
<div class="grid-item">6</div>
<div class="grid-item">7</div>
<div class="grid-item">8</div>
<div class="grid-item">9</div>
<div class="grid-item">10</div>
<div class="grid-item">11</div>
<div class="grid-item">12</div>
</div>
</body>
</html>

Bootstrap
-
结果表明,有许多库是其他人已经编写的,可以使网页的样式化更加简单。在本课程中,我们将使用的一个流行库被称为bootstrap。
-
我们可以通过在 HTML 文件的头部添加一行来将 Bootstrap 包含到我们的代码中:
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css" integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
-
接下来,我们可以通过导航到他们网站的文档部分来查看一些 Bootstrap 的特性。在这个页面上,你会找到许多可以添加到元素上的类,这些类允许使用 Bootstrap 进行样式化。
-
Bootstrap 的一个流行特性是它们的网格系统。Bootstrap 自动将页面分成 12 列,我们可以通过添加类
col-x(其中x是 1 到 12 之间的数字)来决定一个元素占用多少列。例如,在以下页面中,我们有一行等宽的列,然后是一行中间的列更宽:
<!DOCTYPE html>
<html lang="en">
<head>
<title>My Web Page!</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<style>
.row > div {
padding: 20px;
background-color: teal;
border: 2px solid black;
}
</style>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-4">
This is a section.
</div>
<div class="col-4">
This is another section.
</div>
<div class="col-4">
This is a third section.
</div>
</div>
</div>
<br/>
<div class="container">
<div class="row">
<div class="col-3">
This is a section.
</div>
<div class="col-6">
This is another section.
</div>
<div class="col-3">
This is a third section.
</div>
</div>
</div>
</body>
</html>

- 为了提高移动端的响应性,Bootstrap 还允许我们根据屏幕大小指定不同的列宽。在下面的示例中,我们使用
col-lg-3来表示在大型屏幕上元素应占用 3 列,而col-sm-6表示在屏幕较小时元素应占用 6 列:
<!DOCTYPE html>
<html lang="en">
<head>
<title>My Web Page!</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<style>
.row > div {
padding: 20px;
background-color: teal;
border: 2px solid black;
}
</style>
</head>
<body>
<div class="container">
<div class="row">
<div class="col-lg-3 col-sm-6">
This is a section.
</div>
<div class="col-lg-3 col-sm-6">
This is another section.
</div>
<div class="col-lg-3 col-sm-6">
This is a third section.
</div>
<div class="col-lg-3 col-sm-6">
This is a fourth section.
</div>
</div>
</div>
</body>
</html>

Sass(Syntactically Awesome Style Sheets)
-
到目前为止,我们已经找到了几种消除 CSS 中冗余的方法,例如将其移动到单独的文件或使用 Bootstrap,但仍有一些地方我们可以进行改进。例如,如果我们想让几个元素有不同的样式,但所有元素的颜色都相同,怎么办?如果我们后来决定要更改颜色,那么我们就需要在几个不同的元素中更改它。
-
Sass 是一种语言,它以多种方式使我们能够更有效地编写 CSS,其中之一就是允许我们使用变量,如下面的示例所示。
-
当使用 Sass 编写代码时,我们创建一个扩展名为
filename.scss的新文件。在这个文件中,我们可以通过在名称前添加一个$符号,然后是一个冒号,再然后是一个值来创建一个新的变量。例如,我们可以写$color: red来设置变量color的值为红色。然后我们使用$color来访问这个变量。以下是我们variables.scss文件的一个示例:
$color: red;
ul {
font-size: 14px;
color: $color;
}
ol {
font-size: 18px;
color: $color;
}
-
现在,为了将这种样式链接到我们的 HTML 文件,我们不能只是链接到
.scss文件,因为大多数网络浏览器只识别.css文件。为了解决这个问题,我们必须在我们的计算机上下载一个名为 Sass 的程序。下载 Sass。然后,在我们的终端中,我们输入sass variables.scss:variables.css命令。这个命令会将名为variables.scss的 .scss 文件编译成名为variables.css的 .css 文件,你可以在你的 HTML 页面中添加对该文件的链接。 -
为了加快这个流程,我们可以使用命令
sass --watch variables.scss:variables.css,这个命令会在检测到.scss文件中的更改时自动更改.css文件。 -
在使用 Sass 的同时,我们还可以物理地嵌套我们的样式,而不是使用我们之前提到的 CSS 选择器。例如,如果我们只想将一些样式应用于 div 中的段落和无序列表,我们可以编写以下代码:
div {
font-size: 18px;
p {
color: blue;
}
ul {
color: green;
}
}
一旦编译成 CSS,我们就会得到一个看起来像这样的文件:
div {
font-size: 18px;
}
div p {
color: blue;
}
div ul {
color: green;
}
- Sass 给我们的另一个特性被称为 继承。这允许我们创建一组基本的样式,可以被多个不同的元素共享。我们通过在类名前添加一个
%符号,添加一些样式,然后在某些样式的开头添加一行@extend %classname来实现这一点。例如,以下代码将message类内的样式应用于下面的每个不同类,从而生成一个看起来像下面的网页。
%message {
font-family: sans-serif;
font-size: 18px;
font-weight: bold;
border: 1px solid black;
padding: 20px;
margin: 20px;
}
.success {
@extend %message;
background-color: green;
}
.warning {
@extend %message;
background-color: orange;
}
.error {
@extend %message;
background-color: red;
}

- 今天的内容就到这里了!
第一讲
-
介绍
-
Git
-
GitHub
-
提交
-
合并冲突
-
分支
- 更多 GitHub 功能
介绍
欢迎回到第一讲!在第 0 讲中,我们介绍了 HTML、CSS 和 Sass 作为我们可以用来创建一些基本网页的工具。今天,我们将学习如何使用 Git 和 GitHub 来帮助我们开发网络编程应用。
Git
-
Git 是一个命令行工具,它将以多种方式帮助我们进行版本控制:
- 允许我们通过在特定时间点保存代码的快照来跟踪我们对代码所做的更改。
![更改文件]()
- 允许我们通过允许多个人从存储在网上的仓库中拉取信息和向仓库推送信息,轻松地在不同的人之间同步代码。
![多用户]()
-
允许我们在不影响主代码库的情况下,在不同的 分支 上进行代码更改和测试,然后将两个分支合并在一起。
-
允许我们在意识到我们犯了一个错误后,将代码回滚到之前的版本。
-
在上述解释中,我们使用了“仓库”这个词,我们还没有解释过。Git 仓库是一个文件位置,我们将存储与特定项目相关的所有文件。这些可以是远程的(存储在线上)或本地的(存储在你的电脑上)。
GitHub
-
GitHub 是一个网站,允许我们在网上远程存储 Git 仓库。
-
让我们从创建一个在线新仓库开始
-
确保你已经设置了 GitHub 账户。如果你还没有,你可以在 这里 创建一个。
-
点击右上角的 + 按钮,然后点击“新建仓库”
-
创建一个描述你项目的仓库名称
-
(可选)为你的仓库提供描述
-
选择仓库应该是公开的(对任何在网络上的人可见)还是私有的(仅对你和你特别授权的人可见)
-
(可选)决定你是否想添加一个 README 文件,这是一个描述你的新仓库的文件。
![新仓库演示]()
-
-
一旦我们有了仓库,我们可能还想向其中添加一些文件。为了做到这一点,我们将创建一个新的 远程 仓库的副本,或者克隆,将其作为我们电脑上的 本地 仓库。
-
通过在终端中输入
git来确保你的电脑上已安装 git。如果没有安装,你可以从这里下载它 这里。 -
点击你仓库页面上的绿色“克隆或下载”按钮,并复制弹出的 url。如果你没有创建一个 README,这个链接将出现在页面顶部的“快速设置”部分。
![克隆和添加]()
-
在你的终端中,运行
git clone <repository url>。这将把仓库下载到你的电脑上。如果你没有创建一个 README 文件,你会收到警告:You appear to have cloned into an empty repository.这是正常的,无需为此担心。![克隆演示]()
-
运行
ls,这是一个列出你当前目录中所有文件和文件夹的命令。你应该能看到你刚刚克隆的仓库的名称。 -
运行
cd <repository name>来更改目录到那个文件夹。 -
运行
touch <new file name>在该文件夹中创建一个新文件。你现在可以编辑该文件。或者,你也可以在你的文本编辑器中打开该文件夹并手动添加新文件。 -
现在,为了让 Git 知道它应该跟踪你新创建的文件,运行
git add <new file name>来跟踪那个特定的文件,或者运行git add .来跟踪该目录下的所有文件。![同一时间]()
-
提交
-
现在,我们将开始了解 Git 真正有用的功能。在修改一个文件后,我们可以 提交 这些更改,对代码的当前状态进行快照。为此,我们运行:
git commit -m "some message",其中消息描述了你刚刚所做的更改。 -
在此更改之后,我们可以运行
git status来查看我们的代码与远程仓库上的代码如何比较。 -
当我们准备好将本地提交发布到 GitHub 时,我们可以运行
git push。现在,当我们用网络浏览器访问 GitHub 时,我们的更改将会反映出来。 -
如果你只修改了现有文件而没有创建新文件,我们不需要使用
git add .然后执行git commit...,我们可以将这压缩成一个命令:git commit -am "some message"。这个命令将提交你做的所有更改。 -
有时候,GitHub 上的远程仓库可能比本地版本更新。在这种情况下,你首先需要提交任何更改,然后运行
git pull将任何远程更改拉到你的仓库中。
合并冲突
-
当使用 Git 工作时,特别是当你与其他人协作时,可能会出现一个称为 合并冲突 的问题。合并冲突发生在两个人试图以相互冲突的方式更改文件时。
-
这通常发生在你执行
git push或git pull时。当这种情况发生时,Git 会自动将文件转换为一种格式,清楚地说明冲突是什么。以下是一个示例,其中相同的行以两种不同的方式被添加:
a = 1
<<<<< HEAD
b = 2
=====
b = 3
>>>>> 56782736387980937883
c = 3
d = 4
e = 5
-
在上面的例子中,你添加了
b = 2这一行,而另一个人写下了b = 3,现在我们必须选择其中一个来保留。这个长数字是一个 哈希,它代表与你的编辑冲突的提交。许多文本编辑器也会提供高亮显示和简单的选项,例如“接受当前”或“接受传入”,这可以节省你删除上面添加的行的时间。 -
另一个可能很有用的 git 命令是
git log,它为你提供了在该仓库上所有提交的历史记录。

-
如果你意识到你犯了一个错误,你可以使用命令
git reset以两种方式之一回滚到之前的提交:-
git reset --hard <commit>将你的代码回滚到指定提交后的确切状态。要指定提交,请使用与提交关联的提交哈希,这可以通过如上所示的git log查找。 -
git reset --hard origin/master将你的代码回滚到目前在 Github 上存储的版本。
-
分支
在你为一个项目工作了一段时间之后,你可能决定想要添加一个额外的功能。目前,我们可能只是像下面图形所示那样提交这个新功能的更改。

但如果我们随后发现原始代码中有一个错误,并想要回滚而不更改新功能,这可能会变得有问题。这就是分支变得非常有用的地方。
- 分支是在创建新功能时转向新方向的一种方法,一旦完成,只将这个新功能与你的代码的主要部分或主分支结合起来。这个工作流程看起来更像是下面的图形:

-
你目前正在查看的分支是由
HEAD决定的,它指向两个分支中的一个。默认情况下,HEAD指向主分支,但我们可以检出其他分支。 -
现在,让我们深入了解如何在我们的 git 仓库中实际实现分支:
- 运行
git branch来查看你目前正在工作的分支,它在其名称左侧会有一个星号。
![分支终端]()
- 要创建一个新分支,我们将运行
git checkout -b <新分支名称>
![新分支]()
-
使用命令
git checkout <分支名称>在分支之间切换,并对每个分支提交任何更改。 -
当我们准备好合并两个分支时,我们将检出我们希望保留的分支(几乎总是主分支),然后运行命令
git merge <其他分支名称>。这将被处理得类似于推送或拉取,并且可能会出现合并冲突。
- 运行
更多 GitHub 功能
有一些特定于 GitHub 的有用功能,可以在你工作在项目时提供帮助:
-
Forking:作为一个 GitHub 用户,你有权限“Fork”任何你能够访问的仓库,这将创建一个属于你的仓库的副本。我们通过点击右上角的“Fork”按钮来完成这个操作。
-
Pull Requests:一旦你 fork 了一个仓库并对你的版本进行了更改,你可能希望请求将这些更改添加到仓库的主版本中。例如,如果你想向 Bootstrap 添加一个新功能,你可以 fork 仓库,进行一些更改,然后提交一个 pull request。这个 pull request 然后可以被 Bootstrap 仓库的管理人员评估,并可能被接受。人们进行一些编辑然后请求将它们合并到主仓库的过程对于所谓的“开源软件”至关重要,或者说是由多个开发者的贡献创建的软件。
-
GitHub Pages:GitHub Pages 是一种简单的方式来将静态站点发布到网络上。(我们稍后会学习静态站点与动态站点的区别。)为了做到这一点:
-
创建一个新的 GitHub 仓库。
-
克隆仓库并在本地进行更改,确保包含一个
index.html文件,这将是你网站的着陆页。 -
将这些更改推送到 GitHub。
-
导航到你的仓库的设置页面,滚动到 GitHub Pages,并在下拉菜单中选择 master 分支。
-
滚动到设置页面中的 GitHub Pages 部分,几分钟后,你应该会看到一个通知,显示“您的站点已发布在:…”,包括一个你可以找到你站点的 URL!
-
这节课的内容就到这里!下次,我们将探讨 Python!
第二讲
-
简介
-
Python
-
变量
-
字符串格式化
-
条件
-
序列
-
字符串
-
列表
-
元组
-
集合
-
字典
-
循环
-
-
函数
-
模块
-
面向对象编程
-
函数式编程
-
装饰器
-
Lambda 函数
-
-
异常
简介
-
到目前为止,我们已经讨论了如何使用 HTML 和 CSS 构建简单的网页,以及如何使用 Git 和 GitHub 来跟踪代码的更改并与他人协作。
-
今天,我们将深入探讨 Python,这是我们将在整个课程中使用的两种主要编程语言之一。
Python

-
Python 是一种非常强大且广泛使用的语言,它将使我们能够快速构建相当复杂的网络应用程序。在本课程中,我们将使用 Python 3,尽管 Python 2 在某些地方仍在使用。在查看外部资源时,请务必确保它们使用的是同一版本。
-
让我们从许多编程语言开始的地方开始:Hello, world。这个用 Python 编写的程序看起来是这样的:
print("Hello, world!")
-
要分解那行代码中发生的事情,Python 语言内置了一个名为
print的函数,它接受括号中的参数,并在命令行上显示该参数。 -
要在实际的计算机上编写和运行此程序,你首先需要将此行输入你选择的文本编辑器中,然后将文件保存为
something.py。接下来,你将前往终端,导航到包含你的文件的目录,并输入python something.py。在上面的程序中,单词“Hello, world!”将随后在终端中显示。 -
根据你的计算机配置,你可能需要在文件名前输入
python3而不是python,如果你还没有安装 Python,你可能甚至需要下载 Python。安装 Python 后,我们建议你还要下载 Pip,因为你在课程中稍后需要它。 -
当你在终端中输入
python file.py时,一个名为解释器的程序(你与 Python 一起下载的)会逐行读取你的文件,并执行每一行代码。这与像C或Java这样的语言不同,这些语言在运行之前需要编译成机器代码。
变量
任何编程语言的关键部分都是创建和操作变量的能力。为了在 Python 中给变量赋值,其语法看起来像这样:
a = 28
b = 1.5
c = "Hello!"
d = True
e = None
每一行都将=右侧的值取出来,并存储在左侧的变量名中。
与某些其他编程语言不同,Python 变量类型是推断的,这意味着虽然每个变量都有一个类型,但我们创建变量时不必明确声明它是哪种类型。最常见的变量类型包括:
-
int: 一个整数
-
float: 一个十进制数
-
str: 一个字符串,或字符序列
-
bool: 一个值为
True或False的值 -
NoneType: 表示没有值的一个特殊值(
None)。
现在,我们将编写一个更有趣的程序,可以从用户那里获取输入并问候该用户。为此,我们将使用另一个内置函数input,它向用户显示一个提示,并返回用户提供的任何输入。例如,我们可以在名为name.py的文件中编写以下内容:
name = input("Name: ")
print("Hello, " + name)
当在终端上运行时,程序看起来是这样的:

在这里有一些要点需要指出:
-
在第一行中,我们不是将变量名赋给一个显式的值,而是将其赋给
input函数返回的任何值。 -
在第二行中,我们使用
+运算符来组合,或连接两个字符串。在 Python 中,+运算符可以用来加数字或连接字符串和列表。
格式化字符串
-
虽然我们可以使用
+运算符来组合字符串,就像我们上面做的那样,但在 Python 的最新版本中,还有更简单的方法来处理字符串,称为格式化字符串,或简称为f-字符串。 -
要表示我们正在使用格式化字符串,我们只需在引号前添加一个
f。例如,我们可以在"Hello, " + name的用法上写f"Hello, {name}"以获得相同的结果。我们甚至可以在这个字符串中插入一个函数,并将我们上面的程序转换为一行:
print(f"Hello, {input("Name: ")}")
条件
- 就像在其他编程语言中一样,Python 让我们能够根据不同的条件运行不同的代码段。例如,在下面的程序中,我们将根据用户输入的数字改变我们的输出:
num = input("Number: ")
if num > 0:
print("Number is positive")
elif num < 0:
print("Number is negative")
else:
print("Number is 0")
-
了解上述程序的工作原理,Python 中的条件语句包含一个关键字(
if、elif或else),然后(除了else情况外)是一个布尔表达式,或者是一个评估为True或False的表达式。然后,我们想要在某个表达式为真时运行的代码将直接缩进在语句下方。缩进是 Python 语法的一部分。 -
然而,当我们运行这个程序时,我们会遇到一个异常,看起来像这样:

-
当我们在运行 Python 代码时发生错误,异常就会发生,随着时间的推移,你会越来越擅长解释这些错误,这是一个非常有价值的技能。
-
让我们更仔细地看看这个特定的异常:如果我们看到底部,我们会看到我们遇到了一个
TypeError,这通常意味着 Python 期望某个变量是某种类型,但发现它是另一种类型。在这种情况下,异常告诉我们我们不能使用>符号来比较一个str和一个int,然后在上面的代码行 2 中我们可以看到这个比较发生了。 -
在这种情况下,很明显
0是一个整数,所以我们的num变量必须是字符串。这是因为input函数总是返回一个字符串,我们必须指定使用int函数将其转换为(或强制类型转换为)整数。这意味着我们的第一行现在看起来像这样:
num = int(input("Number: "))
- 现在,程序将按我们预期的那样工作!
序列
Python 语言最强大的部分之一是它能够处理数据序列,而不仅仅是单个变量。
几种序列在某些方面相似,但在其他方面不同。在解释这些差异时,我们将使用术语可变/不可变和有序/无序。可变意味着一旦定义了一个序列,我们就可以改变该序列的各个元素,而有序意味着对象的顺序很重要。
字符串
有序: 是
可变: 否
我们已经稍微了解了一些字符串,但除了变量之外,我们可以将字符串视为字符序列。这意味着我们可以在字符串中访问单个元素!例如:
name = "Harry"
print(name[0])
print(name[1])
打印出字符串中的第一个(或索引-0)字符,在这个例子中恰好是H,然后打印出第二个(或索引-1)字符,它是a。
列表
有序: 是
可变: 是
Python 列表允许你存储任何变量类型。我们使用方括号和逗号创建列表,如下所示。类似于字符串,我们可以打印整个列表,或者打印一些单个元素。我们还可以使用append向列表中添加元素,并使用sort对列表进行排序。
# This is a Python comment names = ["Harry", "Ron", "Hermione"]
# Print the entire list: print(names)
# Print the second element of the list: print(names[1])
# Add a new name to the list: names.append("Draco")
# Sort the list: names.sort()
# Print the new list: print(names)

元组
有序: 是
可变: 否
元组通常用于需要存储两个或三个值的情况,例如一个点的 x 和 y 值。在 Python 代码中,我们使用括号:
point = (12.5, 10.6)
集合
有序: 否
可变: 不适用
集合与列表和元组不同,因为它们是无序的。它们还不同,因为虽然你可以在列表/元组中包含两个或更多相同的元素,但集合只会存储每个值一次。我们可以使用set函数定义一个空集合。然后我们可以使用add和remove向集合中添加和删除元素,并使用len函数来找到集合的大小。请注意,len函数在 Python 的所有序列中都有效。另外,尽管我们两次向集合中添加了4和3,但每个项目在集合中只能出现一次。
# Create an empty set: s = set()
# Add some elements: s.add(1)
s.add(2)
s.add(3)
s.add(4)
s.add(3)
s.add(1)
# Remove 2 from the set s.remove(2)
# Print the set: print(s)
# Find the size of the set: print(f"The set has {len(s)} elements.")
""" This is a python multi-line comment:
Output:
{1, 3, 4}
The set has 3 elements. """
字典
有序:否
可变:是
Python 字典或dict在本课程中特别有用。字典是一组键值对,其中每个键都有一个相应的值,就像字典中的每个词(键)都有一个相应的定义(值)。在 Python 中,我们使用花括号来包含字典,并使用冒号来表示键和值。例如:
# Define a dictionary houses = {"Harry": "Gryffindor", "Draco": "Slytherin"}
# Print out Harry's house print(houses["Harry"])
# Adding values to a dictionary: houses["Hermione"] = "Gryffindor"
# Print out Hermione's House: print(houses["Hermione"])
""" Output:
Gryffindor
Gryffindor """
循环
循环是任何编程语言中极其重要的部分,在 Python 中,它们主要有两种形式:for 循环和while 循环。目前,我们将专注于 for 循环。
- 循环用于遍历一系列元素,并对序列中的每个元素执行一些代码块(如下所示缩进)。例如,以下代码将打印出从 0 到 5 的数字:
for i in [0, 1, 2, 3, 4, 5]:
print(i)
""" Output:
0
1
2
3
4
5 """
- 我们可以使用 Python 的
range函数来简化这段代码,它允许我们轻松地获取一个数字序列。以下代码与上面的代码产生相同的结果:
for i in range(6):
print(i)
""" Output:
0
1
2
3
4
5 """
- 这种循环可以适用于任何序列!例如,如果我们想打印列表中的每个名字,我们可以编写以下代码:
# Create a list: names = ["Harry", "Ron", "Hermione"]
# Print each name: for name in names:
print(name)
""" Output:
Harry
Ron
Hermione """
- 如果我们想更具体一些,我们可以遍历单个名字中的每个字符!
name = "Harry"
for char in name:
print(char)
""" Output:
H
a
r
r
y """
函数
我们已经看到了一些 Python 函数,例如print和input,但现在我们将深入编写我们自己的函数。为了开始,我们将编写一个接受一个数字并将其平方的函数:
def square(x):
return x * x
注意我们如何使用def关键字来表示我们正在定义一个函数,我们正在接受一个名为x的单个输入,并且我们使用return关键字来表示函数的输出应该是什么。
我们可以像调用其他函数一样调用这个函数:使用括号:
for i in range(10):
print(f"The square of {i} is {square(i)}")
""" Output:
The square of 0 is 0
The square of 1 is 1
The square of 2 is 4
The square of 3 is 9
The square of 4 is 16
The square of 5 is 25
The square of 6 is 36
The square of 7 is 49
The square of 8 is 64
The square of 9 is 81 """
模块
随着我们的项目越来越大,能够在一个文件中编写函数并在另一个文件中运行它们将变得非常有用。在上面的例子中,我们可以创建一个名为functions.py的文件,其中包含以下代码:
def square(x):
return x * x
另一个名为square.py的文件,其中包含以下代码:
for i in range(10):
print(f"The square of {i} is {square(i)}")
然而,当我们尝试运行square.py时,我们遇到了以下错误:

我们遇到这个问题是因为默认情况下,Python 文件之间并不知道彼此,所以我们必须显式地 import 我们刚刚编写的 functions 模块中的 square 函数。现在,当 square.py 看起来像这样:
from functions import square
for i in range(10):
print(f"The square of {i} is {square(i)}")
或者,我们可以选择导入整个 functions 模块,然后使用点符号来访问 square 函数:
import functions
for i in range(10):
print(f"The square of {i} is {functions.square(i)}")
我们可以导入许多内置的 Python 模块,例如 math 或 csv,这些模块为我们提供了访问更多函数的权限。此外,我们还可以下载更多的模块来访问更多的功能!我们将花费大量时间使用 Django 模块,我们将在下一讲中讨论。
面向对象编程
面向对象编程是一种编程范式,或者说是关于编程的一种思考方式,它以可以存储信息和执行动作的对象为中心。
- 类:我们已经看到了 Python 中的一些不同类型的变量,但如果我们想创建自己的类型呢?一个 Python 类 实质上是一个新类型对象的模板,它可以存储信息并执行动作。以下是一个定义二维点的类:
class Point():
# A method defining how to create a point:
def __init__(self, x, y):
self.x = x
self.y = y
- 注意,在上面的代码中,我们使用关键字
self来表示我们正在处理的对象。self应该是 Python 类中任何方法的第一个参数。
现在,让我们看看我们如何实际使用上面的类来创建一个对象:
p = Point(2, 8)
print(p.x)
print(p.y)
""" Output:
2
8 """
现在,让我们看看一个更有趣的例子,在这个例子中,我们不仅存储一个点的坐标,而是创建一个表示航空公司航班的类:
class Flight():
# Method to create new flight with given capacity
def __init__(self, capacity):
self.capacity = capacity
self.passengers = []
# Method to add a passenger to the flight:
def add_passenger(self, name):
self.passengers.append(name)
然而,这个类是有缺陷的,因为我们虽然设定了容量,但我们仍然可能添加过多的乘客。让我们增强它,以便在添加乘客之前检查航班上是否有空位:
class Flight():
# Method to create new flight with given capacity
def __init__(self, capacity):
self.capacity = capacity
self.passengers = []
# Method to add a passenger to the flight:
def add_passenger(self, name):
if not self.open_seats():
return False
self.passengers.append(name)
return True
# Method to return number of open seats
def open_seats(self):
return self.capacity - len(self.passengers)
注意,在上面,我们使用 if not self.open_seats() 这一行来确定是否有空位。这之所以有效,是因为在 Python 中,数字 0 可以解释为 False 的意思,我们还可以使用关键字 not 来表示以下语句的相反,所以 not True 是 False,not False 是 True。因此,如果 open_seats 返回 0,整个表达式将评估为 True
现在,让我们通过实例化一些对象来尝试我们创建的类:
# Create a new flight with o=up to 3 passengers flight = Flight(3)
# Create a list of people people = ["Harry", "Ron", "Hermione", "Ginny"]
# Attempt to add each person in the list to a flight for person in people:
if flight.add_passenger(person):
print(f"Added {person} to flight successfully")
else:
print(f"No available seats for {person}")
""" Output:
Added Harry to flight successfully
Added Ron to flight successfully
Added Hermione to flight successfully
No available seats for Ginny """
函数式编程
除了支持面向对象编程,Python 还支持 函数式编程范式,在这个范式中,函数被当作值来对待,就像任何其他变量一样。
装饰器
函数式编程使得装饰器的概念成为可能,装饰器是一种高阶函数,可以修改另一个函数。例如,我们可以编写一个装饰器,用于在函数开始和结束时发出通知。然后,我们可以使用一个 @ 符号应用这个装饰器。
def announce(f):
def wrapper():
print("About to run the function")
f()
print("Done with the function")
return wrapper
@announce
def hello():
print("Hello, world!")
hello()
""" Output:
About to run the function
Hello, world!
Done with the function """
Lambda 函数
Lambda 函数为 Python 中创建函数提供了另一种方式。例如,如果我们想定义之前定义过的相同的square函数,我们可以这样写:
square = lambda x: x * x
其中输入位于:的左侧,输出位于右侧。
这在我们不想为单个、小规模的使用编写整个单独的函数时非常有用。例如,如果我们想要对一些对象进行排序,但一开始不清楚如何排序。想象一下,我们有一个包含人名和房屋的人名列表,但我们希望按人名排序:
people = [
{"name": "Harry", "house": "Gryffindor"},
{"name": "Cho", "house": "Ravenclaw"},
{"name": "Draco", "house": "Slytherin"}
]
people.sort()
print(people)
然而,这却给我们留下了错误:

这是因为 Python 不知道如何比较两个字典以检查一个是否小于另一个。
我们可以通过在排序函数中包含一个key参数来解决此问题,该参数指定我们希望用于排序的字典部分:
people = [
{"name": "Harry", "house": "Gryffindor"},
{"name": "Cho", "house": "Ravenclaw"},
{"name": "Draco", "house": "Slytherin"}
]
def f(person):
return person["name"]
people.sort(key=f)
print(people)
""" Output:
[{'name': 'Cho', 'house': 'Ravenclaw'}, {'name': 'Draco', 'house': 'Slytherin'}, {'name': 'Harry', 'house': 'Gryffindor'}] """
虽然这样做是可行的,但我们不得不编写一个只使用一次的整个函数,我们可以通过使用 lambda 函数来使我们的代码更易读:
people = [
{"name": "Harry", "house": "Gryffindor"},
{"name": "Cho", "house": "Ravenclaw"},
{"name": "Draco", "house": "Slytherin"}
]
people.sort(key=lambda person: person["name"])
print(people)
""" Output:
[{'name': 'Cho', 'house': 'Ravenclaw'}, {'name': 'Draco', 'house': 'Slytherin'}, {'name': 'Harry', 'house': 'Gryffindor'}] """
异常
在这次讲座中,我们遇到了几种不同的异常,所以现在我们将探讨一些处理它们的新方法。
在下面的代码块中,我们将从用户那里获取两个整数,并尝试除以它们:
x = int(input("x: "))
y = int(input("y: "))
result = x / y
print(f"{x} / {y} = {result}")
在许多情况下,这个程序运行良好:

然而,当我们尝试除以 0 时,我们会遇到问题:

我们可以使用异常处理来处理这种混乱的错误。在下面的代码块中,我们将尝试除以两个数字,如果遇到ZeroDivisionError,则except:
import sys
x = int(input("x: "))
y = int(input("y: "))
try:
result = x / y
except ZeroDivisionError:
print("Error: Cannot divide by 0.")
# Exit the program
sys.exit(1)
print(f"{x} / {y} = {result}")
在这种情况下,当我们再次尝试时:

然而,当用户输入非数字的 x 和 y 时,我们仍然会遇到错误:

我们可以用类似的方式解决这个问题!
import sys
try:
x = int(input("x: "))
y = int(input("y: "))
except ValueError:
print("Error: Invalid input")
sys.exit(1)
try:
result = x / y
except ZeroDivisionError:
print("Error: Cannot divide by 0.")
# Exit the program
sys.exit(1)
print(f"{x} / {y} = {result}")
这就是本次讲座的全部内容!下次,我们将使用 Python 的Django模块来构建一些应用程序!
第三讲
-
介绍
-
Web 应用程序
-
HTTP
-
Django
-
路由
-
模板
-
条件语句:
-
样式
-
-
任务
-
表单
- Django 表单
-
会话
介绍
-
到目前为止,我们已经讨论了如何使用 HTML 和 CSS 构建简单的网页,以及如何使用 Git 和 GitHub 来跟踪代码更改并与他人协作。我们还熟悉了 Python 编程语言。
-
今天,我们将使用 Python 的
Django框架来创建动态应用程序。
Web 应用程序
到目前为止,我们编写的所有 Web 应用程序都是静态的。这意味着每次我们打开那个网页时,它看起来都完全一样。然而,我们每天访问的许多网站在每次访问时都会发生变化。例如,如果你访问了《纽约时报》(https://www.nytimes.com/)或 Facebook(https://www.facebook.com/),你今天看到的内容很可能与明天不同。对于像这些大型网站,员工每次更改时手动编辑大型 HTML 文件是不合理的,这就是动态网站非常有用的地方。动态网站是利用编程语言(如 Python)动态生成 HTML 和 CSS 文件的网站。在本讲中,我们将学习如何创建我们的第一个动态应用程序。
HTTP
HTTP,或超文本传输协议,是一种广泛接受的协议,用于在互联网上传输消息。通常,在线信息是在客户端(用户)和服务器之间传递的。
在此协议中,客户端将向服务器发送一个请求,可能看起来像下面的示例。在下面的示例中,GET只是一个请求类型,我们将在本课程中讨论的三种类型之一。/通常表示我们正在寻找网站的首页,而三个点表示我们还可以传递更多信息。
在收到请求后,服务器将发送一个 HTTP 响应,可能看起来像下面的示例。这样的响应将包括 HTTP 版本、状态码(200 表示 OK)、内容描述以及一些附加信息。
200 只是许多状态码中的一个,其中一些你可能以前见过:
Django
Django 是一个基于 Python 的 Web 框架,它将允许我们编写动态生成 HTML 和 CSS 的 Python 代码。使用像 Django 这样的框架的优势在于,已经为我们编写了很多代码,我们可以利用这些代码。
-
要开始,我们必须安装 Django,这意味着如果您还没有这样做,您还必须安装 pip。
-
一旦您安装了 Pip,您可以在终端中运行
pip3 install Django来安装 Django。
在安装 Django 后,我们可以通过以下步骤创建一个新的 Django 项目:
-
运行
django-admin startproject PROJECT_NAME以创建我们项目的一些起始文件。 -
运行
cd PROJECT_NAME以进入您的新项目目录。 -
在您选择的文本编辑器中打开该目录。您会注意到已经为您创建了某些文件。现在我们不需要查看这些文件中的大多数,但有三件从开始起就非常重要:
-
manage.py是我们在终端上执行命令时使用的。我们不需要编辑它,但我们会经常使用它。 -
settings.py包含了我们新项目的一些重要配置设置。有一些默认设置,但我们可能希望不时地更改其中的一些。 -
urls.py包含了用户在导航到特定 URL 后应被路由到的指示。
-
-
通过运行
python manage.py runserver启动项目。这将打开一个开发服务器,您可以通过访问提供的 URL 来访问它。这个开发服务器是在您的机器上本地运行的,这意味着其他人无法访问您的网站。这应该会带您到一个默认的着陆页:![着陆页]()
-
接下来,我们必须创建一个应用。Django 项目分为一个或多个应用。我们的大多数项目只需要一个应用,但较大的网站可以利用这种将网站拆分为多个应用的能力。要创建一个应用,我们运行
python manage.py startapp APP_NAME。这将创建一些额外的目录和文件,这些文件将很快变得有用,包括views.py。 -
现在,我们必须安装我们的新应用。为此,我们进入
settings.py,向下滚动到INSTALLED_APPS列表,并将我们新应用的名称添加到该列表中。![已安装应用]()
路由
现在,为了开始我们的应用:
-
接下来,我们将导航到
views.py。这个文件将包含多个不同的视图,我们可以将视图现在视为用户可能希望看到的一页。为了创建我们的第一个视图,我们将编写一个接受request的函数。现在,我们将简单地返回一个HttpResponse(一个非常简单的响应,包括一个 200 的响应代码和一个可以在网页浏览器中显示的文本字符串)。为了做到这一点,我们包含了from django.http import HttpResponse。我们的文件现在看起来像:from django.shortcuts import render from django.http import HttpResponse # Create your views here. def index(request): return HttpResponse("Hello, world!") -
现在,我们需要以某种方式将我们刚刚创建的视图与一个特定的 URL 关联起来。为此,我们将在与
views.py相同的目录中创建另一个名为urls.py的文件。我们已经有了一个整个项目的urls.py文件,但最好为每个单独的应用程序都保留一个。 -
在我们的新
urls.py中,我们将创建一个用户在使用我们的网站时可能会访问的 URL 模式列表。为了做到这一点:-
我们必须做一些导入:
from django.urls import path将给我们重定向 URL 的能力,而from . import views将导入我们在views.py中创建的任何函数。 -
创建一个名为
urlpatterns的列表 -
对于每个期望的 URL,向
urlpatterns列表中添加一个项目,该项目包含对path函数的调用,该函数有两个或三个参数:一个表示 URL 路径的字符串,一个在访问该 URL 时希望调用的views.py中的函数,以及(可选的)该路径的名称,格式为name="something"。例如,这就是我们简单的应用程序现在看起来像:
from django.urls import path from . import views urlpatterns = [ path("", views.index, name="index") ] -
-
现在,我们已经为这个特定应用程序创建了一个
urls.py文件,并且是时候编辑为我们整个项目创建的urls.py文件了。当你打开这个文件时,你应该会看到已经有一个名为admin的路径,我们将在后面的课程中讲解。我们想要为我们的新应用程序添加另一个路径,所以我们将向urlpatterns列表中添加一个项目。这遵循了我们之前路径相同的模式,除了我们不想将views.py中的函数作为第二个参数添加,而是希望能够包含我们应用程序中urls.py文件内的所有路径。为此,我们写下:include("APP_NAME.urls"),其中include是我们通过从django.urls中也导入include获得的函数,如下面的urls.py所示:from django.contrib import admin from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), path('hello/', include("hello.urls")) ] -
通过这样做,我们指定了当用户访问我们的网站,然后在搜索栏中添加
/hello到 URL 时,他们将被重定向到我们新应用程序中的路径。
现在,当我使用python manage.py runserver启动应用程序并访问提供的 URL 时,我遇到了这个屏幕:
但这是因为我们只定义了 URL localhost:8000/hello,但没有定义末尾没有任何内容的 URL localhost:8000。所以,当我在搜索栏中的 URL 中添加/hello时:
现在我们已经取得了一些成功,让我们回顾一下我们是如何到达这个点的:
-
当我们访问 URL
localhost:8000/hello/时,Django 查看基本 URL(localhost:8000/)之后的内容,然后前往我们的项目urls.py文件并搜索与hello匹配的模式。 -
它之所以发现扩展,是因为我们定义了它,并且看到当遇到这种扩展时,它应该
包含我们应用程序内的urls.py文件。 -
然后,Django 在重定向时忽略了它已经使用的 URL 部分(
localhost:8000/hello/,或者全部),并在我们的其他urls.py文件中寻找与 URL 剩余部分匹配的模式。 -
它发现我们迄今为止的唯一路径(
"")与 URL 剩余部分匹配,因此它将我们导向与该路径关联的views.py中的函数。 -
最后,Django 在
views.py中运行该函数,并将结果(HttpResponse("Hello, world!"))返回到我们的网页浏览器。
现在,如果我们想的话,我们可以将views.py中的index函数更改为返回我们想要的任何内容!我们甚至可以在函数中跟踪变量并进行计算,然后再返回某些内容。
现在,让我们看看我们如何将多个视图添加到我们的应用程序中。我们可以在应用程序内部遵循许多相同的步骤来创建向布莱恩和大卫打招呼的页面。
在views.py内部:
from django.shortcuts import render
from django.http import HttpResponse
# Create your views here.
def index(request):
return HttpResponse("Hello, world!")
def brian(request):
return HttpResponse("Hello, Brian!")
def david(request):
return HttpResponse("Hello, David!")
在urls.py(在我们的应用程序内部)
from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="index"),
path("brian", views.brian, name="brian"),
path("david", views.david, name="david")
]
现在,当我们访问localhost:8000/hello时,我们的网站保持不变,但当我们向 URL 中添加brian或david时,我们会得到不同的页面:

许多网站通过 URL 中包含的项目进行参数化。例如,访问www.twitter.com/cs50将显示 CS50 的所有推文,而访问www.github.com/cs50将带您到 CS50 的 GitHub 页面。您甚至可以通过导航到www.github.com/YOUR_USERNAME找到您自己的公共 GitHub 仓库!
在考虑如何实现这一点时,似乎不可能 GitHub 和 Twitter 这样的网站为每个用户都有一个单独的 URL 路径,因此让我们看看我们如何创建一个更灵活的路径。我们将从向views.py添加一个更通用的函数greet开始:
def greet(request, name):
return HttpResponse(f"Hello, {name}!")
这个函数不仅接受一个请求,还接受一个额外的参数,即用户的名称,然后根据该名称返回一个自定义的 HTTP 响应。接下来,我们必须在 urls.py 中创建一个更灵活的路径,这可能看起来像这样:
path("<str:name>", views.greet, name="greet")
这是一种新的语法,但本质上这里发生的事情是我们不再寻找 URL 中的特定单词或名称,而是任何用户可能输入的字符串。现在,我们可以尝试使用几个其他的 URL 来测试网站:

我甚至可以通过增强 greet 函数来利用 Python 的 capitalize 函数,使其字符串首字母大写,使这些看起来更美观一些:
def greet(request, name):
return HttpResponse(f"Hello, {name.capitalize()}!")

这很好地说明了我们如何在 Python 中拥有的任何功能在返回之前都可以在 Django 中使用。
模板
到目前为止,我们的 HTTP 响应只是文本,但我们可以包含我们想要的任何 HTML 元素!例如,我可以在 index 函数中决定返回一个蓝色标题而不是纯文本:
def index(request):
return HttpResponse("<h1 style=\"color:blue\">Hello, world!</h1>")

在 views.py 中编写整个 HTML 页面会非常繁琐。这也会构成不良设计,因为我们希望在可能的情况下将项目的不同部分保存在不同的文件中。
这就是为什么我们现在要介绍 Django 的模板,它将允许我们在单独的文件中编写 HTML 和 CSS,并使用 Django 渲染这些文件。我们将用于渲染模板的语法看起来是这样的:
def index(request):
return render(request, "hello/index.html")
现在,我们需要创建这个模板。为此,我们将在我们的应用中创建一个名为 templates 的文件夹,然后在其中创建一个名为 hello(或我们应用的名称)的文件夹,最后添加一个名为 index.html 的文件。

接下来,我们将添加我们想要添加到新文件中的内容:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hello</title>
</head>
<body>
<h1>Hello, World!</h1>
</body>
</html>
现在,当我们访问我们应用程序的主页时,我们可以看到标题和标题已经更新了:
除了编写一些静态的 HTML 页面外,我们还可以使用 Django 的模板语言 来根据访问的 URL 改变我们 HTML 文件的内容。让我们通过更改之前的 greet 函数来试一试:
def greet(request, name):
return render(request, "hello/greet.html", {
"name": name.capitalize()
})
注意,我们在 render 函数中传递了第三个参数,这个参数被称为 上下文。在这个上下文中,我们可以提供我们希望在 HTML 文件中可用的信息。这个上下文以 Python 字典的形式存在。现在,我们可以创建一个 greet.html 文件:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hello</title>
</head>
<body>
<h1>Hello, {{ name }}!</h1>
</body>
</html>
您会注意到我们使用了一些新的语法:双大括号。这种语法允许我们访问在 context 参数中提供的变量。现在,当我们尝试它时:

现在,我们已经看到了如何根据我们提供的上下文修改我们的 HTML 模板。然而,Django 模板语言比这更强大,所以让我们看看它还有哪些其他方式可以帮助我们:
条件语句:
我们可能希望根据某些条件更改我们网站上显示的内容。例如,如果您访问网站 www.isitchristmas.com,您可能会看到一个看起来像这样的页面:
但这个网站在圣诞节那天会改变,届时网站会说 YES。为了创建类似的东西,让我们尝试创建一个类似的应用程序,其中我们检查是否是新年第一天。让我们创建一个新的应用程序来完成这个任务,回顾我们创建新应用程序的过程:
-
在终端中运行
python manage.py startapp newyear。 -
编辑
settings.py,将“newyear”添加为我们的INSTALLED_APPS之一 -
编辑我们项目的
urls.py文件,并包含一个类似于为hello应用程序创建的路径:
path('newyear/', include("newyear.urls"))
- 在我们新应用程序的目录中创建另一个
urls.py文件,并更新它以包含类似于hello中索引路径的路径:
from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="index"),
]
- 在
views.py中创建一个索引函数。
现在我们已经设置好了我们的新应用程序,让我们弄清楚如何检查是否是新年第一天。为此,我们可以导入 Python 的 datetime 模块。为了了解这个模块的工作方式,我们可以查看 文档,然后使用 Python 解释器在 Django 之外测试它。
-
Python 解释器 是一个我们可以用来测试小块 Python 代码的工具。要使用它,请在您的终端中运行
python,然后您将能够在终端中输入并运行 Python 代码。当您完成使用解释器后,运行exit()退出。![解释器]()
-
我们可以使用这个知识来构建一个布尔表达式,该表达式仅在今天是新年第一天时评估为 True:
now.day == 1 and now.month == 1 -
现在我们有一个可以用来评估是否是新年第一天的表达式,我们可以更新
views.py中的索引函数:
def index(request):
now = datetime.datetime.now()
return render(request, "newyear/index.html", {
"newyear": now.month == 1 and now.day == 1
})
现在,让我们创建我们的 index.html 模板。我们再次需要创建一个名为 templates 的新文件夹,该文件夹位于其中,然后是一个名为 newyear 的文件夹,以及一个名为 index.html 的文件。在该文件中,我们将编写如下内容:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Is it New Year's?</title>
</head>
<body>
{% if newyear -%}
<h1>YES</h1>
{%- else -%}
<h1>NO</h1>
{%- endif %}
</body>
</html>
在上面的代码中,请注意,当我们希望在 HTML 文件中包含逻辑时,我们使用 {% 和 %} 作为逻辑语句的开启和关闭标签。此外,请注意 Django 的格式化语言要求你包含一个结束标签,表示我们已完成 if-else 块。现在,我们可以打开我们的页面来查看:

现在,为了更好地了解幕后发生的事情,让我们检查这个页面的元素:

注意,实际上发送到你的网页浏览器的 HTML 只包括 NO 标题,这意味着 Django 正在使用我们编写的 HTML 模板来创建一个新的 HTML 文件,并将其发送到我们的网页浏览器。如果我们稍微作弊一下,确保我们的条件始终为真,我们会看到相反的情况被填充:
def index(request):
now = datetime.datetime.now()
return render(request, "newyear/index.html", {
"newyear": True
})

样式
如果我们想添加一个 CSS 文件,它是一个 静态 文件,因为它不会改变,我们首先创建一个名为 static 的文件夹,然后在其中创建一个 newyear 文件夹,最后在该文件夹中创建一个 styles.css 文件。在这个文件中,我们可以添加任何我们想要的样式,就像我们在第一节课中做的那样:
h1 {
font-family: sans-serif;
font-size: 90px;
text-align: center;
}
现在,为了在 HTML 文件中包含这个样式,我们在 HTML 模板顶部添加一行 {% load static %},这向 Django 信号我们希望访问 static 文件夹中的文件。然后,而不是像之前那样硬编码样式表的链接,我们将使用一些 Django 特定的语法:
<link rel="stylesheet" href="{% static 'newyear/styles.css' %}">
现在,如果我们重新启动服务器,我们可以看到样式更改确实已经应用:
任务
现在,让我们将我们迄今为止学到的知识应用到一个小型项目中:创建一个 TODO 列表。让我们再次创建一个新的应用:
-
在终端中运行
python manage.py startapp tasks。 -
编辑
settings.py,将“tasks”添加为我们的INSTALLED_APPS之一 -
编辑我们项目的
urls.py文件,并包含一个类似于为hello应用创建的路径:path('tasks/', include("tasks.urls")) -
在我们新应用的目录中创建另一个
urls.py文件,并将其更新为包含一个类似于hello中的索引路径:from django.urls import path from . import views urlpatterns = [ path("", views.index, name="index"), ] -
在
views.py中创建一个索引函数。
现在,让我们先尝试简单地创建一个任务列表,并将其显示在页面上。让我们在 views.py 的顶部创建一个 Python 列表,我们将在这里存储我们的任务。然后,我们可以更新我们的 index 函数以渲染一个模板,并提供我们新创建的列表作为上下文。
from django.shortcuts import render
tasks = ["foo", "bar", "baz"]
# Create your views here. def index(request):
return render(request, "tasks/index.html", {
"tasks": tasks
})
现在,让我们着手创建我们的模板 HTML 文件:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Tasks</title>
</head>
<body>
<ul>
{% for task in tasks %}
<li>{{ task }}</li>
{% endfor %}
</ul>
</body>
</html>
注意这里,我们能够使用类似于我们之前条件语句的语法,以及类似于第二部分课中 Python 循环的语法来遍历我们的任务。当我们现在访问任务页面时,我们可以看到我们的列表被渲染:
表单
现在我们可以看到所有当前任务作为一个列表,我们可能想要能够添加一些新任务。为此,我们将开始查看如何使用表单来更新网页。让我们首先向 views.py 添加另一个函数,该函数将渲染一个带有添加新任务表单的页面:
# Add a new task: def add(request):
return render(request, "tasks/add.html")
接下来,确保向 urls.py 添加另一个路径:
path("add", views.add, name="add")
现在,我们将创建我们的 add.html 文件,它与 index.html 非常相似,只是在主体中我们将包含一个表单而不是列表:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Tasks</title>
</head>
<body>
<h1>Add Task:</h1>
<form action="">
<input type="text" name="task">
<input type="submit">
</form>
</body>
</html>
然而,我们刚刚所做的不一定是最佳设计,因为我们已经在两个不同的文件中重复了大部分 HTML。Django 的模板语言为我们提供了一种消除这种糟糕设计的方法:模板继承。这允许我们创建一个包含我们页面通用结构的 layout.html 文件:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Tasks</title>
</head>
<body>
{% block body %}
{% endblock %}
</body>
</html>
注意我们再次使用了 {%...%} 来表示某种非 HTML 逻辑,在这种情况下,我们告诉 Django 用来自另一个文件的一些文本填充这个“块”。现在,我们可以修改我们其他两个 HTML 文件,使其看起来像这样:
index.html:
{% extends "tasks/layout.html" %}
{% block body %}
<h1>Tasks:</h1>
<ul>
{% for task in tasks %}
<li>{{ task }}</li>
{% endfor %}
</ul>
{% endblock %}
add.html:
{% extends "tasks/layout.html" %}
{% block body %}
<h1>Add Task:</h1>
<form action="">
<input type="text" name="task">
<input type="submit">
</form>
{% endblock %}
注意我们现在可以通过 扩展 我们的布局文件来删除大部分重复的代码。现在,我们的索引页面保持不变,我们现在还有一个添加页面:

接下来,每次我们想要添加一个新任务时,在 URL 中输入“/add”并不是很理想,所以我们可能想要在页面之间添加一些链接。但是,我们不是硬编码链接,现在我们可以使用在 urls.py 中为每个路径分配的 name 变量,创建一个看起来像这样的链接:
<a href="{% url 'add' %}">Add a New Task</a>
其中 'add' 是该路径的名称。我们可以在 add.html 中做类似的事情:
<a href="{% url 'index' %}">View Tasks</a>
这可能会产生问题,因为我们有多个名为 index 的路由分布在不同的应用中。我们可以通过进入每个应用的 urls.py 文件,并添加一个 app_name 变量来解决此问题,这样文件现在看起来就像这样:
from django.urls import path
from . import views
app_name = "tasks"
urlpatterns = [
path("", views.index, name="index"),
path("add", views.add, name="add")
]
然后,我们可以将链接从简单的 index 和 add 改为 tasks:index 和 tasks:add
<a href="{% url 'tasks:index' %}">View Tasks</a>
<a href="{% url 'tasks:add' %}">Add a New Task</a>
现在,让我们确保当用户提交表单时表单实际上会做一些事情。我们可以通过向 add.html 中创建的表单添加一个 action 来做到这一点:
<form action="{% url 'tasks:add' %}" method="post">
这意味着一旦表单提交,我们将被路由回 add URL。在这里,我们指定我们将使用 post 方法而不是 get 方法,这通常是我们在表单可能改变该网页状态时使用的方法。
现在,我们需要对这个表单添加更多内容,因为 Django 需要一个令牌来防止跨站请求伪造(CSRF)攻击。这种攻击是指恶意用户试图从你的网站之外发送请求到你的服务器。这对某些网站来说可能是一个大问题。比如说,一个银行网站有一个表单,允许一个用户向另一个用户转账。如果有人能够从银行网站之外提交转账,那将是一场灾难!
为了解决这个问题,当 Django 发送响应渲染模板时,它还会提供一个CSRF 令牌,该令牌在每个新的会话中都是唯一的。然后,当提交请求时,Django 会检查请求关联的 CSRF 令牌是否与它最近提供的令牌匹配。因此,如果另一个网站上的恶意用户试图提交请求,他们将会因为无效的 CSRF 令牌而被阻止。这种 CSRF 验证是内置在Django 中间件框架中的,它可以干预 Django 应用的请求-响应处理。我们在这门课程中不会进一步讨论中间件,但如果感兴趣,请查看文档!
要将这项技术整合到我们的代码中,我们必须在add.html表单中添加一行代码。
<form action="{% url 'tasks:add' %}" method="post">
{% csrf_token %}
<input type="text" name="task">
<input type="submit">
</form>
这行代码添加了一个由 Django 提供的 CSRF 令牌的隐藏输入字段,这样当我们重新加载页面时,看起来好像没有变化。然而,如果我们检查元素,我们会注意到添加了一个新的输入字段:
Django 表单
尽管我们可以像刚才那样通过编写原始 HTML 来创建表单,但 Django 提供了一个更简单的方法来收集用户信息:Django 表单。为了使用这种方法,我们需要在views.py的顶部添加以下内容以导入forms模块:
from django import forms
现在,我们可以在views.py中创建一个新的表单,通过创建一个名为NewTaskForm的 Python 类来实现:
class NewTaskForm(forms.Form):
task = forms.CharField(label="New Task")
现在,让我们来看看这个类中发生了什么:
-
在
NewTaskForm括号后面,我们看到我们使用了forms.Form。这是因为我们的新表单继承自一个名为Form的类,该类包含在forms模块中。我们已经看到了如何在 Django 的模板语言和 Sass 样式中使用继承。这是继承如何被用来从一个更通用的描述(forms.Form类)缩小到我们想要的(我们的新表单)的另一个例子。继承是面向对象编程的关键部分,我们在这门课程中不会详细讨论,但关于这个主题有许多在线资源可供学习! -
在这个类内部,我们可以指定我们希望从用户那里收集哪些信息,在这种情况下是任务的名称。
-
我们通过编写
forms.CharField来指定这是一个文本输入,但 Django 的表单模块中包含了许多其他输入字段,我们可以从中选择。 -
在这个
CharField中,我们指定一个label,当用户加载页面时会显示出来。label只是我们可以传递给表单字段的许多参数之一。
现在我们已经创建了NewTaskForm类,我们可以在渲染add页面时将其包含在上下文中:
# Add a new task: def add(request):
return render(request, "tasks/add.html", {
"form": NewTaskForm()
})
现在,在add.html中,我们可以用我们刚刚创建的表单替换我们的输入字段:
{% extends "tasks/layout.html" %}
{% block body %}
<h1>Add Task:</h1>
<form action="{% url 'tasks:add' %}" method="post">
{% csrf_token %}
{{ form }}
<input type="submit">
</form>
<a href="{% url 'tasks:index' %}">View Tasks</a>
{% endblock %}
使用forms模块而不是手动编写 HTML 表单有几个优点:
-
如果我们想在表单中添加新字段,我们可以在
views.py中简单地添加它们,而无需编写额外的 HTML。 -
Django 自动执行客户端验证,或用户机器本地的验证。这意味着它不会允许用户提交不完整的表单。
-
Django 提供了简单的服务器端验证,或验证在表单数据到达服务器后发生。
-
在下一讲中,我们将开始使用模型来存储信息,Django 使得根据模型创建表单变得非常简单。
现在我们已经设置好了表单,让我们来处理用户点击提交按钮时会发生什么。当用户通过点击链接或输入 URL 导航到添加页面时,他们向服务器发送一个GET请求,我们已经在add函数中处理了它。但是,当用户提交表单时,他们向服务器发送一个POST请求,目前这个请求在add函数中没有被处理。我们可以通过在函数接收的request参数上添加条件来处理POST方法。下面代码中的注释解释了每行的目的:
# Add a new task: def add(request):
# Check if method is POST
if request.method == "POST":
# Take in the data the user submitted and save it as form
form = NewTaskForm(request.POST)
# Check if form data is valid (server-side)
if form.is_valid():
# Isolate the task from the 'cleaned' version of form data
task = form.cleaned_data["task"]
# Add the new task to our list of tasks
tasks.append(task)
# Redirect user to list of tasks
return HttpResponseRedirect(reverse("tasks:index"))
else:
# If the form is invalid, re-render the page with existing information.
return render(request, "tasks/add.html", {
"form": form
})
return render(request, "tasks/add.html", {
"form": NewTaskForm()
})
简要说明:为了在成功提交后重定向用户,我们需要导入一些额外的模块:
from django.urls import reverse
from django.http import HttpResponseRedirect
会话
到目前为止,我们已经成功构建了一个应用程序,允许我们向不断增长的任务列表中添加任务。然而,将任务存储为全局变量可能是一个问题,因为它意味着所有访问页面的用户都会看到完全相同的列表。为了解决这个问题,我们将使用一个称为sessions的工具。
会话是存储在服务器端为每个新访问网站的唯一数据的方式。
要在我们的应用程序中使用会话,我们首先会删除全局tasks变量,然后修改我们的index函数,最后确保在之前任何使用变量tasks的地方,我们都将其替换为request.session["tasks"]。
def index(request):
# Check if there already exists a "tasks" key in our session
if "tasks" not in request.session:
# If not, create a new list
request.session["tasks"] = []
return render(request, "tasks/index.html", {
"tasks": request.session["tasks"]
})
# Add a new task: def add(request):
if request.method == "POST":
# Take in the data the user submitted and save it as form
form = NewTaskForm(request.POST)
# Check if form data is valid (server-side)
if form.is_valid():
# Isolate the task from the 'cleaned' version of form data
task = form.cleaned_data["task"]
# Add the new task to our list of tasks
request.session["tasks"] += [task]
# Redirect user to list of tasks
return HttpResponseRedirect(reverse("tasks:index"))
else:
# If the form is invalid, re-render the page with existing information.
return render(request, "tasks/add.html", {
"form": form
})
return render(request, "tasks/add.html", {
"form": NewTaskForm()
})
最后,在 Django 能够存储这些数据之前,我们必须在终端中运行python manage.py migrate。下周我们将更详细地讨论迁移是什么,但现阶段只需知道上述命令允许我们存储会话。
这节课的内容就到这里!下次我们将讨论如何使用 Django 来存储、访问和操作数据。
第四讲
-
介绍
-
SQL
-
数据库
-
列类型
-
-
表格
-
选择
-
在终端中使用 SQL
-
函数
-
更新
-
删除
-
其他子句
-
-
连接表
-
JOIN 查询
-
索引
-
SQL 漏洞
-
-
Django 模型
-
迁移
-
Shell
- 启动我们的应用程序
-
Django 管理员
-
多对多关系
-
用户
介绍
-
到目前为止,我们已经讨论了如何使用 HTML 和 CSS 构建简单的网页,以及如何使用 Git 和 GitHub 来跟踪代码更改并与他人协作。我们还熟悉了 Python 编程语言,并开始使用 Django 来创建网络应用程序。
-
今天,我们将学习如何使用 SQL 和 Django 模型高效地存储和访问数据。
SQL
SQL,或结构化查询语言,是一种编程语言,允许我们更新和查询数据库。

数据库
在我们深入探讨如何使用 SQL 语言之前,我们应该讨论我们的数据是如何存储的。当使用 SQL 时,我们将与一个 关系数据库 一起工作,在那里我们可以找到所有数据存储在多个 表格 中。这些表格中的每一个都由一定数量的列和灵活数量的行组成。
为了说明如何使用 SQL,我们将使用一个航空公司的网站示例,该网站用于跟踪航班和乘客。在下面的表格中,我们可以看到我们正在跟踪多个航班,每个航班都有一个 origin(起点)、一个 destination(目的地)和一个 duration(时长)。

有几种不同的关系数据库管理系统被广泛用于存储信息,并且可以轻松与 SQL 命令交互:
前两个,MySQL 和 PostgreSQL,是更重型的数据库管理系统,通常在运行网站的独立服务器上运行。另一方面,SQLite 是一个更轻量级的系统,可以将所有数据存储在一个单独的文件中。在本课程中,我们将使用 SQLite,因为它是 Django 默认使用的系统。
列类型
正如我们在 Python 中使用了几种不同的变量类型一样,SQLite 有 类型 代表不同形式的信息。其他管理系统可能有不同的数据类型,但它们都与 SQLite 的类型相当相似:
-
TEXT: 用于文本字符串(例如,一个人的名字) -
NUMERIC: 一种更通用的数值数据形式(例如,日期或布尔值) -
INTEGER: 任何非十进制数字(例如,一个人的年龄) -
REAL: 任何实数(例如,一个人的体重) -
BLOB(二进制大对象): 我们可能想要存储在数据库中的任何其他二进制数据(例如,一张图片)
表
现在,为了真正开始使用 SQL 与数据库交互,让我们首先创建一个新表。创建新表的 命令 大概是这样的:
CREATE TABLE flights(
id INTEGER PRIMARY KEY AUTOINCREMENT,
origin TEXT NOT NULL,
destination TEXT NOT NULL,
duration INTEGER NOT NULL
);
在上述命令中,我们创建了一个新表,我们决定将其命名为 flights,并且我们向这个表添加了四个列:
-
id: 有一个允许我们唯一标识表中每一行的数字通常很有帮助。在这里,我们指定id是一个整数,并且它也是我们的 主键,这意味着它是我们的唯一标识符。我们还指定了它将AUTOINCREMENT,这意味着我们每次向表中添加时不需要提供 id,因为它会自动完成。 -
origin: 在这里我们指定这是一个文本字段,并且通过写入NOT NULL我们要求它必须有一个值。 -
destination: 同样,我们指定这将是一个文本字段,并阻止它为空。 -
duration: 同样,这个值不能为空,但这次它是一个整数而不是文本。
我们在创建列时刚刚看到了 NOT NULL 和 PRIMARY KEY 约束,但还有其他几个 约束 可供我们使用:
-
CHECK: 确保在允许添加/修改行之前满足某些约束 -
DEFAULT: 如果没有给出值,则提供默认值 -
NOT NULL: 确保提供值 -
PRIMARY KEY: 表示这是在数据库中搜索行的主要方式 -
UNIQUE: 确保该列中不会有两个行具有相同的值。 -
…
现在我们已经看到了如何创建表,让我们看看我们如何可以向其中添加行。在 SQL 中,我们使用 INSERT 命令来完成这个操作:
INSERT INTO flights
(origin, destination, duration)
VALUES ("New York", "London", 415);
在上述命令中,我们指定了我们要插入的表名,然后提供了一列列名列表,我们将提供有关这些列的信息,然后指定我们想要填充表中该行的 VALUES,确保 VALUES 的顺序与我们的列名列表相对应。请注意,我们不需要为 id 提供值,因为它会自动递增。
SELECT
一旦表格被填充了一些行,我们可能希望有一种方法来访问该表中的数据。我们通过使用 SQL 的SELECT查询来实现这一点。最简单的SELECT查询到我们的航班表可能看起来像这样:
SELECT * FROM flights;
上述命令(*)检索了我们航班表中的所有数据

然而,可能我们并不真的需要数据库中的所有列,只需要起点和目的地。为了只访问这些列,我们可以用我们想要访问的列名替换*。以下查询返回所有起点和目的地。
SELECT origin, destination FROM flights;

随着我们的表格越来越大,我们可能还想缩小查询返回的行数。我们通过添加一个WHERE并跟上一个条件来实现这一点。例如,以下命令只选择id为3的行:
SELECT * FROM flights WHERE id = 3;

我们可以按任何列过滤,而不仅仅是id!
SELECT * FROM flights WHERE origin = "New York";

在终端中使用 SQL
现在我们已经了解了一些基本的 SQL 命令,让我们在终端中测试它们!为了在您的计算机上使用 SQLite,您必须首先下载 SQLite。(我们不会在讲座中使用它,但您也可以下载 DB Browser以更用户友好的方式运行 SQL 查询。)
我们可以通过手动创建一个新文件或在终端中运行touch flights.sql来为我们的数据库创建一个文件。现在,如果我们通过终端运行sqlite3 flights.sql,我们将进入一个 SQLite 提示符,在那里我们可以运行 SQL 命令:
# Entering into the SQLite Prompt
(base) % sqlite3 flights.sql
SQLite version 3.26.0 2018-12-01 12:34:55
Enter ".help" for usage hints.
# Creating a new Table
sqlite> CREATE TABLE flights(
...> id INTEGER PRIMARY KEY AUTOINCREMENT,
...> origin TEXT NOT NULL,
...> destination TEXT NOT NULL,
...> duration INTEGER NOT NULL
...> );
# Listing all current tables (Just flights for now)
sqlite> .tables
flights
# Querying for everything within flights (Which is now empty)
sqlite> SELECT * FROM flights;
# Adding one flight
sqlite> INSERT INTO flights
...> (origin, destination, duration)
...> VALUES ("New York", "London", 415);
# Checking for new information, which we can now see
sqlite> SELECT * FROM flights;
1|New York|London|415
# Adding some more flights
sqlite> INSERT INTO flights (origin, destination, duration) VALUES ("Shanghai", "Paris", 760);
sqlite> INSERT INTO flights (origin, destination, duration) VALUES ("Istanbul", "Tokyo", 700);
sqlite> INSERT INTO flights (origin, destination, duration) VALUES ("New York", "Paris", 435);
sqlite> INSERT INTO flights (origin, destination, duration) VALUES ("Moscow", "Paris", 245);
sqlite> INSERT INTO flights (origin, destination, duration) VALUES ("Lima", "New York", 455);
# Querying this new information
sqlite> SELECT * FROM flights;
1|New York|London|415
2|Shanghai|Paris|760
3|Istanbul|Tokyo|700
4|New York|Paris|435
5|Moscow|Paris|245
6|Lima|New York|455
# Changing the settings to make output more readable
sqlite> .mode columns
sqlite> .headers yes
# Querying all information again
sqlite> SELECT * FROM flights;
id origin destination duration
---------- ---------- ----------- ----------
1 New York London 415
2 Shanghai Paris 760
3 Istanbul Tokyo 700
4 New York Paris 435
5 Moscow Paris 245
6 Lima New York 455
# Searching for just those flights originating in New York
sqlite> SELECT * FROM flights WHERE origin = "New York";
id origin destination duration
---------- ---------- ----------- ----------
1 New York London 415
4 New York Paris 435
我们还可以使用不仅仅是等于来过滤我们的航班。对于整数和实数值,我们可以使用大于或小于:
SELECT * FROM flights WHERE duration > 500;

我们还可以使用其他逻辑(AND, OR)如 Python 中的逻辑:
SELECT * FROM flights WHERE duration > 500 AND destination = "Paris";

SELECT * FROM flights WHERE duration > 500 OR destination = "Paris";

我们还可以使用关键字IN来查看数据是否是几个选项之一:
SELECT * FROM flights WHERE origin IN ("New York", "Lima");

我们甚至可以使用正则表达式通过使用LIKE关键字更广泛地搜索单词。以下查询通过使用%作为通配符字符,找到所有在起点中有a的结果。
SELECT * FROM flights WHERE origin LIKE "%a%";

函数
此外,我们还可以将一些 SQL 函数应用于查询结果。如果我们不需要查询返回的所有数据,而只需要一些数据的摘要统计信息,这些函数可能很有用。
UPDATE
我们现在已经看到了如何添加和搜索表,但我们可能还希望能够更新已存在的表的行。我们可以使用UPDATE命令来完成这个操作,如下所示。正如你可能通过大声读出来所猜到的,该命令找到所有从纽约飞往伦敦的航班,并将它们的持续时间设置为 430。
UPDATE flights
SET duration = 430
WHERE origin = "New York"
AND destination = "London";
DELETE
我们还可能想要有从我们的数据库中删除行的能力,我们可以使用DELETE命令来完成这个操作。以下代码将删除所有飞往东京的航班:
DELETE FROM flights WHERE destination = "Tokyo";
其他子句
我们可以使用许多额外的子句来控制返回给我们的查询
表的连接
到目前为止,我们一直是在一次处理一个表,但实践中许多数据库都是由多个相互关联的表组成的。在我们的航班示例中,让我们想象我们还想添加一个机场代码与城市一起。按照我们目前表的结构,我们可能需要为每一行添加两个额外的列。我们也会重复信息,因为我们必须在多个地方写上城市 X 与代码 Y 相关联。
我们可以解决这个问题的方法之一是决定有一个表来跟踪航班,然后另一个表来跟踪机场。第二个表可能看起来像这样

现在我们有一个与代码和城市相关的表,而不是在我们的航班表中存储整个城市名称,如果我们能够只保存那个机场的id,那么这将节省存储空间。因此,我们应该相应地重写航班表。由于我们正在使用机场表的id列来填充origin_id和destination_id,我们称这些值为外键

除了航班和机场,航空公司可能还想要存储有关其乘客的数据,比如每个乘客将乘坐哪班航班。利用关系型数据库的力量,我们可以添加另一个表来存储姓名和代表他们所乘坐航班的键

尽管如此,我们还能做得更好,因为同一个人可能乘坐多趟航班。为了解决这个问题,我们可以创建一个people表来存储姓名,以及一个passengers表来将人与航班配对


由于在这种情况下,一个人可以乘坐多趟航班,而一趟航班可以有很多人,我们称flights和people之间的关系为多对多关系。连接这两个表的passengers表被称为关联表。
JOIN 查询
虽然我们的数据现在存储得更高效,但似乎查询数据可能更困难。幸运的是,SQL 有一个JOIN查询,我们可以用它来合并两个表以进行另一个查询。
例如,假设我们想找到乘客正在乘坐的每次旅行的出发地、目的地和姓名。为了简化这个表,我们将使用包含航班 ID、名和姓的非优化passengers表。这个查询的第一部分看起来相当熟悉:
SELECT first, origin, destination
FROM ...
但在这里我们遇到了一个问题,因为first存储在passengers表中,而origin和destination存储在flights表中。我们通过使用passengers表中的flight_id与flights表中的id相对应的事实来连接这两个表:
SELECT first, origin, destination
FROM flights JOIN passengers
ON passengers.flight_id = flights.id;

我们刚刚使用了叫做INNER JOIN的东西,这意味着我们正在忽略两个表之间没有匹配的行,但还有其他类型的连接,包括LEFT JOIN、RIGHT JOIN和FULL OUTER JOIN,我们在这里不会详细讨论。
索引
当处理大型表时,我们可以通过创建一个类似于教科书背面的索引来使我们的查询更高效。例如,如果我们知道我们经常通过姓氏查找乘客,我们可以使用以下命令创建一个从姓氏到 ID 的索引:
CREATE INDEX name_index ON passengers (last);
SQL 漏洞
现在我们已经了解了使用 SQL 处理数据的基础知识,重要的是要指出与使用 SQL 相关的主要漏洞。我们将从SQL 注入开始。
SQL 注入攻击是指恶意用户在网站上输入 SQL 代码作为输入,以绕过网站的安全措施。例如,假设我们有一个存储用户名和密码的表,然后在页面的主页上有一个登录表单。我们可能使用以下查询来搜索用户:
SELECT * FROM users
WHERE username = username AND password = password;
一个名为 Harry 的用户可能会访问这个网站,并输入harry作为用户名,12345作为密码,在这种情况下,查询看起来会是这样:
SELECT * FROM users
WHERE username = "harry" AND password = "12345";
另一方面,一个黑客可能会输入harry" --作为用户名,密码为空。结果是--在 SQL 中表示注释,这意味着查询看起来会是这样:
SELECT * FROM users
WHERE username = "harry"--" AND password = "12345";
因为在这个查询中,密码检查已经被注释掉了,黑客可以在不知道密码的情况下登录 Harry 的账户。为了解决这个问题,我们可以使用:
-
转义字符以确保 SQL 将输入视为纯文本而不是 SQL 代码。
-
在 SQL 之上有一个抽象层,它包括自己的转义序列,所以我们不需要自己编写 SQL 查询。
当涉及到 SQL 时,另一个主要漏洞被称为竞态条件.
竞态条件是在同时对数据库进行多个查询时发生的情况。当这些查询没有得到妥善处理时,数据库更新时的精确时间可能会出现问题。例如,假设我银行账户中有 150 美元。如果我在手机和笔记本电脑上同时登录我的银行账户,并在每个设备上尝试提取 100 美元,就可能发生竞态条件。如果银行的软件开发者没有正确处理竞态条件,那么我可能能够从只有 150 美元的账户中提取 200 美元。解决这个问题的一个潜在方案是锁定数据库。我们不允许在完成一个事务之前与数据库进行任何其他交互。在银行示例中,在我电脑上点击导航到“提取”页面后,银行可能不允许我在手机上导航到该页面。
Django Models
Django 模型是在 SQL 之上的一个抽象层,它允许我们使用 Python 类和对象而不是直接 SQL 查询来与数据库交互。
让我们开始使用模型,为我们的航空公司创建一个 Django 项目,并在该项目中创建一个应用程序。
django-admin startproject airline
cd airline
python manage.py startapp flights
现在我们将像通常添加应用程序一样进行添加应用程序的过程:
-
在
settings.py中将flights添加到INSTALLED_APPS列表中 -
在
urls.py中添加flights的路由:path("flights/", include("flights.urls")), -
在
flights应用程序中创建一个urls.py文件。并填充标准的urls.py导入和列表。
现在,我们不再创建实际的路径,而是从views.py开始,我们将在models.py文件中创建一些模型。在这个文件中,我们将概述我们希望在应用程序中存储的数据。然后,Django 将确定存储我们每个模型所需的信息的 SQL 语法。让我们看看单个航班模型的例子:
class Flight(models.Model):
origin = models.CharField(max_length=64)
destination = models.CharField(max_length=64)
duration = models.IntegerField()
让我们看看这个模型定义中发生了什么:
-
在第一行中,我们创建了一个新的模型,该模型扩展了 Django 的模型类。
-
下面,我们添加了起点、终点和持续时间的字段。前两个是Char Fields,意味着它们存储字符串,第三个是Integer Field。这些只是许多内置 Django 字段类中的两个
-
我们为两个字符字段指定了最大长度为 64。你可以通过检查文档来查看给定字段的可用规格。
Migrations
现在,尽管我们已经创建了一个模型,但我们还没有数据库来存储这些信息。要从我们的模型创建数据库,我们导航到项目的根目录并运行以下命令。
python manage.py makemigrations
此命令创建了一些 Python 文件,这些文件将创建或编辑我们的数据库,以便能够存储我们在模型中的内容。你应该得到一个类似于下面的输出,如果你导航到你的migrations目录,你会注意到为我们创建了一个新文件

接下来,要应用这些迁移到我们的数据库,我们运行以下命令
python manage.py migrate
现在,你会看到一些默认迁移已经应用,并且你也会注意到我们现在在项目的目录中有一个名为db.sqlite3的文件

Shell
现在,为了开始向数据库添加信息并对其进行操作,我们可以进入 Django 的 shell,在那里我们可以在项目中运行 Python 命令。
python manage.py shell
Python 3.7.2 (default, Dec 29 2018, 00:00:04)
Type 'copyright', 'credits' or 'license' for more information
IPython 6.5.0 -- An enhanced Interactive Python. Type '?' for help.
# Import our flight model In [1]: from flights.models import Flight
# Create a new flight In [2]: f = Flight(origin="New York", destination="London", duration=415)
# Instert that flight into our database In [3]: f.save()
# Query for all flights stored in the database In [4]: Flight.objects.all()
Out[4]: <QuerySet [<Flight: Flight object (1)>]>
当我们查询数据库时,我们看到我们只得到一个名为Flight object (1)的航班。这个名字不是很 informative,但我们可以修复它。在models.py中,我们将定义一个__str__函数,该函数提供将 Flight 对象转换为字符串的指令:
class Flight(models.Model):
origin = models.CharField(max_length=64)
destination = models.CharField(max_length=64)
duration = models.IntegerField()
def __str__(self):
return f"{self.id}: {self.origin} to {self.destination}"
现在,当我们回到 shell 时,我们的输出更易于阅读:
# Create a variable called flights to store the results of a query In [7]: flights = Flight.objects.all()
# Displaying all flights In [8]: flights
Out[8]: <QuerySet [<Flight: 1: New York to London>]>
# Isolating just the first flight In [9]: flight = flights.first()
# Printing flight information In [10]: flight
Out[10]: <Flight: 1: New York to London>
# Display flight id In [11]: flight.id
Out[11]: 1
# Display flight origin In [12]: flight.origin
Out[12]: 'New York'
# Display flight destination In [13]: flight.destination
Out[13]: 'London'
# Display flight duration In [14]: flight.duration
Out[14]: 415
这是一个好的开始,但回想一下之前,我们不想为每个航班存储城市名称作为起点和终点,所以我们可能需要一个与航班模型相关联的机场模型:
class Airport(models.Model):
code = models.CharField(max_length=3)
city = models.CharField(max_length=64)
def __str__(self):
return f"{self.city} ({self.code})"
class Flight(models.Model):
origin = models.ForeignKey(Airport, on_delete=models.CASCADE, related_name="departures")
destination = models.ForeignKey(Airport, on_delete=models.CASCADE, related_name="arrivals")
duration = models.IntegerField()
def __str__(self):
return f"{self.id}: {self.origin} to {self.destination}"
我们在新的Airport类中已经看到了所有内容,但Flight类中origin和destination字段的变化对我们来说是新的:
-
我们指定
origin和destination字段是每个外键,这意味着它们引用另一个对象。 -
通过将
Airport作为我们的第一个参数输入,我们指定了这个字段所引用的对象的类型。 -
下一个参数
on_delete=models.CASCADE指示了如果删除机场时应该发生什么。在这种情况下,我们指定当删除机场时,与其关联的所有航班也应该被删除。除了CASCADE之外,还有 其他几个选项。 -
我们提供了一个 相关名称,这为我们提供了一种通过给定机场作为起点或终点来搜索所有航班的途径。
每次我们在 models.py 中进行更改时,我们必须进行迁移然后迁移。请注意,您可能需要删除现有的从纽约到伦敦的航班,因为它不符合新的数据库结构。
# Create New Migrations
python manage.py makemigrations
# Migrate
python manage.py migrate
现在,让我们在 Django 命令行中尝试这些新的模型:
# Import all models In [1]: from flights.models import *
# Create some new airports In [2]: jfk = Airport(code="JFK", city="New York")
In [4]: lhr = Airport(code="LHR", city="London")
In [6]: cdg = Airport(code="CDG", city="Paris")
In [9]: nrt = Airport(code="NRT", city="Tokyo")
# Save the airports to the database In [3]: jfk.save()
In [5]: lhr.save()
In [8]: cdg.save()
In [10]: nrt.save()
# Add a flight and save it to the database f = Flight(origin=jfk, destination=lhr, duration=414)
f.save()
# Display some info about the flight In [14]: f
Out[14]: <Flight: 1: New York (JFK) to London (LHR)>
In [15]: f.origin
Out[15]: <Airport: New York (JFK)>
# Using the related name to query by airport of arrival: In [17]: lhr.arrivals.all()
Out[17]: <QuerySet [<Flight: 1: New York (JFK) to London (LHR)>]>
启动我们的应用程序
我们现在可以开始构建一个应用程序,该应用程序围绕使用模型与数据库交互的过程。让我们首先为我们的航空公司创建一个索引路由。在 urls.py 中:
urlpatterns = [
path('', views.index, name="index"),
]
在 views.py 文件中:
from django.shortcuts import render
from .models import Flight, Airport
# Create your views here.
def index(request):
return render(request, "flights/index.html", {
"flights": Flight.objects.all()
})
在我们的新 layout.html 文件中:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Flights</title>
</head>
<body>
{% block body %}
{% endblock %}
</body>
</html>
在新的 index.html 文件中:
{% extends "flights/layout.html" %}
{% block body %}
<h1>Flights:</h1>
<ul>
{% for flight in flights %}
<li>Flight {{ flight.id }}: {{ flight.origin }} to {{ flight.destination }}</li>
{% endfor %}
</ul>
{% endblock %}
我们在这里所做的是创建了一个默认页面,其中列出了我们迄今为止创建的所有航班。当我们现在打开这个页面时,它看起来像这样

现在,让我们通过返回 Django 命令行来为我们的应用程序添加更多航班:
# Using the filter command to find all airports based in New York In [3]: Airport.objects.filter(city="New York")
Out[3]: <QuerySet [<Airport: New York (JFK)>]>
# Using the get command to get only one airport in New York In [5]: Airport.objects.get(city="New York")
Out[5]: <Airport: New York (JFK)>
# Assigning some airports to variable names: In [6]: jfk = Airport.objects.get(city="New York")
In [7]: cdg = Airport.objects.get(city="Paris")
# Creating and saving a new flight: In [8]: f = Flight(origin=jfk, destination=cdg, duration=435)
In [9]: f.save()
现在,当我们再次访问我们的网站时

Django 管理员
由于开发者经常需要创建新对象,就像我们在 shell 中所做的那样,Django 提供了一个 默认管理界面,这使得我们可以更容易地完成这项工作。要开始使用这个工具,我们必须首先创建一个管理用户:
(base) cleggett@Connors-MacBook-Pro airline % python manage.py createsuperuser
Username: user_a
Email address: a@a.com
Password:
Password (again):
Superuser created successfully.
现在,我们必须通过在我们的应用中进入 admin.py 文件,并导入和注册我们的模型,将我们的模型添加到管理应用程序中。这告诉 Django 我们希望在管理应用程序中访问哪些模型。
from django.contrib import admin
from .models import Flight, Airport
# Register your models here. admin.site.register(Flight)
admin.site.register(Airport)
现在,当我们访问我们的网站并将 /admin 添加到 URL 中时,我们可以登录到一个看起来像这样的页面

登录后,您将被带到下面的页面,您可以在其中创建、编辑和删除存储在数据库中的对象

现在,让我们为我们的网站添加更多页面。我们将首先添加点击航班以获取更多航班信息的功能。为此,让我们创建一个包含航班 id 的 URL 路径:
path("<int:flight_id>", views.flight, name="flight")
然后,在 views.py 中,我们将创建一个 flight 函数,它接受一个航班 ID 并渲染一个新的 HTML 页面:
def flight(request, flight_id):
flight = Flight.objects.get(id=flight_id)
return render(request, "flights/flight.html", {
"flight": flight
})
现在,我们将创建一个模板来显示这些航班信息,并包含一个链接回到主页
{% extends "flights/layout.html" %}
{% block body %}
<h1>Flight {{ flight.id }}</h1>
<ul>
<li>Origin: {{ flight.origin }}</li>
<li>Destination: {{ flight.destination }}</li>
<li>Duration: {{ flight.duration }} minutes</li>
</ul>
<a href="{% url 'index' %}">All Flights</a>
{% endblock %}
最后,我们需要添加从一页链接到另一页的能力,因此我们将修改我们的索引页面以包含链接:
{% extends "flights/layout.html" %}
{% block body %}
<h1>Flights:</h1>
<ul>
{% for flight in flights %}
<li><a href="{% url 'flight' flight.id %}">Flight {{ flight.id }}</a>: {{ flight.origin }} to {{ flight.destination }}</li>
{% endfor %}
</ul>
{% endblock %}
现在主页看起来是这样的

例如,当我们点击航班 5 时,我们会来到这个页面

多对多关系
现在,让我们将乘客集成到我们的模型中。我们将首先创建一个乘客模型:
class Passenger(models.Model):
first = models.CharField(max_length=64)
last = models.CharField(max_length=64)
flights = models.ManyToManyField(Flight, blank=True, related_name="passengers")
def __str__(self):
return f"{self.first} {self.last}"
-
正如我们讨论的那样,乘客与航班有 多对多 的关系,我们在 Django 中使用 ManyToManyField 来描述这种关系。
-
此字段中的第一个参数是与该对象相关联的类的类型。
-
我们提供了
blank=True参数,这意味着乘客可以没有航班 -
我们添加了一个
related_name,它具有与之前相同的作用:它将允许我们找到给定航班上的所有乘客。
要实际应用这些更改,我们必须进行迁移并执行迁移。然后我们可以在 admin.py 中注册 Passenger 模型,并访问管理页面来创建一些乘客!
现在我们已经添加了一些乘客,让我们更新我们的航班页面,以便它显示航班上的所有乘客。我们首先访问 views.py 并更新我们的航班视图,以提供乘客列表作为上下文。我们使用之前定义的相关名称来访问列表。
def flight(request, flight_id):
flight = Flight.objects.get(id=flight_id)
passengers = flight.passengers.all()
return render(request, "flights/flight.html", {
"flight": flight,
"passengers": passengers
})
现在,将乘客列表添加到 flight.html:
<h2>Passengers:</h2>
<ul>
{% for passenger in passengers %}
<li>{{ passenger }}</li>
{% empty %}
<li>No Passengers.</li>
{% endfor %}
</ul>
在这一点上,当我们点击航班 5 时,我们看到

现在,让我们来为网站访客提供预订航班的能力。我们将通过在 urls.py 中添加一个预订路由来实现这一点:
path("<int:flight_id>/book", views.book, name="book")
现在,我们将在 views.py 中添加一个名为 book 的函数,该函数将乘客添加到航班中:
def book(request, flight_id):
# For a post request, add a new flight
if request.method == "POST":
# Accessing the flight
flight = Flight.objects.get(pk=flight_id)
# Finding the passenger id from the submitted form data
passenger_id = int(request.POST["passenger"])
# Finding the passenger based on the id
passenger = Passenger.objects.get(pk=passenger_id)
# Add passenger to the flight
passenger.flights.add(flight)
# Redirect user to flight page
return HttpResponseRedirect(reverse("flight", args=(flight.id,)))
接下来,我们将向我们的航班模板添加一些上下文,以便页面可以通过 Django 的能力从查询中排除某些对象来访问当前不是航班乘客的每个人:
def flight(request, flight_id):
flight = Flight.objects.get(id=flight_id)
passengers = flight.passengers.all()
non_passengers = Passenger.objects.exclude(flights=flight).all()
return render(request, "flights/flight.html", {
"flight": flight,
"passengers": passengers,
"non_passengers": non_passengers
})
现在,我们将向我们的航班页面 HTML 添加一个表单,使用选择输入字段:
<form action="{% url 'book' flight.id %}" method="post">
{% csrf_token %}
<select name="passenger" id="">
{% for passenger in non_passengers %}
<option value="{{ passenger.id }}">{{ passenger }}</option>
{% endfor %}
</select>
<input type="submit">
</form>
现在,让我们看看我访问航班页面并添加乘客后网站看起来像什么


使用 Django 管理应用的一个优点是它可以自定义。例如,如果我们希望在管理界面中看到航班的各个方面,我们可以在 admin.py 中创建一个新的类,并在注册 Flight 模型时将其作为参数添加:
class FlightAdmin(admin.ModelAdmin):
list_display = ("id", "origin", "destination", "duration")
# Register your models here. admin.site.register(Flight, FlightAdmin)
现在,当我们访问航班的管理页面时,我们还可以看到 id

查阅 Django 的管理文档 以找到更多自定义管理应用的方法。
用户
今天讲座的最后我们将讨论认证的概念,即允许用户登录和退出网站。幸运的是,Django 为我们简化了这一过程,让我们通过一个示例来看看我们如何实现。我们首先创建一个名为users的新应用。在这里,我们将完成创建新应用的所有常规步骤,但在我们的新urls.py文件中,我们将添加一些额外的路由:
urlpatterns = [
path('', views.index, name="index"),
path("login", views.login_view, name="login"),
path("logout", views.logout_view, name="logout")
]
让我们从创建一个用户可以登录的表单开始。我们将像往常一样创建一个layout.html文件,然后创建一个login.html文件,该文件包含一个表单,并在存在消息时显示该消息。
{% extends "users/layout.html" %}
{% block body %}
{% if message -%}
<div>{{ message }}</div>
{%- endif %}
<form action="{% url 'login' %}" method="post">
{% csrf_token %}
<input type="text", name="username", placeholder="Username">
<input type="password", name="password", placeholder="Password">
<input type="submit", value="Login">
</form>
{% endblock %}
现在,在views.py中,我们将添加三个函数:
def index(request):
# If no user is signed in, return to login page:
if not request.user.is_authenticated:
return HttpResponseRedirect(reverse("login"))
return render(request, "users/user.html")
def login_view(request):
return render(request, "users/login.html")
def logout_view(request):
# Pass is a simple way to tell python to do nothing.
pass
接下来,我们可以前往管理站点并添加一些用户。完成之后,我们将回到views.py并更新我们的login_view函数以处理带有用户名和密码的POST请求:
# Additional imports we'll need: from django.contrib.auth import authenticate, login, logout
def login_view(request):
if request.method == "POST":
# Accessing username and password from form data
username = request.POST["username"]
password = request.POST["password"]
# Check if username and password are correct, returning User object if so
user = authenticate(request, username=username, password=password)
# If user object is returned, log in and route to index page:
if user:
login(request, user)
return HttpResponseRedirect(reverse("index"))
# Otherwise, return login page again with new context
else:
return render(request, "users/login.html", {
"message": "Invalid Credentials"
})
return render(request, "users/login.html")
现在,我们将创建一个user.html文件,当用户认证时,index函数将渲染此文件:
{% extends "users/layout.html" %}
{% block body %}
<h1>Welcome, {{ request.user.first_name }}</h1>
<ul>
<li>Username: {{ request.user.username }}</li>
<li>Email: {{ request.user.email }}</li>
</ul>
<a href="{% url 'logout' %}">Log Out</a>
{% endblock %}
最后,为了允许用户登出,我们将更新logout_view函数,使其使用 Django 的内置logout函数:
def logout_view(request):
logout(request)
return render(request, "users/login.html", {
"message": "Logged Out"
})
现在我们已经完成,这是一个网站的演示

这节课就到这里!下次,我们将学习课程中的第二种编程语言:JavaScript。
第五讲
-
简介
-
JavaScript
-
事件
-
变量
-
querySelector
-
DOM 操作
-
JavaScript 控制台
-
箭头函数
-
待办事项列表
-
-
间隔
-
本地存储
-
APIs
-
JavaScript 对象
-
货币兑换
-
简介
-
到目前为止,我们已经讨论了如何使用 HTML 和 CSS 构建简单的网页,以及如何使用 Git 和 GitHub 来跟踪代码更改并与他人协作。我们还熟悉了 Python 编程语言,开始使用 Django 创建网络应用程序,并学习了如何使用 Django 模型在我们的网站上存储信息。
-
今天,我们将介绍一种新的编程语言:JavaScript。
JavaScript
让我们从几节课前的一个图表开始回顾:

回想一下,在大多数在线交互中,我们有一个客户端/用户向服务器发送 HTTP 请求,服务器发送 HTTP 响应。我们迄今为止使用 Django 编写的所有 Python 代码都在服务器上运行。JavaScript 将允许我们在客户端运行代码,这意味着在运行时不需要与服务器交互,使我们的网站变得更加互动。
为了在我们的页面上添加一些 JavaScript,我们可以在 HTML 页面的某个位置添加一对 <script> 标签。我们使用 <script> 标签来通知浏览器,在两个标签之间写入的任何内容都是我们希望在用户访问我们的网站时执行的 JavaScript 代码。我们的第一个程序可能看起来像这样:
alert('Hello, world!');
JavaScript 中的 alert 函数向用户显示一条消息,然后他们可以将其关闭。为了展示这在实际 HTML 文档中的位置,这里有一个包含一些 JavaScript 的简单页面的示例:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hello</title>
<script>
alert('Hello, world!');
</script>
</head>
<body>
<h1>Hello!</h1>
</body> </html>

事件
JavaScript 的一个特性是它支持 事件驱动编程,这使得它在网页编程中非常有用。
事件驱动编程是一种编程范式,它围绕事件的检测以及检测到事件时应采取的操作展开。
事件可以是几乎任何东西,包括按钮被点击、光标移动、输入响应或页面加载。几乎用户与网页交互的每一件事都可以被视为一个事件。在 JavaScript 中,我们使用 事件监听器 来等待某些事件的发生,然后执行一些代码。
让我们从将上面的 JavaScript 转换为名为hello的函数开始:
function hello() {
alert('Hello, world!')
}
现在,让我们工作在每次点击按钮时运行这个函数。为此,我们将在页面上创建一个带有onclick属性的 HTML 按钮,该属性为浏览器提供了当按钮被点击时应执行的操作的指令:
<button onclick="hello()">Click Here</button>
这些更改允许我们在某些事件发生之前等待运行 JavaScript 代码的某些部分。
变量
JavaScript 是一种编程语言,就像 Python、C 或你之前工作过的任何其他语言一样,这意味着它具有与其他语言相同的许多功能,包括变量。在 JavaScript 中,我们可以使用以下三个关键字来分配值:
var:用于在全局范围内定义变量
var age = 20;
let:用于在当前块(如函数或循环)中定义作用域有限的变量
let counter = 1;
const:用于定义不会改变的值
const PI = 3.14;
为了说明我们可以如何使用变量,让我们看看一个跟踪计数器的页面:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Count</title>
<script>
let counter = 0;
function count() {
counter++;
alert(counter);
}
</script>
</head>
<body>
<h1>Hello!</h1>
<button onclick="count()">Count</button>
</body>
</html>

querySelector
除了允许我们通过弹窗显示消息外,JavaScript 还允许我们更改页面上的元素。为了做到这一点,我们首先需要介绍一个名为document.querySelector的函数。这个函数会搜索并返回 DOM 中的元素。例如,我们会使用:
let heading = document.querySelector('h1');
以提取一个标题。然后,为了操作我们最近找到的元素,我们可以更改其innerHTML属性:
heading.innerHTML = `Goodbye!`;
就像在 Python 中一样,我们也可以在 JavaScript 中利用条件。例如,让我们说,如果我们不想总是将标题更改为Goodbye!,我们希望在不同之间切换Hello!和Goodbye!。我们的页面可能看起来像下面这样。注意,在 JavaScript 中,我们使用===作为两个项目之间更强的比较,它还会检查对象是否属于同一类型。我们通常尽可能使用===。
<!DOCTYPE html>
<html lang="en">
<head>
<title>Count</title>
<script>
function hello() {
const header = document.querySelector('h1');
if (header.innerHTML === 'Hello!') {
header.innerHTML = 'Goodbye!';
}
else {
header.innerHTML = 'Hello!';
}
}
</script>
</head>
<body>
<h1>Hello!</h1>
<button onclick="hello()">Click Here</button>
</body>
</html>

DOM 操作
让我们利用 DOM 操作这个想法来改进我们的计数页面:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Count</title>
<script>
let counter = 0;
function count() {
counter++;
document.querySelector('h1').innerHTML = counter;
}
</script>
</head>
<body>
<h1>0</h1>
<button onclick="count()">Count</button>
</body>
</html>

我们可以通过在计数器达到十的倍数时显示一个弹窗来使这个页面更有趣。在这个弹窗中,我们想要格式化一个字符串以自定义消息,在 JavaScript 中我们可以使用模板字符串来完成。模板字符串要求整个表达式周围有反引号(`),任何替换项周围有美元符号和花括号。例如,让我们改变我们的计数函数
function count() {
counter++;
document.querySelector('h1').innerHTML = counter;
if (counter % 10 === 0) {
alert(`Count is now ${counter}`)
}
}

现在,让我们看看我们可以如何改进这个页面的设计。首先,就像我们试图避免使用 CSS 的内联样式一样,我们希望尽可能避免内联 JavaScript。在我们的计数器示例中,我们可以通过添加一行脚本,改变页面按钮的 onclick 属性,并从 button 标签内部移除 onclick 属性来实现这一点。
document.querySelector('button').onclick = count;
关于我们刚刚所做的一件事需要注意的一点是,我们不是通过在后面添加括号来调用 count 函数,而是仅仅命名这个函数。这指定了我们只希望在按钮被点击时调用这个函数。这之所以可行,是因为,像 Python 一样,JavaScript 支持函数式编程,因此函数可以被当作值本身来处理。
仅通过上述更改是不够的,正如我们通过检查页面和查看浏览器控制台所看到的那样:

这个错误出现是因为当 JavaScript 使用 document.querySelector('button') 搜索元素时,它没有找到任何东西。这是因为页面加载需要一点时间,而我们的 JavaScript 代码在按钮被渲染之前就运行了。为了解决这个问题,我们可以指定代码只有在页面加载后才会运行,使用 addEventListener 函数。这个函数接受两个参数:
-
要监听的事件(例如:
'click') -
当检测到事件时运行的函数(例如:上面的
hello)
我们可以使用这个函数来确保代码只在所有内容加载完毕后运行:
document.addEventListener('DOMContentLoaded', function() {
// Some code here
});
在上面的例子中,我们使用了一个 匿名函数,这是一个从未被赋予名称的函数。将这些放在一起,我们的 JavaScript 现在看起来是这样的:
let counter = 0;
function count() {
counter++;
document.querySelector('h1').innerHTML = counter;
if (counter % 10 === 0) {
alert(`Count is now ${counter}`)
}
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('button').onclick = count;
});
我们可以通过将 JavaScript 移入一个单独的文件来改进我们的设计。我们这样做的方式与我们为样式将 CSS 放入单独文件的方式非常相似:
-
将所有的 JavaScript 代码都写入一个以
.js结尾的单独文件中,比如index.js。 -
给
<script>标签添加一个src属性,指向这个新文件。
对于我们的计数器页面,我们可以有一个名为 counter.html 的文件,其内容如下:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Count</title>
<script src="counter.js"></script>
</head>
<body>
<h1>0</h1>
<button>Count</button>
</body>
</html>
以及一个名为 counter.js 的文件,其内容如下:
let counter = 0;
function count() {
counter++;
document.querySelector('h1').innerHTML = counter;
if (counter % 10 === 0) {
alert(`Count is now ${counter}`)
}
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('button').onclick = count;
});
将 JavaScript 放在单独的文件中有几个原因:
-
视觉吸引力:我们的单个 HTML 和 JavaScript 文件变得更加易读。
-
HTML 文件之间的访问:现在我们可以有多个 HTML 文件,它们共享相同的 JavaScript。
-
协作:现在,我们可以轻松地让一个人处理 JavaScript,而另一个人处理 HTML。
-
导入:我们可以导入其他人已经编写的 JavaScript 库。例如 Bootstrap 有自己的 JavaScript 库,你可以包含它来使你的网站更具交互性。
让我们开始另一个示例页面,这个页面可以更加互动。下面,我们将创建一个页面,用户可以在其中输入他们的名字以获取自定义问候。
<!DOCTYPE html>
<html lang="en">
<head>
<title>Hello</title>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('form').onsubmit = function() {
const name = document.querySelector('#name').value;
alert(`Hello, ${name}`);
};
});
</script>
</head>
<body>
<form>
<input autofocus id="name" placeholder="Name" type="text">
<input type="submit">
</form>
</body>
</html>

关于上面页面的几点说明:
-
我们在
name输入的autofocus字段中使用了data-SOMETHING属性来指示光标应在页面加载时立即设置在该输入内。 -
我们在
document.querySelector内部使用#name来查找具有id为name的元素。在这个函数中,我们可以使用与 CSS 中相同的所有选择器。 -
我们使用输入字段的
value属性来查找当前输入的内容。
我们可以使用 JavaScript 不仅向页面添加 HTML,还可以更改页面的样式!在下面的页面中,我们使用按钮来更改标题的颜色。
<!DOCTYPE html>
<html lang="en">
<head>
<title>Colors</title>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('button').forEach(function(button) {
button.onclick = function() {
document.querySelector("#hello").style.color = button.dataset.color;
}
});
});
</script>
</head>
<body>
<h1 id="hello">Hello</h1>
<button data-color="red">Red</button>
<button data-color="blue">Blue</button>
<button data-color="green">Green</button>
</body>
</html>

关于上面页面的几点说明:
-
我们使用
style.SOMETHING属性来更改元素的风格。 -
我们使用
data-SOMETHING属性将数据分配给 HTML 元素。我们可以在 JavaScript 中使用元素的dataset属性稍后访问该数据。 -
我们使用
querySelectorAll函数来获取一个包含所有匹配查询的元素的Node List(类似于 Python 列表或 JavaScript 数组)。 -
JavaScript 中的
forEach函数接受另一个函数,并将该函数应用于列表或数组中的每个元素。
JavaScript 控制台
控制台是一个有用的工具,可以用来测试小块代码和调试。你可以在控制台中编写和运行 JavaScript 代码,这可以通过在网页浏览器中检查元素然后点击console来实现。(具体过程可能因浏览器而异。)调试的一个有用工具是向控制台打印,你可以使用console.log函数来完成。例如,在上面的colors.html页面中,我可以添加以下行:
console.log(document.querySelectorAll('button'));
这在控制台给出了以下结果:

箭头函数
除了我们之前已经看到的传统函数表示法之外,JavaScript 现在还允许我们使用箭头函数,其中有一个输入(或当没有输入时括号)后跟=>,然后是执行一些代码。例如,我们可以修改上面的脚本以使用匿名箭头函数:
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('button').forEach(button => {
button.onclick = () => {
document.querySelector("#hello").style.color = button.dataset.color;
}
});
});
我们也可以有命名函数,使用箭头,就像对count函数的这种重写:
count = () => {
counter++;
document.querySelector('h1').innerHTML = counter;
if (counter % 10 === 0) {
alert(`Count is now ${counter}`)
}
}
要了解我们可以使用的一些其他事件,让我们看看如何使用下拉菜单而不是三个单独的按钮来实现我们的颜色切换器。我们可以使用onchange属性检测select元素的变化。在 JavaScript 中,this是一个根据其使用上下文而变化的关键字。在事件处理程序的情况下,this指的是触发事件的那个对象。
<!DOCTYPE html>
<html lang="en">
<head>
<title>Colors</title>
<script>
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('select').onchange = function() {
document.querySelector('#hello').style.color = this.value;
}
});
</script>
</head>
<body>
<h1 id="hello">Hello</h1>
<select>
<option value="black">Black</option>
<option value="red">Red</option>
<option value="blue">Blue</option>
<option value="green">Green</option>
</select>
</body>
</html>

在 JavaScript 中,我们可以检测许多其他事件,包括以下常见的:
-
onclick -
onmouseover -
onkeydown -
onkeyup -
onload -
onblur -
…
TODO 列表
为了将本节课学到的几个知识点结合起来,让我们尝试使用 JavaScript 制作一个完全基于 JavaScript 的 TODO 列表。我们将从编写页面的 HTML 布局开始。注意以下内容,我们为无序列表留出了空间,但我们还没有添加任何内容。同时注意,我们在tasks.js中添加了一个链接,我们将在这里编写 JavaScript。
<!DOCTYPE html>
<html lang="en">
<head>
<title>Tasks</title>
<script src="tasks.js"></script>
</head>
<body>
<h1>Tasks</h1>
<ul id="tasks"></ul>
<form>
<input id="task" placeholder = "New Task" type="text">
<input id="submit" type="submit">
</form>
</body>
</html>
现在,这是我们的代码,我们可以将其保存在tasks.js中。以下是一些关于您将看到的内容的说明:
-
这段代码与讲座中的代码略有不同。在这里,我们只在开始时查询我们的提交按钮和输入任务字段一次,并将这两个值存储在变量
submit和newTask中。 -
我们可以通过设置其
disabled属性为false/true来启用/禁用按钮。 -
在 JavaScript 中,我们使用
.length来查找字符串和数组等对象的长短。 -
在脚本的末尾,我们添加一行
return false。这防止了表单的默认提交,这可能涉及重新加载当前页面或重定向到新页面。 -
在 JavaScript 中,我们可以使用createElement函数创建 HTML 元素。然后我们可以使用
append函数将这些元素添加到 DOM 中。
// Wait for page to load
document.addEventListener('DOMContentLoaded', function() {
// Select the submit button and input to be used later
const submit = document.querySelector('#submit');
const newTask = document.querySelector('#task');
// Disable submit button by default:
submit.disabled = true;
// Listen for input to be typed into the input field
newTask.onkeyup = () => {
if (newTask.value.length > 0) {
submit.disabled = false;
}
else {
submit.disabled = true;
}
}
// Listen for submission of form
document.querySelector('form').onsubmit = () => {
// Find the task the user just submitted
const task = newTask.value;
// Create a list item for the new task and add the task to it
const li = document.createElement('li');
li.innerHTML = task;
// Add new element to our unordered list:
document.querySelector('#tasks').append(li);
// Clear out input field:
newTask.value = '';
// Disable the submit button again:
submit.disabled = true;
// Stop form from submitting
return false;
}
});

间隔
除了指定在事件触发时运行函数外,我们还可以设置函数在设定的时间后运行。例如,让我们回到我们的计数器页面的脚本,并添加一个间隔,即使用户没有点击任何东西,计数器也会每秒增加。为此,我们使用setInterval函数,该函数接受一个要运行的函数和一个函数运行之间的时间(以毫秒为单位)作为参数。
let counter = 0;
function count() {
counter++;
document.querySelector('h1').innerHTML = counter;
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('button').onclick = count;
setInterval(count, 1000);
});

本地存储
到目前为止,我们所有的网站都有一个需要注意的事情,那就是每次我们重新加载页面时,我们所有的信息都会丢失。标题颜色会变回黑色,计数器会回到 0,所有的任务都会被清除。有时这是我们想要的,但有时我们希望能够存储信息,以便用户返回网站时可以使用。
我们可以这样做的一种方式是使用本地存储,或者将信息存储在用户的网页浏览器中,我们可以在以后访问它。这些信息以一组键值对的形式存储,几乎就像 Python 字典一样。为了使用本地存储,我们将使用两个关键函数:
-
localStorage.getItem(key): 这个函数在本地存储中搜索具有给定键的条目,并返回与该键关联的值。 -
localStorage.setItem(key, value): 这个函数在本地存储中设置一个条目,将键与一个新值关联。
让我们看看如何使用这些新功能来更新我们的计数器!在下面的代码中,
// Check if there is already a value in local storage
if (!localStorage.getItem('counter')) {
// If not, set the counter to 0 in local storage
localStorage.setItem('counter', 0);
}
function count() {
// Retrieve counter value from local storage
let counter = localStorage.getItem('counter');
// update counter
counter++;
document.querySelector('h1').innerHTML = counter;
// Store counter in local storage
localStorage.setItem('counter', counter);
}
document.addEventListener('DOMContentLoaded', function() {
// Set heading to the current value inside local storage
document.querySelector('h1').innerHTML = localStorage.getItem('counter');
document.querySelector('button').onclick = count;
});
APIs
JavaScript 对象
一个JavaScript 对象与 Python 字典非常相似,因为它允许我们存储键值对。例如,我可以创建一个代表哈利·波特的 JavaScript 对象:
let person = {
first: 'Harry',
last: 'Potter'
};
我可以使用括号或点符号来访问或更改该对象的部分:

JavaScript 对象的一个非常有用的用途是在一个网站和另一个网站之间传输数据,尤其是在使用APIs时
API,或应用程序编程接口,是两个不同应用程序之间结构化通信的形式。
例如,我们可能希望我们的应用程序从谷歌地图、亚马逊或某些天气预报服务中获取信息。我们可以通过调用服务的 API 来实现这一点,它将返回结构化数据给我们,通常以JSON(JavaScript 对象表示法)的形式。例如,一个航班在 JSON 形式中可能看起来像这样:
{ "origin": "New York", "destination": "London", "duration": 415 }
JSON 中的值不必仅仅是字符串和数字,如上面的例子所示。我们还可以存储列表,甚至其他 JavaScript 对象:
{ "origin": { "city": "New York", "code": "JFK" }, "destination": { "city": "London", "code": "LHR" }, "duration": 415 }
货币兑换
为了展示我们如何在应用程序中使用 API,让我们构建一个应用程序,我们可以找到两种货币之间的汇率。在整个练习中,我们将使用欧洲中央银行的汇率 API。通过访问他们的网站,你会看到 API 的文档,这通常是当你想使用 API 时开始的好地方。我们可以通过访问 URL 来测试这个 API:api.exchangeratesapi.io/latest?base=USD。当你访问这个页面时,你会看到美元与其他许多货币之间的汇率,以 JSON 形式呈现。你还可以通过将 URL 中的 GET 参数从USD更改为任何其他货币代码来更改你得到的汇率。
让我们通过创建一个名为currency.html的新 HTML 文件并将其链接到一个 JavaScript 文件来实现将此 API 集成到应用程序中,但保持主体为空:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Currency Exchange</title>
<script src="currency.js"></script>
</head>
<body></body>
</html>
现在,我们将使用一种叫做 AJAX 的东西,或者称为异步 JavaScript 和 XML,它允许我们在页面加载后访问外部页面的信息。为了做到这一点,我们将使用 fetch 函数,这将允许我们发送 HTTP 请求。fetch 函数返回一个 promise。我们在这里不会详细讨论 promise 的细节,但我们可以将其视为某个时刻会传递过来的值,但不一定是立即。我们通过给它们一个 .then 属性来处理 promise,该属性描述了在接收到 response 时应该执行的操作。下面的代码片段将把我们的响应记录到控制台。
document.addEventListener('DOMContentLoaded', function() {
// Send a GET request to the URL
fetch('https://api.exchangeratesapi.io/latest?base=USD')
// Put response into json form
.then(response => response.json())
.then(data => {
// Log data to the console
console.log(data);
});
});

关于上述代码的一个重要观点是,.then 的参数始终是一个函数。尽管看起来我们正在创建 response 和 data 这两个变量,但这些变量仅仅是两个匿名函数的参数。
而不是简单地记录这些数据,我们可以使用 JavaScript 在屏幕上显示一条消息,如下面的代码所示:
document.addEventListener('DOMContentLoaded', function() {
// Send a GET request to the URL
fetch('https://api.exchangeratesapi.io/latest?base=USD')
// Put response into json form
.then(response => response.json())
.then(data => {
// Get rate from data
const rate = data.rates.EUR;
// Display message on the screen
document.querySelector('body').innerHTML = `1 USD is equal to ${rate.toFixed(3)} EUR.`;
});
});

现在,让我们通过允许用户选择他们想看到的货币来使网站更加互动。我们将首先修改我们的 HTML,以便用户可以输入货币:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Currency Exchange</title>
<script src="currency.js"></script>
</head>
<body>
<form>
<input id="currency" placeholder="Currency" type="text">
<input type="submit" value="Convert">
</form>
<div id="result"></div>
</body>
</html>
现在,我们将对 JavaScript 进行一些修改,使其仅在表单提交时才改变,并考虑到用户的输入。我们还将在这里添加一些错误检查:
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('form').onsubmit = function() {
// Send a GET request to the URL
fetch('https://api.exchangeratesapi.io/latest?base=USD')
// Put response into json form
.then(response => response.json())
.then(data => {
// Get currency from user input and convert to upper case
const currency = document.querySelector('#currency').value.toUpperCase();
// Get rate from data
const rate = data.rates[currency];
// Check if currency is valid:
if (rate !== undefined) {
// Display exchange on the screen
document.querySelector('#result').innerHTML = `1 USD is equal to ${rate.toFixed(3)} ${currency}.`;
}
else {
// Display error on the screen
document.querySelector('#result').innerHTML = 'Invalid Currency.';
}
})
// Catch any errors and log them to the console
.catch(error => {
console.log('Error:', error);
});
// Prevent default submission
return false;
}
});

这节课的内容就到这里!下次,我们将探讨如何使用 JavaScript 创建更加吸引人的用户界面!
第六讲
-
简介
-
用户界面
-
单页应用程序
-
滚动
- 无限滚动
-
动画
-
React
- 加法
简介
-
到目前为止,我们已经讨论了如何使用 HTML 和 CSS 构建简单的网页,以及如何使用 Git 和 GitHub 来跟踪我们代码的变化并与他人协作。我们还熟悉了 Python 编程语言,开始使用 Django 创建 Web 应用程序,并学习了如何使用 Django 模型在我们的网站上存储信息。然后我们介绍了 JavaScript,并学习了如何使用它使网页更加互动。
-
今天,我们将讨论用户界面设计中的常见范式,使用 JavaScript 和 CSS 使我们的网站更加用户友好。
用户界面
用户界面是网页访问者与该页面交互的方式。作为 Web 开发者,我们的目标是让这些交互尽可能愉快,我们可以使用许多方法来实现这一点。
单页应用程序
以前,如果我们想要一个包含多个页面的网站,我们会通过 Django 应用程序中的不同路由来实现这一点。现在,我们有能力只加载一个页面,然后使用 JavaScript 来操作 DOM。这样做的一个主要优点是我们只需要修改实际改变的部分页面。例如,如果我们有一个不根据你的当前页面变化的导航栏(Nav Bar),我们就不想每次切换到页面的新部分时都要重新渲染那个导航栏。
让我们看看一个如何在 JavaScript 中模拟页面切换的例子:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Single Page</title>
<style>
div {
display: none;
}
</style>
<script src="singlepage.js"></script>
</head>
<body>
<button data-page="page1">Page 1</button>
<button data-page="page2">Page 2</button>
<button data-page="page3">Page 3</button>
<div id="page1">
<h1>This is page 1</h1>
</div>
<div id="page2">
<h1>This is page 2</h1>
</div>
<div id="page3">
<h1>This is page 3</h1>
</div>
</body>
</html>
注意在上述 HTML 中,我们有三个按钮和三个 div。目前,div 中只包含一小部分文本,但我们可以想象每个 div 包含我们网站上的一页内容。现在,我们将添加一些 JavaScript,允许我们使用按钮在页面之间切换。
// Shows one page and hides the other two
function showPage(page) {
// Hide all of the divs:
document.querySelectorAll('div').forEach(div => {
div.style.display = 'none';
});
// Show the div provided in the argument
document.querySelector(`#${page}`).style.display = 'block';
}
// Wait for page to loaded:
document.addEventListener('DOMContentLoaded', function() {
// Select all buttons
document.querySelectorAll('button').forEach(button => {
// When a button is clicked, switch to that page
button.onclick = function() {
showPage(this.dataset.page);
}
})
});

在许多情况下,当我们首次访问一个网站时,加载每一页的全部内容将是不高效的,因此我们需要使用服务器来访问新数据。例如,当你访问一个新闻网站时,如果它在你首次访问页面时必须加载所有可用的文章,那么网站加载将花费非常长的时间。我们可以通过使用与我们在前一次讲座中加载货币汇率时使用的类似策略来避免这个问题。这次,我们将探讨如何使用 Django 从我们的单页应用程序发送和接收信息。为了展示这是如何工作的,让我们看看一个简单的 Django 应用程序。它在urls.py中有两个 URL 模式:
urlpatterns = [
path("", views.index, name="index"),
path("sections/<int:num>", views.section, name="section")
]
以及views.py中的两个相应路由。请注意,section路由接受一个整数,然后根据该整数返回一个基于 HTTP 响应的文本字符串。
from django.http import Http404, HttpResponse
from django.shortcuts import render
# Create your views here. def index(request):
return render(request, "singlepage/index.html")
# The texts are much longer in reality, but have
# been shortened here to save space texts = ["Text 1", "Text 2", "Text 3"]
def section(request, num):
if 1 <= num <= 3:
return HttpResponse(texts[num - 1])
else:
raise Http404("No such section")
现在,在我们的index.html文件中,我们将利用我们上次讲座中了解到的 AJAX,向服务器发送请求以获取特定部分的文本并在屏幕上显示:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Single Page</title>
<style>
</style>
<script>
// Shows given section
function showSection(section) {
// Find section text from server
fetch(`/sections/${section}`)
.then(response => response.text())
.then(text => {
// Log text and display on page
console.log(text);
document.querySelector('#content').innerHTML = text;
});
}
document.addEventListener('DOMContentLoaded', function() {
// Add button functionality
document.querySelectorAll('button').forEach(button => {
button.onclick = function() {
showSection(this.dataset.section);
};
});
});
</script>
</head>
<body>
<h1>Hello!</h1>
<button data-section="1">Section 1</button>
<button data-section="2">Section 2</button>
<button data-section="3">Section 3</button>
<div id="content">
</div>
</body>
</html>

现在,我们已经创建了一个网站,我们可以从服务器加载新数据,而无需重新加载整个 HTML 页面!
然而,我们网站的缺点是 URL 现在信息量较少。您会注意到在上面的视频中,即使我们从一个部分切换到另一个部分,URL 仍然保持不变。我们可以使用JavaScript 历史 API来解决这个问题。此 API 允许我们将信息推送到浏览器历史记录并手动更新 URL。让我们看看我们如何使用此 API。想象我们有一个与上一个项目相同的 Django 项目,但这次我们希望修改我们的脚本以使用历史 API:
// When back arrow is clicked, show previous section
window.onpopstate = function(event) {
console.log(event.state.section);
showSection(event.state.section);
}
function showSection(section) {
fetch(`/sections/${section}`)
.then(response => response.text())
.then(text => {
console.log(text);
document.querySelector('#content').innerHTML = text;
});
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('button').forEach(button => {
button.onclick = function() {
const section = this.dataset.section;
// Add the current state to the history
history.pushState({section: section}, "", `section${section}`);
showSection(section);
};
});
});
在上面的showSection函数中,我们使用了history.pushState函数。此函数根据三个参数向我们的浏览历史添加一个新元素:
-
与状态相关的任何数据。
-
大多数网络浏览器忽略的标题参数
-
应该显示在 URL 中的内容
在上面的 JavaScript 中,我们做的另一个更改是在设置onpopstate参数,该参数指定了当用户点击后退箭头时应执行的操作。在这种情况下,我们希望在按钮按下时显示上一个部分。现在,网站看起来更加用户友好:

滚动
为了更新和访问浏览器历史记录,我们使用了名为window的重要 JavaScript 对象。窗口还有一些其他属性,我们可以使用它们来使我们的网站看起来更美观:
-
window.innerWidth:窗口的像素宽度 -
window.innerHeight:窗口的像素高度

当前的窗口代表用户当前可见的内容,而document则指整个网页,通常比窗口大得多,迫使用户滚动上下才能看到页面内容。为了处理滚动,我们可以访问其他变量:
-
window.scrollY:我们从页面顶部滚动的像素数 -
document.body.offsetHeight:整个文档的像素高度。

我们可以使用这些措施来确定用户是否已经滚动到页面的底部,使用比较 window.scrollY + window.innerHeight >= document.body.offsetHeight。例如,以下页面将在我们到达页面底部时将背景颜色更改为绿色:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Scroll</title>
<script>
// Event listener for scrolling
window.onscroll = () => {
// Check if we're at the bottom
if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
// Change color to green
document.querySelector('body').style.background = 'green';
} else {
// Change color to white
document.querySelector('body').style.background = 'white';
}
};
</script>
</head>
<body>
<p>1</p>
<p>2</p>
<!-- More paragraphs left out to save space -->
<p>99</p>
<p>100</p>
</body>
</html>

无限滚动
在页面底部更改背景颜色可能并不那么有用,但如果我们想实现 无限滚动,我们可能需要检测我们是否到达了页面的底部。例如,如果你在一个社交媒体网站上,你不想一次性加载所有帖子,你可能想先加载前十个,然后当用户到达底部时再加载下一个十个。让我们看看一个可以实现这一功能的 Django 应用程序。这个应用程序在 urls.py 中有两个路径
urlpatterns = [
path("", views.index, name="index"),
path("posts", views.posts, name="posts")
]
以及 views.py 中的两个相应视图:
import time
from django.http import JsonResponse
from django.shortcuts import render
# Create your views here. def index(request):
return render(request, "posts/index.html")
def posts(request):
# Get start and end points
start = int(request.GET.get("start") or 0)
end = int(request.GET.get("end") or (start + 9))
# Generate list of posts
data = []
for i in range(start, end + 1):
data.append(f"Post #{i}")
# Artificially delay speed of response
time.sleep(1)
# Return list of posts
return JsonResponse({
"posts": data
})
注意,posts 视图需要两个参数:一个 start 点和一个 end 点。在这个视图中,我们创建了自己的 API,可以通过访问网址 localhost:8000/posts?start=10&end=15 来测试,它返回以下 JSON:
{ "posts": [ "Post #10", "Post #11", "Post #12", "Post #13", "Post #14", "Post #15" ] }
现在,在网站加载的 index.html 模板中,我们一开始在主体中只有一个空的 div 和一些样式。注意,我们在开始时加载了静态文件,然后在我们 static 文件夹中引用了一个 JavaScript 文件。
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>My Webpage</title>
<style>
.post {
background-color: #77dd11;
padding: 20px;
margin: 10px;
}
body {
padding-bottom: 50px;
}
</style>
<script scr="{% static 'posts/script.js' %}"></script>
</head>
<body>
<div id="posts">
</div>
</body>
</html>
现在用 JavaScript,我们将等待用户滚动到页面底部,然后使用我们的 API 加载更多帖子:
// Start with first post
let counter = 1;
// Load posts 20 at a time
const quantity = 20;
// When DOM loads, render the first 20 posts
document.addEventListener('DOMContentLoaded', load);
// If scrolled to bottom, load the next 20 posts
window.onscroll = () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
load();
}
};
// Load next set of posts
function load() {
// Set start and end post numbers, and update counter
const start = counter;
const end = start + quantity - 1;
counter = end + 1;
// Get new posts and add posts
fetch(`/posts?start=${start}&end=${end}`)
.then(response => response.json())
.then(data => {
data.posts.forEach(add_post);
})
};
// Add a new post with given contents to DOM
function add_post(contents) {
// Create new post
const post = document.createElement('div');
post.className = 'post';
post.innerHTML = contents;
// Add post to DOM
document.querySelector('#posts').append(post);
};
现在,我们已经创建了一个具有无限滚动的网站!

动画
我们还可以通过添加一些动画来使我们的网站更有趣。事实证明,除了提供样式外,CSS 还使我们能够轻松地动画化 HTML 元素。
要在 CSS 中创建动画,我们使用以下格式,其中动画的具体内容可以包括起始和结束样式(to 和 from)或持续时间不同阶段的样式(从 0% 到 100%)。例如:
@keyframes animation_name {
from {
/* Some styling for the start */
}
to {
/* Some styling for the end */
}
}
或者:
@keyframes animation_name {
0% {
/* Some styling for the start */
}
75% {
/* Some styling after 3/4 of animation */
}
100% {
/* Some styling for the end */
}
}
然后,为了对一个元素应用动画,我们需要包含 animation-name、animation-duration(以秒为单位)和 animation-fill-mode(通常是 forwards)。例如,以下是一个页面,当第一次进入页面时标题会变大:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Animate</title>
<style>
@keyframes grow {
from {
font-size: 20px;
}
to {
font-size: 100px;
}
}
h1 {
animation-name: grow;
animation-duration: 2s;
animation-fill-mode: forwards;
}
</style>
</head>
<body>
<h1>Welcome!</h1>
</body>
</html>

我们不仅可以操纵大小:以下示例展示了我们如何通过更改几行来改变标题的位置:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Animate</title>
<style>
@keyframes move {
from {
left: 0%;
}
to {
left: 50%;
}
}
h1 {
position: relative;
animation-name: move;
animation-duration: 2s;
animation-fill-mode: forwards;
}
</style>
</head>
<body>
<h1>Welcome!</h1>
</body>
</html>

现在,让我们看看设置一些中间 CSS 属性。我们可以在动画的任何百分比处指定样式。在以下示例中,我们将标题从左到右移动,然后通过仅更改上面的动画将其移回左方
@keyframes move {
0% {
left: 0%;
}
50% {
left: 50%;
}
100% {
left: 0%;
}
}

如果我们想要重复动画多次,可以将animation-iteration-count属性更改为大于一的数字(甚至可以设置为infinite以实现无限动画)。我们可以设置许多动画属性,以改变动画的不同方面。
除了 CSS 之外,我们还可以使用 JavaScript 进一步控制动画。让我们使用我们的移动标题示例(具有无限重复)来展示我们如何创建一个开始和停止动画的按钮。假设我们已经有了一个动画、按钮和标题,我们可以添加以下脚本以开始和暂停动画:
document.addEventListener('DOMContentLoaded', function() {
// Find heading
const h1 = document.querySelector('h1');
// Pause Animation by default
h1.style.animationPlayState = 'paused';
// Wait for button to be clicked
document.querySelector('button').onclick = () => {
// If animation is currently paused, begin playing it
if (h1.style.animationPlayState == 'paused') {
h1.style.animationPlayState = 'running';
}
// Otherwise, pause the animation
else {
h1.style.animationPlayState = 'paused';
}
}
})

现在,让我们看看如何将我们对动画的新知识应用到我们之前制作的帖子页面。具体来说,假设我们希望在阅读完帖子后能够隐藏帖子。让我们想象一个与刚刚创建的项目相同的 Django 项目,但有一些 HTML 和 JavaScript 的细微差别。我们将做的第一个更改是修改add_post函数,这次也在帖子的右侧添加了一个按钮:
// Add a new post with given contents to DOM
function add_post(contents) {
// Create new post
const post = document.createElement('div');
post.className = 'post';
post.innerHTML = `${contents} <button class="hide">Hide</button>`;
// Add post to DOM
document.querySelector('#posts').append(post);
};
现在,我们将处理在点击“隐藏”按钮时隐藏帖子。为此,我们将添加一个事件监听器,它在用户点击页面上的任何地方时被触发。然后我们编写一个函数,该函数接受event作为参数,这很有用,因为我们可以使用event.target属性来访问被点击的元素。我们还可以使用parentElement类在 DOM 中找到给定元素的父元素。
// If hide button is clicked, delete the post
document.addEventListener('click', event => {
// Find what was clicked on
const element = event.target;
// Check if the user clicked on a hide button
if (element.className === 'hide') {
element.parentElement.remove()
}
});

我们现在可以看到我们已经实现了隐藏按钮,但它看起来并没有可能那么漂亮。也许我们希望帖子在移除之前先淡出并缩小。为了做到这一点,我们首先创建一个 CSS 动画。下面的动画将花费 75%的时间将opacity从 1 变为 0,这本质上使得帖子缓慢淡出。然后,它将剩余的时间将所有与height相关的属性移动到 0,有效地将帖子缩小到无。
@keyframes hide {
0% {
opacity: 1;
height: 100%;
line-height: 100%;
padding: 20px;
margin-bottom: 10px;
}
75% {
opacity: 0;
height: 100%;
line-height: 100%;
padding: 20px;
margin-bottom: 10px;
}
100% {
opacity: 0;
height: 0px;
line-height: 0px;
padding: 0px;
margin-bottom: 0px;
}
}
接下来,我们将添加此动画到我们帖子的 CSS 中。注意,我们最初将animation-play-state设置为paused,这意味着帖子默认不会隐藏。
.post {
background-color: #77dd11;
padding: 20px;
margin-bottom: 10px;
animation-name: hide;
animation-duration: 2s;
animation-fill-mode: forwards;
animation-play-state: paused;
}
最后,我们希望在点击“隐藏”按钮后开始动画,然后移除帖子。我们可以通过编辑上面的 JavaScript 来实现这一点:
// If hide button is clicked, delete the post
document.addEventListener('click', event => {
// Find what was clicked on
const element = event.target;
// Check if the user clicked on a hide button
if (element.className === 'hide') {
element.parentElement.style.animationPlayState = 'running';
element.parentElement.addEventListener('animationend', () => {
element.parentElement.remove();
});
}
});

如您所见,隐藏功能现在看起来好多了!
React
到目前为止,你可以想象在一个更复杂的网站上需要多少 JavaScript 代码。我们可以通过使用 JavaScript 框架来减轻我们实际上需要编写的代码量,就像我们使用 Bootstrap 作为 CSS 框架来减少我们实际上需要编写的 CSS 量一样。最受欢迎的 JavaScript 框架之一是一个名为React的库。
到目前为止,在这个课程中,我们一直在使用命令式编程方法,其中我们给计算机一组要执行的语句。例如,为了更新 HTML 页面中的计数器,我们可能有一段看起来像这样的代码:
查看视图:
<h1>0</h1>
逻辑:
let num = parseInt(document.querySelector("h1").innerHTML);
num += 1;
document.querySelector("h1").innerHTML = num;
React 允许我们使用声明式编程,这将使我们能够简单地编写代码来解释我们希望显示的内容,而不用担心如何显示它。在 React 中,计数器可能看起来更像是这样:
查看视图:
<h1>{num}</h1>
逻辑:
num += 1;
React 框架围绕组件的概念构建,每个组件都可以有一个底层状态。组件可以是网页上可见的任何东西,比如帖子或导航栏,而状态是与该组件相关的一组变量。React 的美丽之处在于,当状态发生变化时,React 会自动相应地更改 DOM。
有许多种使用 React 的方法(包括 Facebook 发布的流行create-react-app命令),但今天我们将专注于直接在 HTML 文件中开始。为此,我们必须导入三个 JavaScript 包:
-
React:定义组件及其行为 -
ReactDOM:将 React 组件插入到 DOM 中 -
Babel:将JSX,我们在 React 中使用的语言,转换为浏览器可以解释的纯 JavaScript。JSX 与 JavaScript 非常相似,但有一些额外的功能,包括在代码中表示 HTML 的能力。
让我们深入其中,创建我们的第一个 React 应用!
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://unpkg.com/react@17/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
<title>Hello</title>
</head>
<body>
<div id="app"></div>
<script type="text/babel">
function App() {
return (
<div>
Hello!
</div>
);
}
ReactDOM.render(<App />, document.querySelector("#app"));
</script>
</body>
</html>
由于这是我们第一个 React 应用,让我们详细看看这段代码的每个部分都在做什么:
-
在标题上方三行中,我们导入 React、ReactDOM 和 Babel 的最新版本。
-
在主体中,我们包含一个具有
id为app的单个div。我们几乎总是想留空,并在下面的 React 代码中填充。 -
我们包含一个脚本标签,指定
type="text/babel"。这向浏览器发出信号,表示以下脚本需要使用 Babel 进行翻译。 -
接下来,我们创建一个名为
App的组件。React 中的组件可以用 JavaScript 函数表示。 -
我们的组件返回我们想要渲染到 DOM 中的内容。在这种情况下,我们简单地返回
<div>Hello!</div>。 -
我们脚本中的最后一行使用了
ReactDOM.render函数,它接受两个参数:-
一个要渲染的组件
-
DOM 中的一个元素,其中应该渲染组件
-
现在我们已经理解了代码的作用,我们可以看看生成的网页:

React 的一个有用特性是能够在其他组件内渲染组件。为了演示这一点,让我们创建另一个名为Hello的组件:
function Hello(props) {
return (
<h1>Hello</h1>
);
}
现在,让我们在App组件内部渲染三个Hello组件:
function App() {
return (
<div>
<Hello />
<Hello />
<Hello />
</div>
);
}
这给我们一个看起来像这样的页面:

到目前为止,组件并没有那么有趣,因为它们都是完全相同的。我们可以通过为它们添加额外的属性(在 React 术语中称为props)来使这些组件更加灵活。例如,假设我们希望向三个人打招呼。我们可以在一个类似于 HTML 属性的方法中提供这些人的名字:
function App() {
return (
<div>
<Hello name="Harry" />
<Hello name="Ron" />
<Hello name="Hermione" />
</div>
);
}
我们可以使用props.PROP_NAME来访问这些 props。然后我们可以使用花括号将其插入到我们的 JSX 中:
function Hello(props) {
return (
<h1>Hello, {props.name}!</h1>
);
}
现在,我们的页面显示了三个名字!

现在,让我们看看我们如何使用 React 重新实现我们在首次使用 JavaScript 时构建的计数器页面。我们的整体结构将保持不变,但在我们的App组件内部,我们将使用 React 的useState钩子为我们的组件添加状态。useState的参数是状态的初始值,我们将将其设置为0。该函数返回表示状态的变量和一个允许我们更新状态的函数。
const [count, setCount] = React.useState(0);
现在,我们可以工作于函数将渲染的内容,我们将指定一个标题和一个按钮。我们还将添加一个事件监听器,当按钮被点击时,React 使用onClick属性来处理:
return (
<div>
<h1>{count}</h1>
<button onClick={updateCount}>Count</button>
</div>
);
最后,让我们定义updateCount函数。为此,我们将使用setCount函数,它可以接受作为状态的新值作为参数。
function updateCount() {
setCount(count + 1);
}
现在我们有一个功能齐全的计数器网站!

加法
现在我们已经对 React 框架有了感觉,让我们利用所学知识来构建一个类似游戏的网站,用户将在网站上解决加法问题。我们将首先创建一个与我们的其他 React 页面设置相同的文件。为了开始构建这个应用程序,让我们思考我们可能想要在状态中跟踪的内容。我们应该包括任何我们认为用户在我们页面上可能会改变的内容。我们的状态可能包括:
-
num1:要相加的第一个数字 -
num2:要相加的第二个数字 -
response:用户输入的内容 -
score:用户回答正确的题目数量。
现在,我们的状态可以是一个包含所有这些信息的 JavaScript 对象:
const [state, setState] = React.useState({
num1: 1,
num2: 1,
response: "",
score: 0
});
现在,使用状态中的值,我们可以渲染一个基本的用户界面。
return (
<div>
<div>{state.num1} + {state.num2}</div>
<input value={state.response} />
<div>Score: {state.score}</div>
</div>
);
现在,网站的基本布局看起来像这样:

在这个阶段,用户无法在输入框中输入任何内容,因为它的值被固定为state.response,当前是空字符串。为了解决这个问题,让我们给输入元素添加一个onChange属性,并将其设置为名为updateResponse的函数。
onChange={updateResponse}
现在,我们必须定义updateResposne函数,它接受触发函数的事件,并将response设置为输入的当前值。这个函数允许用户输入,并将输入的内容存储在state中。
function updateResponse(event) {
setState({
...state,
response: event.target.value
});
}
现在,让我们添加用户提交问题的功能。我们首先添加另一个事件监听器,并将其链接到我们将要编写的函数:
onKeyPress={inputKeyPress}
现在,我们将定义inputKeyPress函数。在这个函数中,我们首先检查是否按下了Enter键,然后检查答案是否正确。当用户回答正确时,我们希望增加 1 分,为下一个问题选择随机数字,并清除响应。如果答案不正确,我们希望减少 1 分并清除响应。
function inputKeyPress(event) {
if (event.key === "Enter") {
const answer = parseInt(state.response);
if (answer === state.num1 + state.num2) {
// User got question right
setState({
...state,
score: state.score + 1,
response: "",
num1: Math.ceil(Math.random() * 10),
num2: Math.ceil(Math.random() * 10)
});
} else {
// User got question wrong
setState({
...state,
score: state.score - 1,
response: ""
})
}
}
}
为了给应用程序添加一些收尾工作,让我们给页面添加一些样式。我们将使应用中的所有内容居中,然后通过给包含问题的 div 添加id为problem,并添加以下 CSS 到样式标签来使问题更大:
#app {
text-align: center;
font-family: sans-serif;
}
#problem {
font-size: 72px;
}
最后,让我们添加在获得 10 分后赢得游戏的能力。为此,我们将在render函数中添加一个条件,一旦我们获得 10 分,就返回完全不同的内容:
if (state.score === 10) {
return (
<div id="winner">You won!</div>
);
}
为了使胜利更加激动人心,我们还将给替代 div 添加一些样式:
#winner {
font-size: 72px;
color: green;
}
现在,让我们看看我们的应用程序!

今天的内容就到这里!下次,我们将讨论构建大型 Web 应用程序的一些最佳实践。
第七讲
-
简介
-
测试
-
断言
- 测试驱动开发
-
单元测试
-
Django 测试
- 客户端测试
-
Selenium
-
持续集成/持续部署
-
GitHub Actions
-
Docker
简介
-
到目前为止,我们讨论了如何使用 HTML 和 CSS 构建简单的网页,以及如何使用 Git 和 GitHub 来跟踪我们代码的变化并与他人协作。我们还熟悉了 Python 编程语言,开始使用 Django 创建 Web 应用程序,并学习了如何使用 Django 模型在我们的网站上存储信息。然后我们介绍了 JavaScript,并学习了如何使用它使网页更加互动,还讨论了使用动画和 React 来进一步改进我们的用户界面。
-
今天,我们将学习关于在处理和发布大型项目时的最佳实践。
测试
软件开发过程中的一个重要部分是测试我们所编写的代码,以确保一切按预期运行。在本讲座中,我们将讨论几种我们可以改进测试代码的方法。
断言
我们可以在 Python 中运行测试的最简单方法之一是使用assert命令。此命令后面跟一个应该为True的表达式。如果表达式为True,则不会发生任何事情,如果为False,则会抛出异常。让我们看看我们如何将命令集成到测试我们在学习 Python 时编写的square函数中。当函数编写正确时,由于assert为True,所以不会发生任何事情
def square(x):
return x * x
assert square(10) == 100
""" Output: """
如果编写错误,则会抛出异常。
def square(x):
return x + x
assert square(10) == 100
""" Output:
Traceback (most recent call last):
File "assert.py", line 4, in <module>
assert square(10) == 100
AssertionError """
测试驱动开发
当你开始构建更大的项目时,你可能想要考虑使用测试驱动开发,这是一种开发风格,每次修复一个错误时,你都会添加一个测试来检查该错误,并将其添加到一个不断增长的测试集中,每次你进行更改时都会运行这些测试。这将帮助你确保你添加到项目中的新功能不会干扰现有的功能。
现在,让我们看看一个稍微复杂一些的函数,并思考编写测试如何帮助我们找到错误。我们现在将编写一个名为is_prime的函数,该函数在其输入是质数时返回True:
import math
def is_prime(n):
# We know numbers less than 2 are not prime
if n < 2:
return False
# Checking factors up to sqrt(n)
for i in range(2, int(math.sqrt(n))):
# If i is a factor, return false
if n % i == 0:
return False
# If no factors were found, return true
return True
现在,让我们看看我们编写的测试prime函数的函数:
from prime import is_prime
def test_prime(n, expected):
if is_prime(n) != expected:
print(f"ERROR on is_prime({n}), expected {expected}")
到目前为止,我们可以进入我们的 Python 解释器并测试一些值:
>>> test_prime(5, True)
>>> test_prime(10, False)
>>> test_prime(25, False)
ERROR on is_prime(25), expected False
从上面的输出中我们可以看到,5 和 10 被正确地识别为质数和非质数,但 25 被错误地识别为质数,所以我们的函数肯定有问题。在我们查看函数的问题之前,让我们看看一种自动化测试的方法。我们可以这样做的一种方法是通过创建一个 shell 脚本,或者可以在我们的终端中运行的脚本。这些文件需要 .sh 扩展名,所以我们的文件将被称为 tests0.sh。下面每一行都包含
-
使用
python3指定我们正在运行的 Python 版本 -
-c表示我们希望运行一个命令 -
一个以字符串格式运行的命令
python3 -c "from tests0 import test_prime; test_prime(1, False)"
python3 -c "from tests0 import test_prime; test_prime(2, True)"
python3 -c "from tests0 import test_prime; test_prime(8, False)"
python3 -c "from tests0 import test_prime; test_prime(11, True)"
python3 -c "from tests0 import test_prime; test_prime(25, False)"
python3 -c "from tests0 import test_prime; test_prime(28, False)"
现在,我们可以在终端中运行这些命令,通过运行 ./tests0.sh,得到这个结果:
ERROR on is_prime(8), expected False
ERROR on is_prime(25), expected False
单元测试
尽管我们能够使用上述方法自动运行测试,但我们仍然可能希望避免逐个编写这些测试。幸运的是,我们可以使用 Python 的 unittest 库使这个过程变得容易一些。让我们看看我们的 is_prime 函数的测试程序可能是什么样子。
# Import the unittest library and our function import unittest
from prime import is_prime
# A class containing all of our tests class Tests(unittest.TestCase):
def test_1(self):
"""Check that 1 is not prime."""
self.assertFalse(is_prime(1))
def test_2(self):
"""Check that 2 is prime."""
self.assertTrue(is_prime(2))
def test_8(self):
"""Check that 8 is not prime."""
self.assertFalse(is_prime(8))
def test_11(self):
"""Check that 11 is prime."""
self.assertTrue(is_prime(11))
def test_25(self):
"""Check that 25 is not prime."""
self.assertFalse(is_prime(25))
def test_28(self):
"""Check that 28 is not prime."""
self.assertFalse(is_prime(28))
# Run each of the testing functions if __name__ == "__main__":
unittest.main()
注意到我们 Tests 类中的每个函数都遵循了一个模式:
-
函数的名称以
test_开头。这对于函数在调用unittest.main()时自动运行是必要的。 -
每个测试都接受
self参数。这是在 Python 类中编写方法时的标准做法。 -
每个函数的第一行包含一个由三个引号包围的 文档字符串。这些不仅是为了代码的可读性。当测试运行时,如果测试失败,注释将作为测试的描述显示。
-
每个函数的下一行包含了一个形式为
self.assertSOMETHING的断言。你可以做出很多不同的断言,包括assertTrue、assertFalse、assertEqual和assertGreater。你可以通过查看 文档 来找到这些以及其他断言。
现在,让我们检查这些测试的结果:
...F.F
======================================================================
FAIL: test_25 (__main__.Tests)
Check that 25 is not prime.
----------------------------------------------------------------------
Traceback (most recent call last):
File "tests1.py", line 26, in test_25
self.assertFalse(is_prime(25))
AssertionError: True is not false
======================================================================
FAIL: test_8 (__main__.Tests)
Check that 8 is not prime.
----------------------------------------------------------------------
Traceback (most recent call last):
File "tests1.py", line 18, in test_8
self.assertFalse(is_prime(8))
AssertionError: True is not false
----------------------------------------------------------------------
Ran 6 tests in 0.001s
FAILED (failures=2)
运行测试后,unittest 会提供一些关于它发现的有用信息。在第一行,它按测试编写的顺序给出了成功的一系列 . 和失败的一系列 F。
...F.F
接下来,对于每个失败的测试,我们还会得到失败函数的名称:
FAIL: test_25 (__main__.Tests)
我们之前提供的描述性注释:
Check that 25 is not prime.
以及异常的跟踪信息:
Traceback (most recent call last):
File "tests1.py", line 26, in test_25
self.assertFalse(is_prime(25))
AssertionError: True is not false
最后,我们得到了一个关于运行了多少个测试、花费了多少时间以及有多少失败的概述:
Ran 6 tests in 0.001s
FAILED (failures=2)
现在,让我们看看如何修复我们函数中的错误。结果是,我们需要在我们的 for 循环中测试一个额外的数字。例如,当 n 是 25 时,平方根是 5,但当它是 range 函数的一个参数时,for 循环在数字 4 处终止。因此,我们可以简单地改变 for 循环的头部为:
for i in range(2, int(math.sqrt(n)) + 1):
现在,当我们再次使用我们的单元测试运行测试时,我们得到以下输出,表明我们的更改修复了错误。
......
----------------------------------------------------------------------
Ran 6 tests in 0.000s
OK
随着你对这个函数进行优化,这些自动化测试将变得更加有用。例如,你可能想利用这样一个事实,即你不需要检查所有整数作为因子,只需检查较小的质数(如果一个数不能被 3 整除,它也不能被 6、9、12 等整除),或者你可能想使用更高级的概率性质数测试,如Fermat和Miller-Rabin质数测试。无论何时你修改此函数以改进它,你都需要能够轻松地再次运行你的单元测试,以确保你的函数仍然正确。
Django 测试
现在,让我们看看在创建 Django 应用程序时如何应用自动化测试的理念。在处理这个项目时,我们将使用我们在第一次学习 Django 模型时创建的flights项目。我们首先将向我们的Flight模型添加一个方法,该方法通过检查两个条件来验证航班是否有效:
-
起点与目的地不同
-
持续时间大于 0 分钟
现在,我们的模型可能看起来像这样:
class Flight(models.Model):
origin = models.ForeignKey(Airport, on_delete=models.CASCADE, related_name="departures")
destination = models.ForeignKey(Airport, on_delete=models.CASCADE, related_name="arrivals")
duration = models.IntegerField()
def __str__(self):
return f"{self.id}: {self.origin} to {self.destination}"
def is_valid_flight(self):
return self.origin != self.destination or self.duration > 0
为了确保我们的应用程序按预期工作,每次我们创建一个新的应用程序时,我们都会自动获得一个tests.py文件。当我们第一次打开这个文件时,我们看到 Django 的TestCase库被自动导入:
from django.test import TestCase
使用TestCase库的一个优点是,当我们运行测试时,将仅用于测试目的创建一个全新的数据库。这很有帮助,因为我们避免了意外修改或删除数据库中现有条目的风险,我们也不必担心移除仅用于测试而创建的虚拟条目。
要开始使用这个库,我们首先想要导入我们所有的模型:
from .models import Flight, Airport, Passenger
然后我们将创建一个新的类,该类扩展了我们刚刚导入的TestCase类。在这个类中,我们将定义一个setUp函数,该函数将在测试过程开始时运行。在这个函数中,我们可能想要创建。我们的类将如下所示:
class FlightTestCase(TestCase):
def setUp(self):
# Create airports.
a1 = Airport.objects.create(code="AAA", city="City A")
a2 = Airport.objects.create(code="BBB", city="City B")
# Create flights.
Flight.objects.create(origin=a1, destination=a2, duration=100)
Flight.objects.create(origin=a1, destination=a1, duration=200)
Flight.objects.create(origin=a1, destination=a2, duration=-100)
现在我们测试数据库中有了一些条目,让我们向这个类添加一些函数来执行一些测试。首先,让我们确保我们的departures和arrivals字段工作正常,通过尝试计算从机场AAA出发的航班数量(我们知道应该是 3)和到达数量(应该是 1):
def test_departures_count(self):
a = Airport.objects.get(code="AAA")
self.assertEqual(a.departures.count(), 3)
def test_arrivals_count(self):
a = Airport.objects.get(code="AAA")
self.assertEqual(a.arrivals.count(), 1)
我们还可以测试我们添加到Flight模型中的is_valid_flight函数。我们将首先断言当航班有效时,该函数确实返回 true:
def test_valid_flight(self):
a1 = Airport.objects.get(code="AAA")
a2 = Airport.objects.get(code="BBB")
f = Flight.objects.get(origin=a1, destination=a2, duration=100)
self.assertTrue(f.is_valid_flight())
接下来,让我们确保具有无效目的地和持续时间的航班返回 false:
def test_invalid_flight_destination(self):
a1 = Airport.objects.get(code="AAA")
f = Flight.objects.get(origin=a1, destination=a1)
self.assertFalse(f.is_valid_flight())
def test_invalid_flight_duration(self):
a1 = Airport.objects.get(code="AAA")
a2 = Airport.objects.get(code="BBB")
f = Flight.objects.get(origin=a1, destination=a2, duration=-100)
self.assertFalse(f.is_valid_flight())
现在,为了运行我们的测试,我们将运行python manage.py test。这个输出的结果几乎与使用 Python unittest库时的输出相同,尽管它也记录了它正在创建和销毁测试数据库:
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..FF.
======================================================================
FAIL: test_invalid_flight_destination (flights.tests.FlightTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/cleggett/Documents/cs50/web_notes_files/7/django/airline/flights/tests.py", line 37, in test_invalid_flight_destination
self.assertFalse(f.is_valid_flight())
AssertionError: True is not false
======================================================================
FAIL: test_invalid_flight_duration (flights.tests.FlightTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/cleggett/Documents/cs50/web_notes_files/7/django/airline/flights/tests.py", line 43, in test_invalid_flight_duration
self.assertFalse(f.is_valid_flight())
AssertionError: True is not false
----------------------------------------------------------------------
Ran 5 tests in 0.018s
FAILED (failures=2)
Destroying test database for alias 'default'...
从上面的输出中我们可以看到,有时is_valid_flight在应该返回False的时候返回了True。进一步检查我们的函数后,我们发现错误在于使用了or而不是and,这意味着只有当飞行要求中的一项被满足时,航班才被认为是有效的。如果我们把函数改为这样:
def is_valid_flight(self):
return self.origin != self.destination and self.duration > 0
我们可以再次运行测试,并得到更好的结果:
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.014s
OK
Destroying test database for alias 'default'...
Client Testing
在创建 Web 应用程序时,我们可能不仅想要检查特定函数是否工作,还想要检查单个 Web 页面是否按预期加载。我们可以通过在我们的 Django 测试类中创建一个Client对象,然后使用该对象进行请求来实现这一点。为了做到这一点,我们首先必须将Client添加到我们的导入中:
from django.test import Client, TestCase
例如,现在让我们添加一个测试来确保我们得到 HTTP 响应代码 200,并且我们的三个航班都被添加到响应的上下文中:
def test_index(self):
# Set up client to make requests
c = Client()
# Send get request to index page and store response
response = c.get("/flights/")
# Make sure status code is 200
self.assertEqual(response.status_code, 200)
# Make sure three flights are returned in the context
self.assertEqual(response.context["flights"].count(), 3)
我们可以类似地检查以确保我们得到有效页面的有效响应代码,以及不存在页面的无效响应代码。(注意,我们使用Max函数来找到最大的id,我们通过在文件顶部包含from django.db.models import Max来访问它)
def test_valid_flight_page(self):
a1 = Airport.objects.get(code="AAA")
f = Flight.objects.get(origin=a1, destination=a1)
c = Client()
response = c.get(f"/flights/{f.id}")
self.assertEqual(response.status_code, 200)
def test_invalid_flight_page(self):
max_id = Flight.objects.all().aggregate(Max("id"))["id__max"]
c = Client()
response = c.get(f"/flights/{max_id + 1}")
self.assertEqual(response.status_code, 404)
最后,让我们添加一些测试以确保乘客和非乘客列表被按预期生成:
def test_flight_page_passengers(self):
f = Flight.objects.get(pk=1)
p = Passenger.objects.create(first="Alice", last="Adams")
f.passengers.add(p)
c = Client()
response = c.get(f"/flights/{f.id}")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["passengers"].count(), 1)
def test_flight_page_non_passengers(self):
f = Flight.objects.get(pk=1)
p = Passenger.objects.create(first="Alice", last="Adams")
c = Client()
response = c.get(f"/flights/{f.id}")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["non_passengers"].count(), 1)
现在,我们可以一起运行所有的测试,看到目前我们没有错误!
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..........
----------------------------------------------------------------------
Ran 10 tests in 0.048s
OK
Destroying test database for alias 'default'...
Selenium
到目前为止,我们已经能够使用 Python 和 Django 测试我们编写的服务器端代码,但随着我们构建应用程序,我们还将想要为我们的客户端代码创建测试。例如,让我们回顾一下我们的counter.html页面,并为其编写一些测试。
我们将开始编写一个稍微不同的计数页面,其中包含一个用于减少计数的按钮:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Counter</title>
<script>
// Wait for page to load
document.addEventListener('DOMContentLoaded', () => {
// Initialize variable to 0
let counter = 0;
// If increase button clicked, increase counter and change inner html
document.querySelector('#increase').onclick = () => {
counter ++;
document.querySelector('h1').innerHTML = counter;
}
// If decrease button clicked, decrease counter and change inner html
document.querySelector('#decrease').onclick = () => {
counter --;
document.querySelector('h1').innerHTML = counter;
}
})
</script>
</head>
<body>
<h1>0</h1>
<button id="increase">+</button>
<button id="decrease">-</button>
</body>
</html>
现在如果我们想测试这段代码,我们只需打开我们的网页浏览器,点击两个按钮,观察发生了什么。然而,随着你编写越来越大的单页应用程序,这会变得非常繁琐,这就是为什么有几个框架被创建出来以帮助进行浏览器内测试,其中一个叫做Selenium。
使用 Selenium,我们可以在 Python 中定义一个测试文件,在那里我们可以模拟用户打开一个网络浏览器,导航到我们的页面,并与它交互。我们在做这件事时使用的主要工具被称为Web Driver,它将在您的计算机上打开一个网络浏览器。让我们看看我们如何开始使用这个库来与页面进行交互。注意,以下我们使用了selenium和ChromeDriver。Selenium 可以通过运行pip install selenium来为 Python 安装,而ChromeDriver可以通过运行pip install chromedriver-py来安装
import os
import pathlib
import unittest
from selenium import webdriver
# Finds the Uniform Resourse Identifier of a file def file_uri(filename):
return pathlib.Path(os.path.abspath(filename)).as_uri()
# Sets up web driver using Google chrome driver = webdriver.Chrome()
上述代码是我们需要的所有基本设置,因此现在我们可以通过使用 Python 解释器来探索一些更有趣的用途。关于前几行的一个注意事项是,为了针对特定的页面,我们需要该页面的统一资源标识符(URI),这是一个唯一的字符串,代表该资源。
# Find the URI of our newly created file >>> uri = file_uri("counter.html")
# Use the URI to open the web page >>> driver.get(uri)
# Access the title of the current page >>> driver.title
'Counter'
# Access the source code of the page >>> driver.page_source
'<html lang="en"><head>\n <title>Counter</title>\n <script>\n \n // Wait for page to load\n document.addEventListener(\'DOMContentLoaded\', () => {\n\n // Initialize variable to 0\n let counter = 0;\n\n // If increase button clicked, increase counter and change inner html\n document.querySelector(\'#increase\').onclick = () => {\n counter ++;\n document.querySelector(\'h1\').innerHTML = counter;\n }\n\n // If decrease button clicked, decrease counter and change inner html\n document.querySelector(\'#decrease\').onclick = () => {\n counter --;\n document.querySelector(\'h1\').innerHTML = counter;\n }\n })\n </script>\n </head>\n <body>\n <h1>0</h1>\n <button id="increase">+</button>\n <button id="decrease">-</button>\n \n</body></html>'
# Find and store the increase and decrease buttons: >>> increase = driver.find_element_by_id("increase")
>>> decrease = driver.find_element_by_id("decrease")
# Simulate the user clicking on the two buttons >>> increase.click()
>>> increase.click()
>>> decrease.click()
# We can even include clicks within other Python constructs: >>> for i in range(25):
... increase.click()
现在,让我们看看我们如何使用这个模拟来创建我们页面的自动化测试:
# Standard outline of testing class class WebpageTests(unittest.TestCase):
def test_title(self):
"""Make sure title is correct"""
driver.get(file_uri("counter.html"))
self.assertEqual(driver.title, "Counter")
def test_increase(self):
"""Make sure header updated to 1 after 1 click of increase button"""
driver.get(file_uri("counter.html"))
increase = driver.find_element_by_id("increase")
increase.click()
self.assertEqual(driver.find_element_by_tag_name("h1").text, "1")
def test_decrease(self):
"""Make sure header updated to -1 after 1 click of decrease button"""
driver.get(file_uri("counter.html"))
decrease = driver.find_element_by_id("decrease")
decrease.click()
self.assertEqual(driver.find_element_by_tag_name("h1").text, "-1")
def test_multiple_increase(self):
"""Make sure header updated to 3 after 3 clicks of increase button"""
driver.get(file_uri("counter.html"))
increase = driver.find_element_by_id("increase")
for i in range(3):
increase.click()
self.assertEqual(driver.find_element_by_tag_name("h1").text, "3")
if __name__ == "__main__":
unittest.main()
现在,如果我们运行 python tests.py,我们的模拟将在浏览器中执行,然后测试结果将被打印到控制台。以下是一个示例,当代码中存在错误且测试失败时,它可能看起来是这样的:

CI/CD
CI/CD,代表持续集成和持续交付,是一套软件开发最佳实践,它规定了由一组人员编写的代码,以及该代码如何随后交付给应用程序的用户。正如其名所示,这种方法由两个主要部分组成:
-
持续集成:
-
主分支上的频繁合并
-
每次合并时进行自动单元测试
-
-
持续交付:
- 短发布周期,意味着应用程序的新版本会频繁发布。
CI/CD 由于以下原因在软件开发团队中越来越受欢迎:
-
当不同的团队成员正在开发不同的功能时,当多个功能同时结合时,可能会出现许多兼容性问题。持续集成允许团队在出现冲突时解决小问题。
-
由于单元测试是在每次合并时运行的,因此当测试失败时,更容易隔离导致问题的代码部分。
-
经常发布新版本的应用程序允许开发者在发布后隔离可能出现的问题。
-
逐步发布小而渐进的变化,使用户能够逐渐适应新的应用程序功能,而不是被一个全新的版本所淹没
-
不等待发布新功能使公司能够在竞争激烈的市场中保持领先。
GitHub Actions
一个用于帮助持续集成的流行工具被称为 GitHub Actions。GitHub Actions 允许我们创建工作流程,其中我们可以指定每次有人向 git 仓库推送时需要执行的操作。例如,我们可能希望在每次推送时检查是否遵循了样式指南,或者是否通过了一组单元测试。
为了设置 GitHub Action,我们将使用一种名为 YAML 的配置语言。YAML 将其数据结构化为键值对(类似于 JSON 对象或 Python 字典)。以下是一个简单的 YAML 文件示例:
key1: value1
key2: value2
key3:
- item1
- item2
- item3
现在,让我们看看一个配置 YAML 文件(其形式为 name.yml 或 name.yaml)的例子,这个文件与 GitHub Actions 一起工作。为此,我将在我的仓库中创建一个 .github 目录,然后在其中创建一个 workflows 目录,最后在这个目录中创建一个 ci.yml 文件。在这个文件中,我们将编写:
name: Testing
on: push
jobs:
test_project:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run Django unit tests
run: |
pip3 install --user django
python3 manage.py test
由于这是我们第一次使用 GitHub Actions,让我们看看这个文件的每个部分都在做什么:
-
首先,我们给工作流程一个
name,在我们的例子中是 Testing。 -
接下来,使用
on键,我们指定工作流程应该在何时运行。在我们的情况下,我们希望在有人向仓库推送时执行测试。 -
文件的其余部分包含在
jobs键中,它指示每次推送时应运行哪些工作。-
在我们的情况下,唯一的工作是
test_project。每个工作都必须定义两个组件-
runs-on键指定我们希望我们的代码在 GitHub 的哪个虚拟机上运行。 -
steps键提供了在运行此工作时应发生的操作-
在
uses键中,我们指定我们希望使用哪个 GitHub Action。actions/checkout@v2是 GitHub 编写的我们可以使用的操作。 -
在这里,
name键允许我们提供对所采取操作的描述 -
在
run键之后,我们输入希望在 GitHub 服务器上运行的命令。在我们的例子中,我们希望安装 Django 然后运行测试文件。
-
-
-
现在,让我们在 GitHub 中打开我们的仓库,并查看页面顶部附近的一些标签页:
-
代码:这是我们使用频率最高的标签页,因为它允许我们查看目录中的文件和文件夹。
-
问题:在这里,我们可以打开和关闭问题,这些问题是请求修复错误或新功能。我们可以将其视为我们应用程序的待办事项列表。
-
拉取请求:希望将某个分支的代码合并到另一个分支的人的请求。这是一个有用的工具,因为它允许人们在代码集成到主分支之前进行 代码审查,并发表评论和提供建议。
-
GitHub Actions:这是我们进行持续集成时使用的标签页,因为它提供了每次推送后发生的操作日志。
这里,让我们假设我们在修复 models.py 文件中 airport 项目内的 is_valid_flight 函数中的错误之前,已经推送了我们的更改。我们现在可以导航到 GitHub Actions 选项卡,点击我们最近的推送,点击失败的操作,并查看日志:

现在,在修复了错误之后,我们可以再次尝试并找到更好的结果:

Docker
在软件开发的世界中,当你的电脑配置与应用程序运行时的配置不同时,可能会出现问题。你可能有一个不同的 Python 版本或安装了一些额外的包,这些包允许应用程序在你的电脑上顺利运行,而它在服务器上可能会崩溃。为了避免这些问题,我们需要确保所有参与项目的人都使用相同的环境。一种方法是通过使用名为 Docker 的工具来实现,这是一个容器化软件,意味着它可以在你的电脑中创建一个隔离的环境,可以在许多协作者和运行你网站的服务器之间标准化。虽然 Docker 有点像 虚拟机,但它们实际上是不同的技术。虚拟机(如 GitHub Actions 或当你启动 AWS 服务器时使用的)实际上是一个完整的虚拟计算机,具有自己的操作系统,这意味着它在任何运行的地方都会占用很多空间。另一方面,Docker 通过在现有计算机中设置容器来工作,因此占用的空间更少。
现在我们已经了解了 Docker 容器的概念,让我们看看如何在我们的电脑上配置一个。我们做这件事的第一步将是创建一个名为 Dockerfile 的 Docker 文件。在这个文件中,我们将提供创建 Docker 镜像 的指令,该镜像描述了我们希望在容器中包含的库和二进制文件。以下是我们 Dockerfile 可能的样子示例:
FROM python:3
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN pip install -r requirements.txt
CMD ["python3", "manage.py", "runserver", "0.0.0.0:8000"]
这里,我们将深入探讨上述文件实际上做了什么:
-
FROM python3: 这表明我们是以安装了 Python 3 的标准镜像为基础构建这个镜像。这在编写 Docker 文件时相当常见,因为它允许你避免在每个新镜像中重新定义相同的基本设置。 -
COPY . /usr/src/app: 这表明我们希望将当前目录(.)中的所有内容复制到新容器中的/usr/src/app目录。 -
WORKDIR /usr/src/app: 这设置了我们在容器内运行命令的位置。(有点像终端上的cd命令) -
RUN pip install -r requirements.txt: 在这一行中,假设你已经将所有需求包含在一个名为requirements.txt的文件中,它们都将被安装到容器内。 -
CMD ["python3", "manage.py", "runserver", "0.0.0.0:8000"]:最后,我们指定了启动容器时应运行的命令。
到目前为止,在这个课程中,我们只使用了 SQLite,因为它是 Django 的默认数据库管理系统。然而,在实际的用户应用程序中,SQLite 几乎从不使用,因为它不像其他系统那样容易扩展。幸运的是,如果我们希望为我们的数据库运行一个单独的服务器,我们只需添加另一个 Docker 容器,并使用一个称为Docker Compose的功能将它们一起运行。这将允许两个不同的服务器在不同的容器中运行,同时还能相互通信。为了指定这一点,我们将使用一个名为docker-compose.yml的 YAML 文件:
version: '3'
services:
db:
image: postgres
web:
build: .
volumes:
- .:/usr/src/app
ports:
- "8000:8000"
在上面的文件中,我们:
-
指定我们使用 Docker Compose 的版本 3。
-
概述两个服务:
-
db根据 Postgres 已经编写好的镜像设置我们的数据库容器。 -
web通过指示 Docker 设置我们的服务器容器:-
在当前目录中使用 Dockerfile。
-
在容器中使用指定的路径。
-
将容器内的端口 8000 链接到我们电脑上的端口 8000。
-
-
现在,我们准备好使用命令docker-compose up启动我们的服务。这将启动我们两个服务器,并在新的 Docker 容器内运行。
在这一点上,我们可能想要在我们的 Docker 容器中运行命令来添加数据库条目或运行测试。为此,我们首先运行docker ps来显示所有正在运行的 Docker 容器。然后,我们将找到我们想要进入的容器的CONTAINER ID,并运行docker exec -it CONTAINER_ID bash -l。这将把你移动到我们在容器内设置的usr/src/app目录中。我们可以在那个容器内运行任何我们想要的命令,然后通过运行CTRL-D来退出。
这节课的内容就到这里!下次,我们将致力于扩展我们的项目并确保它们的安全性。
第八讲
-
简介
-
可扩展性
-
扩展
-
负载均衡
-
自动扩展
- 服务器故障
-
扩展数据库
- 数据库复制
-
缓存
-
安全
- Git 和 GitHub
-
HTML
-
HTTPS
-
密钥加密
-
公钥加密
-
-
数据库
-
APIs
-
环境变量
-
-
JavaScript
- 跨站请求伪造
-
接下来是什么?
简介
-
到目前为止,我们讨论了如何使用 HTML 和 CSS 构建简单的网页,以及如何使用 Git 和 GitHub 来跟踪我们代码的变化并与他人协作。我们还熟悉了 Python 编程语言,开始使用 Django 创建 Web 应用程序,并学习了如何使用 Django 模型在我们的网站上存储信息。然后我们介绍了 JavaScript,并学习了如何使用它使网页更加互动,并讨论了使用动画和 React 来进一步改进我们的用户界面。然后我们讨论了一些软件开发的最佳实践和一些常用于实现这些最佳实践的技术。
-
今天,在我们的最后一讲中,我们将讨论扩展和确保我们的 Web 应用程序安全的问题。
可扩展性
到目前为止,在本课程中,我们构建的应用程序仅在本地计算机上运行,但最终,我们希望发布我们的网站,以便任何互联网用户都可以访问。为了做到这一点,我们在服务器上运行我们的网站,这些服务器是专门用于运行应用程序的物理硬件。服务器可以是本地(我们拥有并维护物理服务器,我们的应用程序托管在其中)或云上(服务器由不同的公司拥有,如亚马逊或谷歌,我们支付租用服务器空间以托管我们的应用程序)。这两种选择都有其优点和缺点:
-
定制:托管自己的服务器让您能够决定它们如何工作,这比基于云的托管提供了更多的灵活性。
-
专业知识:在云上托管应用程序比维护自己的服务器要简单得多。
-
成本:由于服务器托管网站需要盈利,它们将向您收取比维护本地服务器成本更高的费用,这使得基于云的服务器更昂贵。然而,运行本地服务器的启动成本可能很高,因为您需要购买物理服务器,并可能需要聘请具有设置这些服务器所需专业知识的人。
-
可扩展性(Scalability): 当在云上托管时,扩展通常更容易。例如,如果我们托管一个每天有 500 次访问的本地网站,然后它开始每天有 500,000 次访问,我们就必须订购和设置更多的物理服务器来处理请求,同时许多用户将无法访问该网站。大多数云托管网站将允许你灵活地租用服务器空间,根据你的网站活动量来支付费用。
当用户向这个服务器发送 HTTP 请求时,服务器应该发送回一个响应。然而,在现实中,大多数服务器一次会接收到远超过一个请求,如下所示:

这就是我们会遇到可扩展性问题的地方。单个服务器一次只能处理这么多请求,迫使我们制定计划,当我们的一个服务器过载时我们将如何处理。无论我们决定在本地还是云上托管,我们都必须确定服务器可以处理而不崩溃的请求数量,这可以使用任何数量的基准测试工具来完成,包括 Apache Bench。
扩展
一旦我们确定我们的服务器可以处理多少请求的上限,我们就可以开始考虑我们想要如何处理应用程序的扩展。两种不同的扩展方法包括:
-
垂直扩展(Vertical Scaling): 在垂直扩展中,当我们的服务器过载时,我们只是购买或构建一个更大的服务器。然而,这种策略是有限的,因为单个服务器的强大程度有一个上限。
-
水平扩展(Horizontal Scaling): 在水平扩展中,当我们的服务器过载时,我们购买或构建更多的服务器,然后将请求分配给我们的多个服务器。
负载均衡
当我们使用水平扩展时,我们面临的一个额外问题是决定哪些服务器被分配给哪些请求。我们通过采用负载均衡器来回答这个问题,这是一种拦截传入请求并分配给我们的服务器的另一件硬件。有几种不同的方法来决定哪个服务器接收哪个请求,但这里有一些:
-
随机(Random): 在这个简单的方法中,负载均衡器将随机决定将请求分配给哪个服务器。
-
轮询(Round-Robin): 在这种方法中,负载均衡器将交替选择哪个服务器接收传入的请求。如果我们有三个服务器,第一个请求可能会发送到服务器 A,第二个发送到服务器 B,第三个发送到服务器 C,第四个又回到服务器 A。
-
最少连接(Fewest Connections): 在这种方法中,负载均衡器会寻找当前处理最少请求的服务器,并将传入的请求分配给该服务器。这确保了我们不会过度使用某个特定的服务器,但这也使得负载均衡器计算每个服务器当前处理的请求数量所需的时间比随机选择服务器要长。
没有一种负载均衡方法在所有其他方法中绝对优于其他方法,实践中使用了许多不同的方法。在水平扩展时可能出现的一个问题是,我们可能会有存储在一个服务器上的会话,但不在另一个服务器上,我们不希望用户因为负载均衡器将他们的请求推送到新的服务器而不得不重新输入信息。像许多可扩展性问题一样,解决会话问题的方法有多种:
-
粘性会话:一旦用户访问了一个网站,负载均衡器会记住他们最初被发送到哪个服务器,并确保将他们发送到同一个服务器。这种方法的一个主要担忧是,我们可能会让大量用户粘附在一个服务器上,导致该服务器崩溃。
-
数据库会话:所有会话都存储在一个所有服务器都可以访问的数据库中。这样,无论用户被分配到哪个服务器,他们的信息都将可用。这里的缺点是,从数据库中读取和写入需要额外的时间和计算能力。
-
客户端会话:而不是在我们的服务器上存储信息,我们可以选择将它们作为 cookie 存储在用户的网络浏览器中。这种方法的不利之处包括用户创建虚假 cookie 以允许他们以其他用户身份登录的安全问题,以及每次请求都要来回发送 cookie 信息的计算问题。
就像负载均衡一样,对于会话问题没有最好的答案,你选择的方法通常会取决于你的具体情况。
自动扩展
我们可能遇到另一个问题是,许多网站在特定时间访问频率要高得多。例如,如果我们决定从早些时候启动我们的“是新年吗?”应用程序,我们可能会预计它在年底到一月初的流量会比一年中的任何其他时间都要多。如果我们为网站购买足够的服务器以保持冬季的活跃状态,那么这些服务器在其余的时间里将处于闲置状态,浪费空间和能源。这种场景催生了自动扩展的概念,这在云计算中已成为常见做法,即网站使用的服务器数量可以根据接收到的请求数量增长和缩小。尽管如此,自动扩展并不是一个完美的解决方案,因为它需要时间来确定需要新的服务器并启动该服务器。另一个潜在的问题是,你拥有的运行服务器越多,出现故障的机会就越多。
服务器故障
虽然拥有多个服务器可以帮助避免所谓的单点故障,即一个硬件设备在故障后会导致整个网站崩溃。在水平扩展时,负载均衡器可以通过向每个服务器发送定期的心跳请求来检测哪些服务器已崩溃,然后停止将新请求分配给已崩溃的服务器。此时,似乎我们只是将单点故障从服务器转移到了负载均衡器,但我们可以通过备用负载均衡器的可用性来解决这个问题,以防原始负载均衡器意外崩溃。
数据库扩展
除了扩展处理请求的服务器外,我们还需要考虑如何扩展我们的数据库。在本课程中,我们使用 SQLite,它将数据存储在服务器上的文件中,但随着我们存储的数据越来越多,有时将数据存储在多个不同的文件中,甚至可能是在单独的服务器上,可能更有意义。这引发了一个问题,即当我们的数据库服务器无法处理所有传入的请求时应该怎么办。与其他可扩展性问题一样,我们可以使用多种方法来减轻这个问题:
-
垂直分区:这是一种与我们最初讨论 SQL 时使用的方法类似的方法,其中我们将数据拆分到多个不同的表中,而不是在一个表中保留冗余信息。(请随时回顾第四讲,其中我们将
flights表拆分为flights表和airports表)。 -
水平分区:这种方法涉及存储具有相同格式但不同信息的多个表。例如,我们可以将
flights表拆分为domestic_flights表和international_flights表。这样,当我们希望搜索从 JFK 到 LHR 的航班时,我们不必浪费时间搜索一个充满国内航班的表。这种方法的一个缺点是,一旦表被拆分,连接多个表可能会很昂贵。
数据库复制
即使我们在数据库进行了扩展,似乎我们仍然面临一个单点故障的问题。如果我们的数据库服务器崩溃,我们所有的数据可能会丢失。正如我们添加更多服务器以避免单点故障一样,我们也可以添加数据库的副本来确保一个数据库的故障不会使我们的应用程序关闭。同样,之前也有不同的数据库复制方法,其中两种最受欢迎的是:
- 单主复制:在这种方法中,有多个数据库,但只有一个被认为是主数据库,这意味着你可以从其中一个数据库中读取和写入,但只能从每个其他数据库中读取。当主数据库更新时,其他数据库随后更新以匹配主数据库。这种方法的一个缺点是,在写入数据库时仍然存在单点故障。

-
多主复制:在这种方法中,所有数据库都可以读取和写入。这解决了单点故障的问题,但代价是现在要使所有数据库保持最新状态变得更加困难,因为每个数据库都必须了解所有其他数据库的变化。这个系统也使我们面临一些冲突的可能性:
-
更新冲突:在多个数据库中,一个用户可能尝试在一个数据库中编辑一行,而另一个用户可能尝试在另一个数据库中编辑同一行,当数据库同步时,这会导致问题。
-
唯一性冲突:SQL 数据库中的每一行都必须有一个唯一的标识符,我们可能会遇到在两个不同的数据库中为两个不同的条目分配相同 ID 的问题。
-
删除冲突:一个用户可能删除一行,而另一个用户可能尝试更新它。
-

缓存
无论我们与大型数据库交互时,都应认识到每一次与数据库的交互都是昂贵的。因此,我们希望最小化对数据库服务器的调用次数。以纽约时报网站为例。纽约时报可能有一个包含所有文章的数据库,每次有人加载主页时都会查询该数据库,并渲染一些模板,但这样做会浪费资源,因为主页上显示的文章很可能每秒变化不大。我们可以通过使用缓存来解决这个问题,即如果我们预计在不久的将来需要再次使用某些信息,就将它们存储在更易于访问的位置。
缓存的一种实现方式是将数据存储在用户的网络浏览器中,这样当用户加载某些页面时,甚至不需要向服务器发送请求。实现这一点的办法之一是在 HTTP 响应的头部包含以下这一行:
Cache-Control: max-age=86400
这将告诉浏览器,当访问页面时,只要我在过去 86400 毫秒内访问过该页面,就不需要向服务器发送请求。这种方法通常用于浏览器,特别是对于不太可能在短时间内更改的文件,如 CSS 文件。为了更多地控制这个过程,我们还可以在 HTTP 响应头中添加一个ETag,它是一串唯一的字符序列,代表文档的特定版本。这很有用,因为未来的请求可以包含这个标签,并将其与服务器上最新文档的标签进行比较,只有当两者不同时才返回整个文档。
除了上面讨论的客户端缓存外,通常在服务器端包含一个缓存也很有帮助。有了这个缓存,我们的后端设置将类似于下面的一个,其中所有服务器都可以访问缓存。

Django 提供了一个自己的缓存框架,这将允许我们在项目中实现缓存。这个框架提供了几种实现缓存的方法:
-
视图级缓存:这允许我们决定一旦加载了特定的视图,该视图可以在不经过下一个指定时间内通过函数的情况下渲染。
-
模板片段缓存:这种缓存可以缓存模板的特定部分,这样它们就不需要重新渲染。例如,我们可能有一个很少改变的导航栏,这意味着我们可以通过不重新加载它来节省时间。
-
低级缓存 API:这允许你进行更灵活的缓存,本质上可以存储你想要的任何信息。
我们在这里不会详细介绍如何在 Django 中实现缓存,但如果您感兴趣,请查看文档!
安全
现在,我们将开始讨论如何确保我们的 Web 应用程序安全,这将涉及许多不同的措施,几乎涵盖了我们在本课程中讨论的几乎所有主题。
Git 和 GitHub
Git 和 GitHub 最大的优势之一是它们使共享和贡献开源软件变得非常容易,任何互联网用户都可以查看和贡献。这个缺点是,如果你在任何时候提交了一个包含一些私人凭证(如密码或 API 密钥)的文件,这些凭证可能会公开可用。
HTML
使用 HTML 会引发许多漏洞。其中一种常见的弱点被称为钓鱼攻击,当用户认为他们将要访问一个页面时,实际上却被带到了另一个页面。这些并不是我们在设计网站时可以预见的,但我们在自己与网络互动时应该肯定要考虑到它们。例如,一个恶意用户可能会编写以下 HTML 代码:
<!DOCTYPE html>
<html lang="en">
<head>
<title>Link</title>
</head>
<body>
<a href="https://cs50.harvard.edu/">https://www.google.com/</a>
</body>
</html>
它的作用如下:

HTML 实际上作为请求的一部分发送给用户,这增加了更多的漏洞,因为每个人都可以访问到允许你创建网站的布局和样式。例如,黑客可以访问bankofamerica.com,复制他们的所有 HTML,并将其粘贴到自己的网站上,创建一个看起来与美洲银行一模一样的网站。然后黑客可以重定向页面上的登录表单,使得所有用户名和密码都发送给他们。(此外,这里还有真正的美洲银行链接——只是想看看你在点击之前是否检查了 URL!)
HTTPS
如我们之前在课程中讨论的那样,大多数在线交互都遵循 HTTP 协议,尽管现在越来越多的交易使用 HTTPS,这是 HTTP 的加密版本。在使用这些协议时,信息通过一系列服务器以如图所示的方式从一个计算机传输到另一个计算机。

通常没有方法可以确保所有这些传输都是安全的,因此确保所有传输的信息都是加密的非常重要,这意味着消息的字符被改变,以便发送者和接收者可以理解它,但其他人不能。
秘密密钥密码学
一种处理方式被称为秘密密钥密码学。在这种方法中,发送者和接收者都拥有一个只有他们知道的秘密密钥。然后,发送者使用这个秘密密钥来加密一条消息,并将其发送给接收者,接收者使用秘密密钥来解密这条消息。这种方法非常安全,但当它涉及到实际应用时会产生一个大问题。为了使其工作,发送者和接收者都必须能够访问秘密密钥,这意味着他们必须亲自会面来安全地交换密钥。考虑到我们每天与不同网站互动的数量,很明显,面对面会面不是一个可行的选择。
公钥密码学
一种在密码学中令人难以置信的进步,使得互联网能够像今天这样运行,被称为公钥密码学。在这种方法中,有两个密钥:一个是公开的,可以共享,另一个必须保密。一旦这些密钥被建立(有几种不同的数学方法可以创建密钥对,这本身可以构成一门完整的课程,所以我们在这里不会讨论它们),发送者可以查找接收者的公钥并使用它来加密一条消息,然后接收者可以使用他们的私钥来解密这条消息。当我们使用 HTTPS 而不是 HTTP 时,我们知道我们的请求正在使用公钥加密来得到保护。
数据库
除了我们的请求和响应之外,我们还必须确保我们的数据库是安全的。我们需要存储的一个常见信息是用户信息,包括用户名和密码,如下表所示:

然而,你绝对不希望以明文形式存储密码,以防未经授权的人访问你的数据库。相反,我们将想要使用一个哈希函数,这是一个接受一些文本并输出一个看似随机的字符串的函数,为每个密码创建一个哈希值,如下表所示:

重要的是要注意,哈希函数是单向的,这意味着它可以将密码转换为哈希值,但不能将哈希值转换回密码。这意味着任何以这种方式存储用户信息的公司实际上并不知道任何用户的密码,这意味着每次用户尝试登录时,输入的密码将被哈希并与其现有的哈希值进行比较。幸运的是,这个过程已经被 Django 为我们处理了。这种存储技术的一个影响是,当用户忘记他们的密码时,公司无法告诉他们他们的旧密码是什么,这意味着他们必须创建一个新的密码。
有一些情况下,作为开发者,你必须决定你愿意泄露多少信息。例如,许多网站都有一个看起来像这样的忘记密码页面:

作为一名开发者,你可能在提交后想要包含成功或错误信息:


但请注意,通过输入电子邮件,任何人都可以确定谁在该网站上注册了电子邮件。在一个人是否使用该网站无关紧要的情况下(比如 Facebook),这可能完全没问题,但如果你是某个网站的成员可能会让你处于危险之中(比如虐待受害者的在线支持小组),这就会非常鲁莽。
数据可能泄露的另一种方式是在响应返回所需的时间。拒绝一个电子邮件地址无效的人可能比拒绝一个电子邮件地址正确但密码错误的人更快。
如我们在课程中之前讨论过的,每次我们在代码中使用直接的 SQL 查询时,都必须警惕 SQL 注入攻击。
APIs
我们经常将 JavaScript 与 API 结合使用来构建单页应用程序。当我们自己构建 API 时,我们可以使用一些方法来保持我们的 API 安全:
-
API 密钥:只处理你提供给 API 客户端的密钥的请求。
-
速率限制:限制任何用户在给定时间段内可以发出的请求数量。这有助于防止拒绝服务(DoS)攻击,恶意用户通过向你的 API 发出大量调用,使其崩溃。
-
路由认证:有许多情况下我们不希望让每个人都访问我们的所有数据,因此我们可以使用路由认证来确保只有特定的用户可以看到特定的数据。
环境变量
正如我们想要避免以明文形式存储密码一样,我们也会想要避免在我们的源代码中包含 API 密钥。避免这种情况的一种常见方法就是使用环境变量,或者存储在你的操作系统或服务器环境中的变量。然后,而不是在我们的源代码中包含一串文本,我们可以包含对环境变量的引用。
JavaScript
恶意用户可能会尝试使用 JavaScript 进行几种类型的攻击。一个例子是跨站脚本攻击,即用户在自己的网站上编写并运行自己的 JavaScript 代码。例如,让我们想象我们有一个 Django 应用程序,它有一个单一的 URL:
urlpatterns = [
path("<path:path>", views.index, name="index")
]
以及一个单一视图:
def index(request, path):
return HttpResponse(f"Requested Path: {path}")
该网站本质上告诉用户他们导航到的 URL 是什么:

但用户现在可以通过在 URL 中输入来轻松地将一些 JavaScript 插入页面:

虽然这个alert示例相对无害,但要包含一些操纵 DOM 或使用fetch发送请求的 JavaScript 并不会更困难。
跨站请求伪造
我们已经讨论了如何使用 Django 来防止 CSRF 攻击,但让我们看看没有这种保护会发生什么。作为一个例子,想象一家银行有一个你可以访问的 URL,可以从你的账户中转账。一个人可以轻松地创建一个链接来执行这种转账:
<a href="http://yourbank.com/transfer?to=brian&amt=2800">
Click Here!
</a>
这种攻击甚至可能比链接更微妙。如果 URL 被放在图片中,那么它将在浏览器尝试加载图片时被访问:
<img src="http://yourbank.com/transfer?to=brian&amt=2800">
由于这个原因,每次你构建一个可以接受某些状态变化的应用程序时,都应该使用 POST 请求。即使银行要求 POST 请求,隐藏表单字段仍然可以诱使用户意外提交请求。以下表单甚至不需要用户点击;它会自动提交!
<body onload="document.forms[0].submit()">
<form action="https://yourbank.com/transfer"
method="post">
<input type="hidden" name="to" value="brian">
<input type="hidden" name="amt" value="2800">
<input type="submit" value="Click Here!">
</form>
</body>
上述示例展示了跨站请求伪造可能的样子。我们可以通过在加载网页时创建 CSRF 令牌来阻止此类攻击,并且只接受带有有效令牌的表单。
接下来是什么?
我们在本课程中讨论了许多 Web 框架,如 Django 和 React,但还有更多你可能感兴趣的框架:
-
服务器端
-
客户端
在未来,你可能还希望能够通过多种不同的服务将你的网站部署到网络上:
我们自从这门课程开始以来已经走得很远了,覆盖了大量的材料,但在网络编程的世界里还有很多东西要学习。尽管有时可能会感到压倒性,但学习更多知识的最佳方法之一就是投身到一个项目中,看看你能将它推进多远。我们相信,在这个阶段,你在网络设计概念方面已经打下了坚实的基础,而且你已经拥有了将一个想法转化为你自己的工作网站所需的一切!











注意左侧有一个文件资源管理器,你可以在这里找到你的文件。此外,注意中间有一个称为文本编辑器的区域,你可以在这里编辑你的程序。最后,还有一个称为命令行界面、CLI、命令行或终端窗口的区域,我们可以在这里向云端的计算机发送命令。














































































































































































浙公网安备 33010602011771号