曹开力 Kaili Cao

观点 · Python哲学

Python哲学

如今,很多代码都由AI来写。仍然值得下功夫的,是对代码作用的直观认识——有了它,你才能读懂代码、检查代码,并且知道它什么时候出了错。

2022至2023年,我创建并讲授了一门免费的线上Python课程,四期共46名学生,其中不少人完全没有编程基础。我为课程写的材料,很多是关于细节的:哪个方法有什么作用,该用哪种语法,去哪里查。几年之后,这些细节大多可以交给AI助手,它给出答案比我自己去查还快。但我关于怎样思考代码的建议,反而变得更有用了,因为和AI合作时,读代码的时间比写代码多。本页汇集了这些建议,并针对AI时代作了修订。给学习者的实用建议以及这门课的教学方式,见学习Python。

掌握、理解与了解

在学习指南中,我为每个知识点都设定了学习目标,分为三个层次:

当时,课程的大部分内容以“掌握”为目标,也就是会写。如今,写代码的工作很大一部分已由AI代劳,“了解”这一层更是几乎全部交给了它:它记得每一个字符串方法、每一个关键字参数。仍然要靠人自己的是“理解”,也就是读懂代码;本页其余部分讲的都是这一层。会写仍然重要,因为读懂代码的能力来自亲手写过代码;不过我现在会把重心向读代码倾斜。

自己试一试,除非你能预知结果

编程是一门实践的艺术。如果你想知道一种写法行不行,最好的方法往往不是问人,而是自己尝试一下。我要求学生把每个例子都自行尝试一遍,然后改一改例子,看看会发生什么。

只有一种例外:如果你看到输入就能知道输出是什么,或者能说出脚本中每一行的作用,就可以不试。判断这一点,需要本着实事求是的原则。正如孔子所说:“知之为知之,不知为不知。”如果对结果没有把握,动手试一试总是更稳妥的。

同样的标准,现在也适用于AI替你写的代码。如果你能说出每一行的作用,就算是检查过了;如果说不出,就运行一下,把里面的内容打印出来,搞清楚之后,再在它的基础上搭建其他东西。

先明确输入和输出

写程序之前,要明确输入什么、输出什么——无论是程序整体还是其中的每个局部。

除非文件是给人读的最终成品,否则我会同时写出生成它的程序和读取它的程序。

向AI提出一个好的请求,需要的也正是这些。如果你说不清输入什么、应该输出什么,AI就只能猜,而Zen of Python警告的恰恰是这一点:“面对模棱两可,拒绝猜测的诱惑。”(In the face of ambiguity, refuse the temptation to guess.)

名字是代词

头脑清楚地写程序,意味着在每一行都知道每个名字指的是什么:它的类型和内容。如果不知道,就借助type和print搞明白——尤其是在使用其他人写的代码时。AI写的代码,就是其他人写的代码。

我发现最有帮助的一个比喻是:Python中的名字就像代词。你不需要事先声明它们;一个名字指代的,就是最后交给它的东西。函数也是如此:实参好比名词,形参好比代词。如果你写的函数有一个名为arg的形参,那么写这个函数时,函数处理的对象是“被称为arg的东西”;而程序实际运行时,函数处理的对象是“被当作arg交给函数的东西”。

a
[1, 2, 3, 4]一个列表对象
b
名字就是代词:执行b = a之后,两个名字指代同一个列表,所以b.append(4)也会改变a指代的东西。Python Tutor能一步一步地演示这个过程,很适合自己动手看一看。借助AI(Claude)制作。

用代词来理解,就能解释Python中最常见的一种意外。下面的b并不是列表的副本,而是指代同一个列表的第二个代词:

>>> a = [1, 2, 3]
>>> b = a
>>> b.append(4)
>>> a
[1, 2, 3, 4]

所以我给初学者定的规矩是:除非有特别的理由,否则不要给同一个东西起两个名字,也不要给两个东西起同一个名字。照此去做,你甚至可以不学变量的作用域。

可以询问的对象

在Python中,名字指代的一切都是对象:数字、列表、字典,甚至函数。以复数z = 3 + 4j为例。它的实部和虚部z.real和z.imag是它的属性,也就是属于它的数据;它的共轭复数z.conjugate()则来自一个方法,也就是属于它的函数。面向过程的思路,就好像z是个没有生命的东西,我们把它扔进一台机器,机器吐出它的共轭复数。面向对象的思路,则是把z喊过来,问它:你的共轭复数是什么?Python两种写法都支持,例如sorted(items)和items.sort()。

大部分使用Python的人从来不需要设计自己的类,但几乎每个人都会用到以类来组织的程序包,所以建立这样的认识很有好处。而且,模块化和面向对象的思想方法是组织思路的有力工具——即使不编程也是如此。

先手动做一遍

面对一项新任务时,我会先想象自己手动完成它。在一道练习中,学生们先用纸笔列出4个数字的全错位排列,也就是没有一个数字留在原位的排列方式:先列出全部24种排列,再从中筛选出符合条件的9种。然后他们才编写程序,再把程序推广到任意个数字。

另一个例子来自实际工作:一个文件中每投一次票就记录一行用户id,要统计每位用户各自投了几次票。在拿到实际数据之前,我们先生成了一些模拟数据,这样就可以提前编写和测试程序。写代码之前,我们先想清楚了如何存储统计结果:用一个字典,记录每位用户的票数。能运行的第一版往往很慢。想象手动完成这项工作,就能想到更好的办法:你不会为每位用户把整个列表扫一遍,而是从头到尾过一遍,每遇到一票就画一笔“正”字。而到最后,几乎总会发现已经有人写好了:

# Slow: one pass through all the votes for each user
counts = {user: votes.count(user) for user in set(votes)}

# Tally marks: one pass, one mark per vote
counts = {}
for user in votes:
    counts[user] = counts.get(user, 0) + 1

# Someone has already written it
from collections import Counter
counts = Counter(votes)

AI通常会直接跳到第三种写法,这没有问题。正是因为亲手走过了前两种写法,你才能看出第三种为什么是对的,以及它大致应该运行多长时间。

好代码的样子

对AI写的代码,也应当提出这些要求,因为有了这些品质,代码才便于审查。注释的标准可以直接套用:现在你请教的“别人”常常是AI,而审查代码的是你自己。

什么值得花时间

我以前跟学生说,用编程完成一项工作,时间成本可以分为三部分:学习相关的功能、编写和调试代码、运行程序。运行时间一般可以忽略,因为要么很短,要么可以一边等一边做其他事。别人写的代码一般比自己写的更系统、更优雅;但如果学习时间太长,那还不如自己写,毕竟写代码是为了实现功能,不是为了美观。

AI改变了这笔账。如今学习一个库的成本很低,天平因此偏向了现成的工具:如果已经有人写好了你需要的东西,AI几分钟就能教会你怎么用。没有改变的,是本页其余部分讲的那些东西。不管工具是谁写的,你仍然需要知道它对你的数据做了什么。