跳转到内容

使用 C 和 C++ 的编程语言概念/程序级结构

来自维基教科书,开放世界中的开放书籍

我们对程序级结构的调查将最初以编译器的结构为指导。我们将首先看看程序实体(令牌)是如何线性形成的,以及它们是如何进一步分组到层次结构中的。前者对应于词法分析器查看源程序的方式,而后者对应于语法分析器对令牌流施加的层次结构。随后将介绍子程序结构以及子程序可用的数据对象。最后我们将详细阐述不同类型的参数传递机制。

程序文本

[编辑 | 编辑源代码]

任何语言的语法都是一组规则,这些规则控制着语法上正确的字符字符串的构建。程序文本可以被认为是一个字符字符串,最终被翻译成机器指令的有序序列。我们现在将检查这些字符本身,它们如何组织成构成程序文本各个部分的子字符串,以及它们如何在构成完整程序的许多行代码中排列。

字符集

[编辑 | 编辑源代码]

就像自然语言中使用的通信中的字母符号一样,我们使用字符集来组成编程语言中的程序。虽然所有语言的字符集都可以夸耀英语字母的 26 个大写字母、10 个十进制数字、空格符号以及标点符号和基本运算符的符号,但一些较旧的语言不支持小写字母。一些例子,例如 FORTRAN 和 COBOL - 因为它们最初开发时(50 年代)常用的字符代码不包括小写字母 - 在它们的字符集中不包括小写字母。另一个具有这种特性的编程语言示例是 APL,它具有包含许多希腊字母符号的特殊字符集。

在计算机内存中,这些字符可以以不同的方式编码。表示这些字符最常用的选择是 ASCII 标准。Unicode 标准本质上是 Anglo-American ASCII 的超集,它为世界上所有 spoken 的自然语言提供了支持,甚至更多。因此,由 Java、C# 和许多其他编程语言支持的 Unicode 随着软件国际化变得越来越重要而获得了认可。

示例:一个计算从 1 到 10 的数字总和的 Java 程序。

public class TryUnicode { public static void main(String[] args) { int toplam = 0; // "toplam" 是土耳其语中的 "sum" for (int sayaç = 1; sayaç < 10; sayaç++) toplam += sayaç; // "sayaç" 是土耳其语中的 "counter" System.out.println("1'den 10'a kadar sayıların toplamı: " + toplam); // "Summation of numbers from 1 to 10: " } // void main(String[] 结束 } // TryUnicode 类结束

除了这两个之外,我们还有一系列 ISO/IEC 标准,它们允许使用单字节表示各种字母。这些字符编码被称为 ISO-8859-n,它们是基于 ASCII 的,并在相关字符表的上半部分提供特定于语言/语言组的字母;所有 ISO-8859-n 编码的下半部分与 ASCII 字符编码完全相同。得益于 ISO-8859-n,英语和各种其他语言现在可以在同一个程序源代码中共存。但是,使用来自不同 ISO/IEC 编码的语言意味着我们必须使用多种编码,这可能需要相当多的技巧。解决我们担忧的办法是使用 UTF-8,它是基于 Unicode 的可变长度编码。在 UTF-8 编码中,Unicode 的 ASCII 子集(与其他基于拉丁字母的字母表有许多共同的字符)用一个字节表示,而其余字符用 2、3 或 4 个字节表示。

运算符

[编辑 | 编辑源代码]

运算符是返回值的泛型函数。运算符名称通常由单个字符或两个字符的字符串组成。尽管许多语言将可用运算符的语义限制在语言规范中,但有些语言,包括 C++、C# 和 Ada,为程序员提供了重新定义这种语义的功能。有些语言,例如 PROLOG,甚至可以让您构建新的运算符并为它们提供语义。

示例:一个重载“+”运算符以添加两个 Fraction 类型的操作数的 Ada83 函数定义。

FUNCTION "+" (Left, Right: Fraction) RETURN Fraction IS Result: Fraction; BEGIN Result.Numerator := Left.Numerator * Right.Denominator + Right.Numerator * Left.Denominator; Result.Denominator := Left.Denominator * Right.Denominator; Reduce(Result); END "+"; ... f1, f2, f3: Fraction; ... f3 := f1 + f2; ...

运算符可以大致分为以下几类

  1. 算术运算符提供基本运算,如加、减、乘、除。一些语言,例如 FORTRAN,还包括指数运算符。
  2. 布尔运算符仅对逻辑值(truefalse)起作用。示例包括合取、析取和否定。
  3. 关系运算符测试两个操作数之间关系的真值。如果关系成立,关系运算符返回的结果为真。否则,结果为假。此类中众所周知的运算符有小于、等于、不等于和大于。
  4. 位运算符涉及对单个位的布尔操作。此类中的典型运算符是按位与、按位或、按位非和按位异或。

另一种可能的划分,在许多情况下有助于找出优先级,如下所示

  1. 一元运算符,如 -、+、NOT(最高优先级)
  2. 乘法运算符,如 *、/、DIV、MOD、AND
  3. 加法运算符,如 +、-、OR
  4. 关系运算符,如 <、>、<=、>=、<>(最低优先级)

分隔符

[edit | edit source]

分隔符是用于分隔程序文本片段的符号或关键字。从某种意义上说,它们是程序的标点符号。

它们有两种形式

  • 分隔符表示一个程序实体的结束和下一个程序实体的开始。例如,空格通常用于分隔语句中的标识符和表达式;基于 Pascal 的语言使用冒号来分隔变量名及其类型声明。
示例:Pascal 中的变量定义。

var ID_Number : integer;


许多不同的符号可以用来分隔一个程序语句的结束和下一个语句的开始。基于 ALGOL 的编程语言中常见的选择是分号。但是,应该记住,基于 Pascal 的语言和基于 C 的语言对分号的使用略有不同。在前者中,分号用作一种分隔符。这就是为什么这些语言中块的最后一个语句没有以分号结尾。(这就像写 a + b + 而不是 a + b。)在后者中,分号用作终止符。也就是说,每个语句,无论它在哪里,都必须以分号结尾。

  • 括号是成对出现的符号或关键字。它们用于界定一段程序代码。示例包括 ( ) 用于界定子程序参数列表,[ ] 用于指定相关数组组件,以及 { }(或 BEGIN-END)用于构建复合语句
示例:交换两个变量值的 C 块。

if (a < b) { int temp = a; a = b; b = temp; }

程序文本外观

[edit | edit source]

固定格式与自由格式

[edit | edit source]

在 1950 年代,人们,或者更确切地说是一群精选的研究人员,习惯于通过在卡片上打孔单个指令来输入程序。这些穿孔卡具有固定格式。为了简化与这些卡片的向后兼容性,早期的编程语言被设计为固定格式。

FORTRAN 90 之前源代码行的结构
列号 功能
1 C 用于注释
1-5 行号
6 续行指示符
7-72 Fortran 语句
73-80 标识和排序,编译器不读取
COBOL 源代码行的结构
列号 功能
1-6 排序,编译器不使用
7 * 用于注释或 - 用于继续单词或字符串
8-11 程序单元的标题(A 边缘)
12-72 语句(B 边缘)
73-80 程序标识,编译器不使用

后来,随着终端开始用于输入/输出目的,自由格式语言占据了上风。这些语言的起源可以追溯到 ALGOL。

不用说,比较这两种格式没有太多意义。一个迹象是,从 FORTRAN 90 开始,FORTRAN 现在是自由格式的。但这并不意味着用固定格式编程语言编写的代码都会消失。要维护此类代码,仍然需要了解固定格式。

实际问题

[edit | edit source]

程序不仅是使用特定语言实现算法的方案,也是将您的想法传达给其他程序员的一种方式。因此,除了编程语言和我们所处的环境提供的功能之外,我们还应该努力使代码尽可能清晰。为了实现这一点,我们需要使用许多方法和约定。这些是源代码缩进、空行和注释。大量使用并一致使用这三者,而不掩盖代码,将使程序更容易理解,因此也更容易维护。

程序结构

[edit | edit source]

接下来,我们将研究程序的层次结构。换句话说,程序中包含了什么?这种包含关系可以扩展到多个级别吗?第二个问题的答案是“是”。在本节中,我们将确定这些级别。

标识符

[edit | edit source]

标识符是用于访问程序和数据实体的单词。数据级标识符的一个示例是变量名。程序级标识符的一个示例是过程名。

在与变量名类似的上下文中使用的是引用。引用必须首先经过一定程度的评估。示例包括函数引用和数组组件引用。

一些标识符由程序员提供,而另一些则内置于编程语言中。后者通常被称为关键字。通常,编程语言禁止将这些关键字重新用作用户定义的标识符。PL/1 是一个值得注意的例外。

表达式

[edit | edit source]

表达式由运算符和操作数组成,是待评估的运算序列。表达式评估为单个结果,因此非常像变量,只是它没有名称。事实上,在执行过程中会为表达式(子表达式)赋予临时变量名,但这些名称对程序员不可用。

表达式本质上是递归的;一个表达式可以包含在另一个表达式中,并用另一个表达式定义。这意味着通过评估一个表达式获得的结果随后可以用作操作数来评估下一个表达式。

语句

[edit | edit source]

语句可以类似于程序或子程序来定义。主要区别在于它们的抽象级别。在一个级别上被认为是简单语句的东西,在另一个级别上可能被认为是复合语句。它们(语句、子程序和程序)都完成一项特定任务。

语句可以分类为

可执行语句:这类语句会导致计算机执行某种操作,例如将(新)值赋予变量。信息性语句:也被称为非可执行语句,这类语句描述了程序中使用的数据的性质。变量声明语句就是一个例子。

还可以根据语句的组合进行进一步分类。

简单语句:简单语句包含单个操作。常见的例子包括 I/O 语句和赋值语句。复合语句:复合语句是一种程序结构,它由零个或多个简单语句组成,通常用一对匹配的关键字或括号分隔。前者的例子是 Pascal 中的 BEGIN/END 对。C 语言编程语言中用于相同目的的花括号 { 和 } 是后者的例子。Python 编程语言在分隔复合语句的方式上相当独特。它不使用分隔符,而是通过利用程序中的缩进,由语言处理器来判断块。

由于复合语句在语法上被视为单个语句,因此它可以在允许使用单个语句的任何地方使用。

子程序

[编辑 | 编辑源代码]

子程序是通常带有名称的一组语句;它相对独立,执行特定任务。

子程序对它的使用者来说是一个单一的语句,而对它的实现者来说则是信息性语句和可执行语句的集合。这突出了语句和子程序之间的相似性:在一个抽象级别上被视为子程序的东西,在另一个级别上可能被视为简单语句,反之亦然。

内部子程序
这些是命名程序单元,是程序文本的一部分,包含在程序文本中。内部子程序与调用它的程序一起编译。
外部子程序
这些是命名程序单元,位于程序文本的外部,可以独立(或单独)编译成目标模块,然后根据需要使用(链接)。

模块是相关子程序的集合,作为独立的单元打包,以便其他单元重用,可能还会附带相关数据。例子包括 Java 和 C# 中的类、Modula-2 中的模块和 C 源文件。

模块的源代码和相应目标代码通常被区分,分别称为源模块和目标模块。

程序可以松散地定义为可以执行的独立单元。它通常有名称,并且可以调用一个或多个子程序,在这种情况下,它可能被称为主程序。

应该注意程序和进程的区别。程序是一个可执行文件,而进程是正在执行的程序的实例。也就是说,在某种程度上,程序之于进程,就像类之于对象。当生成一个进程时,它的内存映像是使用相关的程序作为模板创建的。

除了这些程序结构,作业可以定义为与操作系统交互的单元,它可以包含多个需要链接在一起或按顺序执行的程序。例如,脚本语言程序,如 DOS 中的批处理程序。

示例

gcc –o $1 $1.c if [ $? –eq 0 ]; then mv –f $1 Executables/$1 fi

上面的作业编译 C 程序,并将生成的可执行文件移动到名为 Executables 的目录中。只有在第一个操作成功的情况下才会执行第二个操作。这是通过检查 gcc 命令的退出值来实现的。按照广泛采用的约定;成功时,gcc 以 0 退出,失败时以非零值退出。在 bash 中,可以通过检查 $0 来找到最后一个命令的退出值,就像在第二行中所做的那样。

子程序作为程序结构

[编辑 | 编辑源代码]

子程序,就像程序本身一样,包含信息性语句和可执行语句。[1] 子程序的声明部分包含信息性语句,这些语句由与子程序相关的声明组成,并提供子程序中包含的动作语句和调用者之间的语法接口。

动作部分包含可执行语句;即实现。一个(或多个)语句被指定为子程序的入口点。通常,只有一个入口点,它是第一个语句。出口点可能是子程序中的最后一个语句,或者是一个显式的返回或退出语句。不建议有多个入口点和出口点。

块用于收集一组相关语句。与子程序类似,它开启了自己的新作用域。它不是由显式调用语句激活的,而是由程序中指令的正常顺序执行激活的。

允许嵌套块的编程语言被称为块结构语言。这有时会被清教徒反对,他们认为,为了将一种语言归类为块结构语言,它必须允许用户定义嵌套子程序。

示例:实现冒泡排序的 C 代码片段。

#define FALSE 0 #define TRUE 1 ... pass = 1; do { swapped = FALSE; for (j = 0; j < noOfComponents - pass; j++) { if (a[j] <= a[j + 1]) continue; swap: { int temp = a[j]; a[j] = a[j + 1]; a[j + 1] = temp; swapped = TRUE; } /* end of swap block */ } /* end of for loop */ pass += 1; } while (pass < noOfComponents && swapped);

注意:这不是实现冒泡排序的正确方法;一些不必要的和丑陋的代码被添加进来以展示在 C 中使用块。

段落

[edit | edit source]

段落是一个内部子程序,一个命名的语句序列。它通过伪调用语句来调用,例如 COBOL 中的 PERFORM paragraph-name。它们没有声明部分。换句话说,它们不允许您声明局部变量,也不接受参数。

示例:一个用于交换两个变量值的 COBOL 段落。

PERFORM SWAP. ... SWAP. MOVE A TO TEMP. MOVE B TO A. MOVE TEMP TO B. SOME_OTHER_PARAGRAPH. ...

请注意,段落不接受任何参数。因此,示例中使用的所有变量必须声明为全局变量。

过程,子程序

[edit | edit source]

通常,术语过程和子程序用于指代不返回值的子程序(内部或外部)。它们用于其副作用;也就是说,用于在屏幕上打印输出,通过一组赋值语句更改内存,等等。它们通过显式调用被激活,并接受参数。在某些编程语言中,如基于 Pascal 的语言,它们可以嵌套。

函数

[edit | edit source]

函数可以被称为返回值过程或类型化过程。它们仅仅通过在程序文本中任何允许使用变量名作为右值的地方使用函数名(以及任何需要的参数)来激活。

内置函数是那些与编译器一起提供的函数,因此是语言词汇的一部分。泛型函数是指其类型取决于调用时使用的参数类型的函数。

示例:一个查找其两个参数的最小值的泛型 C++ 函数。

template <class Type> Type min(Type a, Type b) { return a < b ? a : b; } // end of <Type> min(<Type>, <Type>)

子程序中的数据访问

[edit | edit source]

定义:程序执行期间存储绑定到对象的时间段——即可以检查和存储的内存区域——被称为对象的范围

定义:声明的作用域是程序文本中该声明生效的区域。

认识到作用域是一个静态概念,而范围是一个动态概念。作用域可以通过扫描源代码来确定,这是一个在程序开始运行后无法改变的东西,因此是一个静态概念。另一方面,范围取决于程序的控制流,这是一个可能被输入改变的东西,因此是一个动态概念。

全局变量

[edit | edit source]

当子程序被调用时,一些数据,全局变量,可能已经可以被访问,而无需做任何特殊的事情。全局变量的作用域从其声明点扩展到源文件的末尾,并且只有一个副本。只有一个副本意味着它只能有一个变量名-地址绑定,这意味着分配给它的内存位置在整个程序执行过程中不会改变。这样的对象被称为具有静态范围;其相对地址可以在执行开始之前确定,即在编译时;它存储在称为静态数据区域的内存区域中。查看源代码,可以计算所有静态对象的地址——即所有全局和静态局部对象——相对于该区域的开头。

内存中的全局变量

int i2; int i1; void f(...) { ... } /* end of void f(...) */ int main(void) { ... ... } /* end of void main(void) */

代码
0 int i2
4 int i1
?

类变量

[edit | edit source]

类变量 用于表示在不同实例之间不变的属性。这意味着所有实例都可以共享一个副本,这使得在编译时确定[相对]地址成为可能。

局部变量

[编辑 | 编辑源代码]

局部变量在调用子程序时变得可访问。这些变量被认为具有局部作用域,从它们的声明点到它们声明所在的子程序的末尾。同样,它们是可见的,除非被相同名称的另一个标识符隐藏,从它们的声明点到子程序的末尾。它们的范围(生命周期)通常仅限于特定的子程序调用,但在某些编程语言中,可以通过将它们声明为static来扩展到整个程序的执行。

子程序的参数可以被认为是特殊局部变量,它们从子程序外部初始化。

如果我们限制自己进行简单的调用,局部变量的处理将非常类似于全局变量:它们将只有一个副本;将只有一个(变量名-地址)绑定。唯一的区别是变量的作用域。

这种局部变量分配方式的优点是能够静态地确定地址。这可以做到,因为在程序执行过程中的任何时间,一个函数最多只能被激活一次。另一方面,递归是不可能的;程序员必须模拟递归算法或编写算法的非递归版本。

内存中的局部变量(无递归)

int i2; int i1; void f(...) { double d1; double d2; ... } /* void f(...) 的结束 */ int main(void) { ... ... } /* void main(void) 的结束 */

代码
0 int i2
4 int i1
8 double d1
16 double d2
?
问题
在 FORTRAN 90 之前,FORTRAN 仅支持简单的调用。您认为这是什么原因呢?
尝试将局部变量放在内存中(使用递归)

int i2; int i1; void f(...) { double d1; double d2; f(...); } /* void f(...) 的结束 */ int main(void) { ... ... } /* void main(void) 的结束 */

代码
0 int i2
4 int i1
8 double d1
16 double d2
8 double d1
16 double d2
8 double d1
16 double d2
?

递归的可能性、子程序可能被调用的次数不确定、每个子程序调用都有独立的参数和局部变量集,以及子程序调用终止顺序的性质,要求使用单独的动态、堆栈式内存区域来存储局部变量和参数,以及一些其他信息。该区域称为运行时堆栈。每次调用子程序时,都会创建一个新的调用记录并将其压入此堆栈。该记录被称为该特定子程序调用的激活记录;插入激活记录的槽称为激活帧

典型激活记录中包含的字段

  1. 临时值,例如表达式求值时产生的值,存储在临时值字段中。
  2. 局部数据字段保存子程序执行时局部的数据。
  3. 保存的机器状态字段保存有关子程序被调用之前机器状态的信息。它包括程序计数器和机器寄存器的值,这些值在控制从子程序返回到调用者时必须恢复。
  4. 文本链接用于引用保存在其他激活帧中的非局部数据。在基于 C 的语言中,不需要文本链接,因为不允许嵌套定义子程序。允许嵌套子程序定义的语言,例如 Pascal,需要使用此字段。
  5. 动态链接指向调用者的激活帧。
  6. 调用者在实际参数字段中向被调用者提供参数。此区域对于特定函数的大小不会改变。(可变长度参数列表是对此的例外。)在实践中,参数通常为了更高效而传递到机器寄存器中。
  7. 被调用者在返回的值字段中将结果值返回给调用者。同样,在实践中,此值通常为了更高效而返回到寄存器中。
激活记录中的典型字段
返回值
实际参数
文本链接
动态链接
保存的机器状态
局部数据
临时值

子程序调用和返回

[编辑 | 编辑源代码]

在将控制传递给被调用者之前,会对参数进行评估,并将它们压入运行时堆栈的顶部,然后调整 LB(局部基址)以指向被调用者的激活帧。LB 的先前值,即调用者激活帧的起始地址,与一些其他控制信息一起被压入运行时堆栈。每次将新内容压入堆栈时,ST(堆栈顶部)都会向前移动,以便显示第一个可用位置。

在此处插入图片

在进入被调用者后,会在堆栈上分配局部变量。随着表达式求值过程中的需要,临时变量也会在堆栈上创建。当子程序完成执行并返回到调用者时,局部变量将被释放,参数通过弹出当前激活记录来解除关联,并且在函数子程序的情况下,返回值将返回给调用者。

问题
根据经验,将指向局部变量的指针作为子程序的结果返回被认为是不好的编程方式,应该避免。您认为这是什么原因呢?

出于优化目的,编译器可以选择将有关当前子程序调用的一些信息存储在寄存器中,而不是运行时堆栈中。

局部变量与全局变量

[编辑 | 编辑源代码]

在子程序范围内定义的变量是局部变量。它们只有在调用子程序时才出现(为子程序创建激活记录并压入运行时堆栈),并且一旦子程序执行完成,它们就将不存在(通过弹出运行时堆栈来销毁子程序的激活记录)。它们不会在同一函数的不同激活之间保留其值。

局部声明用于限制变量的作用域。使用局部变量减少了数据名称冲突的问题。它还有助于创建独立的单元,即子程序。另一方面,全局变量在主程序中声明,在程序执行过程中一直存在,并且可以从所有程序单元访问。因此,它们可以在编译时分配,甚至在程序开始运行之前,并且在程序终止时释放。

强烈建议不要在子程序中使用全局变量。子程序中对全局变量的修改会造成副作用,导致相同参数下返回不同的结果。同时,它也会导致不同子程序之间强耦合,使得子程序测试更加困难,并带来调试和维护方面的困扰。可以通过将这些强耦合的子程序分组,并放置在模块、类或(如果语言不支持这些结构)源文件中,来最小化这种影响。

一些语言提供了介于局部变量和全局变量之间的概念:static 局部变量。这类变量具有局部作用域,类似于局部变量,但具有静态范围,类似于全局变量。也就是说,它们在调用之间保留其值,但仍然只能在子程序中访问。在编译时(从静态数据区域)为它们分配内存,并在程序执行结束时释放。

示例:C 中的静态局部变量。

void silly(void) { static int noOfTimes = 1; printf("This function has been called %d times.\n", noOfTimes++); } /* end of void silly(void) */

在首次调用此函数时,局部变量noOfTimes 的值为 1。在子程序执行结束时,noOfTimes 变量的值为 2。该值在子程序结束和下次调用之间被保留。下次调用子程序时,noOfTimes 的值为 2。

由于静态局部变量的初始化与普通局部变量不同,是在运行时之前发生的,因此它们保证具有一致的值。作为演示,运行以下程序,您将看到staticLocal_i 打印了 5,而plainLocal_i 打印了随机值。

示例:C 中static 局部变量的初始化。

#include <stdio.h> void f(void) { goto L1; { static int staticLocal_i = 5; int plainLocal_i = 6; L1: printf("Static i: %d\nNon-static i: %d\n", staticLocal_i, plainLocal_i); } } /* void f(void) */ int main(void) { f(); exit(0); } /* int main(void) */

问题
在 C 中,static 局部变量默认初始化为 0,而普通局部变量则保持未初始化状态。这背后的原因是什么?

堆变量

[edit | edit source]

到目前为止,我们所讨论的所有数据对象的生命周期都与它们定义的块相对应。但是,一些语言支持这样一种特性:数据对象的生命周期与其创建它们的程序结构无关。大多数基于 ALGOL 的编程语言中动态创建并通过指针或句柄操作的记录是这类数据对象的例子。为了在运行时表示这些对象,必须使用其他一些存储分配和回收方法。由于这些语言功能允许以随机顺序进行存储分配和回收,因此所涉及的存储及其控制机制通常被称为堆。

在一些编程语言中,程序员通过语言支持的操作(如 Pascal 中的dispose 或 C 中的free)显式地丢弃堆内存,而在另一些语言中,这由称为垃圾回收的语言运行时库的一部分来处理。

由于堆对象的寿命不受创建它的程序结构的影响,因此这些对象被称为具有动态范围。通过指针或句柄间接访问堆对象,会使这些对象具有匿名性。不要忘记,并不是引用是匿名的,而是被引用的堆对象是匿名的。由于这个特性,我们可以通过简单的赋值让两个句柄代表同一个堆对象。这意味着我们可以共享同一个数据对象。请注意,是堆对象被共享,而不是句柄;句柄被复制。如果同一个堆对象的句柄具有不同的范围,则堆对象本身预期将一直存在,直到指向它的最后一个句柄消失。这就是为什么这些对象被称为具有动态范围。

通过共享而不是复制,通过句柄间接操作而不是直接操作;人们必须采用不同的编程风格,一种更有纪律的风格。对于没有垃圾回收功能的编程语言来说,这一点尤为重要。除了悬挂指针问题(指针保存了指向不存在对象的地址)外,人们还必须避免创建垃圾:没有指针指向的堆对象。

除了列表和动态数组等动态结构之外,堆内存最常用于在面向对象编程语言中分配实例变量——即用于表示对象状态的非静态数据。为了提供多态性,这需要同一个句柄保存大小可变的对象的引用,动态分配堆中的内存成为唯一的选择。[2]

总结

[edit | edit source]

前面介绍的内容引出了下面的图。基本上,正在运行的程序的内存布局包括代码段和数据段。前者是不可变的,也就是说在运行时不能被改变。出于这个简单的原因,编译器将代码页面标记为只读。这可以防止(意外或恶意)覆盖这些页面。数据段,除了编译时常量外,都是读写。编译时常量可以作为立即数数据合并到代码段中。运行时常量(如在 C#、Java 中找到的)可以通过地址进行操作,非常像变量。但它们必须存储在只读页面中。

运行程序的各个部分示意图

至于数据段的细分,静态数据区用于分配全局和静态局部数据对象。此类数据对象的初始化是在运行时之前完成的。如果程序员可能没有提供显式的初始值,编译器保证它们在表示中都为零。对于整数,此值被解释为 0;对于实数,被解释为 0.0;对于句柄或指针,被解释为 null;对于布尔值,被解释为 false;等等。该区域由编译器管理,在运行时之前分配,并且其中的对象具有静态作用域。

运行时栈用于分配普通的局部变量、参数、临时变量和一些控制信息。除非程序员没有提供初始值,否则不会提供默认初始值。这是因为该区域是在运行时分配的,任何额外的操作都会导致性能损失。因此,如果程序对初始值做出假设,程序员必须明确提供这些值。尽管子程序可以被调用无限次,但在调用子程序(前奏)过程中要执行的操作和在从子程序返回(后奏)过程中要执行的操作始终是相同的。这些模板被放在代码中:每次调用子程序时都会执行前奏,并跳转到被调用者的开头;每次子程序返回时,都会执行后奏,并将控制权返回到 IP 中包含的指令。由于此序列始终相同,因此编译器可以管理运行时栈。运行时栈数据对象的范围仅限于创建它们的帧的生命周期;也就是说,它们具有局部作用域。

最后,堆用于分配具有动态作用域的数据对象。它们的用法模式不可预测,这意味着它必须由程序员管理,尽管垃圾收集器可以减轻一些负担。我们对运行时栈数据对象初始化所做的推理也适用于堆对象:如果需要,程序员必须加倍努力。

不同数据段属性表

参数

[edit | edit source]

子程序,就像程序一样,可以被认为是将一组特定的值映射到一组特定的结果。这些值和结果的名称统称为参数。输入参数指的是输入到子程序并由子程序操作的值。输出参数通过子程序内部完成的处理接收它们的值;这些是结果。一个专门指定的输出参数也称为子程序的返回值。输入/输出参数提供调用者和被调用者之间的双向通信:它们既可以用于将数据输入到子程序,也可以用于从子程序输出结果。[3]

示例:一个 Ada 子程序,用于查找其两个参数中较大的一个。

PROCEDURE Max(num1: IN Integer; num2: IN Integer; larger: OUT Integer);
或者
FUNCTION Max(num1: IN Integer; num2: IN Integer) RETURN Float;

示例:一个 Ada 子程序,用于交换其参数的值。

PROCEDURE Swap(num1: IN OUT Float; num2: IN OUT Float);

当调用子程序时,子程序名称后面跟着一个参数列表,该列表通常用括号括起来。参数的数量和顺序与子程序规范中列出的参数相对应。调用者提供的参数称为实际参数,而在子程序规范中提供的参数称为形式参数。

一些编程语言可以减轻程序员对上一段中所述限制的负担。例如,C、C++ 和 Java 等编程语言为程序员提供了定义具有可变大小参数列表的子程序的功能。类似地,Lisp 和 Ada 等编程语言使程序员能够以非顺序的方式将参数与形式参数匹配。

示例:在 C 中传递可变数量的参数。

int printf(const char* format, ...);

上面的原型中的省略号表示 printf 可以传递零个或多个参数,这些参数在第一个固定参数之后。第一个参数中传递的格式信息大概会提供数据来确定后续参数的数量和类型。

printf("Sum of %d and %d is %d\n", num1, num2, num1 + num2); printf("Class average in CSE224 came out as %f", avg);

示例:Lisp 中的命名参数。

(defun make-complex (&key real-part imag-part) (cons real-part imag-part))

上面的 &key 关键字允许 make-complex 的用户通过将参数与形式参数名称关联来传递参数。以这种方式传递的参数称为关键字参数

(setq c1 (make-complex :imag-part 4.0 :real-part 3.0))

参数传递机制

[edit | edit source]

将参数传递给子程序对应参数的机制称为参数传递。参数传递活动遵循参数求值的活动,其中调用程序(也称为调用者)中的每个参数都与其在子程序(也称为被调用者)中的对应参数相关联,并执行任何必要的计算。

按值调用

[edit | edit source]

当按值将参数传递给参数时,参数被设置为子程序的局部变量,并初始化为参数的值。实际上,参数的值被复制到为参数保留的位置。因此,参数的存储位置受到子程序内更改的保护。

示例:一个返回其参数立方的 C 函数。

// 计算给定值的立方体不是一个特别好的实现。 long cube(int n) { long result = n * n * n; return(result); } /* end of long cube(int) */ a = cube(m); ... ...

按位置调用

[edit | edit source]

当使用此机制将参数传递给子程序时,调用者传递对该参数的引用或机器地址。然后,参数是一个指向参数位置的指针变量。因此,它也称为按引用调用按地址调用

这种方法不是将值发送到子程序,而是让两个模块共享驻留在某个存储位置的数据。数据共享产生的效果是,按位置传递的变量参数可以不受限制地从子程序中访问。换句话说,子程序中的语句可以访问存储在存储位置中的数据,既可以读取也可以写入。当子程序终止时,指针会被释放,但对参数所做的任何更改当然都是永久性的。因此,这种方法与全局变量几乎没有区别。它们都会产生副作用。

当使用全局变量时,被调用者中使用的数据名称必须与调用者中使用的数据名称完全相同。这是 BASIC 和 COBOL 中唯一可用的数据共享方法。与使用全局变量相比,使用位置参数的优势在于,用于参数的数据名称与用于参数的数据名称无关。

示例:用于交换两个变量值的 OBERON 过程。

PROCEDURE swap(VAR a, b: INTEGER); VAR temp : INTEGER; BEGIN temp := a; a : = b; b := temp; END swap; swap(x, y); ... ...

示例:用于交换两个变量值的 C 函数。

int x = 3, y = 5; ... ... void swap(int *a, int *b) { int temp = *a; *a = *b; *b = temp; } /* end of void swap(int*, int*) */ ... ... swap(&x, &y); ... ...

上面的 C 函数和前面的示例完成了相同的任务:交换两个整数变量。调用者和被调用者之间传递参数信息的方式相同:通过实际参数的地址。但是,仍然有一个(OBERON)被称为按引用调用,而另一个(C)被称为按值调用。OBERON 工具的用户不需要知道数据是如何在调用者和被调用者之间传递的;他们只需要知道过程的作用即可。另一方面,C 工具的用户必须知道他们必须传递一个地址才能看到实际参数的修改后的值,这是在 OBERON 情况下由编译器完成的;他们必须知道“如何”问题的答案。因此,可以说用户实际上是在按值传递地址,而不是变量本身;他们使用按值调用来模拟按引用调用。

问题
在 C 中,数组无论其大小如何,都作为指向其第一个组件的指针传递。这背后的原理是什么?

按位置调用的变体是按结果调用机制,与按位置调用不同,调用者不需要初始化传递的参数。换句话说,按结果调用实现输出参数,而按位置调用实现输入输出参数。

示例:C# 中按结果调用机制的示例。

class CName { ... public static void Add(int lhs, int rhs, out long result) { result = lhs + rhs; return; } // end of void Add(int, int, long) ... } // end of class CName ... long res; int lhs = 3; ... CName.Add(lhs, 5, res); Console.WriteLine(Sum of {0} and 5 is {1}, lhs, res); ...

上面的片段将产生以下输出:3 和 5 的和是 8

按结果传递参数用于从被调用子程序返回的值。因此,如果在上面的示例中省略对 result 的修改,C# 编译器会将其视为错误。

使用这种参数的效果可以通过将参数值作为子程序结果的一部分返回来获得。上面的示例可以改写如下

public static long Add(int lhs, int rhs) { return (lhs + rhs); } // end of long Add(int, int)

按名称调用

[edit | edit source]

这种机制将每个参数的求值推迟到在子程序执行期间实际需要时。它不是将值或值的地址传递给参数,而是传递一个求值规则。至少在概念上,它是一种文本替换机制。每次参数名称出现在子程序文本中时,它都会被“替换”(实际上没有,但效果就像被替换一样)为参数的精确文本。

对于按名称调用,如果参数是标量变量,其效果与按位置调用相同;如果参数是表达式,其效果与按值调用相同。

示例:伪帕斯卡中的按名称调用。

function SIGMA(name x: real; name k, n: integer):integer; begin real s = 0; for k := 1 to n do s := s + x; SIGMA := s end ... ... SIGMA(A[i], i, 10); SIGMA(A[i] * B[i], i, 10);

第一个调用返回向量 A 中元素的总和,而第二个调用返回两个向量 AB 的标量积。

可以利用返回给定参数地址的函数(称为thunk)来模拟这种行为。

按需调用

[edit | edit source]

在某些编程语言中,特别是函数式编程语言,表达式只有在其他表达式或函数需要其输出时才会被求值。这种延迟求值,也称为惰性求值,在参数传递机制中被称为**按需调用**。其效果与**按名调用**非常相似,因为参数只在需要其值时才会被求值,而不是在调用时立即求值。

示例:Haskell 中的延迟求值。

non_strict_func a b = if a == 0 then 0 else a / b non_strict_func 0 (1/0) 0.0 :: Double

由于 Haskell 延迟求值其参数,因此上面的代码片段会产生“更自然”的结果:0。然而,运行相同片段的 C 版本会导致除零异常。[4] 我们说一个子程序在延迟求值的参数中是**非严格的**,而一个子程序在参数进入子程序之前就被求值,则被称为**严格的**。

笔记

[edit | edit source]
  1. 在旧的编程语言中,声明部分通常需要在可执行部分之前,而在其他语言中,两种类型的语句可以混合排序。
  2. 注意关键字是多态性,而不是继承。因为在没有多态性的情况下,自然使用继承是不可能的。然而,在某些编程语言(如 C++)中,也可以在没有提到多态性的情况下讨论继承。在这种情况下,实例变量可以分配在数据段的其他部分。
  3. 使用输出参数从子程序返回结果被认为是糟糕的编程实践。如果可能,子程序应该实现为函数,输出参数应该作为函数结果的一部分返回。
  4. 为了公平起见,应该强调的是,这一说法对几乎所有基于 Algol 的编程语言都是正确的,包括 C、Pascal、C++、Java、C# 等等。
华夏公益教科书