本文对之前 向量对应 numpy 数组为例,进行进一步说明,介绍一种 Python 程序设计的一般流程。程序设计流程,不得不承认这是一个大问题,专业的讨论也已经很多了,但是,当我们聚焦在某些具体问题上时,其实可以简化出一个大致的流程。
程序设计并非是一个简单的任务。一个优秀程序里,各种学科的知识都在里面,特别那些能解决实际问题的程序,比如:当下很火的工业软件,这是我们选择从数理知识出发,逐步介绍构建py开源软件的原因。与其他设计过程相比,程序设计最大难点在于:抽象性。在经验中,“看着费劲,摸又摸不着,怎么也想不明白”这样的抱怨,经常遇到。没有抽象性,又如何融入不同学科的技术和工具?抽象性,使得程序开发过程,也成为某种创新的过程,这本身就是难事儿。因此,如果有一个大致的流程,或许会有利于那些敢于又善于创新的大侠们,尽快写出自己的好程序。熟练本文方法,你会发现也没什么可怕的。
程序设计可以分为两个阶段,即:问题求解阶段和实现阶段。对于小问题,可以一次搞定。对于大问题,要分成同构的多个流程,特别是要利用关注点分离的思维方式。问题求解阶段和实现阶段的关系,可以如下图1:
在问题求解阶段,首先需要根据问题定义找到解决问题的方案(也称为:方法、业务逻辑或算法),通常以人类语言来进行描述,但用算法逻辑来组织。等到了实现阶段,才真正将问题求解转化成编程语言,或者支撑这个问题求解的软硬件系统。虽然这个流程非常理想化,但真实开发流程,也不过是该流程,在某个工作团体习惯或场景中的具体应用。
问题求解第一步是定义问题,需要对所求问题进行分析,这一步首先要确定问题的输入和输出。注意,这一步容易和定义问题的前条件和后条件搞混。本文所述的输入往往是指的输入的数据的类型和格式,而输出往往是确定程序输出的数据类型和格式,而前条件和后条件是这个程序的逻辑断言,实际是一个数学概念。
提示:常见的输入、输出分别是前条件和后条件的子集。
确定输入和输出有很多小技巧,例如,之前我们聊过的位矢计算模拟程序,例题描述已经给出了输入: t = [ 0 s , 15 s ] t = [ 0 s , 15 s ] t=[0s,15s], 但是,没有给出输出,因此,您就需要决定一个 。在那篇博文里,我们想聊 numpy 在 jupyterlab 中的情况,自然而然就想到了 pandas 的 Dataframe 作为输出格式。实际上,也可以决定使用 print 函数或者输出到文件。这一步关键在于目标,如果要写一个互联网应用,那么输入就可能使 json 或 xml 格式,输出则需要根据这个应用的实际需求而确定。再如果您想写一个科学计算或数据处理程序,那么输入和输出可能完全由采集设备或视觉展示设备的接口规约所确定,但必须要首先明确。
紧接着,就需要明确方法,这就开始难了。在计算机领域,为解决问题制定的一系列有限多条指令集,定义为:算法(algorithm),有时也叫方法、指示、过程或者事务等等。很多新手总被这个名称吓住,最后就忽略了这个过程,其结果是折腾了很久,却始终在对一个简单程序里重复查错和努力找 bug 。实际上,这个算法设计过程并没有那么复杂,而且在非常简单的程序中也有存在,以位矢计算模拟程序 为例,这个程序的方法设计可以为:
procedure 兔子位矢计算(t):
输入: 一个表示0到15s时间间隔的数组,这个数组由不同时刻的位矢坐标值组成
输出:不同时刻下位矢、位矢大小和位矢角度值的数据表
positions:= 某种数组
for t:= 0 to 15
begin
positions[t]:= r(t) # r(t) 是位矢函数
end
output(positions)
在这里,我们采用了 pascal 风格的伪代码来描述这个算法,以保证博文表达的无歧义。初学者可能读起来不熟悉,那就忽略掉。
这一步,其实就是在纸上一步步按照自己的理解,写出期望的计算机处理步骤。 例如:先算出一个位矢,再步进这个运算,这句话就可以算一个算法设计。经验上,等有了一定原理和技术细节的积累,大多数人都会找到合适自己的算法设计思路。实际上,就算不知道什么是步进,用其他方式也能搞对结果,但设计方法过程不应该以初学而省略。多次重复这个步骤,你就会发现一个叫:逻辑的东西,显性的摆在你面前。实际上,程序与逻辑相关的问题,才是最大的难点。
提示:如果您已经有相关专业要求,应该以评价者的要求为准。
在方法设计时,我们建议采用伪代码的形式化的设计风格,是防止遇到复杂的程序时,写了几天就看不懂了或者和他人沟通时产生歧义,特别是后期进行算法分析和优化,相对规范的形式化就太有利了,再或者发现了牛逼的算法准备发个学术期刊,那为什么要写两遍呢?但是,在算法设计这个阶段,形式化确实不是最重要的,在这一个步骤,明确如何利用计算机来解决问题的步骤,才是关键。
最后,我们要对这个算法进行一个简单的理论测试,这一般都是数学过程,可能非常简单,也可能非常困难,一般利用程序正确性证明的方法,这篇文章先不展开了。因为,像位矢计算模拟程序这样的小程序,就在纸上算一算就可以了,其逻辑过程可以为:
r:= 兔子在n时刻的位移是r(n)函数
S:=
r(0) -> True
(r(k) -> r(k+1)) ->True
所以,程序终止时,r(n) 一定算对了。
如上所示,这一步其实也并不轻松,至少得了解程序语言为您工作的基本原理,具备相对扎实的数理基础。因此,建议初学者一定要到正规教育机构去学习《计算机及编程语言》这些相对底层的课程,有时间就学学相关数理知识。对着电脑狂敲键盘或狂加班,肯定不如去认真读读圣贤书来的轻松。总之,在这一步,我们认为是可以忽略语言和技术的实现细节的,但要重视将问题定义和方法步骤的构建,并且理论测试通过。
提示:以自己或团队熟悉的语言,定义问题和描述算法更有利于开发。
到了实现阶段,也不一定是轻松的事情,我们选择 python 撰写博文代码,就是因为想尽可能的简化实现的复杂度,毕竟 python 语言的易用易学已得到了大量证明。那么实现 Guido van Rossum 所说的: Have your cake and eat it, too: Productivity and readable code,就一定是相对容易的事情。
在这个阶段,最主要的就是熟悉所使用的语言,掌握其语法和内置数据结构。需要掌握多少语法?对不起,这个答案是:全部基本语法(不包括语言中内置库的语言)。好在 python 的语法非常简单,一般英文水平就搓搓有余了。这个阶段要大胆的去 run 一下。因为这方面的优秀内容已经很多了,比如:首页 - 廖雪峰的官方网站 (liaoxuefeng.com),就不再重复。这篇博文里我们着重想聊的,还是程序设计两个阶段(图 1)怎么使用的问题。在本文的实例解释章节中,将会给出本文设计过程的实例解释。
很不幸的是,当遇到问题时,这两个过程,都不能依靠某种程序去自动化的完成,如果您听说过 CS 学科里著名的停机问题,您就会知道这个愿望的实现会有多么遥远。但是,常见的错误无非就两种:
运行时错误:这类错误,大概率会有系统或py解析器的提示,也是最常见的。主要出现在方法转语言的过程中。
逻辑错误:这类错误,通常不会有任何报错,但程序运行完毕,不会得到想要的结果,是相对麻烦的。如果代码静态检查没有问题,那就一定在问题求解阶段。
为了保证编写程序的正确性,程序任何一个运行时的问题,都是要开发人员去判断的。积累不够时,可以采用排除法,结合一定的调试技术,比如:print 一下或者加个断点。python 语言提供了逐行或逐段运行的功能,一行行、一段段的排除和分析,很快可以找出是实现还是问题求解的问题。积累够的时候,您一定会找到更好的办法。
提示: 一定要杜绝随机更改代码,进行查错的方法。对于错误要不带“偏见”的分析和排除问题。
图 1 给出的程序设计两个阶段,其实就是面向过程的设计流程。虽然面向过程并非总是最有效的,但依然是应用最为广泛的程序设计思想,相对容易做对。在充分熟练的掌握了 POP 思想的代码设计流程以后,就应该大胆的学习和掌握 OOP(面向对象)的设计和应用。
在面向过程设计时,最重要的就是在问题求解阶段,给出并证明方法设计的正确性。
在面向对象 OOP 中,所有的程序都会被视为一系列对象的某种集合,这里的集合就是数学里定义的集合(aggregate 或 set),高级的也可以在《范畴论》中找到相关模型。让这些对象进行合理相互沟通和联系,构建这些对象间的关系,就是面向对象的设计内容。
例如,您需要写一个物流的调度程序,那么调度就可能是一类软件对象,描述物流控制货物的各种信息也就可能是一类软件对象,离线调度的计算方法也可能是一类软件对象,当然,实时调度的计算方法也可能是一类软件对象,等等。然后,你就会发现,嘿,这个世界为什么很多事物和业务,都“粘”在一起,合理的切分这么有挑战,这个时候,你就是在 OOP 了。
拟人化是 OOP 设计最主要的思路,其实也可以是 POP 的思路,比如,当下热议的贫血代码和充血代码,说白了,就是写出的“拟人化”的代码或者代码形式上要像人类语言。请注意,OOP 与 POP 和 面向服务(SOA) 相互之间并不冲突。在工作场景中,大多数情况,这些风格也是上一级的程序员或架构师确定的,新手是定不了的。但如果您要使用 OOP,那么只需要将面向过程设计流程图中的方法设计替换成设计软件对象及算法(方法) 设计即可,如下图:
OOP 最重要的作用在于信息隐藏,使用 numpy 就是一个典型的 POP 和 OOP 的混用,计算流程是面向过程的,而软件对象都是面向对象设计的,有意思的地方在于,只要一关注问题应该如何解决,他们就不分彼此。
计算机语言符合数学逻辑中的命题的代入和替换定理。这就是说,只要你问题解决搞对了,哪怕你还不希望自己看懂代码,弄个代码混淆也不会影响程序运行效果,所以,关注问题解决才是上策,等问题解决以后,再逐步考虑提高编码和书写水平。在工作场景中,就得看具体要求了。
以 numpy 的应用为例,由于信息隐藏,就不需要关心 numpy 或相关包是怎么实现的这些技术细节了,只需要导入这些包,按照说明书给出的用户接口调用即可。对于 numpy 的绝对大多数程序设计,只要知道他是个数组,以及怎么调用这个 np 数组就好,复杂的物理方法很容易转化为易读的代码。只有当您需要了解内部运行原理或对其进行扩展等深层次需求时,才会有可能需要进入其源代码。
提示:当已经掌握了一些软件对象模型以后,使用 OOP 思想进行设计,也要首先搞定关注问题求解的问题定义和方法设计,同样,也需要进行理论验证,最后再进入实现过程。
但实际情况是,OOP 相对 POP 抽象太多,OOP 主流的设计规范又是 SOLID 原则和软件模式,查了下,普遍是硕士生以上的CS专业课程,可能是因为这些原则每个字母后面都是一篇高水平学术论文的原因,相对热门和高价值一些的就是测试驱动、领域驱动等企业软件设计方法。所以,对于新手,一上来就设计 OOP 的软件对象,弄出一大堆独创性且复杂的软件层级、关系和对象,绝对不是最优选择。要不然 C 语言、Matlab 为什么那么流行?Java、C Charp 为什么那么专业?
在后期的博文中,我们也想给出一些 python 的例子逐步介绍,这里就点到为止。
以之前的位矢计算模拟程序来举例说明这个一般流程,对于兔子位矢计算(t)的问题求解过程在前面已经给出。观察一下,立刻就可确定这个程序里一定会有一个数组,一个函数和一个循环。由于已经选型采用 numpy,而且,numpy 自身的特点就是可以减少显性的循环,于是,我们就开始查阅 numpy 的数组说明或者找到一篇描述向量怎么转化成 numpy 数组的文章看一看,找一找怎么使用 numpy 用户接口为我们服务,通常都会发现很多精彩的例子。这时候,要选定一种方案并坚持写完。
根据本文的流程,可以“机械式”的翻译一下位矢的数学函数,再用一个 py 的推导式 (comprehension syntax) 语法实现循环,然后,再把这个设计的计算结果存到 numpy 中。为了使得我们的博文代码看起来更像原始的物理公式且多提到一个 numpy 常用的软件包,我们决定使用了 pandas,这就形成了如下代码:
import numpy as np
def r(t) -> tuple[float, float]:
"""计算t时刻位置矢量。
前条件:t 为某时刻值。
后条件: x,y 为位置矢量的坐标值。
"""
x = -0.31 * t**2 + 7.2*t + 28
y = 0.22 * t**2 - 9.1*t + 30
return x, y
def timestep_pos(n, t0, dt) -> np.ndarray:
"""计算兔子在n个时刻的位移向量
前条件:n 为时刻的总步长,t0 为初始时刻,dt 为时间增加步长
后条件: 利用 np.ndarray 记录每个时刻的位矢变化
"""
return np.array(
[r(t0+i*dt) for i in range(n+1)], dtype='float16'
)
假如想让这些代码变得更具有工程价值,那么只需要,将本文所提到的程序设计流程再走一次,改变一下问题定义,再到实现层做出一点点的改进,然后勇敢的删除(且存档)不需要的代码。也就是所谓的代码重构和赋能思路操作。因为,之前博文的需求是想现实一个 Jupterlab 的交互程序,重构的结果可能会看来如下:
def simulate_rabbit_position(t1):
"""模拟位矢变化的实验
"""
t0 = 0
dt = 1
n = int((t1-t0)/dt)
return timestep_pos(n, t0, dt)
如上,这就实现了一个相对完整模拟程序的快速开发,可以对任意大于等于 0 时刻的位矢进行模拟。至于下一步,是做采用什么形式输出,那取决于这个程序的需求,在实现层,通常是由用户用例的规约给出。
面对可能的需求变化,比如,需要绘制个兔子与起点的距离变化,再或者绘制个矢量角度变化图,只需要增加几行同样结构的代码即可。增加一个函数,就可以如下代码:
def simulate_rabbit_distance_v1(t1,t0=0,dt=1):
"""模拟实验: 兔子与起点的距离变化
"""
n = int((t1-t0)/dt)
pos = timestep_pos(n, t0, dt)
x = pos[:, 0]
y = pos[:, 1]
return np.sqrt(x**2+y**2)
def simulate_rabbit_distance_v2(t1,t0=0,dt=1):
"""模拟实验: 兔子与起点的距离变化
"""
n = int((t1-t0)/dt)
pos = timestep_pos(n, t0, dt)
return np.linalg.norm(pos,axis =1)
实际上,经过本文这个看起来“机械”的设计流程,就已经套入了不太严格的 SOLID 原则。没错,不用复杂用例和 Class,依然可以套入高级设计方法,这就是 CS 科学理论的巧妙之处。首先,这几个函数显然是低耦合又高内聚的,位矢还是可以满足任意空间维度的;同时,每一个函数都只有一个修改目的;代码只对增加开放,对修改肯定是封闭的;接口设计也是 ISP,依赖还是倒置的,因为不用 numpy 数组这个基础设施也是可以的。最后,如果你熟悉其他语言的语法和数据结构,把代码翻译到其他语言也是 ok 的。
提示:经过设计的优良代码,总会伴随开发过程不断迭代。越是专业的问题解决,生命期往往就越长。
当然,利用 np 的向量,取消掉 r ( t ) r(t) r(t)中的显式循环也很容易,我们这里是想表达出步进这个过程,使其看来更像个人话。后期,如需加上贴图,搞搞计算机图形学或再加一些简单的交互设计,这几段小代码可以马上变成一个 py 游戏的核心。另一方面,通过对 matplotlib 的应用,至少可以在物理课上,做一个炫酷的演示课件了。有兴趣的话可以试试,正好感受下科学和实际问题的联系有多紧密。可见,程序设计要考虑的事情很多,这些都需要理智和知识积累,而不是激情和经验。
至于有关 numpy 优化的话题,我觉得其实属于算法效率和函数增长这块的问题,后面有时间再举例说明。
本文介绍了一种 python 程序设计的简化版设计流程,给出了在面向过程和面向对象思想下的使用方法。简单说,这个思路的程序设计可以由以下四个“黑盒”组成:
问题定义
算法设计或(对象设计 and 算法设计)
转换成语言
测试
设计难点就是,开发者要把这些黑盒变成白盒。认真阅读和学习程序设计时,特别是那些已经实证 ok 的程序,总能找到这四个黑盒。本文的目的,也就仅仅是前言的所述的字面意思。而且,这个方法是有 bug 的,就是直观上是不能“直扑目标”的。就如 Savitch 所说:你是想进行半天有条不紊的工作,就设计出一系列完善的代码,还是想着进行几天令人窒息的工作,对一个难以理解的程序进行排错呢?当然,如果您已经非常习惯用成百上千行代码解决一个小问题,或者一开始学习就立下一个小目标,想着学习几个月就写个几万行,那么这个流程就不适合您。您可能没有时间去思考和了解程序设计的问题求解过程。在本文中,我们推荐您采用自顶向下(也叫:分而治之)的设计+编码工作模式,多以该语言设计者和库开发者的一手文档作为参考资料,掌握其设计目的,再进行应用。少在名词相关问题上徘徊,多在本文介绍的两个阶段,不断完善。要不然,您觉得,我们自己哪儿来的时间,叨叨了这么多:)
如果以上代码对您有一定的益处,请多多点赞;若有不同意见,欢迎来怼:)
Savitch W J, Mock K. Problem solving with C++[M]. Ninth edition. Boston: Pearson, 2015. ↩︎