优秀程序的良好习惯-一-

119 阅读1小时+

优秀程序的良好习惯(一)

原文:Good Habits for Great Coding

协议:CC BY-NC-SA 4.0

一、编程幻想

很久很久以前,一个有才华的年轻程序员没有资源去寻求更多的教育。他的工作没有前途,永远不会有任何提升。此外,他的家人也帮不了他,他住在镇上一个破败、不安全的地方。我们的程序员有四个开发了类似编程技能的朋友,他们也感到受到了机会的限制。他们都有点沮丧,担心自己的未来。

突然,五个程序员发现了一个惊人的机会。如果他们可以组队编写一个特定的计算机应用,那么他们将得到的关注将立即为更好的工作打开大门。

当然,在这种情况下,任何人都想尝试编写应用。但事情没那么简单。以前,他们每个人编写的最具挑战性的程序需要三周时间,每天 1-2 小时。大部分时间都花在了调试上。其中一些错误很难追踪,以至于他们两次放弃了他们的程序,只是出于好奇才回来找他们。事实上,那三周的时间实际上分散在六周内。他们都有同样的经历。

在回顾这个新项目的工作时,似乎这项工作自然可以分成五个相等的部分。问题是每一部分都比他们任何一个人以前做过的任何东西都长五倍。他们有 40 周的时间来完成这个项目。理论上,如果所有人都能保持专注,那就有足够的时间来完成。但实际上,其复杂性超出了任何人的想象。诱人的奖品也是对失败的邀请。简而言之,每个人都认为,他们目前过的这种无处可去的平静生活,可能比几乎肯定会导致失败的 40 周痛苦生活要好。谁需要那个?也许会有别的事情发生。最终,在交谈中,这五个朋友意识到这种失败主义思想是人们无法从生活中的贫困处境中爬出来的一个常见原因。然而,就每个人目前对这个项目的理解而言,完成这个项目对他们来说太难了。如果他们能增加成功的可能性,那么这也许值得一试。那么,该怎么办呢?

首先,这五个程序员必须接受这样一个事实:他们必须把自己变成编程机器人。他们日常生活中的许多乐趣将不得不被数小时的编程所取代。这需要改变习惯和对生活的看法。他们能做到吗?摆在他们面前的奖品可能就足够了。

真正的问题是调试。尽管代码的所有部分看起来都足够合理,但是有太多的部分会导致调试问题大量出现。他们不知道他们中的任何一个人是如何成功的。然后有人提出了一个解决方案:对于几乎每个编写的关键函数,可以编写一个配套函数来测试该函数。在每一次编程之后,测试功能将被运行。另一个程序将导入大多数重要的函数,并通过每个函数运行几组数据。例如,数据将测试函数中几乎所有的 if 语句。

这意味着,如果发生重新设计,受到不利影响的功能将立即被标记出来。为应用中需要的每一个函数编写两个函数将是额外的工作,但是测试函数将很容易编写,并且彼此非常相似。这个被称为单元测试的方案似乎带来了希望。

另一个成员建议团队每周聚在一起阅读彼此的代码,讨论问题,并提出新的解决方案。在这些代码评审中,他们会分享问题和艰难获得的解决方案。另一个建议是用英文描述(docstrings)记录几乎每一个关键函数,以便其他成员可以更容易地理解代码。另一个建议是,他们应该偶尔尝试成对工作(结对编程):一个打字,另一个思考正在键入什么。

该小组认为,他们成功的唯一机会是通过这些公约。其中一名成员后来描述说,使用这些约定就像穿着紧身衣写代码一样。

编程开始后不久,成员们注意到进展缓慢但稳定。不可避免的重新设计,通常基于被忽略的特殊情况和选择不当的数据结构,几乎总是会引起其他变化的多米诺骨牌效应。单元测试很快发现并定位了这些变化。

成员们也开始讨论编程风格的细微差别——例如,是否应该写

if (x and y) == True: print(x)

或者

if x and y: print(x)?

因为意见不一,他们决定以团体风格投票,坚持团体的决定。最终,他们的惯例,通常是武断的,开始看起来正确,任何不同的惯例看起来都是错误的。因为每个人都使用相同的风格,所以他们在阅读以他们商店风格编写的代码时都变得很有效率。

长话短说,他们的牺牲、承诺和关于编写代码的正确决策使他们能够完成项目并赢得更好的生活。最终,他们被寻找专业程序员的雇主雇用了。

他们的新雇主欣赏这个团队的成员有几个原因。首先,程序员将他们的生命投入到编写代码中,他们的技能非常优秀。他们写代码很快,而且几乎没有错误。他们理解他们的语言,并有效地使用其结构。第二,同样重要的是,他们的代码很容易被其他人阅读。第三,他们很灵活。他们在编程中采用了当前的 house 风格,即使他们个人更喜欢以不同的方式编写代码。

在这些编程员工作的一些公司,有裁员。我们的五个原编程员从未被放过。正如一位雇主所说,“他们给的总是比期望的多。谁会让这样的员工离开呢?”

几年过去了,他们都退出了写代码的行业。其中一个年轻的程序员有点无聊。他怀念编程的经历,但年纪太大了,无法重返全职工作。他的配偶注意到邻近的高中需要一名兼职教师来教授一个学期的高级编程课程。他接受了这份工作。

以前的老师希望学生理解不同的算法,并通过在计算机代码中正确实现算法来培养他们的编程技能。这位由程序员转行的老教师同意了这一观点,并意识到商业成功所必需的许多惯例不适用于编写小程序的学生。尽管如此,他认为,编写可读的代码应该与算法、语言指令和数据结构一起教授。课程进行到一半时,他已经讲课并张贴了以下准则。

开发程序员的建议(疼痛管理)

  1. 将功能局限于单个任务,或者简单且高度相关的任务(内聚与耦合)。
  2. 标记并对齐您的输出。
  3. 在顶部记录你的项目:名称、日期、课时(可能是课程和讲师)、标题和项目描述。注意你的拼写、语法和标点。
  4. 用行号编程,不要少于三个空格。
  5. 如果要强调重要的关系,请在代码中使用垂直对齐。
  6. 不要使用 Python 语言名称(保留字和内置名称)作为标识符或文件名,例如randommaxprintfactorial等。
  7. 在程序变得更具可读性之后,对其进行重构。这就是学习编程的时间和方式。
  8. 使用逐步细化:概述程序工作的函数调用。将main()函数限制为调用其他函数。在短程序中,你可以给main函数添加初始化和一些输出行。
  9. 编写自文档化的代码(描述性标识符,通常是动词对象函数名),从而最大限度地减少注释。避免过度缩写标识符,以节省键入几个字母。
  10. 总是打印每个程序的运行时间,也许还有其他一些统计数据。
  11. 避免使用神奇的数字,除非它们能使代码变得非常简单。
  12. 避免全局变量,但是全局常量是可以接受的。
  13. 不要写聪明的代码(代码看起来不像它做的那样),简单的代码就可以了。
  14. 选择可读性,而不是速度优化和内存使用优化。
  15. 通过使用防御措施(断言、错误陷阱、try/except块和中间打印)来预测 bug。只是不要过度。
  16. 完成后测试每个关键功能。将未测试的代码视为损坏的代码。
  17. 对于复杂的算法,可以考虑在编写代码之前编写一些简单的测试,而不是在编写代码之后。* * *
  18. 在完成前一项任务或职能之前,不要开始下一项任务或职能。
  19. 编程时,你需要全神贯注。避开健谈的同学。(有时候孤立自己的目的是逼你自己解决问题。不要依赖你的同学。)
  20. 将每个作业保存在至少两个不同的物理设备上。* * *
  21. 每周写点代码。不要退步。你可能要逼自己。
  22. 花时间和聪明人在一起,试着让他们谈谈工作。
  23. 看其他程序员的代码。
  24. 自学编程工具:复杂的编辑器、语言习惯用法和技巧、内置函数和数据结构。
  25. 带着尝试挑战性问题的历史来解决你的问题。
  26. 努力避免作弊。
  27. 不要让成绩和课外活动破坏你的教育。你要对自己的学习负责,而不是学校。

不幸的是,这份名单不仅被忽视,还被学生们质疑。他无意中听到许多贬低的评论:

  • “我不明白为什么我的代码对别人来说是可读的,而对我来说却是可读的,没有人会去读它。让程序运行已经够难了。我需要时间上其他课。”
  • “我不敢相信他要求我们不要写聪明的代码。他是想扼杀我们吗?”
  • “我认为我的代码足够具有描述性。他太挑剔了,要求更好的描述。”
  • “其他的 C.S .老师没有这么挑剔。我希望我在她的班上。”
  • “我的代码和保罗的完全一样,因为我们一起工作。他总是说我们需要互相帮助。他最好别说我作弊。”
  • “他告诉我们要互相帮助,而不是寻求帮助。这毫无意义。”
  • “我的程序中没有错误,那么他为什么要我在代码中设置错误陷阱呢?”
  • “我的代码适用于我的输入。这对他的输入不起作用,因为他用奇怪的数据进行测试,比如空集。”
  • "我仍然不明白关注分数会对我的教育产生什么负面影响。"
  • “为什么我们要在自己的时间里学习工具?他不应该把它们教给我们吗?”
  • “互联网上有很多程序不遵守他的规则。那么,他以为自己是谁?”

这位老程序员足够敏感,最终意识到课堂气氛已经从热情转为厌恶。因此,他改变了他的优先事项。只有少数短节目会被检查风格。其他人如果工作的话会被接受。作业变得更短更容易。他表扬了学生们简单的成功。他用一个有趣的 YouTube 视频开始了大多数课程,并允许热烈的讨论继续进行,即使他们抢了全班的练习时间。最后,学生们惊讶于老师进步了这么多。几个学生给了老师临别赠言和小礼物。

就在学年结束前,他反思了所发生的事情。他试图传递给学生的想法、习惯和观点超出了他们的理解范围。他们很快就能捕捉到细节,但没有足够的成熟度或动机去欣赏任何形式的大局。多年前,他和他的朋友们被绝望逼得不得不改变自己。教授他们这种元思维不能通过谈话来完成。它必须以某种方式经历才会被相信。然而,他给他们留下了一个警告。他给他们讲了下面的故事。

The Old Programmer’s Story

同学们,昨晚我发生了一件很神奇的事情,我想和大家分享一下。我与上帝交谈。是的,没错,上帝眷顾了我。诚然,他在梦中来找我,但我知道那是上帝。我们谈到了你的未来。不是对你们所有人,而是对很多人。我想告诉你你的未来会怎样。你的未来会很美好。你要去上大学,毕业,找一份好工作。在你遇到你的另一半之前,你会有很多有趣的假期和冒险。你会有一栋漂亮的房子,享受你的工作,有几个好孩子,还有健康的身体。你将拥有每个人都想拥有的未来。我想我应该现在就告诉你,趁你还年轻,你的未来会有多美好——至少到 45 岁左右。

那时你将被解雇。不是因为你做错了什么。这只是商业的变化和合并。就业部门在不断变化。因为你在你的职业中不突出,你被解雇了。

很自然,你试图找到另一份工作。毕竟,你有多年的经验。不幸的是,编程公司更喜欢雇佣年轻的程序员,他们不必支付那么多工资。不同的管理层认为,几年后,年轻的程序员会有和老程序员一样多的经验。年轻的程序员可能会成为伟大的程序员,而你只是一个普通的程序员。所以你参加了许多面试,但从未得到工作机会。这意味着你的配偶在支撑这个家庭。你大部分时间呆在家里做家务。假期被取消,孩子们的夏令营被取消,电子设备无法升级,当主电视坏了,你的家人把小电视从书房搬到了客厅。你花在自己身上的任何钱都会立即被注意到并受到严厉的批评。当你的配偶和你结婚时,他/她并没有预料到这种简朴的生活方式。怨恨导致了争论。你的配偶在孩子们面前批评你,孩子们也开始对你失去尊重。你的家庭关系变得有毒。最后,你的配偶提出离婚,要求你搬出去。

在离婚时,你从你们的共同储蓄中得到一些钱来买房子,但是你的配偶得到了孩子——你不能抚养他们。最终,你的钱花光了,你最后做了一份清洁工的工作,只是为了支付你的房租和食物。你开始变得沮丧,并开始从廉价的酒精快感中获得安慰。你没有变成酒鬼,但你每天都喝酒。几年后,你偶然照了照镜子,发现自己看起来比实际年龄要老。你掉了一颗牙,又没钱去替换它。你抬头看着天花板说,“为什么这一切会发生在我身上?我做了什么才落得如此下场?”突然,你听到身后有声音,注意到镜子里有动静。你转过身,你猜怎么着,你碰巧看到了我,你以前的计算机科学老师。

“s 先生,我以为你几年前就去世了。你在这里干什么?”

“我几年前就死了。但现在我成了宇宙力量的工具。我是来帮你摆脱困境的。”

“我不敢相信我的运气,”你说。“你要给我找份好工作,让我养家糊口,找回自尊吗?”

“不,宇宙力量不是那样运作的。”

“那你打算给我钱吗?”

“不,宇宙力量也不是那样运作的。”

“嗯然后呢?你打算怎么帮我?”

“首先,我想告诉你,你是如何让自己陷入这种困境的。你因平庸而犯罪。你从未脱颖而出。你没有学习超过你所需要的。你只做了要求的最少部分。你没有努力学习新技能。你没有努力提高你目前的技能,因为你没有必要这样做。当潜在雇主给你的前雇主打电话时,管理层所能做的就是核实你以前的工作。他们对你没什么好评价。难怪你第一个被解雇,最后一个被重新雇用。一旦你明白了这一点,就有希望成功”

“好吧,”你说。“这似乎是真的。在我看来,我从没想过我的未来会变成这样。我以为一般就够好了。除了工作,我还有其他爱好。我不想成为一个工作狂。不过,好吧,我已经吸取教训了。只要让我离开这种生活。”

“你吸取教训了吗?我们会找到答案的。我要让时间倒流,把你送回你在我班上的时候。你会忘记你的未来,除了我现在告诉你的这个关于你生活的小故事。我就像圣诞未来的幽灵。未来不是固定的,否则我不会告诉你这些。你的警告是要比平均水平更好。总是继续学习更多,提高自己的技能。无论是在工作中还是在人际关系中,总是付出比期望的更多。然而,要知道:你不会再有机会了。祝你好运。”

学生们认为这个故事很可爱,他们很欣赏一个能让他们开心的老师。他们中的大多数人很快就忘记了这个故事。只有少数人对此感到困扰。对他们来说,这个故事支持了他们已经开始相信的事情:可怕的陷阱在他们的未来——工作中的陷阱,婚姻中的陷阱,甚至是他们最终试图保护的孩子的陷阱。他从来不知道,但老程序员已经为他的学生做了所有可能的事情。

二、编程技巧

  • 掌握分析棋位的技巧需要大量的练习和努力。但是一旦你把它弄下来,你就不会后悔投资了。—乔尔·约翰逊(美国国际象棋大师)《阵型进攻》(私人出版,2010),第 15 页。

本章将采用一个简单的——几乎微不足道的——函数,并用 12 种不同的方式来编写它。这些招数大部分都不是学校教的。你需要自己去学习它们。

定义:斐波纳契 1 数字是一个序列中的数字,从 1,1,…开始,此后每个新数字都是前两个数字的和。以下是前 17 个斐波那契数列:

+----------------------------------------------------------------------------------------+
| Fibonacci numbers: 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597 |
| The nth position   1  2  3  4  5  6   7   8   9  10  11   12   13   14   15   16    17 |
+----------------------------------------------------------------------------------------+

这里,第 1000 个斐波那契数是 4346655…228875 (209 位)。有时这个序列最初的索引是零,有时它从初始值零开始。如果你让一个初学编程的人编写一个函数来打印第 n 个斐波那契数,他/她可能会编写一个简单的迭代函数,如下所示:

def fibA(num): # This function took 7.45 seconds to find the 1000th

               # Fibonacci number 100,000 times in Python Ver. 3.4.
    if num < 3:
       return 1
    a = b = 1
    for i in range(2, num):
        a, b = b, a+b
    return b

如果你让同一个程序员递归地解决这个问题,结果会像下面的函数fibB一样。

def fibB(num): # Too slow.
    if num < 3:
       return 1
    return fibB(num-1) + fibB(num-2)

这是这个斐波纳契函数集合中唯一一个对于实际工作来说太慢的函数。看起来,fibB的唯一理由是向初学者介绍递归。并非如此。它也可以作为一个糟糕的递归方法的例子。如果递归做得更好(稍后显示的fibH,或者可能通过使用记忆装饰器,也在稍后显示),它会快得多。

你可能会说fibB是这个集合中最差的函数。也是最简单的功能。所以我们已经学会了两种评估函数的方法:速度和简单。还有多少其他方法?至少还有四种方法。我们稍后将回到这个问题。

fibB函数仅计算第 45 个斐波那契数列一次就花费了 313.48 秒(5 分 13 秒)。我对计算第 1000 个斐波那契数十万次感兴趣。当然,为了让fibB更快,我们可以提供更多的基本案例。引入查找表是编程中的一个标准技巧。在 Python 中,有时可以用下面显示的巧妙的索引方法来完成。

def fibBB(num): # Still too slow to compare.
    if num < 18:
       return [0,1,1,2,3,5,8,13,21,34,55,89,144,233,377,610,987,1597,][num]#17 base cases
    return fibBB(num-1) + fibBB(num-2)

fibBB中,313.45 秒减少到大约半秒。不幸的是,fibBB函数仅计算第 55 个斐波那契数就花了 51.08 秒:还是太慢了。仔细阅读并检查所有技术资料。你注意到 17 种基本情况需要 18 个数字吗?

我们可以通过引入动态(变化的)查找表来改进双胞胎fibBfibBB。这叫做记忆化。虽然记忆化可以加速重叠子案例的递归,但这个改进的函数(fibC)仍然比fibA(迭代)慢 7 倍以上。当追加 17 基情况的查找表时,时间增加了(多么奇怪)几乎 24%(从 57.11 秒增加到 70.69 秒)。有时听起来不错的编程想法并不总是那样。

def fibC(num, dict): # 57.11 seconds to find the 1000th Fibonacci number
                     # 100,000 times.
    if num in dict:
        return dict[num]
    dict[num] = fibC(num-1, dict) + fibC(num-2, dict)
    return dict[num]
# The call to fibC looks like this: print(' C.', fibC(n, {1:1, 2:1}))

函数fibAfibC都是“动态规划”的例子,这是一个困难的话题,我们将在最后一章考虑。在fibC让字典全球化让我们免于传递字典。然而,使用全局字典不会降低速度,并且尽可能避免使用全局变量。那么,我们能避免在没有全局变量的情况下传递字典吗?是的。Python 函数是类;他们有阶级变量。

def fibD(num): # 73.96 seconds.
    if num in fibD.dict:
        return fibD.dict[num]
    fibD.dict[num] = fibD(num-1) + fibD(num-2)
    return fibD.dict[num]
fibD.dict = {1:1, 2:1}
# A Python function's class variable must be declared BELOW the
# function.

不幸的是,fibD明显比fibC慢,尽管fibD代码与fibC代码完全相同,除了fibC在传递地址时做了额外的工作。这样的速度变化,尤其是时间的增加,怎么可能呢?显然,访问一个类变量(fibD.dict)要比访问一个全局变量或参数(dict)花费更多的时间。

fibD的设计让我很不舒服,因为我们在代码中有一个四处浮动的查找表。假设他们走散了呢?查看一个函数同时试图找到它的一个引用会降低可读性。我的建议是将它们一起嵌入到另一个函数中。但是时间还是很慢。事实上,嵌套函数的执行速度总是比非嵌套函数慢。

def fibE(num): # 76.35 seconds.
    def fib(num):
        if num in fib.dict:
            return fib.dict[num]
        fib.dict[num] = fib(num-1) + fib(num-2)
        return fib.dict[num]
    fib.dict = {1:1, 2:1}
    return (fib(num))

我们能做得更好吗?是,使用初始字典的默认值。这是编程中的标准伎俩。记住它。

def fibF(num, dict = {1:1, 2:1}): # 59.99 seconds.
    if num in dict:
        return dict[num]
    dict[num] = fibF(num-1, dict) + fibF(num-2, dict)
    return dict[num]

难道不应该有一个断言语句吗,比如:

assert type(num) == int and num > 1, 'Bad data: num = ' + str(num)

是的,但是对于这些例子,我已经简化了代码。

现在我要引入一个棘手的概念:装饰者。回忆缓慢的fibB

def fibB(num): # Simple code, but too slow, or is it?.
    if num < 3: return 1
    return fibB(num-1) + fibB(num-2)

如果有一个记忆词典,它会运行得更快,但这会使代码变得复杂。那么,我们可以两者兼得吗?嗯,差不多了。Python 的设计者已经引入了一种方法来做到这一点,而没有大多数缺点。唉,代码将驻留在两个地方。这是你怎么做的。

def memoize(function):            # function = fibB.
   dict = {}                      # This line is executed only once.
   def wrapper(num):              # num came from fibB(num).
      if num not in dict:
         dict[num] = function(num)# The return of fibB is always to dict[num].
      return dict[num]            # The return is to function, except for final.
   return wrapper                 # This line  is executed only once.

@memoize
def fibB(num):
   if num < 3: return 1
   return fibB(num-1) + fibB(num-2)

这个过程被称为“修饰函数”它不仅使我们不必在每个需要记忆的单参数函数中引入新的字典,而且装饰器还通过提取记忆代码简化了被装饰的函数。不幸的是,设计师们找不到一个简单的设计来装饰一个功能。程序员必须研究和编写许多 decorators 来了解正在发生的事情。

偶尔你可能想为一个功能计时。为什么不把@timer放在函数定义的上面,从你的个人库中取出这个装饰器呢?

def timer(function):
    from time import clock
    from sys  import setrecursionlimit; setrecursionlimit(100) # default = 1000
    startTime = clock()
    def wrapper(*args, **kwargs):
        result = function(*args, **kwargs)
        return result
    elapsedTime = round(clock()-startTime, 2)
    print('-->', function.__name__ +"'s time =", elapsedTime, 'seconds.')
    return wrapper

clock可以从其他地方导入。可选的setrecursionlimit有时对递归函数很有用。(*args, **kwargs)意味着任何一组普通参数和关键字参数都将被接受。function.__name__只是调出函数名。所以你看装饰者有时可以简化代码。请注意:1)递归修饰函数似乎比没有修饰的函数需要更多的递归。2)装修工在本书中不会用到太多。

看了几个斐波纳契函数之后,我们再次问是否还有其他的方法?如何看待使用公式:没有循环,没有递归?我们怎么忽略了这一点?公式既简单又快速。

def fibG(num):
    from math import sqrt
    phi1 = (1 + sqrt(5))/2
    phi2 = (1 - sqrt(5))/2
    return round((phi1**num - phi2**num) / sqrt(5))
# fibG(70) = 190392490709135

嗨。注:这些方程被称为比奈公式,以 1843 年发表该公式的法国学者命名。 2 作为练习,提高fibG的速度。我的版本在脚注里。 3

然而,使用带有浮点数的公式来产生大数是一个可怕的想法,因为浮点数的精度是有限的,因此最终会输出不正确的值。Python 中的整数只受计算机可用内存的限制。继续使用整数,我们可以精确地生成第一千万个斐波那契数,它有 2,089,877 个数字。至少有四个原因使得计算机算术不总是和数学算术一样。

  1. 计算机——由于二进制表示——只有近似的浮点数:

    print( (1/3) == 0.3333333333333333 ) # = True
    print(1.0e+309)                      # = 'inf'
    print(1.4/10)                        # = 0.13999999999999999
    
    
  2. 超过有效数字的限制(Python 中的 16 位(53 位)),计算不可信:

    print('2.0**53-1 =', 2.0**53-1) # = 2.0**53-1 = 9007199254740991.0
    print('2.0**53-0 =', 2.0**53-0) # = 2.0**53-0 = 9007199254740992.0 (limit)
    print('2.0**53+1 =', 2.0**53+1) # = 2.0**53+1 = 9007199254740992.0
    
    
  3. 舍入误差累积:

    print(0.1 + 0.1 + 0.1 == 0.3) # = False
    print(0.1 + 0.1 + 0.1)        # = 0.30000000000000004
    
    
  4. 翻车。在很多语言中,最大的整数加 1,就变成了绝对值几乎相同的负数。Python 整数不会出现这种情况。但是这种便利性和其他便利性(列表中的混合数据类型)使得 Python 比其他语言慢。

Python 确实有大浮点数的十进制格式。可惜慢了。

def fibGG(num): # 1153 seconds = 19 minutes and 13 seconds.
    from decimal import Decimal, getcontext
    from math import sqrt
    if num > 70:
        getcontext().prec = 2*num
    phi1 = (Decimal(1) + Decimal(5).sqrt())/Decimal(2)
    phi2 = (Decimal(1) - Decimal(5).sqrt())/Decimal(2)
    return round((phi1**Decimal(num) - phi2**Decimal(num)) /
           Decimal(5).sqrt())

在这一点上,大多数人可能会选择fibA而不是其他函数,因为它容易理解并且比我们见过的其他函数更快。函数fibA可以在 16 分钟内找到第 1000 万个斐波那契数。

也许一个更好的解决方案是把第一个亿万斐波那契数列保存在磁盘上,然后读出我们想要的那个。下面的代码将在 933 秒(= 15 分 33 秒)内创建一个保存第一个 max = 78125 斐波那契数列的文件。

#---Create file containing the first max Fibonacci numbers.
    from time import clock
    max = 78125
    print('max =', max)
    print('start')
    start = clock()
    file1 = open('g:\\junk.txt', 'w')
    file1.write('1\n')
    a = b = 1
    for i in range(1, max):
        file1.write(str(a)+'\n')
        a, b = b, a+b
    file1.close()
    stop = clock()
    print('stop')
    print('time =', round(stop-start, 2), 'seconds.')

数字范围扩大一倍似乎比时间增加四倍多一点。这可能是因为斐波那契数列的大小在增长。如果将范围扩大一倍将时间乘以 4(可能是一个低估值),那么我们通过对 10,000,000/(27) = 78125 个数字计时并将时间乘以 47 = 16384 来粗略估计创建前一千万个斐波那契数列文件所需的时间。因此,为前 1000 万个斐波那契数列创建一个文件的时间估计至少需要 933*16384 秒= 15286272 秒,差不多是 177 天。注意:你不应该被动地阅读技术资料。你需要检查这些计算背后的逻辑和数学。 4

一旦建立了文件,提取小数字很快,但是提取大数字需要时间。

#---Extract a number from of a file of numbers.
    file1 = open('g:\\junk.txt', 'r')
    print('start')
    start = clock()
    for n in range(78124):
        file1.readline()
    num = (file1.readline())
    file1.close()
    stop = clock()
    print('stop')
    print('time =', round(stop-start, 2), 'seconds.') # 8.94 seconds.

现在来了一个惊喜。上面的代码花了 9 秒来提取第 78124 个斐波那契数。函数fibA将在 0.3 秒内生成第 78124 个斐波那契数。查找表的想法(这里存储在磁盘上)是一个有用的想法。我们已经看到它在fibBBfibB大大提高了速度。但是,访问大型 Python 文件可能比直接计算要慢。

因此,也许你已经学会了一些编程技巧(记忆化、类变量、嵌入式函数、默认值、为了大数的准确性而选择整数而不是浮点数,以及查找表的值)。记住窍门有助于你成为一名成熟的程序员。忘记技巧几乎就像从来没学过一样。那么我们如何记住它们呢?我们使用最近学到的知识编写代码。

这些例子虽然简单,却给了我们一个自然的机会来看看大多数初学者的编程风格。我们将在第四章回到编程技巧。

Footnotes 1

我认为更好的发音是 FEE buh naht chee,但是这已经被英语化成一个可接受的 FIB uh naht chee。我的来源是有用的韦氏新传记词典(韦氏词典)。

  2

见罗斯·洪斯伯格《数学瑰宝 II》(MAA,1985),页 108。

  3

def fibG(num): # Faster version

from math import sqrt

sqrt5 = sqrt(5) # Do not compute this number more than once.

phi   = (1 + sqrt5)/2

return round((phi**num)/sqrt5)

  4

如果我们将数字 78125 加倍七次,那么我们得到 10,000,000。因此,如果生成前 78125 个斐波那契数需要 t 秒,那么生成前 10,000,000 个斐波那契数需要(4**7) t 秒= 16384 t 秒。

三、风格

  • 硬科学的人很少知道如何写,他们中的大多数也不知道如何编程;他们学习如何编写算法,而不是如何编写可维护的计算机程序。——艾伦·I·霍勒布,《足够打自己脚的绳子》(麦格劳-希尔,1995),页 18。* * *
  • 有人告诉我,可读性并不是一切。呼吸也不是,但它确实在接下来发生的事情之前。——威廉·斯隆,《写作的技巧》(诺顿出版社,1979),第 11 页。

风格是预见到其他人在理解、调试、修改和在他们的程序中使用你的代码时会遇到的困难,然后在你的构建中解决这些困难。这是礼貌的一种形式。我把前一章给了我的几个编程班的所有学生,我们详细地讨论了它。然后我收集了讲义,给了下面的作业。

作业:写出以下七个斐波那契函数:

  1. fibA简单迭代。
  2. fibB简单递归。
  3. 带有装饰器的简单递归。
  4. 递归和记忆,将字典作为参数传递。
  5. fibD递归和记忆化,以字典作为类变量。
  6. 带有嵌入函数的递归和记忆。
  7. 使用默认字典参数的递归和记忆。
  8. 你必须在网上找到的公式。

任何计算机科学课程的能力范围都是巨大的。有些学生在 30-45 分钟内完成了这项作业。其他人又花了 30 分钟,需要同学们的帮助。有些人无法完成作业,不得不在家完成。

我开始查看作为我的函数的副本提供的工作函数。我会大吃一惊的。编程语言中特殊用途的句法结构被称为“习惯用法”。在 Python 中,将一个值赋给两个变量的首选方式(习惯用法)是这样的:a = b = 1。两个变量互换的首选方式是这样的:a, b = b, a

这是 15 分钟前我给我的学生展示的斐波那契函数。它使用了上面描述的两个成语。

def fibA(num):
    if num < 3: return 1
    a = b = 1
    for i in range(2, num):
        a, b = b, a+b
    return b

我的一个学生使用 Java/C/C++习惯用法编写了迭代斐波那契函数。

def fibA(n):
    if n <= 2: return n
    a = 1
    b = 1
    tmp = 0
    for i in range(n-2):
        tmp = b
        b  += a
        a   = tmp
    return b

有一种不去学习一个新成语的自然倾向。如果一个老方法有效,那么为什么不继续使用它呢?这些 Python 习惯用法是如此简单、常见和有用,并且已经在我自己的公共代码中演示了几个月,以至于我很惊讶这个学生没有采用它们。

当我看他的第四个函数时,我不容易理解他的代码,直到我缩进它。这名学生试图重现这段代码:

def fibC(num, Dict):
if num in Dict:
   return Dict[num]
Dict[num] = fibC(num-1, Dict) + fibC(num-2, Dict)
return Dict[num]

以下是他想出的方法,它确实有效:

def fibC(n, d:dict):
     if n <= 2: return 1
     if n-1 in d: a=d[n-1]
     else: a = fibC(n-1,d)
     if n-2 in d: b = d[n-2]
     else: b = fibC(n-2,d)
     d[n] = a+b
     return a+b

下面是缩进的同一个函数:

def fibC(n, d:dict):
     if n <= 2:
        return 1

     if n-1 in d:
        a=d[n-1]
     else:
        a = fibC(n-1,d)

     if n-2 in d:
        b = d[n-2]
     else:
        b = fibC(n-2,d)

     d[n] = a+b
     return a+b

这个特别的学生是我的一个聪明的学生,并且经常是第一个完成测验的学生之一。然而,这个学生和他的一些同学并没有试图采用一种可读的风格。让代码工作是他们唯一的目标。这至少是他们第三次编程课程的年底。

由于缺乏编程经验,学生不理解风格。他们不写冗长复杂的程序。他们不修改和调试他人编写的遗留代码。因此,他们编写程序的自然风格永远不会超出编写短程序的范围。老师坚持一种风格,这种风格有利于长节目被其他人阅读,这种风格适用于只被学生或者老师阅读的短节目。试图用这种方式教学很容易让认为老师过于迂腐的学生与老师发生冲突。

几年前,我让我的学生用可读性最好的代码来解决一个问题,这些代码会让他们在面试中自豪地展示出来。令我惊讶的是,即使是很强的学生也写出了丑陋且注释过多的代码。他们根本不知道什么是可读代码。之后,我开始展示易读代码和难读代码的例子。

现在,每年大约有五六次,我坚持让我的学生用易读的风格写短程序。当他们打印出他们的小程序并交给我时,我会指出我看到的第一个风格错误,并让他们重新打印。一些学生需要打印六次或更多次他们的程序。我总是看到一些学生在互相讨论,试图预测我的下一个批评会是什么,这样他们就不用再打印他们的程序了。至少他们获得了一些编写可读代码的经验。但是除非他们知道我会检查他们的代码,否则大多数学生不会花时间去写可读的代码。

让学生养成重构的习惯(清理他们的代码,使其更具可读性)的问题与让学生写出合格的论文是一样的。除非老师要求,否则对语法、标点、修辞(句子的有效使用)、措辞(单词的选择)、内容甚至校对的关注大多会被忽略。质量要求高,需要仔细检查每个学生的作业。我问英语系一位德高望重的成员,她是否反复阅读并退回同一篇论文,直到可以接受为止。她说,作为一名初任教师,她这样做了,但停止了这种做法,因为它占用了太多的个人时间。大多数老师在大多数科目上都是如此。我们没有时间定期检查学生的作业以确保质量。最终,每个学生都必须成为自己的老师。

  • 科学家在哪里学习如何开发软件和在研究中使用计算机?几乎所有人(2008 年一项网络调查的近 2000 名学术受访者)都表示,非正式的自学最为重要。同伴指导排在第二位,学校或工作中的正式指导远远落后。——格雷格·威尔逊,美国科学家,第 97 卷(2009 年 9-10 月),第 361-362 页。

下面是我用来稍微自动化重构过程的检查表:

Is Your Program Finished Before the Deadline? If Yes,

  1. 你使用了逐步细化吗?【如果没有,那就回去修。]
  2. 你完成的时候重构了吗?【如果没有,那就回去修。]
  3. 你写了自我记录的代码吗?【如果没有,那就回去修。]
  4. 你把功能限制在单一任务上了吗?【如果没有,那就回去修。]
  5. 你使用了你的语言中的习语吗?【如果没有,那就回去修。]
  6. 你使用断言和其他错误陷阱了吗?【如果没有,那就回去修。]
  7. 你是否在有用的地方使用了垂直对齐?【如果没有,那就回去修。]
  8. 你创造了有标签的有吸引力的输出吗?【如果没有,那就回去修。]
  9. 你打印了你的程序运行的时间了吗?【如果没有,那就回去修。]
  10. 你测试好最终产品了吗,尤其是特殊情况和临界情况?【如果没有,那就回去修。]
  11. 在你写完之后,你是否立即测试了每个主要的功能?【如果没有,就不要再这样了。采用专业人士的习惯。]
  12. 你有没有避免写聪明的代码,做不必要的优化,为不重要的情况编程?【如果没有,就不要再这样了。采用专业人士的习惯。]

现在回到更多的编程技巧。

Footnotes 1

最佳定义:礼貌是让你周围的人感到舒适。这又是一个相同的想法:“优秀的作家对他们的读者产生了持久的共鸣,而糟糕的作家则没有。”—布莱恩·加纳,《简明英语法律写作》(芝加哥大学,2001 年),第 145 页。

四、更多编程技巧

  • 人们常说,一个人只有在把某样东西教给别人的时候,他才真正理解了它。实际上,一个人并没有真正理解某件事,直到他能把它教给计算机,也就是说,把它表达成一种算法。将事物形式化为算法的尝试比我们简单地试图以传统方式理解事物会导致更深刻的理解。—Donald E. Knuth (1974 年图灵奖 1 获得者)“计算机科学及其与数学的关系”,《美国数学月刊》,第 81 卷,1974 年 4 月,第 327 页。

下面,递归函数(fibH)是对fibB的改进。

def fibH(num, a = 0, b = 1): # 31.91 seconds.
    if num == 1:
        return b
    return fibH(num - 1, b, a+b)

我们可以如下所示在一行中写fibH(没有速度增加)。

def fibHH(n, a = 0, b = 1): # 31.91 seconds.
    return fibHH(n-1, b, a+b) if n > 1 else b

由于 Python 允许动态编写匿名函数,所以我们可以使用 lambda,但结果较慢。

f = lambda n, a=1, b=1: int(n<3) or a+f(n-1,b,a+b) # 56.08 seconds.

问题总是这样:三个版本中哪一个最容易调试。

题外话。任何递归函数都可以迭代编写。事实上。递归本身不是递归的。递归被实现为一个带有参数、局部变量和返回到调用例程的地址的调用栈。调用堆栈上每一项的所有这些信息都是一个堆栈帧。

注意,在fibH中,递归调用是独立的,不像return fib(x-1) + x中,在递归调用之后附加一个加法。这种独立的或者使递归成为返回之前的最后一个动作(例如return x + fib(x-1))被称为“尾部递归”其优势在于,智能编译器(即优化的编译器)将识别尾部递归,并将其更改为goto,从而降低递归的巨大堆栈内存需求。奇怪的是,Python 编译器没有针对尾部递归进行优化。

即使没有编译器的优化,尾部递归也可以通过消除递归调用极大地提高函数的速度,就像这里的fibH一样。

考虑阶乘函数,而不是斐波那契函数。这里没有以前解决的案件的总和来得出一个最终数字。下面我们比较阶乘函数的五种不同形式。我们看到尾部递归不比非尾部递归快,因为尾部递归不能消除阶乘函数中的递归调用。即使是查找表也无济于事。

def factorial1(n):        # Tail recursion 1 = 12.25 seconds
    if n == 1: return 1
    return n*factorial1(n-1)

def factorial2(n, x = 1): # Tail recursion 2 = 13.72 seconds
    if n == 1: return x
    return factorial2(n-1, n*x)

def factorial3(n):        # non-Tail recursion = 11.88 seconds
    if n == 1: return 1
    return factorial3(n-1)*n

def factorial4(n):        # Iteration = 5.51 seconds
    t = 1
    for n in range(1,n+1):
        t = t*n
    return t

def factorial5(n):        # Tail recursion with look-up table = 12.36 seconds
    if n <=11:
        return [0,1,2,3,24, 120, 720, 5040, 40320,362880, 3628800, 39916800][n]
    return n*factorial5(n-1)

与此讨论无关的是下面的好奇心:下面的两个单行函数都将使用 Python“and/or”技巧来计算 n 阶乘,这种技巧可能永远都不应该使用。(为了让这个技巧起作用,中间的表达式必须总是计算为True。)

def factorialA(n):
    return (n>1) and (n*factorialA(n-1)) or 1
#----------------------------------------------
def factorialB(n, x = 1):
    return (n>1) and factorialB(n-1, n*x) or x

题外话结束。

我们能不能做得更好,或者更快,或者至少用不同的方法来建立一个斐波纳契函数?在互联网上搜索,我发现了奇怪的“斐波那契矩阵”(又名 Q 矩阵),其中

{\mathbf{A}}^n={\left(\begin{array}{cc}1& 1\\ {}1& 0\end{array}\right)}^n=\left(\begin{array}{cc}\mathrm{fib}\left(n+1\right)& \mathrm{fib}(n)\\ {}\mathrm{fib}(n)& \mathrm{fib}\left(n-1\right)\end{array}\right)

或者在代码中:

A**n  =  [ [1,1], [1,0] ]**n  =  [ [fib(n+1),fib(n)], [fib(n),fib(n-1)] ],

在这一点上,你应该做我第一次遇到这个方程时做的事情。手算几个例子,说服自己矩阵方程是真的。例如:

{\displaystyle \begin{array}{l}{\left(\begin{array}{cc}1& 1\\ {}1& 0\end{array}\right)}¹=\left(\begin{array}{cc}1& 1\\ {}\underset{\_}{\mathbf{1}}& 0\end{array}\right),{\left(\begin{array}{cc}1& 1\\ {}1& 0\end{array}\right)}²=\left(\begin{array}{cc}2& 1\\ {}\underset{\_}{\mathbf{1}}& 1\end{array}\right),{\left(\begin{array}{cc}1& 1\\ {}1& 0\end{array}\right)}³=\left(\begin{array}{cc}3& 2\\ {}\underset{\_}{\mathbf{2}}& 1\end{array}\right),{\left(\begin{array}{cc}1& 1\\ {}1& 0\end{array}\right)}⁴=\left(\begin{array}{cc}5& 3\\ {}\underset{\_}{\mathbf{3}}& 2\end{array}\right),\dots, \\ {}{\left(\begin{array}{cc}1& 1\\ {}1& 0\end{array}\right)}^{12}=\left(\begin{array}{cc}233& 144\\ {}\underset{\_}{144}& 89\end{array}\right),\dots, {\left(\begin{array}{cc}1& 1\\ {}1& 0\end{array}\right)}^{23}=\left(\begin{array}{cc}46368& 28657\\ {}\underset{\_}{\mathbf{2}\mathbf{8657}}& 17711\end{array}\right)\end{array}}

这不是一个证明,但会给你一个感觉,为什么斐波纳契数会从这些矩阵中冒出来。

矩阵乘法似乎是一种很慢的生成斐波那契数的方法,如果我们需要将A乘以 22 次才能生成第 23 个斐波那契数(28657),那就是这样。[注:基数为 2 时,23 = 10111。]但假设在生成第二个斐波那契数A**2 = [[2,1],[1,1]]后,我们将A**2乘以自身得到A**4 = [[5,3],[3,2]]。然后我们将A**4乘以自身,生成A**8 = [[34,21],[21,13]]。然后我们将A**8乘以自身,生成A**16 =[[1597,987],[987,610]]。事实证明

X = A**23 = (A**16) * (A**4) * (A**2) * (A**1) = [[46368,28657],[28657, 17711]].

我们的答案是X[0][1] = 28657 =第 23 个斐波那契数,只有 7 次(= 4 + 3)矩阵乘法而不是 22 次矩阵乘法。你可能仍然不会对这个计划的速度印象深刻。但是想象一下试图计算第一千万个斐波那契数。

我们只需要计算 23 (= 24-1)个斐波那契数(只需要 30 = 23+7 的矩阵乘法),而不是计算一千万个斐波那契数(几乎需要 10,000,000 次加法)。24 是从哪里来的?以 2 为基数表示的数字 10,000,000 有 24 位。

print(bin(10000000)) # = 100,110,001,001,011,010,000,000 in base 2.

此外,24 是一千万分之二的对数基数,使用基数变化公式向上取整。现在算这个数来检查我的工作。如果你记不住对数的底基变化公式,就去查一查。还是做不到?那就找个能教你怎么做的人。对数很有用。你需要一些技巧。计算在脚注里。 2

我们首先需要一个将两个 2x2 矩阵相乘的效用函数mul(A,B)。然后为了找到fib(23),我们设置A =[1,1,1,0](不是[[1,1],[1,0]],因为我们希望减少方括号的使用)。接下来,使用mul()我们生产A**1, A**2, A**4, A**8,A**16。我们让X = A**23 = A**16 * A**4 * A**2 * A**1。我们如何做到这一点?换句话说,我们是如何决定在X的计算中忽略A**8的?我们如何找到一个等于任意正整数的 2 的幂和?我们能肯定我们总能找到这样一笔钱吗?

在介绍二进制系统之前,我喜欢问学生一个问题:一个古怪的富人喜欢购物,但他总是想支付准确的金额(最多 100 美元,包括支票和 99 便士),所以他没有得到零钱。问题:在他去购物之前,他最少需要开多少张支票?答案在脚注里。 3

因为二进制中的 23 是 10111,所以反转数字并将其类型改为字符串,我们得到“11101”。如果我们让X =这些(A**p)表达式的乘积,对于所有位置p(初始位置是 1,不是 0),其中在反转的字符串中有一个 1,那么第 23 个斐波那契数就是答案。这种方案总是可行的,部分原因是任何正整数都可以表示为二进制数。

  • 我深感遗憾的是,我没有走得足够远,至少没有理解一些伟大的数学原理,因为被赋予这种天赋的人似乎有一种额外的感觉。—查尔斯·达尔文,自传(剑桥回忆,1828-1831)。
def fibIII(n): # 1.61 seconds. (Remember, fibA took 7.45 seconds.)
    def mul(A, B): # multiply two 2x2 matrices
        a, b, c, d = A
        e, f, g, h = B
        return a*e+b*g, a*f+b*h, c*e+d*g, c*f+d*h
    A = [1,1,1,0]         # = Fibonacci matrix. We will generate A, A**2, A**4, A**8, A**16,
                          #   etc., some of which can be combined to produce matrix X.
    X = [1,0,0,1]         # = identity  matrix, which will later contains the answer:
    s = str(bin(n))[2:]   #   x[1] = fibIII(n). The str(bin(n))[2:] will change fibIII
    s = s[::-1]           #   number to a binary string--e.g., n = 12 --> '1100'.
    for n in range(len(s)): # The s[::-1]will reverse digits in a binary string.
        if s[n] == '1':
            X = mul(X, A) # Matrix X accumulates some of the powers of matrix A--
        A = mul(A, A)     # e.g., X = A**12 = A**4 + A**8.
    return X[1]

这是一个令人印象深刻的时间减少。它不使用递归,只是通过使每一步都是前一步的两倍来大步迈向目标数。但是为什么不重写fibIII,去掉对内嵌函数的调用呢?当然,这将使功能更快。

def fibII(n): # 2.10 seconds
    A = [1,1,1,0]         # = Fibonacci matrix.

    X = [1,0,0,1]         # = identity  matrix.
    s = str(bin(n))[2:]   # Change fibII number to a binary string--e.g., n = 12 --> 1100.
    s = s[::-1]           # Reverse digits in binary string--e.g., 1100 --> 0011.
    for n in range(len(s)):
        if s[n] == '1':
           X = X[0]*A[0] + X[1]*A[2], X[0]*A[1] + X[1]*A[3], X[2]*A[0] + X[3]*A[2], X[2]*A[1] + X[3]*A[3]
        A = A[0]*A[0] + A[1]*A[2], A[0]*A[1] + A[1]*A[3], A[2]*A[0] + A[3]*A[2], A[2]*A[1] + A[3]*A[3]
    return X[1]

让我惊讶的是,fibII功能比fibIII慢一点。你能通过检查确定原因吗?这个秘密将在下一个函数中解释。

def fibI(n): # 1.37 seconds.
    a,b,c,d = 1,1,1,0   # = Fibonacci matrix.
    e,f,g,h = 1,0,0,1   # = identity  matrix.
    s = str(bin(n))[2:] # = base 2 representation of n--e.g., if n = 12, then s= "1100".
    r = s[::-1]          # = reversed version of s--e.g.,  if s = "1100", then r= "0011".
    for n in range(len(r)):
        if r[n] == '1':
           e,f,g,h = a*e+b*g, a*f+b*h, c*e+d*g, c*f+d*h       # = X*Y (2x2 matrix mult).
        a,b,c,d = a*a + b*c, a*b + b*d, c*a + d*c, c*b + d*d  # = Y*Y (2x2 matrix mult).
    return f

函数fibIfibII完全相同,除了需要更少的列表索引(带方括号)。回想一下,原语标识符(如ax)只是一个内存地址。但是列表中的每个元素(不是内存中连续位置的数组)既是一个值,也是下一个元素的地址。因此为了找到x[3],计算机转到地址x。然后它读取并移动到下一个地址:x[1]。然后它读取并移动到下一个地址:x[2]。最后,它读取并移动到下一个地址:x[3]fibII中的代码需要 12 次这样的读取和移动操作。只需查找一次,然后将值分配给非下标标识符,比继续查找链式地址更有效。反正速度提升很小。也许fibIII是首选,因为它更容易理解。

下面的公式可以从斐波那契矩阵中导出。你能推导出它们吗?

fib(2*k)   = fib(k)*(2*fib(k+1)-fib(k)) [= fib(k)*(fib(k+1)+fib(k-1))],

fib(2*k+1) = fib(k+1)**2 + fib(k)**2.

最初,我无法推导出这些公式,但我还是使用了它们。然后让我恼火的是——真的让我恼火——我无法推导出这些线性代数公式。我是什么样的微积分老师?所以我回去摆弄了一下A**n * A**n = A**(2n)。20 分钟后,答案出来了。(实际上我是从答案开始逆向寻找推导过程的。)

许多数学家使用他们自己无法证明的经典定理。使用经过专家验证的数学是没有问题的,即使我们无法遵循他们的证明。但是,您需要了解约束/限制/界限/附加条款/规定/界限/特殊情况等。

计算机科学家和物理学家经常做所谓的非严格数学——即基于类比和明显模式的数学思维,这种推理对数学家来说是不可接受的。这在计算机科学中是可行的,因为计算机科学家然后基于数学编写一个可行的程序,从而证实(在某种程度上被一些人接受)数学。以类似的方式,物理学家建造起工作的东西,从而证实(在某种程度上被一些人接受)数学。当然,严格证明数学会更好,但这通常需要研究人员不具备的符号操作技能。发展这些技能(如果可能的话)会占用研究时间。大多数现代研究都是在团队中完成的,部分原因是雄心勃勃的项目对一个人来说花费了太多的时间,但也是因为很少有人拥有一个大项目所需的所有技能。顺便问一下,“证明”的定义是什么? 4

注意下面 1)在fibJJ中不需要elseelif。有些人喜欢把它们放进去,2)我们更喜欢用fibJ(k)**2而不是fibJ(k)*fibJ(k)来减少一半的递归调用。

def fibJJ(n): # 3158.00 seconds
    if n < 3:
        return 1

    if (n%2) == 0:
        k = n//2
        return fibJJ(k)*(2*fibJJ(k+1)-fibJJ(k))

    k = (n-1)//2
    return fibJJ(k+1)*fibJJ(k+1) + fibJJ(k)*fibJJ(k)

我对fibJJ代码的执行速度之慢感到惊讶,但我一直专注于让函数返回正确的值。几天后,我重新审视它,立刻意识到我写这段代码的效率是多么低。我重写了代码,把时间从 3158 秒减少到 38 秒。然后,我用一个 17 值的基本情况查找表替换了 2 值的基本情况,并将时间减少到 5 秒(fibJ)。不要忘记考虑查找表的力量。

def fibJ(n): # 5.00 seconds
    if n < 18:
        return [0,1,1,2,3,5,8,13,21,34,55,89,
                144,233,377,610,987,1597,][n]
    if (n%2) == 0:
        k = n//2
        f = fibJ(k)
        g = fibJ(k+1)
        return f*(2*g-f) # = fibJ(k)*(2*fibJ(k+1)-fibJ(k))
    k = (n-1)//2
    f = fibJ(k)
    g = fibJ(k+1)
    return g*g + f*f # = fibJ(k+1)*fibJ(k+1) + fibJ(k)*fibJ(k)

现在也许你明白为什么我选择不使用公式fib(2*k) = fib(k)*(fib(k+1)+fib(k-1))]。该公式要求代码进行三次递归调用,而不是两次。

fibJ()函数仍然重新计算一些相同的斐波那契数。因此,我们引入记忆来避免重新计算相同的数字。但是代码现在变得更加复杂。我们曾经想要这样写代码吗?只有在一定要有速度的时候,这个功能确实很快。

def fibK(n, dict = {}): # 1.19 seconds
    if n < 18:
        return [0,1,1,2,3,5,8,13,21,34,55,89,
                144,233,377,610,987,1597,][n]

    if (n%2) == 0:
        k = n//2
        if k not in dict:
             dict[k] = fibK(k, dict)
        A = dict[k]
        if (k+1) not in dict:
              dict[k+1] = fibK(k+1, dict)
        B = dict[k+1]
        return 2*A*B-A*A
    else:
        k = (n-1)//2
        if (k+1) not in dict:
                dict[k+1] = fibK(k+1, dict)
        A = dict[k+1]
        if k not in dict:
             dict[k] = fibK(k, dict)
        B = dict[k]
        return A*A + B*B

题外话:请注意,在 Python 中,默认参数通常不应该像我上面所做的那样设置为空集(或空列表):dict = {}。即使代码运行良好,第二次运行fibK,程序没有结束也不会重置dict = {}。因此,在第二次调用时不需要重新构建字典,这将使函数看起来比重复测试时更快。我已经不止一次被 Python 的这种特性所困扰。看看这段代码:

def doIt(dict ={}):
    print(dict)
    dict['A'] = 1

def main():
    doIt() # output: {}
    doIt() # output: {'A': 1}

这里有两种方法可以解决这个问题。

def doIt(Lst = None):
    if Lst == None: Lst = []
    Lst.append('x')
    return Lst

def main():
    print(doIt()) # output: main ['x']
    print(doIt()) # output: main ['x']

def doIt(Lst = None):
    Lst = Lst or []
    return Lst

def main():
    print(doIt()) # output: main ['x']
    print(doIt()) # output: main ['x']

回想一下,Python orand都返回最后检查的值。因此,如果Lst = None (=假),则计算机被迫检查[]并返回[]。为了保持简单,我保留了最初编写时的样子。题外话结束。

函数fibK是这个列表中最复杂的函数之一。我们能清理一下吗?是的,通过返回两个值。不幸的是,这使得该函数更难使用。有两次,我把答案当作第二个值,而不是第一个值。函数fibL看起来比fibK更简单,也更快。附加 17 值查找表仅增加了大约 25%的速度。也许我应该尝试 100 值查找表。

def fibL(n): # 0.63 seconds [0.46 seconds with the look-up table.]
    if n == 0:
        return (0, 1)
##    if n < 18: # Optional base case look-up table.
##        return [(0,1),(1,1),(1,2),(2,3),(3,5),(5,8),(8,13),(13,21),(21,34),(34,55),
##                (55,89),(89,144),(144,233),(233,377),(377,610),(610,987),(987,1597),
##                (1597,2584),][n]
    else:
        a, b = fibL(n // 2)    # a = fibL(2*k); b = fibL(2*k+1).
        c = a*(2*b - a)        # fibL(2*k  ) = fibL(k)*(2*fibL(k+1) - fibL(k))
        d = a*a + b*b          # fibL(2*k+1) = fibL(k+1)**2 + fibL(k)**2
        if (n%2) == 0:
            return (c, d)      # return fibL(k), fibL(k+1)
        else:
            return (d, c + d)  # return fibL(k), fibL(k+1)

我们还没有讨论内存使用。所以我们要求每个函数计算第一千万个斐波那契数,这个数以 380546875 结尾,有 2089877 个数字。我们会大吃一惊的。功能fibK现在比fibL稍快。

  1. fibA = 949.76 秒(差不多 16 分钟)。
  2. 不可能的
  3. fibC =超过了最大递归深度。
  4. fibD =超过了最大递归深度。
  5. fibE =超过了最大递归深度。
  6. fibF =超过了最大递归深度。
  7. fibG =溢出,结果太大。
  8. fibH =超过了最大递归深度。
  9. fibI = 24.09 秒
  10. fibJ = 3.23 秒
  11. fibK = 2.32 秒
  12. fibL = 2.55 秒

总的来说,哪个功能最好?

  • 很容易理解,但是对于大数字来说太慢了。
  • fibI比其他人慢,但更容易理解。
  • fibJfibI快 5 倍,但是使用了一些程序员无法推导的公式。
  • fibK最快,但很复杂。
  • fibLfibK短,几乎和fibK一样快,但是返回了两个值,这两个值在测试代码的时候让我犯了两次错误。

像生活中的许多问题一样,哪个是最好的问题被证明是毫无意义的,因为我们没有一个单一的“最好”标准。

回想一下,算法及其作为函数的实例化,传统上通过三个标准来评估:

  1. 速度(“更好”是“足够好”的敌人你可能不需要超快的速度。)令人困惑的是,在一组数据中次好的函数有时在另一组数据中是最好的。
  2. 可读性(易于调试、修改和理解)。当然,有些函数无论怎么写都很难理解。
  3. 内存(内存猪不切实际)。

几年前,作为一名学生,我写了快速排序。我的代码对几乎所有的数字进行了排序,但是有几个没有排序。我用了一个“

我的观点是:评估一个算法不仅仅是前面提到的三个标准。算法的易理解性、翻译成计算机代码的难度以及在其他程序中使用代码的难度也是算法的重要属性。

我对技术的定义:硬件、软件和算法。

Footnotes 1

以防读者不知情,计算机科学领域的最高奖项是图灵奖。该奖项每年由 ACM(计算机协会)颁发,旨在表彰对计算机领域做出的持久而重要的技术贡献。图灵奖以早期计算机先驱艾伦·图灵(1912-1954)的名字命名。今天,图灵被认为是计算机科学和人工智能之父。1945 年,图灵因二战期间破译密码的努力被授予大英帝国勋章。1952 年,艾伦·图灵因与一名 19 岁的男性发生同性恋关系而被捕。为了不进监狱,他接受了一种荷尔蒙“治疗”,这种治疗对他的身体和精神都有不良影响。两年后,41 岁的他被发现死于氰化物中毒。验尸结果表明是自杀,但他的母亲和他的许多密友认为这是一场意外。参见维基百科。1966 年,图灵奖成立。1999 年,《时代》杂志宣布艾伦·图灵为 20 世纪 100 位最重要的人物之一。2013 年 12 月 24 日,图灵被英国女王追授特赦(这是二战以来第四次)。2014 年,好莱坞电影《模仿游戏》上映。它记录了图灵作为密码破译者的一生,以及他在生命末期遇到的困难。

  2

答案:\mathrm{ceil}\left({\log}_2\left(10,000,000\right)\right)=\mathrm{ceil}\left(\frac{\log_{10}\left(10,000,000\right)}{\log_{10}(2)}\right)=24\.

  3

答:7 张支票。这个古怪的购物者一定有一张 1 美元的支票。然后,如果下一张支票是 2 美元,他可以买任何高达 3 美元的东西。所以他的第三张支票应该是 4 美元。然后他可以买任何高达 7 美元的东西。所以他的第四张支票应该是 8 美元。你可以看到这个模式:1 美元,2 美元,4 美元,8 美元,16 美元,32 美元,64 美元。根据这个论点,任何正整数都可以表示为 2 的不同幂的和。所以 23 表示为和 1 + 2 + 4 + 16。

  4

我的定义:证据是令人信服的论点。因此,证明可能是错误的。数学史上有几个这方面的著名案例。想到了 Kempe 发表的证明和 Tait 发表的四色定理证明。11 年来,每个人都没有受到质疑。此外,对一代人来说被接受为证据的东西有时对下一代人来说并不充分。"一天的严格已经足够了."——e·h·摩尔(1903)。

五、函数设计

我认为有效地使用函数是很棘手的,我将用下面的例子来说服你。

大多数情况下,您希望创建只执行一项任务的函数。大多数时候没有多函数。我曾经写过一个图形程序,读入一个图像文件,然后用彩色打印(一个函数)或者用灰度打印(另一个函数)。两个函数中的大部分代码是相同的,或者几乎相同。使用一个布尔参数(colorFlag),灰色和彩色函数可以合并成一个函数。因此,我用四行额外代码的代价少了一个函数。见下文。

AN EXAMPLE OF MULTI-PURPOSE

CODE

WIDTH = 512
HEIGHT = 512
class ImageFrame:
    def __init__(self, colors, wd = WIDTH, ht = HEIGHT, colorFlag= False):
        self.img = PhotoImage(width = wd, height = ht)
        for row in range(ht):
            for col in range(wd):
                num = colors[row*wd + col]
                if colorFlag == True:

                   kolor ='#%02x%02x%02x' % (num[0], num[1], num[2]) # = color
                else:

                   kolor ='#%02x%02x%02x' % (num, num, num)# = gray-scale

                self.img.put(kolor, (col,row))
        c = Canvas(root, width = wd, height = ht); c.pack()
        c.create_image(0,0, image = self.img, anchor = NW)
        printElapsedTime ('displayed image')

当我一年后回顾我的工作时,我必须阅读代码——不仅仅是函数的名字——才能理解colorFlag做了什么。如果代码被保存为两个具有描述性名称ImageFrameForColorListImageFrameForGrayScaleList的函数,那么就没有colorFlag可以理解。两个函数中的公共代码可以提取到第三个函数中,该函数可以由灰度函数和颜色函数调用。第三个函数的理由是公共代码中的任何更改只需要做一次(干:不要重复)。重复代码的危险在于,你可能在一个地方修改了代码,却没有意识到在另一个地方也需要修改。

这个例子很好地说明了内聚和耦合。将解决这两个相关任务的所有代码放在一个函数中增加了内聚性(通常是好的)。将它分散到两个或三个函数中会增加函数之间的耦合(通常是不好的)。那么哪种方案更好呢——单一函数、两种函数还是三种函数?我的感觉是,由于代码的简单性(至少对我来说),将所有内容放在一个函数中会使代码更容易理解和调试。通常,当我们遵循一个指导方针(最大化内聚性,从而最小化耦合性)时,我们会违反另一个原则(将函数限制到单一任务)。无论你做什么决定,都要意识到其中的问题。编程专家沃德·坎宁安完美地陈述了这一点:“如果你不仔细思考,你可能会认为编程只是用编程语言键入语句。” 1

一个函数应该有多长?程序员 Brian Kernigham 和 P.J. Plauger 曾经提到,他们的函数的中值大小是 15 行,平均值是 19 行。一个函数包含的行数似乎很少会超出屏幕的显示范围。我的文本编辑屏幕有 38 行,字体大小我喜欢。但是,当然,我们从不追求小;我们追求可读性。下面是我的 34 行代码,用来判断 n×n 数独板是否是一个解决方案。

def solutionIsCorrect(matrix):
#---Build lists of rows and columns.
    rows = [[]] * MAX
    cols = [[]] * MAX
    for r in range(MAX):
        for c in range(MAX):
            rows[r].append(matrix[r][c].value)
            cols[c].append(matrix[r][c].value)

#---Build list of blocks.
    block  = []
    for n in range(MAX):
        block.append([])
    for n in range(MAX):
        for r in range(blockHeight):
            for c in range(blockWidth):
                  row = (n//blockWidth)*blockHeight+r
                  col = (n%blockHeight*blockWidth) +c
                  block[n].append(matrix[row][col].value)

#---Check all rows for all n digits.
    for r in rows:
        for n in range(1, MAX+1):
            if {n,} not in r:  #  <--The type must be set({n}), not int (n).
                return False

#---Check all columns for all n digits.
    for c in cols:
        for n in range(1, MAX+1):
            if {n,} not in c:
                return False

#---Check all blocks for all n digits

.
    for b in block:
        for n in range(1, MAX+1):
            if {n,} not in b:
                return False
    return True # True means NO errors in the matrix

.

为什么不把小零件推到自己的函数里,从这个函数里调用呢?答案是这些部分很容易调试。没有太多的复杂性需要降低,所以我选择了内聚而不是耦合。注意注释被用作函数头。当多任务函数可以被分解成一组相关的简单的单任务部分时,这种方法非常有效。

为什么有人会费心去创建一行函数,而不是使用一行代码本身呢?答案是函数的名字比单行代码更容易理解。但是这一行代码最终不是必须要被理解吗?除非我们调试或修改特定的代码行。难道你不想遇到布尔表达式(在 Nelder-Mead 算法中)

if triangleHasNotConverged(count, A, B, C):
    return  

它引用了这个函数

def triangleHasNotConverged(count, A, B, C): # Boolean result
    return (count < MAX_TRIANGLE_COUNT and
           SMALLEST_TRIANGLE_SIZE < max(B.dist(C), A.dist(B), A.dist(C)))

而不是这条丑陋的线:

If (count < MAX_TRIANGLE_COUNT and
   SMALLEST_TRIANGLE_SIZE < max(B.dist(C), A.dist(B), A.dist(C))):
       return

我曾经写过函数makeComputerReply()让一个游戏在屏幕上移动(在《奥赛罗》中)。这是一个很短的函数,只执行一项任务,或者我是这样认为的。但是这个函数实际做的是 1)计算应该移动的位置,2)调用另一个函数在一个内部矩阵中移动,然后 3)在屏幕上显示移动。因为 2)和 3)总是一起发生,也许它们可以被认为是一个任务。不过,这是两项任务,而不是三项。如果有人向我指出这一点,我会说分解函数会增加程序的复杂性,而不是降低它:内聚力超过耦合。函数调用需要从简单的

makeComputerReply()

到更复杂的

bestCol, bestRow, finalPieces = makeComputerReply()
makeMoveInMatrixAndOnScreen (bestCol, bestRow, finalPieces, COMPUTER)

后来我回到我的程序中,意识到计算计算机最佳一步棋的代码,只要稍加修改,也可以计算人类最佳一步棋的反击。因此,计算机可以预先考虑两层而不是一层。如果它可以做两层,那么它可以做四层,并做出一些深思熟虑的举动。所有这些都可以通过重新设计makeComputerReply()函数来完成。

但正如我所说的,我试图修改的函数也将每一步棋插入到一个矩阵中,并在屏幕上显示出来。所以我不得不从函数中删除插入并打印代码,并把它放在对现在已重命名的bestResponse(player)的调用下面。最初的设计降低了理解代码的复杂性,但是增加了修改代码的复杂性。以前,我不知道这样的情况是可能的。真是个惊喜。

现在假设你正在写一个需要 2D 和 3D 距离函数的程序。以下三种方法你会选择哪一种?

# METHOD 1 (two functions)
def distance2D(x,y):
    assert len(x) == len(y) == 2
    return sqrt( (x[0]-y[0])**2 + (x[1]-y[1])**2 )

def distance3D(x,y):
    assert len(x) == len(y) == 3
    return sqrt( (x[0]-y[0])**2 + (x[1]-y[1])**2 + (x[2]-y[2])**2)
#-------------------------------------------------------------

# METHOD 2 (one function with a for loop)
def distance(x,y):
    assert len(x) == len(y) and len(x) in {2,3}
    total = 0
    for n in range(len(x)):
        total += (x[n]-y[n])**2
    return sqrt( total)
#-------------------------------------------------------------

# METHOD 3 (one function with a loop comprehension)
def distance(x,y):
    assert len(x) == len(y) and len(x) in {2,3}
    return sqrt(sum([(x[n]-y[n])**2 for n in range(len(x))]))

当一个函数可以工作时,为什么要编写两个函数呢?一个合理的回答是,两个函数名比单个函数名更具描述性。并且这两个函数比更强大的单个函数更容易调试。然而,因为距离的计算很简单,并且因为我习惯于列出 comps,所以我更喜欢Method 3。顺便说一句,除非你知道你可能要扩展一个函数,否则不要把它通用化。即使你知道,你可能仍然喜欢让你的程序使用更简单的函数。

也就是说,我实际上认为可以通过展开for循环来改进Method 3,如下所示。这就给我们带来了另一个问题。在下面显示的四个错误信息中,你更希望哪一个完成Method 4

# METHOD 4 (one function with no loops)
def distance(x,y):
    if len(x) == len(y) == 2:
       return sqrt((x[0]-y[0])**2 + (x[1]-y[1])**2)

    if len(x) == len(y) == 3:
       return sqrt((x[0]-y[0])**2 + (x[1]-y[1])**2 + (x[2]-y[2])**2)

通过选择下面的错误陷阱来完成此函数。

#---Exit message A
    exit('Error in distance function.')

#---Exit message B
    assert(False), 'Error in distance function.'

#---Exit message C
    msg = 'len(x) = '+ str(len(x)) + ' and len(y) = '+ str(len(y))
    assert False, 'Error in distance function: ' + msg

#---Exit message D
    msg = 'len(x) = '+ str(len(x)) + ' and len(y) = '+ str(len(y))
    exit('Error in distance function: ' + msg)

我的答案在脚注里。 3

回想一下古老的字母拼图 SEND + MORE = MONEY, 4 其中每个字母代表一个不同的数字。唯一解是 9567 + 1085 = 10652。我曾经让一个班级写一个程序,它可以找到任何一种算法的所有解——例如,DOG * CAT = FIGHT 有 16 种解。我这样做是因为我想让学生们熟悉强大的 Python 命令evalmaketranstranslate命令。我生成的代码(如下所示)让我大吃一惊。

#                       Teacher's solution
########################<BEGIN PROGRAM>########################
def createAlphametic():
    from itertools import permutations
    from re        import findall  # re stands for regular expressions.
    puzzle = 'SEND + MORE == MONEY' # Notice we use '==', not '='.
    puzzle = 'OOOH + FOOD == FIGHT' # 8886 + 1883 == 10769
    print(' NOW ATTEMPTING TO FIND ALL\n SOLUTIONS FOR THIS ALPHAMETIC\n PUZZLE:', puzzle)
    solutionFound = False
    count = 0

    words = findall('[A-Z]+', puzzle.upper())        # words = ['SEND', 'MORE', 'MONEY']
    keys = set(''.join(words))                      # keys  = {'Y', 'S', 'R', 'M', 'O', 'N', 'E', 'D'}
    if len(keys) > 10:
       print('--- ERROR: The puzzle has MORE than ten letters.')
       exit()
    initialLetters = {word[0] for word in words}   # Example: initialLetters = {'M', 'S'}
    numberOfInitials = len(initialLetters)
    keys             = ''.join(initialLetters) + ''.join(keys - initialLetters) # Example: keys = 'MSEDONRY'

    for values in permutations('1234567890', len(keys)):
        values = ''.join(values)        # Example: ('1', '2', '3', '4', '5', '6', '7', '8') becomes '12345678'
        if '0' in values[0:numberOfInitials]:        # No zeros are allowed in initial letters.
            continue             # If eval() finds a number beginning with zero, it will throw an exception.
                                 # 'M':  3,  'S':  8,  'E':  5, ...}
        table    = str.maketrans(keys, values)       # table = {77: 51,   83: 56,   69: 53, ...}
        equation = puzzle.translate(table)           # Example: equation = 8514 + 3275 == 32156
        if eval(equation):
           solutionFound = True
           if count == 0:
              print('------------------------------------')
              print('All solutions are listed below:') 

           count += 1
           print(count,'. ', equation, sep = '')

    if not solutionFound:
       print('No solutions exist.')
#-------------------------ALPHAMETICS-------------------------

def main():
    createAlphametic()
#-------------------------ALPHAMETICS-------------------------
if __name__ == '__main__':
     from time import clock; START_TIME = clock();  main();  print('\n+===<RUN TIME>===+');
     print('|  %5.2f'%(clock()-START_TIME), 'seconds |'); print('+================+')
#######################<END OF PROGRAM>#######################

为什么我没有使用逐步细化,把代码分解成单任务函数?例如,为什么不像这样分解它:

    def main():
        puzzle      = createAlphametic()
        solutionSet = solveAlphametic(puzzle)
        printResults(solutionSet)

事实上,我就是这样开始编写作业的。然而,程序通常需要 30 秒或更长的时间来运行,我希望看到发现的结果,而不是在最后一次打印出来。这意味着我在 main 函数中只有两个调用。但是createAlphametic()函数太简单了,与其他函数分开并没有增加多少清晰度。结果是,这个复杂的代码并没有因为被分解成几个小函数而变得更加易读。那为什么不把所有代码都塞到主函数里呢?我的策略是用一个描述性的名称来调用任何关键的代码块。主函数应该至少调用一个其他函数。我对这一政策的唯一例外是教学代码,它被设计用来说明语法。

这是另一个例外。当我设计一个玩具神经网络时,我编写了一个函数,它既创建训练数据,又创建随机权重值。(见下文。)这是两个任务。这些任务是如此的短、简单和相关,以至于把它们塞进同一个函数中是有意义的:再次强调,内聚性高于耦合性。

def createNetwork(iMax = 8, jMax = 3, kMax = 8):
#---Create the training data.
    inputs = [[1,0,0,0,0,0,0,0,-1], [0,1,0,0,0,0,0,0,-1], [0,0,1,0,0,0,0,0,-1],
             [0,0,0,1,0,0,0,0,-1], [0,0,0,0,1,0,0,0,-1],[0,0,0,0,0,1,0,0,-1],
             [0,0,0,0,0,0,1,0,-1], [0,0,0,0,0,0,0,1,-1],]

#---Create the w and v weights.
    w = [ [uniform(-2,2) for col in range(jMax)] for row in range(iMax+1)]  # = 9 rows & 3 cols
    v = [ [uniform(-2,2) for col in range(iMax)] for row in range(jMax+1)]  # = 4 rows & 8 cols
    return inputs, w, v, h

我的观点是:将一个函数限制在一个单一的任务中,并通过使用逐步细化来分解它的各个部分,这些规则非常重要,并且通常需要遵守。规则是人类的产物,并不完美。他们只是向导。一个经常被引用的关于编程的专家规则是“特例不会特殊到违反规则。”我不同意;不同的环境,不同的情况,需要不同的政策。

我第一次在一本哲学书上看到关于警惕规则的警告:“只要道德被认为是达到目的的手段,它就是有价值的;它是一个好仆人,但一个可怕的主人。” 5 生活或编程中没有绝对的规则吗?想了一辈子,还是不确定。考虑到这一点:在编写代码时,可读性是第一位的,如果优化是不必要的,如果时间不能更好地用在其他地方。这是绝对的规则吗?

Footnotes 1

见安德鲁·亨特和戴维·托马斯,《实用主义程序员》(艾迪森·卫斯理,2000 年),第十三页。

  2

Pascal 中的软件工具(Addison-Wesley,1981),第 189 页。

  3

我的选择是 b。退出消息 C 和 D 花费了太多的时间和代码来处理一个我认为很少会发生的错误。消息 A 简单地退出程序,并显示一条错误消息。消息 B 的作用与 A 相同,但也会将光标放在函数的断言行上。

  4

alphametic 是由 Henry Dudeney 发明的,并在 1924 年 7 月的英国 Strand 杂志上首次发表。

  5

艾伦·瓦特,《禅宗的精神》(格罗夫出版社,1958),第 61 页。

六、自我记录代码

  • 我感到不忠诚,但毫不畏惧地诚实地说,大多数科学家不知道如何写作,因为风格确实背叛了 l ' homme même[这个人自己],他们写作好像他们讨厌写作,最重要的是想与它断绝关系。——彼得·b·梅德瓦尔爵士(诺贝尔奖获得者),《给年轻科学家的忠告》(哈珀&罗出版社,1979 年),第 63 页。
  • 值得注意的是,几乎所有的科学家,在他们从数学或化学语言转向英语的时候,似乎都觉得不再有任何精确术语的义务了。——罗伯特·格雷夫斯和艾伦·霍奇,《英语的使用和滥用》(Paragon,1970),第 227 页。
  • 很少有人意识到自己写得有多差。——威廉·津瑟,《论写作》,第 5 版。(哈珀,1994),第 19 页。
  • 所有好的写作都是自学的。事实是,想成为作家的人必须通过书籍或评论家来自学。——雅克·巴尔赞,《简单而直接》(哈珀&罗出版社,1975 年),第 3 页。[2003 年,雅克·巴尔赞教授(哥伦比亚大学)因其有影响力的著作获得总统自由勋章。]

上述引文的要点是清楚的交流是困难的。如果我们把语言从英语改为计算机语言,难度是否会降低到任何尝试的人都会做得很好的程度?我不这么认为,我的证据是我们可以在互联网和一些计算机书籍中找到的命名不当、缩写过度、结构笨拙的代码。

计算机代码可读性的关键是自文档化代码,即通过精心构建(与相关任务的结合、单个任务的耦合)和选择标识符(函数和数据的描述性名称)来揭示其意图的代码。

作为一般的约定,类和变量名应该是名词或名词短语,函数名应该是动作动词或动宾短语。我有时将函数命名为描述返回项的名词,例如,result(代表井字游戏的赢、输或平)、symbol(代表返回的字符)。有人建议所有布尔函数都要以 is 开头。由此可见,allVowels应该是isAllVowels。最初,我并不认为这个建议有多好,但后来我注意到它实际上使我的一些代码读起来像英语句子。所以现在我遵循这个建议。我的建议是避免使用笑话名称,可爱的词,和攻击性的词。我总是找到 foo、bar、baz 和 spam 这样的名字来使例子不那么清楚。他们的使用似乎是在用一个圈内人的笑话炫耀。 1 我更喜欢通用的函数名doIt(动宾)。

当然,在开始时创建描述性名称是困难的,因为您更关注的是让代码工作,而函数任务仍在被修改。也许一个很好的例子是一组没有告诉我们任何东西的标识符:

def process(argument, parameter, data, whatIsIt):
     ...
    something  = action(value)
    entity     = call(variable)
    stuff      = phunction(identifier)
    ...

那些容易写的变量bugcatcowdogflyfoxhenhogpigrat,甚至it呢?(学生守则里的thingystringy、淫秽用语我都见过。)你还能想到更难听的名字吗?是的,这很简单:不能发音的标识符,比如l01OoO0Oo和一串下划线:____。也就是说,单下划线(_)作为变量实际上至少有两种用途。考虑打印列表中数字总和的目标:

Lst = [('A', 1), ('B',2), ('C', 3), ('D',4),]

下面是两种方法。哪个更好?

#---Method 1
    total = 0
    for (ch,num) in Lst:
        total += num
    print('total =', total) # output: total = 10

#---Method 2
    print('total =', sum([num for (_,num) in Lst]))
    # output: total = 10

请注意,下划线在第二个方法中被用作一次性变量。如果这是您第一次看到它,这似乎是一个很差的标识符选择,但是我已经多次在商业代码中看到它的使用。它告诉读者这是一个占位符变量——也就是说,我们必须拥有它,但我们从未使用过它。

我觉得Method 1更具可读性,但我还是推荐Method 2。为什么呢?因为Method 2更蟒,更专业。我们需要以专业人士喜欢的方式来阅读代码,比如在这种情况下使用列表理解和下划线作为虚拟变量。

下面是下划线的另一种用法:

_ = 0 # <-- The underscore is the constant 0.

#     Easy to read
M = [[3, _, 4, _, _, 6,],
     [_, 7, _, _, _, _,],
     [_, _, _, 9, _, _,],
     [_, _, 5, _, _, _,],
     [2, _, _, _, 1, _,],]

#     Less easy to read.
M = [[3, 0, 4, 0, 0, 6,],
     [0, 7, 0, 0, 0, 0,],
     [0, 0, 0, 9, 0, 0,],
     [0, 0, 5, 0, 0, 0,],
     [2, 0, 0, 0, 1, 0,],]

在我读到的某处,我们应该避免类似的名字,比如str1str2,因为很容易把一个打成另一个,名字之间的差异没有意义。在我看来,这个想法在小范围内并不成立。

现在做一个小实验。我写了一些代码,其中我需要在 0 和 1 之间选择两个随机数,第一个数小于或等于第二个数。他们的名字我想到了四个选择:(randomNum1randomNum2)、(r1r2)、(xy)、(ab)。您希望调试下面的哪个代码段?

Version 1
   for n in range(totalRuns):
        randomNum1, randomNum2  = random(), random()
        if randomNum1 > randomNum2:
            randomNum1, randomNum2 = randomNum2, randomNum1
        if (randomNum1 > 0.5 or randomNum2-randomNum1 > 0.5
                             or randomNum2 < 0.5):
           noTriangleCount += 1
        else:
           triangleCount += 1

Version 2
    for n in range(totalRuns):
        r1, r2  = random(), random()
        if r1 > r2:
            r1, r2 = r2, r1
        if (r1 > 0.5 or r2-r1 > 0.5 or r2 < 0.5):
           noTriangleCount += 1
        else:
           triangleCount += 1

Version 3.
    for n in range(totalRuns):
        x, y  = random(), random()
        if x > y:
            x, y = y, x
        if (x > 0.5 or y-x > 0.5 or y < 0.5):
           noTriangleCount += 1
        else:
           triangleCount += 1

Version 4.
    for n in range(totalRuns):
        a, b  = random(), random()
        if a > b:
            a, b = b, a
        if (a > 0.5 or b-a > 0.5 or b < 0.5):
           noTriangleCount += 1
        else:
           triangleCount += 1

我选择了版本 4 ( ab),因为单字母标识符最容易阅读,ab有心理顺序(a < b)。xy也是如此,但它们也有yx的函数的历史(不在这里)。我知道的另一个常见的对是pq,它们用于指针或列表中的位置。以下两个函数执行相同的任务:它们展平列表,例如,它们都将翻转

[0, [1, [2, 3, [4, 5]], 6, [7]], [8, 9]]

到…里面

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

那么,下面哪个函数更具可读性:第一个使用描述性的newLst,还是第二个使用模糊性的y

def flatten(Lst): # Recursive
    newLst = []
    for x in Lst:
        if type(x) == list:
            newLst.extend(flatten(x))
        else:
            newLst.append(x)
    return newLst

def flatten(Lst): # Recursive
    y = []
    for x in Lst:
        if type(x) == list:
            y.extend(flatten(x))
        else:
            y.append(x)
    return y

同样,我认为y.append(x)newLst.append(x),更容易理解,尽管newLsty更具描述性。一个在孤立状态下可读性更强的变量在代码中的可读性怎么会更差呢?嗯,通常它不能,但是这个代码足够简单,名字newLst中的信息是不需要的。有帮助的是,我们期望yx的函数,这正是这里的情况。当我们有简单代码的小片段时,单个字母的变量比多单词的描述性变量更具可读性。一般规则是范围越大,标识符越长。这是一个普遍的规则,而不是一个绝对的,教条式的法律。

命名变量的最大陷阱是没有使它们足够具有描述性。第二大陷阱是过度缩写他们的名字。也就是说,短标识符和单字母标识符对于循环索引和短范围的临时变量是可以接受的。甚至这些小的孩子也可以描述。当然,千万不要用o 2 或者O(两者看起来都像零:0),避免用字母l(看起来像一:1)。以下是一些描述性的单字母标识符。

  • b为布尔型(bool为内置)
  • c和也许k为常数(也许const1const2更好)
  • f用于功能,不用于标志(使用flag用于标志)
  • g用于功能(使用f后)
  • h为启发式功能
  • ij,可能还有k用于循环索引 3 (有时可能还有nnumindx)
  • p为位置或指针
  • Q进行排队(但是为什么不用queue,甚至que)
  • r为 random(也许rand更好),而不是模块名random
  • t为 total(或者tot,甚至total,而不是内置函数sum)。也许用t表示时间,或者用tictoc表示时间,但不要用模块名time
  • M为矩阵(也许matrix更好)
  • (r,c)行和列(也许rowcol更好)
  • (x,y,和也许z)为坐标
  • (a,b)对于第一和第二值
  • x[n]y[n]z[n]为数组,但arrayXarrayYarrayZ可能更好
  • chkh为字符等。

我尽量避免以下情况:

  • d为距离(dist更好)
  • m为最大值(bigmaximum更好,但不是内置的max
  • p为概率(prob更好)
  • 弦用ss1(stngstr1更好)

甚至像argsotherdatainfocollectionresult这样的中性标识符在一个短范围内也是可以接受的,它们的含义要么是显而易见的,要么在一个行内注释中解释。举个例子,

data = ['-',0,0,0,0,0,0,0,0,0,] # Distances to goal node from nodes 1-9.

在下面的代码中,我缩短了一个标识符,使代码更易读。

原始版本:

def fb(node):
    if node == 9: return 0
    shortestDistanceFromNodeToGoal =
        min([dist + fb(neighbor) for (dist, neighbor) in graph[node]])
    return  shortestDistanceFromNodeToGoal

改进版本:

def fb(node):
    if node == 9: return 0
    shortest = min([dist + fb(neighbor) for (dist, neighbor) in graph[node]])
    return  shortest # = shortest distance from current node to goal node

尽可能遵循数学符号。在数学书上,我们这样写线性向量方程:\overrightarrow{y}=m\overrightarrow{x}+\overrightarrow{b}或者这样 Y = m X + b, 4 不这样(除非我们别无选择):

outputVector = matrix*inputVector + auxiliaryVector.

我们这样做是因为前两个表达式比第三个表达式可读性更好。数学常数/变量/参数的命名规则不同于程序变量/函数/模块/库/文件/目录的命名规则。编写数学表达式时,尽量遵循数学惯例。

我看到一个程序作者使用标识符startend来表示列表中的两个位置。这很清楚,但我更喜欢英语习语——例如,beginend,或者startstop,或者firstlast,甚至leftright。他还用pivpivot。为什么不把它说出来?

假设我们有一个男性身高的列表。列表的合理标识符是mensHeights。但是当我们只选择一个元素时,我们必须用mensHeights[n]来表示一个人的身高。标识符对于列表来说很好,但是对于列表中的元素就不那么好了。没有一种语言是完美的。那么,我们应该选择哪个标识符呢?比起mansHeight.,我更喜欢mensHeights

下面一行 Python 代码导致了一个错误(也称为引发、抛出和生成异常),其中A是一个 Vector 对象:

print(A*2)

在学生的 Vector 类中找到的 Python 方法中存在错误:

def __rmu1__(self, entity):
return self*entity

我找不到错误,因为代码实际上是正确的。那么是什么导致了这个错误呢?以下是更正后的代码:

def __rmul__(self, entity):
return self*entity

纠正后的代码看起来和坏代码一模一样吗?这是因为错误几乎是看不出来的。rmu 1看起来几乎像rmu 1。这个学生已经输入了小写字母“l”的数字“1”。那么,程序员的字体是什么样子的呢?它是等宽的(对垂直对齐很有用),使不同的字母看起来不同——例如,数字105看起来不像字母lOS。顺便说一句,这个例子一直留在我的脑海里,因为我发现它的痛苦。

A461100_1_En_6_Figa_HTML.jpg

哪个是最好的函数名:

createMatrix(),6

createPopulation(),
createPopulationMatrix(),
popMat(), or
coffee()?

我们很少对变量的数据类型感兴趣。所以我更喜欢createPopulation()而不是createMatrix().7createPopulationMatrix()似乎没必要那么长。较短的popMat()对我的口味来说太缩写了。为什么有人会给一个函数命名为coffee?有些人很难想到描述性的名字。孤立的程序员知道他自己的变量意味着什么,那么为什么不选择一个名字,或者至少是一个快速选择的合理的名字呢?对我来说,主要的实际原因是我经常对大型复杂程序失去控制。程序错综复杂,以至于我开始忘记上周我做了什么和怎么做的。为了理解我自己的工作,我被迫以更易读的风格重新编写程序。作为一名 C.S .老师,我希望我的代码能被别人理解,而不是被它吓倒。

  • 人们有时会问我在一个方法中寻找什么样的长度。对我来说,长度不是问题。关键是方法名和方法体之间的语义距离。——马丁·福勒,《重构》(艾迪森·韦斯利,1999),第 77 页。

将数据类型作为标签添加到变量或常数的名称前称为匈牙利表示法。偶尔有一些正当理由,但不经常在学校的问题。

当然,功能之间至少应该用一个空行隔开。你应该在函数之间放一行破折号或星号吗?除了我,我不知道谁会这么做。在屏幕上,大多数程序员不认为这是值得的麻烦,但在纸上(没有颜色),分行有助于阅读代码的讲义。

下面哪个例子更好(对齐或不规则间隔的等号)?

version 1:
    bestX         = x
    bestY         = y
    bestDirection = f(x,y)
    step          = 2*pi/64  # = 64 directions
    radius        = 0.01     # = the distance of the step.
------------------------------------------------------

version 2:
    bestX = x
    bestY = y
    bestDirection = f(x,y)
    step = 2*pi/64  # = 64 directions
    radius = 0.01 # = the distance of the step.

答:两者都可以接受,因为它们都是可读的。【注意:Python PEP 0008 风格指南不鼓励版本 1。]有些人看不到让代码看起来更有吸引力的好处(版本 1)。事实上,他们为别人在这件事上的大惊小怪而烦恼。垂直对齐确实需要更多的时间来设置和维护。然而,其他人却为缺乏视觉组织而烦恼。所以,我认为这是一种个人风格。顺便提一下,我记得有两个伟大的数学系主任,他们的办公室总是一团糟(缺乏视觉组织)。这没关系,因为他们的工作效率很高。

在一些语言中,程序员可以选择使用作为命名参数接收的命名参数(也称为作为关键字参数接收的关键字参数)。在 Python 中,如果接收的参数集以星号开头,那么关键字参数是必需的。 8 下面是两个例子。[注意:您传递参数(也称为实际参数)和接收参数(也称为形式参数)。]

def fn(*,a,b,c,d):
    print(a,b,c,d)
#---------------------
fn(a=1, b=2, d=3, c=4) # output:1 2 4 3

fn(a=1, b=2, d=3, 4)   # output:ERROR (missing keyword)

这是个好主意吗?对于很长的参数列表,或者读者需要额外帮助的地方,额外的努力是有意义的。使用命名参数也可以节省空间。而不是这个:

def createArray(arraySize):
    array = []
    ...
def main():
    arraySize = 100
    array     = createArray(arraySize)

少写一行:

def createArray(arraySize):
    array = []
    ...
def main():
    array = createArray(arraySize = 100)

如果我们只通过 100,我们就失去了描述符。

如果有许多参数,请使用命名参数和垂直对齐。下面是 O'Reilly Python 编程书第四版中的一行代码:

    threadtools.startThread(
        action     = self.cache.deleteMessages,
        args       = (msgnumlist,),
        context    = (popup,),
        onExit     = self.onDeleteExit,
        onFail     = self.onDeleteFail,
        onProgress = self.onDeleteProgress)

请注意命名参数的使用、垂直对齐和参数的堆叠。我从未在函数头中堆叠过参数,因为我从未见过如此冗长的函数。然而,对于冗长的参数集,堆叠似乎是一个好主意。

以下三个例子哪个可读性最强?

Method 1.
netSalary = (jobIncome + hobbyIncome + stockDividends +    \
            (rents - utilities) - personalLivingExpenses - \
             mortgagePayments - medicalExpenses)
print(netSalary)

Method 2.
netSalary = (jobIncome              +
             hobbyIncome            +
             stockDividends         +
             (rents - utilities)    -
             personalLivingExpenses -
             mortgagePayments       -
             medicalExpenses)
print(netSalary)

Method 3.
netSalary =  (jobIncome
            + hobbyIncome
            + stockDividends
            + (rents - utilities)
            - personalLivingExpenses
            - mortgagePayments
            - medicalExpenses)
print(netSalary)

我倾向于方法 3。在文本中,大多数数学书在运算符后换行。在代码中,有时在运算符之前中断会更好。

使用外部文档。我的意思是在你的程序的顶部,在一个整洁的盒子里,放置一些下面的信息:

  1. *程序的标题
  2. *程序描述,可能还有一些程序要求
  3. *你的名字
  4. *文件上交的日期(包括年份)
  5. *课程名称,课时/节
  6. 编程语言
  7. 导入的包、模块和库,尤其是图形
  8. 使用的关键算法
  9. 计划中实施的策略或设计
  10. 外部文件

下面是我自己代码中的一个例子:

"""+===============+=====-========*========-======+===========+
   ||                      CIRCLE DETECTION                  ||
   ||               by M. Stueben (October 8, 2017)          ||
   ||          Artificial Intelligence; Mr. Stueben,         ||   ||          Periods 1, 2, and 7                           ||
   ||                                                        ||
   ||  Description: This program detects a circle (radius    ||   ||               and center) in a 512x512 gray-scale      ||
   ||               image of a circle and 500 random points  ||   ||               (aka snow, noise).                       ||
   ||               It then draws a new circle in red over the ||    ||               initial circle. The circles almost match.  ||
   ||  Algorithms:  Gaussian smoothing, Sobel operator/filter, ||   ||               Canny edge detection, and a vote accumulator- ||   ||               matrix equal to the size of the image.   ||
   ||  Downloads:   None                                     ||
   ||  Language:    Python Ver. 3.3                          ||
   ||  Graphics:    Tkinter Graphics                         ||
   +==========================================================+
"""

接下来是一个让一些人抓狂的话题:小编程约定。下面哪个表达式可读性最强?

ANN = inputs,w,h,v

ANN = inputs, w, h, v

我稍微倾向于第二种,除非有一种商店风格需要每个人都遵守。你应该写吗

y = 2 * (x + y)

或者

y=2*(x+y)?

有人建议的是

y = 2*(x + y).

为什么?可能是因为教科书上的乘法往往隐含着:2a,而不是 2×a. 9 因此,我们在“+”和“─”周围放置空格,而不是“*”周围。写你认为最清楚的。

下面是一个给定顶点来确定三角形面积的函数。我只在一个操作员周围放置了空格,而没有在其他七个操作员周围放置空格。参数对也由三个空格分隔。

def triangleArea (x1,y1,   x2,y2,   x3,y3): # vertices
    return abs((x1-x3)*(y2-y3) - (x2-x3)*(y1-y3))/2

Python PEP 0008 风格指南建议通常用空格包围赋值和关系:x = 5,而不是x=5。但是不允许为指定的变量/参数赋值留空格,例如doIt (a=1, b=2)。它还建议函数名: print(x)后面不要有空格,而不是print (x)。我努力遵守这些规则,但偶尔也会出错。精彩的 VIM 代码编辑器将标记不遵循 PEP 0008 指南的代码。

我们应该把每一个陈述放在它自己的行上吗?或者这太武断了?摘自一本 1981 年出版的计算机科学旧书 11 :“连续的命令可以写在同一行上,只要它们在逻辑上属于一起。”一如既往,问题是可读性。以下所有方法都可以,因为它们都是可读的。

#--Method 1 (acceptable, but discouraged in Python)
a = 1; b = 2; c = 3; d = 4

#--Method 2 (common in Python)
a, b, c, d = 1, 2, 3, 4

#--Method 3 (bulky, but this is the most readable)
a = 1
b = 2
c = 3
d = 4

根据 PEP 0008,冒号后面不应该有任何内容。换句话说,这是大多数代码读者应该期待的:

    if a == b:
        doIt(c)
#----------------------

    if a == b:
        doIt(c)
    else:
        runIt(c)
#----------------------

    for i in range(5):
        print(i)

但是如果你在互联网上查看代码,你会发现以下内容。

    if a == b: doIt(c)
    else: runIt(c)

    for n in range(5): doIt(n)

    while type(x) == int: (p, x) = (x, array[x])

来自初级 Python 教科书:

def fib(num):
    return 1 if num < 3 else fib(num-1) + fib(num-2)

可以,前面几项都是可读的。只是它们出乎意料,让一些编程人员觉得很丑。也就是说,我们都期望在 Python 中使用的 list comprehensions 正是以这种所谓的丑陋方式编写的。

print( [x*x for x in range(5)])             # = [0, 1, 4, 9, 16]
print( [x*x for x in range(5) if x%2 == 0])  # = [0, 4, 16]
print( [x*x if x%2 == 0 else -1 for x in range(5)] ) # = [0, -1, 4, -1, 16]

一般来说,理解列表比循环要快。然而,用一个for循环(用一个if-else-if-else)替换一个列表理解(用一个if-else-if-else)实际上会使代码变慢。那对我来说是一个惊喜。

以下是可读性极强的代码,它打破了冒号规则,甚至将多个语句放在同一行上:

    for x in dataSet:
        if -10 <= x <  0: print('Case   I'); continue
        if   0 <= x < 10: print('Case  II'); continue
        if  10 <= x < 20: print('Case III'); continue
        print(x)

因为它使用垂直对齐达到如此好的效果,我不认为这个代码块可以变得更可读。

坦白:我有时会用单行形式(if a == b: doIt (c)),但从来不用else。一位受人尊敬的 Python 作者建议,函数、循环和 if 语句都使用单行体,在一行中编写是可以接受的。我不喜欢看到这样的代码,但它是可读的。

下面是一个有争议的例子。两个版本都使用遗传交叉方法,从两个父母的染色体(这里是字符串)生成两个遗传上新的孩子。哪个可读性更强?

Version 1:
def produceTwoChildren(parent1, parent2):
    r  = randint (0, MAX)
    child1 = parent1[0:r] + parent2[r:MAX]
    child2 = parent2[0:r] + parent1[r:MAX]
    return (child1, child2)

Version 2:
def produceTwoChildren(parent1, parent2):
    r  = randint (0, MAX)
    return (parent1[0:r] + parent2[r:MAX], parent2[0:r] + parent1[r:MAX])

Version 1可读性更好,因为它使用垂直对齐进行计算,包含描述性标识符child1child2,并将两个计算放在不同的行上,这使得它们更容易理解。

Version 2可读性更好,因为它更短,代码非常简单,我们不需要把它分解,不需要垂直对齐,也不需要描述性的名字。

我更喜欢Version 1,但无法反驳更喜欢Version 2的理由。话虽如此,我们再来看同一个问题。直棍的长度是一个单位。棍子上随机做了两个记号。这些标记彼此相差不超过十分之一个单位的概率是多少?通过模拟求解,最大运行次数= 10000000 次。

    from random import random
    max   = 10000000

#---Method 1 (one line, broken into two lines)
    print ('Answer1 =', round(sum([abs(random()-random()) <= 0.1
                        for n in range (max)])/max, 2))

#---Method 2 (five lines)
    total = 0
    for n in range (max):
       total += abs(random()-random()) <= 0.1
    answer = round(total/max, 2)
    print ('Answer2 =', answer)

我几乎可以像理解Method 2\. Method 1中的代码一样容易地理解Method 1中的代码,并且具有只有一个逻辑行长的优点。然而Method 2是首选,因为它更容易调试。在写Method 1的时候,我不小心把2放在了最后一个括号旁边。没有产生编译器错误,代码看起来是正确的。输出是0 2,而不是正确的0.19

那么,对于所有这些例子,我们能说些什么呢?第一,永远不要和店铺风格决裂。如果没有商店风格,如果你打破了 PEP 0008 或其他编程惯例,至少要有这样做的理由。如果其他人不遵循你的小编程惯例,不要开始一场宗教争论。

A461100_1_En_6_Figb_HTML.jpg

Footnotes 1

这些占位符在技术上被称为“元同步变量”参见维基百科。foo 和 bar 这两个术语来历不明,但可能与军事俚语 Fubar 有关,“弄得面目全非”垃圾邮件一词(可能是 1937 年引入的“五香火腿”)是指 YouTube 上的一部蒙蒂 Python 喜剧小品(“蒙蒂 Python 垃圾邮件”)。读者可能已经知道,“Python”这个名字的选择是参考了由六名成员组成的英国喜剧团体蒙蒂·Python 的飞行马戏团(从 1969 年到 1974 年有 45 集电视节目,还有五部电影,最后一部是在 1983 年)。这个群体的幽默以不同的方式打动不同的人。当我向我的学生展示 YouTube 上的“Monty Python 辩论诊所”时,一些人认为这很好笑,而另一些人显然很无聊。

  2

我有一本 C++教材,作者用o输出。output不是更好吗?

  3

indexeses 和 index 都是同样可接受的复数,但 indexes 更适合于数学和技术用途。for循环i大概代表 index,不是 integer。

  4

在线性方程 y = mx + b 中,m 可以被认为代表“矩阵”标量可以被认为是 1×1 矩阵。

  5

一个很好的编程类型是 Vera Sans Mono。在网上查一下。

  6

camel case(aka cap words aka studly caps)符号比 under_score (aka snake case)符号更容易编写,under _ score(aka snake case)符号更容易阅读—例如,

def extractxandycoordinates from chrome(行):

def extract _ X _ and _ Y _ Coordinates _ From _ chronous(行):

这两种风格对于编程都是可以接受的。

  7

对名称的研究,尤其是在技术领域,被称为专名学。

  8

保留字即关键字(一个字)不能用作标识符,例如,for = 3会导致编译器错误,因为编译器认为for是循环的开始。然而,关键字变量和关键字参数只是函数调用中的命名标识符。

  9

回想一下,在解释算术数学表达式时,乘法和除法具有同等的优先地位,也就是说,您按照它们出现的顺序执行这两种运算:8/2×4 = 16。现在,转到代数,设 a = 4。近世代数书有 8/2a = 1。所以我们看到,在代数中,隐式乘法(隐式分组)的优先级不同于显式乘法。在编程中,隐式乘法(通常)是不可能的。但是,在我的可编程 TI-84 计算器上是可能的,它将两个表达式都解释为 16。

  10

为什么会这样?行列式\mid {\displaystyle \begin{array}{cc}{x}_1& {y}_1\\ {}{x}_2& {y}_2\end{array}}\mid是由位置向量〈x 1 ,y 1 〉和〈x 2 ,y 2 〉.组成的平行四边形的面积(可能为负)这很容易用几何图证明。现在就做。(位置向量的初始点在原点。)从点(x3,y3)到点(x1,y1)的向量是位置向量x1-x3,y1-y3。从点(x3,y3)到点(x2,y2)的矢量是位置矢量x2-x3,\kern1.25em y2-y3。所以以这两个向量为边的三角形的面积一定是\frac{1}{2}\mid {\displaystyle \begin{array}{cc}x1-x3& y1-y3\\ {}x2-x3& y2-y3\end{array}}\mid =\left(\left(x1-x3\right)\left(y2-y3\right)-\left(x2-x3\right)\left(y-y3\right)\right)/2。我在布赖恩·海斯的一篇文章中发现了这个计算方法,这篇文章发表在安迪·奥兰姆和格雷格·威尔逊的《美丽密码》(O'Reilly,2007)中。作者试图确定三个点是否共线(如果它们作为顶点形成的三角形的面积为零)。

  11

大卫·格里斯,《编程的科学》(施普林格出版社,1981),第 276 页。

七、逐步细化

  • 只有故事才是真正可读的。——鲁道夫·弗莱施,《可读写作的艺术》(科利尔·麦克米伦出版社,1949),第 74 页。

自我记录代码的一种方法是使用自顶向下的设计,这是一种结构化编程的形式,也称为逐步细化。 1 在这种风格下,2main 函数包含带有描述性英文名称的函数调用——例如enterData()computeData()printData()。函数调用将形成代码所做工作的轮廓。他们讲述一个故事。

当您跟踪这些调用中的一个时,您可能会再次得到子调用的概要,这些子调用描述了子函数的作用。例如,computeData()可能会把我们引向calculateDistances()FindSmallestDistance()。当然,这不可能永远持续下去,最终读者必须遇到真正的计算机指令。我们的目标是选择描述性很强的函数名,这样读者就可以很容易地理解程序的设计,而不需要阅读太多的计算机代码或注释。这是另一个例子:

def main():
    matrix = createSudoku()
    matrix = solveTheSudoku(matrix)
    printVerification(matrix)
    root.mainloop() # Required for Tk graphics.

相比之下,自底向上的设计是一种意识流编程,也称为牛仔编程,也称为凭感觉编程——也就是说,我们编程程序的下一部分,而大画面模糊地保存在我们的脑海中。这种风格适用于小程序。

一个程序是自下而上、自上而下还是两者的混合都没关系。目标是程序自顶向下阅读。这允许在不同的级别(森林级别和树级别)上进行程序验证,并使审阅者更容易阅读程序,审阅者可能是三个月后的作者。

下面是我在 Python 中自顶向下(逐步细化)的主要函数之一:

如果仔细观察,您会注意到大多数行都接受前一行的输出。

def main():
    image = list(readPixelColorsFromImageFile\
            (IMAGE_FILE_NAME = 'e:\\lena_rgb_p3.ppm'))
    displayImageInWindow(image, False)

    saveTheImageGrayScaleNumbersToFile\
            (image, GRAY_SCALE_NUMBERS_FILE_NAME = 'e:\\grayScale.ppm')
    image = extractTheImageGrayScaleNumbersFromFile\
            (GRAY_SCALE_NUMBERS_FILE_NAME = 'e:\\grayScale.ppm')
    displayImageInWindow(image, False)

    image = smoothTheImage\
            (image, NUMBER_OF_TIMES_TO_SMOOTH_IMAGE = 4)
    saveTheImageGrayScaleNumbersToFile\
            (image, GRAY_SCALE_NUMBERS_FILE_NAME = 'e:\\smoothed.ppm')
    image = extractTheImageGrayScaleNumbersFromFile\
            (GRAY_SCALE_NUMBERS_FILE_NAME = 'e:\\smoothed.ppm')
    displayImageInWindow(image, False)

    image = sobelTransformation(image)  # image = [...(mag, angle)...]

    sobelMagnitudes = normalize([x[0] for x in image]) 

    displayImageInWindow(sobelMagnitudes, False)

    imageWithGrayValuesTransformedToLists = cannyTransform(image)

    image = doubleThresholdImageListsInToGrayScaleValues\
            (imageWithGrayValuesTransformedToLists)

    displayImageInWindow(image, True)

    root.mainloop()

仅仅几个星期后,当我打开程序检查一些细节时,我才怀疑主函数太大了。以下是我的改写:

def main():
    imageFileName            = 'g:\\lena_rgb_p3.ppm'
    grayScaleNumbersFileName = convertColorFileToGrayScaleFile(imageFileName)
    smoothedFileName = extractSmoothAndSaveImage(grayScaleNumbersFileName)
    imageLists       = sobelTransformSmoothedImage(smoothedFileName)
    printNormalizedImageLists(imageLists)
    imageLists       = cannyTransform(imageLists)
    image            = doubleThresholdImageListsInToGrayScaleValues(imageLists)
    displayImageInWindow(image)
    root.mainloop()

这个版本可读性更强,因为它更短。能不能写的更清楚一点?也许吧,但这是我两次尝试后最大的努力。

一种重要的编程风格被称为增量式(也称为迭代式,也称为进化式)开发。在这种风格中,程序员首先只编写一小部分需求的程序(一个“行走的骨架”)。一旦这样做了,一组新的需求就增加了。然后,当改进的程序工作时,添加另一组需求,等等。在开发过程中,可能需要多次进行设计重组。有时,进化的方法被称为莫斯科方法:必须拥有、应该拥有、能够拥有和不想拥有,但是想要拥有。有时它被称为时间拳击。

这种方法有很多优点。一个工作的——不可否认是不完整的——程序总是会完成的。这给了程序员心理上的刺激。与大型项目的典型情况相比,项目结束时的压力和不确定性要小得多。由于早期的用户反馈,图形布局、界面和用户指示往往会变得更好。程序的早期版本成为指导最终设计的原型。这是最好的编程方式吗?可能对于有很多功能的程序来说,但是大多数学校的程序只是开发算法。

Footnotes 1

我把结构化编程等同于过程化编程(用函数、过程、子例程和方法编程),以逐步求精为目标。结构化代码的对立面是意大利面条式代码。

  2

"风格是选择的艺术。"——温斯顿·韦瑟斯,“该系列的修辞”,见于格伦·a·洛夫和迈克尔·佩恩的《当代风格论》(斯科特·福尔斯曼,1969),第 21 页。

八、注释

小心使用注释。下面的 5 行布尔函数是我对 13 行代码的修改,增加了 9 行注释。更长的版本是一个互联网教师的例子,指示对几乎每一行代码进行注释(即使对初学者来说,这也是一个糟糕的想法)。自文档更好。

def isAllVowels(stng):
    for ch in stng.lower():
        if ch not in ['a', 'e', 'i', 'o', 'u']:
           return False
    return True

自文档化代码消除了许多注释的需要。但是我们仍然需要注释,原因如下:

  1. 为了显示组织(将代码分解成案例),
  2. 给出洞见——也就是说,让细微的观察变得清晰,
  3. 陈述一些假设,特别是前置条件、后置条件、不变量和边界限制,或者
  4. 举个例子(写类的时候有用)。

下面的代码打印出一个有八个皇后的棋盘。第一行显示了示例注释的好处。

def printBoard(board):     # Example: board = [3,5,7,2,0,6,4,1]
    print("###################")
    for col in board:
        s = ['- '] * len(board)        # build a list of strings with no 'Q '
        s[col] = 'Q '                  # insert 'Q 's in the correct places
        print('# ' + ''.join(s) + "#") # make the list into one string.
    print("###################")

注释应该像人教版 0008 建议的那样用完整的句子写吗?是的,如果你可以的话,但是注释是可读的。注释应该像printBoard()例子中那样写在行内吗?一些专家说不。但我更喜欢这样做简短的注释。这就是 110 字符行长度的优势,不是针对长代码行,而是针对代码后面偶尔出现的行内注释。此外,如上所示,排列注释使代码更容易阅读。

注释应该告诉你为什么(如果代码不清楚),而不是如何。您不需要解释如何做,甚至不需要解释什么,因为这已经在代码中完成了。如果你写了一个关于“如何”的注释,而“如何”被改变了,那么这个注释也需要被改变。但是,仅仅通过代码往往不能理解的是我们为什么要这样做?

你如何看待这两个关于 vector 类中一个方法的注释?

def dist(self, other): # Return the distance between two points (position vectors).
    return (self-other).mag()  # Vector.dist(A,B) and A.dist(B) both work

"Return the distance ..."注释有必要吗?我也这么认为类,尤其是复杂的类,应该像手册一样被记录,并且应该包含冗余。作为一个 Python 初学者,我没有意识到 Python 类自动允许这两种符号。因此,我现在试图使可选的符号显式化。注意"self-other"中的减号看起来像连字符。也许应该写成"self - other."

我怀疑非常有才华的程序员很少会觉得注释和重组代码对他们的高中程序是必要的。他们几乎从来不会迷失在自己的代码中,写得不好的代码对他们来说仍然很容易理解。这也是普通员工难以追随天才的原因之一。他们不怎么努力说清楚,只是简洁。这就是为什么有天赋的学生有时会轻视可读性的要求。他们真的不理解我们的困难。

注释表示代码不好吗?虽然这可能是真的,但这样的声明可能会导致初级程序员回避注释。目标是编写可读的代码。如果注释有帮助,那么就应该使用它们。考虑这个深度优先搜索函数中的注释。

def DFS_FewestNodesPath(node, goalNode, path=[]):
# Notes: 1\. We avoid loops by reference to the path itself.
#        2\. The recursion will be unwound just below the recursive call at (*).

#---Append current node.
    path = path + [node]

#---base case
    if node == goalNode:
       return path

#---recursive case
    bestPathSoFar = []
    for (child, dist) in graph[node]: # dist is a dummy variable that is never used.
        if child not in path:
           newPath = DFS_FewestNodesPath(child, goalNode, path)           # <-- (*)
           if newPath and (len(newPath) < len(bestPathSoFar) or bestPathSoFar == []):
              bestPathSoFar = newPath

#---Return best path, which could be [].
    return bestPathSoFar

我认为这十行代码体中需要八个注释,因为函数是递归的,对我来说算法很复杂。除非绝对必要,否则工业界尽量避免递归,因为它太难维护了。

下面是来自一个编程竞赛训练手册的建议:“先写注释。如果你不能很容易地写出这些注释,你很可能没有真正理解程序是做什么的。我们发现调试注释比调试程序容易得多。” 1 (我认为通过“注释”他们通过函数调用包括了程序的概要。)不幸的是,在我们理解解决方案之前,我们无法正确地编写注释。当我们发现我们的程序失败时,我们开始理解解决方案,并且我们跟踪代码。我举个例子。我正在编写代码来实现奥赛罗游戏的最小最大决策规则。以下是我的原始注释:

#---Return best board score for white

几天后,我修改了代码,并将注释修改为:

#---Three cases: 1\. Return (usually) the move with the minimum boardScore value (COMPUTER’s choice), or
#                2\. if there is no legal move AND depth is zero, then return
#                   boardScore(), or
#                3\. if there is no legal move AND depth != 0, then return maxValue(depth-1, alpha, beta)

直到我追溯我失败的程序,发现我头脑简单的代码在某些情况下失败,我从来没有想过一方没有合法行动的立场。所以注释和代码一样错误。不过,先写注释可能是个好主意。我只是从来没有尝试过。作者提出了有见地的观察,即“错误倾向于滋生那些太难看而无法阅读或者太聪明而无法理解的代码。” 2 阿门。

下面是一个函数的两个版本,它接收一个列表和一个数字r,然后返回列表的第r个排列。

# VERSION 1.
def permute(Lst, r):
 #--initialize
    Lst = Lst[:]
    L = len(Lst)

 #--check data
    assert L>=1 and r>=0 and r<factorial(L) and \
          type(Lst) == list and type(r)==int

 #--base case
    if L == 1: return Lst

 #--recursive case
    d     = factorial(L-1)
    digit = Lst[r//d]
    Lst.remove(digit)
    return [digit] + permute(Lst, r%d)

# VERSION 2.
def permute(Lst, r):
    Lst = Lst[:]
    L = len(Lst)
    assert L>=1 and r>=0 and r<factorial(L) and \
           type(Lst) == list and type(r)==int
    if L == 1: return Lst
    d     = factorial(L-1)
    digit = Lst[r//d]
    Lst.remove(digit)
return [digit] + permute(Lst, r%d)

我最初写的是版本 1。但是后来我发现这些注释不仅是不必要的,而且使代码更难阅读。那么,为什么我的观点会发生变化呢?回答:我越来越习惯阅读 Python 代码。对于初学者来说必要的注释对于更有经验的程序员来说是不必要的。

一年后,当我需要在Cell类中打印一个矩阵时,下面的第一条注释很有帮助。那时我很少使用类,以至于我不能立即记住调用格式。

#---The call looks like this: Cell.print(matrix)
#   def print(matrix): # DEBUGGING UTILITY: Print the matrix/board to the console.
        . . .  
#--------------------------Cell Class--------------------------

我认为编写将重复的小数转换成分数的算法很难。奇怪的是,这些例子很容易理解。为了让学生程序员明白这一点,下面的代码将同一个例子做了两次:用代数方法和用计算机代码。如果我引入了一个小错误,您可能会在一两分钟内发现它。这是记录良好的代码,但是谁有时间编写这样的注释呢?我的回答是,在特殊情况下,这种细节是必要的。

#      EXAMPLE:
#      Let              x =      12.345676767...
#      Then       100000x = 1234567.676767676...
#      And          1000x =   12345.676767676...
#      So 100000x - 1000x = 1234567 - 12345 =  1222222\. <-- Notice that we can ignore the decimal parts.
#      Thus, x = 1222222/99000

def repeatingDecimalToFraction(number, repLength):
#---Preconditions: number is float type, repLength is integer and 0 < repLength <= length of decimal portion.
    numberCastToString     = str(number)
    decimalPointPosition   = numberCastToString.find('.')
    lengthOfDecimalPortion = len(numberCastToString) - decimalPointPosition - 1

                            # == AN EXAMPLE IS GIVEN TO MAKE THIS ALGORITHM CLEAR. ==
                            # number            = 12.34567 <-- Here, the 67 repeats.
                            # repLength         = 2, the length of 67
    numberlength = len(numberCastToString) # numberlength = 8, the total length
    lengthOfIntegerPart = len(str(int(number)))  # lengthOfIntegerPart = 2, the length of 12
    shiftLength = numberlength - (lengthOfIntegerPart + 1 + repLength) # 1 is for the decimal point.
                            # shiftLength       = 8 - (2 + 1 + 2) = 3, the distance
                            # from the decimal point in 12.34567 to the repeating part (67)
    factor1 = int (10**(shiftLength+repLength))  # factor1   =  100000
    factor2 = int (10**shiftLength)              # factor2 =    1000
    numberTimesFactor1 = int(number * factor1)   #   = 1234567.676767
    numberTimesFactor2  = int(number * factor2)    #   =   12345.676767
    numerator = numberTimesFactor1 - numberTimesFactor2                     #   = 1234567.676767 - 12345.676767= 1222222
    denominator = factor1 - factor2          # = 99000 (= 100000x -                                         1000x = (100000 - 1000)x
    return numerator, denominator      # postcondition: integer types                                                    are returned.

一些程序员会认为这是太多的细节,但对他们来说太多对其他人来说并不算多。如果你仔细观察,你会注意到变量factor1factor2。通常,我们更喜欢描述性更强、不太相似的变量名。我知道这个,但我想不出更好的名字了。

根据人教版 0008,“你应该在句末句号后使用两个空格。”我记得在 1961 年的一次高中打字课上,有人给了我同样的建议。当文字处理软件出现时,一般的建议是在句子之间加一个空格。我不认为两个空格的规则有多大关系。

业界希望每个 Python 函数(docstrings)都有一个注释。这对遗留代码有意义。因为这是世界标准,您可能会考虑养成对复杂函数这样做的习惯。我在互联网上看到过许多命名不当的函数,它们使用了如此缩写的神秘参数,我真希望作者使用了 docstrings。

Footnotes 1

Steven S. Skiena(石溪)和 Miguel A. Revilla(西班牙瓦拉多利斯),《编程挑战》(Springer,2003 年),第 9 页。今天这被称为 CDD(注释驱动开发)。

  2

Steven S. Skiena 和 Miguel A. Revilla,《编程挑战》(Springer,2003 年),第 40 页。