Week 6 和 Week 7 标志着 CS50 的地层转换。前 5 周你在 C 语言的地壳深处挖矿——指针、malloc、链表。Week 6 上升到 Python 的地表——同一套思维模型换了一门语言,你会以一种前所未有的轻松感把过去五周写的代码重写一遍,然后震惊于代码量的缩减。Week 7 则进入 SQL 的世界——不再是「写程序操作数据」,而是「描述你想要什么数据,让数据库引擎自己去算」。这两周加起来,是你从「程序员」向「全栈开发者」过渡的起点。
上一篇:Week 4 & 5 · 下一篇:Week 8 & 10
| 关键词 | 一句话说明 |
|---|---|
| ⭐ C → Python 代码量对比 | 同一功能,Python 代码量通常是 C 的 1/5 到 1/10。不是因为 Python 更聪明——是它背后藏了你不用写的几万行 C 代码 |
| Python 之禅 | import this——19 条设计哲学。核心:可读性高于一切。Malan 会花 3 分钟读给学生听 |
| 解释型语言 | 不需要编译。写完直接 python hello.py。背后是 CPython 解释器(用 C 写的)逐行翻译字节码 |
| 动态类型 | 变量不声明类型。C 的 int x = 5; 在 Python 里就是 x = 5。代价:运行时才能发现类型错误 |
| def 函数定义 | Python 的函数——没有返回类型声明、没有参数类型声明、没有花括号。缩进就是作用域 |
| dict / list / set / tuple | Python 四大内置数据结构——它们就是 Week 5 里你用 C 手写过的链表、哈希表、数组的「开箱即用版」 |
| ⭐ 切片(Slicing) | s[2:5]——从字符串或列表中取一段。C 里你需要写 for 循环 + 下标——Python 一个冒号搞定 |
| with 上下文管理器 | with open(...) as f:——自动关闭文件,不用写 fclose。告别 C 的文件句柄泄漏焦虑 |
| 列表推导式 | [x*2 for x in range(10)]——一行搞定原来需要 3-5 行的 for 循环。Malan:「这是 Python 最美的语法之一」 |
| f-strings | f"你好,{name}"——Python 3.6+ 的字符串格式化。对比 C 的 printf("%s", name) 简直是降维打击 |
| try/except 异常处理 | Python 的错误处理——不是检查返回值、不是检查 errno。错误发生时自动跳到 except 块。告别 C 的「每个函数调用完都要 if 检查」 |
| PIL / Pillow | Python 图像处理库。Week 6 作业:用 Python 做图像滤镜(模糊、边缘检测、镜像等) |
| sys.argv / import 体系 | Python 的命令行参数和模块化——对应 C 的 argc/argv 和 #include。但 Python 的 import 比 C 的 #include 强大得多 |
| ⭐ SQL | 结构化查询语言——不是说「怎么取数据」,是说「要什么数据」。SELECT 比 C 的 for+if 快几个数量级 |
| CRUD | Create, Read, Update, Delete——数据库的四种基本操作。对应的 SQL:INSERT, SELECT, UPDATE, DELETE |
| 主键 / 外键 | 一条记录的唯一身份证 + 跨表引用。外键 = 数据库版的指针 |
| 约束(Constraints) | NOT NULL, UNIQUE, DEFAULT, CHECK——在数据库层面强制数据规则。让错误的脏数据根本插不进去 |
| ⭐ JOIN | 把两张表按某个共同字段「缝」在一起。INNER / LEFT / RIGHT JOIN。这是关系型数据库的超级能力 |
| 索引(Index) | 给某列建 B-Tree——WHERE 查找从 O(n) 变成 O(log n)。代价:INSERT 变慢(要更新索引) |
| 聚合 + HAVING + 子查询 | COUNT/AVG/SUM/MAX/MIN + 对聚合结果的过滤 + 查询中的查询。SQL 的表达力在这三个特性组合下达到顶峰 |
| ⭐ SQL 注入 | 用户输入中包含 SQL 代码——Malan 现场演示:输入 "Robert'); DROP TABLE students;--"。防御:参数化查询(?占位符) |
| 事务(Transaction) | BEGIN → 一堆操作 → 全部成功就 COMMIT,任何一步出错就 ROLLBACK。银行转账的原子性保障 |
| SQLite | 轻量级数据库——一个 .db 文件就是整个数据库。CS50 用它。你的手机 App 本地数据库也是它 |
Malan 的开场白很有仪式感。他打开终端,敲了三行:
#include <stdio.h>
int main(void) {
printf("hello, world\n");
}
「这是 C。五周了。现在看这个——」然后他敲:
print("hello, world")
全场起哄、鼓掌。Malan 摊手:「这就是 Python。」这 3 秒的对比是整个 Week 6 教学策略的浓缩——不是从零教 Python,而是用你已经背下来的 C 代码做参照物,让你震惊于「同一个任务为什么能短这么多」。每多一行对比,你的大脑就多理解一层:你不是在学新语言——你是在「卸载包袱」。
Malan 不做 Python 语法逐条讲解。他打开两周前用 C 写的程序——mario.c(打印马里奥金字塔)、caesar.c(凯撒加密)、phonebook.c——然后用 Python 逐行重写。整个教学就是一场「代码减肥秀」。
| 你要做的事 | C(你 5 周前写的) | Python(现在写) | 少了多少 |
|---|---|---|---|
| 打印 hello world | #include <stdio.h> | print("hello, world") | 4 行 → 1 行 |
| 读一个整数 | int x; | x = int(input("x: ")) | 指针消失了 |
| 条件判断 | if (x < y) { ... } | if x < y: ... | 括号和花括号消失了 |
| for 循环 | for (int i=0; i<3; i++){...} | for i in range(3): ... | 三个表达式→一个 |
| 字符串比较 | strcmp(s1,s2)==0 | s1 == s2 | 5 个词 → 1 个词 |
| 创建动态数组 | int *arr = malloc(n*sizeof(int)); | arr = [](自动扩容 + 自动回收) | 你不需要知道堆在哪 |
Malan 在逐一重写后的总结:「你不是在学新的语法——你是在卸掉 C 语言的脏活。你在 Week 4 花了整整一节课学 malloc/free,Week 5 花了半节课学链表和哈希表。现在 Python 一个
list和dict全包了。这不代表 Week 4 和 Week 5 白学了——恰恰相反,只有学过它们,你才能理解为什么 Python 的 list 这么慢、dict 这么快。」
Malan 在 C → Python 对照之后,立刻讲函数——因为 Python 函数和 C 函数的对比,完美浓缩了两门语言的哲学差异:
// C 版本——猜数字游戏的核心判断逻辑
bool check(int guess, int secret)
{
if (guess == secret)
{
printf("猜对了!\n");
return true;
}
else if (guess < secret)
{
printf("太小了!\n");
return false;
}
else
{
printf("太大了!\n");
return false;
}
}
# Python 版本——同一个函数
def check(guess, secret):
if guess == secret:
print("猜对了!")
return True
elif guess < secret:
print("太小了!")
return False
else:
print("太大了!")
return False
| 差异维度 | C 的 def(函数定义) | Python 的 def |
|---|---|---|
| 关键字 | 前置返回类型(bool, int, void) | 统一 def——返回类型不写,由 return 决定 |
| 参数 | 必须声明类型 | 只写名字,类型由调用者传入的实参决定 |
| 代码块 | 花括号 { } | 冒号 : + 缩进 |
| else if | else if (...) | elif ...:(C 里两个词,Python 一个词) |
| 返回真假 | true / false(小写,来自 stdbool.h) | True / False(大写,内置关键字) |
Malan 在白板上列出了一个「悼念名单」——Python 里你不用再亲自操作的东西:
| C 里你亲手做了 5 周的东西 | Python 里谁替你干了 | 代价 |
|---|---|---|
| 编译(gcc / make) | 解释器(CPython,用 C 写的) | 必须安装 Python。没有独立的可执行文件(除非用 PyInstaller 打包) |
| 内存管理(malloc / free) | 垃圾回收器(引用计数 + 循环检测) | 内存回收时机不确定。可能有短暂的「内存峰值」 |
| 类型声明(int, float, char *) | 动态类型(运行时存一个类型标签) | 类型错误只会在运行到那一行时暴露——编译阶段发现不了 |
| 指针(&, *, ->, 指针运算) | 「一切皆引用」——赋值默认传引用 | 你可能在不知情的情况下修改了不该修改的变量(可变对象共享引用) |
| 数组边界 | list 自动扩容 + 越界自动报错 IndexError | list 扩容需要复制整个数组——O(n) 的隐藏成本。但大部分时间你注意不到 |
| 字符串结尾 \0 | Python str 对象自带长度字段——不用数 \0 | Python str 是不可变的——每次「修改」字符串实际是创建了新对象 |
Malan 的结语:「Python 不是 C 的'升级'——它是 C 的抽象层。你在 Python 里写的每一行代码,底层都有 C 代码在替你跑。
arr.append(42)背后是 C 写的realloc。dict['key']背后是 C 写的哈希表查找。你现在是从'造车轮的人'变成了'开车的人'。」
Malan 在第 4 周花了大量时间演示 C 的错误处理模式——每次 malloc 之后检查是否为 NULL,每次 fopen 之后检查是否为 NULL。Python 换了一套思路:
# C 风格——每个操作都要检查返回值
FILE *f = fopen("data.txt", "r");
if (f == NULL) { printf("打不开!\n"); return 1; }
int *arr = malloc(100 * sizeof(int));
if (arr == NULL) { printf("内存不够!\n"); return 1; }
// 正常操作...
fclose(f);
free(arr);
# Python 风格——try 包裹正常逻辑,except 收拾残局
try:
with open("data.txt", "r") as f:
data = f.read()
numbers = [int(line) for line in data.split("\n") if line]
except FileNotFoundError:
print("文件不存在!")
except ValueError:
print("文件里有不是数字的行!")
# 不需要手动关闭文件——with 已经处理了
Malan 的评价:「C 和 Python 的错误处理代表了两种哲学——C 倾向于'每次做事之前先看路',Python 倾向于'先干再说,摔了再爬起来'。 前者更严谨——你知道每一步是否成功。后者更简洁——你不被错误处理打断主逻辑。」
常见的 Python 异常速查:
FileNotFoundError(文件不存在)、ValueError(值类型/范围不对)、TypeError(类型错误)、IndexError(列表越界)、KeyError(字典键不存在)、ZeroDivisionError(除以零)。
Malan 在第 6 周引出了四个 Python 核心类型,并明确指出了每一类是 Week 5 里哪一种手写数据结构的「开箱版」:
| 类型 | C 对应(你手写过的) | Python 中怎么用 | 时间复杂度 |
|---|---|---|---|
| list | 动态数组(malloc + realloc) | nums = [1,2,3]; nums.append(4) | 索引 O(1), 末尾插入 O(1) 均摊, 头部插入 O(n) |
| dict | 哈希表(装链表的数组 + 哈希函数) | phonebook = {"Alice": "+1-111"}; phonebook["Bob"] = "+1-222" | 增删查平均 O(1) |
| set | 哈希表(只存键不存值) | unique = set(); unique.add(42); 42 in unique → True | 增删查 O(1); 自动去重 |
| tuple | 不可变数组(const) | point = (3, 4)——不能改,但可以用作 dict 的键 | 索引 O(1) |
Malan 专门挑了四个 Python 语法特性,每个都配了一个「等效的 C 代码」来制造震撼效果。
s = "CS50"
print(s[1:4]) # → "S50"(索引 1 到 3,不含 4)
# C 等效(你需要写):
for (int i = 1; i < 4; i++) printf("%c", s[i]); // 3 行才搞定
# Python——一行 squares = [x * x for x in range(10)] # → [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] # C 等效(你需要写): int squares[10]; for (int i = 0; i < 10; i++) squares[i] = i * i; // 3 行 + 数组声明
# Python f-string——变量直接嵌入字符串
name = "Alice"
age = 20
print(f"你好,{name},你明年 {age + 1} 岁。")
# → 你好,Alice,你明年 21 岁。
# C 等效——你需要数 % 的个数和对应的变量排列
printf("你好,%s,你明年 %i 岁。\n", name, age + 1);
# Python——自动关闭,就算中间出错也会关
with open("file.txt", "r") as f:
for line in f:
print(line, end="")
# 缩进结束 → 文件自动关闭。你永远不会忘记 f.close()
# C 等效——你必须手动 fclose,而且如果中间 return 或出错,
# 很可能跳过 fclose → 文件句柄泄漏
FILE *f = fopen("file.txt", "r");
while (fgets(buffer, sizeof(buffer), f) != NULL) ...
fclose(f); // ← 必须记得这一行
Malan 的评论:「
with是 Python 解决'人类的健忘'的设计。C 要求你记住fclose和free——这很严格,但你确实会忘。Python 说:你只管写逻辑,收尾的事我来。」
Malan 把 Python 的 import 和 C 的 #include 做了系统对比。核心区别:#include 是文本复制粘贴(预处理阶段)。import 是运行时加载——模块是真正的代码实体,有自己的命名空间。
# 方式一:导入整个模块 import random coin = random.choice(["正面", "反面"]) # 方式二:从模块中导入特定的函数 from random import choice, randint coin = choice(["正面", "反面"]) dice = randint(1, 6) # 方式三:导入全部(不推荐——会污染命名空间) from random import * # 方式四:导入并起别名(当模块名太长时) import numpy as np # 你自己的模块也可以被导入—— # 如果有一个 helpers.py 文件,里面定义了 greet() 函数: from helpers import greet
对应 C 的 argc/argv,Python 使用 sys.argv:
import sys
# sys.argv 是一个列表——第一个元素是程序名
if len(sys.argv) != 2:
print(f"用法:python {sys.argv[0]} <名字>")
sys.exit(1) # 对应 C 的 return 1
print(f"你好,{sys.argv[1]}")
# 等价于 C 的:printf("你好,%s\n", argv[1]);
Malan 在课上一定会做这个对照——从 Week 2 的 C 实现翻到 Python 实现。因为凯撒密码是 CS50 前 6 周的共同锚点:
# include <...> → import sys
# int main(int argc, → def main():
# string argv[])
# { → (缩进开始)
# argv[1] → sys.argv[1]
# atoi(argv[1]) → int(sys.argv[1])
# get_string("...") → input("...")
# isupper(c) → c.isupper()
# islower(c) → c.islower()
# printf("%c", ...) → print(..., end="")
# printf("\n") → print()
# return 0; → sys.exit(0)
# } → (缩进结束)
#
# 每一行 C 代码都有一行更短的 Python 代码在等着它。
# 同一个算法,同一种思维,两门语言。
Malan 花了 10 分钟讲 Python 里几个「C 程序员一看就懵」的特性。不是 bug,是设计:
| 现象 | 代码 | 为什么 |
|---|---|---|
| 可变对象作为默认参数 | def f(x, memo={}): | 默认参数只在函数定义时创建一次——不是每次调用都建新的。如果函数修改了它,下次调用会看到上次的残留值。Malan:「这是 Python 面试题 Top 3。」 |
| 嵌套列表引用共享 | a = [[0]*3]*3→a[0][0]=1 后整列变 1 | * 复制的是引用,不是值。三行指向了同一个内部列表。正确做法:[[0]*3 for _ in range(3)] |
| 字符串不可变 | s = "hello"; s[0] = 'H' → 报错 | Python str 是 immutable。任何「修改」都创建新对象。想逐字符修改?用 list 中转 |
Malan 用一个非常漂亮的方式让 Python 的第一个作业落地——图像处理。你写一个程序,读取 BMP 位图,逐个像素处理,然后输出一张新图片。这和 Week 4 的 JPEG 恢复作业形成了精妙的对照——同样的底层原理(逐字节处理二进制文件),但用 Python 写起来像是换了一个世界。
滤镜列表:
这个作业的精妙之处:它逼你正确使用 Python 的二维列表。 你如果像 C 程序员那样用
[[0]*width]*height创建「二维数组」——恭喜,你会收获一个整整一下午的引用共享 bug。做完这道题,你对 Python 的「一切皆引用」和「可变 vs 不可变」的理解就落地了。
Malan 用一个问题开场:「如果我有 1000 万行数据,想要找出其中所有在 2024 年消费超过 1000 元的北京用户——你写 C 或 Python 怎么做?」学生们想了几秒:读文件、解析 CSV、for 循环、if 筛选……「大概 50 行代码。现在看 SQL:」
SELECT * FROM users WHERE city = 'Beijing' AND year = 2024 AND amount > 1000;
全场安静。这就是 SQL 的本质区别——你不需要告诉电脑「怎么取」,你只告诉它「要什么」。 数据库引擎自己决定用不用索引、走哪条执行计划。你描述结果,引擎负责效率。
Malan 的入门比喻始终围绕电子表格:
| SQL 概念 | Excel 对应 | 解释 |
|---|---|---|
| 表(Table) | 一个工作表 | 数据按行和列组织 |
| 行(Row / Record) | 一行 | 一个实体的一条完整记录(如:一个用户) |
| 列(Column / Field) | 一列 | 一种属性(如:姓名、年龄、城市) |
| 主键(Primary Key) | 第一列的行号 | 唯一标识一行。通常是自增整数。永远不为空、不重复 |
| 外键(Foreign Key) | VLOOKUP 的目标列 | 一张表中的列,存着另一张表的主键值——相当于 C 的指针 |
Malan 指着外键说:「外键就是数据库的指针。 你在 C 里用一个内存地址指向另一个节点。在 SQL 里,你用一个整数(外键值)指向另一行的主键。同样的思路——间接引用——只是换了语言。」
Malan 在讲完「表是什么」之后,立刻进入数据定义——你可以在建表时声明「这一列只接受什么类型」和「这一列有什么规则」。这比 C 的类型系统更接近「业务规则」:
| SQLite 数据类型 | 对应 C 类型 | 存什么 |
|---|---|---|
INTEGER | int / long | 整数(自动选择 1/2/4/8 字节存储) |
TEXT | char * / string | 任意长度的文本 |
REAL | float / double | 8 字节浮点数 |
BLOB | void * | 二进制数据(图片、文件等)——原样存储,不解码 |
NUMERIC | — | 精确数值。可能存为 INTEGER 或 REAL,取决于值 |
比数据类型更重要的是 约束(Constraints)——Malan 强调这是「在数据库层面防呆,而不是在 Python 代码里防呆」:
| 约束 | 作用 | 示例 |
|---|---|---|
NOT NULL | 这一列不允许为空 | name TEXT NOT NULL——每个用户必须有名字 |
UNIQUE | 这一列的值在整个表中不重复 | email TEXT UNIQUE——不能有两个相同邮箱 |
DEFAULT | 不指定值时自动填入默认值 | score INTEGER DEFAULT 0 |
CHECK | 插入/更新时验证条件 | CHECK(age >= 0 AND age <= 150) |
PRIMARY KEY | NOT NULL + UNIQUE 的组合,且只能有一个主键 | id INTEGER PRIMARY KEY |
FOREIGN KEY | 确保引用的目标行确实存在 | FOREIGN KEY (user_id) REFERENCES users(id) |
Malan 的一个关键观点:「在 C 里,类型错误发生在编译时。在 Python 里,类型错误发生在运行时。在 SQL 里——加了约束之后——类型错误根本不会发生,因为数据库在错误值被写入之前就拒绝了。这是'防线前置'——把错误挡在最外层的入口。」
-- ① 建表(CREATE)——定义列名、类型和约束
CREATE TABLE students (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
age INTEGER CHECK(age > 0),
city TEXT DEFAULT 'Unknown'
);
-- ② 插入(INSERT)——往里加一行
INSERT INTO students (name, age, city)
VALUES ('Alice', 20, 'Beijing');
-- ③ 查询(SELECT)——这是 SQL 的 80%
SELECT name, age FROM students WHERE city = 'Beijing' ORDER BY age DESC;
-- ④ 更新(UPDATE)——修改已有数据
UPDATE students SET age = 21 WHERE id = 1;
-- ⑤ 删除(DELETE)——删一行
DELETE FROM students WHERE id = 1;
Malan 强调 SELECT 的重要性:「SQL 里 80% 的语句是 SELECT。UPDATE 和 DELETE 你可能一天写一条。SELECT 你一分钟写三条。把 WHERE 和 ORDER BY 练到睡着觉都能写对。」
-- 条件筛选 SELECT * FROM movies WHERE year >= 2000 AND genre = 'Sci-Fi'; -- 模糊匹配(LIKE + 通配符 %) SELECT * FROM books WHERE title LIKE '%Harry%'; -- 书名包含 Harry -- 聚合函数——COUNT, AVG, SUM, MAX, MIN SELECT COUNT(*) FROM users; -- 总用户数 SELECT AVG(age) FROM users; -- 平均年龄 SELECT SUM(amount) FROM orders; -- 总销售额 SELECT MAX(price), MIN(price) FROM products; -- 最贵和最便宜 -- 分组统计——每个城市的用户数 SELECT city, COUNT(*) AS count FROM users GROUP BY city ORDER BY count DESC LIMIT 10; -- 去重 SELECT DISTINCT city FROM users ORDER BY city;
Malan 专门花了 3 分钟讲这对极易混淆的概念:
| WHERE | HAVING | |
|---|---|---|
| 过滤时机 | 分组之前——先筛行,再分组 | 分组之后——先分组,再筛组 |
| 能过滤什么 | 原始列的值 | 聚合函数的结果(COUNT, AVG 等) |
| 典型用法 | WHERE city = 'Beijing' | HAVING COUNT(*) > 5(找出用户数超过 5 的城市) |
-- 正确:找出用户数超过 5 的城市(必须用 HAVING,不能用 WHERE) SELECT city, COUNT(*) AS cnt FROM users GROUP BY city HAVING cnt > 5; -- 错误!WHERE 在分组之前执行,此时 cnt 还不存在 SELECT city, COUNT(*) AS cnt FROM users GROUP BY city WHERE cnt > 5; -- ❌
这是 SQL 最强大的特性之一——一个 SELECT 的结果可以直接作为另一个 SELECT 的条件或数据源:
-- 找出评分高于平均分所有的电影
SELECT title, rating FROM movies
WHERE rating > (SELECT AVG(rating) FROM movies);
-- 找出和 'Tom Hanks' 合作过的所有演员
SELECT DISTINCT name FROM people
WHERE id IN (
SELECT person_id FROM stars
WHERE movie_id IN (
SELECT movie_id FROM stars
WHERE person_id = (
SELECT id FROM people WHERE name = 'Tom Hanks'
)
)
) AND name != 'Tom Hanks';
Malan 在第 7 周的 Problem Set 中会让学生写这种嵌套子查询。他的建议:「从最内层开始写、开始理解。最内层的查询先独立跑通,然后一层层向外包。」
这是关系型数据库真正的超能力。Malan 用一个场景说明:「你用 Week 5 的链表写了电话簿——每个人存一个 name 和 number。但如果你想要每个人的'购物记录'呢?你不可能在 phonebook 节点里塞一个可变长的购物数组。在 SQL 里——两张表,一行 JOIN,齐活。」
-- 建两张表 CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT); CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, product TEXT, price REAL); -- 插入数据 INSERT INTO users VALUES (1, 'Alice'), (2, 'Bob'); INSERT INTO orders VALUES (1, 1, 'Book', 29.9), (2, 1, 'Pen', 3.5), (3, 2, 'Laptop', 999.0); -- JOIN:把两张表按 user_id = users.id 缝在一起 SELECT users.name, orders.product, orders.price FROM users JOIN orders ON users.id = orders.user_id; -- 结果: -- Alice | Book | 29.9 -- Alice | Pen | 3.5 -- Bob | Laptop| 999.0
| JOIN 类型 | 结果 | 什么时候用 |
|---|---|---|
| INNER JOIN | 只返回两张表中匹配到的行 | 最常用。只关心有对应关系的记录 |
| LEFT JOIN | 左表的所有行 + 右表匹配的行(没匹配到显示 NULL) | 「用户列表 + 订单(含没下过单的用户)」 |
| RIGHT JOIN | 右表所有行 + 左表匹配的行 | 较少用。SQLite 不支持,但概念存在 |
Malan 在第 7 周用一个非常接地气的例子引入数据库设计的思想:
-- ❌ 坏设计——一张大表存一切
CREATE TABLE bad (
user_name TEXT,
user_email TEXT,
order_product TEXT,
order_price REAL
);
-- 问题:Alice 买了 3 件东西 → 她的名字和邮箱被重复存了 3 遍
-- 如果 Alice 改邮箱 → 需要更新 3 行,容易漏
-- 如果删除 Alice 的最后一个订单 → Alice 这个人就消失了
-- ✅ 好设计——拆成两张表,用外键关联
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT UNIQUE);
CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, product TEXT, price REAL,
FOREIGN KEY (user_id) REFERENCES users(id));
-- 好处:Alice 的信息只存一次。改邮箱 → 改 1 行。删订单 → 人还在
这个原则叫数据库规范化(Normalization)——减少数据冗余、避免更新异常和删除异常。Malan 没有深入 1NF/2NF/3NF 的理论,但他用一个「坏设计→好设计」的对比让你直观感受到了「为什么拆表是对的」。
Malan 用了一组实测数字。一张有 100 万行数据的 users 表,查询 WHERE name = 'Zoe':
| 状态 | 耗时 | 为什么 |
|---|---|---|
| 没有索引 | 约 0.5 秒 | 全表扫描(O(n))——从头扫到尾找 'Zoe' |
加了 CREATE INDEX idx_name ON users(name); | 约 0.0003 秒 | B-Tree 索引查找(O(log n))——直接定位到 'Z' 开头的区域 |
索引的原理和你在 Week 3 学的二分搜索完全一样——但数据库自动维护索引,你不需要在每次插入后重新排序。 代价:
Malan 的脸色变得严肃起来。这是 CS50 最重要的安全课之一——他在讲台上现场演示一个经典的 SQL 注入攻击。
场景:一个登录页面,用户输入用户名和密码。后端代码是(用 Python 写的 CS50 示例):
# 危险示范——用字符串拼接直接构造 SQL 语句!
username = input("Username: ")
password = input("Password: ")
db.execute(f"SELECT * FROM users WHERE username='{username}' AND password='{password}'")
正常人输入:alice / 12345。构造的 SQL:
SELECT * FROM users WHERE username='alice' AND password='12345'
工作正常。但攻击者输入:alice'--。构造的 SQL 变成:
SELECT * FROM users WHERE username='alice'--' AND password='...'
-- 在 SQL 中是注释——后面的所有内容(包括密码检查)全被注释掉了!攻击者用不知谁的密码登录了 alice 的账户。
更可怕的——攻击者输入:Robert'); DROP TABLE students;--。这条语句会被执行两次:第一次 SELECT,第二次删除整张 students 表。Malan 没有在课上真的执行——他在黑板上写好然后盯着学生:「你们现在知道了——永远不要把用户输入直接拼进 SQL 语句。」
# 安全做法——用占位符(?),让数据库引擎处理转义
username = input("Username: ")
password = input("Password: ")
db.execute("SELECT * FROM users WHERE username = ? AND password = ?", username, password)
此时 ? 占位符告诉数据库:「这两个值是数据,不是 SQL 代码。」即使用户输入了 '--,它也会被当作字面文本存储和比较——而不是被当作 SQL 语法执行。
Malan 补充了两条防线(纵深防御原则):
Malan 的总结:「SQL 注入不是高级黑客技术——它是一个 10 岁小孩在输入框里打
'; DROP TABLE就能做到的事。但防御也同样简单:永远用参数化查询。 永远。一个字。」
Malan 在第 7 周的后半段引入了事务——这是数据库的「原子性」保障。场景:银行转账——从 Alice 的账户扣 100 元,加给 Bob。两步操作必须要么都成功,要么都撤销。如果扣了 Alice 的钱但加不到 Bob 的账户(因为网络断了、数据库崩了)——钱就丢了。
-- 事务的写法
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE name = 'Alice';
UPDATE accounts SET balance = balance + 100 WHERE name = 'Bob';
COMMIT; -- 全部成功 → 提交,数据持久化
-- 如果中间出错了——
ROLLBACK; -- 全部撤销,回到 BEGIN 之前的状态
Malan 的经典拆解:「事务给数据库加了一个'后悔药'——在 COMMIT 之前,所有的修改都只存在于你的会话中,外部用户看不到。一旦 COMMIT,修改才真正写入磁盘。一旦 ROLLBACK——什么都没发生过。这就是为什么银行转账的代码里一定会有 BEGIN 和 COMMIT。这不是可选功能,这是法律要求。」
Malan 在第 7 周选择 SQLite 而不是 MySQL/PostgreSQL——理由很实际:「SQLite 不需要安装服务器。一个 .db 文件就是整个数据库。你的 iPhone 里每一个 App 的本地数据存的都是 SQLite。你在 CS50 IDE 里不需要配置任何东西——sqlite3 mydata.db 回车,就开始写 SQL。」
# 打开数据库 sqlite3 mydata.db # 在 sqlite3 命令行中: .tables -- 列出所有表 .schema students -- 查看 students 表的结构(建表语句) .mode csv -- 输出格式设为 CSV .import data.csv students -- 从 CSV 文件导入数据 .headers on -- 查询结果的第一行显示列名 .output result.txt -- 把之后的查询结果输出到文件 .quit -- 退出
CS50 提供了一份近百万行的电影数据库(IMDb 数据),包含 movies(电影)、people(演员/导演)、ratings(评分)、stars(主演关系)等表。你的任务——用纯 SQL 查询回答一系列问题:
这个作业的迷人之处:不需要 C,不需要 Python,不需要编译。你就在一个终端里写 SELECT 语句,每一行回答一个问题。 学生反馈中最常见的感叹:「我第一次感受到'数据在语言层面就是可查询的'。」
| 你之前在做什么 | C 的方式 | Python / SQL 的方式 |
|---|---|---|
| 存一组数据 | int arr[n]; 或 malloc | Python: list — 自动扩容SQL: CREATE TABLE — 持久化到磁盘,自动索引 |
| 查数据 | for 循环逐行比对 | Python: if x in list (O(n)), dict[key] (O(1))SQL: SELECT WHERE — 引擎自动用索引,你甚至不用想 O(n) 的问题 |
| 处理文本 | strcmp, strlen, strcpy | Python: ==, len, s[2:5]SQL: LIKE '%keyword%' |
| 处理图像 | fread 逐字节读 BMP 文件头 → 逐像素处理 → fwrite | Python: PIL/Pillow — 3 行开图、5 行做滤镜、1 行保存 |
| 多表关联查询 | 嵌套 for 循环 + if 比对 ID | SQL: JOIN ON — 一行声明,引擎自动优化执行计划 |
| 错误处理 | 每个函数调用完检查返回值 | Python: try/except 包裹 SQL: 约束在数据入口拦截 |
| 数据一致性 | 手动管理——如果中间崩溃,状态未知 | SQL: 事务(BEGIN+COMMIT/ROLLBACK)保证原子性 |