颜林林的个人网站

软件程序结构演变不完全简史

2023-12-30 09:52
题图
(题图由AI生成)

掰起指头(包括手指和脚趾)数,从当年写下第一行“10 PRINT”语句起,如今我也是超过20年“码”龄(指头不够数了)的老人家了。使用过的计算机语言(正经写过相对完整程序的那种)至少包括:BASIC、汇编、PASCAL、C、C++、R、Perl、Python、Javascript(按我的入门时间排序)。

最近Python我用得较多。不仅用于编写流程控制脚本、Notebook中实验ML/DL,还包括利用Django等框架构建网页应用。这个过程中,Python的数据生成器、Javascript的异步调用等特性,都让我产生一个感受:高级编程语言的各种语法,都在不断帮人提高关于软件程序结构的表达能力,使能以更“优雅”的方式,写出更复杂、却更不容易出错的代码来。

基于这些个人经历和理解,我整理了以下软件程序结构演变中的一些关键“发明”:

  1. 三大基本结构:顺序、条件和循环 从一开始学编程,无论接触哪种语言,都不可避免会学习到这三大基本程序结构逻辑。它们是编写任何程序的根基,是梦开始的地方。

顺序执行:程序按代码的顺序执行。

Image

条件执行:根据条件执行不同的代码段。

Image

循环执行:重复执行代码直到条件不再满足。

Image

  1. 子程序:代码复用的开端 随着程序日益复杂和庞大,必然出现大量重复代码。避免重复代码,就成了程序员的日常打怪行为。

子程序提供了一种高效的代码复用方式。通过将代码块独立出来,对其中少量需要改变的变量,以参数的形式暴露出来,让调用者按需加以控制。这极大提升了编程的效率和代码的可维护性。

Image

  1. 面向对象编程:从此有了你我他 很多子程序,经常需要传入相同或相似的变量。于是这些变量会被打包在一起,以诸如结构体的方式出现。这些结构体又往往跟特定的各种函数深度绑定在一起。久而久之,代表数据的结构体,与那些对结构体进行不同操作的函数,就合在一起,衍生出了类和对象的概念。面向对象编程(OOP,object-oriented programming),其实来得挺自然的。

通过对象的封装,有了对外可见或不可见,让代码清晰了许多。避免或降低了外部调用者错误更改了内部变量而不自知、导致诡异错误的可能。

Image

至此,单纯的对象封装,其实还仅是“object-based”(基于对象)的一种重复代码消除手法。直到有了对象的继承,通过继承一个类并定义新的变量和函数,使功能得到扩展,这才支撑起了完整的“object-oriented”(面向对象)的“编程范式”。

Image

  1. 消息驱动:别管其他,听我号令行事就是 消息驱动编程在Windows编程中占据了核心地位。当年,我在开始学习Windows 3.1图形界面编程时,编程思维逻辑就发生了第一次大幅度改变。

为了避免因业务逻辑长时间执行,而导致图形界面卡死,Windows重新定义了一整套所谓“协作式多任务”(cooperative multitasking)的编程框架。值得注意的是,这里的多任务,并非后来的多线程编程,它仍是在单个线程中实现了图形界面和业务的处理,只不过在任务调度上进行了创新。

在协作式多任务环境中,每个程序都需要定期“主动”释放控制权回到操作系统,以便其他程序可以获得执行的机会。这意味着每个应用程序对整个系统的响应性和稳定性负有很大责任。如果一个应用程序没有规律地或者根本不释放控制权,它可以独占CPU,导致系统变得无响应。 在这种模式下,程序员需要在编写应用程序时非常小心,确保应用程序在执行长任务时,能够适时地将控制权让出。这通常通过在代码中适当位置调用特定的系统调用来完成,这些系统调用使得操作系统可以处理其他任务的消息。正是这些问题,后来才有了Windows“抢占式多任务”(preemptive multitasking)的设计,不过这不是今天的主题,就不更多展开了。 下面的例子展示了一个典型的协作式多任务程序结构。它需要一个消息处理循环,作为WinMain函数的主体,贯穿几乎整个程序生命周期。其中的DispatchMessage函数,就会把控制权交还给操作系统。然后,操作系统会在需要时,再通过调用WindowProcedure,仅让程序(或用户)来处理必要的消息即可。

Image

Image

到MFC等大型框架的阶段,连这种消息循环,通常都被封装隐藏起来。这时我们根本无需理解整个程序,按需写出对应消息的处理过程也就够了。

  1. 多线程编程:都别闲着,全部都动起来 多核CPU的出现,让程序的流程变得更加复杂。要重复发挥这些CPU核的计算能力,我们就需要让程序启动多线程。这些线程往往会调用同一套代码,并操作同一份数据内容,这就导致了资源竞争等诸多问题。多线程编程,本质上都是在处理这些资源调度和竞争冲突解决的过程。

以下是一个简单的Python例子,演示两个线程竞争同一资源(在这里是简单的整数变量)时可能出现的问题。

Image

在上述代码中,我们期望shared_resource的值为200,000,因为两个线程各自增加100,000次。然而,由于操作系统线程调度和资源竞争,实际结果可能会小于200,000。这是因为当两个线程几乎同时读取并尝试更新shared_resource时,它们可能会在另一个线程写入新值之前覆盖对方的写入,导致所谓的“丢失更新”。 这个简单的例子揭示了在没有适当的同步机制(如锁)来控制对共享资源的访问时,多线程程序可能会遇到的问题。在实际应用中,解决这类问题通常涉及到使用互斥锁(mutexes)、信号量(semaphores)、条件变量(condition variables)或其他同步工具来确保数据一致性和线程安全。 多线程程序开发是超级大坑,每个细节都可以展开成一篇完整文章,甚至一个系列。这里就暂时不继续了。 6. 数据生成器:反正,一切按我的节奏来 数据生成器(data generator),这绝对是一个优雅的发明!而且,它在Python中,竟然是作为语言的原生语法被提供出来的。

我们在编程处理数据时,数据的获取过程,与数据的处理过程,经常未必是相同的节奏。为了充分协调两者,一个选择是使用多线程技术,比如,可以一个线程专用于数据获取,而另一个(或多个)线程专用于数据处理。但如前所述,多线程带来的资源竞争问题,会大幅增加程序开发和调试的难度。

在Python中,数据生成器提供了一种新的思路,来解决这个问题。如下面的例子所示,我们可以在数据获取(数据提供)端,仍然采用简单的循环处理方式(哪怕它是一个死循环),但程序并不会真的耗费计算资源,去执行这个死循环,而是仅仅按需地、挤牙膏似的,一个个把数据丢出来。

Image

这看起来打乱了程序的执行顺序,但它提供了一种更符合编程者思维习惯的支持,让程序员可以一鼓作气地解决业务问题,而无需考虑数据提供过程,到底采用推或是拉的方式。这大大减轻了编程的负担。

  1. 异步编程:心急如我,岂能等待

在使用JavaScript进行网页或应用程序开发时,异步编程模式是不可或缺的部分。

异步执行意味着当一个函数被调用时,它会立即返回,而不是等待所有操作完成。这种方式使得执行线程可以继续进行,而不必阻塞等待耗时操作,如网络请求或大型文件的读写。这对于维持用户界面的响应性和提高程序性能至关重要。

没有异步编程,每一个IO操作,不论是访问网络资源还是查询本地数据库,都可能导致界面冻结,影响用户体验。JavaScript通过回调函数、Promises、async/await等机制,提供了强大的异步编程支持,允许开发者以更直观和高效的方式管理并发操作和响应结果。

Image

上面这个例子中,fetchUserData函数通过fetch异步获取网络资源。它使用async关键字标记为异步函数,在函数内部使用await暂停函数执行,直到异步操作完成。这种模式使得异步代码看起来像是同步代码,增加了代码的可读性和维护性。同时,由于它不会阻塞,用户界面仍然是可响应的,用户不会遇到不愉快的等待时间。


尽管现今的软件程序结构已经相当成熟,但技术的不断进步总会带来新的变革。未来的程序员们定将在现在的基础上,继续推进软件程序结构的演进,使之更加完善和强大。相应地,我们也应持续学习,不断拓展自己的编程思维。

--- END ---

注:本文首发表于“不靠谱颜论”公众号,并同步至本站。