本文系统回顾了编程范型的发展,从早期的非结构化程序到命令式、过程式、面向对象与函数式编程,探讨它们在抽象层次、控制流与数据组织方式上的差异,并进一步分析声明式与命令式编程的边界问题及其在现代计算机体系结构下的重新审视。
早期的程序没有所谓编程范型的概念,是非结构化的(即没有“子程序”的概念,也无法完成选择、跳转等概念,Harvard Mark I的循环语句甚至需要纸条首尾相接完成),程序员将记载有一系列计算指令的纸带输入计算机,计算机无休止地顺序执行机器指令,直至得到最终的结果。
命令式编程(Imperative programming)作为一种编程范型,一般与声明式编程(Declarative programming)相对立,强调通过特定的指令顺序(即控制流)执行程序,要求程序指令的执行顺序被规定,编程者不仅(从动机上)要求程序达到自己想要的效果,还要求程序“按照自己想要的方式”达成它。
如果按传统的计算机组成原理课程惯例,即,以指令集体系结构(Instruction Set Architecture,ISA)为软硬件的分界线,那么从“根本”上可以讲,计算机硬件“底层”支持的仍然是命令式的编程。无论高级语言如何使用循环、分支,乃至多态等特性,在机器码的“底层”他们都会被还原为一系列非常“明确”的寄存器操作。(这里打引号的原因见第4节)
过程式编程(Procedural programming)是一种归类为命令式编程的编程范式,将程序的过程进行模块化(Modularity),将最终的程序目的拆分为一系列的子目的,并将子目的组织成单独的模块(如function、method)。过程在这里根本上是可以通过调用进行复用的代码片段,节省了开发成本也提高了可读性。
面向对象(Object-Oriented,OO)程序可以看成一系列对象(Object)的交互,而不是传统的过程式编程(procedural programing)那样,按照顺序去执行一系列的任务。
对象和消息(message)是OOP的核心。在理想情况下,OOP中的每一个对象都可以被看做不同角色或承担了不同指责的功能单元。每一个对象都有能力接受、处理和向其它对象发送消息,对象的方法(method)则与对象的数据(attribute,不过这里和property的用法很混乱)紧密绑定。
有一些工具直接内置了这个功能,比如QT的消息和槽(slot)机制,在编译时加入功能代码,直接在用户态下实现不同类之间通用的消息交流机制,Linux中epoll则是由操作系统帮助完成的。
当今主流的OOP是基于类风格的(class-based style)OO实现的,对象被进一步抽象成了类(class)的实例,主流的语言如Java,而另一个选择是基于原型风格的(prototype-based style)OO,例子有JavaScript:
JavaScript// 创建一个原型对象
const animal = {
type: 'animal',
eat() {
console.log('Eating...');
}
};
// 基于animal原型创建一个新的对象
const dog = Object.create(animal);
dog.bark = function() {
console.log('Woof!');
};
dog.eat(); // 输出 'Eating...' - 继承自animal
dog.bark(); // 输出 'Woof!' - dog自己的方法
用C++来理解,就是类的实例化也可以被继承——不过这个说法就太倒反天罡了,硬要说的话,把继承限定在类才是显得更加不自然的那一边。
OOP常具有的特性有:数据抽象(调整对象的“颗粒度”)、封装(使对象的独立性不被破坏)、消息(对象之间信息交换的机制)、组件化(工程上的需要,降低模块耦合)、多态(不同实体提供统一接口,单一符号表示不同类型)和继承(提高开发效率,复用相似对象的代码)。需要注意的是,这些特性都并非OOP的必要组成部分,而更像OOP实现自身设计理念而选择的功能。
TODO: 具体解释OOP的常见特性
即使不使用这些特性,也可以实现OOP,比如用C模拟类的功能:
Cstruct foo {
int (*bar)(struct foo *this, int a, int b); // 函数指针
};
void baz() {
struct foo *ptr = get_object();
ptr->bar(ptr, 3, 4);
// 等效于C++: ptr->bar(3, 4)
}
动态绑定也可以实现,定义一个的struct作为虚函数表即可:
Cstruct object_header {
void **vptr;
};
struct foo {
struct object_header header;
...
};
void baz {
struct foo *ptr = get_object();
// ptr->bar(3, 4), dynamic binding
// INDEX_OF_BAR在编译时由编译器确定
(int (*)(void *, int, int)) (ptr->header.vptr[INDEX_OF_BAR]) (ptr, 3, 4);
}
函数式编程(functional programming)是声明式编程的一种,程序被视为函数应用(function application)和合成函数(function composition)的组合,每一个函数被定义为一个表达式树(expression tree),而不是命令式编程中的指令集合。
表达式树是一种对函数组织形式的比喻,其中根节点可以理解为“最终”的计算目标,而其子节点相当于被“拆解”出的任务。需要注意的是,这里的“拆解”是严格符合数学上的复合函数的意义的,而不是利用函数副作用的命令式指令集合。
理想中的纯函数式编程(purely functional programming)是0副作用的,即所有的函数对一切的“同样意义的”(并非返回数值的相同,而是指表达式指代的含义/对象)的输入总有相同的输出,即引用透明性(reference transparency),这使得函数式编程中开发者可以相对自由地通过语义逻辑(或者更准确地说,代数)的方式对函数进行自由组合,而不用考虑局部的具体实现。
纯函数式编程的问题在于无法处理需要操作和处理状态(比如,来自计算机外部的网络信号)的情况,除非将整个物质世界都纳入到函数定义域中。如果要在保持纯函数编程的前提下处理副作用,需要引入单子(Monad)限制副作用发生的位置
TODO:纯函数式编程下的异常处理
函数式编程的“许愿编程法”可以将最终的计算目标拆解成一系列显而易见、逻辑清晰的子任务。在初次了解时,我是将其当做过程式编程中一种编写模块的分析方式来看待的,因为在我看来,计算机的“根本”ISA仍然是命令式的,拆解到最后的函数还是要还原为具体的计算机指令,函数式编程有点“脱裤子放屁”了。
在了解DBMS时,我就对SQL这种声明式语言执行优化的复杂度感到深深的畏惧,并且产生了“为什么不让用户直接指定自己要什么数据”的困惑。
但是我其实大大高估了程序员的“命令”能力:不光高级程序语言会被编译器优化的面目全非,连汇编指令都会被CPU的现代化流水线设计打散地乱七八糟。例如在条件中预测、将指令拆成更细小的微结构、通过预存取加速IO操作等,比起一条条地执行指令,CPU更像是通过“命令”来猜程序员的“声明”是什么,最终再反过来保证程序员的每一条“命令”都能得到完美执行。
开启优化的编译器不会按C的指示那样写汇编,CPU也不会真的按编译器的指令一条条执行。从这个角度来看,命令式编程倒更像”脱裤子放屁”了——既然CPU比我都更知道我想干什么,那我为什么不直接告诉CPU我要干什么呢?还真有,之后就是面向硬件编程的领域了(SIMD)。
了解到这一点后,一些以前的困惑也就解开了:为什么总有人强调不要用C的指针去理解计算机内存结构?为什么不要用C的“实现”来理解C++(以及其他更加抽象的程序语言)提供的高级功能?——因为只要是高级语言,彼此之间就不是“实现”关系,设计程序语言需要关心的是编译器的实现可行性及其在CPU上的运行性能。
不能说这个高级语言的高阶抽象特性,就是另一个更加“底层”的语言的实现。它们是完全不一样的,这样的错觉很大原因是硬背C语言规则来入门编程,以及对C产生的“底层崇拜”导致的。
从这个角度,倒是能理解一些资深程序员为什么推荐SICP或者Python作为入门了。要么彻底地了解程序的运行,要么彻底地把编程语言作为一种功能的集成,都比起学C带来错误的“底层崇拜”要强。
2024/8/10
本文作者:Ever97
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!