从Python学习中得到的一点感悟

程序设计语言

  我们经常性的使用语言,比如被我们忽略的人类语言(中文、英文等)。语言作为沟通的工具,它的功用再清楚不过了,不一样的是,程序设计语言是人和计算机之间沟通的工具。
  任何语言的诞生都需要文化,这其中包括计算机。计算机最初提出来的原因是为了解决可计算性,而我们现在普遍使用的计算机构造,就是冯诺依曼计算机。

冯诺依曼计算机有五大部件:控制器、存储器、运算器、输入、输出

  可以说,冯诺依曼计算机的五大部件决定了计算机的文化和程序语言的基本结构。计算机最底层的运行完全依靠高低电平,而高低电平决定了计算机的二进制体系,计算机的存储器存放了数据和指令,而控制器和运算器合作完成指令所表明的具体任务。那么数据和指令就需要在存储器和运算器之间频繁被传输。如下图所示:

  如此看来,计算机指令最频繁的操作就是:将数据从存储器运输到运算器(load)和将数据从运算器运输回存储器(store),这也是汇编语言中最为常见的指令。所以,以冯诺依曼计算机结构设计的程序语言必然有频繁的赋值操作,而频繁赋值操作就会使得存储器(内存)中的变量变得难以维护。所以,我们需要给变量设置一定的访问权限(作用域),以免变量被污染。但是,这只是一定程度上解决了副作用的问题。
  我们现在设想一下,假如让一段程序在计算机中运行,我们需要为它做什么。首先,我们需要将该段可执行的代码(指令)加载到内存空间中,即代码区,并指出指令开始的地方在哪里;同时,我们需要为程序运行设置一个环境,用来维护程序运行过程中每一时刻变量的变化,这就是最为经典的x86运行时栈结构。因为栈结构对变量的大小要求特别严格,所以,我们需要为某些不知道变量大小或者我们可以自由控制变量空间的变量分配内存,这就是堆。当然,我们还需要维护一些程序运行过程中的全局/静态变量。于是,我们便可以得到程序在内存中执行过程时的分配情况,如下图:

  代码区我们不需要操心,会有一条指针(寄存器PC(Programming count))指向当前指令,每执行完一条指令PC自动加一。两个数据区也不需要我们操心,在程序执行前,它们已经进来了。
  堆区是供程序员进行动态分配使用的内存区,具体使用可以参看《操作系统》中内存管理的章节。堆区是用链表进行维护的,如图,假设我们需要申请2M大小的内存,其实其真正分配给我们的内存要大于2M,而前面的部分我们叫做元信息,其中存放了关于这块内存的信息(大小),以及链表指针。

  而对于程序员来说,最为操心的部分,应该是栈区,因为我们所见的程序,最后都是在栈区中运行的。Python虚拟机实现了虚拟栈区,所以才叫虚拟机,Python中在虚拟机中执行的代码叫字节码(bytecode),正如x86栈中执行的代码--汇编。

  栈--在数据结构中定义为一种后进先出的线性结构。我们看一下C语言程序是如何在栈中执行的。为了讲述方便,我们写一段简单的C语言代码:

int addOne(int a) {
    int m = 0;  //加一个局部变量
    m = a+1;
    return m;
}

int main(void) {
    i = 10;
    addOne(i);
    return 0;
}

  C语言程序执行的入口是main()函数。所以,当这段可执行代码被加载到内存中的时候,毫无疑问,寄存器PC指向了main()函数的起始位置。在程序开始执行之前,栈为空,所以,栈顶指针和栈底指针重合。我们从i=10开始执行:在栈中分配一个整型大小的空间,栈顶指针:SP = SP+4

  接下来执行函数:addOne(i):如果要执行函数就要知道函数在栈空间中分配多大的内存,所以,C语言函数需要在使用函数之前进行声明(定义可以放在使用之后,如果上段代码,我将addOne()函数的定义放在main()函数之后,就会报错)。

函数声明的作用就是告诉栈空间:我需要多大的内存执行该函数。
一个函数的栈空间分为三部分:参数列表区、局部变量区、保存指针区

  • 参数列表区:函数定义中的参数列表;
  • 保存指针区:当函数执行完以后,需要知道下一条指令的位置
  • 局部变量区:函数定义中的局部变量;

  所以,addOne(i)执行的栈空间为:

  由此可以看出:

  • 函数在参数列表区赋值了i的值,所以,函数的执行不会改变原来i的值,即传值方式为值传递;
  • 参数列表是倒序压入栈中的,为的是访问方便和实现可变参数列表;
  • 保存指针区保留了下一条指令的位置;
  • 局部变量区和参数列表区是分开存放的;
  • 当函数执行完成后,三部分都被清空,PC拿到保存的下一条指令地址,继续向下执行,而SP指针则回到i的位置

  既然我们知道了函数在栈中的分布情况,我们是不是可以更改其中的保存指针呢?如下代码,通过数组下标越界来访问到PC,同时将PC的值减去4(一条指令的长度),即回退到函数执行的位置,此程序将陷入死循环。(如果我们把第五行和第四行的代码互换,又会出现什么情况呢?)

#include <stdio.h>

int foo() {
    int a[5];
    int i;
    for(i=0;i<6;i++) {  
        a[i] = a[i]-4;
        printf("loop");
    }
} 

int main(void) {
    foo();
}

  当然关于x86运行时栈,我们还有很多需要学习的地方。不论如何,我们需要明白其运行的基本原理,这样理解Python的源码就比较容易了。

编程范式的问题

在编程设计语言发展的过程中,出现了很多方法学以及由此产生的新概念。但,我把所有的这些新概念都归结为语法、语义层面的表述。比如Python中出现的类,要知道C语言是不支持类和对象,但Python的出发点就是一切皆对象,原因是C语言从语法上实现了Python中类和对象的概念。

  我们在学习语言的时候,经常遇到关于编程范式的问题。那么,最为常见的编程范式我认为有三类:

  • 面向过程
  • 面向对象
  • 函数式编程

  面向过程和面向对象同出一支,都是从冯诺依曼体系中层层抽象上来的。在解决可计算性问题的时候,除了冯诺依曼提出的方案,还有一个人提出了一种方案:lambda演算,它应该就是函数式编程的起源。函数式编程最后还要依靠传统的语言来解释。所以,我想函数式编程也是语义层次的一种抽象方式。

  在面向对象中出现了很多新的概念,比如元类、基类、继承、多态等等,但我想这些都是语义层次的概念。