语言设计 🎨

定义

程序设计语言是一组规则的集合,可以看作 语言 = 语法 + 语义

  • 字母表的定义
  • 词法规则:单词符号的形成规则,比如关键字,标识符,运算符,常量,分界符
  • 语法规则:语法单位的形成规则,比如表达式,语句,函数,程序
  • 语义规则:包括但此符号和语法单位的含义规则;
  • 语用规则:语义规则的发展和延申;
  • 其他规则:包括类型使用规则,参数传递规则,作用域规则;

历史发展

  • 第一代语言 (1GL) 🤖: 机器语言 (Machine Language)
    • 直接由计算机硬件执行的二进制指令。
  • 第二代语言 (2GL) 🛠️: 汇编语言 (Assembly Language)
    • 使用助记符表示机器指令,需要汇编器转换为机器语言。
  • 第三代语言 (3GL) 🚀: 高级程序设计语言 (High-Level Languages)
    • C, C++, Java, Python, Fortran, COBOL。更接近人类语言,具有更强的抽象能力,可移植性更好。
    • 包括命令式和过程式
  • 第四代语言 (4GL) 📊: 为特定应用领域设计的语言 (Domain-Specific Languages - DSLs)
    • SQL (数据库查询), MATLAB (数值计算)。通常用于特定问题领域,提供更高层次的抽象。
    • 包括说明性语言,超高级语言
  • 第五代语言 (5GL) 🧠: (概念较模糊,通常指用于人工智能和逻辑编程的语言,如 Prolog,或基于约束求解的语言)
    • 包括函数式,逻辑式语言

范式分类

  • 强制式语言 (Imperative Languages) ➡️:

    • 描述计算过程如何执行,通过一系列改变程序状态的命令来完成任务。
    • C, C++, Java, Pascal, Fortran
  • 声明式语言 (Declarative Languages) 📜:

    • 描述计算应该做什么,而不是如何做。程序的执行逻辑由语言的解释器或编译器负责。
    • 子范式包括:
      • 函数式语言 (Functional Languages) λ: 如 Haskell, Lisp, Scheme, F#, Scala。计算被视为数学函数的求值,强调无副作用和不可变性。
      • 逻辑式语言 (Logic Languages) 💡: 如 Prolog。基于形式逻辑规则进行推理。
      • 数据流语言 (Dataflow Languages): 如部分响应式编程框架。
  • 冯诺依曼语言 (Von Neumann Languages) 🏛️:

    • 程序设计语言的计算模型基于冯诺依曼计算机体系结构(即,指令和数据存储在同一内存中,CPU按顺序执行指令)。大多数强制式语言属于此类。
    • 在命令式语言上的表现为:变量,赋值。重复
  • 面向对象语言 (Object-Oriented Languages) 🧩:

    • 基于"对象"概念进行编程,对象包含数据(属性)和操作数据的代码(方法)。支持封装、继承、多态。
    • Java, C++, Python, Ruby, Smalltalk
  • 脚本语言 (Scripting Languages) 🐍:

    • 通常是解释执行的,语法相对简单,用于编写"脚本"来自动化任务、快速原型开发或作为大型应用的扩展语言。
    • Python, JavaScript, Perl, Ruby, PHP, Shell
  • 逻辑式语言

    • 数理逻辑,谓词演算
  • 对象式语言

    • 抽象数据类型

语言设计实践

静态/动态

静态策略 (Static Strategy) 🧊: 指语言的某个方面或问题在编译时刻 (compile time) 就可以确定或处理。

  • 例如:C语言的变量类型是静态确定的(静态类型检查),大多数内存分配(全局/静态变量)在编译时规划。

动态策略 (Dynamic Strategy) 🔥: 指语言的某个方面或问题必须等到运行时刻 (run time) 才能确定或处理。例如:C语言中的动态内存分配 (malloc, free),某些面向对象语言中的方法动态分派 (dynamic dispatch)。

C语言主要是一种静态类型的编译型语言,但它也提供了一些动态特性(如 void* 指针的灵活性,动态内存分配)。

绑定(Binding)在编译时完成,且在运行时不会改变,则成为静态绑定,若绑定在运行时完成,则成为动态绑定;

  • 语言实现采用编译还是解释方式,强相关于变量与类型绑定规则;
  • 静态绑定语言是面向编译的语言
  • 动态绑定语言是面向解释的语言

变量/赋值

变量是对若干个存储单元的抽象,用名字来标识;

  • 一个存储单元至少有一个字节;一个变量至少占用一个存储单元;
  • 作用域:可绑定静态作用域或动态作用域,依此划分为全局变量,局部变量,非局部变量;
  • 生存期:变量绑定于存储区的时间区间,分配指变量获得存储区,变量长度为变量对应存储单元的个数;
    • 全局变量静态分配;
    • 局部变量和非局部变量可静态分配和动态分配(自动、显式请求);
    • 匿名变量:通过指针实现访问,属于动态分配
  • 值:存储区的内容,二进制编码;
  • 类型:变量值按照所绑定的类型解释;

赋值是对修改存储单元内容的抽象;

初始化问题:不同语言规定不同;

环境/状态

环境 (Environment) 🗺️: 在程序执行的某一点,环境是一个从名字 (identifier)存储位置 (storage location/address) 的映射。它定义了哪些名字是可见的以及它们所代表的实体(如变量、函数)。

状态 (State) 🔄: 在程序执行的某一点,状态是一个从存储位置 (storage location/address) 到其值 (value) 的映射。它反映了程序当前内存中存储的数据。

环境将名字映射到左值 (L-values) (表示位置),状态将左值映射到其对应的右值 (R-values) (表示内容)。

静态作用域/块 (Static Scoping / Blocks)

静态作用域 (Static Scoping / Lexical Scoping) 📖:

  • 一个声明的作用域(即该声明有效的程序文本区域)由其在源代码中的位置决定,可以在编译时通过分析程序文本来确定。
  • C语言采用静态作用域。当引用一个名字时,编译器会查找包含该引用的最内层词法块中的声明,然后是外层块,直至全局作用域。

块 (Block) {}:

在C语言中,一对花括号 { ... } 定义了一个块。在块内声明的变量(局部变量)其作用域仅限于该块及其内部嵌套的块,从声明点开始到块结束。

动态作用域 (Dynamic Scoping)📞

  • 一个名字的引用会解析到程序执行时调用栈 (call stack) 上最近的、活动的该名字的声明。
  • 作用域取决于函数调用的顺序,而不是它们在源代码中的词法位置。
  • C语言不使用动态作用域。一些早期的Lisp方言、Perl(通过 local 关键字)曾使用或支持动态作用域。动态作用域通常会使程序更难理解和维护。

显式访问控制 (Explicit Access Control) 🛡️

  • (此概念主要用于支持面向对象的语言如 C++, Java,C语言本身对结构体成员没有显式访问控制关键字)
  • 在C++等语言中,关键字 public, private, protected 用于控制类成员的访问权限,以实现封装 (encapsulation)
    • 私有 (private): 成员只能被其所在的类的成员函数访问。
    • 保护 (protected): 成员可以被其所在的类的成员函数以及其派生类 (子类) 的成员函数访问。
    • 公共 (public): 成员可以从任何地方访问。
  • C语言中,虽然没有这些关键字用于结构体成员,但可以通过 static 关键字限制全局变量和函数的作用域到当前文件(文件作用域),提供一种模块级别的封装。结构体成员默认是"公共"的。

参数传递机制 (Parameter Passing Mechanisms) 🎁

值调用 (Call by Value):

  • 实际参数的值被复制到函数的形式参数中。函数内部对形式参数的修改不影响调用者处的实际参数。
  • C语言默认采用值调用。

引用调用 (Call by Reference) 🔗:

  • 传递的是实际参数的地址(或一个指向实际参数的引用)。函数内部对形式参数的修改会影响调用者处的实际参数。
  • C语言通过传递指针并解引用来模拟引用调用 (e.g., void swap(int *a, int *b)).
  • C++ 提供了真正的引用类型 (e.g., void swap(int &a, int &b)).

名调用 (Call by Name) 📝:

  • 一种较早的参数传递机制 (如 Algol 60)。实际参数的表达式在每次于函数体中被引用时,都会在调用者的环境中重新求值。可以想象成文本替换(但有作用域处理,类似无冲突的宏)。
  • 由于其复杂性和潜在的性能问题及副作用,现代语言中已不再采用。

共享调用 (Call by Sharing / Call by Object / Call by Object-Sharing): 常见于Python, Java (对象类型), Ruby等。函数接收的是对象引用的副本。如果对象是可变的,函数内部通过该引用修改对象会影响原始对象。如果函数给形式参数赋一个新的引用(指向不同对象),原始对象的引用不受影响。

别名 (Aliasing) 🎭

  • 别名 (Aliasing): 当同一个内存位置可以通过两个或多个不同的名字(标识符、指针、引用)来访问时,就发生了别名。

  • 例如,在C中:

    1
    2
    3
    4
    5
    6
    int x = 10;
    int *p1 = &x; // p1 指向 x
    int *p2 = p1; // p2 也指向 x (通过 p1)
    // 此时,x, *p1, *p2 都是访问同一内存位置的别名
    *p1 = 20; // x 的值变为 20
    // printf("%d", *p2); // 会输出 20
  • 别名会使程序分析、理解和优化更加复杂,因为一个变量的修改可能会通过一个看似不相关的名字隐式地影响到其他部分的代码。编译器在进行优化时需要保守地处理可能存在别名的情况。

数据类型

抽象

数据类型本质上是对存储器中存储的数据进行的抽象,包含一组值和一组操作;

根据语言面向的机器和语言面向的领域,可抽象为三个层次

  • 内部类型:基本表示不可见,能进行静态类型检查,编译时可确定无二义性操作,允许运算符的重载,实现精度控制
  • 用户定义类型:提供数据的组合/聚合机制
    1. 笛卡尔积:比如C中的结构体
    2. 有限映像:从定义域的优先级和到至于的有限集合,值域对象通过下标变量方式对应,下标有编译时绑定,对象建立时绑定,对象处理时绑定三种策略
    3. 序列:任意多个数据项组成,数据项成为序列的成分,类型相同
    4. 递归:数据类型包含属于同一种类型的成分,通常利用指针实现,直接递归通常不被允许
    5. 判定或:根据不同的成员构造机制,比如联合类型union
    6. 幂集:类型所有子集的集合
  • 抽象数据类型

C语言数据类型

在C语言中,实现如下数据类型

C语言数据类型

值得注意的是,在C语言中

  • 联合类型是不安全的
  • 文件类型FILE被视作字符字节的序列;
  • 不允许定义空类型变量,只能定义空类型指针;
  • 不支持幂集

类型检查

定义:对数据操作和对应类型是否匹配的一致性检查

  • 例子:非法运算,赋值,形参实参是否匹配
  • 动态检查:编程方便,但是影响力可靠性,降低了执行效率
  • 静态检查:使得程序更加有效

语言按照类型检查可以分为

  • 无类型语言:没有任何数据类型,如一些函数式语言和泛函程序设计语言
  • 弱类型语言:类型检查部分或全部在运行时完成,如Python
  • 强类型语言:所有类型检查在编译时完成,如C

类型转换

一般语言提供类型转换机制

  • 隐式/自动转换:比如FORTRAN语言
  • 显式/强制转换:比如Golang,在C语言中两者都有

类型转换可能发生在以下场景:

  • 混合运算
  • 表达式值赋值给变量
  • 实参想函数传形参
  • 函数返回值

控制结构

控制结构规定了程序语句和程序单元的执行流程和控制戒指;

语句级控制结构:基本的,有一定意义的抽象控制结构,具体来说是对PC + 1指令的抽象 & GOTO指令的抽象,相对于显式控制结构更好;

  • 顺序:通常带有结束标记;
  • 选择:单选,二选一,多选一(比如多层嵌套或者switch的简洁表达)
  • 循环:计数器制导,条件制导,
  • 最终由条件转移和无条件转移的指令实现,由编译器生成,用户不可见

单元级控制结构:在程序顺序执行的过程中,遇到一个分程序,就建立一个新的引用环境,并执行这个分程序;显式调用单元,把控制从一个单元转移到另一个单元

  • 显式调用从属单元:比如C中的函数,单元间的通信,可使用参数传递实现,也可以通过全局变量和非局部变量实现

    程序调用

  • 异常处理:导致程序正常执行中止的事件,要靠发信号来引发,用异常条件来表示。

  • 协同程序

    协同程序间的控制转移关系

  • 并发

    并发提供了同时执行多个程序单元的途径,是多处理器和分布式系统的重要基础。

    C语言没有原生支持,但是可以通过操作系统提供或者第三方库提供实现,比如pthread.h