CS 61A  /  作业解析
LAB 00

Lab 0:起步与工具链ok 2 个测试用例通过1 题需课程私有口令

这份 lab 没有难题,但它决定了你后面十周是「能自己查错的人」还是「只会等别人告诉你哪里错了的人」。真正要练的是:终端、工作目录、ok 评分器,以及「赋值语句到底做了什么」。

对应讲次:Lecture 1 · 欢迎、编程环境、函数与异常 I 官方题面:cs61a.org/lab/lab00 代码:labs/lab00/

0. 这份作业在练什么

Lab 0 的标题是 Getting Started。官方题面里绝大部分篇幅在讲怎么装 Python、怎么装终端、怎么装 VS Code——这些是一次性的体力活。真正需要动脑、并且会一直用到期末的,只有三件事:

本次要点
  • 工作目录(working directory):ok 这个程序为什么必须在 lab00 目录里运行?「文件在哪」和「我现在在哪」是两回事,绝大多数新手的第一个报错都出在这。
  • WWPD(What Would Python Do?):给出一串语句,预测解释器会打印什么。这类题不考语法,考的是赋值语句和表达式求值的区别——本课的第一母题。
  • ok 评分器的三种用法:-u 解锁概念题、直接运行跑 doctest、--score 看分数。学会读它的报错输出,比学会任何一个 Python 语法点都重要。

做之前你需要掌握的,只有 Lecture 1 前半段的内容:表达式与值、名字绑定、函数调用的三步求值规则。如果这三个词你还说不清,先回去看第一讲,再回来做这份 lab——否则第 2 节的 WWPD 你会「蒙对但不知道为什么对」,那等于没做。

整份作业在本仓库里的落地位置是 labs/lab00/,包含四个文件:

文件作用你要不要改它
lab00.py作业本体,唯一要提交的文件要,本次只改两行
ok课程自带的评分器程序(一个 Python 脚本)不要碰
lab00.ok配置文件,告诉 ok 这次作业叫什么、要测哪几道题不要碰
tests/python-basics.pyWWPD 概念题的题面与答案不要手改;用 -u 解锁
关于本页的验证状态,说在最前面

本仓库里跑 python3 ok --local 的真实结果是:2 个测试用例通过(twenty_twenty_six 的 doctest + python-basics 的 WWPD)。

还有第三道题 presem_survey,它需要一段课程开学问卷发给选课学生本人的私有口令。校外自学者拿不到这个字符串,因此这一题必然不通过。本页不会编造一个假口令来把输出凑成全绿——那对学习本课没有任何价值。第 4 节会把这道题的机制讲清楚,包括它为什么可以被「验证」但不能被「猜出来」。

1. 先把「我在哪」搞清楚:工作目录与 ok 的运行前提

题目要什么

官方题面里有一句话,读起来像客套,其实是这份 lab 最容易失分的地方:

Open your terminal, and make sure you are in the lab00 directory that
contains the unzipped lab files for this assignment. In that directory,
type ls to verify that there are the following files:
  - lab00.py
  - ok
  - lab00.ok

Attempting to run ok in a directory that does not have ok will produce an error.

翻成人话:你敲 python3 ok 的时候,Python 是去「当前工作目录」里找一个叫 ok 的文件。它不会满硬盘搜。如果你的终端此刻停在家目录,而 ok 躺在 ~/Downloads/lab00/ 里,那么 Python 找不到它,会报:

python3: can't open file '/home/you/ok': [Errno 2] No such file or directory

边界情况在于:这个报错不是「你代码写错了」,也不是「Python 没装好」。它是一个纯粹的位置问题。新手最容易在这里浪费一小时,因为报错信息里出现了 python3,于是他去重装 Python。

怎么想到的

要理解这件事,得先接受一个反直觉的设定:终端(terminal)永远「站在」某一个目录里。这个目录叫 working directory(工作目录),也叫 current directory。你敲的每一条命令,凡是提到文件名而没写完整路径的,都是相对这个目录去解释的。

为什么要有这么个设定?因为如果没有它,你每次提到一个文件都得写全 /home/qingyu/cs61a-lesson/labs/lab00/lab00.py,写十次就疯了。有了工作目录,你先 cd 过去「站好」,之后就只需要写 lab00.py。

由此引出两种路径写法,这是本节唯一需要真正记住的概念:

绝对路径(absolute path)相对路径(relative path)
长什么样以 / 开头(Windows 上是 C:\)不以 / 开头
例子/home/qingyu/cs61a-lesson/labs/lab00/okok、labs/lab00/ok、../lab01/ok
含义从磁盘根目录出发的完整地址,在哪敲都一样从当前工作目录出发的地址,换个目录含义就变了
好比「北京市海淀区颐和园路 5 号」「往前走两个路口右转」

两个特殊名字要记住:. 表示当前目录本身,.. 表示上一层目录。所以 cd .. 是「退回上一级」,../lab01 是「上一级里那个叫 lab01 的兄弟目录」。

关键一步

把 ls 当成「确认我站对了地方」的动作,而不是「列出文件」的动作。每次运行 ok 之前先敲一次 ls,看见 lab00.py 和 ok 同时出现,才开始跑测试。这个习惯能省掉你整个学期一大半的莫名其妙。

代码:三条命令就够了

题面附录里给了三条终端命令,这就是本课需要的全部 shell 知识:

ls                      # 列出当前目录下的所有文件
cd <path to directory>  # 切换到指定目录
mkdir <directory name>  # 新建一个目录

逐条说为什么是这三条而不是别的:

  • ls(list)不需要任何参数,它回答「我这儿有什么」。它是只读的,敲多少次都不会弄坏东西,所以放心敲。
  • cd(change directory)改变的是终端的「站位」,不是文件本身。cd 后面既可以跟绝对路径也可以跟相对路径——这正是上面那张表的用处。cd 后面什么都不跟,多数系统会把你送回家目录。
  • mkdir(make directory)用来给这门课建一个专门的文件夹。官方建议你建一个 cs61a 目录,把每次作业解压进去,别全堆在 Downloads 里。这不是洁癖,是因为半学期后你会有二十多个作业包,同名文件互相覆盖是真的会发生的事。

本仓库里对应的一次完整操作长这样(这就是我们真实跑测试的方式):

cd /home/qingyu/cs61a-lesson/labs/lab00
ls
# lab00.ok  lab00.py  ok  tests
python3 ok --local

注意最后那个 --local。官方题面里写的是直接 python3 ok,那会额外尝试把你的代码备份到课程服务器并要求你输入 bCourses 邮箱。我们不是选课学生,服务器会拒绝或超时。--local 的意思是「只在本地评分,别联网」,输出的测试结果与联网时完全一致。题面自己也说了,看到 Connection timed out 不用慌,那不影响评分。

验证

把「工作目录」这个抽象概念落到一次真实的追踪上。假设文件系统长这样:

/home/qingyu/
└── cs61a-lesson/
    ├── index.html
    └── labs/
        ├── lab00/
        │   ├── lab00.ok
        │   ├── lab00.py
        │   └── ok
        └── lab01/
逐步推演:一次 cd 之旅
1 打开终端,工作目录 = /home/qingyu。敲 ls,看到 cs61a-lesson,没有 ok。
2 此时敲 python3 ok → Python 把相对路径 ok 拼成 /home/qingyu/ok,该文件不存在 → No such file or directory。这不是你的错,是站错了地方。
3 敲 cd cs61a-lesson/labs/lab00。这是一条相对路径,从 /home/qingyu 出发逐层往下:cs61a-lesson → labs → lab00。工作目录变成 /home/qingyu/cs61a-lesson/labs/lab00。
4 敲 ls,看到 lab00.ok lab00.py ok tests。三个关键文件齐了,站位正确。
5 敲 python3 ok --local → 相对路径 ok 拼成 /home/qingyu/cs61a-lesson/labs/lab00/ok,存在,开始评分。
6 想去隔壁做 lab01?敲 cd ../lab01:.. 把你退回 labs,再进 lab01。等价于 cd /home/qingyu/cs61a-lesson/labs/lab01,但少打很多字。
常见误区
  • 「我明明双击了文件夹」:在文件管理器里点开某个目录,不会改变终端的工作目录。两者是完全独立的两个程序。想让它们一致,要么在终端里 cd,要么用 VS Code 的 Open Folder… 打开整个 lab00 文件夹再开内置终端——后者会自动把工作目录设成那个文件夹,这就是官方反复推荐它的原因。
  • 只把 lab00.py 单独拖进编辑器:VS Code 里「打开单个文件」和「打开文件夹」是两回事。只开文件的话,新建终端的工作目录多半还是上一次的,跑 ok 照样找不到。
  • 路径里有空格:如果目录叫 CS 61A,cd CS 61A 会被当成两个参数而失败,报 too many arguments。要写 cd "CS 61A",或者干脆别在目录名里用空格。
  • python3 命令不存在:Windows 上常常只有 python 或 py。题面明确说了这三个都可以试。这不是代码问题,是安装器给可执行文件起的名字不同。

2. WWPD:python-basics

题目要什么

WWPD 是 What Would Python Do 的缩写:给你一串按顺序输入解释器的语句,你要预测每一行会在屏幕上显示什么。它不给你写代码,它逼你在脑子里当一次解释器。

题面给出的原始形式是这样(下划线处是要你填的):

>>> x = 20
>>> x + 2
______
>>> x
______
>>> y = 5
>>> y = y + 3
>>> y * 2
______
>>> y + x
______

运行方式是:

python3 ok -q python-basics -u

三个部分各是什么意思:-q python-basics 指定只跑名叫 python-basics 的那道题(-q 是 question);-u 是 unlock(解锁),表示「进入交互问答模式,一行行问我答案」。答错没有分数惩罚,但你必须答对才能往下走——这个设计是故意的:它不允许你跳过不懂的地方。

边界情况:有四行,但只有四行有输出

八行输入里只有四行需要你填。x = 20、y = 5、y = y + 3 这三行是赋值语句(assignment statement),它们不产生任何输出——连空行都没有。为什么?这正是这道题真正在考的东西,下一小节讲。

怎么想到的

如果你只是把 x = 20 读成「x 是 20」,那这道题你会全对,但你什么也没学到。要看到它背后的机制,得先把「语句」和「表达式」分开。

Python 交互式解释器(那个 >>> 提示符)的行为规则只有一句话,但它统治了整个学期:

核心结论:解释器的读—求值—打印循环

你敲下一行按回车,解释器做三件事:读入这行文本 → 求值 → 如果求出来的是一个值,就把它的 repr 打印出来;如果这行是一条语句、没有产生值,就什么也不打印。

这就是为什么 x + 2 会显示 22,而 x = 20 什么都不显示。前者是表达式(expression),有值;后者是语句(statement),它的作用是产生一个副作用——在当前环境里把名字 x 绑定到值 20——但它本身不是一个值。

接下来是第二个关键点,也是 y = y + 3 这一行存在的全部理由。在数学里,y = y + 3 是个无解方程。在 Python 里它完全合法,因为等号不是等式,是赋值。赋值语句的执行规则是:

赋值语句的执行规则
1 先把等号右边的表达式完整求值,得到一个值。
2 再把等号左边那个名字,绑定到这个值上。如果这个名字之前已经绑过别的值,旧的绑定被覆盖。

顺序不能反。因为右边先算完,所以右边那个 y 用的是旧值;等右边算出 8 之后,左边的 y 才被改写成 8。一个名字在同一行里前后指两个不同的值,这在数学里说不通,在这套规则下毫无歧义。

把这两条规则合起来,题目就变成了一次机械推演,不需要任何灵感。我第一次做的时候的确停顿了一下,卡在「x + 2 之后 x 还是不是 20」——如果 x + 2 把 x 改成了 22 呢?题目故意在 x + 2 后面紧跟一行光秃秃的 x,就是要打消这个疑虑:x + 2 只是算了个值,没有赋值语句就没有任何绑定被改变。这是「求值」和「改变状态」的分野,第一次见的人真的会搞混。

代码:官方测试文件里的答案

WWPD 的答案在官方 tests/ 目录里原本是哈希加密的(防止直接看答案)。本仓库的做法是真的推演出答案再与哈希比对,比对通过后解锁成明文。labs/lab00/tests/python-basics.py 现在长这样:

test = {
  'name': 'Python Basics',
  'points': 0,
  'suites': [
    {
      'cases': [
        {
          'code': r"""
          >>> x = 20
          >>> x + 2
          22
          >>> x
          20
          >>> y = 5
          >>> y = y + 3
          >>> y * 2
          16
          >>> y + x
          28
          """,
          'hidden': False,
          'locked': False,
          'multiline': False
        }
      ],
      'scored': False,
      'type': 'wwpp'
    }
  ]
}

顺带把这个文件读懂,因为后面每一份 lab 的测试文件都是这个结构:

  • 'type': 'wwpp'——这道题的类型,wwpp 就是 WWPD 那类交互问答题。别的题会是 'doctest' 或 'concept'。
  • 'locked': False——已解锁。如果是 True,22 那些地方会是一串哈希,你必须用 -u 答对才会变成明文。
  • 'points': 0 与 'scored': False——这道题不计分。题面也明说了:WWPD 的答案不提交到 Gradescope,lab 学分只看写代码的题。它的价值全在过程。
  • r"""...""" 前面那个 r 是 raw string,让反斜杠不被转义。这里其实用不上,是模板统一写法。

验证:逐行手动追踪

把八行输入当成八个时刻,追踪全局环境(global frame)里的绑定。这就是环境图的最简形态:

时刻 0  Global frame:  (空)

>>> x = 20
   右边 20 求值 → 20
   把名字 x 绑定到 20
时刻 1  Global frame:  x → 20
   这是语句,无输出

>>> x + 2
   这是表达式:查 x 得 20,算 20 + 2 → 22
   有值,打印
   输出:22
时刻 2  Global frame:  x → 20        ← 没变!加法不修改绑定

>>> x
   表达式:查 x → 20
   输出:20
时刻 3  Global frame:  x → 20

>>> y = 5
时刻 4  Global frame:  x → 20, y → 5
   无输出

>>> y = y + 3
   步骤 1:先算右边 y + 3 → 查 y 得旧值 5 → 5 + 3 = 8
   步骤 2:再把 y 绑定到 8,覆盖原来的 5
时刻 5  Global frame:  x → 20, y → 8
   无输出

>>> y * 2
   表达式:查 y 得 8 → 8 * 2 = 16
   输出:16
时刻 6  Global frame:  x → 20, y → 8

>>> y + x
   表达式:查 y 得 8,查 x 得 20 → 28
   输出:28
时刻 7  Global frame:  x → 20, y → 8

四个输出依次是 22、20、16、28,与解锁后的测试文件逐字一致。

直觉:把名字想成便利贴

值是放在桌上的东西,名字是贴在上面的便利贴。y = y + 3 是:先看便利贴 y 现在贴在哪个东西上(数字 5),拿它算出一个新东西(数字 8),然后把便利贴撕下来,贴到新东西上。数字 5 本身没被改变——它不可变,谁也改不了它。被改的只是便利贴的位置。这个区分在学到列表(可变对象)时会救你的命:那时候「改便利贴」和「改桌上的东西」会变成两件截然不同的事。

常见误区
  • 以为赋值语句会打印出被赋的值。写成 >>> x = 20 然后填 20,ok 会判错。C 语言里赋值确实是有值的表达式,Python 里不是。
  • 以为 x + 2 会把 x 变成 22。这是把「算式」和「赋值」混为一谈。要让 x 变成 22,必须写 x = x + 2。
  • 在 -u 交互里填 Error 或 Nothing。这两个是 WWPD 的合法答案(分别表示会报错、表示无输出),但这道题四问全都有正常的数字答案,用不上。到 Lab 1 才会真正遇到它们。
  • 把答案直接改进 tests/python-basics.py。锁着的时候里面是哈希,你改成明文反而会让 ok 校验失败。老老实实用 -u。

3. 实现函数:twenty_twenty_six

题目要什么

打开 lab00.py,里面有一个函数,函数体是一条空白的 return。题面的要求是:

You should see a function called twenty_twenty_six that has a blank return
statement. That blank is the only part you should change. Replace it with an
expression that evaluates to 2026. What's the most creative expression you
can come up with?

而函数自己的 docstring 把约束写得更具体:

"""Come up with the most creative expression that evaluates to 2026
using only numbers and the +, *, and - operators (or ** and % if you'd like).

>>> twenty_twenty_six()
2026
"""

翻成人话:写一个算术表达式,值必须正好等于 2026,只许用数字和 + - *(外加可选的 ** 乘方、% 取余)。不许用 /,也不许调用别的函数。

需要留意的边界情况有三个,全都不在题面明文里,但会真实卡住人:

  • 必须是 return,不能是 print。 doctest 里写的是 >>> twenty_twenty_six() 下面一行 2026,这检查的是函数的返回值。用 print(2026) 的话,屏幕上确实会出现 2026,但函数返回的是 None,doctest 会失败。第 6 节专门讲这个区别。
  • 必须是整数 2026,不是浮点数 2026.0。 doctest 是逐字符比对文本的:期望 2026,你返回 2026.0 会打印成 2026.0,四个字符对上了、多出来的 .0 对不上,判错。这就是为什么题面把 / 排除在允许的运算符之外——Python 3 里 4052 / 2 得到的是 2026.0,不是 2026。
  • 函数不接受参数。 括号里是空的,所以它每次调用都返回同一个数。它没有任何实用价值,纯粹是一个「你会不会改文件、会不会跑测试」的检查点。

怎么想到的

最省事的答案是 return 2026。它能过测试。但题面用了 most creative 这个词,说明出题人想让你多花三十秒,把 2026 拆开看看。这三十秒其实很值——它是这门课第一次要求你「把一个数看成一个结构,而不是一个符号」。

我的思考过程是这样走的,包括走进死胡同的部分:

1 第一反应:找 2026 的因数分解。 2026 是偶数,除以 2 得 1013。1013 是不是质数?逐个试除到 31(因为 32² = 1024 > 1013):不被 2、3、5、7、11、13、17、19、23、29、31 整除,所以 1013 是质数。于是 2026 = 2 × 1013,只有这一种分解。写成 return 2 * 1013——能用,但一点也不「creative」,因为 1013 这个数是凭空冒出来的,跟 2026 一样没有内在结构。这条路走死了。
2 换思路:既然乘法拆不开,试试加减法凑。 2026 离哪个「好看」的数近?2048 是 2 的 11 次方,2026 = 2048 − 22,写成 2 ** 11 - 22。这个还不错,但 22 又是个凭空数字。
3 关键一步:想到 2025 这个数。 2026 = 2025 + 1,而 2025 恰好是完全平方数——45 × 45 = 2025。这个不是巧合被我碰上的,是因为 2025 年被数学爱好者反复提过:它既是 45²,也是 (1+2+…+9)²,还是 1³+2³+…+9³。而 2026 就是它加一。
4 落地:45 ** 2 + 1。 只用了两个运算符、两个小数字,而且 45 和 1 都有出处(45 = 1+2+…+9),比 2 * 1013 有结构得多。定稿。
关键一步

这道题唯一的技术要点是运算符优先级(operator precedence):45 ** 2 + 1 会不会被理解成 45 ** (2 + 1) = 91125?不会。Python 的优先级是 ** > * / > + -,乘方结合得最紧,所以它先算,等价于 (45 ** 2) + 1。

但请注意:拿不准优先级的时候,加括号永远不会错。写 (45 ** 2) + 1 和 45 ** 2 + 1 完全等价,前者多两个字符,换来读者零思考成本。这门课后面写复杂布尔表达式时,这个习惯会持续帮到你。

代码逐行讲

这是 labs/lab00/lab00.py 里真实通过 ok 的代码:

def twenty_twenty_six():
    """Come up with the most creative expression that evaluates to 2026
    using only numbers and the +, *, and - operators (or ** and % if you'd like).

    >>> twenty_twenty_six()
    2026
    """
    return 45 ** 2 + 1  # 2025 = 45 * 45,所以 2026 = 45² + 1

逐行说为什么是这样:

行内容为什么这样写
1def twenty_twenty_six():没动过。 函数名是评分器按名字找的,改一个字母 ok 就会报 NameError。括号里空着表示零个形参(formal parameter),末尾冒号开启函数体。
2–7docstring也没动过。 它不只是注释:三引号字符串放在函数体第一行时会成为函数的文档,而里面的 >>> 行会被 doctest 当成可执行的测试。删掉它测试就没了。
8return 45 ** 2 + 1唯一改动的一行。return 而非 print,因为要的是返回值。45 ** 2 先算得 2025,再 + 1 得 2026。两个操作数都是整数,整数的 ** 和 + 结果仍是整数,所以打印出来是 2026 而不是 2026.0。

行尾的 # 注释是我们自己加的,说明这个表达式的来历。注释对 Python 完全透明——它读到 # 就忽略这一行剩下的内容。给一个「魔法数字」写清出处,是从第一份作业就该养成的习惯。

为什么不能写 print

这是新手第一个月最高频的错误,值得在这里就说清。

return 是把一个值交还给调用方,同时立刻结束这次函数调用;print 是往屏幕上写字,它自己返回 None。如果把第 8 行写成 print(45 ** 2 + 1),那么在解释器里调用会看到:

>>> twenty_twenty_six()
2026

看起来一模一样!但那个 2026 是 print 打出来的副作用,函数的返回值是 None——而 None 在交互解释器里是不显示的。一旦你把它接到别处,比如 twenty_twenty_six() + 1,就会炸:

TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'

doctest 比对的是整个输出文本,用 print 的版本恰好也打印 2026,所以这道题里 print 版其实能蒙混过关。但请不要这么写——Lab 1 开始,题目会明确要求返回值,那时 print 版会全线崩溃,而你已经养成了坏习惯。

验证:手动追踪一次调用

逐步推演:twenty_twenty_six()
1 解释器读到 twenty_twenty_six()。这是一个调用表达式(call expression):先求算子 twenty_twenty_six —— 在全局帧里查这个名字,得到那个函数对象。
2 求算子数(operand):括号里没有参数,所以这一步没事可做。
3 调用函数:新建一个局部帧 f1: twenty_twenty_six,parent 是 Global。因为没有形参,这个帧里一个绑定也没有。
4 在 f1 里执行函数体。docstring 是一个字符串字面量,求值后被丢弃,没有效果。执行到 return 45 ** 2 + 1:先求 45 ** 2 → 2025;再求 2025 + 1 → 2026。
5 return 把 2026 作为这次调用的值交回去,帧 f1 结束。
6 整个调用表达式的值是 2026。它是一个值,交互解释器把它打印出来 → 屏幕显示 2026,与 doctest 期望的那一行逐字相同。
Global frame
    twenty_twenty_six  ─────→  func twenty_twenty_six()

f1: twenty_twenty_six          [parent = Global]
    (无参数,无局部绑定)
    当前在算:45 ** 2 + 1
    Return value: 2026

真实跑一遍的结果:

$ cd labs/lab00 && python3 -i lab00.py
>>> twenty_twenty_six()
2026
>>> type(twenty_twenty_six())
<class 'int'>

type(...) 那一行是自己加的确认:结果确实是 int 而不是 float,所以打印出来不会带 .0。

常见误区
  • 用了除法:return 4052 / 2 → 打印 2026.0,doctest 报 Expected: 2026 / Got: 2026.0。要整除得用 //,但题面把它排除在允许运算符外了。
  • 忘了改,留着空白的 return:函数返回 None,交互里什么都不显示,ok 报 Expected: 2026 / Got: nothing。
  • 改了函数名或缩进:函数体必须缩进(通常 4 个空格)。少缩进会得到 IndentationError: expected an indented block;把 return 写到函数外面则是 SyntaxError: 'return' outside function。
  • 删掉了 docstring:测试就跟着没了,ok 会报这道题没有可运行的测试用例。docstring 不是装饰,它是测试的宿主。

4. 开学问卷:presem_survey(本仓库无法通过,原因如实说明)

题目要什么

题面的要求是:

As part of this assignment, fill out the Pre-Semester Survey form.

Once you finish the survey, you will be presented with a passphrase. Put this
passphrase, as a string, on the line that says
passphrase = 'REPLACE_THIS_WITH_PASSPHRASE' in the Python file for this
assignment. E.g. if the passphrase is abc, then the line should be
passphrase = 'abc'.

也就是:去填一份开学问卷 → 问卷提交后网页会显示一串口令 → 把这串口令原样写进 lab00.py 的 passphrase 变量 → 用 python3 ok -q presem_survey 验证。

这道题的本体在 lab00.py 底部:

passphrase = 'REPLACE_THIS_WITH_PASSPHRASE'

def presem_survey(p):
    """
    You do not need to understand this code.
    >>> presem_survey(passphrase)
    '490cbafdbd19352a62ff3988211180244329a1d311d1ee5e4a452791'
    """
    import hashlib
    return hashlib.sha224(p.encode('utf-8')).hexdigest()
诚实说明:这一题在本仓库必然不通过

那串口令是 CS 61A 发给选课学生本人的私有字符串,问卷表单只对 Berkeley 的账号开放。校外自学者拿不到它。

本仓库的 lab00.py 因此把这一行原样留成了 passphrase = 'REPLACE_THIS_WITH_PASSPHRASE',并在文件里写了注释说明。python3 ok --local 的真实输出是 2 个测试用例通过(twenty_twenty_six 与 python-basics),presem_survey 不通过。我们不会编造一个假口令去凑绿——那既骗不了 SHA-224,也对理解本课毫无帮助。

怎么想到的:这段代码在防什么

docstring 第一句就写着 You do not need to understand this code(你不需要看懂这段代码)。但恰恰是这段「不需要看懂」的代码,藏着一个很漂亮的设计,值得花几分钟拆开——它是本课后面 WWPD 解锁机制的同一套思路。

出题人面对的问题是:怎么在不把口令写进作业文件的前提下,让作业文件自己能验证口令对不对?

1 最笨的办法:在 lab00.py 里写 assert passphrase == '真口令'。立刻失败——真口令明晃晃地写在每个人都能打开的文件里,谁都不用填问卷了。
2 改进:把口令做点变形再存,比如每个字母往后移三位。但这种变形是可逆的,任何人花两分钟就能倒推回去。
3 正解:用密码学哈希函数(cryptographic hash function)。它把任意长度的字符串映射成一个固定长度的十六进制串,而且这个映射单向——由口令算哈希是瞬间的事,由哈希倒推口令在计算上不可行。于是作业文件里只需要存哈希值,不存口令。
4 验证逻辑就变成:把你填的 passphrase 算一遍哈希,看结果是不是等于文件里存着的那个期望值。对得上说明你填对了;对不上说明没填对。而文件本身从头到尾没有泄露口令。

docstring 里那串 '490cbafdbd19352a62ff3988211180244329a1d311d1ee5e4a452791' 就是真口令的 SHA-224 哈希值。它有 56 个十六进制字符 = 224 位。这就是为什么我们知道自己填的口令是错的(哈希对不上),却无法从这个哈希反推出正确口令。

核心结论

「能验证」和「能求解」是两回事。这段代码让任何人都能验证一个候选口令对不对,却让任何人都不能求出正确口令。这个不对称性是现代密码学的地基,也是本课 WWPD 题目「答案被锁住但 ok 能判对错」的完全相同的机制。

代码逐行讲

代码在做什么
passphrase = 'REPLACE_THIS_WITH_PASSPHRASE'一个全局变量,字符串。注意它写在函数外面——所以 docstring 里的 presem_survey(passphrase) 能在全局帧里查到它。
def presem_survey(p):接受一个形参 p,就是待检验的口令字符串。
import hashlib导入 Python 标准库的哈希模块。写在函数体内而不是文件顶部,这在生产代码里不推荐,这里是为了让整段逻辑自包含在一个函数里。
p.encode('utf-8')哈希函数吃的是字节(bytes),不是字符串。encode 按 UTF-8 规则把字符串转成字节序列。少了这一步会报 TypeError: Unicode-objects must be encoded before hashing。
hashlib.sha224(...)用这串字节构造一个 SHA-224 哈希对象。
.hexdigest()把哈希结果输出成 56 个字符的十六进制字符串。
return ...返回这个字符串。doctest 会拿它和期望值逐字符比对。

还有一个容易被略过的细节:docstring 里期望的输出是 '490cbaf...',带单引号。因为返回的是一个字符串对象,而交互解释器显示字符串时用的是 repr 形式,会把引号一起打出来。如果你在 doctest 里把引号漏了,即使哈希算对了也会判错。这个「repr vs str」的区别第一次见很容易吃亏。

验证:用一个已知的口令走一遍

既然真口令拿不到,就换一个我们能完全掌控的例子,把机制跑通。题面自己举的例子是「如果口令是 abc」,那就试 abc:

$ python3 -c "import hashlib; print(hashlib.sha224('abc'.encode('utf-8')).hexdigest())"
23097d223405d8228642a477bda255b32aadbce4bda0b3f7e36c9da7
逐步推演:presem_survey('abc')
1 调用表达式求值:查到函数对象,求算子数得字符串 'abc',新建帧 f1,绑定 p → 'abc'。
2 import hashlib:在 f1 里绑定名字 hashlib 到模块对象。
3 p.encode('utf-8'):'abc' 三个字符在 UTF-8 下各占一字节 → b'abc',即字节 0x61 0x62 0x63。
4 hashlib.sha224(b'abc') → 一个哈希对象;.hexdigest() → '23097d223405d8228642a477bda255b32aadbce4bda0b3f7e36c9da7'。
5 return 这个字符串,帧结束。
6 拿它和文件里期望的 '490cbafd...' 比:第一个字符 2 vs 4,已经不同 → 口令不是 abc。

这就是 ok 判这道题不通过时给出的报告形式:

---------------------------------------------------------------------
Doctests for presem_survey

>>> from lab00 import *
>>> presem_survey(passphrase)
'c0e1eeb...(REPLACE_THIS_WITH_PASSPHRASE 的哈希)'

# Error: expected
#     '490cbafdbd19352a62ff3988211180244329a1d311d1ee5e4a452791'
# but got
#     '...'

顺带注意这个哈希函数的一个性质:哪怕只改一个字符,输出也会面目全非。'abc' 和 'abd' 的 SHA-224 之间没有任何可见的相似性。这叫雪崩效应(avalanche effect),它保证了你不能靠「越试越接近」来逼近答案。

常见误区
  • 把口令写成变量名而不是字符串:passphrase = abc(没引号)会报 NameError: name 'abc' is not defined。题面特意强调 as a string,就是在防这个。
  • 复制口令时带上了首尾空格或换行:' abc' 和 'abc' 是两个不同的字符串,哈希完全不同。从网页复制时尤其容易多带一个空格,而肉眼看不出来。
  • 试图从哈希值反推口令:不可能,别浪费时间。这道题不是智力题。
  • 因为这题过不了就以为整份作业没做完:这道题只是问卷回执,不涉及任何编程概念。lab 学分按题面说的是看写代码的题。校外自学者跳过它,损失为零。

5. 学会用 ok 与 Python 命令行:一整个学期的调试基础设施

这一节要什么

Lab 0 的编程量小到近乎为零,但它把整个学期要用的两套工具摆在了台面上:课程自带的 ok 评分器,和 Python 解释器自己的几个命令行选项。这两样东西决定了你遇到 bug 时是「有办法查」还是「只能盯着代码看」。这一节把它们讲透。

ok 的几种用法

命令做什么什么时候用
python3 ok跑配置文件里 default_tests 列出的所有题目写完一整份作业,做最后确认
python3 ok -q 题名只跑指定的那一道题调某一道题时。别的题的报错会干扰你
python3 ok -q 题名 -u解锁模式:交互式地问你每一步的输出WWPD 概念题必须先解锁才能算完成
python3 ok --score本地打印每道题的得分提交前自查。这不会提交作业
python3 ok --local只本地评分,不联网备份、不问邮箱非选课学生(比如本仓库)用这个

-q 后面能填什么,由 lab00.ok 这个配置文件决定。它是本次作业的「说明书」,值得读一遍:

{
    "name": "Lab 0",
    "endpoint": "cal/cs61a/su26/lab00",
    "src": [
        "lab00.py"
    ],
    "tests": {
        "lab*.py": "doctest",
        "tests/*.py": "ok_test"
    },
    "default_tests": [
        "twenty_twenty_six",
        "python-basics",
        "presem_survey"
    ],
    "protocols": [
        "restore",
        "file_contents",
        "analytics",
        "unlock",
        "grading",
        "backup"
    ]
}
  • "src":只有 lab00.py 会被提交和评分,其他文件都不会。
  • "tests":告诉 ok 去哪儿找测试,以及用什么方式跑。lab*.py 里的测试是 doctest(也就是 docstring 里的 >>> 行);tests/ 目录下的 .py 是 ok_test(就是第 2 节那种 test = {...} 结构)。本课的两类测试就是这两种,没有第三种。
  • "default_tests":不加 -q 时会跑的三道题,正是本页第 2、3、4 节讲的那三个。这三个名字也正是 -q 的合法取值。
  • "protocols":ok 会依次执行的动作。unlock 对应 -u,grading 是评分,backup 是往课程服务器备份——这最后一项就是校外用户会看到 Connection timed out 的来源,也是我们加 --local 的理由。

怎么读 ok 的失败报告

比起「怎么让它变绿」,更值钱的技能是看懂它红的时候在说什么。题面给了一个标准的失败样例:

---------------------------------------------------------------------
Doctests for twenty_twenty_six

>>> from lab00 import *
>>> twenty_twenty_six()
0

# Error: expected
#     2026
# but got
#     0

---------------------------------------------------------------------
Test summary
    0 test cases passed before encountering first failed test case

这段报告分四块,每块都要会读:

1 标题行 Doctests for twenty_twenty_six:告诉你是哪道题、用的哪类测试。看到 Doctests for 就知道要去那个函数的 docstring 里找对应的 >>> 行。
2 >>> from lab00 import *:ok 帮你做的第一步,把 lab00.py 里所有名字导入。如果你的文件有语法错误,会在这一步就炸,报的是 SyntaxError 而不是 Expected/Got——见到这种情况先查语法,别查逻辑。
3 失败的那次调用与它的实际输出:这里是 twenty_twenty_six() 返回了 0。
4 expected / but got:期望值 vs 实际值。这是逐字符文本比对,不是数值比较——所以 2026 与 2026.0、'abc' 与 abc 都算不同。
最后一行最容易被忽略

0 test cases passed before encountering first failed test case 说的是:ok 一遇到失败就停,后面的用例根本没跑。所以「0 passed」不代表你后面全错了,只代表它还没走到那儿。修好第一个再跑,数字通常会一下跳很多。

成功时的输出则是这样,这也是本仓库跑出来的真实结果(除去需要私有口令的那题):

---------------------------------------------------------------------
Test summary
    2 test cases passed! No cases failed.

Python 自己的三个命令行选项

题面附录列了三种运行 Python 文件的方式。它们的差别不是「风格问题」,是三种不同的调试场景:

命令发生了什么用在什么场景
python3 lab00.py从上到下执行整个文件,然后退出只想确认文件没有语法错误。文件里全是函数定义时,正常情况下一点输出都没有——没输出就是好消息
python3 -i lab00.py执行整个文件,然后不退出,进入 >>> 交互式会话,你定义的函数都可以直接调用最常用的调试方式。想试各种输入看返回值,就用它
python3 -m doctest lab00.py只把 docstring 里的 >>> 行当测试跑一遍不想启动 ok,只想快速验一下 doctest。全过时没有任何输出

-i 是这三个里最该记住的。它把「改代码」和「试代码」之间的往返成本降到最低:

$ python3 -i lab00.py
>>> twenty_twenty_six()
2026
>>> 45 ** 2
2025
>>> presem_survey('abc')
'23097d223405d8228642a477bda255b32aadbce4bda0b3f7e36c9da7'
>>> exit()
-i 有一个坑,一定要知道

题面写得很清楚:If you edit the Python file while running it interactively, you will need to exit and restart the interpreter in order for those changes to take effect.

也就是说:在 -i 会话开着的时候去编辑器里改代码,会话里的函数不会跟着变。文件是在启动那一刻被读进内存的,之后你在磁盘上改什么,这个会话都看不见。新手常常因此得出「我明明改了,为什么还报同样的错」的结论,然后开始怀疑人生。

正确做法:exit()(或 Linux/Mac 上按 Ctrl-D,Windows 上按 Ctrl-Z 再回车)退出,然后重新 python3 -i lab00.py。

验证:三条命令在本仓库的真实输出

逐步推演:一次完整的作业流程
1 cd labs/lab00 && ls → 看到 lab00.ok lab00.py ok tests。站位正确。
2 python3 lab00.py → 无任何输出。因为文件里只有一个变量赋值和两个函数定义,执行它们不产生可见效果。这说明语法没问题。
3 python3 ok -q python-basics -u → 进入交互问答,依次填 22、20、16、28,四问全过,这道题标记为已解锁。
4 编辑 lab00.py,把空白 return 改成 return 45 ** 2 + 1,保存。
5 python3 ok --local → 2 test cases passed(twenty_twenty_six 的 doctest 与 python-basics),presem_survey 因缺少私有口令而失败。
6 选课学生这里还要去 Gradescope 上传 lab00.py。题面强调:只需提交到 Gradescope,不需要提交到 ok;截止前可以无限次重交。
常见误区
  • 以为 --score 会提交作业。题面明说 This does NOT submit the assignment。它只是本地打分。
  • 把 python3 lab00.py 的「没有输出」当成出错了。恰恰相反,纯函数定义文件跑完无输出是正常的。有输出反而说明你在文件顶层写了会执行的语句。
  • WWPD 没解锁就去跑 python3 ok。未解锁的题会被跳过或提示先运行 -u,容易误以为是自己代码有问题。
  • 被 Connection timed out 吓到。那是备份到课程服务器失败,题面自己写着「Don't worry」,不影响评分也不影响提交。加 --local 可以彻底避开。
  • 在 -i 会话里改完代码不重启。见上面的警告框。这个坑一学期能坑你三次。

6. 整份作业回顾

Lab 0 只让你写了一行代码。但如果把它当成「一行代码的作业」,你就浪费了它。这份作业真正交付的是三种思维方式,它们会在后面十周里反复出现。

一、位置是一种状态

「工作目录」是你在这门课里遇到的第一个隐式状态(implicit state):它不在你敲的命令里,但它决定了命令的含义。同一条 python3 ok,在这个目录里能跑,在那个目录里报错。

这个模式会一直复现。函数调用时的「当前环境」是隐式状态,决定了同一个名字 x 查出来是什么值;对象方法里的 self 是隐式状态,决定了你改的是哪个实例。每当代码「明明一样却行为不同」,先问一句:是不是有个我没看见的状态在起作用。

二、名字绑定不是等式

y = y + 3 这一行是第 2 节的全部价值所在。它逼你接受:等号右边先算完,左边的名字才被改写。这条规则本身两句话就讲完了,但它是整个环境模型的种子。

后面所有让人头大的东西——函数的局部帧、闭包捕获了哪个变量、列表被两个名字同时指着改一个另一个也变——追到底都是这一条规则在不同场景下的展开。如果你现在还觉得「便利贴」这个比喻幼稚,等学到可变列表的别名问题时你会回来找它。

三、能验证 ≠ 能求解

presem_survey 那段「不需要看懂」的代码,其实演示了本课测试体系的底层逻辑:把答案哈希掉,于是验证变得廉价而求解变得不可能。WWPD 题目锁着的时候你答不上来,但一旦答对,ok 立刻能确认——就是这个机制。

更一般地:写代码时,「我怎么知道它对了」这个问题,往往比「我怎么写出来」更值得先想。 doctest 存在的理由就是这个。这门课后面每一道题都会先给你 doctest,再让你写实现——顺序不是偶然的。

题目核心手法迁移到哪里
工作目录与路径区分绝对路径与相对路径;ls 先确认站位再动手整个学期每一次跑 ok;以后所有命令行工作
python-basics(WWPD)表达式有值会打印,语句没有值不打印;赋值先算右边再绑左边环境图、函数调用、作用域、闭包、可变对象别名
twenty_twenty_sixreturn 交还值,print 只写屏幕;运算符优先级;int 与 float 的显示差异Lab 1 起每一道函数题;None 引发的 TypeError
presem_survey单向哈希:可验证不可反推;字符串必须 encode 成字节;repr 带引号WWPD 解锁机制;以后遇到的所有「测试先行」写法
ok 与命令行选项-q 缩小范围、-u 解锁、-i 交互调试;读懂 expected/got 报告调试任何一份作业的第一步
本次真实验证状态

在 labs/lab00/ 下运行 python3 ok --local:2 个测试用例通过,无失败用例(twenty_twenty_six 的 doctest 与 python-basics 的 WWPD)。

presem_survey 需要课程发给选课学生的私有口令,校外无法获得,因此在本仓库必然不通过。lab00.py 里的 passphrase 保持成官方初始值 'REPLACE_THIS_WITH_PASSPHRASE',没有伪造。

动手自测

下面几题不需要写代码,在脑子里做完再展开对答案。做不对说明第 2、3 节还得回去看。

1. 下面这段在解释器里逐行输入,屏幕上会出现哪几行?
>>> a = 3
>>> b = a
>>> a = a * 2
>>> b
>>> a

答案:只有两行输出,3 和 6。

前三行都是赋值语句,无输出。b = a 执行时右边 a 先求值得到 3,于是 b 绑定到 3——绑定到的是那个值,不是「a 这个名字」。所以后面 a = a * 2 把 a 改绑到 6 时,b 完全不受影响,仍是 3。

2. 如果把 twenty_twenty_six 的函数体写成 return 4052 / 2,ok 会说什么?

会失败,报 expected 2026 but got 2026.0。

Python 3 里单斜杠 / 是真除法,结果永远是 float,哪怕能整除。doctest 逐字符比对输出文本,2026.0 与 2026 不是同一串字符。这也解释了题面为什么只允许 + - * ** %——这几个作用在整数上时结果仍是整数。

3. 你在终端里敲 python3 ok,得到 can't open file '/home/you/ok'。第一步该做什么?

敲 ls,看当前目录里有没有 ok 和 lab00.py。

报错里的路径 /home/you/ok 已经告诉你答案了:Python 把相对路径 ok 拼到了 /home/you 下面,说明你的工作目录是家目录,不是 lab00。用 cd 切过去即可。永远先读报错里的那个路径——它直接暴露了 Python 以为你在哪儿。

4. 为什么 presem_survey 里必须写 p.encode('utf-8'),直接传 p 不行?

因为哈希算法作用在字节序列上,而 Python 3 的 str 是 Unicode 字符序列,不是字节。同一个字符在不同编码下对应不同字节,哈希函数没法替你决定用哪种,所以强制你显式指定。

直接传字符串会报:TypeError: Unicode-objects must be encoded before hashing。

5. 你在 python3 -i lab00.py 会话里发现函数有 bug,切到编辑器改好保存,回到会话再调用一次,结果和之前一模一样。为什么?

因为 -i 会话里的函数对象是启动那一刻从文件读进内存的。之后磁盘上的文件怎么变,这个会话都不知道。

解决办法是退出(exit() 或 Ctrl-D)后重新 python3 -i lab00.py。题面附录里专门写了这句提醒,就是因为这个坑太常见。