斯坦福-CS107-编程范式笔记-全-

斯坦福 CS107 编程范式笔记(全)

001:课程概述与编程范式介绍 🚀

在本节课中,我们将学习斯坦福大学CS107课程的核心内容,了解编程范式的概念,并概览课程中将涉及的不同编程语言及其背后的思维方式。

课程大纲 📋

本课程将涵盖多种编程语言和一个核心概念。以下是课程中将要学习的内容列表:

  • C语言
  • 汇编语言
  • C++语言
  • 并发编程(一种范式,而非特定语言)
  • Scheme语言
  • Python语言

学习这些语言的核心价值,不在于仅仅将它们列在简历上,而在于深入理解它们所代表的编程范式。接下来,我们将解释什么是编程范式。

理解编程范式 🤔

上一节我们列出了课程涵盖的语言,本节中我们来看看什么是编程范式。简单来说,使用C或C++编程时,你的思维过程与使用Scheme或Python时是不同的。你们已经掌握了足够的C++知识,并且可能没有意识到,你们其实已经了解了很多C语言的知识——将C++中的面向对象特性去掉,剩下的基本上就是C语言了。

过程式/命令式范式

当你使用纯C语言编程(没有类)时,你的思维是过程导向的。你将主函数视为一个高层纲要,可能调用五个顶层函数,而这些顶层函数又包含子函数,子函数又包含更小的函数,以此类推。这就像历史课的提纲笔记,有罗马数字I、II、III作为主要部分,下面又有A、B和小写的i、ii等细节。

这种范式也被称为命令式范式。它是动词导向的,因为你通常在语句中首先看到的是一个函数调用,而函数名通常包含一个强烈的动词,这让你对函数要完成什么功能有所了解。

面向对象范式

当你学习C++时,情况发生了变化。你不再是通过调用“做这个”函数并传递某个对象的地址,而是使用 myObject->doThis() 这样的语法。这时,你在一行代码中首先看到的是被操作的数据或对象。正因为首先看到的是对象,所以它被称为面向对象,而不是过程导向。

许多C++程序员实际上是用C的风格在编程,只是顺便使用了可用的对象和类,这是一种完全合理的编程方式。大多数人在工业界仍然非常精通C语言,尽管有无数更新的、在某些方面更好的语言,他们仍然坚持使用所熟悉的工具,这也是C和C++至今仍然非常流行的原因。

纯粹用C编程没有任何问题,如果你非常精通并能写出清晰可读的代码的话。只是,在C语言中更容易出现内存错误,因为一切对程序员都是暴露的,并且几乎没有错误检查。C++在这方面稍好一些。

汇编语言的作用 🔧

在C和C++之间,我们提到了汇编语言。教授这门课的目的,主要不是教你如何用汇编语言编程,而是将其作为一个工具,向你展示C和C++程序是如何编译成 .o 目标文件、二进制文件,并最终成为可执行文件的。

我们将了解像 i = 7j++ 或函数调用 foo(x, y) 这样的代码行,是如何被翻译成汇编代码的。你们知道,当你编写C++代码并执行程序时,它已经不再是C++了,它变成了汇编代码,最终都是0和1。我希望让你们深入了解C是如何被翻译成汇编代码的,你们所有的变量、函数和对象最终是如何被归结为一串0和1的集合。

对于C++,我们也会做类似的事情。尽管C++和C代表了不同的范式,但它们最终都被编译成0和1。通过足够的学习,你们将能够看着C代码,推断出其对应的汇编代码会是什么样子。你们将会发现,就底层的0和1而言,C++和C看起来几乎像是同一种语言。我们将把用于从C到汇编的编译模型进行扩展,以适应C++中引入的引用、类、模板等新特性。

在课程的前五周之后,你们将对C和C++的工作原理有一个非常深入的理解。这将使你们成为更出色的C/C++程序员。我们将揭开指针的神秘面纱。你们将能够理解像 *p = 7 这样的语句的确切含义。虽然这需要一些努力,但你们会看到,类型转换、&* 的操作看似随意,但只要它能编译,就意味着它有某种含义;当它运行时,它确实会做某件事。如果你的代码中有很多 *&,这可能不是好迹象,但至少你能理解它为什么会崩溃,而不仅仅是知道它崩溃了。

并发编程范式 ⚡

接下来,我们将花相当多的精力讨论并发编程。目前,我们实际上是用C语言来进行这类编程的。你们在过去两个季度(如果你们刚上完斯坦福的106A和106B或106X课程)所写的所有程序,在接触CS107之前,都是顺序程序。这意味着,无论是面向对象还是命令式编程,你们都遵循一个按部就班的步骤大纲,所有事情一个接一个地发生,没有并行或流水线操作,一切都是在一个清晰的指令流中完成的。

并发编程是关于在一个单一程序中,试图让两个函数看似同时运行。如果可以做到让两个函数看似同时运行,那么就可以扩展到让10个、20个甚至更多函数同时(或看似同时)运行。我之所以说“看似”,是因为从技术上讲,它们并不在同一时刻运行。

当我讲解汇编代码时,你们可以凭直觉理解汇编代码是什么样的。如果你只关心一个函数的执行(比如我的左手代表一个函数),那么你可以只想着读取并执行它的代码以产生效果。但在处理并发编程时,你有两个或更多的函数(比如我的两只手代表两个函数),它们看起来是同时运行的。实际上发生的情况,就像同时看两部电影:因为大多数机器只有一个处理器,它们并不是真正像这样并行运行,而是像这样来回切换,在两个函数之间快速交替。只是切换速度极快(远超每秒24帧,可能达到每秒百万次),以至于你无法察觉区别。这在理论上可以很好地扩展到3个、5个、10个甚至100个函数。

在很多情况下,并发编程并不是非常有用,但在某些场景下,特别是涉及网络时,并发编程实际上非常有用。处理并发编程时,会出现一些你可能未曾想到的问题。

我经常在开学第一天举的例子是使用两台富国银行的ATM机。假设你有一个富国银行的支票账户,里面有100美元。你和你的好朋友共享了密码,然后你们走到相邻的两台ATM机前,尽可能快地操作,试图取出那100美元。然后你们数到三,同时按下确认键,试图总共取出200美元。这个例子并非毫无意义,因为这两台机器本质上是简单的计算机,最终都需要访问同一个主账户余额,以确认有100美元可用。以这种“事务性”的方式(这个词在金钱和并发编程中都有意义),你必须确保被识别为账户余额的这100美元,其操作具有某种“原子性”,这样如果两个人同时试图取出100美元,只有一个人能成功。这100美元的账户余额就是两个不同进程可以访问的共享资源

总结 📝

本节课我们一起学习了编程范式的核心概念。我们了解到,不同的编程语言(如C、C++、Scheme、Python)代表了不同的思维方式或“范式”,例如过程式、面向对象式。我们还预览了本课程将如何通过汇编语言来揭示高级语言(C/C++)到机器代码的编译过程,从而加深对程序底层运行机制的理解。最后,我们引入了并发编程范式,探讨了让多个任务看似同时执行所带来的机遇与挑战(如资源共享问题)。在接下来的课程中,我们将深入这些主题,逐步揭开编程语言和计算机系统的神秘面纱。

002:内存中的数据类型表示

本演示由斯坦福大学职业发展中心提供。

在本节课中,我们将学习数据在内存中的底层表示机制。理解像布尔值、整数、浮点数和结构体这样简单的数据是如何在内存中表示的,这非常有趣。理解一切如何最终被表示为0和1的集合,以及如何每次都能被准确地解释为一个大写字母A、数字7、π或一个表示分数的结构体,将使你成为一名更优秀的C/C++程序员。目前,C和C++对我来说是相同的语言。

首先,让我们回顾一下你可能处理过的所有数据类型。你可能处理过布尔值(bool)。你肯定处理过字符(char)。你可能没有处理过短整型(short),但我还是会把它列出来。你肯定处理过整型(int)。你可能也见过长整型(long)。你很可能见过浮点数(float)和双精度浮点数(double)。这些数据类型足以让我们讨论前10分钟的内容。

这些数据类型中,boolcharshortintlong是相关的。bool表示真或假。char在我们的世界中表示256个不同字符中的一个。shortintlong都旨在表示标量数字。floatdouble则试图表示任意精度的数字。

char在内存中通常是1字节。short通常是2字节。int实际上可以是2到4字节,但在本课程中我们假设它总是4字节。long目前是4字节内存。还有一种不常见的数据类型叫long long,它提供8字节内存来表示非常大的十进制数,我们稍后会讨论。float是4字节。它试图用4字节内存来表示一个任意精度的数字。double在一些系统上是10或12字节,但我们假设它是8字节。

这是你可能见过的最无聊的表格。但我的动机是揭示字节的构成,以及4个字节如何能表示大量数字,8个字节如何能表示非常大量的数字,并足够精确地满足我们的需求。

现在暂时忘记浮点数。我稍后会回到布尔值,因为它与字符占用的内存量有关。但我目前感兴趣的是一个通常被称为二进制数字的东西。

你听说过它,它被称为(bit)。电气工程专业的学生和喜欢电子学的人从晶体管和电压(高电压、低电压)的角度来思考二进制数字。计算机科学家不需要这样看。他们只需要认识到,位是一个很小的内存单元,可以区分两个不同的值。电气工程师会说是高电压、低电压。我们则假设单个位可以存储一个0或一个1。

从技术上讲,一个布尔值可以只映射到内存中的一个位。但事实证明这样做不切实际。不过,如果你真的想用单个位来表示一个布尔变量,你可以设计编译器来实现。

当位被分组时,它们变得更有趣。如果我在这里放下8位,我甚至不指定是0还是1,只是画出来。这不是分数,这是我画的8位。让我在旁边画一个,这样我有一些空间,并以这种二分查找的方式给每个位画一个小框。

这个图最有趣的一点是,这个小框可以采用两个值中的一个,独立于其他框选择采用什么值。实际上,每个位都有2个独立的选择。这意味着,作为一个分组,一个具有8个独立位的字节内存,可以区分 2^8256 个不同的值。这就是ASCII表如此之大的原因。

例如,65到65+25代表字母表。我不记得小写a从哪里开始。但打印到屏幕或文件的每个字符都由某个数字支持。当你在内存中查看大写字母A的表示方式时,你实际上会看到一个1在这里和一个1在那里。我稍后会画出来解释为什么是这样,因为大写A由数字65支持。

我们在内存中不以十进制形式存放数据,而是以二进制(base 2)形式存放。因为在面向位的系统中,这是最容易表示的形式。所以,如果我说大写字母A等于65,你必须停止将其视为65,而应将其视为2的完美幂次之和。65实际上是 64 + 1。1是 2^0,64是 2^6

在这样一个框中表示时,如果你去检查所有晶体管,即构成单个字符字节的8个并排放置的位,它看起来会是这样。为了恢复实际的十进制等效值,你确实需要进行幂级数展开,你说因为它在从右数第6位(从0开始计数),所以贡献了 2^6;第0位贡献了 2^0。你可以将其视为第1位、第3位和第7位的贡献被0加权,而不是1。

这对字符来说已经足够了。让我们升级到短整型(short)。有些人在使用短整型时非常聪明。很多时候,如果你知道要存储很多数字并且它们都很小,你会选择短整型数组或向量,因为知道以后确实会节省一些内存。

短整型是2字节,仅仅意味着内存中两个相邻的字节会被放置下来。目前这两个字节会被放置下来,并且该空间中可用的 2^16 种不同模式可以区分 2^16 个不同的值。

所以我就这样编造。我会在这里放很多0。除非我在这里放一个1。


本节课总结:在本节课中,我们一起学习了数据在内存中的底层表示。我们从基本数据类型(如boolcharintfloat)的内存占用开始,深入探讨了(bit)作为最小的信息单元,以及如何将多个位组合成字节(byte)来表示更多的值(例如,1字节可表示 2^8=256 个值)。我们了解了字符(如‘A’)在内存中实际上由其对应的ASCII码值(如65)的二进制形式(64 + 1 = 2^6 + 20**)表示。最后,我们看到了更大的数据类型(如`short`,占2字节)如何利用更多位(**216种组合)来表示更大范围的数字。理解这些基础是成为优秀程序员的关键。

003:指针、内存布局与结构体

在本节课中,我们将深入学习指针操作、内存布局以及C语言中结构体的基本概念。我们将通过具体的代码示例,理解数据在内存中的存储方式以及如何通过指针进行访问和操作。

上一节我们介绍了指针和取地址运算符的基本用法,本节中我们来看看更复杂的类型转换和结构体的内存布局。

类型转换与内存解释

考虑以下代码:

double d = 3.1416;
char ch = *(char*)&d;

变量 d 在内存中占据8个字节,存储着 3.1416 的二进制表示。表达式 &d 获取变量 d 的起始地址。(char*)&d 将这个地址强制解释为一个指向字符(1字节)的指针。最后,*(char*)&d 解引用这个指针,读取内存起始地址处的第一个字节。无论这个字节原本是什么比特模式,在此刻它都被当作一个 char 类型的值赋给了变量 ch

接下来看一个更复杂的例子:

short s = 45;
double d = *(double*)&s;

变量 s 是一个2字节的短整型,存储着数字45。&s 获取其地址,(double*)&s 将这个地址强行解释为指向一个8字节 double 类型的指针。解引用这个指针时,程序会尝试从 s 的地址开始读取8个字节。这非常危险,因为 s 之后的内存可能不属于当前变量,可能导致内存访问错误。如果侥幸没有崩溃,那么这8个字节的比特模式将被解释为一个 double 类型的值并赋给 d

字节序(Endianness)

字节序指的是多字节数据在内存中的存储顺序。主要有两种:

  • 大端序:最高有效字节存储在最低的内存地址。
  • 小端序:最低有效字节存储在最低的内存地址。

例如,对于短整型数字 1(十六进制 0x0001):

  • 在大端序机器上,内存布局可能是:0x00(高地址)| 0x01(低地址)。
  • 在小端序机器上,内存布局可能是:0x01(低地址)| 0x00(高地址)。

如果直接将内存字节从一个机器复制到另一个机器,而不考虑字节序,解释出的数值就会错误。在我们的课程中,大部分情况下无需担心此问题,但需要知道其存在。

结构体(Struct)的内存布局

现在让我们转向结构体。结构体允许我们将不同的数据类型组合成一个新的复合类型。

考虑以下结构体定义:

struct fraction {
    int num;
    int denom;
};

这个结构体包含两个整型字段:num(分子)和 denom(分母)。

当我们声明一个变量时:

struct fraction pi;

内存中会分配足够容纳两个 int 的空间(通常是8字节,假设 int 为4字节)。这些字节在内存中是连续、紧密排列的。

以下是其内存布局的可视化表示:

整个结构体的地址(&pi)与它的第一个字段 num 的地址(&pi.num)是相同的。字段 denom 紧接着存储在 num 之后的高地址处。

当我们通过指针操作结构体时,例如 &pi,这个地址指向的就是结构体起始位置,也就是字段 num 的开始。

本节课中我们一起学习了指针的强制类型转换及其风险、字节序的概念,以及C语言结构体的基本内存布局。理解数据在内存中的实际存储方式是掌握底层编程和调试复杂问题的关键。下一节我们将探讨数组以及结构体与数组的结合使用。

004:通用交换函数

在本节课中,我们将学习如何编写一个通用的交换函数,它能够交换任意类型的数据,而不仅仅是整数。我们将从回顾一个特定于整数的交换函数开始,然后探讨如何将其通用化,使用C语言中的void*指针和memcpy函数来实现。

上一节我们介绍了如何使用指针交换两个整数。本节中我们来看看如何将这个函数通用化,使其能够交换任意大小的数据。

回顾整数交换

以下是之前实现的整数交换函数代码:

void swap(int* ap, int* bp) {
    int temp = *ap;
    *ap = *bp;
    *bp = temp;
}

这个函数通过指针apbp接收两个整数的地址。算法上,它交换了这两个地址所指向的整数。从表示层面看,它交换了这两个整数的位模式。

这个实现隐含地知道正在移动的字节数,因为int类型在编译时被理解为4字节的数据。如果我们想用这个函数交换double类型、structclass,将无法实现。

通用交换函数的目标

我们希望编写一个版本的swap函数,能够交换两个任意大小的数据。这两个数据本身大小相同,但我们不希望将其限制为4字节。

在纯C语言中,实现这一目标的方法是使用通用指针类型void*void*并不意味着它不指向任何东西,而是意味着它指向一个没有任何类型信息的东西。

函数原型如下:

void swap(void* vp1, void* vp2, int size);

参数vp1vp2指向内存中某些数据的起始地址。我们用“L”形而不是矩形来表示它们,因为我们不知道它们有多宽。size参数应明确指定为构成被交换数据的字节数。

实现通用交换

以下是通用交换函数的实现尝试,但存在一些问题:

void swap(void* vp1, void* vp2, int size) {
    void temp = *vp1; // 错误:不能声明void类型的变量
    *vp1 = *vp2;      // 错误:不能解引用void*指针
    *vp2 = temp;
}

这里有两个问题:

  1. 不能声明一个类型为void的变量tempvoid仅用作函数的返回类型,表示没有返回值。
  2. 不允许解引用void*指针。原因是它没有任何关于所指向数据大小的类型信息,不知道是处理1字节、2字节还是4字节的数据。

正确的实现方法

正确的实现需要利用一个临时缓冲区,并使用内存复制函数memcpymemcpy函数可以按字节复制内存,而不关心数据内容。

以下是正确的实现代码:

void swap(void* vp1, void* vp2, int size) {
    char buffer[size]; // 声明一个大小为`size`的字符缓冲区
    memcpy(buffer, vp1, size); // 将vp1指向的size字节复制到buffer
    memcpy(vp1, vp2, size);    // 将vp2指向的size字节复制到vp1
    memcpy(vp2, buffer, size); // 将buffer中的size字节复制到vp2
}

char buffer[size];声明了一个大小为size字节的字符数组,用作临时存储空间。memcpy函数将指定数量的字节从一个内存位置复制到另一个位置。

假设我们有一个8字节的数据。memcpy(buffer, vp1, size);会将vp1地址处的8字节位模式,逐字节地复制到buffer中。随后的memcpy调用则完成位模式的交换。

总结

本节课中我们一起学习了如何实现一个通用的数据交换函数。我们回顾了特定类型的交换函数的局限性,引入了void*通用指针和size参数来处理未知大小的数据。通过使用memcpy函数和临时缓冲区,我们成功地实现了一个能够交换任意大小、任意类型数据的通用swap函数。这种方法的核心思想是交换数据的底层位模式,而不关心其具体的类型含义。

005:通用线性搜索实现

在本节课中,我们将学习如何用C语言实现一个通用的线性搜索函数。这个函数不依赖于任何特定的数据类型,可以用于搜索任何类型的数组。我们将重点理解如何通过指针运算和回调函数来实现这种通用性。

上一节我们讨论了从特定类型(如整数)的搜索转向通用搜索的需求。本节中我们来看看如何具体实现这个通用的 lsearch 函数。

函数原型与参数

lsearch 函数需要以下参数来执行通用搜索:

  • void *key: 指向要搜索的键值的指针。
  • void *base: 指向数组起始位置的指针。
  • int n: 数组中元素的数量。
  • int elemSize: 数组中每个元素的大小(以字节为单位)。
  • int (*cmpFn)(void *, void *): 一个指向比较函数的指针。该函数接收两个 void* 参数并返回一个整数(0表示相等)。

核心算法实现

线性搜索从前到后遍历数组的基本算法不变。关键在于我们需要一种不关心具体数据类型的方式来访问和比较元素。

以下是 lsearch 函数的核心实现逻辑:

void *lsearch(void *key, void *base, int n, int elemSize, int (*cmpFn)(void *, void *)) {
    for (int i = 0; i < n; i++) {
        // 计算第 i 个元素的地址
        void *elemAddr = (char *)base + i * elemSize;
        // 使用客户端提供的比较函数进行比较
        if (cmpFn(key, elemAddr) == 0) {
            return elemAddr; // 找到匹配项,返回其地址
        }
    }
    return NULL; // 未找到匹配项
}

代码解析

  1. 地址计算(char *)base + i * elemSize 是关键的指针运算。由于 void* 指针不能直接进行算术运算,我们将其临时转换为 char* 类型。char 的大小为1字节,因此 (char *)base 移动 i * elemSize 个字节后,就精确地指向了数组中的第 i 个元素。
  2. 通用比较:我们不再使用 == 运算符直接比较,而是调用客户端传入的 cmpFn 函数。这确保了无论数组存储的是什么类型的数据,都能用正确的方式进行比较。

如何使用通用搜索函数

理解代码实现后,使用它作为客户端同样重要。我们需要准备数据并提供一个正确的比较函数。

假设我们有一个整数数组,并想在其中搜索数字 7

以下是调用 lsearch 的步骤:

int array[] = {4, 2, 9, 7, 1, 7};
int key = 7;
int n = 6;

// 调用 lsearch 进行搜索
int *found = (int *)lsearch(&key,        // 要搜索的值的地址
                            array,       // 数组起始地址
                            n,           // 元素个数
                            sizeof(int), // 每个元素的大小
                            intCmp);     // 整数比较函数

if (found == NULL) {
    printf("未找到。\n");
} else {
    printf("找到值:%d\n", *found);
}

编写比较函数

lsearch 的第五个参数需要一个特定原型的函数:int (*)(void *, void *)。我们必须提供一个匹配此原型的函数。

以下是用于比较两个整数的 intCmp 函数实现:

int intCmp(void *vp1, void *vp2) {
    // 1. 将 void* 指针转换为 int* 指针
    int *ip1 = (int *)vp1;
    int *ip2 = (int *)vp2;
    // 2. 解引用指针,获取实际的整数值
    // 3. 使用整数减法来实现比较(返回0表示相等)
    return *ip1 - *ip2;
}

函数解析

  • 参数是两个 void* 指针,它们分别指向要比较的两个值(例如,搜索键 key 和数组中的某个元素 elemAddr)。
  • 在函数内部,我们首先将通用的 void* 指针转换回我们知道的具体类型指针 int*
  • 然后,通过解引用(* 操作符)获取指针所指向的实际整数值。
  • 最后,返回两个整数的差。按照约定,如果差值为 0,则两个值相等,lsearch 函数即认为找到了匹配项。

本节课中我们一起学习了如何实现和使用一个通用的线性搜索函数。我们掌握了几个核心概念:

  1. 使用 void* 指针来编写不依赖特定数据类型的通用代码。
  2. 通过将 void* 转换为 char* 并结合元素大小(elemSize)进行指针运算,来访问数组中的任意元素。
  3. 利用回调函数cmpFn)将数据比较的逻辑从通用算法中分离出来,交由客户端提供。这是实现通用性的关键。
  4. 作为客户端,需要正确传递数据地址、元素大小,并实现一个符合 int (*)(void *, void *) 原型的比较函数。

通过这种方式,同一个 lsearch 函数可以用于搜索整数数组、字符串数组、结构体数组等任何数据类型,只要提供相应的比较函数即可。

006:C语言栈实现详解

在本节课中,我们将学习如何使用纯C语言实现一个整数栈。我们将从栈的基本结构开始,逐步实现其初始化、销毁、压入和弹出操作,并重点关注动态内存管理。

栈结构定义与接口

上一节我们介绍了栈的基本概念,本节中我们来看看如何在C语言中定义栈的结构和操作接口。

在C语言中,我们没有类的概念,因此通过结构体和一组操作函数来模拟对象。栈的接口定义在头文件中,结构体的所有成员都是公开的。

typedef struct {
    int *elems;          // 指向动态数组的指针
    int logicalLength;   // 栈中当前元素数量
    int allocatedLength; // 数组已分配的空间大小
} stack;

以下是栈的操作函数原型:

  • stackNew:初始化一个栈结构。
  • stackDispose:销毁一个栈,释放其占用的内存。
  • stackPush:将一个整数压入栈顶。
  • stackPop:从栈顶弹出一个整数。

栈的初始化与销毁

现在,我们开始实现栈的初始化和销毁函数。

栈初始化 (stackNew)

stackNew 函数负责将一个已分配内存的 stack 结构体初始化为一个空栈。

void stackNew(stack *s) {
    s->logicalLength = 0;                 // 栈初始为空
    s->allocatedLength = 4;               // 初始分配4个整数的空间
    s->elems = (int*)malloc(4 * sizeof(int)); // 分配内存
    assert(s->elems != NULL);              // 确保内存分配成功
}

代码解析:

  1. logicalLength 设置为0,表示栈为空。
  2. allocatedLength 初始化为4,这是我们为栈元素数组预分配的大小。
  3. 调用 malloc 动态分配足够容纳4个整数的内存块,并将返回的地址赋给 elems 指针。
  4. 使用 assert 宏检查 malloc 是否成功,若失败(返回 NULL)则程序终止并报错。

栈销毁 (stackDispose)

stackDispose 函数负责清理栈占用的资源,主要是释放动态分配的数组内存。

void stackDispose(stack *s) {
    free(s->elems); // 释放存储栈元素的数组
}

重要说明:

  • 我们只释放 s->elems 指向的内存,而不释放 s 本身。因为 stackNew 并不负责分配 stack 结构体的内存,它只是初始化一个由调用者提供的现有结构体。这个结构体可能是在栈上分配的局部变量,也可能是动态分配的。
  • 对于当前存储整数的栈,我们只需释放数组。但如果栈存储的是指向动态内存的指针(例如字符串),则需要在释放数组前,先遍历并释放其中每个元素指向的内存,以避免内存泄漏。当我们后续实现通用栈时,会考虑这一点。

栈的压入操作

接下来,我们实现栈的核心操作之一——压入 (stackPush)。这个函数的有趣之处不仅在于添加元素,更在于当数组空间不足时如何动态扩展。

基本压入逻辑

当栈的当前逻辑长度小于已分配长度时,压入操作很简单。

void stackPush(stack *s, int value) {
    // 检查空间是否足够,若不够则扩展内存(代码见下一部分)
    // ...
    // 空间足够时,执行压入
    s->elems[s->logicalLength] = value;
    s->logicalLength++;
}

代码解析:

  1. logicalLength 既表示栈的当前深度,也指示了下一个元素应该插入的数组索引位置。
  2. value 存入数组的 logicalLength 索引处。
  3. logicalLength 加1,为下一次压入做准备。

动态数组扩容策略

当尝试压入元素但数组已满时(即 logicalLength == allocatedLength),我们需要分配一个更大的数组。

以下是处理扩容的步骤:

  1. allocatedLength 加倍(这是一种常用策略,并非唯一选择)。
  2. 使用 malloc 分配一个大小为 newAllocatedLength * sizeof(int) 的新数组。
  3. 将旧数组中的所有元素复制到新数组中。
  4. 释放旧数组占用的内存。
  5. 更新栈的 elems 指针,使其指向新数组。
  6. 更新 allocatedLength 为新的大小。
if (s->logicalLength == s->allocatedLength) {
    // 空间不足,需要扩容
    s->allocatedLength *= 2; // 分配长度加倍
    int *newElems = (int*)malloc(s->allocatedLength * sizeof(int));
    assert(newElems != NULL);

    // 复制旧数据
    for (int i = 0; i < s->logicalLength; i++) {
        newElems[i] = s->elems[i];
    }

    // 释放旧数组,更新指针
    free(s->elems);
    s->elems = newElems;
}
// 然后执行压入操作...

总结

本节课中我们一起学习了如何使用纯C语言实现一个整数栈。我们定义了栈的结构体,并实现了四个关键函数:stackNew 用于初始化栈,stackDispose 用于清理资源,stackPush 用于压入元素并处理数组的动态扩容。我们重点理解了C语言中手动管理内存的方式,包括使用 malloc 分配和 free 释放内存,以及通过 assert 进行错误检查。这个实现为后续学习更通用的、可存储任意数据类型的栈打下了基础。

007:通用栈与C字符串操作

本课程由斯坦福大学专业发展中心提供。

大家好。今天有一份讲义发给大家,是第三次作业。这份作业在下周四晚上截止。你们将在下周三收到第四次作业,它将在再下一个周四晚上截止。此外,几周后的周三会有一份书面习题集,不需要提交,但会为接下来的周三晚上的期中考试提供一系列需要掌握的书面问题。届时我会在发放时详细说明。

上一节我们介绍了通用栈的完整实现。为了简化,我们当时主要关注了存储intdoublecharbool类型。现在,我们将暂时搁置实现细节约10到15分钟,重新审视它,并思考如何使用这个通用栈来存储一组字符串,并以相反的顺序打印它们。

接下来要展示的代码本质上只是逆序打印一个数组,但其目的是为了演示在存储C风格字符串时,使用那四个栈操作函数(newpushpopdispose)的机制。这在后续处理C字符串时非常重要,因此我们现在进行这个练习。

假设这是你的main函数。我们不关心argcargv,但需要声明一个栈。

我将强调这是一个字符串栈。

我的目标是压入这四个字符串的深拷贝。定义const char*数组,我们称之为friends,并初始化为以下值。

const char* friends[] = {"Al", "Bob", "Carl"};

接下来,声明一个栈。根据之前的类型定义,此时栈的图示并不复杂。

它只是一个16字节的空(或无意义)内存块。然后,我们调用StackNew

StackNew(&stringStack, sizeof(char*));

这里传入sizeof(char*)。现在情况变得有点复杂了。我们要求栈跟踪动态分配的C字符串的地址。所以,栈初始化后,我们得到这样的结构:它指向一个16字节的内存块,栈深度为0,但为元素预留了空间。这里的4实际上就是sizeof(char*)

现在,我们用一个for循环来创建每个字符串的拷贝。

for (int i = 0; i < 3; i++) {
    char* copy = strdup(friends[i]);
    // ...
}

copy是一个足够重要的变量,值得画图说明。在第一次迭代(i = 0)时,copy被设置为指向堆中friends[0](即"Al")的一个深拷贝。

当我们向栈中压入一个元素时,所有权从我们转移到了栈。基于此,我们这样调用StackPush

StackPush(&stringStack, &copy);

这里存在一些争议:下一个参数应该是什么?由于我们存储的是char*,这意味着栈需要保存这些4字节的字符指针。因此,我们必须传入一个char*的地址,这样栈才知道去那个地址复制4字节的内容到栈内存中。这能理解吗?

传入&copy后,栈会复制copy变量中的内容(即那个地址),将其存入栈中,并将栈深度计数器加1。

在第二次迭代中,copy被重新声明和初始化,指向"Bob"。我们再次传入&copy,栈复制其中的地址值。这个过程就像循环吹起了三个分别写着Al、Bob、Carl名字的气球,然后栈在幕后通过memcpy复制了这些“气球线”(即字符串地址)。我们正在将这三个动态分配的字符串拷贝的所有权转移给栈。

接下来,我们想打印这些字符串,这需要从栈中取回所有权。

for (int i = 0; i < 3; i++) {
    char* name;
    StackPop(&stringStack, &name);
    printf("%s\n", name);
    free(name);
}

我们希望栈将最近压入的字符串地址放回name变量所指向的空间。第一次pop时,name会指向"Carl",同时栈的逻辑长度减为2。理解了吗?

然后我们可以用printf打印这个字符串,并用free释放name指向的内存,将其归还给堆。

为了保持代码整洁,最后我们调用:

StackDispose(&stringStack);

传入&stringStack。至此完成。

这里StackPushStackPop调用中的&(取地址符)通常不会让人意外,因为stringStack是函数栈帧上的一个直接分配的变量,我们需要传递它的地址。

但是,&copy&name这里的&常常让人困惑。如果省略它们,基于之前例子的同样原因,代码可能仍能编译运行。但如果你向StackPush提供了copy的地址(&copy),那么你就做对了。如果你省略了&,直接传入copy的值,栈会去复制"Bob\0"这些字符本身,仿佛它们是一个地址,并将其复制到栈内存中。这显然不是我们想要的。理解了吗?

现在,问题来了。

假设我注释掉所有代码,或者把循环条件改为i < 2之类的。在某个时刻,我...

(注:原文在此处中断,教程内容结束)


本节课总结

本节课我们一起学习了如何利用之前实现的通用栈来存储C风格字符串。我们通过一个逆序打印字符串数组的例子,详细演示了栈的四个核心操作(newpushpopdispose)在处理指针类型(如char*)时的具体用法,特别是所有权转移的概念以及传递变量地址(&操作符)的重要性。理解这些机制是后续高效、安全地操作C字符串和动态内存的基础。

008:堆内存管理

概述

在本节课中,我们将要学习C语言中堆内存的管理机制。我们将深入探讨mallocfreerealloc函数在幕后是如何工作的,特别是它们如何通过一个“头部”结构来记录内存块的大小,以及为什么某些看似合理的操作会导致未定义行为。

内存布局与堆段

上一节我们介绍了内存的不同分段。本节中我们来看看堆段的具体管理方式。

每个应用程序在运行时都表现得好像它拥有全部内存。实际上,堆的起始和结束地址会在程序加载时告知一个库,这个库负责实现mallocfreerealloc

堆本质上是一个字节的“沙盒”。malloc负责分配地址。在幕后,它会记录每个分配块的实际大小,这样当free被调用并传入一个指针时,它就知道应该将多少内存归还给堆。

内存分配的幕后机制

因为堆是由软件管理的,所以mallocfree的实现者可以使用任何他们认为能使其运行得更快、更高效、更正确的启发式算法。

你一直以来的印象是,当你写下这样的代码时:

int *arr = malloc(40 * sizeof(int));

你认为你恰好得到了160字节。但事实并非如此。系统实际上为你预留了超过160字节的空间。

假设这是堆,malloc会找到一个足够大的节点来满足这个请求。我们可能认为它恰好是160字节,但实际上返回给你的地址指向的节点更大。

内存块头部

在你获得指针之前,如果你请求160字节,系统通常会预留164或168字节。这是因为它会在完整节点的开头预留一小块空间(例如4、8、16或32字节),作为头部,用于记录该节点的大小信息。

例如,如果实际数据是160字节,头部是4字节,那么系统可能会在这4字节中写入数字164

当你获得一个指针时,你得到的并不是整个节点起始处的指针,而是从起始处偏移了4或8字节的地址。这意味着你似乎可以访问从该指针开始的所有空间。

当你将这个指针传回给free时,free会假设这是一个它之前分配出去的指针。它会将这个指针转换为int*long*等类型,以便优雅地向回移动4或8字节,读取它之前写入头部的信息(比如数字164),从而知道需要将多少字节的内存重新整合回堆中。

常见错误与未定义行为

理解了上述机制后,我们来看看几个问题,并理解它们为何无法正常工作。

场景一:释放部分数组

int *arr = malloc(100 * sizeof(int));
// ... 使用数组,假设只用了前60个元素 ...
free(arr + 60); // 错误!

如果你尝试释放arr + 60free会盲目地向回移动4或8字节,将arr[58]arr[59]所在位置的内存位模式解释为头部信息(比如大小)。如果那里恰好存储着数字29000free就会尝试将一个29000字节的块整合回堆,这完全破坏了堆的数据结构。因此,你不能这样做。

场景二:释放静态数组

int arr[100];
// ... 使用数组 ...
free(arr); // 错误!

如果你静态分配了一个数组(不涉及堆),然后尝试free它,free同样不会进行错误检查来确认这个地址是否在堆段内。它会走到你静态数组的基地址,向回移动4或8字节,将那里碰巧存在的位模式解释为某个节点的大小,并尝试将其整合到空闲节点集合中。这必然导致程序崩溃或数据损坏。

实现策略与错误检查

最好的(或者说最快的)实现通常不进行错误检查,它们使用启发式算法来确保尽可能快地运行。有些实现可能会维护一个已分配指针的集合,并在调试模式下检查传入free的指针是否在该集合中,如果不在则报错。但在非调试模式下,为了速度,它们可能不做此类检查。

总结

本节课中我们一起学习了C语言堆内存管理的核心机制。我们了解到:

  • malloc分配的内存块包含一个记录大小的头部
  • 返回给用户的指针指向头部之后的数据区。
  • free通过回退指针并读取头部信息来确定要释放的内存大小。
  • 不能释放非malloc/realloc返回的指针(如静态数组地址)。
  • 不能释放指向分配块内部的指针(如arr + 60)。
  • 许多malloc/free的实现为了追求速度,默认不进行严格的错误检查,因此程序员必须确保正确使用这些函数。

009:斯坦福大学【中英⚡编程范式|CS107 Programming Paradigms】 p09 P9 ) -BV1aJ1EBHEoN_p9-

本演示由斯坦福大学专业发展中心提供。

在本节课中,我们将学习计算机体系结构和汇编语言的基础知识。我们将探讨C/C++代码片段如何编译为汇编代码,理解内存模型,并了解函数调用和返回的工作原理。我们将使用一个为CS107课程设计的模拟汇编语言来展示加法、乘法、除法、函数调用、返回和指针等操作在硬件层面是如何实现的。

上一节我们介绍了栈段和活动记录的概念。本节中,我们来看看如何通过汇编指令来操作活动记录中的变量。

这里有一段简单的C代码块:

int i;
int j;
i = 10;
j = i + 7;
j++;

我们假设 ij 被打包在一个8字节的活动记录中,并位于栈段的某个位置。寄存器 R1 存储着这个活动记录的基地址。

为了将值 10 存入变量 i 的位置,我们不需要直接处理 i 的绝对地址,而是使用相对于基地址 R1 的偏移量。在我们的模拟汇编语言中,对应的指令是:

M[R1 + 4] = 10

这条指令是一个存储操作。M 代表整个内存(RAM),可以将其视为一个巨大的字节数组。R1 + 4 计算出了变量 i 在内存中的确切地址,并将值 10 存入该地址。

接下来,我们需要执行 j = i + 7。最稳健的方法是先获取 i 的值,在寄存器中完成加法,再将结果写回 j 的位置。这涉及以下步骤:

首先,我们需要将 i 的值从内存加载到寄存器中。这是一个加载操作:

R2 = M[R1 + 4]

执行后,寄存器 R2 中包含了变量 i 的4字节位模式(即值 10)。

然后,我们在寄存器中执行加法运算。这是一个算术逻辑单元操作:

R3 = R2 + 7

这条指令将寄存器 R2 中的值与立即数常量 7 相加,并将结果存入寄存器 R3。现在 R3 中存储着值 17

最后,我们需要将结果 17 存储到变量 j 的位置。这又是一个存储操作:

M[R1 + 8] = R3

这里,R1 + 8 是变量 j 相对于基地址的偏移量。

至此,我们完成了 j = i + 7 的汇编实现。这个“加载-运算-存储”的指令序列在C/C++的赋值语句编译中非常常见。

对于最后一行代码 j++,其本质是 j = j + 1。其汇编实现结构与上面类似:

R2 = M[R1 + 8]
R2 = R2 + 1
M[R1 + 8] = R2

我们首先将 j 的值加载到寄存器 R2,然后将其加1,最后将新值存回 j 的内存位置。在这个过程中,我们可以复用寄存器,只要不再需要旧值即可。

关于这些指令,有几点需要注意:默认情况下,所有的加载、存储和ALU操作都处理4字节的量。这是因为在C/C++编程中,指针和整型是最常见的原子类型,硬件默认优化了对4字节数据的处理。当然,我们也可以处理字符、短整型、双精度浮点数或整个结构体,但本节课我们主要关注4字节的操作。

你可能会问,为什么我们不直接写成 R3 = 10 + 7 呢?原因是,我们希望生成的汇编代码是上下文无关且健壮的。上面展示的三条指令块能够正确工作,即使我们改变了源代码中 i 的初始值(例如将 i = 10 改为 i = 20),汇编代码也无需修改就能正确计算出 j 的新值。这种生成方式保证了代码的通用性。

本节课中,我们一起学习了如何将简单的C语言赋值语句翻译成底层的汇编指令。我们了解了加载存储ALU运算这三种基本操作,并通过一个具体例子看到了“加载-运算-存储”这一通用模式是如何实现变量赋值和计算的。我们还理解了使用基地址加偏移量的方式来访问局部变量,以及生成上下文无关的汇编代码的重要性。这些是理解程序如何在硬件层面运行的基础。

010:第10讲 - 函数调用与栈帧详解 🖥️

在本节课中,我们将深入学习函数调用时栈内存的具体布局,特别是栈帧(或称活动记录)的构成。我们将通过一个具体的C语言函数示例,详细拆解参数、局部变量以及关键控制信息在内存中的排列方式。

上一节我们介绍了栈内存的基本概念和局部变量的存储。本节中我们来看看当一个函数被调用时,其完整的栈帧是如何构建的。

栈帧的完整结构

考虑以下函数原型:

void foo(int bar, int *baz) {
    char qux[4];
    short *quux;
    // ... 函数体代码
}

函数 foo 接收一个整型参数 bar 和一个整型指针参数 baz,并在内部声明了一个4字节的字符数组 qux 和一个指向短整型的指针 quux

这个函数的栈帧在内存中的布局并非随意。其完整结构如下图所示(地址从下往上增长):

高地址
+-------------------+
|  返回地址 (PC)    |  // 调用者下一条指令地址
+-------------------+
|       baz         |  // 第二个参数
+-------------------+
|       bar         |  // 第一个参数
+-------------------+
|       ...         |  // 可能的其他空间
+-------------------+
|      *quux        |  // 局部变量:指针
+-------------------+
|    qux[0..3]      |  // 局部变量:字符数组
+-------------------+
低地址  <-- 栈指针 (SP)

关于此布局,有几个关键点需要注意:

  • 参数(bar, baz)被压入调用者的栈帧顶部,顺序是从右向左。即最后一个参数(baz)先入栈(位于较低地址),第一个参数(bar)后入栈(位于较高地址)。
  • 局部变量(qux, quux)在函数被调用后,在自身的栈帧内分配空间,位于参数的下方(更低地址处)。
  • 在参数和局部变量之间,存在一个保存的程序计数器(PC)值,通常称为“返回地址”。它记录了当 foo 函数执行完毕后,应该跳转回调用者(例如 main 函数)的哪条指令继续执行。

栈帧的构建过程

现在,我们通过一个调用示例来看栈帧是如何在运行时动态构建的。假设 main 函数如下:

int main(int argc, char *argv[]) {
    int i = 4;
    foo(i, &i);
    return 0;
}

main 函数开始执行时,它已经拥有一个部分构建的栈帧,其中包含了传入的参数 argcargv。当执行到 foo(i, &i) 这一行时,会发生以下步骤:

  1. 参数压栈:调用者(main)负责将参数按从右到左的顺序压入当前栈顶。首先计算 &i 的地址并压栈,然后将 i 的值(4)压栈。
  2. 调用指令:执行 call foo 指令。该指令会做两件事:首先将下一条指令的地址(返回地址)压入栈中,然后跳转到 foo 函数的代码起始处。
  3. 分配局部变量空间:进入 foo 函数后,函数序言代码会通过移动栈指针(SP) 来为局部变量分配空间。例如,为 qux 数组和 quux 指针分配总共8字节空间(假设4字节对齐),指令类似于 SP = SP - 8
  4. 栈指针与帧指针:在之前的简化模型中,我们使用一个通用寄存器(如R1)指向栈帧基址。在实际架构中,通常有一个专用寄存器帧指针(FP或BP) 来稳定地指向当前栈帧的起始处(即保存的返回地址附近),而栈指针(SP) 则总是指向栈的当前最低地址(即最后一个分配的局部变量处)。

通过以上步骤,foo 函数的完整栈帧就在内存中构建完毕了。函数执行期间,通过帧指针的固定偏移可以访问参数和局部变量。函数返回时,过程相反:释放局部变量空间、弹出返回地址并跳转、由调用者清理参数栈空间。

本节课中我们一起学习了函数栈帧的详细内存布局和构建过程。我们明确了参数从右向左压栈的规则,理解了返回地址的关键作用,并跟踪了从函数调用到分配局部变量的完整步骤。掌握这些细节对于理解程序底层执行机制、调试内存问题至关重要。下一讲我们将继续利用这些知识,深入探讨更复杂的控制流和数据结构在栈上的表现。

011:函数调用与栈帧管理

本课程由斯坦福大学专业发展中心提供。

概述

在本节课中,我们将学习函数调用在汇编层面的实现机制,特别是栈帧的构建与销毁过程。我们将通过一个简单的 swap 函数示例,对比C语言指针版本和C++引用版本在汇编层面的异同,并详细分析其栈帧布局。

函数 foo 的栈帧构建

上一节我们介绍了函数调用的一般原理。本节中,我们来看看一个具体函数 foo 的栈帧是如何构建的。

我们首先定义一个 void foo() 函数,它分配两个局部变量 xy,并调用 swap 函数。

void foo() {
    int x = 11;
    int y = 17;
    swap(&x, &y);
}

foo 被调用时,栈指针(SP)指向调用者保存的返回地址(PC)。函数 foo 需要为它的两个局部变量分配空间。

以下是 foo 函数开始执行时栈帧的初始状态和操作步骤:

  1. 保存返回地址:栈顶(SP指向的位置)保存着调用 foo 的指令的下一条指令地址。
  2. 分配局部变量空间:栈指针向下移动,为 xy 分配空间。假设每个 int 为4字节,则总共需要8字节。
  3. 初始化局部变量:将值 1117 存入为 xy 预留的栈位置。
    • M[SP + 4] = 11 // 假设 x 在较高地址
    • M[SP] = 17 // 假设 y 在较低地址
  4. 准备调用 swap 的参数:计算 &x&y 的地址,并将它们压入栈中,为调用 swap 做准备。
    • R1 = SP + 4 // R1 保存 &x
    • R2 = SP // R2 保存 &y
    • 栈指针再次下移8字节(为两个指针参数腾出空间)。
    • M[SP] = R2 // 第一个参数(&y)入栈
    • M[SP + 4] = R1 // 第二个参数(&x)入栈
  5. 调用 swap:执行 call swap 指令。这会将当前的程序计数器(PC)值(即 call 指令后的下一条指令地址)压栈,并跳转到 swap 函数的代码。

在调用 swap 之前,栈帧布局如下(地址从高到低增长):

... (调用者的栈帧) ...
返回地址 (调用foo后的下一条指令)
x = 11
y = 17
&y (swap的第一个参数)
&x (swap的第二个参数) <-- SP 指向这里

swap 函数的栈帧与操作

现在,我们进入被调用的 swap 函数,看看它是如何工作的。

swap 函数使用指针实现变量值交换:

void swap(int* a, int* b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

swap 开始执行时,栈指针(SP)指向保存的返回地址(即 foocall swap 之后的那条指令地址)。根据约定,它的参数 ab 分别位于 SP+4SP+8 的位置。

以下是 swap 函数的执行步骤:

  1. 分配局部变量空间:栈指针下移,为局部变量 temp 分配4字节空间。
    • SP = SP - 4
  2. 访问参数并取值:通过参数指针获取待交换的值。
    • R3 = M[SP + 8] // 加载参数 a 的值(即 &x 的地址)到寄存器 R3
    • R4 = M[R3] // 解引用,获取 *a 的值(即 x 的值 11)到寄存器 R4
    • M[SP] = R4 // 将 *a 的值存入 temp 的空间
  3. 执行交换
    • R5 = M[SP + 4] // 加载参数 b 的值(即 &y 的地址)到寄存器 R5
    • R6 = M[R5] // 解引用,获取 *b 的值(即 y 的值 17)到寄存器 R6
    • M[R3] = R6 // 将 *b 的值(17)写入 a 指向的地址(即 x 现在为17)
    • R7 = M[SP] // 从 temp 中加载之前保存的 *a 的值(11)
    • M[R5] = R7 // 将 temp 的值(11)写入 b 指向的地址(即 y 现在为11)
  4. 清理栈帧并返回
    • SP = SP + 4 // 释放局部变量 temp 的空间
    • ret // 从栈顶弹出返回地址并跳转回去

此时,swap 函数栈帧的典型布局如下:

... (foo的栈帧) ...
返回地址 (call swap后的下一条指令)
temp (用于交换的临时变量) <-- SP 最初指向这里,执行后上移
参数 b (&y) 位于 M[SP+4] (相对swap开始时的SP)
参数 a (&x) 位于 M[SP+8] (相对swap开始时的SP)

返回 foo 与栈帧清理

swap 函数返回后,控制流回到 foo 函数中 call swap 指令之后。根据协议,swap 的返回指令已经将栈指针恢复到了调用前的状态(即指向参数 &x 的位置)。

foo 函数需要负责清理为调用 swap 而压入的参数:

  1. 清理参数空间:栈指针上移8字节,丢弃两个指针参数。
    • SP = SP + 8
  2. 函数返回:由于 xy 的值已通过指针被 swap 修改,且 foo 没有其他操作,因此可以直接执行 ret 指令返回。ret 指令会从栈顶弹出返回地址并跳转。

最终,整个调用过程中栈指针的变化是平衡的:分配了多少空间,就释放了多少空间。

关于C++引用版本的说明

一个关键点是,如果 swap 函数使用C++的引用参数来实现:

void swap(int& a, int& b) {
    int temp = a;
    a = b;
    b = temp;
}

并在 foo 中调用 swap(x, y),其生成的汇编代码与上述指针版本完全相同。编译器在底层将引用视为自动解引用的指针。参数传递时,传递的仍然是 xy 的地址;函数内部操作时,也是通过地址进行间接访问和修改。这展示了高级语言特性在底层实现上的一致性。

总结

本节课我们一起学习了函数调用中栈帧管理的完整过程。我们详细分析了调用者(foo)如何构建栈帧、传递参数,以及被调用者(swap)如何访问参数和局部变量、执行操作,并在返回后双方如何协作清理栈空间。重要的是,我们了解到C语言的指针和C++的引用在机器码层面实现方式是相同的,它们都是通过传递内存地址来实现对实参的间接访问和修改。理解这些底层机制对于掌握编程范式和调试复杂程序至关重要。

012:预处理、编译与链接

本演示由斯坦福大学专业发展中心提供。

大家好。今天有两份讲义要发给大家,一份是明天讨论课的讲义,其内容主要围绕我上周一、周三和周五讨论的汇编代码生成。另一份是作业5。我原计划周三发,但决定提前发,以便大家有更灵活的时间来处理这些问题。作业5不需要提交,它是一个书面习题集,没有任何编程练习,主要是大量关于代码生成的练习。这些内容在明天的讨论课中会涉及,并且也肯定会出现在下周三晚上的期中考试中。作业5的唯一要求是大家完成习题,并确保答案与我的答案思路一致。期中考试将于下周三晚上7点举行,具体地点我将在接下来两天内确定。本周三,我会发一份模拟试题及其答案,供大家在未来一周练习。讨论课讲义上的习题,如果有20道,其中19道都来自过去的模拟试题,所以务必理解它们。考试时,可以携带任何课堂笔记、作业打印稿等,也可以带教材,但我认为必要性不大,因为所有考核内容都已涵盖在课堂或讲义中。作业4将于本周四截止,这可能是本学期迄今为止最难的一次作业,建议大家尽早开始。

上一节我们介绍了C预处理器的基础概念。本节中,我们来看看预处理、编译和链接这三个阶段的区别。在之前的课程中,你可能习惯于将它们视为一个整体操作。实际上,这三个步骤是按顺序在后台执行的,但通常不会明确告知是哪个阶段出了问题。因此,了解每个阶段的具体职责非常重要。

上次结束时,我刚刚引入了 #define 的概念。它本质上就是一种高级的查找和替换操作,将这个标记替换为那段文本。例如:

#define WIDTH 40
#define HEIGHT 80

在这两行之后,任何出现 WIDTHHEIGHT 的地方(字符串常量内部除外),都会被替换为 4080。这种替换甚至会发生在后续的 #define 中。例如:

#define PERIMETER 2 * (WIDTH + HEIGHT)

那么,PERIMETER 在预处理后,看到的不是 WIDTHHEIGHT 这两个标记,而是已经被替换为 4080 的文本流,即 2 * (40 + 80)。预处理器并不计算这个表达式,它只是进行文本替换。#define 在纯C语言中主要用于整合那些“魔数”和字符串常量,并赋予它们有意义的名称。

关于 #define,你可能不知道的是,它可以被扩展,并且可以像函数一样接受参数。它们不叫函数,而被称为宏。例如,我可以这样定义一个求最大值的宏:

#define MAX(A, B) ((A) > (B) ? (A) : (B))

只要在宏名 MAX 和左括号 ( 之间没有空格,它就被明确理解为一个带参数的宏定义。为了确保运算顺序和求值没有歧义,通常会在参数和整个表达式周围加上大量括号。这样,当这个宏在代码中后续被展开时,就不会因为运算顺序而产生混淆。

例如,如果在代码中写下 MAX(10, 40),预处理器会将其替换为 ((10) > (40) ? (10) : (40))。虽然这是一种判断40大于10的曲折方式,但这正是文本替换的结果。

所以,它就像一个 #define,是一种快速、直接的内联功能的方式,比编写一个完整的函数更简洁。当然,你也可以使用函数。但从上周看到的关于函数调用和返回的汇编代码可知,调用函数需要花费大量时间设置参数、跳转、返回并清理参数。虽然工作量不大,可能就10条汇编指令,但使用宏可能只需要三到四条指令。因此,使用传统函数调用来求最大值,大约70%的时间都花在了函数调用和返回上。使用 #define 宏,则可以在每个使用 MAX 的地方高效地展开这段代码。

宏并不要求参数 AB 必须是整数。从代码上看,它们应该是,但预处理器只进行模板化的查找和替换。如果我错误地写下 MAX(40, "hello"),预处理器只会将 A 替换为 40B 替换为 "hello"。只有在编译阶段,当编译器读到展开后的代码 ((40) > ("hello") ? (40) : ("hello")) 时,才会报错,比如不允许用 > 比较整数和字符串。所以错误是在编译时发现的,而不是预处理时。

关于这种用法是好是坏,我认为只要使用得当,并没有太大问题。我会在作业3的示例中展示两个 #define 宏的用法,其中一个你可能已经在不知不觉中使用了,另一个则在我的解决方案中。这个例子只是为了说明预处理仍然是文本替换,可能导致后续问题,这些问题可能在编译时被发现,也可能在运行时导致段错误。

关于这个 MAX 宏是否有返回值的问题:它本身不返回,但它是一个表达式,其求值结果要么是 A 的值,要么是 B 的值。所以在我划掉的那个错误例子之前,表达式会求值为数字 40

本节课中我们一起学习了C语言中预处理、编译和链接三个阶段的基本区别,重点探讨了 #define 宏的工作原理及其与函数调用的效率对比。理解这些底层过程,有助于我们编写更高效、更清晰的代码,并更好地诊断程序构建过程中可能出现的问题。

013:编译与链接详解

在本节课中,我们将深入探讨C语言程序的编译和链接过程。我们将通过一个具体的代码示例,分析预处理、编译、汇编和链接各个阶段的作用,并理解头文件、函数声明以及标准库是如何被整合到最终的可执行文件中的。

概述

上一节我们介绍了程序执行的内存模型和函数调用栈。本节中,我们来看看一个C语言程序是如何从源代码转变为可执行文件的。我们将重点关注gcc编译器的工作流程,并解释当缺少必要的头文件时会发生什么。

编译与链接过程

考虑以下简单的C程序:

#include <stdio.h>
#include <stdlib.h>
int main() {
    void* memory = malloc(400);
    assert(memory != NULL);
    printf("Yay!\n");
    free(memory);
    return 0;
}

这个程序完成了以下工作:

  • 调用malloc分配400字节内存。
  • 使用assert确保分配成功。
  • 调用printf打印信息。
  • 调用free释放内存。
  • 返回0。

当我们使用gcc编译此程序时,例如执行gcc program.c -o program,编译器会默认执行一系列步骤来生成可执行文件。

编译阶段

在编译阶段,gcc会将program.c这个翻译单元处理成一个.o(目标)文件。这个目标文件包含了对应的汇编代码。

对于我们的程序,生成的汇编代码中会包含:

  • call malloc指令
  • call printf指令
  • call free指令
  • mov $0, %eax(对应return 0;
  • 管理栈指针的指令(如sub $4, %espadd $4, %esp),用于处理局部变量和函数调用。

此时,这些函数调用(如malloc, printf)只是未解析的符号引用。编译器知道需要调用它们,但还不知道它们的实际代码在哪里。

链接阶段

链接器的工作是将一个或多个.o文件与所需的库(如C标准库)合并,生成最终的可执行文件。

以下是链接器执行的关键步骤:

  1. 它收集我们的program.o文件。
  2. 它从编译器自带的库中(如libc.a)找到mallocprintffree等函数的已编译代码(它们本身也是.o文件格式)。
  3. 它将所有必要的代码“拼接”在一起,形成一个完整的程序映像。
  4. 它会解析所有符号引用,将代码中的call malloc地址替换为实际malloc函数在内存中的地址。
  5. 通常,链接器会进行“死代码消除”,移除程序中从未被调用的函数代码,以减小可执行文件体积。

最终,生成的可执行文件包含了运行所需的所有机器指令。

头文件的作用

现在,让我们探讨头文件在编译过程中的实际作用。如果我们注释掉#include <stdio.h>这一行。

// #include <stdio.h>
#include <stdlib.h>
int main() {
    void* memory = malloc(400);
    assert(memory != NULL);
    printf("Yay!\n"); // 编译器此时没有看到printf的声明
    free(memory);
    return 0;
}

许多编译器会因此报错,因为它们对函数调用的检查非常严格,要求在使用前必须看到函数原型。然而,gcc的行为有所不同。

gcc在编译时看到一个函数调用(如printf(“Yay!\n”)),但没有找到该函数的显式声明(原型)时,它会尝试根据函数调用的方式推断一个隐式声明

推断的规则通常是:假设该函数返回int类型,并接受给定的参数。但这非常危险,因为如果推断错误(例如printf实际返回的是int,但假设正确;如果是一个返回double的函数,假设就错了),就会导致未定义行为。

核心概念:头文件(.h)在编译阶段的主要作用是向编译器提供类型信息(函数原型、结构体定义、宏等),确保编译器能正确检查代码的语法和类型安全性。它本身并不包含函数的具体实现代码。实现代码在库文件(.a.so)中,由链接器在链接阶段处理。

总结

本节课我们一起学习了C程序构建过程中的编译与链接阶段。

  • 编译将单个源文件转换为包含机器码和目标格式的目标文件(.o),其中外部函数调用仍是未解析的引用。
  • 链接将一个或多个目标文件与库文件合并,解析所有符号引用,分配最终的内存地址,生成可执行文件。
  • 头文件在编译时提供类型声明,确保编译器能进行类型检查,但其本身不包含实现代码。
  • gcc这样的编译器在缺少函数声明时可能会进行隐式声明推断,但这是一种不安全的做法,应始终包含正确的头文件。

理解这些步骤有助于我们诊断复杂的构建错误,并理解诸如“未定义的引用”和“多重定义”等链接器错误的根本原因。

014:内存模型与函数调用

在本节课中,我们将深入探讨C语言中的内存模型,特别是函数调用时栈帧(激活记录)的行为。我们将通过具体的代码示例,理解局部变量的存储方式、数组越界操作的后果,以及函数调用之间栈内存的“残留”现象。这些知识对于理解程序运行机制和调试复杂问题至关重要。


栈帧与局部变量

上一节我们介绍了函数调用时栈帧的构成。本节中,我们来看看一个具体的例子,它展示了局部变量在栈上的布局以及不当操作可能带来的问题。

考虑以下代码片段:

void f() {
    int array[4];
    int i;
    for (i = 0; i <= 4; i++) {
        array[i] -= 4;
    }
}

在这段代码中,我们声明了一个长度为4的整型数组array和一个循环变量i。循环试图对数组的每个元素减去4。

然而,循环条件i <= 4是一个错误,它会导致数组越界访问。当i等于4时,array[4]的赋值操作实际上会修改到栈上紧邻数组之后的内存位置。

在我们的简化内存模型中,栈帧的布局如下:

  1. 数组array(占用4个int的空间)。
  2. 变量i
  3. 保存的返回地址(Saved PC)。

当执行array[4] -= 4;时,程序错误地修改了保存的返回地址,使其指向了前一条指令。因此,当函数f返回时,它不会正常返回到调用者,而是跳回到函数内部的某个位置,导致程序陷入无限循环。这是一种伪装巧妙的无限递归形式。


栈内存的“残留”效应

理解了栈帧的基本操作后,我们来看一个更微妙的例子,它展示了连续函数调用之间栈内存的“残留”数据。

假设一个初学者编写了如下程序:

int main() {
    declareAndInitArray();
    printArray();
    return 0;
}

void declareAndInitArray() {
    int array[100];
    for (int i = 0; i < 100; i++) {
        array[i] = i;
    }
}

void printArray() {
    int array[100];
    for (int i = 0; i < 100; i++) {
        printf("%d\n", array[i]);
    }
}

程序员可能错误地认为,只要在两个函数中使用相同的数组名array,它们就会自动共享数据。但实际上,array是两个函数中独立的局部变量。

以下是程序能够“意外”正确打印0到99的原因:

  1. declareAndInitArray被调用,其栈帧被创建,数组array被初始化。
  2. 函数返回后,其栈帧被释放(栈指针上移),但栈内存中的数据并未被清除
  3. 紧接着,printArray被调用。它创建了一个大小完全相同的栈帧,恰好覆盖了前一个函数栈帧的同一块内存区域。
  4. 因此,printArray中的array读取到的,正是declareAndInitArray留在栈上的“残留”数据。

这个例子说明,函数返回后,其栈帧内存并不会被主动清零,后续函数调用可能会意外地读取到之前留下的数据。


实际应用场景

虽然上述例子看起来像是编程错误,但这种栈内存的“残留”特性在某些特定场景下可以被有意利用。

例如,在十多年前编写声卡驱动程序时,开发者可能会遇到这种情况:硬件中断处理程序每次执行的时间很短,且任务量不均衡。有时任务很轻,有时则需要在下一个中断到来前完成大量准备工作。

一种优化策略是:

  • 在本次中断处理中,提前将下一次中断需要的数据计算好。
  • 将这些数据精确地放置在栈上某个已知的位置。
  • 当下一个中断触发,新的中断处理函数被调用时,它的栈帧会覆盖同一块内存,从而可以直接读取预先准备好的数据,而无需在时间紧迫的中断处理过程中重新计算。

这可以被看作是一种在栈上模拟“全局变量”或参数传递的非常规但高效的方法。


总结

本节课中我们一起学习了:

  1. 栈帧的精确布局:理解了局部变量(如数组)和返回地址在栈上的相邻关系。
  2. 数组越界的严重后果:通过array[i] -= 4的例子,看到修改返回地址如何导致程序行为完全失控(如无限循环)。
  3. 栈内存的持久性:函数返回后,其栈帧数据不会被清除,可能被后续函数调用意外读取,如declareAndInitArrayprintArray的例子。
  4. 特性的实际应用:在像低级硬件驱动这样的特定领域,可以利用栈内存残留的特性进行高效的数据预置和传递。

掌握这些底层内存模型的知识,有助于你深入理解程序运行的本质,并能够分析和调试那些看似诡异的内存相关错误。

015:线程与并发编程

在本节课中,我们将学习如何使用线程来模拟多个任务同时执行,以解决顺序执行程序无法真实模拟现实场景的问题。我们将从一个简单的顺序售票程序开始,分析其局限性,并逐步将其改造为并发执行的程序。

概述

上一节我们介绍了一个简单的顺序售票程序,它无法真实模拟多个售票员同时工作的场景。本节中,我们来看看如何使用线程来实现并发执行,使多个售票员能够同时售票。

顺序售票程序的问题

我上次编写了一个极其简单的程序,旨在模拟单次航班上150张机票的销售。

程序如下:

int numTickets = 150;
int numAgents = 10;

for (int agent = 1; agent <= numAgents; agent++) {
    sellTickets(agent, numTickets / numAgents);
}

sellTickets函数的实现如下:

void sellTickets(int agentID, int numTicketsToSell) {
    while (numTicketsToSell > 0) {
        printf("Agent %d sells a ticket.\n", agentID);
        numTicketsToSell--;
    }
    printf("Agent %d: All done!\n", agentID);
}

从代码角度看,它非常简单。我并非要重新讨论for循环和while循环,我更感兴趣的是弄清楚为什么这个程序作为一个模拟并不理想,因为它没有真实地模拟实际会发生的情况。

这种设置方式显然是顺序执行的。售票员1会在他/她的15张票全部售出后,售票员2才开始工作。因此,这个程序的输出会先全是售票员1的160行记录(每个售票员16行),然后是“完成”提示,接着才是售票员2的记录,依此类推。

我并不喜欢这样。如果我们真的想模拟一个真实的航空公司售票厅,我们希望看到所有这些售票员同时运行和工作,不是竞争,而是协作地同时售出所有150张票。我将稍微简化问题,让所有售票员都去销售15张票,因为根据代码的写法,如果每个人都售出15张票,那么所有150张票就都售出了。但这是顺序执行的。我不想顺序执行,我想设置成所有售票员遵循相同的售票函数和步骤,但看起来是同时运行的。

引入线程

为了实现这一点,我们必须使用一个线程包。线程是“单一线程执行”的简称。当我谈论线程时,我指的是某个正在运行的函数,并且它很可能与其他线程同时运行。

我想这样设置,让15个售票员像赛狗场的狗一样运作。我真正想做的就是打开闸门,与其说它们是彼此竞争,不如说是一场冲向终点的比赛。所有这15个“线程”都将遵循完成函数所需的步骤,当它们全部完成时,你就知道所有工作都完成了。

我将保持sellTickets函数不变,至少短期内不变。我将在这里使用一个不同的for循环。

使用线程库

为了使用线程库,在我们的系统上,你必须告诉可执行文件你将这样做。这只是一个初始化例程,在你调用我将要教的这个库中的任何其他函数之前,它必须始终就位。

以下是使用线程库的初始化步骤:

initThreadPackage(false);

创建并运行线程

现在,我将创建一个线程数组,并为每个售票员启动一个线程。

以下是创建和启动线程的步骤:

  1. 声明一个线程数组。
  2. 为每个售票员创建一个新线程,并指定其要执行的函数和参数。
  3. 启动所有线程。
  4. 等待所有线程完成。
Thread threads[numAgents];
for (int agent = 1; agent <= numAgents; agent++) {
    char name[32];
    sprintf(name, "Agent %d", agent);
    threads[agent-1] = createThread(sellTickets, name, numTickets / numAgents);
}
for (int i = 0; i < numAgents; i++) {
    joinThread(threads[i]);
}

线程函数详解

sellTickets函数现在将在多个线程中并发执行。每个线程都有自己的执行流,它们可以同时运行,模拟多个售票员同时售票的场景。

sellTickets函数内部逻辑不变,但现在是并发执行的:

void sellTickets(int agentID, int numTicketsToSell) {
    while (numTicketsToSell > 0) {
        printf("Agent %d sells a ticket.\n", agentID);
        numTicketsToSell--;
    }
    printf("Agent %d: All done!\n", agentID);
}

总结

本节课中我们一起学习了如何将顺序执行的程序改造为并发执行的程序。我们首先分析了顺序售票程序的局限性,然后引入了线程的概念。通过使用线程库,我们创建了多个线程来模拟多个售票员同时工作,从而更真实地模拟了现实场景。线程允许我们同时执行多个任务,提高了程序的效率和真实性。

016:多线程与信号量

概述

在本节课中,我们将深入学习多线程编程中的关键概念——信号量。我们将回顾如何使用信号量来保护临界区,确保多线程环境下共享数据的安全访问。课程将详细解释信号量的工作原理,并通过代码示例展示其具体应用。


信号量回顾:保护临界区

上一节我们介绍了多线程编程的基本概念,并初步接触了信号量。本节中,我们来看看信号量如何作为一个同步工具,精确控制线程对共享资源的访问。

信号量本质上是一个同步计数器,其值始终大于或等于0。我们可以通过特定的函数来原子性地增减这个计数器的值。

信号量的核心操作

以下是信号量的两个基本操作:

  • semaphore_wait(sem_t *s):此函数尝试将信号量的值减1。如果减1后值不会变为负数,则操作成功,线程继续执行。如果减1会导致值为负,则调用线程会被阻塞,直到其他线程增加信号量的值。
  • semaphore_signal(sem_t *s):此函数将信号量的值加1。如果有其他线程正因等待此信号量而被阻塞,其中一个将被唤醒。

在代码中,初始化一个值为1的信号量,可以将其用作一个互斥锁(Mutex)。线程通过 semaphore_wait 获取锁,通过 semaphore_signal 释放锁。

sem_t lock;
sem_init(&lock, 0, 1); // 初始化信号量,初始值为1

// 线程函数中
semaphore_wait(&lock);   // 进入临界区前,获取锁
// ... 临界区代码 ...
semaphore_signal(&lock); // 离开临界区后,释放锁

售票示例详解

让我们通过一个具体的多线程售票示例,来理解信号量如何保护共享变量。

假设有多个售票代理(线程)同时出售一定数量的票(共享变量 numTickets)。如果不加保护,可能会出现多个线程卖出同一张票的竞态条件。

线程函数结构

以下是保护了临界区的线程函数伪代码:

void sellTickets(int agent, int *numTickets, sem_t *lock) {
    while (1) { // 循环尝试售票
        semaphore_wait(lock); // 尝试获取锁,进入临界区

        if (*numTickets == 0) { // 检查是否还有票
            semaphore_signal(lock); // 释放锁并退出
            break;
        }

        (*numTickets)--; // 售出一张票
        printf("Agent %d sold a ticket. %d tickets left.\n", agent, *numTickets);

        semaphore_signal(lock); // 完成操作,释放锁
        sleep(0.1); // 模拟其他工作
    }
}

临界区保护逻辑

  1. 进入检查:线程首先调用 semaphore_wait(&lock)。如果 lock 的值为1,则将其减为0,线程进入临界区。如果值为0,线程被阻塞。
  2. 安全操作:在临界区内,线程可以安全地检查 numTickets 并对其进行减1操作,因为此时其他线程无法进入此区域。
  3. 退出释放:操作完成后,线程调用 semaphore_signal(&lock),将 lock 的值加回1,释放锁,允许其他线程进入。

关于线程调度的重要说明

即使一个线程获得了锁(进入了临界区),操作系统仍然可能在其完成所有操作前将其切换出处理器。此时,锁(信号量值仍为0)仍被该线程持有。其他试图进入临界区的线程会在 semaphore_wait 处被阻塞,从而保证了数据的一致性。


设计最佳实践

在使用信号量和锁时,遵循一些最佳实践可以使程序更高效、更健壮。

保持临界区短小

临界区应尽可能只包含必须互斥执行的代码。将非关键操作(如日志打印、复杂计算)放在锁外执行,可以最大程度地减少线程阻塞,提高程序并发性能。

// 不佳实践:将非关键操作也放在锁内
semaphore_wait(&lock);
complexCalculation(); // 耗时操作
updateSharedData();   // 关键操作
printf("Log...");     // 非关键操作
semaphore_signal(&lock);

// 更佳实践:只保护关键操作
complexCalculation(); // 在锁外执行
semaphore_wait(&lock);
updateSharedData();   // 关键操作在锁内
semaphore_signal(&lock);
printf("Log...");     // 在锁外执行

总结

本节课中我们一起学习了多线程编程中信号量的核心作用。我们明确了信号量作为一个同步计数器,通过 waitsignal 操作来实现对临界区的互斥访问。通过售票示例,我们详细分析了如何使用值为1的信号量作为互斥锁,来保护共享变量 numTickets,避免竞态条件。最后,我们强调了保持临界区短小的重要性,这是编写高效并发程序的关键原则。掌握这些概念,是构建正确、健壮的多线程应用程序的基础。

017:哲学家就餐问题与死锁避免

在本节课中,我们将继续深入探讨并发编程中的经典问题——哲学家就餐问题。我们将回顾问题的基本设定,分析其潜在的死锁风险,并学习一种通过引入额外信号量来避免死锁的解决方案。

上一节我们介绍了哲学家就餐问题的基本模型,本节中我们来看看如何通过限制并发进餐的哲学家数量来避免死锁。

问题回顾

设想一个圆桌,中央有一大盘意大利面,周围坐着五位哲学家。每两位哲学家之间摆放着一把叉子,总共五把叉子。每位哲学家的一生(即一个线程的生命周期)遵循“思考-进食-思考-进食”的循环,最终结束。

我们用五个初始值为1的信号量(semaphore)数组来表示五把叉子的可用状态。值为1表示叉子可用。

semaphore forks[5] = {1, 1, 1, 1, 1};

每位哲学家(线程)根据其编号 id 来决定获取哪两把叉子。通常,哲学家会先尝试获取右侧的叉子(forks[id]),然后获取左侧的叉子(forks[(id+1)%5])。进食完成后,再按顺序释放这两把叉子。

死锁风险

上述简单实现存在一个严重的并发问题:死锁。

考虑这样一种执行顺序:所有五位哲学家同时开始行动,并且都成功地获取了自己右侧的叉子。此时,所有叉子都已被拿起,信号量值均变为0。接下来,当每位哲学家尝试获取左侧的叉子时,都会因为该叉子已被邻居持有而无限期等待。这就形成了循环等待,即死锁。

解决方案:限制并发进餐者

要解决这个问题,核心思路是确保不会出现所有哲学家都持有一把叉子并等待另一把的局面。一个有效的方法是限制同时尝试进餐的哲学家数量。

我们观察到,由于只有五把叉子,且每位进餐者需要两把,因此最多只能有两位哲学家同时进餐。基于此,我们可以引入一个额外的“通行证”信号量。

semaphore num_allowed_to_eat = 4; // 关键:初始值设为4

以下是修改后的哲学家线程逻辑:

  1. 在尝试获取叉子之前,哲学家必须首先等待(waitnum_allowed_to_eat 信号量,以获得“进餐许可”。
  2. 获得许可后,再按顺序获取左右两把叉子。
  3. 进食。
  4. 按顺序释放两把叉子。
  5. 进食完成后,释放(signalnum_allowed_to_eat 信号量,归还“进餐许可”,以便其他等待的哲学家可以获取。
  6. 继续思考。

为什么初始值设为4?
虽然理论上最多允许2人同时进餐,但将信号量初始值设为4(而非2)是更优选择。这确保了至少有一位哲学家总是能够顺利获得两把叉子并开始进餐,从而打破可能的全局等待僵局。如果设为5,则该信号量将不起任何限制作用;如果设为2,则限制过于严格,可能不必要的降低了并发度。设为4是在避免死锁和保持并发效率之间的一个平衡点。

这个新增的信号量创建了一个“临界区”,但这个临界区限制的是“正在尝试或正在进行拿叉子和进食动作”的哲学家数量,而非传统的“仅允许一个线程访问”的临界区。

总结

本节课中我们一起学习了哲学家就餐问题及其死锁场景。我们了解到,通过引入一个全局信号量来限制同时可以竞争叉子的哲学家数量,可以有效预防死锁。关键在于将信号量的初始值设置为哲学家总数减一(本例中为4),这既能保证至少一人可以进餐以推动系统前进,又避免了所有哲学家同时持有单把叉子的死锁局面。这是并发编程中利用信号量解决资源竞争和死锁问题的经典范例。

018:冰淇淋店模拟问题详解

在本节课中,我们将详细解析斯坦福大学CS107课程中一个复杂的线程同步问题——冰淇淋店模拟。我们将逐步分析问题中的各个角色、它们之间的交互以及所需的同步机制。

上一节我们介绍了多线程编程的基本概念,本节中我们来看看一个综合性的应用案例。

问题概述

模拟场景中有四个主要角色:

  • 收银员:负责处理结账,仅有一位。
  • 顾客:共有10位,每位会随机点1到4个冰淇淋甜筒。
  • 店员:负责制作甜筒。每位顾客点的每个甜筒都会分配一个店员线程来制作,因此店员线程总数在10到40个之间。
  • 经理:负责检查每个制作完成的甜筒,仅有一位。

核心交互规则如下:

  1. 顾客点单后,会为每个甜筒“派遣”一个店员线程。
  2. 店员制作完甜筒后,必须获得经理的检查批准。
  3. 经理办公室一次只能容纳一位店员,即不能有两个店员同时请求检查。
  4. 如果经理检查不通过,店员必须重做甜筒并再次请求检查。
  5. 顾客必须等到所有他点的甜筒都制作完成并获得批准后,才能去收银台排队结账。
  6. 收银台前的队伍必须遵循先到先服务的顺序。

线程间通信分析

以下是各角色间的主要通信模式:

  • 顾客与店员:一对多通信。顾客需要等待其派遣的所有店员线程完成任务。
  • 店员与经理:一对一、带互斥的通信。店员请求检查,经理返回结果。经理办公室需要加锁,确保同一时间只有一位店员。
  • 顾客与收银员:一对一通信。顾客排队,收银员依次服务。

主函数结构

主函数负责初始化并启动所有线程。以下是其核心逻辑的伪代码表示:

int totalCones = 0; // 总甜筒数

int main() {
    // 初始化必要的信号量等同步原语(后续介绍)

    // 生成10位顾客线程
    for (int i = 0; i < 10; i++) {
        int numCones = RandomInteger(1, 4); // 为每位顾客生成随机甜筒数
        ThreadNew("Customer", CustomerFunction, 1, numCones);
        totalCones += numCones; // 累加总甜筒数
    }

    // 生成收银员线程
    ThreadNew("Cashier", CashierFunction, 0);

    // 生成经理线程,并传入总甜筒数
    ThreadNew("Manager", ManagerFunction, 1, totalCones);

    // 运行所有线程
    RunAllThreads();
}

代码解释

  • totalCones 变量用于记录所有顾客点的甜筒总数,这个信息需要传递给经理,因为经理必须检查完所有甜筒后才能结束工作。
  • 顾客线程函数 CustomerFunction 接收一个参数:该顾客要点的甜筒数量 numCones
  • 经理线程函数 ManagerFunction 接收一个参数:需要检查的甜筒总数 totalCones

关键同步点

基于以上分析,我们可以识别出几个需要同步控制的关键点:

  1. 经理办公室互斥:需要一个互斥锁(Mutex) 或一个初始值为1的信号量(Semaphore),以确保任何时候最多只有一位店员在请求检查。
  2. 顾客等待店员:每位顾客需要等待其所有店员完成工作。这可以通过一个计数信号量连接(Join) 操作来实现,计数初始值为该顾客的甜筒数,每完成一个甜筒计数减一,减到零时顾客才能继续。
  3. 店员与经理的会合:店员请求检查后需等待经理的批准结果。这通常涉及一对信号量来实现双向等待(即“会合”模式)。
  4. 收银台队列:顾客需要按到达顺序在收银台前排队。这需要一个队列数据结构配合信号量来实现,确保先到的顾客先被服务。

本节课中我们一起学习了冰淇淋店模拟问题的完整场景、角色交互和初步的线程设计。我们分析了主线程如何创建所有参与者,并识别出了实现正确同步所需的关键控制点。在接下来的课程中,我们将深入每个角色的具体实现,并编写完整的同步代码。

019:函数式编程范式介绍

在本节课中,我们将学习一种全新的编程范式——函数式编程。我们将通过介绍Scheme语言来理解这种范式的核心思想,并与之前学过的命令式、面向对象范式进行对比。


课程概述

上一节我们讨论了期中考试和评分标准。本节中,我们将告别命令式和面向对象范式,开始探索函数式编程的世界。函数式编程的核心是围绕函数的返回值来组织和思考程序,这与我们之前熟悉的围绕“动作”或“对象”的思维方式有本质不同。


编程范式回顾

在深入新内容之前,我们先简要回顾已学的两种主要范式。

命令式/过程式范式
我们使用C语言作为代表。这种范式专注于解决方案描述中的“动词”。典型的C程序main函数会先声明数据,然后调用一系列高级函数(如initializedoitterminate)来完成任务。语句的开头通常是即将被调用以执行某部分程序的函数名。

面向对象范式
我们使用C++作为代表。这种范式则围绕被操作的“数据”或“对象”来组织。在C++中,你首先声明一个对象(例如vector<int> v;),后续的语句通常以这个对象开头,并调用其方法(例如v.push_back(4);v.erase(v.begin());)。语句的首要焦点是数据本身。

你可能认为编程方式不过如此,但即使是顺序编程与并发编程之间也存在思维范式的转换。在编写多线程程序时,你需要同时考虑多个执行流的交互,这与编写线性单线程程序的思维方式截然不同。


引入函数式范式

现在,让我们暂时放下过程和对象,回到顺序编程的语境,开始探讨什么是函数式范式。

过程和函数听起来相似,但严格来说,“过程”指的是一段被调用但不关心返回值的代码块(例如返回类型为void的函数)。而当我们谈论“函数式”时,我们虽然也讨论过程和函数,但我们的核心是围绕函数的返回值

我们将学习一门非常有趣的语言——Scheme。Scheme语言本身有其特点,但我希望你们投入更多精力去理解函数式范式本身,而不仅仅是这门语言。因为范式的特性才是本课程更重要的收获。即使你们中90%的人以后不会再使用Scheme,理解函数式编程的思想也至关重要。

函数式范式深深植根于数学中的函数概念。请看以下数学表达式:

f(x, y) = x^3 + y^2 + 7

在纯粹的数学中,函数f接受两个参数xy,并“求值”或“返回”一个结果。如果我们代入具体值,例如f(5, 11),我们清楚地知道如何计算它。

现在,考虑函数的组合。我们可以定义另一个函数g

g(x) = f(x, x) + 8

函数g接受一个参数x,将其同时作为两个参数传递给f,然后将f的结果加上8。这完全基于返回值进行定义。

更进一步,我们可以定义更复杂的函数h

h(x, y, z) = f(x, z) * g(x + y)

函数h通过组合fg的返回值来定义自己。这只是一系列基于返回值的关联关系。

函数式编程方法所做的,正是将这种数学函数的求值和组合思想,直接应用到编程中。 程序由一系列函数定义和函数应用(调用)构成,计算过程被视为表达式的求值,而程序设计的核心是思考如何通过组合已有函数来构造新的函数,以得到期望的返回值。


本节总结

本节课我们一起学习了函数式编程范式的核心思想。我们回顾了命令式与面向对象范式的特点,并引入了以Scheme语言为代表的函数式范式。函数式编程将计算视为数学函数的求值,并强调通过函数的组合与返回值来构建程序,这与我们之前学习的范式有根本性的不同。在接下来的课程中,我们将通过具体的Scheme代码来深入实践这一范式。

020:Scheme递归编程入门

在本节课中,我们将学习Scheme语言中的递归编程。我们将通过实现一个列表求和函数和一个斐波那契数列函数来理解递归的基本概念和Scheme的执行模型。课程将重点讲解如何利用carcdrnull?等基本操作,以纯函数式和递归的方式解决问题。

列表求和函数

上一节我们介绍了Scheme中的基本原子操作。本节中,我们来看看如何利用这些操作实现一个递归的列表求和函数。

我们首先定义一个名为sum-list的函数,它接收一个数字列表作为参数。其核心思想是:整个列表的和等于列表的第一个元素(car)加上剩余列表(cdr)的和。

以下是sum-list函数的实现代码:

(define (sum-list num-list)
  (if (null? num-list)
      0
      (+ (car num-list) (sum-list (cdr num-list)))
  )
)

该函数的执行逻辑如下:

  • 首先,检查列表num-list是否为空(null?)。
  • 如果列表为空,则函数返回0
  • 如果列表不为空,则函数返回 (car num-list)(sum-list (cdr num-list)) 的递归调用结果之和。

例如,调用(sum-list '(1 2 3 4 5))将返回15。调用(sum-list '())将返回0

Scheme的类型检查与执行模型

现在,让我们深入探讨Scheme语言的一个关键特性:动态类型检查。

如果我们尝试对一个包含非数字元素的列表求和,例如(sum-list '(hello 1 2 3 4 5)),程序并不会在解析代码时就报错。相反,它会忠实地开始执行递归过程。

它会递归地计算出后面四个数字的和(10),然后在最后一步尝试执行 (+ "hello" 10) 时,才会因为类型不匹配而抛出运行时错误。

这一点突显了Scheme(以及许多Lisp方言)作为动态类型语言的特点:类型检查发生在运行时,而非编译时。编译器的工作主要是语法解析(例如检查括号是否匹配),而所有类型信息都作为标签与数据一同存储在内存中,直到程序执行时才进行验证。

这与C语言中使用void*实现异构列表有些类似,但Scheme的错误信息通常更明确,会直接指出是字符串和整数之间无法进行+操作。

斐波那契数列函数

递归不仅限于处理列表。接下来,我们看看如何实现一个计算斐波那契数列的递归函数。

我们定义一个名为fib的函数,它接收一个整数n作为参数。斐波那契数列的定义是:F(0) = 0, F(1) = 1, 且对于n > 1,有F(n) = F(n-1) + F(n-2)

以下是fib函数的一种实现方式:

(define (fib n)
  (if (or (= n 0) (= n 1))
      n
      (+ (fib (- n 1)) (fib (- n 2)))
  )
)

该函数的执行逻辑如下:

  • 首先,检查n是否为01。这里使用or操作符来组合两个基础条件。
  • 如果n01,则函数直接返回n
  • 否则,函数返回 (fib (- n 1))(fib (- n 2)) 的递归调用结果之和。

这种实现清晰地将两个基础情况合并,并展示了递归函数处理数值计算的典型模式。

总结

本节课中我们一起学习了Scheme语言中的递归编程。我们通过sum-list函数掌握了如何递归处理列表,并理解了Scheme动态类型检查的运行时特性。随后,我们通过fib函数了解了递归在数值计算中的应用。这两个例子都体现了函数式编程中“将问题分解为更小的自相似问题”的核心思想。记住,在编写递归函数时,明确的基础情况和向基础情况收敛的递归步骤是至关重要的。

021:Scheme开发环境与高阶函数

概述

在本节课中,我们将学习Scheme编程语言的开发环境使用方法,并深入探讨高阶函数的概念。我们将通过实际操作演示如何编写、加载和测试Scheme代码,并理解如何将函数作为参数传递以实现通用算法。

开发环境介绍

上一节我们介绍了Scheme语言的基本概念,本节中我们来看看如何在实际开发环境中使用它。

Scheme开发环境通常包含一个交互式解释器(REPL)。你可以直接输入表达式并立即看到结果。例如,输入 (+ 3 4) 会返回 7。然而,对于更复杂的程序,我们通常将代码保存在文件中。

以下是使用Kawa(一种Scheme实现)进行开发的基本步骤:

  1. 在文本编辑器中编写Scheme代码,并保存为 .scm 文件。
  2. 在Kawa解释器中,使用 (load “文件名.scm”) 命令加载该文件。
  3. 加载后,即可在解释器中调用文件中定义的函数。

代码示例:通用排序函数

现在,让我们回顾并深入理解上节课结束时提到的通用排序函数 sorted?

这个函数的设计目标是能够判断任何类型的列表是否已排序。它不关心列表元素的具体类型(整数、字符串等),而是依赖一个外部传入的比较函数来进行判断。

其核心逻辑可以用以下伪代码描述:

函数 sorted? (列表 lst, 比较函数 cmp)
    如果 lst 为空或只有一个元素
        返回 #t (真)
    否则
        如果 (cmp (第一个元素 lst) (第二个元素 lst)) 为真
            则递归调用 sorted? (剩余列表 lst, cmp)
        否则
            返回 #f (假)

在Scheme中,其实现代码如下:

(define (sorted? lst cmp)
  (or (null? lst)
      (null? (cdr lst))
      (and (cmp (car lst) (cadr lst))
           (sorted? (cdr lst) cmp))))

我们可以这样使用它:

  • (sorted? ‘(1 2 3 4) <) 会返回 #t,因为列表按升序排列。
  • (sorted? ‘(1 3 2 4) <) 会返回 #f,因为顺序不对。

这个例子展示了Scheme中“函数作为一等公民”的思想,我们可以像传递数据一样传递函数(cmp)。

高阶函数模式:map

理解了函数作为参数后,我们来看看一个常见的编程模式。在C++中,我们可能用过迭代器和 std::transform;在之前的作业中,你们实现过 vector_map。在Scheme中,对应的概念是 map

考虑两个简单的操作:

  1. 加倍:将一个列表 (1 2 3 4) 中的每个元素乘以2,得到 (2 4 6 8)
  2. 递增:将一个列表 (1 2 3 4) 中的每个元素加1,得到 (2 3 4 5)

这两个操作在算法上高度相似:

  • 它们都遍历列表中的每一个元素。
  • 它们都对每个元素应用一个特定的转换规则(加倍或递增)。
  • 它们都收集结果并形成一个新列表。

在Scheme中,我们可以使用内置的 map 函数来抽象这个模式,而不是为每个操作都写一个遍历循环。map 接受一个函数和一个列表,将该函数应用到列表的每个元素上,并返回结果列表。

例如:

  • (map (lambda (x) (* 2 x)) ‘(1 2 3 4)) 实现加倍操作。
  • (map (lambda (x) (+ 1 x)) ‘(1 2 3 4)) 实现递增操作。

map 是“高阶函数”的典型代表,它本身不处理具体数据,而是通过接收一个处理数据的函数来定义行为,这使得代码极其通用和灵活。

总结

本节课我们一起学习了Scheme开发环境的基本工作流程,包括编写 .scm 文件和使用 load 命令。我们重点分析了高阶函数的概念,通过 sorted? 函数理解了如何将函数作为参数传递以实现通用逻辑。最后,我们引入了 map 函数,它体现了函数式编程中强大的抽象能力,能够将“遍历”和“操作”分离开来。掌握这些概念对于完成接下来的作业和深入理解函数式编程范式至关重要。

022:Scheme中的幂集与Lambda函数

在本节课中,我们将学习如何使用Scheme语言解决一个离散数学问题——计算一个集合的幂集。我们将深入探讨递归思想,并重点介绍如何使用lambda函数和map操作来构建简洁而强大的函数式编程解决方案。


上一节我们介绍了幂集的基本概念,本节中我们来看看如何在Scheme中实现它。

幂集是一个包含原集合所有子集的集合。例如,对于集合 {1, 2, 3},其幂集包含8个子集,包括空集和它自身。

为了递归地构建幂集,我们可以利用一个关键观察:一个集合的幂集,可以看作由两部分组成:

  1. 不包含第一个元素(car)的所有子集。
  2. 包含第一个元素的所有子集,这等价于将第一个元素添加到第一部分中的每一个子集之前。

以下是实现这个逻辑的Scheme代码:

(define (power-set set)
  (if (null? set)
      '(())
      (let ((ps-rest (power-set (cdr set))))
        (append ps-rest
                (map (lambda (subset) (cons (car set) subset))
                     ps-rest)))))

代码解释

  • 基础情况:如果输入集合set为空(null?),则幂集是只包含空列表'()的列表,即'(())
  • 递归步骤
    1. (ps-rest (power-set (cdr set))):递归计算除去第一个元素后剩余集合((cdr set))的幂集,并将结果绑定到变量ps-rest
    2. (append ps-rest ...):将两部分结果拼接。
    3. (map (lambda (subset) (cons (car set) subset)) ps-rest):这是核心。我们使用map函数对ps-rest中的每一个子集(subset)应用一个操作。这个操作由一个lambda函数定义:它将原集合的第一个元素((car set))通过cons添加到子集subset的前面。这样就生成了所有包含第一个元素的子集。

这个实现巧妙地运用了递归和map-lambda组合,避免了显式循环,是函数式编程思想的典型体现。


本节课中我们一起学习了如何使用Scheme计算集合的幂集。我们掌握了如何将问题分解为递归结构,并利用lambda创建匿名函数,结合map将其应用于列表,从而优雅地生成结果。这个例子展示了函数式编程在表达复杂逻辑时的简洁性和强大能力。

023:Scheme内存模型与列表操作

在本节课中,我们将深入学习Scheme语言的内存模型,理解列表在内存中是如何构建和表示的。我们将通过具体的代码示例和内存图示,清晰地展示conscarcdr等操作背后的原理,并解释为何在Scheme中别名问题不像在C语言中那样危险。

课程概述与澄清

本演示由斯坦福大学专业发展中心提供。

关于课程安排,有几点需要澄清。首先,作业提交日期有误,正确的截止日期是周三晚上(28日),而非29日。这为大家留出了充足的时间完成作业。

其次,下周三将发布最后一项作业,仅包含一个问题。课程将开始介绍Python语言。进入Scheme部分后,课程节奏会稍作调整,重点不再是掌握语言的所有新特性,而是着重介绍Python中命令式、面向对象和函数式编程的特点。本季度的剩余课程将以此方式进行。

今天的重点是Scheme语言。课程内容分为两部分,完成后大家可以提前下课。

回顾Scheme内存模型

上一节我们重点讨论了内存模型。现在,我们将用五分钟时间回顾一下,以便大家确切理解如何轻松实现一个Scheme解释器。

当你在解释器中输入 (1 2 3) 并按下回车时,这实际上是一个指令,它会在幕后合成一个链表,并将其地址与链表的头节点关联起来。

虽然上次我画得很仔细,但这就是输入该表达式并得到响应后,内存中发生的情况。随后,打印功能会作用于这个地址,最终输出类似下图的结构:

概念上,这就是内存中存在的东西。print函数可以通过强类型重载或检查对象本身,来判断它打印的是一个独立的数字、字符串,还是一个列表的开头括号等。

变量定义与符号表

当你执行类似 (define seq (1 2 3)) 的语句时,会发生非常相似的事情。系统会为该语句构造类似的结构。

因为define语句是我们目前学过的少数(实际上是唯一)具有副作用的操作,它会在某个全局符号表中关联一个名为seq的条目。这个条目会被放入一个全局映射中,这样,任何引用seq的地方,如果发现它不是局部变量,就会在这个全局映射中查找与之关联的数据。

大家都理解了吗?

列表共享与不可变性

接下来,我要明确说明列表是如何共享的,以及为什么由于我们不允许更新列表,别名问题不会像在C语言中那样危险。

如果我执行 (car seq),它会求值为指向1的箭头。car操作本质上是一个请求,要求返回箭头所指的内容。打印功能作用于那个箭头(或其基地址),检测到它是一个整数类型,与列表无关,因此直接打印出独立的1

当我执行 (cdr seq) 时,它只是请求去获取箭头所指的下一个部分,而它本身也是一个列表(只是节点更少),所以打印出 (2 3)

Cons操作详解

cons指令更有趣。如果我执行 (cons (1 2 3) (4 5 6)),我将在这里详尽地画出过程。

首先,列表 (1 2 3) 被合成,指向一个节点,节点包含1,并指向下一个包含2的节点,依此类推直到3和空值()。列表 (4 5 6) 也被类似地合成。

然后,cons语句实际上是一个请求,要求构建一个新的节点。它将第一个参数(无论是1还是一个列表)的地址放入新节点的car域,将第二个参数的地址放入新节点的cdr域。

因此,当整个语句求值产生这个新节点的地址时,其副作用就是构造了这个节点。打印结果 ((1 2 3) 4 5 6) 的原因就很明显了:car是一个列表,所以那样打印;cdr也以列表形式打印。

大家明白了吗?

点对(Dotted Pairs)说明

有一个问题:cdr总是指向一个列表吗?至少在我们学习的Scheme子集中,cdr总是指向一个列表。但在真实的Scheme中,情况并非如此,你可以构造所谓的“点对”。

我提到这一点只是因为有人问起,但通常没有理由这样做。例如,(cons 1 2) 会打破我过去三天一直在推行的列表范式,但它是合法的。这会生成一个点对,其中的点是为了强调5不是列表的一部分,而是一个独立的、存放在cdr域中的整数。如果没有这个点,就意味着cdr指向一个包含5的列表。

我并不是说这不重要,只是我们只花五六节课讲Scheme,而不是十二节。Scheme中存在的一些数据结构(如关联映射)实际上在幕后将所有对存储为点对。如果值本身是列表,那么序列化或打印出来就像普通列表一样。但如果你将整数映射到整数,那么映射存储的就是这种点对结构。

Scheme实际上也有向量(相当于数组)和映射(内部使用了一些哈希技术)。明白了吗?

两种列表构造方式对比

现在,我想强调几点,并用两种不同的方式构造同一个列表。

第一种是明显的方式:(cons 1 (cons 2 ()))。这个例子与上面的情况基本一致,但我想画出内存图,以强调当我用第二种方式书写时,内存图是如何变化的。

第二种方式是:(cons 1 2)。这里有一个关键区别。对于第一种方式,内存中会有多个链表节点。对于第二种方式 (cons 1 2),它会创建一个点对,其car1cdr2,而不是一个以2开头、以空列表结尾的列表。

如果我序列化并打印 (cons 1 2) 的结果,它会显示为 (1 . 2),明确表示这是一个点对。

课程总结

本节课中,我们一起深入探讨了Scheme的内存模型。我们了解了列表和点对在内存中的表示方式,以及conscarcdr等操作如何与这些结构交互。关键要点在于,由于Scheme列表的不可变性,数据的共享(别名)不会导致意外修改,这简化了推理过程。我们还简要接触了Scheme中更丰富的数据类型,如向量和映射,为后续学习奠定了基础。理解这些底层机制,对于掌握函数式编程范式和编写可靠的Scheme程序至关重要。

024:Python语言简介 🐍

在本节课中,我们将学习Python语言的基础知识。Python是一种多范式语言,它融合了脚本语言、面向对象和函数式编程的特点。我们将从Python的基本环境开始,逐步了解其语法和核心概念。

概述 📋

Python是一种动态类型的脚本语言,它强调代码的可读性和简洁性。与C、C++和Java等语言不同,Python使用缩进来定义代码块,而不是大括号。本节我们将通过交互式环境来初步认识Python。

Python环境与基本交互 💻

上一节我们介绍了Python的多范式特性,本节中我们来看看如何启动Python并与之进行基本交互。

启动Python解释器后,我们可以直接输入表达式并立即看到结果。这与Scheme的交互式环境类似。

以下是几个基本交互示例:

  • >>> 1 + 2 + 3 + 4 将计算并返回结果 10
  • >>> “hello” 将直接返回字符串 ‘hello’
  • >>> True and False 将进行布尔运算并返回结果 False

Python的核心特性 ✨

了解了基本交互后,我们来深入探讨Python的几个核心特性。

Python主要被视为一种脚本语言。脚本语言的执行方式类似于戏剧排练时演员手持剧本念台词,Python解释器逐行读取并执行代码,同时立即产生相应的副作用。

Python支持多种编程范式

  • 命令式编程:像C语言一样,侧重于通过一系列顺序执行的语句和赋值来完成任务。
  • 面向对象编程:像C++一样,一切围绕对象和类来组织数据。
  • 函数式编程:像Scheme一样,支持Lambda表达式、map函数和eval语句等。

Python是动态类型语言。与Scheme类似,Python在运行时为每个数据维护类型信息,并且可以查询数据的类型。这与C/C++等静态类型语言在编译时确定所有类型信息的方式截然不同。

Python的语法非常简洁。它通过缩进(空格和制表符) 来定义代码块,而不是依赖大括号{}或大量的括号()。对于初学者来说,这可能需要一些时间来适应。

Python的应用场景 🌐

由于其丰富的库和简洁的语法,Python被广泛应用于多个领域。

虽然许多程序员以命令式风格使用Python,将其用于自动化任务和批处理脚本,但Python的能力远不止于此。

以下是Python的一些常见应用:

  • 网络服务:许多公司使用Python构建服务器端应用,因为它拥有强大的网络库,原生支持HTTP、HTTPS等协议。
  • 大型软件:除了脚本,Python也用于开发游戏、编译器组件等规模更大的软件项目。

总结 🎯

本节课中我们一起学习了Python语言的入门知识。我们了解到Python是一种多范式的动态脚本语言,它语法简洁,使用缩进定义结构,并广泛应用于从脚本自动化到大型网络服务的各种场景。通过交互式环境,我们初步体验了Python的执行方式。在接下来的课程中,我们将深入学习Python的函数、类等具体语法。

025:Python中的字典、对象与RG程序重写

本次演示由斯坦福大学专业发展中心提供。

概述

在本节课中,我们将深入学习Python中的字典数据结构,并通过一个具体示例——用Python重写作业1中的随机句子生成器(RG)程序——来展示其实际应用。我们将编写一个非常简洁的程序,其中融合了命令式、面向对象和函数式编程范式。此外,我们还将介绍Python中的lambda表达式,并探讨Python的对象模型,理解其与C++等语言在实现上的差异。

字典:Python的核心数据结构

上一节我们提到了Python中字典的重要性。本节中,我们将更详细地探讨它。

字典本质上是哈希表的一个简单语法层。其键可以是任何可哈希的对象,且不必是同一类型。值可以是任意类型,字典内部的数据类型可以非常异构,这与Scheme语言中列表的特性类似。

与C和C++不同,Python允许我们以文本形式直接初始化复杂的数据结构。在C++中,你无法像下面这样直接初始化一个容器:

# 在Python中,你可以这样写:
my_dict = {'key1': 'value1', 'key2': [1, 2, 3]}

但在C++中,对于std::mapstd::vector这类容器,没有对应的“对象字面量”语法来直接初始化包含多个值的数据结构。

示例:重写RG程序

现在,让我们来看一个具体的例子,将作业1中的RG程序用Python重写。我不会写出完整的语法,但会创建一个用于随机句子生成的语法文件格式。

以下是语法的字典表示:

grammar = {
    '<start>': [['The', '<object>', 'is', 'here', '.']],
    '<object>': [['computer'], ['car'], ['assignment']]
}

在这个字典中:

  • 键是字符串,代表非终结符(如<start><object>)。
  • 值是一个列表,代表该非终结符的所有可能展开式。
  • 每个展开式本身也是一个列表,包含一系列终结符(如'The')或非终结符(如'<object>')。

这个结构使得grammar成为一个内存中的字典对象,它有两个键,每个键映射到一个列表的列表。

实现展开函数

我们的目标是递归地展开起始符号以生成随机句子。假设grammar是一个全局变量,我们定义一个expand函数。

函数逻辑如下:

  1. 判断传入的symbol是终结符还是非终结符。
  2. 如果是非终结符(以'<'开头),则从grammar中获取其定义列表,并随机选择其中一个展开式。
  3. 然后,按顺序递归展开该展开式中的每一个符号。
  4. 如果是终结符,则直接打印它。

以下是expand函数的核心代码框架:

import random

def expand(symbol):
    if symbol.startswith('<'):  # 判断是否为非终结符
        definitions = grammar[symbol]  # 获取该非终结符的所有可能展开式
        expansion = random.choice(definitions)  # 随机选择一个展开式
        for sym in expansion:  # 遍历展开式中的每个符号
            expand(sym)  # 递归展开
    else:  # 如果是终结符
        print(symbol, end=' ')  # 直接打印

这里使用了Python内置的random.choice函数,它可以从一个列表中随机均匀地选择一个元素,正好满足我们的需求。

Python中的对象模型

在像CS107这样的课程中,我们通常会深入底层了解对象的实现机制。这可能会给人一种印象,认为所有语言中对象的实现方式都相同。但事实并非如此。

Python的对象模型有其独特之处。它非常动态和灵活,允许在运行时修改对象的结构。这与C++或Java中更静态、编译时确定的类结构有很大不同。理解这种差异对于掌握Python的面向对象编程至关重要。

Lambda表达式

Python也支持lambda表达式,它提供了一种创建匿名函数的简洁方式。这与我们在Scheme中学到的lambda概念本质相同,只是语法不同。

例如,一个简单的lambda表达式:

f = lambda x: x * 2
print(f(5))  # 输出 10

总结

本节课中,我们一起学习了:

  1. Python字典:作为核心的哈希表数据结构,支持异构键值对和直观的字面量初始化。
  2. 实践示例:用极简的Python代码重写了RG程序,融合了多范式编程。
  3. 递归应用:使用递归来展开语法生成句子,模仿了函数式编程的风格。
  4. 对象模型:认识到不同语言(如Python与C++)在实现对象和类时的差异。
  5. Lambda表达式:了解了Python中创建匿名函数的方法。

通过这个例子,你可以看到Python如何利用其强大的内置数据结构和简洁的语法,优雅地解决实际问题。

026:Python中的XML处理

在本节课中,我们将学习如何使用Python进行XML处理。我们将探讨两种不同的XML解析模型,并了解Python如何利用其内置功能简化网络编程和数据处理。课程将涵盖从基本的XML结构到使用Python库进行解析的完整流程。


概述

XML(可扩展标记语言)是一种用于存储和传输数据的标记语言。在之前的作业中,你可能已经接触过XML处理,例如使用expat解析器。Python作为一种现代语言,内置了对XML处理的支持,这使得网络编程和数据解析变得更加简单和优雅。


XML的基本结构

XML文档由标签和内容组成,形成一种树状结构。例如,一个简单的地址信息可以表示为:

<address>
    <number>2210</number>
    <street>Lane</street>
</address>

这种结构化的文本可以通过解析器转换为程序可读的数据。


流式解析模型

在之前的作业中,我们使用了流式解析模型。这种模型逐字符读取XML文档,并在遇到特定事件(如开始标签、结束标签或字符数据)时触发相应的处理函数。例如:

  • 遇到 <address> 标签时,触发开始标签处理函数。
  • 遇到 2210 字符数据时,触发字符数据处理函数。
  • 遇到 </address> 标签时,触发结束标签处理函数。

这种解析方式依赖于状态和副作用,与函数式编程的理念相反。


Python中的XML解析

Python提供了更现代的XML解析方式。它通过内置的 urllibxml.sax 库简化了网络连接和XML处理。以下是使用Python进行XML解析的基本步骤:

导入必要的库

首先,我们需要导入相关的库:

from urllib.request import urlopen
from xml.sax import make_parser, ContentHandler
import sys

  • urlopen 函数用于打开URL,类似于文件操作的 open 函数。
  • make_parser 是一个工厂函数,用于创建XML解析器。
  • ContentHandler 是一个基类,需要子类化并实现特定方法以处理XML事件。

创建解析器

接下来,我们创建一个解析器并设置内容处理器:

parser = make_parser()
handler = MyContentHandler()
parser.setContentHandler(handler)

解析XML文档

最后,我们使用解析器处理XML文档:

url = "http://example.com/data.xml"
data = urlopen(url)
parser.parse(data)

通过这种方式,Python可以优雅地处理XML文档,而无需手动管理状态和副作用。


总结

本节课中,我们一起学习了XML的基本结构、流式解析模型以及Python中的XML处理方式。Python通过内置的库简化了网络编程和XML解析,使得数据处理变得更加高效和直观。希望这些知识能帮助你在未来的项目中更好地处理XML数据。

027:Haskell 语言简介 🎼

在本节课中,我们将学习一种名为 Haskell 的函数式编程语言。Haskell 以其强大的类型系统、函数式编程范式和独特的“惰性求值”特性而闻名。我们将了解它的历史、核心特性以及它与我们已学语言(如 C、Scheme、Python)的区别。

本次讲座由斯坦福大学职业发展中心提供。


大家好,欢迎。今天没有新的讲义发给大家。你们应该已经完成了所有讲义。昨天课程的解答尚未发布到网上,但 Ryan 会马上处理。下次见面将是周一上午 8:30 或周一下午 3:30。请记住,你们可以在任意一个时间参加期末考试,无需提前告知我选择哪个时间。但只能参加一次。我将在周一下午 3:30 将试卷作为讲义发布。因此,正在观看录像的 SCPD 学生,请计划好下载试卷。我对你们的考试时间要求更灵活一些,因为我知道你们需要工作。但我需要在周二下午 5 点前收到你们传真回来的试卷,我们将在周二晚上开始批改。

你们明晚有一项作业要提交。你们可以使用任意多的延期天数。这项作业的设计初衷并非让你们用掉 5 天延期,但如果你们有 5 天延期并想在期末考试后提交,这也不是问题,因为我们反正要等到之后才能批改。如果你们还没有收到作业 4 的批改结果,请告诉我,因为你们应该已经收到了。我已经看到所有成绩通过邮件发给我了。作业 6 正在批改中,我要求助教们必须在周末前将作业发还给你们,以便你们确保理解了我将要测试的线程相关知识。作业 7,我认为你们在期末考试前可能收不到批改结果。但那是关于 Scheme 的历史性作业。如果班上有 200 人,大约 195 人能得 A 或 A-,因为你们都能让它运行起来,而且这是一种新语言,所以我们对代码风格的要求并不严格。如果你们的程序能运行,很可能就没问题。因此,你们不会对那项作业的反馈感到意外。

好的,我上次已经讲解了足够的 Python 知识来完成作业。我邀请了一位来自 Facebook 的同事,他负责……不,我邀请了一位同事。实际上,在我入职 Facebook 的头六、七周里,他算是我的上司。他也是一位编程语言爱好者。他精通两种我不太了解的语言,叫做 Haskell 和 ML。我请他来讲讲其中一种,Haskell。所以,我现在就把讲台交给他,让他向你们介绍一种如果只有我教课你们可能不会接触到的语言。好的,让我们在这里切换一下麦克风。这位是 Sasha Rush。我将让他来讲。嗯。


好的,今天我要讲的是 Haskell,这有点像一种前卫的编程语言。如今你们不会在很多公司看到使用它。如果想找工作,你应该成为一名优秀的 C 程序员。如果想编程未来,你应该多学学 Haskell。可以把它看作是编程语言界的勋伯格。

以下是 Haskell 的一点历史。它和 Lisp、Scheme 属于同一家族。它是一种函数式语言,这意味着不鼓励修改变量,通过大量使用函数来完成所有事情。它有类似 Scheme 中的 map 函数,但括号少得多,这很好。

然后,它发展到了称为 ML 的语言。还有一种叫 OCaml 的语言,是 ML 的现代版本,相当流行。这些语言吸收了 Scheme,并以一种有趣的方式与 C 融合,即它们是静态类型的。因此,你有真正的整数类型,如果尝试将整数与字符串一起使用,程序会报错。这很好,因为它为程序增加了速度,但在 C 语言中这很烦人,因为你必须在各处声明类型。ML 背后的绝妙想法是,你编写的代码看起来像 Python 代码,但编译器会为你推断类型,因此它会抛出错误,而你无需写下任何类型定义。这个 30 年前发明的绝妙想法至今未被广泛使用,但我认为未来它会变得越来越标准。最新版本的 C# 和未来版本的 JavaScript 可能会包含这类特性。

后来出现了一种叫 Miranda 的语言,它是一种专有语言,但它有一个非常巧妙的想法,我们稍后会讨论,那就是“惰性求值”。惰性求值在除了 Haskell 之外的几乎所有语言中都看不到。原因是所有研究这个问题的人聚在一起说:“嘿,让我们创建一个开源的、惰性求值的、标准的 Miranda 版本,这样我们都可以一起研究。”所以,如果你想让研究人员关注你的语言,不要让它成为专有语言。

这一切在 Haskell 98 中汇聚成型,顾名思义,它于 2003 年发布。这可以说是 Haskell 的现代版本。自 2003 年发布以来,它在一小群非常专注的人群中极其受欢迎,他们在过去五年里一直非常努力地改进它,现在已经达到了相当出色的程度。

好的,那么 Haskell 有什么特别之处呢?首先,它像 Java 一样安全。这里说的“安全”,是指很难写出一个编译通过但运行失败的程序。

这意味着,如果你尝试将一个字符串和一个整数相加,程序会失败。: 语法有点像 Scheme 中的 cons 操作符,意思是将元素添加到列表的开头。所以,如果你尝试将一个字符串放入一个数字列表中,程序也会失败。这又与 Scheme 不同,因为 Scheme 不是静态类型的。Scheme 允许你在列表中放入任何类型的东西,而 Haskell 中,一个列表中的所有元素必须是相同类型的。

所以,它像 Java,但更安全。Java 中有很多有趣的问题。这里有一个例子:我有一个名为 temp 的对象,并将其设为 null。然后我尝试运行一个本应是对象的方法。理论上它应该被保证是一个对象,但它可能失败,因为 tempnull。这是命令式语言中常见的现象,但它是一个大问题。存在许多你没有防范的潜在运行时错误。

另一个例子:我们有一个名为 Tt 的对象,我试图将其转换为 String,这在 Java 中是允许的(我认为这叫向下转型),允许从父类转到子类。但这有很大的失败可能性,因为在这个例子中,它不是 String,你不能假装它是。

其次,它像 Scheme 一样富有表现力。正如之前所说,它有 map 函数。方括号表示一个列表,类似于 Python 中的列表字面量。你可以在上面运行匿名函数,所以这个函数将列表中的每个数字加一,并返回一个新列表。另一个例子是 sortBy 函数,它接受一个比较函数并用该函数进行排序。这里的例子只是按标准顺序对列表进行排序。

第三,它像 C 一样快。虽然没有 C 那么快,但正在接近。大概在一个数量级上。这里有一些统计数据(来自计算机语言基准测试游戏)。这并非衡量语言速度的最佳方式,但可以让我们有个直观了解。关键区别在于:类型不仅保证了安全性,还允许语言运行得更快。当你看到一个加号时,你知道你将要把两个整数相加,而不需要检查失败的情况,这使得语言看起来快得多。

最重要的是,它很有趣。我说的“有趣”是指,如果你想写一个乘法函数,你只需写一个乘法函数,而不必写周围所有的其他东西。

posted @ 2026-03-29 09:45  布客飞龙I  阅读(24)  评论(0)    收藏  举报